From 0eb7d308615bae1ad4be1ca5112ac7b6b6cbfbaf Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 9 Dec 2013 11:41:05 +0000 Subject: [PATCH 0001/4072] yay --- .gitignore | 3 +++ plum/__init__.py | 3 +++ plum/service.py | 30 +++++++++++++++++++++++++ plum/tests/__init__.py | 0 plum/tests/service_test.py | 39 ++++++++++++++++++++++++++++++++ setup.py | 46 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 121 insertions(+) create mode 100644 .gitignore create mode 100644 plum/__init__.py create mode 100644 plum/service.py create mode 100644 plum/tests/__init__.py create mode 100644 plum/tests/service_test.py create mode 100644 setup.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000000..21a256dfb23 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +*.egg-info +*.pyc +/dist diff --git a/plum/__init__.py b/plum/__init__.py new file mode 100644 index 00000000000..404eba85da7 --- /dev/null +++ b/plum/__init__.py @@ -0,0 +1,3 @@ +from .service import Service + +__version__ = '1.0.0' diff --git a/plum/service.py b/plum/service.py new file mode 100644 index 00000000000..e9777be76fb --- /dev/null +++ b/plum/service.py @@ -0,0 +1,30 @@ +class Service(object): + def __init__(self, client, image, command): + self.client = client + self.image = image + self.command = command + + @property + def containers(self): + return self.client.containers() + + def start(self): + if len(self.containers) == 0: + self.start_container() + + def stop(self): + self.scale(0) + + def scale(self, num): + while len(self.containers) < num: + self.start_container() + + while len(self.containers) > num: + self.stop_container() + + def start_container(self): + container = self.client.create_container(self.image, self.command) + self.client.start(container['Id']) + + def stop_container(self): + self.client.kill(self.containers[0]['Id']) diff --git a/plum/tests/__init__.py b/plum/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/plum/tests/service_test.py b/plum/tests/service_test.py new file mode 100644 index 00000000000..6075965e9c1 --- /dev/null +++ b/plum/tests/service_test.py @@ -0,0 +1,39 @@ +from unittest import TestCase +from docker import Client +from plum import Service + + +class ServiceTestCase(TestCase): + def setUp(self): + self.client = Client('http://127.0.0.1:4243') + self.client.pull('ubuntu') + + for c in self.client.containers(): + self.client.kill(c['Id']) + + self.service = Service( + client=self.client, + image="ubuntu", + command=["/bin/sleep", "300"], + ) + + def test_up_scale_down(self): + self.assertEqual(len(self.service.containers), 0) + + self.service.start() + self.assertEqual(len(self.service.containers), 1) + + self.service.start() + self.assertEqual(len(self.service.containers), 1) + + self.service.scale(2) + self.assertEqual(len(self.service.containers), 2) + + self.service.scale(1) + self.assertEqual(len(self.service.containers), 1) + + self.service.stop() + self.assertEqual(len(self.service.containers), 0) + + self.service.stop() + self.assertEqual(len(self.service.containers), 0) diff --git a/setup.py b/setup.py new file mode 100644 index 00000000000..b4b80f125d1 --- /dev/null +++ b/setup.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +from setuptools import setup +import re +import os +import codecs + + +# Borrowed from +# https://github.com/jezdez/django_compressor/blob/develop/setup.py +def read(*parts): + return codecs.open(os.path.join(os.path.dirname(__file__), *parts)).read() + + +def find_version(*file_paths): + version_file = read(*file_paths) + version_match = re.search(r"^__version__ = ['\"]([^'\"]*)['\"]", + version_file, re.M) + if version_match: + return version_match.group(1) + raise RuntimeError("Unable to find version string.") + + +setup( + name='plum', + version=find_version("plum", "__init__.py"), + description='', + url='https://github.com/orchardup.plum', + author='Orchard Laboratories Ltd.', + author_email='hello@orchardup.com', + packages=['plum'], + package_data={}, + include_package_data=True, + install_requires=[ + 'docopt==0.6.1', + 'docker-py==0.2.2', + 'requests==2.0.1', + 'texttable==0.8.1', + ], + dependency_links=[], + entry_points=""" + [console_scripts] + plum=plum:main + """, +) From 950bfda579d7bc1a7985d1a849c12b576aac478c Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Mon, 9 Dec 2013 12:01:15 +0000 Subject: [PATCH 0002/4072] Add readme --- README.md | 159 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 00000000000..22ad71bfe01 --- /dev/null +++ b/README.md @@ -0,0 +1,159 @@ +Plum +==== + +Plum is tool for defining and running apps with Docker. It uses a simple, version-controllable YAML configuration file that looks something like this: + +```yaml + db: + image: orchardup/postgresql + + web: + build: web/ + link: db +``` + +Installing +---------- + +```bash + $ sudo pip install plum +``` + +Defining your app +----------------- + +Put a `plum.yml` in your app's directory. Each top-level key defines a "service", such as a web app, database or cache. For each service, Plum will start a Docker container, so at minimum it needs to know what image to use. + +The way to get started is to just give it an image name: + +```yaml + db: + image: orchardup/postgresql +``` + +Alternatively, you can give it the location of a directory with a Dockerfile (or a Git URL, as per the `docker build` command), and it'll automatically build the image for you: + +```yaml + db: + build: /path/to/postgresql/build/directory +``` + +You've now given Plum the minimal amount of configuration it needs to run: + +```bash + $ plum up + Building db... done + db is running at 127.0.0.1:45678 + <...output from postgresql server...> +``` + +For each service you've defined, Plum will start a Docker container with the specified image, building or pulling it if necessary. You now have a PostgreSQL server running at `127.0.0.1:45678`. + +By default, `plum up` will run until each container has shut down, and relay their output to the terminal. To run in the background instead, pass the `-d` flag: + +```bash + $ plum run -d + Building db... done + db is running at 127.0.0.1:45678 + + $ plum ps + SERVICE STATE PORT + db up 45678 +``` + + +### Getting your code in + +Some services may include your own code. To get that code into the container, ADD it in a Dockerfile. + +`plum.yml`: + +```yaml + web: + build: web/ +``` + +`web/Dockerfile`: + + FROM orchardup/rails + ADD . /code + CMD: bundle exec rackup + + +### Communicating between containers + +Your web app will probably need to talk to your database. You can use [Docker links] to enable containers to communicatecommunicate, pass in the right IP address and port via environment variables: + +```yaml + db: + image: orchardup/postgresql + + web: + build: web/ + link: db +``` + +This will pass an environment variable called DB_PORT into the web container, whose value will look like `tcp://172.17.0.4:45678`. Your web app's code can then use that to connect to the database. + +You can pass in multiple links, too: + +```yaml + link: + - db + - memcached + - redis +``` + + +In each case, the resulting environment variable will begin with the uppercased name of the linked service (`DB_PORT`, `MEMCACHED_PORT`, `REDIS_PORT`). + + +### Container configuration options + +You can pass extra configuration options to a container, much like with `docker run`: + +```yaml + web: + build: web/ + + -- override the default run command + run: bundle exec thin -p 3000 + + -- expose ports - can also be an array + ports: 3000 + + -- map volumes - can also be an array + volumes: /tmp/cache + + -- add environment variables - can also be a dictionary + environment: + - RACK_ENV=development +``` + + +Running a one-off command +------------------------- + +If you want to run a management command, use `plum run` to start a one-off container: + + $ plum run db createdb myapp_development + $ plum run web rake db:migrate + $ plum run web bash + + +Running more than one container for a service +--------------------------------------------- + +You can set the number of containers to run for each service with `plum scale`: + + $ plum up -d + db is running at 127.0.0.1:45678 + web is running at 127.0.0.1:45679 + + $ plum scale db=0,web=3 + Stopped db (127.0.0.1:45678) + Started web (127.0.0.1:45680) + Started web (127.0.0.1:45681) + + + From 84ea31dc92916da69fae2a2cdad3994b74350e77 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Mon, 9 Dec 2013 12:01:44 +0000 Subject: [PATCH 0003/4072] Add license --- LICENSE | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000000..a75bd571b8f --- /dev/null +++ b/LICENSE @@ -0,0 +1,24 @@ +Copyright (c) 2013, Orchard Laboratories Ltd. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + * The names of its contributors may not be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. From ed1918ef0972f810fc68a2b16452cbfa02cf537f Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Mon, 9 Dec 2013 12:03:26 +0000 Subject: [PATCH 0004/4072] Fix readme formatting --- README.md | 119 +++++++++++++++++++++++++++--------------------------- 1 file changed, 60 insertions(+), 59 deletions(-) diff --git a/README.md b/README.md index 22ad71bfe01..055604061c4 100644 --- a/README.md +++ b/README.md @@ -4,19 +4,19 @@ Plum Plum is tool for defining and running apps with Docker. It uses a simple, version-controllable YAML configuration file that looks something like this: ```yaml - db: - image: orchardup/postgresql +db: + image: orchardup/postgresql - web: - build: web/ - link: db +web: + build: web/ + link: db ``` Installing ---------- ```bash - $ sudo pip install plum +$ sudo pip install plum ``` Defining your app @@ -27,24 +27,24 @@ Put a `plum.yml` in your app's directory. Each top-level key defines a "service" The way to get started is to just give it an image name: ```yaml - db: - image: orchardup/postgresql +db: + image: orchardup/postgresql ``` Alternatively, you can give it the location of a directory with a Dockerfile (or a Git URL, as per the `docker build` command), and it'll automatically build the image for you: ```yaml - db: - build: /path/to/postgresql/build/directory +db: + build: /path/to/postgresql/build/directory ``` You've now given Plum the minimal amount of configuration it needs to run: ```bash - $ plum up - Building db... done - db is running at 127.0.0.1:45678 - <...output from postgresql server...> +$ plum up +Building db... done +db is running at 127.0.0.1:45678 +<...output from postgresql server...> ``` For each service you've defined, Plum will start a Docker container with the specified image, building or pulling it if necessary. You now have a PostgreSQL server running at `127.0.0.1:45678`. @@ -52,13 +52,13 @@ For each service you've defined, Plum will start a Docker container with the spe By default, `plum up` will run until each container has shut down, and relay their output to the terminal. To run in the background instead, pass the `-d` flag: ```bash - $ plum run -d - Building db... done - db is running at 127.0.0.1:45678 +$ plum run -d +Building db... done +db is running at 127.0.0.1:45678 - $ plum ps - SERVICE STATE PORT - db up 45678 +$ plum ps +SERVICE STATE PORT +db up 45678 ``` @@ -69,15 +69,15 @@ Some services may include your own code. To get that code into the container, AD `plum.yml`: ```yaml - web: - build: web/ +web: + build: web/ ``` `web/Dockerfile`: - FROM orchardup/rails - ADD . /code - CMD: bundle exec rackup +FROM orchardup/rails +ADD . /code +CMD: bundle exec rackup ### Communicating between containers @@ -85,12 +85,12 @@ Some services may include your own code. To get that code into the container, AD Your web app will probably need to talk to your database. You can use [Docker links] to enable containers to communicatecommunicate, pass in the right IP address and port via environment variables: ```yaml - db: - image: orchardup/postgresql +db: + image: orchardup/postgresql - web: - build: web/ - link: db +web: + build: web/ + link: db ``` This will pass an environment variable called DB_PORT into the web container, whose value will look like `tcp://172.17.0.4:45678`. Your web app's code can then use that to connect to the database. @@ -98,10 +98,10 @@ This will pass an environment variable called DB_PORT into the web container, wh You can pass in multiple links, too: ```yaml - link: - - db - - memcached - - redis +link: + - db + - memcached + - redis ``` @@ -113,21 +113,21 @@ In each case, the resulting environment variable will begin with the uppercased You can pass extra configuration options to a container, much like with `docker run`: ```yaml - web: - build: web/ +web: + build: web/ - -- override the default run command - run: bundle exec thin -p 3000 +-- override the default run command +run: bundle exec thin -p 3000 - -- expose ports - can also be an array - ports: 3000 +-- expose ports - can also be an array +ports: 3000 - -- map volumes - can also be an array - volumes: /tmp/cache +-- map volumes - can also be an array +volumes: /tmp/cache - -- add environment variables - can also be a dictionary - environment: - - RACK_ENV=development +-- add environment variables - can also be a dictionary +environment: + - RACK_ENV=development ``` @@ -136,9 +136,11 @@ Running a one-off command If you want to run a management command, use `plum run` to start a one-off container: - $ plum run db createdb myapp_development - $ plum run web rake db:migrate - $ plum run web bash +```bash +$ plum run db createdb myapp_development +$ plum run web rake db:migrate +$ plum run web bash +``` Running more than one container for a service @@ -146,14 +148,13 @@ Running more than one container for a service You can set the number of containers to run for each service with `plum scale`: - $ plum up -d - db is running at 127.0.0.1:45678 - web is running at 127.0.0.1:45679 - - $ plum scale db=0,web=3 - Stopped db (127.0.0.1:45678) - Started web (127.0.0.1:45680) - Started web (127.0.0.1:45681) - - - +```bash +$ plum up -d +db is running at 127.0.0.1:45678 +web is running at 127.0.0.1:45679 + +$ plum scale db=0,web=3 +Stopped db (127.0.0.1:45678) +Started web (127.0.0.1:45680) +Started web (127.0.0.1:45681) +``` From f80ccab9d689274a91d5b83c1e95b8765c7893ed Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Mon, 9 Dec 2013 12:06:41 +0000 Subject: [PATCH 0005/4072] Add .travis.yml --- .travis.yml | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .travis.yml diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 00000000000..117b1c0f1f3 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,9 @@ +language: python +python: + - "2.6" + - "2.7" + - "3.2" + - "3.3" +install: pip install nose==1.3.0 +script: nosetests + From 83a086b865c2db791a208d3854c15963ed3fc693 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 9 Dec 2013 11:46:53 +0000 Subject: [PATCH 0006/4072] Delete all containers on the Docker daemon before running test --- plum/tests/service_test.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plum/tests/service_test.py b/plum/tests/service_test.py index 6075965e9c1..0c03a2ab824 100644 --- a/plum/tests/service_test.py +++ b/plum/tests/service_test.py @@ -8,8 +8,9 @@ def setUp(self): self.client = Client('http://127.0.0.1:4243') self.client.pull('ubuntu') - for c in self.client.containers(): + for c in self.client.containers(all=True): self.client.kill(c['Id']) + self.client.remove_container(c['Id']) self.service = Service( client=self.client, From ffdebb84bbce70cb297da8d31745141e20298f81 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 9 Dec 2013 12:19:27 +0000 Subject: [PATCH 0007/4072] Services have names --- plum/service.py | 9 ++++++++- plum/tests/service_test.py | 20 +++++++++++++++++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/plum/service.py b/plum/service.py index e9777be76fb..8bc5a84e1d6 100644 --- a/plum/service.py +++ b/plum/service.py @@ -1,5 +1,12 @@ +import re + + class Service(object): - def __init__(self, client, image, command): + def __init__(self, name, client=None, image=None, command=None): + if not re.match('^[a-zA-Z0-9_]+$', name): + raise ValueError('Invalid name: %s' % name) + + self.name = name self.client = client self.image = image self.command = command diff --git a/plum/tests/service_test.py b/plum/tests/service_test.py index 0c03a2ab824..eac2d498ce7 100644 --- a/plum/tests/service_test.py +++ b/plum/tests/service_test.py @@ -3,7 +3,24 @@ from plum import Service -class ServiceTestCase(TestCase): +class NameTestCase(TestCase): + def test_name_validations(self): + self.assertRaises(ValueError, lambda: Service(name='')) + + self.assertRaises(ValueError, lambda: Service(name=' ')) + self.assertRaises(ValueError, lambda: Service(name='/')) + self.assertRaises(ValueError, lambda: Service(name='!')) + self.assertRaises(ValueError, lambda: Service(name='\xe2')) + + Service('a') + Service('foo') + Service('foo_bar') + Service('__foo_bar__') + Service('_') + Service('_____') + + +class ScalingTestCase(TestCase): def setUp(self): self.client = Client('http://127.0.0.1:4243') self.client.pull('ubuntu') @@ -13,6 +30,7 @@ def setUp(self): self.client.remove_container(c['Id']) self.service = Service( + name="test", client=self.client, image="ubuntu", command=["/bin/sleep", "300"], From 973760a5016ccf5673164fa77b11c3abeb5d1941 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Mon, 9 Dec 2013 14:09:18 +0000 Subject: [PATCH 0008/4072] Add links argument to service --- plum/service.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plum/service.py b/plum/service.py index 8bc5a84e1d6..2d832182a35 100644 --- a/plum/service.py +++ b/plum/service.py @@ -2,7 +2,7 @@ class Service(object): - def __init__(self, name, client=None, image=None, command=None): + def __init__(self, name, client=None, image=None, command=None, links=None): if not re.match('^[a-zA-Z0-9_]+$', name): raise ValueError('Invalid name: %s' % name) @@ -10,6 +10,7 @@ def __init__(self, name, client=None, image=None, command=None): self.client = client self.image = image self.command = command + self.links = links or [] @property def containers(self): From bc51134ceb47f5830d2bc14afc24a8ee65a0b00b Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Mon, 9 Dec 2013 14:10:23 +0000 Subject: [PATCH 0009/4072] Add ServiceCollection --- plum/service_collection.py | 37 ++++++++++++++++++++++ plum/tests/service_collection_test.py | 44 +++++++++++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 plum/service_collection.py create mode 100644 plum/tests/service_collection_test.py diff --git a/plum/service_collection.py b/plum/service_collection.py new file mode 100644 index 00000000000..b73511a9245 --- /dev/null +++ b/plum/service_collection.py @@ -0,0 +1,37 @@ +from .service import Service + +def sort_service_dicts(services): + # Sort in dependency order + def cmp(x, y): + x_deps_y = y['name'] in x.get('links', []) + y_deps_x = x['name'] in y.get('links', []) + if x_deps_y and not y_deps_x: + return 1 + elif y_deps_x and not x_deps_y: + return -1 + return 0 + return sorted(services, cmp=cmp) + +class ServiceCollection(list): + @classmethod + def from_dicts(cls, client, service_dicts): + """ + Construct a ServiceCollection from a list of dicts representing services. + """ + collection = ServiceCollection() + for service_dict in sort_service_dicts(service_dicts): + # Reference links by object + links = [] + if 'links' in service_dict: + for name in service_dict.get('links', []): + links.append(collection.get(name)) + del service_dict['links'] + collection.append(Service(client=client, links=links, **service_dict)) + return collection + + def get(self, name): + for service in self: + if service.name == name: + return service + + diff --git a/plum/tests/service_collection_test.py b/plum/tests/service_collection_test.py new file mode 100644 index 00000000000..88641c0a996 --- /dev/null +++ b/plum/tests/service_collection_test.py @@ -0,0 +1,44 @@ +from plum.service import Service +from plum.service_collection import ServiceCollection +from unittest import TestCase + + +class ServiceCollectionTest(TestCase): + def test_from_dict(self): + collection = ServiceCollection.from_dicts(None, [ + { + 'name': 'web', + 'image': 'ubuntu' + }, + { + 'name': 'db', + 'image': 'ubuntu' + } + ]) + self.assertEqual(len(collection), 2) + web = [s for s in collection if s.name == 'web'][0] + self.assertEqual(web.name, 'web') + self.assertEqual(web.image, 'ubuntu') + db = [s for s in collection if s.name == 'db'][0] + self.assertEqual(db.name, 'db') + self.assertEqual(db.image, 'ubuntu') + + def test_from_dict_sorts_in_dependency_order(self): + collection = ServiceCollection.from_dicts(None, [ + { + 'name': 'web', + 'image': 'ubuntu', + 'links': ['db'], + }, + { + 'name': 'db', + 'image': 'ubuntu' + } + ]) + + self.assertEqual(collection[0].name, 'db') + self.assertEqual(collection[1].name, 'web') + + + + From 630f6fcf02f51a3c257c69c65127597c343500cf Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 9 Dec 2013 15:00:01 +0000 Subject: [PATCH 0010/4072] Extract test setup --- plum/tests/service_test.py | 38 +++++++++++++++++++++++--------------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/plum/tests/service_test.py b/plum/tests/service_test.py index eac2d498ce7..5870c30da38 100644 --- a/plum/tests/service_test.py +++ b/plum/tests/service_test.py @@ -3,7 +3,26 @@ from plum import Service -class NameTestCase(TestCase): +client = Client('http://127.0.0.1:4243') +client.pull('ubuntu') + + +class ServiceTestCase(TestCase): + def setUp(self): + for c in client.containers(all=True): + client.kill(c['Id']) + client.remove_container(c['Id']) + + def create_service(self, name): + return Service( + name=name, + client=client, + image="ubuntu", + command=["/bin/sleep", "300"], + ) + + +class NameTestCase(ServiceTestCase): def test_name_validations(self): self.assertRaises(ValueError, lambda: Service(name='')) @@ -20,21 +39,10 @@ def test_name_validations(self): Service('_____') -class ScalingTestCase(TestCase): +class ScalingTestCase(ServiceTestCase): def setUp(self): - self.client = Client('http://127.0.0.1:4243') - self.client.pull('ubuntu') - - for c in self.client.containers(all=True): - self.client.kill(c['Id']) - self.client.remove_container(c['Id']) - - self.service = Service( - name="test", - client=self.client, - image="ubuntu", - command=["/bin/sleep", "300"], - ) + super(ServiceTestCase, self).setUp() + self.service = self.create_service("scaling_test") def test_up_scale_down(self): self.assertEqual(len(self.service.containers), 0) From 4e426e77fa5b8d38730a37695a8892a53e73754a Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 9 Dec 2013 15:00:41 +0000 Subject: [PATCH 0011/4072] Containers have names which are used to isolate them to their service --- plum/service.py | 33 +++++++++++++++++++++++++++++++-- plum/tests/service_test.py | 16 ++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/plum/service.py b/plum/service.py index 2d832182a35..6b928564def 100644 --- a/plum/service.py +++ b/plum/service.py @@ -14,7 +14,7 @@ def __init__(self, name, client=None, image=None, command=None, links=None): @property def containers(self): - return self.client.containers() + return [c for c in self.client.containers() if parse_name(get_container_name(c))[0] == self.name] def start(self): if len(self.containers) == 0: @@ -31,8 +31,37 @@ def scale(self, num): self.stop_container() def start_container(self): - container = self.client.create_container(self.image, self.command) + number = self.next_container_number() + name = make_name(self.name, number) + container = self.client.create_container(self.image, self.command, name=name) self.client.start(container['Id']) def stop_container(self): self.client.kill(self.containers[0]['Id']) + + def next_container_number(self): + numbers = [parse_name(get_container_name(c))[1] for c in self.containers] + + if len(numbers) == 0: + return 1 + else: + return max(numbers) + 1 + + +def make_name(prefix, number): + return '%s_%s' % (prefix, number) + + +def parse_name(name): + match = re.match('^(.+)_(\d+)$', name) + + if match is None: + raise ValueError("Invalid name: %s" % name) + + (service_name, suffix) = match.groups() + + return (service_name, int(suffix)) + + +def get_container_name(container): + return container['Names'][0][1:] diff --git a/plum/tests/service_test.py b/plum/tests/service_test.py index 5870c30da38..e8db26ff005 100644 --- a/plum/tests/service_test.py +++ b/plum/tests/service_test.py @@ -39,6 +39,22 @@ def test_name_validations(self): Service('_____') +class ContainersTestCase(ServiceTestCase): + def test_containers(self): + foo = self.create_service('foo') + bar = self.create_service('bar') + + foo.start() + + self.assertEqual(len(foo.containers), 1) + self.assertEqual(len(bar.containers), 0) + + bar.scale(2) + + self.assertEqual(len(foo.containers), 1) + self.assertEqual(len(bar.containers), 2) + + class ScalingTestCase(ServiceTestCase): def setUp(self): super(ServiceTestCase, self).setUp() From e116e7db99d1103318ab1514b452b2ae89b5ebf3 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Mon, 9 Dec 2013 16:16:15 +0000 Subject: [PATCH 0012/4072] Add setup.py install to travis.yml --- .travis.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 117b1c0f1f3..da53766e519 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,6 +4,8 @@ python: - "2.7" - "3.2" - "3.3" -install: pip install nose==1.3.0 +install: + - python setup.py install + - pip install nose==1.3.0 script: nosetests From 084b06cd128c1166bdaa51752da883e53fde8580 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 9 Dec 2013 16:25:50 +0000 Subject: [PATCH 0013/4072] Install docker-py from GitHub Just use requirements.txt for now, as doing it in setup.py is a pain. --- requirements.txt | 1 + setup.py | 7 +------ 2 files changed, 2 insertions(+), 6 deletions(-) create mode 100644 requirements.txt diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000000..e560af40dfa --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +git+git://github.com/dotcloud/docker-py.git@4fde1a242e1853cbf83e5a36371d8b4a49501c52 diff --git a/setup.py b/setup.py index b4b80f125d1..db17156e65b 100644 --- a/setup.py +++ b/setup.py @@ -32,12 +32,7 @@ def find_version(*file_paths): packages=['plum'], package_data={}, include_package_data=True, - install_requires=[ - 'docopt==0.6.1', - 'docker-py==0.2.2', - 'requests==2.0.1', - 'texttable==0.8.1', - ], + install_requires=[], dependency_links=[], entry_points=""" [console_scripts] From e2e5172a59216e304d36968191bd6a14bb12e6e1 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 9 Dec 2013 16:41:47 +0000 Subject: [PATCH 0014/4072] READMEREADME fixfix --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 055604061c4..c6ae78919a1 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ CMD: bundle exec rackup ### Communicating between containers -Your web app will probably need to talk to your database. You can use [Docker links] to enable containers to communicatecommunicate, pass in the right IP address and port via environment variables: +Your web app will probably need to talk to your database. You can use [Docker links] to enable containers to communicate, pass in the right IP address and port via environment variables: ```yaml db: From dc4f90f3edf0737675b1f9cd9e31212d9e72881f Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Mon, 9 Dec 2013 16:57:12 +0000 Subject: [PATCH 0015/4072] Add envvar to set Docker URL in tests --- plum/tests/service_test.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/plum/tests/service_test.py b/plum/tests/service_test.py index e8db26ff005..99285233744 100644 --- a/plum/tests/service_test.py +++ b/plum/tests/service_test.py @@ -1,9 +1,13 @@ from unittest import TestCase from docker import Client from plum import Service +import os -client = Client('http://127.0.0.1:4243') +if os.environ.get('DOCKER_URL'): + client = Client(os.environ['DOCKER_URL']) +else: + client = Client() client.pull('ubuntu') From 4cf072a013c4fb078e105a05d71543f4e89bde0a Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Mon, 9 Dec 2013 17:36:44 +0000 Subject: [PATCH 0016/4072] Move ServiceTestCase to separate module --- plum/tests/service_collection_test.py | 8 ++----- plum/tests/service_test.py | 26 +---------------------- plum/tests/testcases.py | 30 +++++++++++++++++++++++++++ 3 files changed, 33 insertions(+), 31 deletions(-) create mode 100644 plum/tests/testcases.py diff --git a/plum/tests/service_collection_test.py b/plum/tests/service_collection_test.py index 88641c0a996..1715c2999bc 100644 --- a/plum/tests/service_collection_test.py +++ b/plum/tests/service_collection_test.py @@ -1,9 +1,9 @@ from plum.service import Service from plum.service_collection import ServiceCollection -from unittest import TestCase +from .testcases import ServiceTestCase -class ServiceCollectionTest(TestCase): +class ServiceCollectionTest(ServiceTestCase): def test_from_dict(self): collection = ServiceCollection.from_dicts(None, [ { @@ -38,7 +38,3 @@ def test_from_dict_sorts_in_dependency_order(self): self.assertEqual(collection[0].name, 'db') self.assertEqual(collection[1].name, 'web') - - - - diff --git a/plum/tests/service_test.py b/plum/tests/service_test.py index 99285233744..49c4d585469 100644 --- a/plum/tests/service_test.py +++ b/plum/tests/service_test.py @@ -1,29 +1,5 @@ -from unittest import TestCase -from docker import Client from plum import Service -import os - - -if os.environ.get('DOCKER_URL'): - client = Client(os.environ['DOCKER_URL']) -else: - client = Client() -client.pull('ubuntu') - - -class ServiceTestCase(TestCase): - def setUp(self): - for c in client.containers(all=True): - client.kill(c['Id']) - client.remove_container(c['Id']) - - def create_service(self, name): - return Service( - name=name, - client=client, - image="ubuntu", - command=["/bin/sleep", "300"], - ) +from .testcases import ServiceTestCase class NameTestCase(ServiceTestCase): diff --git a/plum/tests/testcases.py b/plum/tests/testcases.py new file mode 100644 index 00000000000..7be5b99be97 --- /dev/null +++ b/plum/tests/testcases.py @@ -0,0 +1,30 @@ +from docker import Client +from plum.service import Service +import os +from unittest import TestCase + + +class ServiceTestCase(TestCase): + @classmethod + def setUpClass(cls): + if os.environ.get('DOCKER_URL'): + cls.client = Client(os.environ['DOCKER_URL']) + else: + cls.client = Client() + cls.client.pull('ubuntu') + + def setUp(self): + for c in self.client.containers(all=True): + self.client.kill(c['Id']) + self.client.remove_container(c['Id']) + + def create_service(self, name): + return Service( + name=name, + client=self.client, + image="ubuntu", + command=["/bin/sleep", "300"], + ) + + + From 39497f6ee7692203de7cdd68eea0ade46499c185 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Mon, 9 Dec 2013 17:48:15 +0000 Subject: [PATCH 0017/4072] Add start and stop to ServiceCollections --- plum/service_collection.py | 9 +++++++++ plum/tests/service_collection_test.py | 18 ++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/plum/service_collection.py b/plum/service_collection.py index b73511a9245..3803bafc6d0 100644 --- a/plum/service_collection.py +++ b/plum/service_collection.py @@ -34,4 +34,13 @@ def get(self, name): if service.name == name: return service + def start(self): + for container in self: + container.start() + + def stop(self): + for container in self: + container.stop() + + diff --git a/plum/tests/service_collection_test.py b/plum/tests/service_collection_test.py index 1715c2999bc..080ee2cff08 100644 --- a/plum/tests/service_collection_test.py +++ b/plum/tests/service_collection_test.py @@ -38,3 +38,21 @@ def test_from_dict_sorts_in_dependency_order(self): self.assertEqual(collection[0].name, 'db') self.assertEqual(collection[1].name, 'web') + + def test_start_stop(self): + collection = ServiceCollection([ + self.create_service('web'), + self.create_service('db'), + ]) + + collection.start() + + self.assertEqual(len(collection[0].containers), 1) + self.assertEqual(len(collection[1].containers), 1) + + collection.stop() + + self.assertEqual(len(collection[0].containers), 0) + self.assertEqual(len(collection[1].containers), 0) + + From 2199b62783fbaaa5003a8a54e46751dd10cad39d Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Mon, 9 Dec 2013 18:42:33 +0000 Subject: [PATCH 0018/4072] Test that names are created correctly --- plum/tests/service_test.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/plum/tests/service_test.py b/plum/tests/service_test.py index 49c4d585469..5af3ef7b727 100644 --- a/plum/tests/service_test.py +++ b/plum/tests/service_test.py @@ -27,6 +27,7 @@ def test_containers(self): foo.start() self.assertEqual(len(foo.containers), 1) + self.assertEqual(foo.containers[0]['Names'], ['/foo_1']) self.assertEqual(len(bar.containers), 0) bar.scale(2) @@ -34,6 +35,10 @@ def test_containers(self): self.assertEqual(len(foo.containers), 1) self.assertEqual(len(bar.containers), 2) + names = [c['Names'] for c in bar.containers] + self.assertIn(['/bar_1'], names) + self.assertIn(['/bar_2'], names) + class ScalingTestCase(ServiceTestCase): def setUp(self): From f0768d4dca87bf249a35782bbaa5ddbd8f40fab4 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Mon, 9 Dec 2013 18:43:10 +0000 Subject: [PATCH 0019/4072] Add test for ServiceCollection.get --- plum/tests/service_collection_test.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/plum/tests/service_collection_test.py b/plum/tests/service_collection_test.py index 080ee2cff08..0125511ed9a 100644 --- a/plum/tests/service_collection_test.py +++ b/plum/tests/service_collection_test.py @@ -16,12 +16,10 @@ def test_from_dict(self): } ]) self.assertEqual(len(collection), 2) - web = [s for s in collection if s.name == 'web'][0] - self.assertEqual(web.name, 'web') - self.assertEqual(web.image, 'ubuntu') - db = [s for s in collection if s.name == 'db'][0] - self.assertEqual(db.name, 'db') - self.assertEqual(db.image, 'ubuntu') + self.assertEqual(collection.get('web').name, 'web') + self.assertEqual(collection.get('web').image, 'ubuntu') + self.assertEqual(collection.get('db').name, 'db') + self.assertEqual(collection.get('db').image, 'ubuntu') def test_from_dict_sorts_in_dependency_order(self): collection = ServiceCollection.from_dicts(None, [ @@ -35,10 +33,15 @@ def test_from_dict_sorts_in_dependency_order(self): 'image': 'ubuntu' } ]) - + self.assertEqual(collection[0].name, 'db') self.assertEqual(collection[1].name, 'web') + def test_get(self): + web = self.create_service('web') + collection = ServiceCollection([web]) + self.assertEqual(collection.get('web'), web) + def test_start_stop(self): collection = ServiceCollection([ self.create_service('web'), From 9a81623581f0488b3f8230dcbbd82a563e84346b Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Mon, 9 Dec 2013 21:39:11 +0000 Subject: [PATCH 0020/4072] Create links when creating containers --- plum/service.py | 19 ++++++++++++++++++- plum/tests/service_collection_test.py | 1 + plum/tests/service_test.py | 13 +++++++++++++ plum/tests/testcases.py | 3 ++- 4 files changed, 34 insertions(+), 2 deletions(-) diff --git a/plum/service.py b/plum/service.py index 6b928564def..3f9d95f50ff 100644 --- a/plum/service.py +++ b/plum/service.py @@ -34,7 +34,10 @@ def start_container(self): number = self.next_container_number() name = make_name(self.name, number) container = self.client.create_container(self.image, self.command, name=name) - self.client.start(container['Id']) + self.client.start( + container['Id'], + links=self._get_links(), + ) def stop_container(self): self.client.kill(self.containers[0]['Id']) @@ -47,6 +50,20 @@ def next_container_number(self): else: return max(numbers) + 1 + def get_names(self): + return [get_container_name(c) for c in self.containers] + + def inspect(self): + return [self.client.inspect_container(c['Id']) for c in self.containers] + + def _get_links(self): + links = {} + for service in self.links: + for name in service.get_names(): + links[name] = name + return links + + def make_name(prefix, number): return '%s_%s' % (prefix, number) diff --git a/plum/tests/service_collection_test.py b/plum/tests/service_collection_test.py index 0125511ed9a..8029b6e9f91 100644 --- a/plum/tests/service_collection_test.py +++ b/plum/tests/service_collection_test.py @@ -59,3 +59,4 @@ def test_start_stop(self): self.assertEqual(len(collection[1].containers), 0) + diff --git a/plum/tests/service_test.py b/plum/tests/service_test.py index 5af3ef7b727..9c3726bcdb4 100644 --- a/plum/tests/service_test.py +++ b/plum/tests/service_test.py @@ -65,3 +65,16 @@ def test_up_scale_down(self): self.service.stop() self.assertEqual(len(self.service.containers), 0) + + +class LinksTestCase(ServiceTestCase): + def test_links_are_created_when_starting(self): + db = self.create_service('db') + web = self.create_service('web', links=[db]) + db.start() + web.start() + self.assertIn('/web_1/db_1', db.containers[0]['Names']) + db.stop() + web.stop() + + diff --git a/plum/tests/testcases.py b/plum/tests/testcases.py index 7be5b99be97..df13e752bc6 100644 --- a/plum/tests/testcases.py +++ b/plum/tests/testcases.py @@ -18,12 +18,13 @@ def setUp(self): self.client.kill(c['Id']) self.client.remove_container(c['Id']) - def create_service(self, name): + def create_service(self, name, **kwargs): return Service( name=name, client=self.client, image="ubuntu", command=["/bin/sleep", "300"], + **kwargs ) From 3e6b3bae83734114aa9db208c04d42338573c40d Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Mon, 9 Dec 2013 21:50:36 +0000 Subject: [PATCH 0021/4072] Add warning to readme --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index c6ae78919a1..fa491541baa 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ Plum ==== +**WARNING**: This is a work in progress and probably won't work yet. Feedback welcome. + Plum is tool for defining and running apps with Docker. It uses a simple, version-controllable YAML configuration file that looks something like this: ```yaml From 3a3767f59d83e02e4d1456b7afe49872fb70a1b8 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Mon, 9 Dec 2013 21:51:17 +0000 Subject: [PATCH 0022/4072] Fix readme formatting --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index fa491541baa..8a2fe7dcff8 100644 --- a/README.md +++ b/README.md @@ -77,9 +77,9 @@ web: `web/Dockerfile`: -FROM orchardup/rails -ADD . /code -CMD: bundle exec rackup + FROM orchardup/rails + ADD . /code + CMD: bundle exec rackup ### Communicating between containers From b59436742b90bab56e08a8ae4afaafe2d1efa863 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Tue, 10 Dec 2013 20:47:08 +0000 Subject: [PATCH 0023/4072] Reorganise tests --- plum/tests/service_collection_test.py | 4 +-- plum/tests/service_test.py | 41 +++++++++++---------------- plum/tests/testcases.py | 2 +- 3 files changed, 19 insertions(+), 28 deletions(-) diff --git a/plum/tests/service_collection_test.py b/plum/tests/service_collection_test.py index 8029b6e9f91..8d36e996d04 100644 --- a/plum/tests/service_collection_test.py +++ b/plum/tests/service_collection_test.py @@ -1,9 +1,9 @@ from plum.service import Service from plum.service_collection import ServiceCollection -from .testcases import ServiceTestCase +from .testcases import DockerClientTestCase -class ServiceCollectionTest(ServiceTestCase): +class ServiceCollectionTest(DockerClientTestCase): def test_from_dict(self): collection = ServiceCollection.from_dicts(None, [ { diff --git a/plum/tests/service_test.py b/plum/tests/service_test.py index 9c3726bcdb4..51289e68c48 100644 --- a/plum/tests/service_test.py +++ b/plum/tests/service_test.py @@ -1,8 +1,8 @@ from plum import Service -from .testcases import ServiceTestCase +from .testcases import DockerClientTestCase -class NameTestCase(ServiceTestCase): +class NameTestCase(DockerClientTestCase): def test_name_validations(self): self.assertRaises(ValueError, lambda: Service(name='')) @@ -18,8 +18,6 @@ def test_name_validations(self): Service('_') Service('_____') - -class ContainersTestCase(ServiceTestCase): def test_containers(self): foo = self.create_service('foo') bar = self.create_service('bar') @@ -39,35 +37,28 @@ def test_containers(self): self.assertIn(['/bar_1'], names) self.assertIn(['/bar_2'], names) - -class ScalingTestCase(ServiceTestCase): - def setUp(self): - super(ServiceTestCase, self).setUp() - self.service = self.create_service("scaling_test") - def test_up_scale_down(self): - self.assertEqual(len(self.service.containers), 0) - - self.service.start() - self.assertEqual(len(self.service.containers), 1) + service = self.create_service('scaling_test') + self.assertEqual(len(service.containers), 0) - self.service.start() - self.assertEqual(len(self.service.containers), 1) + service.start() + self.assertEqual(len(service.containers), 1) - self.service.scale(2) - self.assertEqual(len(self.service.containers), 2) + service.start() + self.assertEqual(len(service.containers), 1) - self.service.scale(1) - self.assertEqual(len(self.service.containers), 1) + service.scale(2) + self.assertEqual(len(service.containers), 2) - self.service.stop() - self.assertEqual(len(self.service.containers), 0) + service.scale(1) + self.assertEqual(len(service.containers), 1) - self.service.stop() - self.assertEqual(len(self.service.containers), 0) + service.stop() + self.assertEqual(len(service.containers), 0) + service.stop() + self.assertEqual(len(service.containers), 0) -class LinksTestCase(ServiceTestCase): def test_links_are_created_when_starting(self): db = self.create_service('db') web = self.create_service('web', links=[db]) diff --git a/plum/tests/testcases.py b/plum/tests/testcases.py index df13e752bc6..8fc65ee877d 100644 --- a/plum/tests/testcases.py +++ b/plum/tests/testcases.py @@ -4,7 +4,7 @@ from unittest import TestCase -class ServiceTestCase(TestCase): +class DockerClientTestCase(TestCase): @classmethod def setUpClass(cls): if os.environ.get('DOCKER_URL'): From bf2505d15def76e853a54682f0ade9d358d0c7cf Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Tue, 10 Dec 2013 20:51:55 +0000 Subject: [PATCH 0024/4072] Add options for containers to Service --- plum/service.py | 7 +++---- plum/tests/service_collection_test.py | 4 ++-- plum/tests/service_test.py | 11 ++++++++--- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/plum/service.py b/plum/service.py index 3f9d95f50ff..8e7660a11c4 100644 --- a/plum/service.py +++ b/plum/service.py @@ -2,15 +2,14 @@ class Service(object): - def __init__(self, name, client=None, image=None, command=None, links=None): + def __init__(self, name, client=None, links=[], **options): if not re.match('^[a-zA-Z0-9_]+$', name): raise ValueError('Invalid name: %s' % name) self.name = name self.client = client - self.image = image - self.command = command self.links = links or [] + self.options = options @property def containers(self): @@ -33,7 +32,7 @@ def scale(self, num): def start_container(self): number = self.next_container_number() name = make_name(self.name, number) - container = self.client.create_container(self.image, self.command, name=name) + container = self.client.create_container(name=name, **self.options) self.client.start( container['Id'], links=self._get_links(), diff --git a/plum/tests/service_collection_test.py b/plum/tests/service_collection_test.py index 8d36e996d04..1c764ecacf5 100644 --- a/plum/tests/service_collection_test.py +++ b/plum/tests/service_collection_test.py @@ -17,9 +17,9 @@ def test_from_dict(self): ]) self.assertEqual(len(collection), 2) self.assertEqual(collection.get('web').name, 'web') - self.assertEqual(collection.get('web').image, 'ubuntu') + self.assertEqual(collection.get('web').options['image'], 'ubuntu') self.assertEqual(collection.get('db').name, 'db') - self.assertEqual(collection.get('db').image, 'ubuntu') + self.assertEqual(collection.get('db').options['image'], 'ubuntu') def test_from_dict_sorts_in_dependency_order(self): collection = ServiceCollection.from_dicts(None, [ diff --git a/plum/tests/service_test.py b/plum/tests/service_test.py index 51289e68c48..7bd0e0b2311 100644 --- a/plum/tests/service_test.py +++ b/plum/tests/service_test.py @@ -59,11 +59,16 @@ def test_up_scale_down(self): service.stop() self.assertEqual(len(service.containers), 0) - def test_links_are_created_when_starting(self): + def test_start_container_passes_through_options(self): + db = self.create_service('db', environment={'FOO': 'BAR'}) + db.start_container() + self.assertEqual(db.inspect()[0]['Config']['Env'], ['FOO=BAR']) + + def test_start_container_creates_links(self): db = self.create_service('db') web = self.create_service('web', links=[db]) - db.start() - web.start() + db.start_container() + web.start_container() self.assertIn('/web_1/db_1', db.containers[0]['Names']) db.stop() web.stop() From c2e935376072dac05bb97c7e85399e157960691f Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Tue, 10 Dec 2013 21:01:39 +0000 Subject: [PATCH 0025/4072] Allow options to passed to start_container --- plum/service.py | 6 ++++-- plum/tests/service_test.py | 5 +++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/plum/service.py b/plum/service.py index 8e7660a11c4..254bc9d9995 100644 --- a/plum/service.py +++ b/plum/service.py @@ -29,10 +29,12 @@ def scale(self, num): while len(self.containers) > num: self.stop_container() - def start_container(self): + def start_container(self, **override_options): + options = dict(self.options) + options.update(override_options) number = self.next_container_number() name = make_name(self.name, number) - container = self.client.create_container(name=name, **self.options) + container = self.client.create_container(name=name, **options) self.client.start( container['Id'], links=self._get_links(), diff --git a/plum/tests/service_test.py b/plum/tests/service_test.py index 7bd0e0b2311..ed2c3db784f 100644 --- a/plum/tests/service_test.py +++ b/plum/tests/service_test.py @@ -60,6 +60,11 @@ def test_up_scale_down(self): self.assertEqual(len(service.containers), 0) def test_start_container_passes_through_options(self): + db = self.create_service('db') + db.start_container(environment={'FOO': 'BAR'}) + self.assertEqual(db.inspect()[0]['Config']['Env'], ['FOO=BAR']) + + def test_start_container_inherits_options_from_constructor(self): db = self.create_service('db', environment={'FOO': 'BAR'}) db.start_container() self.assertEqual(db.inspect()[0]['Config']['Env'], ['FOO=BAR']) From 523fb99d7919cc7d11e8d0cdb1e5c05c07a4319f Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Wed, 11 Dec 2013 09:39:17 +0000 Subject: [PATCH 0026/4072] Fix URL in setup.py --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index db17156e65b..658e3907ecc 100644 --- a/setup.py +++ b/setup.py @@ -26,7 +26,7 @@ def find_version(*file_paths): name='plum', version=find_version("plum", "__init__.py"), description='', - url='https://github.com/orchardup.plum', + url='https://github.com/orchardup/plum', author='Orchard Laboratories Ltd.', author_email='hello@orchardup.com', packages=['plum'], From 3b654ad349600439cd7b639106e3f3eb25790a72 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Wed, 11 Dec 2013 14:25:32 +0000 Subject: [PATCH 0027/4072] Add basic CLI --- plum/cli/__init__.py | 0 plum/cli/command.py | 29 +++++++++++++ plum/cli/docopt_command.py | 46 +++++++++++++++++++++ plum/cli/errors.py | 6 +++ plum/cli/formatter.py | 15 +++++++ plum/cli/main.py | 83 ++++++++++++++++++++++++++++++++++++++ plum/cli/utils.py | 76 ++++++++++++++++++++++++++++++++++ plum/service_collection.py | 8 ++++ requirements.txt | 2 + setup.py | 2 +- 10 files changed, 266 insertions(+), 1 deletion(-) create mode 100644 plum/cli/__init__.py create mode 100644 plum/cli/command.py create mode 100644 plum/cli/docopt_command.py create mode 100644 plum/cli/errors.py create mode 100644 plum/cli/formatter.py create mode 100644 plum/cli/main.py create mode 100644 plum/cli/utils.py diff --git a/plum/cli/__init__.py b/plum/cli/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/plum/cli/command.py b/plum/cli/command.py new file mode 100644 index 00000000000..e59d1869f41 --- /dev/null +++ b/plum/cli/command.py @@ -0,0 +1,29 @@ +from docker import Client +import logging +import os +import yaml + +from ..service_collection import ServiceCollection +from .docopt_command import DocoptCommand +from .formatter import Formatter +from .utils import cached_property, mkdir + +log = logging.getLogger(__name__) + +class Command(DocoptCommand): + @cached_property + def client(self): + if os.environ.get('DOCKER_URL'): + return Client(os.environ['DOCKER_URL']) + else: + return Client() + + @cached_property + def service_collection(self): + config = yaml.load(open('plum.yml')) + return ServiceCollection.from_config(self.client, config) + + @cached_property + def formatter(self): + return Formatter() + diff --git a/plum/cli/docopt_command.py b/plum/cli/docopt_command.py new file mode 100644 index 00000000000..0a11d8eea5b --- /dev/null +++ b/plum/cli/docopt_command.py @@ -0,0 +1,46 @@ +import sys + +from inspect import getdoc +from docopt import docopt, DocoptExit + + +def docopt_full_help(docstring, *args, **kwargs): + try: + return docopt(docstring, *args, **kwargs) + except DocoptExit: + raise SystemExit(docstring) + + +class DocoptCommand(object): + def sys_dispatch(self): + self.dispatch(sys.argv[1:], None) + + def dispatch(self, argv, global_options): + self.perform_command(*self.parse(argv, global_options)) + + def perform_command(self, options, command, handler, command_options): + handler(command_options) + + def parse(self, argv, global_options): + options = docopt_full_help(getdoc(self), argv, options_first=True) + command = options['COMMAND'] + + if not hasattr(self, command): + raise NoSuchCommand(command, self) + + handler = getattr(self, command) + docstring = getdoc(handler) + + if docstring is None: + raise NoSuchCommand(command, self) + + command_options = docopt_full_help(docstring, options['ARGS'], options_first=True) + return (options, command, handler, command_options) + + +class NoSuchCommand(Exception): + def __init__(self, command, supercommand): + super(NoSuchCommand, self).__init__("No such command: %s" % command) + + self.command = command + self.supercommand = supercommand diff --git a/plum/cli/errors.py b/plum/cli/errors.py new file mode 100644 index 00000000000..038a7ea18bc --- /dev/null +++ b/plum/cli/errors.py @@ -0,0 +1,6 @@ +from textwrap import dedent + + +class UserError(Exception): + def __init__(self, msg): + self.msg = dedent(msg).strip() diff --git a/plum/cli/formatter.py b/plum/cli/formatter.py new file mode 100644 index 00000000000..55a967f9f27 --- /dev/null +++ b/plum/cli/formatter.py @@ -0,0 +1,15 @@ +import texttable +import os + + +class Formatter(object): + def table(self, headers, rows): + height, width = os.popen('stty size', 'r').read().split() + + table = texttable.Texttable(max_width=width) + table.set_cols_dtype(['t' for h in headers]) + table.add_rows([headers] + rows) + table.set_deco(table.HEADER) + table.set_chars(['-', '|', '+', '-']) + + return table.draw() diff --git a/plum/cli/main.py b/plum/cli/main.py new file mode 100644 index 00000000000..2bd8f3bab7b --- /dev/null +++ b/plum/cli/main.py @@ -0,0 +1,83 @@ +import datetime +import logging +import sys +import os +import re + +from docopt import docopt +from inspect import getdoc + +from .. import __version__ +from ..service_collection import ServiceCollection +from .command import Command + +from .errors import UserError +from .docopt_command import NoSuchCommand + +log = logging.getLogger(__name__) + +def main(): + try: + command = TopLevelCommand() + command.sys_dispatch() + except KeyboardInterrupt: + log.error("\nAborting.") + exit(1) + except UserError, e: + log.error(e.msg) + exit(1) + except NoSuchCommand, e: + log.error("No such command: %s", e.command) + log.error("") + log.error("\n".join(parse_doc_section("commands:", getdoc(e.supercommand)))) + exit(1) + + +# stolen from docopt master +def parse_doc_section(name, source): + pattern = re.compile('^([^\n]*' + name + '[^\n]*\n?(?:[ \t].*?(?:\n|$))*)', + re.IGNORECASE | re.MULTILINE) + return [s.strip() for s in pattern.findall(source)] + + +class TopLevelCommand(Command): + """. + + Usage: + plum [options] [COMMAND] [ARGS...] + plum -h|--help + + Options: + --verbose Show more output + --version Print version and exit + + Commands: + ps List services and containers + + """ + def ps(self, options): + """ + List services and containers. + + Usage: ps + """ + for service in self.service_collection: + for container in service.containers: + print container['Names'][0] + + def start(self, options): + """ + Start all services + + Usage: start + """ + self.service_collection.start() + + def stop(self, options): + """ + Stop all services + + Usage: stop + """ + self.service_collection.stop() + diff --git a/plum/cli/utils.py b/plum/cli/utils.py new file mode 100644 index 00000000000..8d1764258df --- /dev/null +++ b/plum/cli/utils.py @@ -0,0 +1,76 @@ +import datetime +import os + + +def cached_property(f): + """ + returns a cached property that is calculated by function f + http://code.activestate.com/recipes/576563-cached-property/ + """ + def get(self): + try: + return self._property_cache[f] + except AttributeError: + self._property_cache = {} + x = self._property_cache[f] = f(self) + return x + except KeyError: + x = self._property_cache[f] = f(self) + return x + + return property(get) + + +def yesno(prompt, default=None): + """ + Prompt the user for a yes or no. + + Can optionally specify a default value, which will only be + used if they enter a blank line. + + Unrecognised input (anything other than "y", "n", "yes", + "no" or "") will return None. + """ + answer = raw_input(prompt).strip().lower() + + if answer == "y" or answer == "yes": + return True + elif answer == "n" or answer == "no": + return False + elif answer == "": + return default + else: + return None + + +# http://stackoverflow.com/a/5164027 +def prettydate(d): + diff = datetime.datetime.utcnow() - d + s = diff.seconds + if diff.days > 7 or diff.days < 0: + return d.strftime('%d %b %y') + elif diff.days == 1: + return '1 day ago' + elif diff.days > 1: + return '{0} days ago'.format(diff.days) + elif s <= 1: + return 'just now' + elif s < 60: + return '{0} seconds ago'.format(s) + elif s < 120: + return '1 minute ago' + elif s < 3600: + return '{0} minutes ago'.format(s/60) + elif s < 7200: + return '1 hour ago' + else: + return '{0} hours ago'.format(s/3600) + + +def mkdir(path, permissions=0700): + if not os.path.exists(path): + os.mkdir(path) + + os.chmod(path, permissions) + + return path diff --git a/plum/service_collection.py b/plum/service_collection.py index 3803bafc6d0..82ea78ffaf9 100644 --- a/plum/service_collection.py +++ b/plum/service_collection.py @@ -29,6 +29,14 @@ def from_dicts(cls, client, service_dicts): collection.append(Service(client=client, links=links, **service_dict)) return collection + @classmethod + def from_config(cls, client, config): + dicts = [] + for name, service in config.items(): + service['name'] = name + dicts.append(service) + return cls.from_dicts(client, dicts) + def get(self, name): for service in self: if service.name == name: diff --git a/requirements.txt b/requirements.txt index e560af40dfa..6d769742fbb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,3 @@ git+git://github.com/dotcloud/docker-py.git@4fde1a242e1853cbf83e5a36371d8b4a49501c52 +docopt==0.6.1 +PyYAML==3.10 diff --git a/setup.py b/setup.py index 658e3907ecc..5b01a47e6d5 100644 --- a/setup.py +++ b/setup.py @@ -36,6 +36,6 @@ def find_version(*file_paths): dependency_links=[], entry_points=""" [console_scripts] - plum=plum:main + plum=plum.cli.main:main """, ) From 8005254138c3b7d02b24e047374d7579cc7f0612 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Fri, 13 Dec 2013 19:19:44 +0000 Subject: [PATCH 0028/4072] Set up default logging --- plum/cli/main.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/plum/cli/main.py b/plum/cli/main.py index 2bd8f3bab7b..191b2f48710 100644 --- a/plum/cli/main.py +++ b/plum/cli/main.py @@ -16,7 +16,18 @@ log = logging.getLogger(__name__) + def main(): + console_handler = logging.StreamHandler() + console_handler.setFormatter(logging.Formatter()) + console_handler.setLevel(logging.INFO) + root_logger = logging.getLogger() + root_logger.addHandler(console_handler) + root_logger.setLevel(logging.DEBUG) + + # Disable requests logging + logging.getLogger("requests").propagate = False + try: command = TopLevelCommand() command.sys_dispatch() From 1727586dd06fb93aa4ff65a1e6746e6ee2f00507 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Fri, 13 Dec 2013 20:35:54 +0000 Subject: [PATCH 0029/4072] Add start and stop to docs --- plum/cli/main.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plum/cli/main.py b/plum/cli/main.py index 191b2f48710..9f18f8ffd25 100644 --- a/plum/cli/main.py +++ b/plum/cli/main.py @@ -64,6 +64,8 @@ class TopLevelCommand(Command): Commands: ps List services and containers + start Start services + stop Stop services """ def ps(self, options): From 88b04aaa9c56fe26dfdfe31d49450dc0947a1c5d Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Fri, 13 Dec 2013 20:36:10 +0000 Subject: [PATCH 0030/4072] Add support for building images --- plum/service.py | 23 ++++++++++++++----- .../fixtures/simple-dockerfile/Dockerfile | 2 ++ plum/tests/service_test.py | 10 ++++++++ 3 files changed, 29 insertions(+), 6 deletions(-) create mode 100644 plum/tests/fixtures/simple-dockerfile/Dockerfile diff --git a/plum/service.py b/plum/service.py index 254bc9d9995..3bcbdb2bacd 100644 --- a/plum/service.py +++ b/plum/service.py @@ -5,6 +5,8 @@ class Service(object): def __init__(self, name, client=None, links=[], **options): if not re.match('^[a-zA-Z0-9_]+$', name): raise ValueError('Invalid name: %s' % name) + if 'image' in options and 'build' in options: + raise ValueError('Service %s has both an image and build path specified. A service can either be built to image or use an existing image, not both.') self.name = name self.client = client @@ -17,7 +19,7 @@ def containers(self): def start(self): if len(self.containers) == 0: - self.start_container() + return self.start_container() def stop(self): self.scale(0) @@ -30,15 +32,12 @@ def scale(self, num): self.stop_container() def start_container(self, **override_options): - options = dict(self.options) - options.update(override_options) - number = self.next_container_number() - name = make_name(self.name, number) - container = self.client.create_container(name=name, **options) + container = self.client.create_container(**self._get_container_options(override_options)) self.client.start( container['Id'], links=self._get_links(), ) + return container['Id'] def stop_container(self): self.client.kill(self.containers[0]['Id']) @@ -64,6 +63,18 @@ def _get_links(self): links[name] = name return links + def _get_container_options(self, override_options): + keys = ['image', 'command', 'hostname', 'user', 'detach', 'stdin_open', 'tty', 'mem_limit', 'ports', 'environment', 'dns', 'volumes', 'volumes_from'] + container_options = dict((k, self.options[k]) for k in keys if k in self.options) + container_options.update(override_options) + + number = self.next_container_number() + container_options['name'] = make_name(self.name, number) + + if 'build' in self.options: + container_options['image'] = self.client.build(self.options['build'])[0] + + return container_options def make_name(prefix, number): diff --git a/plum/tests/fixtures/simple-dockerfile/Dockerfile b/plum/tests/fixtures/simple-dockerfile/Dockerfile new file mode 100644 index 00000000000..b7fd870f051 --- /dev/null +++ b/plum/tests/fixtures/simple-dockerfile/Dockerfile @@ -0,0 +1,2 @@ +FROM ubuntu +CMD echo "success" diff --git a/plum/tests/service_test.py b/plum/tests/service_test.py index ed2c3db784f..571e0efbff9 100644 --- a/plum/tests/service_test.py +++ b/plum/tests/service_test.py @@ -78,4 +78,14 @@ def test_start_container_creates_links(self): db.stop() web.stop() + def test_start_container_builds_images(self): + service = Service( + name='test', + client=self.client, + build='plum/tests/fixtures/simple-dockerfile', + ) + container = service.start() + self.client.wait(container) + self.assertIn('success', self.client.logs(container)) + From 539f1acdb8307d18e591442927b978043f2786e0 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Fri, 13 Dec 2013 20:43:52 +0000 Subject: [PATCH 0031/4072] Use RUN in readme example --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8a2fe7dcff8..625131ecbe7 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ web: FROM orchardup/rails ADD . /code - CMD: bundle exec rackup + RUN bundle exec rackup ### Communicating between containers From 21159b801fcd8498e8cc810822eee127b6bc0dfc Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Fri, 13 Dec 2013 20:44:35 +0000 Subject: [PATCH 0032/4072] Fix readme formatting --- README.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 625131ecbe7..efe80d93f1b 100644 --- a/README.md +++ b/README.md @@ -118,18 +118,18 @@ You can pass extra configuration options to a container, much like with `docker web: build: web/ --- override the default run command -run: bundle exec thin -p 3000 + -- override the default run command + run: bundle exec thin -p 3000 --- expose ports - can also be an array -ports: 3000 + -- expose ports - can also be an array + ports: 3000 --- map volumes - can also be an array -volumes: /tmp/cache + -- map volumes - can also be an array + volumes: /tmp/cache --- add environment variables - can also be a dictionary -environment: - - RACK_ENV=development + -- add environment variables - can also be a dictionary + environment: + RACK_ENV: development ``` From 5c5bb9a02f735620d4e5fb2e135f32e83e962bfb Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Fri, 13 Dec 2013 20:55:28 +0000 Subject: [PATCH 0033/4072] Add basic run command --- plum/cli/main.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/plum/cli/main.py b/plum/cli/main.py index 9f18f8ffd25..6985b1fefa2 100644 --- a/plum/cli/main.py +++ b/plum/cli/main.py @@ -64,6 +64,7 @@ class TopLevelCommand(Command): Commands: ps List services and containers + run Run a one-off command start Start services stop Stop services @@ -78,6 +79,15 @@ def ps(self, options): for container in service.containers: print container['Names'][0] + def run(self, options): + """ + Run a one-off command. + + Usage: run SERVICE COMMAND [ARGS...] + """ + service = self.service_collection.get(options['SERVICE']) + service.start_container(command=[options['COMMAND']] + options['ARGS']) + def start(self, options): """ Start all services From 772585109dfb127f1937963bba30ae0ca13c9940 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Sat, 14 Dec 2013 16:34:24 +0000 Subject: [PATCH 0034/4072] Moved tests to root directory --- {plum/tests => tests}/__init__.py | 0 {plum/tests => tests}/fixtures/simple-dockerfile/Dockerfile | 0 {plum/tests => tests}/service_collection_test.py | 0 {plum/tests => tests}/service_test.py | 2 +- {plum/tests => tests}/testcases.py | 0 5 files changed, 1 insertion(+), 1 deletion(-) rename {plum/tests => tests}/__init__.py (100%) rename {plum/tests => tests}/fixtures/simple-dockerfile/Dockerfile (100%) rename {plum/tests => tests}/service_collection_test.py (100%) rename {plum/tests => tests}/service_test.py (97%) rename {plum/tests => tests}/testcases.py (100%) diff --git a/plum/tests/__init__.py b/tests/__init__.py similarity index 100% rename from plum/tests/__init__.py rename to tests/__init__.py diff --git a/plum/tests/fixtures/simple-dockerfile/Dockerfile b/tests/fixtures/simple-dockerfile/Dockerfile similarity index 100% rename from plum/tests/fixtures/simple-dockerfile/Dockerfile rename to tests/fixtures/simple-dockerfile/Dockerfile diff --git a/plum/tests/service_collection_test.py b/tests/service_collection_test.py similarity index 100% rename from plum/tests/service_collection_test.py rename to tests/service_collection_test.py diff --git a/plum/tests/service_test.py b/tests/service_test.py similarity index 97% rename from plum/tests/service_test.py rename to tests/service_test.py index 571e0efbff9..33789448cee 100644 --- a/plum/tests/service_test.py +++ b/tests/service_test.py @@ -82,7 +82,7 @@ def test_start_container_builds_images(self): service = Service( name='test', client=self.client, - build='plum/tests/fixtures/simple-dockerfile', + build='tests/fixtures/simple-dockerfile', ) container = service.start() self.client.wait(container) diff --git a/plum/tests/testcases.py b/tests/testcases.py similarity index 100% rename from plum/tests/testcases.py rename to tests/testcases.py From 03e16c49815bc50477734f9f5a334f95e985f48a Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Sat, 14 Dec 2013 16:46:34 +0000 Subject: [PATCH 0035/4072] Revert "Use RUN in readme example" This reverts commit 539f1acdb8307d18e591442927b978043f2786e0. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index efe80d93f1b..5d77ec6ab98 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ web: FROM orchardup/rails ADD . /code - RUN bundle exec rackup + CMD: bundle exec rackup ### Communicating between containers From 96ca74ccc8a94bad76f409bd0ca2e08bd4a5cdf1 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Sat, 14 Dec 2013 16:46:50 +0000 Subject: [PATCH 0036/4072] Fix readme example --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 5d77ec6ab98..e7d5387c603 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ web: FROM orchardup/rails ADD . /code - CMD: bundle exec rackup + CMD bundle exec rackup ### Communicating between containers From ee0ac206e008131c6729f863e5c024c359203b37 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Mon, 16 Dec 2013 10:51:22 +0000 Subject: [PATCH 0037/4072] Add build log message --- plum/service.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/plum/service.py b/plum/service.py index 3bcbdb2bacd..ff198ee49b7 100644 --- a/plum/service.py +++ b/plum/service.py @@ -1,5 +1,7 @@ +import logging import re +log = logging.getLogger(__name__) class Service(object): def __init__(self, name, client=None, links=[], **options): @@ -72,6 +74,7 @@ def _get_container_options(self, override_options): container_options['name'] = make_name(self.name, number) if 'build' in self.options: + log.info('Building %s from %s...' % (self.name, self.options['build'])) container_options['image'] = self.client.build(self.options['build'])[0] return container_options From 6a2d528d2e278cb93cc76bdce38087d867804b7e Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Mon, 16 Dec 2013 11:22:54 +0000 Subject: [PATCH 0038/4072] Add port binding --- plum/service.py | 7 ++++++- requirements.txt | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/plum/service.py b/plum/service.py index ff198ee49b7..940859d351a 100644 --- a/plum/service.py +++ b/plum/service.py @@ -34,10 +34,15 @@ def scale(self, num): self.stop_container() def start_container(self, **override_options): - container = self.client.create_container(**self._get_container_options(override_options)) + container_options = self._get_container_options(override_options) + container = self.client.create_container(**container_options) + port_bindings = {} + for port in container_options.get('ports', []): + port_bindings[port] = None self.client.start( container['Id'], links=self._get_links(), + port_bindings=port_bindings, ) return container['Id'] diff --git a/requirements.txt b/requirements.txt index 6d769742fbb..8523db07982 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,3 @@ -git+git://github.com/dotcloud/docker-py.git@4fde1a242e1853cbf83e5a36371d8b4a49501c52 +git+git://github.com/dotcloud/docker-py.git@5c928dcab51a276f421a36d584c37b745b3b9a3d docopt==0.6.1 PyYAML==3.10 From 6abec8570389e0f262910ae1ead8c39340e06560 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Tue, 17 Dec 2013 11:44:36 +0000 Subject: [PATCH 0039/4072] Fix variable naming in service collection --- plum/service_collection.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/plum/service_collection.py b/plum/service_collection.py index 82ea78ffaf9..3c59c6ed599 100644 --- a/plum/service_collection.py +++ b/plum/service_collection.py @@ -43,12 +43,12 @@ def get(self, name): return service def start(self): - for container in self: - container.start() + for service in self: + service.start() def stop(self): - for container in self: - container.stop() + for service in self: + service.stop() From accc1a219ac9dde6e9f9eeaef11acbfd6c8cb954 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Tue, 17 Dec 2013 12:09:45 +0000 Subject: [PATCH 0040/4072] Perform all operations against stopped containers --- plum/service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plum/service.py b/plum/service.py index 940859d351a..09e297b010f 100644 --- a/plum/service.py +++ b/plum/service.py @@ -17,7 +17,7 @@ def __init__(self, name, client=None, links=[], **options): @property def containers(self): - return [c for c in self.client.containers() if parse_name(get_container_name(c))[0] == self.name] + return [c for c in self.client.containers(all=True) if parse_name(get_container_name(c))[0] == self.name] def start(self): if len(self.containers) == 0: From d3bd7f3239b05bbde48fd25b3ca847e8e12a29d2 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Tue, 17 Dec 2013 12:12:05 +0000 Subject: [PATCH 0041/4072] Remove containers after stopping them --- plum/service.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/plum/service.py b/plum/service.py index 09e297b010f..144f3d547ef 100644 --- a/plum/service.py +++ b/plum/service.py @@ -47,7 +47,9 @@ def start_container(self, **override_options): return container['Id'] def stop_container(self): - self.client.kill(self.containers[0]['Id']) + container_id = self.containers[-1]['Id'] + self.client.kill(container_id) + self.client.remove_container(container_id) def next_container_number(self): numbers = [parse_name(get_container_name(c))[1] for c in self.containers] From 3e680a2c7ab78345c8db4bb5cd19ec62be172ac3 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Tue, 17 Dec 2013 12:12:13 +0000 Subject: [PATCH 0042/4072] Fix container naming --- plum/cli/main.py | 3 ++- plum/service.py | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/plum/cli/main.py b/plum/cli/main.py index 6985b1fefa2..95eef03b2c0 100644 --- a/plum/cli/main.py +++ b/plum/cli/main.py @@ -8,6 +8,7 @@ from inspect import getdoc from .. import __version__ +from ..service import get_container_name from ..service_collection import ServiceCollection from .command import Command @@ -77,7 +78,7 @@ def ps(self, options): """ for service in self.service_collection: for container in service.containers: - print container['Names'][0] + print get_container_name(container) def run(self, options): """ diff --git a/plum/service.py b/plum/service.py index 144f3d547ef..f734c41e084 100644 --- a/plum/service.py +++ b/plum/service.py @@ -103,4 +103,6 @@ def parse_name(name): def get_container_name(container): - return container['Names'][0][1:] + for name in container['Names']: + if len(name.split('/')) == 2: + return name[1:] From 4fdd2dc077c398a15d1c0db84e96790aae4548c5 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Tue, 17 Dec 2013 14:13:12 +0000 Subject: [PATCH 0043/4072] Print output from run --- plum/cli/main.py | 11 ++++++++++- plum/service.py | 11 ++++++++--- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/plum/cli/main.py b/plum/cli/main.py index 95eef03b2c0..c43cf7a7981 100644 --- a/plum/cli/main.py +++ b/plum/cli/main.py @@ -87,7 +87,16 @@ def run(self, options): Usage: run SERVICE COMMAND [ARGS...] """ service = self.service_collection.get(options['SERVICE']) - service.start_container(command=[options['COMMAND']] + options['ARGS']) + container_options = { + 'command': [options['COMMAND']] + options['ARGS'], + } + container = service.create_container(**container_options) + stream = self.client.logs(container, stream=True) + service.start_container(container, **container_options) + for data in stream: + if data is None: + break + print data def start(self, options): """ diff --git a/plum/service.py b/plum/service.py index f734c41e084..e81ed0101b1 100644 --- a/plum/service.py +++ b/plum/service.py @@ -33,9 +33,14 @@ def scale(self, num): while len(self.containers) > num: self.stop_container() - def start_container(self, **override_options): + def create_container(self, **override_options): container_options = self._get_container_options(override_options) - container = self.client.create_container(**container_options) + return self.client.create_container(**container_options) + + def start_container(self, container=None, **override_options): + container_options = self._get_container_options(override_options) + if container is None: + container = self.create_container(**override_options) port_bindings = {} for port in container_options.get('ports', []): port_bindings[port] = None @@ -44,7 +49,7 @@ def start_container(self, **override_options): links=self._get_links(), port_bindings=port_bindings, ) - return container['Id'] + return container def stop_container(self): container_id = self.containers[-1]['Id'] From 120d57e856ac5955eedbee41cc59c04b8b1952f0 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Tue, 17 Dec 2013 15:42:46 +0000 Subject: [PATCH 0044/4072] Change plum up to plum start --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index e7d5387c603..c91370cdf23 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ db: You've now given Plum the minimal amount of configuration it needs to run: ```bash -$ plum up +$ plum start Building db... done db is running at 127.0.0.1:45678 <...output from postgresql server...> @@ -51,10 +51,10 @@ db is running at 127.0.0.1:45678 For each service you've defined, Plum will start a Docker container with the specified image, building or pulling it if necessary. You now have a PostgreSQL server running at `127.0.0.1:45678`. -By default, `plum up` will run until each container has shut down, and relay their output to the terminal. To run in the background instead, pass the `-d` flag: +By default, `plum start` will run until each container has shut down, and relay their output to the terminal. To run in the background instead, pass the `-d` flag: ```bash -$ plum run -d +$ plum start -d Building db... done db is running at 127.0.0.1:45678 @@ -151,7 +151,7 @@ Running more than one container for a service You can set the number of containers to run for each service with `plum scale`: ```bash -$ plum up -d +$ plum start -d db is running at 127.0.0.1:45678 web is running at 127.0.0.1:45679 From 87c46e281ce8c45f7047008eab31a9da6c1d2788 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Wed, 18 Dec 2013 11:14:14 +0000 Subject: [PATCH 0045/4072] Add support for specifying external port --- plum/cli/main.py | 1 - plum/service.py | 12 ++++++++++-- tests/service_test.py | 16 +++++++++++++++- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/plum/cli/main.py b/plum/cli/main.py index c43cf7a7981..93196edf410 100644 --- a/plum/cli/main.py +++ b/plum/cli/main.py @@ -92,7 +92,6 @@ def run(self, options): } container = service.create_container(**container_options) stream = self.client.logs(container, stream=True) - service.start_container(container, **container_options) for data in stream: if data is None: break diff --git a/plum/service.py b/plum/service.py index e81ed0101b1..2d12dbada88 100644 --- a/plum/service.py +++ b/plum/service.py @@ -42,8 +42,13 @@ def start_container(self, container=None, **override_options): if container is None: container = self.create_container(**override_options) port_bindings = {} - for port in container_options.get('ports', []): - port_bindings[port] = None + for port in self.options.get('ports', []): + port = unicode(port) + if ':' in port: + internal_port, external_port = port.split(':', 1) + port_bindings[int(internal_port)] = int(external_port) + else: + port_bindings[int(port)] = None self.client.start( container['Id'], links=self._get_links(), @@ -85,6 +90,9 @@ def _get_container_options(self, override_options): number = self.next_container_number() container_options['name'] = make_name(self.name, number) + if 'ports' in container_options: + container_options['ports'] = [unicode(p).split(':')[0] for p in container_options['ports']] + if 'build' in self.options: log.info('Building %s from %s...' % (self.name, self.options['build'])) container_options['image'] = self.client.build(self.options['build'])[0] diff --git a/tests/service_test.py b/tests/service_test.py index 33789448cee..f2c32507a1c 100644 --- a/tests/service_test.py +++ b/tests/service_test.py @@ -87,5 +87,19 @@ def test_start_container_builds_images(self): container = service.start() self.client.wait(container) self.assertIn('success', self.client.logs(container)) - + + def test_start_container_creates_ports(self): + service = self.create_service('web', ports=[8000]) + service.start_container() + container = service.inspect()[0] + self.assertIn('8000/tcp', container['HostConfig']['PortBindings']) + self.assertNotEqual(container['HostConfig']['PortBindings']['8000/tcp'][0]['HostPort'], '8000') + + def test_start_container_creates_fixed_external_ports(self): + service = self.create_service('web', ports=['8000:8000']) + service.start_container() + container = service.inspect()[0] + self.assertIn('8000/tcp', container['HostConfig']['PortBindings']) + self.assertEqual(container['HostConfig']['PortBindings']['8000/tcp'][0]['HostPort'], '8000') + From 785cb12833c586e8165608e1d45ccf2ca4c6c762 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Wed, 18 Dec 2013 11:15:59 +0000 Subject: [PATCH 0046/4072] Add missing format var in error --- plum/service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plum/service.py b/plum/service.py index 2d12dbada88..4f2a1083b54 100644 --- a/plum/service.py +++ b/plum/service.py @@ -8,7 +8,7 @@ def __init__(self, name, client=None, links=[], **options): if not re.match('^[a-zA-Z0-9_]+$', name): raise ValueError('Invalid name: %s' % name) if 'image' in options and 'build' in options: - raise ValueError('Service %s has both an image and build path specified. A service can either be built to image or use an existing image, not both.') + raise ValueError('Service %s has both an image and build path specified. A service can either be built to image or use an existing image, not both.' % name) self.name = name self.client = client From 24a98b0552c2972b550d7f6a8a903888033d8b94 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Wed, 18 Dec 2013 11:37:51 +0000 Subject: [PATCH 0047/4072] Pull images if they do not exist --- plum/service.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/plum/service.py b/plum/service.py index 4f2a1083b54..58dc8294d92 100644 --- a/plum/service.py +++ b/plum/service.py @@ -1,3 +1,4 @@ +from docker.client import APIError import logging import re @@ -34,8 +35,19 @@ def scale(self, num): self.stop_container() def create_container(self, **override_options): + """ + Create a container for this service. If the image doesn't exist, attempt to pull + it. + """ container_options = self._get_container_options(override_options) - return self.client.create_container(**container_options) + try: + return self.client.create_container(**container_options) + except APIError, e: + if e.response.status_code == 404 and e.explanation and 'No such image' in e.explanation: + log.info('Pulling image %s...' % container_options['image']) + self.client.pull(container_options['image']) + return self.client.create_container(**container_options) + raise def start_container(self, container=None, **override_options): container_options = self._get_container_options(override_options) From 90130eec65b6bfa2e15e6a9d0d3002ae281f87c7 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Wed, 18 Dec 2013 12:01:54 +0000 Subject: [PATCH 0048/4072] Ignore non-plum containers --- plum/service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plum/service.py b/plum/service.py index 58dc8294d92..5034fd2ed68 100644 --- a/plum/service.py +++ b/plum/service.py @@ -120,7 +120,7 @@ def parse_name(name): match = re.match('^(.+)_(\d+)$', name) if match is None: - raise ValueError("Invalid name: %s" % name) + return (None, None) (service_name, suffix) = match.groups() From cb366eed7adcf2f1aaf322655af708b0f8e712ae Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Wed, 18 Dec 2013 13:13:40 +0000 Subject: [PATCH 0049/4072] Add logging to start and stop --- plum/service.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/plum/service.py b/plum/service.py index 5034fd2ed68..d8e7f235de6 100644 --- a/plum/service.py +++ b/plum/service.py @@ -61,6 +61,7 @@ def start_container(self, container=None, **override_options): port_bindings[int(internal_port)] = int(external_port) else: port_bindings[int(port)] = None + log.info("Starting %s..." % container_options['name']) self.client.start( container['Id'], links=self._get_links(), @@ -69,9 +70,10 @@ def start_container(self, container=None, **override_options): return container def stop_container(self): - container_id = self.containers[-1]['Id'] - self.client.kill(container_id) - self.client.remove_container(container_id) + container = self.containers[-1] + log.info("Stopping and removing %s..." % get_container_name(container)) + self.client.kill(container) + self.client.remove_container(container) def next_container_number(self): numbers = [parse_name(get_container_name(c))[1] for c in self.containers] From 3458dd2fad1176665262d9ccbd1e17a8d083450c Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Wed, 18 Dec 2013 16:12:53 +0000 Subject: [PATCH 0050/4072] Print build output --- plum/service.py | 34 ++++++++++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/plum/service.py b/plum/service.py index d8e7f235de6..8a862bc14d5 100644 --- a/plum/service.py +++ b/plum/service.py @@ -4,6 +4,11 @@ log = logging.getLogger(__name__) + +class BuildError(Exception): + pass + + class Service(object): def __init__(self, name, client=None, links=[], **options): if not re.match('^[a-zA-Z0-9_]+$', name): @@ -50,7 +55,6 @@ def create_container(self, **override_options): raise def start_container(self, container=None, **override_options): - container_options = self._get_container_options(override_options) if container is None: container = self.create_container(**override_options) port_bindings = {} @@ -61,7 +65,7 @@ def start_container(self, container=None, **override_options): port_bindings[int(internal_port)] = int(external_port) else: port_bindings[int(port)] = None - log.info("Starting %s..." % container_options['name']) + log.info("Starting %s..." % container['Id']) self.client.start( container['Id'], links=self._get_links(), @@ -108,11 +112,29 @@ def _get_container_options(self, override_options): container_options['ports'] = [unicode(p).split(':')[0] for p in container_options['ports']] if 'build' in self.options: - log.info('Building %s from %s...' % (self.name, self.options['build'])) - container_options['image'] = self.client.build(self.options['build'])[0] + container_options['image'] = self.build() return container_options + def build(self): + log.info('Building %s from %s...' % (self.name, self.options['build'])) + + build_output = self.client.build(self.options['build'], stream=True) + + image_id = None + + for line in build_output: + if line: + match = re.search(r'Successfully built ([0-9a-f]+)', line) + if match: + image_id = match.group(1) + print line + + if image_id is None: + raise BuildError() + + return image_id + def make_name(prefix, number): return '%s_%s' % (prefix, number) @@ -130,6 +152,10 @@ def parse_name(name): def get_container_name(container): + # inspect + if 'Name' in container: + return container['Name'] + # ps for name in container['Names']: if len(name.split('/')) == 2: return name[1:] From f0df5c60796f05b79ec3c2435d8fc3b57de3b0b9 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 18 Dec 2013 14:54:28 +0000 Subject: [PATCH 0051/4072] Refactor container retrieval / name parsing --- plum/service.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/plum/service.py b/plum/service.py index 8a862bc14d5..b3fadbd9a40 100644 --- a/plum/service.py +++ b/plum/service.py @@ -23,7 +23,13 @@ def __init__(self, name, client=None, links=[], **options): @property def containers(self): - return [c for c in self.client.containers(all=True) if parse_name(get_container_name(c))[0] == self.name] + return list(self.get_containers(all=True)) + + def get_containers(self, all): + for container in self.client.containers(all=all): + name = get_container_name(container) + if is_valid_name(name) and parse_name(name)[0] == self.name: + yield container def start(self): if len(self.containers) == 0: @@ -136,18 +142,20 @@ def build(self): return image_id +name_regex = '^(.+)_(\d+)$' + + def make_name(prefix, number): return '%s_%s' % (prefix, number) -def parse_name(name): - match = re.match('^(.+)_(\d+)$', name) +def is_valid_name(name): + return (re.match(name_regex, name) is not None) - if match is None: - return (None, None) +def parse_name(name): + match = re.match(name_regex, name) (service_name, suffix) = match.groups() - return (service_name, int(suffix)) From 23c3dc430b4e803b0eeaadf168f1e19466890f14 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 18 Dec 2013 14:54:51 +0000 Subject: [PATCH 0052/4072] Add texttable to requirements.txt --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index 8523db07982..f8e53737207 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ git+git://github.com/dotcloud/docker-py.git@5c928dcab51a276f421a36d584c37b745b3b9a3d docopt==0.6.1 PyYAML==3.10 +texttable==0.8.1 From 64253a8290b073f5238ca4d49517de6cd7c500e5 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 18 Dec 2013 14:58:58 +0000 Subject: [PATCH 0053/4072] Basic log output --- plum/cli/colors.py | 41 +++++++++++++++++++++++++++++++ plum/cli/log_printer.py | 53 +++++++++++++++++++++++++++++++++++++++++ plum/cli/main.py | 13 ++++++++++ plum/cli/multiplexer.py | 32 +++++++++++++++++++++++++ 4 files changed, 139 insertions(+) create mode 100644 plum/cli/colors.py create mode 100644 plum/cli/log_printer.py create mode 100644 plum/cli/multiplexer.py diff --git a/plum/cli/colors.py b/plum/cli/colors.py new file mode 100644 index 00000000000..09ec84bdb77 --- /dev/null +++ b/plum/cli/colors.py @@ -0,0 +1,41 @@ +NAMES = [ + 'grey', + 'red', + 'green', + 'yellow', + 'blue', + 'magenta', + 'cyan', + 'white' +] + + +def get_pairs(): + for i, name in enumerate(NAMES): + yield(name, str(30 + i)) + yield('intense_' + name, str(30 + i) + ';1') + + +def ansi(code): + return '\033[{0}m'.format(code) + + +def ansi_color(code, s): + return '{0}{1}{2}'.format(ansi(code), s, ansi(0)) + + +def make_color_fn(code): + return lambda s: ansi_color(code, s) + + +for (name, code) in get_pairs(): + globals()[name] = make_color_fn(code) + + +def rainbow(): + cs = ['cyan', 'yellow', 'green', 'magenta', 'red', 'blue', + 'intense_cyan', 'intense_yellow', 'intense_green', + 'intense_magenta', 'intense_red', 'intense_blue'] + + for c in cs: + yield globals()[c] diff --git a/plum/cli/log_printer.py b/plum/cli/log_printer.py new file mode 100644 index 00000000000..9fced4f314d --- /dev/null +++ b/plum/cli/log_printer.py @@ -0,0 +1,53 @@ +import sys + +from itertools import cycle + +from ..service import get_container_name +from .multiplexer import Multiplexer +from . import colors + + +class LogPrinter(object): + def __init__(self, client): + self.client = client + + def attach(self, containers): + generators = self._make_log_generators(containers) + mux = Multiplexer(generators) + for line in mux.loop(): + sys.stdout.write(line) + + def _make_log_generators(self, containers): + color_fns = cycle(colors.rainbow()) + generators = [] + + for container in containers: + color_fn = color_fns.next() + generators.append(self._make_log_generator(container, color_fn)) + + return generators + + def _make_log_generator(self, container, color_fn): + container_name = get_container_name(container) + format = lambda line: color_fn(container_name + " | ") + line + return (format(line) for line in self._readlines(container)) + + def _readlines(self, container, logs=False, stream=True): + socket = self.client.attach_socket( + container['Id'], + params={ + 'stdin': 0, + 'stdout': 1, + 'stderr': 1, + 'logs': 1 if logs else 0, + 'stream': 1 if stream else 0 + }, + ) + + for line in iter(socket.makefile().readline, b''): + if not line.endswith('\n'): + line += '\n' + + yield line + + socket.close() diff --git a/plum/cli/main.py b/plum/cli/main.py index 93196edf410..9c14e8f59a5 100644 --- a/plum/cli/main.py +++ b/plum/cli/main.py @@ -11,6 +11,7 @@ from ..service import get_container_name from ..service_collection import ServiceCollection from .command import Command +from .log_printer import LogPrinter from .errors import UserError from .docopt_command import NoSuchCommand @@ -113,3 +114,15 @@ def stop(self, options): """ self.service_collection.stop() + def logs(self, options): + """ + View containers' output + + Usage: logs + """ + containers = self._get_containers(all=False) + print "Attaching to", ", ".join(get_container_name(c) for c in containers) + LogPrinter(client=self.client).attach(containers) + + def _get_containers(self, all): + return [c for s in self.service_collection for c in s.get_containers(all=all)] diff --git a/plum/cli/multiplexer.py b/plum/cli/multiplexer.py new file mode 100644 index 00000000000..cfcc3b6ce33 --- /dev/null +++ b/plum/cli/multiplexer.py @@ -0,0 +1,32 @@ +from threading import Thread + +try: + from Queue import Queue, Empty +except ImportError: + from queue import Queue, Empty # Python 3.x + + +class Multiplexer(object): + def __init__(self, generators): + self.generators = generators + self.queue = Queue() + + def loop(self): + self._init_readers() + + while True: + try: + yield self.queue.get(timeout=0.1) + except Empty: + pass + + def _init_readers(self): + for generator in self.generators: + t = Thread(target=_enqueue_output, args=(generator, self.queue)) + t.daemon = True + t.start() + + +def _enqueue_output(generator, queue): + for item in generator: + queue.put(item) From 4cc906fcd29dbec4683a3ab2dba6c7a88d40c216 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 18 Dec 2013 15:12:45 +0000 Subject: [PATCH 0054/4072] ps only lists running containers --- plum/cli/main.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/plum/cli/main.py b/plum/cli/main.py index 9c14e8f59a5..5650cdeb334 100644 --- a/plum/cli/main.py +++ b/plum/cli/main.py @@ -77,9 +77,8 @@ def ps(self, options): Usage: ps """ - for service in self.service_collection: - for container in service.containers: - print get_container_name(container) + for container in self._get_containers(all=False): + print get_container_name(container) def run(self, options): """ From e5642bd8b71d7a4a413565c95f39399a097b347d Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 18 Dec 2013 15:13:02 +0000 Subject: [PATCH 0055/4072] Show a sensible error when an unknown service name is given to 'run' --- plum/cli/main.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plum/cli/main.py b/plum/cli/main.py index 5650cdeb334..f4ba57666ba 100644 --- a/plum/cli/main.py +++ b/plum/cli/main.py @@ -87,6 +87,8 @@ def run(self, options): Usage: run SERVICE COMMAND [ARGS...] """ service = self.service_collection.get(options['SERVICE']) + if service is None: + raise UserError("No such service: %s" % options['SERVICE']) container_options = { 'command': [options['COMMAND']] + options['ARGS'], } From 5e1e4a71e00831a5151c3091af7a3c024b6cfa46 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Wed, 18 Dec 2013 17:01:50 +0000 Subject: [PATCH 0056/4072] Rename ServiceTest --- tests/service_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/service_test.py b/tests/service_test.py index f2c32507a1c..321f550bbf4 100644 --- a/tests/service_test.py +++ b/tests/service_test.py @@ -2,7 +2,7 @@ from .testcases import DockerClientTestCase -class NameTestCase(DockerClientTestCase): +class ServiceTest(DockerClientTestCase): def test_name_validations(self): self.assertRaises(ValueError, lambda: Service(name='')) From a5fc880d1046d28543cbdebd8d918301e4cc3030 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Wed, 18 Dec 2013 18:37:48 +0000 Subject: [PATCH 0057/4072] Refactor service to add a container object --- plum/cli/main.py | 4 +- plum/container.py | 86 ++++++++++++++++++++++++++++++++ plum/service.py | 46 +++++++---------- tests/container_test.py | 36 +++++++++++++ tests/service_collection_test.py | 8 +-- tests/service_test.py | 46 ++++++++--------- 6 files changed, 169 insertions(+), 57 deletions(-) create mode 100644 plum/container.py create mode 100644 tests/container_test.py diff --git a/plum/cli/main.py b/plum/cli/main.py index f4ba57666ba..56ef3e579a4 100644 --- a/plum/cli/main.py +++ b/plum/cli/main.py @@ -78,7 +78,7 @@ def ps(self, options): Usage: ps """ for container in self._get_containers(all=False): - print get_container_name(container) + print container.name def run(self, options): """ @@ -126,4 +126,4 @@ def logs(self, options): LogPrinter(client=self.client).attach(containers) def _get_containers(self, all): - return [c for s in self.service_collection for c in s.get_containers(all=all)] + return [c for s in self.service_collection for c in s.containers(all=all)] diff --git a/plum/container.py b/plum/container.py new file mode 100644 index 00000000000..9ce6509df17 --- /dev/null +++ b/plum/container.py @@ -0,0 +1,86 @@ + + +class Container(object): + """ + Represents a Docker container, constructed from the output of + GET /containers/:id:/json. + """ + def __init__(self, client, dictionary, has_been_inspected=False): + self.client = client + self.dictionary = dictionary + self.has_been_inspected = has_been_inspected + + @classmethod + def from_ps(cls, client, dictionary, **kwargs): + """ + Construct a container object from the output of GET /containers/json. + """ + new_dictionary = { + 'ID': dictionary['Id'], + 'Image': dictionary['Image'], + } + for name in dictionary.get('Names', []): + if len(name.split('/')) == 2: + new_dictionary['Name'] = name + return cls(client, new_dictionary, **kwargs) + + @classmethod + def from_id(cls, client, id): + return cls(client, client.inspect_container(id)) + + @classmethod + def create(cls, client, **options): + response = client.create_container(**options) + return cls.from_id(client, response['Id']) + + @property + def id(self): + return self.dictionary['ID'] + + @property + def name(self): + return self.dictionary['Name'] + + @property + def environment(self): + self.inspect_if_not_inspected() + out = {} + for var in self.dictionary.get('Config', {}).get('Env', []): + k, v = var.split('=', 1) + out[k] = v + return out + + def start(self, **options): + return self.client.start(self.id, **options) + + def stop(self): + return self.client.stop(self.id) + + def kill(self): + return self.client.kill(self.id) + + def remove(self): + return self.client.remove_container(self.id) + + def inspect_if_not_inspected(self): + if not self.has_been_inspected: + self.inspect() + + def wait(self): + return self.client.wait(self.id) + + def logs(self, *args, **kwargs): + return self.client.logs(self.id, *args, **kwargs) + + def inspect(self): + self.dictionary = self.client.inspect_container(self.id) + return self.dictionary + + def links(self): + links = [] + for container in self.client.containers(): + for name in container['Names']: + bits = name.split('/') + if len(bits) > 2 and bits[1] == self.name[1:]: + links.append(bits[2]) + return links diff --git a/plum/service.py b/plum/service.py index b3fadbd9a40..2964705113b 100644 --- a/plum/service.py +++ b/plum/service.py @@ -1,6 +1,7 @@ from docker.client import APIError import logging import re +from .container import Container log = logging.getLogger(__name__) @@ -21,28 +22,26 @@ def __init__(self, name, client=None, links=[], **options): self.links = links or [] self.options = options - @property - def containers(self): - return list(self.get_containers(all=True)) - - def get_containers(self, all): + def containers(self, all=False): + l = [] for container in self.client.containers(all=all): name = get_container_name(container) if is_valid_name(name) and parse_name(name)[0] == self.name: - yield container + l.append(Container.from_ps(self.client, container)) + return l def start(self): - if len(self.containers) == 0: + if len(self.containers()) == 0: return self.start_container() def stop(self): self.scale(0) def scale(self, num): - while len(self.containers) < num: + while len(self.containers()) < num: self.start_container() - while len(self.containers) > num: + while len(self.containers()) > num: self.stop_container() def create_container(self, **override_options): @@ -52,12 +51,12 @@ def create_container(self, **override_options): """ container_options = self._get_container_options(override_options) try: - return self.client.create_container(**container_options) + return Container.create(self.client, **container_options) except APIError, e: if e.response.status_code == 404 and e.explanation and 'No such image' in e.explanation: log.info('Pulling image %s...' % container_options['image']) self.client.pull(container_options['image']) - return self.client.create_container(**container_options) + return Container.create(self.client, **container_options) raise def start_container(self, container=None, **override_options): @@ -71,39 +70,32 @@ def start_container(self, container=None, **override_options): port_bindings[int(internal_port)] = int(external_port) else: port_bindings[int(port)] = None - log.info("Starting %s..." % container['Id']) - self.client.start( - container['Id'], + log.info("Starting %s..." % container.name) + container.start( links=self._get_links(), port_bindings=port_bindings, ) return container def stop_container(self): - container = self.containers[-1] - log.info("Stopping and removing %s..." % get_container_name(container)) - self.client.kill(container) - self.client.remove_container(container) + container = self.containers()[-1] + log.info("Stopping and removing %s..." % container.name) + container.kill() + container.remove() def next_container_number(self): - numbers = [parse_name(get_container_name(c))[1] for c in self.containers] + numbers = [parse_name(c.name)[1] for c in self.containers(all=True)] if len(numbers) == 0: return 1 else: return max(numbers) + 1 - def get_names(self): - return [get_container_name(c) for c in self.containers] - - def inspect(self): - return [self.client.inspect_container(c['Id']) for c in self.containers] - def _get_links(self): links = {} for service in self.links: - for name in service.get_names(): - links[name] = name + for container in service.containers(): + links[container.name[1:]] = container.name[1:] return links def _get_container_options(self, override_options): diff --git a/tests/container_test.py b/tests/container_test.py new file mode 100644 index 00000000000..8628b04d8ff --- /dev/null +++ b/tests/container_test.py @@ -0,0 +1,36 @@ +from .testcases import DockerClientTestCase +from plum.container import Container + +class ContainerTest(DockerClientTestCase): + def test_from_ps(self): + container = Container.from_ps(self.client, { + "Id":"abc", + "Image":"ubuntu:12.04", + "Command":"sleep 300", + "Created":1387384730, + "Status":"Up 8 seconds", + "Ports":None, + "SizeRw":0, + "SizeRootFs":0, + "Names":["/db_1"] + }, has_been_inspected=True) + self.assertEqual(container.dictionary, { + "ID": "abc", + "Image":"ubuntu:12.04", + "Name": "/db_1", + }) + + def test_environment(self): + container = Container(self.client, { + 'ID': 'abc', + 'Config': { + 'Env': [ + 'FOO=BAR', + 'BAZ=DOGE', + ] + } + }, has_been_inspected=True) + self.assertEqual(container.environment, { + 'FOO': 'BAR', + 'BAZ': 'DOGE', + }) diff --git a/tests/service_collection_test.py b/tests/service_collection_test.py index 1c764ecacf5..7dbffdcee8b 100644 --- a/tests/service_collection_test.py +++ b/tests/service_collection_test.py @@ -50,13 +50,13 @@ def test_start_stop(self): collection.start() - self.assertEqual(len(collection[0].containers), 1) - self.assertEqual(len(collection[1].containers), 1) + self.assertEqual(len(collection[0].containers()), 1) + self.assertEqual(len(collection[1].containers()), 1) collection.stop() - self.assertEqual(len(collection[0].containers), 0) - self.assertEqual(len(collection[1].containers), 0) + self.assertEqual(len(collection[0].containers()), 0) + self.assertEqual(len(collection[1].containers()), 0) diff --git a/tests/service_test.py b/tests/service_test.py index 321f550bbf4..62d53e563b2 100644 --- a/tests/service_test.py +++ b/tests/service_test.py @@ -24,57 +24,57 @@ def test_containers(self): foo.start() - self.assertEqual(len(foo.containers), 1) - self.assertEqual(foo.containers[0]['Names'], ['/foo_1']) - self.assertEqual(len(bar.containers), 0) + self.assertEqual(len(foo.containers()), 1) + self.assertEqual(foo.containers()[0].name, '/foo_1') + self.assertEqual(len(bar.containers()), 0) bar.scale(2) - self.assertEqual(len(foo.containers), 1) - self.assertEqual(len(bar.containers), 2) + self.assertEqual(len(foo.containers()), 1) + self.assertEqual(len(bar.containers()), 2) - names = [c['Names'] for c in bar.containers] - self.assertIn(['/bar_1'], names) - self.assertIn(['/bar_2'], names) + names = [c.name for c in bar.containers()] + self.assertIn('/bar_1', names) + self.assertIn('/bar_2', names) def test_up_scale_down(self): service = self.create_service('scaling_test') - self.assertEqual(len(service.containers), 0) + self.assertEqual(len(service.containers()), 0) service.start() - self.assertEqual(len(service.containers), 1) + self.assertEqual(len(service.containers()), 1) service.start() - self.assertEqual(len(service.containers), 1) + self.assertEqual(len(service.containers()), 1) service.scale(2) - self.assertEqual(len(service.containers), 2) + self.assertEqual(len(service.containers()), 2) service.scale(1) - self.assertEqual(len(service.containers), 1) + self.assertEqual(len(service.containers()), 1) service.stop() - self.assertEqual(len(service.containers), 0) + self.assertEqual(len(service.containers()), 0) service.stop() - self.assertEqual(len(service.containers), 0) + self.assertEqual(len(service.containers()), 0) def test_start_container_passes_through_options(self): db = self.create_service('db') db.start_container(environment={'FOO': 'BAR'}) - self.assertEqual(db.inspect()[0]['Config']['Env'], ['FOO=BAR']) + self.assertEqual(db.containers()[0].environment['FOO'], 'BAR') def test_start_container_inherits_options_from_constructor(self): db = self.create_service('db', environment={'FOO': 'BAR'}) db.start_container() - self.assertEqual(db.inspect()[0]['Config']['Env'], ['FOO=BAR']) + self.assertEqual(db.containers()[0].environment['FOO'], 'BAR') def test_start_container_creates_links(self): db = self.create_service('db') web = self.create_service('web', links=[db]) db.start_container() web.start_container() - self.assertIn('/web_1/db_1', db.containers[0]['Names']) + self.assertIn('db_1', web.containers()[0].links()) db.stop() web.stop() @@ -85,20 +85,18 @@ def test_start_container_builds_images(self): build='tests/fixtures/simple-dockerfile', ) container = service.start() - self.client.wait(container) - self.assertIn('success', self.client.logs(container)) + container.wait() + self.assertIn('success', container.logs()) def test_start_container_creates_ports(self): service = self.create_service('web', ports=[8000]) - service.start_container() - container = service.inspect()[0] + container = service.start_container().inspect() self.assertIn('8000/tcp', container['HostConfig']['PortBindings']) self.assertNotEqual(container['HostConfig']['PortBindings']['8000/tcp'][0]['HostPort'], '8000') def test_start_container_creates_fixed_external_ports(self): service = self.create_service('web', ports=['8000:8000']) - service.start_container() - container = service.inspect()[0] + container = service.start_container().inspect() self.assertIn('8000/tcp', container['HostConfig']['PortBindings']) self.assertEqual(container['HostConfig']['PortBindings']['8000/tcp'][0]['HostPort'], '8000') From f89e4bc70f8c8098a77bd5e4a7cc77575ea8aa9c Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Wed, 18 Dec 2013 18:44:33 +0000 Subject: [PATCH 0058/4072] Add quotes to build output --- plum/service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plum/service.py b/plum/service.py index 2964705113b..3b16a084f3a 100644 --- a/plum/service.py +++ b/plum/service.py @@ -115,7 +115,7 @@ def _get_container_options(self, override_options): return container_options def build(self): - log.info('Building %s from %s...' % (self.name, self.options['build'])) + log.info('Building %s from "%s"...' % (self.name, self.options['build'])) build_output = self.client.build(self.options['build'], stream=True) From dd767aef34b40b353ea56d3615b9274b259160c4 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Wed, 18 Dec 2013 18:45:25 +0000 Subject: [PATCH 0059/4072] Remove extraneous new lines when building --- plum/service.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plum/service.py b/plum/service.py index 3b16a084f3a..0badd48cf47 100644 --- a/plum/service.py +++ b/plum/service.py @@ -1,6 +1,7 @@ from docker.client import APIError import logging import re +import sys from .container import Container log = logging.getLogger(__name__) @@ -126,7 +127,7 @@ def build(self): match = re.search(r'Successfully built ([0-9a-f]+)', line) if match: image_id = match.group(1) - print line + sys.stdout.write(line) if image_id is None: raise BuildError() From 26ea08087a951f035d82852edae7deb347fbb18d Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Wed, 18 Dec 2013 18:46:53 +0000 Subject: [PATCH 0060/4072] Remove build target from logs --- plum/service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plum/service.py b/plum/service.py index 0badd48cf47..bdca97ed8ed 100644 --- a/plum/service.py +++ b/plum/service.py @@ -116,7 +116,7 @@ def _get_container_options(self, override_options): return container_options def build(self): - log.info('Building %s from "%s"...' % (self.name, self.options['build'])) + log.info('Building %s...' % self.name) build_output = self.client.build(self.options['build'], stream=True) From 730f9772f9c460905c3c93fee0939c084e5fa77d Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 18 Dec 2013 19:01:53 +0000 Subject: [PATCH 0061/4072] plum start runs in foreground by default Also fixed LogPrinter regressions. Sorry for not doing that in a separate commit. Also made 'plum logs' show backlog. Yep, rolled that right in too. Gonna go whip myself now. --- plum/cli/log_printer.py | 48 ++++++++++++++++++++--------------------- plum/cli/main.py | 34 ++++++++++++++++++++++++----- plum/container.py | 3 +++ 3 files changed, 56 insertions(+), 29 deletions(-) diff --git a/plum/cli/log_printer.py b/plum/cli/log_printer.py index 9fced4f314d..653c0f50bd3 100644 --- a/plum/cli/log_printer.py +++ b/plum/cli/log_printer.py @@ -2,48 +2,48 @@ from itertools import cycle -from ..service import get_container_name from .multiplexer import Multiplexer from . import colors class LogPrinter(object): - def __init__(self, client): - self.client = client + def __init__(self, containers, attach_params=None): + self.containers = containers + self.attach_params = attach_params or {} + self.generators = self._make_log_generators() - def attach(self, containers): - generators = self._make_log_generators(containers) - mux = Multiplexer(generators) + def run(self): + mux = Multiplexer(self.generators) for line in mux.loop(): sys.stdout.write(line) - def _make_log_generators(self, containers): + def _make_log_generators(self): color_fns = cycle(colors.rainbow()) generators = [] - for container in containers: + for container in self.containers: color_fn = color_fns.next() generators.append(self._make_log_generator(container, color_fn)) return generators def _make_log_generator(self, container, color_fn): - container_name = get_container_name(container) - format = lambda line: color_fn(container_name + " | ") + line - return (format(line) for line in self._readlines(container)) - - def _readlines(self, container, logs=False, stream=True): - socket = self.client.attach_socket( - container['Id'], - params={ - 'stdin': 0, - 'stdout': 1, - 'stderr': 1, - 'logs': 1 if logs else 0, - 'stream': 1 if stream else 0 - }, - ) - + format = lambda line: color_fn(container.name + " | ") + line + return (format(line) for line in self._readlines(self._attach(container))) + + def _attach(self, container): + params = { + 'stdin': False, + 'stdout': True, + 'stderr': True, + 'logs': False, + 'stream': True, + } + params.update(self.attach_params) + params = dict((name, 1 if value else 0) for (name, value) in params.items()) + return container.attach_socket(params=params) + + def _readlines(self, socket): for line in iter(socket.makefile().readline, b''): if not line.endswith('\n'): line += '\n' diff --git a/plum/cli/main.py b/plum/cli/main.py index 56ef3e579a4..14737898dd2 100644 --- a/plum/cli/main.py +++ b/plum/cli/main.py @@ -8,7 +8,6 @@ from inspect import getdoc from .. import __version__ -from ..service import get_container_name from ..service_collection import ServiceCollection from .command import Command from .log_printer import LogPrinter @@ -103,9 +102,30 @@ def start(self, options): """ Start all services - Usage: start + Usage: start [-d] """ - self.service_collection.start() + if options['-d']: + self.service_collection.start() + return + + running = [] + unstarted = [] + + for s in self.service_collection: + if len(s.containers()) == 0: + unstarted.append((s, s.create_container())) + else: + running += s.get_containers(all=False) + + log_printer = LogPrinter(running + [c for (s, c) in unstarted]) + + for (s, c) in unstarted: + s.start_container(c) + + try: + log_printer.run() + finally: + self.service_collection.stop() def stop(self, options): """ @@ -122,8 +142,12 @@ def logs(self, options): Usage: logs """ containers = self._get_containers(all=False) - print "Attaching to", ", ".join(get_container_name(c) for c in containers) - LogPrinter(client=self.client).attach(containers) + print "Attaching to", list_containers(containers) + LogPrinter(containers, attach_params={'logs': True}).run() def _get_containers(self, all): return [c for s in self.service_collection for c in s.containers(all=all)] + + +def list_containers(containers): + return ", ".join(c.name for c in containers) diff --git a/plum/container.py b/plum/container.py index 9ce6509df17..abd1c3f5559 100644 --- a/plum/container.py +++ b/plum/container.py @@ -84,3 +84,6 @@ def links(self): if len(bits) > 2 and bits[1] == self.name[1:]: links.append(bits[2]) return links + + def attach_socket(self, **kwargs): + return self.client.attach_socket(self.id, **kwargs) From beaa1dbc14c94aa76b0649b698c3ff27b60ac851 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 19 Dec 2013 12:26:13 +0000 Subject: [PATCH 0062/4072] Fix run: use Container.logs(), explicitly start container --- plum/cli/main.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plum/cli/main.py b/plum/cli/main.py index 14737898dd2..6f3aa9fdc76 100644 --- a/plum/cli/main.py +++ b/plum/cli/main.py @@ -92,7 +92,8 @@ def run(self, options): 'command': [options['COMMAND']] + options['ARGS'], } container = service.create_container(**container_options) - stream = self.client.logs(container, stream=True) + stream = container.logs(stream=True) + service.start_container(container) for data in stream: if data is None: break From fb69512008afb9973baf00b2de48767fa53e39e3 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 19 Dec 2013 12:26:58 +0000 Subject: [PATCH 0063/4072] Set port_bindings to None when starting a one-off container in 'plum run' --- plum/cli/main.py | 2 +- plum/service.py | 21 ++++++++++++++------- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/plum/cli/main.py b/plum/cli/main.py index 6f3aa9fdc76..8dd465556b7 100644 --- a/plum/cli/main.py +++ b/plum/cli/main.py @@ -93,7 +93,7 @@ def run(self, options): } container = service.create_container(**container_options) stream = container.logs(stream=True) - service.start_container(container) + service.start_container(container, ports=None) for data in stream: if data is None: break diff --git a/plum/service.py b/plum/service.py index bdca97ed8ed..14e2d8aca79 100644 --- a/plum/service.py +++ b/plum/service.py @@ -63,14 +63,21 @@ def create_container(self, **override_options): def start_container(self, container=None, **override_options): if container is None: container = self.create_container(**override_options) + + options = self.options.copy() + options.update(override_options) + port_bindings = {} - for port in self.options.get('ports', []): - port = unicode(port) - if ':' in port: - internal_port, external_port = port.split(':', 1) - port_bindings[int(internal_port)] = int(external_port) - else: - port_bindings[int(port)] = None + + if options.get('ports', None) is not None: + for port in options['ports']: + port = unicode(port) + if ':' in port: + internal_port, external_port = port.split(':', 1) + port_bindings[int(internal_port)] = int(external_port) + else: + port_bindings[int(port)] = None + log.info("Starting %s..." % container.name) container.start( links=self._get_links(), From ae0fa0c447dea43d03684ff740a5083da5d8cb38 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 19 Dec 2013 12:36:38 +0000 Subject: [PATCH 0064/4072] Hide stack traces for Docker API errors --- plum/cli/main.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/plum/cli/main.py b/plum/cli/main.py index 8dd465556b7..2de1cd136c3 100644 --- a/plum/cli/main.py +++ b/plum/cli/main.py @@ -12,6 +12,7 @@ from .command import Command from .log_printer import LogPrinter +from docker.client import APIError from .errors import UserError from .docopt_command import NoSuchCommand @@ -43,6 +44,9 @@ def main(): log.error("") log.error("\n".join(parse_doc_section("commands:", getdoc(e.supercommand)))) exit(1) + except APIError, e: + log.error(e.explanation) + exit(1) # stolen from docopt master From bac37a19e305af6b1a505a56da3d0be242640131 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 19 Dec 2013 12:39:23 +0000 Subject: [PATCH 0065/4072] Fix method name in start() --- plum/cli/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plum/cli/main.py b/plum/cli/main.py index 2de1cd136c3..1db3873a25f 100644 --- a/plum/cli/main.py +++ b/plum/cli/main.py @@ -120,7 +120,7 @@ def start(self, options): if len(s.containers()) == 0: unstarted.append((s, s.create_container())) else: - running += s.get_containers(all=False) + running += s.containers(all=False) log_printer = LogPrinter(running + [c for (s, c) in unstarted]) From 9cf1d232b2fef4d47e2569a751d27633bb3faab3 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Thu, 19 Dec 2013 13:02:04 +0000 Subject: [PATCH 0066/4072] Better ps output --- plum/cli/main.py | 32 +++++++++++++++++++++++++------- plum/container.py | 31 +++++++++++++++++++++++++++++++ plum/service_collection.py | 6 ++++++ 3 files changed, 62 insertions(+), 7 deletions(-) diff --git a/plum/cli/main.py b/plum/cli/main.py index 1db3873a25f..688bb1800b7 100644 --- a/plum/cli/main.py +++ b/plum/cli/main.py @@ -10,6 +10,7 @@ from .. import __version__ from ..service_collection import ServiceCollection from .command import Command +from .formatter import Formatter from .log_printer import LogPrinter from docker.client import APIError @@ -78,10 +79,30 @@ def ps(self, options): """ List services and containers. - Usage: ps + Usage: ps [options] + + Options: + -q Only display IDs """ - for container in self._get_containers(all=False): - print container.name + if options['-q']: + for container in self.service_collection.containers(all=True): + print container.id + else: + headers = [ + 'Name', + 'Command', + 'State', + 'Ports', + ] + rows = [] + for container in self.service_collection.containers(all=True): + rows.append([ + container.name, + container.human_readable_command, + container.human_readable_state, + container.human_readable_ports, + ]) + print Formatter().table(headers, rows) def run(self, options): """ @@ -146,13 +167,10 @@ def logs(self, options): Usage: logs """ - containers = self._get_containers(all=False) + containers = self.service_collection.containers(all=False) print "Attaching to", list_containers(containers) LogPrinter(containers, attach_params={'logs': True}).run() - def _get_containers(self, all): - return [c for s in self.service_collection for c in s.containers(all=all)] - def list_containers(containers): return ", ".join(c.name for c in containers) diff --git a/plum/container.py b/plum/container.py index abd1c3f5559..eb65fc12a0f 100644 --- a/plum/container.py +++ b/plum/container.py @@ -37,10 +37,41 @@ def create(cls, client, **options): def id(self): return self.dictionary['ID'] + @property + def short_id(self): + return self.id[:10] + @property def name(self): return self.dictionary['Name'] + @property + def human_readable_ports(self): + self.inspect_if_not_inspected() + if not self.dictionary['NetworkSettings']['Ports']: + return '' + ports = [] + for private, public in self.dictionary['NetworkSettings']['Ports'].items(): + if public: + ports.append('%s->%s' % (public[0]['HostPort'], private)) + return ', '.join(ports) + + @property + def human_readable_state(self): + self.inspect_if_not_inspected() + if self.dictionary['State']['Running']: + if self.dictionary['State']['Ghost']: + return 'Ghost' + else: + return 'Up' + else: + return 'Exit %s' % self.dictionary['State']['ExitCode'] + + @property + def human_readable_command(self): + self.inspect_if_not_inspected() + return ' '.join(self.dictionary['Config']['Cmd']) + @property def environment(self): self.inspect_if_not_inspected() diff --git a/plum/service_collection.py b/plum/service_collection.py index 3c59c6ed599..a72835b0f0a 100644 --- a/plum/service_collection.py +++ b/plum/service_collection.py @@ -50,5 +50,11 @@ def stop(self): for service in self: service.stop() + def containers(self, *args, **kwargs): + l = [] + for service in self: + for container in service.containers(*args, **kwargs): + l.append(container) + return l From 9e9a20b227145cd3c0ce274d187270c3044ce28e Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 19 Dec 2013 13:02:42 +0000 Subject: [PATCH 0067/4072] Remove unused imports --- plum/cli/main.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/plum/cli/main.py b/plum/cli/main.py index 688bb1800b7..0e94f438718 100644 --- a/plum/cli/main.py +++ b/plum/cli/main.py @@ -1,14 +1,9 @@ -import datetime import logging -import sys -import os import re -from docopt import docopt from inspect import getdoc from .. import __version__ -from ..service_collection import ServiceCollection from .command import Command from .formatter import Formatter from .log_printer import LogPrinter From 9f1d08c54be21487476a9e7b5779bc05ff862ee3 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 19 Dec 2013 13:06:26 +0000 Subject: [PATCH 0068/4072] Implement --version flag --- plum/cli/docopt_command.py | 5 ++++- plum/cli/main.py | 6 ++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/plum/cli/docopt_command.py b/plum/cli/docopt_command.py index 0a11d8eea5b..93cf889eb06 100644 --- a/plum/cli/docopt_command.py +++ b/plum/cli/docopt_command.py @@ -12,6 +12,9 @@ def docopt_full_help(docstring, *args, **kwargs): class DocoptCommand(object): + def docopt_options(self): + return {'options_first': True} + def sys_dispatch(self): self.dispatch(sys.argv[1:], None) @@ -22,7 +25,7 @@ def perform_command(self, options, command, handler, command_options): handler(command_options) def parse(self, argv, global_options): - options = docopt_full_help(getdoc(self), argv, options_first=True) + options = docopt_full_help(getdoc(self), argv, **self.docopt_options()) command = options['COMMAND'] if not hasattr(self, command): diff --git a/plum/cli/main.py b/plum/cli/main.py index 0e94f438718..0e0cb8a5382 100644 --- a/plum/cli/main.py +++ b/plum/cli/main.py @@ -1,4 +1,5 @@ import logging +import sys import re from inspect import getdoc @@ -70,6 +71,11 @@ class TopLevelCommand(Command): stop Stop services """ + def docopt_options(self): + options = super(TopLevelCommand, self).docopt_options() + options['version'] = "plum %s" % __version__ + return options + def ps(self, options): """ List services and containers. From 6c551a200b7abf49cd0574847e7f746ecf6fb27a Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Thu, 19 Dec 2013 14:47:43 +0000 Subject: [PATCH 0069/4072] Do not allow underscores in names --- plum/service.py | 2 +- tests/service_test.py | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/plum/service.py b/plum/service.py index 14e2d8aca79..3d6465f1265 100644 --- a/plum/service.py +++ b/plum/service.py @@ -13,7 +13,7 @@ class BuildError(Exception): class Service(object): def __init__(self, name, client=None, links=[], **options): - if not re.match('^[a-zA-Z0-9_]+$', name): + if not re.match('^[a-zA-Z0-9]+$', name): raise ValueError('Invalid name: %s' % name) if 'image' in options and 'build' in options: raise ValueError('Service %s has both an image and build path specified. A service can either be built to image or use an existing image, not both.' % name) diff --git a/tests/service_test.py b/tests/service_test.py index 62d53e563b2..51559b4bdde 100644 --- a/tests/service_test.py +++ b/tests/service_test.py @@ -10,13 +10,13 @@ def test_name_validations(self): self.assertRaises(ValueError, lambda: Service(name='/')) self.assertRaises(ValueError, lambda: Service(name='!')) self.assertRaises(ValueError, lambda: Service(name='\xe2')) + self.assertRaises(ValueError, lambda: Service(name='_')) + self.assertRaises(ValueError, lambda: Service(name='____')) + self.assertRaises(ValueError, lambda: Service(name='foo_bar')) + self.assertRaises(ValueError, lambda: Service(name='__foo_bar__')) Service('a') Service('foo') - Service('foo_bar') - Service('__foo_bar__') - Service('_') - Service('_____') def test_containers(self): foo = self.create_service('foo') @@ -38,7 +38,7 @@ def test_containers(self): self.assertIn('/bar_2', names) def test_up_scale_down(self): - service = self.create_service('scaling_test') + service = self.create_service('scalingtest') self.assertEqual(len(service.containers()), 0) service.start() From c4887106258a0d1c02f22ddfcfe05bcc5f70e0f2 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Thu, 19 Dec 2013 15:16:17 +0000 Subject: [PATCH 0070/4072] Add project option to services --- plum/service.py | 28 ++++++++++++++++------------ tests/service_test.py | 17 +++++++++++++---- 2 files changed, 29 insertions(+), 16 deletions(-) diff --git a/plum/service.py b/plum/service.py index 3d6465f1265..26ebb16722c 100644 --- a/plum/service.py +++ b/plum/service.py @@ -12,14 +12,17 @@ class BuildError(Exception): class Service(object): - def __init__(self, name, client=None, links=[], **options): + def __init__(self, name, client=None, project='default', links=[], **options): if not re.match('^[a-zA-Z0-9]+$', name): raise ValueError('Invalid name: %s' % name) + if not re.match('^[a-zA-Z0-9]+$', project): + raise ValueError('Invalid project: %s' % project) if 'image' in options and 'build' in options: raise ValueError('Service %s has both an image and build path specified. A service can either be built to image or use an existing image, not both.' % name) self.name = name self.client = client + self.project = project self.links = links or [] self.options = options @@ -27,7 +30,10 @@ def containers(self, all=False): l = [] for container in self.client.containers(all=all): name = get_container_name(container) - if is_valid_name(name) and parse_name(name)[0] == self.name: + if not is_valid_name(name): + continue + project, name, number = parse_name(name) + if project == self.project and name == self.name: l.append(Container.from_ps(self.client, container)) return l @@ -91,8 +97,11 @@ def stop_container(self): container.kill() container.remove() + def next_container_name(self): + return '%s_%s_%s' % (self.project, self.name, self.next_container_number()) + def next_container_number(self): - numbers = [parse_name(c.name)[1] for c in self.containers(all=True)] + numbers = [parse_name(c.name)[2] for c in self.containers(all=True)] if len(numbers) == 0: return 1 @@ -111,8 +120,7 @@ def _get_container_options(self, override_options): container_options = dict((k, self.options[k]) for k in keys if k in self.options) container_options.update(override_options) - number = self.next_container_number() - container_options['name'] = make_name(self.name, number) + container_options['name'] = self.next_container_name() if 'ports' in container_options: container_options['ports'] = [unicode(p).split(':')[0] for p in container_options['ports']] @@ -142,11 +150,7 @@ def build(self): return image_id -name_regex = '^(.+)_(\d+)$' - - -def make_name(prefix, number): - return '%s_%s' % (prefix, number) +name_regex = '^([^_]+)_([^_]+)_(\d+)$' def is_valid_name(name): @@ -155,8 +159,8 @@ def is_valid_name(name): def parse_name(name): match = re.match(name_regex, name) - (service_name, suffix) = match.groups() - return (service_name, int(suffix)) + (project, service_name, suffix) = match.groups() + return (project, service_name, int(suffix)) def get_container_name(container): diff --git a/tests/service_test.py b/tests/service_test.py index 51559b4bdde..7ad890213fa 100644 --- a/tests/service_test.py +++ b/tests/service_test.py @@ -18,6 +18,10 @@ def test_name_validations(self): Service('a') Service('foo') + def test_project_validation(self): + self.assertRaises(ValueError, lambda: Service(name='foo', project='_')) + Service(name='foo', project='bar') + def test_containers(self): foo = self.create_service('foo') bar = self.create_service('bar') @@ -25,7 +29,7 @@ def test_containers(self): foo.start() self.assertEqual(len(foo.containers()), 1) - self.assertEqual(foo.containers()[0].name, '/foo_1') + self.assertEqual(foo.containers()[0].name, '/default_foo_1') self.assertEqual(len(bar.containers()), 0) bar.scale(2) @@ -34,8 +38,13 @@ def test_containers(self): self.assertEqual(len(bar.containers()), 2) names = [c.name for c in bar.containers()] - self.assertIn('/bar_1', names) - self.assertIn('/bar_2', names) + self.assertIn('/default_bar_1', names) + self.assertIn('/default_bar_2', names) + + def test_project_is_added_to_container_name(self): + service = self.create_service('web', project='myproject') + service.start() + self.assertEqual(service.containers()[0].name, '/myproject_web_1') def test_up_scale_down(self): service = self.create_service('scalingtest') @@ -74,7 +83,7 @@ def test_start_container_creates_links(self): web = self.create_service('web', links=[db]) db.start_container() web.start_container() - self.assertIn('db_1', web.containers()[0].links()) + self.assertIn('default_db_1', web.containers()[0].links()) db.stop() web.stop() From d6db049b421407ae67ee3ed214f9e4094e7aa43d Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Thu, 19 Dec 2013 15:32:24 +0000 Subject: [PATCH 0071/4072] Generate project name based on current dir --- plum/cli/command.py | 15 ++++++++++++++- plum/service_collection.py | 8 ++++---- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/plum/cli/command.py b/plum/cli/command.py index e59d1869f41..78e28730d19 100644 --- a/plum/cli/command.py +++ b/plum/cli/command.py @@ -1,6 +1,7 @@ from docker import Client import logging import os +import re import yaml from ..service_collection import ServiceCollection @@ -21,7 +22,19 @@ def client(self): @cached_property def service_collection(self): config = yaml.load(open('plum.yml')) - return ServiceCollection.from_config(self.client, config) + return ServiceCollection.from_config( + config, + client=self.client, + project=self.project + ) + + @cached_property + def project(self): + project = os.path.basename(os.getcwd()) + project = re.sub(r'[^a-zA-Z0-9]', '', project) + if not project: + project = 'default' + return project @cached_property def formatter(self): diff --git a/plum/service_collection.py b/plum/service_collection.py index a72835b0f0a..feef3de5767 100644 --- a/plum/service_collection.py +++ b/plum/service_collection.py @@ -14,7 +14,7 @@ def cmp(x, y): class ServiceCollection(list): @classmethod - def from_dicts(cls, client, service_dicts): + def from_dicts(cls, service_dicts, client, project='default'): """ Construct a ServiceCollection from a list of dicts representing services. """ @@ -26,16 +26,16 @@ def from_dicts(cls, client, service_dicts): for name in service_dict.get('links', []): links.append(collection.get(name)) del service_dict['links'] - collection.append(Service(client=client, links=links, **service_dict)) + collection.append(Service(client=client, project=project, links=links, **service_dict)) return collection @classmethod - def from_config(cls, client, config): + def from_config(cls, config, client, project='default'): dicts = [] for name, service in config.items(): service['name'] = name dicts.append(service) - return cls.from_dicts(client, dicts) + return cls.from_dicts(dicts, client, project) def get(self, name): for service in self: From 818728b825320e77760afeda55cf2e13f0abb733 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 19 Dec 2013 15:53:39 +0000 Subject: [PATCH 0072/4072] Mount volumes --- plum/service.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/plum/service.py b/plum/service.py index 26ebb16722c..5371bd5787d 100644 --- a/plum/service.py +++ b/plum/service.py @@ -1,6 +1,7 @@ from docker.client import APIError import logging import re +import os import sys from .container import Container @@ -84,10 +85,18 @@ def start_container(self, container=None, **override_options): else: port_bindings[int(port)] = None + volume_bindings = {} + + if options.get('volumes', None) is not None: + for volume in options['volumes']: + external_dir, internal_dir = volume.split(':') + volume_bindings[os.path.abspath(external_dir)] = internal_dir + log.info("Starting %s..." % container.name) container.start( links=self._get_links(), port_bindings=port_bindings, + binds=volume_bindings, ) return container @@ -125,6 +134,9 @@ def _get_container_options(self, override_options): if 'ports' in container_options: container_options['ports'] = [unicode(p).split(':')[0] for p in container_options['ports']] + if 'volumes' in container_options: + container_options['volumes'] = dict((v.split(':')[1], {}) for v in container_options['volumes']) + if 'build' in self.options: container_options['image'] = self.build() From 2d2d81d33fe31285db3578eb7202354e36ec17db Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Thu, 19 Dec 2013 16:55:12 +0000 Subject: [PATCH 0073/4072] Rename "service collection" to "project" --- plum/cli/command.py | 12 ++--- plum/cli/main.py | 16 +++--- plum/{service_collection.py => project.py} | 34 +++++++----- tests/project_test.py | 59 ++++++++++++++++++++ tests/service_collection_test.py | 62 ---------------------- 5 files changed, 92 insertions(+), 91 deletions(-) rename plum/{service_collection.py => project.py} (62%) create mode 100644 tests/project_test.py delete mode 100644 tests/service_collection_test.py diff --git a/plum/cli/command.py b/plum/cli/command.py index 78e28730d19..5bbed26bea2 100644 --- a/plum/cli/command.py +++ b/plum/cli/command.py @@ -4,7 +4,7 @@ import re import yaml -from ..service_collection import ServiceCollection +from ..project import Project from .docopt_command import DocoptCommand from .formatter import Formatter from .utils import cached_property, mkdir @@ -20,16 +20,12 @@ def client(self): return Client() @cached_property - def service_collection(self): + def project(self): config = yaml.load(open('plum.yml')) - return ServiceCollection.from_config( - config, - client=self.client, - project=self.project - ) + return Project.from_config(self.project_name, config, self.client) @cached_property - def project(self): + def project_name(self): project = os.path.basename(os.getcwd()) project = re.sub(r'[^a-zA-Z0-9]', '', project) if not project: diff --git a/plum/cli/main.py b/plum/cli/main.py index 0e0cb8a5382..05b90c8ffee 100644 --- a/plum/cli/main.py +++ b/plum/cli/main.py @@ -86,7 +86,7 @@ def ps(self, options): -q Only display IDs """ if options['-q']: - for container in self.service_collection.containers(all=True): + for container in self.project.containers(all=True): print container.id else: headers = [ @@ -96,7 +96,7 @@ def ps(self, options): 'Ports', ] rows = [] - for container in self.service_collection.containers(all=True): + for container in self.project.containers(all=True): rows.append([ container.name, container.human_readable_command, @@ -111,7 +111,7 @@ def run(self, options): Usage: run SERVICE COMMAND [ARGS...] """ - service = self.service_collection.get(options['SERVICE']) + service = self.project.get_service(options['SERVICE']) if service is None: raise UserError("No such service: %s" % options['SERVICE']) container_options = { @@ -132,13 +132,13 @@ def start(self, options): Usage: start [-d] """ if options['-d']: - self.service_collection.start() + self.project.start() return running = [] unstarted = [] - for s in self.service_collection: + for s in self.project.services: if len(s.containers()) == 0: unstarted.append((s, s.create_container())) else: @@ -152,7 +152,7 @@ def start(self, options): try: log_printer.run() finally: - self.service_collection.stop() + self.project.stop() def stop(self, options): """ @@ -160,7 +160,7 @@ def stop(self, options): Usage: stop """ - self.service_collection.stop() + self.project.stop() def logs(self, options): """ @@ -168,7 +168,7 @@ def logs(self, options): Usage: logs """ - containers = self.service_collection.containers(all=False) + containers = self.project.containers(all=False) print "Attaching to", list_containers(containers) LogPrinter(containers, attach_params={'logs': True}).run() diff --git a/plum/service_collection.py b/plum/project.py similarity index 62% rename from plum/service_collection.py rename to plum/project.py index feef3de5767..f4c1d0186ef 100644 --- a/plum/service_collection.py +++ b/plum/project.py @@ -12,47 +12,55 @@ def cmp(x, y): return 0 return sorted(services, cmp=cmp) -class ServiceCollection(list): +class Project(object): + """ + A collection of services. + """ + def __init__(self, name, services, client): + self.name = name + self.services = services + self.client = client + @classmethod - def from_dicts(cls, service_dicts, client, project='default'): + def from_dicts(cls, name, service_dicts, client): """ Construct a ServiceCollection from a list of dicts representing services. """ - collection = ServiceCollection() + project = cls(name, [], client) for service_dict in sort_service_dicts(service_dicts): # Reference links by object links = [] if 'links' in service_dict: for name in service_dict.get('links', []): - links.append(collection.get(name)) + links.append(project.get_service(name)) del service_dict['links'] - collection.append(Service(client=client, project=project, links=links, **service_dict)) - return collection + project.services.append(Service(client=client, project=name, links=links, **service_dict)) + return project @classmethod - def from_config(cls, config, client, project='default'): + def from_config(cls, name, config, client): dicts = [] for name, service in config.items(): service['name'] = name dicts.append(service) - return cls.from_dicts(dicts, client, project) + return cls.from_dicts(name, dicts, client) - def get(self, name): - for service in self: + def get_service(self, name): + for service in self.services: if service.name == name: return service def start(self): - for service in self: + for service in self.services: service.start() def stop(self): - for service in self: + for service in self.services: service.stop() def containers(self, *args, **kwargs): l = [] - for service in self: + for service in self.services: for container in service.containers(*args, **kwargs): l.append(container) return l diff --git a/tests/project_test.py b/tests/project_test.py new file mode 100644 index 00000000000..aa56b407592 --- /dev/null +++ b/tests/project_test.py @@ -0,0 +1,59 @@ +from plum.project import Project +from plum.service import Service +from .testcases import DockerClientTestCase + + +class ProjectTest(DockerClientTestCase): + def test_from_dict(self): + project = Project.from_dicts('test', [ + { + 'name': 'web', + 'image': 'ubuntu' + }, + { + 'name': 'db', + 'image': 'ubuntu' + } + ], self.client) + self.assertEqual(len(project.services), 2) + self.assertEqual(project.get_service('web').name, 'web') + self.assertEqual(project.get_service('web').options['image'], 'ubuntu') + self.assertEqual(project.get_service('db').name, 'db') + self.assertEqual(project.get_service('db').options['image'], 'ubuntu') + + def test_from_dict_sorts_in_dependency_order(self): + project = Project.from_dicts('test', [ + { + 'name': 'web', + 'image': 'ubuntu', + 'links': ['db'], + }, + { + 'name': 'db', + 'image': 'ubuntu' + } + ], self.client) + + self.assertEqual(project.services[0].name, 'db') + self.assertEqual(project.services[1].name, 'web') + + def test_get_service(self): + web = self.create_service('web') + project = Project('test', [web], self.client) + self.assertEqual(project.get_service('web'), web) + + def test_start_stop(self): + project = Project('test', [ + self.create_service('web'), + self.create_service('db'), + ], self.client) + + project.start() + + self.assertEqual(len(project.get_service('web').containers()), 1) + self.assertEqual(len(project.get_service('db').containers()), 1) + + project.stop() + + self.assertEqual(len(project.get_service('web').containers()), 0) + self.assertEqual(len(project.get_service('db').containers()), 0) diff --git a/tests/service_collection_test.py b/tests/service_collection_test.py deleted file mode 100644 index 7dbffdcee8b..00000000000 --- a/tests/service_collection_test.py +++ /dev/null @@ -1,62 +0,0 @@ -from plum.service import Service -from plum.service_collection import ServiceCollection -from .testcases import DockerClientTestCase - - -class ServiceCollectionTest(DockerClientTestCase): - def test_from_dict(self): - collection = ServiceCollection.from_dicts(None, [ - { - 'name': 'web', - 'image': 'ubuntu' - }, - { - 'name': 'db', - 'image': 'ubuntu' - } - ]) - self.assertEqual(len(collection), 2) - self.assertEqual(collection.get('web').name, 'web') - self.assertEqual(collection.get('web').options['image'], 'ubuntu') - self.assertEqual(collection.get('db').name, 'db') - self.assertEqual(collection.get('db').options['image'], 'ubuntu') - - def test_from_dict_sorts_in_dependency_order(self): - collection = ServiceCollection.from_dicts(None, [ - { - 'name': 'web', - 'image': 'ubuntu', - 'links': ['db'], - }, - { - 'name': 'db', - 'image': 'ubuntu' - } - ]) - - self.assertEqual(collection[0].name, 'db') - self.assertEqual(collection[1].name, 'web') - - def test_get(self): - web = self.create_service('web') - collection = ServiceCollection([web]) - self.assertEqual(collection.get('web'), web) - - def test_start_stop(self): - collection = ServiceCollection([ - self.create_service('web'), - self.create_service('db'), - ]) - - collection.start() - - self.assertEqual(len(collection[0].containers()), 1) - self.assertEqual(len(collection[1].containers()), 1) - - collection.stop() - - self.assertEqual(len(collection[0].containers()), 0) - self.assertEqual(len(collection[1].containers()), 0) - - - From 5a46278f797665a078acc4f58f6d74dc81d46e8b Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Thu, 19 Dec 2013 16:56:58 +0000 Subject: [PATCH 0074/4072] Fix project name getting overridden with service --- plum/project.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/plum/project.py b/plum/project.py index f4c1d0186ef..7a72e4a6512 100644 --- a/plum/project.py +++ b/plum/project.py @@ -31,8 +31,8 @@ def from_dicts(cls, name, service_dicts, client): # Reference links by object links = [] if 'links' in service_dict: - for name in service_dict.get('links', []): - links.append(project.get_service(name)) + for service_name in service_dict.get('links', []): + links.append(project.get_service(service_name)) del service_dict['links'] project.services.append(Service(client=client, project=name, links=links, **service_dict)) return project @@ -40,8 +40,8 @@ def from_dicts(cls, name, service_dicts, client): @classmethod def from_config(cls, name, config, client): dicts = [] - for name, service in config.items(): - service['name'] = name + for service_name, service in config.items(): + service['name'] = service_name dicts.append(service) return cls.from_dicts(name, dicts, client) From bdf99cd443505d2996b5cbc79ac4bcf702a410f6 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Thu, 19 Dec 2013 18:20:48 +0000 Subject: [PATCH 0075/4072] Move log messages to container --- plum/container.py | 6 ++++++ plum/service.py | 2 -- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/plum/container.py b/plum/container.py index eb65fc12a0f..0bf6fccd22f 100644 --- a/plum/container.py +++ b/plum/container.py @@ -1,4 +1,6 @@ +import logging +log = logging.getLogger(__name__) class Container(object): """ @@ -82,15 +84,19 @@ def environment(self): return out def start(self, **options): + log.info("Starting %s..." % self.name) return self.client.start(self.id, **options) def stop(self): + log.info("Stopping %s..." % self.name) return self.client.stop(self.id) def kill(self): + log.info("Killing %s..." % self.name) return self.client.kill(self.id) def remove(self): + log.info("Removing %s..." % self.name) return self.client.remove_container(self.id) def inspect_if_not_inspected(self): diff --git a/plum/service.py b/plum/service.py index 5371bd5787d..4cec3999d6f 100644 --- a/plum/service.py +++ b/plum/service.py @@ -92,7 +92,6 @@ def start_container(self, container=None, **override_options): external_dir, internal_dir = volume.split(':') volume_bindings[os.path.abspath(external_dir)] = internal_dir - log.info("Starting %s..." % container.name) container.start( links=self._get_links(), port_bindings=port_bindings, @@ -102,7 +101,6 @@ def start_container(self, container=None, **override_options): def stop_container(self): container = self.containers()[-1] - log.info("Stopping and removing %s..." % container.name) container.kill() container.remove() From 68e4341fbf01c074851c3ebce475ef98adf17722 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Thu, 19 Dec 2013 20:09:54 +0000 Subject: [PATCH 0076/4072] Compile name regex --- plum/service.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plum/service.py b/plum/service.py index 4cec3999d6f..de1d5440944 100644 --- a/plum/service.py +++ b/plum/service.py @@ -160,15 +160,15 @@ def build(self): return image_id -name_regex = '^([^_]+)_([^_]+)_(\d+)$' +NAME_RE = re.compile(r'^([^_]+)_([^_]+)_(\d+)$') def is_valid_name(name): - return (re.match(name_regex, name) is not None) + return (NAME_RE.match(name) is not None) def parse_name(name): - match = re.match(name_regex, name) + match = NAME_RE.match(name) (project, service_name, suffix) = match.groups() return (project, service_name, int(suffix)) From 2f28265d1078b2fe49527a5689ec60f39f933193 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Fri, 20 Dec 2013 10:46:55 +0000 Subject: [PATCH 0077/4072] Add support for differentiating one-off containers This is a basic start, the API is pretty shonky. --- plum/cli/main.py | 12 +++++++----- plum/container.py | 8 ++++++++ plum/service.py | 39 ++++++++++++++++++++++++--------------- tests/service_test.py | 11 +++++++++++ 4 files changed, 50 insertions(+), 20 deletions(-) diff --git a/plum/cli/main.py b/plum/cli/main.py index 05b90c8ffee..0fd68656fe9 100644 --- a/plum/cli/main.py +++ b/plum/cli/main.py @@ -85,8 +85,10 @@ def ps(self, options): Options: -q Only display IDs """ + containers = self.project.containers(stopped=True) + self.project.containers(one_off=True) + if options['-q']: - for container in self.project.containers(all=True): + for container in containers: print container.id else: headers = [ @@ -96,7 +98,7 @@ def ps(self, options): 'Ports', ] rows = [] - for container in self.project.containers(all=True): + for container in containers: rows.append([ container.name, container.human_readable_command, @@ -117,7 +119,7 @@ def run(self, options): container_options = { 'command': [options['COMMAND']] + options['ARGS'], } - container = service.create_container(**container_options) + container = service.create_container(one_off=True, **container_options) stream = container.logs(stream=True) service.start_container(container, ports=None) for data in stream: @@ -142,7 +144,7 @@ def start(self, options): if len(s.containers()) == 0: unstarted.append((s, s.create_container())) else: - running += s.containers(all=False) + running += s.containers(stopped=False) log_printer = LogPrinter(running + [c for (s, c) in unstarted]) @@ -168,7 +170,7 @@ def logs(self, options): Usage: logs """ - containers = self.project.containers(all=False) + containers = self.project.containers(stopped=False) print "Attaching to", list_containers(containers) LogPrinter(containers, attach_params={'logs': True}).run() diff --git a/plum/container.py b/plum/container.py index 0bf6fccd22f..61695216166 100644 --- a/plum/container.py +++ b/plum/container.py @@ -124,3 +124,11 @@ def links(self): def attach_socket(self, **kwargs): return self.client.attach_socket(self.id, **kwargs) + + def __repr__(self): + return '' % self.name + + def __eq__(self, other): + if type(self) != type(other): + return False + return self.id == other.id diff --git a/plum/service.py b/plum/service.py index de1d5440944..486015e1de2 100644 --- a/plum/service.py +++ b/plum/service.py @@ -27,11 +27,11 @@ def __init__(self, name, client=None, project='default', links=[], **options): self.links = links or [] self.options = options - def containers(self, all=False): + def containers(self, stopped=False, one_off=False): l = [] - for container in self.client.containers(all=all): + for container in self.client.containers(all=stopped): name = get_container_name(container) - if not is_valid_name(name): + if not is_valid_name(name, one_off): continue project, name, number = parse_name(name) if project == self.project and name == self.name: @@ -52,12 +52,12 @@ def scale(self, num): while len(self.containers()) > num: self.stop_container() - def create_container(self, **override_options): + def create_container(self, one_off=False, **override_options): """ Create a container for this service. If the image doesn't exist, attempt to pull it. """ - container_options = self._get_container_options(override_options) + container_options = self._get_container_options(override_options, one_off=one_off) try: return Container.create(self.client, **container_options) except APIError, e: @@ -104,11 +104,14 @@ def stop_container(self): container.kill() container.remove() - def next_container_name(self): - return '%s_%s_%s' % (self.project, self.name, self.next_container_number()) + def next_container_name(self, one_off=False): + bits = [self.project, self.name] + if one_off: + bits.append('run') + return '_'.join(bits + [unicode(self.next_container_number())]) def next_container_number(self): - numbers = [parse_name(c.name)[2] for c in self.containers(all=True)] + numbers = [parse_name(c.name)[2] for c in self.containers(stopped=True)] if len(numbers) == 0: return 1 @@ -122,12 +125,12 @@ def _get_links(self): links[container.name[1:]] = container.name[1:] return links - def _get_container_options(self, override_options): + def _get_container_options(self, override_options, one_off=False): keys = ['image', 'command', 'hostname', 'user', 'detach', 'stdin_open', 'tty', 'mem_limit', 'ports', 'environment', 'dns', 'volumes', 'volumes_from'] container_options = dict((k, self.options[k]) for k in keys if k in self.options) container_options.update(override_options) - container_options['name'] = self.next_container_name() + container_options['name'] = self.next_container_name(one_off) if 'ports' in container_options: container_options['ports'] = [unicode(p).split(':')[0] for p in container_options['ports']] @@ -160,16 +163,22 @@ def build(self): return image_id -NAME_RE = re.compile(r'^([^_]+)_([^_]+)_(\d+)$') +NAME_RE = re.compile(r'^([^_]+)_([^_]+)_(run_)?(\d+)$') -def is_valid_name(name): - return (NAME_RE.match(name) is not None) +def is_valid_name(name, one_off=False): + match = NAME_RE.match(name) + if match is None: + return False + if one_off: + return match.group(3) == 'run_' + else: + return match.group(3) is None -def parse_name(name): +def parse_name(name, one_off=False): match = NAME_RE.match(name) - (project, service_name, suffix) = match.groups() + (project, service_name, _, suffix) = match.groups() return (project, service_name, int(suffix)) diff --git a/tests/service_test.py b/tests/service_test.py index 7ad890213fa..c09e17e361a 100644 --- a/tests/service_test.py +++ b/tests/service_test.py @@ -41,6 +41,12 @@ def test_containers(self): self.assertIn('/default_bar_1', names) self.assertIn('/default_bar_2', names) + def test_containers_one_off(self): + 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]) + def test_project_is_added_to_container_name(self): service = self.create_service('web', project='myproject') service.start() @@ -68,6 +74,11 @@ def test_up_scale_down(self): service.stop() self.assertEqual(len(service.containers()), 0) + def test_create_container_with_one_off(self): + db = self.create_service('db') + container = db.create_container(one_off=True) + self.assertEqual(container.name, '/default_db_run_1') + def test_start_container_passes_through_options(self): db = self.create_service('db') db.start_container(environment={'FOO': 'BAR'}) From ea09ec672c1f6c819ed25a4d886c84386f6729e2 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Fri, 20 Dec 2013 10:53:07 +0000 Subject: [PATCH 0078/4072] Add detached mode to run --- plum/cli/main.py | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/plum/cli/main.py b/plum/cli/main.py index 0fd68656fe9..475cb4070fa 100644 --- a/plum/cli/main.py +++ b/plum/cli/main.py @@ -111,7 +111,10 @@ def run(self, options): """ Run a one-off command. - Usage: run SERVICE COMMAND [ARGS...] + Usage: run [options] SERVICE COMMAND [ARGS...] + + Options: + -d Detached mode: Run container in the background, print new container name """ service = self.project.get_service(options['SERVICE']) if service is None: @@ -120,18 +123,25 @@ def run(self, options): 'command': [options['COMMAND']] + options['ARGS'], } container = service.create_container(one_off=True, **container_options) - stream = container.logs(stream=True) - service.start_container(container, ports=None) - for data in stream: - if data is None: - break - print data + if options['-d']: + service.start_container(container, ports=None) + print container.name + else: + stream = container.logs(stream=True) + service.start_container(container, ports=None) + for data in stream: + if data is None: + break + print data def start(self, options): """ Start all services - Usage: start [-d] + Usage: start [options] + + Options: + -d Detached mode: Run containers in the background, print new container names """ if options['-d']: self.project.start() From 4a729fe47f2658011942ea8f0b6a39eaa6fe3d57 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Fri, 20 Dec 2013 10:57:28 +0000 Subject: [PATCH 0079/4072] Document logs command --- plum/cli/main.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plum/cli/main.py b/plum/cli/main.py index 475cb4070fa..80646e33932 100644 --- a/plum/cli/main.py +++ b/plum/cli/main.py @@ -65,6 +65,7 @@ class TopLevelCommand(Command): --version Print version and exit Commands: + logs View output from containers ps List services and containers run Run a one-off command start Start services @@ -176,7 +177,7 @@ def stop(self, options): def logs(self, options): """ - View containers' output + View output from containers Usage: logs """ From aa7a5a1487aeed751282eba3cc6f090acd30328a Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 20 Dec 2013 10:52:46 +0000 Subject: [PATCH 0080/4072] Small refactor for clarity --- plum/cli/log_printer.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plum/cli/log_printer.py b/plum/cli/log_printer.py index 653c0f50bd3..e7f8eac872a 100644 --- a/plum/cli/log_printer.py +++ b/plum/cli/log_printer.py @@ -28,8 +28,8 @@ def _make_log_generators(self): return generators def _make_log_generator(self, container, color_fn): - format = lambda line: color_fn(container.name + " | ") + line - return (format(line) for line in self._readlines(self._attach(container))) + prefix = color_fn(container.name + " | ") + return (prefix + line for line in self._readlines(self._attach(container))) def _attach(self, container): params = { From 86e551f2e29c8d21b5fb9da62b4bacc9d39f5a99 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 20 Dec 2013 11:08:40 +0000 Subject: [PATCH 0081/4072] Attach with websocket and do manual line buffering This works around the odd byte sequences we see at the beginning of every chunk when attaching via the streaming HTTP endpoint and a plain socket. --- plum/cli/log_printer.py | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/plum/cli/log_printer.py b/plum/cli/log_printer.py index e7f8eac872a..480fc5ebafd 100644 --- a/plum/cli/log_printer.py +++ b/plum/cli/log_printer.py @@ -29,7 +29,8 @@ def _make_log_generators(self): def _make_log_generator(self, container, color_fn): prefix = color_fn(container.name + " | ") - return (prefix + line for line in self._readlines(self._attach(container))) + websocket = self._attach(container) + return (prefix + line for line in split_buffer(read_websocket(websocket), '\n')) def _attach(self, container): params = { @@ -41,13 +42,25 @@ def _attach(self, container): } params.update(self.attach_params) params = dict((name, 1 if value else 0) for (name, value) in params.items()) - return container.attach_socket(params=params) + return container.attach_socket(params=params, ws=True) - def _readlines(self, socket): - for line in iter(socket.makefile().readline, b''): - if not line.endswith('\n'): - line += '\n' +def read_websocket(websocket): + while True: + data = websocket.recv() + if data: + yield data + else: + break - yield line +def split_buffer(reader, separator): + buffered = '' - socket.close() + for data in reader: + lines = (buffered + data).split(separator) + for line in lines[:-1]: + yield line + separator + if len(lines) > 1: + buffered = lines[-1] + + if len(buffered) > 0: + yield buffered From 15f12c6e2c8c18a7d5912114f869fdd82df28950 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Fri, 20 Dec 2013 12:51:20 +0000 Subject: [PATCH 0082/4072] Ignore containers without names --- plum/service.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/plum/service.py b/plum/service.py index 486015e1de2..8b829ace1d1 100644 --- a/plum/service.py +++ b/plum/service.py @@ -31,7 +31,7 @@ def containers(self, stopped=False, one_off=False): l = [] for container in self.client.containers(all=stopped): name = get_container_name(container) - if not is_valid_name(name, one_off): + if not name or not is_valid_name(name, one_off): continue project, name, number = parse_name(name) if project == self.project and name == self.name: @@ -183,6 +183,8 @@ def parse_name(name, one_off=False): def get_container_name(container): + if not container.get('Name') and not container.get('Names'): + return None # inspect if 'Name' in container: return container['Name'] From 326438b1706f62c74b703813d6fdd5ec0253b3ba Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Fri, 20 Dec 2013 12:55:45 +0000 Subject: [PATCH 0083/4072] Pick correct numbers for one off containers --- plum/service.py | 6 +++--- tests/service_test.py | 6 ++++++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/plum/service.py b/plum/service.py index 8b829ace1d1..6fab273b62f 100644 --- a/plum/service.py +++ b/plum/service.py @@ -108,10 +108,10 @@ def next_container_name(self, one_off=False): bits = [self.project, self.name] if one_off: bits.append('run') - return '_'.join(bits + [unicode(self.next_container_number())]) + return '_'.join(bits + [unicode(self.next_container_number(one_off=one_off))]) - def next_container_number(self): - numbers = [parse_name(c.name)[2] for c in self.containers(stopped=True)] + def next_container_number(self, one_off=False): + numbers = [parse_name(c.name)[2] for c in self.containers(stopped=True, one_off=one_off)] if len(numbers) == 0: return 1 diff --git a/tests/service_test.py b/tests/service_test.py index c09e17e361a..2fdfba7de0e 100644 --- a/tests/service_test.py +++ b/tests/service_test.py @@ -79,6 +79,12 @@ def test_create_container_with_one_off(self): container = db.create_container(one_off=True) self.assertEqual(container.name, '/default_db_run_1') + def test_create_container_with_one_off_when_existing_container_is_running(self): + db = self.create_service('db') + db.start() + container = db.create_container(one_off=True) + self.assertEqual(container.name, '/default_db_run_1') + def test_start_container_passes_through_options(self): db = self.create_service('db') db.start_container(environment={'FOO': 'BAR'}) From abfb3b800f7bcd5380189a34db8629270d4708e5 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Fri, 20 Dec 2013 15:03:01 +0000 Subject: [PATCH 0084/4072] Interactive plum run --- plum/cli/main.py | 48 +++++++++++++-- plum/cli/socketclient.py | 129 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 171 insertions(+), 6 deletions(-) create mode 100644 plum/cli/socketclient.py diff --git a/plum/cli/main.py b/plum/cli/main.py index 80646e33932..b3e4f11e67b 100644 --- a/plum/cli/main.py +++ b/plum/cli/main.py @@ -12,6 +12,7 @@ from docker.client import APIError from .errors import UserError from .docopt_command import NoSuchCommand +from .socketclient import SocketClient log = logging.getLogger(__name__) @@ -122,18 +123,22 @@ def run(self, options): raise UserError("No such service: %s" % options['SERVICE']) container_options = { 'command': [options['COMMAND']] + options['ARGS'], + 'tty': not options['-d'], + 'stdin_open': not options['-d'], } container = service.create_container(one_off=True, **container_options) if options['-d']: service.start_container(container, ports=None) print container.name else: - stream = container.logs(stream=True) - service.start_container(container, ports=None) - for data in stream: - if data is None: - break - print data + with self._attach_to_container( + container.id, + interactive=True, + logs=True, + raw=True + ) as c: + service.start_container(container, ports=None) + c.run() def start(self, options): """ @@ -185,6 +190,37 @@ def logs(self, options): print "Attaching to", list_containers(containers) LogPrinter(containers, attach_params={'logs': True}).run() + def _attach_to_container(self, container_id, interactive, logs=False, stream=True, raw=False): + stdio = self.client.attach_socket( + container_id, + params={ + 'stdin': 1 if interactive else 0, + 'stdout': 1, + 'stderr': 0, + 'logs': 1 if logs else 0, + 'stream': 1 if stream else 0 + }, + ws=True, + ) + + stderr = self.client.attach_socket( + container_id, + params={ + 'stdin': 0, + 'stdout': 0, + 'stderr': 1, + 'logs': 1 if logs else 0, + 'stream': 1 if stream else 0 + }, + ws=True, + ) + + return SocketClient( + socket_in=stdio, + socket_out=stdio, + socket_err=stderr, + raw=raw, + ) def list_containers(containers): return ", ".join(c.name for c in containers) diff --git a/plum/cli/socketclient.py b/plum/cli/socketclient.py new file mode 100644 index 00000000000..90ed8b587fb --- /dev/null +++ b/plum/cli/socketclient.py @@ -0,0 +1,129 @@ +# Adapted from https://github.com/benthor/remotty/blob/master/socketclient.py + +from select import select +import sys +import tty +import fcntl +import os +import termios +import threading +import errno + +import logging +log = logging.getLogger(__name__) + + +class SocketClient: + def __init__(self, + socket_in=None, + socket_out=None, + socket_err=None, + raw=True, + ): + self.socket_in = socket_in + self.socket_out = socket_out + self.socket_err = socket_err + self.raw = raw + + self.stdin_fileno = sys.stdin.fileno() + + def __enter__(self): + self.create() + return self + + def __exit__(self, type, value, trace): + self.destroy() + + def create(self): + if os.isatty(sys.stdin.fileno()): + self.settings = termios.tcgetattr(sys.stdin.fileno()) + else: + self.settings = None + + if self.socket_in is not None: + self.set_blocking(sys.stdin, False) + self.set_blocking(sys.stdout, True) + self.set_blocking(sys.stderr, True) + + if self.raw: + tty.setraw(sys.stdin.fileno()) + + def set_blocking(self, file, blocking): + fd = file.fileno() + flags = fcntl.fcntl(fd, fcntl.F_GETFL) + flags = (flags & ~os.O_NONBLOCK) if blocking else (flags | os.O_NONBLOCK) + fcntl.fcntl(fd, fcntl.F_SETFL, flags) + + def run(self): + if self.socket_in is not None: + self.start_background_thread(target=self.send_ws, args=(self.socket_in, sys.stdin)) + + recv_threads = [] + + if self.socket_out is not None: + recv_threads.append(self.start_background_thread(target=self.recv_ws, args=(self.socket_out, sys.stdout))) + + if self.socket_err is not None: + recv_threads.append(self.start_background_thread(target=self.recv_ws, args=(self.socket_err, sys.stderr))) + + for t in recv_threads: + t.join() + + def start_background_thread(self, **kwargs): + thread = threading.Thread(**kwargs) + thread.daemon = True + thread.start() + return thread + + def recv_ws(self, socket, stream): + try: + while True: + chunk = socket.recv() + + if chunk: + stream.write(chunk) + stream.flush() + else: + break + except Exception, e: + log.debug(e) + + def send_ws(self, socket, stream): + while True: + r, w, e = select([stream.fileno()], [], []) + + if r: + chunk = stream.read(1) + + if chunk == '': + socket.send_close() + break + else: + try: + socket.send(chunk) + except Exception, e: + if hasattr(e, 'errno') and e.errno == errno.EPIPE: + break + else: + raise e + + def destroy(self): + if self.settings is not None: + termios.tcsetattr(self.stdin_fileno, termios.TCSADRAIN, self.settings) + + sys.stdout.flush() + +if __name__ == '__main__': + import websocket + + if len(sys.argv) != 2: + sys.stderr.write("Usage: python socketclient.py WEBSOCKET_URL\n") + exit(1) + + url = sys.argv[1] + socket = websocket.create_connection(url) + + print "connected\r" + + with SocketClient(socket, interactive=True) as client: + client.run() From 76b6354173d4e355a0171bc09c1a549d97e644b1 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Fri, 20 Dec 2013 16:18:44 +0000 Subject: [PATCH 0085/4072] Add requirements-dev.txt --- requirements-dev.txt | 1 + 1 file changed, 1 insertion(+) create mode 100644 requirements-dev.txt diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 00000000000..f3c7e8e6ffb --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1 @@ +nose From 507940535fbb8f3932287dfd313ce01d99354abe Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Fri, 20 Dec 2013 16:23:40 +0000 Subject: [PATCH 0086/4072] Tag built images and use them when starting A basic measure to get round the fact that adding isn't cached. Once Docker supports cached adds, this is probably redundant. --- plum/service.py | 16 ++++++++++++++-- tests/service_test.py | 12 ++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/plum/service.py b/plum/service.py index 6fab273b62f..0550a7c03d7 100644 --- a/plum/service.py +++ b/plum/service.py @@ -139,14 +139,20 @@ def _get_container_options(self, override_options, one_off=False): container_options['volumes'] = dict((v.split(':')[1], {}) for v in container_options['volumes']) if 'build' in self.options: - container_options['image'] = self.build() + if len(self.client.images(name=self._build_tag_name())) == 0: + self.build() + container_options['image'] = self._build_tag_name() return container_options def build(self): log.info('Building %s...' % self.name) - build_output = self.client.build(self.options['build'], stream=True) + build_output = self.client.build( + self.options['build'], + tag=self._build_tag_name(), + stream=True + ) image_id = None @@ -162,6 +168,12 @@ def build(self): return image_id + def _build_tag_name(self): + """ + The tag to give to images built for this service. + """ + return '%s_%s' % (self.project, self.name) + NAME_RE = re.compile(r'^([^_]+)_([^_]+)_(run_)?(\d+)$') diff --git a/tests/service_test.py b/tests/service_test.py index 2fdfba7de0e..3f84664dc75 100644 --- a/tests/service_test.py +++ b/tests/service_test.py @@ -113,6 +113,18 @@ def test_start_container_builds_images(self): container = service.start() container.wait() self.assertIn('success', container.logs()) + self.assertEqual(len(self.client.images(name='default_test')), 1) + + def test_start_container_uses_tagged_image_if_it_exists(self): + self.client.build('tests/fixtures/simple-dockerfile', tag='default_test') + service = Service( + name='test', + client=self.client, + build='this/does/not/exist/and/will/throw/error', + ) + container = service.start() + container.wait() + self.assertIn('success', container.logs()) def test_start_container_creates_ports(self): service = self.create_service('web', ports=[8000]) From 791028866c29f42e4c2aec9c13d5b6b3126195f0 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Fri, 20 Dec 2013 18:10:30 +0000 Subject: [PATCH 0087/4072] Update readme to reflect how it currently works --- README.md | 129 +++++++++++++++++++++++++++--------------------------- 1 file changed, 65 insertions(+), 64 deletions(-) diff --git a/README.md b/README.md index c91370cdf23..3ef299bfd66 100644 --- a/README.md +++ b/README.md @@ -3,15 +3,17 @@ Plum **WARNING**: This is a work in progress and probably won't work yet. Feedback welcome. -Plum is tool for defining and running apps with Docker. It uses a simple, version-controllable YAML configuration file that looks something like this: +Plum is tool for defining and running application environments with Docker. It uses a simple, version-controllable YAML configuration file that looks something like this: ```yaml +web: + build: . + links: + - db + ports: + - 8000:8000 db: image: orchardup/postgresql - -web: - build: web/ - link: db ``` Installing @@ -26,26 +28,20 @@ Defining your app Put a `plum.yml` in your app's directory. Each top-level key defines a "service", such as a web app, database or cache. For each service, Plum will start a Docker container, so at minimum it needs to know what image to use. -The way to get started is to just give it an image name: +The simplest way to get started is to just give it an image name: ```yaml db: image: orchardup/postgresql ``` -Alternatively, you can give it the location of a directory with a Dockerfile (or a Git URL, as per the `docker build` command), and it'll automatically build the image for you: - -```yaml -db: - build: /path/to/postgresql/build/directory -``` - You've now given Plum the minimal amount of configuration it needs to run: ```bash $ plum start -Building db... done -db is running at 127.0.0.1:45678 +Pulling image orchardup/postgresql... +Starting myapp_db_1... +myapp_db_1 is running at 127.0.0.1:45678 <...output from postgresql server...> ``` @@ -55,59 +51,76 @@ By default, `plum start` will run until each container has shut down, and relay ```bash $ plum start -d -Building db... done -db is running at 127.0.0.1:45678 +Starting myapp_db_1... done +myapp_db_1 is running at 127.0.0.1:45678 $ plum ps -SERVICE STATE PORT -db up 45678 +Name State Ports +------------------------------------ +myapp_db_1 Up 5432->45678/tcp ``` +### Building services -### Getting your code in +Plum can automatically build images for you if your service specifies a directory with a `Dockerfile` in it (or a Git URL, as per the `docker build` command). + +This example will build an image with `app.py` inside it: -Some services may include your own code. To get that code into the container, ADD it in a Dockerfile. +#### app.py + +```python +print "Hello world!" +``` -`plum.yml`: +#### plum.yaml ```yaml web: - build: web/ + build: . ``` -`web/Dockerfile`: +#### Dockerfile - FROM orchardup/rails - ADD . /code - CMD bundle exec rackup + FROM ubuntu:12.04 + RUN apt-get install python + ADD . /opt + WORKDIR /opt + CMD python app.py -### Communicating between containers -Your web app will probably need to talk to your database. You can use [Docker links] to enable containers to communicate, pass in the right IP address and port via environment variables: +### Getting your code in -```yaml -db: - image: orchardup/postgresql +If you want to work on an application being run by Plum, you probably don't want to have to rebuild your image every time you make a change. To solve this, you can share the directory with the container using a volume so the changes are reflected immediately: +```yaml web: - build: web/ - link: db + build: . + volumes: + - .:/opt ``` -This will pass an environment variable called DB_PORT into the web container, whose value will look like `tcp://172.17.0.4:45678`. Your web app's code can then use that to connect to the database. -You can pass in multiple links, too: +### Communicating between containers + +Your dweb app will probably need to talk to your database. You can use [Docker links](http://docs.docker.io/en/latest/use/port_redirection/#linking-a-container) to enable containers to communicate, pass in the right IP address and port via environment variables: ```yaml -link: - - db - - memcached - - redis +db: + image: orchardup/postgresql + +web: + build: . + links: + - db ``` +This will pass an environment variable called `MYAPP_DB_1_PORT` into the web container, whose value will look like `tcp://172.17.0.4:45678`. Your web app's code can use that to connect to the database. To see all of the environment variables available, run `env` inside a container: -In each case, the resulting environment variable will begin with the uppercased name of the linked service (`DB_PORT`, `MEMCACHED_PORT`, `REDIS_PORT`). +```bash +$ plum start -d db +$ plum run web env +``` ### Container configuration options @@ -116,18 +129,21 @@ You can pass extra configuration options to a container, much like with `docker ```yaml web: - build: web/ + build: . - -- override the default run command - run: bundle exec thin -p 3000 + -- override the default command + command: bundle exec thin -p 3000 - -- expose ports - can also be an array - ports: 3000 + -- expose ports, optionally specifying both host and container ports (a random host port will be chosen otherwise) + ports: + - 3000 + - 8000:8000 - -- map volumes - can also be an array - volumes: /tmp/cache + -- map volumes + volumes: + - cache/:/tmp/cache - -- add environment variables - can also be a dictionary + -- add environment variables environment: RACK_ENV: development ``` @@ -145,18 +161,3 @@ $ plum run web bash ``` -Running more than one container for a service ---------------------------------------------- - -You can set the number of containers to run for each service with `plum scale`: - -```bash -$ plum start -d -db is running at 127.0.0.1:45678 -web is running at 127.0.0.1:45679 - -$ plum scale db=0,web=3 -Stopped db (127.0.0.1:45678) -Started web (127.0.0.1:45680) -Started web (127.0.0.1:45681) -``` From 3bebd18de79946bb3ebc1127817f7824f851df9d Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 20 Dec 2013 12:09:07 +0000 Subject: [PATCH 0088/4072] Show help banner if no command given --- plum/cli/docopt_command.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/plum/cli/docopt_command.py b/plum/cli/docopt_command.py index 93cf889eb06..d2aeb035fcb 100644 --- a/plum/cli/docopt_command.py +++ b/plum/cli/docopt_command.py @@ -28,6 +28,9 @@ def parse(self, argv, global_options): options = docopt_full_help(getdoc(self), argv, **self.docopt_options()) command = options['COMMAND'] + if command is None: + raise SystemExit(getdoc(self)) + if not hasattr(self, command): raise NoSuchCommand(command, self) From a4710fa9e149dc570fc9588d0e18e85e9a388d94 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 20 Dec 2013 16:22:54 +0000 Subject: [PATCH 0089/4072] 'plum up' is now the special magic 'start' and 'stop' are now analogous to their Docker namesakes. --- plum/cli/main.py | 44 ++++++++++++++++++++---------------- plum/container.py | 9 ++++++-- plum/project.py | 24 ++++++++++++++++---- plum/service.py | 24 ++++++-------------- tests/project_test.py | 52 +++++++++++++++++++++++++++++++++++-------- tests/service_test.py | 35 ++++++++++++++--------------- 6 files changed, 119 insertions(+), 69 deletions(-) diff --git a/plum/cli/main.py b/plum/cli/main.py index b3e4f11e67b..7819df61d89 100644 --- a/plum/cli/main.py +++ b/plum/cli/main.py @@ -140,37 +140,43 @@ def run(self, options): service.start_container(container, ports=None) c.run() - def start(self, options): + def up(self, options): """ - Start all services + Create and start containers - Usage: start [options] + Usage: up [options] Options: -d Detached mode: Run containers in the background, print new container names """ - if options['-d']: - self.project.start() - return + detached = options['-d'] - running = [] - unstarted = [] + unstarted = self.project.create_containers() - for s in self.project.services: - if len(s.containers()) == 0: - unstarted.append((s, s.create_container())) - else: - running += s.containers(stopped=False) - - log_printer = LogPrinter(running + [c for (s, c) in unstarted]) + if not detached: + to_attach = self.project.containers() + [c for (s, c) in unstarted] + print "Attaching to", list_containers(to_attach) + log_printer = LogPrinter(to_attach, attach_params={'logs': True}) for (s, c) in unstarted: s.start_container(c) - try: - log_printer.run() - finally: - self.project.stop() + if detached: + for (s, c) in unstarted: + print c.name + else: + try: + log_printer.run() + finally: + self.project.kill_and_remove(unstarted) + + def start(self, options): + """ + Start all services + + Usage: start + """ + self.project.start() def stop(self, options): """ diff --git a/plum/container.py b/plum/container.py index 61695216166..5e025440062 100644 --- a/plum/container.py +++ b/plum/container.py @@ -83,13 +83,18 @@ def environment(self): out[k] = v return out + @property + def is_running(self): + self.inspect_if_not_inspected() + return self.dictionary['State']['Running'] + def start(self, **options): log.info("Starting %s..." % self.name) return self.client.start(self.id, **options) - def stop(self): + def stop(self, **options): log.info("Stopping %s..." % self.name) - return self.client.stop(self.id) + return self.client.stop(self.id, **options) def kill(self): log.info("Killing %s..." % self.name) diff --git a/plum/project.py b/plum/project.py index 7a72e4a6512..84761644165 100644 --- a/plum/project.py +++ b/plum/project.py @@ -50,13 +50,29 @@ def get_service(self, name): if service.name == name: return service - def start(self): + def create_containers(self): + """ + Returns a list of (service, container) tuples, + one for each service with no running containers. + """ + containers = [] + for service in self.services: + if len(service.containers()) == 0: + containers.append((service, service.create_container())) + return containers + + def kill_and_remove(self, tuples): + for (service, container) in tuples: + container.kill() + container.remove() + + def start(self, **options): for service in self.services: - service.start() + service.start(**options) - def stop(self): + def stop(self, **options): for service in self.services: - service.stop() + service.stop(**options) def containers(self, *args, **kwargs): l = [] diff --git a/plum/service.py b/plum/service.py index 0550a7c03d7..9caccc6ff86 100644 --- a/plum/service.py +++ b/plum/service.py @@ -38,19 +38,14 @@ def containers(self, stopped=False, one_off=False): l.append(Container.from_ps(self.client, container)) return l - def start(self): - if len(self.containers()) == 0: - return self.start_container() + def start(self, **options): + for c in self.containers(stopped=True): + if not c.is_running: + self.start_container(c, **options) - def stop(self): - self.scale(0) - - def scale(self, num): - while len(self.containers()) < num: - self.start_container() - - while len(self.containers()) > num: - self.stop_container() + def stop(self, **options): + for c in self.containers(): + c.stop(**options) def create_container(self, one_off=False, **override_options): """ @@ -99,11 +94,6 @@ def start_container(self, container=None, **override_options): ) return container - def stop_container(self): - container = self.containers()[-1] - container.kill() - container.remove() - def next_container_name(self, one_off=False): bits = [self.project, self.name] if one_off: diff --git a/tests/project_test.py b/tests/project_test.py index aa56b407592..c982990aebc 100644 --- a/tests/project_test.py +++ b/tests/project_test.py @@ -42,18 +42,52 @@ def test_get_service(self): project = Project('test', [web], self.client) self.assertEqual(project.get_service('web'), web) + def test_up(self): + web = self.create_service('web') + db = self.create_service('db') + project = Project('test', [web, db], self.client) + + web.create_container() + + self.assertEqual(len(web.containers()), 0) + self.assertEqual(len(db.containers()), 0) + self.assertEqual(len(web.containers(stopped=True)), 1) + self.assertEqual(len(db.containers(stopped=True)), 0) + + unstarted = project.create_containers() + self.assertEqual(len(unstarted), 2) + self.assertEqual(unstarted[0][0], web) + self.assertEqual(unstarted[1][0], db) + + self.assertEqual(len(web.containers()), 0) + self.assertEqual(len(db.containers()), 0) + self.assertEqual(len(web.containers(stopped=True)), 2) + self.assertEqual(len(db.containers(stopped=True)), 1) + + project.kill_and_remove(unstarted) + + self.assertEqual(len(web.containers()), 0) + self.assertEqual(len(db.containers()), 0) + self.assertEqual(len(web.containers(stopped=True)), 1) + self.assertEqual(len(db.containers(stopped=True)), 0) + def test_start_stop(self): - project = Project('test', [ - self.create_service('web'), - self.create_service('db'), - ], self.client) + web = self.create_service('web') + db = self.create_service('db') + project = Project('test', [web, db], self.client) + + project.start() + + self.assertEqual(len(web.containers()), 0) + self.assertEqual(len(db.containers()), 0) + web.create_container() project.start() - self.assertEqual(len(project.get_service('web').containers()), 1) - self.assertEqual(len(project.get_service('db').containers()), 1) + self.assertEqual(len(web.containers()), 1) + self.assertEqual(len(db.containers()), 0) - project.stop() + project.stop(timeout=1) - self.assertEqual(len(project.get_service('web').containers()), 0) - self.assertEqual(len(project.get_service('db').containers()), 0) + self.assertEqual(len(web.containers()), 0) + self.assertEqual(len(db.containers()), 0) diff --git a/tests/service_test.py b/tests/service_test.py index 3f84664dc75..7741963028b 100644 --- a/tests/service_test.py +++ b/tests/service_test.py @@ -26,13 +26,14 @@ def test_containers(self): foo = self.create_service('foo') bar = self.create_service('bar') - foo.start() + foo.start_container() self.assertEqual(len(foo.containers()), 1) self.assertEqual(foo.containers()[0].name, '/default_foo_1') self.assertEqual(len(bar.containers()), 0) - bar.scale(2) + bar.start_container() + bar.start_container() self.assertEqual(len(foo.containers()), 1) self.assertEqual(len(bar.containers()), 2) @@ -49,30 +50,28 @@ def test_containers_one_off(self): def test_project_is_added_to_container_name(self): service = self.create_service('web', project='myproject') - service.start() + service.start_container() self.assertEqual(service.containers()[0].name, '/myproject_web_1') - def test_up_scale_down(self): + def test_start_stop(self): service = self.create_service('scalingtest') - self.assertEqual(len(service.containers()), 0) + self.assertEqual(len(service.containers(stopped=True)), 0) - service.start() - self.assertEqual(len(service.containers()), 1) + service.create_container() + self.assertEqual(len(service.containers()), 0) + self.assertEqual(len(service.containers(stopped=True)), 1) service.start() self.assertEqual(len(service.containers()), 1) + self.assertEqual(len(service.containers(stopped=True)), 1) - service.scale(2) - self.assertEqual(len(service.containers()), 2) - - service.scale(1) - self.assertEqual(len(service.containers()), 1) - - service.stop() + service.stop(timeout=1) self.assertEqual(len(service.containers()), 0) + self.assertEqual(len(service.containers(stopped=True)), 1) - service.stop() + service.stop(timeout=1) self.assertEqual(len(service.containers()), 0) + self.assertEqual(len(service.containers(stopped=True)), 1) def test_create_container_with_one_off(self): db = self.create_service('db') @@ -101,8 +100,8 @@ def test_start_container_creates_links(self): db.start_container() web.start_container() self.assertIn('default_db_1', web.containers()[0].links()) - db.stop() - web.stop() + db.stop(timeout=1) + web.stop(timeout=1) def test_start_container_builds_images(self): service = Service( @@ -110,7 +109,7 @@ def test_start_container_builds_images(self): client=self.client, build='tests/fixtures/simple-dockerfile', ) - container = service.start() + container = service.start_container() container.wait() self.assertIn('success', container.logs()) self.assertEqual(len(self.client.images(name='default_test')), 1) From 81093627fe9b1ed9be947a0178318798ca319f68 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 20 Dec 2013 16:53:07 +0000 Subject: [PATCH 0090/4072] Implement kill and rm --- plum/cli/main.py | 18 ++++++++++++++++++ plum/project.py | 8 ++++++++ plum/service.py | 9 +++++++++ tests/service_test.py | 16 ++++++++++++++++ 4 files changed, 51 insertions(+) diff --git a/plum/cli/main.py b/plum/cli/main.py index 7819df61d89..fe03dbd3cdb 100644 --- a/plum/cli/main.py +++ b/plum/cli/main.py @@ -71,6 +71,8 @@ class TopLevelCommand(Command): run Run a one-off command start Start services stop Stop services + kill Kill containers + rm Remove stopped containers """ def docopt_options(self): @@ -186,6 +188,22 @@ def stop(self, options): """ self.project.stop() + def kill(self, options): + """ + Kill all containers + + Usage: kill + """ + self.project.kill() + + def rm(self, options): + """ + Remove all stopped containers + + Usage: rm + """ + self.project.remove_stopped() + def logs(self, options): """ View output from containers diff --git a/plum/project.py b/plum/project.py index 84761644165..b9fbaabd4b5 100644 --- a/plum/project.py +++ b/plum/project.py @@ -74,6 +74,14 @@ def stop(self, **options): for service in self.services: service.stop(**options) + def kill(self, **options): + for service in self.services: + service.kill(**options) + + def remove_stopped(self, **options): + for service in self.services: + service.remove_stopped(**options) + def containers(self, *args, **kwargs): l = [] for service in self.services: diff --git a/plum/service.py b/plum/service.py index 9caccc6ff86..fab896f55e6 100644 --- a/plum/service.py +++ b/plum/service.py @@ -47,6 +47,15 @@ def stop(self, **options): for c in self.containers(): c.stop(**options) + def kill(self, **options): + for c in self.containers(): + c.kill(**options) + + def remove_stopped(self, **options): + for c in self.containers(stopped=True): + if not c.is_running: + c.remove(**options) + def create_container(self, one_off=False, **override_options): """ Create a container for this service. If the image doesn't exist, attempt to pull diff --git a/tests/service_test.py b/tests/service_test.py index 7741963028b..e3a3e625ac0 100644 --- a/tests/service_test.py +++ b/tests/service_test.py @@ -73,6 +73,22 @@ def test_start_stop(self): self.assertEqual(len(service.containers()), 0) self.assertEqual(len(service.containers(stopped=True)), 1) + def test_kill_remove(self): + service = self.create_service('scalingtest') + + service.start_container() + self.assertEqual(len(service.containers()), 1) + + service.remove_stopped() + self.assertEqual(len(service.containers()), 1) + + service.kill() + self.assertEqual(len(service.containers()), 0) + self.assertEqual(len(service.containers(stopped=True)), 1) + + service.remove_stopped() + self.assertEqual(len(service.containers(stopped=True)), 0) + def test_create_container_with_one_off(self): db = self.create_service('db') container = db.create_container(one_off=True) From d3346fa174897498b5f86c9c4a8fadd4e961d345 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 20 Dec 2013 18:30:23 +0000 Subject: [PATCH 0091/4072] up, start, stop, kill and rm all accept a list of services --- plum/cli/main.py | 28 +++++++++++----------- plum/project.py | 54 +++++++++++++++++++++++++++++++++---------- tests/project_test.py | 49 +++++++++++++++++++++++++++++++-------- 3 files changed, 97 insertions(+), 34 deletions(-) diff --git a/plum/cli/main.py b/plum/cli/main.py index fe03dbd3cdb..0c844040f0c 100644 --- a/plum/cli/main.py +++ b/plum/cli/main.py @@ -5,6 +5,7 @@ from inspect import getdoc from .. import __version__ +from ..project import NoSuchService from .command import Command from .formatter import Formatter from .log_printer import LogPrinter @@ -37,6 +38,9 @@ def main(): except UserError, e: log.error(e.msg) exit(1) + except NoSuchService, e: + log.error(e.msg) + exit(1) except NoSuchCommand, e: log.error("No such command: %s", e.command) log.error("") @@ -121,8 +125,6 @@ def run(self, options): -d Detached mode: Run container in the background, print new container name """ service = self.project.get_service(options['SERVICE']) - if service is None: - raise UserError("No such service: %s" % options['SERVICE']) container_options = { 'command': [options['COMMAND']] + options['ARGS'], 'tty': not options['-d'], @@ -146,17 +148,17 @@ def up(self, options): """ Create and start containers - Usage: up [options] + Usage: up [options] [SERVICE...] Options: -d Detached mode: Run containers in the background, print new container names """ detached = options['-d'] - unstarted = self.project.create_containers() + unstarted = self.project.create_containers(service_names=options['SERVICE']) if not detached: - to_attach = self.project.containers() + [c for (s, c) in unstarted] + to_attach = self.project.containers(service_names=options['SERVICE']) + [c for (s, c) in unstarted] print "Attaching to", list_containers(to_attach) log_printer = LogPrinter(to_attach, attach_params={'logs': True}) @@ -176,33 +178,33 @@ def start(self, options): """ Start all services - Usage: start + Usage: start [SERVICE...] """ - self.project.start() + self.project.start(service_names=options['SERVICE']) def stop(self, options): """ Stop all services - Usage: stop + Usage: stop [SERVICE...] """ - self.project.stop() + self.project.stop(service_names=options['SERVICE']) def kill(self, options): """ Kill all containers - Usage: kill + Usage: kill [SERVICE...] """ - self.project.kill() + self.project.kill(service_names=options['SERVICE']) def rm(self, options): """ Remove all stopped containers - Usage: rm + Usage: rm [SERVICE...] """ - self.project.remove_stopped() + self.project.remove_stopped(service_names=options['SERVICE']) def logs(self, options): """ diff --git a/plum/project.py b/plum/project.py index b9fbaabd4b5..52a050d2320 100644 --- a/plum/project.py +++ b/plum/project.py @@ -46,17 +46,40 @@ def from_config(cls, name, config, client): return cls.from_dicts(name, dicts, client) def get_service(self, name): + """ + Retrieve a service by name. Raises NoSuchService + if the named service does not exist. + """ for service in self.services: if service.name == name: return service - def create_containers(self): + raise NoSuchService(name) + + def get_services(self, service_names=None): + """ + Returns a list of this project's services filtered + by the provided list of names, or all services if + service_names is None or []. + + Preserves the original order of self.services. + + Raises NoSuchService if any of the named services + do not exist. + """ + if service_names is None or len(service_names) == 0: + return self.services + else: + unsorted = [self.get_service(name) for name in service_names] + return [s for s in self.services if s in unsorted] + + def create_containers(self, service_names=None): """ Returns a list of (service, container) tuples, one for each service with no running containers. """ containers = [] - for service in self.services: + for service in self.get_services(service_names): if len(service.containers()) == 0: containers.append((service, service.create_container())) return containers @@ -66,27 +89,34 @@ def kill_and_remove(self, tuples): container.kill() container.remove() - def start(self, **options): - for service in self.services: + def start(self, service_names=None, **options): + for service in self.get_services(service_names): service.start(**options) - def stop(self, **options): - for service in self.services: + def stop(self, service_names=None, **options): + for service in self.get_services(service_names): service.stop(**options) - def kill(self, **options): - for service in self.services: + def kill(self, service_names=None, **options): + for service in self.get_services(service_names): service.kill(**options) - def remove_stopped(self, **options): - for service in self.services: + def remove_stopped(self, service_names=None, **options): + for service in self.get_services(service_names): service.remove_stopped(**options) - def containers(self, *args, **kwargs): + def containers(self, service_names=None, *args, **kwargs): l = [] - for service in self.services: + for service in self.get_services(service_names): for container in service.containers(*args, **kwargs): l.append(container) return l +class NoSuchService(Exception): + def __init__(self, name): + self.name = name + self.msg = "No such service: %s" % self.name + + def __str__(self): + return self.msg diff --git a/tests/project_test.py b/tests/project_test.py index c982990aebc..e96923dd5f8 100644 --- a/tests/project_test.py +++ b/tests/project_test.py @@ -42,11 +42,30 @@ def test_get_service(self): project = Project('test', [web], self.client) self.assertEqual(project.get_service('web'), web) - def test_up(self): + def test_create_containers(self): web = self.create_service('web') db = self.create_service('db') project = Project('test', [web, db], self.client) + unstarted = project.create_containers(service_names=['web']) + self.assertEqual(len(unstarted), 1) + self.assertEqual(unstarted[0][0], web) + self.assertEqual(len(web.containers(stopped=True)), 1) + self.assertEqual(len(db.containers(stopped=True)), 0) + + unstarted = project.create_containers() + self.assertEqual(len(unstarted), 2) + self.assertEqual(unstarted[0][0], web) + self.assertEqual(unstarted[1][0], db) + self.assertEqual(len(web.containers(stopped=True)), 2) + self.assertEqual(len(db.containers(stopped=True)), 1) + + def test_up(self): + web = self.create_service('web') + db = self.create_service('db') + other = self.create_service('other') + project = Project('test', [web, db, other], self.client) + web.create_container() self.assertEqual(len(web.containers()), 0) @@ -54,7 +73,7 @@ def test_up(self): self.assertEqual(len(web.containers(stopped=True)), 1) self.assertEqual(len(db.containers(stopped=True)), 0) - unstarted = project.create_containers() + unstarted = project.create_containers(service_names=['web', 'db']) self.assertEqual(len(unstarted), 2) self.assertEqual(unstarted[0][0], web) self.assertEqual(unstarted[1][0], db) @@ -71,7 +90,7 @@ def test_up(self): self.assertEqual(len(web.containers(stopped=True)), 1) self.assertEqual(len(db.containers(stopped=True)), 0) - def test_start_stop(self): + def test_start_stop_kill_remove(self): web = self.create_service('web') db = self.create_service('db') project = Project('test', [web, db], self.client) @@ -81,13 +100,25 @@ def test_start_stop(self): self.assertEqual(len(web.containers()), 0) self.assertEqual(len(db.containers()), 0) - web.create_container() + web_container_1 = web.create_container() + web_container_2 = web.create_container() + 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])) + 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(len(web.containers()), 1) - self.assertEqual(len(db.containers()), 0) + project.stop(service_names=['web'], timeout=1) + self.assertEqual(set(c.name for c in project.containers()), set([db_container.name])) - project.stop(timeout=1) + project.kill(service_names=['db']) + self.assertEqual(len(project.containers()), 0) + self.assertEqual(len(project.containers(stopped=True)), 3) - self.assertEqual(len(web.containers()), 0) - self.assertEqual(len(db.containers()), 0) + project.remove_stopped(service_names=['web']) + self.assertEqual(len(project.containers(stopped=True)), 1) + + project.remove_stopped() + self.assertEqual(len(project.containers(stopped=True)), 0) From 94cae104173554496f0ba4c3ab104fb4652ecb6f Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 20 Dec 2013 19:13:55 +0000 Subject: [PATCH 0092/4072] ps and logs can filter by service too --- plum/cli/main.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/plum/cli/main.py b/plum/cli/main.py index 0c844040f0c..b5289bc09b2 100644 --- a/plum/cli/main.py +++ b/plum/cli/main.py @@ -88,12 +88,12 @@ def ps(self, options): """ List services and containers. - Usage: ps [options] + Usage: ps [options] [SERVICE...] Options: -q Only display IDs """ - containers = self.project.containers(stopped=True) + self.project.containers(one_off=True) + containers = self.project.containers(service_names=options['SERVICE'], stopped=True) + self.project.containers(service_names=options['SERVICE'], one_off=True) if options['-q']: for container in containers: @@ -210,9 +210,9 @@ def logs(self, options): """ View output from containers - Usage: logs + Usage: logs [SERVICE...] """ - containers = self.project.containers(stopped=False) + containers = self.project.containers(service_names=options['SERVICE'], stopped=False) print "Attaching to", list_containers(containers) LogPrinter(containers, attach_params={'logs': True}).run() From 08e4468bdbb80d07526393bb2ecc5d78f49473eb Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 20 Dec 2013 19:15:12 +0000 Subject: [PATCH 0093/4072] Clean up the help banners a bit --- plum/cli/main.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/plum/cli/main.py b/plum/cli/main.py index b5289bc09b2..1e91528eba3 100644 --- a/plum/cli/main.py +++ b/plum/cli/main.py @@ -70,8 +70,9 @@ class TopLevelCommand(Command): --version Print version and exit Commands: + up Create and start containers logs View output from containers - ps List services and containers + ps List containers run Run a one-off command start Start services stop Stop services @@ -86,7 +87,7 @@ def docopt_options(self): def ps(self, options): """ - List services and containers. + List containers. Usage: ps [options] [SERVICE...] @@ -146,7 +147,7 @@ def run(self, options): def up(self, options): """ - Create and start containers + Create and start containers. Usage: up [options] [SERVICE...] @@ -176,7 +177,7 @@ def up(self, options): def start(self, options): """ - Start all services + Start existing containers. Usage: start [SERVICE...] """ @@ -184,7 +185,7 @@ def start(self, options): def stop(self, options): """ - Stop all services + Stop running containers. Usage: stop [SERVICE...] """ @@ -192,7 +193,7 @@ def stop(self, options): def kill(self, options): """ - Kill all containers + Kill containers. Usage: kill [SERVICE...] """ @@ -200,7 +201,7 @@ def kill(self, options): def rm(self, options): """ - Remove all stopped containers + Remove stopped containers Usage: rm [SERVICE...] """ @@ -208,7 +209,7 @@ def rm(self, options): def logs(self, options): """ - View output from containers + View output from containers. Usage: logs [SERVICE...] """ From 8291d36eafbba81e8afc6515a69418ca377785c8 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 20 Dec 2013 19:30:31 +0000 Subject: [PATCH 0094/4072] Fix stray test regression --- tests/service_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/service_test.py b/tests/service_test.py index e3a3e625ac0..841a585b267 100644 --- a/tests/service_test.py +++ b/tests/service_test.py @@ -137,7 +137,7 @@ def test_start_container_uses_tagged_image_if_it_exists(self): client=self.client, build='this/does/not/exist/and/will/throw/error', ) - container = service.start() + container = service.start_container() container.wait() self.assertIn('success', container.logs()) From 13a30c327a2c964b1167974766a0f664f0024248 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 20 Dec 2013 19:33:41 +0000 Subject: [PATCH 0095/4072] Container.name strips the leading slash --- plum/container.py | 4 ++-- plum/service.py | 2 +- tests/service_test.py | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/plum/container.py b/plum/container.py index 5e025440062..454ec91f66e 100644 --- a/plum/container.py +++ b/plum/container.py @@ -45,7 +45,7 @@ def short_id(self): @property def name(self): - return self.dictionary['Name'] + return self.dictionary['Name'][1:] @property def human_readable_ports(self): @@ -123,7 +123,7 @@ def links(self): for container in self.client.containers(): for name in container['Names']: bits = name.split('/') - if len(bits) > 2 and bits[1] == self.name[1:]: + if len(bits) > 2 and bits[1] == self.name: links.append(bits[2]) return links diff --git a/plum/service.py b/plum/service.py index fab896f55e6..537842db327 100644 --- a/plum/service.py +++ b/plum/service.py @@ -121,7 +121,7 @@ def _get_links(self): links = {} for service in self.links: for container in service.containers(): - links[container.name[1:]] = container.name[1:] + links[container.name] = container.name return links def _get_container_options(self, override_options, one_off=False): diff --git a/tests/service_test.py b/tests/service_test.py index 841a585b267..643473b01af 100644 --- a/tests/service_test.py +++ b/tests/service_test.py @@ -29,7 +29,7 @@ def test_containers(self): foo.start_container() self.assertEqual(len(foo.containers()), 1) - self.assertEqual(foo.containers()[0].name, '/default_foo_1') + self.assertEqual(foo.containers()[0].name, 'default_foo_1') self.assertEqual(len(bar.containers()), 0) bar.start_container() @@ -39,8 +39,8 @@ def test_containers(self): self.assertEqual(len(bar.containers()), 2) names = [c.name for c in bar.containers()] - self.assertIn('/default_bar_1', names) - self.assertIn('/default_bar_2', names) + self.assertIn('default_bar_1', names) + self.assertIn('default_bar_2', names) def test_containers_one_off(self): db = self.create_service('db') @@ -51,7 +51,7 @@ def test_containers_one_off(self): def test_project_is_added_to_container_name(self): service = self.create_service('web', project='myproject') service.start_container() - self.assertEqual(service.containers()[0].name, '/myproject_web_1') + self.assertEqual(service.containers()[0].name, 'myproject_web_1') def test_start_stop(self): service = self.create_service('scalingtest') @@ -92,13 +92,13 @@ def test_kill_remove(self): def test_create_container_with_one_off(self): db = self.create_service('db') container = db.create_container(one_off=True) - self.assertEqual(container.name, '/default_db_run_1') + self.assertEqual(container.name, 'default_db_run_1') def test_create_container_with_one_off_when_existing_container_is_running(self): db = self.create_service('db') db.start() container = db.create_container(one_off=True) - self.assertEqual(container.name, '/default_db_run_1') + self.assertEqual(container.name, 'default_db_run_1') def test_start_container_passes_through_options(self): db = self.create_service('db') From 4d35d47969a386faaf3c988c091a9ac94c268004 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 20 Dec 2013 20:04:37 +0000 Subject: [PATCH 0096/4072] Fix a couple of typos in README --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3ef299bfd66..ae61648b118 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ This example will build an image with `app.py` inside it: print "Hello world!" ``` -#### plum.yaml +#### plum.yml ```yaml web: @@ -103,7 +103,7 @@ web: ### Communicating between containers -Your dweb app will probably need to talk to your database. You can use [Docker links](http://docs.docker.io/en/latest/use/port_redirection/#linking-a-container) to enable containers to communicate, pass in the right IP address and port via environment variables: +Your web app will probably need to talk to your database. You can use [Docker links](http://docs.docker.io/en/latest/use/port_redirection/#linking-a-container) to enable containers to communicate, pass in the right IP address and port via environment variables: ```yaml db: From 0cafdc9c6c19dab2ef2795979dc8b2f48f623379 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 20 Dec 2013 20:28:24 +0000 Subject: [PATCH 0097/4072] plum -> fig --- README.md | 38 ++++++++++++++--------------- {plum => fig}/__init__.py | 0 {plum => fig}/cli/__init__.py | 0 {plum => fig}/cli/colors.py | 0 {plum => fig}/cli/command.py | 2 +- {plum => fig}/cli/docopt_command.py | 0 {plum => fig}/cli/errors.py | 0 {plum => fig}/cli/formatter.py | 0 {plum => fig}/cli/log_printer.py | 0 {plum => fig}/cli/main.py | 6 ++--- {plum => fig}/cli/multiplexer.py | 0 {plum => fig}/cli/socketclient.py | 0 {plum => fig}/cli/utils.py | 0 {plum => fig}/container.py | 0 {plum => fig}/project.py | 0 {plum => fig}/service.py | 0 setup.py | 10 ++++---- tests/container_test.py | 2 +- tests/project_test.py | 4 +-- tests/service_test.py | 2 +- tests/testcases.py | 2 +- 21 files changed, 33 insertions(+), 33 deletions(-) rename {plum => fig}/__init__.py (100%) rename {plum => fig}/cli/__init__.py (100%) rename {plum => fig}/cli/colors.py (100%) rename {plum => fig}/cli/command.py (95%) rename {plum => fig}/cli/docopt_command.py (100%) rename {plum => fig}/cli/errors.py (100%) rename {plum => fig}/cli/formatter.py (100%) rename {plum => fig}/cli/log_printer.py (100%) rename {plum => fig}/cli/main.py (98%) rename {plum => fig}/cli/multiplexer.py (100%) rename {plum => fig}/cli/socketclient.py (100%) rename {plum => fig}/cli/utils.py (100%) rename {plum => fig}/container.py (100%) rename {plum => fig}/project.py (100%) rename {plum => fig}/service.py (100%) diff --git a/README.md b/README.md index ae61648b118..09e68649ec4 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ -Plum +Fig ==== **WARNING**: This is a work in progress and probably won't work yet. Feedback welcome. -Plum is tool for defining and running application environments with Docker. It uses a simple, version-controllable YAML configuration file that looks something like this: +Fig is tool for defining and running application environments with Docker. It uses a simple, version-controllable YAML configuration file that looks something like this: ```yaml web: @@ -20,13 +20,13 @@ Installing ---------- ```bash -$ sudo pip install plum +$ sudo pip install fig ``` Defining your app ----------------- -Put a `plum.yml` in your app's directory. Each top-level key defines a "service", such as a web app, database or cache. For each service, Plum will start a Docker container, so at minimum it needs to know what image to use. +Put a `fig.yml` in your app's directory. Each top-level key defines a "service", such as a web app, database or cache. For each service, Fig will start a Docker container, so at minimum it needs to know what image to use. The simplest way to get started is to just give it an image name: @@ -35,26 +35,26 @@ db: image: orchardup/postgresql ``` -You've now given Plum the minimal amount of configuration it needs to run: +You've now given Fig the minimal amount of configuration it needs to run: ```bash -$ plum start +$ fig start Pulling image orchardup/postgresql... Starting myapp_db_1... myapp_db_1 is running at 127.0.0.1:45678 <...output from postgresql server...> ``` -For each service you've defined, Plum will start a Docker container with the specified image, building or pulling it if necessary. You now have a PostgreSQL server running at `127.0.0.1:45678`. +For each service you've defined, Fig will start a Docker container with the specified image, building or pulling it if necessary. You now have a PostgreSQL server running at `127.0.0.1:45678`. -By default, `plum start` will run until each container has shut down, and relay their output to the terminal. To run in the background instead, pass the `-d` flag: +By default, `fig start` will run until each container has shut down, and relay their output to the terminal. To run in the background instead, pass the `-d` flag: ```bash -$ plum start -d +$ fig start -d Starting myapp_db_1... done myapp_db_1 is running at 127.0.0.1:45678 -$ plum ps +$ fig ps Name State Ports ------------------------------------ myapp_db_1 Up 5432->45678/tcp @@ -62,7 +62,7 @@ myapp_db_1 Up 5432->45678/tcp ### Building services -Plum can automatically build images for you if your service specifies a directory with a `Dockerfile` in it (or a Git URL, as per the `docker build` command). +Fig can automatically build images for you if your service specifies a directory with a `Dockerfile` in it (or a Git URL, as per the `docker build` command). This example will build an image with `app.py` inside it: @@ -72,7 +72,7 @@ This example will build an image with `app.py` inside it: print "Hello world!" ``` -#### plum.yml +#### fig.yml ```yaml web: @@ -91,7 +91,7 @@ web: ### Getting your code in -If you want to work on an application being run by Plum, you probably don't want to have to rebuild your image every time you make a change. To solve this, you can share the directory with the container using a volume so the changes are reflected immediately: +If you want to work on an application being run by Fig, you probably don't want to have to rebuild your image every time you make a change. To solve this, you can share the directory with the container using a volume so the changes are reflected immediately: ```yaml web: @@ -118,8 +118,8 @@ web: This will pass an environment variable called `MYAPP_DB_1_PORT` into the web container, whose value will look like `tcp://172.17.0.4:45678`. Your web app's code can use that to connect to the database. To see all of the environment variables available, run `env` inside a container: ```bash -$ plum start -d db -$ plum run web env +$ fig start -d db +$ fig run web env ``` @@ -152,12 +152,12 @@ web: Running a one-off command ------------------------- -If you want to run a management command, use `plum run` to start a one-off container: +If you want to run a management command, use `fig run` to start a one-off container: ```bash -$ plum run db createdb myapp_development -$ plum run web rake db:migrate -$ plum run web bash +$ fig run db createdb myapp_development +$ fig run web rake db:migrate +$ fig run web bash ``` diff --git a/plum/__init__.py b/fig/__init__.py similarity index 100% rename from plum/__init__.py rename to fig/__init__.py diff --git a/plum/cli/__init__.py b/fig/cli/__init__.py similarity index 100% rename from plum/cli/__init__.py rename to fig/cli/__init__.py diff --git a/plum/cli/colors.py b/fig/cli/colors.py similarity index 100% rename from plum/cli/colors.py rename to fig/cli/colors.py diff --git a/plum/cli/command.py b/fig/cli/command.py similarity index 95% rename from plum/cli/command.py rename to fig/cli/command.py index 5bbed26bea2..f8899c3f6d1 100644 --- a/plum/cli/command.py +++ b/fig/cli/command.py @@ -21,7 +21,7 @@ def client(self): @cached_property def project(self): - config = yaml.load(open('plum.yml')) + config = yaml.load(open('fig.yml')) return Project.from_config(self.project_name, config, self.client) @cached_property diff --git a/plum/cli/docopt_command.py b/fig/cli/docopt_command.py similarity index 100% rename from plum/cli/docopt_command.py rename to fig/cli/docopt_command.py diff --git a/plum/cli/errors.py b/fig/cli/errors.py similarity index 100% rename from plum/cli/errors.py rename to fig/cli/errors.py diff --git a/plum/cli/formatter.py b/fig/cli/formatter.py similarity index 100% rename from plum/cli/formatter.py rename to fig/cli/formatter.py diff --git a/plum/cli/log_printer.py b/fig/cli/log_printer.py similarity index 100% rename from plum/cli/log_printer.py rename to fig/cli/log_printer.py diff --git a/plum/cli/main.py b/fig/cli/main.py similarity index 98% rename from plum/cli/main.py rename to fig/cli/main.py index 1e91528eba3..f8687217373 100644 --- a/plum/cli/main.py +++ b/fig/cli/main.py @@ -62,8 +62,8 @@ class TopLevelCommand(Command): """. Usage: - plum [options] [COMMAND] [ARGS...] - plum -h|--help + fig [options] [COMMAND] [ARGS...] + fig -h|--help Options: --verbose Show more output @@ -82,7 +82,7 @@ class TopLevelCommand(Command): """ def docopt_options(self): options = super(TopLevelCommand, self).docopt_options() - options['version'] = "plum %s" % __version__ + options['version'] = "fig %s" % __version__ return options def ps(self, options): diff --git a/plum/cli/multiplexer.py b/fig/cli/multiplexer.py similarity index 100% rename from plum/cli/multiplexer.py rename to fig/cli/multiplexer.py diff --git a/plum/cli/socketclient.py b/fig/cli/socketclient.py similarity index 100% rename from plum/cli/socketclient.py rename to fig/cli/socketclient.py diff --git a/plum/cli/utils.py b/fig/cli/utils.py similarity index 100% rename from plum/cli/utils.py rename to fig/cli/utils.py diff --git a/plum/container.py b/fig/container.py similarity index 100% rename from plum/container.py rename to fig/container.py diff --git a/plum/project.py b/fig/project.py similarity index 100% rename from plum/project.py rename to fig/project.py diff --git a/plum/service.py b/fig/service.py similarity index 100% rename from plum/service.py rename to fig/service.py diff --git a/setup.py b/setup.py index 5b01a47e6d5..f1e3d324880 100644 --- a/setup.py +++ b/setup.py @@ -23,19 +23,19 @@ def find_version(*file_paths): setup( - name='plum', - version=find_version("plum", "__init__.py"), + name='fig', + version=find_version("fig", "__init__.py"), description='', - url='https://github.com/orchardup/plum', + url='https://github.com/orchardup/fig', author='Orchard Laboratories Ltd.', author_email='hello@orchardup.com', - packages=['plum'], + packages=['fig'], package_data={}, include_package_data=True, install_requires=[], dependency_links=[], entry_points=""" [console_scripts] - plum=plum.cli.main:main + fig=fig.cli.main:main """, ) diff --git a/tests/container_test.py b/tests/container_test.py index 8628b04d8ff..0d6c5f0f626 100644 --- a/tests/container_test.py +++ b/tests/container_test.py @@ -1,5 +1,5 @@ from .testcases import DockerClientTestCase -from plum.container import Container +from fig.container import Container class ContainerTest(DockerClientTestCase): def test_from_ps(self): diff --git a/tests/project_test.py b/tests/project_test.py index e96923dd5f8..7dd3715fb21 100644 --- a/tests/project_test.py +++ b/tests/project_test.py @@ -1,5 +1,5 @@ -from plum.project import Project -from plum.service import Service +from fig.project import Project +from fig.service import Service from .testcases import DockerClientTestCase diff --git a/tests/service_test.py b/tests/service_test.py index 643473b01af..02b96ab8e20 100644 --- a/tests/service_test.py +++ b/tests/service_test.py @@ -1,4 +1,4 @@ -from plum import Service +from fig import Service from .testcases import DockerClientTestCase diff --git a/tests/testcases.py b/tests/testcases.py index 8fc65ee877d..a930d68ecef 100644 --- a/tests/testcases.py +++ b/tests/testcases.py @@ -1,5 +1,5 @@ from docker import Client -from plum.service import Service +from fig.service import Service import os from unittest import TestCase From 4182830e7e4bb8e34ee91f1fdb80ccc45a95e63f Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 20 Dec 2013 20:30:59 +0000 Subject: [PATCH 0098/4072] README: change 'start' back to 'up' --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 09e68649ec4..44eac9d0e87 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ db: You've now given Fig the minimal amount of configuration it needs to run: ```bash -$ fig start +$ fig up Pulling image orchardup/postgresql... Starting myapp_db_1... myapp_db_1 is running at 127.0.0.1:45678 @@ -47,10 +47,10 @@ myapp_db_1 is running at 127.0.0.1:45678 For each service you've defined, Fig will start a Docker container with the specified image, building or pulling it if necessary. You now have a PostgreSQL server running at `127.0.0.1:45678`. -By default, `fig start` will run until each container has shut down, and relay their output to the terminal. To run in the background instead, pass the `-d` flag: +By default, `fig up` will run until each container has shut down, and relay their output to the terminal. To run in the background instead, pass the `-d` flag: ```bash -$ fig start -d +$ fig up -d Starting myapp_db_1... done myapp_db_1 is running at 127.0.0.1:45678 @@ -118,7 +118,7 @@ web: This will pass an environment variable called `MYAPP_DB_1_PORT` into the web container, whose value will look like `tcp://172.17.0.4:45678`. Your web app's code can use that to connect to the database. To see all of the environment variables available, run `env` inside a container: ```bash -$ fig start -d db +$ fig up -d db $ fig run web env ``` From 50d1a39b3a25487f2b9f976edbaa55b811a278f3 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Fri, 20 Dec 2013 20:45:27 +0000 Subject: [PATCH 0099/4072] Update description in readme --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 44eac9d0e87..0ab7c1106be 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ Fig ==== -**WARNING**: This is a work in progress and probably won't work yet. Feedback welcome. +Punctual, lightweight development environments using Docker. -Fig is tool for defining and running application environments with Docker. It uses a simple, version-controllable YAML configuration file that looks something like this: +Fig is tool for defining and running isolated application environments. It uses a simple, version-controllable YAML configuration file that looks something like this: ```yaml web: From 206c338e142ba2af30a3c098b167f9e41d4282ad Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Fri, 20 Dec 2013 20:57:14 +0000 Subject: [PATCH 0100/4072] Readme pluralisation --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 0ab7c1106be..12dd61c0ac0 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ Fig Punctual, lightweight development environments using Docker. -Fig is tool for defining and running isolated application environments. It uses a simple, version-controllable YAML configuration file that looks something like this: +Fig is tool for defining and running isolated application environments. It uses simple, version-controllable YAML configuration files that look something like this: ```yaml web: From 5cc4b59dc02dce0d8330370ae4665e1ecd2619a1 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Fri, 20 Dec 2013 21:25:06 +0000 Subject: [PATCH 0101/4072] Switch to stable docker-py --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index f8e53737207..dccc0aa118e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -git+git://github.com/dotcloud/docker-py.git@5c928dcab51a276f421a36d584c37b745b3b9a3d +docker-py==0.2.3 docopt==0.6.1 PyYAML==3.10 texttable==0.8.1 From dd920890f3510d69ea8780a5a4436fa4d3000e09 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Fri, 20 Dec 2013 21:25:19 +0000 Subject: [PATCH 0102/4072] Read requirements in setup.py --- setup.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index f1e3d324880..fdffe020106 100644 --- a/setup.py +++ b/setup.py @@ -21,6 +21,8 @@ def find_version(*file_paths): return version_match.group(1) raise RuntimeError("Unable to find version string.") +with open('requirements.txt') as f: + install_requires = f.read().splitlines() setup( name='fig', @@ -32,8 +34,7 @@ def find_version(*file_paths): packages=['fig'], package_data={}, include_package_data=True, - install_requires=[], - dependency_links=[], + install_requires=install_requires, entry_points=""" [console_scripts] fig=fig.cli.main:main From fb445b3a065287885587a855428ecc6be55936e0 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Fri, 20 Dec 2013 21:31:00 +0000 Subject: [PATCH 0103/4072] Version 0.0.1 --- fig/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fig/__init__.py b/fig/__init__.py index 404eba85da7..5282dce934b 100644 --- a/fig/__init__.py +++ b/fig/__init__.py @@ -1,3 +1,3 @@ from .service import Service -__version__ = '1.0.0' +__version__ = '0.0.1' From 7b925b8eaca2864ad142ce04df16a0f693ca823f Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Fri, 20 Dec 2013 21:32:41 +0000 Subject: [PATCH 0104/4072] Add some helpful scripts --- script/clean | 3 +++ script/release | 14 ++++++++++++++ script/test | 2 ++ 3 files changed, 19 insertions(+) create mode 100755 script/clean create mode 100755 script/release create mode 100755 script/test diff --git a/script/clean b/script/clean new file mode 100755 index 00000000000..f9f1b0da2fe --- /dev/null +++ b/script/clean @@ -0,0 +1,3 @@ +#!/bin/sh +find . -type f -name '*.pyc' -delete + diff --git a/script/release b/script/release new file mode 100755 index 00000000000..fdd4a960cc8 --- /dev/null +++ b/script/release @@ -0,0 +1,14 @@ +#!/bin/bash + +set -xe + +if [ -z "$1" ]; then + echo 'pass a version as first argument' + exit 1 +fi + +git tag $1 +git push --tags +python setup.py sdist upload + + diff --git a/script/test b/script/test new file mode 100755 index 00000000000..1ceefaad886 --- /dev/null +++ b/script/test @@ -0,0 +1,2 @@ +#!/bin/sh +nosetests From 23d6ae867d0602b2e99172fe30df0ed4e17a5ca7 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Fri, 20 Dec 2013 21:34:27 +0000 Subject: [PATCH 0105/4072] Add description to main help text --- fig/cli/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fig/cli/main.py b/fig/cli/main.py index f8687217373..6f3f57eafcf 100644 --- a/fig/cli/main.py +++ b/fig/cli/main.py @@ -59,7 +59,7 @@ def parse_doc_section(name, source): class TopLevelCommand(Command): - """. + """Punctual, lightweight development environments using Docker. Usage: fig [options] [COMMAND] [ARGS...] From 8998bd1adc3def9e6e55b654b16415a46e1ca28b Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Fri, 20 Dec 2013 21:35:00 +0000 Subject: [PATCH 0106/4072] Make setup.py actually work for release --- MANIFEST.in | 3 +++ setup.py | 8 ++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) create mode 100644 MANIFEST.in diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 00000000000..a929a01cefe --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,3 @@ +include LICENSE +include *.md +include requirements.txt diff --git a/setup.py b/setup.py index fdffe020106..aaf506bacea 100644 --- a/setup.py +++ b/setup.py @@ -24,14 +24,18 @@ def find_version(*file_paths): with open('requirements.txt') as f: install_requires = f.read().splitlines() +with open('README.md') as f: + long_description = f.read() + setup( name='fig', version=find_version("fig", "__init__.py"), - description='', + description='Punctual, lightweight development environments using Docker', + long_description=long_description, url='https://github.com/orchardup/fig', author='Orchard Laboratories Ltd.', author_email='hello@orchardup.com', - packages=['fig'], + packages=['fig', 'fig.cli'], package_data={}, include_package_data=True, install_requires=install_requires, From 89cd7d8db0117875a2aeae401ce724608b0cef47 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Fri, 20 Dec 2013 21:36:06 +0000 Subject: [PATCH 0107/4072] Remove long description --- setup.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/setup.py b/setup.py index aaf506bacea..2f7131f68c1 100644 --- a/setup.py +++ b/setup.py @@ -24,14 +24,10 @@ def find_version(*file_paths): with open('requirements.txt') as f: install_requires = f.read().splitlines() -with open('README.md') as f: - long_description = f.read() - setup( name='fig', version=find_version("fig", "__init__.py"), description='Punctual, lightweight development environments using Docker', - long_description=long_description, url='https://github.com/orchardup/fig', author='Orchard Laboratories Ltd.', author_email='hello@orchardup.com', From 2857631e9065851043e1987ec05c0849fb6449bb Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Fri, 20 Dec 2013 21:37:55 +0000 Subject: [PATCH 0108/4072] Add change log --- CHANGES.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 CHANGES.md diff --git a/CHANGES.md b/CHANGES.md new file mode 100644 index 00000000000..d360853d05d --- /dev/null +++ b/CHANGES.md @@ -0,0 +1,9 @@ +Change log +========== + +0.0.1 (2013-12-20) +------------------ + +Initial release. + + From f3eff9a389aa2adca4b574c94c5ec3b386e0dcec Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 30 Dec 2013 18:57:27 +0000 Subject: [PATCH 0109/4072] Add _site to .gitignore (it's generated by Jekyll in the gh-pages branch) --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 21a256dfb23..c6e51dbc14a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ *.egg-info *.pyc /dist +/_site From fdc1e0f2e196d0172a52a71c5602f3ea3977166b Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 30 Dec 2013 18:57:50 +0000 Subject: [PATCH 0110/4072] Missing article in README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 12dd61c0ac0..873063d8427 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ Fig Punctual, lightweight development environments using Docker. -Fig is tool for defining and running isolated application environments. It uses simple, version-controllable YAML configuration files that look something like this: +Fig is a tool for defining and running isolated application environments. It uses simple, version-controllable YAML configuration files that look something like this: ```yaml web: From ebf9bf387c99e07bf5e0e376c177ebc7690dee80 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 31 Dec 2013 11:51:52 +0000 Subject: [PATCH 0111/4072] Remove unused import --- fig/cli/command.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fig/cli/command.py b/fig/cli/command.py index f8899c3f6d1..b3e0583e70d 100644 --- a/fig/cli/command.py +++ b/fig/cli/command.py @@ -7,7 +7,7 @@ from ..project import Project from .docopt_command import DocoptCommand from .formatter import Formatter -from .utils import cached_property, mkdir +from .utils import cached_property log = logging.getLogger(__name__) From ff65a3e1b0f061100a20462dea4f654b02707a6f Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 31 Dec 2013 12:18:27 +0000 Subject: [PATCH 0112/4072] Check default socket and localhost:4243 for Docker daemon --- fig/cli/command.py | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/fig/cli/command.py b/fig/cli/command.py index b3e0583e70d..2bece9139e8 100644 --- a/fig/cli/command.py +++ b/fig/cli/command.py @@ -3,11 +3,13 @@ import os import re import yaml +import socket from ..project import Project from .docopt_command import DocoptCommand from .formatter import Formatter from .utils import cached_property +from .errors import UserError log = logging.getLogger(__name__) @@ -16,8 +18,26 @@ class Command(DocoptCommand): def client(self): if os.environ.get('DOCKER_URL'): return Client(os.environ['DOCKER_URL']) - else: - return Client() + + socket_path = '/var/run/docker.sock' + tcp_host = '127.0.0.1' + tcp_port = 4243 + + if os.path.exists(socket_path): + return Client('unix://%s' % socket_path) + + try: + s = socket.socket() + s.connect((tcp_host, tcp_port)) + s.close() + return Client('http://%s:%s' % (tcp_host, tcp_port)) + except: + pass + + raise UserError(""" + Couldn't find Docker daemon - tried %s and %s:%s. + If it's running elsewhere, specify a url with DOCKER_URL. + """ % (socket_path, tcp_host, tcp_port)) @cached_property def project(self): From 9ed653869330edd1d2183bfccfeac26412a998da Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 31 Dec 2013 12:37:17 +0000 Subject: [PATCH 0113/4072] Extract docker URL logic, use it in tests as well --- fig/cli/command.py | 27 ++------------------------- fig/cli/utils.py | 27 +++++++++++++++++++++++++++ tests/project_test.py | 3 +-- tests/testcases.py | 7 ++----- 4 files changed, 32 insertions(+), 32 deletions(-) diff --git a/fig/cli/command.py b/fig/cli/command.py index 2bece9139e8..36d4c0c6c62 100644 --- a/fig/cli/command.py +++ b/fig/cli/command.py @@ -3,41 +3,18 @@ import os import re import yaml -import socket from ..project import Project from .docopt_command import DocoptCommand from .formatter import Formatter -from .utils import cached_property -from .errors import UserError +from .utils import cached_property, docker_url log = logging.getLogger(__name__) class Command(DocoptCommand): @cached_property def client(self): - if os.environ.get('DOCKER_URL'): - return Client(os.environ['DOCKER_URL']) - - socket_path = '/var/run/docker.sock' - tcp_host = '127.0.0.1' - tcp_port = 4243 - - if os.path.exists(socket_path): - return Client('unix://%s' % socket_path) - - try: - s = socket.socket() - s.connect((tcp_host, tcp_port)) - s.close() - return Client('http://%s:%s' % (tcp_host, tcp_port)) - except: - pass - - raise UserError(""" - Couldn't find Docker daemon - tried %s and %s:%s. - If it's running elsewhere, specify a url with DOCKER_URL. - """ % (socket_path, tcp_host, tcp_port)) + return Client(docker_url()) @cached_property def project(self): diff --git a/fig/cli/utils.py b/fig/cli/utils.py index 8d1764258df..1094b5e77b2 100644 --- a/fig/cli/utils.py +++ b/fig/cli/utils.py @@ -1,5 +1,7 @@ import datetime import os +import socket +from .errors import UserError def cached_property(f): @@ -74,3 +76,28 @@ def mkdir(path, permissions=0700): os.chmod(path, permissions) return path + + +def docker_url(): + if os.environ.get('DOCKER_URL'): + return os.environ['DOCKER_URL'] + + socket_path = '/var/run/docker.sock' + tcp_host = '127.0.0.1' + tcp_port = 4243 + + if os.path.exists(socket_path): + return 'unix://%s' % socket_path + + try: + s = socket.socket() + s.connect((tcp_host, tcp_port)) + s.close() + return 'http://%s:%s' % (tcp_host, tcp_port) + except: + pass + + raise UserError(""" + Couldn't find Docker daemon - tried %s and %s:%s. + If it's running elsewhere, specify a url with DOCKER_URL. + """ % (socket_path, tcp_host, tcp_port)) diff --git a/tests/project_test.py b/tests/project_test.py index 7dd3715fb21..e8949a59db8 100644 --- a/tests/project_test.py +++ b/tests/project_test.py @@ -1,5 +1,4 @@ from fig.project import Project -from fig.service import Service from .testcases import DockerClientTestCase @@ -57,7 +56,7 @@ def test_create_containers(self): self.assertEqual(len(unstarted), 2) self.assertEqual(unstarted[0][0], web) self.assertEqual(unstarted[1][0], db) - self.assertEqual(len(web.containers(stopped=True)), 2) + self.assertEqual(len(web.containers(stopped=True)), 1) self.assertEqual(len(db.containers(stopped=True)), 1) def test_up(self): diff --git a/tests/testcases.py b/tests/testcases.py index a930d68ecef..b363bc00300 100644 --- a/tests/testcases.py +++ b/tests/testcases.py @@ -1,16 +1,13 @@ from docker import Client from fig.service import Service -import os +from fig.cli.utils import docker_url from unittest import TestCase class DockerClientTestCase(TestCase): @classmethod def setUpClass(cls): - if os.environ.get('DOCKER_URL'): - cls.client = Client(os.environ['DOCKER_URL']) - else: - cls.client = Client() + cls.client = Client(docker_url()) cls.client.pull('ubuntu') def setUp(self): From d4f3ed1840d94c228c8c4c461a5cf14ff1822e89 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 31 Dec 2013 13:02:08 +0000 Subject: [PATCH 0114/4072] Fix 'fig up' behaviour - For each service, creates a container if there are none (stopped OR started) - Attaches to all containers (unless -d is passed) - Starts all containers - On ^C, kills all containers (unless -d is passed) --- fig/cli/main.py | 18 +++++++----------- fig/project.py | 14 +++----------- tests/project_test.py | 39 ++------------------------------------- 3 files changed, 12 insertions(+), 59 deletions(-) diff --git a/fig/cli/main.py b/fig/cli/main.py index 6f3f57eafcf..f6714f1e89c 100644 --- a/fig/cli/main.py +++ b/fig/cli/main.py @@ -156,24 +156,20 @@ def up(self, options): """ detached = options['-d'] - unstarted = self.project.create_containers(service_names=options['SERVICE']) + self.project.create_containers(service_names=options['SERVICE']) + containers = self.project.containers(service_names=options['SERVICE'], stopped=True) if not detached: - to_attach = self.project.containers(service_names=options['SERVICE']) + [c for (s, c) in unstarted] - print "Attaching to", list_containers(to_attach) - log_printer = LogPrinter(to_attach, attach_params={'logs': True}) + print "Attaching to", list_containers(containers) + log_printer = LogPrinter(containers) - for (s, c) in unstarted: - s.start_container(c) + self.project.start(service_names=options['SERVICE']) - if detached: - for (s, c) in unstarted: - print c.name - else: + if not detached: try: log_printer.run() finally: - self.project.kill_and_remove(unstarted) + self.project.kill(service_names=options['SERVICE']) def start(self, options): """ diff --git a/fig/project.py b/fig/project.py index 52a050d2320..eb9e8d43d93 100644 --- a/fig/project.py +++ b/fig/project.py @@ -75,19 +75,11 @@ def get_services(self, service_names=None): def create_containers(self, service_names=None): """ - Returns a list of (service, container) tuples, - one for each service with no running containers. + For each service, creates a container if there are none. """ - containers = [] for service in self.get_services(service_names): - if len(service.containers()) == 0: - containers.append((service, service.create_container())) - return containers - - def kill_and_remove(self, tuples): - for (service, container) in tuples: - container.kill() - container.remove() + if len(service.containers(stopped=True)) == 0: + service.create_container() def start(self, service_names=None, **options): for service in self.get_services(service_names): diff --git a/tests/project_test.py b/tests/project_test.py index e8949a59db8..5d0d1e72aba 100644 --- a/tests/project_test.py +++ b/tests/project_test.py @@ -46,49 +46,14 @@ def test_create_containers(self): db = self.create_service('db') project = Project('test', [web, db], self.client) - unstarted = project.create_containers(service_names=['web']) - self.assertEqual(len(unstarted), 1) - self.assertEqual(unstarted[0][0], web) + project.create_containers(service_names=['web']) self.assertEqual(len(web.containers(stopped=True)), 1) self.assertEqual(len(db.containers(stopped=True)), 0) - unstarted = project.create_containers() - self.assertEqual(len(unstarted), 2) - self.assertEqual(unstarted[0][0], web) - self.assertEqual(unstarted[1][0], db) + project.create_containers() self.assertEqual(len(web.containers(stopped=True)), 1) self.assertEqual(len(db.containers(stopped=True)), 1) - def test_up(self): - web = self.create_service('web') - db = self.create_service('db') - other = self.create_service('other') - project = Project('test', [web, db, other], self.client) - - web.create_container() - - self.assertEqual(len(web.containers()), 0) - self.assertEqual(len(db.containers()), 0) - self.assertEqual(len(web.containers(stopped=True)), 1) - self.assertEqual(len(db.containers(stopped=True)), 0) - - unstarted = project.create_containers(service_names=['web', 'db']) - self.assertEqual(len(unstarted), 2) - self.assertEqual(unstarted[0][0], web) - self.assertEqual(unstarted[1][0], db) - - self.assertEqual(len(web.containers()), 0) - self.assertEqual(len(db.containers()), 0) - self.assertEqual(len(web.containers(stopped=True)), 2) - self.assertEqual(len(db.containers(stopped=True)), 1) - - project.kill_and_remove(unstarted) - - self.assertEqual(len(web.containers()), 0) - self.assertEqual(len(db.containers()), 0) - self.assertEqual(len(web.containers(stopped=True)), 1) - self.assertEqual(len(db.containers(stopped=True)), 0) - def test_start_stop_kill_remove(self): web = self.create_service('web') db = self.create_service('db') From c0676e3fa3b79cbf61d9f544f2ac7f97252b6793 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 31 Dec 2013 13:42:58 +0000 Subject: [PATCH 0115/4072] Add confirmation prompt to 'fig rm' --- fig/cli/main.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/fig/cli/main.py b/fig/cli/main.py index f6714f1e89c..7028cf2917f 100644 --- a/fig/cli/main.py +++ b/fig/cli/main.py @@ -9,6 +9,7 @@ from .command import Command from .formatter import Formatter from .log_printer import LogPrinter +from .utils import yesno from docker.client import APIError from .errors import UserError @@ -201,7 +202,15 @@ def rm(self, options): Usage: rm [SERVICE...] """ - self.project.remove_stopped(service_names=options['SERVICE']) + 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 yesno("Are you sure? [yN] ", default=False): + self.project.remove_stopped(service_names=options['SERVICE']) + else: + print "No stopped containers" def logs(self, options): """ From e5065bed1603110431c903e785faac68bdd075de Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 31 Dec 2013 16:31:40 +0000 Subject: [PATCH 0116/4072] Expand the intro a bit --- README.md | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 873063d8427..d4d539a02a3 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ Fig Punctual, lightweight development environments using Docker. -Fig is a tool for defining and running isolated application environments. It uses simple, version-controllable YAML configuration files that look something like this: +Fig is a tool for defining and running isolated application environments. You define the services which comprise your app in a simple, version-controllable YAML configuration file that looks like this: ```yaml web: @@ -16,6 +16,15 @@ db: image: orchardup/postgresql ``` +Then type `fig up`, and Fig will start and run your entire app. + +There are commands to: + + - start, stop and rebuild services + - view the status of running services + - tail running services' log output + - run a one-off command on a service + Installing ---------- @@ -26,7 +35,7 @@ $ sudo pip install fig Defining your app ----------------- -Put a `fig.yml` in your app's directory. Each top-level key defines a "service", such as a web app, database or cache. For each service, Fig will start a Docker container, so at minimum it needs to know what image to use. +Put a `fig.yml` in your app's directory. Each top-level key defines a service, such as a web app, database or cache. For each service, Fig will start a Docker container, so at minimum it needs to know what image to use. The simplest way to get started is to just give it an image name: From 770e78fdce3cac7635f0eeb0b7c9b32ad8299bed Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Tue, 31 Dec 2013 17:05:20 +0000 Subject: [PATCH 0117/4072] Make usage alphabetical --- fig/cli/main.py | 106 ++++++++++++++++++++++++------------------------ 1 file changed, 53 insertions(+), 53 deletions(-) diff --git a/fig/cli/main.py b/fig/cli/main.py index 7028cf2917f..f5b4f166b63 100644 --- a/fig/cli/main.py +++ b/fig/cli/main.py @@ -71,14 +71,14 @@ class TopLevelCommand(Command): --version Print version and exit Commands: - up Create and start containers + kill Kill containers logs View output from containers ps List containers + rm Remove stopped containers run Run a one-off command start Start services stop Stop services - kill Kill containers - rm Remove stopped containers + up Create and start containers """ def docopt_options(self): @@ -86,6 +86,24 @@ def docopt_options(self): options['version'] = "fig %s" % __version__ return options + def kill(self, options): + """ + Kill containers. + + Usage: kill [SERVICE...] + """ + self.project.kill(service_names=options['SERVICE']) + + def logs(self, options): + """ + View output from containers. + + Usage: logs [SERVICE...] + """ + containers = self.project.containers(service_names=options['SERVICE'], stopped=False) + print "Attaching to", list_containers(containers) + LogPrinter(containers, attach_params={'logs': True}).run() + def ps(self, options): """ List containers. @@ -117,6 +135,22 @@ def ps(self, options): ]) print Formatter().table(headers, rows) + def rm(self, options): + """ + Remove stopped containers + + Usage: rm [SERVICE...] + """ + 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 yesno("Are you sure? [yN] ", default=False): + self.project.remove_stopped(service_names=options['SERVICE']) + else: + print "No stopped containers" + def run(self, options): """ Run a one-off command. @@ -146,6 +180,22 @@ def run(self, options): service.start_container(container, ports=None) c.run() + def start(self, options): + """ + Start existing containers. + + Usage: start [SERVICE...] + """ + self.project.start(service_names=options['SERVICE']) + + def stop(self, options): + """ + Stop running containers. + + Usage: stop [SERVICE...] + """ + self.project.stop(service_names=options['SERVICE']) + def up(self, options): """ Create and start containers. @@ -172,56 +222,6 @@ def up(self, options): finally: self.project.kill(service_names=options['SERVICE']) - def start(self, options): - """ - Start existing containers. - - Usage: start [SERVICE...] - """ - self.project.start(service_names=options['SERVICE']) - - def stop(self, options): - """ - Stop running containers. - - Usage: stop [SERVICE...] - """ - self.project.stop(service_names=options['SERVICE']) - - def kill(self, options): - """ - Kill containers. - - Usage: kill [SERVICE...] - """ - self.project.kill(service_names=options['SERVICE']) - - def rm(self, options): - """ - Remove stopped containers - - Usage: rm [SERVICE...] - """ - 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 yesno("Are you sure? [yN] ", default=False): - self.project.remove_stopped(service_names=options['SERVICE']) - else: - print "No stopped containers" - - def logs(self, options): - """ - View output from containers. - - Usage: logs [SERVICE...] - """ - containers = self.project.containers(service_names=options['SERVICE'], stopped=False) - print "Attaching to", list_containers(containers) - LogPrinter(containers, attach_params={'logs': True}).run() - def _attach_to_container(self, container_id, interactive, logs=False, stream=True, raw=False): stdio = self.client.attach_socket( container_id, From 17c6ae067ae949ddcdd8af55ff4e39374c35b677 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Thu, 2 Jan 2014 14:55:48 +0000 Subject: [PATCH 0118/4072] Rewrite readme intro --- README.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d4d539a02a3..cf661cefea8 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,15 @@ db: image: orchardup/postgresql ``` -Then type `fig up`, and Fig will start and run your entire app. +Then type `fig up`, and Fig will start and run your entire app: + + $ fig up + Pulling image orchardup/postgresql... + Building web... + Starting example_db_1... + Starting example_web_1... + example_db_1 | 2014-01-02 14:47:18 UTC LOG: database system is ready to accept connections + example_web_1 | * Running on http://0.0.0.0:5000/ There are commands to: @@ -25,6 +33,9 @@ There are commands to: - tail running services' log output - run a one-off command on a service +Fig is a project from [Orchard](https://orchardup.com), a Docker hosting service. [Follow us on Twitter](https://twitter.com/orchardup) to keep up to date with Fig and other Docker news. + + Installing ---------- From 853d8ad28078159fcd74fcac3b6868408a9fca25 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Thu, 2 Jan 2014 15:27:51 +0000 Subject: [PATCH 0119/4072] Namespace tests inside a project So it doesn't delete all your containers for every test. Cool. --- tests/project_test.py | 6 +++--- tests/service_test.py | 22 ++++++++++++---------- tests/testcases.py | 6 ++++-- 3 files changed, 19 insertions(+), 15 deletions(-) diff --git a/tests/project_test.py b/tests/project_test.py index 5d0d1e72aba..82b9be9df7d 100644 --- a/tests/project_test.py +++ b/tests/project_test.py @@ -4,7 +4,7 @@ class ProjectTest(DockerClientTestCase): def test_from_dict(self): - project = Project.from_dicts('test', [ + project = Project.from_dicts('figtest', [ { 'name': 'web', 'image': 'ubuntu' @@ -21,7 +21,7 @@ def test_from_dict(self): self.assertEqual(project.get_service('db').options['image'], 'ubuntu') def test_from_dict_sorts_in_dependency_order(self): - project = Project.from_dicts('test', [ + project = Project.from_dicts('figtest', [ { 'name': 'web', 'image': 'ubuntu', @@ -57,7 +57,7 @@ def test_create_containers(self): def test_start_stop_kill_remove(self): web = self.create_service('web') db = self.create_service('db') - project = Project('test', [web, db], self.client) + project = Project('figtest', [web, db], self.client) project.start() diff --git a/tests/service_test.py b/tests/service_test.py index 02b96ab8e20..f946ab38f8c 100644 --- a/tests/service_test.py +++ b/tests/service_test.py @@ -29,7 +29,7 @@ def test_containers(self): foo.start_container() self.assertEqual(len(foo.containers()), 1) - self.assertEqual(foo.containers()[0].name, 'default_foo_1') + self.assertEqual(foo.containers()[0].name, 'figtest_foo_1') self.assertEqual(len(bar.containers()), 0) bar.start_container() @@ -39,8 +39,8 @@ def test_containers(self): self.assertEqual(len(bar.containers()), 2) names = [c.name for c in bar.containers()] - self.assertIn('default_bar_1', names) - self.assertIn('default_bar_2', names) + self.assertIn('figtest_bar_1', names) + self.assertIn('figtest_bar_2', names) def test_containers_one_off(self): db = self.create_service('db') @@ -49,9 +49,9 @@ def test_containers_one_off(self): self.assertEqual(db.containers(one_off=True, stopped=True), [container]) def test_project_is_added_to_container_name(self): - service = self.create_service('web', project='myproject') + service = self.create_service('web') service.start_container() - self.assertEqual(service.containers()[0].name, 'myproject_web_1') + self.assertEqual(service.containers()[0].name, 'figtest_web_1') def test_start_stop(self): service = self.create_service('scalingtest') @@ -92,13 +92,13 @@ def test_kill_remove(self): def test_create_container_with_one_off(self): db = self.create_service('db') container = db.create_container(one_off=True) - self.assertEqual(container.name, 'default_db_run_1') + self.assertEqual(container.name, 'figtest_db_run_1') def test_create_container_with_one_off_when_existing_container_is_running(self): db = self.create_service('db') db.start() container = db.create_container(one_off=True) - self.assertEqual(container.name, 'default_db_run_1') + self.assertEqual(container.name, 'figtest_db_run_1') def test_start_container_passes_through_options(self): db = self.create_service('db') @@ -115,7 +115,7 @@ def test_start_container_creates_links(self): web = self.create_service('web', links=[db]) db.start_container() web.start_container() - self.assertIn('default_db_1', web.containers()[0].links()) + self.assertIn('figtest_db_1', web.containers()[0].links()) db.stop(timeout=1) web.stop(timeout=1) @@ -124,18 +124,20 @@ def test_start_container_builds_images(self): name='test', client=self.client, build='tests/fixtures/simple-dockerfile', + project='figtest', ) container = service.start_container() container.wait() self.assertIn('success', container.logs()) - self.assertEqual(len(self.client.images(name='default_test')), 1) + self.assertEqual(len(self.client.images(name='figtest_test')), 1) def test_start_container_uses_tagged_image_if_it_exists(self): - self.client.build('tests/fixtures/simple-dockerfile', tag='default_test') + self.client.build('tests/fixtures/simple-dockerfile', tag='figtest_test') service = Service( name='test', client=self.client, build='this/does/not/exist/and/will/throw/error', + project='figtest', ) container = service.start_container() container.wait() diff --git a/tests/testcases.py b/tests/testcases.py index b363bc00300..d43ab394431 100644 --- a/tests/testcases.py +++ b/tests/testcases.py @@ -12,11 +12,13 @@ def setUpClass(cls): def setUp(self): for c in self.client.containers(all=True): - self.client.kill(c['Id']) - self.client.remove_container(c['Id']) + if c['Names'] and 'figtest' in c['Names'][0]: + self.client.kill(c['Id']) + self.client.remove_container(c['Id']) def create_service(self, name, **kwargs): return Service( + project='figtest', name=name, client=self.client, image="ubuntu", From a39db86651a62f9abda12f4272d10b2e4c5cff3b Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Thu, 2 Jan 2014 15:28:33 +0000 Subject: [PATCH 0120/4072] Add "fig build" command --- fig/cli/main.py | 9 +++++++++ fig/project.py | 7 +++++++ fig/service.py | 5 ++++- 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/fig/cli/main.py b/fig/cli/main.py index f5b4f166b63..2f014a36c60 100644 --- a/fig/cli/main.py +++ b/fig/cli/main.py @@ -71,6 +71,7 @@ class TopLevelCommand(Command): --version Print version and exit Commands: + build Build or rebuild services kill Kill containers logs View output from containers ps List containers @@ -86,6 +87,14 @@ def docopt_options(self): options['version'] = "fig %s" % __version__ return options + def build(self, options): + """ + Build or rebuild services. + + Usage: build [SERVICE...] + """ + self.project.build(service_names=options['SERVICE']) + def kill(self, options): """ Kill containers. diff --git a/fig/project.py b/fig/project.py index eb9e8d43d93..9180dace6e6 100644 --- a/fig/project.py +++ b/fig/project.py @@ -93,6 +93,13 @@ def kill(self, service_names=None, **options): for service in self.get_services(service_names): service.kill(**options) + def build(self, service_names=None, **options): + for service in self.get_services(service_names): + if service.can_be_built(): + service.build(**options) + else: + log.info('%s uses an image, skipping') + def remove_stopped(self, service_names=None, **options): for service in self.get_services(service_names): service.remove_stopped(**options) diff --git a/fig/service.py b/fig/service.py index 537842db327..3fb0e4287e9 100644 --- a/fig/service.py +++ b/fig/service.py @@ -137,7 +137,7 @@ def _get_container_options(self, override_options, one_off=False): if 'volumes' in container_options: container_options['volumes'] = dict((v.split(':')[1], {}) for v in container_options['volumes']) - if 'build' in self.options: + if self.can_be_built(): if len(self.client.images(name=self._build_tag_name())) == 0: self.build() container_options['image'] = self._build_tag_name() @@ -167,6 +167,9 @@ def build(self): return image_id + def can_be_built(self): + return 'build' in self.options + def _build_tag_name(self): """ The tag to give to images built for this service. From 38478ea50477db649b27d9bed9aba7b5b06f9237 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 2 Jan 2014 17:00:49 +0000 Subject: [PATCH 0121/4072] Full fig.yml and environment variable reference --- README.md | 95 +++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 68 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index cf661cefea8..4b053b53648 100644 --- a/README.md +++ b/README.md @@ -123,7 +123,7 @@ web: ### Communicating between containers -Your web app will probably need to talk to your database. You can use [Docker links](http://docs.docker.io/en/latest/use/port_redirection/#linking-a-container) to enable containers to communicate, pass in the right IP address and port via environment variables: +Your web app will probably need to talk to your database. You can use [Docker links] to enable containers to communicate, pass in the right IP address and port via environment variables: ```yaml db: @@ -135,49 +135,90 @@ web: - db ``` -This will pass an environment variable called `MYAPP_DB_1_PORT` into the web container, whose value will look like `tcp://172.17.0.4:45678`. Your web app's code can use that to connect to the database. To see all of the environment variables available, run `env` inside a container: +This will pass an environment variable called `MYAPP_DB_1_PORT` into the web container (where MYAPP is the name of the current directory). Your web app's code can use that to connect to the database. ```bash $ fig up -d db $ fig run web env +... +MYAPP_DB_1_PORT=tcp://172.17.0.5:5432 +... ``` +The full set of environment variables is documented in the Reference section. -### Container configuration options +Running a one-off command +------------------------- -You can pass extra configuration options to a container, much like with `docker run`: +If you want to run a management command, use `fig run` to start a one-off container: -```yaml -web: - build: . +```bash +$ fig run db createdb myapp_development +$ fig run web rake db:migrate +$ fig run web bash +``` - -- override the default command - command: bundle exec thin -p 3000 +Reference +--------- - -- expose ports, optionally specifying both host and container ports (a random host port will be chosen otherwise) - ports: - - 3000 - - 8000:8000 +### fig.yml - -- map volumes - volumes: - - cache/:/tmp/cache +Each service defined in `fig.yml` must specify exactly one of `image` or `build`. Other keys are optional, and are analogous to their `docker run` command-line counterparts. - -- add environment variables - environment: - RACK_ENV: development +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 `fig.yml`. + +```yaml +-- Tag or partial image ID. Can be local or remote - Fig will attempt to pull if it doesn't exist locally. +image: ubuntu +image: orchardup/postgresql +image: a4bc65fd + +-- Path to a directory containing a Dockerfile. Fig will build and tag it with a generated name, and use that image thereafter. +build: /path/to/build/dir + +-- Override the default command. +command: bundle exec thin -p 3000 + +-- Link to containers in another service (see "Communicating between containers"). +links: + - db + - redis + +-- Expose ports. Either specify both ports (HOST:CONTAINER), or just the container port (a random host port will be chosen). +ports: + - 3000 + - 8000:8000 + +-- Map volumes from the host machine (HOST:CONTAINER). +volumes: + - cache/:/tmp/cache + +-- Add environment variables. +environment: + RACK_ENV: development ``` +### Environment variables -Running a one-off command -------------------------- +Fig 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. -If you want to run a management command, use `fig run` to start a one-off container: +name\_PORT
+Full URL, e.g. `MYAPP_DB_1_PORT=tcp://172.17.0.5:5432` -```bash -$ fig run db createdb myapp_development -$ fig run web rake db:migrate -$ fig run web bash -``` +name\_PORT\_num\_protocol
+Full URL, e.g. `MYAPP_DB_1_PORT_5432_TCP=tcp://172.17.0.5:5432` + +name\_PORT\_num\_protocol\_ADDR
+Container's IP address, e.g. `MYAPP_DB_1_PORT_5432_TCP_ADDR=172.17.0.5` + +name\_PORT\_num\_protocol\_PORT
+Exposed port number, e.g. `MYAPP_DB_1_PORT_5432_TCP_PORT=5432` + +name\_PORT\_num\_protocol\_PROTO
+Protocol (tcp or udp), e.g. `MYAPP_DB_1_PORT_5432_TCP_PROTO=tcp` + +name\_NAME
+Fully qualified container name, e.g. `MYAPP_DB_1_NAME=/myapp_web_1/myapp_db_1` +[Docker links]: http://docs.docker.io/en/latest/use/port_redirection/#linking-a-container From 6d0702e6075e8bdc9d4094fbd063214870f407cc Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Thu, 2 Jan 2014 18:30:47 +0000 Subject: [PATCH 0122/4072] Send log output to stderr --- fig/cli/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fig/cli/main.py b/fig/cli/main.py index 2f014a36c60..a8c89e57026 100644 --- a/fig/cli/main.py +++ b/fig/cli/main.py @@ -20,7 +20,7 @@ def main(): - console_handler = logging.StreamHandler() + console_handler = logging.StreamHandler(stream=sys.stderr) console_handler.setFormatter(logging.Formatter()) console_handler.setLevel(logging.INFO) root_logger = logging.getLogger() From 9b289b6f3bf5420022147ef0cdff8a1a1f13be63 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Thu, 2 Jan 2014 19:18:08 +0000 Subject: [PATCH 0123/4072] Stop "fig up" containers gracefully With double ctrl-c force. --- fig/cli/main.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/fig/cli/main.py b/fig/cli/main.py index a8c89e57026..d4ac576b9f2 100644 --- a/fig/cli/main.py +++ b/fig/cli/main.py @@ -1,6 +1,8 @@ import logging import sys import re +import signal +import sys from inspect import getdoc @@ -229,7 +231,13 @@ def up(self, options): try: log_printer.run() finally: - self.project.kill(service_names=options['SERVICE']) + def handler(signal, frame): + self.project.kill(service_names=options['SERVICE']) + sys.exit(0) + signal.signal(signal.SIGINT, handler) + + print "Gracefully stopping... (press Ctrl+C again to force)" + self.project.stop(service_names=options['SERVICE']) def _attach_to_container(self, container_id, interactive, logs=False, stream=True, raw=False): stdio = self.client.attach_socket( From 5b1fd647084fdcef8f52dd8919ca47b5ac194569 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Thu, 2 Jan 2014 20:04:14 +0000 Subject: [PATCH 0124/4072] Add getting started guide to readme --- README.md | 177 ++++++++++++++++++++++++++---------------------------- 1 file changed, 84 insertions(+), 93 deletions(-) diff --git a/README.md b/README.md index 4b053b53648..ec925c6142a 100644 --- a/README.md +++ b/README.md @@ -36,127 +36,118 @@ There are commands to: Fig is a project from [Orchard](https://orchardup.com), a Docker hosting service. [Follow us on Twitter](https://twitter.com/orchardup) to keep up to date with Fig and other Docker news. -Installing ----------- +Getting started +--------------- -```bash -$ sudo pip install fig -``` - -Defining your app ------------------ - -Put a `fig.yml` in your app's directory. Each top-level key defines a service, such as a web app, database or cache. For each service, Fig will start a Docker container, so at minimum it needs to know what image to use. - -The simplest way to get started is to just give it an image name: - -```yaml -db: - image: orchardup/postgresql -``` - -You've now given Fig the minimal amount of configuration it needs to run: +Let's get a basic Python web app running on Fig. It assumes a little knowledge of Python, but the concepts should be clear if you're not familiar with it. -```bash -$ fig up -Pulling image orchardup/postgresql... -Starting myapp_db_1... -myapp_db_1 is running at 127.0.0.1:45678 -<...output from postgresql server...> -``` - -For each service you've defined, Fig will start a Docker container with the specified image, building or pulling it if necessary. You now have a PostgreSQL server running at `127.0.0.1:45678`. +First, install Docker. If you're on OS X, you can use [docker-osx](https://github.com/noplay/docker-osx): -By default, `fig up` will run until each container has shut down, and relay their output to the terminal. To run in the background instead, pass the `-d` flag: + $ curl https://raw.github.com/noplay/docker-osx/master/docker > /usr/local/bin/docker + $ chmod +x /usr/local/bin/docker + $ docker version -```bash -$ fig up -d -Starting myapp_db_1... done -myapp_db_1 is running at 127.0.0.1:45678 +Docker has guides for [Ubuntu](http://docs.docker.io/en/latest/installation/ubuntulinux/) and [other platforms](http://docs.docker.io/en/latest/installation/) in their documentation. -$ fig ps -Name State Ports ------------------------------------- -myapp_db_1 Up 5432->45678/tcp -``` +Next, install Fig: -### Building services + $ sudo pip install fig -Fig can automatically build images for you if your service specifies a directory with a `Dockerfile` in it (or a Git URL, as per the `docker build` command). +You'll want to make a directory for the project: -This example will build an image with `app.py` inside it: + $ mkdir figtest + $ cd figtest -#### app.py +Inside this directory, create `app.py`, a simple web app that uses the Flask framework and increments a value in Redis: ```python -print "Hello world!" -``` - -#### fig.yml - -```yaml -web: - build: . -``` - -#### Dockerfile - - FROM ubuntu:12.04 - RUN apt-get install python - ADD . /opt - WORKDIR /opt +from flask import Flask +from redis import Redis +import os +app = Flask(__name__) +redis = Redis( + host=os.environ.get('FIGTEST_REDIS_1_PORT_6379_TCP_ADDR'), + port=int(os.environ.get('FIGTEST_REDIS_1_PORT_6379_TCP_PORT')) +) + +@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)``` + +We define our Python dependencies in a file called `requirements.txt`: + + flask + redis + +And we define how to build this into a Docker image using a file called `Dockerfile`: + + FROM stackbrew/ubuntu:13.10 + RUN apt-get -qq update + RUN apt-get install -y python python-pip + ADD . /code + WORKDIR /code + RUN pip install -r requirements.txt + EXPOSE 5000 CMD python app.py +That tells Docker to create an image with Python and Flask installed on it, run the command `python app.py`, and open port 5000 (the port that Flask listens on). +We then define a set of services using `fig.yml`: -### Getting your code in + web: + build: . + ports: + - 5000:5000 + volumes: + - .:/code + links: + - redis + redis: + image: orchardup/redis -If you want to work on an application being run by Fig, you probably don't want to have to rebuild your image every time you make a change. To solve this, you can share the directory with the container using a volume so the changes are reflected immediately: +This defines two services: -```yaml -web: - build: . - volumes: - - .:/opt -``` + - `web`, which is built from `Dockerfile` in the current directory. It also says to forward the exposed port 5000 on the container to port 5000 on the host machine, connect up the `redis` service, and mount the current directory inside the container so we can work on code without having to rebuild the image. + - `redis`, which uses the public image [orchardup/redis](https://index.docker.io/u/orchardup/redis/). +Now if we run `fig up`, it'll pull a Redis image, build an image for our own app, and start everything up: -### Communicating between containers + $ fig up + Pulling image orchardup/redis... + Building web... + Starting figtest_redis_1... + Starting figtest_web_1... + figtest_redis_1 | [8] 02 Jan 18:43:35.576 # Server started, Redis version 2.8.3 + figtest_web_1 | * Running on http://0.0.0.0:5000/ -Your web app will probably need to talk to your database. You can use [Docker links] to enable containers to communicate, pass in the right IP address and port via environment variables: +Open up [http://localhost:5000](http://localhost:5000) in your browser (or [http://localdocker:5000](http://localdocker:5000) if you're using [docker-osx](https://github.com/noplay/docker-osx)). That's it! -```yaml -db: - image: orchardup/postgresql +You can also pass the `-d` flag to `fig up` to run your services in the background, and use `fig ps` to see what is currently running: -web: - build: . - links: - - db -``` + $ fig up -d + Starting figtest_redis_1... + Starting figtest_web_1... + $ fig ps + Name Command State Ports + ------------------------------------------------------------------- + figtest_redis_1 /usr/local/bin/run Up + figtest_web_1 /bin/sh -c python app.py Up 5000->5000/tcp -This will pass an environment variable called `MYAPP_DB_1_PORT` into the web container (where MYAPP is the name of the current directory). Your web app's code can use that to connect to the database. +`fig run` allows you to run one-off commands for your services. For example, to see what environment variables are available to the `web` service: -```bash -$ fig up -d db -$ fig run web env -... -MYAPP_DB_1_PORT=tcp://172.17.0.5:5432 -... -``` + $ fig run web env -The full set of environment variables is documented in the Reference section. -Running a one-off command -------------------------- +Run `fig --help` to see what other commands are available. You'll probably want to stop them when you've finished: -If you want to run a management command, use `fig run` to start a one-off container: + $ fig stop + +That's more-or-less how Fig works. See the reference section below for full details on the commands, configuration file and environment variables. -```bash -$ fig run db createdb myapp_development -$ fig run web rake db:migrate -$ fig run web bash -``` Reference --------- From 0a92cbfa4d63b18098bac10f01c0e15de9f76599 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Thu, 2 Jan 2014 20:11:59 +0000 Subject: [PATCH 0125/4072] Fix readme formatting --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ec925c6142a..3e5bbb2302d 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,8 @@ def hello(): return 'Hello World! I have been seen %s times.' % redis.get('hits') if __name__ == "__main__": - app.run(host="0.0.0.0", debug=True)``` + app.run(host="0.0.0.0", debug=True) +``` We define our Python dependencies in a file called `requirements.txt`: From 36002f95edcb4f7664d03c42e7f96bcf56232903 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Thu, 2 Jan 2014 20:51:35 +0000 Subject: [PATCH 0126/4072] Try connecting to localdocker:4243 See https://github.com/noplay/docker-osx/pull/22 --- fig/cli/utils.py | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/fig/cli/utils.py b/fig/cli/utils.py index 1094b5e77b2..a1ff0c008c4 100644 --- a/fig/cli/utils.py +++ b/fig/cli/utils.py @@ -83,21 +83,29 @@ def docker_url(): return os.environ['DOCKER_URL'] socket_path = '/var/run/docker.sock' + tcp_hosts = [ + ('localdocker', 4243), + ('127.0.0.1', 4243), + ] tcp_host = '127.0.0.1' tcp_port = 4243 if os.path.exists(socket_path): return 'unix://%s' % socket_path - try: - s = socket.socket() - s.connect((tcp_host, tcp_port)) - s.close() - return 'http://%s:%s' % (tcp_host, tcp_port) - except: - pass + for host, port in tcp_hosts: + try: + s = socket.create_connection((host, port), timeout=1) + s.close() + return 'http://%s:%s' % (host, port) + except: + pass raise UserError(""" - Couldn't find Docker daemon - tried %s and %s:%s. - If it's running elsewhere, specify a url with DOCKER_URL. - """ % (socket_path, tcp_host, tcp_port)) +Couldn't find Docker daemon - tried: + +unix://%s +%s + +If it's running elsewhere, specify a url with DOCKER_URL. + """ % (socket_path, '\n'.join('tcp://%s:%s' % h for h in tcp_hosts))) From 45b7bd43613c31f02e480425f783ae5752d842c9 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Thu, 2 Jan 2014 21:23:47 +0000 Subject: [PATCH 0127/4072] Readme tweaks --- README.md | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 3e5bbb2302d..db44d3bb8f8 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,8 @@ Next, install Fig: $ sudo pip install fig +(If you don’t have pip installed, try `brew install python` or `apt-get install python-pip`.) + You'll want to make a directory for the project: $ mkdir figtest @@ -112,10 +114,10 @@ We then define a set of services using `fig.yml`: This defines two services: - - `web`, which is built from `Dockerfile` in the current directory. It also says to forward the exposed port 5000 on the container to port 5000 on the host machine, connect up the `redis` service, and mount the current directory inside the container so we can work on code without having to rebuild the image. + - `web`, which is built from `Dockerfile` in the current directory. It also says to forward the exposed port 5000 on the container to port 5000 on the host machine, connect up the Redis service, and mount the current directory inside the container so we can work on code without having to rebuild the image. - `redis`, which uses the public image [orchardup/redis](https://index.docker.io/u/orchardup/redis/). -Now if we run `fig up`, it'll pull a Redis image, build an image for our own app, and start everything up: +Now if we run `fig up`, it'll pull a Redis image, build an image for our own code, and start everything up: $ fig up Pulling image orchardup/redis... @@ -125,9 +127,9 @@ Now if we run `fig up`, it'll pull a Redis image, build an image for our own app figtest_redis_1 | [8] 02 Jan 18:43:35.576 # Server started, Redis version 2.8.3 figtest_web_1 | * Running on http://0.0.0.0:5000/ -Open up [http://localhost:5000](http://localhost:5000) in your browser (or [http://localdocker:5000](http://localdocker:5000) if you're using [docker-osx](https://github.com/noplay/docker-osx)). That's it! +Open up [http://localhost:5000](http://localhost:5000) in your browser (or [http://localdocker:5000](http://localdocker:5000) if you're using [docker-osx](https://github.com/noplay/docker-osx)) and you should see it running! -You can also pass the `-d` flag to `fig up` to run your services in the background, and use `fig ps` to see what is currently running: +If you want to run your services in the background, you can pass the `-d` flag to `fig up` and use `fig ps` to see what is currently running: $ fig up -d Starting figtest_redis_1... @@ -143,11 +145,13 @@ You can also pass the `-d` flag to `fig up` to run your services in the backgrou $ fig run web env -Run `fig --help` to see what other commands are available. You'll probably want to stop them when you've finished: +See `fig --help` other commands that are available. + +You'll probably want to stop your services when you've finished with them: $ fig stop -That's more-or-less how Fig works. See the reference section below for full details on the commands, configuration file and environment variables. +That's more-or-less how Fig works. See the reference section below for full details on the commands, configuration file and environment variables. If you have any thoughts or suggestions, [open an issue on GitHub](https://github.com/orchardup/fig) or [email us](mailto:hello@orchardup.com). Reference From e0a21e3df48bf7f4b564d7b4802c2b647fb7f8e8 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Thu, 2 Jan 2014 21:30:28 +0000 Subject: [PATCH 0128/4072] Bump version to 0.0.2 --- CHANGES.md | 9 +++++++++ fig/__init__.py | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index d360853d05d..8d5001eb309 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,6 +1,15 @@ Change log ========== +0.0.2 (2014-01-02) +------------------ + + - Improve documentation + - Try to connect to Docker on `tcp://localdocker:4243` and a UNIX socket in addition to `localhost`. + - Improve `fig up` behaviour + - Add confirmation prompt to `fig rm` + - Add `fig build` command + 0.0.1 (2013-12-20) ------------------ diff --git a/fig/__init__.py b/fig/__init__.py index 5282dce934b..7d6d5a305c9 100644 --- a/fig/__init__.py +++ b/fig/__init__.py @@ -1,3 +1,3 @@ from .service import Service -__version__ = '0.0.1' +__version__ = '0.0.2' From 0fb915e57e63866ce6bb8c514aa3ffa840d0ee60 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Thu, 2 Jan 2014 22:20:54 +0000 Subject: [PATCH 0129/4072] Add commands section to readme --- README.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/README.md b/README.md index db44d3bb8f8..51cc9761fc5 100644 --- a/README.md +++ b/README.md @@ -194,6 +194,52 @@ environment: RACK_ENV: development ``` +### Commands + +Most commands are run against one or more services. If the service is omitted, it will apply to all services. + +#### `build [SERVICE...]` + +Build or rebuild services. + +Services are built once and then tagged as `project\_service`. If you change a service's `Dockerfile` or its configuration in `fig.yml`, you will probably need to run `fig build` to rebuild it. + +#### `kill [SERVICE...]` + +Force stop service containers. + +#### `logs [SERVICE...]` + +View output from services. + +#### `ps` + +List running containers. + +#### `rm [SERVICE...]` + +Remove stopped service containers. + + +#### `run SERVICE COMMAND [ARGS...]` + +Run a one-off command for a service. + +Note that this will not start any services that the command's service links to. So if, for example, your one-off command talks to your database, you will need to run `fig up -d db` first. + +#### `start [SERVICE...]` + +Start existing containers for a service. + +#### `stop [SERVICE...]` + +Stop running containers without removing them. They can be started again with `fig start`. + +#### `up [SERVICE...]` + +Build, create, start and attach to containers for a service. + + ### Environment variables Fig 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. From 88c74d67f6cb84a1b1c66d52f90663bdaff25753 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Thu, 2 Jan 2014 22:31:14 +0000 Subject: [PATCH 0130/4072] Update tag line --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 51cc9761fc5..11389253085 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ Fig ==== -Punctual, lightweight development environments using Docker. +Punctual, isolated development environments using Docker. Fig is a tool for defining and running isolated application environments. You define the services which comprise your app in a simple, version-controllable YAML configuration file that looks like this: From febcbcddb98c1cdf8377c7f845e430afb40238d3 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Thu, 2 Jan 2014 22:36:04 +0000 Subject: [PATCH 0131/4072] Revert "Update tag line" This reverts commit 88c74d67f6cb84a1b1c66d52f90663bdaff25753. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 11389253085..51cc9761fc5 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ Fig ==== -Punctual, isolated development environments using Docker. +Punctual, lightweight development environments using Docker. Fig is a tool for defining and running isolated application environments. You define the services which comprise your app in a simple, version-controllable YAML configuration file that looks like this: From dd1f8934ad3c56a68877fa93c46e25246920965f Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Thu, 2 Jan 2014 22:48:51 +0000 Subject: [PATCH 0132/4072] Fix markdown formatting --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 51cc9761fc5..58ae7457350 100644 --- a/README.md +++ b/README.md @@ -202,7 +202,7 @@ Most commands are run against one or more services. If the service is omitted, i Build or rebuild services. -Services are built once and then tagged as `project\_service`. If you change a service's `Dockerfile` or its configuration in `fig.yml`, you will probably need to run `fig build` to rebuild it. +Services are built once and then tagged as `project_service`. If you change a service's `Dockerfile` or its configuration in `fig.yml`, you will probably need to run `fig build` to rebuild it. #### `kill [SERVICE...]` From 3d411ed0bb172b01706a953291fb857c0e22fa12 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Thu, 2 Jan 2014 23:05:58 +0000 Subject: [PATCH 0133/4072] Remove monospace font from command headings --- README.md | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 58ae7457350..817e25bc76c 100644 --- a/README.md +++ b/README.md @@ -198,44 +198,48 @@ environment: Most commands are run against one or more services. If the service is omitted, it will apply to all services. -#### `build [SERVICE...]` +Run `fig [COMMAND] --help` for full usage. + +#### build Build or rebuild services. Services are built once and then tagged as `project_service`. If you change a service's `Dockerfile` or its configuration in `fig.yml`, you will probably need to run `fig build` to rebuild it. -#### `kill [SERVICE...]` +#### kill Force stop service containers. -#### `logs [SERVICE...]` +#### logs View output from services. -#### `ps` +#### ps List running containers. -#### `rm [SERVICE...]` +#### rm Remove stopped service containers. -#### `run SERVICE COMMAND [ARGS...]` +#### run + +Run a one-off command for a service. E.g.: -Run a one-off command for a service. + $ fig run web python manage.py shell Note that this will not start any services that the command's service links to. So if, for example, your one-off command talks to your database, you will need to run `fig up -d db` first. -#### `start [SERVICE...]` +#### start Start existing containers for a service. -#### `stop [SERVICE...]` +#### stop Stop running containers without removing them. They can be started again with `fig start`. -#### `up [SERVICE...]` +#### up Build, create, start and attach to containers for a service. From 5ba7040df279724a98a90448854f680bd5d36fad Mon Sep 17 00:00:00 2001 From: Tom Stuart Date: Thu, 2 Jan 2014 23:27:47 +0000 Subject: [PATCH 0134/4072] Make logger available in project.py --- fig/project.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/fig/project.py b/fig/project.py index 9180dace6e6..24d7e7e5c60 100644 --- a/fig/project.py +++ b/fig/project.py @@ -1,5 +1,8 @@ +import logging from .service import Service +log = logging.getLogger(__name__) + def sort_service_dicts(services): # Sort in dependency order def cmp(x, y): From aaf90639a065d189a8d8ebbdb5a10d40c5f4b8c7 Mon Sep 17 00:00:00 2001 From: Tom Stuart Date: Thu, 2 Jan 2014 23:28:18 +0000 Subject: [PATCH 0135/4072] Include service name in log message --- fig/project.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fig/project.py b/fig/project.py index 24d7e7e5c60..97ff319db70 100644 --- a/fig/project.py +++ b/fig/project.py @@ -101,7 +101,7 @@ def build(self, service_names=None, **options): if service.can_be_built(): service.build(**options) else: - log.info('%s uses an image, skipping') + log.info('%s uses an image, skipping' % service.name) def remove_stopped(self, service_names=None, **options): for service in self.get_services(service_names): From 3fa80cd9745c3d6ac638c67ba62725f099d0891b Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Thu, 2 Jan 2014 23:47:04 +0000 Subject: [PATCH 0136/4072] Add note about fig rm/build dance This needs more thought. Ref #2 --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 817e25bc76c..4e046038aa0 100644 --- a/README.md +++ b/README.md @@ -204,7 +204,7 @@ Run `fig [COMMAND] --help` for full usage. Build or rebuild services. -Services are built once and then tagged as `project_service`. If you change a service's `Dockerfile` or its configuration in `fig.yml`, you will probably need to run `fig build` to rebuild it. +Services are built once and then tagged as `project_service`. If you change a service's `Dockerfile` or its configuration in `fig.yml`, you will probably need to run `fig build` to rebuild it, then run `fig rm` to make `fig up` recreate your containers. #### kill @@ -241,8 +241,9 @@ Stop running containers without removing them. They can be started again with `f #### up -Build, create, start and attach to containers for a service. +Build, create, start and attach to containers for a service. +If there are stopped containers for a service, `fig up` will start those again instead of creating new containers. When it exits, the containers it started will be stopped. This means if you want to recreate containers, you will need to explicitly run `fig rm`. ### Environment variables From 490742b89246d503acb81cd833b954efb3c7656b Mon Sep 17 00:00:00 2001 From: Tom Stuart Date: Fri, 3 Jan 2014 11:58:49 +0000 Subject: [PATCH 0137/4072] Emit a friendly error when fig.yml is missing I keep doing this by accident, so I'd rather not see the stack trace. --- fig/cli/command.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/fig/cli/command.py b/fig/cli/command.py index 36d4c0c6c62..813ea4db717 100644 --- a/fig/cli/command.py +++ b/fig/cli/command.py @@ -1,4 +1,5 @@ from docker import Client +import errno import logging import os import re @@ -18,7 +19,16 @@ def client(self): @cached_property def project(self): - config = yaml.load(open('fig.yml')) + try: + config = yaml.load(open('fig.yml')) + except IOError, e: + if e.errno == errno.ENOENT: + log.error("Can't find %s. Are you in the right directory?", e.filename) + else: + log.error(e) + + exit(1) + return Project.from_config(self.project_name, config, self.client) @cached_property From 17b9cc430c8f0ffe0d263e2afb08e7daf44f350f Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Mon, 6 Jan 2014 10:12:37 +0000 Subject: [PATCH 0138/4072] Add Travis CI --- .travis.yml | 16 +++++++++++++--- script/travis | 18 ++++++++++++++++++ 2 files changed, 31 insertions(+), 3 deletions(-) create mode 100755 script/travis diff --git a/.travis.yml b/.travis.yml index da53766e519..7ef225a85bb 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,10 +2,20 @@ language: python python: - "2.6" - "2.7" - - "3.2" - - "3.3" + install: + - sudo sh -c "wget -qO- https://get.docker.io/gpg | apt-key add -" + - sudo sh -c "echo deb http://get.docker.io/ubuntu docker main > /etc/apt/sources.list.d/docker.list" + - sudo apt-get update + - echo exit 101 | sudo tee /usr/sbin/policy-rc.d + - sudo chmod +x /usr/sbin/policy-rc.d + - sudo apt-get install -qy slirp lxc lxc-docker=0.7.3 + - git clone git://github.com/jpetazzo/sekexe - python setup.py install - pip install nose==1.3.0 -script: nosetests + +script: + - pwd + - env + - sekexe/run "`pwd`/script/travis $TRAVIS_PYTHON_VERSION" diff --git a/script/travis b/script/travis new file mode 100755 index 00000000000..650c9d395f2 --- /dev/null +++ b/script/travis @@ -0,0 +1,18 @@ +#!/bin/bash + +# Exit on first error +set -e + +TRAVIS_PYTHON_VERSION=$1 +source /home/travis/virtualenv/python${TRAVIS_PYTHON_VERSION}/bin/activate +env + +# Kill background processes on exit +trap 'kill $(jobs -p)' SIGINT SIGTERM EXIT + +# Start docker daemon +docker -d -H 0.0.0.0:4243 -H unix:///var/run/docker.sock 2>> /dev/null >> /dev/null & +sleep 2 + +# $init is set by sekexe +cd $(dirname $init)/.. && nosetests From ff9fa5661dd6f7f9e0572e7711db96586081a468 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Mon, 6 Jan 2014 11:22:46 +0000 Subject: [PATCH 0139/4072] Fix Python 2.6 --- .travis.yml | 2 +- requirements-dev.txt | 3 ++- tests/testcases.py | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.travis.yml b/.travis.yml index 7ef225a85bb..2a7cad5b587 100644 --- a/.travis.yml +++ b/.travis.yml @@ -12,7 +12,7 @@ install: - sudo apt-get install -qy slirp lxc lxc-docker=0.7.3 - git clone git://github.com/jpetazzo/sekexe - python setup.py install - - pip install nose==1.3.0 + - pip install -r requirements-dev.txt script: - pwd diff --git a/requirements-dev.txt b/requirements-dev.txt index f3c7e8e6ffb..4ef6576c491 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1 +1,2 @@ -nose +nose==1.3.0 +unittest2==0.5.1 diff --git a/tests/testcases.py b/tests/testcases.py index d43ab394431..c1da5389810 100644 --- a/tests/testcases.py +++ b/tests/testcases.py @@ -1,7 +1,7 @@ from docker import Client from fig.service import Service from fig.cli.utils import docker_url -from unittest import TestCase +from unittest2 import TestCase class DockerClientTestCase(TestCase): From 8de07ccf65ae5fc3b70919464ae3d01b6d874100 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Mon, 6 Jan 2014 11:34:44 +0000 Subject: [PATCH 0140/4072] Add Travis badge --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4e046038aa0..71d874837a9 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -Fig +Fig [![Build Status](https://travis-ci.org/orchardup/fig.png?branch=master)](https://travis-ci.org/orchardup/fig) ==== Punctual, lightweight development environments using Docker. From f96a1a0b35b37c4da711f060ad6337a70d4aba48 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Mon, 6 Jan 2014 11:22:46 +0000 Subject: [PATCH 0141/4072] Fix Python 2.6 --- tests/__init__.py | 7 +++++++ tests/testcases.py | 4 ++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/__init__.py b/tests/__init__.py index e69de29bb2d..b4d38cccb22 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -0,0 +1,7 @@ +import sys + +if sys.version_info >= (2,7): + import unittest +else: + import unittest2 as unittest + diff --git a/tests/testcases.py b/tests/testcases.py index c1da5389810..4ac61d9d7d4 100644 --- a/tests/testcases.py +++ b/tests/testcases.py @@ -1,10 +1,10 @@ from docker import Client from fig.service import Service from fig.cli.utils import docker_url -from unittest2 import TestCase +from . import unittest -class DockerClientTestCase(TestCase): +class DockerClientTestCase(unittest.TestCase): @classmethod def setUpClass(cls): cls.client = Client(docker_url()) From 93b9b6fd9fe23388682df75cc3b0212d4f6bb69f Mon Sep 17 00:00:00 2001 From: Christopher Grebs Date: Sun, 5 Jan 2014 18:26:32 -0800 Subject: [PATCH 0142/4072] First version with python3 support. * Moved requirements*.txt files to proper spec definitions in setup.py * Added a new fig.compat module to store some compatibility code --- MANIFEST.in | 4 +++- fig/__init__.py | 1 + fig/cli/colors.py | 1 + fig/cli/command.py | 4 +++- fig/cli/docopt_command.py | 2 ++ fig/cli/errors.py | 1 + fig/cli/formatter.py | 2 ++ fig/cli/log_printer.py | 4 +++- fig/cli/multiplexer.py | 1 + fig/cli/utils.py | 4 +++- fig/compat.py | 23 +++++++++++++++++++++++ fig/container.py | 4 +++- fig/project.py | 7 +++++-- fig/service.py | 10 ++++++---- requirements-dev.txt | 2 -- requirements.txt | 4 ---- setup.py | 30 ++++++++++++++++++++---------- tests/container_test.py | 1 + tests/project_test.py | 1 + tests/service_test.py | 2 ++ tests/testcases.py | 2 ++ 21 files changed, 83 insertions(+), 27 deletions(-) create mode 100644 fig/compat.py delete mode 100644 requirements-dev.txt delete mode 100644 requirements.txt diff --git a/MANIFEST.in b/MANIFEST.in index a929a01cefe..95e97bf385d 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,3 +1,5 @@ include LICENSE include *.md -include requirements.txt +recursive-include tests * +global-exclude *.pyc +global-exclode *.pyo diff --git a/fig/__init__.py b/fig/__init__.py index 7d6d5a305c9..baa08f1ed92 100644 --- a/fig/__init__.py +++ b/fig/__init__.py @@ -1,3 +1,4 @@ +from __future__ import unicode_literals from .service import Service __version__ = '0.0.2' diff --git a/fig/cli/colors.py b/fig/cli/colors.py index 09ec84bdb77..af4a32ab452 100644 --- a/fig/cli/colors.py +++ b/fig/cli/colors.py @@ -1,3 +1,4 @@ +from __future__ import unicode_literals NAMES = [ 'grey', 'red', diff --git a/fig/cli/command.py b/fig/cli/command.py index 813ea4db717..fb0adaf050a 100644 --- a/fig/cli/command.py +++ b/fig/cli/command.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals +from __future__ import absolute_import from docker import Client import errno import logging @@ -21,7 +23,7 @@ def client(self): def project(self): try: config = yaml.load(open('fig.yml')) - except IOError, e: + except IOError as e: if e.errno == errno.ENOENT: log.error("Can't find %s. Are you in the right directory?", e.filename) else: diff --git a/fig/cli/docopt_command.py b/fig/cli/docopt_command.py index d2aeb035fcb..cbb8e5303c8 100644 --- a/fig/cli/docopt_command.py +++ b/fig/cli/docopt_command.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals +from __future__ import absolute_import import sys from inspect import getdoc diff --git a/fig/cli/errors.py b/fig/cli/errors.py index 038a7ea18bc..bb1702fda62 100644 --- a/fig/cli/errors.py +++ b/fig/cli/errors.py @@ -1,3 +1,4 @@ +from __future__ import absolute_import from textwrap import dedent diff --git a/fig/cli/formatter.py b/fig/cli/formatter.py index 55a967f9f27..b57c1895f66 100644 --- a/fig/cli/formatter.py +++ b/fig/cli/formatter.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals +from __future__ import absolute_import import texttable import os diff --git a/fig/cli/log_printer.py b/fig/cli/log_printer.py index 480fc5ebafd..f31fb9b8740 100644 --- a/fig/cli/log_printer.py +++ b/fig/cli/log_printer.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals +from __future__ import absolute_import import sys from itertools import cycle @@ -41,7 +43,7 @@ def _attach(self, container): 'stream': True, } params.update(self.attach_params) - params = dict((name, 1 if value else 0) for (name, value) in params.items()) + params = dict((name, 1 if value else 0) for (name, value) in list(params.items())) return container.attach_socket(params=params, ws=True) def read_websocket(websocket): diff --git a/fig/cli/multiplexer.py b/fig/cli/multiplexer.py index cfcc3b6ce33..579f3bcac3f 100644 --- a/fig/cli/multiplexer.py +++ b/fig/cli/multiplexer.py @@ -1,3 +1,4 @@ +from __future__ import absolute_import from threading import Thread try: diff --git a/fig/cli/utils.py b/fig/cli/utils.py index a1ff0c008c4..8e8ab3767ab 100644 --- a/fig/cli/utils.py +++ b/fig/cli/utils.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals +from __future__ import absolute_import import datetime import os import socket @@ -69,7 +71,7 @@ def prettydate(d): return '{0} hours ago'.format(s/3600) -def mkdir(path, permissions=0700): +def mkdir(path, permissions=0o700): if not os.path.exists(path): os.mkdir(path) diff --git a/fig/compat.py b/fig/compat.py new file mode 100644 index 00000000000..5b01f6b3b71 --- /dev/null +++ b/fig/compat.py @@ -0,0 +1,23 @@ + + +# Taken from python2.7/3.3 functools +def cmp_to_key(mycmp): + """Convert a cmp= function into a key= function""" + class K(object): + __slots__ = ['obj'] + def __init__(self, obj): + self.obj = obj + def __lt__(self, other): + return mycmp(self.obj, other.obj) < 0 + def __gt__(self, other): + return mycmp(self.obj, other.obj) > 0 + def __eq__(self, other): + return mycmp(self.obj, other.obj) == 0 + def __le__(self, other): + return mycmp(self.obj, other.obj) <= 0 + def __ge__(self, other): + return mycmp(self.obj, other.obj) >= 0 + def __ne__(self, other): + return mycmp(self.obj, other.obj) != 0 + __hash__ = None + return K diff --git a/fig/container.py b/fig/container.py index 454ec91f66e..9556ec1f6fc 100644 --- a/fig/container.py +++ b/fig/container.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals +from __future__ import absolute_import import logging log = logging.getLogger(__name__) @@ -53,7 +55,7 @@ def human_readable_ports(self): if not self.dictionary['NetworkSettings']['Ports']: return '' ports = [] - for private, public in self.dictionary['NetworkSettings']['Ports'].items(): + for private, public in list(self.dictionary['NetworkSettings']['Ports'].items()): if public: ports.append('%s->%s' % (public[0]['HostPort'], private)) return ', '.join(ports) diff --git a/fig/project.py b/fig/project.py index 97ff319db70..3c536ab6dfa 100644 --- a/fig/project.py +++ b/fig/project.py @@ -1,5 +1,8 @@ +from __future__ import unicode_literals +from __future__ import absolute_import import logging from .service import Service +from .compat import cmp_to_key log = logging.getLogger(__name__) @@ -13,7 +16,7 @@ def cmp(x, y): elif y_deps_x and not x_deps_y: return -1 return 0 - return sorted(services, cmp=cmp) + return sorted(services, key=cmp_to_key(cmp)) class Project(object): """ @@ -43,7 +46,7 @@ def from_dicts(cls, name, service_dicts, client): @classmethod def from_config(cls, name, config, client): dicts = [] - for service_name, service in config.items(): + for service_name, service in list(config.items()): service['name'] = service_name dicts.append(service) return cls.from_dicts(name, dicts, client) diff --git a/fig/service.py b/fig/service.py index 3fb0e4287e9..4571f8197a2 100644 --- a/fig/service.py +++ b/fig/service.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals +from __future__ import absolute_import from docker.client import APIError import logging import re @@ -64,7 +66,7 @@ def create_container(self, one_off=False, **override_options): container_options = self._get_container_options(override_options, one_off=one_off) try: return Container.create(self.client, **container_options) - except APIError, e: + except APIError as e: if e.response.status_code == 404 and e.explanation and 'No such image' in e.explanation: log.info('Pulling image %s...' % container_options['image']) self.client.pull(container_options['image']) @@ -82,7 +84,7 @@ def start_container(self, container=None, **override_options): if options.get('ports', None) is not None: for port in options['ports']: - port = unicode(port) + port = str(port) if ':' in port: internal_port, external_port = port.split(':', 1) port_bindings[int(internal_port)] = int(external_port) @@ -107,7 +109,7 @@ def next_container_name(self, one_off=False): bits = [self.project, self.name] if one_off: bits.append('run') - return '_'.join(bits + [unicode(self.next_container_number(one_off=one_off))]) + return '_'.join(bits + [str(self.next_container_number(one_off=one_off))]) def next_container_number(self, one_off=False): numbers = [parse_name(c.name)[2] for c in self.containers(stopped=True, one_off=one_off)] @@ -132,7 +134,7 @@ def _get_container_options(self, override_options, one_off=False): container_options['name'] = self.next_container_name(one_off) if 'ports' in container_options: - container_options['ports'] = [unicode(p).split(':')[0] for p in container_options['ports']] + container_options['ports'] = [str(p).split(':')[0] for p in container_options['ports']] if 'volumes' in container_options: container_options['volumes'] = dict((v.split(':')[1], {}) for v in container_options['volumes']) diff --git a/requirements-dev.txt b/requirements-dev.txt deleted file mode 100644 index 4ef6576c491..00000000000 --- a/requirements-dev.txt +++ /dev/null @@ -1,2 +0,0 @@ -nose==1.3.0 -unittest2==0.5.1 diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index dccc0aa118e..00000000000 --- a/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -docker-py==0.2.3 -docopt==0.6.1 -PyYAML==3.10 -texttable==0.8.1 diff --git a/setup.py b/setup.py index 2f7131f68c1..60b01a43bbf 100644 --- a/setup.py +++ b/setup.py @@ -1,16 +1,17 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- - -from setuptools import setup +from __future__ import unicode_literals +from __future__ import absolute_import +from setuptools import setup, find_packages import re import os import codecs -# Borrowed from -# https://github.com/jezdez/django_compressor/blob/develop/setup.py def read(*parts): - return codecs.open(os.path.join(os.path.dirname(__file__), *parts)).read() + path = os.path.join(os.path.dirname(__file__), *parts) + with codecs.open(path, encoding='utf-8') as fobj: + return fobj.read() def find_version(*file_paths): @@ -21,8 +22,6 @@ def find_version(*file_paths): return version_match.group(1) raise RuntimeError("Unable to find version string.") -with open('requirements.txt') as f: - install_requires = f.read().splitlines() setup( name='fig', @@ -31,10 +30,21 @@ def find_version(*file_paths): url='https://github.com/orchardup/fig', author='Orchard Laboratories Ltd.', author_email='hello@orchardup.com', - packages=['fig', 'fig.cli'], - package_data={}, + packages=find_packages(), include_package_data=True, - install_requires=install_requires, + test_suite='nose.collector', + install_requires=[ + 'docker-py==0.2.3', + 'docopt==0.6.1', + 'PyYAML==3.10', + 'texttable==0.8.1', + # unfortunately `docker` requires six ==1.3.0 + 'six==1.3.0', + ], + tests_require=[ + 'nose==1.3.0', + 'unittest2==0.5.1' + ], entry_points=""" [console_scripts] fig=fig.cli.main:main diff --git a/tests/container_test.py b/tests/container_test.py index 0d6c5f0f626..3666b3e4408 100644 --- a/tests/container_test.py +++ b/tests/container_test.py @@ -1,3 +1,4 @@ +from __future__ import unicode_literals from .testcases import DockerClientTestCase from fig.container import Container diff --git a/tests/project_test.py b/tests/project_test.py index 82b9be9df7d..09792fabdd5 100644 --- a/tests/project_test.py +++ b/tests/project_test.py @@ -1,3 +1,4 @@ +from __future__ import unicode_literals from fig.project import Project from .testcases import DockerClientTestCase diff --git a/tests/service_test.py b/tests/service_test.py index f946ab38f8c..bc5c6ffe9cf 100644 --- a/tests/service_test.py +++ b/tests/service_test.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals +from __future__ import absolute_import from fig import Service from .testcases import DockerClientTestCase diff --git a/tests/testcases.py b/tests/testcases.py index 4ac61d9d7d4..671e091b7c8 100644 --- a/tests/testcases.py +++ b/tests/testcases.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals +from __future__ import absolute_import from docker import Client from fig.service import Service from fig.cli.utils import docker_url From bf8875d93075e34712b753c90b2affadba7db8d2 Mon Sep 17 00:00:00 2001 From: Christopher Grebs Date: Sun, 5 Jan 2014 18:53:26 -0800 Subject: [PATCH 0143/4072] Added tox file --- tox.ini | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 tox.ini diff --git a/tox.ini b/tox.ini new file mode 100644 index 00000000000..a4a195d665f --- /dev/null +++ b/tox.ini @@ -0,0 +1,8 @@ +[tox] +envlist = py26,py27,py32,py33,pypy + +[testenv] +commands = + pip install -e {toxinidir} + pip install -e {toxinidir}[test] + python setup.py test From 3c91315426fe7e614560668ce2684361bc8deb39 Mon Sep 17 00:00:00 2001 From: Christopher Grebs Date: Sun, 5 Jan 2014 19:06:12 -0800 Subject: [PATCH 0144/4072] Fix exception alias syntax --- fig/cli/main.py | 8 ++++---- fig/cli/socketclient.py | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/fig/cli/main.py b/fig/cli/main.py index d4ac576b9f2..3ce9e69cb21 100644 --- a/fig/cli/main.py +++ b/fig/cli/main.py @@ -38,18 +38,18 @@ def main(): except KeyboardInterrupt: log.error("\nAborting.") exit(1) - except UserError, e: + except UserError as e: log.error(e.msg) exit(1) - except NoSuchService, e: + except NoSuchService as e: log.error(e.msg) exit(1) - except NoSuchCommand, e: + except NoSuchCommand as e: log.error("No such command: %s", e.command) log.error("") log.error("\n".join(parse_doc_section("commands:", getdoc(e.supercommand)))) exit(1) - except APIError, e: + except APIError as e: log.error(e.explanation) exit(1) diff --git a/fig/cli/socketclient.py b/fig/cli/socketclient.py index 90ed8b587fb..550c30905e7 100644 --- a/fig/cli/socketclient.py +++ b/fig/cli/socketclient.py @@ -85,7 +85,7 @@ def recv_ws(self, socket, stream): stream.flush() else: break - except Exception, e: + except Exception as e: log.debug(e) def send_ws(self, socket, stream): @@ -101,7 +101,7 @@ def send_ws(self, socket, stream): else: try: socket.send(chunk) - except Exception, e: + except Exception as e: if hasattr(e, 'errno') and e.errno == errno.EPIPE: break else: From 30ea4508c308bfb28af67520e62192b825ed4958 Mon Sep 17 00:00:00 2001 From: Christopher Grebs Date: Sun, 5 Jan 2014 19:07:21 -0800 Subject: [PATCH 0145/4072] Use print function --- fig/cli/main.py | 16 ++++++++-------- fig/cli/socketclient.py | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/fig/cli/main.py b/fig/cli/main.py index 3ce9e69cb21..88ec1d47d1d 100644 --- a/fig/cli/main.py +++ b/fig/cli/main.py @@ -112,7 +112,7 @@ def logs(self, options): Usage: logs [SERVICE...] """ containers = self.project.containers(service_names=options['SERVICE'], stopped=False) - print "Attaching to", list_containers(containers) + print("Attaching to", list_containers(containers)) LogPrinter(containers, attach_params={'logs': True}).run() def ps(self, options): @@ -128,7 +128,7 @@ def ps(self, options): if options['-q']: for container in containers: - print container.id + print(container.id) else: headers = [ 'Name', @@ -144,7 +144,7 @@ def ps(self, options): container.human_readable_state, container.human_readable_ports, ]) - print Formatter().table(headers, rows) + print(Formatter().table(headers, rows)) def rm(self, options): """ @@ -156,11 +156,11 @@ def rm(self, options): 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) + print("Going to remove", list_containers(stopped_containers)) if yesno("Are you sure? [yN] ", default=False): self.project.remove_stopped(service_names=options['SERVICE']) else: - print "No stopped containers" + print("No stopped containers") def run(self, options): """ @@ -180,7 +180,7 @@ def run(self, options): container = service.create_container(one_off=True, **container_options) if options['-d']: service.start_container(container, ports=None) - print container.name + print(container.name) else: with self._attach_to_container( container.id, @@ -222,7 +222,7 @@ def up(self, options): containers = self.project.containers(service_names=options['SERVICE'], stopped=True) if not detached: - print "Attaching to", list_containers(containers) + print("Attaching to", list_containers(containers)) log_printer = LogPrinter(containers) self.project.start(service_names=options['SERVICE']) @@ -236,7 +236,7 @@ def handler(signal, frame): sys.exit(0) signal.signal(signal.SIGINT, handler) - print "Gracefully stopping... (press Ctrl+C again to force)" + print("Gracefully stopping... (press Ctrl+C again to force)") self.project.stop(service_names=options['SERVICE']) def _attach_to_container(self, container_id, interactive, logs=False, stream=True, raw=False): diff --git a/fig/cli/socketclient.py b/fig/cli/socketclient.py index 550c30905e7..b20ae39463b 100644 --- a/fig/cli/socketclient.py +++ b/fig/cli/socketclient.py @@ -123,7 +123,7 @@ def destroy(self): url = sys.argv[1] socket = websocket.create_connection(url) - print "connected\r" + print("connected\r") with SocketClient(socket, interactive=True) as client: client.run() From b101118d1e9fd414909b9954552a1fa5bcd7cf15 Mon Sep 17 00:00:00 2001 From: Christopher Grebs Date: Sun, 5 Jan 2014 19:07:44 -0800 Subject: [PATCH 0146/4072] Add future import for print function --- fig/cli/main.py | 1 + 1 file changed, 1 insertion(+) diff --git a/fig/cli/main.py b/fig/cli/main.py index 88ec1d47d1d..e061ab25f3d 100644 --- a/fig/cli/main.py +++ b/fig/cli/main.py @@ -1,3 +1,4 @@ +from __future__ import print_function import logging import sys import re From c6e91db32fae225df8908f6d649f2f24d95e6223 Mon Sep 17 00:00:00 2001 From: Christopher Grebs Date: Sun, 5 Jan 2014 19:13:41 -0800 Subject: [PATCH 0147/4072] Add texttable compat module that is py3k compatible --- fig/cli/formatter.py | 3 +- fig/{compat.py => compat/__init__.py} | 0 fig/compat/texttable.py | 579 ++++++++++++++++++++++++++ setup.py | 1 - 4 files changed, 581 insertions(+), 2 deletions(-) rename fig/{compat.py => compat/__init__.py} (100%) create mode 100644 fig/compat/texttable.py diff --git a/fig/cli/formatter.py b/fig/cli/formatter.py index b57c1895f66..47bf680cd97 100644 --- a/fig/cli/formatter.py +++ b/fig/cli/formatter.py @@ -1,8 +1,9 @@ from __future__ import unicode_literals from __future__ import absolute_import -import texttable import os +from fig.compat import texttable + class Formatter(object): def table(self, headers, rows): diff --git a/fig/compat.py b/fig/compat/__init__.py similarity index 100% rename from fig/compat.py rename to fig/compat/__init__.py diff --git a/fig/compat/texttable.py b/fig/compat/texttable.py new file mode 100644 index 00000000000..a60be2ba618 --- /dev/null +++ b/fig/compat/texttable.py @@ -0,0 +1,579 @@ +#!/usr/bin/env python +# +# texttable - module for creating simple ASCII tables +# Copyright (C) 2003-2011 Gerome Fournier +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +"""module for creating simple ASCII tables + + +Example: + + table = Texttable() + table.set_cols_align(["l", "r", "c"]) + table.set_cols_valign(["t", "m", "b"]) + table.add_rows([ ["Name", "Age", "Nickname"], + ["Mr\\nXavier\\nHuon", 32, "Xav'"], + ["Mr\\nBaptiste\\nClement", 1, "Baby"] ]) + print(table.draw() + "\\n") + + table = Texttable() + table.set_deco(Texttable.HEADER) + table.set_cols_dtype(['t', # text + 'f', # float (decimal) + 'e', # float (exponent) + 'i', # integer + 'a']) # automatic + table.set_cols_align(["l", "r", "r", "r", "l"]) + table.add_rows([["text", "float", "exp", "int", "auto"], + ["abcd", "67", 654, 89, 128.001], + ["efghijk", 67.5434, .654, 89.6, 12800000000000000000000.00023], + ["lmn", 5e-78, 5e-78, 89.4, .000000000000128], + ["opqrstu", .023, 5e+78, 92., 12800000000000000000000]]) + print(table.draw()) + +Result: + + +----------+-----+----------+ + | Name | Age | Nickname | + +==========+=====+==========+ + | Mr | | | + | Xavier | 32 | | + | Huon | | Xav' | + +----------+-----+----------+ + | Mr | | | + | Baptiste | 1 | | + | Clement | | Baby | + +----------+-----+----------+ + + text float exp int auto + =========================================== + abcd 67.000 6.540e+02 89 128.001 + efgh 67.543 6.540e-01 90 1.280e+22 + ijkl 0.000 5.000e-78 89 0.000 + mnop 0.023 5.000e+78 92 1.280e+22 +""" + +__all__ = ["Texttable", "ArraySizeError"] + +__author__ = 'Gerome Fournier ' +__license__ = 'LGPL' +__version__ = '0.8.1' +__credits__ = """\ +Jeff Kowalczyk: + - textwrap improved import + - comment concerning header output + +Anonymous: + - add_rows method, for adding rows in one go + +Sergey Simonenko: + - redefined len() function to deal with non-ASCII characters + +Roger Lew: + - columns datatype specifications + +Brian Peterson: + - better handling of unicode errors +""" + +# Modified version of `texttable` for python3 support. + +import sys +import string + + +def len(iterable): + """Redefining len here so it will be able to work with non-ASCII characters + """ + if not isinstance(iterable, str): + return iterable.__len__() + + try: + return len(unicode(iterable, 'utf')) + except: + return iterable.__len__() + + +class ArraySizeError(Exception): + """Exception raised when specified rows don't fit the required size + """ + + def __init__(self, msg): + self.msg = msg + Exception.__init__(self, msg, '') + + def __str__(self): + return self.msg + +class Texttable: + + BORDER = 1 + HEADER = 1 << 1 + HLINES = 1 << 2 + VLINES = 1 << 3 + + def __init__(self, max_width=80): + """Constructor + + - max_width is an integer, specifying the maximum width of the table + - if set to 0, size is unlimited, therefore cells won't be wrapped + """ + + if max_width <= 0: + max_width = False + self._max_width = max_width + self._precision = 3 + + self._deco = Texttable.VLINES | Texttable.HLINES | Texttable.BORDER | \ + Texttable.HEADER + self.set_chars(['-', '|', '+', '=']) + self.reset() + + def reset(self): + """Reset the instance + + - reset rows and header + """ + + self._hline_string = None + self._row_size = None + self._header = [] + self._rows = [] + + def set_chars(self, array): + """Set the characters used to draw lines between rows and columns + + - the array should contain 4 fields: + + [horizontal, vertical, corner, header] + + - default is set to: + + ['-', '|', '+', '='] + """ + + if len(array) != 4: + raise ArraySizeError("array should contain 4 characters") + array = [ x[:1] for x in [ str(s) for s in array ] ] + (self._char_horiz, self._char_vert, + self._char_corner, self._char_header) = array + + def set_deco(self, deco): + """Set the table decoration + + - 'deco' can be a combinaison of: + + Texttable.BORDER: Border around the table + Texttable.HEADER: Horizontal line below the header + Texttable.HLINES: Horizontal lines between rows + Texttable.VLINES: Vertical lines between columns + + All of them are enabled by default + + - example: + + Texttable.BORDER | Texttable.HEADER + """ + + self._deco = deco + + def set_cols_align(self, array): + """Set the desired columns alignment + + - the elements of the array should be either "l", "c" or "r": + + * "l": column flushed left + * "c": column centered + * "r": column flushed right + """ + + self._check_row_size(array) + self._align = array + + def set_cols_valign(self, array): + """Set the desired columns vertical alignment + + - the elements of the array should be either "t", "m" or "b": + + * "t": column aligned on the top of the cell + * "m": column aligned on the middle of the cell + * "b": column aligned on the bottom of the cell + """ + + self._check_row_size(array) + self._valign = array + + def set_cols_dtype(self, array): + """Set the desired columns datatype for the cols. + + - the elements of the array should be either "a", "t", "f", "e" or "i": + + * "a": automatic (try to use the most appropriate datatype) + * "t": treat as text + * "f": treat as float in decimal format + * "e": treat as float in exponential format + * "i": treat as int + + - by default, automatic datatyping is used for each column + """ + + self._check_row_size(array) + self._dtype = array + + def set_cols_width(self, array): + """Set the desired columns width + + - the elements of the array should be integers, specifying the + width of each column. For example: + + [10, 20, 5] + """ + + self._check_row_size(array) + try: + array = map(int, array) + if reduce(min, array) <= 0: + raise ValueError + except ValueError: + sys.stderr.write("Wrong argument in column width specification\n") + raise + self._width = array + + def set_precision(self, width): + """Set the desired precision for float/exponential formats + + - width must be an integer >= 0 + + - default value is set to 3 + """ + + if not type(width) is int or width < 0: + raise ValueError('width must be an integer greater then 0') + self._precision = width + + def header(self, array): + """Specify the header of the table + """ + + self._check_row_size(array) + self._header = map(str, array) + + def add_row(self, array): + """Add a row in the rows stack + + - cells can contain newlines and tabs + """ + + self._check_row_size(array) + + if not hasattr(self, "_dtype"): + self._dtype = ["a"] * self._row_size + + cells = [] + for i,x in enumerate(array): + cells.append(self._str(i,x)) + self._rows.append(cells) + + def add_rows(self, rows, header=True): + """Add several rows in the rows stack + + - The 'rows' argument can be either an iterator returning arrays, + or a by-dimensional array + - 'header' specifies if the first row should be used as the header + of the table + """ + + # nb: don't use 'iter' on by-dimensional arrays, to get a + # usable code for python 2.1 + if header: + if hasattr(rows, '__iter__') and hasattr(rows, 'next'): + self.header(rows.next()) + else: + self.header(rows[0]) + rows = rows[1:] + for row in rows: + self.add_row(row) + + def draw(self): + """Draw the table + + - the table is returned as a whole string + """ + + if not self._header and not self._rows: + return + self._compute_cols_width() + self._check_align() + out = "" + if self._has_border(): + out += self._hline() + if self._header: + out += self._draw_line(self._header, isheader=True) + if self._has_header(): + out += self._hline_header() + length = 0 + for row in self._rows: + length += 1 + out += self._draw_line(row) + if self._has_hlines() and length < len(self._rows): + out += self._hline() + if self._has_border(): + out += self._hline() + return out[:-1] + + def _str(self, i, x): + """Handles string formatting of cell data + + i - index of the cell datatype in self._dtype + x - cell data to format + """ + try: + f = float(x) + except: + return str(x) + + n = self._precision + dtype = self._dtype[i] + + if dtype == 'i': + return str(int(round(f))) + elif dtype == 'f': + return '%.*f' % (n, f) + elif dtype == 'e': + return '%.*e' % (n, f) + elif dtype == 't': + return str(x) + else: + if f - round(f) == 0: + if abs(f) > 1e8: + return '%.*e' % (n, f) + else: + return str(int(round(f))) + else: + if abs(f) > 1e8: + return '%.*e' % (n, f) + else: + return '%.*f' % (n, f) + + def _check_row_size(self, array): + """Check that the specified array fits the previous rows size + """ + + if not self._row_size: + self._row_size = len(array) + elif self._row_size != len(array): + raise ArraySizeError("array should contain %d elements" % self._row_size) + + def _has_vlines(self): + """Return a boolean, if vlines are required or not + """ + + return self._deco & Texttable.VLINES > 0 + + def _has_hlines(self): + """Return a boolean, if hlines are required or not + """ + + return self._deco & Texttable.HLINES > 0 + + def _has_border(self): + """Return a boolean, if border is required or not + """ + + return self._deco & Texttable.BORDER > 0 + + def _has_header(self): + """Return a boolean, if header line is required or not + """ + + return self._deco & Texttable.HEADER > 0 + + def _hline_header(self): + """Print header's horizontal line + """ + + return self._build_hline(True) + + def _hline(self): + """Print an horizontal line + """ + + if not self._hline_string: + self._hline_string = self._build_hline() + return self._hline_string + + def _build_hline(self, is_header=False): + """Return a string used to separated rows or separate header from + rows + """ + horiz = self._char_horiz + if (is_header): + horiz = self._char_header + # compute cell separator + s = "%s%s%s" % (horiz, [horiz, self._char_corner][self._has_vlines()], + horiz) + # build the line + l = string.join([horiz * n for n in self._width], s) + # add border if needed + if self._has_border(): + l = "%s%s%s%s%s\n" % (self._char_corner, horiz, l, horiz, + self._char_corner) + else: + l += "\n" + return l + + def _len_cell(self, cell): + """Return the width of the cell + + Special characters are taken into account to return the width of the + cell, such like newlines and tabs + """ + + cell_lines = cell.split('\n') + maxi = 0 + for line in cell_lines: + length = 0 + parts = line.split('\t') + for part, i in zip(parts, range(1, len(parts) + 1)): + length = length + len(part) + if i < len(parts): + length = (length/8 + 1) * 8 + maxi = max(maxi, length) + return maxi + + def _compute_cols_width(self): + """Return an array with the width of each column + + If a specific width has been specified, exit. If the total of the + columns width exceed the table desired width, another width will be + computed to fit, and cells will be wrapped. + """ + + if hasattr(self, "_width"): + return + maxi = [] + if self._header: + maxi = [ self._len_cell(x) for x in self._header ] + for row in self._rows: + for cell,i in zip(row, range(len(row))): + try: + maxi[i] = max(maxi[i], self._len_cell(cell)) + except (TypeError, IndexError): + maxi.append(self._len_cell(cell)) + items = len(maxi) + length = reduce(lambda x,y: x+y, maxi) + if self._max_width and length + items * 3 + 1 > self._max_width: + maxi = [(self._max_width - items * 3 -1) / items \ + for n in range(items)] + self._width = maxi + + def _check_align(self): + """Check if alignment has been specified, set default one if not + """ + + if not hasattr(self, "_align"): + self._align = ["l"] * self._row_size + if not hasattr(self, "_valign"): + self._valign = ["t"] * self._row_size + + def _draw_line(self, line, isheader=False): + """Draw a line + + Loop over a single cell length, over all the cells + """ + + line = self._splitit(line, isheader) + space = " " + out = "" + for i in range(len(line[0])): + if self._has_border(): + out += "%s " % self._char_vert + length = 0 + for cell, width, align in zip(line, self._width, self._align): + length += 1 + cell_line = cell[i] + fill = width - len(cell_line) + if isheader: + align = "c" + if align == "r": + out += "%s " % (fill * space + cell_line) + elif align == "c": + out += "%s " % (fill/2 * space + cell_line \ + + (fill/2 + fill%2) * space) + else: + out += "%s " % (cell_line + fill * space) + if length < len(line): + out += "%s " % [space, self._char_vert][self._has_vlines()] + out += "%s\n" % ['', self._char_vert][self._has_border()] + return out + + def _splitit(self, line, isheader): + """Split each element of line to fit the column width + + Each element is turned into a list, result of the wrapping of the + string to the desired width + """ + + line_wrapped = [] + for cell, width in zip(line, self._width): + array = [] + for c in cell.split('\n'): + try: + c = unicode(c, 'utf') + except UnicodeDecodeError as strerror: + sys.stderr.write("UnicodeDecodeError exception for string '%s': %s\n" % (c, strerror)) + c = unicode(c, 'utf', 'replace') + array.extend(textwrap.wrap(c, width)) + line_wrapped.append(array) + max_cell_lines = reduce(max, map(len, line_wrapped)) + for cell, valign in zip(line_wrapped, self._valign): + if isheader: + valign = "t" + if valign == "m": + missing = max_cell_lines - len(cell) + cell[:0] = [""] * (missing / 2) + cell.extend([""] * (missing / 2 + missing % 2)) + elif valign == "b": + cell[:0] = [""] * (max_cell_lines - len(cell)) + else: + cell.extend([""] * (max_cell_lines - len(cell))) + return line_wrapped + +if __name__ == '__main__': + table = Texttable() + table.set_cols_align(["l", "r", "c"]) + table.set_cols_valign(["t", "m", "b"]) + table.add_rows([ ["Name", "Age", "Nickname"], + ["Mr\nXavier\nHuon", 32, "Xav'"], + ["Mr\nBaptiste\nClement", 1, "Baby"] ]) + print(table.draw() + "\n") + + table = Texttable() + table.set_deco(Texttable.HEADER) + table.set_cols_dtype(['t', # text + 'f', # float (decimal) + 'e', # float (exponent) + 'i', # integer + 'a']) # automatic + table.set_cols_align(["l", "r", "r", "r", "l"]) + table.add_rows([["text", "float", "exp", "int", "auto"], + ["abcd", "67", 654, 89, 128.001], + ["efghijk", 67.5434, .654, 89.6, 12800000000000000000000.00023], + ["lmn", 5e-78, 5e-78, 89.4, .000000000000128], + ["opqrstu", .023, 5e+78, 92., 12800000000000000000000]]) + print(table.draw()) + diff --git a/setup.py b/setup.py index 60b01a43bbf..a114bdf97c9 100644 --- a/setup.py +++ b/setup.py @@ -37,7 +37,6 @@ def find_version(*file_paths): 'docker-py==0.2.3', 'docopt==0.6.1', 'PyYAML==3.10', - 'texttable==0.8.1', # unfortunately `docker` requires six ==1.3.0 'six==1.3.0', ], From f600fa8bf3ec62b77e9dc036b88d0f96bd766aa8 Mon Sep 17 00:00:00 2001 From: Christopher Grebs Date: Sun, 5 Jan 2014 19:15:09 -0800 Subject: [PATCH 0148/4072] More future imports for safety --- fig/cli/socketclient.py | 1 + fig/cli/utils.py | 1 + fig/compat/texttable.py | 3 +++ 3 files changed, 5 insertions(+) diff --git a/fig/cli/socketclient.py b/fig/cli/socketclient.py index b20ae39463b..bbae60e6de2 100644 --- a/fig/cli/socketclient.py +++ b/fig/cli/socketclient.py @@ -1,3 +1,4 @@ +from __future__ import print_function # Adapted from https://github.com/benthor/remotty/blob/master/socketclient.py from select import select diff --git a/fig/cli/utils.py b/fig/cli/utils.py index 8e8ab3767ab..249baba7897 100644 --- a/fig/cli/utils.py +++ b/fig/cli/utils.py @@ -1,5 +1,6 @@ from __future__ import unicode_literals from __future__ import absolute_import +from __future__ import division import datetime import os import socket diff --git a/fig/compat/texttable.py b/fig/compat/texttable.py index a60be2ba618..a9c91a504e8 100644 --- a/fig/compat/texttable.py +++ b/fig/compat/texttable.py @@ -66,6 +66,9 @@ ijkl 0.000 5.000e-78 89 0.000 mnop 0.023 5.000e+78 92 1.280e+22 """ +from __future__ import division +from __future__ import print_function +from functools import reduce __all__ = ["Texttable", "ArraySizeError"] From 9bec059cc78e3992ff97c3b356367daba434ec0d Mon Sep 17 00:00:00 2001 From: Christopher Grebs Date: Sun, 5 Jan 2014 19:32:06 -0800 Subject: [PATCH 0149/4072] e.explanation a 'str' object --- fig/service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fig/service.py b/fig/service.py index 4571f8197a2..a2492e7f9b9 100644 --- a/fig/service.py +++ b/fig/service.py @@ -67,7 +67,7 @@ def create_container(self, one_off=False, **override_options): try: return Container.create(self.client, **container_options) except APIError as e: - if e.response.status_code == 404 and e.explanation and 'No such image' in e.explanation: + if e.response.status_code == 404 and e.explanation and 'No such image' in str(e.explanation): log.info('Pulling image %s...' % container_options['image']) self.client.pull(container_options['image']) return Container.create(self.client, **container_options) From 31f09077324aa1f7611deb6d9bd2335c02a1e283 Mon Sep 17 00:00:00 2001 From: Christopher Grebs Date: Sun, 5 Jan 2014 19:52:56 -0800 Subject: [PATCH 0150/4072] Add unicode_literals to main module --- fig/cli/main.py | 1 + 1 file changed, 1 insertion(+) diff --git a/fig/cli/main.py b/fig/cli/main.py index e061ab25f3d..6b112f41280 100644 --- a/fig/cli/main.py +++ b/fig/cli/main.py @@ -1,4 +1,5 @@ from __future__ import print_function +from __future__ import unicode_literals import logging import sys import re From 0760ea1b006cfb8dc3b9dcad7043b6d2866ee197 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Mon, 6 Jan 2014 13:06:00 +0000 Subject: [PATCH 0151/4072] Add Python 3 and PyPy to .travis.yml --- .travis.yml | 13 +++---------- script/travis-install | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 10 deletions(-) create mode 100644 script/travis-install diff --git a/.travis.yml b/.travis.yml index 2a7cad5b587..51d44bf3d81 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,17 +2,10 @@ language: python python: - "2.6" - "2.7" + - "3.2" + - "3.3" -install: - - sudo sh -c "wget -qO- https://get.docker.io/gpg | apt-key add -" - - sudo sh -c "echo deb http://get.docker.io/ubuntu docker main > /etc/apt/sources.list.d/docker.list" - - sudo apt-get update - - echo exit 101 | sudo tee /usr/sbin/policy-rc.d - - sudo chmod +x /usr/sbin/policy-rc.d - - sudo apt-get install -qy slirp lxc lxc-docker=0.7.3 - - git clone git://github.com/jpetazzo/sekexe - - python setup.py install - - pip install -r requirements-dev.txt +install: script/travis-install script: - pwd diff --git a/script/travis-install b/script/travis-install new file mode 100644 index 00000000000..e68423dafa5 --- /dev/null +++ b/script/travis-install @@ -0,0 +1,16 @@ +#!/bin/bash + +sudo sh -c "wget -qO- https://get.docker.io/gpg | apt-key add -" +sudo sh -c "echo deb http://get.docker.io/ubuntu docker main > /etc/apt/sources.list.d/docker.list" +sudo apt-get update +echo exit 101 | sudo tee /usr/sbin/policy-rc.d +sudo chmod +x /usr/sbin/policy-rc.d +sudo apt-get install -qy slirp lxc lxc-docker=0.7.3 +git clone git://github.com/jpetazzo/sekexe +python setup.py install +pip install -r requirements-dev.txt + +if [[ $TRAVIS_PYTHON_VERSION == "2.6" ]]; then + pip install unittest2 +fi + From 7888027425262f9c16c91ed0d71ab8683a9496fd Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Mon, 6 Jan 2014 15:09:56 +0000 Subject: [PATCH 0152/4072] Put requirements back in .txt files Read-only FS in travis --- requirements-dev.txt | 2 ++ requirements.txt | 5 +++++ script/travis-install | 0 setup.py | 18 +++++++----------- 4 files changed, 14 insertions(+), 11 deletions(-) create mode 100644 requirements-dev.txt create mode 100644 requirements.txt mode change 100644 => 100755 script/travis-install diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 00000000000..4ef6576c491 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,2 @@ +nose==1.3.0 +unittest2==0.5.1 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000000..bfc9109399e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +docker-py==0.2.3 +docopt==0.6.1 +PyYAML==3.10 +# docker requires six==1.3.0 +six==1.3.0 diff --git a/script/travis-install b/script/travis-install old mode 100644 new mode 100755 diff --git a/setup.py b/setup.py index a114bdf97c9..f0ffceb74ab 100644 --- a/setup.py +++ b/setup.py @@ -22,6 +22,11 @@ def find_version(*file_paths): return version_match.group(1) raise RuntimeError("Unable to find version string.") +with open('requirements.txt') as f: + install_requires = f.read().splitlines() + +with open('requirements-dev.txt') as f: + tests_require = f.read().splitlines() setup( name='fig', @@ -33,17 +38,8 @@ def find_version(*file_paths): packages=find_packages(), include_package_data=True, test_suite='nose.collector', - install_requires=[ - 'docker-py==0.2.3', - 'docopt==0.6.1', - 'PyYAML==3.10', - # unfortunately `docker` requires six ==1.3.0 - 'six==1.3.0', - ], - tests_require=[ - 'nose==1.3.0', - 'unittest2==0.5.1' - ], + install_requires=install_requires, + tests_require=tests_require, entry_points=""" [console_scripts] fig=fig.cli.main:main From 00a1835faeb10b3436309ea3f82158963bd7fe8c Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Mon, 6 Jan 2014 17:34:08 +0000 Subject: [PATCH 0153/4072] Allow Python 3 to fail docker-py is broken --- .travis.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.travis.yml b/.travis.yml index 51d44bf3d81..d00b2228ea1 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,6 +5,11 @@ python: - "3.2" - "3.3" +matrix: + allow_failures: + - python: "3.2" + - python: "3.3" + install: script/travis-install script: From 892677a9d3dc3d6fb3b425d2ebd7c19e7f729879 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 9 Jan 2014 15:30:36 +0000 Subject: [PATCH 0154/4072] Very basic CLI smoke test See #8. --- fig/cli/command.py | 7 +++++-- tests/cli_test.py | 12 ++++++++++++ tests/fixtures/simple-figfile/fig.yml | 2 ++ 3 files changed, 19 insertions(+), 2 deletions(-) create mode 100644 tests/cli_test.py create mode 100644 tests/fixtures/simple-figfile/fig.yml diff --git a/fig/cli/command.py b/fig/cli/command.py index fb0adaf050a..4e0705a9916 100644 --- a/fig/cli/command.py +++ b/fig/cli/command.py @@ -15,6 +15,8 @@ log = logging.getLogger(__name__) class Command(DocoptCommand): + base_dir = '.' + @cached_property def client(self): return Client(docker_url()) @@ -22,10 +24,11 @@ def client(self): @cached_property def project(self): try: - config = yaml.load(open('fig.yml')) + yaml_path = os.path.join(self.base_dir, 'fig.yml') + config = yaml.load(open(yaml_path)) except IOError as e: if e.errno == errno.ENOENT: - log.error("Can't find %s. Are you in the right directory?", e.filename) + log.error("Can't find %s. Are you in the right directory?", os.path.basename(e.filename)) else: log.error(e) diff --git a/tests/cli_test.py b/tests/cli_test.py new file mode 100644 index 00000000000..ffd7fd4172c --- /dev/null +++ b/tests/cli_test.py @@ -0,0 +1,12 @@ +from __future__ import unicode_literals +from __future__ import absolute_import +from . import unittest +from fig.cli.main import TopLevelCommand + +class CLITestCase(unittest.TestCase): + def setUp(self): + self.command = TopLevelCommand() + self.command.base_dir = 'tests/fixtures/simple-figfile' + + def test_help(self): + self.assertRaises(SystemExit, lambda: self.command.dispatch(['-h'], None)) diff --git a/tests/fixtures/simple-figfile/fig.yml b/tests/fixtures/simple-figfile/fig.yml new file mode 100644 index 00000000000..aef2d39ba0c --- /dev/null +++ b/tests/fixtures/simple-figfile/fig.yml @@ -0,0 +1,2 @@ +simple: + image: ubuntu From 8cab05feb4565eca43874c11ad8fcc74f1e748a9 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 9 Jan 2014 15:31:37 +0000 Subject: [PATCH 0155/4072] Failing (on 2.7, at least) smoke test for 'fig ps' See #8. --- tests/cli_test.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/cli_test.py b/tests/cli_test.py index ffd7fd4172c..2146a90622d 100644 --- a/tests/cli_test.py +++ b/tests/cli_test.py @@ -10,3 +10,6 @@ def setUp(self): def test_help(self): self.assertRaises(SystemExit, lambda: self.command.dispatch(['-h'], None)) + + def test_ps(self): + self.command.dispatch(['ps'], None) From 7a4b69edc032d5c09d1f4da50009ee99b699f1f0 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 9 Jan 2014 15:32:06 +0000 Subject: [PATCH 0156/4072] Remove compat texttable module - breaks on Python 2.7 --- fig/cli/formatter.py | 3 +- fig/compat/__init__.py | 23 -- fig/compat/texttable.py | 582 ---------------------------------------- requirements.txt | 1 + 4 files changed, 2 insertions(+), 607 deletions(-) delete mode 100644 fig/compat/__init__.py delete mode 100644 fig/compat/texttable.py diff --git a/fig/cli/formatter.py b/fig/cli/formatter.py index 47bf680cd97..3d7e2d517ad 100644 --- a/fig/cli/formatter.py +++ b/fig/cli/formatter.py @@ -1,8 +1,7 @@ from __future__ import unicode_literals from __future__ import absolute_import import os - -from fig.compat import texttable +import texttable class Formatter(object): diff --git a/fig/compat/__init__.py b/fig/compat/__init__.py deleted file mode 100644 index 5b01f6b3b71..00000000000 --- a/fig/compat/__init__.py +++ /dev/null @@ -1,23 +0,0 @@ - - -# Taken from python2.7/3.3 functools -def cmp_to_key(mycmp): - """Convert a cmp= function into a key= function""" - class K(object): - __slots__ = ['obj'] - def __init__(self, obj): - self.obj = obj - def __lt__(self, other): - return mycmp(self.obj, other.obj) < 0 - def __gt__(self, other): - return mycmp(self.obj, other.obj) > 0 - def __eq__(self, other): - return mycmp(self.obj, other.obj) == 0 - def __le__(self, other): - return mycmp(self.obj, other.obj) <= 0 - def __ge__(self, other): - return mycmp(self.obj, other.obj) >= 0 - def __ne__(self, other): - return mycmp(self.obj, other.obj) != 0 - __hash__ = None - return K diff --git a/fig/compat/texttable.py b/fig/compat/texttable.py deleted file mode 100644 index a9c91a504e8..00000000000 --- a/fig/compat/texttable.py +++ /dev/null @@ -1,582 +0,0 @@ -#!/usr/bin/env python -# -# texttable - module for creating simple ASCII tables -# Copyright (C) 2003-2011 Gerome Fournier -# -# This library is free software; you can redistribute it and/or -# modify it under the terms of the GNU Lesser General Public -# License as published by the Free Software Foundation; either -# version 2.1 of the License, or (at your option) any later version. -# -# This library is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -# Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public -# License along with this library; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - -"""module for creating simple ASCII tables - - -Example: - - table = Texttable() - table.set_cols_align(["l", "r", "c"]) - table.set_cols_valign(["t", "m", "b"]) - table.add_rows([ ["Name", "Age", "Nickname"], - ["Mr\\nXavier\\nHuon", 32, "Xav'"], - ["Mr\\nBaptiste\\nClement", 1, "Baby"] ]) - print(table.draw() + "\\n") - - table = Texttable() - table.set_deco(Texttable.HEADER) - table.set_cols_dtype(['t', # text - 'f', # float (decimal) - 'e', # float (exponent) - 'i', # integer - 'a']) # automatic - table.set_cols_align(["l", "r", "r", "r", "l"]) - table.add_rows([["text", "float", "exp", "int", "auto"], - ["abcd", "67", 654, 89, 128.001], - ["efghijk", 67.5434, .654, 89.6, 12800000000000000000000.00023], - ["lmn", 5e-78, 5e-78, 89.4, .000000000000128], - ["opqrstu", .023, 5e+78, 92., 12800000000000000000000]]) - print(table.draw()) - -Result: - - +----------+-----+----------+ - | Name | Age | Nickname | - +==========+=====+==========+ - | Mr | | | - | Xavier | 32 | | - | Huon | | Xav' | - +----------+-----+----------+ - | Mr | | | - | Baptiste | 1 | | - | Clement | | Baby | - +----------+-----+----------+ - - text float exp int auto - =========================================== - abcd 67.000 6.540e+02 89 128.001 - efgh 67.543 6.540e-01 90 1.280e+22 - ijkl 0.000 5.000e-78 89 0.000 - mnop 0.023 5.000e+78 92 1.280e+22 -""" -from __future__ import division -from __future__ import print_function -from functools import reduce - -__all__ = ["Texttable", "ArraySizeError"] - -__author__ = 'Gerome Fournier ' -__license__ = 'LGPL' -__version__ = '0.8.1' -__credits__ = """\ -Jeff Kowalczyk: - - textwrap improved import - - comment concerning header output - -Anonymous: - - add_rows method, for adding rows in one go - -Sergey Simonenko: - - redefined len() function to deal with non-ASCII characters - -Roger Lew: - - columns datatype specifications - -Brian Peterson: - - better handling of unicode errors -""" - -# Modified version of `texttable` for python3 support. - -import sys -import string - - -def len(iterable): - """Redefining len here so it will be able to work with non-ASCII characters - """ - if not isinstance(iterable, str): - return iterable.__len__() - - try: - return len(unicode(iterable, 'utf')) - except: - return iterable.__len__() - - -class ArraySizeError(Exception): - """Exception raised when specified rows don't fit the required size - """ - - def __init__(self, msg): - self.msg = msg - Exception.__init__(self, msg, '') - - def __str__(self): - return self.msg - -class Texttable: - - BORDER = 1 - HEADER = 1 << 1 - HLINES = 1 << 2 - VLINES = 1 << 3 - - def __init__(self, max_width=80): - """Constructor - - - max_width is an integer, specifying the maximum width of the table - - if set to 0, size is unlimited, therefore cells won't be wrapped - """ - - if max_width <= 0: - max_width = False - self._max_width = max_width - self._precision = 3 - - self._deco = Texttable.VLINES | Texttable.HLINES | Texttable.BORDER | \ - Texttable.HEADER - self.set_chars(['-', '|', '+', '=']) - self.reset() - - def reset(self): - """Reset the instance - - - reset rows and header - """ - - self._hline_string = None - self._row_size = None - self._header = [] - self._rows = [] - - def set_chars(self, array): - """Set the characters used to draw lines between rows and columns - - - the array should contain 4 fields: - - [horizontal, vertical, corner, header] - - - default is set to: - - ['-', '|', '+', '='] - """ - - if len(array) != 4: - raise ArraySizeError("array should contain 4 characters") - array = [ x[:1] for x in [ str(s) for s in array ] ] - (self._char_horiz, self._char_vert, - self._char_corner, self._char_header) = array - - def set_deco(self, deco): - """Set the table decoration - - - 'deco' can be a combinaison of: - - Texttable.BORDER: Border around the table - Texttable.HEADER: Horizontal line below the header - Texttable.HLINES: Horizontal lines between rows - Texttable.VLINES: Vertical lines between columns - - All of them are enabled by default - - - example: - - Texttable.BORDER | Texttable.HEADER - """ - - self._deco = deco - - def set_cols_align(self, array): - """Set the desired columns alignment - - - the elements of the array should be either "l", "c" or "r": - - * "l": column flushed left - * "c": column centered - * "r": column flushed right - """ - - self._check_row_size(array) - self._align = array - - def set_cols_valign(self, array): - """Set the desired columns vertical alignment - - - the elements of the array should be either "t", "m" or "b": - - * "t": column aligned on the top of the cell - * "m": column aligned on the middle of the cell - * "b": column aligned on the bottom of the cell - """ - - self._check_row_size(array) - self._valign = array - - def set_cols_dtype(self, array): - """Set the desired columns datatype for the cols. - - - the elements of the array should be either "a", "t", "f", "e" or "i": - - * "a": automatic (try to use the most appropriate datatype) - * "t": treat as text - * "f": treat as float in decimal format - * "e": treat as float in exponential format - * "i": treat as int - - - by default, automatic datatyping is used for each column - """ - - self._check_row_size(array) - self._dtype = array - - def set_cols_width(self, array): - """Set the desired columns width - - - the elements of the array should be integers, specifying the - width of each column. For example: - - [10, 20, 5] - """ - - self._check_row_size(array) - try: - array = map(int, array) - if reduce(min, array) <= 0: - raise ValueError - except ValueError: - sys.stderr.write("Wrong argument in column width specification\n") - raise - self._width = array - - def set_precision(self, width): - """Set the desired precision for float/exponential formats - - - width must be an integer >= 0 - - - default value is set to 3 - """ - - if not type(width) is int or width < 0: - raise ValueError('width must be an integer greater then 0') - self._precision = width - - def header(self, array): - """Specify the header of the table - """ - - self._check_row_size(array) - self._header = map(str, array) - - def add_row(self, array): - """Add a row in the rows stack - - - cells can contain newlines and tabs - """ - - self._check_row_size(array) - - if not hasattr(self, "_dtype"): - self._dtype = ["a"] * self._row_size - - cells = [] - for i,x in enumerate(array): - cells.append(self._str(i,x)) - self._rows.append(cells) - - def add_rows(self, rows, header=True): - """Add several rows in the rows stack - - - The 'rows' argument can be either an iterator returning arrays, - or a by-dimensional array - - 'header' specifies if the first row should be used as the header - of the table - """ - - # nb: don't use 'iter' on by-dimensional arrays, to get a - # usable code for python 2.1 - if header: - if hasattr(rows, '__iter__') and hasattr(rows, 'next'): - self.header(rows.next()) - else: - self.header(rows[0]) - rows = rows[1:] - for row in rows: - self.add_row(row) - - def draw(self): - """Draw the table - - - the table is returned as a whole string - """ - - if not self._header and not self._rows: - return - self._compute_cols_width() - self._check_align() - out = "" - if self._has_border(): - out += self._hline() - if self._header: - out += self._draw_line(self._header, isheader=True) - if self._has_header(): - out += self._hline_header() - length = 0 - for row in self._rows: - length += 1 - out += self._draw_line(row) - if self._has_hlines() and length < len(self._rows): - out += self._hline() - if self._has_border(): - out += self._hline() - return out[:-1] - - def _str(self, i, x): - """Handles string formatting of cell data - - i - index of the cell datatype in self._dtype - x - cell data to format - """ - try: - f = float(x) - except: - return str(x) - - n = self._precision - dtype = self._dtype[i] - - if dtype == 'i': - return str(int(round(f))) - elif dtype == 'f': - return '%.*f' % (n, f) - elif dtype == 'e': - return '%.*e' % (n, f) - elif dtype == 't': - return str(x) - else: - if f - round(f) == 0: - if abs(f) > 1e8: - return '%.*e' % (n, f) - else: - return str(int(round(f))) - else: - if abs(f) > 1e8: - return '%.*e' % (n, f) - else: - return '%.*f' % (n, f) - - def _check_row_size(self, array): - """Check that the specified array fits the previous rows size - """ - - if not self._row_size: - self._row_size = len(array) - elif self._row_size != len(array): - raise ArraySizeError("array should contain %d elements" % self._row_size) - - def _has_vlines(self): - """Return a boolean, if vlines are required or not - """ - - return self._deco & Texttable.VLINES > 0 - - def _has_hlines(self): - """Return a boolean, if hlines are required or not - """ - - return self._deco & Texttable.HLINES > 0 - - def _has_border(self): - """Return a boolean, if border is required or not - """ - - return self._deco & Texttable.BORDER > 0 - - def _has_header(self): - """Return a boolean, if header line is required or not - """ - - return self._deco & Texttable.HEADER > 0 - - def _hline_header(self): - """Print header's horizontal line - """ - - return self._build_hline(True) - - def _hline(self): - """Print an horizontal line - """ - - if not self._hline_string: - self._hline_string = self._build_hline() - return self._hline_string - - def _build_hline(self, is_header=False): - """Return a string used to separated rows or separate header from - rows - """ - horiz = self._char_horiz - if (is_header): - horiz = self._char_header - # compute cell separator - s = "%s%s%s" % (horiz, [horiz, self._char_corner][self._has_vlines()], - horiz) - # build the line - l = string.join([horiz * n for n in self._width], s) - # add border if needed - if self._has_border(): - l = "%s%s%s%s%s\n" % (self._char_corner, horiz, l, horiz, - self._char_corner) - else: - l += "\n" - return l - - def _len_cell(self, cell): - """Return the width of the cell - - Special characters are taken into account to return the width of the - cell, such like newlines and tabs - """ - - cell_lines = cell.split('\n') - maxi = 0 - for line in cell_lines: - length = 0 - parts = line.split('\t') - for part, i in zip(parts, range(1, len(parts) + 1)): - length = length + len(part) - if i < len(parts): - length = (length/8 + 1) * 8 - maxi = max(maxi, length) - return maxi - - def _compute_cols_width(self): - """Return an array with the width of each column - - If a specific width has been specified, exit. If the total of the - columns width exceed the table desired width, another width will be - computed to fit, and cells will be wrapped. - """ - - if hasattr(self, "_width"): - return - maxi = [] - if self._header: - maxi = [ self._len_cell(x) for x in self._header ] - for row in self._rows: - for cell,i in zip(row, range(len(row))): - try: - maxi[i] = max(maxi[i], self._len_cell(cell)) - except (TypeError, IndexError): - maxi.append(self._len_cell(cell)) - items = len(maxi) - length = reduce(lambda x,y: x+y, maxi) - if self._max_width and length + items * 3 + 1 > self._max_width: - maxi = [(self._max_width - items * 3 -1) / items \ - for n in range(items)] - self._width = maxi - - def _check_align(self): - """Check if alignment has been specified, set default one if not - """ - - if not hasattr(self, "_align"): - self._align = ["l"] * self._row_size - if not hasattr(self, "_valign"): - self._valign = ["t"] * self._row_size - - def _draw_line(self, line, isheader=False): - """Draw a line - - Loop over a single cell length, over all the cells - """ - - line = self._splitit(line, isheader) - space = " " - out = "" - for i in range(len(line[0])): - if self._has_border(): - out += "%s " % self._char_vert - length = 0 - for cell, width, align in zip(line, self._width, self._align): - length += 1 - cell_line = cell[i] - fill = width - len(cell_line) - if isheader: - align = "c" - if align == "r": - out += "%s " % (fill * space + cell_line) - elif align == "c": - out += "%s " % (fill/2 * space + cell_line \ - + (fill/2 + fill%2) * space) - else: - out += "%s " % (cell_line + fill * space) - if length < len(line): - out += "%s " % [space, self._char_vert][self._has_vlines()] - out += "%s\n" % ['', self._char_vert][self._has_border()] - return out - - def _splitit(self, line, isheader): - """Split each element of line to fit the column width - - Each element is turned into a list, result of the wrapping of the - string to the desired width - """ - - line_wrapped = [] - for cell, width in zip(line, self._width): - array = [] - for c in cell.split('\n'): - try: - c = unicode(c, 'utf') - except UnicodeDecodeError as strerror: - sys.stderr.write("UnicodeDecodeError exception for string '%s': %s\n" % (c, strerror)) - c = unicode(c, 'utf', 'replace') - array.extend(textwrap.wrap(c, width)) - line_wrapped.append(array) - max_cell_lines = reduce(max, map(len, line_wrapped)) - for cell, valign in zip(line_wrapped, self._valign): - if isheader: - valign = "t" - if valign == "m": - missing = max_cell_lines - len(cell) - cell[:0] = [""] * (missing / 2) - cell.extend([""] * (missing / 2 + missing % 2)) - elif valign == "b": - cell[:0] = [""] * (max_cell_lines - len(cell)) - else: - cell.extend([""] * (max_cell_lines - len(cell))) - return line_wrapped - -if __name__ == '__main__': - table = Texttable() - table.set_cols_align(["l", "r", "c"]) - table.set_cols_valign(["t", "m", "b"]) - table.add_rows([ ["Name", "Age", "Nickname"], - ["Mr\nXavier\nHuon", 32, "Xav'"], - ["Mr\nBaptiste\nClement", 1, "Baby"] ]) - print(table.draw() + "\n") - - table = Texttable() - table.set_deco(Texttable.HEADER) - table.set_cols_dtype(['t', # text - 'f', # float (decimal) - 'e', # float (exponent) - 'i', # integer - 'a']) # automatic - table.set_cols_align(["l", "r", "r", "r", "l"]) - table.add_rows([["text", "float", "exp", "int", "auto"], - ["abcd", "67", 654, 89, 128.001], - ["efghijk", 67.5434, .654, 89.6, 12800000000000000000000.00023], - ["lmn", 5e-78, 5e-78, 89.4, .000000000000128], - ["opqrstu", .023, 5e+78, 92., 12800000000000000000000]]) - print(table.draw()) - diff --git a/requirements.txt b/requirements.txt index bfc9109399e..7eedd09c1df 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ docker-py==0.2.3 docopt==0.6.1 PyYAML==3.10 +texttable==0.8.1 # docker requires six==1.3.0 six==1.3.0 From 059d240824c78326c9bd92c52a1e3d9e118b6985 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 9 Jan 2014 16:19:22 +0000 Subject: [PATCH 0157/4072] Fix line buffering when there's UTF-8 in a container's output --- fig/cli/log_printer.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/fig/cli/log_printer.py b/fig/cli/log_printer.py index f31fb9b8740..dce47d2e0df 100644 --- a/fig/cli/log_printer.py +++ b/fig/cli/log_printer.py @@ -55,10 +55,15 @@ def read_websocket(websocket): break def split_buffer(reader, separator): + """ + Given a generator which yields strings and a separator string, + joins all input, splits on the separator and yields each chunk. + Requires that each input string is decodable as UTF-8. + """ buffered = '' for data in reader: - lines = (buffered + data).split(separator) + lines = (buffered + data.decode('utf-8')).split(separator) for line in lines[:-1]: yield line + separator if len(lines) > 1: From 38008a87e848aed4e19f3c8c11cd6398ec6b5f80 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 10 Jan 2014 20:42:00 +0000 Subject: [PATCH 0158/4072] Gif. --- README.md | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/README.md b/README.md index 71d874837a9..19a9bb0f7fb 100644 --- a/README.md +++ b/README.md @@ -18,13 +18,7 @@ db: Then type `fig up`, and Fig will start and run your entire app: - $ fig up - Pulling image orchardup/postgresql... - Building web... - Starting example_db_1... - Starting example_web_1... - example_db_1 | 2014-01-02 14:47:18 UTC LOG: database system is ready to accept connections - example_web_1 | * Running on http://0.0.0.0:5000/ +![example fig run](https://orchardup.com/static/images/fig-example.5807d0d2dbe6.gif) There are commands to: From c6efb455856c4c71ca4deceed323d463a1b2ecf1 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Sat, 11 Jan 2014 14:13:38 +0000 Subject: [PATCH 0159/4072] Exit travis-install script on error --- script/travis-install | 2 ++ 1 file changed, 2 insertions(+) diff --git a/script/travis-install b/script/travis-install index e68423dafa5..0b4889e0c0d 100755 --- a/script/travis-install +++ b/script/travis-install @@ -1,5 +1,7 @@ #!/bin/bash +set -e + sudo sh -c "wget -qO- https://get.docker.io/gpg | apt-key add -" sudo sh -c "echo deb http://get.docker.io/ubuntu docker main > /etc/apt/sources.list.d/docker.list" sudo apt-get update From 544cd884ee095935c2e0377a1dac4f2df2ca3c5f Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Sat, 11 Jan 2014 14:14:02 +0000 Subject: [PATCH 0160/4072] Use Docker 0.7.4 on Travis Also use a package that doesn't disappear and break the tests. --- script/travis-install | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/travis-install b/script/travis-install index 0b4889e0c0d..75b4790cea5 100755 --- a/script/travis-install +++ b/script/travis-install @@ -7,7 +7,7 @@ sudo sh -c "echo deb http://get.docker.io/ubuntu docker main > /etc/apt/sources. sudo apt-get update echo exit 101 | sudo tee /usr/sbin/policy-rc.d sudo chmod +x /usr/sbin/policy-rc.d -sudo apt-get install -qy slirp lxc lxc-docker=0.7.3 +sudo apt-get install -qy slirp lxc lxc-docker-0.7.4 git clone git://github.com/jpetazzo/sekexe python setup.py install pip install -r requirements-dev.txt From 431b3dc2b2d4a931849488b68cd8a34af9d9ae4a Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Sat, 11 Jan 2014 14:17:00 +0000 Subject: [PATCH 0161/4072] Move Travis badge out of heading --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 19a9bb0f7fb..a069c6331b4 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ -Fig [![Build Status](https://travis-ci.org/orchardup/fig.png?branch=master)](https://travis-ci.org/orchardup/fig) -==== +Fig +=== + +[![Build Status](https://travis-ci.org/orchardup/fig.png?branch=master)](https://travis-ci.org/orchardup/fig) Punctual, lightweight development environments using Docker. From 0614e2c5905c35c551d5bc558288663e35b5f16c Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Sat, 11 Jan 2014 14:31:47 +0000 Subject: [PATCH 0162/4072] Use Docker 0.7.5 on Travis --- script/travis-install | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/travis-install b/script/travis-install index 75b4790cea5..77f5df50950 100755 --- a/script/travis-install +++ b/script/travis-install @@ -7,7 +7,7 @@ sudo sh -c "echo deb http://get.docker.io/ubuntu docker main > /etc/apt/sources. sudo apt-get update echo exit 101 | sudo tee /usr/sbin/policy-rc.d sudo chmod +x /usr/sbin/policy-rc.d -sudo apt-get install -qy slirp lxc lxc-docker-0.7.4 +sudo apt-get install -qy slirp lxc lxc-docker-0.7.5 git clone git://github.com/jpetazzo/sekexe python setup.py install pip install -r requirements-dev.txt From d063f0e00c28331f2397ea80eea42681594cedc9 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Sat, 11 Jan 2014 14:31:56 +0000 Subject: [PATCH 0163/4072] Add back missing compat module --- fig/compat/__init__.py | 0 fig/compat/functools.py | 23 +++++++++++++++++++++++ fig/project.py | 2 +- 3 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 fig/compat/__init__.py create mode 100644 fig/compat/functools.py diff --git a/fig/compat/__init__.py b/fig/compat/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/fig/compat/functools.py b/fig/compat/functools.py new file mode 100644 index 00000000000..38a48c334f8 --- /dev/null +++ b/fig/compat/functools.py @@ -0,0 +1,23 @@ + +# Taken from python2.7/3.3 functools +def cmp_to_key(mycmp): + """Convert a cmp= function into a key= function""" + class K(object): + __slots__ = ['obj'] + def __init__(self, obj): + self.obj = obj + def __lt__(self, other): + return mycmp(self.obj, other.obj) < 0 + def __gt__(self, other): + return mycmp(self.obj, other.obj) > 0 + def __eq__(self, other): + return mycmp(self.obj, other.obj) == 0 + def __le__(self, other): + return mycmp(self.obj, other.obj) <= 0 + def __ge__(self, other): + return mycmp(self.obj, other.obj) >= 0 + def __ne__(self, other): + return mycmp(self.obj, other.obj) != 0 + __hash__ = None + return K + diff --git a/fig/project.py b/fig/project.py index 3c536ab6dfa..7c05b2c7380 100644 --- a/fig/project.py +++ b/fig/project.py @@ -2,7 +2,7 @@ from __future__ import absolute_import import logging from .service import Service -from .compat import cmp_to_key +from .compat.functools import cmp_to_key log = logging.getLogger(__name__) From 342f187318c4d964dfa9e33d3f0096ee1826bf8e Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Sat, 11 Jan 2014 14:52:37 +0000 Subject: [PATCH 0164/4072] Put python egg cache in a writeable dir --- script/travis | 2 ++ 1 file changed, 2 insertions(+) diff --git a/script/travis b/script/travis index 650c9d395f2..b77a3213e1e 100755 --- a/script/travis +++ b/script/travis @@ -3,6 +3,8 @@ # Exit on first error set -e +export PYTHON_EGG_CACHE="/tmp/.python-eggs" + TRAVIS_PYTHON_VERSION=$1 source /home/travis/virtualenv/python${TRAVIS_PYTHON_VERSION}/bin/activate env From c9c844c27943af674539940405ef4289564e2113 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Sat, 11 Jan 2014 14:53:07 +0000 Subject: [PATCH 0165/4072] Print commands travis scripts are running --- script/travis | 2 +- script/travis-install | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/script/travis b/script/travis index b77a3213e1e..3cdacfa5c25 100755 --- a/script/travis +++ b/script/travis @@ -1,7 +1,7 @@ #!/bin/bash # Exit on first error -set -e +set -ex export PYTHON_EGG_CACHE="/tmp/.python-eggs" diff --git a/script/travis-install b/script/travis-install index 77f5df50950..84b1e6561cf 100755 --- a/script/travis-install +++ b/script/travis-install @@ -1,6 +1,6 @@ #!/bin/bash -set -e +set -ex sudo sh -c "wget -qO- https://get.docker.io/gpg | apt-key add -" sudo sh -c "echo deb http://get.docker.io/ubuntu docker main > /etc/apt/sources.list.d/docker.list" From f448a841c563e413488c81ee90f6150ba66e0e62 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Sun, 12 Jan 2014 16:58:50 +0000 Subject: [PATCH 0166/4072] New docker-osx installation instructions --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index a069c6331b4..9075e0f51f1 100644 --- a/README.md +++ b/README.md @@ -39,9 +39,9 @@ Let's get a basic Python web app running on Fig. It assumes a little knowledge o First, install Docker. If you're on OS X, you can use [docker-osx](https://github.com/noplay/docker-osx): - $ curl https://raw.github.com/noplay/docker-osx/master/docker > /usr/local/bin/docker - $ chmod +x /usr/local/bin/docker - $ docker version + $ curl https://raw.github.com/noplay/docker-osx/master/docker-osx > /usr/local/bin/docker-osx + $ chmod +x /usr/local/bin/docker-osx + $ docker-osx shell Docker has guides for [Ubuntu](http://docs.docker.io/en/latest/installation/ubuntulinux/) and [other platforms](http://docs.docker.io/en/latest/installation/) in their documentation. From b92e998929826e806d560414b2e0dc6722e647dd Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 14 Jan 2014 12:39:13 +0000 Subject: [PATCH 0167/4072] 'fig logs' shows output for stopped containers --- fig/cli/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fig/cli/main.py b/fig/cli/main.py index 6b112f41280..d2d0af156d2 100644 --- a/fig/cli/main.py +++ b/fig/cli/main.py @@ -113,7 +113,7 @@ def logs(self, options): Usage: logs [SERVICE...] """ - containers = self.project.containers(service_names=options['SERVICE'], stopped=False) + containers = self.project.containers(service_names=options['SERVICE'], stopped=True) print("Attaching to", list_containers(containers)) LogPrinter(containers, attach_params={'logs': True}).run() From a3d024e11dee584828587c94d98bf958a170afb8 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 14 Jan 2014 19:19:15 +0000 Subject: [PATCH 0168/4072] Larger gif in README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 9075e0f51f1..e9fd500eae5 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ db: Then type `fig up`, and Fig will start and run your entire app: -![example fig run](https://orchardup.com/static/images/fig-example.5807d0d2dbe6.gif) +![example fig run](https://orchardup.com/static/images/fig-example-large.f96065fc9e22.gif) There are commands to: From d4000e07a99954df02f2de1f6bb6aa4ad56edda8 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Thu, 16 Jan 2014 00:58:46 +0000 Subject: [PATCH 0169/4072] Switch order of connection logic so TCP is tried first --- fig/cli/utils.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/fig/cli/utils.py b/fig/cli/utils.py index 249baba7897..e9e437859ea 100644 --- a/fig/cli/utils.py +++ b/fig/cli/utils.py @@ -93,9 +93,6 @@ def docker_url(): tcp_host = '127.0.0.1' tcp_port = 4243 - if os.path.exists(socket_path): - return 'unix://%s' % socket_path - for host, port in tcp_hosts: try: s = socket.create_connection((host, port), timeout=1) @@ -104,6 +101,9 @@ def docker_url(): except: pass + if os.path.exists(socket_path): + return 'unix://%s' % socket_path + raise UserError(""" Couldn't find Docker daemon - tried: From 7a1fb3a8d271224c9846d32e3f5ac85dcee4de3e Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Thu, 16 Jan 2014 01:54:05 +0000 Subject: [PATCH 0170/4072] Fix ordering of port mapping --- fig/service.py | 10 ++++++++-- tests/service_test.py | 5 +++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/fig/service.py b/fig/service.py index a2492e7f9b9..5b7b7663e56 100644 --- a/fig/service.py +++ b/fig/service.py @@ -86,7 +86,7 @@ def start_container(self, container=None, **override_options): for port in options['ports']: port = str(port) if ':' in port: - internal_port, external_port = port.split(':', 1) + external_port, internal_port = port.split(':', 1) port_bindings[int(internal_port)] = int(external_port) else: port_bindings[int(port)] = None @@ -134,7 +134,13 @@ def _get_container_options(self, override_options, one_off=False): container_options['name'] = self.next_container_name(one_off) if 'ports' in container_options: - container_options['ports'] = [str(p).split(':')[0] for p in container_options['ports']] + ports = [] + for port in container_options['ports']: + port = str(port) + if ':' in port: + port = port.split(':')[-1] + ports.append(port) + container_options['ports'] = ports if 'volumes' in container_options: container_options['volumes'] = dict((v.split(':')[1], {}) for v in container_options['volumes']) diff --git a/tests/service_test.py b/tests/service_test.py index bc5c6ffe9cf..d2078414aa5 100644 --- a/tests/service_test.py +++ b/tests/service_test.py @@ -157,4 +157,9 @@ def test_start_container_creates_fixed_external_ports(self): self.assertIn('8000/tcp', container['HostConfig']['PortBindings']) self.assertEqual(container['HostConfig']['PortBindings']['8000/tcp'][0]['HostPort'], '8000') + def test_start_container_creates_fixed_external_ports_when_it_is_different_to_internal_port(self): + service = self.create_service('web', ports=['8001:8000']) + container = service.start_container().inspect() + self.assertIn('8000/tcp', container['HostConfig']['PortBindings']) + self.assertEqual(container['HostConfig']['PortBindings']['8000/tcp'][0]['HostPort'], '8001') From 887a30e327135ce31fb2bba36ed740cd517529e3 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 16 Jan 2014 12:07:18 +0000 Subject: [PATCH 0171/4072] Clarify when 'fig stop' is necessary in README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e9fd500eae5..e2e87f75531 100644 --- a/README.md +++ b/README.md @@ -143,7 +143,7 @@ If you want to run your services in the background, you can pass the `-d` flag t See `fig --help` other commands that are available. -You'll probably want to stop your services when you've finished with them: +If you started Fig with `fig up -d`, you'll probably want to stop your services once you've finished with them: $ fig stop From a8e275a4321c4bf743a88793ed9f14cd905df053 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 15 Jan 2014 12:01:17 +0000 Subject: [PATCH 0172/4072] Implement UserError __unicode__ method --- fig/cli/errors.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/fig/cli/errors.py b/fig/cli/errors.py index bb1702fda62..874d35918b8 100644 --- a/fig/cli/errors.py +++ b/fig/cli/errors.py @@ -5,3 +5,6 @@ class UserError(Exception): def __init__(self, msg): self.msg = dedent(msg).strip() + + def __unicode__(self): + return self.msg From 3c5e334d9d0c9c20f825ee8a16385d4718cfabb9 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Fri, 3 Jan 2014 11:18:59 +0000 Subject: [PATCH 0173/4072] Add recreate_containers method to service --- fig/project.py | 7 +++++++ fig/service.py | 19 +++++++++++++++++++ tests/service_test.py | 7 +++++++ 3 files changed, 33 insertions(+) diff --git a/fig/project.py b/fig/project.py index 7c05b2c7380..95d13ee9e60 100644 --- a/fig/project.py +++ b/fig/project.py @@ -87,6 +87,13 @@ def create_containers(self, service_names=None): if len(service.containers(stopped=True)) == 0: service.create_container() + def recreate_containers(self, service_names): + """ + For each service, create or recreate their containers. + """ + for service in self.get_services(service_names): + service.recreate_containers() + def start(self, service_names=None, **options): for service in self.get_services(service_names): service.start(**options) diff --git a/fig/service.py b/fig/service.py index 5b7b7663e56..9ae47a36e8f 100644 --- a/fig/service.py +++ b/fig/service.py @@ -73,6 +73,25 @@ def create_container(self, one_off=False, **override_options): return Container.create(self.client, **container_options) raise + def recreate_containers(self, **override_options): + """ + If a container for this service doesn't exist, create one. If there are + any, stop, remove and recreate them. + """ + containers = self.containers(stopped=True) + if len(containers) == 0: + return [self.create_container(**override_options)] + else: + new_containers = [] + for old_container in containers: + if old_container.is_running: + old_container.stop() + options = dict(override_options) + options['volumes_from'] = old_container.id + new_containers.append(self.create_container(**options)) + old_container.remove() + return new_containers + def start_container(self, container=None, **override_options): if container is None: container = self.create_container(**override_options) diff --git a/tests/service_test.py b/tests/service_test.py index d2078414aa5..202c5e2641a 100644 --- a/tests/service_test.py +++ b/tests/service_test.py @@ -102,6 +102,13 @@ def test_create_container_with_one_off_when_existing_container_is_running(self): container = db.create_container(one_off=True) self.assertEqual(container.name, 'figtest_db_run_1') + def test_recreate_containers(self): + service = self.create_service('db') + container = service.create_container() + new_container = service.recreate_containers()[0] + self.assertEqual(len(service.containers(stopped=True)), 1) + self.assertNotEqual(container.id, new_container.id) + def test_start_container_passes_through_options(self): db = self.create_service('db') db.start_container(environment={'FOO': 'BAR'}) From 207e83ac2f5345e3ecc7e4ff8f282284cb2f7d83 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 15 Jan 2014 12:22:55 +0000 Subject: [PATCH 0174/4072] Be sure to test that recreate_containers updates config --- tests/service_test.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/service_test.py b/tests/service_test.py index 202c5e2641a..ea7db0ca409 100644 --- a/tests/service_test.py +++ b/tests/service_test.py @@ -103,9 +103,14 @@ def test_create_container_with_one_off_when_existing_container_is_running(self): self.assertEqual(container.name, 'figtest_db_run_1') def test_recreate_containers(self): - service = self.create_service('db') + service = self.create_service('db', environment={'FOO': '1'}) container = service.create_container() + self.assertEqual(container.dictionary['Config']['Env'], ['FOO=1']) + + service.options['environment']['FOO'] = '2' new_container = service.recreate_containers()[0] + self.assertEqual(new_container.dictionary['Config']['Env'], ['FOO=2']) + self.assertEqual(len(service.containers(stopped=True)), 1) self.assertNotEqual(container.id, new_container.id) From 3669236aa18b234b20b622c818662eb6f52592a5 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 15 Jan 2014 12:43:40 +0000 Subject: [PATCH 0175/4072] Support volumes in config with an unspecified host path --- fig/service.py | 18 +++++++++++++++--- tests/service_test.py | 6 ++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/fig/service.py b/fig/service.py index 9ae47a36e8f..d16ab956645 100644 --- a/fig/service.py +++ b/fig/service.py @@ -114,8 +114,9 @@ def start_container(self, container=None, **override_options): if options.get('volumes', None) is not None: for volume in options['volumes']: - external_dir, internal_dir = volume.split(':') - volume_bindings[os.path.abspath(external_dir)] = internal_dir + if ':' in volume: + external_dir, internal_dir = volume.split(':') + volume_bindings[os.path.abspath(external_dir)] = internal_dir container.start( links=self._get_links(), @@ -162,7 +163,7 @@ def _get_container_options(self, override_options, one_off=False): container_options['ports'] = ports if 'volumes' in container_options: - container_options['volumes'] = dict((v.split(':')[1], {}) for v in container_options['volumes']) + container_options['volumes'] = dict((split_volume(v)[1], {}) for v in container_options['volumes']) if self.can_be_built(): if len(self.client.images(name=self._build_tag_name())) == 0: @@ -233,3 +234,14 @@ def get_container_name(container): for name in container['Names']: if len(name.split('/')) == 2: return name[1:] + + +def split_volume(v): + """ + If v is of the format EXTERNAL:INTERNAL, returns (EXTERNAL, INTERNAL). + If v is of the format INTERNAL, returns (None, INTERNAL). + """ + if ':' in v: + return v.split(':', 1) + else: + return (None, v) diff --git a/tests/service_test.py b/tests/service_test.py index ea7db0ca409..c59b4ebb111 100644 --- a/tests/service_test.py +++ b/tests/service_test.py @@ -102,6 +102,12 @@ def test_create_container_with_one_off_when_existing_container_is_running(self): container = db.create_container(one_off=True) self.assertEqual(container.name, 'figtest_db_run_1') + def test_create_container_with_unspecified_volume(self): + service = self.create_service('db', volumes=['/var/db']) + container = service.create_container() + service.start_container(container) + self.assertIn('/var/db', container.inspect()['Volumes']) + def test_recreate_containers(self): service = self.create_service('db', environment={'FOO': '1'}) container = service.create_container() From bdc6b47e1f8a0d9aafef7cb3a0b261b7f0f8bf4f Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 15 Jan 2014 13:06:25 +0000 Subject: [PATCH 0176/4072] service.recreate_containers() no longer removes the old containers We need to keep them around until the new ones have been started. --- fig/service.py | 11 +++++------ tests/service_test.py | 20 ++++++++++++++------ 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/fig/service.py b/fig/service.py index d16ab956645..c6396d8c703 100644 --- a/fig/service.py +++ b/fig/service.py @@ -76,21 +76,20 @@ def create_container(self, one_off=False, **override_options): def recreate_containers(self, **override_options): """ If a container for this service doesn't exist, create one. If there are - any, stop, remove and recreate them. + any, stop them and create new ones. Does not remove the old containers. """ - containers = self.containers(stopped=True) - if len(containers) == 0: + old_containers = self.containers(stopped=True) + if len(old_containers) == 0: return [self.create_container(**override_options)] else: new_containers = [] - for old_container in containers: + for old_container in old_containers: if old_container.is_running: old_container.stop() options = dict(override_options) options['volumes_from'] = old_container.id new_containers.append(self.create_container(**options)) - old_container.remove() - return new_containers + return (old_containers, new_containers) def start_container(self, container=None, **override_options): if container is None: diff --git a/tests/service_test.py b/tests/service_test.py index c59b4ebb111..ee2e90933c7 100644 --- a/tests/service_test.py +++ b/tests/service_test.py @@ -109,16 +109,24 @@ def test_create_container_with_unspecified_volume(self): self.assertIn('/var/db', container.inspect()['Volumes']) def test_recreate_containers(self): - service = self.create_service('db', environment={'FOO': '1'}) - container = service.create_container() - self.assertEqual(container.dictionary['Config']['Env'], ['FOO=1']) + service = self.create_service('db', environment={'FOO': '1'}, volumes=['/var/db']) + old_container = service.create_container() + self.assertEqual(old_container.dictionary['Config']['Env'], ['FOO=1']) + service.start_container(old_container) + volume_path = old_container.inspect()['Volumes']['/var/db'] service.options['environment']['FOO'] = '2' - new_container = service.recreate_containers()[0] + (old, new) = service.recreate_containers() + self.assertEqual(old, [old_container]) + self.assertEqual(len(new), 1) + + new_container = new[0] self.assertEqual(new_container.dictionary['Config']['Env'], ['FOO=2']) + service.start_container(new_container) + self.assertEqual(new_container.inspect()['Volumes']['/var/db'], volume_path) - self.assertEqual(len(service.containers(stopped=True)), 1) - self.assertNotEqual(container.id, new_container.id) + self.assertEqual(len(service.containers(stopped=True)), 2) + self.assertNotEqual(old_container.id, new_container.id) def test_start_container_passes_through_options(self): db = self.create_service('db') From f5f93577366ac1cfdbb7ea803c157959aa9a7009 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 15 Jan 2014 13:17:39 +0000 Subject: [PATCH 0177/4072] Remove project.create_containers(), revamp project.recreate_containers() `recreate_containers` now returns two lists of old+new containers, along with their services. --- fig/project.py | 22 ++++++++++++---------- fig/service.py | 2 +- tests/project_test.py | 13 +++++++++---- 3 files changed, 22 insertions(+), 15 deletions(-) diff --git a/fig/project.py b/fig/project.py index 95d13ee9e60..f77da5f7d67 100644 --- a/fig/project.py +++ b/fig/project.py @@ -79,20 +79,22 @@ def get_services(self, service_names=None): unsorted = [self.get_service(name) for name in service_names] return [s for s in self.services if s in unsorted] - def create_containers(self, service_names=None): - """ - For each service, creates a container if there are none. - """ - for service in self.get_services(service_names): - if len(service.containers(stopped=True)) == 0: - service.create_container() - - def recreate_containers(self, service_names): + def recreate_containers(self, service_names=None): """ For each service, create or recreate their containers. + Returns a tuple with two lists. The first is a list of + (service, old_container) tuples; the second is a list + of (service, new_container) tuples. """ + old = [] + new = [] + for service in self.get_services(service_names): - service.recreate_containers() + (s_old, s_new) = service.recreate_containers() + old += [(service, container) for container in s_old] + new += [(service, container) for container in s_new] + + return (old, new) def start(self, service_names=None, **options): for service in self.get_services(service_names): diff --git a/fig/service.py b/fig/service.py index c6396d8c703..91a89791d16 100644 --- a/fig/service.py +++ b/fig/service.py @@ -80,7 +80,7 @@ def recreate_containers(self, **override_options): """ old_containers = self.containers(stopped=True) if len(old_containers) == 0: - return [self.create_container(**override_options)] + return ([], [self.create_container(**override_options)]) else: new_containers = [] for old_container in old_containers: diff --git a/tests/project_test.py b/tests/project_test.py index 09792fabdd5..bad9f612b6a 100644 --- a/tests/project_test.py +++ b/tests/project_test.py @@ -42,17 +42,22 @@ def test_get_service(self): project = Project('test', [web], self.client) self.assertEqual(project.get_service('web'), web) - def test_create_containers(self): + def test_recreate_containers(self): web = self.create_service('web') db = self.create_service('db') project = Project('test', [web, db], self.client) - project.create_containers(service_names=['web']) + old_web_container = web.create_container() self.assertEqual(len(web.containers(stopped=True)), 1) self.assertEqual(len(db.containers(stopped=True)), 0) - project.create_containers() - self.assertEqual(len(web.containers(stopped=True)), 1) + (old, new) = project.recreate_containers() + self.assertEqual(old, [(web, old_web_container)]) + self.assertEqual(len(new), 2) + self.assertEqual(new[0][0], web) + self.assertEqual(new[1][0], db) + + self.assertEqual(len(web.containers(stopped=True)), 2) self.assertEqual(len(db.containers(stopped=True)), 1) def test_start_stop_kill_remove(self): From 5db6c9f51ba045220ec1c8f3e12e6fbe620cf3d4 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 15 Jan 2014 13:25:40 +0000 Subject: [PATCH 0178/4072] Rework 'fig up' to use recreate_containers() --- fig/cli/main.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/fig/cli/main.py b/fig/cli/main.py index d2d0af156d2..51c4d27fb33 100644 --- a/fig/cli/main.py +++ b/fig/cli/main.py @@ -220,14 +220,18 @@ def up(self, options): """ detached = options['-d'] - self.project.create_containers(service_names=options['SERVICE']) - containers = self.project.containers(service_names=options['SERVICE'], stopped=True) + (old, new) = self.project.recreate_containers(service_names=options['SERVICE']) if not detached: - print("Attaching to", list_containers(containers)) - log_printer = LogPrinter(containers) + to_attach = [c for (s, c) in new] + print("Attaching to", list_containers(to_attach)) + log_printer = LogPrinter(to_attach) - self.project.start(service_names=options['SERVICE']) + for (service, container) in new: + service.start_container(container) + + for (service, container) in old: + container.remove() if not detached: try: From 8a0071d9c150a3b11ac8ee5f8cc05601963bce9f Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 15 Jan 2014 15:51:39 +0000 Subject: [PATCH 0179/4072] Reduce stop() timeout when recreating containers --- fig/service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fig/service.py b/fig/service.py index 91a89791d16..e81ec810a6f 100644 --- a/fig/service.py +++ b/fig/service.py @@ -85,7 +85,7 @@ def recreate_containers(self, **override_options): new_containers = [] for old_container in old_containers: if old_container.is_running: - old_container.stop() + old_container.stop(timeout=1) options = dict(override_options) options['volumes_from'] = old_container.id new_containers.append(self.create_container(**options)) From 3956d85a8c2a8ce0ab8edfd70dc474fda6b88b4b Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 15 Jan 2014 16:15:46 +0000 Subject: [PATCH 0180/4072] Refactor recreate_containers() in preparation for smart name-preserving logic --- fig/service.py | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/fig/service.py b/fig/service.py index e81ec810a6f..d7e2fff3153 100644 --- a/fig/service.py +++ b/fig/service.py @@ -78,19 +78,30 @@ def recreate_containers(self, **override_options): If a container for this service doesn't exist, create one. If there are any, stop them and create new ones. Does not remove the old containers. """ - old_containers = self.containers(stopped=True) - if len(old_containers) == 0: + containers = self.containers(stopped=True) + + if len(containers) == 0: return ([], [self.create_container(**override_options)]) else: + old_containers = [] new_containers = [] - for old_container in old_containers: - if old_container.is_running: - old_container.stop(timeout=1) - options = dict(override_options) - options['volumes_from'] = old_container.id - new_containers.append(self.create_container(**options)) + + for c in containers: + (old_container, new_container) = self.recreate_container(c, **override_options) + old_containers.append(old_container) + new_containers.append(new_container) + return (old_containers, new_containers) + def recreate_container(self, container, **override_options): + if container.is_running: + container.stop(timeout=1) + + options = dict(override_options) + options['volumes_from'] = container.id + + return (container, self.create_container(**options)) + def start_container(self, container=None, **override_options): if container is None: container = self.create_container(**override_options) From ea4753c49a95c9f2cedb9c00081499b17898d6b8 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 15 Jan 2014 17:06:16 +0000 Subject: [PATCH 0181/4072] Use an anonymous intermediate container so that when recreating containers, suffixes always start from 1 --- fig/service.py | 15 +++++++++++++-- tests/service_test.py | 8 ++++++-- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/fig/service.py b/fig/service.py index d7e2fff3153..c9a36499f14 100644 --- a/fig/service.py +++ b/fig/service.py @@ -97,10 +97,21 @@ def recreate_container(self, container, **override_options): if container.is_running: container.stop(timeout=1) + intermediate_container = Container.create( + self.client, + image='ubuntu', + command='echo', + volumes_from=container.id, + ) + intermediate_container.start() + intermediate_container.wait() + container.remove() + options = dict(override_options) - options['volumes_from'] = container.id + options['volumes_from'] = intermediate_container.id + new_container = self.create_container(**options) - return (container, self.create_container(**options)) + return (intermediate_container, new_container) def start_container(self, container=None, **override_options): if container is None: diff --git a/tests/service_test.py b/tests/service_test.py index ee2e90933c7..2ccf17555b9 100644 --- a/tests/service_test.py +++ b/tests/service_test.py @@ -112,20 +112,24 @@ def test_recreate_containers(self): service = self.create_service('db', environment={'FOO': '1'}, volumes=['/var/db']) old_container = service.create_container() self.assertEqual(old_container.dictionary['Config']['Env'], ['FOO=1']) + self.assertEqual(old_container.name, 'figtest_db_1') service.start_container(old_container) volume_path = old_container.inspect()['Volumes']['/var/db'] + num_containers_before = len(self.client.containers(all=True)) + service.options['environment']['FOO'] = '2' (old, new) = service.recreate_containers() - self.assertEqual(old, [old_container]) + self.assertEqual(len(old), 1) self.assertEqual(len(new), 1) new_container = new[0] self.assertEqual(new_container.dictionary['Config']['Env'], ['FOO=2']) + self.assertEqual(new_container.name, 'figtest_db_1') service.start_container(new_container) self.assertEqual(new_container.inspect()['Volumes']['/var/db'], volume_path) - self.assertEqual(len(service.containers(stopped=True)), 2) + self.assertEqual(len(self.client.containers(all=True)), num_containers_before + 1) self.assertNotEqual(old_container.id, new_container.id) def test_start_container_passes_through_options(self): From 8c583d1bb2f53f336437bb89821093c258cc21e6 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 15 Jan 2014 18:06:49 +0000 Subject: [PATCH 0182/4072] Quieter log output when recreating Moved log stuff to Service, which I think makes more sense anyway. Maybe. --- fig/container.py | 7 ------- fig/service.py | 6 ++++++ 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/fig/container.py b/fig/container.py index 9556ec1f6fc..24b239ac692 100644 --- a/fig/container.py +++ b/fig/container.py @@ -1,8 +1,5 @@ from __future__ import unicode_literals from __future__ import absolute_import -import logging - -log = logging.getLogger(__name__) class Container(object): """ @@ -91,19 +88,15 @@ def is_running(self): return self.dictionary['State']['Running'] def start(self, **options): - log.info("Starting %s..." % self.name) return self.client.start(self.id, **options) def stop(self, **options): - log.info("Stopping %s..." % self.name) return self.client.stop(self.id, **options) def kill(self): - log.info("Killing %s..." % self.name) return self.client.kill(self.id) def remove(self): - log.info("Removing %s..." % self.name) return self.client.remove_container(self.id) def inspect_if_not_inspected(self): diff --git a/fig/service.py b/fig/service.py index c9a36499f14..e730041013e 100644 --- a/fig/service.py +++ b/fig/service.py @@ -43,19 +43,23 @@ def containers(self, stopped=False, one_off=False): def start(self, **options): for c in self.containers(stopped=True): if not c.is_running: + log.info("Starting %s..." % c.name) self.start_container(c, **options) def stop(self, **options): for c in self.containers(): + log.info("Stopping %s..." % c.name) c.stop(**options) def kill(self, **options): for c in self.containers(): + log.info("Killing %s..." % c.name) c.kill(**options) def remove_stopped(self, **options): for c in self.containers(stopped=True): if not c.is_running: + log.info("Removing %s..." % c.name) c.remove(**options) def create_container(self, one_off=False, **override_options): @@ -81,12 +85,14 @@ def recreate_containers(self, **override_options): containers = self.containers(stopped=True) if len(containers) == 0: + log.info("Creating %s..." % self.next_container_name()) return ([], [self.create_container(**override_options)]) else: old_containers = [] new_containers = [] for c in containers: + log.info("Recreating %s..." % c.name) (old_container, new_container) = self.recreate_container(c, **override_options) old_containers.append(old_container) new_containers.append(new_container) From ee0c4bf690a721d205849b780fb48b191da39dd2 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 16 Jan 2014 11:56:02 +0000 Subject: [PATCH 0183/4072] Fix test regression --- tests/project_test.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/project_test.py b/tests/project_test.py index bad9f612b6a..a73aa25276d 100644 --- a/tests/project_test.py +++ b/tests/project_test.py @@ -52,12 +52,13 @@ def test_recreate_containers(self): self.assertEqual(len(db.containers(stopped=True)), 0) (old, new) = project.recreate_containers() - self.assertEqual(old, [(web, old_web_container)]) + self.assertEqual(len(old), 1) + self.assertEqual(old[0][0], web) self.assertEqual(len(new), 2) self.assertEqual(new[0][0], web) self.assertEqual(new[1][0], db) - self.assertEqual(len(web.containers(stopped=True)), 2) + self.assertEqual(len(web.containers(stopped=True)), 1) self.assertEqual(len(db.containers(stopped=True)), 1) def test_start_stop_kill_remove(self): From e38b403b148b5b985d7dfc6965cd1adce566adae Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 16 Jan 2014 12:06:50 +0000 Subject: [PATCH 0184/4072] Update README for new 'fig up' behaviour --- README.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index e2e87f75531..f9139497290 100644 --- a/README.md +++ b/README.md @@ -200,7 +200,7 @@ Run `fig [COMMAND] --help` for full usage. Build or rebuild services. -Services are built once and then tagged as `project_service`. If you change a service's `Dockerfile` or its configuration in `fig.yml`, you will probably need to run `fig build` to rebuild it, then run `fig rm` to make `fig up` recreate your containers. +Services are built once and then tagged as `project_service`, e.g. `figtest_db`. If you change a service's `Dockerfile` or the contents of its build directory, you can run `fig build` to rebuild it. #### kill @@ -237,9 +237,11 @@ Stop running containers without removing them. They can be started again with `f #### up -Build, create, start and attach to containers for a service. +Build, (re)create, start and attach to containers for a service. -If there are stopped containers for a service, `fig up` will start those again instead of creating new containers. When it exits, the containers it started will be stopped. This means if you want to recreate containers, you will need to explicitly run `fig rm`. +By default, `fig up` will aggregate the output of each container, and when it exits, all containers will be stopped. If you run `fig up -d`, it'll start the containers in the background and leave them running. + +If there are existing containers for a service, `fig up` will stop and recreate them, so that changes in `fig.yml` are picked up. ### Environment variables From 7b31fdf6f60de481c9d1458fefc2e0e206ea1279 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 16 Jan 2014 12:41:18 +0000 Subject: [PATCH 0185/4072] Clarify that volumes are preserved when recreating containers --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index f9139497290..2f9599a8b95 100644 --- a/README.md +++ b/README.md @@ -241,7 +241,7 @@ Build, (re)create, start and attach to containers for a service. By default, `fig up` will aggregate the output of each container, and when it exits, all containers will be stopped. If you run `fig up -d`, it'll start the containers in the background and leave them running. -If there are existing containers for a service, `fig up` will stop and recreate them, so that changes in `fig.yml` are picked up. +If there are existing containers for a service, `fig up` will stop and recreate them (preserving mounted volumes with [volumes-from]), so that changes in `fig.yml` are picked up. ### Environment variables @@ -267,3 +267,4 @@ Fully qualified container name, e.g. `MYAPP_DB_1_NAME=/myapp_web_1/myapp_db_1` [Docker links]: http://docs.docker.io/en/latest/use/port_redirection/#linking-a-container +[volumes-from]: http://docs.docker.io/en/latest/use/working_with_volumes/ From cdcea98290e01a90fa4ebfbd4528b3dfd890d1d6 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Thu, 16 Jan 2014 13:17:00 +0000 Subject: [PATCH 0186/4072] Copy readme commands docs to CLI docstrings --- README.md | 6 ++++-- fig/cli/main.py | 35 +++++++++++++++++++++++++++++------ 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 2f9599a8b95..9499fc86c1f 100644 --- a/README.md +++ b/README.md @@ -212,7 +212,7 @@ View output from services. #### ps -List running containers. +List containers. #### rm @@ -221,7 +221,9 @@ Remove stopped service containers. #### run -Run a one-off command for a service. E.g.: +Run a one-off command on a service. + +For example: $ fig run web python manage.py shell diff --git a/fig/cli/main.py b/fig/cli/main.py index 51c4d27fb33..b7a84fa3d98 100644 --- a/fig/cli/main.py +++ b/fig/cli/main.py @@ -95,13 +95,17 @@ def build(self, options): """ Build or rebuild services. + Services are built once and then tagged as `project_service`, + e.g. `figtest_db`. If you change a service's `Dockerfile` or the + contents of its build directory, you can run `fig build` to rebuild it. + Usage: build [SERVICE...] """ self.project.build(service_names=options['SERVICE']) def kill(self, options): """ - Kill containers. + Force stop service containers. Usage: kill [SERVICE...] """ @@ -150,7 +154,7 @@ def ps(self, options): def rm(self, options): """ - Remove stopped containers + Remove stopped service containers. Usage: rm [SERVICE...] """ @@ -166,7 +170,15 @@ def rm(self, options): def run(self, options): """ - Run a one-off command. + Run a one-off command on a service. + + For example: + + $ fig run web python manage.py shell + + Note that this will not start any services that the command's service + links to. So if, for example, your one-off command talks to your + database, you will need to run `fig up -d db` first. Usage: run [options] SERVICE COMMAND [ARGS...] @@ -203,7 +215,9 @@ def start(self, options): def stop(self, options): """ - Stop running containers. + Stop running containers without removing them. + + They can be started again with `fig start`. Usage: stop [SERVICE...] """ @@ -211,12 +225,21 @@ def stop(self, options): def up(self, options): """ - Create and start containers. + Build, (re)create, start and attach to containers for a service. + + By default, `fig up` will aggregate the output of each container, and + when it exits, all containers will be stopped. If you run `fig up -d`, + it'll start the containers in the background and leave them running. + + If there are existing containers for a service, `fig up` will stop + and recreate them (preserving mounted volumes with volumes-from), + so that changes in `fig.yml` are picked up. Usage: up [options] [SERVICE...] Options: - -d Detached mode: Run containers in the background, print new container names + -d Detached mode: Run containers in the background, print new + container names """ detached = options['-d'] From b1e7f548f4058baf0050f804a0d928b128bcf191 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Thu, 16 Jan 2014 13:24:43 +0000 Subject: [PATCH 0187/4072] Add help command --- README.md | 4 ++++ fig/cli/main.py | 12 ++++++++++++ 2 files changed, 16 insertions(+) diff --git a/README.md b/README.md index 2f9599a8b95..c290e126871 100644 --- a/README.md +++ b/README.md @@ -202,6 +202,10 @@ Build or rebuild services. Services are built once and then tagged as `project_service`, e.g. `figtest_db`. If you change a service's `Dockerfile` or the contents of its build directory, you can run `fig build` to rebuild it. +#### help + +Get help on a command. + #### kill Force stop service containers. diff --git a/fig/cli/main.py b/fig/cli/main.py index 51c4d27fb33..0eaf787478f 100644 --- a/fig/cli/main.py +++ b/fig/cli/main.py @@ -76,6 +76,7 @@ class TopLevelCommand(Command): Commands: build Build or rebuild services + help Get help on a command kill Kill containers logs View output from containers ps List containers @@ -99,6 +100,17 @@ def build(self, options): """ self.project.build(service_names=options['SERVICE']) + def help(self, options): + """ + Get help on a command. + + Usage: help COMMAND + """ + command = options['COMMAND'] + if not hasattr(self, command): + raise NoSuchCommand(command, self) + raise SystemExit(getdoc(getattr(self, command))) + def kill(self, options): """ Kill containers. From 21528f08d4ff2987e59b9ea888893120352dbb6a Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 16 Jan 2014 12:56:36 +0000 Subject: [PATCH 0188/4072] Vendor docker-py From https://github.com/aanand/docker-py/commit/9dc03c57373a92076a7ea323f00d9717f97cd35e --- fig/cli/command.py | 2 +- fig/cli/main.py | 2 +- fig/packages/__init__.py | 0 fig/packages/docker/__init__.py | 15 + fig/packages/docker/auth/__init__.py | 7 + fig/packages/docker/auth/auth.py | 153 +++++ fig/packages/docker/client.py | 746 +++++++++++++++++++++++ fig/packages/docker/unixconn/__init__.py | 1 + fig/packages/docker/unixconn/unixconn.py | 71 +++ fig/packages/docker/utils/__init__.py | 3 + fig/packages/docker/utils/utils.py | 96 +++ fig/service.py | 2 +- requirements.txt | 3 +- tests/testcases.py | 2 +- 14 files changed, 1098 insertions(+), 5 deletions(-) create mode 100644 fig/packages/__init__.py create mode 100644 fig/packages/docker/__init__.py create mode 100644 fig/packages/docker/auth/__init__.py create mode 100644 fig/packages/docker/auth/auth.py create mode 100644 fig/packages/docker/client.py create mode 100644 fig/packages/docker/unixconn/__init__.py create mode 100644 fig/packages/docker/unixconn/unixconn.py create mode 100644 fig/packages/docker/utils/__init__.py create mode 100644 fig/packages/docker/utils/utils.py diff --git a/fig/cli/command.py b/fig/cli/command.py index 4e0705a9916..00452dd385b 100644 --- a/fig/cli/command.py +++ b/fig/cli/command.py @@ -1,6 +1,6 @@ from __future__ import unicode_literals from __future__ import absolute_import -from docker import Client +from ..packages.docker import Client import errno import logging import os diff --git a/fig/cli/main.py b/fig/cli/main.py index 51c4d27fb33..c0e42143a84 100644 --- a/fig/cli/main.py +++ b/fig/cli/main.py @@ -15,7 +15,7 @@ from .log_printer import LogPrinter from .utils import yesno -from docker.client import APIError +from ..packages.docker.client import APIError from .errors import UserError from .docopt_command import NoSuchCommand from .socketclient import SocketClient diff --git a/fig/packages/__init__.py b/fig/packages/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/fig/packages/docker/__init__.py b/fig/packages/docker/__init__.py new file mode 100644 index 00000000000..5f642a85509 --- /dev/null +++ b/fig/packages/docker/__init__.py @@ -0,0 +1,15 @@ +# Copyright 2013 dotCloud inc. + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from .client import Client, APIError # flake8: noqa diff --git a/fig/packages/docker/auth/__init__.py b/fig/packages/docker/auth/__init__.py new file mode 100644 index 00000000000..66acdb36ad2 --- /dev/null +++ b/fig/packages/docker/auth/__init__.py @@ -0,0 +1,7 @@ +from .auth import ( + INDEX_URL, + encode_header, + load_config, + resolve_authconfig, + resolve_repository_name +) # flake8: noqa \ No newline at end of file diff --git a/fig/packages/docker/auth/auth.py b/fig/packages/docker/auth/auth.py new file mode 100644 index 00000000000..bef010f29cc --- /dev/null +++ b/fig/packages/docker/auth/auth.py @@ -0,0 +1,153 @@ +# Copyright 2013 dotCloud inc. + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import base64 +import fileinput +import json +import os + +import six + +from ..utils import utils + +INDEX_URL = 'https://index.docker.io/v1/' +DOCKER_CONFIG_FILENAME = '.dockercfg' + + +def swap_protocol(url): + if url.startswith('http://'): + return url.replace('http://', 'https://', 1) + if url.startswith('https://'): + return url.replace('https://', 'http://', 1) + return url + + +def expand_registry_url(hostname): + if hostname.startswith('http:') or hostname.startswith('https:'): + if '/' not in hostname[9:]: + hostname = hostname + '/v1/' + return hostname + if utils.ping('https://' + hostname + '/v1/_ping'): + return 'https://' + hostname + '/v1/' + return 'http://' + hostname + '/v1/' + + +def resolve_repository_name(repo_name): + if '://' in repo_name: + raise ValueError('Repository name cannot contain a ' + 'scheme ({0})'.format(repo_name)) + parts = repo_name.split('/', 1) + if not '.' in parts[0] and not ':' in parts[0] and parts[0] != 'localhost': + # This is a docker index repo (ex: foo/bar or ubuntu) + return INDEX_URL, repo_name + if len(parts) < 2: + raise ValueError('Invalid repository name ({0})'.format(repo_name)) + + if 'index.docker.io' in parts[0]: + raise ValueError('Invalid repository name,' + 'try "{0}" instead'.format(parts[1])) + + return expand_registry_url(parts[0]), parts[1] + + +def resolve_authconfig(authconfig, registry=None): + """Return the authentication data from the given auth configuration for a + specific registry. We'll do our best to infer the correct URL for the + registry, trying both http and https schemes. Returns an empty dictionnary + if no data exists.""" + # Default to the public index server + registry = registry or INDEX_URL + + # Ff its not the index server there are three cases: + # + # 1. this is a full config url -> it should be used as is + # 2. it could be a full url, but with the wrong protocol + # 3. it can be the hostname optionally with a port + # + # as there is only one auth entry which is fully qualified we need to start + # parsing and matching + if '/' not in registry: + registry = registry + '/v1/' + if not registry.startswith('http:') and not registry.startswith('https:'): + registry = 'https://' + registry + + if registry in authconfig: + return authconfig[registry] + return authconfig.get(swap_protocol(registry), None) + + +def decode_auth(auth): + if isinstance(auth, six.string_types): + auth = auth.encode('ascii') + s = base64.b64decode(auth) + login, pwd = s.split(b':') + return login.decode('ascii'), pwd.decode('ascii') + + +def encode_header(auth): + auth_json = json.dumps(auth).encode('ascii') + return base64.b64encode(auth_json) + + +def load_config(root=None): + """Loads authentication data from a Docker configuration file in the given + root directory.""" + conf = {} + data = None + + config_file = os.path.join(root or os.environ.get('HOME', '.'), + DOCKER_CONFIG_FILENAME) + + # First try as JSON + try: + with open(config_file) as f: + conf = {} + for registry, entry in six.iteritems(json.load(f)): + username, password = decode_auth(entry['auth']) + conf[registry] = { + 'username': username, + 'password': password, + 'email': entry['email'], + 'serveraddress': registry, + } + return conf + except: + pass + + # If that fails, we assume the configuration file contains a single + # authentication token for the public registry in the following format: + # + # auth = AUTH_TOKEN + # email = email@domain.com + try: + data = [] + for line in fileinput.input(config_file): + data.append(line.strip().split(' = ')[1]) + if len(data) < 2: + # Not enough data + raise Exception('Invalid or empty configuration file!') + + username, password = decode_auth(data[0]) + conf[INDEX_URL] = { + 'username': username, + 'password': password, + 'email': data[1], + 'serveraddress': INDEX_URL, + } + return conf + except: + pass + + # If all fails, return an empty config + return {} diff --git a/fig/packages/docker/client.py b/fig/packages/docker/client.py new file mode 100644 index 00000000000..e3cd976cd4b --- /dev/null +++ b/fig/packages/docker/client.py @@ -0,0 +1,746 @@ +# Copyright 2013 dotCloud inc. + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import json +import re +import shlex +import struct + +import requests +import requests.exceptions +import six + +from .auth import auth +from .unixconn import unixconn +from .utils import utils + +if not six.PY3: + import websocket + +DEFAULT_TIMEOUT_SECONDS = 60 +STREAM_HEADER_SIZE_BYTES = 8 + + +class APIError(requests.exceptions.HTTPError): + def __init__(self, message, response, explanation=None): + super(APIError, self).__init__(message, response=response) + + self.explanation = explanation + + if self.explanation is None and response.content: + self.explanation = response.content.strip() + + def __str__(self): + message = super(APIError, self).__str__() + + if self.is_client_error(): + message = '%s Client Error: %s' % ( + self.response.status_code, self.response.reason) + + elif self.is_server_error(): + message = '%s Server Error: %s' % ( + self.response.status_code, self.response.reason) + + if self.explanation: + message = '%s ("%s")' % (message, self.explanation) + + return message + + def is_client_error(self): + return 400 <= self.response.status_code < 500 + + def is_server_error(self): + return 500 <= self.response.status_code < 600 + + +class Client(requests.Session): + def __init__(self, base_url=None, version="1.6", + timeout=DEFAULT_TIMEOUT_SECONDS): + super(Client, self).__init__() + if base_url is None: + base_url = "unix://var/run/docker.sock" + if base_url.startswith('unix:///'): + base_url = base_url.replace('unix:/', 'unix:') + if base_url.startswith('tcp:'): + base_url = base_url.replace('tcp:', 'http:') + if base_url.endswith('/'): + base_url = base_url[:-1] + self.base_url = base_url + self._version = version + self._timeout = timeout + self._auth_configs = auth.load_config() + + self.mount('unix://', unixconn.UnixAdapter(base_url, timeout)) + + def _set_request_timeout(self, kwargs): + """Prepare the kwargs for an HTTP request by inserting the timeout + parameter, if not already present.""" + kwargs.setdefault('timeout', self._timeout) + return kwargs + + def _post(self, url, **kwargs): + return self.post(url, **self._set_request_timeout(kwargs)) + + def _get(self, url, **kwargs): + return self.get(url, **self._set_request_timeout(kwargs)) + + def _delete(self, url, **kwargs): + return self.delete(url, **self._set_request_timeout(kwargs)) + + def _url(self, path): + return '{0}/v{1}{2}'.format(self.base_url, self._version, path) + + def _raise_for_status(self, response, explanation=None): + """Raises stored :class:`APIError`, if one occurred.""" + try: + response.raise_for_status() + except requests.exceptions.HTTPError as e: + raise APIError(e, response, explanation=explanation) + + def _result(self, response, json=False, binary=False): + assert not (json and binary) + self._raise_for_status(response) + + if json: + return response.json() + if binary: + return response.content + return response.text + + def _container_config(self, image, command, hostname=None, user=None, + detach=False, stdin_open=False, tty=False, + mem_limit=0, ports=None, environment=None, dns=None, + volumes=None, volumes_from=None, + network_disabled=False): + if isinstance(command, six.string_types): + command = shlex.split(str(command)) + if isinstance(environment, dict): + environment = [ + '{0}={1}'.format(k, v) for k, v in environment.items() + ] + + if ports and isinstance(ports, list): + exposed_ports = {} + for port_definition in ports: + port = port_definition + proto = None + if isinstance(port_definition, tuple): + if len(port_definition) == 2: + proto = port_definition[1] + port = port_definition[0] + exposed_ports['{0}{1}'.format( + port, + '/' + proto if proto else '' + )] = {} + ports = exposed_ports + + if volumes and isinstance(volumes, list): + volumes_dict = {} + for vol in volumes: + volumes_dict[vol] = {} + volumes = volumes_dict + + attach_stdin = False + attach_stdout = False + attach_stderr = False + + if not detach: + attach_stdout = True + attach_stderr = True + + if stdin_open: + attach_stdin = True + + return { + 'Hostname': hostname, + 'ExposedPorts': ports, + 'User': user, + 'Tty': tty, + 'OpenStdin': stdin_open, + 'Memory': mem_limit, + 'AttachStdin': attach_stdin, + 'AttachStdout': attach_stdout, + 'AttachStderr': attach_stderr, + 'Env': environment, + 'Cmd': command, + 'Dns': dns, + 'Image': image, + 'Volumes': volumes, + 'VolumesFrom': volumes_from, + 'NetworkDisabled': network_disabled + } + + def _post_json(self, url, data, **kwargs): + # Go <1.1 can't unserialize null to a string + # so we do this disgusting thing here. + data2 = {} + if data is not None: + for k, v in six.iteritems(data): + if v is not None: + data2[k] = v + + if 'headers' not in kwargs: + kwargs['headers'] = {} + kwargs['headers']['Content-Type'] = 'application/json' + return self._post(url, data=json.dumps(data2), **kwargs) + + def _attach_params(self, override=None): + return override or { + 'stdout': 1, + 'stderr': 1, + 'stream': 1 + } + + def _attach_websocket(self, container, params=None): + if six.PY3: + raise NotImplementedError("This method is not currently supported " + "under python 3") + url = self._url("/containers/{0}/attach/ws".format(container)) + req = requests.Request("POST", url, params=self._attach_params(params)) + full_url = req.prepare().url + full_url = full_url.replace("http://", "ws://", 1) + full_url = full_url.replace("https://", "wss://", 1) + return self._create_websocket_connection(full_url) + + def _create_websocket_connection(self, url): + return websocket.create_connection(url) + + def _stream_result(self, response): + """Generator for straight-out, non chunked-encoded HTTP responses.""" + self._raise_for_status(response) + for line in response.iter_lines(chunk_size=1): + # filter out keep-alive new lines + if line: + yield line + '\n' + + def _stream_result_socket(self, response): + self._raise_for_status(response) + return response.raw._fp.fp._sock + + def _stream_helper(self, response): + """Generator for data coming from a chunked-encoded HTTP response.""" + socket_fp = self._stream_result_socket(response) + socket_fp.setblocking(1) + socket = socket_fp.makefile() + while True: + size = int(socket.readline(), 16) + if size <= 0: + break + data = socket.readline() + if not data: + break + yield data + + def _multiplexed_buffer_helper(self, response): + """A generator of multiplexed data blocks read from a buffered + response.""" + buf = self._result(response, binary=True) + walker = 0 + while True: + if len(buf[walker:]) < 8: + break + _, length = struct.unpack_from('>BxxxL', buf[walker:]) + start = walker + STREAM_HEADER_SIZE_BYTES + end = start + length + walker = end + yield str(buf[start:end]) + + def _multiplexed_socket_stream_helper(self, response): + """A generator of multiplexed data blocks coming from a response + socket.""" + socket = self._stream_result_socket(response) + + def recvall(socket, size): + data = '' + while size > 0: + block = socket.recv(size) + if not block: + return None + + data += block + size -= len(block) + return data + + while True: + socket.settimeout(None) + header = recvall(socket, STREAM_HEADER_SIZE_BYTES) + if not header: + break + _, length = struct.unpack('>BxxxL', header) + if not length: + break + data = recvall(socket, length) + if not data: + break + yield data + + def attach(self, container, stdout=True, stderr=True, + stream=False, logs=False): + if isinstance(container, dict): + container = container.get('Id') + params = { + 'logs': logs and 1 or 0, + 'stdout': stdout and 1 or 0, + 'stderr': stderr and 1 or 0, + 'stream': stream and 1 or 0, + } + u = self._url("/containers/{0}/attach".format(container)) + response = self._post(u, params=params, stream=stream) + + # Stream multi-plexing was introduced in API v1.6. + if utils.compare_version('1.6', self._version) < 0: + return stream and self._stream_result(response) or \ + self._result(response, binary=True) + + return stream and self._multiplexed_socket_stream_helper(response) or \ + ''.join([x for x in self._multiplexed_buffer_helper(response)]) + + def attach_socket(self, container, params=None, ws=False): + if params is None: + params = { + 'stdout': 1, + 'stderr': 1, + 'stream': 1 + } + if ws: + return self._attach_websocket(container, params) + + if isinstance(container, dict): + container = container.get('Id') + u = self._url("/containers/{0}/attach".format(container)) + return self._stream_result_socket(self.post( + u, None, params=self._attach_params(params), stream=True)) + + def build(self, path=None, tag=None, quiet=False, fileobj=None, + nocache=False, rm=False, stream=False, timeout=None): + remote = context = headers = None + if path is None and fileobj is None: + raise Exception("Either path or fileobj needs to be provided.") + + if fileobj is not None: + context = utils.mkbuildcontext(fileobj) + elif path.startswith(('http://', 'https://', 'git://', 'github.com/')): + remote = path + else: + context = utils.tar(path) + + u = self._url('/build') + params = { + 't': tag, + 'remote': remote, + 'q': quiet, + 'nocache': nocache, + 'rm': rm + } + if context is not None: + headers = {'Content-Type': 'application/tar'} + + response = self._post( + u, + data=context, + params=params, + headers=headers, + stream=stream, + timeout=timeout, + ) + + if context is not None: + context.close() + if stream: + return self._stream_result(response) + else: + output = self._result(response) + srch = r'Successfully built ([0-9a-f]+)' + match = re.search(srch, output) + if not match: + return None, output + return match.group(1), output + + def commit(self, container, repository=None, tag=None, message=None, + author=None, conf=None): + params = { + 'container': container, + 'repo': repository, + 'tag': tag, + 'comment': message, + 'author': author + } + u = self._url("/commit") + return self._result(self._post_json(u, data=conf, params=params), + json=True) + + def containers(self, quiet=False, all=False, trunc=True, latest=False, + since=None, before=None, limit=-1): + params = { + 'limit': 1 if latest else limit, + 'all': 1 if all else 0, + 'trunc_cmd': 1 if trunc else 0, + 'since': since, + 'before': before + } + u = self._url("/containers/json") + res = self._result(self._get(u, params=params), True) + + if quiet: + return [{'Id': x['Id']} for x in res] + return res + + def copy(self, container, resource): + res = self._post_json( + self._url("/containers/{0}/copy".format(container)), + data={"Resource": resource}, + stream=True + ) + self._raise_for_status(res) + return res.raw + + def create_container(self, image, command=None, hostname=None, user=None, + detach=False, stdin_open=False, tty=False, + mem_limit=0, ports=None, environment=None, dns=None, + volumes=None, volumes_from=None, + network_disabled=False, name=None): + + config = self._container_config( + image, command, hostname, user, detach, stdin_open, tty, mem_limit, + ports, environment, dns, volumes, volumes_from, network_disabled + ) + return self.create_container_from_config(config, name) + + def create_container_from_config(self, config, name=None): + u = self._url("/containers/create") + params = { + 'name': name + } + res = self._post_json(u, data=config, params=params) + return self._result(res, True) + + def diff(self, container): + if isinstance(container, dict): + container = container.get('Id') + return self._result(self._get(self._url("/containers/{0}/changes". + format(container))), True) + + def events(self): + u = self._url("/events") + + socket = self._stream_result_socket(self.get(u, stream=True)) + + while True: + chunk = socket.recv(4096) + if chunk: + # Messages come in the format of length, data, newline. + length, data = chunk.split("\n", 1) + length = int(length, 16) + if length > len(data): + data += socket.recv(length - len(data)) + yield json.loads(data) + else: + break + + def export(self, container): + if isinstance(container, dict): + container = container.get('Id') + res = self._get(self._url("/containers/{0}/export".format(container)), + stream=True) + self._raise_for_status(res) + return res.raw + + def history(self, image): + res = self._get(self._url("/images/{0}/history".format(image))) + self._raise_for_status(res) + return self._result(res) + + def images(self, name=None, quiet=False, all=False, viz=False): + if viz: + return self._result(self._get(self._url("images/viz"))) + params = { + 'filter': name, + 'only_ids': 1 if quiet else 0, + 'all': 1 if all else 0, + } + res = self._result(self._get(self._url("/images/json"), params=params), + True) + if quiet: + return [x['Id'] for x in res] + return res + + def import_image(self, src, data=None, repository=None, tag=None): + u = self._url("/images/create") + params = { + 'repo': repository, + 'tag': tag + } + try: + # XXX: this is ways not optimal but the only way + # for now to import tarballs through the API + fic = open(src) + data = fic.read() + fic.close() + src = "-" + except IOError: + # file does not exists or not a file (URL) + data = None + if isinstance(src, six.string_types): + params['fromSrc'] = src + return self._result(self._post(u, data=data, params=params)) + + return self._result(self._post(u, data=src, params=params)) + + def info(self): + return self._result(self._get(self._url("/info")), + True) + + def insert(self, image, url, path): + api_url = self._url("/images/" + image + "/insert") + params = { + 'url': url, + 'path': path + } + return self._result(self._post(api_url, params=params)) + + def inspect_container(self, container): + if isinstance(container, dict): + container = container.get('Id') + return self._result( + self._get(self._url("/containers/{0}/json".format(container))), + True) + + def inspect_image(self, image_id): + return self._result( + self._get(self._url("/images/{0}/json".format(image_id))), + True + ) + + def kill(self, container, signal=None): + if isinstance(container, dict): + container = container.get('Id') + url = self._url("/containers/{0}/kill".format(container)) + params = {} + if signal is not None: + params['signal'] = signal + res = self._post(url, params=params) + + self._raise_for_status(res) + + def login(self, username, password=None, email=None, registry=None, + reauth=False): + # If we don't have any auth data so far, try reloading the config file + # one more time in case anything showed up in there. + if not self._auth_configs: + self._auth_configs = auth.load_config() + + registry = registry or auth.INDEX_URL + + authcfg = auth.resolve_authconfig(self._auth_configs, registry) + # If we found an existing auth config for this registry and username + # combination, we can return it immediately unless reauth is requested. + if authcfg and authcfg.get('username', None) == username \ + and not reauth: + return authcfg + + req_data = { + 'username': username, + 'password': password, + 'email': email, + 'serveraddress': registry, + } + + response = self._post_json(self._url('/auth'), data=req_data) + if response.status_code == 200: + self._auth_configs[registry] = req_data + return self._result(response, json=True) + + def logs(self, container, stdout=True, stderr=True, stream=False): + return self.attach( + container, + stdout=stdout, + stderr=stderr, + stream=stream, + logs=True + ) + + def port(self, container, private_port): + if isinstance(container, dict): + container = container.get('Id') + res = self._get(self._url("/containers/{0}/json".format(container))) + self._raise_for_status(res) + json_ = res.json() + s_port = str(private_port) + f_port = None + if s_port in json_['NetworkSettings']['PortMapping']['Udp']: + f_port = json_['NetworkSettings']['PortMapping']['Udp'][s_port] + elif s_port in json_['NetworkSettings']['PortMapping']['Tcp']: + f_port = json_['NetworkSettings']['PortMapping']['Tcp'][s_port] + + return f_port + + def pull(self, repository, tag=None, stream=False): + registry, repo_name = auth.resolve_repository_name(repository) + if repo_name.count(":") == 1: + repository, tag = repository.rsplit(":", 1) + + params = { + 'tag': tag, + 'fromImage': repository + } + headers = {} + + if utils.compare_version('1.5', self._version) >= 0: + # If we don't have any auth data so far, try reloading the config + # file one more time in case anything showed up in there. + if not self._auth_configs: + self._auth_configs = auth.load_config() + authcfg = auth.resolve_authconfig(self._auth_configs, registry) + + # Do not fail here if no atuhentication exists for this specific + # registry as we can have a readonly pull. Just put the header if + # we can. + if authcfg: + headers['X-Registry-Auth'] = auth.encode_header(authcfg) + + response = self._post(self._url('/images/create'), params=params, + headers=headers, stream=stream, timeout=None) + + if stream: + return self._stream_helper(response) + else: + return self._result(response) + + def push(self, repository, stream=False): + registry, repo_name = auth.resolve_repository_name(repository) + u = self._url("/images/{0}/push".format(repository)) + headers = {} + + if utils.compare_version('1.5', self._version) >= 0: + # If we don't have any auth data so far, try reloading the config + # file one more time in case anything showed up in there. + if not self._auth_configs: + self._auth_configs = auth.load_config() + authcfg = auth.resolve_authconfig(self._auth_configs, registry) + + # Do not fail here if no atuhentication exists for this specific + # registry as we can have a readonly pull. Just put the header if + # we can. + if authcfg: + headers['X-Registry-Auth'] = auth.encode_header(authcfg) + + response = self._post_json(u, None, headers=headers, stream=stream) + else: + response = self._post_json(u, authcfg, stream=stream) + + return stream and self._stream_helper(response) \ + or self._result(response) + + def remove_container(self, container, v=False, link=False): + if isinstance(container, dict): + container = container.get('Id') + params = {'v': v, 'link': link} + res = self._delete(self._url("/containers/" + container), + params=params) + self._raise_for_status(res) + + def remove_image(self, image): + res = self._delete(self._url("/images/" + image)) + self._raise_for_status(res) + + def restart(self, container, timeout=10): + if isinstance(container, dict): + container = container.get('Id') + params = {'t': timeout} + url = self._url("/containers/{0}/restart".format(container)) + res = self._post(url, params=params) + self._raise_for_status(res) + + def search(self, term): + return self._result(self._get(self._url("/images/search"), + params={'term': term}), + True) + + def start(self, container, binds=None, port_bindings=None, lxc_conf=None, + publish_all_ports=False, links=None, privileged=False): + if isinstance(container, dict): + container = container.get('Id') + + if isinstance(lxc_conf, dict): + formatted = [] + for k, v in six.iteritems(lxc_conf): + formatted.append({'Key': k, 'Value': str(v)}) + lxc_conf = formatted + + start_config = { + 'LxcConf': lxc_conf + } + if binds: + bind_pairs = [ + '{0}:{1}'.format(host, dest) for host, dest in binds.items() + ] + start_config['Binds'] = bind_pairs + + if port_bindings: + start_config['PortBindings'] = utils.convert_port_bindings( + port_bindings + ) + + start_config['PublishAllPorts'] = publish_all_ports + + if links: + formatted_links = [ + '{0}:{1}'.format(k, v) for k, v in sorted(six.iteritems(links)) + ] + + start_config['Links'] = formatted_links + + start_config['Privileged'] = privileged + + url = self._url("/containers/{0}/start".format(container)) + res = self._post_json(url, data=start_config) + self._raise_for_status(res) + + def stop(self, container, timeout=10): + if isinstance(container, dict): + container = container.get('Id') + params = {'t': timeout} + url = self._url("/containers/{0}/stop".format(container)) + res = self._post(url, params=params, + timeout=max(timeout, self._timeout)) + self._raise_for_status(res) + + def tag(self, image, repository, tag=None, force=False): + params = { + 'tag': tag, + 'repo': repository, + 'force': 1 if force else 0 + } + url = self._url("/images/{0}/tag".format(image)) + res = self._post(url, params=params) + self._raise_for_status(res) + return res.status_code == 201 + + def top(self, container): + u = self._url("/containers/{0}/top".format(container)) + return self._result(self._get(u), True) + + def version(self): + return self._result(self._get(self._url("/version")), True) + + def wait(self, container): + if isinstance(container, dict): + container = container.get('Id') + url = self._url("/containers/{0}/wait".format(container)) + res = self._post(url, timeout=None) + self._raise_for_status(res) + json_ = res.json() + if 'StatusCode' in json_: + return json_['StatusCode'] + return -1 diff --git a/fig/packages/docker/unixconn/__init__.py b/fig/packages/docker/unixconn/__init__.py new file mode 100644 index 00000000000..53711fc6d87 --- /dev/null +++ b/fig/packages/docker/unixconn/__init__.py @@ -0,0 +1 @@ +from .unixconn import UnixAdapter # flake8: noqa diff --git a/fig/packages/docker/unixconn/unixconn.py b/fig/packages/docker/unixconn/unixconn.py new file mode 100644 index 00000000000..c9565a25189 --- /dev/null +++ b/fig/packages/docker/unixconn/unixconn.py @@ -0,0 +1,71 @@ +# Copyright 2013 dotCloud inc. + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import six + +if six.PY3: + import http.client as httplib +else: + import httplib +import requests.adapters +import socket + +try: + import requests.packages.urllib3.connectionpool as connectionpool +except ImportError: + import urllib3.connectionpool as connectionpool + + +class UnixHTTPConnection(httplib.HTTPConnection, object): + def __init__(self, base_url, unix_socket, timeout=60): + httplib.HTTPConnection.__init__(self, 'localhost', timeout=timeout) + self.base_url = base_url + self.unix_socket = unix_socket + self.timeout = timeout + + def connect(self): + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + sock.settimeout(self.timeout) + sock.connect(self.base_url.replace("unix:/", "")) + self.sock = sock + + def _extract_path(self, url): + #remove the base_url entirely.. + return url.replace(self.base_url, "") + + def request(self, method, url, **kwargs): + url = self._extract_path(self.unix_socket) + super(UnixHTTPConnection, self).request(method, url, **kwargs) + + +class UnixHTTPConnectionPool(connectionpool.HTTPConnectionPool): + def __init__(self, base_url, socket_path, timeout=60): + connectionpool.HTTPConnectionPool.__init__(self, 'localhost', + timeout=timeout) + self.base_url = base_url + self.socket_path = socket_path + self.timeout = timeout + + def _new_conn(self): + return UnixHTTPConnection(self.base_url, self.socket_path, + self.timeout) + + +class UnixAdapter(requests.adapters.HTTPAdapter): + def __init__(self, base_url, timeout=60): + self.base_url = base_url + self.timeout = timeout + super(UnixAdapter, self).__init__() + + def get_connection(self, socket_path, proxies=None): + return UnixHTTPConnectionPool(self.base_url, socket_path, self.timeout) diff --git a/fig/packages/docker/utils/__init__.py b/fig/packages/docker/utils/__init__.py new file mode 100644 index 00000000000..386a01af7a8 --- /dev/null +++ b/fig/packages/docker/utils/__init__.py @@ -0,0 +1,3 @@ +from .utils import ( + compare_version, convert_port_bindings, mkbuildcontext, ping, tar +) # flake8: noqa diff --git a/fig/packages/docker/utils/utils.py b/fig/packages/docker/utils/utils.py new file mode 100644 index 00000000000..8fd9e9478d8 --- /dev/null +++ b/fig/packages/docker/utils/utils.py @@ -0,0 +1,96 @@ +# Copyright 2013 dotCloud inc. + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import io +import tarfile +import tempfile + +import requests +import six + + +def mkbuildcontext(dockerfile): + f = tempfile.NamedTemporaryFile() + t = tarfile.open(mode='w', fileobj=f) + if isinstance(dockerfile, io.StringIO): + dfinfo = tarfile.TarInfo('Dockerfile') + if six.PY3: + raise TypeError('Please use io.BytesIO to create in-memory ' + 'Dockerfiles with Python 3') + else: + dfinfo.size = len(dockerfile.getvalue()) + elif isinstance(dockerfile, io.BytesIO): + dfinfo = tarfile.TarInfo('Dockerfile') + dfinfo.size = len(dockerfile.getvalue()) + else: + dfinfo = t.gettarinfo(fileobj=dockerfile, arcname='Dockerfile') + t.addfile(dfinfo, dockerfile) + t.close() + f.seek(0) + return f + + +def tar(path): + f = tempfile.NamedTemporaryFile() + t = tarfile.open(mode='w', fileobj=f) + t.add(path, arcname='.') + t.close() + f.seek(0) + return f + + +def compare_version(v1, v2): + return float(v2) - float(v1) + + +def ping(url): + try: + res = requests.get(url) + return res.status >= 400 + except Exception: + return False + + +def _convert_port_binding(binding): + result = {'HostIp': '', 'HostPort': ''} + if isinstance(binding, tuple): + if len(binding) == 2: + result['HostPort'] = binding[1] + result['HostIp'] = binding[0] + elif isinstance(binding[0], six.string_types): + result['HostIp'] = binding[0] + else: + result['HostPort'] = binding[0] + else: + result['HostPort'] = binding + + if result['HostPort'] is None: + result['HostPort'] = '' + else: + result['HostPort'] = str(result['HostPort']) + + return result + + +def convert_port_bindings(port_bindings): + result = {} + for k, v in six.iteritems(port_bindings): + key = str(k) + if '/' not in key: + key = key + '/tcp' + if isinstance(v, list): + result[key] = [_convert_port_binding(binding) for binding in v] + else: + result[key] = [_convert_port_binding(v)] + return result diff --git a/fig/service.py b/fig/service.py index e730041013e..29e867e9c0f 100644 --- a/fig/service.py +++ b/fig/service.py @@ -1,6 +1,6 @@ from __future__ import unicode_literals from __future__ import absolute_import -from docker.client import APIError +from .packages.docker.client import APIError import logging import re import os diff --git a/requirements.txt b/requirements.txt index 7eedd09c1df..a4de170cb1c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ -docker-py==0.2.3 +requests==1.2.3 +websocket-client==0.11.0 docopt==0.6.1 PyYAML==3.10 texttable==0.8.1 diff --git a/tests/testcases.py b/tests/testcases.py index 671e091b7c8..8cc1ab35467 100644 --- a/tests/testcases.py +++ b/tests/testcases.py @@ -1,6 +1,6 @@ from __future__ import unicode_literals from __future__ import absolute_import -from docker import Client +from fig.packages.docker import Client from fig.service import Service from fig.cli.utils import docker_url from . import unittest From c4f5ed839fb44b2d057389efa599d2f30938bd3e Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Thu, 16 Jan 2014 14:02:52 +0000 Subject: [PATCH 0189/4072] Shorten long commands in ps --- fig/cli/main.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/fig/cli/main.py b/fig/cli/main.py index cd4476f4a14..0993510d7a6 100644 --- a/fig/cli/main.py +++ b/fig/cli/main.py @@ -156,9 +156,12 @@ def ps(self, options): ] rows = [] for container in containers: + command = container.human_readable_command + if len(command) > 30: + command = '%s ...' % command[:30] rows.append([ container.name, - container.human_readable_command, + command, container.human_readable_state, container.human_readable_ports, ]) From feafea2c6d992d2f1f455b83971e25671e539736 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 13 Jan 2014 16:23:10 +0000 Subject: [PATCH 0190/4072] LogPrinter uses regular `attach()`, not websocket Fixes #7. --- fig/cli/log_printer.py | 16 +++------------- fig/container.py | 3 +++ 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/fig/cli/log_printer.py b/fig/cli/log_printer.py index dce47d2e0df..f20ad88d1ea 100644 --- a/fig/cli/log_printer.py +++ b/fig/cli/log_printer.py @@ -31,28 +31,18 @@ def _make_log_generators(self): def _make_log_generator(self, container, color_fn): prefix = color_fn(container.name + " | ") - websocket = self._attach(container) - return (prefix + line for line in split_buffer(read_websocket(websocket), '\n')) + for line in split_buffer(self._attach(container), '\n'): + yield prefix + line def _attach(self, container): params = { - 'stdin': False, 'stdout': True, 'stderr': True, - 'logs': False, 'stream': True, } params.update(self.attach_params) params = dict((name, 1 if value else 0) for (name, value) in list(params.items())) - return container.attach_socket(params=params, ws=True) - -def read_websocket(websocket): - while True: - data = websocket.recv() - if data: - yield data - else: - break + return container.attach(**params) def split_buffer(reader, separator): """ diff --git a/fig/container.py b/fig/container.py index 24b239ac692..f8abe83d311 100644 --- a/fig/container.py +++ b/fig/container.py @@ -122,6 +122,9 @@ def links(self): links.append(bits[2]) return links + def attach(self, *args, **kwargs): + return self.client.attach(self.id, *args, **kwargs) + def attach_socket(self, **kwargs): return self.client.attach_socket(self.id, **kwargs) From af1b0ed08895fa13ca46ef417c975e07f8391b08 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 16 Jan 2014 14:06:48 +0000 Subject: [PATCH 0191/4072] Account for length of the ellipsis string when truncating commands --- fig/cli/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fig/cli/main.py b/fig/cli/main.py index 0993510d7a6..24a0180d9b0 100644 --- a/fig/cli/main.py +++ b/fig/cli/main.py @@ -158,7 +158,7 @@ def ps(self, options): for container in containers: command = container.human_readable_command if len(command) > 30: - command = '%s ...' % command[:30] + command = '%s ...' % command[:26] rows.append([ container.name, command, From 3e2fd6a2a1dd9b8d68e1f44ba33cda5315a3b990 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 16 Jan 2014 16:36:01 +0000 Subject: [PATCH 0192/4072] Use DOCKER_HOST environment variable to find Docker daemon Removed all "smart" connection logic. Fig either uses the DOCKER_HOST environment variable if it's present, or passes `None` to docker-py, which does the "right thing" (i.e. falls back to the Unix socket). This means we no longer know at URL-deciding time whether we can connect to the Docker daemon, so we wrap `dispatch` in a `try/except` which catches `requests.exceptions.ConnectionError`. --- fig/cli/command.py | 12 ++++++++++++ fig/cli/utils.py | 31 +------------------------------ 2 files changed, 13 insertions(+), 30 deletions(-) diff --git a/fig/cli/command.py b/fig/cli/command.py index 00452dd385b..2ccc8c1bc38 100644 --- a/fig/cli/command.py +++ b/fig/cli/command.py @@ -1,6 +1,7 @@ from __future__ import unicode_literals from __future__ import absolute_import from ..packages.docker import Client +from requests.exceptions import ConnectionError import errno import logging import os @@ -11,12 +12,23 @@ from .docopt_command import DocoptCommand from .formatter import Formatter from .utils import cached_property, docker_url +from .errors import UserError log = logging.getLogger(__name__) class Command(DocoptCommand): base_dir = '.' + def dispatch(self, *args, **kwargs): + try: + super(Command, self).dispatch(*args, **kwargs) + except ConnectionError: + raise UserError(""" +Couldn't connect to Docker daemon at %s - is it running? + +If it's at a non-standard location, specify the URL with the DOCKER_HOST environment variable. +""" % self.client.base_url) + @cached_property def client(self): return Client(docker_url()) diff --git a/fig/cli/utils.py b/fig/cli/utils.py index e9e437859ea..2b0eb42d6e0 100644 --- a/fig/cli/utils.py +++ b/fig/cli/utils.py @@ -82,33 +82,4 @@ def mkdir(path, permissions=0o700): def docker_url(): - if os.environ.get('DOCKER_URL'): - return os.environ['DOCKER_URL'] - - socket_path = '/var/run/docker.sock' - tcp_hosts = [ - ('localdocker', 4243), - ('127.0.0.1', 4243), - ] - tcp_host = '127.0.0.1' - tcp_port = 4243 - - for host, port in tcp_hosts: - try: - s = socket.create_connection((host, port), timeout=1) - s.close() - return 'http://%s:%s' % (host, port) - except: - pass - - if os.path.exists(socket_path): - return 'unix://%s' % socket_path - - raise UserError(""" -Couldn't find Docker daemon - tried: - -unix://%s -%s - -If it's running elsewhere, specify a url with DOCKER_URL. - """ % (socket_path, '\n'.join('tcp://%s:%s' % h for h in tcp_hosts))) + return os.environ.get('DOCKER_HOST') From bb7613f37bc0c8917b402d9610eef0cfc7d7c462 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 16 Jan 2014 16:50:52 +0000 Subject: [PATCH 0193/4072] Travis runs Docker just on the Unix socket, not on TCP --- script/travis | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/travis b/script/travis index 3cdacfa5c25..e85af550886 100755 --- a/script/travis +++ b/script/travis @@ -13,7 +13,7 @@ env trap 'kill $(jobs -p)' SIGINT SIGTERM EXIT # Start docker daemon -docker -d -H 0.0.0.0:4243 -H unix:///var/run/docker.sock 2>> /dev/null >> /dev/null & +docker -d -H unix:///var/run/docker.sock 2>> /dev/null >> /dev/null & sleep 2 # $init is set by sekexe From b20190da980b77c6243e8fc59ef23cc70acd93d5 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Thu, 16 Jan 2014 17:28:47 +0000 Subject: [PATCH 0194/4072] Add basic Dockerfile Because. --- Dockerfile | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 Dockerfile diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000000..4d31a5a001b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,9 @@ +FROM stackbrew/ubuntu:12.04 +RUN apt-get update -qq +RUN apt-get install -y python python-pip +ADD requirements.txt /code/ +WORKDIR /code/ +RUN pip install -r requirements.txt +ADD requirements-dev.txt /code/ +RUN pip install -r requirements-dev.txt +ADD . /code/ From 8ed86ed551f57a53e70e86e08f346d4659b71580 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Thu, 16 Jan 2014 18:05:45 +0000 Subject: [PATCH 0195/4072] Add number to container --- fig/container.py | 7 +++++++ tests/container_test.py | 14 ++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/fig/container.py b/fig/container.py index f8abe83d311..da10ec7aea6 100644 --- a/fig/container.py +++ b/fig/container.py @@ -46,6 +46,13 @@ def short_id(self): def name(self): return self.dictionary['Name'][1:] + @property + def number(self): + try: + return int(self.name.split('_')[-1]) + except ValueError: + return None + @property def human_readable_ports(self): self.inspect_if_not_inspected() diff --git a/tests/container_test.py b/tests/container_test.py index 3666b3e4408..351a807a833 100644 --- a/tests/container_test.py +++ b/tests/container_test.py @@ -35,3 +35,17 @@ def test_environment(self): 'FOO': 'BAR', 'BAZ': 'DOGE', }) + + def test_number(self): + container = Container.from_ps(self.client, { + "Id":"abc", + "Image":"ubuntu:12.04", + "Command":"sleep 300", + "Created":1387384730, + "Status":"Up 8 seconds", + "Ports":None, + "SizeRw":0, + "SizeRootFs":0, + "Names":["/db_1"] + }, has_been_inspected=True) + self.assertEqual(container.number, 1) From 56c6efdfcebfb2fab6a8281e9e7cccd29168fe8a Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Thu, 16 Jan 2014 17:58:53 +0000 Subject: [PATCH 0196/4072] Add scale command Closes #9 --- README.md | 9 ++++++ fig/cli/main.py | 27 ++++++++++++++++ fig/service.py | 44 +++++++++++++++++++++++++++ tests/cli_test.py | 23 ++++++++++++++ tests/fixtures/simple-figfile/fig.yml | 4 +++ tests/service_test.py | 18 +++++++++++ 6 files changed, 125 insertions(+) diff --git a/README.md b/README.md index 585cdd89d75..327ac2327ca 100644 --- a/README.md +++ b/README.md @@ -233,6 +233,15 @@ For example: Note that this will not start any services that the command's service links to. So if, for example, your one-off command talks to your database, you will need to run `fig up -d db` first. +#### scale + +Set number of containers to run for a service. + +Numbers are specified in the form `service=num` as arguments. +For example: + + $ fig scale web=2 worker=3 + #### start Start existing containers for a service. diff --git a/fig/cli/main.py b/fig/cli/main.py index 24a0180d9b0..44422777f2b 100644 --- a/fig/cli/main.py +++ b/fig/cli/main.py @@ -10,6 +10,7 @@ from .. import __version__ from ..project import NoSuchService +from ..service import CannotBeScaledError from .command import Command from .formatter import Formatter from .log_printer import LogPrinter @@ -82,6 +83,7 @@ class TopLevelCommand(Command): ps List containers rm Remove stopped containers run Run a one-off command + scale Set number of containers for a service start Start services stop Stop services up Create and start containers @@ -220,6 +222,31 @@ def run(self, options): service.start_container(container, ports=None) c.run() + def scale(self, options): + """ + Set number of containers to run for a service. + + Numbers are specified in the form `service=num` as arguments. + For example: + + $ fig scale web=2 worker=3 + + Usage: scale [SERVICE=NUM...] + """ + for s in options['SERVICE=NUM']: + if '=' not in s: + raise UserError('Arguments to scale should be in the form service=num') + service_name, num = s.split('=', 1) + try: + num = int(num) + except ValueError: + raise UserError('Number of containers for service "%s" is not a number' % service) + try: + self.project.get_service(service_name).scale(num) + except CannotBeScaledError: + raise UserError('Service "%s" cannot be scaled because it specifies a port on the host. If multiple containers for this service were created, the port would clash.\n\nRemove the ":" from the port definition in fig.yml so Docker can choose a random port for each container.' % service_name) + + def start(self, options): """ Start existing containers. diff --git a/fig/service.py b/fig/service.py index 29e867e9c0f..90194fdd591 100644 --- a/fig/service.py +++ b/fig/service.py @@ -14,6 +14,10 @@ class BuildError(Exception): pass +class CannotBeScaledError(Exception): + pass + + class Service(object): def __init__(self, name, client=None, project='default', links=[], **options): if not re.match('^[a-zA-Z0-9]+$', name): @@ -56,6 +60,40 @@ def kill(self, **options): log.info("Killing %s..." % c.name) c.kill(**options) + def scale(self, desired_num): + if not self.can_be_scaled(): + raise CannotBeScaledError() + + # Create enough containers + containers = self.containers(stopped=True) + while len(containers) < desired_num: + containers.append(self.create_container()) + + running_containers = [] + stopped_containers = [] + for c in containers: + if c.is_running: + running_containers.append(c) + else: + stopped_containers.append(c) + running_containers.sort(key=lambda c: c.number) + stopped_containers.sort(key=lambda c: c.number) + + # Stop containers + while len(running_containers) > desired_num: + c = running_containers.pop() + log.info("Stopping %s..." % c.name) + c.stop(timeout=1) + stopped_containers.append(c) + + # Start containers + while len(running_containers) < desired_num: + c = stopped_containers.pop(0) + log.info("Starting %s..." % c.name) + c.start() + running_containers.append(c) + + def remove_stopped(self, **options): for c in self.containers(stopped=True): if not c.is_running: @@ -231,6 +269,12 @@ def _build_tag_name(self): """ return '%s_%s' % (self.project, self.name) + def can_be_scaled(self): + for port in self.options.get('ports', []): + if ':' in str(port): + return False + return True + NAME_RE = re.compile(r'^([^_]+)_([^_]+)_(run_)?(\d+)$') diff --git a/tests/cli_test.py b/tests/cli_test.py index 2146a90622d..0d9a2f54061 100644 --- a/tests/cli_test.py +++ b/tests/cli_test.py @@ -13,3 +13,26 @@ def test_help(self): def test_ps(self): self.command.dispatch(['ps'], None) + + def test_scale(self): + project = self.command.project + + self.command.scale({'SERVICE=NUM': ['simple=1']}) + self.assertEqual(len(project.get_service('simple').containers()), 1) + + self.command.scale({'SERVICE=NUM': ['simple=3', 'another=2']}) + self.assertEqual(len(project.get_service('simple').containers()), 3) + self.assertEqual(len(project.get_service('another').containers()), 2) + + self.command.scale({'SERVICE=NUM': ['simple=1', 'another=1']}) + self.assertEqual(len(project.get_service('simple').containers()), 1) + self.assertEqual(len(project.get_service('another').containers()), 1) + + self.command.scale({'SERVICE=NUM': ['simple=1', 'another=1']}) + self.assertEqual(len(project.get_service('simple').containers()), 1) + self.assertEqual(len(project.get_service('another').containers()), 1) + + self.command.scale({'SERVICE=NUM': ['simple=0', 'another=0']}) + self.assertEqual(len(project.get_service('simple').containers()), 0) + self.assertEqual(len(project.get_service('another').containers()), 0) + diff --git a/tests/fixtures/simple-figfile/fig.yml b/tests/fixtures/simple-figfile/fig.yml index aef2d39ba0c..225323755c3 100644 --- a/tests/fixtures/simple-figfile/fig.yml +++ b/tests/fixtures/simple-figfile/fig.yml @@ -1,2 +1,6 @@ simple: image: ubuntu + command: /bin/sleep 300 +another: + image: ubuntu + command: /bin/sleep 300 diff --git a/tests/service_test.py b/tests/service_test.py index 2ccf17555b9..ff7b2416007 100644 --- a/tests/service_test.py +++ b/tests/service_test.py @@ -1,6 +1,7 @@ from __future__ import unicode_literals from __future__ import absolute_import from fig import Service +from fig.service import CannotBeScaledError from .testcases import DockerClientTestCase @@ -193,3 +194,20 @@ def test_start_container_creates_fixed_external_ports_when_it_is_different_to_in self.assertIn('8000/tcp', container['HostConfig']['PortBindings']) self.assertEqual(container['HostConfig']['PortBindings']['8000/tcp'][0]['HostPort'], '8001') + def test_scale(self): + service = self.create_service('web') + service.scale(1) + self.assertEqual(len(service.containers()), 1) + service.scale(3) + self.assertEqual(len(service.containers()), 3) + service.scale(1) + self.assertEqual(len(service.containers()), 1) + service.scale(0) + self.assertEqual(len(service.containers()), 0) + + def test_scale_on_service_that_cannot_be_scaled(self): + service = self.create_service('web', ports=['8000:8000']) + self.assertRaises(CannotBeScaledError, lambda: service.scale(1)) + + + From 65f23583ae340aa711e60d77983fbdfc40041be4 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Thu, 16 Jan 2014 18:16:26 +0000 Subject: [PATCH 0197/4072] Ignore vim undo files --- MANIFEST.in | 1 + 1 file changed, 1 insertion(+) diff --git a/MANIFEST.in b/MANIFEST.in index 95e97bf385d..02bdc73b8ca 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -3,3 +3,4 @@ include *.md recursive-include tests * global-exclude *.pyc global-exclode *.pyo +global-exclode *.un~ From a07b00606bc92e68002907b9114df62199368d8c Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Thu, 16 Jan 2014 18:17:16 +0000 Subject: [PATCH 0198/4072] Fix typos in manifest --- MANIFEST.in | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/MANIFEST.in b/MANIFEST.in index 02bdc73b8ca..aae1042d2e5 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -2,5 +2,5 @@ include LICENSE include *.md recursive-include tests * global-exclude *.pyc -global-exclode *.pyo -global-exclode *.un~ +global-exclude *.pyo +global-exclude *.un~ From 74fb400fefee51fa6fba2decfa3c41a995b2e345 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 16 Jan 2014 17:28:43 +0000 Subject: [PATCH 0199/4072] Version 0.1.0 --- CHANGES.md | 14 ++++++++++++++ fig/__init__.py | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index 8d5001eb309..bfae75d045f 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,6 +1,20 @@ Change log ========== +0.1.0 (2014-01-16) +------------------ + + - Containers are recreated on each `fig up`, ensuring config is up-to-date with `fig.yml` (#2) + - Add `fig scale` command (#9) + - Use DOCKER_HOST environment variable to find Docker daemon (#19) + - Truncate long commands in `fig ps` (#18) + - Fill out CLI help banners for commands (#15, #16) + - Show a friendlier error when `fig.yml` is missing (#4) + + - Fix bug with `fig build` logging (#3) + - Fix bug where builds would time out if a step took a long time without generating output (#6) + - Fix bug where streaming container output over the Unix socket raised an error (#7) + 0.0.2 (2014-01-02) ------------------ diff --git a/fig/__init__.py b/fig/__init__.py index baa08f1ed92..0cc7b145c0e 100644 --- a/fig/__init__.py +++ b/fig/__init__.py @@ -1,4 +1,4 @@ from __future__ import unicode_literals from .service import Service -__version__ = '0.0.2' +__version__ = '0.1.0' From 573fae089f665130d056f78f50a98235aeb959ee Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Thu, 16 Jan 2014 18:21:56 +0000 Subject: [PATCH 0200/4072] Add missing files to manifest --- MANIFEST.in | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/MANIFEST.in b/MANIFEST.in index aae1042d2e5..9d1c2ae058d 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,8 @@ +include Dockerfile include LICENSE +include requirements.txt +include requirements-dev.txt +tox.ini include *.md recursive-include tests * global-exclude *.pyc From 720cc192bbfe8fb0656b38c8d6b28016fc086d22 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Thu, 16 Jan 2014 18:27:06 +0000 Subject: [PATCH 0201/4072] Add upgrade instructions --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 327ac2327ca..dd76fe928de 100644 --- a/README.md +++ b/README.md @@ -47,9 +47,9 @@ Docker has guides for [Ubuntu](http://docs.docker.io/en/latest/installation/ubun Next, install Fig: - $ sudo pip install fig + $ sudo pip install -U fig -(If you don’t have pip installed, try `brew install python` or `apt-get install python-pip`.) +(If you don’t have pip installed, try `brew install python` or `apt-get install python-pip`. This command also upgrades Fig when we release a new version.) You'll want to make a directory for the project: From caccf96d3fb3825e7a1708e24d85cfc0ede04fcf Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 16 Jan 2014 18:32:35 +0000 Subject: [PATCH 0202/4072] Fix markdown --- CHANGES.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index bfae75d045f..67f93847522 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -10,7 +10,6 @@ Change log - Truncate long commands in `fig ps` (#18) - Fill out CLI help banners for commands (#15, #16) - Show a friendlier error when `fig.yml` is missing (#4) - - Fix bug with `fig build` logging (#3) - Fix bug where builds would time out if a step took a long time without generating output (#6) - Fix bug where streaming container output over the Unix socket raised an error (#7) From 15e8c9ffbbe893b80e73322a59c3f1456401d075 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Thu, 16 Jan 2014 18:35:30 +0000 Subject: [PATCH 0203/4072] Fix ambiguous order of upgrade instructions --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index dd76fe928de..6cb640782db 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ Next, install Fig: $ sudo pip install -U fig -(If you don’t have pip installed, try `brew install python` or `apt-get install python-pip`. This command also upgrades Fig when we release a new version.) +(This command also upgrades Fig when we release a new version. If you don’t have pip installed, try `brew install python` or `apt-get install python-pip`.) You'll want to make a directory for the project: From 5b9c228cf852772eb0508f264aa759aaf63aeaa4 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 16 Jan 2014 18:42:03 +0000 Subject: [PATCH 0204/4072] Add note to CHANGES.md about old/new env vars --- CHANGES.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index 67f93847522..82a33c2b4c1 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -6,7 +6,7 @@ Change log - Containers are recreated on each `fig up`, ensuring config is up-to-date with `fig.yml` (#2) - Add `fig scale` command (#9) - - Use DOCKER_HOST environment variable to find Docker daemon (#19) + - Use `DOCKER_HOST` environment variable to find Docker daemon, for consistency with the official Docker client (was previously `DOCKER_URL`) (#19) - Truncate long commands in `fig ps` (#18) - Fill out CLI help banners for commands (#15, #16) - Show a friendlier error when `fig.yml` is missing (#4) From 7070e06ac6992217c9c1c0f213bcde7e9285afc1 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 16 Jan 2014 18:45:38 +0000 Subject: [PATCH 0205/4072] Thank some folks --- CHANGES.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index 82a33c2b4c1..644ff495402 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -14,6 +14,8 @@ Change log - Fix bug where builds would time out if a step took a long time without generating output (#6) - Fix bug where streaming container output over the Unix socket raised an error (#7) +Big thanks to @tomstuart, @EnTeQuAk, @schickling, @aronasorman and @GeoffreyPlitt. + 0.0.2 (2014-01-02) ------------------ From 8b77b51c1586cca031b07003dbcecd6650318742 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Thu, 16 Jan 2014 18:49:15 +0000 Subject: [PATCH 0206/4072] Fix travis formatting To use formatting travis command line tool uses --- .travis.yml | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/.travis.yml b/.travis.yml index d00b2228ea1..8e93cb86798 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,19 +1,15 @@ language: python python: - - "2.6" - - "2.7" - - "3.2" - - "3.3" - +- '2.6' +- '2.7' +- '3.2' +- '3.3' matrix: allow_failures: - - python: "3.2" - - python: "3.3" - + - python: '3.2' + - python: '3.3' install: script/travis-install - script: - - pwd - - env - - sekexe/run "`pwd`/script/travis $TRAVIS_PYTHON_VERSION" - +- pwd +- env +- sekexe/run "`pwd`/script/travis $TRAVIS_PYTHON_VERSION" From 7c1ec74cf62172ec2454cae77f1d96e823bbe59b Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Thu, 16 Jan 2014 18:51:32 +0000 Subject: [PATCH 0207/4072] Add Travis PyPi deployment --- .travis.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.travis.yml b/.travis.yml index 8e93cb86798..c0e4cfc118a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -13,3 +13,11 @@ script: - pwd - env - sekexe/run "`pwd`/script/travis $TRAVIS_PYTHON_VERSION" +deploy: + provider: pypi + user: orchard + password: + secure: M8UMupCLSsB1hV00Zn6ra8Vg81SCFBpbcRsa0nUw9kgXn9hOCESWYVHTqQ1ksWZOa8z6WMaqYtoosPKXGJQNf0wF/kEVDsMUeaZWOF/PqDkx1EwQ1diVfwlbN4/k0iX+Se7SrZfiWnJiAqiIPqToQipvLlJohqf8WwfPcVvILVE= + on: + tags: true + repo: orchardup/fig From 4bec39535fe50055879c1da70cc0553f6d536103 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Thu, 16 Jan 2014 18:52:15 +0000 Subject: [PATCH 0208/4072] Remove unnecessary release script --- script/release | 14 -------------- 1 file changed, 14 deletions(-) delete mode 100755 script/release diff --git a/script/release b/script/release deleted file mode 100755 index fdd4a960cc8..00000000000 --- a/script/release +++ /dev/null @@ -1,14 +0,0 @@ -#!/bin/bash - -set -xe - -if [ -z "$1" ]; then - echo 'pass a version as first argument' - exit 1 -fi - -git tag $1 -git push --tags -python setup.py sdist upload - - From c6e19e34f721699a5a7f1f730a5d252122e8896d Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 17 Jan 2014 18:00:22 +0000 Subject: [PATCH 0209/4072] Fix external port config When exposing a port externally, it seems Docker only actually exposes it if you specify the *internal* port as `xxxx/tcp`. So add that on if it's not there. --- fig/service.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/fig/service.py b/fig/service.py index 90194fdd591..f96794267bd 100644 --- a/fig/service.py +++ b/fig/service.py @@ -224,6 +224,8 @@ def _get_container_options(self, override_options, one_off=False): port = str(port) if ':' in port: port = port.split(':')[-1] + if '/' not in port: + port = "%s/tcp" % port ports.append(port) container_options['ports'] = ports From b428988ef659b7c51db25267d9fd03b50d407adc Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Fri, 17 Jan 2014 19:14:48 +0000 Subject: [PATCH 0210/4072] Bump to version 0.1.1 --- CHANGES.md | 5 +++++ fig/__init__.py | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index 644ff495402..f94c0205f56 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,6 +1,11 @@ Change log ========== +0.1.1 (2014-01-17) +------------------ + + - Fix bug where ports were not exposed correctly (#29). Thanks @dustinlacewell! + 0.1.0 (2014-01-16) ------------------ diff --git a/fig/__init__.py b/fig/__init__.py index 0cc7b145c0e..415b4a3137c 100644 --- a/fig/__init__.py +++ b/fig/__init__.py @@ -1,4 +1,4 @@ from __future__ import unicode_literals from .service import Service -__version__ = '0.1.0' +__version__ = '0.1.1' From 07f3c783699799129d83b518ac7705630db3939f Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Sun, 19 Jan 2014 16:50:08 +0000 Subject: [PATCH 0211/4072] Update docker-py From https://github.com/bfirsh/docker-py/commit/4bc5d27e51cc9afecfbc1d566b413f4d45203823 --- fig/packages/docker/client.py | 72 ++++++++++++++++++++--------------- 1 file changed, 41 insertions(+), 31 deletions(-) diff --git a/fig/packages/docker/client.py b/fig/packages/docker/client.py index e3cd976cd4b..3fc2b08463b 100644 --- a/fig/packages/docker/client.py +++ b/fig/packages/docker/client.py @@ -122,7 +122,8 @@ def _container_config(self, image, command, hostname=None, user=None, detach=False, stdin_open=False, tty=False, mem_limit=0, ports=None, environment=None, dns=None, volumes=None, volumes_from=None, - network_disabled=False): + network_disabled=False, entrypoint=None, + cpu_shares=None, working_dir=None): if isinstance(command, six.string_types): command = shlex.split(str(command)) if isinstance(environment, dict): @@ -134,15 +135,12 @@ def _container_config(self, image, command, hostname=None, user=None, exposed_ports = {} for port_definition in ports: port = port_definition - proto = None + proto = 'tcp' if isinstance(port_definition, tuple): if len(port_definition) == 2: proto = port_definition[1] port = port_definition[0] - exposed_ports['{0}{1}'.format( - port, - '/' + proto if proto else '' - )] = {} + exposed_ports['{0}/{1}'.format(port, proto)] = {} ports = exposed_ports if volumes and isinstance(volumes, list): @@ -178,7 +176,10 @@ def _container_config(self, image, command, hostname=None, user=None, 'Image': image, 'Volumes': volumes, 'VolumesFrom': volumes_from, - 'NetworkDisabled': network_disabled + 'NetworkDisabled': network_disabled, + 'Entrypoint': entrypoint, + 'CpuShares': cpu_shares, + 'WorkingDir': working_dir } def _post_json(self, url, data, **kwargs): @@ -409,11 +410,13 @@ def create_container(self, image, command=None, hostname=None, user=None, detach=False, stdin_open=False, tty=False, mem_limit=0, ports=None, environment=None, dns=None, volumes=None, volumes_from=None, - network_disabled=False, name=None): + network_disabled=False, name=None, entrypoint=None, + cpu_shares=None, working_dir=None): config = self._container_config( image, command, hostname, user, detach, stdin_open, tty, mem_limit, - ports, environment, dns, volumes, volumes_from, network_disabled + ports, environment, dns, volumes, volumes_from, network_disabled, + entrypoint, cpu_shares, working_dir ) return self.create_container_from_config(config, name) @@ -475,27 +478,34 @@ def images(self, name=None, quiet=False, all=False, viz=False): return [x['Id'] for x in res] return res - def import_image(self, src, data=None, repository=None, tag=None): + def import_image(self, src=None, repository=None, tag=None, image=None): u = self._url("/images/create") params = { 'repo': repository, 'tag': tag } - try: - # XXX: this is ways not optimal but the only way - # for now to import tarballs through the API - fic = open(src) - data = fic.read() - fic.close() - src = "-" - except IOError: - # file does not exists or not a file (URL) - data = None - if isinstance(src, six.string_types): - params['fromSrc'] = src - return self._result(self._post(u, data=data, params=params)) - - return self._result(self._post(u, data=src, params=params)) + + if src: + try: + # XXX: this is ways not optimal but the only way + # for now to import tarballs through the API + fic = open(src) + data = fic.read() + fic.close() + src = "-" + except IOError: + # file does not exists or not a file (URL) + data = None + if isinstance(src, six.string_types): + params['fromSrc'] = src + return self._result(self._post(u, data=data, params=params)) + return self._result(self._post(u, data=src, params=params)) + + if image: + params['fromImage'] = image + return self._result(self._post(u, data=None, params=params)) + + raise Exception("Must specify a src or image") def info(self): return self._result(self._get(self._url("/info")), @@ -577,13 +587,13 @@ def port(self, container, private_port): self._raise_for_status(res) json_ = res.json() s_port = str(private_port) - f_port = None - if s_port in json_['NetworkSettings']['PortMapping']['Udp']: - f_port = json_['NetworkSettings']['PortMapping']['Udp'][s_port] - elif s_port in json_['NetworkSettings']['PortMapping']['Tcp']: - f_port = json_['NetworkSettings']['PortMapping']['Tcp'][s_port] + h_ports = None + + h_ports = json_['NetworkSettings']['Ports'].get(s_port + '/udp') + if h_ports is None: + h_ports = json_['NetworkSettings']['Ports'].get(s_port + '/tcp') - return f_port + return h_ports def pull(self, repository, tag=None, stream=False): registry, repo_name = auth.resolve_repository_name(repository) From 62bba1684b91397f5deec3dad37a60eb37c1646e Mon Sep 17 00:00:00 2001 From: Cameron Maske Date: Sat, 18 Jan 2014 14:11:25 +0000 Subject: [PATCH 0212/4072] Updated recreate_containers to attempt to base intermediate container's the previous container's image. Added in additional functionality to reset any entrypoints for the intermediate container and pull/retry handling if the image does not exist. Updated test coverage to check if an container is recreated with an entrypoint it is handled correctly. --- fig/container.py | 6 +++++- fig/service.py | 16 +++++++++------- tests/service_test.py | 11 ++++++++--- 3 files changed, 22 insertions(+), 11 deletions(-) diff --git a/fig/container.py b/fig/container.py index da10ec7aea6..76f2d29e1da 100644 --- a/fig/container.py +++ b/fig/container.py @@ -3,7 +3,7 @@ class Container(object): """ - Represents a Docker container, constructed from the output of + Represents a Docker container, constructed from the output of GET /containers/:id:/json. """ def __init__(self, client, dictionary, has_been_inspected=False): @@ -38,6 +38,10 @@ def create(cls, client, **options): def id(self): return self.dictionary['ID'] + @property + def image(self): + return self.dictionary['Image'] + @property def short_id(self): return self.id[:10] diff --git a/fig/service.py b/fig/service.py index f96794267bd..730e3a1c91d 100644 --- a/fig/service.py +++ b/fig/service.py @@ -141,12 +141,14 @@ def recreate_container(self, container, **override_options): if container.is_running: container.stop(timeout=1) - intermediate_container = Container.create( - self.client, - image='ubuntu', - command='echo', - volumes_from=container.id, - ) + intermediate_container_options = { + 'image': container.image, + 'command': 'echo', + 'volumes_from': container.id, + 'entrypoint': None + } + intermediate_container = self.create_container( + one_off=True, **intermediate_container_options) intermediate_container.start() intermediate_container.wait() container.remove() @@ -212,7 +214,7 @@ def _get_links(self): return links def _get_container_options(self, override_options, one_off=False): - keys = ['image', 'command', 'hostname', 'user', 'detach', 'stdin_open', 'tty', 'mem_limit', 'ports', 'environment', 'dns', 'volumes', 'volumes_from'] + keys = ['image', 'command', 'hostname', 'user', 'detach', 'stdin_open', 'tty', 'mem_limit', 'ports', 'environment', 'dns', 'volumes', 'volumes_from', 'entrypoint'] container_options = dict((k, self.options[k]) for k in keys if k in self.options) container_options.update(override_options) diff --git a/tests/service_test.py b/tests/service_test.py index ff7b2416007..e4183a02bbe 100644 --- a/tests/service_test.py +++ b/tests/service_test.py @@ -110,8 +110,9 @@ def test_create_container_with_unspecified_volume(self): self.assertIn('/var/db', container.inspect()['Volumes']) def test_recreate_containers(self): - service = self.create_service('db', environment={'FOO': '1'}, volumes=['/var/db']) + service = self.create_service('db', environment={'FOO': '1'}, volumes=['/var/db'], entrypoint=['ps']) old_container = service.create_container() + self.assertEqual(old_container.dictionary['Config']['Entrypoint'], ['ps']) self.assertEqual(old_container.dictionary['Config']['Env'], ['FOO=1']) self.assertEqual(old_container.name, 'figtest_db_1') service.start_container(old_container) @@ -120,11 +121,15 @@ def test_recreate_containers(self): num_containers_before = len(self.client.containers(all=True)) service.options['environment']['FOO'] = '2' - (old, new) = service.recreate_containers() - self.assertEqual(len(old), 1) + (intermediate, new) = service.recreate_containers() + self.assertEqual(len(intermediate), 1) self.assertEqual(len(new), 1) new_container = new[0] + intermediate_container = intermediate[0] + self.assertEqual(intermediate_container.dictionary['Config']['Entrypoint'], None) + + self.assertEqual(new_container.dictionary['Config']['Entrypoint'], ['ps']) self.assertEqual(new_container.dictionary['Config']['Env'], ['FOO=2']) self.assertEqual(new_container.name, 'figtest_db_1') service.start_container(new_container) From f3d273864d0fbeec91a61fae467a82d784da7c86 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Sun, 19 Jan 2014 19:55:28 +0000 Subject: [PATCH 0213/4072] Add comments to script/travis --- script/travis | 3 +++ 1 file changed, 3 insertions(+) diff --git a/script/travis b/script/travis index e85af550886..12ad673aa7f 100755 --- a/script/travis +++ b/script/travis @@ -3,10 +3,13 @@ # Exit on first error set -ex +# Put Python eggs in a writeable directory export PYTHON_EGG_CACHE="/tmp/.python-eggs" +# Activate correct virtualenv TRAVIS_PYTHON_VERSION=$1 source /home/travis/virtualenv/python${TRAVIS_PYTHON_VERSION}/bin/activate + env # Kill background processes on exit From 24a6d1d836e918ba5534012727766f74778d147b Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Sun, 19 Jan 2014 19:56:29 +0000 Subject: [PATCH 0214/4072] Forcibly kill Docker when Travis ends May fix tests timing out. --- script/travis | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/travis b/script/travis index 12ad673aa7f..69bc3043b26 100755 --- a/script/travis +++ b/script/travis @@ -13,7 +13,7 @@ source /home/travis/virtualenv/python${TRAVIS_PYTHON_VERSION}/bin/activate env # Kill background processes on exit -trap 'kill $(jobs -p)' SIGINT SIGTERM EXIT +trap 'kill -9 $(jobs -p)' SIGINT SIGTERM EXIT # Start docker daemon docker -d -H unix:///var/run/docker.sock 2>> /dev/null >> /dev/null & From fc1bbb45b1a9a72d8f4d3fa00eeb1f8a3ebb3eb8 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Sun, 19 Jan 2014 20:33:06 +0000 Subject: [PATCH 0215/4072] Add option to disable pseudo-tty on fig run Also disable tty if stdin is not a tty. --- fig/cli/main.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/fig/cli/main.py b/fig/cli/main.py index 44422777f2b..1696a7c98ed 100644 --- a/fig/cli/main.py +++ b/fig/cli/main.py @@ -4,7 +4,6 @@ import sys import re import signal -import sys from inspect import getdoc @@ -200,12 +199,20 @@ def run(self, options): Usage: run [options] SERVICE COMMAND [ARGS...] Options: - -d Detached mode: Run container in the background, print new container name + -d Detached mode: Run container in the background, print new + container name + -T Disable pseudo-tty allocation. By default `fig run` + allocates a TTY. """ service = self.project.get_service(options['SERVICE']) + + tty = True + if options['-d'] or options['-T'] or not sys.stdin.isatty(): + tty = False + container_options = { 'command': [options['COMMAND']] + options['ARGS'], - 'tty': not options['-d'], + 'tty': tty, 'stdin_open': not options['-d'], } container = service.create_container(one_off=True, **container_options) @@ -217,7 +224,7 @@ def run(self, options): container.id, interactive=True, logs=True, - raw=True + raw=tty ) as c: service.start_container(container, ports=None) c.run() From 405079f744fb6901a97a1910218ee008a03607de Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 20 Jan 2014 15:52:07 +0000 Subject: [PATCH 0216/4072] Use raw socket in 'fig run', simplify _attach_to_container --- fig/cli/main.py | 41 ++++++++--------------------------------- fig/cli/socketclient.py | 14 +++++++------- 2 files changed, 15 insertions(+), 40 deletions(-) diff --git a/fig/cli/main.py b/fig/cli/main.py index 1696a7c98ed..66d2bdf2897 100644 --- a/fig/cli/main.py +++ b/fig/cli/main.py @@ -220,12 +220,7 @@ def run(self, options): service.start_container(container, ports=None) print(container.name) else: - with self._attach_to_container( - container.id, - interactive=True, - logs=True, - raw=tty - ) as c: + with self._attach_to_container(container.id, raw=tty) as c: service.start_container(container, ports=None) c.run() @@ -317,35 +312,15 @@ def handler(signal, frame): print("Gracefully stopping... (press Ctrl+C again to force)") self.project.stop(service_names=options['SERVICE']) - def _attach_to_container(self, container_id, interactive, logs=False, stream=True, raw=False): - stdio = self.client.attach_socket( - container_id, - params={ - 'stdin': 1 if interactive else 0, - 'stdout': 1, - 'stderr': 0, - 'logs': 1 if logs else 0, - 'stream': 1 if stream else 0 - }, - ws=True, - ) - - stderr = self.client.attach_socket( - container_id, - params={ - 'stdin': 0, - 'stdout': 0, - 'stderr': 1, - 'logs': 1 if logs else 0, - 'stream': 1 if stream else 0 - }, - ws=True, - ) + def _attach_to_container(self, container_id, raw=False): + socket_in = self.client.attach_socket(container_id, params={'stdin': 1, 'stream': 1}) + socket_out = self.client.attach_socket(container_id, params={'stdout': 1, 'logs': 1, 'stream': 1}) + socket_err = self.client.attach_socket(container_id, params={'stderr': 1, 'logs': 1, 'stream': 1}) return SocketClient( - socket_in=stdio, - socket_out=stdio, - socket_err=stderr, + socket_in=socket_in, + socket_out=socket_out, + socket_err=socket_err, raw=raw, ) diff --git a/fig/cli/socketclient.py b/fig/cli/socketclient.py index bbae60e6de2..842c9c6a4cd 100644 --- a/fig/cli/socketclient.py +++ b/fig/cli/socketclient.py @@ -57,15 +57,15 @@ def set_blocking(self, file, blocking): def run(self): if self.socket_in is not None: - self.start_background_thread(target=self.send_ws, args=(self.socket_in, sys.stdin)) + self.start_background_thread(target=self.send, args=(self.socket_in, sys.stdin)) recv_threads = [] if self.socket_out is not None: - recv_threads.append(self.start_background_thread(target=self.recv_ws, args=(self.socket_out, sys.stdout))) + recv_threads.append(self.start_background_thread(target=self.recv, args=(self.socket_out, sys.stdout))) if self.socket_err is not None: - recv_threads.append(self.start_background_thread(target=self.recv_ws, args=(self.socket_err, sys.stderr))) + recv_threads.append(self.start_background_thread(target=self.recv, args=(self.socket_err, sys.stderr))) for t in recv_threads: t.join() @@ -76,10 +76,10 @@ def start_background_thread(self, **kwargs): thread.start() return thread - def recv_ws(self, socket, stream): + def recv(self, socket, stream): try: while True: - chunk = socket.recv() + chunk = socket.recv(4096) if chunk: stream.write(chunk) @@ -89,7 +89,7 @@ def recv_ws(self, socket, stream): except Exception as e: log.debug(e) - def send_ws(self, socket, stream): + def send(self, socket, stream): while True: r, w, e = select([stream.fileno()], [], []) @@ -97,7 +97,7 @@ def send_ws(self, socket, stream): chunk = stream.read(1) if chunk == '': - socket.send_close() + socket.close() break else: try: From 7e2d86c5109c5fc4ea337dbb16264dad3f3822a5 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Mon, 20 Jan 2014 16:10:54 +0000 Subject: [PATCH 0217/4072] Use Container.create to recreate containers self.create_container might do unexpected things. --- fig/service.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/fig/service.py b/fig/service.py index 730e3a1c91d..a60bf23f200 100644 --- a/fig/service.py +++ b/fig/service.py @@ -141,14 +141,13 @@ def recreate_container(self, container, **override_options): if container.is_running: container.stop(timeout=1) - intermediate_container_options = { - 'image': container.image, - 'command': 'echo', - 'volumes_from': container.id, - 'entrypoint': None - } - intermediate_container = self.create_container( - one_off=True, **intermediate_container_options) + intermediate_container = Container.create( + self.client, + image=container.image, + command='echo', + volumes_from=container.id, + entrypoint=None + ) intermediate_container.start() intermediate_container.wait() container.remove() From 855a9c623c7b52bcded4d741fee2ffe8abb66ee6 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Mon, 20 Jan 2014 16:47:58 +0000 Subject: [PATCH 0218/4072] Remove containers after running CLI tests --- tests/cli_test.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/cli_test.py b/tests/cli_test.py index 0d9a2f54061..197046c11e8 100644 --- a/tests/cli_test.py +++ b/tests/cli_test.py @@ -8,6 +8,10 @@ def setUp(self): self.command = TopLevelCommand() self.command.base_dir = 'tests/fixtures/simple-figfile' + def tearDown(self): + self.command.project.kill() + self.command.project.remove_stopped() + def test_help(self): self.assertRaises(SystemExit, lambda: self.command.dispatch(['-h'], None)) From 7abc4fbf3a6ca92701c019c6464d0d771dcb2611 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Mon, 20 Jan 2014 16:50:41 +0000 Subject: [PATCH 0219/4072] Improve ps CLI test --- requirements.txt | 1 + tests/cli_test.py | 7 ++++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index a4de170cb1c..ba6741dc784 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,4 @@ PyYAML==3.10 texttable==0.8.1 # docker requires six==1.3.0 six==1.3.0 +mock==1.0.1 diff --git a/tests/cli_test.py b/tests/cli_test.py index 197046c11e8..f57553c590b 100644 --- a/tests/cli_test.py +++ b/tests/cli_test.py @@ -1,6 +1,8 @@ from __future__ import unicode_literals from __future__ import absolute_import from . import unittest +from mock import patch +from six import StringIO from fig.cli.main import TopLevelCommand class CLITestCase(unittest.TestCase): @@ -15,8 +17,11 @@ def tearDown(self): def test_help(self): self.assertRaises(SystemExit, lambda: self.command.dispatch(['-h'], None)) - def test_ps(self): + @patch('sys.stdout', new_callable=StringIO) + def test_ps(self, mock_stdout): + self.command.project.get_service('simple').create_container() self.command.dispatch(['ps'], None) + self.assertIn('fig_simple_1', mock_stdout.getvalue()) def test_scale(self): project = self.command.project From 084db337a00ad10224c12ab6aebf21a3bdff8476 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 20 Jan 2014 18:09:25 +0000 Subject: [PATCH 0220/4072] Update docker-py Brought in changes from https://github.com/dotcloud/docker-py/pull/145 --- fig/packages/docker/client.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/fig/packages/docker/client.py b/fig/packages/docker/client.py index 3fc2b08463b..7bc46aacf22 100644 --- a/fig/packages/docker/client.py +++ b/fig/packages/docker/client.py @@ -152,6 +152,7 @@ def _container_config(self, image, command, hostname=None, user=None, attach_stdin = False attach_stdout = False attach_stderr = False + stdin_once = False if not detach: attach_stdout = True @@ -159,6 +160,7 @@ def _container_config(self, image, command, hostname=None, user=None, if stdin_open: attach_stdin = True + stdin_once = True return { 'Hostname': hostname, @@ -166,6 +168,7 @@ def _container_config(self, image, command, hostname=None, user=None, 'User': user, 'Tty': tty, 'OpenStdin': stdin_open, + 'StdinOnce': stdin_once, 'Memory': mem_limit, 'AttachStdin': attach_stdin, 'AttachStdout': attach_stdout, From 4646ac85b0f6aef9ab8de9dec92e12bcc1a32481 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Mon, 20 Jan 2014 18:41:04 +0000 Subject: [PATCH 0221/4072] Add PyPi badge --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 6cb640782db..bb5ebeec0fd 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,7 @@ Fig === [![Build Status](https://travis-ci.org/orchardup/fig.png?branch=master)](https://travis-ci.org/orchardup/fig) +[![PyPI version](https://badge.fury.io/py/fig.png)](http://badge.fury.io/py/fig) Punctual, lightweight development environments using Docker. From 40d04a076c84e7a59398e288ca70fa379c097a10 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 20 Jan 2014 19:22:05 +0000 Subject: [PATCH 0222/4072] Fix lag when using cursor keys in an interactive 'fig run' --- fig/cli/socketclient.py | 29 +++++++++++++---------------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/fig/cli/socketclient.py b/fig/cli/socketclient.py index 842c9c6a4cd..99333af8c42 100644 --- a/fig/cli/socketclient.py +++ b/fig/cli/socketclient.py @@ -91,22 +91,19 @@ def recv(self, socket, stream): def send(self, socket, stream): while True: - r, w, e = select([stream.fileno()], [], []) - - if r: - chunk = stream.read(1) - - if chunk == '': - socket.close() - break - else: - try: - socket.send(chunk) - except Exception as e: - if hasattr(e, 'errno') and e.errno == errno.EPIPE: - break - else: - raise e + chunk = stream.read(1) + + if chunk == '': + socket.close() + break + else: + try: + socket.send(chunk) + except Exception as e: + if hasattr(e, 'errno') and e.errno == errno.EPIPE: + break + else: + raise e def destroy(self): if self.settings is not None: From 977ec7c94156b08d01ff9259b6b2a262f33bba73 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 20 Jan 2014 19:25:28 +0000 Subject: [PATCH 0223/4072] Remove unused import --- fig/cli/socketclient.py | 1 - 1 file changed, 1 deletion(-) diff --git a/fig/cli/socketclient.py b/fig/cli/socketclient.py index 99333af8c42..b0bb087b69b 100644 --- a/fig/cli/socketclient.py +++ b/fig/cli/socketclient.py @@ -1,7 +1,6 @@ from __future__ import print_function # Adapted from https://github.com/benthor/remotty/blob/master/socketclient.py -from select import select import sys import tty import fcntl From 65071aafb0acad0603b5926e9b3d25d685252519 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 21 Jan 2014 17:58:04 +0000 Subject: [PATCH 0224/4072] Make sure attach() is called as soon as LogPrinter is initialized Fixes #35. --- fig/cli/log_printer.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/fig/cli/log_printer.py b/fig/cli/log_printer.py index f20ad88d1ea..a3ad15eb3b9 100644 --- a/fig/cli/log_printer.py +++ b/fig/cli/log_printer.py @@ -31,8 +31,9 @@ def _make_log_generators(self): def _make_log_generator(self, container, color_fn): prefix = color_fn(container.name + " | ") - for line in split_buffer(self._attach(container), '\n'): - yield prefix + line + # Attach to container before log printer starts running + line_generator = split_buffer(self._attach(container), '\n') + return (prefix + line for line in line_generator) def _attach(self, container): params = { From deb7f3c5b602d49e36e18bba120ad554de9b44f2 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Wed, 22 Jan 2014 13:37:00 +0000 Subject: [PATCH 0225/4072] Bump to version 0.1.2 --- CHANGES.md | 7 +++++++ fig/__init__.py | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index f94c0205f56..941a9e95e8a 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,6 +1,13 @@ Change log ========== +0.1.2 (2014-01-22) +------------------ + + - Add `-T` option to `fig run` to disable pseudo-TTY. (#34) + - Fix `fig up` requiring the ubuntu image to be pulled to recreate containers. (#33) Thanks @cameronmaske! + - Improve reliability, fix arrow keys and fix a race condition in `fig run`. (#34, #39, #40) + 0.1.1 (2014-01-17) ------------------ diff --git a/fig/__init__.py b/fig/__init__.py index 415b4a3137c..ca028a4ef93 100644 --- a/fig/__init__.py +++ b/fig/__init__.py @@ -1,4 +1,4 @@ from __future__ import unicode_literals from .service import Service -__version__ = '0.1.1' +__version__ = '0.1.2' From 64513e8d6f55223eb6555e2f4e81fb39d12050ec Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Sun, 19 Jan 2014 20:39:21 +0000 Subject: [PATCH 0226/4072] Verbose nosetests --- script/travis | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/travis b/script/travis index 69bc3043b26..878b86e04fa 100755 --- a/script/travis +++ b/script/travis @@ -20,4 +20,4 @@ docker -d -H unix:///var/run/docker.sock 2>> /dev/null >> /dev/null & sleep 2 # $init is set by sekexe -cd $(dirname $init)/.. && nosetests +cd $(dirname $init)/.. && nosetests -v From 18525554ede6aaa26a6a97ad8cb9053882a7feb8 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Wed, 22 Jan 2014 14:15:17 +0000 Subject: [PATCH 0227/4072] Add license to setup.py --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index f0ffceb74ab..da520a1c373 100644 --- a/setup.py +++ b/setup.py @@ -35,6 +35,7 @@ def find_version(*file_paths): url='https://github.com/orchardup/fig', author='Orchard Laboratories Ltd.', author_email='hello@orchardup.com', + license='BSD', packages=find_packages(), include_package_data=True, test_suite='nose.collector', From ae67d55bf2ff59946d4d41e2fe9069851055689f Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 22 Jan 2014 16:52:42 +0000 Subject: [PATCH 0228/4072] Fix bug where too many '/tcp' suffixes were added to port config --- fig/service.py | 2 -- tests/service_test.py | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/fig/service.py b/fig/service.py index a60bf23f200..adaf940ec94 100644 --- a/fig/service.py +++ b/fig/service.py @@ -225,8 +225,6 @@ def _get_container_options(self, override_options, one_off=False): port = str(port) if ':' in port: port = port.split(':')[-1] - if '/' not in port: - port = "%s/tcp" % port ports.append(port) container_options['ports'] = ports diff --git a/tests/service_test.py b/tests/service_test.py index e4183a02bbe..dcbc4627dae 100644 --- a/tests/service_test.py +++ b/tests/service_test.py @@ -184,7 +184,7 @@ def test_start_container_uses_tagged_image_if_it_exists(self): def test_start_container_creates_ports(self): service = self.create_service('web', ports=[8000]) container = service.start_container().inspect() - self.assertIn('8000/tcp', container['HostConfig']['PortBindings']) + self.assertEqual(container['HostConfig']['PortBindings'].keys(), ['8000/tcp']) self.assertNotEqual(container['HostConfig']['PortBindings']['8000/tcp'][0]['HostPort'], '8000') def test_start_container_creates_fixed_external_ports(self): From df9f66d437fb2b4e21595f312f8f63e6cc63b3d7 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 22 Jan 2014 17:01:10 +0000 Subject: [PATCH 0229/4072] Allow ports to be specified in '1234/tcp' format --- fig/service.py | 7 +++++-- tests/service_test.py | 5 +++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/fig/service.py b/fig/service.py index adaf940ec94..aaaa97ab6b8 100644 --- a/fig/service.py +++ b/fig/service.py @@ -172,9 +172,10 @@ def start_container(self, container=None, **override_options): port = str(port) if ':' in port: external_port, internal_port = port.split(':', 1) - port_bindings[int(internal_port)] = int(external_port) else: - port_bindings[int(port)] = None + external_port, internal_port = (None, port) + + port_bindings[internal_port] = external_port volume_bindings = {} @@ -225,6 +226,8 @@ def _get_container_options(self, override_options, one_off=False): port = str(port) if ':' in port: port = port.split(':')[-1] + if '/' in port: + port = tuple(port.split('/')) ports.append(port) container_options['ports'] = ports diff --git a/tests/service_test.py b/tests/service_test.py index dcbc4627dae..ca9a0021ce0 100644 --- a/tests/service_test.py +++ b/tests/service_test.py @@ -187,6 +187,11 @@ def test_start_container_creates_ports(self): self.assertEqual(container['HostConfig']['PortBindings'].keys(), ['8000/tcp']) self.assertNotEqual(container['HostConfig']['PortBindings']['8000/tcp'][0]['HostPort'], '8000') + def test_start_container_creates_port_with_explicit_protocol(self): + service = self.create_service('web', ports=['8000/udp']) + container = service.start_container().inspect() + self.assertEqual(container['HostConfig']['PortBindings'].keys(), ['8000/udp']) + def test_start_container_creates_fixed_external_ports(self): service = self.create_service('web', ports=['8000:8000']) container = service.start_container().inspect() From e8472be6d5b21246303f2a78eb1cdfeb62ea202a Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 22 Jan 2014 17:44:04 +0000 Subject: [PATCH 0230/4072] Fig bug in split_buffer where input was being discarded Also, write some tests for it. --- fig/cli/log_printer.py | 21 ++------------------- fig/cli/utils.py | 25 +++++++++++++++++++++++++ tests/split_buffer_test.py | 37 +++++++++++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 19 deletions(-) create mode 100644 tests/split_buffer_test.py diff --git a/fig/cli/log_printer.py b/fig/cli/log_printer.py index a3ad15eb3b9..0fe3215e6cf 100644 --- a/fig/cli/log_printer.py +++ b/fig/cli/log_printer.py @@ -6,6 +6,7 @@ from .multiplexer import Multiplexer from . import colors +from .utils import split_buffer class LogPrinter(object): @@ -33,7 +34,7 @@ def _make_log_generator(self, container, color_fn): prefix = color_fn(container.name + " | ") # Attach to container before log printer starts running line_generator = split_buffer(self._attach(container), '\n') - return (prefix + line for line in line_generator) + return (prefix + line.decode('utf-8') for line in line_generator) def _attach(self, container): params = { @@ -44,21 +45,3 @@ def _attach(self, container): params.update(self.attach_params) params = dict((name, 1 if value else 0) for (name, value) in list(params.items())) return container.attach(**params) - -def split_buffer(reader, separator): - """ - Given a generator which yields strings and a separator string, - joins all input, splits on the separator and yields each chunk. - Requires that each input string is decodable as UTF-8. - """ - buffered = '' - - for data in reader: - lines = (buffered + data.decode('utf-8')).split(separator) - for line in lines[:-1]: - yield line + separator - if len(lines) > 1: - buffered = lines[-1] - - if len(buffered) > 0: - yield buffered diff --git a/fig/cli/utils.py b/fig/cli/utils.py index 2b0eb42d6e0..3116df98bd8 100644 --- a/fig/cli/utils.py +++ b/fig/cli/utils.py @@ -83,3 +83,28 @@ def mkdir(path, permissions=0o700): def docker_url(): return os.environ.get('DOCKER_HOST') + + +def split_buffer(reader, separator): + """ + Given a generator which yields strings and a separator string, + joins all input, splits on the separator and yields each chunk. + + Unlike string.split(), each chunk includes the trailing + separator, except for the last one if none was found on the end + of the input. + """ + buffered = str('') + separator = str(separator) + + for data in reader: + buffered += data + while True: + index = buffered.find(separator) + if index == -1: + break + yield buffered[:index+1] + buffered = buffered[index+1:] + + if len(buffered) > 0: + yield buffered diff --git a/tests/split_buffer_test.py b/tests/split_buffer_test.py new file mode 100644 index 00000000000..b90463c07c0 --- /dev/null +++ b/tests/split_buffer_test.py @@ -0,0 +1,37 @@ +from __future__ import unicode_literals +from __future__ import absolute_import +from fig.cli.utils import split_buffer +from . import unittest + +class SplitBufferTest(unittest.TestCase): + def test_single_line_chunks(self): + def reader(): + yield "abc\n" + yield "def\n" + yield "ghi\n" + + self.assertEqual(list(split_buffer(reader(), '\n')), ["abc\n", "def\n", "ghi\n"]) + + def test_no_end_separator(self): + def reader(): + yield "abc\n" + yield "def\n" + yield "ghi" + + self.assertEqual(list(split_buffer(reader(), '\n')), ["abc\n", "def\n", "ghi"]) + + def test_multiple_line_chunk(self): + def reader(): + yield "abc\ndef\nghi" + + self.assertEqual(list(split_buffer(reader(), '\n')), ["abc\n", "def\n", "ghi"]) + + def test_chunked_line(self): + def reader(): + yield "a" + yield "b" + yield "c" + yield "\n" + yield "d" + + self.assertEqual(list(split_buffer(reader(), '\n')), ["abc\n", "d"]) From 33aada05a47358e260588b8d998963af0a8f748d Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Thu, 23 Jan 2014 11:58:48 +0000 Subject: [PATCH 0231/4072] Bump version to 0.1.3 --- CHANGES.md | 6 ++++++ fig/__init__.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index 941a9e95e8a..c68184b03a4 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,6 +1,12 @@ Change log ========== +0.1.3 (2014-01-23) +------------------ + + - Fix ports sometimes being configured incorrectly. (#46) + - Fix log output sometimes not displaying. (#47) + 0.1.2 (2014-01-22) ------------------ diff --git a/fig/__init__.py b/fig/__init__.py index ca028a4ef93..35b931c99b6 100644 --- a/fig/__init__.py +++ b/fig/__init__.py @@ -1,4 +1,4 @@ from __future__ import unicode_literals from .service import Service -__version__ = '0.1.2' +__version__ = '0.1.3' From 2ebec048119ec84064f9d1b5851a84f6c46d1439 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 23 Jan 2014 12:14:12 +0000 Subject: [PATCH 0232/4072] Hide build/PyPi badges on homepage --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index bb5ebeec0fd..e905658390b 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,10 @@ Fig === + [![Build Status](https://travis-ci.org/orchardup/fig.png?branch=master)](https://travis-ci.org/orchardup/fig) [![PyPI version](https://badge.fury.io/py/fig.png)](http://badge.fury.io/py/fig) + Punctual, lightweight development environments using Docker. From cf18a3141f6b9d618cd35adc2f574965fba29c92 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Sun, 26 Jan 2014 19:37:35 +0000 Subject: [PATCH 0233/4072] Remove images created by tests --- tests/testcases.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/testcases.py b/tests/testcases.py index 8cc1ab35467..7f602bb553f 100644 --- a/tests/testcases.py +++ b/tests/testcases.py @@ -17,6 +17,9 @@ def setUp(self): if c['Names'] and 'figtest' in c['Names'][0]: self.client.kill(c['Id']) self.client.remove_container(c['Id']) + for i in self.client.images(): + if 'figtest' in i['Tag']: + self.client.remove_image(i) def create_service(self, name, **kwargs): return Service( From ea93c01dfb0c8de5945dad49440dc49282210f09 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Sun, 26 Jan 2014 19:47:43 +0000 Subject: [PATCH 0234/4072] Remove intermediate containers in recreate test --- tests/project_test.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/project_test.py b/tests/project_test.py index a73aa25276d..13afe533b48 100644 --- a/tests/project_test.py +++ b/tests/project_test.py @@ -61,6 +61,10 @@ def test_recreate_containers(self): self.assertEqual(len(web.containers(stopped=True)), 1) self.assertEqual(len(db.containers(stopped=True)), 1) + # remove intermediate containers + for (service, container) in old: + container.remove() + def test_start_stop_kill_remove(self): web = self.create_service('web') db = self.create_service('db') From ac90e0e9398be7447d6a300c3c77c7dba295df48 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Sun, 26 Jan 2014 19:54:00 +0000 Subject: [PATCH 0235/4072] Remove Travis badge Travis is running out of disk space. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e905658390b..b00160c417c 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Fig === -[![Build Status](https://travis-ci.org/orchardup/fig.png?branch=master)](https://travis-ci.org/orchardup/fig) + [![PyPI version](https://badge.fury.io/py/fig.png)](http://badge.fury.io/py/fig) From ee49e7055b836f05f87d979528d20c0078653237 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Sun, 26 Jan 2014 20:28:37 +0000 Subject: [PATCH 0236/4072] Pull ubuntu image for CLI tests --- tests/cli_test.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/cli_test.py b/tests/cli_test.py index f57553c590b..f6d37268101 100644 --- a/tests/cli_test.py +++ b/tests/cli_test.py @@ -1,12 +1,13 @@ from __future__ import unicode_literals from __future__ import absolute_import -from . import unittest +from .testcases import DockerClientTestCase from mock import patch from six import StringIO from fig.cli.main import TopLevelCommand -class CLITestCase(unittest.TestCase): +class CLITestCase(DockerClientTestCase): def setUp(self): + super(CLITestCase, self).setUp() self.command = TopLevelCommand() self.command.base_dir = 'tests/fixtures/simple-figfile' From ddf6819a75f4e39352b4a85f212b301f2f46243d Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Sun, 26 Jan 2014 20:37:30 +0000 Subject: [PATCH 0237/4072] Only pull ubuntu:latest in tests Might stop Travis running out of disk space. --- tests/testcases.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/testcases.py b/tests/testcases.py index 7f602bb553f..13c24a9287d 100644 --- a/tests/testcases.py +++ b/tests/testcases.py @@ -10,7 +10,7 @@ class DockerClientTestCase(unittest.TestCase): @classmethod def setUpClass(cls): cls.client = Client(docker_url()) - cls.client.pull('ubuntu') + cls.client.pull('ubuntu', tag='latest') def setUp(self): for c in self.client.containers(all=True): From 8f8b0bbd16c58ad7305002fdf367d496cb2c4543 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Sun, 26 Jan 2014 21:29:59 +0000 Subject: [PATCH 0238/4072] Revert "Remove Travis badge" This reverts commit ac90e0e9398be7447d6a300c3c77c7dba295df48. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b00160c417c..e905658390b 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Fig === - +[![Build Status](https://travis-ci.org/orchardup/fig.png?branch=master)](https://travis-ci.org/orchardup/fig) [![PyPI version](https://badge.fury.io/py/fig.png)](http://badge.fury.io/py/fig) From 7c9c55785da9c7181743357a3ff719d6388c74d4 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Mon, 27 Jan 2014 10:00:36 +0000 Subject: [PATCH 0239/4072] Move mock to dev requirements --- requirements-dev.txt | 1 + requirements.txt | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 4ef6576c491..5fd088d49ea 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,2 +1,3 @@ +mock==1.0.1 nose==1.3.0 unittest2==0.5.1 diff --git a/requirements.txt b/requirements.txt index ba6741dc784..a4de170cb1c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,4 +5,3 @@ PyYAML==3.10 texttable==0.8.1 # docker requires six==1.3.0 six==1.3.0 -mock==1.0.1 From f60621ee1bbed066bb36fb3e78f7b9ed32bc3c4c Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 27 Jan 2014 11:42:38 +0000 Subject: [PATCH 0240/4072] Move docs to master branch - build with script/build-docs - deploy with script/deploy-docs --- .gitignore | 3 +- docs/.gitignore-gh-pages | 1 + docs/Dockerfile | 10 ++ docs/Gemfile | 3 + docs/Gemfile.lock | 62 ++++++++ docs/_config.yml | 1 + docs/_layouts/default.html | 43 ++++++ docs/css/bootstrap.min.css | 7 + docs/css/fig.css | 124 ++++++++++++++++ docs/fig.yml | 8 + docs/img/logo.png | Bin 0 -> 133640 bytes docs/index.md | 293 +++++++++++++++++++++++++++++++++++++ script/build-docs | 5 + script/deploy-docs | 29 ++++ 14 files changed, 588 insertions(+), 1 deletion(-) create mode 100644 docs/.gitignore-gh-pages create mode 100644 docs/Dockerfile create mode 100644 docs/Gemfile create mode 100644 docs/Gemfile.lock create mode 100644 docs/_config.yml create mode 100644 docs/_layouts/default.html create mode 100644 docs/css/bootstrap.min.css create mode 100644 docs/css/fig.css create mode 100644 docs/fig.yml create mode 100644 docs/img/logo.png create mode 100644 docs/index.md create mode 100755 script/build-docs create mode 100755 script/deploy-docs diff --git a/.gitignore b/.gitignore index c6e51dbc14a..5aad33fecea 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ *.egg-info *.pyc /dist -/_site +/docs/_site +/docs/.git-gh-pages diff --git a/docs/.gitignore-gh-pages b/docs/.gitignore-gh-pages new file mode 100644 index 00000000000..0baf015229c --- /dev/null +++ b/docs/.gitignore-gh-pages @@ -0,0 +1 @@ +/_site diff --git a/docs/Dockerfile b/docs/Dockerfile new file mode 100644 index 00000000000..3102b418b2b --- /dev/null +++ b/docs/Dockerfile @@ -0,0 +1,10 @@ +FROM stackbrew/ubuntu:13.10 +RUN apt-get -qq update && apt-get install -y ruby1.8 bundler python +RUN locale-gen en_US.UTF-8 +ADD Gemfile /code/ +ADD Gemfile.lock /code/ +WORKDIR /code +RUN bundle install +ADD . /code +EXPOSE 4000 +CMD bundle exec jekyll build diff --git a/docs/Gemfile b/docs/Gemfile new file mode 100644 index 00000000000..97355ea723a --- /dev/null +++ b/docs/Gemfile @@ -0,0 +1,3 @@ +source 'https://rubygems.org' + +gem 'github-pages' diff --git a/docs/Gemfile.lock b/docs/Gemfile.lock new file mode 100644 index 00000000000..447ae05cc84 --- /dev/null +++ b/docs/Gemfile.lock @@ -0,0 +1,62 @@ +GEM + remote: https://rubygems.org/ + specs: + RedCloth (4.2.9) + blankslate (2.1.2.4) + classifier (1.3.3) + fast-stemmer (>= 1.0.0) + colorator (0.1) + commander (4.1.5) + highline (~> 1.6.11) + fast-stemmer (1.0.2) + ffi (1.9.3) + github-pages (12) + RedCloth (= 4.2.9) + jekyll (= 1.4.2) + kramdown (= 1.2.0) + liquid (= 2.5.4) + maruku (= 0.7.0) + rdiscount (= 2.1.7) + redcarpet (= 2.3.0) + highline (1.6.20) + jekyll (1.4.2) + classifier (~> 1.3) + colorator (~> 0.1) + commander (~> 4.1.3) + liquid (~> 2.5.2) + listen (~> 1.3) + maruku (~> 0.7.0) + pygments.rb (~> 0.5.0) + redcarpet (~> 2.3.0) + safe_yaml (~> 0.9.7) + toml (~> 0.1.0) + kramdown (1.2.0) + liquid (2.5.4) + listen (1.3.1) + rb-fsevent (>= 0.9.3) + rb-inotify (>= 0.9) + rb-kqueue (>= 0.2) + maruku (0.7.0) + parslet (1.5.0) + blankslate (~> 2.0) + posix-spawn (0.3.8) + pygments.rb (0.5.4) + posix-spawn (~> 0.3.6) + yajl-ruby (~> 1.1.0) + rb-fsevent (0.9.4) + rb-inotify (0.9.3) + ffi (>= 0.5.0) + rb-kqueue (0.2.0) + ffi (>= 0.5.0) + rdiscount (2.1.7) + redcarpet (2.3.0) + safe_yaml (0.9.7) + toml (0.1.0) + parslet (~> 1.5.0) + yajl-ruby (1.1.0) + +PLATFORMS + ruby + +DEPENDENCIES + github-pages diff --git a/docs/_config.yml b/docs/_config.yml new file mode 100644 index 00000000000..956da86d053 --- /dev/null +++ b/docs/_config.yml @@ -0,0 +1 @@ +markdown: redcarpet diff --git a/docs/_layouts/default.html b/docs/_layouts/default.html new file mode 100644 index 00000000000..796d0d4ed72 --- /dev/null +++ b/docs/_layouts/default.html @@ -0,0 +1,43 @@ + + + + + {{ page.title }} + + + + + + + + +
+

+ + Fig +

+ + + + +
{{ content }}
+
+ + + + + + diff --git a/docs/css/bootstrap.min.css b/docs/css/bootstrap.min.css new file mode 100644 index 00000000000..c547283bbda --- /dev/null +++ b/docs/css/bootstrap.min.css @@ -0,0 +1,7 @@ +/*! + * Bootstrap v3.0.3 (http://getbootstrap.com) + * Copyright 2013 Twitter, Inc. + * Licensed under http://www.apache.org/licenses/LICENSE-2.0 + */ + +/*! normalize.css v2.1.3 | MIT License | git.io/normalize */article,aside,details,figcaption,figure,footer,header,hgroup,main,nav,section,summary{display:block}audio,canvas,video{display:inline-block}audio:not([controls]){display:none;height:0}[hidden],template{display:none}html{font-family:sans-serif;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%}body{margin:0}a{background:transparent}a:focus{outline:thin dotted}a:active,a:hover{outline:0}h1{margin:.67em 0;font-size:2em}abbr[title]{border-bottom:1px dotted}b,strong{font-weight:bold}dfn{font-style:italic}hr{height:0;-moz-box-sizing:content-box;box-sizing:content-box}mark{color:#000;background:#ff0}code,kbd,pre,samp{font-family:monospace,serif;font-size:1em}pre{white-space:pre-wrap}q{quotes:"\201C" "\201D" "\2018" "\2019"}small{font-size:80%}sub,sup{position:relative;font-size:75%;line-height:0;vertical-align:baseline}sup{top:-0.5em}sub{bottom:-0.25em}img{border:0}svg:not(:root){overflow:hidden}figure{margin:0}fieldset{padding:.35em .625em .75em;margin:0 2px;border:1px solid #c0c0c0}legend{padding:0;border:0}button,input,select,textarea{margin:0;font-family:inherit;font-size:100%}button,input{line-height:normal}button,select{text-transform:none}button,html input[type="button"],input[type="reset"],input[type="submit"]{cursor:pointer;-webkit-appearance:button}button[disabled],html input[disabled]{cursor:default}input[type="checkbox"],input[type="radio"]{padding:0;box-sizing:border-box}input[type="search"]{-webkit-box-sizing:content-box;-moz-box-sizing:content-box;box-sizing:content-box;-webkit-appearance:textfield}input[type="search"]::-webkit-search-cancel-button,input[type="search"]::-webkit-search-decoration{-webkit-appearance:none}button::-moz-focus-inner,input::-moz-focus-inner{padding:0;border:0}textarea{overflow:auto;vertical-align:top}table{border-collapse:collapse;border-spacing:0}@media print{*{color:#000!important;text-shadow:none!important;background:transparent!important;box-shadow:none!important}a,a:visited{text-decoration:underline}a[href]:after{content:" (" attr(href) ")"}abbr[title]:after{content:" (" attr(title) ")"}a[href^="javascript:"]:after,a[href^="#"]:after{content:""}pre,blockquote{border:1px solid #999;page-break-inside:avoid}thead{display:table-header-group}tr,img{page-break-inside:avoid}img{max-width:100%!important}@page{margin:2cm .5cm}p,h2,h3{orphans:3;widows:3}h2,h3{page-break-after:avoid}select{background:#fff!important}.navbar{display:none}.table td,.table th{background-color:#fff!important}.btn>.caret,.dropup>.btn>.caret{border-top-color:#000!important}.label{border:1px solid #000}.table{border-collapse:collapse!important}.table-bordered th,.table-bordered td{border:1px solid #ddd!important}}*,*:before,*:after{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}html{font-size:62.5%;-webkit-tap-highlight-color:rgba(0,0,0,0)}body{font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-size:14px;line-height:1.428571429;color:#333;background-color:#fff}input,button,select,textarea{font-family:inherit;font-size:inherit;line-height:inherit}a{color:#428bca;text-decoration:none}a:hover,a:focus{color:#2a6496;text-decoration:underline}a:focus{outline:thin dotted;outline:5px auto -webkit-focus-ring-color;outline-offset:-2px}img{vertical-align:middle}.img-responsive{display:block;height:auto;max-width:100%}.img-rounded{border-radius:6px}.img-thumbnail{display:inline-block;height:auto;max-width:100%;padding:4px;line-height:1.428571429;background-color:#fff;border:1px solid #ddd;border-radius:4px;-webkit-transition:all .2s ease-in-out;transition:all .2s ease-in-out}.img-circle{border-radius:50%}hr{margin-top:20px;margin-bottom:20px;border:0;border-top:1px solid #eee}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);border:0}h1,h2,h3,h4,h5,h6,.h1,.h2,.h3,.h4,.h5,.h6{font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-weight:500;line-height:1.1;color:inherit}h1 small,h2 small,h3 small,h4 small,h5 small,h6 small,.h1 small,.h2 small,.h3 small,.h4 small,.h5 small,.h6 small,h1 .small,h2 .small,h3 .small,h4 .small,h5 .small,h6 .small,.h1 .small,.h2 .small,.h3 .small,.h4 .small,.h5 .small,.h6 .small{font-weight:normal;line-height:1;color:#999}h1,h2,h3{margin-top:20px;margin-bottom:10px}h1 small,h2 small,h3 small,h1 .small,h2 .small,h3 .small{font-size:65%}h4,h5,h6{margin-top:10px;margin-bottom:10px}h4 small,h5 small,h6 small,h4 .small,h5 .small,h6 .small{font-size:75%}h1,.h1{font-size:36px}h2,.h2{font-size:30px}h3,.h3{font-size:24px}h4,.h4{font-size:18px}h5,.h5{font-size:14px}h6,.h6{font-size:12px}p{margin:0 0 10px}.lead{margin-bottom:20px;font-size:16px;font-weight:200;line-height:1.4}@media(min-width:768px){.lead{font-size:21px}}small,.small{font-size:85%}cite{font-style:normal}.text-muted{color:#999}.text-primary{color:#428bca}.text-primary:hover{color:#3071a9}.text-warning{color:#8a6d3b}.text-warning:hover{color:#66512c}.text-danger{color:#a94442}.text-danger:hover{color:#843534}.text-success{color:#3c763d}.text-success:hover{color:#2b542c}.text-info{color:#31708f}.text-info:hover{color:#245269}.text-left{text-align:left}.text-right{text-align:right}.text-center{text-align:center}.page-header{padding-bottom:9px;margin:40px 0 20px;border-bottom:1px solid #eee}ul,ol{margin-top:0;margin-bottom:10px}ul ul,ol ul,ul ol,ol ol{margin-bottom:0}.list-unstyled{padding-left:0;list-style:none}.list-inline{padding-left:0;list-style:none}.list-inline>li{display:inline-block;padding-right:5px;padding-left:5px}.list-inline>li:first-child{padding-left:0}dl{margin-top:0;margin-bottom:20px}dt,dd{line-height:1.428571429}dt{font-weight:bold}dd{margin-left:0}@media(min-width:768px){.dl-horizontal dt{float:left;width:160px;overflow:hidden;clear:left;text-align:right;text-overflow:ellipsis;white-space:nowrap}.dl-horizontal dd{margin-left:180px}.dl-horizontal dd:before,.dl-horizontal dd:after{display:table;content:" "}.dl-horizontal dd:after{clear:both}.dl-horizontal dd:before,.dl-horizontal dd:after{display:table;content:" "}.dl-horizontal dd:after{clear:both}}abbr[title],abbr[data-original-title]{cursor:help;border-bottom:1px dotted #999}.initialism{font-size:90%;text-transform:uppercase}blockquote{padding:10px 20px;margin:0 0 20px;border-left:5px solid #eee}blockquote p{font-size:17.5px;font-weight:300;line-height:1.25}blockquote p:last-child{margin-bottom:0}blockquote small,blockquote .small{display:block;line-height:1.428571429;color:#999}blockquote small:before,blockquote .small:before{content:'\2014 \00A0'}blockquote.pull-right{padding-right:15px;padding-left:0;border-right:5px solid #eee;border-left:0}blockquote.pull-right p,blockquote.pull-right small,blockquote.pull-right .small{text-align:right}blockquote.pull-right small:before,blockquote.pull-right .small:before{content:''}blockquote.pull-right small:after,blockquote.pull-right .small:after{content:'\00A0 \2014'}blockquote:before,blockquote:after{content:""}address{margin-bottom:20px;font-style:normal;line-height:1.428571429}code,kbd,pre,samp{font-family:Menlo,Monaco,Consolas,"Courier New",monospace}code{padding:2px 4px;font-size:90%;color:#c7254e;white-space:nowrap;background-color:#f9f2f4;border-radius:4px}pre{display:block;padding:9.5px;margin:0 0 10px;font-size:13px;line-height:1.428571429;color:#333;word-break:break-all;word-wrap:break-word;background-color:#f5f5f5;border:1px solid #ccc;border-radius:4px}pre code{padding:0;font-size:inherit;color:inherit;white-space:pre-wrap;background-color:transparent;border-radius:0}.pre-scrollable{max-height:340px;overflow-y:scroll}.container{padding-right:15px;padding-left:15px;margin-right:auto;margin-left:auto}.container:before,.container:after{display:table;content:" "}.container:after{clear:both}.container:before,.container:after{display:table;content:" "}.container:after{clear:both}@media(min-width:768px){.container{width:750px}}@media(min-width:992px){.container{width:970px}}@media(min-width:1200px){.container{width:1170px}}.row{margin-right:-15px;margin-left:-15px}.row:before,.row:after{display:table;content:" "}.row:after{clear:both}.row:before,.row:after{display:table;content:" "}.row:after{clear:both}.col-xs-1,.col-sm-1,.col-md-1,.col-lg-1,.col-xs-2,.col-sm-2,.col-md-2,.col-lg-2,.col-xs-3,.col-sm-3,.col-md-3,.col-lg-3,.col-xs-4,.col-sm-4,.col-md-4,.col-lg-4,.col-xs-5,.col-sm-5,.col-md-5,.col-lg-5,.col-xs-6,.col-sm-6,.col-md-6,.col-lg-6,.col-xs-7,.col-sm-7,.col-md-7,.col-lg-7,.col-xs-8,.col-sm-8,.col-md-8,.col-lg-8,.col-xs-9,.col-sm-9,.col-md-9,.col-lg-9,.col-xs-10,.col-sm-10,.col-md-10,.col-lg-10,.col-xs-11,.col-sm-11,.col-md-11,.col-lg-11,.col-xs-12,.col-sm-12,.col-md-12,.col-lg-12{position:relative;min-height:1px;padding-right:15px;padding-left:15px}.col-xs-1,.col-xs-2,.col-xs-3,.col-xs-4,.col-xs-5,.col-xs-6,.col-xs-7,.col-xs-8,.col-xs-9,.col-xs-10,.col-xs-11,.col-xs-12{float:left}.col-xs-12{width:100%}.col-xs-11{width:91.66666666666666%}.col-xs-10{width:83.33333333333334%}.col-xs-9{width:75%}.col-xs-8{width:66.66666666666666%}.col-xs-7{width:58.333333333333336%}.col-xs-6{width:50%}.col-xs-5{width:41.66666666666667%}.col-xs-4{width:33.33333333333333%}.col-xs-3{width:25%}.col-xs-2{width:16.666666666666664%}.col-xs-1{width:8.333333333333332%}.col-xs-pull-12{right:100%}.col-xs-pull-11{right:91.66666666666666%}.col-xs-pull-10{right:83.33333333333334%}.col-xs-pull-9{right:75%}.col-xs-pull-8{right:66.66666666666666%}.col-xs-pull-7{right:58.333333333333336%}.col-xs-pull-6{right:50%}.col-xs-pull-5{right:41.66666666666667%}.col-xs-pull-4{right:33.33333333333333%}.col-xs-pull-3{right:25%}.col-xs-pull-2{right:16.666666666666664%}.col-xs-pull-1{right:8.333333333333332%}.col-xs-pull-0{right:0}.col-xs-push-12{left:100%}.col-xs-push-11{left:91.66666666666666%}.col-xs-push-10{left:83.33333333333334%}.col-xs-push-9{left:75%}.col-xs-push-8{left:66.66666666666666%}.col-xs-push-7{left:58.333333333333336%}.col-xs-push-6{left:50%}.col-xs-push-5{left:41.66666666666667%}.col-xs-push-4{left:33.33333333333333%}.col-xs-push-3{left:25%}.col-xs-push-2{left:16.666666666666664%}.col-xs-push-1{left:8.333333333333332%}.col-xs-push-0{left:0}.col-xs-offset-12{margin-left:100%}.col-xs-offset-11{margin-left:91.66666666666666%}.col-xs-offset-10{margin-left:83.33333333333334%}.col-xs-offset-9{margin-left:75%}.col-xs-offset-8{margin-left:66.66666666666666%}.col-xs-offset-7{margin-left:58.333333333333336%}.col-xs-offset-6{margin-left:50%}.col-xs-offset-5{margin-left:41.66666666666667%}.col-xs-offset-4{margin-left:33.33333333333333%}.col-xs-offset-3{margin-left:25%}.col-xs-offset-2{margin-left:16.666666666666664%}.col-xs-offset-1{margin-left:8.333333333333332%}.col-xs-offset-0{margin-left:0}@media(min-width:768px){.col-sm-1,.col-sm-2,.col-sm-3,.col-sm-4,.col-sm-5,.col-sm-6,.col-sm-7,.col-sm-8,.col-sm-9,.col-sm-10,.col-sm-11,.col-sm-12{float:left}.col-sm-12{width:100%}.col-sm-11{width:91.66666666666666%}.col-sm-10{width:83.33333333333334%}.col-sm-9{width:75%}.col-sm-8{width:66.66666666666666%}.col-sm-7{width:58.333333333333336%}.col-sm-6{width:50%}.col-sm-5{width:41.66666666666667%}.col-sm-4{width:33.33333333333333%}.col-sm-3{width:25%}.col-sm-2{width:16.666666666666664%}.col-sm-1{width:8.333333333333332%}.col-sm-pull-12{right:100%}.col-sm-pull-11{right:91.66666666666666%}.col-sm-pull-10{right:83.33333333333334%}.col-sm-pull-9{right:75%}.col-sm-pull-8{right:66.66666666666666%}.col-sm-pull-7{right:58.333333333333336%}.col-sm-pull-6{right:50%}.col-sm-pull-5{right:41.66666666666667%}.col-sm-pull-4{right:33.33333333333333%}.col-sm-pull-3{right:25%}.col-sm-pull-2{right:16.666666666666664%}.col-sm-pull-1{right:8.333333333333332%}.col-sm-pull-0{right:0}.col-sm-push-12{left:100%}.col-sm-push-11{left:91.66666666666666%}.col-sm-push-10{left:83.33333333333334%}.col-sm-push-9{left:75%}.col-sm-push-8{left:66.66666666666666%}.col-sm-push-7{left:58.333333333333336%}.col-sm-push-6{left:50%}.col-sm-push-5{left:41.66666666666667%}.col-sm-push-4{left:33.33333333333333%}.col-sm-push-3{left:25%}.col-sm-push-2{left:16.666666666666664%}.col-sm-push-1{left:8.333333333333332%}.col-sm-push-0{left:0}.col-sm-offset-12{margin-left:100%}.col-sm-offset-11{margin-left:91.66666666666666%}.col-sm-offset-10{margin-left:83.33333333333334%}.col-sm-offset-9{margin-left:75%}.col-sm-offset-8{margin-left:66.66666666666666%}.col-sm-offset-7{margin-left:58.333333333333336%}.col-sm-offset-6{margin-left:50%}.col-sm-offset-5{margin-left:41.66666666666667%}.col-sm-offset-4{margin-left:33.33333333333333%}.col-sm-offset-3{margin-left:25%}.col-sm-offset-2{margin-left:16.666666666666664%}.col-sm-offset-1{margin-left:8.333333333333332%}.col-sm-offset-0{margin-left:0}}@media(min-width:992px){.col-md-1,.col-md-2,.col-md-3,.col-md-4,.col-md-5,.col-md-6,.col-md-7,.col-md-8,.col-md-9,.col-md-10,.col-md-11,.col-md-12{float:left}.col-md-12{width:100%}.col-md-11{width:91.66666666666666%}.col-md-10{width:83.33333333333334%}.col-md-9{width:75%}.col-md-8{width:66.66666666666666%}.col-md-7{width:58.333333333333336%}.col-md-6{width:50%}.col-md-5{width:41.66666666666667%}.col-md-4{width:33.33333333333333%}.col-md-3{width:25%}.col-md-2{width:16.666666666666664%}.col-md-1{width:8.333333333333332%}.col-md-pull-12{right:100%}.col-md-pull-11{right:91.66666666666666%}.col-md-pull-10{right:83.33333333333334%}.col-md-pull-9{right:75%}.col-md-pull-8{right:66.66666666666666%}.col-md-pull-7{right:58.333333333333336%}.col-md-pull-6{right:50%}.col-md-pull-5{right:41.66666666666667%}.col-md-pull-4{right:33.33333333333333%}.col-md-pull-3{right:25%}.col-md-pull-2{right:16.666666666666664%}.col-md-pull-1{right:8.333333333333332%}.col-md-pull-0{right:0}.col-md-push-12{left:100%}.col-md-push-11{left:91.66666666666666%}.col-md-push-10{left:83.33333333333334%}.col-md-push-9{left:75%}.col-md-push-8{left:66.66666666666666%}.col-md-push-7{left:58.333333333333336%}.col-md-push-6{left:50%}.col-md-push-5{left:41.66666666666667%}.col-md-push-4{left:33.33333333333333%}.col-md-push-3{left:25%}.col-md-push-2{left:16.666666666666664%}.col-md-push-1{left:8.333333333333332%}.col-md-push-0{left:0}.col-md-offset-12{margin-left:100%}.col-md-offset-11{margin-left:91.66666666666666%}.col-md-offset-10{margin-left:83.33333333333334%}.col-md-offset-9{margin-left:75%}.col-md-offset-8{margin-left:66.66666666666666%}.col-md-offset-7{margin-left:58.333333333333336%}.col-md-offset-6{margin-left:50%}.col-md-offset-5{margin-left:41.66666666666667%}.col-md-offset-4{margin-left:33.33333333333333%}.col-md-offset-3{margin-left:25%}.col-md-offset-2{margin-left:16.666666666666664%}.col-md-offset-1{margin-left:8.333333333333332%}.col-md-offset-0{margin-left:0}}@media(min-width:1200px){.col-lg-1,.col-lg-2,.col-lg-3,.col-lg-4,.col-lg-5,.col-lg-6,.col-lg-7,.col-lg-8,.col-lg-9,.col-lg-10,.col-lg-11,.col-lg-12{float:left}.col-lg-12{width:100%}.col-lg-11{width:91.66666666666666%}.col-lg-10{width:83.33333333333334%}.col-lg-9{width:75%}.col-lg-8{width:66.66666666666666%}.col-lg-7{width:58.333333333333336%}.col-lg-6{width:50%}.col-lg-5{width:41.66666666666667%}.col-lg-4{width:33.33333333333333%}.col-lg-3{width:25%}.col-lg-2{width:16.666666666666664%}.col-lg-1{width:8.333333333333332%}.col-lg-pull-12{right:100%}.col-lg-pull-11{right:91.66666666666666%}.col-lg-pull-10{right:83.33333333333334%}.col-lg-pull-9{right:75%}.col-lg-pull-8{right:66.66666666666666%}.col-lg-pull-7{right:58.333333333333336%}.col-lg-pull-6{right:50%}.col-lg-pull-5{right:41.66666666666667%}.col-lg-pull-4{right:33.33333333333333%}.col-lg-pull-3{right:25%}.col-lg-pull-2{right:16.666666666666664%}.col-lg-pull-1{right:8.333333333333332%}.col-lg-pull-0{right:0}.col-lg-push-12{left:100%}.col-lg-push-11{left:91.66666666666666%}.col-lg-push-10{left:83.33333333333334%}.col-lg-push-9{left:75%}.col-lg-push-8{left:66.66666666666666%}.col-lg-push-7{left:58.333333333333336%}.col-lg-push-6{left:50%}.col-lg-push-5{left:41.66666666666667%}.col-lg-push-4{left:33.33333333333333%}.col-lg-push-3{left:25%}.col-lg-push-2{left:16.666666666666664%}.col-lg-push-1{left:8.333333333333332%}.col-lg-push-0{left:0}.col-lg-offset-12{margin-left:100%}.col-lg-offset-11{margin-left:91.66666666666666%}.col-lg-offset-10{margin-left:83.33333333333334%}.col-lg-offset-9{margin-left:75%}.col-lg-offset-8{margin-left:66.66666666666666%}.col-lg-offset-7{margin-left:58.333333333333336%}.col-lg-offset-6{margin-left:50%}.col-lg-offset-5{margin-left:41.66666666666667%}.col-lg-offset-4{margin-left:33.33333333333333%}.col-lg-offset-3{margin-left:25%}.col-lg-offset-2{margin-left:16.666666666666664%}.col-lg-offset-1{margin-left:8.333333333333332%}.col-lg-offset-0{margin-left:0}}table{max-width:100%;background-color:transparent}th{text-align:left}.table{width:100%;margin-bottom:20px}.table>thead>tr>th,.table>tbody>tr>th,.table>tfoot>tr>th,.table>thead>tr>td,.table>tbody>tr>td,.table>tfoot>tr>td{padding:8px;line-height:1.428571429;vertical-align:top;border-top:1px solid #ddd}.table>thead>tr>th{vertical-align:bottom;border-bottom:2px solid #ddd}.table>caption+thead>tr:first-child>th,.table>colgroup+thead>tr:first-child>th,.table>thead:first-child>tr:first-child>th,.table>caption+thead>tr:first-child>td,.table>colgroup+thead>tr:first-child>td,.table>thead:first-child>tr:first-child>td{border-top:0}.table>tbody+tbody{border-top:2px solid #ddd}.table .table{background-color:#fff}.table-condensed>thead>tr>th,.table-condensed>tbody>tr>th,.table-condensed>tfoot>tr>th,.table-condensed>thead>tr>td,.table-condensed>tbody>tr>td,.table-condensed>tfoot>tr>td{padding:5px}.table-bordered{border:1px solid #ddd}.table-bordered>thead>tr>th,.table-bordered>tbody>tr>th,.table-bordered>tfoot>tr>th,.table-bordered>thead>tr>td,.table-bordered>tbody>tr>td,.table-bordered>tfoot>tr>td{border:1px solid #ddd}.table-bordered>thead>tr>th,.table-bordered>thead>tr>td{border-bottom-width:2px}.table-striped>tbody>tr:nth-child(odd)>td,.table-striped>tbody>tr:nth-child(odd)>th{background-color:#f9f9f9}.table-hover>tbody>tr:hover>td,.table-hover>tbody>tr:hover>th{background-color:#f5f5f5}table col[class*="col-"]{position:static;display:table-column;float:none}table td[class*="col-"],table th[class*="col-"]{display:table-cell;float:none}.table>thead>tr>.active,.table>tbody>tr>.active,.table>tfoot>tr>.active,.table>thead>.active>td,.table>tbody>.active>td,.table>tfoot>.active>td,.table>thead>.active>th,.table>tbody>.active>th,.table>tfoot>.active>th{background-color:#f5f5f5}.table-hover>tbody>tr>.active:hover,.table-hover>tbody>.active:hover>td,.table-hover>tbody>.active:hover>th{background-color:#e8e8e8}.table>thead>tr>.success,.table>tbody>tr>.success,.table>tfoot>tr>.success,.table>thead>.success>td,.table>tbody>.success>td,.table>tfoot>.success>td,.table>thead>.success>th,.table>tbody>.success>th,.table>tfoot>.success>th{background-color:#dff0d8}.table-hover>tbody>tr>.success:hover,.table-hover>tbody>.success:hover>td,.table-hover>tbody>.success:hover>th{background-color:#d0e9c6}.table>thead>tr>.danger,.table>tbody>tr>.danger,.table>tfoot>tr>.danger,.table>thead>.danger>td,.table>tbody>.danger>td,.table>tfoot>.danger>td,.table>thead>.danger>th,.table>tbody>.danger>th,.table>tfoot>.danger>th{background-color:#f2dede}.table-hover>tbody>tr>.danger:hover,.table-hover>tbody>.danger:hover>td,.table-hover>tbody>.danger:hover>th{background-color:#ebcccc}.table>thead>tr>.warning,.table>tbody>tr>.warning,.table>tfoot>tr>.warning,.table>thead>.warning>td,.table>tbody>.warning>td,.table>tfoot>.warning>td,.table>thead>.warning>th,.table>tbody>.warning>th,.table>tfoot>.warning>th{background-color:#fcf8e3}.table-hover>tbody>tr>.warning:hover,.table-hover>tbody>.warning:hover>td,.table-hover>tbody>.warning:hover>th{background-color:#faf2cc}@media(max-width:767px){.table-responsive{width:100%;margin-bottom:15px;overflow-x:scroll;overflow-y:hidden;border:1px solid #ddd;-ms-overflow-style:-ms-autohiding-scrollbar;-webkit-overflow-scrolling:touch}.table-responsive>.table{margin-bottom:0}.table-responsive>.table>thead>tr>th,.table-responsive>.table>tbody>tr>th,.table-responsive>.table>tfoot>tr>th,.table-responsive>.table>thead>tr>td,.table-responsive>.table>tbody>tr>td,.table-responsive>.table>tfoot>tr>td{white-space:nowrap}.table-responsive>.table-bordered{border:0}.table-responsive>.table-bordered>thead>tr>th:first-child,.table-responsive>.table-bordered>tbody>tr>th:first-child,.table-responsive>.table-bordered>tfoot>tr>th:first-child,.table-responsive>.table-bordered>thead>tr>td:first-child,.table-responsive>.table-bordered>tbody>tr>td:first-child,.table-responsive>.table-bordered>tfoot>tr>td:first-child{border-left:0}.table-responsive>.table-bordered>thead>tr>th:last-child,.table-responsive>.table-bordered>tbody>tr>th:last-child,.table-responsive>.table-bordered>tfoot>tr>th:last-child,.table-responsive>.table-bordered>thead>tr>td:last-child,.table-responsive>.table-bordered>tbody>tr>td:last-child,.table-responsive>.table-bordered>tfoot>tr>td:last-child{border-right:0}.table-responsive>.table-bordered>tbody>tr:last-child>th,.table-responsive>.table-bordered>tfoot>tr:last-child>th,.table-responsive>.table-bordered>tbody>tr:last-child>td,.table-responsive>.table-bordered>tfoot>tr:last-child>td{border-bottom:0}}fieldset{padding:0;margin:0;border:0}legend{display:block;width:100%;padding:0;margin-bottom:20px;font-size:21px;line-height:inherit;color:#333;border:0;border-bottom:1px solid #e5e5e5}label{display:inline-block;margin-bottom:5px;font-weight:bold}input[type="search"]{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}input[type="radio"],input[type="checkbox"]{margin:4px 0 0;margin-top:1px \9;line-height:normal}input[type="file"]{display:block}select[multiple],select[size]{height:auto}select optgroup{font-family:inherit;font-size:inherit;font-style:inherit}input[type="file"]:focus,input[type="radio"]:focus,input[type="checkbox"]:focus{outline:thin dotted;outline:5px auto -webkit-focus-ring-color;outline-offset:-2px}input[type="number"]::-webkit-outer-spin-button,input[type="number"]::-webkit-inner-spin-button{height:auto}output{display:block;padding-top:7px;font-size:14px;line-height:1.428571429;color:#555;vertical-align:middle}.form-control{display:block;width:100%;height:34px;padding:6px 12px;font-size:14px;line-height:1.428571429;color:#555;vertical-align:middle;background-color:#fff;background-image:none;border:1px solid #ccc;border-radius:4px;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,0.075);box-shadow:inset 0 1px 1px rgba(0,0,0,0.075);-webkit-transition:border-color ease-in-out .15s,box-shadow ease-in-out .15s;transition:border-color ease-in-out .15s,box-shadow ease-in-out .15s}.form-control:focus{border-color:#66afe9;outline:0;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,0.075),0 0 8px rgba(102,175,233,0.6);box-shadow:inset 0 1px 1px rgba(0,0,0,0.075),0 0 8px rgba(102,175,233,0.6)}.form-control:-moz-placeholder{color:#999}.form-control::-moz-placeholder{color:#999;opacity:1}.form-control:-ms-input-placeholder{color:#999}.form-control::-webkit-input-placeholder{color:#999}.form-control[disabled],.form-control[readonly],fieldset[disabled] .form-control{cursor:not-allowed;background-color:#eee}textarea.form-control{height:auto}.form-group{margin-bottom:15px}.radio,.checkbox{display:block;min-height:20px;padding-left:20px;margin-top:10px;margin-bottom:10px;vertical-align:middle}.radio label,.checkbox label{display:inline;margin-bottom:0;font-weight:normal;cursor:pointer}.radio input[type="radio"],.radio-inline input[type="radio"],.checkbox input[type="checkbox"],.checkbox-inline input[type="checkbox"]{float:left;margin-left:-20px}.radio+.radio,.checkbox+.checkbox{margin-top:-5px}.radio-inline,.checkbox-inline{display:inline-block;padding-left:20px;margin-bottom:0;font-weight:normal;vertical-align:middle;cursor:pointer}.radio-inline+.radio-inline,.checkbox-inline+.checkbox-inline{margin-top:0;margin-left:10px}input[type="radio"][disabled],input[type="checkbox"][disabled],.radio[disabled],.radio-inline[disabled],.checkbox[disabled],.checkbox-inline[disabled],fieldset[disabled] input[type="radio"],fieldset[disabled] input[type="checkbox"],fieldset[disabled] .radio,fieldset[disabled] .radio-inline,fieldset[disabled] .checkbox,fieldset[disabled] .checkbox-inline{cursor:not-allowed}.input-sm{height:30px;padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}select.input-sm{height:30px;line-height:30px}textarea.input-sm{height:auto}.input-lg{height:46px;padding:10px 16px;font-size:18px;line-height:1.33;border-radius:6px}select.input-lg{height:46px;line-height:46px}textarea.input-lg{height:auto}.has-warning .help-block,.has-warning .control-label,.has-warning .radio,.has-warning .checkbox,.has-warning .radio-inline,.has-warning .checkbox-inline{color:#8a6d3b}.has-warning .form-control{border-color:#8a6d3b;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,0.075);box-shadow:inset 0 1px 1px rgba(0,0,0,0.075)}.has-warning .form-control:focus{border-color:#66512c;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,0.075),0 0 6px #c0a16b;box-shadow:inset 0 1px 1px rgba(0,0,0,0.075),0 0 6px #c0a16b}.has-warning .input-group-addon{color:#8a6d3b;background-color:#fcf8e3;border-color:#8a6d3b}.has-error .help-block,.has-error .control-label,.has-error .radio,.has-error .checkbox,.has-error .radio-inline,.has-error .checkbox-inline{color:#a94442}.has-error .form-control{border-color:#a94442;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,0.075);box-shadow:inset 0 1px 1px rgba(0,0,0,0.075)}.has-error .form-control:focus{border-color:#843534;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,0.075),0 0 6px #ce8483;box-shadow:inset 0 1px 1px rgba(0,0,0,0.075),0 0 6px #ce8483}.has-error .input-group-addon{color:#a94442;background-color:#f2dede;border-color:#a94442}.has-success .help-block,.has-success .control-label,.has-success .radio,.has-success .checkbox,.has-success .radio-inline,.has-success .checkbox-inline{color:#3c763d}.has-success .form-control{border-color:#3c763d;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,0.075);box-shadow:inset 0 1px 1px rgba(0,0,0,0.075)}.has-success .form-control:focus{border-color:#2b542c;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,0.075),0 0 6px #67b168;box-shadow:inset 0 1px 1px rgba(0,0,0,0.075),0 0 6px #67b168}.has-success .input-group-addon{color:#3c763d;background-color:#dff0d8;border-color:#3c763d}.form-control-static{margin-bottom:0}.help-block{display:block;margin-top:5px;margin-bottom:10px;color:#737373}@media(min-width:768px){.form-inline .form-group{display:inline-block;margin-bottom:0;vertical-align:middle}.form-inline .form-control{display:inline-block}.form-inline select.form-control{width:auto}.form-inline .radio,.form-inline .checkbox{display:inline-block;padding-left:0;margin-top:0;margin-bottom:0}.form-inline .radio input[type="radio"],.form-inline .checkbox input[type="checkbox"]{float:none;margin-left:0}}.form-horizontal .control-label,.form-horizontal .radio,.form-horizontal .checkbox,.form-horizontal .radio-inline,.form-horizontal .checkbox-inline{padding-top:7px;margin-top:0;margin-bottom:0}.form-horizontal .radio,.form-horizontal .checkbox{min-height:27px}.form-horizontal .form-group{margin-right:-15px;margin-left:-15px}.form-horizontal .form-group:before,.form-horizontal .form-group:after{display:table;content:" "}.form-horizontal .form-group:after{clear:both}.form-horizontal .form-group:before,.form-horizontal .form-group:after{display:table;content:" "}.form-horizontal .form-group:after{clear:both}.form-horizontal .form-control-static{padding-top:7px}@media(min-width:768px){.form-horizontal .control-label{text-align:right}}.btn{display:inline-block;padding:6px 12px;margin-bottom:0;font-size:14px;font-weight:normal;line-height:1.428571429;text-align:center;white-space:nowrap;vertical-align:middle;cursor:pointer;background-image:none;border:1px solid transparent;border-radius:4px;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;-o-user-select:none;user-select:none}.btn:focus{outline:thin dotted;outline:5px auto -webkit-focus-ring-color;outline-offset:-2px}.btn:hover,.btn:focus{color:#333;text-decoration:none}.btn:active,.btn.active{background-image:none;outline:0;-webkit-box-shadow:inset 0 3px 5px rgba(0,0,0,0.125);box-shadow:inset 0 3px 5px rgba(0,0,0,0.125)}.btn.disabled,.btn[disabled],fieldset[disabled] .btn{pointer-events:none;cursor:not-allowed;opacity:.65;filter:alpha(opacity=65);-webkit-box-shadow:none;box-shadow:none}.btn-default{color:#333;background-color:#fff;border-color:#ccc}.btn-default:hover,.btn-default:focus,.btn-default:active,.btn-default.active,.open .dropdown-toggle.btn-default{color:#333;background-color:#ebebeb;border-color:#adadad}.btn-default:active,.btn-default.active,.open .dropdown-toggle.btn-default{background-image:none}.btn-default.disabled,.btn-default[disabled],fieldset[disabled] .btn-default,.btn-default.disabled:hover,.btn-default[disabled]:hover,fieldset[disabled] .btn-default:hover,.btn-default.disabled:focus,.btn-default[disabled]:focus,fieldset[disabled] .btn-default:focus,.btn-default.disabled:active,.btn-default[disabled]:active,fieldset[disabled] .btn-default:active,.btn-default.disabled.active,.btn-default[disabled].active,fieldset[disabled] .btn-default.active{background-color:#fff;border-color:#ccc}.btn-default .badge{color:#fff;background-color:#fff}.btn-primary{color:#fff;background-color:#428bca;border-color:#357ebd}.btn-primary:hover,.btn-primary:focus,.btn-primary:active,.btn-primary.active,.open .dropdown-toggle.btn-primary{color:#fff;background-color:#3276b1;border-color:#285e8e}.btn-primary:active,.btn-primary.active,.open .dropdown-toggle.btn-primary{background-image:none}.btn-primary.disabled,.btn-primary[disabled],fieldset[disabled] .btn-primary,.btn-primary.disabled:hover,.btn-primary[disabled]:hover,fieldset[disabled] .btn-primary:hover,.btn-primary.disabled:focus,.btn-primary[disabled]:focus,fieldset[disabled] .btn-primary:focus,.btn-primary.disabled:active,.btn-primary[disabled]:active,fieldset[disabled] .btn-primary:active,.btn-primary.disabled.active,.btn-primary[disabled].active,fieldset[disabled] .btn-primary.active{background-color:#428bca;border-color:#357ebd}.btn-primary .badge{color:#428bca;background-color:#fff}.btn-warning{color:#fff;background-color:#f0ad4e;border-color:#eea236}.btn-warning:hover,.btn-warning:focus,.btn-warning:active,.btn-warning.active,.open .dropdown-toggle.btn-warning{color:#fff;background-color:#ed9c28;border-color:#d58512}.btn-warning:active,.btn-warning.active,.open .dropdown-toggle.btn-warning{background-image:none}.btn-warning.disabled,.btn-warning[disabled],fieldset[disabled] .btn-warning,.btn-warning.disabled:hover,.btn-warning[disabled]:hover,fieldset[disabled] .btn-warning:hover,.btn-warning.disabled:focus,.btn-warning[disabled]:focus,fieldset[disabled] .btn-warning:focus,.btn-warning.disabled:active,.btn-warning[disabled]:active,fieldset[disabled] .btn-warning:active,.btn-warning.disabled.active,.btn-warning[disabled].active,fieldset[disabled] .btn-warning.active{background-color:#f0ad4e;border-color:#eea236}.btn-warning .badge{color:#f0ad4e;background-color:#fff}.btn-danger{color:#fff;background-color:#d9534f;border-color:#d43f3a}.btn-danger:hover,.btn-danger:focus,.btn-danger:active,.btn-danger.active,.open .dropdown-toggle.btn-danger{color:#fff;background-color:#d2322d;border-color:#ac2925}.btn-danger:active,.btn-danger.active,.open .dropdown-toggle.btn-danger{background-image:none}.btn-danger.disabled,.btn-danger[disabled],fieldset[disabled] .btn-danger,.btn-danger.disabled:hover,.btn-danger[disabled]:hover,fieldset[disabled] .btn-danger:hover,.btn-danger.disabled:focus,.btn-danger[disabled]:focus,fieldset[disabled] .btn-danger:focus,.btn-danger.disabled:active,.btn-danger[disabled]:active,fieldset[disabled] .btn-danger:active,.btn-danger.disabled.active,.btn-danger[disabled].active,fieldset[disabled] .btn-danger.active{background-color:#d9534f;border-color:#d43f3a}.btn-danger .badge{color:#d9534f;background-color:#fff}.btn-success{color:#fff;background-color:#5cb85c;border-color:#4cae4c}.btn-success:hover,.btn-success:focus,.btn-success:active,.btn-success.active,.open .dropdown-toggle.btn-success{color:#fff;background-color:#47a447;border-color:#398439}.btn-success:active,.btn-success.active,.open .dropdown-toggle.btn-success{background-image:none}.btn-success.disabled,.btn-success[disabled],fieldset[disabled] .btn-success,.btn-success.disabled:hover,.btn-success[disabled]:hover,fieldset[disabled] .btn-success:hover,.btn-success.disabled:focus,.btn-success[disabled]:focus,fieldset[disabled] .btn-success:focus,.btn-success.disabled:active,.btn-success[disabled]:active,fieldset[disabled] .btn-success:active,.btn-success.disabled.active,.btn-success[disabled].active,fieldset[disabled] .btn-success.active{background-color:#5cb85c;border-color:#4cae4c}.btn-success .badge{color:#5cb85c;background-color:#fff}.btn-info{color:#fff;background-color:#5bc0de;border-color:#46b8da}.btn-info:hover,.btn-info:focus,.btn-info:active,.btn-info.active,.open .dropdown-toggle.btn-info{color:#fff;background-color:#39b3d7;border-color:#269abc}.btn-info:active,.btn-info.active,.open .dropdown-toggle.btn-info{background-image:none}.btn-info.disabled,.btn-info[disabled],fieldset[disabled] .btn-info,.btn-info.disabled:hover,.btn-info[disabled]:hover,fieldset[disabled] .btn-info:hover,.btn-info.disabled:focus,.btn-info[disabled]:focus,fieldset[disabled] .btn-info:focus,.btn-info.disabled:active,.btn-info[disabled]:active,fieldset[disabled] .btn-info:active,.btn-info.disabled.active,.btn-info[disabled].active,fieldset[disabled] .btn-info.active{background-color:#5bc0de;border-color:#46b8da}.btn-info .badge{color:#5bc0de;background-color:#fff}.btn-link{font-weight:normal;color:#428bca;cursor:pointer;border-radius:0}.btn-link,.btn-link:active,.btn-link[disabled],fieldset[disabled] .btn-link{background-color:transparent;-webkit-box-shadow:none;box-shadow:none}.btn-link,.btn-link:hover,.btn-link:focus,.btn-link:active{border-color:transparent}.btn-link:hover,.btn-link:focus{color:#2a6496;text-decoration:underline;background-color:transparent}.btn-link[disabled]:hover,fieldset[disabled] .btn-link:hover,.btn-link[disabled]:focus,fieldset[disabled] .btn-link:focus{color:#999;text-decoration:none}.btn-lg{padding:10px 16px;font-size:18px;line-height:1.33;border-radius:6px}.btn-sm{padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}.btn-xs{padding:1px 5px;font-size:12px;line-height:1.5;border-radius:3px}.btn-block{display:block;width:100%;padding-right:0;padding-left:0}.btn-block+.btn-block{margin-top:5px}input[type="submit"].btn-block,input[type="reset"].btn-block,input[type="button"].btn-block{width:100%}.fade{opacity:0;-webkit-transition:opacity .15s linear;transition:opacity .15s linear}.fade.in{opacity:1}.collapse{display:none}.collapse.in{display:block}.collapsing{position:relative;height:0;overflow:hidden;-webkit-transition:height .35s ease;transition:height .35s ease}@font-face{font-family:'Glyphicons Halflings';src:url('../fonts/glyphicons-halflings-regular.eot');src:url('../fonts/glyphicons-halflings-regular.eot?#iefix') format('embedded-opentype'),url('../fonts/glyphicons-halflings-regular.woff') format('woff'),url('../fonts/glyphicons-halflings-regular.ttf') format('truetype'),url('../fonts/glyphicons-halflings-regular.svg#glyphicons-halflingsregular') format('svg')}.glyphicon{position:relative;top:1px;display:inline-block;font-family:'Glyphicons Halflings';-webkit-font-smoothing:antialiased;font-style:normal;font-weight:normal;line-height:1;-moz-osx-font-smoothing:grayscale}.glyphicon:empty{width:1em}.glyphicon-asterisk:before{content:"\2a"}.glyphicon-plus:before{content:"\2b"}.glyphicon-euro:before{content:"\20ac"}.glyphicon-minus:before{content:"\2212"}.glyphicon-cloud:before{content:"\2601"}.glyphicon-envelope:before{content:"\2709"}.glyphicon-pencil:before{content:"\270f"}.glyphicon-glass:before{content:"\e001"}.glyphicon-music:before{content:"\e002"}.glyphicon-search:before{content:"\e003"}.glyphicon-heart:before{content:"\e005"}.glyphicon-star:before{content:"\e006"}.glyphicon-star-empty:before{content:"\e007"}.glyphicon-user:before{content:"\e008"}.glyphicon-film:before{content:"\e009"}.glyphicon-th-large:before{content:"\e010"}.glyphicon-th:before{content:"\e011"}.glyphicon-th-list:before{content:"\e012"}.glyphicon-ok:before{content:"\e013"}.glyphicon-remove:before{content:"\e014"}.glyphicon-zoom-in:before{content:"\e015"}.glyphicon-zoom-out:before{content:"\e016"}.glyphicon-off:before{content:"\e017"}.glyphicon-signal:before{content:"\e018"}.glyphicon-cog:before{content:"\e019"}.glyphicon-trash:before{content:"\e020"}.glyphicon-home:before{content:"\e021"}.glyphicon-file:before{content:"\e022"}.glyphicon-time:before{content:"\e023"}.glyphicon-road:before{content:"\e024"}.glyphicon-download-alt:before{content:"\e025"}.glyphicon-download:before{content:"\e026"}.glyphicon-upload:before{content:"\e027"}.glyphicon-inbox:before{content:"\e028"}.glyphicon-play-circle:before{content:"\e029"}.glyphicon-repeat:before{content:"\e030"}.glyphicon-refresh:before{content:"\e031"}.glyphicon-list-alt:before{content:"\e032"}.glyphicon-lock:before{content:"\e033"}.glyphicon-flag:before{content:"\e034"}.glyphicon-headphones:before{content:"\e035"}.glyphicon-volume-off:before{content:"\e036"}.glyphicon-volume-down:before{content:"\e037"}.glyphicon-volume-up:before{content:"\e038"}.glyphicon-qrcode:before{content:"\e039"}.glyphicon-barcode:before{content:"\e040"}.glyphicon-tag:before{content:"\e041"}.glyphicon-tags:before{content:"\e042"}.glyphicon-book:before{content:"\e043"}.glyphicon-bookmark:before{content:"\e044"}.glyphicon-print:before{content:"\e045"}.glyphicon-camera:before{content:"\e046"}.glyphicon-font:before{content:"\e047"}.glyphicon-bold:before{content:"\e048"}.glyphicon-italic:before{content:"\e049"}.glyphicon-text-height:before{content:"\e050"}.glyphicon-text-width:before{content:"\e051"}.glyphicon-align-left:before{content:"\e052"}.glyphicon-align-center:before{content:"\e053"}.glyphicon-align-right:before{content:"\e054"}.glyphicon-align-justify:before{content:"\e055"}.glyphicon-list:before{content:"\e056"}.glyphicon-indent-left:before{content:"\e057"}.glyphicon-indent-right:before{content:"\e058"}.glyphicon-facetime-video:before{content:"\e059"}.glyphicon-picture:before{content:"\e060"}.glyphicon-map-marker:before{content:"\e062"}.glyphicon-adjust:before{content:"\e063"}.glyphicon-tint:before{content:"\e064"}.glyphicon-edit:before{content:"\e065"}.glyphicon-share:before{content:"\e066"}.glyphicon-check:before{content:"\e067"}.glyphicon-move:before{content:"\e068"}.glyphicon-step-backward:before{content:"\e069"}.glyphicon-fast-backward:before{content:"\e070"}.glyphicon-backward:before{content:"\e071"}.glyphicon-play:before{content:"\e072"}.glyphicon-pause:before{content:"\e073"}.glyphicon-stop:before{content:"\e074"}.glyphicon-forward:before{content:"\e075"}.glyphicon-fast-forward:before{content:"\e076"}.glyphicon-step-forward:before{content:"\e077"}.glyphicon-eject:before{content:"\e078"}.glyphicon-chevron-left:before{content:"\e079"}.glyphicon-chevron-right:before{content:"\e080"}.glyphicon-plus-sign:before{content:"\e081"}.glyphicon-minus-sign:before{content:"\e082"}.glyphicon-remove-sign:before{content:"\e083"}.glyphicon-ok-sign:before{content:"\e084"}.glyphicon-question-sign:before{content:"\e085"}.glyphicon-info-sign:before{content:"\e086"}.glyphicon-screenshot:before{content:"\e087"}.glyphicon-remove-circle:before{content:"\e088"}.glyphicon-ok-circle:before{content:"\e089"}.glyphicon-ban-circle:before{content:"\e090"}.glyphicon-arrow-left:before{content:"\e091"}.glyphicon-arrow-right:before{content:"\e092"}.glyphicon-arrow-up:before{content:"\e093"}.glyphicon-arrow-down:before{content:"\e094"}.glyphicon-share-alt:before{content:"\e095"}.glyphicon-resize-full:before{content:"\e096"}.glyphicon-resize-small:before{content:"\e097"}.glyphicon-exclamation-sign:before{content:"\e101"}.glyphicon-gift:before{content:"\e102"}.glyphicon-leaf:before{content:"\e103"}.glyphicon-fire:before{content:"\e104"}.glyphicon-eye-open:before{content:"\e105"}.glyphicon-eye-close:before{content:"\e106"}.glyphicon-warning-sign:before{content:"\e107"}.glyphicon-plane:before{content:"\e108"}.glyphicon-calendar:before{content:"\e109"}.glyphicon-random:before{content:"\e110"}.glyphicon-comment:before{content:"\e111"}.glyphicon-magnet:before{content:"\e112"}.glyphicon-chevron-up:before{content:"\e113"}.glyphicon-chevron-down:before{content:"\e114"}.glyphicon-retweet:before{content:"\e115"}.glyphicon-shopping-cart:before{content:"\e116"}.glyphicon-folder-close:before{content:"\e117"}.glyphicon-folder-open:before{content:"\e118"}.glyphicon-resize-vertical:before{content:"\e119"}.glyphicon-resize-horizontal:before{content:"\e120"}.glyphicon-hdd:before{content:"\e121"}.glyphicon-bullhorn:before{content:"\e122"}.glyphicon-bell:before{content:"\e123"}.glyphicon-certificate:before{content:"\e124"}.glyphicon-thumbs-up:before{content:"\e125"}.glyphicon-thumbs-down:before{content:"\e126"}.glyphicon-hand-right:before{content:"\e127"}.glyphicon-hand-left:before{content:"\e128"}.glyphicon-hand-up:before{content:"\e129"}.glyphicon-hand-down:before{content:"\e130"}.glyphicon-circle-arrow-right:before{content:"\e131"}.glyphicon-circle-arrow-left:before{content:"\e132"}.glyphicon-circle-arrow-up:before{content:"\e133"}.glyphicon-circle-arrow-down:before{content:"\e134"}.glyphicon-globe:before{content:"\e135"}.glyphicon-wrench:before{content:"\e136"}.glyphicon-tasks:before{content:"\e137"}.glyphicon-filter:before{content:"\e138"}.glyphicon-briefcase:before{content:"\e139"}.glyphicon-fullscreen:before{content:"\e140"}.glyphicon-dashboard:before{content:"\e141"}.glyphicon-paperclip:before{content:"\e142"}.glyphicon-heart-empty:before{content:"\e143"}.glyphicon-link:before{content:"\e144"}.glyphicon-phone:before{content:"\e145"}.glyphicon-pushpin:before{content:"\e146"}.glyphicon-usd:before{content:"\e148"}.glyphicon-gbp:before{content:"\e149"}.glyphicon-sort:before{content:"\e150"}.glyphicon-sort-by-alphabet:before{content:"\e151"}.glyphicon-sort-by-alphabet-alt:before{content:"\e152"}.glyphicon-sort-by-order:before{content:"\e153"}.glyphicon-sort-by-order-alt:before{content:"\e154"}.glyphicon-sort-by-attributes:before{content:"\e155"}.glyphicon-sort-by-attributes-alt:before{content:"\e156"}.glyphicon-unchecked:before{content:"\e157"}.glyphicon-expand:before{content:"\e158"}.glyphicon-collapse-down:before{content:"\e159"}.glyphicon-collapse-up:before{content:"\e160"}.glyphicon-log-in:before{content:"\e161"}.glyphicon-flash:before{content:"\e162"}.glyphicon-log-out:before{content:"\e163"}.glyphicon-new-window:before{content:"\e164"}.glyphicon-record:before{content:"\e165"}.glyphicon-save:before{content:"\e166"}.glyphicon-open:before{content:"\e167"}.glyphicon-saved:before{content:"\e168"}.glyphicon-import:before{content:"\e169"}.glyphicon-export:before{content:"\e170"}.glyphicon-send:before{content:"\e171"}.glyphicon-floppy-disk:before{content:"\e172"}.glyphicon-floppy-saved:before{content:"\e173"}.glyphicon-floppy-remove:before{content:"\e174"}.glyphicon-floppy-save:before{content:"\e175"}.glyphicon-floppy-open:before{content:"\e176"}.glyphicon-credit-card:before{content:"\e177"}.glyphicon-transfer:before{content:"\e178"}.glyphicon-cutlery:before{content:"\e179"}.glyphicon-header:before{content:"\e180"}.glyphicon-compressed:before{content:"\e181"}.glyphicon-earphone:before{content:"\e182"}.glyphicon-phone-alt:before{content:"\e183"}.glyphicon-tower:before{content:"\e184"}.glyphicon-stats:before{content:"\e185"}.glyphicon-sd-video:before{content:"\e186"}.glyphicon-hd-video:before{content:"\e187"}.glyphicon-subtitles:before{content:"\e188"}.glyphicon-sound-stereo:before{content:"\e189"}.glyphicon-sound-dolby:before{content:"\e190"}.glyphicon-sound-5-1:before{content:"\e191"}.glyphicon-sound-6-1:before{content:"\e192"}.glyphicon-sound-7-1:before{content:"\e193"}.glyphicon-copyright-mark:before{content:"\e194"}.glyphicon-registration-mark:before{content:"\e195"}.glyphicon-cloud-download:before{content:"\e197"}.glyphicon-cloud-upload:before{content:"\e198"}.glyphicon-tree-conifer:before{content:"\e199"}.glyphicon-tree-deciduous:before{content:"\e200"}.caret{display:inline-block;width:0;height:0;margin-left:2px;vertical-align:middle;border-top:4px solid;border-right:4px solid transparent;border-left:4px solid transparent}.dropdown{position:relative}.dropdown-toggle:focus{outline:0}.dropdown-menu{position:absolute;top:100%;left:0;z-index:1000;display:none;float:left;min-width:160px;padding:5px 0;margin:2px 0 0;font-size:14px;list-style:none;background-color:#fff;border:1px solid #ccc;border:1px solid rgba(0,0,0,0.15);border-radius:4px;-webkit-box-shadow:0 6px 12px rgba(0,0,0,0.175);box-shadow:0 6px 12px rgba(0,0,0,0.175);background-clip:padding-box}.dropdown-menu.pull-right{right:0;left:auto}.dropdown-menu .divider{height:1px;margin:9px 0;overflow:hidden;background-color:#e5e5e5}.dropdown-menu>li>a{display:block;padding:3px 20px;clear:both;font-weight:normal;line-height:1.428571429;color:#333;white-space:nowrap}.dropdown-menu>li>a:hover,.dropdown-menu>li>a:focus{color:#262626;text-decoration:none;background-color:#f5f5f5}.dropdown-menu>.active>a,.dropdown-menu>.active>a:hover,.dropdown-menu>.active>a:focus{color:#fff;text-decoration:none;background-color:#428bca;outline:0}.dropdown-menu>.disabled>a,.dropdown-menu>.disabled>a:hover,.dropdown-menu>.disabled>a:focus{color:#999}.dropdown-menu>.disabled>a:hover,.dropdown-menu>.disabled>a:focus{text-decoration:none;cursor:not-allowed;background-color:transparent;background-image:none;filter:progid:DXImageTransform.Microsoft.gradient(enabled=false)}.open>.dropdown-menu{display:block}.open>a{outline:0}.dropdown-header{display:block;padding:3px 20px;font-size:12px;line-height:1.428571429;color:#999}.dropdown-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;z-index:990}.pull-right>.dropdown-menu{right:0;left:auto}.dropup .caret,.navbar-fixed-bottom .dropdown .caret{border-top:0;border-bottom:4px solid;content:""}.dropup .dropdown-menu,.navbar-fixed-bottom .dropdown .dropdown-menu{top:auto;bottom:100%;margin-bottom:1px}@media(min-width:768px){.navbar-right .dropdown-menu{right:0;left:auto}}.btn-group,.btn-group-vertical{position:relative;display:inline-block;vertical-align:middle}.btn-group>.btn,.btn-group-vertical>.btn{position:relative;float:left}.btn-group>.btn:hover,.btn-group-vertical>.btn:hover,.btn-group>.btn:focus,.btn-group-vertical>.btn:focus,.btn-group>.btn:active,.btn-group-vertical>.btn:active,.btn-group>.btn.active,.btn-group-vertical>.btn.active{z-index:2}.btn-group>.btn:focus,.btn-group-vertical>.btn:focus{outline:0}.btn-group .btn+.btn,.btn-group .btn+.btn-group,.btn-group .btn-group+.btn,.btn-group .btn-group+.btn-group{margin-left:-1px}.btn-toolbar:before,.btn-toolbar:after{display:table;content:" "}.btn-toolbar:after{clear:both}.btn-toolbar:before,.btn-toolbar:after{display:table;content:" "}.btn-toolbar:after{clear:both}.btn-toolbar .btn-group{float:left}.btn-toolbar>.btn+.btn,.btn-toolbar>.btn-group+.btn,.btn-toolbar>.btn+.btn-group,.btn-toolbar>.btn-group+.btn-group{margin-left:5px}.btn-group>.btn:not(:first-child):not(:last-child):not(.dropdown-toggle){border-radius:0}.btn-group>.btn:first-child{margin-left:0}.btn-group>.btn:first-child:not(:last-child):not(.dropdown-toggle){border-top-right-radius:0;border-bottom-right-radius:0}.btn-group>.btn:last-child:not(:first-child),.btn-group>.dropdown-toggle:not(:first-child){border-bottom-left-radius:0;border-top-left-radius:0}.btn-group>.btn-group{float:left}.btn-group>.btn-group:not(:first-child):not(:last-child)>.btn{border-radius:0}.btn-group>.btn-group:first-child>.btn:last-child,.btn-group>.btn-group:first-child>.dropdown-toggle{border-top-right-radius:0;border-bottom-right-radius:0}.btn-group>.btn-group:last-child>.btn:first-child{border-bottom-left-radius:0;border-top-left-radius:0}.btn-group .dropdown-toggle:active,.btn-group.open .dropdown-toggle{outline:0}.btn-group-xs>.btn{padding:1px 5px;font-size:12px;line-height:1.5;border-radius:3px}.btn-group-sm>.btn{padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}.btn-group-lg>.btn{padding:10px 16px;font-size:18px;line-height:1.33;border-radius:6px}.btn-group>.btn+.dropdown-toggle{padding-right:8px;padding-left:8px}.btn-group>.btn-lg+.dropdown-toggle{padding-right:12px;padding-left:12px}.btn-group.open .dropdown-toggle{-webkit-box-shadow:inset 0 3px 5px rgba(0,0,0,0.125);box-shadow:inset 0 3px 5px rgba(0,0,0,0.125)}.btn-group.open .dropdown-toggle.btn-link{-webkit-box-shadow:none;box-shadow:none}.btn .caret{margin-left:0}.btn-lg .caret{border-width:5px 5px 0;border-bottom-width:0}.dropup .btn-lg .caret{border-width:0 5px 5px}.btn-group-vertical>.btn,.btn-group-vertical>.btn-group,.btn-group-vertical>.btn-group>.btn{display:block;float:none;width:100%;max-width:100%}.btn-group-vertical>.btn-group:before,.btn-group-vertical>.btn-group:after{display:table;content:" "}.btn-group-vertical>.btn-group:after{clear:both}.btn-group-vertical>.btn-group:before,.btn-group-vertical>.btn-group:after{display:table;content:" "}.btn-group-vertical>.btn-group:after{clear:both}.btn-group-vertical>.btn-group>.btn{float:none}.btn-group-vertical>.btn+.btn,.btn-group-vertical>.btn+.btn-group,.btn-group-vertical>.btn-group+.btn,.btn-group-vertical>.btn-group+.btn-group{margin-top:-1px;margin-left:0}.btn-group-vertical>.btn:not(:first-child):not(:last-child){border-radius:0}.btn-group-vertical>.btn:first-child:not(:last-child){border-top-right-radius:4px;border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn:last-child:not(:first-child){border-top-right-radius:0;border-bottom-left-radius:4px;border-top-left-radius:0}.btn-group-vertical>.btn-group:not(:first-child):not(:last-child)>.btn{border-radius:0}.btn-group-vertical>.btn-group:first-child>.btn:last-child,.btn-group-vertical>.btn-group:first-child>.dropdown-toggle{border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn-group:last-child>.btn:first-child{border-top-right-radius:0;border-top-left-radius:0}.btn-group-justified{display:table;width:100%;border-collapse:separate;table-layout:fixed}.btn-group-justified>.btn,.btn-group-justified>.btn-group{display:table-cell;float:none;width:1%}.btn-group-justified>.btn-group .btn{width:100%}[data-toggle="buttons"]>.btn>input[type="radio"],[data-toggle="buttons"]>.btn>input[type="checkbox"]{display:none}.input-group{position:relative;display:table;border-collapse:separate}.input-group[class*="col-"]{float:none;padding-right:0;padding-left:0}.input-group .form-control{width:100%;margin-bottom:0}.input-group-lg>.form-control,.input-group-lg>.input-group-addon,.input-group-lg>.input-group-btn>.btn{height:46px;padding:10px 16px;font-size:18px;line-height:1.33;border-radius:6px}select.input-group-lg>.form-control,select.input-group-lg>.input-group-addon,select.input-group-lg>.input-group-btn>.btn{height:46px;line-height:46px}textarea.input-group-lg>.form-control,textarea.input-group-lg>.input-group-addon,textarea.input-group-lg>.input-group-btn>.btn{height:auto}.input-group-sm>.form-control,.input-group-sm>.input-group-addon,.input-group-sm>.input-group-btn>.btn{height:30px;padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}select.input-group-sm>.form-control,select.input-group-sm>.input-group-addon,select.input-group-sm>.input-group-btn>.btn{height:30px;line-height:30px}textarea.input-group-sm>.form-control,textarea.input-group-sm>.input-group-addon,textarea.input-group-sm>.input-group-btn>.btn{height:auto}.input-group-addon,.input-group-btn,.input-group .form-control{display:table-cell}.input-group-addon:not(:first-child):not(:last-child),.input-group-btn:not(:first-child):not(:last-child),.input-group .form-control:not(:first-child):not(:last-child){border-radius:0}.input-group-addon,.input-group-btn{width:1%;white-space:nowrap;vertical-align:middle}.input-group-addon{padding:6px 12px;font-size:14px;font-weight:normal;line-height:1;color:#555;text-align:center;background-color:#eee;border:1px solid #ccc;border-radius:4px}.input-group-addon.input-sm{padding:5px 10px;font-size:12px;border-radius:3px}.input-group-addon.input-lg{padding:10px 16px;font-size:18px;border-radius:6px}.input-group-addon input[type="radio"],.input-group-addon input[type="checkbox"]{margin-top:0}.input-group .form-control:first-child,.input-group-addon:first-child,.input-group-btn:first-child>.btn,.input-group-btn:first-child>.dropdown-toggle,.input-group-btn:last-child>.btn:not(:last-child):not(.dropdown-toggle){border-top-right-radius:0;border-bottom-right-radius:0}.input-group-addon:first-child{border-right:0}.input-group .form-control:last-child,.input-group-addon:last-child,.input-group-btn:last-child>.btn,.input-group-btn:last-child>.dropdown-toggle,.input-group-btn:first-child>.btn:not(:first-child){border-bottom-left-radius:0;border-top-left-radius:0}.input-group-addon:last-child{border-left:0}.input-group-btn{position:relative;white-space:nowrap}.input-group-btn:first-child>.btn{margin-right:-1px}.input-group-btn:last-child>.btn{margin-left:-1px}.input-group-btn>.btn{position:relative}.input-group-btn>.btn+.btn{margin-left:-4px}.input-group-btn>.btn:hover,.input-group-btn>.btn:active{z-index:2}.nav{padding-left:0;margin-bottom:0;list-style:none}.nav:before,.nav:after{display:table;content:" "}.nav:after{clear:both}.nav:before,.nav:after{display:table;content:" "}.nav:after{clear:both}.nav>li{position:relative;display:block}.nav>li>a{position:relative;display:block;padding:10px 15px}.nav>li>a:hover,.nav>li>a:focus{text-decoration:none;background-color:#eee}.nav>li.disabled>a{color:#999}.nav>li.disabled>a:hover,.nav>li.disabled>a:focus{color:#999;text-decoration:none;cursor:not-allowed;background-color:transparent}.nav .open>a,.nav .open>a:hover,.nav .open>a:focus{background-color:#eee;border-color:#428bca}.nav .nav-divider{height:1px;margin:9px 0;overflow:hidden;background-color:#e5e5e5}.nav>li>a>img{max-width:none}.nav-tabs{border-bottom:1px solid #ddd}.nav-tabs>li{float:left;margin-bottom:-1px}.nav-tabs>li>a{margin-right:2px;line-height:1.428571429;border:1px solid transparent;border-radius:4px 4px 0 0}.nav-tabs>li>a:hover{border-color:#eee #eee #ddd}.nav-tabs>li.active>a,.nav-tabs>li.active>a:hover,.nav-tabs>li.active>a:focus{color:#555;cursor:default;background-color:#fff;border:1px solid #ddd;border-bottom-color:transparent}.nav-tabs.nav-justified{width:100%;border-bottom:0}.nav-tabs.nav-justified>li{float:none}.nav-tabs.nav-justified>li>a{margin-bottom:5px;text-align:center}.nav-tabs.nav-justified>.dropdown .dropdown-menu{top:auto;left:auto}@media(min-width:768px){.nav-tabs.nav-justified>li{display:table-cell;width:1%}.nav-tabs.nav-justified>li>a{margin-bottom:0}}.nav-tabs.nav-justified>li>a{margin-right:0;border-radius:4px}.nav-tabs.nav-justified>.active>a,.nav-tabs.nav-justified>.active>a:hover,.nav-tabs.nav-justified>.active>a:focus{border:1px solid #ddd}@media(min-width:768px){.nav-tabs.nav-justified>li>a{border-bottom:1px solid #ddd;border-radius:4px 4px 0 0}.nav-tabs.nav-justified>.active>a,.nav-tabs.nav-justified>.active>a:hover,.nav-tabs.nav-justified>.active>a:focus{border-bottom-color:#fff}}.nav-pills>li{float:left}.nav-pills>li>a{border-radius:4px}.nav-pills>li+li{margin-left:2px}.nav-pills>li.active>a,.nav-pills>li.active>a:hover,.nav-pills>li.active>a:focus{color:#fff;background-color:#428bca}.nav-stacked>li{float:none}.nav-stacked>li+li{margin-top:2px;margin-left:0}.nav-justified{width:100%}.nav-justified>li{float:none}.nav-justified>li>a{margin-bottom:5px;text-align:center}.nav-justified>.dropdown .dropdown-menu{top:auto;left:auto}@media(min-width:768px){.nav-justified>li{display:table-cell;width:1%}.nav-justified>li>a{margin-bottom:0}}.nav-tabs-justified{border-bottom:0}.nav-tabs-justified>li>a{margin-right:0;border-radius:4px}.nav-tabs-justified>.active>a,.nav-tabs-justified>.active>a:hover,.nav-tabs-justified>.active>a:focus{border:1px solid #ddd}@media(min-width:768px){.nav-tabs-justified>li>a{border-bottom:1px solid #ddd;border-radius:4px 4px 0 0}.nav-tabs-justified>.active>a,.nav-tabs-justified>.active>a:hover,.nav-tabs-justified>.active>a:focus{border-bottom-color:#fff}}.tab-content>.tab-pane{display:none}.tab-content>.active{display:block}.nav-tabs .dropdown-menu{margin-top:-1px;border-top-right-radius:0;border-top-left-radius:0}.navbar{position:relative;min-height:50px;margin-bottom:20px;border:1px solid transparent}.navbar:before,.navbar:after{display:table;content:" "}.navbar:after{clear:both}.navbar:before,.navbar:after{display:table;content:" "}.navbar:after{clear:both}@media(min-width:768px){.navbar{border-radius:4px}}.navbar-header:before,.navbar-header:after{display:table;content:" "}.navbar-header:after{clear:both}.navbar-header:before,.navbar-header:after{display:table;content:" "}.navbar-header:after{clear:both}@media(min-width:768px){.navbar-header{float:left}}.navbar-collapse{max-height:340px;padding-right:15px;padding-left:15px;overflow-x:visible;border-top:1px solid transparent;box-shadow:inset 0 1px 0 rgba(255,255,255,0.1);-webkit-overflow-scrolling:touch}.navbar-collapse:before,.navbar-collapse:after{display:table;content:" "}.navbar-collapse:after{clear:both}.navbar-collapse:before,.navbar-collapse:after{display:table;content:" "}.navbar-collapse:after{clear:both}.navbar-collapse.in{overflow-y:auto}@media(min-width:768px){.navbar-collapse{width:auto;border-top:0;box-shadow:none}.navbar-collapse.collapse{display:block!important;height:auto!important;padding-bottom:0;overflow:visible!important}.navbar-collapse.in{overflow-y:visible}.navbar-fixed-top .navbar-collapse,.navbar-static-top .navbar-collapse,.navbar-fixed-bottom .navbar-collapse{padding-right:0;padding-left:0}}.container>.navbar-header,.container>.navbar-collapse{margin-right:-15px;margin-left:-15px}@media(min-width:768px){.container>.navbar-header,.container>.navbar-collapse{margin-right:0;margin-left:0}}.navbar-static-top{z-index:1000;border-width:0 0 1px}@media(min-width:768px){.navbar-static-top{border-radius:0}}.navbar-fixed-top,.navbar-fixed-bottom{position:fixed;right:0;left:0;z-index:1030}@media(min-width:768px){.navbar-fixed-top,.navbar-fixed-bottom{border-radius:0}}.navbar-fixed-top{top:0;border-width:0 0 1px}.navbar-fixed-bottom{bottom:0;margin-bottom:0;border-width:1px 0 0}.navbar-brand{float:left;padding:15px 15px;font-size:18px;line-height:20px}.navbar-brand:hover,.navbar-brand:focus{text-decoration:none}@media(min-width:768px){.navbar>.container .navbar-brand{margin-left:-15px}}.navbar-toggle{position:relative;float:right;padding:9px 10px;margin-top:8px;margin-right:15px;margin-bottom:8px;background-color:transparent;background-image:none;border:1px solid transparent;border-radius:4px}.navbar-toggle .icon-bar{display:block;width:22px;height:2px;border-radius:1px}.navbar-toggle .icon-bar+.icon-bar{margin-top:4px}@media(min-width:768px){.navbar-toggle{display:none}}.navbar-nav{margin:7.5px -15px}.navbar-nav>li>a{padding-top:10px;padding-bottom:10px;line-height:20px}@media(max-width:767px){.navbar-nav .open .dropdown-menu{position:static;float:none;width:auto;margin-top:0;background-color:transparent;border:0;box-shadow:none}.navbar-nav .open .dropdown-menu>li>a,.navbar-nav .open .dropdown-menu .dropdown-header{padding:5px 15px 5px 25px}.navbar-nav .open .dropdown-menu>li>a{line-height:20px}.navbar-nav .open .dropdown-menu>li>a:hover,.navbar-nav .open .dropdown-menu>li>a:focus{background-image:none}}@media(min-width:768px){.navbar-nav{float:left;margin:0}.navbar-nav>li{float:left}.navbar-nav>li>a{padding-top:15px;padding-bottom:15px}.navbar-nav.navbar-right:last-child{margin-right:-15px}}@media(min-width:768px){.navbar-left{float:left!important}.navbar-right{float:right!important}}.navbar-form{padding:10px 15px;margin-top:8px;margin-right:-15px;margin-bottom:8px;margin-left:-15px;border-top:1px solid transparent;border-bottom:1px solid transparent;-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,0.1),0 1px 0 rgba(255,255,255,0.1);box-shadow:inset 0 1px 0 rgba(255,255,255,0.1),0 1px 0 rgba(255,255,255,0.1)}@media(min-width:768px){.navbar-form .form-group{display:inline-block;margin-bottom:0;vertical-align:middle}.navbar-form .form-control{display:inline-block}.navbar-form select.form-control{width:auto}.navbar-form .radio,.navbar-form .checkbox{display:inline-block;padding-left:0;margin-top:0;margin-bottom:0}.navbar-form .radio input[type="radio"],.navbar-form .checkbox input[type="checkbox"]{float:none;margin-left:0}}@media(max-width:767px){.navbar-form .form-group{margin-bottom:5px}}@media(min-width:768px){.navbar-form{width:auto;padding-top:0;padding-bottom:0;margin-right:0;margin-left:0;border:0;-webkit-box-shadow:none;box-shadow:none}.navbar-form.navbar-right:last-child{margin-right:-15px}}.navbar-nav>li>.dropdown-menu{margin-top:0;border-top-right-radius:0;border-top-left-radius:0}.navbar-fixed-bottom .navbar-nav>li>.dropdown-menu{border-bottom-right-radius:0;border-bottom-left-radius:0}.navbar-nav.pull-right>li>.dropdown-menu,.navbar-nav>li>.dropdown-menu.pull-right{right:0;left:auto}.navbar-btn{margin-top:8px;margin-bottom:8px}.navbar-btn.btn-sm{margin-top:10px;margin-bottom:10px}.navbar-btn.btn-xs{margin-top:14px;margin-bottom:14px}.navbar-text{margin-top:15px;margin-bottom:15px}@media(min-width:768px){.navbar-text{float:left;margin-right:15px;margin-left:15px}.navbar-text.navbar-right:last-child{margin-right:0}}.navbar-default{background-color:#f8f8f8;border-color:#e7e7e7}.navbar-default .navbar-brand{color:#777}.navbar-default .navbar-brand:hover,.navbar-default .navbar-brand:focus{color:#5e5e5e;background-color:transparent}.navbar-default .navbar-text{color:#777}.navbar-default .navbar-nav>li>a{color:#777}.navbar-default .navbar-nav>li>a:hover,.navbar-default .navbar-nav>li>a:focus{color:#333;background-color:transparent}.navbar-default .navbar-nav>.active>a,.navbar-default .navbar-nav>.active>a:hover,.navbar-default .navbar-nav>.active>a:focus{color:#555;background-color:#e7e7e7}.navbar-default .navbar-nav>.disabled>a,.navbar-default .navbar-nav>.disabled>a:hover,.navbar-default .navbar-nav>.disabled>a:focus{color:#ccc;background-color:transparent}.navbar-default .navbar-toggle{border-color:#ddd}.navbar-default .navbar-toggle:hover,.navbar-default .navbar-toggle:focus{background-color:#ddd}.navbar-default .navbar-toggle .icon-bar{background-color:#ccc}.navbar-default .navbar-collapse,.navbar-default .navbar-form{border-color:#e7e7e7}.navbar-default .navbar-nav>.open>a,.navbar-default .navbar-nav>.open>a:hover,.navbar-default .navbar-nav>.open>a:focus{color:#555;background-color:#e7e7e7}@media(max-width:767px){.navbar-default .navbar-nav .open .dropdown-menu>li>a{color:#777}.navbar-default .navbar-nav .open .dropdown-menu>li>a:hover,.navbar-default .navbar-nav .open .dropdown-menu>li>a:focus{color:#333;background-color:transparent}.navbar-default .navbar-nav .open .dropdown-menu>.active>a,.navbar-default .navbar-nav .open .dropdown-menu>.active>a:hover,.navbar-default .navbar-nav .open .dropdown-menu>.active>a:focus{color:#555;background-color:#e7e7e7}.navbar-default .navbar-nav .open .dropdown-menu>.disabled>a,.navbar-default .navbar-nav .open .dropdown-menu>.disabled>a:hover,.navbar-default .navbar-nav .open .dropdown-menu>.disabled>a:focus{color:#ccc;background-color:transparent}}.navbar-default .navbar-link{color:#777}.navbar-default .navbar-link:hover{color:#333}.navbar-inverse{background-color:#222;border-color:#080808}.navbar-inverse .navbar-brand{color:#999}.navbar-inverse .navbar-brand:hover,.navbar-inverse .navbar-brand:focus{color:#fff;background-color:transparent}.navbar-inverse .navbar-text{color:#999}.navbar-inverse .navbar-nav>li>a{color:#999}.navbar-inverse .navbar-nav>li>a:hover,.navbar-inverse .navbar-nav>li>a:focus{color:#fff;background-color:transparent}.navbar-inverse .navbar-nav>.active>a,.navbar-inverse .navbar-nav>.active>a:hover,.navbar-inverse .navbar-nav>.active>a:focus{color:#fff;background-color:#080808}.navbar-inverse .navbar-nav>.disabled>a,.navbar-inverse .navbar-nav>.disabled>a:hover,.navbar-inverse .navbar-nav>.disabled>a:focus{color:#444;background-color:transparent}.navbar-inverse .navbar-toggle{border-color:#333}.navbar-inverse .navbar-toggle:hover,.navbar-inverse .navbar-toggle:focus{background-color:#333}.navbar-inverse .navbar-toggle .icon-bar{background-color:#fff}.navbar-inverse .navbar-collapse,.navbar-inverse .navbar-form{border-color:#101010}.navbar-inverse .navbar-nav>.open>a,.navbar-inverse .navbar-nav>.open>a:hover,.navbar-inverse .navbar-nav>.open>a:focus{color:#fff;background-color:#080808}@media(max-width:767px){.navbar-inverse .navbar-nav .open .dropdown-menu>.dropdown-header{border-color:#080808}.navbar-inverse .navbar-nav .open .dropdown-menu .divider{background-color:#080808}.navbar-inverse .navbar-nav .open .dropdown-menu>li>a{color:#999}.navbar-inverse .navbar-nav .open .dropdown-menu>li>a:hover,.navbar-inverse .navbar-nav .open .dropdown-menu>li>a:focus{color:#fff;background-color:transparent}.navbar-inverse .navbar-nav .open .dropdown-menu>.active>a,.navbar-inverse .navbar-nav .open .dropdown-menu>.active>a:hover,.navbar-inverse .navbar-nav .open .dropdown-menu>.active>a:focus{color:#fff;background-color:#080808}.navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a,.navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a:hover,.navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a:focus{color:#444;background-color:transparent}}.navbar-inverse .navbar-link{color:#999}.navbar-inverse .navbar-link:hover{color:#fff}.breadcrumb{padding:8px 15px;margin-bottom:20px;list-style:none;background-color:#f5f5f5;border-radius:4px}.breadcrumb>li{display:inline-block}.breadcrumb>li+li:before{padding:0 5px;color:#ccc;content:"/\00a0"}.breadcrumb>.active{color:#999}.pagination{display:inline-block;padding-left:0;margin:20px 0;border-radius:4px}.pagination>li{display:inline}.pagination>li>a,.pagination>li>span{position:relative;float:left;padding:6px 12px;margin-left:-1px;line-height:1.428571429;text-decoration:none;background-color:#fff;border:1px solid #ddd}.pagination>li:first-child>a,.pagination>li:first-child>span{margin-left:0;border-bottom-left-radius:4px;border-top-left-radius:4px}.pagination>li:last-child>a,.pagination>li:last-child>span{border-top-right-radius:4px;border-bottom-right-radius:4px}.pagination>li>a:hover,.pagination>li>span:hover,.pagination>li>a:focus,.pagination>li>span:focus{background-color:#eee}.pagination>.active>a,.pagination>.active>span,.pagination>.active>a:hover,.pagination>.active>span:hover,.pagination>.active>a:focus,.pagination>.active>span:focus{z-index:2;color:#fff;cursor:default;background-color:#428bca;border-color:#428bca}.pagination>.disabled>span,.pagination>.disabled>span:hover,.pagination>.disabled>span:focus,.pagination>.disabled>a,.pagination>.disabled>a:hover,.pagination>.disabled>a:focus{color:#999;cursor:not-allowed;background-color:#fff;border-color:#ddd}.pagination-lg>li>a,.pagination-lg>li>span{padding:10px 16px;font-size:18px}.pagination-lg>li:first-child>a,.pagination-lg>li:first-child>span{border-bottom-left-radius:6px;border-top-left-radius:6px}.pagination-lg>li:last-child>a,.pagination-lg>li:last-child>span{border-top-right-radius:6px;border-bottom-right-radius:6px}.pagination-sm>li>a,.pagination-sm>li>span{padding:5px 10px;font-size:12px}.pagination-sm>li:first-child>a,.pagination-sm>li:first-child>span{border-bottom-left-radius:3px;border-top-left-radius:3px}.pagination-sm>li:last-child>a,.pagination-sm>li:last-child>span{border-top-right-radius:3px;border-bottom-right-radius:3px}.pager{padding-left:0;margin:20px 0;text-align:center;list-style:none}.pager:before,.pager:after{display:table;content:" "}.pager:after{clear:both}.pager:before,.pager:after{display:table;content:" "}.pager:after{clear:both}.pager li{display:inline}.pager li>a,.pager li>span{display:inline-block;padding:5px 14px;background-color:#fff;border:1px solid #ddd;border-radius:15px}.pager li>a:hover,.pager li>a:focus{text-decoration:none;background-color:#eee}.pager .next>a,.pager .next>span{float:right}.pager .previous>a,.pager .previous>span{float:left}.pager .disabled>a,.pager .disabled>a:hover,.pager .disabled>a:focus,.pager .disabled>span{color:#999;cursor:not-allowed;background-color:#fff}.label{display:inline;padding:.2em .6em .3em;font-size:75%;font-weight:bold;line-height:1;color:#fff;text-align:center;white-space:nowrap;vertical-align:baseline;border-radius:.25em}.label[href]:hover,.label[href]:focus{color:#fff;text-decoration:none;cursor:pointer}.label:empty{display:none}.btn .label{position:relative;top:-1px}.label-default{background-color:#999}.label-default[href]:hover,.label-default[href]:focus{background-color:#808080}.label-primary{background-color:#428bca}.label-primary[href]:hover,.label-primary[href]:focus{background-color:#3071a9}.label-success{background-color:#5cb85c}.label-success[href]:hover,.label-success[href]:focus{background-color:#449d44}.label-info{background-color:#5bc0de}.label-info[href]:hover,.label-info[href]:focus{background-color:#31b0d5}.label-warning{background-color:#f0ad4e}.label-warning[href]:hover,.label-warning[href]:focus{background-color:#ec971f}.label-danger{background-color:#d9534f}.label-danger[href]:hover,.label-danger[href]:focus{background-color:#c9302c}.badge{display:inline-block;min-width:10px;padding:3px 7px;font-size:12px;font-weight:bold;line-height:1;color:#fff;text-align:center;white-space:nowrap;vertical-align:baseline;background-color:#999;border-radius:10px}.badge:empty{display:none}.btn .badge{position:relative;top:-1px}a.badge:hover,a.badge:focus{color:#fff;text-decoration:none;cursor:pointer}a.list-group-item.active>.badge,.nav-pills>.active>a>.badge{color:#428bca;background-color:#fff}.nav-pills>li>a>.badge{margin-left:3px}.jumbotron{padding:30px;margin-bottom:30px;font-size:21px;font-weight:200;line-height:2.1428571435;color:inherit;background-color:#eee}.jumbotron h1,.jumbotron .h1{line-height:1;color:inherit}.jumbotron p{line-height:1.4}.container .jumbotron{border-radius:6px}.jumbotron .container{max-width:100%}@media screen and (min-width:768px){.jumbotron{padding-top:48px;padding-bottom:48px}.container .jumbotron{padding-right:60px;padding-left:60px}.jumbotron h1,.jumbotron .h1{font-size:63px}}.thumbnail{display:block;padding:4px;margin-bottom:20px;line-height:1.428571429;background-color:#fff;border:1px solid #ddd;border-radius:4px;-webkit-transition:all .2s ease-in-out;transition:all .2s ease-in-out}.thumbnail>img,.thumbnail a>img{display:block;height:auto;max-width:100%;margin-right:auto;margin-left:auto}a.thumbnail:hover,a.thumbnail:focus,a.thumbnail.active{border-color:#428bca}.thumbnail .caption{padding:9px;color:#333}.alert{padding:15px;margin-bottom:20px;border:1px solid transparent;border-radius:4px}.alert h4{margin-top:0;color:inherit}.alert .alert-link{font-weight:bold}.alert>p,.alert>ul{margin-bottom:0}.alert>p+p{margin-top:5px}.alert-dismissable{padding-right:35px}.alert-dismissable .close{position:relative;top:-2px;right:-21px;color:inherit}.alert-success{color:#3c763d;background-color:#dff0d8;border-color:#d6e9c6}.alert-success hr{border-top-color:#c9e2b3}.alert-success .alert-link{color:#2b542c}.alert-info{color:#31708f;background-color:#d9edf7;border-color:#bce8f1}.alert-info hr{border-top-color:#a6e1ec}.alert-info .alert-link{color:#245269}.alert-warning{color:#8a6d3b;background-color:#fcf8e3;border-color:#faebcc}.alert-warning hr{border-top-color:#f7e1b5}.alert-warning .alert-link{color:#66512c}.alert-danger{color:#a94442;background-color:#f2dede;border-color:#ebccd1}.alert-danger hr{border-top-color:#e4b9c0}.alert-danger .alert-link{color:#843534}@-webkit-keyframes progress-bar-stripes{from{background-position:40px 0}to{background-position:0 0}}@keyframes progress-bar-stripes{from{background-position:40px 0}to{background-position:0 0}}.progress{height:20px;margin-bottom:20px;overflow:hidden;background-color:#f5f5f5;border-radius:4px;-webkit-box-shadow:inset 0 1px 2px rgba(0,0,0,0.1);box-shadow:inset 0 1px 2px rgba(0,0,0,0.1)}.progress-bar{float:left;width:0;height:100%;font-size:12px;line-height:20px;color:#fff;text-align:center;background-color:#428bca;-webkit-box-shadow:inset 0 -1px 0 rgba(0,0,0,0.15);box-shadow:inset 0 -1px 0 rgba(0,0,0,0.15);-webkit-transition:width .6s ease;transition:width .6s ease}.progress-striped .progress-bar{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,0.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,0.15) 50%,rgba(255,255,255,0.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,0.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,0.15) 50%,rgba(255,255,255,0.15) 75%,transparent 75%,transparent);background-size:40px 40px}.progress.active .progress-bar{-webkit-animation:progress-bar-stripes 2s linear infinite;animation:progress-bar-stripes 2s linear infinite}.progress-bar-success{background-color:#5cb85c}.progress-striped .progress-bar-success{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,0.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,0.15) 50%,rgba(255,255,255,0.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,0.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,0.15) 50%,rgba(255,255,255,0.15) 75%,transparent 75%,transparent)}.progress-bar-info{background-color:#5bc0de}.progress-striped .progress-bar-info{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,0.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,0.15) 50%,rgba(255,255,255,0.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,0.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,0.15) 50%,rgba(255,255,255,0.15) 75%,transparent 75%,transparent)}.progress-bar-warning{background-color:#f0ad4e}.progress-striped .progress-bar-warning{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,0.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,0.15) 50%,rgba(255,255,255,0.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,0.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,0.15) 50%,rgba(255,255,255,0.15) 75%,transparent 75%,transparent)}.progress-bar-danger{background-color:#d9534f}.progress-striped .progress-bar-danger{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,0.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,0.15) 50%,rgba(255,255,255,0.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,0.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,0.15) 50%,rgba(255,255,255,0.15) 75%,transparent 75%,transparent)}.media,.media-body{overflow:hidden;zoom:1}.media,.media .media{margin-top:15px}.media:first-child{margin-top:0}.media-object{display:block}.media-heading{margin:0 0 5px}.media>.pull-left{margin-right:10px}.media>.pull-right{margin-left:10px}.media-list{padding-left:0;list-style:none}.list-group{padding-left:0;margin-bottom:20px}.list-group-item{position:relative;display:block;padding:10px 15px;margin-bottom:-1px;background-color:#fff;border:1px solid #ddd}.list-group-item:first-child{border-top-right-radius:4px;border-top-left-radius:4px}.list-group-item:last-child{margin-bottom:0;border-bottom-right-radius:4px;border-bottom-left-radius:4px}.list-group-item>.badge{float:right}.list-group-item>.badge+.badge{margin-right:5px}a.list-group-item{color:#555}a.list-group-item .list-group-item-heading{color:#333}a.list-group-item:hover,a.list-group-item:focus{text-decoration:none;background-color:#f5f5f5}a.list-group-item.active,a.list-group-item.active:hover,a.list-group-item.active:focus{z-index:2;color:#fff;background-color:#428bca;border-color:#428bca}a.list-group-item.active .list-group-item-heading,a.list-group-item.active:hover .list-group-item-heading,a.list-group-item.active:focus .list-group-item-heading{color:inherit}a.list-group-item.active .list-group-item-text,a.list-group-item.active:hover .list-group-item-text,a.list-group-item.active:focus .list-group-item-text{color:#e1edf7}.list-group-item-heading{margin-top:0;margin-bottom:5px}.list-group-item-text{margin-bottom:0;line-height:1.3}.panel{margin-bottom:20px;background-color:#fff;border:1px solid transparent;border-radius:4px;-webkit-box-shadow:0 1px 1px rgba(0,0,0,0.05);box-shadow:0 1px 1px rgba(0,0,0,0.05)}.panel-body{padding:15px}.panel-body:before,.panel-body:after{display:table;content:" "}.panel-body:after{clear:both}.panel-body:before,.panel-body:after{display:table;content:" "}.panel-body:after{clear:both}.panel>.list-group{margin-bottom:0}.panel>.list-group .list-group-item{border-width:1px 0}.panel>.list-group .list-group-item:first-child{border-top-right-radius:0;border-top-left-radius:0}.panel>.list-group .list-group-item:last-child{border-bottom:0}.panel-heading+.list-group .list-group-item:first-child{border-top-width:0}.panel>.table,.panel>.table-responsive>.table{margin-bottom:0}.panel>.panel-body+.table,.panel>.panel-body+.table-responsive{border-top:1px solid #ddd}.panel>.table>tbody:first-child th,.panel>.table>tbody:first-child td{border-top:0}.panel>.table-bordered,.panel>.table-responsive>.table-bordered{border:0}.panel>.table-bordered>thead>tr>th:first-child,.panel>.table-responsive>.table-bordered>thead>tr>th:first-child,.panel>.table-bordered>tbody>tr>th:first-child,.panel>.table-responsive>.table-bordered>tbody>tr>th:first-child,.panel>.table-bordered>tfoot>tr>th:first-child,.panel>.table-responsive>.table-bordered>tfoot>tr>th:first-child,.panel>.table-bordered>thead>tr>td:first-child,.panel>.table-responsive>.table-bordered>thead>tr>td:first-child,.panel>.table-bordered>tbody>tr>td:first-child,.panel>.table-responsive>.table-bordered>tbody>tr>td:first-child,.panel>.table-bordered>tfoot>tr>td:first-child,.panel>.table-responsive>.table-bordered>tfoot>tr>td:first-child{border-left:0}.panel>.table-bordered>thead>tr>th:last-child,.panel>.table-responsive>.table-bordered>thead>tr>th:last-child,.panel>.table-bordered>tbody>tr>th:last-child,.panel>.table-responsive>.table-bordered>tbody>tr>th:last-child,.panel>.table-bordered>tfoot>tr>th:last-child,.panel>.table-responsive>.table-bordered>tfoot>tr>th:last-child,.panel>.table-bordered>thead>tr>td:last-child,.panel>.table-responsive>.table-bordered>thead>tr>td:last-child,.panel>.table-bordered>tbody>tr>td:last-child,.panel>.table-responsive>.table-bordered>tbody>tr>td:last-child,.panel>.table-bordered>tfoot>tr>td:last-child,.panel>.table-responsive>.table-bordered>tfoot>tr>td:last-child{border-right:0}.panel>.table-bordered>thead>tr:last-child>th,.panel>.table-responsive>.table-bordered>thead>tr:last-child>th,.panel>.table-bordered>tbody>tr:last-child>th,.panel>.table-responsive>.table-bordered>tbody>tr:last-child>th,.panel>.table-bordered>tfoot>tr:last-child>th,.panel>.table-responsive>.table-bordered>tfoot>tr:last-child>th,.panel>.table-bordered>thead>tr:last-child>td,.panel>.table-responsive>.table-bordered>thead>tr:last-child>td,.panel>.table-bordered>tbody>tr:last-child>td,.panel>.table-responsive>.table-bordered>tbody>tr:last-child>td,.panel>.table-bordered>tfoot>tr:last-child>td,.panel>.table-responsive>.table-bordered>tfoot>tr:last-child>td{border-bottom:0}.panel>.table-responsive{margin-bottom:0;border:0}.panel-heading{padding:10px 15px;border-bottom:1px solid transparent;border-top-right-radius:3px;border-top-left-radius:3px}.panel-heading>.dropdown .dropdown-toggle{color:inherit}.panel-title{margin-top:0;margin-bottom:0;font-size:16px;color:inherit}.panel-title>a{color:inherit}.panel-footer{padding:10px 15px;background-color:#f5f5f5;border-top:1px solid #ddd;border-bottom-right-radius:3px;border-bottom-left-radius:3px}.panel-group .panel{margin-bottom:0;overflow:hidden;border-radius:4px}.panel-group .panel+.panel{margin-top:5px}.panel-group .panel-heading{border-bottom:0}.panel-group .panel-heading+.panel-collapse .panel-body{border-top:1px solid #ddd}.panel-group .panel-footer{border-top:0}.panel-group .panel-footer+.panel-collapse .panel-body{border-bottom:1px solid #ddd}.panel-default{border-color:#ddd}.panel-default>.panel-heading{color:#333;background-color:#f5f5f5;border-color:#ddd}.panel-default>.panel-heading+.panel-collapse .panel-body{border-top-color:#ddd}.panel-default>.panel-footer+.panel-collapse .panel-body{border-bottom-color:#ddd}.panel-primary{border-color:#428bca}.panel-primary>.panel-heading{color:#fff;background-color:#428bca;border-color:#428bca}.panel-primary>.panel-heading+.panel-collapse .panel-body{border-top-color:#428bca}.panel-primary>.panel-footer+.panel-collapse .panel-body{border-bottom-color:#428bca}.panel-success{border-color:#d6e9c6}.panel-success>.panel-heading{color:#3c763d;background-color:#dff0d8;border-color:#d6e9c6}.panel-success>.panel-heading+.panel-collapse .panel-body{border-top-color:#d6e9c6}.panel-success>.panel-footer+.panel-collapse .panel-body{border-bottom-color:#d6e9c6}.panel-warning{border-color:#faebcc}.panel-warning>.panel-heading{color:#8a6d3b;background-color:#fcf8e3;border-color:#faebcc}.panel-warning>.panel-heading+.panel-collapse .panel-body{border-top-color:#faebcc}.panel-warning>.panel-footer+.panel-collapse .panel-body{border-bottom-color:#faebcc}.panel-danger{border-color:#ebccd1}.panel-danger>.panel-heading{color:#a94442;background-color:#f2dede;border-color:#ebccd1}.panel-danger>.panel-heading+.panel-collapse .panel-body{border-top-color:#ebccd1}.panel-danger>.panel-footer+.panel-collapse .panel-body{border-bottom-color:#ebccd1}.panel-info{border-color:#bce8f1}.panel-info>.panel-heading{color:#31708f;background-color:#d9edf7;border-color:#bce8f1}.panel-info>.panel-heading+.panel-collapse .panel-body{border-top-color:#bce8f1}.panel-info>.panel-footer+.panel-collapse .panel-body{border-bottom-color:#bce8f1}.well{min-height:20px;padding:19px;margin-bottom:20px;background-color:#f5f5f5;border:1px solid #e3e3e3;border-radius:4px;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,0.05);box-shadow:inset 0 1px 1px rgba(0,0,0,0.05)}.well blockquote{border-color:#ddd;border-color:rgba(0,0,0,0.15)}.well-lg{padding:24px;border-radius:6px}.well-sm{padding:9px;border-radius:3px}.close{float:right;font-size:21px;font-weight:bold;line-height:1;color:#000;text-shadow:0 1px 0 #fff;opacity:.2;filter:alpha(opacity=20)}.close:hover,.close:focus{color:#000;text-decoration:none;cursor:pointer;opacity:.5;filter:alpha(opacity=50)}button.close{padding:0;cursor:pointer;background:transparent;border:0;-webkit-appearance:none}.modal-open{overflow:hidden}.modal{position:fixed;top:0;right:0;bottom:0;left:0;z-index:1040;display:none;overflow:auto;overflow-y:scroll}.modal.fade .modal-dialog{-webkit-transform:translate(0,-25%);-ms-transform:translate(0,-25%);transform:translate(0,-25%);-webkit-transition:-webkit-transform .3s ease-out;-moz-transition:-moz-transform .3s ease-out;-o-transition:-o-transform .3s ease-out;transition:transform .3s ease-out}.modal.in .modal-dialog{-webkit-transform:translate(0,0);-ms-transform:translate(0,0);transform:translate(0,0)}.modal-dialog{position:relative;z-index:1050;width:auto;margin:10px}.modal-content{position:relative;background-color:#fff;border:1px solid #999;border:1px solid rgba(0,0,0,0.2);border-radius:6px;outline:0;-webkit-box-shadow:0 3px 9px rgba(0,0,0,0.5);box-shadow:0 3px 9px rgba(0,0,0,0.5);background-clip:padding-box}.modal-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;z-index:1030;background-color:#000}.modal-backdrop.fade{opacity:0;filter:alpha(opacity=0)}.modal-backdrop.in{opacity:.5;filter:alpha(opacity=50)}.modal-header{min-height:16.428571429px;padding:15px;border-bottom:1px solid #e5e5e5}.modal-header .close{margin-top:-2px}.modal-title{margin:0;line-height:1.428571429}.modal-body{position:relative;padding:20px}.modal-footer{padding:19px 20px 20px;margin-top:15px;text-align:right;border-top:1px solid #e5e5e5}.modal-footer:before,.modal-footer:after{display:table;content:" "}.modal-footer:after{clear:both}.modal-footer:before,.modal-footer:after{display:table;content:" "}.modal-footer:after{clear:both}.modal-footer .btn+.btn{margin-bottom:0;margin-left:5px}.modal-footer .btn-group .btn+.btn{margin-left:-1px}.modal-footer .btn-block+.btn-block{margin-left:0}@media screen and (min-width:768px){.modal-dialog{width:600px;margin:30px auto}.modal-content{-webkit-box-shadow:0 5px 15px rgba(0,0,0,0.5);box-shadow:0 5px 15px rgba(0,0,0,0.5)}}.tooltip{position:absolute;z-index:1030;display:block;font-size:12px;line-height:1.4;opacity:0;filter:alpha(opacity=0);visibility:visible}.tooltip.in{opacity:.9;filter:alpha(opacity=90)}.tooltip.top{padding:5px 0;margin-top:-3px}.tooltip.right{padding:0 5px;margin-left:3px}.tooltip.bottom{padding:5px 0;margin-top:3px}.tooltip.left{padding:0 5px;margin-left:-3px}.tooltip-inner{max-width:200px;padding:3px 8px;color:#fff;text-align:center;text-decoration:none;background-color:#000;border-radius:4px}.tooltip-arrow{position:absolute;width:0;height:0;border-color:transparent;border-style:solid}.tooltip.top .tooltip-arrow{bottom:0;left:50%;margin-left:-5px;border-top-color:#000;border-width:5px 5px 0}.tooltip.top-left .tooltip-arrow{bottom:0;left:5px;border-top-color:#000;border-width:5px 5px 0}.tooltip.top-right .tooltip-arrow{right:5px;bottom:0;border-top-color:#000;border-width:5px 5px 0}.tooltip.right .tooltip-arrow{top:50%;left:0;margin-top:-5px;border-right-color:#000;border-width:5px 5px 5px 0}.tooltip.left .tooltip-arrow{top:50%;right:0;margin-top:-5px;border-left-color:#000;border-width:5px 0 5px 5px}.tooltip.bottom .tooltip-arrow{top:0;left:50%;margin-left:-5px;border-bottom-color:#000;border-width:0 5px 5px}.tooltip.bottom-left .tooltip-arrow{top:0;left:5px;border-bottom-color:#000;border-width:0 5px 5px}.tooltip.bottom-right .tooltip-arrow{top:0;right:5px;border-bottom-color:#000;border-width:0 5px 5px}.popover{position:absolute;top:0;left:0;z-index:1010;display:none;max-width:276px;padding:1px;text-align:left;white-space:normal;background-color:#fff;border:1px solid #ccc;border:1px solid rgba(0,0,0,0.2);border-radius:6px;-webkit-box-shadow:0 5px 10px rgba(0,0,0,0.2);box-shadow:0 5px 10px rgba(0,0,0,0.2);background-clip:padding-box}.popover.top{margin-top:-10px}.popover.right{margin-left:10px}.popover.bottom{margin-top:10px}.popover.left{margin-left:-10px}.popover-title{padding:8px 14px;margin:0;font-size:14px;font-weight:normal;line-height:18px;background-color:#f7f7f7;border-bottom:1px solid #ebebeb;border-radius:5px 5px 0 0}.popover-content{padding:9px 14px}.popover .arrow,.popover .arrow:after{position:absolute;display:block;width:0;height:0;border-color:transparent;border-style:solid}.popover .arrow{border-width:11px}.popover .arrow:after{border-width:10px;content:""}.popover.top .arrow{bottom:-11px;left:50%;margin-left:-11px;border-top-color:#999;border-top-color:rgba(0,0,0,0.25);border-bottom-width:0}.popover.top .arrow:after{bottom:1px;margin-left:-10px;border-top-color:#fff;border-bottom-width:0;content:" "}.popover.right .arrow{top:50%;left:-11px;margin-top:-11px;border-right-color:#999;border-right-color:rgba(0,0,0,0.25);border-left-width:0}.popover.right .arrow:after{bottom:-10px;left:1px;border-right-color:#fff;border-left-width:0;content:" "}.popover.bottom .arrow{top:-11px;left:50%;margin-left:-11px;border-bottom-color:#999;border-bottom-color:rgba(0,0,0,0.25);border-top-width:0}.popover.bottom .arrow:after{top:1px;margin-left:-10px;border-bottom-color:#fff;border-top-width:0;content:" "}.popover.left .arrow{top:50%;right:-11px;margin-top:-11px;border-left-color:#999;border-left-color:rgba(0,0,0,0.25);border-right-width:0}.popover.left .arrow:after{right:1px;bottom:-10px;border-left-color:#fff;border-right-width:0;content:" "}.carousel{position:relative}.carousel-inner{position:relative;width:100%;overflow:hidden}.carousel-inner>.item{position:relative;display:none;-webkit-transition:.6s ease-in-out left;transition:.6s ease-in-out left}.carousel-inner>.item>img,.carousel-inner>.item>a>img{display:block;height:auto;max-width:100%;line-height:1}.carousel-inner>.active,.carousel-inner>.next,.carousel-inner>.prev{display:block}.carousel-inner>.active{left:0}.carousel-inner>.next,.carousel-inner>.prev{position:absolute;top:0;width:100%}.carousel-inner>.next{left:100%}.carousel-inner>.prev{left:-100%}.carousel-inner>.next.left,.carousel-inner>.prev.right{left:0}.carousel-inner>.active.left{left:-100%}.carousel-inner>.active.right{left:100%}.carousel-control{position:absolute;top:0;bottom:0;left:0;width:15%;font-size:20px;color:#fff;text-align:center;text-shadow:0 1px 2px rgba(0,0,0,0.6);opacity:.5;filter:alpha(opacity=50)}.carousel-control.left{background-image:-webkit-linear-gradient(left,color-stop(rgba(0,0,0,0.5) 0),color-stop(rgba(0,0,0,0.0001) 100%));background-image:linear-gradient(to right,rgba(0,0,0,0.5) 0,rgba(0,0,0,0.0001) 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#80000000',endColorstr='#00000000',GradientType=1)}.carousel-control.right{right:0;left:auto;background-image:-webkit-linear-gradient(left,color-stop(rgba(0,0,0,0.0001) 0),color-stop(rgba(0,0,0,0.5) 100%));background-image:linear-gradient(to right,rgba(0,0,0,0.0001) 0,rgba(0,0,0,0.5) 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#00000000',endColorstr='#80000000',GradientType=1)}.carousel-control:hover,.carousel-control:focus{color:#fff;text-decoration:none;outline:0;opacity:.9;filter:alpha(opacity=90)}.carousel-control .icon-prev,.carousel-control .icon-next,.carousel-control .glyphicon-chevron-left,.carousel-control .glyphicon-chevron-right{position:absolute;top:50%;z-index:5;display:inline-block}.carousel-control .icon-prev,.carousel-control .glyphicon-chevron-left{left:50%}.carousel-control .icon-next,.carousel-control .glyphicon-chevron-right{right:50%}.carousel-control .icon-prev,.carousel-control .icon-next{width:20px;height:20px;margin-top:-10px;margin-left:-10px;font-family:serif}.carousel-control .icon-prev:before{content:'\2039'}.carousel-control .icon-next:before{content:'\203a'}.carousel-indicators{position:absolute;bottom:10px;left:50%;z-index:15;width:60%;padding-left:0;margin-left:-30%;text-align:center;list-style:none}.carousel-indicators li{display:inline-block;width:10px;height:10px;margin:1px;text-indent:-999px;cursor:pointer;background-color:#000 \9;background-color:rgba(0,0,0,0);border:1px solid #fff;border-radius:10px}.carousel-indicators .active{width:12px;height:12px;margin:0;background-color:#fff}.carousel-caption{position:absolute;right:15%;bottom:20px;left:15%;z-index:10;padding-top:20px;padding-bottom:20px;color:#fff;text-align:center;text-shadow:0 1px 2px rgba(0,0,0,0.6)}.carousel-caption .btn{text-shadow:none}@media screen and (min-width:768px){.carousel-control .glyphicons-chevron-left,.carousel-control .glyphicons-chevron-right,.carousel-control .icon-prev,.carousel-control .icon-next{width:30px;height:30px;margin-top:-15px;margin-left:-15px;font-size:30px}.carousel-caption{right:20%;left:20%;padding-bottom:30px}.carousel-indicators{bottom:20px}}.clearfix:before,.clearfix:after{display:table;content:" "}.clearfix:after{clear:both}.center-block{display:block;margin-right:auto;margin-left:auto}.pull-right{float:right!important}.pull-left{float:left!important}.hide{display:none!important}.show{display:block!important}.invisible{visibility:hidden}.text-hide{font:0/0 a;color:transparent;text-shadow:none;background-color:transparent;border:0}.hidden{display:none!important;visibility:hidden!important}.affix{position:fixed}@-ms-viewport{width:device-width}.visible-xs,tr.visible-xs,th.visible-xs,td.visible-xs{display:none!important}@media(max-width:767px){.visible-xs{display:block!important}table.visible-xs{display:table}tr.visible-xs{display:table-row!important}th.visible-xs,td.visible-xs{display:table-cell!important}}@media(min-width:768px) and (max-width:991px){.visible-xs.visible-sm{display:block!important}table.visible-xs.visible-sm{display:table}tr.visible-xs.visible-sm{display:table-row!important}th.visible-xs.visible-sm,td.visible-xs.visible-sm{display:table-cell!important}}@media(min-width:992px) and (max-width:1199px){.visible-xs.visible-md{display:block!important}table.visible-xs.visible-md{display:table}tr.visible-xs.visible-md{display:table-row!important}th.visible-xs.visible-md,td.visible-xs.visible-md{display:table-cell!important}}@media(min-width:1200px){.visible-xs.visible-lg{display:block!important}table.visible-xs.visible-lg{display:table}tr.visible-xs.visible-lg{display:table-row!important}th.visible-xs.visible-lg,td.visible-xs.visible-lg{display:table-cell!important}}.visible-sm,tr.visible-sm,th.visible-sm,td.visible-sm{display:none!important}@media(max-width:767px){.visible-sm.visible-xs{display:block!important}table.visible-sm.visible-xs{display:table}tr.visible-sm.visible-xs{display:table-row!important}th.visible-sm.visible-xs,td.visible-sm.visible-xs{display:table-cell!important}}@media(min-width:768px) and (max-width:991px){.visible-sm{display:block!important}table.visible-sm{display:table}tr.visible-sm{display:table-row!important}th.visible-sm,td.visible-sm{display:table-cell!important}}@media(min-width:992px) and (max-width:1199px){.visible-sm.visible-md{display:block!important}table.visible-sm.visible-md{display:table}tr.visible-sm.visible-md{display:table-row!important}th.visible-sm.visible-md,td.visible-sm.visible-md{display:table-cell!important}}@media(min-width:1200px){.visible-sm.visible-lg{display:block!important}table.visible-sm.visible-lg{display:table}tr.visible-sm.visible-lg{display:table-row!important}th.visible-sm.visible-lg,td.visible-sm.visible-lg{display:table-cell!important}}.visible-md,tr.visible-md,th.visible-md,td.visible-md{display:none!important}@media(max-width:767px){.visible-md.visible-xs{display:block!important}table.visible-md.visible-xs{display:table}tr.visible-md.visible-xs{display:table-row!important}th.visible-md.visible-xs,td.visible-md.visible-xs{display:table-cell!important}}@media(min-width:768px) and (max-width:991px){.visible-md.visible-sm{display:block!important}table.visible-md.visible-sm{display:table}tr.visible-md.visible-sm{display:table-row!important}th.visible-md.visible-sm,td.visible-md.visible-sm{display:table-cell!important}}@media(min-width:992px) and (max-width:1199px){.visible-md{display:block!important}table.visible-md{display:table}tr.visible-md{display:table-row!important}th.visible-md,td.visible-md{display:table-cell!important}}@media(min-width:1200px){.visible-md.visible-lg{display:block!important}table.visible-md.visible-lg{display:table}tr.visible-md.visible-lg{display:table-row!important}th.visible-md.visible-lg,td.visible-md.visible-lg{display:table-cell!important}}.visible-lg,tr.visible-lg,th.visible-lg,td.visible-lg{display:none!important}@media(max-width:767px){.visible-lg.visible-xs{display:block!important}table.visible-lg.visible-xs{display:table}tr.visible-lg.visible-xs{display:table-row!important}th.visible-lg.visible-xs,td.visible-lg.visible-xs{display:table-cell!important}}@media(min-width:768px) and (max-width:991px){.visible-lg.visible-sm{display:block!important}table.visible-lg.visible-sm{display:table}tr.visible-lg.visible-sm{display:table-row!important}th.visible-lg.visible-sm,td.visible-lg.visible-sm{display:table-cell!important}}@media(min-width:992px) and (max-width:1199px){.visible-lg.visible-md{display:block!important}table.visible-lg.visible-md{display:table}tr.visible-lg.visible-md{display:table-row!important}th.visible-lg.visible-md,td.visible-lg.visible-md{display:table-cell!important}}@media(min-width:1200px){.visible-lg{display:block!important}table.visible-lg{display:table}tr.visible-lg{display:table-row!important}th.visible-lg,td.visible-lg{display:table-cell!important}}.hidden-xs{display:block!important}table.hidden-xs{display:table}tr.hidden-xs{display:table-row!important}th.hidden-xs,td.hidden-xs{display:table-cell!important}@media(max-width:767px){.hidden-xs,tr.hidden-xs,th.hidden-xs,td.hidden-xs{display:none!important}}@media(min-width:768px) and (max-width:991px){.hidden-xs.hidden-sm,tr.hidden-xs.hidden-sm,th.hidden-xs.hidden-sm,td.hidden-xs.hidden-sm{display:none!important}}@media(min-width:992px) and (max-width:1199px){.hidden-xs.hidden-md,tr.hidden-xs.hidden-md,th.hidden-xs.hidden-md,td.hidden-xs.hidden-md{display:none!important}}@media(min-width:1200px){.hidden-xs.hidden-lg,tr.hidden-xs.hidden-lg,th.hidden-xs.hidden-lg,td.hidden-xs.hidden-lg{display:none!important}}.hidden-sm{display:block!important}table.hidden-sm{display:table}tr.hidden-sm{display:table-row!important}th.hidden-sm,td.hidden-sm{display:table-cell!important}@media(max-width:767px){.hidden-sm.hidden-xs,tr.hidden-sm.hidden-xs,th.hidden-sm.hidden-xs,td.hidden-sm.hidden-xs{display:none!important}}@media(min-width:768px) and (max-width:991px){.hidden-sm,tr.hidden-sm,th.hidden-sm,td.hidden-sm{display:none!important}}@media(min-width:992px) and (max-width:1199px){.hidden-sm.hidden-md,tr.hidden-sm.hidden-md,th.hidden-sm.hidden-md,td.hidden-sm.hidden-md{display:none!important}}@media(min-width:1200px){.hidden-sm.hidden-lg,tr.hidden-sm.hidden-lg,th.hidden-sm.hidden-lg,td.hidden-sm.hidden-lg{display:none!important}}.hidden-md{display:block!important}table.hidden-md{display:table}tr.hidden-md{display:table-row!important}th.hidden-md,td.hidden-md{display:table-cell!important}@media(max-width:767px){.hidden-md.hidden-xs,tr.hidden-md.hidden-xs,th.hidden-md.hidden-xs,td.hidden-md.hidden-xs{display:none!important}}@media(min-width:768px) and (max-width:991px){.hidden-md.hidden-sm,tr.hidden-md.hidden-sm,th.hidden-md.hidden-sm,td.hidden-md.hidden-sm{display:none!important}}@media(min-width:992px) and (max-width:1199px){.hidden-md,tr.hidden-md,th.hidden-md,td.hidden-md{display:none!important}}@media(min-width:1200px){.hidden-md.hidden-lg,tr.hidden-md.hidden-lg,th.hidden-md.hidden-lg,td.hidden-md.hidden-lg{display:none!important}}.hidden-lg{display:block!important}table.hidden-lg{display:table}tr.hidden-lg{display:table-row!important}th.hidden-lg,td.hidden-lg{display:table-cell!important}@media(max-width:767px){.hidden-lg.hidden-xs,tr.hidden-lg.hidden-xs,th.hidden-lg.hidden-xs,td.hidden-lg.hidden-xs{display:none!important}}@media(min-width:768px) and (max-width:991px){.hidden-lg.hidden-sm,tr.hidden-lg.hidden-sm,th.hidden-lg.hidden-sm,td.hidden-lg.hidden-sm{display:none!important}}@media(min-width:992px) and (max-width:1199px){.hidden-lg.hidden-md,tr.hidden-lg.hidden-md,th.hidden-lg.hidden-md,td.hidden-lg.hidden-md{display:none!important}}@media(min-width:1200px){.hidden-lg,tr.hidden-lg,th.hidden-lg,td.hidden-lg{display:none!important}}.visible-print,tr.visible-print,th.visible-print,td.visible-print{display:none!important}@media print{.visible-print{display:block!important}table.visible-print{display:table}tr.visible-print{display:table-row!important}th.visible-print,td.visible-print{display:table-cell!important}.hidden-print,tr.hidden-print,th.hidden-print,td.hidden-print{display:none!important}} \ No newline at end of file diff --git a/docs/css/fig.css b/docs/css/fig.css new file mode 100644 index 00000000000..48fef5e217f --- /dev/null +++ b/docs/css/fig.css @@ -0,0 +1,124 @@ +body { + padding-top: 20px; + padding-bottom: 60px; + font-family: 'Lato', sans-serif; + font-weight: 300; + font-size: 18px; + color: #362; +} + +h1, h2, h3, h4, h5, h6 { + font-family: 'Lato', sans-serif; + font-weight: 400; + color: #25594D; +} + +h2, h3, h4, h5, h6 { + margin-top: 1.5em; +} + +p { + margin: 20px 0; +} + +a, a:hover, a:visited { + color: #4D9900; + text-decoration: underline; +} + +pre, code { + border: none; + background: #D5E1B4; +} + +code, pre code { + color: #484F40; +} + +pre { + border-bottom: 2px solid #bec9a1; + font-size: 14px; +} + +code { + font-size: 0.84em; +} + +pre code { + background: none; +} + +img { + max-width: 100%; +} + +/* Customize container */ +@media (min-width: 768px) { + .container { + max-width: 730px; + } +} + +@media (min-width: 481px) { + .github-top { + position: absolute; + top: 0; + right: 0; + } +} + +.content h1 { + display: none; +} + +.logo { + text-align: center; + font-family: 'Lilita One', sans-serif; + font-size: 80px; + color: #a41211; + margin: 20px 0 40px 0; +} + +.logo img { + width: 100px; + vertical-align: -17px; +} + +@media (min-width: 481px) { + .logo { + font-size: 96px; + margin-top: 40px; + } + .logo img { + vertical-align: -40px; + width: 150px; + margin-right: 20px; + } +} + +.github-top, +.github-bottom { + text-align: center; +} + +.github-top { + margin: 30px; +} + +.github-bottom { + margin: 60px 0; +} + +a.btn { + background: #25594D; + color: white; + text-transform: uppercase; + text-decoration: none; +} + +a.btn:hover { + color: white; +} + + + diff --git a/docs/fig.yml b/docs/fig.yml new file mode 100644 index 00000000000..45edcb3d15e --- /dev/null +++ b/docs/fig.yml @@ -0,0 +1,8 @@ +jekyll: + build: . + ports: + - 4000:4000 + volumes: + - .:/code + environment: + - LANG=en_US.UTF-8 diff --git a/docs/img/logo.png b/docs/img/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..13ca9bc78b18f3bb7195dec87662195508cf5a51 GIT binary patch literal 133640 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≺_r~!Y{H%?R0D5ge&@j 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 z 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 + +Punctual, lightweight development environments using Docker. + +Fig is a tool for defining and running isolated application environments. You define the services which comprise your app in a simple, version-controllable YAML configuration file that looks like this: + +```yaml +web: + build: . + links: + - db + ports: + - 8000:8000 +db: + image: orchardup/postgresql +``` + +Then type `fig up`, and Fig will start and run your entire app: + +![example fig run](https://orchardup.com/static/images/fig-example-large.f96065fc9e22.gif) + +There are commands to: + + - start, stop and rebuild services + - view the status of running services + - tail running services' log output + - run a one-off command on a service + +Fig is a project from [Orchard](https://orchardup.com), a Docker hosting service. [Follow us on Twitter](https://twitter.com/orchardup) to keep up to date with Fig and other Docker news. + + +Getting started +--------------- + +Let's get a basic Python web app running on Fig. It assumes a little knowledge of Python, but the concepts should be clear if you're not familiar with it. + +First, install Docker. If you're on OS X, you can use [docker-osx](https://github.com/noplay/docker-osx): + + $ curl https://raw.github.com/noplay/docker-osx/master/docker-osx > /usr/local/bin/docker-osx + $ chmod +x /usr/local/bin/docker-osx + $ docker-osx shell + +Docker has guides for [Ubuntu](http://docs.docker.io/en/latest/installation/ubuntulinux/) and [other platforms](http://docs.docker.io/en/latest/installation/) in their documentation. + +Next, install Fig: + + $ sudo pip install -U fig + +(This command also upgrades Fig when we release a new version. If you don’t have pip installed, try `brew install python` or `apt-get install python-pip`.) + +You'll want to make a directory for the project: + + $ mkdir figtest + $ cd figtest + +Inside this directory, create `app.py`, a simple web app that uses the Flask framework and increments a value in Redis: + +```python +from flask import Flask +from redis import Redis +import os +app = Flask(__name__) +redis = Redis( + host=os.environ.get('FIGTEST_REDIS_1_PORT_6379_TCP_ADDR'), + port=int(os.environ.get('FIGTEST_REDIS_1_PORT_6379_TCP_PORT')) +) + +@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) +``` + +We define our Python dependencies in a file called `requirements.txt`: + + flask + redis + +And we define how to build this into a Docker image using a file called `Dockerfile`: + + FROM stackbrew/ubuntu:13.10 + RUN apt-get -qq update + RUN apt-get install -y python python-pip + ADD . /code + WORKDIR /code + RUN pip install -r requirements.txt + EXPOSE 5000 + CMD python app.py + +That tells Docker to create an image with Python and Flask installed on it, run the command `python app.py`, and open port 5000 (the port that Flask listens on). + +We then define a set of services using `fig.yml`: + + web: + build: . + ports: + - 5000:5000 + volumes: + - .:/code + links: + - redis + redis: + image: orchardup/redis + +This defines two services: + + - `web`, which is built from `Dockerfile` in the current directory. It also says to forward the exposed port 5000 on the container to port 5000 on the host machine, connect up the Redis service, and mount the current directory inside the container so we can work on code without having to rebuild the image. + - `redis`, which uses the public image [orchardup/redis](https://index.docker.io/u/orchardup/redis/). + +Now if we run `fig up`, it'll pull a Redis image, build an image for our own code, and start everything up: + + $ fig up + Pulling image orchardup/redis... + Building web... + Starting figtest_redis_1... + Starting figtest_web_1... + figtest_redis_1 | [8] 02 Jan 18:43:35.576 # Server started, Redis version 2.8.3 + figtest_web_1 | * Running on http://0.0.0.0:5000/ + +Open up [http://localhost:5000](http://localhost:5000) in your browser (or [http://localdocker:5000](http://localdocker:5000) if you're using [docker-osx](https://github.com/noplay/docker-osx)) and you should see it running! + +If you want to run your services in the background, you can pass the `-d` flag to `fig up` and use `fig ps` to see what is currently running: + + $ fig up -d + Starting figtest_redis_1... + Starting figtest_web_1... + $ fig ps + Name Command State Ports + ------------------------------------------------------------------- + figtest_redis_1 /usr/local/bin/run Up + figtest_web_1 /bin/sh -c python app.py Up 5000->5000/tcp + +`fig run` allows you to run one-off commands for your services. For example, to see what environment variables are available to the `web` service: + + $ fig run web env + + +See `fig --help` other commands that are available. + +If you started Fig with `fig up -d`, you'll probably want to stop your services once you've finished with them: + + $ fig stop + +That's more-or-less how Fig works. See the reference section below for full details on the commands, configuration file and environment variables. If you have any thoughts or suggestions, [open an issue on GitHub](https://github.com/orchardup/fig) or [email us](mailto:hello@orchardup.com). + + +Reference +--------- + +### fig.yml + +Each service defined in `fig.yml` must specify exactly one of `image` or `build`. Other keys are optional, and are analogous to their `docker run` command-line counterparts. + +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 `fig.yml`. + +```yaml +-- Tag or partial image ID. Can be local or remote - Fig will attempt to pull if it doesn't exist locally. +image: ubuntu +image: orchardup/postgresql +image: a4bc65fd + +-- Path to a directory containing a Dockerfile. Fig will build and tag it with a generated name, and use that image thereafter. +build: /path/to/build/dir + +-- Override the default command. +command: bundle exec thin -p 3000 + +-- Link to containers in another service (see "Communicating between containers"). +links: + - db + - redis + +-- Expose ports. Either specify both ports (HOST:CONTAINER), or just the container port (a random host port will be chosen). +ports: + - 3000 + - 8000:8000 + +-- Map volumes from the host machine (HOST:CONTAINER). +volumes: + - cache/:/tmp/cache + +-- Add environment variables. +environment: + RACK_ENV: development +``` + +### Commands + +Most commands are run against one or more services. If the service is omitted, it will apply to all services. + +Run `fig [COMMAND] --help` for full usage. + +#### build + +Build or rebuild services. + +Services are built once and then tagged as `project_service`, e.g. `figtest_db`. If you change a service's `Dockerfile` or the contents of its build directory, you can run `fig build` to rebuild it. + +#### help + +Get help on a command. + +#### kill + +Force stop service containers. + +#### logs + +View output from services. + +#### ps + +List containers. + +#### rm + +Remove stopped service containers. + + +#### run + +Run a one-off command on a service. + +For example: + + $ fig run web python manage.py shell + +Note that this will not start any services that the command's service links to. So if, for example, your one-off command talks to your database, you will need to run `fig up -d db` first. + +#### scale + +Set number of containers to run for a service. + +Numbers are specified in the form `service=num` as arguments. +For example: + + $ fig scale web=2 worker=3 + +#### start + +Start existing containers for a service. + +#### stop + +Stop running containers without removing them. They can be started again with `fig start`. + +#### up + +Build, (re)create, start and attach to containers for a service. + +By default, `fig up` will aggregate the output of each container, and when it exits, all containers will be stopped. If you run `fig up -d`, it'll start the containers in the background and leave them running. + +If there are existing containers for a service, `fig up` will stop and recreate them (preserving mounted volumes with [volumes-from]), so that changes in `fig.yml` are picked up. + +### Environment variables + +Fig 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. + +name\_PORT
+Full URL, e.g. `MYAPP_DB_1_PORT=tcp://172.17.0.5:5432` + +name\_PORT\_num\_protocol
+Full URL, e.g. `MYAPP_DB_1_PORT_5432_TCP=tcp://172.17.0.5:5432` + +name\_PORT\_num\_protocol\_ADDR
+Container's IP address, e.g. `MYAPP_DB_1_PORT_5432_TCP_ADDR=172.17.0.5` + +name\_PORT\_num\_protocol\_PORT
+Exposed port number, e.g. `MYAPP_DB_1_PORT_5432_TCP_PORT=5432` + +name\_PORT\_num\_protocol\_PROTO
+Protocol (tcp or udp), e.g. `MYAPP_DB_1_PORT_5432_TCP_PROTO=tcp` + +name\_NAME
+Fully qualified container name, e.g. `MYAPP_DB_1_NAME=/myapp_web_1/myapp_db_1` + + +[Docker links]: http://docs.docker.io/en/latest/use/port_redirection/#linking-a-container +[volumes-from]: http://docs.docker.io/en/latest/use/working_with_volumes/ diff --git a/script/build-docs b/script/build-docs new file mode 100755 index 00000000000..c4e6494306d --- /dev/null +++ b/script/build-docs @@ -0,0 +1,5 @@ +#!/bin/bash + +pushd docs +fig run jekyll jekyll build +popd diff --git a/script/deploy-docs b/script/deploy-docs new file mode 100755 index 00000000000..447751eed31 --- /dev/null +++ b/script/deploy-docs @@ -0,0 +1,29 @@ +#!/bin/bash + +set -ex + +pushd docs + +export GIT_DIR=.git-gh-pages +export GIT_WORK_TREE=. + +if [ ! -d "$GIT_DIR" ]; then + git init +fi + +if !(git remote | grep origin); then + git remote add origin git@github.com:orchardup/fig.git +fi + +cp .gitignore-gh-pages .gitignore +echo ".git-gh-pages" >> .gitignore + +git add -u +git add . + +git commit -m "update" || echo "didn't commit" +git push -f origin master:gh-pages + +rm .gitignore + +popd From db396b81ef29c31be179624a7fee5dfcb370a1ae Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 27 Jan 2014 12:26:33 +0000 Subject: [PATCH 0241/4072] Just deploy the _site directory to gh-pages --- script/deploy-docs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/script/deploy-docs b/script/deploy-docs index 447751eed31..4bb6b7b0e21 100755 --- a/script/deploy-docs +++ b/script/deploy-docs @@ -2,7 +2,7 @@ set -ex -pushd docs +pushd docs/_site export GIT_DIR=.git-gh-pages export GIT_WORK_TREE=. @@ -15,8 +15,7 @@ if !(git remote | grep origin); then git remote add origin git@github.com:orchardup/fig.git fi -cp .gitignore-gh-pages .gitignore -echo ".git-gh-pages" >> .gitignore +echo ".git-gh-pages" > .gitignore git add -u git add . @@ -24,6 +23,4 @@ git add . git commit -m "update" || echo "didn't commit" git push -f origin master:gh-pages -rm .gitignore - popd From 1bab14213dde464dd44c468461674f7035b492d5 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Mon, 27 Jan 2014 15:29:31 +0000 Subject: [PATCH 0242/4072] Update docker-py From https://github.com/bfirsh/docker-py/commit/0a9512d008b4b79d625aed360d9dc323ccc342d5 --- fig/packages/docker/client.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/fig/packages/docker/client.py b/fig/packages/docker/client.py index 7bc46aacf22..bc04c40d17b 100644 --- a/fig/packages/docker/client.py +++ b/fig/packages/docker/client.py @@ -708,8 +708,11 @@ def start(self, container, binds=None, port_bindings=None, lxc_conf=None, start_config['PublishAllPorts'] = publish_all_ports if links: + if isinstance(links, dict): + links = six.iteritems(links) + formatted_links = [ - '{0}:{1}'.format(k, v) for k, v in sorted(six.iteritems(links)) + '{0}:{1}'.format(k, v) for k, v in sorted(links) ] start_config['Links'] = formatted_links From 3e7e6e76563afbbd244803769be4e3e84f4d8a81 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Mon, 27 Jan 2014 15:29:58 +0000 Subject: [PATCH 0243/4072] Add link alias without project name REDIS_1_PORT_6379_TCP_ADDR instead of FIGTEST_REDIS_1_PORT_6379_TCP_ADDR. Ref #37 --- README.md | 16 ++++++++-------- fig/container.py | 4 ++++ fig/service.py | 5 +++-- tests/service_test.py | 1 + 4 files changed, 16 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index e905658390b..b2df35025f6 100644 --- a/README.md +++ b/README.md @@ -67,8 +67,8 @@ from redis import Redis import os app = Flask(__name__) redis = Redis( - host=os.environ.get('FIGTEST_REDIS_1_PORT_6379_TCP_ADDR'), - port=int(os.environ.get('FIGTEST_REDIS_1_PORT_6379_TCP_PORT')) + host=os.environ.get('REDIS_1_PORT_6379_TCP_ADDR'), + port=int(os.environ.get('REDIS_1_PORT_6379_TCP_PORT')) ) @app.route('/') @@ -266,22 +266,22 @@ If there are existing containers for a service, `fig up` will stop and recreate Fig 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. name\_PORT
-Full URL, e.g. `MYAPP_DB_1_PORT=tcp://172.17.0.5:5432` +Full URL, e.g. `DB_1_PORT=tcp://172.17.0.5:5432` name\_PORT\_num\_protocol
-Full URL, e.g. `MYAPP_DB_1_PORT_5432_TCP=tcp://172.17.0.5:5432` +Full URL, e.g. `DB_1_PORT_5432_TCP=tcp://172.17.0.5:5432` name\_PORT\_num\_protocol\_ADDR
-Container's IP address, e.g. `MYAPP_DB_1_PORT_5432_TCP_ADDR=172.17.0.5` +Container's IP address, e.g. `DB_1_PORT_5432_TCP_ADDR=172.17.0.5` name\_PORT\_num\_protocol\_PORT
-Exposed port number, e.g. `MYAPP_DB_1_PORT_5432_TCP_PORT=5432` +Exposed port number, e.g. `DB_1_PORT_5432_TCP_PORT=5432` name\_PORT\_num\_protocol\_PROTO
-Protocol (tcp or udp), e.g. `MYAPP_DB_1_PORT_5432_TCP_PROTO=tcp` +Protocol (tcp or udp), e.g. `DB_1_PORT_5432_TCP_PROTO=tcp` name\_NAME
-Fully qualified container name, e.g. `MYAPP_DB_1_NAME=/myapp_web_1/myapp_db_1` +Fully qualified container name, e.g. `DB_1_NAME=/myapp_web_1/myapp_db_1` [Docker links]: http://docs.docker.io/en/latest/use/port_redirection/#linking-a-container diff --git a/fig/container.py b/fig/container.py index 76f2d29e1da..c9417d0f62a 100644 --- a/fig/container.py +++ b/fig/container.py @@ -50,6 +50,10 @@ def short_id(self): def name(self): return self.dictionary['Name'][1:] + @property + def name_without_project(self): + return '_'.join(self.dictionary['Name'].split('_')[1:]) + @property def number(self): try: diff --git a/fig/service.py b/fig/service.py index aaaa97ab6b8..2e7a89d0115 100644 --- a/fig/service.py +++ b/fig/service.py @@ -207,10 +207,11 @@ def next_container_number(self, one_off=False): return max(numbers) + 1 def _get_links(self): - links = {} + links = [] for service in self.links: for container in service.containers(): - links[container.name] = container.name + links.append((container.name, container.name)) + links.append((container.name, container.name_without_project)) return links def _get_container_options(self, override_options, one_off=False): diff --git a/tests/service_test.py b/tests/service_test.py index ca9a0021ce0..1357b76135e 100644 --- a/tests/service_test.py +++ b/tests/service_test.py @@ -154,6 +154,7 @@ def test_start_container_creates_links(self): db.start_container() web.start_container() self.assertIn('figtest_db_1', web.containers()[0].links()) + self.assertIn('db_1', web.containers()[0].links()) db.stop(timeout=1) web.stop(timeout=1) From 9a5a021f913a1d9570ef6357f354bbee217818b6 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Mon, 27 Jan 2014 15:54:43 +0000 Subject: [PATCH 0244/4072] Rewrite introduction --- README.md | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index e905658390b..23732d0c977 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,17 @@ Fig [![PyPI version](https://badge.fury.io/py/fig.png)](http://badge.fury.io/py/fig) -Punctual, lightweight development environments using Docker. +Fast, isolated development environments using Docker. -Fig is a tool for defining and running isolated application environments. You define the services which comprise your app in a simple, version-controllable YAML configuration file that looks like this: +Define your app's environment with Docker so it can be reproduced anywhere. + + FROM orchardup/python:2.7 + ADD . /code + RUN pip install -r requirements.txt + WORKDIR /code + CMD python app.py + +Define your app's services so they can be run alongside in an isolated environment. (No more installing Postgres on your laptop!) ```yaml web: @@ -32,7 +40,7 @@ There are commands to: - tail running services' log output - run a one-off command on a service -Fig is a project from [Orchard](https://orchardup.com), a Docker hosting service. [Follow us on Twitter](https://twitter.com/orchardup) to keep up to date with Fig and other Docker news. +Fig is a project from [Orchard](https://orchardup.com). [Follow us on Twitter](https://twitter.com/orchardup) to keep up to date with Fig and other Docker news. Getting started From 5d71c33cd79215385e9340fe6b03b2a1d8e89f7f Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Thu, 23 Jan 2014 11:47:51 +0000 Subject: [PATCH 0245/4072] Only install unittest2 on Python 2.6 --- requirements-dev.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 5fd088d49ea..115e826e34f 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,3 +1,2 @@ mock==1.0.1 nose==1.3.0 -unittest2==0.5.1 From 5035a10cbef1071ebf9dd25f07b3b09d4290715b Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Mon, 27 Jan 2014 17:53:58 +0000 Subject: [PATCH 0246/4072] Ship 0.1.4 --- CHANGES.md | 5 +++++ fig/__init__.py | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index c68184b03a4..097cb3c112d 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,6 +1,11 @@ Change log ========== +0.1.4 (2014-01-27) +------------------ + + - Add a link alias without the project name. This makes the environment variables a little shorter: `REDIS_1_PORT_6379_TCP_ADDR`. (#54) + 0.1.3 (2014-01-23) ------------------ diff --git a/fig/__init__.py b/fig/__init__.py index 35b931c99b6..357eaa34c31 100644 --- a/fig/__init__.py +++ b/fig/__init__.py @@ -1,4 +1,4 @@ from __future__ import unicode_literals from .service import Service -__version__ = '0.1.3' +__version__ = '0.1.4' From 01d0e49a1c180ed1fa1a648df3c6debc07053b66 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 27 Jan 2014 15:03:21 +0000 Subject: [PATCH 0247/4072] New website --- docs/_layouts/default.html | 46 ++++++++--- docs/cli.md | 75 ++++++++++++++++++ docs/css/fig.css | 114 +++++++++++++++++++-------- docs/django.md | 90 +++++++++++++++++++++ docs/env.md | 29 +++++++ docs/index.md | 157 +++---------------------------------- docs/install.md | 23 ++++++ docs/rails.md | 98 +++++++++++++++++++++++ docs/yml.md | 43 ++++++++++ 9 files changed, 482 insertions(+), 193 deletions(-) create mode 100644 docs/cli.md create mode 100644 docs/django.md create mode 100644 docs/env.md create mode 100644 docs/install.md create mode 100644 docs/rails.md create mode 100644 docs/yml.md diff --git a/docs/_layouts/default.html b/docs/_layouts/default.html index 796d0d4ed72..b76ed438835 100644 --- a/docs/_layouts/default.html +++ b/docs/_layouts/default.html @@ -4,29 +4,49 @@ {{ page.title }} - + - -
-

+

- - - +
{{ content }}
- - From 491181ec3181c14175450192d8e5cf0126550278 Mon Sep 17 00:00:00 2001 From: Tyler Rivera Date: Wed, 22 Oct 2014 10:57:43 -0400 Subject: [PATCH 0594/4072] Allow dependent image pull from insecure registry PR #490 Provides the ability to pull from an insecure registry by passing --allow-insecure-ssl. This commit extends the work done in #490 and adds the ability to pass --allow-insecure-ssl to the up and run commands which will attempt to pull dependent images if they do not exist. Signed-off-by: Tyler Rivera --- fig/cli/main.py | 43 ++++++++++++++++++++++++-------------- fig/project.py | 4 ++-- fig/service.py | 12 +++++++---- tests/unit/service_test.py | 17 +++++++++++++++ 4 files changed, 54 insertions(+), 22 deletions(-) diff --git a/fig/cli/main.py b/fig/cli/main.py index 9a47771f459..e00e0a6ef13 100644 --- a/fig/cli/main.py +++ b/fig/cli/main.py @@ -264,17 +264,21 @@ def run(self, project, options): Usage: run [options] [-e KEY=VAL...] SERVICE [COMMAND] [ARGS...] Options: - -d Detached mode: Run container in the background, print - new container name. - --entrypoint CMD Override the entrypoint of the image. - -e KEY=VAL Set an environment variable (can be used multiple times) - --no-deps Don't start linked services. - --rm Remove container after run. Ignored in detached mode. - -T Disable pseudo-tty allocation. By default `fig run` - allocates a TTY. + --allow-insecure-ssl Allow insecure connections to the docker + registry + -d Detached mode: Run container in the background, print + new container name. + --entrypoint CMD Override the entrypoint of the image. + -e KEY=VAL Set an environment variable (can be used multiple times) + --no-deps Don't start linked services. + --rm Remove container after run. Ignored in detached mode. + -T Disable pseudo-tty allocation. By default `fig run` + allocates a TTY. """ service = project.get_service(options['SERVICE']) + insecure_registry = options['--allow-insecure-ssl'] + if not options['--no-deps']: deps = service.get_linked_names() @@ -309,8 +313,11 @@ def run(self, project, options): if options['--entrypoint']: container_options['entrypoint'] = options.get('--entrypoint') - - container = service.create_container(one_off=True, **container_options) + container = service.create_container( + one_off=True, + insecure_registry=insecure_registry, + **container_options + ) if options['-d']: service.start_container(container, ports=None, one_off=True) print(container.name) @@ -396,12 +403,15 @@ def up(self, project, options): Usage: up [options] [SERVICE...] Options: - -d Detached mode: Run containers in the background, - print new container names. - --no-color Produce monochrome output. - --no-deps Don't start linked services. - --no-recreate If containers already exist, don't recreate them. + --allow-insecure-ssl Allow insecure connections to the docker + registry + -d Detached mode: Run containers in the background, + print new container names. + --no-color Produce monochrome output. + --no-deps Don't start linked services. + --no-recreate If containers already exist, don't recreate them. """ + insecure_registry = options['--allow-insecure-ssl'] detached = options['-d'] monochrome = options['--no-color'] @@ -413,7 +423,8 @@ def up(self, project, options): project.up( service_names=service_names, start_links=start_links, - recreate=recreate + recreate=recreate, + insecure_registry=insecure_registry, ) to_attach = [c for s in project.get_services(service_names) for c in s.containers()] diff --git a/fig/project.py b/fig/project.py index 38b9f46fc41..b30513dd935 100644 --- a/fig/project.py +++ b/fig/project.py @@ -167,7 +167,7 @@ def build(self, service_names=None, no_cache=False): else: log.info('%s uses an image, skipping' % service.name) - def up(self, service_names=None, start_links=True, recreate=True): + def up(self, service_names=None, start_links=True, recreate=True, insecure_registry=False): running_containers = [] for service in self.get_services(service_names, include_links=start_links): @@ -175,7 +175,7 @@ def up(self, service_names=None, start_links=True, recreate=True): for (_, container) in service.recreate_containers(): running_containers.append(container) else: - for container in service.start_or_create_containers(): + for container in service.start_or_create_containers(insecure_registry=insecure_registry): running_containers.append(container) return running_containers diff --git a/fig/service.py b/fig/service.py index 6c635dee004..558ada2b2cf 100644 --- a/fig/service.py +++ b/fig/service.py @@ -168,7 +168,7 @@ def remove_stopped(self, **options): log.info("Removing %s..." % c.name) c.remove(**options) - def create_container(self, one_off=False, **override_options): + def create_container(self, one_off=False, insecure_registry=False, **override_options): """ Create a container for this service. If the image doesn't exist, attempt to pull it. @@ -179,7 +179,11 @@ def create_container(self, one_off=False, **override_options): except APIError as e: if e.response.status_code == 404 and e.explanation and 'No such image' in str(e.explanation): log.info('Pulling image %s...' % container_options['image']) - output = self.client.pull(container_options['image'], stream=True) + output = self.client.pull( + container_options['image'], + stream=True, + insecure_registry=insecure_registry + ) stream_output(output, sys.stdout) return Container.create(self.client, **container_options) raise @@ -270,12 +274,12 @@ def start_container(self, container=None, intermediate_container=None, **overrid ) return container - def start_or_create_containers(self): + def start_or_create_containers(self, insecure_registry=False): containers = self.containers(stopped=True) if not containers: log.info("Creating %s..." % self._next_container_name(containers)) - new_container = self.create_container() + new_container = self.create_container(insecure_registry=insecure_registry) return [self.start_container(new_container)] else: return [self.start_container_if_stopped(c) for c in containers] diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index bfe53a94e62..f1d1c79d910 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -6,6 +6,7 @@ import mock import docker +from requests import Response from fig import Service from fig.container import Container @@ -14,6 +15,7 @@ split_port, parse_volume_spec, build_volume_binding, + APIError, ) @@ -174,6 +176,21 @@ def test_pull_image(self, mock_log): self.mock_client.pull.assert_called_once_with('someimage:sometag', insecure_registry=True) mock_log.info.assert_called_once_with('Pulling foo (someimage:sometag)...') + @mock.patch('fig.service.log', autospec=True) + def test_create_container_from_insecure_registry(self, mock_log): + service = Service('foo', client=self.mock_client, image='someimage:sometag') + mock_response = mock.Mock(Response) + mock_response.status_code = 404 + mock_response.reason = "Not Found" + Container.create = mock.Mock() + Container.create.side_effect = APIError('Mock error', mock_response, "No such image") + try: + service.create_container(insecure_registry=True) + except APIError: # We expect the APIError because our service requires a non-existent image. + pass + self.mock_client.pull.assert_called_once_with('someimage:sometag', insecure_registry=True, stream=True) + mock_log.info.assert_called_once_with('Pulling image someimage:sometag...') + class ServiceVolumesTest(unittest.TestCase): From 7f0745d146f189733cd1625bc1a1cec2bfd5a2c0 Mon Sep 17 00:00:00 2001 From: Andrea Grandi Date: Tue, 21 Oct 2014 14:51:01 +0100 Subject: [PATCH 0595/4072] Add command line options to the documentation Signed-off-by: Andrea Grandi --- docs/cli.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/cli.md b/docs/cli.md index 1dfd703ecdc..4462575df02 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -10,6 +10,24 @@ Most commands are run against one or more services. If the service is omitted, i Run `fig [COMMAND] --help` for full usage. +## Options + +### --verbose + + Show more output + +### --version + + Print version and exit + +### -f, --file FILE + + Specify an alternate fig file (default: fig.yml) + +### -p, --project-name NAME + + Specify an alternate project name (default: directory name) + ## Commands ### build From b759b9854a6770300965e7e143422b4c1078893d Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Thu, 23 Oct 2014 16:05:11 +0100 Subject: [PATCH 0596/4072] Update Twitter button URL I originally left it as orchardup.github.io so the number wouldn't roll back to 0, but people have been tweeting about fig.sh now. Signed-off-by: Ben Firshman --- docs/_layouts/default.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/_layouts/default.html b/docs/_layouts/default.html index 77d0454bb51..cc191918548 100644 --- a/docs/_layouts/default.html +++ b/docs/_layouts/default.html @@ -52,7 +52,7 @@

- +
From 28f9c8d0473f96ec502cde04305e7b928c43e14f Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Thu, 23 Oct 2014 18:28:17 +0100 Subject: [PATCH 0597/4072] Add TL;DR section to CONTRIBUTING.md Signed-off-by: Ben Firshman --- CONTRIBUTING.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 631f450f54a..63cf3474a9d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,5 +1,14 @@ # Contributing to Fig +## TL;DR + +Pull requests will need: + + - Tests + - Documentation + - [To be signed off](#sign-your-work) + - A logical series of [well written commits](https://github.com/alphagov/styleguides/blob/master/git.md) + ## Development environment If you're looking contribute to [Fig](http://www.fig.sh/) From ea45715a50dd2f590d8035d1e88c0c0cd50895e9 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Thu, 23 Oct 2014 18:30:48 +0100 Subject: [PATCH 0598/4072] Tidied up development environment instructions Signed-off-by: Ben Firshman --- CONTRIBUTING.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 63cf3474a9d..4de07fd2869 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,12 +15,10 @@ If you're looking contribute to [Fig](http://www.fig.sh/) but you're new to the project or maybe even to Python, here are the steps that should get you started. -1. Fork [https://github.com/docker/fig](https://github.com/docker/fig) to your username. kvz in this example. -1. Clone your forked repository locally `git clone git@github.com:kvz/fig.git`. +1. Fork [https://github.com/docker/fig](https://github.com/docker/fig) to your username. +1. Clone your forked repository locally `git clone git@github.com:yourusername/fig.git`. 1. Enter the local directory `cd fig`. -1. Set up a development environment `python setup.py develop`. That will install the dependencies and set up a symlink from your `fig` executable to the checkout of the repo. So from any of your fig projects, `fig` now refers to your development project. Time to start hacking : ) -1. Works for you? Run the test suite via `./script/test` to verify it won't break other usecases. -1. All good? Commit and push to GitHub, and submit a pull request. +1. Set up a development environment by running `python setup.py develop`. This will install the dependencies and set up a symlink from your `fig` executable to the checkout of the repository. When you now run `fig` from anywhere on your machine, it will run your development version of Fig. ## Running the test suite From 899670fc6ce1dc4c52bb3d603667554772976cc8 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Thu, 23 Oct 2014 18:31:28 +0100 Subject: [PATCH 0599/4072] Move building binaries instructions It's less important than signing your work. Signed-off-by: Ben Firshman --- CONTRIBUTING.md | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4de07fd2869..eb101955488 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,18 +24,6 @@ that should get you started. $ script/test -## Building binaries - -Linux: - - $ script/build-linux - -OS X: - - $ script/build-osx - -Note that this only works on Mountain Lion, not Mavericks, due to a [bug in PyInstaller](http://www.pyinstaller.org/ticket/807). - ## Sign your work The sign-off is a simple line at the end of the explanation for the @@ -80,6 +68,17 @@ The easiest way to do this is to use the `--signoff` flag when committing. E.g.: $ git commit --signoff +## Building binaries + +Linux: + + $ script/build-linux + +OS X: + + $ script/build-osx + +Note that this only works on Mountain Lion, not Mavericks, due to a [bug in PyInstaller](http://www.pyinstaller.org/ticket/807). ## Release process From e66c0452d5a6da8d35dca887d0ea3a7af36407cc Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Thu, 23 Oct 2014 19:00:37 +0100 Subject: [PATCH 0600/4072] Add .dockerignore Signed-off-by: Ben Firshman --- .dockerignore | 1 + 1 file changed, 1 insertion(+) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000000..6b8710a711f --- /dev/null +++ b/.dockerignore @@ -0,0 +1 @@ +.git From b47ab2b0f6e83f6f46d0688a40800cbcc36555d1 Mon Sep 17 00:00:00 2001 From: Kevin Simper Date: Fri, 24 Oct 2014 23:50:06 +0200 Subject: [PATCH 0601/4072] Update wordpress url on wordpress example The old url gave a 302 and wordpress 4.0 has also been released. --- docs/wordpress.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/wordpress.md b/docs/wordpress.md index 6e512b7920f..e2c43252561 100644 --- a/docs/wordpress.md +++ b/docs/wordpress.md @@ -8,7 +8,7 @@ Getting started with Fig and Wordpress Fig makes it nice and easy to run Wordpress in an isolated environment. [Install Fig](install.html), then download Wordpress into the current directory: - $ curl http://wordpress.org/wordpress-3.8.1.tar.gz | tar -xvzf - + $ curl https://wordpress.org/latest.tar.gz | tar -xvzf - This will create a directory called `wordpress`, which you can rename to the name of your project if you wish. Inside that directory, we create `Dockerfile`, a file that defines what environment your app is going to run in: From b64ea859166420ee5e9ec4385687a044c833dd27 Mon Sep 17 00:00:00 2001 From: Nicolas Peters Date: Mon, 27 Oct 2014 23:40:47 +0100 Subject: [PATCH 0602/4072] fix insecure parameter Signed-off-by: Nicolas Peters --- fig/project.py | 3 +-- fig/service.py | 7 +++---- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/fig/project.py b/fig/project.py index b30513dd935..569df38d580 100644 --- a/fig/project.py +++ b/fig/project.py @@ -169,10 +169,9 @@ def build(self, service_names=None, no_cache=False): def up(self, service_names=None, start_links=True, recreate=True, insecure_registry=False): running_containers = [] - for service in self.get_services(service_names, include_links=start_links): if recreate: - for (_, container) in service.recreate_containers(): + for (_, container) in service.recreate_containers(insecure_registry=insecure_registry): running_containers.append(container) else: for container in service.start_or_create_containers(insecure_registry=insecure_registry): diff --git a/fig/service.py b/fig/service.py index 558ada2b2cf..e6dcf01281b 100644 --- a/fig/service.py +++ b/fig/service.py @@ -188,16 +188,15 @@ def create_container(self, one_off=False, insecure_registry=False, **override_op return Container.create(self.client, **container_options) raise - def recreate_containers(self, **override_options): + def recreate_containers(self, insecure_registry=False, **override_options): """ If a container for this service doesn't exist, create and start one. If there are any, stop them, create+start new ones, and remove the old containers. """ containers = self.containers(stopped=True) - if not containers: log.info("Creating %s..." % self._next_container_name(containers)) - container = self.create_container(**override_options) + container = self.create_container(insecure_registry=insecure_registry, **override_options) self.start_container(container) return [(None, container)] else: @@ -205,7 +204,7 @@ def recreate_containers(self, **override_options): for c in containers: log.info("Recreating %s..." % c.name) - tuples.append(self.recreate_container(c, **override_options)) + tuples.append(self.recreate_container(c, insecure_registry=insecure_registry, **override_options)) return tuples From 782a46fd6074ecabcd8bf65b126bfa39e6ca90bc Mon Sep 17 00:00:00 2001 From: "Michael A. Smith" Date: Sat, 1 Nov 2014 19:22:22 -0400 Subject: [PATCH 0603/4072] Fixes #602 Allowing `help $cmd` with no figfile Signed-off-by: Michael A. Smith --- fig/cli/command.py | 5 +++++ tests/fixtures/no-figfile/.gitignore | 0 tests/integration/cli_test.py | 10 ++++++++++ 3 files changed, 15 insertions(+) create mode 100644 tests/fixtures/no-figfile/.gitignore diff --git a/fig/cli/command.py b/fig/cli/command.py index 743c96e930d..1601f94a24e 100644 --- a/fig/cli/command.py +++ b/fig/cli/command.py @@ -42,6 +42,11 @@ def dispatch(self, *args, **kwargs): raise errors.ConnectionErrorGeneric(self.get_client().base_url) def perform_command(self, options, handler, command_options): + if options['COMMAND'] == 'help': + # Skip looking up the figfile. + handler(None, command_options) + return + explicit_config_path = options.get('--file') or os.environ.get('FIG_FILE') project = self.get_project( self.get_config_path(explicit_config_path), diff --git a/tests/fixtures/no-figfile/.gitignore b/tests/fixtures/no-figfile/.gitignore new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/integration/cli_test.py b/tests/integration/cli_test.py index 0581e8b1411..ceb93f62bcd 100644 --- a/tests/integration/cli_test.py +++ b/tests/integration/cli_test.py @@ -25,6 +25,16 @@ def tearDown(self): def project(self): return self.command.get_project(self.command.get_config_path()) + def test_help(self): + old_base_dir = self.command.base_dir + self.command.base_dir = 'tests/fixtures/no-figfile' + with self.assertRaises(SystemExit) as exc_context: + self.command.dispatch(['help', 'up'], None) + self.assertIn('Usage: up [options] [SERVICE...]', str(exc_context.exception)) + # self.project.kill() fails during teardown + # unless there is a figfile. + self.command.base_dir = old_base_dir + @patch('sys.stdout', new_callable=StringIO) def test_ps(self, mock_stdout): self.project.get_service('simple').create_container() From 38a3ee8d63a0b280f76524803bac352c07f3b99e Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Mon, 27 Oct 2014 11:27:20 +0000 Subject: [PATCH 0604/4072] Use upstream dockerpty 0.3.2 This reverts commit 60411e9f05ac2b9245053491cef7c3cda9e923f1. Closes #556 Signed-off-by: Ben Firshman Conflicts: requirements.txt setup.py --- fig/cli/main.py | 2 +- fig/packages/__init__.py | 0 fig/packages/dockerpty/__init__.py | 27 --- fig/packages/dockerpty/io.py | 294 ----------------------------- fig/packages/dockerpty/pty.py | 235 ----------------------- fig/packages/dockerpty/tty.py | 130 ------------- requirements.txt | 1 + script/test | 11 +- setup.py | 1 + tests/integration/cli_test.py | 14 +- 10 files changed, 12 insertions(+), 703 deletions(-) delete mode 100644 fig/packages/__init__.py delete mode 100644 fig/packages/dockerpty/__init__.py delete mode 100644 fig/packages/dockerpty/io.py delete mode 100644 fig/packages/dockerpty/pty.py delete mode 100644 fig/packages/dockerpty/tty.py diff --git a/fig/cli/main.py b/fig/cli/main.py index 2ce8cfc3268..e31ba539e53 100644 --- a/fig/cli/main.py +++ b/fig/cli/main.py @@ -7,7 +7,7 @@ from operator import attrgetter from inspect import getdoc -from fig.packages import dockerpty +import dockerpty from .. import __version__ from ..project import NoSuchService, ConfigurationError diff --git a/fig/packages/__init__.py b/fig/packages/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/fig/packages/dockerpty/__init__.py b/fig/packages/dockerpty/__init__.py deleted file mode 100644 index a5d707a4740..00000000000 --- a/fig/packages/dockerpty/__init__.py +++ /dev/null @@ -1,27 +0,0 @@ -# dockerpty. -# -# Copyright 2014 Chris Corbyn -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from .pty import PseudoTerminal - - -def start(client, container): - """ - Present the PTY of the container inside the current process. - - This is just a wrapper for PseudoTerminal(client, container).start() - """ - - PseudoTerminal(client, container).start() diff --git a/fig/packages/dockerpty/io.py b/fig/packages/dockerpty/io.py deleted file mode 100644 index c31c54010f0..00000000000 --- a/fig/packages/dockerpty/io.py +++ /dev/null @@ -1,294 +0,0 @@ -# dockerpty: io.py -# -# Copyright 2014 Chris Corbyn -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import os -import fcntl -import errno -import struct -import select as builtin_select - - -def set_blocking(fd, blocking=True): - """ - Set the given file-descriptor blocking or non-blocking. - - Returns the original blocking status. - """ - - old_flag = fcntl.fcntl(fd, fcntl.F_GETFL) - - if blocking: - new_flag = old_flag &~ os.O_NONBLOCK - else: - new_flag = old_flag | os.O_NONBLOCK - - fcntl.fcntl(fd, fcntl.F_SETFL, new_flag) - - return not bool(old_flag & os.O_NONBLOCK) - - -def select(read_streams, timeout=0): - """ - Select the streams from `read_streams` that are ready for reading. - - Uses `select.select()` internally but returns a flat list of streams. - """ - - write_streams = [] - exception_streams = [] - - try: - return builtin_select.select( - read_streams, - write_streams, - exception_streams, - timeout, - )[0] - except builtin_select.error as e: - # POSIX signals interrupt select() - if e[0] == errno.EINTR: - return [] - else: - raise e - - -class Stream(object): - """ - Generic Stream class. - - This is a file-like abstraction on top of os.read() and os.write(), which - add consistency to the reading of sockets and files alike. - """ - - - """ - Recoverable IO/OS Errors. - """ - ERRNO_RECOVERABLE = [ - errno.EINTR, - errno.EDEADLK, - errno.EWOULDBLOCK, - ] - - - def __init__(self, fd): - """ - Initialize the Stream for the file descriptor `fd`. - - The `fd` object must have a `fileno()` method. - """ - self.fd = fd - - - def fileno(self): - """ - Return the fileno() of the file descriptor. - """ - - return self.fd.fileno() - - - def set_blocking(self, value): - if hasattr(self.fd, 'setblocking'): - self.fd.setblocking(value) - return True - else: - return set_blocking(self.fd, value) - - - def read(self, n=4096): - """ - Return `n` bytes of data from the Stream, or None at end of stream. - """ - - try: - if hasattr(self.fd, 'recv'): - return self.fd.recv(n) - return os.read(self.fd.fileno(), n) - except EnvironmentError as e: - if e.errno not in Stream.ERRNO_RECOVERABLE: - raise e - - - def write(self, data): - """ - Write `data` to the Stream. - """ - - if not data: - return None - - while True: - try: - if hasattr(self.fd, 'send'): - self.fd.send(data) - return len(data) - os.write(self.fd.fileno(), data) - return len(data) - except EnvironmentError as e: - if e.errno not in Stream.ERRNO_RECOVERABLE: - raise e - - def __repr__(self): - return "{cls}({fd})".format(cls=type(self).__name__, fd=self.fd) - - -class Demuxer(object): - """ - Wraps a multiplexed Stream to read in data demultiplexed. - - Docker multiplexes streams together when there is no PTY attached, by - sending an 8-byte header, followed by a chunk of data. - - The first 4 bytes of the header denote the stream from which the data came - (i.e. 0x01 = stdout, 0x02 = stderr). Only the first byte of these initial 4 - bytes is used. - - The next 4 bytes indicate the length of the following chunk of data as an - integer in big endian format. This much data must be consumed before the - next 8-byte header is read. - """ - - def __init__(self, stream): - """ - Initialize a new Demuxer reading from `stream`. - """ - - self.stream = stream - self.remain = 0 - - - def fileno(self): - """ - Returns the fileno() of the underlying Stream. - - This is useful for select() to work. - """ - - return self.stream.fileno() - - - def set_blocking(self, value): - return self.stream.set_blocking(value) - - - def read(self, n=4096): - """ - Read up to `n` bytes of data from the Stream, after demuxing. - - Less than `n` bytes of data may be returned depending on the available - payload, but the number of bytes returned will never exceed `n`. - - Because demuxing involves scanning 8-byte headers, the actual amount of - data read from the underlying stream may be greater than `n`. - """ - - size = self._next_packet_size(n) - - if size <= 0: - return - else: - return self.stream.read(size) - - - def write(self, data): - """ - Delegates the the underlying Stream. - """ - - return self.stream.write(data) - - - def _next_packet_size(self, n=0): - size = 0 - - if self.remain > 0: - size = min(n, self.remain) - self.remain -= size - else: - data = self.stream.read(8) - if data is None: - return 0 - if len(data) == 8: - __, actual = struct.unpack('>BxxxL', data) - size = min(n, actual) - self.remain = actual - size - - return size - - def __repr__(self): - return "{cls}({stream})".format(cls=type(self).__name__, - stream=self.stream) - - -class Pump(object): - """ - Stream pump class. - - A Pump wraps two Streams, reading from one and and writing its data into - the other, much like a pipe but manually managed. - - This abstraction is used to facilitate piping data between the file - descriptors associated with the tty and those associated with a container's - allocated pty. - - Pumps are selectable based on the 'read' end of the pipe. - """ - - def __init__(self, from_stream, to_stream): - """ - Initialize a Pump with a Stream to read from and another to write to. - """ - - self.from_stream = from_stream - self.to_stream = to_stream - - - def fileno(self): - """ - Returns the `fileno()` of the reader end of the Pump. - - This is useful to allow Pumps to function with `select()`. - """ - - return self.from_stream.fileno() - - - def set_blocking(self, value): - return self.from_stream.set_blocking(value) - - - def flush(self, n=4096): - """ - Flush `n` bytes of data from the reader Stream to the writer Stream. - - Returns the number of bytes that were actually flushed. A return value - of zero is not an error. - - If EOF has been reached, `None` is returned. - """ - - try: - return self.to_stream.write(self.from_stream.read(n)) - except OSError as e: - if e.errno != errno.EPIPE: - raise e - - def __repr__(self): - return "{cls}(from={from_stream}, to={to_stream})".format( - cls=type(self).__name__, - from_stream=self.from_stream, - to_stream=self.to_stream) diff --git a/fig/packages/dockerpty/pty.py b/fig/packages/dockerpty/pty.py deleted file mode 100644 index 4e11ca0aa9e..00000000000 --- a/fig/packages/dockerpty/pty.py +++ /dev/null @@ -1,235 +0,0 @@ -# dockerpty: pty.py -# -# Copyright 2014 Chris Corbyn -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import sys -import signal -from ssl import SSLError - -from . import io -from . import tty - - -class WINCHHandler(object): - """ - WINCH Signal handler to keep the PTY correctly sized. - """ - - def __init__(self, pty): - """ - Initialize a new WINCH handler for the given PTY. - - Initializing a handler has no immediate side-effects. The `start()` - method must be invoked for the signals to be trapped. - """ - - self.pty = pty - self.original_handler = None - - - def __enter__(self): - """ - Invoked on entering a `with` block. - """ - - self.start() - return self - - - def __exit__(self, *_): - """ - Invoked on exiting a `with` block. - """ - - self.stop() - - - def start(self): - """ - Start trapping WINCH signals and resizing the PTY. - - This method saves the previous WINCH handler so it can be restored on - `stop()`. - """ - - def handle(signum, frame): - if signum == signal.SIGWINCH: - self.pty.resize() - - self.original_handler = signal.signal(signal.SIGWINCH, handle) - - - def stop(self): - """ - Stop trapping WINCH signals and restore the previous WINCH handler. - """ - - if self.original_handler is not None: - signal.signal(signal.SIGWINCH, self.original_handler) - - -class PseudoTerminal(object): - """ - Wraps the pseudo-TTY (PTY) allocated to a docker container. - - The PTY is managed via the current process' TTY until it is closed. - - Example: - - import docker - from dockerpty import PseudoTerminal - - client = docker.Client() - container = client.create_container( - image='busybox:latest', - stdin_open=True, - tty=True, - command='/bin/sh', - ) - - # hijacks the current tty until the pty is closed - PseudoTerminal(client, container).start() - - Care is taken to ensure all file descriptors are restored on exit. For - example, you can attach to a running container from within a Python REPL - and when the container exits, the user will be returned to the Python REPL - without adverse effects. - """ - - - def __init__(self, client, container): - """ - Initialize the PTY using the docker.Client instance and container dict. - """ - - self.client = client - self.container = container - self.raw = None - - - def start(self, **kwargs): - """ - Present the PTY of the container inside the current process. - - This will take over the current process' TTY until the container's PTY - is closed. - """ - - pty_stdin, pty_stdout, pty_stderr = self.sockets() - - mappings = [ - (io.Stream(sys.stdin), pty_stdin), - (pty_stdout, io.Stream(sys.stdout)), - (pty_stderr, io.Stream(sys.stderr)), - ] - - pumps = [io.Pump(a, b) for (a, b) in mappings if a and b] - - if not self.container_info()['State']['Running']: - self.client.start(self.container, **kwargs) - - flags = [p.set_blocking(False) for p in pumps] - - try: - with WINCHHandler(self): - self._hijack_tty(pumps) - finally: - if flags: - for (pump, flag) in zip(pumps, flags): - io.set_blocking(pump, flag) - - - def israw(self): - """ - Returns True if the PTY should operate in raw mode. - - If the container was not started with tty=True, this will return False. - """ - - if self.raw is None: - info = self.container_info() - self.raw = sys.stdout.isatty() and info['Config']['Tty'] - - return self.raw - - - def sockets(self): - """ - Returns a tuple of sockets connected to the pty (stdin,stdout,stderr). - - If any of the sockets are not attached in the container, `None` is - returned in the tuple. - """ - - info = self.container_info() - - def attach_socket(key): - if info['Config']['Attach{0}'.format(key.capitalize())]: - socket = self.client.attach_socket( - self.container, - {key: 1, 'stream': 1, 'logs': 1}, - ) - stream = io.Stream(socket) - - if info['Config']['Tty']: - return stream - else: - return io.Demuxer(stream) - else: - return None - - return map(attach_socket, ('stdin', 'stdout', 'stderr')) - - - def resize(self, size=None): - """ - Resize the container's PTY. - - If `size` is not None, it must be a tuple of (height,width), otherwise - it will be determined by the size of the current TTY. - """ - - if not self.israw(): - return - - size = size or tty.size(sys.stdout) - - if size is not None: - rows, cols = size - try: - self.client.resize(self.container, height=rows, width=cols) - except IOError: # Container already exited - pass - - - def container_info(self): - """ - Thin wrapper around client.inspect_container(). - """ - - return self.client.inspect_container(self.container) - - - def _hijack_tty(self, pumps): - with tty.Terminal(sys.stdin, raw=self.israw()): - self.resize() - while True: - _ready = io.select(pumps, timeout=60) - try: - if all([p.flush() is None for p in pumps]): - break - except SSLError as e: - if 'The operation did not complete' not in e.strerror: - raise e diff --git a/fig/packages/dockerpty/tty.py b/fig/packages/dockerpty/tty.py deleted file mode 100644 index bd2ccb5add9..00000000000 --- a/fig/packages/dockerpty/tty.py +++ /dev/null @@ -1,130 +0,0 @@ -# dockerpty: tty.py -# -# Copyright 2014 Chris Corbyn -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from __future__ import absolute_import - -import os -import termios -import tty -import fcntl -import struct - - -def size(fd): - """ - Return a tuple (rows,cols) representing the size of the TTY `fd`. - - The provided file descriptor should be the stdout stream of the TTY. - - If the TTY size cannot be determined, returns None. - """ - - if not os.isatty(fd.fileno()): - return None - - try: - dims = struct.unpack('hh', fcntl.ioctl(fd, termios.TIOCGWINSZ, 'hhhh')) - except: - try: - dims = (os.environ['LINES'], os.environ['COLUMNS']) - except: - return None - - return dims - - -class Terminal(object): - """ - Terminal provides wrapper functionality to temporarily make the tty raw. - - This is useful when streaming data from a pseudo-terminal into the tty. - - Example: - - with Terminal(sys.stdin, raw=True): - do_things_in_raw_mode() - """ - - def __init__(self, fd, raw=True): - """ - Initialize a terminal for the tty with stdin attached to `fd`. - - Initializing the Terminal has no immediate side effects. The `start()` - method must be invoked, or `with raw_terminal:` used before the - terminal is affected. - """ - - self.fd = fd - self.raw = raw - self.original_attributes = None - - - def __enter__(self): - """ - Invoked when a `with` block is first entered. - """ - - self.start() - return self - - - def __exit__(self, *_): - """ - Invoked when a `with` block is finished. - """ - - self.stop() - - - def israw(self): - """ - Returns True if the TTY should operate in raw mode. - """ - - return self.raw - - - def start(self): - """ - Saves the current terminal attributes and makes the tty raw. - - This method returns None immediately. - """ - - if os.isatty(self.fd.fileno()) and self.israw(): - self.original_attributes = termios.tcgetattr(self.fd) - tty.setraw(self.fd) - - - def stop(self): - """ - Restores the terminal attributes back to before setting raw mode. - - If the raw terminal was not started, does nothing. - """ - - if self.original_attributes is not None: - termios.tcsetattr( - self.fd, - termios.TCSADRAIN, - self.original_attributes, - ) - - def __repr__(self): - return "{cls}({fd}, raw={raw})".format( - cls=type(self).__name__, - fd=self.fd, - raw=self.raw) diff --git a/requirements.txt b/requirements.txt index 1ed9a95dcf3..59aa90f02f9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ PyYAML==3.10 docker-py==0.5.3 +dockerpty==0.3.2 docopt==0.6.1 requests==2.2.1 six==1.7.3 diff --git a/script/test b/script/test index 2ed3f6b4fbb..79cc7e6b2a3 100755 --- a/script/test +++ b/script/test @@ -1,12 +1,5 @@ #!/bin/sh set -ex - -target="tests" - -if [[ -n "$@" ]]; then - target="$@" -fi - docker build -t fig . -docker run -v /var/run/docker.sock:/var/run/docker.sock fig flake8 --exclude=packages fig -docker run -v /var/run/docker.sock:/var/run/docker.sock fig nosetests $target +docker run -v /var/run/docker.sock:/var/run/docker.sock fig flake8 fig +docker run -v /var/run/docker.sock:/var/run/docker.sock fig nosetests $@ diff --git a/setup.py b/setup.py index a4aa23a62d0..fe839f5ad5b 100644 --- a/setup.py +++ b/setup.py @@ -31,6 +31,7 @@ def find_version(*file_paths): 'texttable >= 0.8.1, < 0.9', 'websocket-client >= 0.11.0, < 0.12', 'docker-py >= 0.5.3, < 0.6', + 'dockerpty >= 0.3.2, < 0.4', 'six >= 1.3.0, < 2', ] diff --git a/tests/integration/cli_test.py b/tests/integration/cli_test.py index ceb93f62bcd..3929502d719 100644 --- a/tests/integration/cli_test.py +++ b/tests/integration/cli_test.py @@ -139,13 +139,13 @@ def test_up_with_keep_old(self): self.assertEqual(old_ids, new_ids) - @patch('fig.packages.dockerpty.start') + @patch('dockerpty.start') def test_run_service_without_links(self, mock_stdout): self.command.base_dir = 'tests/fixtures/links-figfile' self.command.dispatch(['run', 'console', '/bin/true'], None) self.assertEqual(len(self.project.containers()), 0) - @patch('fig.packages.dockerpty.start') + @patch('dockerpty.start') def test_run_service_with_links(self, __): self.command.base_dir = 'tests/fixtures/links-figfile' self.command.dispatch(['run', 'web', '/bin/true'], None) @@ -154,14 +154,14 @@ def test_run_service_with_links(self, __): self.assertEqual(len(db.containers()), 1) self.assertEqual(len(console.containers()), 0) - @patch('fig.packages.dockerpty.start') + @patch('dockerpty.start') def test_run_with_no_deps(self, __): self.command.base_dir = 'tests/fixtures/links-figfile' self.command.dispatch(['run', '--no-deps', 'web', '/bin/true'], None) db = self.project.get_service('db') self.assertEqual(len(db.containers()), 0) - @patch('fig.packages.dockerpty.start') + @patch('dockerpty.start') def test_run_does_not_recreate_linked_containers(self, __): self.command.base_dir = 'tests/fixtures/links-figfile' self.command.dispatch(['up', '-d', 'db'], None) @@ -177,7 +177,7 @@ def test_run_does_not_recreate_linked_containers(self, __): self.assertEqual(old_ids, new_ids) - @patch('fig.packages.dockerpty.start') + @patch('dockerpty.start') def test_run_without_command(self, __): self.command.base_dir = 'tests/fixtures/commands-figfile' self.check_build('tests/fixtures/simple-dockerfile', tag='figtest_test') @@ -201,7 +201,7 @@ def test_run_without_command(self, __): [u'/bin/true'], ) - @patch('fig.packages.dockerpty.start') + @patch('dockerpty.start') def test_run_service_with_entrypoint_overridden(self, _): self.command.base_dir = 'tests/fixtures/dockerfile_with_entrypoint' name = 'service' @@ -216,7 +216,7 @@ def test_run_service_with_entrypoint_overridden(self, _): u'/bin/echo helloworld' ) - @patch('fig.packages.dockerpty.start') + @patch('dockerpty.start') def test_run_service_with_environement_overridden(self, _): name = 'service' self.command.base_dir = 'tests/fixtures/environment-figfile' From 8773f515834bf3cb8d7274b4f878dfe2596b3494 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Tue, 4 Nov 2014 10:25:27 +0000 Subject: [PATCH 0605/4072] Don't select stdin when interactive=False Patch from https://github.com/d11wtq/dockerpty/pull/24 Signed-off-by: Ben Firshman --- fig/cli/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fig/cli/main.py b/fig/cli/main.py index e31ba539e53..707b038704c 100644 --- a/fig/cli/main.py +++ b/fig/cli/main.py @@ -323,7 +323,7 @@ def run(self, project, options): print(container.name) else: service.start_container(container, ports=None, one_off=True) - dockerpty.start(project.client, container.id) + dockerpty.start(project.client, container.id, interactive=not options['-T']) exit_code = container.wait() if options['--rm']: log.info("Removing %s..." % container.name) From 9abdd337b5146643084205ad949c017b6ab843a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=BAl=20Cumplido?= Date: Mon, 3 Nov 2014 22:46:01 +0000 Subject: [PATCH 0606/4072] Add signal in the kill CLI commando to send a specific signal to the service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Raúl Cumplido --- docs/cli.md | 4 +++- fig/cli/main.py | 10 ++++++++-- fig/container.py | 4 ++-- tests/integration/cli_test.py | 35 +++++++++++++++++++++++++++++++++++ 4 files changed, 48 insertions(+), 5 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 4462575df02..822f1b78030 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -42,7 +42,9 @@ Get help on a command. ### kill -Force stop service containers. +Force stop running containers by sending a `SIGKILL` signal. Optionally the signal can be passed, for example: + + $ fig kill -s SIGINT ### logs diff --git a/fig/cli/main.py b/fig/cli/main.py index 2ce8cfc3268..2f83c1639f6 100644 --- a/fig/cli/main.py +++ b/fig/cli/main.py @@ -133,9 +133,15 @@ def kill(self, project, options): """ Force stop service containers. - Usage: kill [SERVICE...] + Usage: kill [options] [SERVICE...] + + Options: + -s SIGNAL SIGNAL to send to the container. + Default signal is SIGKILL. """ - project.kill(service_names=options['SERVICE']) + signal = options.get('-s', 'SIGKILL') + + project.kill(service_names=options['SERVICE'], signal=signal) def logs(self, project, options): """ diff --git a/fig/container.py b/fig/container.py index 7e06bde3550..0ab75512062 100644 --- a/fig/container.py +++ b/fig/container.py @@ -124,8 +124,8 @@ def start(self, **options): def stop(self, **options): return self.client.stop(self.id, **options) - def kill(self): - return self.client.kill(self.id) + def kill(self, **options): + return self.client.kill(self.id, **options) def restart(self): return self.client.restart(self.id) diff --git a/tests/integration/cli_test.py b/tests/integration/cli_test.py index ceb93f62bcd..76c0df0f4fc 100644 --- a/tests/integration/cli_test.py +++ b/tests/integration/cli_test.py @@ -84,6 +84,7 @@ def test_build_no_cache(self, mock_stdout): self.command.dispatch(['build', '--no-cache', 'simple'], None) output = mock_stdout.getvalue() self.assertNotIn(cache_indicator, output) + def test_up(self): self.command.dispatch(['up', '-d'], None) service = self.project.get_service('simple') @@ -244,6 +245,40 @@ def test_rm(self): self.command.dispatch(['rm', '--force'], None) self.assertEqual(len(service.containers(stopped=True)), 0) + def test_kill(self): + self.command.dispatch(['up', '-d'], None) + service = self.project.get_service('simple') + self.assertEqual(len(service.containers()), 1) + self.assertTrue(service.containers()[0].is_running) + + self.command.dispatch(['kill'], None) + + self.assertEqual(len(service.containers(stopped=True)), 1) + self.assertFalse(service.containers(stopped=True)[0].is_running) + + def test_kill_signal_sigint(self): + self.command.dispatch(['up', '-d'], None) + service = self.project.get_service('simple') + self.assertEqual(len(service.containers()), 1) + self.assertTrue(service.containers()[0].is_running) + + self.command.dispatch(['kill', '-s', 'SIGINT'], None) + + self.assertEqual(len(service.containers()), 1) + # The container is still running. It has been only interrupted + self.assertTrue(service.containers()[0].is_running) + + def test_kill_interrupted_service(self): + self.command.dispatch(['up', '-d'], None) + service = self.project.get_service('simple') + self.command.dispatch(['kill', '-s', 'SIGINT'], None) + self.assertTrue(service.containers()[0].is_running) + + self.command.dispatch(['kill', '-s', 'SIGKILL'], None) + + self.assertEqual(len(service.containers(stopped=True)), 1) + self.assertFalse(service.containers(stopped=True)[0].is_running) + def test_restart(self): service = self.project.get_service('simple') container = service.create_container() From 46433c70b6501c4317fdcdd84c1e96b49a180860 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Tue, 4 Nov 2014 14:10:44 +0000 Subject: [PATCH 0607/4072] Ship 1.0.1 Signed-off-by: Ben Firshman --- CHANGES.md | 7 +++++++ docs/install.md | 2 +- fig/__init__.py | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 8a256296000..57e958aac96 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,6 +1,13 @@ Change log ========== +1.0.1 (2014-11-04) +------------------ + + - Added an `--allow-insecure-ssl` option to allow `fig up`, `fig run` and `fig pull` to pull from insecure registries. + - Fixed `fig run` not showing output in Jenkins. + - Fixed a bug where Fig couldn't build Dockerfiles with ADD statements pointing at URLs. + 1.0.0 (2014-10-16) ------------------ diff --git a/docs/install.md b/docs/install.md index 6722b4f0154..14fd64c8c58 100644 --- a/docs/install.md +++ b/docs/install.md @@ -18,7 +18,7 @@ There are also guides for [Ubuntu](https://docs.docker.com/installation/ubuntuli Next, install Fig: - curl -L https://github.com/docker/fig/releases/download/1.0.0/fig-`uname -s`-`uname -m` > /usr/local/bin/fig; chmod +x /usr/local/bin/fig + curl -L https://github.com/docker/fig/releases/download/1.0.1/fig-`uname -s`-`uname -m` > /usr/local/bin/fig; chmod +x /usr/local/bin/fig Releases are available for OS X and 64-bit Linux. Fig is also available as a Python package if you're on another platform (or if you prefer that sort of thing): diff --git a/fig/__init__.py b/fig/__init__.py index 9f854545fde..a7b29e0c909 100644 --- a/fig/__init__.py +++ b/fig/__init__.py @@ -1,4 +1,4 @@ from __future__ import unicode_literals from .service import Service # noqa:flake8 -__version__ = '1.0.0' +__version__ = '1.0.1' From 06a1b32c12117e0c618d03193bdf9b018b70a4bf Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Wed, 5 Nov 2014 09:47:48 +0000 Subject: [PATCH 0608/4072] Make @dnephin a maintainer Signed-off-by: Ben Firshman --- MAINTAINERS | 1 + 1 file changed, 1 insertion(+) diff --git a/MAINTAINERS b/MAINTAINERS index 6562a6b953d..8c98b04c32a 100644 --- a/MAINTAINERS +++ b/MAINTAINERS @@ -1,4 +1,5 @@ Aanand Prasad (@aanand) Ben Firshman (@bfirsh) Chris Corbyn (@d11wtq) +Daniel Nephin (@dnephin) Nathan LeClaire (@nathanleclaire) From f98323b79e8371df63dd448064d1255fa86dbf1c Mon Sep 17 00:00:00 2001 From: Andrew Burkett Date: Thu, 6 Nov 2014 16:19:58 -0800 Subject: [PATCH 0609/4072] Support multiple port mappings for same internal port Signed-off-by: Andrew Burkett --- fig/service.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/fig/service.py b/fig/service.py index e6dcf01281b..0648aeb872b 100644 --- a/fig/service.py +++ b/fig/service.py @@ -251,7 +251,13 @@ def start_container_if_stopped(self, container, **options): def start_container(self, container=None, intermediate_container=None, **override_options): container = container or self.create_container(**override_options) options = dict(self.options, **override_options) - ports = dict(split_port(port) for port in options.get('ports') or []) + ports = {} + for port in options.get('ports') or []: + internal_port, external = split_port(port) + if internal_port in ports: + ports[internal_port].append(external) + else: + ports[internal_port] = [external] volume_bindings = dict( build_volume_binding(parse_volume_spec(volume)) From 4f6d02867b4512bd44fe6780037edabc1c7310c2 Mon Sep 17 00:00:00 2001 From: Andrew Burkett Date: Thu, 6 Nov 2014 17:54:45 -0800 Subject: [PATCH 0610/4072] Move to build_port_bindings(). Added Tests Signed-off-by: Andrew Burkett --- fig/service.py | 21 +++++++++++++-------- tests/unit/service_test.py | 14 ++++++++++++++ 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/fig/service.py b/fig/service.py index 0648aeb872b..bbbef7bc43e 100644 --- a/fig/service.py +++ b/fig/service.py @@ -251,13 +251,7 @@ def start_container_if_stopped(self, container, **options): def start_container(self, container=None, intermediate_container=None, **override_options): container = container or self.create_container(**override_options) options = dict(self.options, **override_options) - ports = {} - for port in options.get('ports') or []: - internal_port, external = split_port(port) - if internal_port in ports: - ports[internal_port].append(external) - else: - ports[internal_port] = [external] + port_bindings = build_port_bindings(options.get('ports') or []) volume_bindings = dict( build_volume_binding(parse_volume_spec(volume)) @@ -270,7 +264,7 @@ def start_container(self, container=None, intermediate_container=None, **overrid container.start( links=self._get_links(link_to_self=options.get('one_off', False)), - port_bindings=ports, + port_bindings=port_bindings, binds=volume_bindings, volumes_from=self._get_volumes_from(intermediate_container), privileged=privileged, @@ -498,6 +492,17 @@ def build_volume_binding(volume_spec): return os.path.abspath(os.path.expandvars(external)), internal +def build_port_bindings(ports): + port_bindings = {} + for port in ports: + internal_port, external = split_port(port) + if internal_port in port_bindings: + port_bindings[internal_port].append(external) + else: + port_bindings[internal_port] = [external] + return port_bindings + + def split_port(port): parts = str(port).split(':') if not 1 <= len(parts) <= 3: diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index f1d1c79d910..119b4144062 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -13,6 +13,7 @@ from fig.service import ( ConfigError, split_port, + build_port_bindings, parse_volume_spec, build_volume_binding, APIError, @@ -114,6 +115,19 @@ def test_split_port_invalid(self): with self.assertRaises(ConfigError): split_port("0.0.0.0:1000:2000:tcp") + def test_build_port_bindings_with_one_port(self): + port_bindings = build_port_bindings(["127.0.0.1:1000:1000"]) + self.assertEqual(port_bindings["1000"],[("127.0.0.1","1000")]) + + def test_build_port_bindings_with_matching_internal_ports(self): + port_bindings = build_port_bindings(["127.0.0.1:1000:1000","127.0.0.1:2000:1000"]) + self.assertEqual(port_bindings["1000"],[("127.0.0.1","1000"),("127.0.0.1","2000")]) + + def test_build_port_bindings_with_nonmatching_internal_ports(self): + port_bindings = build_port_bindings(["127.0.0.1:1000:1000","127.0.0.1:2000:2000"]) + self.assertEqual(port_bindings["1000"],[("127.0.0.1","1000")]) + self.assertEqual(port_bindings["2000"],[("127.0.0.1","2000")]) + def test_split_domainname_none(self): service = Service('foo', hostname='name', client=self.mock_client) self.mock_client.containers.return_value = [] From 04da6b035e74e12446724cc6129d3aaf54a6d1d2 Mon Sep 17 00:00:00 2001 From: Paul B Date: Tue, 28 Oct 2014 16:31:09 +0100 Subject: [PATCH 0611/4072] Add restart option to Fig. Related to #478 Signed-off-by: Paul Bonaud --- docs/yml.md | 4 +++- fig/service.py | 23 +++++++++++++++++++++-- tests/integration/service_test.py | 11 +++++++++++ 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/docs/yml.md b/docs/yml.md index dedfa5c1d1c..059d165ca5d 100644 --- a/docs/yml.md +++ b/docs/yml.md @@ -142,7 +142,7 @@ dns: - 9.9.9.9 ``` -### working\_dir, entrypoint, user, hostname, domainname, mem\_limit, privileged +### working\_dir, entrypoint, user, hostname, domainname, mem\_limit, privileged, restart Each of these is a single value, analogous to its [docker run](https://docs.docker.com/reference/run/) counterpart. @@ -156,4 +156,6 @@ domainname: foo.com mem_limit: 1000000000 privileged: true + +restart: always ``` diff --git a/fig/service.py b/fig/service.py index bbbef7bc43e..1685111ce64 100644 --- a/fig/service.py +++ b/fig/service.py @@ -15,7 +15,7 @@ log = logging.getLogger(__name__) -DOCKER_CONFIG_KEYS = ['image', 'command', 'hostname', 'domainname', 'user', 'detach', 'stdin_open', 'tty', 'mem_limit', 'ports', 'environment', 'dns', 'volumes', 'entrypoint', 'privileged', 'volumes_from', 'net', 'working_dir'] +DOCKER_CONFIG_KEYS = ['image', 'command', 'hostname', 'domainname', 'user', 'detach', 'stdin_open', 'tty', 'mem_limit', 'ports', 'environment', 'dns', 'volumes', 'entrypoint', 'privileged', 'volumes_from', 'net', 'working_dir', 'restart'] DOCKER_CONFIG_HINTS = { 'link' : 'links', 'port' : 'ports', @@ -262,6 +262,8 @@ def start_container(self, container=None, intermediate_container=None, **overrid net = options.get('net', 'bridge') dns = options.get('dns', None) + restart = parse_restart_spec(options.get('restart', None)) + container.start( links=self._get_links(link_to_self=options.get('one_off', False)), port_bindings=port_bindings, @@ -270,6 +272,7 @@ def start_container(self, container=None, intermediate_container=None, **overrid privileged=privileged, network_mode=net, dns=dns, + restart_policy=restart ) return container @@ -376,7 +379,7 @@ def _get_container_create_options(self, override_options, one_off=False): container_options['image'] = self._build_tag_name() # Delete options which are only used when starting - for key in ['privileged', 'net', 'dns']: + for key in ['privileged', 'net', 'dns', 'restart']: if key in container_options: del container_options[key] @@ -466,6 +469,22 @@ def get_container_name(container): return name[1:] +def parse_restart_spec(restart_config): + if not restart_config: + return None + parts = restart_config.split(':') + if len(parts) > 2: + raise ConfigError("Restart %s has incorrect format, should be " + "mode[:max_retry]" % restart_config) + if len(parts) == 2: + name, max_retry_count = parts + else: + name, = parts + max_retry_count = 0 + + return {'Name': name, 'MaximumRetryCount': int(max_retry_count)} + + def parse_volume_spec(volume_config): parts = volume_config.split(':') if len(parts) > 3: diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index c288edf6b97..117cf99d634 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -365,6 +365,17 @@ def test_dns_list(self): container = service.start_container().inspect() self.assertEqual(container['HostConfig']['Dns'], ['8.8.8.8', '9.9.9.9']) + def test_restart_always_value(self): + service = self.create_service('web', restart='always') + container = service.start_container().inspect() + self.assertEqual(container['HostConfig']['RestartPolicy']['Name'], 'always') + + def test_restart_on_failure_value(self): + service = self.create_service('web', restart='on-failure:5') + container = service.start_container().inspect() + self.assertEqual(container['HostConfig']['RestartPolicy']['Name'], 'on-failure') + self.assertEqual(container['HostConfig']['RestartPolicy']['MaximumRetryCount'], 5) + def test_working_dir_param(self): service = self.create_service('container', working_dir='/working/dir/sample') container = service.create_container().inspect() From bb85e238e0d450f8e2f3e81daaa518b260b1c8a1 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Thu, 20 Nov 2014 17:23:43 +0000 Subject: [PATCH 0612/4072] Add fig as entrypoint to Dockerfile A step towards "docker run fig". Signed-off-by: Ben Firshman --- Dockerfile | 2 ++ script/build-linux | 4 ++-- script/test | 4 ++-- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index cc6b9990edb..c430950ba63 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,3 +13,5 @@ ADD . /code/ RUN python setup.py install RUN chown -R user /code/ + +ENTRYPOINT ["/usr/local/bin/fig"] diff --git a/script/build-linux b/script/build-linux index 3dc2c643c33..b7b6edf5d3b 100755 --- a/script/build-linux +++ b/script/build-linux @@ -3,6 +3,6 @@ set -ex mkdir -p `pwd`/dist chmod 777 `pwd`/dist docker build -t fig . -docker run -u user -v `pwd`/dist:/code/dist fig pyinstaller -F bin/fig +docker run -u user -v `pwd`/dist:/code/dist --entrypoint pyinstaller fig -F bin/fig mv dist/fig dist/fig-Linux-x86_64 -docker run -u user -v `pwd`/dist:/code/dist fig dist/fig-Linux-x86_64 --version +docker run -u user -v `pwd`/dist:/code/dist --entrypoint dist/fig-Linux-x86_64 fig --version diff --git a/script/test b/script/test index 79cc7e6b2a3..54c2077f411 100755 --- a/script/test +++ b/script/test @@ -1,5 +1,5 @@ #!/bin/sh set -ex docker build -t fig . -docker run -v /var/run/docker.sock:/var/run/docker.sock fig flake8 fig -docker run -v /var/run/docker.sock:/var/run/docker.sock fig nosetests $@ +docker run -v /var/run/docker.sock:/var/run/docker.sock --entrypoint flake8 fig fig +docker run -v /var/run/docker.sock:/var/run/docker.sock --entrypoint nosetests fig $@ From 0150b38b8f3e178cb79d3a56a7bde9148bc753f6 Mon Sep 17 00:00:00 2001 From: Alex Brandt Date: Sat, 25 Oct 2014 21:25:20 -0500 Subject: [PATCH 0613/4072] Add tests to sdist. In order to validate installation it's very convenient to have the tests as part of the source distribution. This greatly assists native packaging that might occur (i.e. Gentoo ebuilds). Signed-off-by: Alex Brandt --- MANIFEST.in | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MANIFEST.in b/MANIFEST.in index 1328f20ea6a..ca9ecbd5bf6 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -4,7 +4,7 @@ include requirements.txt include requirements-dev.txt include tox.ini include *.md -recursive-exclude tests * +recursive-include tests * global-exclude *.pyc global-exclude *.pyo global-exclude *.un~ From e34a62956e21672c6b3676cf9cc56edc182edce1 Mon Sep 17 00:00:00 2001 From: Dan Tenenbaum Date: Wed, 3 Dec 2014 09:44:35 -0800 Subject: [PATCH 0614/4072] interpolate service_name in error message when service has no configuration options. Signed-off-by: Dan Tenenbaum --- fig/project.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fig/project.py b/fig/project.py index 569df38d580..93c102c72b5 100644 --- a/fig/project.py +++ b/fig/project.py @@ -67,7 +67,7 @@ def from_config(cls, name, config, client): dicts = [] for service_name, service in list(config.items()): if not isinstance(service, dict): - raise ConfigurationError('Service "%s" doesn\'t have any configuration options. All top level keys in your fig.yml must map to a dictionary of configuration options.') + raise ConfigurationError('Service "%s" doesn\'t have any configuration options. All top level keys in your fig.yml must map to a dictionary of configuration options.' % service_name) service['name'] = service_name dicts.append(service) return cls.from_dicts(name, dicts, client) From 4e8337c16892d6429822ee06548397b99ddbc52c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20BERSAC?= Date: Mon, 1 Dec 2014 16:50:38 +0100 Subject: [PATCH 0615/4072] Respect --allow-insecure-ssl option for dependencies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Étienne Bersac --- fig/cli/main.py | 1 + 1 file changed, 1 insertion(+) diff --git a/fig/cli/main.py b/fig/cli/main.py index e98fea8666e..2c6a04020f7 100644 --- a/fig/cli/main.py +++ b/fig/cli/main.py @@ -293,6 +293,7 @@ def run(self, project, options): service_names=deps, start_links=True, recreate=False, + insecure_registry=insecure_registry, ) tty = True From 5c581805386d5088dabc3656df1afdb69084fb85 Mon Sep 17 00:00:00 2001 From: Tyler Fenby Date: Thu, 6 Nov 2014 14:38:58 -0500 Subject: [PATCH 0616/4072] Add capability add/drop introduced in Docker 1.2 Signed-off-by: Tyler Fenby --- docs/yml.md | 14 ++++++++++++++ fig/service.py | 10 +++++++--- tests/integration/service_test.py | 10 ++++++++++ 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/docs/yml.md b/docs/yml.md index 059d165ca5d..3096ba83553 100644 --- a/docs/yml.md +++ b/docs/yml.md @@ -142,6 +142,20 @@ dns: - 9.9.9.9 ``` +### 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 +``` + ### working\_dir, entrypoint, user, hostname, domainname, mem\_limit, privileged, restart Each of these is a single value, analogous to its [docker run](https://docs.docker.com/reference/run/) counterpart. diff --git a/fig/service.py b/fig/service.py index 1685111ce64..645b6adfc91 100644 --- a/fig/service.py +++ b/fig/service.py @@ -15,7 +15,7 @@ log = logging.getLogger(__name__) -DOCKER_CONFIG_KEYS = ['image', 'command', 'hostname', 'domainname', 'user', 'detach', 'stdin_open', 'tty', 'mem_limit', 'ports', 'environment', 'dns', 'volumes', 'entrypoint', 'privileged', 'volumes_from', 'net', 'working_dir', 'restart'] +DOCKER_CONFIG_KEYS = ['image', 'command', 'hostname', 'domainname', 'user', 'detach', 'stdin_open', 'tty', 'mem_limit', 'ports', 'environment', 'dns', 'volumes', 'entrypoint', 'privileged', 'volumes_from', 'net', 'working_dir', 'restart', 'cap_add', 'cap_drop'] DOCKER_CONFIG_HINTS = { 'link' : 'links', 'port' : 'ports', @@ -261,6 +261,8 @@ def start_container(self, container=None, intermediate_container=None, **overrid privileged = options.get('privileged', False) net = options.get('net', 'bridge') dns = options.get('dns', None) + cap_add = options.get('cap_add', None) + cap_drop = options.get('cap_drop', None) restart = parse_restart_spec(options.get('restart', None)) @@ -272,7 +274,9 @@ def start_container(self, container=None, intermediate_container=None, **overrid privileged=privileged, network_mode=net, dns=dns, - restart_policy=restart + restart_policy=restart, + cap_add=cap_add, + cap_drop=cap_drop, ) return container @@ -379,7 +383,7 @@ def _get_container_create_options(self, override_options, one_off=False): container_options['image'] = self._build_tag_name() # Delete options which are only used when starting - for key in ['privileged', 'net', 'dns', 'restart']: + for key in ['privileged', 'net', 'dns', 'restart', 'cap_add', 'cap_drop']: if key in container_options: del container_options[key] diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 117cf99d634..9d3e0b126f4 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -376,6 +376,16 @@ def test_restart_on_failure_value(self): self.assertEqual(container['HostConfig']['RestartPolicy']['Name'], 'on-failure') self.assertEqual(container['HostConfig']['RestartPolicy']['MaximumRetryCount'], 5) + def test_cap_add_list(self): + service = self.create_service('web', cap_add=['SYS_ADMIN', 'NET_ADMIN']) + container = service.start_container().inspect() + self.assertEqual(container['HostConfig']['CapAdd'], ['SYS_ADMIN', 'NET_ADMIN']) + + def test_cap_drop_list(self): + service = self.create_service('web', cap_drop=['SYS_ADMIN', 'NET_ADMIN']) + container = service.start_container().inspect() + self.assertEqual(container['HostConfig']['CapDrop'], ['SYS_ADMIN', 'NET_ADMIN']) + def test_working_dir_param(self): service = self.create_service('container', working_dir='/working/dir/sample') container = service.create_container().inspect() From ab77cef7ab41119f3e899af169c0a9053c0ce380 Mon Sep 17 00:00:00 2001 From: Alexander Leishman Date: Mon, 8 Dec 2014 13:04:02 -0800 Subject: [PATCH 0617/4072] Add favicon.ico and links in header Signed-off-by: Alexander Leishman --- docs/_layouts/default.html | 2 ++ docs/img/favicon.ico | Bin 0 -> 1150 bytes 2 files changed, 2 insertions(+) create mode 100644 docs/img/favicon.ico diff --git a/docs/_layouts/default.html b/docs/_layouts/default.html index cc191918548..1f0de50826c 100644 --- a/docs/_layouts/default.html +++ b/docs/_layouts/default.html @@ -8,6 +8,8 @@ + +
diff --git a/docs/img/favicon.ico b/docs/img/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..71c02f1136c5ab430b5c0a61db023684877cf546 GIT binary patch literal 1150 zcmZuwc}x>l7=Nf+7MCT=Kk(nhjG+t|_gxCK1xh)JN|_u<87K}StRRAJAcPnaflWjN zDdG|a2F6j2f`wktld}}jLOBLP0&JRT;sU$)?TLxWjCuKe-}}DzzTbD`Jwj~gvb7~t zenQS4BE*gma-4#?sOPnxDnjVlkq=*E-+qU1aEm38`IW@$WVT&W(T%LiZkeHWXmY2t zZG5+|Su-VS>d8#KcJrh3N-;@uJoZ0^^rA|VQBZO)zVJpwK~wKY*F)={ExHY)UY-L# zV+t{6Cy;yFv|G}pz9SNM9LZ~v?8kGzE+)*>0vkc$^<&{VRi(oEfoIkF6*Tle0sku_ zTp0>5V@BZ_qC`;iC@wY3?v)Q1%ekW3Ly2W~3Fkt^`=y<#bB%iQV!mX2Pf%+@eDMq- zQ^w#PD2F>^7;cOqFnCIMg~$*l(BYco@%EKYO)w$v9VqLNi-uP={%D)pLS}^pf^TLJ z6sHG+=JX3!!-FA*i~j)pB4qg7djL+}GGtfH;kt5GoO$a$r`@!$)u!3R+3#i%9IHhj zPXp#D0|F@rE5V2Wo(j(XQn&^^p!xx=L5}2$)2Pu~7c1rCT~)m+NVqVCAezrBbQs<& zB|-#7Fi&gYAJI?yAA~1+7@nb0obZsq-nj=pYz2z$&+pvUFF#Grn*ryv4(xa>d?VDf zW)*lsBN)*VxCZyZnfCAGBZ0GTFC0Dk;2S1~PnZf9f3)mX%U5<2vZld0r3Ej=0484x zzbIP&Ny>Ld19tKx*oj&?`##vabmLP;3H-v;h)J77)yOX|ZfTYt2@A~#rT0T9{JCmy zlg1E{G6_Mp8Sz;r>X*SSuos`Xbi$L_hrmb$lJcg}Xj+@7P|g;V_bmPuB^*Nlt=*ra zL|ob!qJ$F&PZ|d&!2sV#HQa)vIPNZ?96hu@1qvE0|JLX%CBmA)kAKiDv|MemBAWIb zLwS-#Cirkvbe3{(ztr!)8E=*hj$ZxvB48L9WwWT!ESZZW2K#TNCX)4&=IG6_#kxA( z+RMCZ3$D~zA-rq`D^7#pm=SOj4PXiM2VTeNTfk3(o4 zX|}GDGO6)!gZ8oa&oj%plKJ)KR?CLGeR0#Mdh(lDvAVr3S=#*je8b>#LCHNfFSq7R z{M#FTtCcvU)*m?6YB*f1wAkhk%pMhWj-SYBH;T`E-}f@9yiMf9ze@f&NXT;=Le!L? S literal 0 HcmV?d00001 From 98b6d7be78121a8a353eb670bb651d4b0321ca75 Mon Sep 17 00:00:00 2001 From: Ben Langfeld Date: Fri, 12 Sep 2014 00:57:23 -0300 Subject: [PATCH 0618/4072] Add support for 'env_file' key Signed-off-by: Ben Langfeld --- docs/yml.md | 15 +++++++ fig/service.py | 41 +++++++++++++++--- tests/fixtures/env/one.env | 4 ++ tests/fixtures/env/resolve.env | 4 ++ tests/fixtures/env/two.env | 2 + tests/integration/service_test.py | 6 +++ tests/unit/service_test.py | 69 +++++++++++++++++++++++++++++++ 7 files changed, 135 insertions(+), 6 deletions(-) create mode 100644 tests/fixtures/env/one.env create mode 100644 tests/fixtures/env/resolve.env create mode 100644 tests/fixtures/env/two.env diff --git a/docs/yml.md b/docs/yml.md index 3096ba83553..bd6914f8a15 100644 --- a/docs/yml.md +++ b/docs/yml.md @@ -120,6 +120,21 @@ environment: - SESSION_SECRET ``` +### env_file + +Add environment variables from a file. Can be a single value or a list. + +Environment variables specified in `environment` override these values. + +``` +env_file: + - .env +``` + +``` +RACK_ENV: development +``` + ### net Networking mode. Use the same values as the docker client `--net` parameter. diff --git a/fig/service.py b/fig/service.py index 645b6adfc91..6622db83a95 100644 --- a/fig/service.py +++ b/fig/service.py @@ -15,7 +15,7 @@ log = logging.getLogger(__name__) -DOCKER_CONFIG_KEYS = ['image', 'command', 'hostname', 'domainname', 'user', 'detach', 'stdin_open', 'tty', 'mem_limit', 'ports', 'environment', 'dns', 'volumes', 'entrypoint', 'privileged', 'volumes_from', 'net', 'working_dir', 'restart', 'cap_add', 'cap_drop'] +DOCKER_CONFIG_KEYS = ['image', 'command', 'hostname', 'domainname', 'user', 'detach', 'stdin_open', 'tty', 'mem_limit', 'ports', 'environment', 'env_file', 'dns', 'volumes', 'entrypoint', 'privileged', 'volumes_from', 'net', 'working_dir', 'restart', 'cap_add', 'cap_drop'] DOCKER_CONFIG_HINTS = { 'link' : 'links', 'port' : 'ports', @@ -372,10 +372,7 @@ def _get_container_create_options(self, override_options, one_off=False): (parse_volume_spec(v).internal, {}) for v in container_options['volumes']) - if 'environment' in container_options: - if isinstance(container_options['environment'], list): - container_options['environment'] = dict(split_env(e) for e in container_options['environment']) - container_options['environment'] = dict(resolve_env(k, v) for k, v in container_options['environment'].iteritems()) + container_options['environment'] = merge_environment(container_options) if self.can_be_built(): if len(self.client.images(name=self._build_tag_name())) == 0: @@ -383,7 +380,7 @@ def _get_container_create_options(self, override_options, one_off=False): container_options['image'] = self._build_tag_name() # Delete options which are only used when starting - for key in ['privileged', 'net', 'dns', 'restart', 'cap_add', 'cap_drop']: + for key in ['privileged', 'net', 'dns', 'restart', 'cap_add', 'cap_drop', 'env_file']: if key in container_options: del container_options[key] @@ -543,6 +540,25 @@ def split_port(port): return internal_port, (external_ip, external_port or None) +def merge_environment(options): + env = {} + + if 'env_file' in options: + if isinstance(options['env_file'], list): + for f in options['env_file']: + env.update(env_vars_from_file(f)) + else: + env.update(env_vars_from_file(options['env_file'])) + + if 'environment' in options: + if isinstance(options['environment'], list): + env.update(dict(split_env(e) for e in options['environment'])) + else: + env.update(options['environment']) + + return dict(resolve_env(k, v) for k, v in env.iteritems()) + + def split_env(env): if '=' in env: return env.split('=', 1) @@ -557,3 +573,16 @@ def resolve_env(key, val): return key, os.environ[key] else: return key, '' + + +def env_vars_from_file(filename): + """ + Read in a line delimited file of environment variables. + """ + env = {} + for line in open(filename, 'r'): + line = line.strip() + if line and not line.startswith('#'): + k, v = split_env(line) + env[k] = v + return env diff --git a/tests/fixtures/env/one.env b/tests/fixtures/env/one.env new file mode 100644 index 00000000000..75a4f62ff8e --- /dev/null +++ b/tests/fixtures/env/one.env @@ -0,0 +1,4 @@ +ONE=2 +TWO=1 +THREE=3 +FOO=bar diff --git a/tests/fixtures/env/resolve.env b/tests/fixtures/env/resolve.env new file mode 100644 index 00000000000..720520d29e5 --- /dev/null +++ b/tests/fixtures/env/resolve.env @@ -0,0 +1,4 @@ +FILE_DEF=F1 +FILE_DEF_EMPTY= +ENV_DEF +NO_DEF diff --git a/tests/fixtures/env/two.env b/tests/fixtures/env/two.env new file mode 100644 index 00000000000..3b21871a046 --- /dev/null +++ b/tests/fixtures/env/two.env @@ -0,0 +1,2 @@ +FOO=baz +DOO=dah diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 9d3e0b126f4..3eae62ae6d6 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -397,6 +397,12 @@ def test_split_env(self): for k,v in {'NORMAL': 'F1', 'CONTAINS_EQUALS': 'F=2', 'TRAILING_EQUALS': ''}.iteritems(): self.assertEqual(env[k], v) + def test_env_from_file_combined_with_env(self): + service = self.create_service('web', environment=['ONE=1', 'TWO=2', 'THREE=3'], env_file=['tests/fixtures/env/one.env', 'tests/fixtures/env/two.env']) + env = service.start_container().environment + for k,v in {'ONE': '1', 'TWO': '2', 'THREE': '3', 'FOO': 'baz', 'DOO': 'dah'}.iteritems(): + self.assertEqual(env[k], v) + def test_resolve_env(self): service = self.create_service('web', environment={'FILE_DEF': 'F1', 'FILE_DEF_EMPTY': '', 'ENV_DEF': None, 'NO_DEF': None}) os.environ['FILE_DEF'] = 'E1' diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 119b4144062..e562ebc37f6 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -247,3 +247,72 @@ def test_building_volume_binding_with_home(self): self.assertEqual( binding, ('/home/user', dict(bind='/home/user', ro=False))) + +class ServiceEnvironmentTest(unittest.TestCase): + + def setUp(self): + self.mock_client = mock.create_autospec(docker.Client) + self.mock_client.containers.return_value = [] + + def test_parse_environment(self): + service = Service('foo', + environment=['NORMAL=F1', 'CONTAINS_EQUALS=F=2', 'TRAILING_EQUALS='], + client=self.mock_client, + ) + options = service._get_container_create_options({}) + self.assertEqual( + options['environment'], + {'NORMAL': 'F1', 'CONTAINS_EQUALS': 'F=2', 'TRAILING_EQUALS': ''} + ) + + @mock.patch.dict(os.environ) + def test_resolve_environment(self): + os.environ['FILE_DEF'] = 'E1' + os.environ['FILE_DEF_EMPTY'] = 'E2' + os.environ['ENV_DEF'] = 'E3' + service = Service('foo', + environment={'FILE_DEF': 'F1', 'FILE_DEF_EMPTY': '', 'ENV_DEF': None, 'NO_DEF': None}, + client=self.mock_client, + ) + options = service._get_container_create_options({}) + self.assertEqual( + options['environment'], + {'FILE_DEF': 'F1', 'FILE_DEF_EMPTY': '', 'ENV_DEF': 'E3', 'NO_DEF': ''} + ) + + def test_env_from_file(self): + service = Service('foo', + env_file='tests/fixtures/env/one.env', + client=self.mock_client, + ) + options = service._get_container_create_options({}) + self.assertEqual( + options['environment'], + {'ONE': '2', 'TWO': '1', 'THREE': '3', 'FOO': 'bar'} + ) + + def test_env_from_multiple_files(self): + service = Service('foo', + env_file=['tests/fixtures/env/one.env', 'tests/fixtures/env/two.env'], + client=self.mock_client, + ) + options = service._get_container_create_options({}) + self.assertEqual( + options['environment'], + {'ONE': '2', 'TWO': '1', 'THREE': '3', 'FOO': 'baz', 'DOO': 'dah'} + ) + + @mock.patch.dict(os.environ) + def test_resolve_environment_from_file(self): + os.environ['FILE_DEF'] = 'E1' + os.environ['FILE_DEF_EMPTY'] = 'E2' + os.environ['ENV_DEF'] = 'E3' + service = Service('foo', + env_file=['tests/fixtures/env/resolve.env'], + client=self.mock_client, + ) + options = service._get_container_create_options({}) + self.assertEqual( + options['environment'], + {'FILE_DEF': 'F1', 'FILE_DEF_EMPTY': '', 'ENV_DEF': 'E3', 'NO_DEF': ''} + ) From c12d1d73f032b191c10768c87c1b3c015664e338 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Tue, 9 Dec 2014 10:49:37 -0800 Subject: [PATCH 0619/4072] Remove containers in scripts So we don't clutter up Docker with loads of stopped containers. Signed-off-by: Ben Firshman --- script/build-docs | 2 +- script/build-linux | 4 ++-- script/test | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/script/build-docs b/script/build-docs index cafba23f514..abcaec4a8fc 100755 --- a/script/build-docs +++ b/script/build-docs @@ -1,5 +1,5 @@ #!/bin/bash set -ex pushd docs -fig run jekyll jekyll build +fig run --rm jekyll jekyll build popd diff --git a/script/build-linux b/script/build-linux index b7b6edf5d3b..f7b99210b51 100755 --- a/script/build-linux +++ b/script/build-linux @@ -3,6 +3,6 @@ set -ex mkdir -p `pwd`/dist chmod 777 `pwd`/dist docker build -t fig . -docker run -u user -v `pwd`/dist:/code/dist --entrypoint pyinstaller fig -F bin/fig +docker run -u user -v `pwd`/dist:/code/dist --rm --entrypoint pyinstaller fig -F bin/fig mv dist/fig dist/fig-Linux-x86_64 -docker run -u user -v `pwd`/dist:/code/dist --entrypoint dist/fig-Linux-x86_64 fig --version +docker run -u user -v `pwd`/dist:/code/dist --rm --entrypoint dist/fig-Linux-x86_64 fig --version diff --git a/script/test b/script/test index 54c2077f411..e73ba893d58 100755 --- a/script/test +++ b/script/test @@ -1,5 +1,5 @@ #!/bin/sh set -ex docker build -t fig . -docker run -v /var/run/docker.sock:/var/run/docker.sock --entrypoint flake8 fig fig -docker run -v /var/run/docker.sock:/var/run/docker.sock --entrypoint nosetests fig $@ +docker run -v /var/run/docker.sock:/var/run/docker.sock --rm --entrypoint flake8 fig fig +docker run -v /var/run/docker.sock:/var/run/docker.sock --rm --entrypoint nosetests fig $@ From 788741025efa6d2d6d834037014c383f9f873f92 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Tue, 9 Dec 2014 10:56:36 -0800 Subject: [PATCH 0620/4072] Upgrade to docker-py 0.6.0 Force using remote API version 1.14 so Fig is still compatible with Docker 1.2. Signed-off-by: Ben Firshman --- fig/cli/docker_client.py | 2 +- requirements.txt | 2 +- setup.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/fig/cli/docker_client.py b/fig/cli/docker_client.py index 99615e0177a..88f6147c1be 100644 --- a/fig/cli/docker_client.py +++ b/fig/cli/docker_client.py @@ -31,4 +31,4 @@ def docker_client(): ca_cert=ca_cert, ) - return Client(base_url=base_url, tls=tls_config) + return Client(base_url=base_url, tls=tls_config, version='1.14') diff --git a/requirements.txt b/requirements.txt index 59aa90f02f9..2ccdf59a258 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ PyYAML==3.10 -docker-py==0.5.3 +docker-py==0.6.0 dockerpty==0.3.2 docopt==0.6.1 requests==2.2.1 diff --git a/setup.py b/setup.py index fe839f5ad5b..4cf8e589d98 100644 --- a/setup.py +++ b/setup.py @@ -30,7 +30,7 @@ def find_version(*file_paths): 'requests >= 2.2.1, < 3', 'texttable >= 0.8.1, < 0.9', 'websocket-client >= 0.11.0, < 0.12', - 'docker-py >= 0.5.3, < 0.6', + 'docker-py >= 0.6.0, < 0.7', 'dockerpty >= 0.3.2, < 0.4', 'six >= 1.3.0, < 2', ] From 3c0f297ba60b19e3f6415a8c487258b6dd28f507 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 11 Dec 2014 10:08:39 -0800 Subject: [PATCH 0621/4072] Some minor cleanup from yelp/fig Signed-off-by: Daniel Nephin --- fig/cli/docker_client.py | 3 ++- fig/service.py | 29 ++++++++++++++++++++++++++-- tests/unit/cli/docker_client_test.py | 6 ++++++ tests/unit/service_test.py | 12 +++++------- tox.ini | 4 ++-- 5 files changed, 42 insertions(+), 12 deletions(-) diff --git a/fig/cli/docker_client.py b/fig/cli/docker_client.py index 88f6147c1be..b27948446ca 100644 --- a/fig/cli/docker_client.py +++ b/fig/cli/docker_client.py @@ -31,4 +31,5 @@ def docker_client(): ca_cert=ca_cert, ) - return Client(base_url=base_url, tls=tls_config, version='1.14') + timeout = int(os.environ.get('DOCKER_CLIENT_TIMEOUT', 60)) + return Client(base_url=base_url, tls=tls_config, version='1.14', timeout=timeout) diff --git a/fig/service.py b/fig/service.py index 6622db83a95..5789e2a5f7c 100644 --- a/fig/service.py +++ b/fig/service.py @@ -15,7 +15,30 @@ log = logging.getLogger(__name__) -DOCKER_CONFIG_KEYS = ['image', 'command', 'hostname', 'domainname', 'user', 'detach', 'stdin_open', 'tty', 'mem_limit', 'ports', 'environment', 'env_file', 'dns', 'volumes', 'entrypoint', 'privileged', 'volumes_from', 'net', 'working_dir', 'restart', 'cap_add', 'cap_drop'] +DOCKER_CONFIG_KEYS = [ + 'cap_add', + 'cap_drop', + 'command', + 'detach', + 'dns', + 'domainname', + 'entrypoint', + 'env_file', + 'environment', + 'hostname', + 'image', + 'mem_limit', + 'net', + 'ports', + 'privileged', + 'restart', + 'stdin_open', + 'tty', + 'user', + 'volumes', + 'volumes_from', + 'working_dir', +] DOCKER_CONFIG_HINTS = { 'link' : 'links', 'port' : 'ports', @@ -337,7 +360,9 @@ def _get_volumes_from(self, intermediate_container=None): return volumes_from def _get_container_create_options(self, override_options, one_off=False): - container_options = dict((k, self.options[k]) for k in DOCKER_CONFIG_KEYS if k in self.options) + container_options = dict( + (k, self.options[k]) + for k in DOCKER_CONFIG_KEYS if k in self.options) container_options.update(override_options) container_options['name'] = self._next_container_name( diff --git a/tests/unit/cli/docker_client_test.py b/tests/unit/cli/docker_client_test.py index c8821073364..67575ee0d52 100644 --- a/tests/unit/cli/docker_client_test.py +++ b/tests/unit/cli/docker_client_test.py @@ -14,3 +14,9 @@ def test_docker_client_no_home(self): with mock.patch.dict(os.environ): del os.environ['HOME'] docker_client.docker_client() + + def test_docker_client_with_custom_timeout(self): + with mock.patch.dict(os.environ): + os.environ['DOCKER_CLIENT_TIMEOUT'] = timeout = "300" + client = docker_client.docker_client() + self.assertEqual(client._timeout, int(timeout)) diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index e562ebc37f6..5df6679d6a5 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -165,23 +165,21 @@ def test_split_domainname_weird(self): self.assertEqual(opts['domainname'], 'domain.tld', 'domainname') def test_get_container_not_found(self): - mock_client = mock.create_autospec(docker.Client) - mock_client.containers.return_value = [] - service = Service('foo', client=mock_client) + self.mock_client.containers.return_value = [] + service = Service('foo', client=self.mock_client) self.assertRaises(ValueError, service.get_container) @mock.patch('fig.service.Container', autospec=True) def test_get_container(self, mock_container_class): - mock_client = mock.create_autospec(docker.Client) container_dict = dict(Name='default_foo_2') - mock_client.containers.return_value = [container_dict] - service = Service('foo', client=mock_client) + self.mock_client.containers.return_value = [container_dict] + service = Service('foo', client=self.mock_client) container = service.get_container(number=2) self.assertEqual(container, mock_container_class.from_ps.return_value) mock_container_class.from_ps.assert_called_once_with( - mock_client, container_dict) + self.mock_client, container_dict) @mock.patch('fig.service.log', autospec=True) def test_pull_image(self, mock_log): diff --git a/tox.ini b/tox.ini index f6a81d0bcad..a20d984b704 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py26,py27,py32,py33,pypy +envlist = py26,py27 [testenv] usedevelop=True @@ -7,7 +7,7 @@ deps = -rrequirements.txt -rrequirements-dev.txt commands = - nosetests {posargs} + nosetests -v {posargs} flake8 fig [flake8] From 8ebec9a67f470e94c5dd8f446f5f4197d6db486f Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Mon, 8 Dec 2014 16:03:42 -0800 Subject: [PATCH 0622/4072] Pull latest tag by default This was changed in Docker recently: https://github.com/docker/docker/pull/7759 This means we aren't pulling loads of tags when we only use the latest. Signed-off-by: Ben Firshman --- fig/service.py | 22 ++++++++++++++++++++-- tests/unit/service_test.py | 23 +++++++++++++++++++---- 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/fig/service.py b/fig/service.py index 645b6adfc91..c86c8a97e15 100644 --- a/fig/service.py +++ b/fig/service.py @@ -381,6 +381,8 @@ def _get_container_create_options(self, override_options, one_off=False): if len(self.client.images(name=self._build_tag_name())) == 0: self.build() container_options['image'] = self._build_tag_name() + else: + container_options['image'] = self._get_image_name(container_options['image']) # Delete options which are only used when starting for key in ['privileged', 'net', 'dns', 'restart', 'cap_add', 'cap_drop']: @@ -389,6 +391,12 @@ def _get_container_create_options(self, override_options, one_off=False): return container_options + def _get_image_name(self, image): + repo, tag = parse_repository_tag(image) + if tag == "": + tag = "latest" + return '%s:%s' % (repo, tag) + def build(self, no_cache=False): log.info('Building %s...' % self.name) @@ -435,9 +443,10 @@ def can_be_scaled(self): def pull(self, insecure_registry=False): if 'image' in self.options: - log.info('Pulling %s (%s)...' % (self.name, self.options.get('image'))) + image_name = self._get_image_name(self.options['image']) + log.info('Pulling %s (%s)...' % (self.name, image_name)) self.client.pull( - self.options.get('image'), + image_name, insecure_registry=insecure_registry ) @@ -509,6 +518,15 @@ def parse_volume_spec(volume_config): return VolumeSpec(external, internal, mode) +def parse_repository_tag(s): + if ":" not in s: + return s, "" + repo, tag = s.rsplit(":", 1) + if "/" in tag: + return s, "" + return repo, tag + + def build_volume_binding(volume_spec): internal = {'bind': volume_spec.internal, 'ro': volume_spec.mode == 'ro'} external = os.path.expanduser(volume_spec.external) diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 119b4144062..b4c8a6dc89b 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -17,6 +17,7 @@ parse_volume_spec, build_volume_binding, APIError, + parse_repository_tag, ) @@ -131,7 +132,7 @@ def test_build_port_bindings_with_nonmatching_internal_ports(self): def test_split_domainname_none(self): service = Service('foo', hostname='name', client=self.mock_client) self.mock_client.containers.return_value = [] - opts = service._get_container_create_options({}) + opts = service._get_container_create_options({'image': 'foo'}) self.assertEqual(opts['hostname'], 'name', 'hostname') self.assertFalse('domainname' in opts, 'domainname') @@ -140,7 +141,7 @@ def test_split_domainname_fqdn(self): hostname='name.domain.tld', client=self.mock_client) self.mock_client.containers.return_value = [] - opts = service._get_container_create_options({}) + opts = service._get_container_create_options({'image': 'foo'}) self.assertEqual(opts['hostname'], 'name', 'hostname') self.assertEqual(opts['domainname'], 'domain.tld', 'domainname') @@ -150,7 +151,7 @@ def test_split_domainname_both(self): domainname='domain.tld', client=self.mock_client) self.mock_client.containers.return_value = [] - opts = service._get_container_create_options({}) + opts = service._get_container_create_options({'image': 'foo'}) self.assertEqual(opts['hostname'], 'name', 'hostname') self.assertEqual(opts['domainname'], 'domain.tld', 'domainname') @@ -160,7 +161,7 @@ def test_split_domainname_weird(self): domainname='domain.tld', client=self.mock_client) self.mock_client.containers.return_value = [] - opts = service._get_container_create_options({}) + opts = service._get_container_create_options({'image': 'foo'}) self.assertEqual(opts['hostname'], 'name.sub', 'hostname') self.assertEqual(opts['domainname'], 'domain.tld', 'domainname') @@ -205,6 +206,20 @@ def test_create_container_from_insecure_registry(self, mock_log): self.mock_client.pull.assert_called_once_with('someimage:sometag', insecure_registry=True, stream=True) mock_log.info.assert_called_once_with('Pulling image someimage:sometag...') + def test_parse_repository_tag(self): + self.assertEqual(parse_repository_tag("root"), ("root", "")) + self.assertEqual(parse_repository_tag("root:tag"), ("root", "tag")) + 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")) + + def test_latest_is_used_when_tag_is_not_specified(self): + service = Service('foo', client=self.mock_client, image='someimage') + Container.create = mock.Mock() + service.create_container() + self.assertEqual(Container.create.call_args[1]['image'], 'someimage:latest') + class ServiceVolumesTest(unittest.TestCase): From 8f8e322de26e546d17f61998cbbc337c0d1b025b Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 11 Dec 2014 14:25:26 -0800 Subject: [PATCH 0623/4072] Include image name for env_file tests. Signed-off-by: Daniel Nephin --- tests/unit/service_test.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 8c9ed69f533..336f783fe6d 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -271,6 +271,7 @@ def test_parse_environment(self): service = Service('foo', environment=['NORMAL=F1', 'CONTAINS_EQUALS=F=2', 'TRAILING_EQUALS='], client=self.mock_client, + image='image_name', ) options = service._get_container_create_options({}) self.assertEqual( @@ -286,6 +287,7 @@ def test_resolve_environment(self): service = Service('foo', environment={'FILE_DEF': 'F1', 'FILE_DEF_EMPTY': '', 'ENV_DEF': None, 'NO_DEF': None}, client=self.mock_client, + image='image_name', ) options = service._get_container_create_options({}) self.assertEqual( @@ -297,6 +299,7 @@ def test_env_from_file(self): service = Service('foo', env_file='tests/fixtures/env/one.env', client=self.mock_client, + image='image_name', ) options = service._get_container_create_options({}) self.assertEqual( @@ -308,6 +311,7 @@ def test_env_from_multiple_files(self): service = Service('foo', env_file=['tests/fixtures/env/one.env', 'tests/fixtures/env/two.env'], client=self.mock_client, + image='image_name', ) options = service._get_container_create_options({}) self.assertEqual( @@ -323,6 +327,7 @@ def test_resolve_environment_from_file(self): service = Service('foo', env_file=['tests/fixtures/env/resolve.env'], client=self.mock_client, + image='image_name', ) options = service._get_container_create_options({}) self.assertEqual( From cc834aa5642f2648a18a035478e03a5ce42e8e3e Mon Sep 17 00:00:00 2001 From: Stephen Quebe Date: Mon, 15 Sep 2014 18:18:03 -0400 Subject: [PATCH 0624/4072] Added map service ports option for run command. When using the fig run command, ports defined in fig.yml can be mapped to the host computer using the -service-ports option. Signed-off-by: Stephen Quebe --- docs/cli.md | 5 ++++- fig/cli/main.py | 10 ++++++++-- tests/integration/cli_test.py | 37 +++++++++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 822f1b78030..74c82bedef0 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -77,7 +77,7 @@ For example: By default, linked services will be started, unless they are already running. -One-off commands are started in new containers with the same config as a normal container for that service, so volumes, links, etc will all be created as expected. The only thing different to a normal container is the command will be overridden with the one specified and no ports will be created in case they collide. +One-off commands are started in new containers with the same config as a normal container for that service, so volumes, links, etc will all be created as expected. The only thing different to a normal container is the command will be overridden with the one specified and by default no ports will be created in case they collide. Links are also created between one-off commands and the other containers for that service so you can do stuff like this: @@ -87,6 +87,9 @@ If you do not want linked containers to be started when running the one-off comm $ fig run --no-deps web python manage.py shell +If you want the service's ports to be created and mapped to the host, specify the `--service-ports` flag: + $ fig run --service-ports web python manage.py shell + ### scale Set number of containers to run for a service. diff --git a/fig/cli/main.py b/fig/cli/main.py index 2c6a04020f7..2ba411f717c 100644 --- a/fig/cli/main.py +++ b/fig/cli/main.py @@ -278,6 +278,8 @@ def run(self, project, options): -e KEY=VAL Set an environment variable (can be used multiple times) --no-deps Don't start linked services. --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 `fig run` allocates a TTY. """ @@ -325,11 +327,15 @@ def run(self, project, options): insecure_registry=insecure_registry, **container_options ) + + service_ports = None + if options['--service-ports']: + service_ports = service.options['ports'] if options['-d']: - service.start_container(container, ports=None, one_off=True) + service.start_container(container, ports=service_ports, one_off=True) print(container.name) else: - service.start_container(container, ports=None, one_off=True) + service.start_container(container, ports=service_ports, one_off=True) dockerpty.start(project.client, container.id, interactive=not options['-T']) exit_code = container.wait() if options['--rm']: diff --git a/tests/integration/cli_test.py b/tests/integration/cli_test.py index f03d72d2d88..cdeca9212dd 100644 --- a/tests/integration/cli_test.py +++ b/tests/integration/cli_test.py @@ -237,6 +237,43 @@ def test_run_service_with_environement_overridden(self, _): # make sure a value with a = don't crash out self.assertEqual('moto=bobo', container.environment['allo']) + @patch('dockerpty.start') + def test_run_service_without_map_ports(self, __): + # create one off container + self.command.base_dir = 'tests/fixtures/ports-figfile' + self.command.dispatch(['run', '-d', 'simple'], None) + container = self.project.get_service('simple').containers(one_off=True)[0] + + # get port information + port_random = container.get_local_port(3000) + port_assigned = container.get_local_port(3001) + + # close all one off containers we just created + container.stop() + + # check the ports + self.assertEqual(port_random, None) + self.assertEqual(port_assigned, None) + + @patch('dockerpty.start') + def test_run_service_with_map_ports(self, __): + # create one off container + self.command.base_dir = 'tests/fixtures/ports-figfile' + self.command.dispatch(['run', '-d', '--service-ports', 'simple'], None) + container = self.project.get_service('simple').containers(one_off=True)[0] + + # get port information + port_random = container.get_local_port(3000) + port_assigned = container.get_local_port(3001) + + # close all one off containers we just created + container.stop() + + # check the ports + self.assertNotEqual(port_random, None) + self.assertIn("0.0.0.0", port_random) + self.assertEqual(port_assigned, "0.0.0.0:9999") + def test_rm(self): service = self.project.get_service('simple') service.create_container() From 05544ce2417336f11e61ffc734fb6014dc76a58d Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 12 Dec 2014 17:53:02 -0800 Subject: [PATCH 0625/4072] Stronger integration tests for volume binds Signed-off-by: Aanand Prasad --- tests/integration/service_test.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 3eae62ae6d6..864b30a9f9d 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -1,6 +1,7 @@ from __future__ import unicode_literals from __future__ import absolute_import import os +from os import path from fig import Service from fig.service import CannotBeScaledError @@ -95,10 +96,20 @@ def test_create_container_with_unspecified_volume(self): self.assertIn('/var/db', container.inspect()['Volumes']) def test_create_container_with_specified_volume(self): - service = self.create_service('db', volumes=['/tmp:/host-tmp']) + host_path = '/tmp/host-path' + container_path = '/container-path' + + service = self.create_service('db', volumes=['%s:%s' % (host_path, container_path)]) container = service.create_container() service.start_container(container) - self.assertIn('/host-tmp', container.inspect()['Volumes']) + + volumes = container.inspect()['Volumes'] + self.assertIn(container_path, volumes) + + # Match the last component ("host-path"), because boot2docker symlinks /tmp + actual_host_path = volumes[container_path] + self.assertTrue(path.basename(actual_host_path) == path.basename(host_path), + msg=("Last component differs: %s, %s" % (actual_host_path, host_path))) def test_create_container_with_volumes_from(self): volume_service = self.create_service('data') From 5182bd0968473f9dd6af48900faf827c44d9ae89 Mon Sep 17 00:00:00 2001 From: Mohammad Salehe Date: Sun, 17 Aug 2014 17:48:24 +0430 Subject: [PATCH 0626/4072] Add dns_search support in yml config Signed-off-by: Mohammad Salehe --- docs/yml.md | 11 +++++++++++ fig/service.py | 5 ++++- tests/integration/service_test.py | 10 ++++++++++ 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/docs/yml.md b/docs/yml.md index bd6914f8a15..9ee2d27a32e 100644 --- a/docs/yml.md +++ b/docs/yml.md @@ -171,6 +171,17 @@ cap_drop: - SYS_ADMIN ``` +### dns_search + +Custom DNS search domains. Can be a single value or a list. + +``` +dns_search: example.com +dns: + - dc1.example.com + - dc2.example.com +``` + ### working\_dir, entrypoint, user, hostname, domainname, mem\_limit, privileged, restart Each of these is a single value, analogous to its [docker run](https://docs.docker.com/reference/run/) counterpart. diff --git a/fig/service.py b/fig/service.py index 2abcc6d439f..7c34be922f7 100644 --- a/fig/service.py +++ b/fig/service.py @@ -21,6 +21,7 @@ 'command', 'detach', 'dns', + 'dns_search', 'domainname', 'entrypoint', 'env_file', @@ -284,6 +285,7 @@ def start_container(self, container=None, intermediate_container=None, **overrid privileged = options.get('privileged', False) net = options.get('net', 'bridge') dns = options.get('dns', None) + dns_search = options.get('dns_search', None) cap_add = options.get('cap_add', None) cap_drop = options.get('cap_drop', None) @@ -297,6 +299,7 @@ def start_container(self, container=None, intermediate_container=None, **overrid privileged=privileged, network_mode=net, dns=dns, + dns_search=dns_search, restart_policy=restart, cap_add=cap_add, cap_drop=cap_drop, @@ -407,7 +410,7 @@ def _get_container_create_options(self, override_options, one_off=False): container_options['image'] = self._get_image_name(container_options['image']) # Delete options which are only used when starting - for key in ['privileged', 'net', 'dns', 'restart', 'cap_add', 'cap_drop', 'env_file']: + for key in ['privileged', 'net', 'dns', 'dns_search', 'restart', 'cap_add', 'cap_drop', 'env_file']: if key in container_options: del container_options[key] diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 864b30a9f9d..d755b5afb76 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -397,6 +397,16 @@ def test_cap_drop_list(self): container = service.start_container().inspect() self.assertEqual(container['HostConfig']['CapDrop'], ['SYS_ADMIN', 'NET_ADMIN']) + def test_dns_search_single_value(self): + service = self.create_service('web', dns_search='example.com') + container = service.start_container().inspect() + self.assertEqual(container['HostConfig']['DnsSearch'], ['example.com']) + + def test_dns_search_list(self): + service = self.create_service('web', dns_search=['dc1.example.com', 'dc2.example.com']) + container = service.start_container().inspect() + self.assertEqual(container['HostConfig']['DnsSearch'], ['dc1.example.com', 'dc2.example.com']) + def test_working_dir_param(self): service = self.create_service('container', working_dir='/working/dir/sample') container = service.create_container().inspect() From 3c105c6db2902e786145d51ac21271265aa96282 Mon Sep 17 00:00:00 2001 From: Mohammad Salehe Date: Wed, 1 Oct 2014 20:10:50 +0330 Subject: [PATCH 0627/4072] Fix typo in dns_search documentation Signed-off-by: Mohammad Salehe --- docs/yml.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/yml.md b/docs/yml.md index 9ee2d27a32e..a911e450b86 100644 --- a/docs/yml.md +++ b/docs/yml.md @@ -177,7 +177,7 @@ Custom DNS search domains. Can be a single value or a list. ``` dns_search: example.com -dns: +dns_search: - dc1.example.com - dc2.example.com ``` From e89826fe43969dcc450d98b397e6222b1414e103 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Wed, 17 Dec 2014 18:31:22 -0800 Subject: [PATCH 0628/4072] Don't attach stdin and stdout when in detach mode This is primarily to make it work with Swarm, which checks that AttachStd{in,out,err} is false when creating containers. Signed-off-by: Ben Firshman --- fig/cli/main.py | 3 +++ fig/project.py | 6 +++--- fig/service.py | 10 +++++++--- tests/integration/cli_test.py | 13 +++++++++++++ tests/integration/service_test.py | 8 ++++++++ 5 files changed, 34 insertions(+), 6 deletions(-) diff --git a/fig/cli/main.py b/fig/cli/main.py index 2c6a04020f7..5f5861c3dcd 100644 --- a/fig/cli/main.py +++ b/fig/cli/main.py @@ -294,6 +294,7 @@ def run(self, project, options): start_links=True, recreate=False, insecure_registry=insecure_registry, + detach=options['-d'] ) tty = True @@ -309,6 +310,7 @@ def run(self, project, options): 'command': command, 'tty': tty, 'stdin_open': not options['-d'], + 'detach': options['-d'], } if options['-e']: @@ -432,6 +434,7 @@ def up(self, project, options): start_links=start_links, recreate=recreate, insecure_registry=insecure_registry, + detach=options['-d'] ) to_attach = [c for s in project.get_services(service_names) for c in s.containers()] diff --git a/fig/project.py b/fig/project.py index 93c102c72b5..8d71a4c29fd 100644 --- a/fig/project.py +++ b/fig/project.py @@ -167,14 +167,14 @@ def build(self, service_names=None, no_cache=False): else: log.info('%s uses an image, skipping' % service.name) - def up(self, service_names=None, start_links=True, recreate=True, insecure_registry=False): + def up(self, service_names=None, start_links=True, recreate=True, insecure_registry=False, detach=False): running_containers = [] for service in self.get_services(service_names, include_links=start_links): if recreate: - for (_, container) in service.recreate_containers(insecure_registry=insecure_registry): + for (_, container) in service.recreate_containers(insecure_registry=insecure_registry, detach=detach): running_containers.append(container) else: - for container in service.start_or_create_containers(insecure_registry=insecure_registry): + for container in service.start_or_create_containers(insecure_registry=insecure_registry, detach=detach): running_containers.append(container) return running_containers diff --git a/fig/service.py b/fig/service.py index 2abcc6d439f..5693083d455 100644 --- a/fig/service.py +++ b/fig/service.py @@ -157,7 +157,7 @@ def scale(self, desired_num): # Create enough containers containers = self.containers(stopped=True) while len(containers) < desired_num: - containers.append(self.create_container()) + containers.append(self.create_container(detach=True)) running_containers = [] stopped_containers = [] @@ -251,6 +251,7 @@ def recreate_container(self, container, **override_options): image=container.image, entrypoint=['/bin/echo'], command=[], + detach=True, ) intermediate_container.start(volumes_from=container.id) intermediate_container.wait() @@ -303,12 +304,15 @@ def start_container(self, container=None, intermediate_container=None, **overrid ) return container - def start_or_create_containers(self, insecure_registry=False): + def start_or_create_containers(self, insecure_registry=False, detach=False): containers = self.containers(stopped=True) if not containers: log.info("Creating %s..." % self._next_container_name(containers)) - new_container = self.create_container(insecure_registry=insecure_registry) + new_container = self.create_container( + insecure_registry=insecure_registry, + detach=detach + ) return [self.start_container(new_container)] else: return [self.start_container_if_stopped(c) for c in containers] diff --git a/tests/integration/cli_test.py b/tests/integration/cli_test.py index f03d72d2d88..76e033c4c33 100644 --- a/tests/integration/cli_test.py +++ b/tests/integration/cli_test.py @@ -92,6 +92,12 @@ def test_up(self): self.assertEqual(len(service.containers()), 1) self.assertEqual(len(another.containers()), 1) + # Ensure containers don't have stdin and stdout connected in -d mode + config = service.containers()[0].inspect()['Config'] + self.assertFalse(config['AttachStderr']) + self.assertFalse(config['AttachStdout']) + self.assertFalse(config['AttachStdin']) + def test_up_with_links(self): self.command.base_dir = 'tests/fixtures/links-figfile' self.command.dispatch(['up', '-d', 'web'], None) @@ -146,6 +152,13 @@ def test_run_service_without_links(self, mock_stdout): self.command.dispatch(['run', 'console', '/bin/true'], None) self.assertEqual(len(self.project.containers()), 0) + # Ensure stdin/out was open + container = self.project.containers(stopped=True, one_off=True)[0] + config = container.inspect()['Config'] + self.assertTrue(config['AttachStderr']) + self.assertTrue(config['AttachStdout']) + self.assertTrue(config['AttachStdin']) + @patch('dockerpty.start') def test_run_service_with_links(self, __): self.command.base_dir = 'tests/fixtures/links-figfile' diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 864b30a9f9d..e29ee7e962a 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -332,6 +332,14 @@ def test_scale(self): service = self.create_service('web') service.scale(1) self.assertEqual(len(service.containers()), 1) + + # Ensure containers don't have stdout or stdin connected + container = service.containers()[0] + config = container.inspect()['Config'] + self.assertFalse(config['AttachStderr']) + self.assertFalse(config['AttachStdout']) + self.assertFalse(config['AttachStdin']) + service.scale(3) self.assertEqual(len(service.containers()), 3) service.scale(1) From 42577072443384d2dc35cc4f827bc57239a42757 Mon Sep 17 00:00:00 2001 From: Jason Bernardino Alonso Date: Wed, 8 Oct 2014 22:31:04 -0400 Subject: [PATCH 0629/4072] Accept an external_links list in the service configuration dictionary to create links to containers outside of the project Signed-off-by: Jason Bernardino Alonso Signed-off-by: Mauricio de Abreu Antunes --- docs/yml.md | 12 ++++++++++++ fig/service.py | 12 ++++++++++-- tests/integration/service_test.py | 20 ++++++++++++++++++++ 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/docs/yml.md b/docs/yml.md index a911e450b86..71d2cb7a2c1 100644 --- a/docs/yml.md +++ b/docs/yml.md @@ -58,6 +58,18 @@ An entry with the alias' name will be created in `/etc/hosts` inside containers Environment variables will also be created - see the [environment variable reference](env.html) for details. +### external_links + +Link to containers started outside this `fig.yml` or even outside of fig, 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 +``` + ### ports Expose ports. Either specify both ports (`HOST:CONTAINER`), or just the container port (a random host port will be chosen). diff --git a/fig/service.py b/fig/service.py index bd3000c6207..f88b466d545 100644 --- a/fig/service.py +++ b/fig/service.py @@ -74,7 +74,7 @@ class ConfigError(ValueError): class Service(object): - def __init__(self, name, client=None, project='default', links=None, volumes_from=None, **options): + def __init__(self, name, client=None, project='default', links=None, external_links=None, volumes_from=None, **options): if not re.match('^%s+$' % VALID_NAME_CHARS, name): raise ConfigError('Invalid service name "%s" - only %s are allowed' % (name, VALID_NAME_CHARS)) if not re.match('^%s+$' % VALID_NAME_CHARS, project): @@ -82,7 +82,8 @@ def __init__(self, name, client=None, project='default', links=None, volumes_fro if 'image' in options and 'build' in options: raise ConfigError('Service %s has both an image and build path specified. A service can either be built to image or use an existing image, not both.' % name) - supported_options = DOCKER_CONFIG_KEYS + ['build', 'expose'] + supported_options = DOCKER_CONFIG_KEYS + ['build', 'expose', + 'external_links'] for k in options: if k not in supported_options: @@ -95,6 +96,7 @@ def __init__(self, name, client=None, project='default', links=None, volumes_fro self.client = client self.project = project self.links = links or [] + self.external_links = external_links or [] self.volumes_from = volumes_from or [] self.options = options @@ -345,6 +347,12 @@ def _get_links(self, link_to_self): links.append((container.name, self.name)) links.append((container.name, container.name)) links.append((container.name, container.name_without_project)) + for external_link in self.external_links: + 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 def _get_volumes_from(self, intermediate_container=None): diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index a1740272b53..bbe348eedb5 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -219,6 +219,26 @@ def test_start_container_creates_links_with_names(self): ]), ) + def test_start_container_with_external_links(self): + db = self.create_service('db') + web = self.create_service('web', external_links=['figtest_db_1', + 'figtest_db_2', + 'figtest_db_3:db_3']) + + db.start_container() + db.start_container() + db.start_container() + web.start_container() + + self.assertEqual( + set(web.containers()[0].links()), + set([ + 'figtest_db_1', + 'figtest_db_2', + 'db_3', + ]), + ) + def test_start_normal_container_does_not_create_links_to_its_own_service(self): db = self.create_service('db') From c885aaa5f8aa2ee9af8492ea5736930509161f18 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 31 Dec 2014 18:05:34 +0000 Subject: [PATCH 0630/4072] ROADMAP.md Signed-off-by: Aanand Prasad --- ROADMAP.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 ROADMAP.md diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 00000000000..c99188f072a --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,20 @@ +# Roadmap + +## Fig 1.1 + +- All this cool stuff: https://github.com/docker/fig/issues?q=milestone%3A1.1.0+ + +## Compose 1.2 + +- Project-wide rename and rebrand to Docker Compose, with new names for the command-line tool and configuration file +- Version specifier in configuration file +- A “fig watch” command which automatically kicks off builds while editing code +- It should be possible to somehow define hostnames for containers which work from the host machine, e.g. “mywebcontainer.local”. This is needed by e.g. apps comprising multiple web services which generate links to one another (e.g. a frontend website and a separate admin webapp) +- A way to share config between apps ([#318](https://github.com/docker/fig/issues/318)) + +## Future + +- Fig uses Docker container names to separate and identify containers in different apps and belonging to different services within apps; this should really be done in a less hacky and more performant way, by attaching metadata to containers and doing the filtering on the server side. **This requires changes to the Docker daemon.** +- The config file should be parameterisable so that config can be partially modified for different environments (dev/test/staging/prod), passing in e.g. custom ports or volume mount paths. ([#426](https://github.com/docker/fig/issues/426)) +- Fig’s brute-force “delete and recreate everything” approach is great for dev and testing, and with manual command-line scoping can be made to work in production (e.g. “fig up -d web” will update *just* the web service), but a smarter solution is needed, its logic probably based around convergence from “current” to “desired” app state. +- Compose should recommend a simple technique for zero-downtime deploys. This will likely involve new Docker networking methods that allow for a load balancer container to be dynamically hooked up to new app containers and disconnected from old ones. From 5b777ee5f18c0c8e3766550c62b69a89fa7a5642 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Sun, 26 Oct 2014 13:22:16 -0400 Subject: [PATCH 0631/4072] Cleanup service unit tests and restructure some service create logic. Signed-off-by: Daniel Nephin --- fig/service.py | 49 +++++++++++++----- tests/integration/service_test.py | 85 ++++++++++++++++--------------- tests/unit/service_test.py | 26 +++++++--- 3 files changed, 98 insertions(+), 62 deletions(-) diff --git a/fig/service.py b/fig/service.py index bd3000c6207..461bc1fe56b 100644 --- a/fig/service.py +++ b/fig/service.py @@ -50,6 +50,17 @@ 'workdir' : 'working_dir', } +DOCKER_START_KEYS = [ + 'cap_add', + 'cap_drop', + 'dns', + 'dns_search', + 'env_file', + 'net', + 'privileged', + 'restart', +] + VALID_NAME_CHARS = '[a-zA-Z0-9]' @@ -145,7 +156,8 @@ def restart(self, **options): def scale(self, desired_num): """ - Adjusts the number of containers to the specified number and ensures they are running. + Adjusts the number of containers to the specified number and ensures + they are running. - creates containers until there are at least `desired_num` - stops containers until there are at most `desired_num` running @@ -192,12 +204,24 @@ def remove_stopped(self, **options): log.info("Removing %s..." % c.name) c.remove(**options) - def create_container(self, one_off=False, insecure_registry=False, **override_options): + def create_container(self, + one_off=False, + insecure_registry=False, + do_build=True, + **override_options): """ Create a container for this service. If the image doesn't exist, attempt to pull it. """ - container_options = self._get_container_create_options(override_options, one_off=one_off) + container_options = self._get_container_create_options( + override_options, + one_off=one_off) + + if (do_build and + self.can_be_built() and + not self.client.images(name=self.full_name)): + self.build() + try: return Container.create(self.client, **container_options) except APIError as e: @@ -273,8 +297,7 @@ def start_container_if_stopped(self, container, **options): log.info("Starting %s..." % container.name) return self.start_container(container, **options) - def start_container(self, container=None, intermediate_container=None, **override_options): - container = container or self.create_container(**override_options) + def start_container(self, container, intermediate_container=None, **override_options): options = dict(self.options, **override_options) port_bindings = build_port_bindings(options.get('ports') or []) @@ -407,16 +430,13 @@ def _get_container_create_options(self, override_options, one_off=False): container_options['environment'] = merge_environment(container_options) if self.can_be_built(): - if len(self.client.images(name=self._build_tag_name())) == 0: - self.build() - container_options['image'] = self._build_tag_name() + container_options['image'] = self.full_name else: container_options['image'] = self._get_image_name(container_options['image']) # Delete options which are only used when starting - for key in ['privileged', 'net', 'dns', 'dns_search', 'restart', 'cap_add', 'cap_drop', 'env_file']: - if key in container_options: - del container_options[key] + for key in DOCKER_START_KEYS: + container_options.pop(key, None) return container_options @@ -431,7 +451,7 @@ def build(self, no_cache=False): build_output = self.client.build( self.options['build'], - tag=self._build_tag_name(), + tag=self.full_name, stream=True, rm=True, nocache=no_cache, @@ -451,14 +471,15 @@ def build(self, no_cache=False): image_id = match.group(1) if image_id is None: - raise BuildError(self) + raise BuildError(self, event if all_events else 'Unknown') return image_id def can_be_built(self): return 'build' in self.options - def _build_tag_name(self): + @property + def full_name(self): """ The tag to give to images built for this service. """ diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index a1740272b53..0e08ac1cbe8 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -10,19 +10,24 @@ from .testcases import DockerClientTestCase +def create_and_start_container(service, **override_options): + container = service.create_container(**override_options) + return service.start_container(container, **override_options) + + class ServiceTest(DockerClientTestCase): def test_containers(self): foo = self.create_service('foo') bar = self.create_service('bar') - foo.start_container() + create_and_start_container(foo) self.assertEqual(len(foo.containers()), 1) self.assertEqual(foo.containers()[0].name, 'figtest_foo_1') self.assertEqual(len(bar.containers()), 0) - bar.start_container() - bar.start_container() + create_and_start_container(bar) + create_and_start_container(bar) self.assertEqual(len(foo.containers()), 1) self.assertEqual(len(bar.containers()), 2) @@ -39,7 +44,7 @@ def test_containers_one_off(self): def test_project_is_added_to_container_name(self): service = self.create_service('web') - service.start_container() + create_and_start_container(service) self.assertEqual(service.containers()[0].name, 'figtest_web_1') def test_start_stop(self): @@ -65,7 +70,7 @@ def test_start_stop(self): def test_kill_remove(self): service = self.create_service('scalingtest') - service.start_container() + create_and_start_container(service) self.assertEqual(len(service.containers()), 1) service.remove_stopped() @@ -177,21 +182,21 @@ def test_recreate_containers_when_containers_are_stopped(self): def test_start_container_passes_through_options(self): db = self.create_service('db') - db.start_container(environment={'FOO': 'BAR'}) + create_and_start_container(db, environment={'FOO': 'BAR'}) self.assertEqual(db.containers()[0].environment['FOO'], 'BAR') def test_start_container_inherits_options_from_constructor(self): db = self.create_service('db', environment={'FOO': 'BAR'}) - db.start_container() + create_and_start_container(db) self.assertEqual(db.containers()[0].environment['FOO'], 'BAR') def test_start_container_creates_links(self): db = self.create_service('db') web = self.create_service('web', links=[(db, None)]) - db.start_container() - db.start_container() - web.start_container() + create_and_start_container(db) + create_and_start_container(db) + create_and_start_container(web) self.assertEqual( set(web.containers()[0].links()), @@ -206,9 +211,9 @@ def test_start_container_creates_links_with_names(self): db = self.create_service('db') web = self.create_service('web', links=[(db, 'custom_link_name')]) - db.start_container() - db.start_container() - web.start_container() + create_and_start_container(db) + create_and_start_container(db) + create_and_start_container(web) self.assertEqual( set(web.containers()[0].links()), @@ -222,19 +227,19 @@ def test_start_container_creates_links_with_names(self): def test_start_normal_container_does_not_create_links_to_its_own_service(self): db = self.create_service('db') - db.start_container() - db.start_container() + create_and_start_container(db) + create_and_start_container(db) - c = db.start_container() + c = create_and_start_container(db) self.assertEqual(set(c.links()), set([])) def test_start_one_off_container_creates_links_to_its_own_service(self): db = self.create_service('db') - db.start_container() - db.start_container() + create_and_start_container(db) + create_and_start_container(db) - c = db.start_container(one_off=True) + c = create_and_start_container(db, one_off=True) self.assertEqual( set(c.links()), @@ -252,7 +257,7 @@ def test_start_container_builds_images(self): build='tests/fixtures/simple-dockerfile', project='figtest', ) - container = service.start_container() + container = create_and_start_container(service) container.wait() self.assertIn('success', container.logs()) self.assertEqual(len(self.client.images(name='figtest_test')), 1) @@ -265,45 +270,45 @@ def test_start_container_uses_tagged_image_if_it_exists(self): build='this/does/not/exist/and/will/throw/error', project='figtest', ) - container = service.start_container() + container = create_and_start_container(service) container.wait() self.assertIn('success', container.logs()) def test_start_container_creates_ports(self): service = self.create_service('web', ports=[8000]) - container = service.start_container().inspect() + container = create_and_start_container(service).inspect() self.assertEqual(list(container['NetworkSettings']['Ports'].keys()), ['8000/tcp']) self.assertNotEqual(container['NetworkSettings']['Ports']['8000/tcp'][0]['HostPort'], '8000') def test_start_container_stays_unpriviliged(self): service = self.create_service('web') - container = service.start_container().inspect() + container = create_and_start_container(service).inspect() self.assertEqual(container['HostConfig']['Privileged'], False) def test_start_container_becomes_priviliged(self): service = self.create_service('web', privileged = True) - container = service.start_container().inspect() + container = create_and_start_container(service).inspect() self.assertEqual(container['HostConfig']['Privileged'], True) def test_expose_does_not_publish_ports(self): service = self.create_service('web', expose=[8000]) - container = service.start_container().inspect() + container = create_and_start_container(service).inspect() self.assertEqual(container['NetworkSettings']['Ports'], {'8000/tcp': None}) def test_start_container_creates_port_with_explicit_protocol(self): service = self.create_service('web', ports=['8000/udp']) - container = service.start_container().inspect() + container = create_and_start_container(service).inspect() self.assertEqual(list(container['NetworkSettings']['Ports'].keys()), ['8000/udp']) def test_start_container_creates_fixed_external_ports(self): service = self.create_service('web', ports=['8000:8000']) - container = service.start_container().inspect() + container = create_and_start_container(service).inspect() self.assertIn('8000/tcp', container['NetworkSettings']['Ports']) self.assertEqual(container['NetworkSettings']['Ports']['8000/tcp'][0]['HostPort'], '8000') def test_start_container_creates_fixed_external_ports_when_it_is_different_to_internal_port(self): service = self.create_service('web', ports=['8001:8000']) - container = service.start_container().inspect() + container = create_and_start_container(service).inspect() self.assertIn('8000/tcp', container['NetworkSettings']['Ports']) self.assertEqual(container['NetworkSettings']['Ports']['8000/tcp'][0]['HostPort'], '8001') @@ -312,7 +317,7 @@ def test_port_with_explicit_interface(self): '127.0.0.1:8001:8000', '0.0.0.0:9001:9000/udp', ]) - container = service.start_container().inspect() + container = create_and_start_container(service).inspect() self.assertEqual(container['NetworkSettings']['Ports'], { '8000/tcp': [ { @@ -361,28 +366,28 @@ def test_scale_sets_ports(self): def test_network_mode_none(self): service = self.create_service('web', net='none') - container = service.start_container() + 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='bridge') - container = service.start_container() + 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='host') - container = service.start_container() + container = create_and_start_container(service) self.assertEqual(container.get('HostConfig.NetworkMode'), 'host') def test_dns_single_value(self): service = self.create_service('web', dns='8.8.8.8') - container = service.start_container().inspect() - self.assertEqual(container['HostConfig']['Dns'], ['8.8.8.8']) + container = create_and_start_container(service) + self.assertEqual(container.get('HostConfig.Dns'), ['8.8.8.8']) def test_dns_list(self): service = self.create_service('web', dns=['8.8.8.8', '9.9.9.9']) - container = service.start_container().inspect() - self.assertEqual(container['HostConfig']['Dns'], ['8.8.8.8', '9.9.9.9']) + container = create_and_start_container(service) + self.assertEqual(container.get('HostConfig.Dns'), ['8.8.8.8', '9.9.9.9']) def test_restart_always_value(self): service = self.create_service('web', restart='always') @@ -417,12 +422,12 @@ def test_dns_search_list(self): def test_working_dir_param(self): service = self.create_service('container', working_dir='/working/dir/sample') - container = service.create_container().inspect() - self.assertEqual(container['Config']['WorkingDir'], '/working/dir/sample') + container = service.create_container() + 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=']) - env = service.start_container().environment + env = create_and_start_container(service).environment for k,v in {'NORMAL': 'F1', 'CONTAINS_EQUALS': 'F=2', 'TRAILING_EQUALS': ''}.iteritems(): self.assertEqual(env[k], v) @@ -438,7 +443,7 @@ def test_resolve_env(self): os.environ['FILE_DEF_EMPTY'] = 'E2' os.environ['ENV_DEF'] = 'E3' try: - env = service.start_container().environment + env = create_and_start_container(service).environment for k,v in {'FILE_DEF': 'F1', 'FILE_DEF_EMPTY': '', 'ENV_DEF': 'E3', 'NO_DEF': ''}.iteritems(): self.assertEqual(env[k], v) finally: diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 336f783fe6d..88ebd1d6b8f 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -189,20 +189,30 @@ def test_pull_image(self, mock_log): self.mock_client.pull.assert_called_once_with('someimage:sometag', insecure_registry=True) mock_log.info.assert_called_once_with('Pulling foo (someimage:sometag)...') + @mock.patch('fig.service.Container', autospec=True) @mock.patch('fig.service.log', autospec=True) - def test_create_container_from_insecure_registry(self, mock_log): + def test_create_container_from_insecure_registry( + self, + mock_log, + mock_container): service = Service('foo', client=self.mock_client, image='someimage:sometag') mock_response = mock.Mock(Response) mock_response.status_code = 404 mock_response.reason = "Not Found" - Container.create = mock.Mock() - Container.create.side_effect = APIError('Mock error', mock_response, "No such image") - try: + mock_container.create.side_effect = APIError( + 'Mock error', mock_response, "No such image") + + # We expect the APIError because our service requires a + # non-existent image. + with self.assertRaises(APIError): service.create_container(insecure_registry=True) - except APIError: # We expect the APIError because our service requires a non-existent image. - pass - self.mock_client.pull.assert_called_once_with('someimage:sometag', insecure_registry=True, stream=True) - mock_log.info.assert_called_once_with('Pulling image someimage:sometag...') + + self.mock_client.pull.assert_called_once_with( + 'someimage:sometag', + insecure_registry=True, + stream=True) + mock_log.info.assert_called_once_with( + 'Pulling image someimage:sometag...') def test_parse_repository_tag(self): self.assertEqual(parse_repository_tag("root"), ("root", "")) From 3056ae4be329333e0f63568fc7d20a1d379b4172 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Sun, 26 Oct 2014 13:23:15 -0400 Subject: [PATCH 0632/4072] Add a no-build option to fig up, to save time when services were already freshly built. Signed-off-by: Daniel Nephin --- fig/cli/main.py | 4 +++- fig/project.py | 18 +++++++++++++++--- fig/service.py | 20 ++++++++++++++------ tests/integration/service_test.py | 28 ++++++++++++++-------------- tests/unit/service_test.py | 17 +++++++++++++++++ 5 files changed, 63 insertions(+), 24 deletions(-) diff --git a/fig/cli/main.py b/fig/cli/main.py index c367266bbfa..8cdaee62090 100644 --- a/fig/cli/main.py +++ b/fig/cli/main.py @@ -425,6 +425,7 @@ def up(self, project, options): --no-color Produce monochrome output. --no-deps Don't start linked services. --no-recreate If containers already exist, don't recreate them. + --no-build Don't build an image, even if it's missing """ insecure_registry = options['--allow-insecure-ssl'] detached = options['-d'] @@ -440,7 +441,8 @@ def up(self, project, options): start_links=start_links, recreate=recreate, insecure_registry=insecure_registry, - detach=options['-d'] + detach=options['-d'], + do_build=not options['--no-build'], ) to_attach = [c for s in project.get_services(service_names) for c in s.containers()] diff --git a/fig/project.py b/fig/project.py index 8d71a4c29fd..e013da4e980 100644 --- a/fig/project.py +++ b/fig/project.py @@ -167,14 +167,26 @@ def build(self, service_names=None, no_cache=False): else: log.info('%s uses an image, skipping' % service.name) - def up(self, service_names=None, start_links=True, recreate=True, insecure_registry=False, detach=False): + def up(self, + service_names=None, + start_links=True, + recreate=True, + insecure_registry=False, + detach=False, + do_build=True): running_containers = [] for service in self.get_services(service_names, include_links=start_links): if recreate: - for (_, container) in service.recreate_containers(insecure_registry=insecure_registry, detach=detach): + for (_, container) in service.recreate_containers( + insecure_registry=insecure_registry, + detach=detach, + do_build=do_build): running_containers.append(container) else: - for container in service.start_or_create_containers(insecure_registry=insecure_registry, detach=detach): + for container in service.start_or_create_containers( + insecure_registry=insecure_registry, + detach=detach, + do_build=do_build): running_containers.append(container) return running_containers diff --git a/fig/service.py b/fig/service.py index 461bc1fe56b..d06d271f682 100644 --- a/fig/service.py +++ b/fig/service.py @@ -54,7 +54,7 @@ 'cap_add', 'cap_drop', 'dns', - 'dns_search', + 'dns_search', 'env_file', 'net', 'privileged', @@ -236,7 +236,7 @@ def create_container(self, return Container.create(self.client, **container_options) raise - def recreate_containers(self, insecure_registry=False, **override_options): + def recreate_containers(self, insecure_registry=False, do_build=True, **override_options): """ If a container for this service doesn't exist, create and start one. If there are any, stop them, create+start new ones, and remove the old containers. @@ -244,7 +244,10 @@ def recreate_containers(self, insecure_registry=False, **override_options): containers = self.containers(stopped=True) if not containers: log.info("Creating %s..." % self._next_container_name(containers)) - container = self.create_container(insecure_registry=insecure_registry, **override_options) + container = self.create_container( + insecure_registry=insecure_registry, + do_build=do_build, + **override_options) self.start_container(container) return [(None, container)] else: @@ -283,7 +286,7 @@ def recreate_container(self, container, **override_options): container.remove() options = dict(override_options) - new_container = self.create_container(**options) + new_container = self.create_container(do_build=False, **options) self.start_container(new_container, intermediate_container=intermediate_container) intermediate_container.remove() @@ -330,14 +333,19 @@ def start_container(self, container, intermediate_container=None, **override_opt ) return container - def start_or_create_containers(self, insecure_registry=False, detach=False): + def start_or_create_containers( + self, + insecure_registry=False, + detach=False, + do_build=True): containers = self.containers(stopped=True) if not containers: log.info("Creating %s..." % self._next_container_name(containers)) new_container = self.create_container( insecure_registry=insecure_registry, - detach=detach + detach=detach, + do_build=do_build, ) return [self.start_container(new_container)] else: diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 0e08ac1cbe8..fca4da67d71 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -391,34 +391,34 @@ def test_dns_list(self): def test_restart_always_value(self): service = self.create_service('web', restart='always') - container = service.start_container().inspect() - self.assertEqual(container['HostConfig']['RestartPolicy']['Name'], 'always') + container = create_and_start_container(service) + self.assertEqual(container.get('HostConfig.RestartPolicy.Name'), 'always') def test_restart_on_failure_value(self): service = self.create_service('web', restart='on-failure:5') - container = service.start_container().inspect() - self.assertEqual(container['HostConfig']['RestartPolicy']['Name'], 'on-failure') - self.assertEqual(container['HostConfig']['RestartPolicy']['MaximumRetryCount'], 5) + container = create_and_start_container(service) + self.assertEqual(container.get('HostConfig.RestartPolicy.Name'), 'on-failure') + self.assertEqual(container.get('HostConfig.RestartPolicy.MaximumRetryCount'), 5) def test_cap_add_list(self): service = self.create_service('web', cap_add=['SYS_ADMIN', 'NET_ADMIN']) - container = service.start_container().inspect() - self.assertEqual(container['HostConfig']['CapAdd'], ['SYS_ADMIN', 'NET_ADMIN']) + container = create_and_start_container(service) + self.assertEqual(container.get('HostConfig.CapAdd'), ['SYS_ADMIN', 'NET_ADMIN']) def test_cap_drop_list(self): service = self.create_service('web', cap_drop=['SYS_ADMIN', 'NET_ADMIN']) - container = service.start_container().inspect() - self.assertEqual(container['HostConfig']['CapDrop'], ['SYS_ADMIN', 'NET_ADMIN']) + container = create_and_start_container(service) + self.assertEqual(container.get('HostConfig.CapDrop'), ['SYS_ADMIN', 'NET_ADMIN']) def test_dns_search_single_value(self): service = self.create_service('web', dns_search='example.com') - container = service.start_container().inspect() - self.assertEqual(container['HostConfig']['DnsSearch'], ['example.com']) + container = create_and_start_container(service) + self.assertEqual(container.get('HostConfig.DnsSearch'), ['example.com']) def test_dns_search_list(self): service = self.create_service('web', dns_search=['dc1.example.com', 'dc2.example.com']) - container = service.start_container().inspect() - self.assertEqual(container['HostConfig']['DnsSearch'], ['dc1.example.com', 'dc2.example.com']) + container = create_and_start_container(service) + self.assertEqual(container.get('HostConfig.DnsSearch'), ['dc1.example.com', 'dc2.example.com']) def test_working_dir_param(self): service = self.create_service('container', working_dir='/working/dir/sample') @@ -433,7 +433,7 @@ def test_split_env(self): def test_env_from_file_combined_with_env(self): service = self.create_service('web', environment=['ONE=1', 'TWO=2', 'THREE=3'], env_file=['tests/fixtures/env/one.env', 'tests/fixtures/env/two.env']) - env = service.start_container().environment + env = create_and_start_container(service).environment for k,v in {'ONE': '1', 'TWO': '2', 'THREE': '3', 'FOO': 'baz', 'DOO': 'dah'}.iteritems(): self.assertEqual(env[k], v) diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 88ebd1d6b8f..68dcf06ab46 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -228,6 +228,23 @@ def test_latest_is_used_when_tag_is_not_specified(self): service.create_container() self.assertEqual(Container.create.call_args[1]['image'], 'someimage:latest') + def test_create_container_with_build(self): + self.mock_client.images.return_value = [] + service = Service('foo', client=self.mock_client, build='.') + service.build = mock.create_autospec(service.build) + service.create_container(do_build=True) + + self.mock_client.images.assert_called_once_with(name=service.full_name) + service.build.assert_called_once_with() + + def test_create_container_no_build(self): + self.mock_client.images.return_value = [] + service = Service('foo', client=self.mock_client, build='.') + service.create_container(do_build=False) + + self.assertFalse(self.mock_client.images.called) + self.assertFalse(self.mock_client.build.called) + class ServiceVolumesTest(unittest.TestCase): From 91c90a722abb604598c73c34f56d83a2ba2eeadd Mon Sep 17 00:00:00 2001 From: Christophe Labouisse Date: Sat, 3 Jan 2015 19:18:22 +0100 Subject: [PATCH 0633/4072] Added missing options The stdin_open and tty options are supported by fig but were missing from the documentation. Signed-off-by: Christophe Labouisse --- docs/yml.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/yml.md b/docs/yml.md index a911e450b86..831e6a2ff3e 100644 --- a/docs/yml.md +++ b/docs/yml.md @@ -182,7 +182,7 @@ dns_search: - dc2.example.com ``` -### working\_dir, entrypoint, user, hostname, domainname, mem\_limit, privileged, restart +### working\_dir, entrypoint, user, hostname, domainname, mem\_limit, privileged, restart, stdin\_open, tty Each of these is a single value, analogous to its [docker run](https://docs.docker.com/reference/run/) counterpart. @@ -198,4 +198,7 @@ mem_limit: 1000000000 privileged: true restart: always + +stdin_open: true +tty: true ``` From b252300e94843763c5d229d7a2b7bc8412ffd38a Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Tue, 6 Jan 2015 11:14:39 +0000 Subject: [PATCH 0634/4072] Reorganised roadmap, adding high-level goals Signed-off-by: Ben Firshman --- ROADMAP.md | 34 +++++++++++++++++++++------------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c99188f072a..0ee228955a0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,20 +1,28 @@ # Roadmap -## Fig 1.1 +Fig will be incorporated as part of the Docker ecosystem and renamed Docker Compose. The command-line tool and configuration file will get new names, and its documentation will be moved to [docs.docker.com](https://docs.docker.com). -- All this cool stuff: https://github.com/docker/fig/issues?q=milestone%3A1.1.0+ +## More than just development environments -## Compose 1.2 +Over time we will extend Fig's remit to cover test, staging and production environments. This is not a simple task, and will take many incremental improvements such as: -- Project-wide rename and rebrand to Docker Compose, with new names for the command-line tool and configuration file -- Version specifier in configuration file -- A “fig watch” command which automatically kicks off builds while editing code -- It should be possible to somehow define hostnames for containers which work from the host machine, e.g. “mywebcontainer.local”. This is needed by e.g. apps comprising multiple web services which generate links to one another (e.g. a frontend website and a separate admin webapp) -- A way to share config between apps ([#318](https://github.com/docker/fig/issues/318)) +- Fig’s brute-force “delete and recreate everything” approach is great for dev and testing, but it not sufficient for production environments. You should be able to define a "desired" state that Fig will intelligently converge to. +- 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. ([#426](https://github.com/docker/fig/issues/426)) +- Fig recommend a technique for zero-downtime deploys. -## Future +## Integration with Swarm -- Fig uses Docker container names to separate and identify containers in different apps and belonging to different services within apps; this should really be done in a less hacky and more performant way, by attaching metadata to containers and doing the filtering on the server side. **This requires changes to the Docker daemon.** -- The config file should be parameterisable so that config can be partially modified for different environments (dev/test/staging/prod), passing in e.g. custom ports or volume mount paths. ([#426](https://github.com/docker/fig/issues/426)) -- Fig’s brute-force “delete and recreate everything” approach is great for dev and testing, and with manual command-line scoping can be made to work in production (e.g. “fig up -d web” will update *just* the web service), but a smarter solution is needed, its logic probably based around convergence from “current” to “desired” app state. -- Compose should recommend a simple technique for zero-downtime deploys. This will likely involve new Docker networking methods that allow for a load balancer container to be dynamically hooked up to new app containers and disconnected from old ones. +Fig should integrate really well with Swarm so you can take an application you've developed on your laptop and run it on a Swarm cluster. + +## Applications spanning multiple teams + +Fig 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, Fig 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). + +## An even better tool for development environments + +Fig is a great tool for development environments, but it could be even better. For example: + +- [Fig 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 3b638f0c433f9f9245b118c22609e34d77c025a8 Mon Sep 17 00:00:00 2001 From: Richard Adams Date: Wed, 7 Jan 2015 16:45:48 +0000 Subject: [PATCH 0635/4072] tweaks to the rails tutorial to bring it inline with rail 4.2 release Due to a change in Rack, rails server now listens on localhost instead of 0.0.0.0 by default. Signed-off-by: Richard Adams --- docs/rails.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/rails.md b/docs/rails.md index 25678b2955b..4e3e797517c 100644 --- a/docs/rails.md +++ b/docs/rails.md @@ -23,7 +23,7 @@ That'll put our application code inside an image with Ruby, Bundler and all our Next, we have a bootstrap `Gemfile` which just loads Rails. It'll be overwritten in a moment by `rails new`. source 'https://rubygems.org' - gem 'rails', '4.0.2' + gem 'rails', '4.2.0' Finally, `fig.yml` is where the magic happens. It describes what services our app comprises (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 we need to link them together and expose the web app's port. @@ -33,7 +33,7 @@ Finally, `fig.yml` is where the magic happens. It describes what services our ap - "5432" web: build: . - command: bundle exec rackup -p 3000 + command: bundle exec rails s -p 3000 -b '0.0.0.0' volumes: - .:/myapp ports: @@ -86,7 +86,7 @@ We can now boot the app. 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.0.0 (2013-11-22) [x86_64-linux-gnu] + 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, we just need to create the database. In another terminal, run: From 9a90a273769c46258d54a5b8c20ec7305bd31823 Mon Sep 17 00:00:00 2001 From: Richard Adams Date: Wed, 7 Jan 2015 19:45:00 +0000 Subject: [PATCH 0636/4072] be explicit with a ruby version number in the Dockerfile Signed-off-by: Richard Adams --- docs/rails.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/rails.md b/docs/rails.md index 4e3e797517c..9b1ecb046b6 100644 --- a/docs/rails.md +++ b/docs/rails.md @@ -10,7 +10,7 @@ We're going to use Fig to set up and run a Rails/PostgreSQL app. Before starting Let's set up the three files that'll get us started. First, our app is going to be running inside a Docker container which contains all of its dependencies. We can define what goes inside that Docker container using a file called `Dockerfile`. It'll contain this to start with: - FROM ruby + FROM ruby:2.2.0 RUN apt-get update -qq && apt-get install -y build-essential libpq-dev RUN mkdir /myapp WORKDIR /myapp From 69db596b5dda3d6865a5387a065e8c4e6a53fe58 Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Mon, 10 Nov 2014 20:55:43 +0100 Subject: [PATCH 0637/4072] Bash completion for fig command Signed-off-by: Harald Albers --- contrib/completion/bash/fig | 316 ++++++++++++++++++++++++++++++++++++ 1 file changed, 316 insertions(+) create mode 100644 contrib/completion/bash/fig diff --git a/contrib/completion/bash/fig b/contrib/completion/bash/fig new file mode 100644 index 00000000000..b44efa7b123 --- /dev/null +++ b/contrib/completion/bash/fig @@ -0,0 +1,316 @@ +#!bash +# +# bash completion for fig commands +# +# This work is based on the completion for the docker command. +# +# This script provides completion of: +# - commands and their options +# - service names +# - filepaths +# +# To enable the completions either: +# - place this file in /etc/bash_completion.d +# or +# - copy this file and add the line below to your .bashrc after +# bash completion features are loaded +# . docker.bash +# +# Note: +# Some completions require the current user to have sufficient permissions +# to execute the docker command. + + +# Extracts all service names from the figfile. +___fig_all_services_in_figfile() { + awk -F: '/^[a-zA-Z0-9]/{print $1}' "${fig_file:-fig.yml}" +} + +# All services, even those without an existing container +__fig_services_all() { + COMPREPLY=( $(compgen -W "$(___fig_all_services_in_figfile)" -- "$cur") ) +} + +# All services that have an entry with the given key in their figfile section +___fig_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;}' fig.yml | awk -F: -v key=": +$1:" '$0 ~ key {print $1}' +} + +# All services that are defined by a Dockerfile reference +__fig_services_from_build() { + COMPREPLY=( $(compgen -W "$(___fig_services_with_key build)" -- "$cur") ) +} + +# All services that are defined by an image +__fig_services_from_image() { + COMPREPLY=( $(compgen -W "$(___fig_services_with_key image)" -- "$cur") ) +} + +# The services for which containers have been created, optionally filtered +# by a boolean expression passed in as argument. +__fig_services_with() { + local containers names + containers="$(fig 2>/dev/null ${fig_file:+-f $fig_file} ${fig_project:+-p $fig_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") ) +} + +# The services for which at least one running container exists +__fig_services_running() { + __fig_services_with '.State.Running' +} + +# The services for which at least one stopped container exists +__fig_services_stopped() { + __fig_services_with 'not .State.Running' +} + + +_fig_build() { + case "$cur" in + -*) + COMPREPLY=( $( compgen -W "--no-cache" -- "$cur" ) ) + ;; + *) + __fig_services_from_build + ;; + esac +} + + +_fig_fig() { + case "$prev" in + --file|-f) + _filedir + return + ;; + --project-name|-p) + return + ;; + esac + + case "$cur" in + -*) + COMPREPLY=( $( compgen -W "--help -h --verbose --version --file -f --project-name -p" -- "$cur" ) ) + ;; + *) + COMPREPLY=( $( compgen -W "${commands[*]}" -- "$cur" ) ) + ;; + esac +} + + +_fig_help() { + COMPREPLY=( $( compgen -W "${commands[*]}" -- "$cur" ) ) +} + + +_fig_kill() { + case "$prev" in + -s) + COMPREPLY=( $( compgen -W "SIGHUP SIGINT SIGKILL SIGUSR1 SIGUSR2" -- "$(echo $cur | tr '[:lower:]' '[:upper:]')" ) ) + return + ;; + esac + + case "$cur" in + -*) + COMPREPLY=( $( compgen -W "-s" -- "$cur" ) ) + ;; + *) + __fig_services_running + ;; + esac +} + + +_fig_logs() { + case "$cur" in + -*) + COMPREPLY=( $( compgen -W "--no-color" -- "$cur" ) ) + ;; + *) + __fig_services_all + ;; + esac +} + + +_fig_port() { + case "$prev" in + --protocol) + COMPREPLY=( $( compgen -W "tcp udp" -- "$cur" ) ) + return; + ;; + --index) + return; + ;; + esac + + case "$cur" in + -*) + COMPREPLY=( $( compgen -W "--protocol --index" -- "$cur" ) ) + ;; + *) + __fig_services_all + ;; + esac +} + + +_fig_ps() { + case "$cur" in + -*) + COMPREPLY=( $( compgen -W "-q" -- "$cur" ) ) + ;; + *) + __fig_services_all + ;; + esac +} + + +_fig_pull() { + case "$cur" in + -*) + COMPREPLY=( $( compgen -W "--allow-insecure-ssl" -- "$cur" ) ) + ;; + *) + __fig_services_from_image + ;; + esac +} + + +_fig_restart() { + __fig_services_running +} + + +_fig_rm() { + case "$cur" in + -*) + COMPREPLY=( $( compgen -W "--force -v" -- "$cur" ) ) + ;; + *) + __fig_services_stopped + ;; + esac +} + + +_fig_run() { + case "$prev" in + -e) + COMPREPLY=( $( compgen -e -- "$cur" ) ) + compopt -o nospace + return + ;; + --entrypoint) + return + ;; + esac + + case "$cur" in + -*) + COMPREPLY=( $( compgen -W "--allow-insecure-ssl -d --entrypoint -e --no-deps --rm -T" -- "$cur" ) ) + ;; + *) + __fig_services_all + ;; + esac +} + + +_fig_scale() { + case "$prev" in + =) + COMPREPLY=("$cur") + ;; + *) + COMPREPLY=( $(compgen -S "=" -W "$(___fig_all_services_in_figfile)" -- "$cur") ) + compopt -o nospace + ;; + esac +} + + +_fig_start() { + __fig_services_stopped +} + + +_fig_stop() { + __fig_services_running +} + + +_fig_up() { + case "$cur" in + -*) + COMPREPLY=( $( compgen -W "--allow-insecure-ssl -d --no-build --no-color --no-deps --no-recreate" -- "$cur" ) ) + ;; + *) + __fig_services_all + ;; + esac +} + + +_fig() { + local commands=( + build + help + kill + logs + port + ps + pull + restart + rm + run + scale + start + stop + up + ) + + COMPREPLY=() + local cur prev words cword + _get_comp_words_by_ref -n : cur prev words cword + + # search subcommand and invoke its handler. + # special treatment of some top-level options + local command='fig' + local counter=1 + local fig_file fig_project + while [ $counter -lt $cword ]; do + case "${words[$counter]}" in + -f|--file) + (( counter++ )) + fig_file="${words[$counter]}" + ;; + -p|--project-name) + (( counter++ )) + fig_project="${words[$counter]}" + ;; + -*) + ;; + *) + command="${words[$counter]}" + break + ;; + esac + (( counter++ )) + done + + local completions_func=_fig_${command} + declare -F $completions_func >/dev/null && $completions_func + + return 0 +} + +complete -F _fig fig From 2406a3936aa88ed6732eb0c66089bbe8c9df25de Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Thu, 25 Dec 2014 11:16:45 +0100 Subject: [PATCH 0638/4072] Documentation for bash completion Signed-off-by: Harald Albers --- docs/completion.md | 33 +++++++++++++++++++++++++++++++++ docs/install.md | 2 ++ 2 files changed, 35 insertions(+) create mode 100644 docs/completion.md diff --git a/docs/completion.md b/docs/completion.md new file mode 100644 index 00000000000..ec8d7766aff --- /dev/null +++ b/docs/completion.md @@ -0,0 +1,33 @@ +--- +layout: default +title: Command Completion +--- + +Command Completion +================== + +Fig comes with [command completion](http://en.wikipedia.org/wiki/Command-line_completion) +for the bash shell. + +Installing Command Completion +----------------------------- + +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/fig/master/contrib/completion/bash/fig > /etc/bash_completion.d/fig + +Completion will be available upon next login. + +Available completions +--------------------- +Depending on what you typed on the command line so far, it will complete + + - available fig 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 `fig scale`, completed service names will automatically have "=" appended. + - arguments for selected options, e.g. `fig kill -s` will complete some signals like SIGHUP and SIGUSR1. + +Enjoy working with fig faster and with less typos! diff --git a/docs/install.md b/docs/install.md index 14fd64c8c58..c91c3db0d9e 100644 --- a/docs/install.md +++ b/docs/install.md @@ -20,6 +20,8 @@ Next, install Fig: curl -L https://github.com/docker/fig/releases/download/1.0.1/fig-`uname -s`-`uname -m` > /usr/local/bin/fig; chmod +x /usr/local/bin/fig +Optionally, install [command completion](completion.html) for the bash shell. + Releases are available for OS X and 64-bit Linux. Fig is also available as a Python package if you're on another platform (or if you prefer that sort of thing): $ sudo pip install -U fig From 3ee8437eaa6a424e4346e3910f42a785a7666b1c Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 8 Jan 2015 10:55:40 -0500 Subject: [PATCH 0639/4072] Fix the failing test. Signed-off-by: Daniel Nephin --- tests/integration/service_test.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index a5c84e5a255..234dec91393 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -230,10 +230,9 @@ def test_start_container_with_external_links(self): 'figtest_db_2', 'figtest_db_3:db_3']) - db.start_container() - db.start_container() - db.start_container() - web.start_container() + for _ in range(3): + create_and_start_container(db) + create_and_start_container(web) self.assertEqual( set(web.containers()[0].links()), From aa0c43df965e058b10c89ed0c17c661ba15e6e93 Mon Sep 17 00:00:00 2001 From: Christophe Labouisse Date: Sun, 11 Jan 2015 19:58:08 +0100 Subject: [PATCH 0640/4072] Add cpu_shares option in fig.yml This options maps exactly the docker run option with the same name. Signed-off-by: Christophe Labouisse --- docs/yml.md | 4 +++- fig/service.py | 2 ++ tests/integration/service_test.py | 6 ++++++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/yml.md b/docs/yml.md index 5d2c7237fce..81668b5610b 100644 --- a/docs/yml.md +++ b/docs/yml.md @@ -194,11 +194,13 @@ dns_search: - dc2.example.com ``` -### working\_dir, entrypoint, user, hostname, domainname, mem\_limit, privileged, restart, stdin\_open, tty +### working\_dir, entrypoint, user, hostname, domainname, mem\_limit, privileged, restart, stdin\_open, tty, cpu\_shares Each of these is a single value, analogous to its [docker run](https://docs.docker.com/reference/run/) counterpart. ``` +cpu_shares: 73 + working_dir: /code entrypoint: /code/entrypoint.sh user: postgresql diff --git a/fig/service.py b/fig/service.py index 647b427d11b..18ba3f618fb 100644 --- a/fig/service.py +++ b/fig/service.py @@ -18,6 +18,7 @@ DOCKER_CONFIG_KEYS = [ 'cap_add', 'cap_drop', + 'cpu_shares', 'command', 'detach', 'dns', @@ -41,6 +42,7 @@ 'working_dir', ] DOCKER_CONFIG_HINTS = { + 'cpu_share' : 'cpu_shares', 'link' : 'links', 'port' : 'ports', 'privilege' : 'privileged', diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 234dec91393..d01d118ff25 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -100,6 +100,12 @@ def test_create_container_with_unspecified_volume(self): service.start_container(container) self.assertIn('/var/db', container.inspect()['Volumes']) + def test_create_container_with_cpu_shares(self): + service = self.create_service('db', cpu_shares=73) + container = service.create_container() + service.start_container(container) + self.assertEqual(container.inspect()['Config']['CpuShares'], 73) + def test_create_container_with_specified_volume(self): host_path = '/tmp/host-path' container_path = '/container-path' From 26f45efea2e76f95f1c9bc162ed2f6710f258a42 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Sat, 6 Dec 2014 14:20:29 -0500 Subject: [PATCH 0641/4072] Remove unused intermediate_container from return value. Signed-off-by: Daniel Nephin --- fig/project.py | 16 ++++++---------- fig/service.py | 24 +++++++++++++----------- tests/integration/service_test.py | 23 ++++++++--------------- 3 files changed, 27 insertions(+), 36 deletions(-) diff --git a/fig/project.py b/fig/project.py index e013da4e980..cb6404a70c6 100644 --- a/fig/project.py +++ b/fig/project.py @@ -176,18 +176,14 @@ def up(self, do_build=True): running_containers = [] for service in self.get_services(service_names, include_links=start_links): - if recreate: - for (_, container) in service.recreate_containers( - insecure_registry=insecure_registry, - detach=detach, - do_build=do_build): - running_containers.append(container) - else: - for container in service.start_or_create_containers( - insecure_registry=insecure_registry, + create_func = (service.recreate_containers if recreate + else service.start_or_create_containers) + + for container in create_func( + insecure_registry=insecure_registry, detach=detach, do_build=do_build): - running_containers.append(container) + running_containers.append(container) return running_containers diff --git a/fig/service.py b/fig/service.py index 18ba3f618fb..64735f4d667 100644 --- a/fig/service.py +++ b/fig/service.py @@ -242,8 +242,9 @@ def create_container(self, def recreate_containers(self, insecure_registry=False, do_build=True, **override_options): """ - If a container for this service doesn't exist, create and start one. If there are - any, stop them, create+start new ones, and remove the old containers. + If a container for this service doesn't exist, create and start one. If + there are any, stop them, create+start new ones, and remove the old + containers. """ containers = self.containers(stopped=True) if not containers: @@ -253,21 +254,22 @@ def recreate_containers(self, insecure_registry=False, do_build=True, **override do_build=do_build, **override_options) self.start_container(container) - return [(None, container)] + return [container] else: - tuples = [] - - for c in containers: - log.info("Recreating %s..." % c.name) - tuples.append(self.recreate_container(c, insecure_registry=insecure_registry, **override_options)) - - return tuples + return [ + self.recreate_container( + container, + insecure_registry=insecure_registry, + **override_options) + for container in containers + ] def recreate_container(self, container, **override_options): """Recreate a container. An intermediate container is created so that the new container has the same name, while still supporting `volumes-from` the original container. """ + log.info("Recreating %s..." % container.name) try: container.stop() except APIError as e: @@ -295,7 +297,7 @@ def recreate_container(self, container, **override_options): intermediate_container.remove() - return (intermediate_container, new_container) + return new_container def start_container_if_stopped(self, container, **options): if container.is_running: diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index d01d118ff25..fcf46038372 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -148,30 +148,23 @@ def test_recreate_containers(self): self.assertIn('FOO=1', old_container.dictionary['Config']['Env']) self.assertEqual(old_container.name, 'figtest_db_1') service.start_container(old_container) - volume_path = old_container.inspect()['Volumes']['/etc'] + volume_path = old_container.get('Volumes')['/etc'] num_containers_before = len(self.client.containers(all=True)) service.options['environment']['FOO'] = '2' - tuples = service.recreate_containers() - self.assertEqual(len(tuples), 1) + containers = service.recreate_containers() + self.assertEqual(len(containers), 1) - intermediate_container = tuples[0][0] - new_container = tuples[0][1] - self.assertEqual(intermediate_container.dictionary['Config']['Entrypoint'], ['/bin/echo']) - - self.assertEqual(new_container.dictionary['Config']['Entrypoint'], ['sleep']) - self.assertEqual(new_container.dictionary['Config']['Cmd'], ['300']) - self.assertIn('FOO=2', new_container.dictionary['Config']['Env']) + new_container = containers[0] + self.assertEqual(new_container.get('Config.Entrypoint'), ['sleep']) + self.assertEqual(new_container.get('Config.Cmd'), ['300']) + self.assertIn('FOO=2', new_container.get('Config.Env')) self.assertEqual(new_container.name, 'figtest_db_1') - self.assertEqual(new_container.inspect()['Volumes']['/etc'], volume_path) - self.assertIn(intermediate_container.id, new_container.dictionary['HostConfig']['VolumesFrom']) + self.assertEqual(new_container.get('Volumes')['/etc'], volume_path) self.assertEqual(len(self.client.containers(all=True)), num_containers_before) self.assertNotEqual(old_container.id, new_container.id) - self.assertRaises(APIError, - self.client.inspect_container, - intermediate_container.id) def test_recreate_containers_when_containers_are_stopped(self): service = self.create_service( From 7eb476e61de7abf5a70ba8e824c7e2e765e2ac09 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Sat, 6 Dec 2014 17:51:49 -0500 Subject: [PATCH 0642/4072] Resolves #447, fix volume logic for recreate container Signed-off-by: Daniel Nephin --- fig/project.py | 4 +- fig/service.py | 68 ++++++++++++++++++++++++------- tests/integration/service_test.py | 12 +++++- tests/unit/service_test.py | 59 ++++++++++++++++++++++----- 4 files changed, 115 insertions(+), 28 deletions(-) diff --git a/fig/project.py b/fig/project.py index cb6404a70c6..f5ac88e4eea 100644 --- a/fig/project.py +++ b/fig/project.py @@ -181,8 +181,8 @@ def up(self, for container in create_func( insecure_registry=insecure_registry, - detach=detach, - do_build=do_build): + detach=detach, + do_build=do_build): running_containers.append(container) return running_containers diff --git a/fig/service.py b/fig/service.py index 64735f4d667..3b1273d1018 100644 --- a/fig/service.py +++ b/fig/service.py @@ -280,6 +280,7 @@ def recreate_container(self, container, **override_options): else: raise + intermediate_options = dict(self.options, **override_options) intermediate_container = Container.create( self.client, image=container.image, @@ -287,16 +288,21 @@ def recreate_container(self, container, **override_options): command=[], detach=True, ) - intermediate_container.start(volumes_from=container.id) + intermediate_container.start( + binds=get_container_data_volumes( + container, intermediate_options.get('volumes'))) intermediate_container.wait() container.remove() + # TODO: volumes are being passed to both start and create, this is + # probably unnecessary options = dict(override_options) new_container = self.create_container(do_build=False, **options) - self.start_container(new_container, intermediate_container=intermediate_container) + self.start_container( + new_container, + intermediate_container=intermediate_container) intermediate_container.remove() - return new_container def start_container_if_stopped(self, container, **options): @@ -309,12 +315,6 @@ def start_container_if_stopped(self, container, **options): def start_container(self, container, intermediate_container=None, **override_options): options = dict(self.options, **override_options) port_bindings = build_port_bindings(options.get('ports') or []) - - volume_bindings = dict( - build_volume_binding(parse_volume_spec(volume)) - for volume in options.get('volumes') or [] - if ':' in volume) - privileged = options.get('privileged', False) net = options.get('net', 'bridge') dns = options.get('dns', None) @@ -323,12 +323,14 @@ def start_container(self, container, intermediate_container=None, **override_opt cap_drop = options.get('cap_drop', None) restart = parse_restart_spec(options.get('restart', None)) + binds = get_volume_bindings( + options.get('volumes'), intermediate_container) container.start( links=self._get_links(link_to_self=options.get('one_off', False)), port_bindings=port_bindings, - binds=volume_bindings, - volumes_from=self._get_volumes_from(intermediate_container), + binds=binds, + volumes_from=self._get_volumes_from(), privileged=privileged, network_mode=net, dns=dns, @@ -390,7 +392,7 @@ def _get_links(self, link_to_self): links.append((external_link, link_name)) return links - def _get_volumes_from(self, intermediate_container=None): + def _get_volumes_from(self): volumes_from = [] for volume_source in self.volumes_from: if isinstance(volume_source, Service): @@ -404,9 +406,6 @@ def _get_volumes_from(self, intermediate_container=None): elif isinstance(volume_source, Container): volumes_from.append(volume_source.id) - if intermediate_container: - volumes_from.append(intermediate_container.id) - return volumes_from def _get_container_create_options(self, override_options, one_off=False): @@ -521,6 +520,45 @@ def pull(self, insecure_registry=False): ) +def get_container_data_volumes(container, volumes_option): + """Find the container data volumes that are in `volumes_option`, and return + a mapping of volume bindings for those volumes. + """ + volumes = [] + for volume in volumes_option or []: + volume = parse_volume_spec(volume) + # No need to preserve host volumes + if volume.external: + continue + + volume_path = (container.get('Volumes') or {}).get(volume.internal) + # New volume, doesn't exist in the old container + if not volume_path: + continue + + # Copy existing volume from old container + volume = volume._replace(external=volume_path) + volumes.append(build_volume_binding(volume)) + + return dict(volumes) + + +def get_volume_bindings(volumes_option, intermediate_container): + """Return a list of volume bindings for a container. Container data volume + bindings are replaced by those in the intermediate container. + """ + volume_bindings = dict( + build_volume_binding(parse_volume_spec(volume)) + for volume in volumes_option or [] + if ':' in volume) + + if intermediate_container: + volume_bindings.update( + get_container_data_volumes(intermediate_container, volumes_option)) + + return volume_bindings + + NAME_RE = re.compile(r'^([^_]+)_([^_]+)_(run_)?(\d+)$') diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index fcf46038372..dadd8d4a911 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -98,7 +98,7 @@ def test_create_container_with_unspecified_volume(self): service = self.create_service('db', volumes=['/var/db']) container = service.create_container() service.start_container(container) - self.assertIn('/var/db', container.inspect()['Volumes']) + self.assertIn('/var/db', container.get('Volumes')) def test_create_container_with_cpu_shares(self): service = self.create_service('db', cpu_shares=73) @@ -179,6 +179,16 @@ def test_recreate_containers_when_containers_are_stopped(self): service.recreate_containers() self.assertEqual(len(service.containers(stopped=True)), 1) + def test_recreate_containers_with_volume_changes(self): + service = self.create_service('withvolumes', volumes=['/etc']) + old_container = create_and_start_container(service) + self.assertEqual(old_container.get('Volumes').keys(), ['/etc']) + + service = self.create_service('withvolumes') + container, = service.recreate_containers() + service.start_container(container) + self.assertEqual(container.get('Volumes'), {}) + def test_start_container_passes_through_options(self): db = self.create_service('db') create_and_start_container(db, environment={'FOO': 'BAR'}) diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 68dcf06ab46..3e6b7c4ed14 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -11,13 +11,15 @@ from fig import Service from fig.container import Container from fig.service import ( + APIError, ConfigError, - split_port, build_port_bindings, - parse_volume_spec, build_volume_binding, - APIError, + get_container_data_volumes, + get_volume_bindings, parse_repository_tag, + parse_volume_spec, + split_port, ) @@ -57,13 +59,6 @@ def test_get_volumes_from_container(self): self.assertEqual(service._get_volumes_from(), [container_id]) - def test_get_volumes_from_intermediate_container(self): - container_id = 'aabbccddee' - service = Service('test') - container = mock.Mock(id=container_id, spec=Container) - - self.assertEqual(service._get_volumes_from(container), [container_id]) - def test_get_volumes_from_service_container_exists(self): container_ids = ['aabbccddee', '12345'] from_service = mock.create_autospec(Service) @@ -288,6 +283,50 @@ def test_building_volume_binding_with_home(self): binding, ('/home/user', dict(bind='/home/user', ro=False))) + def test_get_container_data_volumes(self): + options = [ + '/host/volume:/host/volume:ro', + '/new/volume', + '/existing/volume', + ] + + container = Container(None, { + 'Volumes': { + '/host/volume': '/host/volume', + '/existing/volume': '/var/lib/docker/aaaaaaaa', + '/removed/volume': '/var/lib/docker/bbbbbbbb', + }, + }, has_been_inspected=True) + + expected = { + '/var/lib/docker/aaaaaaaa': {'bind': '/existing/volume', 'ro': False}, + } + + binds = get_container_data_volumes(container, options) + self.assertEqual(binds, expected) + + def test_get_volume_bindings(self): + options = [ + '/host/volume:/host/volume:ro', + '/host/rw/volume:/host/rw/volume', + '/new/volume', + '/existing/volume', + ] + + intermediate_container = Container(None, { + 'Volumes': {'/existing/volume': '/var/lib/docker/aaaaaaaa'}, + }, has_been_inspected=True) + + expected = { + '/host/volume': {'bind': '/host/volume', 'ro': True}, + '/host/rw/volume': {'bind': '/host/rw/volume', 'ro': False}, + '/var/lib/docker/aaaaaaaa': {'bind': '/existing/volume', 'ro': False}, + } + + binds = get_volume_bindings(options, intermediate_container) + self.assertEqual(binds, expected) + + class ServiceEnvironmentTest(unittest.TestCase): def setUp(self): From 2dd1cc80ca1e6d46001e31be9dcbcb62a47a7ca1 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 20 Jan 2015 12:01:50 -0800 Subject: [PATCH 0643/4072] Revert "Merge pull request #711 from dnephin/fix_volumes_on_recreate" This reverts commit 55095ef488ffdf0afde66ad5d1136fdc9f1fbb5f, reversing changes made to 72095f54b26650affed47c3b888d77572d6ecbf0. Signed-off-by: Daniel Nephin --- fig/project.py | 20 ++++--- fig/service.py | 92 +++++++++---------------------- tests/integration/service_test.py | 35 ++++++------ tests/unit/service_test.py | 59 ++++---------------- 4 files changed, 64 insertions(+), 142 deletions(-) diff --git a/fig/project.py b/fig/project.py index f5ac88e4eea..e013da4e980 100644 --- a/fig/project.py +++ b/fig/project.py @@ -176,14 +176,18 @@ def up(self, do_build=True): running_containers = [] for service in self.get_services(service_names, include_links=start_links): - create_func = (service.recreate_containers if recreate - else service.start_or_create_containers) - - for container in create_func( - insecure_registry=insecure_registry, - detach=detach, - do_build=do_build): - running_containers.append(container) + if recreate: + for (_, container) in service.recreate_containers( + insecure_registry=insecure_registry, + detach=detach, + do_build=do_build): + running_containers.append(container) + else: + for container in service.start_or_create_containers( + insecure_registry=insecure_registry, + detach=detach, + do_build=do_build): + running_containers.append(container) return running_containers diff --git a/fig/service.py b/fig/service.py index 3b1273d1018..18ba3f618fb 100644 --- a/fig/service.py +++ b/fig/service.py @@ -242,9 +242,8 @@ def create_container(self, def recreate_containers(self, insecure_registry=False, do_build=True, **override_options): """ - If a container for this service doesn't exist, create and start one. If - there are any, stop them, create+start new ones, and remove the old - containers. + If a container for this service doesn't exist, create and start one. If there are + any, stop them, create+start new ones, and remove the old containers. """ containers = self.containers(stopped=True) if not containers: @@ -254,22 +253,21 @@ def recreate_containers(self, insecure_registry=False, do_build=True, **override do_build=do_build, **override_options) self.start_container(container) - return [container] + return [(None, container)] else: - return [ - self.recreate_container( - container, - insecure_registry=insecure_registry, - **override_options) - for container in containers - ] + tuples = [] + + for c in containers: + log.info("Recreating %s..." % c.name) + tuples.append(self.recreate_container(c, insecure_registry=insecure_registry, **override_options)) + + return tuples def recreate_container(self, container, **override_options): """Recreate a container. An intermediate container is created so that the new container has the same name, while still supporting `volumes-from` the original container. """ - log.info("Recreating %s..." % container.name) try: container.stop() except APIError as e: @@ -280,7 +278,6 @@ def recreate_container(self, container, **override_options): else: raise - intermediate_options = dict(self.options, **override_options) intermediate_container = Container.create( self.client, image=container.image, @@ -288,22 +285,17 @@ def recreate_container(self, container, **override_options): command=[], detach=True, ) - intermediate_container.start( - binds=get_container_data_volumes( - container, intermediate_options.get('volumes'))) + intermediate_container.start(volumes_from=container.id) intermediate_container.wait() container.remove() - # TODO: volumes are being passed to both start and create, this is - # probably unnecessary options = dict(override_options) new_container = self.create_container(do_build=False, **options) - self.start_container( - new_container, - intermediate_container=intermediate_container) + self.start_container(new_container, intermediate_container=intermediate_container) intermediate_container.remove() - return new_container + + return (intermediate_container, new_container) def start_container_if_stopped(self, container, **options): if container.is_running: @@ -315,6 +307,12 @@ def start_container_if_stopped(self, container, **options): def start_container(self, container, intermediate_container=None, **override_options): options = dict(self.options, **override_options) port_bindings = build_port_bindings(options.get('ports') or []) + + volume_bindings = dict( + build_volume_binding(parse_volume_spec(volume)) + for volume in options.get('volumes') or [] + if ':' in volume) + privileged = options.get('privileged', False) net = options.get('net', 'bridge') dns = options.get('dns', None) @@ -323,14 +321,12 @@ def start_container(self, container, intermediate_container=None, **override_opt cap_drop = options.get('cap_drop', None) restart = parse_restart_spec(options.get('restart', None)) - binds = get_volume_bindings( - options.get('volumes'), intermediate_container) container.start( links=self._get_links(link_to_self=options.get('one_off', False)), port_bindings=port_bindings, - binds=binds, - volumes_from=self._get_volumes_from(), + binds=volume_bindings, + volumes_from=self._get_volumes_from(intermediate_container), privileged=privileged, network_mode=net, dns=dns, @@ -392,7 +388,7 @@ def _get_links(self, link_to_self): links.append((external_link, link_name)) return links - def _get_volumes_from(self): + def _get_volumes_from(self, intermediate_container=None): volumes_from = [] for volume_source in self.volumes_from: if isinstance(volume_source, Service): @@ -406,6 +402,9 @@ def _get_volumes_from(self): elif isinstance(volume_source, Container): volumes_from.append(volume_source.id) + if intermediate_container: + volumes_from.append(intermediate_container.id) + return volumes_from def _get_container_create_options(self, override_options, one_off=False): @@ -520,45 +519,6 @@ def pull(self, insecure_registry=False): ) -def get_container_data_volumes(container, volumes_option): - """Find the container data volumes that are in `volumes_option`, and return - a mapping of volume bindings for those volumes. - """ - volumes = [] - for volume in volumes_option or []: - volume = parse_volume_spec(volume) - # No need to preserve host volumes - if volume.external: - continue - - volume_path = (container.get('Volumes') or {}).get(volume.internal) - # New volume, doesn't exist in the old container - if not volume_path: - continue - - # Copy existing volume from old container - volume = volume._replace(external=volume_path) - volumes.append(build_volume_binding(volume)) - - return dict(volumes) - - -def get_volume_bindings(volumes_option, intermediate_container): - """Return a list of volume bindings for a container. Container data volume - bindings are replaced by those in the intermediate container. - """ - volume_bindings = dict( - build_volume_binding(parse_volume_spec(volume)) - for volume in volumes_option or [] - if ':' in volume) - - if intermediate_container: - volume_bindings.update( - get_container_data_volumes(intermediate_container, volumes_option)) - - return volume_bindings - - NAME_RE = re.compile(r'^([^_]+)_([^_]+)_(run_)?(\d+)$') diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index dadd8d4a911..d01d118ff25 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -98,7 +98,7 @@ def test_create_container_with_unspecified_volume(self): service = self.create_service('db', volumes=['/var/db']) container = service.create_container() service.start_container(container) - self.assertIn('/var/db', container.get('Volumes')) + self.assertIn('/var/db', container.inspect()['Volumes']) def test_create_container_with_cpu_shares(self): service = self.create_service('db', cpu_shares=73) @@ -148,23 +148,30 @@ def test_recreate_containers(self): self.assertIn('FOO=1', old_container.dictionary['Config']['Env']) self.assertEqual(old_container.name, 'figtest_db_1') service.start_container(old_container) - volume_path = old_container.get('Volumes')['/etc'] + volume_path = old_container.inspect()['Volumes']['/etc'] num_containers_before = len(self.client.containers(all=True)) service.options['environment']['FOO'] = '2' - containers = service.recreate_containers() - self.assertEqual(len(containers), 1) + tuples = service.recreate_containers() + self.assertEqual(len(tuples), 1) - new_container = containers[0] - self.assertEqual(new_container.get('Config.Entrypoint'), ['sleep']) - self.assertEqual(new_container.get('Config.Cmd'), ['300']) - self.assertIn('FOO=2', new_container.get('Config.Env')) + intermediate_container = tuples[0][0] + new_container = tuples[0][1] + self.assertEqual(intermediate_container.dictionary['Config']['Entrypoint'], ['/bin/echo']) + + self.assertEqual(new_container.dictionary['Config']['Entrypoint'], ['sleep']) + self.assertEqual(new_container.dictionary['Config']['Cmd'], ['300']) + self.assertIn('FOO=2', new_container.dictionary['Config']['Env']) self.assertEqual(new_container.name, 'figtest_db_1') - self.assertEqual(new_container.get('Volumes')['/etc'], volume_path) + self.assertEqual(new_container.inspect()['Volumes']['/etc'], volume_path) + self.assertIn(intermediate_container.id, new_container.dictionary['HostConfig']['VolumesFrom']) self.assertEqual(len(self.client.containers(all=True)), num_containers_before) self.assertNotEqual(old_container.id, new_container.id) + self.assertRaises(APIError, + self.client.inspect_container, + intermediate_container.id) def test_recreate_containers_when_containers_are_stopped(self): service = self.create_service( @@ -179,16 +186,6 @@ def test_recreate_containers_when_containers_are_stopped(self): service.recreate_containers() self.assertEqual(len(service.containers(stopped=True)), 1) - def test_recreate_containers_with_volume_changes(self): - service = self.create_service('withvolumes', volumes=['/etc']) - old_container = create_and_start_container(service) - self.assertEqual(old_container.get('Volumes').keys(), ['/etc']) - - service = self.create_service('withvolumes') - container, = service.recreate_containers() - service.start_container(container) - self.assertEqual(container.get('Volumes'), {}) - def test_start_container_passes_through_options(self): db = self.create_service('db') create_and_start_container(db, environment={'FOO': 'BAR'}) diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 3e6b7c4ed14..68dcf06ab46 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -11,15 +11,13 @@ from fig import Service from fig.container import Container from fig.service import ( - APIError, ConfigError, + split_port, build_port_bindings, + parse_volume_spec, build_volume_binding, - get_container_data_volumes, - get_volume_bindings, + APIError, parse_repository_tag, - parse_volume_spec, - split_port, ) @@ -59,6 +57,13 @@ def test_get_volumes_from_container(self): self.assertEqual(service._get_volumes_from(), [container_id]) + def test_get_volumes_from_intermediate_container(self): + container_id = 'aabbccddee' + service = Service('test') + container = mock.Mock(id=container_id, spec=Container) + + self.assertEqual(service._get_volumes_from(container), [container_id]) + def test_get_volumes_from_service_container_exists(self): container_ids = ['aabbccddee', '12345'] from_service = mock.create_autospec(Service) @@ -283,50 +288,6 @@ def test_building_volume_binding_with_home(self): binding, ('/home/user', dict(bind='/home/user', ro=False))) - def test_get_container_data_volumes(self): - options = [ - '/host/volume:/host/volume:ro', - '/new/volume', - '/existing/volume', - ] - - container = Container(None, { - 'Volumes': { - '/host/volume': '/host/volume', - '/existing/volume': '/var/lib/docker/aaaaaaaa', - '/removed/volume': '/var/lib/docker/bbbbbbbb', - }, - }, has_been_inspected=True) - - expected = { - '/var/lib/docker/aaaaaaaa': {'bind': '/existing/volume', 'ro': False}, - } - - binds = get_container_data_volumes(container, options) - self.assertEqual(binds, expected) - - def test_get_volume_bindings(self): - options = [ - '/host/volume:/host/volume:ro', - '/host/rw/volume:/host/rw/volume', - '/new/volume', - '/existing/volume', - ] - - intermediate_container = Container(None, { - 'Volumes': {'/existing/volume': '/var/lib/docker/aaaaaaaa'}, - }, has_been_inspected=True) - - expected = { - '/host/volume': {'bind': '/host/volume', 'ro': True}, - '/host/rw/volume': {'bind': '/host/rw/volume', 'ro': False}, - '/var/lib/docker/aaaaaaaa': {'bind': '/existing/volume', 'ro': False}, - } - - binds = get_volume_bindings(options, intermediate_container) - self.assertEqual(binds, expected) - - class ServiceEnvironmentTest(unittest.TestCase): def setUp(self): From 17a8a7be4bb93e18b507ea5eef8dedbade719c40 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 20 Jan 2015 13:10:01 +0000 Subject: [PATCH 0644/4072] (Failing) test for preservation of volumes declared in images Signed-off-by: Aanand Prasad --- .../dockerfile-with-volume/Dockerfile | 3 +++ tests/integration/service_test.py | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+) create mode 100644 tests/fixtures/dockerfile-with-volume/Dockerfile diff --git a/tests/fixtures/dockerfile-with-volume/Dockerfile b/tests/fixtures/dockerfile-with-volume/Dockerfile new file mode 100644 index 00000000000..2d6437cf433 --- /dev/null +++ b/tests/fixtures/dockerfile-with-volume/Dockerfile @@ -0,0 +1,3 @@ +FROM busybox +VOLUME /data +CMD sleep 3000 diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index d01d118ff25..b7b11d5f04d 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -186,6 +186,25 @@ def test_recreate_containers_when_containers_are_stopped(self): service.recreate_containers() self.assertEqual(len(service.containers(stopped=True)), 1) + + def test_recreate_containers_with_image_declared_volume(self): + service = Service( + project='figtest', + name='db', + client=self.client, + build='tests/fixtures/dockerfile-with-volume', + ) + + old_container = create_and_start_container(service) + self.assertEqual(old_container.get('Volumes').keys(), ['/data']) + volume_path = old_container.get('Volumes')['/data'] + + service.recreate_containers() + new_container = service.containers()[0] + service.start_container(new_container) + self.assertEqual(new_container.get('Volumes').keys(), ['/data']) + self.assertEqual(new_container.get('Volumes')['/data'], volume_path) + def test_start_container_passes_through_options(self): db = self.create_service('db') create_and_start_container(db, environment={'FOO': 'BAR'}) From 608f29c7cb574e1f6a36f39a48f78676738738e0 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 16 Jan 2015 13:12:29 +0000 Subject: [PATCH 0645/4072] Unit tests for Service.containers() and get_container_name() Signed-off-by: Aanand Prasad --- tests/unit/service_test.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 68dcf06ab46..1f033380987 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -17,6 +17,7 @@ parse_volume_spec, build_volume_binding, APIError, + get_container_name, parse_repository_tag, ) @@ -49,6 +50,25 @@ def test_config_validation(self): self.assertRaises(ConfigError, lambda: Service(name='foo', port=['8000'])) Service(name='foo', ports=['8000']) + 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') + + def test_containers(self): + service = Service('db', client=self.mock_client, project='myproject') + + self.mock_client.containers.return_value = [] + self.assertEqual(service.containers(), []) + + self.mock_client.containers.return_value = [ + {'Image': 'busybox', 'Id': 'OUT_1', 'Names': ['/myproject', '/foo/bar']}, + {'Image': 'busybox', 'Id': 'OUT_2', 'Names': ['/myproject_db']}, + {'Image': 'busybox', 'Id': 'OUT_3', 'Names': ['/db_1']}, + {'Image': 'busybox', 'Id': 'IN_1', 'Names': ['/myproject_db_1', '/myproject_web_1/db']}, + ] + self.assertEqual([c.id for c in service.containers()], ['IN_1']) + def test_get_volumes_from_container(self): container_id = 'aabbccddee' service = Service( From edb6b24b8ff1c13cefe3ee347d75557918f38aa8 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 16 Jan 2015 16:43:59 +0000 Subject: [PATCH 0646/4072] Handle Swarm-style prefixed names in Service.containers() Signed-off-by: Aanand Prasad --- fig/service.py | 5 ++--- tests/unit/service_test.py | 12 ++++++++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/fig/service.py b/fig/service.py index 18ba3f618fb..369d4e845eb 100644 --- a/fig/service.py +++ b/fig/service.py @@ -545,9 +545,8 @@ def get_container_name(container): if 'Name' in container: return container['Name'] # ps - for name in container['Names']: - if len(name.split('/')) == 2: - return name[1:] + shortest_name = min(container['Names'], key=lambda n: len(n.split('/'))) + return shortest_name.split('/')[-1] def parse_restart_spec(restart_config): diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 1f033380987..6db603d0555 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -54,6 +54,7 @@ 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': ['/swarm-host-1/myproject_db_1', '/swarm-host-1/myproject_web_1/db']}), 'myproject_db_1') def test_containers(self): service = Service('db', client=self.mock_client, project='myproject') @@ -69,6 +70,17 @@ def test_containers(self): ] self.assertEqual([c.id for c in service.containers()], ['IN_1']) + def test_containers_prefixed(self): + service = Service('db', client=self.mock_client, project='myproject') + + self.mock_client.containers.return_value = [ + {'Image': 'busybox', 'Id': 'OUT_1', 'Names': ['/swarm-host-1/myproject', '/swarm-host-1/foo/bar']}, + {'Image': 'busybox', 'Id': 'OUT_2', 'Names': ['/swarm-host-1/myproject_db']}, + {'Image': 'busybox', 'Id': 'OUT_3', 'Names': ['/swarm-host-1/db_1']}, + {'Image': 'busybox', 'Id': 'IN_1', 'Names': ['/swarm-host-1/myproject_db_1', '/swarm-host-1/myproject_web_1/db']}, + ] + self.assertEqual([c.id for c in service.containers()], ['IN_1']) + def test_get_volumes_from_container(self): container_id = 'aabbccddee' service = Service( From cbd3ca07c4644c62b3914cecfa60436a46d77836 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 16 Jan 2015 17:24:29 +0000 Subject: [PATCH 0647/4072] Handle Swarm-style prefixed names in Container.from_ps() Signed-off-by: Aanand Prasad --- fig/container.py | 15 ++++++++++++--- fig/service.py | 13 +------------ tests/unit/container_test.py | 14 +++++++++++++- 3 files changed, 26 insertions(+), 16 deletions(-) diff --git a/fig/container.py b/fig/container.py index 0ab75512062..6921459884e 100644 --- a/fig/container.py +++ b/fig/container.py @@ -22,10 +22,8 @@ def from_ps(cls, client, dictionary, **kwargs): new_dictionary = { 'Id': dictionary['Id'], 'Image': dictionary['Image'], + 'Name': '/' + get_container_name(dictionary), } - for name in dictionary.get('Names', []): - if len(name.split('/')) == 2: - new_dictionary['Name'] = name return cls(client, new_dictionary, **kwargs) @classmethod @@ -170,3 +168,14 @@ def __eq__(self, other): if type(self) != type(other): return False return self.id == other.id + + +def get_container_name(container): + if not container.get('Name') and not container.get('Names'): + return None + # inspect + if 'Name' in container: + return container['Name'] + # ps + shortest_name = min(container['Names'], key=lambda n: len(n.split('/'))) + return shortest_name.split('/')[-1] diff --git a/fig/service.py b/fig/service.py index 369d4e845eb..6743e171777 100644 --- a/fig/service.py +++ b/fig/service.py @@ -9,7 +9,7 @@ from docker.errors import APIError -from .container import Container +from .container import Container, get_container_name from .progress_stream import stream_output, StreamOutputError log = logging.getLogger(__name__) @@ -538,17 +538,6 @@ def parse_name(name): return ServiceName(project, service_name, int(suffix)) -def get_container_name(container): - if not container.get('Name') and not container.get('Names'): - return None - # inspect - if 'Name' in container: - return container['Name'] - # ps - shortest_name = min(container['Names'], key=lambda n: len(n.split('/'))) - return shortest_name.split('/')[-1] - - def parse_restart_spec(restart_config): if not restart_config: return None diff --git a/tests/unit/container_test.py b/tests/unit/container_test.py index 18f7944eb39..26468118510 100644 --- a/tests/unit/container_test.py +++ b/tests/unit/container_test.py @@ -20,7 +20,7 @@ def setUp(self): "Ports": None, "SizeRw": 0, "SizeRootFs": 0, - "Names": ["/figtest_db_1"], + "Names": ["/figtest_db_1", "/figtest_web_1/db"], "NetworkSettings": { "Ports": {}, }, @@ -36,6 +36,18 @@ def test_from_ps(self): "Name": "/figtest_db_1", }) + def test_from_ps_prefixed(self): + 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) + self.assertEqual(container.dictionary, { + "Id": "abc", + "Image":"busybox:latest", + "Name": "/figtest_db_1", + }) + def test_environment(self): container = Container(None, { 'Id': 'abc', From 2af7693e64010e11ba53f7fd923bb97f29b10063 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 12 Jan 2015 14:59:05 +0000 Subject: [PATCH 0648/4072] WIP: rename Fig to Compose Signed-off-by: Aanand Prasad --- .gitignore | 2 +- Dockerfile | 2 +- bin/compose | 3 + bin/fig | 3 - {fig => compose}/__init__.py | 0 {fig => compose}/cli/__init__.py | 0 {fig => compose}/cli/colors.py | 0 {fig => compose}/cli/command.py | 30 +++++---- {fig => compose}/cli/docker_client.py | 0 {fig => compose}/cli/docopt_command.py | 0 {fig => compose}/cli/errors.py | 4 +- {fig => compose}/cli/formatter.py | 0 {fig => compose}/cli/log_printer.py | 0 {fig => compose}/cli/main.py | 34 +++++------ {fig => compose}/cli/multiplexer.py | 0 {fig => compose}/cli/utils.py | 0 {fig => compose}/cli/verbose_proxy.py | 0 {fig => compose}/container.py | 0 {fig => compose}/progress_stream.py | 0 {fig => compose}/project.py | 2 +- {fig => compose}/service.py | 2 +- script/build-docs | 2 +- script/build-linux | 8 +-- script/build-osx | 6 +- script/clean | 2 +- script/deploy-docs | 29 --------- script/test | 6 +- setup.py | 10 +-- .../fixtures/commands-composefile/compose.yml | 5 ++ tests/fixtures/commands-figfile/fig.yml | 5 -- .../{fig.yml => compose.yml} | 0 .../compose.yml} | 0 .../fig.yml => links-composefile/compose.yml} | 0 .../compose.yaml} | 0 .../compose.yml} | 0 .../compose2.yml} | 0 .../{no-figfile => no-composefile}/.gitignore | 0 .../fig.yml => ports-composefile/compose.yml} | 0 .../compose.yml} | 0 .../{fig.yml => compose.yml} | 0 tests/integration/cli_test.py | 61 ++++++++++--------- tests/integration/project_test.py | 30 ++++----- tests/integration/service_test.py | 52 ++++++++-------- tests/integration/testcases.py | 12 ++-- tests/unit/cli/docker_client_test.py | 2 +- tests/unit/cli/verbose_proxy_test.py | 2 +- tests/unit/cli_test.py | 30 +++++---- tests/unit/container_test.py | 10 +-- tests/unit/log_printer_test.py | 2 +- tests/unit/progress_stream_test.py | 2 +- tests/unit/project_test.py | 34 +++++------ tests/unit/service_test.py | 14 ++--- tests/unit/sort_service_test.py | 2 +- tests/unit/split_buffer_test.py | 2 +- 54 files changed, 199 insertions(+), 211 deletions(-) create mode 100755 bin/compose delete mode 100755 bin/fig rename {fig => compose}/__init__.py (100%) rename {fig => compose}/cli/__init__.py (100%) rename {fig => compose}/cli/colors.py (100%) rename {fig => compose}/cli/command.py (76%) rename {fig => compose}/cli/docker_client.py (100%) rename {fig => compose}/cli/docopt_command.py (100%) rename {fig => compose}/cli/errors.py (94%) rename {fig => compose}/cli/formatter.py (100%) rename {fig => compose}/cli/log_printer.py (100%) rename {fig => compose}/cli/main.py (93%) rename {fig => compose}/cli/multiplexer.py (100%) rename {fig => compose}/cli/utils.py (100%) rename {fig => compose}/cli/verbose_proxy.py (100%) rename {fig => compose}/container.py (100%) rename {fig => compose}/progress_stream.py (100%) rename {fig => compose}/project.py (98%) rename {fig => compose}/service.py (99%) delete mode 100755 script/deploy-docs create mode 100644 tests/fixtures/commands-composefile/compose.yml delete mode 100644 tests/fixtures/commands-figfile/fig.yml rename tests/fixtures/dockerfile_with_entrypoint/{fig.yml => compose.yml} (100%) rename tests/fixtures/{environment-figfile/fig.yml => environment-composefile/compose.yml} (100%) rename tests/fixtures/{links-figfile/fig.yml => links-composefile/compose.yml} (100%) rename tests/fixtures/{longer-filename-figfile/fig.yaml => longer-filename-composefile/compose.yaml} (100%) rename tests/fixtures/{multiple-figfiles/fig.yml => multiple-composefiles/compose.yml} (100%) rename tests/fixtures/{multiple-figfiles/fig2.yml => multiple-composefiles/compose2.yml} (100%) rename tests/fixtures/{no-figfile => no-composefile}/.gitignore (100%) rename tests/fixtures/{ports-figfile/fig.yml => ports-composefile/compose.yml} (100%) rename tests/fixtures/{simple-figfile/fig.yml => simple-composefile/compose.yml} (100%) rename tests/fixtures/simple-dockerfile/{fig.yml => compose.yml} (100%) diff --git a/.gitignore b/.gitignore index d987f272caa..83533dd7fda 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,4 @@ /dist /docs/_site /venv -fig.spec +compose.spec diff --git a/Dockerfile b/Dockerfile index c430950ba63..85313b302bd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,4 +14,4 @@ RUN python setup.py install RUN chown -R user /code/ -ENTRYPOINT ["/usr/local/bin/fig"] +ENTRYPOINT ["/usr/local/bin/compose"] diff --git a/bin/compose b/bin/compose new file mode 100755 index 00000000000..5976e1d4aa5 --- /dev/null +++ b/bin/compose @@ -0,0 +1,3 @@ +#!/usr/bin/env python +from compose.cli.main import main +main() diff --git a/bin/fig b/bin/fig deleted file mode 100755 index 550a5e2439e..00000000000 --- a/bin/fig +++ /dev/null @@ -1,3 +0,0 @@ -#!/usr/bin/env python -from fig.cli.main import main -main() diff --git a/fig/__init__.py b/compose/__init__.py similarity index 100% rename from fig/__init__.py rename to compose/__init__.py diff --git a/fig/cli/__init__.py b/compose/cli/__init__.py similarity index 100% rename from fig/cli/__init__.py rename to compose/cli/__init__.py diff --git a/fig/cli/colors.py b/compose/cli/colors.py similarity index 100% rename from fig/cli/colors.py rename to compose/cli/colors.py diff --git a/fig/cli/command.py b/compose/cli/command.py similarity index 76% rename from fig/cli/command.py rename to compose/cli/command.py index e1e7e5e4337..45b124a195b 100644 --- a/fig/cli/command.py +++ b/compose/cli/command.py @@ -43,11 +43,15 @@ def dispatch(self, *args, **kwargs): def perform_command(self, options, handler, command_options): if options['COMMAND'] == 'help': - # Skip looking up the figfile. + # Skip looking up the compose file. handler(None, command_options) return - explicit_config_path = options.get('--file') or os.environ.get('FIG_FILE') + if 'FIG_FILE' in os.environ: + log.warn('The FIG_FILE environment variable is deprecated.') + log.warn('Please use COMPOSE_FILE instead.') + + explicit_config_path = options.get('--file') or os.environ.get('COMPOSE_FILE') or os.environ.get('FIG_FILE') project = self.get_project( self.get_config_path(explicit_config_path), project_name=options.get('--project-name'), @@ -59,7 +63,7 @@ def get_client(self, verbose=False): client = docker_client() if verbose: version_info = six.iteritems(client.version()) - log.info("Fig version %s", __version__) + log.info("Compose version %s", __version__) log.info("Docker base_url: %s", client.base_url) log.info("Docker version: %s", ", ".join("%s=%s" % item for item in version_info)) @@ -72,7 +76,7 @@ def get_config(self, config_path): return yaml.safe_load(fh) except IOError as e: if e.errno == errno.ENOENT: - raise errors.FigFileNotFound(os.path.basename(e.filename)) + raise errors.ComposeFileNotFound(os.path.basename(e.filename)) raise errors.UserError(six.text_type(e)) def get_project(self, config_path, project_name=None, verbose=False): @@ -88,7 +92,11 @@ def get_project_name(self, config_path, project_name=None): def normalize_name(name): return re.sub(r'[^a-z0-9]', '', name.lower()) - project_name = project_name or os.environ.get('FIG_PROJECT_NAME') + if 'FIG_PROJECT_NAME' in os.environ: + log.warn('The FIG_PROJECT_NAME environment variable is deprecated.') + log.warn('Please use COMPOSE_PROJECT_NAME instead.') + + project_name = project_name or os.environ.get('COMPOSE_PROJECT_NAME') or os.environ.get('FIG_PROJECT_NAME') if project_name is not None: return normalize_name(project_name) @@ -102,13 +110,13 @@ def get_config_path(self, file_path=None): if file_path: return os.path.join(self.base_dir, file_path) - if os.path.exists(os.path.join(self.base_dir, 'fig.yaml')): - log.warning("Fig just read the file 'fig.yaml' on startup, rather " - "than 'fig.yml'") - log.warning("Please be aware that fig.yml the expected extension " + if os.path.exists(os.path.join(self.base_dir, 'compose.yaml')): + log.warning("Fig just read the file 'compose.yaml' on startup, rather " + "than 'compose.yml'") + log.warning("Please be aware that .yml is the expected extension " "in most cases, and using .yaml can cause compatibility " "issues in future") - return os.path.join(self.base_dir, 'fig.yaml') + return os.path.join(self.base_dir, 'compose.yaml') - return os.path.join(self.base_dir, 'fig.yml') + return os.path.join(self.base_dir, 'compose.yml') diff --git a/fig/cli/docker_client.py b/compose/cli/docker_client.py similarity index 100% rename from fig/cli/docker_client.py rename to compose/cli/docker_client.py diff --git a/fig/cli/docopt_command.py b/compose/cli/docopt_command.py similarity index 100% rename from fig/cli/docopt_command.py rename to compose/cli/docopt_command.py diff --git a/fig/cli/errors.py b/compose/cli/errors.py similarity index 94% rename from fig/cli/errors.py rename to compose/cli/errors.py index 53d1af36e85..d0d9472466a 100644 --- a/fig/cli/errors.py +++ b/compose/cli/errors.py @@ -55,8 +55,8 @@ def __init__(self, url): """ % url) -class FigFileNotFound(UserError): +class ComposeFileNotFound(UserError): def __init__(self, filename): - super(FigFileNotFound, self).__init__(""" + super(ComposeFileNotFound, self).__init__(""" Can't find %s. Are you in the right directory? """ % filename) diff --git a/fig/cli/formatter.py b/compose/cli/formatter.py similarity index 100% rename from fig/cli/formatter.py rename to compose/cli/formatter.py diff --git a/fig/cli/log_printer.py b/compose/cli/log_printer.py similarity index 100% rename from fig/cli/log_printer.py rename to compose/cli/log_printer.py diff --git a/fig/cli/main.py b/compose/cli/main.py similarity index 93% rename from fig/cli/main.py rename to compose/cli/main.py index 8cdaee62090..391916244b0 100644 --- a/fig/cli/main.py +++ b/compose/cli/main.py @@ -71,13 +71,13 @@ class TopLevelCommand(Command): """Fast, isolated development environments using Docker. Usage: - fig [options] [COMMAND] [ARGS...] - fig -h|--help + compose [options] [COMMAND] [ARGS...] + compose -h|--help Options: --verbose Show more output --version Print version and exit - -f, --file FILE Specify an alternate fig file (default: fig.yml) + -f, --file FILE Specify an alternate compose file (default: compose.yml) -p, --project-name NAME Specify an alternate project name (default: directory name) Commands: @@ -99,7 +99,7 @@ class TopLevelCommand(Command): """ def docopt_options(self): options = super(TopLevelCommand, self).docopt_options() - options['version'] = "fig %s" % __version__ + options['version'] = "compose %s" % __version__ return options def build(self, project, options): @@ -107,8 +107,8 @@ def build(self, project, options): Build or rebuild services. Services are built once and then tagged as `project_service`, - e.g. `figtest_db`. If you change a service's `Dockerfile` or the - contents of its build directory, you can run `fig build` to rebuild it. + e.g. `composetest_db`. If you change a service's `Dockerfile` or the + contents of its build directory, you can run `compose build` to rebuild it. Usage: build [options] [SERVICE...] @@ -261,11 +261,11 @@ def run(self, project, options): For example: - $ fig run web python manage.py shell + $ compose run web python manage.py shell By default, linked services will be started, unless they are already running. If you do not want to start linked services, use - `fig run --no-deps SERVICE COMMAND [ARGS...]`. + `compose run --no-deps SERVICE COMMAND [ARGS...]`. Usage: run [options] [-e KEY=VAL...] SERVICE [COMMAND] [ARGS...] @@ -280,7 +280,7 @@ def run(self, project, options): --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 `fig run` + -T Disable pseudo-tty allocation. By default `compose run` allocates a TTY. """ service = project.get_service(options['SERVICE']) @@ -352,7 +352,7 @@ def scale(self, project, options): Numbers are specified in the form `service=num` as arguments. For example: - $ fig scale web=2 worker=3 + $ compose scale web=2 worker=3 Usage: scale [SERVICE=NUM...] """ @@ -372,7 +372,7 @@ def scale(self, project, options): 'Service "%s" cannot be scaled because it specifies a port ' 'on the host. If multiple containers for this service were ' 'created, the port would clash.\n\nRemove the ":" from the ' - 'port definition in fig.yml so Docker can choose a random ' + 'port definition in compose.yml so Docker can choose a random ' 'port for each container.' % service_name) def start(self, project, options): @@ -387,7 +387,7 @@ def stop(self, project, options): """ Stop running containers without removing them. - They can be started again with `fig start`. + They can be started again with `compose start`. Usage: stop [SERVICE...] """ @@ -405,14 +405,14 @@ def up(self, project, options): """ Build, (re)create, start and attach to containers for a service. - By default, `fig up` will aggregate the output of each container, and - when it exits, all containers will be stopped. If you run `fig up -d`, + By default, `compose up` will aggregate the output of each container, and + when it exits, all containers will be stopped. If you run `compose up -d`, it'll start the containers in the background and leave them running. - If there are existing containers for a service, `fig up` will stop + If there are existing containers for a service, `compose up` will stop and recreate them (preserving mounted volumes with volumes-from), - so that changes in `fig.yml` are picked up. If you do not want existing - containers to be recreated, `fig up --no-recreate` will re-use existing + so that changes in `compose.yml` are picked up. If you do not want existing + containers to be recreated, `compose up --no-recreate` will re-use existing containers. Usage: up [options] [SERVICE...] diff --git a/fig/cli/multiplexer.py b/compose/cli/multiplexer.py similarity index 100% rename from fig/cli/multiplexer.py rename to compose/cli/multiplexer.py diff --git a/fig/cli/utils.py b/compose/cli/utils.py similarity index 100% rename from fig/cli/utils.py rename to compose/cli/utils.py diff --git a/fig/cli/verbose_proxy.py b/compose/cli/verbose_proxy.py similarity index 100% rename from fig/cli/verbose_proxy.py rename to compose/cli/verbose_proxy.py diff --git a/fig/container.py b/compose/container.py similarity index 100% rename from fig/container.py rename to compose/container.py diff --git a/fig/progress_stream.py b/compose/progress_stream.py similarity index 100% rename from fig/progress_stream.py rename to compose/progress_stream.py diff --git a/fig/project.py b/compose/project.py similarity index 98% rename from fig/project.py rename to compose/project.py index e013da4e980..b707d637411 100644 --- a/fig/project.py +++ b/compose/project.py @@ -67,7 +67,7 @@ def from_config(cls, name, config, client): dicts = [] for service_name, service in list(config.items()): if not isinstance(service, dict): - raise ConfigurationError('Service "%s" doesn\'t have any configuration options. All top level keys in your fig.yml must map to a dictionary of configuration options.' % service_name) + raise ConfigurationError('Service "%s" doesn\'t have any configuration options. All top level keys in your compose.yml must map to a dictionary of configuration options.' % service_name) service['name'] = service_name dicts.append(service) return cls.from_dicts(name, dicts, client) diff --git a/fig/service.py b/compose/service.py similarity index 99% rename from fig/service.py rename to compose/service.py index 6743e171777..7095840595f 100644 --- a/fig/service.py +++ b/compose/service.py @@ -127,7 +127,7 @@ def has_container(self, container, one_off=False): return project == self.project and name == self.name def get_container(self, number=1): - """Return a :class:`fig.container.Container` for this service. The + """Return a :class:`compose.container.Container` for this service. The container must be active, and match `number`. """ for container in self.client.containers(): diff --git a/script/build-docs b/script/build-docs index abcaec4a8fc..edcc52380eb 100755 --- a/script/build-docs +++ b/script/build-docs @@ -1,5 +1,5 @@ #!/bin/bash set -ex pushd docs -fig run --rm jekyll jekyll build +compose run --rm jekyll jekyll build popd diff --git a/script/build-linux b/script/build-linux index f7b99210b51..aa69fa7b761 100755 --- a/script/build-linux +++ b/script/build-linux @@ -2,7 +2,7 @@ set -ex mkdir -p `pwd`/dist chmod 777 `pwd`/dist -docker build -t fig . -docker run -u user -v `pwd`/dist:/code/dist --rm --entrypoint pyinstaller fig -F bin/fig -mv dist/fig dist/fig-Linux-x86_64 -docker run -u user -v `pwd`/dist:/code/dist --rm --entrypoint dist/fig-Linux-x86_64 fig --version +docker build -t compose . +docker run -u user -v `pwd`/dist:/code/dist --rm --entrypoint pyinstaller compose -F bin/compose +mv dist/compose dist/compose-Linux-x86_64 +docker run -u user -v `pwd`/dist:/code/dist --rm --entrypoint dist/compose-Linux-x86_64 compose --version diff --git a/script/build-osx b/script/build-osx index 359e9a03792..9b239caf2bf 100755 --- a/script/build-osx +++ b/script/build-osx @@ -5,6 +5,6 @@ virtualenv venv venv/bin/pip install -r requirements.txt venv/bin/pip install -r requirements-dev.txt venv/bin/pip install . -venv/bin/pyinstaller -F bin/fig -mv dist/fig dist/fig-Darwin-x86_64 -dist/fig-Darwin-x86_64 --version +venv/bin/pyinstaller -F bin/compose +mv dist/compose dist/compose-Darwin-x86_64 +dist/compose-Darwin-x86_64 --version diff --git a/script/clean b/script/clean index d9fa444c6b6..0c845012c63 100755 --- a/script/clean +++ b/script/clean @@ -1,3 +1,3 @@ #!/bin/sh find . -type f -name '*.pyc' -delete -rm -rf docs/_site build dist fig.egg-info +rm -rf docs/_site build dist compose.egg-info diff --git a/script/deploy-docs b/script/deploy-docs deleted file mode 100755 index 424472f816c..00000000000 --- a/script/deploy-docs +++ /dev/null @@ -1,29 +0,0 @@ -#!/bin/bash -set -ex - -script/build-docs - -pushd docs/_site - -export GIT_DIR=.git-gh-pages -export GIT_WORK_TREE=. - -if [ ! -d "$GIT_DIR" ]; then - git init -fi - -if !(git remote | grep origin); then - git remote add origin git@github.com:docker/fig.git -fi - -git fetch origin -git reset --soft origin/gh-pages - -echo ".git-gh-pages" > .gitignore - -git add -A . - -git commit -m "update" || echo "didn't commit" -git push origin master:gh-pages - -popd diff --git a/script/test b/script/test index e73ba893d58..b7e7245da60 100755 --- a/script/test +++ b/script/test @@ -1,5 +1,5 @@ #!/bin/sh set -ex -docker build -t fig . -docker run -v /var/run/docker.sock:/var/run/docker.sock --rm --entrypoint flake8 fig fig -docker run -v /var/run/docker.sock:/var/run/docker.sock --rm --entrypoint nosetests fig $@ +docker build -t compose . +docker run -v /var/run/docker.sock:/var/run/docker.sock --rm --entrypoint flake8 compose compose +docker run -v /var/run/docker.sock:/var/run/docker.sock --rm --entrypoint nosetests compose $@ diff --git a/setup.py b/setup.py index 4cf8e589d98..7c21c53d76d 100644 --- a/setup.py +++ b/setup.py @@ -48,10 +48,10 @@ def find_version(*file_paths): setup( - name='fig', - version=find_version("fig", "__init__.py"), - description='Fast, isolated development environments using Docker', - url='http://www.fig.sh/', + name='compose', + version=find_version("compose", "__init__.py"), + description='Multi-container orchestration for Docker', + url='https://www.docker.com/', author='Docker, Inc.', license='Apache License 2.0', packages=find_packages(exclude=[ 'tests.*', 'tests' ]), @@ -61,6 +61,6 @@ def find_version(*file_paths): tests_require=tests_require, entry_points=""" [console_scripts] - fig=fig.cli.main:main + compose=compose.cli.main:main """, ) diff --git a/tests/fixtures/commands-composefile/compose.yml b/tests/fixtures/commands-composefile/compose.yml new file mode 100644 index 00000000000..87602bd6ef1 --- /dev/null +++ b/tests/fixtures/commands-composefile/compose.yml @@ -0,0 +1,5 @@ +implicit: + image: composetest_test +explicit: + image: composetest_test + command: [ "/bin/true" ] diff --git a/tests/fixtures/commands-figfile/fig.yml b/tests/fixtures/commands-figfile/fig.yml deleted file mode 100644 index 707c18a750e..00000000000 --- a/tests/fixtures/commands-figfile/fig.yml +++ /dev/null @@ -1,5 +0,0 @@ -implicit: - image: figtest_test -explicit: - image: figtest_test - command: [ "/bin/true" ] diff --git a/tests/fixtures/dockerfile_with_entrypoint/fig.yml b/tests/fixtures/dockerfile_with_entrypoint/compose.yml similarity index 100% rename from tests/fixtures/dockerfile_with_entrypoint/fig.yml rename to tests/fixtures/dockerfile_with_entrypoint/compose.yml diff --git a/tests/fixtures/environment-figfile/fig.yml b/tests/fixtures/environment-composefile/compose.yml similarity index 100% rename from tests/fixtures/environment-figfile/fig.yml rename to tests/fixtures/environment-composefile/compose.yml diff --git a/tests/fixtures/links-figfile/fig.yml b/tests/fixtures/links-composefile/compose.yml similarity index 100% rename from tests/fixtures/links-figfile/fig.yml rename to tests/fixtures/links-composefile/compose.yml diff --git a/tests/fixtures/longer-filename-figfile/fig.yaml b/tests/fixtures/longer-filename-composefile/compose.yaml similarity index 100% rename from tests/fixtures/longer-filename-figfile/fig.yaml rename to tests/fixtures/longer-filename-composefile/compose.yaml diff --git a/tests/fixtures/multiple-figfiles/fig.yml b/tests/fixtures/multiple-composefiles/compose.yml similarity index 100% rename from tests/fixtures/multiple-figfiles/fig.yml rename to tests/fixtures/multiple-composefiles/compose.yml diff --git a/tests/fixtures/multiple-figfiles/fig2.yml b/tests/fixtures/multiple-composefiles/compose2.yml similarity index 100% rename from tests/fixtures/multiple-figfiles/fig2.yml rename to tests/fixtures/multiple-composefiles/compose2.yml diff --git a/tests/fixtures/no-figfile/.gitignore b/tests/fixtures/no-composefile/.gitignore similarity index 100% rename from tests/fixtures/no-figfile/.gitignore rename to tests/fixtures/no-composefile/.gitignore diff --git a/tests/fixtures/ports-figfile/fig.yml b/tests/fixtures/ports-composefile/compose.yml similarity index 100% rename from tests/fixtures/ports-figfile/fig.yml rename to tests/fixtures/ports-composefile/compose.yml diff --git a/tests/fixtures/simple-figfile/fig.yml b/tests/fixtures/simple-composefile/compose.yml similarity index 100% rename from tests/fixtures/simple-figfile/fig.yml rename to tests/fixtures/simple-composefile/compose.yml diff --git a/tests/fixtures/simple-dockerfile/fig.yml b/tests/fixtures/simple-dockerfile/compose.yml similarity index 100% rename from tests/fixtures/simple-dockerfile/fig.yml rename to tests/fixtures/simple-dockerfile/compose.yml diff --git a/tests/integration/cli_test.py b/tests/integration/cli_test.py index 2f7ecb59492..cf939837929 100644 --- a/tests/integration/cli_test.py +++ b/tests/integration/cli_test.py @@ -5,7 +5,7 @@ from mock import patch from .testcases import DockerClientTestCase -from fig.cli.main import TopLevelCommand +from compose.cli.main import TopLevelCommand class CLITestCase(DockerClientTestCase): @@ -14,7 +14,7 @@ def setUp(self): self.old_sys_exit = sys.exit sys.exit = lambda code=0: None self.command = TopLevelCommand() - self.command.base_dir = 'tests/fixtures/simple-figfile' + self.command.base_dir = 'tests/fixtures/simple-composefile' def tearDown(self): sys.exit = self.old_sys_exit @@ -27,43 +27,44 @@ def project(self): def test_help(self): old_base_dir = self.command.base_dir - self.command.base_dir = 'tests/fixtures/no-figfile' + self.command.base_dir = 'tests/fixtures/no-composefile' with self.assertRaises(SystemExit) as exc_context: self.command.dispatch(['help', 'up'], None) self.assertIn('Usage: up [options] [SERVICE...]', str(exc_context.exception)) # self.project.kill() fails during teardown - # unless there is a figfile. + # unless there is a composefile. self.command.base_dir = old_base_dir + # TODO: address the "Inappropriate ioctl for device" warnings in test output @patch('sys.stdout', new_callable=StringIO) def test_ps(self, mock_stdout): self.project.get_service('simple').create_container() self.command.dispatch(['ps'], None) - self.assertIn('simplefigfile_simple_1', mock_stdout.getvalue()) + self.assertIn('simplecomposefile_simple_1', mock_stdout.getvalue()) @patch('sys.stdout', new_callable=StringIO) - def test_ps_default_figfile(self, mock_stdout): - self.command.base_dir = 'tests/fixtures/multiple-figfiles' + def test_ps_default_composefile(self, mock_stdout): + self.command.base_dir = 'tests/fixtures/multiple-composefiles' self.command.dispatch(['up', '-d'], None) self.command.dispatch(['ps'], None) output = mock_stdout.getvalue() - self.assertIn('multiplefigfiles_simple_1', output) - self.assertIn('multiplefigfiles_another_1', output) - self.assertNotIn('multiplefigfiles_yetanother_1', output) + self.assertIn('multiplecomposefiles_simple_1', output) + self.assertIn('multiplecomposefiles_another_1', output) + self.assertNotIn('multiplecomposefiles_yetanother_1', output) @patch('sys.stdout', new_callable=StringIO) - def test_ps_alternate_figfile(self, mock_stdout): - self.command.base_dir = 'tests/fixtures/multiple-figfiles' - self.command.dispatch(['-f', 'fig2.yml', 'up', '-d'], None) - self.command.dispatch(['-f', 'fig2.yml', 'ps'], None) + def test_ps_alternate_composefile(self, mock_stdout): + self.command.base_dir = 'tests/fixtures/multiple-composefiles' + self.command.dispatch(['-f', 'compose2.yml', 'up', '-d'], None) + self.command.dispatch(['-f', 'compose2.yml', 'ps'], None) output = mock_stdout.getvalue() - self.assertNotIn('multiplefigfiles_simple_1', output) - self.assertNotIn('multiplefigfiles_another_1', output) - self.assertIn('multiplefigfiles_yetanother_1', output) + self.assertNotIn('multiplecomposefiles_simple_1', output) + self.assertNotIn('multiplecomposefiles_another_1', output) + self.assertIn('multiplecomposefiles_yetanother_1', output) - @patch('fig.service.log') + @patch('compose.service.log') def test_pull(self, mock_logging): self.command.dispatch(['pull'], None) mock_logging.info.assert_any_call('Pulling simple (busybox:latest)...') @@ -99,7 +100,7 @@ def test_up(self): self.assertFalse(config['AttachStdin']) def test_up_with_links(self): - self.command.base_dir = 'tests/fixtures/links-figfile' + self.command.base_dir = 'tests/fixtures/links-composefile' self.command.dispatch(['up', '-d', 'web'], None) web = self.project.get_service('web') db = self.project.get_service('db') @@ -109,7 +110,7 @@ def test_up_with_links(self): self.assertEqual(len(console.containers()), 0) def test_up_with_no_deps(self): - self.command.base_dir = 'tests/fixtures/links-figfile' + self.command.base_dir = 'tests/fixtures/links-composefile' self.command.dispatch(['up', '-d', '--no-deps', 'web'], None) web = self.project.get_service('web') db = self.project.get_service('db') @@ -148,7 +149,7 @@ def test_up_with_keep_old(self): @patch('dockerpty.start') def test_run_service_without_links(self, mock_stdout): - self.command.base_dir = 'tests/fixtures/links-figfile' + self.command.base_dir = 'tests/fixtures/links-composefile' self.command.dispatch(['run', 'console', '/bin/true'], None) self.assertEqual(len(self.project.containers()), 0) @@ -161,7 +162,7 @@ def test_run_service_without_links(self, mock_stdout): @patch('dockerpty.start') def test_run_service_with_links(self, __): - self.command.base_dir = 'tests/fixtures/links-figfile' + self.command.base_dir = 'tests/fixtures/links-composefile' self.command.dispatch(['run', 'web', '/bin/true'], None) db = self.project.get_service('db') console = self.project.get_service('console') @@ -170,14 +171,14 @@ def test_run_service_with_links(self, __): @patch('dockerpty.start') def test_run_with_no_deps(self, __): - self.command.base_dir = 'tests/fixtures/links-figfile' + self.command.base_dir = 'tests/fixtures/links-composefile' self.command.dispatch(['run', '--no-deps', 'web', '/bin/true'], None) db = self.project.get_service('db') self.assertEqual(len(db.containers()), 0) @patch('dockerpty.start') def test_run_does_not_recreate_linked_containers(self, __): - self.command.base_dir = 'tests/fixtures/links-figfile' + self.command.base_dir = 'tests/fixtures/links-composefile' self.command.dispatch(['up', '-d', 'db'], None) db = self.project.get_service('db') self.assertEqual(len(db.containers()), 1) @@ -193,8 +194,8 @@ def test_run_does_not_recreate_linked_containers(self, __): @patch('dockerpty.start') def test_run_without_command(self, __): - self.command.base_dir = 'tests/fixtures/commands-figfile' - self.check_build('tests/fixtures/simple-dockerfile', tag='figtest_test') + self.command.base_dir = 'tests/fixtures/commands-composefile' + self.check_build('tests/fixtures/simple-dockerfile', tag='composetest_test') for c in self.project.containers(stopped=True, one_off=True): c.remove() @@ -233,7 +234,7 @@ def test_run_service_with_entrypoint_overridden(self, _): @patch('dockerpty.start') def test_run_service_with_environement_overridden(self, _): name = 'service' - self.command.base_dir = 'tests/fixtures/environment-figfile' + self.command.base_dir = 'tests/fixtures/environment-composefile' self.command.dispatch( ['run', '-e', 'foo=notbar', '-e', 'allo=moto=bobo', '-e', 'alpha=beta', name], @@ -253,7 +254,7 @@ def test_run_service_with_environement_overridden(self, _): @patch('dockerpty.start') def test_run_service_without_map_ports(self, __): # create one off container - self.command.base_dir = 'tests/fixtures/ports-figfile' + self.command.base_dir = 'tests/fixtures/ports-composefile' self.command.dispatch(['run', '-d', 'simple'], None) container = self.project.get_service('simple').containers(one_off=True)[0] @@ -271,7 +272,7 @@ def test_run_service_without_map_ports(self, __): @patch('dockerpty.start') def test_run_service_with_map_ports(self, __): # create one off container - self.command.base_dir = 'tests/fixtures/ports-figfile' + self.command.base_dir = 'tests/fixtures/ports-composefile' self.command.dispatch(['run', '-d', '--service-ports', 'simple'], None) container = self.project.get_service('simple').containers(one_off=True)[0] @@ -368,7 +369,7 @@ def test_scale(self): self.assertEqual(len(project.get_service('another').containers()), 0) def test_port(self): - self.command.base_dir = 'tests/fixtures/ports-figfile' + self.command.base_dir = 'tests/fixtures/ports-composefile' self.command.dispatch(['up', '-d'], None) container = self.project.get_service('simple').get_container() diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index ce087245813..2577fd61673 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -1,13 +1,13 @@ from __future__ import unicode_literals -from fig.project import Project, ConfigurationError -from fig.container import Container +from compose.project import Project, ConfigurationError +from compose.container import Container from .testcases import DockerClientTestCase class ProjectTest(DockerClientTestCase): def test_volumes_from_service(self): project = Project.from_config( - name='figtest', + name='composetest', config={ 'data': { 'image': 'busybox:latest', @@ -29,14 +29,14 @@ def test_volumes_from_container(self): self.client, image='busybox:latest', volumes=['/var/data'], - name='figtest_data_container', + name='composetest_data_container', ) project = Project.from_config( - name='figtest', + name='composetest', config={ 'db': { 'image': 'busybox:latest', - 'volumes_from': ['figtest_data_container'], + 'volumes_from': ['composetest_data_container'], }, }, client=self.client, @@ -47,7 +47,7 @@ def test_volumes_from_container(self): def test_start_stop_kill_remove(self): web = self.create_service('web') db = self.create_service('db') - project = Project('figtest', [web, db], self.client) + project = Project('composetest', [web, db], self.client) project.start() @@ -80,7 +80,7 @@ def test_start_stop_kill_remove(self): def test_project_up(self): web = self.create_service('web') db = self.create_service('db', volumes=['/var/db']) - project = Project('figtest', [web, db], self.client) + project = Project('composetest', [web, db], self.client) project.start() self.assertEqual(len(project.containers()), 0) @@ -95,7 +95,7 @@ def test_project_up(self): def test_project_up_recreates_containers(self): web = self.create_service('web') db = self.create_service('db', volumes=['/etc']) - project = Project('figtest', [web, db], self.client) + project = Project('composetest', [web, db], self.client) project.start() self.assertEqual(len(project.containers()), 0) @@ -117,7 +117,7 @@ def test_project_up_recreates_containers(self): def test_project_up_with_no_recreate_running(self): web = self.create_service('web') db = self.create_service('db', volumes=['/var/db']) - project = Project('figtest', [web, db], self.client) + project = Project('composetest', [web, db], self.client) project.start() self.assertEqual(len(project.containers()), 0) @@ -140,7 +140,7 @@ def test_project_up_with_no_recreate_running(self): def test_project_up_with_no_recreate_stopped(self): web = self.create_service('web') db = self.create_service('db', volumes=['/var/db']) - project = Project('figtest', [web, db], self.client) + project = Project('composetest', [web, db], self.client) project.start() self.assertEqual(len(project.containers()), 0) @@ -169,7 +169,7 @@ def test_project_up_with_no_recreate_stopped(self): def test_project_up_without_all_services(self): console = self.create_service('console') db = self.create_service('db') - project = Project('figtest', [console, db], self.client) + project = Project('composetest', [console, db], self.client) project.start() self.assertEqual(len(project.containers()), 0) @@ -186,7 +186,7 @@ def test_project_up_starts_links(self): db = self.create_service('db', volumes=['/var/db']) web = self.create_service('web', links=[(db, 'db')]) - project = Project('figtest', [web, db, console], self.client) + project = Project('composetest', [web, db, console], self.client) project.start() self.assertEqual(len(project.containers()), 0) @@ -204,7 +204,7 @@ def test_project_up_with_no_deps(self): db = self.create_service('db', volumes=['/var/db']) web = self.create_service('web', links=[(db, 'db')]) - project = Project('figtest', [web, db, console], self.client) + project = Project('composetest', [web, db, console], self.client) project.start() self.assertEqual(len(project.containers()), 0) @@ -219,7 +219,7 @@ def test_project_up_with_no_deps(self): def test_unscale_after_restart(self): web = self.create_service('web') - project = Project('figtest', [web], self.client) + project = Project('composetest', [web], self.client) project.start() diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index b7b11d5f04d..e11672bf783 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -3,9 +3,9 @@ import os from os import path -from fig import Service -from fig.service import CannotBeScaledError -from fig.container import Container +from compose import Service +from compose.service import CannotBeScaledError +from compose.container import Container from docker.errors import APIError from .testcases import DockerClientTestCase @@ -23,7 +23,7 @@ def test_containers(self): create_and_start_container(foo) self.assertEqual(len(foo.containers()), 1) - self.assertEqual(foo.containers()[0].name, 'figtest_foo_1') + self.assertEqual(foo.containers()[0].name, 'composetest_foo_1') self.assertEqual(len(bar.containers()), 0) create_and_start_container(bar) @@ -33,8 +33,8 @@ def test_containers(self): self.assertEqual(len(bar.containers()), 2) names = [c.name for c in bar.containers()] - self.assertIn('figtest_bar_1', names) - self.assertIn('figtest_bar_2', names) + self.assertIn('composetest_bar_1', names) + self.assertIn('composetest_bar_2', names) def test_containers_one_off(self): db = self.create_service('db') @@ -45,7 +45,7 @@ def test_containers_one_off(self): def test_project_is_added_to_container_name(self): service = self.create_service('web') create_and_start_container(service) - self.assertEqual(service.containers()[0].name, 'figtest_web_1') + self.assertEqual(service.containers()[0].name, 'composetest_web_1') def test_start_stop(self): service = self.create_service('scalingtest') @@ -86,13 +86,13 @@ def test_kill_remove(self): def test_create_container_with_one_off(self): db = self.create_service('db') container = db.create_container(one_off=True) - self.assertEqual(container.name, 'figtest_db_run_1') + self.assertEqual(container.name, 'composetest_db_run_1') def test_create_container_with_one_off_when_existing_container_is_running(self): db = self.create_service('db') db.start() container = db.create_container(one_off=True) - self.assertEqual(container.name, 'figtest_db_run_1') + self.assertEqual(container.name, 'composetest_db_run_1') def test_create_container_with_unspecified_volume(self): service = self.create_service('db', volumes=['/var/db']) @@ -146,7 +146,7 @@ def test_recreate_containers(self): self.assertEqual(old_container.dictionary['Config']['Entrypoint'], ['sleep']) self.assertEqual(old_container.dictionary['Config']['Cmd'], ['300']) self.assertIn('FOO=1', old_container.dictionary['Config']['Env']) - self.assertEqual(old_container.name, 'figtest_db_1') + self.assertEqual(old_container.name, 'composetest_db_1') service.start_container(old_container) volume_path = old_container.inspect()['Volumes']['/etc'] @@ -163,7 +163,7 @@ def test_recreate_containers(self): self.assertEqual(new_container.dictionary['Config']['Entrypoint'], ['sleep']) self.assertEqual(new_container.dictionary['Config']['Cmd'], ['300']) self.assertIn('FOO=2', new_container.dictionary['Config']['Env']) - self.assertEqual(new_container.name, 'figtest_db_1') + self.assertEqual(new_container.name, 'composetest_db_1') self.assertEqual(new_container.inspect()['Volumes']['/etc'], volume_path) self.assertIn(intermediate_container.id, new_container.dictionary['HostConfig']['VolumesFrom']) @@ -226,8 +226,8 @@ def test_start_container_creates_links(self): self.assertEqual( set(web.containers()[0].links()), set([ - 'figtest_db_1', 'db_1', - 'figtest_db_2', 'db_2', + 'composetest_db_1', 'db_1', + 'composetest_db_2', 'db_2', 'db', ]), ) @@ -243,17 +243,17 @@ def test_start_container_creates_links_with_names(self): self.assertEqual( set(web.containers()[0].links()), set([ - 'figtest_db_1', 'db_1', - 'figtest_db_2', 'db_2', + 'composetest_db_1', 'db_1', + 'composetest_db_2', 'db_2', 'custom_link_name', ]), ) def test_start_container_with_external_links(self): db = self.create_service('db') - web = self.create_service('web', external_links=['figtest_db_1', - 'figtest_db_2', - 'figtest_db_3:db_3']) + web = self.create_service('web', external_links=['composetest_db_1', + 'composetest_db_2', + 'composetest_db_3:db_3']) for _ in range(3): create_and_start_container(db) @@ -262,8 +262,8 @@ def test_start_container_with_external_links(self): self.assertEqual( set(web.containers()[0].links()), set([ - 'figtest_db_1', - 'figtest_db_2', + 'composetest_db_1', + 'composetest_db_2', 'db_3', ]), ) @@ -288,8 +288,8 @@ def test_start_one_off_container_creates_links_to_its_own_service(self): self.assertEqual( set(c.links()), set([ - 'figtest_db_1', 'db_1', - 'figtest_db_2', 'db_2', + 'composetest_db_1', 'db_1', + 'composetest_db_2', 'db_2', 'db', ]), ) @@ -299,20 +299,20 @@ def test_start_container_builds_images(self): name='test', client=self.client, build='tests/fixtures/simple-dockerfile', - project='figtest', + project='composetest', ) container = create_and_start_container(service) container.wait() self.assertIn('success', container.logs()) - self.assertEqual(len(self.client.images(name='figtest_test')), 1) + self.assertEqual(len(self.client.images(name='composetest_test')), 1) def test_start_container_uses_tagged_image_if_it_exists(self): - self.client.build('tests/fixtures/simple-dockerfile', tag='figtest_test') + self.client.build('tests/fixtures/simple-dockerfile', tag='composetest_test') service = Service( name='test', client=self.client, build='this/does/not/exist/and/will/throw/error', - project='figtest', + project='composetest', ) container = create_and_start_container(service) container.wait() diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index 9e65b904474..53882561b1c 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -1,8 +1,8 @@ from __future__ import unicode_literals from __future__ import absolute_import -from fig.service import Service -from fig.cli.docker_client import docker_client -from fig.progress_stream import stream_output +from compose.service import Service +from compose.cli.docker_client import docker_client +from compose.progress_stream import stream_output from .. import unittest @@ -13,18 +13,18 @@ def setUpClass(cls): def setUp(self): for c in self.client.containers(all=True): - if c['Names'] and 'figtest' in c['Names'][0]: + if c['Names'] and 'composetest' in c['Names'][0]: self.client.kill(c['Id']) self.client.remove_container(c['Id']) for i in self.client.images(): - if isinstance(i.get('Tag'), basestring) and 'figtest' in i['Tag']: + if isinstance(i.get('Tag'), basestring) and 'composetest' in i['Tag']: self.client.remove_image(i) def create_service(self, name, **kwargs): if 'command' not in kwargs: kwargs['command'] = ["/bin/sleep", "300"] return Service( - project='figtest', + project='composetest', name=name, client=self.client, image="busybox:latest", diff --git a/tests/unit/cli/docker_client_test.py b/tests/unit/cli/docker_client_test.py index 67575ee0d52..abd40ef082f 100644 --- a/tests/unit/cli/docker_client_test.py +++ b/tests/unit/cli/docker_client_test.py @@ -5,7 +5,7 @@ import mock from tests import unittest -from fig.cli import docker_client +from compose.cli import docker_client class DockerClientTestCase(unittest.TestCase): diff --git a/tests/unit/cli/verbose_proxy_test.py b/tests/unit/cli/verbose_proxy_test.py index 90067f0dcbf..59417bb3ef5 100644 --- a/tests/unit/cli/verbose_proxy_test.py +++ b/tests/unit/cli/verbose_proxy_test.py @@ -2,7 +2,7 @@ from __future__ import absolute_import from tests import unittest -from fig.cli import verbose_proxy +from compose.cli import verbose_proxy class VerboseProxyTestCase(unittest.TestCase): diff --git a/tests/unit/cli_test.py b/tests/unit/cli_test.py index bc3daa11bc6..1154d3de1a3 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -6,8 +6,8 @@ import mock -from fig.cli import main -from fig.cli.main import TopLevelCommand +from compose.cli import main +from compose.cli.main import TopLevelCommand from six import StringIO @@ -16,18 +16,18 @@ def test_default_project_name(self): cwd = os.getcwd() try: - os.chdir('tests/fixtures/simple-figfile') + os.chdir('tests/fixtures/simple-composefile') command = TopLevelCommand() project_name = command.get_project_name(command.get_config_path()) - self.assertEquals('simplefigfile', project_name) + self.assertEquals('simplecomposefile', project_name) finally: os.chdir(cwd) def test_project_name_with_explicit_base_dir(self): command = TopLevelCommand() - command.base_dir = 'tests/fixtures/simple-figfile' + command.base_dir = 'tests/fixtures/simple-composefile' project_name = command.get_project_name(command.get_config_path()) - self.assertEquals('simplefigfile', project_name) + self.assertEquals('simplecomposefile', project_name) def test_project_name_with_explicit_uppercase_base_dir(self): command = TopLevelCommand() @@ -41,7 +41,7 @@ def test_project_name_with_explicit_project_name(self): project_name = command.get_project_name(None, project_name=name) self.assertEquals('explicitprojectname', project_name) - def test_project_name_from_environment(self): + def test_project_name_from_environment_old_var(self): command = TopLevelCommand() name = 'namefromenv' with mock.patch.dict(os.environ): @@ -49,18 +49,26 @@ def test_project_name_from_environment(self): project_name = command.get_project_name(None) self.assertEquals(project_name, name) + def test_project_name_from_environment_new_var(self): + command = TopLevelCommand() + name = 'namefromenv' + with mock.patch.dict(os.environ): + os.environ['COMPOSE_PROJECT_NAME'] = name + project_name = command.get_project_name(None) + self.assertEquals(project_name, name) + def test_yaml_filename_check(self): command = TopLevelCommand() - command.base_dir = 'tests/fixtures/longer-filename-figfile' - with mock.patch('fig.cli.command.log', autospec=True) as mock_log: + command.base_dir = 'tests/fixtures/longer-filename-composefile' + with mock.patch('compose.cli.command.log', autospec=True) as mock_log: self.assertTrue(command.get_config_path()) self.assertEqual(mock_log.warning.call_count, 2) def test_get_project(self): command = TopLevelCommand() - command.base_dir = 'tests/fixtures/longer-filename-figfile' + command.base_dir = 'tests/fixtures/longer-filename-composefile' project = command.get_project(command.get_config_path()) - self.assertEqual(project.name, 'longerfilenamefigfile') + self.assertEqual(project.name, 'longerfilenamecomposefile') self.assertTrue(project.client) self.assertTrue(project.services) diff --git a/tests/unit/container_test.py b/tests/unit/container_test.py index 26468118510..a5f3f7d3405 100644 --- a/tests/unit/container_test.py +++ b/tests/unit/container_test.py @@ -4,7 +4,7 @@ import mock import docker -from fig.container import Container +from compose.container import Container class ContainerTest(unittest.TestCase): @@ -20,7 +20,7 @@ def setUp(self): "Ports": None, "SizeRw": 0, "SizeRootFs": 0, - "Names": ["/figtest_db_1", "/figtest_web_1/db"], + "Names": ["/composetest_db_1", "/composetest_web_1/db"], "NetworkSettings": { "Ports": {}, }, @@ -33,7 +33,7 @@ def test_from_ps(self): self.assertEqual(container.dictionary, { "Id": "abc", "Image":"busybox:latest", - "Name": "/figtest_db_1", + "Name": "/composetest_db_1", }) def test_from_ps_prefixed(self): @@ -45,7 +45,7 @@ def test_from_ps_prefixed(self): self.assertEqual(container.dictionary, { "Id": "abc", "Image":"busybox:latest", - "Name": "/figtest_db_1", + "Name": "/composetest_db_1", }) def test_environment(self): @@ -73,7 +73,7 @@ def test_name(self): container = Container.from_ps(None, self.container_dict, has_been_inspected=True) - self.assertEqual(container.name, "figtest_db_1") + self.assertEqual(container.name, "composetest_db_1") def test_name_without_project(self): container = Container.from_ps(None, diff --git a/tests/unit/log_printer_test.py b/tests/unit/log_printer_test.py index 40dca775fdc..e40a1f75dae 100644 --- a/tests/unit/log_printer_test.py +++ b/tests/unit/log_printer_test.py @@ -2,7 +2,7 @@ from __future__ import absolute_import import os -from fig.cli.log_printer import LogPrinter +from compose.cli.log_printer import LogPrinter from .. import unittest diff --git a/tests/unit/progress_stream_test.py b/tests/unit/progress_stream_test.py index 29759d1d8af..b53f2eb9aca 100644 --- a/tests/unit/progress_stream_test.py +++ b/tests/unit/progress_stream_test.py @@ -5,7 +5,7 @@ import mock from six import StringIO -from fig import progress_stream +from compose import progress_stream class ProgressStreamTestCase(unittest.TestCase): diff --git a/tests/unit/project_test.py b/tests/unit/project_test.py index 5c8d35b1d92..d7aca64cf9a 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -1,11 +1,11 @@ from __future__ import unicode_literals from .. import unittest -from fig.service import Service -from fig.project import Project, ConfigurationError +from compose.service import Service +from compose.project import Project, ConfigurationError class ProjectTest(unittest.TestCase): def test_from_dict(self): - project = Project.from_dicts('figtest', [ + project = Project.from_dicts('composetest', [ { 'name': 'web', 'image': 'busybox:latest' @@ -22,7 +22,7 @@ def test_from_dict(self): self.assertEqual(project.get_service('db').options['image'], 'busybox:latest') def test_from_dict_sorts_in_dependency_order(self): - project = Project.from_dicts('figtest', [ + project = Project.from_dicts('composetest', [ { 'name': 'web', 'image': 'busybox:latest', @@ -45,7 +45,7 @@ def test_from_dict_sorts_in_dependency_order(self): self.assertEqual(project.services[2].name, 'web') def test_from_config(self): - project = Project.from_config('figtest', { + project = Project.from_config('composetest', { 'web': { 'image': 'busybox:latest', }, @@ -61,13 +61,13 @@ def test_from_config(self): def test_from_config_throws_error_when_not_dict(self): with self.assertRaises(ConfigurationError): - project = Project.from_config('figtest', { + project = Project.from_config('composetest', { 'web': 'busybox:latest', }, None) def test_get_service(self): web = Service( - project='figtest', + project='composetest', name='web', client=None, image="busybox:latest", @@ -77,11 +77,11 @@ def test_get_service(self): def test_get_services_returns_all_services_without_args(self): web = Service( - project='figtest', + project='composetest', name='web', ) console = Service( - project='figtest', + project='composetest', name='console', ) project = Project('test', [web, console], None) @@ -89,11 +89,11 @@ def test_get_services_returns_all_services_without_args(self): def test_get_services_returns_listed_services_with_args(self): web = Service( - project='figtest', + project='composetest', name='web', ) console = Service( - project='figtest', + project='composetest', name='console', ) project = Project('test', [web, console], None) @@ -101,20 +101,20 @@ def test_get_services_returns_listed_services_with_args(self): def test_get_services_with_include_links(self): db = Service( - project='figtest', + project='composetest', name='db', ) web = Service( - project='figtest', + project='composetest', name='web', links=[(db, 'database')] ) cache = Service( - project='figtest', + project='composetest', name='cache' ) console = Service( - project='figtest', + project='composetest', name='console', links=[(web, 'web')] ) @@ -126,11 +126,11 @@ def test_get_services_with_include_links(self): def test_get_services_removes_duplicates_following_links(self): db = Service( - project='figtest', + project='composetest', name='db', ) web = Service( - project='figtest', + project='composetest', name='web', links=[(db, 'database')] ) diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 6db603d0555..0a7239b0d82 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -8,9 +8,9 @@ import docker from requests import Response -from fig import Service -from fig.container import Container -from fig.service import ( +from compose import Service +from compose.container import Container +from compose.service import ( ConfigError, split_port, build_port_bindings, @@ -203,7 +203,7 @@ def test_get_container_not_found(self): self.assertRaises(ValueError, service.get_container) - @mock.patch('fig.service.Container', autospec=True) + @mock.patch('compose.service.Container', autospec=True) def test_get_container(self, mock_container_class): container_dict = dict(Name='default_foo_2') self.mock_client.containers.return_value = [container_dict] @@ -214,15 +214,15 @@ def test_get_container(self, mock_container_class): mock_container_class.from_ps.assert_called_once_with( self.mock_client, container_dict) - @mock.patch('fig.service.log', autospec=True) + @mock.patch('compose.service.log', autospec=True) def test_pull_image(self, mock_log): service = Service('foo', client=self.mock_client, image='someimage:sometag') service.pull(insecure_registry=True) self.mock_client.pull.assert_called_once_with('someimage:sometag', insecure_registry=True) mock_log.info.assert_called_once_with('Pulling foo (someimage:sometag)...') - @mock.patch('fig.service.Container', autospec=True) - @mock.patch('fig.service.log', autospec=True) + @mock.patch('compose.service.Container', autospec=True) + @mock.patch('compose.service.log', autospec=True) def test_create_container_from_insecure_registry( self, mock_log, diff --git a/tests/unit/sort_service_test.py b/tests/unit/sort_service_test.py index e2a7bdb3884..420353c8a39 100644 --- a/tests/unit/sort_service_test.py +++ b/tests/unit/sort_service_test.py @@ -1,4 +1,4 @@ -from fig.project import sort_service_dicts, DependencyError +from compose.project import sort_service_dicts, DependencyError from .. import unittest diff --git a/tests/unit/split_buffer_test.py b/tests/unit/split_buffer_test.py index 41dc50e49b9..3322fb55fa2 100644 --- a/tests/unit/split_buffer_test.py +++ b/tests/unit/split_buffer_test.py @@ -1,6 +1,6 @@ from __future__ import unicode_literals from __future__ import absolute_import -from fig.cli.utils import split_buffer +from compose.cli.utils import split_buffer from .. import unittest class SplitBufferTest(unittest.TestCase): From 620e29b63ffd6a8b609c24a176889532673137d0 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 20 Jan 2015 11:27:10 +0000 Subject: [PATCH 0649/4072] Rename binary to docker-compose and config file to docker-compose.yml Signed-off-by: Aanand Prasad --- .gitignore | 2 +- Dockerfile | 2 +- bin/{compose => docker-compose} | 0 compose/cli/command.py | 10 +++---- compose/cli/main.py | 30 +++++++++---------- script/build-docs | 2 +- script/build-linux | 8 ++--- script/build-osx | 6 ++-- script/test | 6 ++-- setup.py | 4 +-- .../{compose.yml => docker-compose.yml} | 0 .../{compose.yml => docker-compose.yml} | 0 .../{compose.yml => docker-compose.yml} | 0 .../{compose.yml => docker-compose.yml} | 0 .../{compose.yaml => docker-compose.yaml} | 0 .../{compose.yml => docker-compose.yml} | 0 .../{compose.yml => docker-compose.yml} | 0 .../{compose.yml => docker-compose.yml} | 0 .../{compose.yml => docker-compose.yml} | 0 19 files changed, 35 insertions(+), 35 deletions(-) rename bin/{compose => docker-compose} (100%) rename tests/fixtures/commands-composefile/{compose.yml => docker-compose.yml} (100%) rename tests/fixtures/dockerfile_with_entrypoint/{compose.yml => docker-compose.yml} (100%) rename tests/fixtures/environment-composefile/{compose.yml => docker-compose.yml} (100%) rename tests/fixtures/links-composefile/{compose.yml => docker-compose.yml} (100%) rename tests/fixtures/longer-filename-composefile/{compose.yaml => docker-compose.yaml} (100%) rename tests/fixtures/multiple-composefiles/{compose.yml => docker-compose.yml} (100%) rename tests/fixtures/ports-composefile/{compose.yml => docker-compose.yml} (100%) rename tests/fixtures/simple-composefile/{compose.yml => docker-compose.yml} (100%) rename tests/fixtures/simple-dockerfile/{compose.yml => docker-compose.yml} (100%) diff --git a/.gitignore b/.gitignore index 83533dd7fda..da7fe7fa47e 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,4 @@ /dist /docs/_site /venv -compose.spec +docker-compose.spec diff --git a/Dockerfile b/Dockerfile index 85313b302bd..ee9fb4a2fcc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,4 +14,4 @@ RUN python setup.py install RUN chown -R user /code/ -ENTRYPOINT ["/usr/local/bin/compose"] +ENTRYPOINT ["/usr/local/bin/docker-compose"] diff --git a/bin/compose b/bin/docker-compose similarity index 100% rename from bin/compose rename to bin/docker-compose diff --git a/compose/cli/command.py b/compose/cli/command.py index 45b124a195b..dc7683a359a 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -110,13 +110,13 @@ def get_config_path(self, file_path=None): if file_path: return os.path.join(self.base_dir, file_path) - if os.path.exists(os.path.join(self.base_dir, 'compose.yaml')): - log.warning("Fig just read the file 'compose.yaml' on startup, rather " - "than 'compose.yml'") + if os.path.exists(os.path.join(self.base_dir, 'docker-compose.yaml')): + log.warning("Fig just read the file 'docker-compose.yaml' on startup, rather " + "than 'docker-compose.yml'") log.warning("Please be aware that .yml is the expected extension " "in most cases, and using .yaml can cause compatibility " "issues in future") - return os.path.join(self.base_dir, 'compose.yaml') + return os.path.join(self.base_dir, 'docker-compose.yaml') - return os.path.join(self.base_dir, 'compose.yml') + return os.path.join(self.base_dir, 'docker-compose.yml') diff --git a/compose/cli/main.py b/compose/cli/main.py index 391916244b0..cfe29cd077d 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -71,13 +71,13 @@ class TopLevelCommand(Command): """Fast, isolated development environments using Docker. Usage: - compose [options] [COMMAND] [ARGS...] - compose -h|--help + docker-compose [options] [COMMAND] [ARGS...] + docker-compose -h|--help Options: --verbose Show more output --version Print version and exit - -f, --file FILE Specify an alternate compose file (default: compose.yml) + -f, --file FILE Specify an alternate compose file (default: docker-compose.yml) -p, --project-name NAME Specify an alternate project name (default: directory name) Commands: @@ -99,7 +99,7 @@ class TopLevelCommand(Command): """ def docopt_options(self): options = super(TopLevelCommand, self).docopt_options() - options['version'] = "compose %s" % __version__ + options['version'] = "docker-compose %s" % __version__ return options def build(self, project, options): @@ -261,11 +261,11 @@ def run(self, project, options): For example: - $ compose run web python manage.py shell + $ docker-compose run web python manage.py shell By default, linked services will be started, unless they are already running. If you do not want to start linked services, use - `compose run --no-deps SERVICE COMMAND [ARGS...]`. + `docker-compose run --no-deps SERVICE COMMAND [ARGS...]`. Usage: run [options] [-e KEY=VAL...] SERVICE [COMMAND] [ARGS...] @@ -280,7 +280,7 @@ def run(self, project, options): --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 `compose run` + -T Disable pseudo-tty allocation. By default `docker-compose run` allocates a TTY. """ service = project.get_service(options['SERVICE']) @@ -352,7 +352,7 @@ def scale(self, project, options): Numbers are specified in the form `service=num` as arguments. For example: - $ compose scale web=2 worker=3 + $ docker-compose scale web=2 worker=3 Usage: scale [SERVICE=NUM...] """ @@ -372,7 +372,7 @@ def scale(self, project, options): 'Service "%s" cannot be scaled because it specifies a port ' 'on the host. If multiple containers for this service were ' 'created, the port would clash.\n\nRemove the ":" from the ' - 'port definition in compose.yml so Docker can choose a random ' + 'port definition in docker-compose.yml so Docker can choose a random ' 'port for each container.' % service_name) def start(self, project, options): @@ -387,7 +387,7 @@ def stop(self, project, options): """ Stop running containers without removing them. - They can be started again with `compose start`. + They can be started again with `docker-compose start`. Usage: stop [SERVICE...] """ @@ -405,14 +405,14 @@ def up(self, project, options): """ Build, (re)create, start and attach to containers for a service. - By default, `compose up` will aggregate the output of each container, and - when it exits, all containers will be stopped. If you run `compose up -d`, + By default, `docker-compose up` will aggregate the output of each container, and + when it exits, all containers will be stopped. If you run `docker-compose up -d`, it'll start the containers in the background and leave them running. - If there are existing containers for a service, `compose up` will stop + If there are existing containers for a service, `docker-compose up` will stop and recreate them (preserving mounted volumes with volumes-from), - so that changes in `compose.yml` are picked up. If you do not want existing - containers to be recreated, `compose up --no-recreate` will re-use existing + so that changes in `docker-compose.yml` are picked up. If you do not want existing + containers to be recreated, `docker-compose up --no-recreate` will re-use existing containers. Usage: up [options] [SERVICE...] diff --git a/script/build-docs b/script/build-docs index edcc52380eb..57dc82a2761 100755 --- a/script/build-docs +++ b/script/build-docs @@ -1,5 +1,5 @@ #!/bin/bash set -ex pushd docs -compose run --rm jekyll jekyll build +docker-compose run --rm jekyll jekyll build popd diff --git a/script/build-linux b/script/build-linux index aa69fa7b761..07c9d7ec6d2 100755 --- a/script/build-linux +++ b/script/build-linux @@ -2,7 +2,7 @@ set -ex mkdir -p `pwd`/dist chmod 777 `pwd`/dist -docker build -t compose . -docker run -u user -v `pwd`/dist:/code/dist --rm --entrypoint pyinstaller compose -F bin/compose -mv dist/compose dist/compose-Linux-x86_64 -docker run -u user -v `pwd`/dist:/code/dist --rm --entrypoint dist/compose-Linux-x86_64 compose --version +docker build -t docker-compose . +docker run -u user -v `pwd`/dist:/code/dist --rm --entrypoint pyinstaller docker-compose -F bin/docker-compose +mv dist/docker-compose dist/docker-compose-Linux-x86_64 +docker run -u user -v `pwd`/dist:/code/dist --rm --entrypoint dist/docker-compose-Linux-x86_64 docker-compose --version diff --git a/script/build-osx b/script/build-osx index 9b239caf2bf..26309744ad2 100755 --- a/script/build-osx +++ b/script/build-osx @@ -5,6 +5,6 @@ virtualenv venv venv/bin/pip install -r requirements.txt venv/bin/pip install -r requirements-dev.txt venv/bin/pip install . -venv/bin/pyinstaller -F bin/compose -mv dist/compose dist/compose-Darwin-x86_64 -dist/compose-Darwin-x86_64 --version +venv/bin/pyinstaller -F bin/docker-compose +mv dist/docker-compose dist/docker-compose-Darwin-x86_64 +dist/docker-compose-Darwin-x86_64 --version diff --git a/script/test b/script/test index b7e7245da60..fef16b80781 100755 --- a/script/test +++ b/script/test @@ -1,5 +1,5 @@ #!/bin/sh set -ex -docker build -t compose . -docker run -v /var/run/docker.sock:/var/run/docker.sock --rm --entrypoint flake8 compose compose -docker run -v /var/run/docker.sock:/var/run/docker.sock --rm --entrypoint nosetests compose $@ +docker build -t docker-compose . +docker run -v /var/run/docker.sock:/var/run/docker.sock --rm --entrypoint flake8 docker-compose compose +docker run -v /var/run/docker.sock:/var/run/docker.sock --rm --entrypoint nosetests docker-compose $@ diff --git a/setup.py b/setup.py index 7c21c53d76d..68c11bd9174 100644 --- a/setup.py +++ b/setup.py @@ -48,7 +48,7 @@ def find_version(*file_paths): setup( - name='compose', + name='docker-compose', version=find_version("compose", "__init__.py"), description='Multi-container orchestration for Docker', url='https://www.docker.com/', @@ -61,6 +61,6 @@ def find_version(*file_paths): tests_require=tests_require, entry_points=""" [console_scripts] - compose=compose.cli.main:main + docker-compose=compose.cli.main:main """, ) diff --git a/tests/fixtures/commands-composefile/compose.yml b/tests/fixtures/commands-composefile/docker-compose.yml similarity index 100% rename from tests/fixtures/commands-composefile/compose.yml rename to tests/fixtures/commands-composefile/docker-compose.yml diff --git a/tests/fixtures/dockerfile_with_entrypoint/compose.yml b/tests/fixtures/dockerfile_with_entrypoint/docker-compose.yml similarity index 100% rename from tests/fixtures/dockerfile_with_entrypoint/compose.yml rename to tests/fixtures/dockerfile_with_entrypoint/docker-compose.yml diff --git a/tests/fixtures/environment-composefile/compose.yml b/tests/fixtures/environment-composefile/docker-compose.yml similarity index 100% rename from tests/fixtures/environment-composefile/compose.yml rename to tests/fixtures/environment-composefile/docker-compose.yml diff --git a/tests/fixtures/links-composefile/compose.yml b/tests/fixtures/links-composefile/docker-compose.yml similarity index 100% rename from tests/fixtures/links-composefile/compose.yml rename to tests/fixtures/links-composefile/docker-compose.yml diff --git a/tests/fixtures/longer-filename-composefile/compose.yaml b/tests/fixtures/longer-filename-composefile/docker-compose.yaml similarity index 100% rename from tests/fixtures/longer-filename-composefile/compose.yaml rename to tests/fixtures/longer-filename-composefile/docker-compose.yaml diff --git a/tests/fixtures/multiple-composefiles/compose.yml b/tests/fixtures/multiple-composefiles/docker-compose.yml similarity index 100% rename from tests/fixtures/multiple-composefiles/compose.yml rename to tests/fixtures/multiple-composefiles/docker-compose.yml diff --git a/tests/fixtures/ports-composefile/compose.yml b/tests/fixtures/ports-composefile/docker-compose.yml similarity index 100% rename from tests/fixtures/ports-composefile/compose.yml rename to tests/fixtures/ports-composefile/docker-compose.yml diff --git a/tests/fixtures/simple-composefile/compose.yml b/tests/fixtures/simple-composefile/docker-compose.yml similarity index 100% rename from tests/fixtures/simple-composefile/compose.yml rename to tests/fixtures/simple-composefile/docker-compose.yml diff --git a/tests/fixtures/simple-dockerfile/compose.yml b/tests/fixtures/simple-dockerfile/docker-compose.yml similarity index 100% rename from tests/fixtures/simple-dockerfile/compose.yml rename to tests/fixtures/simple-dockerfile/docker-compose.yml From c981ce929a0d410c6033096354f84ea06459abed Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 20 Jan 2015 15:23:16 +0000 Subject: [PATCH 0650/4072] Update README.md, ROADMAP.md and CONTRIBUTING.md Signed-off-by: Aanand Prasad --- CONTRIBUTING.md | 16 ++++++++-------- README.md | 8 +++----- ROADMAP.md | 16 +++++++--------- 3 files changed, 18 insertions(+), 22 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index eb101955488..a9f7e564bb8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,4 +1,4 @@ -# Contributing to Fig +# Contributing to Compose ## TL;DR @@ -11,14 +11,14 @@ Pull requests will need: ## Development environment -If you're looking contribute to [Fig](http://www.fig.sh/) +If you're looking contribute to Compose but you're new to the project or maybe even to Python, here are the steps that should get you started. -1. Fork [https://github.com/docker/fig](https://github.com/docker/fig) to your username. -1. Clone your forked repository locally `git clone git@github.com:yourusername/fig.git`. -1. Enter the local directory `cd fig`. -1. Set up a development environment by running `python setup.py develop`. This will install the dependencies and set up a symlink from your `fig` executable to the checkout of the repository. When you now run `fig` from anywhere on your machine, it will run your development version of Fig. +1. Fork [https://github.com/docker/compose](https://github.com/docker/compose) to your username. +1. Clone your forked repository locally `git clone git@github.com:yourusername/compose.git`. +1. Enter the local directory `cd compose`. +1. Set up a development environment by running `python setup.py develop`. This will install the dependencies and set up a symlink from your `docker-compose` executable to the checkout of the repository. When you now run `docker-compose` from anywhere on your machine, it will run your development version of Compose. ## Running the test suite @@ -84,7 +84,7 @@ Note that this only works on Mountain Lion, not Mavericks, due to a [bug in PyIn 1. Open pull request that: - - Updates version in `fig/__init__.py` + - Updates version in `compose/__init__.py` - Updates version in `docs/install.md` - Adds release notes to `CHANGES.md` @@ -92,7 +92,7 @@ Note that this only works on Mountain Lion, not Mavericks, due to a [bug in PyIn 3. Build Linux version on any Docker host with `script/build-linux` and attach to release -4. Build OS X version on Mountain Lion with `script/build-osx` and attach to release as `fig-Darwin-x86_64` and `fig-Linux-x86_64`. +4. Build OS X version on Mountain Lion with `script/build-osx` and attach to release as `docker-compose-Darwin-x86_64` and `docker-compose-Linux-x86_64`. 5. Publish GitHub release, creating tag diff --git a/README.md b/README.md index fc978996ec4..6efe779b5e5 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ -Fig -=== +Docker Compose +============== [![wercker status](https://app.wercker.com/status/d5dbac3907301c3d5ce735e2d5e95a5b/s/master "wercker status")](https://app.wercker.com/project/bykey/d5dbac3907301c3d5ce735e2d5e95a5b) @@ -29,9 +29,7 @@ db: (No more installing Postgres on your laptop!) -Then type `fig up`, and Fig will start and run your entire app: - -![example fig run](https://orchardup.com/static/images/fig-example-large.gif) +Then type `docker-compose up`, and Compose will start and run your entire app. There are commands to: diff --git a/ROADMAP.md b/ROADMAP.md index 0ee228955a0..68c858367e5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,28 +1,26 @@ # Roadmap -Fig will be incorporated as part of the Docker ecosystem and renamed Docker Compose. The command-line tool and configuration file will get new names, and its documentation will be moved to [docs.docker.com](https://docs.docker.com). - ## More than just development environments -Over time we will extend Fig's remit to cover test, staging and production environments. This is not a simple task, and will take many incremental improvements such as: +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: -- Fig’s brute-force “delete and recreate everything” approach is great for dev and testing, but it not sufficient for production environments. You should be able to define a "desired" state that Fig will intelligently converge to. +- Compose’s brute-force “delete and recreate everything” approach is great for dev and testing, but it not sufficient for production environments. You should be able to define a "desired" state that Compose will intelligently converge to. - 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. ([#426](https://github.com/docker/fig/issues/426)) -- Fig recommend a technique for zero-downtime deploys. +- Compose should recommend a technique for zero-downtime deploys. ## Integration with Swarm -Fig should integrate really well with Swarm so you can take an application you've developed on your laptop and run it on a Swarm cluster. +Compose should integrate really well with Swarm so you can take an application you've developed on your laptop and run it on a Swarm cluster. ## Applications spanning multiple teams -Fig 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, Fig doesn't work as well. +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). ## An even better tool for development environments -Fig is a great tool for development environments, but it could be even better. For example: +Compose is a great tool for development environments, but it could be even better. For example: -- [Fig could watch your code and automatically kick off builds when something changes.](https://github.com/docker/fig/issues/184) +- [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 e3c4a662d9c2acd3b4190da90c0a671b29742e2e Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 20 Jan 2015 17:29:28 +0000 Subject: [PATCH 0651/4072] Find-and-replace on docs Signed-off-by: Aanand Prasad --- docs/cli.md | 38 ++++++++++++++++----------------- docs/completion.md | 12 +++++------ docs/django.md | 28 ++++++++++++------------- docs/env.md | 8 +++---- docs/index.md | 52 +++++++++++++++++++++++----------------------- docs/install.md | 16 +++++++------- docs/rails.md | 28 ++++++++++++------------- docs/wordpress.md | 12 +++++------ docs/yml.md | 18 ++++++++-------- 9 files changed, 106 insertions(+), 106 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 74c82bedef0..f719a10c495 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1,6 +1,6 @@ --- layout: default -title: Fig CLI reference +title: Compose CLI reference --- CLI reference @@ -8,7 +8,7 @@ CLI reference Most commands are run against one or more services. If the service is omitted, it will apply to all services. -Run `fig [COMMAND] --help` for full usage. +Run `docker-compose [COMMAND] --help` for full usage. ## Options @@ -22,7 +22,7 @@ Run `fig [COMMAND] --help` for full usage. ### -f, --file FILE - Specify an alternate fig file (default: fig.yml) + Specify an alternate docker-compose file (default: docker-compose.yml) ### -p, --project-name NAME @@ -34,7 +34,7 @@ Run `fig [COMMAND] --help` for full usage. Build or rebuild services. -Services are built once and then tagged as `project_service`, e.g. `figtest_db`. If you change a service's `Dockerfile` or the contents of its build directory, you can run `fig build` to rebuild it. +Services are built once and then tagged as `project_service`, e.g. `docker-composetest_db`. If you change a service's `Dockerfile` or the contents of its build directory, you can run `docker-compose build` to rebuild it. ### help @@ -44,7 +44,7 @@ Get help on a command. Force stop running containers by sending a `SIGKILL` signal. Optionally the signal can be passed, for example: - $ fig kill -s SIGINT + $ docker-compose kill -s SIGINT ### logs @@ -73,22 +73,22 @@ Run a one-off command on a service. For example: - $ fig run web python manage.py shell + $ docker-compose run web python manage.py shell By default, linked services will be started, unless they are already running. -One-off commands are started in new containers with the same config as a normal container for that service, so volumes, links, etc will all be created as expected. The only thing different to a normal container is the command will be overridden with the one specified and by default no ports will be created in case they collide. +One-off commands are started in new containers with the same condocker-compose as a normal container for that service, so volumes, links, etc will all be created as expected. The only thing different to a normal container is the command will be overridden with the one specified and by default no ports will be created in case they collide. Links are also created between one-off commands and the other containers for that service so you can do stuff like this: - $ fig run db psql -h db -U docker + $ docker-compose run db psql -h db -U docker If you do not want linked containers to be started when running the one-off command, specify the `--no-deps` flag: - $ fig run --no-deps web python manage.py shell + $ docker-compose run --no-deps web python manage.py shell If you want the service's ports to be created and mapped to the host, specify the `--service-ports` flag: - $ fig run --service-ports web python manage.py shell + $ docker-compose run --service-ports web python manage.py shell ### scale @@ -97,7 +97,7 @@ Set number of containers to run for a service. Numbers are specified in the form `service=num` as arguments. For example: - $ fig scale web=2 worker=3 + $ docker-compose scale web=2 worker=3 ### start @@ -105,7 +105,7 @@ Start existing containers for a service. ### stop -Stop running containers without removing them. They can be started again with `fig start`. +Stop running containers without removing them. They can be started again with `docker-compose start`. ### up @@ -113,26 +113,26 @@ Build, (re)create, start and attach to containers for a service. Linked services will be started, unless they are already running. -By default, `fig up` will aggregate the output of each container, and when it exits, all containers will be stopped. If you run `fig up -d`, it'll start the containers in the background and leave them running. +By default, `docker-compose up` will aggregate the output of each container, and when it exits, all containers will be stopped. If you run `docker-compose up -d`, it'll start the containers in the background and leave them running. -By default if there are existing containers for a service, `fig up` will stop and recreate them (preserving mounted volumes with [volumes-from]), so that changes in `fig.yml` are picked up. If you do no want containers to be stopped and recreated, use `fig up --no-recreate`. This will still start any stopped containers, if needed. +By default if there are existing containers for a service, `docker-compose up` will stop and recreate them (preserving mounted volumes with [volumes-from]), so that changes in `docker-compose.yml` are picked up. If you do no want containers to be stopped and recreated, use `docker-compose up --no-recreate`. This will still start any stopped containers, if needed. [volumes-from]: http://docs.docker.io/en/latest/use/working_with_volumes/ ## Environment Variables -Several environment variables can be used to configure Fig's behaviour. +Several environment variables can be used to condocker-composeure Compose's behaviour. -Variables starting with `DOCKER_` are the same as those used to configure the Docker command-line client. If you're using boot2docker, `$(boot2docker shellinit)` will set them to their correct values. +Variables starting with `DOCKER_` are the same as those used to condocker-composeure the Docker command-line client. If you're using boot2docker, `$(boot2docker shellinit)` will set them to their correct values. ### FIG\_PROJECT\_NAME -Set the project name, which is prepended to the name of every container started by Fig. Defaults to the `basename` of the current working directory. +Set the project name, which is prepended to the name of every container started by Compose. Defaults to the `basename` of the current working directory. ### FIG\_FILE -Set the path to the `fig.yml` to use. Defaults to `fig.yml` in the current working directory. +Set the path to the `docker-compose.yml` to use. Defaults to `docker-compose.yml` in the current working directory. ### DOCKER\_HOST @@ -144,4 +144,4 @@ When set to anything other than an empty string, enables TLS communication with ### DOCKER\_CERT\_PATH -Configure the path to the `ca.pem`, `cert.pem` and `key.pem` files used for TLS verification. Defaults to `~/.docker`. +Condocker-composeure the path to the `ca.pem`, `cert.pem` and `key.pem` files used for TLS verification. Defaults to `~/.docker`. diff --git a/docs/completion.md b/docs/completion.md index ec8d7766aff..1a2d49ee7e6 100644 --- a/docs/completion.md +++ b/docs/completion.md @@ -6,7 +6,7 @@ title: Command Completion Command Completion ================== -Fig comes with [command completion](http://en.wikipedia.org/wiki/Command-line_completion) +Compose comes with [command completion](http://en.wikipedia.org/wiki/Command-line_completion) for the bash shell. Installing Command Completion @@ -17,7 +17,7 @@ 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/fig/master/contrib/completion/bash/fig > /etc/bash_completion.d/fig + curl -L https://raw.githubusercontent.com/docker/docker-compose/master/contrib/completion/bash/docker-compose > /etc/bash_completion.d/docker-compose Completion will be available upon next login. @@ -25,9 +25,9 @@ Available completions --------------------- Depending on what you typed on the command line so far, it will complete - - available fig commands + - 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 `fig scale`, completed service names will automatically have "=" appended. - - arguments for selected options, e.g. `fig kill -s` will complete some signals like SIGHUP and SIGUSR1. + - 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 fig faster and with less typos! +Enjoy working with docker-compose faster and with less typos! diff --git a/docs/django.md b/docs/django.md index 1fc0f77e7f6..2a7e9cea2ef 100644 --- a/docs/django.md +++ b/docs/django.md @@ -1,12 +1,12 @@ --- layout: default -title: Getting started with Fig and Django +title: Getting started with Compose and Django --- -Getting started with Fig and Django +Getting started with Compose and Django =================================== -Let's use Fig to set up and run a Django/PostgreSQL app. Before starting, you'll need to have [Fig installed](install.html). +Let's use Compose to set up and run a Django/PostgreSQL app. Before starting, you'll need to have [Compose installed](install.html). Let's set up the three files that'll get us started. First, our app is going to be running inside a Docker container which contains all of its dependencies. We can define what goes inside that Docker container using a file called `Dockerfile`. It'll contain this to start with: @@ -25,7 +25,7 @@ Second, we define our Python dependencies in a file called `requirements.txt`: Django psycopg2 -Simple enough. Finally, this is all tied together with a file called `fig.yml`. It describes the services that our app comprises of (a web server and database), what Docker images they use, how they link together, what volumes will be mounted inside the containers and what ports they expose. +Simple enough. Finally, this is all tied together with a file called `docker-compose.yml`. It describes the services that our app comprises of (a web server and database), what Docker images they use, how they link together, what volumes will be mounted inside the containers and what ports they expose. db: image: postgres @@ -39,20 +39,20 @@ Simple enough. Finally, this is all tied together with a file called `fig.yml`. links: - db -See the [`fig.yml` reference](yml.html) for more information on how it works. +See the [`docker-compose.yml` reference](yml.html) for more information on how it works. -We can now start a Django project using `fig run`: +We can now start a Django project using `docker-compose run`: - $ fig run web django-admin.py startproject figexample . + $ docker-compose run web django-admin.py startproject docker-composeexample . -First, Fig will build an image for the `web` service using the `Dockerfile`. It will then run `django-admin.py startproject figexample .` inside a container using that image. +First, Compose will build an image for the `web` service using the `Dockerfile`. It will then run `django-admin.py startproject docker-composeexample .` inside a container using that image. This will generate a Django app inside the current directory: $ ls - Dockerfile fig.yml figexample manage.py requirements.txt + Dockerfile docker-compose.yml docker-composeexample manage.py requirements.txt -First thing we need to do is set up the database connection. Replace the `DATABASES = ...` definition in `figexample/settings.py` to read: +First thing we need to do is set up the database connection. Replace the `DATABASES = ...` definition in `docker-composeexample/settings.py` to read: DATABASES = { 'default': { @@ -66,7 +66,7 @@ First thing we need to do is set up the database connection. Replace the `DATABA These settings are determined by the [postgres](https://registry.hub.docker.com/_/postgres/) Docker image we are using. -Then, run `fig up`: +Then, run `docker-compose up`: Recreating myapp_db_1... Recreating myapp_web_1... @@ -79,13 +79,13 @@ Then, run `fig up`: myapp_web_1 | myapp_web_1 | 0 errors found myapp_web_1 | January 27, 2014 - 12:12:40 - myapp_web_1 | Django version 1.6.1, using settings 'figexample.settings' + myapp_web_1 | Django version 1.6.1, using settings 'docker-composeexample.settings' myapp_web_1 | Starting development server at http://0.0.0.0:8000/ myapp_web_1 | Quit the server with CONTROL-C. And your Django app should be running at port 8000 on your docker daemon (if you're using boot2docker, `boot2docker ip` will tell you its address). -You can also run management commands with Docker. To set up your database, for example, run `fig up` and in another terminal run: +You can also run management commands with Docker. To set up your database, for example, run `docker-compose up` and in another terminal run: - $ fig run web python manage.py syncdb + $ docker-compose run web python manage.py syncdb diff --git a/docs/env.md b/docs/env.md index b38cd176a93..8644c8ae64c 100644 --- a/docs/env.md +++ b/docs/env.md @@ -1,16 +1,16 @@ --- layout: default -title: Fig environment variables reference +title: Compose environment variables reference --- 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 [fig.yml documentation](yml.html#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](yml.html#links) for details. -Fig 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] 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 `fig run SERVICE env`. +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` diff --git a/docs/index.md b/docs/index.md index f2cdc83cf34..9cb465361dd 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,6 +1,6 @@ --- layout: default -title: Fig | Fast, isolated development environments using Docker +title: Compose | Fast, isolated development environments using Docker --- Fast, isolated development environments using Docker. @@ -12,7 +12,7 @@ Define your app's environment with a `Dockerfile` so it can be reproduced anywhe WORKDIR /code RUN pip install -r requirements.txt -Define the services that make up your app in `fig.yml` so they can be run together in an isolated environment: +Define the services that make up your app in `docker-compose.yml` so they can be run together in an isolated environment: ```yaml web: @@ -28,9 +28,9 @@ db: (No more installing Postgres on your laptop!) -Then type `fig up`, and Fig will start and run your entire app: +Then type `docker-compose up`, and Compose will start and run your entire app: -![example fig run](https://orchardup.com/static/images/fig-example-large.gif) +![example docker-compose run](https://orchardup.com/static/images/docker-compose-example-large.gif) There are commands to: @@ -43,14 +43,14 @@ There are commands to: Quick start ----------- -Let's get a basic Python web app running on Fig. It assumes a little knowledge of Python, but the concepts should be clear if you're not familiar with it. +Let's get a basic Python web app running on Compose. It assumes a little knowledge of Python, but the concepts should be clear if you're not familiar with it. -First, [install Docker and Fig](install.html). +First, [install Docker and Compose](install.html). You'll want to make a directory for the project: - $ mkdir figtest - $ cd figtest + $ mkdir docker-composetest + $ cd docker-composetest Inside this directory, create `app.py`, a simple web app that uses the Flask framework and increments a value in Redis: @@ -84,7 +84,7 @@ Next, we want to create a Docker image containing all of our app's dependencies. This tells Docker to install Python, our code and our Python dependencies inside a Docker image. For more information on how to write Dockerfiles, see the [Docker user guide](https://docs.docker.com/userguide/dockerimages/#building-an-image-from-a-dockerfile) and the [Dockerfile reference](http://docs.docker.com/reference/builder/). -We then define a set of services using `fig.yml`: +We then define a set of services using `docker-compose.yml`: web: build: . @@ -103,38 +103,38 @@ This defines two services: - `web`, which is built from `Dockerfile` in the current directory. It also says to run the command `python app.py` inside the image, forward the exposed port 5000 on the container to port 5000 on the host machine, connect up the Redis service, and mount the current directory inside the container so we can work on code without having to rebuild the image. - `redis`, which uses the public image [redis](https://registry.hub.docker.com/_/redis/). -Now if we run `fig up`, it'll pull a Redis image, build an image for our own code, and start everything up: +Now if we run `docker-compose up`, it'll pull a Redis image, build an image for our own code, and start everything up: - $ fig up + $ docker-compose up Pulling image redis... Building web... - Starting figtest_redis_1... - Starting figtest_web_1... + Starting docker-composetest_redis_1... + Starting docker-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/ The web app should now be listening on port 5000 on your docker daemon (if you're using boot2docker, `boot2docker ip` will tell you its address). -If you want to run your services in the background, you can pass the `-d` flag to `fig up` and use `fig ps` to see what is currently running: +If you want to run your services in the background, you can pass the `-d` flag to `docker-compose up` and use `docker-compose ps` to see what is currently running: - $ fig up -d - Starting figtest_redis_1... - Starting figtest_web_1... - $ fig ps + $ docker-compose up -d + Starting docker-composetest_redis_1... + Starting docker-composetest_web_1... + $ docker-compose ps Name Command State Ports ------------------------------------------------------------------- - figtest_redis_1 /usr/local/bin/run Up - figtest_web_1 /bin/sh -c python app.py Up 5000->5000/tcp + docker-composetest_redis_1 /usr/local/bin/run Up + docker-composetest_web_1 /bin/sh -c python app.py Up 5000->5000/tcp -`fig run` 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` allows you to run one-off commands for your services. For example, to see what environment variables are available to the `web` service: - $ fig run web env + $ docker-compose run web env -See `fig --help` other commands that are available. +See `docker-compose --help` other commands that are available. -If you started Fig with `fig up -d`, you'll probably want to stop your services once you've finished with them: +If you started Compose with `docker-compose up -d`, you'll probably want to stop your services once you've finished with them: - $ fig stop + $ docker-compose stop -That's more-or-less how Fig works. See the reference section below for full details on the commands, configuration file and environment variables. If you have any thoughts or suggestions, [open an issue on GitHub](https://github.com/docker/fig). +That's more-or-less how Compose works. See the reference section below for full details on the commands, condocker-composeuration file and environment variables. If you have any thoughts or suggestions, [open an issue on GitHub](https://github.com/docker/docker-compose). diff --git a/docs/install.md b/docs/install.md index c91c3db0d9e..b1fa83457ea 100644 --- a/docs/install.md +++ b/docs/install.md @@ -1,14 +1,14 @@ --- layout: default -title: Installing Fig +title: Installing Compose --- -Installing Fig +Installing Compose ============== First, install Docker version 1.3 or greater. -If you're on OS X, you can use the [OS X installer](https://docs.docker.com/installation/mac/) to install both Docker and boot2docker. Once boot2docker is running, set the environment variables that'll configure Docker and Fig to talk to it: +If you're on OS X, you can use the [OS X installer](https://docs.docker.com/installation/mac/) to install both Docker and boot2docker. Once boot2docker is running, set the environment variables that'll condocker-composeure Docker and Compose to talk to it: $(boot2docker shellinit) @@ -16,14 +16,14 @@ To persist the environment variables across shell sessions, you can add that lin There are also guides for [Ubuntu](https://docs.docker.com/installation/ubuntulinux/) and [other platforms](https://docs.docker.com/installation/) in Docker’s documentation. -Next, install Fig: +Next, install Compose: - curl -L https://github.com/docker/fig/releases/download/1.0.1/fig-`uname -s`-`uname -m` > /usr/local/bin/fig; chmod +x /usr/local/bin/fig + curl -L https://github.com/docker/docker-compose/releases/download/1.0.1/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose; chmod +x /usr/local/bin/docker-compose Optionally, install [command completion](completion.html) for the bash shell. -Releases are available for OS X and 64-bit Linux. Fig is also available as a Python package if you're on another platform (or if you prefer that sort of thing): +Releases are available for OS X and 64-bit Linux. Compose is also available as a Python package if you're on another platform (or if you prefer that sort of thing): - $ sudo pip install -U fig + $ sudo pip install -U docker-compose -That should be all you need! Run `fig --version` to see if it worked. +That should be all you need! Run `docker-compose --version` to see if it worked. diff --git a/docs/rails.md b/docs/rails.md index 25678b2955b..d2f5343b70e 100644 --- a/docs/rails.md +++ b/docs/rails.md @@ -1,12 +1,12 @@ --- layout: default -title: Getting started with Fig and Rails +title: Getting started with Compose and Rails --- -Getting started with Fig and Rails +Getting started with Compose and Rails ================================== -We're going to use Fig to set up and run a Rails/PostgreSQL app. Before starting, you'll need to have [Fig installed](install.html). +We're going to use Compose to set up and run a Rails/PostgreSQL app. Before starting, you'll need to have [Compose installed](install.html). Let's set up the three files that'll get us started. First, our app is going to be running inside a Docker container which contains all of its dependencies. We can define what goes inside that Docker container using a file called `Dockerfile`. It'll contain this to start with: @@ -25,7 +25,7 @@ Next, we have a bootstrap `Gemfile` which just loads Rails. It'll be overwritten source 'https://rubygems.org' gem 'rails', '4.0.2' -Finally, `fig.yml` is where the magic happens. It describes what services our app comprises (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 we need to link them together and expose the web app's port. +Finally, `docker-compose.yml` is where the magic happens. It describes what services our app comprises (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 condocker-composeuration we need to link them together and expose the web app's port. db: image: postgres @@ -41,17 +41,17 @@ Finally, `fig.yml` is where the magic happens. It describes what services our ap links: - db -With those files in place, we can now generate the Rails skeleton app using `fig run`: +With those files in place, we can now generate the Rails skeleton app using `docker-compose run`: - $ fig run web rails new . --force --database=postgresql --skip-bundle + $ docker-compose run web rails new . --force --database=postgresql --skip-bundle -First, Fig 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 a fresh app generated: +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 a fresh app generated: $ ls - Dockerfile app fig.yml tmp + Dockerfile app docker-compose.yml tmp Gemfile bin lib vendor - Gemfile.lock config log - README.rdoc config.ru public + Gemfile.lock condocker-compose log + README.rdoc condocker-compose.ru public Rakefile db test Uncomment the line in your new `Gemfile` which loads `therubyracer`, so we've got a Javascript runtime: @@ -60,7 +60,7 @@ Uncomment the line in your new `Gemfile` which loads `therubyracer`, so we've go Now that we've got a new `Gemfile`, we need to build the image again. (This, and changes to the Dockerfile itself, should be the only times you'll need to rebuild). - $ fig build + $ docker-compose build The app is now bootable, but we're not quite there yet. By default, Rails expects a database to be running on `localhost` - we need to point it at the `db` container instead. We also need to change the database and username to align with the defaults set by the `postgres` image. @@ -81,7 +81,7 @@ Open up your newly-generated `database.yml`. Replace its contents with the follo We can now boot the app. - $ fig up + $ docker-compose up If all's well, you should see some PostgreSQL output, and then—after a few seconds—the familiar refrain: @@ -91,8 +91,8 @@ If all's well, you should see some PostgreSQL output, and then—after a few sec Finally, we just need to create the database. In another terminal, run: - $ fig run web rake db:create + $ docker-compose run web rake db:create And we're rolling—your app should now be running on port 3000 on your docker daemon (if you're using boot2docker, `boot2docker ip` will tell you its address). -![Screenshot of Rails' stock index.html](https://orchardup.com/static/images/fig-rails-screenshot.png) +![Screenshot of Rails' stock index.html](https://orchardup.com/static/images/docker-compose-rails-screenshot.png) diff --git a/docs/wordpress.md b/docs/wordpress.md index e2c43252561..9c69b2eef47 100644 --- a/docs/wordpress.md +++ b/docs/wordpress.md @@ -1,12 +1,12 @@ --- layout: default -title: Getting started with Fig and Wordpress +title: Getting started with Compose and Wordpress --- -Getting started with Fig and Wordpress +Getting started with Compose and Wordpress ====================================== -Fig makes it nice and easy to run Wordpress in an isolated environment. [Install Fig](install.html), then download Wordpress into the current directory: +Compose makes it nice and easy to run Wordpress in an isolated environment. [Install Compose](install.html), then download Wordpress into the current directory: $ curl https://wordpress.org/latest.tar.gz | tar -xvzf - @@ -19,7 +19,7 @@ ADD . /code This instructs Docker on how to build an image that contains PHP and Wordpress. For more information on how to write Dockerfiles, see the [Docker user guide](https://docs.docker.com/userguide/dockerimages/#building-an-image-from-a-dockerfile) and the [Dockerfile reference](http://docs.docker.com/reference/builder/). -Next up, `fig.yml` starts our web service and a separate MySQL instance: +Next up, `docker-compose.yml` starts our web service and a separate MySQL instance: ``` web: @@ -37,7 +37,7 @@ db: MYSQL_DATABASE: wordpress ``` -Two supporting files are needed to get this working - first up, `wp-config.php` is the standard Wordpress config file with a single change to point the database configuration at the `db` container: +Two supporting files are needed to get this working - first up, `wp-condocker-compose.php` is the standard Wordpress condocker-compose file with a single change to point the database condocker-composeuration at the `db` container: ``` Date: Tue, 20 Jan 2015 17:44:48 +0000 Subject: [PATCH 0652/4072] Manual fixes to docs Signed-off-by: Aanand Prasad --- docs/cli.md | 12 ++++++------ docs/completion.md | 2 +- docs/django.md | 10 +++++----- docs/index.md | 26 +++++++++++--------------- docs/install.md | 2 +- docs/rails.md | 8 +++----- docs/wordpress.md | 2 +- docs/yml.md | 4 ++-- 8 files changed, 30 insertions(+), 36 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index f719a10c495..8fd4b800e11 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -22,7 +22,7 @@ Run `docker-compose [COMMAND] --help` for full usage. ### -f, --file FILE - Specify an alternate docker-compose file (default: docker-compose.yml) + Specify an alternate compose file (default: docker-compose.yml) ### -p, --project-name NAME @@ -34,7 +34,7 @@ Run `docker-compose [COMMAND] --help` for full usage. Build or rebuild services. -Services are built once and then tagged as `project_service`, e.g. `docker-composetest_db`. If you change a service's `Dockerfile` or the contents of its build directory, you can run `docker-compose build` to rebuild it. +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, you can run `docker-compose build` to rebuild it. ### help @@ -77,7 +77,7 @@ For example: By default, linked services will be started, unless they are already running. -One-off commands are started in new containers with the same condocker-compose as a normal container for that service, so volumes, links, etc will all be created as expected. The only thing different to a normal container is the command will be overridden with the one specified and by default no ports will be created in case they collide. +One-off commands are started in new containers with the same configuration as a normal container for that service, so volumes, links, etc will all be created as expected. The only thing different to a normal container is the command will be overridden with the one specified and by default no ports will be created in case they collide. Links are also created between one-off commands and the other containers for that service so you can do stuff like this: @@ -122,9 +122,9 @@ By default if there are existing containers for a service, `docker-compose up` w ## Environment Variables -Several environment variables can be used to condocker-composeure Compose's behaviour. +Several environment variables can be used to configure Compose's behaviour. -Variables starting with `DOCKER_` are the same as those used to condocker-composeure the Docker command-line client. If you're using boot2docker, `$(boot2docker shellinit)` will set them to their correct values. +Variables starting with `DOCKER_` are the same as those used to configure the Docker command-line client. If you're using boot2docker, `$(boot2docker shellinit)` will set them to their correct values. ### FIG\_PROJECT\_NAME @@ -144,4 +144,4 @@ When set to anything other than an empty string, enables TLS communication with ### DOCKER\_CERT\_PATH -Condocker-composeure the path to the `ca.pem`, `cert.pem` and `key.pem` files used for TLS verification. Defaults to `~/.docker`. +Configure the path to the `ca.pem`, `cert.pem` and `key.pem` files used for TLS verification. Defaults to `~/.docker`. diff --git a/docs/completion.md b/docs/completion.md index 1a2d49ee7e6..2710a635c94 100644 --- a/docs/completion.md +++ b/docs/completion.md @@ -30,4 +30,4 @@ Depending on what you typed on the command line so far, it will complete - 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 docker-compose faster and with less typos! +Enjoy working with Compose faster and with less typos! diff --git a/docs/django.md b/docs/django.md index 2a7e9cea2ef..93e778b3c31 100644 --- a/docs/django.md +++ b/docs/django.md @@ -43,16 +43,16 @@ See the [`docker-compose.yml` reference](yml.html) for more information on how i We can now start a Django project using `docker-compose run`: - $ docker-compose run web django-admin.py startproject docker-composeexample . + $ docker-compose run web django-admin.py startproject composeexample . -First, Compose will build an image for the `web` service using the `Dockerfile`. It will then run `django-admin.py startproject docker-composeexample .` inside a container using that image. +First, Compose will build an image for the `web` service using the `Dockerfile`. It will then run `django-admin.py startproject composeexample .` inside a container using that image. This will generate a Django app inside the current directory: $ ls - Dockerfile docker-compose.yml docker-composeexample manage.py requirements.txt + Dockerfile docker-compose.yml composeexample manage.py requirements.txt -First thing we need to do is set up the database connection. Replace the `DATABASES = ...` definition in `docker-composeexample/settings.py` to read: +First thing we need to do is set up the database connection. Replace the `DATABASES = ...` definition in `composeexample/settings.py` to read: DATABASES = { 'default': { @@ -79,7 +79,7 @@ Then, run `docker-compose up`: myapp_web_1 | myapp_web_1 | 0 errors found myapp_web_1 | January 27, 2014 - 12:12:40 - myapp_web_1 | Django version 1.6.1, using settings 'docker-composeexample.settings' + myapp_web_1 | Django version 1.6.1, using settings 'composeexample.settings' myapp_web_1 | Starting development server at http://0.0.0.0:8000/ myapp_web_1 | Quit the server with CONTROL-C. diff --git a/docs/index.md b/docs/index.md index 9cb465361dd..00f48be9edb 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,10 +1,8 @@ --- layout: default -title: Compose | Fast, isolated development environments using Docker +title: Compose: Multi-container orchestration for Docker --- -Fast, isolated development environments using Docker. - Define your app's environment with a `Dockerfile` so it can be reproduced anywhere: FROM python:2.7 @@ -28,9 +26,7 @@ db: (No more installing Postgres on your laptop!) -Then type `docker-compose up`, and Compose will start and run your entire app: - -![example docker-compose run](https://orchardup.com/static/images/docker-compose-example-large.gif) +Then type `docker-compose up`, and Compose will start and run your entire app. There are commands to: @@ -49,8 +45,8 @@ First, [install Docker and Compose](install.html). You'll want to make a directory for the project: - $ mkdir docker-composetest - $ cd docker-composetest + $ mkdir composetest + $ cd composetest Inside this directory, create `app.py`, a simple web app that uses the Flask framework and increments a value in Redis: @@ -108,8 +104,8 @@ Now if we run `docker-compose up`, it'll pull a Redis image, build an image for $ docker-compose up Pulling image redis... Building web... - Starting docker-composetest_redis_1... - Starting docker-composetest_web_1... + 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/ @@ -118,13 +114,13 @@ The web app should now be listening on port 5000 on your docker daemon (if you'r If you want to run your services in the background, you can pass the `-d` flag to `docker-compose up` and use `docker-compose ps` to see what is currently running: $ docker-compose up -d - Starting docker-composetest_redis_1... - Starting docker-composetest_web_1... + Starting composetest_redis_1... + Starting composetest_web_1... $ docker-compose ps Name Command State Ports ------------------------------------------------------------------- - docker-composetest_redis_1 /usr/local/bin/run Up - docker-composetest_web_1 /bin/sh -c python app.py Up 5000->5000/tcp + composetest_redis_1 /usr/local/bin/run Up + composetest_web_1 /bin/sh -c python app.py Up 5000->5000/tcp `docker-compose run` allows you to run one-off commands for your services. For example, to see what environment variables are available to the `web` service: @@ -137,4 +133,4 @@ If you started Compose with `docker-compose up -d`, you'll probably want to stop $ docker-compose stop -That's more-or-less how Compose works. See the reference section below for full details on the commands, condocker-composeuration file and environment variables. If you have any thoughts or suggestions, [open an issue on GitHub](https://github.com/docker/docker-compose). +That's more-or-less how Compose works. See the reference section below for full details on the commands, configuration file and environment variables. If you have any thoughts or suggestions, [open an issue on GitHub](https://github.com/docker/docker-compose). diff --git a/docs/install.md b/docs/install.md index b1fa83457ea..e61cd0fc3a3 100644 --- a/docs/install.md +++ b/docs/install.md @@ -8,7 +8,7 @@ Installing Compose First, install Docker version 1.3 or greater. -If you're on OS X, you can use the [OS X installer](https://docs.docker.com/installation/mac/) to install both Docker and boot2docker. Once boot2docker is running, set the environment variables that'll condocker-composeure Docker and Compose to talk to it: +If you're on OS X, you can use the [OS X installer](https://docs.docker.com/installation/mac/) to install both Docker and boot2docker. Once boot2docker is running, set the environment variables that'll configure Docker and Compose to talk to it: $(boot2docker shellinit) diff --git a/docs/rails.md b/docs/rails.md index d2f5343b70e..59ee20b469c 100644 --- a/docs/rails.md +++ b/docs/rails.md @@ -25,7 +25,7 @@ Next, we have a bootstrap `Gemfile` which just loads Rails. It'll be overwritten source 'https://rubygems.org' gem 'rails', '4.0.2' -Finally, `docker-compose.yml` is where the magic happens. It describes what services our app comprises (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 condocker-composeuration we need to link them together and expose the web app's port. +Finally, `docker-compose.yml` is where the magic happens. It describes what services our app comprises (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 we need to link them together and expose the web app's port. db: image: postgres @@ -50,8 +50,8 @@ First, Compose will build the image for the `web` service using the `Dockerfile` $ ls Dockerfile app docker-compose.yml tmp Gemfile bin lib vendor - Gemfile.lock condocker-compose log - README.rdoc condocker-compose.ru public + Gemfile.lock config log + README.rdoc config.ru public Rakefile db test Uncomment the line in your new `Gemfile` which loads `therubyracer`, so we've got a Javascript runtime: @@ -94,5 +94,3 @@ Finally, we just need to create the database. In another terminal, run: $ docker-compose run web rake db:create And we're rolling—your app should now be running on port 3000 on your docker daemon (if you're using boot2docker, `boot2docker ip` will tell you its address). - -![Screenshot of Rails' stock index.html](https://orchardup.com/static/images/docker-compose-rails-screenshot.png) diff --git a/docs/wordpress.md b/docs/wordpress.md index 9c69b2eef47..47808fe6a30 100644 --- a/docs/wordpress.md +++ b/docs/wordpress.md @@ -37,7 +37,7 @@ db: MYSQL_DATABASE: wordpress ``` -Two supporting files are needed to get this working - first up, `wp-condocker-compose.php` is the standard Wordpress condocker-compose file with a single change to point the database condocker-composeuration at the `db` container: +Two supporting files are needed to get this working - first up, `wp-config.php` is the standard Wordpress config file with a single change to point the database configuration at the `db` container: ``` Date: Tue, 20 Jan 2015 17:49:13 +0000 Subject: [PATCH 0653/4072] Remove all website-related stuff from docs Signed-off-by: Aanand Prasad --- docs/.gitignore-gh-pages | 1 - docs/CNAME | 1 - docs/Dockerfile | 10 -- docs/Gemfile | 3 - docs/Gemfile.lock | 62 ------------ docs/_config.yml | 3 - docs/_layouts/default.html | 73 --------------- docs/css/bootstrap.min.css | 7 -- docs/css/fig.css | 187 ------------------------------------- docs/fig.yml | 8 -- docs/img/favicon.ico | Bin 1150 -> 0 bytes docs/img/logo.png | Bin 133640 -> 0 bytes script/build-docs | 5 - script/open-docs | 2 - 14 files changed, 362 deletions(-) delete mode 100644 docs/.gitignore-gh-pages delete mode 100644 docs/CNAME delete mode 100644 docs/Dockerfile delete mode 100644 docs/Gemfile delete mode 100644 docs/Gemfile.lock delete mode 100644 docs/_config.yml delete mode 100644 docs/_layouts/default.html delete mode 100644 docs/css/bootstrap.min.css delete mode 100644 docs/css/fig.css delete mode 100644 docs/fig.yml delete mode 100644 docs/img/favicon.ico delete mode 100644 docs/img/logo.png delete mode 100755 script/build-docs delete mode 100755 script/open-docs diff --git a/docs/.gitignore-gh-pages b/docs/.gitignore-gh-pages deleted file mode 100644 index 0baf015229c..00000000000 --- a/docs/.gitignore-gh-pages +++ /dev/null @@ -1 +0,0 @@ -/_site diff --git a/docs/CNAME b/docs/CNAME deleted file mode 100644 index 537d9d46ff5..00000000000 --- a/docs/CNAME +++ /dev/null @@ -1 +0,0 @@ -www.fig.sh diff --git a/docs/Dockerfile b/docs/Dockerfile deleted file mode 100644 index 2639848ab16..00000000000 --- a/docs/Dockerfile +++ /dev/null @@ -1,10 +0,0 @@ -FROM ubuntu:13.10 -RUN apt-get -qq update && apt-get install -y ruby1.8 bundler python -RUN locale-gen en_US.UTF-8 -ADD Gemfile /code/ -ADD Gemfile.lock /code/ -WORKDIR /code -RUN bundle install -ADD . /code -EXPOSE 4000 -CMD bundle exec jekyll build diff --git a/docs/Gemfile b/docs/Gemfile deleted file mode 100644 index 97355ea723a..00000000000 --- a/docs/Gemfile +++ /dev/null @@ -1,3 +0,0 @@ -source 'https://rubygems.org' - -gem 'github-pages' diff --git a/docs/Gemfile.lock b/docs/Gemfile.lock deleted file mode 100644 index 447ae05cc84..00000000000 --- a/docs/Gemfile.lock +++ /dev/null @@ -1,62 +0,0 @@ -GEM - remote: https://rubygems.org/ - specs: - RedCloth (4.2.9) - blankslate (2.1.2.4) - classifier (1.3.3) - fast-stemmer (>= 1.0.0) - colorator (0.1) - commander (4.1.5) - highline (~> 1.6.11) - fast-stemmer (1.0.2) - ffi (1.9.3) - github-pages (12) - RedCloth (= 4.2.9) - jekyll (= 1.4.2) - kramdown (= 1.2.0) - liquid (= 2.5.4) - maruku (= 0.7.0) - rdiscount (= 2.1.7) - redcarpet (= 2.3.0) - highline (1.6.20) - jekyll (1.4.2) - classifier (~> 1.3) - colorator (~> 0.1) - commander (~> 4.1.3) - liquid (~> 2.5.2) - listen (~> 1.3) - maruku (~> 0.7.0) - pygments.rb (~> 0.5.0) - redcarpet (~> 2.3.0) - safe_yaml (~> 0.9.7) - toml (~> 0.1.0) - kramdown (1.2.0) - liquid (2.5.4) - listen (1.3.1) - rb-fsevent (>= 0.9.3) - rb-inotify (>= 0.9) - rb-kqueue (>= 0.2) - maruku (0.7.0) - parslet (1.5.0) - blankslate (~> 2.0) - posix-spawn (0.3.8) - pygments.rb (0.5.4) - posix-spawn (~> 0.3.6) - yajl-ruby (~> 1.1.0) - rb-fsevent (0.9.4) - rb-inotify (0.9.3) - ffi (>= 0.5.0) - rb-kqueue (0.2.0) - ffi (>= 0.5.0) - rdiscount (2.1.7) - redcarpet (2.3.0) - safe_yaml (0.9.7) - toml (0.1.0) - parslet (~> 1.5.0) - yajl-ruby (1.1.0) - -PLATFORMS - ruby - -DEPENDENCIES - github-pages diff --git a/docs/_config.yml b/docs/_config.yml deleted file mode 100644 index b7abc223479..00000000000 --- a/docs/_config.yml +++ /dev/null @@ -1,3 +0,0 @@ -markdown: redcarpet -encoding: utf-8 - diff --git a/docs/_layouts/default.html b/docs/_layouts/default.html deleted file mode 100644 index 1f0de50826c..00000000000 --- a/docs/_layouts/default.html +++ /dev/null @@ -1,73 +0,0 @@ - - - - - {{ page.title }} - - - - - - - - - -
- - -
{{ content }}
- - -
- - - - diff --git a/docs/css/bootstrap.min.css b/docs/css/bootstrap.min.css deleted file mode 100644 index c547283bbda..00000000000 --- a/docs/css/bootstrap.min.css +++ /dev/null @@ -1,7 +0,0 @@ -/*! - * Bootstrap v3.0.3 (http://getbootstrap.com) - * Copyright 2013 Twitter, Inc. - * Licensed under http://www.apache.org/licenses/LICENSE-2.0 - */ - -/*! normalize.css v2.1.3 | MIT License | git.io/normalize */article,aside,details,figcaption,figure,footer,header,hgroup,main,nav,section,summary{display:block}audio,canvas,video{display:inline-block}audio:not([controls]){display:none;height:0}[hidden],template{display:none}html{font-family:sans-serif;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%}body{margin:0}a{background:transparent}a:focus{outline:thin dotted}a:active,a:hover{outline:0}h1{margin:.67em 0;font-size:2em}abbr[title]{border-bottom:1px dotted}b,strong{font-weight:bold}dfn{font-style:italic}hr{height:0;-moz-box-sizing:content-box;box-sizing:content-box}mark{color:#000;background:#ff0}code,kbd,pre,samp{font-family:monospace,serif;font-size:1em}pre{white-space:pre-wrap}q{quotes:"\201C" "\201D" "\2018" "\2019"}small{font-size:80%}sub,sup{position:relative;font-size:75%;line-height:0;vertical-align:baseline}sup{top:-0.5em}sub{bottom:-0.25em}img{border:0}svg:not(:root){overflow:hidden}figure{margin:0}fieldset{padding:.35em .625em .75em;margin:0 2px;border:1px solid #c0c0c0}legend{padding:0;border:0}button,input,select,textarea{margin:0;font-family:inherit;font-size:100%}button,input{line-height:normal}button,select{text-transform:none}button,html input[type="button"],input[type="reset"],input[type="submit"]{cursor:pointer;-webkit-appearance:button}button[disabled],html input[disabled]{cursor:default}input[type="checkbox"],input[type="radio"]{padding:0;box-sizing:border-box}input[type="search"]{-webkit-box-sizing:content-box;-moz-box-sizing:content-box;box-sizing:content-box;-webkit-appearance:textfield}input[type="search"]::-webkit-search-cancel-button,input[type="search"]::-webkit-search-decoration{-webkit-appearance:none}button::-moz-focus-inner,input::-moz-focus-inner{padding:0;border:0}textarea{overflow:auto;vertical-align:top}table{border-collapse:collapse;border-spacing:0}@media print{*{color:#000!important;text-shadow:none!important;background:transparent!important;box-shadow:none!important}a,a:visited{text-decoration:underline}a[href]:after{content:" (" attr(href) ")"}abbr[title]:after{content:" (" attr(title) ")"}a[href^="javascript:"]:after,a[href^="#"]:after{content:""}pre,blockquote{border:1px solid #999;page-break-inside:avoid}thead{display:table-header-group}tr,img{page-break-inside:avoid}img{max-width:100%!important}@page{margin:2cm .5cm}p,h2,h3{orphans:3;widows:3}h2,h3{page-break-after:avoid}select{background:#fff!important}.navbar{display:none}.table td,.table th{background-color:#fff!important}.btn>.caret,.dropup>.btn>.caret{border-top-color:#000!important}.label{border:1px solid #000}.table{border-collapse:collapse!important}.table-bordered th,.table-bordered td{border:1px solid #ddd!important}}*,*:before,*:after{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}html{font-size:62.5%;-webkit-tap-highlight-color:rgba(0,0,0,0)}body{font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-size:14px;line-height:1.428571429;color:#333;background-color:#fff}input,button,select,textarea{font-family:inherit;font-size:inherit;line-height:inherit}a{color:#428bca;text-decoration:none}a:hover,a:focus{color:#2a6496;text-decoration:underline}a:focus{outline:thin dotted;outline:5px auto -webkit-focus-ring-color;outline-offset:-2px}img{vertical-align:middle}.img-responsive{display:block;height:auto;max-width:100%}.img-rounded{border-radius:6px}.img-thumbnail{display:inline-block;height:auto;max-width:100%;padding:4px;line-height:1.428571429;background-color:#fff;border:1px solid #ddd;border-radius:4px;-webkit-transition:all .2s ease-in-out;transition:all .2s ease-in-out}.img-circle{border-radius:50%}hr{margin-top:20px;margin-bottom:20px;border:0;border-top:1px solid #eee}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);border:0}h1,h2,h3,h4,h5,h6,.h1,.h2,.h3,.h4,.h5,.h6{font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-weight:500;line-height:1.1;color:inherit}h1 small,h2 small,h3 small,h4 small,h5 small,h6 small,.h1 small,.h2 small,.h3 small,.h4 small,.h5 small,.h6 small,h1 .small,h2 .small,h3 .small,h4 .small,h5 .small,h6 .small,.h1 .small,.h2 .small,.h3 .small,.h4 .small,.h5 .small,.h6 .small{font-weight:normal;line-height:1;color:#999}h1,h2,h3{margin-top:20px;margin-bottom:10px}h1 small,h2 small,h3 small,h1 .small,h2 .small,h3 .small{font-size:65%}h4,h5,h6{margin-top:10px;margin-bottom:10px}h4 small,h5 small,h6 small,h4 .small,h5 .small,h6 .small{font-size:75%}h1,.h1{font-size:36px}h2,.h2{font-size:30px}h3,.h3{font-size:24px}h4,.h4{font-size:18px}h5,.h5{font-size:14px}h6,.h6{font-size:12px}p{margin:0 0 10px}.lead{margin-bottom:20px;font-size:16px;font-weight:200;line-height:1.4}@media(min-width:768px){.lead{font-size:21px}}small,.small{font-size:85%}cite{font-style:normal}.text-muted{color:#999}.text-primary{color:#428bca}.text-primary:hover{color:#3071a9}.text-warning{color:#8a6d3b}.text-warning:hover{color:#66512c}.text-danger{color:#a94442}.text-danger:hover{color:#843534}.text-success{color:#3c763d}.text-success:hover{color:#2b542c}.text-info{color:#31708f}.text-info:hover{color:#245269}.text-left{text-align:left}.text-right{text-align:right}.text-center{text-align:center}.page-header{padding-bottom:9px;margin:40px 0 20px;border-bottom:1px solid #eee}ul,ol{margin-top:0;margin-bottom:10px}ul ul,ol ul,ul ol,ol ol{margin-bottom:0}.list-unstyled{padding-left:0;list-style:none}.list-inline{padding-left:0;list-style:none}.list-inline>li{display:inline-block;padding-right:5px;padding-left:5px}.list-inline>li:first-child{padding-left:0}dl{margin-top:0;margin-bottom:20px}dt,dd{line-height:1.428571429}dt{font-weight:bold}dd{margin-left:0}@media(min-width:768px){.dl-horizontal dt{float:left;width:160px;overflow:hidden;clear:left;text-align:right;text-overflow:ellipsis;white-space:nowrap}.dl-horizontal dd{margin-left:180px}.dl-horizontal dd:before,.dl-horizontal dd:after{display:table;content:" "}.dl-horizontal dd:after{clear:both}.dl-horizontal dd:before,.dl-horizontal dd:after{display:table;content:" "}.dl-horizontal dd:after{clear:both}}abbr[title],abbr[data-original-title]{cursor:help;border-bottom:1px dotted #999}.initialism{font-size:90%;text-transform:uppercase}blockquote{padding:10px 20px;margin:0 0 20px;border-left:5px solid #eee}blockquote p{font-size:17.5px;font-weight:300;line-height:1.25}blockquote p:last-child{margin-bottom:0}blockquote small,blockquote .small{display:block;line-height:1.428571429;color:#999}blockquote small:before,blockquote .small:before{content:'\2014 \00A0'}blockquote.pull-right{padding-right:15px;padding-left:0;border-right:5px solid #eee;border-left:0}blockquote.pull-right p,blockquote.pull-right small,blockquote.pull-right .small{text-align:right}blockquote.pull-right small:before,blockquote.pull-right .small:before{content:''}blockquote.pull-right small:after,blockquote.pull-right .small:after{content:'\00A0 \2014'}blockquote:before,blockquote:after{content:""}address{margin-bottom:20px;font-style:normal;line-height:1.428571429}code,kbd,pre,samp{font-family:Menlo,Monaco,Consolas,"Courier New",monospace}code{padding:2px 4px;font-size:90%;color:#c7254e;white-space:nowrap;background-color:#f9f2f4;border-radius:4px}pre{display:block;padding:9.5px;margin:0 0 10px;font-size:13px;line-height:1.428571429;color:#333;word-break:break-all;word-wrap:break-word;background-color:#f5f5f5;border:1px solid #ccc;border-radius:4px}pre code{padding:0;font-size:inherit;color:inherit;white-space:pre-wrap;background-color:transparent;border-radius:0}.pre-scrollable{max-height:340px;overflow-y:scroll}.container{padding-right:15px;padding-left:15px;margin-right:auto;margin-left:auto}.container:before,.container:after{display:table;content:" "}.container:after{clear:both}.container:before,.container:after{display:table;content:" "}.container:after{clear:both}@media(min-width:768px){.container{width:750px}}@media(min-width:992px){.container{width:970px}}@media(min-width:1200px){.container{width:1170px}}.row{margin-right:-15px;margin-left:-15px}.row:before,.row:after{display:table;content:" "}.row:after{clear:both}.row:before,.row:after{display:table;content:" "}.row:after{clear:both}.col-xs-1,.col-sm-1,.col-md-1,.col-lg-1,.col-xs-2,.col-sm-2,.col-md-2,.col-lg-2,.col-xs-3,.col-sm-3,.col-md-3,.col-lg-3,.col-xs-4,.col-sm-4,.col-md-4,.col-lg-4,.col-xs-5,.col-sm-5,.col-md-5,.col-lg-5,.col-xs-6,.col-sm-6,.col-md-6,.col-lg-6,.col-xs-7,.col-sm-7,.col-md-7,.col-lg-7,.col-xs-8,.col-sm-8,.col-md-8,.col-lg-8,.col-xs-9,.col-sm-9,.col-md-9,.col-lg-9,.col-xs-10,.col-sm-10,.col-md-10,.col-lg-10,.col-xs-11,.col-sm-11,.col-md-11,.col-lg-11,.col-xs-12,.col-sm-12,.col-md-12,.col-lg-12{position:relative;min-height:1px;padding-right:15px;padding-left:15px}.col-xs-1,.col-xs-2,.col-xs-3,.col-xs-4,.col-xs-5,.col-xs-6,.col-xs-7,.col-xs-8,.col-xs-9,.col-xs-10,.col-xs-11,.col-xs-12{float:left}.col-xs-12{width:100%}.col-xs-11{width:91.66666666666666%}.col-xs-10{width:83.33333333333334%}.col-xs-9{width:75%}.col-xs-8{width:66.66666666666666%}.col-xs-7{width:58.333333333333336%}.col-xs-6{width:50%}.col-xs-5{width:41.66666666666667%}.col-xs-4{width:33.33333333333333%}.col-xs-3{width:25%}.col-xs-2{width:16.666666666666664%}.col-xs-1{width:8.333333333333332%}.col-xs-pull-12{right:100%}.col-xs-pull-11{right:91.66666666666666%}.col-xs-pull-10{right:83.33333333333334%}.col-xs-pull-9{right:75%}.col-xs-pull-8{right:66.66666666666666%}.col-xs-pull-7{right:58.333333333333336%}.col-xs-pull-6{right:50%}.col-xs-pull-5{right:41.66666666666667%}.col-xs-pull-4{right:33.33333333333333%}.col-xs-pull-3{right:25%}.col-xs-pull-2{right:16.666666666666664%}.col-xs-pull-1{right:8.333333333333332%}.col-xs-pull-0{right:0}.col-xs-push-12{left:100%}.col-xs-push-11{left:91.66666666666666%}.col-xs-push-10{left:83.33333333333334%}.col-xs-push-9{left:75%}.col-xs-push-8{left:66.66666666666666%}.col-xs-push-7{left:58.333333333333336%}.col-xs-push-6{left:50%}.col-xs-push-5{left:41.66666666666667%}.col-xs-push-4{left:33.33333333333333%}.col-xs-push-3{left:25%}.col-xs-push-2{left:16.666666666666664%}.col-xs-push-1{left:8.333333333333332%}.col-xs-push-0{left:0}.col-xs-offset-12{margin-left:100%}.col-xs-offset-11{margin-left:91.66666666666666%}.col-xs-offset-10{margin-left:83.33333333333334%}.col-xs-offset-9{margin-left:75%}.col-xs-offset-8{margin-left:66.66666666666666%}.col-xs-offset-7{margin-left:58.333333333333336%}.col-xs-offset-6{margin-left:50%}.col-xs-offset-5{margin-left:41.66666666666667%}.col-xs-offset-4{margin-left:33.33333333333333%}.col-xs-offset-3{margin-left:25%}.col-xs-offset-2{margin-left:16.666666666666664%}.col-xs-offset-1{margin-left:8.333333333333332%}.col-xs-offset-0{margin-left:0}@media(min-width:768px){.col-sm-1,.col-sm-2,.col-sm-3,.col-sm-4,.col-sm-5,.col-sm-6,.col-sm-7,.col-sm-8,.col-sm-9,.col-sm-10,.col-sm-11,.col-sm-12{float:left}.col-sm-12{width:100%}.col-sm-11{width:91.66666666666666%}.col-sm-10{width:83.33333333333334%}.col-sm-9{width:75%}.col-sm-8{width:66.66666666666666%}.col-sm-7{width:58.333333333333336%}.col-sm-6{width:50%}.col-sm-5{width:41.66666666666667%}.col-sm-4{width:33.33333333333333%}.col-sm-3{width:25%}.col-sm-2{width:16.666666666666664%}.col-sm-1{width:8.333333333333332%}.col-sm-pull-12{right:100%}.col-sm-pull-11{right:91.66666666666666%}.col-sm-pull-10{right:83.33333333333334%}.col-sm-pull-9{right:75%}.col-sm-pull-8{right:66.66666666666666%}.col-sm-pull-7{right:58.333333333333336%}.col-sm-pull-6{right:50%}.col-sm-pull-5{right:41.66666666666667%}.col-sm-pull-4{right:33.33333333333333%}.col-sm-pull-3{right:25%}.col-sm-pull-2{right:16.666666666666664%}.col-sm-pull-1{right:8.333333333333332%}.col-sm-pull-0{right:0}.col-sm-push-12{left:100%}.col-sm-push-11{left:91.66666666666666%}.col-sm-push-10{left:83.33333333333334%}.col-sm-push-9{left:75%}.col-sm-push-8{left:66.66666666666666%}.col-sm-push-7{left:58.333333333333336%}.col-sm-push-6{left:50%}.col-sm-push-5{left:41.66666666666667%}.col-sm-push-4{left:33.33333333333333%}.col-sm-push-3{left:25%}.col-sm-push-2{left:16.666666666666664%}.col-sm-push-1{left:8.333333333333332%}.col-sm-push-0{left:0}.col-sm-offset-12{margin-left:100%}.col-sm-offset-11{margin-left:91.66666666666666%}.col-sm-offset-10{margin-left:83.33333333333334%}.col-sm-offset-9{margin-left:75%}.col-sm-offset-8{margin-left:66.66666666666666%}.col-sm-offset-7{margin-left:58.333333333333336%}.col-sm-offset-6{margin-left:50%}.col-sm-offset-5{margin-left:41.66666666666667%}.col-sm-offset-4{margin-left:33.33333333333333%}.col-sm-offset-3{margin-left:25%}.col-sm-offset-2{margin-left:16.666666666666664%}.col-sm-offset-1{margin-left:8.333333333333332%}.col-sm-offset-0{margin-left:0}}@media(min-width:992px){.col-md-1,.col-md-2,.col-md-3,.col-md-4,.col-md-5,.col-md-6,.col-md-7,.col-md-8,.col-md-9,.col-md-10,.col-md-11,.col-md-12{float:left}.col-md-12{width:100%}.col-md-11{width:91.66666666666666%}.col-md-10{width:83.33333333333334%}.col-md-9{width:75%}.col-md-8{width:66.66666666666666%}.col-md-7{width:58.333333333333336%}.col-md-6{width:50%}.col-md-5{width:41.66666666666667%}.col-md-4{width:33.33333333333333%}.col-md-3{width:25%}.col-md-2{width:16.666666666666664%}.col-md-1{width:8.333333333333332%}.col-md-pull-12{right:100%}.col-md-pull-11{right:91.66666666666666%}.col-md-pull-10{right:83.33333333333334%}.col-md-pull-9{right:75%}.col-md-pull-8{right:66.66666666666666%}.col-md-pull-7{right:58.333333333333336%}.col-md-pull-6{right:50%}.col-md-pull-5{right:41.66666666666667%}.col-md-pull-4{right:33.33333333333333%}.col-md-pull-3{right:25%}.col-md-pull-2{right:16.666666666666664%}.col-md-pull-1{right:8.333333333333332%}.col-md-pull-0{right:0}.col-md-push-12{left:100%}.col-md-push-11{left:91.66666666666666%}.col-md-push-10{left:83.33333333333334%}.col-md-push-9{left:75%}.col-md-push-8{left:66.66666666666666%}.col-md-push-7{left:58.333333333333336%}.col-md-push-6{left:50%}.col-md-push-5{left:41.66666666666667%}.col-md-push-4{left:33.33333333333333%}.col-md-push-3{left:25%}.col-md-push-2{left:16.666666666666664%}.col-md-push-1{left:8.333333333333332%}.col-md-push-0{left:0}.col-md-offset-12{margin-left:100%}.col-md-offset-11{margin-left:91.66666666666666%}.col-md-offset-10{margin-left:83.33333333333334%}.col-md-offset-9{margin-left:75%}.col-md-offset-8{margin-left:66.66666666666666%}.col-md-offset-7{margin-left:58.333333333333336%}.col-md-offset-6{margin-left:50%}.col-md-offset-5{margin-left:41.66666666666667%}.col-md-offset-4{margin-left:33.33333333333333%}.col-md-offset-3{margin-left:25%}.col-md-offset-2{margin-left:16.666666666666664%}.col-md-offset-1{margin-left:8.333333333333332%}.col-md-offset-0{margin-left:0}}@media(min-width:1200px){.col-lg-1,.col-lg-2,.col-lg-3,.col-lg-4,.col-lg-5,.col-lg-6,.col-lg-7,.col-lg-8,.col-lg-9,.col-lg-10,.col-lg-11,.col-lg-12{float:left}.col-lg-12{width:100%}.col-lg-11{width:91.66666666666666%}.col-lg-10{width:83.33333333333334%}.col-lg-9{width:75%}.col-lg-8{width:66.66666666666666%}.col-lg-7{width:58.333333333333336%}.col-lg-6{width:50%}.col-lg-5{width:41.66666666666667%}.col-lg-4{width:33.33333333333333%}.col-lg-3{width:25%}.col-lg-2{width:16.666666666666664%}.col-lg-1{width:8.333333333333332%}.col-lg-pull-12{right:100%}.col-lg-pull-11{right:91.66666666666666%}.col-lg-pull-10{right:83.33333333333334%}.col-lg-pull-9{right:75%}.col-lg-pull-8{right:66.66666666666666%}.col-lg-pull-7{right:58.333333333333336%}.col-lg-pull-6{right:50%}.col-lg-pull-5{right:41.66666666666667%}.col-lg-pull-4{right:33.33333333333333%}.col-lg-pull-3{right:25%}.col-lg-pull-2{right:16.666666666666664%}.col-lg-pull-1{right:8.333333333333332%}.col-lg-pull-0{right:0}.col-lg-push-12{left:100%}.col-lg-push-11{left:91.66666666666666%}.col-lg-push-10{left:83.33333333333334%}.col-lg-push-9{left:75%}.col-lg-push-8{left:66.66666666666666%}.col-lg-push-7{left:58.333333333333336%}.col-lg-push-6{left:50%}.col-lg-push-5{left:41.66666666666667%}.col-lg-push-4{left:33.33333333333333%}.col-lg-push-3{left:25%}.col-lg-push-2{left:16.666666666666664%}.col-lg-push-1{left:8.333333333333332%}.col-lg-push-0{left:0}.col-lg-offset-12{margin-left:100%}.col-lg-offset-11{margin-left:91.66666666666666%}.col-lg-offset-10{margin-left:83.33333333333334%}.col-lg-offset-9{margin-left:75%}.col-lg-offset-8{margin-left:66.66666666666666%}.col-lg-offset-7{margin-left:58.333333333333336%}.col-lg-offset-6{margin-left:50%}.col-lg-offset-5{margin-left:41.66666666666667%}.col-lg-offset-4{margin-left:33.33333333333333%}.col-lg-offset-3{margin-left:25%}.col-lg-offset-2{margin-left:16.666666666666664%}.col-lg-offset-1{margin-left:8.333333333333332%}.col-lg-offset-0{margin-left:0}}table{max-width:100%;background-color:transparent}th{text-align:left}.table{width:100%;margin-bottom:20px}.table>thead>tr>th,.table>tbody>tr>th,.table>tfoot>tr>th,.table>thead>tr>td,.table>tbody>tr>td,.table>tfoot>tr>td{padding:8px;line-height:1.428571429;vertical-align:top;border-top:1px solid #ddd}.table>thead>tr>th{vertical-align:bottom;border-bottom:2px solid #ddd}.table>caption+thead>tr:first-child>th,.table>colgroup+thead>tr:first-child>th,.table>thead:first-child>tr:first-child>th,.table>caption+thead>tr:first-child>td,.table>colgroup+thead>tr:first-child>td,.table>thead:first-child>tr:first-child>td{border-top:0}.table>tbody+tbody{border-top:2px solid #ddd}.table .table{background-color:#fff}.table-condensed>thead>tr>th,.table-condensed>tbody>tr>th,.table-condensed>tfoot>tr>th,.table-condensed>thead>tr>td,.table-condensed>tbody>tr>td,.table-condensed>tfoot>tr>td{padding:5px}.table-bordered{border:1px solid #ddd}.table-bordered>thead>tr>th,.table-bordered>tbody>tr>th,.table-bordered>tfoot>tr>th,.table-bordered>thead>tr>td,.table-bordered>tbody>tr>td,.table-bordered>tfoot>tr>td{border:1px solid #ddd}.table-bordered>thead>tr>th,.table-bordered>thead>tr>td{border-bottom-width:2px}.table-striped>tbody>tr:nth-child(odd)>td,.table-striped>tbody>tr:nth-child(odd)>th{background-color:#f9f9f9}.table-hover>tbody>tr:hover>td,.table-hover>tbody>tr:hover>th{background-color:#f5f5f5}table col[class*="col-"]{position:static;display:table-column;float:none}table td[class*="col-"],table th[class*="col-"]{display:table-cell;float:none}.table>thead>tr>.active,.table>tbody>tr>.active,.table>tfoot>tr>.active,.table>thead>.active>td,.table>tbody>.active>td,.table>tfoot>.active>td,.table>thead>.active>th,.table>tbody>.active>th,.table>tfoot>.active>th{background-color:#f5f5f5}.table-hover>tbody>tr>.active:hover,.table-hover>tbody>.active:hover>td,.table-hover>tbody>.active:hover>th{background-color:#e8e8e8}.table>thead>tr>.success,.table>tbody>tr>.success,.table>tfoot>tr>.success,.table>thead>.success>td,.table>tbody>.success>td,.table>tfoot>.success>td,.table>thead>.success>th,.table>tbody>.success>th,.table>tfoot>.success>th{background-color:#dff0d8}.table-hover>tbody>tr>.success:hover,.table-hover>tbody>.success:hover>td,.table-hover>tbody>.success:hover>th{background-color:#d0e9c6}.table>thead>tr>.danger,.table>tbody>tr>.danger,.table>tfoot>tr>.danger,.table>thead>.danger>td,.table>tbody>.danger>td,.table>tfoot>.danger>td,.table>thead>.danger>th,.table>tbody>.danger>th,.table>tfoot>.danger>th{background-color:#f2dede}.table-hover>tbody>tr>.danger:hover,.table-hover>tbody>.danger:hover>td,.table-hover>tbody>.danger:hover>th{background-color:#ebcccc}.table>thead>tr>.warning,.table>tbody>tr>.warning,.table>tfoot>tr>.warning,.table>thead>.warning>td,.table>tbody>.warning>td,.table>tfoot>.warning>td,.table>thead>.warning>th,.table>tbody>.warning>th,.table>tfoot>.warning>th{background-color:#fcf8e3}.table-hover>tbody>tr>.warning:hover,.table-hover>tbody>.warning:hover>td,.table-hover>tbody>.warning:hover>th{background-color:#faf2cc}@media(max-width:767px){.table-responsive{width:100%;margin-bottom:15px;overflow-x:scroll;overflow-y:hidden;border:1px solid #ddd;-ms-overflow-style:-ms-autohiding-scrollbar;-webkit-overflow-scrolling:touch}.table-responsive>.table{margin-bottom:0}.table-responsive>.table>thead>tr>th,.table-responsive>.table>tbody>tr>th,.table-responsive>.table>tfoot>tr>th,.table-responsive>.table>thead>tr>td,.table-responsive>.table>tbody>tr>td,.table-responsive>.table>tfoot>tr>td{white-space:nowrap}.table-responsive>.table-bordered{border:0}.table-responsive>.table-bordered>thead>tr>th:first-child,.table-responsive>.table-bordered>tbody>tr>th:first-child,.table-responsive>.table-bordered>tfoot>tr>th:first-child,.table-responsive>.table-bordered>thead>tr>td:first-child,.table-responsive>.table-bordered>tbody>tr>td:first-child,.table-responsive>.table-bordered>tfoot>tr>td:first-child{border-left:0}.table-responsive>.table-bordered>thead>tr>th:last-child,.table-responsive>.table-bordered>tbody>tr>th:last-child,.table-responsive>.table-bordered>tfoot>tr>th:last-child,.table-responsive>.table-bordered>thead>tr>td:last-child,.table-responsive>.table-bordered>tbody>tr>td:last-child,.table-responsive>.table-bordered>tfoot>tr>td:last-child{border-right:0}.table-responsive>.table-bordered>tbody>tr:last-child>th,.table-responsive>.table-bordered>tfoot>tr:last-child>th,.table-responsive>.table-bordered>tbody>tr:last-child>td,.table-responsive>.table-bordered>tfoot>tr:last-child>td{border-bottom:0}}fieldset{padding:0;margin:0;border:0}legend{display:block;width:100%;padding:0;margin-bottom:20px;font-size:21px;line-height:inherit;color:#333;border:0;border-bottom:1px solid #e5e5e5}label{display:inline-block;margin-bottom:5px;font-weight:bold}input[type="search"]{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}input[type="radio"],input[type="checkbox"]{margin:4px 0 0;margin-top:1px \9;line-height:normal}input[type="file"]{display:block}select[multiple],select[size]{height:auto}select optgroup{font-family:inherit;font-size:inherit;font-style:inherit}input[type="file"]:focus,input[type="radio"]:focus,input[type="checkbox"]:focus{outline:thin dotted;outline:5px auto -webkit-focus-ring-color;outline-offset:-2px}input[type="number"]::-webkit-outer-spin-button,input[type="number"]::-webkit-inner-spin-button{height:auto}output{display:block;padding-top:7px;font-size:14px;line-height:1.428571429;color:#555;vertical-align:middle}.form-control{display:block;width:100%;height:34px;padding:6px 12px;font-size:14px;line-height:1.428571429;color:#555;vertical-align:middle;background-color:#fff;background-image:none;border:1px solid #ccc;border-radius:4px;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,0.075);box-shadow:inset 0 1px 1px rgba(0,0,0,0.075);-webkit-transition:border-color ease-in-out .15s,box-shadow ease-in-out .15s;transition:border-color ease-in-out .15s,box-shadow ease-in-out .15s}.form-control:focus{border-color:#66afe9;outline:0;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,0.075),0 0 8px rgba(102,175,233,0.6);box-shadow:inset 0 1px 1px rgba(0,0,0,0.075),0 0 8px rgba(102,175,233,0.6)}.form-control:-moz-placeholder{color:#999}.form-control::-moz-placeholder{color:#999;opacity:1}.form-control:-ms-input-placeholder{color:#999}.form-control::-webkit-input-placeholder{color:#999}.form-control[disabled],.form-control[readonly],fieldset[disabled] .form-control{cursor:not-allowed;background-color:#eee}textarea.form-control{height:auto}.form-group{margin-bottom:15px}.radio,.checkbox{display:block;min-height:20px;padding-left:20px;margin-top:10px;margin-bottom:10px;vertical-align:middle}.radio label,.checkbox label{display:inline;margin-bottom:0;font-weight:normal;cursor:pointer}.radio input[type="radio"],.radio-inline input[type="radio"],.checkbox input[type="checkbox"],.checkbox-inline input[type="checkbox"]{float:left;margin-left:-20px}.radio+.radio,.checkbox+.checkbox{margin-top:-5px}.radio-inline,.checkbox-inline{display:inline-block;padding-left:20px;margin-bottom:0;font-weight:normal;vertical-align:middle;cursor:pointer}.radio-inline+.radio-inline,.checkbox-inline+.checkbox-inline{margin-top:0;margin-left:10px}input[type="radio"][disabled],input[type="checkbox"][disabled],.radio[disabled],.radio-inline[disabled],.checkbox[disabled],.checkbox-inline[disabled],fieldset[disabled] input[type="radio"],fieldset[disabled] input[type="checkbox"],fieldset[disabled] .radio,fieldset[disabled] .radio-inline,fieldset[disabled] .checkbox,fieldset[disabled] .checkbox-inline{cursor:not-allowed}.input-sm{height:30px;padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}select.input-sm{height:30px;line-height:30px}textarea.input-sm{height:auto}.input-lg{height:46px;padding:10px 16px;font-size:18px;line-height:1.33;border-radius:6px}select.input-lg{height:46px;line-height:46px}textarea.input-lg{height:auto}.has-warning .help-block,.has-warning .control-label,.has-warning .radio,.has-warning .checkbox,.has-warning .radio-inline,.has-warning .checkbox-inline{color:#8a6d3b}.has-warning .form-control{border-color:#8a6d3b;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,0.075);box-shadow:inset 0 1px 1px rgba(0,0,0,0.075)}.has-warning .form-control:focus{border-color:#66512c;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,0.075),0 0 6px #c0a16b;box-shadow:inset 0 1px 1px rgba(0,0,0,0.075),0 0 6px #c0a16b}.has-warning .input-group-addon{color:#8a6d3b;background-color:#fcf8e3;border-color:#8a6d3b}.has-error .help-block,.has-error .control-label,.has-error .radio,.has-error .checkbox,.has-error .radio-inline,.has-error .checkbox-inline{color:#a94442}.has-error .form-control{border-color:#a94442;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,0.075);box-shadow:inset 0 1px 1px rgba(0,0,0,0.075)}.has-error .form-control:focus{border-color:#843534;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,0.075),0 0 6px #ce8483;box-shadow:inset 0 1px 1px rgba(0,0,0,0.075),0 0 6px #ce8483}.has-error .input-group-addon{color:#a94442;background-color:#f2dede;border-color:#a94442}.has-success .help-block,.has-success .control-label,.has-success .radio,.has-success .checkbox,.has-success .radio-inline,.has-success .checkbox-inline{color:#3c763d}.has-success .form-control{border-color:#3c763d;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,0.075);box-shadow:inset 0 1px 1px rgba(0,0,0,0.075)}.has-success .form-control:focus{border-color:#2b542c;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,0.075),0 0 6px #67b168;box-shadow:inset 0 1px 1px rgba(0,0,0,0.075),0 0 6px #67b168}.has-success .input-group-addon{color:#3c763d;background-color:#dff0d8;border-color:#3c763d}.form-control-static{margin-bottom:0}.help-block{display:block;margin-top:5px;margin-bottom:10px;color:#737373}@media(min-width:768px){.form-inline .form-group{display:inline-block;margin-bottom:0;vertical-align:middle}.form-inline .form-control{display:inline-block}.form-inline select.form-control{width:auto}.form-inline .radio,.form-inline .checkbox{display:inline-block;padding-left:0;margin-top:0;margin-bottom:0}.form-inline .radio input[type="radio"],.form-inline .checkbox input[type="checkbox"]{float:none;margin-left:0}}.form-horizontal .control-label,.form-horizontal .radio,.form-horizontal .checkbox,.form-horizontal .radio-inline,.form-horizontal .checkbox-inline{padding-top:7px;margin-top:0;margin-bottom:0}.form-horizontal .radio,.form-horizontal .checkbox{min-height:27px}.form-horizontal .form-group{margin-right:-15px;margin-left:-15px}.form-horizontal .form-group:before,.form-horizontal .form-group:after{display:table;content:" "}.form-horizontal .form-group:after{clear:both}.form-horizontal .form-group:before,.form-horizontal .form-group:after{display:table;content:" "}.form-horizontal .form-group:after{clear:both}.form-horizontal .form-control-static{padding-top:7px}@media(min-width:768px){.form-horizontal .control-label{text-align:right}}.btn{display:inline-block;padding:6px 12px;margin-bottom:0;font-size:14px;font-weight:normal;line-height:1.428571429;text-align:center;white-space:nowrap;vertical-align:middle;cursor:pointer;background-image:none;border:1px solid transparent;border-radius:4px;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;-o-user-select:none;user-select:none}.btn:focus{outline:thin dotted;outline:5px auto -webkit-focus-ring-color;outline-offset:-2px}.btn:hover,.btn:focus{color:#333;text-decoration:none}.btn:active,.btn.active{background-image:none;outline:0;-webkit-box-shadow:inset 0 3px 5px rgba(0,0,0,0.125);box-shadow:inset 0 3px 5px rgba(0,0,0,0.125)}.btn.disabled,.btn[disabled],fieldset[disabled] .btn{pointer-events:none;cursor:not-allowed;opacity:.65;filter:alpha(opacity=65);-webkit-box-shadow:none;box-shadow:none}.btn-default{color:#333;background-color:#fff;border-color:#ccc}.btn-default:hover,.btn-default:focus,.btn-default:active,.btn-default.active,.open .dropdown-toggle.btn-default{color:#333;background-color:#ebebeb;border-color:#adadad}.btn-default:active,.btn-default.active,.open .dropdown-toggle.btn-default{background-image:none}.btn-default.disabled,.btn-default[disabled],fieldset[disabled] .btn-default,.btn-default.disabled:hover,.btn-default[disabled]:hover,fieldset[disabled] .btn-default:hover,.btn-default.disabled:focus,.btn-default[disabled]:focus,fieldset[disabled] .btn-default:focus,.btn-default.disabled:active,.btn-default[disabled]:active,fieldset[disabled] .btn-default:active,.btn-default.disabled.active,.btn-default[disabled].active,fieldset[disabled] .btn-default.active{background-color:#fff;border-color:#ccc}.btn-default .badge{color:#fff;background-color:#fff}.btn-primary{color:#fff;background-color:#428bca;border-color:#357ebd}.btn-primary:hover,.btn-primary:focus,.btn-primary:active,.btn-primary.active,.open .dropdown-toggle.btn-primary{color:#fff;background-color:#3276b1;border-color:#285e8e}.btn-primary:active,.btn-primary.active,.open .dropdown-toggle.btn-primary{background-image:none}.btn-primary.disabled,.btn-primary[disabled],fieldset[disabled] .btn-primary,.btn-primary.disabled:hover,.btn-primary[disabled]:hover,fieldset[disabled] .btn-primary:hover,.btn-primary.disabled:focus,.btn-primary[disabled]:focus,fieldset[disabled] .btn-primary:focus,.btn-primary.disabled:active,.btn-primary[disabled]:active,fieldset[disabled] .btn-primary:active,.btn-primary.disabled.active,.btn-primary[disabled].active,fieldset[disabled] .btn-primary.active{background-color:#428bca;border-color:#357ebd}.btn-primary .badge{color:#428bca;background-color:#fff}.btn-warning{color:#fff;background-color:#f0ad4e;border-color:#eea236}.btn-warning:hover,.btn-warning:focus,.btn-warning:active,.btn-warning.active,.open .dropdown-toggle.btn-warning{color:#fff;background-color:#ed9c28;border-color:#d58512}.btn-warning:active,.btn-warning.active,.open .dropdown-toggle.btn-warning{background-image:none}.btn-warning.disabled,.btn-warning[disabled],fieldset[disabled] .btn-warning,.btn-warning.disabled:hover,.btn-warning[disabled]:hover,fieldset[disabled] .btn-warning:hover,.btn-warning.disabled:focus,.btn-warning[disabled]:focus,fieldset[disabled] .btn-warning:focus,.btn-warning.disabled:active,.btn-warning[disabled]:active,fieldset[disabled] .btn-warning:active,.btn-warning.disabled.active,.btn-warning[disabled].active,fieldset[disabled] .btn-warning.active{background-color:#f0ad4e;border-color:#eea236}.btn-warning .badge{color:#f0ad4e;background-color:#fff}.btn-danger{color:#fff;background-color:#d9534f;border-color:#d43f3a}.btn-danger:hover,.btn-danger:focus,.btn-danger:active,.btn-danger.active,.open .dropdown-toggle.btn-danger{color:#fff;background-color:#d2322d;border-color:#ac2925}.btn-danger:active,.btn-danger.active,.open .dropdown-toggle.btn-danger{background-image:none}.btn-danger.disabled,.btn-danger[disabled],fieldset[disabled] .btn-danger,.btn-danger.disabled:hover,.btn-danger[disabled]:hover,fieldset[disabled] .btn-danger:hover,.btn-danger.disabled:focus,.btn-danger[disabled]:focus,fieldset[disabled] .btn-danger:focus,.btn-danger.disabled:active,.btn-danger[disabled]:active,fieldset[disabled] .btn-danger:active,.btn-danger.disabled.active,.btn-danger[disabled].active,fieldset[disabled] .btn-danger.active{background-color:#d9534f;border-color:#d43f3a}.btn-danger .badge{color:#d9534f;background-color:#fff}.btn-success{color:#fff;background-color:#5cb85c;border-color:#4cae4c}.btn-success:hover,.btn-success:focus,.btn-success:active,.btn-success.active,.open .dropdown-toggle.btn-success{color:#fff;background-color:#47a447;border-color:#398439}.btn-success:active,.btn-success.active,.open .dropdown-toggle.btn-success{background-image:none}.btn-success.disabled,.btn-success[disabled],fieldset[disabled] .btn-success,.btn-success.disabled:hover,.btn-success[disabled]:hover,fieldset[disabled] .btn-success:hover,.btn-success.disabled:focus,.btn-success[disabled]:focus,fieldset[disabled] .btn-success:focus,.btn-success.disabled:active,.btn-success[disabled]:active,fieldset[disabled] .btn-success:active,.btn-success.disabled.active,.btn-success[disabled].active,fieldset[disabled] .btn-success.active{background-color:#5cb85c;border-color:#4cae4c}.btn-success .badge{color:#5cb85c;background-color:#fff}.btn-info{color:#fff;background-color:#5bc0de;border-color:#46b8da}.btn-info:hover,.btn-info:focus,.btn-info:active,.btn-info.active,.open .dropdown-toggle.btn-info{color:#fff;background-color:#39b3d7;border-color:#269abc}.btn-info:active,.btn-info.active,.open .dropdown-toggle.btn-info{background-image:none}.btn-info.disabled,.btn-info[disabled],fieldset[disabled] .btn-info,.btn-info.disabled:hover,.btn-info[disabled]:hover,fieldset[disabled] .btn-info:hover,.btn-info.disabled:focus,.btn-info[disabled]:focus,fieldset[disabled] .btn-info:focus,.btn-info.disabled:active,.btn-info[disabled]:active,fieldset[disabled] .btn-info:active,.btn-info.disabled.active,.btn-info[disabled].active,fieldset[disabled] .btn-info.active{background-color:#5bc0de;border-color:#46b8da}.btn-info .badge{color:#5bc0de;background-color:#fff}.btn-link{font-weight:normal;color:#428bca;cursor:pointer;border-radius:0}.btn-link,.btn-link:active,.btn-link[disabled],fieldset[disabled] .btn-link{background-color:transparent;-webkit-box-shadow:none;box-shadow:none}.btn-link,.btn-link:hover,.btn-link:focus,.btn-link:active{border-color:transparent}.btn-link:hover,.btn-link:focus{color:#2a6496;text-decoration:underline;background-color:transparent}.btn-link[disabled]:hover,fieldset[disabled] .btn-link:hover,.btn-link[disabled]:focus,fieldset[disabled] .btn-link:focus{color:#999;text-decoration:none}.btn-lg{padding:10px 16px;font-size:18px;line-height:1.33;border-radius:6px}.btn-sm{padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}.btn-xs{padding:1px 5px;font-size:12px;line-height:1.5;border-radius:3px}.btn-block{display:block;width:100%;padding-right:0;padding-left:0}.btn-block+.btn-block{margin-top:5px}input[type="submit"].btn-block,input[type="reset"].btn-block,input[type="button"].btn-block{width:100%}.fade{opacity:0;-webkit-transition:opacity .15s linear;transition:opacity .15s linear}.fade.in{opacity:1}.collapse{display:none}.collapse.in{display:block}.collapsing{position:relative;height:0;overflow:hidden;-webkit-transition:height .35s ease;transition:height .35s ease}@font-face{font-family:'Glyphicons Halflings';src:url('../fonts/glyphicons-halflings-regular.eot');src:url('../fonts/glyphicons-halflings-regular.eot?#iefix') format('embedded-opentype'),url('../fonts/glyphicons-halflings-regular.woff') format('woff'),url('../fonts/glyphicons-halflings-regular.ttf') format('truetype'),url('../fonts/glyphicons-halflings-regular.svg#glyphicons-halflingsregular') format('svg')}.glyphicon{position:relative;top:1px;display:inline-block;font-family:'Glyphicons Halflings';-webkit-font-smoothing:antialiased;font-style:normal;font-weight:normal;line-height:1;-moz-osx-font-smoothing:grayscale}.glyphicon:empty{width:1em}.glyphicon-asterisk:before{content:"\2a"}.glyphicon-plus:before{content:"\2b"}.glyphicon-euro:before{content:"\20ac"}.glyphicon-minus:before{content:"\2212"}.glyphicon-cloud:before{content:"\2601"}.glyphicon-envelope:before{content:"\2709"}.glyphicon-pencil:before{content:"\270f"}.glyphicon-glass:before{content:"\e001"}.glyphicon-music:before{content:"\e002"}.glyphicon-search:before{content:"\e003"}.glyphicon-heart:before{content:"\e005"}.glyphicon-star:before{content:"\e006"}.glyphicon-star-empty:before{content:"\e007"}.glyphicon-user:before{content:"\e008"}.glyphicon-film:before{content:"\e009"}.glyphicon-th-large:before{content:"\e010"}.glyphicon-th:before{content:"\e011"}.glyphicon-th-list:before{content:"\e012"}.glyphicon-ok:before{content:"\e013"}.glyphicon-remove:before{content:"\e014"}.glyphicon-zoom-in:before{content:"\e015"}.glyphicon-zoom-out:before{content:"\e016"}.glyphicon-off:before{content:"\e017"}.glyphicon-signal:before{content:"\e018"}.glyphicon-cog:before{content:"\e019"}.glyphicon-trash:before{content:"\e020"}.glyphicon-home:before{content:"\e021"}.glyphicon-file:before{content:"\e022"}.glyphicon-time:before{content:"\e023"}.glyphicon-road:before{content:"\e024"}.glyphicon-download-alt:before{content:"\e025"}.glyphicon-download:before{content:"\e026"}.glyphicon-upload:before{content:"\e027"}.glyphicon-inbox:before{content:"\e028"}.glyphicon-play-circle:before{content:"\e029"}.glyphicon-repeat:before{content:"\e030"}.glyphicon-refresh:before{content:"\e031"}.glyphicon-list-alt:before{content:"\e032"}.glyphicon-lock:before{content:"\e033"}.glyphicon-flag:before{content:"\e034"}.glyphicon-headphones:before{content:"\e035"}.glyphicon-volume-off:before{content:"\e036"}.glyphicon-volume-down:before{content:"\e037"}.glyphicon-volume-up:before{content:"\e038"}.glyphicon-qrcode:before{content:"\e039"}.glyphicon-barcode:before{content:"\e040"}.glyphicon-tag:before{content:"\e041"}.glyphicon-tags:before{content:"\e042"}.glyphicon-book:before{content:"\e043"}.glyphicon-bookmark:before{content:"\e044"}.glyphicon-print:before{content:"\e045"}.glyphicon-camera:before{content:"\e046"}.glyphicon-font:before{content:"\e047"}.glyphicon-bold:before{content:"\e048"}.glyphicon-italic:before{content:"\e049"}.glyphicon-text-height:before{content:"\e050"}.glyphicon-text-width:before{content:"\e051"}.glyphicon-align-left:before{content:"\e052"}.glyphicon-align-center:before{content:"\e053"}.glyphicon-align-right:before{content:"\e054"}.glyphicon-align-justify:before{content:"\e055"}.glyphicon-list:before{content:"\e056"}.glyphicon-indent-left:before{content:"\e057"}.glyphicon-indent-right:before{content:"\e058"}.glyphicon-facetime-video:before{content:"\e059"}.glyphicon-picture:before{content:"\e060"}.glyphicon-map-marker:before{content:"\e062"}.glyphicon-adjust:before{content:"\e063"}.glyphicon-tint:before{content:"\e064"}.glyphicon-edit:before{content:"\e065"}.glyphicon-share:before{content:"\e066"}.glyphicon-check:before{content:"\e067"}.glyphicon-move:before{content:"\e068"}.glyphicon-step-backward:before{content:"\e069"}.glyphicon-fast-backward:before{content:"\e070"}.glyphicon-backward:before{content:"\e071"}.glyphicon-play:before{content:"\e072"}.glyphicon-pause:before{content:"\e073"}.glyphicon-stop:before{content:"\e074"}.glyphicon-forward:before{content:"\e075"}.glyphicon-fast-forward:before{content:"\e076"}.glyphicon-step-forward:before{content:"\e077"}.glyphicon-eject:before{content:"\e078"}.glyphicon-chevron-left:before{content:"\e079"}.glyphicon-chevron-right:before{content:"\e080"}.glyphicon-plus-sign:before{content:"\e081"}.glyphicon-minus-sign:before{content:"\e082"}.glyphicon-remove-sign:before{content:"\e083"}.glyphicon-ok-sign:before{content:"\e084"}.glyphicon-question-sign:before{content:"\e085"}.glyphicon-info-sign:before{content:"\e086"}.glyphicon-screenshot:before{content:"\e087"}.glyphicon-remove-circle:before{content:"\e088"}.glyphicon-ok-circle:before{content:"\e089"}.glyphicon-ban-circle:before{content:"\e090"}.glyphicon-arrow-left:before{content:"\e091"}.glyphicon-arrow-right:before{content:"\e092"}.glyphicon-arrow-up:before{content:"\e093"}.glyphicon-arrow-down:before{content:"\e094"}.glyphicon-share-alt:before{content:"\e095"}.glyphicon-resize-full:before{content:"\e096"}.glyphicon-resize-small:before{content:"\e097"}.glyphicon-exclamation-sign:before{content:"\e101"}.glyphicon-gift:before{content:"\e102"}.glyphicon-leaf:before{content:"\e103"}.glyphicon-fire:before{content:"\e104"}.glyphicon-eye-open:before{content:"\e105"}.glyphicon-eye-close:before{content:"\e106"}.glyphicon-warning-sign:before{content:"\e107"}.glyphicon-plane:before{content:"\e108"}.glyphicon-calendar:before{content:"\e109"}.glyphicon-random:before{content:"\e110"}.glyphicon-comment:before{content:"\e111"}.glyphicon-magnet:before{content:"\e112"}.glyphicon-chevron-up:before{content:"\e113"}.glyphicon-chevron-down:before{content:"\e114"}.glyphicon-retweet:before{content:"\e115"}.glyphicon-shopping-cart:before{content:"\e116"}.glyphicon-folder-close:before{content:"\e117"}.glyphicon-folder-open:before{content:"\e118"}.glyphicon-resize-vertical:before{content:"\e119"}.glyphicon-resize-horizontal:before{content:"\e120"}.glyphicon-hdd:before{content:"\e121"}.glyphicon-bullhorn:before{content:"\e122"}.glyphicon-bell:before{content:"\e123"}.glyphicon-certificate:before{content:"\e124"}.glyphicon-thumbs-up:before{content:"\e125"}.glyphicon-thumbs-down:before{content:"\e126"}.glyphicon-hand-right:before{content:"\e127"}.glyphicon-hand-left:before{content:"\e128"}.glyphicon-hand-up:before{content:"\e129"}.glyphicon-hand-down:before{content:"\e130"}.glyphicon-circle-arrow-right:before{content:"\e131"}.glyphicon-circle-arrow-left:before{content:"\e132"}.glyphicon-circle-arrow-up:before{content:"\e133"}.glyphicon-circle-arrow-down:before{content:"\e134"}.glyphicon-globe:before{content:"\e135"}.glyphicon-wrench:before{content:"\e136"}.glyphicon-tasks:before{content:"\e137"}.glyphicon-filter:before{content:"\e138"}.glyphicon-briefcase:before{content:"\e139"}.glyphicon-fullscreen:before{content:"\e140"}.glyphicon-dashboard:before{content:"\e141"}.glyphicon-paperclip:before{content:"\e142"}.glyphicon-heart-empty:before{content:"\e143"}.glyphicon-link:before{content:"\e144"}.glyphicon-phone:before{content:"\e145"}.glyphicon-pushpin:before{content:"\e146"}.glyphicon-usd:before{content:"\e148"}.glyphicon-gbp:before{content:"\e149"}.glyphicon-sort:before{content:"\e150"}.glyphicon-sort-by-alphabet:before{content:"\e151"}.glyphicon-sort-by-alphabet-alt:before{content:"\e152"}.glyphicon-sort-by-order:before{content:"\e153"}.glyphicon-sort-by-order-alt:before{content:"\e154"}.glyphicon-sort-by-attributes:before{content:"\e155"}.glyphicon-sort-by-attributes-alt:before{content:"\e156"}.glyphicon-unchecked:before{content:"\e157"}.glyphicon-expand:before{content:"\e158"}.glyphicon-collapse-down:before{content:"\e159"}.glyphicon-collapse-up:before{content:"\e160"}.glyphicon-log-in:before{content:"\e161"}.glyphicon-flash:before{content:"\e162"}.glyphicon-log-out:before{content:"\e163"}.glyphicon-new-window:before{content:"\e164"}.glyphicon-record:before{content:"\e165"}.glyphicon-save:before{content:"\e166"}.glyphicon-open:before{content:"\e167"}.glyphicon-saved:before{content:"\e168"}.glyphicon-import:before{content:"\e169"}.glyphicon-export:before{content:"\e170"}.glyphicon-send:before{content:"\e171"}.glyphicon-floppy-disk:before{content:"\e172"}.glyphicon-floppy-saved:before{content:"\e173"}.glyphicon-floppy-remove:before{content:"\e174"}.glyphicon-floppy-save:before{content:"\e175"}.glyphicon-floppy-open:before{content:"\e176"}.glyphicon-credit-card:before{content:"\e177"}.glyphicon-transfer:before{content:"\e178"}.glyphicon-cutlery:before{content:"\e179"}.glyphicon-header:before{content:"\e180"}.glyphicon-compressed:before{content:"\e181"}.glyphicon-earphone:before{content:"\e182"}.glyphicon-phone-alt:before{content:"\e183"}.glyphicon-tower:before{content:"\e184"}.glyphicon-stats:before{content:"\e185"}.glyphicon-sd-video:before{content:"\e186"}.glyphicon-hd-video:before{content:"\e187"}.glyphicon-subtitles:before{content:"\e188"}.glyphicon-sound-stereo:before{content:"\e189"}.glyphicon-sound-dolby:before{content:"\e190"}.glyphicon-sound-5-1:before{content:"\e191"}.glyphicon-sound-6-1:before{content:"\e192"}.glyphicon-sound-7-1:before{content:"\e193"}.glyphicon-copyright-mark:before{content:"\e194"}.glyphicon-registration-mark:before{content:"\e195"}.glyphicon-cloud-download:before{content:"\e197"}.glyphicon-cloud-upload:before{content:"\e198"}.glyphicon-tree-conifer:before{content:"\e199"}.glyphicon-tree-deciduous:before{content:"\e200"}.caret{display:inline-block;width:0;height:0;margin-left:2px;vertical-align:middle;border-top:4px solid;border-right:4px solid transparent;border-left:4px solid transparent}.dropdown{position:relative}.dropdown-toggle:focus{outline:0}.dropdown-menu{position:absolute;top:100%;left:0;z-index:1000;display:none;float:left;min-width:160px;padding:5px 0;margin:2px 0 0;font-size:14px;list-style:none;background-color:#fff;border:1px solid #ccc;border:1px solid rgba(0,0,0,0.15);border-radius:4px;-webkit-box-shadow:0 6px 12px rgba(0,0,0,0.175);box-shadow:0 6px 12px rgba(0,0,0,0.175);background-clip:padding-box}.dropdown-menu.pull-right{right:0;left:auto}.dropdown-menu .divider{height:1px;margin:9px 0;overflow:hidden;background-color:#e5e5e5}.dropdown-menu>li>a{display:block;padding:3px 20px;clear:both;font-weight:normal;line-height:1.428571429;color:#333;white-space:nowrap}.dropdown-menu>li>a:hover,.dropdown-menu>li>a:focus{color:#262626;text-decoration:none;background-color:#f5f5f5}.dropdown-menu>.active>a,.dropdown-menu>.active>a:hover,.dropdown-menu>.active>a:focus{color:#fff;text-decoration:none;background-color:#428bca;outline:0}.dropdown-menu>.disabled>a,.dropdown-menu>.disabled>a:hover,.dropdown-menu>.disabled>a:focus{color:#999}.dropdown-menu>.disabled>a:hover,.dropdown-menu>.disabled>a:focus{text-decoration:none;cursor:not-allowed;background-color:transparent;background-image:none;filter:progid:DXImageTransform.Microsoft.gradient(enabled=false)}.open>.dropdown-menu{display:block}.open>a{outline:0}.dropdown-header{display:block;padding:3px 20px;font-size:12px;line-height:1.428571429;color:#999}.dropdown-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;z-index:990}.pull-right>.dropdown-menu{right:0;left:auto}.dropup .caret,.navbar-fixed-bottom .dropdown .caret{border-top:0;border-bottom:4px solid;content:""}.dropup .dropdown-menu,.navbar-fixed-bottom .dropdown .dropdown-menu{top:auto;bottom:100%;margin-bottom:1px}@media(min-width:768px){.navbar-right .dropdown-menu{right:0;left:auto}}.btn-group,.btn-group-vertical{position:relative;display:inline-block;vertical-align:middle}.btn-group>.btn,.btn-group-vertical>.btn{position:relative;float:left}.btn-group>.btn:hover,.btn-group-vertical>.btn:hover,.btn-group>.btn:focus,.btn-group-vertical>.btn:focus,.btn-group>.btn:active,.btn-group-vertical>.btn:active,.btn-group>.btn.active,.btn-group-vertical>.btn.active{z-index:2}.btn-group>.btn:focus,.btn-group-vertical>.btn:focus{outline:0}.btn-group .btn+.btn,.btn-group .btn+.btn-group,.btn-group .btn-group+.btn,.btn-group .btn-group+.btn-group{margin-left:-1px}.btn-toolbar:before,.btn-toolbar:after{display:table;content:" "}.btn-toolbar:after{clear:both}.btn-toolbar:before,.btn-toolbar:after{display:table;content:" "}.btn-toolbar:after{clear:both}.btn-toolbar .btn-group{float:left}.btn-toolbar>.btn+.btn,.btn-toolbar>.btn-group+.btn,.btn-toolbar>.btn+.btn-group,.btn-toolbar>.btn-group+.btn-group{margin-left:5px}.btn-group>.btn:not(:first-child):not(:last-child):not(.dropdown-toggle){border-radius:0}.btn-group>.btn:first-child{margin-left:0}.btn-group>.btn:first-child:not(:last-child):not(.dropdown-toggle){border-top-right-radius:0;border-bottom-right-radius:0}.btn-group>.btn:last-child:not(:first-child),.btn-group>.dropdown-toggle:not(:first-child){border-bottom-left-radius:0;border-top-left-radius:0}.btn-group>.btn-group{float:left}.btn-group>.btn-group:not(:first-child):not(:last-child)>.btn{border-radius:0}.btn-group>.btn-group:first-child>.btn:last-child,.btn-group>.btn-group:first-child>.dropdown-toggle{border-top-right-radius:0;border-bottom-right-radius:0}.btn-group>.btn-group:last-child>.btn:first-child{border-bottom-left-radius:0;border-top-left-radius:0}.btn-group .dropdown-toggle:active,.btn-group.open .dropdown-toggle{outline:0}.btn-group-xs>.btn{padding:1px 5px;font-size:12px;line-height:1.5;border-radius:3px}.btn-group-sm>.btn{padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}.btn-group-lg>.btn{padding:10px 16px;font-size:18px;line-height:1.33;border-radius:6px}.btn-group>.btn+.dropdown-toggle{padding-right:8px;padding-left:8px}.btn-group>.btn-lg+.dropdown-toggle{padding-right:12px;padding-left:12px}.btn-group.open .dropdown-toggle{-webkit-box-shadow:inset 0 3px 5px rgba(0,0,0,0.125);box-shadow:inset 0 3px 5px rgba(0,0,0,0.125)}.btn-group.open .dropdown-toggle.btn-link{-webkit-box-shadow:none;box-shadow:none}.btn .caret{margin-left:0}.btn-lg .caret{border-width:5px 5px 0;border-bottom-width:0}.dropup .btn-lg .caret{border-width:0 5px 5px}.btn-group-vertical>.btn,.btn-group-vertical>.btn-group,.btn-group-vertical>.btn-group>.btn{display:block;float:none;width:100%;max-width:100%}.btn-group-vertical>.btn-group:before,.btn-group-vertical>.btn-group:after{display:table;content:" "}.btn-group-vertical>.btn-group:after{clear:both}.btn-group-vertical>.btn-group:before,.btn-group-vertical>.btn-group:after{display:table;content:" "}.btn-group-vertical>.btn-group:after{clear:both}.btn-group-vertical>.btn-group>.btn{float:none}.btn-group-vertical>.btn+.btn,.btn-group-vertical>.btn+.btn-group,.btn-group-vertical>.btn-group+.btn,.btn-group-vertical>.btn-group+.btn-group{margin-top:-1px;margin-left:0}.btn-group-vertical>.btn:not(:first-child):not(:last-child){border-radius:0}.btn-group-vertical>.btn:first-child:not(:last-child){border-top-right-radius:4px;border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn:last-child:not(:first-child){border-top-right-radius:0;border-bottom-left-radius:4px;border-top-left-radius:0}.btn-group-vertical>.btn-group:not(:first-child):not(:last-child)>.btn{border-radius:0}.btn-group-vertical>.btn-group:first-child>.btn:last-child,.btn-group-vertical>.btn-group:first-child>.dropdown-toggle{border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn-group:last-child>.btn:first-child{border-top-right-radius:0;border-top-left-radius:0}.btn-group-justified{display:table;width:100%;border-collapse:separate;table-layout:fixed}.btn-group-justified>.btn,.btn-group-justified>.btn-group{display:table-cell;float:none;width:1%}.btn-group-justified>.btn-group .btn{width:100%}[data-toggle="buttons"]>.btn>input[type="radio"],[data-toggle="buttons"]>.btn>input[type="checkbox"]{display:none}.input-group{position:relative;display:table;border-collapse:separate}.input-group[class*="col-"]{float:none;padding-right:0;padding-left:0}.input-group .form-control{width:100%;margin-bottom:0}.input-group-lg>.form-control,.input-group-lg>.input-group-addon,.input-group-lg>.input-group-btn>.btn{height:46px;padding:10px 16px;font-size:18px;line-height:1.33;border-radius:6px}select.input-group-lg>.form-control,select.input-group-lg>.input-group-addon,select.input-group-lg>.input-group-btn>.btn{height:46px;line-height:46px}textarea.input-group-lg>.form-control,textarea.input-group-lg>.input-group-addon,textarea.input-group-lg>.input-group-btn>.btn{height:auto}.input-group-sm>.form-control,.input-group-sm>.input-group-addon,.input-group-sm>.input-group-btn>.btn{height:30px;padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}select.input-group-sm>.form-control,select.input-group-sm>.input-group-addon,select.input-group-sm>.input-group-btn>.btn{height:30px;line-height:30px}textarea.input-group-sm>.form-control,textarea.input-group-sm>.input-group-addon,textarea.input-group-sm>.input-group-btn>.btn{height:auto}.input-group-addon,.input-group-btn,.input-group .form-control{display:table-cell}.input-group-addon:not(:first-child):not(:last-child),.input-group-btn:not(:first-child):not(:last-child),.input-group .form-control:not(:first-child):not(:last-child){border-radius:0}.input-group-addon,.input-group-btn{width:1%;white-space:nowrap;vertical-align:middle}.input-group-addon{padding:6px 12px;font-size:14px;font-weight:normal;line-height:1;color:#555;text-align:center;background-color:#eee;border:1px solid #ccc;border-radius:4px}.input-group-addon.input-sm{padding:5px 10px;font-size:12px;border-radius:3px}.input-group-addon.input-lg{padding:10px 16px;font-size:18px;border-radius:6px}.input-group-addon input[type="radio"],.input-group-addon input[type="checkbox"]{margin-top:0}.input-group .form-control:first-child,.input-group-addon:first-child,.input-group-btn:first-child>.btn,.input-group-btn:first-child>.dropdown-toggle,.input-group-btn:last-child>.btn:not(:last-child):not(.dropdown-toggle){border-top-right-radius:0;border-bottom-right-radius:0}.input-group-addon:first-child{border-right:0}.input-group .form-control:last-child,.input-group-addon:last-child,.input-group-btn:last-child>.btn,.input-group-btn:last-child>.dropdown-toggle,.input-group-btn:first-child>.btn:not(:first-child){border-bottom-left-radius:0;border-top-left-radius:0}.input-group-addon:last-child{border-left:0}.input-group-btn{position:relative;white-space:nowrap}.input-group-btn:first-child>.btn{margin-right:-1px}.input-group-btn:last-child>.btn{margin-left:-1px}.input-group-btn>.btn{position:relative}.input-group-btn>.btn+.btn{margin-left:-4px}.input-group-btn>.btn:hover,.input-group-btn>.btn:active{z-index:2}.nav{padding-left:0;margin-bottom:0;list-style:none}.nav:before,.nav:after{display:table;content:" "}.nav:after{clear:both}.nav:before,.nav:after{display:table;content:" "}.nav:after{clear:both}.nav>li{position:relative;display:block}.nav>li>a{position:relative;display:block;padding:10px 15px}.nav>li>a:hover,.nav>li>a:focus{text-decoration:none;background-color:#eee}.nav>li.disabled>a{color:#999}.nav>li.disabled>a:hover,.nav>li.disabled>a:focus{color:#999;text-decoration:none;cursor:not-allowed;background-color:transparent}.nav .open>a,.nav .open>a:hover,.nav .open>a:focus{background-color:#eee;border-color:#428bca}.nav .nav-divider{height:1px;margin:9px 0;overflow:hidden;background-color:#e5e5e5}.nav>li>a>img{max-width:none}.nav-tabs{border-bottom:1px solid #ddd}.nav-tabs>li{float:left;margin-bottom:-1px}.nav-tabs>li>a{margin-right:2px;line-height:1.428571429;border:1px solid transparent;border-radius:4px 4px 0 0}.nav-tabs>li>a:hover{border-color:#eee #eee #ddd}.nav-tabs>li.active>a,.nav-tabs>li.active>a:hover,.nav-tabs>li.active>a:focus{color:#555;cursor:default;background-color:#fff;border:1px solid #ddd;border-bottom-color:transparent}.nav-tabs.nav-justified{width:100%;border-bottom:0}.nav-tabs.nav-justified>li{float:none}.nav-tabs.nav-justified>li>a{margin-bottom:5px;text-align:center}.nav-tabs.nav-justified>.dropdown .dropdown-menu{top:auto;left:auto}@media(min-width:768px){.nav-tabs.nav-justified>li{display:table-cell;width:1%}.nav-tabs.nav-justified>li>a{margin-bottom:0}}.nav-tabs.nav-justified>li>a{margin-right:0;border-radius:4px}.nav-tabs.nav-justified>.active>a,.nav-tabs.nav-justified>.active>a:hover,.nav-tabs.nav-justified>.active>a:focus{border:1px solid #ddd}@media(min-width:768px){.nav-tabs.nav-justified>li>a{border-bottom:1px solid #ddd;border-radius:4px 4px 0 0}.nav-tabs.nav-justified>.active>a,.nav-tabs.nav-justified>.active>a:hover,.nav-tabs.nav-justified>.active>a:focus{border-bottom-color:#fff}}.nav-pills>li{float:left}.nav-pills>li>a{border-radius:4px}.nav-pills>li+li{margin-left:2px}.nav-pills>li.active>a,.nav-pills>li.active>a:hover,.nav-pills>li.active>a:focus{color:#fff;background-color:#428bca}.nav-stacked>li{float:none}.nav-stacked>li+li{margin-top:2px;margin-left:0}.nav-justified{width:100%}.nav-justified>li{float:none}.nav-justified>li>a{margin-bottom:5px;text-align:center}.nav-justified>.dropdown .dropdown-menu{top:auto;left:auto}@media(min-width:768px){.nav-justified>li{display:table-cell;width:1%}.nav-justified>li>a{margin-bottom:0}}.nav-tabs-justified{border-bottom:0}.nav-tabs-justified>li>a{margin-right:0;border-radius:4px}.nav-tabs-justified>.active>a,.nav-tabs-justified>.active>a:hover,.nav-tabs-justified>.active>a:focus{border:1px solid #ddd}@media(min-width:768px){.nav-tabs-justified>li>a{border-bottom:1px solid #ddd;border-radius:4px 4px 0 0}.nav-tabs-justified>.active>a,.nav-tabs-justified>.active>a:hover,.nav-tabs-justified>.active>a:focus{border-bottom-color:#fff}}.tab-content>.tab-pane{display:none}.tab-content>.active{display:block}.nav-tabs .dropdown-menu{margin-top:-1px;border-top-right-radius:0;border-top-left-radius:0}.navbar{position:relative;min-height:50px;margin-bottom:20px;border:1px solid transparent}.navbar:before,.navbar:after{display:table;content:" "}.navbar:after{clear:both}.navbar:before,.navbar:after{display:table;content:" "}.navbar:after{clear:both}@media(min-width:768px){.navbar{border-radius:4px}}.navbar-header:before,.navbar-header:after{display:table;content:" "}.navbar-header:after{clear:both}.navbar-header:before,.navbar-header:after{display:table;content:" "}.navbar-header:after{clear:both}@media(min-width:768px){.navbar-header{float:left}}.navbar-collapse{max-height:340px;padding-right:15px;padding-left:15px;overflow-x:visible;border-top:1px solid transparent;box-shadow:inset 0 1px 0 rgba(255,255,255,0.1);-webkit-overflow-scrolling:touch}.navbar-collapse:before,.navbar-collapse:after{display:table;content:" "}.navbar-collapse:after{clear:both}.navbar-collapse:before,.navbar-collapse:after{display:table;content:" "}.navbar-collapse:after{clear:both}.navbar-collapse.in{overflow-y:auto}@media(min-width:768px){.navbar-collapse{width:auto;border-top:0;box-shadow:none}.navbar-collapse.collapse{display:block!important;height:auto!important;padding-bottom:0;overflow:visible!important}.navbar-collapse.in{overflow-y:visible}.navbar-fixed-top .navbar-collapse,.navbar-static-top .navbar-collapse,.navbar-fixed-bottom .navbar-collapse{padding-right:0;padding-left:0}}.container>.navbar-header,.container>.navbar-collapse{margin-right:-15px;margin-left:-15px}@media(min-width:768px){.container>.navbar-header,.container>.navbar-collapse{margin-right:0;margin-left:0}}.navbar-static-top{z-index:1000;border-width:0 0 1px}@media(min-width:768px){.navbar-static-top{border-radius:0}}.navbar-fixed-top,.navbar-fixed-bottom{position:fixed;right:0;left:0;z-index:1030}@media(min-width:768px){.navbar-fixed-top,.navbar-fixed-bottom{border-radius:0}}.navbar-fixed-top{top:0;border-width:0 0 1px}.navbar-fixed-bottom{bottom:0;margin-bottom:0;border-width:1px 0 0}.navbar-brand{float:left;padding:15px 15px;font-size:18px;line-height:20px}.navbar-brand:hover,.navbar-brand:focus{text-decoration:none}@media(min-width:768px){.navbar>.container .navbar-brand{margin-left:-15px}}.navbar-toggle{position:relative;float:right;padding:9px 10px;margin-top:8px;margin-right:15px;margin-bottom:8px;background-color:transparent;background-image:none;border:1px solid transparent;border-radius:4px}.navbar-toggle .icon-bar{display:block;width:22px;height:2px;border-radius:1px}.navbar-toggle .icon-bar+.icon-bar{margin-top:4px}@media(min-width:768px){.navbar-toggle{display:none}}.navbar-nav{margin:7.5px -15px}.navbar-nav>li>a{padding-top:10px;padding-bottom:10px;line-height:20px}@media(max-width:767px){.navbar-nav .open .dropdown-menu{position:static;float:none;width:auto;margin-top:0;background-color:transparent;border:0;box-shadow:none}.navbar-nav .open .dropdown-menu>li>a,.navbar-nav .open .dropdown-menu .dropdown-header{padding:5px 15px 5px 25px}.navbar-nav .open .dropdown-menu>li>a{line-height:20px}.navbar-nav .open .dropdown-menu>li>a:hover,.navbar-nav .open .dropdown-menu>li>a:focus{background-image:none}}@media(min-width:768px){.navbar-nav{float:left;margin:0}.navbar-nav>li{float:left}.navbar-nav>li>a{padding-top:15px;padding-bottom:15px}.navbar-nav.navbar-right:last-child{margin-right:-15px}}@media(min-width:768px){.navbar-left{float:left!important}.navbar-right{float:right!important}}.navbar-form{padding:10px 15px;margin-top:8px;margin-right:-15px;margin-bottom:8px;margin-left:-15px;border-top:1px solid transparent;border-bottom:1px solid transparent;-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,0.1),0 1px 0 rgba(255,255,255,0.1);box-shadow:inset 0 1px 0 rgba(255,255,255,0.1),0 1px 0 rgba(255,255,255,0.1)}@media(min-width:768px){.navbar-form .form-group{display:inline-block;margin-bottom:0;vertical-align:middle}.navbar-form .form-control{display:inline-block}.navbar-form select.form-control{width:auto}.navbar-form .radio,.navbar-form .checkbox{display:inline-block;padding-left:0;margin-top:0;margin-bottom:0}.navbar-form .radio input[type="radio"],.navbar-form .checkbox input[type="checkbox"]{float:none;margin-left:0}}@media(max-width:767px){.navbar-form .form-group{margin-bottom:5px}}@media(min-width:768px){.navbar-form{width:auto;padding-top:0;padding-bottom:0;margin-right:0;margin-left:0;border:0;-webkit-box-shadow:none;box-shadow:none}.navbar-form.navbar-right:last-child{margin-right:-15px}}.navbar-nav>li>.dropdown-menu{margin-top:0;border-top-right-radius:0;border-top-left-radius:0}.navbar-fixed-bottom .navbar-nav>li>.dropdown-menu{border-bottom-right-radius:0;border-bottom-left-radius:0}.navbar-nav.pull-right>li>.dropdown-menu,.navbar-nav>li>.dropdown-menu.pull-right{right:0;left:auto}.navbar-btn{margin-top:8px;margin-bottom:8px}.navbar-btn.btn-sm{margin-top:10px;margin-bottom:10px}.navbar-btn.btn-xs{margin-top:14px;margin-bottom:14px}.navbar-text{margin-top:15px;margin-bottom:15px}@media(min-width:768px){.navbar-text{float:left;margin-right:15px;margin-left:15px}.navbar-text.navbar-right:last-child{margin-right:0}}.navbar-default{background-color:#f8f8f8;border-color:#e7e7e7}.navbar-default .navbar-brand{color:#777}.navbar-default .navbar-brand:hover,.navbar-default .navbar-brand:focus{color:#5e5e5e;background-color:transparent}.navbar-default .navbar-text{color:#777}.navbar-default .navbar-nav>li>a{color:#777}.navbar-default .navbar-nav>li>a:hover,.navbar-default .navbar-nav>li>a:focus{color:#333;background-color:transparent}.navbar-default .navbar-nav>.active>a,.navbar-default .navbar-nav>.active>a:hover,.navbar-default .navbar-nav>.active>a:focus{color:#555;background-color:#e7e7e7}.navbar-default .navbar-nav>.disabled>a,.navbar-default .navbar-nav>.disabled>a:hover,.navbar-default .navbar-nav>.disabled>a:focus{color:#ccc;background-color:transparent}.navbar-default .navbar-toggle{border-color:#ddd}.navbar-default .navbar-toggle:hover,.navbar-default .navbar-toggle:focus{background-color:#ddd}.navbar-default .navbar-toggle .icon-bar{background-color:#ccc}.navbar-default .navbar-collapse,.navbar-default .navbar-form{border-color:#e7e7e7}.navbar-default .navbar-nav>.open>a,.navbar-default .navbar-nav>.open>a:hover,.navbar-default .navbar-nav>.open>a:focus{color:#555;background-color:#e7e7e7}@media(max-width:767px){.navbar-default .navbar-nav .open .dropdown-menu>li>a{color:#777}.navbar-default .navbar-nav .open .dropdown-menu>li>a:hover,.navbar-default .navbar-nav .open .dropdown-menu>li>a:focus{color:#333;background-color:transparent}.navbar-default .navbar-nav .open .dropdown-menu>.active>a,.navbar-default .navbar-nav .open .dropdown-menu>.active>a:hover,.navbar-default .navbar-nav .open .dropdown-menu>.active>a:focus{color:#555;background-color:#e7e7e7}.navbar-default .navbar-nav .open .dropdown-menu>.disabled>a,.navbar-default .navbar-nav .open .dropdown-menu>.disabled>a:hover,.navbar-default .navbar-nav .open .dropdown-menu>.disabled>a:focus{color:#ccc;background-color:transparent}}.navbar-default .navbar-link{color:#777}.navbar-default .navbar-link:hover{color:#333}.navbar-inverse{background-color:#222;border-color:#080808}.navbar-inverse .navbar-brand{color:#999}.navbar-inverse .navbar-brand:hover,.navbar-inverse .navbar-brand:focus{color:#fff;background-color:transparent}.navbar-inverse .navbar-text{color:#999}.navbar-inverse .navbar-nav>li>a{color:#999}.navbar-inverse .navbar-nav>li>a:hover,.navbar-inverse .navbar-nav>li>a:focus{color:#fff;background-color:transparent}.navbar-inverse .navbar-nav>.active>a,.navbar-inverse .navbar-nav>.active>a:hover,.navbar-inverse .navbar-nav>.active>a:focus{color:#fff;background-color:#080808}.navbar-inverse .navbar-nav>.disabled>a,.navbar-inverse .navbar-nav>.disabled>a:hover,.navbar-inverse .navbar-nav>.disabled>a:focus{color:#444;background-color:transparent}.navbar-inverse .navbar-toggle{border-color:#333}.navbar-inverse .navbar-toggle:hover,.navbar-inverse .navbar-toggle:focus{background-color:#333}.navbar-inverse .navbar-toggle .icon-bar{background-color:#fff}.navbar-inverse .navbar-collapse,.navbar-inverse .navbar-form{border-color:#101010}.navbar-inverse .navbar-nav>.open>a,.navbar-inverse .navbar-nav>.open>a:hover,.navbar-inverse .navbar-nav>.open>a:focus{color:#fff;background-color:#080808}@media(max-width:767px){.navbar-inverse .navbar-nav .open .dropdown-menu>.dropdown-header{border-color:#080808}.navbar-inverse .navbar-nav .open .dropdown-menu .divider{background-color:#080808}.navbar-inverse .navbar-nav .open .dropdown-menu>li>a{color:#999}.navbar-inverse .navbar-nav .open .dropdown-menu>li>a:hover,.navbar-inverse .navbar-nav .open .dropdown-menu>li>a:focus{color:#fff;background-color:transparent}.navbar-inverse .navbar-nav .open .dropdown-menu>.active>a,.navbar-inverse .navbar-nav .open .dropdown-menu>.active>a:hover,.navbar-inverse .navbar-nav .open .dropdown-menu>.active>a:focus{color:#fff;background-color:#080808}.navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a,.navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a:hover,.navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a:focus{color:#444;background-color:transparent}}.navbar-inverse .navbar-link{color:#999}.navbar-inverse .navbar-link:hover{color:#fff}.breadcrumb{padding:8px 15px;margin-bottom:20px;list-style:none;background-color:#f5f5f5;border-radius:4px}.breadcrumb>li{display:inline-block}.breadcrumb>li+li:before{padding:0 5px;color:#ccc;content:"/\00a0"}.breadcrumb>.active{color:#999}.pagination{display:inline-block;padding-left:0;margin:20px 0;border-radius:4px}.pagination>li{display:inline}.pagination>li>a,.pagination>li>span{position:relative;float:left;padding:6px 12px;margin-left:-1px;line-height:1.428571429;text-decoration:none;background-color:#fff;border:1px solid #ddd}.pagination>li:first-child>a,.pagination>li:first-child>span{margin-left:0;border-bottom-left-radius:4px;border-top-left-radius:4px}.pagination>li:last-child>a,.pagination>li:last-child>span{border-top-right-radius:4px;border-bottom-right-radius:4px}.pagination>li>a:hover,.pagination>li>span:hover,.pagination>li>a:focus,.pagination>li>span:focus{background-color:#eee}.pagination>.active>a,.pagination>.active>span,.pagination>.active>a:hover,.pagination>.active>span:hover,.pagination>.active>a:focus,.pagination>.active>span:focus{z-index:2;color:#fff;cursor:default;background-color:#428bca;border-color:#428bca}.pagination>.disabled>span,.pagination>.disabled>span:hover,.pagination>.disabled>span:focus,.pagination>.disabled>a,.pagination>.disabled>a:hover,.pagination>.disabled>a:focus{color:#999;cursor:not-allowed;background-color:#fff;border-color:#ddd}.pagination-lg>li>a,.pagination-lg>li>span{padding:10px 16px;font-size:18px}.pagination-lg>li:first-child>a,.pagination-lg>li:first-child>span{border-bottom-left-radius:6px;border-top-left-radius:6px}.pagination-lg>li:last-child>a,.pagination-lg>li:last-child>span{border-top-right-radius:6px;border-bottom-right-radius:6px}.pagination-sm>li>a,.pagination-sm>li>span{padding:5px 10px;font-size:12px}.pagination-sm>li:first-child>a,.pagination-sm>li:first-child>span{border-bottom-left-radius:3px;border-top-left-radius:3px}.pagination-sm>li:last-child>a,.pagination-sm>li:last-child>span{border-top-right-radius:3px;border-bottom-right-radius:3px}.pager{padding-left:0;margin:20px 0;text-align:center;list-style:none}.pager:before,.pager:after{display:table;content:" "}.pager:after{clear:both}.pager:before,.pager:after{display:table;content:" "}.pager:after{clear:both}.pager li{display:inline}.pager li>a,.pager li>span{display:inline-block;padding:5px 14px;background-color:#fff;border:1px solid #ddd;border-radius:15px}.pager li>a:hover,.pager li>a:focus{text-decoration:none;background-color:#eee}.pager .next>a,.pager .next>span{float:right}.pager .previous>a,.pager .previous>span{float:left}.pager .disabled>a,.pager .disabled>a:hover,.pager .disabled>a:focus,.pager .disabled>span{color:#999;cursor:not-allowed;background-color:#fff}.label{display:inline;padding:.2em .6em .3em;font-size:75%;font-weight:bold;line-height:1;color:#fff;text-align:center;white-space:nowrap;vertical-align:baseline;border-radius:.25em}.label[href]:hover,.label[href]:focus{color:#fff;text-decoration:none;cursor:pointer}.label:empty{display:none}.btn .label{position:relative;top:-1px}.label-default{background-color:#999}.label-default[href]:hover,.label-default[href]:focus{background-color:#808080}.label-primary{background-color:#428bca}.label-primary[href]:hover,.label-primary[href]:focus{background-color:#3071a9}.label-success{background-color:#5cb85c}.label-success[href]:hover,.label-success[href]:focus{background-color:#449d44}.label-info{background-color:#5bc0de}.label-info[href]:hover,.label-info[href]:focus{background-color:#31b0d5}.label-warning{background-color:#f0ad4e}.label-warning[href]:hover,.label-warning[href]:focus{background-color:#ec971f}.label-danger{background-color:#d9534f}.label-danger[href]:hover,.label-danger[href]:focus{background-color:#c9302c}.badge{display:inline-block;min-width:10px;padding:3px 7px;font-size:12px;font-weight:bold;line-height:1;color:#fff;text-align:center;white-space:nowrap;vertical-align:baseline;background-color:#999;border-radius:10px}.badge:empty{display:none}.btn .badge{position:relative;top:-1px}a.badge:hover,a.badge:focus{color:#fff;text-decoration:none;cursor:pointer}a.list-group-item.active>.badge,.nav-pills>.active>a>.badge{color:#428bca;background-color:#fff}.nav-pills>li>a>.badge{margin-left:3px}.jumbotron{padding:30px;margin-bottom:30px;font-size:21px;font-weight:200;line-height:2.1428571435;color:inherit;background-color:#eee}.jumbotron h1,.jumbotron .h1{line-height:1;color:inherit}.jumbotron p{line-height:1.4}.container .jumbotron{border-radius:6px}.jumbotron .container{max-width:100%}@media screen and (min-width:768px){.jumbotron{padding-top:48px;padding-bottom:48px}.container .jumbotron{padding-right:60px;padding-left:60px}.jumbotron h1,.jumbotron .h1{font-size:63px}}.thumbnail{display:block;padding:4px;margin-bottom:20px;line-height:1.428571429;background-color:#fff;border:1px solid #ddd;border-radius:4px;-webkit-transition:all .2s ease-in-out;transition:all .2s ease-in-out}.thumbnail>img,.thumbnail a>img{display:block;height:auto;max-width:100%;margin-right:auto;margin-left:auto}a.thumbnail:hover,a.thumbnail:focus,a.thumbnail.active{border-color:#428bca}.thumbnail .caption{padding:9px;color:#333}.alert{padding:15px;margin-bottom:20px;border:1px solid transparent;border-radius:4px}.alert h4{margin-top:0;color:inherit}.alert .alert-link{font-weight:bold}.alert>p,.alert>ul{margin-bottom:0}.alert>p+p{margin-top:5px}.alert-dismissable{padding-right:35px}.alert-dismissable .close{position:relative;top:-2px;right:-21px;color:inherit}.alert-success{color:#3c763d;background-color:#dff0d8;border-color:#d6e9c6}.alert-success hr{border-top-color:#c9e2b3}.alert-success .alert-link{color:#2b542c}.alert-info{color:#31708f;background-color:#d9edf7;border-color:#bce8f1}.alert-info hr{border-top-color:#a6e1ec}.alert-info .alert-link{color:#245269}.alert-warning{color:#8a6d3b;background-color:#fcf8e3;border-color:#faebcc}.alert-warning hr{border-top-color:#f7e1b5}.alert-warning .alert-link{color:#66512c}.alert-danger{color:#a94442;background-color:#f2dede;border-color:#ebccd1}.alert-danger hr{border-top-color:#e4b9c0}.alert-danger .alert-link{color:#843534}@-webkit-keyframes progress-bar-stripes{from{background-position:40px 0}to{background-position:0 0}}@keyframes progress-bar-stripes{from{background-position:40px 0}to{background-position:0 0}}.progress{height:20px;margin-bottom:20px;overflow:hidden;background-color:#f5f5f5;border-radius:4px;-webkit-box-shadow:inset 0 1px 2px rgba(0,0,0,0.1);box-shadow:inset 0 1px 2px rgba(0,0,0,0.1)}.progress-bar{float:left;width:0;height:100%;font-size:12px;line-height:20px;color:#fff;text-align:center;background-color:#428bca;-webkit-box-shadow:inset 0 -1px 0 rgba(0,0,0,0.15);box-shadow:inset 0 -1px 0 rgba(0,0,0,0.15);-webkit-transition:width .6s ease;transition:width .6s ease}.progress-striped .progress-bar{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,0.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,0.15) 50%,rgba(255,255,255,0.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,0.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,0.15) 50%,rgba(255,255,255,0.15) 75%,transparent 75%,transparent);background-size:40px 40px}.progress.active .progress-bar{-webkit-animation:progress-bar-stripes 2s linear infinite;animation:progress-bar-stripes 2s linear infinite}.progress-bar-success{background-color:#5cb85c}.progress-striped .progress-bar-success{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,0.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,0.15) 50%,rgba(255,255,255,0.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,0.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,0.15) 50%,rgba(255,255,255,0.15) 75%,transparent 75%,transparent)}.progress-bar-info{background-color:#5bc0de}.progress-striped .progress-bar-info{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,0.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,0.15) 50%,rgba(255,255,255,0.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,0.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,0.15) 50%,rgba(255,255,255,0.15) 75%,transparent 75%,transparent)}.progress-bar-warning{background-color:#f0ad4e}.progress-striped .progress-bar-warning{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,0.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,0.15) 50%,rgba(255,255,255,0.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,0.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,0.15) 50%,rgba(255,255,255,0.15) 75%,transparent 75%,transparent)}.progress-bar-danger{background-color:#d9534f}.progress-striped .progress-bar-danger{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,0.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,0.15) 50%,rgba(255,255,255,0.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,0.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,0.15) 50%,rgba(255,255,255,0.15) 75%,transparent 75%,transparent)}.media,.media-body{overflow:hidden;zoom:1}.media,.media .media{margin-top:15px}.media:first-child{margin-top:0}.media-object{display:block}.media-heading{margin:0 0 5px}.media>.pull-left{margin-right:10px}.media>.pull-right{margin-left:10px}.media-list{padding-left:0;list-style:none}.list-group{padding-left:0;margin-bottom:20px}.list-group-item{position:relative;display:block;padding:10px 15px;margin-bottom:-1px;background-color:#fff;border:1px solid #ddd}.list-group-item:first-child{border-top-right-radius:4px;border-top-left-radius:4px}.list-group-item:last-child{margin-bottom:0;border-bottom-right-radius:4px;border-bottom-left-radius:4px}.list-group-item>.badge{float:right}.list-group-item>.badge+.badge{margin-right:5px}a.list-group-item{color:#555}a.list-group-item .list-group-item-heading{color:#333}a.list-group-item:hover,a.list-group-item:focus{text-decoration:none;background-color:#f5f5f5}a.list-group-item.active,a.list-group-item.active:hover,a.list-group-item.active:focus{z-index:2;color:#fff;background-color:#428bca;border-color:#428bca}a.list-group-item.active .list-group-item-heading,a.list-group-item.active:hover .list-group-item-heading,a.list-group-item.active:focus .list-group-item-heading{color:inherit}a.list-group-item.active .list-group-item-text,a.list-group-item.active:hover .list-group-item-text,a.list-group-item.active:focus .list-group-item-text{color:#e1edf7}.list-group-item-heading{margin-top:0;margin-bottom:5px}.list-group-item-text{margin-bottom:0;line-height:1.3}.panel{margin-bottom:20px;background-color:#fff;border:1px solid transparent;border-radius:4px;-webkit-box-shadow:0 1px 1px rgba(0,0,0,0.05);box-shadow:0 1px 1px rgba(0,0,0,0.05)}.panel-body{padding:15px}.panel-body:before,.panel-body:after{display:table;content:" "}.panel-body:after{clear:both}.panel-body:before,.panel-body:after{display:table;content:" "}.panel-body:after{clear:both}.panel>.list-group{margin-bottom:0}.panel>.list-group .list-group-item{border-width:1px 0}.panel>.list-group .list-group-item:first-child{border-top-right-radius:0;border-top-left-radius:0}.panel>.list-group .list-group-item:last-child{border-bottom:0}.panel-heading+.list-group .list-group-item:first-child{border-top-width:0}.panel>.table,.panel>.table-responsive>.table{margin-bottom:0}.panel>.panel-body+.table,.panel>.panel-body+.table-responsive{border-top:1px solid #ddd}.panel>.table>tbody:first-child th,.panel>.table>tbody:first-child td{border-top:0}.panel>.table-bordered,.panel>.table-responsive>.table-bordered{border:0}.panel>.table-bordered>thead>tr>th:first-child,.panel>.table-responsive>.table-bordered>thead>tr>th:first-child,.panel>.table-bordered>tbody>tr>th:first-child,.panel>.table-responsive>.table-bordered>tbody>tr>th:first-child,.panel>.table-bordered>tfoot>tr>th:first-child,.panel>.table-responsive>.table-bordered>tfoot>tr>th:first-child,.panel>.table-bordered>thead>tr>td:first-child,.panel>.table-responsive>.table-bordered>thead>tr>td:first-child,.panel>.table-bordered>tbody>tr>td:first-child,.panel>.table-responsive>.table-bordered>tbody>tr>td:first-child,.panel>.table-bordered>tfoot>tr>td:first-child,.panel>.table-responsive>.table-bordered>tfoot>tr>td:first-child{border-left:0}.panel>.table-bordered>thead>tr>th:last-child,.panel>.table-responsive>.table-bordered>thead>tr>th:last-child,.panel>.table-bordered>tbody>tr>th:last-child,.panel>.table-responsive>.table-bordered>tbody>tr>th:last-child,.panel>.table-bordered>tfoot>tr>th:last-child,.panel>.table-responsive>.table-bordered>tfoot>tr>th:last-child,.panel>.table-bordered>thead>tr>td:last-child,.panel>.table-responsive>.table-bordered>thead>tr>td:last-child,.panel>.table-bordered>tbody>tr>td:last-child,.panel>.table-responsive>.table-bordered>tbody>tr>td:last-child,.panel>.table-bordered>tfoot>tr>td:last-child,.panel>.table-responsive>.table-bordered>tfoot>tr>td:last-child{border-right:0}.panel>.table-bordered>thead>tr:last-child>th,.panel>.table-responsive>.table-bordered>thead>tr:last-child>th,.panel>.table-bordered>tbody>tr:last-child>th,.panel>.table-responsive>.table-bordered>tbody>tr:last-child>th,.panel>.table-bordered>tfoot>tr:last-child>th,.panel>.table-responsive>.table-bordered>tfoot>tr:last-child>th,.panel>.table-bordered>thead>tr:last-child>td,.panel>.table-responsive>.table-bordered>thead>tr:last-child>td,.panel>.table-bordered>tbody>tr:last-child>td,.panel>.table-responsive>.table-bordered>tbody>tr:last-child>td,.panel>.table-bordered>tfoot>tr:last-child>td,.panel>.table-responsive>.table-bordered>tfoot>tr:last-child>td{border-bottom:0}.panel>.table-responsive{margin-bottom:0;border:0}.panel-heading{padding:10px 15px;border-bottom:1px solid transparent;border-top-right-radius:3px;border-top-left-radius:3px}.panel-heading>.dropdown .dropdown-toggle{color:inherit}.panel-title{margin-top:0;margin-bottom:0;font-size:16px;color:inherit}.panel-title>a{color:inherit}.panel-footer{padding:10px 15px;background-color:#f5f5f5;border-top:1px solid #ddd;border-bottom-right-radius:3px;border-bottom-left-radius:3px}.panel-group .panel{margin-bottom:0;overflow:hidden;border-radius:4px}.panel-group .panel+.panel{margin-top:5px}.panel-group .panel-heading{border-bottom:0}.panel-group .panel-heading+.panel-collapse .panel-body{border-top:1px solid #ddd}.panel-group .panel-footer{border-top:0}.panel-group .panel-footer+.panel-collapse .panel-body{border-bottom:1px solid #ddd}.panel-default{border-color:#ddd}.panel-default>.panel-heading{color:#333;background-color:#f5f5f5;border-color:#ddd}.panel-default>.panel-heading+.panel-collapse .panel-body{border-top-color:#ddd}.panel-default>.panel-footer+.panel-collapse .panel-body{border-bottom-color:#ddd}.panel-primary{border-color:#428bca}.panel-primary>.panel-heading{color:#fff;background-color:#428bca;border-color:#428bca}.panel-primary>.panel-heading+.panel-collapse .panel-body{border-top-color:#428bca}.panel-primary>.panel-footer+.panel-collapse .panel-body{border-bottom-color:#428bca}.panel-success{border-color:#d6e9c6}.panel-success>.panel-heading{color:#3c763d;background-color:#dff0d8;border-color:#d6e9c6}.panel-success>.panel-heading+.panel-collapse .panel-body{border-top-color:#d6e9c6}.panel-success>.panel-footer+.panel-collapse .panel-body{border-bottom-color:#d6e9c6}.panel-warning{border-color:#faebcc}.panel-warning>.panel-heading{color:#8a6d3b;background-color:#fcf8e3;border-color:#faebcc}.panel-warning>.panel-heading+.panel-collapse .panel-body{border-top-color:#faebcc}.panel-warning>.panel-footer+.panel-collapse .panel-body{border-bottom-color:#faebcc}.panel-danger{border-color:#ebccd1}.panel-danger>.panel-heading{color:#a94442;background-color:#f2dede;border-color:#ebccd1}.panel-danger>.panel-heading+.panel-collapse .panel-body{border-top-color:#ebccd1}.panel-danger>.panel-footer+.panel-collapse .panel-body{border-bottom-color:#ebccd1}.panel-info{border-color:#bce8f1}.panel-info>.panel-heading{color:#31708f;background-color:#d9edf7;border-color:#bce8f1}.panel-info>.panel-heading+.panel-collapse .panel-body{border-top-color:#bce8f1}.panel-info>.panel-footer+.panel-collapse .panel-body{border-bottom-color:#bce8f1}.well{min-height:20px;padding:19px;margin-bottom:20px;background-color:#f5f5f5;border:1px solid #e3e3e3;border-radius:4px;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,0.05);box-shadow:inset 0 1px 1px rgba(0,0,0,0.05)}.well blockquote{border-color:#ddd;border-color:rgba(0,0,0,0.15)}.well-lg{padding:24px;border-radius:6px}.well-sm{padding:9px;border-radius:3px}.close{float:right;font-size:21px;font-weight:bold;line-height:1;color:#000;text-shadow:0 1px 0 #fff;opacity:.2;filter:alpha(opacity=20)}.close:hover,.close:focus{color:#000;text-decoration:none;cursor:pointer;opacity:.5;filter:alpha(opacity=50)}button.close{padding:0;cursor:pointer;background:transparent;border:0;-webkit-appearance:none}.modal-open{overflow:hidden}.modal{position:fixed;top:0;right:0;bottom:0;left:0;z-index:1040;display:none;overflow:auto;overflow-y:scroll}.modal.fade .modal-dialog{-webkit-transform:translate(0,-25%);-ms-transform:translate(0,-25%);transform:translate(0,-25%);-webkit-transition:-webkit-transform .3s ease-out;-moz-transition:-moz-transform .3s ease-out;-o-transition:-o-transform .3s ease-out;transition:transform .3s ease-out}.modal.in .modal-dialog{-webkit-transform:translate(0,0);-ms-transform:translate(0,0);transform:translate(0,0)}.modal-dialog{position:relative;z-index:1050;width:auto;margin:10px}.modal-content{position:relative;background-color:#fff;border:1px solid #999;border:1px solid rgba(0,0,0,0.2);border-radius:6px;outline:0;-webkit-box-shadow:0 3px 9px rgba(0,0,0,0.5);box-shadow:0 3px 9px rgba(0,0,0,0.5);background-clip:padding-box}.modal-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;z-index:1030;background-color:#000}.modal-backdrop.fade{opacity:0;filter:alpha(opacity=0)}.modal-backdrop.in{opacity:.5;filter:alpha(opacity=50)}.modal-header{min-height:16.428571429px;padding:15px;border-bottom:1px solid #e5e5e5}.modal-header .close{margin-top:-2px}.modal-title{margin:0;line-height:1.428571429}.modal-body{position:relative;padding:20px}.modal-footer{padding:19px 20px 20px;margin-top:15px;text-align:right;border-top:1px solid #e5e5e5}.modal-footer:before,.modal-footer:after{display:table;content:" "}.modal-footer:after{clear:both}.modal-footer:before,.modal-footer:after{display:table;content:" "}.modal-footer:after{clear:both}.modal-footer .btn+.btn{margin-bottom:0;margin-left:5px}.modal-footer .btn-group .btn+.btn{margin-left:-1px}.modal-footer .btn-block+.btn-block{margin-left:0}@media screen and (min-width:768px){.modal-dialog{width:600px;margin:30px auto}.modal-content{-webkit-box-shadow:0 5px 15px rgba(0,0,0,0.5);box-shadow:0 5px 15px rgba(0,0,0,0.5)}}.tooltip{position:absolute;z-index:1030;display:block;font-size:12px;line-height:1.4;opacity:0;filter:alpha(opacity=0);visibility:visible}.tooltip.in{opacity:.9;filter:alpha(opacity=90)}.tooltip.top{padding:5px 0;margin-top:-3px}.tooltip.right{padding:0 5px;margin-left:3px}.tooltip.bottom{padding:5px 0;margin-top:3px}.tooltip.left{padding:0 5px;margin-left:-3px}.tooltip-inner{max-width:200px;padding:3px 8px;color:#fff;text-align:center;text-decoration:none;background-color:#000;border-radius:4px}.tooltip-arrow{position:absolute;width:0;height:0;border-color:transparent;border-style:solid}.tooltip.top .tooltip-arrow{bottom:0;left:50%;margin-left:-5px;border-top-color:#000;border-width:5px 5px 0}.tooltip.top-left .tooltip-arrow{bottom:0;left:5px;border-top-color:#000;border-width:5px 5px 0}.tooltip.top-right .tooltip-arrow{right:5px;bottom:0;border-top-color:#000;border-width:5px 5px 0}.tooltip.right .tooltip-arrow{top:50%;left:0;margin-top:-5px;border-right-color:#000;border-width:5px 5px 5px 0}.tooltip.left .tooltip-arrow{top:50%;right:0;margin-top:-5px;border-left-color:#000;border-width:5px 0 5px 5px}.tooltip.bottom .tooltip-arrow{top:0;left:50%;margin-left:-5px;border-bottom-color:#000;border-width:0 5px 5px}.tooltip.bottom-left .tooltip-arrow{top:0;left:5px;border-bottom-color:#000;border-width:0 5px 5px}.tooltip.bottom-right .tooltip-arrow{top:0;right:5px;border-bottom-color:#000;border-width:0 5px 5px}.popover{position:absolute;top:0;left:0;z-index:1010;display:none;max-width:276px;padding:1px;text-align:left;white-space:normal;background-color:#fff;border:1px solid #ccc;border:1px solid rgba(0,0,0,0.2);border-radius:6px;-webkit-box-shadow:0 5px 10px rgba(0,0,0,0.2);box-shadow:0 5px 10px rgba(0,0,0,0.2);background-clip:padding-box}.popover.top{margin-top:-10px}.popover.right{margin-left:10px}.popover.bottom{margin-top:10px}.popover.left{margin-left:-10px}.popover-title{padding:8px 14px;margin:0;font-size:14px;font-weight:normal;line-height:18px;background-color:#f7f7f7;border-bottom:1px solid #ebebeb;border-radius:5px 5px 0 0}.popover-content{padding:9px 14px}.popover .arrow,.popover .arrow:after{position:absolute;display:block;width:0;height:0;border-color:transparent;border-style:solid}.popover .arrow{border-width:11px}.popover .arrow:after{border-width:10px;content:""}.popover.top .arrow{bottom:-11px;left:50%;margin-left:-11px;border-top-color:#999;border-top-color:rgba(0,0,0,0.25);border-bottom-width:0}.popover.top .arrow:after{bottom:1px;margin-left:-10px;border-top-color:#fff;border-bottom-width:0;content:" "}.popover.right .arrow{top:50%;left:-11px;margin-top:-11px;border-right-color:#999;border-right-color:rgba(0,0,0,0.25);border-left-width:0}.popover.right .arrow:after{bottom:-10px;left:1px;border-right-color:#fff;border-left-width:0;content:" "}.popover.bottom .arrow{top:-11px;left:50%;margin-left:-11px;border-bottom-color:#999;border-bottom-color:rgba(0,0,0,0.25);border-top-width:0}.popover.bottom .arrow:after{top:1px;margin-left:-10px;border-bottom-color:#fff;border-top-width:0;content:" "}.popover.left .arrow{top:50%;right:-11px;margin-top:-11px;border-left-color:#999;border-left-color:rgba(0,0,0,0.25);border-right-width:0}.popover.left .arrow:after{right:1px;bottom:-10px;border-left-color:#fff;border-right-width:0;content:" "}.carousel{position:relative}.carousel-inner{position:relative;width:100%;overflow:hidden}.carousel-inner>.item{position:relative;display:none;-webkit-transition:.6s ease-in-out left;transition:.6s ease-in-out left}.carousel-inner>.item>img,.carousel-inner>.item>a>img{display:block;height:auto;max-width:100%;line-height:1}.carousel-inner>.active,.carousel-inner>.next,.carousel-inner>.prev{display:block}.carousel-inner>.active{left:0}.carousel-inner>.next,.carousel-inner>.prev{position:absolute;top:0;width:100%}.carousel-inner>.next{left:100%}.carousel-inner>.prev{left:-100%}.carousel-inner>.next.left,.carousel-inner>.prev.right{left:0}.carousel-inner>.active.left{left:-100%}.carousel-inner>.active.right{left:100%}.carousel-control{position:absolute;top:0;bottom:0;left:0;width:15%;font-size:20px;color:#fff;text-align:center;text-shadow:0 1px 2px rgba(0,0,0,0.6);opacity:.5;filter:alpha(opacity=50)}.carousel-control.left{background-image:-webkit-linear-gradient(left,color-stop(rgba(0,0,0,0.5) 0),color-stop(rgba(0,0,0,0.0001) 100%));background-image:linear-gradient(to right,rgba(0,0,0,0.5) 0,rgba(0,0,0,0.0001) 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#80000000',endColorstr='#00000000',GradientType=1)}.carousel-control.right{right:0;left:auto;background-image:-webkit-linear-gradient(left,color-stop(rgba(0,0,0,0.0001) 0),color-stop(rgba(0,0,0,0.5) 100%));background-image:linear-gradient(to right,rgba(0,0,0,0.0001) 0,rgba(0,0,0,0.5) 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#00000000',endColorstr='#80000000',GradientType=1)}.carousel-control:hover,.carousel-control:focus{color:#fff;text-decoration:none;outline:0;opacity:.9;filter:alpha(opacity=90)}.carousel-control .icon-prev,.carousel-control .icon-next,.carousel-control .glyphicon-chevron-left,.carousel-control .glyphicon-chevron-right{position:absolute;top:50%;z-index:5;display:inline-block}.carousel-control .icon-prev,.carousel-control .glyphicon-chevron-left{left:50%}.carousel-control .icon-next,.carousel-control .glyphicon-chevron-right{right:50%}.carousel-control .icon-prev,.carousel-control .icon-next{width:20px;height:20px;margin-top:-10px;margin-left:-10px;font-family:serif}.carousel-control .icon-prev:before{content:'\2039'}.carousel-control .icon-next:before{content:'\203a'}.carousel-indicators{position:absolute;bottom:10px;left:50%;z-index:15;width:60%;padding-left:0;margin-left:-30%;text-align:center;list-style:none}.carousel-indicators li{display:inline-block;width:10px;height:10px;margin:1px;text-indent:-999px;cursor:pointer;background-color:#000 \9;background-color:rgba(0,0,0,0);border:1px solid #fff;border-radius:10px}.carousel-indicators .active{width:12px;height:12px;margin:0;background-color:#fff}.carousel-caption{position:absolute;right:15%;bottom:20px;left:15%;z-index:10;padding-top:20px;padding-bottom:20px;color:#fff;text-align:center;text-shadow:0 1px 2px rgba(0,0,0,0.6)}.carousel-caption .btn{text-shadow:none}@media screen and (min-width:768px){.carousel-control .glyphicons-chevron-left,.carousel-control .glyphicons-chevron-right,.carousel-control .icon-prev,.carousel-control .icon-next{width:30px;height:30px;margin-top:-15px;margin-left:-15px;font-size:30px}.carousel-caption{right:20%;left:20%;padding-bottom:30px}.carousel-indicators{bottom:20px}}.clearfix:before,.clearfix:after{display:table;content:" "}.clearfix:after{clear:both}.center-block{display:block;margin-right:auto;margin-left:auto}.pull-right{float:right!important}.pull-left{float:left!important}.hide{display:none!important}.show{display:block!important}.invisible{visibility:hidden}.text-hide{font:0/0 a;color:transparent;text-shadow:none;background-color:transparent;border:0}.hidden{display:none!important;visibility:hidden!important}.affix{position:fixed}@-ms-viewport{width:device-width}.visible-xs,tr.visible-xs,th.visible-xs,td.visible-xs{display:none!important}@media(max-width:767px){.visible-xs{display:block!important}table.visible-xs{display:table}tr.visible-xs{display:table-row!important}th.visible-xs,td.visible-xs{display:table-cell!important}}@media(min-width:768px) and (max-width:991px){.visible-xs.visible-sm{display:block!important}table.visible-xs.visible-sm{display:table}tr.visible-xs.visible-sm{display:table-row!important}th.visible-xs.visible-sm,td.visible-xs.visible-sm{display:table-cell!important}}@media(min-width:992px) and (max-width:1199px){.visible-xs.visible-md{display:block!important}table.visible-xs.visible-md{display:table}tr.visible-xs.visible-md{display:table-row!important}th.visible-xs.visible-md,td.visible-xs.visible-md{display:table-cell!important}}@media(min-width:1200px){.visible-xs.visible-lg{display:block!important}table.visible-xs.visible-lg{display:table}tr.visible-xs.visible-lg{display:table-row!important}th.visible-xs.visible-lg,td.visible-xs.visible-lg{display:table-cell!important}}.visible-sm,tr.visible-sm,th.visible-sm,td.visible-sm{display:none!important}@media(max-width:767px){.visible-sm.visible-xs{display:block!important}table.visible-sm.visible-xs{display:table}tr.visible-sm.visible-xs{display:table-row!important}th.visible-sm.visible-xs,td.visible-sm.visible-xs{display:table-cell!important}}@media(min-width:768px) and (max-width:991px){.visible-sm{display:block!important}table.visible-sm{display:table}tr.visible-sm{display:table-row!important}th.visible-sm,td.visible-sm{display:table-cell!important}}@media(min-width:992px) and (max-width:1199px){.visible-sm.visible-md{display:block!important}table.visible-sm.visible-md{display:table}tr.visible-sm.visible-md{display:table-row!important}th.visible-sm.visible-md,td.visible-sm.visible-md{display:table-cell!important}}@media(min-width:1200px){.visible-sm.visible-lg{display:block!important}table.visible-sm.visible-lg{display:table}tr.visible-sm.visible-lg{display:table-row!important}th.visible-sm.visible-lg,td.visible-sm.visible-lg{display:table-cell!important}}.visible-md,tr.visible-md,th.visible-md,td.visible-md{display:none!important}@media(max-width:767px){.visible-md.visible-xs{display:block!important}table.visible-md.visible-xs{display:table}tr.visible-md.visible-xs{display:table-row!important}th.visible-md.visible-xs,td.visible-md.visible-xs{display:table-cell!important}}@media(min-width:768px) and (max-width:991px){.visible-md.visible-sm{display:block!important}table.visible-md.visible-sm{display:table}tr.visible-md.visible-sm{display:table-row!important}th.visible-md.visible-sm,td.visible-md.visible-sm{display:table-cell!important}}@media(min-width:992px) and (max-width:1199px){.visible-md{display:block!important}table.visible-md{display:table}tr.visible-md{display:table-row!important}th.visible-md,td.visible-md{display:table-cell!important}}@media(min-width:1200px){.visible-md.visible-lg{display:block!important}table.visible-md.visible-lg{display:table}tr.visible-md.visible-lg{display:table-row!important}th.visible-md.visible-lg,td.visible-md.visible-lg{display:table-cell!important}}.visible-lg,tr.visible-lg,th.visible-lg,td.visible-lg{display:none!important}@media(max-width:767px){.visible-lg.visible-xs{display:block!important}table.visible-lg.visible-xs{display:table}tr.visible-lg.visible-xs{display:table-row!important}th.visible-lg.visible-xs,td.visible-lg.visible-xs{display:table-cell!important}}@media(min-width:768px) and (max-width:991px){.visible-lg.visible-sm{display:block!important}table.visible-lg.visible-sm{display:table}tr.visible-lg.visible-sm{display:table-row!important}th.visible-lg.visible-sm,td.visible-lg.visible-sm{display:table-cell!important}}@media(min-width:992px) and (max-width:1199px){.visible-lg.visible-md{display:block!important}table.visible-lg.visible-md{display:table}tr.visible-lg.visible-md{display:table-row!important}th.visible-lg.visible-md,td.visible-lg.visible-md{display:table-cell!important}}@media(min-width:1200px){.visible-lg{display:block!important}table.visible-lg{display:table}tr.visible-lg{display:table-row!important}th.visible-lg,td.visible-lg{display:table-cell!important}}.hidden-xs{display:block!important}table.hidden-xs{display:table}tr.hidden-xs{display:table-row!important}th.hidden-xs,td.hidden-xs{display:table-cell!important}@media(max-width:767px){.hidden-xs,tr.hidden-xs,th.hidden-xs,td.hidden-xs{display:none!important}}@media(min-width:768px) and (max-width:991px){.hidden-xs.hidden-sm,tr.hidden-xs.hidden-sm,th.hidden-xs.hidden-sm,td.hidden-xs.hidden-sm{display:none!important}}@media(min-width:992px) and (max-width:1199px){.hidden-xs.hidden-md,tr.hidden-xs.hidden-md,th.hidden-xs.hidden-md,td.hidden-xs.hidden-md{display:none!important}}@media(min-width:1200px){.hidden-xs.hidden-lg,tr.hidden-xs.hidden-lg,th.hidden-xs.hidden-lg,td.hidden-xs.hidden-lg{display:none!important}}.hidden-sm{display:block!important}table.hidden-sm{display:table}tr.hidden-sm{display:table-row!important}th.hidden-sm,td.hidden-sm{display:table-cell!important}@media(max-width:767px){.hidden-sm.hidden-xs,tr.hidden-sm.hidden-xs,th.hidden-sm.hidden-xs,td.hidden-sm.hidden-xs{display:none!important}}@media(min-width:768px) and (max-width:991px){.hidden-sm,tr.hidden-sm,th.hidden-sm,td.hidden-sm{display:none!important}}@media(min-width:992px) and (max-width:1199px){.hidden-sm.hidden-md,tr.hidden-sm.hidden-md,th.hidden-sm.hidden-md,td.hidden-sm.hidden-md{display:none!important}}@media(min-width:1200px){.hidden-sm.hidden-lg,tr.hidden-sm.hidden-lg,th.hidden-sm.hidden-lg,td.hidden-sm.hidden-lg{display:none!important}}.hidden-md{display:block!important}table.hidden-md{display:table}tr.hidden-md{display:table-row!important}th.hidden-md,td.hidden-md{display:table-cell!important}@media(max-width:767px){.hidden-md.hidden-xs,tr.hidden-md.hidden-xs,th.hidden-md.hidden-xs,td.hidden-md.hidden-xs{display:none!important}}@media(min-width:768px) and (max-width:991px){.hidden-md.hidden-sm,tr.hidden-md.hidden-sm,th.hidden-md.hidden-sm,td.hidden-md.hidden-sm{display:none!important}}@media(min-width:992px) and (max-width:1199px){.hidden-md,tr.hidden-md,th.hidden-md,td.hidden-md{display:none!important}}@media(min-width:1200px){.hidden-md.hidden-lg,tr.hidden-md.hidden-lg,th.hidden-md.hidden-lg,td.hidden-md.hidden-lg{display:none!important}}.hidden-lg{display:block!important}table.hidden-lg{display:table}tr.hidden-lg{display:table-row!important}th.hidden-lg,td.hidden-lg{display:table-cell!important}@media(max-width:767px){.hidden-lg.hidden-xs,tr.hidden-lg.hidden-xs,th.hidden-lg.hidden-xs,td.hidden-lg.hidden-xs{display:none!important}}@media(min-width:768px) and (max-width:991px){.hidden-lg.hidden-sm,tr.hidden-lg.hidden-sm,th.hidden-lg.hidden-sm,td.hidden-lg.hidden-sm{display:none!important}}@media(min-width:992px) and (max-width:1199px){.hidden-lg.hidden-md,tr.hidden-lg.hidden-md,th.hidden-lg.hidden-md,td.hidden-lg.hidden-md{display:none!important}}@media(min-width:1200px){.hidden-lg,tr.hidden-lg,th.hidden-lg,td.hidden-lg{display:none!important}}.visible-print,tr.visible-print,th.visible-print,td.visible-print{display:none!important}@media print{.visible-print{display:block!important}table.visible-print{display:table}tr.visible-print{display:table-row!important}th.visible-print,td.visible-print{display:table-cell!important}.hidden-print,tr.hidden-print,th.hidden-print,td.hidden-print{display:none!important}} \ No newline at end of file diff --git a/docs/css/fig.css b/docs/css/fig.css deleted file mode 100644 index 3dc990f15f4..00000000000 --- a/docs/css/fig.css +++ /dev/null @@ -1,187 +0,0 @@ -body { - padding-top: 20px; - padding-bottom: 60px; - font-family: 'Lato', sans-serif; - font-weight: 300; - font-size: 18px; - color: #362; -} - -h1, h2, h3, h4, h5, h6 { - font-family: 'Lato', sans-serif; - font-weight: 400; - color: #25594D; -} - -h2, h3, h4, h5, h6 { - margin-top: 1.5em; -} - -p { - margin: 20px 0; -} - -a, a:hover, a:visited { - color: #4D9900; - text-decoration: underline; -} - -pre, code { - border: none; - background: #D5E1B4; -} - -code, pre code { - color: #484F40; -} - -pre { - border-bottom: 2px solid #bec9a1; - font-size: 14px; -} - -code { - font-size: 0.84em; -} - -pre code { - background: none; -} - -img { - max-width: 100%; -} - -.container { - margin-left: 0; -} - -.logo { - font-family: 'Lilita One', sans-serif; - font-size: 64px; - margin: 20px 0 40px 0; -} - -.logo a { - color: #a41211; - text-decoration: none; -} - -.logo img { - width: 60px; - vertical-align: -8px; -} - -.mobile-logo { - text-align: center; -} - -.sidebar { - font-size: 15px; - color: #777; -} - -.sidebar a { - color: #a41211; -} - -.sidebar p { - margin: 10px 0; -} - -@media (max-width: 767px) { - .sidebar { - text-align: center; - margin-top: 40px; - } - - .sidebar .logo { - display: none; - } -} - -@media (min-width: 768px) { - .mobile-logo { - display: none; - } - - .logo { - margin-top: 30px; - margin-bottom: 30px; - } - - .content h1 { - margin: 60px 0 55px 0; - } - - .sidebar { - position: fixed; - top: 0; - left: 0; - bottom: 0; - width: 280px; - overflow-y: auto; - padding-left: 40px; - padding-right: 10px; - border-right: 1px solid #ccc; - } - - .content { - margin-left: 320px; - max-width: 650px; - } -} - -.nav { - margin: 15px 0; -} - -.nav li a { - display: block; - padding: 5px 0; - line-height: 1.2; - text-decoration: none; -} - -.nav li a:hover, .nav li a:focus { - text-decoration: underline; - background: none; -} - -.nav ul { - padding-left: 20px; - list-style: none; -} - -.badges { - margin: 40px 0; -} - -a.btn { - background: #25594D; - color: white; - text-transform: uppercase; - text-decoration: none; -} - -a.btn:hover { - color: white; -} - -.strapline { - font-size: 30px; -} - -@media (min-width: 768px) { - .strapline { - font-size: 40px; - display: block; - line-height: 1.2; - margin-top: 25px; - margin-bottom: 35px; - } -} - -strong { - font-weight: 700; -} diff --git a/docs/fig.yml b/docs/fig.yml deleted file mode 100644 index 30e8f6d9dd6..00000000000 --- a/docs/fig.yml +++ /dev/null @@ -1,8 +0,0 @@ -jekyll: - build: . - ports: - - "4000:4000" - volumes: - - .:/code - environment: - - LANG=en_US.UTF-8 diff --git a/docs/img/favicon.ico b/docs/img/favicon.ico deleted file mode 100644 index 71c02f1136c5ab430b5c0a61db023684877cf546..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1150 zcmZuwc}x>l7=Nf+7MCT=Kk(nhjG+t|_gxCK1xh)JN|_u<87K}StRRAJAcPnaflWjN zDdG|a2F6j2f`wktld}}jLOBLP0&JRT;sU$)?TLxWjCuKe-}}DzzTbD`Jwj~gvb7~t zenQS4BE*gma-4#?sOPnxDnjVlkq=*E-+qU1aEm38`IW@$WVT&W(T%LiZkeHWXmY2t zZG5+|Su-VS>d8#KcJrh3N-;@uJoZ0^^rA|VQBZO)zVJpwK~wKY*F)={ExHY)UY-L# zV+t{6Cy;yFv|G}pz9SNM9LZ~v?8kGzE+)*>0vkc$^<&{VRi(oEfoIkF6*Tle0sku_ zTp0>5V@BZ_qC`;iC@wY3?v)Q1%ekW3Ly2W~3Fkt^`=y<#bB%iQV!mX2Pf%+@eDMq- zQ^w#PD2F>^7;cOqFnCIMg~$*l(BYco@%EKYO)w$v9VqLNi-uP={%D)pLS}^pf^TLJ z6sHG+=JX3!!-FA*i~j)pB4qg7djL+}GGtfH;kt5GoO$a$r`@!$)u!3R+3#i%9IHhj zPXp#D0|F@rE5V2Wo(j(XQn&^^p!xx=L5}2$)2Pu~7c1rCT~)m+NVqVCAezrBbQs<& zB|-#7Fi&gYAJI?yAA~1+7@nb0obZsq-nj=pYz2z$&+pvUFF#Grn*ryv4(xa>d?VDf zW)*lsBN)*VxCZyZnfCAGBZ0GTFC0Dk;2S1~PnZf9f3)mX%U5<2vZld0r3Ej=0484x zzbIP&Ny>Ld19tKx*oj&?`##vabmLP;3H-v;h)J77)yOX|ZfTYt2@A~#rT0T9{JCmy zlg1E{G6_Mp8Sz;r>X*SSuos`Xbi$L_hrmb$lJcg}Xj+@7P|g;V_bmPuB^*Nlt=*ra zL|ob!qJ$F&PZ|d&!2sV#HQa)vIPNZ?96hu@1qvE0|JLX%CBmA)kAKiDv|MemBAWIb zLwS-#Cirkvbe3{(ztr!)8E=*hj$ZxvB48L9WwWT!ESZZW2K#TNCX)4&=IG6_#kxA( z+RMCZ3$D~zA-rq`D^7#pm=SOj4PXiM2VTeNTfk3(o4 zX|}GDGO6)!gZ8oa&oj%plKJ)KR?CLGeR0#Mdh(lDvAVr3S=#*je8b>#LCHNfFSq7R z{M#FTtCcvU)*m?6YB*f1wAkhk%pMhWj-SYBH;T`E-}f@9yiMf9ze@f&NXT;=Le!L? S diff --git a/docs/img/logo.png b/docs/img/logo.png deleted file mode 100644 index 13ca9bc78b18f3bb7195dec87662195508cf5a51..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 133640 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≺_r~!Y{H%?R0D5ge&@j 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 z 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 Date: Tue, 20 Jan 2015 21:23:01 +0000 Subject: [PATCH 0654/4072] Fix typos Signed-off-by: Aanand Prasad --- compose/cli/command.py | 2 +- compose/project.py | 2 +- script/clean | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/compose/cli/command.py b/compose/cli/command.py index dc7683a359a..f92f95cd628 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -111,7 +111,7 @@ def get_config_path(self, file_path=None): return os.path.join(self.base_dir, file_path) if os.path.exists(os.path.join(self.base_dir, 'docker-compose.yaml')): - log.warning("Fig just read the file 'docker-compose.yaml' on startup, rather " + log.warning("Compose just read the file 'docker-compose.yaml' on startup, rather " "than 'docker-compose.yml'") log.warning("Please be aware that .yml is the expected extension " "in most cases, and using .yaml can cause compatibility " diff --git a/compose/project.py b/compose/project.py index b707d637411..028c6a96655 100644 --- a/compose/project.py +++ b/compose/project.py @@ -67,7 +67,7 @@ def from_config(cls, name, config, client): dicts = [] for service_name, service in list(config.items()): if not isinstance(service, dict): - raise ConfigurationError('Service "%s" doesn\'t have any configuration options. All top level keys in your compose.yml must map to a dictionary of configuration options.' % service_name) + raise ConfigurationError('Service "%s" doesn\'t have any configuration options. All top level keys in your docker-compose.yml must map to a dictionary of configuration options.' % service_name) service['name'] = service_name dicts.append(service) return cls.from_dicts(name, dicts, client) diff --git a/script/clean b/script/clean index 0c845012c63..07a9cff14df 100755 --- a/script/clean +++ b/script/clean @@ -1,3 +1,3 @@ #!/bin/sh find . -type f -name '*.pyc' -delete -rm -rf docs/_site build dist compose.egg-info +rm -rf docs/_site build dist docker-compose.egg-info From 43fdae8bc66051e040333bbe8dd1082f95ffee11 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 20 Jan 2015 18:24:10 +0000 Subject: [PATCH 0655/4072] Ship 1.1.0-rc1 Signed-off-by: Aanand Prasad --- CHANGES.md | 29 +++++++++++++++++++++++++++++ compose/__init__.py | 2 +- docs/install.md | 2 +- 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 57e958aac96..c8199cbd16c 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,6 +1,35 @@ Change log ========== +1.1.0-rc1 (2015-01-20) +---------------------- + +Fig has been renamed to Docker Compose, or just Compose for short. This has several implications for you: + +- 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`. + +Besides that, there’s a lot of new stuff in this release: + +- We’ve made a few small changes to ensure that Compose will work with Swarm, Docker’s new clustering tool (https://github.com/docker/swarm). Eventually you'll be able to point Compose at a Swarm cluster instead of a standalone Docker host and it’ll run your containers on the cluster with no extra work from you. As Swarm is still developing, integration is rough and lots of Compose features don't work yet. + +- `docker-compose run` now has a `--service-ports` flag for exposing ports on the given service. This is useful for e.g. running your webapp with an interactive debugger. + +- You can now link to containers outside your app with the `external_links` option in docker-compose.yml. + +- You can now prevent `docker-compose up` from automatically building images with the `--no-build` option. This will make fewer API calls and run faster. + +- If you don’t specify a tag when using the `image` key, Compose will default to the `latest` tag, rather than pulling all tags. + +- `docker-compose kill` now supports the `-s` flag, allowing you to specify the exact signal you want to send to a service’s containers. + +- docker-compose.yml now has an `env_file` key, analogous to `docker run --env-file`, letting you specify multiple environment variables in a separate file. This is great if you have a lot of them, or if you want to keep sensitive information out of version control. + +- docker-compose.yml now supports the `dns_search`, `cap_add`, `cap_drop` and `restart` options, analogous to `docker run`’s `--dns-search`, `--cap-add`, `--cap-drop` and `--restart` options. + +- A number of bugs have been fixed - see the milestone for details: https://github.com/docker/fig/issues?q=milestone%3A1.1.0+ + 1.0.1 (2014-11-04) ------------------ diff --git a/compose/__init__.py b/compose/__init__.py index a7b29e0c909..4ed232c669e 100644 --- a/compose/__init__.py +++ b/compose/__init__.py @@ -1,4 +1,4 @@ from __future__ import unicode_literals from .service import Service # noqa:flake8 -__version__ = '1.0.1' +__version__ = '1.1.0-rc1' diff --git a/docs/install.md b/docs/install.md index e61cd0fc3a3..4c35e12aa3c 100644 --- a/docs/install.md +++ b/docs/install.md @@ -18,7 +18,7 @@ There are also guides for [Ubuntu](https://docs.docker.com/installation/ubuntuli Next, install Compose: - curl -L https://github.com/docker/docker-compose/releases/download/1.0.1/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose; chmod +x /usr/local/bin/docker-compose + curl -L https://github.com/docker/docker-compose/releases/download/1.1.0-rc1/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose; chmod +x /usr/local/bin/docker-compose Optionally, install [command completion](completion.html) for the bash shell. From f1e4fb7736a826665886158d8a9bcfd9470e7d4e Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Wed, 21 Jan 2015 09:37:07 +0100 Subject: [PATCH 0656/4072] Rebrand bash completion script Signed-off-by: Harald Albers --- .../completion/bash/{fig => docker-compose} | 118 +++++++++--------- 1 file changed, 57 insertions(+), 61 deletions(-) rename contrib/completion/bash/{fig => docker-compose} (59%) diff --git a/contrib/completion/bash/fig b/contrib/completion/bash/docker-compose similarity index 59% rename from contrib/completion/bash/fig rename to contrib/completion/bash/docker-compose index b44efa7b123..53231604f83 100644 --- a/contrib/completion/bash/fig +++ b/contrib/completion/bash/docker-compose @@ -1,6 +1,6 @@ #!bash # -# bash completion for fig commands +# bash completion for docker-compose # # This work is based on the completion for the docker command. # @@ -12,46 +12,42 @@ # To enable the completions either: # - place this file in /etc/bash_completion.d # or -# - copy this file and add the line below to your .bashrc after -# bash completion features are loaded -# . docker.bash -# -# Note: -# Some completions require the current user to have sufficient permissions -# to execute the docker command. +# - copy this file to e.g. ~/.docker-compose-completion.sh and add the line +# below to your .bashrc after bash completion features are loaded +# . ~/.docker-compose-completion.sh -# Extracts all service names from the figfile. -___fig_all_services_in_figfile() { - awk -F: '/^[a-zA-Z0-9]/{print $1}' "${fig_file:-fig.yml}" +# Extracts all service names from docker-compose.yml. +___docker-compose_all_services_in_compose_file() { + awk -F: '/^[a-zA-Z0-9]/{print $1}' "${compose_file:-docker-compose.yml}" } # All services, even those without an existing container -__fig_services_all() { - COMPREPLY=( $(compgen -W "$(___fig_all_services_in_figfile)" -- "$cur") ) +__docker-compose_services_all() { + COMPREPLY=( $(compgen -W "$(___docker-compose_all_services_in_compose_file)" -- "$cur") ) } -# All services that have an entry with the given key in their figfile section -___fig_services_with_key() { +# All services that have an entry with the given key in their docker-compose.yml 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;}' fig.yml | awk -F: -v key=": +$1:" '$0 ~ key {print $1}' + awk '/^[a-zA-Z0-9]/{printf "\n"};{printf $0;next;}' "${compose_file:-docker-compose.yml}" | awk -F: -v key=": +$1:" '$0 ~ key {print $1}' } # All services that are defined by a Dockerfile reference -__fig_services_from_build() { - COMPREPLY=( $(compgen -W "$(___fig_services_with_key build)" -- "$cur") ) +__docker-compose_services_from_build() { + COMPREPLY=( $(compgen -W "$(___docker-compose_services_with_key build)" -- "$cur") ) } # All services that are defined by an image -__fig_services_from_image() { - COMPREPLY=( $(compgen -W "$(___fig_services_with_key image)" -- "$cur") ) +__docker-compose_services_from_image() { + COMPREPLY=( $(compgen -W "$(___docker-compose_services_with_key image)" -- "$cur") ) } # The services for which containers have been created, optionally filtered # by a boolean expression passed in as argument. -__fig_services_with() { +__docker-compose_services_with() { local containers names - containers="$(fig 2>/dev/null ${fig_file:+-f $fig_file} ${fig_project:+-p $fig_project} ps -q)" + 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 @@ -59,29 +55,29 @@ __fig_services_with() { } # The services for which at least one running container exists -__fig_services_running() { - __fig_services_with '.State.Running' +__docker-compose_services_running() { + __docker-compose_services_with '.State.Running' } # The services for which at least one stopped container exists -__fig_services_stopped() { - __fig_services_with 'not .State.Running' +__docker-compose_services_stopped() { + __docker-compose_services_with 'not .State.Running' } -_fig_build() { +_docker-compose_build() { case "$cur" in -*) COMPREPLY=( $( compgen -W "--no-cache" -- "$cur" ) ) ;; *) - __fig_services_from_build + __docker-compose_services_from_build ;; esac } -_fig_fig() { +_docker-compose_docker-compose() { case "$prev" in --file|-f) _filedir @@ -103,12 +99,12 @@ _fig_fig() { } -_fig_help() { +_docker-compose_help() { COMPREPLY=( $( compgen -W "${commands[*]}" -- "$cur" ) ) } -_fig_kill() { +_docker-compose_kill() { case "$prev" in -s) COMPREPLY=( $( compgen -W "SIGHUP SIGINT SIGKILL SIGUSR1 SIGUSR2" -- "$(echo $cur | tr '[:lower:]' '[:upper:]')" ) ) @@ -121,25 +117,25 @@ _fig_kill() { COMPREPLY=( $( compgen -W "-s" -- "$cur" ) ) ;; *) - __fig_services_running + __docker-compose_services_running ;; esac } -_fig_logs() { +_docker-compose_logs() { case "$cur" in -*) COMPREPLY=( $( compgen -W "--no-color" -- "$cur" ) ) ;; *) - __fig_services_all + __docker-compose_services_all ;; esac } -_fig_port() { +_docker-compose_port() { case "$prev" in --protocol) COMPREPLY=( $( compgen -W "tcp udp" -- "$cur" ) ) @@ -155,54 +151,54 @@ _fig_port() { COMPREPLY=( $( compgen -W "--protocol --index" -- "$cur" ) ) ;; *) - __fig_services_all + __docker-compose_services_all ;; esac } -_fig_ps() { +_docker-compose_ps() { case "$cur" in -*) COMPREPLY=( $( compgen -W "-q" -- "$cur" ) ) ;; *) - __fig_services_all + __docker-compose_services_all ;; esac } -_fig_pull() { +_docker-compose_pull() { case "$cur" in -*) COMPREPLY=( $( compgen -W "--allow-insecure-ssl" -- "$cur" ) ) ;; *) - __fig_services_from_image + __docker-compose_services_from_image ;; esac } -_fig_restart() { - __fig_services_running +_docker-compose_restart() { + __docker-compose_services_running } -_fig_rm() { +_docker-compose_rm() { case "$cur" in -*) COMPREPLY=( $( compgen -W "--force -v" -- "$cur" ) ) ;; *) - __fig_services_stopped + __docker-compose_services_stopped ;; esac } -_fig_run() { +_docker-compose_run() { case "$prev" in -e) COMPREPLY=( $( compgen -e -- "$cur" ) ) @@ -219,48 +215,48 @@ _fig_run() { COMPREPLY=( $( compgen -W "--allow-insecure-ssl -d --entrypoint -e --no-deps --rm -T" -- "$cur" ) ) ;; *) - __fig_services_all + __docker-compose_services_all ;; esac } -_fig_scale() { +_docker-compose_scale() { case "$prev" in =) COMPREPLY=("$cur") ;; *) - COMPREPLY=( $(compgen -S "=" -W "$(___fig_all_services_in_figfile)" -- "$cur") ) + COMPREPLY=( $(compgen -S "=" -W "$(___docker-compose_all_services_in_compose_file)" -- "$cur") ) compopt -o nospace ;; esac } -_fig_start() { - __fig_services_stopped +_docker-compose_start() { + __docker-compose_services_stopped } -_fig_stop() { - __fig_services_running +_docker-compose_stop() { + __docker-compose_services_running } -_fig_up() { +_docker-compose_up() { case "$cur" in -*) COMPREPLY=( $( compgen -W "--allow-insecure-ssl -d --no-build --no-color --no-deps --no-recreate" -- "$cur" ) ) ;; *) - __fig_services_all + __docker-compose_services_all ;; esac } -_fig() { +_docker-compose() { local commands=( build help @@ -284,18 +280,18 @@ _fig() { # search subcommand and invoke its handler. # special treatment of some top-level options - local command='fig' + local command='docker-compose' local counter=1 - local fig_file fig_project + local compose_file compose_project while [ $counter -lt $cword ]; do case "${words[$counter]}" in -f|--file) (( counter++ )) - fig_file="${words[$counter]}" + compose_file="${words[$counter]}" ;; -p|--project-name) (( counter++ )) - fig_project="${words[$counter]}" + compose_project="${words[$counter]}" ;; -*) ;; @@ -307,10 +303,10 @@ _fig() { (( counter++ )) done - local completions_func=_fig_${command} + local completions_func=_docker-compose_${command} declare -F $completions_func >/dev/null && $completions_func return 0 } -complete -F _fig fig +complete -F _docker-compose docker-compose From bd535c76d09e4ca70695624926626b7261e1bd17 Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Sun, 25 Jan 2015 09:47:30 -0800 Subject: [PATCH 0657/4072] Add --service-ports to bash completion 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 53231604f83..a8d56d06ab2 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -212,7 +212,7 @@ _docker-compose_run() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "--allow-insecure-ssl -d --entrypoint -e --no-deps --rm -T" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--allow-insecure-ssl -d --entrypoint -e --no-deps --rm --service-ports -T" -- "$cur" ) ) ;; *) __docker-compose_services_all From 0bc4a28dccd6a569623793fbea7f60202655541d Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Sun, 25 Jan 2015 13:54:57 -0500 Subject: [PATCH 0658/4072] Use latest docker-py. Signed-off-by: Daniel Nephin --- requirements.txt | 2 +- setup.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/requirements.txt b/requirements.txt index 2ccdf59a258..a31a19ae9c6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ PyYAML==3.10 -docker-py==0.6.0 +docker-py==0.7.1 dockerpty==0.3.2 docopt==0.6.1 requests==2.2.1 diff --git a/setup.py b/setup.py index 68c11bd9174..e1e29744bdd 100644 --- a/setup.py +++ b/setup.py @@ -29,8 +29,8 @@ def find_version(*file_paths): 'PyYAML >= 3.10, < 4', 'requests >= 2.2.1, < 3', 'texttable >= 0.8.1, < 0.9', - 'websocket-client >= 0.11.0, < 0.12', - 'docker-py >= 0.6.0, < 0.7', + 'websocket-client >= 0.11.0, < 1.0', + 'docker-py >= 0.6.0, < 0.8', 'dockerpty >= 0.3.2, < 0.4', 'six >= 1.3.0, < 2', ] From 27e4f982facf4e571b4b8b333bed906265e2f6d1 Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Mon, 26 Jan 2015 19:09:18 +0100 Subject: [PATCH 0659/4072] Bash completion supports fig.yml and other legacy filenames Signed-off-by: Harald Albers --- contrib/completion/bash/docker-compose | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index 53231604f83..19eeffdad02 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -17,9 +17,23 @@ # . ~/.docker-compose-completion.sh -# Extracts all service names from docker-compose.yml. +# 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 fig.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() { - awk -F: '/^[a-zA-Z0-9]/{print $1}' "${compose_file:-docker-compose.yml}" + awk -F: '/^[a-zA-Z0-9]/{print $1}' "${compose_file:-$(__docker-compose_compose_file)}" 2>/dev/null } # All services, even those without an existing container @@ -27,10 +41,10 @@ __docker-compose_services_all() { COMPREPLY=( $(compgen -W "$(___docker-compose_all_services_in_compose_file)" -- "$cur") ) } -# All services that have an entry with the given key in their docker-compose.yml section +# 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.yml}" | awk -F: -v key=": +$1:" '$0 ~ key {print $1}' + awk '/^[a-zA-Z0-9]/{printf "\n"};{printf $0;next;}' "${compose_file:-$(__docker-compose_compose_file)}" | awk -F: -v key=": +$1:" '$0 ~ key {print $1}' } # All services that are defined by a Dockerfile reference From 147602741059544af55082c6a2a2efc97b74fcda Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 27 Jan 2015 14:46:58 -0500 Subject: [PATCH 0660/4072] Fix test for image-declared volumes A stray 'fig' got lost in the merge post-rename, meaning the containers weren't being cleaned up properly. Signed-off-by: Aanand Prasad --- 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 e11672bf783..5904eb4ea0e 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -189,7 +189,7 @@ def test_recreate_containers_when_containers_are_stopped(self): def test_recreate_containers_with_image_declared_volume(self): service = Service( - project='figtest', + project='composetest', name='db', client=self.client, build='tests/fixtures/dockerfile-with-volume', From 6c45b6ccdb57b6a9e56aa96bb192e115f3ce067b Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 28 Jan 2015 15:58:49 -0500 Subject: [PATCH 0661/4072] Make sure we're testing blank lines and comments in env files Signed-off-by: Aanand Prasad --- tests/fixtures/env/one.env | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/fixtures/env/one.env b/tests/fixtures/env/one.env index 75a4f62ff8e..45b59fe6337 100644 --- a/tests/fixtures/env/one.env +++ b/tests/fixtures/env/one.env @@ -1,4 +1,11 @@ +# Keep the blank lines and comments in this file, please + ONE=2 TWO=1 + + # (thanks) + THREE=3 + FOO=bar +# FOO=somethingelse From dfc6206d0d3e8f31a1dcbb5c707c7b36688a9450 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 28 Jan 2015 16:13:34 -0500 Subject: [PATCH 0662/4072] Extract get_env_files() Signed-off-by: Aanand Prasad --- compose/service.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/compose/service.py b/compose/service.py index 7095840595f..49bccd31d16 100644 --- a/compose/service.py +++ b/compose/service.py @@ -617,15 +617,18 @@ def split_port(port): return internal_port, (external_ip, external_port or None) +def get_env_files(options): + env_files = options.get('env_file', []) + if not isinstance(env_files, list): + env_files = [env_files] + return env_files + + def merge_environment(options): env = {} - if 'env_file' in options: - if isinstance(options['env_file'], list): - for f in options['env_file']: - env.update(env_vars_from_file(f)) - else: - env.update(env_vars_from_file(options['env_file'])) + for f in get_env_files(options): + env.update(env_vars_from_file(f)) if 'environment' in options: if isinstance(options['environment'], list): From de07e0471ef02d85c248144959fd6932d8adaaf7 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 28 Jan 2015 16:01:48 -0500 Subject: [PATCH 0663/4072] Show a nicer error when the env file doesn't exist Closes #865 Signed-off-by: Aanand Prasad --- compose/service.py | 4 ++++ tests/unit/service_test.py | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/compose/service.py b/compose/service.py index 49bccd31d16..0c9bd357052 100644 --- a/compose/service.py +++ b/compose/service.py @@ -95,6 +95,10 @@ def __init__(self, name, client=None, project='default', links=None, external_li if 'image' in options and 'build' in options: raise ConfigError('Service %s has both an image and build path specified. A service can either be built to image or use an existing image, not both.' % name) + for filename in get_env_files(options): + if not os.path.exists(filename): + raise ConfigError("Couldn't find env file for service %s: %s" % (name, filename)) + supported_options = DOCKER_CONFIG_KEYS + ['build', 'expose', 'external_links'] diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 0a7239b0d82..c7b122fc2c0 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -378,6 +378,10 @@ def test_env_from_multiple_files(self): {'ONE': '2', 'TWO': '1', 'THREE': '3', 'FOO': 'baz', 'DOO': 'dah'} ) + def test_env_nonexistent_file(self): + self.assertRaises(ConfigError, lambda: Service('foo', env_file='tests/fixtures/env/nonexistent.env')) + + @mock.patch.dict(os.environ) def test_resolve_environment_from_file(self): os.environ['FILE_DEF'] = 'E1' From 9bc7604e0e47ab3debe6bf41e86ed6a4db842ca6 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 28 Jan 2015 17:19:27 -0500 Subject: [PATCH 0664/4072] Make sure we're testing uppercase directories properly (OS X is case-sensitive so we can't have fixture dirs which are identically named if you ignore case) Signed-off-by: Aanand Prasad --- tests/fixtures/UpperCaseDir/docker-compose.yml | 6 ++++++ tests/unit/cli_test.py | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) create mode 100644 tests/fixtures/UpperCaseDir/docker-compose.yml diff --git a/tests/fixtures/UpperCaseDir/docker-compose.yml b/tests/fixtures/UpperCaseDir/docker-compose.yml new file mode 100644 index 00000000000..3538ab09716 --- /dev/null +++ b/tests/fixtures/UpperCaseDir/docker-compose.yml @@ -0,0 +1,6 @@ +simple: + image: busybox:latest + command: /bin/sleep 300 +another: + image: busybox:latest + command: /bin/sleep 300 diff --git a/tests/unit/cli_test.py b/tests/unit/cli_test.py index 1154d3de1a3..98a8f9ee03d 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -31,9 +31,9 @@ def test_project_name_with_explicit_base_dir(self): def test_project_name_with_explicit_uppercase_base_dir(self): command = TopLevelCommand() - command.base_dir = 'tests/fixtures/Simple-figfile' + command.base_dir = 'tests/fixtures/UpperCaseDir' project_name = command.get_project_name(command.get_config_path()) - self.assertEquals('simplefigfile', project_name) + self.assertEquals('uppercasedir', project_name) def test_project_name_with_explicit_project_name(self): command = TopLevelCommand() From 7c087f1c07fefeeef092b010841fc36b77eda3bc Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 28 Jan 2015 17:27:53 -0500 Subject: [PATCH 0665/4072] Support fig.y(a)ml and show a deprecation warning Closes #864 Signed-off-by: Aanand Prasad --- compose/cli/command.py | 36 +++++++++++++++++++++-------- compose/cli/errors.py | 8 ++++--- tests/unit/cli_test.py | 51 +++++++++++++++++++++++++++++++++++++----- 3 files changed, 77 insertions(+), 18 deletions(-) diff --git a/compose/cli/command.py b/compose/cli/command.py index f92f95cd628..67b77f31b57 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -1,7 +1,6 @@ from __future__ import unicode_literals from __future__ import absolute_import from requests.exceptions import ConnectionError, SSLError -import errno import logging import os import re @@ -75,8 +74,6 @@ def get_config(self, config_path): with open(config_path, 'r') as fh: return yaml.safe_load(fh) except IOError as e: - if e.errno == errno.ENOENT: - raise errors.ComposeFileNotFound(os.path.basename(e.filename)) raise errors.UserError(six.text_type(e)) def get_project(self, config_path, project_name=None, verbose=False): @@ -110,13 +107,34 @@ def get_config_path(self, file_path=None): if file_path: return os.path.join(self.base_dir, file_path) - if os.path.exists(os.path.join(self.base_dir, 'docker-compose.yaml')): - log.warning("Compose just read the file 'docker-compose.yaml' on startup, rather " - "than 'docker-compose.yml'") + supported_filenames = [ + 'docker-compose.yml', + 'docker-compose.yaml', + 'fig.yml', + 'fig.yaml', + ] + + def expand(filename): + return os.path.join(self.base_dir, filename) + + candidates = [filename for filename in supported_filenames if os.path.exists(expand(filename))] + + if len(candidates) == 0: + raise errors.ComposeFileNotFound(supported_filenames) + + winner = candidates[0] + + if len(candidates) > 1: + log.warning("Found multiple config files with supported names: %s", ", ".join(candidates)) + log.warning("Using %s\n", winner) + + if winner == 'docker-compose.yaml': log.warning("Please be aware that .yml is the expected extension " "in most cases, and using .yaml can cause compatibility " - "issues in future") + "issues in future.\n") - return os.path.join(self.base_dir, 'docker-compose.yaml') + if winner.startswith("fig."): + log.warning("%s is deprecated and will not be supported in future. " + "Please rename your config file to docker-compose.yml\n" % winner) - return os.path.join(self.base_dir, 'docker-compose.yml') + return expand(winner) diff --git a/compose/cli/errors.py b/compose/cli/errors.py index d0d9472466a..d439aa61c66 100644 --- a/compose/cli/errors.py +++ b/compose/cli/errors.py @@ -56,7 +56,9 @@ def __init__(self, url): class ComposeFileNotFound(UserError): - def __init__(self, filename): + def __init__(self, supported_filenames): super(ComposeFileNotFound, self).__init__(""" - Can't find %s. Are you in the right directory? - """ % filename) + Can't find a suitable configuration file. Are you in the right directory? + + Supported filenames: %s + """ % ", ".join(supported_filenames)) diff --git a/tests/unit/cli_test.py b/tests/unit/cli_test.py index 98a8f9ee03d..57e2f327f1b 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -2,12 +2,15 @@ from __future__ import absolute_import import logging import os +import tempfile +import shutil from .. import unittest import mock from compose.cli import main from compose.cli.main import TopLevelCommand +from compose.cli.errors import ComposeFileNotFound from six import StringIO @@ -57,12 +60,30 @@ def test_project_name_from_environment_new_var(self): project_name = command.get_project_name(None) self.assertEquals(project_name, name) - def test_yaml_filename_check(self): - command = TopLevelCommand() - command.base_dir = 'tests/fixtures/longer-filename-composefile' - with mock.patch('compose.cli.command.log', autospec=True) as mock_log: - self.assertTrue(command.get_config_path()) - self.assertEqual(mock_log.warning.call_count, 2) + def test_filename_check(self): + self.assertEqual('docker-compose.yml', get_config_filename_for_files([ + 'docker-compose.yml', + 'docker-compose.yaml', + 'fig.yml', + 'fig.yaml', + ])) + + self.assertEqual('docker-compose.yaml', get_config_filename_for_files([ + 'docker-compose.yaml', + 'fig.yml', + 'fig.yaml', + ])) + + self.assertEqual('fig.yml', get_config_filename_for_files([ + 'fig.yml', + 'fig.yaml', + ])) + + self.assertEqual('fig.yaml', get_config_filename_for_files([ + 'fig.yaml', + ])) + + self.assertRaises(ComposeFileNotFound, lambda: get_config_filename_for_files([])) def test_get_project(self): command = TopLevelCommand() @@ -81,3 +102,21 @@ def test_setup_logging(self): main.setup_logging() self.assertEqual(logging.getLogger().level, logging.DEBUG) self.assertEqual(logging.getLogger('requests').propagate, False) + + +def get_config_filename_for_files(filenames): + project_dir = tempfile.mkdtemp() + try: + make_files(project_dir, filenames) + command = TopLevelCommand() + command.base_dir = project_dir + return os.path.basename(command.get_config_path()) + finally: + shutil.rmtree(project_dir) + + +def make_files(dirname, filenames): + for fname in filenames: + with open(os.path.join(dirname, fname), 'w') as f: + f.write('') + From deb2de3c078d675555b071d735368fbe0a74e632 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 29 Jan 2015 13:12:51 -0500 Subject: [PATCH 0666/4072] Ship 1.1.0-rc2 Signed-off-by: Aanand Prasad --- CHANGES.md | 13 +++++++++++++ compose/__init__.py | 2 +- docs/completion.md | 2 +- docs/install.md | 2 +- 4 files changed, 16 insertions(+), 3 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index c8199cbd16c..e361ef14a50 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,6 +1,17 @@ Change log ========== +1.1.0-rc2 (2015-01-29) +---------------------- + +On top of the changelog for 1.1.0-rc1 (see below), the following bugs have been fixed: + +- When an environment variables file specified with `env_file` doesn't exist, Compose was showing a stack trace instead of a helpful error. + +- Configuration files using the old name (`fig.yml`) were not being read unless explicitly specified with `docker-compose -f`. Compose now reads them and prints a deprecation warning. + +- Bash tab completion now reads `fig.yml` if it's present. + 1.1.0-rc1 (2015-01-20) ---------------------- @@ -28,6 +39,8 @@ Besides that, there’s a lot of new stuff in this release: - docker-compose.yml now supports the `dns_search`, `cap_add`, `cap_drop` and `restart` options, analogous to `docker run`’s `--dns-search`, `--cap-add`, `--cap-drop` and `--restart` options. +- Compose now ships with Bash tab completion - see the installation and usage docs at https://github.com/docker/fig/blob/1.1.0-rc1/docs/completion.md + - A number of bugs have been fixed - see the milestone for details: https://github.com/docker/fig/issues?q=milestone%3A1.1.0+ 1.0.1 (2014-11-04) diff --git a/compose/__init__.py b/compose/__init__.py index 4ed232c669e..0f4cc72a4cd 100644 --- a/compose/__init__.py +++ b/compose/__init__.py @@ -1,4 +1,4 @@ from __future__ import unicode_literals from .service import Service # noqa:flake8 -__version__ = '1.1.0-rc1' +__version__ = '1.1.0-rc2' diff --git a/docs/completion.md b/docs/completion.md index 2710a635c94..351e5764973 100644 --- a/docs/completion.md +++ b/docs/completion.md @@ -17,7 +17,7 @@ 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/docker-compose/master/contrib/completion/bash/docker-compose > /etc/bash_completion.d/docker-compose + curl -L https://raw.githubusercontent.com/docker/fig/1.1.0-rc2/contrib/completion/bash/docker-compose > /etc/bash_completion.d/docker-compose Completion will be available upon next login. diff --git a/docs/install.md b/docs/install.md index 4c35e12aa3c..b8d26d3875a 100644 --- a/docs/install.md +++ b/docs/install.md @@ -18,7 +18,7 @@ There are also guides for [Ubuntu](https://docs.docker.com/installation/ubuntuli Next, install Compose: - curl -L https://github.com/docker/docker-compose/releases/download/1.1.0-rc1/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose; chmod +x /usr/local/bin/docker-compose + curl -L https://github.com/docker/fig/releases/download/1.1.0-rc2/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose; chmod +x /usr/local/bin/docker-compose Optionally, install [command completion](completion.html) for the bash shell. From d8d0fd6dc9d8f73b83864797fae93590156381c4 Mon Sep 17 00:00:00 2001 From: Sven Dowideit Date: Mon, 19 Jan 2015 15:46:23 +1000 Subject: [PATCH 0667/4072] Add Docker docs.docker.com meta-data, and reflow to 80-chars to simplify github diffs Signed-off-by: Sven Dowideit --- docs/cli.md | 63 +++++++++++++++++++++++++++++++++++-------------- docs/index.md | 47 ++++++++++++++++++++++++++---------- docs/install.md | 19 ++++++++++----- docs/yml.md | 55 +++++++++++++++++++++++++++++------------- 4 files changed, 130 insertions(+), 54 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 8fd4b800e11..0d39cb12ec2 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1,12 +1,15 @@ --- layout: default title: Compose CLI reference +page_title: Compose CLI reference +page_description: Compose CLI reference +page_keywords: fig, composition, compose, docker --- -CLI reference -============= +# CLI reference -Most commands are run against one or more services. If the service is omitted, it will apply to all services. +Most commands are run against one or more services. If the service is omitted, +it will apply to all services. Run `docker-compose [COMMAND] --help` for full usage. @@ -34,7 +37,9 @@ Run `docker-compose [COMMAND] --help` for full usage. Build or rebuild services. -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, you can run `docker-compose build` to rebuild it. +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, you +can run `docker-compose build` to rebuild it. ### help @@ -42,7 +47,8 @@ Get help on a command. ### kill -Force stop running containers by sending a `SIGKILL` signal. Optionally the signal can be passed, for example: +Force stop running containers by sending a `SIGKILL` signal. Optionally the signal +can be passed, for example: $ docker-compose kill -s SIGINT @@ -77,17 +83,24 @@ For example: By default, linked services will be started, unless they are already running. -One-off commands are started in new containers with the same configuration as a normal container for that service, so volumes, links, etc will all be created as expected. The only thing different to a normal container is the command will be overridden with the one specified and by default no ports will be created in case they collide. +One-off commands are started in new containers with the same configuration as a +normal container for that service, so volumes, links, etc will all be created as +expected. The only thing different to a normal container is the command will be +overridden with the one specified and by default no ports will be created in case +they collide. -Links are also created between one-off commands and the other containers for that service so you can do stuff like this: +Links are also created between one-off commands and the other containers for that +service so you can do stuff like this: $ docker-compose run db psql -h db -U docker -If you do not want linked containers to be started when running the one-off command, specify the `--no-deps` flag: +If you do not want linked containers to be started when running the one-off command, +specify the `--no-deps` flag: $ docker-compose run --no-deps web python manage.py shell -If you want the service's ports to be created and mapped to the host, specify the `--service-ports` flag: +If you want the service's ports to be created and mapped to the host, specify the +`--service-ports` flag: $ docker-compose run --service-ports web python manage.py shell ### scale @@ -105,7 +118,8 @@ Start existing containers for a service. ### stop -Stop running containers without removing them. They can be started again with `docker-compose start`. +Stop running containers without removing them. They can be started again with +`docker-compose start`. ### up @@ -113,9 +127,15 @@ Build, (re)create, start and attach to containers for a service. Linked services will be started, unless they are already running. -By default, `docker-compose up` will aggregate the output of each container, and when it exits, all containers will be stopped. If you run `docker-compose up -d`, it'll start the containers in the background and leave them running. +By default, `docker-compose up` will aggregate the output of each container, and when +it exits, all containers will be stopped. If you run `docker-compose up -d`, it'll +start the containers in the background and leave them running. -By default if there are existing containers for a service, `docker-compose up` will stop and recreate them (preserving mounted volumes with [volumes-from]), so that changes in `docker-compose.yml` are picked up. If you do no want containers to be stopped and recreated, use `docker-compose up --no-recreate`. This will still start any stopped containers, if needed. +By default if there are existing containers for a service, `docker-compose up` will +stop and recreate them (preserving mounted volumes with [volumes-from]), so that +changes in `docker-compose.yml` are picked up. If you do not want containers to be +stopped and recreated, use `docker-compose up --no-recreate`. This will still start +any stopped containers, if needed. [volumes-from]: http://docs.docker.io/en/latest/use/working_with_volumes/ @@ -124,24 +144,31 @@ By default if there are existing containers for a service, `docker-compose up` w Several environment variables can be used to configure Compose's behaviour. -Variables starting with `DOCKER_` are the same as those used to configure the Docker command-line client. If you're using boot2docker, `$(boot2docker shellinit)` will set them to their correct values. +Variables starting with `DOCKER_` are the same as those used to configure the +Docker command-line client. If you're using boot2docker, `$(boot2docker shellinit)` +will set them to their correct values. ### FIG\_PROJECT\_NAME -Set the project name, which is prepended to the name of every container started by Compose. Defaults to the `basename` of the current working directory. +Set the project name, which is prepended to the name of every container started by +Compose. Defaults to the `basename` of the current working directory. ### FIG\_FILE -Set the path to the `docker-compose.yml` to use. Defaults to `docker-compose.yml` in the current working directory. +Set the path to the `docker-compose.yml` to use. Defaults to `docker-compose.yml` +in the current working directory. ### DOCKER\_HOST -Set the URL to the docker daemon. Defaults to `unix:///var/run/docker.sock`, as with the docker client. +Set the URL to the docker daemon. Defaults to `unix:///var/run/docker.sock`, as +with the docker client. ### DOCKER\_TLS\_VERIFY -When set to anything other than an empty string, enables TLS communication with the daemon. +When set to anything other than an empty string, enables TLS communication with +the daemon. ### DOCKER\_CERT\_PATH -Configure the path to the `ca.pem`, `cert.pem` and `key.pem` files used for TLS verification. Defaults to `~/.docker`. +Configure the path to the `ca.pem`, `cert.pem` and `key.pem` files used for TLS +verification. Defaults to `~/.docker`. diff --git a/docs/index.md b/docs/index.md index 00f48be9edb..7f20624e2e5 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,8 +1,13 @@ --- layout: default title: Compose: Multi-container orchestration for Docker +page_title: Compose: Multi-container orchestration for Docker +page_description: Compose: Multi-container orchestration for Docker +page_keywords: fig, composition, compose, docker --- +# Fast, isolated development environments using Docker. + Define your app's environment with a `Dockerfile` so it can be reproduced anywhere: FROM python:2.7 @@ -10,7 +15,8 @@ Define your app's environment with a `Dockerfile` so it can be reproduced anywhe WORKDIR /code RUN pip install -r requirements.txt -Define the services that make up your app in `docker-compose.yml` so they can be run together in an isolated environment: +Define the services that make up your app in `docker-compose.yml` so they can be +run together in an isolated environment: ```yaml web: @@ -36,10 +42,10 @@ There are commands to: - run a one-off command on a service -Quick start ------------ +## Quick start -Let's get a basic Python web app running on Compose. It assumes a little knowledge of Python, but the concepts should be clear if you're not familiar with it. +Let's get a basic Python web app running on Compose. It assumes a little knowledge +of Python, but the concepts should be clear if you're not familiar with it. First, [install Docker and Compose](install.html). @@ -48,7 +54,8 @@ You'll want to make a directory for the project: $ mkdir composetest $ cd composetest -Inside this directory, create `app.py`, a simple web app that uses the Flask framework and increments a value in Redis: +Inside this directory, create `app.py`, a simple web app that uses the Flask +framework and increments a value in Redis: ```python from flask import Flask @@ -71,14 +78,18 @@ We define our Python dependencies in a file called `requirements.txt`: flask redis -Next, we want to create a Docker image containing all of our app's dependencies. We specify how to build one using a file called `Dockerfile`: +Next, we want to create a Docker image containing all of our app's dependencies. +We specify how to build one using a file called `Dockerfile`: FROM python:2.7 ADD . /code WORKDIR /code RUN pip install -r requirements.txt -This tells Docker to install Python, our code and our Python dependencies inside a Docker image. For more information on how to write Dockerfiles, see the [Docker user guide](https://docs.docker.com/userguide/dockerimages/#building-an-image-from-a-dockerfile) and the [Dockerfile reference](http://docs.docker.com/reference/builder/). +This tells Docker to install Python, our code and our Python dependencies inside +a Docker image. For more information on how to write Dockerfiles, see the +[Docker user guide](https://docs.docker.com/userguide/dockerimages/#building-an-image-from-a-dockerfile) +and the [Dockerfile reference](http://docs.docker.com/reference/builder/). We then define a set of services using `docker-compose.yml`: @@ -96,7 +107,11 @@ We then define a set of services using `docker-compose.yml`: This defines two services: - - `web`, which is built from `Dockerfile` in the current directory. It also says to run the command `python app.py` inside the image, forward the exposed port 5000 on the container to port 5000 on the host machine, connect up the Redis service, and mount the current directory inside the container so we can work on code without having to rebuild the image. + - `web`, which is built from `Dockerfile` in the current directory. It also says + to run the command `python app.py` inside the image, forward the exposed port + 5000 on the container to port 5000 on the host machine, connect up the Redis + service, and mount the current directory inside the container so we can work + on code without having to rebuild the image. - `redis`, which uses the public image [redis](https://registry.hub.docker.com/_/redis/). Now if we run `docker-compose up`, it'll pull a Redis image, build an image for our own code, and start everything up: @@ -109,9 +124,11 @@ Now if we run `docker-compose up`, it'll pull a Redis image, build an image for 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/ -The web app should now be listening on port 5000 on your docker daemon (if you're using boot2docker, `boot2docker ip` will tell you its address). +The web app should now be listening on port 5000 on your docker daemon (if you're +using boot2docker, `boot2docker ip` will tell you its address). -If you want to run your services in the background, you can pass the `-d` flag to `docker-compose up` and use `docker-compose ps` to see what is currently running: +If you want to run your services in the background, you can pass the `-d` flag to +`docker-compose up` and use `docker-compose ps` to see what is currently running: $ docker-compose up -d Starting composetest_redis_1... @@ -122,15 +139,19 @@ If you want to run your services in the background, you can pass the `-d` flag t composetest_redis_1 /usr/local/bin/run Up composetest_web_1 /bin/sh -c python app.py Up 5000->5000/tcp -`docker-compose run` 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` 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` other commands that are available. -If you started Compose with `docker-compose up -d`, you'll probably want to stop your services once you've finished with them: +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 -That's more-or-less how Compose works. See the reference section below for full details on the commands, configuration file and environment variables. If you have any thoughts or suggestions, [open an issue on GitHub](https://github.com/docker/docker-compose). +That's more-or-less how Compose works. See the reference section below for full +details on the commands, configuration file and environment variables. If you +have any thoughts or suggestions, [open an issue on GitHub](https://github.com/docker/docker-compose). diff --git a/docs/install.md b/docs/install.md index b8d26d3875a..4509822aafe 100644 --- a/docs/install.md +++ b/docs/install.md @@ -1,20 +1,26 @@ --- layout: default title: Installing Compose +page_title: Installing Compose +page_description: Installing Compose +page_keywords: fig, composition, compose, docker --- -Installing Compose -============== +# Installing Compose First, install Docker version 1.3 or greater. -If you're on OS X, you can use the [OS X installer](https://docs.docker.com/installation/mac/) to install both Docker and boot2docker. Once boot2docker is running, set the environment variables that'll configure Docker and Compose to talk to it: +If you're on OS X, you can use the [OS X installer](https://docs.docker.com/installation/mac/) +to install both Docker and boot2docker. Once boot2docker is running, set the environment +variables that'll configure Docker and Compose to talk to it: $(boot2docker shellinit) -To persist the environment variables across shell sessions, you can add that line to your `~/.bashrc` file. +To persist the environment variables across shell sessions, you can add that line +to your `~/.bashrc` file. -There are also guides for [Ubuntu](https://docs.docker.com/installation/ubuntulinux/) and [other platforms](https://docs.docker.com/installation/) in Docker’s documentation. +There are also guides for [Ubuntu](https://docs.docker.com/installation/ubuntulinux/) +and [other platforms](https://docs.docker.com/installation/) in Docker`s documentation. Next, install Compose: @@ -22,7 +28,8 @@ Next, install Compose: Optionally, install [command completion](completion.html) for the bash shell. -Releases are available for OS X and 64-bit Linux. Compose is also available as a Python package if you're on another platform (or if you prefer that sort of thing): +Releases are available for OS X and 64-bit Linux. Compose is also available as a Python +package if you're on another platform (or if you prefer that sort of thing): $ sudo pip install -U docker-compose diff --git a/docs/yml.md b/docs/yml.md index e3072d7979e..228fe2ce517 100644 --- a/docs/yml.md +++ b/docs/yml.md @@ -1,18 +1,25 @@ --- layout: default title: docker-compose.yml reference +page_title: docker-compose.yml reference +page_description: docker-compose.yml reference +page_keywords: fig, composition, compose, docker --- -docker-compose.yml reference -================= +# docker-compose.yml reference -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. +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. -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`. +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`. -###image +### image -Tag or partial image ID. Can be local or remote - Compose will attempt to pull if it doesn't exist locally. +Tag or partial image ID. Can be local or remote - Compose will attempt to +pull if it doesn't exist locally. ``` image: ubuntu @@ -22,7 +29,8 @@ image: a4bc65fd ### build -Path to a directory containing a Dockerfile. Compose will build and tag it with a generated name, and use that image thereafter. +Path to a directory containing a Dockerfile. Compose will build and tag it +with a generated name, and use that image thereafter. ``` build: /path/to/build/dir @@ -39,7 +47,9 @@ command: bundle exec thin -p 3000 ### links -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). +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). ``` links: @@ -48,7 +58,8 @@ links: - redis ``` -An entry with the alias' name will be created in `/etc/hosts` inside containers for this service, e.g: +An entry with the alias' name will be created in `/etc/hosts` inside containers +for this service, e.g: ``` 172.17.2.186 db @@ -56,12 +67,15 @@ An entry with the alias' name will be created in `/etc/hosts` inside containers 172.17.2.187 redis ``` -Environment variables will also be created - see the [environment variable reference](env.html) for details. +Environment variables will also be created - see the [environment variable +reference](env.html) for details. ### 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`). +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: @@ -72,9 +86,13 @@ external_links: ### ports -Expose ports. Either specify both ports (`HOST:CONTAINER`), or just the container port (a random host port will be chosen). +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. +> **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: @@ -86,7 +104,8 @@ ports: ### 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 ports without publishing them to the host machine - they'll only be +accessible to linked services. Only the internal port can be specified. ``` expose: @@ -120,7 +139,8 @@ volumes_from: Add environment variables. You can use either an array or a dictionary. -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 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: @@ -196,7 +216,8 @@ dns_search: ### working\_dir, entrypoint, user, hostname, domainname, mem\_limit, privileged, restart, stdin\_open, tty, cpu\_shares -Each of these is a single value, analogous to its [docker run](https://docs.docker.com/reference/run/) counterpart. +Each of these is a single value, analogous to its +[docker run](https://docs.docker.com/reference/run/) counterpart. ``` cpu_shares: 73 From 461f1ad5d5a4dd02e259d6fb4699dc0f5dcf76d1 Mon Sep 17 00:00:00 2001 From: Fred Lifton Date: Thu, 29 Jan 2015 18:21:49 -0800 Subject: [PATCH 0668/4072] Edit and revision of overview & quick start doc Signed-off-by: Fred Lifton --- docs/index.md | 98 ++++++++++++++++++++++++++++----------------------- 1 file changed, 54 insertions(+), 44 deletions(-) diff --git a/docs/index.md b/docs/index.md index 7f20624e2e5..cdde1da7c45 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,22 +1,24 @@ ---- -layout: default -title: Compose: Multi-container orchestration for Docker page_title: Compose: Multi-container orchestration for Docker -page_description: Compose: Multi-container orchestration for Docker -page_keywords: fig, composition, compose, docker ---- +page_description: Introduction and Overview of Compose +page_keywords: documentation, docs, docker, compose, orchestration, containers -# Fast, isolated development environments using Docker. -Define your app's environment with a `Dockerfile` so it can be reproduced anywhere: +## Overview + +Compose is a tool that allows you to orchestrate multiple Docker containers. With Compose, you can build clusters of containers which provide the resources (services, volumes, etc.) needed to build and run a complete distributed application. + +You can use Compose to build your app with containers hosted locally, or on a remote server, including cloud-based instances. Compose can also be used to deploy code to production. + +Using Compose is basically a three-step process. + +First, you define your app's environment with a `Dockerfile` so it can be reproduced anywhere: FROM python:2.7 ADD . /code WORKDIR /code RUN pip install -r requirements.txt -Define the services that make up your app in `docker-compose.yml` so they can be -run together in an isolated environment: +Next, you define the services that make up your app in `docker-compose.yml` so they can be run together in an isolated environment: ```yaml web: @@ -32,24 +34,25 @@ db: (No more installing Postgres on your laptop!) -Then type `docker-compose up`, and Compose will start and run your entire app. +Lastly, run `docker-compose up` and Compose will start and run your entire app. -There are commands to: +Compose includes commands to: - - start, stop and rebuild services - - view the status of running services - - tail running services' log output - - run a one-off command on a service + * Start, stop and rebuild services + * View the status of running services + * tail the log output of running services + * run a one-off command on a service ## Quick start -Let's get a basic Python web app running on Compose. It assumes a little knowledge -of Python, but the concepts should be clear if you're not familiar with it. +Let's get started with a walkthrough of getting a simple Python web app running on Compose. It assumes a little knowledge of Python, but the concepts demonstrated here should be understandable even if you're not familiar with Python. + +### Installation and set-up First, [install Docker and Compose](install.html). -You'll want to make a directory for the project: +Next, you'll want to make a directory for the project: $ mkdir composetest $ cd composetest @@ -73,25 +76,29 @@ if __name__ == "__main__": app.run(host="0.0.0.0", debug=True) ``` -We define our Python dependencies in a file called `requirements.txt`: +Next, define the Python dependencies in a file called `requirements.txt`: flask redis -Next, we want to create a Docker image containing all of our app's dependencies. -We specify how to build one using a file called `Dockerfile`: +### Create a Docker image + +Now, create a Docker image containing all of your app's dependencies. You +specify how to build the image using a file called [`Dockerfile`](http://docs.docker.com/reference/builder/): FROM python:2.7 ADD . /code WORKDIR /code RUN pip install -r requirements.txt -This tells Docker to install Python, our code and our Python dependencies inside +This tells Docker to include Python, your code, and your Python dependencies in a Docker image. For more information on how to write Dockerfiles, see the -[Docker user guide](https://docs.docker.com/userguide/dockerimages/#building-an-image-from-a-dockerfile) -and the [Dockerfile reference](http://docs.docker.com/reference/builder/). +[Docker user guide](https://docs.docker.com/userguide/dockerimages/#building-an-image-from-a-dockerfile) and the +[Dockerfile reference](http://docs.docker.com/reference/builder/). + +### Define services -We then define a set of services using `docker-compose.yml`: +Next, define a set of services using `docker-compose.yml`: web: build: . @@ -107,14 +114,18 @@ We then define a set of services using `docker-compose.yml`: This defines two services: - - `web`, which is built from `Dockerfile` in the current directory. It also says - to run the command `python app.py` inside the image, forward the exposed port - 5000 on the container to port 5000 on the host machine, connect up the Redis - service, and mount the current directory inside the container so we can work - on code without having to rebuild the image. - - `redis`, which uses the public image [redis](https://registry.hub.docker.com/_/redis/). + - `web`, which is built from the `Dockerfile` in the current directory. It also + says to run the command `python app.py` inside the image, forward the exposed +port 5000 on the container to port 5000 on the host machine, connect up the +Redis service, and mount the current directory inside the container so we can +work on code without having to rebuild the image. + - `redis`, which uses the public image [redis](https://registry.hub.docker.com/_/redis/), which gets pulled from the + Docker Hub registry. -Now if we run `docker-compose up`, it'll pull a Redis image, build an image for our own code, and start everything up: +### Build and run your app with Compose + +Now, when you run `docker-compose up`, Compose will pull a Redis image, build an +image for your code, and start everything up: $ docker-compose up Pulling image redis... @@ -124,11 +135,12 @@ Now if we run `docker-compose up`, it'll pull a Redis image, build an image for 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/ -The web app should now be listening on port 5000 on your docker daemon (if you're -using boot2docker, `boot2docker ip` will tell you its address). +The web app should now be listening on port 5000 on your docker daemon (if +you're using boot2docker, `boot2docker ip` will tell you its address). -If you want to run your services in the background, you can pass the `-d` flag to -`docker-compose up` and use `docker-compose ps` to see what is currently running: +If you want to run your services in the background, you can pass the `-d` flag +(for daemon mode) to `docker-compose up` and use `docker-compose ps` to see what +is currently running: $ docker-compose up -d Starting composetest_redis_1... @@ -139,19 +151,17 @@ If you want to run your services in the background, you can pass the `-d` flag t composetest_redis_1 /usr/local/bin/run Up composetest_web_1 /bin/sh -c python app.py Up 5000->5000/tcp -`docker-compose run` allows you to run one-off commands for your services. For -example, to see what environment variables are available to the `web` service: +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` other commands that are available. +See `docker-compose --help` to see other 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 -That's more-or-less how Compose works. See the reference section below for full -details on the commands, configuration file and environment variables. If you -have any thoughts or suggestions, [open an issue on GitHub](https://github.com/docker/docker-compose). +At this point, you have seen the basics of how Compose works. See the reference section for complete details on the commands, configuration file and environment variables. From 3b7ea5c0555daaae725dd7d5403661d82459cfed Mon Sep 17 00:00:00 2001 From: Sven Dowideit Date: Fri, 30 Jan 2015 21:27:57 +1000 Subject: [PATCH 0669/4072] resolve most of my comments Signed-off-by: Sven Dowideit --- docs/index.md | 57 +++++++++++++++++++++++++++++++++------------------ 1 file changed, 37 insertions(+), 20 deletions(-) diff --git a/docs/index.md b/docs/index.md index cdde1da7c45..ffe4d5b147b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,24 +1,32 @@ page_title: Compose: Multi-container orchestration for Docker page_description: Introduction and Overview of Compose -page_keywords: documentation, docs, docker, compose, orchestration, containers +page_keywords: documentation, docs, docker, compose, orchestration, containers ## Overview -Compose is a tool that allows you to orchestrate multiple Docker containers. With Compose, you can build clusters of containers which provide the resources (services, volumes, etc.) needed to build and run a complete distributed application. +Compose is a tool that allows you to orchestrate multiple Docker containers. +With Compose, you can build clusters of containers which provide the resources +(services, volumes, etc.) needed to build and run a complete distributed +application. -You can use Compose to build your app with containers hosted locally, or on a remote server, including cloud-based instances. Compose can also be used to deploy code to production. +You can use Compose to build your app with containers hosted locally, or on a +remote server, including cloud-based instances. Compose can also be used to +deploy code to production. Using Compose is basically a three-step process. - -First, you define your app's environment with a `Dockerfile` so it can be reproduced anywhere: + +First, you define your app's environment with a `Dockerfile` so it can be +reproduced anywhere: FROM python:2.7 - ADD . /code WORKDIR /code + ADD rements.txt /code/ RUN pip install -r requirements.txt + ADD . /code -Next, you define the services that make up your app in `docker-compose.yml` so they can be run together in an isolated environment: +Next, you define the services that make up your app in `docker-compose.yml` so +they can be run together in an isolated environment: ```yaml web: @@ -46,7 +54,10 @@ Compose includes commands to: ## Quick start -Let's get started with a walkthrough of getting a simple Python web app running on Compose. It assumes a little knowledge of Python, but the concepts demonstrated here should be understandable even if you're not familiar with Python. +Let's get started with a walkthrough of getting a simple Python web app running +on Compose. It assumes a little knowledge of Python, but the concepts +demonstrated here should be understandable even if you're not familiar with +Python. ### Installation and set-up @@ -84,7 +95,8 @@ Next, define the Python dependencies in a file called `requirements.txt`: ### Create a Docker image Now, create a Docker image containing all of your app's dependencies. You -specify how to build the image using a file called [`Dockerfile`](http://docs.docker.com/reference/builder/): +specify how to build the image using a file called +[`Dockerfile`](http://docs.docker.com/reference/builder/): FROM python:2.7 ADD . /code @@ -93,7 +105,9 @@ specify how to build the image using a file called [`Dockerfile`](http://docs.do This tells Docker to include Python, your code, and your Python dependencies in a Docker image. For more information on how to write Dockerfiles, see the -[Docker user guide](https://docs.docker.com/userguide/dockerimages/#building-an-image-from-a-dockerfile) and the +[Docker user +guide](https://docs.docker.com/userguide/dockerimages/#building-an-image-from-a-dockerfile) +and the [Dockerfile reference](http://docs.docker.com/reference/builder/). ### Define services @@ -115,12 +129,13 @@ Next, define a set of services using `docker-compose.yml`: This defines two services: - `web`, which is built from the `Dockerfile` in the current directory. It also - says to run the command `python app.py` inside the image, forward the exposed -port 5000 on the container to port 5000 on the host machine, connect up the -Redis service, and mount the current directory inside the container so we can -work on code without having to rebuild the image. - - `redis`, which uses the public image [redis](https://registry.hub.docker.com/_/redis/), which gets pulled from the - Docker Hub registry. + says to run the command `python app.py` inside the image, forward the exposed + port 5000 on the container to port 5000 on the host machine, connect up the + Redis service, and mount the current directory inside the container so we can + work on code without having to rebuild the image. + - `redis`, which uses the public image + [redis](https://registry.hub.docker.com/_/redis/), which gets pulled from the + Docker Hub registry. ### Build and run your app with Compose @@ -135,8 +150,8 @@ image for your code, and start everything up: 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/ -The web app should now be listening on port 5000 on your docker daemon (if -you're using boot2docker, `boot2docker ip` will tell you its address). +The web app should now be listening on port 5000 on your Docker daemon host (if +you're using Boot2docker, `boot2docker ip` will tell you its address). If you want to run your services in the background, you can pass the `-d` flag (for daemon mode) to `docker-compose up` and use `docker-compose ps` to see what @@ -146,7 +161,7 @@ is currently running: Starting composetest_redis_1... Starting composetest_web_1... $ docker-compose ps - Name Command State Ports + 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 @@ -164,4 +179,6 @@ your services once you've finished with them: $ docker-compose stop -At this point, you have seen the basics of how Compose works. See the reference section for complete details on the commands, configuration file and environment variables. +At this point, you have seen the basics of how Compose works. See the reference +section for complete details on the commands, configuration file and environment +variables. From 75247e5a54736debaa72ca115ec2fffecb376da9 Mon Sep 17 00:00:00 2001 From: Ashley Penney Date: Sat, 31 Jan 2015 20:00:04 -0500 Subject: [PATCH 0670/4072] Fix a small typo. --- docs/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/index.md b/docs/index.md index ffe4d5b147b..b295032084f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -21,7 +21,7 @@ reproduced anywhere: FROM python:2.7 WORKDIR /code - ADD rements.txt /code/ + ADD requirements.txt /code/ RUN pip install -r requirements.txt ADD . /code From 45b8d526ba94972c1d9b1f2256c575afe5b1c107 Mon Sep 17 00:00:00 2001 From: Christophe Labouisse Date: Sun, 1 Feb 2015 16:59:21 +0100 Subject: [PATCH 0671/4072] Add missing cpu_shares option for rel. 1.1.0-rc1 Signed-off-by: Christophe Labouisse --- CHANGES.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index e361ef14a50..83eb94eba48 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -37,7 +37,7 @@ Besides that, there’s a lot of new stuff in this release: - docker-compose.yml now has an `env_file` key, analogous to `docker run --env-file`, letting you specify multiple environment variables in a separate file. This is great if you have a lot of them, or if you want to keep sensitive information out of version control. -- docker-compose.yml now supports the `dns_search`, `cap_add`, `cap_drop` and `restart` options, analogous to `docker run`’s `--dns-search`, `--cap-add`, `--cap-drop` and `--restart` options. +- docker-compose.yml now supports the `dns_search`, `cap_add`, `cap_drop`, `cpu_shares` and `restart` options, analogous to `docker run`’s `--dns-search`, `--cap-add`, `--cap-drop`, `--cpu-shares` and `--restart` options. - Compose now ships with Bash tab completion - see the installation and usage docs at https://github.com/docker/fig/blob/1.1.0-rc1/docs/completion.md From b25ed59b1c5a119285771137b7d29fa507caf721 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 5 Feb 2015 16:16:11 -0500 Subject: [PATCH 0672/4072] Tweak intro We shouldn't yet be recommending production use - for now, let's continue to emphasise the development use case and mention staging/CI. Signed-off-by: Aanand Prasad --- docs/index.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/index.md b/docs/index.md index b295032084f..67a93970df5 100644 --- a/docs/index.md +++ b/docs/index.md @@ -11,8 +11,9 @@ With Compose, you can build clusters of containers which provide the resources application. You can use Compose to build your app with containers hosted locally, or on a -remote server, including cloud-based instances. Compose can also be used to -deploy code to production. +remote server, including cloud-based instances - anywhere a Docker daemon can +run. Its primary use case is for development environments, but it can be used +just as easily for staging or CI. Using Compose is basically a three-step process. From 7ff607fb7adc28157db7d215a7f73bfe0f36811a Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 5 Feb 2015 17:36:57 -0500 Subject: [PATCH 0673/4072] Add checklist item for completion script URL to CONTRIBUTING.md Signed-off-by: Aanand Prasad --- CONTRIBUTING.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a9f7e564bb8..b03d1d40ebf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -84,8 +84,9 @@ Note that this only works on Mountain Lion, not Mavericks, due to a [bug in PyIn 1. Open pull request that: - - Updates version in `compose/__init__.py` - - Updates version in `docs/install.md` + - Updates the version in `compose/__init__.py` + - Updates the binary URL in `docs/install.md` + - Updates the script URL in `docs/completion.md` - Adds release notes to `CHANGES.md` 2. Create unpublished GitHub release with release notes From 9f4775c55487b2b22bf415de57ff73d54a243c65 Mon Sep 17 00:00:00 2001 From: Fred Lifton Date: Fri, 30 Jan 2015 17:16:48 -0800 Subject: [PATCH 0674/4072] Revision and edit of Compose install doc Fix rc version to latest. Signed-off-by: Fred Lifton --- docs/install.md | 48 ++++++++++++++++++++++++++++-------------------- 1 file changed, 28 insertions(+), 20 deletions(-) diff --git a/docs/install.md b/docs/install.md index 4509822aafe..4cc6dae7854 100644 --- a/docs/install.md +++ b/docs/install.md @@ -1,36 +1,44 @@ ---- -layout: default -title: Installing Compose page_title: Installing Compose -page_description: Installing Compose -page_keywords: fig, composition, compose, docker ---- +page_description: How to intall Docker Compose +page_keywords: compose, orchestration, install, installation, docker, documentation -# Installing Compose -First, install Docker version 1.3 or greater. +## Installing Compose -If you're on OS X, you can use the [OS X installer](https://docs.docker.com/installation/mac/) -to install both Docker and boot2docker. Once boot2docker is running, set the environment -variables that'll configure Docker and Compose to talk to it: +To install Compose, you'll need to install Docker first. You'll then install +Compose with a `curl` command. + +### Install Docker + +First, you'll need to install Docker version 1.3 or greater. + +If you're on OS X, you can use the +[OS X installer](https://docs.docker.com/installation/mac/) to install both +Docker and the OSX helper app, boot2docker. Once boot2docker is running, set the +environment variables that'll configure Docker and Compose to talk to it: $(boot2docker shellinit) -To persist the environment variables across shell sessions, you can add that line +To persist the environment variables across shell sessions, add the above line to your `~/.bashrc` file. -There are also guides for [Ubuntu](https://docs.docker.com/installation/ubuntulinux/) -and [other platforms](https://docs.docker.com/installation/) in Docker`s documentation. +For complete instructions, or if you are on another platform, consult Docker's +[installation instructions](https://docs.docker.com/installation/). + +### Install Compose -Next, install Compose: +To install Compose, run the following commands: - curl -L https://github.com/docker/fig/releases/download/1.1.0-rc2/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose; chmod +x /usr/local/bin/docker-compose + curl -L https://github.com/docker/docker-compose/releases/download/1.1.0-rc2/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose + chmod +x /usr/local/bin/docker-compose -Optionally, install [command completion](completion.html) for the bash shell. +Optionally, you can also install [command completion](completion.html) for the +bash shell. -Releases are available for OS X and 64-bit Linux. Compose is also available as a Python -package if you're on another platform (or if you prefer that sort of thing): +Compose is available for OS X and 64-bit Linux. If you're on another platform, +Compose can also be installed as a Python package: $ sudo pip install -U docker-compose -That should be all you need! Run `docker-compose --version` to see if it worked. +No further steps are required; Compose should now be successfully installed. +You can test the installation by running `docker-compose --version`. From c39a0b0a2d16d557ed4205fd2b01f1c1bb5e5b6a Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 11 Feb 2015 19:00:13 +0000 Subject: [PATCH 0675/4072] Update pep8, fix errors and freeze requirements-dev.txt Signed-off-by: Aanand Prasad --- compose/cli/log_printer.py | 5 ++++- compose/project.py | 3 ++- requirements-dev.txt | 7 ++++--- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/compose/cli/log_printer.py b/compose/cli/log_printer.py index 9c37cccee22..77fa49ae3c5 100644 --- a/compose/cli/log_printer.py +++ b/compose/cli/log_printer.py @@ -39,9 +39,12 @@ def _make_log_generators(self, monochrome): color_fns = cycle(colors.rainbow()) generators = [] + def no_color(text): + return text + for container in self.containers: if monochrome: - color_fn = lambda s: s + color_fn = no_color else: color_fn = color_fns.next() generators.append(self._make_log_generator(container, color_fn)) diff --git a/compose/project.py b/compose/project.py index 028c6a96655..601604474c7 100644 --- a/compose/project.py +++ b/compose/project.py @@ -15,7 +15,8 @@ def sort_service_dicts(services): temporary_marked = set() sorted_services = [] - get_service_names = lambda links: [link.split(':')[0] for link in links] + def get_service_names(links): + return [link.split(':')[0] for link in links] def visit(n): if n['name'] in temporary_marked: diff --git a/requirements-dev.txt b/requirements-dev.txt index bd5a3949387..7b529623fb8 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,5 +1,6 @@ mock >= 1.0.1 -nose +nose==1.3.4 git+https://github.com/pyinstaller/pyinstaller.git@12e40471c77f588ea5be352f7219c873ddaae056#egg=pyinstaller -unittest2 -flake8 +unittest2==0.8.0 +flake8==2.3.0 +pep8==1.6.1 From 4a336867875b6bcbbd00e0b75ca54ccea1924739 Mon Sep 17 00:00:00 2001 From: Fred Lifton Date: Wed, 11 Feb 2015 18:22:36 -0800 Subject: [PATCH 0676/4072] Revises Compose cli reference --- docs/cli.md | 148 +++++++++++++++++++++++++++------------------------- 1 file changed, 77 insertions(+), 71 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 0d39cb12ec2..39674810da3 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1,68 +1,47 @@ ---- -layout: default -title: Compose CLI reference page_title: Compose CLI reference page_description: Compose CLI reference -page_keywords: fig, composition, compose, docker ---- +page_keywords: fig, composition, compose, docker, orchestration, cli, reference -# CLI reference - -Most commands are run against one or more services. If the service is omitted, -it will apply to all services. - -Run `docker-compose [COMMAND] --help` for full usage. - -## Options - -### --verbose - - Show more output - -### --version - - Print version and exit -### -f, --file FILE +# CLI reference - Specify an alternate compose file (default: docker-compose.yml) +Most commands are run against one or more services. If the service is not +specified, the command will apply to all services. -### -p, --project-name NAME - - Specify an alternate project name (default: directory name) +For full usage information, run `docker-compose [COMMAND] --help`. ## Commands ### build -Build or rebuild services. +Builds or rebuilds services. -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, you -can run `docker-compose build` to rebuild it. +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. ### help -Get help on a command. +Displays help and usage instructions for a command. ### kill -Force stop running containers by sending a `SIGKILL` signal. Optionally the signal -can be passed, for example: +Forces running containers to stop by sending a `SIGKILL` signal. Optionally the +signal can be passed, for example: $ docker-compose kill -s SIGINT ### logs -View output from services. +Displays log output from services. ### port -Print the public port for a port binding +Prints the public port for a port binding ### ps -List containers. +Lists containers. ### pull @@ -70,79 +49,103 @@ Pulls service images. ### rm -Remove stopped service containers. +Removes stopped service containers. ### run -Run a one-off command on a service. +Runs a one-off command on a service. -For example: +For example, $ docker-compose run web python manage.py shell -By default, linked services will be started, unless they are already running. +will start the `web` service and then run `manage.py shell` in python. +Note that by default, linked services will also be started, unless they are +already running. One-off commands are started in new containers with the same configuration as a normal container for that service, so volumes, links, etc will all be created as -expected. The only thing different to a normal container is the command will be -overridden with the one specified and by default no ports will be created in case -they collide. +expected. When using `run`, there are two differences from bringing up a +container normally: + +1. the command will be overridden with the one specified. So, if you run +`docker-compose run web bash`, the container's web command (which could default +to, e.g., `python app.py`) will be overridden to `bash` -Links are also created between one-off commands and the other containers for that -service so you can do stuff like this: +2. by default no ports will be created in case they collide with already opened +ports. + +Links are also created between one-off commands and the other containers which +are part of that service. So, for example, you could run: $ docker-compose run db psql -h db -U docker -If you do not want linked containers to be started when running the one-off command, +This would open up an interactive PostgreSQL shell for the linked `db` container +(which would get created or started as needed). + +If you do not want linked containers to start when running the one-off command, specify the `--no-deps` flag: $ docker-compose run --no-deps web python manage.py shell -If you want the service's ports to be created and mapped to the host, specify the -`--service-ports` flag: +Similarly, if you do want the service's ports to be created and mapped to the +host, specify the `--service-ports` flag: $ docker-compose run --service-ports web python manage.py shell ### scale -Set number of containers to run for a service. +Sets the number of containers to run for a service. -Numbers are specified in the form `service=num` as arguments. -For example: +Numbers are specified as arguments in the form `service=num`. For example: $ docker-compose scale web=2 worker=3 ### start -Start existing containers for a service. +Starts existing containers for a service. ### stop -Stop running containers without removing them. They can be started again with +Stops running containers without removing them. They can be started again with `docker-compose start`. ### up -Build, (re)create, start and attach to containers for a service. +Builds, (re)creates, starts, and attaches to containers for a service. Linked services will be started, unless they are already running. -By default, `docker-compose up` will aggregate the output of each container, and when -it exits, all containers will be stopped. If you run `docker-compose up -d`, it'll -start the containers in the background and leave them running. +By default, `docker-compose up` will aggregate the output of each container and, +when it exits, all containers will be stopped. Running `docker-compose up -d`, +will start the containers in the background and leave them running. -By default if there are existing containers for a service, `docker-compose up` will -stop and recreate them (preserving mounted volumes with [volumes-from]), so that -changes in `docker-compose.yml` are picked up. If you do not want containers to be -stopped and recreated, use `docker-compose up --no-recreate`. This will still start -any stopped containers, if needed. +By default, if there are existing containers for a service, `docker-compose up` will stop and recreate them (preserving mounted volumes with [volumes-from]), so that changes in `docker-compose.yml` are picked up. If you do not want containers stopped and recreated, use `docker-compose up --no-recreate`. This will still start any stopped containers, if needed. [volumes-from]: http://docs.docker.io/en/latest/use/working_with_volumes/ +## Options + +### --verbose + + Shows more output + +### --version + + Prints version and exits + +### -f, --file FILE + + Specifies an alternate Compose yaml file (default: `docker-compose.yml`) + +### -p, --project-name NAME + + Specifies an alternate project name (default: current directory name) + ## Environment Variables -Several environment variables can be used to configure Compose's behaviour. +Several environment variables are available for you to configure Compose's behaviour. Variables starting with `DOCKER_` are the same as those used to configure the Docker command-line client. If you're using boot2docker, `$(boot2docker shellinit)` @@ -150,18 +153,15 @@ will set them to their correct values. ### FIG\_PROJECT\_NAME -Set the project name, which is prepended to the name of every container started by -Compose. Defaults to the `basename` of the current working directory. +Sets the project name, which is prepended to the name of every container started by Compose. Defaults to the `basename` of the current working directory. ### FIG\_FILE -Set the path to the `docker-compose.yml` to use. Defaults to `docker-compose.yml` -in the current working directory. +Sets the path to the `docker-compose.yml` to use. Defaults to `docker-compose.yml` in the current working directory. ### DOCKER\_HOST -Set the URL to the docker daemon. Defaults to `unix:///var/run/docker.sock`, as -with the docker client. +Sets the URL of the docker daemon. As with the Docker client, defaults to `unix:///var/run/docker.sock`. ### DOCKER\_TLS\_VERIFY @@ -170,5 +170,11 @@ the daemon. ### DOCKER\_CERT\_PATH -Configure the path to the `ca.pem`, `cert.pem` and `key.pem` files used for TLS -verification. Defaults to `~/.docker`. +Configures the path to the `ca.pem`, `cert.pem`, and `key.pem` files used for TLS verification. Defaults to `~/.docker`. + +## Compose Docs + +[Installing Compose](http://docs.docker.com/compose/install) +[Intro & Overview]((http://docs.docker.com/compose/userguide) +[Yaml file reference]((http://docs.docker.com/compose/yml) + From 3146fe5e4b957efc4faf76ace74c2305424c4798 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Thu, 12 Feb 2015 13:38:21 +0000 Subject: [PATCH 0677/4072] Tidy up contributing guidelines - Add intro saying we roughly follow the Docker project's guidelines - Link to Docker's signing off docs - Fix formatting at bottom Signed-off-by: Ben Firshman --- CONTRIBUTING.md | 52 +++++-------------------------------------------- 1 file changed, 5 insertions(+), 47 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a9f7e564bb8..b1ead2a6b27 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,12 +1,14 @@ # Contributing to Compose +Compose is a part of the Docker project, and follows the same rules and principles. Take a read of [Docker's contributing guidelines](https://github.com/docker/docker/blob/master/CONTRIBUTING.md) to get an overview. + ## TL;DR Pull requests will need: - Tests - Documentation - - [To be signed off](#sign-your-work) + - [To be signed off](https://github.com/docker/docker/blob/master/CONTRIBUTING.md#sign-your-work) - A logical series of [well written commits](https://github.com/alphagov/styleguides/blob/master/git.md) ## Development environment @@ -24,50 +26,6 @@ that should get you started. $ script/test -## Sign your work - -The sign-off is a simple line at the end of the explanation for the -patch, which certifies that you wrote it or otherwise have the right to -pass it on as an open-source patch. The rules are pretty simple: if you -can certify the below (from [developercertificate.org](http://developercertificate.org/)): - - Developer's Certificate of Origin 1.1 - - By making a contribution to this project, I certify that: - - (a) The contribution was created in whole or in part by me and I - have the right to submit it under the open source license - indicated in the file; or - - (b) The contribution is based upon previous work that, to the best - of my knowledge, is covered under an appropriate open source - license and I have the right under that license to submit that - work with modifications, whether created in whole or in part - by me, under the same open source license (unless I am - permitted to submit under a different license), as indicated - in the file; or - - (c) The contribution was provided directly to me by some other - person who certified (a), (b) or (c) and I have not modified - it. - - (d) I understand and agree that this project and the contribution - are public and that a record of the contribution (including all - personal information I submit with it, including my sign-off) is - maintained indefinitely and may be redistributed consistent with - this project or the open source license(s) involved. - -then you just add a line saying - - Signed-off-by: Random J Developer - -using your real name (sorry, no pseudonyms or anonymous contributions.) - -The easiest way to do this is to use the `--signoff` flag when committing. E.g.: - - - $ git commit --signoff - ## Building binaries Linux: @@ -100,5 +58,5 @@ Note that this only works on Mountain Lion, not Mavericks, due to a [bug in PyIn 7. Upload PyPi package - $ git checkout $VERSION - $ python setup.py sdist upload + $ git checkout $VERSION + $ python setup.py sdist upload From 0a8f9abfae419506e5a0f14721cd975d68eaea73 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Thu, 12 Feb 2015 16:39:59 +0000 Subject: [PATCH 0678/4072] Remove @nathanleclaire as a maintainer Thanks for your help <3 Signed-off-by: Ben Firshman --- MAINTAINERS | 1 - 1 file changed, 1 deletion(-) diff --git a/MAINTAINERS b/MAINTAINERS index 8c98b04c32a..0fd7f81c87f 100644 --- a/MAINTAINERS +++ b/MAINTAINERS @@ -2,4 +2,3 @@ Aanand Prasad (@aanand) Ben Firshman (@bfirsh) Chris Corbyn (@d11wtq) Daniel Nephin (@dnephin) -Nathan LeClaire (@nathanleclaire) From 3a8a25b9b35002368615084a9a1cbc6a6fd0e7a6 Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Fri, 13 Feb 2015 10:25:18 +0100 Subject: [PATCH 0679/4072] Favour yml and yaml extensions in bash completion for -f 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 03721f505f0..a0007fcbdee 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -94,7 +94,7 @@ _docker-compose_build() { _docker-compose_docker-compose() { case "$prev" in --file|-f) - _filedir + _filedir y?(a)ml return ;; --project-name|-p) From 5e8bcd2d29a41322570dc9cb17101b6c07460ed6 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 16 Feb 2015 19:21:17 +0000 Subject: [PATCH 0680/4072] Rejig introduction Signed-off-by: Aanand Prasad --- README.md | 48 +++++++++++++++++++++++++++++------------------- docs/index.md | 36 +++++++++++++++++------------------- 2 files changed, 46 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 6efe779b5e5..4df3bd2116b 100644 --- a/README.md +++ b/README.md @@ -3,17 +3,30 @@ Docker Compose [![wercker status](https://app.wercker.com/status/d5dbac3907301c3d5ce735e2d5e95a5b/s/master "wercker status")](https://app.wercker.com/project/bykey/d5dbac3907301c3d5ce735e2d5e95a5b) -Fast, isolated development environments using Docker. - -Define your app's environment with Docker so it can be reproduced anywhere: - - FROM python:2.7 - ADD . /code - WORKDIR /code - RUN pip install -r requirements.txt - CMD python app.py +Compose is a tool for defining and running complex applications with Docker. +With Compose, you define a multi-container application in a single file, then +spin your application up in a single command which does everything that needs to +be done to get it running. + +Compose is great for development environments, staging servers, and CI. We don't +recommend that you use it in production yet. + +Using Compose is basically a three-step process. + +First, you define your app's environment with a `Dockerfile` so it can be +reproduced anywhere: + +```Dockerfile +FROM python:2.7 +WORKDIR /code +ADD requirements.txt /code/ +RUN pip install -r requirements.txt +ADD . /code +CMD python app.py +``` -Define the services that make up your app so they can be run together in an isolated environment: +Next, you define the services that make up your app in `docker-compose.yml` so +they can be run together in an isolated environment: ```yaml web: @@ -22,21 +35,18 @@ web: - db ports: - "8000:8000" - - "49100:22" db: image: postgres ``` -(No more installing Postgres on your laptop!) - -Then type `docker-compose up`, and Compose will start and run your entire app. +Lastly, run `docker-compose up` and Compose will start and run your entire app. -There are commands to: +Compose has commands for managing the whole lifecycle of your application: - - start, stop and rebuild services - - view the status of running services - - tail running services' log output - - run a one-off command on a service + * 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 Installation and documentation ------------------------------ diff --git a/docs/index.md b/docs/index.md index 67a93970df5..1800ab16127 100644 --- a/docs/index.md +++ b/docs/index.md @@ -5,26 +5,27 @@ page_keywords: documentation, docs, docker, compose, orchestration, containers ## Overview -Compose is a tool that allows you to orchestrate multiple Docker containers. -With Compose, you can build clusters of containers which provide the resources -(services, volumes, etc.) needed to build and run a complete distributed -application. +Compose is a tool for defining and running complex applications with Docker. +With Compose, you define a multi-container application in a single file, then +spin your application up in a single command which does everything that needs to +be done to get it running. -You can use Compose to build your app with containers hosted locally, or on a -remote server, including cloud-based instances - anywhere a Docker daemon can -run. Its primary use case is for development environments, but it can be used -just as easily for staging or CI. +Compose is great for development environments, staging servers, and CI. We don't +recommend that you use it in production yet. Using Compose is basically a three-step process. First, you define your app's environment with a `Dockerfile` so it can be reproduced anywhere: - FROM python:2.7 - WORKDIR /code - ADD requirements.txt /code/ - RUN pip install -r requirements.txt - ADD . /code +```Dockerfile +FROM python:2.7 +WORKDIR /code +ADD requirements.txt /code/ +RUN pip install -r requirements.txt +ADD . /code +CMD python app.py +``` Next, you define the services that make up your app in `docker-compose.yml` so they can be run together in an isolated environment: @@ -32,7 +33,6 @@ they can be run together in an isolated environment: ```yaml web: build: . - command: python app.py links: - db ports: @@ -41,16 +41,14 @@ db: image: postgres ``` -(No more installing Postgres on your laptop!) - Lastly, run `docker-compose up` and Compose will start and run your entire app. -Compose includes commands to: +Compose has commands for managing the whole lifecycle of your application: * Start, stop and rebuild services * View the status of running services - * tail the log output of running services - * run a one-off command on a service + * Stream the log output of running services + * Run a one-off command on a service ## Quick start From a516d61b494f1d4655a066c55e3b1b90f73f8b90 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 18 Feb 2015 11:39:57 +0000 Subject: [PATCH 0681/4072] Update environment variable names in docs Signed-off-by: Aanand Prasad --- docs/cli.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 0d39cb12ec2..cb428418acc 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -148,12 +148,12 @@ Variables starting with `DOCKER_` are the same as those used to configure the Docker command-line client. If you're using boot2docker, `$(boot2docker shellinit)` will set them to their correct values. -### FIG\_PROJECT\_NAME +### COMPOSE\_PROJECT\_NAME Set the project name, which is prepended to the name of every container started by Compose. Defaults to the `basename` of the current working directory. -### FIG\_FILE +### COMPOSE\_FILE Set the path to the `docker-compose.yml` to use. Defaults to `docker-compose.yml` in the current working directory. From d32994c250e241a9e22f133d945b22dc3995d28b Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Thu, 19 Feb 2015 14:34:08 +0000 Subject: [PATCH 0682/4072] Add title to docs index Signed-off-by: Ben Firshman --- docs/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/index.md b/docs/index.md index 1800ab16127..fb0e95ab088 100644 --- a/docs/index.md +++ b/docs/index.md @@ -3,7 +3,7 @@ page_description: Introduction and Overview of Compose page_keywords: documentation, docs, docker, compose, orchestration, containers -## Overview +# Docker Compose Compose is a tool for defining and running complex applications with Docker. With Compose, you define a multi-container application in a single file, then From f78dfa7958e0b436cca04d1a7d0a2e86c2134d6d Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 19 Feb 2015 17:42:33 +0000 Subject: [PATCH 0683/4072] Credit contributors in CHANGES.md Signed-off-by: Aanand Prasad --- CHANGES.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index 83eb94eba48..6aba8f39b01 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -12,6 +12,8 @@ On top of the changelog for 1.1.0-rc1 (see below), the following bugs have been - Bash tab completion now reads `fig.yml` if it's present. +Thanks, @dnephin and @albers! + 1.1.0-rc1 (2015-01-20) ---------------------- @@ -43,6 +45,8 @@ Besides that, there’s a lot of new stuff in this release: - A number of bugs have been fixed - see the milestone for details: https://github.com/docker/fig/issues?q=milestone%3A1.1.0+ +Thanks @dnephin, @squebe, @jbalonso, @raulcd, @benlangfield, @albers, @ggtools, @bersace, @dtenenba, @petercv, @drewkett, @TFenby, @paulRbr, @Aigeruth and @salehe! + 1.0.1 (2014-11-04) ------------------ From 0fa013137260a50044dfcb06d755a60baf3af721 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Wed, 5 Nov 2014 12:07:25 +0000 Subject: [PATCH 0684/4072] Add script which runs Fig inside Docker Signed-off-by: Ben Firshman --- .dockerignore | 1 + script/dev | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+) create mode 100755 script/dev diff --git a/.dockerignore b/.dockerignore index 6b8710a711f..f1b636b3ebd 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1 +1,2 @@ .git +venv diff --git a/script/dev b/script/dev new file mode 100755 index 00000000000..80b3d0131e2 --- /dev/null +++ b/script/dev @@ -0,0 +1,21 @@ +#!/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 $@ From bd320b19fe2211e77d362e07e3714920665df15d Mon Sep 17 00:00:00 2001 From: Sven Dowideit Date: Mon, 23 Feb 2015 13:56:13 +1000 Subject: [PATCH 0685/4072] add ./script/doc to build fig documentation using the docs.docker.com tooling Signed-off-by: Sven Dowideit --- docs/Dockerfile | 15 +++++++++++++++ docs/cli.md | 8 ++++---- docs/django.md | 2 +- docs/env.md | 2 +- docs/index.md | 2 +- docs/install.md | 2 +- docs/mkdocs.yml | 7 +++++++ docs/rails.md | 2 +- docs/wordpress.md | 2 +- docs/yml.md | 2 +- script/docs | 11 +++++++++++ 11 files changed, 44 insertions(+), 11 deletions(-) create mode 100644 docs/Dockerfile create mode 100644 docs/mkdocs.yml create mode 100755 script/docs diff --git a/docs/Dockerfile b/docs/Dockerfile new file mode 100644 index 00000000000..59ef66cdd80 --- /dev/null +++ b/docs/Dockerfile @@ -0,0 +1,15 @@ +FROM docs/base:latest +MAINTAINER Sven Dowideit (@SvenDowideit) + +# to get the git info for this repo +COPY . /src + +# Reset the /docs dir so we can replace the theme meta with the new repo's git info +RUN git reset --hard + +RUN grep "__version" /src/compose/__init__.py | sed "s/.*'\(.*\)'/\1/" > /docs/VERSION +COPY docs/* /docs/sources/compose/ +COPY docs/mkdocs.yml /docs/mkdocs-compose.yml + +# Then build everything together, ready for mkdocs +RUN /docs/build.sh diff --git a/docs/cli.md b/docs/cli.md index 24926bf1a31..9da5835e433 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -172,9 +172,9 @@ the daemon. Configures the path to the `ca.pem`, `cert.pem`, and `key.pem` files used for TLS verification. Defaults to `~/.docker`. -## Compose Docs +## Compose Documentation -[Installing Compose](http://docs.docker.com/compose/install) -[Intro & Overview]((http://docs.docker.com/compose/userguide) -[Yaml file reference]((http://docs.docker.com/compose/yml) +- [Installing Compose](install.md) +- [Intro & Overview](index.md) +- [Yaml file reference](yml.md) diff --git a/docs/django.md b/docs/django.md index 93e778b3c31..3866222372f 100644 --- a/docs/django.md +++ b/docs/django.md @@ -6,7 +6,7 @@ title: Getting started with Compose and Django Getting started with Compose and Django =================================== -Let's use Compose to set up and run a Django/PostgreSQL app. Before starting, you'll need to have [Compose installed](install.html). +Let's use Compose to set up and run a Django/PostgreSQL app. Before starting, you'll need to have [Compose installed](install.md). Let's set up the three files that'll get us started. First, our app is going to be running inside a Docker container which contains all of its dependencies. We can define what goes inside that Docker container using a file called `Dockerfile`. It'll contain this to start with: diff --git a/docs/env.md b/docs/env.md index 8644c8ae64c..2783138963e 100644 --- a/docs/env.md +++ b/docs/env.md @@ -6,7 +6,7 @@ title: Compose environment variables reference 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](yml.html#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](yml.md#links) for details. 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/index.md b/docs/index.md index fb0e95ab088..76765d41ffb 100644 --- a/docs/index.md +++ b/docs/index.md @@ -60,7 +60,7 @@ Python. ### Installation and set-up -First, [install Docker and Compose](install.html). +First, [install Docker and Compose](install.md). Next, you'll want to make a directory for the project: diff --git a/docs/install.md b/docs/install.md index 4cc6dae7854..ef0da087ed1 100644 --- a/docs/install.md +++ b/docs/install.md @@ -32,7 +32,7 @@ To install Compose, run the following commands: curl -L https://github.com/docker/docker-compose/releases/download/1.1.0-rc2/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose chmod +x /usr/local/bin/docker-compose -Optionally, you can also install [command completion](completion.html) for the +Optionally, you can also install [command completion](completion.md) for the bash shell. Compose is available for OS X and 64-bit Linux. If you're on another platform, diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml new file mode 100644 index 00000000000..fc725b28c55 --- /dev/null +++ b/docs/mkdocs.yml @@ -0,0 +1,7 @@ + +- ['compose/index.md', 'User Guide', 'Docker Compose' ] +- ['compose/install.md', 'Installation', 'Docker Compose'] +- ['compose/cli.md', 'Reference', 'Compose command line'] +- ['compose/yml.md', 'Reference', 'Compose yml'] +- ['compose/env.md', 'Reference', 'Compose ENV variables'] +- ['compose/completion.md', 'Reference', 'Compose commandline completion'] diff --git a/docs/rails.md b/docs/rails.md index ca8f876738a..be6b313316e 100644 --- a/docs/rails.md +++ b/docs/rails.md @@ -6,7 +6,7 @@ title: Getting started with Compose and Rails Getting started with Compose and Rails ================================== -We're going to use Compose to set up and run a Rails/PostgreSQL app. Before starting, you'll need to have [Compose installed](install.html). +We're going to use Compose to set up and run a Rails/PostgreSQL app. Before starting, you'll need to have [Compose installed](install.md). Let's set up the three files that'll get us started. First, our app is going to be running inside a Docker container which contains all of its dependencies. We can define what goes inside that Docker container using a file called `Dockerfile`. It'll contain this to start with: diff --git a/docs/wordpress.md b/docs/wordpress.md index 47808fe6a30..0d76668e55b 100644 --- a/docs/wordpress.md +++ b/docs/wordpress.md @@ -6,7 +6,7 @@ title: Getting started with Compose and Wordpress Getting started with Compose and Wordpress ====================================== -Compose makes it nice and easy to run Wordpress in an isolated environment. [Install Compose](install.html), then download Wordpress into the current directory: +Compose makes it nice and easy to run Wordpress in an isolated environment. [Install Compose](install.md), then download Wordpress into the current directory: $ curl https://wordpress.org/latest.tar.gz | tar -xvzf - diff --git a/docs/yml.md b/docs/yml.md index 228fe2ce517..15d3943e7e1 100644 --- a/docs/yml.md +++ b/docs/yml.md @@ -68,7 +68,7 @@ for this service, e.g: ``` Environment variables will also be created - see the [environment variable -reference](env.html) for details. +reference](env.md) for details. ### external_links diff --git a/script/docs b/script/docs new file mode 100755 index 00000000000..31c58861d08 --- /dev/null +++ b/script/docs @@ -0,0 +1,11 @@ +#!/bin/sh +set -ex + +# import the existing docs build cmds from docker/docker +DOCSPORT=8000 +GIT_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null) +DOCKER_DOCS_IMAGE="compose-docs$GIT_BRANCH" +DOCKER_RUN_DOCS="docker run --rm -it -e NOCACHE" + +docker build -t "$DOCKER_DOCS_IMAGE" -f docs/Dockerfile . +$DOCKER_RUN_DOCS -p $DOCSPORT:8000 "$DOCKER_DOCS_IMAGE" mkdocs serve From c3215a1764106ac71fd0327a1fc125fa3423054f Mon Sep 17 00:00:00 2001 From: Sven Dowideit Date: Mon, 23 Feb 2015 16:10:14 +1000 Subject: [PATCH 0686/4072] Add links to the main Compose references Signed-off-by: Sven Dowideit --- docs/index.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/index.md b/docs/index.md index fb0e95ab088..f7bf8a7aa12 100644 --- a/docs/index.md +++ b/docs/index.md @@ -179,5 +179,5 @@ your services once you've finished with them: $ docker-compose stop At this point, you have seen the basics of how Compose works. See the reference -section for complete details on the commands, configuration file and environment -variables. +guides for complete details on the [commands](cli.md), the [configuration +file](yml.md) and [environment variables](env.md). From 34c6920b37a22ea918abe51455ab0e51cf2a9220 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 24 Feb 2015 11:43:14 +0000 Subject: [PATCH 0687/4072] Point at official Docker install instructions rather than repeating them Signed-off-by: Aanand Prasad --- docs/install.md | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/docs/install.md b/docs/install.md index ef0da087ed1..b0c1b0e272f 100644 --- a/docs/install.md +++ b/docs/install.md @@ -10,20 +10,11 @@ Compose with a `curl` command. ### Install Docker -First, you'll need to install Docker version 1.3 or greater. +First, install Docker version 1.3 or greater: -If you're on OS X, you can use the -[OS X installer](https://docs.docker.com/installation/mac/) to install both -Docker and the OSX helper app, boot2docker. Once boot2docker is running, set the -environment variables that'll configure Docker and Compose to talk to it: - - $(boot2docker shellinit) - -To persist the environment variables across shell sessions, add the above line -to your `~/.bashrc` file. - -For complete instructions, or if you are on another platform, consult Docker's -[installation instructions](https://docs.docker.com/installation/). +- [Instructions for Mac OS X](http://docs.docker.com/installation/mac/) +- [Instructions for Ubuntu](http://docs.docker.com/installation/ubuntulinux/) +- [Instructions for other systems](http://docs.docker.com/installation/) ### Install Compose From b2425c1f1e66ba59bd9f8f7c1a8273778521654e Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Tue, 24 Feb 2015 13:39:38 +0000 Subject: [PATCH 0688/4072] Log "creating container" when scaling If an image needs pulling, it just looks like it's hanging. Signed-off-by: Ben Firshman --- compose/service.py | 1 + 1 file changed, 1 insertion(+) diff --git a/compose/service.py b/compose/service.py index 0c9bd357052..eb6e86fb5d1 100644 --- a/compose/service.py +++ b/compose/service.py @@ -178,6 +178,7 @@ def scale(self, desired_num): # Create enough containers containers = self.containers(stopped=True) while len(containers) < desired_num: + log.info("Creating %s..." % self._next_container_name(containers)) containers.append(self.create_container(detach=True)) running_containers = [] From ec2966222a679cda06974844bbce7874311ca822 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 25 Feb 2015 10:22:24 +0000 Subject: [PATCH 0689/4072] Document Swarm integration Signed-off-by: Aanand Prasad --- ROADMAP.md | 2 ++ SWARM.md | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 SWARM.md diff --git a/ROADMAP.md b/ROADMAP.md index 68c858367e5..a74a781ee61 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -12,6 +12,8 @@ Over time we will extend Compose's remit to cover test, staging and production e Compose should integrate really well with Swarm so you can take an application you've developed on your laptop and run it on a Swarm cluster. +The current state of integration is documented in [SWARM.md](SWARM.md). + ## Applications spanning multiple teams 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. diff --git a/SWARM.md b/SWARM.md new file mode 100644 index 00000000000..3e549e560fe --- /dev/null +++ b/SWARM.md @@ -0,0 +1,51 @@ +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, the current extent of integration is minimal: 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, defeating much of the purpose of using Swarm in the first place. + +Still, Compose and Swarm can be useful in a “batch processing” scenario (where a large number of containers need to be spun up and down to do independent computation) or a “shared cluster” scenario (where multiple teams want to deploy apps on a cluster without worrying about where to put them). + +A number of things need to happen before full integration is achieved, which are documented below. + +Re-deploying containers with `docker-compose up` +------------------------------------------------ + +Repeated invocations of `docker-compose up` will not work reliably when used against a Swarm cluster because of an under-the-hood design problem; [this will be fixed](https://github.com/docker/fig/pull/972) in the next version of Compose. For now, containers must be completely removed and re-created: + + $ docker-compose kill + $ docker-compose rm --force + $ docker-compose up + +Links and networking +-------------------- + +The primary thing stopping multi-container apps from working seamlessly on Swarm is getting them to talk to one another: enabling private communication between containers on different hosts hasn’t been solved in a non-hacky way. + +Long-term, networking is [getting overhauled](https://github.com/docker/docker/issues/9983) in such a way that it’ll fit the multi-host model much better. For now, containers on different hosts cannot be linked. In the next version of Compose, linked services will be automatically scheduled on the same host; for now, this must be done manually (see “Co-scheduling containers” below). + +`volumes_from` and `net: container` +----------------------------------- + +For containers to share volumes or a network namespace, they must be scheduled on the same host - this is, after all, inherent to how both volumes and network namespaces work. In the next version of Compose, this co-scheduling will be automatic whenever `volumes_from` or `net: "container:..."` is specified; for now, containers which share volumes or a network namespace must be co-scheduled manually (see “Co-scheduling containers” below). + +Co-scheduling containers +------------------------ + +For now, containers can be manually scheduled on the same host using Swarm’s [affinity filters](https://github.com/docker/swarm/blob/master/scheduler/filter/README.md#affinity-filter). Here’s a simple example: + +```yaml +web: + image: my-web-image + links: ["db"] + environment: + - "affinity:container==myproject_db_*" +db: + image: postgres +``` + +Here, we express an affinity filter on all web containers, saying that each one must run alongside a container whose name begins with `myproject_db_`. + +- `myproject` is the common prefix Compose gives to all containers in your project, which is either generated from the name of the current directory or specified with `-p` or the `DOCKER_COMPOSE_PROJECT_NAME` environment variable. +- `*` is a wildcard, which works just like filename wildcards in a Unix shell. From 5b07c581e0b38f6929437afb6adf255d72ef839c Mon Sep 17 00:00:00 2001 From: Sven Dowideit Date: Wed, 25 Feb 2015 18:43:33 +1000 Subject: [PATCH 0690/4072] Add an index to the bottom of the Compose docs as they're scattered around docs.docker.com Signed-off-by: Sven Dowideit --- docs/cli.md | 11 ++++++----- docs/completion.md | 8 ++++++++ docs/django.md | 8 ++++++++ docs/env.md | 8 ++++++++ docs/index.md | 12 +++++++++--- docs/install.md | 8 ++++++++ docs/rails.md | 9 +++++++++ docs/wordpress.md | 9 +++++++++ docs/yml.md | 14 ++++++++++++-- 9 files changed, 77 insertions(+), 10 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 9da5835e433..30f82177148 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -5,8 +5,8 @@ page_keywords: fig, composition, compose, docker, orchestration, cli, reference # CLI reference -Most commands are run against one or more services. If the service is not -specified, the command will apply to all services. +Most Docker Compose commands are run against one or more services. If +the service is not specified, the command will apply to all services. For full usage information, run `docker-compose [COMMAND] --help`. @@ -172,9 +172,10 @@ the daemon. Configures the path to the `ca.pem`, `cert.pem`, and `key.pem` files used for TLS verification. Defaults to `~/.docker`. -## Compose Documentation +## Compose documentation - [Installing Compose](install.md) -- [Intro & Overview](index.md) +- [User guide](index.md) - [Yaml file reference](yml.md) - +- [Compose environment variables](env.md) +- [Compose command line completion](completion.md) diff --git a/docs/completion.md b/docs/completion.md index 351e5764973..01a3421698c 100644 --- a/docs/completion.md +++ b/docs/completion.md @@ -31,3 +31,11 @@ Depending on what you typed on the command line so far, it will complete - 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 + +- [Installing Compose](install.md) +- [User guide](index.md) +- [Command line reference](cli.md) +- [Yaml file reference](yml.md) +- [Compose environment variables](env.md) diff --git a/docs/django.md b/docs/django.md index 3866222372f..48d3e6d01d5 100644 --- a/docs/django.md +++ b/docs/django.md @@ -89,3 +89,11 @@ You can also run management commands with Docker. To set up your database, for e $ docker-compose run web python manage.py syncdb +## Compose documentation + +- [Installing Compose](install.md) +- [User guide](index.md) +- [Command line reference](cli.md) +- [Yaml file reference](yml.md) +- [Compose environment variables](env.md) +- [Compose command line completion](completion.md) diff --git a/docs/env.md b/docs/env.md index 2783138963e..3fc7b95aac5 100644 --- a/docs/env.md +++ b/docs/env.md @@ -31,3 +31,11 @@ Protocol (tcp or udp), e.g. `DB_PORT_5432_TCP_PROTO=tcp` Fully qualified container name, e.g. `DB_1_NAME=/myapp_web_1/myapp_db_1` [Docker links]: http://docs.docker.com/userguide/dockerlinks/ + +## Compose documentation + +- [Installing Compose](install.md) +- [User guide](index.md) +- [Command line reference](cli.md) +- [Yaml file reference](yml.md) +- [Compose command line completion](completion.md) diff --git a/docs/index.md b/docs/index.md index a9668bdd630..471b10afa28 100644 --- a/docs/index.md +++ b/docs/index.md @@ -178,6 +178,12 @@ your services once you've finished with them: $ docker-compose stop -At this point, you have seen the basics of how Compose works. See the reference -guides for complete details on the [commands](cli.md), the [configuration -file](yml.md) and [environment variables](env.md). +At this point, you have seen the basics of how Compose works. + +## Compose documentation + +- [Installing Compose](install.md) +- [Command line reference](cli.md) +- [Yaml file reference](yml.md) +- [Compose environment variables](env.md) +- [Compose command line completion](completion.md) diff --git a/docs/install.md b/docs/install.md index ef0da087ed1..78242062280 100644 --- a/docs/install.md +++ b/docs/install.md @@ -42,3 +42,11 @@ Compose can also be installed as a Python package: No further steps are required; Compose should now be successfully installed. You can test the installation by running `docker-compose --version`. + +## Compose documentation + +- [User guide](index.md) +- [Command line reference](cli.md) +- [Yaml file reference](yml.md) +- [Compose environment variables](env.md) +- [Compose command line completion](completion.md) diff --git a/docs/rails.md b/docs/rails.md index be6b313316e..67682cf5924 100644 --- a/docs/rails.md +++ b/docs/rails.md @@ -94,3 +94,12 @@ Finally, we just need to create the database. In another terminal, run: $ docker-compose run web rake db:create And we're rolling—your app should now be running on port 3000 on your docker daemon (if you're using boot2docker, `boot2docker ip` will tell you its address). + +## Compose documentation + +- [Installing Compose](install.md) +- [User guide](index.md) +- [Command line reference](cli.md) +- [Yaml file reference](yml.md) +- [Compose environment variables](env.md) +- [Compose command line completion](completion.md) diff --git a/docs/wordpress.md b/docs/wordpress.md index 0d76668e55b..1fa1d9e33e1 100644 --- a/docs/wordpress.md +++ b/docs/wordpress.md @@ -89,3 +89,12 @@ if(file_exists($root.$path)) ``` With those four files in place, run `docker-compose up` inside your Wordpress directory and it'll pull and build the images we need, and then start the web and database containers. You'll then be able to visit Wordpress at port 8000 on your docker daemon (if you're using boot2docker, `boot2docker ip` will tell you its address). + +## Compose documentation + +- [Installing Compose](install.md) +- [User guide](index.md) +- [Command line reference](cli.md) +- [Yaml file reference](yml.md) +- [Compose environment variables](env.md) +- [Compose command line completion](completion.md) diff --git a/docs/yml.md b/docs/yml.md index 15d3943e7e1..035a99e921a 100644 --- a/docs/yml.md +++ b/docs/yml.md @@ -29,8 +29,10 @@ image: a4bc65fd ### build -Path to a directory containing a Dockerfile. Compose will build and tag it -with a generated name, and use that image thereafter. +Path to a directory containing a Dockerfile. 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: /path/to/build/dir @@ -237,3 +239,11 @@ restart: always stdin_open: true tty: true ``` + +## Compose documentation + +- [Installing Compose](install.md) +- [User guide](index.md) +- [Command line reference](cli.md) +- [Compose environment variables](env.md) +- [Compose command line completion](completion.md) From 178c50d46fa1f1934f523e9228a7a50f645027ab Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Wed, 25 Feb 2015 14:09:30 +0000 Subject: [PATCH 0691/4072] Move docs index higher up on index page Signed-off-by: Ben Firshman --- docs/index.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/index.md b/docs/index.md index 471b10afa28..b44adc8e287 100644 --- a/docs/index.md +++ b/docs/index.md @@ -50,6 +50,13 @@ Compose has commands for managing the whole lifecycle of your application: * Stream the log output of running services * Run a one-off command on a service +## Compose documentation + +- [Installing Compose](install.md) +- [Command line reference](cli.md) +- [Yaml file reference](yml.md) +- [Compose environment variables](env.md) +- [Compose command line completion](completion.md) ## Quick start @@ -180,10 +187,4 @@ your services once you've finished with them: At this point, you have seen the basics of how Compose works. -## Compose documentation -- [Installing Compose](install.md) -- [Command line reference](cli.md) -- [Yaml file reference](yml.md) -- [Compose environment variables](env.md) -- [Compose command line completion](completion.md) From bb943d5cb5551aa00bda77830d25533a556052da Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 25 Feb 2015 14:58:03 +0000 Subject: [PATCH 0692/4072] Update URLs in documentation Signed-off-by: Aanand Prasad --- docs/completion.md | 2 +- docs/install.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/completion.md b/docs/completion.md index 01a3421698c..a0ab8a3c363 100644 --- a/docs/completion.md +++ b/docs/completion.md @@ -17,7 +17,7 @@ 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/fig/1.1.0-rc2/contrib/completion/bash/docker-compose > /etc/bash_completion.d/docker-compose + curl -L https://raw.githubusercontent.com/docker/compose/1.1.0-rc2/contrib/completion/bash/docker-compose > /etc/bash_completion.d/docker-compose Completion will be available upon next login. diff --git a/docs/install.md b/docs/install.md index 78242062280..a84c151fc87 100644 --- a/docs/install.md +++ b/docs/install.md @@ -29,7 +29,7 @@ For complete instructions, or if you are on another platform, consult Docker's To install Compose, run the following commands: - curl -L https://github.com/docker/docker-compose/releases/download/1.1.0-rc2/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose + curl -L https://github.com/docker/compose/releases/download/1.1.0-rc2/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose chmod +x /usr/local/bin/docker-compose Optionally, you can also install [command completion](completion.md) for the From cf6b09e94b3d8b698da77050c890077a7d0c157e Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 25 Feb 2015 17:34:22 +0000 Subject: [PATCH 0693/4072] Fix requests version range It was more permissive than docker-py's, resulting in an incompatible version (2.5.x) being installed. Signed-off-by: Aanand Prasad --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index e1e29744bdd..9dfe9321267 100644 --- a/setup.py +++ b/setup.py @@ -27,7 +27,7 @@ def find_version(*file_paths): install_requires = [ 'docopt >= 0.6.1, < 0.7', 'PyYAML >= 3.10, < 4', - 'requests >= 2.2.1, < 3', + 'requests >= 2.2.1, < 2.5.0', 'texttable >= 0.8.1, < 0.9', 'websocket-client >= 0.11.0, < 1.0', 'docker-py >= 0.6.0, < 0.8', From ea8364fd11278559055fc2b55d6cf5781fdb0413 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 25 Feb 2015 15:01:40 +0000 Subject: [PATCH 0694/4072] Add 'previously known as Fig' note to README.md Signed-off-by: Aanand Prasad --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 4df3bd2116b..52e6f3607c2 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,8 @@ Docker Compose [![wercker status](https://app.wercker.com/status/d5dbac3907301c3d5ce735e2d5e95a5b/s/master "wercker status")](https://app.wercker.com/project/bykey/d5dbac3907301c3d5ce735e2d5e95a5b) +*(Previously known as Fig)* + Compose is a tool for defining and running complex applications with Docker. With Compose, you define a multi-container application in a single file, then spin your application up in a single command which does everything that needs to From 35d5d1a5b1889956d72778dfc08427c628990fa9 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 25 Feb 2015 14:04:30 +0000 Subject: [PATCH 0695/4072] Build and link to getting started guides Signed-off-by: Aanand Prasad --- docs/index.md | 7 +++++-- docs/mkdocs.yml | 3 +++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/index.md b/docs/index.md index b44adc8e287..a75e7285a21 100644 --- a/docs/index.md +++ b/docs/index.md @@ -185,6 +185,9 @@ your services once you've finished with them: $ docker-compose stop -At this point, you have seen the basics of how Compose works. - +At this point, you have seen the basics of how Compose works. +- Next, try the quick start guide for [Django](django.md), + [Rails](rails.md), or [Wordpress](wordpress.md). +- See the reference guides for complete details on the [commands](cli.md), the + [configuration file](yml.md) and [environment variables](env.md). diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index fc725b28c55..14335873dea 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -5,3 +5,6 @@ - ['compose/yml.md', 'Reference', 'Compose yml'] - ['compose/env.md', 'Reference', 'Compose ENV variables'] - ['compose/completion.md', 'Reference', 'Compose commandline completion'] +- ['compose/django.md', 'Examples', 'Getting started with Compose and Django'] +- ['compose/rails.md', 'Examples', 'Getting started with Compose and Rails'] +- ['compose/wordpress.md', 'Examples', 'Getting started with Compose and Wordpress'] From 4ac02bfca640437be84f149a76f6dad3170d9601 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 25 Feb 2015 18:16:44 +0000 Subject: [PATCH 0696/4072] Ship 1.1.0 Signed-off-by: Aanand Prasad --- CHANGES.md | 21 ++++----------------- compose/__init__.py | 2 +- docs/completion.md | 2 +- docs/install.md | 2 +- 4 files changed, 7 insertions(+), 20 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 6aba8f39b01..75c13090679 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,21 +1,8 @@ Change log ========== -1.1.0-rc2 (2015-01-29) ----------------------- - -On top of the changelog for 1.1.0-rc1 (see below), the following bugs have been fixed: - -- When an environment variables file specified with `env_file` doesn't exist, Compose was showing a stack trace instead of a helpful error. - -- Configuration files using the old name (`fig.yml`) were not being read unless explicitly specified with `docker-compose -f`. Compose now reads them and prints a deprecation warning. - -- Bash tab completion now reads `fig.yml` if it's present. - -Thanks, @dnephin and @albers! - -1.1.0-rc1 (2015-01-20) ----------------------- +1.1.0 (2015-02-25) +------------------ Fig has been renamed to Docker Compose, or just Compose for short. This has several implications for you: @@ -41,9 +28,9 @@ Besides that, there’s a lot of new stuff in this release: - docker-compose.yml now supports the `dns_search`, `cap_add`, `cap_drop`, `cpu_shares` and `restart` options, analogous to `docker run`’s `--dns-search`, `--cap-add`, `--cap-drop`, `--cpu-shares` and `--restart` options. -- Compose now ships with Bash tab completion - see the installation and usage docs at https://github.com/docker/fig/blob/1.1.0-rc1/docs/completion.md +- Compose now ships with Bash tab completion - see the installation and usage docs at https://github.com/docker/compose/blob/1.1.0/docs/completion.md -- A number of bugs have been fixed - see the milestone for details: https://github.com/docker/fig/issues?q=milestone%3A1.1.0+ +- A number of bugs have been fixed - see the milestone for details: https://github.com/docker/compose/issues?q=milestone%3A1.1.0+ Thanks @dnephin, @squebe, @jbalonso, @raulcd, @benlangfield, @albers, @ggtools, @bersace, @dtenenba, @petercv, @drewkett, @TFenby, @paulRbr, @Aigeruth and @salehe! diff --git a/compose/__init__.py b/compose/__init__.py index 0f4cc72a4cd..c770b3950ba 100644 --- a/compose/__init__.py +++ b/compose/__init__.py @@ -1,4 +1,4 @@ from __future__ import unicode_literals from .service import Service # noqa:flake8 -__version__ = '1.1.0-rc2' +__version__ = '1.1.0' diff --git a/docs/completion.md b/docs/completion.md index a0ab8a3c363..d9b94f6cf1f 100644 --- a/docs/completion.md +++ b/docs/completion.md @@ -17,7 +17,7 @@ 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/1.1.0-rc2/contrib/completion/bash/docker-compose > /etc/bash_completion.d/docker-compose + curl -L https://raw.githubusercontent.com/docker/compose/1.1.0/contrib/completion/bash/docker-compose > /etc/bash_completion.d/docker-compose Completion will be available upon next login. diff --git a/docs/install.md b/docs/install.md index a84c151fc87..e78f4b48f6b 100644 --- a/docs/install.md +++ b/docs/install.md @@ -29,7 +29,7 @@ For complete instructions, or if you are on another platform, consult Docker's To install Compose, run the following commands: - curl -L https://github.com/docker/compose/releases/download/1.1.0-rc2/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose + curl -L https://github.com/docker/compose/releases/download/1.1.0/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose chmod +x /usr/local/bin/docker-compose Optionally, you can also install [command completion](completion.md) for the From c41342501bef738c7f39e7dfbee0b571b9b77e33 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 25 Feb 2015 18:25:15 +0000 Subject: [PATCH 0697/4072] Update README with new docs URL and IRC channel Signed-off-by: Aanand Prasad --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 52e6f3607c2..c943c70d8a1 100644 --- a/README.md +++ b/README.md @@ -53,4 +53,5 @@ Compose has commands for managing the whole lifecycle of your application: Installation and documentation ------------------------------ -Full documentation is available on [Fig's website](http://www.fig.sh/). +- Full documentation is available on [Docker's website](http://docs.docker.com/compose/). +- Hop into #docker-compose on Freenode if you have any questions. From 882dc673ce84b0b29cd59b6815cb93f74a6c4134 Mon Sep 17 00:00:00 2001 From: Fred Lifton Date: Thu, 26 Feb 2015 18:58:06 -0800 Subject: [PATCH 0698/4072] Edits and revisions to Compose Quickstart guides --- docs/django.md | 66 +++++++++++++++++++++++++++++++++-------------- docs/rails.md | 66 +++++++++++++++++++++++++++++++---------------- docs/wordpress.md | 50 +++++++++++++++++++++++++---------- 3 files changed, 127 insertions(+), 55 deletions(-) diff --git a/docs/django.md b/docs/django.md index 48d3e6d01d5..0605c86b691 100644 --- a/docs/django.md +++ b/docs/django.md @@ -1,14 +1,23 @@ ---- -layout: default -title: Getting started with Compose and Django ---- +page_title: Quickstart Guide: Compose and Django +page_description: Getting started with Docker Compose and Django +page_keywords: documentation, docs, docker, compose, orchestration, containers, +django -Getting started with Compose and Django -=================================== -Let's use Compose to set up and run a Django/PostgreSQL app. Before starting, you'll need to have [Compose installed](install.md). +## Getting started with Compose and Django -Let's set up the three files that'll get us started. First, our app is going to be running inside a Docker container which contains all of its dependencies. We can define what goes inside that Docker container using a file called `Dockerfile`. It'll contain this to start with: + +This Quick-start Guide will demonstrate how to use 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 + +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 python:2.7 ENV PYTHONUNBUFFERED 1 @@ -18,14 +27,21 @@ Let's set up the three files that'll get us started. First, our app is going to RUN pip install -r requirements.txt ADD . /code/ -That'll install our application inside an image with Python installed alongside all of our Python dependencies. For more information on how to write Dockerfiles, see the [Docker user guide](https://docs.docker.com/userguide/dockerimages/#building-an-image-from-a-dockerfile) and the [Dockerfile reference](http://docs.docker.com/reference/builder/). +This Dockerfile will define an image that is used to build a container that +includes your application and has Python installed alongside all of your Python +dependencies. For more information on how to write Dockerfiles, see the +[Docker user guide](https://docs.docker.com/userguide/dockerimages/#building-an-image-from-a-dockerfile) and the [Dockerfile reference](http://docs.docker.com/reference/builder/). -Second, we define our Python dependencies in a file called `requirements.txt`: +Second, you'll define your Python dependencies in a file called +`requirements.txt`: Django psycopg2 -Simple enough. Finally, this is all tied together with a file called `docker-compose.yml`. It describes the services that our app comprises of (a web server and database), what Docker images they use, how they link together, what volumes will be mounted inside the containers and what ports they expose. +Finally, this is all tied together with a file called `docker-compose.yml`. It +describes the services that comprise your app (here, a web server and database), +which Docker images they use, how they link together, what volumes will be +mounted inside the containers, and what ports they expose. db: image: postgres @@ -39,20 +55,28 @@ Simple enough. Finally, this is all tied together with a file called `docker-com links: - db -See the [`docker-compose.yml` reference](yml.html) for more information on how it works. +See the [`docker-compose.yml` reference](yml.html) for more information on how +this file works. -We can now start a Django project using `docker-compose run`: +### Build the project + +You can now start a Django project with `docker-compose run`: $ docker-compose run web django-admin.py startproject composeexample . -First, Compose will build an image for the `web` service using the `Dockerfile`. It will then run `django-admin.py startproject composeexample .` inside a container using that image. +First, Compose will build an image for the `web` service using the `Dockerfile`. +It will then run `django-admin.py startproject composeexample .` inside a +container built using that image. This will generate a Django app inside the current directory: $ ls Dockerfile docker-compose.yml composeexample manage.py requirements.txt -First thing we need to do is set up the database connection. Replace the `DATABASES = ...` definition in `composeexample/settings.py` to read: +### Connect the database + +Now you need to set up the database connection. Replace the `DATABASES = ...` +definition in `composeexample/settings.py` to read: DATABASES = { 'default': { @@ -64,7 +88,9 @@ First thing we need to do is set up the database connection. Replace the `DATABA } } -These settings are determined by the [postgres](https://registry.hub.docker.com/_/postgres/) Docker image we are using. +These settings are determined by the +[postgres](https://registry.hub.docker.com/_/postgres/) Docker image specified +in the Dockerfile. Then, run `docker-compose up`: @@ -83,13 +109,15 @@ Then, run `docker-compose up`: myapp_web_1 | Starting development server at http://0.0.0.0:8000/ myapp_web_1 | Quit the server with CONTROL-C. -And your Django app should be running at port 8000 on your docker daemon (if you're using boot2docker, `boot2docker ip` will tell you its address). +Your Django app should nw be running at port 8000 on your Docker daemon (if +you're using Boot2docker, `boot2docker ip` will tell you its address). -You can also run management commands with Docker. To set up your database, for example, run `docker-compose up` and in another terminal run: +You can also run management commands with Docker. To set up your database, for +example, run `docker-compose up` and in another terminal run: $ docker-compose run web python manage.py syncdb -## Compose documentation +## More Compose documentation - [Installing Compose](install.md) - [User guide](index.md) diff --git a/docs/rails.md b/docs/rails.md index 67682cf5924..1fe484990d2 100644 --- a/docs/rails.md +++ b/docs/rails.md @@ -1,14 +1,20 @@ ---- -layout: default -title: Getting started with Compose and Rails ---- +page_title: Quickstart Guide: Compose and Rails +page_description: Getting started with Docker Compose and Rails +page_keywords: documentation, docs, docker, compose, orchestration, containers, +rails -Getting started with Compose and Rails -================================== -We're going to use Compose to set up and run a Rails/PostgreSQL app. Before starting, you'll need to have [Compose installed](install.md). +## Getting started with Compose and Rails -Let's set up the three files that'll get us started. First, our app is going to be running inside a Docker container which contains all of its dependencies. We can define what goes inside that Docker container using a file called `Dockerfile`. It'll contain this to start with: +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). + +### 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 @@ -18,14 +24,14 @@ Let's set up the three files that'll get us started. First, our app is going to RUN bundle install ADD . /myapp -That'll put our application code inside an image with Ruby, Bundler and all our dependencies. For more information on how to write Dockerfiles, see the [Docker user guide](https://docs.docker.com/userguide/dockerimages/#building-an-image-from-a-dockerfile) and the [Dockerfile reference](http://docs.docker.com/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/userguide/dockerimages/#building-an-image-from-a-dockerfile) and the [Dockerfile reference](http://docs.docker.com/reference/builder/). -Next, we have a bootstrap `Gemfile` which just loads Rails. It'll be overwritten in a moment by `rails new`. +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' -Finally, `docker-compose.yml` is where the magic happens. It describes what services our app comprises (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 we need 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 @@ -41,11 +47,16 @@ Finally, `docker-compose.yml` is where the magic happens. It describes what serv links: - db -With those files in place, we can now generate the Rails skeleton app using `docker-compose run`: +### 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 a fresh app generated: +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 @@ -54,17 +65,26 @@ First, Compose will build the image for the `web` service using the `Dockerfile` README.rdoc config.ru public Rakefile db test -Uncomment the line in your new `Gemfile` which loads `therubyracer`, so we've got a Javascript runtime: +Uncomment the line in your new `Gemfile` which loads `therubyracer`, so you've +got a Javascript runtime: gem 'therubyracer', platforms: :ruby -Now that we've got a new `Gemfile`, we need to build the image again. (This, and changes to the Dockerfile itself, should be the only times you'll need to rebuild). +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 -The app is now bootable, but we're not quite there yet. By default, Rails expects a database to be running on `localhost` - we need to point it at the `db` container instead. We also need to change the database and username to align with the defaults set by the `postgres` image. +### 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. -Open up your newly-generated `database.yml`. Replace its contents with the following: +Open up your newly-generated `database.yml`file. Replace its contents with the +following: development: &default adapter: postgresql @@ -79,23 +99,25 @@ Open up your newly-generated `database.yml`. Replace its contents with the follo <<: *default database: myapp_test -We can now boot the app. +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: +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, we just need to create the database. In another terminal, run: +Finally, you need to create the database. In another terminal, run: $ docker-compose run web rake db:create -And we're rolling—your app should now be running on port 3000 on your docker daemon (if you're using boot2docker, `boot2docker ip` will tell you its address). +That's it. Your app should now be running on port 3000 on your Docker daemon (if +you're using Boot2docker, `boot2docker ip` will tell you its address). -## Compose documentation +## More Compose documentation - [Installing Compose](install.md) - [User guide](index.md) diff --git a/docs/wordpress.md b/docs/wordpress.md index 1fa1d9e33e1..5a9c37a8d3e 100644 --- a/docs/wordpress.md +++ b/docs/wordpress.md @@ -1,25 +1,40 @@ ---- -layout: default -title: Getting started with Compose and Wordpress ---- +page_title: Quickstart Guide: Compose and Wordpress +page_description: Getting started with Docker Compose and Rails +page_keywords: documentation, docs, docker, compose, orchestration, containers, +wordpress -Getting started with Compose and Wordpress -====================================== +## Getting started with Compose and Wordpress -Compose makes it nice and easy to run Wordpress in an isolated environment. [Install Compose](install.md), then download Wordpress into the current directory: +You can use Compose to easily run Wordpress in an isolated environment built +with Docker containers. + +### Define the project + +First, [Install Compose](install.md) and then download Wordpress into the +current directory: $ curl https://wordpress.org/latest.tar.gz | tar -xvzf - -This will create a directory called `wordpress`, which you can rename to the name of your project if you wish. Inside that directory, we create `Dockerfile`, a file that defines what environment your app is going to run in: +This will create a directory called `wordpress`. If you wish, you can rename it +to the name of your project. + +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/userguide/dockerimages/#building-an-image-from-a-dockerfile) and the +[Dockerfile reference](http://docs.docker.com/reference/builder/). In this case, +your Dockerfile should be: ``` FROM orchardup/php5 ADD . /code ``` -This instructs Docker on how to build an image that contains PHP and Wordpress. For more information on how to write Dockerfiles, see the [Docker user guide](https://docs.docker.com/userguide/dockerimages/#building-an-image-from-a-dockerfile) and the [Dockerfile reference](http://docs.docker.com/reference/builder/). +This tells Docker how to build an image defining a container that contains PHP +and Wordpress. -Next up, `docker-compose.yml` starts our web service and a separate MySQL instance: +Next you'll create a `docker-compose.yml` file that will start your web service +and a separate MySQL instance: ``` web: @@ -37,7 +52,9 @@ db: MYSQL_DATABASE: wordpress ``` -Two supporting files are needed to get this working - first up, `wp-config.php` is the standard Wordpress config file with a single change to point the database configuration at the `db` container: +Two supporting files are needed to get this working - first, `wp-config.php` is +the standard Wordpress config file with a single change to point the database +configuration at the `db` container: ``` Date: Sun, 22 Feb 2015 23:13:15 +0000 Subject: [PATCH 0699/4072] Add -f flag as option on rm. Signed-off-by: Kingsley Kelly --- compose/cli/main.py | 4 ++-- tests/integration/cli_test.py | 6 ++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index cfe29cd077d..858f1947912 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -238,8 +238,8 @@ def rm(self, project, options): Usage: rm [options] [SERVICE...] Options: - --force Don't ask to confirm removal - -v Remove volumes associated with containers + -f, --force Don't ask to confirm removal + -v Remove volumes associated with containers """ all_containers = project.containers(service_names=options['SERVICE'], stopped=True) stopped_containers = [c for c in all_containers if not c.is_running] diff --git a/tests/integration/cli_test.py b/tests/integration/cli_test.py index cf939837929..32c4294cc20 100644 --- a/tests/integration/cli_test.py +++ b/tests/integration/cli_test.py @@ -295,6 +295,12 @@ def test_rm(self): self.assertEqual(len(service.containers(stopped=True)), 1) self.command.dispatch(['rm', '--force'], None) self.assertEqual(len(service.containers(stopped=True)), 0) + service = self.project.get_service('simple') + service.create_container() + service.kill() + self.assertEqual(len(service.containers(stopped=True)), 1) + self.command.dispatch(['rm', '-f'], None) + self.assertEqual(len(service.containers(stopped=True)), 0) def test_kill(self): self.command.dispatch(['up', '-d'], None) From 98f32a21e740cc30abdfd9427b047999341b98a6 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Fri, 27 Feb 2015 11:18:41 +0000 Subject: [PATCH 0700/4072] Remove @d11wtq as a maintainer Thanks for all the help! Signed-off-by: Ben Firshman --- MAINTAINERS | 1 - 1 file changed, 1 deletion(-) diff --git a/MAINTAINERS b/MAINTAINERS index 0fd7f81c87f..8ac3985fa67 100644 --- a/MAINTAINERS +++ b/MAINTAINERS @@ -1,4 +1,3 @@ Aanand Prasad (@aanand) Ben Firshman (@bfirsh) -Chris Corbyn (@d11wtq) Daniel Nephin (@dnephin) From 0e30d0085bd66bbb91d597b63ce3c72785b9469e Mon Sep 17 00:00:00 2001 From: Alex Brandt Date: Sat, 28 Feb 2015 15:18:29 -0600 Subject: [PATCH 0701/4072] add bash completion to sdist When installing from the source distribution (for packaging or other purposes), it's convenient to be able to install the bash completion file as part of that process. --- MANIFEST.in | 1 + 1 file changed, 1 insertion(+) diff --git a/MANIFEST.in b/MANIFEST.in index ca9ecbd5bf6..2acd5ab6b14 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -4,6 +4,7 @@ include requirements.txt include requirements-dev.txt include tox.ini include *.md +include contrib/completion/bash/docker-compose recursive-include tests * global-exclude *.pyc global-exclude *.pyo From f1fc1d7a16d2d35f172bd615873ad910ae4e47dc Mon Sep 17 00:00:00 2001 From: Igor Ch Date: Sat, 28 Feb 2015 23:26:20 +0200 Subject: [PATCH 0702/4072] Move several steps closer to python3 compatibility Signed-off-by: Igor Ch --- compose/cli/command.py | 2 +- compose/cli/log_printer.py | 2 +- compose/container.py | 1 + compose/project.py | 1 + compose/service.py | 4 ++-- requirements.txt | 2 +- tests/integration/service_test.py | 6 +++--- 7 files changed, 10 insertions(+), 8 deletions(-) diff --git a/compose/cli/command.py b/compose/cli/command.py index 67b77f31b57..c26f3bc38ed 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -25,7 +25,7 @@ class Command(DocoptCommand): def dispatch(self, *args, **kwargs): try: super(Command, self).dispatch(*args, **kwargs) - except SSLError, e: + except SSLError as e: raise errors.UserError('SSL error: %s' % e) except ConnectionError: if call_silently(['which', 'docker']) != 0: diff --git a/compose/cli/log_printer.py b/compose/cli/log_printer.py index 77fa49ae3c5..ce7e1065338 100644 --- a/compose/cli/log_printer.py +++ b/compose/cli/log_printer.py @@ -46,7 +46,7 @@ def no_color(text): if monochrome: color_fn = no_color else: - color_fn = color_fns.next() + color_fn = next(color_fns) generators.append(self._make_log_generator(container, color_fn)) return generators diff --git a/compose/container.py b/compose/container.py index 6921459884e..1d044a421ce 100644 --- a/compose/container.py +++ b/compose/container.py @@ -2,6 +2,7 @@ from __future__ import absolute_import import six +from functools import reduce class Container(object): diff --git a/compose/project.py b/compose/project.py index 601604474c7..f2fa6a7ee8f 100644 --- a/compose/project.py +++ b/compose/project.py @@ -2,6 +2,7 @@ from __future__ import absolute_import import logging +from functools import reduce from .service import Service from .container import Container from docker.errors import APIError diff --git a/compose/service.py b/compose/service.py index eb6e86fb5d1..a21b78b761f 100644 --- a/compose/service.py +++ b/compose/service.py @@ -482,7 +482,7 @@ def build(self, no_cache=False): try: all_events = stream_output(build_output, sys.stdout) - except StreamOutputError, e: + except StreamOutputError as e: raise BuildError(self, unicode(e)) image_id = None @@ -641,7 +641,7 @@ def merge_environment(options): else: env.update(options['environment']) - return dict(resolve_env(k, v) for k, v in env.iteritems()) + return dict(resolve_env(k, v) for k, v in env.items()) def split_env(env): diff --git a/requirements.txt b/requirements.txt index a31a19ae9c6..80b494562fb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,5 +4,5 @@ dockerpty==0.3.2 docopt==0.6.1 requests==2.2.1 six==1.7.3 -texttable==0.8.1 +texttable==0.8.2 websocket-client==0.11.0 diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 5904eb4ea0e..7b95b870ff0 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -472,13 +472,13 @@ def test_working_dir_param(self): def test_split_env(self): 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': ''}.iteritems(): + for k,v in {'NORMAL': 'F1', 'CONTAINS_EQUALS': 'F=2', 'TRAILING_EQUALS': ''}.items(): self.assertEqual(env[k], v) def test_env_from_file_combined_with_env(self): service = self.create_service('web', environment=['ONE=1', 'TWO=2', 'THREE=3'], env_file=['tests/fixtures/env/one.env', 'tests/fixtures/env/two.env']) env = create_and_start_container(service).environment - for k,v in {'ONE': '1', 'TWO': '2', 'THREE': '3', 'FOO': 'baz', 'DOO': 'dah'}.iteritems(): + for k,v in {'ONE': '1', 'TWO': '2', 'THREE': '3', 'FOO': 'baz', 'DOO': 'dah'}.items(): self.assertEqual(env[k], v) def test_resolve_env(self): @@ -488,7 +488,7 @@ def test_resolve_env(self): os.environ['ENV_DEF'] = 'E3' try: env = create_and_start_container(service).environment - for k,v in {'FILE_DEF': 'F1', 'FILE_DEF_EMPTY': '', 'ENV_DEF': 'E3', 'NO_DEF': ''}.iteritems(): + for k,v in {'FILE_DEF': 'F1', 'FILE_DEF_EMPTY': '', 'ENV_DEF': 'E3', 'NO_DEF': ''}.items(): self.assertEqual(env[k], v) finally: del os.environ['FILE_DEF'] From 8f38b288168a6f8ce4c7df8275cbebb5fdfa73df Mon Sep 17 00:00:00 2001 From: Zoltan Nagy Date: Fri, 20 Feb 2015 17:18:51 +0100 Subject: [PATCH 0703/4072] Use docker-py 1.0.0 Signed-off-by: Zoltan Nagy --- requirements.txt | 2 +- setup.py | 2 +- tests/unit/cli/docker_client_test.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/requirements.txt b/requirements.txt index a31a19ae9c6..ecfcd2c5b5c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ PyYAML==3.10 -docker-py==0.7.1 +docker-py==1.0.0 dockerpty==0.3.2 docopt==0.6.1 requests==2.2.1 diff --git a/setup.py b/setup.py index e1e29744bdd..4cbffd5f39b 100644 --- a/setup.py +++ b/setup.py @@ -30,7 +30,7 @@ def find_version(*file_paths): 'requests >= 2.2.1, < 3', 'texttable >= 0.8.1, < 0.9', 'websocket-client >= 0.11.0, < 1.0', - 'docker-py >= 0.6.0, < 0.8', + 'docker-py >= 1.0.0, < 1.1.0', 'dockerpty >= 0.3.2, < 0.4', 'six >= 1.3.0, < 2', ] diff --git a/tests/unit/cli/docker_client_test.py b/tests/unit/cli/docker_client_test.py index abd40ef082f..184aff4de40 100644 --- a/tests/unit/cli/docker_client_test.py +++ b/tests/unit/cli/docker_client_test.py @@ -19,4 +19,4 @@ def test_docker_client_with_custom_timeout(self): with mock.patch.dict(os.environ): os.environ['DOCKER_CLIENT_TIMEOUT'] = timeout = "300" client = docker_client.docker_client() - self.assertEqual(client._timeout, int(timeout)) + self.assertEqual(client.timeout, int(timeout)) From f47431d591fddd3a7791fc2e4fbafeae7cc6496a Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Sat, 14 Feb 2015 14:09:55 -0500 Subject: [PATCH 0704/4072] Resolves #927 - fix merging command line environment with a list in the config Signed-off-by: Daniel Nephin --- compose/cli/main.py | 24 ++++++++-------- compose/service.py | 26 +++++++++++------ tests/unit/cli_test.py | 33 +++++++++++++++++++++- tests/unit/service_test.py | 57 +++++++++++++++++++++++++------------- tox.ini | 4 +-- 5 files changed, 101 insertions(+), 43 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 858f1947912..eee59bb7489 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -1,26 +1,25 @@ from __future__ import print_function from __future__ import unicode_literals +from inspect import getdoc +from operator import attrgetter import logging -import sys import re import signal -from operator import attrgetter +import sys -from inspect import getdoc +from docker.errors import APIError import dockerpty from .. import __version__ from ..project import NoSuchService, ConfigurationError -from ..service import BuildError, CannotBeScaledError +from ..service import BuildError, CannotBeScaledError, parse_environment from .command import Command +from .docopt_command import NoSuchCommand +from .errors import UserError from .formatter import Formatter from .log_printer import LogPrinter from .utils import yesno -from docker.errors import APIError -from .errors import UserError -from .docopt_command import NoSuchCommand - log = logging.getLogger(__name__) @@ -316,11 +315,10 @@ def run(self, project, options): } if options['-e']: - for option in options['-e']: - if 'environment' not in service.options: - service.options['environment'] = {} - k, v = option.split('=', 1) - service.options['environment'][k] = v + # Merge environment from config with -e command line + container_options['environment'] = dict( + parse_environment(service.options.get('environment')), + **parse_environment(options['-e'])) if options['--entrypoint']: container_options['entrypoint'] = options.get('--entrypoint') diff --git a/compose/service.py b/compose/service.py index a21b78b761f..df6dd6ab545 100644 --- a/compose/service.py +++ b/compose/service.py @@ -8,6 +8,7 @@ import sys from docker.errors import APIError +import six from .container import Container, get_container_name from .progress_stream import stream_output, StreamOutputError @@ -450,7 +451,7 @@ def _get_container_create_options(self, override_options, one_off=False): (parse_volume_spec(v).internal, {}) for v in container_options['volumes']) - container_options['environment'] = merge_environment(container_options) + container_options['environment'] = build_environment(container_options) if self.can_be_built(): container_options['image'] = self.full_name @@ -629,19 +630,28 @@ def get_env_files(options): return env_files -def merge_environment(options): +def build_environment(options): env = {} for f in get_env_files(options): env.update(env_vars_from_file(f)) - if 'environment' in options: - if isinstance(options['environment'], list): - env.update(dict(split_env(e) for e in options['environment'])) - else: - env.update(options['environment']) + env.update(parse_environment(options.get('environment'))) + return dict(resolve_env(k, v) for k, v in six.iteritems(env)) + + +def parse_environment(environment): + if not environment: + return {} + + if isinstance(environment, list): + return dict(split_env(e) for e in environment) + + if isinstance(environment, dict): + return environment - return dict(resolve_env(k, v) for k, v in env.items()) + raise ConfigError("environment \"%s\" must be a list or mapping," % + environment) def split_env(env): diff --git a/tests/unit/cli_test.py b/tests/unit/cli_test.py index 57e2f327f1b..d9a191ef076 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -6,12 +6,14 @@ import shutil from .. import unittest +import docker import mock +from six import StringIO from compose.cli import main from compose.cli.main import TopLevelCommand from compose.cli.errors import ComposeFileNotFound -from six import StringIO +from compose.service import Service class CLITestCase(unittest.TestCase): @@ -103,6 +105,35 @@ def test_setup_logging(self): self.assertEqual(logging.getLogger().level, logging.DEBUG) self.assertEqual(logging.getLogger('requests').propagate, False) + @mock.patch('compose.cli.main.dockerpty', autospec=True) + def test_run_with_environment_merged_with_options_list(self, mock_dockerpty): + command = TopLevelCommand() + mock_client = mock.create_autospec(docker.Client) + mock_project = mock.Mock() + 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=THREE'], + '--no-deps': None, + '--allow-insecure-ssl': None, + '-d': True, + '-T': None, + '--entrypoint': None, + '--service-ports': None, + '--rm': None, + }) + + _, _, call_kwargs = mock_client.create_container.mock_calls[0] + self.assertEqual( + call_kwargs['environment'], + {'FOO': 'ONE', 'BAR': 'NEW', 'OTHER': 'THREE'}) + def get_config_filename_for_files(filenames): project_dir = tempfile.mkdtemp() diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index c7b122fc2c0..012a51ab648 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -11,14 +11,15 @@ from compose import Service from compose.container import Container from compose.service import ( + APIError, ConfigError, - split_port, build_port_bindings, - parse_volume_spec, build_volume_binding, - APIError, get_container_name, + parse_environment, parse_repository_tag, + parse_volume_spec, + split_port, ) @@ -326,28 +327,47 @@ def setUp(self): self.mock_client = mock.create_autospec(docker.Client) self.mock_client.containers.return_value = [] - def test_parse_environment(self): - service = Service('foo', - environment=['NORMAL=F1', 'CONTAINS_EQUALS=F=2', 'TRAILING_EQUALS='], - client=self.mock_client, - image='image_name', - ) - options = service._get_container_create_options({}) + def test_parse_environment_as_list(self): + environment =[ + 'NORMAL=F1', + 'CONTAINS_EQUALS=F=2', + 'TRAILING_EQUALS=' + ] self.assertEqual( - options['environment'], - {'NORMAL': 'F1', 'CONTAINS_EQUALS': 'F=2', 'TRAILING_EQUALS': ''} - ) + parse_environment(environment), + {'NORMAL': 'F1', 'CONTAINS_EQUALS': 'F=2', 'TRAILING_EQUALS': ''}) + + def test_parse_environment_as_dict(self): + environment = { + 'NORMAL': 'F1', + 'CONTAINS_EQUALS': 'F=2', + 'TRAILING_EQUALS': None, + } + self.assertEqual(parse_environment(environment), environment) + + def test_parse_environment_invalid(self): + with self.assertRaises(ConfigError): + parse_environment('a=b') + + def test_parse_environment_empty(self): + self.assertEqual(parse_environment(None), {}) @mock.patch.dict(os.environ) def test_resolve_environment(self): os.environ['FILE_DEF'] = 'E1' os.environ['FILE_DEF_EMPTY'] = 'E2' os.environ['ENV_DEF'] = 'E3' - service = Service('foo', - environment={'FILE_DEF': 'F1', 'FILE_DEF_EMPTY': '', 'ENV_DEF': None, 'NO_DEF': None}, - client=self.mock_client, - image='image_name', - ) + service = Service( + 'foo', + environment={ + 'FILE_DEF': 'F1', + 'FILE_DEF_EMPTY': '', + 'ENV_DEF': None, + 'NO_DEF': None + }, + client=self.mock_client, + image='image_name', + ) options = service._get_container_create_options({}) self.assertEqual( options['environment'], @@ -381,7 +401,6 @@ def test_env_from_multiple_files(self): def test_env_nonexistent_file(self): self.assertRaises(ConfigError, lambda: Service('foo', env_file='tests/fixtures/env/nonexistent.env')) - @mock.patch.dict(os.environ) def test_resolve_environment_from_file(self): os.environ['FILE_DEF'] = 'E1' diff --git a/tox.ini b/tox.ini index a20d984b704..6e83fc414d5 100644 --- a/tox.ini +++ b/tox.ini @@ -8,9 +8,9 @@ deps = -rrequirements-dev.txt commands = nosetests -v {posargs} - flake8 fig + flake8 compose [flake8] # ignore line-length for now ignore = E501,E203 -exclude = fig/packages +exclude = compose/packages From ac7a97f42039e941bdfe1f64d0246b380ef99ff8 Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Mon, 2 Mar 2015 18:33:34 +0100 Subject: [PATCH 0705/4072] Add -f flag to bash completion for docker-compose rm 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 03721f505f0..0f268b39f98 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -203,7 +203,7 @@ _docker-compose_restart() { _docker-compose_rm() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "--force -v" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--force -f -v" -- "$cur" ) ) ;; *) __docker-compose_services_stopped From 08f936b2e7ba91e3c6497e12ba4dd798de7158ba Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Wed, 4 Mar 2015 10:27:06 +0000 Subject: [PATCH 0706/4072] Fix missing space in rails docs From #1031 Signed-off-by: Ben Firshman --- docs/rails.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/rails.md b/docs/rails.md index 1fe484990d2..0671d0624a4 100644 --- a/docs/rails.md +++ b/docs/rails.md @@ -83,7 +83,7 @@ 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. -Open up your newly-generated `database.yml`file. Replace its contents with the +Open up your newly-generated `database.yml` file. Replace its contents with the following: development: &default From 95f4e2c7c3214c184aae2717417f273ddc6ad2ca Mon Sep 17 00:00:00 2001 From: Gil Clark Date: Fri, 6 Mar 2015 13:30:56 -0800 Subject: [PATCH 0707/4072] Make volumes_from and net containers first class dependencies and assure that starting order is correct. Added supporting unit and integration tests as well. Signed-off-by: Gil Clark --- compose/cli/main.py | 6 +- compose/project.py | 88 +++++++++++++----- compose/service.py | 35 +++++++- tests/integration/project_test.py | 143 +++++++++++++++++++++++++++--- tests/unit/project_test.py | 107 +++++++++++++++++++++- tests/unit/sort_service_test.py | 89 +++++++++++++++++++ 6 files changed, 428 insertions(+), 40 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index eee59bb7489..15c5e05f430 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -292,7 +292,7 @@ def run(self, project, options): if len(deps) > 0: project.up( service_names=deps, - start_links=True, + start_deps=True, recreate=False, insecure_registry=insecure_registry, detach=options['-d'] @@ -430,13 +430,13 @@ def up(self, project, options): monochrome = options['--no-color'] - start_links = not options['--no-deps'] + start_deps = not options['--no-deps'] recreate = not options['--no-recreate'] service_names = options['SERVICE'] project.up( service_names=service_names, - start_links=start_links, + start_deps=start_deps, recreate=recreate, insecure_registry=insecure_registry, detach=options['-d'], diff --git a/compose/project.py b/compose/project.py index f2fa6a7ee8f..794ef2b6556 100644 --- a/compose/project.py +++ b/compose/project.py @@ -10,6 +10,17 @@ log = logging.getLogger(__name__) +def get_service_name_from_net(net_config): + if not net_config: + return + + if not net_config.startswith('container:'): + return + + _, net_name = net_config.split(':', 1) + return net_name + + def sort_service_dicts(services): # Topological sort (Cormen/Tarjan algorithm). unmarked = services[:] @@ -19,6 +30,15 @@ def sort_service_dicts(services): def get_service_names(links): return [link.split(':')[0] for link in links] + 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 service.get('volumes_from', []) or + name == get_service_name_from_net(service.get('net'))) + ] + def visit(n): if n['name'] in temporary_marked: if n['name'] in get_service_names(n.get('links', [])): @@ -29,8 +49,7 @@ def visit(n): raise DependencyError('Circular import between %s' % ' and '.join(temporary_marked)) if n in unmarked: temporary_marked.add(n['name']) - dependents = [m for m in services if (n['name'] in get_service_names(m.get('links', []))) or (n['name'] in m.get('volumes_from', []))] - for m in dependents: + for m in get_service_dependents(n, services): visit(m) temporary_marked.remove(n['name']) unmarked.remove(n) @@ -60,8 +79,10 @@ def from_dicts(cls, name, service_dicts, client): for service_dict in sort_service_dicts(service_dicts): links = project.get_links(service_dict) volumes_from = project.get_volumes_from(service_dict) + net = project.get_net(service_dict) - project.services.append(Service(client=client, project=name, links=links, volumes_from=volumes_from, **service_dict)) + project.services.append(Service(client=client, project=name, links=links, net=net, + volumes_from=volumes_from, **service_dict)) return project @classmethod @@ -85,31 +106,31 @@ def get_service(self, name): raise NoSuchService(name) - def get_services(self, service_names=None, include_links=False): + def get_services(self, service_names=None, include_deps=False): """ Returns a list of this project's services filtered by the provided list of names, or all services if service_names is None or []. - If include_links is specified, returns a list including the links for + If include_deps is specified, returns a list including the dependencies for service_names, in order of dependency. Preserves the original order of self.services where possible, - reordering as needed to resolve links. + reordering as needed to resolve dependencies. Raises NoSuchService if any of the named services do not exist. """ if service_names is None or len(service_names) == 0: return self.get_services( service_names=[s.name for s in self.services], - include_links=include_links + include_deps=include_deps ) else: unsorted = [self.get_service(name) for name in service_names] services = [s for s in self.services if s in unsorted] - if include_links: - services = reduce(self._inject_links, services, []) + if include_deps: + services = reduce(self._inject_deps, services, []) uniques = [] [uniques.append(s) for s in services if s not in uniques] @@ -146,6 +167,28 @@ def get_volumes_from(self, service_dict): del service_dict['volumes_from'] return volumes_from + def get_net(self, service_dict): + if 'net' in service_dict: + net_name = get_service_name_from_net(service_dict.get('net')) + + if net_name: + try: + net = self.get_service(net_name) + except NoSuchService: + try: + net = Container.from_id(self.client, net_name) + except APIError: + raise ConfigurationError('Serivce "%s" is trying to use the network of "%s", which is not the name of a service or container.' % (service_dict['name'], net_name)) + else: + net = service_dict['net'] + + del service_dict['net'] + + else: + net = 'bridge' + + return net + def start(self, service_names=None, **options): for service in self.get_services(service_names): service.start(**options) @@ -171,13 +214,13 @@ def build(self, service_names=None, no_cache=False): def up(self, service_names=None, - start_links=True, + start_deps=True, recreate=True, insecure_registry=False, detach=False, do_build=True): running_containers = [] - for service in self.get_services(service_names, include_links=start_links): + for service in self.get_services(service_names, include_deps=start_deps): if recreate: for (_, container) in service.recreate_containers( insecure_registry=insecure_registry, @@ -194,7 +237,7 @@ def up(self, return running_containers def pull(self, service_names=None, insecure_registry=False): - for service in self.get_services(service_names, include_links=True): + for service in self.get_services(service_names, include_deps=True): service.pull(insecure_registry=insecure_registry) def remove_stopped(self, service_names=None, **options): @@ -207,19 +250,22 @@ def containers(self, service_names=None, stopped=False, one_off=False): for service in self.get_services(service_names) if service.has_container(container, one_off=one_off)] - def _inject_links(self, acc, service): - linked_names = service.get_linked_names() + def _inject_deps(self, acc, service): + net_name = service.get_net_name() + dep_names = (service.get_linked_names() + + service.get_volumes_from_names() + + ([net_name] if net_name else [])) - if len(linked_names) > 0: - linked_services = self.get_services( - service_names=linked_names, - include_links=True + if len(dep_names) > 0: + dep_services = self.get_services( + service_names=list(set(dep_names)), + include_deps=True ) else: - linked_services = [] + dep_services = [] - linked_services.append(service) - return acc + linked_services + dep_services.append(service) + return acc + dep_services class NoSuchService(Exception): diff --git a/compose/service.py b/compose/service.py index df6dd6ab545..377198cf486 100644 --- a/compose/service.py +++ b/compose/service.py @@ -88,7 +88,7 @@ class ConfigError(ValueError): class Service(object): - def __init__(self, name, client=None, project='default', links=None, external_links=None, volumes_from=None, **options): + def __init__(self, name, client=None, project='default', links=None, external_links=None, volumes_from=None, net=None, **options): if not re.match('^%s+$' % VALID_NAME_CHARS, name): raise ConfigError('Invalid service name "%s" - only %s are allowed' % (name, VALID_NAME_CHARS)) if not re.match('^%s+$' % VALID_NAME_CHARS, project): @@ -116,6 +116,7 @@ def __init__(self, name, client=None, project='default', links=None, external_li self.links = links or [] self.external_links = external_links or [] self.volumes_from = volumes_from or [] + self.net = net or None self.options = options def containers(self, stopped=False, one_off=False): @@ -320,7 +321,6 @@ def start_container(self, container, intermediate_container=None, **override_opt if ':' in volume) privileged = options.get('privileged', False) - net = options.get('net', 'bridge') dns = options.get('dns', None) dns_search = options.get('dns_search', None) cap_add = options.get('cap_add', None) @@ -334,7 +334,7 @@ def start_container(self, container, intermediate_container=None, **override_opt binds=volume_bindings, volumes_from=self._get_volumes_from(intermediate_container), privileged=privileged, - network_mode=net, + network_mode=self._get_net(), dns=dns, dns_search=dns_search, restart_policy=restart, @@ -364,6 +364,15 @@ def start_or_create_containers( def get_linked_names(self): return [s.name for (s, _) in self.links] + def get_volumes_from_names(self): + return [s.name for s in self.volumes_from if isinstance(s, Service)] + + def get_net_name(self): + if isinstance(self.net, Service): + return self.net.name + else: + return + def _next_container_name(self, all_containers, one_off=False): bits = [self.project, self.name] if one_off: @@ -399,7 +408,6 @@ def _get_volumes_from(self, intermediate_container=None): for volume_source in self.volumes_from: if isinstance(volume_source, Service): containers = volume_source.containers(stopped=True) - if not containers: volumes_from.append(volume_source.create_container().id) else: @@ -413,6 +421,25 @@ def _get_volumes_from(self, intermediate_container=None): return volumes_from + def _get_net(self): + if not self.net: + return "bridge" + + if isinstance(self.net, Service): + containers = self.net.containers() + if len(containers) > 0: + net = 'container:' + containers[0].id + else: + log.warning("Warning: Service %s is trying to use reuse the network stack " + "of another service that is not running." % (self.net.name)) + net = None + elif isinstance(self.net, Container): + net = 'container:' + self.net.id + else: + net = self.net + + return net + def _get_container_create_options(self, override_options, one_off=False): container_options = dict( (k, self.options[k]) diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 2577fd61673..17b54daeeaf 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -44,6 +44,63 @@ def test_volumes_from_container(self): db = project.get_service('db') self.assertEqual(db.volumes_from, [data_container]) + project.kill() + project.remove_stopped() + + def test_net_from_service(self): + project = Project.from_config( + name='composetest', + config={ + 'net': { + 'image': 'busybox:latest', + 'command': ["/bin/sleep", "300"] + }, + 'web': { + 'image': 'busybox:latest', + 'net': 'container:net', + 'command': ["/bin/sleep", "300"] + }, + }, + client=self.client, + ) + + project.up() + + web = project.get_service('web') + net = project.get_service('net') + self.assertEqual(web._get_net(), 'container:'+net.containers()[0].id) + + project.kill() + project.remove_stopped() + + def test_net_from_container(self): + net_container = Container.create( + self.client, + image='busybox:latest', + name='composetest_net_container', + command='/bin/sleep 300' + ) + net_container.start() + + project = Project.from_config( + name='composetest', + config={ + 'web': { + 'image': 'busybox:latest', + 'net': 'container:composetest_net_container' + }, + }, + client=self.client, + ) + + project.up() + + web = project.get_service('web') + self.assertEqual(web._get_net(), 'container:'+net_container.id) + + project.kill() + project.remove_stopped() + def test_start_stop_kill_remove(self): web = self.create_service('web') db = self.create_service('db') @@ -199,20 +256,86 @@ def test_project_up_starts_links(self): project.kill() project.remove_stopped() - def test_project_up_with_no_deps(self): - console = self.create_service('console') - db = self.create_service('db', volumes=['/var/db']) - web = self.create_service('web', links=[(db, 'db')]) + def test_project_up_starts_depends(self): + project = Project.from_config( + name='composetest', + config={ + 'console': { + 'image': 'busybox:latest', + 'command': ["/bin/sleep", "300"], + }, + 'net' : { + 'image': 'busybox:latest', + 'command': ["/bin/sleep", "300"] + }, + 'app': { + 'image': 'busybox:latest', + 'command': ["/bin/sleep", "300"], + 'net': 'container:net' + }, + 'web': { + 'image': 'busybox:latest', + 'command': ["/bin/sleep", "300"], + 'net': 'container:net', + 'links': ['app'] + }, + }, + client=self.client, + ) + project.start() + self.assertEqual(len(project.containers()), 0) - project = Project('composetest', [web, db, console], self.client) + project.up(['web']) + self.assertEqual(len(project.containers()), 3) + self.assertEqual(len(project.get_service('web').containers()), 1) + self.assertEqual(len(project.get_service('app').containers()), 1) + self.assertEqual(len(project.get_service('net').containers()), 1) + self.assertEqual(len(project.get_service('console').containers()), 0) + + project.kill() + project.remove_stopped() + + def test_project_up_with_no_deps(self): + project = Project.from_config( + name='composetest', + config={ + 'console': { + 'image': 'busybox:latest', + 'command': ["/bin/sleep", "300"], + }, + 'net' : { + 'image': 'busybox:latest', + 'command': ["/bin/sleep", "300"] + }, + 'vol': { + 'image': 'busybox:latest', + 'command': ["/bin/sleep", "300"], + 'volumes': ["/tmp"] + }, + 'app': { + 'image': 'busybox:latest', + 'command': ["/bin/sleep", "300"], + 'net': 'container:net' + }, + 'web': { + 'image': 'busybox:latest', + 'command': ["/bin/sleep", "300"], + 'net': 'container:net', + 'links': ['app'], + 'volumes_from': ['vol'] + }, + }, + client=self.client, + ) project.start() self.assertEqual(len(project.containers()), 0) - project.up(['web'], start_links=False) - self.assertEqual(len(project.containers()), 1) - self.assertEqual(len(web.containers()), 1) - self.assertEqual(len(db.containers()), 0) - self.assertEqual(len(console.containers()), 0) + project.up(['web'], start_deps=False) + self.assertEqual(len(project.containers(stopped=True)), 2) + self.assertEqual(len(project.get_service('web').containers()), 1) + self.assertEqual(len(project.get_service('vol').containers(stopped=True)), 1) + self.assertEqual(len(project.get_service('net').containers()), 0) + self.assertEqual(len(project.get_service('console').containers()), 0) project.kill() project.remove_stopped() diff --git a/tests/unit/project_test.py b/tests/unit/project_test.py index d7aca64cf9a..b06e14e589a 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -2,6 +2,10 @@ from .. import unittest from compose.service import Service from compose.project import Project, ConfigurationError +from compose.container import Container + +import mock +import docker class ProjectTest(unittest.TestCase): def test_from_dict(self): @@ -120,7 +124,7 @@ def test_get_services_with_include_links(self): ) project = Project('test', [web, db, cache, console], None) self.assertEqual( - project.get_services(['console'], include_links=True), + project.get_services(['console'], include_deps=True), [db, web, console] ) @@ -136,6 +140,105 @@ def test_get_services_removes_duplicates_following_links(self): ) project = Project('test', [web, db], None) self.assertEqual( - project.get_services(['web', 'db'], include_links=True), + project.get_services(['web', 'db'], include_deps=True), [db, web] ) + + def test_use_volumes_from_container(self): + container_id = 'aabbccddee' + container_dict = dict(Name='aaa', Id=container_id) + mock_client = mock.create_autospec(docker.Client) + mock_client.inspect_container.return_value = container_dict + project = Project.from_dicts('test', [ + { + 'name': 'test', + 'image': 'busybox:latest', + 'volumes_from': ['aaa'] + } + ], mock_client) + self.assertEqual(project.get_service('test')._get_volumes_from(), [container_id]) + + def test_use_volumes_from_service_no_container(self): + container_name = 'test_vol_1' + mock_client = mock.create_autospec(docker.Client) + mock_client.containers.return_value = [ + { + "Name": container_name, + "Names": [container_name], + "Id": container_name, + "Image": 'busybox:latest' + } + ] + project = Project.from_dicts('test', [ + { + 'name': 'vol', + 'image': 'busybox:latest' + }, + { + 'name': 'test', + 'image': 'busybox:latest', + 'volumes_from': ['vol'] + } + ], mock_client) + self.assertEqual(project.get_service('test')._get_volumes_from(), [container_name]) + + @mock.patch.object(Service, 'containers') + def test_use_volumes_from_service_container(self, mock_return): + container_ids = ['aabbccddee', '12345'] + mock_return.return_value = [ + mock.Mock(id=container_id, spec=Container) + for container_id in container_ids] + + project = Project.from_dicts('test', [ + { + 'name': 'vol', + 'image': 'busybox:latest' + }, + { + 'name': 'test', + 'image': 'busybox:latest', + 'volumes_from': ['vol'] + } + ], None) + self.assertEqual(project.get_service('test')._get_volumes_from(), container_ids) + + def test_use_net_from_container(self): + container_id = 'aabbccddee' + container_dict = dict(Name='aaa', Id=container_id) + mock_client = mock.create_autospec(docker.Client) + mock_client.inspect_container.return_value = container_dict + project = Project.from_dicts('test', [ + { + 'name': 'test', + 'image': 'busybox:latest', + 'net': 'container:aaa' + } + ], mock_client) + service = project.get_service('test') + self.assertEqual(service._get_net(), 'container:'+container_id) + + def test_use_net_from_service(self): + container_name = 'test_aaa_1' + mock_client = mock.create_autospec(docker.Client) + mock_client.containers.return_value = [ + { + "Name": container_name, + "Names": [container_name], + "Id": container_name, + "Image": 'busybox:latest' + } + ] + project = Project.from_dicts('test', [ + { + 'name': 'aaa', + 'image': 'busybox:latest' + }, + { + 'name': 'test', + 'image': 'busybox:latest', + 'net': 'container:aaa' + } + ], mock_client) + + service = project.get_service('test') + self.assertEqual(service._get_net(), 'container:'+container_name) diff --git a/tests/unit/sort_service_test.py b/tests/unit/sort_service_test.py index 420353c8a39..f42a947484a 100644 --- a/tests/unit/sort_service_test.py +++ b/tests/unit/sort_service_test.py @@ -65,6 +65,95 @@ def test_sort_service_dicts_3(self): self.assertEqual(sorted_services[1]['name'], 'parent') self.assertEqual(sorted_services[2]['name'], 'grandparent') + def test_sort_service_dicts_4(self): + services = [ + { + 'name': 'child' + }, + { + 'name': 'parent', + 'volumes_from': ['child'] + }, + { + 'links': ['parent'], + 'name': 'grandparent' + }, + ] + + 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') + + def test_sort_service_dicts_5(self): + services = [ + { + 'links': ['parent'], + 'name': 'grandparent' + }, + { + 'name': 'parent', + 'net': 'container:child' + }, + { + 'name': 'child' + } + ] + + 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') + + def test_sort_service_dicts_6(self): + services = [ + { + 'links': ['parent'], + 'name': 'grandparent' + }, + { + 'name': 'parent', + 'volumes_from': ['child'] + }, + { + 'name': 'child' + } + ] + + 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') + + def test_sort_service_dicts_7(self): + services = [ + { + 'net': 'container:three', + 'name': 'four' + }, + { + 'links': ['two'], + 'name': 'three' + }, + { + 'name': 'two', + 'volumes_from': ['one'] + }, + { + 'name': 'one' + } + ] + + 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') + def test_sort_service_dicts_circular_imports(self): services = [ { From 74440b2f921e52746be20071ac5a451029a6d66d Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 23 Feb 2015 11:01:37 +0000 Subject: [PATCH 0708/4072] Run tests using Docker-in-Docker so we can test multiple versions Signed-off-by: Aanand Prasad --- CONTRIBUTING.md | 13 +++++++ Dockerfile | 15 +++++++- Dockerfile.tests | 5 +++ script/test | 20 ++++++++-- script/wrapdocker | 98 +++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 147 insertions(+), 4 deletions(-) create mode 100644 Dockerfile.tests create mode 100755 script/wrapdocker diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 00bb7f43799..22cbdcf80b4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,8 +24,21 @@ that should get you started. ## Running the test suite +Use the test script to run linting checks and then the full test suite: + $ script/test +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 + +Arguments to `script/test` are passed through to the `nosetests` 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.integration.service_test + $ script/test tests.integration.service_test:ServiceTest.test_containers + ## Building binaries Linux: diff --git a/Dockerfile b/Dockerfile index ee9fb4a2fcc..fc553cef0f2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,18 @@ FROM debian:wheezy -RUN apt-get update -qq && apt-get install -qy python python-pip python-dev git && apt-get clean + +RUN apt-get update -qq + +# Compose dependencies +RUN apt-get install -qqy python python-pip python-dev git + +# Test dependencies +RUN apt-get install -qqy apt-transport-https ca-certificates curl lxc iptables +RUN curl https://get.docker.com/builds/Linux/x86_64/docker-1.3.3 > /usr/local/bin/docker-1.3.3 && chmod +x /usr/local/bin/docker-1.3.3 +RUN curl https://get.docker.com/builds/Linux/x86_64/docker-1.4.1 > /usr/local/bin/docker-1.4.1 && chmod +x /usr/local/bin/docker-1.4.1 +RUN curl https://get.docker.com/builds/Linux/x86_64/docker-1.5.0 > /usr/local/bin/docker-1.5.0 && chmod +x /usr/local/bin/docker-1.5.0 + +RUN apt-get clean + RUN useradd -d /home/user -m -s /bin/bash user WORKDIR /code/ diff --git a/Dockerfile.tests b/Dockerfile.tests new file mode 100644 index 00000000000..f30564d2b07 --- /dev/null +++ b/Dockerfile.tests @@ -0,0 +1,5 @@ +FROM docker-compose + +ADD script/wrapdocker /usr/local/bin/wrapdocker +VOLUME /var/lib/docker +ENTRYPOINT ["/usr/local/bin/wrapdocker"] diff --git a/script/test b/script/test index fef16b80781..461fe7d13fd 100755 --- a/script/test +++ b/script/test @@ -1,5 +1,19 @@ -#!/bin/sh +#!/bin/bash +# See CONTRIBUTING.md for usage. + set -ex + docker build -t docker-compose . -docker run -v /var/run/docker.sock:/var/run/docker.sock --rm --entrypoint flake8 docker-compose compose -docker run -v /var/run/docker.sock:/var/run/docker.sock --rm --entrypoint nosetests docker-compose $@ +docker run --privileged --rm --entrypoint flake8 docker-compose compose + +docker build -f Dockerfile.tests -t docker-compose-tests . + +if [ "$DOCKER_VERSIONS" == "" ]; then + DOCKER_VERSIONS="1.5.0" +elif [ "$DOCKER_VERSIONS" == "all" ]; then + DOCKER_VERSIONS="1.3.3 1.4.1 1.5.0" +fi + +for version in $DOCKER_VERSIONS; do + docker run --privileged --rm -e "DOCKER_VERSION=$version" docker-compose-tests nosetests "$@" +done diff --git a/script/wrapdocker b/script/wrapdocker new file mode 100755 index 00000000000..e5bcbad6c82 --- /dev/null +++ b/script/wrapdocker @@ -0,0 +1,98 @@ +#!/bin/bash +# Adapted from https://github.com/jpetazzo/dind + +# First, make sure that cgroups are mounted correctly. +CGROUP=/sys/fs/cgroup +: {LOG:=stdio} + +[ -d $CGROUP ] || + mkdir $CGROUP + +mountpoint -q $CGROUP || + mount -n -t tmpfs -o uid=0,gid=0,mode=0755 cgroup $CGROUP || { + echo "Could not make a tmpfs mount. Did you use --privileged?" + exit 1 + } + +if [ -d /sys/kernel/security ] && ! mountpoint -q /sys/kernel/security +then + mount -t securityfs none /sys/kernel/security || { + echo "Could not mount /sys/kernel/security." + echo "AppArmor detection and --privileged mode might break." + } +fi + +# Mount the cgroup hierarchies exactly as they are in the parent system. +for SUBSYS in $(cut -d: -f2 /proc/1/cgroup) +do + [ -d $CGROUP/$SUBSYS ] || mkdir $CGROUP/$SUBSYS + mountpoint -q $CGROUP/$SUBSYS || + mount -n -t cgroup -o $SUBSYS cgroup $CGROUP/$SUBSYS + + # The two following sections address a bug which manifests itself + # by a cryptic "lxc-start: no ns_cgroup option specified" when + # trying to start containers withina container. + # The bug seems to appear when the cgroup hierarchies are not + # mounted on the exact same directories in the host, and in the + # container. + + # Named, control-less cgroups are mounted with "-o name=foo" + # (and appear as such under /proc//cgroup) but are usually + # mounted on a directory named "foo" (without the "name=" prefix). + # Systemd and OpenRC (and possibly others) both create such a + # cgroup. To avoid the aforementioned bug, we symlink "foo" to + # "name=foo". This shouldn't have any adverse effect. + echo $SUBSYS | grep -q ^name= && { + NAME=$(echo $SUBSYS | sed s/^name=//) + ln -s $SUBSYS $CGROUP/$NAME + } + + # Likewise, on at least one system, it has been reported that + # systemd would mount the CPU and CPU accounting controllers + # (respectively "cpu" and "cpuacct") with "-o cpuacct,cpu" + # but on a directory called "cpu,cpuacct" (note the inversion + # in the order of the groups). This tries to work around it. + [ $SUBSYS = cpuacct,cpu ] && ln -s $SUBSYS $CGROUP/cpu,cpuacct +done + +# Note: as I write those lines, the LXC userland tools cannot setup +# a "sub-container" properly if the "devices" cgroup is not in its +# own hierarchy. Let's detect this and issue a warning. +grep -q :devices: /proc/1/cgroup || + echo "WARNING: the 'devices' cgroup should be in its own hierarchy." +grep -qw devices /proc/1/cgroup || + echo "WARNING: it looks like the 'devices' cgroup is not mounted." + +# Now, close extraneous file descriptors. +pushd /proc/self/fd >/dev/null +for FD in * +do + case "$FD" in + # Keep stdin/stdout/stderr + [012]) + ;; + # Nuke everything else + *) + eval exec "$FD>&-" + ;; + esac +done +popd >/dev/null + +if [ "$DOCKER_VERSION" == "" ]; then + DOCKER_VERSION="1.5.0" +fi + +ln -s "/usr/local/bin/docker-$DOCKER_VERSION" "/usr/local/bin/docker" + +# If a pidfile is still around (for example after a container restart), +# delete it so that docker can start. +rm -rf /var/run/docker.pid +docker -d $DOCKER_DAEMON_ARGS &>/var/log/docker.log & + +>&2 echo "Waiting for Docker to start..." +while ! docker ps &>/dev/null; do + sleep 1 +done + +exec "$@" From 42e6296b0ee9e9c1ccb1c9ff249e33775108eba2 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 25 Feb 2015 13:52:55 +0000 Subject: [PATCH 0709/4072] Kick everything off from a single container Signed-off-by: Aanand Prasad --- Dockerfile | 34 +++++++++++------ Dockerfile.tests | 5 --- script/dind | 88 ++++++++++++++++++++++++++++++++++++++++++++ script/test | 23 +++++------- script/test-versions | 26 +++++++++++++ script/wrapdocker | 80 +--------------------------------------- 6 files changed, 147 insertions(+), 109 deletions(-) delete mode 100644 Dockerfile.tests create mode 100755 script/dind create mode 100755 script/test-versions diff --git a/Dockerfile b/Dockerfile index fc553cef0f2..d7a6019aa89 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,17 +1,27 @@ FROM debian:wheezy -RUN apt-get update -qq - -# Compose dependencies -RUN apt-get install -qqy python python-pip python-dev git - -# Test dependencies -RUN apt-get install -qqy apt-transport-https ca-certificates curl lxc iptables -RUN curl https://get.docker.com/builds/Linux/x86_64/docker-1.3.3 > /usr/local/bin/docker-1.3.3 && chmod +x /usr/local/bin/docker-1.3.3 -RUN curl https://get.docker.com/builds/Linux/x86_64/docker-1.4.1 > /usr/local/bin/docker-1.4.1 && chmod +x /usr/local/bin/docker-1.4.1 -RUN curl https://get.docker.com/builds/Linux/x86_64/docker-1.5.0 > /usr/local/bin/docker-1.5.0 && chmod +x /usr/local/bin/docker-1.5.0 - -RUN apt-get clean +RUN set -ex; \ + apt-get update -qq; \ + apt-get install -y \ + python \ + python-pip \ + python-dev \ + git \ + apt-transport-https \ + ca-certificates \ + curl \ + lxc \ + iptables \ + ; \ + rm -rf /var/lib/apt/lists/* + +ENV ALL_DOCKER_VERSIONS 1.3.3 1.4.1 1.5.0 + +RUN set -ex; \ + for v in ${ALL_DOCKER_VERSIONS}; do \ + curl https://get.docker.com/builds/Linux/x86_64/docker-$v -o /usr/local/bin/docker-$v; \ + chmod +x /usr/local/bin/docker-$v; \ + done RUN useradd -d /home/user -m -s /bin/bash user WORKDIR /code/ diff --git a/Dockerfile.tests b/Dockerfile.tests deleted file mode 100644 index f30564d2b07..00000000000 --- a/Dockerfile.tests +++ /dev/null @@ -1,5 +0,0 @@ -FROM docker-compose - -ADD script/wrapdocker /usr/local/bin/wrapdocker -VOLUME /var/lib/docker -ENTRYPOINT ["/usr/local/bin/wrapdocker"] diff --git a/script/dind b/script/dind new file mode 100755 index 00000000000..f8fae6379c0 --- /dev/null +++ b/script/dind @@ -0,0 +1,88 @@ +#!/bin/bash +set -e + +# DinD: a wrapper script which allows docker to be run inside a docker container. +# Original version by Jerome Petazzoni +# See the blog post: http://blog.docker.com/2013/09/docker-can-now-run-within-docker/ +# +# This script should be executed inside a docker container in privilieged mode +# ('docker run --privileged', introduced in docker 0.6). + +# Usage: dind CMD [ARG...] + +# apparmor sucks and Docker needs to know that it's in a container (c) @tianon +export container=docker + +# First, make sure that cgroups are mounted correctly. +CGROUP=/cgroup + +mkdir -p "$CGROUP" + +if ! mountpoint -q "$CGROUP"; then + mount -n -t tmpfs -o uid=0,gid=0,mode=0755 cgroup $CGROUP || { + echo >&2 'Could not make a tmpfs mount. Did you use --privileged?' + exit 1 + } +fi + +if [ -d /sys/kernel/security ] && ! mountpoint -q /sys/kernel/security; then + mount -t securityfs none /sys/kernel/security || { + echo >&2 'Could not mount /sys/kernel/security.' + echo >&2 'AppArmor detection and -privileged mode might break.' + } +fi + +# Mount the cgroup hierarchies exactly as they are in the parent system. +for SUBSYS in $(cut -d: -f2 /proc/1/cgroup); do + mkdir -p "$CGROUP/$SUBSYS" + if ! mountpoint -q $CGROUP/$SUBSYS; then + mount -n -t cgroup -o "$SUBSYS" cgroup "$CGROUP/$SUBSYS" + fi + + # The two following sections address a bug which manifests itself + # by a cryptic "lxc-start: no ns_cgroup option specified" when + # trying to start containers withina container. + # The bug seems to appear when the cgroup hierarchies are not + # mounted on the exact same directories in the host, and in the + # container. + + # Named, control-less cgroups are mounted with "-o name=foo" + # (and appear as such under /proc//cgroup) but are usually + # mounted on a directory named "foo" (without the "name=" prefix). + # Systemd and OpenRC (and possibly others) both create such a + # cgroup. To avoid the aforementioned bug, we symlink "foo" to + # "name=foo". This shouldn't have any adverse effect. + name="${SUBSYS#name=}" + if [ "$name" != "$SUBSYS" ]; then + ln -s "$SUBSYS" "$CGROUP/$name" + fi + + # Likewise, on at least one system, it has been reported that + # systemd would mount the CPU and CPU accounting controllers + # (respectively "cpu" and "cpuacct") with "-o cpuacct,cpu" + # but on a directory called "cpu,cpuacct" (note the inversion + # in the order of the groups). This tries to work around it. + if [ "$SUBSYS" = 'cpuacct,cpu' ]; then + ln -s "$SUBSYS" "$CGROUP/cpu,cpuacct" + fi +done + +# Note: as I write those lines, the LXC userland tools cannot setup +# a "sub-container" properly if the "devices" cgroup is not in its +# own hierarchy. Let's detect this and issue a warning. +if ! grep -q :devices: /proc/1/cgroup; then + echo >&2 'WARNING: the "devices" cgroup should be in its own hierarchy.' +fi +if ! grep -qw devices /proc/1/cgroup; then + echo >&2 'WARNING: it looks like the "devices" cgroup is not mounted.' +fi + +# Mount /tmp +mount -t tmpfs none /tmp + +if [ $# -gt 0 ]; then + exec "$@" +fi + +echo >&2 'ERROR: No command specified.' +echo >&2 'You probably want to run hack/make.sh, or maybe a shell?' diff --git a/script/test b/script/test index 461fe7d13fd..f278023a048 100755 --- a/script/test +++ b/script/test @@ -3,17 +3,14 @@ set -ex -docker build -t docker-compose . -docker run --privileged --rm --entrypoint flake8 docker-compose compose +TAG="docker-compose:$(git rev-parse --short HEAD)" -docker build -f Dockerfile.tests -t docker-compose-tests . - -if [ "$DOCKER_VERSIONS" == "" ]; then - DOCKER_VERSIONS="1.5.0" -elif [ "$DOCKER_VERSIONS" == "all" ]; then - DOCKER_VERSIONS="1.3.3 1.4.1 1.5.0" -fi - -for version in $DOCKER_VERSIONS; do - docker run --privileged --rm -e "DOCKER_VERSION=$version" docker-compose-tests nosetests "$@" -done +docker build -t "$TAG" . +docker run \ + --rm \ + --volume="/var/run/docker.sock:/var/run/docker.sock" \ + -e DOCKER_VERSIONS \ + -e "TAG=$TAG" \ + --entrypoint="script/test-versions" \ + "$TAG" \ + "$@" diff --git a/script/test-versions b/script/test-versions new file mode 100755 index 00000000000..9f30eecaac4 --- /dev/null +++ b/script/test-versions @@ -0,0 +1,26 @@ +#!/bin/bash +# This should be run inside a container built from the Dockerfile +# at the root of the repo - script/test will do it automatically. + +set -e + +>&2 echo "Running lint checks" +flake8 compose + +if [ "$DOCKER_VERSIONS" == "" ]; then + DOCKER_VERSIONS="1.5.0" +elif [ "$DOCKER_VERSIONS" == "all" ]; then + DOCKER_VERSIONS="$ALL_DOCKER_VERSIONS" +fi + +for version in $DOCKER_VERSIONS; do + >&2 echo "Running tests against Docker $version" + docker-1.5.0 run \ + --rm \ + --privileged \ + --volume="/var/lib/docker" \ + -e "DOCKER_VERSION=$version" \ + --entrypoint="script/dind" \ + "$TAG" \ + script/wrapdocker nosetests "$@" +done diff --git a/script/wrapdocker b/script/wrapdocker index e5bcbad6c82..20dc9e3cece 100755 --- a/script/wrapdocker +++ b/script/wrapdocker @@ -1,83 +1,4 @@ #!/bin/bash -# Adapted from https://github.com/jpetazzo/dind - -# First, make sure that cgroups are mounted correctly. -CGROUP=/sys/fs/cgroup -: {LOG:=stdio} - -[ -d $CGROUP ] || - mkdir $CGROUP - -mountpoint -q $CGROUP || - mount -n -t tmpfs -o uid=0,gid=0,mode=0755 cgroup $CGROUP || { - echo "Could not make a tmpfs mount. Did you use --privileged?" - exit 1 - } - -if [ -d /sys/kernel/security ] && ! mountpoint -q /sys/kernel/security -then - mount -t securityfs none /sys/kernel/security || { - echo "Could not mount /sys/kernel/security." - echo "AppArmor detection and --privileged mode might break." - } -fi - -# Mount the cgroup hierarchies exactly as they are in the parent system. -for SUBSYS in $(cut -d: -f2 /proc/1/cgroup) -do - [ -d $CGROUP/$SUBSYS ] || mkdir $CGROUP/$SUBSYS - mountpoint -q $CGROUP/$SUBSYS || - mount -n -t cgroup -o $SUBSYS cgroup $CGROUP/$SUBSYS - - # The two following sections address a bug which manifests itself - # by a cryptic "lxc-start: no ns_cgroup option specified" when - # trying to start containers withina container. - # The bug seems to appear when the cgroup hierarchies are not - # mounted on the exact same directories in the host, and in the - # container. - - # Named, control-less cgroups are mounted with "-o name=foo" - # (and appear as such under /proc//cgroup) but are usually - # mounted on a directory named "foo" (without the "name=" prefix). - # Systemd and OpenRC (and possibly others) both create such a - # cgroup. To avoid the aforementioned bug, we symlink "foo" to - # "name=foo". This shouldn't have any adverse effect. - echo $SUBSYS | grep -q ^name= && { - NAME=$(echo $SUBSYS | sed s/^name=//) - ln -s $SUBSYS $CGROUP/$NAME - } - - # Likewise, on at least one system, it has been reported that - # systemd would mount the CPU and CPU accounting controllers - # (respectively "cpu" and "cpuacct") with "-o cpuacct,cpu" - # but on a directory called "cpu,cpuacct" (note the inversion - # in the order of the groups). This tries to work around it. - [ $SUBSYS = cpuacct,cpu ] && ln -s $SUBSYS $CGROUP/cpu,cpuacct -done - -# Note: as I write those lines, the LXC userland tools cannot setup -# a "sub-container" properly if the "devices" cgroup is not in its -# own hierarchy. Let's detect this and issue a warning. -grep -q :devices: /proc/1/cgroup || - echo "WARNING: the 'devices' cgroup should be in its own hierarchy." -grep -qw devices /proc/1/cgroup || - echo "WARNING: it looks like the 'devices' cgroup is not mounted." - -# Now, close extraneous file descriptors. -pushd /proc/self/fd >/dev/null -for FD in * -do - case "$FD" in - # Keep stdin/stdout/stderr - [012]) - ;; - # Nuke everything else - *) - eval exec "$FD>&-" - ;; - esac -done -popd >/dev/null if [ "$DOCKER_VERSION" == "" ]; then DOCKER_VERSION="1.5.0" @@ -95,4 +16,5 @@ while ! docker ps &>/dev/null; do sleep 1 done +>&2 echo ">" "$@" exec "$@" From 2534a0964fbdf1e6b3d59fed81946c2fb7bb0b2a Mon Sep 17 00:00:00 2001 From: Paul Horn Date: Wed, 21 Jan 2015 00:22:30 +0100 Subject: [PATCH 0710/4072] Add timeout flag to stop, restart, and up The commands `stop`, `restart`, and `up` now support a flag `--timeout`. It represents the number of seconds to give the services to comply to the command. In case of `up`, this is only relevant if running in attached mode. Signed-off-by: Paul Horn --- compose/cli/main.py | 45 ++++++++++++++++++-------- contrib/completion/bash/docker-compose | 42 +++++++++++++++++++++--- 2 files changed, 68 insertions(+), 19 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index eee59bb7489..68c7da526f2 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -387,17 +387,29 @@ def stop(self, project, options): They can be started again with `docker-compose start`. - Usage: stop [SERVICE...] + Usage: stop [options] [SERVICE...] + + Options: + -t, --timeout TIMEOUT Specify a shutdown timeout in seconds. + (default: 10) """ - project.stop(service_names=options['SERVICE']) + timeout = options.get('--timeout') + params = {} if timeout is None else {'timeout': int(timeout)} + project.stop(service_names=options['SERVICE'], **params) def restart(self, project, options): """ Restart running containers. - Usage: restart [SERVICE...] + Usage: restart [options] [SERVICE...] + + Options: + -t, --timeout TIMEOUT Specify a shutdown timeout in seconds. + (default: 10) """ - project.restart(service_names=options['SERVICE']) + timeout = options.get('--timeout') + params = {} if timeout is None else {'timeout': int(timeout)} + project.restart(service_names=options['SERVICE'], **params) def up(self, project, options): """ @@ -416,14 +428,17 @@ def up(self, project, options): Usage: up [options] [SERVICE...] Options: - --allow-insecure-ssl Allow insecure connections to the docker - registry - -d Detached mode: Run containers in the background, - print new container names. - --no-color Produce monochrome output. - --no-deps Don't start linked services. - --no-recreate If containers already exist, don't recreate them. - --no-build Don't build an image, even if it's missing + --allow-insecure-ssl Allow insecure connections to the docker + registry + -d Detached mode: Run containers in the background, + print new container names. + --no-color Produce monochrome output. + --no-deps Don't start linked services. + --no-recreate If containers already exist, don't recreate them. + --no-build Don't build an image, even if it's missing + -t, --timeout TIMEOUT When attached, use this timeout in seconds + for the shutdown. (default: 10) + """ insecure_registry = options['--allow-insecure-ssl'] detached = options['-d'] @@ -439,7 +454,7 @@ def up(self, project, options): start_links=start_links, recreate=recreate, insecure_registry=insecure_registry, - detach=options['-d'], + detach=detached, do_build=not options['--no-build'], ) @@ -458,7 +473,9 @@ def handler(signal, frame): signal.signal(signal.SIGINT, handler) print("Gracefully stopping... (press Ctrl+C again to force)") - project.stop(service_names=service_names) + timeout = options.get('--timeout') + params = {} if timeout is None else {'timeout': int(timeout)} + project.stop(service_names=service_names, **params) def list_containers(containers): diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index 587d70a0226..af336803670 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -1,7 +1,7 @@ #!bash # # bash completion for docker-compose -# +# # This work is based on the completion for the docker command. # # This script provides completion of: @@ -196,7 +196,20 @@ _docker-compose_pull() { _docker-compose_restart() { - __docker-compose_services_running + case "$prev" in + -t | --timeout) + return + ;; + esac + + case "$cur" in + -*) + COMPREPLY=( $( compgen -W "-t --timeout" -- "$cur" ) ) + ;; + *) + __docker-compose_services_running + ;; + esac } @@ -221,7 +234,7 @@ _docker-compose_run() { ;; --entrypoint) return - ;; + ;; esac case "$cur" in @@ -254,14 +267,33 @@ _docker-compose_start() { _docker-compose_stop() { - __docker-compose_services_running + case "$prev" in + -t | --timeout) + return + ;; + esac + + case "$cur" in + -*) + COMPREPLY=( $( compgen -W "-t --timeout" -- "$cur" ) ) + ;; + *) + __docker-compose_services_running + ;; + esac } _docker-compose_up() { + case "$prev" in + -t | --timeout) + return + ;; + esac + case "$cur" in -*) - COMPREPLY=( $( compgen -W "--allow-insecure-ssl -d --no-build --no-color --no-deps --no-recreate" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--allow-insecure-ssl -d --no-build --no-color --no-deps --no-recreate -t --timeout" -- "$cur" ) ) ;; *) __docker-compose_services_all From 86b723e2273e9bac1a6f5b3299ee1394bfa3ec9c Mon Sep 17 00:00:00 2001 From: Ian VanSchooten Date: Sat, 14 Feb 2015 21:08:47 -0500 Subject: [PATCH 0711/4072] Provide user override option on command line Allows overriding a user on the command line from the one specified in the docker-compose.yml The added tests verify that a specified user overrides a default user in the docker-compose.yml file. Based on commit f2f01e207b491866349db7168e3d48082d7abdda by @chmouel Signed-off-by: Ian VanSchooten --- compose/cli/main.py | 5 +++++ .../user-composefile/docker-compose.yml | 4 ++++ tests/integration/cli_test.py | 22 +++++++++++++++++++ tests/unit/cli_test.py | 1 + 4 files changed, 32 insertions(+) create mode 100644 tests/fixtures/user-composefile/docker-compose.yml diff --git a/compose/cli/main.py b/compose/cli/main.py index eee59bb7489..cb6866a0e44 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -275,6 +275,7 @@ def run(self, project, options): new container name. --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. --service-ports Run command with the service's ports enabled and mapped @@ -322,6 +323,10 @@ def run(self, project, options): if options['--entrypoint']: container_options['entrypoint'] = options.get('--entrypoint') + + if options['--user']: + container_options['user'] = options.get('--user') + container = service.create_container( one_off=True, insecure_registry=insecure_registry, diff --git a/tests/fixtures/user-composefile/docker-compose.yml b/tests/fixtures/user-composefile/docker-compose.yml new file mode 100644 index 00000000000..3eb7d397760 --- /dev/null +++ b/tests/fixtures/user-composefile/docker-compose.yml @@ -0,0 +1,4 @@ +service: + image: busybox:latest + user: notauser + command: id diff --git a/tests/integration/cli_test.py b/tests/integration/cli_test.py index 32c4294cc20..7cf19be60c8 100644 --- a/tests/integration/cli_test.py +++ b/tests/integration/cli_test.py @@ -231,6 +231,28 @@ def test_run_service_with_entrypoint_overridden(self, _): u'/bin/echo helloworld' ) + @patch('dockerpty.start') + def test_run_service_with_user_overridden(self, _): + self.command.base_dir = 'tests/fixtures/user-composefile' + name = 'service' + user = 'sshd' + args = ['run', '--user={}'.format(user), name] + self.command.dispatch(args, None) + service = self.project.get_service(name) + container = service.containers(stopped=True, one_off=True)[0] + self.assertEqual(user, container.get('Config.User')) + + @patch('dockerpty.start') + def test_run_service_with_user_overridden_short_form(self, _): + self.command.base_dir = 'tests/fixtures/user-composefile' + name = 'service' + user = 'sshd' + args = ['run', '-u', user, name] + self.command.dispatch(args, None) + service = self.project.get_service(name) + container = service.containers(stopped=True, one_off=True)[0] + self.assertEqual(user, container.get('Config.User')) + @patch('dockerpty.start') def test_run_service_with_environement_overridden(self, _): name = 'service' diff --git a/tests/unit/cli_test.py b/tests/unit/cli_test.py index d9a191ef076..0cb7a1d59ec 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -120,6 +120,7 @@ def test_run_with_environment_merged_with_options_list(self, mock_dockerpty): 'SERVICE': 'service', 'COMMAND': None, '-e': ['BAR=NEW', 'OTHER=THREE'], + '--user': None, '--no-deps': None, '--allow-insecure-ssl': None, '-d': True, From 7b1f01bb520c37f4b18262f16ed15e06dbb98298 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 12 Mar 2015 14:02:14 +0000 Subject: [PATCH 0712/4072] Add script/shell Signed-off-by: Aanand Prasad --- script/shell | 4 ++++ 1 file changed, 4 insertions(+) create mode 100755 script/shell diff --git a/script/shell b/script/shell new file mode 100755 index 00000000000..903be76fc3d --- /dev/null +++ b/script/shell @@ -0,0 +1,4 @@ +#!/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 f393447ac9a7584d2a062d80567e2a2998daf16c Mon Sep 17 00:00:00 2001 From: likeon Date: Thu, 5 Mar 2015 19:43:02 +0300 Subject: [PATCH 0713/4072] Docs: yml link in getting started with django Signed-off-by: Alexander Sterchov i@likeon.name --- docs/django.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/django.md b/docs/django.md index 0605c86b691..99724cd68ad 100644 --- a/docs/django.md +++ b/docs/django.md @@ -55,7 +55,7 @@ mounted inside the containers, and what ports they expose. links: - db -See the [`docker-compose.yml` reference](yml.html) for more information on how +See the [`docker-compose.yml` reference](yml.md) for more information on how this file works. ### Build the project From 81a32a266f998e144d97152cae953300d8956a78 Mon Sep 17 00:00:00 2001 From: Rotem Yaari Date: Fri, 13 Mar 2015 12:40:13 +0200 Subject: [PATCH 0714/4072] Remove restriction for requests version, update docker-py requirement --- requirements.txt | 4 ++-- setup.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/requirements.txt b/requirements.txt index 4c4113ab9f2..582aac1c261 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,8 @@ PyYAML==3.10 -docker-py==1.0.0 +docker-py==1.1.0 dockerpty==0.3.2 docopt==0.6.1 -requests==2.2.1 +requests==2.5.3 six==1.7.3 texttable==0.8.2 websocket-client==0.11.0 diff --git a/setup.py b/setup.py index 0e25abd48d0..cce4d7cdf7f 100644 --- a/setup.py +++ b/setup.py @@ -27,10 +27,10 @@ def find_version(*file_paths): install_requires = [ 'docopt >= 0.6.1, < 0.7', 'PyYAML >= 3.10, < 4', - 'requests >= 2.2.1, < 2.5.0', + 'requests >= 2.5.0, < 2.6', 'texttable >= 0.8.1, < 0.9', 'websocket-client >= 0.11.0, < 1.0', - 'docker-py >= 1.0.0, < 1.1.0', + 'docker-py >= 1.1.0, < 1.2', 'dockerpty >= 0.3.2, < 0.4', 'six >= 1.3.0, < 2', ] From 4ecf5e01ff14b85e60eb2fe02aa15d41931528c2 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 27 Feb 2015 11:54:57 +0000 Subject: [PATCH 0715/4072] Extract YAML loading and parsing into config module Signed-off-by: Aanand Prasad --- compose/cli/command.py | 13 +-- compose/cli/main.py | 3 +- compose/config.py | 180 ++++++++++++++++++++++++++++++ compose/project.py | 19 +--- compose/service.py | 115 +------------------ tests/integration/project_test.py | 41 +++---- tests/integration/service_test.py | 15 +-- tests/integration/testcases.py | 8 +- tests/unit/config_test.py | 134 ++++++++++++++++++++++ tests/unit/project_test.py | 14 +-- tests/unit/service_test.py | 100 ----------------- 11 files changed, 358 insertions(+), 284 deletions(-) create mode 100644 compose/config.py create mode 100644 tests/unit/config_test.py diff --git a/compose/cli/command.py b/compose/cli/command.py index c26f3bc38ed..e829b25b2d8 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -4,9 +4,9 @@ import logging import os import re -import yaml import six +from .. import config from ..project import Project from ..service import ConfigError from .docopt_command import DocoptCommand @@ -69,18 +69,11 @@ def get_client(self, verbose=False): return verbose_proxy.VerboseProxy('docker', client) return client - def get_config(self, config_path): - try: - with open(config_path, 'r') as fh: - return yaml.safe_load(fh) - except IOError as e: - raise errors.UserError(six.text_type(e)) - def get_project(self, config_path, project_name=None, verbose=False): try: - return Project.from_config( + return Project.from_dicts( self.get_project_name(config_path, project_name), - self.get_config(config_path), + config.load(config_path), self.get_client(verbose=verbose)) except ConfigError as e: raise errors.UserError(six.text_type(e)) diff --git a/compose/cli/main.py b/compose/cli/main.py index 434480b504e..aafb199b7f8 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -12,7 +12,8 @@ from .. import __version__ from ..project import NoSuchService, ConfigurationError -from ..service import BuildError, CannotBeScaledError, parse_environment +from ..service import BuildError, CannotBeScaledError +from ..config import parse_environment from .command import Command from .docopt_command import NoSuchCommand from .errors import UserError diff --git a/compose/config.py b/compose/config.py new file mode 100644 index 00000000000..4376d97cfa9 --- /dev/null +++ b/compose/config.py @@ -0,0 +1,180 @@ +import os +import yaml +import six + + +DOCKER_CONFIG_KEYS = [ + 'cap_add', + 'cap_drop', + 'cpu_shares', + 'command', + 'detach', + 'dns', + 'dns_search', + 'domainname', + 'entrypoint', + 'env_file', + 'environment', + 'hostname', + 'image', + 'links', + 'mem_limit', + 'net', + 'ports', + 'privileged', + 'restart', + 'stdin_open', + 'tty', + 'user', + 'volumes', + 'volumes_from', + 'working_dir', +] + +ALLOWED_KEYS = DOCKER_CONFIG_KEYS + [ + 'build', + 'expose', + 'external_links', + 'name', +] + +DOCKER_CONFIG_HINTS = { + 'cpu_share' : 'cpu_shares', + 'link' : 'links', + 'port' : 'ports', + 'privilege' : 'privileged', + 'priviliged': 'privileged', + 'privilige' : 'privileged', + 'volume' : 'volumes', + 'workdir' : 'working_dir', +} + + +def load(filename): + return from_dictionary(load_yaml(filename)) + + +def load_yaml(filename): + try: + with open(filename, 'r') as fh: + return yaml.safe_load(fh) + except IOError as e: + raise ConfigurationError(six.text_type(e)) + + +def from_dictionary(dictionary): + service_dicts = [] + + for service_name, service_dict in list(dictionary.items()): + if not isinstance(service_dict, dict): + raise ConfigurationError('Service "%s" doesn\'t have any configuration options. All top level keys in your docker-compose.yml must map to a dictionary of configuration options.' % service_name) + service_dict = make_service_dict(service_name, service_dict) + service_dicts.append(service_dict) + + return service_dicts + + +def make_service_dict(name, options): + service_dict = options.copy() + service_dict['name'] = name + return process_container_options(service_dict) + + +def process_container_options(service_dict): + for k in service_dict: + if k not in ALLOWED_KEYS: + msg = "Unsupported config option for %s service: '%s'" % (service_dict['name'], k) + if k in DOCKER_CONFIG_HINTS: + msg += " (did you mean '%s'?)" % DOCKER_CONFIG_HINTS[k] + raise ConfigurationError(msg) + + for filename in get_env_files(service_dict): + if not os.path.exists(filename): + raise ConfigurationError("Couldn't find env file for service %s: %s" % (service_dict['name'], filename)) + + if 'environment' in service_dict or 'env_file' in service_dict: + service_dict['environment'] = build_environment(service_dict) + + return service_dict + + +def parse_links(links): + return dict(parse_link(l) for l in links) + + +def parse_link(link): + if ':' in link: + source, alias = link.split(':', 1) + return (alias, source) + else: + return (link, link) + + +def get_env_files(options): + env_files = options.get('env_file', []) + if not isinstance(env_files, list): + env_files = [env_files] + return env_files + + +def build_environment(options): + env = {} + + for f in get_env_files(options): + env.update(env_vars_from_file(f)) + + env.update(parse_environment(options.get('environment'))) + return dict(resolve_env(k, v) for k, v in six.iteritems(env)) + + +def parse_environment(environment): + if not environment: + return {} + + if isinstance(environment, list): + return dict(split_env(e) for e in environment) + + if isinstance(environment, dict): + return environment + + raise ConfigurationError( + "environment \"%s\" must be a list or mapping," % + environment + ) + + +def split_env(env): + if '=' in env: + return env.split('=', 1) + else: + return env, None + + +def resolve_env(key, val): + if val is not None: + return key, val + elif key in os.environ: + return key, os.environ[key] + else: + return key, '' + + +def env_vars_from_file(filename): + """ + Read in a line delimited file of environment variables. + """ + env = {} + for line in open(filename, 'r'): + line = line.strip() + if line and not line.startswith('#'): + k, v = split_env(line) + env[k] = v + return env + + +class ConfigurationError(Exception): + def __init__(self, msg): + self.msg = msg + + def __str__(self): + return self.msg diff --git a/compose/project.py b/compose/project.py index 794ef2b6556..881d8eb0ac5 100644 --- a/compose/project.py +++ b/compose/project.py @@ -3,6 +3,7 @@ import logging from functools import reduce +from .config import ConfigurationError from .service import Service from .container import Container from docker.errors import APIError @@ -85,16 +86,6 @@ def from_dicts(cls, name, service_dicts, client): volumes_from=volumes_from, **service_dict)) return project - @classmethod - def from_config(cls, name, config, client): - dicts = [] - for service_name, service in list(config.items()): - if not isinstance(service, dict): - raise ConfigurationError('Service "%s" doesn\'t have any configuration options. All top level keys in your docker-compose.yml must map to a dictionary of configuration options.' % service_name) - service['name'] = service_name - dicts.append(service) - return cls.from_dicts(name, dicts, client) - def get_service(self, name): """ Retrieve a service by name. Raises NoSuchService @@ -277,13 +268,5 @@ def __str__(self): return self.msg -class ConfigurationError(Exception): - def __init__(self, msg): - self.msg = msg - - def __str__(self): - return self.msg - - class DependencyError(ConfigurationError): pass diff --git a/compose/service.py b/compose/service.py index 377198cf486..c65874c26a6 100644 --- a/compose/service.py +++ b/compose/service.py @@ -8,51 +8,14 @@ import sys from docker.errors import APIError -import six +from .config import DOCKER_CONFIG_KEYS from .container import Container, get_container_name from .progress_stream import stream_output, StreamOutputError log = logging.getLogger(__name__) -DOCKER_CONFIG_KEYS = [ - 'cap_add', - 'cap_drop', - 'cpu_shares', - 'command', - 'detach', - 'dns', - 'dns_search', - 'domainname', - 'entrypoint', - 'env_file', - 'environment', - 'hostname', - 'image', - 'mem_limit', - 'net', - 'ports', - 'privileged', - 'restart', - 'stdin_open', - 'tty', - 'user', - 'volumes', - 'volumes_from', - 'working_dir', -] -DOCKER_CONFIG_HINTS = { - 'cpu_share' : 'cpu_shares', - 'link' : 'links', - 'port' : 'ports', - 'privilege' : 'privileged', - 'priviliged': 'privileged', - 'privilige' : 'privileged', - 'volume' : 'volumes', - 'workdir' : 'working_dir', -} - DOCKER_START_KEYS = [ 'cap_add', 'cap_drop', @@ -96,20 +59,6 @@ def __init__(self, name, client=None, project='default', links=None, external_li if 'image' in options and 'build' in options: raise ConfigError('Service %s has both an image and build path specified. A service can either be built to image or use an existing image, not both.' % name) - for filename in get_env_files(options): - if not os.path.exists(filename): - raise ConfigError("Couldn't find env file for service %s: %s" % (name, filename)) - - supported_options = DOCKER_CONFIG_KEYS + ['build', 'expose', - 'external_links'] - - for k in options: - if k not in supported_options: - msg = "Unsupported config option for %s service: '%s'" % (name, k) - if k in DOCKER_CONFIG_HINTS: - msg += " (did you mean '%s'?)" % DOCKER_CONFIG_HINTS[k] - raise ConfigError(msg) - self.name = name self.client = client self.project = project @@ -478,8 +427,6 @@ def _get_container_create_options(self, override_options, one_off=False): (parse_volume_spec(v).internal, {}) for v in container_options['volumes']) - container_options['environment'] = build_environment(container_options) - if self.can_be_built(): container_options['image'] = self.full_name else: @@ -648,63 +595,3 @@ def split_port(port): external_ip, external_port, internal_port = parts return internal_port, (external_ip, external_port or None) - - -def get_env_files(options): - env_files = options.get('env_file', []) - if not isinstance(env_files, list): - env_files = [env_files] - return env_files - - -def build_environment(options): - env = {} - - for f in get_env_files(options): - env.update(env_vars_from_file(f)) - - env.update(parse_environment(options.get('environment'))) - return dict(resolve_env(k, v) for k, v in six.iteritems(env)) - - -def parse_environment(environment): - if not environment: - return {} - - if isinstance(environment, list): - return dict(split_env(e) for e in environment) - - if isinstance(environment, dict): - return environment - - raise ConfigError("environment \"%s\" must be a list or mapping," % - environment) - - -def split_env(env): - if '=' in env: - return env.split('=', 1) - else: - return env, None - - -def resolve_env(key, val): - if val is not None: - return key, val - elif key in os.environ: - return key, os.environ[key] - else: - return key, '' - - -def env_vars_from_file(filename): - """ - Read in a line delimited file of environment variables. - """ - env = {} - for line in open(filename, 'r'): - line = line.strip() - if line and not line.startswith('#'): - k, v = split_env(line) - env[k] = v - return env diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 17b54daeeaf..a46fc2f5abe 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -1,14 +1,15 @@ from __future__ import unicode_literals -from compose.project import Project, ConfigurationError +from compose import config +from compose.project import Project from compose.container import Container from .testcases import DockerClientTestCase class ProjectTest(DockerClientTestCase): def test_volumes_from_service(self): - project = Project.from_config( + project = Project.from_dicts( name='composetest', - config={ + service_dicts=config.from_dictionary({ 'data': { 'image': 'busybox:latest', 'volumes': ['/var/data'], @@ -17,7 +18,7 @@ def test_volumes_from_service(self): 'image': 'busybox:latest', 'volumes_from': ['data'], }, - }, + }), client=self.client, ) db = project.get_service('db') @@ -31,14 +32,14 @@ def test_volumes_from_container(self): volumes=['/var/data'], name='composetest_data_container', ) - project = Project.from_config( + project = Project.from_dicts( name='composetest', - config={ + service_dicts=config.from_dictionary({ 'db': { 'image': 'busybox:latest', 'volumes_from': ['composetest_data_container'], }, - }, + }), client=self.client, ) db = project.get_service('db') @@ -48,9 +49,9 @@ def test_volumes_from_container(self): project.remove_stopped() def test_net_from_service(self): - project = Project.from_config( + project = Project.from_dicts( name='composetest', - config={ + service_dicts=config.from_dictionary({ 'net': { 'image': 'busybox:latest', 'command': ["/bin/sleep", "300"] @@ -59,8 +60,8 @@ def test_net_from_service(self): 'image': 'busybox:latest', 'net': 'container:net', 'command': ["/bin/sleep", "300"] - }, - }, + }, + }), client=self.client, ) @@ -82,14 +83,14 @@ def test_net_from_container(self): ) net_container.start() - project = Project.from_config( + project = Project.from_dicts( name='composetest', - config={ + service_dicts=config.from_dictionary({ 'web': { 'image': 'busybox:latest', 'net': 'container:composetest_net_container' }, - }, + }), client=self.client, ) @@ -257,9 +258,9 @@ def test_project_up_starts_links(self): project.remove_stopped() def test_project_up_starts_depends(self): - project = Project.from_config( + project = Project.from_dicts( name='composetest', - config={ + service_dicts=config.from_dictionary({ 'console': { 'image': 'busybox:latest', 'command': ["/bin/sleep", "300"], @@ -279,7 +280,7 @@ def test_project_up_starts_depends(self): 'net': 'container:net', 'links': ['app'] }, - }, + }), client=self.client, ) project.start() @@ -296,9 +297,9 @@ def test_project_up_starts_depends(self): project.remove_stopped() def test_project_up_with_no_deps(self): - project = Project.from_config( + project = Project.from_dicts( name='composetest', - config={ + service_dicts=config.from_dictionary({ 'console': { 'image': 'busybox:latest', 'command': ["/bin/sleep", "300"], @@ -324,7 +325,7 @@ def test_project_up_with_no_deps(self): 'links': ['app'], 'volumes_from': ['vol'] }, - }, + }), client=self.client, ) project.start() diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 7b95b870ff0..8008fbbcaa1 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -2,6 +2,7 @@ from __future__ import absolute_import import os from os import path +import mock from compose import Service from compose.service import CannotBeScaledError @@ -481,16 +482,12 @@ def test_env_from_file_combined_with_env(self): for k,v in {'ONE': '1', 'TWO': '2', 'THREE': '3', 'FOO': 'baz', 'DOO': 'dah'}.items(): self.assertEqual(env[k], v) + @mock.patch.dict(os.environ) def test_resolve_env(self): - service = self.create_service('web', environment={'FILE_DEF': 'F1', 'FILE_DEF_EMPTY': '', 'ENV_DEF': None, 'NO_DEF': None}) os.environ['FILE_DEF'] = 'E1' os.environ['FILE_DEF_EMPTY'] = 'E2' os.environ['ENV_DEF'] = 'E3' - try: - env = create_and_start_container(service).environment - for k,v in {'FILE_DEF': 'F1', 'FILE_DEF_EMPTY': '', 'ENV_DEF': 'E3', 'NO_DEF': ''}.items(): - self.assertEqual(env[k], v) - finally: - del os.environ['FILE_DEF'] - del os.environ['FILE_DEF_EMPTY'] - del os.environ['ENV_DEF'] + service = self.create_service('web', environment={'FILE_DEF': 'F1', 'FILE_DEF_EMPTY': '', 'ENV_DEF': None, 'NO_DEF': None}) + env = create_and_start_container(service).environment + for k,v in {'FILE_DEF': 'F1', 'FILE_DEF_EMPTY': '', 'ENV_DEF': 'E3', 'NO_DEF': ''}.items(): + self.assertEqual(env[k], v) diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index 53882561b1c..4f49124cf01 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -1,6 +1,7 @@ from __future__ import unicode_literals from __future__ import absolute_import from compose.service import Service +from compose.config import make_service_dict from compose.cli.docker_client import docker_client from compose.progress_stream import stream_output from .. import unittest @@ -21,14 +22,15 @@ def setUp(self): self.client.remove_image(i) def create_service(self, name, **kwargs): + kwargs['image'] = "busybox:latest" + if 'command' not in kwargs: kwargs['command'] = ["/bin/sleep", "300"] + return Service( project='composetest', - name=name, client=self.client, - image="busybox:latest", - **kwargs + **make_service_dict(name, kwargs) ) def check_build(self, *args, **kwargs): diff --git a/tests/unit/config_test.py b/tests/unit/config_test.py new file mode 100644 index 00000000000..8f59694de93 --- /dev/null +++ b/tests/unit/config_test.py @@ -0,0 +1,134 @@ +import os +import mock +from .. import unittest + +from compose import config + +class ConfigTest(unittest.TestCase): + def test_from_dictionary(self): + service_dicts = config.from_dictionary({ + 'foo': {'image': 'busybox'}, + 'bar': {'environment': ['FOO=1']}, + }) + + self.assertEqual( + sorted(service_dicts, key=lambda d: d['name']), + sorted([ + { + 'name': 'bar', + 'environment': {'FOO': '1'}, + }, + { + 'name': 'foo', + 'image': 'busybox', + } + ]) + ) + + def test_from_dictionary_throws_error_when_not_dict(self): + with self.assertRaises(config.ConfigurationError): + config.from_dictionary({ + 'web': 'busybox:latest', + }) + + def test_config_validation(self): + self.assertRaises( + config.ConfigurationError, + lambda: config.make_service_dict('foo', {'port': ['8000']}) + ) + config.make_service_dict('foo', {'ports': ['8000']}) + + def test_parse_environment_as_list(self): + environment =[ + 'NORMAL=F1', + 'CONTAINS_EQUALS=F=2', + 'TRAILING_EQUALS=', + ] + self.assertEqual( + config.parse_environment(environment), + {'NORMAL': 'F1', 'CONTAINS_EQUALS': 'F=2', 'TRAILING_EQUALS': ''}, + ) + + def test_parse_environment_as_dict(self): + environment = { + 'NORMAL': 'F1', + 'CONTAINS_EQUALS': 'F=2', + 'TRAILING_EQUALS': None, + } + self.assertEqual(config.parse_environment(environment), environment) + + def test_parse_environment_invalid(self): + with self.assertRaises(config.ConfigurationError): + config.parse_environment('a=b') + + def test_parse_environment_empty(self): + self.assertEqual(config.parse_environment(None), {}) + + @mock.patch.dict(os.environ) + def test_resolve_environment(self): + os.environ['FILE_DEF'] = 'E1' + os.environ['FILE_DEF_EMPTY'] = 'E2' + os.environ['ENV_DEF'] = 'E3' + + service_dict = config.make_service_dict( + 'foo', + { + 'environment': { + 'FILE_DEF': 'F1', + 'FILE_DEF_EMPTY': '', + 'ENV_DEF': None, + 'NO_DEF': None + }, + }, + ) + + self.assertEqual( + service_dict['environment'], + {'FILE_DEF': 'F1', 'FILE_DEF_EMPTY': '', 'ENV_DEF': 'E3', 'NO_DEF': ''}, + ) + + def test_env_from_file(self): + service_dict = config.make_service_dict( + 'foo', + {'env_file': 'tests/fixtures/env/one.env'}, + ) + self.assertEqual( + service_dict['environment'], + {'ONE': '2', 'TWO': '1', 'THREE': '3', 'FOO': 'bar'}, + ) + + def test_env_from_multiple_files(self): + service_dict = config.make_service_dict( + 'foo', + { + 'env_file': [ + 'tests/fixtures/env/one.env', + 'tests/fixtures/env/two.env', + ], + }, + ) + self.assertEqual( + service_dict['environment'], + {'ONE': '2', 'TWO': '1', 'THREE': '3', 'FOO': 'baz', 'DOO': 'dah'}, + ) + + def test_env_nonexistent_file(self): + options = {'env_file': 'tests/fixtures/env/nonexistent.env'} + self.assertRaises( + config.ConfigurationError, + lambda: config.make_service_dict('foo', options), + ) + + @mock.patch.dict(os.environ) + def test_resolve_environment_from_file(self): + os.environ['FILE_DEF'] = 'E1' + os.environ['FILE_DEF_EMPTY'] = 'E2' + os.environ['ENV_DEF'] = 'E3' + service_dict = config.make_service_dict( + 'foo', + {'env_file': 'tests/fixtures/env/resolve.env'}, + ) + self.assertEqual( + service_dict['environment'], + {'FILE_DEF': 'F1', 'FILE_DEF_EMPTY': '', 'ENV_DEF': 'E3', 'NO_DEF': ''}, + ) diff --git a/tests/unit/project_test.py b/tests/unit/project_test.py index b06e14e589a..c995d432f9c 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -1,8 +1,9 @@ from __future__ import unicode_literals from .. import unittest from compose.service import Service -from compose.project import Project, ConfigurationError +from compose.project import Project from compose.container import Container +from compose import config import mock import docker @@ -49,26 +50,21 @@ def test_from_dict_sorts_in_dependency_order(self): self.assertEqual(project.services[2].name, 'web') def test_from_config(self): - project = Project.from_config('composetest', { + dicts = config.from_dictionary({ 'web': { 'image': 'busybox:latest', }, 'db': { 'image': 'busybox:latest', }, - }, None) + }) + project = Project.from_dicts('composetest', dicts, None) self.assertEqual(len(project.services), 2) self.assertEqual(project.get_service('web').name, 'web') 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') - def test_from_config_throws_error_when_not_dict(self): - with self.assertRaises(ConfigurationError): - project = Project.from_config('composetest', { - 'web': 'busybox:latest', - }, None) - def test_get_service(self): web = Service( project='composetest', diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 012a51ab648..c70c30bfa11 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -16,7 +16,6 @@ build_port_bindings, build_volume_binding, get_container_name, - parse_environment, parse_repository_tag, parse_volume_spec, split_port, @@ -47,10 +46,6 @@ def test_project_validation(self): self.assertRaises(ConfigError, lambda: Service(name='foo', project='_')) Service(name='foo', project='bar') - def test_config_validation(self): - self.assertRaises(ConfigError, lambda: Service(name='foo', port=['8000'])) - Service(name='foo', ports=['8000']) - def test_get_container_name(self): self.assertIsNone(get_container_name({})) self.assertEqual(get_container_name({'Name': 'myproject_db_1'}), 'myproject_db_1') @@ -321,98 +316,3 @@ def test_building_volume_binding_with_home(self): binding, ('/home/user', dict(bind='/home/user', ro=False))) -class ServiceEnvironmentTest(unittest.TestCase): - - def setUp(self): - self.mock_client = mock.create_autospec(docker.Client) - self.mock_client.containers.return_value = [] - - def test_parse_environment_as_list(self): - environment =[ - 'NORMAL=F1', - 'CONTAINS_EQUALS=F=2', - 'TRAILING_EQUALS=' - ] - self.assertEqual( - parse_environment(environment), - {'NORMAL': 'F1', 'CONTAINS_EQUALS': 'F=2', 'TRAILING_EQUALS': ''}) - - def test_parse_environment_as_dict(self): - environment = { - 'NORMAL': 'F1', - 'CONTAINS_EQUALS': 'F=2', - 'TRAILING_EQUALS': None, - } - self.assertEqual(parse_environment(environment), environment) - - def test_parse_environment_invalid(self): - with self.assertRaises(ConfigError): - parse_environment('a=b') - - def test_parse_environment_empty(self): - self.assertEqual(parse_environment(None), {}) - - @mock.patch.dict(os.environ) - def test_resolve_environment(self): - os.environ['FILE_DEF'] = 'E1' - os.environ['FILE_DEF_EMPTY'] = 'E2' - os.environ['ENV_DEF'] = 'E3' - service = Service( - 'foo', - environment={ - 'FILE_DEF': 'F1', - 'FILE_DEF_EMPTY': '', - 'ENV_DEF': None, - 'NO_DEF': None - }, - client=self.mock_client, - image='image_name', - ) - options = service._get_container_create_options({}) - self.assertEqual( - options['environment'], - {'FILE_DEF': 'F1', 'FILE_DEF_EMPTY': '', 'ENV_DEF': 'E3', 'NO_DEF': ''} - ) - - def test_env_from_file(self): - service = Service('foo', - env_file='tests/fixtures/env/one.env', - client=self.mock_client, - image='image_name', - ) - options = service._get_container_create_options({}) - self.assertEqual( - options['environment'], - {'ONE': '2', 'TWO': '1', 'THREE': '3', 'FOO': 'bar'} - ) - - def test_env_from_multiple_files(self): - service = Service('foo', - env_file=['tests/fixtures/env/one.env', 'tests/fixtures/env/two.env'], - client=self.mock_client, - image='image_name', - ) - options = service._get_container_create_options({}) - self.assertEqual( - options['environment'], - {'ONE': '2', 'TWO': '1', 'THREE': '3', 'FOO': 'baz', 'DOO': 'dah'} - ) - - def test_env_nonexistent_file(self): - self.assertRaises(ConfigError, lambda: Service('foo', env_file='tests/fixtures/env/nonexistent.env')) - - @mock.patch.dict(os.environ) - def test_resolve_environment_from_file(self): - os.environ['FILE_DEF'] = 'E1' - os.environ['FILE_DEF_EMPTY'] = 'E2' - os.environ['ENV_DEF'] = 'E3' - service = Service('foo', - env_file=['tests/fixtures/env/resolve.env'], - client=self.mock_client, - image='image_name', - ) - options = service._get_container_create_options({}) - self.assertEqual( - options['environment'], - {'FILE_DEF': 'F1', 'FILE_DEF_EMPTY': '', 'ENV_DEF': 'E3', 'NO_DEF': ''} - ) From 528bed9ef6921eeea219370d74ec0e6686e10636 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 12 Mar 2015 13:59:23 +0000 Subject: [PATCH 0716/4072] Fix environment resolution Signed-off-by: Aanand Prasad --- compose/config.py | 60 ++++++++++++++-------- docs/yml.md | 9 +++- tests/fixtures/env-file/docker-compose.yml | 4 ++ tests/fixtures/env-file/test.env | 1 + tests/integration/cli_test.py | 16 ++++++ tests/integration/testcases.py | 2 +- tests/unit/config_test.py | 18 +++---- 7 files changed, 77 insertions(+), 33 deletions(-) create mode 100644 tests/fixtures/env-file/docker-compose.yml create mode 100644 tests/fixtures/env-file/test.env diff --git a/compose/config.py b/compose/config.py index 4376d97cfa9..a4e3a991f76 100644 --- a/compose/config.py +++ b/compose/config.py @@ -51,7 +51,8 @@ def load(filename): - return from_dictionary(load_yaml(filename)) + working_dir = os.path.dirname(filename) + return from_dictionary(load_yaml(filename), working_dir=working_dir) def load_yaml(filename): @@ -62,25 +63,26 @@ def load_yaml(filename): raise ConfigurationError(six.text_type(e)) -def from_dictionary(dictionary): +def from_dictionary(dictionary, working_dir=None): service_dicts = [] for service_name, service_dict in list(dictionary.items()): if not isinstance(service_dict, dict): raise ConfigurationError('Service "%s" doesn\'t have any configuration options. All top level keys in your docker-compose.yml must map to a dictionary of configuration options.' % service_name) - service_dict = make_service_dict(service_name, service_dict) + service_dict = make_service_dict(service_name, service_dict, working_dir=working_dir) service_dicts.append(service_dict) return service_dicts -def make_service_dict(name, options): +def make_service_dict(name, options, working_dir=None): service_dict = options.copy() service_dict['name'] = name - return process_container_options(service_dict) + service_dict = resolve_environment(service_dict, working_dir=working_dir) + return process_container_options(service_dict, working_dir=working_dir) -def process_container_options(service_dict): +def process_container_options(service_dict, working_dir=None): for k in service_dict: if k not in ALLOWED_KEYS: msg = "Unsupported config option for %s service: '%s'" % (service_dict['name'], k) @@ -88,13 +90,6 @@ def process_container_options(service_dict): msg += " (did you mean '%s'?)" % DOCKER_CONFIG_HINTS[k] raise ConfigurationError(msg) - for filename in get_env_files(service_dict): - if not os.path.exists(filename): - raise ConfigurationError("Couldn't find env file for service %s: %s" % (service_dict['name'], filename)) - - if 'environment' in service_dict or 'env_file' in service_dict: - service_dict['environment'] = build_environment(service_dict) - return service_dict @@ -110,21 +105,38 @@ def parse_link(link): return (link, link) -def get_env_files(options): +def get_env_files(options, working_dir=None): + if 'env_file' not in options: + return {} + + if working_dir is None: + raise Exception("No working_dir passed to get_env_files()") + env_files = options.get('env_file', []) if not isinstance(env_files, list): env_files = [env_files] - return env_files + + return [expand_path(working_dir, path) for path in env_files] -def build_environment(options): +def resolve_environment(service_dict, working_dir=None): + service_dict = service_dict.copy() + + if 'environment' not in service_dict and 'env_file' not in service_dict: + return service_dict + env = {} - for f in get_env_files(options): - env.update(env_vars_from_file(f)) + if 'env_file' in service_dict: + for f in get_env_files(service_dict, working_dir=working_dir): + env.update(env_vars_from_file(f)) + del service_dict['env_file'] + + env.update(parse_environment(service_dict.get('environment'))) + env = dict(resolve_env_var(k, v) for k, v in six.iteritems(env)) - env.update(parse_environment(options.get('environment'))) - return dict(resolve_env(k, v) for k, v in six.iteritems(env)) + service_dict['environment'] = env + return service_dict def parse_environment(environment): @@ -150,7 +162,7 @@ def split_env(env): return env, None -def resolve_env(key, val): +def resolve_env_var(key, val): if val is not None: return key, val elif key in os.environ: @@ -163,6 +175,8 @@ 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 open(filename, 'r'): line = line.strip() @@ -172,6 +186,10 @@ def env_vars_from_file(filename): return env +def expand_path(working_dir, path): + return os.path.abspath(os.path.join(working_dir, path)) + + class ConfigurationError(Exception): def __init__(self, msg): self.msg = msg diff --git a/docs/yml.md b/docs/yml.md index 035a99e921a..52be706a073 100644 --- a/docs/yml.md +++ b/docs/yml.md @@ -158,11 +158,18 @@ environment: 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: - - .env + - ./common.env + - ./apps/web.env + - /opt/secrets.env ``` ``` diff --git a/tests/fixtures/env-file/docker-compose.yml b/tests/fixtures/env-file/docker-compose.yml new file mode 100644 index 00000000000..d9366ace233 --- /dev/null +++ b/tests/fixtures/env-file/docker-compose.yml @@ -0,0 +1,4 @@ +web: + image: busybox + command: /bin/true + env_file: ./test.env diff --git a/tests/fixtures/env-file/test.env b/tests/fixtures/env-file/test.env new file mode 100644 index 00000000000..c9604dad5b3 --- /dev/null +++ b/tests/fixtures/env-file/test.env @@ -0,0 +1 @@ +FOO=1 \ No newline at end of file diff --git a/tests/integration/cli_test.py b/tests/integration/cli_test.py index 7cf19be60c8..a79b45cfba1 100644 --- a/tests/integration/cli_test.py +++ b/tests/integration/cli_test.py @@ -1,5 +1,6 @@ from __future__ import absolute_import import sys +import os from six import StringIO from mock import patch @@ -23,6 +24,12 @@ def tearDown(self): @property def project(self): + # Hack: allow project to be overridden. This needs refactoring so that + # the project object is built exactly once, by the command object, and + # accessed by the test case object. + if hasattr(self, '_project'): + return self._project + return self.command.get_project(self.command.get_config_path()) def test_help(self): @@ -409,3 +416,12 @@ def get_port(number, mock_stdout): self.assertEqual(get_port(3000), container.get_local_port(3000)) self.assertEqual(get_port(3001), "0.0.0.0:9999") self.assertEqual(get_port(3002), "") + + def test_env_file_relative_to_compose_file(self): + config_path = os.path.abspath('tests/fixtures/env-file/docker-compose.yml') + self.command.dispatch(['-f', config_path, 'up', '-d'], None) + self._project = self.command.get_project(config_path) + + containers = self.project.containers(stopped=True) + self.assertEqual(len(containers), 1) + self.assertIn("FOO=1", containers[0].get('Config.Env')) diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index 4f49124cf01..d5ca1debc8b 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -30,7 +30,7 @@ def create_service(self, name, **kwargs): return Service( project='composetest', client=self.client, - **make_service_dict(name, kwargs) + **make_service_dict(name, kwargs, working_dir='.') ) def check_build(self, *args, **kwargs): diff --git a/tests/unit/config_test.py b/tests/unit/config_test.py index 8f59694de93..4ff08a9effc 100644 --- a/tests/unit/config_test.py +++ b/tests/unit/config_test.py @@ -90,7 +90,8 @@ def test_resolve_environment(self): def test_env_from_file(self): service_dict = config.make_service_dict( 'foo', - {'env_file': 'tests/fixtures/env/one.env'}, + {'env_file': 'one.env'}, + 'tests/fixtures/env', ) self.assertEqual( service_dict['environment'], @@ -100,12 +101,8 @@ def test_env_from_file(self): def test_env_from_multiple_files(self): service_dict = config.make_service_dict( 'foo', - { - 'env_file': [ - 'tests/fixtures/env/one.env', - 'tests/fixtures/env/two.env', - ], - }, + {'env_file': ['one.env', 'two.env']}, + 'tests/fixtures/env', ) self.assertEqual( service_dict['environment'], @@ -113,10 +110,10 @@ def test_env_from_multiple_files(self): ) def test_env_nonexistent_file(self): - options = {'env_file': 'tests/fixtures/env/nonexistent.env'} + options = {'env_file': 'nonexistent.env'} self.assertRaises( config.ConfigurationError, - lambda: config.make_service_dict('foo', options), + lambda: config.make_service_dict('foo', options, 'tests/fixtures/env'), ) @mock.patch.dict(os.environ) @@ -126,7 +123,8 @@ def test_resolve_environment_from_file(self): os.environ['ENV_DEF'] = 'E3' service_dict = config.make_service_dict( 'foo', - {'env_file': 'tests/fixtures/env/resolve.env'}, + {'env_file': 'resolve.env'}, + 'tests/fixtures/env', ) self.assertEqual( service_dict['environment'], From 3c8ef6a94c5d94446eabcbe27df33168ec52234d Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 13 Mar 2015 14:51:26 +0000 Subject: [PATCH 0717/4072] Fix Project.up() tests Signed-off-by: Aanand Prasad --- tests/integration/project_test.py | 37 +++++++++++++------------------ 1 file changed, 15 insertions(+), 22 deletions(-) diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 17b54daeeaf..73e8badc36b 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -264,20 +264,19 @@ def test_project_up_starts_depends(self): 'image': 'busybox:latest', 'command': ["/bin/sleep", "300"], }, - 'net' : { + 'data' : { 'image': 'busybox:latest', 'command': ["/bin/sleep", "300"] }, - 'app': { + 'db': { 'image': 'busybox:latest', 'command': ["/bin/sleep", "300"], - 'net': 'container:net' + 'volumes_from': ['data'], }, 'web': { 'image': 'busybox:latest', 'command': ["/bin/sleep", "300"], - 'net': 'container:net', - 'links': ['app'] + 'links': ['db'], }, }, client=self.client, @@ -288,8 +287,8 @@ def test_project_up_starts_depends(self): project.up(['web']) self.assertEqual(len(project.containers()), 3) self.assertEqual(len(project.get_service('web').containers()), 1) - self.assertEqual(len(project.get_service('app').containers()), 1) - self.assertEqual(len(project.get_service('net').containers()), 1) + self.assertEqual(len(project.get_service('db').containers()), 1) + self.assertEqual(len(project.get_service('data').containers()), 1) self.assertEqual(len(project.get_service('console').containers()), 0) project.kill() @@ -303,26 +302,19 @@ def test_project_up_with_no_deps(self): 'image': 'busybox:latest', 'command': ["/bin/sleep", "300"], }, - 'net' : { + 'data' : { 'image': 'busybox:latest', 'command': ["/bin/sleep", "300"] }, - 'vol': { - 'image': 'busybox:latest', - 'command': ["/bin/sleep", "300"], - 'volumes': ["/tmp"] - }, - 'app': { + 'db': { 'image': 'busybox:latest', 'command': ["/bin/sleep", "300"], - 'net': 'container:net' + 'volumes_from': ['data'], }, 'web': { 'image': 'busybox:latest', 'command': ["/bin/sleep", "300"], - 'net': 'container:net', - 'links': ['app'], - 'volumes_from': ['vol'] + 'links': ['db'], }, }, client=self.client, @@ -330,11 +322,12 @@ def test_project_up_with_no_deps(self): project.start() self.assertEqual(len(project.containers()), 0) - project.up(['web'], start_deps=False) + project.up(['db'], start_deps=False) self.assertEqual(len(project.containers(stopped=True)), 2) - self.assertEqual(len(project.get_service('web').containers()), 1) - self.assertEqual(len(project.get_service('vol').containers(stopped=True)), 1) - self.assertEqual(len(project.get_service('net').containers()), 0) + self.assertEqual(len(project.get_service('web').containers()), 0) + self.assertEqual(len(project.get_service('db').containers()), 1) + self.assertEqual(len(project.get_service('data').containers()), 0) + self.assertEqual(len(project.get_service('data').containers(stopped=True)), 1) self.assertEqual(len(project.get_service('console').containers()), 0) project.kill() From 198598c9364c02b47db54881e92515dbe39d1664 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Fri, 13 Mar 2015 19:36:54 +0000 Subject: [PATCH 0718/4072] Remove wercker status from readme Because it doesn't really work anymore and looks scary when it's broken. Signed-off-by: Ben Firshman --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index c943c70d8a1..1c30420da38 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,6 @@ Docker Compose ============== -[![wercker status](https://app.wercker.com/status/d5dbac3907301c3d5ce735e2d5e95a5b/s/master "wercker status")](https://app.wercker.com/project/bykey/d5dbac3907301c3d5ce735e2d5e95a5b) - *(Previously known as Fig)* Compose is a tool for defining and running complex applications with Docker. From 4c5a80f25344abac06de5fc0e88452990004dace Mon Sep 17 00:00:00 2001 From: funkyfuture Date: Tue, 17 Mar 2015 00:21:29 +0100 Subject: [PATCH 0719/4072] Change port in ports-composefile to 49152 This shall lower the propability to interfere with another service (e.g. the WebUI of an application) that is running on the machine where tests are run. Signed-off-by: funkyfuture --- tests/fixtures/ports-composefile/docker-compose.yml | 2 +- tests/integration/cli_test.py | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/fixtures/ports-composefile/docker-compose.yml b/tests/fixtures/ports-composefile/docker-compose.yml index 5ff08d339c8..2474087d0cd 100644 --- a/tests/fixtures/ports-composefile/docker-compose.yml +++ b/tests/fixtures/ports-composefile/docker-compose.yml @@ -4,4 +4,4 @@ simple: command: /bin/sleep 300 ports: - '3000' - - '9999:3001' + - '49152:3001' diff --git a/tests/integration/cli_test.py b/tests/integration/cli_test.py index a79b45cfba1..2f961d2b2aa 100644 --- a/tests/integration/cli_test.py +++ b/tests/integration/cli_test.py @@ -300,6 +300,7 @@ def test_run_service_without_map_ports(self, __): @patch('dockerpty.start') def test_run_service_with_map_ports(self, __): + # create one off container self.command.base_dir = 'tests/fixtures/ports-composefile' self.command.dispatch(['run', '-d', '--service-ports', 'simple'], None) @@ -315,7 +316,7 @@ def test_run_service_with_map_ports(self, __): # check the ports self.assertNotEqual(port_random, None) self.assertIn("0.0.0.0", port_random) - self.assertEqual(port_assigned, "0.0.0.0:9999") + self.assertEqual(port_assigned, "0.0.0.0:49152") def test_rm(self): service = self.project.get_service('simple') @@ -404,6 +405,7 @@ def test_scale(self): self.assertEqual(len(project.get_service('another').containers()), 0) def test_port(self): + self.command.base_dir = 'tests/fixtures/ports-composefile' self.command.dispatch(['up', '-d'], None) container = self.project.get_service('simple').get_container() @@ -414,7 +416,7 @@ def get_port(number, mock_stdout): return mock_stdout.getvalue().rstrip() self.assertEqual(get_port(3000), container.get_local_port(3000)) - self.assertEqual(get_port(3001), "0.0.0.0:9999") + self.assertEqual(get_port(3001), "0.0.0.0:49152") self.assertEqual(get_port(3002), "") def test_env_file_relative_to_compose_file(self): From 85fb8956f3847d0e167e5bbe05ab698c04250fb7 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 17 Mar 2015 17:01:36 -0700 Subject: [PATCH 0720/4072] Validate DCO in script/test-versions Signed-off-by: Aanand Prasad --- script/test | 1 + script/test-versions | 3 +++ script/validate-dco | 2 ++ 3 files changed, 6 insertions(+) diff --git a/script/test b/script/test index f278023a048..ab0645fdc1a 100755 --- a/script/test +++ b/script/test @@ -9,6 +9,7 @@ docker build -t "$TAG" . docker run \ --rm \ --volume="/var/run/docker.sock:/var/run/docker.sock" \ + --volume="$(pwd):/code" \ -e DOCKER_VERSIONS \ -e "TAG=$TAG" \ --entrypoint="script/test-versions" \ diff --git a/script/test-versions b/script/test-versions index 9f30eecaac4..a9e3bc4c7a9 100755 --- a/script/test-versions +++ b/script/test-versions @@ -4,6 +4,9 @@ set -e +>&2 echo "Validating DCO" +script/validate-dco + >&2 echo "Running lint checks" flake8 compose diff --git a/script/validate-dco b/script/validate-dco index 1c75d91bfaf..701ac5e465c 100755 --- a/script/validate-dco +++ b/script/validate-dco @@ -1,5 +1,7 @@ #!/bin/bash +set -e + source "$(dirname "$BASH_SOURCE")/.validate" adds=$(validate_diff --numstat | awk '{ s += $1 } END { print s }') From 02266757663f659cb52105d4b78103c70d30a026 Mon Sep 17 00:00:00 2001 From: Jessica Frazelle Date: Thu, 19 Mar 2015 17:50:15 -0700 Subject: [PATCH 0721/4072] Add build status \o/ Signed-off-by: Jessica Frazelle --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 1c30420da38..e76431b0682 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ Docker Compose ============== - +[![Build Status](http://jenkins.dockerproject.com/buildStatus/icon?job=Compose Master)](http://jenkins.dockerproject.com/job/Compose%20Master/) *(Previously known as Fig)* Compose is a tool for defining and running complex applications with Docker. From eef4bc39175913e82eb8d814f3f4b03df1f04f16 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 16 Feb 2015 14:30:31 +0000 Subject: [PATCH 0722/4072] Specify all HostConfig at create time This is required for Swarm integration: the cluster needs to know about config like `links` and `volumes_from` at create time so that it can co-schedule containers. Signed-off-by: Aanand Prasad --- compose/cli/docker_client.py | 2 +- compose/cli/main.py | 10 ++-- compose/service.py | 94 +++++++++++++++++++------------ tests/integration/service_test.py | 2 +- 4 files changed, 64 insertions(+), 44 deletions(-) diff --git a/compose/cli/docker_client.py b/compose/cli/docker_client.py index b27948446ca..20acbdebcf3 100644 --- a/compose/cli/docker_client.py +++ b/compose/cli/docker_client.py @@ -32,4 +32,4 @@ def docker_client(): ) timeout = int(os.environ.get('DOCKER_CLIENT_TIMEOUT', 60)) - return Client(base_url=base_url, tls=tls_config, version='1.14', timeout=timeout) + return Client(base_url=base_url, tls=tls_config, version='1.15', timeout=timeout) diff --git a/compose/cli/main.py b/compose/cli/main.py index 2fb8536497d..95dfb6cbd36 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -328,20 +328,20 @@ def run(self, project, options): if options['--user']: container_options['user'] = options.get('--user') + if not options['--service-ports']: + container_options['ports'] = [] + container = service.create_container( one_off=True, insecure_registry=insecure_registry, **container_options ) - service_ports = None - if options['--service-ports']: - service_ports = service.options['ports'] if options['-d']: - service.start_container(container, ports=service_ports, one_off=True) + service.start_container(container) print(container.name) else: - service.start_container(container, ports=service_ports, one_off=True) + service.start_container(container) dockerpty.start(project.client, container.id, interactive=not options['-T']) exit_code = container.wait() if options['--rm']: diff --git a/compose/service.py b/compose/service.py index c65874c26a6..49e19cc56cb 100644 --- a/compose/service.py +++ b/compose/service.py @@ -8,6 +8,7 @@ import sys from docker.errors import APIError +from docker.utils import create_host_config from .config import DOCKER_CONFIG_KEYS from .container import Container, get_container_name @@ -168,6 +169,7 @@ def create_container(self, one_off=False, insecure_registry=False, do_build=True, + intermediate_container=None, **override_options): """ Create a container for this service. If the image doesn't exist, attempt to pull @@ -175,7 +177,9 @@ def create_container(self, """ container_options = self._get_container_create_options( override_options, - one_off=one_off) + one_off=one_off, + intermediate_container=intermediate_container, + ) if (do_build and self.can_be_built() and @@ -240,56 +244,33 @@ def recreate_container(self, container, **override_options): entrypoint=['/bin/echo'], command=[], detach=True, + host_config=create_host_config(volumes_from=[container.id]), ) - intermediate_container.start(volumes_from=container.id) + intermediate_container.start() intermediate_container.wait() container.remove() options = dict(override_options) - new_container = self.create_container(do_build=False, **options) - self.start_container(new_container, intermediate_container=intermediate_container) + new_container = self.create_container( + do_build=False, + intermediate_container=intermediate_container, + **options + ) + self.start_container(new_container) intermediate_container.remove() return (intermediate_container, new_container) - def start_container_if_stopped(self, container, **options): + def start_container_if_stopped(self, container): if container.is_running: return container else: log.info("Starting %s..." % container.name) - return self.start_container(container, **options) - - def start_container(self, container, intermediate_container=None, **override_options): - options = dict(self.options, **override_options) - port_bindings = build_port_bindings(options.get('ports') or []) - - volume_bindings = dict( - build_volume_binding(parse_volume_spec(volume)) - for volume in options.get('volumes') or [] - if ':' in volume) - - privileged = options.get('privileged', False) - dns = options.get('dns', None) - dns_search = options.get('dns_search', None) - cap_add = options.get('cap_add', None) - cap_drop = options.get('cap_drop', None) - - restart = parse_restart_spec(options.get('restart', None)) + return self.start_container(container) - container.start( - links=self._get_links(link_to_self=options.get('one_off', False)), - port_bindings=port_bindings, - binds=volume_bindings, - volumes_from=self._get_volumes_from(intermediate_container), - privileged=privileged, - network_mode=self._get_net(), - dns=dns, - dns_search=dns_search, - restart_policy=restart, - cap_add=cap_add, - cap_drop=cap_drop, - ) + def start_container(self, container): + container.start() return container def start_or_create_containers( @@ -389,7 +370,7 @@ def _get_net(self): return net - def _get_container_create_options(self, override_options, one_off=False): + def _get_container_create_options(self, override_options, one_off=False, intermediate_container=None): container_options = dict( (k, self.options[k]) for k in DOCKER_CONFIG_KEYS if k in self.options) @@ -436,8 +417,47 @@ def _get_container_create_options(self, override_options, one_off=False): for key in DOCKER_START_KEYS: container_options.pop(key, None) + container_options['host_config'] = self._get_container_host_config(override_options, one_off=one_off, intermediate_container=intermediate_container) + return container_options + def _get_container_host_config(self, override_options, one_off=False, intermediate_container=None): + options = dict(self.options, **override_options) + port_bindings = build_port_bindings(options.get('ports') or []) + + volume_bindings = dict( + build_volume_binding(parse_volume_spec(volume)) + for volume in options.get('volumes') or [] + if ':' in volume) + + privileged = options.get('privileged', False) + cap_add = options.get('cap_add', None) + cap_drop = options.get('cap_drop', None) + + dns = options.get('dns', None) + if not isinstance(dns, list): + dns = [dns] + + dns_search = options.get('dns_search', None) + if not isinstance(dns_search, list): + dns_search = [dns_search] + + restart = parse_restart_spec(options.get('restart', None)) + + return create_host_config( + links=self._get_links(link_to_self=one_off), + port_bindings=port_bindings, + binds=volume_bindings, + volumes_from=self._get_volumes_from(intermediate_container), + privileged=privileged, + network_mode=self._get_net(), + dns=dns, + dns_search=dns_search, + restart_policy=restart, + cap_add=cap_add, + cap_drop=cap_drop, + ) + def _get_image_name(self, image): repo, tag = parse_repository_tag(image) if tag == "": diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 8008fbbcaa1..7c169562447 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -13,7 +13,7 @@ def create_and_start_container(service, **override_options): container = service.create_container(**override_options) - return service.start_container(container, **override_options) + return service.start_container(container) class ServiceTest(DockerClientTestCase): From 4c582e4352f056c70b87abb4cb2d51bc231dec74 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 18 Mar 2015 13:51:27 -0700 Subject: [PATCH 0723/4072] Implement `extends` Signed-off-by: Aanand Prasad --- compose/config.py | 163 ++++++++++++++++-- compose/project.py | 13 +- docs/yml.md | 77 +++++++++ tests/fixtures/extends/circle-1.yml | 12 ++ tests/fixtures/extends/circle-2.yml | 12 ++ tests/fixtures/extends/common.yml | 6 + tests/fixtures/extends/docker-compose.yml | 16 ++ .../fixtures/extends/nested-intermediate.yml | 6 + tests/fixtures/extends/nested.yml | 6 + tests/integration/cli_test.py | 27 +++ tests/unit/config_test.py | 111 ++++++++++++ 11 files changed, 421 insertions(+), 28 deletions(-) create mode 100644 tests/fixtures/extends/circle-1.yml create mode 100644 tests/fixtures/extends/circle-2.yml create mode 100644 tests/fixtures/extends/common.yml create mode 100644 tests/fixtures/extends/docker-compose.yml create mode 100644 tests/fixtures/extends/nested-intermediate.yml create mode 100644 tests/fixtures/extends/nested.yml diff --git a/compose/config.py b/compose/config.py index a4e3a991f76..cfa5ce44af1 100644 --- a/compose/config.py +++ b/compose/config.py @@ -52,34 +52,110 @@ def load(filename): working_dir = os.path.dirname(filename) - return from_dictionary(load_yaml(filename), working_dir=working_dir) + return from_dictionary(load_yaml(filename), working_dir=working_dir, filename=filename) -def load_yaml(filename): - try: - with open(filename, 'r') as fh: - return yaml.safe_load(fh) - except IOError as e: - raise ConfigurationError(six.text_type(e)) - - -def from_dictionary(dictionary, working_dir=None): +def from_dictionary(dictionary, working_dir=None, filename=None): service_dicts = [] for service_name, service_dict in list(dictionary.items()): if not isinstance(service_dict, dict): raise ConfigurationError('Service "%s" doesn\'t have any configuration options. All top level keys in your docker-compose.yml must map to a dictionary of configuration options.' % service_name) - service_dict = make_service_dict(service_name, service_dict, working_dir=working_dir) + loader = ServiceLoader(working_dir=working_dir, filename=filename) + service_dict = loader.make_service_dict(service_name, service_dict) service_dicts.append(service_dict) return service_dicts -def make_service_dict(name, options, working_dir=None): - service_dict = options.copy() - service_dict['name'] = name - service_dict = resolve_environment(service_dict, working_dir=working_dir) - return process_container_options(service_dict, working_dir=working_dir) +def make_service_dict(name, service_dict, working_dir=None): + return ServiceLoader(working_dir=working_dir).make_service_dict(name, service_dict) + + +class ServiceLoader(object): + def __init__(self, working_dir, filename=None, already_seen=None): + self.working_dir = working_dir + self.filename = filename + self.already_seen = already_seen or [] + + def make_service_dict(self, name, service_dict): + if self.signature(name) in self.already_seen: + raise CircularReference(self.already_seen) + + service_dict = service_dict.copy() + service_dict['name'] = name + service_dict = resolve_environment(service_dict, working_dir=self.working_dir) + service_dict = self.resolve_extends(service_dict) + return process_container_options(service_dict, working_dir=self.working_dir) + + def resolve_extends(self, service_dict): + if 'extends' not in service_dict: + return service_dict + + extends_options = process_extends_options(service_dict['name'], service_dict['extends']) + + if self.working_dir is None: + raise Exception("No working_dir passed to ServiceLoader()") + + other_config_path = expand_path(self.working_dir, extends_options['file']) + other_working_dir = os.path.dirname(other_config_path) + other_already_seen = self.already_seen + [self.signature(service_dict['name'])] + other_loader = ServiceLoader( + working_dir=other_working_dir, + filename=other_config_path, + already_seen=other_already_seen, + ) + + other_config = load_yaml(other_config_path) + other_service_dict = other_config[extends_options['service']] + other_service_dict = other_loader.make_service_dict( + service_dict['name'], + other_service_dict, + ) + validate_extended_service_dict( + other_service_dict, + filename=other_config_path, + service=extends_options['service'], + ) + + return merge_service_dicts(other_service_dict, service_dict) + + def signature(self, name): + return (self.filename, name) + + +def process_extends_options(service_name, extends_options): + error_prefix = "Invalid 'extends' configuration for %s:" % service_name + + if not isinstance(extends_options, dict): + raise ConfigurationError("%s must be a dictionary" % error_prefix) + + if 'service' not in extends_options: + raise ConfigurationError( + "%s you need to specify a service, e.g. 'service: web'" % error_prefix + ) + + for k, _ in extends_options.items(): + if k not in ['file', 'service']: + raise ConfigurationError( + "%s unsupported configuration option '%s'" % (error_prefix, k) + ) + + return extends_options + + +def validate_extended_service_dict(service_dict, filename, service): + error_prefix = "Cannot extend service '%s' in %s:" % (service, filename) + + if 'links' in service_dict: + raise ConfigurationError("%s services with 'links' cannot be extended" % error_prefix) + + if 'volumes_from' in service_dict: + raise ConfigurationError("%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: + raise ConfigurationError("%s services with 'net: container' cannot be extended" % error_prefix) def process_container_options(service_dict, working_dir=None): @@ -93,6 +169,29 @@ def process_container_options(service_dict, working_dir=None): return service_dict +def merge_service_dicts(base, override): + d = base.copy() + + if 'environment' in base or 'environment' in override: + d['environment'] = merge_environment( + base.get('environment'), + override.get('environment'), + ) + + for k in ALLOWED_KEYS: + if k not in ['environment']: + if k in override: + d[k] = override[k] + + return d + + +def merge_environment(base, override): + env = parse_environment(base) + env.update(parse_environment(override)) + return env + + def parse_links(links): return dict(parse_link(l) for l in links) @@ -190,9 +289,41 @@ def expand_path(working_dir, path): return os.path.abspath(os.path.join(working_dir, path)) +def get_service_name_from_net(net_config): + if not net_config: + return + + if not net_config.startswith('container:'): + return + + _, net_name = net_config.split(':', 1) + return net_name + + +def load_yaml(filename): + try: + with open(filename, 'r') as fh: + return yaml.safe_load(fh) + except IOError as e: + raise ConfigurationError(six.text_type(e)) + + class ConfigurationError(Exception): def __init__(self, msg): self.msg = msg def __str__(self): return self.msg + + +class CircularReference(ConfigurationError): + def __init__(self, trail): + self.trail = trail + + @property + def msg(self): + lines = [ + "{} in {}".format(service_name, filename) + for (filename, service_name) in self.trail + ] + return "Circular reference:\n {}".format("\n extends ".join(lines)) diff --git a/compose/project.py b/compose/project.py index 881d8eb0ac5..7c0d19da390 100644 --- a/compose/project.py +++ b/compose/project.py @@ -3,7 +3,7 @@ import logging from functools import reduce -from .config import ConfigurationError +from .config import get_service_name_from_net, ConfigurationError from .service import Service from .container import Container from docker.errors import APIError @@ -11,17 +11,6 @@ log = logging.getLogger(__name__) -def get_service_name_from_net(net_config): - if not net_config: - return - - if not net_config.startswith('container:'): - return - - _, net_name = net_config.split(':', 1) - return net_name - - def sort_service_dicts(services): # Topological sort (Cormen/Tarjan algorithm). unmarked = services[:] diff --git a/docs/yml.md b/docs/yml.md index 52be706a073..157ba4e67d9 100644 --- a/docs/yml.md +++ b/docs/yml.md @@ -176,6 +176,83 @@ env_file: RACK_ENV: development ``` +### extends + +Extend another service, in the current file or another, optionally overriding +configuration. + +Here's a simple example. Suppose we have 2 files - **common.yml** and +**development.yml**. We can use `extends` to define a service in +**development.yml** which uses configuration defined in **common.yml**: + +**common.yml** + +``` +webapp: + build: ./webapp + environment: + - DEBUG=false + - SEND_EMAILS=false +``` + +**development.yml** + +``` +web: + extends: + file: common.yml + service: webapp + ports: + - "8000:8000" + links: + - db + environment: + - DEBUG=true +db: + image: postgres +``` + +Here, the `web` service in **development.yml** inherits the configuration of +the `webapp` service in **common.yml** - the `build` and `environment` keys - +and adds `ports` and `links` configuration. It overrides one of the defined +environment variables (DEBUG) with a new value, and the other one +(SEND_EMAILS) is left untouched. It's exactly as if you defined `web` like +this: + +```yaml +web: + build: ./webapp + ports: + - "8000:8000" + links: + - db + environment: + - DEBUG=true + - SEND_EMAILS=false +``` + +The `extends` option is great for sharing configuration between different +apps, or for configuring the same app differently for different environments. +You could write a new file for a staging environment, **staging.yml**, which +binds to a different port and doesn't turn on debugging: + +``` +web: + extends: + file: common.yml + service: webapp + ports: + - "80:8000" + links: + - db +db: + image: postgres +``` + +> **Note:** When you extend a service, `links` and `volumes_from` +> configuration options are **not** inherited - you will have to define +> those manually each time you extend it. + ### net Networking mode. Use the same values as the docker client `--net` parameter. diff --git a/tests/fixtures/extends/circle-1.yml b/tests/fixtures/extends/circle-1.yml new file mode 100644 index 00000000000..a034e9619e0 --- /dev/null +++ b/tests/fixtures/extends/circle-1.yml @@ -0,0 +1,12 @@ +foo: + image: busybox +bar: + image: busybox +web: + extends: + file: circle-2.yml + service: web +baz: + image: busybox +quux: + image: busybox diff --git a/tests/fixtures/extends/circle-2.yml b/tests/fixtures/extends/circle-2.yml new file mode 100644 index 00000000000..fa6ddefcc34 --- /dev/null +++ b/tests/fixtures/extends/circle-2.yml @@ -0,0 +1,12 @@ +foo: + image: busybox +bar: + image: busybox +web: + extends: + file: circle-1.yml + service: web +baz: + image: busybox +quux: + image: busybox diff --git a/tests/fixtures/extends/common.yml b/tests/fixtures/extends/common.yml new file mode 100644 index 00000000000..358ef5bcc4c --- /dev/null +++ b/tests/fixtures/extends/common.yml @@ -0,0 +1,6 @@ +web: + image: busybox + command: /bin/true + environment: + - FOO=1 + - BAR=1 diff --git a/tests/fixtures/extends/docker-compose.yml b/tests/fixtures/extends/docker-compose.yml new file mode 100644 index 00000000000..0ae92d2a58d --- /dev/null +++ b/tests/fixtures/extends/docker-compose.yml @@ -0,0 +1,16 @@ +myweb: + extends: + file: common.yml + service: web + command: sleep 300 + links: + - "mydb:db" + environment: + # leave FOO alone + # override BAR + BAR: "2" + # add BAZ + BAZ: "2" +mydb: + image: busybox + command: sleep 300 diff --git a/tests/fixtures/extends/nested-intermediate.yml b/tests/fixtures/extends/nested-intermediate.yml new file mode 100644 index 00000000000..c2dd8c94329 --- /dev/null +++ b/tests/fixtures/extends/nested-intermediate.yml @@ -0,0 +1,6 @@ +webintermediate: + extends: + file: common.yml + service: web + environment: + - "FOO=2" diff --git a/tests/fixtures/extends/nested.yml b/tests/fixtures/extends/nested.yml new file mode 100644 index 00000000000..6025e6d530d --- /dev/null +++ b/tests/fixtures/extends/nested.yml @@ -0,0 +1,6 @@ +myweb: + extends: + file: nested-intermediate.yml + service: webintermediate + environment: + - "BAR=2" diff --git a/tests/integration/cli_test.py b/tests/integration/cli_test.py index 2f961d2b2aa..df3eec66d08 100644 --- a/tests/integration/cli_test.py +++ b/tests/integration/cli_test.py @@ -427,3 +427,30 @@ def test_env_file_relative_to_compose_file(self): containers = self.project.containers(stopped=True) self.assertEqual(len(containers), 1) self.assertIn("FOO=1", containers[0].get('Config.Env')) + + def test_up_with_extends(self): + self.command.base_dir = 'tests/fixtures/extends' + self.command.dispatch(['up', '-d'], None) + + self.assertEqual( + set([s.name for s in self.project.services]), + set(['mydb', 'myweb']), + ) + + # Sort by name so we get [db, web] + containers = sorted( + self.project.containers(stopped=True), + key=lambda c: c.name, + ) + + self.assertEqual(len(containers), 2) + web = containers[1] + + self.assertEqual(set(web.links()), set(['db', 'mydb_1', 'extends_mydb_1'])) + + expected_env = set([ + "FOO=1", + "BAR=2", + "BAZ=2", + ]) + self.assertTrue(expected_env <= set(web.get('Config.Env'))) diff --git a/tests/unit/config_test.py b/tests/unit/config_test.py index 4ff08a9effc..7e18e2e92f1 100644 --- a/tests/unit/config_test.py +++ b/tests/unit/config_test.py @@ -38,6 +38,8 @@ def test_config_validation(self): ) config.make_service_dict('foo', {'ports': ['8000']}) + +class EnvTest(unittest.TestCase): def test_parse_environment_as_list(self): environment =[ 'NORMAL=F1', @@ -130,3 +132,112 @@ def test_resolve_environment_from_file(self): service_dict['environment'], {'FILE_DEF': 'F1', 'FILE_DEF_EMPTY': '', 'ENV_DEF': 'E3', 'NO_DEF': ''}, ) + + +class ExtendsTest(unittest.TestCase): + def test_extends(self): + service_dicts = config.load('tests/fixtures/extends/docker-compose.yml') + + service_dicts = sorted( + service_dicts, + key=lambda sd: sd['name'], + ) + + self.assertEqual(service_dicts, [ + { + 'name': 'mydb', + 'image': 'busybox', + 'command': 'sleep 300', + }, + { + 'name': 'myweb', + 'image': 'busybox', + 'command': 'sleep 300', + 'links': ['mydb:db'], + 'environment': { + "FOO": "1", + "BAR": "2", + "BAZ": "2", + }, + } + ]) + + def test_nested(self): + service_dicts = config.load('tests/fixtures/extends/nested.yml') + + self.assertEqual(service_dicts, [ + { + 'name': 'myweb', + 'image': 'busybox', + 'command': '/bin/true', + 'environment': { + "FOO": "2", + "BAR": "2", + }, + }, + ]) + + def test_circular(self): + try: + config.load('tests/fixtures/extends/circle-1.yml') + raise Exception("Expected config.CircularReference to be raised") + except config.CircularReference as e: + self.assertEqual( + [(os.path.basename(filename), service_name) for (filename, service_name) in e.trail], + [ + ('circle-1.yml', 'web'), + ('circle-2.yml', 'web'), + ('circle-1.yml', 'web'), + ], + ) + + + def test_extends_validation(self): + dictionary = {'extends': None} + load_config = lambda: config.make_service_dict('myweb', dictionary, working_dir='tests/fixtures/extends') + + self.assertRaisesRegexp(config.ConfigurationError, 'dictionary', load_config) + + dictionary['extends'] = {} + self.assertRaises(config.ConfigurationError, load_config) + + dictionary['extends']['file'] = 'common.yml' + self.assertRaisesRegexp(config.ConfigurationError, 'service', load_config) + + dictionary['extends']['service'] = 'web' + self.assertIsInstance(load_config(), dict) + + dictionary['extends']['what'] = 'is this' + self.assertRaisesRegexp(config.ConfigurationError, 'what', load_config) + + def test_blacklisted_options(self): + def load_config(): + return config.make_service_dict('myweb', { + 'extends': { + 'file': 'whatever', + 'service': 'web', + } + }, '.') + + with self.assertRaisesRegexp(config.ConfigurationError, 'links'): + other_config = {'web': {'links': ['db']}} + + with mock.patch.object(config, 'load_yaml', return_value=other_config): + print load_config() + + with self.assertRaisesRegexp(config.ConfigurationError, 'volumes_from'): + other_config = {'web': {'volumes_from': ['db']}} + + with mock.patch.object(config, 'load_yaml', return_value=other_config): + print load_config() + + with self.assertRaisesRegexp(config.ConfigurationError, 'net'): + other_config = {'web': {'net': 'container:db'}} + + with mock.patch.object(config, 'load_yaml', return_value=other_config): + print load_config() + + other_config = {'web': {'net': 'host'}} + + with mock.patch.object(config, 'load_yaml', return_value=other_config): + print load_config() From 37efdb1f8bfa5952fd15a3050f46c00c3fa582f0 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 18 Mar 2015 18:23:17 -0700 Subject: [PATCH 0724/4072] Make volume host paths relative to file, merge volumes when extending Signed-off-by: Aanand Prasad --- compose/config.py | 49 ++++++++++++++++++- .../fixtures/volume-path/common/services.yml | 5 ++ tests/fixtures/volume-path/docker-compose.yml | 6 +++ tests/integration/project_test.py | 21 ++++---- tests/unit/config_test.py | 11 ++++- 5 files changed, 80 insertions(+), 12 deletions(-) create mode 100644 tests/fixtures/volume-path/common/services.yml create mode 100644 tests/fixtures/volume-path/docker-compose.yml diff --git a/compose/config.py b/compose/config.py index cfa5ce44af1..668d2b726ec 100644 --- a/compose/config.py +++ b/compose/config.py @@ -166,6 +166,11 @@ def process_container_options(service_dict, working_dir=None): msg += " (did you mean '%s'?)" % DOCKER_CONFIG_HINTS[k] raise ConfigurationError(msg) + service_dict = service_dict.copy() + + if 'volumes' in service_dict: + service_dict['volumes'] = resolve_host_paths(service_dict['volumes'], working_dir=working_dir) + return service_dict @@ -178,8 +183,14 @@ def merge_service_dicts(base, override): override.get('environment'), ) + if 'volumes' in base or 'volumes' in override: + d['volumes'] = merge_volumes( + base.get('volumes'), + override.get('volumes'), + ) + for k in ALLOWED_KEYS: - if k not in ['environment']: + if k not in ['environment', 'volumes']: if k in override: d[k] = override[k] @@ -285,6 +296,42 @@ def env_vars_from_file(filename): return env +def resolve_host_paths(volumes, working_dir=None): + if working_dir is None: + raise Exception("No working_dir passed to resolve_host_paths()") + + return [resolve_host_path(v, working_dir) for v in volumes] + + +def resolve_host_path(volume, working_dir): + container_path, host_path = split_volume(volume) + if host_path is not None: + return "%s:%s" % (expand_path(working_dir, host_path), container_path) + else: + return container_path + + +def merge_volumes(base, override): + d = dict_from_volumes(base) + d.update(dict_from_volumes(override)) + return volumes_from_dict(d) + + +def dict_from_volumes(volumes): + return dict(split_volume(v) for v in volumes) + + +def split_volume(volume): + if ':' in volume: + return reversed(volume.split(':', 1)) + else: + return (volume, None) + + +def volumes_from_dict(d): + return ["%s:%s" % (host_path, container_path) for (container_path, host_path) in d.items()] + + def expand_path(working_dir, path): return os.path.abspath(os.path.join(working_dir, path)) diff --git a/tests/fixtures/volume-path/common/services.yml b/tests/fixtures/volume-path/common/services.yml new file mode 100644 index 00000000000..2dbf75961d0 --- /dev/null +++ b/tests/fixtures/volume-path/common/services.yml @@ -0,0 +1,5 @@ +db: + image: busybox + volumes: + - ./foo:/foo + - ./bar:/bar diff --git a/tests/fixtures/volume-path/docker-compose.yml b/tests/fixtures/volume-path/docker-compose.yml new file mode 100644 index 00000000000..af433c52f79 --- /dev/null +++ b/tests/fixtures/volume-path/docker-compose.yml @@ -0,0 +1,6 @@ +db: + extends: + file: common/services.yml + service: db + volumes: + - ./bar:/bar diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 3212b0be9a9..a585740f4c9 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -7,18 +7,19 @@ class ProjectTest(DockerClientTestCase): def test_volumes_from_service(self): + service_dicts = config.from_dictionary({ + 'data': { + 'image': 'busybox:latest', + 'volumes': ['/var/data'], + }, + 'db': { + 'image': 'busybox:latest', + 'volumes_from': ['data'], + }, + }, working_dir='.') project = Project.from_dicts( name='composetest', - service_dicts=config.from_dictionary({ - 'data': { - 'image': 'busybox:latest', - 'volumes': ['/var/data'], - }, - 'db': { - 'image': 'busybox:latest', - 'volumes_from': ['data'], - }, - }), + service_dicts=service_dicts, client=self.client, ) db = project.get_service('db') diff --git a/tests/unit/config_test.py b/tests/unit/config_test.py index 7e18e2e92f1..013ad503156 100644 --- a/tests/unit/config_test.py +++ b/tests/unit/config_test.py @@ -133,7 +133,6 @@ def test_resolve_environment_from_file(self): {'FILE_DEF': 'F1', 'FILE_DEF_EMPTY': '', 'ENV_DEF': 'E3', 'NO_DEF': ''}, ) - class ExtendsTest(unittest.TestCase): def test_extends(self): service_dicts = config.load('tests/fixtures/extends/docker-compose.yml') @@ -241,3 +240,13 @@ def load_config(): with mock.patch.object(config, 'load_yaml', return_value=other_config): print load_config() + + def test_volume_path(self): + dicts = config.load('tests/fixtures/volume-path/docker-compose.yml') + + paths = [ + '%s:/foo' % os.path.abspath('tests/fixtures/volume-path/common/foo'), + '%s:/bar' % os.path.abspath('tests/fixtures/volume-path/bar'), + ] + + self.assertEqual(set(dicts[0]['volumes']), set(paths)) From d209ded13c9a7478fc47c41b2319fcf3c1935131 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 19 Mar 2015 11:21:58 -0700 Subject: [PATCH 0725/4072] Use dev version of Docker Signed-off-by: Aanand Prasad --- Dockerfile | 19 ++++++++++++------- script/test-versions | 4 ++-- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/Dockerfile b/Dockerfile index d7a6019aa89..1b016e9eeb9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,13 +15,18 @@ RUN set -ex; \ ; \ rm -rf /var/lib/apt/lists/* -ENV ALL_DOCKER_VERSIONS 1.3.3 1.4.1 1.5.0 - -RUN set -ex; \ - for v in ${ALL_DOCKER_VERSIONS}; do \ - curl https://get.docker.com/builds/Linux/x86_64/docker-$v -o /usr/local/bin/docker-$v; \ - chmod +x /usr/local/bin/docker-$v; \ - done +# ENV ALL_DOCKER_VERSIONS 1.6.0 + +# RUN set -ex; \ +# for v in ${ALL_DOCKER_VERSIONS}; do \ +# curl https://get.docker.com/builds/Linux/x86_64/docker-$v -o /usr/local/bin/docker-$v; \ +# chmod +x /usr/local/bin/docker-$v; \ +# done + +# Temporarily use dev version of Docker +ENV ALL_DOCKER_VERSIONS dev +RUN curl https://master.dockerproject.com/linux/amd64/docker-1.5.0-dev > /usr/local/bin/docker-dev +RUN chmod +x /usr/local/bin/docker-dev RUN useradd -d /home/user -m -s /bin/bash user WORKDIR /code/ diff --git a/script/test-versions b/script/test-versions index a9e3bc4c7a9..d44cc9a7b10 100755 --- a/script/test-versions +++ b/script/test-versions @@ -11,14 +11,14 @@ script/validate-dco flake8 compose if [ "$DOCKER_VERSIONS" == "" ]; then - DOCKER_VERSIONS="1.5.0" + DOCKER_VERSIONS="dev" elif [ "$DOCKER_VERSIONS" == "all" ]; then DOCKER_VERSIONS="$ALL_DOCKER_VERSIONS" fi for version in $DOCKER_VERSIONS; do >&2 echo "Running tests against Docker $version" - docker-1.5.0 run \ + docker-$version run \ --rm \ --privileged \ --volume="/var/lib/docker" \ From 721327110d922f807a225a572d772168c8c22641 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 19 Mar 2015 16:10:27 -0700 Subject: [PATCH 0726/4072] Add 'labels:' config option Signed-off-by: Aanand Prasad --- compose/cli/docker_client.py | 2 +- compose/config.py | 27 +++++++++++++++++++++++++++ docs/install.md | 2 +- docs/yml.md | 18 ++++++++++++++++++ requirements.txt | 2 +- tests/integration/service_test.py | 27 +++++++++++++++++++++++++++ 6 files changed, 75 insertions(+), 3 deletions(-) diff --git a/compose/cli/docker_client.py b/compose/cli/docker_client.py index 20acbdebcf3..e513182fb3c 100644 --- a/compose/cli/docker_client.py +++ b/compose/cli/docker_client.py @@ -32,4 +32,4 @@ def docker_client(): ) timeout = int(os.environ.get('DOCKER_CLIENT_TIMEOUT', 60)) - return Client(base_url=base_url, tls=tls_config, version='1.15', timeout=timeout) + return Client(base_url=base_url, tls=tls_config, version='1.18', timeout=timeout) diff --git a/compose/config.py b/compose/config.py index 668d2b726ec..0f7eec8b8bf 100644 --- a/compose/config.py +++ b/compose/config.py @@ -17,6 +17,7 @@ 'environment', 'hostname', 'image', + 'labels', 'links', 'mem_limit', 'net', @@ -171,6 +172,9 @@ def process_container_options(service_dict, working_dir=None): if 'volumes' in service_dict: service_dict['volumes'] = resolve_host_paths(service_dict['volumes'], working_dir=working_dir) + if 'labels' in service_dict: + service_dict['labels'] = parse_labels(service_dict['labels']) + return service_dict @@ -332,6 +336,29 @@ def volumes_from_dict(d): return ["%s:%s" % (host_path, container_path) for (container_path, host_path) in d.items()] +def parse_labels(labels): + if not labels: + return {} + + if isinstance(labels, list): + return dict(split_label(e) for e in labels) + + if isinstance(labels, dict): + return labels + + raise ConfigurationError( + "labels \"%s\" must be a list or mapping" % + labels + ) + + +def split_label(label): + if '=' in label: + return label.split('=', 1) + else: + return label, '' + + def expand_path(working_dir, path): return os.path.abspath(os.path.join(working_dir, path)) diff --git a/docs/install.md b/docs/install.md index 0e60e1f184a..00e4a3e3a2c 100644 --- a/docs/install.md +++ b/docs/install.md @@ -10,7 +10,7 @@ Compose with a `curl` command. ### Install Docker -First, install Docker version 1.3 or greater: +First, install Docker version 1.6 or greater: - [Instructions for Mac OS X](http://docs.docker.com/installation/mac/) - [Instructions for Ubuntu](http://docs.docker.com/installation/ubuntulinux/) diff --git a/docs/yml.md b/docs/yml.md index 157ba4e67d9..a85f0923f87 100644 --- a/docs/yml.md +++ b/docs/yml.md @@ -253,6 +253,24 @@ db: > configuration options are **not** inherited - you will have to define > those manually each time you extend it. +### labels + +Add metadata to containers using [Docker labels](http://docs.docker.com/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" +``` + ### net Networking mode. Use the same values as the docker client `--net` parameter. diff --git a/requirements.txt b/requirements.txt index 582aac1c261..32c2b082b35 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ PyYAML==3.10 -docker-py==1.1.0 +-e git+https://github.com/docker/docker-py.git@70ce156e26d283d181e6ec10bd1309ddc1da1bbd#egg=docker-py dockerpty==0.3.2 docopt==0.6.1 requests==2.5.3 diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 7c169562447..4e5ef8adb11 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -491,3 +491,30 @@ def test_resolve_env(self): env = create_and_start_container(service).environment for k,v in {'FILE_DEF': 'F1', 'FILE_DEF_EMPTY': '', 'ENV_DEF': 'E3', 'NO_DEF': ''}.items(): self.assertEqual(env[k], v) + + def test_labels(self): + labels_dict = { + 'com.example.description': "Accounting webapp", + 'com.example.department': "Finance", + 'com.example.label-with-empty-value': "", + } + + service = self.create_service('web', labels=labels_dict) + labels = create_and_start_container(service).get('Config.Labels').items() + for pair in labels_dict.items(): + self.assertIn(pair, labels) + + labels_list = ["%s=%s" % pair for pair in labels_dict.items()] + + service = self.create_service('web', labels=labels_list) + labels = create_and_start_container(service).get('Config.Labels').items() + for pair in labels_dict.items(): + self.assertIn(pair, labels) + + def test_empty_labels(self): + labels_list = ['foo', 'bar'] + + service = self.create_service('web', labels=labels_list) + labels = create_and_start_container(service).get('Config.Labels').items() + for name in labels_list: + self.assertIn((name, ''), labels) From 965426e39b21e4ede9b4d9278cc8af50ca2bae35 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 23 Mar 2015 14:41:43 -0700 Subject: [PATCH 0727/4072] Revert "Add 'labels:' config option" This reverts commit 721327110d922f807a225a572d772168c8c22641. Signed-off-by: Aanand Prasad --- compose/cli/docker_client.py | 2 +- compose/config.py | 27 --------------------------- docs/install.md | 2 +- docs/yml.md | 18 ------------------ requirements.txt | 2 +- tests/integration/service_test.py | 27 --------------------------- 6 files changed, 3 insertions(+), 75 deletions(-) diff --git a/compose/cli/docker_client.py b/compose/cli/docker_client.py index e513182fb3c..20acbdebcf3 100644 --- a/compose/cli/docker_client.py +++ b/compose/cli/docker_client.py @@ -32,4 +32,4 @@ def docker_client(): ) timeout = int(os.environ.get('DOCKER_CLIENT_TIMEOUT', 60)) - return Client(base_url=base_url, tls=tls_config, version='1.18', timeout=timeout) + return Client(base_url=base_url, tls=tls_config, version='1.15', timeout=timeout) diff --git a/compose/config.py b/compose/config.py index 0f7eec8b8bf..668d2b726ec 100644 --- a/compose/config.py +++ b/compose/config.py @@ -17,7 +17,6 @@ 'environment', 'hostname', 'image', - 'labels', 'links', 'mem_limit', 'net', @@ -172,9 +171,6 @@ def process_container_options(service_dict, working_dir=None): if 'volumes' in service_dict: service_dict['volumes'] = resolve_host_paths(service_dict['volumes'], working_dir=working_dir) - if 'labels' in service_dict: - service_dict['labels'] = parse_labels(service_dict['labels']) - return service_dict @@ -336,29 +332,6 @@ def volumes_from_dict(d): return ["%s:%s" % (host_path, container_path) for (container_path, host_path) in d.items()] -def parse_labels(labels): - if not labels: - return {} - - if isinstance(labels, list): - return dict(split_label(e) for e in labels) - - if isinstance(labels, dict): - return labels - - raise ConfigurationError( - "labels \"%s\" must be a list or mapping" % - labels - ) - - -def split_label(label): - if '=' in label: - return label.split('=', 1) - else: - return label, '' - - def expand_path(working_dir, path): return os.path.abspath(os.path.join(working_dir, path)) diff --git a/docs/install.md b/docs/install.md index 00e4a3e3a2c..0e60e1f184a 100644 --- a/docs/install.md +++ b/docs/install.md @@ -10,7 +10,7 @@ Compose with a `curl` command. ### Install Docker -First, install Docker version 1.6 or greater: +First, install Docker version 1.3 or greater: - [Instructions for Mac OS X](http://docs.docker.com/installation/mac/) - [Instructions for Ubuntu](http://docs.docker.com/installation/ubuntulinux/) diff --git a/docs/yml.md b/docs/yml.md index a85f0923f87..157ba4e67d9 100644 --- a/docs/yml.md +++ b/docs/yml.md @@ -253,24 +253,6 @@ db: > configuration options are **not** inherited - you will have to define > those manually each time you extend it. -### labels - -Add metadata to containers using [Docker labels](http://docs.docker.com/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" -``` - ### net Networking mode. Use the same values as the docker client `--net` parameter. diff --git a/requirements.txt b/requirements.txt index 32c2b082b35..582aac1c261 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ PyYAML==3.10 --e git+https://github.com/docker/docker-py.git@70ce156e26d283d181e6ec10bd1309ddc1da1bbd#egg=docker-py +docker-py==1.1.0 dockerpty==0.3.2 docopt==0.6.1 requests==2.5.3 diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 4e5ef8adb11..7c169562447 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -491,30 +491,3 @@ def test_resolve_env(self): env = create_and_start_container(service).environment for k,v in {'FILE_DEF': 'F1', 'FILE_DEF_EMPTY': '', 'ENV_DEF': 'E3', 'NO_DEF': ''}.items(): self.assertEqual(env[k], v) - - def test_labels(self): - labels_dict = { - 'com.example.description': "Accounting webapp", - 'com.example.department': "Finance", - 'com.example.label-with-empty-value': "", - } - - service = self.create_service('web', labels=labels_dict) - labels = create_and_start_container(service).get('Config.Labels').items() - for pair in labels_dict.items(): - self.assertIn(pair, labels) - - labels_list = ["%s=%s" % pair for pair in labels_dict.items()] - - service = self.create_service('web', labels=labels_list) - labels = create_and_start_container(service).get('Config.Labels').items() - for pair in labels_dict.items(): - self.assertIn(pair, labels) - - def test_empty_labels(self): - labels_list = ['foo', 'bar'] - - service = self.create_service('web', labels=labels_list) - labels = create_and_start_container(service).get('Config.Labels').items() - for name in labels_list: - self.assertIn((name, ''), labels) From 16495c577b673f16218b64e8547b4c5d2ae490ec Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 23 Mar 2015 14:41:53 -0700 Subject: [PATCH 0728/4072] Revert "Use dev version of Docker" This reverts commit d209ded13c9a7478fc47c41b2319fcf3c1935131. Signed-off-by: Aanand Prasad --- Dockerfile | 19 +++++++------------ script/test-versions | 4 ++-- 2 files changed, 9 insertions(+), 14 deletions(-) diff --git a/Dockerfile b/Dockerfile index 1b016e9eeb9..d7a6019aa89 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,18 +15,13 @@ RUN set -ex; \ ; \ rm -rf /var/lib/apt/lists/* -# ENV ALL_DOCKER_VERSIONS 1.6.0 - -# RUN set -ex; \ -# for v in ${ALL_DOCKER_VERSIONS}; do \ -# curl https://get.docker.com/builds/Linux/x86_64/docker-$v -o /usr/local/bin/docker-$v; \ -# chmod +x /usr/local/bin/docker-$v; \ -# done - -# Temporarily use dev version of Docker -ENV ALL_DOCKER_VERSIONS dev -RUN curl https://master.dockerproject.com/linux/amd64/docker-1.5.0-dev > /usr/local/bin/docker-dev -RUN chmod +x /usr/local/bin/docker-dev +ENV ALL_DOCKER_VERSIONS 1.3.3 1.4.1 1.5.0 + +RUN set -ex; \ + for v in ${ALL_DOCKER_VERSIONS}; do \ + curl https://get.docker.com/builds/Linux/x86_64/docker-$v -o /usr/local/bin/docker-$v; \ + chmod +x /usr/local/bin/docker-$v; \ + done RUN useradd -d /home/user -m -s /bin/bash user WORKDIR /code/ diff --git a/script/test-versions b/script/test-versions index d44cc9a7b10..a9e3bc4c7a9 100755 --- a/script/test-versions +++ b/script/test-versions @@ -11,14 +11,14 @@ script/validate-dco flake8 compose if [ "$DOCKER_VERSIONS" == "" ]; then - DOCKER_VERSIONS="dev" + DOCKER_VERSIONS="1.5.0" elif [ "$DOCKER_VERSIONS" == "all" ]; then DOCKER_VERSIONS="$ALL_DOCKER_VERSIONS" fi for version in $DOCKER_VERSIONS; do >&2 echo "Running tests against Docker $version" - docker-$version run \ + docker-1.5.0 run \ --rm \ --privileged \ --volume="/var/lib/docker" \ From f4ef2c09d66a0bf9fe5a82259e4a88af9f5c16e1 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 23 Mar 2015 14:42:21 -0700 Subject: [PATCH 0729/4072] Revert "Remove restriction for requests version, update docker-py requirement" This reverts commit 81a32a266f998e144d97152cae953300d8956a78. Signed-off-by: Aanand Prasad --- requirements.txt | 4 ++-- setup.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/requirements.txt b/requirements.txt index 582aac1c261..4c4113ab9f2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,8 @@ PyYAML==3.10 -docker-py==1.1.0 +docker-py==1.0.0 dockerpty==0.3.2 docopt==0.6.1 -requests==2.5.3 +requests==2.2.1 six==1.7.3 texttable==0.8.2 websocket-client==0.11.0 diff --git a/setup.py b/setup.py index cce4d7cdf7f..0e25abd48d0 100644 --- a/setup.py +++ b/setup.py @@ -27,10 +27,10 @@ def find_version(*file_paths): install_requires = [ 'docopt >= 0.6.1, < 0.7', 'PyYAML >= 3.10, < 4', - 'requests >= 2.5.0, < 2.6', + 'requests >= 2.2.1, < 2.5.0', 'texttable >= 0.8.1, < 0.9', 'websocket-client >= 0.11.0, < 1.0', - 'docker-py >= 1.1.0, < 1.2', + 'docker-py >= 1.0.0, < 1.1.0', 'dockerpty >= 0.3.2, < 0.4', 'six >= 1.3.0, < 2', ] From 1c14fc06da0d685b3af7f00eb97310e05174f88b Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 23 Mar 2015 14:44:28 -0700 Subject: [PATCH 0730/4072] Update docker-py and requests version ranges Leave the pinned versions in requirements.txt alone, as there's an incompatibility between PyInstaller and requests 2.5.2 and 2.5.3, and by extension with docker-py 1.1.0. Signed-off-by: Aanand Prasad --- setup.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 0e25abd48d0..a8f7d98ea19 100644 --- a/setup.py +++ b/setup.py @@ -27,10 +27,10 @@ def find_version(*file_paths): install_requires = [ 'docopt >= 0.6.1, < 0.7', 'PyYAML >= 3.10, < 4', - 'requests >= 2.2.1, < 2.5.0', + 'requests >= 2.2.1, < 2.6', 'texttable >= 0.8.1, < 0.9', 'websocket-client >= 0.11.0, < 1.0', - 'docker-py >= 1.0.0, < 1.1.0', + 'docker-py >= 1.0.0, < 1.2', 'dockerpty >= 0.3.2, < 0.4', 'six >= 1.3.0, < 2', ] From 276e43ca6b41917d2383199c870a2e73b7bbfa8e Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 23 Mar 2015 11:31:30 -0700 Subject: [PATCH 0731/4072] Fix service dict merging when only one dict has a volumes key Signed-off-by: Aanand Prasad --- compose/config.py | 5 ++++- tests/unit/config_test.py | 23 +++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/compose/config.py b/compose/config.py index 668d2b726ec..af30a702f32 100644 --- a/compose/config.py +++ b/compose/config.py @@ -318,7 +318,10 @@ def merge_volumes(base, override): def dict_from_volumes(volumes): - return dict(split_volume(v) for v in volumes) + if volumes: + return dict(split_volume(v) for v in volumes) + else: + return {} def split_volume(volume): diff --git a/tests/unit/config_test.py b/tests/unit/config_test.py index 013ad503156..323323d41aa 100644 --- a/tests/unit/config_test.py +++ b/tests/unit/config_test.py @@ -39,6 +39,29 @@ def test_config_validation(self): config.make_service_dict('foo', {'ports': ['8000']}) +class MergeTest(unittest.TestCase): + def test_merge_volumes(self): + service_dict = config.merge_service_dicts({}, {}) + self.assertNotIn('volumes', service_dict) + + service_dict = config.merge_service_dicts({ + 'volumes': ['/foo:/data'], + }, {}) + self.assertEqual(service_dict['volumes'], ['/foo:/data']) + + service_dict = config.merge_service_dicts({}, { + 'volumes': ['/bar:/data'], + }) + self.assertEqual(service_dict['volumes'], ['/bar:/data']) + + service_dict = config.merge_service_dicts({ + 'volumes': ['/foo:/data'], + }, { + 'volumes': ['/bar:/data'], + }) + self.assertEqual(service_dict['volumes'], ['/bar:/data']) + + class EnvTest(unittest.TestCase): def test_parse_environment_as_list(self): environment =[ From 35c6e0314c07b9a6d2e97831a1bc4523526a6344 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 23 Mar 2015 11:40:44 -0700 Subject: [PATCH 0732/4072] Fix volume merging when there's no explicit host path Signed-off-by: Aanand Prasad --- compose/config.py | 21 ++++++++++++----- tests/unit/config_test.py | 48 +++++++++++++++++++++++++++------------ 2 files changed, 48 insertions(+), 21 deletions(-) diff --git a/compose/config.py b/compose/config.py index af30a702f32..0cd7c1ae653 100644 --- a/compose/config.py +++ b/compose/config.py @@ -324,15 +324,24 @@ def dict_from_volumes(volumes): return {} -def split_volume(volume): - if ':' in volume: - return reversed(volume.split(':', 1)) +def volumes_from_dict(d): + return [join_volume(v) for v in d.items()] + + +def split_volume(string): + if ':' in string: + (host, container) = string.split(':', 1) + return (container, host) else: - return (volume, None) + return (string, None) -def volumes_from_dict(d): - return ["%s:%s" % (host_path, container_path) for (container_path, host_path) in d.items()] +def join_volume(pair): + (container, host) = pair + if host is None: + return container + else: + return ":".join((host, container)) def expand_path(working_dir, path): diff --git a/tests/unit/config_test.py b/tests/unit/config_test.py index 323323d41aa..8deb457afab 100644 --- a/tests/unit/config_test.py +++ b/tests/unit/config_test.py @@ -40,26 +40,44 @@ def test_config_validation(self): class MergeTest(unittest.TestCase): - def test_merge_volumes(self): + def test_merge_volumes_empty(self): service_dict = config.merge_service_dicts({}, {}) self.assertNotIn('volumes', service_dict) - service_dict = config.merge_service_dicts({ - 'volumes': ['/foo:/data'], - }, {}) - self.assertEqual(service_dict['volumes'], ['/foo:/data']) + def test_merge_volumes_no_override(self): + service_dict = config.merge_service_dicts( + {'volumes': ['/foo:/code', '/data']}, + {}, + ) + self.assertEqual(set(service_dict['volumes']), set(['/foo:/code', '/data'])) - service_dict = config.merge_service_dicts({}, { - 'volumes': ['/bar:/data'], - }) - self.assertEqual(service_dict['volumes'], ['/bar:/data']) + def test_merge_volumes_no_base(self): + service_dict = config.merge_service_dicts( + {}, + {'volumes': ['/bar:/code']}, + ) + self.assertEqual(set(service_dict['volumes']), set(['/bar:/code'])) - service_dict = config.merge_service_dicts({ - 'volumes': ['/foo:/data'], - }, { - 'volumes': ['/bar:/data'], - }) - self.assertEqual(service_dict['volumes'], ['/bar:/data']) + def test_merge_volumes_override_explicit_path(self): + service_dict = config.merge_service_dicts( + {'volumes': ['/foo:/code', '/data']}, + {'volumes': ['/bar:/code']}, + ) + self.assertEqual(set(service_dict['volumes']), set(['/bar:/code', '/data'])) + + def test_merge_volumes_add_explicit_path(self): + service_dict = config.merge_service_dicts( + {'volumes': ['/foo:/code', '/data']}, + {'volumes': ['/bar:/code', '/quux:/data']}, + ) + self.assertEqual(set(service_dict['volumes']), set(['/bar:/code', '/quux:/data'])) + + def test_merge_volumes_remove_explicit_path(self): + service_dict = config.merge_service_dicts( + {'volumes': ['/foo:/code', '/quux:/data']}, + {'volumes': ['/bar:/code', '/data']}, + ) + self.assertEqual(set(service_dict['volumes']), set(['/bar:/code', '/data'])) class EnvTest(unittest.TestCase): From 83dcceacafa720e55efa499485495c9f9dc59d93 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 24 Mar 2015 16:17:05 -0700 Subject: [PATCH 0733/4072] Fix regression in Dns and DnsSearch settings Signed-off-by: Aanand Prasad --- compose/service.py | 5 +++-- tests/integration/service_test.py | 10 ++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/compose/service.py b/compose/service.py index 49e19cc56cb..936e3f9d0c7 100644 --- a/compose/service.py +++ b/compose/service.py @@ -6,6 +6,7 @@ import os from operator import attrgetter import sys +import six from docker.errors import APIError from docker.utils import create_host_config @@ -435,11 +436,11 @@ def _get_container_host_config(self, override_options, one_off=False, intermedia cap_drop = options.get('cap_drop', None) dns = options.get('dns', None) - if not isinstance(dns, list): + if isinstance(dns, six.string_types): dns = [dns] dns_search = options.get('dns_search', None) - if not isinstance(dns_search, list): + if isinstance(dns_search, six.string_types): dns_search = [dns_search] restart = parse_restart_spec(options.get('restart', None)) diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 7c169562447..f0fb771d951 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -424,6 +424,11 @@ def test_network_mode_host(self): container = create_and_start_container(service) self.assertEqual(container.get('HostConfig.NetworkMode'), 'host') + def test_dns_no_value(self): + service = self.create_service('web') + container = create_and_start_container(service) + self.assertIsNone(container.get('HostConfig.Dns')) + def test_dns_single_value(self): service = self.create_service('web', dns='8.8.8.8') container = create_and_start_container(service) @@ -455,6 +460,11 @@ def test_cap_drop_list(self): container = create_and_start_container(service) self.assertEqual(container.get('HostConfig.CapDrop'), ['SYS_ADMIN', 'NET_ADMIN']) + def test_dns_search_no_value(self): + service = self.create_service('web') + container = create_and_start_container(service) + self.assertIsNone(container.get('HostConfig.DnsSearch')) + def test_dns_search_single_value(self): service = self.create_service('web', dns_search='example.com') container = create_and_start_container(service) From e1b27acd0275593081fb3db58949b34ad4b91a00 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Thu, 19 Mar 2015 18:05:45 -0700 Subject: [PATCH 0734/4072] Add contributing section to readme Signed-off-by: Ben Firshman --- README.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e76431b0682..ce89d5aa271 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,5 @@ Docker Compose ============== -[![Build Status](http://jenkins.dockerproject.com/buildStatus/icon?job=Compose Master)](http://jenkins.dockerproject.com/job/Compose%20Master/) *(Previously known as Fig)* Compose is a tool for defining and running complex applications with Docker. @@ -53,3 +52,11 @@ Installation and documentation - Full documentation is available on [Docker's website](http://docs.docker.com/compose/). - Hop into #docker-compose on Freenode if you have any questions. + +Contributing +------------ + +[![Build Status](http://jenkins.dockerproject.com/buildStatus/icon?job=Compose Master)](http://jenkins.dockerproject.com/job/Compose%20Master/) + +Want to help build Compose? Check out our [contributing documentation](https://github.com/docker/compose/blob/master/CONTRIBUTING.md). + From 99f7eba9305968d8eb73d1fd0da5d082fafd380c Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 25 Mar 2015 14:48:36 -0700 Subject: [PATCH 0735/4072] Add Docker 1.6 RC2 to tested versions Signed-off-by: Aanand Prasad --- Dockerfile | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index d7a6019aa89..c9dba927dd1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,13 +15,15 @@ RUN set -ex; \ ; \ rm -rf /var/lib/apt/lists/* -ENV ALL_DOCKER_VERSIONS 1.3.3 1.4.1 1.5.0 +ENV ALL_DOCKER_VERSIONS 1.3.3 1.4.1 1.5.0 1.6.0-rc2 RUN set -ex; \ - for v in ${ALL_DOCKER_VERSIONS}; do \ + for v in 1.3.3 1.4.1 1.5.0; do \ curl https://get.docker.com/builds/Linux/x86_64/docker-$v -o /usr/local/bin/docker-$v; \ chmod +x /usr/local/bin/docker-$v; \ - done + done; \ + curl https://test.docker.com/builds/Linux/x86_64/docker-1.6.0-rc2 -o /usr/local/bin/docker-1.6.0-rc2; \ + chmod +x /usr/local/bin/docker-1.6.0-rc2 RUN useradd -d /home/user -m -s /bin/bash user WORKDIR /code/ From f9ea5ecf40113fca8a09d8c96a3a00d77c7bf6c3 Mon Sep 17 00:00:00 2001 From: akoskaaa Date: Wed, 25 Mar 2015 20:13:01 -0700 Subject: [PATCH 0736/4072] [pep8] make test files and config files pep8 valid Signed-off-by: akoskaaa --- compose/config.py | 14 +++++----- setup.py | 3 ++- tests/__init__.py | 3 +-- tests/integration/project_test.py | 8 +++--- tests/integration/service_test.py | 20 ++++++--------- tests/unit/cli/docker_client_test.py | 2 +- tests/unit/cli_test.py | 1 - tests/unit/config_test.py | 10 ++++---- tests/unit/container_test.py | 25 +++++++++--------- tests/unit/progress_stream_test.py | 2 +- tests/unit/project_test.py | 5 ++-- tests/unit/service_test.py | 38 +++++++++++++++------------- tests/unit/split_buffer_test.py | 1 + 13 files changed, 66 insertions(+), 66 deletions(-) diff --git a/compose/config.py b/compose/config.py index 0cd7c1ae653..c89f6de5528 100644 --- a/compose/config.py +++ b/compose/config.py @@ -39,14 +39,14 @@ ] DOCKER_CONFIG_HINTS = { - 'cpu_share' : 'cpu_shares', - 'link' : 'links', - 'port' : 'ports', - 'privilege' : 'privileged', + 'cpu_share': 'cpu_shares', + 'link': 'links', + 'port': 'ports', + 'privilege': 'privileged', 'priviliged': 'privileged', - 'privilige' : 'privileged', - 'volume' : 'volumes', - 'workdir' : 'working_dir', + 'privilige': 'privileged', + 'volume': 'volumes', + 'workdir': 'working_dir', } diff --git a/setup.py b/setup.py index a8f7d98ea19..39ac0f6f50b 100644 --- a/setup.py +++ b/setup.py @@ -35,6 +35,7 @@ def find_version(*file_paths): 'six >= 1.3.0, < 2', ] + tests_require = [ 'mock >= 1.0.1', 'nose', @@ -54,7 +55,7 @@ def find_version(*file_paths): url='https://www.docker.com/', author='Docker, Inc.', license='Apache License 2.0', - packages=find_packages(exclude=[ 'tests.*', 'tests' ]), + packages=find_packages(exclude=['tests.*', 'tests']), include_package_data=True, test_suite='nose.collector', install_requires=install_requires, diff --git a/tests/__init__.py b/tests/__init__.py index b4d38cccb22..68116d58eb6 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,7 +1,6 @@ import sys -if sys.version_info >= (2,7): +if sys.version_info >= (2, 7): import unittest else: import unittest2 as unittest - diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index a585740f4c9..00d156b370b 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -70,7 +70,7 @@ def test_net_from_service(self): web = project.get_service('web') net = project.get_service('net') - self.assertEqual(web._get_net(), 'container:'+net.containers()[0].id) + self.assertEqual(web._get_net(), 'container:' + net.containers()[0].id) project.kill() project.remove_stopped() @@ -98,7 +98,7 @@ def test_net_from_container(self): project.up() web = project.get_service('web') - self.assertEqual(web._get_net(), 'container:'+net_container.id) + self.assertEqual(web._get_net(), 'container:' + net_container.id) project.kill() project.remove_stopped() @@ -266,7 +266,7 @@ def test_project_up_starts_depends(self): 'image': 'busybox:latest', 'command': ["/bin/sleep", "300"], }, - 'data' : { + 'data': { 'image': 'busybox:latest', 'command': ["/bin/sleep", "300"] }, @@ -304,7 +304,7 @@ def test_project_up_with_no_deps(self): 'image': 'busybox:latest', 'command': ["/bin/sleep", "300"], }, - 'data' : { + 'data': { 'image': 'busybox:latest', 'command': ["/bin/sleep", "300"] }, diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index f0fb771d951..148ac4caf68 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -121,7 +121,7 @@ def test_create_container_with_specified_volume(self): # Match the last component ("host-path"), because boot2docker symlinks /tmp actual_host_path = volumes[container_path] self.assertTrue(path.basename(actual_host_path) == path.basename(host_path), - msg=("Last component differs: %s, %s" % (actual_host_path, host_path))) + msg=("Last component differs: %s, %s" % (actual_host_path, host_path))) def test_create_container_with_volumes_from(self): volume_service = self.create_service('data') @@ -187,7 +187,6 @@ def test_recreate_containers_when_containers_are_stopped(self): service.recreate_containers() self.assertEqual(len(service.containers(stopped=True)), 1) - def test_recreate_containers_with_image_declared_volume(self): service = Service( project='composetest', @@ -229,8 +228,7 @@ def test_start_container_creates_links(self): set([ 'composetest_db_1', 'db_1', 'composetest_db_2', 'db_2', - 'db', - ]), + 'db']) ) def test_start_container_creates_links_with_names(self): @@ -246,8 +244,7 @@ def test_start_container_creates_links_with_names(self): set([ 'composetest_db_1', 'db_1', 'composetest_db_2', 'db_2', - 'custom_link_name', - ]), + 'custom_link_name']) ) def test_start_container_with_external_links(self): @@ -291,8 +288,7 @@ def test_start_one_off_container_creates_links_to_its_own_service(self): set([ 'composetest_db_1', 'db_1', 'composetest_db_2', 'db_2', - 'db', - ]), + 'db']) ) def test_start_container_builds_images(self): @@ -331,7 +327,7 @@ def test_start_container_stays_unpriviliged(self): self.assertEqual(container['HostConfig']['Privileged'], False) def test_start_container_becomes_priviliged(self): - service = self.create_service('web', privileged = True) + service = self.create_service('web', privileged=True) container = create_and_start_container(service).inspect() self.assertEqual(container['HostConfig']['Privileged'], True) @@ -483,13 +479,13 @@ def test_working_dir_param(self): def test_split_env(self): 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(): + for k, v in {'NORMAL': 'F1', 'CONTAINS_EQUALS': 'F=2', 'TRAILING_EQUALS': ''}.items(): self.assertEqual(env[k], v) def test_env_from_file_combined_with_env(self): service = self.create_service('web', environment=['ONE=1', 'TWO=2', 'THREE=3'], env_file=['tests/fixtures/env/one.env', 'tests/fixtures/env/two.env']) env = create_and_start_container(service).environment - for k,v in {'ONE': '1', 'TWO': '2', 'THREE': '3', 'FOO': 'baz', 'DOO': 'dah'}.items(): + for k, v in {'ONE': '1', 'TWO': '2', 'THREE': '3', 'FOO': 'baz', 'DOO': 'dah'}.items(): self.assertEqual(env[k], v) @mock.patch.dict(os.environ) @@ -499,5 +495,5 @@ def test_resolve_env(self): os.environ['ENV_DEF'] = 'E3' service = self.create_service('web', environment={'FILE_DEF': 'F1', 'FILE_DEF_EMPTY': '', 'ENV_DEF': None, 'NO_DEF': None}) env = create_and_start_container(service).environment - for k,v in {'FILE_DEF': 'F1', 'FILE_DEF_EMPTY': '', 'ENV_DEF': 'E3', 'NO_DEF': ''}.items(): + for k, v in {'FILE_DEF': 'F1', 'FILE_DEF_EMPTY': '', 'ENV_DEF': 'E3', 'NO_DEF': ''}.items(): self.assertEqual(env[k], v) diff --git a/tests/unit/cli/docker_client_test.py b/tests/unit/cli/docker_client_test.py index 184aff4de40..44bdbb291ec 100644 --- a/tests/unit/cli/docker_client_test.py +++ b/tests/unit/cli/docker_client_test.py @@ -5,7 +5,7 @@ import mock from tests import unittest -from compose.cli import docker_client +from compose.cli import docker_client class DockerClientTestCase(unittest.TestCase): diff --git a/tests/unit/cli_test.py b/tests/unit/cli_test.py index 0cb7a1d59ec..2ed771f0257 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -151,4 +151,3 @@ def make_files(dirname, filenames): for fname in filenames: with open(os.path.join(dirname, fname), 'w') as f: f.write('') - diff --git a/tests/unit/config_test.py b/tests/unit/config_test.py index 8deb457afab..1eaeee203a8 100644 --- a/tests/unit/config_test.py +++ b/tests/unit/config_test.py @@ -4,6 +4,7 @@ from compose import config + class ConfigTest(unittest.TestCase): def test_from_dictionary(self): service_dicts = config.from_dictionary({ @@ -82,7 +83,7 @@ def test_merge_volumes_remove_explicit_path(self): class EnvTest(unittest.TestCase): def test_parse_environment_as_list(self): - environment =[ + environment = [ 'NORMAL=F1', 'CONTAINS_EQUALS=F=2', 'TRAILING_EQUALS=', @@ -114,9 +115,8 @@ def test_resolve_environment(self): os.environ['ENV_DEF'] = 'E3' service_dict = config.make_service_dict( - 'foo', - { - 'environment': { + 'foo', { + 'environment': { 'FILE_DEF': 'F1', 'FILE_DEF_EMPTY': '', 'ENV_DEF': None, @@ -174,6 +174,7 @@ def test_resolve_environment_from_file(self): {'FILE_DEF': 'F1', 'FILE_DEF_EMPTY': '', 'ENV_DEF': 'E3', 'NO_DEF': ''}, ) + class ExtendsTest(unittest.TestCase): def test_extends(self): service_dicts = config.load('tests/fixtures/extends/docker-compose.yml') @@ -231,7 +232,6 @@ def test_circular(self): ], ) - def test_extends_validation(self): dictionary = {'extends': None} load_config = lambda: config.make_service_dict('myweb', dictionary, working_dir='tests/fixtures/extends') diff --git a/tests/unit/container_test.py b/tests/unit/container_test.py index a5f3f7d3405..7637adf58b0 100644 --- a/tests/unit/container_test.py +++ b/tests/unit/container_test.py @@ -9,7 +9,6 @@ class ContainerTest(unittest.TestCase): - def setUp(self): self.container_dict = { "Id": "abc", @@ -30,11 +29,13 @@ def test_from_ps(self): container = Container.from_ps(None, self.container_dict, has_been_inspected=True) - self.assertEqual(container.dictionary, { - "Id": "abc", - "Image":"busybox:latest", - "Name": "/composetest_db_1", - }) + self.assertEqual( + container.dictionary, + { + "Id": "abc", + "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']] @@ -44,7 +45,7 @@ def test_from_ps_prefixed(self): has_been_inspected=True) self.assertEqual(container.dictionary, { "Id": "abc", - "Image":"busybox:latest", + "Image": "busybox:latest", "Name": "/composetest_db_1", }) @@ -100,7 +101,7 @@ def test_human_readable_ports_none(self): def test_human_readable_ports_public_and_private(self): self.container_dict['NetworkSettings']['Ports'].update({ - "45454/tcp": [ { "HostIp": "0.0.0.0", "HostPort": "49197" } ], + "45454/tcp": [{"HostIp": "0.0.0.0", "HostPort": "49197"}], "45453/tcp": [], }) container = Container(None, self.container_dict, has_been_inspected=True) @@ -110,7 +111,7 @@ def test_human_readable_ports_public_and_private(self): def test_get_local_port(self): self.container_dict['NetworkSettings']['Ports'].update({ - "45454/tcp": [ { "HostIp": "0.0.0.0", "HostPort": "49197" } ], + "45454/tcp": [{"HostIp": "0.0.0.0", "HostPort": "49197"}], }) container = Container(None, self.container_dict, has_been_inspected=True) @@ -120,12 +121,12 @@ def test_get_local_port(self): def test_get(self): container = Container(None, { - "Status":"Up 8 seconds", + "Status": "Up 8 seconds", "HostConfig": { - "VolumesFrom": ["volume_id",] + "VolumesFrom": ["volume_id"] }, }, has_been_inspected=True) self.assertEqual(container.get('Status'), "Up 8 seconds") - self.assertEqual(container.get('HostConfig.VolumesFrom'), ["volume_id",]) + self.assertEqual(container.get('HostConfig.VolumesFrom'), ["volume_id"]) self.assertEqual(container.get('Foo.Bar.DoesNotExist'), None) diff --git a/tests/unit/progress_stream_test.py b/tests/unit/progress_stream_test.py index b53f2eb9aca..694f120ed9d 100644 --- a/tests/unit/progress_stream_test.py +++ b/tests/unit/progress_stream_test.py @@ -5,7 +5,7 @@ import mock from six import StringIO -from compose import progress_stream +from compose import progress_stream class ProgressStreamTestCase(unittest.TestCase): diff --git a/tests/unit/project_test.py b/tests/unit/project_test.py index c995d432f9c..d5c5acb780a 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -8,6 +8,7 @@ import mock import docker + class ProjectTest(unittest.TestCase): def test_from_dict(self): project = Project.from_dicts('composetest', [ @@ -211,7 +212,7 @@ def test_use_net_from_container(self): } ], mock_client) service = project.get_service('test') - self.assertEqual(service._get_net(), 'container:'+container_id) + self.assertEqual(service._get_net(), 'container:' + container_id) def test_use_net_from_service(self): container_name = 'test_aaa_1' @@ -237,4 +238,4 @@ def test_use_net_from_service(self): ], mock_client) service = project.get_service('test') - self.assertEqual(service._get_net(), 'container:'+container_name) + self.assertEqual(service._get_net(), 'container:' + container_name) diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index c70c30bfa11..39a6f5c10cf 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -146,16 +146,16 @@ def test_split_port_invalid(self): def test_build_port_bindings_with_one_port(self): port_bindings = build_port_bindings(["127.0.0.1:1000:1000"]) - self.assertEqual(port_bindings["1000"],[("127.0.0.1","1000")]) + self.assertEqual(port_bindings["1000"], [("127.0.0.1", "1000")]) def test_build_port_bindings_with_matching_internal_ports(self): - port_bindings = build_port_bindings(["127.0.0.1:1000:1000","127.0.0.1:2000:1000"]) - self.assertEqual(port_bindings["1000"],[("127.0.0.1","1000"),("127.0.0.1","2000")]) + port_bindings = build_port_bindings(["127.0.0.1:1000:1000", "127.0.0.1:2000:1000"]) + self.assertEqual(port_bindings["1000"], [("127.0.0.1", "1000"), ("127.0.0.1", "2000")]) def test_build_port_bindings_with_nonmatching_internal_ports(self): - port_bindings = build_port_bindings(["127.0.0.1:1000:1000","127.0.0.1:2000:2000"]) - self.assertEqual(port_bindings["1000"],[("127.0.0.1","1000")]) - self.assertEqual(port_bindings["2000"],[("127.0.0.1","2000")]) + port_bindings = build_port_bindings(["127.0.0.1:1000:1000", "127.0.0.1:2000:2000"]) + self.assertEqual(port_bindings["1000"], [("127.0.0.1", "1000")]) + self.assertEqual(port_bindings["2000"], [("127.0.0.1", "2000")]) def test_split_domainname_none(self): service = Service('foo', hostname='name', client=self.mock_client) @@ -165,29 +165,32 @@ def test_split_domainname_none(self): self.assertFalse('domainname' in opts, 'domainname') def test_split_domainname_fqdn(self): - service = Service('foo', - hostname='name.domain.tld', - client=self.mock_client) + service = Service( + 'foo', + hostname='name.domain.tld', + client=self.mock_client) self.mock_client.containers.return_value = [] opts = service._get_container_create_options({'image': 'foo'}) self.assertEqual(opts['hostname'], 'name', 'hostname') self.assertEqual(opts['domainname'], 'domain.tld', 'domainname') def test_split_domainname_both(self): - service = Service('foo', - hostname='name', - domainname='domain.tld', - client=self.mock_client) + service = Service( + 'foo', + hostname='name', + domainname='domain.tld', + client=self.mock_client) self.mock_client.containers.return_value = [] opts = service._get_container_create_options({'image': 'foo'}) self.assertEqual(opts['hostname'], 'name', 'hostname') self.assertEqual(opts['domainname'], 'domain.tld', 'domainname') def test_split_domainname_weird(self): - service = Service('foo', - hostname='name.sub', - domainname='domain.tld', - client=self.mock_client) + service = Service( + 'foo', + hostname='name.sub', + domainname='domain.tld', + client=self.mock_client) self.mock_client.containers.return_value = [] opts = service._get_container_create_options({'image': 'foo'}) self.assertEqual(opts['hostname'], 'name.sub', 'hostname') @@ -315,4 +318,3 @@ def test_building_volume_binding_with_home(self): self.assertEqual( binding, ('/home/user', dict(bind='/home/user', ro=False))) - diff --git a/tests/unit/split_buffer_test.py b/tests/unit/split_buffer_test.py index 3322fb55fa2..8eb54177aa7 100644 --- a/tests/unit/split_buffer_test.py +++ b/tests/unit/split_buffer_test.py @@ -3,6 +3,7 @@ from compose.cli.utils import split_buffer from .. import unittest + class SplitBufferTest(unittest.TestCase): def test_single_line_chunks(self): def reader(): From fa2fb6bd38904e73e52c0bde2abb5de3914ff254 Mon Sep 17 00:00:00 2001 From: akoskaaa Date: Wed, 25 Mar 2015 23:15:34 -0700 Subject: [PATCH 0737/4072] [pep8] flake8 run for everything, fix items from this change Signed-off-by: akoskaaa --- script/test-versions | 2 +- tests/__init__.py | 4 ++-- tests/integration/service_test.py | 5 ++--- tests/unit/cli_test.py | 1 - tests/unit/config_test.py | 4 +++- tests/unit/progress_stream_test.py | 1 - tox.ini | 2 +- 7 files changed, 9 insertions(+), 10 deletions(-) diff --git a/script/test-versions b/script/test-versions index a9e3bc4c7a9..e5174200b18 100755 --- a/script/test-versions +++ b/script/test-versions @@ -8,7 +8,7 @@ set -e script/validate-dco >&2 echo "Running lint checks" -flake8 compose +flake8 if [ "$DOCKER_VERSIONS" == "" ]; then DOCKER_VERSIONS="1.5.0" diff --git a/tests/__init__.py b/tests/__init__.py index 68116d58eb6..08a7865e908 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,6 +1,6 @@ import sys if sys.version_info >= (2, 7): - import unittest + import unittest # NOQA else: - import unittest2 as unittest + import unittest2 as unittest # NOQA diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 148ac4caf68..066f8b0957e 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -182,7 +182,7 @@ def test_recreate_containers_when_containers_are_stopped(self): entrypoint=['sleep'], command=['300'] ) - old_container = service.create_container() + service.create_container() self.assertEqual(len(service.containers(stopped=True)), 1) service.recreate_containers() self.assertEqual(len(service.containers(stopped=True)), 1) @@ -262,8 +262,7 @@ def test_start_container_with_external_links(self): set([ 'composetest_db_1', 'composetest_db_2', - 'db_3', - ]), + 'db_3']), ) def test_start_normal_container_does_not_create_links_to_its_own_service(self): diff --git a/tests/unit/cli_test.py b/tests/unit/cli_test.py index 2ed771f0257..fcb55a67313 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -8,7 +8,6 @@ import docker import mock -from six import StringIO from compose.cli import main from compose.cli.main import TopLevelCommand diff --git a/tests/unit/config_test.py b/tests/unit/config_test.py index 1eaeee203a8..8644d354b4b 100644 --- a/tests/unit/config_test.py +++ b/tests/unit/config_test.py @@ -234,7 +234,9 @@ def test_circular(self): def test_extends_validation(self): dictionary = {'extends': None} - load_config = lambda: config.make_service_dict('myweb', dictionary, working_dir='tests/fixtures/extends') + + def load_config(): + return config.make_service_dict('myweb', dictionary, working_dir='tests/fixtures/extends') self.assertRaisesRegexp(config.ConfigurationError, 'dictionary', load_config) diff --git a/tests/unit/progress_stream_test.py b/tests/unit/progress_stream_test.py index 694f120ed9d..142560681f3 100644 --- a/tests/unit/progress_stream_test.py +++ b/tests/unit/progress_stream_test.py @@ -2,7 +2,6 @@ from __future__ import absolute_import from tests import unittest -import mock from six import StringIO from compose import progress_stream diff --git a/tox.ini b/tox.ini index 6e83fc414d5..76a1b32979a 100644 --- a/tox.ini +++ b/tox.ini @@ -8,7 +8,7 @@ deps = -rrequirements-dev.txt commands = nosetests -v {posargs} - flake8 compose + flake8 [flake8] # ignore line-length for now From 826b8ca4d35efee112ad9adf80e488d212f27a34 Mon Sep 17 00:00:00 2001 From: funkyfuture Date: Thu, 26 Mar 2015 13:11:05 +0100 Subject: [PATCH 0738/4072] Reformat CONTRIBUTING.md - some reformatting to make it better readable in smaller terminals - adds a note that suggests validating DCO before pushing Signed-off-by: funkyfuture --- CONTRIBUTING.md | 46 ++++++++++++++++++++++++++++------------------ 1 file changed, 28 insertions(+), 18 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 22cbdcf80b4..0cca17b00f8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,8 @@ # Contributing to Compose -Compose is a part of the Docker project, and follows the same rules and principles. Take a read of [Docker's contributing guidelines](https://github.com/docker/docker/blob/master/CONTRIBUTING.md) to get an overview. +Compose is a part of the Docker project, and follows the same rules and +principles. Take a read of [Docker's contributing guidelines](https://github.com/docker/docker/blob/master/CONTRIBUTING.md) +to get an overview. ## TL;DR @@ -17,28 +19,40 @@ If you're looking contribute to Compose but you're new to the project or maybe even to Python, here are the steps that should get you started. -1. Fork [https://github.com/docker/compose](https://github.com/docker/compose) to your username. -1. Clone your forked repository locally `git clone git@github.com:yourusername/compose.git`. -1. Enter the local directory `cd compose`. -1. Set up a development environment by running `python setup.py develop`. This will install the dependencies and set up a symlink from your `docker-compose` executable to the checkout of the repository. When you now run `docker-compose` from anywhere on your machine, it will run your development version of Compose. +1. Fork [https://github.com/docker/compose](https://github.com/docker/compose) + to your username. +2. Clone your forked repository locally `git clone git@github.com:yourusername/compose.git`. +3. Enter the local directory `cd compose`. +4. Set up a development environment by running `python setup.py develop`. This + will install the dependencies and set up a symlink from your `docker-compose` + executable to the checkout of the repository. When you now run + `docker-compose` from anywhere on your machine, it will run your development + version of Compose. ## Running the test suite -Use the test script to run linting checks and then the full test suite: +Use the test script to run DCO check, linting checks and then the full test +suite against different Python interpreters: $ script/test -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: +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 -Arguments to `script/test` are passed through to the `nosetests` executable, so you can specify a test directory, file, module, class or method: +Arguments to `script/test` are passed through to the `nosetests` 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.integration.service_test $ script/test tests.integration.service_test:ServiceTest.test_containers +Before pushing a commit you can check the DCO by invoking `script/validate-dco`. + ## Building binaries Linux: @@ -49,27 +63,23 @@ OS X: $ script/build-osx -Note that this only works on Mountain Lion, not Mavericks, due to a [bug in PyInstaller](http://www.pyinstaller.org/ticket/807). +Note that this only works on Mountain Lion, not Mavericks, due to a +[bug in PyInstaller](http://www.pyinstaller.org/ticket/807). ## Release process 1. Open pull request that: - - Updates the version in `compose/__init__.py` - Updates the binary URL in `docs/install.md` - Updates the script URL in `docs/completion.md` - Adds release notes to `CHANGES.md` - 2. Create unpublished GitHub release with release notes - -3. Build Linux version on any Docker host with `script/build-linux` and attach to release - -4. Build OS X version on Mountain Lion with `script/build-osx` and attach to release as `docker-compose-Darwin-x86_64` and `docker-compose-Linux-x86_64`. - +3. Build Linux version on any Docker host with `script/build-linux` and attach + to release +4. Build OS X version on Mountain Lion with `script/build-osx` and attach to + release as `docker-compose-Darwin-x86_64` and `docker-compose-Linux-x86_64`. 5. Publish GitHub release, creating tag - 6. Update website with `script/deploy-docs` - 7. Upload PyPi package $ git checkout $VERSION From 4e0f555c585fd22d2152075e97626abb1d75d1a2 Mon Sep 17 00:00:00 2001 From: akoskaaa Date: Thu, 26 Mar 2015 09:09:15 -0700 Subject: [PATCH 0739/4072] make flake8 a bit more specific Signed-off-by: akoskaaa --- script/test-versions | 2 +- tox.ini | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/script/test-versions b/script/test-versions index e5174200b18..166c1ef4e55 100755 --- a/script/test-versions +++ b/script/test-versions @@ -8,7 +8,7 @@ set -e script/validate-dco >&2 echo "Running lint checks" -flake8 +flake8 compose tests setup.py if [ "$DOCKER_VERSIONS" == "" ]; then DOCKER_VERSIONS="1.5.0" diff --git a/tox.ini b/tox.ini index 76a1b32979a..33cdee167f4 100644 --- a/tox.ini +++ b/tox.ini @@ -8,7 +8,7 @@ deps = -rrequirements-dev.txt commands = nosetests -v {posargs} - flake8 + flake8 compose tests setup.py [flake8] # ignore line-length for now From 59f04c6e29b52a0461cf8fdfbd5e47363b5c9c47 Mon Sep 17 00:00:00 2001 From: Pascal Borreli Date: Thu, 26 Mar 2015 01:43:23 +0000 Subject: [PATCH 0740/4072] Fixed typo Signed-off-by: Pascal Borreli --- docs/install.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/install.md b/docs/install.md index 0e60e1f184a..6017dd19094 100644 --- a/docs/install.md +++ b/docs/install.md @@ -1,5 +1,5 @@ page_title: Installing Compose -page_description: How to intall Docker Compose +page_description: How to install Docker Compose page_keywords: compose, orchestration, install, installation, docker, documentation @@ -31,7 +31,7 @@ Compose can also be installed as a Python package: $ sudo pip install -U docker-compose -No further steps are required; Compose should now be successfully installed. +No further steps are required; Compose should now be successfully installed. You can test the installation by running `docker-compose --version`. ## Compose documentation From c441ac90d67bf10ceb368915d9cd8c669c98ca58 Mon Sep 17 00:00:00 2001 From: Patrick Chanezon Date: Thu, 26 Mar 2015 16:35:53 -0700 Subject: [PATCH 0741/4072] paulczar fixes plus example file Signed-off-by: Patrick Chanezon --- docs/index.md | 80 +++++++++++++++++++++++++++------------------------ 1 file changed, 43 insertions(+), 37 deletions(-) diff --git a/docs/index.md b/docs/index.md index a75e7285a21..9e67b690706 100644 --- a/docs/index.md +++ b/docs/index.md @@ -15,34 +15,28 @@ recommend that you use it in production yet. Using Compose is basically a three-step process. -First, you define your app's environment with a `Dockerfile` so it can be -reproduced anywhere: - -```Dockerfile -FROM python:2.7 -WORKDIR /code -ADD requirements.txt /code/ -RUN pip install -r requirements.txt -ADD . /code -CMD python app.py -``` - -Next, you define the services that make up your app in `docker-compose.yml` so +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: ```yaml web: build: . - links: - - db + command: python app.py ports: - - "8000:8000" -db: - image: postgres + - "5000:5000" + volumes: + - .:/code + links: + - redis +redis: + image: redis ``` -Lastly, run `docker-compose up` and Compose will start and run your entire app. - Compose has commands for managing the whole lifecycle of your application: * Start, stop and rebuild services @@ -108,13 +102,18 @@ specify how to build the image using a file called ADD . /code WORKDIR /code RUN pip install -r requirements.txt + CMD python app.py + +This tells Docker to: -This tells Docker to include Python, your code, and your Python dependencies in -a Docker image. For more information on how to write Dockerfiles, see the -[Docker user -guide](https://docs.docker.com/userguide/dockerimages/#building-an-image-from-a-dockerfile) -and the -[Dockerfile reference](http://docs.docker.com/reference/builder/). +* Build an image starting with the Python 2.7 image. +* Add the curret directory `.` into the path `/code` in the image. +* Set the working directory to `/code`. +* Install your Python dependencies. + +For more information on how to write Dockerfiles, see the [Docker user guide](https://docs.docker.com/userguide/dockerimages/#building-an-image-from-a-dockerfile) and the [Dockerfile reference](http://docs.docker.com/reference/builder/). + +You can test that this builds by running `docker build -t web .`. ### Define services @@ -134,19 +133,21 @@ Next, define a set of services using `docker-compose.yml`: This defines two services: - - `web`, which is built from the `Dockerfile` in the current directory. It also - says to run the command `python app.py` inside the image, forward the exposed - port 5000 on the container to port 5000 on the host machine, connect up the - Redis service, and mount the current directory inside the container so we can - work on code without having to rebuild the image. - - `redis`, which uses the public image - [redis](https://registry.hub.docker.com/_/redis/), which gets pulled from the - Docker Hub registry. +#### web + +* Builds from the `Dockerfile` in the current directory. +* Defines to run the command `python app.py` inside the image on start. +* Forwards the exposed port 5000 on the container to port 5000 on the host machine. +* Connects the web container to the Redis service via a link. +* Mounts the current directory on the host to `/code` inside the container allowing you to modify the code without having to rebuild the image. + +#### redis + +* Uses the public [redis](https://registry.hub.docker.com/_/redis/) image which gets pulled from the Docker Hub registry. ### Build and run your app with Compose -Now, when you run `docker-compose up`, Compose will pull a Redis image, build an -image for your code, and start everything up: +Now, when you run `docker-compose up`, Compose will pull a Redis image, build an image for your code, and start everything up: $ docker-compose up Pulling image redis... @@ -157,7 +158,12 @@ image for your code, and start everything up: web_1 | * Running on http://0.0.0.0:5000/ The web app should now be listening on port 5000 on your Docker daemon host (if -you're using Boot2docker, `boot2docker ip` will tell you its address). +you're using Boot2docker, `boot2docker ip` will tell you its address). In a browser, +open `http://ip-from-boot2docker:5000` and you should get a message in your browser saying: + +`Hello World! I have been seen 1 times.` + +Refreshing the page will see the number increment. If you want to run your services in the background, you can pass the `-d` flag (for daemon mode) to `docker-compose up` and use `docker-compose ps` to see what From 98dd0cd1f892b357b8b17ef4e301a9cd104f44ff Mon Sep 17 00:00:00 2001 From: Patrick Chanezon Date: Fri, 27 Mar 2015 13:26:51 -0700 Subject: [PATCH 0742/4072] implemented @aanand comments Signed-off-by: Patrick Chanezon --- docs/index.md | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/docs/index.md b/docs/index.md index 9e67b690706..a8ee926bd90 100644 --- a/docs/index.md +++ b/docs/index.md @@ -26,7 +26,6 @@ A `docker-compose.yml` looks like this: ```yaml web: build: . - command: python app.py ports: - "5000:5000" volumes: @@ -107,9 +106,10 @@ specify how to build the image using a file called This tells Docker to: * Build an image starting with the Python 2.7 image. -* Add the curret directory `.` into the path `/code` in the image. +* Add the current directory `.` into the path `/code` in the image. * Set the working directory to `/code`. -* Install your Python dependencies. +* Install your 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/userguide/dockerimages/#building-an-image-from-a-dockerfile) and the [Dockerfile reference](http://docs.docker.com/reference/builder/). @@ -121,7 +121,6 @@ Next, define a set of services using `docker-compose.yml`: web: build: . - command: python app.py ports: - "5000:5000" volumes: @@ -135,10 +134,9 @@ This defines two services: #### web -* Builds from the `Dockerfile` in the current directory. -* Defines to run the command `python app.py` inside the image on start. +* Builds from the `Dockerfile` in the current directory. * Forwards the exposed port 5000 on the container to port 5000 on the host machine. -* Connects the web container to the Redis service via a link. +* Connects the web container to the Redis service via a link. * Mounts the current directory on the host to `/code` inside the container allowing you to modify the code without having to rebuild the image. #### redis From db852e14e4d8ebc1f0a477360ceef7694970dc59 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 23 Mar 2015 14:16:14 -0700 Subject: [PATCH 0743/4072] Add script/ci Signed-off-by: Aanand Prasad --- Dockerfile | 3 +++ script/build-linux | 18 +++++++++++------- script/build-linux-inner | 10 ++++++++++ script/ci | 18 ++++++++++++++++++ script/test-versions | 5 +---- script/wrapdocker | 2 +- 6 files changed, 44 insertions(+), 12 deletions(-) create mode 100755 script/build-linux-inner create mode 100755 script/ci diff --git a/Dockerfile b/Dockerfile index c9dba927dd1..594e321f8ac 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,6 +25,9 @@ RUN set -ex; \ curl https://test.docker.com/builds/Linux/x86_64/docker-1.6.0-rc2 -o /usr/local/bin/docker-1.6.0-rc2; \ chmod +x /usr/local/bin/docker-1.6.0-rc2 +# Set the default Docker to be run +RUN ln -s /usr/local/bin/docker-1.3.3 /usr/local/bin/docker + RUN useradd -d /home/user -m -s /bin/bash user WORKDIR /code/ diff --git a/script/build-linux b/script/build-linux index 07c9d7ec6d2..5e4a9470e9b 100755 --- a/script/build-linux +++ b/script/build-linux @@ -1,8 +1,12 @@ -#!/bin/sh +#!/bin/bash + set -ex -mkdir -p `pwd`/dist -chmod 777 `pwd`/dist -docker build -t docker-compose . -docker run -u user -v `pwd`/dist:/code/dist --rm --entrypoint pyinstaller docker-compose -F bin/docker-compose -mv dist/docker-compose dist/docker-compose-Linux-x86_64 -docker run -u user -v `pwd`/dist:/code/dist --rm --entrypoint dist/docker-compose-Linux-x86_64 docker-compose --version + +TAG="docker-compose" +docker build -t "$TAG" . +docker run \ + --rm \ + --user=user \ + --volume="$(pwd):/code" \ + --entrypoint="script/build-linux-inner" \ + "$TAG" diff --git a/script/build-linux-inner b/script/build-linux-inner new file mode 100755 index 00000000000..34b0c06fd5d --- /dev/null +++ b/script/build-linux-inner @@ -0,0 +1,10 @@ +#!/bin/bash + +set -ex + +mkdir -p `pwd`/dist +chmod 777 `pwd`/dist + +pyinstaller -F bin/docker-compose +mv dist/docker-compose dist/docker-compose-Linux-x86_64 +dist/docker-compose-Linux-x86_64 --version diff --git a/script/ci b/script/ci new file mode 100755 index 00000000000..a1391c62746 --- /dev/null +++ b/script/ci @@ -0,0 +1,18 @@ +#!/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 -e + +>&2 echo "Validating DCO" +script/validate-dco + +export DOCKER_VERSIONS=all +. script/test-versions + +>&2 echo "Building Linux binary" +su -c script/build-linux-inner user diff --git a/script/test-versions b/script/test-versions index 166c1ef4e55..dc54d7f26fe 100755 --- a/script/test-versions +++ b/script/test-versions @@ -4,9 +4,6 @@ set -e ->&2 echo "Validating DCO" -script/validate-dco - >&2 echo "Running lint checks" flake8 compose tests setup.py @@ -18,7 +15,7 @@ fi for version in $DOCKER_VERSIONS; do >&2 echo "Running tests against Docker $version" - docker-1.5.0 run \ + docker run \ --rm \ --privileged \ --volume="/var/lib/docker" \ diff --git a/script/wrapdocker b/script/wrapdocker index 20dc9e3cece..7b699688af2 100755 --- a/script/wrapdocker +++ b/script/wrapdocker @@ -4,7 +4,7 @@ if [ "$DOCKER_VERSION" == "" ]; then DOCKER_VERSION="1.5.0" fi -ln -s "/usr/local/bin/docker-$DOCKER_VERSION" "/usr/local/bin/docker" +ln -fs "/usr/local/bin/docker-$DOCKER_VERSION" "/usr/local/bin/docker" # If a pidfile is still around (for example after a container restart), # delete it so that docker can start. From cec6dc28bbeac1ee9ab85bb5d0df029749116fc2 Mon Sep 17 00:00:00 2001 From: Patrick Chanezon Date: Fri, 27 Mar 2015 17:12:29 -0700 Subject: [PATCH 0744/4072] implemented @fredl suggestions Signed-off-by: Patrick Chanezon --- docs/index.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/index.md b/docs/index.md index a8ee926bd90..62774568706 100644 --- a/docs/index.md +++ b/docs/index.md @@ -141,7 +141,7 @@ This defines two services: #### redis -* Uses the public [redis](https://registry.hub.docker.com/_/redis/) image which gets pulled from the Docker Hub registry. +* Uses the public [Redis](https://registry.hub.docker.com/_/redis/) image which gets pulled from the Docker Hub registry. ### Build and run your app with Compose @@ -161,7 +161,7 @@ open `http://ip-from-boot2docker:5000` and you should get a message in your brow `Hello World! I have been seen 1 times.` -Refreshing the page will see the number increment. +Refreshing the page will increment the number. If you want to run your services in the background, you can pass the `-d` flag (for daemon mode) to `docker-compose up` and use `docker-compose ps` to see what From 0b89ae6f208eff3606854034a97bfa20d8ad15fd Mon Sep 17 00:00:00 2001 From: Joseph Page Date: Fri, 27 Mar 2015 11:31:43 +0100 Subject: [PATCH 0745/4072] [cli] run --rm overrides restart: always docker/compose#1013 Signed-off-by: Joseph Page --- compose/cli/main.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/compose/cli/main.py b/compose/cli/main.py index 95dfb6cbd36..85e6755687b 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -325,6 +325,9 @@ def run(self, project, options): 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') From 1a14449fe6a84f39548d45cbea235ae2520cba48 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 30 Mar 2015 11:33:21 -0400 Subject: [PATCH 0746/4072] Update Swarm doc - Co-scheduling will now work, so we can remove the stuff about `volumes_from` and `net` and manual affinity filters. - Added a section about building. Signed-off-by: Aanand Prasad --- SWARM.md | 45 +++++++++++++-------------------------------- 1 file changed, 13 insertions(+), 32 deletions(-) diff --git a/SWARM.md b/SWARM.md index 3e549e560fe..6cb24b601d4 100644 --- a/SWARM.md +++ b/SWARM.md @@ -9,43 +9,24 @@ Still, Compose and Swarm can be useful in a “batch processing” scenario (whe A number of things need to happen before full integration is achieved, which are documented below. -Re-deploying containers with `docker-compose up` ------------------------------------------------- - -Repeated invocations of `docker-compose up` will not work reliably when used against a Swarm cluster because of an under-the-hood design problem; [this will be fixed](https://github.com/docker/fig/pull/972) in the next version of Compose. For now, containers must be completely removed and re-created: - - $ docker-compose kill - $ docker-compose rm --force - $ docker-compose up - Links and networking -------------------- The primary thing stopping multi-container apps from working seamlessly on Swarm is getting them to talk to one another: enabling private communication between containers on different hosts hasn’t been solved in a non-hacky way. -Long-term, networking is [getting overhauled](https://github.com/docker/docker/issues/9983) in such a way that it’ll fit the multi-host model much better. For now, containers on different hosts cannot be linked. In the next version of Compose, linked services will be automatically scheduled on the same host; for now, this must be done manually (see “Co-scheduling containers” below). - -`volumes_from` and `net: container` ------------------------------------ - -For containers to share volumes or a network namespace, they must be scheduled on the same host - this is, after all, inherent to how both volumes and network namespaces work. In the next version of Compose, this co-scheduling will be automatic whenever `volumes_from` or `net: "container:..."` is specified; for now, containers which share volumes or a network namespace must be co-scheduled manually (see “Co-scheduling containers” below). - -Co-scheduling containers ------------------------- - -For now, containers can be manually scheduled on the same host using Swarm’s [affinity filters](https://github.com/docker/swarm/blob/master/scheduler/filter/README.md#affinity-filter). Here’s a simple example: +Long-term, networking is [getting overhauled](https://github.com/docker/docker/issues/9983) 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**. -```yaml -web: - image: my-web-image - links: ["db"] - environment: - - "affinity:container==myproject_db_*" -db: - image: postgres -``` +Building +-------- -Here, we express an affinity filter on all web containers, saying that each one must run alongside a container whose name begins with `myproject_db_`. +`docker build` against a Swarm cluster is not implemented, so for now the `build` option will not work - you will need to manually build your service's image, push it somewhere and use `image` to instruct Compose to pull it. Here's an example using the Docker Hub: -- `myproject` is the common prefix Compose gives to all containers in your project, which is either generated from the name of the current directory or specified with `-p` or the `DOCKER_COMPOSE_PROJECT_NAME` environment variable. -- `*` is a wildcard, which works just like filename wildcards in a Unix shell. + $ docker build -t myusername/web . + $ docker push myusername/web + $ cat docker-compose.yml + web: + image: myusername/web + links: ["db"] + db: + image: postgres + $ docker-compose up -d From 2a415ede088a3cb04443cc4d91b804593d17a396 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 30 Mar 2015 17:14:19 -0400 Subject: [PATCH 0747/4072] When extending, `build` replaces `image` and vice versa Signed-off-by: Aanand Prasad --- compose/config.py | 6 ++++++ tests/unit/config_test.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/compose/config.py b/compose/config.py index c89f6de5528..dea734d514a 100644 --- a/compose/config.py +++ b/compose/config.py @@ -189,6 +189,12 @@ def merge_service_dicts(base, override): override.get('volumes'), ) + if 'image' in override and 'build' in d: + del d['build'] + + if 'build' in override and 'image' in d: + del d['image'] + for k in ALLOWED_KEYS: if k not in ['environment', 'volumes']: if k in override: diff --git a/tests/unit/config_test.py b/tests/unit/config_test.py index 8644d354b4b..280034449c3 100644 --- a/tests/unit/config_test.py +++ b/tests/unit/config_test.py @@ -80,6 +80,39 @@ def test_merge_volumes_remove_explicit_path(self): ) self.assertEqual(set(service_dict['volumes']), set(['/bar:/code', '/data'])) + def test_merge_build_or_image_no_override(self): + self.assertEqual( + config.merge_service_dicts({'build': '.'}, {}), + {'build': '.'}, + ) + + self.assertEqual( + config.merge_service_dicts({'image': 'redis'}, {}), + {'image': 'redis'}, + ) + + def test_merge_build_or_image_override_with_same(self): + self.assertEqual( + config.merge_service_dicts({'build': '.'}, {'build': './web'}), + {'build': './web'}, + ) + + self.assertEqual( + config.merge_service_dicts({'image': 'redis'}, {'image': 'postgres'}), + {'image': 'postgres'}, + ) + + def test_merge_build_or_image_override_with_other(self): + self.assertEqual( + config.merge_service_dicts({'build': '.'}, {'image': 'redis'}), + {'image': 'redis'} + ) + + self.assertEqual( + config.merge_service_dicts({'image': 'redis'}, {'build': '.'}), + {'build': '.'} + ) + class EnvTest(unittest.TestCase): def test_parse_environment_as_list(self): From 907918b492deb7543a2ce4385ea5a3a3228ff93d Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 30 Mar 2015 18:20:34 -0400 Subject: [PATCH 0748/4072] Merge multi-value options when extending Closes #1143. Signed-off-by: Aanand Prasad --- compose/config.py | 30 ++++++++++++++--- tests/unit/config_test.py | 71 +++++++++++++++++++++++++++++++++++---- 2 files changed, 90 insertions(+), 11 deletions(-) diff --git a/compose/config.py b/compose/config.py index dea734d514a..022069fdf27 100644 --- a/compose/config.py +++ b/compose/config.py @@ -195,10 +195,23 @@ def merge_service_dicts(base, override): if 'build' in override and 'image' in d: del d['image'] - for k in ALLOWED_KEYS: - if k not in ['environment', 'volumes']: - if k in override: - d[k] = override[k] + list_keys = ['ports', 'expose', 'external_links'] + + for key in list_keys: + if key in base or key in override: + d[key] = base.get(key, []) + override.get(key, []) + + list_or_string_keys = ['dns', 'dns_search'] + + for key in list_or_string_keys: + if key in base or key in override: + d[key] = to_list(base.get(key)) + to_list(override.get(key)) + + already_merged_keys = ['environment', 'volumes'] + list_keys + list_or_string_keys + + for k in set(ALLOWED_KEYS) - set(already_merged_keys): + if k in override: + d[k] = override[k] return d @@ -354,6 +367,15 @@ def expand_path(working_dir, path): return os.path.abspath(os.path.join(working_dir, path)) +def to_list(value): + if value is None: + return [] + elif isinstance(value, six.string_types): + return [value] + else: + return value + + def get_service_name_from_net(net_config): if not net_config: return diff --git a/tests/unit/config_test.py b/tests/unit/config_test.py index 280034449c3..af3bebb3338 100644 --- a/tests/unit/config_test.py +++ b/tests/unit/config_test.py @@ -40,40 +40,40 @@ def test_config_validation(self): config.make_service_dict('foo', {'ports': ['8000']}) -class MergeTest(unittest.TestCase): - def test_merge_volumes_empty(self): +class MergeVolumesTest(unittest.TestCase): + def test_empty(self): service_dict = config.merge_service_dicts({}, {}) self.assertNotIn('volumes', service_dict) - def test_merge_volumes_no_override(self): + def test_no_override(self): service_dict = config.merge_service_dicts( {'volumes': ['/foo:/code', '/data']}, {}, ) self.assertEqual(set(service_dict['volumes']), set(['/foo:/code', '/data'])) - def test_merge_volumes_no_base(self): + def test_no_base(self): service_dict = config.merge_service_dicts( {}, {'volumes': ['/bar:/code']}, ) self.assertEqual(set(service_dict['volumes']), set(['/bar:/code'])) - def test_merge_volumes_override_explicit_path(self): + def test_override_explicit_path(self): service_dict = config.merge_service_dicts( {'volumes': ['/foo:/code', '/data']}, {'volumes': ['/bar:/code']}, ) self.assertEqual(set(service_dict['volumes']), set(['/bar:/code', '/data'])) - def test_merge_volumes_add_explicit_path(self): + def test_add_explicit_path(self): service_dict = config.merge_service_dicts( {'volumes': ['/foo:/code', '/data']}, {'volumes': ['/bar:/code', '/quux:/data']}, ) self.assertEqual(set(service_dict['volumes']), set(['/bar:/code', '/quux:/data'])) - def test_merge_volumes_remove_explicit_path(self): + def test_remove_explicit_path(self): service_dict = config.merge_service_dicts( {'volumes': ['/foo:/code', '/quux:/data']}, {'volumes': ['/bar:/code', '/data']}, @@ -114,6 +114,63 @@ def test_merge_build_or_image_override_with_other(self): ) +class MergeListsTest(unittest.TestCase): + def test_empty(self): + service_dict = config.merge_service_dicts({}, {}) + self.assertNotIn('ports', service_dict) + + def test_no_override(self): + service_dict = config.merge_service_dicts( + {'ports': ['10:8000', '9000']}, + {}, + ) + self.assertEqual(set(service_dict['ports']), set(['10:8000', '9000'])) + + def test_no_base(self): + service_dict = config.merge_service_dicts( + {}, + {'ports': ['10:8000', '9000']}, + ) + self.assertEqual(set(service_dict['ports']), set(['10:8000', '9000'])) + + def test_add_item(self): + service_dict = config.merge_service_dicts( + {'ports': ['10:8000', '9000']}, + {'ports': ['20:8000']}, + ) + self.assertEqual(set(service_dict['ports']), set(['10:8000', '9000', '20:8000'])) + + +class MergeStringsOrListsTest(unittest.TestCase): + def test_no_override(self): + service_dict = config.merge_service_dicts( + {'dns': '8.8.8.8'}, + {}, + ) + self.assertEqual(set(service_dict['dns']), set(['8.8.8.8'])) + + def test_no_base(self): + service_dict = config.merge_service_dicts( + {}, + {'dns': '8.8.8.8'}, + ) + self.assertEqual(set(service_dict['dns']), set(['8.8.8.8'])) + + def test_add_string(self): + service_dict = config.merge_service_dicts( + {'dns': ['8.8.8.8']}, + {'dns': '9.9.9.9'}, + ) + self.assertEqual(set(service_dict['dns']), set(['8.8.8.8', '9.9.9.9'])) + + def test_add_list(self): + service_dict = config.merge_service_dicts( + {'dns': '8.8.8.8'}, + {'dns': ['9.9.9.9']}, + ) + self.assertEqual(set(service_dict['dns']), set(['8.8.8.8', '9.9.9.9'])) + + class EnvTest(unittest.TestCase): def test_parse_environment_as_list(self): environment = [ From 0650c4485a85e350a76fbaa6d167c0ee26b08cbd Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 31 Mar 2015 16:04:22 -0400 Subject: [PATCH 0749/4072] Test against Docker 1.6 RC2 only Signed-off-by: Aanand Prasad --- Dockerfile | 8 ++------ script/test-versions | 2 +- script/wrapdocker | 6 ++---- 3 files changed, 5 insertions(+), 11 deletions(-) diff --git a/Dockerfile b/Dockerfile index 594e321f8ac..75de99326ab 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,18 +15,14 @@ RUN set -ex; \ ; \ rm -rf /var/lib/apt/lists/* -ENV ALL_DOCKER_VERSIONS 1.3.3 1.4.1 1.5.0 1.6.0-rc2 +ENV ALL_DOCKER_VERSIONS 1.6.0-rc2 RUN set -ex; \ - for v in 1.3.3 1.4.1 1.5.0; do \ - curl https://get.docker.com/builds/Linux/x86_64/docker-$v -o /usr/local/bin/docker-$v; \ - chmod +x /usr/local/bin/docker-$v; \ - done; \ curl https://test.docker.com/builds/Linux/x86_64/docker-1.6.0-rc2 -o /usr/local/bin/docker-1.6.0-rc2; \ chmod +x /usr/local/bin/docker-1.6.0-rc2 # Set the default Docker to be run -RUN ln -s /usr/local/bin/docker-1.3.3 /usr/local/bin/docker +RUN ln -s /usr/local/bin/docker-1.6.0-rc2 /usr/local/bin/docker RUN useradd -d /home/user -m -s /bin/bash user WORKDIR /code/ diff --git a/script/test-versions b/script/test-versions index dc54d7f26fe..7f1a14a9b15 100755 --- a/script/test-versions +++ b/script/test-versions @@ -8,7 +8,7 @@ set -e flake8 compose tests setup.py if [ "$DOCKER_VERSIONS" == "" ]; then - DOCKER_VERSIONS="1.5.0" + DOCKER_VERSIONS="default" elif [ "$DOCKER_VERSIONS" == "all" ]; then DOCKER_VERSIONS="$ALL_DOCKER_VERSIONS" fi diff --git a/script/wrapdocker b/script/wrapdocker index 7b699688af2..2e07bdadfd9 100755 --- a/script/wrapdocker +++ b/script/wrapdocker @@ -1,11 +1,9 @@ #!/bin/bash -if [ "$DOCKER_VERSION" == "" ]; then - DOCKER_VERSION="1.5.0" +if [ "$DOCKER_VERSION" != "" ] && [ "$DOCKER_VERSION" != "default" ]; then + ln -fs "/usr/local/bin/docker-$DOCKER_VERSION" "/usr/local/bin/docker" fi -ln -fs "/usr/local/bin/docker-$DOCKER_VERSION" "/usr/local/bin/docker" - # If a pidfile is still around (for example after a container restart), # delete it so that docker can start. rm -rf /var/run/docker.pid From 8584525e8d2a3b3c48abece0d1ecc38ec808fe18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Moys=C3=A9s=20Borges?= Date: Sun, 29 Mar 2015 17:06:35 -0300 Subject: [PATCH 0750/4072] Interpret 'build:' as relative to the yml file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * This fix introduces one side-effect: the build parameter is now validated early, when the service dicionary is first constructed. That leads to less scary stack traces when the path is not valid. * The tests for the changes introduced here alter the fixtures of those (otherwise unrelated) tests that make use of the 'build:' parameter) Signed-off-by: Moysés Borges Furtado --- compose/config.py | 14 ++++++++ docs/yml.md | 5 +-- tests/fixtures/build-ctx/Dockerfile | 2 ++ tests/fixtures/build-path/docker-compose.yml | 2 ++ .../docker-compose.yml | 2 +- .../simple-dockerfile/docker-compose.yml | 2 +- tests/unit/config_test.py | 33 +++++++++++++++++++ 7 files changed, 56 insertions(+), 4 deletions(-) create mode 100644 tests/fixtures/build-ctx/Dockerfile create mode 100644 tests/fixtures/build-path/docker-compose.yml diff --git a/compose/config.py b/compose/config.py index 022069fdf27..2c2ddf63382 100644 --- a/compose/config.py +++ b/compose/config.py @@ -171,6 +171,9 @@ def process_container_options(service_dict, working_dir=None): if 'volumes' in service_dict: service_dict['volumes'] = resolve_host_paths(service_dict['volumes'], working_dir=working_dir) + if 'build' in service_dict: + service_dict['build'] = resolve_build_path(service_dict['build'], working_dir=working_dir) + return service_dict @@ -330,6 +333,17 @@ def resolve_host_path(volume, working_dir): return container_path +def resolve_build_path(build_path, working_dir=None): + if working_dir is None: + raise Exception("No working_dir passed to resolve_build_path") + + _path = expand_path(working_dir, build_path) + if not os.path.exists(_path) or not os.access(_path, os.R_OK): + raise ConfigurationError("build path %s either does not exist or is not accessible." % _path) + else: + return _path + + def merge_volumes(base, override): d = dict_from_volumes(base) d.update(dict_from_volumes(override)) diff --git a/docs/yml.md b/docs/yml.md index 157ba4e67d9..a9909e8167a 100644 --- a/docs/yml.md +++ b/docs/yml.md @@ -29,8 +29,9 @@ image: a4bc65fd ### build -Path to a directory containing a Dockerfile. This directory is also the -build context that is sent to the Docker daemon. +Path to a directory containing a Dockerfile. When the value supplied is a +relative path, it is interpreted as relative to the location of the yml file +itself. 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. diff --git a/tests/fixtures/build-ctx/Dockerfile b/tests/fixtures/build-ctx/Dockerfile new file mode 100644 index 00000000000..d1ceac6b743 --- /dev/null +++ b/tests/fixtures/build-ctx/Dockerfile @@ -0,0 +1,2 @@ +FROM busybox:latest +CMD echo "success" diff --git a/tests/fixtures/build-path/docker-compose.yml b/tests/fixtures/build-path/docker-compose.yml new file mode 100644 index 00000000000..66e8916e9d4 --- /dev/null +++ b/tests/fixtures/build-path/docker-compose.yml @@ -0,0 +1,2 @@ +foo: + build: ../build-ctx/ diff --git a/tests/fixtures/dockerfile_with_entrypoint/docker-compose.yml b/tests/fixtures/dockerfile_with_entrypoint/docker-compose.yml index a10381187ab..786315020e8 100644 --- a/tests/fixtures/dockerfile_with_entrypoint/docker-compose.yml +++ b/tests/fixtures/dockerfile_with_entrypoint/docker-compose.yml @@ -1,2 +1,2 @@ service: - build: tests/fixtures/dockerfile_with_entrypoint + build: . diff --git a/tests/fixtures/simple-dockerfile/docker-compose.yml b/tests/fixtures/simple-dockerfile/docker-compose.yml index a3f56d46f33..b0357541ee3 100644 --- a/tests/fixtures/simple-dockerfile/docker-compose.yml +++ b/tests/fixtures/simple-dockerfile/docker-compose.yml @@ -1,2 +1,2 @@ simple: - build: tests/fixtures/simple-dockerfile + build: . diff --git a/tests/unit/config_test.py b/tests/unit/config_test.py index af3bebb3338..ea7503430dd 100644 --- a/tests/unit/config_test.py +++ b/tests/unit/config_test.py @@ -383,3 +383,36 @@ def test_volume_path(self): ] self.assertEqual(set(dicts[0]['volumes']), set(paths)) + + +class BuildPathTest(unittest.TestCase): + def setUp(self): + self.abs_context_path = os.path.join(os.getcwd(), 'tests/fixtures/build-ctx') + + def test_nonexistent_path(self): + options = {'build': 'nonexistent.path'} + self.assertRaises( + config.ConfigurationError, + lambda: config.make_service_dict('foo', options, 'tests/fixtures/build-path'), + ) + + def test_relative_path(self): + relative_build_path = '../build-ctx/' + service_dict = config.make_service_dict( + 'relpath', + {'build': relative_build_path}, + working_dir='tests/fixtures/build-path' + ) + self.assertEquals(service_dict['build'], self.abs_context_path) + + def test_absolute_path(self): + service_dict = config.make_service_dict( + 'abspath', + {'build': self.abs_context_path}, + working_dir='tests/fixtures/build-path' + ) + self.assertEquals(service_dict['build'], self.abs_context_path) + + def test_from_file(self): + service_dict = config.load('tests/fixtures/build-path/docker-compose.yml') + self.assertEquals(service_dict, [{'name': 'foo', 'build': self.abs_context_path}]) From e3cff5d17db64921f98d087a21df2391780d1cb6 Mon Sep 17 00:00:00 2001 From: Laurent Arnoud Date: Tue, 24 Mar 2015 15:22:11 +0100 Subject: [PATCH 0751/4072] Docs: fix env_file example Thanks-to: @aanand Signed-off-by: Laurent Arnoud --- docs/yml.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/yml.md b/docs/yml.md index a85f0923f87..2272e381f5b 100644 --- a/docs/yml.md +++ b/docs/yml.md @@ -172,8 +172,12 @@ env_file: - /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. + ``` -RACK_ENV: development +# Set Rails/Rack environment +RACK_ENV=development ``` ### extends From 461b600068465fb9260f3918ba12e8d501986515 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 31 Mar 2015 15:23:34 -0400 Subject: [PATCH 0752/4072] Merge pull request #1225 from aanand/fix-1222 When extending, `build` replaces `image` and vice versa (cherry picked from commit 6dbe321a45dfd7539234f889825b54e1a026e46f) Signed-off-by: Aanand Prasad --- compose/config.py | 6 ++++++ tests/unit/config_test.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/compose/config.py b/compose/config.py index 0cd7c1ae653..8d6ffea7a41 100644 --- a/compose/config.py +++ b/compose/config.py @@ -189,6 +189,12 @@ def merge_service_dicts(base, override): override.get('volumes'), ) + if 'image' in override and 'build' in d: + del d['build'] + + if 'build' in override and 'image' in d: + del d['image'] + for k in ALLOWED_KEYS: if k not in ['environment', 'volumes']: if k in override: diff --git a/tests/unit/config_test.py b/tests/unit/config_test.py index 8deb457afab..67f24a92df5 100644 --- a/tests/unit/config_test.py +++ b/tests/unit/config_test.py @@ -79,6 +79,39 @@ def test_merge_volumes_remove_explicit_path(self): ) self.assertEqual(set(service_dict['volumes']), set(['/bar:/code', '/data'])) + def test_merge_build_or_image_no_override(self): + self.assertEqual( + config.merge_service_dicts({'build': '.'}, {}), + {'build': '.'}, + ) + + self.assertEqual( + config.merge_service_dicts({'image': 'redis'}, {}), + {'image': 'redis'}, + ) + + def test_merge_build_or_image_override_with_same(self): + self.assertEqual( + config.merge_service_dicts({'build': '.'}, {'build': './web'}), + {'build': './web'}, + ) + + self.assertEqual( + config.merge_service_dicts({'image': 'redis'}, {'image': 'postgres'}), + {'image': 'postgres'}, + ) + + def test_merge_build_or_image_override_with_other(self): + self.assertEqual( + config.merge_service_dicts({'build': '.'}, {'image': 'redis'}), + {'image': 'redis'} + ) + + self.assertEqual( + config.merge_service_dicts({'image': 'redis'}, {'build': '.'}), + {'build': '.'} + ) + class EnvTest(unittest.TestCase): def test_parse_environment_as_list(self): From b24a60ba9fdbfc9d3bdade6efbc9b629cdd4872b Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 31 Mar 2015 16:01:22 -0400 Subject: [PATCH 0753/4072] Merge pull request #1226 from aanand/merge-multi-value-options Merge multi-value options when extending (cherry picked from commit e708f4f59dcb417e90a5bbdcadcee37e8c6b7802) Signed-off-by: Aanand Prasad --- compose/config.py | 30 ++++++++++++++--- tests/unit/config_test.py | 71 +++++++++++++++++++++++++++++++++++---- 2 files changed, 90 insertions(+), 11 deletions(-) diff --git a/compose/config.py b/compose/config.py index 8d6ffea7a41..7f2e302b3f7 100644 --- a/compose/config.py +++ b/compose/config.py @@ -195,10 +195,23 @@ def merge_service_dicts(base, override): if 'build' in override and 'image' in d: del d['image'] - for k in ALLOWED_KEYS: - if k not in ['environment', 'volumes']: - if k in override: - d[k] = override[k] + list_keys = ['ports', 'expose', 'external_links'] + + for key in list_keys: + if key in base or key in override: + d[key] = base.get(key, []) + override.get(key, []) + + list_or_string_keys = ['dns', 'dns_search'] + + for key in list_or_string_keys: + if key in base or key in override: + d[key] = to_list(base.get(key)) + to_list(override.get(key)) + + already_merged_keys = ['environment', 'volumes'] + list_keys + list_or_string_keys + + for k in set(ALLOWED_KEYS) - set(already_merged_keys): + if k in override: + d[k] = override[k] return d @@ -354,6 +367,15 @@ def expand_path(working_dir, path): return os.path.abspath(os.path.join(working_dir, path)) +def to_list(value): + if value is None: + return [] + elif isinstance(value, six.string_types): + return [value] + else: + return value + + def get_service_name_from_net(net_config): if not net_config: return diff --git a/tests/unit/config_test.py b/tests/unit/config_test.py index 67f24a92df5..d95ba7838a0 100644 --- a/tests/unit/config_test.py +++ b/tests/unit/config_test.py @@ -39,40 +39,40 @@ def test_config_validation(self): config.make_service_dict('foo', {'ports': ['8000']}) -class MergeTest(unittest.TestCase): - def test_merge_volumes_empty(self): +class MergeVolumesTest(unittest.TestCase): + def test_empty(self): service_dict = config.merge_service_dicts({}, {}) self.assertNotIn('volumes', service_dict) - def test_merge_volumes_no_override(self): + def test_no_override(self): service_dict = config.merge_service_dicts( {'volumes': ['/foo:/code', '/data']}, {}, ) self.assertEqual(set(service_dict['volumes']), set(['/foo:/code', '/data'])) - def test_merge_volumes_no_base(self): + def test_no_base(self): service_dict = config.merge_service_dicts( {}, {'volumes': ['/bar:/code']}, ) self.assertEqual(set(service_dict['volumes']), set(['/bar:/code'])) - def test_merge_volumes_override_explicit_path(self): + def test_override_explicit_path(self): service_dict = config.merge_service_dicts( {'volumes': ['/foo:/code', '/data']}, {'volumes': ['/bar:/code']}, ) self.assertEqual(set(service_dict['volumes']), set(['/bar:/code', '/data'])) - def test_merge_volumes_add_explicit_path(self): + def test_add_explicit_path(self): service_dict = config.merge_service_dicts( {'volumes': ['/foo:/code', '/data']}, {'volumes': ['/bar:/code', '/quux:/data']}, ) self.assertEqual(set(service_dict['volumes']), set(['/bar:/code', '/quux:/data'])) - def test_merge_volumes_remove_explicit_path(self): + def test_remove_explicit_path(self): service_dict = config.merge_service_dicts( {'volumes': ['/foo:/code', '/quux:/data']}, {'volumes': ['/bar:/code', '/data']}, @@ -113,6 +113,63 @@ def test_merge_build_or_image_override_with_other(self): ) +class MergeListsTest(unittest.TestCase): + def test_empty(self): + service_dict = config.merge_service_dicts({}, {}) + self.assertNotIn('ports', service_dict) + + def test_no_override(self): + service_dict = config.merge_service_dicts( + {'ports': ['10:8000', '9000']}, + {}, + ) + self.assertEqual(set(service_dict['ports']), set(['10:8000', '9000'])) + + def test_no_base(self): + service_dict = config.merge_service_dicts( + {}, + {'ports': ['10:8000', '9000']}, + ) + self.assertEqual(set(service_dict['ports']), set(['10:8000', '9000'])) + + def test_add_item(self): + service_dict = config.merge_service_dicts( + {'ports': ['10:8000', '9000']}, + {'ports': ['20:8000']}, + ) + self.assertEqual(set(service_dict['ports']), set(['10:8000', '9000', '20:8000'])) + + +class MergeStringsOrListsTest(unittest.TestCase): + def test_no_override(self): + service_dict = config.merge_service_dicts( + {'dns': '8.8.8.8'}, + {}, + ) + self.assertEqual(set(service_dict['dns']), set(['8.8.8.8'])) + + def test_no_base(self): + service_dict = config.merge_service_dicts( + {}, + {'dns': '8.8.8.8'}, + ) + self.assertEqual(set(service_dict['dns']), set(['8.8.8.8'])) + + def test_add_string(self): + service_dict = config.merge_service_dicts( + {'dns': ['8.8.8.8']}, + {'dns': '9.9.9.9'}, + ) + self.assertEqual(set(service_dict['dns']), set(['8.8.8.8', '9.9.9.9'])) + + def test_add_list(self): + service_dict = config.merge_service_dicts( + {'dns': '8.8.8.8'}, + {'dns': ['9.9.9.9']}, + ) + self.assertEqual(set(service_dict['dns']), set(['8.8.8.8', '9.9.9.9'])) + + class EnvTest(unittest.TestCase): def test_parse_environment_as_list(self): environment =[ From e4e802d1f86ceb16d45d0176f94906e799f90fc9 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 31 Mar 2015 21:20:02 -0400 Subject: [PATCH 0754/4072] Merge pull request #1213 from moysesb/relative_build Make value of 'build:' relative to the yml file. (cherry picked from commit 0f70b8638ff7167e9755d24dc8dab1579662f72d) Signed-off-by: Aanand Prasad --- compose/config.py | 14 ++++++++ docs/yml.md | 5 +-- tests/fixtures/build-ctx/Dockerfile | 2 ++ tests/fixtures/build-path/docker-compose.yml | 2 ++ .../docker-compose.yml | 2 +- .../simple-dockerfile/docker-compose.yml | 2 +- tests/unit/config_test.py | 33 +++++++++++++++++++ 7 files changed, 56 insertions(+), 4 deletions(-) create mode 100644 tests/fixtures/build-ctx/Dockerfile create mode 100644 tests/fixtures/build-path/docker-compose.yml diff --git a/compose/config.py b/compose/config.py index 7f2e302b3f7..1dc64af25de 100644 --- a/compose/config.py +++ b/compose/config.py @@ -171,6 +171,9 @@ def process_container_options(service_dict, working_dir=None): if 'volumes' in service_dict: service_dict['volumes'] = resolve_host_paths(service_dict['volumes'], working_dir=working_dir) + if 'build' in service_dict: + service_dict['build'] = resolve_build_path(service_dict['build'], working_dir=working_dir) + return service_dict @@ -330,6 +333,17 @@ def resolve_host_path(volume, working_dir): return container_path +def resolve_build_path(build_path, working_dir=None): + if working_dir is None: + raise Exception("No working_dir passed to resolve_build_path") + + _path = expand_path(working_dir, build_path) + if not os.path.exists(_path) or not os.access(_path, os.R_OK): + raise ConfigurationError("build path %s either does not exist or is not accessible." % _path) + else: + return _path + + def merge_volumes(base, override): d = dict_from_volumes(base) d.update(dict_from_volumes(override)) diff --git a/docs/yml.md b/docs/yml.md index 157ba4e67d9..a9909e8167a 100644 --- a/docs/yml.md +++ b/docs/yml.md @@ -29,8 +29,9 @@ image: a4bc65fd ### build -Path to a directory containing a Dockerfile. This directory is also the -build context that is sent to the Docker daemon. +Path to a directory containing a Dockerfile. When the value supplied is a +relative path, it is interpreted as relative to the location of the yml file +itself. 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. diff --git a/tests/fixtures/build-ctx/Dockerfile b/tests/fixtures/build-ctx/Dockerfile new file mode 100644 index 00000000000..d1ceac6b743 --- /dev/null +++ b/tests/fixtures/build-ctx/Dockerfile @@ -0,0 +1,2 @@ +FROM busybox:latest +CMD echo "success" diff --git a/tests/fixtures/build-path/docker-compose.yml b/tests/fixtures/build-path/docker-compose.yml new file mode 100644 index 00000000000..66e8916e9d4 --- /dev/null +++ b/tests/fixtures/build-path/docker-compose.yml @@ -0,0 +1,2 @@ +foo: + build: ../build-ctx/ diff --git a/tests/fixtures/dockerfile_with_entrypoint/docker-compose.yml b/tests/fixtures/dockerfile_with_entrypoint/docker-compose.yml index a10381187ab..786315020e8 100644 --- a/tests/fixtures/dockerfile_with_entrypoint/docker-compose.yml +++ b/tests/fixtures/dockerfile_with_entrypoint/docker-compose.yml @@ -1,2 +1,2 @@ service: - build: tests/fixtures/dockerfile_with_entrypoint + build: . diff --git a/tests/fixtures/simple-dockerfile/docker-compose.yml b/tests/fixtures/simple-dockerfile/docker-compose.yml index a3f56d46f33..b0357541ee3 100644 --- a/tests/fixtures/simple-dockerfile/docker-compose.yml +++ b/tests/fixtures/simple-dockerfile/docker-compose.yml @@ -1,2 +1,2 @@ simple: - build: tests/fixtures/simple-dockerfile + build: . diff --git a/tests/unit/config_test.py b/tests/unit/config_test.py index d95ba7838a0..f25f3a9d642 100644 --- a/tests/unit/config_test.py +++ b/tests/unit/config_test.py @@ -381,3 +381,36 @@ def test_volume_path(self): ] self.assertEqual(set(dicts[0]['volumes']), set(paths)) + + +class BuildPathTest(unittest.TestCase): + def setUp(self): + self.abs_context_path = os.path.join(os.getcwd(), 'tests/fixtures/build-ctx') + + def test_nonexistent_path(self): + options = {'build': 'nonexistent.path'} + self.assertRaises( + config.ConfigurationError, + lambda: config.make_service_dict('foo', options, 'tests/fixtures/build-path'), + ) + + def test_relative_path(self): + relative_build_path = '../build-ctx/' + service_dict = config.make_service_dict( + 'relpath', + {'build': relative_build_path}, + working_dir='tests/fixtures/build-path' + ) + self.assertEquals(service_dict['build'], self.abs_context_path) + + def test_absolute_path(self): + service_dict = config.make_service_dict( + 'abspath', + {'build': self.abs_context_path}, + working_dir='tests/fixtures/build-path' + ) + self.assertEquals(service_dict['build'], self.abs_context_path) + + def test_from_file(self): + service_dict = config.load('tests/fixtures/build-path/docker-compose.yml') + self.assertEquals(service_dict, [{'name': 'foo', 'build': self.abs_context_path}]) From 78227c3c068a3ca7be47d3104fceb8c1e065e078 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 27 Mar 2015 14:59:49 -0700 Subject: [PATCH 0755/4072] Merge pull request #1202 from aanand/jenkins-script WIP: Jenkins script (cherry picked from commit 853ce255eac5375562e399d3e105dc5a456dbb99) Signed-off-by: Aanand Prasad --- Dockerfile | 3 +++ script/build-linux | 18 +++++++++++------- script/build-linux-inner | 10 ++++++++++ script/ci | 18 ++++++++++++++++++ script/test-versions | 5 +---- script/wrapdocker | 2 +- 6 files changed, 44 insertions(+), 12 deletions(-) create mode 100755 script/build-linux-inner create mode 100755 script/ci diff --git a/Dockerfile b/Dockerfile index d7a6019aa89..8ec05cc9b91 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,6 +23,9 @@ RUN set -ex; \ chmod +x /usr/local/bin/docker-$v; \ done +# Set the default Docker to be run +RUN ln -s /usr/local/bin/docker-1.3.3 /usr/local/bin/docker + RUN useradd -d /home/user -m -s /bin/bash user WORKDIR /code/ diff --git a/script/build-linux b/script/build-linux index 07c9d7ec6d2..5e4a9470e9b 100755 --- a/script/build-linux +++ b/script/build-linux @@ -1,8 +1,12 @@ -#!/bin/sh +#!/bin/bash + set -ex -mkdir -p `pwd`/dist -chmod 777 `pwd`/dist -docker build -t docker-compose . -docker run -u user -v `pwd`/dist:/code/dist --rm --entrypoint pyinstaller docker-compose -F bin/docker-compose -mv dist/docker-compose dist/docker-compose-Linux-x86_64 -docker run -u user -v `pwd`/dist:/code/dist --rm --entrypoint dist/docker-compose-Linux-x86_64 docker-compose --version + +TAG="docker-compose" +docker build -t "$TAG" . +docker run \ + --rm \ + --user=user \ + --volume="$(pwd):/code" \ + --entrypoint="script/build-linux-inner" \ + "$TAG" diff --git a/script/build-linux-inner b/script/build-linux-inner new file mode 100755 index 00000000000..34b0c06fd5d --- /dev/null +++ b/script/build-linux-inner @@ -0,0 +1,10 @@ +#!/bin/bash + +set -ex + +mkdir -p `pwd`/dist +chmod 777 `pwd`/dist + +pyinstaller -F bin/docker-compose +mv dist/docker-compose dist/docker-compose-Linux-x86_64 +dist/docker-compose-Linux-x86_64 --version diff --git a/script/ci b/script/ci new file mode 100755 index 00000000000..a1391c62746 --- /dev/null +++ b/script/ci @@ -0,0 +1,18 @@ +#!/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 -e + +>&2 echo "Validating DCO" +script/validate-dco + +export DOCKER_VERSIONS=all +. script/test-versions + +>&2 echo "Building Linux binary" +su -c script/build-linux-inner user diff --git a/script/test-versions b/script/test-versions index a9e3bc4c7a9..a172b9a3369 100755 --- a/script/test-versions +++ b/script/test-versions @@ -4,9 +4,6 @@ set -e ->&2 echo "Validating DCO" -script/validate-dco - >&2 echo "Running lint checks" flake8 compose @@ -18,7 +15,7 @@ fi for version in $DOCKER_VERSIONS; do >&2 echo "Running tests against Docker $version" - docker-1.5.0 run \ + docker run \ --rm \ --privileged \ --volume="/var/lib/docker" \ diff --git a/script/wrapdocker b/script/wrapdocker index 20dc9e3cece..7b699688af2 100755 --- a/script/wrapdocker +++ b/script/wrapdocker @@ -4,7 +4,7 @@ if [ "$DOCKER_VERSION" == "" ]; then DOCKER_VERSION="1.5.0" fi -ln -s "/usr/local/bin/docker-$DOCKER_VERSION" "/usr/local/bin/docker" +ln -fs "/usr/local/bin/docker-$DOCKER_VERSION" "/usr/local/bin/docker" # If a pidfile is still around (for example after a container restart), # delete it so that docker can start. From b3382ffd4f455745c0401a0c2d6d74c2a182bc6f Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 1 Apr 2015 14:59:21 -0400 Subject: [PATCH 0756/4072] Use Docker 1.6 RC4 Signed-off-by: Aanand Prasad --- Dockerfile | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index 75de99326ab..7438d6b1bf1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,14 +15,14 @@ RUN set -ex; \ ; \ rm -rf /var/lib/apt/lists/* -ENV ALL_DOCKER_VERSIONS 1.6.0-rc2 +ENV ALL_DOCKER_VERSIONS 1.6.0-rc4 RUN set -ex; \ - curl https://test.docker.com/builds/Linux/x86_64/docker-1.6.0-rc2 -o /usr/local/bin/docker-1.6.0-rc2; \ - chmod +x /usr/local/bin/docker-1.6.0-rc2 + curl https://test.docker.com/builds/Linux/x86_64/docker-1.6.0-rc4 -o /usr/local/bin/docker-1.6.0-rc4; \ + chmod +x /usr/local/bin/docker-1.6.0-rc4 # Set the default Docker to be run -RUN ln -s /usr/local/bin/docker-1.6.0-rc2 /usr/local/bin/docker +RUN ln -s /usr/local/bin/docker-1.6.0-rc4 /usr/local/bin/docker RUN useradd -d /home/user -m -s /bin/bash user WORKDIR /code/ From d866415b9a5bd5c01f834173fe709f699026263e Mon Sep 17 00:00:00 2001 From: Roland Cooper Date: Thu, 26 Mar 2015 19:17:36 -0500 Subject: [PATCH 0757/4072] Update install docs for permission denied error Signed-off-by: Roland Cooper --- docs/install.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/install.md b/docs/install.md index 6017dd19094..7ee6f1b9df4 100644 --- a/docs/install.md +++ b/docs/install.md @@ -23,6 +23,8 @@ To install Compose, run the following commands: curl -L https://github.com/docker/compose/releases/download/1.1.0/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose chmod +x /usr/local/bin/docker-compose +> 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 above, then `exit`. + Optionally, you can also install [command completion](completion.md) for the bash shell. From 502d58abe6e017f26a9d1d8360f9f298dc5eda5f Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 3 Apr 2015 18:26:02 -0400 Subject: [PATCH 0758/4072] Add guide to using Compose in production Signed-off-by: Aanand Prasad --- docs/mkdocs.yml | 1 + docs/production.md | 77 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+) create mode 100644 docs/production.md diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 14335873dea..aa741384644 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -1,5 +1,6 @@ - ['compose/index.md', 'User Guide', 'Docker Compose' ] +- ['compose/production.md', 'User Guide', 'Using Compose in production' ] - ['compose/install.md', 'Installation', 'Docker Compose'] - ['compose/cli.md', 'Reference', 'Compose command line'] - ['compose/yml.md', 'Reference', 'Compose yml'] diff --git a/docs/production.md b/docs/production.md new file mode 100644 index 00000000000..d267ed41f99 --- /dev/null +++ b/docs/production.md @@ -0,0 +1,77 @@ +page_title: Using Compose in production +page_description: Guide to using Docker Compose in production +page_keywords: documentation, docs, docker, compose, orchestration, containers, production + + +## Using Compose in production + +While **Compose is not yet considered production-ready**, you can try using it +for production deployments if you're feeling brave. Production-readiness is an +active, ongoing project - see the +[roadmap](https://github.com/docker/compose/blob/master/ROADMAP.md) for details +on how it's coming along and what needs to be done. + +When deploying to production, you'll almost certainly want to make changes to +your app configuration that are more appropriate to a live environment. This 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 a separate Compose file, say +`production.yml`, which specifies production-appropriate configuration. + + + +Once you've got an alternate configuration file, you can make Compose use it +by setting the `COMPOSE_FILE` environment variable: + + $ COMPOSE_FILE=production.yml + $ docker-compose up -d + +> **Note:** You can also use the file for a one-off command without setting +> an environment variable by passing the `-f` flag, e.g. +> `docker-compose -f production.yml up -d`. + +### Deploying changes + +When you make changes to your app code, you'll need to rebuild your image and +recreate your app containers. If the service you want to redeploy is called +`web`, this will look like: + + $ 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. + +### Run 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. [Docker Machine](https://docs.docker.com/machine) 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 extra configuration. + +### Run Compose on a Swarm cluster + +[Docker Swarm](https://docs.docker.com/swarm), 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're interested to try it out, check out the +[integration guide](https://github.com/docker/compose/blob/master/SWARM.md). From 11a2100d537319361f9515414e10ebd55bbb9ac4 Mon Sep 17 00:00:00 2001 From: Steven Dake Date: Tue, 24 Feb 2015 06:39:06 -0700 Subject: [PATCH 0759/4072] Add a --pid=host feature to expose the host PID space to the container Docker 1.5.0+ introduces a --pid=host feature which allows sharing of PID namespaces between baremetal and containers. This is useful for atomic upgrades, atomic rollbacks, and monitoring. For more details of a real-life use case, check out: http://sdake.io/2015/01/28/an-atomic-upgrade-process-for-openstack-compute-nodes/ Signed-off-by: Steven Dake --- docs/yml.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/yml.md b/docs/yml.md index a9909e8167a..f8191766a3d 100644 --- a/docs/yml.md +++ b/docs/yml.md @@ -264,6 +264,16 @@ net: "none" net: "container:[name or id]" net: "host" ``` +### 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. ### dns From 94277a3eb052c1bef77e95f0d12bcf5f3c327038 Mon Sep 17 00:00:00 2001 From: Steven Dake Date: Tue, 24 Feb 2015 22:42:23 -0700 Subject: [PATCH 0760/4072] Add --pid=host support Allow docker-compsoe to use the docker --pid=host API available in 1.17 Signed-off-by: Steven Dake --- compose/config.py | 1 + compose/service.py | 3 +++ tests/integration/service_test.py | 11 +++++++++++ 3 files changed, 15 insertions(+) diff --git a/compose/config.py b/compose/config.py index 2c2ddf63382..6ef637c5a5a 100644 --- a/compose/config.py +++ b/compose/config.py @@ -20,6 +20,7 @@ 'links', 'mem_limit', 'net', + 'pid', 'ports', 'privileged', 'restart', diff --git a/compose/service.py b/compose/service.py index 936e3f9d0c7..20955d23553 100644 --- a/compose/service.py +++ b/compose/service.py @@ -25,6 +25,7 @@ 'dns_search', 'env_file', 'net', + 'pid', 'privileged', 'restart', ] @@ -434,6 +435,7 @@ def _get_container_host_config(self, override_options, one_off=False, intermedia privileged = options.get('privileged', False) cap_add = options.get('cap_add', None) cap_drop = options.get('cap_drop', None) + pid = options.get('pid', None) dns = options.get('dns', None) if isinstance(dns, six.string_types): @@ -457,6 +459,7 @@ def _get_container_host_config(self, override_options, one_off=False, intermedia restart_policy=restart, cap_add=cap_add, cap_drop=cap_drop, + pid_mode=pid ) def _get_image_name(self, image): diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 066f8b0957e..85f6db9dbf4 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -419,6 +419,17 @@ def test_network_mode_host(self): container = create_and_start_container(service) self.assertEqual(container.get('HostConfig.NetworkMode'), 'host') + def test_pid_mode_none_defined(self): + service = self.create_service('web', pid=None) + container = create_and_start_container(service) + print 'STEAK %s' % (container.get('HostConfig.PidMode')) + self.assertEqual(container.get('HostConfig.PidMode'), '') + + def test_pid_mode_host(self): + service = self.create_service('web', pid='host') + container = create_and_start_container(service) + self.assertEqual(container.get('HostConfig.PidMode'), 'host') + def test_dns_no_value(self): service = self.create_service('web') container = create_and_start_container(service) From 947742852e5d6f1f8ddd20a764165531b510d2f3 Mon Sep 17 00:00:00 2001 From: Fred Lifton Date: Mon, 6 Apr 2015 16:47:07 -0700 Subject: [PATCH 0761/4072] Prepping for 1.6 release. Adds release notes and edits/revises new Compose in production doc. --- docs/index.md | 16 ++++++++++++++++ docs/production.md | 48 +++++++++++++++++++++++----------------------- 2 files changed, 40 insertions(+), 24 deletions(-) diff --git a/docs/index.md b/docs/index.md index a75e7285a21..da5a8efdb32 100644 --- a/docs/index.md +++ b/docs/index.md @@ -5,6 +5,8 @@ page_keywords: documentation, docs, docker, compose, orchestration, containers # Docker Compose +## Overview + Compose is a tool for defining and running complex applications with Docker. With Compose, you define a multi-container application in a single file, then spin your application up in a single command which does everything that needs to @@ -191,3 +193,17 @@ At this point, you have seen the basics of how Compose works. [Rails](rails.md), or [Wordpress](wordpress.md). - See the reference guides for complete details on the [commands](cli.md), the [configuration file](yml.md) and [environment variables](env.md). + +## Release Notes + +### Version 1.2.0 (April 7, 2015) + +For complete information on this release, see the [1.2.0 Milestone project page](https://github.com/docker/compose/wiki/1.2.0-Milestone-Project-Page). +In addition to bug fixes and refinements, this release adds the following: + +* The `extends` keyword, which adds the ability to extend services by sharing common configurations. For details, see +[PR #972](https://github.com/docker/compose/pull/1088). + +* Better integration with Swarm. Swarm will now schedule inter-dependent +containers on the same host. For details, see +[PR #972](https://github.com/docker/compose/pull/972). diff --git a/docs/production.md b/docs/production.md index d267ed41f99..8524c99b813 100644 --- a/docs/production.md +++ b/docs/production.md @@ -5,73 +5,73 @@ page_keywords: documentation, docs, docker, compose, orchestration, containers, ## Using Compose in production -While **Compose is not yet considered production-ready**, you can try using it -for production deployments if you're feeling brave. Production-readiness is an -active, ongoing project - see the +While **Compose is not yet considered production-ready**, if you'd like to experiment and learn more about using it in production deployments, this guide +can help. +The project is actively working towards becoming +production-ready; to learn more about the progress being made, check out the [roadmap](https://github.com/docker/compose/blob/master/ROADMAP.md) for details -on how it's coming along and what needs to be done. +on how it's coming along and what still needs to be done. When deploying to production, you'll almost certainly want to make changes to -your app configuration that are more appropriate to a live environment. This may -include: +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 +- 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) +- 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 a separate Compose file, say `production.yml`, which specifies production-appropriate configuration. - -Once you've got an alternate configuration file, you can make Compose use it +Once you've got an alternate configuration file, make Compose use it by setting the `COMPOSE_FILE` environment variable: $ COMPOSE_FILE=production.yml $ docker-compose up -d > **Note:** You can also use the file for a one-off command without setting -> an environment variable by passing the `-f` flag, e.g. +> an environment variable. You do this by passing the `-f` flag, e.g., > `docker-compose -f production.yml up -d`. ### Deploying changes When you make changes to your app code, you'll need to rebuild your image and -recreate your app containers. If the service you want to redeploy is called -`web`, this will look like: +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 +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. -### Run Compose on a single server +### 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. [Docker Machine](https://docs.docker.com/machine) makes managing -local and remote Docker hosts very easy, and is recommended even if you're not -deploying remotely. +`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 +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 extra configuration. +commands will work with no further configuration. -### Run Compose on a Swarm cluster +### Running Compose on a Swarm cluster [Docker Swarm](https://docs.docker.com/swarm), 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're interested to try it out, check out the +in beta, but if you'd like to explore and experiment, check out the [integration guide](https://github.com/docker/compose/blob/master/SWARM.md). From 0b48e137e898f55d395cd1a06d7d3a08db75c6d8 Mon Sep 17 00:00:00 2001 From: Joseph Page Date: Tue, 7 Apr 2015 10:11:36 +0200 Subject: [PATCH 0762/4072] add unit tests for run --rm with restart Signed-off-by: Joseph Page --- tests/unit/cli_test.py | 49 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/tests/unit/cli_test.py b/tests/unit/cli_test.py index fcb55a67313..240069adb6f 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -134,6 +134,55 @@ def test_run_with_environment_merged_with_options_list(self, mock_dockerpty): call_kwargs['environment'], {'FOO': 'ONE', 'BAR': 'NEW', 'OTHER': 'THREE'}) + def test_run_service_with_restart_always(self): + command = TopLevelCommand() + mock_client = mock.create_autospec(docker.Client) + mock_project = mock.Mock() + mock_project.get_service.return_value = Service( + 'service', + client=mock_client, + restart='always', + image='someimage') + command.run(mock_project, { + 'SERVICE': 'service', + 'COMMAND': None, + '-e': [], + '--user': None, + '--no-deps': None, + '--allow-insecure-ssl': None, + '-d': True, + '-T': None, + '--entrypoint': None, + '--service-ports': None, + '--rm': None, + }) + _, _, call_kwargs = mock_client.create_container.mock_calls[0] + self.assertEquals(call_kwargs['host_config']['RestartPolicy']['Name'], 'always') + + command = TopLevelCommand() + mock_client = mock.create_autospec(docker.Client) + mock_project = mock.Mock() + mock_project.get_service.return_value = Service( + 'service', + client=mock_client, + restart='always', + image='someimage') + command.run(mock_project, { + 'SERVICE': 'service', + 'COMMAND': None, + '-e': [], + '--user': None, + '--no-deps': None, + '--allow-insecure-ssl': None, + '-d': True, + '-T': None, + '--entrypoint': None, + '--service-ports': None, + '--rm': True, + }) + _, _, call_kwargs = mock_client.create_container.mock_calls[0] + self.assertFalse('RestartPolicy' in call_kwargs['host_config']) + def get_config_filename_for_files(filenames): project_dir = tempfile.mkdtemp() From f3f7f000fec841a62cc1849462f851230614af85 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 3 Apr 2015 16:31:12 -0400 Subject: [PATCH 0763/4072] Add tutorial and reference for `extends` Signed-off-by: Aanand Prasad --- docs/extends.md | 364 ++++++++++++++++++++++++++++++++++++++++++++++++ docs/mkdocs.yml | 1 + docs/yml.md | 38 +---- 3 files changed, 368 insertions(+), 35 deletions(-) create mode 100644 docs/extends.md diff --git a/docs/extends.md b/docs/extends.md new file mode 100644 index 00000000000..2393ca6aecd --- /dev/null +++ b/docs/extends.md @@ -0,0 +1,364 @@ +page_title: Extending services in Compose +page_description: How to use Docker Compose's "extends" keyword to share configuration between files and projects +page_keywords: fig, composition, compose, docker, orchestration, documentation, docs + + +## Extending services in Compose + +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 applications that reuse commonly-defined services. +Using `extends` you can define a service in one place and refer to it from +anywhere. + +Alternatively, you can deploy the same application to multiple environments with +a slightly different set of services in each case (or with changes to the +configuration of some services). Moreover, you can do so without copy-pasting +the configuration around. + +### Understand the extends configuration + +When defining any service in `docker-compose.yml`, you can declare that you are +extending another service like this: + +```yaml +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: + +```yaml +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 that `build`, `ports` and `volumes` configuration +defined directly under `web`. + +You can go further and define (or re-define) configuration locally in +`docker-compose.yml`: + +```yaml +web: + extends: + file: common-services.yml + service: webapp + environment: + - DEBUG=1 + cpu_shares: 5 +``` + +You can also write other services and link your `web` service to them: + +```yaml +web: + extends: + file: common-services.yml + service: webapp + environment: + - DEBUG=1 + cpu_shares: 5 + links: + - db +db: + image: postgres +``` + +For full details on how to use `extends`, refer to the [reference](#reference). + +### Example use case + +In this example, you’ll repurpose the example app from the [quick start +guide](index.md). (If you're not familiar with Compose, it's recommended that +you go through the quick start first.) This example assumes you want to use +Compose both to develop an application locally and then deploy it to a +production environment. + +The local and production environments are similar, but there are some +differences. In development, you mount the application code as a volume so that +it can pick up changes; in production, the code should be immutable from the +outside. This ensures it’s not accidentally changed. The development environment +uses a local Redis container, but in production another team manages the Redis +service, which is listening at `redis-production.example.com`. + +To configure with `extends` for this sample, you must: + +1. Define the web application as a Docker image in `Dockerfile` and a Compose + service in `common.yml`. + +2. Define the development environment in the standard Compose file, + `docker-compose.yml`. + + - Use `extends` to pull in the web service. + - Configure a volume to enable code reloading. + - Create an additional Redis service for the application to use locally. + +3. Define the production environment in a third Compose file, `production.yml`. + + - Use `extends` to pull in the web service. + - Configure the web service to talk to the external, production Redis service. + +#### Define the web app + +Defining the web application requires the following: + +1. Create an `app.py` file. + + This file contains a simple Python application that uses Flask to serve HTTP + and increments a counter in Redis: + + from flask import Flask + from redis import Redis + import os + + app = Flask(__name__) + redis = Redis(host=os.environ['REDIS_HOST'], port=6379) + + @app.route('/') + def hello(): + redis.incr('hits') + return 'Hello World! I have been seen %s times.\n' % redis.get('hits') + + if __name__ == "__main__": + app.run(host="0.0.0.0", debug=True) + + This code uses a `REDIS_HOST` environment variable to determine where to + find Redis. + +2. Define the Python dependencies in a `requirements.txt` file: + + flask + redis + +3. Create a `Dockerfile` to build an image containing the app: + + FROM python:2.7 + ADD . /code + WORKDIR /code + RUN pip install -r + requirements.txt + CMD python app.py + +4. Create a Compose configuration file called `common.yml`: + + This configuration defines how to run the app. + + web: + build: . + ports: + - "5000:5000" + + Typically, you would have dropped this configuration into + `docker-compose.yml` file, but in order to pull it into multiple files with + `extends`, it needs to be in a separate file. + +#### Define the development environment + +1. Create a `docker-compose.yml` file. + + The `extends` option pulls in the `web` service from the `common.yml` file + you created in the previous section. + + web: + extends: + file: common.yml + service: web + volumes: + - .:/code + links: + - redis + environment: + - REDIS_HOST=redis + redis: + image: redis + + The new addition defines a `web` service that: + + - Fetches the base configuration for `web` out of `common.yml`. + - Adds `volumes` and `links` configuration to the base (`common.yml`) + configuration. + - Sets the `REDIS_HOST` environment variable to point to the linked redis + container. This environment uses a stock `redis` image from the Docker Hub. + +2. Run `docker-compose up`. + + Compose creates, links, and starts a web and redis container linked together. + It mounts your application code inside the web container. + +3. Verify that the code is mounted by changing the message in + `app.py`—say, from `Hello world!` to `Hello from Compose!`. + + Don't forget to refresh your browser to see the change! + +#### Define the production environment + +You are almost done. Now, define your production environment: + +1. Create a `production.yml` file. + + As with `docker-compose.yml`, the `extends` option pulls in the `web` service + from `common.yml`. + + web: + extends: + file: common.yml + service: web + environment: + - REDIS_HOST=redis-production.example.com + +2. Run `docker-compose -f production.yml up`. + + Compose creates *just* a web container and configures the Redis connection via + the `REDIS_HOST` environment variable. This variable points to the production + Redis instance. + + > **Note**: If you try to load up the webapp in your browser you'll get an + > error—`redis-production.example.com` isn't actually a Redis server. + +You've now done a basic `extends` configuration. As your application develops, +you can make any necessary changes to the web service in `common.yml`. Compose +picks up both the development and production environments when you next run +`docker-compose`. You don't have to do any copy-and-paste, and you don't have to +manually keep both environments in sync. + + +### Reference + +You can use `extends` on any service together with other configuration keys. It +always expects a dictionary that should always contain two keys: `file` and +`service`. + +The `file` key specifies which file to look in. It can be an absolute path or a +relative one—if relative, it's treated as relative to the current file. + +The `service` key specifies the name of the service to extend, for example `web` +or `database`. + +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 them. + +#### 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. + +If a configuration option is defined in both the original service and the local +service, the local value either *override*s or *extend*s the definition of the +original service. This works differently for other configuration options. + +For single-value options like `image`, `command` or `mem_limit`, the new value +replaces the old value. **This is the default behaviour - all exceptions are +listed below.** + +```yaml +# original service +command: python app.py + +# local service +command: python otherapp.py + +# 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. + +```yaml +# original service +build: . + +# local service +image: redis + +# result +image: redis +``` + +```yaml +# original service +image: redis + +# local service +build: . + +# result +build: . +``` + +For the **multi-value options** `ports`, `expose`, `external_links`, `dns` and +`dns_search`, Compose concatenates both sets of values: + +```yaml +# original service +expose: + - "3000" + +# local service +expose: + - "4000" + - "5000" + +# result +expose: + - "3000" + - "4000" + - "5000" +``` + +In the case of `environment`, Compose "merges" entries together with +locally-defined values taking precedence: + +```yaml +# original service +environment: + - FOO=original + - BAR=original + +# local service +environment: + - BAR=local + - BAZ=local + +# result +environment: + - FOO=original + - BAR=local + - BAZ=local +``` + +Finally, for `volumes`, Compose "merges" entries together with locally-defined +bindings taking precedence: + +```yaml +# original service +volumes: + - /original-dir/foo:/foo + - /original-dir/bar:/bar + +# local service +volumes: + - /local-dir/bar:/bar + - /local-dir/baz/:baz + +# result +volumes: + - /original-dir/foo:/foo + - /local-dir/bar:/bar + - /local-dir/baz/:baz +``` \ No newline at end of file diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index aa741384644..428439bc425 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -1,6 +1,7 @@ - ['compose/index.md', 'User Guide', 'Docker Compose' ] - ['compose/production.md', 'User Guide', 'Using Compose in production' ] +- ['compose/extends.md', 'User Guide', 'Extending services in Compose'] - ['compose/install.md', 'Installation', 'Docker Compose'] - ['compose/cli.md', 'Reference', 'Compose command line'] - ['compose/yml.md', 'Reference', 'Compose yml'] diff --git a/docs/yml.md b/docs/yml.md index a9909e8167a..140a26e1d31 100644 --- a/docs/yml.md +++ b/docs/yml.md @@ -217,42 +217,10 @@ Here, the `web` service in **development.yml** inherits the configuration of the `webapp` service in **common.yml** - the `build` and `environment` keys - and adds `ports` and `links` configuration. It overrides one of the defined environment variables (DEBUG) with a new value, and the other one -(SEND_EMAILS) is left untouched. It's exactly as if you defined `web` like -this: +(SEND_EMAILS) is left untouched. -```yaml -web: - build: ./webapp - ports: - - "8000:8000" - links: - - db - environment: - - DEBUG=true - - SEND_EMAILS=false -``` - -The `extends` option is great for sharing configuration between different -apps, or for configuring the same app differently for different environments. -You could write a new file for a staging environment, **staging.yml**, which -binds to a different port and doesn't turn on debugging: - -``` -web: - extends: - file: common.yml - service: webapp - ports: - - "80:8000" - links: - - db -db: - image: postgres -``` - -> **Note:** When you extend a service, `links` and `volumes_from` -> configuration options are **not** inherited - you will have to define -> those manually each time you extend it. +For more on `extends`, see the [tutorial](extends.md#example) and +[reference](extends.md#reference). ### net From fd568b389ddaf1a07d5c1e5e7aebc53b96dc4b04 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 7 Apr 2015 12:59:47 +0100 Subject: [PATCH 0764/4072] Fix home directory and env expansion in volume paths Signed-off-by: Aanand Prasad --- compose/config.py | 2 ++ compose/service.py | 4 +--- tests/integration/service_test.py | 18 ++++++++++++++++++ tests/unit/config_test.py | 14 ++++++++++++++ tests/unit/service_test.py | 15 --------------- 5 files changed, 35 insertions(+), 18 deletions(-) diff --git a/compose/config.py b/compose/config.py index 2c2ddf63382..d3300d51136 100644 --- a/compose/config.py +++ b/compose/config.py @@ -328,6 +328,8 @@ def resolve_host_paths(volumes, working_dir=None): def resolve_host_path(volume, working_dir): container_path, host_path = split_volume(volume) if host_path is not None: + host_path = os.path.expanduser(host_path) + host_path = os.path.expandvars(host_path) return "%s:%s" % (expand_path(working_dir, host_path), container_path) else: return container_path diff --git a/compose/service.py b/compose/service.py index 936e3f9d0c7..86427a1ea25 100644 --- a/compose/service.py +++ b/compose/service.py @@ -3,7 +3,6 @@ from collections import namedtuple import logging import re -import os from operator import attrgetter import sys import six @@ -586,8 +585,7 @@ def parse_repository_tag(s): def build_volume_binding(volume_spec): internal = {'bind': volume_spec.internal, 'ro': volume_spec.mode == 'ro'} - external = os.path.expanduser(volume_spec.external) - return os.path.abspath(os.path.expandvars(external)), internal + return volume_spec.external, internal def build_port_bindings(ports): diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 066f8b0957e..544e2def0b5 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -123,6 +123,24 @@ def test_create_container_with_specified_volume(self): self.assertTrue(path.basename(actual_host_path) == path.basename(host_path), msg=("Last component differs: %s, %s" % (actual_host_path, host_path))) + @mock.patch.dict(os.environ) + def test_create_container_with_home_and_env_var_in_volume_path(self): + os.environ['VOLUME_NAME'] = 'my-volume' + os.environ['HOME'] = '/tmp/home-dir' + expected_host_path = os.path.join(os.environ['HOME'], os.environ['VOLUME_NAME']) + + host_path = '~/${VOLUME_NAME}' + container_path = '/container-path' + + service = self.create_service('db', volumes=['%s:%s' % (host_path, container_path)]) + container = service.create_container() + service.start_container(container) + + actual_host_path = container.get('Volumes')[container_path] + components = actual_host_path.split('/') + self.assertTrue(components[-2:] == ['home-dir', 'my-volume'], + msg="Last two components differ: %s, %s" % (actual_host_path, expected_host_path)) + def test_create_container_with_volumes_from(self): volume_service = self.create_service('data') volume_container_1 = volume_service.create_container() diff --git a/tests/unit/config_test.py b/tests/unit/config_test.py index ea7503430dd..97bd1b91d1d 100644 --- a/tests/unit/config_test.py +++ b/tests/unit/config_test.py @@ -40,6 +40,20 @@ def test_config_validation(self): config.make_service_dict('foo', {'ports': ['8000']}) +class VolumePathTest(unittest.TestCase): + @mock.patch.dict(os.environ) + def test_volume_binding_with_environ(self): + os.environ['VOLUME_PATH'] = '/host/path' + d = config.make_service_dict('foo', {'volumes': ['${VOLUME_PATH}:/container/path']}, working_dir='.') + self.assertEqual(d['volumes'], ['/host/path:/container/path']) + + @mock.patch.dict(os.environ) + def test_volume_binding_with_home(self): + os.environ['HOME'] = '/home/user' + d = config.make_service_dict('foo', {'volumes': ['~:/container/path']}, working_dir='.') + self.assertEqual(d['volumes'], ['/home/user:/container/path']) + + class MergeVolumesTest(unittest.TestCase): def test_empty(self): service_dict = config.merge_service_dicts({}, {}) diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 39a6f5c10cf..a3a94048caa 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -1,6 +1,5 @@ from __future__ import unicode_literals from __future__ import absolute_import -import os from .. import unittest import mock @@ -304,17 +303,3 @@ def test_build_volume_binding(self): self.assertEqual( binding, ('/outside', dict(bind='/inside', ro=False))) - - @mock.patch.dict(os.environ) - def test_build_volume_binding_with_environ(self): - os.environ['VOLUME_PATH'] = '/opt' - binding = build_volume_binding(parse_volume_spec('${VOLUME_PATH}:/opt')) - self.assertEqual(binding, ('/opt', dict(bind='/opt', ro=False))) - - @mock.patch.dict(os.environ) - def test_building_volume_binding_with_home(self): - os.environ['HOME'] = '/home/user' - binding = build_volume_binding(parse_volume_spec('~:/home/user')) - self.assertEqual( - binding, - ('/home/user', dict(bind='/home/user', ro=False))) From 1d7247b67e1f83c3534d80fe3f2addf4dbd8ccc7 Mon Sep 17 00:00:00 2001 From: Steven Dake Date: Wed, 8 Apr 2015 12:49:37 -0700 Subject: [PATCH 0765/4072] Remove stray print A previous commit introduced a stray print operation. Remove it. Signed-off-by: Steven Dake --- 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 85f6db9dbf4..38b994ba89c 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -422,7 +422,6 @@ def test_network_mode_host(self): def test_pid_mode_none_defined(self): service = self.create_service('web', pid=None) container = create_and_start_container(service) - print 'STEAK %s' % (container.get('HostConfig.PidMode')) self.assertEqual(container.get('HostConfig.PidMode'), '') def test_pid_mode_host(self): From a467a8a09486e9770a4d7de4f982aeb17d8439b2 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Thu, 9 Apr 2015 14:44:07 +0100 Subject: [PATCH 0766/4072] Merge pull request #1261 from aanand/fix-vars-in-volume-paths Fix vars in volume paths (cherry picked from commit 4926f8aef629631032327542a56ae35099807005) Signed-off-by: Aanand Prasad Conflicts: tests/unit/service_test.py --- compose/config.py | 2 ++ compose/service.py | 4 +--- tests/integration/service_test.py | 18 ++++++++++++++++++ tests/unit/config_test.py | 14 ++++++++++++++ tests/unit/service_test.py | 16 ---------------- 5 files changed, 35 insertions(+), 19 deletions(-) diff --git a/compose/config.py b/compose/config.py index 1dc64af25de..2dc59d23127 100644 --- a/compose/config.py +++ b/compose/config.py @@ -328,6 +328,8 @@ def resolve_host_paths(volumes, working_dir=None): def resolve_host_path(volume, working_dir): container_path, host_path = split_volume(volume) if host_path is not None: + host_path = os.path.expanduser(host_path) + host_path = os.path.expandvars(host_path) return "%s:%s" % (expand_path(working_dir, host_path), container_path) else: return container_path diff --git a/compose/service.py b/compose/service.py index 936e3f9d0c7..86427a1ea25 100644 --- a/compose/service.py +++ b/compose/service.py @@ -3,7 +3,6 @@ from collections import namedtuple import logging import re -import os from operator import attrgetter import sys import six @@ -586,8 +585,7 @@ def parse_repository_tag(s): def build_volume_binding(volume_spec): internal = {'bind': volume_spec.internal, 'ro': volume_spec.mode == 'ro'} - external = os.path.expanduser(volume_spec.external) - return os.path.abspath(os.path.expandvars(external)), internal + return volume_spec.external, internal def build_port_bindings(ports): diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index f0fb771d951..a89fde97bf5 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -123,6 +123,24 @@ def test_create_container_with_specified_volume(self): self.assertTrue(path.basename(actual_host_path) == path.basename(host_path), msg=("Last component differs: %s, %s" % (actual_host_path, host_path))) + @mock.patch.dict(os.environ) + def test_create_container_with_home_and_env_var_in_volume_path(self): + os.environ['VOLUME_NAME'] = 'my-volume' + os.environ['HOME'] = '/tmp/home-dir' + expected_host_path = os.path.join(os.environ['HOME'], os.environ['VOLUME_NAME']) + + host_path = '~/${VOLUME_NAME}' + container_path = '/container-path' + + service = self.create_service('db', volumes=['%s:%s' % (host_path, container_path)]) + container = service.create_container() + service.start_container(container) + + actual_host_path = container.get('Volumes')[container_path] + components = actual_host_path.split('/') + self.assertTrue(components[-2:] == ['home-dir', 'my-volume'], + msg="Last two components differ: %s, %s" % (actual_host_path, expected_host_path)) + def test_create_container_with_volumes_from(self): volume_service = self.create_service('data') volume_container_1 = volume_service.create_container() diff --git a/tests/unit/config_test.py b/tests/unit/config_test.py index f25f3a9d642..aa14a2a5e45 100644 --- a/tests/unit/config_test.py +++ b/tests/unit/config_test.py @@ -39,6 +39,20 @@ def test_config_validation(self): config.make_service_dict('foo', {'ports': ['8000']}) +class VolumePathTest(unittest.TestCase): + @mock.patch.dict(os.environ) + def test_volume_binding_with_environ(self): + os.environ['VOLUME_PATH'] = '/host/path' + d = config.make_service_dict('foo', {'volumes': ['${VOLUME_PATH}:/container/path']}, working_dir='.') + self.assertEqual(d['volumes'], ['/host/path:/container/path']) + + @mock.patch.dict(os.environ) + def test_volume_binding_with_home(self): + os.environ['HOME'] = '/home/user' + d = config.make_service_dict('foo', {'volumes': ['~:/container/path']}, working_dir='.') + self.assertEqual(d['volumes'], ['/home/user:/container/path']) + + class MergeVolumesTest(unittest.TestCase): def test_empty(self): service_dict = config.merge_service_dicts({}, {}) diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index c70c30bfa11..24222dfe976 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -1,6 +1,5 @@ from __future__ import unicode_literals from __future__ import absolute_import -import os from .. import unittest import mock @@ -301,18 +300,3 @@ def test_build_volume_binding(self): self.assertEqual( binding, ('/outside', dict(bind='/inside', ro=False))) - - @mock.patch.dict(os.environ) - def test_build_volume_binding_with_environ(self): - os.environ['VOLUME_PATH'] = '/opt' - binding = build_volume_binding(parse_volume_spec('${VOLUME_PATH}:/opt')) - self.assertEqual(binding, ('/opt', dict(bind='/opt', ro=False))) - - @mock.patch.dict(os.environ) - def test_building_volume_binding_with_home(self): - os.environ['HOME'] = '/home/user' - binding = build_volume_binding(parse_volume_spec('~:/home/user')) - self.assertEqual( - binding, - ('/home/user', dict(bind='/home/user', ro=False))) - From ceff5cb9cabc7b5e7f9ae6adc8dbd48abe340b85 Mon Sep 17 00:00:00 2001 From: Aleksandr Vinokurov Date: Tue, 31 Mar 2015 20:21:04 +0000 Subject: [PATCH 0767/4072] Add parent directories search for default compose-files Does not change directory to the parent with the compose-file found. Works like passing '--file' or setting 'COMPOSE_FILE' with absolute path. Resolves issue #946. Signed-off-by: Aleksandr Vinokurov --- compose/cli/command.py | 25 +++++++++++-------------- compose/cli/errors.py | 2 +- compose/cli/utils.py | 19 +++++++++++++++++++ docs/cli.md | 9 +++++++-- tests/unit/cli_test.py | 39 ++++++++++++++++++++++----------------- 5 files changed, 60 insertions(+), 34 deletions(-) diff --git a/compose/cli/command.py b/compose/cli/command.py index e829b25b2d8..bd6b2dc8485 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -10,7 +10,7 @@ from ..project import Project from ..service import ConfigError from .docopt_command import DocoptCommand -from .utils import call_silently, is_mac, is_ubuntu +from .utils import call_silently, is_mac, is_ubuntu, find_candidates_in_parent_dirs from .docker_client import docker_client from . import verbose_proxy from . import errors @@ -18,6 +18,13 @@ log = logging.getLogger(__name__) +SUPPORTED_FILENAMES = [ + 'docker-compose.yml', + 'docker-compose.yaml', + 'fig.yml', + 'fig.yaml', +] + class Command(DocoptCommand): base_dir = '.' @@ -100,20 +107,10 @@ def get_config_path(self, file_path=None): if file_path: return os.path.join(self.base_dir, file_path) - supported_filenames = [ - 'docker-compose.yml', - 'docker-compose.yaml', - 'fig.yml', - 'fig.yaml', - ] - - def expand(filename): - return os.path.join(self.base_dir, filename) - - candidates = [filename for filename in supported_filenames if os.path.exists(expand(filename))] + (candidates, path) = find_candidates_in_parent_dirs(SUPPORTED_FILENAMES, self.base_dir) if len(candidates) == 0: - raise errors.ComposeFileNotFound(supported_filenames) + raise errors.ComposeFileNotFound(SUPPORTED_FILENAMES) winner = candidates[0] @@ -130,4 +127,4 @@ def expand(filename): log.warning("%s is deprecated and will not be supported in future. " "Please rename your config file to docker-compose.yml\n" % winner) - return expand(winner) + return os.path.join(path, winner) diff --git a/compose/cli/errors.py b/compose/cli/errors.py index d439aa61c66..9a909e469dd 100644 --- a/compose/cli/errors.py +++ b/compose/cli/errors.py @@ -58,7 +58,7 @@ def __init__(self, url): class ComposeFileNotFound(UserError): def __init__(self, supported_filenames): super(ComposeFileNotFound, self).__init__(""" - Can't find a suitable configuration file. 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/cli/utils.py b/compose/cli/utils.py index d64eef4bccf..5f5fed64e20 100644 --- a/compose/cli/utils.py +++ b/compose/cli/utils.py @@ -62,6 +62,25 @@ def mkdir(path, permissions=0o700): return path +def find_candidates_in_parent_dirs(filenames, path): + """ + Given a directory path to start, looks for filenames in the + directory, and then each parent directory successively, + until found. + + Returns tuple (candidates, path). + """ + candidates = [filename for filename in filenames + if os.path.exists(os.path.join(path, filename))] + + if len(candidates) == 0: + parent_dir = os.path.join(path, '..') + if os.path.abspath(parent_dir) != os.path.abspath(path): + return find_candidates_in_parent_dirs(filenames, parent_dir) + + return (candidates, path) + + def split_buffer(reader, separator): """ Given a generator which yields strings and a separator string, diff --git a/docs/cli.md b/docs/cli.md index 30f82177148..1b0fa852e5f 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -136,7 +136,10 @@ By default, if there are existing containers for a service, `docker-compose up` ### -f, --file FILE - Specifies an alternate Compose yaml file (default: `docker-compose.yml`) + Specify what file to read configuration from. If not provided, Compose will look + for `docker-compose.yml` in the current working directory, and then each parent + directory successively, until found. + ### -p, --project-name NAME @@ -157,7 +160,9 @@ Sets the project name, which is prepended to the name of every container started ### COMPOSE\_FILE -Sets the path to the `docker-compose.yml` to use. Defaults to `docker-compose.yml` in the current working directory. +Specify what file to read configuration from. If not provided, Compose will look +for `docker-compose.yml` in the current working directory, and then each parent +directory successively, until found. ### DOCKER\_HOST diff --git a/tests/unit/cli_test.py b/tests/unit/cli_test.py index fcb55a67313..bc49be4b85c 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -62,29 +62,31 @@ def test_project_name_from_environment_new_var(self): self.assertEquals(project_name, name) def test_filename_check(self): - self.assertEqual('docker-compose.yml', get_config_filename_for_files([ + files = [ 'docker-compose.yml', 'docker-compose.yaml', 'fig.yml', 'fig.yaml', - ])) + ] - self.assertEqual('docker-compose.yaml', get_config_filename_for_files([ - 'docker-compose.yaml', - 'fig.yml', - 'fig.yaml', - ])) + """Test with files placed in the basedir""" - self.assertEqual('fig.yml', get_config_filename_for_files([ - 'fig.yml', - 'fig.yaml', - ])) + self.assertEqual('docker-compose.yml', get_config_filename_for_files(files[0:])) + self.assertEqual('docker-compose.yaml', get_config_filename_for_files(files[1:])) + self.assertEqual('fig.yml', get_config_filename_for_files(files[2:])) + self.assertEqual('fig.yaml', get_config_filename_for_files(files[3:])) + self.assertRaises(ComposeFileNotFound, lambda: get_config_filename_for_files([])) - self.assertEqual('fig.yaml', get_config_filename_for_files([ - 'fig.yaml', - ])) + """Test with files placed in the subdir""" - self.assertRaises(ComposeFileNotFound, lambda: get_config_filename_for_files([])) + def get_config_filename_for_files_in_subdir(files): + return get_config_filename_for_files(files, subdir=True) + + self.assertEqual('docker-compose.yml', get_config_filename_for_files_in_subdir(files[0:])) + self.assertEqual('docker-compose.yaml', get_config_filename_for_files_in_subdir(files[1:])) + self.assertEqual('fig.yml', get_config_filename_for_files_in_subdir(files[2:])) + self.assertEqual('fig.yaml', get_config_filename_for_files_in_subdir(files[3:])) + self.assertRaises(ComposeFileNotFound, lambda: get_config_filename_for_files_in_subdir([])) def test_get_project(self): command = TopLevelCommand() @@ -135,12 +137,15 @@ def test_run_with_environment_merged_with_options_list(self, mock_dockerpty): {'FOO': 'ONE', 'BAR': 'NEW', 'OTHER': 'THREE'}) -def get_config_filename_for_files(filenames): +def get_config_filename_for_files(filenames, subdir=None): project_dir = tempfile.mkdtemp() try: make_files(project_dir, filenames) command = TopLevelCommand() - command.base_dir = project_dir + if subdir: + command.base_dir = tempfile.mkdtemp(dir=project_dir) + else: + command.base_dir = project_dir return os.path.basename(command.get_config_path()) finally: shutil.rmtree(project_dir) From 2a442ec6d98bb014bee381dfcb9ef7a69a0a3d4a Mon Sep 17 00:00:00 2001 From: Fred Lifton Date: Thu, 9 Apr 2015 16:23:25 -0700 Subject: [PATCH 0768/4072] Adds Where to Get Help section Signed-off-by: Fred Lifton --- docs/index.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/index.md b/docs/index.md index da5a8efdb32..78d9de281d1 100644 --- a/docs/index.md +++ b/docs/index.md @@ -207,3 +207,17 @@ In addition to bug fixes and refinements, this release adds the following: * Better integration with Swarm. Swarm will now schedule inter-dependent containers on the same host. For details, see [PR #972](https://github.com/docker/compose/pull/972). + +## Getting help + +Docker Compose is still in its infancy and 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 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/project/get-help/). From 5f17423d3ebe45b43dc8d59b56ab781cc54c89a3 Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Fri, 10 Apr 2015 19:51:45 +0200 Subject: [PATCH 0769/4072] Add bash completion for docker-compose run --user 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 af336803670..548773d61df 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -232,14 +232,14 @@ _docker-compose_run() { compopt -o nospace return ;; - --entrypoint) + --entrypoint|--user|-u) return ;; esac case "$cur" in -*) - COMPREPLY=( $( compgen -W "--allow-insecure-ssl -d --entrypoint -e --no-deps --rm --service-ports -T" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--allow-insecure-ssl -d --entrypoint -e --no-deps --rm --service-ports -T --user -u" -- "$cur" ) ) ;; *) __docker-compose_services_all From 15b763acdbeb07c8039fe940771c762b8377d71c Mon Sep 17 00:00:00 2001 From: Michael Chase-Salerno Date: Wed, 15 Apr 2015 02:03:02 +0000 Subject: [PATCH 0770/4072] Fix for #1224, check that image or build is specified Signed-off-by: Michael Chase-Salerno --- compose/service.py | 2 ++ tests/unit/project_test.py | 12 +++++++++++- tests/unit/service_test.py | 30 +++++++++++++++++------------- 3 files changed, 30 insertions(+), 14 deletions(-) diff --git a/compose/service.py b/compose/service.py index a58ab5ff481..5afaa30fa8a 100644 --- a/compose/service.py +++ b/compose/service.py @@ -60,6 +60,8 @@ def __init__(self, name, client=None, project='default', links=None, external_li raise ConfigError('Invalid project name "%s" - only %s are allowed' % (project, VALID_NAME_CHARS)) if 'image' in options and 'build' in options: raise ConfigError('Service %s has both an image and build path specified. A service can either be built to image or use an existing image, not both.' % name) + if 'image' not in options and 'build' not in options: + raise ConfigError('Service %s has neither an image nor a build path specified. Exactly one must be provided.' % name) self.name = name self.client = client diff --git a/tests/unit/project_test.py b/tests/unit/project_test.py index d5c5acb780a..fc49e9b88ef 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -80,10 +80,12 @@ def test_get_services_returns_all_services_without_args(self): web = Service( project='composetest', name='web', + image='foo', ) console = Service( project='composetest', name='console', + image='foo', ) project = Project('test', [web, console], None) self.assertEqual(project.get_services(), [web, console]) @@ -92,10 +94,12 @@ def test_get_services_returns_listed_services_with_args(self): web = Service( project='composetest', name='web', + image='foo', ) console = Service( project='composetest', name='console', + image='foo', ) project = Project('test', [web, console], None) self.assertEqual(project.get_services(['console']), [console]) @@ -104,19 +108,23 @@ def test_get_services_with_include_links(self): db = Service( project='composetest', name='db', + image='foo', ) web = Service( project='composetest', name='web', + image='foo', links=[(db, 'database')] ) cache = Service( project='composetest', - name='cache' + name='cache', + image='foo' ) console = Service( project='composetest', name='console', + image='foo', links=[(web, 'web')] ) project = Project('test', [web, db, cache, console], None) @@ -129,10 +137,12 @@ def test_get_services_removes_duplicates_following_links(self): db = Service( project='composetest', name='db', + image='foo', ) web = Service( project='composetest', name='web', + image='foo', links=[(db, 'database')] ) project = Project('test', [web, db], None) diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index a3a94048caa..96b08107203 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -38,12 +38,12 @@ def test_name_validations(self): self.assertRaises(ConfigError, lambda: Service(name='foo_bar')) self.assertRaises(ConfigError, lambda: Service(name='__foo_bar__')) - Service('a') - Service('foo') + Service('a', image='foo') + Service('foo', image='foo') def test_project_validation(self): - self.assertRaises(ConfigError, lambda: Service(name='foo', project='_')) - Service(name='foo', project='bar') + self.assertRaises(ConfigError, lambda: Service(name='foo', project='_', image='foo')) + Service(name='foo', project='bar', image='foo') def test_get_container_name(self): self.assertIsNone(get_container_name({})) @@ -52,7 +52,7 @@ def test_get_container_name(self): self.assertEqual(get_container_name({'Names': ['/swarm-host-1/myproject_db_1', '/swarm-host-1/myproject_web_1/db']}), 'myproject_db_1') def test_containers(self): - service = Service('db', client=self.mock_client, project='myproject') + service = Service('db', client=self.mock_client, image='foo', project='myproject') self.mock_client.containers.return_value = [] self.assertEqual(service.containers(), []) @@ -66,7 +66,7 @@ def test_containers(self): self.assertEqual([c.id for c in service.containers()], ['IN_1']) def test_containers_prefixed(self): - service = Service('db', client=self.mock_client, project='myproject') + service = Service('db', client=self.mock_client, image='foo', project='myproject') self.mock_client.containers.return_value = [ {'Image': 'busybox', 'Id': 'OUT_1', 'Names': ['/swarm-host-1/myproject', '/swarm-host-1/foo/bar']}, @@ -80,14 +80,15 @@ def test_get_volumes_from_container(self): container_id = 'aabbccddee' service = Service( 'test', + image='foo', volumes_from=[mock.Mock(id=container_id, spec=Container)]) self.assertEqual(service._get_volumes_from(), [container_id]) def test_get_volumes_from_intermediate_container(self): container_id = 'aabbccddee' - service = Service('test') - container = mock.Mock(id=container_id, spec=Container) + service = Service('test', image='foo') + container = mock.Mock(id=container_id, spec=Container, image='foo') self.assertEqual(service._get_volumes_from(container), [container_id]) @@ -98,7 +99,7 @@ def test_get_volumes_from_service_container_exists(self): mock.Mock(id=container_id, spec=Container) for container_id in container_ids ] - service = Service('test', volumes_from=[from_service]) + service = Service('test', volumes_from=[from_service], image='foo') self.assertEqual(service._get_volumes_from(), container_ids) @@ -109,7 +110,7 @@ def test_get_volumes_from_service_no_container(self): from_service.create_container.return_value = mock.Mock( id=container_id, spec=Container) - service = Service('test', volumes_from=[from_service]) + service = Service('test', image='foo', volumes_from=[from_service]) self.assertEqual(service._get_volumes_from(), [container_id]) from_service.create_container.assert_called_once_with() @@ -157,7 +158,7 @@ def test_build_port_bindings_with_nonmatching_internal_ports(self): self.assertEqual(port_bindings["2000"], [("127.0.0.1", "2000")]) def test_split_domainname_none(self): - service = Service('foo', hostname='name', client=self.mock_client) + service = Service('foo', image='foo', hostname='name', client=self.mock_client) self.mock_client.containers.return_value = [] opts = service._get_container_create_options({'image': 'foo'}) self.assertEqual(opts['hostname'], 'name', 'hostname') @@ -167,6 +168,7 @@ def test_split_domainname_fqdn(self): service = Service( 'foo', hostname='name.domain.tld', + image='foo', client=self.mock_client) self.mock_client.containers.return_value = [] opts = service._get_container_create_options({'image': 'foo'}) @@ -177,6 +179,7 @@ def test_split_domainname_both(self): service = Service( 'foo', hostname='name', + image='foo', domainname='domain.tld', client=self.mock_client) self.mock_client.containers.return_value = [] @@ -189,6 +192,7 @@ def test_split_domainname_weird(self): 'foo', hostname='name.sub', domainname='domain.tld', + image='foo', client=self.mock_client) self.mock_client.containers.return_value = [] opts = service._get_container_create_options({'image': 'foo'}) @@ -197,7 +201,7 @@ def test_split_domainname_weird(self): def test_get_container_not_found(self): self.mock_client.containers.return_value = [] - service = Service('foo', client=self.mock_client) + service = Service('foo', client=self.mock_client, image='foo') self.assertRaises(ValueError, service.get_container) @@ -205,7 +209,7 @@ def test_get_container_not_found(self): def test_get_container(self, mock_container_class): container_dict = dict(Name='default_foo_2') self.mock_client.containers.return_value = [container_dict] - service = Service('foo', client=self.mock_client) + service = Service('foo', image='foo', client=self.mock_client) container = service.get_container(number=2) self.assertEqual(container, mock_container_class.from_ps.return_value) From 24a6c240fcc74025ec703f7a9232d346cee4a58f Mon Sep 17 00:00:00 2001 From: Michael Chase-Salerno Date: Wed, 15 Apr 2015 21:38:24 +0000 Subject: [PATCH 0771/4072] Testcase for #1224, check that image or build is specified Signed-off-by: Michael Chase-Salerno --- tests/unit/service_test.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 96b08107203..ec17018ed57 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -42,6 +42,7 @@ def test_name_validations(self): Service('foo', image='foo') def test_project_validation(self): + self.assertRaises(ConfigError, lambda: Service('bar')) self.assertRaises(ConfigError, lambda: Service(name='foo', project='_', image='foo')) Service(name='foo', project='bar', image='foo') From b6acb3cd8cec598504a4f25a2f91383e71d61701 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 14 Apr 2015 11:04:03 -0400 Subject: [PATCH 0772/4072] Merge pull request #1278 from albers/completion-run-user Add bash completion for docker-compose run --user (cherry picked from commit 3cd116b99d71f0e0da84e77797392e12070734e1) Signed-off-by: Aanand Prasad --- 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 af336803670..548773d61df 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -232,14 +232,14 @@ _docker-compose_run() { compopt -o nospace return ;; - --entrypoint) + --entrypoint|--user|-u) return ;; esac case "$cur" in -*) - COMPREPLY=( $( compgen -W "--allow-insecure-ssl -d --entrypoint -e --no-deps --rm --service-ports -T" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--allow-insecure-ssl -d --entrypoint -e --no-deps --rm --service-ports -T --user -u" -- "$cur" ) ) ;; *) __docker-compose_services_all From 39ae91c81c2dd8cddd2cbb3601dee8349a596340 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 23 Mar 2015 10:40:23 -0700 Subject: [PATCH 0773/4072] Bump 1.2.0 Signed-off-by: Aanand Prasad --- CHANGES.md | 23 +++++++++++++++++++++++ compose/__init__.py | 2 +- docs/completion.md | 2 +- docs/install.md | 2 +- 4 files changed, 26 insertions(+), 3 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 75c13090679..277a188a31e 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,6 +1,29 @@ Change log ========== +1.2.0 (2015-04-16) +------------------ + +- `docker-compose.yml` now supports an `extends` option, which enables a service to inherit configuration from another service in another configuration file. This is really good for sharing common configuration between apps, or for configuring the same app for different environments. Here's the [documentation](https://github.com/docker/compose/blob/master/docs/yml.md#extends). + +- When using Compose with a Swarm cluster, containers that depend on one another will be co-scheduled on the same node. This means that most Compose apps will now work out of the box, as long as they don't use `build`. + +- Repeated invocations of `docker-compose up` when using Compose with a Swarm cluster now work reliably. + +- Directories passed to `build`, filenames passed to `env_file` and volume host paths passed to `volumes` are now treated as relative to the *directory of the configuration file*, not the directory that `docker-compose` is being run in. In the majority of cases, those are the same, but if you use the `-f|--file` argument to specify a configuration file in another directory, **this is a breaking change**. + +- A service can now share another service's network namespace with `net: container:`. + +- `volumes_from` and `net: container:` entries are taken into account when resolving dependencies, so `docker-compose up ` will correctly start all dependencies of ``. + +- `docker-compose run` now accepts a `--user` argument to specify a user to run the command as, just like `docker run`. + +- The `up`, `stop` and `restart` commands now accept a `--timeout` (or `-t`) argument to specify how long to wait when attempting to gracefully stop containers, just like `docker stop`. + +- `docker-compose rm` now accepts `-f` as a shorthand for `--force`, just like `docker rm`. + +Thanks, @abesto, @albers, @alunduil, @dnephin, @funkyfuture, @gilclark, @IanVS, @KingsleyKelly, @knutwalker, @thaJeztah and @vmalloc! + 1.1.0 (2015-02-25) ------------------ diff --git a/compose/__init__.py b/compose/__init__.py index c770b3950ba..2c426c78145 100644 --- a/compose/__init__.py +++ b/compose/__init__.py @@ -1,4 +1,4 @@ from __future__ import unicode_literals from .service import Service # noqa:flake8 -__version__ = '1.1.0' +__version__ = '1.2.0' diff --git a/docs/completion.md b/docs/completion.md index d9b94f6cf1f..6ac95c2ef4b 100644 --- a/docs/completion.md +++ b/docs/completion.md @@ -17,7 +17,7 @@ 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/1.1.0/contrib/completion/bash/docker-compose > /etc/bash_completion.d/docker-compose + curl -L https://raw.githubusercontent.com/docker/compose/1.2.0/contrib/completion/bash/docker-compose > /etc/bash_completion.d/docker-compose Completion will be available upon next login. diff --git a/docs/install.md b/docs/install.md index 0e60e1f184a..064ddc5f112 100644 --- a/docs/install.md +++ b/docs/install.md @@ -20,7 +20,7 @@ First, install Docker version 1.3 or greater: To install Compose, run the following commands: - curl -L https://github.com/docker/compose/releases/download/1.1.0/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose + curl -L https://github.com/docker/compose/releases/download/1.2.0/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose chmod +x /usr/local/bin/docker-compose Optionally, you can also install [command completion](completion.md) for the From 8b5015c10fa6f441a7ac5337ae9af8ca083fac95 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 23 Mar 2015 10:40:23 -0700 Subject: [PATCH 0774/4072] Bump 1.2.0 Signed-off-by: Aanand Prasad --- CHANGES.md | 23 +++++++++++++++++++++++ compose/__init__.py | 2 +- docs/completion.md | 2 +- docs/install.md | 2 +- 4 files changed, 26 insertions(+), 3 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 75c13090679..277a188a31e 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,6 +1,29 @@ Change log ========== +1.2.0 (2015-04-16) +------------------ + +- `docker-compose.yml` now supports an `extends` option, which enables a service to inherit configuration from another service in another configuration file. This is really good for sharing common configuration between apps, or for configuring the same app for different environments. Here's the [documentation](https://github.com/docker/compose/blob/master/docs/yml.md#extends). + +- When using Compose with a Swarm cluster, containers that depend on one another will be co-scheduled on the same node. This means that most Compose apps will now work out of the box, as long as they don't use `build`. + +- Repeated invocations of `docker-compose up` when using Compose with a Swarm cluster now work reliably. + +- Directories passed to `build`, filenames passed to `env_file` and volume host paths passed to `volumes` are now treated as relative to the *directory of the configuration file*, not the directory that `docker-compose` is being run in. In the majority of cases, those are the same, but if you use the `-f|--file` argument to specify a configuration file in another directory, **this is a breaking change**. + +- A service can now share another service's network namespace with `net: container:`. + +- `volumes_from` and `net: container:` entries are taken into account when resolving dependencies, so `docker-compose up ` will correctly start all dependencies of ``. + +- `docker-compose run` now accepts a `--user` argument to specify a user to run the command as, just like `docker run`. + +- The `up`, `stop` and `restart` commands now accept a `--timeout` (or `-t`) argument to specify how long to wait when attempting to gracefully stop containers, just like `docker stop`. + +- `docker-compose rm` now accepts `-f` as a shorthand for `--force`, just like `docker rm`. + +Thanks, @abesto, @albers, @alunduil, @dnephin, @funkyfuture, @gilclark, @IanVS, @KingsleyKelly, @knutwalker, @thaJeztah and @vmalloc! + 1.1.0 (2015-02-25) ------------------ diff --git a/compose/__init__.py b/compose/__init__.py index c770b3950ba..2c426c78145 100644 --- a/compose/__init__.py +++ b/compose/__init__.py @@ -1,4 +1,4 @@ from __future__ import unicode_literals from .service import Service # noqa:flake8 -__version__ = '1.1.0' +__version__ = '1.2.0' diff --git a/docs/completion.md b/docs/completion.md index d9b94f6cf1f..6ac95c2ef4b 100644 --- a/docs/completion.md +++ b/docs/completion.md @@ -17,7 +17,7 @@ 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/1.1.0/contrib/completion/bash/docker-compose > /etc/bash_completion.d/docker-compose + curl -L https://raw.githubusercontent.com/docker/compose/1.2.0/contrib/completion/bash/docker-compose > /etc/bash_completion.d/docker-compose Completion will be available upon next login. diff --git a/docs/install.md b/docs/install.md index 7ee6f1b9df4..24928d74c73 100644 --- a/docs/install.md +++ b/docs/install.md @@ -20,7 +20,7 @@ First, install Docker version 1.3 or greater: To install Compose, run the following commands: - curl -L https://github.com/docker/compose/releases/download/1.1.0/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose + curl -L https://github.com/docker/compose/releases/download/1.2.0/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose chmod +x /usr/local/bin/docker-compose > 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 above, then `exit`. From 310c7623f9c4c9a6928f8f2e0cb471c22c396cdf Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 16 Apr 2015 17:54:18 +0100 Subject: [PATCH 0775/4072] Bump 1.3.0dev Signed-off-by: Aanand Prasad --- compose/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/__init__.py b/compose/__init__.py index 2c426c78145..2de2a7f8b85 100644 --- a/compose/__init__.py +++ b/compose/__init__.py @@ -1,4 +1,4 @@ from __future__ import unicode_literals from .service import Service # noqa:flake8 -__version__ = '1.2.0' +__version__ = '1.3.0dev' From 43af1684c143ad8d07a5132fb376d4812497a6d3 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 17 Apr 2015 16:02:57 +0100 Subject: [PATCH 0776/4072] Update docs for 1.2.0 Signed-off-by: Aanand Prasad --- docs/extends.md | 364 +++++++++++++++++++++++++++++++++++++++++++++ docs/index.md | 30 ++++ docs/install.md | 6 +- docs/mkdocs.yml | 2 + docs/production.md | 77 ++++++++++ docs/yml.md | 54 +++---- 6 files changed, 495 insertions(+), 38 deletions(-) create mode 100644 docs/extends.md create mode 100644 docs/production.md diff --git a/docs/extends.md b/docs/extends.md new file mode 100644 index 00000000000..2393ca6aecd --- /dev/null +++ b/docs/extends.md @@ -0,0 +1,364 @@ +page_title: Extending services in Compose +page_description: How to use Docker Compose's "extends" keyword to share configuration between files and projects +page_keywords: fig, composition, compose, docker, orchestration, documentation, docs + + +## Extending services in Compose + +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 applications that reuse commonly-defined services. +Using `extends` you can define a service in one place and refer to it from +anywhere. + +Alternatively, you can deploy the same application to multiple environments with +a slightly different set of services in each case (or with changes to the +configuration of some services). Moreover, you can do so without copy-pasting +the configuration around. + +### Understand the extends configuration + +When defining any service in `docker-compose.yml`, you can declare that you are +extending another service like this: + +```yaml +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: + +```yaml +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 that `build`, `ports` and `volumes` configuration +defined directly under `web`. + +You can go further and define (or re-define) configuration locally in +`docker-compose.yml`: + +```yaml +web: + extends: + file: common-services.yml + service: webapp + environment: + - DEBUG=1 + cpu_shares: 5 +``` + +You can also write other services and link your `web` service to them: + +```yaml +web: + extends: + file: common-services.yml + service: webapp + environment: + - DEBUG=1 + cpu_shares: 5 + links: + - db +db: + image: postgres +``` + +For full details on how to use `extends`, refer to the [reference](#reference). + +### Example use case + +In this example, you’ll repurpose the example app from the [quick start +guide](index.md). (If you're not familiar with Compose, it's recommended that +you go through the quick start first.) This example assumes you want to use +Compose both to develop an application locally and then deploy it to a +production environment. + +The local and production environments are similar, but there are some +differences. In development, you mount the application code as a volume so that +it can pick up changes; in production, the code should be immutable from the +outside. This ensures it’s not accidentally changed. The development environment +uses a local Redis container, but in production another team manages the Redis +service, which is listening at `redis-production.example.com`. + +To configure with `extends` for this sample, you must: + +1. Define the web application as a Docker image in `Dockerfile` and a Compose + service in `common.yml`. + +2. Define the development environment in the standard Compose file, + `docker-compose.yml`. + + - Use `extends` to pull in the web service. + - Configure a volume to enable code reloading. + - Create an additional Redis service for the application to use locally. + +3. Define the production environment in a third Compose file, `production.yml`. + + - Use `extends` to pull in the web service. + - Configure the web service to talk to the external, production Redis service. + +#### Define the web app + +Defining the web application requires the following: + +1. Create an `app.py` file. + + This file contains a simple Python application that uses Flask to serve HTTP + and increments a counter in Redis: + + from flask import Flask + from redis import Redis + import os + + app = Flask(__name__) + redis = Redis(host=os.environ['REDIS_HOST'], port=6379) + + @app.route('/') + def hello(): + redis.incr('hits') + return 'Hello World! I have been seen %s times.\n' % redis.get('hits') + + if __name__ == "__main__": + app.run(host="0.0.0.0", debug=True) + + This code uses a `REDIS_HOST` environment variable to determine where to + find Redis. + +2. Define the Python dependencies in a `requirements.txt` file: + + flask + redis + +3. Create a `Dockerfile` to build an image containing the app: + + FROM python:2.7 + ADD . /code + WORKDIR /code + RUN pip install -r + requirements.txt + CMD python app.py + +4. Create a Compose configuration file called `common.yml`: + + This configuration defines how to run the app. + + web: + build: . + ports: + - "5000:5000" + + Typically, you would have dropped this configuration into + `docker-compose.yml` file, but in order to pull it into multiple files with + `extends`, it needs to be in a separate file. + +#### Define the development environment + +1. Create a `docker-compose.yml` file. + + The `extends` option pulls in the `web` service from the `common.yml` file + you created in the previous section. + + web: + extends: + file: common.yml + service: web + volumes: + - .:/code + links: + - redis + environment: + - REDIS_HOST=redis + redis: + image: redis + + The new addition defines a `web` service that: + + - Fetches the base configuration for `web` out of `common.yml`. + - Adds `volumes` and `links` configuration to the base (`common.yml`) + configuration. + - Sets the `REDIS_HOST` environment variable to point to the linked redis + container. This environment uses a stock `redis` image from the Docker Hub. + +2. Run `docker-compose up`. + + Compose creates, links, and starts a web and redis container linked together. + It mounts your application code inside the web container. + +3. Verify that the code is mounted by changing the message in + `app.py`—say, from `Hello world!` to `Hello from Compose!`. + + Don't forget to refresh your browser to see the change! + +#### Define the production environment + +You are almost done. Now, define your production environment: + +1. Create a `production.yml` file. + + As with `docker-compose.yml`, the `extends` option pulls in the `web` service + from `common.yml`. + + web: + extends: + file: common.yml + service: web + environment: + - REDIS_HOST=redis-production.example.com + +2. Run `docker-compose -f production.yml up`. + + Compose creates *just* a web container and configures the Redis connection via + the `REDIS_HOST` environment variable. This variable points to the production + Redis instance. + + > **Note**: If you try to load up the webapp in your browser you'll get an + > error—`redis-production.example.com` isn't actually a Redis server. + +You've now done a basic `extends` configuration. As your application develops, +you can make any necessary changes to the web service in `common.yml`. Compose +picks up both the development and production environments when you next run +`docker-compose`. You don't have to do any copy-and-paste, and you don't have to +manually keep both environments in sync. + + +### Reference + +You can use `extends` on any service together with other configuration keys. It +always expects a dictionary that should always contain two keys: `file` and +`service`. + +The `file` key specifies which file to look in. It can be an absolute path or a +relative one—if relative, it's treated as relative to the current file. + +The `service` key specifies the name of the service to extend, for example `web` +or `database`. + +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 them. + +#### 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. + +If a configuration option is defined in both the original service and the local +service, the local value either *override*s or *extend*s the definition of the +original service. This works differently for other configuration options. + +For single-value options like `image`, `command` or `mem_limit`, the new value +replaces the old value. **This is the default behaviour - all exceptions are +listed below.** + +```yaml +# original service +command: python app.py + +# local service +command: python otherapp.py + +# 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. + +```yaml +# original service +build: . + +# local service +image: redis + +# result +image: redis +``` + +```yaml +# original service +image: redis + +# local service +build: . + +# result +build: . +``` + +For the **multi-value options** `ports`, `expose`, `external_links`, `dns` and +`dns_search`, Compose concatenates both sets of values: + +```yaml +# original service +expose: + - "3000" + +# local service +expose: + - "4000" + - "5000" + +# result +expose: + - "3000" + - "4000" + - "5000" +``` + +In the case of `environment`, Compose "merges" entries together with +locally-defined values taking precedence: + +```yaml +# original service +environment: + - FOO=original + - BAR=original + +# local service +environment: + - BAR=local + - BAZ=local + +# result +environment: + - FOO=original + - BAR=local + - BAZ=local +``` + +Finally, for `volumes`, Compose "merges" entries together with locally-defined +bindings taking precedence: + +```yaml +# original service +volumes: + - /original-dir/foo:/foo + - /original-dir/bar:/bar + +# local service +volumes: + - /local-dir/bar:/bar + - /local-dir/baz/:baz + +# result +volumes: + - /original-dir/foo:/foo + - /local-dir/bar:/bar + - /local-dir/baz/:baz +``` \ No newline at end of file diff --git a/docs/index.md b/docs/index.md index a75e7285a21..78d9de281d1 100644 --- a/docs/index.md +++ b/docs/index.md @@ -5,6 +5,8 @@ page_keywords: documentation, docs, docker, compose, orchestration, containers # Docker Compose +## Overview + Compose is a tool for defining and running complex applications with Docker. With Compose, you define a multi-container application in a single file, then spin your application up in a single command which does everything that needs to @@ -191,3 +193,31 @@ At this point, you have seen the basics of how Compose works. [Rails](rails.md), or [Wordpress](wordpress.md). - See the reference guides for complete details on the [commands](cli.md), the [configuration file](yml.md) and [environment variables](env.md). + +## Release Notes + +### Version 1.2.0 (April 7, 2015) + +For complete information on this release, see the [1.2.0 Milestone project page](https://github.com/docker/compose/wiki/1.2.0-Milestone-Project-Page). +In addition to bug fixes and refinements, this release adds the following: + +* The `extends` keyword, which adds the ability to extend services by sharing common configurations. For details, see +[PR #972](https://github.com/docker/compose/pull/1088). + +* Better integration with Swarm. Swarm will now schedule inter-dependent +containers on the same host. For details, see +[PR #972](https://github.com/docker/compose/pull/972). + +## Getting help + +Docker Compose is still in its infancy and 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 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/project/get-help/). diff --git a/docs/install.md b/docs/install.md index 064ddc5f112..24928d74c73 100644 --- a/docs/install.md +++ b/docs/install.md @@ -1,5 +1,5 @@ page_title: Installing Compose -page_description: How to intall Docker Compose +page_description: How to install Docker Compose page_keywords: compose, orchestration, install, installation, docker, documentation @@ -23,6 +23,8 @@ To install Compose, run the following commands: curl -L https://github.com/docker/compose/releases/download/1.2.0/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose chmod +x /usr/local/bin/docker-compose +> 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 above, then `exit`. + Optionally, you can also install [command completion](completion.md) for the bash shell. @@ -31,7 +33,7 @@ Compose can also be installed as a Python package: $ sudo pip install -U docker-compose -No further steps are required; Compose should now be successfully installed. +No further steps are required; Compose should now be successfully installed. You can test the installation by running `docker-compose --version`. ## Compose documentation diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 14335873dea..428439bc425 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -1,5 +1,7 @@ - ['compose/index.md', 'User Guide', 'Docker Compose' ] +- ['compose/production.md', 'User Guide', 'Using Compose in production' ] +- ['compose/extends.md', 'User Guide', 'Extending services in Compose'] - ['compose/install.md', 'Installation', 'Docker Compose'] - ['compose/cli.md', 'Reference', 'Compose command line'] - ['compose/yml.md', 'Reference', 'Compose yml'] diff --git a/docs/production.md b/docs/production.md new file mode 100644 index 00000000000..8524c99b813 --- /dev/null +++ b/docs/production.md @@ -0,0 +1,77 @@ +page_title: Using Compose in production +page_description: Guide to using Docker Compose in production +page_keywords: documentation, docs, docker, compose, orchestration, containers, production + + +## Using Compose in production + +While **Compose is not yet considered production-ready**, if you'd like to experiment and learn more about using it in production deployments, this guide +can help. +The project is actively working towards becoming +production-ready; to learn more about the progress being made, check out the +[roadmap](https://github.com/docker/compose/blob/master/ROADMAP.md) for details +on how it's coming along and what still needs to be done. + +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: + +- 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 a separate Compose file, say +`production.yml`, which specifies production-appropriate configuration. + +> **Note:** The [extends](extends.md) keyword is useful for maintaining multiple +> Compose files which re-use common services without having to manually copy and +> paste. + +Once you've got an alternate configuration file, make Compose use it +by setting the `COMPOSE_FILE` environment variable: + + $ COMPOSE_FILE=production.yml + $ docker-compose up -d + +> **Note:** You can also use the file for a one-off command without setting +> an environment variable. You do this by passing the `-f` flag, e.g., +> `docker-compose -f production.yml up -d`. + +### 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](https://docs.docker.com/machine) 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](https://docs.docker.com/swarm), 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](https://github.com/docker/compose/blob/master/SWARM.md). diff --git a/docs/yml.md b/docs/yml.md index a9909e8167a..c375648df4f 100644 --- a/docs/yml.md +++ b/docs/yml.md @@ -173,8 +173,12 @@ env_file: - /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. + ``` -RACK_ENV: development +# Set Rails/Rack environment +RACK_ENV=development ``` ### extends @@ -217,42 +221,10 @@ Here, the `web` service in **development.yml** inherits the configuration of the `webapp` service in **common.yml** - the `build` and `environment` keys - and adds `ports` and `links` configuration. It overrides one of the defined environment variables (DEBUG) with a new value, and the other one -(SEND_EMAILS) is left untouched. It's exactly as if you defined `web` like -this: +(SEND_EMAILS) is left untouched. -```yaml -web: - build: ./webapp - ports: - - "8000:8000" - links: - - db - environment: - - DEBUG=true - - SEND_EMAILS=false -``` - -The `extends` option is great for sharing configuration between different -apps, or for configuring the same app differently for different environments. -You could write a new file for a staging environment, **staging.yml**, which -binds to a different port and doesn't turn on debugging: - -``` -web: - extends: - file: common.yml - service: webapp - ports: - - "80:8000" - links: - - db -db: - image: postgres -``` - -> **Note:** When you extend a service, `links` and `volumes_from` -> configuration options are **not** inherited - you will have to define -> those manually each time you extend it. +For more on `extends`, see the [tutorial](extends.md#example) and +[reference](extends.md#reference). ### net @@ -264,6 +236,16 @@ net: "none" net: "container:[name or id]" net: "host" ``` +### 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. ### dns From 2291fa2d45ad38d6804e988e001761f4d8a29650 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 21 Apr 2015 11:58:44 +0100 Subject: [PATCH 0777/4072] Fix --timeout flag on restart, add tests for stop and restart Signed-off-by: Aanand Prasad --- compose/container.py | 4 ++-- tests/integration/cli_test.py | 13 ++++++++++++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/compose/container.py b/compose/container.py index 1d044a421ce..e10f1385032 100644 --- a/compose/container.py +++ b/compose/container.py @@ -126,8 +126,8 @@ def stop(self, **options): def kill(self, **options): return self.client.kill(self.id, **options) - def restart(self): - return self.client.restart(self.id) + def restart(self, **options): + return self.client.restart(self.id, **options) def remove(self, **options): return self.client.remove_container(self.id, **options) diff --git a/tests/integration/cli_test.py b/tests/integration/cli_test.py index df3eec66d08..c7e2ea3438d 100644 --- a/tests/integration/cli_test.py +++ b/tests/integration/cli_test.py @@ -332,6 +332,17 @@ def test_rm(self): self.command.dispatch(['rm', '-f'], None) self.assertEqual(len(service.containers(stopped=True)), 0) + def test_stop(self): + self.command.dispatch(['up', '-d'], None) + service = self.project.get_service('simple') + self.assertEqual(len(service.containers()), 1) + self.assertTrue(service.containers()[0].is_running) + + self.command.dispatch(['stop', '-t', '1'], None) + + self.assertEqual(len(service.containers(stopped=True)), 1) + self.assertFalse(service.containers(stopped=True)[0].is_running) + def test_kill(self): self.command.dispatch(['up', '-d'], None) service = self.project.get_service('simple') @@ -371,7 +382,7 @@ def test_restart(self): container = service.create_container() service.start_container(container) started_at = container.dictionary['State']['StartedAt'] - self.command.dispatch(['restart'], None) + self.command.dispatch(['restart', '-t', '1'], None) container.inspect() self.assertNotEqual( container.dictionary['State']['FinishedAt'], From fb81c37ca643876dff1e3f9a4feee5882a32cc1e Mon Sep 17 00:00:00 2001 From: Sam Wing Date: Thu, 15 Jan 2015 12:58:17 -0800 Subject: [PATCH 0778/4072] added the extra_hosts option to the yml configuration which exposes the --add-host flag from the docker client Signed-off-by: Sam Wing --- compose/config.py | 2 ++ compose/service.py | 14 ++++++++++++++ docs/yml.md | 17 +++++++++++++++++ tests/integration/service_test.py | 14 ++++++++++++++ 4 files changed, 47 insertions(+) diff --git a/compose/config.py b/compose/config.py index f87da1d8c15..87d610c8208 100644 --- a/compose/config.py +++ b/compose/config.py @@ -15,6 +15,7 @@ 'entrypoint', 'env_file', 'environment', + 'extra_hosts', 'hostname', 'image', 'links', @@ -41,6 +42,7 @@ DOCKER_CONFIG_HINTS = { 'cpu_share': 'cpu_shares', + 'add_host': 'extra_hosts', 'link': 'links', 'port': 'ports', 'privilege': 'privileged', diff --git a/compose/service.py b/compose/service.py index 5afaa30fa8a..dfc7a71f079 100644 --- a/compose/service.py +++ b/compose/service.py @@ -23,6 +23,7 @@ 'dns', 'dns_search', 'env_file', + 'extra_hosts', 'net', 'pid', 'privileged', @@ -448,6 +449,8 @@ def _get_container_host_config(self, override_options, one_off=False, intermedia restart = parse_restart_spec(options.get('restart', None)) + extra_hosts = build_extra_hosts(options.get('extra_hosts', None)) + return create_host_config( links=self._get_links(link_to_self=one_off), port_bindings=port_bindings, @@ -460,6 +463,7 @@ def _get_container_host_config(self, override_options, one_off=False, intermedia restart_policy=restart, cap_add=cap_add, cap_drop=cap_drop, + extra_hosts=extra_hosts, pid_mode=pid ) @@ -619,3 +623,13 @@ def split_port(port): external_ip, external_port, internal_port = parts return internal_port, (external_ip, external_port or None) + + +def build_extra_hosts(extra_hosts_config): + if extra_hosts_config is None: + return None + + if isinstance(extra_hosts_config, list): + return dict(r.split(':') for r in extra_hosts_config) + else: + return dict([extra_hosts_config.split(':')]) diff --git a/docs/yml.md b/docs/yml.md index c375648df4f..8756b202003 100644 --- a/docs/yml.md +++ b/docs/yml.md @@ -87,6 +87,23 @@ external_links: - project_db_1:postgresql ``` +### extra_hosts + +Add hostname mappings. Use the same values as the docker client `--add-hosts` parameter. + +``` +extra_hosts: + - docker: 162.242.195.82 + - fig: 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 docker +50.31.209.229 fig +``` + ### ports Expose ports. Either specify both ports (`HOST:CONTAINER`), or just the container diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 4abd4a9096e..5bc877d03e7 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -107,6 +107,20 @@ def test_create_container_with_cpu_shares(self): service.start_container(container) self.assertEqual(container.inspect()['Config']['CpuShares'], 73) + def test_create_container_with_extra_hosts_list(self): + extra_hosts = ['docker:162.242.195.82', 'fig:50.31.209.229'] + service = self.create_service('db', extra_hosts=extra_hosts) + container = service.create_container() + service.start_container(container) + self.assertEqual(container.get('HostConfig.ExtraHosts'), extra_hosts) + + def test_create_container_with_extra_hosts_string(self): + extra_hosts = 'docker:162.242.195.82' + service = self.create_service('db', extra_hosts=extra_hosts) + container = service.create_container() + service.start_container(container) + self.assertEqual(container.get('HostConfig.ExtraHosts'), [extra_hosts]) + def test_create_container_with_specified_volume(self): host_path = '/tmp/host-path' container_path = '/container-path' From 8098b65576b5c8e69705d533580c1d37a09632ad Mon Sep 17 00:00:00 2001 From: Thomas Desvenain Date: Wed, 21 Jan 2015 20:33:51 +0100 Subject: [PATCH 0779/4072] Fix when pyyaml has interpreted line as a dictionary Added unit tests in build_extra_hosts + fix Signed-off-by: CJ --- compose/service.py | 18 ++++++++++++++---- tests/integration/service_test.py | 23 +++++++++++++++++++++++ 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/compose/service.py b/compose/service.py index dfc7a71f079..f2dbd746ecd 100644 --- a/compose/service.py +++ b/compose/service.py @@ -629,7 +629,17 @@ def build_extra_hosts(extra_hosts_config): if extra_hosts_config is None: return None - if isinstance(extra_hosts_config, list): - return dict(r.split(':') for r in extra_hosts_config) - else: - return dict([extra_hosts_config.split(':')]) + if isinstance(extra_hosts_config, basestring): + extra_hosts_config = [extra_hosts_config] + + extra_hosts_dict = {} + for extra_hosts_line in extra_hosts_config: + if isinstance(extra_hosts_line, dict): + # already interpreted as a dict (depends on pyyaml version) + extra_hosts_dict.update(extra_hosts_line) + else: + # not already interpreted as a dict + host, ip = extra_hosts_line.split(':') + extra_hosts_dict.update({host.strip(): ip.strip()}) + + return extra_hosts_dict diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 5bc877d03e7..f71d609daea 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -6,6 +6,7 @@ from compose import Service from compose.service import CannotBeScaledError +from compose.service import build_extra_hosts from compose.container import Container from docker.errors import APIError from .testcases import DockerClientTestCase @@ -107,6 +108,28 @@ def test_create_container_with_cpu_shares(self): service.start_container(container) self.assertEqual(container.inspect()['Config']['CpuShares'], 73) + def test_build_extra_hosts(self): + # string + self.assertEqual(build_extra_hosts("www.example.com: 192.168.0.17"), + {'www.example.com': '192.168.0.17'}) + + # list of strings + self.assertEqual(build_extra_hosts( + ["www.example.com: 192.168.0.17"]), + {'www.example.com': '192.168.0.17'}) + self.assertEqual(build_extra_hosts( + ["www.example.com: 192.168.0.17", + "api.example.com: 192.168.0.18"]), + {'www.example.com': '192.168.0.17', + 'api.example.com': '192.168.0.18'}) + # list of dictionaries + self.assertEqual(build_extra_hosts( + [{'www.example.com': '192.168.0.17'}, + {'api.example.com': '192.168.0.18'} + ]), + {'www.example.com': '192.168.0.17', + 'api.example.com': '192.168.0.18'}) + def test_create_container_with_extra_hosts_list(self): extra_hosts = ['docker:162.242.195.82', 'fig:50.31.209.229'] service = self.create_service('db', extra_hosts=extra_hosts) From 25ee3f0033f349df9692cac943065b32be1fa4e2 Mon Sep 17 00:00:00 2001 From: CJ Date: Tue, 24 Mar 2015 18:25:09 +0800 Subject: [PATCH 0780/4072] Remove extra s from --add-host linting... six.string_types list-of-strings in examples disallow extra_hosts support for list-of-dicts A more thorough sets of tests for extra_hosts Provide better examples As per @aanand's [comment](https://github.com/docker/compose/pull/1158/files#r28326312) I think it'd be better to check `if not isinstance(extra_hosts_line, six.string_types)` and raise an error saying `extra_hosts_config must be either a list of strings or a string->string mapping`. We shouldn't need to do anything special with the list-of-dicts case. order result to work with assert use set() instead of sort() Signed-off-by: CJ --- compose/service.py | 33 ++++++++++------- docs/yml.md | 10 ++--- tests/integration/service_test.py | 61 +++++++++++++++++++++---------- 3 files changed, 66 insertions(+), 38 deletions(-) diff --git a/compose/service.py b/compose/service.py index f2dbd746ecd..e668dc49316 100644 --- a/compose/service.py +++ b/compose/service.py @@ -626,20 +626,25 @@ def split_port(port): def build_extra_hosts(extra_hosts_config): - if extra_hosts_config is None: - return None - - if isinstance(extra_hosts_config, basestring): - extra_hosts_config = [extra_hosts_config] - - extra_hosts_dict = {} - for extra_hosts_line in extra_hosts_config: - if isinstance(extra_hosts_line, dict): - # already interpreted as a dict (depends on pyyaml version) - extra_hosts_dict.update(extra_hosts_line) - else: - # not already interpreted as a dict + if not extra_hosts_config: + return {} + + if isinstance(extra_hosts_config, list): + extra_hosts_dict = {} + for extra_hosts_line in extra_hosts_config: + if not isinstance(extra_hosts_line, six.string_types): + raise ConfigError( + "extra_hosts_config \"%s\" must be either a list of strings or a string->string mapping," % + extra_hosts_config + ) host, ip = extra_hosts_line.split(':') extra_hosts_dict.update({host.strip(): ip.strip()}) + extra_hosts_config = extra_hosts_dict + + if isinstance(extra_hosts_config, dict): + return extra_hosts_config - return extra_hosts_dict + raise ConfigError( + "extra_hosts_config \"%s\" must be either a list of strings or a string->string mapping," % + extra_hosts_config + ) diff --git a/docs/yml.md b/docs/yml.md index 8756b202003..82aeed12888 100644 --- a/docs/yml.md +++ b/docs/yml.md @@ -89,19 +89,19 @@ external_links: ### extra_hosts -Add hostname mappings. Use the same values as the docker client `--add-hosts` parameter. +Add hostname mappings. Use the same values as the docker client `--add-host` parameter. ``` extra_hosts: - - docker: 162.242.195.82 - - fig: 50.31.209.229 + - "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 docker -50.31.209.229 fig +162.242.195.82 somehost +50.31.209.229 otherhost ``` ### ports diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index f71d609daea..3fbf546c038 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -5,8 +5,11 @@ import mock from compose import Service -from compose.service import CannotBeScaledError -from compose.service import build_extra_hosts +from compose.service import ( + CannotBeScaledError, + build_extra_hosts, + ConfigError, +) from compose.container import Container from docker.errors import APIError from .testcases import DockerClientTestCase @@ -110,39 +113,59 @@ def test_create_container_with_cpu_shares(self): def test_build_extra_hosts(self): # string - self.assertEqual(build_extra_hosts("www.example.com: 192.168.0.17"), - {'www.example.com': '192.168.0.17'}) + self.assertRaises(ConfigError, lambda: build_extra_hosts("www.example.com: 192.168.0.17")) # list of strings self.assertEqual(build_extra_hosts( - ["www.example.com: 192.168.0.17"]), - {'www.example.com': '192.168.0.17'}) + ["www.example.com:192.168.0.17"]), + {'www.example.com': '192.168.0.17'}) self.assertEqual(build_extra_hosts( - ["www.example.com: 192.168.0.17", - "api.example.com: 192.168.0.18"]), - {'www.example.com': '192.168.0.17', - 'api.example.com': '192.168.0.18'}) + ["www.example.com: 192.168.0.17"]), + {'www.example.com': '192.168.0.17'}) + self.assertEqual(build_extra_hosts( + ["www.example.com: 192.168.0.17", + "static.example.com:192.168.0.19", + "api.example.com: 192.168.0.18"]), + {'www.example.com': '192.168.0.17', + 'static.example.com': '192.168.0.19', + 'api.example.com': '192.168.0.18'}) + # list of dictionaries + self.assertRaises(ConfigError, lambda: build_extra_hosts( + [{'www.example.com': '192.168.0.17'}, + {'api.example.com': '192.168.0.18'}])) + + # dictionaries self.assertEqual(build_extra_hosts( - [{'www.example.com': '192.168.0.17'}, - {'api.example.com': '192.168.0.18'} - ]), - {'www.example.com': '192.168.0.17', - 'api.example.com': '192.168.0.18'}) + {'www.example.com': '192.168.0.17', + 'api.example.com': '192.168.0.18'}), + {'www.example.com': '192.168.0.17', + 'api.example.com': '192.168.0.18'}) def test_create_container_with_extra_hosts_list(self): - extra_hosts = ['docker:162.242.195.82', 'fig:50.31.209.229'] + extra_hosts = ['somehost:162.242.195.82', 'otherhost:50.31.209.229'] service = self.create_service('db', extra_hosts=extra_hosts) container = service.create_container() service.start_container(container) - self.assertEqual(container.get('HostConfig.ExtraHosts'), extra_hosts) + self.assertEqual(set(container.get('HostConfig.ExtraHosts')), set(extra_hosts)) def test_create_container_with_extra_hosts_string(self): - extra_hosts = 'docker:162.242.195.82' + extra_hosts = 'somehost:162.242.195.82' + service = self.create_service('db', extra_hosts=extra_hosts) + self.assertRaises(ConfigError, lambda: service.create_container()) + + def test_create_container_with_extra_hosts_list_of_dicts(self): + extra_hosts = [{'somehost': '162.242.195.82'}, {'otherhost': '50.31.209.229'}] + service = self.create_service('db', extra_hosts=extra_hosts) + self.assertRaises(ConfigError, lambda: service.create_container()) + + def test_create_container_with_extra_hosts_dicts(self): + extra_hosts = {'somehost': '162.242.195.82', 'otherhost': '50.31.209.229'} + extra_hosts_list = ['somehost:162.242.195.82', 'otherhost:50.31.209.229'] service = self.create_service('db', extra_hosts=extra_hosts) container = service.create_container() service.start_container(container) - self.assertEqual(container.get('HostConfig.ExtraHosts'), [extra_hosts]) + self.assertEqual(set(container.get('HostConfig.ExtraHosts')), set(extra_hosts_list)) def test_create_container_with_specified_volume(self): host_path = '/tmp/host-path' From d17c4d27fa5259fb8d853ccedc82d28fd199ff8f Mon Sep 17 00:00:00 2001 From: Kyle Walker Date: Fri, 6 Mar 2015 12:33:56 -0800 Subject: [PATCH 0781/4072] Support alternate Dockerfile name. Signed-off-by: Kyle James Walker --- compose/cli/docker_client.py | 2 +- compose/config.py | 1 + compose/service.py | 1 + docs/yml.md | 10 ++++++++++ requirements.txt | 4 ++-- setup.py | 4 ++-- 6 files changed, 17 insertions(+), 5 deletions(-) diff --git a/compose/cli/docker_client.py b/compose/cli/docker_client.py index 20acbdebcf3..7bbe0ebfaf0 100644 --- a/compose/cli/docker_client.py +++ b/compose/cli/docker_client.py @@ -32,4 +32,4 @@ def docker_client(): ) timeout = int(os.environ.get('DOCKER_CLIENT_TIMEOUT', 60)) - return Client(base_url=base_url, tls=tls_config, version='1.15', timeout=timeout) + return Client(base_url=base_url, tls=tls_config, version='1.17', timeout=timeout) diff --git a/compose/config.py b/compose/config.py index 2c2ddf63382..049c9cb326c 100644 --- a/compose/config.py +++ b/compose/config.py @@ -33,6 +33,7 @@ ALLOWED_KEYS = DOCKER_CONFIG_KEYS + [ 'build', + 'dockerfile', 'expose', 'external_links', 'name', diff --git a/compose/service.py b/compose/service.py index 936e3f9d0c7..7dbbfe7d006 100644 --- a/compose/service.py +++ b/compose/service.py @@ -474,6 +474,7 @@ def build(self, no_cache=False): stream=True, rm=True, nocache=no_cache, + dockerfile=self.options.get('dockerfile', None), ) try: diff --git a/docs/yml.md b/docs/yml.md index a9909e8167a..9dc2884baaf 100644 --- a/docs/yml.md +++ b/docs/yml.md @@ -39,6 +39,16 @@ Compose will build and tag it with a generated name, and use that image thereaft build: /path/to/build/dir ``` +### dockerfile + +Alternate Dockerfile. + +Compose will use an alternate file to build with. + +``` +dockerfile: Dockerfile-alternate +``` + ### command Override the default command. diff --git a/requirements.txt b/requirements.txt index 4c4113ab9f2..65f0754421e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,8 @@ PyYAML==3.10 -docker-py==1.0.0 +docker-py==1.1.0 dockerpty==0.3.2 docopt==0.6.1 -requests==2.2.1 +requests==2.6.1 six==1.7.3 texttable==0.8.2 websocket-client==0.11.0 diff --git a/setup.py b/setup.py index 39ac0f6f50b..c02a31f4faa 100644 --- a/setup.py +++ b/setup.py @@ -27,10 +27,10 @@ def find_version(*file_paths): install_requires = [ 'docopt >= 0.6.1, < 0.7', 'PyYAML >= 3.10, < 4', - 'requests >= 2.2.1, < 2.6', + 'requests >= 2.6.1, < 2.7', 'texttable >= 0.8.1, < 0.9', 'websocket-client >= 0.11.0, < 1.0', - 'docker-py >= 1.0.0, < 1.2', + 'docker-py >= 1.1.0, < 1.2', 'dockerpty >= 0.3.2, < 0.4', 'six >= 1.3.0, < 2', ] From 9a44708081406304325c45571026c1ca3ba1f944 Mon Sep 17 00:00:00 2001 From: Michael Chase-Salerno Date: Fri, 24 Apr 2015 20:45:18 +0000 Subject: [PATCH 0782/4072] Fix for #1301, Alphabetize Commands Signed-off-by: Michael Chase-Salerno --- compose/cli/main.py | 6 +++--- docs/cli.md | 6 +++++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 85e6755687b..92a7c5f3166 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -75,10 +75,10 @@ class TopLevelCommand(Command): docker-compose -h|--help Options: - --verbose Show more output - --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 Commands: build Build or rebuild services @@ -88,12 +88,12 @@ class TopLevelCommand(Command): 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 - restart Restart services up Create and start containers """ diff --git a/docs/cli.md b/docs/cli.md index 1b0fa852e5f..62287f1381a 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -47,6 +47,10 @@ Lists containers. Pulls service images. +### restart + +Restarts services. + ### rm Removes stopped service containers. @@ -130,7 +134,7 @@ By default, if there are existing containers for a service, `docker-compose up` Shows more output -### --version +### -v, --version Prints version and exits From 688f82c1cf12a7eb771ef543d4744fd89497f8d3 Mon Sep 17 00:00:00 2001 From: xuxinkun Date: Thu, 23 Apr 2015 09:33:46 +0800 Subject: [PATCH 0783/4072] Add cpuset config. Signed-off-by: xuxinkun --- compose/config.py | 1 + docs/yml.md | 3 ++- tests/integration/service_test.py | 6 ++++++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/compose/config.py b/compose/config.py index 6455d784201..c50ae211a50 100644 --- a/compose/config.py +++ b/compose/config.py @@ -7,6 +7,7 @@ 'cap_add', 'cap_drop', 'cpu_shares', + 'cpuset', 'command', 'detach', 'dns', diff --git a/docs/yml.md b/docs/yml.md index 101c2cf27b4..848c34a3f9d 100644 --- a/docs/yml.md +++ b/docs/yml.md @@ -310,13 +310,14 @@ dns_search: - dc2.example.com ``` -### working\_dir, entrypoint, user, hostname, domainname, mem\_limit, privileged, restart, stdin\_open, tty, cpu\_shares +### working\_dir, entrypoint, user, hostname, domainname, mem\_limit, privileged, restart, stdin\_open, tty, cpu\_shares, cpuset Each of these is a single value, analogous to its [docker run](https://docs.docker.com/reference/run/) counterpart. ``` cpu_shares: 73 +cpuset: 0,1 working_dir: /code entrypoint: /code/entrypoint.sh diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 3fbf546c038..c3d1219526a 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -167,6 +167,12 @@ def test_create_container_with_extra_hosts_dicts(self): service.start_container(container) self.assertEqual(set(container.get('HostConfig.ExtraHosts')), set(extra_hosts_list)) + def test_create_container_with_cpu_set(self): + service = self.create_service('db', cpuset='0') + container = service.create_container() + service.start_container(container) + self.assertEqual(container.inspect()['Config']['Cpuset'], '0') + def test_create_container_with_specified_volume(self): host_path = '/tmp/host-path' container_path = '/container-path' From 0ca9fa8b2b8fe08b639a07b15d828b22d32807a0 Mon Sep 17 00:00:00 2001 From: xwisen Date: Sun, 26 Apr 2015 12:49:27 +0800 Subject: [PATCH 0784/4072] modified the release notes section the first[PR #972]to[PR #1088] Signed-off-by: xwisen --- docs/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/index.md b/docs/index.md index 78d9de281d1..5ddf1bbc4f3 100644 --- a/docs/index.md +++ b/docs/index.md @@ -202,7 +202,7 @@ For complete information on this release, see the [1.2.0 Milestone project page] In addition to bug fixes and refinements, this release adds the following: * The `extends` keyword, which adds the ability to extend services by sharing common configurations. For details, see -[PR #972](https://github.com/docker/compose/pull/1088). +[PR #1088](https://github.com/docker/compose/pull/1088). * Better integration with Swarm. Swarm will now schedule inter-dependent containers on the same host. For details, see From 86a08c00f247131bfebcf750f37866eb0fcf9458 Mon Sep 17 00:00:00 2001 From: CJ Date: Mon, 27 Apr 2015 14:07:21 +0800 Subject: [PATCH 0785/4072] See https://github.com/docker/compose/pull/1158#discussion_r29063218 Signed-off-by: CJ --- compose/config.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/compose/config.py b/compose/config.py index 87d610c8208..95948ef8a07 100644 --- a/compose/config.py +++ b/compose/config.py @@ -43,6 +43,8 @@ DOCKER_CONFIG_HINTS = { 'cpu_share': 'cpu_shares', 'add_host': 'extra_hosts', + 'hosts': 'extra_hosts', + 'extra_host': 'extra_hosts', 'link': 'links', 'port': 'ports', 'privilege': 'privileged', From 2e19887bf102cbc77f668a2abff9e5b3035e5746 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 27 Apr 2015 14:58:20 +0100 Subject: [PATCH 0786/4072] Update README.md with changes to docs/index.md Signed-off-by: Aanand Prasad --- README.md | 43 +++++++++++++++++-------------------------- 1 file changed, 17 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index ce89d5aa271..60b57709443 100644 --- a/README.md +++ b/README.md @@ -12,33 +12,24 @@ recommend that you use it in production yet. Using Compose is basically a three-step process. -First, you define your app's environment with a `Dockerfile` so it can be -reproduced anywhere: - -```Dockerfile -FROM python:2.7 -WORKDIR /code -ADD requirements.txt /code/ -RUN pip install -r requirements.txt -ADD . /code -CMD python app.py -``` - -Next, you define the services that make up your app in `docker-compose.yml` so +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: - -```yaml -web: - build: . - links: - - db - ports: - - "8000:8000" -db: - image: postgres -``` - -Lastly, run `docker-compose up` and Compose will start and run your entire app. +3. Lastly, run `docker-compose up` and Compose will start and run your entire app. + +A `docker-compose.yml` looks like this: + + web: + build: . + ports: + - "5000:5000" + volumes: + - .:/code + links: + - redis + redis: + image: redis Compose has commands for managing the whole lifecycle of your application: From 240495f07f54574cbc83030f472948cca7fbd93e Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 27 Apr 2015 15:00:50 +0100 Subject: [PATCH 0787/4072] Remove DCO validation from CI script Signed-off-by: Aanand Prasad --- CONTRIBUTING.md | 6 ++--- script/.validate | 33 -------------------------- script/ci | 3 --- script/validate-dco | 58 --------------------------------------------- 4 files changed, 2 insertions(+), 98 deletions(-) delete mode 100644 script/.validate delete mode 100755 script/validate-dco diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0cca17b00f8..373c8dc6f82 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -31,8 +31,8 @@ that should get you started. ## Running the test suite -Use the test script to run DCO check, linting checks and then the full test -suite against different Python interpreters: +Use the test script to run linting checks and then the full test suite against +different Python interpreters: $ script/test @@ -51,8 +51,6 @@ you can specify a test directory, file, module, class or method: $ script/test tests.integration.service_test $ script/test tests.integration.service_test:ServiceTest.test_containers -Before pushing a commit you can check the DCO by invoking `script/validate-dco`. - ## Building binaries Linux: diff --git a/script/.validate b/script/.validate deleted file mode 100644 index 244cbe49c8f..00000000000 --- a/script/.validate +++ /dev/null @@ -1,33 +0,0 @@ -#!/bin/bash - -if [ -z "$VALIDATE_UPSTREAM" ]; then - # this is kind of an expensive check, so let's not do this twice if we - # are running more than one validate bundlescript - - VALIDATE_REPO='https://github.com/docker/fig.git' - VALIDATE_BRANCH='master' - - if [ "$TRAVIS" = 'true' -a "$TRAVIS_PULL_REQUEST" != 'false' ]; then - VALIDATE_REPO="https://github.com/${TRAVIS_REPO_SLUG}.git" - VALIDATE_BRANCH="${TRAVIS_BRANCH}" - fi - - VALIDATE_HEAD="$(git rev-parse --verify HEAD)" - - git fetch -q "$VALIDATE_REPO" "refs/heads/$VALIDATE_BRANCH" - VALIDATE_UPSTREAM="$(git rev-parse --verify FETCH_HEAD)" - - VALIDATE_COMMIT_LOG="$VALIDATE_UPSTREAM..$VALIDATE_HEAD" - VALIDATE_COMMIT_DIFF="$VALIDATE_UPSTREAM...$VALIDATE_HEAD" - - validate_diff() { - if [ "$VALIDATE_UPSTREAM" != "$VALIDATE_HEAD" ]; then - git diff "$VALIDATE_COMMIT_DIFF" "$@" - fi - } - validate_log() { - if [ "$VALIDATE_UPSTREAM" != "$VALIDATE_HEAD" ]; then - git log "$VALIDATE_COMMIT_LOG" "$@" - fi - } -fi diff --git a/script/ci b/script/ci index a1391c62746..2e4ec9197f2 100755 --- a/script/ci +++ b/script/ci @@ -8,9 +8,6 @@ set -e ->&2 echo "Validating DCO" -script/validate-dco - export DOCKER_VERSIONS=all . script/test-versions diff --git a/script/validate-dco b/script/validate-dco deleted file mode 100755 index 701ac5e465c..00000000000 --- a/script/validate-dco +++ /dev/null @@ -1,58 +0,0 @@ -#!/bin/bash - -set -e - -source "$(dirname "$BASH_SOURCE")/.validate" - -adds=$(validate_diff --numstat | awk '{ s += $1 } END { print s }') -dels=$(validate_diff --numstat | awk '{ s += $2 } END { print s }') -notDocs="$(validate_diff --numstat | awk '$3 !~ /^docs\// { print $3 }')" - -: ${adds:=0} -: ${dels:=0} - -# "Username may only contain alphanumeric characters or dashes and cannot begin with a dash" -githubUsernameRegex='[a-zA-Z0-9][a-zA-Z0-9-]+' - -# https://github.com/docker/docker/blob/master/CONTRIBUTING.md#sign-your-work -dcoPrefix='Signed-off-by:' -dcoRegex="^(Docker-DCO-1.1-)?$dcoPrefix ([^<]+) <([^<>@]+@[^<>]+)>( \\(github: ($githubUsernameRegex)\\))?$" - -check_dco() { - grep -qE "$dcoRegex" -} - -if [ $adds -eq 0 -a $dels -eq 0 ]; then - echo '0 adds, 0 deletions; nothing to validate! :)' -elif [ -z "$notDocs" -a $adds -le 1 -a $dels -le 1 ]; then - echo 'Congratulations! DCO small-patch-exception material!' -else - commits=( $(validate_log --format='format:%H%n') ) - badCommits=() - for commit in "${commits[@]}"; do - if [ -z "$(git log -1 --format='format:' --name-status "$commit")" ]; then - # no content (ie, Merge commit, etc) - continue - fi - if ! git log -1 --format='format:%B' "$commit" | check_dco; then - badCommits+=( "$commit" ) - fi - done - if [ ${#badCommits[@]} -eq 0 ]; then - echo "Congratulations! All commits are properly signed with the DCO!" - else - { - echo "These commits do not have a proper '$dcoPrefix' marker:" - for commit in "${badCommits[@]}"; do - echo " - $commit" - done - echo - echo 'Please amend each commit to include a properly formatted DCO marker.' - echo - echo 'Visit the following URL for information about the Docker DCO:' - echo ' https://github.com/docker/docker/blob/master/CONTRIBUTING.md#sign-your-work' - echo - } >&2 - false - fi -fi From 7d617d60bc2d4e0ed27a7c27378a84bb6485acbe Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 27 Apr 2015 15:10:01 +0100 Subject: [PATCH 0788/4072] Remove wercker.yml Signed-off-by: Aanand Prasad --- wercker.yml | 12 ------------ 1 file changed, 12 deletions(-) delete mode 100644 wercker.yml diff --git a/wercker.yml b/wercker.yml deleted file mode 100644 index 96fb22b5724..00000000000 --- a/wercker.yml +++ /dev/null @@ -1,12 +0,0 @@ -box: wercker-labs/docker -build: - steps: - - script: - name: validate DCO - code: script/validate-dco - - script: - name: run tests - code: script/test - - script: - name: build binary - code: script/build-linux From 855855a0e64d2304f8a9a1218783e82570ed7aa1 Mon Sep 17 00:00:00 2001 From: Timothy Van Heest Date: Mon, 27 Apr 2015 08:17:53 -0400 Subject: [PATCH 0789/4072] Fix for #1350, nonexisting build path in parent section causes extending section to fail Signed-off-by: Timothy Van Heest --- compose/config.py | 13 +++++++----- .../extends/nonexistent-path-base.yml | 6 ++++++ .../extends/nonexistent-path-child.yml | 8 ++++++++ tests/unit/config_test.py | 20 ++++++++++++++++++- 4 files changed, 41 insertions(+), 6 deletions(-) create mode 100644 tests/fixtures/extends/nonexistent-path-base.yml create mode 100644 tests/fixtures/extends/nonexistent-path-child.yml diff --git a/compose/config.py b/compose/config.py index f87da1d8c15..50ac5b606c7 100644 --- a/compose/config.py +++ b/compose/config.py @@ -64,6 +64,7 @@ def from_dictionary(dictionary, working_dir=None, filename=None): raise ConfigurationError('Service "%s" doesn\'t have any configuration options. All top level keys in your docker-compose.yml must map to a dictionary of configuration options.' % service_name) loader = ServiceLoader(working_dir=working_dir, filename=filename) service_dict = loader.make_service_dict(service_name, service_dict) + validate_paths(service_dict) service_dicts.append(service_dict) return service_dicts @@ -339,12 +340,14 @@ def resolve_host_path(volume, working_dir): def resolve_build_path(build_path, working_dir=None): if working_dir is None: raise Exception("No working_dir passed to resolve_build_path") + return expand_path(working_dir, build_path) - _path = expand_path(working_dir, build_path) - if not os.path.exists(_path) or not os.access(_path, os.R_OK): - raise ConfigurationError("build path %s either does not exist or is not accessible." % _path) - else: - return _path + +def validate_paths(service_dict): + if 'build' in service_dict: + build_path = service_dict['build'] + if not os.path.exists(build_path) or not os.access(build_path, os.R_OK): + raise ConfigurationError("build path %s either does not exist or is not accessible." % build_path) def merge_volumes(base, override): diff --git a/tests/fixtures/extends/nonexistent-path-base.yml b/tests/fixtures/extends/nonexistent-path-base.yml new file mode 100644 index 00000000000..1cf9a304aed --- /dev/null +++ b/tests/fixtures/extends/nonexistent-path-base.yml @@ -0,0 +1,6 @@ +dnebase: + build: nonexistent.path + command: /bin/true + environment: + - FOO=1 + - BAR=1 \ No newline at end of file diff --git a/tests/fixtures/extends/nonexistent-path-child.yml b/tests/fixtures/extends/nonexistent-path-child.yml new file mode 100644 index 00000000000..aab11459b1e --- /dev/null +++ b/tests/fixtures/extends/nonexistent-path-child.yml @@ -0,0 +1,8 @@ +dnechild: + extends: + file: nonexistent-path-base.yml + service: dnebase + image: busybox + command: /bin/true + environment: + - BAR=2 \ No newline at end of file diff --git a/tests/unit/config_test.py b/tests/unit/config_test.py index 97bd1b91d1d..12610f1025e 100644 --- a/tests/unit/config_test.py +++ b/tests/unit/config_test.py @@ -398,6 +398,21 @@ def test_volume_path(self): self.assertEqual(set(dicts[0]['volumes']), set(paths)) + def test_parent_build_path_dne(self): + child = config.load('tests/fixtures/extends/nonexistent-path-child.yml') + + self.assertEqual(child, [ + { + 'name': 'dnechild', + 'image': 'busybox', + 'command': '/bin/true', + 'environment': { + "FOO": "1", + "BAR": "2", + }, + }, + ]) + class BuildPathTest(unittest.TestCase): def setUp(self): @@ -407,7 +422,10 @@ def test_nonexistent_path(self): options = {'build': 'nonexistent.path'} self.assertRaises( config.ConfigurationError, - lambda: config.make_service_dict('foo', options, 'tests/fixtures/build-path'), + lambda: config.from_dictionary({ + 'foo': options, + 'working_dir': 'tests/fixtures/build-path' + }) ) def test_relative_path(self): From e5a118e3cedaf7a3a77967ba9201665d64eae069 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Fri, 24 Apr 2015 23:26:56 +0100 Subject: [PATCH 0790/4072] Use cool new IRCCloud links for IRC channel Signed-off-by: Ben Firshman --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ce89d5aa271..f62f0d86bcc 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ Installation and documentation ------------------------------ - Full documentation is available on [Docker's website](http://docs.docker.com/compose/). -- Hop into #docker-compose on Freenode if you have any questions. +- If you have any questions, you can talk in real-time with other developers in the #docker-compose IRC channel on Freenode. [Click here to join using IRCCloud.](https://www.irccloud.com/invite?hostname=irc.freenode.net&channel=%23docker-compose) Contributing ------------ From 021bf465572c2dae7e002a98f415fc61fa864691 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 27 Apr 2015 15:18:00 +0100 Subject: [PATCH 0791/4072] Update Docker version to 1.6 stable Signed-off-by: Aanand Prasad --- Dockerfile | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index 7438d6b1bf1..b2ae0063c8b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,14 +15,14 @@ RUN set -ex; \ ; \ rm -rf /var/lib/apt/lists/* -ENV ALL_DOCKER_VERSIONS 1.6.0-rc4 +ENV ALL_DOCKER_VERSIONS 1.6.0 RUN set -ex; \ - curl https://test.docker.com/builds/Linux/x86_64/docker-1.6.0-rc4 -o /usr/local/bin/docker-1.6.0-rc4; \ - chmod +x /usr/local/bin/docker-1.6.0-rc4 + curl https://get.docker.com/builds/Linux/x86_64/docker-1.6.0 -o /usr/local/bin/docker-1.6.0; \ + chmod +x /usr/local/bin/docker-1.6.0 # Set the default Docker to be run -RUN ln -s /usr/local/bin/docker-1.6.0-rc4 /usr/local/bin/docker +RUN ln -s /usr/local/bin/docker-1.6.0 /usr/local/bin/docker RUN useradd -d /home/user -m -s /bin/bash user WORKDIR /code/ From 3dd860f0ba67ac57ef9889eb0dd6ad58784a3030 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 24 Apr 2015 11:11:01 -0400 Subject: [PATCH 0792/4072] Fix #923, support image with ids instead of names. Signed-off-by: Daniel Nephin --- compose/service.py | 35 +++++++++++------------------ tests/integration/service_test.py | 5 +++++ tests/integration/testcases.py | 2 +- tests/unit/service_test.py | 37 +++++++++++++++++++++++++------ 4 files changed, 49 insertions(+), 30 deletions(-) diff --git a/compose/service.py b/compose/service.py index 5afaa30fa8a..eeda8f32bf3 100644 --- a/compose/service.py +++ b/compose/service.py @@ -193,13 +193,7 @@ def create_container(self, return Container.create(self.client, **container_options) except APIError as e: if e.response.status_code == 404 and e.explanation and 'No such image' in str(e.explanation): - log.info('Pulling image %s...' % container_options['image']) - output = self.client.pull( - container_options['image'], - stream=True, - insecure_registry=insecure_registry - ) - stream_output(output, sys.stdout) + self.pull(insecure_registry=insecure_registry) return Container.create(self.client, **container_options) raise @@ -413,8 +407,6 @@ def _get_container_create_options(self, override_options, one_off=False, interme if self.can_be_built(): container_options['image'] = self.full_name - else: - container_options['image'] = self._get_image_name(container_options['image']) # Delete options which are only used when starting for key in DOCKER_START_KEYS: @@ -463,12 +455,6 @@ def _get_container_host_config(self, override_options, one_off=False, intermedia pid_mode=pid ) - def _get_image_name(self, image): - repo, tag = parse_repository_tag(image) - if tag == "": - tag = "latest" - return '%s:%s' % (repo, tag) - def build(self, no_cache=False): log.info('Building %s...' % self.name) @@ -515,13 +501,18 @@ def can_be_scaled(self): return True def pull(self, insecure_registry=False): - if 'image' in self.options: - image_name = self._get_image_name(self.options['image']) - log.info('Pulling %s (%s)...' % (self.name, image_name)) - self.client.pull( - image_name, - insecure_registry=insecure_registry - ) + if 'image' not in self.options: + return + + repo, tag = parse_repository_tag(self.options['image']) + tag = tag or 'latest' + log.info('Pulling %s (%s:%s)...' % (self.name, repo, tag)) + output = self.client.pull( + repo, + tag=tag, + stream=True, + insecure_registry=insecure_registry) + stream_output(output, sys.stdout) NAME_RE = re.compile(r'^([^_]+)_([^_]+)_(run_)?(\d+)$') diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 4abd4a9096e..891caae585e 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -391,6 +391,11 @@ def test_port_with_explicit_interface(self): ], }) + def test_start_with_image_id(self): + # Image id for the current busybox:latest + service = self.create_service('foo', image='8c2e06607696') + self.assertTrue(service.start_or_create_containers()) + def test_scale(self): service = self.create_service('web') service.scale(1) diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index d5ca1debc8b..715b135c43d 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -22,7 +22,7 @@ def setUp(self): self.client.remove_image(i) def create_service(self, name, **kwargs): - kwargs['image'] = "busybox:latest" + kwargs['image'] = kwargs.pop('image', 'busybox:latest') if 'command' not in kwargs: kwargs['command'] = ["/bin/sleep", "300"] diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index ec17018ed57..b9f968db10a 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -221,9 +221,22 @@ def test_get_container(self, mock_container_class): def test_pull_image(self, mock_log): service = Service('foo', client=self.mock_client, image='someimage:sometag') service.pull(insecure_registry=True) - self.mock_client.pull.assert_called_once_with('someimage:sometag', insecure_registry=True) + self.mock_client.pull.assert_called_once_with( + 'someimage', + tag='sometag', + insecure_registry=True, + stream=True) mock_log.info.assert_called_once_with('Pulling foo (someimage:sometag)...') + def test_pull_image_no_tag(self): + service = Service('foo', client=self.mock_client, image='ababab') + service.pull() + self.mock_client.pull.assert_called_once_with( + 'ababab', + tag='latest', + insecure_registry=False, + stream=True) + @mock.patch('compose.service.Container', autospec=True) @mock.patch('compose.service.log', autospec=True) def test_create_container_from_insecure_registry( @@ -243,11 +256,12 @@ def test_create_container_from_insecure_registry( service.create_container(insecure_registry=True) self.mock_client.pull.assert_called_once_with( - 'someimage:sometag', + 'someimage', + tag='sometag', insecure_registry=True, stream=True) mock_log.info.assert_called_once_with( - 'Pulling image someimage:sometag...') + 'Pulling foo (someimage:sometag)...') def test_parse_repository_tag(self): self.assertEqual(parse_repository_tag("root"), ("root", "")) @@ -257,11 +271,20 @@ def test_parse_repository_tag(self): self.assertEqual(parse_repository_tag("url:5000/repo"), ("url:5000/repo", "")) self.assertEqual(parse_repository_tag("url:5000/repo:tag"), ("url:5000/repo", "tag")) - def test_latest_is_used_when_tag_is_not_specified(self): + @mock.patch('compose.service.Container', autospec=True) + def test_create_container_latest_is_used_when_no_tag_specified(self, mock_container): + mock_container.create.side_effect = APIError( + "oops", + mock.Mock(status_code=404), + "No such image") service = Service('foo', client=self.mock_client, image='someimage') - Container.create = mock.Mock() - service.create_container() - self.assertEqual(Container.create.call_args[1]['image'], 'someimage:latest') + with self.assertRaises(APIError): + service.create_container() + self.mock_client.pull.assert_called_once_with( + 'someimage', + tag='latest', + insecure_registry=False, + stream=True) def test_create_container_with_build(self): self.mock_client.images.return_value = [] From 2e6bc078fbc502965ac5d3a3ec0be13aafcfeb09 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 19 Mar 2015 16:10:27 -0700 Subject: [PATCH 0793/4072] Implement 'labels' option Signed-off-by: Aanand Prasad --- compose/cli/docker_client.py | 2 +- compose/config.py | 41 ++++++++++++++++++++++++++++++- compose/container.py | 4 +++ docs/extends.md | 4 +-- docs/install.md | 2 +- docs/yml.md | 18 ++++++++++++++ requirements.txt | 2 +- setup.py | 2 +- tests/integration/service_test.py | 27 ++++++++++++++++++++ tests/unit/config_test.py | 41 +++++++++++++++++++++++++++++++ 10 files changed, 136 insertions(+), 7 deletions(-) diff --git a/compose/cli/docker_client.py b/compose/cli/docker_client.py index 7bbe0ebfaf0..e513182fb3c 100644 --- a/compose/cli/docker_client.py +++ b/compose/cli/docker_client.py @@ -32,4 +32,4 @@ def docker_client(): ) timeout = int(os.environ.get('DOCKER_CLIENT_TIMEOUT', 60)) - return Client(base_url=base_url, tls=tls_config, version='1.17', timeout=timeout) + return Client(base_url=base_url, tls=tls_config, version='1.18', timeout=timeout) diff --git a/compose/config.py b/compose/config.py index c28703961d8..d5a82114a31 100644 --- a/compose/config.py +++ b/compose/config.py @@ -18,6 +18,7 @@ 'extra_hosts', 'hostname', 'image', + 'labels', 'links', 'mem_limit', 'net', @@ -180,6 +181,9 @@ def process_container_options(service_dict, working_dir=None): if 'build' in service_dict: service_dict['build'] = resolve_build_path(service_dict['build'], working_dir=working_dir) + if 'labels' in service_dict: + service_dict['labels'] = parse_labels(service_dict['labels']) + return service_dict @@ -198,6 +202,12 @@ def merge_service_dicts(base, override): override.get('volumes'), ) + if 'labels' in base or 'labels' in override: + d['labels'] = merge_labels( + base.get('labels'), + override.get('labels'), + ) + if 'image' in override and 'build' in d: del d['build'] @@ -216,7 +226,7 @@ def merge_service_dicts(base, override): if key in base or key in override: d[key] = to_list(base.get(key)) + to_list(override.get(key)) - already_merged_keys = ['environment', 'volumes'] + list_keys + list_or_string_keys + already_merged_keys = ['environment', 'volumes', 'labels'] + list_keys + list_or_string_keys for k in set(ALLOWED_KEYS) - set(already_merged_keys): if k in override: @@ -385,6 +395,35 @@ def join_volume(pair): return ":".join((host, container)) +def merge_labels(base, override): + labels = parse_labels(base) + labels.update(parse_labels(override)) + return labels + + +def parse_labels(labels): + if not labels: + return {} + + if isinstance(labels, list): + return dict(split_label(e) for e in labels) + + if isinstance(labels, dict): + return labels + + raise ConfigurationError( + "labels \"%s\" must be a list or mapping" % + labels + ) + + +def split_label(label): + if '=' in label: + return label.split('=', 1) + else: + return label, '' + + def expand_path(working_dir, path): return os.path.abspath(os.path.join(working_dir, path)) diff --git a/compose/container.py b/compose/container.py index e10f1385032..9439a708735 100644 --- a/compose/container.py +++ b/compose/container.py @@ -79,6 +79,10 @@ def format_port(private, public): return ', '.join(format_port(*item) for item in sorted(six.iteritems(self.ports))) + @property + def labels(self): + return self.get('Config.Labels') or {} + @property def human_readable_state(self): if self.is_running: diff --git a/docs/extends.md b/docs/extends.md index 2393ca6aecd..06c08f25e7a 100644 --- a/docs/extends.md +++ b/docs/extends.md @@ -321,8 +321,8 @@ expose: - "5000" ``` -In the case of `environment`, Compose "merges" entries together with -locally-defined values taking precedence: +In the case of `environment` and `labels`, Compose "merges" entries together +with locally-defined values taking precedence: ```yaml # original service diff --git a/docs/install.md b/docs/install.md index 24928d74c73..a3524c603c5 100644 --- a/docs/install.md +++ b/docs/install.md @@ -10,7 +10,7 @@ Compose with a `curl` command. ### Install Docker -First, install Docker version 1.3 or greater: +First, install Docker version 1.6 or greater: - [Instructions for Mac OS X](http://docs.docker.com/installation/mac/) - [Instructions for Ubuntu](http://docs.docker.com/installation/ubuntulinux/) diff --git a/docs/yml.md b/docs/yml.md index 101c2cf27b4..964cf5f2091 100644 --- a/docs/yml.md +++ b/docs/yml.md @@ -253,6 +253,24 @@ environment variables (DEBUG) with a new value, and the other one For more on `extends`, see the [tutorial](extends.md#example) and [reference](extends.md#reference). +### labels + +Add metadata to containers using [Docker labels](http://docs.docker.com/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" +``` + ### net Networking mode. Use the same values as the docker client `--net` parameter. diff --git a/requirements.txt b/requirements.txt index 65f0754421e..ed09cccac0e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ PyYAML==3.10 -docker-py==1.1.0 +docker-py==1.2.1 dockerpty==0.3.2 docopt==0.6.1 requests==2.6.1 diff --git a/setup.py b/setup.py index c02a31f4faa..46193eeefc7 100644 --- a/setup.py +++ b/setup.py @@ -30,7 +30,7 @@ def find_version(*file_paths): 'requests >= 2.6.1, < 2.7', 'texttable >= 0.8.1, < 0.9', 'websocket-client >= 0.11.0, < 1.0', - 'docker-py >= 1.1.0, < 1.2', + 'docker-py >= 1.2.0, < 1.3', 'dockerpty >= 0.3.2, < 0.4', 'six >= 1.3.0, < 2', ] diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 3fbf546c038..df5b2b9d391 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -584,3 +584,30 @@ def test_resolve_env(self): env = create_and_start_container(service).environment for k, v in {'FILE_DEF': 'F1', 'FILE_DEF_EMPTY': '', 'ENV_DEF': 'E3', 'NO_DEF': ''}.items(): self.assertEqual(env[k], v) + + def test_labels(self): + labels_dict = { + 'com.example.description': "Accounting webapp", + 'com.example.department': "Finance", + 'com.example.label-with-empty-value': "", + } + + service = self.create_service('web', labels=labels_dict) + labels = create_and_start_container(service).labels.items() + for pair in labels_dict.items(): + self.assertIn(pair, labels) + + labels_list = ["%s=%s" % pair for pair in labels_dict.items()] + + service = self.create_service('web', labels=labels_list) + labels = create_and_start_container(service).labels.items() + for pair in labels_dict.items(): + self.assertIn(pair, labels) + + def test_empty_labels(self): + labels_list = ['foo', 'bar'] + + service = self.create_service('web', labels=labels_list) + labels = create_and_start_container(service).labels.items() + for name in labels_list: + self.assertIn((name, ''), labels) diff --git a/tests/unit/config_test.py b/tests/unit/config_test.py index 97bd1b91d1d..c478a2182fa 100644 --- a/tests/unit/config_test.py +++ b/tests/unit/config_test.py @@ -185,6 +185,47 @@ def test_add_list(self): self.assertEqual(set(service_dict['dns']), set(['8.8.8.8', '9.9.9.9'])) +class MergeLabelsTest(unittest.TestCase): + def test_empty(self): + service_dict = config.merge_service_dicts({}, {}) + self.assertNotIn('labels', service_dict) + + def test_no_override(self): + service_dict = config.merge_service_dicts( + config.make_service_dict('foo', {'labels': ['foo=1', 'bar']}), + config.make_service_dict('foo', {}), + ) + self.assertEqual(service_dict['labels'], {'foo': '1', 'bar': ''}) + + def test_no_base(self): + service_dict = config.merge_service_dicts( + config.make_service_dict('foo', {}), + config.make_service_dict('foo', {'labels': ['foo=2']}), + ) + self.assertEqual(service_dict['labels'], {'foo': '2'}) + + def test_override_explicit_value(self): + service_dict = config.merge_service_dicts( + config.make_service_dict('foo', {'labels': ['foo=1', 'bar']}), + config.make_service_dict('foo', {'labels': ['foo=2']}), + ) + self.assertEqual(service_dict['labels'], {'foo': '2', 'bar': ''}) + + def test_add_explicit_value(self): + service_dict = config.merge_service_dicts( + config.make_service_dict('foo', {'labels': ['foo=1', 'bar']}), + config.make_service_dict('foo', {'labels': ['bar=2']}), + ) + self.assertEqual(service_dict['labels'], {'foo': '1', 'bar': '2'}) + + def test_remove_explicit_value(self): + service_dict = config.merge_service_dicts( + config.make_service_dict('foo', {'labels': ['foo=1', 'bar=2']}), + config.make_service_dict('foo', {'labels': ['bar']}), + ) + self.assertEqual(service_dict['labels'], {'foo': '1', 'bar': ''}) + + class EnvTest(unittest.TestCase): def test_parse_environment_as_list(self): environment = [ From 1a77feea3f6f938d0511a2dcc5f2f8c7b14a720c Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 29 Apr 2015 17:54:33 +0100 Subject: [PATCH 0794/4072] Close connection before attaching on 'up' and 'run' This ensures that the connection is not recycled, which can cause the Docker daemon to complain if we've already performed another streaming call such as doing a build. Signed-off-by: Aanand Prasad --- compose/service.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/compose/service.py b/compose/service.py index 43230be3d56..b81922dd165 100644 --- a/compose/service.py +++ b/compose/service.py @@ -476,6 +476,11 @@ def build(self, no_cache=False): except StreamOutputError as e: raise BuildError(self, unicode(e)) + # Ensure the HTTP connection is not reused for another + # streaming command, as the Docker daemon can sometimes + # complain about it + self.client.close() + image_id = None for event in all_events: From 4f366d83556f23a34f5a1a59cc45fb7eada3c5c2 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 30 Apr 2015 11:57:46 +0100 Subject: [PATCH 0795/4072] Make sure the build path we pass to docker-py is a binary string Signed-off-by: Aanand Prasad --- compose/service.py | 4 +++- tests/integration/service_test.py | 27 +++++++++++++++++++++++++++ tests/integration/testcases.py | 3 ++- 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/compose/service.py b/compose/service.py index b81922dd165..c6634483328 100644 --- a/compose/service.py +++ b/compose/service.py @@ -462,8 +462,10 @@ def _get_container_host_config(self, override_options, one_off=False, intermedia def build(self, no_cache=False): log.info('Building %s...' % self.name) + path = six.binary_type(self.options['build']) + build_output = self.client.build( - self.options['build'], + path=path, tag=self.full_name, stream=True, rm=True, diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 6e63bf3654b..8b2a1b1a7ea 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -4,6 +4,10 @@ from os import path import mock +import tempfile +import shutil +import six + from compose import Service from compose.service import ( CannotBeScaledError, @@ -404,6 +408,29 @@ def test_start_container_creates_ports(self): self.assertEqual(list(container['NetworkSettings']['Ports'].keys()), ['8000/tcp']) self.assertNotEqual(container['NetworkSettings']['Ports']['8000/tcp'][0]['HostPort'], '8000') + def test_build(self): + base_dir = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, base_dir) + + with open(os.path.join(base_dir, 'Dockerfile'), 'w') as f: + f.write("FROM busybox\n") + + self.create_service('web', build=base_dir).build() + self.assertEqual(len(self.client.images(name='composetest_web')), 1) + + def test_build_non_ascii_filename(self): + base_dir = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, base_dir) + + with open(os.path.join(base_dir, 'Dockerfile'), 'w') as f: + f.write("FROM busybox\n") + + with open(os.path.join(base_dir, b'foo\xE2bar'), 'w') as f: + f.write("hello world\n") + + self.create_service('web', build=six.text_type(base_dir)).build() + self.assertEqual(len(self.client.images(name='composetest_web')), 1) + def test_start_container_stays_unpriviliged(self): service = self.create_service('web') container = create_and_start_container(service).inspect() diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index 715b135c43d..31281a1d71e 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -22,7 +22,8 @@ def setUp(self): self.client.remove_image(i) def create_service(self, name, **kwargs): - kwargs['image'] = kwargs.pop('image', 'busybox:latest') + if 'image' not in kwargs and 'build' not in kwargs: + kwargs['image'] = 'busybox:latest' if 'command' not in kwargs: kwargs['command'] = ["/bin/sleep", "300"] From b8e0aed21cf4a13d11c49b8f09a73e7e20c4ea46 Mon Sep 17 00:00:00 2001 From: Simon Herter Date: Fri, 1 May 2015 18:51:20 -0400 Subject: [PATCH 0796/4072] Show proper command in help text of build subcommand The help text of the build subcommand suggested to use 'compose build' (instead of 'docker-compose build') to rebuild images. Signed-off-by: Simon Herter --- 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 92a7c5f3166..e941c005ce5 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -108,7 +108,7 @@ def build(self, project, options): 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, you can run `compose build` to rebuild it. + contents of its build directory, you can run `docker-compose build` to rebuild it. Usage: build [options] [SERVICE...] From b06294399a4f7a4895a3ac1ea324b235f1763377 Mon Sep 17 00:00:00 2001 From: CJ Date: Sun, 29 Mar 2015 23:28:57 +0800 Subject: [PATCH 0797/4072] See #1335: Added --read-only Signed-off-by: CJ --- compose/config.py | 1 + compose/service.py | 3 +++ docs/yml.md | 3 ++- tests/integration/service_test.py | 7 +++++++ 4 files changed, 13 insertions(+), 1 deletion(-) diff --git a/compose/config.py b/compose/config.py index d54215dd273..8ebcef01000 100644 --- a/compose/config.py +++ b/compose/config.py @@ -17,6 +17,7 @@ 'env_file', 'environment', 'extra_hosts', + 'read_only', 'hostname', 'image', 'labels', diff --git a/compose/service.py b/compose/service.py index c6634483328..6250e535109 100644 --- a/compose/service.py +++ b/compose/service.py @@ -24,6 +24,7 @@ 'dns_search', 'env_file', 'extra_hosts', + 'read_only', 'net', 'pid', 'privileged', @@ -442,6 +443,7 @@ def _get_container_host_config(self, override_options, one_off=False, intermedia restart = parse_restart_spec(options.get('restart', None)) extra_hosts = build_extra_hosts(options.get('extra_hosts', None)) + read_only = options.get('read_only', None) return create_host_config( links=self._get_links(link_to_self=one_off), @@ -456,6 +458,7 @@ def _get_container_host_config(self, override_options, one_off=False, intermedia cap_add=cap_add, cap_drop=cap_drop, extra_hosts=extra_hosts, + read_only=read_only, pid_mode=pid ) diff --git a/docs/yml.md b/docs/yml.md index d7196b40e5f..1e910d1842c 100644 --- a/docs/yml.md +++ b/docs/yml.md @@ -328,7 +328,7 @@ dns_search: - dc2.example.com ``` -### working\_dir, entrypoint, user, hostname, domainname, mem\_limit, privileged, restart, stdin\_open, tty, cpu\_shares, cpuset +### working\_dir, entrypoint, user, hostname, domainname, mem\_limit, privileged, restart, stdin\_open, tty, cpu\_shares, cpuset, read\_only Each of these is a single value, analogous to its [docker run](https://docs.docker.com/reference/run/) counterpart. @@ -351,6 +351,7 @@ restart: always stdin_open: true tty: true +read_only: true ``` ## Compose documentation diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index f7190df6431..edda71dec9c 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -177,6 +177,13 @@ def test_create_container_with_cpu_set(self): service.start_container(container) self.assertEqual(container.inspect()['Config']['Cpuset'], '0') + def test_create_container_with_read_only_root_fs(self): + read_only = True + 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')) + def test_create_container_with_specified_volume(self): host_path = '/tmp/host-path' container_path = '/container-path' From 1579a125a3b50acb9d34b116078c133ace28fded Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Wed, 6 May 2015 09:33:22 +0200 Subject: [PATCH 0798/4072] Ensure that exglob is set in bash completion Signed-off-by: Harald Albers --- contrib/completion/bash/docker-compose | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index 548773d61df..ec0f234812b 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -94,7 +94,7 @@ _docker-compose_build() { _docker-compose_docker-compose() { case "$prev" in --file|-f) - _filedir y?(a)ml + _filedir "y?(a)ml" return ;; --project-name|-p) @@ -303,6 +303,9 @@ _docker-compose_up() { _docker-compose() { + local previous_extglob_setting=$(shopt -p extglob) + shopt -s extglob + local commands=( build help @@ -352,6 +355,7 @@ _docker-compose() { local completions_func=_docker-compose_${command} declare -F $completions_func >/dev/null && $completions_func + eval "$previous_extglob_setting" return 0 } From f626fc5ce8f25f82b47d503c781855d9481e2b19 Mon Sep 17 00:00:00 2001 From: Vincent Demeester Date: Wed, 6 May 2015 13:18:58 +0200 Subject: [PATCH 0799/4072] Add support for log-driver in docker-compose.yml Closes #1303 Signed-off-by: Vincent Demeester --- compose/config.py | 1 + compose/container.py | 4 ++++ compose/service.py | 5 ++++- docs/yml.md | 14 ++++++++++++++ tests/integration/service_test.py | 18 ++++++++++++++++++ 5 files changed, 41 insertions(+), 1 deletion(-) diff --git a/compose/config.py b/compose/config.py index d54215dd273..5f9ed22b3c3 100644 --- a/compose/config.py +++ b/compose/config.py @@ -23,6 +23,7 @@ 'links', 'mem_limit', 'net', + 'log_driver', 'pid', 'ports', 'privileged', diff --git a/compose/container.py b/compose/container.py index 9439a708735..fc3370d9e44 100644 --- a/compose/container.py +++ b/compose/container.py @@ -83,6 +83,10 @@ def format_port(private, public): def labels(self): return self.get('Config.Labels') or {} + @property + def log_config(self): + return self.get('HostConfig.LogConfig') or None + @property def human_readable_state(self): if self.is_running: diff --git a/compose/service.py b/compose/service.py index c6634483328..ed87bc7ed26 100644 --- a/compose/service.py +++ b/compose/service.py @@ -8,7 +8,7 @@ import six from docker.errors import APIError -from docker.utils import create_host_config +from docker.utils import create_host_config, LogConfig from .config import DOCKER_CONFIG_KEYS from .container import Container, get_container_name @@ -25,6 +25,7 @@ 'env_file', 'extra_hosts', 'net', + 'log_driver', 'pid', 'privileged', 'restart', @@ -429,6 +430,7 @@ def _get_container_host_config(self, override_options, one_off=False, intermedia privileged = options.get('privileged', False) cap_add = options.get('cap_add', None) cap_drop = options.get('cap_drop', None) + log_config = LogConfig(type=options.get('log_driver', 'json-file')) pid = options.get('pid', None) dns = options.get('dns', None) @@ -455,6 +457,7 @@ def _get_container_host_config(self, override_options, one_off=False, intermedia restart_policy=restart, cap_add=cap_add, cap_drop=cap_drop, + log_config=log_config, extra_hosts=extra_hosts, pid_mode=pid ) diff --git a/docs/yml.md b/docs/yml.md index d7196b40e5f..40b6c605317 100644 --- a/docs/yml.md +++ b/docs/yml.md @@ -271,6 +271,20 @@ labels: - "com.example.label-with-empty-value" ``` +### log driver + +Specify a logging driver for the service's containers, as with the ``--log-driver`` option for docker run ([documented here](http://docs.docker.com/reference/run/#logging-drivers-log-driver)). + +Allowed values are currently ``json-file``, ``syslog`` and ``none``. The list will change over time as more drivers are added to the Docker engine. + +The default value is json-file. + +``` +log_driver: "json-file" +log_driver: "syslog" +log_driver: "none" +``` + ### net Networking mode. Use the same values as the docker client `--net` parameter. diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index f7190df6431..d25408bc5bc 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -649,3 +649,21 @@ def test_empty_labels(self): labels = create_and_start_container(service).labels.items() for name in labels_list: self.assertIn((name, ''), labels) + + def test_log_drive_invalid(self): + service = self.create_service('web', log_driver='xxx') + self.assertRaises(ValueError, lambda: create_and_start_container(service)) + + def test_log_drive_empty_default_jsonfile(self): + service = self.create_service('web') + log_config = create_and_start_container(service).log_config + + self.assertEqual('json-file', log_config['Type']) + self.assertFalse(log_config['Config']) + + def test_log_drive_none(self): + service = self.create_service('web', log_driver='none') + log_config = create_and_start_container(service).log_config + + self.assertEqual('none', log_config['Type']) + self.assertFalse(log_config['Config']) From d6223371d68b1a88dda67ab47cfca902fcbb58d8 Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Thu, 7 May 2015 03:22:11 -0700 Subject: [PATCH 0800/4072] Fix markdown formatting issue Signed-off-by: Harald Albers --- docs/completion.md | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/docs/completion.md b/docs/completion.md index 6ac95c2ef4b..96b5e8742bc 100644 --- a/docs/completion.md +++ b/docs/completion.md @@ -3,14 +3,12 @@ layout: default title: Command Completion --- -Command Completion -================== +#Command Completion Compose comes with [command completion](http://en.wikipedia.org/wiki/Command-line_completion) for the bash shell. -Installing Command Completion ------------------------------ +##Installing Command Completion 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` @@ -21,8 +19,8 @@ Place the completion script in `/etc/bash_completion.d/` (`/usr/local/etc/bash_c Completion will be available upon next login. -Available completions ---------------------- +##Available completions + Depending on what you typed on the command line so far, it will complete - available docker-compose commands From 6829efd4d3200ab67bef6bfb959f089f2dafcb45 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 24 Apr 2015 14:05:17 -0400 Subject: [PATCH 0801/4072] Resolves #874, Rename instead of use an intermediate. Signed-off-by: Daniel Nephin --- compose/project.py | 18 ++++---- compose/service.py | 74 ++++++++++++++----------------- tests/integration/service_test.py | 11 ++--- tests/unit/service_test.py | 16 ++++++- 4 files changed, 60 insertions(+), 59 deletions(-) diff --git a/compose/project.py b/compose/project.py index 7c0d19da390..2f3675ffbae 100644 --- a/compose/project.py +++ b/compose/project.py @@ -202,17 +202,15 @@ def up(self, running_containers = [] for service in self.get_services(service_names, include_deps=start_deps): if recreate: - for (_, container) in service.recreate_containers( - insecure_registry=insecure_registry, - detach=detach, - do_build=do_build): - running_containers.append(container) + create_func = service.recreate_containers else: - for container in service.start_or_create_containers( - insecure_registry=insecure_registry, - detach=detach, - do_build=do_build): - running_containers.append(container) + create_func = service.start_or_create_containers + + for container in create_func( + insecure_registry=insecure_registry, + detach=detach, + do_build=do_build): + running_containers.append(container) return running_containers diff --git a/compose/service.py b/compose/service.py index ee47142f26c..a1c0f9258f1 100644 --- a/compose/service.py +++ b/compose/service.py @@ -30,6 +30,7 @@ 'pid', 'privileged', 'restart', + 'volumes_from', ] VALID_NAME_CHARS = '[a-zA-Z0-9]' @@ -175,16 +176,16 @@ def create_container(self, one_off=False, insecure_registry=False, do_build=True, - intermediate_container=None, + previous_container=None, **override_options): """ Create a container for this service. If the image doesn't exist, attempt to pull it. """ + override_options['volumes_from'] = self._get_volumes_from(previous_container) container_options = self._get_container_create_options( override_options, one_off=one_off, - intermediate_container=intermediate_container, ) if (do_build and @@ -213,21 +214,24 @@ def recreate_containers(self, insecure_registry=False, do_build=True, **override do_build=do_build, **override_options) self.start_container(container) - return [(None, container)] - else: - tuples = [] - - for c in containers: - log.info("Recreating %s..." % c.name) - tuples.append(self.recreate_container(c, insecure_registry=insecure_registry, **override_options)) + return [container] - return tuples + return [ + self.recreate_container( + c, + insecure_registry=insecure_registry, + **override_options) + for c in containers + ] def recreate_container(self, container, **override_options): - """Recreate a container. An intermediate container is created so that - the new container has the same name, while still supporting - `volumes-from` the original container. + """Recreate a container. + + The original container is renamed to a temporary name so that data + volumes can be copied to the new container, before the original + container is removed. """ + log.info("Recreating %s..." % container.name) try: container.stop() except APIError as e: @@ -238,29 +242,17 @@ def recreate_container(self, container, **override_options): else: raise - intermediate_container = Container.create( - self.client, - image=container.image, - entrypoint=['/bin/echo'], - command=[], - detach=True, - host_config=create_host_config(volumes_from=[container.id]), - ) - intermediate_container.start() - intermediate_container.wait() - container.remove() - - options = dict(override_options) + # Use a hopefully unique container name by prepending the short id + self.client.rename( + container.id, + '%s_%s' % (container.short_id, container.name)) new_container = self.create_container( do_build=False, - intermediate_container=intermediate_container, - **options - ) + previous_container=container, + **override_options) self.start_container(new_container) - - intermediate_container.remove() - - return (intermediate_container, new_container) + container.remove() + return new_container def start_container_if_stopped(self, container): if container.is_running: @@ -333,7 +325,7 @@ def _get_links(self, link_to_self): links.append((external_link, link_name)) return links - def _get_volumes_from(self, intermediate_container=None): + def _get_volumes_from(self, previous_container=None): volumes_from = [] for volume_source in self.volumes_from: if isinstance(volume_source, Service): @@ -346,8 +338,8 @@ def _get_volumes_from(self, intermediate_container=None): elif isinstance(volume_source, Container): volumes_from.append(volume_source.id) - if intermediate_container: - volumes_from.append(intermediate_container.id) + if previous_container: + volumes_from.append(previous_container.id) return volumes_from @@ -370,7 +362,7 @@ def _get_net(self): return net - def _get_container_create_options(self, override_options, one_off=False, intermediate_container=None): + def _get_container_create_options(self, override_options, one_off=False): container_options = dict( (k, self.options[k]) for k in DOCKER_CONFIG_KEYS if k in self.options) @@ -415,11 +407,13 @@ def _get_container_create_options(self, override_options, one_off=False, interme for key in DOCKER_START_KEYS: container_options.pop(key, None) - container_options['host_config'] = self._get_container_host_config(override_options, one_off=one_off, intermediate_container=intermediate_container) + container_options['host_config'] = self._get_container_host_config( + override_options, + one_off=one_off) return container_options - def _get_container_host_config(self, override_options, one_off=False, intermediate_container=None): + def _get_container_host_config(self, override_options, one_off=False): options = dict(self.options, **override_options) port_bindings = build_port_bindings(options.get('ports') or []) @@ -451,7 +445,7 @@ def _get_container_host_config(self, override_options, one_off=False, intermedia links=self._get_links(link_to_self=one_off), port_bindings=port_bindings, binds=volume_bindings, - volumes_from=self._get_volumes_from(intermediate_container), + volumes_from=options.get('volumes_from'), privileged=privileged, network_mode=self._get_net(), dns=dns, diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 678aacdd079..dbb4a609c2d 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -249,25 +249,20 @@ def test_recreate_containers(self): num_containers_before = len(self.client.containers(all=True)) service.options['environment']['FOO'] = '2' - tuples = service.recreate_containers() - self.assertEqual(len(tuples), 1) - - intermediate_container = tuples[0][0] - new_container = tuples[0][1] - self.assertEqual(intermediate_container.dictionary['Config']['Entrypoint'], ['/bin/echo']) + new_container, = service.recreate_containers() self.assertEqual(new_container.dictionary['Config']['Entrypoint'], ['sleep']) self.assertEqual(new_container.dictionary['Config']['Cmd'], ['300']) self.assertIn('FOO=2', new_container.dictionary['Config']['Env']) self.assertEqual(new_container.name, 'composetest_db_1') self.assertEqual(new_container.inspect()['Volumes']['/etc'], volume_path) - self.assertIn(intermediate_container.id, new_container.dictionary['HostConfig']['VolumesFrom']) + self.assertIn(old_container.id, new_container.dictionary['HostConfig']['VolumesFrom']) self.assertEqual(len(self.client.containers(all=True)), num_containers_before) self.assertNotEqual(old_container.id, new_container.id) self.assertRaises(APIError, self.client.inspect_container, - intermediate_container.id) + old_container.id) def test_recreate_containers_when_containers_are_stopped(self): service = self.create_service( diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index b9f968db10a..583f72ef0b4 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -86,7 +86,7 @@ def test_get_volumes_from_container(self): self.assertEqual(service._get_volumes_from(), [container_id]) - def test_get_volumes_from_intermediate_container(self): + def test_get_volumes_from_previous_container(self): container_id = 'aabbccddee' service = Service('test', image='foo') container = mock.Mock(id=container_id, spec=Container, image='foo') @@ -263,6 +263,20 @@ def test_create_container_from_insecure_registry( mock_log.info.assert_called_once_with( 'Pulling foo (someimage:sometag)...') + @mock.patch('compose.service.Container', autospec=True) + def test_recreate_container(self, _): + mock_container = mock.create_autospec(Container) + service = Service('foo', client=self.mock_client, image='someimage') + new_container = service.recreate_container(mock_container) + + mock_container.stop.assert_called_once_with() + self.mock_client.rename.assert_called_once_with( + mock_container.id, + '%s_%s' % (mock_container.short_id, mock_container.name)) + + new_container.start.assert_called_once_with() + mock_container.remove.assert_called_once_with() + def test_parse_repository_tag(self): self.assertEqual(parse_repository_tag("root"), ("root", "")) self.assertEqual(parse_repository_tag("root:tag"), ("root", "tag")) From df87bd91c86ba089667c771a5bd8e0ad5957de39 Mon Sep 17 00:00:00 2001 From: "delbert@umn.edu" Date: Fri, 8 May 2015 18:14:32 -0500 Subject: [PATCH 0802/4072] Added devices configuration option Signed-off-by: Dan Elbert --- compose/config.py | 41 +++++++++++++++------------- compose/service.py | 4 +++ docs/extends.md | 6 ++--- docs/yml.md | 14 ++++++++-- tests/integration/service_test.py | 13 +++++++++ tests/unit/config_test.py | 45 ++++++++++++++++++++----------- 6 files changed, 85 insertions(+), 38 deletions(-) diff --git a/compose/config.py b/compose/config.py index 3241bb80e3b..1919ef5a35c 100644 --- a/compose/config.py +++ b/compose/config.py @@ -10,6 +10,7 @@ 'cpuset', 'command', 'detach', + 'devices', 'dns', 'dns_search', 'domainname', @@ -50,6 +51,7 @@ 'add_host': 'extra_hosts', 'hosts': 'extra_hosts', 'extra_host': 'extra_hosts', + 'device': 'devices', 'link': 'links', 'port': 'ports', 'privilege': 'privileged', @@ -200,11 +202,14 @@ def merge_service_dicts(base, override): override.get('environment'), ) - if 'volumes' in base or 'volumes' in override: - d['volumes'] = merge_volumes( - base.get('volumes'), - override.get('volumes'), - ) + path_mapping_keys = ['volumes', 'devices'] + + for key in path_mapping_keys: + if key in base or key in override: + d[key] = merge_path_mappings( + base.get(key), + override.get(key), + ) if 'labels' in base or 'labels' in override: d['labels'] = merge_labels( @@ -230,7 +235,7 @@ def merge_service_dicts(base, override): if key in base or key in override: d[key] = to_list(base.get(key)) + to_list(override.get(key)) - already_merged_keys = ['environment', 'volumes', 'labels'] + list_keys + list_or_string_keys + already_merged_keys = ['environment', 'labels'] + path_mapping_keys + list_keys + list_or_string_keys for k in set(ALLOWED_KEYS) - set(already_merged_keys): if k in override: @@ -346,7 +351,7 @@ def resolve_host_paths(volumes, working_dir=None): def resolve_host_path(volume, working_dir): - container_path, host_path = split_volume(volume) + container_path, host_path = split_path_mapping(volume) if host_path is not None: host_path = os.path.expanduser(host_path) host_path = os.path.expandvars(host_path) @@ -368,24 +373,24 @@ def validate_paths(service_dict): raise ConfigurationError("build path %s either does not exist or is not accessible." % build_path) -def merge_volumes(base, override): - d = dict_from_volumes(base) - d.update(dict_from_volumes(override)) - return volumes_from_dict(d) +def merge_path_mappings(base, override): + d = dict_from_path_mappings(base) + d.update(dict_from_path_mappings(override)) + return path_mappings_from_dict(d) -def dict_from_volumes(volumes): - if volumes: - return dict(split_volume(v) for v in volumes) +def dict_from_path_mappings(path_mappings): + if path_mappings: + return dict(split_path_mapping(v) for v in path_mappings) else: return {} -def volumes_from_dict(d): - return [join_volume(v) for v in d.items()] +def path_mappings_from_dict(d): + return [join_path_mapping(v) for v in d.items()] -def split_volume(string): +def split_path_mapping(string): if ':' in string: (host, container) = string.split(':', 1) return (container, host) @@ -393,7 +398,7 @@ def split_volume(string): return (string, None) -def join_volume(pair): +def join_path_mapping(pair): (container, host) = pair if host is None: return container diff --git a/compose/service.py b/compose/service.py index a1c0f9258f1..20f8db0a409 100644 --- a/compose/service.py +++ b/compose/service.py @@ -20,6 +20,7 @@ DOCKER_START_KEYS = [ 'cap_add', 'cap_drop', + 'devices', 'dns', 'dns_search', 'env_file', @@ -441,6 +442,8 @@ def _get_container_host_config(self, override_options, one_off=False): extra_hosts = build_extra_hosts(options.get('extra_hosts', None)) read_only = options.get('read_only', None) + devices = options.get('devices', None) + return create_host_config( links=self._get_links(link_to_self=one_off), port_bindings=port_bindings, @@ -448,6 +451,7 @@ def _get_container_host_config(self, override_options, one_off=False): volumes_from=options.get('volumes_from'), privileged=privileged, network_mode=self._get_net(), + devices=devices, dns=dns, dns_search=dns_search, restart_policy=restart, diff --git a/docs/extends.md b/docs/extends.md index 06c08f25e7a..a4768b8f5cc 100644 --- a/docs/extends.md +++ b/docs/extends.md @@ -342,8 +342,8 @@ environment: - BAZ=local ``` -Finally, for `volumes`, Compose "merges" entries together with locally-defined -bindings taking precedence: +Finally, for `volumes` and `devices`, Compose "merges" entries together with +locally-defined bindings taking precedence: ```yaml # original service @@ -361,4 +361,4 @@ volumes: - /original-dir/foo:/foo - /local-dir/bar:/bar - /local-dir/baz/:baz -``` \ No newline at end of file +``` diff --git a/docs/yml.md b/docs/yml.md index 96a478bb2b3..0b8d4313b2b 100644 --- a/docs/yml.md +++ b/docs/yml.md @@ -29,8 +29,8 @@ image: a4bc65fd ### build -Path to a directory containing a Dockerfile. When the value supplied is a -relative path, it is interpreted as relative to the location of the yml file +Path to a directory containing a Dockerfile. When the value supplied is a +relative path, it is interpreted as relative to the location of the yml file itself. 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. @@ -342,6 +342,16 @@ dns_search: - dc2.example.com ``` +### devices + +List of device mappings. Uses the same format as the `--device` docker +client create option. + +``` +devices: + - "/dev/ttyUSB0:/dev/ttyUSB0" +``` + ### working\_dir, entrypoint, user, hostname, domainname, mem\_limit, privileged, restart, stdin\_open, tty, cpu\_shares, cpuset, read\_only Each of these is a single value, analogous to its diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index dbb4a609c2d..08e92a57ff6 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -669,3 +669,16 @@ def test_log_drive_none(self): self.assertEqual('none', log_config['Type']) self.assertFalse(log_config['Config']) + + def test_devices(self): + service = self.create_service('web', devices=["/dev/random:/dev/mapped-random"]) + device_config = create_and_start_container(service).get('HostConfig.Devices') + + device_dict = { + 'PathOnHost': '/dev/random', + 'CgroupPermissions': 'rwm', + 'PathInContainer': '/dev/mapped-random' + } + + self.assertEqual(1, len(device_config)) + self.assertDictEqual(device_dict, device_config[0]) diff --git a/tests/unit/config_test.py b/tests/unit/config_test.py index fcd417b0651..0a48dfefe5f 100644 --- a/tests/unit/config_test.py +++ b/tests/unit/config_test.py @@ -54,46 +54,61 @@ def test_volume_binding_with_home(self): self.assertEqual(d['volumes'], ['/home/user:/container/path']) -class MergeVolumesTest(unittest.TestCase): +class MergePathMappingTest(object): + def config_name(self): + return "" + def test_empty(self): service_dict = config.merge_service_dicts({}, {}) - self.assertNotIn('volumes', service_dict) + self.assertNotIn(self.config_name(), service_dict) def test_no_override(self): service_dict = config.merge_service_dicts( - {'volumes': ['/foo:/code', '/data']}, + {self.config_name(): ['/foo:/code', '/data']}, {}, ) - self.assertEqual(set(service_dict['volumes']), set(['/foo:/code', '/data'])) + self.assertEqual(set(service_dict[self.config_name()]), set(['/foo:/code', '/data'])) def test_no_base(self): service_dict = config.merge_service_dicts( {}, - {'volumes': ['/bar:/code']}, + {self.config_name(): ['/bar:/code']}, ) - self.assertEqual(set(service_dict['volumes']), set(['/bar:/code'])) + self.assertEqual(set(service_dict[self.config_name()]), set(['/bar:/code'])) def test_override_explicit_path(self): service_dict = config.merge_service_dicts( - {'volumes': ['/foo:/code', '/data']}, - {'volumes': ['/bar:/code']}, + {self.config_name(): ['/foo:/code', '/data']}, + {self.config_name(): ['/bar:/code']}, ) - self.assertEqual(set(service_dict['volumes']), set(['/bar:/code', '/data'])) + self.assertEqual(set(service_dict[self.config_name()]), set(['/bar:/code', '/data'])) def test_add_explicit_path(self): service_dict = config.merge_service_dicts( - {'volumes': ['/foo:/code', '/data']}, - {'volumes': ['/bar:/code', '/quux:/data']}, + {self.config_name(): ['/foo:/code', '/data']}, + {self.config_name(): ['/bar:/code', '/quux:/data']}, ) - self.assertEqual(set(service_dict['volumes']), set(['/bar:/code', '/quux:/data'])) + self.assertEqual(set(service_dict[self.config_name()]), set(['/bar:/code', '/quux:/data'])) def test_remove_explicit_path(self): service_dict = config.merge_service_dicts( - {'volumes': ['/foo:/code', '/quux:/data']}, - {'volumes': ['/bar:/code', '/data']}, + {self.config_name(): ['/foo:/code', '/quux:/data']}, + {self.config_name(): ['/bar:/code', '/data']}, ) - self.assertEqual(set(service_dict['volumes']), set(['/bar:/code', '/data'])) + self.assertEqual(set(service_dict[self.config_name()]), set(['/bar:/code', '/data'])) + + +class MergeVolumesTest(unittest.TestCase, MergePathMappingTest): + def config_name(self): + return 'volumes' + + +class MergeDevicesTest(unittest.TestCase, MergePathMappingTest): + def config_name(self): + return 'devices' + +class BuildOrImageMergeTest(unittest.TestCase): def test_merge_build_or_image_no_override(self): self.assertEqual( config.merge_service_dicts({'build': '.'}, {}), From 417d9c2d51ae305742330314b124573512b54f51 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Sat, 9 May 2015 19:38:53 -0400 Subject: [PATCH 0803/4072] Use individual volumes for recreate instead of volumes_from Signed-off-by: Daniel Nephin --- compose/cli/main.py | 5 +- compose/container.py | 4 ++ compose/service.py | 86 +++++++++++++++++++++++++------ tests/integration/service_test.py | 27 +++++----- tests/unit/service_test.py | 70 ++++++++++++++++++++++--- 5 files changed, 152 insertions(+), 40 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index e941c005ce5..a2375516e99 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -317,10 +317,7 @@ def run(self, project, options): } if options['-e']: - # Merge environment from config with -e command line - container_options['environment'] = dict( - parse_environment(service.options.get('environment')), - **parse_environment(options['-e'])) + container_options['environment'] = parse_environment(options['-e']) if options['--entrypoint']: container_options['entrypoint'] = options.get('--entrypoint') diff --git a/compose/container.py b/compose/container.py index fc3370d9e44..6388ca80c4e 100644 --- a/compose/container.py +++ b/compose/container.py @@ -44,6 +44,10 @@ def id(self): def image(self): return self.dictionary['Image'] + @property + def image_config(self): + return self.client.inspect_image(self.image) + @property def short_id(self): return self.id[:10] diff --git a/compose/service.py b/compose/service.py index 20f8db0a409..08d203274ff 100644 --- a/compose/service.py +++ b/compose/service.py @@ -3,14 +3,14 @@ from collections import namedtuple import logging import re -from operator import attrgetter import sys -import six +from operator import attrgetter +import six from docker.errors import APIError from docker.utils import create_host_config, LogConfig -from .config import DOCKER_CONFIG_KEYS +from .config import DOCKER_CONFIG_KEYS, merge_environment from .container import Container, get_container_name from .progress_stream import stream_output, StreamOutputError @@ -183,10 +183,10 @@ def create_container(self, Create a container for this service. If the image doesn't exist, attempt to pull it. """ - override_options['volumes_from'] = self._get_volumes_from(previous_container) container_options = self._get_container_create_options( override_options, one_off=one_off, + previous_container=previous_container, ) if (do_build and @@ -247,6 +247,12 @@ def recreate_container(self, container, **override_options): self.client.rename( container.id, '%s_%s' % (container.short_id, container.name)) + + override_options = dict( + override_options, + environment=merge_environment( + override_options.get('environment'), + {'affinity:container': '=' + container.id})) new_container = self.create_container( do_build=False, previous_container=container, @@ -326,7 +332,7 @@ def _get_links(self, link_to_self): links.append((external_link, link_name)) return links - def _get_volumes_from(self, previous_container=None): + def _get_volumes_from(self): volumes_from = [] for volume_source in self.volumes_from: if isinstance(volume_source, Service): @@ -339,9 +345,6 @@ def _get_volumes_from(self, previous_container=None): elif isinstance(volume_source, Container): volumes_from.append(volume_source.id) - if previous_container: - volumes_from.append(previous_container.id) - return volumes_from def _get_net(self): @@ -363,7 +366,11 @@ def _get_net(self): return net - def _get_container_create_options(self, override_options, one_off=False): + def _get_container_create_options( + self, + override_options, + one_off=False, + previous_container=None): container_options = dict( (k, self.options[k]) for k in DOCKER_CONFIG_KEYS if k in self.options) @@ -396,11 +403,19 @@ def _get_container_create_options(self, override_options, one_off=False): 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( (parse_volume_spec(v).internal, {}) for v in container_options['volumes']) + container_options['environment'] = merge_environment( + self.options.get('environment'), + override_options.get('environment')) + if self.can_be_built(): container_options['image'] = self.full_name @@ -418,11 +433,6 @@ def _get_container_host_config(self, override_options, one_off=False): options = dict(self.options, **override_options) port_bindings = build_port_bindings(options.get('ports') or []) - volume_bindings = dict( - build_volume_binding(parse_volume_spec(volume)) - for volume in options.get('volumes') or [] - if ':' in volume) - privileged = options.get('privileged', False) cap_add = options.get('cap_add', None) cap_drop = options.get('cap_drop', None) @@ -447,8 +457,8 @@ def _get_container_host_config(self, override_options, one_off=False): return create_host_config( links=self._get_links(link_to_self=one_off), port_bindings=port_bindings, - binds=volume_bindings, - volumes_from=options.get('volumes_from'), + binds=options.get('binds'), + volumes_from=self._get_volumes_from(), privileged=privileged, network_mode=self._get_net(), devices=devices, @@ -531,6 +541,50 @@ def pull(self, insecure_registry=False): stream_output(output, sys.stdout) +def get_container_data_volumes(container, volumes_option): + """Find the container data volumes that are in `volumes_option`, and return + a mapping of volume bindings for those volumes. + """ + volumes = [] + + volumes_option = volumes_option or [] + container_volumes = container.get('Volumes') or {} + image_volumes = container.image_config['ContainerConfig'].get('Volumes') or {} + + for volume in set(volumes_option + image_volumes.keys()): + volume = parse_volume_spec(volume) + # No need to preserve host volumes + if volume.external: + continue + + volume_path = container_volumes.get(volume.internal) + # New volume, doesn't exist in the old container + if not volume_path: + continue + + # Copy existing volume from old container + volume = volume._replace(external=volume_path) + volumes.append(build_volume_binding(volume)) + + return dict(volumes) + + +def merge_volume_bindings(volumes_option, previous_container): + """Return a list of volume bindings for a container. Container data volumes + are replaced by those from the previous container. + """ + volume_bindings = dict( + build_volume_binding(parse_volume_spec(volume)) + for volume in volumes_option or [] + if ':' in volume) + + if previous_container: + volume_bindings.update( + get_container_data_volumes(previous_container, volumes_option)) + + return volume_bindings + + NAME_RE = re.compile(r'^([^_]+)_([^_]+)_(run_)?(\d+)$') diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 08e92a57ff6..bc21ab01869 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -107,7 +107,7 @@ def test_create_container_with_unspecified_volume(self): service = self.create_service('db', volumes=['/var/db']) container = service.create_container() service.start_container(container) - self.assertIn('/var/db', container.inspect()['Volumes']) + self.assertIn('/var/db', container.get('Volumes')) def test_create_container_with_cpu_shares(self): service = self.create_service('db', cpu_shares=73) @@ -239,24 +239,27 @@ def test_recreate_containers(self): command=['300'] ) old_container = service.create_container() - self.assertEqual(old_container.dictionary['Config']['Entrypoint'], ['sleep']) - self.assertEqual(old_container.dictionary['Config']['Cmd'], ['300']) - self.assertIn('FOO=1', old_container.dictionary['Config']['Env']) + self.assertEqual(old_container.get('Config.Entrypoint'), ['sleep']) + self.assertEqual(old_container.get('Config.Cmd'), ['300']) + self.assertIn('FOO=1', old_container.get('Config.Env')) self.assertEqual(old_container.name, 'composetest_db_1') service.start_container(old_container) - volume_path = old_container.inspect()['Volumes']['/etc'] + old_container.inspect() # reload volume data + volume_path = old_container.get('Volumes')['/etc'] num_containers_before = len(self.client.containers(all=True)) service.options['environment']['FOO'] = '2' new_container, = service.recreate_containers() - self.assertEqual(new_container.dictionary['Config']['Entrypoint'], ['sleep']) - self.assertEqual(new_container.dictionary['Config']['Cmd'], ['300']) - self.assertIn('FOO=2', new_container.dictionary['Config']['Env']) + self.assertEqual(new_container.get('Config.Entrypoint'), ['sleep']) + self.assertEqual(new_container.get('Config.Cmd'), ['300']) + self.assertIn('FOO=2', new_container.get('Config.Env')) self.assertEqual(new_container.name, 'composetest_db_1') - self.assertEqual(new_container.inspect()['Volumes']['/etc'], volume_path) - self.assertIn(old_container.id, new_container.dictionary['HostConfig']['VolumesFrom']) + self.assertEqual(new_container.get('Volumes')['/etc'], volume_path) + self.assertIn( + 'affinity:container==%s' % old_container.id, + new_container.get('Config.Env')) self.assertEqual(len(self.client.containers(all=True)), num_containers_before) self.assertNotEqual(old_container.id, new_container.id) @@ -289,9 +292,7 @@ def test_recreate_containers_with_image_declared_volume(self): self.assertEqual(old_container.get('Volumes').keys(), ['/data']) volume_path = old_container.get('Volumes')['/data'] - service.recreate_containers() - new_container = service.containers()[0] - service.start_container(new_container) + new_container = service.recreate_containers()[0] self.assertEqual(new_container.get('Volumes').keys(), ['/data']) self.assertEqual(new_container.get('Volumes')['/data'], volume_path) diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 583f72ef0b4..2ea94edbd37 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -14,7 +14,9 @@ ConfigError, build_port_bindings, build_volume_binding, + get_container_data_volumes, get_container_name, + merge_volume_bindings, parse_repository_tag, parse_volume_spec, split_port, @@ -86,13 +88,6 @@ def test_get_volumes_from_container(self): self.assertEqual(service._get_volumes_from(), [container_id]) - def test_get_volumes_from_previous_container(self): - container_id = 'aabbccddee' - service = Service('test', image='foo') - container = mock.Mock(id=container_id, spec=Container, image='foo') - - self.assertEqual(service._get_volumes_from(container), [container_id]) - def test_get_volumes_from_service_container_exists(self): container_ids = ['aabbccddee', '12345'] from_service = mock.create_autospec(Service) @@ -320,6 +315,9 @@ def test_create_container_no_build(self): class ServiceVolumesTest(unittest.TestCase): + def setUp(self): + self.mock_client = mock.create_autospec(docker.Client) + def test_parse_volume_spec_only_one_path(self): spec = parse_volume_spec('/the/volume') self.assertEqual(spec, (None, '/the/volume', 'rw')) @@ -345,3 +343,61 @@ def test_build_volume_binding(self): self.assertEqual( binding, ('/outside', dict(bind='/inside', ro=False))) + + def test_get_container_data_volumes(self): + options = [ + '/host/volume:/host/volume:ro', + '/new/volume', + '/existing/volume', + ] + + self.mock_client.inspect_image.return_value = { + 'ContainerConfig': { + 'Volumes': { + '/mnt/image/data': {}, + } + } + } + container = Container(self.mock_client, { + 'Image': 'ababab', + 'Volumes': { + '/host/volume': '/host/volume', + '/existing/volume': '/var/lib/docker/aaaaaaaa', + '/removed/volume': '/var/lib/docker/bbbbbbbb', + '/mnt/image/data': '/var/lib/docker/cccccccc', + }, + }, has_been_inspected=True) + + expected = { + '/var/lib/docker/aaaaaaaa': {'bind': '/existing/volume', 'ro': False}, + '/var/lib/docker/cccccccc': {'bind': '/mnt/image/data', 'ro': False}, + } + + binds = get_container_data_volumes(container, options) + self.assertEqual(binds, expected) + + def test_merge_volume_bindings(self): + options = [ + '/host/volume:/host/volume:ro', + '/host/rw/volume:/host/rw/volume', + '/new/volume', + '/existing/volume', + ] + + self.mock_client.inspect_image.return_value = { + 'ContainerConfig': {'Volumes': {}} + } + + intermediate_container = Container(self.mock_client, { + 'Image': 'ababab', + 'Volumes': {'/existing/volume': '/var/lib/docker/aaaaaaaa'}, + }, has_been_inspected=True) + + expected = { + '/host/volume': {'bind': '/host/volume', 'ro': True}, + '/host/rw/volume': {'bind': '/host/rw/volume', 'ro': False}, + '/var/lib/docker/aaaaaaaa': {'bind': '/existing/volume', 'ro': False}, + } + + binds = merge_volume_bindings(options, intermediate_container) + self.assertEqual(binds, expected) From 4d745ab87a1c1e3bfe9259907cbf29a025ced3e5 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Tue, 12 May 2015 12:44:43 +0100 Subject: [PATCH 0804/4072] Link to getting started guides from each page These are really hard to find. Signed-off-by: Ben Firshman --- docs/cli.md | 5 ++++- docs/completion.md | 5 ++++- docs/django.md | 5 ++++- docs/env.md | 5 ++++- docs/extends.md | 11 +++++++++++ docs/index.md | 3 +++ docs/install.md | 3 +++ docs/production.md | 12 ++++++++++++ docs/rails.md | 5 ++++- docs/wordpress.md | 5 ++++- docs/yml.md | 5 ++++- 11 files changed, 57 insertions(+), 7 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 62287f1381a..e5594871d6c 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -183,8 +183,11 @@ Configures the path to the `ca.pem`, `cert.pem`, and `key.pem` files used for TL ## Compose documentation -- [Installing Compose](install.md) - [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) - [Yaml file reference](yml.md) - [Compose environment variables](env.md) - [Compose command line completion](completion.md) diff --git a/docs/completion.md b/docs/completion.md index 96b5e8742bc..35c53b55fee 100644 --- a/docs/completion.md +++ b/docs/completion.md @@ -32,8 +32,11 @@ Enjoy working with Compose faster and with less typos! ## Compose documentation -- [Installing Compose](install.md) - [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](cli.md) - [Yaml file reference](yml.md) - [Compose environment variables](env.md) diff --git a/docs/django.md b/docs/django.md index 0605c86b691..4cbebe04158 100644 --- a/docs/django.md +++ b/docs/django.md @@ -119,8 +119,11 @@ example, run `docker-compose up` and in another terminal run: ## More Compose documentation -- [Installing Compose](install.md) - [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](cli.md) - [Yaml file reference](yml.md) - [Compose environment variables](env.md) diff --git a/docs/env.md b/docs/env.md index 3fc7b95aac5..a4b543ae370 100644 --- a/docs/env.md +++ b/docs/env.md @@ -34,8 +34,11 @@ Fully qualified container name, e.g. `DB_1_NAME=/myapp_web_1/myapp_db_1` ## Compose documentation -- [Installing Compose](install.md) - [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](cli.md) - [Yaml file reference](yml.md) - [Compose command line completion](completion.md) diff --git a/docs/extends.md b/docs/extends.md index a4768b8f5cc..84fd1609f21 100644 --- a/docs/extends.md +++ b/docs/extends.md @@ -362,3 +362,14 @@ volumes: - /local-dir/bar:/bar - /local-dir/baz/:baz ``` + +## 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](cli.md) +- [Yaml file reference](yml.md) +- [Compose command line completion](completion.md) diff --git a/docs/index.md b/docs/index.md index b3190fca4ce..44f56ae969d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -48,6 +48,9 @@ Compose has commands for managing the whole lifecycle of your application: ## 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](cli.md) - [Yaml file reference](yml.md) - [Compose environment variables](env.md) diff --git a/docs/install.md b/docs/install.md index a3524c603c5..a521ec06cde 100644 --- a/docs/install.md +++ b/docs/install.md @@ -39,6 +39,9 @@ You can test the installation by running `docker-compose --version`. ## Compose documentation - [User guide](index.md) +- [Get started with Django](django.md) +- [Get started with Rails](rails.md) +- [Get started with Wordpress](wordpress.md) - [Command line reference](cli.md) - [Yaml file reference](yml.md) - [Compose environment variables](env.md) diff --git a/docs/production.md b/docs/production.md index 8524c99b813..60a6873daf2 100644 --- a/docs/production.md +++ b/docs/production.md @@ -75,3 +75,15 @@ 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](https://github.com/docker/compose/blob/master/SWARM.md). + +## 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](cli.md) +- [Yaml file reference](yml.md) +- [Compose environment variables](env.md) +- [Compose command line completion](completion.md) + diff --git a/docs/rails.md b/docs/rails.md index 0671d0624a4..aedb4c6e767 100644 --- a/docs/rails.md +++ b/docs/rails.md @@ -119,8 +119,11 @@ you're using Boot2docker, `boot2docker ip` will tell you its address). ## More Compose documentation -- [Installing Compose](install.md) - [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](cli.md) - [Yaml file reference](yml.md) - [Compose environment variables](env.md) diff --git a/docs/wordpress.md b/docs/wordpress.md index 5a9c37a8d3e..b40d1a9f084 100644 --- a/docs/wordpress.md +++ b/docs/wordpress.md @@ -114,8 +114,11 @@ address). ## More Compose documentation -- [Installing Compose](install.md) - [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](cli.md) - [Yaml file reference](yml.md) - [Compose environment variables](env.md) diff --git a/docs/yml.md b/docs/yml.md index 0b8d4313b2b..41247c70348 100644 --- a/docs/yml.md +++ b/docs/yml.md @@ -380,8 +380,11 @@ read_only: true ## Compose documentation -- [Installing Compose](install.md) - [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](cli.md) - [Compose environment variables](env.md) - [Compose command line completion](completion.md) From 1dccd58209a61b4001a7034c5e0b7472fc6878f9 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 12 May 2015 18:51:45 +0100 Subject: [PATCH 0805/4072] Update docker-py to 1.2.2 Signed-off-by: Aanand Prasad --- requirements.txt | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index ed09cccac0e..43907e1dcc2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ PyYAML==3.10 -docker-py==1.2.1 +docker-py==1.2.2 dockerpty==0.3.2 docopt==0.6.1 requests==2.6.1 diff --git a/setup.py b/setup.py index 46193eeefc7..5668cf13801 100644 --- a/setup.py +++ b/setup.py @@ -30,7 +30,7 @@ def find_version(*file_paths): 'requests >= 2.6.1, < 2.7', 'texttable >= 0.8.1, < 0.9', 'websocket-client >= 0.11.0, < 1.0', - 'docker-py >= 1.2.0, < 1.3', + 'docker-py >= 1.2.2, < 1.3', 'dockerpty >= 0.3.2, < 0.4', 'six >= 1.3.0, < 2', ] From ad9c5ad938254aa742900b3e80515fa7ebb52ae1 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 14 May 2015 10:48:35 +0100 Subject: [PATCH 0806/4072] Fix typo in extends.md Signed-off-by: Aanand Prasad --- docs/extends.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/extends.md b/docs/extends.md index 84fd1609f21..fd372ce2d55 100644 --- a/docs/extends.md +++ b/docs/extends.md @@ -145,8 +145,7 @@ Defining the web application requires the following: FROM python:2.7 ADD . /code WORKDIR /code - RUN pip install -r - requirements.txt + RUN pip install -r requirements.txt CMD python app.py 4. Create a Compose configuration file called `common.yml`: From 9bbf1a33d103bddbadbfe174cd4095b758e3d18a Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 14 May 2015 19:59:52 +0100 Subject: [PATCH 0807/4072] Update dockerpty to 0.3.3 Signed-off-by: Aanand Prasad --- requirements.txt | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 43907e1dcc2..b939884801e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ PyYAML==3.10 docker-py==1.2.2 -dockerpty==0.3.2 +dockerpty==0.3.3 docopt==0.6.1 requests==2.6.1 six==1.7.3 diff --git a/setup.py b/setup.py index 5668cf13801..153275f69de 100644 --- a/setup.py +++ b/setup.py @@ -31,7 +31,7 @@ def find_version(*file_paths): 'texttable >= 0.8.1, < 0.9', 'websocket-client >= 0.11.0, < 1.0', 'docker-py >= 1.2.2, < 1.3', - 'dockerpty >= 0.3.2, < 0.4', + 'dockerpty >= 0.3.3, < 0.4', 'six >= 1.3.0, < 2', ] From 862971cffa9db04f597acf0cd01e55b66681579e Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 15 May 2015 12:16:24 +0100 Subject: [PATCH 0808/4072] Fix race condition in `docker-compose run` We shouldn't start the container before handing it off to dockerpty - dockerpty will start it after attaching, which is the correct order. Otherwise the container might exit before we attach to it, which can lead to weird bugs. Signed-off-by: Aanand Prasad --- compose/cli/main.py | 1 - 1 file changed, 1 deletion(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index a2375516e99..19e4e9378db 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -341,7 +341,6 @@ def run(self, project, options): service.start_container(container) print(container.name) else: - service.start_container(container) dockerpty.start(project.client, container.id, interactive=not options['-T']) exit_code = container.wait() if options['--rm']: From 28d2aff8b8f65666d4659b9a25671228396dc7dc Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Sun, 26 Apr 2015 21:21:55 -0400 Subject: [PATCH 0809/4072] Fix teardown for integration tests. Signed-off-by: Daniel Nephin --- tests/integration/cli_test.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/integration/cli_test.py b/tests/integration/cli_test.py index c7e2ea3438d..92789363e41 100644 --- a/tests/integration/cli_test.py +++ b/tests/integration/cli_test.py @@ -21,6 +21,8 @@ def tearDown(self): sys.exit = self.old_sys_exit self.project.kill() self.project.remove_stopped() + for container in self.project.containers(stopped=True, one_off=True): + container.remove(force=True) @property def project(self): @@ -62,6 +64,10 @@ def test_ps_default_composefile(self, mock_stdout): @patch('sys.stdout', new_callable=StringIO) def test_ps_alternate_composefile(self, mock_stdout): + config_path = os.path.abspath( + 'tests/fixtures/multiple-composefiles/compose2.yml') + self._project = self.command.get_project(config_path) + self.command.base_dir = 'tests/fixtures/multiple-composefiles' self.command.dispatch(['-f', 'compose2.yml', 'up', '-d'], None) self.command.dispatch(['-f', 'compose2.yml', 'ps'], None) @@ -416,7 +422,6 @@ def test_scale(self): self.assertEqual(len(project.get_service('another').containers()), 0) def test_port(self): - self.command.base_dir = 'tests/fixtures/ports-composefile' self.command.dispatch(['up', '-d'], None) container = self.project.get_service('simple').get_container() From ed50a0a3a02821f5a6e50fc46db864e386258921 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Sun, 26 Apr 2015 17:09:20 -0400 Subject: [PATCH 0810/4072] Resolves #1066, use labels to identify containers Signed-off-by: Daniel Nephin --- compose/__init__.py | 1 - compose/const.py | 6 ++ compose/container.py | 10 +-- compose/project.py | 13 +++- compose/service.py | 100 ++++++++++++++++-------------- tests/integration/service_test.py | 33 ++++++---- tests/integration/testcases.py | 1 + tests/unit/container_test.py | 29 ++++++--- tests/unit/service_test.py | 49 ++++++--------- 9 files changed, 139 insertions(+), 103 deletions(-) create mode 100644 compose/const.py diff --git a/compose/__init__.py b/compose/__init__.py index 2de2a7f8b85..045e791449f 100644 --- a/compose/__init__.py +++ b/compose/__init__.py @@ -1,4 +1,3 @@ from __future__ import unicode_literals -from .service import Service # noqa:flake8 __version__ = '1.3.0dev' diff --git a/compose/const.py b/compose/const.py new file mode 100644 index 00000000000..0714a6dbf60 --- /dev/null +++ b/compose/const.py @@ -0,0 +1,6 @@ + +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_VERSION = 'com.docker.compose.version' diff --git a/compose/container.py b/compose/container.py index 6388ca80c4e..183d5fde895 100644 --- a/compose/container.py +++ b/compose/container.py @@ -4,6 +4,8 @@ import six from functools import reduce +from .const import LABEL_CONTAINER_NUMBER, LABEL_SERVICE + class Container(object): """ @@ -58,14 +60,11 @@ def name(self): @property def name_without_project(self): - return '_'.join(self.dictionary['Name'].split('_')[1:]) + return '{0}_{1}'.format(self.labels.get(LABEL_SERVICE), self.number) @property def number(self): - try: - return int(self.name.split('_')[-1]) - except ValueError: - return None + return int(self.labels.get(LABEL_CONTAINER_NUMBER) or 0) @property def ports(self): @@ -159,6 +158,7 @@ def inspect(self): self.has_been_inspected = True return self.dictionary + # TODO: only used by tests, move to test module def links(self): links = [] for container in self.client.containers(): diff --git a/compose/project.py b/compose/project.py index 2f3675ffbae..d22bdf4dc8a 100644 --- a/compose/project.py +++ b/compose/project.py @@ -4,6 +4,7 @@ from functools import reduce from .config import get_service_name_from_net, ConfigurationError +from .const import LABEL_PROJECT, LABEL_ONE_OFF from .service import Service from .container import Container from docker.errors import APIError @@ -60,6 +61,12 @@ def __init__(self, name, services, client): self.services = services self.client = client + 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"), + ] + @classmethod def from_dicts(cls, name, service_dicts, client): """ @@ -224,9 +231,9 @@ def remove_stopped(self, service_names=None, **options): def containers(self, service_names=None, stopped=False, one_off=False): return [Container.from_ps(self.client, container) - for container in self.client.containers(all=stopped) - for service in self.get_services(service_names) - if service.has_container(container, one_off=one_off)] + for container in self.client.containers( + all=stopped, + filters={'label': self.labels(one_off=one_off)})] def _inject_deps(self, acc, service): net_name = service.get_net_name() diff --git a/compose/service.py b/compose/service.py index 08d203274ff..3c62dbebb83 100644 --- a/compose/service.py +++ b/compose/service.py @@ -10,8 +10,16 @@ from docker.errors import APIError from docker.utils import create_host_config, LogConfig +from . import __version__ from .config import DOCKER_CONFIG_KEYS, merge_environment -from .container import Container, get_container_name +from .const import ( + LABEL_CONTAINER_NUMBER, + LABEL_ONE_OFF, + LABEL_PROJECT, + LABEL_SERVICE, + LABEL_VERSION, +) +from .container import Container from .progress_stream import stream_output, StreamOutputError log = logging.getLogger(__name__) @@ -79,27 +87,17 @@ def __init__(self, name, client=None, project='default', links=None, external_li def containers(self, stopped=False, one_off=False): return [Container.from_ps(self.client, container) - for container in self.client.containers(all=stopped) - if self.has_container(container, one_off=one_off)] - - def has_container(self, container, one_off=False): - """Return True if `container` was created to fulfill this service.""" - name = get_container_name(container) - if not name or not is_valid_name(name, one_off): - return False - project, name, _number = parse_name(name) - return project == self.project and name == self.name + for container in self.client.containers( + all=stopped, + filters={'label': self.labels(one_off=one_off)})] def get_container(self, number=1): """Return a :class:`compose.container.Container` for this service. The container must be active, and match `number`. """ - for container in self.client.containers(): - if not self.has_container(container): - continue - _, _, container_number = parse_name(get_container_name(container)) - if container_number == number: - return Container.from_ps(self.client, container) + labels = self.labels() + ['{0}={1}'.format(LABEL_CONTAINER_NUMBER, number)] + for container in self.client.containers(filters={'label': labels}): + return Container.from_ps(self.client, container) raise ValueError("No container found for %s_%s" % (self.name, number)) @@ -138,7 +136,6 @@ def scale(self, desired_num): # Create enough containers containers = self.containers(stopped=True) while len(containers) < desired_num: - log.info("Creating %s..." % self._next_container_name(containers)) containers.append(self.create_container(detach=True)) running_containers = [] @@ -178,6 +175,7 @@ def create_container(self, insecure_registry=False, do_build=True, previous_container=None, + number=None, **override_options): """ Create a container for this service. If the image doesn't exist, attempt to pull @@ -185,6 +183,7 @@ def create_container(self, """ container_options = self._get_container_create_options( override_options, + number or self._next_container_number(one_off=one_off), one_off=one_off, previous_container=previous_container, ) @@ -209,7 +208,6 @@ def recreate_containers(self, insecure_registry=False, do_build=True, **override """ containers = self.containers(stopped=True) if not containers: - log.info("Creating %s..." % self._next_container_name(containers)) container = self.create_container( insecure_registry=insecure_registry, do_build=do_build, @@ -256,6 +254,7 @@ def recreate_container(self, container, **override_options): new_container = self.create_container( do_build=False, previous_container=container, + number=container.labels.get(LABEL_CONTAINER_NUMBER), **override_options) self.start_container(new_container) container.remove() @@ -280,7 +279,6 @@ def start_or_create_containers( containers = self.containers(stopped=True) if not containers: - log.info("Creating %s..." % self._next_container_name(containers)) new_container = self.create_container( insecure_registry=insecure_registry, detach=detach, @@ -302,14 +300,19 @@ def get_net_name(self): else: return - def _next_container_name(self, all_containers, one_off=False): - bits = [self.project, self.name] - if one_off: - bits.append('run') - return '_'.join(bits + [str(self._next_container_number(all_containers))]) - - def _next_container_number(self, all_containers): - numbers = [parse_name(c.name).number for c in all_containers] + 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/11943 + # to remove the need to inspect every container + def _next_container_number(self, one_off=False): + numbers = [ + Container.from_ps(self.client, container).number + for container in self.client.containers( + all=True, + filters={'label': self.labels(one_off=one_off)}) + ] return 1 if not numbers else max(numbers) + 1 def _get_links(self, link_to_self): @@ -369,6 +372,7 @@ def _get_net(self): def _get_container_create_options( self, override_options, + number, one_off=False, previous_container=None): container_options = dict( @@ -376,9 +380,7 @@ def _get_container_create_options( for k in DOCKER_CONFIG_KEYS if k in self.options) container_options.update(override_options) - container_options['name'] = self._next_container_name( - self.containers(stopped=True, one_off=one_off), - one_off) + container_options['name'] = self.get_container_name(number, one_off) # If a qualified hostname was given, split it into an # unqualified hostname and a domainname unless domainname @@ -419,6 +421,11 @@ def _get_container_create_options( if self.can_be_built(): container_options['image'] = self.full_name + container_options['labels'] = build_container_labels( + container_options.get('labels', {}), + self.labels(one_off=one_off), + number) + # Delete options which are only used when starting for key in DOCKER_START_KEYS: container_options.pop(key, None) @@ -520,6 +527,13 @@ def full_name(self): """ return '%s_%s' % (self.project, self.name) + def labels(self, one_off=False): + return [ + '{0}={1}'.format(LABEL_PROJECT, self.project), + '{0}={1}'.format(LABEL_SERVICE, self.name), + '{0}={1}'.format(LABEL_ONE_OFF, "True" if one_off else "False") + ] + def can_be_scaled(self): for port in self.options.get('ports', []): if ':' in str(port): @@ -585,23 +599,19 @@ def merge_volume_bindings(volumes_option, previous_container): return volume_bindings -NAME_RE = re.compile(r'^([^_]+)_([^_]+)_(run_)?(\d+)$') - - -def is_valid_name(name, one_off=False): - match = NAME_RE.match(name) - if match is None: - return False +def build_container_name(project, service, number, one_off=False): + bits = [project, service] if one_off: - return match.group(3) == 'run_' - else: - return match.group(3) is None + bits.append('run') + return '_'.join(bits + [str(number)]) -def parse_name(name): - match = NAME_RE.match(name) - (project, service_name, _, suffix) = match.groups() - return ServiceName(project, service_name, int(suffix)) +def build_container_labels(label_options, service_labels, number, one_off=False): + labels = label_options or {} + labels.update(label.split('=', 1) for label in service_labels) + labels[LABEL_CONTAINER_NUMBER] = str(number) + labels[LABEL_VERSION] = __version__ + return labels def parse_restart_spec(restart_config): diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index bc21ab01869..47c826ec5b6 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -8,11 +8,19 @@ import shutil import six -from compose import Service +from compose import __version__ +from compose.const import ( + LABEL_CONTAINER_NUMBER, + LABEL_ONE_OFF, + LABEL_PROJECT, + LABEL_SERVICE, + LABEL_VERSION, +) from compose.service import ( CannotBeScaledError, - build_extra_hosts, ConfigError, + Service, + build_extra_hosts, ) from compose.container import Container from docker.errors import APIError @@ -633,17 +641,18 @@ def test_labels(self): 'com.example.label-with-empty-value': "", } - service = self.create_service('web', labels=labels_dict) - labels = create_and_start_container(service).labels.items() - for pair in labels_dict.items(): - self.assertIn(pair, labels) - - labels_list = ["%s=%s" % pair for pair in labels_dict.items()] + compose_labels = { + LABEL_CONTAINER_NUMBER: '1', + LABEL_ONE_OFF: 'False', + LABEL_PROJECT: 'composetest', + LABEL_SERVICE: 'web', + LABEL_VERSION: __version__, + } + expected = dict(labels_dict, **compose_labels) - service = self.create_service('web', labels=labels_list) - labels = create_and_start_container(service).labels.items() - for pair in labels_dict.items(): - self.assertIn(pair, labels) + service = self.create_service('web', labels=labels_dict) + labels = create_and_start_container(service).labels + self.assertEqual(labels, expected) def test_empty_labels(self): labels_list = ['foo', 'bar'] diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index 31281a1d71e..4a0f7248a72 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -12,6 +12,7 @@ class DockerClientTestCase(unittest.TestCase): def setUpClass(cls): cls.client = docker_client() + # TODO: update to use labels in #652 def setUp(self): for c in self.client.containers(all=True): if c['Names'] and 'composetest' in c['Names'][0]: diff --git a/tests/unit/container_test.py b/tests/unit/container_test.py index 7637adf58b0..b04df6592ed 100644 --- a/tests/unit/container_test.py +++ b/tests/unit/container_test.py @@ -5,6 +5,7 @@ import docker from compose.container import Container +from compose.container import get_container_name class ContainerTest(unittest.TestCase): @@ -23,6 +24,13 @@ def setUp(self): "NetworkSettings": { "Ports": {}, }, + "Config": { + "Labels": { + "com.docker.compose.project": "composetest", + "com.docker.compose.service": "web", + "com.docker.compose.container_number": 7, + }, + } } def test_from_ps(self): @@ -65,10 +73,8 @@ def test_environment(self): }) def test_number(self): - container = Container.from_ps(None, - self.container_dict, - has_been_inspected=True) - self.assertEqual(container.number, 1) + container = Container(None, self.container_dict, has_been_inspected=True) + self.assertEqual(container.number, 7) def test_name(self): container = Container.from_ps(None, @@ -77,10 +83,8 @@ def test_name(self): self.assertEqual(container.name, "composetest_db_1") def test_name_without_project(self): - container = Container.from_ps(None, - self.container_dict, - has_been_inspected=True) - self.assertEqual(container.name_without_project, "db_1") + container = Container(None, self.container_dict, has_been_inspected=True) + self.assertEqual(container.name_without_project, "web_7") def test_inspect_if_not_inspected(self): mock_client = mock.create_autospec(docker.Client) @@ -130,3 +134,12 @@ def test_get(self): self.assertEqual(container.get('Status'), "Up 8 seconds") self.assertEqual(container.get('HostConfig.VolumesFrom'), ["volume_id"]) self.assertEqual(container.get('Foo.Bar.DoesNotExist'), None) + + +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': ['/swarm-host-1/myproject_db_1', '/swarm-host-1/myproject_web_1/db']}), 'myproject_db_1') diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 2ea94edbd37..fa252062c5a 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -7,15 +7,15 @@ import docker from requests import Response -from compose import Service +from compose.service import Service from compose.container import Container +from compose.const import LABEL_SERVICE, LABEL_PROJECT, LABEL_ONE_OFF from compose.service import ( APIError, ConfigError, build_port_bindings, build_volume_binding, get_container_data_volumes, - get_container_name, merge_volume_bindings, parse_repository_tag, parse_volume_spec, @@ -48,36 +48,27 @@ def test_project_validation(self): self.assertRaises(ConfigError, lambda: Service(name='foo', project='_', image='foo')) Service(name='foo', project='bar', image='foo') - 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': ['/swarm-host-1/myproject_db_1', '/swarm-host-1/myproject_web_1/db']}), 'myproject_db_1') - def test_containers(self): - service = Service('db', client=self.mock_client, image='foo', project='myproject') - + service = Service('db', self.mock_client, 'myproject', image='foo') self.mock_client.containers.return_value = [] self.assertEqual(service.containers(), []) + def test_containers_with_containers(self): self.mock_client.containers.return_value = [ - {'Image': 'busybox', 'Id': 'OUT_1', 'Names': ['/myproject', '/foo/bar']}, - {'Image': 'busybox', 'Id': 'OUT_2', 'Names': ['/myproject_db']}, - {'Image': 'busybox', 'Id': 'OUT_3', 'Names': ['/db_1']}, - {'Image': 'busybox', 'Id': 'IN_1', 'Names': ['/myproject_db_1', '/myproject_web_1/db']}, + dict(Name=str(i), Image='foo', Id=i) for i in range(3) ] - self.assertEqual([c.id for c in service.containers()], ['IN_1']) - - def test_containers_prefixed(self): - service = Service('db', client=self.mock_client, image='foo', project='myproject') + service = Service('db', self.mock_client, 'myproject', image='foo') + self.assertEqual([c.id for c in service.containers()], range(3)) - self.mock_client.containers.return_value = [ - {'Image': 'busybox', 'Id': 'OUT_1', 'Names': ['/swarm-host-1/myproject', '/swarm-host-1/foo/bar']}, - {'Image': 'busybox', 'Id': 'OUT_2', 'Names': ['/swarm-host-1/myproject_db']}, - {'Image': 'busybox', 'Id': 'OUT_3', 'Names': ['/swarm-host-1/db_1']}, - {'Image': 'busybox', 'Id': 'IN_1', 'Names': ['/swarm-host-1/myproject_db_1', '/swarm-host-1/myproject_web_1/db']}, + expected_labels = [ + '{0}=myproject'.format(LABEL_PROJECT), + '{0}=db'.format(LABEL_SERVICE), + '{0}=False'.format(LABEL_ONE_OFF), ] - self.assertEqual([c.id for c in service.containers()], ['IN_1']) + + self.mock_client.containers.assert_called_once_with( + all=False, + filters={'label': expected_labels}) def test_get_volumes_from_container(self): container_id = 'aabbccddee' @@ -156,7 +147,7 @@ def test_build_port_bindings_with_nonmatching_internal_ports(self): def test_split_domainname_none(self): service = Service('foo', image='foo', hostname='name', client=self.mock_client) self.mock_client.containers.return_value = [] - opts = service._get_container_create_options({'image': 'foo'}) + opts = service._get_container_create_options({'image': 'foo'}, 1) self.assertEqual(opts['hostname'], 'name', 'hostname') self.assertFalse('domainname' in opts, 'domainname') @@ -167,7 +158,7 @@ def test_split_domainname_fqdn(self): image='foo', client=self.mock_client) self.mock_client.containers.return_value = [] - opts = service._get_container_create_options({'image': 'foo'}) + opts = service._get_container_create_options({'image': 'foo'}, 1) self.assertEqual(opts['hostname'], 'name', 'hostname') self.assertEqual(opts['domainname'], 'domain.tld', 'domainname') @@ -179,7 +170,7 @@ def test_split_domainname_both(self): domainname='domain.tld', client=self.mock_client) self.mock_client.containers.return_value = [] - opts = service._get_container_create_options({'image': 'foo'}) + opts = service._get_container_create_options({'image': 'foo'}, 1) self.assertEqual(opts['hostname'], 'name', 'hostname') self.assertEqual(opts['domainname'], 'domain.tld', 'domainname') @@ -191,7 +182,7 @@ def test_split_domainname_weird(self): image='foo', client=self.mock_client) self.mock_client.containers.return_value = [] - opts = service._get_container_create_options({'image': 'foo'}) + opts = service._get_container_create_options({'image': 'foo'}, 1) self.assertEqual(opts['hostname'], 'name.sub', 'hostname') self.assertEqual(opts['domainname'], 'domain.tld', 'domainname') @@ -255,7 +246,7 @@ def test_create_container_from_insecure_registry( tag='sometag', insecure_registry=True, stream=True) - mock_log.info.assert_called_once_with( + mock_log.info.assert_called_with( 'Pulling foo (someimage:sometag)...') @mock.patch('compose.service.Container', autospec=True) From 62059d55e60e01d395fb8a5e99cda364ce517b49 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Sat, 9 May 2015 12:53:59 -0400 Subject: [PATCH 0811/4072] Add migration warning and option to migrate to labels. Signed-off-by: Daniel Nephin --- compose/cli/main.py | 33 ++++++++++++--------- compose/container.py | 6 +++- compose/migration.py | 35 ++++++++++++++++++++++ compose/project.py | 32 +++++++++++++++----- compose/service.py | 46 +++++++++++++++++++++++++---- tests/integration/migration_test.py | 23 +++++++++++++++ tests/unit/container_test.py | 2 +- 7 files changed, 148 insertions(+), 29 deletions(-) create mode 100644 compose/migration.py create mode 100644 tests/integration/migration_test.py diff --git a/compose/cli/main.py b/compose/cli/main.py index a2375516e99..0c5d5a7f640 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -11,6 +11,7 @@ import dockerpty from .. import __version__ +from .. import migration from ..project import NoSuchService, ConfigurationError from ..service import BuildError, CannotBeScaledError from ..config import parse_environment @@ -81,20 +82,21 @@ class TopLevelCommand(Command): -v, --version Print version and exit Commands: - build Build or rebuild services - help Get help on a command - kill Kill containers - logs View output from containers - 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 - up Create and start containers + build Build or rebuild services + help Get help on a command + kill Kill containers + logs View output from containers + 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 + up Create and start containers + migrate_to_labels Recreate containers to add labels """ def docopt_options(self): @@ -483,6 +485,9 @@ def handler(signal, frame): params = {} if timeout is None else {'timeout': int(timeout)} project.stop(service_names=service_names, **params) + def migrate_to_labels(self, project, _options): + migration.migrate_project_to_labels(project) + def list_containers(containers): return ", ".join(c.name for c in containers) diff --git a/compose/container.py b/compose/container.py index 183d5fde895..3e462088fed 100644 --- a/compose/container.py +++ b/compose/container.py @@ -64,7 +64,11 @@ def name_without_project(self): @property def number(self): - return int(self.labels.get(LABEL_CONTAINER_NUMBER) or 0) + number = self.labels.get(LABEL_CONTAINER_NUMBER) + if not number: + raise ValueError("Container {0} does not have a {1} label".format( + self.short_id, LABEL_CONTAINER_NUMBER)) + return int(number) @property def ports(self): diff --git a/compose/migration.py b/compose/migration.py new file mode 100644 index 00000000000..16b5dd1678e --- /dev/null +++ b/compose/migration.py @@ -0,0 +1,35 @@ +import logging +import re + +from .container import get_container_name, Container + + +log = logging.getLogger(__name__) + + +# TODO: remove this section when migrate_project_to_labels is removed +NAME_RE = re.compile(r'^([^_]+)_([^_]+)_(run_)?(\d+)$') + + +def is_valid_name(name): + match = NAME_RE.match(name) + return match is not None + + +def add_labels(project, container, name): + project_name, service_name, one_off, number = NAME_RE.match(name).groups() + if project_name != project.name or service_name not in project.service_names: + return + service = project.get_service(service_name) + service.recreate_container(container) + + +def migrate_project_to_labels(project): + log.info("Running migration to labels for project %s", project.name) + + client = project.client + for container in client.containers(all=True): + name = get_container_name(container) + if not is_valid_name(name): + continue + add_labels(project, Container.from_ps(client, container), name) diff --git a/compose/project.py b/compose/project.py index d22bdf4dc8a..8ca144813c3 100644 --- a/compose/project.py +++ b/compose/project.py @@ -1,13 +1,14 @@ from __future__ import unicode_literals from __future__ import absolute_import import logging - from functools import reduce + +from docker.errors import APIError + from .config import get_service_name_from_net, ConfigurationError from .const import LABEL_PROJECT, LABEL_ONE_OFF -from .service import Service +from .service import Service, check_for_legacy_containers from .container import Container -from docker.errors import APIError log = logging.getLogger(__name__) @@ -82,6 +83,10 @@ def from_dicts(cls, name, service_dicts, client): volumes_from=volumes_from, **service_dict)) return project + @property + def service_names(self): + return [service.name for service in self.services] + def get_service(self, name): """ Retrieve a service by name. Raises NoSuchService @@ -109,7 +114,7 @@ def get_services(self, service_names=None, include_deps=False): """ if service_names is None or len(service_names) == 0: return self.get_services( - service_names=[s.name for s in self.services], + service_names=self.service_names, include_deps=include_deps ) else: @@ -230,10 +235,21 @@ def remove_stopped(self, service_names=None, **options): service.remove_stopped(**options) def containers(self, service_names=None, stopped=False, one_off=False): - return [Container.from_ps(self.client, container) - for container in self.client.containers( - all=stopped, - filters={'label': self.labels(one_off=one_off)})] + containers = [ + Container.from_ps(self.client, container) + for container in self.client.containers( + all=stopped, + filters={'label': self.labels(one_off=one_off)})] + + if not containers: + check_for_legacy_containers( + self.client, + self.name, + self.service_names, + stopped=stopped, + one_off=one_off) + + return containers def _inject_deps(self, acc, service): net_name = service.get_net_name() diff --git a/compose/service.py b/compose/service.py index 3c62dbebb83..dc34a9bc25d 100644 --- a/compose/service.py +++ b/compose/service.py @@ -19,7 +19,7 @@ LABEL_SERVICE, LABEL_VERSION, ) -from .container import Container +from .container import Container, get_container_name from .progress_stream import stream_output, StreamOutputError log = logging.getLogger(__name__) @@ -86,10 +86,21 @@ def __init__(self, name, client=None, project='default', links=None, external_li self.options = options def containers(self, stopped=False, one_off=False): - return [Container.from_ps(self.client, container) - for container in self.client.containers( - all=stopped, - filters={'label': self.labels(one_off=one_off)})] + containers = [ + Container.from_ps(self.client, container) + for container in self.client.containers( + all=stopped, + filters={'label': self.labels(one_off=one_off)})] + + if not containers: + check_for_legacy_containers( + self.client, + self.project, + [self.name], + stopped=stopped, + one_off=one_off) + + return containers def get_container(self, number=1): """Return a :class:`compose.container.Container` for this service. The @@ -614,6 +625,31 @@ def build_container_labels(label_options, service_labels, number, one_off=False) return labels +def check_for_legacy_containers( + client, + project, + services, + stopped=False, + one_off=False): + """Check if there are containers named using the old naming convention + and warn the user that those containers may need to be migrated to + using labels, so that compose can find them. + """ + for container in client.containers(all=stopped): + name = get_container_name(container) + for service in services: + prefix = '%s_%s_%s' % (project, service, 'run_' if one_off else '') + if not name.startswith(prefix): + continue + + log.warn( + "Compose found a found a container named %s without any " + "labels. As of compose 1.3.0 containers are identified with " + "labels instead of naming convention. If you'd like compose " + "to use this container, please run " + "`docker-compose --migrate-to-labels`" % (name,)) + + def parse_restart_spec(restart_config): if not restart_config: return None diff --git a/tests/integration/migration_test.py b/tests/integration/migration_test.py new file mode 100644 index 00000000000..133d231481c --- /dev/null +++ b/tests/integration/migration_test.py @@ -0,0 +1,23 @@ +import mock + +from compose import service, migration +from compose.project import Project +from .testcases import DockerClientTestCase + + +class ProjectTest(DockerClientTestCase): + + def test_migration_to_labels(self): + web = self.create_service('web') + db = self.create_service('db') + project = Project('composetest', [web, db], self.client) + + self.client.create_container(name='composetest_web_1', **web.options) + self.client.create_container(name='composetest_db_1', **db.options) + + with mock.patch.object(service, 'log', autospec=True) as mock_log: + self.assertEqual(project.containers(stopped=True), []) + self.assertEqual(mock_log.warn.call_count, 2) + + migration.migrate_project_to_labels(project) + self.assertEqual(len(project.containers(stopped=True)), 2) diff --git a/tests/unit/container_test.py b/tests/unit/container_test.py index b04df6592ed..2313d4b8ef3 100644 --- a/tests/unit/container_test.py +++ b/tests/unit/container_test.py @@ -28,7 +28,7 @@ def setUp(self): "Labels": { "com.docker.compose.project": "composetest", "com.docker.compose.service": "web", - "com.docker.compose.container_number": 7, + "com.docker.compose.container-number": 7, }, } } From 3304c68891a16ad5d3972a95688f2c4ed426d9c6 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 12 May 2015 11:11:36 +0100 Subject: [PATCH 0812/4072] Only set AttachStdin/out/err for one-off containers If we're just streaming logs from `docker-compose up`, we don't need to set AttachStdin/out/err, and doing so results in containers with different configuration depending on whether `up` or `run` were invoked with `-d` or not. Signed-off-by: Aanand Prasad --- compose/cli/main.py | 2 -- compose/project.py | 2 -- compose/service.py | 7 ++++--- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 8dae737b8be..9379c79e58f 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -299,7 +299,6 @@ def run(self, project, options): start_deps=True, recreate=False, insecure_registry=insecure_registry, - detach=options['-d'] ) tty = True @@ -461,7 +460,6 @@ def up(self, project, options): start_deps=start_deps, recreate=recreate, insecure_registry=insecure_registry, - detach=detached, do_build=not options['--no-build'], ) diff --git a/compose/project.py b/compose/project.py index 8ca144813c3..41cd14e0d6b 100644 --- a/compose/project.py +++ b/compose/project.py @@ -209,7 +209,6 @@ def up(self, start_deps=True, recreate=True, insecure_registry=False, - detach=False, do_build=True): running_containers = [] for service in self.get_services(service_names, include_deps=start_deps): @@ -220,7 +219,6 @@ def up(self, for container in create_func( insecure_registry=insecure_registry, - detach=detach, do_build=do_build): running_containers.append(container) diff --git a/compose/service.py b/compose/service.py index dc34a9bc25d..001d36b4124 100644 --- a/compose/service.py +++ b/compose/service.py @@ -147,7 +147,7 @@ def scale(self, desired_num): # Create enough containers containers = self.containers(stopped=True) while len(containers) < desired_num: - containers.append(self.create_container(detach=True)) + containers.append(self.create_container()) running_containers = [] stopped_containers = [] @@ -285,14 +285,12 @@ def start_container(self, container): def start_or_create_containers( self, insecure_registry=False, - detach=False, do_build=True): containers = self.containers(stopped=True) if not containers: new_container = self.create_container( insecure_registry=insecure_registry, - detach=detach, do_build=do_build, ) return [self.start_container(new_container)] @@ -393,6 +391,9 @@ def _get_container_create_options( container_options['name'] = self.get_container_name(number, one_off) + if 'detach' not in container_options: + container_options['detach'] = True + # If a qualified hostname was given, split it into an # unqualified hostname and a domainname unless domainname # was also given explicitly. This matches the behavior of From 82bc7cd5ba84bcb6cc14ba9f8c8775dbfb8fa474 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 12 May 2015 14:35:44 +0100 Subject: [PATCH 0813/4072] Remove override_options arg from recreate_container(s) Signed-off-by: Aanand Prasad --- compose/service.py | 26 ++++++++++---------------- 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/compose/service.py b/compose/service.py index 001d36b4124..306b81833e5 100644 --- a/compose/service.py +++ b/compose/service.py @@ -212,7 +212,7 @@ def create_container(self, return Container.create(self.client, **container_options) raise - def recreate_containers(self, insecure_registry=False, do_build=True, **override_options): + def recreate_containers(self, insecure_registry=False, do_build=True): """ If a container for this service doesn't exist, create and start one. If there are any, stop them, create+start new ones, and remove the old containers. @@ -221,20 +221,16 @@ def recreate_containers(self, insecure_registry=False, do_build=True, **override if not containers: container = self.create_container( insecure_registry=insecure_registry, - do_build=do_build, - **override_options) + do_build=do_build) self.start_container(container) return [container] return [ - self.recreate_container( - c, - insecure_registry=insecure_registry, - **override_options) + self.recreate_container(c, insecure_registry=insecure_registry) for c in containers ] - def recreate_container(self, container, **override_options): + def recreate_container(self, container, insecure_registry=False): """Recreate a container. The original container is renamed to a temporary name so that data @@ -257,16 +253,12 @@ def recreate_container(self, container, **override_options): container.id, '%s_%s' % (container.short_id, container.name)) - override_options = dict( - override_options, - environment=merge_environment( - override_options.get('environment'), - {'affinity:container': '=' + container.id})) new_container = self.create_container( + insecure_registry=insecure_registry, do_build=False, previous_container=container, number=container.labels.get(LABEL_CONTAINER_NUMBER), - **override_options) + ) self.start_container(new_container) container.remove() return new_container @@ -430,8 +422,10 @@ def _get_container_create_options( self.options.get('environment'), override_options.get('environment')) - if self.can_be_built(): - container_options['image'] = self.full_name + if previous_container: + container_options['environment']['affinity:container'] = ('=' + previous_container.id) + + container_options['image'] = self.image_name container_options['labels'] = build_container_labels( container_options.get('labels', {}), From ef4eb66723318af8aa189ce93fdd525a1f1d427d Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 12 May 2015 11:11:36 +0100 Subject: [PATCH 0814/4072] Implement smart recreate behind an experimental CLI flag Signed-off-by: Aanand Prasad --- compose/cli/main.py | 15 +- compose/const.py | 1 + compose/container.py | 5 +- compose/project.py | 64 ++++++-- compose/service.py | 176 ++++++++++++++++++-- compose/state.py | 0 compose/utils.py | 9 + tests/integration/project_test.py | 7 +- tests/integration/service_test.py | 27 ++- tests/integration/state_test.py | 263 ++++++++++++++++++++++++++++++ tests/unit/service_test.py | 86 +++++----- 11 files changed, 563 insertions(+), 90 deletions(-) create mode 100644 compose/state.py create mode 100644 compose/utils.py create mode 100644 tests/integration/state_test.py diff --git a/compose/cli/main.py b/compose/cli/main.py index 9379c79e58f..557dc636701 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -13,7 +13,7 @@ from .. import __version__ from .. import migration from ..project import NoSuchService, ConfigurationError -from ..service import BuildError, CannotBeScaledError +from ..service import BuildError, CannotBeScaledError, NeedsBuildError from ..config import parse_environment from .command import Command from .docopt_command import NoSuchCommand @@ -47,6 +47,9 @@ def main(): except BuildError as e: log.error("Service '%s' failed to build: %s" % (e.service.name, e.reason)) sys.exit(1) + except NeedsBuildError as e: + log.error("Service '%s' needs to be built, but --no-build was passed." % e.service.name) + sys.exit(1) def setup_logging(): @@ -297,7 +300,7 @@ def run(self, project, options): project.up( service_names=deps, start_deps=True, - recreate=False, + allow_recreate=False, insecure_registry=insecure_registry, ) @@ -440,6 +443,8 @@ def up(self, project, options): print new container names. --no-color Produce monochrome output. --no-deps Don't start linked services. + --x-smart-recreate Only recreate containers whose configuration or + image needs to be updated. (EXPERIMENTAL) --no-recreate If containers already exist, don't recreate them. --no-build Don't build an image, even if it's missing -t, --timeout TIMEOUT When attached, use this timeout in seconds @@ -452,13 +457,15 @@ def up(self, project, options): monochrome = options['--no-color'] start_deps = not options['--no-deps'] - recreate = not options['--no-recreate'] + allow_recreate = not options['--no-recreate'] + smart_recreate = options['--x-smart-recreate'] service_names = options['SERVICE'] project.up( service_names=service_names, start_deps=start_deps, - recreate=recreate, + allow_recreate=allow_recreate, + smart_recreate=smart_recreate, insecure_registry=insecure_registry, do_build=not options['--no-build'], ) diff --git a/compose/const.py b/compose/const.py index 0714a6dbf60..f76fb572cd5 100644 --- a/compose/const.py +++ b/compose/const.py @@ -4,3 +4,4 @@ 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' diff --git a/compose/container.py b/compose/container.py index 3e462088fed..71951497168 100644 --- a/compose/container.py +++ b/compose/container.py @@ -179,13 +179,16 @@ def attach_socket(self, **kwargs): return self.client.attach_socket(self.id, **kwargs) def __repr__(self): - return '' % self.name + return '' % (self.name, self.id[:6]) def __eq__(self, other): if type(self) != type(other): return False return self.id == other.id + def __hash__(self): + return self.id.__hash__() + def get_container_name(container): if not container.get('Name') and not container.get('Names'): diff --git a/compose/project.py b/compose/project.py index 41cd14e0d6b..c37175ae075 100644 --- a/compose/project.py +++ b/compose/project.py @@ -207,22 +207,59 @@ def build(self, service_names=None, no_cache=False): def up(self, service_names=None, start_deps=True, - recreate=True, + allow_recreate=True, + smart_recreate=False, insecure_registry=False, do_build=True): - running_containers = [] - for service in self.get_services(service_names, include_deps=start_deps): - if recreate: - create_func = service.recreate_containers + + services = self.get_services(service_names, include_deps=start_deps) + + plans = self._get_convergence_plans( + services, + allow_recreate=allow_recreate, + smart_recreate=smart_recreate, + ) + + return [ + container + for service in services + for container in service.execute_convergence_plan( + plans[service.name], + insecure_registry=insecure_registry, + do_build=do_build, + ) + ] + + def _get_convergence_plans(self, + services, + allow_recreate=True, + smart_recreate=False): + + plans = {} + + for service in services: + updated_dependencies = [ + name + for name in service.get_dependency_names() + if name in plans + and plans[name].action == 'recreate' + ] + + if updated_dependencies: + log.debug( + '%s has not changed but its dependencies (%s) have, so recreating', + service.name, ", ".join(updated_dependencies), + ) + plan = service.recreate_plan() else: - create_func = service.start_or_create_containers + plan = service.convergence_plan( + allow_recreate=allow_recreate, + smart_recreate=smart_recreate, + ) - for container in create_func( - insecure_registry=insecure_registry, - do_build=do_build): - running_containers.append(container) + plans[service.name] = plan - return running_containers + return plans def pull(self, service_names=None, insecure_registry=False): for service in self.get_services(service_names, include_deps=True): @@ -250,10 +287,7 @@ def containers(self, service_names=None, stopped=False, one_off=False): return containers def _inject_deps(self, acc, service): - net_name = service.get_net_name() - dep_names = (service.get_linked_names() + - service.get_volumes_from_names() + - ([net_name] if net_name else [])) + dep_names = service.get_dependency_names() if len(dep_names) > 0: dep_services = self.get_services( diff --git a/compose/service.py b/compose/service.py index 306b81833e5..0c03648c41b 100644 --- a/compose/service.py +++ b/compose/service.py @@ -18,9 +18,11 @@ LABEL_PROJECT, LABEL_SERVICE, LABEL_VERSION, + LABEL_CONFIG_HASH, ) from .container import Container, get_container_name from .progress_stream import stream_output, StreamOutputError +from .utils import json_hash log = logging.getLogger(__name__) @@ -59,12 +61,20 @@ class ConfigError(ValueError): pass +class NeedsBuildError(Exception): + def __init__(self, service): + self.service = service + + VolumeSpec = namedtuple('VolumeSpec', 'external internal mode') ServiceName = namedtuple('ServiceName', 'project service number') +ConvergencePlan = namedtuple('ConvergencePlan', 'action containers') + + class Service(object): def __init__(self, name, client=None, project='default', links=None, external_links=None, volumes_from=None, net=None, **options): if not re.match('^%s+$' % VALID_NAME_CHARS, name): @@ -192,6 +202,11 @@ def create_container(self, Create a container for this service. If the image doesn't exist, attempt to pull it. """ + self.ensure_image_exists( + do_build=do_build, + insecure_registry=insecure_registry, + ) + container_options = self._get_container_create_options( override_options, number or self._next_container_number(one_off=one_off), @@ -199,38 +214,142 @@ def create_container(self, previous_container=previous_container, ) - if (do_build and - self.can_be_built() and - not self.client.images(name=self.full_name)): - self.build() + return Container.create(self.client, **container_options) + def ensure_image_exists(self, + do_build=True, + insecure_registry=False): + + if self.image(): + return + + if self.can_be_built(): + if do_build: + self.build() + else: + raise NeedsBuildError(self) + else: + self.pull(insecure_registry=insecure_registry) + + def image(self): try: - return Container.create(self.client, **container_options) + 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): - self.pull(insecure_registry=insecure_registry) - return Container.create(self.client, **container_options) - raise + return None + else: + raise + + @property + def image_name(self): + if self.can_be_built(): + return self.full_name + else: + return self.options['image'] - def recreate_containers(self, insecure_registry=False, do_build=True): + def converge(self, + allow_recreate=True, + smart_recreate=False, + insecure_registry=False, + do_build=True): """ If a container for this service doesn't exist, create and start one. If there are any, stop them, create+start new ones, and remove the old containers. """ + plan = self.convergence_plan( + allow_recreate=allow_recreate, + smart_recreate=smart_recreate, + ) + + return self.execute_convergence_plan( + plan, + insecure_registry=insecure_registry, + do_build=do_build, + ) + + def convergence_plan(self, + allow_recreate=True, + smart_recreate=False): + containers = self.containers(stopped=True) + if not containers: + return ConvergencePlan('create', []) + + if smart_recreate and not self._containers_have_diverged(containers): + stopped = [c for c in containers if not c.is_running] + + if stopped: + return ConvergencePlan('start', stopped) + + return ConvergencePlan('noop', containers) + + if not allow_recreate: + return ConvergencePlan('start', containers) + + return ConvergencePlan('recreate', containers) + + def recreate_plan(self): + containers = self.containers(stopped=True) + return ConvergencePlan('recreate', containers) + + def _containers_have_diverged(self, containers): + config_hash = self.config_hash() + has_diverged = False + + for c in containers: + container_config_hash = c.labels.get(LABEL_CONFIG_HASH, None) + if container_config_hash != config_hash: + log.debug( + '%s has diverged: %s != %s', + c.name, container_config_hash, config_hash, + ) + has_diverged = True + + return has_diverged + + def execute_convergence_plan(self, + plan, + insecure_registry=False, + do_build=True): + (action, containers) = plan + + if action == 'create': container = self.create_container( insecure_registry=insecure_registry, - do_build=do_build) + do_build=do_build, + ) self.start_container(container) + return [container] - return [ - self.recreate_container(c, insecure_registry=insecure_registry) - for c in containers - ] + elif action == 'recreate': + return [ + self.recreate_container( + c, + insecure_registry=insecure_registry, + ) + for c in containers + ] + + elif action == 'start': + for c in containers: + self.start_container_if_stopped(c) + + return containers + + elif action == 'noop': + for c in containers: + log.info("%s is up-to-date" % c.name) + + return containers + + else: + raise Exception("Invalid action: {}".format(action)) - def recreate_container(self, container, insecure_registry=False): + def recreate_container(self, + container, + insecure_registry=False): """Recreate a container. The original container is renamed to a temporary name so that data @@ -289,6 +408,21 @@ def start_or_create_containers( else: return [self.start_container_if_stopped(c) for c in containers] + def config_hash(self): + return json_hash(self.config_dict()) + + def config_dict(self): + return { + 'options': self.options, + 'image_id': self.image()['Id'], + } + + def get_dependency_names(self): + net_name = self.get_net_name() + return (self.get_linked_names() + + self.get_volumes_from_names() + + ([net_name] if net_name else [])) + def get_linked_names(self): return [s.name for (s, _) in self.links] @@ -376,6 +510,9 @@ def _get_container_create_options( number, one_off=False, previous_container=None): + + add_config_hash = (not one_off and not override_options) + container_options = dict( (k, self.options[k]) for k in DOCKER_CONFIG_KEYS if k in self.options) @@ -383,6 +520,13 @@ def _get_container_create_options( container_options['name'] = self.get_container_name(number, one_off) + if add_config_hash: + config_hash = self.config_hash() + if 'labels' not in container_options: + container_options['labels'] = {} + container_options['labels'][LABEL_CONFIG_HASH] = config_hash + log.debug("Added config hash: %s" % config_hash) + if 'detach' not in container_options: container_options['detach'] = True @@ -493,7 +637,7 @@ def build(self, no_cache=False): build_output = self.client.build( path=path, - tag=self.full_name, + tag=self.image_name, stream=True, rm=True, nocache=no_cache, diff --git a/compose/state.py b/compose/state.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/compose/utils.py b/compose/utils.py new file mode 100644 index 00000000000..d441a2daee9 --- /dev/null +++ b/compose/utils.py @@ -0,0 +1,9 @@ +import json +import hashlib + + +def json_hash(obj): + dump = json.dumps(obj, sort_keys=True) + h = hashlib.sha256() + h.update(dump) + return h.hexdigest() diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 00d156b370b..b6dcecbc65e 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -185,7 +185,7 @@ def test_project_up_with_no_recreate_running(self): old_db_id = project.containers()[0].id db_volume_path = project.containers()[0].inspect()['Volumes']['/var/db'] - project.up(recreate=False) + project.up(allow_recreate=False) self.assertEqual(len(project.containers()), 2) db_container = [c for c in project.containers() if 'db' in c.name][0] @@ -204,7 +204,7 @@ def test_project_up_with_no_recreate_stopped(self): self.assertEqual(len(project.containers()), 0) project.up(['db']) - project.stop() + project.kill() old_containers = project.containers(stopped=True) @@ -212,10 +212,11 @@ def test_project_up_with_no_recreate_stopped(self): old_db_id = old_containers[0].id db_volume_path = old_containers[0].inspect()['Volumes']['/var/db'] - project.up(recreate=False) + project.up(allow_recreate=False) new_containers = project.containers(stopped=True) self.assertEqual(len(new_containers), 2) + self.assertEqual([c.is_running for c in new_containers], [True, True]) db_container = [c for c in new_containers if 'db' in c.name][0] self.assertEqual(db_container.id, old_db_id) diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 47c826ec5b6..26f02d4a957 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -238,7 +238,7 @@ def test_create_container_with_volumes_from(self): self.assertIn(volume_container_2.id, host_container.get('HostConfig.VolumesFrom')) - def test_recreate_containers(self): + def test_converge(self): service = self.create_service( 'db', environment={'FOO': '1'}, @@ -258,7 +258,7 @@ def test_recreate_containers(self): num_containers_before = len(self.client.containers(all=True)) service.options['environment']['FOO'] = '2' - new_container, = service.recreate_containers() + new_container = service.converge()[0] self.assertEqual(new_container.get('Config.Entrypoint'), ['sleep']) self.assertEqual(new_container.get('Config.Cmd'), ['300']) @@ -275,7 +275,7 @@ def test_recreate_containers(self): self.client.inspect_container, old_container.id) - def test_recreate_containers_when_containers_are_stopped(self): + def test_converge_when_containers_are_stopped(self): service = self.create_service( 'db', environment={'FOO': '1'}, @@ -285,10 +285,10 @@ def test_recreate_containers_when_containers_are_stopped(self): ) service.create_container() self.assertEqual(len(service.containers(stopped=True)), 1) - service.recreate_containers() + service.converge() self.assertEqual(len(service.containers(stopped=True)), 1) - def test_recreate_containers_with_image_declared_volume(self): + def test_converge_with_image_declared_volume(self): service = Service( project='composetest', name='db', @@ -300,7 +300,7 @@ def test_recreate_containers_with_image_declared_volume(self): self.assertEqual(old_container.get('Volumes').keys(), ['/data']) volume_path = old_container.get('Volumes')['/data'] - new_container = service.recreate_containers()[0] + new_container = service.converge()[0] self.assertEqual(new_container.get('Volumes').keys(), ['/data']) self.assertEqual(new_container.get('Volumes')['/data'], volume_path) @@ -651,8 +651,19 @@ def test_labels(self): expected = dict(labels_dict, **compose_labels) service = self.create_service('web', labels=labels_dict) - labels = create_and_start_container(service).labels - self.assertEqual(labels, expected) + labels = create_and_start_container(service).labels.items() + for pair in expected.items(): + self.assertIn(pair, labels) + + service.kill() + service.remove_stopped() + + labels_list = ["%s=%s" % pair for pair in labels_dict.items()] + + service = self.create_service('web', labels=labels_list) + labels = create_and_start_container(service).labels.items() + for pair in expected.items(): + self.assertIn(pair, labels) def test_empty_labels(self): labels_list = ['foo', 'bar'] diff --git a/tests/integration/state_test.py b/tests/integration/state_test.py new file mode 100644 index 00000000000..3c0b2530fba --- /dev/null +++ b/tests/integration/state_test.py @@ -0,0 +1,263 @@ +from __future__ import unicode_literals +import tempfile +import shutil +import os + +from compose import config +from compose.project import Project +from compose.const import LABEL_CONFIG_HASH + +from .testcases import DockerClientTestCase + + +class ProjectTestCase(DockerClientTestCase): + def run_up(self, cfg, **kwargs): + if 'smart_recreate' not in kwargs: + kwargs['smart_recreate'] = True + + project = self.make_project(cfg) + project.up(**kwargs) + return set(project.containers(stopped=True)) + + def make_project(self, cfg): + return Project.from_dicts( + name='composetest', + client=self.client, + service_dicts=config.from_dictionary(cfg), + ) + + +class BasicProjectTest(ProjectTestCase): + def setUp(self): + super(BasicProjectTest, self).setUp() + + self.cfg = { + 'db': {'image': 'busybox:latest'}, + 'web': {'image': 'busybox:latest'}, + } + + def test_no_change(self): + old_containers = self.run_up(self.cfg) + self.assertEqual(len(old_containers), 2) + + new_containers = self.run_up(self.cfg) + self.assertEqual(len(new_containers), 2) + + self.assertEqual(old_containers, new_containers) + + def test_partial_change(self): + old_containers = self.run_up(self.cfg) + old_db = [c for c in old_containers if c.name_without_project == 'db_1'][0] + old_web = [c for c in old_containers if c.name_without_project == 'web_1'][0] + + self.cfg['web']['command'] = '/bin/true' + + new_containers = self.run_up(self.cfg) + self.assertEqual(len(new_containers), 2) + + preserved = list(old_containers & new_containers) + self.assertEqual(preserved, [old_db]) + + removed = list(old_containers - new_containers) + self.assertEqual(removed, [old_web]) + + created = list(new_containers - old_containers) + self.assertEqual(len(created), 1) + self.assertEqual(created[0].name_without_project, 'web_1') + self.assertEqual(created[0].get('Config.Cmd'), ['/bin/true']) + + def test_all_change(self): + old_containers = self.run_up(self.cfg) + self.assertEqual(len(old_containers), 2) + + self.cfg['web']['command'] = '/bin/true' + self.cfg['db']['command'] = '/bin/true' + + new_containers = self.run_up(self.cfg) + self.assertEqual(len(new_containers), 2) + + unchanged = old_containers & new_containers + self.assertEqual(len(unchanged), 0) + + new = new_containers - old_containers + self.assertEqual(len(new), 2) + + +class ProjectWithDependenciesTest(ProjectTestCase): + def setUp(self): + super(ProjectWithDependenciesTest, self).setUp() + + self.cfg = { + 'db': { + 'image': 'busybox:latest', + 'command': 'tail -f /dev/null', + }, + 'web': { + 'image': 'busybox:latest', + 'command': 'tail -f /dev/null', + 'links': ['db'], + }, + 'nginx': { + 'image': 'busybox:latest', + 'command': 'tail -f /dev/null', + 'links': ['web'], + }, + } + + def test_up(self): + containers = self.run_up(self.cfg) + self.assertEqual( + set(c.name_without_project for c in containers), + set(['db_1', 'web_1', 'nginx_1']), + ) + + def test_change_leaf(self): + old_containers = self.run_up(self.cfg) + + self.cfg['nginx']['environment'] = {'NEW_VAR': '1'} + new_containers = self.run_up(self.cfg) + + self.assertEqual( + set(c.name_without_project for c in new_containers - old_containers), + set(['nginx_1']), + ) + + def test_change_middle(self): + old_containers = self.run_up(self.cfg) + + self.cfg['web']['environment'] = {'NEW_VAR': '1'} + new_containers = self.run_up(self.cfg) + + self.assertEqual( + set(c.name_without_project for c in new_containers - old_containers), + set(['web_1', 'nginx_1']), + ) + + def test_change_root(self): + old_containers = self.run_up(self.cfg) + + self.cfg['db']['environment'] = {'NEW_VAR': '1'} + new_containers = self.run_up(self.cfg) + + self.assertEqual( + set(c.name_without_project for c in new_containers - old_containers), + set(['db_1', 'web_1', 'nginx_1']), + ) + + def test_change_root_no_recreate(self): + old_containers = self.run_up(self.cfg) + + self.cfg['db']['environment'] = {'NEW_VAR': '1'} + new_containers = self.run_up(self.cfg, allow_recreate=False) + + self.assertEqual(new_containers - old_containers, set()) + + +class ServiceStateTest(DockerClientTestCase): + def test_trigger_create(self): + web = self.create_service('web') + self.assertEqual(('create', []), web.convergence_plan(smart_recreate=True)) + + def test_trigger_noop(self): + web = self.create_service('web') + container = web.create_container() + web.start() + + web = self.create_service('web') + self.assertEqual(('noop', [container]), web.convergence_plan(smart_recreate=True)) + + def test_trigger_start(self): + options = dict(command=["/bin/sleep", "300"]) + + web = self.create_service('web', **options) + web.scale(2) + + containers = web.containers(stopped=True) + containers[0].stop() + containers[0].inspect() + + self.assertEqual([c.is_running for c in containers], [False, True]) + + web = self.create_service('web', **options) + self.assertEqual( + ('start', containers[0:1]), + web.convergence_plan(smart_recreate=True), + ) + + def test_trigger_recreate_with_config_change(self): + web = self.create_service('web', command=["/bin/sleep", "300"]) + container = web.create_container() + + web = self.create_service('web', command=["/bin/sleep", "400"]) + self.assertEqual(('recreate', [container]), web.convergence_plan(smart_recreate=True)) + + def test_trigger_recreate_with_image_change(self): + repo = 'composetest_myimage' + tag = 'latest' + image = '{}:{}'.format(repo, tag) + + image_id = self.client.images(name='busybox')[0]['Id'] + self.client.tag(image_id, repository=repo, tag=tag) + + try: + web = self.create_service('web', image=image) + container = web.create_container() + + # update the image + c = self.client.create_container(image, ['touch', '/hello.txt']) + self.client.commit(c, repository=repo, tag=tag) + self.client.remove_container(c) + + web = self.create_service('web', image=image) + self.assertEqual(('recreate', [container]), web.convergence_plan(smart_recreate=True)) + + finally: + self.client.remove_image(image) + + def test_trigger_recreate_with_build(self): + context = tempfile.mkdtemp() + + try: + dockerfile = os.path.join(context, 'Dockerfile') + + with open(dockerfile, 'w') as f: + f.write('FROM busybox\n') + + web = self.create_service('web', build=context) + container = web.create_container() + + with open(dockerfile, 'w') as f: + f.write('FROM busybox\nCMD echo hello world\n') + web.build() + + web = self.create_service('web', build=context) + self.assertEqual(('recreate', [container]), web.convergence_plan(smart_recreate=True)) + finally: + shutil.rmtree(context) + + +class ConfigHashTest(DockerClientTestCase): + def test_no_config_hash_when_one_off(self): + web = self.create_service('web') + container = web.create_container(one_off=True) + self.assertNotIn(LABEL_CONFIG_HASH, container.labels) + + def test_no_config_hash_when_overriding_options(self): + web = self.create_service('web') + container = web.create_container(environment={'FOO': '1'}) + self.assertNotIn(LABEL_CONFIG_HASH, container.labels) + + def test_config_hash_with_custom_labels(self): + web = self.create_service('web', labels={'foo': '1'}) + container = web.converge()[0] + self.assertIn(LABEL_CONFIG_HASH, container.labels) + self.assertIn('foo', container.labels) + + def test_config_hash_sticks_around(self): + web = self.create_service('web', command=["/bin/sleep", "300"]) + container = web.converge()[0] + self.assertIn(LABEL_CONFIG_HASH, container.labels) + + web = self.create_service('web', command=["/bin/sleep", "400"]) + container = web.converge()[0] + self.assertIn(LABEL_CONFIG_HASH, container.labels) diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index fa252062c5a..add48086d4e 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -5,14 +5,13 @@ import mock import docker -from requests import Response from compose.service import Service from compose.container import Container from compose.const import LABEL_SERVICE, LABEL_PROJECT, LABEL_ONE_OFF from compose.service import ( - APIError, ConfigError, + NeedsBuildError, build_port_bindings, build_volume_binding, get_container_data_volumes, @@ -223,36 +222,28 @@ def test_pull_image_no_tag(self): insecure_registry=False, stream=True) - @mock.patch('compose.service.Container', autospec=True) - @mock.patch('compose.service.log', autospec=True) - def test_create_container_from_insecure_registry( - self, - mock_log, - mock_container): + def test_create_container_from_insecure_registry(self): service = Service('foo', client=self.mock_client, image='someimage:sometag') - mock_response = mock.Mock(Response) - mock_response.status_code = 404 - mock_response.reason = "Not Found" - mock_container.create.side_effect = APIError( - 'Mock error', mock_response, "No such image") + images = [] - # We expect the APIError because our service requires a - # non-existent image. - with self.assertRaises(APIError): - service.create_container(insecure_registry=True) + def pull(repo, tag=None, insecure_registry=False, **kwargs): + self.assertEqual('someimage', repo) + self.assertEqual('sometag', tag) + self.assertTrue(insecure_registry) + images.append({'Id': 'abc123'}) + return [] - self.mock_client.pull.assert_called_once_with( - 'someimage', - tag='sometag', - insecure_registry=True, - stream=True) - mock_log.info.assert_called_with( - 'Pulling foo (someimage:sometag)...') + service.image = lambda: images[0] if images else None + self.mock_client.pull = pull + + service.create_container(insecure_registry=True) + self.assertEqual(1, len(images)) @mock.patch('compose.service.Container', autospec=True) def test_recreate_container(self, _): mock_container = mock.create_autospec(Container) service = Service('foo', client=self.mock_client, image='someimage') + service.image = lambda: {'Id': 'abc123'} new_container = service.recreate_container(mock_container) mock_container.stop.assert_called_once_with() @@ -273,36 +264,45 @@ def test_parse_repository_tag(self): @mock.patch('compose.service.Container', autospec=True) def test_create_container_latest_is_used_when_no_tag_specified(self, mock_container): - mock_container.create.side_effect = APIError( - "oops", - mock.Mock(status_code=404), - "No such image") service = Service('foo', client=self.mock_client, image='someimage') - with self.assertRaises(APIError): - service.create_container() - self.mock_client.pull.assert_called_once_with( - 'someimage', - tag='latest', - insecure_registry=False, - stream=True) + images = [] + + def pull(repo, tag=None, **kwargs): + self.assertEqual('someimage', repo) + self.assertEqual('latest', tag) + images.append({'Id': 'abc123'}) + return [] + + service.image = lambda: images[0] if images else None + self.mock_client.pull = pull + + service.create_container() + self.assertEqual(1, len(images)) def test_create_container_with_build(self): - self.mock_client.images.return_value = [] service = Service('foo', client=self.mock_client, build='.') - service.build = mock.create_autospec(service.build) - service.create_container(do_build=True) - self.mock_client.images.assert_called_once_with(name=service.full_name) - service.build.assert_called_once_with() + images = [] + service.image = lambda *args, **kwargs: images[0] if images else None + service.build = lambda: images.append({'Id': 'abc123'}) + + service.create_container(do_build=True) + self.assertEqual(1, len(images)) def test_create_container_no_build(self): - self.mock_client.images.return_value = [] service = Service('foo', client=self.mock_client, build='.') - service.create_container(do_build=False) + service.image = lambda: {'Id': 'abc123'} - self.assertFalse(self.mock_client.images.called) + service.create_container(do_build=False) 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='.') + service.image = lambda: None + + with self.assertRaises(NeedsBuildError): + service.create_container(do_build=False) + class ServiceVolumesTest(unittest.TestCase): From 41315b32cbe40ec3649b484ea8e3cba197469275 Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Tue, 19 May 2015 16:37:50 +0200 Subject: [PATCH 0815/4072] Fix #1426 - migrate_to_labels not found Signed-off-by: Harald Albers --- compose/cli/main.py | 5 +++++ compose/service.py | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 557dc636701..a2dca65db15 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -490,6 +490,11 @@ def handler(signal, frame): project.stop(service_names=service_names, **params) def migrate_to_labels(self, project, _options): + """ + Recreate containers to add labels + + Usage: migrate_to_labels + """ migration.migrate_project_to_labels(project) diff --git a/compose/service.py b/compose/service.py index 0c03648c41b..e351fa60bcc 100644 --- a/compose/service.py +++ b/compose/service.py @@ -786,7 +786,7 @@ def check_for_legacy_containers( "labels. As of compose 1.3.0 containers are identified with " "labels instead of naming convention. If you'd like compose " "to use this container, please run " - "`docker-compose --migrate-to-labels`" % (name,)) + "`docker-compose migrate_to_labels`" % (name,)) def parse_restart_spec(restart_config): From ea7ee301c04127ff5f1b0faa661e5faea38ce1af Mon Sep 17 00:00:00 2001 From: lsowen Date: Fri, 17 Apr 2015 01:34:42 +0000 Subject: [PATCH 0816/4072] Add security_opt as a docker-compose.yml option Signed-off-by: Logan Owen --- compose/config.py | 1 + compose/service.py | 5 ++++- docs/yml.md | 10 ++++++++++ tests/integration/service_test.py | 7 +++++++ 4 files changed, 22 insertions(+), 1 deletion(-) diff --git a/compose/config.py b/compose/config.py index 1919ef5a35c..efc50075e3c 100644 --- a/compose/config.py +++ b/compose/config.py @@ -30,6 +30,7 @@ 'ports', 'privileged', 'restart', + 'security_opt', 'stdin_open', 'tty', 'user', diff --git a/compose/service.py b/compose/service.py index 0c03648c41b..8efa6e9f362 100644 --- a/compose/service.py +++ b/compose/service.py @@ -42,6 +42,7 @@ 'privileged', 'restart', 'volumes_from', + 'security_opt', ] VALID_NAME_CHARS = '[a-zA-Z0-9]' @@ -595,6 +596,7 @@ def _get_container_host_config(self, override_options, one_off=False): cap_drop = options.get('cap_drop', None) log_config = LogConfig(type=options.get('log_driver', 'json-file')) pid = options.get('pid', None) + security_opt = options.get('security_opt', None) dns = options.get('dns', None) if isinstance(dns, six.string_types): @@ -627,7 +629,8 @@ def _get_container_host_config(self, override_options, one_off=False): log_config=log_config, extra_hosts=extra_hosts, read_only=read_only, - pid_mode=pid + pid_mode=pid, + security_opt=security_opt ) def build(self, no_cache=False): diff --git a/docs/yml.md b/docs/yml.md index 41247c70348..df791bc98fd 100644 --- a/docs/yml.md +++ b/docs/yml.md @@ -352,6 +352,16 @@ devices: - "/dev/ttyUSB0:/dev/ttyUSB0" ``` +### security_opt + +Override the default labeling scheme for each container. + +``` +security_opt: + - label:user:USER + - label:role:ROLE +``` + ### working\_dir, entrypoint, user, hostname, domainname, mem\_limit, privileged, restart, stdin\_open, tty, cpu\_shares, cpuset, read\_only Each of these is a single value, analogous to its diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 26f02d4a957..b6cde37cc88 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -192,6 +192,13 @@ def test_create_container_with_read_only_root_fs(self): service.start_container(container) self.assertEqual(container.get('HostConfig.ReadonlyRootfs'), read_only, container.get('HostConfig')) + def test_create_container_with_security_opt(self): + security_opt = ['label:disable'] + service = self.create_service('db', security_opt=security_opt) + container = service.create_container() + service.start_container(container) + self.assertEqual(set(container.get('HostConfig.SecurityOpt')), set(security_opt)) + def test_create_container_with_specified_volume(self): host_path = '/tmp/host-path' container_path = '/container-path' From f5ac1fa0738384fada4abb979ba25dddecc56372 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Wed, 20 May 2015 16:02:08 +0100 Subject: [PATCH 0817/4072] Remove whitespace from json hash Reasoning: https://github.com/aanand/fig/commit/e5d8447f063498164f12567554a2eec16b4a3c88#commitcomment-11243708 Signed-off-by: Ben Firshman --- compose/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/utils.py b/compose/utils.py index d441a2daee9..76a4c6b93a6 100644 --- a/compose/utils.py +++ b/compose/utils.py @@ -3,7 +3,7 @@ def json_hash(obj): - dump = json.dumps(obj, sort_keys=True) + dump = json.dumps(obj, sort_keys=True, separators=(',', ':')) h = hashlib.sha256() h.update(dump) return h.hexdigest() From 022f81711eca255a6ba248a1dc62d807dccfb7dd Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 20 May 2015 20:45:48 -0400 Subject: [PATCH 0818/4072] Fixes #1434, Project.containers with service_names. Signed-off-by: Daniel Nephin --- compose/project.py | 9 +++++++-- tests/integration/project_test.py | 23 +++++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/compose/project.py b/compose/project.py index c37175ae075..a13b8a1fbe5 100644 --- a/compose/project.py +++ b/compose/project.py @@ -6,7 +6,7 @@ from docker.errors import APIError from .config import get_service_name_from_net, ConfigurationError -from .const import LABEL_PROJECT, LABEL_ONE_OFF +from .const import LABEL_PROJECT, LABEL_SERVICE, LABEL_ONE_OFF from .service import Service, check_for_legacy_containers from .container import Container @@ -276,6 +276,11 @@ def containers(self, service_names=None, stopped=False, one_off=False): all=stopped, filters={'label': self.labels(one_off=one_off)})] + def matches_service_names(container): + if not service_names: + return True + return container.labels.get(LABEL_SERVICE) in service_names + if not containers: check_for_legacy_containers( self.client, @@ -284,7 +289,7 @@ def containers(self, service_names=None, stopped=False, one_off=False): stopped=stopped, one_off=one_off) - return containers + return filter(matches_service_names, containers) def _inject_deps(self, acc, service): dep_names = service.get_dependency_names() diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index b6dcecbc65e..6e315e84a82 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -6,6 +6,29 @@ class ProjectTest(DockerClientTestCase): + + def test_containers(self): + web = self.create_service('web') + db = self.create_service('db') + project = Project('composetest', [web, db], self.client) + + project.up() + + containers = project.containers() + self.assertEqual(len(containers), 2) + + def test_containers_with_service_names(self): + web = self.create_service('web') + db = self.create_service('db') + project = Project('composetest', [web, db], self.client) + + project.up() + + containers = project.containers(['web']) + self.assertEqual( + [c.name for c in containers], + ['composetest_web_1']) + def test_volumes_from_service(self): service_dicts = config.from_dictionary({ 'data': { From 3080244c0bcad4c9b70def823acf8f963b4975ce Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 21 May 2015 14:54:41 +0100 Subject: [PATCH 0819/4072] Rename migrate_to_labels -> migrate-to-labels Signed-off-by: Aanand Prasad --- compose/cli/docopt_command.py | 2 ++ compose/cli/main.py | 4 ++-- compose/service.py | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/compose/cli/docopt_command.py b/compose/cli/docopt_command.py index 8105d3b3feb..ee694701259 100644 --- a/compose/cli/docopt_command.py +++ b/compose/cli/docopt_command.py @@ -33,6 +33,8 @@ def parse(self, argv, global_options): if command is None: raise SystemExit(getdoc(self)) + command = command.replace('-', '_') + if not hasattr(self, command): raise NoSuchCommand(command, self) diff --git a/compose/cli/main.py b/compose/cli/main.py index a2dca65db15..cf7d8311463 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -99,7 +99,7 @@ class TopLevelCommand(Command): start Start services stop Stop services up Create and start containers - migrate_to_labels Recreate containers to add labels + migrate-to-labels Recreate containers to add labels """ def docopt_options(self): @@ -493,7 +493,7 @@ def migrate_to_labels(self, project, _options): """ Recreate containers to add labels - Usage: migrate_to_labels + Usage: migrate-to-labels """ migration.migrate_project_to_labels(project) diff --git a/compose/service.py b/compose/service.py index 7e2bca2445e..e10758574d0 100644 --- a/compose/service.py +++ b/compose/service.py @@ -789,7 +789,7 @@ def check_for_legacy_containers( "labels. As of compose 1.3.0 containers are identified with " "labels instead of naming convention. If you'd like compose " "to use this container, please run " - "`docker-compose migrate_to_labels`" % (name,)) + "`docker-compose migrate-to-labels`" % (name,)) def parse_restart_spec(restart_config): From b0cb31c18635f5bb277c51b656974ce6abef5205 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 21 May 2015 16:19:15 +0100 Subject: [PATCH 0820/4072] Use 'top' instead of 'sleep' as a dummy command Signed-off-by: Aanand Prasad --- .../fixtures/UpperCaseDir/docker-compose.yml | 4 ++-- .../dockerfile-with-volume/Dockerfile | 2 +- .../docker-compose.yml | 2 +- tests/fixtures/extends/docker-compose.yml | 4 ++-- .../links-composefile/docker-compose.yml | 6 ++--- .../docker-compose.yaml | 2 +- .../multiple-composefiles/compose2.yml | 2 +- .../multiple-composefiles/docker-compose.yml | 4 ++-- .../ports-composefile/docker-compose.yml | 2 +- .../simple-composefile/docker-compose.yml | 4 ++-- tests/integration/project_test.py | 22 +++++++++---------- tests/integration/service_test.py | 18 +++++++-------- tests/integration/state_test.py | 10 ++++----- tests/integration/testcases.py | 2 +- tests/unit/config_test.py | 4 ++-- tests/unit/container_test.py | 2 +- 16 files changed, 45 insertions(+), 45 deletions(-) diff --git a/tests/fixtures/UpperCaseDir/docker-compose.yml b/tests/fixtures/UpperCaseDir/docker-compose.yml index 3538ab09716..b25beaf4b75 100644 --- a/tests/fixtures/UpperCaseDir/docker-compose.yml +++ b/tests/fixtures/UpperCaseDir/docker-compose.yml @@ -1,6 +1,6 @@ simple: image: busybox:latest - command: /bin/sleep 300 + command: top another: image: busybox:latest - command: /bin/sleep 300 + command: top diff --git a/tests/fixtures/dockerfile-with-volume/Dockerfile b/tests/fixtures/dockerfile-with-volume/Dockerfile index 2d6437cf433..6e5d0a55ee8 100644 --- a/tests/fixtures/dockerfile-with-volume/Dockerfile +++ b/tests/fixtures/dockerfile-with-volume/Dockerfile @@ -1,3 +1,3 @@ FROM busybox VOLUME /data -CMD sleep 3000 +CMD top diff --git a/tests/fixtures/environment-composefile/docker-compose.yml b/tests/fixtures/environment-composefile/docker-compose.yml index 92493227328..9d99fee088c 100644 --- a/tests/fixtures/environment-composefile/docker-compose.yml +++ b/tests/fixtures/environment-composefile/docker-compose.yml @@ -1,6 +1,6 @@ service: image: busybox:latest - command: sleep 5 + command: top environment: foo: bar diff --git a/tests/fixtures/extends/docker-compose.yml b/tests/fixtures/extends/docker-compose.yml index 0ae92d2a58d..c51be49ec51 100644 --- a/tests/fixtures/extends/docker-compose.yml +++ b/tests/fixtures/extends/docker-compose.yml @@ -2,7 +2,7 @@ myweb: extends: file: common.yml service: web - command: sleep 300 + command: top links: - "mydb:db" environment: @@ -13,4 +13,4 @@ myweb: BAZ: "2" mydb: image: busybox - command: sleep 300 + command: top diff --git a/tests/fixtures/links-composefile/docker-compose.yml b/tests/fixtures/links-composefile/docker-compose.yml index bc5391a992c..930fd4c7adf 100644 --- a/tests/fixtures/links-composefile/docker-compose.yml +++ b/tests/fixtures/links-composefile/docker-compose.yml @@ -1,11 +1,11 @@ db: image: busybox:latest - command: /bin/sleep 300 + command: top web: image: busybox:latest - command: /bin/sleep 300 + command: top links: - db:db console: image: busybox:latest - command: /bin/sleep 300 + command: top diff --git a/tests/fixtures/longer-filename-composefile/docker-compose.yaml b/tests/fixtures/longer-filename-composefile/docker-compose.yaml index 3152894022c..b55a9e12456 100644 --- a/tests/fixtures/longer-filename-composefile/docker-compose.yaml +++ b/tests/fixtures/longer-filename-composefile/docker-compose.yaml @@ -1,3 +1,3 @@ definedinyamlnotyml: image: busybox:latest - command: /bin/sleep 300 \ No newline at end of file + command: top \ No newline at end of file diff --git a/tests/fixtures/multiple-composefiles/compose2.yml b/tests/fixtures/multiple-composefiles/compose2.yml index 523becac2b5..56803380407 100644 --- a/tests/fixtures/multiple-composefiles/compose2.yml +++ b/tests/fixtures/multiple-composefiles/compose2.yml @@ -1,3 +1,3 @@ yetanother: image: busybox:latest - command: /bin/sleep 300 + command: top diff --git a/tests/fixtures/multiple-composefiles/docker-compose.yml b/tests/fixtures/multiple-composefiles/docker-compose.yml index 3538ab09716..b25beaf4b75 100644 --- a/tests/fixtures/multiple-composefiles/docker-compose.yml +++ b/tests/fixtures/multiple-composefiles/docker-compose.yml @@ -1,6 +1,6 @@ simple: image: busybox:latest - command: /bin/sleep 300 + command: top another: image: busybox:latest - command: /bin/sleep 300 + command: top diff --git a/tests/fixtures/ports-composefile/docker-compose.yml b/tests/fixtures/ports-composefile/docker-compose.yml index 2474087d0cd..9496ee08260 100644 --- a/tests/fixtures/ports-composefile/docker-compose.yml +++ b/tests/fixtures/ports-composefile/docker-compose.yml @@ -1,7 +1,7 @@ simple: image: busybox:latest - command: /bin/sleep 300 + command: top ports: - '3000' - '49152:3001' diff --git a/tests/fixtures/simple-composefile/docker-compose.yml b/tests/fixtures/simple-composefile/docker-compose.yml index 3538ab09716..b25beaf4b75 100644 --- a/tests/fixtures/simple-composefile/docker-compose.yml +++ b/tests/fixtures/simple-composefile/docker-compose.yml @@ -1,6 +1,6 @@ simple: image: busybox:latest - command: /bin/sleep 300 + command: top another: image: busybox:latest - command: /bin/sleep 300 + command: top diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 6e315e84a82..5e3a40e5b71 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -78,12 +78,12 @@ def test_net_from_service(self): service_dicts=config.from_dictionary({ 'net': { 'image': 'busybox:latest', - 'command': ["/bin/sleep", "300"] + 'command': ["top"] }, 'web': { 'image': 'busybox:latest', 'net': 'container:net', - 'command': ["/bin/sleep", "300"] + 'command': ["top"] }, }), client=self.client, @@ -103,7 +103,7 @@ def test_net_from_container(self): self.client, image='busybox:latest', name='composetest_net_container', - command='/bin/sleep 300' + command='top' ) net_container.start() @@ -288,20 +288,20 @@ def test_project_up_starts_depends(self): service_dicts=config.from_dictionary({ 'console': { 'image': 'busybox:latest', - 'command': ["/bin/sleep", "300"], + 'command': ["top"], }, 'data': { 'image': 'busybox:latest', - 'command': ["/bin/sleep", "300"] + 'command': ["top"] }, 'db': { 'image': 'busybox:latest', - 'command': ["/bin/sleep", "300"], + 'command': ["top"], 'volumes_from': ['data'], }, 'web': { 'image': 'busybox:latest', - 'command': ["/bin/sleep", "300"], + 'command': ["top"], 'links': ['db'], }, }), @@ -326,20 +326,20 @@ def test_project_up_with_no_deps(self): service_dicts=config.from_dictionary({ 'console': { 'image': 'busybox:latest', - 'command': ["/bin/sleep", "300"], + 'command': ["top"], }, 'data': { 'image': 'busybox:latest', - 'command': ["/bin/sleep", "300"] + 'command': ["top"] }, 'db': { 'image': 'busybox:latest', - 'command': ["/bin/sleep", "300"], + 'command': ["top"], 'volumes_from': ['data'], }, 'web': { 'image': 'busybox:latest', - 'command': ["/bin/sleep", "300"], + 'command': ["top"], 'links': ['db'], }, }), diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index b6cde37cc88..8fd8212ced9 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -236,7 +236,7 @@ def test_create_container_with_home_and_env_var_in_volume_path(self): def test_create_container_with_volumes_from(self): volume_service = self.create_service('data') volume_container_1 = volume_service.create_container() - volume_container_2 = Container.create(self.client, image='busybox:latest', command=["/bin/sleep", "300"]) + volume_container_2 = Container.create(self.client, image='busybox:latest', command=["top"]) host_service = self.create_service('host', volumes_from=[volume_service, volume_container_2]) host_container = host_service.create_container() host_service.start_container(host_container) @@ -250,12 +250,12 @@ def test_converge(self): 'db', environment={'FOO': '1'}, volumes=['/etc'], - entrypoint=['sleep'], - command=['300'] + entrypoint=['top'], + command=['-d', '1'] ) old_container = service.create_container() - self.assertEqual(old_container.get('Config.Entrypoint'), ['sleep']) - self.assertEqual(old_container.get('Config.Cmd'), ['300']) + self.assertEqual(old_container.get('Config.Entrypoint'), ['top']) + self.assertEqual(old_container.get('Config.Cmd'), ['-d', '1']) self.assertIn('FOO=1', old_container.get('Config.Env')) self.assertEqual(old_container.name, 'composetest_db_1') service.start_container(old_container) @@ -267,8 +267,8 @@ def test_converge(self): service.options['environment']['FOO'] = '2' new_container = service.converge()[0] - self.assertEqual(new_container.get('Config.Entrypoint'), ['sleep']) - self.assertEqual(new_container.get('Config.Cmd'), ['300']) + self.assertEqual(new_container.get('Config.Entrypoint'), ['top']) + self.assertEqual(new_container.get('Config.Cmd'), ['-d', '1']) self.assertIn('FOO=2', new_container.get('Config.Env')) self.assertEqual(new_container.name, 'composetest_db_1') self.assertEqual(new_container.get('Volumes')['/etc'], volume_path) @@ -287,8 +287,8 @@ def test_converge_when_containers_are_stopped(self): 'db', environment={'FOO': '1'}, volumes=['/var/db'], - entrypoint=['sleep'], - command=['300'] + entrypoint=['top'], + command=['-d', '1'] ) service.create_container() self.assertEqual(len(service.containers(stopped=True)), 1) diff --git a/tests/integration/state_test.py b/tests/integration/state_test.py index 3c0b2530fba..7a7d2b58fc3 100644 --- a/tests/integration/state_test.py +++ b/tests/integration/state_test.py @@ -167,7 +167,7 @@ def test_trigger_noop(self): self.assertEqual(('noop', [container]), web.convergence_plan(smart_recreate=True)) def test_trigger_start(self): - options = dict(command=["/bin/sleep", "300"]) + options = dict(command=["top"]) web = self.create_service('web', **options) web.scale(2) @@ -185,10 +185,10 @@ def test_trigger_start(self): ) def test_trigger_recreate_with_config_change(self): - web = self.create_service('web', command=["/bin/sleep", "300"]) + web = self.create_service('web', command=["top"]) container = web.create_container() - web = self.create_service('web', command=["/bin/sleep", "400"]) + web = self.create_service('web', command=["top", "-d", "1"]) self.assertEqual(('recreate', [container]), web.convergence_plan(smart_recreate=True)) def test_trigger_recreate_with_image_change(self): @@ -254,10 +254,10 @@ def test_config_hash_with_custom_labels(self): self.assertIn('foo', container.labels) def test_config_hash_sticks_around(self): - web = self.create_service('web', command=["/bin/sleep", "300"]) + web = self.create_service('web', command=["top"]) container = web.converge()[0] self.assertIn(LABEL_CONFIG_HASH, container.labels) - web = self.create_service('web', command=["/bin/sleep", "400"]) + web = self.create_service('web', command=["top", "-d", "1"]) container = web.converge()[0] self.assertIn(LABEL_CONFIG_HASH, container.labels) diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index 4a0f7248a72..48fcf3ef296 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -27,7 +27,7 @@ def create_service(self, name, **kwargs): kwargs['image'] = 'busybox:latest' if 'command' not in kwargs: - kwargs['command'] = ["/bin/sleep", "300"] + kwargs['command'] = ["top"] return Service( project='composetest', diff --git a/tests/unit/config_test.py b/tests/unit/config_test.py index 0a48dfefe5f..ebd2af7d5b4 100644 --- a/tests/unit/config_test.py +++ b/tests/unit/config_test.py @@ -348,12 +348,12 @@ def test_extends(self): { 'name': 'mydb', 'image': 'busybox', - 'command': 'sleep 300', + 'command': 'top', }, { 'name': 'myweb', 'image': 'busybox', - 'command': 'sleep 300', + 'command': 'top', 'links': ['mydb:db'], 'environment': { "FOO": "1", diff --git a/tests/unit/container_test.py b/tests/unit/container_test.py index 2313d4b8ef3..c537a8cf55a 100644 --- a/tests/unit/container_test.py +++ b/tests/unit/container_test.py @@ -14,7 +14,7 @@ def setUp(self): self.container_dict = { "Id": "abc", "Image": "busybox:latest", - "Command": "sleep 300", + "Command": "top", "Created": 1387384730, "Status": "Up 8 seconds", "Ports": None, From 0fdb8bf8147d61814cf8007f253a3f6049428d88 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 21 May 2015 11:25:06 +0100 Subject: [PATCH 0821/4072] Refactor migration logic - Rename `migration` module to `legacy` to make its legacy-ness explicit - Move `check_for_legacy_containers` into `legacy` module - Fix migration test so it can be run in isolation Signed-off-by: Aanand Prasad --- compose/cli/main.py | 4 ++-- compose/{migration.py => legacy.py} | 25 ++++++++++++++++++++++++ compose/project.py | 3 ++- compose/service.py | 28 ++------------------------- tests/integration/legacy_test.py | 30 +++++++++++++++++++++++++++++ tests/integration/migration_test.py | 23 ---------------------- 6 files changed, 61 insertions(+), 52 deletions(-) rename compose/{migration.py => legacy.py} (50%) create mode 100644 tests/integration/legacy_test.py delete mode 100644 tests/integration/migration_test.py diff --git a/compose/cli/main.py b/compose/cli/main.py index cf7d8311463..2b95040ca4e 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -11,7 +11,7 @@ import dockerpty from .. import __version__ -from .. import migration +from .. import legacy from ..project import NoSuchService, ConfigurationError from ..service import BuildError, CannotBeScaledError, NeedsBuildError from ..config import parse_environment @@ -495,7 +495,7 @@ def migrate_to_labels(self, project, _options): Usage: migrate-to-labels """ - migration.migrate_project_to_labels(project) + legacy.migrate_project_to_labels(project) def list_containers(containers): diff --git a/compose/migration.py b/compose/legacy.py similarity index 50% rename from compose/migration.py rename to compose/legacy.py index 16b5dd1678e..dc90079dafa 100644 --- a/compose/migration.py +++ b/compose/legacy.py @@ -16,6 +16,31 @@ def is_valid_name(name): return match is not None +def check_for_legacy_containers( + client, + project, + services, + stopped=False, + one_off=False): + """Check if there are containers named using the old naming convention + and warn the user that those containers may need to be migrated to + using labels, so that compose can find them. + """ + for container in client.containers(all=stopped): + name = get_container_name(container) + for service in services: + prefix = '%s_%s_%s' % (project, service, 'run_' if one_off else '') + if not name.startswith(prefix): + continue + + log.warn( + "Compose found a found a container named %s without any " + "labels. As of compose 1.3.0 containers are identified with " + "labels instead of naming convention. If you'd like compose " + "to use this container, please run " + "`docker-compose migrate-to-labels`" % (name,)) + + def add_labels(project, container, name): project_name, service_name, one_off, number = NAME_RE.match(name).groups() if project_name != project.name or service_name not in project.service_names: diff --git a/compose/project.py b/compose/project.py index a13b8a1fbe5..6dc92668128 100644 --- a/compose/project.py +++ b/compose/project.py @@ -7,8 +7,9 @@ from .config import get_service_name_from_net, ConfigurationError from .const import LABEL_PROJECT, LABEL_SERVICE, LABEL_ONE_OFF -from .service import Service, check_for_legacy_containers +from .service import Service from .container import Container +from .legacy import check_for_legacy_containers log = logging.getLogger(__name__) diff --git a/compose/service.py b/compose/service.py index e10758574d0..daf225ce8eb 100644 --- a/compose/service.py +++ b/compose/service.py @@ -20,7 +20,8 @@ LABEL_VERSION, LABEL_CONFIG_HASH, ) -from .container import Container, get_container_name +from .container import Container +from .legacy import check_for_legacy_containers from .progress_stream import stream_output, StreamOutputError from .utils import json_hash @@ -767,31 +768,6 @@ def build_container_labels(label_options, service_labels, number, one_off=False) return labels -def check_for_legacy_containers( - client, - project, - services, - stopped=False, - one_off=False): - """Check if there are containers named using the old naming convention - and warn the user that those containers may need to be migrated to - using labels, so that compose can find them. - """ - for container in client.containers(all=stopped): - name = get_container_name(container) - for service in services: - prefix = '%s_%s_%s' % (project, service, 'run_' if one_off else '') - if not name.startswith(prefix): - continue - - log.warn( - "Compose found a found a container named %s without any " - "labels. As of compose 1.3.0 containers are identified with " - "labels instead of naming convention. If you'd like compose " - "to use this container, please run " - "`docker-compose migrate-to-labels`" % (name,)) - - def parse_restart_spec(restart_config): if not restart_config: return None diff --git a/tests/integration/legacy_test.py b/tests/integration/legacy_test.py new file mode 100644 index 00000000000..d39635b7f9a --- /dev/null +++ b/tests/integration/legacy_test.py @@ -0,0 +1,30 @@ +import mock + +from compose import legacy +from compose.project import Project +from .testcases import DockerClientTestCase + + +class ProjectTest(DockerClientTestCase): + + def test_migration_to_labels(self): + services = [ + self.create_service('web'), + self.create_service('db'), + ] + + project = Project('composetest', services, self.client) + + for service in services: + service.ensure_image_exists() + self.client.create_container( + name='{}_{}_1'.format(project.name, service.name), + **service.options + ) + + with mock.patch.object(legacy, 'log', autospec=True) as mock_log: + self.assertEqual(project.containers(stopped=True), []) + self.assertEqual(mock_log.warn.call_count, 2) + + legacy.migrate_project_to_labels(project) + self.assertEqual(len(project.containers(stopped=True)), 2) diff --git a/tests/integration/migration_test.py b/tests/integration/migration_test.py deleted file mode 100644 index 133d231481c..00000000000 --- a/tests/integration/migration_test.py +++ /dev/null @@ -1,23 +0,0 @@ -import mock - -from compose import service, migration -from compose.project import Project -from .testcases import DockerClientTestCase - - -class ProjectTest(DockerClientTestCase): - - def test_migration_to_labels(self): - web = self.create_service('web') - db = self.create_service('db') - project = Project('composetest', [web, db], self.client) - - self.client.create_container(name='composetest_web_1', **web.options) - self.client.create_container(name='composetest_db_1', **db.options) - - with mock.patch.object(service, 'log', autospec=True) as mock_log: - self.assertEqual(project.containers(stopped=True), []) - self.assertEqual(mock_log.warn.call_count, 2) - - migration.migrate_project_to_labels(project) - self.assertEqual(len(project.containers(stopped=True)), 2) From b5ce23885b22377aab9a1eff8f27f58474c29c76 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 21 May 2015 12:12:02 +0100 Subject: [PATCH 0822/4072] Split out fetching of legacy names so we can test it Signed-off-by: Aanand Prasad --- compose/legacy.py | 33 ++++++++++++++++++++++-------- tests/integration/legacy_test.py | 35 ++++++++++++++++++++++++-------- 2 files changed, 50 insertions(+), 18 deletions(-) diff --git a/compose/legacy.py b/compose/legacy.py index dc90079dafa..7f97c3e946d 100644 --- a/compose/legacy.py +++ b/compose/legacy.py @@ -26,19 +26,34 @@ def check_for_legacy_containers( and warn the user that those containers may need to be migrated to using labels, so that compose can find them. """ + names = get_legacy_container_names( + client, + project, + services, + stopped=stopped, + one_off=one_off) + + for name in names: + log.warn( + "Compose found a found a container named %s without any " + "labels. As of compose 1.3.0 containers are identified with " + "labels instead of naming convention. If you'd like compose " + "to use this container, please run " + "`docker-compose migrate-to-labels`" % (name,)) + + +def get_legacy_container_names( + client, + project, + services, + stopped=False, + one_off=False): for container in client.containers(all=stopped): name = get_container_name(container) for service in services: prefix = '%s_%s_%s' % (project, service, 'run_' if one_off else '') - if not name.startswith(prefix): - continue - - log.warn( - "Compose found a found a container named %s without any " - "labels. As of compose 1.3.0 containers are identified with " - "labels instead of naming convention. If you'd like compose " - "to use this container, please run " - "`docker-compose migrate-to-labels`" % (name,)) + if name.startswith(prefix): + yield name def add_labels(project, container, name): diff --git a/tests/integration/legacy_test.py b/tests/integration/legacy_test.py index d39635b7f9a..85cc4032029 100644 --- a/tests/integration/legacy_test.py +++ b/tests/integration/legacy_test.py @@ -7,24 +7,41 @@ class ProjectTest(DockerClientTestCase): - def test_migration_to_labels(self): - services = [ + def setUp(self): + super(ProjectTest, self).setUp() + + self.services = [ self.create_service('web'), self.create_service('db'), ] - project = Project('composetest', services, self.client) + self.project = Project('composetest', self.services, self.client) - for service in services: + for service in self.services: service.ensure_image_exists() self.client.create_container( - name='{}_{}_1'.format(project.name, service.name), + name='{}_{}_1'.format(self.project.name, service.name), **service.options ) + def get_names(self, **kwargs): + if 'stopped' not in kwargs: + kwargs['stopped'] = True + + return list(legacy.get_legacy_container_names( + self.client, + self.project.name, + [s.name for s in self.services], + **kwargs + )) + + def test_get_legacy_container_names(self): + self.assertEqual(len(self.get_names()), len(self.services)) + + def test_migration_to_labels(self): with mock.patch.object(legacy, 'log', autospec=True) as mock_log: - self.assertEqual(project.containers(stopped=True), []) - self.assertEqual(mock_log.warn.call_count, 2) + self.assertEqual(self.project.containers(stopped=True), []) + self.assertEqual(mock_log.warn.call_count, len(self.services)) - legacy.migrate_project_to_labels(project) - self.assertEqual(len(project.containers(stopped=True)), 2) + legacy.migrate_project_to_labels(self.project) + self.assertEqual(len(self.project.containers(stopped=True)), len(self.services)) From 051f56a1e6994fdbe78720000916d18afe59ea0d Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 21 May 2015 12:12:30 +0100 Subject: [PATCH 0823/4072] Fix bugs with one-off legacy containers - One-off containers were included in the warning log messages, which can make for unreadable output when there are lots (as there often are). - Compose was attempting to recreate one-off containers as normal containers when migrating. Fixed by implementing the exact naming logic from before we used labels. Signed-off-by: Aanand Prasad --- compose/legacy.py | 56 +++++++++++++++++++++----------- tests/integration/legacy_test.py | 10 ++++++ 2 files changed, 47 insertions(+), 19 deletions(-) diff --git a/compose/legacy.py b/compose/legacy.py index 7f97c3e946d..8deabfa2450 100644 --- a/compose/legacy.py +++ b/compose/legacy.py @@ -11,11 +11,6 @@ NAME_RE = re.compile(r'^([^_]+)_([^_]+)_(run_)?(\d+)$') -def is_valid_name(name): - match = NAME_RE.match(name) - return match is not None - - def check_for_legacy_containers( client, project, @@ -42,20 +37,6 @@ def check_for_legacy_containers( "`docker-compose migrate-to-labels`" % (name,)) -def get_legacy_container_names( - client, - project, - services, - stopped=False, - one_off=False): - for container in client.containers(all=stopped): - name = get_container_name(container) - for service in services: - prefix = '%s_%s_%s' % (project, service, 'run_' if one_off else '') - if name.startswith(prefix): - yield name - - def add_labels(project, container, name): project_name, service_name, one_off, number = NAME_RE.match(name).groups() if project_name != project.name or service_name not in project.service_names: @@ -73,3 +54,40 @@ def migrate_project_to_labels(project): if not is_valid_name(name): continue add_labels(project, Container.from_ps(client, container), name) + + +def get_legacy_container_names( + client, + project, + services, + stopped=False, + one_off=False): + + for container in client.containers(all=stopped): + name = get_container_name(container) + for service in services: + if has_container(project, service, name, one_off=one_off): + yield name + + +def has_container(project, service, name, one_off=False): + if not name or not is_valid_name(name, one_off): + return False + container_project, container_service, _container_number = parse_name(name) + return container_project == project and container_service == service + + +def is_valid_name(name, one_off=False): + match = NAME_RE.match(name) + if match is None: + return False + if one_off: + return match.group(3) == 'run_' + else: + return match.group(3) is None + + +def parse_name(name): + match = NAME_RE.match(name) + (project, service_name, _, suffix) = match.groups() + return (project, service_name, int(suffix)) diff --git a/tests/integration/legacy_test.py b/tests/integration/legacy_test.py index 85cc4032029..8b0a9b7fccc 100644 --- a/tests/integration/legacy_test.py +++ b/tests/integration/legacy_test.py @@ -17,6 +17,7 @@ def setUp(self): self.project = Project('composetest', self.services, self.client) + # Create a legacy container for each service for service in self.services: service.ensure_image_exists() self.client.create_container( @@ -24,6 +25,12 @@ def setUp(self): **service.options ) + # Create a single one-off legacy container + self.client.create_container( + name='{}_{}_run_1'.format(self.project.name, self.services[0].name), + **self.services[0].options + ) + def get_names(self, **kwargs): if 'stopped' not in kwargs: kwargs['stopped'] = True @@ -38,6 +45,9 @@ def get_names(self, **kwargs): def test_get_legacy_container_names(self): self.assertEqual(len(self.get_names()), len(self.services)) + def test_get_legacy_container_names_one_off(self): + self.assertEqual(len(self.get_names(one_off=True)), 1) + def test_migration_to_labels(self): with mock.patch.object(legacy, 'log', autospec=True) as mock_log: self.assertEqual(self.project.containers(stopped=True), []) From 30c9e7323a1fd0e8f20207860bd9bfd02e4b4e83 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 21 May 2015 20:05:55 +0100 Subject: [PATCH 0824/4072] Fix missing logging on container creation Signed-off-by: Aanand Prasad --- compose/service.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/compose/service.py b/compose/service.py index e10758574d0..0b1a1453c76 100644 --- a/compose/service.py +++ b/compose/service.py @@ -215,6 +215,9 @@ def create_container(self, previous_container=previous_container, ) + if 'name' in container_options: + log.info("Creating %s..." % container_options['name']) + return Container.create(self.client, **container_options) def ensure_image_exists(self, From 412034a023b846070a847f92ad952315603d461c Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Wed, 20 May 2015 15:31:10 +0200 Subject: [PATCH 0825/4072] bash completion for migrate-to-labels Signed-off-by: Harald Albers --- contrib/completion/bash/docker-compose | 1 + 1 file changed, 1 insertion(+) diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index ec0f234812b..e62b1d8fcf5 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -311,6 +311,7 @@ _docker-compose() { help kill logs + migrate-to-labels port ps pull From 0b4d9401ee0143c9cba3d25017a35c27c362475b Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 21 May 2015 12:41:36 +0100 Subject: [PATCH 0826/4072] Bail out immediately if there are legacy containers Signed-off-by: Aanand Prasad --- compose/cli/main.py | 2 +- compose/legacy.py | 44 ++++++++++++++++++++++++-------- tests/integration/legacy_test.py | 10 +++++--- 3 files changed, 41 insertions(+), 15 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 2b95040ca4e..ff30d9701b0 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -33,7 +33,7 @@ def main(): except KeyboardInterrupt: log.error("\nAborting.") sys.exit(1) - except (UserError, NoSuchService, ConfigurationError) as e: + except (UserError, NoSuchService, ConfigurationError, legacy.LegacyContainersError) as e: log.error(e.msg) sys.exit(1) except NoSuchCommand as e: diff --git a/compose/legacy.py b/compose/legacy.py index 8deabfa2450..af0c8700bcf 100644 --- a/compose/legacy.py +++ b/compose/legacy.py @@ -10,6 +10,20 @@ # TODO: remove this section when migrate_project_to_labels is removed NAME_RE = re.compile(r'^([^_]+)_([^_]+)_(run_)?(\d+)$') +ERROR_MESSAGE_FORMAT = """ +Compose found the following containers without labels: + +{names_list} + +As of Compose 1.3.0, containers are identified with labels instead of naming convention. If you want to continue using these containers, run: + + $ docker-compose migrate-to-labels + +Alternatively, remove them: + + $ docker rm -f {rm_args} +""" + def check_for_legacy_containers( client, @@ -21,20 +35,30 @@ def check_for_legacy_containers( and warn the user that those containers may need to be migrated to using labels, so that compose can find them. """ - names = get_legacy_container_names( + names = list(get_legacy_container_names( client, project, services, stopped=stopped, - one_off=one_off) - - for name in names: - log.warn( - "Compose found a found a container named %s without any " - "labels. As of compose 1.3.0 containers are identified with " - "labels instead of naming convention. If you'd like compose " - "to use this container, please run " - "`docker-compose migrate-to-labels`" % (name,)) + one_off=one_off)) + + if names: + raise LegacyContainersError(names) + + +class LegacyContainersError(Exception): + def __init__(self, names): + self.names = names + + self.msg = ERROR_MESSAGE_FORMAT.format( + names_list="\n".join(" {}".format(name) for name in names), + rm_args=" ".join(names), + ) + + def __unicode__(self): + return self.msg + + __str__ = __unicode__ def add_labels(project, container, name): diff --git a/tests/integration/legacy_test.py b/tests/integration/legacy_test.py index 8b0a9b7fccc..f3c33e600b7 100644 --- a/tests/integration/legacy_test.py +++ b/tests/integration/legacy_test.py @@ -1,5 +1,3 @@ -import mock - from compose import legacy from compose.project import Project from .testcases import DockerClientTestCase @@ -49,9 +47,13 @@ def test_get_legacy_container_names_one_off(self): self.assertEqual(len(self.get_names(one_off=True)), 1) def test_migration_to_labels(self): - with mock.patch.object(legacy, 'log', autospec=True) as mock_log: + with self.assertRaises(legacy.LegacyContainersError) as cm: self.assertEqual(self.project.containers(stopped=True), []) - self.assertEqual(mock_log.warn.call_count, len(self.services)) + + self.assertEqual( + set(cm.exception.names), + set(['composetest_web_1', 'composetest_db_1']), + ) legacy.migrate_project_to_labels(self.project) self.assertEqual(len(self.project.containers(stopped=True)), len(self.services)) From 91ceb33d5a70ba54fcc87dd934d35daba77aa40c Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Tue, 26 May 2015 15:41:59 +0100 Subject: [PATCH 0827/4072] Update description of Compose "Define and run multi-container applications with Docker" Not just development environments, and "complex" is not clear and not really true. Signed-off-by: Ben Firshman --- README.md | 9 ++++----- compose/cli/main.py | 2 +- docs/index.md | 10 +++++----- 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 522488e7a69..acd3cbe7ae8 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,10 @@ Docker Compose ============== *(Previously known as Fig)* -Compose is a tool for defining and running complex applications with Docker. -With Compose, you define a multi-container application in a single file, then -spin your application up in a single command which does everything that needs to -be done to get it running. +Compose is a tool for defining and running multi-container applications with +Docker. With Compose, you define a multi-container application in a single +file, then spin your application up in a single command which does everything +that needs to be done to get it running. Compose is great for development environments, staging servers, and CI. We don't recommend that you use it in production yet. @@ -50,4 +50,3 @@ Contributing [![Build Status](http://jenkins.dockerproject.com/buildStatus/icon?job=Compose Master)](http://jenkins.dockerproject.com/job/Compose%20Master/) Want to help build Compose? Check out our [contributing documentation](https://github.com/docker/compose/blob/master/CONTRIBUTING.md). - diff --git a/compose/cli/main.py b/compose/cli/main.py index a2dca65db15..00fe3115ca2 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -72,7 +72,7 @@ def parse_doc_section(name, source): class TopLevelCommand(Command): - """Fast, isolated development environments using Docker. + """Define and run multi-container applications with Docker. Usage: docker-compose [options] [COMMAND] [ARGS...] diff --git a/docs/index.md b/docs/index.md index 44f56ae969d..981a02702fc 100644 --- a/docs/index.md +++ b/docs/index.md @@ -7,10 +7,10 @@ page_keywords: documentation, docs, docker, compose, orchestration, containers ## Overview -Compose is a tool for defining and running complex applications with Docker. -With Compose, you define a multi-container application in a single file, then -spin your application up in a single command which does everything that needs to -be done to get it running. +Compose is a tool for defining and running multi-container applications with +Docker. With Compose, you define a multi-container application in a single +file, then spin your application up in a single command which does everything +that needs to be done to get it running. Compose is great for development environments, staging servers, and CI. We don't recommend that you use it in production yet. @@ -200,7 +200,7 @@ At this point, you have seen the basics of how Compose works. [Rails](rails.md), or [Wordpress](wordpress.md). - See the reference guides for complete details on the [commands](cli.md), the [configuration file](yml.md) and [environment variables](env.md). - + ## Release Notes ### Version 1.2.0 (April 7, 2015) From 4795fd874f50254af3e9fb6cb60b25410b8456ab Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 21 May 2015 20:03:02 +0100 Subject: [PATCH 0828/4072] Fix regression in `docker-compose up` When an upstream dependency (e.g. a db) has a container but a downstream service (e.g. a web app) doesn't, a web container is not created on `docker-compose up`. Signed-off-by: Aanand Prasad --- compose/project.py | 7 +++++-- compose/service.py | 4 ---- tests/integration/project_test.py | 12 ++++++++++++ 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/compose/project.py b/compose/project.py index 6dc92668128..d3deeeaf966 100644 --- a/compose/project.py +++ b/compose/project.py @@ -248,10 +248,13 @@ def _get_convergence_plans(self, if updated_dependencies: log.debug( - '%s has not changed but its dependencies (%s) have, so recreating', + '%s has upstream changes (%s)', service.name, ", ".join(updated_dependencies), ) - plan = service.recreate_plan() + plan = service.convergence_plan( + allow_recreate=allow_recreate, + smart_recreate=False, + ) else: plan = service.convergence_plan( allow_recreate=allow_recreate, diff --git a/compose/service.py b/compose/service.py index c45a8bdfcde..ccfb3851183 100644 --- a/compose/service.py +++ b/compose/service.py @@ -294,10 +294,6 @@ def convergence_plan(self, return ConvergencePlan('recreate', containers) - def recreate_plan(self): - containers = self.containers(stopped=True) - return ConvergencePlan('recreate', containers) - def _containers_have_diverged(self, containers): config_hash = self.config_hash() has_diverged = False diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 5e3a40e5b71..2976af823b4 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -174,6 +174,18 @@ def test_project_up(self): project.kill() project.remove_stopped() + def test_project_up_starts_uncreated_services(self): + db = self.create_service('db') + web = self.create_service('web', links=[(db, 'db')]) + project = Project('composetest', [db, web], self.client) + project.up(['db']) + self.assertEqual(len(project.containers()), 1) + + project.up() + self.assertEqual(len(project.containers()), 2) + self.assertEqual(len(db.containers()), 1) + self.assertEqual(len(web.containers()), 1) + def test_project_up_recreates_containers(self): web = self.create_service('web') db = self.create_service('db', volumes=['/etc']) From 7da8e6be3b27bbd55a2c818ad1fc4a3bc07ac20a Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 21 May 2015 16:09:06 +0100 Subject: [PATCH 0829/4072] Migrate containers in dependency order This fixes a bug where migration would fail with an error if a downstream container was migrated before its upstream dependencies, due to `check_for_legacy_containers()` being implicitly called when we fetch `links`, `volumes_from` or `net` dependencies. Signed-off-by: Aanand Prasad --- compose/legacy.py | 37 ++++++++++++++++++-------------- tests/integration/legacy_test.py | 24 ++++++++++----------- 2 files changed, 32 insertions(+), 29 deletions(-) diff --git a/compose/legacy.py b/compose/legacy.py index af0c8700bcf..340511a7673 100644 --- a/compose/legacy.py +++ b/compose/legacy.py @@ -35,15 +35,15 @@ def check_for_legacy_containers( and warn the user that those containers may need to be migrated to using labels, so that compose can find them. """ - names = list(get_legacy_container_names( + containers = list(get_legacy_containers( client, project, services, stopped=stopped, one_off=one_off)) - if names: - raise LegacyContainersError(names) + if containers: + raise LegacyContainersError([c.name for c in containers]) class LegacyContainersError(Exception): @@ -61,8 +61,8 @@ def __unicode__(self): __str__ = __unicode__ -def add_labels(project, container, name): - project_name, service_name, one_off, number = NAME_RE.match(name).groups() +def add_labels(project, container): + project_name, service_name, one_off, number = NAME_RE.match(container.name).groups() if project_name != project.name or service_name not in project.service_names: return service = project.get_service(service_name) @@ -72,26 +72,31 @@ def add_labels(project, container, name): def migrate_project_to_labels(project): log.info("Running migration to labels for project %s", project.name) - client = project.client - for container in client.containers(all=True): - name = get_container_name(container) - if not is_valid_name(name): - continue - add_labels(project, Container.from_ps(client, container), name) + containers = get_legacy_containers( + project.client, + project.name, + project.service_names, + stopped=True, + one_off=False) + for container in containers: + add_labels(project, container) -def get_legacy_container_names( + +def get_legacy_containers( client, project, services, stopped=False, one_off=False): - for container in client.containers(all=stopped): - name = get_container_name(container) - for service in services: + containers = client.containers(all=stopped) + + for service in services: + for container in containers: + name = get_container_name(container) if has_container(project, service, name, one_off=one_off): - yield name + yield Container.from_ps(client, container) def has_container(project, service, name, one_off=False): diff --git a/tests/integration/legacy_test.py b/tests/integration/legacy_test.py index f3c33e600b7..6c52b68d33c 100644 --- a/tests/integration/legacy_test.py +++ b/tests/integration/legacy_test.py @@ -8,20 +8,21 @@ class ProjectTest(DockerClientTestCase): def setUp(self): super(ProjectTest, self).setUp() - self.services = [ - self.create_service('web'), - self.create_service('db'), - ] + db = self.create_service('db') + web = self.create_service('web', links=[(db, 'db')]) + nginx = self.create_service('nginx', links=[(web, 'web')]) + self.services = [db, web, nginx] self.project = Project('composetest', self.services, self.client) # Create a legacy container for each service for service in self.services: service.ensure_image_exists() - self.client.create_container( + container = self.client.create_container( name='{}_{}_1'.format(self.project.name, service.name), **service.options ) + self.client.start(container) # Create a single one-off legacy container self.client.create_container( @@ -29,11 +30,8 @@ def setUp(self): **self.services[0].options ) - def get_names(self, **kwargs): - if 'stopped' not in kwargs: - kwargs['stopped'] = True - - return list(legacy.get_legacy_container_names( + def get_legacy_containers(self, **kwargs): + return list(legacy.get_legacy_containers( self.client, self.project.name, [s.name for s in self.services], @@ -41,10 +39,10 @@ def get_names(self, **kwargs): )) def test_get_legacy_container_names(self): - self.assertEqual(len(self.get_names()), len(self.services)) + self.assertEqual(len(self.get_legacy_containers()), len(self.services)) def test_get_legacy_container_names_one_off(self): - self.assertEqual(len(self.get_names(one_off=True)), 1) + self.assertEqual(len(self.get_legacy_containers(stopped=True, one_off=True)), 1) def test_migration_to_labels(self): with self.assertRaises(legacy.LegacyContainersError) as cm: @@ -52,7 +50,7 @@ def test_migration_to_labels(self): self.assertEqual( set(cm.exception.names), - set(['composetest_web_1', 'composetest_db_1']), + set(['composetest_db_1', 'composetest_web_1', 'composetest_nginx_1']), ) legacy.migrate_project_to_labels(self.project) From b9c502531dbcde4e8e628fda260db08c3c908623 Mon Sep 17 00:00:00 2001 From: Todd Whiteman Date: Tue, 26 May 2015 12:25:52 -0700 Subject: [PATCH 0830/4072] Possible division by zero error when pulling an image - fixes #1463 Signed-off-by: Todd Whiteman --- compose/progress_stream.py | 5 +++-- tests/unit/progress_stream_test.py | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/compose/progress_stream.py b/compose/progress_stream.py index 39aab5ff7b5..317c6e81575 100644 --- a/compose/progress_stream.py +++ b/compose/progress_stream.py @@ -74,8 +74,9 @@ def print_output_event(event, stream, is_terminal): stream.write("%s %s%s" % (status, event['progress'], terminator)) elif 'progressDetail' in event: detail = event['progressDetail'] - if 'current' in detail: - percentage = float(detail['current']) / float(detail['total']) * 100 + total = detail.get('total') + if 'current' in detail and total: + percentage = float(detail['current']) / float(total) * 100 stream.write('%s (%.1f%%)%s' % (status, percentage, terminator)) else: stream.write('%s%s' % (status, terminator)) diff --git a/tests/unit/progress_stream_test.py b/tests/unit/progress_stream_test.py index 142560681f3..317b77e9f22 100644 --- a/tests/unit/progress_stream_test.py +++ b/tests/unit/progress_stream_test.py @@ -17,3 +17,21 @@ def test_stream_output(self): ] events = progress_stream.stream_output(output, StringIO()) self.assertEqual(len(events), 1) + + def test_stream_output_div_zero(self): + output = [ + '{"status": "Downloading", "progressDetail": {"current": ' + '0, "start": 1413653874, "total": 0}, ' + '"progress": "..."}', + ] + events = progress_stream.stream_output(output, StringIO()) + self.assertEqual(len(events), 1) + + def test_stream_output_null_total(self): + output = [ + '{"status": "Downloading", "progressDetail": {"current": ' + '0, "start": 1413653874, "total": null}, ' + '"progress": "..."}', + ] + events = progress_stream.stream_output(output, StringIO()) + self.assertEqual(len(events), 1) From ae63d356604aeef6be965c46e625c277964f483e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Martins?= Date: Tue, 26 May 2015 23:17:39 +0100 Subject: [PATCH 0831/4072] Modified scale awareness from exception to warning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: André Martins --- compose/cli/main.py | 12 ++---------- compose/service.py | 8 +++----- tests/integration/service_test.py | 5 ----- 3 files changed, 5 insertions(+), 20 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index a558e835935..f378f065f2a 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -13,7 +13,7 @@ from .. import __version__ from .. import legacy from ..project import NoSuchService, ConfigurationError -from ..service import BuildError, CannotBeScaledError, NeedsBuildError +from ..service import BuildError, NeedsBuildError from ..config import parse_environment from .command import Command from .docopt_command import NoSuchCommand @@ -372,15 +372,7 @@ def scale(self, project, options): except ValueError: raise UserError('Number of containers for service "%s" is not a ' 'number' % service_name) - try: - project.get_service(service_name).scale(num) - except CannotBeScaledError: - raise UserError( - 'Service "%s" cannot be scaled because it specifies a port ' - 'on the host. If multiple containers for this service were ' - 'created, the port would clash.\n\nRemove the ":" from the ' - 'port definition in docker-compose.yml so Docker can choose a random ' - 'port for each container.' % service_name) + project.get_service(service_name).scale(num) def start(self, project, options): """ diff --git a/compose/service.py b/compose/service.py index ccfb3851183..4875982705e 100644 --- a/compose/service.py +++ b/compose/service.py @@ -55,10 +55,6 @@ def __init__(self, service, reason): self.reason = reason -class CannotBeScaledError(Exception): - pass - - class ConfigError(ValueError): pass @@ -154,7 +150,9 @@ def scale(self, desired_num): - removes all stopped containers """ if not self.can_be_scaled(): - raise CannotBeScaledError() + log.warn('Service %s specifies a port on the host. If multiple containers ' + 'for this service are created on a single host, the port will clash.' + % self.name) # Create enough containers containers = self.containers(stopped=True) diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 8fd8212ced9..7e88557f932 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -17,7 +17,6 @@ LABEL_VERSION, ) from compose.service import ( - CannotBeScaledError, ConfigError, Service, build_extra_hosts, @@ -526,10 +525,6 @@ def test_scale(self): service.scale(0) self.assertEqual(len(service.containers()), 0) - def test_scale_on_service_that_cannot_be_scaled(self): - service = self.create_service('web', ports=['8000:8000']) - self.assertRaises(CannotBeScaledError, lambda: service.scale(1)) - def test_scale_sets_ports(self): service = self.create_service('web', ports=['8000']) service.scale(2) From 7d9aa8e0a9da9983b18a132d86ce9cdc377535e3 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 27 May 2015 15:13:12 +0100 Subject: [PATCH 0832/4072] Script to prepare OSX build environment Signed-off-by: Aanand Prasad --- CONTRIBUTING.md | 12 ++++++++---- script/build-osx | 2 +- script/prepare-osx | 22 ++++++++++++++++++++++ 3 files changed, 31 insertions(+), 5 deletions(-) create mode 100755 script/prepare-osx diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 373c8dc6f82..fddf888dc6b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -53,16 +53,20 @@ you can specify a test directory, file, module, class or method: ## Building binaries -Linux: +`script/build-linux` will build the Linux binary inside a Docker container: $ script/build-linux -OS X: +`script/build-osx` will build the Mac OS X binary inside a virtualenv: $ script/build-osx -Note that this only works on Mountain Lion, not Mavericks, due to a -[bug in PyInstaller](http://www.pyinstaller.org/ticket/807). +For official releases, you should build inside a Mountain Lion VM for proper +compatibility. Run the this script first to prepare the environment before +building - it will use Homebrew to make sure Python is installed and +up-to-date. + + $ script/prepare-osx ## Release process diff --git a/script/build-osx b/script/build-osx index 26309744ad2..6ad00bcdb79 100755 --- a/script/build-osx +++ b/script/build-osx @@ -1,7 +1,7 @@ #!/bin/bash set -ex rm -rf venv -virtualenv venv +virtualenv -p /usr/local/bin/python venv venv/bin/pip install -r requirements.txt venv/bin/pip install -r requirements-dev.txt venv/bin/pip install . diff --git a/script/prepare-osx b/script/prepare-osx new file mode 100755 index 00000000000..69ac56f1cd7 --- /dev/null +++ b/script/prepare-osx @@ -0,0 +1,22 @@ +#!/bin/bash + +set -ex + +if !(which brew); then + ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)" +fi + +brew update + +if [ ! -f /usr/local/bin/python ]; then + brew install python +fi + +if [ -n "$(brew outdated | grep python)" ]; then + brew upgrade python +fi + +if !(which virtualenv); then + pip install virtualenv +fi + From 686c25d50ff3822e0f1515cf6aa0c13de97a4368 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 27 May 2015 15:13:12 +0100 Subject: [PATCH 0833/4072] Script to prepare OSX build environment Signed-off-by: Aanand Prasad --- CONTRIBUTING.md | 12 ++++++++---- script/build-osx | 2 +- script/prepare-osx | 22 ++++++++++++++++++++++ 3 files changed, 31 insertions(+), 5 deletions(-) create mode 100755 script/prepare-osx diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 373c8dc6f82..fddf888dc6b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -53,16 +53,20 @@ you can specify a test directory, file, module, class or method: ## Building binaries -Linux: +`script/build-linux` will build the Linux binary inside a Docker container: $ script/build-linux -OS X: +`script/build-osx` will build the Mac OS X binary inside a virtualenv: $ script/build-osx -Note that this only works on Mountain Lion, not Mavericks, due to a -[bug in PyInstaller](http://www.pyinstaller.org/ticket/807). +For official releases, you should build inside a Mountain Lion VM for proper +compatibility. Run the this script first to prepare the environment before +building - it will use Homebrew to make sure Python is installed and +up-to-date. + + $ script/prepare-osx ## Release process diff --git a/script/build-osx b/script/build-osx index 26309744ad2..6ad00bcdb79 100755 --- a/script/build-osx +++ b/script/build-osx @@ -1,7 +1,7 @@ #!/bin/bash set -ex rm -rf venv -virtualenv venv +virtualenv -p /usr/local/bin/python venv venv/bin/pip install -r requirements.txt venv/bin/pip install -r requirements-dev.txt venv/bin/pip install . diff --git a/script/prepare-osx b/script/prepare-osx new file mode 100755 index 00000000000..69ac56f1cd7 --- /dev/null +++ b/script/prepare-osx @@ -0,0 +1,22 @@ +#!/bin/bash + +set -ex + +if !(which brew); then + ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)" +fi + +brew update + +if [ ! -f /usr/local/bin/python ]; then + brew install python +fi + +if [ -n "$(brew outdated | grep python)" ]; then + brew upgrade python +fi + +if !(which virtualenv); then + pip install virtualenv +fi + From ae9d619d8643a093735171e054588525cec32972 Mon Sep 17 00:00:00 2001 From: funkyfuture Date: Sun, 25 Jan 2015 00:16:37 +0100 Subject: [PATCH 0834/4072] Add command for Docker-style version information This adds a command 'version' to show software versions information like Docker does. In addition it includes: - version of the docker-py-package - Python-implementation and -version Signed-off-by: Frank Sachsenheim --- compose/cli/command.py | 2 +- compose/cli/main.py | 22 ++++++++++++++++++---- compose/cli/utils.py | 17 ++++++++++++++++- 3 files changed, 35 insertions(+), 6 deletions(-) diff --git a/compose/cli/command.py b/compose/cli/command.py index bd6b2dc8485..7858dfbc206 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -48,7 +48,7 @@ def dispatch(self, *args, **kwargs): raise errors.ConnectionErrorGeneric(self.get_client().base_url) def perform_command(self, options, handler, command_options): - if options['COMMAND'] == 'help': + if options['COMMAND'] in ('help', 'version'): # Skip looking up the compose file. handler(None, command_options) return diff --git a/compose/cli/main.py b/compose/cli/main.py index a558e835935..c4b33ca132a 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -10,8 +10,7 @@ from docker.errors import APIError import dockerpty -from .. import __version__ -from .. import legacy +from .. import __version__, legacy from ..project import NoSuchService, ConfigurationError from ..service import BuildError, CannotBeScaledError, NeedsBuildError from ..config import parse_environment @@ -20,7 +19,7 @@ from .errors import UserError from .formatter import Formatter from .log_printer import LogPrinter -from .utils import yesno +from .utils import yesno, get_version_info log = logging.getLogger(__name__) @@ -100,11 +99,12 @@ class TopLevelCommand(Command): stop Stop services up Create and start containers migrate-to-labels Recreate containers to add labels + version Show the Docker-Compose version information """ def docopt_options(self): options = super(TopLevelCommand, self).docopt_options() - options['version'] = "docker-compose %s" % __version__ + options['version'] = get_version_info('compose') return options def build(self, project, options): @@ -497,6 +497,20 @@ def migrate_to_labels(self, project, _options): """ legacy.migrate_project_to_labels(project) + def version(self, project, options): + """ + Show version informations + + Usage: version [--short] + + Options: + --short Shows only Compose's version number. + """ + if options['--short']: + print(__version__) + else: + print(get_version_info('full')) + def list_containers(containers): return ", ".join(c.name for c in containers) diff --git a/compose/cli/utils.py b/compose/cli/utils.py index 5f5fed64e20..489b52e2172 100644 --- a/compose/cli/utils.py +++ b/compose/cli/utils.py @@ -1,10 +1,13 @@ from __future__ import unicode_literals from __future__ import absolute_import from __future__ import division + +from .. import __version__ import datetime +from docker import version as docker_py_version import os -import subprocess import platform +import subprocess def yesno(prompt, default=None): @@ -120,3 +123,15 @@ def is_mac(): def is_ubuntu(): return platform.system() == 'Linux' and platform.linux_distribution()[0] == 'Ubuntu' + + +def get_version_info(scope): + versioninfo = 'docker-compose version: %s' % __version__ + if scope == 'compose': + return versioninfo + elif scope == 'full': + return versioninfo + '\n' \ + + "docker-py version: %s\n" % docker_py_version \ + + "%s version: %s" % (platform.python_implementation(), platform.python_version()) + else: + raise RuntimeError('passed unallowed value to `cli.utils.get_version_info`') From 5945db0fa8c230b8314354d5228a220899375a00 Mon Sep 17 00:00:00 2001 From: Ford Hurley Date: Thu, 28 May 2015 15:44:16 -0400 Subject: [PATCH 0835/4072] Fix markdown formatting for `--service-ports` example Signed-off-by: Ford Hurley --- docs/cli.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/cli.md b/docs/cli.md index e5594871d6c..9da12e69724 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -95,7 +95,9 @@ specify the `--no-deps` flag: Similarly, if you do want the service's ports to be created and mapped to the host, specify the `--service-ports` flag: - $ docker-compose run --service-ports web python manage.py shell + + $ docker-compose run --service-ports web python manage.py shell + ### scale From ec437313a7040fd970de328a655152c1be5f0c8e Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Fri, 29 May 2015 11:13:37 +0100 Subject: [PATCH 0836/4072] Change kill SIGINT test to use SIGSTOP I think the original intention of the original test was the check that different signals work, I think. This does this -- it sends a signal that doesn't cause the container to stop. Closes #759. Replaces #1467. Signed-off-by: Ben Firshman --- tests/integration/cli_test.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/integration/cli_test.py b/tests/integration/cli_test.py index 92789363e41..ae57e919c8c 100644 --- a/tests/integration/cli_test.py +++ b/tests/integration/cli_test.py @@ -360,22 +360,22 @@ def test_kill(self): self.assertEqual(len(service.containers(stopped=True)), 1) self.assertFalse(service.containers(stopped=True)[0].is_running) - def test_kill_signal_sigint(self): + def test_kill_signal_sigstop(self): self.command.dispatch(['up', '-d'], None) service = self.project.get_service('simple') self.assertEqual(len(service.containers()), 1) self.assertTrue(service.containers()[0].is_running) - self.command.dispatch(['kill', '-s', 'SIGINT'], None) + self.command.dispatch(['kill', '-s', 'SIGSTOP'], None) self.assertEqual(len(service.containers()), 1) - # The container is still running. It has been only interrupted + # The container is still running. It has only been paused self.assertTrue(service.containers()[0].is_running) - def test_kill_interrupted_service(self): + def test_kill_stopped_service(self): self.command.dispatch(['up', '-d'], None) service = self.project.get_service('simple') - self.command.dispatch(['kill', '-s', 'SIGINT'], None) + self.command.dispatch(['kill', '-s', 'SIGSTOP'], None) self.assertTrue(service.containers()[0].is_running) self.command.dispatch(['kill', '-s', 'SIGKILL'], None) From b3c1c9c954415c97c7d70a8f7e7f26827f09a96e Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Fri, 29 May 2015 13:17:18 +0200 Subject: [PATCH 0837/4072] Support --x-smart-recreate and -v in bash completion 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 e62b1d8fcf5..ba3dff35299 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -104,7 +104,7 @@ _docker-compose_docker-compose() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "--help -h --verbose --version --file -f --project-name -p" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--help -h --verbose --version -v --file -f --project-name -p" -- "$cur" ) ) ;; *) COMPREPLY=( $( compgen -W "${commands[*]}" -- "$cur" ) ) @@ -293,7 +293,7 @@ _docker-compose_up() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "--allow-insecure-ssl -d --no-build --no-color --no-deps --no-recreate -t --timeout" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--allow-insecure-ssl -d --no-build --no-color --no-deps --no-recreate -t --timeout --x-smart-recreate" -- "$cur" ) ) ;; *) __docker-compose_services_all From c128e881c16fd0ce1d6b99967754ca89d46d63a7 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Fri, 29 May 2015 13:07:19 +0100 Subject: [PATCH 0838/4072] Add build and dist to dockerignore These are the bulk of what gets sent in the build. Makes builds much faster, particularly remotely. Signed-off-by: Ben Firshman --- .dockerignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.dockerignore b/.dockerignore index f1b636b3ebd..a03616e534f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,2 +1,4 @@ .git +build +dist venv From a6bd1d22a0148481730c15b1f67f0ef1fb8dde4b Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Fri, 29 May 2015 13:23:42 +0100 Subject: [PATCH 0839/4072] Don't mount code in a volume when running tests An image is built anyway, so this is unnecessary. This makes it possible to run the tests on a remote Docker daemon. Signed-off-by: Ben Firshman --- script/test | 1 - 1 file changed, 1 deletion(-) diff --git a/script/test b/script/test index ab0645fdc1a..f278023a048 100755 --- a/script/test +++ b/script/test @@ -9,7 +9,6 @@ docker build -t "$TAG" . docker run \ --rm \ --volume="/var/run/docker.sock:/var/run/docker.sock" \ - --volume="$(pwd):/code" \ -e DOCKER_VERSIONS \ -e "TAG=$TAG" \ --entrypoint="script/test-versions" \ From bc8d5923e7f1ddb797b752c9e1edaa90d0c4d75e Mon Sep 17 00:00:00 2001 From: Steve Durrheimer Date: Wed, 22 Apr 2015 11:53:02 +0200 Subject: [PATCH 0840/4072] Zsh completion for docker-compose Signed-off-by: Steve Durrheimer --- CONTRIBUTING.md | 1 - contrib/completion/zsh/_docker-compose | 303 +++++++++++++++++++++++++ docs/completion.md | 37 ++- 3 files changed, 332 insertions(+), 9 deletions(-) create mode 100644 contrib/completion/zsh/_docker-compose diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fddf888dc6b..6914e21591b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -73,7 +73,6 @@ up-to-date. 1. Open pull request that: - Updates the version in `compose/__init__.py` - Updates the binary URL in `docs/install.md` - - Updates the script URL in `docs/completion.md` - Adds release notes to `CHANGES.md` 2. Create unpublished GitHub release with release notes 3. Build Linux version on any Docker host with `script/build-linux` and attach diff --git a/contrib/completion/zsh/_docker-compose b/contrib/completion/zsh/_docker-compose new file mode 100644 index 00000000000..9dc2789f2bf --- /dev/null +++ b/contrib/completion/zsh/_docker-compose @@ -0,0 +1,303 @@ +#compdef docker-compose + +# Description +# ----------- +# zsh completion for docker-compose +# https://github.com/sdurrheimer/docker-compose-zsh-completion +# ------------------------------------------------------------------------- +# Version +# ------- +# 0.1.0 +# ------------------------------------------------------------------------- +# Authors +# ------- +# * Steve Durrheimer +# ------------------------------------------------------------------------- +# Inspiration +# ----------- +# * @albers docker-compose bash completion script +# * @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 fig.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" +} + +# All services, even those without an existing container +__docker-compose_services_all() { + services=$(___docker-compose_all_services_in_compose_file) + _alternative "args:services:($services)" +} + +# All services that have an entry with the given key in their docker-compose.yml section +___docker-compose_services_with_key() { + local already_selected + 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" +} + +# All services that are defined by a Dockerfile reference +__docker-compose_services_from_build() { + buildable=$(___docker-compose_services_with_key build) + _alternative "args:buildable services:($buildable)" +} + +# All services that are defined by an image +__docker-compose_services_from_image() { + pullable=$(___docker-compose_services_with_key image) + _alternative "args:pullable services:($pullable)" +} + +__docker-compose_get_services() { + local kind expl + declare -a running stopped lines args services + + docker_status=$(docker ps > /dev/null 2>&1) + if [ $? -ne 0 ]; then + _message "Error! Docker is not running." + return 1 + fi + + kind=$1 + shift + [[ $kind = (stopped|all) ]] && args=($args -a) + + lines=(${(f)"$(_call_program commands docker ps ${args})"}) + services=(${(f)"$(_call_program commands docker-compose 2>/dev/null ${compose_file:+-f $compose_file} ${compose_project:+-p $compose_project} ps -q)"}) + + # Parse header line to find columns + local i=1 j=1 k header=${lines[1]} + declare -A begin end + while (( $j < ${#header} - 1 )) { + i=$(( $j + ${${header[$j,-1]}[(i)[^ ]]} - 1)) + j=$(( $i + ${${header[$i,-1]}[(i) ]} - 1)) + k=$(( $j + ${${header[$j,-1]}[(i)[^ ]]} - 2)) + begin[${header[$i,$(($j-1))]}]=$i + end[${header[$i,$(($j-1))]}]=$k + } + lines=(${lines[2,-1]}) + + # Container ID + local line s name + local -a names + for line in $lines; do + if [[ $services == *"${line[${begin[CONTAINER ID]},${end[CONTAINER ID]}]%% ##}"* ]]; then + names=(${(ps:,:)${${line[${begin[NAMES]},-1]}%% *}}) + for name in $names; do + s="${${name%_*}#*_}:${(l:15:: :::)${${line[${begin[CREATED]},${end[CREATED]}]/ ago/}%% ##}}" + s="$s, ${line[${begin[CONTAINER ID]},${end[CONTAINER ID]}]%% ##}" + s="$s, ${${${line[$begin[IMAGE],$end[IMAGE]]}/:/\\:}%% ##}" + if [[ ${line[${begin[STATUS]},${end[STATUS]}]} = Exit* ]]; then + stopped=($stopped $s) + else + running=($running $s) + fi + done + fi + done + + [[ $kind = (running|all) ]] && _describe -t services-running "running services" running + [[ $kind = (stopped|all) ]] && _describe -t services-stopped "stopped services" stopped +} + +__docker-compose_stoppedservices() { + __docker-compose_get_services stopped "$@" +} + +__docker-compose_runningservices() { + __docker-compose_get_services running "$@" +} + +__docker-compose_services () { + __docker-compose_get_services all "$@" +} + +__docker-compose_caching_policy() { + oldp=( "$1"(Nmh+1) ) # 1 hour + (( $#oldp )) +} + +__docker-compose_commands () { + local cache_policy + + zstyle -s ":completion:${curcontext}:" cache-policy cache_policy + if [[ -z "$cache_policy" ]]; then + zstyle ":completion:${curcontext}:" cache-policy __docker-compose_caching_policy + fi + + if ( [[ ${+_docker_compose_subcommands} -eq 0 ]] || _cache_invalid docker_compose_subcommands) \ + && ! _retrieve_cache docker_compose_subcommands; + then + 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 + fi + _describe -t docker-compose-commands "docker-compose command" _docker_compose_subcommands +} + +__docker-compose_subcommand () { + local -a _command_args + integer ret=1 + case "$words[1]" in + (build) + _arguments \ + '--no-cache[Do not use cache when building the image]' \ + '*:services:__docker-compose_services_from_build' && ret=0 + ;; + (help) + _arguments ':subcommand:__docker-compose_commands' && ret=0 + ;; + (kill) + _arguments \ + '-s[SIGNAL to send to the container. Default signal is SIGKILL.]:signal:_signals' \ + '*:running services:__docker-compose_runningservices' && ret=0 + ;; + (logs) + _arguments \ + '--no-color[Produce monochrome output.]' \ + '*:services:__docker-compose_services_all' && ret=0 + ;; + (migrate-to-labels) + _arguments \ + '(-):Recreate containers to add labels' && ret=0 + ;; + (port) + _arguments \ + '--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: ' \ + '1:running services:__docker-compose_runningservices' \ + '2:port:_ports' && ret=0 + ;; + (ps) + _arguments \ + '-q[Only display IDs]' \ + '*:services:__docker-compose_services_all' && ret=0 + ;; + (pull) + _arguments \ + '--allow-insecure-ssl[Allow insecure connections to the docker registry]' \ + '*:services:__docker-compose_services_from_image' && ret=0 + ;; + (rm) + _arguments \ + '(-f --force)'{-f,--force}"[Don't ask to confirm removal]" \ + '-v[Remove volumes associated with containers]' \ + '*:stopped services:__docker-compose_stoppedservices' && ret=0 + ;; + (run) + _arguments \ + '--allow-insecure-ssl[Allow insecure connections to the docker registry]' \ + '-d[Detached mode: Run container in the background, print new 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' \ + "--no-deps[Don't start linked services.]" \ + '--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.]' \ + '(-):services:__docker-compose_services' \ + '(-):command: _command_names -e' \ + '*::arguments: _normal' && ret=0 + ;; + (scale) + _arguments '*:running services:__docker-compose_runningservices' && ret=0 + ;; + (start) + _arguments '*:stopped services:__docker-compose_stoppedservices' && ret=0 + ;; + (stop|restart) + _arguments \ + '(-t --timeout)'{-t,--timeout}"[Specify a shutdown timeout in seconds. (default: 10)]:seconds: " \ + '*:running services:__docker-compose_runningservices' && ret=0 + ;; + (up) + _arguments \ + '--allow-insecure-ssl[Allow insecure connections to the docker registry]' \ + '-d[Detached mode: Run containers in the background, print new container names.]' \ + '--no-color[Produce monochrome output.]' \ + "--no-deps[Don't start linked services.]" \ + "--no-recreate[If containers already exist, don't recreate them.]" \ + "--no-build[Don't build an image, even if it's missing]" \ + '(-t --timeout)'{-t,--timeout}"[Specify a shutdown timeout in seconds. (default: 10)]:seconds: " \ + '*:services:__docker-compose_services_all' && ret=0 + ;; + (*) + _message 'Unknown sub command' + esac + + return ret +} + +_docker-compose () { + # Support for subservices, which allows for `compdef _docker docker-shell=_docker_containers`. + # Based on /usr/share/zsh/functions/Completion/Unix/_git without support for `ret`. + if [[ $service != docker-compose ]]; then + _call_function - _$service + return + fi + + local curcontext="$curcontext" state line ret=1 + typeset -A opt_args + + _arguments -C \ + '(- :)'{-h,--help}'[Get help]' \ + '--verbose[Show more output]' \ + '(- :)--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:' \ + '(-): :->command' \ + '(-)*:: :->option-or-argument' && ret=0 + + local counter=1 + #local compose_file compose_project + while [ $counter -lt ${#words[@]} ]; do + case "${words[$counter]}" in + -f|--file) + (( counter++ )) + compose_file="${words[$counter]}" + ;; + -p|--project-name) + (( counter++ )) + compose_project="${words[$counter]}" + ;; + *) + ;; + esac + (( counter++ )) + done + + case $state in + (command) + __docker-compose_commands && ret=0 + ;; + (option-or-argument) + curcontext=${curcontext%:*:*}:docker-compose-$words[1]: + __docker-compose_subcommand && ret=0 + ;; + esac + + return ret +} + +_docker-compose "$@" diff --git a/docs/completion.md b/docs/completion.md index 35c53b55fee..5168971f8b0 100644 --- a/docs/completion.md +++ b/docs/completion.md @@ -3,23 +3,44 @@ layout: default title: Command Completion --- -#Command Completion +# Command Completion Compose comes with [command completion](http://en.wikipedia.org/wiki/Command-line_completion) -for the bash shell. +for the bash and zsh shell. -##Installing Command Completion +## 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/1.2.0/contrib/completion/bash/docker-compose > /etc/bash_completion.d/docker-compose - +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 | awk '{print $2}')/contrib/completion/bash/docker-compose > /etc/bash_completion.d/docker-compose + Completion will be available upon next login. -##Available completions +### 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 | awk '{print $2}')/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 From 1d5526c71db6e9696eb8d613ca2d0b92bde5908b Mon Sep 17 00:00:00 2001 From: Steve Durrheimer Date: Fri, 29 May 2015 14:23:37 +0200 Subject: [PATCH 0841/4072] Support --x-smart-recreate and -v in zsh completion 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 9dc2789f2bf..31052e1e0a9 100644 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -240,6 +240,7 @@ __docker-compose_subcommand () { "--no-recreate[If containers already exist, don't recreate them.]" \ "--no-build[Don't build an image, even if it's missing]" \ '(-t --timeout)'{-t,--timeout}"[Specify a shutdown timeout in seconds. (default: 10)]:seconds: " \ + "--x-smart-recreate[Only recreate containers whose configuration or image needs to be updated. (EXPERIMENTAL)]" \ '*:services:__docker-compose_services_all' && ret=0 ;; (*) @@ -263,7 +264,7 @@ _docker-compose () { _arguments -C \ '(- :)'{-h,--help}'[Get help]' \ '--verbose[Show more output]' \ - '(- :)--version[Print version and exit]' \ + '(- :)'{-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:' \ '(-): :->command' \ From c571bb485d62fd9d55657428b4476a5c2d47ce5e Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 28 May 2015 17:18:04 +0100 Subject: [PATCH 0842/4072] Report Python and OpenSSL versions in --version output Signed-off-by: Aanand Prasad --- compose/cli/utils.py | 4 +++- script/build-linux-inner | 2 +- script/build-osx | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/compose/cli/utils.py b/compose/cli/utils.py index 489b52e2172..7f2ba2e0dd5 100644 --- a/compose/cli/utils.py +++ b/compose/cli/utils.py @@ -8,6 +8,7 @@ import os import platform import subprocess +import ssl def yesno(prompt, default=None): @@ -132,6 +133,7 @@ def get_version_info(scope): elif scope == 'full': return versioninfo + '\n' \ + "docker-py version: %s\n" % docker_py_version \ - + "%s version: %s" % (platform.python_implementation(), platform.python_version()) + + "%s version: %s\n" % (platform.python_implementation(), platform.python_version()) \ + + "OpenSSL version: %s" % ssl.OPENSSL_VERSION else: raise RuntimeError('passed unallowed value to `cli.utils.get_version_info`') diff --git a/script/build-linux-inner b/script/build-linux-inner index 34b0c06fd5d..adc030eaa87 100755 --- a/script/build-linux-inner +++ b/script/build-linux-inner @@ -7,4 +7,4 @@ chmod 777 `pwd`/dist pyinstaller -F bin/docker-compose mv dist/docker-compose dist/docker-compose-Linux-x86_64 -dist/docker-compose-Linux-x86_64 --version +dist/docker-compose-Linux-x86_64 version diff --git a/script/build-osx b/script/build-osx index 6ad00bcdb79..78a18294fbf 100755 --- a/script/build-osx +++ b/script/build-osx @@ -7,4 +7,4 @@ venv/bin/pip install -r requirements-dev.txt venv/bin/pip install . venv/bin/pyinstaller -F bin/docker-compose mv dist/docker-compose dist/docker-compose-Darwin-x86_64 -dist/docker-compose-Darwin-x86_64 --version +dist/docker-compose-Darwin-x86_64 version From 8ad11c0bc870b5d9c94b9f0d9212eb15637ed3f7 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 28 May 2015 17:24:03 +0100 Subject: [PATCH 0843/4072] Make sure we use Python 2.7.9 and OpenSSL 1.0.1 when building OSX binary Signed-off-by: Aanand Prasad --- script/build-osx | 3 +++ script/prepare-osx | 39 +++++++++++++++++++++++++++++++++++---- 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/script/build-osx b/script/build-osx index 78a18294fbf..2a9cf512ef9 100755 --- a/script/build-osx +++ b/script/build-osx @@ -1,5 +1,8 @@ #!/bin/bash set -ex + +PATH="/usr/local/bin:$PATH" + rm -rf venv virtualenv -p /usr/local/bin/python venv venv/bin/pip install -r requirements.txt diff --git a/script/prepare-osx b/script/prepare-osx index 69ac56f1cd7..ca2776b6417 100755 --- a/script/prepare-osx +++ b/script/prepare-osx @@ -2,20 +2,51 @@ set -ex +python_version() { + python -V 2>&1 +} + +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_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" + +PATH="/usr/local/bin:$PATH" + if !(which brew); then ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)" fi brew update -if [ ! -f /usr/local/bin/python ]; then - brew install python +if !(python_version | grep "$desired_python_version"); then + if brew list | grep python; then + brew unlink python + fi + + brew install "$python_formula" + brew switch python "$desired_python_brew_version" fi -if [ -n "$(brew outdated | grep python)" ]; then - brew upgrade python +if !(openssl_version | grep "$desired_openssl_version"); then + if brew list | grep openssl; then + brew unlink openssl + fi + + brew install "$openssl_formula" + brew switch openssl "$desired_openssl_brew_version" fi +echo "*** Using $(python_version)" +echo "*** Using $(openssl_version)" + if !(which virtualenv); then pip install virtualenv fi From 25942820820fcd8ed3fbd33dde2dcb24005ef997 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 1 Jun 2015 14:01:30 +0100 Subject: [PATCH 0844/4072] Build Python 2.7.9 in Docker image Signed-off-by: Aanand Prasad --- Dockerfile | 39 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index b2ae0063c8b..fca5f980315 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,9 +3,11 @@ FROM debian:wheezy RUN set -ex; \ apt-get update -qq; \ apt-get install -y \ - python \ - python-pip \ - python-dev \ + gcc \ + make \ + zlib1g \ + zlib1g-dev \ + libssl-dev \ git \ apt-transport-https \ ca-certificates \ @@ -15,6 +17,37 @@ RUN set -ex; \ ; \ rm -rf /var/lib/apt/lists/* +# Build Python 2.7.9 from source +RUN set -ex; \ + curl -LO https://www.python.org/ftp/python/2.7.9/Python-2.7.9.tgz; \ + tar -xzf Python-2.7.9.tgz; \ + cd Python-2.7.9; \ + ./configure --enable-shared; \ + make; \ + make install; \ + cd ..; \ + rm -rf /Python-2.7.9; \ + rm Python-2.7.9.tgz + +# Make libpython findable +ENV LD_LIBRARY_PATH /usr/local/lib + +# Install setuptools +RUN set -ex; \ + curl -LO https://bootstrap.pypa.io/ez_setup.py; \ + python ez_setup.py; \ + rm ez_setup.py + +# Install pip +RUN set -ex; \ + curl -LO https://pypi.python.org/packages/source/p/pip/pip-7.0.1.tar.gz; \ + tar -xzf pip-7.0.1.tar.gz; \ + cd pip-7.0.1; \ + python setup.py install; \ + cd ..; \ + rm -rf pip-7.0.1; \ + rm pip-7.0.1.tar.gz + ENV ALL_DOCKER_VERSIONS 1.6.0 RUN set -ex; \ From 77409737ceed62674fb0afeb9132204c3861b012 Mon Sep 17 00:00:00 2001 From: Steve Durrheimer Date: Tue, 2 Jun 2015 12:38:23 +0200 Subject: [PATCH 0845/4072] Support version command in zsh completion Signed-off-by: Steve Durrheimer --- contrib/completion/zsh/_docker-compose | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/contrib/completion/zsh/_docker-compose b/contrib/completion/zsh/_docker-compose index 31052e1e0a9..19c06675ad5 100644 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -243,6 +243,10 @@ __docker-compose_subcommand () { "--x-smart-recreate[Only recreate containers whose configuration or image needs to be updated. (EXPERIMENTAL)]" \ '*:services:__docker-compose_services_all' && ret=0 ;; + (version) + _arguments \ + "--short[Shows only Compose's version number.]" && ret=0 + ;; (*) _message 'Unknown sub command' esac From 93a846db318bbf7e332db39f0ed7a764053948d6 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 28 May 2015 17:18:04 +0100 Subject: [PATCH 0846/4072] Report Python and OpenSSL versions in --version output Signed-off-by: Aanand Prasad Conflicts: compose/cli/utils.py --- compose/cli/main.py | 5 ++--- compose/cli/utils.py | 11 +++++++++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index a558e835935..61f3ec3f918 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -10,7 +10,6 @@ from docker.errors import APIError import dockerpty -from .. import __version__ from .. import legacy from ..project import NoSuchService, ConfigurationError from ..service import BuildError, CannotBeScaledError, NeedsBuildError @@ -20,7 +19,7 @@ from .errors import UserError from .formatter import Formatter from .log_printer import LogPrinter -from .utils import yesno +from .utils import get_version_info, yesno log = logging.getLogger(__name__) @@ -104,7 +103,7 @@ class TopLevelCommand(Command): """ def docopt_options(self): options = super(TopLevelCommand, self).docopt_options() - options['version'] = "docker-compose %s" % __version__ + options['version'] = get_version_info() return options def build(self, project, options): diff --git a/compose/cli/utils.py b/compose/cli/utils.py index 5f5fed64e20..93b991038e1 100644 --- a/compose/cli/utils.py +++ b/compose/cli/utils.py @@ -5,6 +5,9 @@ import os import subprocess import platform +import ssl + +from .. import __version__ def yesno(prompt, default=None): @@ -120,3 +123,11 @@ def is_mac(): def is_ubuntu(): return platform.system() == 'Linux' and platform.linux_distribution()[0] == 'Ubuntu' + + +def get_version_info(): + return '\n'.join([ + 'docker-compose version: %s' % __version__, + "%s version: %s" % (platform.python_implementation(), platform.python_version()), + "OpenSSL version: %s" % ssl.OPENSSL_VERSION, + ]) From f3d0c63db2621a7bbe77164a23d11d3530bd5d19 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 28 May 2015 17:24:03 +0100 Subject: [PATCH 0847/4072] Make sure we use Python 2.7.9 and OpenSSL 1.0.1 when building OSX binary Signed-off-by: Aanand Prasad --- script/build-osx | 3 +++ script/prepare-osx | 39 +++++++++++++++++++++++++++++++++++---- 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/script/build-osx b/script/build-osx index 6ad00bcdb79..d6561aeead5 100755 --- a/script/build-osx +++ b/script/build-osx @@ -1,5 +1,8 @@ #!/bin/bash set -ex + +PATH="/usr/local/bin:$PATH" + rm -rf venv virtualenv -p /usr/local/bin/python venv venv/bin/pip install -r requirements.txt diff --git a/script/prepare-osx b/script/prepare-osx index 69ac56f1cd7..ca2776b6417 100755 --- a/script/prepare-osx +++ b/script/prepare-osx @@ -2,20 +2,51 @@ set -ex +python_version() { + python -V 2>&1 +} + +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_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" + +PATH="/usr/local/bin:$PATH" + if !(which brew); then ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)" fi brew update -if [ ! -f /usr/local/bin/python ]; then - brew install python +if !(python_version | grep "$desired_python_version"); then + if brew list | grep python; then + brew unlink python + fi + + brew install "$python_formula" + brew switch python "$desired_python_brew_version" fi -if [ -n "$(brew outdated | grep python)" ]; then - brew upgrade python +if !(openssl_version | grep "$desired_openssl_version"); then + if brew list | grep openssl; then + brew unlink openssl + fi + + brew install "$openssl_formula" + brew switch openssl "$desired_openssl_brew_version" fi +echo "*** Using $(python_version)" +echo "*** Using $(openssl_version)" + if !(which virtualenv); then pip install virtualenv fi From 8749bc08443ddb344ecf683e796cdb2d814b7f68 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 1 Jun 2015 14:01:30 +0100 Subject: [PATCH 0848/4072] Build Python 2.7.9 in Docker image Signed-off-by: Aanand Prasad --- Dockerfile | 39 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index b2ae0063c8b..fca5f980315 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,9 +3,11 @@ FROM debian:wheezy RUN set -ex; \ apt-get update -qq; \ apt-get install -y \ - python \ - python-pip \ - python-dev \ + gcc \ + make \ + zlib1g \ + zlib1g-dev \ + libssl-dev \ git \ apt-transport-https \ ca-certificates \ @@ -15,6 +17,37 @@ RUN set -ex; \ ; \ rm -rf /var/lib/apt/lists/* +# Build Python 2.7.9 from source +RUN set -ex; \ + curl -LO https://www.python.org/ftp/python/2.7.9/Python-2.7.9.tgz; \ + tar -xzf Python-2.7.9.tgz; \ + cd Python-2.7.9; \ + ./configure --enable-shared; \ + make; \ + make install; \ + cd ..; \ + rm -rf /Python-2.7.9; \ + rm Python-2.7.9.tgz + +# Make libpython findable +ENV LD_LIBRARY_PATH /usr/local/lib + +# Install setuptools +RUN set -ex; \ + curl -LO https://bootstrap.pypa.io/ez_setup.py; \ + python ez_setup.py; \ + rm ez_setup.py + +# Install pip +RUN set -ex; \ + curl -LO https://pypi.python.org/packages/source/p/pip/pip-7.0.1.tar.gz; \ + tar -xzf pip-7.0.1.tar.gz; \ + cd pip-7.0.1; \ + python setup.py install; \ + cd ..; \ + rm -rf pip-7.0.1; \ + rm pip-7.0.1.tar.gz + ENV ALL_DOCKER_VERSIONS 1.6.0 RUN set -ex; \ From be92b79b4200f66e15d5913589b73de03efb6020 Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Wed, 3 Jun 2015 13:25:26 -0700 Subject: [PATCH 0849/4072] Support version command in Bash completion Signed-off-by: Harald Albers --- contrib/completion/bash/docker-compose | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index ba3dff35299..ad636f5f517 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -302,6 +302,15 @@ _docker-compose_up() { } +_docker-compose_version() { + case "$cur" in + -*) + COMPREPLY=( $( compgen -W "--short" -- "$cur" ) ) + ;; + esac +} + + _docker-compose() { local previous_extglob_setting=$(shopt -p extglob) shopt -s extglob @@ -322,6 +331,7 @@ _docker-compose() { start stop up + version ) COMPREPLY=() From cfcc12692f4aac4aa868941028a4dfe47ac88289 Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Wed, 3 Jun 2015 22:59:12 +0200 Subject: [PATCH 0850/4072] Update dockerproject.com links The dockerproject.com domain is moving to dockerproject.org this changes the buildstatus link to point to the new domain. Signed-off-by: Sebastiaan van Stijn --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index acd3cbe7ae8..4b18fc9dc14 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,6 @@ Installation and documentation Contributing ------------ -[![Build Status](http://jenkins.dockerproject.com/buildStatus/icon?job=Compose Master)](http://jenkins.dockerproject.com/job/Compose%20Master/) +[![Build Status](http://jenkins.dockerproject.org/buildStatus/icon?job=Compose%20Master)](http://jenkins.dockerproject.org/job/Compose%20Master/) Want to help build Compose? Check out our [contributing documentation](https://github.com/docker/compose/blob/master/CONTRIBUTING.md). From 2527ef8055410ee5100631da807ed650ec64c36f Mon Sep 17 00:00:00 2001 From: dano Date: Sat, 6 Jun 2015 15:12:13 -0400 Subject: [PATCH 0851/4072] Validate that service names passed to Project.containers aren't bogus. Signed-off-by: Dan O'Reilly --- compose/project.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/compose/project.py b/compose/project.py index d3deeeaf966..e6ec6d6768d 100644 --- a/compose/project.py +++ b/compose/project.py @@ -274,6 +274,9 @@ def remove_stopped(self, service_names=None, **options): service.remove_stopped(**options) def containers(self, service_names=None, stopped=False, one_off=False): + if service_names: + # Will raise NoSuchService if one of the names is invalid + self.get_services(service_names) containers = [ Container.from_ps(self.client, container) for container in self.client.containers( From f59b43ac27d93e8222bd2cfaef6439a6b233d504 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 8 Jun 2015 10:51:46 -0400 Subject: [PATCH 0852/4072] Fix duplicate logging on up/run Signed-off-by: Aanand Prasad --- compose/cli/main.py | 1 + compose/service.py | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 482794cc968..7a7dff510ed 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -336,6 +336,7 @@ def run(self, project, options): container_options['ports'] = [] container = service.create_container( + quiet=True, one_off=True, insecure_registry=insecure_registry, **container_options diff --git a/compose/service.py b/compose/service.py index 4875982705e..5d0d171d805 100644 --- a/compose/service.py +++ b/compose/service.py @@ -197,6 +197,7 @@ def create_container(self, do_build=True, previous_container=None, number=None, + quiet=False, **override_options): """ Create a container for this service. If the image doesn't exist, attempt to pull @@ -214,7 +215,7 @@ def create_container(self, previous_container=previous_container, ) - if 'name' in container_options: + if 'name' in container_options and not quiet: log.info("Creating %s..." % container_options['name']) return Container.create(self.client, **container_options) @@ -376,6 +377,7 @@ def recreate_container(self, do_build=False, previous_container=container, number=container.labels.get(LABEL_CONTAINER_NUMBER), + quiet=True, ) self.start_container(new_container) container.remove() From db2d02dc0bc7f24a5f631ff39cade1ba2acab0c7 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 8 Jun 2015 10:58:11 -0400 Subject: [PATCH 0853/4072] Remove Service.start_or_create_containers() It's only used in a single test method. Signed-off-by: Aanand Prasad --- compose/service.py | 15 --------------- tests/integration/service_test.py | 4 ++-- 2 files changed, 2 insertions(+), 17 deletions(-) diff --git a/compose/service.py b/compose/service.py index 4875982705e..3d8304ee682 100644 --- a/compose/service.py +++ b/compose/service.py @@ -392,21 +392,6 @@ def start_container(self, container): container.start() return container - def start_or_create_containers( - self, - insecure_registry=False, - do_build=True): - containers = self.containers(stopped=True) - - if not containers: - new_container = self.create_container( - insecure_registry=insecure_registry, - do_build=do_build, - ) - return [self.start_container(new_container)] - else: - return [self.start_container_if_stopped(c) for c in containers] - def config_hash(self): return json_hash(self.config_dict()) diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 7e88557f932..32de5fa4780 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -501,10 +501,10 @@ def test_port_with_explicit_interface(self): ], }) - def test_start_with_image_id(self): + def test_create_with_image_id(self): # Image id for the current busybox:latest service = self.create_service('foo', image='8c2e06607696') - self.assertTrue(service.start_or_create_containers()) + service.create_container() def test_scale(self): service = self.create_service('web') From b6a7db787ffa8cd5f871610e65a3adf2474badf6 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 8 Jun 2015 11:16:00 -0400 Subject: [PATCH 0854/4072] Remove logging on run --rm Signed-off-by: Aanand Prasad --- compose/cli/main.py | 1 - 1 file changed, 1 deletion(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 482794cc968..80dec699885 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -348,7 +348,6 @@ def run(self, project, options): dockerpty.start(project.client, container.id, interactive=not options['-T']) exit_code = container.wait() if options['--rm']: - log.info("Removing %s..." % container.name) project.client.remove_container(container.id) sys.exit(exit_code) From ce880af8215a9c07f91b6d55edf0d4aadd4a610f Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 8 Jun 2015 16:56:14 -0400 Subject: [PATCH 0855/4072] Update dockerpty to 0.3.4 Signed-off-by: Aanand Prasad --- requirements.txt | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index b939884801e..d3909b766ff 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ PyYAML==3.10 docker-py==1.2.2 -dockerpty==0.3.3 +dockerpty==0.3.4 docopt==0.6.1 requests==2.6.1 six==1.7.3 diff --git a/setup.py b/setup.py index 153275f69de..9364f57f39e 100644 --- a/setup.py +++ b/setup.py @@ -31,7 +31,7 @@ def find_version(*file_paths): 'texttable >= 0.8.1, < 0.9', 'websocket-client >= 0.11.0, < 1.0', 'docker-py >= 1.2.2, < 1.3', - 'dockerpty >= 0.3.3, < 0.4', + 'dockerpty >= 0.3.4, < 0.4', 'six >= 1.3.0, < 2', ] From c59c9dd95159036a82a9a6ce8d50a4cadc2f9e07 Mon Sep 17 00:00:00 2001 From: Dan O'Reilly Date: Mon, 8 Jun 2015 17:04:42 -0400 Subject: [PATCH 0856/4072] Add integration test for service name verification Add a test to make sure NoSuchService is raised if a bogus service name is given to 'docker-compose logs'. Signed-off-by: Dan O'Reilly --- tests/integration/cli_test.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/integration/cli_test.py b/tests/integration/cli_test.py index ae57e919c8c..a5289ef814d 100644 --- a/tests/integration/cli_test.py +++ b/tests/integration/cli_test.py @@ -7,6 +7,7 @@ from .testcases import DockerClientTestCase from compose.cli.main import TopLevelCommand +from compose.project import NoSuchService class CLITestCase(DockerClientTestCase): @@ -349,6 +350,10 @@ def test_stop(self): self.assertEqual(len(service.containers(stopped=True)), 1) self.assertFalse(service.containers(stopped=True)[0].is_running) + def test_logs_invalid_service_name(self): + with self.assertRaises(NoSuchService): + self.command.dispatch(['logs', 'madeupname'], None) + def test_kill(self): self.command.dispatch(['up', '-d'], None) service = self.project.get_service('simple') From ff151c8ea04268d2060cf8d281294a0d500ecbba Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 9 Jun 2015 12:54:59 -0400 Subject: [PATCH 0857/4072] Test that data volumes now survive a crash when recreating Signed-off-by: Aanand Prasad --- tests/integration/resilience_test.py | 37 ++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 tests/integration/resilience_test.py diff --git a/tests/integration/resilience_test.py b/tests/integration/resilience_test.py new file mode 100644 index 00000000000..8229e9d3c67 --- /dev/null +++ b/tests/integration/resilience_test.py @@ -0,0 +1,37 @@ +from __future__ import unicode_literals +from __future__ import absolute_import + +import mock + +from compose.project import Project +from .testcases import DockerClientTestCase + + +class ResilienceTest(DockerClientTestCase): + def test_recreate_fails(self): + db = self.create_service('db', volumes=['/var/db'], command='top') + project = Project('composetest', [db], self.client) + + container = db.create_container() + db.start_container(container) + host_path = container.get('Volumes')['/var/db'] + + project.up() + container = db.containers()[0] + self.assertEqual(container.get('Volumes')['/var/db'], host_path) + + with mock.patch('compose.service.Service.create_container', crash): + with self.assertRaises(Crash): + project.up() + + project.up() + container = db.containers()[0] + self.assertEqual(container.get('Volumes')['/var/db'], host_path) + + +class Crash(Exception): + pass + + +def crash(*args, **kwargs): + raise Crash() From 5a5bffebd178670e602e2e9ea8c177bc32ef62b5 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 27 May 2015 12:49:58 +0100 Subject: [PATCH 0858/4072] Merge pull request #1464 from twhiteman/bug1461 Possible division by zero error when pulling an image - fixes #1463 (cherry picked from commit d0e87929a1f39b4e98c2c8497f3f0ffc09fb9e43) Signed-off-by: Aanand Prasad --- compose/progress_stream.py | 5 +++-- tests/unit/progress_stream_test.py | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/compose/progress_stream.py b/compose/progress_stream.py index 39aab5ff7b5..317c6e81575 100644 --- a/compose/progress_stream.py +++ b/compose/progress_stream.py @@ -74,8 +74,9 @@ def print_output_event(event, stream, is_terminal): stream.write("%s %s%s" % (status, event['progress'], terminator)) elif 'progressDetail' in event: detail = event['progressDetail'] - if 'current' in detail: - percentage = float(detail['current']) / float(detail['total']) * 100 + total = detail.get('total') + if 'current' in detail and total: + percentage = float(detail['current']) / float(total) * 100 stream.write('%s (%.1f%%)%s' % (status, percentage, terminator)) else: stream.write('%s%s' % (status, terminator)) diff --git a/tests/unit/progress_stream_test.py b/tests/unit/progress_stream_test.py index 142560681f3..317b77e9f22 100644 --- a/tests/unit/progress_stream_test.py +++ b/tests/unit/progress_stream_test.py @@ -17,3 +17,21 @@ def test_stream_output(self): ] events = progress_stream.stream_output(output, StringIO()) self.assertEqual(len(events), 1) + + def test_stream_output_div_zero(self): + output = [ + '{"status": "Downloading", "progressDetail": {"current": ' + '0, "start": 1413653874, "total": 0}, ' + '"progress": "..."}', + ] + events = progress_stream.stream_output(output, StringIO()) + self.assertEqual(len(events), 1) + + def test_stream_output_null_total(self): + output = [ + '{"status": "Downloading", "progressDetail": {"current": ' + '0, "start": 1413653874, "total": null}, ' + '"progress": "..."}', + ] + events = progress_stream.stream_output(output, StringIO()) + self.assertEqual(len(events), 1) From 4f4ea2a402a42c29c9867b02287dd7ded2d5b0d0 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Fri, 29 May 2015 14:45:21 +0100 Subject: [PATCH 0859/4072] Merge pull request #1325 from sdurrheimer/master Zsh completion for docker-compose (cherry picked from commit b638728d6ca21982e321b4069ef92f8367f069f4) Signed-off-by: Aanand Prasad Conflicts: docs/completion.md --- CONTRIBUTING.md | 1 - contrib/completion/zsh/_docker-compose | 304 +++++++++++++++++++++++++ docs/completion.md | 37 ++- 3 files changed, 333 insertions(+), 9 deletions(-) create mode 100644 contrib/completion/zsh/_docker-compose diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fddf888dc6b..6914e21591b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -73,7 +73,6 @@ up-to-date. 1. Open pull request that: - Updates the version in `compose/__init__.py` - Updates the binary URL in `docs/install.md` - - Updates the script URL in `docs/completion.md` - Adds release notes to `CHANGES.md` 2. Create unpublished GitHub release with release notes 3. Build Linux version on any Docker host with `script/build-linux` and attach diff --git a/contrib/completion/zsh/_docker-compose b/contrib/completion/zsh/_docker-compose new file mode 100644 index 00000000000..31052e1e0a9 --- /dev/null +++ b/contrib/completion/zsh/_docker-compose @@ -0,0 +1,304 @@ +#compdef docker-compose + +# Description +# ----------- +# zsh completion for docker-compose +# https://github.com/sdurrheimer/docker-compose-zsh-completion +# ------------------------------------------------------------------------- +# Version +# ------- +# 0.1.0 +# ------------------------------------------------------------------------- +# Authors +# ------- +# * Steve Durrheimer +# ------------------------------------------------------------------------- +# Inspiration +# ----------- +# * @albers docker-compose bash completion script +# * @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 fig.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" +} + +# All services, even those without an existing container +__docker-compose_services_all() { + services=$(___docker-compose_all_services_in_compose_file) + _alternative "args:services:($services)" +} + +# All services that have an entry with the given key in their docker-compose.yml section +___docker-compose_services_with_key() { + local already_selected + 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" +} + +# All services that are defined by a Dockerfile reference +__docker-compose_services_from_build() { + buildable=$(___docker-compose_services_with_key build) + _alternative "args:buildable services:($buildable)" +} + +# All services that are defined by an image +__docker-compose_services_from_image() { + pullable=$(___docker-compose_services_with_key image) + _alternative "args:pullable services:($pullable)" +} + +__docker-compose_get_services() { + local kind expl + declare -a running stopped lines args services + + docker_status=$(docker ps > /dev/null 2>&1) + if [ $? -ne 0 ]; then + _message "Error! Docker is not running." + return 1 + fi + + kind=$1 + shift + [[ $kind = (stopped|all) ]] && args=($args -a) + + lines=(${(f)"$(_call_program commands docker ps ${args})"}) + services=(${(f)"$(_call_program commands docker-compose 2>/dev/null ${compose_file:+-f $compose_file} ${compose_project:+-p $compose_project} ps -q)"}) + + # Parse header line to find columns + local i=1 j=1 k header=${lines[1]} + declare -A begin end + while (( $j < ${#header} - 1 )) { + i=$(( $j + ${${header[$j,-1]}[(i)[^ ]]} - 1)) + j=$(( $i + ${${header[$i,-1]}[(i) ]} - 1)) + k=$(( $j + ${${header[$j,-1]}[(i)[^ ]]} - 2)) + begin[${header[$i,$(($j-1))]}]=$i + end[${header[$i,$(($j-1))]}]=$k + } + lines=(${lines[2,-1]}) + + # Container ID + local line s name + local -a names + for line in $lines; do + if [[ $services == *"${line[${begin[CONTAINER ID]},${end[CONTAINER ID]}]%% ##}"* ]]; then + names=(${(ps:,:)${${line[${begin[NAMES]},-1]}%% *}}) + for name in $names; do + s="${${name%_*}#*_}:${(l:15:: :::)${${line[${begin[CREATED]},${end[CREATED]}]/ ago/}%% ##}}" + s="$s, ${line[${begin[CONTAINER ID]},${end[CONTAINER ID]}]%% ##}" + s="$s, ${${${line[$begin[IMAGE],$end[IMAGE]]}/:/\\:}%% ##}" + if [[ ${line[${begin[STATUS]},${end[STATUS]}]} = Exit* ]]; then + stopped=($stopped $s) + else + running=($running $s) + fi + done + fi + done + + [[ $kind = (running|all) ]] && _describe -t services-running "running services" running + [[ $kind = (stopped|all) ]] && _describe -t services-stopped "stopped services" stopped +} + +__docker-compose_stoppedservices() { + __docker-compose_get_services stopped "$@" +} + +__docker-compose_runningservices() { + __docker-compose_get_services running "$@" +} + +__docker-compose_services () { + __docker-compose_get_services all "$@" +} + +__docker-compose_caching_policy() { + oldp=( "$1"(Nmh+1) ) # 1 hour + (( $#oldp )) +} + +__docker-compose_commands () { + local cache_policy + + zstyle -s ":completion:${curcontext}:" cache-policy cache_policy + if [[ -z "$cache_policy" ]]; then + zstyle ":completion:${curcontext}:" cache-policy __docker-compose_caching_policy + fi + + if ( [[ ${+_docker_compose_subcommands} -eq 0 ]] || _cache_invalid docker_compose_subcommands) \ + && ! _retrieve_cache docker_compose_subcommands; + then + 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 + fi + _describe -t docker-compose-commands "docker-compose command" _docker_compose_subcommands +} + +__docker-compose_subcommand () { + local -a _command_args + integer ret=1 + case "$words[1]" in + (build) + _arguments \ + '--no-cache[Do not use cache when building the image]' \ + '*:services:__docker-compose_services_from_build' && ret=0 + ;; + (help) + _arguments ':subcommand:__docker-compose_commands' && ret=0 + ;; + (kill) + _arguments \ + '-s[SIGNAL to send to the container. Default signal is SIGKILL.]:signal:_signals' \ + '*:running services:__docker-compose_runningservices' && ret=0 + ;; + (logs) + _arguments \ + '--no-color[Produce monochrome output.]' \ + '*:services:__docker-compose_services_all' && ret=0 + ;; + (migrate-to-labels) + _arguments \ + '(-):Recreate containers to add labels' && ret=0 + ;; + (port) + _arguments \ + '--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: ' \ + '1:running services:__docker-compose_runningservices' \ + '2:port:_ports' && ret=0 + ;; + (ps) + _arguments \ + '-q[Only display IDs]' \ + '*:services:__docker-compose_services_all' && ret=0 + ;; + (pull) + _arguments \ + '--allow-insecure-ssl[Allow insecure connections to the docker registry]' \ + '*:services:__docker-compose_services_from_image' && ret=0 + ;; + (rm) + _arguments \ + '(-f --force)'{-f,--force}"[Don't ask to confirm removal]" \ + '-v[Remove volumes associated with containers]' \ + '*:stopped services:__docker-compose_stoppedservices' && ret=0 + ;; + (run) + _arguments \ + '--allow-insecure-ssl[Allow insecure connections to the docker registry]' \ + '-d[Detached mode: Run container in the background, print new 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' \ + "--no-deps[Don't start linked services.]" \ + '--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.]' \ + '(-):services:__docker-compose_services' \ + '(-):command: _command_names -e' \ + '*::arguments: _normal' && ret=0 + ;; + (scale) + _arguments '*:running services:__docker-compose_runningservices' && ret=0 + ;; + (start) + _arguments '*:stopped services:__docker-compose_stoppedservices' && ret=0 + ;; + (stop|restart) + _arguments \ + '(-t --timeout)'{-t,--timeout}"[Specify a shutdown timeout in seconds. (default: 10)]:seconds: " \ + '*:running services:__docker-compose_runningservices' && ret=0 + ;; + (up) + _arguments \ + '--allow-insecure-ssl[Allow insecure connections to the docker registry]' \ + '-d[Detached mode: Run containers in the background, print new container names.]' \ + '--no-color[Produce monochrome output.]' \ + "--no-deps[Don't start linked services.]" \ + "--no-recreate[If containers already exist, don't recreate them.]" \ + "--no-build[Don't build an image, even if it's missing]" \ + '(-t --timeout)'{-t,--timeout}"[Specify a shutdown timeout in seconds. (default: 10)]:seconds: " \ + "--x-smart-recreate[Only recreate containers whose configuration or image needs to be updated. (EXPERIMENTAL)]" \ + '*:services:__docker-compose_services_all' && ret=0 + ;; + (*) + _message 'Unknown sub command' + esac + + return ret +} + +_docker-compose () { + # Support for subservices, which allows for `compdef _docker docker-shell=_docker_containers`. + # Based on /usr/share/zsh/functions/Completion/Unix/_git without support for `ret`. + if [[ $service != docker-compose ]]; then + _call_function - _$service + return + fi + + local curcontext="$curcontext" state line ret=1 + typeset -A opt_args + + _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:' \ + '(-): :->command' \ + '(-)*:: :->option-or-argument' && ret=0 + + local counter=1 + #local compose_file compose_project + while [ $counter -lt ${#words[@]} ]; do + case "${words[$counter]}" in + -f|--file) + (( counter++ )) + compose_file="${words[$counter]}" + ;; + -p|--project-name) + (( counter++ )) + compose_project="${words[$counter]}" + ;; + *) + ;; + esac + (( counter++ )) + done + + case $state in + (command) + __docker-compose_commands && ret=0 + ;; + (option-or-argument) + curcontext=${curcontext%:*:*}:docker-compose-$words[1]: + __docker-compose_subcommand && ret=0 + ;; + esac + + return ret +} + +_docker-compose "$@" diff --git a/docs/completion.md b/docs/completion.md index 35c53b55fee..5168971f8b0 100644 --- a/docs/completion.md +++ b/docs/completion.md @@ -3,23 +3,44 @@ layout: default title: Command Completion --- -#Command Completion +# Command Completion Compose comes with [command completion](http://en.wikipedia.org/wiki/Command-line_completion) -for the bash shell. +for the bash and zsh shell. -##Installing Command Completion +## 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/1.2.0/contrib/completion/bash/docker-compose > /etc/bash_completion.d/docker-compose - +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 | awk '{print $2}')/contrib/completion/bash/docker-compose > /etc/bash_completion.d/docker-compose + Completion will be available upon next login. -##Available completions +### 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 | awk '{print $2}')/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 From 631f5be02fdc087420989bf820345406e6bc0c7b Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Sat, 30 May 2015 09:01:39 -0500 Subject: [PATCH 0860/4072] Merge pull request #1481 from albers/completion-smart-recreate Support --x-smart-recreate in bash completion (cherry picked from commit 9a0bb325f2d1203b7aac915c3bfca4347cc93489) Signed-off-by: Aanand Prasad --- 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 e62b1d8fcf5..ba3dff35299 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -104,7 +104,7 @@ _docker-compose_docker-compose() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "--help -h --verbose --version --file -f --project-name -p" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--help -h --verbose --version -v --file -f --project-name -p" -- "$cur" ) ) ;; *) COMPREPLY=( $( compgen -W "${commands[*]}" -- "$cur" ) ) @@ -293,7 +293,7 @@ _docker-compose_up() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "--allow-insecure-ssl -d --no-build --no-color --no-deps --no-recreate -t --timeout" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--allow-insecure-ssl -d --no-build --no-color --no-deps --no-recreate -t --timeout --x-smart-recreate" -- "$cur" ) ) ;; *) __docker-compose_services_all From 8ed7dfef6fb8d3f6eeeb4c515315e9ae43baee29 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 8 Jun 2015 12:48:46 -0400 Subject: [PATCH 0861/4072] Merge pull request #1525 from aanand/fix-duplicate-logging Fix duplicate logging on up/run (cherry picked from commit e2b790f7328482591863e496de14c825fd3f8a23) Signed-off-by: Aanand Prasad --- compose/cli/main.py | 1 + compose/service.py | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 61f3ec3f918..fa40131616c 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -335,6 +335,7 @@ def run(self, project, options): container_options['ports'] = [] container = service.create_container( + quiet=True, one_off=True, insecure_registry=insecure_registry, **container_options diff --git a/compose/service.py b/compose/service.py index ccfb3851183..dd931beeebd 100644 --- a/compose/service.py +++ b/compose/service.py @@ -199,6 +199,7 @@ def create_container(self, do_build=True, previous_container=None, number=None, + quiet=False, **override_options): """ Create a container for this service. If the image doesn't exist, attempt to pull @@ -216,7 +217,7 @@ def create_container(self, previous_container=previous_container, ) - if 'name' in container_options: + if 'name' in container_options and not quiet: log.info("Creating %s..." % container_options['name']) return Container.create(self.client, **container_options) @@ -378,6 +379,7 @@ def recreate_container(self, do_build=False, previous_container=container, number=container.labels.get(LABEL_CONTAINER_NUMBER), + quiet=True, ) self.start_container(new_container) container.remove() From dca3bbdea3eb9991d804cc8b9ac9de34a367b866 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 8 Jun 2015 16:21:02 -0400 Subject: [PATCH 0862/4072] Merge pull request #1527 from aanand/remove-logging-on-run-rm Remove logging on run --rm (cherry picked from commit 5578ccbb0113e285a20aeeee820c03766ef1ae6e) Signed-off-by: Aanand Prasad --- compose/cli/main.py | 1 - 1 file changed, 1 deletion(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index fa40131616c..7fde4ebaa0c 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -348,7 +348,6 @@ def run(self, project, options): dockerpty.start(project.client, container.id, interactive=not options['-T']) exit_code = container.wait() if options['--rm']: - log.info("Removing %s..." % container.name) project.client.remove_container(container.id) sys.exit(exit_code) From 8212f1bd45ab36d41895fbbd45490cbb68170187 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 9 Jun 2015 18:21:14 -0400 Subject: [PATCH 0863/4072] Merge pull request #1529 from aanand/update-dockerpty Update dockerpty to 0.3.4 (cherry picked from commit 95b2eaac042bb761b4f94c35a1af539467714098) Signed-off-by: Aanand Prasad --- requirements.txt | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index b939884801e..d3909b766ff 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ PyYAML==3.10 docker-py==1.2.2 -dockerpty==0.3.3 +dockerpty==0.3.4 docopt==0.6.1 requests==2.6.1 six==1.7.3 diff --git a/setup.py b/setup.py index 153275f69de..9364f57f39e 100644 --- a/setup.py +++ b/setup.py @@ -31,7 +31,7 @@ def find_version(*file_paths): 'texttable >= 0.8.1, < 0.9', 'websocket-client >= 0.11.0, < 1.0', 'docker-py >= 1.2.2, < 1.3', - 'dockerpty >= 0.3.3, < 0.4', + 'dockerpty >= 0.3.4, < 0.4', 'six >= 1.3.0, < 2', ] From e3ba302627d9f7e63f70e46aecd79e5bd97e0282 Mon Sep 17 00:00:00 2001 From: Ed Morley Date: Wed, 10 Jun 2015 14:37:12 +0100 Subject: [PATCH 0864/4072] Docs: Update boot2docker shellinit example to use 'eval' The boot2docker documentation has since changed the recommended way to use shellinit - see boot2docker/boot2docker#786. Signed-off-by: Ed Morley --- docs/cli.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/cli.md b/docs/cli.md index 9da12e69724..1fbd4cb2864 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -157,7 +157,7 @@ By default, if there are existing containers for a service, `docker-compose up` Several environment variables are available for you to configure Compose's behaviour. Variables starting with `DOCKER_` are the same as those used to configure the -Docker command-line client. If you're using boot2docker, `$(boot2docker shellinit)` +Docker command-line client. If you're using boot2docker, `eval "$(boot2docker shellinit)"` will set them to their correct values. ### COMPOSE\_PROJECT\_NAME From 4fd5d580762d5402b11a7103717b9f003b8efba2 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 2 Jun 2015 14:28:21 +0100 Subject: [PATCH 0865/4072] Test against Docker 1.7.0 RC2 Signed-off-by: Aanand Prasad --- Dockerfile | 6 ++++-- tests/integration/cli_test.py | 5 +++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index fca5f980315..1ff2d3825d5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -48,11 +48,13 @@ RUN set -ex; \ rm -rf pip-7.0.1; \ rm pip-7.0.1.tar.gz -ENV ALL_DOCKER_VERSIONS 1.6.0 +ENV ALL_DOCKER_VERSIONS 1.6.0 1.7.0-rc2 RUN set -ex; \ curl https://get.docker.com/builds/Linux/x86_64/docker-1.6.0 -o /usr/local/bin/docker-1.6.0; \ - chmod +x /usr/local/bin/docker-1.6.0 + chmod +x /usr/local/bin/docker-1.6.0; \ + curl https://test.docker.com/builds/Linux/x86_64/docker-1.7.0-rc2 -o /usr/local/bin/docker-1.7.0-rc2; \ + chmod +x /usr/local/bin/docker-1.7.0-rc2 # Set the default Docker to be run RUN ln -s /usr/local/bin/docker-1.6.0 /usr/local/bin/docker diff --git a/tests/integration/cli_test.py b/tests/integration/cli_test.py index ae57e919c8c..cb7bc17fcbc 100644 --- a/tests/integration/cli_test.py +++ b/tests/integration/cli_test.py @@ -1,6 +1,7 @@ from __future__ import absolute_import import sys import os +import shlex from six import StringIO from mock import patch @@ -240,8 +241,8 @@ def test_run_service_with_entrypoint_overridden(self, _): service = self.project.get_service(name) container = service.containers(stopped=True, one_off=True)[0] self.assertEqual( - container.human_readable_command, - u'/bin/echo helloworld' + shlex.split(container.human_readable_command), + [u'/bin/echo', u'helloworld'], ) @patch('dockerpty.start') From a5fd91c705a874e87bb1dd2ab8778514d1162c87 Mon Sep 17 00:00:00 2001 From: Vincent Demeester Date: Tue, 12 May 2015 13:44:52 +0200 Subject: [PATCH 0866/4072] Fixing docker-compose port with scale (#667) Fixes #667 and Closes #735 (taking over it) Signed-off-by: Vincent Demeester --- compose/cli/main.py | 7 +++--- .../docker-compose.yml | 6 +++++ tests/integration/cli_test.py | 22 +++++++++++++++++++ 3 files changed, 32 insertions(+), 3 deletions(-) create mode 100644 tests/fixtures/ports-composefile-scale/docker-compose.yml diff --git a/compose/cli/main.py b/compose/cli/main.py index 1a2f3c725d5..8f55eccbd2f 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -170,13 +170,14 @@ def port(self, project, options): Usage: port [options] SERVICE PRIVATE_PORT Options: - --protocol=proto tcp or udp (defaults to tcp) + --protocol=proto tcp or udp [default: tcp] --index=index index of the container if there are multiple - instances of a service (defaults to 1) + instances of a service [default: 1] """ + index = int(options.get('--index')) service = project.get_service(options['SERVICE']) try: - container = service.get_container(number=options.get('--index') or 1) + container = service.get_container(number=index) except ValueError as e: raise UserError(str(e)) print(container.get_local_port( diff --git a/tests/fixtures/ports-composefile-scale/docker-compose.yml b/tests/fixtures/ports-composefile-scale/docker-compose.yml new file mode 100644 index 00000000000..1a2bb485bc7 --- /dev/null +++ b/tests/fixtures/ports-composefile-scale/docker-compose.yml @@ -0,0 +1,6 @@ + +simple: + image: busybox:latest + command: /bin/sleep 300 + ports: + - '3000' diff --git a/tests/integration/cli_test.py b/tests/integration/cli_test.py index cb7bc17fcbc..2d1f1f76e6d 100644 --- a/tests/integration/cli_test.py +++ b/tests/integration/cli_test.py @@ -1,4 +1,5 @@ from __future__ import absolute_import +from operator import attrgetter import sys import os import shlex @@ -436,6 +437,27 @@ def get_port(number, mock_stdout): self.assertEqual(get_port(3001), "0.0.0.0:49152") self.assertEqual(get_port(3002), "") + def test_port_with_scale(self): + + self.command.base_dir = 'tests/fixtures/ports-composefile-scale' + self.command.dispatch(['scale', 'simple=2'], None) + containers = sorted( + self.project.containers(service_names=['simple']), + key=attrgetter('name')) + + @patch('sys.stdout', new_callable=StringIO) + def get_port(number, mock_stdout, index=None): + if index is None: + self.command.dispatch(['port', 'simple', str(number)], None) + else: + self.command.dispatch(['port', '--index=' + str(index), 'simple', str(number)], None) + return mock_stdout.getvalue().rstrip() + + self.assertEqual(get_port(3000), containers[0].get_local_port(3000)) + self.assertEqual(get_port(3000, index=1), containers[0].get_local_port(3000)) + self.assertEqual(get_port(3000, index=2), containers[1].get_local_port(3000)) + self.assertEqual(get_port(3002), "") + def test_env_file_relative_to_compose_file(self): config_path = os.path.abspath('tests/fixtures/env-file/docker-compose.yml') self.command.dispatch(['-f', config_path, 'up', '-d'], None) From 7995fc2ed21943289578e60742384a7d411fcb5e Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 11 Jun 2015 11:41:01 -0400 Subject: [PATCH 0867/4072] Reorder service.py utility methods Signed-off-by: Aanand Prasad --- compose/service.py | 135 ++++++++++++++++++++++++++------------------- 1 file changed, 78 insertions(+), 57 deletions(-) diff --git a/compose/service.py b/compose/service.py index ed3a0d0e44a..71edd5e5ecd 100644 --- a/compose/service.py +++ b/compose/service.py @@ -693,32 +693,29 @@ def pull(self, insecure_registry=False): stream_output(output, sys.stdout) -def get_container_data_volumes(container, volumes_option): - """Find the container data volumes that are in `volumes_option`, and return - a mapping of volume bindings for those volumes. - """ - volumes = [] +# Names - volumes_option = volumes_option or [] - container_volumes = container.get('Volumes') or {} - image_volumes = container.image_config['ContainerConfig'].get('Volumes') or {} - for volume in set(volumes_option + image_volumes.keys()): - volume = parse_volume_spec(volume) - # No need to preserve host volumes - if volume.external: - continue +def build_container_name(project, service, number, one_off=False): + bits = [project, service] + if one_off: + bits.append('run') + return '_'.join(bits + [str(number)]) - volume_path = container_volumes.get(volume.internal) - # New volume, doesn't exist in the old container - if not volume_path: - continue - # Copy existing volume from old container - volume = volume._replace(external=volume_path) - volumes.append(build_volume_binding(volume)) +# Images - return dict(volumes) + +def parse_repository_tag(s): + if ":" not in s: + return s, "" + repo, tag = s.rsplit(":", 1) + if "/" in tag: + return s, "" + return repo, tag + + +# Volumes def merge_volume_bindings(volumes_option, previous_container): @@ -737,35 +734,37 @@ def merge_volume_bindings(volumes_option, previous_container): return volume_bindings -def build_container_name(project, service, number, one_off=False): - bits = [project, service] - if one_off: - bits.append('run') - return '_'.join(bits + [str(number)]) +def get_container_data_volumes(container, volumes_option): + """Find the container data volumes that are in `volumes_option`, and return + a mapping of volume bindings for those volumes. + """ + volumes = [] + + volumes_option = volumes_option or [] + container_volumes = container.get('Volumes') or {} + image_volumes = container.image_config['ContainerConfig'].get('Volumes') or {} + for volume in set(volumes_option + image_volumes.keys()): + volume = parse_volume_spec(volume) + # No need to preserve host volumes + if volume.external: + continue -def build_container_labels(label_options, service_labels, number, one_off=False): - labels = label_options or {} - labels.update(label.split('=', 1) for label in service_labels) - labels[LABEL_CONTAINER_NUMBER] = str(number) - labels[LABEL_VERSION] = __version__ - return labels + volume_path = container_volumes.get(volume.internal) + # New volume, doesn't exist in the old container + if not volume_path: + continue + # Copy existing volume from old container + volume = volume._replace(external=volume_path) + volumes.append(build_volume_binding(volume)) -def parse_restart_spec(restart_config): - if not restart_config: - return None - parts = restart_config.split(':') - if len(parts) > 2: - raise ConfigError("Restart %s has incorrect format, should be " - "mode[:max_retry]" % restart_config) - if len(parts) == 2: - name, max_retry_count = parts - else: - name, = parts - max_retry_count = 0 + return dict(volumes) - return {'Name': name, 'MaximumRetryCount': int(max_retry_count)} + +def build_volume_binding(volume_spec): + internal = {'bind': volume_spec.internal, 'ro': volume_spec.mode == 'ro'} + return volume_spec.external, internal def parse_volume_spec(volume_config): @@ -788,18 +787,7 @@ def parse_volume_spec(volume_config): return VolumeSpec(external, internal, mode) -def parse_repository_tag(s): - if ":" not in s: - return s, "" - repo, tag = s.rsplit(":", 1) - if "/" in tag: - return s, "" - return repo, tag - - -def build_volume_binding(volume_spec): - internal = {'bind': volume_spec.internal, 'ro': volume_spec.mode == 'ro'} - return volume_spec.external, internal +# Ports def build_port_bindings(ports): @@ -830,6 +818,39 @@ def split_port(port): return internal_port, (external_ip, external_port or None) +# Labels + + +def build_container_labels(label_options, service_labels, number, one_off=False): + labels = label_options or {} + labels.update(label.split('=', 1) for label in service_labels) + labels[LABEL_CONTAINER_NUMBER] = str(number) + labels[LABEL_VERSION] = __version__ + return labels + + +# Restart policy + + +def parse_restart_spec(restart_config): + if not restart_config: + return None + parts = restart_config.split(':') + if len(parts) > 2: + raise ConfigError("Restart %s has incorrect format, should be " + "mode[:max_retry]" % restart_config) + if len(parts) == 2: + name, max_retry_count = parts + else: + name, = parts + max_retry_count = 0 + + return {'Name': name, 'MaximumRetryCount': int(max_retry_count)} + + +# Extra hosts + + def build_extra_hosts(extra_hosts_config): if not extra_hosts_config: return {} From f31d4c8a93fbf5ada1aeb9af494fcd1fb809b89f Mon Sep 17 00:00:00 2001 From: Travis Thieman Date: Thu, 11 Jun 2015 14:03:08 -0400 Subject: [PATCH 0868/4072] Correct misspelling of "Service" in an error message Signed-off-by: Travis Thieman --- compose/project.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/project.py b/compose/project.py index d3deeeaf966..bc093628c4f 100644 --- a/compose/project.py +++ b/compose/project.py @@ -171,7 +171,7 @@ def get_net(self, service_dict): try: net = Container.from_id(self.client, net_name) except APIError: - raise ConfigurationError('Serivce "%s" is trying to use the network of "%s", which is not the name of a service or container.' % (service_dict['name'], net_name)) + 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)) else: net = service_dict['net'] From ac222140e74979e705473bb9086a90c359a08232 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Thu, 11 Jun 2015 15:58:07 -0700 Subject: [PATCH 0869/4072] Add image affinity to test script This will allow tests to be run on a Swarm. This is being fixed in Swarm 0.4: https://github.com/docker/swarm/issues/743 Signed-off-by: Ben Firshman --- script/test | 1 + 1 file changed, 1 insertion(+) diff --git a/script/test b/script/test index f278023a048..625af09b354 100755 --- a/script/test +++ b/script/test @@ -11,6 +11,7 @@ docker run \ --volume="/var/run/docker.sock:/var/run/docker.sock" \ -e DOCKER_VERSIONS \ -e "TAG=$TAG" \ + -e "affinity:image==$TAG" \ --entrypoint="script/test-versions" \ "$TAG" \ "$@" From 08bc4b830bb977b90bf3e866e37bbd93a9546fd6 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 12 Jun 2015 13:51:55 -0400 Subject: [PATCH 0870/4072] Fix volume binds de-duplication Signed-off-by: Aanand Prasad --- compose/service.py | 5 +-- requirements.txt | 2 +- tests/unit/service_test.py | 87 +++++++++++++++++++++++++++++++++----- 3 files changed, 79 insertions(+), 15 deletions(-) diff --git a/compose/service.py b/compose/service.py index 71edd5e5ecd..1e91a9f2332 100644 --- a/compose/service.py +++ b/compose/service.py @@ -731,7 +731,7 @@ def merge_volume_bindings(volumes_option, previous_container): volume_bindings.update( get_container_data_volumes(previous_container, volumes_option)) - return volume_bindings + return volume_bindings.values() def get_container_data_volumes(container, volumes_option): @@ -763,8 +763,7 @@ def get_container_data_volumes(container, volumes_option): def build_volume_binding(volume_spec): - internal = {'bind': volume_spec.internal, 'ro': volume_spec.mode == 'ro'} - return volume_spec.external, internal + return volume_spec.internal, "{}:{}:{}".format(*volume_spec) def parse_volume_spec(volume_config): diff --git a/requirements.txt b/requirements.txt index d3909b766ff..47fa1e05bef 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ PyYAML==3.10 -docker-py==1.2.2 +docker-py==1.2.3-rc1 dockerpty==0.3.4 docopt==0.6.1 requests==2.6.1 diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index add48086d4e..fb3a7fcbb98 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -331,9 +331,7 @@ def test_parse_volume_bad_mode(self): def test_build_volume_binding(self): binding = build_volume_binding(parse_volume_spec('/outside:/inside')) - self.assertEqual( - binding, - ('/outside', dict(bind='/inside', ro=False))) + self.assertEqual(binding, ('/inside', '/outside:/inside:rw')) def test_get_container_data_volumes(self): options = [ @@ -360,8 +358,8 @@ def test_get_container_data_volumes(self): }, has_been_inspected=True) expected = { - '/var/lib/docker/aaaaaaaa': {'bind': '/existing/volume', 'ro': False}, - '/var/lib/docker/cccccccc': {'bind': '/mnt/image/data', 'ro': False}, + '/existing/volume': '/var/lib/docker/aaaaaaaa:/existing/volume:rw', + '/mnt/image/data': '/var/lib/docker/cccccccc:/mnt/image/data:rw', } binds = get_container_data_volumes(container, options) @@ -384,11 +382,78 @@ def test_merge_volume_bindings(self): 'Volumes': {'/existing/volume': '/var/lib/docker/aaaaaaaa'}, }, has_been_inspected=True) - expected = { - '/host/volume': {'bind': '/host/volume', 'ro': True}, - '/host/rw/volume': {'bind': '/host/rw/volume', 'ro': False}, - '/var/lib/docker/aaaaaaaa': {'bind': '/existing/volume', 'ro': False}, - } + expected = [ + '/host/volume:/host/volume:ro', + '/host/rw/volume:/host/rw/volume:rw', + '/var/lib/docker/aaaaaaaa:/existing/volume:rw', + ] binds = merge_volume_bindings(options, intermediate_container) - self.assertEqual(binds, expected) + self.assertEqual(set(binds), set(expected)) + + def test_mount_same_host_path_to_two_volumes(self): + service = Service( + 'web', + image='busybox', + volumes=[ + '/host/path:/data1', + '/host/path:/data2', + ], + client=self.mock_client, + ) + + self.mock_client.inspect_image.return_value = { + 'Id': 'ababab', + 'ContainerConfig': { + 'Volumes': {} + } + } + + create_options = service._get_container_create_options( + override_options={}, + number=1, + ) + + self.assertEqual( + set(create_options['host_config']['Binds']), + set([ + '/host/path:/data1:rw', + '/host/path:/data2:rw', + ]), + ) + + def test_different_host_path_in_container_json(self): + service = Service( + 'web', + image='busybox', + volumes=['/host/path:/data'], + client=self.mock_client, + ) + + self.mock_client.inspect_image.return_value = { + 'Id': 'ababab', + 'ContainerConfig': { + 'Volumes': { + '/data': {}, + } + } + } + + self.mock_client.inspect_container.return_value = { + 'Id': '123123123', + 'Image': 'ababab', + 'Volumes': { + '/data': '/mnt/sda1/host/path', + }, + } + + create_options = service._get_container_create_options( + override_options={}, + number=1, + previous_container=Container(self.mock_client, {'Id': '123123123'}), + ) + + self.assertEqual( + create_options['host_config']['Binds'], + ['/mnt/sda1/host/path:/data:rw'], + ) From d827809ffb080cbb90e1d431299de7d388e0d326 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Sat, 30 May 2015 14:52:10 -0400 Subject: [PATCH 0871/4072] Use labels to filter containers. Signed-off-by: Daniel Nephin --- tests/integration/testcases.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index 48fcf3ef296..5a1c5e12008 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -2,6 +2,7 @@ from __future__ import absolute_import from compose.service import Service from compose.config import make_service_dict +from compose.const import LABEL_PROJECT from compose.cli.docker_client import docker_client from compose.progress_stream import stream_output from .. import unittest @@ -12,12 +13,12 @@ class DockerClientTestCase(unittest.TestCase): def setUpClass(cls): cls.client = docker_client() - # TODO: update to use labels in #652 def setUp(self): - for c in self.client.containers(all=True): - if c['Names'] and 'composetest' in c['Names'][0]: - self.client.kill(c['Id']) - self.client.remove_container(c['Id']) + for c in self.client.containers( + all=True, + filters={'label': '%s=composetest' % LABEL_PROJECT}): + self.client.kill(c['Id']) + self.client.remove_container(c['Id']) for i in self.client.images(): if isinstance(i.get('Tag'), basestring) and 'composetest' in i['Tag']: self.client.remove_image(i) From 60351a8e0760112109dee92038c61e9626c17368 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Sat, 30 May 2015 16:17:21 -0400 Subject: [PATCH 0872/4072] Speed up integration test and make cleanup easier by using labels Signed-off-by: Daniel Nephin --- tests/fixtures/build-ctx/Dockerfile | 1 + .../dockerfile-with-volume/Dockerfile | 3 +- .../dockerfile_with_entrypoint/Dockerfile | 1 + tests/fixtures/simple-dockerfile/Dockerfile | 1 + tests/integration/cli_test.py | 6 +-- tests/integration/legacy_test.py | 24 +++++++++-- tests/integration/project_test.py | 41 +++---------------- tests/integration/service_test.py | 9 +++- tests/integration/state_test.py | 5 ++- tests/integration/testcases.py | 9 ++-- 10 files changed, 47 insertions(+), 53 deletions(-) diff --git a/tests/fixtures/build-ctx/Dockerfile b/tests/fixtures/build-ctx/Dockerfile index d1ceac6b743..dd864b8387c 100644 --- a/tests/fixtures/build-ctx/Dockerfile +++ b/tests/fixtures/build-ctx/Dockerfile @@ -1,2 +1,3 @@ FROM busybox:latest +LABEL com.docker.compose.test_image=true CMD echo "success" diff --git a/tests/fixtures/dockerfile-with-volume/Dockerfile b/tests/fixtures/dockerfile-with-volume/Dockerfile index 6e5d0a55ee8..0d376ec4846 100644 --- a/tests/fixtures/dockerfile-with-volume/Dockerfile +++ b/tests/fixtures/dockerfile-with-volume/Dockerfile @@ -1,3 +1,4 @@ -FROM busybox +FROM busybox:latest +LABEL com.docker.compose.test_image=true VOLUME /data CMD top diff --git a/tests/fixtures/dockerfile_with_entrypoint/Dockerfile b/tests/fixtures/dockerfile_with_entrypoint/Dockerfile index 7d28d29332c..e7454e59b0f 100644 --- a/tests/fixtures/dockerfile_with_entrypoint/Dockerfile +++ b/tests/fixtures/dockerfile_with_entrypoint/Dockerfile @@ -1,2 +1,3 @@ FROM busybox:latest +LABEL com.docker.compose.test_image=true ENTRYPOINT echo "From prebuilt entrypoint" diff --git a/tests/fixtures/simple-dockerfile/Dockerfile b/tests/fixtures/simple-dockerfile/Dockerfile index d1ceac6b743..dd864b8387c 100644 --- a/tests/fixtures/simple-dockerfile/Dockerfile +++ b/tests/fixtures/simple-dockerfile/Dockerfile @@ -1,2 +1,3 @@ FROM busybox:latest +LABEL com.docker.compose.test_image=true CMD echo "success" diff --git a/tests/integration/cli_test.py b/tests/integration/cli_test.py index cb7bc17fcbc..e9650668f28 100644 --- a/tests/integration/cli_test.py +++ b/tests/integration/cli_test.py @@ -24,6 +24,7 @@ def tearDown(self): self.project.remove_stopped() for container in self.project.containers(stopped=True, one_off=True): container.remove(force=True) + super(CLITestCase, self).tearDown() @property def project(self): @@ -207,13 +208,10 @@ def test_run_does_not_recreate_linked_containers(self, __): self.assertEqual(old_ids, new_ids) @patch('dockerpty.start') - def test_run_without_command(self, __): + def test_run_without_command(self, _): self.command.base_dir = 'tests/fixtures/commands-composefile' self.check_build('tests/fixtures/simple-dockerfile', tag='composetest_test') - for c in self.project.containers(stopped=True, one_off=True): - c.remove() - self.command.dispatch(['run', 'implicit'], None) service = self.project.get_service('implicit') containers = service.containers(stopped=True, one_off=True) diff --git a/tests/integration/legacy_test.py b/tests/integration/legacy_test.py index 6c52b68d33c..346c84f2e7f 100644 --- a/tests/integration/legacy_test.py +++ b/tests/integration/legacy_test.py @@ -1,12 +1,15 @@ +from docker.errors import APIError + from compose import legacy from compose.project import Project from .testcases import DockerClientTestCase -class ProjectTest(DockerClientTestCase): +class LegacyTestCase(DockerClientTestCase): def setUp(self): - super(ProjectTest, self).setUp() + super(LegacyTestCase, self).setUp() + self.containers = [] db = self.create_service('db') web = self.create_service('web', links=[(db, 'db')]) @@ -23,12 +26,25 @@ def setUp(self): **service.options ) self.client.start(container) + self.containers.append(container) # Create a single one-off legacy container - self.client.create_container( + self.containers.append(self.client.create_container( name='{}_{}_run_1'.format(self.project.name, self.services[0].name), **self.services[0].options - ) + )) + + def tearDown(self): + super(LegacyTestCase, self).tearDown() + for container in self.containers: + try: + self.client.kill(container) + except APIError: + pass + try: + self.client.remove_container(container) + except APIError: + pass def get_legacy_containers(self, **kwargs): return list(legacy.get_legacy_containers( diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 2976af823b4..5e252526e55 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -1,5 +1,7 @@ from __future__ import unicode_literals + from compose import config +from compose.const import LABEL_PROJECT from compose.project import Project from compose.container import Container from .testcases import DockerClientTestCase @@ -55,6 +57,7 @@ def test_volumes_from_container(self): image='busybox:latest', volumes=['/var/data'], name='composetest_data_container', + labels={LABEL_PROJECT: 'composetest'}, ) project = Project.from_dicts( name='composetest', @@ -69,9 +72,6 @@ def test_volumes_from_container(self): db = project.get_service('db') self.assertEqual(db.volumes_from, [data_container]) - project.kill() - project.remove_stopped() - def test_net_from_service(self): project = Project.from_dicts( name='composetest', @@ -95,15 +95,13 @@ def test_net_from_service(self): net = project.get_service('net') self.assertEqual(web._get_net(), 'container:' + net.containers()[0].id) - project.kill() - project.remove_stopped() - def test_net_from_container(self): net_container = Container.create( self.client, image='busybox:latest', name='composetest_net_container', - command='top' + command='top', + labels={LABEL_PROJECT: 'composetest'}, ) net_container.start() @@ -123,9 +121,6 @@ def test_net_from_container(self): web = project.get_service('web') self.assertEqual(web._get_net(), 'container:' + net_container.id) - project.kill() - project.remove_stopped() - def test_start_stop_kill_remove(self): web = self.create_service('web') db = self.create_service('db') @@ -171,9 +166,6 @@ def test_project_up(self): self.assertEqual(len(db.containers()), 1) self.assertEqual(len(web.containers()), 0) - project.kill() - project.remove_stopped() - def test_project_up_starts_uncreated_services(self): db = self.create_service('db') web = self.create_service('web', links=[(db, 'db')]) @@ -205,9 +197,6 @@ def test_project_up_recreates_containers(self): self.assertNotEqual(db_container.id, old_db_id) self.assertEqual(db_container.get('Volumes./etc'), db_volume_path) - project.kill() - project.remove_stopped() - def test_project_up_with_no_recreate_running(self): web = self.create_service('web') db = self.create_service('db', volumes=['/var/db']) @@ -228,9 +217,6 @@ def test_project_up_with_no_recreate_running(self): self.assertEqual(db_container.inspect()['Volumes']['/var/db'], db_volume_path) - project.kill() - project.remove_stopped() - def test_project_up_with_no_recreate_stopped(self): web = self.create_service('web') db = self.create_service('db', volumes=['/var/db']) @@ -258,9 +244,6 @@ def test_project_up_with_no_recreate_stopped(self): self.assertEqual(db_container.inspect()['Volumes']['/var/db'], db_volume_path) - project.kill() - project.remove_stopped() - def test_project_up_without_all_services(self): console = self.create_service('console') db = self.create_service('db') @@ -273,9 +256,6 @@ def test_project_up_without_all_services(self): self.assertEqual(len(db.containers()), 1) self.assertEqual(len(console.containers()), 1) - project.kill() - project.remove_stopped() - def test_project_up_starts_links(self): console = self.create_service('console') db = self.create_service('db', volumes=['/var/db']) @@ -291,9 +271,6 @@ def test_project_up_starts_links(self): self.assertEqual(len(db.containers()), 1) self.assertEqual(len(console.containers()), 0) - project.kill() - project.remove_stopped() - def test_project_up_starts_depends(self): project = Project.from_dicts( name='composetest', @@ -329,9 +306,6 @@ def test_project_up_starts_depends(self): self.assertEqual(len(project.get_service('data').containers()), 1) self.assertEqual(len(project.get_service('console').containers()), 0) - project.kill() - project.remove_stopped() - def test_project_up_with_no_deps(self): project = Project.from_dicts( name='composetest', @@ -368,9 +342,6 @@ def test_project_up_with_no_deps(self): self.assertEqual(len(project.get_service('data').containers(stopped=True)), 1) self.assertEqual(len(project.get_service('console').containers()), 0) - project.kill() - project.remove_stopped() - def test_unscale_after_restart(self): web = self.create_service('web') project = Project('composetest', [web], self.client) @@ -395,5 +366,3 @@ def test_unscale_after_restart(self): project.up() service = project.get_service('web') self.assertEqual(len(service.containers()), 1) - project.kill() - project.remove_stopped() diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 32de5fa4780..5a725f07c10 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -235,7 +235,12 @@ def test_create_container_with_home_and_env_var_in_volume_path(self): def test_create_container_with_volumes_from(self): volume_service = self.create_service('data') volume_container_1 = volume_service.create_container() - volume_container_2 = Container.create(self.client, image='busybox:latest', command=["top"]) + volume_container_2 = Container.create( + self.client, + image='busybox:latest', + command=["top"], + labels={LABEL_PROJECT: 'composetest'}, + ) host_service = self.create_service('host', volumes_from=[volume_service, volume_container_2]) host_container = host_service.create_container() host_service.start_container(host_container) @@ -408,7 +413,7 @@ def test_start_container_builds_images(self): self.assertEqual(len(self.client.images(name='composetest_test')), 1) def test_start_container_uses_tagged_image_if_it_exists(self): - self.client.build('tests/fixtures/simple-dockerfile', tag='composetest_test') + self.check_build('tests/fixtures/simple-dockerfile', tag='composetest_test') service = Service( name='test', client=self.client, diff --git a/tests/integration/state_test.py b/tests/integration/state_test.py index 7a7d2b58fc3..b99a299a0f4 100644 --- a/tests/integration/state_test.py +++ b/tests/integration/state_test.py @@ -216,18 +216,19 @@ def test_trigger_recreate_with_image_change(self): def test_trigger_recreate_with_build(self): context = tempfile.mkdtemp() + base_image = "FROM busybox\nLABEL com.docker.compose.test_image=true\n" try: dockerfile = os.path.join(context, 'Dockerfile') with open(dockerfile, 'w') as f: - f.write('FROM busybox\n') + f.write(base_image) web = self.create_service('web', build=context) container = web.create_container() with open(dockerfile, 'w') as f: - f.write('FROM busybox\nCMD echo hello world\n') + f.write(base_image + 'CMD echo hello world\n') web.build() web = self.create_service('web', build=context) diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index 5a1c5e12008..98c5876eb1d 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -13,15 +13,15 @@ class DockerClientTestCase(unittest.TestCase): def setUpClass(cls): cls.client = docker_client() - def setUp(self): + def tearDown(self): for c in self.client.containers( all=True, filters={'label': '%s=composetest' % LABEL_PROJECT}): self.client.kill(c['Id']) self.client.remove_container(c['Id']) - for i in self.client.images(): - if isinstance(i.get('Tag'), basestring) and 'composetest' in i['Tag']: - self.client.remove_image(i) + for i in self.client.images( + filters={'label': 'com.docker.compose.test_image'}): + self.client.remove_image(i) def create_service(self, name, **kwargs): if 'image' not in kwargs and 'build' not in kwargs: @@ -37,5 +37,6 @@ def create_service(self, name, **kwargs): ) def check_build(self, *args, **kwargs): + kwargs.setdefault('rm', True) build_output = self.client.build(*args, **kwargs) stream_output(build_output, open('/dev/null', 'w')) From c24d5380e69a2b27a4c7b0c34ff05aac5794de26 Mon Sep 17 00:00:00 2001 From: Travis Thieman Date: Thu, 28 May 2015 09:28:02 -0400 Subject: [PATCH 0873/4072] Extend up -t to pass timeout to stop running containers Signed-off-by: Travis Thieman --- compose/cli/main.py | 11 ++++++----- compose/project.py | 4 +++- compose/service.py | 10 +++++++--- tests/integration/cli_test.py | 13 +++++++++++++ tests/unit/service_test.py | 9 +++++++++ 5 files changed, 38 insertions(+), 9 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 1a2f3c725d5..a9d04472f4c 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -439,9 +439,9 @@ def up(self, project, options): image needs to be updated. (EXPERIMENTAL) --no-recreate If containers already exist, don't recreate them. --no-build Don't build an image, even if it's missing - -t, --timeout TIMEOUT When attached, use this timeout in seconds - for the shutdown. (default: 10) - + -t, --timeout TIMEOUT Use this timeout in seconds for container shutdown + when attached or when containers are already + running. (default: 10) """ insecure_registry = options['--allow-insecure-ssl'] detached = options['-d'] @@ -452,6 +452,7 @@ def up(self, project, options): allow_recreate = not options['--no-recreate'] smart_recreate = options['--x-smart-recreate'] service_names = options['SERVICE'] + timeout = int(options['--timeout']) if options['--timeout'] is not None else None project.up( service_names=service_names, @@ -460,6 +461,7 @@ def up(self, project, options): smart_recreate=smart_recreate, insecure_registry=insecure_registry, do_build=not options['--no-build'], + timeout=timeout ) to_attach = [c for s in project.get_services(service_names) for c in s.containers()] @@ -477,8 +479,7 @@ def handler(signal, frame): signal.signal(signal.SIGINT, handler) print("Gracefully stopping... (press Ctrl+C again to force)") - timeout = options.get('--timeout') - params = {} if timeout is None else {'timeout': int(timeout)} + params = {} if timeout is None else {'timeout': timeout} project.stop(service_names=service_names, **params) def migrate_to_labels(self, project, _options): diff --git a/compose/project.py b/compose/project.py index bc093628c4f..ddf681d5d11 100644 --- a/compose/project.py +++ b/compose/project.py @@ -211,7 +211,8 @@ def up(self, allow_recreate=True, smart_recreate=False, insecure_registry=False, - do_build=True): + do_build=True, + timeout=None): services = self.get_services(service_names, include_deps=start_deps) @@ -228,6 +229,7 @@ def up(self, plans[service.name], insecure_registry=insecure_registry, do_build=do_build, + timeout=timeout ) ] diff --git a/compose/service.py b/compose/service.py index 1e91a9f2332..7a0264c2da3 100644 --- a/compose/service.py +++ b/compose/service.py @@ -311,7 +311,8 @@ def _containers_have_diverged(self, containers): def execute_convergence_plan(self, plan, insecure_registry=False, - do_build=True): + do_build=True, + timeout=None): (action, containers) = plan if action == 'create': @@ -328,6 +329,7 @@ def execute_convergence_plan(self, self.recreate_container( c, insecure_registry=insecure_registry, + timeout=timeout ) for c in containers ] @@ -349,7 +351,8 @@ def execute_convergence_plan(self, def recreate_container(self, container, - insecure_registry=False): + insecure_registry=False, + timeout=None): """Recreate a container. The original container is renamed to a temporary name so that data @@ -358,7 +361,8 @@ def recreate_container(self, """ log.info("Recreating %s..." % container.name) try: - container.stop() + stop_params = {} if timeout is None else {'timeout': timeout} + container.stop(**stop_params) except APIError as e: if (e.response.status_code == 500 and e.explanation diff --git a/tests/integration/cli_test.py b/tests/integration/cli_test.py index e9650668f28..f9b251e195f 100644 --- a/tests/integration/cli_test.py +++ b/tests/integration/cli_test.py @@ -162,6 +162,19 @@ def test_up_with_keep_old(self): self.assertEqual(old_ids, new_ids) + def test_up_with_timeout(self): + self.command.dispatch(['up', '-d', '-t', '1'], None) + service = self.project.get_service('simple') + another = self.project.get_service('another') + self.assertEqual(len(service.containers()), 1) + self.assertEqual(len(another.containers()), 1) + + # Ensure containers don't have stdin and stdout connected in -d mode + config = service.containers()[0].inspect()['Config'] + self.assertFalse(config['AttachStderr']) + self.assertFalse(config['AttachStdout']) + self.assertFalse(config['AttachStdin']) + @patch('dockerpty.start') def test_run_service_without_links(self, mock_stdout): self.command.base_dir = 'tests/fixtures/links-composefile' diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index fb3a7fcbb98..595b9d3739a 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -254,6 +254,15 @@ def test_recreate_container(self, _): new_container.start.assert_called_once_with() mock_container.remove.assert_called_once_with() + @mock.patch('compose.service.Container', autospec=True) + def test_recreate_container_with_timeout(self, _): + mock_container = mock.create_autospec(Container) + self.mock_client.inspect_image.return_value = {'Id': 'abc123'} + service = Service('foo', client=self.mock_client, image='someimage') + service.recreate_container(mock_container, timeout=1) + + mock_container.stop.assert_called_once_with(timeout=1) + def test_parse_repository_tag(self): self.assertEqual(parse_repository_tag("root"), ("root", "")) self.assertEqual(parse_repository_tag("root:tag"), ("root", "tag")) From 06db577105d54394842b4634038a54e55e83252f Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Sun, 14 Jun 2015 17:11:29 -0400 Subject: [PATCH 0874/4072] Move converge() to a test module, and use a short timeout for tests. Signed-off-by: Daniel Nephin --- compose/cli/main.py | 19 ++++++++--------- compose/const.py | 1 + compose/project.py | 4 ++-- compose/service.py | 28 ++++--------------------- tests/integration/service_test.py | 33 +++++++++++++++++++++--------- tests/integration/state_test.py | 34 ++++++++++++++++++++++++++----- tests/unit/service_test.py | 2 +- 7 files changed, 69 insertions(+), 52 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index a9d04472f4c..8d21beafec8 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -10,7 +10,9 @@ from docker.errors import APIError import dockerpty -from .. import __version__, legacy +from .. import __version__ +from .. import legacy +from ..const import DEFAULT_TIMEOUT from ..project import NoSuchService, ConfigurationError from ..service import BuildError, NeedsBuildError from ..config import parse_environment @@ -394,9 +396,8 @@ def stop(self, project, options): -t, --timeout TIMEOUT Specify a shutdown timeout in seconds. (default: 10) """ - timeout = options.get('--timeout') - params = {} if timeout is None else {'timeout': int(timeout)} - project.stop(service_names=options['SERVICE'], **params) + timeout = float(options.get('--timeout') or DEFAULT_TIMEOUT) + project.stop(service_names=options['SERVICE'], timeout=timeout) def restart(self, project, options): """ @@ -408,9 +409,8 @@ def restart(self, project, options): -t, --timeout TIMEOUT Specify a shutdown timeout in seconds. (default: 10) """ - timeout = options.get('--timeout') - params = {} if timeout is None else {'timeout': int(timeout)} - project.restart(service_names=options['SERVICE'], **params) + timeout = float(options.get('--timeout') or DEFAULT_TIMEOUT) + project.restart(service_names=options['SERVICE'], timeout=timeout) def up(self, project, options): """ @@ -452,7 +452,7 @@ def up(self, project, options): allow_recreate = not options['--no-recreate'] smart_recreate = options['--x-smart-recreate'] service_names = options['SERVICE'] - timeout = int(options['--timeout']) if options['--timeout'] is not None else None + timeout = float(options.get('--timeout') or DEFAULT_TIMEOUT) project.up( service_names=service_names, @@ -479,8 +479,7 @@ def handler(signal, frame): signal.signal(signal.SIGINT, handler) print("Gracefully stopping... (press Ctrl+C again to force)") - params = {} if timeout is None else {'timeout': timeout} - project.stop(service_names=service_names, **params) + project.stop(service_names=service_names, timeout=timeout) def migrate_to_labels(self, project, _options): """ diff --git a/compose/const.py b/compose/const.py index f76fb572cd5..709c3a10d74 100644 --- a/compose/const.py +++ b/compose/const.py @@ -1,4 +1,5 @@ +DEFAULT_TIMEOUT = 10 LABEL_CONTAINER_NUMBER = 'com.docker.compose.container-number' LABEL_ONE_OFF = 'com.docker.compose.oneoff' LABEL_PROJECT = 'com.docker.compose.project' diff --git a/compose/project.py b/compose/project.py index ddf681d5d11..b4ed12ea4e1 100644 --- a/compose/project.py +++ b/compose/project.py @@ -6,7 +6,7 @@ from docker.errors import APIError from .config import get_service_name_from_net, ConfigurationError -from .const import LABEL_PROJECT, LABEL_SERVICE, LABEL_ONE_OFF +from .const import LABEL_PROJECT, LABEL_SERVICE, LABEL_ONE_OFF, DEFAULT_TIMEOUT from .service import Service from .container import Container from .legacy import check_for_legacy_containers @@ -212,7 +212,7 @@ def up(self, smart_recreate=False, insecure_registry=False, do_build=True, - timeout=None): + timeout=DEFAULT_TIMEOUT): services = self.get_services(service_names, include_deps=start_deps) diff --git a/compose/service.py b/compose/service.py index 7a0264c2da3..53073ffbdb4 100644 --- a/compose/service.py +++ b/compose/service.py @@ -13,6 +13,7 @@ from . import __version__ from .config import DOCKER_CONFIG_KEYS, merge_environment from .const import ( + DEFAULT_TIMEOUT, LABEL_CONTAINER_NUMBER, LABEL_ONE_OFF, LABEL_PROJECT, @@ -251,26 +252,6 @@ def image_name(self): else: return self.options['image'] - def converge(self, - allow_recreate=True, - smart_recreate=False, - insecure_registry=False, - do_build=True): - """ - If a container for this service doesn't exist, create and start one. If there are - any, stop them, create+start new ones, and remove the old containers. - """ - plan = self.convergence_plan( - allow_recreate=allow_recreate, - smart_recreate=smart_recreate, - ) - - return self.execute_convergence_plan( - plan, - insecure_registry=insecure_registry, - do_build=do_build, - ) - def convergence_plan(self, allow_recreate=True, smart_recreate=False): @@ -312,7 +293,7 @@ def execute_convergence_plan(self, plan, insecure_registry=False, do_build=True, - timeout=None): + timeout=DEFAULT_TIMEOUT): (action, containers) = plan if action == 'create': @@ -352,7 +333,7 @@ def execute_convergence_plan(self, def recreate_container(self, container, insecure_registry=False, - timeout=None): + timeout=DEFAULT_TIMEOUT): """Recreate a container. The original container is renamed to a temporary name so that data @@ -361,8 +342,7 @@ def recreate_container(self, """ log.info("Recreating %s..." % container.name) try: - stop_params = {} if timeout is None else {'timeout': timeout} - container.stop(**stop_params) + container.stop(timeout=timeout) except APIError as e: if (e.response.status_code == 500 and e.explanation diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 5a725f07c10..3b3ac22f9bf 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -2,8 +2,9 @@ from __future__ import absolute_import import os from os import path -import mock +from docker.errors import APIError +import mock import tempfile import shutil import six @@ -18,11 +19,11 @@ ) from compose.service import ( ConfigError, + ConvergencePlan, Service, build_extra_hosts, ) from compose.container import Container -from docker.errors import APIError from .testcases import DockerClientTestCase @@ -249,7 +250,7 @@ def test_create_container_with_volumes_from(self): self.assertIn(volume_container_2.id, host_container.get('HostConfig.VolumesFrom')) - def test_converge(self): + def test_execute_convergence_plan_recreate(self): service = self.create_service( 'db', environment={'FOO': '1'}, @@ -269,7 +270,8 @@ def test_converge(self): num_containers_before = len(self.client.containers(all=True)) service.options['environment']['FOO'] = '2' - new_container = service.converge()[0] + new_container, = service.execute_convergence_plan( + ConvergencePlan('recreate', [old_container])) self.assertEqual(new_container.get('Config.Entrypoint'), ['top']) self.assertEqual(new_container.get('Config.Cmd'), ['-d', '1']) @@ -286,7 +288,7 @@ def test_converge(self): self.client.inspect_container, old_container.id) - def test_converge_when_containers_are_stopped(self): + def test_execute_convergence_plan_when_containers_are_stopped(self): service = self.create_service( 'db', environment={'FOO': '1'}, @@ -295,11 +297,21 @@ def test_converge_when_containers_are_stopped(self): command=['-d', '1'] ) service.create_container() - self.assertEqual(len(service.containers(stopped=True)), 1) - service.converge() - self.assertEqual(len(service.containers(stopped=True)), 1) - def test_converge_with_image_declared_volume(self): + containers = service.containers(stopped=True) + self.assertEqual(len(containers), 1) + container, = containers + self.assertFalse(container.is_running) + + service.execute_convergence_plan(ConvergencePlan('start', [container])) + + containers = service.containers() + self.assertEqual(len(containers), 1) + container.inspect() + self.assertEqual(container, containers[0]) + self.assertTrue(container.is_running) + + def test_execute_convergence_plan_with_image_declared_volume(self): service = Service( project='composetest', name='db', @@ -311,7 +323,8 @@ def test_converge_with_image_declared_volume(self): self.assertEqual(old_container.get('Volumes').keys(), ['/data']) volume_path = old_container.get('Volumes')['/data'] - new_container = service.converge()[0] + new_container, = service.execute_convergence_plan( + ConvergencePlan('recreate', [old_container])) self.assertEqual(new_container.get('Volumes').keys(), ['/data']) self.assertEqual(new_container.get('Volumes')['/data'], volume_path) diff --git a/tests/integration/state_test.py b/tests/integration/state_test.py index b99a299a0f4..cd59d13c929 100644 --- a/tests/integration/state_test.py +++ b/tests/integration/state_test.py @@ -12,8 +12,8 @@ class ProjectTestCase(DockerClientTestCase): def run_up(self, cfg, **kwargs): - if 'smart_recreate' not in kwargs: - kwargs['smart_recreate'] = True + kwargs.setdefault('smart_recreate', True) + kwargs.setdefault('timeout', 0.1) project = self.make_project(cfg) project.up(**kwargs) @@ -153,7 +153,31 @@ def test_change_root_no_recreate(self): self.assertEqual(new_containers - old_containers, set()) +def converge(service, + allow_recreate=True, + smart_recreate=False, + insecure_registry=False, + do_build=True): + """ + If a container for this service doesn't exist, create and start one. If there are + any, stop them, create+start new ones, and remove the old containers. + """ + plan = service.convergence_plan( + allow_recreate=allow_recreate, + smart_recreate=smart_recreate, + ) + + return service.execute_convergence_plan( + plan, + insecure_registry=insecure_registry, + do_build=do_build, + timeout=0.1, + ) + + class ServiceStateTest(DockerClientTestCase): + """Test cases for Service.convergence_plan.""" + def test_trigger_create(self): web = self.create_service('web') self.assertEqual(('create', []), web.convergence_plan(smart_recreate=True)) @@ -250,15 +274,15 @@ def test_no_config_hash_when_overriding_options(self): def test_config_hash_with_custom_labels(self): web = self.create_service('web', labels={'foo': '1'}) - container = web.converge()[0] + container = converge(web)[0] self.assertIn(LABEL_CONFIG_HASH, container.labels) self.assertIn('foo', container.labels) def test_config_hash_sticks_around(self): web = self.create_service('web', command=["top"]) - container = web.converge()[0] + container = converge(web)[0] self.assertIn(LABEL_CONFIG_HASH, container.labels) web = self.create_service('web', command=["top", "-d", "1"]) - container = web.converge()[0] + container = converge(web)[0] self.assertIn(LABEL_CONFIG_HASH, container.labels) diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 595b9d3739a..82ea0410196 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -246,7 +246,7 @@ def test_recreate_container(self, _): service.image = lambda: {'Id': 'abc123'} new_container = service.recreate_container(mock_container) - mock_container.stop.assert_called_once_with() + mock_container.stop.assert_called_once_with(timeout=10) self.mock_client.rename.assert_called_once_with( mock_container.id, '%s_%s' % (mock_container.short_id, mock_container.name)) From e40fc0256111aae16013baad9aed82a07e9dd302 Mon Sep 17 00:00:00 2001 From: Mary Anthony Date: Sun, 7 Jun 2015 13:59:58 -0700 Subject: [PATCH 0875/4072] Testing with documentation tooling Updating with changes Updating for Hugo Adding a README' moving index.md compose-overview.md in links changing overview Updating image to pull Signed-off-by: Mary Anthony --- docs/Dockerfile | 31 +++++++---- docs/Makefile | 55 ++++++++++++++++++ docs/README.md | 77 ++++++++++++++++++++++++++ docs/cli.md | 17 ++++-- docs/completion.md | 18 ++++-- docs/{index.md => compose-overview.md} | 20 ++++--- docs/django.md | 18 ++++-- docs/env.md | 20 ++++--- docs/extends.md | 17 ++++-- docs/install.md | 21 ++++--- docs/mkdocs.yml | 12 ---- docs/production.md | 13 ++++- docs/rails.md | 21 ++++--- docs/wordpress.md | 21 ++++--- docs/yml.md | 19 ++++--- 15 files changed, 287 insertions(+), 93 deletions(-) create mode 100644 docs/Makefile create mode 100644 docs/README.md rename docs/{index.md => compose-overview.md} (96%) delete mode 100644 docs/mkdocs.yml diff --git a/docs/Dockerfile b/docs/Dockerfile index 59ef66cdd80..55e7ce700b8 100644 --- a/docs/Dockerfile +++ b/docs/Dockerfile @@ -1,15 +1,24 @@ -FROM docs/base:latest -MAINTAINER Sven Dowideit (@SvenDowideit) +FROM docs/base:hugo +MAINTAINER Mary Anthony (@moxiegirl) -# to get the git info for this repo +# To get the git info for this repo COPY . /src -# Reset the /docs dir so we can replace the theme meta with the new repo's git info -RUN git reset --hard +COPY . /docs/content/compose/ -RUN grep "__version" /src/compose/__init__.py | sed "s/.*'\(.*\)'/\1/" > /docs/VERSION -COPY docs/* /docs/sources/compose/ -COPY docs/mkdocs.yml /docs/mkdocs-compose.yml - -# Then build everything together, ready for mkdocs -RUN /docs/build.sh +# Sed to process GitHub Markdown +# 1-2 Remove comment code from metadata block +# 3 Remove .md extension from link text +# 4 Change ](/ to ](/project/ in links +# 5 Change ](word) to ](/project/word) +# 6 Change ](../../ to ](/project/ +# 7 Change ](../ to ](/project/word) +# +# +RUN find /docs/content/compose -type f -name "*.md" -exec sed -i.old \ + -e '/^/g' \ + -e '/^/g' \ + -e 's/\([(]\)\(.*\)\(\.md\)/\1\2/g' \ + -e 's/\(\]\)\([(]\)\(\/\)/\1\2\/compose\//g' \ + -e 's/\(\][(]\)\([A-z]*[)]\)/\]\(\/compose\/\2/g' \ + -e 's/\(\][(]\)\(\.\.\/\)/\1\/compose\//g' {} \; diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 00000000000..021e8f6e5ea --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,55 @@ +.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 + +# to allow `make DOCSDIR=docs docs-shell` (to create a bind mount in docs) +DOCS_MOUNT := $(if $(DOCSDIR),-v $(CURDIR)/$(DOCSDIR):/$(DOCSDIR)) + +# 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) +DOCKER_IMAGE := docker$(if $(GIT_BRANCH),:$(GIT_BRANCH)) +DOCKER_DOCS_IMAGE := docs-base$(if $(GIT_BRANCH),:$(GIT_BRANCH)) + + +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) + +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 + + +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/README.md b/docs/README.md new file mode 100644 index 00000000000..00736e476b9 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,77 @@ +# Contributing to the Docker Compose documentation + +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. + +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 in your `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 appears 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="smn_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. diff --git a/docs/cli.md b/docs/cli.md index 1fbd4cb2864..a2167d9c3fc 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1,9 +1,16 @@ -page_title: Compose CLI reference -page_description: Compose CLI reference -page_keywords: fig, composition, compose, docker, orchestration, cli, reference + -# CLI reference +# Compose CLI reference Most Docker Compose commands are run against one or more services. If the service is not specified, the command will apply to all services. @@ -185,7 +192,7 @@ Configures the path to the `ca.pem`, `cert.pem`, and `key.pem` files used for TL ## Compose documentation -- [User guide](index.md) +- [User guide](compose-overview.md) - [Installing Compose](install.md) - [Get started with Django](django.md) - [Get started with Rails](rails.md) diff --git a/docs/completion.md b/docs/completion.md index 5168971f8b0..7fb696d80ee 100644 --- a/docs/completion.md +++ b/docs/completion.md @@ -1,7 +1,13 @@ ---- -layout: default -title: Command Completion ---- + # Command Completion @@ -53,11 +59,11 @@ Enjoy working with Compose faster and with less typos! ## Compose documentation -- [User guide](index.md) +- [User guide](compose-overview.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](cli.md) - [Yaml file reference](yml.md) -- [Compose environment variables](env.md) +- [Compose environment variables](env.md) \ No newline at end of file diff --git a/docs/index.md b/docs/compose-overview.md similarity index 96% rename from docs/index.md rename to docs/compose-overview.md index 981a02702fc..33629957a6e 100644 --- a/docs/index.md +++ b/docs/compose-overview.md @@ -1,11 +1,15 @@ -page_title: Compose: Multi-container orchestration for Docker -page_description: Introduction and Overview of Compose -page_keywords: documentation, docs, docker, compose, orchestration, containers - - -# Docker Compose - -## Overview + + + +# Overview of Docker Compose Compose is a tool for defining and running multi-container applications with Docker. With Compose, you define a multi-container application in a single diff --git a/docs/django.md b/docs/django.md index 4cbebe04158..c44329e1cdd 100644 --- a/docs/django.md +++ b/docs/django.md @@ -1,10 +1,16 @@ -page_title: Quickstart Guide: Compose and Django -page_description: Getting started with Docker Compose and Django -page_keywords: documentation, docs, docker, compose, orchestration, containers, -django + -## Getting started with Compose and Django +## Quickstart Guide: Compose and Django This Quick-start Guide will demonstrate how to use Compose to set up and run a @@ -119,7 +125,7 @@ example, run `docker-compose up` and in another terminal run: ## More Compose documentation -- [User guide](index.md) +- [User guide](compose-overview.md) - [Installing Compose](install.md) - [Get started with Django](django.md) - [Get started with Rails](rails.md) diff --git a/docs/env.md b/docs/env.md index a4b543ae370..73496f32f51 100644 --- a/docs/env.md +++ b/docs/env.md @@ -1,9 +1,15 @@ ---- -layout: default -title: Compose environment variables reference ---- - -Environment variables reference + + +# 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](yml.md#links) for details. @@ -34,7 +40,7 @@ Fully qualified container name, e.g. `DB_1_NAME=/myapp_web_1/myapp_db_1` ## Compose documentation -- [User guide](index.md) +- [User guide](compose-overview.md) - [Installing Compose](install.md) - [Get started with Django](django.md) - [Get started with Rails](rails.md) diff --git a/docs/extends.md b/docs/extends.md index fd372ce2d55..8527c81b3cf 100644 --- a/docs/extends.md +++ b/docs/extends.md @@ -1,6 +1,13 @@ -page_title: Extending services in Compose -page_description: How to use Docker Compose's "extends" keyword to share configuration between files and projects -page_keywords: fig, composition, compose, docker, orchestration, documentation, docs + ## Extending services in Compose @@ -79,7 +86,7 @@ For full details on how to use `extends`, refer to the [reference](#reference). ### Example use case In this example, you’ll repurpose the example app from the [quick start -guide](index.md). (If you're not familiar with Compose, it's recommended that +guide](compose-overview.md). (If you're not familiar with Compose, it's recommended that you go through the quick start first.) This example assumes you want to use Compose both to develop an application locally and then deploy it to a production environment. @@ -364,7 +371,7 @@ volumes: ## Compose documentation -- [User guide](index.md) +- [User guide](compose-overview.md) - [Installing Compose](install.md) - [Get started with Django](django.md) - [Get started with Rails](rails.md) diff --git a/docs/install.md b/docs/install.md index a521ec06cde..ec0e6e4d511 100644 --- a/docs/install.md +++ b/docs/install.md @@ -1,14 +1,21 @@ -page_title: Installing Compose -page_description: How to install Docker Compose -page_keywords: compose, orchestration, install, installation, docker, documentation + -## Installing Compose +# Install Docker Compose To install Compose, you'll need to install Docker first. You'll then install Compose with a `curl` command. -### Install Docker +## Install Docker First, install Docker version 1.6 or greater: @@ -16,7 +23,7 @@ First, install Docker version 1.6 or greater: - [Instructions for Ubuntu](http://docs.docker.com/installation/ubuntulinux/) - [Instructions for other systems](http://docs.docker.com/installation/) -### Install Compose +## Install Compose To install Compose, run the following commands: @@ -38,7 +45,7 @@ You can test the installation by running `docker-compose --version`. ## Compose documentation -- [User guide](index.md) +- [User guide](compose-overview.md) - [Get started with Django](django.md) - [Get started with Rails](rails.md) - [Get started with Wordpress](wordpress.md) diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml deleted file mode 100644 index 428439bc425..00000000000 --- a/docs/mkdocs.yml +++ /dev/null @@ -1,12 +0,0 @@ - -- ['compose/index.md', 'User Guide', 'Docker Compose' ] -- ['compose/production.md', 'User Guide', 'Using Compose in production' ] -- ['compose/extends.md', 'User Guide', 'Extending services in Compose'] -- ['compose/install.md', 'Installation', 'Docker Compose'] -- ['compose/cli.md', 'Reference', 'Compose command line'] -- ['compose/yml.md', 'Reference', 'Compose yml'] -- ['compose/env.md', 'Reference', 'Compose ENV variables'] -- ['compose/completion.md', 'Reference', 'Compose commandline completion'] -- ['compose/django.md', 'Examples', 'Getting started with Compose and Django'] -- ['compose/rails.md', 'Examples', 'Getting started with Compose and Rails'] -- ['compose/wordpress.md', 'Examples', 'Getting started with Compose and Wordpress'] diff --git a/docs/production.md b/docs/production.md index 60a6873daf2..294f3c4e865 100644 --- a/docs/production.md +++ b/docs/production.md @@ -1,6 +1,13 @@ -page_title: Using Compose in production -page_description: Guide to using Docker Compose in production -page_keywords: documentation, docs, docker, compose, orchestration, containers, production + ## Using Compose in production diff --git a/docs/rails.md b/docs/rails.md index aedb4c6e767..2ff6f175215 100644 --- a/docs/rails.md +++ b/docs/rails.md @@ -1,10 +1,15 @@ -page_title: Quickstart Guide: Compose and Rails -page_description: Getting started with Docker Compose and Rails -page_keywords: documentation, docs, docker, compose, orchestration, containers, -rails - - -## Getting started with Compose and Rails + + +## Quickstart Guide: 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). @@ -119,7 +124,7 @@ you're using Boot2docker, `boot2docker ip` will tell you its address). ## More Compose documentation -- [User guide](index.md) +- [User guide](compose-overview.md) - [Installing Compose](install.md) - [Get started with Django](django.md) - [Get started with Rails](rails.md) diff --git a/docs/wordpress.md b/docs/wordpress.md index b40d1a9f084..ad0e62966f2 100644 --- a/docs/wordpress.md +++ b/docs/wordpress.md @@ -1,14 +1,21 @@ -page_title: Quickstart Guide: Compose and Wordpress -page_description: Getting started with Docker Compose and Rails -page_keywords: documentation, docs, docker, compose, orchestration, containers, -wordpress + -## Getting started with Compose and Wordpress + +# Quickstart Guide: Compose and Wordpress You can use Compose to easily run Wordpress in an isolated environment built with Docker containers. -### Define the project +## Define the project First, [Install Compose](install.md) and then download Wordpress into the current directory: @@ -114,7 +121,7 @@ address). ## More Compose documentation -- [User guide](index.md) +- [User guide](compose-overview.md) - [Installing Compose](install.md) - [Get started with Django](django.md) - [Get started with Rails](rails.md) diff --git a/docs/yml.md b/docs/yml.md index df791bc98fd..80d6d719f50 100644 --- a/docs/yml.md +++ b/docs/yml.md @@ -1,10 +1,13 @@ ---- -layout: default -title: docker-compose.yml reference -page_title: docker-compose.yml reference -page_description: docker-compose.yml reference -page_keywords: fig, composition, compose, docker ---- + + # docker-compose.yml reference @@ -390,7 +393,7 @@ read_only: true ## Compose documentation -- [User guide](index.md) +- [User guide](compose-overview.md) - [Installing Compose](install.md) - [Get started with Django](django.md) - [Get started with Rails](rails.md) From 4e108e377e70ef66f4a5c319143eb026fbe4cc73 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 15 Jun 2015 10:18:15 -0700 Subject: [PATCH 0876/4072] Update setup.py with new docker-py minimum Signed-off-by: Aanand Prasad --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 9364f57f39e..a94d87374f5 100644 --- a/setup.py +++ b/setup.py @@ -30,7 +30,7 @@ def find_version(*file_paths): 'requests >= 2.6.1, < 2.7', 'texttable >= 0.8.1, < 0.9', 'websocket-client >= 0.11.0, < 1.0', - 'docker-py >= 1.2.2, < 1.3', + 'docker-py >= 1.2.3-rc1, < 1.3', 'dockerpty >= 0.3.4, < 0.4', 'six >= 1.3.0, < 2', ] From b4c49ed805656890fe3ce9a1315688fe8c514dd5 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 15 Jun 2015 10:25:58 -0700 Subject: [PATCH 0877/4072] Use Docker 1.7 RC3 Signed-off-by: Aanand Prasad --- Dockerfile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 1ff2d3825d5..b25e824cdb9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -48,13 +48,13 @@ RUN set -ex; \ rm -rf pip-7.0.1; \ rm pip-7.0.1.tar.gz -ENV ALL_DOCKER_VERSIONS 1.6.0 1.7.0-rc2 +ENV ALL_DOCKER_VERSIONS 1.6.0 1.7.0-rc3 RUN set -ex; \ curl https://get.docker.com/builds/Linux/x86_64/docker-1.6.0 -o /usr/local/bin/docker-1.6.0; \ chmod +x /usr/local/bin/docker-1.6.0; \ - curl https://test.docker.com/builds/Linux/x86_64/docker-1.7.0-rc2 -o /usr/local/bin/docker-1.7.0-rc2; \ - chmod +x /usr/local/bin/docker-1.7.0-rc2 + curl https://test.docker.com/builds/Linux/x86_64/docker-1.7.0-rc3 -o /usr/local/bin/docker-1.7.0-rc3; \ + chmod +x /usr/local/bin/docker-1.7.0-rc3 # Set the default Docker to be run RUN ln -s /usr/local/bin/docker-1.6.0 /usr/local/bin/docker From acd8dce595c872c2b19e6f9304db7a2438529a13 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 15 Jun 2015 10:46:01 -0700 Subject: [PATCH 0878/4072] Add upgrading instructions to install docs Signed-off-by: Aanand Prasad --- docs/install.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/install.md b/docs/install.md index ec0e6e4d511..c1abd4fd6d0 100644 --- a/docs/install.md +++ b/docs/install.md @@ -43,6 +43,18 @@ Compose can also be installed as a Python package: No further steps are required; Compose should now be successfully installed. You can test the installation by running `docker-compose --version`. +### Upgrading + +If you're coming 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 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 myapp_web_1 myapp_db_1 ... + ## Compose documentation - [User guide](compose-overview.md) From 71514cb380c157bbd7c34ad26697dc0638783d79 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 9 Jun 2015 22:22:56 -0400 Subject: [PATCH 0879/4072] Merge pull request #1531 from aanand/test-crash-resilience Test that data volumes now survive a crash when recreating (cherry picked from commit 87c30ae6e48c2341593b03770089e3ff86108881) Signed-off-by: Aanand Prasad --- tests/integration/resilience_test.py | 37 ++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 tests/integration/resilience_test.py diff --git a/tests/integration/resilience_test.py b/tests/integration/resilience_test.py new file mode 100644 index 00000000000..8229e9d3c67 --- /dev/null +++ b/tests/integration/resilience_test.py @@ -0,0 +1,37 @@ +from __future__ import unicode_literals +from __future__ import absolute_import + +import mock + +from compose.project import Project +from .testcases import DockerClientTestCase + + +class ResilienceTest(DockerClientTestCase): + def test_recreate_fails(self): + db = self.create_service('db', volumes=['/var/db'], command='top') + project = Project('composetest', [db], self.client) + + container = db.create_container() + db.start_container(container) + host_path = container.get('Volumes')['/var/db'] + + project.up() + container = db.containers()[0] + self.assertEqual(container.get('Volumes')['/var/db'], host_path) + + with mock.patch('compose.service.Service.create_container', crash): + with self.assertRaises(Crash): + project.up() + + project.up() + container = db.containers()[0] + self.assertEqual(container.get('Volumes')['/var/db'], host_path) + + +class Crash(Exception): + pass + + +def crash(*args, **kwargs): + raise Crash() From ca14ed68f7060ffc6e7856a66e7d6f4d3e245a74 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 10 Jun 2015 13:03:55 -0400 Subject: [PATCH 0880/4072] Merge pull request #1533 from edmorley/update-b2d-shellinit-example Docs: Update boot2docker shellinit example to use 'eval' (cherry picked from commit 17e03b29f9381a10f08e551f0c88899b7961664f) Signed-off-by: Aanand Prasad --- docs/cli.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/cli.md b/docs/cli.md index e5594871d6c..162189481e5 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -155,7 +155,7 @@ By default, if there are existing containers for a service, `docker-compose up` Several environment variables are available for you to configure Compose's behaviour. Variables starting with `DOCKER_` are the same as those used to configure the -Docker command-line client. If you're using boot2docker, `$(boot2docker shellinit)` +Docker command-line client. If you're using boot2docker, `eval "$(boot2docker shellinit)"` will set them to their correct values. ### COMPOSE\_PROJECT\_NAME From ad4cc5d6dfc0718d44bbcb6497f3483fc05b09f4 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 10 Jun 2015 17:19:24 -0400 Subject: [PATCH 0881/4072] Merge pull request #1497 from aanand/use-1.7-rc1 Run tests against Docker 1.7 RC2 (cherry picked from commit 0e9ccd36f3c672902a5241f557ed81df19255ccc) Signed-off-by: Aanand Prasad --- Dockerfile | 6 ++++-- tests/integration/cli_test.py | 5 +++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index fca5f980315..1ff2d3825d5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -48,11 +48,13 @@ RUN set -ex; \ rm -rf pip-7.0.1; \ rm pip-7.0.1.tar.gz -ENV ALL_DOCKER_VERSIONS 1.6.0 +ENV ALL_DOCKER_VERSIONS 1.6.0 1.7.0-rc2 RUN set -ex; \ curl https://get.docker.com/builds/Linux/x86_64/docker-1.6.0 -o /usr/local/bin/docker-1.6.0; \ - chmod +x /usr/local/bin/docker-1.6.0 + chmod +x /usr/local/bin/docker-1.6.0; \ + curl https://test.docker.com/builds/Linux/x86_64/docker-1.7.0-rc2 -o /usr/local/bin/docker-1.7.0-rc2; \ + chmod +x /usr/local/bin/docker-1.7.0-rc2 # Set the default Docker to be run RUN ln -s /usr/local/bin/docker-1.6.0 /usr/local/bin/docker diff --git a/tests/integration/cli_test.py b/tests/integration/cli_test.py index 92789363e41..4d33808cd49 100644 --- a/tests/integration/cli_test.py +++ b/tests/integration/cli_test.py @@ -1,6 +1,7 @@ from __future__ import absolute_import import sys import os +import shlex from six import StringIO from mock import patch @@ -240,8 +241,8 @@ def test_run_service_with_entrypoint_overridden(self, _): service = self.project.get_service(name) container = service.containers(stopped=True, one_off=True)[0] self.assertEqual( - container.human_readable_command, - u'/bin/echo helloworld' + shlex.split(container.human_readable_command), + [u'/bin/echo', u'helloworld'], ) @patch('dockerpty.start') From b7e8770c4fe8f67c0f50fcb0d39094f5db7e8d3d Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Thu, 11 Jun 2015 21:54:53 +0100 Subject: [PATCH 0882/4072] Merge pull request #1538 from thieman/tnt-serivce-misspelled Correct misspelling of "Service" in an error message (cherry picked from commit bd246fb011aa6805d57eb31d641e3c072c072d63) Signed-off-by: Aanand Prasad --- compose/project.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/project.py b/compose/project.py index d3deeeaf966..bc093628c4f 100644 --- a/compose/project.py +++ b/compose/project.py @@ -171,7 +171,7 @@ def get_net(self, service_dict): try: net = Container.from_id(self.client, net_name) except APIError: - raise ConfigurationError('Serivce "%s" is trying to use the network of "%s", which is not the name of a service or container.' % (service_dict['name'], net_name)) + 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)) else: net = service_dict['net'] From cd7f67018e9fcb4ff423e344dba55fd96289ce48 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 4 Jun 2015 16:21:01 +0100 Subject: [PATCH 0883/4072] Merge pull request #1466 from noironetworks/changing-scale-to-warning Modified scale awareness from exception to warning (cherry picked from commit 7d2a89427c59774a8cbf503a57cb9f3b0d47d1fe) Signed-off-by: Aanand Prasad --- compose/cli/main.py | 12 ++---------- compose/service.py | 8 +++----- tests/integration/service_test.py | 5 ----- 3 files changed, 5 insertions(+), 20 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 7fde4ebaa0c..0c3b85e5cfd 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -12,7 +12,7 @@ from .. import legacy from ..project import NoSuchService, ConfigurationError -from ..service import BuildError, CannotBeScaledError, NeedsBuildError +from ..service import BuildError, NeedsBuildError from ..config import parse_environment from .command import Command from .docopt_command import NoSuchCommand @@ -371,15 +371,7 @@ def scale(self, project, options): except ValueError: raise UserError('Number of containers for service "%s" is not a ' 'number' % service_name) - try: - project.get_service(service_name).scale(num) - except CannotBeScaledError: - raise UserError( - 'Service "%s" cannot be scaled because it specifies a port ' - 'on the host. If multiple containers for this service were ' - 'created, the port would clash.\n\nRemove the ":" from the ' - 'port definition in docker-compose.yml so Docker can choose a random ' - 'port for each container.' % service_name) + project.get_service(service_name).scale(num) def start(self, project, options): """ diff --git a/compose/service.py b/compose/service.py index dd931beeebd..5d0d171d805 100644 --- a/compose/service.py +++ b/compose/service.py @@ -55,10 +55,6 @@ def __init__(self, service, reason): self.reason = reason -class CannotBeScaledError(Exception): - pass - - class ConfigError(ValueError): pass @@ -154,7 +150,9 @@ def scale(self, desired_num): - removes all stopped containers """ if not self.can_be_scaled(): - raise CannotBeScaledError() + log.warn('Service %s specifies a port on the host. If multiple containers ' + 'for this service are created on a single host, the port will clash.' + % self.name) # Create enough containers containers = self.containers(stopped=True) diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 8fd8212ced9..7e88557f932 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -17,7 +17,6 @@ LABEL_VERSION, ) from compose.service import ( - CannotBeScaledError, ConfigError, Service, build_extra_hosts, @@ -526,10 +525,6 @@ def test_scale(self): service.scale(0) self.assertEqual(len(service.containers()), 0) - def test_scale_on_service_that_cannot_be_scaled(self): - service = self.create_service('web', ports=['8000:8000']) - self.assertRaises(CannotBeScaledError, lambda: service.scale(1)) - def test_scale_sets_ports(self): service = self.create_service('web', ports=['8000']) service.scale(2) From 59d6af73fa964b2e1ce65964561d21b409194724 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 12 Jun 2015 11:56:02 -0400 Subject: [PATCH 0884/4072] Merge pull request #1539 from bfirsh/add-image-affinity-to-test Add image affinity to test script (cherry picked from commit 4c2112dbfd4da219f2585569b716b59f7562b034) Signed-off-by: Aanand Prasad --- script/test | 1 + 1 file changed, 1 insertion(+) diff --git a/script/test b/script/test index ab0645fdc1a..700de7779b0 100755 --- a/script/test +++ b/script/test @@ -12,6 +12,7 @@ docker run \ --volume="$(pwd):/code" \ -e DOCKER_VERSIONS \ -e "TAG=$TAG" \ + -e "affinity:image==$TAG" \ --entrypoint="script/test-versions" \ "$TAG" \ "$@" From 363a6563c7ed80731908658e8cc9cf431885bb1b Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Thu, 11 Jun 2015 21:55:33 +0100 Subject: [PATCH 0885/4072] Merge pull request #1537 from aanand/reorder-service-utils Reorder service.py utility methods (cherry picked from commit e3525d64b55ba6b95adab54ac0b5baf22d7740e0) Signed-off-by: Aanand Prasad --- compose/service.py | 135 ++++++++++++++++++++++++++------------------- 1 file changed, 78 insertions(+), 57 deletions(-) diff --git a/compose/service.py b/compose/service.py index 5d0d171d805..8b411517363 100644 --- a/compose/service.py +++ b/compose/service.py @@ -708,32 +708,29 @@ def pull(self, insecure_registry=False): stream_output(output, sys.stdout) -def get_container_data_volumes(container, volumes_option): - """Find the container data volumes that are in `volumes_option`, and return - a mapping of volume bindings for those volumes. - """ - volumes = [] +# Names - volumes_option = volumes_option or [] - container_volumes = container.get('Volumes') or {} - image_volumes = container.image_config['ContainerConfig'].get('Volumes') or {} - for volume in set(volumes_option + image_volumes.keys()): - volume = parse_volume_spec(volume) - # No need to preserve host volumes - if volume.external: - continue +def build_container_name(project, service, number, one_off=False): + bits = [project, service] + if one_off: + bits.append('run') + return '_'.join(bits + [str(number)]) - volume_path = container_volumes.get(volume.internal) - # New volume, doesn't exist in the old container - if not volume_path: - continue - # Copy existing volume from old container - volume = volume._replace(external=volume_path) - volumes.append(build_volume_binding(volume)) +# Images - return dict(volumes) + +def parse_repository_tag(s): + if ":" not in s: + return s, "" + repo, tag = s.rsplit(":", 1) + if "/" in tag: + return s, "" + return repo, tag + + +# Volumes def merge_volume_bindings(volumes_option, previous_container): @@ -752,35 +749,37 @@ def merge_volume_bindings(volumes_option, previous_container): return volume_bindings -def build_container_name(project, service, number, one_off=False): - bits = [project, service] - if one_off: - bits.append('run') - return '_'.join(bits + [str(number)]) +def get_container_data_volumes(container, volumes_option): + """Find the container data volumes that are in `volumes_option`, and return + a mapping of volume bindings for those volumes. + """ + volumes = [] + + volumes_option = volumes_option or [] + container_volumes = container.get('Volumes') or {} + image_volumes = container.image_config['ContainerConfig'].get('Volumes') or {} + for volume in set(volumes_option + image_volumes.keys()): + volume = parse_volume_spec(volume) + # No need to preserve host volumes + if volume.external: + continue -def build_container_labels(label_options, service_labels, number, one_off=False): - labels = label_options or {} - labels.update(label.split('=', 1) for label in service_labels) - labels[LABEL_CONTAINER_NUMBER] = str(number) - labels[LABEL_VERSION] = __version__ - return labels + volume_path = container_volumes.get(volume.internal) + # New volume, doesn't exist in the old container + if not volume_path: + continue + # Copy existing volume from old container + volume = volume._replace(external=volume_path) + volumes.append(build_volume_binding(volume)) -def parse_restart_spec(restart_config): - if not restart_config: - return None - parts = restart_config.split(':') - if len(parts) > 2: - raise ConfigError("Restart %s has incorrect format, should be " - "mode[:max_retry]" % restart_config) - if len(parts) == 2: - name, max_retry_count = parts - else: - name, = parts - max_retry_count = 0 + return dict(volumes) - return {'Name': name, 'MaximumRetryCount': int(max_retry_count)} + +def build_volume_binding(volume_spec): + internal = {'bind': volume_spec.internal, 'ro': volume_spec.mode == 'ro'} + return volume_spec.external, internal def parse_volume_spec(volume_config): @@ -803,18 +802,7 @@ def parse_volume_spec(volume_config): return VolumeSpec(external, internal, mode) -def parse_repository_tag(s): - if ":" not in s: - return s, "" - repo, tag = s.rsplit(":", 1) - if "/" in tag: - return s, "" - return repo, tag - - -def build_volume_binding(volume_spec): - internal = {'bind': volume_spec.internal, 'ro': volume_spec.mode == 'ro'} - return volume_spec.external, internal +# Ports def build_port_bindings(ports): @@ -845,6 +833,39 @@ def split_port(port): return internal_port, (external_ip, external_port or None) +# Labels + + +def build_container_labels(label_options, service_labels, number, one_off=False): + labels = label_options or {} + labels.update(label.split('=', 1) for label in service_labels) + labels[LABEL_CONTAINER_NUMBER] = str(number) + labels[LABEL_VERSION] = __version__ + return labels + + +# Restart policy + + +def parse_restart_spec(restart_config): + if not restart_config: + return None + parts = restart_config.split(':') + if len(parts) > 2: + raise ConfigError("Restart %s has incorrect format, should be " + "mode[:max_retry]" % restart_config) + if len(parts) == 2: + name, max_retry_count = parts + else: + name, = parts + max_retry_count = 0 + + return {'Name': name, 'MaximumRetryCount': int(max_retry_count)} + + +# Extra hosts + + def build_extra_hosts(extra_hosts_config): if not extra_hosts_config: return {} From 8f8693e13ed6c6a3fe518bc1928efa1d536e19e0 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 29 May 2015 12:38:40 +0100 Subject: [PATCH 0886/4072] Merge pull request #1480 from bfirsh/change-sigint-test-to-use-sigstop Change kill SIGINT test to use SIGSTOP (cherry picked from commit a15f996744b4005441b289f6b3fb4eef551b5214) Signed-off-by: Aanand Prasad --- tests/integration/cli_test.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/integration/cli_test.py b/tests/integration/cli_test.py index 4d33808cd49..cb7bc17fcbc 100644 --- a/tests/integration/cli_test.py +++ b/tests/integration/cli_test.py @@ -361,22 +361,22 @@ def test_kill(self): self.assertEqual(len(service.containers(stopped=True)), 1) self.assertFalse(service.containers(stopped=True)[0].is_running) - def test_kill_signal_sigint(self): + def test_kill_signal_sigstop(self): self.command.dispatch(['up', '-d'], None) service = self.project.get_service('simple') self.assertEqual(len(service.containers()), 1) self.assertTrue(service.containers()[0].is_running) - self.command.dispatch(['kill', '-s', 'SIGINT'], None) + self.command.dispatch(['kill', '-s', 'SIGSTOP'], None) self.assertEqual(len(service.containers()), 1) - # The container is still running. It has been only interrupted + # The container is still running. It has only been paused self.assertTrue(service.containers()[0].is_running) - def test_kill_interrupted_service(self): + def test_kill_stopped_service(self): self.command.dispatch(['up', '-d'], None) service = self.project.get_service('simple') - self.command.dispatch(['kill', '-s', 'SIGINT'], None) + self.command.dispatch(['kill', '-s', 'SIGSTOP'], None) self.assertTrue(service.containers()[0].is_running) self.command.dispatch(['kill', '-s', 'SIGKILL'], None) From 4353f7b9f92bc0e6bebd1fa8cf647407890851b1 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Sat, 30 May 2015 08:43:48 -0500 Subject: [PATCH 0887/4072] Merge pull request #1475 from fordhurley/patch-1 Fix markdown formatting for `--service-ports` example (cherry picked from commit d64bf88e26f7b1ce097a6b475799364720bcb6cb) Signed-off-by: Aanand Prasad --- docs/cli.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/cli.md b/docs/cli.md index 162189481e5..1fbd4cb2864 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -95,7 +95,9 @@ specify the `--no-deps` flag: Similarly, if you do want the service's ports to be created and mapped to the host, specify the `--service-ports` flag: - $ docker-compose run --service-ports web python manage.py shell + + $ docker-compose run --service-ports web python manage.py shell + ### scale From 58a7844129c9d3797fc11d1680b77b9e9b31577f Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 29 May 2015 17:12:57 +0100 Subject: [PATCH 0888/4072] Merge pull request #1482 from bfirsh/add-build-and-dist-to-dockerignore Make it possible to run tests remotely (cherry picked from commit c8e096e0895cb3589c4699daa44c299ea23f790c) Signed-off-by: Aanand Prasad --- .dockerignore | 2 ++ script/test | 1 - 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.dockerignore b/.dockerignore index f1b636b3ebd..a03616e534f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,2 +1,4 @@ .git +build +dist venv diff --git a/script/test b/script/test index 700de7779b0..625af09b354 100755 --- a/script/test +++ b/script/test @@ -9,7 +9,6 @@ docker build -t "$TAG" . docker run \ --rm \ --volume="/var/run/docker.sock:/var/run/docker.sock" \ - --volume="$(pwd):/code" \ -e DOCKER_VERSIONS \ -e "TAG=$TAG" \ -e "affinity:image==$TAG" \ From 87b4545b44350e7fe5164071ea975d4e4b5a4d91 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 4 Jun 2015 11:18:23 -0500 Subject: [PATCH 0889/4072] Merge pull request #1508 from thaJeztah/update-dockerproject-links Update dockerproject.com links (cherry picked from commit 417e6ce0c9f67cc719d5f3bfa9e3adbfb16a34eb) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index acd3cbe7ae8..4b18fc9dc14 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,6 @@ Installation and documentation Contributing ------------ -[![Build Status](http://jenkins.dockerproject.com/buildStatus/icon?job=Compose Master)](http://jenkins.dockerproject.com/job/Compose%20Master/) +[![Build Status](http://jenkins.dockerproject.org/buildStatus/icon?job=Compose%20Master)](http://jenkins.dockerproject.org/job/Compose%20Master/) Want to help build Compose? Check out our [contributing documentation](https://github.com/docker/compose/blob/master/CONTRIBUTING.md). From e724a346c7c26b5b1c824ae4760bd414144c56e3 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 8 Jun 2015 12:49:32 -0400 Subject: [PATCH 0890/4072] Merge pull request #1526 from aanand/remove-start-or-create-containers Remove Service.start_or_create_containers() (cherry picked from commit 38a11c4c28b1af644448d519544b876132ae89a8) Signed-off-by: Aanand Prasad --- compose/service.py | 15 --------------- tests/integration/service_test.py | 4 ++-- 2 files changed, 2 insertions(+), 17 deletions(-) diff --git a/compose/service.py b/compose/service.py index 8b411517363..71edd5e5ecd 100644 --- a/compose/service.py +++ b/compose/service.py @@ -394,21 +394,6 @@ def start_container(self, container): container.start() return container - def start_or_create_containers( - self, - insecure_registry=False, - do_build=True): - containers = self.containers(stopped=True) - - if not containers: - new_container = self.create_container( - insecure_registry=insecure_registry, - do_build=do_build, - ) - return [self.start_container(new_container)] - else: - return [self.start_container_if_stopped(c) for c in containers] - def config_hash(self): return json_hash(self.config_dict()) diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 7e88557f932..32de5fa4780 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -501,10 +501,10 @@ def test_port_with_explicit_interface(self): ], }) - def test_start_with_image_id(self): + def test_create_with_image_id(self): # Image id for the current busybox:latest service = self.create_service('foo', image='8c2e06607696') - self.assertTrue(service.start_or_create_containers()) + service.create_container() def test_scale(self): service = self.create_service('web') From 67bc3fabe4d045dff44774a1d9681748d8f990e0 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Sun, 14 Jun 2015 13:28:14 -0400 Subject: [PATCH 0891/4072] Merge pull request #1544 from aanand/fix-volume-deduping Fix volume binds de-duplication (cherry picked from commit 77e594dc9405707ef8787728ae63ca091593f3ba) Signed-off-by: Aanand Prasad --- compose/service.py | 5 +-- requirements.txt | 2 +- tests/unit/service_test.py | 87 +++++++++++++++++++++++++++++++++----- 3 files changed, 79 insertions(+), 15 deletions(-) diff --git a/compose/service.py b/compose/service.py index 71edd5e5ecd..1e91a9f2332 100644 --- a/compose/service.py +++ b/compose/service.py @@ -731,7 +731,7 @@ def merge_volume_bindings(volumes_option, previous_container): volume_bindings.update( get_container_data_volumes(previous_container, volumes_option)) - return volume_bindings + return volume_bindings.values() def get_container_data_volumes(container, volumes_option): @@ -763,8 +763,7 @@ def get_container_data_volumes(container, volumes_option): def build_volume_binding(volume_spec): - internal = {'bind': volume_spec.internal, 'ro': volume_spec.mode == 'ro'} - return volume_spec.external, internal + return volume_spec.internal, "{}:{}:{}".format(*volume_spec) def parse_volume_spec(volume_config): diff --git a/requirements.txt b/requirements.txt index d3909b766ff..47fa1e05bef 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ PyYAML==3.10 -docker-py==1.2.2 +docker-py==1.2.3-rc1 dockerpty==0.3.4 docopt==0.6.1 requests==2.6.1 diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index add48086d4e..fb3a7fcbb98 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -331,9 +331,7 @@ def test_parse_volume_bad_mode(self): def test_build_volume_binding(self): binding = build_volume_binding(parse_volume_spec('/outside:/inside')) - self.assertEqual( - binding, - ('/outside', dict(bind='/inside', ro=False))) + self.assertEqual(binding, ('/inside', '/outside:/inside:rw')) def test_get_container_data_volumes(self): options = [ @@ -360,8 +358,8 @@ def test_get_container_data_volumes(self): }, has_been_inspected=True) expected = { - '/var/lib/docker/aaaaaaaa': {'bind': '/existing/volume', 'ro': False}, - '/var/lib/docker/cccccccc': {'bind': '/mnt/image/data', 'ro': False}, + '/existing/volume': '/var/lib/docker/aaaaaaaa:/existing/volume:rw', + '/mnt/image/data': '/var/lib/docker/cccccccc:/mnt/image/data:rw', } binds = get_container_data_volumes(container, options) @@ -384,11 +382,78 @@ def test_merge_volume_bindings(self): 'Volumes': {'/existing/volume': '/var/lib/docker/aaaaaaaa'}, }, has_been_inspected=True) - expected = { - '/host/volume': {'bind': '/host/volume', 'ro': True}, - '/host/rw/volume': {'bind': '/host/rw/volume', 'ro': False}, - '/var/lib/docker/aaaaaaaa': {'bind': '/existing/volume', 'ro': False}, - } + expected = [ + '/host/volume:/host/volume:ro', + '/host/rw/volume:/host/rw/volume:rw', + '/var/lib/docker/aaaaaaaa:/existing/volume:rw', + ] binds = merge_volume_bindings(options, intermediate_container) - self.assertEqual(binds, expected) + self.assertEqual(set(binds), set(expected)) + + def test_mount_same_host_path_to_two_volumes(self): + service = Service( + 'web', + image='busybox', + volumes=[ + '/host/path:/data1', + '/host/path:/data2', + ], + client=self.mock_client, + ) + + self.mock_client.inspect_image.return_value = { + 'Id': 'ababab', + 'ContainerConfig': { + 'Volumes': {} + } + } + + create_options = service._get_container_create_options( + override_options={}, + number=1, + ) + + self.assertEqual( + set(create_options['host_config']['Binds']), + set([ + '/host/path:/data1:rw', + '/host/path:/data2:rw', + ]), + ) + + def test_different_host_path_in_container_json(self): + service = Service( + 'web', + image='busybox', + volumes=['/host/path:/data'], + client=self.mock_client, + ) + + self.mock_client.inspect_image.return_value = { + 'Id': 'ababab', + 'ContainerConfig': { + 'Volumes': { + '/data': {}, + } + } + } + + self.mock_client.inspect_container.return_value = { + 'Id': '123123123', + 'Image': 'ababab', + 'Volumes': { + '/data': '/mnt/sda1/host/path', + }, + } + + create_options = service._get_container_create_options( + override_options={}, + number=1, + previous_container=Container(self.mock_client, {'Id': '123123123'}), + ) + + self.assertEqual( + create_options['host_config']['Binds'], + ['/mnt/sda1/host/path:/data:rw'], + ) From 719954b02f8d6f03e20bde2409b074295dc5da98 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 15 Jun 2015 10:36:37 -0700 Subject: [PATCH 0892/4072] Merge pull request #1545 from moxiegirl/test-tooling Updated for new documentation tooling (cherry picked from commit aaccd12d3df2ab64f44db5c6cd8bae282a314419) Signed-off-by: Aanand Prasad --- docs/Dockerfile | 31 +++++++---- docs/Makefile | 55 ++++++++++++++++++ docs/README.md | 77 ++++++++++++++++++++++++++ docs/cli.md | 17 ++++-- docs/completion.md | 18 ++++-- docs/{index.md => compose-overview.md} | 20 ++++--- docs/django.md | 18 ++++-- docs/env.md | 20 ++++--- docs/extends.md | 17 ++++-- docs/install.md | 21 ++++--- docs/mkdocs.yml | 12 ---- docs/production.md | 13 ++++- docs/rails.md | 21 ++++--- docs/wordpress.md | 21 ++++--- docs/yml.md | 19 ++++--- 15 files changed, 287 insertions(+), 93 deletions(-) create mode 100644 docs/Makefile create mode 100644 docs/README.md rename docs/{index.md => compose-overview.md} (96%) delete mode 100644 docs/mkdocs.yml diff --git a/docs/Dockerfile b/docs/Dockerfile index 59ef66cdd80..55e7ce700b8 100644 --- a/docs/Dockerfile +++ b/docs/Dockerfile @@ -1,15 +1,24 @@ -FROM docs/base:latest -MAINTAINER Sven Dowideit (@SvenDowideit) +FROM docs/base:hugo +MAINTAINER Mary Anthony (@moxiegirl) -# to get the git info for this repo +# To get the git info for this repo COPY . /src -# Reset the /docs dir so we can replace the theme meta with the new repo's git info -RUN git reset --hard +COPY . /docs/content/compose/ -RUN grep "__version" /src/compose/__init__.py | sed "s/.*'\(.*\)'/\1/" > /docs/VERSION -COPY docs/* /docs/sources/compose/ -COPY docs/mkdocs.yml /docs/mkdocs-compose.yml - -# Then build everything together, ready for mkdocs -RUN /docs/build.sh +# Sed to process GitHub Markdown +# 1-2 Remove comment code from metadata block +# 3 Remove .md extension from link text +# 4 Change ](/ to ](/project/ in links +# 5 Change ](word) to ](/project/word) +# 6 Change ](../../ to ](/project/ +# 7 Change ](../ to ](/project/word) +# +# +RUN find /docs/content/compose -type f -name "*.md" -exec sed -i.old \ + -e '/^/g' \ + -e '/^/g' \ + -e 's/\([(]\)\(.*\)\(\.md\)/\1\2/g' \ + -e 's/\(\]\)\([(]\)\(\/\)/\1\2\/compose\//g' \ + -e 's/\(\][(]\)\([A-z]*[)]\)/\]\(\/compose\/\2/g' \ + -e 's/\(\][(]\)\(\.\.\/\)/\1\/compose\//g' {} \; diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 00000000000..021e8f6e5ea --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,55 @@ +.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 + +# to allow `make DOCSDIR=docs docs-shell` (to create a bind mount in docs) +DOCS_MOUNT := $(if $(DOCSDIR),-v $(CURDIR)/$(DOCSDIR):/$(DOCSDIR)) + +# 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) +DOCKER_IMAGE := docker$(if $(GIT_BRANCH),:$(GIT_BRANCH)) +DOCKER_DOCS_IMAGE := docs-base$(if $(GIT_BRANCH),:$(GIT_BRANCH)) + + +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) + +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 + + +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/README.md b/docs/README.md new file mode 100644 index 00000000000..00736e476b9 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,77 @@ +# Contributing to the Docker Compose documentation + +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. + +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 in your `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 appears 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="smn_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. diff --git a/docs/cli.md b/docs/cli.md index 1fbd4cb2864..a2167d9c3fc 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1,9 +1,16 @@ -page_title: Compose CLI reference -page_description: Compose CLI reference -page_keywords: fig, composition, compose, docker, orchestration, cli, reference + -# CLI reference +# Compose CLI reference Most Docker Compose commands are run against one or more services. If the service is not specified, the command will apply to all services. @@ -185,7 +192,7 @@ Configures the path to the `ca.pem`, `cert.pem`, and `key.pem` files used for TL ## Compose documentation -- [User guide](index.md) +- [User guide](compose-overview.md) - [Installing Compose](install.md) - [Get started with Django](django.md) - [Get started with Rails](rails.md) diff --git a/docs/completion.md b/docs/completion.md index 5168971f8b0..7fb696d80ee 100644 --- a/docs/completion.md +++ b/docs/completion.md @@ -1,7 +1,13 @@ ---- -layout: default -title: Command Completion ---- + # Command Completion @@ -53,11 +59,11 @@ Enjoy working with Compose faster and with less typos! ## Compose documentation -- [User guide](index.md) +- [User guide](compose-overview.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](cli.md) - [Yaml file reference](yml.md) -- [Compose environment variables](env.md) +- [Compose environment variables](env.md) \ No newline at end of file diff --git a/docs/index.md b/docs/compose-overview.md similarity index 96% rename from docs/index.md rename to docs/compose-overview.md index 981a02702fc..33629957a6e 100644 --- a/docs/index.md +++ b/docs/compose-overview.md @@ -1,11 +1,15 @@ -page_title: Compose: Multi-container orchestration for Docker -page_description: Introduction and Overview of Compose -page_keywords: documentation, docs, docker, compose, orchestration, containers - - -# Docker Compose - -## Overview + + + +# Overview of Docker Compose Compose is a tool for defining and running multi-container applications with Docker. With Compose, you define a multi-container application in a single diff --git a/docs/django.md b/docs/django.md index 4cbebe04158..c44329e1cdd 100644 --- a/docs/django.md +++ b/docs/django.md @@ -1,10 +1,16 @@ -page_title: Quickstart Guide: Compose and Django -page_description: Getting started with Docker Compose and Django -page_keywords: documentation, docs, docker, compose, orchestration, containers, -django + -## Getting started with Compose and Django +## Quickstart Guide: Compose and Django This Quick-start Guide will demonstrate how to use Compose to set up and run a @@ -119,7 +125,7 @@ example, run `docker-compose up` and in another terminal run: ## More Compose documentation -- [User guide](index.md) +- [User guide](compose-overview.md) - [Installing Compose](install.md) - [Get started with Django](django.md) - [Get started with Rails](rails.md) diff --git a/docs/env.md b/docs/env.md index a4b543ae370..73496f32f51 100644 --- a/docs/env.md +++ b/docs/env.md @@ -1,9 +1,15 @@ ---- -layout: default -title: Compose environment variables reference ---- - -Environment variables reference + + +# 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](yml.md#links) for details. @@ -34,7 +40,7 @@ Fully qualified container name, e.g. `DB_1_NAME=/myapp_web_1/myapp_db_1` ## Compose documentation -- [User guide](index.md) +- [User guide](compose-overview.md) - [Installing Compose](install.md) - [Get started with Django](django.md) - [Get started with Rails](rails.md) diff --git a/docs/extends.md b/docs/extends.md index fd372ce2d55..8527c81b3cf 100644 --- a/docs/extends.md +++ b/docs/extends.md @@ -1,6 +1,13 @@ -page_title: Extending services in Compose -page_description: How to use Docker Compose's "extends" keyword to share configuration between files and projects -page_keywords: fig, composition, compose, docker, orchestration, documentation, docs + ## Extending services in Compose @@ -79,7 +86,7 @@ For full details on how to use `extends`, refer to the [reference](#reference). ### Example use case In this example, you’ll repurpose the example app from the [quick start -guide](index.md). (If you're not familiar with Compose, it's recommended that +guide](compose-overview.md). (If you're not familiar with Compose, it's recommended that you go through the quick start first.) This example assumes you want to use Compose both to develop an application locally and then deploy it to a production environment. @@ -364,7 +371,7 @@ volumes: ## Compose documentation -- [User guide](index.md) +- [User guide](compose-overview.md) - [Installing Compose](install.md) - [Get started with Django](django.md) - [Get started with Rails](rails.md) diff --git a/docs/install.md b/docs/install.md index a521ec06cde..ec0e6e4d511 100644 --- a/docs/install.md +++ b/docs/install.md @@ -1,14 +1,21 @@ -page_title: Installing Compose -page_description: How to install Docker Compose -page_keywords: compose, orchestration, install, installation, docker, documentation + -## Installing Compose +# Install Docker Compose To install Compose, you'll need to install Docker first. You'll then install Compose with a `curl` command. -### Install Docker +## Install Docker First, install Docker version 1.6 or greater: @@ -16,7 +23,7 @@ First, install Docker version 1.6 or greater: - [Instructions for Ubuntu](http://docs.docker.com/installation/ubuntulinux/) - [Instructions for other systems](http://docs.docker.com/installation/) -### Install Compose +## Install Compose To install Compose, run the following commands: @@ -38,7 +45,7 @@ You can test the installation by running `docker-compose --version`. ## Compose documentation -- [User guide](index.md) +- [User guide](compose-overview.md) - [Get started with Django](django.md) - [Get started with Rails](rails.md) - [Get started with Wordpress](wordpress.md) diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml deleted file mode 100644 index 428439bc425..00000000000 --- a/docs/mkdocs.yml +++ /dev/null @@ -1,12 +0,0 @@ - -- ['compose/index.md', 'User Guide', 'Docker Compose' ] -- ['compose/production.md', 'User Guide', 'Using Compose in production' ] -- ['compose/extends.md', 'User Guide', 'Extending services in Compose'] -- ['compose/install.md', 'Installation', 'Docker Compose'] -- ['compose/cli.md', 'Reference', 'Compose command line'] -- ['compose/yml.md', 'Reference', 'Compose yml'] -- ['compose/env.md', 'Reference', 'Compose ENV variables'] -- ['compose/completion.md', 'Reference', 'Compose commandline completion'] -- ['compose/django.md', 'Examples', 'Getting started with Compose and Django'] -- ['compose/rails.md', 'Examples', 'Getting started with Compose and Rails'] -- ['compose/wordpress.md', 'Examples', 'Getting started with Compose and Wordpress'] diff --git a/docs/production.md b/docs/production.md index 60a6873daf2..294f3c4e865 100644 --- a/docs/production.md +++ b/docs/production.md @@ -1,6 +1,13 @@ -page_title: Using Compose in production -page_description: Guide to using Docker Compose in production -page_keywords: documentation, docs, docker, compose, orchestration, containers, production + ## Using Compose in production diff --git a/docs/rails.md b/docs/rails.md index aedb4c6e767..2ff6f175215 100644 --- a/docs/rails.md +++ b/docs/rails.md @@ -1,10 +1,15 @@ -page_title: Quickstart Guide: Compose and Rails -page_description: Getting started with Docker Compose and Rails -page_keywords: documentation, docs, docker, compose, orchestration, containers, -rails - - -## Getting started with Compose and Rails + + +## Quickstart Guide: 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). @@ -119,7 +124,7 @@ you're using Boot2docker, `boot2docker ip` will tell you its address). ## More Compose documentation -- [User guide](index.md) +- [User guide](compose-overview.md) - [Installing Compose](install.md) - [Get started with Django](django.md) - [Get started with Rails](rails.md) diff --git a/docs/wordpress.md b/docs/wordpress.md index b40d1a9f084..ad0e62966f2 100644 --- a/docs/wordpress.md +++ b/docs/wordpress.md @@ -1,14 +1,21 @@ -page_title: Quickstart Guide: Compose and Wordpress -page_description: Getting started with Docker Compose and Rails -page_keywords: documentation, docs, docker, compose, orchestration, containers, -wordpress + -## Getting started with Compose and Wordpress + +# Quickstart Guide: Compose and Wordpress You can use Compose to easily run Wordpress in an isolated environment built with Docker containers. -### Define the project +## Define the project First, [Install Compose](install.md) and then download Wordpress into the current directory: @@ -114,7 +121,7 @@ address). ## More Compose documentation -- [User guide](index.md) +- [User guide](compose-overview.md) - [Installing Compose](install.md) - [Get started with Django](django.md) - [Get started with Rails](rails.md) diff --git a/docs/yml.md b/docs/yml.md index df791bc98fd..80d6d719f50 100644 --- a/docs/yml.md +++ b/docs/yml.md @@ -1,10 +1,13 @@ ---- -layout: default -title: docker-compose.yml reference -page_title: docker-compose.yml reference -page_description: docker-compose.yml reference -page_keywords: fig, composition, compose, docker ---- + + # docker-compose.yml reference @@ -390,7 +393,7 @@ read_only: true ## Compose documentation -- [User guide](index.md) +- [User guide](compose-overview.md) - [Installing Compose](install.md) - [Get started with Django](django.md) - [Get started with Rails](rails.md) From 09018855cebceac34525122feb76c1885a5f4057 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 15 Jun 2015 13:43:57 -0400 Subject: [PATCH 0893/4072] Merge pull request #1550 from aanand/update-docker-py Update setup.py with new docker-py minimum (cherry picked from commit b3b44b8e4c7ee7463136bb13cf6c3d759e6d87e9) Signed-off-by: Aanand Prasad --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 9364f57f39e..a94d87374f5 100644 --- a/setup.py +++ b/setup.py @@ -30,7 +30,7 @@ def find_version(*file_paths): 'requests >= 2.6.1, < 2.7', 'texttable >= 0.8.1, < 0.9', 'websocket-client >= 0.11.0, < 1.0', - 'docker-py >= 1.2.2, < 1.3', + 'docker-py >= 1.2.3-rc1, < 1.3', 'dockerpty >= 0.3.4, < 0.4', 'six >= 1.3.0, < 2', ] From f353d9fbc0df6835ef373c72342feae856e0276d Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 15 Jun 2015 10:58:44 -0700 Subject: [PATCH 0894/4072] Merge pull request #1406 from vdemeester/667-compose-port-scale Fixing docker-compose port with scale (#667) (cherry picked from commit 5b2a0cc73d104340964b299c11723e465ea7c112) Signed-off-by: Aanand Prasad --- compose/cli/main.py | 7 +++--- .../docker-compose.yml | 6 +++++ tests/integration/cli_test.py | 22 +++++++++++++++++++ 3 files changed, 32 insertions(+), 3 deletions(-) create mode 100644 tests/fixtures/ports-composefile-scale/docker-compose.yml diff --git a/compose/cli/main.py b/compose/cli/main.py index 0c3b85e5cfd..4f3f11e4e50 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -169,13 +169,14 @@ def port(self, project, options): Usage: port [options] SERVICE PRIVATE_PORT Options: - --protocol=proto tcp or udp (defaults to tcp) + --protocol=proto tcp or udp [default: tcp] --index=index index of the container if there are multiple - instances of a service (defaults to 1) + instances of a service [default: 1] """ + index = int(options.get('--index')) service = project.get_service(options['SERVICE']) try: - container = service.get_container(number=options.get('--index') or 1) + container = service.get_container(number=index) except ValueError as e: raise UserError(str(e)) print(container.get_local_port( diff --git a/tests/fixtures/ports-composefile-scale/docker-compose.yml b/tests/fixtures/ports-composefile-scale/docker-compose.yml new file mode 100644 index 00000000000..1a2bb485bc7 --- /dev/null +++ b/tests/fixtures/ports-composefile-scale/docker-compose.yml @@ -0,0 +1,6 @@ + +simple: + image: busybox:latest + command: /bin/sleep 300 + ports: + - '3000' diff --git a/tests/integration/cli_test.py b/tests/integration/cli_test.py index cb7bc17fcbc..2d1f1f76e6d 100644 --- a/tests/integration/cli_test.py +++ b/tests/integration/cli_test.py @@ -1,4 +1,5 @@ from __future__ import absolute_import +from operator import attrgetter import sys import os import shlex @@ -436,6 +437,27 @@ def get_port(number, mock_stdout): self.assertEqual(get_port(3001), "0.0.0.0:49152") self.assertEqual(get_port(3002), "") + def test_port_with_scale(self): + + self.command.base_dir = 'tests/fixtures/ports-composefile-scale' + self.command.dispatch(['scale', 'simple=2'], None) + containers = sorted( + self.project.containers(service_names=['simple']), + key=attrgetter('name')) + + @patch('sys.stdout', new_callable=StringIO) + def get_port(number, mock_stdout, index=None): + if index is None: + self.command.dispatch(['port', 'simple', str(number)], None) + else: + self.command.dispatch(['port', '--index=' + str(index), 'simple', str(number)], None) + return mock_stdout.getvalue().rstrip() + + self.assertEqual(get_port(3000), containers[0].get_local_port(3000)) + self.assertEqual(get_port(3000, index=1), containers[0].get_local_port(3000)) + self.assertEqual(get_port(3000, index=2), containers[1].get_local_port(3000)) + self.assertEqual(get_port(3002), "") + def test_env_file_relative_to_compose_file(self): config_path = os.path.abspath('tests/fixtures/env-file/docker-compose.yml') self.command.dispatch(['-f', config_path, 'up', '-d'], None) From c421d23c34fa974df79faeaaf7ca9c15226bfc27 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 15 Jun 2015 13:32:58 -0700 Subject: [PATCH 0895/4072] Update Swarm docs - Link to libnetwork - Building now works, but is complicated by scaling - Document how to set constraints/affinity Signed-off-by: Aanand Prasad --- SWARM.md | 37 ++++++++++++++++++++++--------------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/SWARM.md b/SWARM.md index 6cb24b601d4..1ea4e25f30b 100644 --- a/SWARM.md +++ b/SWARM.md @@ -3,30 +3,37 @@ 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, the current extent of integration is minimal: 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, defeating much of the purpose of using Swarm in the first place. +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. -Still, Compose and Swarm can be useful in a “batch processing” scenario (where a large number of containers need to be spun up and down to do independent computation) or a “shared cluster” scenario (where multiple teams want to deploy apps on a cluster without worrying about where to put them). - -A number of things need to happen before full integration is achieved, which are documented below. - -Links and networking --------------------- - -The primary thing stopping multi-container apps from working seamlessly on Swarm is getting them to talk to one another: enabling private communication between containers on different hosts hasn’t been solved in a non-hacky way. - -Long-term, networking is [getting overhauled](https://github.com/docker/docker/issues/9983) 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**. +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 -------- -`docker build` against a Swarm cluster is not implemented, so for now the `build` option will not work - you will need to manually build your service's image, push it somewhere and use `image` to instruct Compose to pull it. Here's an example using the Docker Hub: +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 - links: ["db"] - db: - image: postgres + $ 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/). From 464ab3d7273317507fe42fe1ef03ac3e08d0bd86 Mon Sep 17 00:00:00 2001 From: Dan O'Reilly Date: Mon, 15 Jun 2015 23:06:06 -0400 Subject: [PATCH 0896/4072] Add a method specifically for service name validation. Signed-off-by: Dan O'Reilly --- compose/project.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/compose/project.py b/compose/project.py index e6ec6d6768d..d7d350611d3 100644 --- a/compose/project.py +++ b/compose/project.py @@ -99,6 +99,16 @@ def get_service(self, name): raise NoSuchService(name) + def validate_service_names(self, service_names): + """ + Validate that the given list of service names only contains valid + services. Raises NoSuchService if one of the names is invalid. + """ + valid_names = self.service_names + for name in service_names: + if name not in valid_names: + raise NoSuchService(name) + def get_services(self, service_names=None, include_deps=False): """ Returns a list of this project's services filtered @@ -275,8 +285,7 @@ def remove_stopped(self, service_names=None, **options): def containers(self, service_names=None, stopped=False, one_off=False): if service_names: - # Will raise NoSuchService if one of the names is invalid - self.get_services(service_names) + self.validate_service_names(service_names) containers = [ Container.from_ps(self.client, container) for container in self.client.containers( From e0af1a44ea8da7d0d6f072690f4b5a768845dcec Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 16 Jun 2015 10:35:08 -0700 Subject: [PATCH 0897/4072] Use Docker 1.7 RC5 Signed-off-by: Aanand Prasad --- Dockerfile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index b25e824cdb9..4f2a0305d93 100644 --- a/Dockerfile +++ b/Dockerfile @@ -48,13 +48,13 @@ RUN set -ex; \ rm -rf pip-7.0.1; \ rm pip-7.0.1.tar.gz -ENV ALL_DOCKER_VERSIONS 1.6.0 1.7.0-rc3 +ENV ALL_DOCKER_VERSIONS 1.6.0 1.7.0-rc5 RUN set -ex; \ curl https://get.docker.com/builds/Linux/x86_64/docker-1.6.0 -o /usr/local/bin/docker-1.6.0; \ chmod +x /usr/local/bin/docker-1.6.0; \ - curl https://test.docker.com/builds/Linux/x86_64/docker-1.7.0-rc3 -o /usr/local/bin/docker-1.7.0-rc3; \ - chmod +x /usr/local/bin/docker-1.7.0-rc3 + curl https://test.docker.com/builds/Linux/x86_64/docker-1.7.0-rc5 -o /usr/local/bin/docker-1.7.0-rc5; \ + chmod +x /usr/local/bin/docker-1.7.0-rc5 # Set the default Docker to be run RUN ln -s /usr/local/bin/docker-1.6.0 /usr/local/bin/docker From 7fa4cd1214deea8f61ce5195ecbe377f70d1e311 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 16 Jun 2015 16:26:40 -0700 Subject: [PATCH 0898/4072] Merge pull request #1552 from aanand/add-upgrade-instructions Add upgrading instructions to install docs (cherry picked from commit bc7161b475f7032bfc36e177935e9d7b13354718) --- docs/install.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/install.md b/docs/install.md index ec0e6e4d511..c1abd4fd6d0 100644 --- a/docs/install.md +++ b/docs/install.md @@ -43,6 +43,18 @@ Compose can also be installed as a Python package: No further steps are required; Compose should now be successfully installed. You can test the installation by running `docker-compose --version`. +### Upgrading + +If you're coming 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 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 myapp_web_1 myapp_db_1 ... + ## Compose documentation - [User guide](compose-overview.md) From b76ac6e633c5e8881162a34c6afdc2f50874aa02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Gruchet?= Date: Fri, 20 Mar 2015 20:14:30 +0100 Subject: [PATCH 0899/4072] Added support to option mac-address MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Sébastien Gruchet Updated doc Signed-off-by: Sébastien Gruchet Fixed LINT errors Signed-off-by: Sébastien Gruchet Changed mac-address entry order in config keys Signed-off-by: Sébastien Gruchet Changed attributes order in docs/yml.md Signed-off-by: Sébastien Gruchet --- compose/config.py | 1 + docs/yml.md | 5 ++++- tests/integration/service_test.py | 6 ++++++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/compose/config.py b/compose/config.py index efc50075e3c..cbdeca2d08d 100644 --- a/compose/config.py +++ b/compose/config.py @@ -23,6 +23,7 @@ 'image', 'labels', 'links', + 'mac_address', 'mem_limit', 'net', 'log_driver', diff --git a/docs/yml.md b/docs/yml.md index df791bc98fd..ed0e6ad870d 100644 --- a/docs/yml.md +++ b/docs/yml.md @@ -362,7 +362,7 @@ security_opt: - label:role:ROLE ``` -### working\_dir, entrypoint, user, hostname, domainname, mem\_limit, privileged, restart, stdin\_open, tty, cpu\_shares, cpuset, read\_only +### working\_dir, entrypoint, user, hostname, domainname, mac\_address, mem\_limit, privileged, restart, stdin\_open, tty, cpu\_shares, cpuset, read\_only Each of these is a single value, analogous to its [docker run](https://docs.docker.com/reference/run/) counterpart. @@ -378,6 +378,8 @@ user: postgresql hostname: foo domainname: foo.com +mac_address: 02:42:ac:11:65:43 + mem_limit: 1000000000 privileged: true @@ -386,6 +388,7 @@ restart: always stdin_open: true tty: true read_only: true + ``` ## Compose documentation diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 7e88557f932..4067f419d31 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -198,6 +198,12 @@ def test_create_container_with_security_opt(self): service.start_container(container) self.assertEqual(set(container.get('HostConfig.SecurityOpt')), set(security_opt)) + def test_create_container_with_mac_address(self): + service = self.create_service('db', mac_address='02:42:ac:11:65:43') + container = service.create_container() + service.start_container(container) + self.assertEqual(container.inspect()['Config']['MacAddress'], '02:42:ac:11:65:43') + def test_create_container_with_specified_volume(self): host_path = '/tmp/host-path' container_path = '/container-path' From c26b1c8ee9b95f8739fcd3197b838a530dd6d4f3 Mon Sep 17 00:00:00 2001 From: Mary Anthony Date: Mon, 15 Jun 2015 20:52:55 -0700 Subject: [PATCH 0900/4072] Entering fixes from Hugo renaming compose-overview back to index Updating with fixes per Aanand. And others found through test Signed-off-by: Mary Anthony --- docs/Dockerfile | 14 +++++++------- docs/cli.md | 2 +- docs/completion.md | 2 +- docs/django.md | 2 +- docs/env.md | 2 +- docs/extends.md | 4 ++-- docs/{compose-overview.md => index.md} | 0 docs/install.md | 2 +- docs/rails.md | 2 +- docs/wordpress.md | 2 +- docs/yml.md | 2 +- 11 files changed, 17 insertions(+), 17 deletions(-) rename docs/{compose-overview.md => index.md} (100%) diff --git a/docs/Dockerfile b/docs/Dockerfile index 55e7ce700b8..a49c1e7f376 100644 --- a/docs/Dockerfile +++ b/docs/Dockerfile @@ -8,17 +8,17 @@ COPY . /docs/content/compose/ # Sed to process GitHub Markdown # 1-2 Remove comment code from metadata block -# 3 Remove .md extension from link text -# 4 Change ](/ to ](/project/ in links -# 5 Change ](word) to ](/project/word) -# 6 Change ](../../ to ](/project/ -# 7 Change ](../ to ](/project/word) +# 3 Change ](/word to ](/project/ in links +# 4 Change ](word.md) to ](/project/word) +# 5 Remove .md extension from link text +# 6 Change ](../ to ](/project/word) +# 7 Change ](../../ to ](/project/ --> not implemented # # RUN find /docs/content/compose -type f -name "*.md" -exec sed -i.old \ -e '/^/g' \ -e '/^/g' \ - -e 's/\([(]\)\(.*\)\(\.md\)/\1\2/g' \ -e 's/\(\]\)\([(]\)\(\/\)/\1\2\/compose\//g' \ - -e 's/\(\][(]\)\([A-z]*[)]\)/\]\(\/compose\/\2/g' \ + -e 's/\(\][(]\)\([A-z].*\)\(\.md\)/\1\/compose\/\2/g' \ + -e 's/\([(]\)\(.*\)\(\.md\)/\1\2/g' \ -e 's/\(\][(]\)\(\.\.\/\)/\1\/compose\//g' {} \; diff --git a/docs/cli.md b/docs/cli.md index a2167d9c3fc..61a6aa6ddef 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -192,7 +192,7 @@ Configures the path to the `ca.pem`, `cert.pem`, and `key.pem` files used for TL ## Compose documentation -- [User guide](compose-overview.md) +- [User guide](/) - [Installing Compose](install.md) - [Get started with Django](django.md) - [Get started with Rails](rails.md) diff --git a/docs/completion.md b/docs/completion.md index 7fb696d80ee..3856d2701e8 100644 --- a/docs/completion.md +++ b/docs/completion.md @@ -59,7 +59,7 @@ Enjoy working with Compose faster and with less typos! ## Compose documentation -- [User guide](compose-overview.md) +- [User guide](/) - [Installing Compose](install.md) - [Get started with Django](django.md) - [Get started with Rails](rails.md) diff --git a/docs/django.md b/docs/django.md index c44329e1cdd..84fdcbfe5ea 100644 --- a/docs/django.md +++ b/docs/django.md @@ -125,7 +125,7 @@ example, run `docker-compose up` and in another terminal run: ## More Compose documentation -- [User guide](compose-overview.md) +- [User guide](/) - [Installing Compose](install.md) - [Get started with Django](django.md) - [Get started with Rails](rails.md) diff --git a/docs/env.md b/docs/env.md index 73496f32f51..e38e6d50cc4 100644 --- a/docs/env.md +++ b/docs/env.md @@ -40,7 +40,7 @@ Fully qualified container name, e.g. `DB_1_NAME=/myapp_web_1/myapp_db_1` ## Compose documentation -- [User guide](compose-overview.md) +- [User guide](/) - [Installing Compose](install.md) - [Get started with Django](django.md) - [Get started with Rails](rails.md) diff --git a/docs/extends.md b/docs/extends.md index 8527c81b3cf..054462b8960 100644 --- a/docs/extends.md +++ b/docs/extends.md @@ -86,7 +86,7 @@ For full details on how to use `extends`, refer to the [reference](#reference). ### Example use case In this example, you’ll repurpose the example app from the [quick start -guide](compose-overview.md). (If you're not familiar with Compose, it's recommended that +guide](index.md). (If you're not familiar with Compose, it's recommended that you go through the quick start first.) This example assumes you want to use Compose both to develop an application locally and then deploy it to a production environment. @@ -371,7 +371,7 @@ volumes: ## Compose documentation -- [User guide](compose-overview.md) +- [User guide](/) - [Installing Compose](install.md) - [Get started with Django](django.md) - [Get started with Rails](rails.md) diff --git a/docs/compose-overview.md b/docs/index.md similarity index 100% rename from docs/compose-overview.md rename to docs/index.md diff --git a/docs/install.md b/docs/install.md index c1abd4fd6d0..ac35c8d9f46 100644 --- a/docs/install.md +++ b/docs/install.md @@ -57,7 +57,7 @@ Alternatively, if you're not worried about keeping them, you can remove them - C ## Compose documentation -- [User guide](compose-overview.md) +- [User guide](/) - [Get started with Django](django.md) - [Get started with Rails](rails.md) - [Get started with Wordpress](wordpress.md) diff --git a/docs/rails.md b/docs/rails.md index 2ff6f175215..cb8078647d6 100644 --- a/docs/rails.md +++ b/docs/rails.md @@ -124,7 +124,7 @@ you're using Boot2docker, `boot2docker ip` will tell you its address). ## More Compose documentation -- [User guide](compose-overview.md) +- [User guide](/) - [Installing Compose](install.md) - [Get started with Django](django.md) - [Get started with Rails](rails.md) diff --git a/docs/wordpress.md b/docs/wordpress.md index ad0e62966f2..aa62e4e4e7a 100644 --- a/docs/wordpress.md +++ b/docs/wordpress.md @@ -121,7 +121,7 @@ address). ## More Compose documentation -- [User guide](compose-overview.md) +- [User guide](/) - [Installing Compose](install.md) - [Get started with Django](django.md) - [Get started with Rails](rails.md) diff --git a/docs/yml.md b/docs/yml.md index 798f8918a33..70fb385ccb9 100644 --- a/docs/yml.md +++ b/docs/yml.md @@ -396,7 +396,7 @@ read_only: true ## Compose documentation -- [User guide](compose-overview.md) +- [User guide](/) - [Installing Compose](install.md) - [Get started with Django](django.md) - [Get started with Rails](rails.md) From ae96e1af16d51c31553072fbc364977315b41aa9 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 18 Jun 2015 10:34:34 -0700 Subject: [PATCH 0901/4072] Use Docker 1.7.0 Signed-off-by: Aanand Prasad --- Dockerfile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 4f2a0305d93..98dc59c555b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -48,13 +48,13 @@ RUN set -ex; \ rm -rf pip-7.0.1; \ rm pip-7.0.1.tar.gz -ENV ALL_DOCKER_VERSIONS 1.6.0 1.7.0-rc5 +ENV ALL_DOCKER_VERSIONS 1.6.0 1.7.0 RUN set -ex; \ curl https://get.docker.com/builds/Linux/x86_64/docker-1.6.0 -o /usr/local/bin/docker-1.6.0; \ chmod +x /usr/local/bin/docker-1.6.0; \ - curl https://test.docker.com/builds/Linux/x86_64/docker-1.7.0-rc5 -o /usr/local/bin/docker-1.7.0-rc5; \ - chmod +x /usr/local/bin/docker-1.7.0-rc5 + curl https://test.docker.com/builds/Linux/x86_64/docker-1.7.0 -o /usr/local/bin/docker-1.7.0; \ + chmod +x /usr/local/bin/docker-1.7.0 # Set the default Docker to be run RUN ln -s /usr/local/bin/docker-1.6.0 /usr/local/bin/docker From ac56ef3d659f04164aa44f1a4f0c991fb5eb6060 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 18 Jun 2015 11:11:51 -0700 Subject: [PATCH 0902/4072] Update docker-py to 1.2.3 final Signed-off-by: Aanand Prasad --- requirements.txt | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 47fa1e05bef..69bd4c5f95c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ PyYAML==3.10 -docker-py==1.2.3-rc1 +docker-py==1.2.3 dockerpty==0.3.4 docopt==0.6.1 requests==2.6.1 diff --git a/setup.py b/setup.py index a94d87374f5..d2e81e175bf 100644 --- a/setup.py +++ b/setup.py @@ -30,7 +30,7 @@ def find_version(*file_paths): 'requests >= 2.6.1, < 2.7', 'texttable >= 0.8.1, < 0.9', 'websocket-client >= 0.11.0, < 1.0', - 'docker-py >= 1.2.3-rc1, < 1.3', + 'docker-py >= 1.2.3, < 1.3', 'dockerpty >= 0.3.4, < 0.4', 'six >= 1.3.0, < 2', ] From c3c5d91c47f00d607b68f345e367ed1b828852f8 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 18 Jun 2015 10:20:10 -0700 Subject: [PATCH 0903/4072] Merge pull request #1563 from moxiegirl/hugo-test-fixes Hugo final 1.7 Documentation PR -- please read carefully (cherry picked from commit 4e73e86d9480de0be87fa5390d915346a435ac26) Signed-off-by: Aanand Prasad --- docs/Dockerfile | 14 +++++++------- docs/cli.md | 2 +- docs/completion.md | 2 +- docs/django.md | 2 +- docs/env.md | 2 +- docs/extends.md | 4 ++-- docs/{compose-overview.md => index.md} | 0 docs/install.md | 2 +- docs/rails.md | 2 +- docs/wordpress.md | 2 +- docs/yml.md | 2 +- 11 files changed, 17 insertions(+), 17 deletions(-) rename docs/{compose-overview.md => index.md} (100%) diff --git a/docs/Dockerfile b/docs/Dockerfile index 55e7ce700b8..a49c1e7f376 100644 --- a/docs/Dockerfile +++ b/docs/Dockerfile @@ -8,17 +8,17 @@ COPY . /docs/content/compose/ # Sed to process GitHub Markdown # 1-2 Remove comment code from metadata block -# 3 Remove .md extension from link text -# 4 Change ](/ to ](/project/ in links -# 5 Change ](word) to ](/project/word) -# 6 Change ](../../ to ](/project/ -# 7 Change ](../ to ](/project/word) +# 3 Change ](/word to ](/project/ in links +# 4 Change ](word.md) to ](/project/word) +# 5 Remove .md extension from link text +# 6 Change ](../ to ](/project/word) +# 7 Change ](../../ to ](/project/ --> not implemented # # RUN find /docs/content/compose -type f -name "*.md" -exec sed -i.old \ -e '/^/g' \ -e '/^/g' \ - -e 's/\([(]\)\(.*\)\(\.md\)/\1\2/g' \ -e 's/\(\]\)\([(]\)\(\/\)/\1\2\/compose\//g' \ - -e 's/\(\][(]\)\([A-z]*[)]\)/\]\(\/compose\/\2/g' \ + -e 's/\(\][(]\)\([A-z].*\)\(\.md\)/\1\/compose\/\2/g' \ + -e 's/\([(]\)\(.*\)\(\.md\)/\1\2/g' \ -e 's/\(\][(]\)\(\.\.\/\)/\1\/compose\//g' {} \; diff --git a/docs/cli.md b/docs/cli.md index a2167d9c3fc..61a6aa6ddef 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -192,7 +192,7 @@ Configures the path to the `ca.pem`, `cert.pem`, and `key.pem` files used for TL ## Compose documentation -- [User guide](compose-overview.md) +- [User guide](/) - [Installing Compose](install.md) - [Get started with Django](django.md) - [Get started with Rails](rails.md) diff --git a/docs/completion.md b/docs/completion.md index 7fb696d80ee..3856d2701e8 100644 --- a/docs/completion.md +++ b/docs/completion.md @@ -59,7 +59,7 @@ Enjoy working with Compose faster and with less typos! ## Compose documentation -- [User guide](compose-overview.md) +- [User guide](/) - [Installing Compose](install.md) - [Get started with Django](django.md) - [Get started with Rails](rails.md) diff --git a/docs/django.md b/docs/django.md index c44329e1cdd..84fdcbfe5ea 100644 --- a/docs/django.md +++ b/docs/django.md @@ -125,7 +125,7 @@ example, run `docker-compose up` and in another terminal run: ## More Compose documentation -- [User guide](compose-overview.md) +- [User guide](/) - [Installing Compose](install.md) - [Get started with Django](django.md) - [Get started with Rails](rails.md) diff --git a/docs/env.md b/docs/env.md index 73496f32f51..e38e6d50cc4 100644 --- a/docs/env.md +++ b/docs/env.md @@ -40,7 +40,7 @@ Fully qualified container name, e.g. `DB_1_NAME=/myapp_web_1/myapp_db_1` ## Compose documentation -- [User guide](compose-overview.md) +- [User guide](/) - [Installing Compose](install.md) - [Get started with Django](django.md) - [Get started with Rails](rails.md) diff --git a/docs/extends.md b/docs/extends.md index 8527c81b3cf..054462b8960 100644 --- a/docs/extends.md +++ b/docs/extends.md @@ -86,7 +86,7 @@ For full details on how to use `extends`, refer to the [reference](#reference). ### Example use case In this example, you’ll repurpose the example app from the [quick start -guide](compose-overview.md). (If you're not familiar with Compose, it's recommended that +guide](index.md). (If you're not familiar with Compose, it's recommended that you go through the quick start first.) This example assumes you want to use Compose both to develop an application locally and then deploy it to a production environment. @@ -371,7 +371,7 @@ volumes: ## Compose documentation -- [User guide](compose-overview.md) +- [User guide](/) - [Installing Compose](install.md) - [Get started with Django](django.md) - [Get started with Rails](rails.md) diff --git a/docs/compose-overview.md b/docs/index.md similarity index 100% rename from docs/compose-overview.md rename to docs/index.md diff --git a/docs/install.md b/docs/install.md index c1abd4fd6d0..ac35c8d9f46 100644 --- a/docs/install.md +++ b/docs/install.md @@ -57,7 +57,7 @@ Alternatively, if you're not worried about keeping them, you can remove them - C ## Compose documentation -- [User guide](compose-overview.md) +- [User guide](/) - [Get started with Django](django.md) - [Get started with Rails](rails.md) - [Get started with Wordpress](wordpress.md) diff --git a/docs/rails.md b/docs/rails.md index 2ff6f175215..cb8078647d6 100644 --- a/docs/rails.md +++ b/docs/rails.md @@ -124,7 +124,7 @@ you're using Boot2docker, `boot2docker ip` will tell you its address). ## More Compose documentation -- [User guide](compose-overview.md) +- [User guide](/) - [Installing Compose](install.md) - [Get started with Django](django.md) - [Get started with Rails](rails.md) diff --git a/docs/wordpress.md b/docs/wordpress.md index ad0e62966f2..aa62e4e4e7a 100644 --- a/docs/wordpress.md +++ b/docs/wordpress.md @@ -121,7 +121,7 @@ address). ## More Compose documentation -- [User guide](compose-overview.md) +- [User guide](/) - [Installing Compose](install.md) - [Get started with Django](django.md) - [Get started with Rails](rails.md) diff --git a/docs/yml.md b/docs/yml.md index 80d6d719f50..087f8ac748f 100644 --- a/docs/yml.md +++ b/docs/yml.md @@ -393,7 +393,7 @@ read_only: true ## Compose documentation -- [User guide](compose-overview.md) +- [User guide](/) - [Installing Compose](install.md) - [Get started with Django](django.md) - [Get started with Rails](rails.md) From c21d6706b6df54f3304b6fc59cb307c42f0c54c5 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 18 Jun 2015 11:23:40 -0700 Subject: [PATCH 0904/4072] Merge pull request #1565 from aanand/use-docker-1.7.0 Use docker 1.7.0 and docker-py 1.2.3 (cherry picked from commit 8ffeaf2a54828014834f49e9a20d9486a6d6d335) Signed-off-by: Aanand Prasad Conflicts: Dockerfile --- Dockerfile | 6 +++--- requirements.txt | 2 +- setup.py | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index 1ff2d3825d5..98dc59c555b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -48,13 +48,13 @@ RUN set -ex; \ rm -rf pip-7.0.1; \ rm pip-7.0.1.tar.gz -ENV ALL_DOCKER_VERSIONS 1.6.0 1.7.0-rc2 +ENV ALL_DOCKER_VERSIONS 1.6.0 1.7.0 RUN set -ex; \ curl https://get.docker.com/builds/Linux/x86_64/docker-1.6.0 -o /usr/local/bin/docker-1.6.0; \ chmod +x /usr/local/bin/docker-1.6.0; \ - curl https://test.docker.com/builds/Linux/x86_64/docker-1.7.0-rc2 -o /usr/local/bin/docker-1.7.0-rc2; \ - chmod +x /usr/local/bin/docker-1.7.0-rc2 + curl https://test.docker.com/builds/Linux/x86_64/docker-1.7.0 -o /usr/local/bin/docker-1.7.0; \ + chmod +x /usr/local/bin/docker-1.7.0 # Set the default Docker to be run RUN ln -s /usr/local/bin/docker-1.6.0 /usr/local/bin/docker diff --git a/requirements.txt b/requirements.txt index 47fa1e05bef..69bd4c5f95c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ PyYAML==3.10 -docker-py==1.2.3-rc1 +docker-py==1.2.3 dockerpty==0.3.4 docopt==0.6.1 requests==2.6.1 diff --git a/setup.py b/setup.py index a94d87374f5..d2e81e175bf 100644 --- a/setup.py +++ b/setup.py @@ -30,7 +30,7 @@ def find_version(*file_paths): 'requests >= 2.6.1, < 2.7', 'texttable >= 0.8.1, < 0.9', 'websocket-client >= 0.11.0, < 1.0', - 'docker-py >= 1.2.3-rc1, < 1.3', + 'docker-py >= 1.2.3, < 1.3', 'dockerpty >= 0.3.4, < 0.4', 'six >= 1.3.0, < 2', ] From 00f61196a44ee140f389a51e50d39d1b846ba180 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 26 May 2015 12:43:23 +0100 Subject: [PATCH 0905/4072] Bump 1.3.0 Signed-off-by: Aanand Prasad --- CHANGES.md | 45 +++++++++++++++++++++++++++++++++++++++++++++ compose/__init__.py | 2 +- docs/install.md | 2 +- 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 277a188a31e..78e629b8918 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,6 +1,51 @@ Change log ========== +1.3.0 (2015-06-18) +------------------ + +Firstly, two important notes: + +- **This release contains breaking changes, and you will need to either remove or migrate your existing containers before running your app** - see the [upgrading section of the install docs](https://github.com/docker/compose/blob/1.3.0rc1/docs/install.md#upgrading) for details. + +- Compose now requires Docker 1.6.0 or later. + +We've done a lot of work in this release to remove hacks and make Compose more stable: + +- Compose now uses container labels, rather than names, to keep track of containers. This makes Compose both faster and easier to integrate with your own tools. + +- Compose no longer uses "intermediate containers" when recreating containers for a service. This makes `docker-compose up` less complex and more resilient to failure. + +There are some new features: + +- `docker-compose up` has an **experimental** new behaviour: it will only recreate containers for services whose configuration has changed in `docker-compose.yml`. This will eventually become the default, but for now you can take it for a spin: + + $ docker-compose up --x-smart-recreate + +- When invoked in a subdirectory of a project, `docker-compose` will now climb up through parent directories until it finds a `docker-compose.yml`. + +Several new configuration keys have been added to `docker-compose.yml`: + +- `dockerfile`, like `docker build --file`, lets you specify an alternate Dockerfile to use with `build`. +- `labels`, like `docker run --labels`, lets you add custom metadata to containers. +- `extra_hosts`, like `docker run --add-host`, lets you add entries to a container's `/etc/hosts` file. +- `pid: host`, like `docker run --pid=host`, lets you reuse the same PID namespace as the host machine. +- `cpuset`, like `docker run --cpuset-cpus`, lets you specify which CPUs to allow execution in. +- `read_only`, like `docker run --read-only`, lets you mount a container's filesystem as read-only. +- `security_opt`, like `docker run --security-opt`, lets you specify [security options](https://docs.docker.com/reference/run/#security-configuration). +- `log_driver`, like `docker run --log-driver`, lets you specify a [log driver](https://docs.docker.com/reference/run/#logging-drivers-log-driver). + +Many bugs have been fixed, including the following: + +- The output of `docker-compose run` was sometimes truncated, especially when running under Jenkins. +- A service's volumes would sometimes not update after volume configuration was changed in `docker-compose.yml`. +- Authenticating against third-party registries would sometimes fail. +- `docker-compose run --rm` would fail to remove the container if the service had a `restart` policy in place. +- `docker-compose scale` would refuse to scale a service beyond 1 container if it exposed a specific port number on the host. +- Compose would refuse to create multiple volume entries with the same host path. + +Thanks @ahromis, @albers, @aleksandr-vin, @antoineco, @ccverak, @chernjie, @dnephin, @edmorley, @fordhurley, @josephpage, @KyleJamesWalker, @lsowen, @mchasal, @noironetworks, @sdake, @sdurrheimer, @sherter, @stephenlawrence, @thaJeztah, @thieman, @turtlemonvh, @twhiteman, @vdemeester, @xuxinkun and @zwily! + 1.2.0 (2015-04-16) ------------------ diff --git a/compose/__init__.py b/compose/__init__.py index 045e791449f..9e4c3fdb2f2 100644 --- a/compose/__init__.py +++ b/compose/__init__.py @@ -1,3 +1,3 @@ from __future__ import unicode_literals -__version__ = '1.3.0dev' +__version__ = '1.3.0' diff --git a/docs/install.md b/docs/install.md index ac35c8d9f46..a608d8fe7ee 100644 --- a/docs/install.md +++ b/docs/install.md @@ -27,7 +27,7 @@ First, install Docker version 1.6 or greater: To install Compose, run the following commands: - curl -L https://github.com/docker/compose/releases/download/1.2.0/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose + curl -L https://github.com/docker/compose/releases/download/1.3.0/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose chmod +x /usr/local/bin/docker-compose > 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 above, then `exit`. From 5aa82a5519e5381a34f14dd51eadb924c4fba00e Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 18 Jun 2015 11:56:43 -0700 Subject: [PATCH 0906/4072] Bump 1.4.0dev Signed-off-by: Aanand Prasad --- CHANGES.md | 45 +++++++++++++++++++++++++++++++++++++++++++++ compose/__init__.py | 2 +- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index 277a188a31e..78e629b8918 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,6 +1,51 @@ Change log ========== +1.3.0 (2015-06-18) +------------------ + +Firstly, two important notes: + +- **This release contains breaking changes, and you will need to either remove or migrate your existing containers before running your app** - see the [upgrading section of the install docs](https://github.com/docker/compose/blob/1.3.0rc1/docs/install.md#upgrading) for details. + +- Compose now requires Docker 1.6.0 or later. + +We've done a lot of work in this release to remove hacks and make Compose more stable: + +- Compose now uses container labels, rather than names, to keep track of containers. This makes Compose both faster and easier to integrate with your own tools. + +- Compose no longer uses "intermediate containers" when recreating containers for a service. This makes `docker-compose up` less complex and more resilient to failure. + +There are some new features: + +- `docker-compose up` has an **experimental** new behaviour: it will only recreate containers for services whose configuration has changed in `docker-compose.yml`. This will eventually become the default, but for now you can take it for a spin: + + $ docker-compose up --x-smart-recreate + +- When invoked in a subdirectory of a project, `docker-compose` will now climb up through parent directories until it finds a `docker-compose.yml`. + +Several new configuration keys have been added to `docker-compose.yml`: + +- `dockerfile`, like `docker build --file`, lets you specify an alternate Dockerfile to use with `build`. +- `labels`, like `docker run --labels`, lets you add custom metadata to containers. +- `extra_hosts`, like `docker run --add-host`, lets you add entries to a container's `/etc/hosts` file. +- `pid: host`, like `docker run --pid=host`, lets you reuse the same PID namespace as the host machine. +- `cpuset`, like `docker run --cpuset-cpus`, lets you specify which CPUs to allow execution in. +- `read_only`, like `docker run --read-only`, lets you mount a container's filesystem as read-only. +- `security_opt`, like `docker run --security-opt`, lets you specify [security options](https://docs.docker.com/reference/run/#security-configuration). +- `log_driver`, like `docker run --log-driver`, lets you specify a [log driver](https://docs.docker.com/reference/run/#logging-drivers-log-driver). + +Many bugs have been fixed, including the following: + +- The output of `docker-compose run` was sometimes truncated, especially when running under Jenkins. +- A service's volumes would sometimes not update after volume configuration was changed in `docker-compose.yml`. +- Authenticating against third-party registries would sometimes fail. +- `docker-compose run --rm` would fail to remove the container if the service had a `restart` policy in place. +- `docker-compose scale` would refuse to scale a service beyond 1 container if it exposed a specific port number on the host. +- Compose would refuse to create multiple volume entries with the same host path. + +Thanks @ahromis, @albers, @aleksandr-vin, @antoineco, @ccverak, @chernjie, @dnephin, @edmorley, @fordhurley, @josephpage, @KyleJamesWalker, @lsowen, @mchasal, @noironetworks, @sdake, @sdurrheimer, @sherter, @stephenlawrence, @thaJeztah, @thieman, @turtlemonvh, @twhiteman, @vdemeester, @xuxinkun and @zwily! + 1.2.0 (2015-04-16) ------------------ diff --git a/compose/__init__.py b/compose/__init__.py index 045e791449f..0d464ee86a6 100644 --- a/compose/__init__.py +++ b/compose/__init__.py @@ -1,3 +1,3 @@ from __future__ import unicode_literals -__version__ = '1.3.0dev' +__version__ = '1.4.0dev' From bef0926c58b6ed86d0a1a95160e5548ee2f34502 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 18 Jun 2015 17:43:16 -0700 Subject: [PATCH 0907/4072] Explicitly set pull=False when building Signed-off-by: Aanand Prasad --- compose/service.py | 1 + tests/unit/service_test.py | 11 +++++++++++ 2 files changed, 12 insertions(+) diff --git a/compose/service.py b/compose/service.py index 53073ffbdb4..eec0225689d 100644 --- a/compose/service.py +++ b/compose/service.py @@ -612,6 +612,7 @@ def build(self, no_cache=False): tag=self.image_name, stream=True, rm=True, + pull=False, nocache=no_cache, dockerfile=self.options.get('dockerfile', None), ) diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 82ea0410196..f99cbbc9d14 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -312,6 +312,17 @@ def test_create_container_no_build_but_needs_build(self): with self.assertRaises(NeedsBuildError): service.create_container(do_build=False) + def test_build_does_not_pull(self): + self.mock_client.build.return_value = [ + '{"stream": "Successfully built 12345"}', + ] + + service = Service('foo', client=self.mock_client, build='.') + service.build() + + self.assertEqual(self.mock_client.build.call_count, 1) + self.assertFalse(self.mock_client.build.call_args[1]['pull']) + class ServiceVolumesTest(unittest.TestCase): From c3df62472bf8714c655eeaeaa84788dd3147017c Mon Sep 17 00:00:00 2001 From: Mary Anthony Date: Fri, 19 Jun 2015 02:28:09 -0700 Subject: [PATCH 0908/4072] Updating from three ticks to code block Signed-off-by: Mary Anthony --- docs/yml.md | 287 +++++++++++++++++++++------------------------------- 1 file changed, 116 insertions(+), 171 deletions(-) diff --git a/docs/yml.md b/docs/yml.md index 70fb385ccb9..02540b3ff13 100644 --- a/docs/yml.md +++ b/docs/yml.md @@ -24,11 +24,9 @@ specify them again in `docker-compose.yml`. Tag or partial image ID. Can be local or remote - Compose will attempt to pull if it doesn't exist locally. -``` -image: ubuntu -image: orchardup/postgresql -image: a4bc65fd -``` + image: ubuntu + image: orchardup/postgresql + image: a4bc65fd ### build @@ -38,9 +36,7 @@ itself. This directory is also the build context that is sent to the Docker daem Compose will build and tag it with a generated name, and use that image thereafter. -``` -build: /path/to/build/dir -``` + build: /path/to/build/dir ### dockerfile @@ -48,17 +44,13 @@ Alternate Dockerfile. Compose will use an alternate file to build with. -``` -dockerfile: Dockerfile-alternate -``` + dockerfile: Dockerfile-alternate ### command Override the default command. -``` -command: bundle exec thin -p 3000 -``` + command: bundle exec thin -p 3000 ### links @@ -67,21 +59,17 @@ 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). -``` -links: - - db - - db:database - - redis -``` + links: + - db + - db:database + - redis An entry with the alias' name will be created in `/etc/hosts` inside containers for this service, e.g: -``` -172.17.2.186 db -172.17.2.186 database -172.17.2.187 redis -``` + 172.17.2.186 db + 172.17.2.186 database + 172.17.2.187 redis Environment variables will also be created - see the [environment variable reference](env.md) for details. @@ -93,29 +81,23 @@ 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 -``` + external_links: + - redis_1 + - project_db_1:mysql + - project_db_1:postgresql ### 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" -``` + 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 -``` + 162.242.195.82 somehost + 50.31.209.229 otherhost ### ports @@ -127,46 +109,38 @@ port (a random host port will be chosen). > 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" - - "8000:8000" - - "49100:22" - - "127.0.0.1:8001:8001" -``` + ports: + - "3000" + - "8000:8000" + - "49100:22" + - "127.0.0.1:8001:8001" ### 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" -``` + expose: + - "3000" + - "8000" ### volumes 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 -``` + volumes: + - /var/lib/mysql + - cache/:/tmp/cache + - ~/configs:/etc/configs/:ro ### volumes_from Mount all of the volumes from another service or container. -``` -volumes_from: - - service_name - - container_name -``` + volumes_from: + - service_name + - container_name ### environment @@ -175,15 +149,13 @@ Add environment variables. You can use either an array or a dictionary. 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 - SESSION_SECRET: + environment: + RACK_ENV: development + SESSION_SECRET: -environment: - - RACK_ENV=development - - SESSION_SECRET -``` + environment: + - RACK_ENV=development + - SESSION_SECRET ### env_file @@ -194,22 +166,18 @@ If you have specified a Compose file with `docker-compose -f FILE`, paths in Environment variables specified in `environment` override these values. -``` -env_file: .env + env_file: .env -env_file: - - ./common.env - - ./apps/web.env - - /opt/secrets.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 -``` + # Set Rails/Rack environment + RACK_ENV=development ### extends @@ -222,30 +190,26 @@ Here's a simple example. Suppose we have 2 files - **common.yml** and **common.yml** -``` -webapp: - build: ./webapp - environment: - - DEBUG=false - - SEND_EMAILS=false -``` + webapp: + build: ./webapp + environment: + - DEBUG=false + - SEND_EMAILS=false **development.yml** -``` -web: - extends: - file: common.yml - service: webapp - ports: - - "8000:8000" - links: - - db - environment: - - DEBUG=true -db: - image: postgres -``` + web: + extends: + file: common.yml + service: webapp + ports: + - "8000:8000" + links: + - db + environment: + - DEBUG=true + db: + image: postgres Here, the `web` service in **development.yml** inherits the configuration of the `webapp` service in **common.yml** - the `build` and `environment` keys - @@ -262,17 +226,15 @@ Add metadata to containers using [Docker labels](http://docs.docker.com/userguid 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: "" -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" ### log driver @@ -282,27 +244,22 @@ Allowed values are currently ``json-file``, ``syslog`` and ``none``. The list wi The default value is json-file. -``` -log_driver: "json-file" -log_driver: "syslog" -log_driver: "none" -``` + log_driver: "json-file" + log_driver: "syslog" + log_driver: "none" ### net Networking mode. Use the same values as the docker client `--net` parameter. -``` -net: "bridge" -net: "none" -net: "container:[name or id]" -net: "host" -``` + net: "bridge" + net: "none" + net: "container:[name or id]" + net: "host" + ### pid -``` -pid: "host" -``` + 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 @@ -313,86 +270,74 @@ containers in the bare-metal machine's namespace and vise-versa. 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: 8.8.8.8 + dns: + - 8.8.8.8 + - 9.9.9.9 ### cap_add, cap_drop Add or drop container capabilities. See `man 7 capabilities` for a full list. -``` -cap_add: - - ALL + cap_add: + - ALL -cap_drop: - - NET_ADMIN - - SYS_ADMIN -``` + cap_drop: + - NET_ADMIN + - SYS_ADMIN ### 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 -``` + dns_search: example.com + dns_search: + - dc1.example.com + - dc2.example.com ### devices List of device mappings. Uses the same format as the `--device` docker client create option. -``` -devices: - - "/dev/ttyUSB0:/dev/ttyUSB0" -``` + devices: + - "/dev/ttyUSB0:/dev/ttyUSB0" ### security_opt Override the default labeling scheme for each container. -``` -security_opt: - - label:user:USER - - label:role:ROLE -``` + security_opt: + - label:user:USER + - label:role:ROLE ### working\_dir, entrypoint, user, hostname, domainname, mac\_address, mem\_limit, privileged, restart, stdin\_open, tty, cpu\_shares, cpuset, read\_only Each of these is a single value, analogous to its [docker run](https://docs.docker.com/reference/run/) counterpart. -``` -cpu_shares: 73 -cpuset: 0,1 + cpu_shares: 73 + cpuset: 0,1 -working_dir: /code -entrypoint: /code/entrypoint.sh -user: postgresql + working_dir: /code + entrypoint: /code/entrypoint.sh + user: postgresql -hostname: foo -domainname: foo.com + hostname: foo + domainname: foo.com -mac_address: 02:42:ac:11:65:43 + mac_address: 02:42:ac:11:65:43 -mem_limit: 1000000000 -privileged: true + mem_limit: 1000000000 + privileged: true -restart: always + restart: always -stdin_open: true -tty: true -read_only: true + stdin_open: true + tty: true + read_only: true -``` ## Compose documentation From d0102f0761a32e27ef09f1c2983a46a32b812c5d Mon Sep 17 00:00:00 2001 From: Steve Durrheimer Date: Fri, 19 Jun 2015 07:42:18 +0200 Subject: [PATCH 0909/4072] Fix completion docs URLs Signed-off-by: Steve Durrheimer --- docs/completion.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/completion.md b/docs/completion.md index 3856d2701e8..41ef88e62de 100644 --- a/docs/completion.md +++ b/docs/completion.md @@ -23,7 +23,7 @@ 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 | awk '{print $2}')/contrib/completion/bash/docker-compose > /etc/bash_completion.d/docker-compose + curl -L https://raw.githubusercontent.com/docker/compose/$(docker-compose --version | awk 'NR==1{print $NF}')/contrib/completion/bash/docker-compose > /etc/bash_completion.d/docker-compose Completion will be available upon next login. @@ -32,7 +32,7 @@ Completion will be available upon next login. 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 | awk '{print $2}')/contrib/completion/zsh/_docker-compose > ~/.zsh/completion/_docker-compose + curl -L https://raw.githubusercontent.com/docker/compose/$(docker-compose --version | awk 'NR==1{print $NF}')/contrib/completion/zsh/_docker-compose > ~/.zsh/completion/_docker-compose Include the directory in your `$fpath`, e.g. by adding in `~/.zshrc` @@ -66,4 +66,4 @@ Enjoy working with Compose faster and with less typos! - [Get started with Wordpress](wordpress.md) - [Command line reference](cli.md) - [Yaml file reference](yml.md) -- [Compose environment variables](env.md) \ No newline at end of file +- [Compose environment variables](env.md) From c22cc02df591d9fb2156e5ef7a1ea81973bac070 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 19 Jun 2015 15:22:13 -0700 Subject: [PATCH 0910/4072] Don't set network mode when none is specified Setting a value overrides the new default network option. Signed-off-by: Aanand Prasad --- compose/project.py | 2 +- compose/service.py | 2 +- tests/unit/project_test.py | 12 ++++++++++++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/compose/project.py b/compose/project.py index a89918efe38..aa0e3e88e44 100644 --- a/compose/project.py +++ b/compose/project.py @@ -188,7 +188,7 @@ def get_net(self, service_dict): del service_dict['net'] else: - net = 'bridge' + net = None return net diff --git a/compose/service.py b/compose/service.py index 53073ffbdb4..46177b23ea0 100644 --- a/compose/service.py +++ b/compose/service.py @@ -457,7 +457,7 @@ def _get_volumes_from(self): def _get_net(self): if not self.net: - return "bridge" + return None if isinstance(self.net, Service): containers = self.net.containers() diff --git a/tests/unit/project_test.py b/tests/unit/project_test.py index fc49e9b88ef..9ee6f28c327 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -209,6 +209,18 @@ def test_use_volumes_from_service_container(self, mock_return): ], None) self.assertEqual(project.get_service('test')._get_volumes_from(), container_ids) + def test_net_unset(self): + mock_client = mock.create_autospec(docker.Client) + project = Project.from_dicts('test', [ + { + 'name': 'test', + 'image': 'busybox:latest', + } + ], mock_client) + service = project.get_service('test') + self.assertEqual(service._get_net(), None) + self.assertNotIn('NetworkMode', service._get_container_host_config({})) + def test_use_net_from_container(self): container_id = 'aabbccddee' container_dict = dict(Name='aaa', Id=container_id) From 93372dd6654ffd480e967a9c6d4fd3ec1cdd7f26 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 19 Jun 2015 11:35:06 -0700 Subject: [PATCH 0911/4072] Fix 'docker-compose help migrate-to-labels' - Fix "No such command" error - Add text from migration section of install docs Signed-off-by: Aanand Prasad --- compose/cli/docopt_command.py | 15 +++++++++------ compose/cli/main.py | 24 ++++++++++++++++++++---- tests/unit/cli_test.py | 17 +++++++++++++++++ 3 files changed, 46 insertions(+), 10 deletions(-) diff --git a/compose/cli/docopt_command.py b/compose/cli/docopt_command.py index ee694701259..6eeb33a317d 100644 --- a/compose/cli/docopt_command.py +++ b/compose/cli/docopt_command.py @@ -33,12 +33,7 @@ def parse(self, argv, global_options): if command is None: raise SystemExit(getdoc(self)) - command = command.replace('-', '_') - - if not hasattr(self, command): - raise NoSuchCommand(command, self) - - handler = getattr(self, command) + handler = self.get_handler(command) docstring = getdoc(handler) if docstring is None: @@ -47,6 +42,14 @@ def parse(self, argv, global_options): command_options = docopt_full_help(docstring, options['ARGS'], options_first=True) return options, handler, command_options + def get_handler(self, command): + command = command.replace('-', '_') + + if not hasattr(self, command): + raise NoSuchCommand(command, self) + + return getattr(self, command) + class NoSuchCommand(Exception): def __init__(self, command, supercommand): diff --git a/compose/cli/main.py b/compose/cli/main.py index 0b2ca947367..4bde658e611 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -131,10 +131,8 @@ def help(self, project, options): Usage: help COMMAND """ - command = options['COMMAND'] - if not hasattr(self, command): - raise NoSuchCommand(command, self) - raise SystemExit(getdoc(getattr(self, command))) + handler = self.get_handler(options['COMMAND']) + raise SystemExit(getdoc(handler)) def kill(self, project, options): """ @@ -486,6 +484,24 @@ def migrate_to_labels(self, project, _options): """ Recreate containers to add labels + If you're coming 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 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 myapp_web_1 myapp_db_1 ... + Usage: migrate-to-labels """ legacy.migrate_project_to_labels(project) diff --git a/tests/unit/cli_test.py b/tests/unit/cli_test.py index 3173a274dac..d10cb9b30bb 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -11,6 +11,7 @@ from compose.cli import main from compose.cli.main import TopLevelCommand +from compose.cli.docopt_command import NoSuchCommand from compose.cli.errors import ComposeFileNotFound from compose.service import Service @@ -101,6 +102,22 @@ def test_help(self): with self.assertRaises(SystemExit): command.dispatch(['-h'], None) + def test_command_help(self): + with self.assertRaises(SystemExit) as ctx: + TopLevelCommand().dispatch(['help', 'up'], None) + + self.assertIn('Usage: up', str(ctx.exception)) + + def test_command_help_dashes(self): + with self.assertRaises(SystemExit) as ctx: + TopLevelCommand().dispatch(['help', 'migrate-to-labels'], None) + + self.assertIn('Usage: migrate-to-labels', str(ctx.exception)) + + def test_command_help_nonexistent(self): + with self.assertRaises(NoSuchCommand): + TopLevelCommand().dispatch(['help', 'nonexistent'], None) + def test_setup_logging(self): main.setup_logging() self.assertEqual(logging.getLogger().level, logging.DEBUG) From 511fc4a05ce7547024d0858e6655652867d41559 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Sun, 21 Jun 2015 12:37:20 -0700 Subject: [PATCH 0912/4072] Replace backtick code blocks with indentation Signed-off-by: Aanand Prasad --- docs/extends.md | 192 +++++++++++++++++++++------------------------- docs/index.md | 50 ++++++------ docs/wordpress.md | 121 ++++++++++++++--------------- 3 files changed, 166 insertions(+), 197 deletions(-) diff --git a/docs/extends.md b/docs/extends.md index 054462b8960..aef1524a768 100644 --- a/docs/extends.md +++ b/docs/extends.md @@ -28,25 +28,21 @@ the configuration around. When defining any service in `docker-compose.yml`, you can declare that you are extending another service like this: -```yaml -web: - extends: - file: common-services.yml - service: webapp -``` + 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: -```yaml -webapp: - build: . - ports: - - "8000:8000" - volumes: - - "/data" -``` + 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 that `build`, `ports` and `volumes` configuration @@ -55,31 +51,27 @@ defined directly under `web`. You can go further and define (or re-define) configuration locally in `docker-compose.yml`: -```yaml -web: - extends: - file: common-services.yml - service: webapp - environment: - - DEBUG=1 - cpu_shares: 5 -``` + web: + extends: + file: common-services.yml + service: webapp + environment: + - DEBUG=1 + cpu_shares: 5 You can also write other services and link your `web` service to them: -```yaml -web: - extends: - file: common-services.yml - service: webapp - environment: - - DEBUG=1 - cpu_shares: 5 - links: - - db -db: - image: postgres -``` + web: + extends: + file: common-services.yml + service: webapp + environment: + - DEBUG=1 + cpu_shares: 5 + links: + - db + db: + image: postgres For full details on how to use `extends`, refer to the [reference](#reference). @@ -271,103 +263,91 @@ For single-value options like `image`, `command` or `mem_limit`, the new value replaces the old value. **This is the default behaviour - all exceptions are listed below.** -```yaml -# original service -command: python app.py + # original service + command: python app.py -# local service -command: python otherapp.py + # local service + command: python otherapp.py -# result -command: python otherapp.py -``` + # 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. -```yaml -# original service -build: . + # original service + build: . -# local service -image: redis + # local service + image: redis -# result -image: redis -``` + # result + image: redis -```yaml -# original service -image: redis + # original service + image: redis -# local service -build: . + # local service + build: . -# result -build: . -``` + # result + build: . For the **multi-value options** `ports`, `expose`, `external_links`, `dns` and `dns_search`, Compose concatenates both sets of values: -```yaml -# original service -expose: - - "3000" + # original service + expose: + - "3000" -# local service -expose: - - "4000" - - "5000" + # local service + expose: + - "4000" + - "5000" -# result -expose: - - "3000" - - "4000" - - "5000" -``` + # result + expose: + - "3000" + - "4000" + - "5000" In the case of `environment` and `labels`, Compose "merges" entries together with locally-defined values taking precedence: -```yaml -# original service -environment: - - FOO=original - - BAR=original + # original service + environment: + - FOO=original + - BAR=original -# local service -environment: - - BAR=local - - BAZ=local + # local service + environment: + - BAR=local + - BAZ=local -# result -environment: - - FOO=original - - BAR=local - - BAZ=local -``` + # result + environment: + - FOO=original + - BAR=local + - BAZ=local Finally, for `volumes` and `devices`, Compose "merges" entries together with locally-defined bindings taking precedence: -```yaml -# original service -volumes: - - /original-dir/foo:/foo - - /original-dir/bar:/bar - -# local service -volumes: - - /local-dir/bar:/bar - - /local-dir/baz/:baz - -# result -volumes: - - /original-dir/foo:/foo - - /local-dir/bar:/bar - - /local-dir/baz/:baz -``` + # original service + volumes: + - /original-dir/foo:/foo + - /original-dir/bar:/bar + + # local service + volumes: + - /local-dir/bar:/bar + - /local-dir/baz/:baz + + # result + volumes: + - /original-dir/foo:/foo + - /local-dir/bar:/bar + - /local-dir/baz/:baz ## Compose documentation diff --git a/docs/index.md b/docs/index.md index 33629957a6e..59a2aa1b2f6 100644 --- a/docs/index.md +++ b/docs/index.md @@ -29,18 +29,16 @@ they can be run together in an isolated environment: A `docker-compose.yml` looks like this: -```yaml -web: - build: . - ports: - - "5000:5000" - volumes: - - .:/code - links: - - redis -redis: - image: redis -``` + web: + build: . + ports: + - "5000:5000" + volumes: + - .:/code + links: + - redis + redis: + image: redis Compose has commands for managing the whole lifecycle of your application: @@ -79,21 +77,19 @@ Next, you'll want to make a directory for the project: Inside this directory, create `app.py`, a simple web app that uses the Flask framework and increments a value in Redis: -```python -from flask import Flask -from redis import Redis -import os -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) -``` + from flask import Flask + from redis import Redis + import os + 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) Next, define the Python dependencies in a file called `requirements.txt`: diff --git a/docs/wordpress.md b/docs/wordpress.md index aa62e4e4e7a..65a7d17f43e 100644 --- a/docs/wordpress.md +++ b/docs/wordpress.md @@ -32,10 +32,8 @@ Dockerfiles, see the [Dockerfile reference](http://docs.docker.com/reference/builder/). In this case, your Dockerfile should be: -``` -FROM orchardup/php5 -ADD . /code -``` + FROM orchardup/php5 + ADD . /code This tells Docker how to build an image defining a container that contains PHP and Wordpress. @@ -43,74 +41,69 @@ 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 -``` + 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 Two supporting files are needed to get this working - first, `wp-config.php` is the standard Wordpress config file with a single change to point the database configuration at the `db` container: -``` - Date: Sun, 21 Jun 2015 13:06:25 -0700 Subject: [PATCH 0913/4072] Fix -d description Signed-off-by: Aanand Prasad --- docs/index.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/index.md b/docs/index.md index 59a2aa1b2f6..f3e73e33660 100644 --- a/docs/index.md +++ b/docs/index.md @@ -169,8 +169,8 @@ open `http://ip-from-boot2docker:5000` and you should get a message in your brow Refreshing the page will increment the number. If you want to run your services in the background, you can pass the `-d` flag -(for daemon mode) to `docker-compose up` and use `docker-compose ps` to see what -is currently running: +(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... From bd0be2cdc7d24cbb0bc8ef80a1d3d756f6099fde Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Fri, 19 Jun 2015 16:01:04 -0700 Subject: [PATCH 0914/4072] Merge pull request #1580 from aanand/dont-set-network-mode-when-none-is-specified Don't set network mode when none is specified (cherry picked from commit 911cd60360ceef2a4c4c4e53b661679a4f1bc48a) Signed-off-by: Aanand Prasad --- compose/project.py | 2 +- compose/service.py | 2 +- tests/unit/project_test.py | 12 ++++++++++++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/compose/project.py b/compose/project.py index bc093628c4f..6446a6d33d7 100644 --- a/compose/project.py +++ b/compose/project.py @@ -178,7 +178,7 @@ def get_net(self, service_dict): del service_dict['net'] else: - net = 'bridge' + net = None return net diff --git a/compose/service.py b/compose/service.py index 1e91a9f2332..12a021bfb2a 100644 --- a/compose/service.py +++ b/compose/service.py @@ -473,7 +473,7 @@ def _get_volumes_from(self): def _get_net(self): if not self.net: - return "bridge" + return None if isinstance(self.net, Service): containers = self.net.containers() diff --git a/tests/unit/project_test.py b/tests/unit/project_test.py index fc49e9b88ef..9ee6f28c327 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -209,6 +209,18 @@ def test_use_volumes_from_service_container(self, mock_return): ], None) self.assertEqual(project.get_service('test')._get_volumes_from(), container_ids) + def test_net_unset(self): + mock_client = mock.create_autospec(docker.Client) + project = Project.from_dicts('test', [ + { + 'name': 'test', + 'image': 'busybox:latest', + } + ], mock_client) + service = project.get_service('test') + self.assertEqual(service._get_net(), None) + self.assertNotIn('NetworkMode', service._get_container_host_config({})) + def test_use_net_from_container(self): container_id = 'aabbccddee' container_dict = dict(Name='aaa', Id=container_id) From d6cd76c3c153b79fa0ceec5c84c984a65a4c41f3 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Sun, 21 Jun 2015 17:25:46 -0700 Subject: [PATCH 0915/4072] Merge pull request #1570 from aanand/fix-build-pull Explicitly set pull=False when building (cherry picked from commit 4f83a1891259bd821efb6c8f2332f06405e88732) Signed-off-by: Aanand Prasad --- compose/service.py | 1 + tests/unit/service_test.py | 11 +++++++++++ 2 files changed, 12 insertions(+) diff --git a/compose/service.py b/compose/service.py index 12a021bfb2a..6c2cc4da598 100644 --- a/compose/service.py +++ b/compose/service.py @@ -628,6 +628,7 @@ def build(self, no_cache=False): tag=self.image_name, stream=True, rm=True, + pull=False, nocache=no_cache, dockerfile=self.options.get('dockerfile', None), ) diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index fb3a7fcbb98..88d30147026 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -303,6 +303,17 @@ def test_create_container_no_build_but_needs_build(self): with self.assertRaises(NeedsBuildError): service.create_container(do_build=False) + def test_build_does_not_pull(self): + self.mock_client.build.return_value = [ + '{"stream": "Successfully built 12345"}', + ] + + service = Service('foo', client=self.mock_client, build='.') + service.build() + + self.assertEqual(self.mock_client.build.call_count, 1) + self.assertFalse(self.mock_client.build.call_args[1]['pull']) + class ServiceVolumesTest(unittest.TestCase): From 882ef2ccd881b0ebb9fb422c02be6802106c0234 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Sun, 21 Jun 2015 17:25:52 -0700 Subject: [PATCH 0916/4072] Merge pull request #1578 from aanand/fix-migrate-help Fix 'docker-compose help migrate-to-labels' (cherry picked from commit c8751980f9011b22ce9d661bd051e4fee44d4adf) Signed-off-by: Aanand Prasad --- compose/cli/docopt_command.py | 15 +++++++++------ compose/cli/main.py | 24 ++++++++++++++++++++---- tests/unit/cli_test.py | 17 +++++++++++++++++ 3 files changed, 46 insertions(+), 10 deletions(-) diff --git a/compose/cli/docopt_command.py b/compose/cli/docopt_command.py index ee694701259..6eeb33a317d 100644 --- a/compose/cli/docopt_command.py +++ b/compose/cli/docopt_command.py @@ -33,12 +33,7 @@ def parse(self, argv, global_options): if command is None: raise SystemExit(getdoc(self)) - command = command.replace('-', '_') - - if not hasattr(self, command): - raise NoSuchCommand(command, self) - - handler = getattr(self, command) + handler = self.get_handler(command) docstring = getdoc(handler) if docstring is None: @@ -47,6 +42,14 @@ def parse(self, argv, global_options): command_options = docopt_full_help(docstring, options['ARGS'], options_first=True) return options, handler, command_options + def get_handler(self, command): + command = command.replace('-', '_') + + if not hasattr(self, command): + raise NoSuchCommand(command, self) + + return getattr(self, command) + class NoSuchCommand(Exception): def __init__(self, command, supercommand): diff --git a/compose/cli/main.py b/compose/cli/main.py index 4f3f11e4e50..8aeb0459d4d 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -128,10 +128,8 @@ def help(self, project, options): Usage: help COMMAND """ - command = options['COMMAND'] - if not hasattr(self, command): - raise NoSuchCommand(command, self) - raise SystemExit(getdoc(getattr(self, command))) + handler = self.get_handler(options['COMMAND']) + raise SystemExit(getdoc(handler)) def kill(self, project, options): """ @@ -485,6 +483,24 @@ def migrate_to_labels(self, project, _options): """ Recreate containers to add labels + If you're coming 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 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 myapp_web_1 myapp_db_1 ... + Usage: migrate-to-labels """ legacy.migrate_project_to_labels(project) diff --git a/tests/unit/cli_test.py b/tests/unit/cli_test.py index 3173a274dac..d10cb9b30bb 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -11,6 +11,7 @@ from compose.cli import main from compose.cli.main import TopLevelCommand +from compose.cli.docopt_command import NoSuchCommand from compose.cli.errors import ComposeFileNotFound from compose.service import Service @@ -101,6 +102,22 @@ def test_help(self): with self.assertRaises(SystemExit): command.dispatch(['-h'], None) + def test_command_help(self): + with self.assertRaises(SystemExit) as ctx: + TopLevelCommand().dispatch(['help', 'up'], None) + + self.assertIn('Usage: up', str(ctx.exception)) + + def test_command_help_dashes(self): + with self.assertRaises(SystemExit) as ctx: + TopLevelCommand().dispatch(['help', 'migrate-to-labels'], None) + + self.assertIn('Usage: migrate-to-labels', str(ctx.exception)) + + def test_command_help_nonexistent(self): + with self.assertRaises(NoSuchCommand): + TopLevelCommand().dispatch(['help', 'nonexistent'], None) + def test_setup_logging(self): main.setup_logging() self.assertEqual(logging.getLogger().level, logging.DEBUG) From 4d4ef4e0b3744944fc2b68fca52a785bc481f12a Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Sun, 21 Jun 2015 17:32:36 -0700 Subject: [PATCH 0917/4072] Bump 1.3.1 Signed-off-by: Aanand Prasad --- CHANGES.md | 9 +++++++++ compose/__init__.py | 2 +- docs/install.md | 2 +- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 78e629b8918..1f43d88d4d6 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,6 +1,15 @@ Change log ========== +1.3.1 (2015-06-21) +------------------ + +The following bugs have been fixed: + +- `docker-compose build` would always attempt to pull the base image before building. +- `docker-compose help migrate-to-labels` failed with an error. +- If no network mode was specified, Compose would set it to "bridge", rather than allowing the Docker daemon to use its configured default network mode. + 1.3.0 (2015-06-18) ------------------ diff --git a/compose/__init__.py b/compose/__init__.py index 9e4c3fdb2f2..f3ec6acb061 100644 --- a/compose/__init__.py +++ b/compose/__init__.py @@ -1,3 +1,3 @@ from __future__ import unicode_literals -__version__ = '1.3.0' +__version__ = '1.3.1' diff --git a/docs/install.md b/docs/install.md index a608d8fe7ee..96a4a2376e9 100644 --- a/docs/install.md +++ b/docs/install.md @@ -27,7 +27,7 @@ First, install Docker version 1.6 or greater: To install Compose, run the following commands: - curl -L https://github.com/docker/compose/releases/download/1.3.0/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose + curl -L https://github.com/docker/compose/releases/download/1.3.1/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose chmod +x /usr/local/bin/docker-compose > 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 above, then `exit`. From 16213dd49304b1d3bef228dda9ea4545cfdd87a5 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 22 Jun 2015 07:58:08 -0700 Subject: [PATCH 0918/4072] Add experimental Compose/Swarm/multi-host networking guide Signed-off-by: Aanand Prasad --- experimental/compose_swarm_networking.md | 183 +++++++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 experimental/compose_swarm_networking.md diff --git a/experimental/compose_swarm_networking.md b/experimental/compose_swarm_networking.md new file mode 100644 index 00000000000..c62d53eaf0b --- /dev/null +++ b/experimental/compose_swarm_networking.md @@ -0,0 +1,183 @@ +# 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. + +> 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](http://docs.docker.com/machine/). + +- To install Compose, follow the instructions [here](http://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-discovery=token://$SWARM_TOKEN --engine-install-url="https://experimental.docker.com" --digitalocean-image "ubuntu-14-10-x64" --swarm-master --swarm --engine-opt=default-network=overlay:multihost --engine-label=com.docker.network.driver.overlay.bind_interface=eth0 --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" --swarm --engine-opt=default-network=overlay:multihost --engine-label=com.docker.network.driver.overlay.bind_interface=eth0 --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. From 52975eca6f3298c4984cf85281ba59e7f6d1e60b Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 22 Jun 2015 08:38:50 -0700 Subject: [PATCH 0919/4072] Fixes Signed-off-by: Aanand Prasad --- experimental/compose_swarm_networking.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/experimental/compose_swarm_networking.md b/experimental/compose_swarm_networking.md index c62d53eaf0b..e3dcf6ccba1 100644 --- a/experimental/compose_swarm_networking.md +++ b/experimental/compose_swarm_networking.md @@ -25,32 +25,32 @@ You’ll also need a [Docker Hub](https://hub.docker.com/account/signup/) accoun 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 + 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 + 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) + SWARM_TOKEN=$(docker run swarm create) Create a Swarm master: - $ docker-machine create -d digitalocean --swarm --swarm-discovery=token://$SWARM_TOKEN --engine-install-url="https://experimental.docker.com" --digitalocean-image "ubuntu-14-10-x64" --swarm-master --swarm --engine-opt=default-network=overlay:multihost --engine-label=com.docker.network.driver.overlay.bind_interface=eth0 --kv-store=consul:$(docker-machine ip consul):8500 swarm-0 + 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" --swarm --engine-opt=default-network=overlay:multihost --engine-label=com.docker.network.driver.overlay.bind_interface=eth0 --kv-store=consul:$(docker-machine ip consul):8500 --engine-label com.docker.network.driver.overlay.neighbor_ip=$(docker-machine ip swarm-0) swarm-1 + 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)" + eval "$(docker-machine env --swarm swarm-0)" ## Run containers and get them communicating From 9a8020d1bf92e4a756a46ff9c3cee8ac9c53f9a3 Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Thu, 25 Jun 2015 12:41:43 -0700 Subject: [PATCH 0920/4072] Add --help to bash completion Signed-off-by: Harald Albers --- contrib/completion/bash/docker-compose | 46 +++++++++++++++++++------- 1 file changed, 34 insertions(+), 12 deletions(-) diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index ad636f5f517..b785a992536 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -82,7 +82,7 @@ __docker-compose_services_stopped() { _docker-compose_build() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "--no-cache" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--help --no-cache" -- "$cur" ) ) ;; *) __docker-compose_services_from_build @@ -128,7 +128,7 @@ _docker-compose_kill() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "-s" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--help -s" -- "$cur" ) ) ;; *) __docker-compose_services_running @@ -140,7 +140,7 @@ _docker-compose_kill() { _docker-compose_logs() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "--no-color" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--help --no-color" -- "$cur" ) ) ;; *) __docker-compose_services_all @@ -149,6 +149,15 @@ _docker-compose_logs() { } +_docker-compose_migrate-to-labels() { + case "$cur" in + -*) + COMPREPLY=( $( compgen -W "--help" -- "$cur" ) ) + ;; + esac +} + + _docker-compose_port() { case "$prev" in --protocol) @@ -162,7 +171,7 @@ _docker-compose_port() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "--protocol --index" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--help --index --protocol" -- "$cur" ) ) ;; *) __docker-compose_services_all @@ -174,7 +183,7 @@ _docker-compose_port() { _docker-compose_ps() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "-q" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--help -q" -- "$cur" ) ) ;; *) __docker-compose_services_all @@ -186,7 +195,7 @@ _docker-compose_ps() { _docker-compose_pull() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "--allow-insecure-ssl" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--allow-insecure-ssl --help" -- "$cur" ) ) ;; *) __docker-compose_services_from_image @@ -204,7 +213,7 @@ _docker-compose_restart() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "-t --timeout" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--help --timeout -t" -- "$cur" ) ) ;; *) __docker-compose_services_running @@ -216,7 +225,7 @@ _docker-compose_restart() { _docker-compose_rm() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "--force -f -v" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--force -f --help -v" -- "$cur" ) ) ;; *) __docker-compose_services_stopped @@ -239,7 +248,7 @@ _docker-compose_run() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "--allow-insecure-ssl -d --entrypoint -e --no-deps --rm --service-ports -T --user -u" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--allow-insecure-ssl -d --entrypoint -e --help --no-deps --rm --service-ports -T --user -u" -- "$cur" ) ) ;; *) __docker-compose_services_all @@ -258,11 +267,24 @@ _docker-compose_scale() { compopt -o nospace ;; esac + + case "$cur" in + -*) + COMPREPLY=( $( compgen -W "--help" -- "$cur" ) ) + ;; + esac } _docker-compose_start() { - __docker-compose_services_stopped + case "$cur" in + -*) + COMPREPLY=( $( compgen -W "--help" -- "$cur" ) ) + ;; + *) + __docker-compose_services_stopped + ;; + esac } @@ -275,7 +297,7 @@ _docker-compose_stop() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "-t --timeout" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--help --timeout -t" -- "$cur" ) ) ;; *) __docker-compose_services_running @@ -293,7 +315,7 @@ _docker-compose_up() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "--allow-insecure-ssl -d --no-build --no-color --no-deps --no-recreate -t --timeout --x-smart-recreate" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--allow-insecure-ssl -d --help --no-build --no-color --no-deps --no-recreate --timeout -t --x-smart-recreate" -- "$cur" ) ) ;; *) __docker-compose_services_all From 745e838673fa1f51af2793ea9407355d14f4efea Mon Sep 17 00:00:00 2001 From: Steve Durrheimer Date: Fri, 26 Jun 2015 09:00:47 +0200 Subject: [PATCH 0921/4072] Add --help to subcommands in zsh completion Signed-off-by: Steve Durrheimer --- contrib/completion/zsh/_docker-compose | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/contrib/completion/zsh/_docker-compose b/contrib/completion/zsh/_docker-compose index 19c06675ad5..e2e5b8f9e7e 100644 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -162,6 +162,7 @@ __docker-compose_subcommand () { case "$words[1]" in (build) _arguments \ + '--help[Print usage]' \ '--no-cache[Do not use cache when building the image]' \ '*:services:__docker-compose_services_from_build' && ret=0 ;; @@ -170,20 +171,24 @@ __docker-compose_subcommand () { ;; (kill) _arguments \ + '--help[Print usage]' \ '-s[SIGNAL to send to the container. Default signal is SIGKILL.]:signal:_signals' \ '*:running services:__docker-compose_runningservices' && ret=0 ;; (logs) _arguments \ + '--help[Print usage]' \ '--no-color[Produce monochrome output.]' \ '*:services:__docker-compose_services_all' && ret=0 ;; (migrate-to-labels) - _arguments \ + _arguments -A '-*' \ + '--help[Print usage]' \ '(-):Recreate containers to add labels' && ret=0 ;; (port) _arguments \ + '--help[Print usage]' \ '--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: ' \ '1:running services:__docker-compose_runningservices' \ @@ -191,17 +196,20 @@ __docker-compose_subcommand () { ;; (ps) _arguments \ + '--help[Print usage]' \ '-q[Only display IDs]' \ '*:services:__docker-compose_services_all' && ret=0 ;; (pull) _arguments \ '--allow-insecure-ssl[Allow insecure connections to the docker registry]' \ + '--help[Print usage]' \ '*:services:__docker-compose_services_from_image' && ret=0 ;; (rm) _arguments \ '(-f --force)'{-f,--force}"[Don't ask to confirm removal]" \ + '--help[Print usage]' \ '-v[Remove volumes associated with containers]' \ '*:stopped services:__docker-compose_stoppedservices' && ret=0 ;; @@ -211,6 +219,7 @@ __docker-compose_subcommand () { '-d[Detached mode: Run container in the background, print new 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: ' \ + '--help[Print usage]' \ '(-u --user)'{-u,--user=-}'[Run as specified username or uid]:username or uid:_users' \ "--no-deps[Don't start linked services.]" \ '--rm[Remove container after run. Ignored in detached mode.]' \ @@ -221,13 +230,18 @@ __docker-compose_subcommand () { '*::arguments: _normal' && ret=0 ;; (scale) - _arguments '*:running services:__docker-compose_runningservices' && ret=0 + _arguments \ + '--help[Print usage]' \ + '*:running services:__docker-compose_runningservices' && ret=0 ;; (start) - _arguments '*:stopped services:__docker-compose_stoppedservices' && ret=0 + _arguments \ + '--help[Print usage]' \ + '*:stopped services:__docker-compose_stoppedservices' && ret=0 ;; (stop|restart) _arguments \ + '--help[Print usage]' \ '(-t --timeout)'{-t,--timeout}"[Specify a shutdown timeout in seconds. (default: 10)]:seconds: " \ '*:running services:__docker-compose_runningservices' && ret=0 ;; @@ -235,6 +249,7 @@ __docker-compose_subcommand () { _arguments \ '--allow-insecure-ssl[Allow insecure connections to the docker registry]' \ '-d[Detached mode: Run containers in the background, print new container names.]' \ + '--help[Print usage]' \ '--no-color[Produce monochrome output.]' \ "--no-deps[Don't start linked services.]" \ "--no-recreate[If containers already exist, don't recreate them.]" \ @@ -245,6 +260,7 @@ __docker-compose_subcommand () { ;; (version) _arguments \ + '--help[Print usage]' \ "--short[Shows only Compose's version number.]" && ret=0 ;; (*) From 8197d0e261acc28d128338b392d0d6fce1d0a502 Mon Sep 17 00:00:00 2001 From: Andy Wendt Date: Tue, 30 Jun 2015 10:21:36 -0600 Subject: [PATCH 0922/4072] Added uninstall documentation for pip and curl Signed-off-by: Andy Wendt --- docs/install.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/install.md b/docs/install.md index ac35c8d9f46..debd2e4eaf4 100644 --- a/docs/install.md +++ b/docs/install.md @@ -55,6 +55,21 @@ Alternatively, if you're not worried about keeping them, you can remove them - C docker rm -f 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. + + ## Compose documentation - [User guide](/) From 63941b8f6cb97c600040633a8371ffcac961bd3d Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 1 Jul 2015 15:38:07 +0100 Subject: [PATCH 0923/4072] Add Mazz to MAINTAINERS Signed-off-by: Aanand Prasad --- MAINTAINERS | 1 + 1 file changed, 1 insertion(+) diff --git a/MAINTAINERS b/MAINTAINERS index 8ac3985fa67..003242327a6 100644 --- a/MAINTAINERS +++ b/MAINTAINERS @@ -1,3 +1,4 @@ Aanand Prasad (@aanand) Ben Firshman (@bfirsh) Daniel Nephin (@dnephin) +Mazz Mosley (@mnowster) From 3906bd067e30f7c4cfbec7c2b7e878254402242d Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Wed, 1 Jul 2015 15:57:26 +0100 Subject: [PATCH 0924/4072] Remove redundant import Signed-off-by: Mazz Mosley --- docs/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/index.md b/docs/index.md index f3e73e33660..faeb8a7735c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -79,7 +79,7 @@ framework and increments a value in Redis: from flask import Flask from redis import Redis - import os + app = Flask(__name__) redis = Redis(host='redis', port=6379) From 4d69a57edda88944ab4ee6fe76640999b79e7e13 Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Wed, 1 Jul 2015 15:57:27 +0100 Subject: [PATCH 0925/4072] Include flask output When running `docker-compose up`, an extra line of output, from flask, is outputted. I've included it so anyone new to docker-compose who sees this output will know that it's expected and not worry that something might have gone wrong. Signed-off-by: Mazz Mosley --- docs/index.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/index.md b/docs/index.md index faeb8a7735c..3320bba9f19 100644 --- a/docs/index.md +++ b/docs/index.md @@ -159,6 +159,7 @@ Now, when you run `docker-compose up`, Compose will pull a Redis image, build an 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 The web app should now be listening on port 5000 on your Docker daemon host (if you're using Boot2docker, `boot2docker ip` will tell you its address). In a browser, From a7a08884469b5bd7a8a63ee91cfd11e52d16a03c Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Wed, 1 Jul 2015 15:57:27 +0100 Subject: [PATCH 0926/4072] Re-phrasing for clarity Signed-off-by: Mazz Mosley --- docs/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/README.md b/docs/README.md index 00736e476b9..4d6465637f2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,7 +12,7 @@ If you want to add a new file or change the location of the document in the menu 2. Save your changes. -3. Make sure you in your `docs` subdirectory. +3. Make sure you are in the `docs` subdirectory. 4. Build the documentation. @@ -41,7 +41,7 @@ If you want to add a new file or change the location of the document in the menu ## 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 appears in GitHub. +The top of each Docker Compose documentation file contains TOML metadata. The metadata is commented out to prevent it from appearing in GitHub. # 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](yml.md#links) for details. diff --git a/docs/reference/build.md b/docs/reference/build.md new file mode 100644 index 00000000000..b2b015119d4 --- /dev/null +++ b/docs/reference/build.md @@ -0,0 +1,22 @@ + + +# build + +``` +Usage: build [options] [SERVICE...] + +Options: +--no-cache Do not use cache when building 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. \ No newline at end of file diff --git a/docs/reference/docker-compose.md b/docs/reference/docker-compose.md new file mode 100644 index 00000000000..e252da0a700 --- /dev/null +++ b/docs/reference/docker-compose.md @@ -0,0 +1,55 @@ + + + +# docker-compose Command + +``` +Usage: + docker-compose [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 + +Commands: + build Build or rebuild services + help Get help on a command + kill Kill containers + logs View output from containers + 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 + up Create and start containers + migrate-to-labels Recreate containers to add labels +``` + +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. This +flag is optional. If you don't provide this flag. Compose looks for a file named +`docker-compose.yml` in the working directory. If the file is not found, +Compose looks in each parent directory successively, until it finds the file. + +Use a `-` as the filename to read configuration file from stdin. When stdin is +used all paths in the configuration are relative to the current working +directory. + +Each configuration can 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. diff --git a/docs/reference/help.md b/docs/reference/help.md new file mode 100644 index 00000000000..229ac5de949 --- /dev/null +++ b/docs/reference/help.md @@ -0,0 +1,17 @@ + + +# help + +``` +Usage: help COMMAND +``` + +Displays help and usage instructions for a command. diff --git a/docs/reference/index.md b/docs/reference/index.md new file mode 100644 index 00000000000..3d3d55d82a7 --- /dev/null +++ b/docs/reference/index.md @@ -0,0 +1,29 @@ + + +## Compose CLI reference + +The following pages describe the usage information for the [docker-compose](/reference/docker-compose.md) subcommands. You can also see this information by running `docker-compose [SUBCOMMAND] --help` from the command line. + +* [build](/reference/reference/build.md) +* [help](/reference/help.md) +* [kill](/reference/kill.md) +* [ps](/reference/ps.md) +* [restart](/reference/restart.md) +* [run](/reference/run.md) +* [start](/reference/start.md) +* [up](/reference/up.md) +* [logs](/reference/logs.md) +* [port](/reference/port.md) +* [pull](/reference/pull.md) +* [rm](/reference/rm.md) +* [scale](/reference/scale.md) +* [stop](/reference/stop.md) diff --git a/docs/reference/kill.md b/docs/reference/kill.md new file mode 100644 index 00000000000..c71608748cd --- /dev/null +++ b/docs/reference/kill.md @@ -0,0 +1,23 @@ + + +# 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 \ No newline at end of file diff --git a/docs/reference/logs.md b/docs/reference/logs.md new file mode 100644 index 00000000000..87f937273f2 --- /dev/null +++ b/docs/reference/logs.md @@ -0,0 +1,20 @@ + + +# logs + +``` +Usage: logs [options] [SERVICE...] + +Options: +--no-color Produce monochrome output. +``` + +Displays log output from services. diff --git a/docs/reference/overview.md b/docs/reference/overview.md new file mode 100644 index 00000000000..561069df87a --- /dev/null +++ b/docs/reference/overview.md @@ -0,0 +1,68 @@ + + + +# 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. + +## 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 + +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 current working directory. + +### 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. + +### 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\_MAX\_WORKERS + +Configures the maximum number of worker threads to be used when executing +commands in parallel. Only a subset of commands execute in parallel, `stop`, +`kill` and `rm`. + + + + + + + +## Compose documentation + +- [User guide](/) +- [Installing Compose](install.md) +- [Get started with Django](django.md) +- [Get started with Rails](rails.md) +- [Get started with Wordpress](wordpress.md) +- [Yaml file reference](yml.md) +- [Compose environment variables](env.md) +- [Compose command line completion](completion.md) diff --git a/docs/reference/port.md b/docs/reference/port.md new file mode 100644 index 00000000000..4745c92d320 --- /dev/null +++ b/docs/reference/port.md @@ -0,0 +1,22 @@ + + +# 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. \ No newline at end of file diff --git a/docs/reference/ps.md b/docs/reference/ps.md new file mode 100644 index 00000000000..b271376f802 --- /dev/null +++ b/docs/reference/ps.md @@ -0,0 +1,20 @@ + + +# ps + +``` +Usage: ps [options] [SERVICE...] + +Options: +-q Only display IDs +``` + +Lists containers. diff --git a/docs/reference/pull.md b/docs/reference/pull.md new file mode 100644 index 00000000000..571d3872b90 --- /dev/null +++ b/docs/reference/pull.md @@ -0,0 +1,20 @@ + + +# pull + +``` +Usage: pull [options] [SERVICE...] + +Options: +--allow-insecure-ssl Allow insecure connections to the docker registry +``` + +Pulls service images. \ No newline at end of file diff --git a/docs/reference/restart.md b/docs/reference/restart.md new file mode 100644 index 00000000000..9b570082bfd --- /dev/null +++ b/docs/reference/restart.md @@ -0,0 +1,20 @@ + + +# 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 new file mode 100644 index 00000000000..0a4ba5b6bc3 --- /dev/null +++ b/docs/reference/rm.md @@ -0,0 +1,21 @@ + + +# rm + +``` +Usage: rm [options] [SERVICE...] + +Options: +-f, --force Don't ask to confirm removal +-v Remove volumes associated with containers +``` + +Removes stopped service containers. diff --git a/docs/reference/run.md b/docs/reference/run.md new file mode 100644 index 00000000000..78ec20fc202 --- /dev/null +++ b/docs/reference/run.md @@ -0,0 +1,54 @@ + + +# run + +``` +Usage: run [options] [-e KEY=VAL...] SERVICE [COMMAND] [ARGS...] + +Options: +--allow-insecure-ssl Allow insecure connections to the docker + registry +-d Detached mode: Run container in the background, print + new container name. +--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. +--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. +``` + +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 + +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 new file mode 100644 index 00000000000..95418300977 --- /dev/null +++ b/docs/reference/scale.md @@ -0,0 +1,21 @@ + + +# 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 \ No newline at end of file diff --git a/docs/reference/start.md b/docs/reference/start.md new file mode 100644 index 00000000000..69d853f9cb6 --- /dev/null +++ b/docs/reference/start.md @@ -0,0 +1,17 @@ + + +# start + +``` +Usage: start [SERVICE...] +``` + +Starts existing containers for a service. diff --git a/docs/reference/stop.md b/docs/reference/stop.md new file mode 100644 index 00000000000..8ff92129d2e --- /dev/null +++ b/docs/reference/stop.md @@ -0,0 +1,21 @@ + + +# 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/up.md b/docs/reference/up.md new file mode 100644 index 00000000000..0a1cecff16d --- /dev/null +++ b/docs/reference/up.md @@ -0,0 +1,42 @@ + + +# up + +``` +Usage: up [options] [SERVICE...] + +Options: +--allow-insecure-ssl Allow insecure connections to the docker + registry +-d Detached mode: Run containers in the background, + print new container names. +--no-color Produce monochrome output. +--no-deps Don't start linked services. +--x-smart-recreate Only recreate containers whose configuration or + image needs to be updated. (EXPERIMENTAL) +--no-recreate If containers already exist, don't recreate them. +--no-build Don't build an image, even if it's missing +-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. + +Linked services will be started, unless they are already running. + +By default, `docker-compose up` will aggregate the output of each container and, +when it exits, all containers will be stopped. Running `docker-compose up -d`, +will start the containers in the background and leave them running. + +By default, if there are existing containers for a service, `docker-compose up` will stop and recreate them (preserving mounted volumes with [volumes-from]), so that changes in `docker-compose.yml` are picked up. If you do not want containers stopped and recreated, use `docker-compose up --no-recreate`. This will still start any stopped containers, if needed. + +[volumes-from]: http://docs.docker.io/en/latest/use/working_with_volumes/ From 9ffe69a572e862eb90017cceddd65d76c4eed555 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 16 Jul 2015 12:46:15 +0100 Subject: [PATCH 0993/4072] Refactor can_be_scaled for clarity Signed-off-by: Aanand Prasad --- compose/service.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/compose/service.py b/compose/service.py index a488b2c68c9..f73fa96b460 100644 --- a/compose/service.py +++ b/compose/service.py @@ -157,7 +157,7 @@ def scale(self, desired_num, timeout=DEFAULT_TIMEOUT): - starts containers until there are at least `desired_num` running - removes all stopped containers """ - if not self.can_be_scaled(): + if self.specifies_host_port(): log.warn('Service %s specifies a port on the host. If multiple containers ' 'for this service are created on a single host, the port will clash.' % self.name) @@ -703,11 +703,11 @@ def labels(self, one_off=False): '{0}={1}'.format(LABEL_ONE_OFF, "True" if one_off else "False") ] - def can_be_scaled(self): + def specifies_host_port(self): for port in self.options.get('ports', []): if ':' in str(port): - return False - return True + return True + return False def pull(self, insecure_registry=False): if 'image' not in self.options: From 445fe89fcec3292919b3432223f90c77cafbbbee Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 17 Jul 2015 14:59:43 +0100 Subject: [PATCH 0994/4072] Tweak wording of scale warning 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 f73fa96b460..d606e6b21f7 100644 --- a/compose/service.py +++ b/compose/service.py @@ -158,7 +158,7 @@ def scale(self, desired_num, timeout=DEFAULT_TIMEOUT): - removes all stopped containers """ if self.specifies_host_port(): - log.warn('Service %s specifies a port on the host. If multiple containers ' + 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) From 35092f1d5ebd9433ceb1dd2c403a69009d122a95 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 17 Jul 2015 15:27:20 +0100 Subject: [PATCH 0995/4072] Fix regression in docs for 'up' Signed-off-by: Aanand Prasad --- docs/reference/up.md | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/docs/reference/up.md b/docs/reference/up.md index 0a1cecff16d..8fe4fad5c8b 100644 --- a/docs/reference/up.md +++ b/docs/reference/up.md @@ -20,9 +20,10 @@ Options: print new container names. --no-color Produce monochrome output. --no-deps Don't start linked services. ---x-smart-recreate Only recreate containers whose configuration or - image needs to be updated. (EXPERIMENTAL) +--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 -t, --timeout TIMEOUT Use this timeout in seconds for container shutdown when attached or when containers are already @@ -31,12 +32,17 @@ Options: Builds, (re)creates, starts, and attaches to containers for a service. -Linked services will be started, unless they are already running. +Unless they are already running, this command also starts any linked services. -By default, `docker-compose up` will aggregate the output of each container and, -when it exits, all containers will be stopped. Running `docker-compose up -d`, -will start the containers in the background and leave them running. +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. -By default, if there are existing containers for a service, `docker-compose up` will stop and recreate them (preserving mounted volumes with [volumes-from]), so that changes in `docker-compose.yml` are picked up. If you do not want containers stopped and recreated, use `docker-compose up --no-recreate`. This will still start any stopped containers, if needed. +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. -[volumes-from]: http://docs.docker.io/en/latest/use/working_with_volumes/ +If you want to force Compose to stop and recreate all containers, use the +`--force-recreate` flag. From a3191ab90f57a42aa84c94e9920287b9e9b81f3f Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 16 Jul 2015 12:51:15 +0100 Subject: [PATCH 0996/4072] Add container_name option Signed-off-by: Aanand Prasad --- compose/config.py | 1 + compose/service.py | 12 +++++++++++- docs/yml.md | 10 ++++++++++ tests/integration/service_test.py | 7 +++++++ 4 files changed, 29 insertions(+), 1 deletion(-) diff --git a/compose/config.py b/compose/config.py index 2baf327b01e..064dadaec44 100644 --- a/compose/config.py +++ b/compose/config.py @@ -50,6 +50,7 @@ ALLOWED_KEYS = DOCKER_CONFIG_KEYS + [ 'build', + 'container_name', 'dockerfile', 'expose', 'external_links', diff --git a/compose/service.py b/compose/service.py index d606e6b21f7..d90318f5ec5 100644 --- a/compose/service.py +++ b/compose/service.py @@ -157,6 +157,12 @@ def scale(self, desired_num, timeout=DEFAULT_TIMEOUT): - starts containers until there are at least `desired_num` running - removes all stopped containers """ + 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())) + if self.specifies_host_port(): 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.' @@ -531,7 +537,8 @@ def _get_container_create_options( for k in DOCKER_CONFIG_KEYS if k in self.options) container_options.update(override_options) - container_options['name'] = self.get_container_name(number, one_off) + container_options['name'] = self.custom_container_name() \ + or self.get_container_name(number, one_off) if add_config_hash: config_hash = self.config_hash() @@ -703,6 +710,9 @@ def labels(self, one_off=False): '{0}={1}'.format(LABEL_ONE_OFF, "True" if one_off else "False") ] + def custom_container_name(self): + return self.options.get('container_name') + def specifies_host_port(self): for port in self.options.get('ports', []): if ':' in str(port): diff --git a/docs/yml.md b/docs/yml.md index 772e5dd5543..f92b5682568 100644 --- a/docs/yml.md +++ b/docs/yml.md @@ -239,6 +239,16 @@ It's recommended that you use reverse-DNS notation to prevent your labels from c - "com.example.department=Finance" - "com.example.label-with-empty-value" +### 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. + ### log driver Specify a logging driver for the service's containers, as with the ``--log-driver`` option for docker run ([documented here](http://docs.docker.com/reference/run/#logging-drivers-log-driver)). diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 9880b8e84dc..dbb97d8f3bf 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -699,6 +699,13 @@ def test_empty_labels(self): for name in labels_list: self.assertIn((name, ''), labels) + 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') + + container = create_and_start_container(service) + self.assertEqual(container.name, 'my-web-container') + def test_log_drive_invalid(self): service = self.create_service('web', log_driver='xxx') self.assertRaises(ValueError, lambda: create_and_start_container(service)) From 89f6caf871f5e7591f91ce1bc39d79c4ab8c90bd Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 5 Jun 2015 09:57:00 +0100 Subject: [PATCH 0997/4072] Allow any volume mode to be specified Signed-off-by: Aanand Prasad --- compose/service.py | 7 +------ tests/unit/service_test.py | 30 ++++++++++++++++++++++++++---- 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/compose/service.py b/compose/service.py index a488b2c68c9..006696c2dc1 100644 --- a/compose/service.py +++ b/compose/service.py @@ -809,12 +809,7 @@ def parse_volume_spec(volume_config): if len(parts) == 2: parts.append('rw') - external, internal, mode = parts - if mode not in ('rw', 'ro'): - raise ConfigError("Volume %s has invalid mode (%s), should be " - "one of: rw, ro." % (volume_config, mode)) - - return VolumeSpec(external, internal, mode) + return VolumeSpec(*parts) # Ports diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 2b370ebea3c..104a90d535a 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -372,14 +372,13 @@ def test_parse_volume_spec_with_mode(self): spec = parse_volume_spec('external:interval:ro') self.assertEqual(spec, ('external', 'interval', 'ro')) + spec = parse_volume_spec('external:interval:z') + self.assertEqual(spec, ('external', 'interval', 'z')) + def test_parse_volume_spec_too_many_parts(self): with self.assertRaises(ConfigError): parse_volume_spec('one:two:three:four') - def test_parse_volume_bad_mode(self): - with self.assertRaises(ConfigError): - parse_volume_spec('one:two:notrw') - def test_build_volume_binding(self): binding = build_volume_binding(parse_volume_spec('/outside:/inside')) self.assertEqual(binding, ('/inside', '/outside:/inside:rw')) @@ -508,3 +507,26 @@ def test_different_host_path_in_container_json(self): create_options['host_config']['Binds'], ['/mnt/sda1/host/path:/data:rw'], ) + + def test_create_with_special_volume_mode(self): + self.mock_client.inspect_image.return_value = {'Id': 'imageid'} + + create_calls = [] + + def create_container(*args, **kwargs): + create_calls.append((args, kwargs)) + return {'Id': 'containerid'} + + self.mock_client.create_container = create_container + + volumes = ['/tmp:/foo:z'] + + Service( + 'web', + client=self.mock_client, + image='busybox', + volumes=volumes, + ).create_container() + + self.assertEqual(len(create_calls), 1) + self.assertEqual(create_calls[0][1]['host_config']['Binds'], volumes) From 24071935947a3008a2087185d05f133e54937a78 Mon Sep 17 00:00:00 2001 From: Mary Anthony Date: Fri, 17 Jul 2015 09:14:44 -0700 Subject: [PATCH 0998/4072] remove cli Signed-off-by: Mary Anthony --- docs/cli.md | 216 ---------------------------------------------------- 1 file changed, 216 deletions(-) delete mode 100644 docs/cli.md diff --git a/docs/cli.md b/docs/cli.md deleted file mode 100644 index 43cf61c52c1..00000000000 --- a/docs/cli.md +++ /dev/null @@ -1,216 +0,0 @@ - - - -# Compose CLI reference - -Most Docker Compose commands are run against one or more services. If -the service is not specified, the command will apply to all services. - -For full usage information, run `docker-compose [COMMAND] --help`. - -## Commands - -### build - -Builds or rebuilds services. - -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. - -### help - -Displays help and usage instructions for a command. - -### kill - -Forces running containers to stop by sending a `SIGKILL` signal. Optionally the -signal can be passed, for example: - - $ docker-compose kill -s SIGINT - -### logs - -Displays log output from services. - -### port - -Prints the public port for a port binding - -### ps - -Lists containers. - -### pull - -Pulls service images. - -### restart - -Restarts services. - -### rm - -Removes stopped service containers. - - -### run - -Runs a one-off command on a service. - -For example, - - $ docker-compose run web python manage.py shell - -will start the `web` service and then run `manage.py shell` in python. -Note that by default, linked services will also be started, unless they are -already running. - -One-off commands are started in new containers with the same configuration as a -normal container for that service, so volumes, links, etc will all be created as -expected. When using `run`, there are two differences from bringing up a -container normally: - -1. the command will be overridden with the one specified. So, if you run -`docker-compose run web bash`, the container's web command (which could default -to, e.g., `python app.py`) will be overridden to `bash` - -2. by default no ports will be created in case they collide with already opened -ports. - -Links are also created between one-off commands and the other containers which -are part of that service. 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 -(which would get created or started as needed). - -If you do not want linked containers to start when running the one-off command, -specify the `--no-deps` flag: - - $ docker-compose run --no-deps web python manage.py shell - -Similarly, if you do want the service's ports to be created and mapped to the -host, specify the `--service-ports` flag: - - $ docker-compose run --service-ports web python manage.py shell - - -### scale - -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 - -### start - -Starts existing containers for a service. - -### stop - -Stops running containers without removing them. They can be started again with -`docker-compose start`. - -### up - -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. - -## Options - -### --verbose - - Shows more output - -### -v, --version - - Prints version and exits - -### -f, --file FILE - - Specify what file to read configuration from. If not provided, Compose will look - for `docker-compose.yml` in the current working directory, and then each parent - directory successively, until found. - - Use a `-` as the filename to read configuration from stdin. When stdin is used - all paths in the configuration will be relative to the current working - directory. - -### -p, --project-name NAME - - Specifies an alternate project name (default: current directory name) - - -## Environment Variables - -Several environment variables are available for you to configure Compose's behaviour. - -Variables starting with `DOCKER_` are the same as those used to configure the -Docker command-line client. If you're using boot2docker, `eval "$(boot2docker shellinit)"` -will set them to their correct values. - -### COMPOSE\_PROJECT\_NAME - -Sets the project name, which is prepended to the name of every container started by Compose. Defaults to the `basename` of the current working directory. - -### COMPOSE\_FILE - -Specify what file to read configuration from. If not provided, Compose will look -for `docker-compose.yml` in the current working directory, and then each parent -directory successively, until found. - -### 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 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\_MAX\_WORKERS - -Configures the maximum number of worker threads to be used when executing -commands in parallel. Only a subset of commands execute in parallel, `stop`, -`kill` and `rm`. - -## Compose documentation - -- [User guide](/) -- [Installing Compose](install.md) -- [Get started with Django](django.md) -- [Get started with Rails](rails.md) -- [Get started with Wordpress](wordpress.md) -- [Yaml file reference](yml.md) -- [Compose environment variables](env.md) -- [Compose command line completion](completion.md) From 4ca210edd78bc00bb63ebc4ad834ae6627cc453a Mon Sep 17 00:00:00 2001 From: Mary Anthony Date: Fri, 17 Jul 2015 16:17:46 -0700 Subject: [PATCH 0999/4072] Removing references to boot2docker - Replace with machine references - 1.8 boot2docker is deprecated Signed-off-by: Mary Anthony --- docs/django.md | 3 +-- docs/index.md | 2 +- docs/rails.md | 4 ++-- docs/wordpress.md | 4 +--- 4 files changed, 5 insertions(+), 8 deletions(-) diff --git a/docs/django.md b/docs/django.md index 844c24f6e08..71df4e11689 100644 --- a/docs/django.md +++ b/docs/django.md @@ -115,8 +115,7 @@ Then, run `docker-compose up`: myapp_web_1 | Starting development server at http://0.0.0.0:8000/ myapp_web_1 | Quit the server with CONTROL-C. -Your Django app should nw be running at port 8000 on your Docker daemon (if -you're using Boot2docker, `boot2docker ip` will tell you its address). +Your Django app should nw be running at port 8000 on your Docker daemon. If you are using a Docker Machine VM, you can use the `docker-machine ip MACHINE_NAME` to get the IP address. You can also run management commands with Docker. To set up your database, for example, run `docker-compose up` and in another terminal run: diff --git a/docs/index.md b/docs/index.md index 62f2198ed9b..6d949f88d3e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -161,7 +161,7 @@ Now, when you run `docker-compose up`, Compose will pull a Redis image, build an web_1 | * Running on http://0.0.0.0:5000/ web_1 | * Restarting with stat -If you're using [Boot2docker](https://github.com/boot2docker/boot2docker), then `boot2docker ip` will tell you its address and you can open `http://ip-from-boot2docker:5000` in a browser. +If you're using [Docker Machine](https://docs.docker.com/machine), then `docker-machine ip MACHINE_VM` will tell you its address and you can open `http://MACHINE_VM_IP:5000` in a browser. If you're not using Boot2docker and are on linux, 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 localhost:5000. diff --git a/docs/rails.md b/docs/rails.md index cb8078647d6..7394aadc8ae 100644 --- a/docs/rails.md +++ b/docs/rails.md @@ -119,8 +119,8 @@ 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 Boot2docker, `boot2docker ip` will tell you its address). +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. + ## More Compose documentation diff --git a/docs/wordpress.md b/docs/wordpress.md index 65a7d17f43e..eda755c1784 100644 --- a/docs/wordpress.md +++ b/docs/wordpress.md @@ -108,9 +108,7 @@ Second, `router.php` tells PHP's built-in web server how to run Wordpress: 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. You'll then be able to visit Wordpress at port 8000 on your -Docker daemon (if you're using Boot2docker, `boot2docker ip` will tell you its -address). +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. ## More Compose documentation From 949dd5b2c7fffdd3790a9429f89af37dd2a8e0af Mon Sep 17 00:00:00 2001 From: Mary Anthony Date: Sun, 19 Jul 2015 16:08:01 -0700 Subject: [PATCH 1000/4072] Updating with the latest image Signed-off-by: Mary Anthony --- docs/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/Dockerfile b/docs/Dockerfile index a49c1e7f376..d6864c2d667 100644 --- a/docs/Dockerfile +++ b/docs/Dockerfile @@ -1,4 +1,4 @@ -FROM docs/base:hugo +FROM docs/base:latest MAINTAINER Mary Anthony (@moxiegirl) # To get the git info for this repo From 61787fecea4e0058dc65d5dcb29afe3399621ce0 Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Thu, 16 Jul 2015 14:32:39 +0100 Subject: [PATCH 1001/4072] Resolve race condition Sometimes, some messages were being executed at the same time, meaning that the status wasn't being overwritten, it was displaying on a separate line for both doing and done messages. Rather than trying to have both sets of statuses being written out concurrently, we write out all of the doing messages first. Then the done messages are written out/updated, as they are completed. Signed-off-by: Mazz Mosley --- compose/utils.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/compose/utils.py b/compose/utils.py index 3efde052146..5ffe7b707da 100644 --- a/compose/utils.py +++ b/compose/utils.py @@ -21,8 +21,10 @@ def parallel_execute(command, containers, doing_msg, done_msg, **options): stream = codecs.getwriter('utf-8')(sys.stdout) lines = [] - def container_command_execute(container, command, **options): + for container in containers: write_out_msg(stream, lines, container.name, doing_msg) + + def container_command_execute(container, command, **options): return getattr(container, command)(**options) with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor: @@ -41,6 +43,10 @@ def container_command_execute(container, command, **options): def write_out_msg(stream, lines, container_name, msg): + """ + Using special ANSI code characters we can write out the msg over the top of + a previous status message, if it exists. + """ if container_name in lines: position = lines.index(container_name) diff = len(lines) - position @@ -56,6 +62,8 @@ def write_out_msg(stream, lines, container_name, msg): lines.append(container_name) stream.write("{}: {}... \r\n".format(container_name, msg)) + stream.flush() + def json_hash(obj): dump = json.dumps(obj, sort_keys=True, separators=(',', ':')) From 9d9b8657966a574ff4ec30390985606227ea6e14 Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Thu, 16 Jul 2015 16:07:30 +0100 Subject: [PATCH 1002/4072] Add in error handling Signed-off-by: Mazz Mosley --- compose/utils.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/compose/utils.py b/compose/utils.py index 5ffe7b707da..c3316ccda8d 100644 --- a/compose/utils.py +++ b/compose/utils.py @@ -5,6 +5,7 @@ import os import sys +from docker.errors import APIError import concurrent.futures from .const import DEFAULT_MAX_WORKERS @@ -20,12 +21,16 @@ def parallel_execute(command, containers, doing_msg, done_msg, **options): max_workers = os.environ.get('COMPOSE_MAX_WORKERS', DEFAULT_MAX_WORKERS) stream = codecs.getwriter('utf-8')(sys.stdout) lines = [] + errors = {} for container in containers: write_out_msg(stream, lines, container.name, doing_msg) def container_command_execute(container, command, **options): - return getattr(container, command)(**options) + try: + getattr(container, command)(**options) + except APIError as e: + errors[container.name] = e.explanation with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor: future_container = { @@ -41,6 +46,10 @@ def container_command_execute(container, command, **options): container = future_container[future] write_out_msg(stream, lines, container.name, done_msg) + if errors: + for container in errors: + stream.write("ERROR: for {} {} \n".format(container, errors[container])) + def write_out_msg(stream, lines, container_name, msg): """ From 4ba9d9dac2c661d6ed56ad0dfca71e64c4162b9c Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 20 Jul 2015 14:03:44 +0100 Subject: [PATCH 1003/4072] Make parallel tasks interruptible with Ctrl-C The concurrent.futures backport doesn't play well with KeyboardInterrupt, so I'm using Thread and Queue instead. Since thread pooling would likely be a pain to implement, I've just removed `COMPOSE_MAX_WORKERS` for now. We'll implement it later if we decide we need it. Signed-off-by: Aanand Prasad --- compose/const.py | 1 - compose/utils.py | 39 +++++++++++++++++++++----------------- docs/reference/overview.md | 6 ------ requirements.txt | 1 - setup.py | 1 - 5 files changed, 22 insertions(+), 26 deletions(-) diff --git a/compose/const.py b/compose/const.py index 479b6af45f2..709c3a10d74 100644 --- a/compose/const.py +++ b/compose/const.py @@ -1,5 +1,4 @@ -DEFAULT_MAX_WORKERS = 20 DEFAULT_TIMEOUT = 10 LABEL_CONTAINER_NUMBER = 'com.docker.compose.container-number' LABEL_ONE_OFF = 'com.docker.compose.oneoff' diff --git a/compose/utils.py b/compose/utils.py index c3316ccda8d..b6ee63d0372 100644 --- a/compose/utils.py +++ b/compose/utils.py @@ -2,13 +2,11 @@ import hashlib import json import logging -import os import sys from docker.errors import APIError -import concurrent.futures - -from .const import DEFAULT_MAX_WORKERS +from Queue import Queue, Empty +from threading import Thread log = logging.getLogger(__name__) @@ -18,7 +16,6 @@ def parallel_execute(command, containers, doing_msg, done_msg, **options): """ Execute a given command upon a list of containers in parallel. """ - max_workers = os.environ.get('COMPOSE_MAX_WORKERS', DEFAULT_MAX_WORKERS) stream = codecs.getwriter('utf-8')(sys.stdout) lines = [] errors = {} @@ -26,25 +23,33 @@ def parallel_execute(command, containers, doing_msg, done_msg, **options): for container in containers: write_out_msg(stream, lines, container.name, doing_msg) + q = Queue() + def container_command_execute(container, command, **options): try: getattr(container, command)(**options) except APIError as e: errors[container.name] = e.explanation + q.put(container) + + for container in containers: + t = Thread( + target=container_command_execute, + args=(container, command), + kwargs=options, + ) + t.daemon = True + t.start() + + done = 0 - with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor: - future_container = { - executor.submit( - container_command_execute, - container, - command, - **options - ): container for container in containers - } - - for future in concurrent.futures.as_completed(future_container): - container = future_container[future] + while done < len(containers): + try: + container = q.get(timeout=1) write_out_msg(stream, lines, container.name, done_msg) + done += 1 + except Empty: + pass if errors: for container in errors: diff --git a/docs/reference/overview.md b/docs/reference/overview.md index 561069df87a..458dea40466 100644 --- a/docs/reference/overview.md +++ b/docs/reference/overview.md @@ -44,12 +44,6 @@ the `docker` daemon. Configures the path to the `ca.pem`, `cert.pem`, and `key.pem` files used for TLS verification. Defaults to `~/.docker`. -### COMPOSE\_MAX\_WORKERS - -Configures the maximum number of worker threads to be used when executing -commands in parallel. Only a subset of commands execute in parallel, `stop`, -`kill` and `rm`. - diff --git a/requirements.txt b/requirements.txt index dce583015be..fc5b68489c0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,6 @@ PyYAML==3.10 docker-py==1.3.0 dockerpty==0.3.4 docopt==0.6.1 -futures==3.0.3 requests==2.6.1 six==1.7.3 texttable==0.8.2 diff --git a/setup.py b/setup.py index 6ce7da44c64..d0ec106798e 100644 --- a/setup.py +++ b/setup.py @@ -33,7 +33,6 @@ def find_version(*file_paths): 'docker-py >= 1.3.0, < 1.4', 'dockerpty >= 0.3.4, < 0.4', 'six >= 1.3.0, < 2', - 'futures >= 3.0.3', ] From 5c29ded6acaf09943e395472b6fb2ee095546f43 Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Fri, 17 Jul 2015 17:40:14 +0100 Subject: [PATCH 1004/4072] Parallelise scale Signed-off-by: Mazz Mosley --- compose/service.py | 61 ++++++++++++++++++++++++++-------------------- compose/utils.py | 45 ++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 27 deletions(-) diff --git a/compose/service.py b/compose/service.py index 006696c2dc1..cda68b7f567 100644 --- a/compose/service.py +++ b/compose/service.py @@ -24,7 +24,7 @@ from .container import Container from .legacy import check_for_legacy_containers from .progress_stream import stream_output, StreamOutputError -from .utils import json_hash +from .utils import json_hash, parallel_create_execute, parallel_execute log = logging.getLogger(__name__) @@ -162,36 +162,43 @@ def scale(self, desired_num, timeout=DEFAULT_TIMEOUT): 'for this service are created on a single host, the port will clash.' % self.name) - # Create enough containers - containers = self.containers(stopped=True) - while len(containers) < desired_num: - containers.append(self.create_container()) + def create_and_start(number): + container = self.create_container(number=number, quiet=True) + container.start() + return container - running_containers = [] - stopped_containers = [] - for c in containers: - if c.is_running: - running_containers.append(c) - else: - stopped_containers.append(c) - running_containers.sort(key=lambda c: c.number) - stopped_containers.sort(key=lambda c: c.number) + msgs = {'doing': 'Creating', 'done': 'Started'} - # Stop containers - while len(running_containers) > desired_num: - c = running_containers.pop() - log.info("Stopping %s..." % c.name) - c.stop(timeout=timeout) - stopped_containers.append(c) + running_containers = self.containers(stopped=False) + num_running = len(running_containers) + + if desired_num == num_running: + # do nothing as we already have the desired number + log.info('Desired container number already achieved') + return + + if desired_num > num_running: + num_to_create = desired_num - num_running + next_number = self._next_container_number() + container_numbers = [ + number for number in range( + next_number, next_number + num_to_create + ) + ] + parallel_create_execute(create_and_start, container_numbers, msgs) + + if desired_num < num_running: + sorted_running_containers = sorted(running_containers, key=attrgetter('number')) - # Start containers - while len(running_containers) < desired_num: - c = stopped_containers.pop(0) - log.info("Starting %s..." % c.name) - self.start_container(c) - running_containers.append(c) + if desired_num < num_running: + # count number of running containers. + num_to_stop = num_running - desired_num - self.remove_stopped() + containers_to_stop = sorted_running_containers[-num_to_stop:] + # TODO: refactor these out? + parallel_execute("stop", containers_to_stop, "Stopping", "Stopped") + parallel_execute("remove", containers_to_stop, "Removing", "Removed") + # self.remove_stopped() def remove_stopped(self, **options): for c in self.containers(stopped=True): diff --git a/compose/utils.py b/compose/utils.py index b6ee63d0372..af6aa902adf 100644 --- a/compose/utils.py +++ b/compose/utils.py @@ -12,6 +12,51 @@ log = logging.getLogger(__name__) +def parallel_create_execute(create_function, container_numbers, msgs={}, **options): + """ + Parallel container creation by calling the create_function for each new container + number passed in. + """ + stream = codecs.getwriter('utf-8')(sys.stdout) + lines = [] + errors = {} + + for number in container_numbers: + write_out_msg(stream, lines, number, msgs['doing']) + + q = Queue() + + def inner_call_function(create_function, number): + try: + container = create_function(number) + except APIError as e: + errors[number] = e.explanation + q.put(container) + + for number in container_numbers: + t = Thread( + target=inner_call_function, + args=(create_function, number), + kwargs=options, + ) + t.daemon = True + t.start() + + done = 0 + total_to_create = len(container_numbers) + while done < total_to_create: + try: + container = q.get(timeout=1) + write_out_msg(stream, lines, container.name, msgs['done']) + done += 1 + except Empty: + pass + + if errors: + for number in errors: + stream.write("ERROR: for {} {} \n".format(number, errors[number])) + + def parallel_execute(command, containers, doing_msg, done_msg, **options): """ Execute a given command upon a list of containers in parallel. From d1fdf1b809609abf22d27bff57b54658e0e4125d Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 20 Jul 2015 17:18:39 +0100 Subject: [PATCH 1005/4072] Update bash and zsh completion for --force-recrate Signed-off-by: Aanand Prasad --- contrib/completion/bash/docker-compose | 2 +- contrib/completion/zsh/_docker-compose | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index b785a992536..133b9fc388b 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -315,7 +315,7 @@ _docker-compose_up() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "--allow-insecure-ssl -d --help --no-build --no-color --no-deps --no-recreate --timeout -t --x-smart-recreate" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--allow-insecure-ssl -d --help --no-build --no-color --no-deps --no-recreate --force-recreate --timeout -t" -- "$cur" ) ) ;; *) __docker-compose_services_all diff --git a/contrib/completion/zsh/_docker-compose b/contrib/completion/zsh/_docker-compose index e2e5b8f9e7e..2893c3fc387 100644 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -253,9 +253,9 @@ __docker-compose_subcommand () { '--no-color[Produce monochrome output.]' \ "--no-deps[Don't start linked services.]" \ "--no-recreate[If containers already exist, don't recreate them.]" \ + "--force-recreate[Recreate containers even if their configuration and image haven't changed]" \ "--no-build[Don't build an image, even if it's missing]" \ '(-t --timeout)'{-t,--timeout}"[Specify a shutdown timeout in seconds. (default: 10)]:seconds: " \ - "--x-smart-recreate[Only recreate containers whose configuration or image needs to be updated. (EXPERIMENTAL)]" \ '*:services:__docker-compose_services_all' && ret=0 ;; (version) From 04b7490ef2156ace9905074de317fb525114e216 Mon Sep 17 00:00:00 2001 From: Christoph Witzany Date: Tue, 21 Jul 2015 11:53:44 +0200 Subject: [PATCH 1006/4072] Fix required version of websockets-client Signed-off-by: Christoph Witzany --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index d0ec106798e..0979b2f2c2e 100644 --- a/setup.py +++ b/setup.py @@ -29,7 +29,7 @@ def find_version(*file_paths): 'PyYAML >= 3.10, < 4', 'requests >= 2.6.1, < 2.7', 'texttable >= 0.8.1, < 0.9', - 'websocket-client >= 0.11.0, < 1.0', + 'websocket-client >= 0.32.0, < 1.0', 'docker-py >= 1.3.0, < 1.4', 'dockerpty >= 0.3.4, < 0.4', 'six >= 1.3.0, < 2', From 38a6209acd5eb65db2fdf8fa6eb77dacbf05e731 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 21 Jul 2015 11:07:20 +0100 Subject: [PATCH 1007/4072] Stop printing a stack trace when there's an error when pulling Signed-off-by: Aanand Prasad --- compose/cli/main.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/compose/cli/main.py b/compose/cli/main.py index 58b2253075c..df40ee930d0 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -16,6 +16,7 @@ from ..project import NoSuchService, ConfigurationError from ..service import BuildError, NeedsBuildError from ..config import parse_environment +from ..progress_stream import StreamOutputError from .command import Command from .docopt_command import NoSuchCommand from .errors import UserError @@ -48,6 +49,9 @@ def main(): except BuildError as e: log.error("Service '%s' failed to build: %s" % (e.service.name, e.reason)) sys.exit(1) + except StreamOutputError as e: + log.error(e) + sys.exit(1) except NeedsBuildError as e: log.error("Service '%s' needs to be built, but --no-build was passed." % e.service.name) sys.exit(1) From da650e9cfdb8f67f7e14592930bc6e8a904f0d85 Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Tue, 21 Jul 2015 11:56:59 +0100 Subject: [PATCH 1008/4072] Refactor parallel execute Refactored parallel execute and execute create into a single function parallel_execute that can now handle both cases. This helps untangle it from being so tightly coupled to the container. Updated all the relevant operations to use the refactored function. Signed-off-by: Mazz Mosley --- compose/project.py | 21 ++++++++-- compose/service.py | 39 ++++++++++++------- compose/utils.py | 97 ++++++++++++++-------------------------------- 3 files changed, 71 insertions(+), 86 deletions(-) diff --git a/compose/project.py b/compose/project.py index 541ff3ff8d8..c5028492c40 100644 --- a/compose/project.py +++ b/compose/project.py @@ -198,15 +198,30 @@ def start(self, service_names=None, **options): service.start(**options) def stop(self, service_names=None, **options): - parallel_execute("stop", self.containers(service_names), "Stopping", "Stopped", **options) + parallel_execute( + objects=self.containers(service_names), + obj_callable=lambda c: c.stop(**options), + msg_index=lambda c: c.name, + msg="Stopping" + ) def kill(self, service_names=None, **options): - parallel_execute("kill", self.containers(service_names), "Killing", "Killed", **options) + parallel_execute( + objects=self.containers(service_names), + obj_callable=lambda c: c.kill(**options), + msg_index=lambda c: c.name, + msg="Killing" + ) def remove_stopped(self, service_names=None, **options): all_containers = self.containers(service_names, stopped=True) stopped_containers = [c for c in all_containers if not c.is_running] - parallel_execute("remove", stopped_containers, "Removing", "Removed", **options) + parallel_execute( + objects=stopped_containers, + obj_callable=lambda c: c.remove(**options), + msg_index=lambda c: c.name, + msg="Removing" + ) def restart(self, service_names=None, **options): for service in self.get_services(service_names): diff --git a/compose/service.py b/compose/service.py index cda68b7f567..abb7536cf9f 100644 --- a/compose/service.py +++ b/compose/service.py @@ -24,7 +24,7 @@ from .container import Container from .legacy import check_for_legacy_containers from .progress_stream import stream_output, StreamOutputError -from .utils import json_hash, parallel_create_execute, parallel_execute +from .utils import json_hash, parallel_execute log = logging.getLogger(__name__) @@ -162,13 +162,11 @@ def scale(self, desired_num, timeout=DEFAULT_TIMEOUT): 'for this service are created on a single host, the port will clash.' % self.name) - def create_and_start(number): - container = self.create_container(number=number, quiet=True) + def create_and_start(service, number): + container = service.create_container(number=number, quiet=True) container.start() return container - msgs = {'doing': 'Creating', 'done': 'Started'} - running_containers = self.containers(stopped=False) num_running = len(running_containers) @@ -185,20 +183,31 @@ def create_and_start(number): next_number, next_number + num_to_create ) ] - parallel_create_execute(create_and_start, container_numbers, msgs) + + parallel_execute( + objects=container_numbers, + obj_callable=lambda n: create_and_start(service=self, number=n), + msg_index=lambda n: n, + msg="Creating and starting" + ) 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:] - if desired_num < num_running: - # count number of running containers. - num_to_stop = num_running - desired_num - - containers_to_stop = sorted_running_containers[-num_to_stop:] - # TODO: refactor these out? - parallel_execute("stop", containers_to_stop, "Stopping", "Stopped") - parallel_execute("remove", containers_to_stop, "Removing", "Removed") - # self.remove_stopped() + parallel_execute( + objects=containers_to_stop, + obj_callable=lambda c: c.stop(timeout=timeout), + msg_index=lambda c: c.name, + msg="Stopping" + ) + parallel_execute( + objects=containers_to_stop, + obj_callable=lambda c: c.remove(), + msg_index=lambda c: c.name, + msg="Removing" + ) def remove_stopped(self, **options): for c in self.containers(stopped=True): diff --git a/compose/utils.py b/compose/utils.py index af6aa902adf..ff3096fd282 100644 --- a/compose/utils.py +++ b/compose/utils.py @@ -12,114 +12,75 @@ log = logging.getLogger(__name__) -def parallel_create_execute(create_function, container_numbers, msgs={}, **options): +def parallel_execute(objects, obj_callable, msg_index, msg): """ - Parallel container creation by calling the create_function for each new container - number passed in. + For a given list of objects, call the callable passing in the first + object we give it. """ stream = codecs.getwriter('utf-8')(sys.stdout) lines = [] errors = {} - for number in container_numbers: - write_out_msg(stream, lines, number, msgs['doing']) + for obj in objects: + write_out_msg(stream, lines, msg_index(obj), msg) q = Queue() - def inner_call_function(create_function, number): + def inner_execute_function(an_callable, parameter, msg_index): try: - container = create_function(number) + result = an_callable(parameter) except APIError as e: - errors[number] = e.explanation - q.put(container) + errors[msg_index] = e.explanation + result = "error" + q.put((msg_index, result)) - for number in container_numbers: + for an_object in objects: t = Thread( - target=inner_call_function, - args=(create_function, number), - kwargs=options, - ) - t.daemon = True - t.start() - - done = 0 - total_to_create = len(container_numbers) - while done < total_to_create: - try: - container = q.get(timeout=1) - write_out_msg(stream, lines, container.name, msgs['done']) - done += 1 - except Empty: - pass - - if errors: - for number in errors: - stream.write("ERROR: for {} {} \n".format(number, errors[number])) - - -def parallel_execute(command, containers, doing_msg, done_msg, **options): - """ - Execute a given command upon a list of containers in parallel. - """ - stream = codecs.getwriter('utf-8')(sys.stdout) - lines = [] - errors = {} - - for container in containers: - write_out_msg(stream, lines, container.name, doing_msg) - - q = Queue() - - def container_command_execute(container, command, **options): - try: - getattr(container, command)(**options) - except APIError as e: - errors[container.name] = e.explanation - q.put(container) - - for container in containers: - t = Thread( - target=container_command_execute, - args=(container, command), - kwargs=options, + target=inner_execute_function, + args=(obj_callable, an_object, msg_index(an_object)), ) t.daemon = True t.start() done = 0 + total_to_execute = len(objects) - while done < len(containers): + while done < total_to_execute: try: - container = q.get(timeout=1) - write_out_msg(stream, lines, container.name, done_msg) + msg_index, result = q.get(timeout=1) + if result == 'error': + write_out_msg(stream, lines, msg_index, msg, status='error') + else: + write_out_msg(stream, lines, msg_index, msg) done += 1 except Empty: pass if errors: - for container in errors: - stream.write("ERROR: for {} {} \n".format(container, errors[container])) + for error in errors: + stream.write("ERROR: for {} {} \n".format(error, errors[error])) -def write_out_msg(stream, lines, container_name, msg): +def write_out_msg(stream, lines, msg_index, msg, status="done"): """ Using special ANSI code characters we can write out the msg over the top of a previous status message, if it exists. """ - if container_name in lines: - position = lines.index(container_name) + obj_index = msg_index + if msg_index in lines: + position = lines.index(obj_index) diff = len(lines) - position # move up stream.write("%c[%dA" % (27, diff)) # erase stream.write("%c[2K\r" % 27) - stream.write("{}: {} \n".format(container_name, msg)) + stream.write("{} {}... {}\n".format(msg, obj_index, status)) # move back down stream.write("%c[%dB" % (27, diff)) else: diff = 0 - lines.append(container_name) - stream.write("{}: {}... \r\n".format(container_name, msg)) + lines.append(obj_index) + stream.write("{} {}... \r\n".format(msg, obj_index)) stream.flush() From 41406cdd686f28aae4297a0f998444a45c34331e Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Tue, 21 Jul 2015 15:37:55 +0100 Subject: [PATCH 1009/4072] Update roadmap with state convergence Signed-off-by: Ben Firshman --- ROADMAP.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a74a781ee61..67903492eb5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,9 +4,12 @@ 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’s brute-force “delete and recreate everything” approach is great for dev and testing, but it not sufficient for production environments. You should be able to define a "desired" state that Compose will intelligently converge to. -- 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. ([#426](https://github.com/docker/fig/issues/426)) +- 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)) - 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. ## Integration with Swarm From e1c1a4c0aa670f28ef335c31d1d0ace9824a047c Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Tue, 21 Jul 2015 14:59:32 +0100 Subject: [PATCH 1010/4072] Scale restarts stopped containers This is existing behaviour and should be kept. Signed-off-by: Mazz Mosley --- compose/service.py | 44 ++++++++++++++++++++++++++++++++++---------- 1 file changed, 34 insertions(+), 10 deletions(-) diff --git a/compose/service.py b/compose/service.py index abb7536cf9f..8908d4c7883 100644 --- a/compose/service.py +++ b/compose/service.py @@ -176,6 +176,30 @@ def create_and_start(service, number): return if desired_num > num_running: + # we need to start/create until we have desired_num + all_containers = self.containers(stopped=True) + + 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')) + + num_stopped = len(stopped_containers) + + if num_stopped + num_running > desired_num: + num_to_start = desired_num - num_running + containers_to_start = stopped_containers[:num_to_start] + else: + containers_to_start = stopped_containers + + parallel_execute( + objects=containers_to_start, + obj_callable=lambda c: c.start(), + msg_index=lambda c: c.name, + msg="Starting" + ) + + num_running += len(containers_to_start) + num_to_create = desired_num - num_running next_number = self._next_container_number() container_numbers = [ @@ -202,18 +226,18 @@ def create_and_start(service, number): msg_index=lambda c: c.name, msg="Stopping" ) - parallel_execute( - objects=containers_to_stop, - obj_callable=lambda c: c.remove(), - msg_index=lambda c: c.name, - msg="Removing" - ) + + self.remove_stopped() def remove_stopped(self, **options): - for c in self.containers(stopped=True): - if not c.is_running: - log.info("Removing %s..." % c.name) - c.remove(**options) + containers = [c for c in self.containers(stopped=True) if not c.is_running] + + parallel_execute( + objects=containers, + obj_callable=lambda c: c.remove(**options), + msg_index=lambda c: c.name, + msg="Removing" + ) def create_container(self, one_off=False, From 233c509f715415a461d908b636a33dc737acd6a7 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 21 Jul 2015 15:56:37 +0100 Subject: [PATCH 1011/4072] Remove logging test It doesn't do much other than cause the remainder of the test suite to generate lots of junk output. Signed-off-by: Aanand Prasad --- tests/unit/cli_test.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/tests/unit/cli_test.py b/tests/unit/cli_test.py index 7f06f5e3ee7..3f500032927 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -1,13 +1,11 @@ from __future__ import unicode_literals from __future__ import absolute_import -import logging import os from .. import unittest import docker import mock -from compose.cli import main from compose.cli.docopt_command import NoSuchCommand from compose.cli.main import TopLevelCommand from compose.service import Service @@ -88,11 +86,6 @@ def test_command_help_nonexistent(self): with self.assertRaises(NoSuchCommand): TopLevelCommand().dispatch(['help', 'nonexistent'], None) - def test_setup_logging(self): - main.setup_logging() - self.assertEqual(logging.getLogger().level, logging.DEBUG) - self.assertEqual(logging.getLogger('requests').propagate, False) - @mock.patch('compose.cli.main.dockerpty', autospec=True) def test_run_with_environment_merged_with_options_list(self, mock_dockerpty): command = TopLevelCommand() From 1739448402e2122a1ecb017dd2a9400df80a18f3 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 22 Jul 2015 15:39:56 +0100 Subject: [PATCH 1012/4072] Don't use custom name for one-off containers Signed-off-by: Aanand Prasad --- compose/service.py | 6 ++++-- tests/integration/service_test.py | 3 +++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/compose/service.py b/compose/service.py index 0e67e8267af..c1907f37c89 100644 --- a/compose/service.py +++ b/compose/service.py @@ -577,8 +577,10 @@ def _get_container_create_options( for k in DOCKER_CONFIG_KEYS if k in self.options) container_options.update(override_options) - container_options['name'] = self.custom_container_name() \ - or self.get_container_name(number, one_off) + if self.custom_container_name() and not one_off: + container_options['name'] = self.custom_container_name() + else: + container_options['name'] = self.get_container_name(number, one_off) if add_config_hash: config_hash = self.config_hash() diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index dbb97d8f3bf..9f8f16826d6 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -706,6 +706,9 @@ def test_custom_container_name(self): container = create_and_start_container(service) self.assertEqual(container.name, 'my-web-container') + one_off_container = service.create_container(one_off=True) + self.assertNotEqual(one_off_container.name, 'my-web-container') + def test_log_drive_invalid(self): service = self.create_service('web', log_driver='xxx') self.assertRaises(ValueError, lambda: create_and_start_container(service)) From ef44c46c72cf5c12cd145712758764b5dd61620c Mon Sep 17 00:00:00 2001 From: Alex Brandt Date: Wed, 22 Jul 2015 19:35:08 -0500 Subject: [PATCH 1013/4072] add all completions to sdist The zsh completion was recently added but missed from the sdist. This includes all completions that might be added at any point. Signed-off-by: Alex Brandt --- MANIFEST.in | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MANIFEST.in b/MANIFEST.in index 2acd5ab6b14..6c756417e04 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -4,7 +4,7 @@ include requirements.txt include requirements-dev.txt include tox.ini include *.md -include contrib/completion/bash/docker-compose +recursive-include contrib/completion * recursive-include tests * global-exclude *.pyc global-exclude *.pyo From 119901c19b8c6a20e0113c84d9f5a3aac06310d6 Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Wed, 22 Jul 2015 18:06:43 +0100 Subject: [PATCH 1014/4072] Improve test coverage for scale Also includes tiny amount of code cleanup, being explicit with imports. Signed-off-by: Mazz Mosley --- tests/integration/service_test.py | 124 ++++++++++++++++++++++++++++-- 1 file changed, 119 insertions(+), 5 deletions(-) diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index dbb97d8f3bf..97c06a9dc2e 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -4,10 +4,10 @@ from os import path from docker.errors import APIError -import mock +from mock import patch import tempfile import shutil -import six +from six import StringIO, text_type from compose import __version__ from compose.const import ( @@ -221,7 +221,7 @@ def test_create_container_with_specified_volume(self): self.assertTrue(path.basename(actual_host_path) == path.basename(host_path), msg=("Last component differs: %s, %s" % (actual_host_path, host_path))) - @mock.patch.dict(os.environ) + @patch.dict(os.environ) def test_create_container_with_home_and_env_var_in_volume_path(self): os.environ['VOLUME_NAME'] = 'my-volume' os.environ['HOME'] = '/tmp/home-dir' @@ -469,7 +469,7 @@ def test_build_non_ascii_filename(self): with open(os.path.join(base_dir, b'foo\xE2bar'), 'w') as f: f.write("hello world\n") - self.create_service('web', build=six.text_type(base_dir)).build() + self.create_service('web', build=text_type(base_dir)).build() self.assertEqual(len(self.client.images(name='composetest_web')), 1) def test_start_container_stays_unpriviliged(self): @@ -549,6 +549,120 @@ def test_scale(self): service.scale(0) self.assertEqual(len(service.containers()), 0) + @patch('sys.stdout', new_callable=StringIO) + def test_scale_with_stopped_containers(self, mock_stdout): + """ + Given there are some stopped containers and scale is called with a + desired number that is the same as the number of stopped containers, + test that those containers are restarted and not removed/recreated. + """ + service = self.create_service('web') + next_number = service._next_container_number() + valid_numbers = [next_number, next_number + 1] + service.create_container(number=next_number, quiet=True) + service.create_container(number=next_number + 1, quiet=True) + + for container in service.containers(): + self.assertFalse(container.is_running) + + service.scale(2) + + self.assertEqual(len(service.containers()), 2) + for container in service.containers(): + self.assertTrue(container.is_running) + self.assertTrue(container.number in valid_numbers) + + captured_output = mock_stdout.getvalue() + self.assertNotIn('Creating', captured_output) + self.assertIn('Starting', captured_output) + + @patch('sys.stdout', new_callable=StringIO) + def test_scale_with_stopped_containers_and_needing_creation(self, mock_stdout): + """ + Given there are some stopped containers and scale is called with a + desired number that is greater than the number of stopped containers, + test that those containers are restarted and required number are created. + """ + service = self.create_service('web') + next_number = service._next_container_number() + service.create_container(number=next_number, quiet=True) + + for container in service.containers(): + self.assertFalse(container.is_running) + + service.scale(2) + + self.assertEqual(len(service.containers()), 2) + for container in service.containers(): + self.assertTrue(container.is_running) + + captured_output = mock_stdout.getvalue() + self.assertIn('Creating', captured_output) + self.assertIn('Starting', captured_output) + + @patch('sys.stdout', new_callable=StringIO) + def test_scale_with_api_returns_errors(self, mock_stdout): + """ + Test that when scaling if the API returns an error, that error is handled + and the remaining threads continue. + """ + service = self.create_service('web') + next_number = service._next_container_number() + service.create_container(number=next_number, quiet=True) + + with patch( + 'compose.container.Container.create', + side_effect=APIError(message="testing", response={}, explanation="Boom")): + + service.scale(3) + + self.assertEqual(len(service.containers()), 1) + self.assertTrue(service.containers()[0].is_running) + self.assertIn("ERROR: for 2 Boom", mock_stdout.getvalue()) + + @patch('compose.service.log') + def test_scale_with_desired_number_already_achieved(self, mock_log): + """ + Test that calling scale with a desired number that is equal to the + number of containers already running results in no change. + """ + service = self.create_service('web') + next_number = service._next_container_number() + container = service.create_container(number=next_number, quiet=True) + container.start() + + self.assertTrue(container.is_running) + self.assertEqual(len(service.containers()), 1) + + service.scale(1) + + self.assertEqual(len(service.containers()), 1) + container.inspect() + self.assertTrue(container.is_running) + + captured_output = mock_log.info.call_args[0] + self.assertIn('Desired container number already achieved', captured_output) + + @patch('compose.service.log') + def test_scale_with_custom_container_name_outputs_warning(self, mock_log): + """ + Test that calling scale on a service that has a custom container name + results in warning output. + """ + service = self.create_service('web', container_name='custom-container') + + self.assertEqual(service.custom_container_name(), 'custom-container') + + service.scale(3) + + captured_output = mock_log.warn.call_args[0][0] + + self.assertEqual(len(service.containers()), 1) + self.assertIn( + "Remove the custom name to scale the service.", + captured_output + ) + def test_scale_sets_ports(self): service = self.create_service('web', ports=['8000']) service.scale(2) @@ -650,7 +764,7 @@ def test_env_from_file_combined_with_env(self): for k, v in {'ONE': '1', 'TWO': '2', 'THREE': '3', 'FOO': 'baz', 'DOO': 'dah'}.items(): self.assertEqual(env[k], v) - @mock.patch.dict(os.environ) + @patch.dict(os.environ) def test_resolve_env(self): os.environ['FILE_DEF'] = 'E1' os.environ['FILE_DEF_EMPTY'] = 'E2' From 2c8aade13e886e450e7226340c115a4641c07586 Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Wed, 22 Jul 2015 18:07:56 +0100 Subject: [PATCH 1015/4072] Space for errors It was harder to see when there are errors if they came straight after the other output. Putting a newline in there gives it a bit of visual room. Signed-off-by: Mazz Mosley --- compose/utils.py | 1 + 1 file changed, 1 insertion(+) diff --git a/compose/utils.py b/compose/utils.py index ff3096fd282..4c7f94c5769 100644 --- a/compose/utils.py +++ b/compose/utils.py @@ -57,6 +57,7 @@ def inner_execute_function(an_callable, parameter, msg_index): pass if errors: + stream.write("\n") for error in errors: stream.write("ERROR: for {} {} \n".format(error, errors[error])) From f4dac02947ec87e71ef648635bcf0dce541a9b2e Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 23 Jul 2015 10:56:15 +0100 Subject: [PATCH 1016/4072] Update docker-py to 1.3.1 Signed-off-by: Aanand Prasad --- requirements.txt | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index fc5b68489c0..f9cec8372c7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ PyYAML==3.10 -docker-py==1.3.0 +docker-py==1.3.1 dockerpty==0.3.4 docopt==0.6.1 requests==2.6.1 diff --git a/setup.py b/setup.py index 0979b2f2c2e..9bca4752de4 100644 --- a/setup.py +++ b/setup.py @@ -30,7 +30,7 @@ def find_version(*file_paths): 'requests >= 2.6.1, < 2.7', 'texttable >= 0.8.1, < 0.9', 'websocket-client >= 0.32.0, < 1.0', - 'docker-py >= 1.3.0, < 1.4', + 'docker-py >= 1.3.1, < 1.4', 'dockerpty >= 0.3.4, < 0.4', 'six >= 1.3.0, < 2', ] From 04a773f1c88059192521c62df9870654ea153510 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 20 Jul 2015 17:13:09 +0100 Subject: [PATCH 1017/4072] Deprecate --allow-insecure-ssl Signed-off-by: Aanand Prasad --- compose/cli/main.py | 29 ++++++++++++++------------ compose/project.py | 6 ++---- compose/service.py | 16 ++++---------- contrib/completion/bash/docker-compose | 6 +++--- contrib/completion/zsh/_docker-compose | 3 --- docs/reference/pull.md | 3 --- docs/reference/run.md | 2 -- docs/reference/up.md | 2 -- tests/integration/state_test.py | 2 -- tests/unit/service_test.py | 21 +------------------ 10 files changed, 26 insertions(+), 64 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index df40ee930d0..56f6c05052d 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -26,6 +26,11 @@ log = logging.getLogger(__name__) +INSECURE_SSL_WARNING = """ +Warning: --allow-insecure-ssl is deprecated and has no effect. +It will be removed in a future version of Compose. +""" + def main(): setup_logging() @@ -232,13 +237,13 @@ def pull(self, project, options): Usage: pull [options] [SERVICE...] Options: - --allow-insecure-ssl Allow insecure connections to the docker - registry + --allow-insecure-ssl Deprecated - no effect. """ - insecure_registry = options['--allow-insecure-ssl'] + if options['--allow-insecure-ssl']: + log.warn(INSECURE_SSL_WARNING) + project.pull( service_names=options['SERVICE'], - insecure_registry=insecure_registry ) def rm(self, project, options): @@ -280,8 +285,7 @@ def run(self, project, options): Usage: run [options] [-e KEY=VAL...] SERVICE [COMMAND] [ARGS...] Options: - --allow-insecure-ssl Allow insecure connections to the docker - registry + --allow-insecure-ssl Deprecated - no effect. -d Detached mode: Run container in the background, print new container name. --entrypoint CMD Override the entrypoint of the image. @@ -296,7 +300,8 @@ def run(self, project, options): """ service = project.get_service(options['SERVICE']) - insecure_registry = options['--allow-insecure-ssl'] + if options['--allow-insecure-ssl']: + log.warn(INSECURE_SSL_WARNING) if not options['--no-deps']: deps = service.get_linked_names() @@ -306,7 +311,6 @@ def run(self, project, options): service_names=deps, start_deps=True, allow_recreate=False, - insecure_registry=insecure_registry, ) tty = True @@ -344,7 +348,6 @@ def run(self, project, options): container = service.create_container( quiet=True, one_off=True, - insecure_registry=insecure_registry, **container_options ) except APIError as e: @@ -453,8 +456,7 @@ def up(self, project, options): Usage: up [options] [SERVICE...] Options: - --allow-insecure-ssl Allow insecure connections to the docker - registry + --allow-insecure-ssl Deprecated - no effect. -d Detached mode: Run containers in the background, print new container names. --no-color Produce monochrome output. @@ -468,7 +470,9 @@ def up(self, project, options): when attached or when containers are already running. (default: 10) """ - insecure_registry = options['--allow-insecure-ssl'] + if options['--allow-insecure-ssl']: + log.warn(INSECURE_SSL_WARNING) + detached = options['-d'] monochrome = options['--no-color'] @@ -487,7 +491,6 @@ def up(self, project, options): start_deps=start_deps, allow_recreate=allow_recreate, force_recreate=force_recreate, - insecure_registry=insecure_registry, do_build=not options['--no-build'], timeout=timeout ) diff --git a/compose/project.py b/compose/project.py index c5028492c40..2667855d9c6 100644 --- a/compose/project.py +++ b/compose/project.py @@ -239,7 +239,6 @@ def up(self, start_deps=True, allow_recreate=True, force_recreate=False, - insecure_registry=False, do_build=True, timeout=DEFAULT_TIMEOUT): @@ -262,7 +261,6 @@ def up(self, for service in services for container in service.execute_convergence_plan( plans[service.name], - insecure_registry=insecure_registry, do_build=do_build, timeout=timeout ) @@ -302,9 +300,9 @@ def _get_convergence_plans(self, return plans - def pull(self, service_names=None, insecure_registry=False): + def pull(self, service_names=None): for service in self.get_services(service_names, include_deps=True): - service.pull(insecure_registry=insecure_registry) + service.pull() def containers(self, service_names=None, stopped=False, one_off=False): if service_names: diff --git a/compose/service.py b/compose/service.py index c1907f37c89..b9b4ed3e0e2 100644 --- a/compose/service.py +++ b/compose/service.py @@ -247,7 +247,6 @@ def remove_stopped(self, **options): def create_container(self, one_off=False, - insecure_registry=False, do_build=True, previous_container=None, number=None, @@ -259,7 +258,6 @@ def create_container(self, """ self.ensure_image_exists( do_build=do_build, - insecure_registry=insecure_registry, ) container_options = self._get_container_create_options( @@ -275,8 +273,7 @@ def create_container(self, return Container.create(self.client, **container_options) def ensure_image_exists(self, - do_build=True, - insecure_registry=False): + do_build=True): try: self.image() @@ -290,7 +287,7 @@ def ensure_image_exists(self, else: raise NeedsBuildError(self) else: - self.pull(insecure_registry=insecure_registry) + self.pull() def image(self): try: @@ -360,14 +357,12 @@ def _containers_have_diverged(self, containers): def execute_convergence_plan(self, plan, - insecure_registry=False, do_build=True, timeout=DEFAULT_TIMEOUT): (action, containers) = plan if action == 'create': container = self.create_container( - insecure_registry=insecure_registry, do_build=do_build, ) self.start_container(container) @@ -378,7 +373,6 @@ def execute_convergence_plan(self, return [ self.recreate_container( c, - insecure_registry=insecure_registry, timeout=timeout ) for c in containers @@ -401,7 +395,6 @@ def execute_convergence_plan(self, def recreate_container(self, container, - insecure_registry=False, timeout=DEFAULT_TIMEOUT): """Recreate a container. @@ -426,7 +419,6 @@ def recreate_container(self, '%s_%s' % (container.short_id, container.name)) new_container = self.create_container( - insecure_registry=insecure_registry, do_build=False, previous_container=container, number=container.labels.get(LABEL_CONTAINER_NUMBER), @@ -761,7 +753,7 @@ def specifies_host_port(self): return True return False - def pull(self, insecure_registry=False): + def pull(self): if 'image' not in self.options: return @@ -772,7 +764,7 @@ def pull(self, insecure_registry=False): repo, tag=tag, stream=True, - insecure_registry=insecure_registry) + ) stream_output(output, sys.stdout) diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index 133b9fc388b..e7d8cb3f8e6 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -195,7 +195,7 @@ _docker-compose_ps() { _docker-compose_pull() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "--allow-insecure-ssl --help" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--help" -- "$cur" ) ) ;; *) __docker-compose_services_from_image @@ -248,7 +248,7 @@ _docker-compose_run() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "--allow-insecure-ssl -d --entrypoint -e --help --no-deps --rm --service-ports -T --user -u" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "-d --entrypoint -e --help --no-deps --rm --service-ports -T --user -u" -- "$cur" ) ) ;; *) __docker-compose_services_all @@ -315,7 +315,7 @@ _docker-compose_up() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "--allow-insecure-ssl -d --help --no-build --no-color --no-deps --no-recreate --force-recreate --timeout -t" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "-d --help --no-build --no-color --no-deps --no-recreate --force-recreate --timeout -t" -- "$cur" ) ) ;; *) __docker-compose_services_all diff --git a/contrib/completion/zsh/_docker-compose b/contrib/completion/zsh/_docker-compose index 2893c3fc387..9af21a98b32 100644 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -202,7 +202,6 @@ __docker-compose_subcommand () { ;; (pull) _arguments \ - '--allow-insecure-ssl[Allow insecure connections to the docker registry]' \ '--help[Print usage]' \ '*:services:__docker-compose_services_from_image' && ret=0 ;; @@ -215,7 +214,6 @@ __docker-compose_subcommand () { ;; (run) _arguments \ - '--allow-insecure-ssl[Allow insecure connections to the docker registry]' \ '-d[Detached mode: Run container in the background, print new 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: ' \ @@ -247,7 +245,6 @@ __docker-compose_subcommand () { ;; (up) _arguments \ - '--allow-insecure-ssl[Allow insecure connections to the docker registry]' \ '-d[Detached mode: Run containers in the background, print new container names.]' \ '--help[Print usage]' \ '--no-color[Produce monochrome output.]' \ diff --git a/docs/reference/pull.md b/docs/reference/pull.md index 571d3872b90..ac22010ec62 100644 --- a/docs/reference/pull.md +++ b/docs/reference/pull.md @@ -12,9 +12,6 @@ parent = "smn_compose_cli" ``` Usage: pull [options] [SERVICE...] - -Options: ---allow-insecure-ssl Allow insecure connections to the docker registry ``` Pulls service images. \ No newline at end of file diff --git a/docs/reference/run.md b/docs/reference/run.md index 78ec20fc202..b07ddd060da 100644 --- a/docs/reference/run.md +++ b/docs/reference/run.md @@ -14,8 +14,6 @@ parent = "smn_compose_cli" Usage: run [options] [-e KEY=VAL...] SERVICE [COMMAND] [ARGS...] Options: ---allow-insecure-ssl Allow insecure connections to the docker - registry -d Detached mode: Run container in the background, print new container name. --entrypoint CMD Override the entrypoint of the image. diff --git a/docs/reference/up.md b/docs/reference/up.md index 8fe4fad5c8b..441d7f9c30b 100644 --- a/docs/reference/up.md +++ b/docs/reference/up.md @@ -14,8 +14,6 @@ parent = "smn_compose_cli" Usage: up [options] [SERVICE...] Options: ---allow-insecure-ssl Allow insecure connections to the docker - registry -d Detached mode: Run containers in the background, print new container names. --no-color Produce monochrome output. diff --git a/tests/integration/state_test.py b/tests/integration/state_test.py index 63027586e94..b124b19ffc6 100644 --- a/tests/integration/state_test.py +++ b/tests/integration/state_test.py @@ -155,7 +155,6 @@ def test_change_root_no_recreate(self): def converge(service, allow_recreate=True, force_recreate=False, - insecure_registry=False, do_build=True): """ If a container for this service doesn't exist, create and start one. If there are @@ -168,7 +167,6 @@ def converge(service, return service.execute_convergence_plan( plan, - insecure_registry=insecure_registry, do_build=do_build, timeout=1, ) diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 104a90d535a..bc6b9e485e4 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -229,11 +229,10 @@ def test_get_container(self, mock_container_class): @mock.patch('compose.service.log', autospec=True) def test_pull_image(self, mock_log): service = Service('foo', client=self.mock_client, image='someimage:sometag') - service.pull(insecure_registry=True) + service.pull() self.mock_client.pull.assert_called_once_with( 'someimage', tag='sometag', - insecure_registry=True, stream=True) mock_log.info.assert_called_once_with('Pulling foo (someimage:sometag)...') @@ -243,26 +242,8 @@ def test_pull_image_no_tag(self): self.mock_client.pull.assert_called_once_with( 'ababab', tag='latest', - insecure_registry=False, stream=True) - def test_create_container_from_insecure_registry(self): - service = Service('foo', client=self.mock_client, image='someimage:sometag') - images = [] - - def pull(repo, tag=None, insecure_registry=False, **kwargs): - self.assertEqual('someimage', repo) - self.assertEqual('sometag', tag) - self.assertTrue(insecure_registry) - images.append({'Id': 'abc123'}) - return [] - - service.image = lambda *args, **kwargs: mock_get_image(images) - self.mock_client.pull = pull - - service.create_container(insecure_registry=True) - self.assertEqual(1, len(images)) - @mock.patch('compose.service.Container', autospec=True) def test_recreate_container(self, _): mock_container = mock.create_autospec(Container) From 227435f6135ffc9609e70a9fb3a7cfbc3502f43c Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 20 Jul 2015 16:23:25 +0100 Subject: [PATCH 1018/4072] Update CHANGES.md and install.md for latest stable version Signed-off-by: Aanand Prasad --- CHANGES.md | 32 ++++++++++++++++++++++++++++++++ docs/install.md | 2 +- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index 78e629b8918..38a54324996 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,6 +1,38 @@ Change log ========== +1.3.3 (2015-07-15) +------------------ + +Two regressions have been fixed: + +- When stopping containers gracefully, Compose was setting the timeout to 0, effectively forcing a SIGKILL every time. +- Compose would sometimes crash depending on the formatting of container data returned from the Docker API. + +1.3.2 (2015-07-14) +------------------ + +The following bugs have been fixed: + +- When there were one-off containers created by running `docker-compose run` on an older version of Compose, `docker-compose run` would fail with a name collision. Compose now shows an error if you have leftover containers of this type lying around, and tells you how to remove them. +- Compose was not reading Docker authentication config files created in the new location, `~/docker/config.json`, and authentication against private registries would therefore fail. +- When a container had a pseudo-TTY attached, its output in `docker-compose up` would be truncated. +- `docker-compose up --x-smart-recreate` would sometimes fail when an image tag was updated. +- `docker-compose up` would sometimes create two containers with the same numeric suffix. +- `docker-compose rm` and `docker-compose ps` would sometimes list services that aren't part of the current project (though no containers were erroneously removed). +- Some `docker-compose` commands would not show an error if invalid service names were passed in. + +Thanks @dano, @josephpage, @kevinsimper, @lieryan, @phemmer, @soulrebel and @sschepens! + +1.3.1 (2015-06-21) +------------------ + +The following bugs have been fixed: + +- `docker-compose build` would always attempt to pull the base image before building. +- `docker-compose help migrate-to-labels` failed with an error. +- If no network mode was specified, Compose would set it to "bridge", rather than allowing the Docker daemon to use its configured default network mode. + 1.3.0 (2015-06-18) ------------------ diff --git a/docs/install.md b/docs/install.md index 80a377dbb52..dad6efd56d7 100644 --- a/docs/install.md +++ b/docs/install.md @@ -27,7 +27,7 @@ First, install Docker version 1.6 or greater: To install Compose, run the following commands: - curl -L https://github.com/docker/compose/releases/download/1.2.0/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose + curl -L https://github.com/docker/compose/releases/download/1.3.3/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose chmod +x /usr/local/bin/docker-compose > 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 above, then `exit`. From 7eabc06df5ca4a1c2ad372ee8e87012de5429f05 Mon Sep 17 00:00:00 2001 From: Mary Anthony Date: Sun, 19 Jul 2015 14:39:25 -0700 Subject: [PATCH 1019/4072] Updating build so that contributors can build public docs Changed base image Signed-off-by: Mary Anthony --- docs/Dockerfile | 16 +++++----- docs/pre-process.sh | 61 +++++++++++++++++++++++++++++++++++++++ docs/reference/build.md | 1 + docs/reference/help.md | 1 + docs/reference/kill.md | 1 + docs/reference/logs.md | 1 + docs/reference/port.md | 1 + docs/reference/ps.md | 1 + docs/reference/pull.md | 1 + docs/reference/restart.md | 1 + docs/reference/rm.md | 1 + docs/reference/run.md | 1 + docs/reference/start.md | 1 + docs/reference/stop.md | 1 + docs/reference/up.md | 1 + 15 files changed, 83 insertions(+), 7 deletions(-) create mode 100755 docs/pre-process.sh diff --git a/docs/Dockerfile b/docs/Dockerfile index d6864c2d667..d9add75c150 100644 --- a/docs/Dockerfile +++ b/docs/Dockerfile @@ -6,6 +6,14 @@ COPY . /src COPY . /docs/content/compose/ +RUN svn checkout https://github.com/docker/docker/trunk/docs /docs/content/docker +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/tutorials/trunk/docs /docs/content/tutorials +RUN svn checkout https://github.com/docker/opensource/trunk/docs /docs/content + + # Sed to process GitHub Markdown # 1-2 Remove comment code from metadata block # 3 Change ](/word to ](/project/ in links @@ -15,10 +23,4 @@ COPY . /docs/content/compose/ # 7 Change ](../../ to ](/project/ --> not implemented # # -RUN find /docs/content/compose -type f -name "*.md" -exec sed -i.old \ - -e '/^/g' \ - -e '/^/g' \ - -e 's/\(\]\)\([(]\)\(\/\)/\1\2\/compose\//g' \ - -e 's/\(\][(]\)\([A-z].*\)\(\.md\)/\1\/compose\/\2/g' \ - -e 's/\([(]\)\(.*\)\(\.md\)/\1\2/g' \ - -e 's/\(\][(]\)\(\.\.\/\)/\1\/compose\//g' {} \; +RUN /src/pre-process.sh /docs diff --git a/docs/pre-process.sh b/docs/pre-process.sh new file mode 100755 index 00000000000..75e9611f2f0 --- /dev/null +++ b/docs/pre-process.sh @@ -0,0 +1,61 @@ +#!/bin/bash -e + +# Populate an array with just docker dirs and one with content dirs +docker_dir=(`ls -d /docs/content/docker/*`) +content_dir=(`ls -d /docs/content/*`) + +# Loop content not of docker/ +# +# Sed to process GitHub Markdown +# 1-2 Remove comment code from metadata block +# 3 Remove .md extension from link text +# 4 Change ](/ to ](/project/ in links +# 5 Change ](word) to ](/project/word) +# 6 Change ](../../ to ](/project/ +# 7 Change ](../ to ](/project/word) +# +for i in "${content_dir[@]}" +do + : + case $i in + "/docs/content/windows") + ;; + "/docs/content/mac") + ;; + "/docs/content/linux") + ;; + "/docs/content/docker") + y=${i##*/} + find $i -type f -name "*.md" -exec sed -i.old \ + -e '/^/g' \ + -e '/^/g' {} \; + ;; + *) + y=${i##*/} + find $i -type f -name "*.md" -exec sed -i.old \ + -e '/^/g' \ + -e '/^/g' \ + -e 's/\(\]\)\([(]\)\(\/\)/\1\2\/'$y'\//g' \ + -e 's/\(\][(]\)\([A-z].*\)\(\.md\)/\1\/'$y'\/\2/g' \ + -e 's/\([(]\)\(.*\)\(\.md\)/\1\2/g' \ + -e 's/\(\][(]\)\(\.\/\)/\1\/'$y'\//g' \ + -e 's/\(\][(]\)\(\.\.\/\.\.\/\)/\1\/'$y'\//g' \ + -e 's/\(\][(]\)\(\.\.\/\)/\1\/'$y'\//g' {} \; + ;; + esac +done + +# +# Move docker directories to content +# +for i in "${docker_dir[@]}" +do + : + if [ -d $i ] + then + mv $i /docs/content/ + fi +done + +rm -rf /docs/content/docker + diff --git a/docs/reference/build.md b/docs/reference/build.md index b2b015119d4..b6e27bb264b 100644 --- a/docs/reference/build.md +++ b/docs/reference/build.md @@ -4,6 +4,7 @@ title = "build" description = "build" keywords = ["fig, composition, compose, docker, orchestration, cli, build"] [menu.main] +identifier="build.compose" parent = "smn_compose_cli" +++ diff --git a/docs/reference/help.md b/docs/reference/help.md index 229ac5de949..613708ed2f0 100644 --- a/docs/reference/help.md +++ b/docs/reference/help.md @@ -4,6 +4,7 @@ title = "help" description = "help" keywords = ["fig, composition, compose, docker, orchestration, cli, help"] [menu.main] +identifier="help.compose" parent = "smn_compose_cli" +++ diff --git a/docs/reference/kill.md b/docs/reference/kill.md index c71608748cd..e5dd057361d 100644 --- a/docs/reference/kill.md +++ b/docs/reference/kill.md @@ -4,6 +4,7 @@ title = "kill" description = "Forces running containers to stop." keywords = ["fig, composition, compose, docker, orchestration, cli, kill"] [menu.main] +identifier="kill.compose" parent = "smn_compose_cli" +++ diff --git a/docs/reference/logs.md b/docs/reference/logs.md index 87f937273f2..5b241ea70b7 100644 --- a/docs/reference/logs.md +++ b/docs/reference/logs.md @@ -4,6 +4,7 @@ title = "logs" description = "Displays log output from services." keywords = ["fig, composition, compose, docker, orchestration, cli, logs"] [menu.main] +identifier="logs.compose" parent = "smn_compose_cli" +++ diff --git a/docs/reference/port.md b/docs/reference/port.md index 4745c92d320..76f93f23935 100644 --- a/docs/reference/port.md +++ b/docs/reference/port.md @@ -4,6 +4,7 @@ title = "port" description = "Prints the public port for a port binding.s" keywords = ["fig, composition, compose, docker, orchestration, cli, port"] [menu.main] +identifier="port.compose" parent = "smn_compose_cli" +++ diff --git a/docs/reference/ps.md b/docs/reference/ps.md index b271376f802..546d68e76ce 100644 --- a/docs/reference/ps.md +++ b/docs/reference/ps.md @@ -4,6 +4,7 @@ title = "ps" description = "Lists containers." keywords = ["fig, composition, compose, docker, orchestration, cli, ps"] [menu.main] +identifier="ps.compose" parent = "smn_compose_cli" +++ diff --git a/docs/reference/pull.md b/docs/reference/pull.md index ac22010ec62..e5b5d166ff3 100644 --- a/docs/reference/pull.md +++ b/docs/reference/pull.md @@ -4,6 +4,7 @@ title = "pull" description = "Pulls service images." keywords = ["fig, composition, compose, docker, orchestration, cli, pull"] [menu.main] +identifier="pull.compose" parent = "smn_compose_cli" +++ diff --git a/docs/reference/restart.md b/docs/reference/restart.md index 9b570082bfd..bbd4a68b0fb 100644 --- a/docs/reference/restart.md +++ b/docs/reference/restart.md @@ -4,6 +4,7 @@ title = "restart" description = "Restarts Docker Compose services." keywords = ["fig, composition, compose, docker, orchestration, cli, restart"] [menu.main] +identifier="restart.compose" parent = "smn_compose_cli" +++ diff --git a/docs/reference/rm.md b/docs/reference/rm.md index 0a4ba5b6bc3..2ed959e411c 100644 --- a/docs/reference/rm.md +++ b/docs/reference/rm.md @@ -4,6 +4,7 @@ title = "rm" description = "Removes stopped service containers." keywords = ["fig, composition, compose, docker, orchestration, cli, rm"] [menu.main] +identifier="rm.compose" parent = "smn_compose_cli" +++ diff --git a/docs/reference/run.md b/docs/reference/run.md index b07ddd060da..5ea9a61bec1 100644 --- a/docs/reference/run.md +++ b/docs/reference/run.md @@ -4,6 +4,7 @@ title = "run" description = "Runs a one-off command on a service." keywords = ["fig, composition, compose, docker, orchestration, cli, run"] [menu.main] +identifier="run.compose" parent = "smn_compose_cli" +++ diff --git a/docs/reference/start.md b/docs/reference/start.md index 69d853f9cb6..f0bdd5a97c5 100644 --- a/docs/reference/start.md +++ b/docs/reference/start.md @@ -4,6 +4,7 @@ title = "start" description = "Starts existing containers for a service." keywords = ["fig, composition, compose, docker, orchestration, cli, start"] [menu.main] +identifier="start.compose" parent = "smn_compose_cli" +++ diff --git a/docs/reference/stop.md b/docs/reference/stop.md index 8ff92129d2e..ec7e6688a51 100644 --- a/docs/reference/stop.md +++ b/docs/reference/stop.md @@ -4,6 +4,7 @@ title = "stop" description = "Stops running containers without removing them. " keywords = ["fig, composition, compose, docker, orchestration, cli, stop"] [menu.main] +identifier="stop.compose" parent = "smn_compose_cli" +++ diff --git a/docs/reference/up.md b/docs/reference/up.md index 441d7f9c30b..966aff1e959 100644 --- a/docs/reference/up.md +++ b/docs/reference/up.md @@ -4,6 +4,7 @@ title = "up" description = "Builds, (re)creates, starts, and attaches to containers for a service." keywords = ["fig, composition, compose, docker, orchestration, cli, up"] [menu.main] +identifier="up.compose" parent = "smn_compose_cli" +++ From 430ba8cda34107237d6cdca6fdc8ecbda9e0fbc6 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 28 Jul 2015 15:06:22 +0100 Subject: [PATCH 1020/4072] Remove custom docs script Signed-off-by: Aanand Prasad --- script/docs | 11 ----------- 1 file changed, 11 deletions(-) delete mode 100755 script/docs diff --git a/script/docs b/script/docs deleted file mode 100755 index 31c58861d08..00000000000 --- a/script/docs +++ /dev/null @@ -1,11 +0,0 @@ -#!/bin/sh -set -ex - -# import the existing docs build cmds from docker/docker -DOCSPORT=8000 -GIT_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null) -DOCKER_DOCS_IMAGE="compose-docs$GIT_BRANCH" -DOCKER_RUN_DOCS="docker run --rm -it -e NOCACHE" - -docker build -t "$DOCKER_DOCS_IMAGE" -f docs/Dockerfile . -$DOCKER_RUN_DOCS -p $DOCSPORT:8000 "$DOCKER_DOCS_IMAGE" mkdocs serve From b08e23d3519125f512cd9a6aff0f01e363d42ea1 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 28 Jul 2015 15:31:18 +0100 Subject: [PATCH 1021/4072] Add hint about OS X binary compatibility Signed-off-by: Aanand Prasad --- docs/install.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/install.md b/docs/install.md index dad6efd56d7..38302485a1f 100644 --- a/docs/install.md +++ b/docs/install.md @@ -35,6 +35,8 @@ To install Compose, run the following commands: Optionally, you can also install [command completion](completion.md) for the bash and zsh shell. +> **Note:** Some older Mac OS X CPU architectures are incompatible with the binary. If you receive an "Illegal instruction: 4" error after installing, you should install using the `pip` command instead. + Compose is available for OS X and 64-bit Linux. If you're on another platform, Compose can also be installed as a Python package: From fc203d643aa9a69c835aebee0de9b17851ef7a58 Mon Sep 17 00:00:00 2001 From: Reilly Herrewig-Pope Date: Tue, 28 Jul 2015 14:12:20 -0400 Subject: [PATCH 1022/4072] Allow API version specification via env var Hard-coding the API version to '1.18' with the docker-py constructor will cause the docker-py logic at https://github.com/docker/docker-py/blob/master/docker/client.py#L143-L146 to always fail, which will cause authentication issues if you're using a remote daemon using API version 1.19 - regardless of the API version of the registry. Allow the user to set the API version via an environment variable. If the variable is not present, it will still default to '1.18' like it does today. Signed-off-by: Reilly Herrewig-Pope --- compose/cli/docker_client.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/compose/cli/docker_client.py b/compose/cli/docker_client.py index e513182fb3c..adee9365b1d 100644 --- a/compose/cli/docker_client.py +++ b/compose/cli/docker_client.py @@ -14,6 +14,8 @@ def docker_client(): cert_path = os.path.join(os.environ.get('HOME', ''), '.docker') base_url = os.environ.get('DOCKER_HOST') + api_version = os.environ.get('COMPOSE_API_VERSION', '1.18') + tls_config = None if os.environ.get('DOCKER_TLS_VERIFY', '') != '': @@ -32,4 +34,4 @@ def docker_client(): ) timeout = int(os.environ.get('DOCKER_CLIENT_TIMEOUT', 60)) - return Client(base_url=base_url, tls=tls_config, version='1.18', timeout=timeout) + return Client(base_url=base_url, tls=tls_config, version=api_version, timeout=timeout) From 118a389646a68b914a2de0efb763d6d71868d951 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 28 Jul 2015 14:51:27 +0100 Subject: [PATCH 1023/4072] Update API version to 1.19 Signed-off-by: Aanand Prasad --- Dockerfile | 6 ++---- compose/cli/docker_client.py | 2 +- docs/install.md | 2 +- tests/integration/service_test.py | 4 ++-- 4 files changed, 6 insertions(+), 8 deletions(-) diff --git a/Dockerfile b/Dockerfile index 738e0b9978c..a0e7f14f91c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -48,16 +48,14 @@ RUN set -ex; \ rm -rf pip-7.0.1; \ rm pip-7.0.1.tar.gz -ENV ALL_DOCKER_VERSIONS 1.6.2 1.7.1 +ENV ALL_DOCKER_VERSIONS 1.7.1 RUN set -ex; \ - curl https://get.docker.com/builds/Linux/x86_64/docker-1.6.2 -o /usr/local/bin/docker-1.6.2; \ - chmod +x /usr/local/bin/docker-1.6.2; \ curl https://get.docker.com/builds/Linux/x86_64/docker-1.7.1 -o /usr/local/bin/docker-1.7.1; \ chmod +x /usr/local/bin/docker-1.7.1 # Set the default Docker to be run -RUN ln -s /usr/local/bin/docker-1.6.2 /usr/local/bin/docker +RUN ln -s /usr/local/bin/docker-1.7.1 /usr/local/bin/docker RUN useradd -d /home/user -m -s /bin/bash user WORKDIR /code/ diff --git a/compose/cli/docker_client.py b/compose/cli/docker_client.py index adee9365b1d..244bcbef2f6 100644 --- a/compose/cli/docker_client.py +++ b/compose/cli/docker_client.py @@ -14,7 +14,7 @@ def docker_client(): cert_path = os.path.join(os.environ.get('HOME', ''), '.docker') base_url = os.environ.get('DOCKER_HOST') - api_version = os.environ.get('COMPOSE_API_VERSION', '1.18') + api_version = os.environ.get('COMPOSE_API_VERSION', '1.19') tls_config = None diff --git a/docs/install.md b/docs/install.md index 38302485a1f..adb32fd50b0 100644 --- a/docs/install.md +++ b/docs/install.md @@ -17,7 +17,7 @@ Compose with a `curl` command. ## Install Docker -First, install Docker version 1.6 or greater: +First, install Docker version 1.7.1 or greater: - [Instructions for Mac OS X](http://docs.docker.com/installation/mac/) - [Instructions for Ubuntu](http://docs.docker.com/installation/ubuntulinux/) diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 60e2eed1f44..a901fc59add 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -121,7 +121,7 @@ def test_create_container_with_cpu_shares(self): service = self.create_service('db', cpu_shares=73) container = service.create_container() service.start_container(container) - self.assertEqual(container.inspect()['Config']['CpuShares'], 73) + self.assertEqual(container.get('HostConfig.CpuShares'), 73) def test_build_extra_hosts(self): # string @@ -183,7 +183,7 @@ def test_create_container_with_cpu_set(self): service = self.create_service('db', cpuset='0') container = service.create_container() service.start_container(container) - self.assertEqual(container.inspect()['Config']['Cpuset'], '0') + self.assertEqual(container.get('HostConfig.CpusetCpus'), '0') def test_create_container_with_read_only_root_fs(self): read_only = True From 976887250708fe1ad4f8c478cc5781c04655b92b Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 29 Jul 2015 18:04:19 +0100 Subject: [PATCH 1024/4072] Fix "Duplicate volume mount" error when config has trailing slashes When an image declares a volume such as `/var/lib/mysql`, and a Compose file has a line like `./data:/var/lib/mysql/` (note the trailing slash), Compose creates duplicate volume binds when *recreating* the container. (The first container is created without a hitch, but contains multiple entries in its "Volumes" config.) Fixed by normalizing all paths in volumes config. Signed-off-by: Aanand Prasad --- compose/service.py | 12 +++++++---- tests/integration/service_test.py | 34 +++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/compose/service.py b/compose/service.py index b9b4ed3e0e2..2e0490a5086 100644 --- a/compose/service.py +++ b/compose/service.py @@ -3,6 +3,7 @@ from collections import namedtuple import logging import re +import os import sys from operator import attrgetter @@ -848,12 +849,15 @@ def parse_volume_spec(volume_config): "external:internal[:mode]" % volume_config) if len(parts) == 1: - return VolumeSpec(None, parts[0], 'rw') + external = None + internal = os.path.normpath(parts[0]) + else: + external = os.path.normpath(parts[0]) + internal = os.path.normpath(parts[1]) - if len(parts) == 2: - parts.append('rw') + mode = parts[2] if len(parts) == 3 else 'rw' - return VolumeSpec(*parts) + return VolumeSpec(external, internal, mode) # Ports diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index a901fc59add..b975fc00d00 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -221,6 +221,40 @@ def test_create_container_with_specified_volume(self): self.assertTrue(path.basename(actual_host_path) == path.basename(host_path), msg=("Last component differs: %s, %s" % (actual_host_path, host_path))) + def test_duplicate_volume_trailing_slash(self): + """ + When an image specifies a volume, and the Compose file specifies a host path + but adds a trailing slash, make sure that we don't create duplicate binds. + """ + host_path = '/tmp/data' + container_path = '/data' + volumes = ['{}:{}/'.format(host_path, container_path)] + + tmp_container = self.client.create_container( + 'busybox', 'true', + volumes={container_path: {}}, + labels={'com.docker.compose.test_image': 'true'}, + ) + image = self.client.commit(tmp_container)['Id'] + + service = self.create_service('db', image=image, volumes=volumes) + old_container = create_and_start_container(service) + + self.assertEqual( + old_container.get('Config.Volumes'), + {container_path: {}}, + ) + + service = self.create_service('db', image=image, volumes=volumes) + new_container = service.recreate_container(old_container) + + self.assertEqual( + new_container.get('Config.Volumes'), + {container_path: {}}, + ) + + self.assertEqual(service.containers(stopped=False), [new_container]) + @patch.dict(os.environ) def test_create_container_with_home_and_env_var_in_volume_path(self): os.environ['VOLUME_NAME'] = 'my-volume' From 03c3d4c768198bea7eedcde79c01177441e8a0c1 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 22 Jul 2015 15:09:24 +0100 Subject: [PATCH 1025/4072] generator -> iterator Signed-off-by: Aanand Prasad --- compose/cli/multiplexer.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/compose/cli/multiplexer.py b/compose/cli/multiplexer.py index 849dbd66a2b..02e39aa1e6f 100644 --- a/compose/cli/multiplexer.py +++ b/compose/cli/multiplexer.py @@ -13,8 +13,13 @@ class Multiplexer(object): - def __init__(self, generators): - self.generators = generators + """ + 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): + self.iterators = iterators self.queue = Queue() def loop(self): @@ -31,12 +36,12 @@ def loop(self): pass def _init_readers(self): - for generator in self.generators: - t = Thread(target=_enqueue_output, args=(generator, self.queue)) + for iterator in self.iterators: + t = Thread(target=_enqueue_output, args=(iterator, self.queue)) t.daemon = True t.start() -def _enqueue_output(generator, queue): - for item in generator: +def _enqueue_output(iterator, queue): + for item in iterator: queue.put(item) From 27378704df946bd4f3bd994f750916c04e0dc139 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 22 Jul 2015 14:09:12 +0100 Subject: [PATCH 1026/4072] Isolate STOP logic in multiplexer module Signed-off-by: Aanand Prasad --- compose/cli/log_printer.py | 3 +-- compose/cli/multiplexer.py | 2 ++ 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/compose/cli/log_printer.py b/compose/cli/log_printer.py index ce7e1065338..9c5d35e1877 100644 --- a/compose/cli/log_printer.py +++ b/compose/cli/log_printer.py @@ -4,7 +4,7 @@ from itertools import cycle -from .multiplexer import Multiplexer, STOP +from .multiplexer import Multiplexer from . import colors from .utils import split_buffer @@ -61,7 +61,6 @@ def _make_log_generator(self, container, color_fn): exit_code = container.wait() yield color_fn("%s exited with code %s\n" % (container.name, exit_code)) - yield STOP def _generate_prefix(self, container): """ diff --git a/compose/cli/multiplexer.py b/compose/cli/multiplexer.py index 02e39aa1e6f..ab7482e1dc5 100644 --- a/compose/cli/multiplexer.py +++ b/compose/cli/multiplexer.py @@ -45,3 +45,5 @@ def _init_readers(self): def _enqueue_output(iterator, queue): for item in iterator: queue.put(item) + + queue.put(STOP) From a9942b512a4fc6b04c334c821804a955a6c45ec0 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 22 Jul 2015 14:08:46 +0100 Subject: [PATCH 1027/4072] Wait for all containers to exit when running 'up' interactively Signed-off-by: Aanand Prasad --- compose/cli/multiplexer.py | 7 +++---- tests/unit/multiplexer_test.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 4 deletions(-) create mode 100644 tests/unit/multiplexer_test.py diff --git a/compose/cli/multiplexer.py b/compose/cli/multiplexer.py index ab7482e1dc5..34b55133ccb 100644 --- a/compose/cli/multiplexer.py +++ b/compose/cli/multiplexer.py @@ -7,8 +7,6 @@ from queue import Queue, Empty # Python 3.x -# Yield STOP from an input generator to stop the -# top-level loop without processing any more input. STOP = object() @@ -20,16 +18,17 @@ class Multiplexer(object): def __init__(self, iterators): self.iterators = iterators + self._num_running = len(iterators) self.queue = Queue() def loop(self): self._init_readers() - while True: + while self._num_running > 0: try: item = self.queue.get(timeout=0.1) if item is STOP: - break + self._num_running -= 1 else: yield item except Empty: diff --git a/tests/unit/multiplexer_test.py b/tests/unit/multiplexer_test.py new file mode 100644 index 00000000000..100b8f0c2d1 --- /dev/null +++ b/tests/unit/multiplexer_test.py @@ -0,0 +1,28 @@ +import unittest + +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())), + ) From 80d90a745ad9816817a14f4aa35c3d9a1a2136b4 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 22 Jul 2015 14:47:59 +0100 Subject: [PATCH 1028/4072] Make sure an exception in any iterator gets raised in the main thread Signed-off-by: Aanand Prasad Conflicts: compose/cli/multiplexer.py --- compose/cli/multiplexer.py | 16 +++++++++++----- tests/unit/multiplexer_test.py | 17 +++++++++++++++++ 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/compose/cli/multiplexer.py b/compose/cli/multiplexer.py index 34b55133ccb..955af632217 100644 --- a/compose/cli/multiplexer.py +++ b/compose/cli/multiplexer.py @@ -26,7 +26,11 @@ def loop(self): while self._num_running > 0: try: - item = self.queue.get(timeout=0.1) + item, exception = self.queue.get(timeout=0.1) + + if exception: + raise exception + if item is STOP: self._num_running -= 1 else: @@ -42,7 +46,9 @@ def _init_readers(self): def _enqueue_output(iterator, queue): - for item in iterator: - queue.put(item) - - queue.put(STOP) + try: + for item in iterator: + queue.put((item, None)) + queue.put((STOP, None)) + except Exception as e: + queue.put((None, e)) diff --git a/tests/unit/multiplexer_test.py b/tests/unit/multiplexer_test.py index 100b8f0c2d1..d565d39d1b7 100644 --- a/tests/unit/multiplexer_test.py +++ b/tests/unit/multiplexer_test.py @@ -26,3 +26,20 @@ def test_aggregates_output(self): [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()) From 27bd987f286209737c665dd355535e76d1e4e71e Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 30 Jul 2015 10:31:54 +0100 Subject: [PATCH 1029/4072] Add test for trailing slash volume copying bug Signed-off-by: Aanand Prasad --- tests/integration/service_test.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index b975fc00d00..abab7c579d5 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -221,6 +221,18 @@ def test_create_container_with_specified_volume(self): self.assertTrue(path.basename(actual_host_path) == path.basename(host_path), msg=("Last component differs: %s, %s" % (actual_host_path, host_path))) + def test_recreate_preserves_volume_with_trailing_slash(self): + """ + When the Compose file specifies a trailing slash in the container path, make + sure we copy the volume over when recreating. + """ + service = self.create_service('data', volumes=['/data/']) + old_container = create_and_start_container(service) + volume_path = old_container.get('Volumes')['/data'] + + new_container = service.recreate_container(old_container) + self.assertEqual(new_container.get('Volumes')['/data'], volume_path) + def test_duplicate_volume_trailing_slash(self): """ When an image specifies a volume, and the Compose file specifies a host path From 1a9ddf645d69cca77b88b4a0c6a38e5c2c841566 Mon Sep 17 00:00:00 2001 From: David BF Date: Fri, 31 Jul 2015 14:26:42 +0200 Subject: [PATCH 1030/4072] Remove useless postgres 'port' configuration Signed-off-by: David BF --- docs/rails.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/rails.md b/docs/rails.md index 7394aadc8ae..9ce6c4a6f8e 100644 --- a/docs/rails.md +++ b/docs/rails.md @@ -40,8 +40,6 @@ Finally, `docker-compose.yml` is where the magic happens. This file describes th db: image: postgres - ports: - - "5432" web: build: . command: bundle exec rails s -p 3000 -b '0.0.0.0' From a68ee0d9c2d3969e161ca973cfbe3e62bcb3dd2e Mon Sep 17 00:00:00 2001 From: Luke Marsden Date: Wed, 3 Jun 2015 12:21:29 +0100 Subject: [PATCH 1031/4072] Support volume_driver in compose * Add support for volume_driver parameter in compose yml * Don't expand volume host paths if a volume_driver is specified (i.e., disable compose feature "relative to absolute path transformation" when volume drivers are in use, since volume drivers can use name where host path is normally specified; this is a heuristic) Signed-off-by: Luke Marsden --- compose/config.py | 3 ++- docs/yml.md | 10 +++++++++- tests/integration/service_test.py | 6 ++++++ tests/unit/config_test.py | 16 ++++++++++++++++ 4 files changed, 33 insertions(+), 2 deletions(-) diff --git a/compose/config.py b/compose/config.py index 064dadaec44..af8983961fe 100644 --- a/compose/config.py +++ b/compose/config.py @@ -43,6 +43,7 @@ 'stdin_open', 'tty', 'user', + 'volume_driver', 'volumes', 'volumes_from', 'working_dir', @@ -251,7 +252,7 @@ def process_container_options(service_dict, working_dir=None): if 'memswap_limit' in service_dict and 'mem_limit' not in service_dict: raise ConfigurationError("Invalid 'memswap_limit' configuration for %s service: when defining 'memswap_limit' you must set 'mem_limit' as well" % service_dict['name']) - if 'volumes' in service_dict: + if 'volumes' in service_dict and service_dict.get('volume_driver') is None: service_dict['volumes'] = resolve_volume_paths(service_dict['volumes'], working_dir=working_dir) if 'build' in service_dict: diff --git a/docs/yml.md b/docs/yml.md index f92b5682568..f89d107bdc5 100644 --- a/docs/yml.md +++ b/docs/yml.md @@ -134,6 +134,12 @@ Mount paths as volumes, optionally specifying a path on the host machine - cache/:/tmp/cache - ~/configs:/etc/configs/:ro +You can mount a relative path on the host, which will expand relative to +the directory of the Compose configuration file being used. + +> Note: No path expansion will be done if you have also specified a +> `volume_driver`. + ### volumes_from Mount all of the volumes from another service or container. @@ -333,7 +339,7 @@ Override the default labeling scheme for each container. - label:user:USER - label:role:ROLE -### working\_dir, entrypoint, user, hostname, domainname, mac\_address, mem\_limit, memswap\_limit, privileged, restart, stdin\_open, tty, cpu\_shares, cpuset, read\_only +### working\_dir, entrypoint, user, hostname, domainname, mac\_address, mem\_limit, memswap\_limit, privileged, restart, stdin\_open, tty, cpu\_shares, cpuset, read\_only, volume\_driver Each of these is a single value, analogous to its [docker run](https://docs.docker.com/reference/run/) counterpart. @@ -360,6 +366,8 @@ Each of these is a single value, analogous to its tty: true read_only: true + volume_driver: mydriver +``` ## Compose documentation diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index abab7c579d5..8856d0245fe 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -117,6 +117,12 @@ def test_create_container_with_unspecified_volume(self): service.start_container(container) self.assertIn('/var/db', container.get('Volumes')) + def test_create_container_with_volume_driver(self): + service = self.create_service('db', volume_driver='foodriver') + container = service.create_container() + service.start_container(container) + self.assertEqual('foodriver', container.get('Config.VolumeDriver')) + def test_create_container_with_cpu_shares(self): service = self.create_service('db', cpu_shares=73) container = service.create_container() diff --git a/tests/unit/config_test.py b/tests/unit/config_test.py index 281717db746..a2c17d7254f 100644 --- a/tests/unit/config_test.py +++ b/tests/unit/config_test.py @@ -72,6 +72,22 @@ def test_volume_binding_with_home(self): d = make_service_dict('foo', {'volumes': ['~:/container/path']}, working_dir='.') self.assertEqual(d['volumes'], ['/home/user:/container/path']) + def test_named_volume_with_driver(self): + d = make_service_dict('foo', { + 'volumes': ['namedvolume:/data'], + 'volume_driver': 'foodriver', + }, working_dir='.') + self.assertEqual(d['volumes'], ['namedvolume:/data']) + + @mock.patch.dict(os.environ) + def test_named_volume_with_special_chars(self): + os.environ['NAME'] = 'surprise!' + d = make_service_dict('foo', { + 'volumes': ['~/${NAME}:/data'], + 'volume_driver': 'foodriver', + }, working_dir='.') + self.assertEqual(d['volumes'], ['~/${NAME}:/data']) + class MergePathMappingTest(object): def config_name(self): From dfe9dccab88955b1cd23159a75333a54d2afbf32 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 28 Jul 2015 15:03:46 +0100 Subject: [PATCH 1032/4072] Merge pull request #1774 from moxiegirl/test-entire-build Contributors can build public docs with compose docs in context (cherry picked from commit 487eae3b7b1e36d037a8fdcf181fdf1bbf9c40cc) Signed-off-by: Aanand Prasad --- docs/Dockerfile | 16 +++++----- docs/pre-process.sh | 61 +++++++++++++++++++++++++++++++++++++++ docs/reference/build.md | 1 + docs/reference/help.md | 1 + docs/reference/kill.md | 1 + docs/reference/logs.md | 1 + docs/reference/port.md | 1 + docs/reference/ps.md | 1 + docs/reference/pull.md | 1 + docs/reference/restart.md | 1 + docs/reference/rm.md | 1 + docs/reference/run.md | 1 + docs/reference/start.md | 1 + docs/reference/stop.md | 1 + docs/reference/up.md | 1 + 15 files changed, 83 insertions(+), 7 deletions(-) create mode 100755 docs/pre-process.sh diff --git a/docs/Dockerfile b/docs/Dockerfile index d6864c2d667..d9add75c150 100644 --- a/docs/Dockerfile +++ b/docs/Dockerfile @@ -6,6 +6,14 @@ COPY . /src COPY . /docs/content/compose/ +RUN svn checkout https://github.com/docker/docker/trunk/docs /docs/content/docker +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/tutorials/trunk/docs /docs/content/tutorials +RUN svn checkout https://github.com/docker/opensource/trunk/docs /docs/content + + # Sed to process GitHub Markdown # 1-2 Remove comment code from metadata block # 3 Change ](/word to ](/project/ in links @@ -15,10 +23,4 @@ COPY . /docs/content/compose/ # 7 Change ](../../ to ](/project/ --> not implemented # # -RUN find /docs/content/compose -type f -name "*.md" -exec sed -i.old \ - -e '/^/g' \ - -e '/^/g' \ - -e 's/\(\]\)\([(]\)\(\/\)/\1\2\/compose\//g' \ - -e 's/\(\][(]\)\([A-z].*\)\(\.md\)/\1\/compose\/\2/g' \ - -e 's/\([(]\)\(.*\)\(\.md\)/\1\2/g' \ - -e 's/\(\][(]\)\(\.\.\/\)/\1\/compose\//g' {} \; +RUN /src/pre-process.sh /docs diff --git a/docs/pre-process.sh b/docs/pre-process.sh new file mode 100755 index 00000000000..75e9611f2f0 --- /dev/null +++ b/docs/pre-process.sh @@ -0,0 +1,61 @@ +#!/bin/bash -e + +# Populate an array with just docker dirs and one with content dirs +docker_dir=(`ls -d /docs/content/docker/*`) +content_dir=(`ls -d /docs/content/*`) + +# Loop content not of docker/ +# +# Sed to process GitHub Markdown +# 1-2 Remove comment code from metadata block +# 3 Remove .md extension from link text +# 4 Change ](/ to ](/project/ in links +# 5 Change ](word) to ](/project/word) +# 6 Change ](../../ to ](/project/ +# 7 Change ](../ to ](/project/word) +# +for i in "${content_dir[@]}" +do + : + case $i in + "/docs/content/windows") + ;; + "/docs/content/mac") + ;; + "/docs/content/linux") + ;; + "/docs/content/docker") + y=${i##*/} + find $i -type f -name "*.md" -exec sed -i.old \ + -e '/^/g' \ + -e '/^/g' {} \; + ;; + *) + y=${i##*/} + find $i -type f -name "*.md" -exec sed -i.old \ + -e '/^/g' \ + -e '/^/g' \ + -e 's/\(\]\)\([(]\)\(\/\)/\1\2\/'$y'\//g' \ + -e 's/\(\][(]\)\([A-z].*\)\(\.md\)/\1\/'$y'\/\2/g' \ + -e 's/\([(]\)\(.*\)\(\.md\)/\1\2/g' \ + -e 's/\(\][(]\)\(\.\/\)/\1\/'$y'\//g' \ + -e 's/\(\][(]\)\(\.\.\/\.\.\/\)/\1\/'$y'\//g' \ + -e 's/\(\][(]\)\(\.\.\/\)/\1\/'$y'\//g' {} \; + ;; + esac +done + +# +# Move docker directories to content +# +for i in "${docker_dir[@]}" +do + : + if [ -d $i ] + then + mv $i /docs/content/ + fi +done + +rm -rf /docs/content/docker + diff --git a/docs/reference/build.md b/docs/reference/build.md index b2b015119d4..b6e27bb264b 100644 --- a/docs/reference/build.md +++ b/docs/reference/build.md @@ -4,6 +4,7 @@ title = "build" description = "build" keywords = ["fig, composition, compose, docker, orchestration, cli, build"] [menu.main] +identifier="build.compose" parent = "smn_compose_cli" +++ diff --git a/docs/reference/help.md b/docs/reference/help.md index 229ac5de949..613708ed2f0 100644 --- a/docs/reference/help.md +++ b/docs/reference/help.md @@ -4,6 +4,7 @@ title = "help" description = "help" keywords = ["fig, composition, compose, docker, orchestration, cli, help"] [menu.main] +identifier="help.compose" parent = "smn_compose_cli" +++ diff --git a/docs/reference/kill.md b/docs/reference/kill.md index c71608748cd..e5dd057361d 100644 --- a/docs/reference/kill.md +++ b/docs/reference/kill.md @@ -4,6 +4,7 @@ title = "kill" description = "Forces running containers to stop." keywords = ["fig, composition, compose, docker, orchestration, cli, kill"] [menu.main] +identifier="kill.compose" parent = "smn_compose_cli" +++ diff --git a/docs/reference/logs.md b/docs/reference/logs.md index 87f937273f2..5b241ea70b7 100644 --- a/docs/reference/logs.md +++ b/docs/reference/logs.md @@ -4,6 +4,7 @@ title = "logs" description = "Displays log output from services." keywords = ["fig, composition, compose, docker, orchestration, cli, logs"] [menu.main] +identifier="logs.compose" parent = "smn_compose_cli" +++ diff --git a/docs/reference/port.md b/docs/reference/port.md index 4745c92d320..76f93f23935 100644 --- a/docs/reference/port.md +++ b/docs/reference/port.md @@ -4,6 +4,7 @@ title = "port" description = "Prints the public port for a port binding.s" keywords = ["fig, composition, compose, docker, orchestration, cli, port"] [menu.main] +identifier="port.compose" parent = "smn_compose_cli" +++ diff --git a/docs/reference/ps.md b/docs/reference/ps.md index b271376f802..546d68e76ce 100644 --- a/docs/reference/ps.md +++ b/docs/reference/ps.md @@ -4,6 +4,7 @@ title = "ps" description = "Lists containers." keywords = ["fig, composition, compose, docker, orchestration, cli, ps"] [menu.main] +identifier="ps.compose" parent = "smn_compose_cli" +++ diff --git a/docs/reference/pull.md b/docs/reference/pull.md index ac22010ec62..e5b5d166ff3 100644 --- a/docs/reference/pull.md +++ b/docs/reference/pull.md @@ -4,6 +4,7 @@ title = "pull" description = "Pulls service images." keywords = ["fig, composition, compose, docker, orchestration, cli, pull"] [menu.main] +identifier="pull.compose" parent = "smn_compose_cli" +++ diff --git a/docs/reference/restart.md b/docs/reference/restart.md index 9b570082bfd..bbd4a68b0fb 100644 --- a/docs/reference/restart.md +++ b/docs/reference/restart.md @@ -4,6 +4,7 @@ title = "restart" description = "Restarts Docker Compose services." keywords = ["fig, composition, compose, docker, orchestration, cli, restart"] [menu.main] +identifier="restart.compose" parent = "smn_compose_cli" +++ diff --git a/docs/reference/rm.md b/docs/reference/rm.md index 0a4ba5b6bc3..2ed959e411c 100644 --- a/docs/reference/rm.md +++ b/docs/reference/rm.md @@ -4,6 +4,7 @@ title = "rm" description = "Removes stopped service containers." keywords = ["fig, composition, compose, docker, orchestration, cli, rm"] [menu.main] +identifier="rm.compose" parent = "smn_compose_cli" +++ diff --git a/docs/reference/run.md b/docs/reference/run.md index b07ddd060da..5ea9a61bec1 100644 --- a/docs/reference/run.md +++ b/docs/reference/run.md @@ -4,6 +4,7 @@ title = "run" description = "Runs a one-off command on a service." keywords = ["fig, composition, compose, docker, orchestration, cli, run"] [menu.main] +identifier="run.compose" parent = "smn_compose_cli" +++ diff --git a/docs/reference/start.md b/docs/reference/start.md index 69d853f9cb6..f0bdd5a97c5 100644 --- a/docs/reference/start.md +++ b/docs/reference/start.md @@ -4,6 +4,7 @@ title = "start" description = "Starts existing containers for a service." keywords = ["fig, composition, compose, docker, orchestration, cli, start"] [menu.main] +identifier="start.compose" parent = "smn_compose_cli" +++ diff --git a/docs/reference/stop.md b/docs/reference/stop.md index 8ff92129d2e..ec7e6688a51 100644 --- a/docs/reference/stop.md +++ b/docs/reference/stop.md @@ -4,6 +4,7 @@ title = "stop" description = "Stops running containers without removing them. " keywords = ["fig, composition, compose, docker, orchestration, cli, stop"] [menu.main] +identifier="stop.compose" parent = "smn_compose_cli" +++ diff --git a/docs/reference/up.md b/docs/reference/up.md index 441d7f9c30b..966aff1e959 100644 --- a/docs/reference/up.md +++ b/docs/reference/up.md @@ -4,6 +4,7 @@ title = "up" description = "Builds, (re)creates, starts, and attaches to containers for a service." keywords = ["fig, composition, compose, docker, orchestration, cli, up"] [menu.main] +identifier="up.compose" parent = "smn_compose_cli" +++ From ca2ce3a034e08520ab7a709c32c9528f7d805cb1 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 29 Jul 2015 15:06:55 +0100 Subject: [PATCH 1033/4072] Merge pull request #1779 from aanand/mac-binary-error-hint Add hint about OS X binary compatibility (cherry picked from commit 1496734cbb2f2084c978eb90f8671bcd970c7095) Signed-off-by: Aanand Prasad --- docs/install.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/install.md b/docs/install.md index dad6efd56d7..38302485a1f 100644 --- a/docs/install.md +++ b/docs/install.md @@ -35,6 +35,8 @@ To install Compose, run the following commands: Optionally, you can also install [command completion](completion.md) for the bash and zsh shell. +> **Note:** Some older Mac OS X CPU architectures are incompatible with the binary. If you receive an "Illegal instruction: 4" error after installing, you should install using the `pip` command instead. + Compose is available for OS X and 64-bit Linux. If you're on another platform, Compose can also be installed as a Python package: From 989b2491b98d84423425a9151e0fa8ae7a73bc79 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 29 Jul 2015 15:42:57 +0100 Subject: [PATCH 1034/4072] Merge pull request #1780 from gheart/specify_api_version_via_env Allow API version specification via env var (cherry picked from commit 2759ab5ab6bbe81bb33151cf0fbc21862f0c9346) Signed-off-by: Aanand Prasad --- compose/cli/docker_client.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/compose/cli/docker_client.py b/compose/cli/docker_client.py index e513182fb3c..adee9365b1d 100644 --- a/compose/cli/docker_client.py +++ b/compose/cli/docker_client.py @@ -14,6 +14,8 @@ def docker_client(): cert_path = os.path.join(os.environ.get('HOME', ''), '.docker') base_url = os.environ.get('DOCKER_HOST') + api_version = os.environ.get('COMPOSE_API_VERSION', '1.18') + tls_config = None if os.environ.get('DOCKER_TLS_VERIFY', '') != '': @@ -32,4 +34,4 @@ def docker_client(): ) timeout = int(os.environ.get('DOCKER_CLIENT_TIMEOUT', 60)) - return Client(base_url=base_url, tls=tls_config, version='1.18', timeout=timeout) + return Client(base_url=base_url, tls=tls_config, version=api_version, timeout=timeout) From 49bafdc4cddaa785c1e04718db6f6a5f0e979658 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 29 Jul 2015 16:59:42 +0100 Subject: [PATCH 1035/4072] Merge pull request #1777 from aanand/update-api-version Update API version to 1.19 (cherry picked from commit 276e369c3167b4f5e189b33e8a55ff6fd7115142) Signed-off-by: Aanand Prasad --- Dockerfile | 6 ++---- compose/cli/docker_client.py | 2 +- docs/install.md | 2 +- tests/integration/service_test.py | 4 ++-- 4 files changed, 6 insertions(+), 8 deletions(-) diff --git a/Dockerfile b/Dockerfile index 738e0b9978c..a0e7f14f91c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -48,16 +48,14 @@ RUN set -ex; \ rm -rf pip-7.0.1; \ rm pip-7.0.1.tar.gz -ENV ALL_DOCKER_VERSIONS 1.6.2 1.7.1 +ENV ALL_DOCKER_VERSIONS 1.7.1 RUN set -ex; \ - curl https://get.docker.com/builds/Linux/x86_64/docker-1.6.2 -o /usr/local/bin/docker-1.6.2; \ - chmod +x /usr/local/bin/docker-1.6.2; \ curl https://get.docker.com/builds/Linux/x86_64/docker-1.7.1 -o /usr/local/bin/docker-1.7.1; \ chmod +x /usr/local/bin/docker-1.7.1 # Set the default Docker to be run -RUN ln -s /usr/local/bin/docker-1.6.2 /usr/local/bin/docker +RUN ln -s /usr/local/bin/docker-1.7.1 /usr/local/bin/docker RUN useradd -d /home/user -m -s /bin/bash user WORKDIR /code/ diff --git a/compose/cli/docker_client.py b/compose/cli/docker_client.py index adee9365b1d..244bcbef2f6 100644 --- a/compose/cli/docker_client.py +++ b/compose/cli/docker_client.py @@ -14,7 +14,7 @@ def docker_client(): cert_path = os.path.join(os.environ.get('HOME', ''), '.docker') base_url = os.environ.get('DOCKER_HOST') - api_version = os.environ.get('COMPOSE_API_VERSION', '1.18') + api_version = os.environ.get('COMPOSE_API_VERSION', '1.19') tls_config = None diff --git a/docs/install.md b/docs/install.md index 38302485a1f..adb32fd50b0 100644 --- a/docs/install.md +++ b/docs/install.md @@ -17,7 +17,7 @@ Compose with a `curl` command. ## Install Docker -First, install Docker version 1.6 or greater: +First, install Docker version 1.7.1 or greater: - [Instructions for Mac OS X](http://docs.docker.com/installation/mac/) - [Instructions for Ubuntu](http://docs.docker.com/installation/ubuntulinux/) diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 60e2eed1f44..a901fc59add 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -121,7 +121,7 @@ def test_create_container_with_cpu_shares(self): service = self.create_service('db', cpu_shares=73) container = service.create_container() service.start_container(container) - self.assertEqual(container.inspect()['Config']['CpuShares'], 73) + self.assertEqual(container.get('HostConfig.CpuShares'), 73) def test_build_extra_hosts(self): # string @@ -183,7 +183,7 @@ def test_create_container_with_cpu_set(self): service = self.create_service('db', cpuset='0') container = service.create_container() service.start_container(container) - self.assertEqual(container.inspect()['Config']['Cpuset'], '0') + self.assertEqual(container.get('HostConfig.CpusetCpus'), '0') def test_create_container_with_read_only_root_fs(self): read_only = True From ad922cd7a1af819be1ffedefb78045f54f7658e7 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 30 Jul 2015 09:59:08 +0100 Subject: [PATCH 1036/4072] Merge pull request #1787 from aanand/fix-duplicate-volume-bind Fix "Duplicate volume mount" error when config has trailing slashes (cherry picked from commit dc7bdd10d45093fff2d51ec20d67928a17f3a06f) Signed-off-by: Aanand Prasad --- compose/service.py | 12 +++++++---- tests/integration/service_test.py | 34 +++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/compose/service.py b/compose/service.py index b9b4ed3e0e2..2e0490a5086 100644 --- a/compose/service.py +++ b/compose/service.py @@ -3,6 +3,7 @@ from collections import namedtuple import logging import re +import os import sys from operator import attrgetter @@ -848,12 +849,15 @@ def parse_volume_spec(volume_config): "external:internal[:mode]" % volume_config) if len(parts) == 1: - return VolumeSpec(None, parts[0], 'rw') + external = None + internal = os.path.normpath(parts[0]) + else: + external = os.path.normpath(parts[0]) + internal = os.path.normpath(parts[1]) - if len(parts) == 2: - parts.append('rw') + mode = parts[2] if len(parts) == 3 else 'rw' - return VolumeSpec(*parts) + return VolumeSpec(external, internal, mode) # Ports diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index a901fc59add..b975fc00d00 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -221,6 +221,40 @@ def test_create_container_with_specified_volume(self): self.assertTrue(path.basename(actual_host_path) == path.basename(host_path), msg=("Last component differs: %s, %s" % (actual_host_path, host_path))) + def test_duplicate_volume_trailing_slash(self): + """ + When an image specifies a volume, and the Compose file specifies a host path + but adds a trailing slash, make sure that we don't create duplicate binds. + """ + host_path = '/tmp/data' + container_path = '/data' + volumes = ['{}:{}/'.format(host_path, container_path)] + + tmp_container = self.client.create_container( + 'busybox', 'true', + volumes={container_path: {}}, + labels={'com.docker.compose.test_image': 'true'}, + ) + image = self.client.commit(tmp_container)['Id'] + + service = self.create_service('db', image=image, volumes=volumes) + old_container = create_and_start_container(service) + + self.assertEqual( + old_container.get('Config.Volumes'), + {container_path: {}}, + ) + + service = self.create_service('db', image=image, volumes=volumes) + new_container = service.recreate_container(old_container) + + self.assertEqual( + new_container.get('Config.Volumes'), + {container_path: {}}, + ) + + self.assertEqual(service.containers(stopped=False), [new_container]) + @patch.dict(os.environ) def test_create_container_with_home_and_env_var_in_volume_path(self): os.environ['VOLUME_NAME'] = 'my-volume' From 5c853c4a2cec86f68c2157ace9b4f4853c3511bf Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 30 Jul 2015 07:18:57 -0700 Subject: [PATCH 1037/4072] Merge pull request #1794 from aanand/add-test-for-trailing-slash-volume-copy Add test for trailing slash volume copying bug (cherry picked from commit ea7276031c237c8c1f8bc6131102b5a73c29826e) Signed-off-by: Aanand Prasad --- tests/integration/service_test.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index b975fc00d00..abab7c579d5 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -221,6 +221,18 @@ def test_create_container_with_specified_volume(self): self.assertTrue(path.basename(actual_host_path) == path.basename(host_path), msg=("Last component differs: %s, %s" % (actual_host_path, host_path))) + def test_recreate_preserves_volume_with_trailing_slash(self): + """ + When the Compose file specifies a trailing slash in the container path, make + sure we copy the volume over when recreating. + """ + service = self.create_service('data', volumes=['/data/']) + old_container = create_and_start_container(service) + volume_path = old_container.get('Volumes')['/data'] + + new_container = service.recreate_container(old_container) + self.assertEqual(new_container.get('Volumes')['/data'], volume_path) + def test_duplicate_volume_trailing_slash(self): """ When an image specifies a volume, and the Compose file specifies a host path From 31cf63b37450eb3464c0c463cc9240dbdb149b79 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 31 Jul 2015 07:27:45 -0700 Subject: [PATCH 1038/4072] Merge pull request #1799 from d2bit/clean-rails-quickstart-guide-db-config Remove useless postgres 'port' configuration (cherry picked from commit b25f05bed4d8865c8d236576e99d8d44571702d4) Signed-off-by: Aanand Prasad --- docs/rails.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/rails.md b/docs/rails.md index 7394aadc8ae..9ce6c4a6f8e 100644 --- a/docs/rails.md +++ b/docs/rails.md @@ -40,8 +40,6 @@ Finally, `docker-compose.yml` is where the magic happens. This file describes th db: image: postgres - ports: - - "5432" web: build: . command: bundle exec rails s -p 3000 -b '0.0.0.0' From 3d6946417db4498297b44f570f23501686bc82a4 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 31 Jul 2015 16:28:46 +0100 Subject: [PATCH 1039/4072] Merge pull request #1800 from aanand/volume-driver-support Support volume_driver (cherry picked from commit 41b9df763925dee4f5702dc48ce18a76cbca19f2) Signed-off-by: Aanand Prasad --- compose/config.py | 3 ++- docs/yml.md | 10 +++++++++- tests/integration/service_test.py | 6 ++++++ tests/unit/config_test.py | 16 ++++++++++++++++ 4 files changed, 33 insertions(+), 2 deletions(-) diff --git a/compose/config.py b/compose/config.py index 064dadaec44..af8983961fe 100644 --- a/compose/config.py +++ b/compose/config.py @@ -43,6 +43,7 @@ 'stdin_open', 'tty', 'user', + 'volume_driver', 'volumes', 'volumes_from', 'working_dir', @@ -251,7 +252,7 @@ def process_container_options(service_dict, working_dir=None): if 'memswap_limit' in service_dict and 'mem_limit' not in service_dict: raise ConfigurationError("Invalid 'memswap_limit' configuration for %s service: when defining 'memswap_limit' you must set 'mem_limit' as well" % service_dict['name']) - if 'volumes' in service_dict: + if 'volumes' in service_dict and service_dict.get('volume_driver') is None: service_dict['volumes'] = resolve_volume_paths(service_dict['volumes'], working_dir=working_dir) if 'build' in service_dict: diff --git a/docs/yml.md b/docs/yml.md index f92b5682568..f89d107bdc5 100644 --- a/docs/yml.md +++ b/docs/yml.md @@ -134,6 +134,12 @@ Mount paths as volumes, optionally specifying a path on the host machine - cache/:/tmp/cache - ~/configs:/etc/configs/:ro +You can mount a relative path on the host, which will expand relative to +the directory of the Compose configuration file being used. + +> Note: No path expansion will be done if you have also specified a +> `volume_driver`. + ### volumes_from Mount all of the volumes from another service or container. @@ -333,7 +339,7 @@ Override the default labeling scheme for each container. - label:user:USER - label:role:ROLE -### working\_dir, entrypoint, user, hostname, domainname, mac\_address, mem\_limit, memswap\_limit, privileged, restart, stdin\_open, tty, cpu\_shares, cpuset, read\_only +### working\_dir, entrypoint, user, hostname, domainname, mac\_address, mem\_limit, memswap\_limit, privileged, restart, stdin\_open, tty, cpu\_shares, cpuset, read\_only, volume\_driver Each of these is a single value, analogous to its [docker run](https://docs.docker.com/reference/run/) counterpart. @@ -360,6 +366,8 @@ Each of these is a single value, analogous to its tty: true read_only: true + volume_driver: mydriver +``` ## Compose documentation diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index abab7c579d5..8856d0245fe 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -117,6 +117,12 @@ def test_create_container_with_unspecified_volume(self): service.start_container(container) self.assertIn('/var/db', container.get('Volumes')) + def test_create_container_with_volume_driver(self): + service = self.create_service('db', volume_driver='foodriver') + container = service.create_container() + service.start_container(container) + self.assertEqual('foodriver', container.get('Config.VolumeDriver')) + def test_create_container_with_cpu_shares(self): service = self.create_service('db', cpu_shares=73) container = service.create_container() diff --git a/tests/unit/config_test.py b/tests/unit/config_test.py index 281717db746..a2c17d7254f 100644 --- a/tests/unit/config_test.py +++ b/tests/unit/config_test.py @@ -72,6 +72,22 @@ def test_volume_binding_with_home(self): d = make_service_dict('foo', {'volumes': ['~:/container/path']}, working_dir='.') self.assertEqual(d['volumes'], ['/home/user:/container/path']) + def test_named_volume_with_driver(self): + d = make_service_dict('foo', { + 'volumes': ['namedvolume:/data'], + 'volume_driver': 'foodriver', + }, working_dir='.') + self.assertEqual(d['volumes'], ['namedvolume:/data']) + + @mock.patch.dict(os.environ) + def test_named_volume_with_special_chars(self): + os.environ['NAME'] = 'surprise!' + d = make_service_dict('foo', { + 'volumes': ['~/${NAME}:/data'], + 'volume_driver': 'foodriver', + }, working_dir='.') + self.assertEqual(d['volumes'], ['~/${NAME}:/data']) + class MergePathMappingTest(object): def config_name(self): From 92ef1f57022008d0bb5ed47971bccb83ed07afa4 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 28 Jul 2015 16:48:38 +0100 Subject: [PATCH 1040/4072] Make compose.config a proper module Signed-off-by: Aanand Prasad --- compose/config/__init__.py | 10 ++++++++++ compose/{ => config}/config.py | 0 tests/integration/testcases.py | 2 +- tests/unit/config_test.py | 2 +- 4 files changed, 12 insertions(+), 2 deletions(-) create mode 100644 compose/config/__init__.py rename compose/{ => config}/config.py (100%) diff --git a/compose/config/__init__.py b/compose/config/__init__.py new file mode 100644 index 00000000000..3907e5b67ef --- /dev/null +++ b/compose/config/__init__.py @@ -0,0 +1,10 @@ +from .config import ( + DOCKER_CONFIG_KEYS, + ConfigDetails, + ConfigurationError, + find, + load, + parse_environment, + merge_environment, + get_service_name_from_net, +) # flake8: noqa diff --git a/compose/config.py b/compose/config/config.py similarity index 100% rename from compose/config.py rename to compose/config/config.py diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index 2a7c0a440d2..a7929088bec 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -1,7 +1,7 @@ from __future__ import unicode_literals from __future__ import absolute_import from compose.service import Service -from compose.config import ServiceLoader +from compose.config.config import ServiceLoader from compose.const import LABEL_PROJECT from compose.cli.docker_client import docker_client from compose.progress_stream import stream_output diff --git a/tests/unit/config_test.py b/tests/unit/config_test.py index a2c17d7254f..3ee754e319e 100644 --- a/tests/unit/config_test.py +++ b/tests/unit/config_test.py @@ -4,7 +4,7 @@ import tempfile from .. import unittest -from compose import config +from compose.config import config def make_service_dict(name, service_dict, working_dir): From 31ac3ce22a381061b13046a4231161ed5c1a9eb3 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 28 Jul 2015 18:05:39 +0100 Subject: [PATCH 1041/4072] Split out compose.config.errors Signed-off-by: Aanand Prasad --- compose/config/config.py | 36 ++++++------------------------------ compose/config/errors.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 30 deletions(-) create mode 100644 compose/config/errors.py diff --git a/compose/config/config.py b/compose/config/config.py index af8983961fe..d36967825cb 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -8,6 +8,12 @@ from compose.cli.utils import find_candidates_in_parent_dirs +from .errors import ( + ConfigurationError, + CircularReference, + ComposeFileNotFound, +) + DOCKER_CONFIG_KEYS = [ 'cap_add', @@ -536,33 +542,3 @@ def load_yaml(filename): return yaml.safe_load(fh) except IOError as e: raise ConfigurationError(six.text_type(e)) - - -class ConfigurationError(Exception): - def __init__(self, msg): - self.msg = msg - - def __str__(self): - return self.msg - - -class CircularReference(ConfigurationError): - def __init__(self, trail): - self.trail = trail - - @property - def msg(self): - lines = [ - "{} in {}".format(service_name, filename) - for (filename, service_name) in self.trail - ] - return "Circular reference:\n {}".format("\n extends ".join(lines)) - - -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? - - Supported filenames: %s - """ % ", ".join(supported_filenames)) diff --git a/compose/config/errors.py b/compose/config/errors.py new file mode 100644 index 00000000000..037b7ec84d7 --- /dev/null +++ b/compose/config/errors.py @@ -0,0 +1,28 @@ +class ConfigurationError(Exception): + def __init__(self, msg): + self.msg = msg + + def __str__(self): + return self.msg + + +class CircularReference(ConfigurationError): + def __init__(self, trail): + self.trail = trail + + @property + def msg(self): + lines = [ + "{} in {}".format(service_name, filename) + for (filename, service_name) in self.trail + ] + return "Circular reference:\n {}".format("\n extends ".join(lines)) + + +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? + + Supported filenames: %s + """ % ", ".join(supported_filenames)) From 8b5bd945d0883ef71b87ca80e75c57e2636183a9 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 24 Jul 2015 15:58:18 +0100 Subject: [PATCH 1042/4072] Interpolate environment variables Signed-off-by: Aanand Prasad --- compose/config/config.py | 9 ++- compose/config/interpolation.py | 69 +++++++++++++++++ docs/yml.md | 31 ++++++++ .../docker-compose.yml | 17 +++++ .../docker-compose.yml | 5 ++ tests/integration/cli_test.py | 15 ++++ tests/integration/service_test.py | 18 ----- tests/unit/config_test.py | 75 +++++++++++++++---- tests/unit/interpolation_test.py | 31 ++++++++ 9 files changed, 235 insertions(+), 35 deletions(-) create mode 100644 compose/config/interpolation.py create mode 100644 tests/fixtures/environment-interpolation/docker-compose.yml create mode 100644 tests/fixtures/volume-path-interpolation/docker-compose.yml create mode 100644 tests/unit/interpolation_test.py diff --git a/compose/config/config.py b/compose/config/config.py index d36967825cb..4d3f5faefad 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -8,6 +8,7 @@ from compose.cli.utils import find_candidates_in_parent_dirs +from .interpolation import interpolate_environment_variables from .errors import ( ConfigurationError, CircularReference, @@ -132,11 +133,11 @@ def get_config_path(base_dir): def load(config_details): dictionary, working_dir, filename = config_details + dictionary = interpolate_environment_variables(dictionary) + service_dicts = [] for service_name, service_dict in list(dictionary.items()): - if not isinstance(service_dict, dict): - raise ConfigurationError('Service "%s" doesn\'t have any configuration options. All top level keys in your docker-compose.yml must map to a dictionary of configuration options.' % service_name) loader = ServiceLoader(working_dir=working_dir, filename=filename) service_dict = loader.make_service_dict(service_name, service_dict) validate_paths(service_dict) @@ -429,9 +430,9 @@ def resolve_volume_paths(volumes, working_dir=None): def resolve_volume_path(volume, working_dir): container_path, host_path = split_path_mapping(volume) - container_path = os.path.expanduser(os.path.expandvars(container_path)) + container_path = os.path.expanduser(container_path) if host_path is not None: - host_path = os.path.expanduser(os.path.expandvars(host_path)) + host_path = os.path.expanduser(host_path) return "%s:%s" % (expand_path(working_dir, host_path), container_path) else: return container_path diff --git a/compose/config/interpolation.py b/compose/config/interpolation.py new file mode 100644 index 00000000000..0d4b96419c6 --- /dev/null +++ b/compose/config/interpolation.py @@ -0,0 +1,69 @@ +import os +from string import Template +from collections import defaultdict + +import six + +from .errors import ConfigurationError + + +def interpolate_environment_variables(config): + return dict( + (service_name, process_service(service_name, service_dict)) + for (service_name, service_dict) in config.items() + ) + + +def process_service(service_name, service_dict): + if not isinstance(service_dict, dict): + raise ConfigurationError( + 'Service "%s" doesn\'t have any configuration options. ' + 'All top level keys in your docker-compose.yml must map ' + 'to a dictionary of configuration options.' % service_name + ) + + return dict( + (key, interpolate_value(service_name, key, val)) + for (key, val) in service_dict.items() + ) + + +def interpolate_value(service_name, config_key, value): + try: + return recursive_interpolate(value) + except InvalidInterpolation as e: + raise ConfigurationError( + 'Invalid interpolation format for "{config_key}" option ' + 'in service "{service_name}": "{string}"' + .format( + config_key=config_key, + service_name=service_name, + string=e.string, + ) + ) + + +def recursive_interpolate(obj): + if isinstance(obj, six.string_types): + return interpolate(obj, os.environ) + elif isinstance(obj, dict): + return dict( + (key, recursive_interpolate(val)) + for (key, val) in obj.items() + ) + elif isinstance(obj, list): + return map(recursive_interpolate, obj) + else: + return obj + + +def interpolate(string, mapping): + try: + return Template(string).substitute(defaultdict(lambda: "", mapping)) + except ValueError: + raise InvalidInterpolation(string) + + +class InvalidInterpolation(Exception): + def __init__(self, string): + self.string = string diff --git a/docs/yml.md b/docs/yml.md index f89d107bdc5..18551bf22fa 100644 --- a/docs/yml.md +++ b/docs/yml.md @@ -19,6 +19,10 @@ 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`. +Values for configuration options can contain environment variables, e.g. +`image: postgres:${POSTGRES_VERSION}`. For more details, see the section on +[variable substitution](#variable-substitution). + ### image Tag or partial image ID. Can be local or remote - Compose will attempt to @@ -369,6 +373,33 @@ Each of these is a single value, analogous to its volume_driver: mydriver ``` +## 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: + + db: + image: "postgres:${POSTGRES_VERSION}" + +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. + +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:`. + +Both `$VARIABLE` and `${VARIABLE}` syntax are supported. Extended shell-style +features, such as `${VARIABLE-default}` and `${VARIABLE/foo/bar}`, are not +supported. + +If you need to put a literal dollar sign in a configuration value, use a double +dollar sign (`$$`). + + ## Compose documentation - [User guide](/) diff --git a/tests/fixtures/environment-interpolation/docker-compose.yml b/tests/fixtures/environment-interpolation/docker-compose.yml new file mode 100644 index 00000000000..7ed43a812cb --- /dev/null +++ b/tests/fixtures/environment-interpolation/docker-compose.yml @@ -0,0 +1,17 @@ +web: + # unbracketed name + image: $IMAGE + + # array element + ports: + - "${HOST_PORT}:8000" + + # dictionary item value + labels: + mylabel: "${LABEL_VALUE}" + + # unset value + hostname: "host-${UNSET_VALUE}" + + # escaped interpolation + command: "$${ESCAPED}" diff --git a/tests/fixtures/volume-path-interpolation/docker-compose.yml b/tests/fixtures/volume-path-interpolation/docker-compose.yml new file mode 100644 index 00000000000..6d4e236af93 --- /dev/null +++ b/tests/fixtures/volume-path-interpolation/docker-compose.yml @@ -0,0 +1,5 @@ +test: + image: busybox + command: top + volumes: + - "~/${VOLUME_NAME}:/container-path" diff --git a/tests/integration/cli_test.py b/tests/integration/cli_test.py index f3b3b9f5fb7..0e86c2792f0 100644 --- a/tests/integration/cli_test.py +++ b/tests/integration/cli_test.py @@ -488,6 +488,21 @@ def test_env_file_relative_to_compose_file(self): self.assertEqual(len(containers), 1) self.assertIn("FOO=1", containers[0].get('Config.Env')) + @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' + expected_host_path = os.path.join(os.environ['HOME'], os.environ['VOLUME_NAME']) + + self.command.base_dir = 'tests/fixtures/volume-path-interpolation' + self.command.dispatch(['up', '-d'], None) + + container = self.project.containers(stopped=True)[0] + actual_host_path = container.get('Volumes')['/container-path'] + components = actual_host_path.split('/') + self.assertTrue(components[-2:] == ['home-dir', 'my-volume'], + msg="Last two components differ: %s, %s" % (actual_host_path, expected_host_path)) + def test_up_with_extends(self): self.command.base_dir = 'tests/fixtures/extends' self.command.dispatch(['up', '-d'], None) diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 8856d0245fe..9bdc12f9930 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -273,24 +273,6 @@ def test_duplicate_volume_trailing_slash(self): self.assertEqual(service.containers(stopped=False), [new_container]) - @patch.dict(os.environ) - def test_create_container_with_home_and_env_var_in_volume_path(self): - os.environ['VOLUME_NAME'] = 'my-volume' - os.environ['HOME'] = '/tmp/home-dir' - expected_host_path = os.path.join(os.environ['HOME'], os.environ['VOLUME_NAME']) - - host_path = '~/${VOLUME_NAME}' - container_path = '/container-path' - - service = self.create_service('db', volumes=['%s:%s' % (host_path, container_path)]) - container = service.create_container() - service.start_container(container) - - actual_host_path = container.get('Volumes')[container_path] - components = actual_host_path.split('/') - self.assertTrue(components[-2:] == ['home-dir', 'my-volume'], - msg="Last two components differ: %s, %s" % (actual_host_path, expected_host_path)) - def test_create_container_with_volumes_from(self): volume_service = self.create_service('data') volume_container_1 = volume_service.create_container() diff --git a/tests/unit/config_test.py b/tests/unit/config_test.py index 3ee754e319e..b1c22235b98 100644 --- a/tests/unit/config_test.py +++ b/tests/unit/config_test.py @@ -59,11 +59,56 @@ def test_config_validation(self): make_service_dict('foo', {'ports': ['8000']}, 'tests/') -class VolumePathTest(unittest.TestCase): +class InterpolationTest(unittest.TestCase): @mock.patch.dict(os.environ) - def test_volume_binding_with_environ(self): + def test_config_file_with_environment_variable(self): + os.environ.update( + IMAGE="busybox", + HOST_PORT="80", + LABEL_VALUE="myvalue", + ) + + service_dicts = config.load( + config.find('tests/fixtures/environment-interpolation', None), + ) + + self.assertEqual(service_dicts, [ + { + 'name': 'web', + 'image': 'busybox', + 'ports': ['80:8000'], + 'labels': {'mylabel': 'myvalue'}, + 'hostname': 'host-', + 'command': '${ESCAPED}', + } + ]) + + @mock.patch.dict(os.environ) + def test_invalid_interpolation(self): + with self.assertRaises(config.ConfigurationError) as cm: + config.load( + config.ConfigDetails( + {'web': {'image': '${'}}, + 'working_dir', + 'filename.yml' + ) + ) + + self.assertIn('Invalid', cm.exception.msg) + self.assertIn('for "image" option', cm.exception.msg) + self.assertIn('in service "web"', cm.exception.msg) + self.assertIn('"${"', cm.exception.msg) + + @mock.patch.dict(os.environ) + def test_volume_binding_with_environment_variable(self): os.environ['VOLUME_PATH'] = '/host/path' - d = make_service_dict('foo', {'volumes': ['${VOLUME_PATH}:/container/path']}, working_dir='.') + d = config.load( + config.ConfigDetails( + config={'foo': {'volumes': ['${VOLUME_PATH}:/container/path']}}, + working_dir='.', + filename=None, + ) + )[0] self.assertEqual(d['volumes'], ['/host/path:/container/path']) @mock.patch.dict(os.environ) @@ -400,18 +445,22 @@ def test_resolve_path(self): os.environ['HOSTENV'] = '/tmp' os.environ['CONTAINERENV'] = '/host/tmp' - service_dict = make_service_dict( - 'foo', - {'volumes': ['$HOSTENV:$CONTAINERENV']}, - working_dir="tests/fixtures/env" - ) + service_dict = config.load( + config.ConfigDetails( + config={'foo': {'volumes': ['$HOSTENV:$CONTAINERENV']}}, + working_dir="tests/fixtures/env", + filename=None, + ) + )[0] self.assertEqual(set(service_dict['volumes']), set(['/tmp:/host/tmp'])) - service_dict = make_service_dict( - 'foo', - {'volumes': ['/opt${HOSTENV}:/opt${CONTAINERENV}']}, - working_dir="tests/fixtures/env" - ) + service_dict = config.load( + config.ConfigDetails( + config={'foo': {'volumes': ['/opt${HOSTENV}:/opt${CONTAINERENV}']}}, + working_dir="tests/fixtures/env", + filename=None, + ) + )[0] self.assertEqual(set(service_dict['volumes']), set(['/opt/tmp:/opt/host/tmp'])) diff --git a/tests/unit/interpolation_test.py b/tests/unit/interpolation_test.py new file mode 100644 index 00000000000..96c6f9b33a8 --- /dev/null +++ b/tests/unit/interpolation_test.py @@ -0,0 +1,31 @@ +import unittest + +from compose.config.interpolation import interpolate, InvalidInterpolation + + +class InterpolationTest(unittest.TestCase): + def test_valid_interpolations(self): + self.assertEqual(interpolate('$foo', dict(foo='hi')), 'hi') + self.assertEqual(interpolate('${foo}', dict(foo='hi')), 'hi') + + self.assertEqual(interpolate('${subject} love you', dict(subject='i')), 'i love you') + self.assertEqual(interpolate('i ${verb} you', dict(verb='love')), 'i love you') + self.assertEqual(interpolate('i love ${object}', dict(object='you')), 'i love you') + + def test_empty_value(self): + self.assertEqual(interpolate('${foo}', dict(foo='')), '') + + def test_unset_value(self): + self.assertEqual(interpolate('${foo}', dict()), '') + + def test_escaped_interpolation(self): + self.assertEqual(interpolate('$${foo}', dict(foo='hi')), '${foo}') + + def test_invalid_strings(self): + self.assertRaises(InvalidInterpolation, lambda: interpolate('${', dict())) + self.assertRaises(InvalidInterpolation, lambda: interpolate('$}', dict())) + self.assertRaises(InvalidInterpolation, lambda: interpolate('${}', dict())) + self.assertRaises(InvalidInterpolation, lambda: interpolate('${ }', dict())) + self.assertRaises(InvalidInterpolation, lambda: interpolate('${ foo}', dict())) + self.assertRaises(InvalidInterpolation, lambda: interpolate('${foo }', dict())) + self.assertRaises(InvalidInterpolation, lambda: interpolate('${foo!}', dict())) From ee6ff294a273d07e157af68f0b5f97f36b957676 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 6 Aug 2015 11:31:42 +0100 Subject: [PATCH 1043/4072] Show a warning when a variable is unset Signed-off-by: Aanand Prasad --- compose/config/interpolation.py | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/compose/config/interpolation.py b/compose/config/interpolation.py index 0d4b96419c6..d33e93be497 100644 --- a/compose/config/interpolation.py +++ b/compose/config/interpolation.py @@ -1,11 +1,13 @@ import os from string import Template -from collections import defaultdict import six from .errors import ConfigurationError +import logging +log = logging.getLogger(__name__) + def interpolate_environment_variables(config): return dict( @@ -59,11 +61,26 @@ def recursive_interpolate(obj): def interpolate(string, mapping): try: - return Template(string).substitute(defaultdict(lambda: "", mapping)) + return Template(string).substitute(BlankDefaultDict(mapping)) except ValueError: raise InvalidInterpolation(string) +class BlankDefaultDict(dict): + def __init__(self, mapping): + super(BlankDefaultDict, self).__init__(mapping) + + def __getitem__(self, key): + try: + return super(BlankDefaultDict, self).__getitem__(key) + except KeyError: + log.warn( + "The {} variable is not set. Substituting a blank string." + .format(key) + ) + return "" + + class InvalidInterpolation(Exception): def __init__(self, string): self.string = string From 4f1429869462f61fd307ec63552b290e50b53882 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 6 Aug 2015 14:45:35 +0100 Subject: [PATCH 1044/4072] Abort tests if daemon fails to start Signed-off-by: Aanand Prasad --- script/wrapdocker | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/script/wrapdocker b/script/wrapdocker index 2e07bdadfd9..119e88df4aa 100755 --- a/script/wrapdocker +++ b/script/wrapdocker @@ -8,9 +8,16 @@ fi # delete it so that docker can start. rm -rf /var/run/docker.pid docker -d $DOCKER_DAEMON_ARGS &>/var/log/docker.log & +docker_pid=$! >&2 echo "Waiting for Docker to start..." while ! docker ps &>/dev/null; do + if ! kill -0 "$docker_pid" &>/dev/null; then + >&2 echo "Docker failed to start" + cat /var/log/docker.log + exit 1 + fi + sleep 1 done From fdaa5f2cde7e0721f26ca4e95cbb8f53402be4a7 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 6 Aug 2015 16:14:37 +0100 Subject: [PATCH 1045/4072] Update volume tests for clarity - Better method names. - Environment variable syntax in volume paths, even when a driver is specified, now *will* be processed (the test wasn't testing it properly). However, `~` will still *not* expand to the user's home directory. Signed-off-by: Aanand Prasad --- tests/unit/config_test.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/unit/config_test.py b/tests/unit/config_test.py index b1c22235b98..0046202030c 100644 --- a/tests/unit/config_test.py +++ b/tests/unit/config_test.py @@ -117,7 +117,7 @@ def test_volume_binding_with_home(self): d = make_service_dict('foo', {'volumes': ['~:/container/path']}, working_dir='.') self.assertEqual(d['volumes'], ['/home/user:/container/path']) - def test_named_volume_with_driver(self): + def test_named_volume_with_driver_does_not_expand(self): d = make_service_dict('foo', { 'volumes': ['namedvolume:/data'], 'volume_driver': 'foodriver', @@ -125,13 +125,13 @@ def test_named_volume_with_driver(self): self.assertEqual(d['volumes'], ['namedvolume:/data']) @mock.patch.dict(os.environ) - def test_named_volume_with_special_chars(self): + def test_home_directory_with_driver_does_not_expand(self): os.environ['NAME'] = 'surprise!' d = make_service_dict('foo', { - 'volumes': ['~/${NAME}:/data'], + 'volumes': ['~:/data'], 'volume_driver': 'foodriver', }, working_dir='.') - self.assertEqual(d['volumes'], ['~/${NAME}:/data']) + self.assertEqual(d['volumes'], ['~:/data']) class MergePathMappingTest(object): From da36ee7cbcaf2051fc0829f273c01517bd7d9bc2 Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Mon, 27 Jul 2015 15:15:07 +0100 Subject: [PATCH 1046/4072] Perform schema validation Define a schema that we can pass to jsonschema to validate against the config a user has supplied. This will help catch a wide variety of common errors that occur. If the config does not pass schema validation then it raises an exception and prints out human readable reasons. Signed-off-by: Mazz Mosley --- compose/config/config.py | 43 ++++--- compose/schema.json | 79 ++++++++++++ compose/service.py | 6 - requirements.txt | 1 + setup.py | 1 + .../fixtures/extends/specify-file-as-self.yml | 1 + tests/unit/config_test.py | 118 +++++++++++------- tests/unit/service_test.py | 1 - 8 files changed, 175 insertions(+), 75 deletions(-) create mode 100644 compose/schema.json diff --git a/compose/config/config.py b/compose/config/config.py index 4d3f5faefad..1e793d9f645 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -3,6 +3,8 @@ import sys import yaml from collections import namedtuple +import json +import jsonschema import six @@ -131,13 +133,31 @@ def get_config_path(base_dir): return os.path.join(path, winner) +def validate_against_schema(config): + config_source_dir = os.path.dirname(os.path.abspath(__file__)) + schema_file = os.path.join(config_source_dir, "schema.json") + + with open(schema_file, "r") as schema_fh: + schema = json.load(schema_fh) + + validation_output = jsonschema.Draft4Validator(schema) + + errors = [error.message for error in sorted(validation_output.iter_errors(config), key=str)] + if errors: + raise ConfigurationError("Validation failed, reason(s): {}".format("\n".join(errors))) + + def load(config_details): - dictionary, working_dir, filename = config_details - dictionary = interpolate_environment_variables(dictionary) + config, working_dir, filename = config_details + config = interpolate_environment_variables(config) service_dicts = [] - for service_name, service_dict in list(dictionary.items()): + validate_against_schema(config) + + for service_name, service_dict in list(config.items()): + if not isinstance(service_dict, dict): + raise ConfigurationError('Service "%s" doesn\'t have any configuration options. All top level keys in your docker-compose.yml must map to a dictionary of configuration options.' % service_name) loader = ServiceLoader(working_dir=working_dir, filename=filename) service_dict = loader.make_service_dict(service_name, service_dict) validate_paths(service_dict) @@ -210,25 +230,11 @@ def signature(self, name): def validate_extends_options(self, service_name, extends_options): error_prefix = "Invalid 'extends' configuration for %s:" % service_name - if not isinstance(extends_options, dict): - raise ConfigurationError("%s must be a dictionary" % error_prefix) - - if 'service' not in extends_options: - raise ConfigurationError( - "%s you need to specify a service, e.g. 'service: web'" % error_prefix - ) - if 'file' not in extends_options and self.filename is None: raise ConfigurationError( "%s you need to specify a 'file', e.g. 'file: something.yml'" % error_prefix ) - for k, _ in extends_options.items(): - if k not in ['file', 'service']: - raise ConfigurationError( - "%s unsupported configuration option '%s'" % (error_prefix, k) - ) - return extends_options @@ -256,9 +262,6 @@ def process_container_options(service_dict, working_dir=None): service_dict = service_dict.copy() - if 'memswap_limit' in service_dict and 'mem_limit' not in service_dict: - raise ConfigurationError("Invalid 'memswap_limit' configuration for %s service: when defining 'memswap_limit' you must set 'mem_limit' as well" % service_dict['name']) - if 'volumes' in service_dict and service_dict.get('volume_driver') is None: service_dict['volumes'] = resolve_volume_paths(service_dict['volumes'], working_dir=working_dir) diff --git a/compose/schema.json b/compose/schema.json new file mode 100644 index 00000000000..7c7e2d096c7 --- /dev/null +++ b/compose/schema.json @@ -0,0 +1,79 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + + "type": "object", + + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "$ref": "#/definitions/service" + } + }, + + "definitions": { + "service": { + "type": "object", + + "properties": { + "build": {"type": "string"}, + "env_file": {"$ref": "#/definitions/string_or_list"}, + "environment": { + "oneOf": [ + {"type": "object"}, + {"type": "array", "items": {"type": "string"}, "uniqueItems": true} + ] + }, + "image": {"type": "string"}, + "mem_limit": {"type": "number"}, + "memswap_limit": {"type": "number"}, + + "extends": { + "type": "object", + + "properties": { + "service": {"type": "string"}, + "file": {"type": "string"} + }, + "required": ["service"], + "additionalProperties": false + } + + }, + + "anyOf": [ + { + "required": ["build"], + "not": {"required": ["image"]} + }, + { + "required": ["image"], + "not": {"required": ["build"]} + }, + { + "required": ["extends"], + "not": {"required": ["build", "image"]} + } + ], + + "dependencies": { + "memswap_limit": ["mem_limit"] + } + + }, + + "string_or_list": { + "oneOf": [ + {"type": "string"}, + {"$ref": "#/definitions/list_of_strings"} + ] + }, + + "list_of_strings": { + "type": "array", + "items": {"type": "string"}, + "uniqueItems": true + } + + }, + + "additionalProperties": false +} diff --git a/compose/service.py b/compose/service.py index 2e0490a5086..c72365cf99c 100644 --- a/compose/service.py +++ b/compose/service.py @@ -82,14 +82,8 @@ class NoSuchImageError(Exception): class Service(object): def __init__(self, name, client=None, project='default', links=None, external_links=None, volumes_from=None, net=None, **options): - if not re.match('^%s+$' % VALID_NAME_CHARS, name): - raise ConfigError('Invalid service name "%s" - only %s are allowed' % (name, VALID_NAME_CHARS)) if not re.match('^%s+$' % VALID_NAME_CHARS, project): raise ConfigError('Invalid project name "%s" - only %s are allowed' % (project, VALID_NAME_CHARS)) - if 'image' in options and 'build' in options: - raise ConfigError('Service %s has both an image and build path specified. A service can either be built to image or use an existing image, not both.' % name) - if 'image' not in options and 'build' not in options: - raise ConfigError('Service %s has neither an image nor a build path specified. Exactly one must be provided.' % name) self.name = name self.client = client diff --git a/requirements.txt b/requirements.txt index f9cec8372c7..64168768615 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ PyYAML==3.10 +jsonschema==2.5.1 docker-py==1.3.1 dockerpty==0.3.4 docopt==0.6.1 diff --git a/setup.py b/setup.py index 9bca4752de4..1f9c981d1b0 100644 --- a/setup.py +++ b/setup.py @@ -33,6 +33,7 @@ def find_version(*file_paths): 'docker-py >= 1.3.1, < 1.4', 'dockerpty >= 0.3.4, < 0.4', 'six >= 1.3.0, < 2', + 'jsonschema >= 2.5.1, < 3', ] diff --git a/tests/fixtures/extends/specify-file-as-self.yml b/tests/fixtures/extends/specify-file-as-self.yml index 7e249976235..c24f10bc92b 100644 --- a/tests/fixtures/extends/specify-file-as-self.yml +++ b/tests/fixtures/extends/specify-file-as-self.yml @@ -12,5 +12,6 @@ web: environment: - "BAZ=3" otherweb: + image: busybox environment: - "YEP=1" diff --git a/tests/unit/config_test.py b/tests/unit/config_test.py index 0046202030c..3ed394a92a6 100644 --- a/tests/unit/config_test.py +++ b/tests/unit/config_test.py @@ -20,10 +20,10 @@ def test_load(self): config.ConfigDetails( { 'foo': {'image': 'busybox'}, - 'bar': {'environment': ['FOO=1']}, + 'bar': {'image': 'busybox', 'environment': ['FOO=1']}, }, - 'working_dir', - 'filename.yml' + 'tests/fixtures/extends', + 'common.yml' ) ) @@ -32,13 +32,14 @@ def test_load(self): sorted([ { 'name': 'bar', + 'image': 'busybox', 'environment': {'FOO': '1'}, }, { 'name': 'foo', 'image': 'busybox', } - ]) + ], key=lambda d: d['name']) ) def test_load_throws_error_when_not_dict(self): @@ -327,23 +328,26 @@ def test_validation_fails_with_just_memswap_limit(self): When you set a 'memswap_limit' it is invalid config unless you also set a mem_limit """ - with self.assertRaises(config.ConfigurationError): - make_service_dict( - 'foo', { - 'memswap_limit': 2000000, - }, - 'tests/' + with self.assertRaisesRegexp(config.ConfigurationError, "u'mem_limit' is a dependency of u'memswap_limit'"): + config.load( + config.ConfigDetails( + { + 'foo': {'image': 'busybox', 'memswap_limit': 2000000}, + }, + 'tests/fixtures/extends', + 'filename.yml' + ) ) def test_validation_with_correct_memswap_values(self): - service_dict = make_service_dict( - 'foo', { - 'mem_limit': 1000000, - 'memswap_limit': 2000000, - }, - 'tests/' + service_dict = config.load( + config.ConfigDetails( + {'foo': {'image': 'busybox', 'mem_limit': 1000000, 'memswap_limit': 2000000}}, + 'tests/fixtures/extends', + 'common.yml' + ) ) - self.assertEqual(service_dict['memswap_limit'], 2000000) + self.assertEqual(service_dict[0]['memswap_limit'], 2000000) class EnvTest(unittest.TestCase): @@ -528,6 +532,7 @@ def test_self_referencing_file(self): { 'environment': {'YEP': '1'}, + 'image': 'busybox', 'name': 'otherweb' }, { @@ -553,36 +558,47 @@ def test_circular(self): ) def test_extends_validation_empty_dictionary(self): - dictionary = {'extends': None} - - def load_config(): - return make_service_dict('myweb', dictionary, working_dir='tests/fixtures/extends') - - self.assertRaisesRegexp(config.ConfigurationError, 'dictionary', load_config) - - dictionary['extends'] = {} - self.assertRaises(config.ConfigurationError, load_config) + with self.assertRaisesRegexp(config.ConfigurationError, 'service'): + config.load( + config.ConfigDetails( + { + 'web': {'image': 'busybox', 'extends': {}}, + }, + 'tests/fixtures/extends', + 'filename.yml' + ) + ) def test_extends_validation_missing_service_key(self): - dictionary = {'extends': {'file': 'common.yml'}} - - def load_config(): - return make_service_dict('myweb', dictionary, working_dir='tests/fixtures/extends') - - self.assertRaisesRegexp(config.ConfigurationError, 'service', load_config) + with self.assertRaisesRegexp(config.ConfigurationError, "u'service' is a required property"): + config.load( + config.ConfigDetails( + { + 'web': {'image': 'busybox', 'extends': {'file': 'common.yml'}}, + }, + 'tests/fixtures/extends', + 'filename.yml' + ) + ) def test_extends_validation_invalid_key(self): - dictionary = { - 'extends': - { - 'service': 'web', 'file': 'common.yml', 'what': 'is this' - } - } - - def load_config(): - return make_service_dict('myweb', dictionary, working_dir='tests/fixtures/extends') - - self.assertRaisesRegexp(config.ConfigurationError, 'what', load_config) + with self.assertRaisesRegexp(config.ConfigurationError, "'rogue_key' was unexpected"): + config.load( + config.ConfigDetails( + { + 'web': { + 'image': 'busybox', + 'extends': { + 'file': 'common.yml', + 'service': 'web', + 'rogue_key': 'is not allowed' + } + }, + }, + 'tests/fixtures/extends', + 'filename.yml' + ) + ) def test_extends_validation_no_file_key_no_filename_set(self): dictionary = {'extends': {'service': 'web'}} @@ -593,12 +609,18 @@ def load_config(): self.assertRaisesRegexp(config.ConfigurationError, 'file', load_config) def test_extends_validation_valid_config(self): - dictionary = {'extends': {'service': 'web', 'file': 'common.yml'}} - - def load_config(): - return make_service_dict('myweb', dictionary, working_dir='tests/fixtures/extends') + service = config.load( + config.ConfigDetails( + { + 'web': {'image': 'busybox', 'extends': {'service': 'web', 'file': 'common.yml'}}, + }, + 'tests/fixtures/extends', + 'common.yml' + ) + ) - self.assertIsInstance(load_config(), dict) + self.assertEquals(len(service), 1) + self.assertIsInstance(service[0], dict) def test_extends_file_defaults_to_self(self): """ diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index bc6b9e485e4..aa348466a40 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -49,7 +49,6 @@ def test_name_validations(self): Service('.__.', image='foo') def test_project_validation(self): - self.assertRaises(ConfigError, lambda: Service('bar')) self.assertRaises(ConfigError, lambda: Service(name='foo', project='>', image='foo')) Service(name='foo', project='bar.bar__', image='foo') From 76e6029f2132cfd531d069e57bb2e33060e84eb5 Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Wed, 29 Jul 2015 16:09:33 +0100 Subject: [PATCH 1047/4072] Replace service tests with config tests We validate the config against our schema before a service is created so checking whether a service name is valid at time of instantiation of the Service class is not needed. Signed-off-by: Mazz Mosley --- tests/unit/config_test.py | 21 +++++++++++++++++++++ tests/unit/service_test.py | 19 ------------------- 2 files changed, 21 insertions(+), 19 deletions(-) diff --git a/tests/unit/config_test.py b/tests/unit/config_test.py index 3ed394a92a6..f06cbab637e 100644 --- a/tests/unit/config_test.py +++ b/tests/unit/config_test.py @@ -59,6 +59,27 @@ def test_config_validation(self): ) make_service_dict('foo', {'ports': ['8000']}, 'tests/') + def test_config_invalid_service_names(self): + with self.assertRaises(config.ConfigurationError): + for invalid_name in ['?not?allowed', ' ', '', '!', '/', '\xe2']: + config.load( + config.ConfigDetails( + {invalid_name: {'image': 'busybox'}}, + 'working_dir', + 'filename.yml' + ) + ) + + def test_config_valid_service_names(self): + for valid_name in ['_', '-', '.__.', '_what-up.', 'what_.up----', 'whatup']: + config.load( + config.ConfigDetails( + {valid_name: {'image': 'busybox'}}, + 'tests/fixtures/extends', + 'common.yml' + ) + ) + class InterpolationTest(unittest.TestCase): @mock.patch.dict(os.environ) diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index aa348466a40..a99197e63cf 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -29,25 +29,6 @@ class ServiceTest(unittest.TestCase): def setUp(self): self.mock_client = mock.create_autospec(docker.Client) - def test_name_validations(self): - self.assertRaises(ConfigError, lambda: Service(name='', image='foo')) - - self.assertRaises(ConfigError, lambda: Service(name=' ', image='foo')) - self.assertRaises(ConfigError, lambda: Service(name='/', image='foo')) - self.assertRaises(ConfigError, lambda: Service(name='!', image='foo')) - self.assertRaises(ConfigError, lambda: Service(name='\xe2', image='foo')) - - Service('a', image='foo') - Service('foo', image='foo') - Service('foo-bar', image='foo') - Service('foo.bar', image='foo') - Service('foo_bar', image='foo') - Service('_', image='foo') - Service('___', image='foo') - Service('-', image='foo') - Service('--', image='foo') - Service('.__.', image='foo') - def test_project_validation(self): self.assertRaises(ConfigError, lambda: Service(name='foo', project='>', image='foo')) From 6c7c5985465d63a70e579ed3e253ca8d0f5d4b06 Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Wed, 29 Jul 2015 16:37:11 +0100 Subject: [PATCH 1048/4072] Format validation of ports Signed-off-by: Mazz Mosley --- compose/config/config.py | 26 ++++++++++++++++++++++++-- compose/schema.json | 11 +++++++++++ tests/unit/config_test.py | 22 ++++++++++++++++++++++ 3 files changed, 57 insertions(+), 2 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 1e793d9f645..6cffa2fe880 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -4,7 +4,7 @@ import yaml from collections import namedtuple import json -import jsonschema +from jsonschema import Draft4Validator, FormatChecker, ValidationError import six @@ -133,6 +133,28 @@ def get_config_path(base_dir): return os.path.join(path, winner) +@FormatChecker.cls_checks(format="ports", raises=ValidationError("Ports is incorrectly formatted.")) +def format_ports(instance): + def _is_valid(port): + if ':' in port or '/' in port: + return True + try: + int(port) + return True + except ValueError: + return False + return False + + if isinstance(instance, list): + for port in instance: + if not _is_valid(port): + return False + return True + elif isinstance(instance, str): + return _is_valid(instance) + return False + + def validate_against_schema(config): config_source_dir = os.path.dirname(os.path.abspath(__file__)) schema_file = os.path.join(config_source_dir, "schema.json") @@ -140,7 +162,7 @@ def validate_against_schema(config): with open(schema_file, "r") as schema_fh: schema = json.load(schema_fh) - validation_output = jsonschema.Draft4Validator(schema) + validation_output = Draft4Validator(schema, format_checker=FormatChecker(["ports"])) errors = [error.message for error in sorted(validation_output.iter_errors(config), key=str)] if errors: diff --git a/compose/schema.json b/compose/schema.json index 7c7e2d096c7..bf43ca36b06 100644 --- a/compose/schema.json +++ b/compose/schema.json @@ -14,6 +14,17 @@ "type": "object", "properties": { + "ports": { + "oneOf": [ + {"type": "string", "format": "ports"}, + { + "type": "array", + "items": {"type": "string"}, + "uniqueItems": true, + "format": "ports" + } + ] + }, "build": {"type": "string"}, "env_file": {"$ref": "#/definitions/string_or_list"}, "environment": { diff --git a/tests/unit/config_test.py b/tests/unit/config_test.py index f06cbab637e..f7e949d3cb7 100644 --- a/tests/unit/config_test.py +++ b/tests/unit/config_test.py @@ -80,6 +80,28 @@ def test_config_valid_service_names(self): ) ) + def test_config_invalid_ports_format_validation(self): + with self.assertRaises(config.ConfigurationError): + for invalid_ports in [{"1": "8000"}, "whatport"]: + config.load( + config.ConfigDetails( + {'web': {'image': 'busybox', 'ports': invalid_ports}}, + 'working_dir', + 'filename.yml' + ) + ) + + def test_config_valid_ports_format_validation(self): + valid_ports = [["8000", "9000"], "625", "8000:8050", ["8000/8050"]] + for ports in valid_ports: + config.load( + config.ConfigDetails( + {'web': {'image': 'busybox', 'ports': ports}}, + 'working_dir', + 'filename.yml' + ) + ) + class InterpolationTest(unittest.TestCase): @mock.patch.dict(os.environ) From 98c7a7da6110e72540810e888eec9d42c8172f9d Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Thu, 30 Jul 2015 10:53:41 +0100 Subject: [PATCH 1049/4072] Order properties alphabetically Improves readability. Signed-off-by: Mazz Mosley --- compose/schema.json | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/compose/schema.json b/compose/schema.json index bf43ca36b06..3e719fc4248 100644 --- a/compose/schema.json +++ b/compose/schema.json @@ -14,28 +14,15 @@ "type": "object", "properties": { - "ports": { - "oneOf": [ - {"type": "string", "format": "ports"}, - { - "type": "array", - "items": {"type": "string"}, - "uniqueItems": true, - "format": "ports" - } - ] - }, "build": {"type": "string"}, "env_file": {"$ref": "#/definitions/string_or_list"}, + "environment": { "oneOf": [ {"type": "object"}, {"type": "array", "items": {"type": "string"}, "uniqueItems": true} ] }, - "image": {"type": "string"}, - "mem_limit": {"type": "number"}, - "memswap_limit": {"type": "number"}, "extends": { "type": "object", @@ -46,6 +33,22 @@ }, "required": ["service"], "additionalProperties": false + }, + + "image": {"type": "string"}, + "mem_limit": {"type": "number"}, + "memswap_limit": {"type": "number"}, + + "ports": { + "oneOf": [ + {"type": "string", "format": "ports"}, + { + "type": "array", + "items": {"type": "string"}, + "uniqueItems": true, + "format": "ports" + } + ] } }, From 8d6694085d8e9a80223b6473e1f9a1939b3ef936 Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Thu, 30 Jul 2015 17:11:28 +0100 Subject: [PATCH 1050/4072] Include remaining valid config properties Signed-off-by: Mazz Mosley --- compose/config/config.py | 5 ---- compose/schema.json | 59 +++++++++++++++++++++++++++++++++++++--- 2 files changed, 55 insertions(+), 9 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 6cffa2fe880..27f845b75ba 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -525,11 +525,6 @@ def parse_labels(labels): if isinstance(labels, dict): return labels - raise ConfigurationError( - "labels \"%s\" must be a list or mapping" % - labels - ) - def split_label(label): if '=' in label: diff --git a/compose/schema.json b/compose/schema.json index 3e719fc4248..258f44ccac0 100644 --- a/compose/schema.json +++ b/compose/schema.json @@ -15,6 +15,19 @@ "properties": { "build": {"type": "string"}, + "cap_add": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "cap_drop": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "command": {"$ref": "#/definitions/string_or_list"}, + "container_name": {"type": "string"}, + "cpu_shares": {"type": "string"}, + "cpuset": {"type": "string"}, + "detach": {"type": "boolean"}, + "devices": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "dns": {"$ref": "#/definitions/string_or_list"}, + "dns_search": {"$ref": "#/definitions/string_or_list"}, + "dockerfile": {"type": "string"}, + "domainname": {"type": "string"}, + "entrypoint": {"type": "string"}, "env_file": {"$ref": "#/definitions/string_or_list"}, "environment": { @@ -24,6 +37,8 @@ ] }, + "expose": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "extends": { "type": "object", @@ -35,9 +50,29 @@ "additionalProperties": false }, + "extra_hosts": {"$ref": "#/definitions/list_or_dict"}, + "external_links": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "hostname": {"type": "string"}, "image": {"type": "string"}, + "labels": {"$ref": "#/definitions/list_or_dict"}, + "links": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "log_driver": {"type": "string"}, + + "log_opt": { + "type": "object", + + "properties": { + "address": {"type": "string"} + }, + "required": ["address"] + }, + + "mac_address": {"type": "string"}, "mem_limit": {"type": "number"}, "memswap_limit": {"type": "number"}, + "name": {"type": "string"}, + "net": {"type": "string"}, + "pid": {"type": "string"}, "ports": { "oneOf": [ @@ -49,8 +84,18 @@ "format": "ports" } ] - } + }, + "privileged": {"type": "string"}, + "read_only": {"type": "boolean"}, + "restart": {"type": "string"}, + "security_opt": {"type": "string"}, + "stdin_open": {"type": "string"}, + "tty": {"type": "string"}, + "user": {"type": "string"}, + "volumes": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "volumes_from": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "working_dir": {"type": "string"} }, "anyOf": [ @@ -70,8 +115,8 @@ "dependencies": { "memswap_limit": ["mem_limit"] - } - + }, + "additionalProperties": false }, "string_or_list": { @@ -85,9 +130,15 @@ "type": "array", "items": {"type": "string"}, "uniqueItems": true + }, + + "list_or_dict": { + "oneOf": [ + {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + {"type": "object"} + ] } }, - "additionalProperties": false } From d8aee782c876e1e6aa1d31ebaaf4fe566018fc26 Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Tue, 4 Aug 2015 17:43:33 +0100 Subject: [PATCH 1051/4072] Error handling jsonschema provides a rich error tree of info, by parsing each error we can pull out relevant info and re-write the error messages. This covers current error handling behaviour. This includes new error handling behaviour for types and formatting of the ports field. Signed-off-by: Mazz Mosley --- compose/config/config.py | 78 ++++++++++++++++++++++++++++++++++++++- compose/service.py | 4 +- tests/unit/config_test.py | 6 ++- 3 files changed, 81 insertions(+), 7 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 27f845b75ba..f2a89699d95 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -18,6 +18,8 @@ ) +VALID_NAME_CHARS = '[a-zA-Z0-9\._\-]' + DOCKER_CONFIG_KEYS = [ 'cap_add', 'cap_drop', @@ -155,6 +157,77 @@ def _is_valid(port): return False +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: + msg += " (did you mean '{}'?)".format(DOCKER_CONFIG_HINTS[error_key]) + return msg + + +def process_errors(errors): + """ + 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 _parse_key_from_error_msg(error): + return error.message.split("'")[1] + + root_msgs = [] + invalid_keys = [] + required = [] + type_errors = [] + + for error in errors: + # handle root level errors + if len(error.path) == 0: + if error.validator == 'type': + msg = "Top level object needs to be a dictionary. Check your .yml file that you have defined a service at the top level." + root_msgs.append(msg) + elif error.validator == 'additionalProperties': + invalid_service_name = _parse_key_from_error_msg(error) + msg = "Invalid service name '{}' - only {} characters are allowed".format(invalid_service_name, VALID_NAME_CHARS) + root_msgs.append(msg) + else: + root_msgs.append(error.message) + + else: + # handle service level errors + service_name = error.path[0] + + if error.validator == 'additionalProperties': + invalid_config_key = _parse_key_from_error_msg(error) + invalid_keys.append(get_unsupported_config_msg(service_name, invalid_config_key)) + elif error.validator == 'anyOf': + if 'image' in error.instance and 'build' in error.instance: + required.append("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)) + elif 'image' not in error.instance and 'build' not in error.instance: + required.append("Service '{}' has neither an image nor a build path specified. Exactly one must be provided.".format(service_name)) + else: + required.append(error.message) + elif error.validator == 'type': + msg = "a" + if error.validator_value == "array": + msg = "an" + + try: + config_key = error.path[1] + type_errors.append("Service '{}' has an invalid value for '{}', it should be {} {}".format(service_name, config_key, msg, error.validator_value)) + except IndexError: + config_key = error.path[0] + root_msgs.append("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(config_key)) + elif error.validator == 'required': + config_key = error.path[1] + required.append("Service '{}' option '{}' is invalid, {}".format(service_name, config_key, error.message)) + elif error.validator == 'dependencies': + dependency_key = error.validator_value.keys()[0] + required_keys = ",".join(error.validator_value[dependency_key]) + required.append("Invalid '{}' configuration for '{}' service: when defining '{}' you must set '{}' as well".format( + dependency_key, service_name, dependency_key, required_keys)) + + return "\n".join(root_msgs + invalid_keys + required + type_errors) + + def validate_against_schema(config): config_source_dir = os.path.dirname(os.path.abspath(__file__)) schema_file = os.path.join(config_source_dir, "schema.json") @@ -164,9 +237,10 @@ def validate_against_schema(config): validation_output = Draft4Validator(schema, format_checker=FormatChecker(["ports"])) - errors = [error.message for error in sorted(validation_output.iter_errors(config), key=str)] + errors = [error for error in sorted(validation_output.iter_errors(config), key=str)] if errors: - raise ConfigurationError("Validation failed, reason(s): {}".format("\n".join(errors))) + error_msg = process_errors(errors) + raise ConfigurationError("Validation failed, reason(s):\n{}".format(error_msg)) def load(config_details): diff --git a/compose/service.py b/compose/service.py index c72365cf99c..103840c3be2 100644 --- a/compose/service.py +++ b/compose/service.py @@ -12,7 +12,7 @@ from docker.utils import create_host_config, LogConfig from . import __version__ -from .config import DOCKER_CONFIG_KEYS, merge_environment +from .config import DOCKER_CONFIG_KEYS, merge_environment, VALID_NAME_CHARS from .const import ( DEFAULT_TIMEOUT, LABEL_CONTAINER_NUMBER, @@ -49,8 +49,6 @@ 'security_opt', ] -VALID_NAME_CHARS = '[a-zA-Z0-9\._\-]' - class BuildError(Exception): def __init__(self, service, reason): diff --git a/tests/unit/config_test.py b/tests/unit/config_test.py index f7e949d3cb7..c0ccead8ab3 100644 --- a/tests/unit/config_test.py +++ b/tests/unit/config_test.py @@ -371,7 +371,8 @@ def test_validation_fails_with_just_memswap_limit(self): When you set a 'memswap_limit' it is invalid config unless you also set a mem_limit """ - with self.assertRaisesRegexp(config.ConfigurationError, "u'mem_limit' is a dependency of u'memswap_limit'"): + expected_error_msg = "Invalid 'memswap_limit' configuration for 'foo' service: when defining 'memswap_limit' you must set 'mem_limit' as well" + with self.assertRaisesRegexp(config.ConfigurationError, expected_error_msg): config.load( config.ConfigDetails( { @@ -625,7 +626,8 @@ def test_extends_validation_missing_service_key(self): ) def test_extends_validation_invalid_key(self): - with self.assertRaisesRegexp(config.ConfigurationError, "'rogue_key' was unexpected"): + expected_error_msg = "Unsupported config option for 'web' service: 'rogue_key'" + with self.assertRaisesRegexp(config.ConfigurationError, expected_error_msg): config.load( config.ConfigDetails( { From ea3608e1f4c5894ebbdc21fddeab4746deda05d8 Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Wed, 5 Aug 2015 12:33:28 +0100 Subject: [PATCH 1052/4072] Improve test coverage for validation Signed-off-by: Mazz Mosley --- tests/unit/config_test.py | 50 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/tests/unit/config_test.py b/tests/unit/config_test.py index c0ccead8ab3..15657f878f9 100644 --- a/tests/unit/config_test.py +++ b/tests/unit/config_test.py @@ -102,6 +102,56 @@ def test_config_valid_ports_format_validation(self): ) ) + def test_config_hint(self): + expected_error_msg = "(did you mean 'privileged'?)" + with self.assertRaisesRegexp(config.ConfigurationError, expected_error_msg): + config.load( + config.ConfigDetails( + { + 'foo': {'image': 'busybox', 'privilige': 'something'}, + }, + 'tests/fixtures/extends', + 'filename.yml' + ) + ) + + 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(config.ConfigurationError, expected_error_msg): + config.load( + config.ConfigDetails( + { + 'foo': {'image': 'busybox', 'build': '.'}, + }, + 'tests/fixtures/extends', + 'filename.yml' + ) + ) + + def test_invalid_config_type_should_be_an_array(self): + expected_error_msg = "Service 'foo' has an invalid value for 'links', it should be an array" + with self.assertRaisesRegexp(config.ConfigurationError, expected_error_msg): + config.load( + config.ConfigDetails( + { + 'foo': {'image': 'busybox', 'links': 'an_link'}, + }, + 'tests/fixtures/extends', + 'filename.yml' + ) + ) + + def test_invalid_config_not_a_dictionary(self): + expected_error_msg = "Top level object needs to be a dictionary." + with self.assertRaisesRegexp(config.ConfigurationError, expected_error_msg): + config.load( + config.ConfigDetails( + ['foo', 'lol'], + 'tests/fixtures/extends', + 'filename.yml' + ) + ) + class InterpolationTest(unittest.TestCase): @mock.patch.dict(os.environ) From 0557b5dce6cbebe7bc24f415f4138d487524319b Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Wed, 5 Aug 2015 15:28:05 +0100 Subject: [PATCH 1053/4072] Remove dead code These functions weren't being called by anything. Signed-off-by: Mazz Mosley --- compose/config/config.py | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index f2a89699d95..31e5e9166cd 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -252,8 +252,6 @@ def load(config_details): validate_against_schema(config) for service_name, service_dict in list(config.items()): - if not isinstance(service_dict, dict): - raise ConfigurationError('Service "%s" doesn\'t have any configuration options. All top level keys in your docker-compose.yml must map to a dictionary of configuration options.' % service_name) loader = ServiceLoader(working_dir=working_dir, filename=filename) service_dict = loader.make_service_dict(service_name, service_dict) validate_paths(service_dict) @@ -427,18 +425,6 @@ def merge_environment(base, override): return env -def parse_links(links): - return dict(parse_link(l) for l in links) - - -def parse_link(link): - if ':' in link: - source, alias = link.split(':', 1) - return (alias, source) - else: - return (link, link) - - def get_env_files(options, working_dir=None): if 'env_file' not in options: return {} From 2e428f94ca3e0333a5b8b6469cb6fd528041cbe7 Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Thu, 6 Aug 2015 16:56:45 +0100 Subject: [PATCH 1054/4072] Refactor validation out Move validation out into its own file without causing circular import errors. Fix some of the tests to import from the right place. Also fix tests that were not using valid test data, as the validation schema is now firing telling you that you couldn't "just" have this dict without a build/image config key. Signed-off-by: Mazz Mosley --- compose/config/config.py | 145 ++----------------------------- compose/{ => config}/schema.json | 0 compose/config/validation.py | 134 ++++++++++++++++++++++++++++ compose/service.py | 3 +- tests/unit/config_test.py | 50 +++++------ 5 files changed, 165 insertions(+), 167 deletions(-) rename compose/{ => config}/schema.json (100%) create mode 100644 compose/config/validation.py diff --git a/compose/config/config.py b/compose/config/config.py index 31e5e9166cd..c1cfdb73d60 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -3,9 +3,6 @@ import sys import yaml from collections import namedtuple -import json -from jsonschema import Draft4Validator, FormatChecker, ValidationError - import six from compose.cli.utils import find_candidates_in_parent_dirs @@ -16,10 +13,9 @@ CircularReference, ComposeFileNotFound, ) +from .validation import validate_against_schema -VALID_NAME_CHARS = '[a-zA-Z0-9\._\-]' - DOCKER_CONFIG_KEYS = [ 'cap_add', 'cap_drop', @@ -69,22 +65,6 @@ 'name', ] -DOCKER_CONFIG_HINTS = { - 'cpu_share': 'cpu_shares', - 'add_host': 'extra_hosts', - 'hosts': 'extra_hosts', - 'extra_host': 'extra_hosts', - 'device': 'devices', - 'link': 'links', - 'memory_swap': 'memswap_limit', - 'port': 'ports', - 'privilege': 'privileged', - 'priviliged': 'privileged', - 'privilige': 'privileged', - 'volume': 'volumes', - 'workdir': 'working_dir', -} - SUPPORTED_FILENAMES = [ 'docker-compose.yml', @@ -135,122 +115,18 @@ def get_config_path(base_dir): return os.path.join(path, winner) -@FormatChecker.cls_checks(format="ports", raises=ValidationError("Ports is incorrectly formatted.")) -def format_ports(instance): - def _is_valid(port): - if ':' in port or '/' in port: - return True - try: - int(port) - return True - except ValueError: - return False - return False - - if isinstance(instance, list): - for port in instance: - if not _is_valid(port): - return False - return True - elif isinstance(instance, str): - return _is_valid(instance) - return False - - -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: - msg += " (did you mean '{}'?)".format(DOCKER_CONFIG_HINTS[error_key]) - return msg - - -def process_errors(errors): - """ - 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 _parse_key_from_error_msg(error): - return error.message.split("'")[1] - - root_msgs = [] - invalid_keys = [] - required = [] - type_errors = [] - - for error in errors: - # handle root level errors - if len(error.path) == 0: - if error.validator == 'type': - msg = "Top level object needs to be a dictionary. Check your .yml file that you have defined a service at the top level." - root_msgs.append(msg) - elif error.validator == 'additionalProperties': - invalid_service_name = _parse_key_from_error_msg(error) - msg = "Invalid service name '{}' - only {} characters are allowed".format(invalid_service_name, VALID_NAME_CHARS) - root_msgs.append(msg) - else: - root_msgs.append(error.message) - - else: - # handle service level errors - service_name = error.path[0] - - if error.validator == 'additionalProperties': - invalid_config_key = _parse_key_from_error_msg(error) - invalid_keys.append(get_unsupported_config_msg(service_name, invalid_config_key)) - elif error.validator == 'anyOf': - if 'image' in error.instance and 'build' in error.instance: - required.append("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)) - elif 'image' not in error.instance and 'build' not in error.instance: - required.append("Service '{}' has neither an image nor a build path specified. Exactly one must be provided.".format(service_name)) - else: - required.append(error.message) - elif error.validator == 'type': - msg = "a" - if error.validator_value == "array": - msg = "an" - - try: - config_key = error.path[1] - type_errors.append("Service '{}' has an invalid value for '{}', it should be {} {}".format(service_name, config_key, msg, error.validator_value)) - except IndexError: - config_key = error.path[0] - root_msgs.append("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(config_key)) - elif error.validator == 'required': - config_key = error.path[1] - required.append("Service '{}' option '{}' is invalid, {}".format(service_name, config_key, error.message)) - elif error.validator == 'dependencies': - dependency_key = error.validator_value.keys()[0] - required_keys = ",".join(error.validator_value[dependency_key]) - required.append("Invalid '{}' configuration for '{}' service: when defining '{}' you must set '{}' as well".format( - dependency_key, service_name, dependency_key, required_keys)) - - return "\n".join(root_msgs + invalid_keys + required + type_errors) - - -def validate_against_schema(config): - config_source_dir = os.path.dirname(os.path.abspath(__file__)) - schema_file = os.path.join(config_source_dir, "schema.json") - - with open(schema_file, "r") as schema_fh: - schema = json.load(schema_fh) - - validation_output = Draft4Validator(schema, format_checker=FormatChecker(["ports"])) - - errors = [error for error in sorted(validation_output.iter_errors(config), key=str)] - if errors: - error_msg = process_errors(errors) - raise ConfigurationError("Validation failed, reason(s):\n{}".format(error_msg)) - - def load(config_details): config, working_dir, filename = config_details + if not isinstance(config, dict): + raise ConfigurationError( + "Top level object needs to be a dictionary. Check your .yml file that you have defined a service at the top level." + ) + config = interpolate_environment_variables(config) + validate_against_schema(config) service_dicts = [] - validate_against_schema(config) - for service_name, service_dict in list(config.items()): loader = ServiceLoader(working_dir=working_dir, filename=filename) service_dict = loader.make_service_dict(service_name, service_dict) @@ -347,13 +223,6 @@ def validate_extended_service_dict(service_dict, filename, service): def process_container_options(service_dict, working_dir=None): - for k in service_dict: - if k not in ALLOWED_KEYS: - msg = "Unsupported config option for %s service: '%s'" % (service_dict['name'], k) - if k in DOCKER_CONFIG_HINTS: - msg += " (did you mean '%s'?)" % DOCKER_CONFIG_HINTS[k] - raise ConfigurationError(msg) - service_dict = service_dict.copy() if 'volumes' in service_dict and service_dict.get('volume_driver') is None: diff --git a/compose/schema.json b/compose/config/schema.json similarity index 100% rename from compose/schema.json rename to compose/config/schema.json diff --git a/compose/config/validation.py b/compose/config/validation.py new file mode 100644 index 00000000000..ba5803decb4 --- /dev/null +++ b/compose/config/validation.py @@ -0,0 +1,134 @@ +import os + +import json +from jsonschema import Draft4Validator, FormatChecker, ValidationError + +from .errors import ConfigurationError + + +DOCKER_CONFIG_HINTS = { + 'cpu_share': 'cpu_shares', + 'add_host': 'extra_hosts', + 'hosts': 'extra_hosts', + 'extra_host': 'extra_hosts', + 'device': 'devices', + 'link': 'links', + 'memory_swap': 'memswap_limit', + 'port': 'ports', + 'privilege': 'privileged', + 'priviliged': 'privileged', + 'privilige': 'privileged', + 'volume': 'volumes', + 'workdir': 'working_dir', +} + + +VALID_NAME_CHARS = '[a-zA-Z0-9\._\-]' + + +@FormatChecker.cls_checks(format="ports", raises=ValidationError("Ports is incorrectly formatted.")) +def format_ports(instance): + def _is_valid(port): + if ':' in port or '/' in port: + return True + try: + int(port) + return True + except ValueError: + return False + return False + + if isinstance(instance, list): + for port in instance: + if not _is_valid(port): + return False + return True + elif isinstance(instance, str): + return _is_valid(instance) + return False + + +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: + msg += " (did you mean '{}'?)".format(DOCKER_CONFIG_HINTS[error_key]) + return msg + + +def process_errors(errors): + """ + 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 _parse_key_from_error_msg(error): + return error.message.split("'")[1] + + root_msgs = [] + invalid_keys = [] + required = [] + type_errors = [] + + for error in errors: + # handle root level errors + if len(error.path) == 0: + if error.validator == 'type': + msg = "Top level object needs to be a dictionary. Check your .yml file that you have defined a service at the top level." + root_msgs.append(msg) + elif error.validator == 'additionalProperties': + invalid_service_name = _parse_key_from_error_msg(error) + msg = "Invalid service name '{}' - only {} characters are allowed".format(invalid_service_name, VALID_NAME_CHARS) + root_msgs.append(msg) + else: + root_msgs.append(error.message) + + else: + # handle service level errors + service_name = error.path[0] + + if error.validator == 'additionalProperties': + invalid_config_key = _parse_key_from_error_msg(error) + invalid_keys.append(get_unsupported_config_msg(service_name, invalid_config_key)) + elif error.validator == 'anyOf': + if 'image' in error.instance and 'build' in error.instance: + required.append("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)) + elif 'image' not in error.instance and 'build' not in error.instance: + required.append("Service '{}' has neither an image nor a build path specified. Exactly one must be provided.".format(service_name)) + else: + required.append(error.message) + elif error.validator == 'type': + msg = "a" + if error.validator_value == "array": + msg = "an" + + try: + config_key = error.path[1] + type_errors.append("Service '{}' has an invalid value for '{}', it should be {} {}".format(service_name, config_key, msg, error.validator_value)) + except IndexError: + config_key = error.path[0] + root_msgs.append("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(config_key)) + elif error.validator == 'required': + config_key = error.path[1] + required.append("Service '{}' option '{}' is invalid, {}".format(service_name, config_key, error.message)) + elif error.validator == 'dependencies': + dependency_key = error.validator_value.keys()[0] + required_keys = ",".join(error.validator_value[dependency_key]) + required.append("Invalid '{}' configuration for '{}' service: when defining '{}' you must set '{}' as well".format( + dependency_key, service_name, dependency_key, required_keys)) + + return "\n".join(root_msgs + invalid_keys + required + type_errors) + + +def validate_against_schema(config): + config_source_dir = os.path.dirname(os.path.abspath(__file__)) + schema_file = os.path.join(config_source_dir, "schema.json") + + with open(schema_file, "r") as schema_fh: + schema = json.load(schema_fh) + + validation_output = Draft4Validator(schema, format_checker=FormatChecker(["ports"])) + + errors = [error for error in sorted(validation_output.iter_errors(config), key=str)] + if errors: + error_msg = process_errors(errors) + raise ConfigurationError("Validation failed, reason(s):\n{}".format(error_msg)) diff --git a/compose/service.py b/compose/service.py index 103840c3be2..9b5e5928b93 100644 --- a/compose/service.py +++ b/compose/service.py @@ -12,7 +12,7 @@ from docker.utils import create_host_config, LogConfig from . import __version__ -from .config import DOCKER_CONFIG_KEYS, merge_environment, VALID_NAME_CHARS +from .config import DOCKER_CONFIG_KEYS, merge_environment from .const import ( DEFAULT_TIMEOUT, LABEL_CONTAINER_NUMBER, @@ -26,6 +26,7 @@ from .legacy import check_for_legacy_containers from .progress_stream import stream_output, StreamOutputError from .utils import json_hash, parallel_execute +from .config.validation import VALID_NAME_CHARS log = logging.getLogger(__name__) diff --git a/tests/unit/config_test.py b/tests/unit/config_test.py index 15657f878f9..9f690f111ea 100644 --- a/tests/unit/config_test.py +++ b/tests/unit/config_test.py @@ -5,6 +5,7 @@ from .. import unittest from compose.config import config +from compose.config.errors import ConfigurationError def make_service_dict(name, service_dict, working_dir): @@ -43,7 +44,7 @@ def test_load(self): ) def test_load_throws_error_when_not_dict(self): - with self.assertRaises(config.ConfigurationError): + with self.assertRaises(ConfigurationError): config.load( config.ConfigDetails( {'web': 'busybox:latest'}, @@ -52,15 +53,8 @@ def test_load_throws_error_when_not_dict(self): ) ) - def test_config_validation(self): - self.assertRaises( - config.ConfigurationError, - lambda: make_service_dict('foo', {'port': ['8000']}, 'tests/') - ) - make_service_dict('foo', {'ports': ['8000']}, 'tests/') - def test_config_invalid_service_names(self): - with self.assertRaises(config.ConfigurationError): + with self.assertRaises(ConfigurationError): for invalid_name in ['?not?allowed', ' ', '', '!', '/', '\xe2']: config.load( config.ConfigDetails( @@ -81,7 +75,7 @@ def test_config_valid_service_names(self): ) def test_config_invalid_ports_format_validation(self): - with self.assertRaises(config.ConfigurationError): + with self.assertRaises(ConfigurationError): for invalid_ports in [{"1": "8000"}, "whatport"]: config.load( config.ConfigDetails( @@ -104,7 +98,7 @@ def test_config_valid_ports_format_validation(self): def test_config_hint(self): expected_error_msg = "(did you mean 'privileged'?)" - with self.assertRaisesRegexp(config.ConfigurationError, expected_error_msg): + with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): config.load( config.ConfigDetails( { @@ -117,7 +111,7 @@ def test_config_hint(self): 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(config.ConfigurationError, expected_error_msg): + with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): config.load( config.ConfigDetails( { @@ -130,7 +124,7 @@ def test_invalid_config_build_and_image_specified(self): def test_invalid_config_type_should_be_an_array(self): expected_error_msg = "Service 'foo' has an invalid value for 'links', it should be an array" - with self.assertRaisesRegexp(config.ConfigurationError, expected_error_msg): + with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): config.load( config.ConfigDetails( { @@ -143,7 +137,7 @@ def test_invalid_config_type_should_be_an_array(self): def test_invalid_config_not_a_dictionary(self): expected_error_msg = "Top level object needs to be a dictionary." - with self.assertRaisesRegexp(config.ConfigurationError, expected_error_msg): + with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): config.load( config.ConfigDetails( ['foo', 'lol'], @@ -198,7 +192,7 @@ def test_volume_binding_with_environment_variable(self): os.environ['VOLUME_PATH'] = '/host/path' d = config.load( config.ConfigDetails( - config={'foo': {'volumes': ['${VOLUME_PATH}:/container/path']}}, + config={'foo': {'build': '.', 'volumes': ['${VOLUME_PATH}:/container/path']}}, working_dir='.', filename=None, ) @@ -422,7 +416,7 @@ def test_validation_fails_with_just_memswap_limit(self): a mem_limit """ expected_error_msg = "Invalid 'memswap_limit' configuration for 'foo' service: when defining 'memswap_limit' you must set 'mem_limit' as well" - with self.assertRaisesRegexp(config.ConfigurationError, expected_error_msg): + with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): config.load( config.ConfigDetails( { @@ -465,7 +459,7 @@ def test_parse_environment_as_dict(self): self.assertEqual(config.parse_environment(environment), environment) def test_parse_environment_invalid(self): - with self.assertRaises(config.ConfigurationError): + with self.assertRaises(ConfigurationError): config.parse_environment('a=b') def test_parse_environment_empty(self): @@ -519,7 +513,7 @@ def test_env_from_multiple_files(self): def test_env_nonexistent_file(self): options = {'env_file': 'nonexistent.env'} self.assertRaises( - config.ConfigurationError, + ConfigurationError, lambda: make_service_dict('foo', options, 'tests/fixtures/env'), ) @@ -545,7 +539,7 @@ def test_resolve_path(self): service_dict = config.load( config.ConfigDetails( - config={'foo': {'volumes': ['$HOSTENV:$CONTAINERENV']}}, + config={'foo': {'build': '.', 'volumes': ['$HOSTENV:$CONTAINERENV']}}, working_dir="tests/fixtures/env", filename=None, ) @@ -554,7 +548,7 @@ def test_resolve_path(self): service_dict = config.load( config.ConfigDetails( - config={'foo': {'volumes': ['/opt${HOSTENV}:/opt${CONTAINERENV}']}}, + config={'foo': {'build': '.', 'volumes': ['/opt${HOSTENV}:/opt${CONTAINERENV}']}}, working_dir="tests/fixtures/env", filename=None, ) @@ -652,7 +646,7 @@ def test_circular(self): ) def test_extends_validation_empty_dictionary(self): - with self.assertRaisesRegexp(config.ConfigurationError, 'service'): + with self.assertRaisesRegexp(ConfigurationError, 'service'): config.load( config.ConfigDetails( { @@ -664,7 +658,7 @@ def test_extends_validation_empty_dictionary(self): ) def test_extends_validation_missing_service_key(self): - with self.assertRaisesRegexp(config.ConfigurationError, "u'service' is a required property"): + with self.assertRaisesRegexp(ConfigurationError, "u'service' is a required property"): config.load( config.ConfigDetails( { @@ -677,7 +671,7 @@ def test_extends_validation_missing_service_key(self): def test_extends_validation_invalid_key(self): expected_error_msg = "Unsupported config option for 'web' service: 'rogue_key'" - with self.assertRaisesRegexp(config.ConfigurationError, expected_error_msg): + with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): config.load( config.ConfigDetails( { @@ -701,7 +695,7 @@ def test_extends_validation_no_file_key_no_filename_set(self): def load_config(): return make_service_dict('myweb', dictionary, working_dir='tests/fixtures/extends') - self.assertRaisesRegexp(config.ConfigurationError, 'file', load_config) + self.assertRaisesRegexp(ConfigurationError, 'file', load_config) def test_extends_validation_valid_config(self): service = config.load( @@ -750,19 +744,19 @@ def load_config(): } }, '.') - with self.assertRaisesRegexp(config.ConfigurationError, 'links'): + with self.assertRaisesRegexp(ConfigurationError, 'links'): other_config = {'web': {'links': ['db']}} with mock.patch.object(config, 'load_yaml', return_value=other_config): print load_config() - with self.assertRaisesRegexp(config.ConfigurationError, 'volumes_from'): + with self.assertRaisesRegexp(ConfigurationError, 'volumes_from'): other_config = {'web': {'volumes_from': ['db']}} with mock.patch.object(config, 'load_yaml', return_value=other_config): print load_config() - with self.assertRaisesRegexp(config.ConfigurationError, 'net'): + with self.assertRaisesRegexp(ConfigurationError, 'net'): other_config = {'web': {'net': 'container:db'}} with mock.patch.object(config, 'load_yaml', return_value=other_config): @@ -804,7 +798,7 @@ def setUp(self): self.abs_context_path = os.path.join(os.getcwd(), 'tests/fixtures/build-ctx') def test_nonexistent_path(self): - with self.assertRaises(config.ConfigurationError): + with self.assertRaises(ConfigurationError): config.load( config.ConfigDetails( { From df74b131ff8ca8f6055a1e16d4e9c7ff56462370 Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Fri, 7 Aug 2015 15:26:26 +0100 Subject: [PATCH 1055/4072] Use split_port for ports format check Rather than implement the logic a second time, use docker-py split_port function to test if the ports is valid. Signed-off-by: Mazz Mosley --- compose/config/schema.json | 13 ++++--------- compose/config/validation.py | 24 ++++++------------------ tests/unit/config_test.py | 4 ++-- 3 files changed, 12 insertions(+), 29 deletions(-) diff --git a/compose/config/schema.json b/compose/config/schema.json index 258f44ccac0..74f5edbbffc 100644 --- a/compose/config/schema.json +++ b/compose/config/schema.json @@ -75,15 +75,10 @@ "pid": {"type": "string"}, "ports": { - "oneOf": [ - {"type": "string", "format": "ports"}, - { - "type": "array", - "items": {"type": "string"}, - "uniqueItems": true, - "format": "ports" - } - ] + "type": "array", + "items": {"type": "string"}, + "uniqueItems": true, + "format": "ports" }, "privileged": {"type": "string"}, diff --git a/compose/config/validation.py b/compose/config/validation.py index ba5803decb4..15e0754cf8d 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -1,5 +1,6 @@ import os +from docker.utils.ports import split_port import json from jsonschema import Draft4Validator, FormatChecker, ValidationError @@ -26,26 +27,13 @@ VALID_NAME_CHARS = '[a-zA-Z0-9\._\-]' -@FormatChecker.cls_checks(format="ports", raises=ValidationError("Ports is incorrectly formatted.")) +@FormatChecker.cls_checks(format="ports", raises=ValidationError("Invalid port formatting, it should be '[[remote_ip:]remote_port:]port[/protocol]'")) def format_ports(instance): - def _is_valid(port): - if ':' in port or '/' in port: - return True - try: - int(port) - return True - except ValueError: - return False + try: + split_port(instance) + except ValueError: return False - - if isinstance(instance, list): - for port in instance: - if not _is_valid(port): - return False - return True - elif isinstance(instance, str): - return _is_valid(instance) - return False + return True def get_unsupported_config_msg(service_name, error_key): diff --git a/tests/unit/config_test.py b/tests/unit/config_test.py index 9f690f111ea..4e982bb49a0 100644 --- a/tests/unit/config_test.py +++ b/tests/unit/config_test.py @@ -76,7 +76,7 @@ def test_config_valid_service_names(self): def test_config_invalid_ports_format_validation(self): with self.assertRaises(ConfigurationError): - for invalid_ports in [{"1": "8000"}, "whatport"]: + for invalid_ports in [{"1": "8000"}, "whatport", "625", "8000:8050"]: config.load( config.ConfigDetails( {'web': {'image': 'busybox', 'ports': invalid_ports}}, @@ -86,7 +86,7 @@ def test_config_invalid_ports_format_validation(self): ) def test_config_valid_ports_format_validation(self): - valid_ports = [["8000", "9000"], "625", "8000:8050", ["8000/8050"]] + valid_ports = [["8000", "9000"], ["8000/8050"], ["8000"]] for ports in valid_ports: config.load( config.ConfigDetails( From 297941e460bbd1556e4fa5b81ee5816f3f4b0773 Mon Sep 17 00:00:00 2001 From: Yuval Kohavi Date: Mon, 6 Jul 2015 12:15:50 -0400 Subject: [PATCH 1056/4072] rebasing port range changes Signed-off-by: Yuval Kohavi --- compose/service.py | 47 ++----- .../ports-composefile/docker-compose.yml | 1 + tests/integration/cli_test.py | 5 +- tests/unit/service_test.py | 127 ++++-------------- 4 files changed, 40 insertions(+), 140 deletions(-) diff --git a/compose/service.py b/compose/service.py index 2e0490a5086..07f268c267e 100644 --- a/compose/service.py +++ b/compose/service.py @@ -10,6 +10,7 @@ import six from docker.errors import APIError from docker.utils import create_host_config, LogConfig +from docker.utils.ports import build_port_bindings, split_port from . import __version__ from .config import DOCKER_CONFIG_KEYS, merge_environment @@ -599,13 +600,13 @@ def _get_container_create_options( if 'ports' in container_options or 'expose' in self.options: ports = [] all_ports = container_options.get('ports', []) + self.options.get('expose', []) - for port in all_ports: - port = str(port) - if ':' in port: - port = port.split(':')[-1] - if '/' in port: - port = tuple(port.split('/')) - ports.append(port) + 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 override_options['binds'] = merge_volume_bindings( @@ -859,38 +860,6 @@ def parse_volume_spec(volume_config): return VolumeSpec(external, internal, mode) - -# Ports - - -def build_port_bindings(ports): - port_bindings = {} - for port in ports: - internal_port, external = split_port(port) - if internal_port in port_bindings: - port_bindings[internal_port].append(external) - else: - port_bindings[internal_port] = [external] - return port_bindings - - -def split_port(port): - parts = str(port).split(':') - if not 1 <= len(parts) <= 3: - raise ConfigError('Invalid port "%s", should be ' - '[[remote_ip:]remote_port:]port[/protocol]' % port) - - if len(parts) == 1: - internal_port, = parts - return internal_port, None - if len(parts) == 2: - external_port, internal_port = parts - return internal_port, external_port - - external_ip, external_port, internal_port = parts - return internal_port, (external_ip, external_port or None) - - # Labels diff --git a/tests/fixtures/ports-composefile/docker-compose.yml b/tests/fixtures/ports-composefile/docker-compose.yml index 9496ee08260..c213068defb 100644 --- a/tests/fixtures/ports-composefile/docker-compose.yml +++ b/tests/fixtures/ports-composefile/docker-compose.yml @@ -5,3 +5,4 @@ simple: ports: - '3000' - '49152:3001' + - '49153-49154:3002-3003' diff --git a/tests/integration/cli_test.py b/tests/integration/cli_test.py index 0e86c2792f0..e844fa2a3bf 100644 --- a/tests/integration/cli_test.py +++ b/tests/integration/cli_test.py @@ -334,6 +334,7 @@ def test_run_service_with_map_ports(self, __): # get port information port_random = container.get_local_port(3000) port_assigned = container.get_local_port(3001) + port_range = container.get_local_port(3002), container.get_local_port(3003) # close all one off containers we just created container.stop() @@ -342,6 +343,8 @@ def test_run_service_with_map_ports(self, __): self.assertNotEqual(port_random, None) self.assertIn("0.0.0.0", port_random) self.assertEqual(port_assigned, "0.0.0.0:49152") + self.assertEqual(port_range[0], "0.0.0.0:49153") + self.assertEqual(port_range[1], "0.0.0.0:49154") def test_rm(self): service = self.project.get_service('simple') @@ -456,7 +459,7 @@ def get_port(number, mock_stdout): self.assertEqual(get_port(3000), container.get_local_port(3000)) self.assertEqual(get_port(3001), "0.0.0.0:49152") - self.assertEqual(get_port(3002), "") + self.assertEqual(get_port(3002), "0.0.0.0:49153") def test_port_with_scale(self): diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index bc6b9e485e4..151fcee94bb 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -5,7 +5,6 @@ import mock import docker -from docker.utils import LogConfig from compose.service import Service from compose.container import Container @@ -13,14 +12,11 @@ from compose.service import ( ConfigError, NeedsBuildError, - NoSuchImageError, - build_port_bindings, build_volume_binding, get_container_data_volumes, merge_volume_bindings, parse_repository_tag, parse_volume_spec, - split_port, ) @@ -108,48 +104,6 @@ def test_get_volumes_from_service_no_container(self): self.assertEqual(service._get_volumes_from(), [container_id]) from_service.create_container.assert_called_once_with() - def test_split_port_with_host_ip(self): - internal_port, external_port = split_port("127.0.0.1:1000:2000") - self.assertEqual(internal_port, "2000") - self.assertEqual(external_port, ("127.0.0.1", "1000")) - - def test_split_port_with_protocol(self): - internal_port, external_port = split_port("127.0.0.1:1000:2000/udp") - self.assertEqual(internal_port, "2000/udp") - self.assertEqual(external_port, ("127.0.0.1", "1000")) - - def test_split_port_with_host_ip_no_port(self): - internal_port, external_port = split_port("127.0.0.1::2000") - self.assertEqual(internal_port, "2000") - self.assertEqual(external_port, ("127.0.0.1", None)) - - def test_split_port_with_host_port(self): - internal_port, external_port = split_port("1000:2000") - self.assertEqual(internal_port, "2000") - self.assertEqual(external_port, "1000") - - def test_split_port_no_host_port(self): - internal_port, external_port = split_port("2000") - self.assertEqual(internal_port, "2000") - self.assertEqual(external_port, None) - - def test_split_port_invalid(self): - with self.assertRaises(ConfigError): - split_port("0.0.0.0:1000:2000:tcp") - - def test_build_port_bindings_with_one_port(self): - port_bindings = build_port_bindings(["127.0.0.1:1000:1000"]) - self.assertEqual(port_bindings["1000"], [("127.0.0.1", "1000")]) - - def test_build_port_bindings_with_matching_internal_ports(self): - port_bindings = build_port_bindings(["127.0.0.1:1000:1000", "127.0.0.1:2000:1000"]) - self.assertEqual(port_bindings["1000"], [("127.0.0.1", "1000"), ("127.0.0.1", "2000")]) - - def test_build_port_bindings_with_nonmatching_internal_ports(self): - port_bindings = build_port_bindings(["127.0.0.1:1000:1000", "127.0.0.1:2000:2000"]) - self.assertEqual(port_bindings["1000"], [("127.0.0.1", "1000")]) - self.assertEqual(port_bindings["2000"], [("127.0.0.1", "2000")]) - def test_split_domainname_none(self): service = Service('foo', image='foo', hostname='name', client=self.mock_client) self.mock_client.containers.return_value = [] @@ -157,23 +111,6 @@ def test_split_domainname_none(self): self.assertEqual(opts['hostname'], 'name', 'hostname') self.assertFalse('domainname' in opts, 'domainname') - def test_memory_swap_limit(self): - service = Service(name='foo', image='foo', hostname='name', client=self.mock_client, mem_limit=1000000000, memswap_limit=2000000000) - self.mock_client.containers.return_value = [] - opts = service._get_container_create_options({'some': 'overrides'}, 1) - self.assertEqual(opts['memswap_limit'], 2000000000) - self.assertEqual(opts['mem_limit'], 1000000000) - - def test_log_opt(self): - log_opt = {'address': 'tcp://192.168.0.42:123'} - service = Service(name='foo', image='foo', hostname='name', client=self.mock_client, log_driver='syslog', log_opt=log_opt) - self.mock_client.containers.return_value = [] - opts = service._get_container_create_options({'some': 'overrides'}, 1) - - self.assertIsInstance(opts['host_config']['LogConfig'], LogConfig) - self.assertEqual(opts['host_config']['LogConfig'].type, 'syslog') - self.assertEqual(opts['host_config']['LogConfig'].config, log_opt) - def test_split_domainname_fqdn(self): service = Service( 'foo', @@ -229,10 +166,11 @@ def test_get_container(self, mock_container_class): @mock.patch('compose.service.log', autospec=True) def test_pull_image(self, mock_log): service = Service('foo', client=self.mock_client, image='someimage:sometag') - service.pull() + service.pull(insecure_registry=True) self.mock_client.pull.assert_called_once_with( 'someimage', tag='sometag', + insecure_registry=True, stream=True) mock_log.info.assert_called_once_with('Pulling foo (someimage:sometag)...') @@ -242,8 +180,26 @@ def test_pull_image_no_tag(self): self.mock_client.pull.assert_called_once_with( 'ababab', tag='latest', + insecure_registry=False, stream=True) + def test_create_container_from_insecure_registry(self): + service = Service('foo', client=self.mock_client, image='someimage:sometag') + images = [] + + def pull(repo, tag=None, insecure_registry=False, **kwargs): + self.assertEqual('someimage', repo) + self.assertEqual('sometag', tag) + self.assertTrue(insecure_registry) + images.append({'Id': 'abc123'}) + return [] + + service.image = lambda: images[0] if images else None + self.mock_client.pull = pull + + service.create_container(insecure_registry=True) + self.assertEqual(1, len(images)) + @mock.patch('compose.service.Container', autospec=True) def test_recreate_container(self, _): mock_container = mock.create_autospec(Container) @@ -287,7 +243,7 @@ def pull(repo, tag=None, **kwargs): images.append({'Id': 'abc123'}) return [] - service.image = lambda *args, **kwargs: mock_get_image(images) + service.image = lambda: images[0] if images else None self.mock_client.pull = pull service.create_container() @@ -297,7 +253,7 @@ def test_create_container_with_build(self): service = Service('foo', client=self.mock_client, build='.') images = [] - service.image = lambda *args, **kwargs: mock_get_image(images) + service.image = lambda *args, **kwargs: images[0] if images else None service.build = lambda: images.append({'Id': 'abc123'}) service.create_container(do_build=True) @@ -312,7 +268,7 @@ def test_create_container_no_build(self): def test_create_container_no_build_but_needs_build(self): service = Service('foo', client=self.mock_client, build='.') - service.image = lambda *args, **kwargs: mock_get_image([]) + service.image = lambda: None with self.assertRaises(NeedsBuildError): service.create_container(do_build=False) @@ -329,13 +285,6 @@ def test_build_does_not_pull(self): self.assertFalse(self.mock_client.build.call_args[1]['pull']) -def mock_get_image(images): - if images: - return images[0] - else: - raise NoSuchImageError() - - class ServiceVolumesTest(unittest.TestCase): def setUp(self): @@ -353,13 +302,14 @@ def test_parse_volume_spec_with_mode(self): spec = parse_volume_spec('external:interval:ro') self.assertEqual(spec, ('external', 'interval', 'ro')) - spec = parse_volume_spec('external:interval:z') - self.assertEqual(spec, ('external', 'interval', 'z')) - def test_parse_volume_spec_too_many_parts(self): with self.assertRaises(ConfigError): parse_volume_spec('one:two:three:four') + def test_parse_volume_bad_mode(self): + with self.assertRaises(ConfigError): + parse_volume_spec('one:two:notrw') + def test_build_volume_binding(self): binding = build_volume_binding(parse_volume_spec('/outside:/inside')) self.assertEqual(binding, ('/inside', '/outside:/inside:rw')) @@ -488,26 +438,3 @@ def test_different_host_path_in_container_json(self): create_options['host_config']['Binds'], ['/mnt/sda1/host/path:/data:rw'], ) - - def test_create_with_special_volume_mode(self): - self.mock_client.inspect_image.return_value = {'Id': 'imageid'} - - create_calls = [] - - def create_container(*args, **kwargs): - create_calls.append((args, kwargs)) - return {'Id': 'containerid'} - - self.mock_client.create_container = create_container - - volumes = ['/tmp:/foo:z'] - - Service( - 'web', - client=self.mock_client, - image='busybox', - volumes=volumes, - ).create_container() - - self.assertEqual(len(create_calls), 1) - self.assertEqual(create_calls[0][1]['host_config']['Binds'], volumes) From 0fdd977b06f95d3eadf04aa975ade85b8cc00e5f Mon Sep 17 00:00:00 2001 From: Yuval Kohavi Date: Tue, 28 Jul 2015 17:00:24 -0400 Subject: [PATCH 1057/4072] fixed merge issue from previous commit Signed-off-by: Yuval Kohavi --- tests/unit/service_test.py | 83 +++++++++++++++++++++++++------------- 1 file changed, 56 insertions(+), 27 deletions(-) diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 151fcee94bb..77a8138d0bb 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -5,6 +5,7 @@ import mock import docker +from docker.utils import LogConfig from compose.service import Service from compose.container import Container @@ -12,6 +13,7 @@ from compose.service import ( ConfigError, NeedsBuildError, + NoSuchImageError, build_volume_binding, get_container_data_volumes, merge_volume_bindings, @@ -111,6 +113,23 @@ def test_split_domainname_none(self): self.assertEqual(opts['hostname'], 'name', 'hostname') self.assertFalse('domainname' in opts, 'domainname') + def test_memory_swap_limit(self): + service = Service(name='foo', image='foo', hostname='name', client=self.mock_client, mem_limit=1000000000, memswap_limit=2000000000) + self.mock_client.containers.return_value = [] + opts = service._get_container_create_options({'some': 'overrides'}, 1) + self.assertEqual(opts['memswap_limit'], 2000000000) + self.assertEqual(opts['mem_limit'], 1000000000) + + def test_log_opt(self): + log_opt = {'address': 'tcp://192.168.0.42:123'} + service = Service(name='foo', image='foo', hostname='name', client=self.mock_client, log_driver='syslog', log_opt=log_opt) + self.mock_client.containers.return_value = [] + opts = service._get_container_create_options({'some': 'overrides'}, 1) + + self.assertIsInstance(opts['host_config']['LogConfig'], LogConfig) + self.assertEqual(opts['host_config']['LogConfig'].type, 'syslog') + self.assertEqual(opts['host_config']['LogConfig'].config, log_opt) + def test_split_domainname_fqdn(self): service = Service( 'foo', @@ -166,11 +185,10 @@ def test_get_container(self, mock_container_class): @mock.patch('compose.service.log', autospec=True) def test_pull_image(self, mock_log): service = Service('foo', client=self.mock_client, image='someimage:sometag') - service.pull(insecure_registry=True) + service.pull() self.mock_client.pull.assert_called_once_with( 'someimage', tag='sometag', - insecure_registry=True, stream=True) mock_log.info.assert_called_once_with('Pulling foo (someimage:sometag)...') @@ -180,26 +198,8 @@ def test_pull_image_no_tag(self): self.mock_client.pull.assert_called_once_with( 'ababab', tag='latest', - insecure_registry=False, stream=True) - def test_create_container_from_insecure_registry(self): - service = Service('foo', client=self.mock_client, image='someimage:sometag') - images = [] - - def pull(repo, tag=None, insecure_registry=False, **kwargs): - self.assertEqual('someimage', repo) - self.assertEqual('sometag', tag) - self.assertTrue(insecure_registry) - images.append({'Id': 'abc123'}) - return [] - - service.image = lambda: images[0] if images else None - self.mock_client.pull = pull - - service.create_container(insecure_registry=True) - self.assertEqual(1, len(images)) - @mock.patch('compose.service.Container', autospec=True) def test_recreate_container(self, _): mock_container = mock.create_autospec(Container) @@ -243,7 +243,7 @@ def pull(repo, tag=None, **kwargs): images.append({'Id': 'abc123'}) return [] - service.image = lambda: images[0] if images else None + service.image = lambda *args, **kwargs: mock_get_image(images) self.mock_client.pull = pull service.create_container() @@ -253,7 +253,7 @@ def test_create_container_with_build(self): service = Service('foo', client=self.mock_client, build='.') images = [] - service.image = lambda *args, **kwargs: images[0] if images else None + service.image = lambda *args, **kwargs: mock_get_image(images) service.build = lambda: images.append({'Id': 'abc123'}) service.create_container(do_build=True) @@ -268,7 +268,7 @@ def test_create_container_no_build(self): def test_create_container_no_build_but_needs_build(self): service = Service('foo', client=self.mock_client, build='.') - service.image = lambda: None + service.image = lambda *args, **kwargs: mock_get_image([]) with self.assertRaises(NeedsBuildError): service.create_container(do_build=False) @@ -285,6 +285,13 @@ def test_build_does_not_pull(self): self.assertFalse(self.mock_client.build.call_args[1]['pull']) +def mock_get_image(images): + if images: + return images[0] + else: + raise NoSuchImageError() + + class ServiceVolumesTest(unittest.TestCase): def setUp(self): @@ -302,14 +309,13 @@ def test_parse_volume_spec_with_mode(self): spec = parse_volume_spec('external:interval:ro') self.assertEqual(spec, ('external', 'interval', 'ro')) + spec = parse_volume_spec('external:interval:z') + self.assertEqual(spec, ('external', 'interval', 'z')) + def test_parse_volume_spec_too_many_parts(self): with self.assertRaises(ConfigError): parse_volume_spec('one:two:three:four') - def test_parse_volume_bad_mode(self): - with self.assertRaises(ConfigError): - parse_volume_spec('one:two:notrw') - def test_build_volume_binding(self): binding = build_volume_binding(parse_volume_spec('/outside:/inside')) self.assertEqual(binding, ('/inside', '/outside:/inside:rw')) @@ -438,3 +444,26 @@ def test_different_host_path_in_container_json(self): create_options['host_config']['Binds'], ['/mnt/sda1/host/path:/data:rw'], ) + + def test_create_with_special_volume_mode(self): + self.mock_client.inspect_image.return_value = {'Id': 'imageid'} + + create_calls = [] + + def create_container(*args, **kwargs): + create_calls.append((args, kwargs)) + return {'Id': 'containerid'} + + self.mock_client.create_container = create_container + + volumes = ['/tmp:/foo:z'] + + Service( + 'web', + client=self.mock_client, + image='busybox', + volumes=volumes, + ).create_container() + + self.assertEqual(len(create_calls), 1) + self.assertEqual(create_calls[0][1]['host_config']['Binds'], volumes) From 557cbb616c887d35c0c79e2ed2a79c0344f58de4 Mon Sep 17 00:00:00 2001 From: Yuval Kohavi Date: Tue, 28 Jul 2015 17:26:32 -0400 Subject: [PATCH 1058/4072] ports documentation Signed-off-by: Yuval Kohavi --- docs/yml.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/yml.md b/docs/yml.md index 18551bf22fa..17dbc59ad28 100644 --- a/docs/yml.md +++ b/docs/yml.md @@ -106,7 +106,7 @@ An entry with the ip address and hostname will be created in `/etc/hosts` inside ### ports Expose ports. Either specify both ports (`HOST:CONTAINER`), or just the container -port (a random host port will be chosen). +port (a random host port will be chosen). You can specify a port range instead of a single port (`START-END`). If you use a range for the container ports, you may specify a range for the host ports as well. both ranges must be of equal size. > **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 @@ -115,9 +115,12 @@ port (a random host port will be chosen). 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" ### expose @@ -410,3 +413,4 @@ dollar sign (`$$`). - [Command line reference](cli.md) - [Compose environment variables](env.md) - [Compose command line completion](completion.md) + From d1455acb6469f1a36376f5f765ed5188336673ee Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Fri, 7 Aug 2015 16:30:00 +0100 Subject: [PATCH 1059/4072] Update docs inline with feedback Signed-off-by: Mazz Mosley --- docs/yml.md | 31 +++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/docs/yml.md b/docs/yml.md index 17dbc59ad28..1055004254a 100644 --- a/docs/yml.md +++ b/docs/yml.md @@ -105,13 +105,25 @@ An entry with the ip address and hostname will be created in `/etc/hosts` inside ### ports -Expose ports. Either specify both ports (`HOST:CONTAINER`), or just the container -port (a random host port will be chosen). You can specify a port range instead of a single port (`START-END`). If you use a range for the container ports, you may specify a range for the host ports as well. both ranges must be of equal size. +Makes an exposed port accessible on a host and the port is available to +any client that can reach that host. Docker binds the exposed port to a random +port on the host within an *ephemeral port range* defined by +`/proc/sys/net/ipv4/ip_local_port_range`. You can also map to a specific port or range of ports. -> **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. +Acceptable formats for the `ports` value are: + +* `containerPort` +* `ip:hostPort:containerPort` +* `ip::containerPort` +* `hostPort:containerPort` + +You can specify a range for both the `hostPort` and the `containerPort` values. +When specifying ranges, the container port values in the range must match the +number of host port values in the range, for example, +`1234-1236:1234-1236/tcp`. Once a host is running, use the 'docker-compose port' command +to see the actual mapping. + +The following configuration shows examples of the port formats in use: ports: - "3000" @@ -122,6 +134,13 @@ port (a random host port will be chosen). You can specify a port range instead o - "127.0.0.1:8001:8001" - "127.0.0.1:5000-5010:5000-5010" + +When mapping ports, in the `hostPort:containerPort` format, you may +experience erroneous results when using a container port lower than 60. This +happens because YAML parses numbers in the format `xx:yy` as sexagesimal (base +60). To avoid this problem, always explicitly specify your port +mappings as strings. + ### expose Expose ports without publishing them to the host machine - they'll only be From 11adca9324b417729d8eafdab1852767455f8cca Mon Sep 17 00:00:00 2001 From: Veres Lajos Date: Fri, 7 Aug 2015 21:59:14 +0100 Subject: [PATCH 1060/4072] typofix - https://github.com/vlajos/misspell_fixer Signed-off-by: Veres Lajos --- tests/unit/config_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/config_test.py b/tests/unit/config_test.py index 0046202030c..2cd3e005596 100644 --- a/tests/unit/config_test.py +++ b/tests/unit/config_test.py @@ -9,7 +9,7 @@ def make_service_dict(name, service_dict, working_dir): """ - Test helper function to contruct a ServiceLoader + Test helper function to construct a ServiceLoader """ return config.ServiceLoader(working_dir=working_dir).make_service_dict(name, service_dict) From 7c128b46a1daff8e333ecdd611eaf0c6e42bb197 Mon Sep 17 00:00:00 2001 From: Mary Anthony Date: Wed, 5 Aug 2015 10:38:40 -0700 Subject: [PATCH 1061/4072] - Closes #1811 for Toolbox - Updating with comments Signed-off-by: Mary Anthony --- docs/install.md | 78 +++++++++++++++++++++++++++++++------------------ 1 file changed, 49 insertions(+), 29 deletions(-) diff --git a/docs/install.md b/docs/install.md index adb32fd50b0..fa36791927d 100644 --- a/docs/install.md +++ b/docs/install.md @@ -12,50 +12,67 @@ weight=4 # Install Docker Compose -To install Compose, you'll need to install Docker first. You'll then install -Compose with a `curl` command. +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. -## Install Docker +Depending on how your system is configured, you may require `sudo` access to +install Compose. If your system requires `sudo`, you will receive "Permission +denied" errors when installing Compose. If this is the case for you, preface the +install commands with `sudo` to install. -First, install Docker version 1.7.1 or greater: +To install Compose, do the following: -- [Instructions for Mac OS X](http://docs.docker.com/installation/mac/) -- [Instructions for Ubuntu](http://docs.docker.com/installation/ubuntulinux/) -- [Instructions for other systems](http://docs.docker.com/installation/) +1. Install Docker Engine version 1.7.1 or greater: -## Install Compose + * Mac OS X installation (installs both Engine and Compose) + + * Ubuntu installation + + * other system installations + +2. Mac OS X users are done installing. Others should continue to the next step. + +3. Go to the repository release page. -To install Compose, run the following commands: +4. Enter the `curl` command in your termial. - curl -L https://github.com/docker/compose/releases/download/1.3.3/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose - chmod +x /usr/local/bin/docker-compose + The command has the following format: -> 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 above, then `exit`. + curl -L https://github.com/docker/compose/releases/download/VERSION_NUM/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose + + If you have problems installing with `curl`, you can use `pip` instead: `pip install -U docker-compose` + +4. Apply executable permissions to the binary: -Optionally, you can also install [command completion](completion.md) for the -bash and zsh shell. + $ chmod +x /usr/local/bin/docker-compose -> **Note:** Some older Mac OS X CPU architectures are incompatible with the binary. If you receive an "Illegal instruction: 4" error after installing, you should install using the `pip` command instead. +5. Optionally, install [command completion](completion.md) for the +`bash` and `zsh` shell. -Compose is available for OS X and 64-bit Linux. If you're on another platform, -Compose can also be installed as a Python package: +6. Test the installation. - $ sudo pip install -U docker-compose + $ docker-compose --version + docker-compose version: 1.4.0 -No further steps are required; Compose should now be successfully installed. -You can test the installation by running `docker-compose --version`. +## Upgrading -### 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 you're coming 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 migrate them with the following command: -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 migrate them with the following command: + $ docker-compose migrate-to-labels - docker-compose migrate-to-labels +Alternatively, if you're not worried about keeping them, you can remove them &endash; +Compose will just create new ones. -Alternatively, if you're not worried about keeping them, you can remove them - Compose will just create new ones. - - docker rm -f myapp_web_1 myapp_db_1 ... + $ docker rm -f -v myapp_web_1 myapp_db_1 ... ## Uninstallation @@ -69,10 +86,13 @@ 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. +>**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. -## Compose documentation +## Where to go next - [User guide](/) - [Get started with Django](django.md) From 4390362366babb04b0b68759814206d2faff2b63 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 5 Aug 2015 15:39:07 +0100 Subject: [PATCH 1062/4072] Test against Docker 1.8.0 RC3 Signed-off-by: Aanand Prasad --- Dockerfile | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index a0e7f14f91c..7c0482323b2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -48,11 +48,13 @@ RUN set -ex; \ rm -rf pip-7.0.1; \ rm pip-7.0.1.tar.gz -ENV ALL_DOCKER_VERSIONS 1.7.1 +ENV ALL_DOCKER_VERSIONS 1.7.1 1.8.0-rc3 RUN set -ex; \ curl https://get.docker.com/builds/Linux/x86_64/docker-1.7.1 -o /usr/local/bin/docker-1.7.1; \ - chmod +x /usr/local/bin/docker-1.7.1 + chmod +x /usr/local/bin/docker-1.7.1; \ + curl https://test.docker.com/builds/Linux/x86_64/docker-1.8.0-rc3 -o /usr/local/bin/docker-1.8.0-rc3; \ + chmod +x /usr/local/bin/docker-1.8.0-rc3 # Set the default Docker to be run RUN ln -s /usr/local/bin/docker-1.7.1 /usr/local/bin/docker From 450ba978c171da5b673391d426db66a3f888a805 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 10 Aug 2015 12:48:25 +0100 Subject: [PATCH 1063/4072] Merge pull request #1812 from moxiegirl/install-update-for-1811 Closes #1811 for Toolbox (cherry picked from commit 6cb8e512f211696d6c1ba5482606d791eaf302a3) Signed-off-by: Aanand Prasad Conflicts: docs/install.md --- docker | Bin 0 -> 7741520 bytes docs/install.md | 78 ++++++++++++++++++++++++++++++------------------ 2 files changed, 49 insertions(+), 29 deletions(-) create mode 100755 docker diff --git a/docker b/docker new file mode 100755 index 0000000000000000000000000000000000000000..f24f3613f9f2b4db619a75df467e07b4dc37aa99 GIT binary patch literal 7741520 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

bsWYWrI+`X5&cK>^5XxLUbbDmEE5R-t9tp& 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�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 z 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≫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

47hjiq|LXYE{U-u<8;T1F4 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ξqM15e+T0QhNUB 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¥L`eg-&{%s!}S6JUzdb?|jdL6+pvs!ud>kI0+cx6pHAQAc~@c&f-yIMuCTv zmvLHN1a>sd

pHyn|oSzjFmfL@fM0VPOuw7C5gxFGe*9aptC?s(t~fb{=lPbo#Cc z*UfbLN*vlgojXycN=4^G^)*qxnm^(GhRfs{!+`C$buNjN%Y9>%6!>ELABq1udGnX@ zCy4mtL*I~0$)>fLoW@>T+MLF+!|YDu_g5lLV>3Ww1{ZhsBvIZOp9zAe zwy;5PR2m4z2n5Y@zsLHUUYOox-YTp4!0$*KPEi(1x*sChp?Sh8-dx;#Ile)k$b4YW z!zDjEVO1?2a=qge`PPRN_{4ZR?#t2~Oc}@KevBTlzO*m-55ea{_y;U6cP{1GAmQ^WAaWe{*w#=E=+o(%xS%+)m{i7b+=w1HLC7uOD8Dc3>(Hfynt{a<&!n6kJ}z z7v3hQ%0`nuir1fH4%V7T&~U!a%y6cL?|?-*kUE9)*MH#S;0#=c!72(aB%nY;GBY&e zsl2XHJnoI#@pxl_k`{B)kUpnul7?$oX1MYW!yzjnxX#--EnFAnM(Eok4P3ivxNwXb z`J-2wsZ-Lbc8jM@$&c^bcy))Y(fRRIf0znvY!fD*k03yMmI(`MachCI9IMtbNIy8M zWwEnFuBibc6UHp1^lu!dSbsRrKu7MbuB+H;Y~6&(L9_urCCP0V_Th*VFUTjiVcD?w zP3HcD+hxM-S6KLh@6>^~%cI=>`hkqxp8Q>u+nWqZOV!+d5vo2Vn*ix5Q}o&<6Xtw^ zZIxU*HD4Bi=iw+8V4>4+l8jWOYi?pXBdg_8X9gCKqe3cgx2v>$2&v5Nn?*V=O4E6{ zbPm$^KH>SN{)g)M7*JW~CJJ@v>bwjDemz>Pa#@F)1PHmFk%j4`}pyaXjH%r+PkxJH10=i?<0TnKf_H`_`*vMJkn z2|Gb$dksx#8vGB`_~#R~BK9exYRpb$IqN=Dn8$n9`at+`HD9g}hwvnCoP%Ax1uAz?k8!nsw*R>-VHHx7WIH{hNqzTu7E+hw zhe-{l38{bj%1`PM*wGmxwV)lwx2M4u4qgPDYUqyCaZ$l)yi+L`JflcZNA&ZIeo|

QZl-6QaW>p$1Ykj=Nd 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

AT3*68m}>Oe5g6XaHaZiEX5$MEVrK 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%0Am6yz&L@A<7VW1o|S8Toop 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%0Amd;gv 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π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

OyuKT#mI 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%0DCVj`P|@HjnZ-o0(`HS7S>GJB!1WM!}?W- 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

Ex-uD7SL>@2}sO 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

BK~sq`)CoF?C- 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 literal 0 HcmV?d00001 diff --git a/docs/install.md b/docs/install.md index adb32fd50b0..fa36791927d 100644 --- a/docs/install.md +++ b/docs/install.md @@ -12,50 +12,67 @@ weight=4 # Install Docker Compose -To install Compose, you'll need to install Docker first. You'll then install -Compose with a `curl` command. +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. -## Install Docker +Depending on how your system is configured, you may require `sudo` access to +install Compose. If your system requires `sudo`, you will receive "Permission +denied" errors when installing Compose. If this is the case for you, preface the +install commands with `sudo` to install. -First, install Docker version 1.7.1 or greater: +To install Compose, do the following: -- [Instructions for Mac OS X](http://docs.docker.com/installation/mac/) -- [Instructions for Ubuntu](http://docs.docker.com/installation/ubuntulinux/) -- [Instructions for other systems](http://docs.docker.com/installation/) +1. Install Docker Engine version 1.7.1 or greater: -## Install Compose + * Mac OS X installation (installs both Engine and Compose) + + * Ubuntu installation + + * other system installations + +2. Mac OS X users are done installing. Others should continue to the next step. + +3. Go to the repository release page. -To install Compose, run the following commands: +4. Enter the `curl` command in your termial. - curl -L https://github.com/docker/compose/releases/download/1.3.3/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose - chmod +x /usr/local/bin/docker-compose + The command has the following format: -> 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 above, then `exit`. + curl -L https://github.com/docker/compose/releases/download/VERSION_NUM/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose + + If you have problems installing with `curl`, you can use `pip` instead: `pip install -U docker-compose` + +4. Apply executable permissions to the binary: -Optionally, you can also install [command completion](completion.md) for the -bash and zsh shell. + $ chmod +x /usr/local/bin/docker-compose -> **Note:** Some older Mac OS X CPU architectures are incompatible with the binary. If you receive an "Illegal instruction: 4" error after installing, you should install using the `pip` command instead. +5. Optionally, install [command completion](completion.md) for the +`bash` and `zsh` shell. -Compose is available for OS X and 64-bit Linux. If you're on another platform, -Compose can also be installed as a Python package: +6. Test the installation. - $ sudo pip install -U docker-compose + $ docker-compose --version + docker-compose version: 1.4.0 -No further steps are required; Compose should now be successfully installed. -You can test the installation by running `docker-compose --version`. +## Upgrading -### 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 you're coming 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 migrate them with the following command: -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 migrate them with the following command: + $ docker-compose migrate-to-labels - docker-compose migrate-to-labels +Alternatively, if you're not worried about keeping them, you can remove them &endash; +Compose will just create new ones. -Alternatively, if you're not worried about keeping them, you can remove them - Compose will just create new ones. - - docker rm -f myapp_web_1 myapp_db_1 ... + $ docker rm -f -v myapp_web_1 myapp_db_1 ... ## Uninstallation @@ -69,10 +86,13 @@ 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. +>**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. -## Compose documentation +## Where to go next - [User guide](/) - [Get started with Django](django.md) From dfa4bf4452584f1a2533254231b862d521d75f85 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 10 Aug 2015 16:00:45 +0100 Subject: [PATCH 1064/4072] Ignore containers that don't have a name If a container is in the process of being removed, or removal has failed, it can sometimes appear in the output of GET /containers/json but not have a 'Name' key. In that case, rather than crashing, we can ignore it. Signed-off-by: Aanand Prasad --- compose/container.py | 6 +++++- compose/project.py | 4 ++-- compose/service.py | 11 ++++++----- tests/integration/legacy_test.py | 17 ++++++++++++++--- tests/unit/project_test.py | 25 +++++++++++++++++++++++++ tests/unit/service_test.py | 12 ++++++++++++ 6 files changed, 64 insertions(+), 11 deletions(-) diff --git a/compose/container.py b/compose/container.py index 71951497168..40aea98a456 100644 --- a/compose/container.py +++ b/compose/container.py @@ -22,10 +22,14 @@ def from_ps(cls, client, dictionary, **kwargs): """ Construct a container object from the output of GET /containers/json. """ + name = get_container_name(dictionary) + if name is None: + return None + new_dictionary = { 'Id': dictionary['Id'], 'Image': dictionary['Image'], - 'Name': '/' + get_container_name(dictionary), + 'Name': '/' + name, } return cls(client, new_dictionary, **kwargs) diff --git a/compose/project.py b/compose/project.py index 2667855d9c6..6d86a4a8729 100644 --- a/compose/project.py +++ b/compose/project.py @@ -310,11 +310,11 @@ def containers(self, service_names=None, stopped=False, one_off=False): else: service_names = self.service_names - containers = [ + containers = filter(None, [ Container.from_ps(self.client, container) for container in self.client.containers( all=stopped, - filters={'label': self.labels(one_off=one_off)})] + filters={'label': self.labels(one_off=one_off)})]) def matches_service_names(container): return container.labels.get(LABEL_SERVICE) in service_names diff --git a/compose/service.py b/compose/service.py index 2e0490a5086..2cdd6c9b58e 100644 --- a/compose/service.py +++ b/compose/service.py @@ -101,11 +101,11 @@ def __init__(self, name, client=None, project='default', links=None, external_li self.options = options def containers(self, stopped=False, one_off=False): - containers = [ + containers = filter(None, [ Container.from_ps(self.client, container) for container in self.client.containers( all=stopped, - filters={'label': self.labels(one_off=one_off)})] + filters={'label': self.labels(one_off=one_off)})]) if not containers: check_for_legacy_containers( @@ -494,12 +494,13 @@ def get_container_name(self, number, one_off=False): # TODO: this would benefit from github.com/docker/docker/pull/11943 # to remove the need to inspect every container def _next_container_number(self, one_off=False): - numbers = [ - Container.from_ps(self.client, container).number + containers = filter(None, [ + Container.from_ps(self.client, container) for container in self.client.containers( all=True, filters={'label': self.labels(one_off=one_off)}) - ] + ]) + numbers = [c.number for c in containers] return 1 if not numbers else max(numbers) + 1 def _get_links(self, link_to_self): diff --git a/tests/integration/legacy_test.py b/tests/integration/legacy_test.py index f79089b2073..9913bbb0fef 100644 --- a/tests/integration/legacy_test.py +++ b/tests/integration/legacy_test.py @@ -65,7 +65,7 @@ def test_is_valid_name_invalid(self): legacy.is_valid_name("composetest_web_lol_1", one_off=True), ) - def test_get_legacy_containers_no_labels(self): + def test_get_legacy_containers(self): client = Mock() client.containers.return_value = [ { @@ -74,12 +74,23 @@ def test_get_legacy_containers_no_labels(self): "Name": "composetest_web_1", "Labels": None, }, + { + "Id": "ghi789", + "Image": "def456", + "Name": None, + "Labels": None, + }, + { + "Id": "jkl012", + "Image": "def456", + "Labels": None, + }, ] - containers = list(legacy.get_legacy_containers( - client, "composetest", ["web"])) + containers = legacy.get_legacy_containers(client, "composetest", ["web"]) self.assertEqual(len(containers), 1) + self.assertEqual(containers[0].id, 'abc123') class LegacyTestCase(DockerClientTestCase): diff --git a/tests/unit/project_test.py b/tests/unit/project_test.py index 39ad30a1529..93bf12ff570 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -3,6 +3,7 @@ from compose.service import Service from compose.project import Project from compose.container import Container +from compose.const import LABEL_SERVICE import mock import docker @@ -260,3 +261,27 @@ def test_use_net_from_service(self): service = project.get_service('test') self.assertEqual(service._get_net(), 'container:' + container_name) + + def test_container_without_name(self): + self.mock_client.containers.return_value = [ + {'Image': 'busybox:latest', 'Id': '1', 'Name': '1'}, + {'Image': 'busybox:latest', 'Id': '2', 'Name': None}, + {'Image': 'busybox:latest', 'Id': '3'}, + ] + self.mock_client.inspect_container.return_value = { + 'Id': '1', + 'Config': { + 'Labels': { + LABEL_SERVICE: 'web', + }, + }, + } + project = Project.from_dicts( + 'test', + [{ + 'name': 'web', + 'image': 'busybox:latest', + }], + self.mock_client, + ) + self.assertEqual([c.id for c in project.containers()], ['1']) diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index bc6b9e485e4..0e274a3583e 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -76,6 +76,18 @@ def test_containers_with_containers(self): all=False, filters={'label': expected_labels}) + def test_container_without_name(self): + self.mock_client.containers.return_value = [ + {'Image': 'foo', 'Id': '1', 'Name': '1'}, + {'Image': 'foo', 'Id': '2', 'Name': None}, + {'Image': 'foo', 'Id': '3'}, + ] + service = Service('db', self.mock_client, 'myproject', image='foo') + + self.assertEqual([c.id for c in service.containers()], ['1']) + self.assertEqual(service._next_container_number(), 2) + self.assertEqual(service.get_container(1).id, '1') + def test_get_volumes_from_container(self): container_id = 'aabbccddee' service = Service( From 7f90e9592a55f198ecd89799ee2bb7579d5b7aae Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 10 Aug 2015 18:05:09 +0100 Subject: [PATCH 1065/4072] Use overlay driver in tests Signed-off-by: Aanand Prasad --- script/wrapdocker | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/wrapdocker b/script/wrapdocker index 119e88df4aa..3e669b5d7a4 100755 --- a/script/wrapdocker +++ b/script/wrapdocker @@ -7,7 +7,7 @@ fi # If a pidfile is still around (for example after a container restart), # delete it so that docker can start. rm -rf /var/run/docker.pid -docker -d $DOCKER_DAEMON_ARGS &>/var/log/docker.log & +docker -d --storage-driver="overlay" &>/var/log/docker.log & docker_pid=$! >&2 echo "Waiting for Docker to start..." From 46e8e4322aa694f176c3fec5e705c5c40c704824 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 10 Aug 2015 13:41:11 +0100 Subject: [PATCH 1066/4072] Show a warning when a relative path is specified without "./" Signed-off-by: Aanand Prasad --- compose/config/config.py | 29 +++++++++++++++++++++---- docs/yml.md | 5 +++-- tests/unit/config_test.py | 45 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 6 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 4d3f5faefad..73516a21d5f 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -90,6 +90,13 @@ ] +PATH_START_CHARS = [ + '/', + '.', + '~', +] + + log = logging.getLogger(__name__) @@ -260,7 +267,7 @@ def process_container_options(service_dict, working_dir=None): raise ConfigurationError("Invalid 'memswap_limit' configuration for %s service: when defining 'memswap_limit' you must set 'mem_limit' as well" % service_dict['name']) if 'volumes' in service_dict and service_dict.get('volume_driver') is None: - service_dict['volumes'] = resolve_volume_paths(service_dict['volumes'], working_dir=working_dir) + service_dict['volumes'] = resolve_volume_paths(service_dict, working_dir=working_dir) if 'build' in service_dict: service_dict['build'] = resolve_build_path(service_dict['build'], working_dir=working_dir) @@ -421,17 +428,31 @@ def env_vars_from_file(filename): return env -def resolve_volume_paths(volumes, working_dir=None): +def resolve_volume_paths(service_dict, working_dir=None): if working_dir is None: raise Exception("No working_dir passed to resolve_volume_paths()") - return [resolve_volume_path(v, working_dir) for v in volumes] + return [ + resolve_volume_path(v, working_dir, service_dict['name']) + for v in service_dict['volumes'] + ] -def resolve_volume_path(volume, working_dir): +def resolve_volume_path(volume, working_dir, service_name): container_path, host_path = split_path_mapping(volume) container_path = os.path.expanduser(container_path) + if host_path is not None: + if not any(host_path.startswith(c) for c in PATH_START_CHARS): + log.warn( + 'Warning: the mapping "{0}" in the volumes config for ' + 'service "{1}" is ambiguous. In a future version of Docker, ' + 'it will designate a "named" volume ' + '(see https://github.com/docker/docker/pull/14242). ' + 'To prevent unexpected behaviour, change it to "./{0}"' + .format(volume, service_name) + ) + host_path = os.path.expanduser(host_path) return "%s:%s" % (expand_path(working_dir, host_path), container_path) else: diff --git a/docs/yml.md b/docs/yml.md index 18551bf22fa..6ac1ce62af0 100644 --- a/docs/yml.md +++ b/docs/yml.md @@ -135,11 +135,12 @@ Mount paths as volumes, optionally specifying a path on the host machine volumes: - /var/lib/mysql - - cache/:/tmp/cache + - ./cache:/tmp/cache - ~/configs:/etc/configs/:ro You can mount a relative path on the host, which will expand relative to -the directory of the Compose configuration file being used. +the directory of the Compose configuration file being used. Relative paths +should always begin with `.` or `..`. > Note: No path expansion will be done if you have also specified a > `volume_driver`. diff --git a/tests/unit/config_test.py b/tests/unit/config_test.py index 0046202030c..a181e79ea01 100644 --- a/tests/unit/config_test.py +++ b/tests/unit/config_test.py @@ -117,6 +117,51 @@ def test_volume_binding_with_home(self): d = make_service_dict('foo', {'volumes': ['~:/container/path']}, working_dir='.') self.assertEqual(d['volumes'], ['/home/user:/container/path']) + @mock.patch.dict(os.environ) + def test_volume_binding_with_local_dir_name_raises_warning(self): + def make_dict(**config): + make_service_dict('foo', config, working_dir='.') + + with mock.patch('compose.config.config.log.warn') as warn: + make_dict(volumes=['/container/path']) + self.assertEqual(0, warn.call_count) + + make_dict(volumes=['/data:/container/path']) + self.assertEqual(0, warn.call_count) + + make_dict(volumes=['.:/container/path']) + self.assertEqual(0, warn.call_count) + + make_dict(volumes=['..:/container/path']) + self.assertEqual(0, warn.call_count) + + make_dict(volumes=['./data:/container/path']) + self.assertEqual(0, warn.call_count) + + make_dict(volumes=['../data:/container/path']) + self.assertEqual(0, warn.call_count) + + make_dict(volumes=['.profile:/container/path']) + self.assertEqual(0, warn.call_count) + + make_dict(volumes=['~:/container/path']) + self.assertEqual(0, warn.call_count) + + make_dict(volumes=['~/data:/container/path']) + self.assertEqual(0, warn.call_count) + + make_dict(volumes=['~tmp:/container/path']) + self.assertEqual(0, warn.call_count) + + make_dict(volumes=['data:/container/path'], volume_driver='mydriver') + self.assertEqual(0, warn.call_count) + + make_dict(volumes=['data:/container/path']) + self.assertEqual(1, warn.call_count) + warning = warn.call_args[0][0] + self.assertIn('"data:/container/path"', warning) + self.assertIn('"./data:/container/path"', warning) + def test_named_volume_with_driver_does_not_expand(self): d = make_service_dict('foo', { 'volumes': ['namedvolume:/data'], From 7ad1fe24bdae2374a954ebff67261205eca43fc7 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 6 Aug 2015 15:29:55 +0100 Subject: [PATCH 1067/4072] Merge pull request #1815 from aanand/abort-if-daemon-cant-start Abort tests if daemon fails to start (cherry picked from commit f7b9daf927cfa31324193d4b426c1c0d848e8bdf) Signed-off-by: Aanand Prasad --- script/wrapdocker | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/script/wrapdocker b/script/wrapdocker index 2e07bdadfd9..119e88df4aa 100755 --- a/script/wrapdocker +++ b/script/wrapdocker @@ -8,9 +8,16 @@ fi # delete it so that docker can start. rm -rf /var/run/docker.pid docker -d $DOCKER_DAEMON_ARGS &>/var/log/docker.log & +docker_pid=$! >&2 echo "Waiting for Docker to start..." while ! docker ps &>/dev/null; do + if ! kill -0 "$docker_pid" &>/dev/null; then + >&2 echo "Docker failed to start" + cat /var/log/docker.log + exit 1 + fi + sleep 1 done From 22ccf35fa1600d36225e422146c633fb1c2ba319 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Mon, 10 Aug 2015 18:47:29 +0100 Subject: [PATCH 1068/4072] Merge pull request #1836 from aanand/use-overlay-driver-in-tests Use overlay driver in tests (cherry picked from commit 197d332620dcf063cc888b8a64e6fa875a2e0943) Signed-off-by: Aanand Prasad --- script/wrapdocker | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/wrapdocker b/script/wrapdocker index 119e88df4aa..3e669b5d7a4 100755 --- a/script/wrapdocker +++ b/script/wrapdocker @@ -7,7 +7,7 @@ fi # If a pidfile is still around (for example after a container restart), # delete it so that docker can start. rm -rf /var/run/docker.pid -docker -d $DOCKER_DAEMON_ARGS &>/var/log/docker.log & +docker -d --storage-driver="overlay" &>/var/log/docker.log & docker_pid=$! >&2 echo "Waiting for Docker to start..." From 74b4fb89bbc7495e9ba68e04a65cda62afdae506 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Mon, 10 Aug 2015 18:50:00 +0100 Subject: [PATCH 1069/4072] Merge pull request #1835 from aanand/fix-crash-when-container-has-no-name Ignore containers that don't have a name (cherry picked from commit 4e12ce39b35ea49bfdcd6677b26a46eb593ec208) Signed-off-by: Aanand Prasad --- compose/container.py | 6 +++++- compose/project.py | 4 ++-- compose/service.py | 11 ++++++----- tests/integration/legacy_test.py | 17 ++++++++++++++--- tests/unit/project_test.py | 25 +++++++++++++++++++++++++ tests/unit/service_test.py | 12 ++++++++++++ 6 files changed, 64 insertions(+), 11 deletions(-) diff --git a/compose/container.py b/compose/container.py index 71951497168..40aea98a456 100644 --- a/compose/container.py +++ b/compose/container.py @@ -22,10 +22,14 @@ def from_ps(cls, client, dictionary, **kwargs): """ Construct a container object from the output of GET /containers/json. """ + name = get_container_name(dictionary) + if name is None: + return None + new_dictionary = { 'Id': dictionary['Id'], 'Image': dictionary['Image'], - 'Name': '/' + get_container_name(dictionary), + 'Name': '/' + name, } return cls(client, new_dictionary, **kwargs) diff --git a/compose/project.py b/compose/project.py index 2667855d9c6..6d86a4a8729 100644 --- a/compose/project.py +++ b/compose/project.py @@ -310,11 +310,11 @@ def containers(self, service_names=None, stopped=False, one_off=False): else: service_names = self.service_names - containers = [ + containers = filter(None, [ Container.from_ps(self.client, container) for container in self.client.containers( all=stopped, - filters={'label': self.labels(one_off=one_off)})] + filters={'label': self.labels(one_off=one_off)})]) def matches_service_names(container): return container.labels.get(LABEL_SERVICE) in service_names diff --git a/compose/service.py b/compose/service.py index 2e0490a5086..2cdd6c9b58e 100644 --- a/compose/service.py +++ b/compose/service.py @@ -101,11 +101,11 @@ def __init__(self, name, client=None, project='default', links=None, external_li self.options = options def containers(self, stopped=False, one_off=False): - containers = [ + containers = filter(None, [ Container.from_ps(self.client, container) for container in self.client.containers( all=stopped, - filters={'label': self.labels(one_off=one_off)})] + filters={'label': self.labels(one_off=one_off)})]) if not containers: check_for_legacy_containers( @@ -494,12 +494,13 @@ def get_container_name(self, number, one_off=False): # TODO: this would benefit from github.com/docker/docker/pull/11943 # to remove the need to inspect every container def _next_container_number(self, one_off=False): - numbers = [ - Container.from_ps(self.client, container).number + containers = filter(None, [ + Container.from_ps(self.client, container) for container in self.client.containers( all=True, filters={'label': self.labels(one_off=one_off)}) - ] + ]) + numbers = [c.number for c in containers] return 1 if not numbers else max(numbers) + 1 def _get_links(self, link_to_self): diff --git a/tests/integration/legacy_test.py b/tests/integration/legacy_test.py index f79089b2073..9913bbb0fef 100644 --- a/tests/integration/legacy_test.py +++ b/tests/integration/legacy_test.py @@ -65,7 +65,7 @@ def test_is_valid_name_invalid(self): legacy.is_valid_name("composetest_web_lol_1", one_off=True), ) - def test_get_legacy_containers_no_labels(self): + def test_get_legacy_containers(self): client = Mock() client.containers.return_value = [ { @@ -74,12 +74,23 @@ def test_get_legacy_containers_no_labels(self): "Name": "composetest_web_1", "Labels": None, }, + { + "Id": "ghi789", + "Image": "def456", + "Name": None, + "Labels": None, + }, + { + "Id": "jkl012", + "Image": "def456", + "Labels": None, + }, ] - containers = list(legacy.get_legacy_containers( - client, "composetest", ["web"])) + containers = legacy.get_legacy_containers(client, "composetest", ["web"]) self.assertEqual(len(containers), 1) + self.assertEqual(containers[0].id, 'abc123') class LegacyTestCase(DockerClientTestCase): diff --git a/tests/unit/project_test.py b/tests/unit/project_test.py index 39ad30a1529..93bf12ff570 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -3,6 +3,7 @@ from compose.service import Service from compose.project import Project from compose.container import Container +from compose.const import LABEL_SERVICE import mock import docker @@ -260,3 +261,27 @@ def test_use_net_from_service(self): service = project.get_service('test') self.assertEqual(service._get_net(), 'container:' + container_name) + + def test_container_without_name(self): + self.mock_client.containers.return_value = [ + {'Image': 'busybox:latest', 'Id': '1', 'Name': '1'}, + {'Image': 'busybox:latest', 'Id': '2', 'Name': None}, + {'Image': 'busybox:latest', 'Id': '3'}, + ] + self.mock_client.inspect_container.return_value = { + 'Id': '1', + 'Config': { + 'Labels': { + LABEL_SERVICE: 'web', + }, + }, + } + project = Project.from_dicts( + 'test', + [{ + 'name': 'web', + 'image': 'busybox:latest', + }], + self.mock_client, + ) + self.assertEqual([c.id for c in project.containers()], ['1']) diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index bc6b9e485e4..0e274a3583e 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -76,6 +76,18 @@ def test_containers_with_containers(self): all=False, filters={'label': expected_labels}) + def test_container_without_name(self): + self.mock_client.containers.return_value = [ + {'Image': 'foo', 'Id': '1', 'Name': '1'}, + {'Image': 'foo', 'Id': '2', 'Name': None}, + {'Image': 'foo', 'Id': '3'}, + ] + service = Service('db', self.mock_client, 'myproject', image='foo') + + self.assertEqual([c.id for c in service.containers()], ['1']) + self.assertEqual(service._next_container_number(), 2) + self.assertEqual(service.get_container(1).id, '1') + def test_get_volumes_from_container(self): container_id = 'aabbccddee' service = Service( From 7850d6de4533de4f4663eace7ef5ee6aa2cb1ab0 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Mon, 10 Aug 2015 20:53:01 +0100 Subject: [PATCH 1070/4072] Merge pull request #1832 from aanand/use-docker-1.8.0-rc3 Test against Docker 1.8.0 RC3 (cherry picked from commit afc9629c59117ab2ae050b91d1e732234591b47f) Signed-off-by: Aanand Prasad --- Dockerfile | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index a0e7f14f91c..7c0482323b2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -48,11 +48,13 @@ RUN set -ex; \ rm -rf pip-7.0.1; \ rm pip-7.0.1.tar.gz -ENV ALL_DOCKER_VERSIONS 1.7.1 +ENV ALL_DOCKER_VERSIONS 1.7.1 1.8.0-rc3 RUN set -ex; \ curl https://get.docker.com/builds/Linux/x86_64/docker-1.7.1 -o /usr/local/bin/docker-1.7.1; \ - chmod +x /usr/local/bin/docker-1.7.1 + chmod +x /usr/local/bin/docker-1.7.1; \ + curl https://test.docker.com/builds/Linux/x86_64/docker-1.8.0-rc3 -o /usr/local/bin/docker-1.8.0-rc3; \ + chmod +x /usr/local/bin/docker-1.8.0-rc3 # Set the default Docker to be run RUN ln -s /usr/local/bin/docker-1.7.1 /usr/local/bin/docker From 16440ff05583707e1deff41f349f0d5929041333 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Mon, 10 Aug 2015 22:13:30 +0100 Subject: [PATCH 1071/4072] Merge pull request #1829 from vlajos/typofixes-vlajos-20150807 typofix - https://github.com/vlajos/misspell_fixer (cherry picked from commit b7baa899e271fd13f17592be015b8bf066c17245) Signed-off-by: Aanand Prasad --- tests/unit/config_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/config_test.py b/tests/unit/config_test.py index a2c17d7254f..c653ade201c 100644 --- a/tests/unit/config_test.py +++ b/tests/unit/config_test.py @@ -9,7 +9,7 @@ def make_service_dict(name, service_dict, working_dir): """ - Test helper function to contruct a ServiceLoader + Test helper function to construct a ServiceLoader """ return config.ServiceLoader(working_dir=working_dir).make_service_dict(name, service_dict) From b4872de2135a41481f3cbfe97c75126b26c11929 Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Mon, 10 Aug 2015 14:55:33 +0100 Subject: [PATCH 1072/4072] Allow integer value for ports While it was intended as a positive to be stricter in validation it would in fact break backwards compatibility, which we do not want to be doing. Consider re-visiting this later and include a deprecation warning if we want to be stricter. Signed-off-by: Mazz Mosley --- compose/config/schema.json | 20 ++++++++++++++++---- compose/config/validation.py | 7 +++++++ tests/unit/config_test.py | 7 ++++--- 3 files changed, 27 insertions(+), 7 deletions(-) diff --git a/compose/config/schema.json b/compose/config/schema.json index 74f5edbbffc..24fd53d116b 100644 --- a/compose/config/schema.json +++ b/compose/config/schema.json @@ -75,10 +75,22 @@ "pid": {"type": "string"}, "ports": { - "type": "array", - "items": {"type": "string"}, - "uniqueItems": true, - "format": "ports" + "oneOf": [ + { + "type": "array", + "items": {"type": "string"}, + "uniqueItems": true, + "format": "ports" + }, + { + "type": "string", + "format": "ports" + }, + { + "type": "number", + "format": "ports" + } + ] }, "privileged": {"type": "string"}, diff --git a/compose/config/validation.py b/compose/config/validation.py index 15e0754cf8d..3f46632b850 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -84,6 +84,13 @@ def _parse_key_from_error_msg(error): required.append("Service '{}' has neither an image nor a build path specified. Exactly one must be provided.".format(service_name)) else: required.append(error.message) + elif error.validator == 'oneOf': + config_key = error.path[1] + valid_types = [context.validator_value for context in error.context] + valid_type_msg = " or ".join(valid_types) + type_errors.append("Service '{}' configuration key '{}' contains an invalid type, it should be either {}".format( + service_name, config_key, valid_type_msg) + ) elif error.validator == 'type': msg = "a" if error.validator_value == "array": diff --git a/tests/unit/config_test.py b/tests/unit/config_test.py index 4e982bb49a0..b4d2ce82f6a 100644 --- a/tests/unit/config_test.py +++ b/tests/unit/config_test.py @@ -75,8 +75,9 @@ def test_config_valid_service_names(self): ) def test_config_invalid_ports_format_validation(self): - with self.assertRaises(ConfigurationError): - for invalid_ports in [{"1": "8000"}, "whatport", "625", "8000:8050"]: + expected_error_msg = "Service 'web' configuration key 'ports' contains an invalid type" + with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): + for invalid_ports in [{"1": "8000"}, False, 0]: config.load( config.ConfigDetails( {'web': {'image': 'busybox', 'ports': invalid_ports}}, @@ -86,7 +87,7 @@ def test_config_invalid_ports_format_validation(self): ) def test_config_valid_ports_format_validation(self): - valid_ports = [["8000", "9000"], ["8000/8050"], ["8000"]] + valid_ports = [["8000", "9000"], ["8000/8050"], ["8000"], "8000", 8000] for ports in valid_ports: config.load( config.ConfigDetails( From ece6a7271259f6d72586eaa066ebb3034e62ff79 Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Mon, 10 Aug 2015 15:33:47 +0100 Subject: [PATCH 1073/4072] Clean error.message Unfortunately the way that jsonschema is calling %r on its property and then encoding the complete message means I've had to do this manual way of removing the literal string prefix, u'. eg: key = 'extends' message = "Invalid value for %r" % key error.message = message.encode("utf-8")" results in: "Invalid value for u'extends'" Performing a replace to strip out the extra "u'", does not change the encoding of the string, it is at this point the character u followed by a '. Signed-off-by: Mazz Mosley --- compose/config/validation.py | 9 ++++++--- tests/unit/config_test.py | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/compose/config/validation.py b/compose/config/validation.py index 3f46632b850..7347c0128dc 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -52,6 +52,9 @@ def process_errors(errors): def _parse_key_from_error_msg(error): return error.message.split("'")[1] + def _clean_error_message(message): + return message.replace("u'", "'") + root_msgs = [] invalid_keys = [] required = [] @@ -68,7 +71,7 @@ def _parse_key_from_error_msg(error): msg = "Invalid service name '{}' - only {} characters are allowed".format(invalid_service_name, VALID_NAME_CHARS) root_msgs.append(msg) else: - root_msgs.append(error.message) + root_msgs.append(_clean_error_message(error.message)) else: # handle service level errors @@ -83,7 +86,7 @@ def _parse_key_from_error_msg(error): elif 'image' not in error.instance and 'build' not in error.instance: required.append("Service '{}' has neither an image nor a build path specified. Exactly one must be provided.".format(service_name)) else: - required.append(error.message) + required.append(_clean_error_message(error.message)) elif error.validator == 'oneOf': config_key = error.path[1] valid_types = [context.validator_value for context in error.context] @@ -104,7 +107,7 @@ def _parse_key_from_error_msg(error): root_msgs.append("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(config_key)) elif error.validator == 'required': config_key = error.path[1] - required.append("Service '{}' option '{}' is invalid, {}".format(service_name, config_key, error.message)) + required.append("Service '{}' option '{}' is invalid, {}".format(service_name, config_key, _clean_error_message(error.message))) elif error.validator == 'dependencies': dependency_key = error.validator_value.keys()[0] required_keys = ",".join(error.validator_value[dependency_key]) diff --git a/tests/unit/config_test.py b/tests/unit/config_test.py index b4d2ce82f6a..e023153a33a 100644 --- a/tests/unit/config_test.py +++ b/tests/unit/config_test.py @@ -659,7 +659,7 @@ def test_extends_validation_empty_dictionary(self): ) def test_extends_validation_missing_service_key(self): - with self.assertRaisesRegexp(ConfigurationError, "u'service' is a required property"): + with self.assertRaisesRegexp(ConfigurationError, "'service' is a required property"): config.load( config.ConfigDetails( { From e0675b50c0fa0cc737bfd814d45e495e3d7eaba6 Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Mon, 10 Aug 2015 16:18:21 +0100 Subject: [PATCH 1074/4072] Retrieve sub property keys The validation message was confusing by displaying only 1 level of property of the service, even if the error was another level down. Eg. if the 'files' property of 'extends' was the incorrect format, it was displaying 'an invalid value for 'extends'', rather than correctly retrieving 'files'. Signed-off-by: Mazz Mosley --- compose/config/validation.py | 14 ++++++++------ tests/unit/config_test.py | 21 ++++++++++++++++++++- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/compose/config/validation.py b/compose/config/validation.py index 7347c0128dc..aa2e0fcf4c4 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -99,12 +99,14 @@ def _clean_error_message(message): if error.validator_value == "array": msg = "an" - try: - config_key = error.path[1] - type_errors.append("Service '{}' has an invalid value for '{}', it should be {} {}".format(service_name, config_key, msg, error.validator_value)) - except IndexError: - config_key = error.path[0] - root_msgs.append("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(config_key)) + # pop the service name off our path + error.path.popleft() + + if len(error.path) > 0: + config_key = " ".join(["'%s'" % k for k in error.path]) + type_errors.append("Service '{}' configuration key {} contains an invalid type, it should be {} {}".format(service_name, config_key, msg, error.validator_value)) + else: + root_msgs.append("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(service_name)) elif error.validator == 'required': config_key = error.path[1] required.append("Service '{}' option '{}' is invalid, {}".format(service_name, config_key, _clean_error_message(error.message))) diff --git a/tests/unit/config_test.py b/tests/unit/config_test.py index e023153a33a..0ea375db49e 100644 --- a/tests/unit/config_test.py +++ b/tests/unit/config_test.py @@ -124,7 +124,7 @@ def test_invalid_config_build_and_image_specified(self): ) def test_invalid_config_type_should_be_an_array(self): - expected_error_msg = "Service 'foo' has an invalid value for 'links', it should be an array" + expected_error_msg = "Service 'foo' configuration key 'links' contains an invalid type, it should be an array" with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): config.load( config.ConfigDetails( @@ -690,6 +690,25 @@ def test_extends_validation_invalid_key(self): ) ) + def test_extends_validation_sub_property_key(self): + expected_error_msg = "Service 'web' configuration key 'extends' 'file' contains an invalid type" + with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): + config.load( + config.ConfigDetails( + { + 'web': { + 'image': 'busybox', + 'extends': { + 'file': 1, + 'service': 'web', + } + }, + }, + 'tests/fixtures/extends', + 'filename.yml' + ) + ) + def test_extends_validation_no_file_key_no_filename_set(self): dictionary = {'extends': {'service': 'web'}} From df14a4384d44f6a27063de08e66d25854509f8d3 Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Mon, 10 Aug 2015 16:57:32 +0100 Subject: [PATCH 1075/4072] Catch non-unique errors When a schema type is set as unique, we should display the validation error to indicate that non-unique values have been provided for a key. Signed-off-by: Mazz Mosley --- compose/config/validation.py | 10 +++++++++- tests/unit/config_test.py | 13 +++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/compose/config/validation.py b/compose/config/validation.py index aa2e0fcf4c4..07b542a116f 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -59,6 +59,7 @@ def _clean_error_message(message): invalid_keys = [] required = [] type_errors = [] + other_errors = [] for error in errors: # handle root level errors @@ -115,8 +116,15 @@ def _clean_error_message(message): required_keys = ",".join(error.validator_value[dependency_key]) required.append("Invalid '{}' configuration for '{}' service: when defining '{}' you must set '{}' as well".format( dependency_key, service_name, dependency_key, required_keys)) + else: + # pop the service name off our path + error.path.popleft() + + config_key = " ".join(["'%s'" % k for k in error.path]) + err_msg = "Service '{}' configuration key {} value {}".format(service_name, config_key, error.message) + other_errors.append(err_msg) - return "\n".join(root_msgs + invalid_keys + required + type_errors) + return "\n".join(root_msgs + invalid_keys + required + type_errors + other_errors) def validate_against_schema(config): diff --git a/tests/unit/config_test.py b/tests/unit/config_test.py index 0ea375db49e..f35010c6955 100644 --- a/tests/unit/config_test.py +++ b/tests/unit/config_test.py @@ -147,6 +147,19 @@ def test_invalid_config_not_a_dictionary(self): ) ) + def test_invalid_config_not_unique_items(self): + expected_error_msg = "has non-unique elements" + with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): + config.load( + config.ConfigDetails( + { + 'web': {'build': '.', 'devices': ['/dev/foo:/dev/foo', '/dev/foo:/dev/foo']} + }, + 'tests/fixtures/extends', + 'filename.yml' + ) + ) + class InterpolationTest(unittest.TestCase): @mock.patch.dict(os.environ) From 68de84a0bfeb60c4660f110cde850ac17ce3672a Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Mon, 10 Aug 2015 17:12:37 +0100 Subject: [PATCH 1076/4072] Clean up error.path handling Tiny bit of refactoring to make it clearer and only pop service_name once. Signed-off-by: Mazz Mosley --- compose/config/validation.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/compose/config/validation.py b/compose/config/validation.py index 07b542a116f..946acf149b0 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -78,6 +78,9 @@ def _clean_error_message(message): # handle service level errors service_name = error.path[0] + # pop the service name off our path + error.path.popleft() + if error.validator == 'additionalProperties': invalid_config_key = _parse_key_from_error_msg(error) invalid_keys.append(get_unsupported_config_msg(service_name, invalid_config_key)) @@ -89,7 +92,7 @@ def _clean_error_message(message): else: required.append(_clean_error_message(error.message)) elif error.validator == 'oneOf': - config_key = error.path[1] + config_key = error.path[0] valid_types = [context.validator_value for context in error.context] valid_type_msg = " or ".join(valid_types) type_errors.append("Service '{}' configuration key '{}' contains an invalid type, it should be either {}".format( @@ -100,16 +103,13 @@ def _clean_error_message(message): if error.validator_value == "array": msg = "an" - # pop the service name off our path - error.path.popleft() - if len(error.path) > 0: config_key = " ".join(["'%s'" % k for k in error.path]) type_errors.append("Service '{}' configuration key {} contains an invalid type, it should be {} {}".format(service_name, config_key, msg, error.validator_value)) else: root_msgs.append("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(service_name)) elif error.validator == 'required': - config_key = error.path[1] + config_key = error.path[0] required.append("Service '{}' option '{}' is invalid, {}".format(service_name, config_key, _clean_error_message(error.message))) elif error.validator == 'dependencies': dependency_key = error.validator_value.keys()[0] @@ -117,9 +117,6 @@ def _clean_error_message(message): required.append("Invalid '{}' configuration for '{}' service: when defining '{}' you must set '{}' as well".format( dependency_key, service_name, dependency_key, required_keys)) else: - # pop the service name off our path - error.path.popleft() - config_key = " ".join(["'%s'" % k for k in error.path]) err_msg = "Service '{}' configuration key {} value {}".format(service_name, config_key, error.message) other_errors.append(err_msg) From 5548aa5c791f3d4419e1fa11547d0e9f4c886abb Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Tue, 11 Aug 2015 11:21:17 +0100 Subject: [PATCH 1077/4072] Merge pull request #1833 from aanand/deprecate-relative-volumes-without-dot Show a warning when a relative path is specified without "./" (cherry picked from commit 52733f699681c3ee762917d2d837fa4bd8c4a9ba) Signed-off-by: Aanand Prasad Conflicts: compose/config.py tests/unit/config_test.py --- compose/config.py | 30 +++++++++++++++++++++---- docs/yml.md | 5 +++-- tests/unit/config_test.py | 47 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 75 insertions(+), 7 deletions(-) diff --git a/compose/config.py b/compose/config.py index af8983961fe..aa2db2c71d7 100644 --- a/compose/config.py +++ b/compose/config.py @@ -83,6 +83,13 @@ ] +PATH_START_CHARS = [ + '/', + '.', + '~', +] + + log = logging.getLogger(__name__) @@ -253,7 +260,7 @@ def process_container_options(service_dict, working_dir=None): raise ConfigurationError("Invalid 'memswap_limit' configuration for %s service: when defining 'memswap_limit' you must set 'mem_limit' as well" % service_dict['name']) if 'volumes' in service_dict and service_dict.get('volume_driver') is None: - service_dict['volumes'] = resolve_volume_paths(service_dict['volumes'], working_dir=working_dir) + service_dict['volumes'] = resolve_volume_paths(service_dict, working_dir=working_dir) if 'build' in service_dict: service_dict['build'] = resolve_build_path(service_dict['build'], working_dir=working_dir) @@ -414,18 +421,33 @@ def env_vars_from_file(filename): return env -def resolve_volume_paths(volumes, working_dir=None): +def resolve_volume_paths(service_dict, working_dir=None): if working_dir is None: raise Exception("No working_dir passed to resolve_volume_paths()") - return [resolve_volume_path(v, working_dir) for v in volumes] + return [ + resolve_volume_path(v, working_dir, service_dict['name']) + for v in service_dict['volumes'] + ] -def resolve_volume_path(volume, working_dir): +def resolve_volume_path(volume, working_dir, service_name): container_path, host_path = split_path_mapping(volume) container_path = os.path.expanduser(os.path.expandvars(container_path)) + if host_path is not None: host_path = os.path.expanduser(os.path.expandvars(host_path)) + + if not any(host_path.startswith(c) for c in PATH_START_CHARS): + log.warn( + 'Warning: the mapping "{0}" in the volumes config for ' + 'service "{1}" is ambiguous. In a future version of Docker, ' + 'it will designate a "named" volume ' + '(see https://github.com/docker/docker/pull/14242). ' + 'To prevent unexpected behaviour, change it to "./{0}"' + .format(volume, service_name) + ) + return "%s:%s" % (expand_path(working_dir, host_path), container_path) else: return container_path diff --git a/docs/yml.md b/docs/yml.md index f89d107bdc5..bd339ec1a0d 100644 --- a/docs/yml.md +++ b/docs/yml.md @@ -131,11 +131,12 @@ Mount paths as volumes, optionally specifying a path on the host machine volumes: - /var/lib/mysql - - cache/:/tmp/cache + - ./cache:/tmp/cache - ~/configs:/etc/configs/:ro You can mount a relative path on the host, which will expand relative to -the directory of the Compose configuration file being used. +the directory of the Compose configuration file being used. Relative paths +should always begin with `.` or `..`. > Note: No path expansion will be done if you have also specified a > `volume_driver`. diff --git a/tests/unit/config_test.py b/tests/unit/config_test.py index c653ade201c..c523798f2b0 100644 --- a/tests/unit/config_test.py +++ b/tests/unit/config_test.py @@ -72,7 +72,52 @@ def test_volume_binding_with_home(self): d = make_service_dict('foo', {'volumes': ['~:/container/path']}, working_dir='.') self.assertEqual(d['volumes'], ['/home/user:/container/path']) - def test_named_volume_with_driver(self): + @mock.patch.dict(os.environ) + def test_volume_binding_with_local_dir_name_raises_warning(self): + def make_dict(**config): + make_service_dict('foo', config, working_dir='.') + + with mock.patch('compose.config.log.warn') as warn: + make_dict(volumes=['/container/path']) + self.assertEqual(0, warn.call_count) + + make_dict(volumes=['/data:/container/path']) + self.assertEqual(0, warn.call_count) + + make_dict(volumes=['.:/container/path']) + self.assertEqual(0, warn.call_count) + + make_dict(volumes=['..:/container/path']) + self.assertEqual(0, warn.call_count) + + make_dict(volumes=['./data:/container/path']) + self.assertEqual(0, warn.call_count) + + make_dict(volumes=['../data:/container/path']) + self.assertEqual(0, warn.call_count) + + make_dict(volumes=['.profile:/container/path']) + self.assertEqual(0, warn.call_count) + + make_dict(volumes=['~:/container/path']) + self.assertEqual(0, warn.call_count) + + make_dict(volumes=['~/data:/container/path']) + self.assertEqual(0, warn.call_count) + + make_dict(volumes=['~tmp:/container/path']) + self.assertEqual(0, warn.call_count) + + make_dict(volumes=['data:/container/path'], volume_driver='mydriver') + self.assertEqual(0, warn.call_count) + + make_dict(volumes=['data:/container/path']) + self.assertEqual(1, warn.call_count) + warning = warn.call_args[0][0] + self.assertIn('"data:/container/path"', warning) + self.assertIn('"./data:/container/path"', warning) + + def test_named_volume_with_driver_does_not_expand(self): d = make_service_dict('foo', { 'volumes': ['namedvolume:/data'], 'volume_driver': 'foodriver', From f8efb54c80ed661538f06ecea7c1329925442b3a Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Tue, 11 Aug 2015 13:06:32 +0100 Subject: [PATCH 1078/4072] Handle $ref defined types errors We use $ref in the schema to allow us to specify multiple type, eg command, it can be a string or a list of strings. It required some extra parsing to retrieve a helpful type to display in our error message rather than 'string or string'. Which while correct, is not helpful. We value helpful. Signed-off-by: Mazz Mosley --- compose/config/validation.py | 16 ++++++++++++++-- tests/unit/config_test.py | 13 +++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/compose/config/validation.py b/compose/config/validation.py index 946acf149b0..36fd03b5f20 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -55,6 +55,16 @@ def _parse_key_from_error_msg(error): def _clean_error_message(message): return message.replace("u'", "'") + def _parse_valid_types_from_schema(schema): + """ + Our defined types using $ref in the schema require some extra parsing + retrieve a helpful type for error message display. + """ + if '$ref' in schema: + return schema['$ref'].replace("#/definitions/", "").replace("_", " ") + else: + return str(schema['type']) + root_msgs = [] invalid_keys = [] required = [] @@ -93,9 +103,11 @@ def _clean_error_message(message): required.append(_clean_error_message(error.message)) elif error.validator == 'oneOf': config_key = error.path[0] - valid_types = [context.validator_value for context in error.context] + + valid_types = [_parse_valid_types_from_schema(schema) for schema in error.schema['oneOf']] valid_type_msg = " or ".join(valid_types) - type_errors.append("Service '{}' configuration key '{}' contains an invalid type, it should be either {}".format( + + type_errors.append("Service '{}' configuration key '{}' contains an invalid type, valid types are {}".format( service_name, config_key, valid_type_msg) ) elif error.validator == 'type': diff --git a/tests/unit/config_test.py b/tests/unit/config_test.py index f35010c6955..1948e218265 100644 --- a/tests/unit/config_test.py +++ b/tests/unit/config_test.py @@ -160,6 +160,19 @@ def test_invalid_config_not_unique_items(self): ) ) + def test_invalid_list_of_strings_format(self): + expected_error_msg = "'command' contains an invalid type, valid types are string or list of strings" + with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): + config.load( + config.ConfigDetails( + { + 'web': {'build': '.', 'command': [1]} + }, + 'tests/fixtures/extends', + 'filename.yml' + ) + ) + class InterpolationTest(unittest.TestCase): @mock.patch.dict(os.environ) From 982a8456352e2e22ac2297ddf128ddb9fb51d6ed Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 11 Aug 2015 14:17:30 +0100 Subject: [PATCH 1079/4072] Fix mem_limit and memswap_limit regression Signed-off-by: Aanand Prasad --- compose/service.py | 4 ++++ tests/unit/service_test.py | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/compose/service.py b/compose/service.py index 2cdd6c9b58e..ab7d154e6ea 100644 --- a/compose/service.py +++ b/compose/service.py @@ -42,6 +42,8 @@ 'net', 'log_driver', 'log_opt', + 'mem_limit', + 'memswap_limit', 'pid', 'privileged', 'restart', @@ -684,6 +686,8 @@ def _get_container_host_config(self, override_options, one_off=False): restart_policy=restart, cap_add=cap_add, cap_drop=cap_drop, + mem_limit=options.get('mem_limit'), + memswap_limit=options.get('memswap_limit'), log_config=log_config, extra_hosts=extra_hosts, read_only=read_only, diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 0e274a3583e..7e5266dd79a 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -173,8 +173,8 @@ def test_memory_swap_limit(self): service = Service(name='foo', image='foo', hostname='name', client=self.mock_client, mem_limit=1000000000, memswap_limit=2000000000) self.mock_client.containers.return_value = [] opts = service._get_container_create_options({'some': 'overrides'}, 1) - self.assertEqual(opts['memswap_limit'], 2000000000) - self.assertEqual(opts['mem_limit'], 1000000000) + self.assertEqual(opts['host_config']['MemorySwap'], 2000000000) + self.assertEqual(opts['host_config']['Memory'], 1000000000) def test_log_opt(self): log_opt = {'address': 'tcp://192.168.0.42:123'} From d0792b49fa6296fad32d879670a8edc8ae27cf29 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 11 Aug 2015 15:59:08 +0100 Subject: [PATCH 1080/4072] Merge pull request #1846 from aanand/fix-mem-limit-options Fix mem_limit and memswap_limit regression (cherry picked from commit 93cc7e375130ebaaa287bb5d7e04f59bd0d6d98e) Signed-off-by: Aanand Prasad --- compose/service.py | 4 ++++ tests/unit/service_test.py | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/compose/service.py b/compose/service.py index 2cdd6c9b58e..ab7d154e6ea 100644 --- a/compose/service.py +++ b/compose/service.py @@ -42,6 +42,8 @@ 'net', 'log_driver', 'log_opt', + 'mem_limit', + 'memswap_limit', 'pid', 'privileged', 'restart', @@ -684,6 +686,8 @@ def _get_container_host_config(self, override_options, one_off=False): restart_policy=restart, cap_add=cap_add, cap_drop=cap_drop, + mem_limit=options.get('mem_limit'), + memswap_limit=options.get('memswap_limit'), log_config=log_config, extra_hosts=extra_hosts, read_only=read_only, diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 0e274a3583e..7e5266dd79a 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -173,8 +173,8 @@ def test_memory_swap_limit(self): service = Service(name='foo', image='foo', hostname='name', client=self.mock_client, mem_limit=1000000000, memswap_limit=2000000000) self.mock_client.containers.return_value = [] opts = service._get_container_create_options({'some': 'overrides'}, 1) - self.assertEqual(opts['memswap_limit'], 2000000000) - self.assertEqual(opts['mem_limit'], 1000000000) + self.assertEqual(opts['host_config']['MemorySwap'], 2000000000) + self.assertEqual(opts['host_config']['Memory'], 1000000000) def test_log_opt(self): log_opt = {'address': 'tcp://192.168.0.42:123'} From 28139ab90dd1be21a3a9a0e768dc1f545ef5fe00 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 20 Jul 2015 17:01:00 +0100 Subject: [PATCH 1081/4072] Bump 1.4.0 Signed-off-by: Aanand Prasad --- CHANGES.md | 39 +++++++++++++++++++++++++++++++++++++++ compose/__init__.py | 2 +- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index 38a54324996..88e725da61f 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,6 +1,45 @@ Change log ========== +1.4.0 (2015-08-04) +------------------ + +- By default, `docker-compose up` now only recreates containers for services whose configuration has changed since they were created. This should result in a dramatic speed-up for many applications. + + The experimental `--x-smart-recreate` flag which introduced this feature in Compose 1.3.0 has been removed, and a `--force-recreate` flag has been added for when you want to recreate everything. + +- Several of Compose's commands - `scale`, `stop`, `kill` and `rm` - now perform actions on multiple containers in parallel, rather than in sequence, which will run much faster on larger applications. + +- You can now specify a custom name for a service's container with `container_name`. Because Docker container names must be unique, this means you can't scale the service beyond one container. + +- You no longer have to specify a `file` option when using `extends` - it will default to the current file. + +- Service names can now contain dots, dashes and underscores. + +- Compose can now read YAML configuration from standard input, rather than from a file, by specifying `-` as the filename. This makes it easier to generate configuration dynamically: + + $ echo 'redis: {"image": "redis"}' | docker-compose --file - up + +- There's a new `docker-compose version` command which prints extended information about Compose's bundled dependencies. + +- `docker-compose.yml` now supports `log_opt` as well as `log_driver`, allowing you to pass extra configuration to a service's logging driver. + +- `docker-compose.yml` now supports `memswap_limit`, similar to `docker run --memory-swap`. + +- When mounting volumes with the `volumes` option, you can now pass in any mode supported by the daemon, not just `:ro` or `:rw`. For example, SELinux users can pass `:z` or `:Z`. + +- You can now specify a custom volume driver with the `volume_driver` option in `docker-compose.yml`, much like `docker run --volume-driver`. + +- A bug has been fixed where Compose would fail to pull images from private registries serving plain (unsecured) HTTP. The `--allow-insecure-ssl` flag, which was previously used to work around this issue, has been deprecated and now has no effect. + +- A bug has been fixed where `docker-compose build` would fail if the build depended on a private Hub image or an image from a private registry. + +- A bug has been fixed where Compose would crash if there were containers which the Docker daemon had not finished removing. + +- Two bugs have been fixed where Compose would sometimes fail with a "Duplicate bind mount" error, or fail to attach volumes to a container, if there was a volume path specified in `docker-compose.yml` with a trailing slash. + +Thanks @mnowster, @dnephin, @ekristen, @funkyfuture, @jeffk and @lukemarsden! + 1.3.3 (2015-07-15) ------------------ diff --git a/compose/__init__.py b/compose/__init__.py index 0d464ee86a6..04a072d5a54 100644 --- a/compose/__init__.py +++ b/compose/__init__.py @@ -1,3 +1,3 @@ from __future__ import unicode_literals -__version__ = '1.4.0dev' +__version__ = '1.4.0' From 810bb702495f1d6d15008ed0cf87956b863a7ad7 Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Tue, 11 Aug 2015 16:31:56 +0100 Subject: [PATCH 1082/4072] Include schema in manifest Signed-off-by: Mazz Mosley --- MANIFEST.in | 1 + 1 file changed, 1 insertion(+) diff --git a/MANIFEST.in b/MANIFEST.in index 6c756417e04..7d48d347a84 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -4,6 +4,7 @@ include requirements.txt include requirements-dev.txt include tox.ini include *.md +include compose/config/schema.json recursive-include contrib/completion * recursive-include tests * global-exclude *.pyc From d454a584da288b5cc4ecc30d85f57a02931dac69 Mon Sep 17 00:00:00 2001 From: Mary Anthony Date: Tue, 11 Aug 2015 09:38:49 -0700 Subject: [PATCH 1083/4072] Fixing links after crawl Signed-off-by: Mary Anthony --- docs/completion.md | 2 +- docs/django.md | 2 +- docs/env.md | 2 +- docs/extends.md | 4 ++-- docs/index.md | 4 ++-- docs/install.md | 2 +- docs/production.md | 10 +++++----- docs/rails.md | 2 +- docs/reference/index.md | 2 +- docs/wordpress.md | 2 +- docs/yml.md | 2 +- 11 files changed, 17 insertions(+), 17 deletions(-) diff --git a/docs/completion.md b/docs/completion.md index 41ef88e62de..7b8a6733e5c 100644 --- a/docs/completion.md +++ b/docs/completion.md @@ -64,6 +64,6 @@ Enjoy working with Compose faster and with less typos! - [Get started with Django](django.md) - [Get started with Rails](rails.md) - [Get started with Wordpress](wordpress.md) -- [Command line reference](cli.md) +- [Command line reference](/reference) - [Yaml file reference](yml.md) - [Compose environment variables](env.md) diff --git a/docs/django.md b/docs/django.md index 71df4e11689..7e476b35694 100644 --- a/docs/django.md +++ b/docs/django.md @@ -129,7 +129,7 @@ example, run `docker-compose up` and in another terminal run: - [Get started with Django](django.md) - [Get started with Rails](rails.md) - [Get started with Wordpress](wordpress.md) -- [Command line reference](cli.md) +- [Command line reference](/reference) - [Yaml file reference](yml.md) - [Compose environment variables](env.md) - [Compose command line completion](completion.md) diff --git a/docs/env.md b/docs/env.md index afeb829e72f..8ead34f01f7 100644 --- a/docs/env.md +++ b/docs/env.md @@ -44,6 +44,6 @@ Fully qualified container name, e.g. `DB_1_NAME=/myapp_web_1/myapp_db_1` - [Get started with Django](django.md) - [Get started with Rails](rails.md) - [Get started with Wordpress](wordpress.md) -- [Command line reference](cli.md) +- [Command line reference](/reference) - [Yaml file reference](yml.md) - [Compose command line completion](completion.md) diff --git a/docs/extends.md b/docs/extends.md index 7a92b771a35..18a072a82d7 100644 --- a/docs/extends.md +++ b/docs/extends.md @@ -78,7 +78,7 @@ For full details on how to use `extends`, refer to the [reference](#reference). ### Example use case In this example, you’ll repurpose the example app from the [quick start -guide](index.md). (If you're not familiar with Compose, it's recommended that +guide](/). (If you're not familiar with Compose, it's recommended that you go through the quick start first.) This example assumes you want to use Compose both to develop an application locally and then deploy it to a production environment. @@ -358,6 +358,6 @@ locally-defined bindings taking precedence: - [Get started with Django](django.md) - [Get started with Rails](rails.md) - [Get started with Wordpress](wordpress.md) -- [Command line reference](cli.md) +- [Command line reference](/reference) - [Yaml file reference](yml.md) - [Compose command line completion](completion.md) diff --git a/docs/index.md b/docs/index.md index 6d949f88d3e..872b0158816 100644 --- a/docs/index.md +++ b/docs/index.md @@ -53,7 +53,7 @@ Compose has commands for managing the whole lifecycle of your application: - [Get started with Django](django.md) - [Get started with Rails](rails.md) - [Get started with Wordpress](wordpress.md) -- [Command line reference](cli.md) +- [Command line reference](/reference) - [Yaml file reference](yml.md) - [Compose environment variables](env.md) - [Compose command line completion](completion.md) @@ -201,7 +201,7 @@ At this point, you have seen the basics of how Compose works. - Next, try the quick start guide for [Django](django.md), [Rails](rails.md), or [Wordpress](wordpress.md). -- See the reference guides for complete details on the [commands](cli.md), the +- See the reference guides for complete details on the [commands](/reference), the [configuration file](yml.md) and [environment variables](env.md). ## Release Notes diff --git a/docs/install.md b/docs/install.md index fa36791927d..d71aa0800d6 100644 --- a/docs/install.md +++ b/docs/install.md @@ -98,7 +98,7 @@ To uninstall Docker Compose if you installed using `pip`: - [Get started with Django](django.md) - [Get started with Rails](rails.md) - [Get started with Wordpress](wordpress.md) -- [Command line reference](cli.md) +- [Command line reference](/reference) - [Yaml file reference](yml.md) - [Compose environment variables](env.md) - [Compose command line completion](completion.md) diff --git a/docs/production.md b/docs/production.md index 294f3c4e865..60051136955 100644 --- a/docs/production.md +++ b/docs/production.md @@ -15,8 +15,7 @@ weight=1 While **Compose is not yet considered production-ready**, if you'd like to experiment and learn more about using it in production deployments, this guide can help. The project is actively working towards becoming -production-ready; to learn more about the progress being made, check out the -[roadmap](https://github.com/docker/compose/blob/master/ROADMAP.md) for details +production-ready; to learn more about the progress being made, check out the roadmap for details on how it's coming along and what still needs to be done. When deploying to production, you'll almost certainly want to make changes to @@ -80,8 +79,9 @@ 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](https://github.com/docker/compose/blob/master/SWARM.md). +in beta, but if you'd like to explore and experiment, check out the integration +guide. ## Compose documentation @@ -89,7 +89,7 @@ in beta, but if you'd like to explore and experiment, check out the - [Get started with Django](django.md) - [Get started with Rails](rails.md) - [Get started with Wordpress](wordpress.md) -- [Command line reference](cli.md) +- [Command line reference](/reference) - [Yaml file reference](yml.md) - [Compose environment variables](env.md) - [Compose command line completion](completion.md) diff --git a/docs/rails.md b/docs/rails.md index 9ce6c4a6f8e..b73be90cb59 100644 --- a/docs/rails.md +++ b/docs/rails.md @@ -127,7 +127,7 @@ That's it. Your app should now be running on port 3000 on your Docker daemon. If - [Get started with Django](django.md) - [Get started with Rails](rails.md) - [Get started with Wordpress](wordpress.md) -- [Command line reference](cli.md) +- [Command line reference](/reference) - [Yaml file reference](yml.md) - [Compose environment variables](env.md) - [Compose command line completion](completion.md) diff --git a/docs/reference/index.md b/docs/reference/index.md index 3d3d55d82a7..5651e5bf056 100644 --- a/docs/reference/index.md +++ b/docs/reference/index.md @@ -13,7 +13,7 @@ parent = "smn_compose_ref" The following pages describe the usage information for the [docker-compose](/reference/docker-compose.md) subcommands. You can also see this information by running `docker-compose [SUBCOMMAND] --help` from the command line. -* [build](/reference/reference/build.md) +* [build](/reference/build.md) * [help](/reference/help.md) * [kill](/reference/kill.md) * [ps](/reference/ps.md) diff --git a/docs/wordpress.md b/docs/wordpress.md index eda755c1784..8440fdbb41c 100644 --- a/docs/wordpress.md +++ b/docs/wordpress.md @@ -117,7 +117,7 @@ database containers. If you're using [Docker Machine](https://docs.docker.com/ma - [Get started with Django](django.md) - [Get started with Rails](rails.md) - [Get started with Wordpress](wordpress.md) -- [Command line reference](cli.md) +- [Command line reference](/reference) - [Yaml file reference](yml.md) - [Compose environment variables](env.md) - [Compose command line completion](completion.md) diff --git a/docs/yml.md b/docs/yml.md index 6ac1ce62af0..8e7cf3bbfd4 100644 --- a/docs/yml.md +++ b/docs/yml.md @@ -408,6 +408,6 @@ dollar sign (`$$`). - [Get started with Django](django.md) - [Get started with Rails](rails.md) - [Get started with Wordpress](wordpress.md) -- [Command line reference](cli.md) +- [Command line reference](/reference) - [Compose environment variables](env.md) - [Compose command line completion](completion.md) From 192dda4140f592a5db53f44cb5cd8d5b1a3f0ca1 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 11 Aug 2015 18:41:21 +0100 Subject: [PATCH 1084/4072] Bump 1.5.0dev Signed-off-by: Aanand Prasad --- CHANGES.md | 39 +++++++++++++++++++++++++++++++++++++++ compose/__init__.py | 2 +- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index 38a54324996..88e725da61f 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,6 +1,45 @@ Change log ========== +1.4.0 (2015-08-04) +------------------ + +- By default, `docker-compose up` now only recreates containers for services whose configuration has changed since they were created. This should result in a dramatic speed-up for many applications. + + The experimental `--x-smart-recreate` flag which introduced this feature in Compose 1.3.0 has been removed, and a `--force-recreate` flag has been added for when you want to recreate everything. + +- Several of Compose's commands - `scale`, `stop`, `kill` and `rm` - now perform actions on multiple containers in parallel, rather than in sequence, which will run much faster on larger applications. + +- You can now specify a custom name for a service's container with `container_name`. Because Docker container names must be unique, this means you can't scale the service beyond one container. + +- You no longer have to specify a `file` option when using `extends` - it will default to the current file. + +- Service names can now contain dots, dashes and underscores. + +- Compose can now read YAML configuration from standard input, rather than from a file, by specifying `-` as the filename. This makes it easier to generate configuration dynamically: + + $ echo 'redis: {"image": "redis"}' | docker-compose --file - up + +- There's a new `docker-compose version` command which prints extended information about Compose's bundled dependencies. + +- `docker-compose.yml` now supports `log_opt` as well as `log_driver`, allowing you to pass extra configuration to a service's logging driver. + +- `docker-compose.yml` now supports `memswap_limit`, similar to `docker run --memory-swap`. + +- When mounting volumes with the `volumes` option, you can now pass in any mode supported by the daemon, not just `:ro` or `:rw`. For example, SELinux users can pass `:z` or `:Z`. + +- You can now specify a custom volume driver with the `volume_driver` option in `docker-compose.yml`, much like `docker run --volume-driver`. + +- A bug has been fixed where Compose would fail to pull images from private registries serving plain (unsecured) HTTP. The `--allow-insecure-ssl` flag, which was previously used to work around this issue, has been deprecated and now has no effect. + +- A bug has been fixed where `docker-compose build` would fail if the build depended on a private Hub image or an image from a private registry. + +- A bug has been fixed where Compose would crash if there were containers which the Docker daemon had not finished removing. + +- Two bugs have been fixed where Compose would sometimes fail with a "Duplicate bind mount" error, or fail to attach volumes to a container, if there was a volume path specified in `docker-compose.yml` with a trailing slash. + +Thanks @mnowster, @dnephin, @ekristen, @funkyfuture, @jeffk and @lukemarsden! + 1.3.3 (2015-07-15) ------------------ diff --git a/compose/__init__.py b/compose/__init__.py index 0d464ee86a6..e3ace983566 100644 --- a/compose/__init__.py +++ b/compose/__init__.py @@ -1,3 +1,3 @@ from __future__ import unicode_literals -__version__ = '1.4.0dev' +__version__ = '1.5.0dev' From 5e2ecff8a15f592b755bb1fffba69992cda450d9 Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Wed, 12 Aug 2015 15:19:28 +0100 Subject: [PATCH 1085/4072] Fix ports validation I had misunderstood the valid formats allowed for ports. They must always be in a list. Signed-off-by: Mazz Mosley --- compose/config/schema.json | 30 ++++++++++++++---------------- tests/unit/config_test.py | 4 ++-- 2 files changed, 16 insertions(+), 18 deletions(-) diff --git a/compose/config/schema.json b/compose/config/schema.json index 24fd53d116b..b615aa20165 100644 --- a/compose/config/schema.json +++ b/compose/config/schema.json @@ -75,22 +75,20 @@ "pid": {"type": "string"}, "ports": { - "oneOf": [ - { - "type": "array", - "items": {"type": "string"}, - "uniqueItems": true, - "format": "ports" - }, - { - "type": "string", - "format": "ports" - }, - { - "type": "number", - "format": "ports" - } - ] + "type": "array", + "items": { + "oneOf": [ + { + "type": "string", + "format": "ports" + }, + { + "type": "number", + "format": "ports" + } + ] + }, + "uniqueItems": true }, "privileged": {"type": "string"}, diff --git a/tests/unit/config_test.py b/tests/unit/config_test.py index 44d757d6b41..136a11834c1 100644 --- a/tests/unit/config_test.py +++ b/tests/unit/config_test.py @@ -77,7 +77,7 @@ def test_config_valid_service_names(self): def test_config_invalid_ports_format_validation(self): expected_error_msg = "Service 'web' configuration key 'ports' contains an invalid type" with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): - for invalid_ports in [{"1": "8000"}, False, 0]: + for invalid_ports in [{"1": "8000"}, False, 0, "8000", 8000, ["8000", "8000"]]: config.load( config.ConfigDetails( {'web': {'image': 'busybox', 'ports': invalid_ports}}, @@ -87,7 +87,7 @@ def test_config_invalid_ports_format_validation(self): ) def test_config_valid_ports_format_validation(self): - valid_ports = [["8000", "9000"], ["8000/8050"], ["8000"], "8000", 8000] + valid_ports = [["8000", "9000"], ["8000/8050"], ["8000"], [8000], ["49153-49154:3002-3003"]] for ports in valid_ports: config.load( config.ConfigDetails( From bcb977425b74fcea8c8b4ff91295b535fc0e58ea Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 12 Aug 2015 15:36:10 +0100 Subject: [PATCH 1086/4072] Only use overlay driver in CI Signed-off-by: Aanand Prasad --- script/ci | 1 + script/test-versions | 1 + script/wrapdocker | 4 +++- 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/script/ci b/script/ci index 2e4ec9197f2..b4975487812 100755 --- a/script/ci +++ b/script/ci @@ -9,6 +9,7 @@ set -e export DOCKER_VERSIONS=all +export DOCKER_DAEMON_ARGS="--storage-driver=overlay" . script/test-versions >&2 echo "Building Linux binary" diff --git a/script/test-versions b/script/test-versions index 9e81a515d99..ae9620e3849 100755 --- a/script/test-versions +++ b/script/test-versions @@ -21,6 +21,7 @@ for version in $DOCKER_VERSIONS; do --volume="/var/lib/docker" \ --volume="${COVERAGE_DIR:-$(pwd)/coverage-html}:/code/coverage-html" \ -e "DOCKER_VERSION=$version" \ + -e "DOCKER_DAEMON_ARGS" \ --entrypoint="script/dind" \ "$TAG" \ script/wrapdocker nosetests --with-coverage --cover-branches --cover-package=compose --cover-erase --cover-html-dir=coverage-html --cover-html "$@" diff --git a/script/wrapdocker b/script/wrapdocker index 3e669b5d7a4..ab89f5ed641 100755 --- a/script/wrapdocker +++ b/script/wrapdocker @@ -7,7 +7,9 @@ fi # If a pidfile is still around (for example after a container restart), # delete it so that docker can start. rm -rf /var/run/docker.pid -docker -d --storage-driver="overlay" &>/var/log/docker.log & +docker_command="docker -d $DOCKER_DAEMON_ARGS" +>&2 echo "Starting Docker with: $docker_command" +$docker_command &>/var/log/docker.log & docker_pid=$! >&2 echo "Waiting for Docker to start..." From 4c65891db10250705015407cb914b40d6eaa3378 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 7 Aug 2015 15:04:30 +0100 Subject: [PATCH 1087/4072] Avoid duplicate warnings if an unset env variable is used multiple times Signed-off-by: Aanand Prasad --- compose/config/interpolation.py | 38 ++++++++++++++++++-------------- tests/unit/config_test.py | 24 ++++++++++++++++++++ tests/unit/interpolation_test.py | 31 +++++++++++++------------- 3 files changed, 62 insertions(+), 31 deletions(-) diff --git a/compose/config/interpolation.py b/compose/config/interpolation.py index d33e93be497..8ebcc87596c 100644 --- a/compose/config/interpolation.py +++ b/compose/config/interpolation.py @@ -10,13 +10,15 @@ def interpolate_environment_variables(config): + mapping = BlankDefaultDict(os.environ) + return dict( - (service_name, process_service(service_name, service_dict)) + (service_name, process_service(service_name, service_dict, mapping)) for (service_name, service_dict) in config.items() ) -def process_service(service_name, service_dict): +def process_service(service_name, service_dict, mapping): if not isinstance(service_dict, dict): raise ConfigurationError( 'Service "%s" doesn\'t have any configuration options. ' @@ -25,14 +27,14 @@ def process_service(service_name, service_dict): ) return dict( - (key, interpolate_value(service_name, key, val)) + (key, interpolate_value(service_name, key, val, mapping)) for (key, val) in service_dict.items() ) -def interpolate_value(service_name, config_key, value): +def interpolate_value(service_name, config_key, value, mapping): try: - return recursive_interpolate(value) + return recursive_interpolate(value, mapping) except InvalidInterpolation as e: raise ConfigurationError( 'Invalid interpolation format for "{config_key}" option ' @@ -45,39 +47,43 @@ def interpolate_value(service_name, config_key, value): ) -def recursive_interpolate(obj): +def recursive_interpolate(obj, mapping): if isinstance(obj, six.string_types): - return interpolate(obj, os.environ) + return interpolate(obj, mapping) elif isinstance(obj, dict): return dict( - (key, recursive_interpolate(val)) + (key, recursive_interpolate(val, mapping)) for (key, val) in obj.items() ) elif isinstance(obj, list): - return map(recursive_interpolate, obj) + return [recursive_interpolate(val, mapping) for val in obj] else: return obj def interpolate(string, mapping): try: - return Template(string).substitute(BlankDefaultDict(mapping)) + return Template(string).substitute(mapping) except ValueError: raise InvalidInterpolation(string) class BlankDefaultDict(dict): - def __init__(self, mapping): - super(BlankDefaultDict, self).__init__(mapping) + 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: - log.warn( - "The {} variable is not set. Substituting a blank string." - .format(key) - ) + if key not in self.missing_keys: + log.warn( + "The {} variable is not set. Substituting a blank string." + .format(key) + ) + self.missing_keys.append(key) + return "" diff --git a/tests/unit/config_test.py b/tests/unit/config_test.py index 44d757d6b41..69959979485 100644 --- a/tests/unit/config_test.py +++ b/tests/unit/config_test.py @@ -198,6 +198,30 @@ def test_config_file_with_environment_variable(self): } ]) + @mock.patch.dict(os.environ) + def test_unset_variable_produces_warning(self): + os.environ.pop('FOO', None) + os.environ.pop('BAR', None) + config_details = config.ConfigDetails( + config={ + 'web': { + 'image': '${FOO}', + 'command': '${BAR}', + 'entrypoint': '${BAR}', + }, + }, + working_dir='.', + filename=None, + ) + + with mock.patch('compose.config.interpolation.log') as log: + config.load(config_details) + + self.assertEqual(2, log.warn.call_count) + warnings = sorted(args[0][0] for args in log.warn.call_args_list) + self.assertIn('BAR', warnings[0]) + self.assertIn('FOO', warnings[1]) + @mock.patch.dict(os.environ) def test_invalid_interpolation(self): with self.assertRaises(config.ConfigurationError) as cm: diff --git a/tests/unit/interpolation_test.py b/tests/unit/interpolation_test.py index 96c6f9b33a8..fb95422b0e1 100644 --- a/tests/unit/interpolation_test.py +++ b/tests/unit/interpolation_test.py @@ -1,31 +1,32 @@ import unittest from compose.config.interpolation import interpolate, InvalidInterpolation +from compose.config.interpolation import BlankDefaultDict as bddict class InterpolationTest(unittest.TestCase): def test_valid_interpolations(self): - self.assertEqual(interpolate('$foo', dict(foo='hi')), 'hi') - self.assertEqual(interpolate('${foo}', dict(foo='hi')), 'hi') + self.assertEqual(interpolate('$foo', bddict(foo='hi')), 'hi') + self.assertEqual(interpolate('${foo}', bddict(foo='hi')), 'hi') - self.assertEqual(interpolate('${subject} love you', dict(subject='i')), 'i love you') - self.assertEqual(interpolate('i ${verb} you', dict(verb='love')), 'i love you') - self.assertEqual(interpolate('i love ${object}', dict(object='you')), 'i love you') + 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}', dict(foo='')), '') + self.assertEqual(interpolate('${foo}', bddict(foo='')), '') def test_unset_value(self): - self.assertEqual(interpolate('${foo}', dict()), '') + self.assertEqual(interpolate('${foo}', bddict()), '') def test_escaped_interpolation(self): - self.assertEqual(interpolate('$${foo}', dict(foo='hi')), '${foo}') + self.assertEqual(interpolate('$${foo}', bddict(foo='hi')), '${foo}') def test_invalid_strings(self): - self.assertRaises(InvalidInterpolation, lambda: interpolate('${', dict())) - self.assertRaises(InvalidInterpolation, lambda: interpolate('$}', dict())) - self.assertRaises(InvalidInterpolation, lambda: interpolate('${}', dict())) - self.assertRaises(InvalidInterpolation, lambda: interpolate('${ }', dict())) - self.assertRaises(InvalidInterpolation, lambda: interpolate('${ foo}', dict())) - self.assertRaises(InvalidInterpolation, lambda: interpolate('${foo }', dict())) - self.assertRaises(InvalidInterpolation, lambda: interpolate('${foo!}', dict())) + 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 f1eef7b416b1ebe4e0e26e6179a5df02343348f1 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 12 Aug 2015 16:31:37 +0100 Subject: [PATCH 1088/4072] Fill out release process documentation Signed-off-by: Aanand Prasad --- RELEASE_PROCESS.md | 147 +++++++++++++++++++++++++++++++++++++-------- 1 file changed, 121 insertions(+), 26 deletions(-) diff --git a/RELEASE_PROCESS.md b/RELEASE_PROCESS.md index 86522faaf35..e81a55ec693 100644 --- a/RELEASE_PROCESS.md +++ b/RELEASE_PROCESS.md @@ -1,36 +1,131 @@ -# Building a Compose release +Building a Compose release +========================== -## Building binaries +## To get started with a new release -`script/build-linux` builds the Linux binary inside a Docker container: +1. Create a `bump-$VERSION` branch off master: - $ script/build-linux + git checkout -b bump-$VERSION master -`script/build-osx` builds the Mac OS X binary inside a virtualenv: +2. Merge in the `release` branch on the upstream repo, discarding its tree entirely: - $ script/build-osx + git fetch origin + git merge --strategy=ours origin/release -For official releases, you should build inside a Mountain Lion VM for proper -compatibility. Run the this script first to prepare the environment before -building - it will use Homebrew to make sure Python is installed and -up-to-date. +3. Update the version in `docs/install.md` and `compose/__init__.py`. - $ script/prepare-osx + If the next release will be an RC, append `rcN`, e.g. `1.4.0rc1`. -## Release process +4. Write release notes in `CHANGES.md`. -1. Open pull request that: - - Updates the version in `compose/__init__.py` - - Updates the binary URL in `docs/install.md` - - Adds release notes to `CHANGES.md` -2. Create unpublished GitHub release with release notes -3. Build Linux version on any Docker host with `script/build-linux` and attach - to release -4. Build OS X version on Mountain Lion with `script/build-osx` and attach to - release as `docker-compose-Darwin-x86_64` and `docker-compose-Linux-x86_64`. -5. Publish GitHub release, creating tag -6. Update website with `script/deploy-docs` -7. Upload PyPi package +5. Add a bump commit: - $ git checkout $VERSION - $ python setup.py sdist upload + git commit -am "Bump $VERSION" + +6. Push the bump branch to your fork: + + git push --set-upstream $USERNAME bump-$VERSION + +7. Open a PR from the bump branch against the `release` branch on the upstream repo, **not** against master. + +## When a PR is merged into master that we want in the release + +1. Check out the bump branch: + + git checkout bump-$VERSION + +2. Cherry-pick the merge commit, fixing any conflicts if necessary: + + git cherry-pick -xm1 $MERGE_COMMIT_HASH + +3. Add a signoff (it’s missing from merge commits): + + git commit --amend --signoff + +4. Move the bump commit back to the tip of the branch: + + git rebase --interactive $PARENT_OF_BUMP_COMMIT + +5. Force-push the bump branch to your fork: + + git push --force $USERNAME bump-$VERSION + +## To release a version (whether RC or stable) + +1. Check that CI is passing on the bump PR. + +2. Check out the bump branch: + + git checkout bump-$VERSION + +3. Build the Linux binary: + + script/build-linux + +4. Build the Mac binary in a Mountain Lion VM: + + script/prepare-osx + script/build-osx + +5. Test the binaries and/or get some other people to test them. + +6. Create a tag: + + TAG=$VERSION # or $VERSION-rcN, if it's an RC + git tag $TAG + +7. Push the tag to the upstream repo: + + git push git@github.com:docker/compose.git $TAG + +8. Create a release from the tag on GitHub. + +9. Paste in installation instructions and release notes. + +10. Attach the binaries. + +11. Don’t publish it just yet! + +12. Upload the latest version to PyPi: + + python setup.py sdist upload + +13. Check that the pip package installs and runs (best done in a virtualenv): + + pip install -U docker-compose==$TAG + docker-compose version + +14. Publish the release on GitHub. + +15. Check that both binaries download (following the install instructions) and run. + +16. Email maintainers@dockerproject.org and engineering@docker.com about the new release. + +## 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. + +## If it’s a minor release (1.x.0), rather than a patch release (1.x.y) + +1. Open a PR against `master` to: + + - update `CHANGELOG.md` to bring it in line with `release` + - bump the version in `compose/__init__.py` to the *next* minor version number with `dev` appended. For example, if you just released `1.4.0`, update it to `1.5.0dev`. + +2. Get the PR merged. + +## Finally + +1. Celebrate, however you’d like. From 440099754d9eb45b953c3db3baecf8219e2a8e1c Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Wed, 12 Aug 2015 17:29:33 +0100 Subject: [PATCH 1089/4072] memory values can be strings or numbers Signed-off-by: Mazz Mosley --- compose/config/schema.json | 14 ++++++++++++-- tests/unit/config_test.py | 10 ++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/compose/config/schema.json b/compose/config/schema.json index b615aa20165..073a0da65a6 100644 --- a/compose/config/schema.json +++ b/compose/config/schema.json @@ -68,8 +68,18 @@ }, "mac_address": {"type": "string"}, - "mem_limit": {"type": "number"}, - "memswap_limit": {"type": "number"}, + "mem_limit": { + "oneOf": [ + {"type": "number"}, + {"type": "string"} + ] + }, + "memswap_limit": { + "oneOf": [ + {"type": "number"}, + {"type": "string"} + ] + }, "name": {"type": "string"}, "net": {"type": "string"}, "pid": {"type": "string"}, diff --git a/tests/unit/config_test.py b/tests/unit/config_test.py index c8a2d0db14b..861d36bdacb 100644 --- a/tests/unit/config_test.py +++ b/tests/unit/config_test.py @@ -533,6 +533,16 @@ def test_validation_with_correct_memswap_values(self): ) self.assertEqual(service_dict[0]['memswap_limit'], 2000000) + def test_memswap_can_be_a_string(self): + service_dict = config.load( + config.ConfigDetails( + {'foo': {'image': 'busybox', 'mem_limit': "1G", 'memswap_limit': "512M"}}, + 'tests/fixtures/extends', + 'common.yml' + ) + ) + self.assertEqual(service_dict[0]['memswap_limit'], "512M") + class EnvTest(unittest.TestCase): def test_parse_environment_as_list(self): From ff87ceabbd4dcc7f0876f4bef03c4587327fa36b Mon Sep 17 00:00:00 2001 From: Karol Duleba Date: Fri, 7 Aug 2015 13:42:37 +0100 Subject: [PATCH 1090/4072] Allow manual port mapping when using "run" command. Fixes #1709 Signed-off-by: Karol Duleba --- compose/cli/main.py | 12 +++++++- contrib/completion/bash/docker-compose | 2 +- contrib/completion/zsh/_docker-compose | 1 + docs/reference/run.md | 5 ++++ tests/integration/cli_test.py | 38 ++++++++++++++++++++++++++ tests/unit/cli_test.py | 31 +++++++++++++++++++++ 6 files changed, 87 insertions(+), 2 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 56f6c05052d..6c2a8edb61d 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -282,7 +282,7 @@ def run(self, project, options): running. If you do not want to start linked services, use `docker-compose run --no-deps SERVICE COMMAND [ARGS...]`. - Usage: run [options] [-e KEY=VAL...] SERVICE [COMMAND] [ARGS...] + Usage: run [options] [-p PORT...] [-e KEY=VAL...] SERVICE [COMMAND] [ARGS...] Options: --allow-insecure-ssl Deprecated - no effect. @@ -293,6 +293,7 @@ def run(self, project, options): -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` @@ -344,6 +345,15 @@ def run(self, project, options): 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' + ) + try: container = service.create_container( quiet=True, diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index e7d8cb3f8e6..128428d9a29 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -248,7 +248,7 @@ _docker-compose_run() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "-d --entrypoint -e --help --no-deps --rm --service-ports -T --user -u" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "-d --entrypoint -e --help --no-deps --rm --service-ports --publish -p -T --user -u" -- "$cur" ) ) ;; *) __docker-compose_services_all diff --git a/contrib/completion/zsh/_docker-compose b/contrib/completion/zsh/_docker-compose index 9af21a98b32..9ac7e7560f4 100644 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -221,6 +221,7 @@ __docker-compose_subcommand () { '(-u --user)'{-u,--user=-}'[Run as specified username or uid]:username or uid:_users' \ "--no-deps[Don't start linked services.]" \ '--rm[Remove container after run. Ignored in detached mode.]' \ + "--publish[Run command with manually mapped 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.]' \ '(-):services:__docker-compose_services' \ diff --git a/docs/reference/run.md b/docs/reference/run.md index 5ea9a61bec1..93ae0212b2d 100644 --- a/docs/reference/run.md +++ b/docs/reference/run.md @@ -22,6 +22,7 @@ Options: -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. ``` @@ -38,6 +39,10 @@ The second difference is the `docker-compose run` command does not create any of $ 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 diff --git a/tests/integration/cli_test.py b/tests/integration/cli_test.py index 0e86c2792f0..ce497c82288 100644 --- a/tests/integration/cli_test.py +++ b/tests/integration/cli_test.py @@ -343,6 +343,44 @@ def test_run_service_with_map_ports(self, __): self.assertIn("0.0.0.0", port_random) self.assertEqual(port_assigned, "0.0.0.0:49152") + @patch('dockerpty.start') + def test_run_service_with_explicitly_maped_ports(self, __): + + # create one off container + self.command.base_dir = 'tests/fixtures/ports-composefile' + self.command.dispatch(['run', '-d', '-p', '30000:3000', '--publish', '30001:3001', 'simple'], None) + container = self.project.get_service('simple').containers(one_off=True)[0] + + # get port information + port_short = container.get_local_port(3000) + port_full = container.get_local_port(3001) + + # close all one off containers we just created + container.stop() + + # check the ports + self.assertEqual(port_short, "0.0.0.0:30000") + self.assertEqual(port_full, "0.0.0.0:30001") + + @patch('dockerpty.start') + def test_run_service_with_explicitly_maped_ip_ports(self, __): + + # create one off container + self.command.base_dir = 'tests/fixtures/ports-composefile' + self.command.dispatch(['run', '-d', '-p', '127.0.0.1:30000:3000', '--publish', '127.0.0.1:30001:3001', 'simple'], None) + container = self.project.get_service('simple').containers(one_off=True)[0] + + # get port information + port_short = container.get_local_port(3000) + port_full = container.get_local_port(3001) + + # close all one off containers we just created + container.stop() + + # check the ports + self.assertEqual(port_short, "127.0.0.1:30000") + self.assertEqual(port_full, "127.0.0.1:30001") + def test_rm(self): service = self.project.get_service('simple') service.create_container() diff --git a/tests/unit/cli_test.py b/tests/unit/cli_test.py index 3f500032927..e11f6f14afa 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -7,6 +7,7 @@ import mock from compose.cli.docopt_command import NoSuchCommand +from compose.cli.errors import UserError from compose.cli.main import TopLevelCommand from compose.service import Service @@ -108,6 +109,7 @@ def test_run_with_environment_merged_with_options_list(self, mock_dockerpty): '-T': None, '--entrypoint': None, '--service-ports': None, + '--publish': [], '--rm': None, }) @@ -136,6 +138,7 @@ def test_run_service_with_restart_always(self): '-T': None, '--entrypoint': None, '--service-ports': None, + '--publish': [], '--rm': None, }) _, _, call_kwargs = mock_client.create_container.mock_calls[0] @@ -160,7 +163,35 @@ def test_run_service_with_restart_always(self): '-T': None, '--entrypoint': None, '--service-ports': None, + '--publish': [], '--rm': True, }) _, _, call_kwargs = mock_client.create_container.mock_calls[0] self.assertFalse('RestartPolicy' in call_kwargs['host_config']) + + 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', + ) + + with self.assertRaises(UserError): + command.run(mock_project, { + 'SERVICE': 'service', + 'COMMAND': None, + '-e': [], + '--user': None, + '--no-deps': None, + '--allow-insecure-ssl': None, + '-d': True, + '-T': None, + '--entrypoint': None, + '--service-ports': True, + '--publish': ['80:80'], + '--rm': None, + }) From 2e7f08c2ef28c375329ab32952b30fe116dadeed Mon Sep 17 00:00:00 2001 From: Karol Duleba Date: Mon, 10 Aug 2015 23:16:55 +0100 Subject: [PATCH 1091/4072] Raise configuration error when trying to extend service that does not exist. Fixes #1826 Signed-off-by: Karol Duleba --- compose/config/config.py | 10 +++++++++- tests/fixtures/extends/nonexistent-service.yml | 4 ++++ tests/unit/config_test.py | 5 +++++ 3 files changed, 18 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/extends/nonexistent-service.yml diff --git a/compose/config/config.py b/compose/config/config.py index b5646a479c0..44c401d4b15 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -186,8 +186,16 @@ def resolve_extends(self, service_dict): already_seen=other_already_seen, ) + base_service = extends_options['service'] other_config = load_yaml(other_config_path) - other_service_dict = other_config[extends_options['service']] + + if base_service not in other_config: + msg = ( + "Cannot extend service '%s' in %s: Service not found" + ) % (base_service, other_config_path) + raise ConfigurationError(msg) + + other_service_dict = other_config[base_service] other_loader.detect_cycle(extends_options['service']) other_service_dict = other_loader.make_service_dict( service_dict['name'], diff --git a/tests/fixtures/extends/nonexistent-service.yml b/tests/fixtures/extends/nonexistent-service.yml new file mode 100644 index 00000000000..e9e17f1bdc1 --- /dev/null +++ b/tests/fixtures/extends/nonexistent-service.yml @@ -0,0 +1,4 @@ +web: + image: busybox + extends: + service: foo diff --git a/tests/unit/config_test.py b/tests/unit/config_test.py index 861d36bdacb..3e3e9e34a61 100644 --- a/tests/unit/config_test.py +++ b/tests/unit/config_test.py @@ -917,6 +917,11 @@ def test_parent_build_path_dne(self): }, ]) + 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): + load_from_filename('tests/fixtures/extends/nonexistent-service.yml') + class BuildPathTest(unittest.TestCase): def setUp(self): From 67995ab9e37cda9c60fd40f96cb77d41bf21de0e Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Wed, 12 Aug 2015 17:09:48 +0100 Subject: [PATCH 1092/4072] Pre-process validation steps In order to validate a service name that has been specified as an integer we need to run that as a pre-process validation step *before* we pass the config to be validated against the schema. It is not possible to validate it *in* the schema, it causes a type error. Even though a number is a valid service name, it must be a cast as a string within the yaml to avoid type error. Taken this opportunity to move the code design in a direction towards: 1. pre-process 2. validate 3. construct Signed-off-by: Mazz Mosley --- compose/config/config.py | 27 +++++++++++++++++++-------- compose/config/validation.py | 24 ++++++++++++++++++++++++ tests/unit/config_test.py | 11 +++++++++++ 3 files changed, 54 insertions(+), 8 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index b5646a479c0..239bbf5e000 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -13,7 +13,11 @@ CircularReference, ComposeFileNotFound, ) -from .validation import validate_against_schema +from .validation import ( + validate_against_schema, + validate_service_names, + validate_top_level_object +) DOCKER_CONFIG_KEYS = [ @@ -122,19 +126,26 @@ def get_config_path(base_dir): return os.path.join(path, winner) +@validate_top_level_object +@validate_service_names +def pre_process_config(config): + """ + Pre validation checks and processing of the config file to interpolate env + vars returning a config dict ready to be tested against the schema. + """ + config = interpolate_environment_variables(config) + return config + + def load(config_details): config, working_dir, filename = config_details - if not isinstance(config, dict): - raise ConfigurationError( - "Top level object needs to be a dictionary. Check your .yml file that you have defined a service at the top level." - ) - config = interpolate_environment_variables(config) - validate_against_schema(config) + processed_config = pre_process_config(config) + validate_against_schema(processed_config) service_dicts = [] - for service_name, service_dict in list(config.items()): + for service_name, service_dict in list(processed_config.items()): loader = ServiceLoader(working_dir=working_dir, filename=filename) service_dict = loader.make_service_dict(service_name, service_dict) validate_paths(service_dict) diff --git a/compose/config/validation.py b/compose/config/validation.py index 36fd03b5f20..26f3ca8ec7a 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -1,3 +1,4 @@ +from functools import wraps import os from docker.utils.ports import split_port @@ -36,6 +37,29 @@ def format_ports(instance): return True +def validate_service_names(func): + @wraps(func) + def func_wrapper(config): + for service_name in config.keys(): + if type(service_name) is int: + raise ConfigurationError( + "Service name: {} needs to be a string, eg '{}'".format(service_name, service_name) + ) + return func(config) + return func_wrapper + + +def validate_top_level_object(func): + @wraps(func) + def func_wrapper(config): + if not isinstance(config, dict): + raise ConfigurationError( + "Top level object needs to be a dictionary. Check your .yml file that you have defined a service at the top level." + ) + return func(config) + return func_wrapper + + 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/tests/unit/config_test.py b/tests/unit/config_test.py index c8a2d0db14b..553e85beb8a 100644 --- a/tests/unit/config_test.py +++ b/tests/unit/config_test.py @@ -64,6 +64,17 @@ def test_config_invalid_service_names(self): ) ) + def test_config_integer_service_name_raise_validation_error(self): + expected_error_msg = "Service name: 1 needs to be a string, eg '1'" + with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): + config.load( + config.ConfigDetails( + {1: {'image': 'busybox'}}, + 'working_dir', + 'filename.yml' + ) + ) + def test_config_valid_service_names(self): for valid_name in ['_', '-', '.__.', '_what-up.', 'what_.up----', 'whatup']: config.load( From 530d20db6d7929b3d73fb08956c3b4f29520d1bd Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 14 Aug 2015 11:15:22 +0100 Subject: [PATCH 1093/4072] Fix volume path warning Signed-off-by: Aanand Prasad --- compose/config/config.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index b5646a479c0..e5b80c01505 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -399,12 +399,12 @@ def resolve_volume_path(volume, working_dir, service_name): if host_path is not None: if not any(host_path.startswith(c) for c in PATH_START_CHARS): log.warn( - 'Warning: the mapping "{0}" in the volumes config for ' - 'service "{1}" is ambiguous. In a future version of Docker, ' + 'Warning: the mapping "{0}:{1}" in the volumes config for ' + 'service "{2}" is ambiguous. In a future version of Docker, ' 'it will designate a "named" volume ' '(see https://github.com/docker/docker/pull/14242). ' - 'To prevent unexpected behaviour, change it to "./{0}"' - .format(volume, service_name) + 'To prevent unexpected behaviour, change it to "./{0}:{1}"' + .format(host_path, container_path, service_name) ) host_path = os.path.expanduser(host_path) From 478054af4775d6f78fcca4b479f91bf569ff04f7 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 11 Aug 2015 15:01:11 +0100 Subject: [PATCH 1094/4072] Rename CHANGES.md to CHANGELOG.md To align with the docker/docker repo. Signed-off-by: Aanand Prasad --- CHANGELOG.md | 414 ++++++++++++++++++++++++++++++++++++++++++++++++++ CHANGES.md | 415 +-------------------------------------------------- 2 files changed, 415 insertions(+), 414 deletions(-) create mode 100644 CHANGELOG.md mode change 100644 => 120000 CHANGES.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000000..88e725da61f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,414 @@ +Change log +========== + +1.4.0 (2015-08-04) +------------------ + +- By default, `docker-compose up` now only recreates containers for services whose configuration has changed since they were created. This should result in a dramatic speed-up for many applications. + + The experimental `--x-smart-recreate` flag which introduced this feature in Compose 1.3.0 has been removed, and a `--force-recreate` flag has been added for when you want to recreate everything. + +- Several of Compose's commands - `scale`, `stop`, `kill` and `rm` - now perform actions on multiple containers in parallel, rather than in sequence, which will run much faster on larger applications. + +- You can now specify a custom name for a service's container with `container_name`. Because Docker container names must be unique, this means you can't scale the service beyond one container. + +- You no longer have to specify a `file` option when using `extends` - it will default to the current file. + +- Service names can now contain dots, dashes and underscores. + +- Compose can now read YAML configuration from standard input, rather than from a file, by specifying `-` as the filename. This makes it easier to generate configuration dynamically: + + $ echo 'redis: {"image": "redis"}' | docker-compose --file - up + +- There's a new `docker-compose version` command which prints extended information about Compose's bundled dependencies. + +- `docker-compose.yml` now supports `log_opt` as well as `log_driver`, allowing you to pass extra configuration to a service's logging driver. + +- `docker-compose.yml` now supports `memswap_limit`, similar to `docker run --memory-swap`. + +- When mounting volumes with the `volumes` option, you can now pass in any mode supported by the daemon, not just `:ro` or `:rw`. For example, SELinux users can pass `:z` or `:Z`. + +- You can now specify a custom volume driver with the `volume_driver` option in `docker-compose.yml`, much like `docker run --volume-driver`. + +- A bug has been fixed where Compose would fail to pull images from private registries serving plain (unsecured) HTTP. The `--allow-insecure-ssl` flag, which was previously used to work around this issue, has been deprecated and now has no effect. + +- A bug has been fixed where `docker-compose build` would fail if the build depended on a private Hub image or an image from a private registry. + +- A bug has been fixed where Compose would crash if there were containers which the Docker daemon had not finished removing. + +- Two bugs have been fixed where Compose would sometimes fail with a "Duplicate bind mount" error, or fail to attach volumes to a container, if there was a volume path specified in `docker-compose.yml` with a trailing slash. + +Thanks @mnowster, @dnephin, @ekristen, @funkyfuture, @jeffk and @lukemarsden! + +1.3.3 (2015-07-15) +------------------ + +Two regressions have been fixed: + +- When stopping containers gracefully, Compose was setting the timeout to 0, effectively forcing a SIGKILL every time. +- Compose would sometimes crash depending on the formatting of container data returned from the Docker API. + +1.3.2 (2015-07-14) +------------------ + +The following bugs have been fixed: + +- When there were one-off containers created by running `docker-compose run` on an older version of Compose, `docker-compose run` would fail with a name collision. Compose now shows an error if you have leftover containers of this type lying around, and tells you how to remove them. +- Compose was not reading Docker authentication config files created in the new location, `~/docker/config.json`, and authentication against private registries would therefore fail. +- When a container had a pseudo-TTY attached, its output in `docker-compose up` would be truncated. +- `docker-compose up --x-smart-recreate` would sometimes fail when an image tag was updated. +- `docker-compose up` would sometimes create two containers with the same numeric suffix. +- `docker-compose rm` and `docker-compose ps` would sometimes list services that aren't part of the current project (though no containers were erroneously removed). +- Some `docker-compose` commands would not show an error if invalid service names were passed in. + +Thanks @dano, @josephpage, @kevinsimper, @lieryan, @phemmer, @soulrebel and @sschepens! + +1.3.1 (2015-06-21) +------------------ + +The following bugs have been fixed: + +- `docker-compose build` would always attempt to pull the base image before building. +- `docker-compose help migrate-to-labels` failed with an error. +- If no network mode was specified, Compose would set it to "bridge", rather than allowing the Docker daemon to use its configured default network mode. + +1.3.0 (2015-06-18) +------------------ + +Firstly, two important notes: + +- **This release contains breaking changes, and you will need to either remove or migrate your existing containers before running your app** - see the [upgrading section of the install docs](https://github.com/docker/compose/blob/1.3.0rc1/docs/install.md#upgrading) for details. + +- Compose now requires Docker 1.6.0 or later. + +We've done a lot of work in this release to remove hacks and make Compose more stable: + +- Compose now uses container labels, rather than names, to keep track of containers. This makes Compose both faster and easier to integrate with your own tools. + +- Compose no longer uses "intermediate containers" when recreating containers for a service. This makes `docker-compose up` less complex and more resilient to failure. + +There are some new features: + +- `docker-compose up` has an **experimental** new behaviour: it will only recreate containers for services whose configuration has changed in `docker-compose.yml`. This will eventually become the default, but for now you can take it for a spin: + + $ docker-compose up --x-smart-recreate + +- When invoked in a subdirectory of a project, `docker-compose` will now climb up through parent directories until it finds a `docker-compose.yml`. + +Several new configuration keys have been added to `docker-compose.yml`: + +- `dockerfile`, like `docker build --file`, lets you specify an alternate Dockerfile to use with `build`. +- `labels`, like `docker run --labels`, lets you add custom metadata to containers. +- `extra_hosts`, like `docker run --add-host`, lets you add entries to a container's `/etc/hosts` file. +- `pid: host`, like `docker run --pid=host`, lets you reuse the same PID namespace as the host machine. +- `cpuset`, like `docker run --cpuset-cpus`, lets you specify which CPUs to allow execution in. +- `read_only`, like `docker run --read-only`, lets you mount a container's filesystem as read-only. +- `security_opt`, like `docker run --security-opt`, lets you specify [security options](https://docs.docker.com/reference/run/#security-configuration). +- `log_driver`, like `docker run --log-driver`, lets you specify a [log driver](https://docs.docker.com/reference/run/#logging-drivers-log-driver). + +Many bugs have been fixed, including the following: + +- The output of `docker-compose run` was sometimes truncated, especially when running under Jenkins. +- A service's volumes would sometimes not update after volume configuration was changed in `docker-compose.yml`. +- Authenticating against third-party registries would sometimes fail. +- `docker-compose run --rm` would fail to remove the container if the service had a `restart` policy in place. +- `docker-compose scale` would refuse to scale a service beyond 1 container if it exposed a specific port number on the host. +- Compose would refuse to create multiple volume entries with the same host path. + +Thanks @ahromis, @albers, @aleksandr-vin, @antoineco, @ccverak, @chernjie, @dnephin, @edmorley, @fordhurley, @josephpage, @KyleJamesWalker, @lsowen, @mchasal, @noironetworks, @sdake, @sdurrheimer, @sherter, @stephenlawrence, @thaJeztah, @thieman, @turtlemonvh, @twhiteman, @vdemeester, @xuxinkun and @zwily! + +1.2.0 (2015-04-16) +------------------ + +- `docker-compose.yml` now supports an `extends` option, which enables a service to inherit configuration from another service in another configuration file. This is really good for sharing common configuration between apps, or for configuring the same app for different environments. Here's the [documentation](https://github.com/docker/compose/blob/master/docs/yml.md#extends). + +- When using Compose with a Swarm cluster, containers that depend on one another will be co-scheduled on the same node. This means that most Compose apps will now work out of the box, as long as they don't use `build`. + +- Repeated invocations of `docker-compose up` when using Compose with a Swarm cluster now work reliably. + +- Directories passed to `build`, filenames passed to `env_file` and volume host paths passed to `volumes` are now treated as relative to the *directory of the configuration file*, not the directory that `docker-compose` is being run in. In the majority of cases, those are the same, but if you use the `-f|--file` argument to specify a configuration file in another directory, **this is a breaking change**. + +- A service can now share another service's network namespace with `net: container:`. + +- `volumes_from` and `net: container:` entries are taken into account when resolving dependencies, so `docker-compose up ` will correctly start all dependencies of ``. + +- `docker-compose run` now accepts a `--user` argument to specify a user to run the command as, just like `docker run`. + +- The `up`, `stop` and `restart` commands now accept a `--timeout` (or `-t`) argument to specify how long to wait when attempting to gracefully stop containers, just like `docker stop`. + +- `docker-compose rm` now accepts `-f` as a shorthand for `--force`, just like `docker rm`. + +Thanks, @abesto, @albers, @alunduil, @dnephin, @funkyfuture, @gilclark, @IanVS, @KingsleyKelly, @knutwalker, @thaJeztah and @vmalloc! + +1.1.0 (2015-02-25) +------------------ + +Fig has been renamed to Docker Compose, or just Compose for short. This has several implications for you: + +- 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`. + +Besides that, there’s a lot of new stuff in this release: + +- We’ve made a few small changes to ensure that Compose will work with Swarm, Docker’s new clustering tool (https://github.com/docker/swarm). Eventually you'll be able to point Compose at a Swarm cluster instead of a standalone Docker host and it’ll run your containers on the cluster with no extra work from you. As Swarm is still developing, integration is rough and lots of Compose features don't work yet. + +- `docker-compose run` now has a `--service-ports` flag for exposing ports on the given service. This is useful for e.g. running your webapp with an interactive debugger. + +- You can now link to containers outside your app with the `external_links` option in docker-compose.yml. + +- You can now prevent `docker-compose up` from automatically building images with the `--no-build` option. This will make fewer API calls and run faster. + +- If you don’t specify a tag when using the `image` key, Compose will default to the `latest` tag, rather than pulling all tags. + +- `docker-compose kill` now supports the `-s` flag, allowing you to specify the exact signal you want to send to a service’s containers. + +- docker-compose.yml now has an `env_file` key, analogous to `docker run --env-file`, letting you specify multiple environment variables in a separate file. This is great if you have a lot of them, or if you want to keep sensitive information out of version control. + +- docker-compose.yml now supports the `dns_search`, `cap_add`, `cap_drop`, `cpu_shares` and `restart` options, analogous to `docker run`’s `--dns-search`, `--cap-add`, `--cap-drop`, `--cpu-shares` and `--restart` options. + +- Compose now ships with Bash tab completion - see the installation and usage docs at https://github.com/docker/compose/blob/1.1.0/docs/completion.md + +- A number of bugs have been fixed - see the milestone for details: https://github.com/docker/compose/issues?q=milestone%3A1.1.0+ + +Thanks @dnephin, @squebe, @jbalonso, @raulcd, @benlangfield, @albers, @ggtools, @bersace, @dtenenba, @petercv, @drewkett, @TFenby, @paulRbr, @Aigeruth and @salehe! + +1.0.1 (2014-11-04) +------------------ + + - Added an `--allow-insecure-ssl` option to allow `fig up`, `fig run` and `fig pull` to pull from insecure registries. + - Fixed `fig run` not showing output in Jenkins. + - Fixed a bug where Fig couldn't build Dockerfiles with ADD statements pointing at URLs. + +1.0.0 (2014-10-16) +------------------ + +The highlights: + + - [Fig has joined Docker.](https://www.orchardup.com/blog/orchard-is-joining-docker) Fig will continue to be maintained, but we'll also be incorporating the best bits of Fig into Docker itself. + + This means the GitHub repository has moved to [https://github.com/docker/fig](https://github.com/docker/fig) and our IRC channel is now #docker-fig on Freenode. + + - Fig can be used with the [official Docker OS X installer](https://docs.docker.com/installation/mac/). Boot2Docker will mount the home directory from your host machine so volumes work as expected. + + - Fig supports Docker 1.3. + + - It is now possible to connect to the Docker daemon using TLS by using the `DOCKER_CERT_PATH` and `DOCKER_TLS_VERIFY` environment variables. + + - There is a new `fig port` command which outputs the host port binding of a service, in a similar way to `docker port`. + + - There is a new `fig pull` command which pulls the latest images for a service. + + - There is a new `fig restart` command which restarts a service's containers. + + - Fig creates multiple containers in service by appending a number to the service name (e.g. `db_1`, `db_2`, etc). As a convenience, Fig will now give the first container an alias of the service name (e.g. `db`). + + This link alias is also a valid hostname and added to `/etc/hosts` so you can connect to linked services using their hostname. For example, instead of resolving the environment variables `DB_PORT_5432_TCP_ADDR` and `DB_PORT_5432_TCP_PORT`, you could just use the hostname `db` and port `5432` directly. + + - Volume definitions now support `ro` mode, expanding `~` and expanding environment variables. + + - `.dockerignore` is supported when building. + + - The project name can be set with the `FIG_PROJECT_NAME` environment variable. + + - The `--env` and `--entrypoint` options have been added to `fig run`. + + - The Fig binary for Linux is now linked against an older version of glibc so it works on CentOS 6 and Debian Wheezy. + +Other things: + + - `fig ps` now works on Jenkins and makes fewer API calls to the Docker daemon. + - `--verbose` displays more useful debugging output. + - When starting a service where `volumes_from` points to a service without any containers running, that service will now be started. + - Lots of docs improvements. Notably, environment variables are documented and official repositories are used throughout. + +Thanks @dnephin, @d11wtq, @marksteve, @rubbish, @jbalonso, @timfreund, @alunduil, @mieciu, @shuron, @moss, @suzaku and @chmouel! Whew. + +0.5.2 (2014-07-28) +------------------ + + - Added a `--no-cache` option to `fig build`, which bypasses the cache just like `docker build --no-cache`. + - Fixed the `dns:` fig.yml option, which was causing fig to error out. + - Fixed a bug where fig couldn't start under Python 2.6. + - Fixed a log-streaming bug that occasionally caused fig to exit. + +Thanks @dnephin and @marksteve! + + +0.5.1 (2014-07-11) +------------------ + + - If a service has a command defined, `fig run [service]` with no further arguments will run it. + - The project name now defaults to the directory containing fig.yml, not the current working directory (if they're different) + - `volumes_from` now works properly with containers as well as services + - Fixed a race condition when recreating containers in `fig up` + +Thanks @ryanbrainard and @d11wtq! + + +0.5.0 (2014-07-11) +------------------ + + - Fig now starts links when you run `fig run` or `fig up`. + + For example, if you have a `web` service which depends on a `db` service, `fig run web ...` will start the `db` service. + + - Environment variables can now be resolved from the environment that Fig is running in. Just specify it as a blank variable in your `fig.yml` and, if set, it'll be resolved: + ``` + environment: + RACK_ENV: development + SESSION_SECRET: + ``` + + - `volumes_from` is now supported in `fig.yml`. All of the volumes from the specified services and containers will be mounted: + + ``` + volumes_from: + - service_name + - container_name + ``` + + - A host address can now be specified in `ports`: + + ``` + ports: + - "0.0.0.0:8000:8000" + - "127.0.0.1:8001:8001" + ``` + + - The `net` and `workdir` options are now supported in `fig.yml`. + - The `hostname` option now works in the same way as the Docker CLI, splitting out into a `domainname` option. + - TTY behaviour is far more robust, and resizes are supported correctly. + - Load YAML files safely. + +Thanks to @d11wtq, @ryanbrainard, @rail44, @j0hnsmith, @binarin, @Elemecca, @mozz100 and @marksteve for their help with this release! + + +0.4.2 (2014-06-18) +------------------ + + - Fix various encoding errors when using `fig run`, `fig up` and `fig build`. + +0.4.1 (2014-05-08) +------------------ + + - Add support for Docker 0.11.0. (Thanks @marksteve!) + - Make project name configurable. (Thanks @jefmathiot!) + - Return correct exit code from `fig run`. + +0.4.0 (2014-04-29) +------------------ + + - Support Docker 0.9 and 0.10 + - Display progress bars correctly when pulling images (no more ski slopes) + - `fig up` now stops all services when any container exits + - Added support for the `privileged` config option in fig.yml (thanks @kvz!) + - Shortened and aligned log prefixes in `fig up` output + - Only containers started with `fig run` link back to their own service + - Handle UTF-8 correctly when streaming `fig build/run/up` output (thanks @mauvm and @shanejonas!) + - Error message improvements + +0.3.2 (2014-03-05) +------------------ + + - Added an `--rm` option to `fig run`. (Thanks @marksteve!) + - Added an `expose` option to `fig.yml`. + +0.3.1 (2014-03-04) +------------------ + + - Added contribution instructions. (Thanks @kvz!) + - Fixed `fig rm` throwing an error. + - Fixed a bug in `fig ps` on Docker 0.8.1 when there is a container with no command. + +0.3.0 (2014-03-03) +------------------ + + - We now ship binaries for OS X and Linux. No more having to install with Pip! + - Add `-f` flag to specify alternate `fig.yml` files + - Add support for custom link names + - Fix a bug where recreating would sometimes hang + - Update docker-py to support Docker 0.8.0. + - Various documentation improvements + - Various error message improvements + +Thanks @marksteve, @Gazler and @teozkr! + +0.2.2 (2014-02-17) +------------------ + + - Resolve dependencies using Cormen/Tarjan topological sort + - Fix `fig up` not printing log output + - Stop containers in reverse order to starting + - Fix scale command not binding ports + +Thanks to @barnybug and @dustinlacewell for their work on this release. + +0.2.1 (2014-02-04) +------------------ + + - General improvements to error reporting (#77, #79) + +0.2.0 (2014-01-31) +------------------ + + - Link services to themselves so run commands can access the running service. (#67) + - Much better documentation. + - Make service dependency resolution more reliable. (#48) + - Load Fig configurations with a `.yaml` extension. (#58) + +Big thanks to @cameronmaske, @mrchrisadams and @damianmoore for their help with this release. + +0.1.4 (2014-01-27) +------------------ + + - Add a link alias without the project name. This makes the environment variables a little shorter: `REDIS_1_PORT_6379_TCP_ADDR`. (#54) + +0.1.3 (2014-01-23) +------------------ + + - Fix ports sometimes being configured incorrectly. (#46) + - Fix log output sometimes not displaying. (#47) + +0.1.2 (2014-01-22) +------------------ + + - Add `-T` option to `fig run` to disable pseudo-TTY. (#34) + - Fix `fig up` requiring the ubuntu image to be pulled to recreate containers. (#33) Thanks @cameronmaske! + - Improve reliability, fix arrow keys and fix a race condition in `fig run`. (#34, #39, #40) + +0.1.1 (2014-01-17) +------------------ + + - Fix bug where ports were not exposed correctly (#29). Thanks @dustinlacewell! + +0.1.0 (2014-01-16) +------------------ + + - Containers are recreated on each `fig up`, ensuring config is up-to-date with `fig.yml` (#2) + - Add `fig scale` command (#9) + - Use `DOCKER_HOST` environment variable to find Docker daemon, for consistency with the official Docker client (was previously `DOCKER_URL`) (#19) + - Truncate long commands in `fig ps` (#18) + - Fill out CLI help banners for commands (#15, #16) + - Show a friendlier error when `fig.yml` is missing (#4) + - Fix bug with `fig build` logging (#3) + - Fix bug where builds would time out if a step took a long time without generating output (#6) + - Fix bug where streaming container output over the Unix socket raised an error (#7) + +Big thanks to @tomstuart, @EnTeQuAk, @schickling, @aronasorman and @GeoffreyPlitt. + +0.0.2 (2014-01-02) +------------------ + + - Improve documentation + - Try to connect to Docker on `tcp://localdocker:4243` and a UNIX socket in addition to `localhost`. + - Improve `fig up` behaviour + - Add confirmation prompt to `fig rm` + - Add `fig build` command + +0.0.1 (2013-12-20) +------------------ + +Initial release. + + diff --git a/CHANGES.md b/CHANGES.md deleted file mode 100644 index 88e725da61f..00000000000 --- a/CHANGES.md +++ /dev/null @@ -1,414 +0,0 @@ -Change log -========== - -1.4.0 (2015-08-04) ------------------- - -- By default, `docker-compose up` now only recreates containers for services whose configuration has changed since they were created. This should result in a dramatic speed-up for many applications. - - The experimental `--x-smart-recreate` flag which introduced this feature in Compose 1.3.0 has been removed, and a `--force-recreate` flag has been added for when you want to recreate everything. - -- Several of Compose's commands - `scale`, `stop`, `kill` and `rm` - now perform actions on multiple containers in parallel, rather than in sequence, which will run much faster on larger applications. - -- You can now specify a custom name for a service's container with `container_name`. Because Docker container names must be unique, this means you can't scale the service beyond one container. - -- You no longer have to specify a `file` option when using `extends` - it will default to the current file. - -- Service names can now contain dots, dashes and underscores. - -- Compose can now read YAML configuration from standard input, rather than from a file, by specifying `-` as the filename. This makes it easier to generate configuration dynamically: - - $ echo 'redis: {"image": "redis"}' | docker-compose --file - up - -- There's a new `docker-compose version` command which prints extended information about Compose's bundled dependencies. - -- `docker-compose.yml` now supports `log_opt` as well as `log_driver`, allowing you to pass extra configuration to a service's logging driver. - -- `docker-compose.yml` now supports `memswap_limit`, similar to `docker run --memory-swap`. - -- When mounting volumes with the `volumes` option, you can now pass in any mode supported by the daemon, not just `:ro` or `:rw`. For example, SELinux users can pass `:z` or `:Z`. - -- You can now specify a custom volume driver with the `volume_driver` option in `docker-compose.yml`, much like `docker run --volume-driver`. - -- A bug has been fixed where Compose would fail to pull images from private registries serving plain (unsecured) HTTP. The `--allow-insecure-ssl` flag, which was previously used to work around this issue, has been deprecated and now has no effect. - -- A bug has been fixed where `docker-compose build` would fail if the build depended on a private Hub image or an image from a private registry. - -- A bug has been fixed where Compose would crash if there were containers which the Docker daemon had not finished removing. - -- Two bugs have been fixed where Compose would sometimes fail with a "Duplicate bind mount" error, or fail to attach volumes to a container, if there was a volume path specified in `docker-compose.yml` with a trailing slash. - -Thanks @mnowster, @dnephin, @ekristen, @funkyfuture, @jeffk and @lukemarsden! - -1.3.3 (2015-07-15) ------------------- - -Two regressions have been fixed: - -- When stopping containers gracefully, Compose was setting the timeout to 0, effectively forcing a SIGKILL every time. -- Compose would sometimes crash depending on the formatting of container data returned from the Docker API. - -1.3.2 (2015-07-14) ------------------- - -The following bugs have been fixed: - -- When there were one-off containers created by running `docker-compose run` on an older version of Compose, `docker-compose run` would fail with a name collision. Compose now shows an error if you have leftover containers of this type lying around, and tells you how to remove them. -- Compose was not reading Docker authentication config files created in the new location, `~/docker/config.json`, and authentication against private registries would therefore fail. -- When a container had a pseudo-TTY attached, its output in `docker-compose up` would be truncated. -- `docker-compose up --x-smart-recreate` would sometimes fail when an image tag was updated. -- `docker-compose up` would sometimes create two containers with the same numeric suffix. -- `docker-compose rm` and `docker-compose ps` would sometimes list services that aren't part of the current project (though no containers were erroneously removed). -- Some `docker-compose` commands would not show an error if invalid service names were passed in. - -Thanks @dano, @josephpage, @kevinsimper, @lieryan, @phemmer, @soulrebel and @sschepens! - -1.3.1 (2015-06-21) ------------------- - -The following bugs have been fixed: - -- `docker-compose build` would always attempt to pull the base image before building. -- `docker-compose help migrate-to-labels` failed with an error. -- If no network mode was specified, Compose would set it to "bridge", rather than allowing the Docker daemon to use its configured default network mode. - -1.3.0 (2015-06-18) ------------------- - -Firstly, two important notes: - -- **This release contains breaking changes, and you will need to either remove or migrate your existing containers before running your app** - see the [upgrading section of the install docs](https://github.com/docker/compose/blob/1.3.0rc1/docs/install.md#upgrading) for details. - -- Compose now requires Docker 1.6.0 or later. - -We've done a lot of work in this release to remove hacks and make Compose more stable: - -- Compose now uses container labels, rather than names, to keep track of containers. This makes Compose both faster and easier to integrate with your own tools. - -- Compose no longer uses "intermediate containers" when recreating containers for a service. This makes `docker-compose up` less complex and more resilient to failure. - -There are some new features: - -- `docker-compose up` has an **experimental** new behaviour: it will only recreate containers for services whose configuration has changed in `docker-compose.yml`. This will eventually become the default, but for now you can take it for a spin: - - $ docker-compose up --x-smart-recreate - -- When invoked in a subdirectory of a project, `docker-compose` will now climb up through parent directories until it finds a `docker-compose.yml`. - -Several new configuration keys have been added to `docker-compose.yml`: - -- `dockerfile`, like `docker build --file`, lets you specify an alternate Dockerfile to use with `build`. -- `labels`, like `docker run --labels`, lets you add custom metadata to containers. -- `extra_hosts`, like `docker run --add-host`, lets you add entries to a container's `/etc/hosts` file. -- `pid: host`, like `docker run --pid=host`, lets you reuse the same PID namespace as the host machine. -- `cpuset`, like `docker run --cpuset-cpus`, lets you specify which CPUs to allow execution in. -- `read_only`, like `docker run --read-only`, lets you mount a container's filesystem as read-only. -- `security_opt`, like `docker run --security-opt`, lets you specify [security options](https://docs.docker.com/reference/run/#security-configuration). -- `log_driver`, like `docker run --log-driver`, lets you specify a [log driver](https://docs.docker.com/reference/run/#logging-drivers-log-driver). - -Many bugs have been fixed, including the following: - -- The output of `docker-compose run` was sometimes truncated, especially when running under Jenkins. -- A service's volumes would sometimes not update after volume configuration was changed in `docker-compose.yml`. -- Authenticating against third-party registries would sometimes fail. -- `docker-compose run --rm` would fail to remove the container if the service had a `restart` policy in place. -- `docker-compose scale` would refuse to scale a service beyond 1 container if it exposed a specific port number on the host. -- Compose would refuse to create multiple volume entries with the same host path. - -Thanks @ahromis, @albers, @aleksandr-vin, @antoineco, @ccverak, @chernjie, @dnephin, @edmorley, @fordhurley, @josephpage, @KyleJamesWalker, @lsowen, @mchasal, @noironetworks, @sdake, @sdurrheimer, @sherter, @stephenlawrence, @thaJeztah, @thieman, @turtlemonvh, @twhiteman, @vdemeester, @xuxinkun and @zwily! - -1.2.0 (2015-04-16) ------------------- - -- `docker-compose.yml` now supports an `extends` option, which enables a service to inherit configuration from another service in another configuration file. This is really good for sharing common configuration between apps, or for configuring the same app for different environments. Here's the [documentation](https://github.com/docker/compose/blob/master/docs/yml.md#extends). - -- When using Compose with a Swarm cluster, containers that depend on one another will be co-scheduled on the same node. This means that most Compose apps will now work out of the box, as long as they don't use `build`. - -- Repeated invocations of `docker-compose up` when using Compose with a Swarm cluster now work reliably. - -- Directories passed to `build`, filenames passed to `env_file` and volume host paths passed to `volumes` are now treated as relative to the *directory of the configuration file*, not the directory that `docker-compose` is being run in. In the majority of cases, those are the same, but if you use the `-f|--file` argument to specify a configuration file in another directory, **this is a breaking change**. - -- A service can now share another service's network namespace with `net: container:`. - -- `volumes_from` and `net: container:` entries are taken into account when resolving dependencies, so `docker-compose up ` will correctly start all dependencies of ``. - -- `docker-compose run` now accepts a `--user` argument to specify a user to run the command as, just like `docker run`. - -- The `up`, `stop` and `restart` commands now accept a `--timeout` (or `-t`) argument to specify how long to wait when attempting to gracefully stop containers, just like `docker stop`. - -- `docker-compose rm` now accepts `-f` as a shorthand for `--force`, just like `docker rm`. - -Thanks, @abesto, @albers, @alunduil, @dnephin, @funkyfuture, @gilclark, @IanVS, @KingsleyKelly, @knutwalker, @thaJeztah and @vmalloc! - -1.1.0 (2015-02-25) ------------------- - -Fig has been renamed to Docker Compose, or just Compose for short. This has several implications for you: - -- 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`. - -Besides that, there’s a lot of new stuff in this release: - -- We’ve made a few small changes to ensure that Compose will work with Swarm, Docker’s new clustering tool (https://github.com/docker/swarm). Eventually you'll be able to point Compose at a Swarm cluster instead of a standalone Docker host and it’ll run your containers on the cluster with no extra work from you. As Swarm is still developing, integration is rough and lots of Compose features don't work yet. - -- `docker-compose run` now has a `--service-ports` flag for exposing ports on the given service. This is useful for e.g. running your webapp with an interactive debugger. - -- You can now link to containers outside your app with the `external_links` option in docker-compose.yml. - -- You can now prevent `docker-compose up` from automatically building images with the `--no-build` option. This will make fewer API calls and run faster. - -- If you don’t specify a tag when using the `image` key, Compose will default to the `latest` tag, rather than pulling all tags. - -- `docker-compose kill` now supports the `-s` flag, allowing you to specify the exact signal you want to send to a service’s containers. - -- docker-compose.yml now has an `env_file` key, analogous to `docker run --env-file`, letting you specify multiple environment variables in a separate file. This is great if you have a lot of them, or if you want to keep sensitive information out of version control. - -- docker-compose.yml now supports the `dns_search`, `cap_add`, `cap_drop`, `cpu_shares` and `restart` options, analogous to `docker run`’s `--dns-search`, `--cap-add`, `--cap-drop`, `--cpu-shares` and `--restart` options. - -- Compose now ships with Bash tab completion - see the installation and usage docs at https://github.com/docker/compose/blob/1.1.0/docs/completion.md - -- A number of bugs have been fixed - see the milestone for details: https://github.com/docker/compose/issues?q=milestone%3A1.1.0+ - -Thanks @dnephin, @squebe, @jbalonso, @raulcd, @benlangfield, @albers, @ggtools, @bersace, @dtenenba, @petercv, @drewkett, @TFenby, @paulRbr, @Aigeruth and @salehe! - -1.0.1 (2014-11-04) ------------------- - - - Added an `--allow-insecure-ssl` option to allow `fig up`, `fig run` and `fig pull` to pull from insecure registries. - - Fixed `fig run` not showing output in Jenkins. - - Fixed a bug where Fig couldn't build Dockerfiles with ADD statements pointing at URLs. - -1.0.0 (2014-10-16) ------------------- - -The highlights: - - - [Fig has joined Docker.](https://www.orchardup.com/blog/orchard-is-joining-docker) Fig will continue to be maintained, but we'll also be incorporating the best bits of Fig into Docker itself. - - This means the GitHub repository has moved to [https://github.com/docker/fig](https://github.com/docker/fig) and our IRC channel is now #docker-fig on Freenode. - - - Fig can be used with the [official Docker OS X installer](https://docs.docker.com/installation/mac/). Boot2Docker will mount the home directory from your host machine so volumes work as expected. - - - Fig supports Docker 1.3. - - - It is now possible to connect to the Docker daemon using TLS by using the `DOCKER_CERT_PATH` and `DOCKER_TLS_VERIFY` environment variables. - - - There is a new `fig port` command which outputs the host port binding of a service, in a similar way to `docker port`. - - - There is a new `fig pull` command which pulls the latest images for a service. - - - There is a new `fig restart` command which restarts a service's containers. - - - Fig creates multiple containers in service by appending a number to the service name (e.g. `db_1`, `db_2`, etc). As a convenience, Fig will now give the first container an alias of the service name (e.g. `db`). - - This link alias is also a valid hostname and added to `/etc/hosts` so you can connect to linked services using their hostname. For example, instead of resolving the environment variables `DB_PORT_5432_TCP_ADDR` and `DB_PORT_5432_TCP_PORT`, you could just use the hostname `db` and port `5432` directly. - - - Volume definitions now support `ro` mode, expanding `~` and expanding environment variables. - - - `.dockerignore` is supported when building. - - - The project name can be set with the `FIG_PROJECT_NAME` environment variable. - - - The `--env` and `--entrypoint` options have been added to `fig run`. - - - The Fig binary for Linux is now linked against an older version of glibc so it works on CentOS 6 and Debian Wheezy. - -Other things: - - - `fig ps` now works on Jenkins and makes fewer API calls to the Docker daemon. - - `--verbose` displays more useful debugging output. - - When starting a service where `volumes_from` points to a service without any containers running, that service will now be started. - - Lots of docs improvements. Notably, environment variables are documented and official repositories are used throughout. - -Thanks @dnephin, @d11wtq, @marksteve, @rubbish, @jbalonso, @timfreund, @alunduil, @mieciu, @shuron, @moss, @suzaku and @chmouel! Whew. - -0.5.2 (2014-07-28) ------------------- - - - Added a `--no-cache` option to `fig build`, which bypasses the cache just like `docker build --no-cache`. - - Fixed the `dns:` fig.yml option, which was causing fig to error out. - - Fixed a bug where fig couldn't start under Python 2.6. - - Fixed a log-streaming bug that occasionally caused fig to exit. - -Thanks @dnephin and @marksteve! - - -0.5.1 (2014-07-11) ------------------- - - - If a service has a command defined, `fig run [service]` with no further arguments will run it. - - The project name now defaults to the directory containing fig.yml, not the current working directory (if they're different) - - `volumes_from` now works properly with containers as well as services - - Fixed a race condition when recreating containers in `fig up` - -Thanks @ryanbrainard and @d11wtq! - - -0.5.0 (2014-07-11) ------------------- - - - Fig now starts links when you run `fig run` or `fig up`. - - For example, if you have a `web` service which depends on a `db` service, `fig run web ...` will start the `db` service. - - - Environment variables can now be resolved from the environment that Fig is running in. Just specify it as a blank variable in your `fig.yml` and, if set, it'll be resolved: - ``` - environment: - RACK_ENV: development - SESSION_SECRET: - ``` - - - `volumes_from` is now supported in `fig.yml`. All of the volumes from the specified services and containers will be mounted: - - ``` - volumes_from: - - service_name - - container_name - ``` - - - A host address can now be specified in `ports`: - - ``` - ports: - - "0.0.0.0:8000:8000" - - "127.0.0.1:8001:8001" - ``` - - - The `net` and `workdir` options are now supported in `fig.yml`. - - The `hostname` option now works in the same way as the Docker CLI, splitting out into a `domainname` option. - - TTY behaviour is far more robust, and resizes are supported correctly. - - Load YAML files safely. - -Thanks to @d11wtq, @ryanbrainard, @rail44, @j0hnsmith, @binarin, @Elemecca, @mozz100 and @marksteve for their help with this release! - - -0.4.2 (2014-06-18) ------------------- - - - Fix various encoding errors when using `fig run`, `fig up` and `fig build`. - -0.4.1 (2014-05-08) ------------------- - - - Add support for Docker 0.11.0. (Thanks @marksteve!) - - Make project name configurable. (Thanks @jefmathiot!) - - Return correct exit code from `fig run`. - -0.4.0 (2014-04-29) ------------------- - - - Support Docker 0.9 and 0.10 - - Display progress bars correctly when pulling images (no more ski slopes) - - `fig up` now stops all services when any container exits - - Added support for the `privileged` config option in fig.yml (thanks @kvz!) - - Shortened and aligned log prefixes in `fig up` output - - Only containers started with `fig run` link back to their own service - - Handle UTF-8 correctly when streaming `fig build/run/up` output (thanks @mauvm and @shanejonas!) - - Error message improvements - -0.3.2 (2014-03-05) ------------------- - - - Added an `--rm` option to `fig run`. (Thanks @marksteve!) - - Added an `expose` option to `fig.yml`. - -0.3.1 (2014-03-04) ------------------- - - - Added contribution instructions. (Thanks @kvz!) - - Fixed `fig rm` throwing an error. - - Fixed a bug in `fig ps` on Docker 0.8.1 when there is a container with no command. - -0.3.0 (2014-03-03) ------------------- - - - We now ship binaries for OS X and Linux. No more having to install with Pip! - - Add `-f` flag to specify alternate `fig.yml` files - - Add support for custom link names - - Fix a bug where recreating would sometimes hang - - Update docker-py to support Docker 0.8.0. - - Various documentation improvements - - Various error message improvements - -Thanks @marksteve, @Gazler and @teozkr! - -0.2.2 (2014-02-17) ------------------- - - - Resolve dependencies using Cormen/Tarjan topological sort - - Fix `fig up` not printing log output - - Stop containers in reverse order to starting - - Fix scale command not binding ports - -Thanks to @barnybug and @dustinlacewell for their work on this release. - -0.2.1 (2014-02-04) ------------------- - - - General improvements to error reporting (#77, #79) - -0.2.0 (2014-01-31) ------------------- - - - Link services to themselves so run commands can access the running service. (#67) - - Much better documentation. - - Make service dependency resolution more reliable. (#48) - - Load Fig configurations with a `.yaml` extension. (#58) - -Big thanks to @cameronmaske, @mrchrisadams and @damianmoore for their help with this release. - -0.1.4 (2014-01-27) ------------------- - - - Add a link alias without the project name. This makes the environment variables a little shorter: `REDIS_1_PORT_6379_TCP_ADDR`. (#54) - -0.1.3 (2014-01-23) ------------------- - - - Fix ports sometimes being configured incorrectly. (#46) - - Fix log output sometimes not displaying. (#47) - -0.1.2 (2014-01-22) ------------------- - - - Add `-T` option to `fig run` to disable pseudo-TTY. (#34) - - Fix `fig up` requiring the ubuntu image to be pulled to recreate containers. (#33) Thanks @cameronmaske! - - Improve reliability, fix arrow keys and fix a race condition in `fig run`. (#34, #39, #40) - -0.1.1 (2014-01-17) ------------------- - - - Fix bug where ports were not exposed correctly (#29). Thanks @dustinlacewell! - -0.1.0 (2014-01-16) ------------------- - - - Containers are recreated on each `fig up`, ensuring config is up-to-date with `fig.yml` (#2) - - Add `fig scale` command (#9) - - Use `DOCKER_HOST` environment variable to find Docker daemon, for consistency with the official Docker client (was previously `DOCKER_URL`) (#19) - - Truncate long commands in `fig ps` (#18) - - Fill out CLI help banners for commands (#15, #16) - - Show a friendlier error when `fig.yml` is missing (#4) - - Fix bug with `fig build` logging (#3) - - Fix bug where builds would time out if a step took a long time without generating output (#6) - - Fix bug where streaming container output over the Unix socket raised an error (#7) - -Big thanks to @tomstuart, @EnTeQuAk, @schickling, @aronasorman and @GeoffreyPlitt. - -0.0.2 (2014-01-02) ------------------- - - - Improve documentation - - Try to connect to Docker on `tcp://localdocker:4243` and a UNIX socket in addition to `localhost`. - - Improve `fig up` behaviour - - Add confirmation prompt to `fig rm` - - Add `fig build` command - -0.0.1 (2013-12-20) ------------------- - -Initial release. - - diff --git a/CHANGES.md b/CHANGES.md new file mode 120000 index 00000000000..83b694704ba --- /dev/null +++ b/CHANGES.md @@ -0,0 +1 @@ +CHANGELOG.md \ No newline at end of file From 65afce526a83c6654428d0d06e45a25991a755fd Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 14 Aug 2015 12:42:33 +0100 Subject: [PATCH 1095/4072] Test against Docker 1.8.1 Signed-off-by: Aanand Prasad --- Dockerfile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 7c0482323b2..ed23e75acb6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -48,13 +48,13 @@ RUN set -ex; \ rm -rf pip-7.0.1; \ rm pip-7.0.1.tar.gz -ENV ALL_DOCKER_VERSIONS 1.7.1 1.8.0-rc3 +ENV ALL_DOCKER_VERSIONS 1.7.1 1.8.1 RUN set -ex; \ curl https://get.docker.com/builds/Linux/x86_64/docker-1.7.1 -o /usr/local/bin/docker-1.7.1; \ chmod +x /usr/local/bin/docker-1.7.1; \ - curl https://test.docker.com/builds/Linux/x86_64/docker-1.8.0-rc3 -o /usr/local/bin/docker-1.8.0-rc3; \ - chmod +x /usr/local/bin/docker-1.8.0-rc3 + curl https://get.docker.com/builds/Linux/x86_64/docker-1.8.1 -o /usr/local/bin/docker-1.8.1; \ + chmod +x /usr/local/bin/docker-1.8.1 # Set the default Docker to be run RUN ln -s /usr/local/bin/docker-1.7.1 /usr/local/bin/docker From f4a8fda283ee63c524b6929d11c71eac4be3751c Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Mon, 17 Aug 2015 16:31:57 +0100 Subject: [PATCH 1096/4072] Handle all exceptions If we get back an error that wasn't an APIError, it was causing the thread to hang. This catch all, while I appreciate feels risky to have a catch all, is better than not catching and silently failing, with a never ending thread. If something worse than an APIError has gone wrong, we want to stop the incredible journey of what we're doing. Signed-off-by: Mazz Mosley --- compose/utils.py | 7 +++++++ tests/integration/service_test.py | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/compose/utils.py b/compose/utils.py index 4c7f94c5769..61d6d802438 100644 --- a/compose/utils.py +++ b/compose/utils.py @@ -32,6 +32,10 @@ def inner_execute_function(an_callable, parameter, msg_index): except APIError as e: errors[msg_index] = e.explanation result = "error" + except Exception as e: + errors[msg_index] = e + result = 'unexpected_exception' + q.put((msg_index, result)) for an_object in objects: @@ -48,6 +52,9 @@ def inner_execute_function(an_callable, parameter, msg_index): while done < total_to_execute: try: msg_index, result = q.get(timeout=1) + + if result == 'unexpected_exception': + raise errors[msg_index] if result == 'error': write_out_msg(stream, lines, msg_index, msg, status='error') else: diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 9bdc12f9930..050a3bf622e 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -654,6 +654,25 @@ def test_scale_with_api_returns_errors(self, mock_stdout): self.assertTrue(service.containers()[0].is_running) self.assertIn("ERROR: for 2 Boom", mock_stdout.getvalue()) + @patch('sys.stdout', new_callable=StringIO) + def test_scale_with_api_returns_unexpected_exception(self, mock_stdout): + """ + Test that when scaling if the API returns an error, that is not of type + APIError, that error is re-raised. + """ + service = self.create_service('web') + next_number = service._next_container_number() + service.create_container(number=next_number, quiet=True) + + with patch( + 'compose.container.Container.create', + side_effect=ValueError("BOOM")): + with self.assertRaises(ValueError): + service.scale(3) + + self.assertEqual(len(service.containers()), 1) + self.assertTrue(service.containers()[0].is_running) + @patch('compose.service.log') def test_scale_with_desired_number_already_achieved(self, mock_log): """ From 18a474211d29a59b9251ce4aa9947bd7d8241114 Mon Sep 17 00:00:00 2001 From: Maxime Horcholle Date: Tue, 18 Aug 2015 09:07:15 +0200 Subject: [PATCH 1097/4072] remove extra ``` Signed-off-by: mhor --- docs/yml.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/yml.md b/docs/yml.md index bec857f8e21..3e9a35ca431 100644 --- a/docs/yml.md +++ b/docs/yml.md @@ -394,7 +394,6 @@ Each of these is a single value, analogous to its read_only: true volume_driver: mydriver -``` ## Variable substitution From 56f03bc20acaffc9b5d4c2a5898ef47126f4df19 Mon Sep 17 00:00:00 2001 From: Karol Duleba Date: Tue, 18 Aug 2015 21:46:05 +0100 Subject: [PATCH 1098/4072] Allow to specify image by digest. Fixes #1670 Signed-off-by: Karol Duleba --- compose/service.py | 35 +++++++++++++++----- docs/yml.md | 3 +- tests/fixtures/simple-composefile/digest.yml | 6 ++++ tests/integration/cli_test.py | 6 ++++ tests/unit/service_test.py | 26 +++++++++++---- 5 files changed, 60 insertions(+), 16 deletions(-) create mode 100644 tests/fixtures/simple-composefile/digest.yml diff --git a/compose/service.py b/compose/service.py index 5a79414bcdd..e49acf0c6a2 100644 --- a/compose/service.py +++ b/compose/service.py @@ -757,9 +757,9 @@ def pull(self): if 'image' not in self.options: return - repo, tag = parse_repository_tag(self.options['image']) + repo, tag, separator = parse_repository_tag(self.options['image']) tag = tag or 'latest' - log.info('Pulling %s (%s:%s)...' % (self.name, repo, tag)) + log.info('Pulling %s (%s%s%s)...' % (self.name, repo, separator, tag)) output = self.client.pull( repo, tag=tag, @@ -780,14 +780,31 @@ def build_container_name(project, service, number, one_off=False): # Images +def parse_repository_tag(repo_path): + """Splits image identification into base image path, tag/digest + and it's separator. -def parse_repository_tag(s): - if ":" not in s: - return s, "" - repo, tag = s.rsplit(":", 1) - if "/" in tag: - return s, "" - return repo, tag + Example: + + >>> parse_repository_tag('user/repo@sha256:digest') + ('user/repo', 'sha256:digest', '@') + >>> parse_repository_tag('user/repo:v1') + ('user/repo', 'v1', ':') + """ + tag_separator = ":" + digest_separator = "@" + + if digest_separator in repo_path: + repo, tag = repo_path.rsplit(digest_separator, 1) + return repo, tag, digest_separator + + repo, tag = repo_path, "" + if tag_separator in repo_path: + repo, tag = repo_path.rsplit(tag_separator, 1) + if "/" in tag: + repo, tag = repo_path, "" + + return repo, tag, tag_separator # Volumes diff --git a/docs/yml.md b/docs/yml.md index 3e9a35ca431..bad9c9bc194 100644 --- a/docs/yml.md +++ b/docs/yml.md @@ -25,12 +25,13 @@ Values for configuration options can contain environment variables, e.g. ### image -Tag or partial image ID. Can be local or remote - Compose will attempt to +Tag, partial image ID or digest. Can be local or remote - Compose will attempt to pull if it doesn't exist locally. image: ubuntu image: orchardup/postgresql image: a4bc65fd + image: busybox@sha256:38a203e1986cf79639cfb9b2e1d6e773de84002feea2d4eb006b52004ee8502d ### build diff --git a/tests/fixtures/simple-composefile/digest.yml b/tests/fixtures/simple-composefile/digest.yml new file mode 100644 index 00000000000..08f1d993e95 --- /dev/null +++ b/tests/fixtures/simple-composefile/digest.yml @@ -0,0 +1,6 @@ +simple: + image: busybox:latest + command: top +digest: + image: busybox@sha256:38a203e1986cf79639cfb9b2e1d6e773de84002feea2d4eb006b52004ee8502d + command: top diff --git a/tests/integration/cli_test.py b/tests/integration/cli_test.py index ef789e19c7f..a02e072fd76 100644 --- a/tests/integration/cli_test.py +++ b/tests/integration/cli_test.py @@ -88,6 +88,12 @@ def test_pull(self, mock_logging): mock_logging.info.assert_any_call('Pulling simple (busybox:latest)...') mock_logging.info.assert_any_call('Pulling another (busybox:latest)...') + @patch('compose.service.log') + def test_pull_with_digest(self, mock_logging): + self.command.dispatch(['-f', 'digest.yml', 'pull'], None) + mock_logging.info.assert_any_call('Pulling simple (busybox:latest)...') + mock_logging.info.assert_any_call('Pulling digest (busybox@sha256:38a203e1986cf79639cfb9b2e1d6e773de84002feea2d4eb006b52004ee8502d)...') + @patch('sys.stdout', new_callable=StringIO) def test_build_no_cache(self, mock_stdout): self.command.base_dir = 'tests/fixtures/simple-dockerfile' diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index bb68c9aa6d4..8b39a63ef7c 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -192,6 +192,16 @@ def test_pull_image_no_tag(self): tag='latest', stream=True) + @mock.patch('compose.service.log', autospec=True) + def test_pull_image_digest(self, mock_log): + service = Service('foo', client=self.mock_client, image='someimage@sha256:1234') + service.pull() + self.mock_client.pull.assert_called_once_with( + 'someimage', + tag='sha256:1234', + stream=True) + mock_log.info.assert_called_once_with('Pulling foo (someimage@sha256:1234)...') + @mock.patch('compose.service.Container', autospec=True) def test_recreate_container(self, _): mock_container = mock.create_autospec(Container) @@ -217,12 +227,16 @@ def test_recreate_container_with_timeout(self, _): mock_container.stop.assert_called_once_with(timeout=1) def test_parse_repository_tag(self): - self.assertEqual(parse_repository_tag("root"), ("root", "")) - self.assertEqual(parse_repository_tag("root:tag"), ("root", "tag")) - 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"), ("root", "", ":")) + self.assertEqual(parse_repository_tag("root:tag"), ("root", "tag", ":")) + 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", "@")) @mock.patch('compose.service.Container', autospec=True) def test_create_container_latest_is_used_when_no_tag_specified(self, mock_container): From 61936f6b88e8e859b23dcf933246675185aaeabd Mon Sep 17 00:00:00 2001 From: Joel Hansson Date: Thu, 20 Aug 2015 16:38:43 +0200 Subject: [PATCH 1099/4072] log_opt: change address to syslog-address Signed-off-by: Joel Hansson --- compose/config/schema.json | 4 ++-- docs/yml.md | 2 +- tests/unit/service_test.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/compose/config/schema.json b/compose/config/schema.json index 073a0da65a6..17e1445a656 100644 --- a/compose/config/schema.json +++ b/compose/config/schema.json @@ -62,9 +62,9 @@ "type": "object", "properties": { - "address": {"type": "string"} + "syslog-address": {"type": "string"} }, - "required": ["address"] + "required": ["syslog-address"] }, "mac_address": {"type": "string"}, diff --git a/docs/yml.md b/docs/yml.md index bad9c9bc194..9662208641e 100644 --- a/docs/yml.md +++ b/docs/yml.md @@ -301,7 +301,7 @@ Logging options are key value pairs. An example of `syslog` options: log_driver: "syslog" log_opt: - address: "tcp://192.168.0.42:123" + syslog-address: "tcp://192.168.0.42:123" ### net diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 8b39a63ef7c..2965d6c8919 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -113,7 +113,7 @@ def test_memory_swap_limit(self): self.assertEqual(opts['host_config']['Memory'], 1000000000) def test_log_opt(self): - log_opt = {'address': 'tcp://192.168.0.42:123'} + log_opt = {'syslog-address': 'tcp://192.168.0.42:123'} service = Service(name='foo', image='foo', hostname='name', client=self.mock_client, log_driver='syslog', log_opt=log_opt) self.mock_client.containers.return_value = [] opts = service._get_container_create_options({'some': 'overrides'}, 1) From c69987661728528655e06d12a8ab76528590192c Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 20 Aug 2015 16:09:28 +0100 Subject: [PATCH 1100/4072] Set log level to DEBUG when `--verbose` is passed Signed-off-by: Aanand Prasad --- compose/cli/main.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 6c2a8edb61d..cb38f54c246 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -25,6 +25,7 @@ from .utils import yesno, get_version_info log = logging.getLogger(__name__) +console_handler = logging.StreamHandler(sys.stderr) INSECURE_SSL_WARNING = """ Warning: --allow-insecure-ssl is deprecated and has no effect. @@ -63,9 +64,6 @@ def main(): def setup_logging(): - console_handler = logging.StreamHandler(sys.stderr) - console_handler.setFormatter(logging.Formatter()) - console_handler.setLevel(logging.INFO) root_logger = logging.getLogger() root_logger.addHandler(console_handler) root_logger.setLevel(logging.DEBUG) @@ -118,6 +116,16 @@ def docopt_options(self): options['version'] = get_version_info('compose') return options + def perform_command(self, options, *args, **kwargs): + if options.get('--verbose'): + console_handler.setFormatter(logging.Formatter('%(name)s.%(funcName)s: %(message)s')) + console_handler.setLevel(logging.DEBUG) + else: + console_handler.setFormatter(logging.Formatter()) + console_handler.setLevel(logging.INFO) + + return super(TopLevelCommand, self).perform_command(options, *args, **kwargs) + def build(self, project, options): """ Build or rebuild services. From 8caaee9eac0032e74b44c8f65bea28fff73630ff Mon Sep 17 00:00:00 2001 From: Joel Hansson Date: Fri, 21 Aug 2015 08:36:03 +0200 Subject: [PATCH 1101/4072] schema.json: remove specific log_opt properties Signed-off-by: Joel Hansson --- compose/config/schema.json | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/compose/config/schema.json b/compose/config/schema.json index 17e1445a656..8e9b79fb645 100644 --- a/compose/config/schema.json +++ b/compose/config/schema.json @@ -56,16 +56,9 @@ "image": {"type": "string"}, "labels": {"$ref": "#/definitions/list_or_dict"}, "links": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, - "log_driver": {"type": "string"}, - - "log_opt": { - "type": "object", - "properties": { - "syslog-address": {"type": "string"} - }, - "required": ["syslog-address"] - }, + "log_driver": {"type": "string"}, + "log_opt": {"type": "object"}, "mac_address": {"type": "string"}, "mem_limit": { From 227584b8640be269f60975d7c7f361e856c9e9f6 Mon Sep 17 00:00:00 2001 From: Frank Sachsenheim Date: Sat, 25 Jul 2015 22:20:58 +0200 Subject: [PATCH 1102/4072] Adds pause and unpause-commands Signed-off-by: Frank Sachsenheim --- compose/cli/main.py | 16 +++++++++++++ compose/container.py | 12 ++++++++++ compose/project.py | 8 +++++++ compose/service.py | 16 +++++++++++-- contrib/completion/bash/docker-compose | 31 ++++++++++++++++++++++++++ docs/reference/docker-compose.md | 2 ++ docs/reference/pause.md | 18 +++++++++++++++ docs/reference/unpause.md | 18 +++++++++++++++ tests/integration/cli_test.py | 11 +++++++++ tests/integration/project_test.py | 19 ++++++++++++++-- 10 files changed, 147 insertions(+), 4 deletions(-) create mode 100644 docs/reference/pause.md create mode 100644 docs/reference/unpause.md diff --git a/compose/cli/main.py b/compose/cli/main.py index 6c2a8edb61d..df0dfe9f340 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -172,6 +172,14 @@ def logs(self, project, options): print("Attaching to", list_containers(containers)) LogPrinter(containers, attach_params={'logs': True}, monochrome=monochrome).run() + def pause(self, project, options): + """ + Pause services. + + Usage: pause [SERVICE...] + """ + project.pause(service_names=options['SERVICE']) + def port(self, project, options): """ Print the public port for a port binding. @@ -444,6 +452,14 @@ def restart(self, project, options): timeout = int(options.get('--timeout') or DEFAULT_TIMEOUT) project.restart(service_names=options['SERVICE'], timeout=timeout) + def unpause(self, project, options): + """ + Unpause services. + + Usage: unpause [SERVICE...] + """ + project.unpause(service_names=options['SERVICE']) + def up(self, project, options): """ Builds, (re)creates, starts, and attaches to containers for a service. diff --git a/compose/container.py b/compose/container.py index 40aea98a456..37ed1fe5937 100644 --- a/compose/container.py +++ b/compose/container.py @@ -100,6 +100,8 @@ def log_config(self): @property def human_readable_state(self): + if self.is_paused: + return 'Paused' if self.is_running: return 'Ghost' if self.get('State.Ghost') else 'Up' else: @@ -119,6 +121,10 @@ def environment(self): def is_running(self): return self.get('State.Running') + @property + def is_paused(self): + return self.get('State.Paused') + def get(self, key): """Return a value from the container or None if the value is not set. @@ -142,6 +148,12 @@ def start(self, **options): def stop(self, **options): return self.client.stop(self.id, **options) + def pause(self, **options): + return self.client.pause(self.id, **options) + + def unpause(self, **options): + return self.client.unpause(self.id, **options) + def kill(self, **options): return self.client.kill(self.id, **options) diff --git a/compose/project.py b/compose/project.py index 6d86a4a8729..276afb543f7 100644 --- a/compose/project.py +++ b/compose/project.py @@ -205,6 +205,14 @@ def stop(self, service_names=None, **options): msg="Stopping" ) + def pause(self, service_names=None, **options): + for service in reversed(self.get_services(service_names)): + service.pause(**options) + + def unpause(self, service_names=None, **options): + for service in self.get_services(service_names): + service.unpause(**options) + def kill(self, service_names=None, **options): parallel_execute( objects=self.containers(service_names), diff --git a/compose/service.py b/compose/service.py index e49acf0c6a2..28d289ffa7a 100644 --- a/compose/service.py +++ b/compose/service.py @@ -96,12 +96,14 @@ def __init__(self, name, client=None, project='default', links=None, external_li self.net = net or None self.options = options - def containers(self, stopped=False, one_off=False): + def containers(self, stopped=False, one_off=False, filters={}): + filters.update({'label': self.labels(one_off=one_off)}) + containers = filter(None, [ Container.from_ps(self.client, container) for container in self.client.containers( all=stopped, - filters={'label': self.labels(one_off=one_off)})]) + filters=filters)]) if not containers: check_for_legacy_containers( @@ -132,6 +134,16 @@ def stop(self, **options): log.info("Stopping %s..." % c.name) c.stop(**options) + def pause(self, **options): + for c in self.containers(filters={'status': 'running'}): + log.info("Pausing %s..." % c.name) + c.pause(**options) + + def unpause(self, **options): + for c in self.containers(filters={'status': 'paused'}): + log.info("Unpausing %s..." % c.name) + c.unpause() + def kill(self, **options): for c in self.containers(): log.info("Killing %s..." % c.name) diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index 66eb6c8bf17..5692f0e4b84 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -68,6 +68,11 @@ __docker_compose_services_with() { COMPREPLY=( $(compgen -W "${names[*]}" -- "$cur") ) } +# The services for which at least one paused container exists +__docker_compose_services_paused() { + __docker_compose_services_with '.State.Paused' +} + # The services for which at least one running container exists __docker_compose_services_running() { __docker_compose_services_with '.State.Running' @@ -158,6 +163,18 @@ _docker_compose_migrate_to_labels() { } +_docker_compose_pause() { + case "$cur" in + -*) + COMPREPLY=( $( compgen -W "--help" -- "$cur" ) ) + ;; + *) + __docker_compose_services_running + ;; + esac +} + + _docker_compose_port() { case "$prev" in --protocol) @@ -306,6 +323,18 @@ _docker_compose_stop() { } +_docker_compose_unpause() { + case "$cur" in + -*) + COMPREPLY=( $( compgen -W "--help" -- "$cur" ) ) + ;; + *) + __docker_compose_services_paused + ;; + esac +} + + _docker_compose_up() { case "$prev" in -t | --timeout) @@ -343,6 +372,7 @@ _docker_compose() { kill logs migrate-to-labels + pause port ps pull @@ -352,6 +382,7 @@ _docker_compose() { scale start stop + unpause up version ) diff --git a/docs/reference/docker-compose.md b/docs/reference/docker-compose.md index e252da0a700..46afba13c13 100644 --- a/docs/reference/docker-compose.md +++ b/docs/reference/docker-compose.md @@ -28,6 +28,7 @@ Commands: 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 @@ -37,6 +38,7 @@ Commands: scale Set number of containers for a service start Start services stop Stop services + unpause Unpause services up Create and start containers migrate-to-labels Recreate containers to add labels ``` diff --git a/docs/reference/pause.md b/docs/reference/pause.md new file mode 100644 index 00000000000..a0ffab03596 --- /dev/null +++ b/docs/reference/pause.md @@ -0,0 +1,18 @@ + + +# pause + +``` +Usage: pause [SERVICE...] +``` + +Pauses running containers of a service. They can be unpaused with `docker-compose unpause`. diff --git a/docs/reference/unpause.md b/docs/reference/unpause.md new file mode 100644 index 00000000000..6434b09ccc3 --- /dev/null +++ b/docs/reference/unpause.md @@ -0,0 +1,18 @@ + + +# pause + +``` +Usage: unpause [SERVICE...] +``` + +Unpauses paused containers of a service. diff --git a/tests/integration/cli_test.py b/tests/integration/cli_test.py index a02e072fd76..38f8ee46488 100644 --- a/tests/integration/cli_test.py +++ b/tests/integration/cli_test.py @@ -415,6 +415,17 @@ def test_stop(self): self.assertEqual(len(service.containers(stopped=True)), 1) self.assertFalse(service.containers(stopped=True)[0].is_running) + def test_pause_unpause(self): + self.command.dispatch(['up', '-d'], None) + service = self.project.get_service('simple') + self.assertFalse(service.containers()[0].is_paused) + + self.command.dispatch(['pause'], None) + self.assertTrue(service.containers()[0].is_paused) + + self.command.dispatch(['unpause'], None) + self.assertFalse(service.containers()[0].is_paused) + def test_logs_invalid_service_name(self): with self.assertRaises(NoSuchService): self.command.dispatch(['logs', 'madeupname'], None) diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 9788c186159..ad2fe4fea15 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -140,7 +140,7 @@ def test_net_from_container(self): web = project.get_service('web') self.assertEqual(web._get_net(), 'container:' + net_container.id) - def test_start_stop_kill_remove(self): + def test_start_pause_unpause_stop_kill_remove(self): web = self.create_service('web') db = self.create_service('db') project = Project('composetest', [web, db], self.client) @@ -158,7 +158,22 @@ def test_start_stop_kill_remove(self): 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])) + + 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])) + + project.unpause(service_names=['db']) + self.assertEqual(len([c.name for c in project.containers() if c.is_paused]), 2) + + project.unpause() + self.assertEqual(len([c.name for c in project.containers() if c.is_paused]), 0) project.stop(service_names=['web'], timeout=1) self.assertEqual(set(c.name for c in project.containers()), set([db_container.name])) From dd738b380b43387724909e3e6caad863c8a9d6e0 Mon Sep 17 00:00:00 2001 From: Frank Sachsenheim Date: Sat, 25 Jul 2015 22:48:55 +0200 Subject: [PATCH 1103/4072] Makes Service.config_hash a property Signed-off-by: Frank Sachsenheim --- compose/service.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/compose/service.py b/compose/service.py index 28d289ffa7a..7df5618c55b 100644 --- a/compose/service.py +++ b/compose/service.py @@ -343,7 +343,7 @@ def _containers_have_diverged(self, containers): config_hash = None try: - config_hash = self.config_hash() + config_hash = self.config_hash except NoSuchImageError as e: log.debug( 'Service %s has diverged: %s', @@ -468,6 +468,7 @@ def duplicate_containers(self): else: numbers.add(c.number) + @property def config_hash(self): return json_hash(self.config_dict()) @@ -585,7 +586,7 @@ def _get_container_create_options( container_options['name'] = self.get_container_name(number, one_off) if add_config_hash: - config_hash = self.config_hash() + config_hash = self.config_hash if 'labels' not in container_options: container_options['labels'] = {} container_options['labels'][LABEL_CONFIG_HASH] = config_hash From a57ce1b1ba18750f6212055566a0f0007e44980e Mon Sep 17 00:00:00 2001 From: Berk Birand Date: Mon, 24 Aug 2015 15:10:00 -0400 Subject: [PATCH 1104/4072] Export COMPOSE_FILE The environment variable is not used by `docker-compose` without the `export` line.. Signed-off-by: Berk Birand --- docs/production.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/production.md b/docs/production.md index 60051136955..85f245810f8 100644 --- a/docs/production.md +++ b/docs/production.md @@ -40,7 +40,7 @@ For this reason, you'll probably want to define a separate Compose file, say Once you've got an alternate configuration file, make Compose use it by setting the `COMPOSE_FILE` environment variable: - $ COMPOSE_FILE=production.yml + $ export COMPOSE_FILE=production.yml $ docker-compose up -d > **Note:** You can also use the file for a one-off command without setting From fae645466115045f3801cd3926afe752c08840ec Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 24 Aug 2015 15:15:08 -0400 Subject: [PATCH 1105/4072] Add pre-commit hooks Signed-off-by: Daniel Nephin --- .pre-commit-config.yaml | 18 ++++++++++++++++++ Dockerfile | 3 +++ script/test-versions | 2 +- tox.ini | 12 +++++++++++- 4 files changed, 33 insertions(+), 2 deletions(-) create mode 100644 .pre-commit-config.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000000..832be6ab8db --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,18 @@ +- repo: git://github.com/pre-commit/pre-commit-hooks + sha: 'v0.4.2' + hooks: + - id: check-added-large-files + - id: check-docstring-first + - id: check-merge-conflict + - id: check-yaml + - id: debug-statements + - id: end-of-file-fixer + - id: flake8 + - id: name-tests-test + exclude: 'tests/integration/testcases.py' + - id: requirements-txt-fixer + - id: trailing-whitespace +- repo: git://github.com/asottile/reorder_python_imports + sha: 3d86483455ab5bd06cc1069fdd5ac57be5463f10 + hooks: + - id: reorder-python-imports diff --git a/Dockerfile b/Dockerfile index ed23e75acb6..a4cc99fea02 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,6 +14,7 @@ RUN set -ex; \ curl \ lxc \ iptables \ + libsqlite3-dev \ ; \ rm -rf /var/lib/apt/lists/* @@ -68,6 +69,8 @@ RUN pip install -r requirements.txt ADD requirements-dev.txt /code/ RUN pip install -r requirements-dev.txt +RUN pip install tox==2.1.1 + ADD . /code/ RUN python setup.py install diff --git a/script/test-versions b/script/test-versions index ae9620e3849..d67a6f5e124 100755 --- a/script/test-versions +++ b/script/test-versions @@ -5,7 +5,7 @@ set -e >&2 echo "Running lint checks" -flake8 compose tests setup.py +tox -e pre-commit if [ "$DOCKER_VERSIONS" == "" ]; then DOCKER_VERSIONS="default" diff --git a/tox.ini b/tox.ini index 33cdee167f4..3a69c5784c9 100644 --- a/tox.ini +++ b/tox.ini @@ -1,8 +1,10 @@ [tox] -envlist = py26,py27 +envlist = py27,pre-commit [testenv] usedevelop=True +passenv = + LD_LIBRARY_PATH deps = -rrequirements.txt -rrequirements-dev.txt @@ -10,6 +12,14 @@ commands = nosetests -v {posargs} flake8 compose tests setup.py +[testenv:pre-commit] +skip_install = True +deps = + pre-commit +commands = + pre-commit install + pre-commit run --all-files + [flake8] # ignore line-length for now ignore = E501,E203 From 59d4f304ee3bf4bb20ba0f5e0ad6c4a3ff1568f3 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 24 Aug 2015 15:25:25 -0400 Subject: [PATCH 1106/4072] Run pre-commit on all files Signed-off-by: Daniel Nephin --- CHANGELOG.md | 6 +-- README.md | 2 +- compose/cli/command.py | 19 +++++---- compose/cli/docker_client.py | 5 ++- compose/cli/docopt_command.py | 8 ++-- compose/cli/errors.py | 1 + compose/cli/formatter.py | 4 +- compose/cli/log_printer.py | 6 +-- compose/cli/main.py | 18 +++++---- compose/cli/multiplexer.py | 1 + compose/cli/utils.py | 10 +++-- compose/cli/verbose_proxy.py | 3 +- compose/config/__init__.py | 19 +++++---- compose/config/config.py | 22 +++++----- compose/config/interpolation.py | 3 +- compose/config/validation.py | 8 ++-- compose/container.py | 8 ++-- compose/legacy.py | 3 +- compose/progress_stream.py | 2 +- compose/project.py | 13 ++++-- compose/service.py | 40 ++++++++++--------- compose/utils.py | 5 ++- docs/README.md | 12 +++--- docs/index.md | 2 +- docs/install.md | 16 ++++---- docs/pre-process.sh | 7 ++-- docs/production.md | 1 - docs/rails.md | 2 +- docs/reference/build.md | 2 +- docs/reference/docker-compose.md | 2 +- docs/reference/index.md | 6 +-- docs/reference/kill.md | 2 +- docs/reference/overview.md | 2 +- docs/reference/port.md | 2 +- docs/reference/pull.md | 2 +- docs/reference/run.md | 6 +-- docs/reference/scale.md | 2 +- docs/wordpress.md | 6 +-- docs/yml.md | 5 +-- requirements-dev.txt | 8 ++-- requirements.txt | 2 +- setup.py | 7 +++- .../extends/nonexistent-path-base.yml | 2 +- .../extends/nonexistent-path-child.yml | 2 +- .../docker-compose.yaml | 2 +- tests/integration/cli_test.py | 9 +++-- tests/integration/legacy_test.py | 4 +- tests/integration/project_test.py | 4 +- tests/integration/resilience_test.py | 4 +- tests/integration/service_test.py | 34 ++++++++-------- tests/integration/state_test.py | 10 ++--- tests/integration/testcases.py | 9 +++-- tests/unit/cli/docker_client_test.py | 5 ++- tests/unit/cli/verbose_proxy_test.py | 4 +- tests/unit/cli_test.py | 5 ++- tests/unit/config_test.py | 5 ++- tests/unit/container_test.py | 4 +- tests/unit/interpolation_test.py | 3 +- tests/unit/log_printer_test.py | 5 ++- tests/unit/progress_stream_test.py | 4 +- tests/unit/project_test.py | 13 +++--- tests/unit/service_test.py | 31 +++++++------- tests/unit/sort_service_test.py | 3 +- tests/unit/split_buffer_test.py | 5 ++- 64 files changed, 247 insertions(+), 220 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 88e725da61f..4f18ddbf8d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -202,7 +202,7 @@ The highlights: - There is a new `fig restart` command which restarts a service's containers. - Fig creates multiple containers in service by appending a number to the service name (e.g. `db_1`, `db_2`, etc). As a convenience, Fig will now give the first container an alias of the service name (e.g. `db`). - + This link alias is also a valid hostname and added to `/etc/hosts` so you can connect to linked services using their hostname. For example, instead of resolving the environment variables `DB_PORT_5432_TCP_ADDR` and `DB_PORT_5432_TCP_PORT`, you could just use the hostname `db` and port `5432` directly. - Volume definitions now support `ro` mode, expanding `~` and expanding environment variables. @@ -250,7 +250,7 @@ Thanks @ryanbrainard and @d11wtq! ------------------ - Fig now starts links when you run `fig run` or `fig up`. - + For example, if you have a `web` service which depends on a `db` service, `fig run web ...` will start the `db` service. - Environment variables can now be resolved from the environment that Fig is running in. Just specify it as a blank variable in your `fig.yml` and, if set, it'll be resolved: @@ -410,5 +410,3 @@ Big thanks to @tomstuart, @EnTeQuAk, @schickling, @aronasorman and @GeoffreyPlit ------------------ Initial release. - - diff --git a/README.md b/README.md index 7121f6a2d95..69423111e5e 100644 --- a/README.md +++ b/README.md @@ -54,4 +54,4 @@ Want to help build Compose? Check out our [contributing documentation](https://g Releasing --------- -Releases are built by maintainers, following an outline of the [release process](https://github.com/docker/compose/blob/master/RELEASE_PROCESS.md). \ No newline at end of file +Releases are built by maintainers, following an outline of the [release process](https://github.com/docker/compose/blob/master/RELEASE_PROCESS.md). diff --git a/compose/cli/command.py b/compose/cli/command.py index 204ed527107..67176df2719 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -1,20 +1,25 @@ -from __future__ import unicode_literals from __future__ import absolute_import -from requests.exceptions import ConnectionError, SSLError +from __future__ import unicode_literals + 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 __version__ from .. import config from ..project import Project from ..service import ConfigError -from .docopt_command import DocoptCommand -from .utils import call_silently, is_mac, is_ubuntu from .docker_client import docker_client -from . import verbose_proxy -from . import errors -from .. import __version__ +from .docopt_command import DocoptCommand +from .utils import call_silently +from .utils import is_mac +from .utils import is_ubuntu log = logging.getLogger(__name__) diff --git a/compose/cli/docker_client.py b/compose/cli/docker_client.py index 244bcbef2f6..ad67d5639f9 100644 --- a/compose/cli/docker_client.py +++ b/compose/cli/docker_client.py @@ -1,7 +1,8 @@ +import os +import ssl + from docker import Client from docker import tls -import ssl -import os def docker_client(): diff --git a/compose/cli/docopt_command.py b/compose/cli/docopt_command.py index 6eeb33a317d..27f4b2bd7f2 100644 --- a/compose/cli/docopt_command.py +++ b/compose/cli/docopt_command.py @@ -1,9 +1,11 @@ -from __future__ import unicode_literals from __future__ import absolute_import -import sys +from __future__ import unicode_literals +import sys from inspect import getdoc -from docopt import docopt, DocoptExit + +from docopt import docopt +from docopt import DocoptExit def docopt_full_help(docstring, *args, **kwargs): diff --git a/compose/cli/errors.py b/compose/cli/errors.py index 135710d4340..0569c1a0dd2 100644 --- a/compose/cli/errors.py +++ b/compose/cli/errors.py @@ -1,4 +1,5 @@ from __future__ import absolute_import + from textwrap import dedent diff --git a/compose/cli/formatter.py b/compose/cli/formatter.py index b5b0b3c03da..9ed52c4aa51 100644 --- a/compose/cli/formatter.py +++ b/compose/cli/formatter.py @@ -1,6 +1,8 @@ -from __future__ import unicode_literals from __future__ import absolute_import +from __future__ import unicode_literals + import os + import texttable diff --git a/compose/cli/log_printer.py b/compose/cli/log_printer.py index 9c5d35e1877..ef484ca6cb4 100644 --- a/compose/cli/log_printer.py +++ b/compose/cli/log_printer.py @@ -1,11 +1,11 @@ -from __future__ import unicode_literals from __future__ import absolute_import -import sys +from __future__ import unicode_literals +import sys from itertools import cycle -from .multiplexer import Multiplexer from . import colors +from .multiplexer import Multiplexer from .utils import split_buffer diff --git a/compose/cli/main.py b/compose/cli/main.py index b95a09c809c..890a3c37177 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -1,28 +1,32 @@ from __future__ import print_function from __future__ import unicode_literals -from inspect import getdoc -from operator import attrgetter + import logging import re import signal import sys +from inspect import getdoc +from operator import attrgetter -from docker.errors import APIError import dockerpty +from docker.errors import APIError from .. import __version__ from .. import legacy -from ..const import DEFAULT_TIMEOUT -from ..project import NoSuchService, ConfigurationError -from ..service import BuildError, NeedsBuildError from ..config import parse_environment +from ..const import DEFAULT_TIMEOUT from ..progress_stream import StreamOutputError +from ..project import ConfigurationError +from ..project import NoSuchService +from ..service import BuildError +from ..service import NeedsBuildError from .command import Command from .docopt_command import NoSuchCommand from .errors import UserError from .formatter import Formatter from .log_printer import LogPrinter -from .utils import yesno, get_version_info +from .utils import get_version_info +from .utils import yesno log = logging.getLogger(__name__) console_handler = logging.StreamHandler(sys.stderr) diff --git a/compose/cli/multiplexer.py b/compose/cli/multiplexer.py index 955af632217..b502c351b72 100644 --- a/compose/cli/multiplexer.py +++ b/compose/cli/multiplexer.py @@ -1,4 +1,5 @@ from __future__ import absolute_import + from threading import Thread try: diff --git a/compose/cli/utils.py b/compose/cli/utils.py index 7f2ba2e0dd5..1bb497cd807 100644 --- a/compose/cli/utils.py +++ b/compose/cli/utils.py @@ -1,14 +1,16 @@ -from __future__ import unicode_literals from __future__ import absolute_import from __future__ import division +from __future__ import unicode_literals -from .. import __version__ import datetime -from docker import version as docker_py_version import os import platform -import subprocess import ssl +import subprocess + +from docker import version as docker_py_version + +from .. import __version__ def yesno(prompt, default=None): diff --git a/compose/cli/verbose_proxy.py b/compose/cli/verbose_proxy.py index a548983e1c4..68dfabe521c 100644 --- a/compose/cli/verbose_proxy.py +++ b/compose/cli/verbose_proxy.py @@ -1,8 +1,7 @@ - import functools -from itertools import chain import logging import pprint +from itertools import chain import six diff --git a/compose/config/__init__.py b/compose/config/__init__.py index 3907e5b67ef..de6f10c9498 100644 --- a/compose/config/__init__.py +++ b/compose/config/__init__.py @@ -1,10 +1,9 @@ -from .config import ( - DOCKER_CONFIG_KEYS, - ConfigDetails, - ConfigurationError, - find, - load, - parse_environment, - merge_environment, - get_service_name_from_net, -) # flake8: noqa +# flake8: noqa +from .config import ConfigDetails +from .config import ConfigurationError +from .config import DOCKER_CONFIG_KEYS +from .config import find +from .config import get_service_name_from_net +from .config import load +from .config import merge_environment +from .config import parse_environment diff --git a/compose/config/config.py b/compose/config/config.py index b79ef254df2..ea122bc422d 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -1,23 +1,19 @@ import logging import os import sys -import yaml from collections import namedtuple -import six -from compose.cli.utils import find_candidates_in_parent_dirs +import six +import yaml +from .errors import CircularReference +from .errors import ComposeFileNotFound +from .errors import ConfigurationError from .interpolation import interpolate_environment_variables -from .errors import ( - ConfigurationError, - CircularReference, - ComposeFileNotFound, -) -from .validation import ( - validate_against_schema, - validate_service_names, - validate_top_level_object -) +from .validation import validate_against_schema +from .validation import validate_service_names +from .validation import validate_top_level_object +from compose.cli.utils import find_candidates_in_parent_dirs DOCKER_CONFIG_KEYS = [ diff --git a/compose/config/interpolation.py b/compose/config/interpolation.py index 8ebcc87596c..f870ab4b27c 100644 --- a/compose/config/interpolation.py +++ b/compose/config/interpolation.py @@ -1,11 +1,10 @@ +import logging import os from string import Template import six from .errors import ConfigurationError - -import logging log = logging.getLogger(__name__) diff --git a/compose/config/validation.py b/compose/config/validation.py index 26f3ca8ec7a..8911f5ae1d7 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -1,9 +1,11 @@ -from functools import wraps +import json import os +from functools import wraps from docker.utils.ports import split_port -import json -from jsonschema import Draft4Validator, FormatChecker, ValidationError +from jsonschema import Draft4Validator +from jsonschema import FormatChecker +from jsonschema import ValidationError from .errors import ConfigurationError diff --git a/compose/container.py b/compose/container.py index 37ed1fe5937..f727c8673af 100644 --- a/compose/container.py +++ b/compose/container.py @@ -1,10 +1,12 @@ -from __future__ import unicode_literals from __future__ import absolute_import +from __future__ import unicode_literals -import six from functools import reduce -from .const import LABEL_CONTAINER_NUMBER, LABEL_SERVICE +import six + +from .const import LABEL_CONTAINER_NUMBER +from .const import LABEL_SERVICE class Container(object): diff --git a/compose/legacy.py b/compose/legacy.py index 6fbf74d6928..e8f4f957396 100644 --- a/compose/legacy.py +++ b/compose/legacy.py @@ -2,7 +2,8 @@ import re from .const import LABEL_VERSION -from .container import get_container_name, Container +from .container import Container +from .container import get_container_name log = logging.getLogger(__name__) diff --git a/compose/progress_stream.py b/compose/progress_stream.py index 317c6e81575..1ccdb861bd6 100644 --- a/compose/progress_stream.py +++ b/compose/progress_stream.py @@ -1,6 +1,6 @@ +import codecs import json import os -import codecs class StreamOutputError(Exception): diff --git a/compose/project.py b/compose/project.py index 276afb543f7..eb395297c64 100644 --- a/compose/project.py +++ b/compose/project.py @@ -1,12 +1,17 @@ -from __future__ import unicode_literals from __future__ import absolute_import -from functools import reduce +from __future__ import unicode_literals + import logging +from functools import reduce from docker.errors import APIError -from .config import get_service_name_from_net, ConfigurationError -from .const import DEFAULT_TIMEOUT, LABEL_PROJECT, LABEL_SERVICE, LABEL_ONE_OFF +from .config import ConfigurationError +from .config import get_service_name_from_net +from .const import DEFAULT_TIMEOUT +from .const import LABEL_ONE_OFF +from .const import LABEL_PROJECT +from .const import LABEL_SERVICE from .container import Container from .legacy import check_for_legacy_containers from .service import Service diff --git a/compose/service.py b/compose/service.py index 7df5618c55b..05e546c4366 100644 --- a/compose/service.py +++ b/compose/service.py @@ -1,33 +1,37 @@ -from __future__ import unicode_literals from __future__ import absolute_import -from collections import namedtuple +from __future__ import unicode_literals + import logging -import re import os +import re import sys +from collections import namedtuple from operator import attrgetter import six from docker.errors import APIError -from docker.utils import create_host_config, LogConfig -from docker.utils.ports import build_port_bindings, split_port +from docker.utils import create_host_config +from docker.utils import LogConfig +from docker.utils.ports import build_port_bindings +from docker.utils.ports import split_port from . import __version__ -from .config import DOCKER_CONFIG_KEYS, merge_environment -from .const import ( - DEFAULT_TIMEOUT, - LABEL_CONTAINER_NUMBER, - LABEL_ONE_OFF, - LABEL_PROJECT, - LABEL_SERVICE, - LABEL_VERSION, - LABEL_CONFIG_HASH, -) +from .config import DOCKER_CONFIG_KEYS +from .config import merge_environment +from .config.validation import VALID_NAME_CHARS +from .const import DEFAULT_TIMEOUT +from .const import LABEL_CONFIG_HASH +from .const import LABEL_CONTAINER_NUMBER +from .const import LABEL_ONE_OFF +from .const import LABEL_PROJECT +from .const import LABEL_SERVICE +from .const import LABEL_VERSION from .container import Container from .legacy import check_for_legacy_containers -from .progress_stream import stream_output, StreamOutputError -from .utils import json_hash, parallel_execute -from .config.validation import VALID_NAME_CHARS +from .progress_stream import stream_output +from .progress_stream import StreamOutputError +from .utils import json_hash +from .utils import parallel_execute log = logging.getLogger(__name__) diff --git a/compose/utils.py b/compose/utils.py index 61d6d802438..bd8922670e3 100644 --- a/compose/utils.py +++ b/compose/utils.py @@ -3,10 +3,11 @@ import json import logging import sys +from Queue import Empty +from Queue import Queue +from threading import Thread from docker.errors import APIError -from Queue import Queue, Empty -from threading import Thread log = logging.getLogger(__name__) diff --git a/docs/README.md b/docs/README.md index 4d6465637f2..8fbad30c58f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,8 +1,8 @@ # Contributing to the Docker Compose documentation -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 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. -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. +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. @@ -23,7 +23,7 @@ If you want to add a new file or change the location of the document in the menu 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 + 0 future content 12 pages created 0 paginator pages created 0 tags created @@ -52,7 +52,7 @@ The top of each Docker Compose documentation file contains TOML metadata. The me parent="smn_workw_compose" weight=2 +++ - + The metadata alone has this structure: @@ -64,7 +64,7 @@ The metadata alone has this structure: parent="smn_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. @@ -73,5 +73,5 @@ You can move an article in the tree by specifying a new parent. You can shift th ## 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. diff --git a/docs/index.md b/docs/index.md index 872b0158816..4342b3686d5 100644 --- a/docs/index.md +++ b/docs/index.md @@ -161,7 +161,7 @@ Now, when you run `docker-compose up`, Compose will pull a Redis image, build an web_1 | * Running on http://0.0.0.0:5000/ web_1 | * Restarting with stat -If you're using [Docker Machine](https://docs.docker.com/machine), then `docker-machine ip MACHINE_VM` will tell you its address and you can open `http://MACHINE_VM_IP:5000` in a browser. +If you're using [Docker Machine](https://docs.docker.com/machine), then `docker-machine ip MACHINE_VM` will tell you its address and you can open `http://MACHINE_VM_IP:5000` in a browser. If you're not using Boot2docker and are on linux, 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 localhost:5000. diff --git a/docs/install.md b/docs/install.md index d71aa0800d6..7a2763edc07 100644 --- a/docs/install.md +++ b/docs/install.md @@ -14,7 +14,7 @@ weight=4 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. +first. Depending on how your system is configured, you may require `sudo` access to install Compose. If your system requires `sudo`, you will receive "Permission @@ -26,13 +26,13 @@ To install Compose, do the following: 1. Install Docker Engine version 1.7.1 or greater: * Mac OS X installation (installs both Engine and Compose) - + * Ubuntu installation - + * other system installations - + 2. Mac OS X users are done installing. Others should continue to the next step. - + 3. Go to the repository release page. 4. Enter the `curl` command in your termial. @@ -40,9 +40,9 @@ To install Compose, do the following: The command has the following format: curl -L https://github.com/docker/compose/releases/download/VERSION_NUM/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose - + If you have problems installing with `curl`, you can use `pip` instead: `pip install -U docker-compose` - + 4. Apply executable permissions to the binary: $ chmod +x /usr/local/bin/docker-compose @@ -85,7 +85,7 @@ To uninstall Docker Compose if you installed using `curl`: 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 diff --git a/docs/pre-process.sh b/docs/pre-process.sh index 75e9611f2f0..f1f6b7fec61 100755 --- a/docs/pre-process.sh +++ b/docs/pre-process.sh @@ -13,7 +13,7 @@ content_dir=(`ls -d /docs/content/*`) # 5 Change ](word) to ](/project/word) # 6 Change ](../../ to ](/project/ # 7 Change ](../ to ](/project/word) -# +# for i in "${content_dir[@]}" do : @@ -51,11 +51,10 @@ done for i in "${docker_dir[@]}" do : - if [ -d $i ] + if [ -d $i ] then - mv $i /docs/content/ + mv $i /docs/content/ fi done rm -rf /docs/content/docker - diff --git a/docs/production.md b/docs/production.md index 60051136955..3020a0c4024 100644 --- a/docs/production.md +++ b/docs/production.md @@ -93,4 +93,3 @@ guide. - [Yaml file reference](yml.md) - [Compose environment variables](env.md) - [Compose command line completion](completion.md) - diff --git a/docs/rails.md b/docs/rails.md index b73be90cb59..186f9b2bf24 100644 --- a/docs/rails.md +++ b/docs/rails.md @@ -117,7 +117,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](https://docs.docker.com/machine), then `docker-machine ip MACHINE_VM` returns the Docker host IP address. ## More Compose documentation diff --git a/docs/reference/build.md b/docs/reference/build.md index b6e27bb264b..77d87def49c 100644 --- a/docs/reference/build.md +++ b/docs/reference/build.md @@ -20,4 +20,4 @@ Options: 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. \ No newline at end of file +build directory, run `docker-compose build` to rebuild it. diff --git a/docs/reference/docker-compose.md b/docs/reference/docker-compose.md index 46afba13c13..6c46b31d180 100644 --- a/docs/reference/docker-compose.md +++ b/docs/reference/docker-compose.md @@ -5,7 +5,7 @@ description = "docker-compose Command Binary" keywords = ["fig, composition, compose, docker, orchestration, cli, docker-compose"] [menu.main] parent = "smn_compose_cli" -weight=-2 +weight=-2 +++ diff --git a/docs/reference/index.md b/docs/reference/index.md index 5651e5bf056..e7a07b09aaa 100644 --- a/docs/reference/index.md +++ b/docs/reference/index.md @@ -5,7 +5,7 @@ description = "Compose CLI reference" keywords = ["fig, composition, compose, docker, orchestration, cli, reference"] [menu.main] identifier = "smn_compose_cli" -parent = "smn_compose_ref" +parent = "smn_compose_ref" +++ @@ -15,7 +15,7 @@ The following pages describe the usage information for the [docker-compose](/ref * [build](/reference/build.md) * [help](/reference/help.md) -* [kill](/reference/kill.md) +* [kill](/reference/kill.md) * [ps](/reference/ps.md) * [restart](/reference/restart.md) * [run](/reference/run.md) @@ -23,7 +23,7 @@ The following pages describe the usage information for the [docker-compose](/ref * [up](/reference/up.md) * [logs](/reference/logs.md) * [port](/reference/port.md) -* [pull](/reference/pull.md) +* [pull](/reference/pull.md) * [rm](/reference/rm.md) * [scale](/reference/scale.md) * [stop](/reference/stop.md) diff --git a/docs/reference/kill.md b/docs/reference/kill.md index e5dd057361d..dc4bf23a1b5 100644 --- a/docs/reference/kill.md +++ b/docs/reference/kill.md @@ -21,4 +21,4 @@ Options: Forces running containers to stop by sending a `SIGKILL` signal. Optionally the signal can be passed, for example: - $ docker-compose kill -s SIGINT \ No newline at end of file + $ docker-compose kill -s SIGINT diff --git a/docs/reference/overview.md b/docs/reference/overview.md index 458dea40466..7425aa5e8a7 100644 --- a/docs/reference/overview.md +++ b/docs/reference/overview.md @@ -5,7 +5,7 @@ description = "Introduction to the CLI" keywords = ["fig, composition, compose, docker, orchestration, cli, reference"] [menu.main] parent = "smn_compose_cli" -weight=-2 +weight=-2 +++ diff --git a/docs/reference/port.md b/docs/reference/port.md index 76f93f23935..c946a97d390 100644 --- a/docs/reference/port.md +++ b/docs/reference/port.md @@ -20,4 +20,4 @@ Options: instances of a service [default: 1] ``` -Prints the public port for a port binding. \ No newline at end of file +Prints the public port for a port binding. diff --git a/docs/reference/pull.md b/docs/reference/pull.md index e5b5d166ff3..d655dd93be6 100644 --- a/docs/reference/pull.md +++ b/docs/reference/pull.md @@ -15,4 +15,4 @@ parent = "smn_compose_cli" Usage: pull [options] [SERVICE...] ``` -Pulls service images. \ No newline at end of file +Pulls service images. diff --git a/docs/reference/run.md b/docs/reference/run.md index 93ae0212b2d..c1efb9a773e 100644 --- a/docs/reference/run.md +++ b/docs/reference/run.md @@ -27,7 +27,7 @@ Options: -T Disable pseudo-tty allocation. By default `docker-compose run` allocates a TTY. ``` -Runs a one-time command against a service. For example, the following command starts the `web` service and runs `bash` as its command. +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 @@ -52,7 +52,3 @@ 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 index 95418300977..75140ee9e50 100644 --- a/docs/reference/scale.md +++ b/docs/reference/scale.md @@ -18,4 +18,4 @@ 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 \ No newline at end of file + $ docker-compose scale web=2 worker=3 diff --git a/docs/wordpress.md b/docs/wordpress.md index 8440fdbb41c..ab22e2a0df8 100644 --- a/docs/wordpress.md +++ b/docs/wordpress.md @@ -13,7 +13,7 @@ weight=6 # Quickstart Guide: Compose and Wordpress You can use Compose to easily run Wordpress in an isolated environment built -with Docker containers. +with Docker containers. ## Define the project @@ -36,7 +36,7 @@ your Dockerfile should be: ADD . /code This tells Docker how to build an image defining a container that contains PHP -and Wordpress. +and Wordpress. Next you'll create a `docker-compose.yml` file that will start your web service and a separate MySQL instance: @@ -108,7 +108,7 @@ Second, `router.php` tells PHP's built-in web server how to run Wordpress: 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. +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. ## More Compose documentation diff --git a/docs/yml.md b/docs/yml.md index 9662208641e..6fb31a7db96 100644 --- a/docs/yml.md +++ b/docs/yml.md @@ -19,7 +19,7 @@ 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`. -Values for configuration options can contain environment variables, e.g. +Values for configuration options can contain environment variables, e.g. `image: postgres:${POSTGRES_VERSION}`. For more details, see the section on [variable substitution](#variable-substitution). @@ -353,7 +353,7 @@ Custom DNS search domains. Can be a single value or a list. ### devices -List of device mappings. Uses the same format as the `--device` docker +List of device mappings. Uses the same format as the `--device` docker client create option. devices: @@ -433,4 +433,3 @@ dollar sign (`$$`). - [Command line reference](/reference) - [Compose environment variables](env.md) - [Compose command line completion](completion.md) - diff --git a/requirements-dev.txt b/requirements-dev.txt index c5d9c106453..97fc4fed868 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,7 +1,7 @@ +coverage==3.7.1 +flake8==2.3.0 +git+https://github.com/pyinstaller/pyinstaller.git@12e40471c77f588ea5be352f7219c873ddaae056#egg=pyinstaller mock >= 1.0.1 nose==1.3.4 -git+https://github.com/pyinstaller/pyinstaller.git@12e40471c77f588ea5be352f7219c873ddaae056#egg=pyinstaller -unittest2==0.8.0 -flake8==2.3.0 pep8==1.6.1 -coverage==3.7.1 +unittest2==0.8.0 diff --git a/requirements.txt b/requirements.txt index 64168768615..e93db7b361d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,8 @@ PyYAML==3.10 -jsonschema==2.5.1 docker-py==1.3.1 dockerpty==0.3.4 docopt==0.6.1 +jsonschema==2.5.1 requests==2.6.1 six==1.7.3 texttable==0.8.2 diff --git a/setup.py b/setup.py index 1f9c981d1b0..2f6dad7a9b6 100644 --- a/setup.py +++ b/setup.py @@ -1,13 +1,16 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -from __future__ import unicode_literals from __future__ import absolute_import -from setuptools import setup, find_packages +from __future__ import unicode_literals + import codecs import os import re import sys +from setuptools import find_packages +from setuptools import setup + def read(*parts): path = os.path.join(os.path.dirname(__file__), *parts) diff --git a/tests/fixtures/extends/nonexistent-path-base.yml b/tests/fixtures/extends/nonexistent-path-base.yml index 1cf9a304aed..4e6c82b0d72 100644 --- a/tests/fixtures/extends/nonexistent-path-base.yml +++ b/tests/fixtures/extends/nonexistent-path-base.yml @@ -3,4 +3,4 @@ dnebase: command: /bin/true environment: - FOO=1 - - BAR=1 \ No newline at end of file + - BAR=1 diff --git a/tests/fixtures/extends/nonexistent-path-child.yml b/tests/fixtures/extends/nonexistent-path-child.yml index aab11459b1e..d3b732f2a3c 100644 --- a/tests/fixtures/extends/nonexistent-path-child.yml +++ b/tests/fixtures/extends/nonexistent-path-child.yml @@ -5,4 +5,4 @@ dnechild: image: busybox command: /bin/true environment: - - BAR=2 \ No newline at end of file + - BAR=2 diff --git a/tests/fixtures/longer-filename-composefile/docker-compose.yaml b/tests/fixtures/longer-filename-composefile/docker-compose.yaml index b55a9e12456..a4eba2d05da 100644 --- a/tests/fixtures/longer-filename-composefile/docker-compose.yaml +++ b/tests/fixtures/longer-filename-composefile/docker-compose.yaml @@ -1,3 +1,3 @@ definedinyamlnotyml: image: busybox:latest - command: top \ No newline at end of file + command: top diff --git a/tests/integration/cli_test.py b/tests/integration/cli_test.py index 38f8ee46488..8bdcadd5294 100644 --- a/tests/integration/cli_test.py +++ b/tests/integration/cli_test.py @@ -1,15 +1,16 @@ from __future__ import absolute_import -from operator import attrgetter -import sys + import os import shlex +import sys +from operator import attrgetter -from six import StringIO from mock import patch +from six import StringIO from .testcases import DockerClientTestCase -from compose.cli.main import TopLevelCommand from compose.cli.errors import UserError +from compose.cli.main import TopLevelCommand from compose.project import NoSuchService diff --git a/tests/integration/legacy_test.py b/tests/integration/legacy_test.py index 9913bbb0fef..fa983e6d597 100644 --- a/tests/integration/legacy_test.py +++ b/tests/integration/legacy_test.py @@ -1,11 +1,11 @@ import unittest -from mock import Mock from docker.errors import APIError +from mock import Mock +from .testcases import DockerClientTestCase from compose import legacy from compose.project import Project -from .testcases import DockerClientTestCase class UtilitiesTestCase(unittest.TestCase): diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index ad2fe4fea15..51619cb5ec7 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -1,10 +1,10 @@ from __future__ import unicode_literals +from .testcases import DockerClientTestCase from compose import config from compose.const import LABEL_PROJECT -from compose.project import Project from compose.container import Container -from .testcases import DockerClientTestCase +from compose.project import Project def build_service_dicts(service_config): diff --git a/tests/integration/resilience_test.py b/tests/integration/resilience_test.py index e0c76f299de..b1faf99dff3 100644 --- a/tests/integration/resilience_test.py +++ b/tests/integration/resilience_test.py @@ -1,10 +1,10 @@ -from __future__ import unicode_literals from __future__ import absolute_import +from __future__ import unicode_literals import mock -from compose.project import Project from .testcases import DockerClientTestCase +from compose.project import Project class ResilienceTest(DockerClientTestCase): diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 050a3bf622e..1d53465f630 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -1,30 +1,28 @@ -from __future__ import unicode_literals from __future__ import absolute_import +from __future__ import unicode_literals + import os +import shutil +import tempfile from os import path from docker.errors import APIError from mock import patch -import tempfile -import shutil -from six import StringIO, text_type +from six import StringIO +from six import text_type +from .testcases import DockerClientTestCase from compose import __version__ -from compose.const import ( - LABEL_CONTAINER_NUMBER, - LABEL_ONE_OFF, - LABEL_PROJECT, - LABEL_SERVICE, - LABEL_VERSION, -) -from compose.service import ( - ConfigError, - ConvergencePlan, - Service, - build_extra_hosts, -) +from compose.const import LABEL_CONTAINER_NUMBER +from compose.const import LABEL_ONE_OFF +from compose.const import LABEL_PROJECT +from compose.const import LABEL_SERVICE +from compose.const import LABEL_VERSION from compose.container import Container -from .testcases import DockerClientTestCase +from compose.service import build_extra_hosts +from compose.service import ConfigError +from compose.service import ConvergencePlan +from compose.service import Service def create_and_start_container(service, **override_options): diff --git a/tests/integration/state_test.py b/tests/integration/state_test.py index b124b19ffc6..3d4a5b5aa6e 100644 --- a/tests/integration/state_test.py +++ b/tests/integration/state_test.py @@ -1,13 +1,13 @@ from __future__ import unicode_literals -import tempfile -import shutil + import os +import shutil +import tempfile +from .testcases import DockerClientTestCase from compose import config -from compose.project import Project from compose.const import LABEL_CONFIG_HASH - -from .testcases import DockerClientTestCase +from compose.project import Project class ProjectTestCase(DockerClientTestCase): diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index a7929088bec..e239010ea2d 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -1,11 +1,12 @@ -from __future__ import unicode_literals from __future__ import absolute_import -from compose.service import Service +from __future__ import unicode_literals + +from .. import unittest +from compose.cli.docker_client import docker_client from compose.config.config import ServiceLoader from compose.const import LABEL_PROJECT -from compose.cli.docker_client import docker_client from compose.progress_stream import stream_output -from .. import unittest +from compose.service import Service class DockerClientTestCase(unittest.TestCase): diff --git a/tests/unit/cli/docker_client_test.py b/tests/unit/cli/docker_client_test.py index 44bdbb291ec..6c2dc5f81ee 100644 --- a/tests/unit/cli/docker_client_test.py +++ b/tests/unit/cli/docker_client_test.py @@ -1,11 +1,12 @@ -from __future__ import unicode_literals from __future__ import absolute_import +from __future__ import unicode_literals + import os import mock -from tests import unittest from compose.cli import docker_client +from tests import unittest class DockerClientTestCase(unittest.TestCase): diff --git a/tests/unit/cli/verbose_proxy_test.py b/tests/unit/cli/verbose_proxy_test.py index 59417bb3ef5..6036974c6f3 100644 --- a/tests/unit/cli/verbose_proxy_test.py +++ b/tests/unit/cli/verbose_proxy_test.py @@ -1,8 +1,8 @@ -from __future__ import unicode_literals from __future__ import absolute_import -from tests import unittest +from __future__ import unicode_literals from compose.cli import verbose_proxy +from tests import unittest class VerboseProxyTestCase(unittest.TestCase): diff --git a/tests/unit/cli_test.py b/tests/unit/cli_test.py index e11f6f14afa..35be4e92646 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -1,11 +1,12 @@ -from __future__ import unicode_literals from __future__ import absolute_import +from __future__ import unicode_literals + import os -from .. import unittest import docker import mock +from .. import unittest from compose.cli.docopt_command import NoSuchCommand from compose.cli.errors import UserError from compose.cli.main import TopLevelCommand diff --git a/tests/unit/config_test.py b/tests/unit/config_test.py index e61172562c6..3d1a53214d4 100644 --- a/tests/unit/config_test.py +++ b/tests/unit/config_test.py @@ -1,9 +1,10 @@ -import mock import os import shutil import tempfile -from .. import unittest +import mock + +from .. import unittest from compose.config import config from compose.config.errors import ConfigurationError diff --git a/tests/unit/container_test.py b/tests/unit/container_test.py index c537a8cf55a..e2381c7c27d 100644 --- a/tests/unit/container_test.py +++ b/tests/unit/container_test.py @@ -1,9 +1,9 @@ from __future__ import unicode_literals -from .. import unittest -import mock import docker +import mock +from .. import unittest from compose.container import Container from compose.container import get_container_name diff --git a/tests/unit/interpolation_test.py b/tests/unit/interpolation_test.py index fb95422b0e1..7444884cb86 100644 --- a/tests/unit/interpolation_test.py +++ b/tests/unit/interpolation_test.py @@ -1,7 +1,8 @@ import unittest -from compose.config.interpolation import interpolate, InvalidInterpolation from compose.config.interpolation import BlankDefaultDict as bddict +from compose.config.interpolation import interpolate +from compose.config.interpolation import InvalidInterpolation class InterpolationTest(unittest.TestCase): diff --git a/tests/unit/log_printer_test.py b/tests/unit/log_printer_test.py index e40a1f75dae..bfd16affe11 100644 --- a/tests/unit/log_printer_test.py +++ b/tests/unit/log_printer_test.py @@ -1,9 +1,10 @@ -from __future__ import unicode_literals from __future__ import absolute_import +from __future__ import unicode_literals + import os -from compose.cli.log_printer import LogPrinter from .. import unittest +from compose.cli.log_printer import LogPrinter class LogPrinterTest(unittest.TestCase): diff --git a/tests/unit/progress_stream_test.py b/tests/unit/progress_stream_test.py index 317b77e9f22..5674f4e4e8d 100644 --- a/tests/unit/progress_stream_test.py +++ b/tests/unit/progress_stream_test.py @@ -1,10 +1,10 @@ -from __future__ import unicode_literals from __future__ import absolute_import -from tests import unittest +from __future__ import unicode_literals from six import StringIO from compose import progress_stream +from tests import unittest class ProgressStreamTestCase(unittest.TestCase): diff --git a/tests/unit/project_test.py b/tests/unit/project_test.py index 93bf12ff570..7d633c95054 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -1,12 +1,13 @@ from __future__ import unicode_literals -from .. import unittest -from compose.service import Service -from compose.project import Project -from compose.container import Container -from compose.const import LABEL_SERVICE -import mock import docker +import mock + +from .. import unittest +from compose.const import LABEL_SERVICE +from compose.container import Container +from compose.project import Project +from compose.service import Service class ProjectTest(unittest.TestCase): diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 2965d6c8919..12bb4ac2d74 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -1,25 +1,24 @@ -from __future__ import unicode_literals from __future__ import absolute_import - -from .. import unittest -import mock +from __future__ import unicode_literals import docker +import mock from docker.utils import LogConfig -from compose.service import Service +from .. import unittest +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.const import LABEL_SERVICE, LABEL_PROJECT, LABEL_ONE_OFF -from compose.service import ( - ConfigError, - NeedsBuildError, - NoSuchImageError, - build_volume_binding, - get_container_data_volumes, - merge_volume_bindings, - parse_repository_tag, - parse_volume_spec, -) +from compose.service import build_volume_binding +from compose.service import ConfigError +from compose.service import get_container_data_volumes +from compose.service import merge_volume_bindings +from compose.service import NeedsBuildError +from compose.service import NoSuchImageError +from compose.service import parse_repository_tag +from compose.service import parse_volume_spec +from compose.service import Service class ServiceTest(unittest.TestCase): diff --git a/tests/unit/sort_service_test.py b/tests/unit/sort_service_test.py index f42a947484a..a7e522a1dd6 100644 --- a/tests/unit/sort_service_test.py +++ b/tests/unit/sort_service_test.py @@ -1,5 +1,6 @@ -from compose.project import sort_service_dicts, DependencyError from .. import unittest +from compose.project import DependencyError +from compose.project import sort_service_dicts class SortServiceTest(unittest.TestCase): diff --git a/tests/unit/split_buffer_test.py b/tests/unit/split_buffer_test.py index 8eb54177aa7..efd99411ae5 100644 --- a/tests/unit/split_buffer_test.py +++ b/tests/unit/split_buffer_test.py @@ -1,7 +1,8 @@ -from __future__ import unicode_literals from __future__ import absolute_import -from compose.cli.utils import split_buffer +from __future__ import unicode_literals + from .. import unittest +from compose.cli.utils import split_buffer class SplitBufferTest(unittest.TestCase): From 809443d6d03e1ec687c01e546ddd9031b56ce40c Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 19 Aug 2014 17:36:46 -0400 Subject: [PATCH 1107/4072] Support python 3 Signed-off-by: Daniel Nephin --- Dockerfile | 4 +- MANIFEST.in | 2 +- compose/cli/log_printer.py | 6 ++- compose/cli/utils.py | 3 +- compose/progress_stream.py | 9 ++-- compose/project.py | 1 + compose/service.py | 2 +- ...ements-dev.txt => requirements-dev-py2.txt | 0 requirements-dev-py3.txt | 2 + setup.py | 5 +- tests/__init__.py | 5 ++ tests/integration/cli_test.py | 46 +++++++++---------- tests/integration/service_test.py | 24 +++++----- tests/unit/cli/docker_client_test.py | 3 +- tests/unit/cli/verbose_proxy_test.py | 7 ++- tests/unit/cli_test.py | 2 +- tests/unit/config_test.py | 18 ++++---- tests/unit/container_test.py | 2 +- tests/unit/log_printer_test.py | 9 ++-- tests/unit/project_test.py | 2 +- tests/unit/service_test.py | 2 +- tests/unit/split_buffer_test.py | 36 +++++++-------- tox.ini | 23 +++++++++- 23 files changed, 128 insertions(+), 85 deletions(-) rename requirements-dev.txt => requirements-dev-py2.txt (100%) create mode 100644 requirements-dev-py3.txt diff --git a/Dockerfile b/Dockerfile index a4cc99fea02..1986ac5a5b4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -66,8 +66,8 @@ WORKDIR /code/ ADD requirements.txt /code/ RUN pip install -r requirements.txt -ADD requirements-dev.txt /code/ -RUN pip install -r requirements-dev.txt +ADD requirements-dev-py2.txt /code/ +RUN pip install -r requirements-dev-py2.txt RUN pip install tox==2.1.1 diff --git a/MANIFEST.in b/MANIFEST.in index 7d48d347a84..7420485961a 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,7 +1,7 @@ include Dockerfile include LICENSE include requirements.txt -include requirements-dev.txt +include requirements-dev*.txt include tox.ini include *.md include compose/config/schema.json diff --git a/compose/cli/log_printer.py b/compose/cli/log_printer.py index ef484ca6cb4..c7d0b638f88 100644 --- a/compose/cli/log_printer.py +++ b/compose/cli/log_printer.py @@ -4,6 +4,8 @@ import sys from itertools import cycle +import six + from . import colors from .multiplexer import Multiplexer from .utils import split_buffer @@ -20,6 +22,8 @@ def __init__(self, containers, attach_params=None, output=sys.stdout, monochrome def run(self): mux = Multiplexer(self.generators) for line in mux.loop(): + if isinstance(line, six.text_type) and not six.PY3: + line = line.encode('utf-8') self.output.write(line) def _calculate_prefix_width(self, containers): @@ -52,7 +56,7 @@ def no_color(text): return generators def _make_log_generator(self, container, color_fn): - prefix = color_fn(self._generate_prefix(container)).encode('utf-8') + prefix = color_fn(self._generate_prefix(container)) # Attach to container before log printer starts running line_generator = split_buffer(self._attach(container), '\n') diff --git a/compose/cli/utils.py b/compose/cli/utils.py index 1bb497cd807..b6c83f9e1fc 100644 --- a/compose/cli/utils.py +++ b/compose/cli/utils.py @@ -9,6 +9,7 @@ import subprocess from docker import version as docker_py_version +from six.moves import input from .. import __version__ @@ -23,7 +24,7 @@ def yesno(prompt, default=None): Unrecognised input (anything other than "y", "n", "yes", "no" or "") will return None. """ - answer = raw_input(prompt).strip().lower() + answer = input(prompt).strip().lower() if answer == "y" or answer == "yes": return True diff --git a/compose/progress_stream.py b/compose/progress_stream.py index 1ccdb861bd6..582c09fb9e4 100644 --- a/compose/progress_stream.py +++ b/compose/progress_stream.py @@ -1,6 +1,7 @@ import codecs import json -import os + +import six class StreamOutputError(Exception): @@ -8,8 +9,9 @@ class StreamOutputError(Exception): def stream_output(output, stream): - is_terminal = hasattr(stream, 'fileno') and os.isatty(stream.fileno()) - stream = codecs.getwriter('utf-8')(stream) + is_terminal = hasattr(stream, 'isatty') and stream.isatty() + if not six.PY3: + stream = codecs.getwriter('utf-8')(stream) all_events = [] lines = {} diff = 0 @@ -55,7 +57,6 @@ def print_output_event(event, stream, is_terminal): # erase current line stream.write("%c[2K\r" % 27) terminator = "\r" - pass elif 'progressDetail' in event: return diff --git a/compose/project.py b/compose/project.py index eb395297c64..d14941e72b2 100644 --- a/compose/project.py +++ b/compose/project.py @@ -17,6 +17,7 @@ from .service import Service from .utils import parallel_execute + log = logging.getLogger(__name__) diff --git a/compose/service.py b/compose/service.py index 05e546c4366..647516ba844 100644 --- a/compose/service.py +++ b/compose/service.py @@ -724,7 +724,7 @@ def build(self, no_cache=False): try: all_events = stream_output(build_output, sys.stdout) except StreamOutputError as e: - raise BuildError(self, unicode(e)) + raise BuildError(self, six.text_type(e)) # Ensure the HTTP connection is not reused for another # streaming command, as the Docker daemon can sometimes diff --git a/requirements-dev.txt b/requirements-dev-py2.txt similarity index 100% rename from requirements-dev.txt rename to requirements-dev-py2.txt diff --git a/requirements-dev-py3.txt b/requirements-dev-py3.txt new file mode 100644 index 00000000000..a2ba1c8b452 --- /dev/null +++ b/requirements-dev-py3.txt @@ -0,0 +1,2 @@ +flake8 +nose >= 1.3.0 diff --git a/setup.py b/setup.py index 2f6dad7a9b6..b7fd4403482 100644 --- a/setup.py +++ b/setup.py @@ -48,8 +48,11 @@ def find_version(*file_paths): ] -if sys.version_info < (2, 7): +if sys.version_info < (2, 6): tests_require.append('unittest2') +if sys.version_info[:1] < (3,): + tests_require.append('pyinstaller') + tests_require.append('mock >= 1.0.1') setup( diff --git a/tests/__init__.py b/tests/__init__.py index 08a7865e908..d3cfb864913 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -4,3 +4,8 @@ import unittest # NOQA else: import unittest2 as unittest # NOQA + +try: + from unittest import mock +except ImportError: + import mock # NOQA diff --git a/tests/integration/cli_test.py b/tests/integration/cli_test.py index 8bdcadd5294..609370a3e1f 100644 --- a/tests/integration/cli_test.py +++ b/tests/integration/cli_test.py @@ -5,9 +5,9 @@ import sys from operator import attrgetter -from mock import patch from six import StringIO +from .. import mock from .testcases import DockerClientTestCase from compose.cli.errors import UserError from compose.cli.main import TopLevelCommand @@ -51,13 +51,13 @@ def test_help(self): self.command.base_dir = old_base_dir # TODO: address the "Inappropriate ioctl for device" warnings in test output - @patch('sys.stdout', new_callable=StringIO) + @mock.patch('sys.stdout', new_callable=StringIO) def test_ps(self, mock_stdout): self.project.get_service('simple').create_container() self.command.dispatch(['ps'], None) self.assertIn('simplecomposefile_simple_1', mock_stdout.getvalue()) - @patch('sys.stdout', new_callable=StringIO) + @mock.patch('sys.stdout', new_callable=StringIO) def test_ps_default_composefile(self, mock_stdout): self.command.base_dir = 'tests/fixtures/multiple-composefiles' self.command.dispatch(['up', '-d'], None) @@ -68,7 +68,7 @@ def test_ps_default_composefile(self, mock_stdout): self.assertIn('multiplecomposefiles_another_1', output) self.assertNotIn('multiplecomposefiles_yetanother_1', output) - @patch('sys.stdout', new_callable=StringIO) + @mock.patch('sys.stdout', new_callable=StringIO) def test_ps_alternate_composefile(self, mock_stdout): config_path = os.path.abspath( 'tests/fixtures/multiple-composefiles/compose2.yml') @@ -83,19 +83,19 @@ def test_ps_alternate_composefile(self, mock_stdout): self.assertNotIn('multiplecomposefiles_another_1', output) self.assertIn('multiplecomposefiles_yetanother_1', output) - @patch('compose.service.log') + @mock.patch('compose.service.log') def test_pull(self, mock_logging): self.command.dispatch(['pull'], None) mock_logging.info.assert_any_call('Pulling simple (busybox:latest)...') mock_logging.info.assert_any_call('Pulling another (busybox:latest)...') - @patch('compose.service.log') + @mock.patch('compose.service.log') def test_pull_with_digest(self, mock_logging): self.command.dispatch(['-f', 'digest.yml', 'pull'], None) mock_logging.info.assert_any_call('Pulling simple (busybox:latest)...') mock_logging.info.assert_any_call('Pulling digest (busybox@sha256:38a203e1986cf79639cfb9b2e1d6e773de84002feea2d4eb006b52004ee8502d)...') - @patch('sys.stdout', new_callable=StringIO) + @mock.patch('sys.stdout', new_callable=StringIO) def test_build_no_cache(self, mock_stdout): self.command.base_dir = 'tests/fixtures/simple-dockerfile' self.command.dispatch(['build', 'simple'], None) @@ -189,7 +189,7 @@ def test_up_with_timeout(self): self.assertFalse(config['AttachStdout']) self.assertFalse(config['AttachStdin']) - @patch('dockerpty.start') + @mock.patch('dockerpty.start') def test_run_service_without_links(self, mock_stdout): self.command.base_dir = 'tests/fixtures/links-composefile' self.command.dispatch(['run', 'console', '/bin/true'], None) @@ -202,7 +202,7 @@ def test_run_service_without_links(self, mock_stdout): self.assertTrue(config['AttachStdout']) self.assertTrue(config['AttachStdin']) - @patch('dockerpty.start') + @mock.patch('dockerpty.start') def test_run_service_with_links(self, __): self.command.base_dir = 'tests/fixtures/links-composefile' self.command.dispatch(['run', 'web', '/bin/true'], None) @@ -211,14 +211,14 @@ def test_run_service_with_links(self, __): self.assertEqual(len(db.containers()), 1) self.assertEqual(len(console.containers()), 0) - @patch('dockerpty.start') + @mock.patch('dockerpty.start') def test_run_with_no_deps(self, __): self.command.base_dir = 'tests/fixtures/links-composefile' self.command.dispatch(['run', '--no-deps', 'web', '/bin/true'], None) db = self.project.get_service('db') self.assertEqual(len(db.containers()), 0) - @patch('dockerpty.start') + @mock.patch('dockerpty.start') def test_run_does_not_recreate_linked_containers(self, __): self.command.base_dir = 'tests/fixtures/links-composefile' self.command.dispatch(['up', '-d', 'db'], None) @@ -234,7 +234,7 @@ def test_run_does_not_recreate_linked_containers(self, __): self.assertEqual(old_ids, new_ids) - @patch('dockerpty.start') + @mock.patch('dockerpty.start') def test_run_without_command(self, _): self.command.base_dir = 'tests/fixtures/commands-composefile' self.check_build('tests/fixtures/simple-dockerfile', tag='composetest_test') @@ -255,7 +255,7 @@ def test_run_without_command(self, _): [u'/bin/true'], ) - @patch('dockerpty.start') + @mock.patch('dockerpty.start') def test_run_service_with_entrypoint_overridden(self, _): self.command.base_dir = 'tests/fixtures/dockerfile_with_entrypoint' name = 'service' @@ -270,7 +270,7 @@ def test_run_service_with_entrypoint_overridden(self, _): [u'/bin/echo', u'helloworld'], ) - @patch('dockerpty.start') + @mock.patch('dockerpty.start') def test_run_service_with_user_overridden(self, _): self.command.base_dir = 'tests/fixtures/user-composefile' name = 'service' @@ -281,7 +281,7 @@ def test_run_service_with_user_overridden(self, _): container = service.containers(stopped=True, one_off=True)[0] self.assertEqual(user, container.get('Config.User')) - @patch('dockerpty.start') + @mock.patch('dockerpty.start') def test_run_service_with_user_overridden_short_form(self, _): self.command.base_dir = 'tests/fixtures/user-composefile' name = 'service' @@ -292,7 +292,7 @@ def test_run_service_with_user_overridden_short_form(self, _): container = service.containers(stopped=True, one_off=True)[0] self.assertEqual(user, container.get('Config.User')) - @patch('dockerpty.start') + @mock.patch('dockerpty.start') def test_run_service_with_environement_overridden(self, _): name = 'service' self.command.base_dir = 'tests/fixtures/environment-composefile' @@ -312,7 +312,7 @@ def test_run_service_with_environement_overridden(self, _): # make sure a value with a = don't crash out self.assertEqual('moto=bobo', container.environment['allo']) - @patch('dockerpty.start') + @mock.patch('dockerpty.start') def test_run_service_without_map_ports(self, __): # create one off container self.command.base_dir = 'tests/fixtures/ports-composefile' @@ -330,7 +330,7 @@ def test_run_service_without_map_ports(self, __): self.assertEqual(port_random, None) self.assertEqual(port_assigned, None) - @patch('dockerpty.start') + @mock.patch('dockerpty.start') def test_run_service_with_map_ports(self, __): # create one off container @@ -353,7 +353,7 @@ def test_run_service_with_map_ports(self, __): self.assertEqual(port_range[0], "0.0.0.0:49153") self.assertEqual(port_range[1], "0.0.0.0:49154") - @patch('dockerpty.start') + @mock.patch('dockerpty.start') def test_run_service_with_explicitly_maped_ports(self, __): # create one off container @@ -372,7 +372,7 @@ def test_run_service_with_explicitly_maped_ports(self, __): self.assertEqual(port_short, "0.0.0.0:30000") self.assertEqual(port_full, "0.0.0.0:30001") - @patch('dockerpty.start') + @mock.patch('dockerpty.start') def test_run_service_with_explicitly_maped_ip_ports(self, __): # create one off container @@ -508,7 +508,7 @@ def test_port(self): self.command.dispatch(['up', '-d'], None) container = self.project.get_service('simple').get_container() - @patch('sys.stdout', new_callable=StringIO) + @mock.patch('sys.stdout', new_callable=StringIO) def get_port(number, mock_stdout): self.command.dispatch(['port', 'simple', str(number)], None) return mock_stdout.getvalue().rstrip() @@ -525,7 +525,7 @@ def test_port_with_scale(self): self.project.containers(service_names=['simple']), key=attrgetter('name')) - @patch('sys.stdout', new_callable=StringIO) + @mock.patch('sys.stdout', new_callable=StringIO) def get_port(number, mock_stdout, index=None): if index is None: self.command.dispatch(['port', 'simple', str(number)], None) @@ -547,7 +547,7 @@ def test_env_file_relative_to_compose_file(self): self.assertEqual(len(containers), 1) self.assertIn("FOO=1", containers[0].get('Config.Env')) - @patch.dict(os.environ) + @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/integration/service_test.py b/tests/integration/service_test.py index 1d53465f630..fe54d4ae248 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -7,10 +7,10 @@ from os import path from docker.errors import APIError -from mock import patch from six import StringIO from six import text_type +from .. import mock from .testcases import DockerClientTestCase from compose import __version__ from compose.const import LABEL_CONTAINER_NUMBER @@ -460,7 +460,7 @@ def test_start_container_builds_images(self): ) container = create_and_start_container(service) container.wait() - self.assertIn('success', container.logs()) + self.assertIn(b'success', container.logs()) self.assertEqual(len(self.client.images(name='composetest_test')), 1) def test_start_container_uses_tagged_image_if_it_exists(self): @@ -473,7 +473,7 @@ def test_start_container_uses_tagged_image_if_it_exists(self): ) container = create_and_start_container(service) container.wait() - self.assertIn('success', container.logs()) + self.assertIn(b'success', container.logs()) def test_start_container_creates_ports(self): service = self.create_service('web', ports=[8000]) @@ -581,7 +581,7 @@ def test_scale(self): service.scale(0) self.assertEqual(len(service.containers()), 0) - @patch('sys.stdout', new_callable=StringIO) + @mock.patch('sys.stdout', new_callable=StringIO) def test_scale_with_stopped_containers(self, mock_stdout): """ Given there are some stopped containers and scale is called with a @@ -608,7 +608,7 @@ def test_scale_with_stopped_containers(self, mock_stdout): self.assertNotIn('Creating', captured_output) self.assertIn('Starting', captured_output) - @patch('sys.stdout', new_callable=StringIO) + @mock.patch('sys.stdout', new_callable=StringIO) def test_scale_with_stopped_containers_and_needing_creation(self, mock_stdout): """ Given there are some stopped containers and scale is called with a @@ -632,7 +632,7 @@ def test_scale_with_stopped_containers_and_needing_creation(self, mock_stdout): self.assertIn('Creating', captured_output) self.assertIn('Starting', captured_output) - @patch('sys.stdout', new_callable=StringIO) + @mock.patch('sys.stdout', new_callable=StringIO) def test_scale_with_api_returns_errors(self, mock_stdout): """ Test that when scaling if the API returns an error, that error is handled @@ -642,7 +642,7 @@ def test_scale_with_api_returns_errors(self, mock_stdout): next_number = service._next_container_number() service.create_container(number=next_number, quiet=True) - with patch( + with mock.patch( 'compose.container.Container.create', side_effect=APIError(message="testing", response={}, explanation="Boom")): @@ -652,7 +652,7 @@ def test_scale_with_api_returns_errors(self, mock_stdout): self.assertTrue(service.containers()[0].is_running) self.assertIn("ERROR: for 2 Boom", mock_stdout.getvalue()) - @patch('sys.stdout', new_callable=StringIO) + @mock.patch('sys.stdout', new_callable=StringIO) def test_scale_with_api_returns_unexpected_exception(self, mock_stdout): """ Test that when scaling if the API returns an error, that is not of type @@ -662,7 +662,7 @@ def test_scale_with_api_returns_unexpected_exception(self, mock_stdout): next_number = service._next_container_number() service.create_container(number=next_number, quiet=True) - with patch( + with mock.patch( 'compose.container.Container.create', side_effect=ValueError("BOOM")): with self.assertRaises(ValueError): @@ -671,7 +671,7 @@ def test_scale_with_api_returns_unexpected_exception(self, mock_stdout): self.assertEqual(len(service.containers()), 1) self.assertTrue(service.containers()[0].is_running) - @patch('compose.service.log') + @mock.patch('compose.service.log') def test_scale_with_desired_number_already_achieved(self, mock_log): """ Test that calling scale with a desired number that is equal to the @@ -694,7 +694,7 @@ def test_scale_with_desired_number_already_achieved(self, mock_log): captured_output = mock_log.info.call_args[0] self.assertIn('Desired container number already achieved', captured_output) - @patch('compose.service.log') + @mock.patch('compose.service.log') def test_scale_with_custom_container_name_outputs_warning(self, mock_log): """ Test that calling scale on a service that has a custom container name @@ -815,7 +815,7 @@ def test_env_from_file_combined_with_env(self): for k, v in {'ONE': '1', 'TWO': '2', 'THREE': '3', 'FOO': 'baz', 'DOO': 'dah'}.items(): self.assertEqual(env[k], v) - @patch.dict(os.environ) + @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/unit/cli/docker_client_test.py b/tests/unit/cli/docker_client_test.py index 6c2dc5f81ee..5ccde73ad36 100644 --- a/tests/unit/cli/docker_client_test.py +++ b/tests/unit/cli/docker_client_test.py @@ -3,9 +3,8 @@ import os -import mock - from compose.cli import docker_client +from tests import mock from tests import unittest diff --git a/tests/unit/cli/verbose_proxy_test.py b/tests/unit/cli/verbose_proxy_test.py index 6036974c6f3..f77568dc08f 100644 --- a/tests/unit/cli/verbose_proxy_test.py +++ b/tests/unit/cli/verbose_proxy_test.py @@ -1,6 +1,8 @@ from __future__ import absolute_import from __future__ import unicode_literals +import six + from compose.cli import verbose_proxy from tests import unittest @@ -8,7 +10,8 @@ class VerboseProxyTestCase(unittest.TestCase): def test_format_call(self): - expected = "(u'arg1', True, key=u'value')" + prefix = '' if six.PY3 else 'u' + expected = "(%(p)s'arg1', True, key=%(p)s'value')" % dict(p=prefix) actual = verbose_proxy.format_call( ("arg1", True), {'key': 'value'}) @@ -21,7 +24,7 @@ def test_format_return_sequence(self): self.assertEqual(expected, actual) def test_format_return(self): - expected = "{u'Id': u'ok'}" + expected = repr({'Id': 'ok'}) actual = verbose_proxy.format_return({'Id': 'ok'}, 2) self.assertEqual(expected, actual) diff --git a/tests/unit/cli_test.py b/tests/unit/cli_test.py index 35be4e92646..7d22ad02ff7 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -4,8 +4,8 @@ import os import docker -import mock +from .. import mock from .. import unittest from compose.cli.docopt_command import NoSuchCommand from compose.cli.errors import UserError diff --git a/tests/unit/config_test.py b/tests/unit/config_test.py index 3d1a53214d4..7ecb6c4a2de 100644 --- a/tests/unit/config_test.py +++ b/tests/unit/config_test.py @@ -1,9 +1,11 @@ +from __future__ import print_function + import os import shutil import tempfile +from operator import itemgetter -import mock - +from .. import mock from .. import unittest from compose.config import config from compose.config.errors import ConfigurationError @@ -30,7 +32,7 @@ def test_load(self): ) self.assertEqual( - sorted(service_dicts, key=lambda d: d['name']), + sorted(service_dicts, key=itemgetter('name')), sorted([ { 'name': 'bar', @@ -41,7 +43,7 @@ def test_load(self): 'name': 'foo', 'image': 'busybox', } - ], key=lambda d: d['name']) + ], key=itemgetter('name')) ) def test_load_throws_error_when_not_dict(self): @@ -885,24 +887,24 @@ def load_config(): other_config = {'web': {'links': ['db']}} with mock.patch.object(config, 'load_yaml', return_value=other_config): - print load_config() + print(load_config()) with self.assertRaisesRegexp(ConfigurationError, 'volumes_from'): other_config = {'web': {'volumes_from': ['db']}} with mock.patch.object(config, 'load_yaml', return_value=other_config): - print load_config() + print(load_config()) with self.assertRaisesRegexp(ConfigurationError, 'net'): other_config = {'web': {'net': 'container:db'}} with mock.patch.object(config, 'load_yaml', return_value=other_config): - print load_config() + print(load_config()) other_config = {'web': {'net': 'host'}} with mock.patch.object(config, 'load_yaml', return_value=other_config): - print load_config() + print(load_config()) def test_volume_path(self): dicts = load_from_filename('tests/fixtures/volume-path/docker-compose.yml') diff --git a/tests/unit/container_test.py b/tests/unit/container_test.py index e2381c7c27d..1eba9f656da 100644 --- a/tests/unit/container_test.py +++ b/tests/unit/container_test.py @@ -1,8 +1,8 @@ from __future__ import unicode_literals import docker -import mock +from .. import mock from .. import unittest from compose.container import Container from compose.container import get_container_name diff --git a/tests/unit/log_printer_test.py b/tests/unit/log_printer_test.py index bfd16affe11..f3fa64c6141 100644 --- a/tests/unit/log_printer_test.py +++ b/tests/unit/log_printer_test.py @@ -3,6 +3,8 @@ import os +import six + from .. import unittest from compose.cli.log_printer import LogPrinter @@ -30,16 +32,17 @@ def test_polychrome(self): output = self.get_default_output() self.assertIn('\033[', output) + @unittest.skipIf(six.PY3, "Only test unicode in python2") def test_unicode(self): - glyph = u'\u2022'.encode('utf-8') + glyph = u'\u2022' def reader(*args, **kwargs): - yield glyph + b'\n' + yield glyph + '\n' container = MockContainer(reader) output = run_log_printer([container]) - self.assertIn(glyph, output) + self.assertIn(glyph, output.decode('utf-8')) def run_log_printer(containers, monochrome=False): diff --git a/tests/unit/project_test.py b/tests/unit/project_test.py index 7d633c95054..37ebe5148d0 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -1,8 +1,8 @@ from __future__ import unicode_literals import docker -import mock +from .. import mock from .. import unittest from compose.const import LABEL_SERVICE from compose.container import Container diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 12bb4ac2d74..3bb3e1722bd 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -2,9 +2,9 @@ from __future__ import unicode_literals import docker -import mock from docker.utils import LogConfig +from .. import mock from .. import unittest from compose.const import LABEL_ONE_OFF from compose.const import LABEL_PROJECT diff --git a/tests/unit/split_buffer_test.py b/tests/unit/split_buffer_test.py index efd99411ae5..11646099373 100644 --- a/tests/unit/split_buffer_test.py +++ b/tests/unit/split_buffer_test.py @@ -8,38 +8,38 @@ class SplitBufferTest(unittest.TestCase): def test_single_line_chunks(self): def reader(): - yield b'abc\n' - yield b'def\n' - yield b'ghi\n' + yield 'abc\n' + yield 'def\n' + yield 'ghi\n' - self.assert_produces(reader, [b'abc\n', b'def\n', b'ghi\n']) + self.assert_produces(reader, ['abc\n', 'def\n', 'ghi\n']) def test_no_end_separator(self): def reader(): - yield b'abc\n' - yield b'def\n' - yield b'ghi' + yield 'abc\n' + yield 'def\n' + yield 'ghi' - self.assert_produces(reader, [b'abc\n', b'def\n', b'ghi']) + self.assert_produces(reader, ['abc\n', 'def\n', 'ghi']) def test_multiple_line_chunk(self): def reader(): - yield b'abc\ndef\nghi' + yield 'abc\ndef\nghi' - self.assert_produces(reader, [b'abc\n', b'def\n', b'ghi']) + self.assert_produces(reader, ['abc\n', 'def\n', 'ghi']) def test_chunked_line(self): def reader(): - yield b'a' - yield b'b' - yield b'c' - yield b'\n' - yield b'd' + yield 'a' + yield 'b' + yield 'c' + yield '\n' + yield 'd' - self.assert_produces(reader, [b'abc\n', b'd']) + self.assert_produces(reader, ['abc\n', 'd']) def test_preserves_unicode_sequences_within_lines(self): - string = u"a\u2022c\n".encode('utf-8') + string = u"a\u2022c\n" def reader(): yield string @@ -47,7 +47,7 @@ def reader(): self.assert_produces(reader, [string]) def assert_produces(self, reader, expectations): - split = split_buffer(reader(), b'\n') + split = split_buffer(reader(), '\n') for (actual, expected) in zip(split, expectations): self.assertEqual(type(actual), type(expected)) diff --git a/tox.ini b/tox.ini index 3a69c5784c9..35523a9697a 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py27,pre-commit +envlist = py27,py34,pre-commit [testenv] usedevelop=True @@ -7,7 +7,6 @@ passenv = LD_LIBRARY_PATH deps = -rrequirements.txt - -rrequirements-dev.txt commands = nosetests -v {posargs} flake8 compose tests setup.py @@ -20,6 +19,26 @@ commands = pre-commit install pre-commit run --all-files +[testenv:py26] +deps = + {[testenv]deps} + -rrequirements-dev-py2.txt + +[testenv:py27] +deps = {[testenv:py26]deps} + +[testenv:pypy] +deps = {[testenv:py26]deps} + +[testenv:py33] +deps = + {[testenv]deps} + -rrequirements-dev-py3.txt + +[testenv:py34] +deps = {[testenv:py33]deps} + + [flake8] # ignore line-length for now ignore = E501,E203 From 9aa61e596e2475fa0bbcf227f2c388f6a9df471a Mon Sep 17 00:00:00 2001 From: funkyfuture Date: Thu, 26 Mar 2015 23:28:02 +0100 Subject: [PATCH 1108/4072] Run tests against Python 2.6, 2.7, 3.3, 3.4 and PyPy2 In particular it includes: - some extension of CONTRIBUTING.md - one fix for Python 2.6 in tests/integration/cli_test.py - one fix for Python 3.3 in tests/integration/service_test.py - removal of unused imports Make stream_output Python 3-compatible Signed-off-by: Frank Sachsenheim --- Dockerfile | 4 ++-- compose/container.py | 7 +++---- compose/progress_stream.py | 2 ++ compose/project.py | 2 +- requirements-dev.txt | 2 ++ script/test-versions | 2 +- setup.py | 2 +- tests/integration/cli_test.py | 2 +- tests/integration/service_test.py | 2 +- tests/unit/progress_stream_test.py | 1 - tox.ini | 3 ++- 11 files changed, 16 insertions(+), 13 deletions(-) create mode 100644 requirements-dev.txt diff --git a/Dockerfile b/Dockerfile index 1986ac5a5b4..a4cc99fea02 100644 --- a/Dockerfile +++ b/Dockerfile @@ -66,8 +66,8 @@ WORKDIR /code/ ADD requirements.txt /code/ RUN pip install -r requirements.txt -ADD requirements-dev-py2.txt /code/ -RUN pip install -r requirements-dev-py2.txt +ADD requirements-dev.txt /code/ +RUN pip install -r requirements-dev.txt RUN pip install tox==2.1.1 diff --git a/compose/container.py b/compose/container.py index f727c8673af..6f426532ac9 100644 --- a/compose/container.py +++ b/compose/container.py @@ -1,9 +1,8 @@ from __future__ import absolute_import from __future__ import unicode_literals -from functools import reduce - -import six +from six import iteritems +from six.moves import reduce from .const import LABEL_CONTAINER_NUMBER from .const import LABEL_SERVICE @@ -90,7 +89,7 @@ def format_port(private, public): private=private, **public[0]) return ', '.join(format_port(*item) - for item in sorted(six.iteritems(self.ports))) + for item in sorted(iteritems(self.ports))) @property def labels(self): diff --git a/compose/progress_stream.py b/compose/progress_stream.py index 582c09fb9e4..e2300fd4af8 100644 --- a/compose/progress_stream.py +++ b/compose/progress_stream.py @@ -17,6 +17,8 @@ def stream_output(output, stream): diff = 0 for chunk in output: + if six.PY3 and not isinstance(chunk, str): + chunk = chunk.decode('utf-8') event = json.loads(chunk) all_events.append(event) diff --git a/compose/project.py b/compose/project.py index d14941e72b2..cd88b2988ba 100644 --- a/compose/project.py +++ b/compose/project.py @@ -2,9 +2,9 @@ from __future__ import unicode_literals import logging -from functools import reduce from docker.errors import APIError +from six.moves import reduce from .config import ConfigurationError from .config import get_service_name_from_net diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 00000000000..cc984225309 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,2 @@ +flake8 +tox diff --git a/script/test-versions b/script/test-versions index d67a6f5e124..e2102e449c4 100755 --- a/script/test-versions +++ b/script/test-versions @@ -24,5 +24,5 @@ for version in $DOCKER_VERSIONS; do -e "DOCKER_DAEMON_ARGS" \ --entrypoint="script/dind" \ "$TAG" \ - script/wrapdocker nosetests --with-coverage --cover-branches --cover-package=compose --cover-erase --cover-html-dir=coverage-html --cover-html "$@" + script/wrapdocker tox "$@" done diff --git a/setup.py b/setup.py index b7fd4403482..cdb5686cf35 100644 --- a/setup.py +++ b/setup.py @@ -48,7 +48,7 @@ def find_version(*file_paths): ] -if sys.version_info < (2, 6): +if sys.version_info < (2, 7): tests_require.append('unittest2') if sys.version_info[:1] < (3,): tests_require.append('pyinstaller') diff --git a/tests/integration/cli_test.py b/tests/integration/cli_test.py index 609370a3e1f..9552bf6a66c 100644 --- a/tests/integration/cli_test.py +++ b/tests/integration/cli_test.py @@ -275,7 +275,7 @@ def test_run_service_with_user_overridden(self, _): self.command.base_dir = 'tests/fixtures/user-composefile' name = 'service' user = 'sshd' - args = ['run', '--user={}'.format(user), name] + args = ['run', '--user={user}'.format(user=user), name] self.command.dispatch(args, None) service = self.project.get_service(name) container = service.containers(stopped=True, one_off=True)[0] diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index fe54d4ae248..effd356dfad 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -358,7 +358,7 @@ def test_execute_convergence_plan_with_image_declared_volume(self): ) old_container = create_and_start_container(service) - self.assertEqual(old_container.get('Volumes').keys(), ['/data']) + self.assertEqual(list(old_container.get('Volumes').keys()), ['/data']) volume_path = old_container.get('Volumes')['/data'] new_container, = service.execute_convergence_plan( diff --git a/tests/unit/progress_stream_test.py b/tests/unit/progress_stream_test.py index 5674f4e4e8d..e38a7443534 100644 --- a/tests/unit/progress_stream_test.py +++ b/tests/unit/progress_stream_test.py @@ -8,7 +8,6 @@ class ProgressStreamTestCase(unittest.TestCase): - def test_stream_output(self): output = [ '{"status": "Downloading", "progressDetail": {"current": ' diff --git a/tox.ini b/tox.ini index 35523a9697a..2e3edd2a50c 100644 --- a/tox.ini +++ b/tox.ini @@ -8,7 +8,7 @@ passenv = deps = -rrequirements.txt commands = - nosetests -v {posargs} + nosetests -v --with-coverage --cover-branches --cover-package=compose --cover-erase --cover-html-dir=coverage-html --cover-html {posargs} flake8 compose tests setup.py [testenv:pre-commit] @@ -38,6 +38,7 @@ deps = [testenv:py34] deps = {[testenv:py33]deps} +# TODO pypy3 [flake8] # ignore line-length for now From 2943ac6812bcc8cdcd5b877155cdf69dd08c5b8a Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 2 Jul 2015 22:35:20 -0400 Subject: [PATCH 1109/4072] Cleanup requirements.txt so we don't have to maintain separate copies for py2 and py3. Signed-off-by: Daniel Nephin --- Dockerfile | 14 ++++++++++++++ MANIFEST.in | 2 +- compose/container.py | 7 ++++--- compose/project.py | 4 ++-- compose/service.py | 8 +++++--- compose/utils.py | 2 +- requirements-dev-py2.txt | 7 ------- requirements-dev-py3.txt | 2 -- requirements-dev.txt | 7 +++++-- script/test-versions | 2 +- setup.py | 3 --- tests/integration/resilience_test.py | 3 +-- tests/integration/service_test.py | 4 ++-- tests/unit/service_test.py | 2 +- tox.ini | 22 +++++++--------------- 15 files changed, 44 insertions(+), 45 deletions(-) delete mode 100644 requirements-dev-py2.txt delete mode 100644 requirements-dev-py3.txt diff --git a/Dockerfile b/Dockerfile index a4cc99fea02..546e28d69da 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,6 +30,18 @@ RUN set -ex; \ rm -rf /Python-2.7.9; \ rm Python-2.7.9.tgz +# Build python 3.4 from source +RUN set -ex; \ + curl -LO https://www.python.org/ftp/python/3.4.3/Python-3.4.3.tgz; \ + tar -xzf Python-3.4.3.tgz; \ + cd Python-3.4.3; \ + ./configure --enable-shared; \ + make; \ + make install; \ + cd ..; \ + rm -rf /Python-3.4.3; \ + rm Python-3.4.3.tgz + # Make libpython findable ENV LD_LIBRARY_PATH /usr/local/lib @@ -63,6 +75,8 @@ RUN ln -s /usr/local/bin/docker-1.7.1 /usr/local/bin/docker RUN useradd -d /home/user -m -s /bin/bash user WORKDIR /code/ +RUN pip install tox + ADD requirements.txt /code/ RUN pip install -r requirements.txt diff --git a/MANIFEST.in b/MANIFEST.in index 7420485961a..7d48d347a84 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,7 +1,7 @@ include Dockerfile include LICENSE include requirements.txt -include requirements-dev*.txt +include requirements-dev.txt include tox.ini include *.md include compose/config/schema.json diff --git a/compose/container.py b/compose/container.py index 6f426532ac9..f727c8673af 100644 --- a/compose/container.py +++ b/compose/container.py @@ -1,8 +1,9 @@ from __future__ import absolute_import from __future__ import unicode_literals -from six import iteritems -from six.moves import reduce +from functools import reduce + +import six from .const import LABEL_CONTAINER_NUMBER from .const import LABEL_SERVICE @@ -89,7 +90,7 @@ def format_port(private, public): private=private, **public[0]) return ', '.join(format_port(*item) - for item in sorted(iteritems(self.ports))) + for item in sorted(six.iteritems(self.ports))) @property def labels(self): diff --git a/compose/project.py b/compose/project.py index cd88b2988ba..a3127c6c295 100644 --- a/compose/project.py +++ b/compose/project.py @@ -2,9 +2,9 @@ from __future__ import unicode_literals import logging +from functools import reduce from docker.errors import APIError -from six.moves import reduce from .config import ConfigurationError from .config import get_service_name_from_net @@ -340,7 +340,7 @@ def matches_service_names(container): self.service_names, ) - return filter(matches_service_names, containers) + return [c for c in containers if matches_service_names(c)] def _inject_deps(self, acc, service): dep_names = service.get_dependency_names() diff --git a/compose/service.py b/compose/service.py index 647516ba844..d8a26e73a9a 100644 --- a/compose/service.py +++ b/compose/service.py @@ -709,7 +709,9 @@ def _get_container_host_config(self, override_options, one_off=False): def build(self, no_cache=False): log.info('Building %s...' % self.name) - path = six.binary_type(self.options['build']) + path = self.options['build'] + if not six.PY3: + path = path.encode('utf8') build_output = self.client.build( path=path, @@ -840,7 +842,7 @@ def merge_volume_bindings(volumes_option, previous_container): volume_bindings.update( get_container_data_volumes(previous_container, volumes_option)) - return volume_bindings.values() + return list(volume_bindings.values()) def get_container_data_volumes(container, volumes_option): @@ -853,7 +855,7 @@ def get_container_data_volumes(container, volumes_option): container_volumes = container.get('Volumes') or {} image_volumes = container.image_config['ContainerConfig'].get('Volumes') or {} - for volume in set(volumes_option + image_volumes.keys()): + for volume in set(volumes_option + list(image_volumes)): volume = parse_volume_spec(volume) # No need to preserve host volumes if volume.external: diff --git a/compose/utils.py b/compose/utils.py index bd8922670e3..0cbefba9beb 100644 --- a/compose/utils.py +++ b/compose/utils.py @@ -97,5 +97,5 @@ def write_out_msg(stream, lines, msg_index, msg, status="done"): def json_hash(obj): dump = json.dumps(obj, sort_keys=True, separators=(',', ':')) h = hashlib.sha256() - h.update(dump) + h.update(dump.encode('utf8')) return h.hexdigest() diff --git a/requirements-dev-py2.txt b/requirements-dev-py2.txt deleted file mode 100644 index 97fc4fed868..00000000000 --- a/requirements-dev-py2.txt +++ /dev/null @@ -1,7 +0,0 @@ -coverage==3.7.1 -flake8==2.3.0 -git+https://github.com/pyinstaller/pyinstaller.git@12e40471c77f588ea5be352f7219c873ddaae056#egg=pyinstaller -mock >= 1.0.1 -nose==1.3.4 -pep8==1.6.1 -unittest2==0.8.0 diff --git a/requirements-dev-py3.txt b/requirements-dev-py3.txt deleted file mode 100644 index a2ba1c8b452..00000000000 --- a/requirements-dev-py3.txt +++ /dev/null @@ -1,2 +0,0 @@ -flake8 -nose >= 1.3.0 diff --git a/requirements-dev.txt b/requirements-dev.txt index cc984225309..9e830733c15 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,2 +1,5 @@ -flake8 -tox +flake8==2.3.0 +git+https://github.com/pyinstaller/pyinstaller.git@12e40471c77f588ea5be352f7219c873ddaae056#egg=pyinstaller +mock >= 1.0.1 +nose==1.3.4 +pep8==1.6.1 diff --git a/script/test-versions b/script/test-versions index e2102e449c4..f39c17e819a 100755 --- a/script/test-versions +++ b/script/test-versions @@ -24,5 +24,5 @@ for version in $DOCKER_VERSIONS; do -e "DOCKER_DAEMON_ARGS" \ --entrypoint="script/dind" \ "$TAG" \ - script/wrapdocker tox "$@" + script/wrapdocker tox -e py27,py34 -- "$@" done diff --git a/setup.py b/setup.py index cdb5686cf35..33335047bca 100644 --- a/setup.py +++ b/setup.py @@ -41,9 +41,7 @@ def find_version(*file_paths): tests_require = [ - 'mock >= 1.0.1', 'nose', - 'pyinstaller', 'flake8', ] @@ -51,7 +49,6 @@ def find_version(*file_paths): if sys.version_info < (2, 7): tests_require.append('unittest2') if sys.version_info[:1] < (3,): - tests_require.append('pyinstaller') tests_require.append('mock >= 1.0.1') diff --git a/tests/integration/resilience_test.py b/tests/integration/resilience_test.py index b1faf99dff3..82a4680d8b0 100644 --- a/tests/integration/resilience_test.py +++ b/tests/integration/resilience_test.py @@ -1,8 +1,7 @@ from __future__ import absolute_import from __future__ import unicode_literals -import mock - +from .. import mock from .testcases import DockerClientTestCase from compose.project import Project diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index effd356dfad..f300c6d531a 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -363,7 +363,7 @@ def test_execute_convergence_plan_with_image_declared_volume(self): new_container, = service.execute_convergence_plan( ConvergencePlan('recreate', [old_container])) - self.assertEqual(new_container.get('Volumes').keys(), ['/data']) + self.assertEqual(list(new_container.get('Volumes')), ['/data']) self.assertEqual(new_container.get('Volumes')['/data'], volume_path) def test_start_container_passes_through_options(self): @@ -498,7 +498,7 @@ def test_build_non_ascii_filename(self): with open(os.path.join(base_dir, 'Dockerfile'), 'w') as f: f.write("FROM busybox\n") - with open(os.path.join(base_dir, b'foo\xE2bar'), 'w') as f: + with open(os.path.join(base_dir.encode('utf8'), b'foo\xE2bar'), 'w') as f: f.write("hello world\n") self.create_service('web', build=text_type(base_dir)).build() diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 3bb3e1722bd..f2247527705 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -41,7 +41,7 @@ def test_containers_with_containers(self): dict(Name=str(i), Image='foo', Id=i) for i in range(3) ] service = Service('db', self.mock_client, 'myproject', image='foo') - self.assertEqual([c.id for c in service.containers()], range(3)) + self.assertEqual([c.id for c in service.containers()], list(range(3))) expected_labels = [ '{0}=myproject'.format(LABEL_PROJECT), diff --git a/tox.ini b/tox.ini index 2e3edd2a50c..a2bd6b6b9ba 100644 --- a/tox.ini +++ b/tox.ini @@ -5,6 +5,8 @@ envlist = py27,py34,pre-commit usedevelop=True passenv = LD_LIBRARY_PATH +setenv = + HOME=/tmp deps = -rrequirements.txt commands = @@ -19,26 +21,16 @@ commands = pre-commit install pre-commit run --all-files -[testenv:py26] -deps = - {[testenv]deps} - -rrequirements-dev-py2.txt - [testenv:py27] -deps = {[testenv:py26]deps} - -[testenv:pypy] -deps = {[testenv:py26]deps} - -[testenv:py33] deps = {[testenv]deps} - -rrequirements-dev-py3.txt + -rrequirements-dev.txt [testenv:py34] -deps = {[testenv:py33]deps} - -# TODO pypy3 +deps = + {[testenv]deps} + flake8 + nose [flake8] # ignore line-length for now From feaa4a5f1aa97caf984d08e50d4e6c384fe1f0ae Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 24 Aug 2015 11:51:38 -0400 Subject: [PATCH 1110/4072] Unit tests passing again. Signed-off-by: Daniel Nephin --- compose/config/validation.py | 2 +- compose/service.py | 4 ++-- compose/utils.py | 4 ++-- tests/unit/config_test.py | 27 +++++++++++++-------------- tests/unit/service_test.py | 2 +- 5 files changed, 19 insertions(+), 20 deletions(-) diff --git a/compose/config/validation.py b/compose/config/validation.py index 8911f5ae1d7..e5f195f4002 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -150,7 +150,7 @@ def _parse_valid_types_from_schema(schema): config_key = error.path[0] required.append("Service '{}' option '{}' is invalid, {}".format(service_name, config_key, _clean_error_message(error.message))) elif error.validator == 'dependencies': - dependency_key = error.validator_value.keys()[0] + dependency_key = list(error.validator_value.keys())[0] required_keys = ",".join(error.validator_value[dependency_key]) required.append("Invalid '{}' configuration for '{}' service: when defining '{}' you must set '{}' as well".format( dependency_key, service_name, dependency_key, required_keys)) diff --git a/compose/service.py b/compose/service.py index d8a26e73a9a..9c0bc443918 100644 --- a/compose/service.py +++ b/compose/service.py @@ -103,11 +103,11 @@ def __init__(self, name, client=None, project='default', links=None, external_li def containers(self, stopped=False, one_off=False, filters={}): filters.update({'label': self.labels(one_off=one_off)}) - containers = filter(None, [ + containers = list(filter(None, [ Container.from_ps(self.client, container) for container in self.client.containers( all=stopped, - filters=filters)]) + filters=filters)])) if not containers: check_for_legacy_containers( diff --git a/compose/utils.py b/compose/utils.py index 0cbefba9beb..738fcacaffa 100644 --- a/compose/utils.py +++ b/compose/utils.py @@ -3,11 +3,11 @@ import json import logging import sys -from Queue import Empty -from Queue import Queue from threading import Thread from docker.errors import APIError +from six.moves.queue import Empty +from six.moves.queue import Queue log = logging.getLogger(__name__) diff --git a/tests/unit/config_test.py b/tests/unit/config_test.py index 7ecb6c4a2de..ccd5b57bf79 100644 --- a/tests/unit/config_test.py +++ b/tests/unit/config_test.py @@ -18,6 +18,10 @@ def make_service_dict(name, service_dict, working_dir): return config.ServiceLoader(working_dir=working_dir).make_service_dict(name, service_dict) +def service_sort(services): + return sorted(services, key=itemgetter('name')) + + class ConfigTest(unittest.TestCase): def test_load(self): service_dicts = config.load( @@ -32,8 +36,8 @@ def test_load(self): ) self.assertEqual( - sorted(service_dicts, key=itemgetter('name')), - sorted([ + service_sort(service_dicts), + service_sort([ { 'name': 'bar', 'image': 'busybox', @@ -43,7 +47,7 @@ def test_load(self): 'name': 'foo', 'image': 'busybox', } - ], key=itemgetter('name')) + ]) ) def test_load_throws_error_when_not_dict(self): @@ -684,12 +688,7 @@ class ExtendsTest(unittest.TestCase): def test_extends(self): service_dicts = load_from_filename('tests/fixtures/extends/docker-compose.yml') - service_dicts = sorted( - service_dicts, - key=lambda sd: sd['name'], - ) - - self.assertEqual(service_dicts, [ + self.assertEqual(service_sort(service_dicts), service_sort([ { 'name': 'mydb', 'image': 'busybox', @@ -706,7 +705,7 @@ def test_extends(self): "BAZ": "2", }, } - ]) + ])) def test_nested(self): service_dicts = load_from_filename('tests/fixtures/extends/nested.yml') @@ -728,7 +727,7 @@ def test_self_referencing_file(self): We specify a 'file' key that is the filename we're already in. """ service_dicts = load_from_filename('tests/fixtures/extends/specify-file-as-self.yml') - self.assertEqual(service_dicts, [ + self.assertEqual(service_sort(service_dicts), service_sort([ { 'environment': { @@ -749,7 +748,7 @@ def test_self_referencing_file(self): 'image': 'busybox', 'name': 'web' } - ]) + ])) def test_circular(self): try: @@ -856,7 +855,7 @@ def test_extends_file_defaults_to_self(self): config is valid and correctly extends from itself. """ service_dicts = load_from_filename('tests/fixtures/extends/no-file-specified.yml') - self.assertEqual(service_dicts, [ + self.assertEqual(service_sort(service_dicts), service_sort([ { 'name': 'myweb', 'image': 'busybox', @@ -872,7 +871,7 @@ def test_extends_file_defaults_to_self(self): "BAZ": "3", } } - ]) + ])) def test_blacklisted_options(self): def load_config(): diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index f2247527705..4708616e346 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -34,7 +34,7 @@ def test_project_validation(self): def test_containers(self): service = Service('db', self.mock_client, 'myproject', image='foo') self.mock_client.containers.return_value = [] - self.assertEqual(service.containers(), []) + self.assertEqual(list(service.containers()), []) def test_containers_with_containers(self): self.mock_client.containers.return_value = [ From 7e4c3142d721ccab37ee6e34d93e9214fc3b89ef Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 24 Aug 2015 12:43:08 -0400 Subject: [PATCH 1111/4072] Have log_printer use utf8 stream. Signed-off-by: Daniel Nephin --- compose/cli/log_printer.py | 6 +++--- tests/unit/log_printer_test.py | 6 ++++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/compose/cli/log_printer.py b/compose/cli/log_printer.py index c7d0b638f88..034551ec62a 100644 --- a/compose/cli/log_printer.py +++ b/compose/cli/log_printer.py @@ -5,7 +5,9 @@ from itertools import cycle import six +from six import next +from compose import utils from . import colors from .multiplexer import Multiplexer from .utils import split_buffer @@ -17,13 +19,11 @@ def __init__(self, containers, attach_params=None, output=sys.stdout, monochrome self.attach_params = attach_params or {} self.prefix_width = self._calculate_prefix_width(containers) self.generators = self._make_log_generators(monochrome) - self.output = output + self.output = utils.get_output_stream(output) def run(self): mux = Multiplexer(self.generators) for line in mux.loop(): - if isinstance(line, six.text_type) and not six.PY3: - line = line.encode('utf-8') self.output.write(line) def _calculate_prefix_width(self, containers): diff --git a/tests/unit/log_printer_test.py b/tests/unit/log_printer_test.py index f3fa64c6141..284934a6af0 100644 --- a/tests/unit/log_printer_test.py +++ b/tests/unit/log_printer_test.py @@ -32,7 +32,6 @@ def test_polychrome(self): output = self.get_default_output() self.assertIn('\033[', output) - @unittest.skipIf(six.PY3, "Only test unicode in python2") def test_unicode(self): glyph = u'\u2022' @@ -42,7 +41,10 @@ def reader(*args, **kwargs): container = MockContainer(reader) output = run_log_printer([container]) - self.assertIn(glyph, output.decode('utf-8')) + if six.PY2: + output = output.decode('utf-8') + + self.assertIn(glyph, output) def run_log_printer(containers, monochrome=False): From 71ff872e8e6f09a15f39f90b7faba2b44201c46d Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 24 Aug 2015 13:16:13 -0400 Subject: [PATCH 1112/4072] Update unit tests for stream_output to match the behaviour of a docker-py response. Signed-off-by: Daniel Nephin --- compose/cli/log_printer.py | 3 +-- compose/progress_stream.py | 8 ++++---- compose/project.py | 4 ++-- compose/service.py | 2 ++ compose/utils.py | 9 ++++++++- tests/integration/legacy_test.py | 4 ++-- tests/unit/progress_stream_test.py | 18 +++++++++--------- tests/unit/service_test.py | 2 +- 8 files changed, 29 insertions(+), 21 deletions(-) diff --git a/compose/cli/log_printer.py b/compose/cli/log_printer.py index 034551ec62a..69ada850e5a 100644 --- a/compose/cli/log_printer.py +++ b/compose/cli/log_printer.py @@ -4,13 +4,12 @@ import sys from itertools import cycle -import six from six import next -from compose import utils from . import colors from .multiplexer import Multiplexer from .utils import split_buffer +from compose import utils class LogPrinter(object): diff --git a/compose/progress_stream.py b/compose/progress_stream.py index e2300fd4af8..c44b33e5614 100644 --- a/compose/progress_stream.py +++ b/compose/progress_stream.py @@ -1,8 +1,9 @@ -import codecs import json import six +from compose import utils + class StreamOutputError(Exception): pass @@ -10,14 +11,13 @@ class StreamOutputError(Exception): def stream_output(output, stream): is_terminal = hasattr(stream, 'isatty') and stream.isatty() - if not six.PY3: - stream = codecs.getwriter('utf-8')(stream) + stream = utils.get_output_stream(stream) all_events = [] lines = {} diff = 0 for chunk in output: - if six.PY3 and not isinstance(chunk, str): + if six.PY3: chunk = chunk.decode('utf-8') event = json.loads(chunk) all_events.append(event) diff --git a/compose/project.py b/compose/project.py index a3127c6c295..542c8785e52 100644 --- a/compose/project.py +++ b/compose/project.py @@ -324,11 +324,11 @@ def containers(self, service_names=None, stopped=False, one_off=False): else: service_names = self.service_names - containers = filter(None, [ + 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)})]) + filters={'label': self.labels(one_off=one_off)})])) def matches_service_names(container): return container.labels.get(LABEL_SERVICE) in service_names diff --git a/compose/service.py b/compose/service.py index 9c0bc443918..a15ee1b9afa 100644 --- a/compose/service.py +++ b/compose/service.py @@ -710,6 +710,8 @@ def build(self, no_cache=False): log.info('Building %s...' % self.name) path = self.options['build'] + # python2 os.path() doesn't support unicode, so we need to encode it to + # a byte string if not six.PY3: path = path.encode('utf8') diff --git a/compose/utils.py b/compose/utils.py index 738fcacaffa..c7292284091 100644 --- a/compose/utils.py +++ b/compose/utils.py @@ -5,6 +5,7 @@ import sys from threading import Thread +import six from docker.errors import APIError from six.moves.queue import Empty from six.moves.queue import Queue @@ -18,7 +19,7 @@ def parallel_execute(objects, obj_callable, msg_index, msg): For a given list of objects, call the callable passing in the first object we give it. """ - stream = codecs.getwriter('utf-8')(sys.stdout) + stream = get_output_stream() lines = [] errors = {} @@ -70,6 +71,12 @@ def inner_execute_function(an_callable, parameter, msg_index): stream.write("ERROR: for {} {} \n".format(error, errors[error])) +def get_output_stream(stream=sys.stdout): + if six.PY3: + return stream + return codecs.getwriter('utf-8')(stream) + + def write_out_msg(stream, lines, msg_index, msg, status="done"): """ Using special ANSI code characters we can write out the msg over the top of diff --git a/tests/integration/legacy_test.py b/tests/integration/legacy_test.py index fa983e6d597..3465d57f491 100644 --- a/tests/integration/legacy_test.py +++ b/tests/integration/legacy_test.py @@ -1,8 +1,8 @@ import unittest from docker.errors import APIError -from mock import Mock +from .. import mock from .testcases import DockerClientTestCase from compose import legacy from compose.project import Project @@ -66,7 +66,7 @@ def test_is_valid_name_invalid(self): ) def test_get_legacy_containers(self): - client = Mock() + client = mock.Mock() client.containers.return_value = [ { "Id": "abc123", diff --git a/tests/unit/progress_stream_test.py b/tests/unit/progress_stream_test.py index e38a7443534..d8f7ec83633 100644 --- a/tests/unit/progress_stream_test.py +++ b/tests/unit/progress_stream_test.py @@ -10,27 +10,27 @@ class ProgressStreamTestCase(unittest.TestCase): def test_stream_output(self): output = [ - '{"status": "Downloading", "progressDetail": {"current": ' - '31019763, "start": 1413653874, "total": 62763875}, ' - '"progress": "..."}', + b'{"status": "Downloading", "progressDetail": {"current": ' + b'31019763, "start": 1413653874, "total": 62763875}, ' + b'"progress": "..."}', ] events = progress_stream.stream_output(output, StringIO()) self.assertEqual(len(events), 1) def test_stream_output_div_zero(self): output = [ - '{"status": "Downloading", "progressDetail": {"current": ' - '0, "start": 1413653874, "total": 0}, ' - '"progress": "..."}', + b'{"status": "Downloading", "progressDetail": {"current": ' + b'0, "start": 1413653874, "total": 0}, ' + b'"progress": "..."}', ] events = progress_stream.stream_output(output, StringIO()) self.assertEqual(len(events), 1) def test_stream_output_null_total(self): output = [ - '{"status": "Downloading", "progressDetail": {"current": ' - '0, "start": 1413653874, "total": null}, ' - '"progress": "..."}', + b'{"status": "Downloading", "progressDetail": {"current": ' + b'0, "start": 1413653874, "total": null}, ' + b'"progress": "..."}', ] events = progress_stream.stream_output(output, StringIO()) self.assertEqual(len(events), 1) diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 4708616e346..275bde1bdde 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -280,7 +280,7 @@ def test_create_container_no_build_but_needs_build(self): def test_build_does_not_pull(self): self.mock_client.build.return_value = [ - '{"stream": "Successfully built 12345"}', + b'{"stream": "Successfully built 12345"}', ] service = Service('foo', client=self.mock_client, build='.') From bd7c032a00b7701e5b29a983bb5a83b202dcd952 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 24 Aug 2015 14:49:51 -0400 Subject: [PATCH 1113/4072] Fix service integration tests. Signed-off-by: Daniel Nephin --- compose/utils.py | 4 ++-- requirements-dev.txt | 1 + tests/integration/service_test.py | 16 +++++----------- 3 files changed, 8 insertions(+), 13 deletions(-) diff --git a/compose/utils.py b/compose/utils.py index c7292284091..30284f97bdb 100644 --- a/compose/utils.py +++ b/compose/utils.py @@ -19,7 +19,7 @@ def parallel_execute(objects, obj_callable, msg_index, msg): For a given list of objects, call the callable passing in the first object we give it. """ - stream = get_output_stream() + stream = get_output_stream(sys.stdout) lines = [] errors = {} @@ -71,7 +71,7 @@ def inner_execute_function(an_callable, parameter, msg_index): stream.write("ERROR: for {} {} \n".format(error, errors[error])) -def get_output_stream(stream=sys.stdout): +def get_output_stream(stream): if six.PY3: return stream return codecs.getwriter('utf-8')(stream) diff --git a/requirements-dev.txt b/requirements-dev.txt index 9e830733c15..c8a694ab277 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -3,3 +3,4 @@ git+https://github.com/pyinstaller/pyinstaller.git@12e40471c77f588ea5be352f7219c mock >= 1.0.1 nose==1.3.4 pep8==1.6.1 +coverage==3.7.1 diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index f300c6d531a..bc9dcc69257 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -581,8 +581,7 @@ def test_scale(self): service.scale(0) self.assertEqual(len(service.containers()), 0) - @mock.patch('sys.stdout', new_callable=StringIO) - def test_scale_with_stopped_containers(self, mock_stdout): + def test_scale_with_stopped_containers(self): """ Given there are some stopped containers and scale is called with a desired number that is the same as the number of stopped containers, @@ -591,15 +590,11 @@ def test_scale_with_stopped_containers(self, mock_stdout): service = self.create_service('web') next_number = service._next_container_number() valid_numbers = [next_number, next_number + 1] - service.create_container(number=next_number, quiet=True) - service.create_container(number=next_number + 1, quiet=True) + service.create_container(number=next_number) + service.create_container(number=next_number + 1) - for container in service.containers(): - self.assertFalse(container.is_running) - - service.scale(2) - - self.assertEqual(len(service.containers()), 2) + with mock.patch('sys.stdout', new_callable=StringIO) as mock_stdout: + service.scale(2) for container in service.containers(): self.assertTrue(container.is_running) self.assertTrue(container.number in valid_numbers) @@ -701,7 +696,6 @@ def test_scale_with_custom_container_name_outputs_warning(self, mock_log): results in warning output. """ service = self.create_service('web', container_name='custom-container') - self.assertEqual(service.custom_container_name(), 'custom-container') service.scale(3) From 1451a6e1889b48a780759c41779e99b09ad16d18 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 24 Aug 2015 18:54:59 -0400 Subject: [PATCH 1114/4072] Python3 requires a locale Signed-off-by: Daniel Nephin --- Dockerfile | 5 +++++ requirements-dev.txt | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 546e28d69da..a9892031b81 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,6 +3,7 @@ FROM debian:wheezy RUN set -ex; \ apt-get update -qq; \ apt-get install -y \ + locales \ gcc \ make \ zlib1g \ @@ -61,6 +62,10 @@ RUN set -ex; \ rm -rf pip-7.0.1; \ rm pip-7.0.1.tar.gz +# Python3 requires a valid locale +RUN echo "en_US.UTF-8 UTF-8" > /etc/locale.gen && locale-gen +ENV LANG en_US.UTF-8 + ENV ALL_DOCKER_VERSIONS 1.7.1 1.8.1 RUN set -ex; \ diff --git a/requirements-dev.txt b/requirements-dev.txt index c8a694ab277..adb4387df25 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,6 +1,6 @@ +coverage==3.7.1 flake8==2.3.0 git+https://github.com/pyinstaller/pyinstaller.git@12e40471c77f588ea5be352f7219c873ddaae056#egg=pyinstaller mock >= 1.0.1 nose==1.3.4 pep8==1.6.1 -coverage==3.7.1 From 196f0afe8d689bf94543db97c96c17ad128459d6 Mon Sep 17 00:00:00 2001 From: Steve Durrheimer Date: Tue, 25 Aug 2015 16:52:10 +0200 Subject: [PATCH 1115/4072] Update zsh completion with last changes - sdurrheimer/docker-compose-zsh-completion@4ff5c0d Add pause and unpause commands - sdurrheimer/docker-compose-zsh-completion@9948d66 Add -t/--timeout flag to scale command - sdurrheimer/docker-compose-zsh-completion@7cf14c8 Improve -p/--publish flag for the run command - sdurrheimer/docker-compose-zsh-completion@cb16818 Don't trigger expensive completion function for flags - sdurrheimer/docker-compose-zsh-completion@52d33fa Several cosmetic improvements and return responses - sdurrheimer/docker-compose-zsh-completion@632ca9c Bump to version 1.5.0 - sdurrheimer/docker-compose-zsh-completion@22f92d9 Refactor compose file and project-name option flags when invoking docker-compose - sdurrheimer/docker-compose-zsh-completion@1b512fc Refactor --help flags Signed-off-by: Steve Durrheimer --- contrib/completion/zsh/_docker-compose | 154 +++++++++++++++---------- 1 file changed, 91 insertions(+), 63 deletions(-) diff --git a/contrib/completion/zsh/_docker-compose b/contrib/completion/zsh/_docker-compose index 9ac7e7560f4..58105dc221d 100644 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -7,7 +7,7 @@ # ------------------------------------------------------------------------- # Version # ------- -# 0.1.0 +# 1.5.0 # ------------------------------------------------------------------------- # Authors # ------- @@ -37,40 +37,54 @@ __docker-compose_compose_file() { ___docker-compose_all_services_in_compose_file() { local already_selected local -a services - already_selected=$(echo ${words[@]} | tr " " "|") + 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" } # All services, even those without an existing container __docker-compose_services_all() { + [[ $PREFIX = -* ]] && return 1 + integer ret=1 services=$(___docker-compose_all_services_in_compose_file) - _alternative "args:services:($services)" + _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() { local already_selected local -a buildable - already_selected=$(echo ${words[@]} | tr " " "|") + 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" } # All services that are defined by a Dockerfile reference __docker-compose_services_from_build() { + [[ $PREFIX = -* ]] && return 1 + integer ret=1 buildable=$(___docker-compose_services_with_key build) - _alternative "args:buildable services:($buildable)" + _alternative "args:buildable services:($buildable)" && ret=0 + + return ret } # All services that are defined by an image __docker-compose_services_from_image() { + [[ $PREFIX = -* ]] && return 1 + integer ret=1 pullable=$(___docker-compose_services_with_key image) - _alternative "args:pullable services:($pullable)" + _alternative "args:pullable services:($pullable)" && ret=0 + + return ret } __docker-compose_get_services() { - local kind expl - declare -a running stopped lines args services + [[ $PREFIX = -* ]] && return 1 + integer ret=1 + local kind + declare -a running paused stopped lines args services docker_status=$(docker ps > /dev/null 2>&1) if [ $? -ne 0 ]; then @@ -80,64 +94,78 @@ __docker-compose_get_services() { kind=$1 shift - [[ $kind = (stopped|all) ]] && args=($args -a) + [[ $kind =~ (stopped|all) ]] && args=($args -a) - lines=(${(f)"$(_call_program commands docker ps ${args})"}) - services=(${(f)"$(_call_program commands docker-compose 2>/dev/null ${compose_file:+-f $compose_file} ${compose_project:+-p $compose_project} ps -q)"}) + lines=(${(f)"$(_call_program commands docker ps $args)"}) + services=(${(f)"$(_call_program commands docker-compose 2>/dev/null $compose_options ps -q)"}) # Parse header line to find columns local i=1 j=1 k header=${lines[1]} declare -A begin end - while (( $j < ${#header} - 1 )) { - i=$(( $j + ${${header[$j,-1]}[(i)[^ ]]} - 1)) - j=$(( $i + ${${header[$i,-1]}[(i) ]} - 1)) - k=$(( $j + ${${header[$j,-1]}[(i)[^ ]]} - 2)) - begin[${header[$i,$(($j-1))]}]=$i - end[${header[$i,$(($j-1))]}]=$k - } + while (( j < ${#header} - 1 )); do + i=$(( j + ${${header[$j,-1]}[(i)[^ ]]} - 1 )) + j=$(( i + ${${header[$i,-1]}[(i) ]} - 1 )) + k=$(( j + ${${header[$j,-1]}[(i)[^ ]]} - 2 )) + begin[${header[$i,$((j-1))]}]=$i + end[${header[$i,$((j-1))]}]=$k + done lines=(${lines[2,-1]}) # Container ID local line s name local -a names for line in $lines; do - if [[ $services == *"${line[${begin[CONTAINER ID]},${end[CONTAINER ID]}]%% ##}"* ]]; then + if [[ ${services[@]} == *"${line[${begin[CONTAINER ID]},${end[CONTAINER ID]}]%% ##}"* ]]; then names=(${(ps:,:)${${line[${begin[NAMES]},-1]}%% *}}) for name in $names; do s="${${name%_*}#*_}:${(l:15:: :::)${${line[${begin[CREATED]},${end[CREATED]}]/ ago/}%% ##}}" s="$s, ${line[${begin[CONTAINER ID]},${end[CONTAINER ID]}]%% ##}" - s="$s, ${${${line[$begin[IMAGE],$end[IMAGE]]}/:/\\:}%% ##}" + s="$s, ${${${line[${begin[IMAGE]},${end[IMAGE]}]}/:/\\:}%% ##}" if [[ ${line[${begin[STATUS]},${end[STATUS]}]} = Exit* ]]; then stopped=($stopped $s) else + if [[ ${line[${begin[STATUS]},${end[STATUS]}]} = *\(Paused\)* ]]; then + paused=($paused $s) + fi running=($running $s) fi done fi done - [[ $kind = (running|all) ]] && _describe -t services-running "running services" running - [[ $kind = (stopped|all) ]] && _describe -t services-stopped "stopped services" stopped + [[ $kind =~ (running|all) ]] && _describe -t services-running "running services" running "$@" && ret=0 + [[ $kind =~ (paused|all) ]] && _describe -t services-paused "paused services" paused "$@" && ret=0 + [[ $kind =~ (stopped|all) ]] && _describe -t services-stopped "stopped services" stopped "$@" && ret=0 + + return ret +} + +__docker-compose_pausedservices() { + [[ $PREFIX = -* ]] && return 1 + __docker-compose_get_services paused "$@" } __docker-compose_stoppedservices() { + [[ $PREFIX = -* ]] && return 1 __docker-compose_get_services stopped "$@" } __docker-compose_runningservices() { + [[ $PREFIX = -* ]] && return 1 __docker-compose_get_services running "$@" } -__docker-compose_services () { +__docker-compose_services() { + [[ $PREFIX = -* ]] && return 1 __docker-compose_get_services all "$@" } __docker-compose_caching_policy() { - oldp=( "$1"(Nmh+1) ) # 1 hour + oldp=( "$1"(Nmh+1) ) # 1 hour (( $#oldp )) } -__docker-compose_commands () { +__docker-compose_commands() { local cache_policy zstyle -s ":completion:${curcontext}:" cache-policy cache_policy @@ -156,13 +184,14 @@ __docker-compose_commands () { _describe -t docker-compose-commands "docker-compose command" _docker_compose_subcommands } -__docker-compose_subcommand () { - local -a _command_args +__docker-compose_subcommand() { + local opts_help='(: -)--help[Print usage]' integer ret=1 + case "$words[1]" in (build) _arguments \ - '--help[Print usage]' \ + $opts_help \ '--no-cache[Do not use cache when building the image]' \ '*:services:__docker-compose_services_from_build' && ret=0 ;; @@ -171,24 +200,29 @@ __docker-compose_subcommand () { ;; (kill) _arguments \ - '--help[Print usage]' \ + $opts_help \ '-s[SIGNAL to send to the container. Default signal is SIGKILL.]:signal:_signals' \ '*:running services:__docker-compose_runningservices' && ret=0 ;; (logs) _arguments \ - '--help[Print usage]' \ + $opts_help \ '--no-color[Produce monochrome output.]' \ '*:services:__docker-compose_services_all' && ret=0 ;; (migrate-to-labels) _arguments -A '-*' \ - '--help[Print usage]' \ + $opts_help \ '(-):Recreate containers to add labels' && ret=0 ;; + (pause) + _arguments \ + $opts_help \ + '*:running services:__docker-compose_runningservices' && ret=0 + ;; (port) _arguments \ - '--help[Print usage]' \ + $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: ' \ '1:running services:__docker-compose_runningservices' \ @@ -196,33 +230,33 @@ __docker-compose_subcommand () { ;; (ps) _arguments \ - '--help[Print usage]' \ + $opts_help \ '-q[Only display IDs]' \ '*:services:__docker-compose_services_all' && ret=0 ;; (pull) _arguments \ - '--help[Print usage]' \ + $opts_help \ '*:services:__docker-compose_services_from_image' && ret=0 ;; (rm) _arguments \ + $opts_help \ '(-f --force)'{-f,--force}"[Don't ask to confirm removal]" \ - '--help[Print usage]' \ '-v[Remove volumes associated with containers]' \ '*:stopped services:__docker-compose_stoppedservices' && ret=0 ;; (run) _arguments \ + $opts_help \ '-d[Detached mode: Run container in the background, print new 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: ' \ - '--help[Print usage]' \ '(-u --user)'{-u,--user=-}'[Run as specified username or uid]:username or uid:_users' \ "--no-deps[Don't start linked services.]" \ '--rm[Remove container after run. Ignored in detached mode.]' \ - "--publish[Run command with manually mapped container's port(s) to the host.]" \ "--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.]' \ '(-):services:__docker-compose_services' \ '(-):command: _command_names -e' \ @@ -230,45 +264,52 @@ __docker-compose_subcommand () { ;; (scale) _arguments \ - '--help[Print usage]' \ + $opts_help \ + '(-t --timeout)'{-t,--timeout}"[Specify a shutdown timeout in seconds. (default: 10)]:seconds: " \ '*:running services:__docker-compose_runningservices' && ret=0 ;; (start) _arguments \ - '--help[Print usage]' \ + $opts_help \ '*:stopped services:__docker-compose_stoppedservices' && ret=0 ;; (stop|restart) _arguments \ - '--help[Print usage]' \ + $opts_help \ '(-t --timeout)'{-t,--timeout}"[Specify a shutdown timeout in seconds. (default: 10)]:seconds: " \ '*:running services:__docker-compose_runningservices' && ret=0 ;; + (unpause) + _arguments \ + $opts_help \ + '*:paused services:__docker-compose_pausedservices' && ret=0 + ;; (up) _arguments \ + $opts_help \ '-d[Detached mode: Run containers in the background, print new container names.]' \ - '--help[Print usage]' \ '--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.]" \ - "--force-recreate[Recreate containers even if their configuration and image haven't changed]" \ "--no-build[Don't build an image, even if it's missing]" \ '(-t --timeout)'{-t,--timeout}"[Specify a shutdown timeout in seconds. (default: 10)]:seconds: " \ '*:services:__docker-compose_services_all' && ret=0 ;; (version) _arguments \ - '--help[Print usage]' \ + $opts_help \ "--short[Shows only Compose's version number.]" && ret=0 ;; (*) - _message 'Unknown sub command' + _message 'Unknown sub command' && ret=1 + ;; esac return ret } -_docker-compose () { +_docker-compose() { # Support for subservices, which allows for `compdef _docker docker-shell=_docker_containers`. # Based on /usr/share/zsh/functions/Completion/Unix/_git without support for `ret`. if [[ $service != docker-compose ]]; then @@ -276,7 +317,8 @@ _docker-compose () { return fi - local curcontext="$curcontext" state line ret=1 + local curcontext="$curcontext" state line + integer ret=1 typeset -A opt_args _arguments -C \ @@ -288,23 +330,9 @@ _docker-compose () { '(-): :->command' \ '(-)*:: :->option-or-argument' && ret=0 - local counter=1 - #local compose_file compose_project - while [ $counter -lt ${#words[@]} ]; do - case "${words[$counter]}" in - -f|--file) - (( counter++ )) - compose_file="${words[$counter]}" - ;; - -p|--project-name) - (( counter++ )) - compose_project="${words[$counter]}" - ;; - *) - ;; - esac - (( counter++ )) - done + 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}" case $state in (command) From 2b589606dab4e9acc94c15fd328760e4da0a84cd Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 25 Aug 2015 16:49:49 -0400 Subject: [PATCH 1116/4072] Move log_printer_test into correct testing module for naming convention. Signed-off-by: Daniel Nephin --- tests/unit/{ => cli}/log_printer_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename tests/unit/{ => cli}/log_printer_test.py (98%) diff --git a/tests/unit/log_printer_test.py b/tests/unit/cli/log_printer_test.py similarity index 98% rename from tests/unit/log_printer_test.py rename to tests/unit/cli/log_printer_test.py index 284934a6af0..142bd7f3429 100644 --- a/tests/unit/log_printer_test.py +++ b/tests/unit/cli/log_printer_test.py @@ -5,8 +5,8 @@ import six -from .. import unittest from compose.cli.log_printer import LogPrinter +from tests import unittest class LogPrinterTest(unittest.TestCase): From a348993d2c15688aefb05914b5e3973622f83179 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 25 Aug 2015 16:55:29 -0400 Subject: [PATCH 1117/4072] Remove two unused functions from cli/utils.py Signed-off-by: Daniel Nephin --- compose/cli/utils.py | 34 ---------------------------------- 1 file changed, 34 deletions(-) diff --git a/compose/cli/utils.py b/compose/cli/utils.py index b6c83f9e1fc..cbc9123cf26 100644 --- a/compose/cli/utils.py +++ b/compose/cli/utils.py @@ -2,7 +2,6 @@ from __future__ import division from __future__ import unicode_literals -import datetime import os import platform import ssl @@ -36,39 +35,6 @@ def yesno(prompt, default=None): return None -# http://stackoverflow.com/a/5164027 -def prettydate(d): - diff = datetime.datetime.utcnow() - d - s = diff.seconds - if diff.days > 7 or diff.days < 0: - return d.strftime('%d %b %y') - elif diff.days == 1: - return '1 day ago' - elif diff.days > 1: - return '{0} days ago'.format(diff.days) - elif s <= 1: - return 'just now' - elif s < 60: - return '{0} seconds ago'.format(s) - elif s < 120: - return '1 minute ago' - elif s < 3600: - return '{0} minutes ago'.format(s / 60) - elif s < 7200: - return '1 hour ago' - else: - return '{0} hours ago'.format(s / 3600) - - -def mkdir(path, permissions=0o700): - if not os.path.exists(path): - os.mkdir(path) - - os.chmod(path, permissions) - - return path - - def find_candidates_in_parent_dirs(filenames, path): """ Given a directory path to start, looks for filenames in the From 9d9550c5b677647ad52235ed6d7fcf5ccfeac21a Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 25 Aug 2015 17:17:12 -0400 Subject: [PATCH 1118/4072] Fix log printing for python3 by converting everything to unicode. Signed-off-by: Daniel Nephin --- compose/cli/log_printer.py | 2 +- compose/cli/utils.py | 7 ++++--- tests/unit/cli/log_printer_test.py | 5 ++--- tests/unit/split_buffer_test.py | 28 ++++++++++++++-------------- 4 files changed, 21 insertions(+), 21 deletions(-) diff --git a/compose/cli/log_printer.py b/compose/cli/log_printer.py index 69ada850e5a..c2fcc54fdd6 100644 --- a/compose/cli/log_printer.py +++ b/compose/cli/log_printer.py @@ -57,7 +57,7 @@ def no_color(text): def _make_log_generator(self, container, color_fn): prefix = color_fn(self._generate_prefix(container)) # Attach to container before log printer starts running - line_generator = split_buffer(self._attach(container), '\n') + line_generator = split_buffer(self._attach(container), u'\n') for line in line_generator: yield prefix + line diff --git a/compose/cli/utils.py b/compose/cli/utils.py index cbc9123cf26..0b7ac683d1b 100644 --- a/compose/cli/utils.py +++ b/compose/cli/utils.py @@ -7,6 +7,7 @@ import ssl import subprocess +import six from docker import version as docker_py_version from six.moves import input @@ -63,11 +64,11 @@ def split_buffer(reader, separator): separator, except for the last one if none was found on the end of the input. """ - buffered = str('') - separator = str(separator) + buffered = six.text_type('') + separator = six.text_type(separator) for data in reader: - buffered += data + buffered += data.decode('utf-8') while True: index = buffered.find(separator) if index == -1: diff --git a/tests/unit/cli/log_printer_test.py b/tests/unit/cli/log_printer_test.py index 142bd7f3429..d8fbf94b9a7 100644 --- a/tests/unit/cli/log_printer_test.py +++ b/tests/unit/cli/log_printer_test.py @@ -12,7 +12,7 @@ class LogPrinterTest(unittest.TestCase): def get_default_output(self, monochrome=False): def reader(*args, **kwargs): - yield "hello\nworld" + yield b"hello\nworld" container = MockContainer(reader) output = run_log_printer([container], monochrome=monochrome) @@ -36,11 +36,10 @@ def test_unicode(self): glyph = u'\u2022' def reader(*args, **kwargs): - yield glyph + '\n' + yield glyph.encode('utf-8') + b'\n' container = MockContainer(reader) output = run_log_printer([container]) - if six.PY2: output = output.decode('utf-8') diff --git a/tests/unit/split_buffer_test.py b/tests/unit/split_buffer_test.py index 11646099373..47c72f0865c 100644 --- a/tests/unit/split_buffer_test.py +++ b/tests/unit/split_buffer_test.py @@ -8,33 +8,33 @@ class SplitBufferTest(unittest.TestCase): def test_single_line_chunks(self): def reader(): - yield 'abc\n' - yield 'def\n' - yield 'ghi\n' + yield b'abc\n' + yield b'def\n' + yield b'ghi\n' self.assert_produces(reader, ['abc\n', 'def\n', 'ghi\n']) def test_no_end_separator(self): def reader(): - yield 'abc\n' - yield 'def\n' - yield 'ghi' + yield b'abc\n' + yield b'def\n' + yield b'ghi' self.assert_produces(reader, ['abc\n', 'def\n', 'ghi']) def test_multiple_line_chunk(self): def reader(): - yield 'abc\ndef\nghi' + yield b'abc\ndef\nghi' self.assert_produces(reader, ['abc\n', 'def\n', 'ghi']) def test_chunked_line(self): def reader(): - yield 'a' - yield 'b' - yield 'c' - yield '\n' - yield 'd' + yield b'a' + yield b'b' + yield b'c' + yield b'\n' + yield b'd' self.assert_produces(reader, ['abc\n', 'd']) @@ -42,12 +42,12 @@ def test_preserves_unicode_sequences_within_lines(self): string = u"a\u2022c\n" def reader(): - yield string + yield string.encode('utf-8') self.assert_produces(reader, [string]) def assert_produces(self, reader, expectations): - split = split_buffer(reader(), '\n') + split = split_buffer(reader(), u'\n') for (actual, expected) in zip(split, expectations): self.assertEqual(type(actual), type(expected)) From 34249fad5d2e14320e35aaa1bf3cc8b3843e69c7 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 25 Aug 2015 14:26:33 +0100 Subject: [PATCH 1119/4072] Improve release docs Incorporating questions from https://gist.github.com/aanand/e567bd8d6a5d8e28c829 Signed-off-by: Aanand Prasad --- RELEASE_PROCESS.md | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/RELEASE_PROCESS.md b/RELEASE_PROCESS.md index e81a55ec693..0d5f42eba0b 100644 --- a/RELEASE_PROCESS.md +++ b/RELEASE_PROCESS.md @@ -18,6 +18,12 @@ Building a Compose release 4. 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. + + 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. + 5. Add a bump commit: git commit -am "Bump $VERSION" @@ -78,9 +84,29 @@ Building a Compose release git push git@github.com:docker/compose.git $TAG -8. Create a release from the tag on GitHub. +8. Draft a release from the tag on GitHub. + + - Go to https://github.com/docker/compose/releases and click "Draft a new release". + - In the "Tag version" dropdown, select the tag you just pushed. + +9. 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. + + Secondly, if you're a Mac user, the **[Docker Toolbox](https://www.docker.com/toolbox)** will install Compose ${VERSION} for you, alongside the latest versions of the Docker Engine, Machine and Kitematic. + + Otherwise, you can use the usual commands to install/upgrade. Either download the binary: + + 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 + + Or install the PyPi package: + + pip install -U docker-compose==1.5.0 + + Here's what's new: -9. Paste in installation instructions and release notes. + ...release notes go here... 10. Attach the binaries. From 7e22719090bf33012c5cd327cc70ebd965cd923f Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Wed, 26 Aug 2015 17:48:51 +0200 Subject: [PATCH 1120/4072] Fix suppressed blank in completion of `scale --help` Wrong placement of `compopt -o` introduces an unexpected behavior that did not matter as long as --help was the only option (you would probably not continue to type after --help): completion of options would not automatically append a whitespace character as expected. For the outstanding addition of the --timeout option, which has an argument, this would mean that the user would have to type an extra whitespace after completion of --timeout before the argument could be added. Signed-off-by: Harald Albers --- contrib/completion/bash/docker-compose | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index 5692f0e4b84..ee810ffaef4 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -278,10 +278,7 @@ _docker_compose_scale() { case "$prev" in =) COMPREPLY=("$cur") - ;; - *) - COMPREPLY=( $(compgen -S "=" -W "$(___docker_compose_all_services_in_compose_file)" -- "$cur") ) - compopt -o nospace + return ;; esac @@ -289,6 +286,10 @@ _docker_compose_scale() { -*) COMPREPLY=( $( compgen -W "--help" -- "$cur" ) ) ;; + *) + COMPREPLY=( $(compgen -S "=" -W "$(___docker_compose_all_services_in_compose_file)" -- "$cur") ) + compopt -o nospace + ;; esac } From b03a2f79104e098a9e9a01f7fcb9e8da7e6c4ec4 Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Wed, 26 Aug 2015 17:57:04 +0200 Subject: [PATCH 1121/4072] Add completion for `scale --timeout` Signed-off-by: Harald Albers --- contrib/completion/bash/docker-compose | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index ee810ffaef4..71745d8270c 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -280,11 +280,14 @@ _docker_compose_scale() { COMPREPLY=("$cur") return ;; + --timeout|-t) + return + ;; esac case "$cur" in -*) - COMPREPLY=( $( compgen -W "--help" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--help --timeout -t" -- "$cur" ) ) ;; *) COMPREPLY=( $(compgen -S "=" -W "$(___docker_compose_all_services_in_compose_file)" -- "$cur") ) From 2cfda01ff4562304d646b10c9069683bda774e33 Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Wed, 26 Aug 2015 18:16:36 +0200 Subject: [PATCH 1122/4072] Use consistent argument order in bash completion In most of this file and in Dockers's bash completion the sort order of options is to sort alphabetically by long option name. The short options are put right behind their long couterpart. This commit improves consistency. Signed-off-by: Harald Albers --- contrib/completion/bash/docker-compose | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index 71745d8270c..fe46a334edc 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -223,7 +223,7 @@ _docker_compose_pull() { _docker_compose_restart() { case "$prev" in - -t | --timeout) + --timeout|-t) return ;; esac @@ -311,7 +311,7 @@ _docker_compose_start() { _docker_compose_stop() { case "$prev" in - -t | --timeout) + --timeout|-t) return ;; esac @@ -341,7 +341,7 @@ _docker_compose_unpause() { _docker_compose_up() { case "$prev" in - -t | --timeout) + --timeout|-t) return ;; esac @@ -402,11 +402,11 @@ _docker_compose() { local compose_file compose_project while [ $counter -lt $cword ]; do case "${words[$counter]}" in - -f|--file) + --file|-f) (( counter++ )) compose_file="${words[$counter]}" ;; - -p|--project-name) + --project-name|p) (( counter++ )) compose_project="${words[$counter]}" ;; From 54973e8200a13dc9a386464c8eddaa2790155326 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 26 Aug 2015 12:53:11 -0400 Subject: [PATCH 1123/4072] Remove flake8 ignores and wrap the longest lines to 140 char. Signed-off-by: Daniel Nephin --- compose/config/validation.py | 34 +++++++++++++++++++++++++------ compose/legacy.py | 3 ++- compose/project.py | 16 ++++++++++++--- tests/integration/cli_test.py | 4 +++- tests/integration/service_test.py | 5 ++++- tests/unit/config_test.py | 5 ++++- tests/unit/container_test.py | 10 ++++++++- tox.ini | 4 ++-- 8 files changed, 65 insertions(+), 16 deletions(-) diff --git a/compose/config/validation.py b/compose/config/validation.py index e5f195f4002..0df73e3c25f 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -30,7 +30,11 @@ VALID_NAME_CHARS = '[a-zA-Z0-9\._\-]' -@FormatChecker.cls_checks(format="ports", raises=ValidationError("Invalid port formatting, it should be '[[remote_ip:]remote_port:]port[/protocol]'")) +@FormatChecker.cls_checks( + format="ports", + raises=ValidationError( + "Invalid port formatting, it should be " + "'[[remote_ip:]remote_port:]port[/protocol]'")) def format_ports(instance): try: split_port(instance) @@ -122,9 +126,14 @@ def _parse_valid_types_from_schema(schema): invalid_keys.append(get_unsupported_config_msg(service_name, invalid_config_key)) elif error.validator == 'anyOf': if 'image' in error.instance and 'build' in error.instance: - required.append("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)) + required.append( + "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)) elif 'image' not in error.instance and 'build' not in error.instance: - required.append("Service '{}' has neither an image nor a build path specified. Exactly one must be provided.".format(service_name)) + required.append( + "Service '{}' has neither an image nor a build path " + "specified. Exactly one must be provided.".format(service_name)) else: required.append(_clean_error_message(error.message)) elif error.validator == 'oneOf': @@ -143,12 +152,25 @@ def _parse_valid_types_from_schema(schema): if len(error.path) > 0: config_key = " ".join(["'%s'" % k for k in error.path]) - type_errors.append("Service '{}' configuration key {} contains an invalid type, it should be {} {}".format(service_name, config_key, msg, error.validator_value)) + type_errors.append( + "Service '{}' configuration key {} contains an invalid " + "type, it should be {} {}".format( + service_name, + config_key, + msg, + error.validator_value)) else: - root_msgs.append("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(service_name)) + root_msgs.append( + "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(service_name)) elif error.validator == 'required': config_key = error.path[0] - required.append("Service '{}' option '{}' is invalid, {}".format(service_name, config_key, _clean_error_message(error.message))) + required.append( + "Service '{}' option '{}' is invalid, {}".format( + service_name, + config_key, + _clean_error_message(error.message))) elif error.validator == 'dependencies': dependency_key = list(error.validator_value.keys())[0] required_keys = ",".join(error.validator_value[dependency_key]) diff --git a/compose/legacy.py b/compose/legacy.py index e8f4f957396..54162417897 100644 --- a/compose/legacy.py +++ b/compose/legacy.py @@ -17,7 +17,8 @@ {names_list} -As of Compose 1.3.0, containers are identified with labels instead of naming convention. If you want to continue using these containers, run: +As of Compose 1.3.0, containers are identified with labels instead of naming +convention. If you want to continue using these containers, run: $ docker-compose migrate-to-labels diff --git a/compose/project.py b/compose/project.py index 542c8785e52..4e8696ba882 100644 --- a/compose/project.py +++ b/compose/project.py @@ -157,7 +157,9 @@ def get_links(self, service_dict): try: links.append((self.get_service(service_name), link_name)) except NoSuchService: - raise ConfigurationError('Service "%s" has a link to service "%s" which does not exist.' % (service_dict['name'], service_name)) + raise ConfigurationError( + 'Service "%s" has a link to service "%s" which does not ' + 'exist.' % (service_dict['name'], service_name)) del service_dict['links'] return links @@ -173,7 +175,11 @@ def get_volumes_from(self, service_dict): container = Container.from_id(self.client, volume_name) volumes_from.append(container) except APIError: - raise ConfigurationError('Service "%s" mounts volumes from "%s", which is not the name of a service or container.' % (service_dict['name'], volume_name)) + raise ConfigurationError( + 'Service "%s" mounts volumes from "%s", which is ' + 'not the name of a service or container.' % ( + service_dict['name'], + volume_name)) del service_dict['volumes_from'] return volumes_from @@ -188,7 +194,11 @@ def get_net(self, service_dict): try: net = 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)) + 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)) else: net = service_dict['net'] diff --git a/tests/integration/cli_test.py b/tests/integration/cli_test.py index 9552bf6a66c..a7bc3b49a20 100644 --- a/tests/integration/cli_test.py +++ b/tests/integration/cli_test.py @@ -93,7 +93,9 @@ def test_pull(self, mock_logging): def test_pull_with_digest(self, mock_logging): self.command.dispatch(['-f', 'digest.yml', 'pull'], None) mock_logging.info.assert_any_call('Pulling simple (busybox:latest)...') - mock_logging.info.assert_any_call('Pulling digest (busybox@sha256:38a203e1986cf79639cfb9b2e1d6e773de84002feea2d4eb006b52004ee8502d)...') + mock_logging.info.assert_any_call( + 'Pulling digest (busybox@' + 'sha256:38a203e1986cf79639cfb9b2e1d6e773de84002feea2d4eb006b52004ee8502d)...') @mock.patch('sys.stdout', new_callable=StringIO) def test_build_no_cache(self, mock_stdout): diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index bc9dcc69257..fc634c8ca3e 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -804,7 +804,10 @@ def test_split_env(self): self.assertEqual(env[k], v) def test_env_from_file_combined_with_env(self): - service = self.create_service('web', environment=['ONE=1', 'TWO=2', 'THREE=3'], env_file=['tests/fixtures/env/one.env', 'tests/fixtures/env/two.env']) + service = self.create_service( + 'web', + environment=['ONE=1', 'TWO=2', 'THREE=3'], + env_file=['tests/fixtures/env/one.env', 'tests/fixtures/env/two.env']) env = create_and_start_container(service).environment for k, v in {'ONE': '1', 'TWO': '2', 'THREE': '3', 'FOO': 'baz', 'DOO': 'dah'}.items(): self.assertEqual(env[k], v) diff --git a/tests/unit/config_test.py b/tests/unit/config_test.py index ccd5b57bf79..51dac052f37 100644 --- a/tests/unit/config_test.py +++ b/tests/unit/config_test.py @@ -529,7 +529,10 @@ def test_validation_fails_with_just_memswap_limit(self): When you set a 'memswap_limit' it is invalid config unless you also set a mem_limit """ - expected_error_msg = "Invalid 'memswap_limit' configuration for 'foo' service: when defining 'memswap_limit' you must set 'mem_limit' as well" + expected_error_msg = ( + "Invalid 'memswap_limit' configuration for 'foo' service: when " + "defining 'memswap_limit' you must set 'mem_limit' as well" + ) with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): config.load( config.ConfigDetails( diff --git a/tests/unit/container_test.py b/tests/unit/container_test.py index 1eba9f656da..5637330cd42 100644 --- a/tests/unit/container_test.py +++ b/tests/unit/container_test.py @@ -142,4 +142,12 @@ 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': ['/swarm-host-1/myproject_db_1', '/swarm-host-1/myproject_web_1/db']}), 'myproject_db_1') + self.assertEqual( + get_container_name({ + 'Names': [ + '/swarm-host-1/myproject_db_1', + '/swarm-host-1/myproject_web_1/db' + ] + }), + 'myproject_db_1' + ) diff --git a/tox.ini b/tox.ini index a2bd6b6b9ba..4b27a4e9bec 100644 --- a/tox.ini +++ b/tox.ini @@ -33,6 +33,6 @@ deps = nose [flake8] -# ignore line-length for now -ignore = E501,E203 +# Allow really long lines for now +max-line-length = 140 exclude = compose/packages From bdec7e6b52500ce7ec3d0f0ee8acc789182e1e10 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 26 Aug 2015 13:32:21 -0400 Subject: [PATCH 1124/4072] Cleanup some test case, remove unused mock return values, and use standard single underscore for unused variable Signed-off-by: Daniel Nephin --- tests/integration/cli_test.py | 8 ++++---- tests/unit/service_test.py | 6 ------ 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/tests/integration/cli_test.py b/tests/integration/cli_test.py index 9552bf6a66c..78ff7604c7d 100644 --- a/tests/integration/cli_test.py +++ b/tests/integration/cli_test.py @@ -313,7 +313,7 @@ def test_run_service_with_environement_overridden(self, _): self.assertEqual('moto=bobo', container.environment['allo']) @mock.patch('dockerpty.start') - def test_run_service_without_map_ports(self, __): + def test_run_service_without_map_ports(self, _): # create one off container self.command.base_dir = 'tests/fixtures/ports-composefile' self.command.dispatch(['run', '-d', 'simple'], None) @@ -331,7 +331,7 @@ def test_run_service_without_map_ports(self, __): self.assertEqual(port_assigned, None) @mock.patch('dockerpty.start') - def test_run_service_with_map_ports(self, __): + def test_run_service_with_map_ports(self, _): # create one off container self.command.base_dir = 'tests/fixtures/ports-composefile' @@ -354,7 +354,7 @@ def test_run_service_with_map_ports(self, __): self.assertEqual(port_range[1], "0.0.0.0:49154") @mock.patch('dockerpty.start') - def test_run_service_with_explicitly_maped_ports(self, __): + def test_run_service_with_explicitly_maped_ports(self, _): # create one off container self.command.base_dir = 'tests/fixtures/ports-composefile' @@ -373,7 +373,7 @@ def test_run_service_with_explicitly_maped_ports(self, __): self.assertEqual(port_full, "0.0.0.0:30001") @mock.patch('dockerpty.start') - def test_run_service_with_explicitly_maped_ip_ports(self, __): + def test_run_service_with_explicitly_maped_ip_ports(self, _): # create one off container self.command.base_dir = 'tests/fixtures/ports-composefile' diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 275bde1bdde..5d37bfedf19 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -99,14 +99,12 @@ def test_get_volumes_from_service_no_container(self): def test_split_domainname_none(self): service = Service('foo', image='foo', hostname='name', client=self.mock_client) - self.mock_client.containers.return_value = [] opts = service._get_container_create_options({'image': 'foo'}, 1) self.assertEqual(opts['hostname'], 'name', 'hostname') self.assertFalse('domainname' in opts, 'domainname') def test_memory_swap_limit(self): service = Service(name='foo', image='foo', hostname='name', client=self.mock_client, mem_limit=1000000000, memswap_limit=2000000000) - self.mock_client.containers.return_value = [] opts = service._get_container_create_options({'some': 'overrides'}, 1) self.assertEqual(opts['host_config']['MemorySwap'], 2000000000) self.assertEqual(opts['host_config']['Memory'], 1000000000) @@ -114,7 +112,6 @@ def test_memory_swap_limit(self): def test_log_opt(self): log_opt = {'syslog-address': 'tcp://192.168.0.42:123'} service = Service(name='foo', image='foo', hostname='name', client=self.mock_client, log_driver='syslog', log_opt=log_opt) - self.mock_client.containers.return_value = [] opts = service._get_container_create_options({'some': 'overrides'}, 1) self.assertIsInstance(opts['host_config']['LogConfig'], LogConfig) @@ -127,7 +124,6 @@ def test_split_domainname_fqdn(self): hostname='name.domain.tld', image='foo', client=self.mock_client) - self.mock_client.containers.return_value = [] opts = service._get_container_create_options({'image': 'foo'}, 1) self.assertEqual(opts['hostname'], 'name', 'hostname') self.assertEqual(opts['domainname'], 'domain.tld', 'domainname') @@ -139,7 +135,6 @@ def test_split_domainname_both(self): image='foo', domainname='domain.tld', client=self.mock_client) - self.mock_client.containers.return_value = [] opts = service._get_container_create_options({'image': 'foo'}, 1) self.assertEqual(opts['hostname'], 'name', 'hostname') self.assertEqual(opts['domainname'], 'domain.tld', 'domainname') @@ -151,7 +146,6 @@ def test_split_domainname_weird(self): domainname='domain.tld', image='foo', client=self.mock_client) - self.mock_client.containers.return_value = [] opts = service._get_container_create_options({'image': 'foo'}, 1) self.assertEqual(opts['hostname'], 'name.sub', 'hostname') self.assertEqual(opts['domainname'], 'domain.tld', 'domainname') From d2718bed9938ad3d72500fb722c02970de4d1ac8 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 26 Aug 2015 13:33:03 -0400 Subject: [PATCH 1125/4072] Allow setting a one-off container name Signed-off-by: Daniel Nephin --- compose/cli/main.py | 4 ++++ compose/service.py | 2 +- tests/integration/cli_test.py | 10 ++++++++++ tests/unit/cli_test.py | 4 ++++ tests/unit/service_test.py | 13 +++++++++++++ 5 files changed, 32 insertions(+), 1 deletion(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 890a3c37177..58e54285169 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -308,6 +308,7 @@ def run(self, project, options): --allow-insecure-ssl Deprecated - no effect. -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 @@ -374,6 +375,9 @@ def run(self, project, options): 'can not be used togather' ) + if options['--name']: + container_options['name'] = options['--name'] + try: container = service.create_container( quiet=True, diff --git a/compose/service.py b/compose/service.py index a15ee1b9afa..a0423ff4470 100644 --- a/compose/service.py +++ b/compose/service.py @@ -586,7 +586,7 @@ def _get_container_create_options( if self.custom_container_name() and not one_off: container_options['name'] = self.custom_container_name() - else: + elif not container_options.get('name'): container_options['name'] = self.get_container_name(number, one_off) if add_config_hash: diff --git a/tests/integration/cli_test.py b/tests/integration/cli_test.py index 78ff7604c7d..b94d7d1eec0 100644 --- a/tests/integration/cli_test.py +++ b/tests/integration/cli_test.py @@ -391,6 +391,16 @@ def test_run_service_with_explicitly_maped_ip_ports(self, _): self.assertEqual(port_short, "127.0.0.1:30000") self.assertEqual(port_full, "127.0.0.1:30001") + @mock.patch('dockerpty.start') + def test_run_with_custom_name(self, _): + self.command.base_dir = 'tests/fixtures/environment-composefile' + name = 'the-container-name' + self.command.dispatch(['run', '--name', name, 'service'], None) + + service = self.project.get_service('service') + container, = service.containers(stopped=True, one_off=True) + self.assertEqual(container.name, name) + def test_rm(self): service = self.project.get_service('simple') service.create_container() diff --git a/tests/unit/cli_test.py b/tests/unit/cli_test.py index 7d22ad02ff7..1fd9f529ed6 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -112,6 +112,7 @@ def test_run_with_environment_merged_with_options_list(self, mock_dockerpty): '--service-ports': None, '--publish': [], '--rm': None, + '--name': None, }) _, _, call_kwargs = mock_client.create_container.mock_calls[0] @@ -141,6 +142,7 @@ def test_run_service_with_restart_always(self): '--service-ports': None, '--publish': [], '--rm': None, + '--name': None, }) _, _, call_kwargs = mock_client.create_container.mock_calls[0] self.assertEquals(call_kwargs['host_config']['RestartPolicy']['Name'], 'always') @@ -166,6 +168,7 @@ def test_run_service_with_restart_always(self): '--service-ports': None, '--publish': [], '--rm': True, + '--name': None, }) _, _, call_kwargs = mock_client.create_container.mock_calls[0] self.assertFalse('RestartPolicy' in call_kwargs['host_config']) @@ -195,4 +198,5 @@ def test_command_manula_and_service_ports_together(self): '--service-ports': True, '--publish': ['80:80'], '--rm': None, + '--name': None, }) diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 5d37bfedf19..a24e524dd53 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -150,6 +150,19 @@ def test_split_domainname_weird(self): self.assertEqual(opts['hostname'], 'name.sub', 'hostname') self.assertEqual(opts['domainname'], 'domain.tld', 'domainname') + def test_get_container_create_options_with_name_option(self): + service = Service( + 'foo', + image='foo', + client=self.mock_client, + container_name='foo1') + name = 'the_new_name' + opts = service._get_container_create_options( + {'name': name}, + 1, + one_off=True) + self.assertEqual(opts['name'], name) + def test_get_container_not_found(self): self.mock_client.containers.return_value = [] service = Service('foo', client=self.mock_client, image='foo') From 3a0153859ae6624970696c967e18a99710479cd4 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 26 Aug 2015 16:21:31 -0400 Subject: [PATCH 1126/4072] Resolves #1856, fix regression in #1645. Includes some refactoring to make testing easier. Signed-off-by: Daniel Nephin --- compose/cli/main.py | 36 ++++++++++++++++++---------- compose/container.py | 6 ++++- tests/unit/cli/main_test.py | 47 +++++++++++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 14 deletions(-) create mode 100644 tests/unit/cli/main_test.py diff --git a/compose/cli/main.py b/compose/cli/main.py index 58e54285169..2ace13c22b8 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -538,19 +538,8 @@ def up(self, project, options): ) if not detached: - print("Attaching to", list_containers(to_attach)) - log_printer = LogPrinter(to_attach, attach_params={"logs": True}, monochrome=monochrome) - - try: - log_printer.run() - finally: - def handler(signal, frame): - project.kill(service_names=service_names) - sys.exit(0) - signal.signal(signal.SIGINT, handler) - - print("Gracefully stopping... (press Ctrl+C again to force)") - project.stop(service_names=service_names, timeout=timeout) + log_printer = build_log_printer(to_attach, service_names, monochrome) + attach_to_logs(project, log_printer, service_names, timeout) def migrate_to_labels(self, project, _options): """ @@ -593,5 +582,26 @@ def version(self, project, options): print(get_version_info('full')) +def build_log_printer(containers, service_names, monochrome): + return LogPrinter( + [c for c in containers if c.service in service_names], + attach_params={"logs": True}, + monochrome=monochrome) + + +def attach_to_logs(project, log_printer, service_names, timeout): + print("Attaching to", list_containers(log_printer.containers)) + try: + log_printer.run() + finally: + def handler(signal, frame): + project.kill(service_names=service_names) + sys.exit(0) + signal.signal(signal.SIGINT, handler) + + print("Gracefully stopping... (press Ctrl+C again to force)") + project.stop(service_names=service_names, timeout=timeout) + + def list_containers(containers): return ", ".join(c.name for c in containers) diff --git a/compose/container.py b/compose/container.py index f727c8673af..f2d8a403e12 100644 --- a/compose/container.py +++ b/compose/container.py @@ -64,9 +64,13 @@ def short_id(self): def name(self): return self.dictionary['Name'][1:] + @property + def service(self): + return self.labels.get(LABEL_SERVICE) + @property def name_without_project(self): - return '{0}_{1}'.format(self.labels.get(LABEL_SERVICE), self.number) + return '{0}_{1}'.format(self.service, self.number) @property def number(self): diff --git a/tests/unit/cli/main_test.py b/tests/unit/cli/main_test.py new file mode 100644 index 00000000000..817e8f49b6a --- /dev/null +++ b/tests/unit/cli/main_test.py @@ -0,0 +1,47 @@ +from __future__ import absolute_import + +from compose import container +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.project import Project +from tests import mock +from tests import unittest + + +def mock_container(service, number): + return mock.create_autospec( + container.Container, + service=service, + number=number, + name_without_project='{0}_{1}'.format(service, number)) + + +class CLIMainTestCase(unittest.TestCase): + + def test_build_log_printer(self): + containers = [ + mock_container('web', 1), + mock_container('web', 2), + mock_container('db', 1), + mock_container('other', 1), + mock_container('another', 1), + ] + service_names = ['web', 'db'] + log_printer = build_log_printer(containers, service_names, True) + self.assertEqual(log_printer.containers, containers[:3]) + + 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.signal', autospec=True) as mock_signal: + attach_to_logs(project, log_printer, service_names, timeout) + + mock_signal.signal.assert_called_once_with(mock_signal.SIGINT, mock.ANY) + log_printer.run.assert_called_once_with() + project.stop.assert_called_once_with( + service_names=service_names, + timeout=timeout) From acca22206ab7a3eeb1cad99d2a93f4e9190e67ff Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 27 Aug 2015 10:36:12 +0100 Subject: [PATCH 1127/4072] Fix typo Signed-off-by: Aanand Prasad --- RELEASE_PROCESS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RELEASE_PROCESS.md b/RELEASE_PROCESS.md index 0d5f42eba0b..966e06ee487 100644 --- a/RELEASE_PROCESS.md +++ b/RELEASE_PROCESS.md @@ -93,7 +93,7 @@ Building a Compose release Firstly, note that Compose 1.5.0 requires Docker 1.8.0 or later. - Secondly, if you're a Mac user, the **[Docker Toolbox](https://www.docker.com/toolbox)** will install Compose ${VERSION} for you, alongside the latest versions of the Docker Engine, Machine and Kitematic. + 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. Otherwise, you can use the usual commands to install/upgrade. Either download the binary: From b39e549c87696d7b1d3ee10ebb1880bd791c647f Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 27 Aug 2015 13:35:45 +0100 Subject: [PATCH 1128/4072] Normalise ignore files - Consistent order and contents (where possible) - Prepend .gitignore paths with slashes where appropriate Signed-off-by: Aanand Prasad --- .dockerignore | 7 ++++++- .gitignore | 7 ++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/.dockerignore b/.dockerignore index b85b7e5d865..ba7e9155d59 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,5 +1,10 @@ +*.egg-info +.coverage .git +.tox build +coverage-html dist +docker-compose.spec +docs/_site venv -coverage-html diff --git a/.gitignore b/.gitignore index 52a78bd974e..f6750c1ff58 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,10 @@ *.egg-info *.pyc -.tox +/.coverage +/.tox /build +/coverage-html /dist +/docker-compose.spec /docs/_site /venv -docker-compose.spec -coverage-html From 477d4f491dca36f9c717f8bb6366d1f756a387bc Mon Sep 17 00:00:00 2001 From: Karol Duleba Date: Wed, 26 Aug 2015 22:03:45 +0100 Subject: [PATCH 1129/4072] Do not allow to specify both image and dockerfile in configuration. Closes #1908 Signed-off-by: Karol Duleba --- compose/config/schema.json | 5 ++++- compose/config/validation.py | 5 +++++ docs/yml.md | 6 ++++++ tests/unit/config_test.py | 11 +++++++++++ 4 files changed, 26 insertions(+), 1 deletion(-) diff --git a/compose/config/schema.json b/compose/config/schema.json index 8e9b79fb645..94fe4fc5224 100644 --- a/compose/config/schema.json +++ b/compose/config/schema.json @@ -113,7 +113,10 @@ }, { "required": ["image"], - "not": {"required": ["build"]} + "not": {"anyOf": [ + {"required": ["build"]}, + {"required": ["dockerfile"]} + ]} }, { "required": ["extends"], diff --git a/compose/config/validation.py b/compose/config/validation.py index 0df73e3c25f..d83504274c7 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -134,6 +134,11 @@ def _parse_valid_types_from_schema(schema): required.append( "Service '{}' has neither an image nor a build path " "specified. Exactly one must be provided.".format(service_name)) + elif 'image' in error.instance and 'dockerfile' in error.instance: + required.append( + "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)) else: required.append(_clean_error_message(error.message)) elif error.validator == 'oneOf': diff --git a/docs/yml.md b/docs/yml.md index 6fb31a7db96..3ece0264945 100644 --- a/docs/yml.md +++ b/docs/yml.md @@ -33,6 +33,8 @@ pull if it doesn't exist locally. image: a4bc65fd image: busybox@sha256:38a203e1986cf79639cfb9b2e1d6e773de84002feea2d4eb006b52004ee8502d +Using `image` together with either `build` or `dockerfile` is not allowed. Attempting to do so results in an error. + ### build Path to a directory containing a Dockerfile. When the value supplied is a @@ -43,6 +45,8 @@ Compose will build and tag it with a generated name, and use that image thereaft build: /path/to/build/dir +Using `build` together with `image` is not allowed. Attempting to do so results in an error. + ### dockerfile Alternate Dockerfile. @@ -51,6 +55,8 @@ Compose will use an alternate file to build with. dockerfile: Dockerfile-alternate +Using `dockerfile` together with `image` is not allowed. Attempting to do so results in an error. + ### command Override the default command. diff --git a/tests/unit/config_test.py b/tests/unit/config_test.py index 51dac052f37..e488ceb5274 100644 --- a/tests/unit/config_test.py +++ b/tests/unit/config_test.py @@ -191,6 +191,17 @@ def test_invalid_list_of_strings_format(self): ) ) + def test_config_image_and_dockerfile_raise_validation_error(self): + expected_error_msg = "Service 'web' has both an image and alternate Dockerfile." + with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): + config.load( + config.ConfigDetails( + {'web': {'image': 'busybox', 'dockerfile': 'Dockerfile.alt'}}, + 'working_dir', + 'filename.yml' + ) + ) + class InterpolationTest(unittest.TestCase): @mock.patch.dict(os.environ) From d264c2e33a57469e17f97ff06819b3203f81a4b1 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 27 Aug 2015 17:42:26 -0400 Subject: [PATCH 1130/4072] Resolves #1804 Fix mutation of service.options when a label or environment variable is specified in the config. Signed-off-by: Daniel Nephin --- compose/config/config.py | 2 +- compose/service.py | 20 +++++++++----------- tests/unit/service_test.py | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 12 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index ea122bc422d..cfa8086f09c 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -358,7 +358,7 @@ def parse_environment(environment): return dict(split_env(e) for e in environment) if isinstance(environment, dict): - return environment + return dict(environment) raise ConfigurationError( "environment \"%s\" must be a list or mapping," % diff --git a/compose/service.py b/compose/service.py index a0423ff4470..b48f2e14bd6 100644 --- a/compose/service.py +++ b/compose/service.py @@ -576,7 +576,6 @@ def _get_container_create_options( number, one_off=False, previous_container=None): - add_config_hash = (not one_off and not override_options) container_options = dict( @@ -589,13 +588,6 @@ def _get_container_create_options( elif not container_options.get('name'): container_options['name'] = self.get_container_name(number, one_off) - if add_config_hash: - config_hash = self.config_hash - if 'labels' not in container_options: - container_options['labels'] = {} - container_options['labels'][LABEL_CONFIG_HASH] = config_hash - log.debug("Added config hash: %s" % config_hash) - if 'detach' not in container_options: container_options['detach'] = True @@ -643,7 +635,8 @@ def _get_container_create_options( container_options['labels'] = build_container_labels( container_options.get('labels', {}), self.labels(one_off=one_off), - number) + number, + self.config_hash if add_config_hash else None) # Delete options which are only used when starting for key in DOCKER_START_KEYS: @@ -899,11 +892,16 @@ def parse_volume_spec(volume_config): # Labels -def build_container_labels(label_options, service_labels, number, one_off=False): - labels = label_options or {} +def build_container_labels(label_options, service_labels, number, config_hash): + labels = dict(label_options or {}) labels.update(label.split('=', 1) for label in service_labels) labels[LABEL_CONTAINER_NUMBER] = str(number) labels[LABEL_VERSION] = __version__ + + if config_hash: + log.debug("Added config hash: %s" % config_hash) + labels[LABEL_CONFIG_HASH] = config_hash + return labels diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index a24e524dd53..aa6d4d74f48 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -6,6 +6,7 @@ from .. import mock from .. import unittest +from compose.const import LABEL_CONFIG_HASH from compose.const import LABEL_ONE_OFF from compose.const import LABEL_PROJECT from compose.const import LABEL_SERVICE @@ -163,6 +164,40 @@ def test_get_container_create_options_with_name_option(self): one_off=True) self.assertEqual(opts['name'], name) + def test_get_container_create_options_does_not_mutate_options(self): + labels = {'thing': 'real'} + environment = {'also': 'real'} + service = Service( + 'foo', + image='foo', + labels=dict(labels), + client=self.mock_client, + environment=dict(environment), + ) + self.mock_client.inspect_image.return_value = {'Id': 'abcd'} + prev_container = mock.Mock( + id='ababab', + image_config={'ContainerConfig': {}}) + + opts = service._get_container_create_options( + {}, + 1, + previous_container=prev_container) + + self.assertEqual(service.options['labels'], labels) + self.assertEqual(service.options['environment'], environment) + + self.assertEqual( + opts['labels'][LABEL_CONFIG_HASH], + 'b30306d0a73b67f67a45b99b88d36c359e470e6fa0c04dda1cf62d2087205b81') + self.assertEqual( + opts['environment'], + { + 'affinity:container': '=ababab', + 'also': 'real', + } + ) + def test_get_container_not_found(self): self.mock_client.containers.return_value = [] service = Service('foo', client=self.mock_client, image='foo') From 9543cb341bc98b7ef7a61cbb2d2543e446dea163 Mon Sep 17 00:00:00 2001 From: Herman Junge Date: Thu, 27 Aug 2015 19:41:17 -0300 Subject: [PATCH 1131/4072] Fix doc install.md termial -> terminal Signed-off-by: Herman Junge --- docs/install.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/install.md b/docs/install.md index 7a2763edc07..85060ce0401 100644 --- a/docs/install.md +++ b/docs/install.md @@ -35,7 +35,7 @@ To install Compose, do the following: 3. Go to the repository release page. -4. Enter the `curl` command in your termial. +4. Enter the `curl` command in your terminal. The command has the following format: From a4bab13aee9b5804e74e6192bc412fccbdf6d8d5 Mon Sep 17 00:00:00 2001 From: Frank Sachsenheim Date: Fri, 28 Aug 2015 19:05:19 +0200 Subject: [PATCH 1132/4072] Adds pause- and unpause-command to docopt's TLC solves # 1921 Signed-off-by: Frank Sachsenheim --- compose/cli/main.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/compose/cli/main.py b/compose/cli/main.py index 2ace13c22b8..06dacf1e9a8 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -101,6 +101,7 @@ class TopLevelCommand(Command): 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 @@ -110,6 +111,7 @@ class TopLevelCommand(Command): scale Set number of containers for a service start Start services stop Stop services + unpause Unpause services up Create and start containers migrate-to-labels Recreate containers to add labels version Show the Docker-Compose version information From 8f310767a63c4cdb151593cb5dd2e8808516ba4f Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 28 Aug 2015 14:01:02 -0400 Subject: [PATCH 1133/4072] Add ISSUE-TRIAGE.md doc Signed-off-by: Daniel Nephin --- project/ISSUE-TRIAGE.md | 32 +++++++++++++++++++ .../RELEASE-PROCESS.md | 0 2 files changed, 32 insertions(+) create mode 100644 project/ISSUE-TRIAGE.md rename RELEASE_PROCESS.md => project/RELEASE-PROCESS.md (100%) diff --git a/project/ISSUE-TRIAGE.md b/project/ISSUE-TRIAGE.md new file mode 100644 index 00000000000..bcedbb43598 --- /dev/null +++ b/project/ISSUE-TRIAGE.md @@ -0,0 +1,32 @@ +Triaging of issues +------------------ + +The docker-compose issue triage process follows +https://github.com/docker/docker/blob/master/project/ISSUE-TRIAGE.md +with the following additions or exceptions. + + +### Classify the Issue + +The following labels are provided in additional to the standard labels: + +| Kind | Description | +|--------------|-------------------------------------------------------------------| +| kind/cleanup | A refactor or improvement that is related to quality not function | +| kind/parity | A request for feature parity with docker cli | + + +### Functional areas + +Most issues should fit into one of the following functional areas: + +| Area | +|-------------| +| area/build | +| area/cli | +| area/config | +| area/logs | +| area/run | +| area/scale | +| area/tests | +| area/up | diff --git a/RELEASE_PROCESS.md b/project/RELEASE-PROCESS.md similarity index 100% rename from RELEASE_PROCESS.md rename to project/RELEASE-PROCESS.md From 235fe21fd0ad3097e6e35692bc2f25b1c2062bc9 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 26 Aug 2015 18:55:22 -0400 Subject: [PATCH 1134/4072] Prevent flaky test by changing container names. Signed-off-by: Daniel Nephin --- tests/integration/service_test.py | 2 +- tests/integration/state_test.py | 1 - tests/integration/testcases.py | 3 +++ 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index fc634c8ca3e..0cf8cdb0ef4 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -695,7 +695,7 @@ def test_scale_with_custom_container_name_outputs_warning(self, mock_log): Test that calling scale on a service that has a custom container name results in warning output. """ - service = self.create_service('web', container_name='custom-container') + service = self.create_service('app', container_name='custom-container') self.assertEqual(service.custom_container_name(), 'custom-container') service.scale(3) diff --git a/tests/integration/state_test.py b/tests/integration/state_test.py index 3d4a5b5aa6e..b3dd42d996b 100644 --- a/tests/integration/state_test.py +++ b/tests/integration/state_test.py @@ -199,7 +199,6 @@ def test_trigger_start(self): self.assertEqual([c.is_running for c in containers], [False, True]) - web = self.create_service('web', **options) self.assertEqual( ('start', containers[0:1]), web.convergence_plan(), diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index e239010ea2d..08ef9f272f9 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -33,6 +33,9 @@ def create_service(self, name, **kwargs): options = ServiceLoader(working_dir='.').make_service_dict(name, kwargs) + labels = options.setdefault('labels', {}) + labels['com.docker.compose.test-name'] = self.id() + return Service( project='composetest', client=self.client, From 5a5f28228a44975899a1b2787ac4b9ad72b74bea Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 25 Aug 2015 10:53:31 -0400 Subject: [PATCH 1135/4072] Document installing of pre-commit hooks. Signed-off-by: Daniel Nephin --- CONTRIBUTING.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c9188ac98ac..9ff8304c76a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -30,6 +30,17 @@ that should get you started. `docker-compose` from anywhere on your machine, it will run your development version of Compose. +## Install pre-commit hooks + +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 +running `tox -e pre-commit` or by following the +[pre-commit install guide](http://pre-commit.com/#install). + +To run the style checks at any time run `tox -e pre-commit`. + ## Submitting a pull request See Docker's [basic contribution workflow](https://docs.docker.com/project/make-a-contribution/#the-basic-contribution-workflow) for a guide on how to submit a pull request for code or documentation. From 74782a56b53638431c93f0081e8d933f7fc0a104 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 25 Aug 2015 10:57:21 -0400 Subject: [PATCH 1136/4072] Fix script/test by just calling script/test-versions directly instead of launching another container. Signed-off-by: Daniel Nephin --- script/ci | 1 + script/test | 15 +++++---------- script/test-versions | 5 ++++- 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/script/ci b/script/ci index b4975487812..e8356bb73fd 100755 --- a/script/ci +++ b/script/ci @@ -10,6 +10,7 @@ set -e export DOCKER_VERSIONS=all export DOCKER_DAEMON_ARGS="--storage-driver=overlay" +GIT_VOLUME="--volumes-from=$(hostname)" . script/test-versions >&2 echo "Building Linux binary" diff --git a/script/test b/script/test index adf3fb1bab2..bdb3579b01f 100755 --- a/script/test +++ b/script/test @@ -6,15 +6,10 @@ set -ex TAG="docker-compose:$(git rev-parse --short HEAD)" rm -rf coverage-html +# Create the host directory so it's owned by $USER +mkdir -p coverage-html docker build -t "$TAG" . -docker run \ - --rm \ - --volume="/var/run/docker.sock:/var/run/docker.sock" \ - -e DOCKER_VERSIONS \ - -e "TAG=$TAG" \ - -e "affinity:image==$TAG" \ - -e "COVERAGE_DIR=$(pwd)/coverage-html" \ - --entrypoint="script/test-versions" \ - "$TAG" \ - "$@" + +GIT_VOLUME="--volume=$(pwd)/.git:/code/.git" +. script/test-versions diff --git a/script/test-versions b/script/test-versions index f39c17e819a..88d2554c2b7 100755 --- a/script/test-versions +++ b/script/test-versions @@ -5,7 +5,10 @@ set -e >&2 echo "Running lint checks" -tox -e pre-commit +docker run --rm \ + ${GIT_VOLUME} \ + --entrypoint="tox" \ + "$TAG" -e pre-commit if [ "$DOCKER_VERSIONS" == "" ]; then DOCKER_VERSIONS="default" From 6ac617bae1871dc6dcd53c1108376f2a920541a0 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 1 Sep 2015 16:23:04 -0400 Subject: [PATCH 1137/4072] Split requirements-build.txt from requirements-dev.txt to support a leaner tox.ini Signed-off-by: Daniel Nephin --- Dockerfile | 2 +- requirements-build.txt | 1 + requirements-dev.txt | 1 - script/build-linux | 1 - script/build-linux-inner | 9 ++++++--- script/build-osx | 2 +- script/ci | 2 +- 7 files changed, 10 insertions(+), 8 deletions(-) create mode 100644 requirements-build.txt diff --git a/Dockerfile b/Dockerfile index a9892031b81..1d13c2b603c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -91,7 +91,7 @@ RUN pip install -r requirements-dev.txt RUN pip install tox==2.1.1 ADD . /code/ -RUN python setup.py install +RUN pip install --no-deps -e /code RUN chown -R user /code/ diff --git a/requirements-build.txt b/requirements-build.txt new file mode 100644 index 00000000000..5da6fa49664 --- /dev/null +++ b/requirements-build.txt @@ -0,0 +1 @@ +git+https://github.com/pyinstaller/pyinstaller.git@12e40471c77f588ea5be352f7219c873ddaae056#egg=pyinstaller diff --git a/requirements-dev.txt b/requirements-dev.txt index adb4387df25..33a4915154a 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,6 +1,5 @@ coverage==3.7.1 flake8==2.3.0 -git+https://github.com/pyinstaller/pyinstaller.git@12e40471c77f588ea5be352f7219c873ddaae056#egg=pyinstaller mock >= 1.0.1 nose==1.3.4 pep8==1.6.1 diff --git a/script/build-linux b/script/build-linux index 5e4a9470e9b..4fdf1d926f6 100755 --- a/script/build-linux +++ b/script/build-linux @@ -6,7 +6,6 @@ TAG="docker-compose" docker build -t "$TAG" . docker run \ --rm \ - --user=user \ --volume="$(pwd):/code" \ --entrypoint="script/build-linux-inner" \ "$TAG" diff --git a/script/build-linux-inner b/script/build-linux-inner index adc030eaa87..cfea838067a 100755 --- a/script/build-linux-inner +++ b/script/build-linux-inner @@ -2,9 +2,12 @@ set -ex +TARGET=dist/docker-compose-Linux-x86_64 + mkdir -p `pwd`/dist chmod 777 `pwd`/dist -pyinstaller -F bin/docker-compose -mv dist/docker-compose dist/docker-compose-Linux-x86_64 -dist/docker-compose-Linux-x86_64 version +pip install -r requirements-build.txt +su -c "pyinstaller -F bin/docker-compose" user +mv dist/docker-compose $TARGET +$TARGET version diff --git a/script/build-osx b/script/build-osx index 2a9cf512ef9..d99c1fb981c 100755 --- a/script/build-osx +++ b/script/build-osx @@ -6,7 +6,7 @@ PATH="/usr/local/bin:$PATH" rm -rf venv virtualenv -p /usr/local/bin/python venv venv/bin/pip install -r requirements.txt -venv/bin/pip install -r requirements-dev.txt +venv/bin/pip install -r requirements-build.txt venv/bin/pip install . venv/bin/pyinstaller -F bin/docker-compose mv dist/docker-compose dist/docker-compose-Darwin-x86_64 diff --git a/script/ci b/script/ci index b4975487812..e392fae7599 100755 --- a/script/ci +++ b/script/ci @@ -13,4 +13,4 @@ export DOCKER_DAEMON_ARGS="--storage-driver=overlay" . script/test-versions >&2 echo "Building Linux binary" -su -c script/build-linux-inner user +. script/build-linux-inner From c1ed1efde81dc2c93b5231cd67416fb89091377e Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 1 Sep 2015 16:24:07 -0400 Subject: [PATCH 1138/4072] Use py.test as the test runner Signed-off-by: Daniel Nephin --- requirements-dev.txt | 7 +++---- setup.py | 5 +---- tox.ini | 28 ++++++++++++++++------------ 3 files changed, 20 insertions(+), 20 deletions(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 33a4915154a..73b80783501 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,5 +1,4 @@ coverage==3.7.1 -flake8==2.3.0 -mock >= 1.0.1 -nose==1.3.4 -pep8==1.6.1 +mock>=1.0.1 +pytest==2.7.2 +pytest-cov==2.1.0 diff --git a/setup.py b/setup.py index 33335047bca..e93dafc6283 100644 --- a/setup.py +++ b/setup.py @@ -41,13 +41,10 @@ def find_version(*file_paths): tests_require = [ - 'nose', - 'flake8', + 'pytest', ] -if sys.version_info < (2, 7): - tests_require.append('unittest2') if sys.version_info[:1] < (3,): tests_require.append('mock >= 1.0.1') diff --git a/tox.ini b/tox.ini index 4b27a4e9bec..71ab4fc9c92 100644 --- a/tox.ini +++ b/tox.ini @@ -8,10 +8,14 @@ passenv = setenv = HOME=/tmp deps = - -rrequirements.txt + -rrequirements-dev.txt commands = - nosetests -v --with-coverage --cover-branches --cover-package=compose --cover-erase --cover-html-dir=coverage-html --cover-html {posargs} - flake8 compose tests setup.py + py.test -v \ + --cov=compose \ + --cov-report html \ + --cov-report term \ + --cov-config=tox.ini \ + {posargs} [testenv:pre-commit] skip_install = True @@ -21,16 +25,16 @@ commands = pre-commit install pre-commit run --all-files -[testenv:py27] -deps = - {[testenv]deps} - -rrequirements-dev.txt +# Coverage configuration +[run] +branch = True -[testenv:py34] -deps = - {[testenv]deps} - flake8 - nose +[report] +show_missing = true + +[html] +directory = coverage-html +# end coverage configuration [flake8] # Allow really long lines for now From 6969829a705fe784213b139f7087708fd0b026b5 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Tue, 1 Sep 2015 17:23:39 -0700 Subject: [PATCH 1139/4072] Link to ZenHub instead of Waffle Signed-off-by: Ben Firshman --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c9188ac98ac..cb26a5501a2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -58,6 +58,6 @@ you can specify a test directory, file, module, class or method: ## Finding things to work on -We use a [Waffle.io board](https://waffle.io/docker/compose) to keep track of specific things we are working on and planning to work on. If you're looking for things to work on, stuff in the backlog is a great place to start. +We use a [ZenHub board](https://www.zenhub.io/) to keep track of specific things we are working on and planning to work on. If you're looking for things to work on, stuff in the backlog is a great place to start. For more information about our project planning, take a look at our [GitHub wiki](https://github.com/docker/compose/wiki). From c907f35e741ff2f40c775c07d311f53a0d4c2373 Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Thu, 20 Aug 2015 15:05:33 +0100 Subject: [PATCH 1140/4072] Raise if working_dir is None Check for this in the init so we can remove the duplication of raising in further functions. A ServiceLoader isn't valid without one. Signed-off-by: Mazz Mosley --- compose/config/config.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index cfa8086f09c..e08b503f3e5 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -152,7 +152,11 @@ def load(config_details): class ServiceLoader(object): def __init__(self, working_dir, filename=None, already_seen=None): + if working_dir is None: + raise Exception("No working_dir passed to ServiceLoader()") + self.working_dir = os.path.abspath(working_dir) + if filename: self.filename = os.path.abspath(filename) else: @@ -176,9 +180,6 @@ def resolve_extends(self, service_dict): extends_options = self.validate_extends_options(service_dict['name'], service_dict['extends']) - if self.working_dir is None: - raise Exception("No working_dir passed to ServiceLoader()") - if 'file' in extends_options: extends_from_filename = extends_options['file'] other_config_path = expand_path(self.working_dir, extends_from_filename) @@ -320,9 +321,6 @@ def get_env_files(options, working_dir=None): if 'env_file' not in options: return {} - if working_dir is None: - raise Exception("No working_dir passed to get_env_files()") - env_files = options.get('env_file', []) if not isinstance(env_files, list): env_files = [env_files] From 1344533b240ddc344029536df8361125617e1a3d Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Fri, 21 Aug 2015 17:04:10 +0100 Subject: [PATCH 1141/4072] filename is not optional While it can be set to ultimately a value of None, when a config file is read in from stdin, it is not optional. We kinda make use of it's ability to be set to None in our tests but functionally and design wise, it is required. If filename is not set, extends does not work. Signed-off-by: Mazz Mosley --- compose/config/config.py | 2 +- tests/integration/testcases.py | 2 +- tests/unit/config_test.py | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index e08b503f3e5..b7697f0020c 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -151,7 +151,7 @@ def load(config_details): class ServiceLoader(object): - def __init__(self, working_dir, filename=None, already_seen=None): + def __init__(self, working_dir, filename, already_seen=None): if working_dir is None: raise Exception("No working_dir passed to ServiceLoader()") diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index 08ef9f272f9..d9d666d2707 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -31,7 +31,7 @@ def create_service(self, name, **kwargs): if 'command' not in kwargs: kwargs['command'] = ["top"] - options = ServiceLoader(working_dir='.').make_service_dict(name, kwargs) + options = ServiceLoader(working_dir='.', filename=None).make_service_dict(name, kwargs) labels = options.setdefault('labels', {}) labels['com.docker.compose.test-name'] = self.id() diff --git a/tests/unit/config_test.py b/tests/unit/config_test.py index e488ceb5274..aa10982b46d 100644 --- a/tests/unit/config_test.py +++ b/tests/unit/config_test.py @@ -11,11 +11,11 @@ from compose.config.errors import ConfigurationError -def make_service_dict(name, service_dict, working_dir): +def make_service_dict(name, service_dict, working_dir, filename=None): """ Test helper function to construct a ServiceLoader """ - return config.ServiceLoader(working_dir=working_dir).make_service_dict(name, service_dict) + return config.ServiceLoader(working_dir=working_dir, filename=filename).make_service_dict(name, service_dict) def service_sort(services): From 8a6061bfb9a3e4a98c11ad385eee45710af81e3f Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Fri, 21 Aug 2015 17:07:37 +0100 Subject: [PATCH 1142/4072] __init__ takes service name and dict Moving service name and dict out of the function make_service_dict and into __init__. We always call make_service_dict with those so let's put them in the initialiser. Slightly cleaner design intent. The whole purpose of the ServiceLoader is to take a service name&service dictionary then validate, process and return service dictionaries ready to be created. This is also another step towards cleaning the code up so we can interpolate and validate an extended dictionary. Signed-off-by: Mazz Mosley --- compose/config/config.py | 42 +++++++++++++++++++--------------- tests/integration/testcases.py | 2 +- tests/unit/config_test.py | 6 ++++- 3 files changed, 30 insertions(+), 20 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index b7697f0020c..9d90bd6142e 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -142,8 +142,12 @@ def load(config_details): service_dicts = [] for service_name, service_dict in list(processed_config.items()): - loader = ServiceLoader(working_dir=working_dir, filename=filename) - service_dict = loader.make_service_dict(service_name, service_dict) + loader = ServiceLoader( + working_dir=working_dir, + filename=filename, + service_name=service_name, + service_dict=service_dict) + service_dict = loader.make_service_dict() validate_paths(service_dict) service_dicts.append(service_dict) @@ -151,7 +155,7 @@ def load(config_details): class ServiceLoader(object): - def __init__(self, working_dir, filename, already_seen=None): + def __init__(self, working_dir, filename, service_name, service_dict, already_seen=None): if working_dir is None: raise Exception("No working_dir passed to ServiceLoader()") @@ -162,17 +166,19 @@ def __init__(self, working_dir, filename, already_seen=None): else: self.filename = filename self.already_seen = already_seen or [] + self.service_dict = service_dict.copy() + self.service_dict['name'] = service_name def detect_cycle(self, name): if self.signature(name) in self.already_seen: raise CircularReference(self.already_seen + [self.signature(name)]) - def make_service_dict(self, name, service_dict): - service_dict = service_dict.copy() - service_dict['name'] = name - service_dict = resolve_environment(service_dict, working_dir=self.working_dir) - service_dict = self.resolve_extends(service_dict) - return process_container_options(service_dict, working_dir=self.working_dir) + def make_service_dict(self): + # service_dict = service_dict.copy() + # service_dict['name'] = name + self.service_dict = resolve_environment(self.service_dict, working_dir=self.working_dir) + self.service_dict = self.resolve_extends(self.service_dict) + return process_container_options(self.service_dict, working_dir=self.working_dir) def resolve_extends(self, service_dict): if 'extends' not in service_dict: @@ -188,11 +194,6 @@ def resolve_extends(self, service_dict): other_working_dir = os.path.dirname(other_config_path) other_already_seen = self.already_seen + [self.signature(service_dict['name'])] - other_loader = ServiceLoader( - working_dir=other_working_dir, - filename=other_config_path, - already_seen=other_already_seen, - ) base_service = extends_options['service'] other_config = load_yaml(other_config_path) @@ -204,11 +205,16 @@ def resolve_extends(self, service_dict): raise ConfigurationError(msg) other_service_dict = other_config[base_service] - other_loader.detect_cycle(extends_options['service']) - other_service_dict = other_loader.make_service_dict( - service_dict['name'], - other_service_dict, + other_loader = ServiceLoader( + working_dir=other_working_dir, + filename=other_config_path, + service_name=service_dict['name'], + service_dict=other_service_dict, + already_seen=other_already_seen, ) + + other_loader.detect_cycle(extends_options['service']) + other_service_dict = other_loader.make_service_dict() validate_extended_service_dict( other_service_dict, filename=other_config_path, diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index d9d666d2707..58240d5ea45 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -31,7 +31,7 @@ def create_service(self, name, **kwargs): if 'command' not in kwargs: kwargs['command'] = ["top"] - options = ServiceLoader(working_dir='.', filename=None).make_service_dict(name, kwargs) + options = ServiceLoader(working_dir='.', filename=None, service_name=name, service_dict=kwargs).make_service_dict() labels = options.setdefault('labels', {}) labels['com.docker.compose.test-name'] = self.id() diff --git a/tests/unit/config_test.py b/tests/unit/config_test.py index aa10982b46d..f3a4bd306a5 100644 --- a/tests/unit/config_test.py +++ b/tests/unit/config_test.py @@ -15,7 +15,11 @@ def make_service_dict(name, service_dict, working_dir, filename=None): """ Test helper function to construct a ServiceLoader """ - return config.ServiceLoader(working_dir=working_dir, filename=filename).make_service_dict(name, service_dict) + return config.ServiceLoader( + working_dir=working_dir, + filename=filename, + service_name=name, + service_dict=service_dict).make_service_dict() def service_sort(services): From 02c52ae673a66c7a8f6455611d8561d8f6954383 Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Mon, 24 Aug 2015 12:23:45 +0100 Subject: [PATCH 1143/4072] Move resolve_environment within __init__ resolve_environment is specific to ServiceLoader, the function does not need to be on the global scope, it is a part of the ServiceLoader object. The environment needs to be resolved before we can make any service dicts, it belongs in the constructor. This is cleaning up the design a little and being clearer about intent and scope of functions. Signed-off-by: Mazz Mosley --- compose/config/config.py | 45 ++++++++++++++++++++-------------------- 1 file changed, 22 insertions(+), 23 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 9d90bd6142e..ff9b3593335 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -169,17 +169,36 @@ def __init__(self, working_dir, filename, service_name, service_dict, already_se self.service_dict = service_dict.copy() self.service_dict['name'] = service_name + self.resolve_environment() + def detect_cycle(self, name): if self.signature(name) in self.already_seen: raise CircularReference(self.already_seen + [self.signature(name)]) def make_service_dict(self): - # service_dict = service_dict.copy() - # service_dict['name'] = name - self.service_dict = resolve_environment(self.service_dict, working_dir=self.working_dir) self.service_dict = self.resolve_extends(self.service_dict) return process_container_options(self.service_dict, working_dir=self.working_dir) + def resolve_environment(self): + """ + Unpack any environment variables from an env_file, if set. + Interpolate environment values if set. + """ + if 'environment' not in self.service_dict and 'env_file' not in self.service_dict: + return + + env = {} + + if 'env_file' in self.service_dict: + for f in get_env_files(self.service_dict, working_dir=self.working_dir): + env.update(env_vars_from_file(f)) + del self.service_dict['env_file'] + + env.update(parse_environment(self.service_dict.get('environment'))) + env = dict(resolve_env_var(k, v) for k, v in six.iteritems(env)) + + self.service_dict['environment'] = env + def resolve_extends(self, service_dict): if 'extends' not in service_dict: return service_dict @@ -334,26 +353,6 @@ def get_env_files(options, working_dir=None): return [expand_path(working_dir, path) for path in env_files] -def resolve_environment(service_dict, working_dir=None): - service_dict = service_dict.copy() - - if 'environment' not in service_dict and 'env_file' not in service_dict: - return service_dict - - env = {} - - if 'env_file' in service_dict: - for f in get_env_files(service_dict, working_dir=working_dir): - env.update(env_vars_from_file(f)) - del service_dict['env_file'] - - env.update(parse_environment(service_dict.get('environment'))) - env = dict(resolve_env_var(k, v) for k, v in six.iteritems(env)) - - service_dict['environment'] = env - return service_dict - - def parse_environment(environment): if not environment: return {} From 538a501eece5f645285f8235cf21507127750300 Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Mon, 24 Aug 2015 15:58:17 +0100 Subject: [PATCH 1144/4072] Refactor validating extends file path Separating out the steps we need to resolve extends, so that it will be clear to insert pre-processing of interpolation and validation. Signed-off-by: Mazz Mosley --- compose/config/config.py | 36 ++++++++++++++++++------------------ compose/config/validation.py | 13 +++++++++++++ 2 files changed, 31 insertions(+), 18 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index ff9b3593335..51bd9384669 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -11,6 +11,7 @@ from .errors import ConfigurationError from .interpolation import interpolate_environment_variables from .validation import validate_against_schema +from .validation import validate_extends_file_path from .validation import validate_service_names from .validation import validate_top_level_object from compose.cli.utils import find_candidates_in_parent_dirs @@ -171,12 +172,20 @@ def __init__(self, working_dir, filename, service_name, service_dict, already_se self.resolve_environment() + if 'extends' in self.service_dict: + validate_extends_file_path( + service_name, + self.service_dict['extends'], + self.filename + ) + + def detect_cycle(self, name): if self.signature(name) in self.already_seen: raise CircularReference(self.already_seen + [self.signature(name)]) def make_service_dict(self): - self.service_dict = self.resolve_extends(self.service_dict) + self.service_dict = self.resolve_extends() return process_container_options(self.service_dict, working_dir=self.working_dir) def resolve_environment(self): @@ -199,11 +208,12 @@ def resolve_environment(self): self.service_dict['environment'] = env - def resolve_extends(self, service_dict): - if 'extends' not in service_dict: - return service_dict + def resolve_extends(self): + if 'extends' not in self.service_dict: + return self.service_dict - extends_options = self.validate_extends_options(service_dict['name'], service_dict['extends']) + extends_options = self.service_dict['extends'] + service_name = self.service_dict['name'] if 'file' in extends_options: extends_from_filename = extends_options['file'] @@ -212,7 +222,7 @@ def resolve_extends(self, service_dict): other_config_path = self.filename other_working_dir = os.path.dirname(other_config_path) - other_already_seen = self.already_seen + [self.signature(service_dict['name'])] + other_already_seen = self.already_seen + [self.signature(service_name)] base_service = extends_options['service'] other_config = load_yaml(other_config_path) @@ -227,7 +237,7 @@ def resolve_extends(self, service_dict): other_loader = ServiceLoader( working_dir=other_working_dir, filename=other_config_path, - service_name=service_dict['name'], + service_name=service_name, service_dict=other_service_dict, already_seen=other_already_seen, ) @@ -240,21 +250,11 @@ def resolve_extends(self, service_dict): service=extends_options['service'], ) - return merge_service_dicts(other_service_dict, service_dict) + return merge_service_dicts(other_service_dict, self.service_dict) def signature(self, name): return (self.filename, name) - def validate_extends_options(self, service_name, extends_options): - error_prefix = "Invalid 'extends' configuration for %s:" % service_name - - if 'file' not in extends_options and self.filename is None: - raise ConfigurationError( - "%s you need to specify a 'file', e.g. 'file: something.yml'" % error_prefix - ) - - return extends_options - def validate_extended_service_dict(service_dict, filename, service): error_prefix = "Cannot extend service '%s' in %s:" % (service, filename) diff --git a/compose/config/validation.py b/compose/config/validation.py index d83504274c7..1ae8981ca61 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -66,6 +66,19 @@ def func_wrapper(config): return func_wrapper +def validate_extends_file_path(service_name, extends_options, filename): + """ + The service to be extended must either be defined in the config key 'file', + or within 'filename'. + """ + error_prefix = "Invalid 'extends' configuration for %s:" % service_name + + if 'file' not in extends_options and filename is None: + raise ConfigurationError( + "%s you need to specify a 'file', e.g. 'file: something.yml'" % error_prefix + ) + + 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: From 37bf8235b71a45b4b303b937129e07997784c61b Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Mon, 24 Aug 2015 16:00:47 +0100 Subject: [PATCH 1145/4072] Get extended config path Refactored out into it's own function. Signed-off-by: Mazz Mosley --- compose/config/config.py | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 51bd9384669..0f3099dc072 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -179,6 +179,9 @@ def __init__(self, working_dir, filename, service_name, service_dict, already_se self.filename ) + self.extended_config_path = self.get_extended_config_path( + self.service_dict['extends'] + ) def detect_cycle(self, name): if self.signature(name) in self.already_seen: @@ -215,11 +218,7 @@ def resolve_extends(self): extends_options = self.service_dict['extends'] service_name = self.service_dict['name'] - if 'file' in extends_options: - extends_from_filename = extends_options['file'] - other_config_path = expand_path(self.working_dir, extends_from_filename) - else: - other_config_path = self.filename + other_config_path = self.get_extended_config_path(extends_options) other_working_dir = os.path.dirname(other_config_path) other_already_seen = self.already_seen + [self.signature(service_name)] @@ -252,6 +251,18 @@ def resolve_extends(self): return merge_service_dicts(other_service_dict, self.service_dict) + def get_extended_config_path(self, extends_options): + """ + Service we are extending either has a value for 'file' set, which we + need to obtain a full path too or we are extending from a service + defined in our own file. + """ + if 'file' in extends_options: + extends_from_filename = extends_options['file'] + return expand_path(self.working_dir, extends_from_filename) + + return self.filename + def signature(self, name): return (self.filename, name) From 36757cde1cbe38d9673f00af0f515038b8280cfe Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Tue, 25 Aug 2015 17:54:06 +0100 Subject: [PATCH 1146/4072] Validate extended service against our schema Signed-off-by: Mazz Mosley --- compose/config/config.py | 7 +-- tests/fixtures/extends/invalid-links.yml | 9 ++++ tests/fixtures/extends/invalid-net.yml | 8 ++++ tests/fixtures/extends/invalid-volumes.yml | 9 ++++ .../extends/service-with-invalid-schema.yml | 5 +++ tests/unit/config_test.py | 45 ++++++++----------- 6 files changed, 53 insertions(+), 30 deletions(-) create mode 100644 tests/fixtures/extends/invalid-links.yml create mode 100644 tests/fixtures/extends/invalid-net.yml create mode 100644 tests/fixtures/extends/invalid-volumes.yml create mode 100644 tests/fixtures/extends/service-with-invalid-schema.yml diff --git a/compose/config/config.py b/compose/config/config.py index 0f3099dc072..65a5b5472d1 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -182,6 +182,8 @@ def __init__(self, working_dir, filename, service_name, service_dict, already_se self.extended_config_path = self.get_extended_config_path( self.service_dict['extends'] ) + extended_config = load_yaml(self.extended_config_path) + validate_against_schema(extended_config) def detect_cycle(self, name): if self.signature(name) in self.already_seen: @@ -217,10 +219,9 @@ def resolve_extends(self): extends_options = self.service_dict['extends'] service_name = self.service_dict['name'] + other_config_path = self.extended_config_path - other_config_path = self.get_extended_config_path(extends_options) - - other_working_dir = os.path.dirname(other_config_path) + other_working_dir = os.path.dirname(self.extended_config_path) other_already_seen = self.already_seen + [self.signature(service_name)] base_service = extends_options['service'] diff --git a/tests/fixtures/extends/invalid-links.yml b/tests/fixtures/extends/invalid-links.yml new file mode 100644 index 00000000000..edfeb8b2313 --- /dev/null +++ b/tests/fixtures/extends/invalid-links.yml @@ -0,0 +1,9 @@ +myweb: + build: '.' + extends: + service: web + command: top +web: + build: '.' + links: + - "mydb:db" diff --git a/tests/fixtures/extends/invalid-net.yml b/tests/fixtures/extends/invalid-net.yml new file mode 100644 index 00000000000..fbcd020bcf4 --- /dev/null +++ b/tests/fixtures/extends/invalid-net.yml @@ -0,0 +1,8 @@ +myweb: + build: '.' + extends: + service: web + command: top +web: + build: '.' + net: "container:db" diff --git a/tests/fixtures/extends/invalid-volumes.yml b/tests/fixtures/extends/invalid-volumes.yml new file mode 100644 index 00000000000..3db0118e0ef --- /dev/null +++ b/tests/fixtures/extends/invalid-volumes.yml @@ -0,0 +1,9 @@ +myweb: + build: '.' + extends: + service: web + command: top +web: + build: '.' + volumes_from: + - "db" diff --git a/tests/fixtures/extends/service-with-invalid-schema.yml b/tests/fixtures/extends/service-with-invalid-schema.yml new file mode 100644 index 00000000000..90dc76a0ea8 --- /dev/null +++ b/tests/fixtures/extends/service-with-invalid-schema.yml @@ -0,0 +1,5 @@ +myweb: + extends: + service: web +web: + command: top diff --git a/tests/unit/config_test.py b/tests/unit/config_test.py index f3a4bd306a5..98ae5138541 100644 --- a/tests/unit/config_test.py +++ b/tests/unit/config_test.py @@ -867,6 +867,12 @@ def test_extends_validation_valid_config(self): self.assertEquals(len(service), 1) self.assertIsInstance(service[0], dict) + def test_extended_service_with_invalid_config(self): + expected_error_msg = "Service 'myweb' has neither an image nor a build path specified" + + with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): + load_from_filename('tests/fixtures/extends/service-with-invalid-schema.yml') + def test_extends_file_defaults_to_self(self): """ Test not specifying a file in our extends options that the @@ -891,37 +897,22 @@ def test_extends_file_defaults_to_self(self): } ])) - def test_blacklisted_options(self): - def load_config(): - return make_service_dict('myweb', { - 'extends': { - 'file': 'whatever', - 'service': 'web', - } - }, '.') - - with self.assertRaisesRegexp(ConfigurationError, 'links'): - other_config = {'web': {'links': ['db']}} - - with mock.patch.object(config, 'load_yaml', return_value=other_config): - print(load_config()) - - with self.assertRaisesRegexp(ConfigurationError, 'volumes_from'): - other_config = {'web': {'volumes_from': ['db']}} - - with mock.patch.object(config, 'load_yaml', return_value=other_config): - print(load_config()) + def test_invalid_links_in_extended_service(self): + expected_error_msg = "services with 'links' cannot be extended" + with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): + load_from_filename('tests/fixtures/extends/invalid-links.yml') - with self.assertRaisesRegexp(ConfigurationError, 'net'): - other_config = {'web': {'net': 'container:db'}} + def test_invalid_volumes_from_in_extended_service(self): + expected_error_msg = "services with 'volumes_from' cannot be extended" - with mock.patch.object(config, 'load_yaml', return_value=other_config): - print(load_config()) + with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): + load_from_filename('tests/fixtures/extends/invalid-volumes.yml') - other_config = {'web': {'net': 'host'}} + def test_invalid_net_in_extended_service(self): + expected_error_msg = "services with 'net: container' cannot be extended" - with mock.patch.object(config, 'load_yaml', return_value=other_config): - print(load_config()) + with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): + load_from_filename('tests/fixtures/extends/invalid-net.yml') def test_volume_path(self): dicts = load_from_filename('tests/fixtures/volume-path/docker-compose.yml') From 4a8b2947caae7151db39a425e71f0f66dfd060ea Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Wed, 26 Aug 2015 13:23:29 +0100 Subject: [PATCH 1147/4072] Interpolate extended config This refactoring is now really coming together. Construction is happening in the __init__, which is a constructor and helps clean up the design and clarity of intent of the code. We can now see (nearly) everything that is being constructed when a ServiceLoader is created. It needs all of these data constructs to perform the domain logic and actions. Which are now clearer to see and moving more towards the principle of functions doing (mostly)one thing and function names being more descriptive. resolve_extends is now concerned with the resolving of extends, rather than the construction, validation, pre processing and *then* resolving of extends. Happy days :) Signed-off-by: Mazz Mosley --- compose/config/config.py | 44 ++++++++++--------- compose/config/validation.py | 8 ++++ .../extends/valid-interpolation-2.yml | 3 ++ .../fixtures/extends/valid-interpolation.yml | 5 +++ tests/unit/config_test.py | 11 +++++ 5 files changed, 50 insertions(+), 21 deletions(-) create mode 100644 tests/fixtures/extends/valid-interpolation-2.yml create mode 100644 tests/fixtures/extends/valid-interpolation.yml diff --git a/compose/config/config.py b/compose/config/config.py index 65a5b5472d1..e3ba2aeb837 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -11,6 +11,7 @@ from .errors import ConfigurationError from .interpolation import interpolate_environment_variables from .validation import validate_against_schema +from .validation import validate_extended_service_exists from .validation import validate_extends_file_path from .validation import validate_service_names from .validation import validate_top_level_object @@ -178,12 +179,25 @@ def __init__(self, working_dir, filename, service_name, service_dict, already_se self.service_dict['extends'], self.filename ) - self.extended_config_path = self.get_extended_config_path( self.service_dict['extends'] ) - extended_config = load_yaml(self.extended_config_path) - validate_against_schema(extended_config) + self.extended_service_name = self.service_dict['extends']['service'] + + full_extended_config = pre_process_config( + load_yaml(self.extended_config_path) + ) + + validate_extended_service_exists( + self.extended_service_name, + full_extended_config, + self.extended_config_path + ) + validate_against_schema(full_extended_config) + + self.extended_config = full_extended_config[self.extended_service_name] + else: + self.extended_config = None def detect_cycle(self, name): if self.signature(name) in self.already_seen: @@ -214,40 +228,28 @@ def resolve_environment(self): self.service_dict['environment'] = env def resolve_extends(self): - if 'extends' not in self.service_dict: + if self.extended_config is None: return self.service_dict - extends_options = self.service_dict['extends'] service_name = self.service_dict['name'] - other_config_path = self.extended_config_path other_working_dir = os.path.dirname(self.extended_config_path) other_already_seen = self.already_seen + [self.signature(service_name)] - base_service = extends_options['service'] - other_config = load_yaml(other_config_path) - - if base_service not in other_config: - msg = ( - "Cannot extend service '%s' in %s: Service not found" - ) % (base_service, other_config_path) - raise ConfigurationError(msg) - - other_service_dict = other_config[base_service] other_loader = ServiceLoader( working_dir=other_working_dir, - filename=other_config_path, + filename=self.extended_config_path, service_name=service_name, - service_dict=other_service_dict, + service_dict=self.extended_config, already_seen=other_already_seen, ) - other_loader.detect_cycle(extends_options['service']) + other_loader.detect_cycle(self.extended_service_name) other_service_dict = other_loader.make_service_dict() validate_extended_service_dict( other_service_dict, - filename=other_config_path, - service=extends_options['service'], + filename=self.extended_config_path, + service=self.extended_service_name, ) return merge_service_dicts(other_service_dict, self.service_dict) diff --git a/compose/config/validation.py b/compose/config/validation.py index 1ae8981ca61..304e7e7600e 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -79,6 +79,14 @@ def validate_extends_file_path(service_name, extends_options, filename): ) +def validate_extended_service_exists(extended_service_name, full_extended_config, extended_config_path): + if extended_service_name not in full_extended_config: + msg = ( + "Cannot extend service '%s' in %s: Service not found" + ) % (extended_service_name, extended_config_path) + raise ConfigurationError(msg) + + 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/tests/fixtures/extends/valid-interpolation-2.yml b/tests/fixtures/extends/valid-interpolation-2.yml new file mode 100644 index 00000000000..cb7bd93fc2a --- /dev/null +++ b/tests/fixtures/extends/valid-interpolation-2.yml @@ -0,0 +1,3 @@ +web: + build: '.' + hostname: "host-${HOSTNAME_VALUE}" diff --git a/tests/fixtures/extends/valid-interpolation.yml b/tests/fixtures/extends/valid-interpolation.yml new file mode 100644 index 00000000000..68e8740fb49 --- /dev/null +++ b/tests/fixtures/extends/valid-interpolation.yml @@ -0,0 +1,5 @@ +myweb: + extends: + service: web + file: valid-interpolation-2.yml + command: top diff --git a/tests/unit/config_test.py b/tests/unit/config_test.py index 98ae5138541..7624bbdfd59 100644 --- a/tests/unit/config_test.py +++ b/tests/unit/config_test.py @@ -914,6 +914,17 @@ def test_invalid_net_in_extended_service(self): with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): load_from_filename('tests/fixtures/extends/invalid-net.yml') + @mock.patch.dict(os.environ) + def test_valid_interpolation_in_extended_service(self): + os.environ.update( + HOSTNAME_VALUE="penguin", + ) + expected_interpolated_value = "host-penguin" + + service_dicts = load_from_filename('tests/fixtures/extends/valid-interpolation.yml') + for service in service_dicts: + self.assertTrue(service['hostname'], expected_interpolated_value) + def test_volume_path(self): dicts = load_from_filename('tests/fixtures/volume-path/docker-compose.yml') From 950577d60f7d9ff76c1087f0f93de97303975d71 Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Fri, 28 Aug 2015 18:49:17 +0100 Subject: [PATCH 1148/4072] Split validation into fields and service We want to give feedback to the user as soon as possible about the validity of the config supplied for the services. When extending a service, we can validate that the fields are correct against our schema but we must wait until the *end* of the extends cycle once all of the extended dicts have been merged into the service dict, to perform the final validation check on the config to ensure it is a complete valid service. Doing this before that had happened resulted in false reports of invalid config, as common config when split out, by itself, is not a valid service but *is* valid config to be included. Signed-off-by: Mazz Mosley --- compose/config/config.py | 11 ++++-- .../{schema.json => fields_schema.json} | 18 --------- compose/config/service_schema.json | 39 +++++++++++++++++++ compose/config/validation.py | 18 +++++++-- .../extends/service-with-invalid-schema.yml | 3 +- .../service-with-valid-composite-extends.yml | 5 +++ .../fixtures/extends/valid-common-config.yml | 6 +++ tests/fixtures/extends/valid-common.yml | 3 ++ .../extends/valid-composite-extends.yml | 2 + tests/unit/config_test.py | 9 +++++ 10 files changed, 88 insertions(+), 26 deletions(-) rename compose/config/{schema.json => fields_schema.json} (90%) create mode 100644 compose/config/service_schema.json create mode 100644 tests/fixtures/extends/service-with-valid-composite-extends.yml create mode 100644 tests/fixtures/extends/valid-common-config.yml create mode 100644 tests/fixtures/extends/valid-common.yml create mode 100644 tests/fixtures/extends/valid-composite-extends.yml diff --git a/compose/config/config.py b/compose/config/config.py index e3ba2aeb837..736f5aeb398 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -10,7 +10,8 @@ from .errors import ComposeFileNotFound from .errors import ConfigurationError from .interpolation import interpolate_environment_variables -from .validation import validate_against_schema +from .validation import validate_against_fields_schema +from .validation import validate_against_service_schema from .validation import validate_extended_service_exists from .validation import validate_extends_file_path from .validation import validate_service_names @@ -139,7 +140,7 @@ def load(config_details): config, working_dir, filename = config_details processed_config = pre_process_config(config) - validate_against_schema(processed_config) + validate_against_fields_schema(processed_config) service_dicts = [] @@ -193,7 +194,7 @@ def __init__(self, working_dir, filename, service_name, service_dict, already_se full_extended_config, self.extended_config_path ) - validate_against_schema(full_extended_config) + validate_against_fields_schema(full_extended_config) self.extended_config = full_extended_config[self.extended_service_name] else: @@ -205,6 +206,10 @@ def detect_cycle(self, name): def make_service_dict(self): self.service_dict = self.resolve_extends() + + if not self.already_seen: + validate_against_service_schema(self.service_dict) + return process_container_options(self.service_dict, working_dir=self.working_dir) def resolve_environment(self): diff --git a/compose/config/schema.json b/compose/config/fields_schema.json similarity index 90% rename from compose/config/schema.json rename to compose/config/fields_schema.json index 94fe4fc5224..92305c575ab 100644 --- a/compose/config/schema.json +++ b/compose/config/fields_schema.json @@ -106,24 +106,6 @@ "working_dir": {"type": "string"} }, - "anyOf": [ - { - "required": ["build"], - "not": {"required": ["image"]} - }, - { - "required": ["image"], - "not": {"anyOf": [ - {"required": ["build"]}, - {"required": ["dockerfile"]} - ]} - }, - { - "required": ["extends"], - "not": {"required": ["build", "image"]} - } - ], - "dependencies": { "memswap_limit": ["mem_limit"] }, diff --git a/compose/config/service_schema.json b/compose/config/service_schema.json new file mode 100644 index 00000000000..5cb5d6d0701 --- /dev/null +++ b/compose/config/service_schema.json @@ -0,0 +1,39 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + + "type": "object", + + "properties": { + "name": {"type": "string"} + }, + + "required": ["name"], + + "allOf": [ + {"$ref": "fields_schema.json#/definitions/service"}, + {"$ref": "#/definitions/service_constraints"} + ], + + "definitions": { + "service_constraints": { + "anyOf": [ + { + "required": ["build"], + "not": {"required": ["image"]} + }, + { + "required": ["image"], + "not": {"anyOf": [ + {"required": ["build"]}, + {"required": ["dockerfile"]} + ]} + }, + { + "required": ["extends"], + "not": {"required": ["build", "image"]} + } + ] + } + } + +} diff --git a/compose/config/validation.py b/compose/config/validation.py index 304e7e7600e..3ae5485a7d6 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -5,6 +5,7 @@ from docker.utils.ports import split_port from jsonschema import Draft4Validator from jsonschema import FormatChecker +from jsonschema import RefResolver from jsonschema import ValidationError from .errors import ConfigurationError @@ -210,14 +211,25 @@ def _parse_valid_types_from_schema(schema): return "\n".join(root_msgs + invalid_keys + required + type_errors + other_errors) -def validate_against_schema(config): +def validate_against_fields_schema(config): + schema_filename = "fields_schema.json" + return _validate_against_schema(config, schema_filename) + + +def validate_against_service_schema(config): + schema_filename = "service_schema.json" + return _validate_against_schema(config, schema_filename) + + +def _validate_against_schema(config, schema_filename): config_source_dir = os.path.dirname(os.path.abspath(__file__)) - schema_file = os.path.join(config_source_dir, "schema.json") + schema_file = os.path.join(config_source_dir, schema_filename) with open(schema_file, "r") as schema_fh: schema = json.load(schema_fh) - validation_output = Draft4Validator(schema, format_checker=FormatChecker(["ports"])) + resolver = RefResolver('file://' + config_source_dir + '/', schema) + validation_output = Draft4Validator(schema, resolver=resolver, format_checker=FormatChecker(["ports"])) errors = [error for error in sorted(validation_output.iter_errors(config), key=str)] if errors: diff --git a/tests/fixtures/extends/service-with-invalid-schema.yml b/tests/fixtures/extends/service-with-invalid-schema.yml index 90dc76a0ea8..00c36647ef5 100644 --- a/tests/fixtures/extends/service-with-invalid-schema.yml +++ b/tests/fixtures/extends/service-with-invalid-schema.yml @@ -1,5 +1,4 @@ myweb: extends: + file: valid-composite-extends.yml service: web -web: - command: top diff --git a/tests/fixtures/extends/service-with-valid-composite-extends.yml b/tests/fixtures/extends/service-with-valid-composite-extends.yml new file mode 100644 index 00000000000..6c419ed0702 --- /dev/null +++ b/tests/fixtures/extends/service-with-valid-composite-extends.yml @@ -0,0 +1,5 @@ +myweb: + build: '.' + extends: + file: 'valid-composite-extends.yml' + service: web diff --git a/tests/fixtures/extends/valid-common-config.yml b/tests/fixtures/extends/valid-common-config.yml new file mode 100644 index 00000000000..d8f13e7a863 --- /dev/null +++ b/tests/fixtures/extends/valid-common-config.yml @@ -0,0 +1,6 @@ +myweb: + build: '.' + extends: + file: valid-common.yml + service: common-config + command: top diff --git a/tests/fixtures/extends/valid-common.yml b/tests/fixtures/extends/valid-common.yml new file mode 100644 index 00000000000..07ad68e3e7a --- /dev/null +++ b/tests/fixtures/extends/valid-common.yml @@ -0,0 +1,3 @@ +common-config: + environment: + - FOO=1 diff --git a/tests/fixtures/extends/valid-composite-extends.yml b/tests/fixtures/extends/valid-composite-extends.yml new file mode 100644 index 00000000000..8816c3f3b2f --- /dev/null +++ b/tests/fixtures/extends/valid-composite-extends.yml @@ -0,0 +1,2 @@ +web: + command: top diff --git a/tests/unit/config_test.py b/tests/unit/config_test.py index 7624bbdfd59..8f4251cfa8b 100644 --- a/tests/unit/config_test.py +++ b/tests/unit/config_test.py @@ -866,6 +866,7 @@ def test_extends_validation_valid_config(self): self.assertEquals(len(service), 1) self.assertIsInstance(service[0], dict) + self.assertEquals(service[0]['command'], "/bin/true") def test_extended_service_with_invalid_config(self): expected_error_msg = "Service 'myweb' has neither an image nor a build path specified" @@ -873,6 +874,10 @@ def test_extended_service_with_invalid_config(self): with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): load_from_filename('tests/fixtures/extends/service-with-invalid-schema.yml') + def test_extended_service_with_valid_config(self): + service = load_from_filename('tests/fixtures/extends/service-with-valid-composite-extends.yml') + self.assertEquals(service[0]['command'], "top") + def test_extends_file_defaults_to_self(self): """ Test not specifying a file in our extends options that the @@ -955,6 +960,10 @@ def test_load_throws_error_when_base_service_does_not_exist(self): with self.assertRaisesRegexp(ConfigurationError, err_msg): load_from_filename('tests/fixtures/extends/nonexistent-service.yml') + 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'}) + class BuildPathTest(unittest.TestCase): def setUp(self): From 9fa6e42f5562be98a4541941f40327f248179b43 Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Mon, 31 Aug 2015 17:52:00 +0100 Subject: [PATCH 1149/4072] process_errors handle both schemas Now the schema has been split into two, we need to modify the process_errors function to accomodate. Previously if an error.path was empty then it meant they were root errors. Now that service_schema checks after the service has been resolved, our service name is a key within the dictionary and so our root error logic check is no longer true. Signed-off-by: Mazz Mosley --- compose/config/validation.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/compose/config/validation.py b/compose/config/validation.py index 3ae5485a7d6..59fb13948dc 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -125,7 +125,7 @@ def _parse_valid_types_from_schema(schema): for error in errors: # handle root level errors - if len(error.path) == 0: + if len(error.path) == 0 and not error.instance.get('name'): if error.validator == 'type': msg = "Top level object needs to be a dictionary. Check your .yml file that you have defined a service at the top level." root_msgs.append(msg) @@ -137,11 +137,13 @@ def _parse_valid_types_from_schema(schema): root_msgs.append(_clean_error_message(error.message)) else: - # handle service level errors - service_name = error.path[0] - - # pop the service name off our path - error.path.popleft() + try: + # field_schema errors will have service name on the path + service_name = error.path[0] + error.path.popleft() + except IndexError: + # service_schema errors will have the name in the instance + service_name = error.instance.get('name') if error.validator == 'additionalProperties': invalid_config_key = _parse_key_from_error_msg(error) From 4b487e3957bf6aad71358fb4fdc2c7bf952b1927 Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Tue, 1 Sep 2015 16:14:02 +0100 Subject: [PATCH 1150/4072] Refactor extends back out of __init__ If make_service_dict is our factory function then we'll give it the responsibility of validation/construction and resolving. Signed-off-by: Mazz Mosley --- compose/config/config.py | 65 +++++++++++++++++++--------------------- 1 file changed, 30 insertions(+), 35 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 736f5aeb398..70eac267b12 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -170,42 +170,18 @@ def __init__(self, working_dir, filename, service_name, service_dict, already_se self.filename = filename self.already_seen = already_seen or [] self.service_dict = service_dict.copy() + self.service_name = service_name self.service_dict['name'] = service_name - self.resolve_environment() - - if 'extends' in self.service_dict: - validate_extends_file_path( - service_name, - self.service_dict['extends'], - self.filename - ) - self.extended_config_path = self.get_extended_config_path( - self.service_dict['extends'] - ) - self.extended_service_name = self.service_dict['extends']['service'] - - full_extended_config = pre_process_config( - load_yaml(self.extended_config_path) - ) - - validate_extended_service_exists( - self.extended_service_name, - full_extended_config, - self.extended_config_path - ) - validate_against_fields_schema(full_extended_config) - - self.extended_config = full_extended_config[self.extended_service_name] - else: - self.extended_config = None - def detect_cycle(self, name): if self.signature(name) in self.already_seen: raise CircularReference(self.already_seen + [self.signature(name)]) def make_service_dict(self): - self.service_dict = self.resolve_extends() + self.resolve_environment() + if 'extends' in self.service_dict: + self.validate_and_construct_extends() + self.service_dict = self.resolve_extends() if not self.already_seen: validate_against_service_schema(self.service_dict) @@ -232,19 +208,38 @@ def resolve_environment(self): self.service_dict['environment'] = env - def resolve_extends(self): - if self.extended_config is None: - return self.service_dict + def validate_and_construct_extends(self): + validate_extends_file_path( + self.service_name, + self.service_dict['extends'], + self.filename + ) + self.extended_config_path = self.get_extended_config_path( + self.service_dict['extends'] + ) + self.extended_service_name = self.service_dict['extends']['service'] - service_name = self.service_dict['name'] + full_extended_config = pre_process_config( + load_yaml(self.extended_config_path) + ) + validate_extended_service_exists( + self.extended_service_name, + full_extended_config, + self.extended_config_path + ) + validate_against_fields_schema(full_extended_config) + + self.extended_config = full_extended_config[self.extended_service_name] + + def resolve_extends(self): other_working_dir = os.path.dirname(self.extended_config_path) - other_already_seen = self.already_seen + [self.signature(service_name)] + other_already_seen = self.already_seen + [self.signature(self.service_name)] other_loader = ServiceLoader( working_dir=other_working_dir, filename=self.extended_config_path, - service_name=service_name, + service_name=self.service_name, service_dict=self.extended_config, already_seen=other_already_seen, ) From 9979880c9fc5371f2e0a26fa4d43bfdad156263f Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Tue, 1 Sep 2015 17:00:52 +0100 Subject: [PATCH 1151/4072] Add in volume_driver I'd missed out this field by accident previously. Signed-off-by: Mazz Mosley --- compose/config/fields_schema.json | 1 + 1 file changed, 1 insertion(+) diff --git a/compose/config/fields_schema.json b/compose/config/fields_schema.json index 92305c575ab..299f6de4ffc 100644 --- a/compose/config/fields_schema.json +++ b/compose/config/fields_schema.json @@ -102,6 +102,7 @@ "tty": {"type": "string"}, "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"} }, From f51a5431ec0c3318af5c39599805f20cd135d5f9 Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Wed, 2 Sep 2015 14:58:45 +0100 Subject: [PATCH 1152/4072] Correct some schema field definitions Now validation is split in two, the integration tests helped highlight some places where the schema definition was incorrect. Signed-off-by: Mazz Mosley --- compose/config/fields_schema.json | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/compose/config/fields_schema.json b/compose/config/fields_schema.json index 299f6de4ffc..2a122b7a317 100644 --- a/compose/config/fields_schema.json +++ b/compose/config/fields_schema.json @@ -19,7 +19,12 @@ "cap_drop": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, "command": {"$ref": "#/definitions/string_or_list"}, "container_name": {"type": "string"}, - "cpu_shares": {"type": "string"}, + "cpu_shares": { + "oneOf": [ + {"type": "number"}, + {"type": "string"} + ] + }, "cpuset": {"type": "string"}, "detach": {"type": "boolean"}, "devices": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, @@ -27,7 +32,7 @@ "dns_search": {"$ref": "#/definitions/string_or_list"}, "dockerfile": {"type": "string"}, "domainname": {"type": "string"}, - "entrypoint": {"type": "string"}, + "entrypoint": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, "env_file": {"$ref": "#/definitions/string_or_list"}, "environment": { @@ -75,7 +80,7 @@ }, "name": {"type": "string"}, "net": {"type": "string"}, - "pid": {"type": "string"}, + "pid": {"type": ["string", "null"]}, "ports": { "type": "array", @@ -94,10 +99,10 @@ "uniqueItems": true }, - "privileged": {"type": "string"}, + "privileged": {"type": "boolean"}, "read_only": {"type": "boolean"}, "restart": {"type": "string"}, - "security_opt": {"type": "string"}, + "security_opt": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, "stdin_open": {"type": "string"}, "tty": {"type": "string"}, "user": {"type": "string"}, From 9b8e404d138a1999594231b12ba29c935b93eb69 Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Wed, 2 Sep 2015 15:00:28 +0100 Subject: [PATCH 1153/4072] Pass service_name to process_errors Previously on Buffy... The process_errors was parsing a load of ValidationErrors that we get back from jsonschema which included assumptions about the state of the instance we're validating. Now it's split in two and we're doing field separate to service, those assumptions don't hold and we can't logically retrieve the service_name from the error parsing when we're doing service schema validation, have to explicitly pass this in. process_errors is high on my list for some future re-factoring to help make it a bit clearer, smaller state of doing things. Signed-off-by: Mazz Mosley --- compose/config/config.py | 2 +- compose/config/validation.py | 19 ++++++++++--------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 70eac267b12..8df45b8a9fc 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -184,7 +184,7 @@ def make_service_dict(self): self.service_dict = self.resolve_extends() if not self.already_seen: - validate_against_service_schema(self.service_dict) + validate_against_service_schema(self.service_dict, self.service_name) return process_container_options(self.service_dict, working_dir=self.working_dir) diff --git a/compose/config/validation.py b/compose/config/validation.py index 59fb13948dc..632bdf03bd5 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -95,7 +95,7 @@ def get_unsupported_config_msg(service_name, error_key): return msg -def process_errors(errors): +def process_errors(errors, service_name=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 @@ -137,13 +137,14 @@ def _parse_valid_types_from_schema(schema): root_msgs.append(_clean_error_message(error.message)) else: - try: + if not service_name: # field_schema errors will have service name on the path service_name = error.path[0] error.path.popleft() - except IndexError: - # service_schema errors will have the name in the instance - service_name = error.instance.get('name') + else: + # service_schema errors have the service name passed in, as that + # is not available on error.path or necessarily error.instance + service_name = service_name if error.validator == 'additionalProperties': invalid_config_key = _parse_key_from_error_msg(error) @@ -218,12 +219,12 @@ def validate_against_fields_schema(config): return _validate_against_schema(config, schema_filename) -def validate_against_service_schema(config): +def validate_against_service_schema(config, service_name): schema_filename = "service_schema.json" - return _validate_against_schema(config, schema_filename) + return _validate_against_schema(config, schema_filename, service_name) -def _validate_against_schema(config, schema_filename): +def _validate_against_schema(config, schema_filename, service_name=None): config_source_dir = os.path.dirname(os.path.abspath(__file__)) schema_file = os.path.join(config_source_dir, schema_filename) @@ -235,5 +236,5 @@ def _validate_against_schema(config, schema_filename): errors = [error for error in sorted(validation_output.iter_errors(config), key=str)] if errors: - error_msg = process_errors(errors) + error_msg = process_errors(errors, service_name) raise ConfigurationError("Validation failed, reason(s):\n{}".format(error_msg)) From d31d24d19fc7faa54bd793e812ca3a4447afaa27 Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Wed, 2 Sep 2015 15:03:29 +0100 Subject: [PATCH 1154/4072] Work around some coupling of links, net & volume_from This is minimal disruptive change I could make to ensure the service integration tests worked, now we have some validation happening. There is some coupling/entanglement/assumption going on here. Project when creating a service, using it's class method from_dicts performs some transformations on links, net & volume_from, which get passed on to Service when creating. Service itself, then performs some transformation on those values. This worked fine in the tests before because those options were merely passed on via make_service_dict. This is no longer true with our validation in place. You can't pass to ServiceLoader [(obj, 'string')] for links, the validation expects it to be a list of strings. Which it would be when passed into Project.from_dicts method. I think the tests need some re-factoring but for now, manually deleting keys out of the kwargs and then putting them back in for Service Creation allows the tests to continue. I am not super happy about this approach. Hopefully we can come back and improve it. Signed-off-by: Mazz Mosley --- tests/integration/testcases.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index 58240d5ea45..4557c07b6a2 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -31,11 +31,29 @@ def create_service(self, name, **kwargs): if 'command' not in kwargs: kwargs['command'] = ["top"] + links = kwargs.get('links', None) + volumes_from = kwargs.get('volumes_from', None) + net = kwargs.get('net', None) + + workaround_options = ['links', 'volumes_from', 'net'] + for key in workaround_options: + try: + del kwargs[key] + except KeyError: + pass + options = ServiceLoader(working_dir='.', filename=None, service_name=name, service_dict=kwargs).make_service_dict() labels = options.setdefault('labels', {}) labels['com.docker.compose.test-name'] = self.id() + if links: + options['links'] = links + if volumes_from: + options['volumes_from'] = volumes_from + if net: + options['net'] = net + return Service( project='composetest', client=self.client, From 6a399a5b2f1ed0e014fcb21bae80cae3b725e506 Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Wed, 2 Sep 2015 15:41:25 +0100 Subject: [PATCH 1155/4072] Update tests to be compatible with validation Some were missing build '.' from their dicts, others were the incorrect type and one I've moved from integration to unit. Signed-off-by: Mazz Mosley --- tests/integration/service_test.py | 12 +----- tests/unit/config_test.py | 67 ++++++++++++++++++++++++------- 2 files changed, 53 insertions(+), 26 deletions(-) diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 0cf8cdb0ef4..177471ffa9d 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -165,16 +165,6 @@ def test_create_container_with_extra_hosts_list(self): service.start_container(container) self.assertEqual(set(container.get('HostConfig.ExtraHosts')), set(extra_hosts)) - def test_create_container_with_extra_hosts_string(self): - extra_hosts = 'somehost:162.242.195.82' - service = self.create_service('db', extra_hosts=extra_hosts) - self.assertRaises(ConfigError, lambda: service.create_container()) - - def test_create_container_with_extra_hosts_list_of_dicts(self): - extra_hosts = [{'somehost': '162.242.195.82'}, {'otherhost': '50.31.209.229'}] - service = self.create_service('db', extra_hosts=extra_hosts) - self.assertRaises(ConfigError, lambda: service.create_container()) - def test_create_container_with_extra_hosts_dicts(self): extra_hosts = {'somehost': '162.242.195.82', 'otherhost': '50.31.209.229'} extra_hosts_list = ['somehost:162.242.195.82', 'otherhost:50.31.209.229'] @@ -515,7 +505,7 @@ def test_start_container_becomes_priviliged(self): self.assertEqual(container['HostConfig']['Privileged'], True) def test_expose_does_not_publish_ports(self): - service = self.create_service('web', expose=[8000]) + service = self.create_service('web', expose=["8000"]) container = create_and_start_container(service).inspect() self.assertEqual(container['NetworkSettings']['Ports'], {'8000/tcp': None}) diff --git a/tests/unit/config_test.py b/tests/unit/config_test.py index 8f4251cfa8b..21f1261ec08 100644 --- a/tests/unit/config_test.py +++ b/tests/unit/config_test.py @@ -206,6 +206,39 @@ def test_config_image_and_dockerfile_raise_validation_error(self): ) ) + 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): + config.load( + config.ConfigDetails( + {'web': { + 'image': 'busybox', + 'extra_hosts': 'somehost:162.242.195.82' + }}, + 'working_dir', + 'filename.yml' + ) + ) + + def test_config_extra_hosts_list_of_dicts_validation_error(self): + expected_error_msg = "Service 'web' configuration key 'extra_hosts' contains an invalid type" + + with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): + config.load( + config.ConfigDetails( + {'web': { + 'image': 'busybox', + 'extra_hosts': [ + {'somehost': '162.242.195.82'}, + {'otherhost': '50.31.209.229'} + ] + }}, + 'working_dir', + 'filename.yml' + ) + ) + class InterpolationTest(unittest.TestCase): @mock.patch.dict(os.environ) @@ -240,7 +273,7 @@ def test_unset_variable_produces_warning(self): 'web': { 'image': '${FOO}', 'command': '${BAR}', - 'entrypoint': '${BAR}', + 'container_name': '${BAR}', }, }, working_dir='.', @@ -286,12 +319,13 @@ def test_volume_binding_with_environment_variable(self): @mock.patch.dict(os.environ) def test_volume_binding_with_home(self): os.environ['HOME'] = '/home/user' - d = make_service_dict('foo', {'volumes': ['~:/container/path']}, working_dir='.') + d = make_service_dict('foo', {'build': '.', 'volumes': ['~:/container/path']}, working_dir='.') self.assertEqual(d['volumes'], ['/home/user:/container/path']) @mock.patch.dict(os.environ) def test_volume_binding_with_local_dir_name_raises_warning(self): def make_dict(**config): + config['build'] = '.' make_service_dict('foo', config, working_dir='.') with mock.patch('compose.config.config.log.warn') as warn: @@ -336,6 +370,7 @@ def make_dict(**config): def test_named_volume_with_driver_does_not_expand(self): d = make_service_dict('foo', { + 'build': '.', 'volumes': ['namedvolume:/data'], 'volume_driver': 'foodriver', }, working_dir='.') @@ -345,6 +380,7 @@ def test_named_volume_with_driver_does_not_expand(self): def test_home_directory_with_driver_does_not_expand(self): os.environ['NAME'] = 'surprise!' d = make_service_dict('foo', { + 'build': '.', 'volumes': ['~:/data'], 'volume_driver': 'foodriver', }, working_dir='.') @@ -504,36 +540,36 @@ def test_empty(self): def test_no_override(self): service_dict = config.merge_service_dicts( - make_service_dict('foo', {'labels': ['foo=1', 'bar']}, 'tests/'), - make_service_dict('foo', {}, 'tests/'), + make_service_dict('foo', {'build': '.', 'labels': ['foo=1', 'bar']}, 'tests/'), + make_service_dict('foo', {'build': '.'}, 'tests/'), ) self.assertEqual(service_dict['labels'], {'foo': '1', 'bar': ''}) def test_no_base(self): service_dict = config.merge_service_dicts( - make_service_dict('foo', {}, 'tests/'), - make_service_dict('foo', {'labels': ['foo=2']}, 'tests/'), + make_service_dict('foo', {'build': '.'}, 'tests/'), + make_service_dict('foo', {'build': '.', 'labels': ['foo=2']}, 'tests/'), ) self.assertEqual(service_dict['labels'], {'foo': '2'}) def test_override_explicit_value(self): service_dict = config.merge_service_dicts( - make_service_dict('foo', {'labels': ['foo=1', 'bar']}, 'tests/'), - make_service_dict('foo', {'labels': ['foo=2']}, 'tests/'), + make_service_dict('foo', {'build': '.', 'labels': ['foo=1', 'bar']}, 'tests/'), + make_service_dict('foo', {'build': '.', 'labels': ['foo=2']}, 'tests/'), ) self.assertEqual(service_dict['labels'], {'foo': '2', 'bar': ''}) def test_add_explicit_value(self): service_dict = config.merge_service_dicts( - make_service_dict('foo', {'labels': ['foo=1', 'bar']}, 'tests/'), - make_service_dict('foo', {'labels': ['bar=2']}, 'tests/'), + make_service_dict('foo', {'build': '.', 'labels': ['foo=1', 'bar']}, 'tests/'), + make_service_dict('foo', {'build': '.', 'labels': ['bar=2']}, 'tests/'), ) self.assertEqual(service_dict['labels'], {'foo': '1', 'bar': '2'}) def test_remove_explicit_value(self): service_dict = config.merge_service_dicts( - make_service_dict('foo', {'labels': ['foo=1', 'bar=2']}, 'tests/'), - make_service_dict('foo', {'labels': ['bar']}, 'tests/'), + make_service_dict('foo', {'build': '.', 'labels': ['foo=1', 'bar=2']}, 'tests/'), + make_service_dict('foo', {'build': '.', 'labels': ['bar']}, 'tests/'), ) self.assertEqual(service_dict['labels'], {'foo': '1', 'bar': ''}) @@ -615,6 +651,7 @@ def test_resolve_environment(self): service_dict = make_service_dict( 'foo', { + 'build': '.', 'environment': { 'FILE_DEF': 'F1', 'FILE_DEF_EMPTY': '', @@ -633,7 +670,7 @@ def test_resolve_environment(self): def test_env_from_file(self): service_dict = make_service_dict( 'foo', - {'env_file': 'one.env'}, + {'build': '.', 'env_file': 'one.env'}, 'tests/fixtures/env', ) self.assertEqual( @@ -644,7 +681,7 @@ def test_env_from_file(self): def test_env_from_multiple_files(self): service_dict = make_service_dict( 'foo', - {'env_file': ['one.env', 'two.env']}, + {'build': '.', 'env_file': ['one.env', 'two.env']}, 'tests/fixtures/env', ) self.assertEqual( @@ -666,7 +703,7 @@ def test_resolve_environment_from_file(self): os.environ['ENV_DEF'] = 'E3' service_dict = make_service_dict( 'foo', - {'env_file': 'resolve.env'}, + {'build': '.', 'env_file': 'resolve.env'}, 'tests/fixtures/env', ) self.assertEqual( From b54b932b54ea39054aeaab1273c8570001b90804 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 2 Sep 2015 11:53:37 -0700 Subject: [PATCH 1156/4072] Exit gracefully when requests encounter a ReadTimeout exception. Signed-off-by: Joffrey F --- compose/cli/docker_client.py | 2 +- compose/cli/main.py | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/compose/cli/docker_client.py b/compose/cli/docker_client.py index ad67d5639f9..91e4059c9c0 100644 --- a/compose/cli/docker_client.py +++ b/compose/cli/docker_client.py @@ -34,5 +34,5 @@ def docker_client(): ca_cert=ca_cert, ) - timeout = int(os.environ.get('DOCKER_CLIENT_TIMEOUT', 60)) + timeout = int(os.environ.get('COMPOSE_HTTP_TIMEOUT', os.environ.get('DOCKER_CLIENT_TIMEOUT', 60))) return Client(base_url=base_url, tls=tls_config, version=api_version, timeout=timeout) diff --git a/compose/cli/main.py b/compose/cli/main.py index 2ace13c22b8..116c830021a 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -10,6 +10,7 @@ import dockerpty from docker.errors import APIError +from requests.exceptions import ReadTimeout from .. import __version__ from .. import legacy @@ -65,6 +66,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( + "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." + ) def setup_logging(): From 80c909299965243800401f061c17afc56a689cdd Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 2 Sep 2015 12:52:48 -0700 Subject: [PATCH 1157/4072] Document COMPOSE_HTTP_TIMEOUT env config Signed-off-by: Joffrey F --- docs/reference/overview.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/reference/overview.md b/docs/reference/overview.md index 7425aa5e8a7..52598737d6b 100644 --- a/docs/reference/overview.md +++ b/docs/reference/overview.md @@ -44,6 +44,11 @@ the `docker` daemon. 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. + From 48466d7d824c17c321be6f4308166f34eff822f9 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 2 Sep 2015 16:08:18 -0400 Subject: [PATCH 1158/4072] Fix #1961 - docker-compose up should attach to all containers with no service names are specified, and add tests. Signed-off-by: Daniel Nephin --- compose/cli/main.py | 5 ++++- tests/integration/cli_test.py | 28 +++++++++++++++++++++++----- tests/unit/cli/main_test.py | 10 ++++++++++ 3 files changed, 37 insertions(+), 6 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 2ace13c22b8..2d72646d101 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -583,8 +583,11 @@ def version(self, project, options): def build_log_printer(containers, service_names, monochrome): + if service_names: + containers = [c for c in containers if c.service in service_names] + return LogPrinter( - [c for c in containers if c.service in service_names], + containers, attach_params={"logs": True}, monochrome=monochrome) diff --git a/tests/integration/cli_test.py b/tests/integration/cli_test.py index 124ae559100..9606ef41f3d 100644 --- a/tests/integration/cli_test.py +++ b/tests/integration/cli_test.py @@ -113,7 +113,7 @@ def test_build_no_cache(self, mock_stdout): output = mock_stdout.getvalue() self.assertNotIn(cache_indicator, output) - def test_up(self): + def test_up_detached(self): self.command.dispatch(['up', '-d'], None) service = self.project.get_service('simple') another = self.project.get_service('another') @@ -121,10 +121,28 @@ def test_up(self): self.assertEqual(len(another.containers()), 1) # Ensure containers don't have stdin and stdout connected in -d mode - config = service.containers()[0].inspect()['Config'] - self.assertFalse(config['AttachStderr']) - self.assertFalse(config['AttachStdout']) - self.assertFalse(config['AttachStdin']) + container, = service.containers() + self.assertFalse(container.get('Config.AttachStderr')) + self.assertFalse(container.get('Config.AttachStdout')) + self.assertFalse(container.get('Config.AttachStdin')) + + def test_up_attached(self): + with mock.patch( + 'compose.cli.main.attach_to_logs', + autospec=True + ) as mock_attach: + self.command.dispatch(['up'], None) + _, args, kwargs = mock_attach.mock_calls[0] + _project, log_printer, _names, _timeout = args + + service = self.project.get_service('simple') + another = self.project.get_service('another') + self.assertEqual(len(service.containers()), 1) + self.assertEqual(len(another.containers()), 1) + self.assertEqual( + set(log_printer.containers), + set(self.project.containers()) + ) def test_up_with_links(self): self.command.base_dir = 'tests/fixtures/links-composefile' diff --git a/tests/unit/cli/main_test.py b/tests/unit/cli/main_test.py index 817e8f49b6a..e3a4629e53b 100644 --- a/tests/unit/cli/main_test.py +++ b/tests/unit/cli/main_test.py @@ -31,6 +31,16 @@ def test_build_log_printer(self): log_printer = build_log_printer(containers, service_names, True) self.assertEqual(log_printer.containers, containers[:3]) + def test_build_log_printer_all_services(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) + self.assertEqual(log_printer.containers, containers) + def test_attach_to_logs(self): project = mock.create_autospec(Project) log_printer = mock.create_autospec(LogPrinter, containers=[]) From e634fe3fd6a768bcd5818dfb81b4c11cdc460853 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 2 Sep 2015 14:28:57 -0700 Subject: [PATCH 1159/4072] Deprecation warning when DOCKER_CLIENT_TIMEOUT is used Signed-off-by: Joffrey F --- compose/cli/docker_client.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/compose/cli/docker_client.py b/compose/cli/docker_client.py index 91e4059c9c0..e16549e87ba 100644 --- a/compose/cli/docker_client.py +++ b/compose/cli/docker_client.py @@ -1,9 +1,12 @@ +import logging import os import ssl from docker import Client from docker import tls +log = logging.getLogger(__name__) + def docker_client(): """ @@ -34,5 +37,8 @@ def docker_client(): ca_cert=ca_cert, ) + if 'DOCKER_CLIENT_TIMEOUT' in os.environ: + log.warn('The DOCKER_CLIENT_TIMEOUT environment variable is deprecated. Please use COMPOSE_HTTP_TIMEOUT instead.') timeout = int(os.environ.get('COMPOSE_HTTP_TIMEOUT', os.environ.get('DOCKER_CLIENT_TIMEOUT', 60))) + return Client(base_url=base_url, tls=tls_config, version=api_version, timeout=timeout) From b110bbe9e3f2c69e8f1dc8e990d16f4b016da955 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 2 Sep 2015 14:31:30 -0700 Subject: [PATCH 1160/4072] Refined error message when timeout is encountered. Signed-off-by: Joffrey F --- compose/cli/main.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 116c830021a..6618742996a 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -2,6 +2,7 @@ from __future__ import unicode_literals import logging +import os import re import signal import sys @@ -68,9 +69,9 @@ def main(): sys.exit(1) except ReadTimeout as e: log.error( - "HTTP request took too long to complete. Retry with --verbose to obtain debug information.\n" + "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." + "COMPOSE_HTTP_TIMEOUT to a higher value (current value: %s)." % os.environ.get('COMPOSE_HTTP_TIMEOUT', 60) ) From f9c7346380dc9c3da7f465b8ac542673700db837 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 2 Sep 2015 14:52:47 -0700 Subject: [PATCH 1161/4072] HTTP_TIMEOUT as importable constant for consistency Signed-off-by: Joffrey F --- compose/cli/docker_client.py | 5 +++-- compose/cli/main.py | 4 ++-- compose/const.py | 2 ++ 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/compose/cli/docker_client.py b/compose/cli/docker_client.py index e16549e87ba..601b0b9aabe 100644 --- a/compose/cli/docker_client.py +++ b/compose/cli/docker_client.py @@ -5,6 +5,8 @@ from docker import Client from docker import tls +from ..const import HTTP_TIMEOUT + log = logging.getLogger(__name__) @@ -39,6 +41,5 @@ def 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.') - timeout = int(os.environ.get('COMPOSE_HTTP_TIMEOUT', os.environ.get('DOCKER_CLIENT_TIMEOUT', 60))) - return Client(base_url=base_url, tls=tls_config, version=api_version, timeout=timeout) + return Client(base_url=base_url, tls=tls_config, version=api_version, timeout=HTTP_TIMEOUT) diff --git a/compose/cli/main.py b/compose/cli/main.py index 6618742996a..13a8cef2669 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -2,7 +2,6 @@ from __future__ import unicode_literals import logging -import os import re import signal import sys @@ -17,6 +16,7 @@ from .. import legacy from ..config import parse_environment from ..const import DEFAULT_TIMEOUT +from ..const import HTTP_TIMEOUT from ..progress_stream import StreamOutputError from ..project import ConfigurationError from ..project import NoSuchService @@ -71,7 +71,7 @@ def main(): 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)." % os.environ.get('COMPOSE_HTTP_TIMEOUT', 60) + "COMPOSE_HTTP_TIMEOUT to a higher value (current value: %s)." % HTTP_TIMEOUT ) diff --git a/compose/const.py b/compose/const.py index 709c3a10d74..dbfa56b8cdd 100644 --- a/compose/const.py +++ b/compose/const.py @@ -1,3 +1,4 @@ +import os DEFAULT_TIMEOUT = 10 LABEL_CONTAINER_NUMBER = 'com.docker.compose.container-number' @@ -6,3 +7,4 @@ LABEL_SERVICE = 'com.docker.compose.service' LABEL_VERSION = 'com.docker.compose.version' LABEL_CONFIG_HASH = 'com.docker.compose.config-hash' +HTTP_TIMEOUT = int(os.environ.get('COMPOSE_HTTP_TIMEOUT', os.environ.get('DOCKER_CLIENT_TIMEOUT', 60))) From b165ae07c97e3af52528c829d136dd29c37da0a3 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 1 Sep 2015 14:58:16 -0700 Subject: [PATCH 1162/4072] Configure PyInstaller using docker-compose.spec Signed-off-by: Aanand Prasad --- .dockerignore | 1 - .gitignore | 1 - docker-compose.spec | 24 ++++++++++++++++++++++++ script/build-linux-inner | 2 +- script/build-osx | 2 +- 5 files changed, 26 insertions(+), 4 deletions(-) create mode 100644 docker-compose.spec diff --git a/.dockerignore b/.dockerignore index ba7e9155d59..5a4da301b12 100644 --- a/.dockerignore +++ b/.dockerignore @@ -5,6 +5,5 @@ build coverage-html dist -docker-compose.spec docs/_site venv diff --git a/.gitignore b/.gitignore index f6750c1ff58..1b0c50113fa 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,5 @@ /build /coverage-html /dist -/docker-compose.spec /docs/_site /venv diff --git a/docker-compose.spec b/docker-compose.spec new file mode 100644 index 00000000000..eae63914c8c --- /dev/null +++ b/docker-compose.spec @@ -0,0 +1,24 @@ +# -*- mode: python -*- + +block_cipher = None + +a = Analysis(['bin/docker-compose'], + pathex=['.'], + hiddenimports=[], + hookspath=None, + runtime_hooks=None, + cipher=block_cipher) + +pyz = PYZ(a.pure, + cipher=block_cipher) + +exe = EXE(pyz, + a.scripts, + a.binaries, + a.zipfiles, + a.datas, + name='docker-compose', + debug=False, + strip=None, + upx=True, + console=True ) diff --git a/script/build-linux-inner b/script/build-linux-inner index cfea838067a..e5d290ebaaf 100755 --- a/script/build-linux-inner +++ b/script/build-linux-inner @@ -8,6 +8,6 @@ mkdir -p `pwd`/dist chmod 777 `pwd`/dist pip install -r requirements-build.txt -su -c "pyinstaller -F bin/docker-compose" user +su -c "pyinstaller docker-compose.spec" user mv dist/docker-compose $TARGET $TARGET version diff --git a/script/build-osx b/script/build-osx index d99c1fb981c..e1cc7038ac8 100755 --- a/script/build-osx +++ b/script/build-osx @@ -8,6 +8,6 @@ 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 . -venv/bin/pyinstaller -F bin/docker-compose +venv/bin/pyinstaller docker-compose.spec mv dist/docker-compose dist/docker-compose-Darwin-x86_64 dist/docker-compose-Darwin-x86_64 version From 6a95f6d628933299ba0531b87a38c7f9a0c5dcc3 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 2 Sep 2015 18:00:08 -0700 Subject: [PATCH 1163/4072] custom timeout test rewrite Signed-off-by: Joffrey F --- tests/unit/cli/docker_client_test.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/unit/cli/docker_client_test.py b/tests/unit/cli/docker_client_test.py index 5ccde73ad36..d497495b40c 100644 --- a/tests/unit/cli/docker_client_test.py +++ b/tests/unit/cli/docker_client_test.py @@ -16,7 +16,7 @@ def test_docker_client_no_home(self): docker_client.docker_client() def test_docker_client_with_custom_timeout(self): - with mock.patch.dict(os.environ): - os.environ['DOCKER_CLIENT_TIMEOUT'] = timeout = "300" + timeout = 300 + with mock.patch('compose.cli.docker_client.HTTP_TIMEOUT', 300): client = docker_client.docker_client() - self.assertEqual(client.timeout, int(timeout)) + self.assertEqual(client.timeout, int(timeout)) From ecea79fd4e3ae4ee91c6e34c5230fec8739295f4 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 1 Sep 2015 15:22:00 -0700 Subject: [PATCH 1164/4072] Bundle schema files Signed-off-by: Aanand Prasad --- docker-compose.spec | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docker-compose.spec b/docker-compose.spec index eae63914c8c..678fc132386 100644 --- a/docker-compose.spec +++ b/docker-compose.spec @@ -17,6 +17,8 @@ exe = EXE(pyz, a.binaries, a.zipfiles, a.datas, + [('compose/config/fields_schema.json', 'compose/config/fields_schema.json', 'DATA')], + [('compose/config/service_schema.json', 'compose/config/service_schema.json', 'DATA')], name='docker-compose', debug=False, strip=None, From 7326608369d52656eae56202d3cc005300a17771 Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Thu, 3 Sep 2015 11:44:08 +0100 Subject: [PATCH 1165/4072] expose array can contain either strings or numbers Signed-off-by: Mazz Mosley --- compose/config/fields_schema.json | 6 +++++- tests/unit/config_test.py | 15 +++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/compose/config/fields_schema.json b/compose/config/fields_schema.json index 2a122b7a317..f03ef7110c5 100644 --- a/compose/config/fields_schema.json +++ b/compose/config/fields_schema.json @@ -42,7 +42,11 @@ ] }, - "expose": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "expose": { + "type": "array", + "items": {"type": ["string", "number"]}, + "uniqueItems": true + }, "extends": { "type": "object", diff --git a/tests/unit/config_test.py b/tests/unit/config_test.py index 21f1261ec08..3f602fb5931 100644 --- a/tests/unit/config_test.py +++ b/tests/unit/config_test.py @@ -239,6 +239,21 @@ def test_config_extra_hosts_list_of_dicts_validation_error(self): ) ) + def test_valid_config_which_allows_two_type_definitions(self): + expose_values = [["8000"], [8000]] + for expose in expose_values: + service = config.load( + config.ConfigDetails( + {'web': { + 'image': 'busybox', + 'expose': expose + }}, + 'working_dir', + 'filename.yml' + ) + ) + self.assertEqual(service[0]['expose'], expose) + class InterpolationTest(unittest.TestCase): @mock.patch.dict(os.environ) From ef56523883a8c2d5bd2d48a556ece6a3b8b130f5 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 1 Sep 2015 18:56:40 -0400 Subject: [PATCH 1166/4072] Make external_links a regular service.option so that it's part of the config hash Signed-off-by: Daniel Nephin --- compose/service.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/compose/service.py b/compose/service.py index b48f2e14bd6..5942fca53da 100644 --- a/compose/service.py +++ b/compose/service.py @@ -87,7 +87,16 @@ class NoSuchImageError(Exception): class Service(object): - def __init__(self, name, client=None, project='default', links=None, external_links=None, volumes_from=None, net=None, **options): + def __init__( + self, + name, + client=None, + project='default', + links=None, + volumes_from=None, + net=None, + **options + ): if not re.match('^%s+$' % VALID_NAME_CHARS, project): raise ConfigError('Invalid project name "%s" - only %s are allowed' % (project, VALID_NAME_CHARS)) @@ -95,7 +104,6 @@ def __init__(self, name, client=None, project='default', links=None, external_li self.client = client self.project = project self.links = links or [] - self.external_links = external_links or [] self.volumes_from = volumes_from or [] self.net = net or None self.options = options @@ -528,7 +536,7 @@ def _get_links(self, link_to_self): links.append((container.name, self.name)) links.append((container.name, container.name)) links.append((container.name, container.name_without_project)) - for external_link in self.external_links: + for external_link in self.options.get('external_links') or []: if ':' not in external_link: link_name = external_link else: From e801981fed4049d0f7eab94ed969ec20f3ba8a76 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 1 Sep 2015 19:21:22 -0400 Subject: [PATCH 1167/4072] Sort config keys Signed-off-by: Daniel Nephin --- compose/config/config.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 8df45b8a9fc..d9b06f3e7d5 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -22,9 +22,9 @@ DOCKER_CONFIG_KEYS = [ 'cap_add', 'cap_drop', + 'command', 'cpu_shares', 'cpuset', - 'command', 'detach', 'devices', 'dns', @@ -38,12 +38,12 @@ 'image', 'labels', 'links', + 'log_driver', + 'log_opt', 'mac_address', 'mem_limit', 'memswap_limit', 'net', - 'log_driver', - 'log_opt', 'pid', 'ports', 'privileged', From 08ba857807753f43a2b64844fe53ca70756bfa14 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 1 Sep 2015 19:54:49 -0400 Subject: [PATCH 1168/4072] Cleanup some project logic. Signed-off-by: Daniel Nephin --- compose/project.py | 54 ++++++++++++++++++++++++---------------------- 1 file changed, 28 insertions(+), 26 deletions(-) diff --git a/compose/project.py b/compose/project.py index 4e8696ba882..54d6c4434da 100644 --- a/compose/project.py +++ b/compose/project.py @@ -87,8 +87,14 @@ def from_dicts(cls, name, service_dicts, client): volumes_from = project.get_volumes_from(service_dict) net = project.get_net(service_dict) - project.services.append(Service(client=client, project=name, links=links, net=net, - volumes_from=volumes_from, **service_dict)) + project.services.append( + Service( + client=client, + project=name, + links=links, + net=net, + volumes_from=volumes_from, + **service_dict)) return project @property @@ -184,30 +190,26 @@ def get_volumes_from(self, service_dict): return volumes_from def get_net(self, service_dict): - if 'net' in service_dict: - net_name = get_service_name_from_net(service_dict.get('net')) - - if net_name: - try: - net = self.get_service(net_name) - except NoSuchService: - try: - net = 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)) - else: - net = service_dict['net'] - - del service_dict['net'] - - else: - net = None - - return net + net = service_dict.pop('net', None) + if not net: + return + + net_name = get_service_name_from_net(net) + if not net_name: + return net + + try: + return self.get_service(net_name) + except NoSuchService: + pass + try: + return 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)) def start(self, service_names=None, **options): for service in self.get_services(service_names): From c183e52502da8efd3e60f104b4d25f0577f55c04 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 1 Sep 2015 19:40:15 -0400 Subject: [PATCH 1169/4072] Fixes #1757 - include all service properties in the config_dict() Signed-off-by: Daniel Nephin --- compose/service.py | 3 +++ tests/integration/state_test.py | 22 +++++++++++++++++ tests/unit/service_test.py | 43 ++++++++++++++++++++++++++++++++- 3 files changed, 67 insertions(+), 1 deletion(-) diff --git a/compose/service.py b/compose/service.py index 5942fca53da..f60d57bfd8f 100644 --- a/compose/service.py +++ b/compose/service.py @@ -488,6 +488,9 @@ def config_dict(self): return { 'options': self.options, 'image_id': self.image()['Id'], + 'links': [(service.name, alias) for service, alias in self.links], + 'net': self.get_net_name() or getattr(self.net, 'id', self.net), + 'volumes_from': self.get_volumes_from_names(), } def get_dependency_names(self): diff --git a/tests/integration/state_test.py b/tests/integration/state_test.py index b3dd42d996b..d077f094d01 100644 --- a/tests/integration/state_test.py +++ b/tests/integration/state_test.py @@ -1,3 +1,7 @@ +""" +Integration tests which cover state convergence (aka smart recreate) performed +by `docker-compose up`. +""" from __future__ import unicode_literals import os @@ -151,6 +155,24 @@ def test_change_root_no_recreate(self): self.assertEqual(new_containers - old_containers, set()) + def test_service_removed_while_down(self): + next_cfg = { + 'web': { + 'image': 'busybox:latest', + 'command': 'tail -f /dev/null', + }, + 'nginx': self.cfg['nginx'], + } + + containers = self.run_up(self.cfg) + self.assertEqual(len(containers), 3) + + project = self.make_project(self.cfg) + project.stop(timeout=1) + + containers = self.run_up(next_cfg) + self.assertEqual(len(containers), 2) + def converge(service, allow_recreate=True, diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index aa6d4d74f48..3981cad2072 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -189,7 +189,7 @@ def test_get_container_create_options_does_not_mutate_options(self): self.assertEqual( opts['labels'][LABEL_CONFIG_HASH], - 'b30306d0a73b67f67a45b99b88d36c359e470e6fa0c04dda1cf62d2087205b81') + '3c85881a8903b9d73a06c41860c8be08acce1494ab4cf8408375966dccd714de') self.assertEqual( opts['environment'], { @@ -331,6 +331,47 @@ def test_build_does_not_pull(self): self.assertEqual(self.mock_client.build.call_count, 1) self.assertFalse(self.mock_client.build.call_args[1]['pull']) + def test_config_dict(self): + self.mock_client.inspect_image.return_value = {'Id': 'abcd'} + service = Service( + 'foo', + image='example.com/foo', + client=self.mock_client, + net=Service('other'), + links=[(Service('one'), 'one')], + volumes_from=[Service('two')]) + + config_dict = service.config_dict() + expected = { + 'image_id': 'abcd', + 'options': {'image': 'example.com/foo'}, + 'links': [('one', 'one')], + 'net': 'other', + 'volumes_from': ['two'], + } + self.assertEqual(config_dict, expected) + + def test_config_dict_with_net_from_container(self): + self.mock_client.inspect_image.return_value = {'Id': 'abcd'} + container = Container( + self.mock_client, + {'Id': 'aaabbb', 'Name': '/foo_1'}) + service = Service( + 'foo', + image='example.com/foo', + client=self.mock_client, + net=container) + + config_dict = service.config_dict() + expected = { + 'image_id': 'abcd', + 'options': {'image': 'example.com/foo'}, + 'links': [], + 'net': 'aaabbb', + 'volumes_from': [], + } + self.assertEqual(config_dict, expected) + def mock_get_image(images): if images: From 187ad4ce26401aaa10984c3c9a9782d6b2efdb87 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 3 Sep 2015 13:02:46 -0400 Subject: [PATCH 1170/4072] Refactor network_mode logic out of Service. Signed-off-by: Daniel Nephin --- compose/project.py | 11 +++-- compose/service.py | 88 +++++++++++++++++++++++++------------- tests/unit/project_test.py | 6 +-- tests/unit/service_test.py | 48 ++++++++++++++++++++- 4 files changed, 116 insertions(+), 37 deletions(-) diff --git a/compose/project.py b/compose/project.py index 54d6c4434da..8db20e76679 100644 --- a/compose/project.py +++ b/compose/project.py @@ -14,7 +14,10 @@ from .const import LABEL_SERVICE from .container import Container from .legacy import check_for_legacy_containers +from .service import ContainerNet +from .service import Net from .service import Service +from .service import ServiceNet from .utils import parallel_execute @@ -192,18 +195,18 @@ def get_volumes_from(self, service_dict): def get_net(self, service_dict): net = service_dict.pop('net', None) if not net: - return + return Net(None) net_name = get_service_name_from_net(net) if not net_name: - return net + return Net(net) try: - return self.get_service(net_name) + return ServiceNet(self.get_service(net_name)) except NoSuchService: pass try: - return Container.from_id(self.client, net_name) + return ContainerNet(Container.from_id(self.client, net_name)) except APIError: raise ConfigurationError( 'Service "%s" is trying to use the network of "%s", ' diff --git a/compose/service.py b/compose/service.py index f60d57bfd8f..bfc6f904e33 100644 --- a/compose/service.py +++ b/compose/service.py @@ -105,7 +105,7 @@ def __init__( self.project = project self.links = links or [] self.volumes_from = volumes_from or [] - self.net = net or None + self.net = net or Net(None) self.options = options def containers(self, stopped=False, one_off=False, filters={}): @@ -489,12 +489,12 @@ def config_dict(self): 'options': self.options, 'image_id': self.image()['Id'], 'links': [(service.name, alias) for service, alias in self.links], - 'net': self.get_net_name() or getattr(self.net, 'id', self.net), + 'net': self.net.id, 'volumes_from': self.get_volumes_from_names(), } def get_dependency_names(self): - net_name = self.get_net_name() + net_name = self.net.service_name return (self.get_linked_names() + self.get_volumes_from_names() + ([net_name] if net_name else [])) @@ -505,12 +505,6 @@ def get_linked_names(self): def get_volumes_from_names(self): return [s.name for s in self.volumes_from if isinstance(s, Service)] - def get_net_name(self): - if isinstance(self.net, Service): - return self.net.name - else: - return - 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) @@ -562,25 +556,6 @@ def _get_volumes_from(self): return volumes_from - def _get_net(self): - if not self.net: - return None - - if isinstance(self.net, Service): - containers = self.net.containers() - if len(containers) > 0: - net = 'container:' + containers[0].id - else: - log.warning("Warning: Service %s is trying to use reuse the network stack " - "of another service that is not running." % (self.net.name)) - net = None - elif isinstance(self.net, Container): - net = 'container:' + self.net.id - else: - net = self.net - - return net - def _get_container_create_options( self, override_options, @@ -694,7 +669,7 @@ def _get_container_host_config(self, override_options, one_off=False): binds=options.get('binds'), volumes_from=self._get_volumes_from(), privileged=privileged, - network_mode=self._get_net(), + network_mode=self.net.mode, devices=devices, dns=dns, dns_search=dns_search, @@ -793,6 +768,61 @@ def pull(self): stream_output(output, sys.stdout) +class Net(object): + """A `standard` network mode (ex: host, bridge)""" + + service_name = None + + def __init__(self, net): + self.net = net + + @property + def id(self): + return self.net + + mode = id + + +class ContainerNet(object): + """A network mode that uses a containers network stack.""" + + service_name = None + + def __init__(self, container): + self.container = container + + @property + def id(self): + return self.container.id + + @property + def mode(self): + return 'container:' + self.container.id + + +class ServiceNet(object): + """A network mode that uses a service's network stack.""" + + def __init__(self, service): + self.service = service + + @property + def id(self): + return self.service.name + + service_name = id + + @property + def mode(self): + containers = self.service.containers() + if containers: + return 'container:' + containers[0].id + + log.warn("Warning: Service %s is trying to use reuse the network stack " + "of another service that is not running." % (self.id)) + return None + + # Names diff --git a/tests/unit/project_test.py b/tests/unit/project_test.py index 37ebe5148d0..ce74eb30b70 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -221,7 +221,7 @@ def test_net_unset(self): } ], self.mock_client) service = project.get_service('test') - self.assertEqual(service._get_net(), None) + self.assertEqual(service.net.id, None) self.assertNotIn('NetworkMode', service._get_container_host_config({})) def test_use_net_from_container(self): @@ -236,7 +236,7 @@ def test_use_net_from_container(self): } ], self.mock_client) service = project.get_service('test') - self.assertEqual(service._get_net(), 'container:' + container_id) + self.assertEqual(service.net.mode, 'container:' + container_id) def test_use_net_from_service(self): container_name = 'test_aaa_1' @@ -261,7 +261,7 @@ def test_use_net_from_service(self): ], self.mock_client) service = project.get_service('test') - self.assertEqual(service._get_net(), 'container:' + container_name) + self.assertEqual(service.net.mode, 'container:' + container_name) def test_container_without_name(self): self.mock_client.containers.return_value = [ diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 3981cad2072..de973339b2d 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -13,13 +13,16 @@ from compose.container import Container from compose.service import build_volume_binding from compose.service import ConfigError +from compose.service import ContainerNet from compose.service import get_container_data_volumes from compose.service import merge_volume_bindings from compose.service import NeedsBuildError +from compose.service import Net from compose.service import NoSuchImageError from compose.service import parse_repository_tag from compose.service import parse_volume_spec from compose.service import Service +from compose.service import ServiceNet class ServiceTest(unittest.TestCase): @@ -337,7 +340,7 @@ def test_config_dict(self): 'foo', image='example.com/foo', client=self.mock_client, - net=Service('other'), + net=ServiceNet(Service('other')), links=[(Service('one'), 'one')], volumes_from=[Service('two')]) @@ -373,6 +376,49 @@ def test_config_dict_with_net_from_container(self): self.assertEqual(config_dict, expected) +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_net_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) + + def test_net_service(self): + container_id = 'bbbb' + service_name = 'web' + mock_client = mock.create_autospec(docker.Client) + mock_client.containers.return_value = [ + {'Id': container_id, 'Name': container_id, 'Image': 'abcd'}, + ] + + service = Service(name=service_name, client=mock_client) + net = ServiceNet(service) + + self.assertEqual(net.id, service_name) + self.assertEqual(net.mode, 'container:' + container_id) + self.assertEqual(net.service_name, service_name) + + def test_net_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) + + self.assertEqual(net.id, service_name) + self.assertEqual(net.mode, None) + self.assertEqual(net.service_name, service_name) + + def mock_get_image(images): if images: return images[0] From db9f577ad6cdadfb8eaa33b492fd513821ed57b6 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 3 Sep 2015 13:13:22 -0400 Subject: [PATCH 1171/4072] Extract link names into a function. Signed-off-by: Daniel Nephin --- compose/cli/main.py | 2 +- compose/service.py | 13 ++++++++----- tests/integration/project_test.py | 4 ++-- tests/integration/service_test.py | 7 ++++--- 4 files changed, 15 insertions(+), 11 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 2d72646d101..11d2d104c74 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -326,7 +326,7 @@ def run(self, project, options): log.warn(INSECURE_SSL_WARNING) if not options['--no-deps']: - deps = service.get_linked_names() + deps = service.get_linked_service_names() if len(deps) > 0: project.up( diff --git a/compose/service.py b/compose/service.py index bfc6f904e33..8dc1efa1d24 100644 --- a/compose/service.py +++ b/compose/service.py @@ -488,19 +488,22 @@ def config_dict(self): return { 'options': self.options, 'image_id': self.image()['Id'], - 'links': [(service.name, alias) for service, alias in self.links], + 'links': self.get_link_names(), 'net': self.net.id, 'volumes_from': self.get_volumes_from_names(), } def get_dependency_names(self): net_name = self.net.service_name - return (self.get_linked_names() + + return (self.get_linked_service_names() + self.get_volumes_from_names() + ([net_name] if net_name else [])) - def get_linked_names(self): - return [s.name for (s, _) in self.links] + def get_linked_service_names(self): + return [service.name for (service, _) in self.links] + + def get_link_names(self): + return [(service.name, alias) for service, alias in self.links] def get_volumes_from_names(self): return [s.name for s in self.volumes_from if isinstance(s, Service)] @@ -784,7 +787,7 @@ def id(self): class ContainerNet(object): - """A network mode that uses a containers network stack.""" + """A network mode that uses a container's network stack.""" service_name = None diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 51619cb5ec7..fe63838fcb5 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -112,7 +112,7 @@ def test_net_from_service(self): web = project.get_service('web') net = project.get_service('net') - self.assertEqual(web._get_net(), 'container:' + net.containers()[0].id) + self.assertEqual(web.net.mode, 'container:' + net.containers()[0].id) def test_net_from_container(self): net_container = Container.create( @@ -138,7 +138,7 @@ def test_net_from_container(self): project.up() web = project.get_service('web') - self.assertEqual(web._get_net(), 'container:' + net_container.id) + self.assertEqual(web.net.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 177471ffa9d..b6257821dcd 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -22,6 +22,7 @@ from compose.service import build_extra_hosts from compose.service import ConfigError from compose.service import ConvergencePlan +from compose.service import Net from compose.service import Service @@ -707,17 +708,17 @@ def test_scale_sets_ports(self): self.assertEqual(list(container.inspect()['HostConfig']['PortBindings'].keys()), ['8000/tcp']) def test_network_mode_none(self): - service = self.create_service('web', net='none') + service = self.create_service('web', net=Net('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='bridge') + service = self.create_service('web', net=Net('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='host') + service = self.create_service('web', net=Net('host')) container = create_and_start_container(service) self.assertEqual(container.get('HostConfig.NetworkMode'), 'host') From 9d7ad796a38ee79f7dd2c1436cadb6d2bb17b24e Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 3 Sep 2015 14:11:44 -0400 Subject: [PATCH 1172/4072] bump requests to 2.7 to fix the ResponseNotReady() error, and add a missing default for tox posargs Signed-off-by: Daniel Nephin --- requirements.txt | 2 +- setup.py | 2 +- tox.ini | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/requirements.txt b/requirements.txt index e93db7b361d..587c04c5a4c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ docker-py==1.3.1 dockerpty==0.3.4 docopt==0.6.1 jsonschema==2.5.1 -requests==2.6.1 +requests==2.7.0 six==1.7.3 texttable==0.8.2 websocket-client==0.32.0 diff --git a/setup.py b/setup.py index e93dafc6283..737e074c811 100644 --- a/setup.py +++ b/setup.py @@ -30,7 +30,7 @@ def find_version(*file_paths): install_requires = [ 'docopt >= 0.6.1, < 0.7', 'PyYAML >= 3.10, < 4', - 'requests >= 2.6.1, < 2.7', + 'requests >= 2.6.1, < 2.8', 'texttable >= 0.8.1, < 0.9', 'websocket-client >= 0.32.0, < 1.0', 'docker-py >= 1.3.1, < 1.4', diff --git a/tox.ini b/tox.ini index 71ab4fc9c92..4cb933dd712 100644 --- a/tox.ini +++ b/tox.ini @@ -15,7 +15,7 @@ commands = --cov-report html \ --cov-report term \ --cov-config=tox.ini \ - {posargs} + {posargs:tests} [testenv:pre-commit] skip_install = True From a1ec26435cbe41ad63e765bfd973ccf306a4e54c Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 3 Sep 2015 18:30:50 -0700 Subject: [PATCH 1173/4072] Test against Docker 1.8.2 RC1 Signed-off-by: Aanand Prasad --- Dockerfile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 1d13c2b603c..ba508742de7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -66,13 +66,13 @@ RUN set -ex; \ RUN echo "en_US.UTF-8 UTF-8" > /etc/locale.gen && locale-gen ENV LANG en_US.UTF-8 -ENV ALL_DOCKER_VERSIONS 1.7.1 1.8.1 +ENV ALL_DOCKER_VERSIONS 1.7.1 1.8.2-rc1 RUN set -ex; \ curl https://get.docker.com/builds/Linux/x86_64/docker-1.7.1 -o /usr/local/bin/docker-1.7.1; \ chmod +x /usr/local/bin/docker-1.7.1; \ - curl https://get.docker.com/builds/Linux/x86_64/docker-1.8.1 -o /usr/local/bin/docker-1.8.1; \ - chmod +x /usr/local/bin/docker-1.8.1 + curl https://test.docker.com/builds/Linux/x86_64/docker-1.8.2-rc1 -o /usr/local/bin/docker-1.8.2-rc1; \ + chmod +x /usr/local/bin/docker-1.8.2-rc1 # Set the default Docker to be run RUN ln -s /usr/local/bin/docker-1.7.1 /usr/local/bin/docker From 000bf1c16a1e2181d4b6b2a580692ed3f48231c0 Mon Sep 17 00:00:00 2001 From: Charles Chan Date: Wed, 2 Sep 2015 21:06:25 -0700 Subject: [PATCH 1174/4072] Fix #1958. Remove release notes for old version of Docker Compose. Replace by link to the latest CHANGELOG in GitHub. Signed-off-by: Charles Chan --- docs/index.md | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/docs/index.md b/docs/index.md index 4342b3686d5..59bf200941b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -206,17 +206,8 @@ At this point, you have seen the basics of how Compose works. ## Release Notes -### Version 1.2.0 (April 7, 2015) - -For complete information on this release, see the [1.2.0 Milestone project page](https://github.com/docker/compose/wiki/1.2.0-Milestone-Project-Page). -In addition to bug fixes and refinements, this release adds the following: - -* The `extends` keyword, which adds the ability to extend services by sharing common configurations. For details, see -[PR #1088](https://github.com/docker/compose/pull/1088). - -* Better integration with Swarm. Swarm will now schedule inter-dependent -containers on the same host. For details, see -[PR #972](https://github.com/docker/compose/pull/972). +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 From 0484e22a84cf430871b32d1136d94d3083214f61 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 2 Sep 2015 11:07:59 -0400 Subject: [PATCH 1175/4072] Add enum34 and use it to create a ConvergenceStrategy enum. Signed-off-by: Daniel Nephin --- compose/cli/main.py | 31 ++++++++++++++--------- compose/project.py | 38 ++++++++-------------------- compose/service.py | 30 +++++++++++++++------- docs/index.md | 2 +- requirements.txt | 1 + setup.py | 3 ++- tests/integration/cli_test.py | 6 ++--- tests/integration/project_test.py | 7 ++--- tests/integration/resilience_test.py | 7 ++--- tests/integration/state_test.py | 25 ++++++------------ tests/unit/cli/main_test.py | 32 +++++++++++++++++++++++ 11 files changed, 105 insertions(+), 77 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 11d2d104c74..cf971844bca 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -19,6 +19,7 @@ from ..project import ConfigurationError from ..project import NoSuchService from ..service import BuildError +from ..service import ConvergenceStrategy from ..service import NeedsBuildError from .command import Command from .docopt_command import NoSuchCommand @@ -332,7 +333,7 @@ def run(self, project, options): project.up( service_names=deps, start_deps=True, - allow_recreate=False, + strategy=ConvergenceStrategy.never, ) tty = True @@ -515,29 +516,20 @@ def up(self, project, options): if options['--allow-insecure-ssl']: log.warn(INSECURE_SSL_WARNING) - detached = options['-d'] - monochrome = options['--no-color'] - start_deps = not options['--no-deps'] - allow_recreate = not options['--no-recreate'] - force_recreate = options['--force-recreate'] service_names = options['SERVICE'] timeout = int(options.get('--timeout') or DEFAULT_TIMEOUT) - if force_recreate and not allow_recreate: - raise UserError("--force-recreate and --no-recreate cannot be combined.") - to_attach = project.up( service_names=service_names, start_deps=start_deps, - allow_recreate=allow_recreate, - force_recreate=force_recreate, + strategy=convergence_strategy_from_opts(options), do_build=not options['--no-build'], timeout=timeout ) - if not detached: + if not options['-d']: log_printer = build_log_printer(to_attach, service_names, monochrome) attach_to_logs(project, log_printer, service_names, timeout) @@ -582,6 +574,21 @@ def version(self, project, options): print(get_version_info('full')) +def convergence_strategy_from_opts(options): + no_recreate = options['--no-recreate'] + force_recreate = options['--force-recreate'] + if force_recreate and no_recreate: + raise UserError("--force-recreate and --no-recreate cannot be combined.") + + if force_recreate: + return ConvergenceStrategy.always + + if no_recreate: + return ConvergenceStrategy.never + + return ConvergenceStrategy.changed + + def build_log_printer(containers, service_names, monochrome): if service_names: containers = [c for c in containers if c.service in service_names] diff --git a/compose/project.py b/compose/project.py index 8db20e76679..9a6e98e01d0 100644 --- a/compose/project.py +++ b/compose/project.py @@ -15,6 +15,7 @@ from .container import Container from .legacy import check_for_legacy_containers from .service import ContainerNet +from .service import ConvergenceStrategy from .service import Net from .service import Service from .service import ServiceNet @@ -266,24 +267,16 @@ def build(self, service_names=None, no_cache=False): def up(self, service_names=None, start_deps=True, - allow_recreate=True, - force_recreate=False, + strategy=ConvergenceStrategy.changed, do_build=True, timeout=DEFAULT_TIMEOUT): - if force_recreate and not allow_recreate: - raise ValueError("force_recreate and allow_recreate are in conflict") - services = self.get_services(service_names, include_deps=start_deps) for service in services: service.remove_duplicate_containers() - plans = self._get_convergence_plans( - services, - allow_recreate=allow_recreate, - force_recreate=force_recreate, - ) + plans = self._get_convergence_plans(services, strategy) return [ container @@ -295,11 +288,7 @@ def up(self, ) ] - def _get_convergence_plans(self, - services, - allow_recreate=True, - force_recreate=False): - + def _get_convergence_plans(self, services, strategy): plans = {} for service in services: @@ -310,20 +299,13 @@ def _get_convergence_plans(self, and plans[name].action == 'recreate' ] - if updated_dependencies and allow_recreate: - log.debug( - '%s has upstream changes (%s)', - service.name, ", ".join(updated_dependencies), - ) - plan = service.convergence_plan( - allow_recreate=allow_recreate, - force_recreate=True, - ) + if updated_dependencies and strategy.allows_recreate: + log.debug('%s has upstream changes (%s)', + service.name, + ", ".join(updated_dependencies)) + plan = service.convergence_plan(ConvergenceStrategy.always) else: - plan = service.convergence_plan( - allow_recreate=allow_recreate, - force_recreate=force_recreate, - ) + plan = service.convergence_plan(strategy) plans[service.name] = plan diff --git a/compose/service.py b/compose/service.py index 8dc1efa1d24..be74ca3a2e0 100644 --- a/compose/service.py +++ b/compose/service.py @@ -8,6 +8,7 @@ from collections import namedtuple from operator import attrgetter +import enum import six from docker.errors import APIError from docker.utils import create_host_config @@ -86,6 +87,20 @@ class NoSuchImageError(Exception): ConvergencePlan = namedtuple('ConvergencePlan', 'action containers') +@enum.unique +class ConvergenceStrategy(enum.Enum): + """Enumeration for all possible convergence strategies. Values refer to + when containers should be recreated. + """ + changed = 1 + always = 2 + never = 3 + + @property + def allows_recreate(self): + return self is not type(self).never + + class Service(object): def __init__( self, @@ -326,22 +341,19 @@ def image_name(self): else: return self.options['image'] - def convergence_plan(self, - allow_recreate=True, - force_recreate=False): - - if force_recreate and not allow_recreate: - raise ValueError("force_recreate and allow_recreate are in conflict") - + def convergence_plan(self, strategy=ConvergenceStrategy.changed): containers = self.containers(stopped=True) if not containers: return ConvergencePlan('create', []) - if not allow_recreate: + if strategy is ConvergenceStrategy.never: return ConvergencePlan('start', containers) - if force_recreate or self._containers_have_diverged(containers): + if ( + strategy is ConvergenceStrategy.always or + self._containers_have_diverged(containers) + ): return ConvergencePlan('recreate', containers) stopped = [c for c in containers if not c.is_running] diff --git a/docs/index.md b/docs/index.md index 59bf200941b..4e4f58dafd8 100644 --- a/docs/index.md +++ b/docs/index.md @@ -206,7 +206,7 @@ At this point, you have seen the basics of how Compose works. ## Release Notes -To see a detailed list of changes for past and current releases of Docker +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 diff --git a/requirements.txt b/requirements.txt index 587c04c5a4c..666efcd2680 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,7 @@ PyYAML==3.10 docker-py==1.3.1 dockerpty==0.3.4 docopt==0.6.1 +enum34==1.0.4 jsonschema==2.5.1 requests==2.7.0 six==1.7.3 diff --git a/setup.py b/setup.py index 737e074c811..29c5299e9d2 100644 --- a/setup.py +++ b/setup.py @@ -45,8 +45,9 @@ def find_version(*file_paths): ] -if sys.version_info[:1] < (3,): +if sys.version_info[:2] < (3, 4): tests_require.append('mock >= 1.0.1') + install_requires.append('enum34 >= 1.0.4, < 2') setup( diff --git a/tests/integration/cli_test.py b/tests/integration/cli_test.py index 9606ef41f3d..4a80d336957 100644 --- a/tests/integration/cli_test.py +++ b/tests/integration/cli_test.py @@ -223,7 +223,7 @@ def test_run_service_without_links(self, mock_stdout): self.assertTrue(config['AttachStdin']) @mock.patch('dockerpty.start') - def test_run_service_with_links(self, __): + def test_run_service_with_links(self, _): self.command.base_dir = 'tests/fixtures/links-composefile' self.command.dispatch(['run', 'web', '/bin/true'], None) db = self.project.get_service('db') @@ -232,14 +232,14 @@ def test_run_service_with_links(self, __): self.assertEqual(len(console.containers()), 0) @mock.patch('dockerpty.start') - def test_run_with_no_deps(self, __): + def test_run_with_no_deps(self, _): self.command.base_dir = 'tests/fixtures/links-composefile' self.command.dispatch(['run', '--no-deps', 'web', '/bin/true'], None) db = self.project.get_service('db') self.assertEqual(len(db.containers()), 0) @mock.patch('dockerpty.start') - def test_run_does_not_recreate_linked_containers(self, __): + def test_run_does_not_recreate_linked_containers(self, _): self.command.base_dir = 'tests/fixtures/links-composefile' self.command.dispatch(['up', '-d', 'db'], None) db = self.project.get_service('db') diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index fe63838fcb5..ad49ad10a8d 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -5,6 +5,7 @@ from compose.const import LABEL_PROJECT from compose.container import Container from compose.project import Project +from compose.service import ConvergenceStrategy def build_service_dicts(service_config): @@ -224,7 +225,7 @@ def test_recreate_preserves_volumes(self): old_db_id = project.containers()[0].id db_volume_path = project.containers()[0].get('Volumes./etc') - project.up(force_recreate=True) + project.up(strategy=ConvergenceStrategy.always) self.assertEqual(len(project.containers()), 2) db_container = [c for c in project.containers() if 'db' in c.name][0] @@ -243,7 +244,7 @@ def test_project_up_with_no_recreate_running(self): old_db_id = project.containers()[0].id db_volume_path = project.containers()[0].inspect()['Volumes']['/var/db'] - project.up(allow_recreate=False) + project.up(strategy=ConvergenceStrategy.never) self.assertEqual(len(project.containers()), 2) db_container = [c for c in project.containers() if 'db' in c.name][0] @@ -267,7 +268,7 @@ def test_project_up_with_no_recreate_stopped(self): old_db_id = old_containers[0].id db_volume_path = old_containers[0].inspect()['Volumes']['/var/db'] - project.up(allow_recreate=False) + project.up(strategy=ConvergenceStrategy.never) new_containers = project.containers(stopped=True) self.assertEqual(len(new_containers), 2) diff --git a/tests/integration/resilience_test.py b/tests/integration/resilience_test.py index 82a4680d8b0..befd72c7f82 100644 --- a/tests/integration/resilience_test.py +++ b/tests/integration/resilience_test.py @@ -4,6 +4,7 @@ from .. import mock from .testcases import DockerClientTestCase from compose.project import Project +from compose.service import ConvergenceStrategy class ResilienceTest(DockerClientTestCase): @@ -16,14 +17,14 @@ def setUp(self): self.host_path = container.get('Volumes')['/var/db'] def test_successful_recreate(self): - self.project.up(force_recreate=True) + self.project.up(strategy=ConvergenceStrategy.always) container = self.db.containers()[0] self.assertEqual(container.get('Volumes')['/var/db'], self.host_path) def test_create_failure(self): with mock.patch('compose.service.Service.create_container', crash): with self.assertRaises(Crash): - self.project.up(force_recreate=True) + self.project.up(strategy=ConvergenceStrategy.always) self.project.up() container = self.db.containers()[0] @@ -32,7 +33,7 @@ def test_create_failure(self): def test_start_failure(self): with mock.patch('compose.service.Service.start_container', crash): with self.assertRaises(Crash): - self.project.up(force_recreate=True) + self.project.up(strategy=ConvergenceStrategy.always) self.project.up() container = self.db.containers()[0] diff --git a/tests/integration/state_test.py b/tests/integration/state_test.py index d077f094d01..93d0572a085 100644 --- a/tests/integration/state_test.py +++ b/tests/integration/state_test.py @@ -12,6 +12,7 @@ from compose import config from compose.const import LABEL_CONFIG_HASH from compose.project import Project +from compose.service import ConvergenceStrategy class ProjectTestCase(DockerClientTestCase): @@ -151,7 +152,9 @@ def test_change_root_no_recreate(self): old_containers = self.run_up(self.cfg) self.cfg['db']['environment'] = {'NEW_VAR': '1'} - new_containers = self.run_up(self.cfg, allow_recreate=False) + new_containers = self.run_up( + self.cfg, + strategy=ConvergenceStrategy.never) self.assertEqual(new_containers - old_containers, set()) @@ -175,23 +178,11 @@ def test_service_removed_while_down(self): def converge(service, - allow_recreate=True, - force_recreate=False, + strategy=ConvergenceStrategy.changed, do_build=True): - """ - If a container for this service doesn't exist, create and start one. If there are - any, stop them, create+start new ones, and remove the old containers. - """ - plan = service.convergence_plan( - allow_recreate=allow_recreate, - force_recreate=force_recreate, - ) - - return service.execute_convergence_plan( - plan, - do_build=do_build, - timeout=1, - ) + """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) class ServiceStateTest(DockerClientTestCase): diff --git a/tests/unit/cli/main_test.py b/tests/unit/cli/main_test.py index e3a4629e53b..a5b369808b7 100644 --- a/tests/unit/cli/main_test.py +++ b/tests/unit/cli/main_test.py @@ -1,10 +1,13 @@ from __future__ import absolute_import from compose import container +from compose.cli.errors import UserError 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.project import Project +from compose.service import ConvergenceStrategy from tests import mock from tests import unittest @@ -55,3 +58,32 @@ def test_attach_to_logs(self): project.stop.assert_called_once_with( service_names=service_names, timeout=timeout) + + +class ConvergeStrategyFromOptsTestCase(unittest.TestCase): + + def test_invalid_opts(self): + options = {'--force-recreate': True, '--no-recreate': True} + with self.assertRaises(UserError): + convergence_strategy_from_opts(options) + + def test_always(self): + options = {'--force-recreate': True, '--no-recreate': False} + self.assertEqual( + 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), + ConvergenceStrategy.never + ) + + def test_changed(self): + options = {'--force-recreate': False, '--no-recreate': False} + self.assertEqual( + convergence_strategy_from_opts(options), + ConvergenceStrategy.changed + ) From 0f60c783fa4feba0e4a1f33ac662e8b046355fe5 Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Fri, 4 Sep 2015 17:01:44 +0100 Subject: [PATCH 1176/4072] Remove trailing white space Signed-off-by: Mazz Mosley --- docs/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/index.md b/docs/index.md index 59bf200941b..4e4f58dafd8 100644 --- a/docs/index.md +++ b/docs/index.md @@ -206,7 +206,7 @@ At this point, you have seen the basics of how Compose works. ## Release Notes -To see a detailed list of changes for past and current releases of Docker +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 From 31e8137452dfefbc3fc36c754d9839e89978542d Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Fri, 4 Sep 2015 17:20:21 +0100 Subject: [PATCH 1177/4072] Running a single test command updated Signed-off-by: Mazz Mosley --- CONTRIBUTING.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a94aa9904b1..62bf415c7ea 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -64,8 +64,8 @@ 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.integration.service_test - $ script/test tests.integration.service_test:ServiceTest.test_containers + $ script/test tests/unit/config_test.py::ConfigTest + $ script/test tests/unit/config_test.py::ConfigTest::test_load ## Finding things to work on From 6da7a9194c8d02dce71b9b70c499c3abb64ede5f Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 4 Sep 2015 17:43:12 -0700 Subject: [PATCH 1178/4072] Remove or space out suspension dots after service name for easier copy-pasting Signed-off-by: Joffrey F --- compose/service.py | 20 ++++++++++---------- compose/utils.py | 4 ++-- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/compose/service.py b/compose/service.py index 8dc1efa1d24..1a34f50c4ff 100644 --- a/compose/service.py +++ b/compose/service.py @@ -143,27 +143,27 @@ def start(self, **options): # TODO: remove these functions, project takes care of starting/stopping, def stop(self, **options): for c in self.containers(): - log.info("Stopping %s..." % c.name) + log.info("Stopping %s" % c.name) c.stop(**options) def pause(self, **options): for c in self.containers(filters={'status': 'running'}): - log.info("Pausing %s..." % c.name) + log.info("Pausing %s" % c.name) c.pause(**options) def unpause(self, **options): for c in self.containers(filters={'status': 'paused'}): - log.info("Unpausing %s..." % c.name) + log.info("Unpausing %s" % c.name) c.unpause() def kill(self, **options): for c in self.containers(): - log.info("Killing %s..." % c.name) + log.info("Killing %s" % c.name) c.kill(**options) def restart(self, **options): for c in self.containers(): - log.info("Restarting %s..." % c.name) + log.info("Restarting %s" % c.name) c.restart(**options) # end TODO @@ -289,7 +289,7 @@ def create_container(self, ) if 'name' in container_options and not quiet: - log.info("Creating %s..." % container_options['name']) + log.info("Creating %s" % container_options['name']) return Container.create(self.client, **container_options) @@ -423,7 +423,7 @@ def recreate_container(self, volumes can be copied to the new container, before the original container is removed. """ - log.info("Recreating %s..." % container.name) + log.info("Recreating %s" % container.name) try: container.stop(timeout=timeout) except APIError as e: @@ -453,7 +453,7 @@ def start_container_if_stopped(self, container): if container.is_running: return container else: - log.info("Starting %s..." % container.name) + log.info("Starting %s" % container.name) return self.start_container(container) def start_container(self, container): @@ -462,7 +462,7 @@ def start_container(self, container): def remove_duplicate_containers(self, timeout=DEFAULT_TIMEOUT): for c in self.duplicate_containers(): - log.info('Removing %s...' % c.name) + log.info('Removing %s' % c.name) c.stop(timeout=timeout) c.remove() @@ -689,7 +689,7 @@ def _get_container_host_config(self, override_options, one_off=False): ) def build(self, no_cache=False): - log.info('Building %s...' % self.name) + log.info('Building %s' % self.name) path = self.options['build'] # python2 os.path() doesn't support unicode, so we need to encode it to diff --git a/compose/utils.py b/compose/utils.py index 30284f97bdb..690c5ffd530 100644 --- a/compose/utils.py +++ b/compose/utils.py @@ -90,13 +90,13 @@ def write_out_msg(stream, lines, msg_index, msg, status="done"): stream.write("%c[%dA" % (27, diff)) # erase stream.write("%c[2K\r" % 27) - stream.write("{} {}... {}\n".format(msg, obj_index, status)) + stream.write("{} {} ... {}\n".format(msg, obj_index, status)) # move back down stream.write("%c[%dB" % (27, diff)) else: diff = 0 lines.append(obj_index) - stream.write("{} {}... \r\n".format(msg, obj_index)) + stream.write("{} {} ... \r\n".format(msg, obj_index)) stream.flush() From 2468235472eb0a849dcad7a7838488cc9df6f8dc Mon Sep 17 00:00:00 2001 From: Lachlan Pease Date: Sun, 6 Sep 2015 11:55:36 +1000 Subject: [PATCH 1179/4072] Added support for IPC namespaces, fixes GH-1689 Signed-off-by: Lachlan Pease --- compose/config/config.py | 1 + compose/service.py | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/compose/config/config.py b/compose/config/config.py index 8df45b8a9fc..c23a541eead 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -36,6 +36,7 @@ 'extra_hosts', 'hostname', 'image', + 'ipc', 'labels', 'links', 'mac_address', diff --git a/compose/service.py b/compose/service.py index b48f2e14bd6..bf65888c781 100644 --- a/compose/service.py +++ b/compose/service.py @@ -44,6 +44,7 @@ 'dns_search', 'env_file', 'extra_hosts', + 'ipc', 'read_only', 'net', 'log_driver', @@ -696,7 +697,8 @@ def _get_container_host_config(self, override_options, one_off=False): extra_hosts=extra_hosts, read_only=read_only, pid_mode=pid, - security_opt=security_opt + security_opt=security_opt, + ipc_mode=options.get('ipc') ) def build(self, no_cache=False): From 67957318ed5080fe2babc3b704583f9c38bd5ea8 Mon Sep 17 00:00:00 2001 From: Lachlan Pease Date: Sun, 6 Sep 2015 12:16:12 +1000 Subject: [PATCH 1180/4072] Added IPC spec to fields_schema.json Signed-off-by: Lachlan Pease --- compose/config/fields_schema.json | 1 + 1 file changed, 1 insertion(+) diff --git a/compose/config/fields_schema.json b/compose/config/fields_schema.json index 2a122b7a317..d25b3fa2362 100644 --- a/compose/config/fields_schema.json +++ b/compose/config/fields_schema.json @@ -59,6 +59,7 @@ "external_links": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, "hostname": {"type": "string"}, "image": {"type": "string"}, + "ipc": {"type": "string"}, "labels": {"$ref": "#/definitions/list_or_dict"}, "links": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, From d83d6306c94c45469f86b7ae08089b8b77514be4 Mon Sep 17 00:00:00 2001 From: Karol Duleba Date: Wed, 12 Aug 2015 23:16:42 +0100 Subject: [PATCH 1181/4072] Use custom container name in logs. Fixes #1851 Signed-off-by: Karol Duleba --- compose/container.py | 8 +++++++- tests/unit/container_test.py | 6 ++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/compose/container.py b/compose/container.py index f2d8a403e12..51b62589097 100644 --- a/compose/container.py +++ b/compose/container.py @@ -6,6 +6,7 @@ import six from .const import LABEL_CONTAINER_NUMBER +from .const import LABEL_PROJECT from .const import LABEL_SERVICE @@ -70,7 +71,12 @@ def service(self): @property def name_without_project(self): - return '{0}_{1}'.format(self.service, self.number) + project = self.labels.get(LABEL_PROJECT) + + if self.name.startswith('{0}_{1}'.format(project, self.service)): + return '{0}_{1}'.format(self.service, self.number) + else: + return self.name @property def number(self): diff --git a/tests/unit/container_test.py b/tests/unit/container_test.py index 5637330cd42..5f7bf1ea7e5 100644 --- a/tests/unit/container_test.py +++ b/tests/unit/container_test.py @@ -83,9 +83,15 @@ def test_name(self): self.assertEqual(container.name, "composetest_db_1") def test_name_without_project(self): + self.container_dict['Name'] = "/composetest_web_7" container = Container(None, self.container_dict, has_been_inspected=True) self.assertEqual(container.name_without_project, "web_7") + def test_name_without_project_custom_container_name(self): + self.container_dict['Name'] = "/custom_name_of_container" + container = Container(None, self.container_dict, has_been_inspected=True) + self.assertEqual(container.name_without_project, "custom_name_of_container") + def test_inspect_if_not_inspected(self): mock_client = mock.create_autospec(docker.Client) container = Container(mock_client, dict(Id="the_id")) From 866979c57bae31d42c3092bdb089a8b11907e4c6 Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Mon, 7 Sep 2015 17:26:38 +0100 Subject: [PATCH 1182/4072] Allow entrypoint to be a list or string Signed-off-by: Mazz Mosley --- compose/config/fields_schema.json | 2 +- tests/unit/config_test.py | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/compose/config/fields_schema.json b/compose/config/fields_schema.json index f03ef7110c5..a82dd397dbd 100644 --- a/compose/config/fields_schema.json +++ b/compose/config/fields_schema.json @@ -32,7 +32,7 @@ "dns_search": {"$ref": "#/definitions/string_or_list"}, "dockerfile": {"type": "string"}, "domainname": {"type": "string"}, - "entrypoint": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "entrypoint": {"$ref": "#/definitions/string_or_list"}, "env_file": {"$ref": "#/definitions/string_or_list"}, "environment": { diff --git a/tests/unit/config_test.py b/tests/unit/config_test.py index 3f602fb5931..aeebc049f67 100644 --- a/tests/unit/config_test.py +++ b/tests/unit/config_test.py @@ -254,6 +254,21 @@ def test_valid_config_which_allows_two_type_definitions(self): ) self.assertEqual(service[0]['expose'], expose) + def test_valid_config_oneof_string_or_list(self): + entrypoint_values = [["sh"], "sh"] + for entrypoint in entrypoint_values: + service = config.load( + config.ConfigDetails( + {'web': { + 'image': 'busybox', + 'entrypoint': entrypoint + }}, + 'working_dir', + 'filename.yml' + ) + ) + self.assertEqual(service[0]['entrypoint'], entrypoint) + class InterpolationTest(unittest.TestCase): @mock.patch.dict(os.environ) From b33dd3bc01a6552a8803b0c4379eb27a1b761a51 Mon Sep 17 00:00:00 2001 From: "Lucas N. Munhoz" Date: Tue, 8 Sep 2015 09:53:10 -0300 Subject: [PATCH 1183/4072] Fix error message and class name from Boot2Docker to DockerMachine Signed-off-by: Lucas N. Munhoz --- compose/cli/command.py | 2 +- compose/cli/errors.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/compose/cli/command.py b/compose/cli/command.py index 67176df2719..ca2d96ea6cd 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -41,7 +41,7 @@ def dispatch(self, *args, **kwargs): else: raise errors.DockerNotFoundGeneric() elif call_silently(['which', 'boot2docker']) == 0: - raise errors.ConnectionErrorBoot2Docker() + raise errors.ConnectionErrorDockerMachine() else: raise errors.ConnectionErrorGeneric(self.get_client().base_url) diff --git a/compose/cli/errors.py b/compose/cli/errors.py index 0569c1a0dd2..244897f8abc 100644 --- a/compose/cli/errors.py +++ b/compose/cli/errors.py @@ -40,10 +40,10 @@ def __init__(self): """) -class ConnectionErrorBoot2Docker(UserError): +class ConnectionErrorDockerMachine(UserError): def __init__(self): - super(ConnectionErrorBoot2Docker, self).__init__(""" - Couldn't connect to Docker daemon - you might need to run `boot2docker up`. + super(ConnectionErrorDockerMachine, self).__init__(""" + Couldn't connect to Docker daemon - you might need to run `docker-machine start default`. """) From ad46757bafed98058f244960ad21edeadcd8b255 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 8 Sep 2015 19:44:25 -0400 Subject: [PATCH 1184/4072] Add more github label areas. Signed-off-by: Daniel Nephin --- .pre-commit-config.yaml | 1 + project/ISSUE-TRIAGE.md | 22 ++++++++++++---------- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 832be6ab8db..8913a05fd21 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -16,3 +16,4 @@ sha: 3d86483455ab5bd06cc1069fdd5ac57be5463f10 hooks: - id: reorder-python-imports + language_version: 'python2.7' diff --git a/project/ISSUE-TRIAGE.md b/project/ISSUE-TRIAGE.md index bcedbb43598..58312a60374 100644 --- a/project/ISSUE-TRIAGE.md +++ b/project/ISSUE-TRIAGE.md @@ -20,13 +20,15 @@ The following labels are provided in additional to the standard labels: Most issues should fit into one of the following functional areas: -| Area | -|-------------| -| area/build | -| area/cli | -| area/config | -| area/logs | -| area/run | -| area/scale | -| area/tests | -| area/up | +| Area | +|----------------| +| area/build | +| area/cli | +| area/config | +| area/logs | +| area/packaging | +| area/run | +| area/scale | +| area/tests | +| area/up | +| area/volumes | From 7223d5cee06b434486e83d283df198f1b62edf93 Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Wed, 9 Sep 2015 16:23:54 +0100 Subject: [PATCH 1185/4072] Remove mistaken field detach is a run param, not a config param. Oops, sorry! Signed-off-by: Mazz Mosley --- compose/config/fields_schema.json | 1 - 1 file changed, 1 deletion(-) diff --git a/compose/config/fields_schema.json b/compose/config/fields_schema.json index a82dd397dbd..6c73a8f3151 100644 --- a/compose/config/fields_schema.json +++ b/compose/config/fields_schema.json @@ -26,7 +26,6 @@ ] }, "cpuset": {"type": "string"}, - "detach": {"type": "boolean"}, "devices": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, "dns": {"$ref": "#/definitions/string_or_list"}, "dns_search": {"$ref": "#/definitions/string_or_list"}, From 4bed5de291307f4099b6abb564bc8c2cf472dbc3 Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Wed, 9 Sep 2015 16:04:27 +0100 Subject: [PATCH 1186/4072] Remove item unique constraint for command The command value can be a list, which would be a Unix command-line invocation broken up into individual values, thus needing the ability to have non unique values. Signed-off-by: Mazz Mosley --- compose/config/fields_schema.json | 7 ++++++- tests/unit/config_test.py | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/compose/config/fields_schema.json b/compose/config/fields_schema.json index a82dd397dbd..bc033f2d8ea 100644 --- a/compose/config/fields_schema.json +++ b/compose/config/fields_schema.json @@ -17,7 +17,12 @@ "build": {"type": "string"}, "cap_add": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, "cap_drop": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, - "command": {"$ref": "#/definitions/string_or_list"}, + "command": { + "oneOf": [ + {"type": "string"}, + {"type": "array", "items": {"type": "string"}} + ] + }, "container_name": {"type": "string"}, "cpu_shares": { "oneOf": [ diff --git a/tests/unit/config_test.py b/tests/unit/config_test.py index aeebc049f67..9d67a891778 100644 --- a/tests/unit/config_test.py +++ b/tests/unit/config_test.py @@ -183,7 +183,7 @@ def test_invalid_config_not_unique_items(self): ) def test_invalid_list_of_strings_format(self): - expected_error_msg = "'command' contains an invalid type, valid types are string or list of strings" + expected_error_msg = "'command' contains an invalid type, valid types are string or array" with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): config.load( config.ConfigDetails( From a372275c6ec74eb96c4d94e9821d8811caad5bba Mon Sep 17 00:00:00 2001 From: Nick H Date: Tue, 1 Sep 2015 12:40:24 -0600 Subject: [PATCH 1187/4072] Allow for user relative paths '~/' in a path currently doesnt work, you get the following error: [Errno 2] No such file or directory: u'/home/USER/folder/~/some/path/.yml' Signed-off-by: Nick H --- compose/config/config.py | 2 +- tests/unit/config_test.py | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/compose/config/config.py b/compose/config/config.py index cfa8086f09c..d5ee486bf77 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -499,7 +499,7 @@ def split_label(label): def expand_path(working_dir, path): - return os.path.abspath(os.path.join(working_dir, path)) + return os.path.abspath(os.path.join(working_dir, os.path.expanduser(path))) def to_list(value): diff --git a/tests/unit/config_test.py b/tests/unit/config_test.py index e488ceb5274..ddcad76e406 100644 --- a/tests/unit/config_test.py +++ b/tests/unit/config_test.py @@ -950,6 +950,27 @@ def test_load_throws_error_when_base_service_does_not_exist(self): load_from_filename('tests/fixtures/extends/nonexistent-service.yml') +class ExpandPathTest(unittest.TestCase): + working_dir = '/home/user/somedir' + + def test_expand_path_normal(self): + result = config.expand_path(self.working_dir, 'myfile') + self.assertEqual(result, self.working_dir + '/' + 'myfile') + + def test_expand_path_absolute(self): + abs_path = '/home/user/otherdir/somefile' + result = config.expand_path(self.working_dir, abs_path) + self.assertEqual(result, abs_path) + + def test_expand_path_with_tilde(self): + test_path = '~/otherdir/somefile' + with mock.patch.dict(os.environ): + os.environ['HOME'] = user_path = '/home/user/' + result = config.expand_path(self.working_dir, test_path) + + self.assertEqual(result, user_path + 'otherdir/somefile') + + class BuildPathTest(unittest.TestCase): def setUp(self): self.abs_context_path = os.path.join(os.getcwd(), 'tests/fixtures/build-ctx') From 851129de6c38be79c3c065104a2a369d74a8dc2b Mon Sep 17 00:00:00 2001 From: Lachlan Pease Date: Thu, 10 Sep 2015 23:34:07 +1000 Subject: [PATCH 1188/4072] Added documentation for IPC config Signed-off-by: Lachlan Pease --- docs/yml.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/yml.md b/docs/yml.md index 3ece0264945..0524940f1fc 100644 --- a/docs/yml.md +++ b/docs/yml.md @@ -373,7 +373,7 @@ Override the default labeling scheme for each container. - label:user:USER - label:role:ROLE -### working\_dir, entrypoint, user, hostname, domainname, mac\_address, mem\_limit, memswap\_limit, privileged, restart, stdin\_open, tty, cpu\_shares, cpuset, read\_only, volume\_driver +### working\_dir, entrypoint, user, hostname, domainname, mac\_address, mem\_limit, memswap\_limit, privileged, ipc, restart, stdin\_open, tty, cpu\_shares, cpuset, read\_only, volume\_driver Each of these is a single value, analogous to its [docker run](https://docs.docker.com/reference/run/) counterpart. @@ -394,6 +394,8 @@ Each of these is a single value, analogous to its memswap_limit: 2000000000 privileged: true + ipc: host + restart: always stdin_open: true From 860b304f4afd094b3c0ffbb6964e854f79e7b582 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 2 Sep 2015 16:30:09 -0400 Subject: [PATCH 1189/4072] Add COMPOSE_API_VERSION to the docs Signed-off-by: Daniel Nephin --- docs/reference/overview.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/reference/overview.md b/docs/reference/overview.md index 52598737d6b..f5d778fd13e 100644 --- a/docs/reference/overview.md +++ b/docs/reference/overview.md @@ -31,6 +31,26 @@ Setting this is optional. If you do not set this, the `COMPOSE_PROJECT_NAME` def 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. +### 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`. From 4bdf57ead8078e9737c87c88b0910d5fa471938b Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 2 Sep 2015 16:55:48 -0400 Subject: [PATCH 1190/4072] Add a where to go next section to the main index page for compose Signed-off-by: Daniel Nephin --- docs/index.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/index.md b/docs/index.md index 4e4f58dafd8..72de04d3422 100644 --- a/docs/index.md +++ b/docs/index.md @@ -222,3 +222,14 @@ like-minded individuals, we have a number of open channels for communication. * 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/project/get-help/). + +## Where to go next + +- [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) +- [Yaml file reference](yml.md) +- [Compose environment variables](env.md) +- [Compose command line completion](completion.md) From 1eb925ee3198596b44f67fe588ca6cfdff9c9521 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 2 Sep 2015 16:59:04 -0400 Subject: [PATCH 1191/4072] Link between pages in the CLI reference section Signed-off-by: Daniel Nephin --- docs/reference/docker-compose.md | 5 +++++ docs/reference/index.md | 5 +++++ docs/reference/overview.md | 12 +++++++----- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/docs/reference/docker-compose.md b/docs/reference/docker-compose.md index 6c46b31d180..b43055fbeab 100644 --- a/docs/reference/docker-compose.md +++ b/docs/reference/docker-compose.md @@ -55,3 +55,8 @@ used all paths in the configuration are relative to the current working directory. Each configuration can 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. + +## Where to go next + +* [CLI environment variables](overview.md) +* [Command line reference](index.md) diff --git a/docs/reference/index.md b/docs/reference/index.md index e7a07b09aaa..7a1fb9b4448 100644 --- a/docs/reference/index.md +++ b/docs/reference/index.md @@ -27,3 +27,8 @@ The following pages describe the usage information for the [docker-compose](/ref * [rm](/reference/rm.md) * [scale](/reference/scale.md) * [stop](/reference/stop.md) + +## Where to go next + +* [CLI environment variables](overview.md) +* [docker-compose Command](docker-compose.md) diff --git a/docs/reference/overview.md b/docs/reference/overview.md index f5d778fd13e..002607118d8 100644 --- a/docs/reference/overview.md +++ b/docs/reference/overview.md @@ -14,6 +14,13 @@ weight=-2 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 Several environment variables are available for you to configure the Docker Compose command-line behaviour. @@ -70,11 +77,6 @@ Configures the time (in seconds) a request to the Docker daemon is allowed to ha it failed. Defaults to 60 seconds. - - - - - ## Compose documentation - [User guide](/) From e80f0bdf86225d172025ac70280eaf869518f5b8 Mon Sep 17 00:00:00 2001 From: Christophe Labouisse Date: Thu, 10 Sep 2015 23:41:22 +0200 Subject: [PATCH 1192/4072] Fix type for `tty` & `stdin_open` Signed-off-by: Christophe Labouisse --- compose/config/fields_schema.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/compose/config/fields_schema.json b/compose/config/fields_schema.json index 38dfd2e368a..5c7322517d5 100644 --- a/compose/config/fields_schema.json +++ b/compose/config/fields_schema.json @@ -111,8 +111,8 @@ "read_only": {"type": "boolean"}, "restart": {"type": "string"}, "security_opt": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, - "stdin_open": {"type": "string"}, - "tty": {"type": "string"}, + "stdin_open": {"type": "boolean"}, + "tty": {"type": "boolean"}, "user": {"type": "string"}, "volumes": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, "volume_driver": {"type": "string"}, From a9b1f15f92b200f4c2ccbd5d6d0ce3e2abc82133 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 14 Aug 2015 11:15:22 +0100 Subject: [PATCH 1193/4072] Fix volume path warning Signed-off-by: Aanand Prasad --- compose/config.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/compose/config.py b/compose/config.py index aa2db2c71d7..365fc3c27ff 100644 --- a/compose/config.py +++ b/compose/config.py @@ -440,12 +440,12 @@ def resolve_volume_path(volume, working_dir, service_name): if not any(host_path.startswith(c) for c in PATH_START_CHARS): log.warn( - 'Warning: the mapping "{0}" in the volumes config for ' - 'service "{1}" is ambiguous. In a future version of Docker, ' + 'Warning: the mapping "{0}:{1}" in the volumes config for ' + 'service "{2}" is ambiguous. In a future version of Docker, ' 'it will designate a "named" volume ' '(see https://github.com/docker/docker/pull/14242). ' - 'To prevent unexpected behaviour, change it to "./{0}"' - .format(volume, service_name) + 'To prevent unexpected behaviour, change it to "./{0}:{1}"' + .format(host_path, container_path, service_name) ) return "%s:%s" % (expand_path(working_dir, host_path), container_path) From 294b9742be5da1f37d520ff8406fbf00b12387de Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Mon, 17 Aug 2015 16:31:57 +0100 Subject: [PATCH 1194/4072] Handle all exceptions If we get back an error that wasn't an APIError, it was causing the thread to hang. This catch all, while I appreciate feels risky to have a catch all, is better than not catching and silently failing, with a never ending thread. If something worse than an APIError has gone wrong, we want to stop the incredible journey of what we're doing. Signed-off-by: Mazz Mosley --- compose/utils.py | 7 +++++++ tests/integration/service_test.py | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/compose/utils.py b/compose/utils.py index 4c7f94c5769..61d6d802438 100644 --- a/compose/utils.py +++ b/compose/utils.py @@ -32,6 +32,10 @@ def inner_execute_function(an_callable, parameter, msg_index): except APIError as e: errors[msg_index] = e.explanation result = "error" + except Exception as e: + errors[msg_index] = e + result = 'unexpected_exception' + q.put((msg_index, result)) for an_object in objects: @@ -48,6 +52,9 @@ def inner_execute_function(an_callable, parameter, msg_index): while done < total_to_execute: try: msg_index, result = q.get(timeout=1) + + if result == 'unexpected_exception': + raise errors[msg_index] if result == 'error': write_out_msg(stream, lines, msg_index, msg, status='error') else: diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 8856d0245fe..70ca758a56d 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -672,6 +672,25 @@ def test_scale_with_api_returns_errors(self, mock_stdout): self.assertTrue(service.containers()[0].is_running) self.assertIn("ERROR: for 2 Boom", mock_stdout.getvalue()) + @patch('sys.stdout', new_callable=StringIO) + def test_scale_with_api_returns_unexpected_exception(self, mock_stdout): + """ + Test that when scaling if the API returns an error, that is not of type + APIError, that error is re-raised. + """ + service = self.create_service('web') + next_number = service._next_container_number() + service.create_container(number=next_number, quiet=True) + + with patch( + 'compose.container.Container.create', + side_effect=ValueError("BOOM")): + with self.assertRaises(ValueError): + service.scale(3) + + self.assertEqual(len(service.containers()), 1) + self.assertTrue(service.containers()[0].is_running) + @patch('compose.service.log') def test_scale_with_desired_number_already_achieved(self, mock_log): """ From 6a23491fa943dec48d7c57fbd02dd931ad3ad0ec Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 26 Aug 2015 16:21:31 -0400 Subject: [PATCH 1195/4072] Resolves #1856, fix regression in #1645. Includes some refactoring to make testing easier. Signed-off-by: Daniel Nephin --- compose/cli/main.py | 36 ++++++++++++++++++---------- compose/container.py | 6 ++++- tests/unit/cli/main_test.py | 47 +++++++++++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 14 deletions(-) create mode 100644 tests/unit/cli/main_test.py diff --git a/compose/cli/main.py b/compose/cli/main.py index 56f6c05052d..1fb62de69db 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -496,19 +496,8 @@ def up(self, project, options): ) if not detached: - print("Attaching to", list_containers(to_attach)) - log_printer = LogPrinter(to_attach, attach_params={"logs": True}, monochrome=monochrome) - - try: - log_printer.run() - finally: - def handler(signal, frame): - project.kill(service_names=service_names) - sys.exit(0) - signal.signal(signal.SIGINT, handler) - - print("Gracefully stopping... (press Ctrl+C again to force)") - project.stop(service_names=service_names, timeout=timeout) + log_printer = build_log_printer(to_attach, service_names, monochrome) + attach_to_logs(project, log_printer, service_names, timeout) def migrate_to_labels(self, project, _options): """ @@ -551,5 +540,26 @@ def version(self, project, options): print(get_version_info('full')) +def build_log_printer(containers, service_names, monochrome): + return LogPrinter( + [c for c in containers if c.service in service_names], + attach_params={"logs": True}, + monochrome=monochrome) + + +def attach_to_logs(project, log_printer, service_names, timeout): + print("Attaching to", list_containers(log_printer.containers)) + try: + log_printer.run() + finally: + def handler(signal, frame): + project.kill(service_names=service_names) + sys.exit(0) + signal.signal(signal.SIGINT, handler) + + print("Gracefully stopping... (press Ctrl+C again to force)") + project.stop(service_names=service_names, timeout=timeout) + + def list_containers(containers): return ", ".join(c.name for c in containers) diff --git a/compose/container.py b/compose/container.py index 40aea98a456..6f88b41dc63 100644 --- a/compose/container.py +++ b/compose/container.py @@ -62,9 +62,13 @@ def short_id(self): def name(self): return self.dictionary['Name'][1:] + @property + def service(self): + return self.labels.get(LABEL_SERVICE) + @property def name_without_project(self): - return '{0}_{1}'.format(self.labels.get(LABEL_SERVICE), self.number) + return '{0}_{1}'.format(self.service, self.number) @property def number(self): diff --git a/tests/unit/cli/main_test.py b/tests/unit/cli/main_test.py new file mode 100644 index 00000000000..817e8f49b6a --- /dev/null +++ b/tests/unit/cli/main_test.py @@ -0,0 +1,47 @@ +from __future__ import absolute_import + +from compose import container +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.project import Project +from tests import mock +from tests import unittest + + +def mock_container(service, number): + return mock.create_autospec( + container.Container, + service=service, + number=number, + name_without_project='{0}_{1}'.format(service, number)) + + +class CLIMainTestCase(unittest.TestCase): + + def test_build_log_printer(self): + containers = [ + mock_container('web', 1), + mock_container('web', 2), + mock_container('db', 1), + mock_container('other', 1), + mock_container('another', 1), + ] + service_names = ['web', 'db'] + log_printer = build_log_printer(containers, service_names, True) + self.assertEqual(log_printer.containers, containers[:3]) + + 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.signal', autospec=True) as mock_signal: + attach_to_logs(project, log_printer, service_names, timeout) + + mock_signal.signal.assert_called_once_with(mock_signal.SIGINT, mock.ANY) + log_printer.run.assert_called_once_with() + project.stop.assert_called_once_with( + service_names=service_names, + timeout=timeout) From 9cb2770da4e7dbd05206194edde86ab06238b4dc Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 1 Sep 2015 18:56:40 -0400 Subject: [PATCH 1196/4072] Make external_links a regular service.option so that it's part of the config hash Signed-off-by: Daniel Nephin --- compose/service.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/compose/service.py b/compose/service.py index ab7d154e6ea..34a19a59911 100644 --- a/compose/service.py +++ b/compose/service.py @@ -83,7 +83,16 @@ class NoSuchImageError(Exception): class Service(object): - def __init__(self, name, client=None, project='default', links=None, external_links=None, volumes_from=None, net=None, **options): + def __init__( + self, + name, + client=None, + project='default', + links=None, + volumes_from=None, + net=None, + **options + ): if not re.match('^%s+$' % VALID_NAME_CHARS, name): raise ConfigError('Invalid service name "%s" - only %s are allowed' % (name, VALID_NAME_CHARS)) if not re.match('^%s+$' % VALID_NAME_CHARS, project): @@ -97,7 +106,6 @@ def __init__(self, name, client=None, project='default', links=None, external_li self.client = client self.project = project self.links = links or [] - self.external_links = external_links or [] self.volumes_from = volumes_from or [] self.net = net or None self.options = options @@ -517,7 +525,7 @@ def _get_links(self, link_to_self): links.append((container.name, self.name)) links.append((container.name, container.name)) links.append((container.name, container.name_without_project)) - for external_link in self.external_links: + for external_link in self.options.get('external_links') or []: if ':' not in external_link: link_name = external_link else: From 8d4c724c2dd3c61f7db9a4e411833a8d516aa521 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 1 Sep 2015 19:21:22 -0400 Subject: [PATCH 1197/4072] Sort config keys Signed-off-by: Daniel Nephin --- compose/config.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/compose/config.py b/compose/config.py index 365fc3c27ff..75391e9b82e 100644 --- a/compose/config.py +++ b/compose/config.py @@ -12,9 +12,9 @@ DOCKER_CONFIG_KEYS = [ 'cap_add', 'cap_drop', + 'command', 'cpu_shares', 'cpuset', - 'command', 'detach', 'devices', 'dns', @@ -28,12 +28,12 @@ 'image', 'labels', 'links', + 'log_driver', + 'log_opt', 'mac_address', 'mem_limit', 'memswap_limit', 'net', - 'log_driver', - 'log_opt', 'pid', 'ports', 'privileged', From cf2dbf55b897802f615079e2ae175194a3502d61 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 1 Sep 2015 19:54:49 -0400 Subject: [PATCH 1198/4072] Cleanup some project logic. Signed-off-by: Daniel Nephin --- compose/project.py | 50 ++++++++++++++++++++++++++-------------------- 1 file changed, 28 insertions(+), 22 deletions(-) diff --git a/compose/project.py b/compose/project.py index 6d86a4a8729..7df79685a78 100644 --- a/compose/project.py +++ b/compose/project.py @@ -81,8 +81,14 @@ def from_dicts(cls, name, service_dicts, client): volumes_from = project.get_volumes_from(service_dict) net = project.get_net(service_dict) - project.services.append(Service(client=client, project=name, links=links, net=net, - volumes_from=volumes_from, **service_dict)) + project.services.append( + Service( + client=client, + project=name, + links=links, + net=net, + volumes_from=volumes_from, + **service_dict)) return project @property @@ -172,26 +178,26 @@ def get_volumes_from(self, service_dict): return volumes_from def get_net(self, service_dict): - if 'net' in service_dict: - net_name = get_service_name_from_net(service_dict.get('net')) - - if net_name: - try: - net = self.get_service(net_name) - except NoSuchService: - try: - net = 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)) - else: - net = service_dict['net'] - - del service_dict['net'] - - else: - net = None - - return net + net = service_dict.pop('net', None) + if not net: + return + + net_name = get_service_name_from_net(net) + if not net_name: + return net + + try: + return self.get_service(net_name) + except NoSuchService: + pass + try: + return 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)) def start(self, service_names=None, **options): for service in self.get_services(service_names): From d92f323e6dd1b9dd210793263d81bd372f82b60d Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 1 Sep 2015 19:40:15 -0400 Subject: [PATCH 1199/4072] Fixes #1757 - include all service properties in the config_dict() Signed-off-by: Daniel Nephin --- compose/service.py | 3 ++ tests/integration/state_test.py | 22 +++++++++ tests/unit/service_test.py | 88 +++++++++++++++++++++++++++++++++ 3 files changed, 113 insertions(+) diff --git a/compose/service.py b/compose/service.py index 34a19a59911..a567a54c949 100644 --- a/compose/service.py +++ b/compose/service.py @@ -477,6 +477,9 @@ def config_dict(self): return { 'options': self.options, 'image_id': self.image()['Id'], + 'links': [(service.name, alias) for service, alias in self.links], + 'net': self.get_net_name() or getattr(self.net, 'id', self.net), + 'volumes_from': self.get_volumes_from_names(), } def get_dependency_names(self): diff --git a/tests/integration/state_test.py b/tests/integration/state_test.py index b124b19ffc6..b254376171c 100644 --- a/tests/integration/state_test.py +++ b/tests/integration/state_test.py @@ -1,3 +1,7 @@ +""" +Integration tests which cover state convergence (aka smart recreate) performed +by `docker-compose up`. +""" from __future__ import unicode_literals import tempfile import shutil @@ -151,6 +155,24 @@ def test_change_root_no_recreate(self): self.assertEqual(new_containers - old_containers, set()) + def test_service_removed_while_down(self): + next_cfg = { + 'web': { + 'image': 'busybox:latest', + 'command': 'tail -f /dev/null', + }, + 'nginx': self.cfg['nginx'], + } + + containers = self.run_up(self.cfg) + self.assertEqual(len(containers), 3) + + project = self.make_project(self.cfg) + project.stop(timeout=1) + + containers = self.run_up(next_cfg) + self.assertEqual(len(containers), 2) + def converge(service, allow_recreate=True, diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 7e5266dd79a..9979c8f123b 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -221,6 +221,53 @@ def test_split_domainname_weird(self): self.assertEqual(opts['hostname'], 'name.sub', 'hostname') self.assertEqual(opts['domainname'], 'domain.tld', 'domainname') + def test_get_container_create_options_with_name_option(self): + service = Service( + 'foo', + image='foo', + client=self.mock_client, + container_name='foo1') + name = 'the_new_name' + opts = service._get_container_create_options( + {'name': name}, + 1, + one_off=True) + self.assertEqual(opts['name'], name) + + def test_get_container_create_options_does_not_mutate_options(self): + labels = {'thing': 'real'} + environment = {'also': 'real'} + service = Service( + 'foo', + image='foo', + labels=dict(labels), + client=self.mock_client, + environment=dict(environment), + ) + self.mock_client.inspect_image.return_value = {'Id': 'abcd'} + prev_container = mock.Mock( + id='ababab', + image_config={'ContainerConfig': {}}) + + opts = service._get_container_create_options( + {}, + 1, + previous_container=prev_container) + + self.assertEqual(service.options['labels'], labels) + self.assertEqual(service.options['environment'], environment) + + self.assertEqual( + opts['labels'][LABEL_CONFIG_HASH], + '3c85881a8903b9d73a06c41860c8be08acce1494ab4cf8408375966dccd714de') + self.assertEqual( + opts['environment'], + { + 'affinity:container': '=ababab', + 'also': 'real', + } + ) + def test_get_container_not_found(self): self.mock_client.containers.return_value = [] service = Service('foo', client=self.mock_client, image='foo') @@ -340,6 +387,47 @@ def test_build_does_not_pull(self): self.assertEqual(self.mock_client.build.call_count, 1) self.assertFalse(self.mock_client.build.call_args[1]['pull']) + def test_config_dict(self): + self.mock_client.inspect_image.return_value = {'Id': 'abcd'} + service = Service( + 'foo', + image='example.com/foo', + client=self.mock_client, + net=Service('other'), + links=[(Service('one'), 'one')], + volumes_from=[Service('two')]) + + config_dict = service.config_dict() + expected = { + 'image_id': 'abcd', + 'options': {'image': 'example.com/foo'}, + 'links': [('one', 'one')], + 'net': 'other', + 'volumes_from': ['two'], + } + self.assertEqual(config_dict, expected) + + def test_config_dict_with_net_from_container(self): + self.mock_client.inspect_image.return_value = {'Id': 'abcd'} + container = Container( + self.mock_client, + {'Id': 'aaabbb', 'Name': '/foo_1'}) + service = Service( + 'foo', + image='example.com/foo', + client=self.mock_client, + net=container) + + config_dict = service.config_dict() + expected = { + 'image_id': 'abcd', + 'options': {'image': 'example.com/foo'}, + 'links': [], + 'net': 'aaabbb', + 'volumes_from': [], + } + self.assertEqual(config_dict, expected) + def mock_get_image(images): if images: From 805f6a76835567cd6b66b45310c7049946ad2f3a Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 3 Sep 2015 13:02:46 -0400 Subject: [PATCH 1200/4072] Refactor network_mode logic out of Service. Signed-off-by: Daniel Nephin --- compose/project.py | 11 +++-- compose/service.py | 88 +++++++++++++++++++++++++------------- tests/unit/project_test.py | 6 +-- tests/unit/service_test.py | 79 +++++++++++++++++++++++++++------- 4 files changed, 133 insertions(+), 51 deletions(-) diff --git a/compose/project.py b/compose/project.py index 7df79685a78..abc3132a27e 100644 --- a/compose/project.py +++ b/compose/project.py @@ -9,7 +9,10 @@ from .const import DEFAULT_TIMEOUT, LABEL_PROJECT, LABEL_SERVICE, LABEL_ONE_OFF from .container import Container from .legacy import check_for_legacy_containers +from .service import ContainerNet +from .service import Net from .service import Service +from .service import ServiceNet from .utils import parallel_execute log = logging.getLogger(__name__) @@ -180,18 +183,18 @@ def get_volumes_from(self, service_dict): def get_net(self, service_dict): net = service_dict.pop('net', None) if not net: - return + return Net(None) net_name = get_service_name_from_net(net) if not net_name: - return net + return Net(net) try: - return self.get_service(net_name) + return ServiceNet(self.get_service(net_name)) except NoSuchService: pass try: - return Container.from_id(self.client, net_name) + return ContainerNet(Container.from_id(self.client, net_name)) except APIError: raise ConfigurationError( 'Service "%s" is trying to use the network of "%s", ' diff --git a/compose/service.py b/compose/service.py index a567a54c949..25584fe2cd0 100644 --- a/compose/service.py +++ b/compose/service.py @@ -107,7 +107,7 @@ def __init__( self.project = project self.links = links or [] self.volumes_from = volumes_from or [] - self.net = net or None + self.net = net or Net(None) self.options = options def containers(self, stopped=False, one_off=False): @@ -478,12 +478,12 @@ def config_dict(self): 'options': self.options, 'image_id': self.image()['Id'], 'links': [(service.name, alias) for service, alias in self.links], - 'net': self.get_net_name() or getattr(self.net, 'id', self.net), + 'net': self.net.id, 'volumes_from': self.get_volumes_from_names(), } def get_dependency_names(self): - net_name = self.get_net_name() + net_name = self.net.service_name return (self.get_linked_names() + self.get_volumes_from_names() + ([net_name] if net_name else [])) @@ -494,12 +494,6 @@ def get_linked_names(self): def get_volumes_from_names(self): return [s.name for s in self.volumes_from if isinstance(s, Service)] - def get_net_name(self): - if isinstance(self.net, Service): - return self.net.name - else: - return - 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) @@ -551,25 +545,6 @@ def _get_volumes_from(self): return volumes_from - def _get_net(self): - if not self.net: - return None - - if isinstance(self.net, Service): - containers = self.net.containers() - if len(containers) > 0: - net = 'container:' + containers[0].id - else: - log.warning("Warning: Service %s is trying to use reuse the network stack " - "of another service that is not running." % (self.net.name)) - net = None - elif isinstance(self.net, Container): - net = 'container:' + self.net.id - else: - net = self.net - - return net - def _get_container_create_options( self, override_options, @@ -690,7 +665,7 @@ def _get_container_host_config(self, override_options, one_off=False): binds=options.get('binds'), volumes_from=self._get_volumes_from(), privileged=privileged, - network_mode=self._get_net(), + network_mode=self.net.mode, devices=devices, dns=dns, dns_search=dns_search, @@ -785,6 +760,61 @@ def pull(self): stream_output(output, sys.stdout) +class Net(object): + """A `standard` network mode (ex: host, bridge)""" + + service_name = None + + def __init__(self, net): + self.net = net + + @property + def id(self): + return self.net + + mode = id + + +class ContainerNet(object): + """A network mode that uses a containers network stack.""" + + service_name = None + + def __init__(self, container): + self.container = container + + @property + def id(self): + return self.container.id + + @property + def mode(self): + return 'container:' + self.container.id + + +class ServiceNet(object): + """A network mode that uses a service's network stack.""" + + def __init__(self, service): + self.service = service + + @property + def id(self): + return self.service.name + + service_name = id + + @property + def mode(self): + containers = self.service.containers() + if containers: + return 'container:' + containers[0].id + + log.warn("Warning: Service %s is trying to use reuse the network stack " + "of another service that is not running." % (self.id)) + return None + + # Names diff --git a/tests/unit/project_test.py b/tests/unit/project_test.py index 93bf12ff570..a66aaf5d270 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -220,7 +220,7 @@ def test_net_unset(self): } ], self.mock_client) service = project.get_service('test') - self.assertEqual(service._get_net(), None) + self.assertEqual(service.net.id, None) self.assertNotIn('NetworkMode', service._get_container_host_config({})) def test_use_net_from_container(self): @@ -235,7 +235,7 @@ def test_use_net_from_container(self): } ], self.mock_client) service = project.get_service('test') - self.assertEqual(service._get_net(), 'container:' + container_id) + self.assertEqual(service.net.mode, 'container:' + container_id) def test_use_net_from_service(self): container_name = 'test_aaa_1' @@ -260,7 +260,7 @@ def test_use_net_from_service(self): ], self.mock_client) service = project.get_service('test') - self.assertEqual(service._get_net(), 'container:' + container_name) + self.assertEqual(service.net.mode, 'container:' + container_name) def test_container_without_name(self): self.mock_client.containers.return_value = [ diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 9979c8f123b..6ed3d981a32 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -7,21 +7,27 @@ import docker from docker.utils import LogConfig -from compose.service import Service +from .. import mock +from .. import unittest +from compose.const import LABEL_CONFIG_HASH +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.const import LABEL_SERVICE, LABEL_PROJECT, LABEL_ONE_OFF -from compose.service import ( - ConfigError, - NeedsBuildError, - NoSuchImageError, - build_port_bindings, - build_volume_binding, - get_container_data_volumes, - merge_volume_bindings, - parse_repository_tag, - parse_volume_spec, - split_port, -) +from compose.service import ConfigError +from compose.service import ContainerNet +from compose.service import NeedsBuildError +from compose.service import Net +from compose.service import NoSuchImageError +from compose.service import Service +from compose.service import ServiceNet +from compose.service import build_port_bindings +from compose.service import build_volume_binding +from compose.service import get_container_data_volumes +from compose.service import merge_volume_bindings +from compose.service import parse_repository_tag +from compose.service import parse_volume_spec +from compose.service import split_port class ServiceTest(unittest.TestCase): @@ -393,7 +399,7 @@ def test_config_dict(self): 'foo', image='example.com/foo', client=self.mock_client, - net=Service('other'), + net=ServiceNet(Service('other')), links=[(Service('one'), 'one')], volumes_from=[Service('two')]) @@ -429,6 +435,49 @@ def test_config_dict_with_net_from_container(self): self.assertEqual(config_dict, expected) +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_net_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) + + def test_net_service(self): + container_id = 'bbbb' + service_name = 'web' + mock_client = mock.create_autospec(docker.Client) + mock_client.containers.return_value = [ + {'Id': container_id, 'Name': container_id, 'Image': 'abcd'}, + ] + + service = Service(name=service_name, client=mock_client) + net = ServiceNet(service) + + self.assertEqual(net.id, service_name) + self.assertEqual(net.mode, 'container:' + container_id) + self.assertEqual(net.service_name, service_name) + + def test_net_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) + + self.assertEqual(net.id, service_name) + self.assertEqual(net.mode, None) + self.assertEqual(net.service_name, service_name) + + def mock_get_image(images): if images: return images[0] From db31adc2085c4b1b219f9195aee3cdfb33fda600 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 3 Sep 2015 13:13:22 -0400 Subject: [PATCH 1201/4072] Extract link names into a function. Signed-off-by: Daniel Nephin --- compose/cli/main.py | 2 +- compose/service.py | 13 ++++++++----- tests/integration/project_test.py | 4 ++-- tests/integration/service_test.py | 19 +++++++++---------- 4 files changed, 20 insertions(+), 18 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 1fb62de69db..d9bda293719 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -304,7 +304,7 @@ def run(self, project, options): log.warn(INSECURE_SSL_WARNING) if not options['--no-deps']: - deps = service.get_linked_names() + deps = service.get_linked_service_names() if len(deps) > 0: project.up( diff --git a/compose/service.py b/compose/service.py index 25584fe2cd0..1433260beca 100644 --- a/compose/service.py +++ b/compose/service.py @@ -477,19 +477,22 @@ def config_dict(self): return { 'options': self.options, 'image_id': self.image()['Id'], - 'links': [(service.name, alias) for service, alias in self.links], + 'links': self.get_link_names(), 'net': self.net.id, 'volumes_from': self.get_volumes_from_names(), } def get_dependency_names(self): net_name = self.net.service_name - return (self.get_linked_names() + + return (self.get_linked_service_names() + self.get_volumes_from_names() + ([net_name] if net_name else [])) - def get_linked_names(self): - return [s.name for (s, _) in self.links] + def get_linked_service_names(self): + return [service.name for (service, _) in self.links] + + def get_link_names(self): + return [(service.name, alias) for service, alias in self.links] def get_volumes_from_names(self): return [s.name for s in self.volumes_from if isinstance(s, Service)] @@ -776,7 +779,7 @@ def id(self): class ContainerNet(object): - """A network mode that uses a containers network stack.""" + """A network mode that uses a container's network stack.""" service_name = None diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 9788c186159..a0fbe3e1f73 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -112,7 +112,7 @@ def test_net_from_service(self): web = project.get_service('web') net = project.get_service('net') - self.assertEqual(web._get_net(), 'container:' + net.containers()[0].id) + self.assertEqual(web.net.mode, 'container:' + net.containers()[0].id) def test_net_from_container(self): net_container = Container.create( @@ -138,7 +138,7 @@ def test_net_from_container(self): project.up() web = project.get_service('web') - self.assertEqual(web._get_net(), 'container:' + net_container.id) + self.assertEqual(web.net.mode, 'container:' + net_container.id) def test_start_stop_kill_remove(self): web = self.create_service('web') diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 70ca758a56d..ec6428200fd 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -9,6 +9,7 @@ import shutil from six import StringIO, text_type +from .testcases import DockerClientTestCase from compose import __version__ from compose.const import ( LABEL_CONTAINER_NUMBER, @@ -17,14 +18,12 @@ LABEL_SERVICE, LABEL_VERSION, ) -from compose.service import ( - ConfigError, - ConvergencePlan, - Service, - build_extra_hosts, -) from compose.container import Container -from .testcases import DockerClientTestCase +from compose.service import build_extra_hosts +from compose.service import ConfigError +from compose.service import ConvergencePlan +from compose.service import Net +from compose.service import Service def create_and_start_container(service, **override_options): @@ -743,17 +742,17 @@ def test_scale_sets_ports(self): self.assertEqual(list(container.inspect()['HostConfig']['PortBindings'].keys()), ['8000/tcp']) def test_network_mode_none(self): - service = self.create_service('web', net='none') + service = self.create_service('web', net=Net('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='bridge') + service = self.create_service('web', net=Net('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='host') + service = self.create_service('web', net=Net('host')) container = create_and_start_container(service) self.assertEqual(container.get('HostConfig.NetworkMode'), 'host') From 7ff8c2b224768c89f4a45e359338ae48084a9145 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 27 Aug 2015 17:42:26 -0400 Subject: [PATCH 1202/4072] Resolves #1804 Fix mutation of service.options when a label or environment variable is specified in the config. Signed-off-by: Daniel Nephin --- compose/config.py | 2 +- compose/service.py | 20 +++++++++----------- tests/unit/service_test.py | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 44 insertions(+), 12 deletions(-) diff --git a/compose/config.py b/compose/config.py index 75391e9b82e..6bb0fea6ac4 100644 --- a/compose/config.py +++ b/compose/config.py @@ -382,7 +382,7 @@ def parse_environment(environment): return dict(split_env(e) for e in environment) if isinstance(environment, dict): - return environment + return dict(environment) raise ConfigurationError( "environment \"%s\" must be a list or mapping," % diff --git a/compose/service.py b/compose/service.py index 1433260beca..fb859ab7a40 100644 --- a/compose/service.py +++ b/compose/service.py @@ -554,7 +554,6 @@ def _get_container_create_options( number, one_off=False, previous_container=None): - add_config_hash = (not one_off and not override_options) container_options = dict( @@ -567,13 +566,6 @@ def _get_container_create_options( else: container_options['name'] = self.get_container_name(number, one_off) - if add_config_hash: - config_hash = self.config_hash() - if 'labels' not in container_options: - container_options['labels'] = {} - container_options['labels'][LABEL_CONFIG_HASH] = config_hash - log.debug("Added config hash: %s" % config_hash) - if 'detach' not in container_options: container_options['detach'] = True @@ -621,7 +613,8 @@ def _get_container_create_options( container_options['labels'] = build_container_labels( container_options.get('labels', {}), self.labels(one_off=one_off), - number) + number, + self.config_hash if add_config_hash else None) # Delete options which are only used when starting for key in DOCKER_START_KEYS: @@ -943,11 +936,16 @@ def split_port(port): # Labels -def build_container_labels(label_options, service_labels, number, one_off=False): - labels = label_options or {} +def build_container_labels(label_options, service_labels, number, config_hash): + labels = dict(label_options or {}) labels.update(label.split('=', 1) for label in service_labels) labels[LABEL_CONTAINER_NUMBER] = str(number) labels[LABEL_VERSION] = __version__ + + if config_hash: + log.debug("Added config hash: %s" % config_hash) + labels[LABEL_CONFIG_HASH] = config_hash + return labels diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 6ed3d981a32..f407d73e93f 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -274,6 +274,40 @@ def test_get_container_create_options_does_not_mutate_options(self): } ) + def test_get_container_create_options_does_not_mutate_options(self): + labels = {'thing': 'real'} + environment = {'also': 'real'} + service = Service( + 'foo', + image='foo', + labels=dict(labels), + client=self.mock_client, + environment=dict(environment), + ) + self.mock_client.inspect_image.return_value = {'Id': 'abcd'} + prev_container = mock.Mock( + id='ababab', + image_config={'ContainerConfig': {}}) + + opts = service._get_container_create_options( + {}, + 1, + previous_container=prev_container) + + self.assertEqual(service.options['labels'], labels) + self.assertEqual(service.options['environment'], environment) + + self.assertEqual( + opts['labels'][LABEL_CONFIG_HASH], + 'b30306d0a73b67f67a45b99b88d36c359e470e6fa0c04dda1cf62d2087205b81') + self.assertEqual( + opts['environment'], + { + 'affinity:container': '=ababab', + 'also': 'real', + } + ) + def test_get_container_not_found(self): self.mock_client.containers.return_value = [] service = Service('foo', client=self.mock_client, image='foo') From 4641d4052640ec541ac3fbedae5e2e5e86385b34 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 9 Sep 2015 15:54:51 -0400 Subject: [PATCH 1203/4072] Document limitation of other log drivers. Signed-off-by: Daniel Nephin --- docs/yml.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/docs/yml.md b/docs/yml.md index 3ece0264945..9c1ffa07a4a 100644 --- a/docs/yml.md +++ b/docs/yml.md @@ -289,11 +289,10 @@ 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. -### log driver +### log_driver -Specify a logging driver for the service's containers, as with the ``--log-driver`` option for docker run ([documented here](http://docs.docker.com/reference/run/#logging-drivers-log-driver)). - -Allowed values are currently ``json-file``, ``syslog`` and ``none``. The list will change over time as more drivers are added to the Docker engine. +Specify a logging driver for the service's containers, as with the ``--log-driver`` +option for docker run ([documented here](https://docs.docker.com/reference/logging/overview/)). The default value is json-file. @@ -301,6 +300,12 @@ The default value is json-file. log_driver: "syslog" log_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. + +### log_opt + Specify logging options with `log_opt` for the logging driver, as with the ``--log-opt`` option for `docker run`. Logging options are key value pairs. An example of `syslog` options: From 413b76e2285f5f6575601385f9b4af7503b41c3e Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 9 Sep 2015 17:53:36 -0400 Subject: [PATCH 1204/4072] Fix warning message when a container uses a non-json log driver Signed-off-by: Daniel Nephin --- compose/cli/log_printer.py | 48 +++++++++++++-------- compose/cli/main.py | 8 +--- compose/container.py | 9 ++++ tests/unit/cli/log_printer_test.py | 67 ++++++++++++++++++------------ 4 files changed, 81 insertions(+), 51 deletions(-) diff --git a/compose/cli/log_printer.py b/compose/cli/log_printer.py index c2fcc54fdd6..49071dd4ae4 100644 --- a/compose/cli/log_printer.py +++ b/compose/cli/log_printer.py @@ -13,9 +13,9 @@ class LogPrinter(object): - def __init__(self, containers, attach_params=None, output=sys.stdout, monochrome=False): + # TODO: move logic to run + def __init__(self, containers, output=sys.stdout, monochrome=False): self.containers = containers - self.attach_params = attach_params or {} self.prefix_width = self._calculate_prefix_width(containers) self.generators = self._make_log_generators(monochrome) self.output = utils.get_output_stream(output) @@ -25,6 +25,7 @@ def run(self): for line in mux.loop(): self.output.write(line) + # TODO: doesn't use self, remove from class def _calculate_prefix_width(self, containers): """ Calculate the maximum width of container names so we can make the log @@ -56,14 +57,10 @@ def no_color(text): def _make_log_generator(self, container, color_fn): prefix = color_fn(self._generate_prefix(container)) - # Attach to container before log printer starts running - line_generator = split_buffer(self._attach(container), u'\n') - for line in line_generator: - yield prefix + line - - exit_code = container.wait() - yield color_fn("%s exited with code %s\n" % (container.name, exit_code)) + if container.has_api_logs: + return build_log_generator(container, prefix, color_fn) + return build_no_log_generator(container, prefix, color_fn) def _generate_prefix(self, container): """ @@ -73,12 +70,27 @@ def _generate_prefix(self, container): padding = ' ' * (self.prefix_width - len(name)) return ''.join([name, padding, ' | ']) - def _attach(self, container): - params = { - 'stdout': True, - 'stderr': True, - 'stream': True, - } - params.update(self.attach_params) - params = dict((name, 1 if value else 0) for (name, value) in list(params.items())) - return container.attach(**params) + +def build_no_log_generator(container, prefix, color_fn): + """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_fn(wait_on_exit(container)) + + +def build_log_generator(container, prefix, color_fn): + # Attach to container before log printer starts running + stream = container.attach(stdout=True, stderr=True, stream=True, logs=True) + line_generator = split_buffer(stream, u'\n') + + for line in line_generator: + yield prefix + line + yield color_fn(wait_on_exit(container)) + + +def wait_on_exit(container): + exit_code = container.wait() + return "%s exited with code %s\n" % (container.name, exit_code) diff --git a/compose/cli/main.py b/compose/cli/main.py index a7b91816805..61461ae7bea 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -193,7 +193,7 @@ def logs(self, project, options): monochrome = options['--no-color'] print("Attaching to", list_containers(containers)) - LogPrinter(containers, attach_params={'logs': True}, monochrome=monochrome).run() + LogPrinter(containers, monochrome=monochrome).run() def pause(self, project, options): """ @@ -602,11 +602,7 @@ def convergence_strategy_from_opts(options): def build_log_printer(containers, service_names, monochrome): if service_names: containers = [c for c in containers if c.service in service_names] - - return LogPrinter( - containers, - attach_params={"logs": True}, - monochrome=monochrome) + return LogPrinter(containers, monochrome=monochrome) def attach_to_logs(project, log_printer, service_names, timeout): diff --git a/compose/container.py b/compose/container.py index 51b62589097..28af093d769 100644 --- a/compose/container.py +++ b/compose/container.py @@ -137,6 +137,15 @@ def is_running(self): def is_paused(self): return self.get('State.Paused') + @property + def log_driver(self): + return self.get('HostConfig.LogConfig.Type') + + @property + def has_api_logs(self): + log_type = self.log_driver + return not log_type or log_type == 'json-file' + def get(self, key): """Return a value from the container or None if the value is not set. diff --git a/tests/unit/cli/log_printer_test.py b/tests/unit/cli/log_printer_test.py index d8fbf94b9a7..2c916898073 100644 --- a/tests/unit/cli/log_printer_test.py +++ b/tests/unit/cli/log_printer_test.py @@ -1,20 +1,31 @@ from __future__ import absolute_import from __future__ import unicode_literals -import os - +import mock import six from compose.cli.log_printer import LogPrinter +from compose.cli.log_printer import wait_on_exit +from compose.container import Container from tests import unittest +def build_mock_container(reader): + return mock.Mock( + spec=Container, + name='myapp_web_1', + name_without_project='web_1', + has_api_logs=True, + attach=reader, + wait=mock.Mock(return_value=0), + ) + + class LogPrinterTest(unittest.TestCase): def get_default_output(self, monochrome=False): def reader(*args, **kwargs): yield b"hello\nworld" - - container = MockContainer(reader) + container = build_mock_container(reader) output = run_log_printer([container], monochrome=monochrome) return output @@ -38,37 +49,39 @@ def test_unicode(self): def reader(*args, **kwargs): yield glyph.encode('utf-8') + b'\n' - container = MockContainer(reader) + container = build_mock_container(reader) output = run_log_printer([container]) if six.PY2: output = output.decode('utf-8') self.assertIn(glyph, 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)) -def run_log_printer(containers, monochrome=False): - r, w = os.pipe() - reader, writer = os.fdopen(r, 'r'), os.fdopen(w, 'w') - printer = LogPrinter(containers, output=writer, monochrome=monochrome) - printer.run() - writer.close() - return reader.read() - + expected = '{} exited with code {}\n'.format(mock_container.name, exit_status) + self.assertEqual(expected, wait_on_exit(mock_container)) -class MockContainer(object): - def __init__(self, reader): - self._reader = reader + def test_generator_with_no_logs(self): + mock_container = mock.Mock( + spec=Container, + has_api_logs=False, + log_driver='none', + name_without_project='web_1', + wait=mock.Mock(return_value=0)) - @property - def name(self): - return 'myapp_web_1' + output = run_log_printer([mock_container]) + self.assertIn( + "WARNING: no logs are available with the 'none' log driver\n", + output + ) - @property - def name_without_project(self): - return 'web_1' - def attach(self, *args, **kwargs): - return self._reader() - - def wait(self, *args, **kwargs): - return 0 +def run_log_printer(containers, monochrome=False): + output = six.StringIO() + LogPrinter(containers, output=output, monochrome=monochrome).run() + return output.getvalue() From 7d8ae9aa6dc907975433657a926c0e329d937d41 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 9 Sep 2015 18:26:34 -0400 Subject: [PATCH 1205/4072] Refactor LogPrinter to make it immutable and remove logic from the constructor. Signed-off-by: Daniel Nephin --- compose/cli/log_printer.py | 85 +++++++++++++++++--------------------- 1 file changed, 38 insertions(+), 47 deletions(-) diff --git a/compose/cli/log_printer.py b/compose/cli/log_printer.py index 49071dd4ae4..845f799b796 100644 --- a/compose/cli/log_printer.py +++ b/compose/cli/log_printer.py @@ -4,8 +4,6 @@ import sys from itertools import cycle -from six import next - from . import colors from .multiplexer import Multiplexer from .utils import split_buffer @@ -13,82 +11,75 @@ class LogPrinter(object): - # TODO: move logic to run + """Print logs from many containers to a single output stream.""" + def __init__(self, containers, output=sys.stdout, monochrome=False): self.containers = containers - self.prefix_width = self._calculate_prefix_width(containers) - self.generators = self._make_log_generators(monochrome) self.output = utils.get_output_stream(output) + self.monochrome = monochrome def run(self): - mux = Multiplexer(self.generators) - for line in mux.loop(): + 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).loop(): self.output.write(line) - # TODO: doesn't use self, remove from class - def _calculate_prefix_width(self, containers): - """ - Calculate the maximum width of container names so we can make the log - prefixes line up like so: + def _make_log_generators(self, monochrome, prefix_width): + def no_color(text): + return text - db_1 | Listening - web_1 | Listening - """ - prefix_width = 0 - for container in containers: - prefix_width = max(prefix_width, len(container.name_without_project)) - return prefix_width + if monochrome: + color_funcs = cycle([no_color]) + else: + color_funcs = cycle(colors.rainbow()) - def _make_log_generators(self, monochrome): - color_fns = cycle(colors.rainbow()) - generators = [] + 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) - def no_color(text): - return text - for container in self.containers: - if monochrome: - color_fn = no_color - else: - color_fn = next(color_fns) - generators.append(self._make_log_generator(container, color_fn)) +def build_log_prefix(container, prefix_width): + return container.name_without_project.ljust(prefix_width) + ' | ' - return generators - def _make_log_generator(self, container, color_fn): - prefix = color_fn(self._generate_prefix(container)) +def max_name_width(containers): + """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(container.name_without_project) for container in containers) - if container.has_api_logs: - return build_log_generator(container, prefix, color_fn) - return build_no_log_generator(container, prefix, color_fn) - def _generate_prefix(self, container): - """ - Generate the prefix for a log line without colour - """ - name = container.name_without_project - padding = ' ' * (self.prefix_width - len(name)) - return ''.join([name, padding, ' | ']) +def get_log_generator(container): + if container.has_api_logs: + return build_log_generator + return build_no_log_generator -def build_no_log_generator(container, prefix, color_fn): +def build_no_log_generator(container, prefix, color_func): """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_fn(wait_on_exit(container)) + yield color_func(wait_on_exit(container)) -def build_log_generator(container, prefix, color_fn): +def build_log_generator(container, prefix, color_func): # Attach to container before log printer starts running stream = container.attach(stdout=True, stderr=True, stream=True, logs=True) line_generator = split_buffer(stream, u'\n') for line in line_generator: yield prefix + line - yield color_fn(wait_on_exit(container)) + yield color_func(wait_on_exit(container)) def wait_on_exit(container): From 2b75741e5a03548d3b34aae7b158da730a5a1e36 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 10 Sep 2015 19:00:44 -0400 Subject: [PATCH 1206/4072] Fix cherry-pick errors. Signed-off-by: Daniel Nephin --- compose/service.py | 2 +- tests/__init__.py | 2 ++ tests/unit/config_test.py | 4 +-- tests/unit/service_test.py | 59 ++++---------------------------------- 4 files changed, 10 insertions(+), 57 deletions(-) diff --git a/compose/service.py b/compose/service.py index fb859ab7a40..b3c68735c59 100644 --- a/compose/service.py +++ b/compose/service.py @@ -614,7 +614,7 @@ def _get_container_create_options( container_options.get('labels', {}), self.labels(one_off=one_off), number, - self.config_hash if add_config_hash else None) + self.config_hash() if add_config_hash else None) # Delete options which are only used when starting for key in DOCKER_START_KEYS: diff --git a/tests/__init__.py b/tests/__init__.py index 08a7865e908..c7a1bd4a0c0 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,5 +1,7 @@ import sys +import mock # noqa + if sys.version_info >= (2, 7): import unittest # NOQA else: diff --git a/tests/unit/config_test.py b/tests/unit/config_test.py index c523798f2b0..cefb1a20ffb 100644 --- a/tests/unit/config_test.py +++ b/tests/unit/config_test.py @@ -512,7 +512,7 @@ def test_self_referencing_file(self): We specify a 'file' key that is the filename we're already in. """ service_dicts = load_from_filename('tests/fixtures/extends/specify-file-as-self.yml') - self.assertEqual(service_dicts, [ + self.assertEqual(sorted(service_dicts), sorted([ { 'environment': { @@ -532,7 +532,7 @@ def test_self_referencing_file(self): 'image': 'busybox', 'name': 'web' } - ]) + ])) def test_circular(self): try: diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index f407d73e93f..263c9b329d2 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -7,8 +7,6 @@ import docker from docker.utils import LogConfig -from .. import mock -from .. import unittest from compose.const import LABEL_CONFIG_HASH from compose.const import LABEL_ONE_OFF from compose.const import LABEL_PROJECT @@ -227,19 +225,6 @@ def test_split_domainname_weird(self): self.assertEqual(opts['hostname'], 'name.sub', 'hostname') self.assertEqual(opts['domainname'], 'domain.tld', 'domainname') - def test_get_container_create_options_with_name_option(self): - service = Service( - 'foo', - image='foo', - client=self.mock_client, - container_name='foo1') - name = 'the_new_name' - opts = service._get_container_create_options( - {'name': name}, - 1, - one_off=True) - self.assertEqual(opts['name'], name) - def test_get_container_create_options_does_not_mutate_options(self): labels = {'thing': 'real'} environment = {'also': 'real'} @@ -274,40 +259,6 @@ def test_get_container_create_options_does_not_mutate_options(self): } ) - def test_get_container_create_options_does_not_mutate_options(self): - labels = {'thing': 'real'} - environment = {'also': 'real'} - service = Service( - 'foo', - image='foo', - labels=dict(labels), - client=self.mock_client, - environment=dict(environment), - ) - self.mock_client.inspect_image.return_value = {'Id': 'abcd'} - prev_container = mock.Mock( - id='ababab', - image_config={'ContainerConfig': {}}) - - opts = service._get_container_create_options( - {}, - 1, - previous_container=prev_container) - - self.assertEqual(service.options['labels'], labels) - self.assertEqual(service.options['environment'], environment) - - self.assertEqual( - opts['labels'][LABEL_CONFIG_HASH], - 'b30306d0a73b67f67a45b99b88d36c359e470e6fa0c04dda1cf62d2087205b81') - self.assertEqual( - opts['environment'], - { - 'affinity:container': '=ababab', - 'also': 'real', - } - ) - def test_get_container_not_found(self): self.mock_client.containers.return_value = [] service = Service('foo', client=self.mock_client, image='foo') @@ -433,9 +384,9 @@ def test_config_dict(self): 'foo', image='example.com/foo', client=self.mock_client, - net=ServiceNet(Service('other')), - links=[(Service('one'), 'one')], - volumes_from=[Service('two')]) + net=ServiceNet(Service('other', image='foo')), + links=[(Service('one', image='foo'), 'one')], + volumes_from=[Service('two', image='foo')]) config_dict = service.config_dict() expected = { @@ -492,7 +443,7 @@ def test_net_service(self): {'Id': container_id, 'Name': container_id, 'Image': 'abcd'}, ] - service = Service(name=service_name, client=mock_client) + service = Service(name=service_name, client=mock_client, image='foo') net = ServiceNet(service) self.assertEqual(net.id, service_name) @@ -504,7 +455,7 @@ def test_net_service_no_containers(self): mock_client = mock.create_autospec(docker.Client) mock_client.containers.return_value = [] - service = Service(name=service_name, client=mock_client) + service = Service(name=service_name, client=mock_client, image='foo') net = ServiceNet(service) self.assertEqual(net.id, service_name) From 61415cd8bcf2d08dbfea957ca4801ed4f0f6e554 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 9 Sep 2015 14:38:46 -0400 Subject: [PATCH 1207/4072] Fixes #1955 - Handle unexpected errors, but don't ignore background threads. Signed-off-by: Daniel Nephin --- compose/utils.py | 26 ++++++++++++++++---------- tests/integration/service_test.py | 6 +++--- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/compose/utils.py b/compose/utils.py index 690c5ffd530..e0304ba5062 100644 --- a/compose/utils.py +++ b/compose/utils.py @@ -21,7 +21,6 @@ def parallel_execute(objects, obj_callable, msg_index, msg): """ stream = get_output_stream(sys.stdout) lines = [] - errors = {} for obj in objects: write_out_msg(stream, lines, msg_index(obj), msg) @@ -29,16 +28,17 @@ def parallel_execute(objects, obj_callable, msg_index, msg): q = Queue() def inner_execute_function(an_callable, parameter, msg_index): + error = None try: result = an_callable(parameter) except APIError as e: - errors[msg_index] = e.explanation + error = e.explanation result = "error" except Exception as e: - errors[msg_index] = e + error = e result = 'unexpected_exception' - q.put((msg_index, result)) + q.put((msg_index, result, error)) for an_object in objects: t = Thread( @@ -49,15 +49,17 @@ def inner_execute_function(an_callable, parameter, msg_index): t.start() done = 0 + errors = {} total_to_execute = len(objects) while done < total_to_execute: try: - msg_index, result = q.get(timeout=1) + msg_index, result, error = q.get(timeout=1) if result == 'unexpected_exception': - raise errors[msg_index] + errors[msg_index] = result, error if result == 'error': + errors[msg_index] = result, error write_out_msg(stream, lines, msg_index, msg, status='error') else: write_out_msg(stream, lines, msg_index, msg) @@ -65,10 +67,14 @@ def inner_execute_function(an_callable, parameter, msg_index): except Empty: pass - if errors: - stream.write("\n") - for error in errors: - stream.write("ERROR: for {} {} \n".format(error, errors[error])) + if not errors: + return + + 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 def get_output_stream(stream): diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index b6257821dcd..79188f69a9d 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -638,8 +638,7 @@ def test_scale_with_api_returns_errors(self, mock_stdout): self.assertTrue(service.containers()[0].is_running) self.assertIn("ERROR: for 2 Boom", mock_stdout.getvalue()) - @mock.patch('sys.stdout', new_callable=StringIO) - def test_scale_with_api_returns_unexpected_exception(self, mock_stdout): + def test_scale_with_api_returns_unexpected_exception(self): """ Test that when scaling if the API returns an error, that is not of type APIError, that error is re-raised. @@ -650,7 +649,8 @@ def test_scale_with_api_returns_unexpected_exception(self, mock_stdout): with mock.patch( 'compose.container.Container.create', - side_effect=ValueError("BOOM")): + side_effect=ValueError("BOOM") + ): with self.assertRaises(ValueError): service.scale(3) From 1fcacae1fe381331a324399332bdb8b0fa926a1a Mon Sep 17 00:00:00 2001 From: Luiz Geron Date: Fri, 11 Sep 2015 19:06:08 -0300 Subject: [PATCH 1208/4072] Fix schema.json MANIFEST.in entry docker-compose up doesn't run after install without this. Signed-off-by: Luiz Geron --- MANIFEST.in | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MANIFEST.in b/MANIFEST.in index 7d48d347a84..43ae06d3e25 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -4,7 +4,7 @@ include requirements.txt include requirements-dev.txt include tox.ini include *.md -include compose/config/schema.json +include compose/config/*.json recursive-include contrib/completion * recursive-include tests * global-exclude *.pyc From d1dd06a7e2f28c0e2f7df53c0a7af13d6b12ece1 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 11 Sep 2015 11:24:57 -0700 Subject: [PATCH 1209/4072] Update docker-py to 1.4.0 Signed-off-by: Aanand Prasad --- requirements.txt | 2 +- setup.py | 2 +- tests/integration/service_test.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/requirements.txt b/requirements.txt index 666efcd2680..4f2ea9d14fe 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ PyYAML==3.10 -docker-py==1.3.1 +docker-py==1.4.0 dockerpty==0.3.4 docopt==0.6.1 enum34==1.0.4 diff --git a/setup.py b/setup.py index 29c5299e9d2..0313fbd052d 100644 --- a/setup.py +++ b/setup.py @@ -33,7 +33,7 @@ def find_version(*file_paths): 'requests >= 2.6.1, < 2.8', 'texttable >= 0.8.1, < 0.9', 'websocket-client >= 0.32.0, < 1.0', - 'docker-py >= 1.3.1, < 1.4', + 'docker-py >= 1.4.0, < 2', 'dockerpty >= 0.3.4, < 0.4', 'six >= 1.3.0, < 2', 'jsonschema >= 2.5.1, < 3', diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 79188f69a9d..bb30da1a1be 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -864,7 +864,7 @@ def test_custom_container_name(self): def test_log_drive_invalid(self): service = self.create_service('web', log_driver='xxx') - self.assertRaises(ValueError, lambda: create_and_start_container(service)) + self.assertRaises(APIError, lambda: create_and_start_container(service)) def test_log_drive_empty_default_jsonfile(self): service = self.create_service('web') From a95ac0f0e0c973a139a50c1e0af10055e32f829a Mon Sep 17 00:00:00 2001 From: Charles Chan Date: Tue, 1 Sep 2015 21:11:43 -0700 Subject: [PATCH 1210/4072] Touch up documentation for Docker Compose. index.md: * clarify Python & Flask * minor edits & reformatting install.md: * merge the elevated installation instructions with `sudo -i` discussed by @asveepay and @aanand in PR #1201; fixes #1081 (not sure what happened to the merge, but it's not showing up on the master branch or website) * minor edits Signed-off-by: Charles Chan --- docs/index.md | 20 ++++++++------------ docs/install.md | 17 ++++++++--------- 2 files changed, 16 insertions(+), 21 deletions(-) diff --git a/docs/index.md b/docs/index.md index 4342b3686d5..992610a9803 100644 --- a/docs/index.md +++ b/docs/index.md @@ -74,7 +74,7 @@ Next, you'll want to make a directory for the project: $ mkdir composetest $ cd composetest -Inside this directory, create `app.py`, a simple web app that uses the Flask +Inside this directory, create `app.py`, a simple Python web app that uses the Flask framework and increments a value in Redis. Don't worry if you don't have Redis installed, docker is going to take care of that for you when we [define services](#define-services): from flask import Flask @@ -113,12 +113,12 @@ 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 your Python dependencies. +* 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/userguide/dockerimages/#building-an-image-from-a-dockerfile) and the [Dockerfile reference](http://docs.docker.com/reference/builder/). -You can test that this builds by running `docker build -t web .`. +You can build the image by running `docker build -t web .`. ### Define services @@ -135,18 +135,14 @@ Next, define a set of services using `docker-compose.yml`: redis: image: redis -This defines two services: - -#### web +This template 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. -* Connects the web container to the Redis service via a link. -* Mounts the current directory on the host to `/code` inside the container allowing you to modify the code without having to rebuild the image. - -#### redis +* Mounts the current directory on the host to ``/code` inside the container allowing you to modify the code without having to rebuild the image. +* Links the web container to the Redis service. -* Uses the public [Redis](https://registry.hub.docker.com/_/redis/) image which gets pulled from the Docker Hub registry. +The `redis` service uses the latest public [Redis](https://registry.hub.docker.com/_/redis/) image pulled from the Docker Hub registry. ### Build and run your app with Compose @@ -163,7 +159,7 @@ Now, when you run `docker-compose up`, Compose will pull a Redis image, build an If you're using [Docker Machine](https://docs.docker.com/machine), then `docker-machine ip MACHINE_VM` will tell you its address and you can open `http://MACHINE_VM_IP:5000` in a browser. -If you're not using Boot2docker and are on linux, 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 localhost:5000. +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. You should get a message in your browser saying: diff --git a/docs/install.md b/docs/install.md index 85060ce0401..371d0a903f8 100644 --- a/docs/install.md +++ b/docs/install.md @@ -16,16 +16,11 @@ 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. -Depending on how your system is configured, you may require `sudo` access to -install Compose. If your system requires `sudo`, you will receive "Permission -denied" errors when installing Compose. If this is the case for you, preface the -install commands with `sudo` to install. - To install Compose, do the following: 1. Install Docker Engine version 1.7.1 or greater: - * Mac OS X installation (installs both Engine and Compose) + * Mac OS X installation (Toolbox installation includes both Engine and Compose) * Ubuntu installation @@ -33,9 +28,13 @@ To install Compose, do the following: 2. Mac OS X users are done installing. Others should continue to the next step. -3. Go to the repository release page. +3. Go to the Compose repository release page on GitHub. + +4. Follow the instructions from the release page and run the `curl` command in your terminal. -4. Enter the `curl` command 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 command has the following format: @@ -69,7 +68,7 @@ to preserve) you can migrate them with the following command: $ docker-compose migrate-to-labels -Alternatively, if you're not worried about keeping them, you can remove them &endash; +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 ... From 32cd404c8c1bb31d04aa2845ca7fa15deae9b00b Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Tue, 8 Sep 2015 11:54:03 +0100 Subject: [PATCH 1211/4072] Remove redundant oneOf definitions For simple definitions where a field can be multiple types, we can specify the allowed types in an array. It's simpler and clearer. This is only applicable to *simple* definitions, like number, string, list, object without any other constraints. Signed-off-by: Mazz Mosley --- compose/config/fields_schema.json | 21 +++------------------ 1 file changed, 3 insertions(+), 18 deletions(-) diff --git a/compose/config/fields_schema.json b/compose/config/fields_schema.json index 5c7322517d5..6277b57d696 100644 --- a/compose/config/fields_schema.json +++ b/compose/config/fields_schema.json @@ -24,12 +24,7 @@ ] }, "container_name": {"type": "string"}, - "cpu_shares": { - "oneOf": [ - {"type": "number"}, - {"type": "string"} - ] - }, + "cpu_shares": {"type": ["number", "string"]}, "cpuset": {"type": "string"}, "devices": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, "dns": {"$ref": "#/definitions/string_or_list"}, @@ -74,18 +69,8 @@ "log_opt": {"type": "object"}, "mac_address": {"type": "string"}, - "mem_limit": { - "oneOf": [ - {"type": "number"}, - {"type": "string"} - ] - }, - "memswap_limit": { - "oneOf": [ - {"type": "number"}, - {"type": "string"} - ] - }, + "mem_limit": {"type": ["number", "string"]}, + "memswap_limit": {"type": ["number", "string"]}, "name": {"type": "string"}, "net": {"type": "string"}, "pid": {"type": ["string", "null"]}, From 418ec5336b0c7848087b38b3d2617b2ce340c67d Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Tue, 8 Sep 2015 12:01:47 +0100 Subject: [PATCH 1212/4072] Improved messaging for simple type validator English language is a tricky old thing and I've pulled out the validator type parsing so that we can prefix our validator types with the correct article, 'an' or 'a'. Doing a bit of extra hard work to ensure the error message is clear and well constructed english. Signed-off-by: Mazz Mosley --- compose/config/validation.py | 41 ++++++++++++++++++++++++++++++------ tests/unit/config_test.py | 15 +++++++++++++ 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/compose/config/validation.py b/compose/config/validation.py index 632bdf03bd5..44763fda3fa 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -117,6 +117,38 @@ def _parse_valid_types_from_schema(schema): else: return str(schema['type']) + 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. + """ + pre_msg_type_prefix = "a" + last_msg_type_prefix = "a" + types_requiring_an = ["array", "object"] + + if isinstance(validator, list): + last_type = validator.pop() + types_from_validator = ", ".join(validator) + + if validator[0] in types_requiring_an: + pre_msg_type_prefix = "an" + + if last_type in types_requiring_an: + last_msg_type_prefix = "an" + + msg = "{} {} or {} {}".format( + pre_msg_type_prefix, + types_from_validator, + last_msg_type_prefix, + last_type + ) + else: + if validator in types_requiring_an: + pre_msg_type_prefix = "an" + msg = "{} {}".format(pre_msg_type_prefix, validator) + + return msg + root_msgs = [] invalid_keys = [] required = [] @@ -176,19 +208,16 @@ def _parse_valid_types_from_schema(schema): service_name, config_key, valid_type_msg) ) elif error.validator == 'type': - msg = "a" - if error.validator_value == "array": - msg = "an" + msg = _parse_valid_types_from_validator(error.validator_value) if len(error.path) > 0: config_key = " ".join(["'%s'" % k for k in error.path]) type_errors.append( "Service '{}' configuration key {} contains an invalid " - "type, it should be {} {}".format( + "type, it should be {}".format( service_name, config_key, - msg, - error.validator_value)) + msg)) else: root_msgs.append( "Service '{}' doesn\'t have any configuration options. " diff --git a/tests/unit/config_test.py b/tests/unit/config_test.py index 870adcf81e9..90d7a6a26d1 100644 --- a/tests/unit/config_test.py +++ b/tests/unit/config_test.py @@ -269,6 +269,21 @@ def test_valid_config_oneof_string_or_list(self): ) self.assertEqual(service[0]['entrypoint'], entrypoint) + def test_validation_message_for_invalid_type_when_multiple_types_allowed(self): + expected_error_msg = "Service 'web' configuration key 'mem_limit' contains an invalid type, it should be a number or a string" + + with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): + config.load( + config.ConfigDetails( + {'web': { + 'image': 'busybox', + 'mem_limit': ['incorrect'] + }}, + 'working_dir', + 'filename.yml' + ) + ) + class InterpolationTest(unittest.TestCase): @mock.patch.dict(os.environ) From b24ca75914811af016e2b7fca6b33f4b8e5cda8a Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 10 Sep 2015 17:49:48 -0400 Subject: [PATCH 1213/4072] Bump 1.4.1 Signed-off-by: Daniel Nephin --- CHANGES.md | 16 ++++++++++++++++ compose/__init__.py | 2 +- docs/install.md | 2 +- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 88e725da61f..ad29512aad3 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,6 +1,22 @@ Change log ========== +1.4.1 (2015-09-10) +------------------ + +The following bugs have been fixed: + +- Some configuration changes (notably changes to `links`, `volumes_from`, and + `net`) were not properly triggering a container recreate as part of + `docker-compose up`. +- `docker-compose up ` was showing logs for all services instead of + just the specified services. +- Containers with custom container names were showing up in logs as + `service_number` instead of their custom container name. +- When scaling a service sometimes containers would be recreated even when + the configuration had not changed. + + 1.4.0 (2015-08-04) ------------------ diff --git a/compose/__init__.py b/compose/__init__.py index 04a072d5a54..8d684354d3e 100644 --- a/compose/__init__.py +++ b/compose/__init__.py @@ -1,3 +1,3 @@ from __future__ import unicode_literals -__version__ = '1.4.0' +__version__ = '1.4.1' diff --git a/docs/install.md b/docs/install.md index fa36791927d..3daf4d944d9 100644 --- a/docs/install.md +++ b/docs/install.md @@ -53,7 +53,7 @@ To install Compose, do the following: 6. Test the installation. $ docker-compose --version - docker-compose version: 1.4.0 + docker-compose version: 1.4.1 ## Upgrading From 0bdbb334476e6155cae346734a21f428ecc15a11 Mon Sep 17 00:00:00 2001 From: Tomas Tomecek Date: Mon, 14 Sep 2015 23:46:48 +0200 Subject: [PATCH 1214/4072] include logo in README Resolves #2024 Signed-off-by: Tomas Tomecek --- README.md | 2 ++ logo.png | Bin 0 -> 39135 bytes 2 files changed, 2 insertions(+) create mode 100644 logo.png diff --git a/README.md b/README.md index 69423111e5e..3c776a71c2e 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ Docker Compose ============== +![Docker Compose](logo.png?raw=true "Docker Compose Logo") + *(Previously known as Fig)* Compose is a tool for defining and running multi-container applications with diff --git a/logo.png b/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..9bc5eb2f9e5051601aa8628f194932c8001e26cd GIT binary patch literal 39135 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

ZMMXuk0bu&{>G&2jGzl-i{PG>Sxw+5V?T)ML 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 UaBka@*8l(j07*qoM6N<$f^aN~WB>pF literal 0 HcmV?d00001 From cf7b59538572c3cb49c8e54aec3e7a7f0da06b42 Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Wed, 9 Sep 2015 13:18:52 +0100 Subject: [PATCH 1215/4072] Improve error messages from oneOf schema errors oneOf schema ValidationError takes a little more work to parse and pull out more detail so we can give a better error message back to the user. Signed-off-by: Mazz Mosley --- compose/config/validation.py | 51 +++++++++++++++++++++++++----------- tests/unit/config_test.py | 5 ++-- 2 files changed, 39 insertions(+), 17 deletions(-) diff --git a/compose/config/validation.py b/compose/config/validation.py index 44763fda3fa..971cfe371f9 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -107,16 +107,6 @@ def _parse_key_from_error_msg(error): def _clean_error_message(message): return message.replace("u'", "'") - def _parse_valid_types_from_schema(schema): - """ - Our defined types using $ref in the schema require some extra parsing - retrieve a helpful type for error message display. - """ - if '$ref' in schema: - return schema['$ref'].replace("#/definitions/", "").replace("_", " ") - else: - return str(schema['type']) - def _parse_valid_types_from_validator(validator): """ A validator value can be either an array of valid types or a string of @@ -149,6 +139,39 @@ def _parse_valid_types_from_validator(validator): return msg + def _parse_oneof_validator(error): + """ + oneOf has multiple schemas, so we need to reason about which schema, sub + schema or constraint the validation is failing on. + Inspecting the context value of a ValidationError gives us information about + which sub schema failed and which kind of error it is. + """ + constraint = [context for context in error.context if len(context.path) > 0] + if constraint: + valid_types = _parse_valid_types_from_validator(constraint[0].validator_value) + msg = "contains {}, which is an invalid type, it should be {}".format( + constraint[0].instance, + valid_types + ) + return msg + + uniqueness = [context for context in error.context if context.validator == 'uniqueItems'] + if uniqueness: + msg = "contains non unique items, please remove duplicates from {}".format( + uniqueness[0].instance + ) + return msg + + types = [context.validator_value for context in error.context if context.validator == 'type'] + if len(types) == 1: + valid_types = _parse_valid_types_from_validator(types[0]) + else: + valid_types = _parse_valid_types_from_validator(types) + + msg = "contains an invalid type, it should be {}".format(valid_types) + + return msg + root_msgs = [] invalid_keys = [] required = [] @@ -200,12 +223,10 @@ def _parse_valid_types_from_validator(validator): required.append(_clean_error_message(error.message)) elif error.validator == 'oneOf': config_key = error.path[0] + msg = _parse_oneof_validator(error) - valid_types = [_parse_valid_types_from_schema(schema) for schema in error.schema['oneOf']] - valid_type_msg = " or ".join(valid_types) - - type_errors.append("Service '{}' configuration key '{}' contains an invalid type, valid types are {}".format( - service_name, config_key, valid_type_msg) + type_errors.append("Service '{}' configuration key '{}' {}".format( + service_name, config_key, msg) ) elif error.validator == 'type': msg = _parse_valid_types_from_validator(error.validator_value) diff --git a/tests/unit/config_test.py b/tests/unit/config_test.py index 90d7a6a26d1..f55789207d7 100644 --- a/tests/unit/config_test.py +++ b/tests/unit/config_test.py @@ -183,7 +183,8 @@ def test_invalid_config_not_unique_items(self): ) def test_invalid_list_of_strings_format(self): - expected_error_msg = "'command' contains an invalid type, valid types are string or array" + 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): config.load( config.ConfigDetails( @@ -222,7 +223,7 @@ def test_config_extra_hosts_string_raises_validation_error(self): ) def test_config_extra_hosts_list_of_dicts_validation_error(self): - expected_error_msg = "Service 'web' configuration key 'extra_hosts' contains an invalid type" + expected_error_msg = "key 'extra_hosts' contains {'somehost': '162.242.195.82'}, which is an invalid type, it should be a string" with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): config.load( From 1007ad0f868e00c61267e7b9eb059b5b811a84d9 Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Mon, 14 Sep 2015 17:23:05 +0100 Subject: [PATCH 1216/4072] Refactor to simplify _parse_valid_types Signed-off-by: Mazz Mosley --- compose/config/validation.py | 45 +++++++++++++++--------------------- 1 file changed, 19 insertions(+), 26 deletions(-) diff --git a/compose/config/validation.py b/compose/config/validation.py index 971cfe371f9..dc630adf279 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -95,6 +95,12 @@ def get_unsupported_config_msg(service_name, error_key): return msg +def anglicize_validator(validator): + if validator in ["array", "object"]: + return 'an ' + validator + return 'a ' + validator + + def process_errors(errors, service_name=None): """ jsonschema gives us an error tree full of information to explain what has @@ -112,30 +118,20 @@ 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. """ - pre_msg_type_prefix = "a" - last_msg_type_prefix = "a" - types_requiring_an = ["array", "object"] - if isinstance(validator, list): - last_type = validator.pop() - types_from_validator = ", ".join(validator) - - if validator[0] in types_requiring_an: - pre_msg_type_prefix = "an" - - if last_type in types_requiring_an: - last_msg_type_prefix = "an" - - msg = "{} {} or {} {}".format( - pre_msg_type_prefix, - types_from_validator, - last_msg_type_prefix, - last_type - ) + if len(validator) >= 2: + first_type = anglicize_validator(validator[0]) + last_type = anglicize_validator(validator[-1]) + types_from_validator = "{}{}".format(first_type, ", ".join(validator[1:-1])) + + msg = "{} or {}".format( + types_from_validator, + last_type + ) + else: + msg = "{}".format(anglicize_validator(validator[0])) else: - if validator in types_requiring_an: - pre_msg_type_prefix = "an" - msg = "{} {}".format(pre_msg_type_prefix, validator) + msg = "{}".format(anglicize_validator(validator)) return msg @@ -163,10 +159,7 @@ def _parse_oneof_validator(error): return msg types = [context.validator_value for context in error.context if context.validator == 'type'] - if len(types) == 1: - valid_types = _parse_valid_types_from_validator(types[0]) - else: - valid_types = _parse_valid_types_from_validator(types) + valid_types = _parse_valid_types_from_validator(types) msg = "contains an invalid type, it should be {}".format(valid_types) From a594a2ccc25206cc7794ccf8db47982eebc34ecb Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Mon, 7 Sep 2015 16:45:58 +0100 Subject: [PATCH 1217/4072] Disallow booleans in environment When users were putting true/false/yes/no in the environment key, the YML parser was converting them into True/False, rather than leaving them as a string. This change will force people to put them in quotes, thus ensuring that the value gets passed through as intended. Signed-off-by: Mazz Mosley --- compose/config/fields_schema.json | 10 +++++++++- docs/yml.md | 6 +++++- tests/unit/config_test.py | 6 +++--- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/compose/config/fields_schema.json b/compose/config/fields_schema.json index 6277b57d696..baf7eb0eec4 100644 --- a/compose/config/fields_schema.json +++ b/compose/config/fields_schema.json @@ -36,7 +36,15 @@ "environment": { "oneOf": [ - {"type": "object"}, + { + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9_]+$": { + "type": ["string", "number"] + } + }, + "additionalProperties": false + }, {"type": "array", "items": {"type": "string"}, "uniqueItems": true} ] }, diff --git a/docs/yml.md b/docs/yml.md index 9c1ffa07a4a..17415684dbd 100644 --- a/docs/yml.md +++ b/docs/yml.md @@ -184,17 +184,21 @@ Mount all of the volumes from another service or container. ### environment -Add environment variables. You can use either an array or a dictionary. +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 ### env_file diff --git a/tests/unit/config_test.py b/tests/unit/config_test.py index f55789207d7..0c1f81baa64 100644 --- a/tests/unit/config_test.py +++ b/tests/unit/config_test.py @@ -270,15 +270,15 @@ def test_valid_config_oneof_string_or_list(self): ) self.assertEqual(service[0]['entrypoint'], entrypoint) - def test_validation_message_for_invalid_type_when_multiple_types_allowed(self): - expected_error_msg = "Service 'web' configuration key 'mem_limit' contains an invalid type, it should be a number or a string" + def test_config_environment_contains_boolean_validation_error(self): + expected_error_msg = "Service 'web' configuration key 'environment' contains an invalid type" with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): config.load( config.ConfigDetails( {'web': { 'image': 'busybox', - 'mem_limit': ['incorrect'] + 'environment': {'SHOW_STUFF': True} }}, 'working_dir', 'filename.yml' From 8caeffe27eb29b830f181c086302ceb724397571 Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Thu, 10 Sep 2015 16:25:54 +0100 Subject: [PATCH 1218/4072] Log a warning when boolean is found in `environment` We're going to warn people that allowing a boolean in the environment is being deprecated, so in a future release we can disallow it. This is to ensure boolean variables are quoted in strings to ensure they don't get mis-parsed by YML. Signed-off-by: Mazz Mosley --- compose/config/fields_schema.json | 3 ++- compose/config/validation.py | 29 +++++++++++++++++++++++++---- tests/unit/config_test.py | 28 +++++++++++++++------------- 3 files changed, 42 insertions(+), 18 deletions(-) diff --git a/compose/config/fields_schema.json b/compose/config/fields_schema.json index baf7eb0eec4..66cb2b41468 100644 --- a/compose/config/fields_schema.json +++ b/compose/config/fields_schema.json @@ -40,7 +40,8 @@ "type": "object", "patternProperties": { "^[a-zA-Z0-9_]+$": { - "type": ["string", "number"] + "type": ["string", "number", "boolean"], + "format": "environment" } }, "additionalProperties": false diff --git a/compose/config/validation.py b/compose/config/validation.py index dc630adf279..0258c5d94c4 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -1,4 +1,5 @@ import json +import logging import os from functools import wraps @@ -11,6 +12,9 @@ from .errors import ConfigurationError +log = logging.getLogger(__name__) + + DOCKER_CONFIG_HINTS = { 'cpu_share': 'cpu_shares', 'add_host': 'extra_hosts', @@ -44,6 +48,21 @@ def format_ports(instance): return True +@FormatChecker.cls_checks(format="environment") +def format_boolean_in_environment(instance): + """ + Check if there is a boolean in the environment and display a warning. + Always return True here so the validation won't raise an error. + """ + if isinstance(instance, bool): + log.warn( + "Warning: There is a boolean value, {0} in the 'environment' key.\n" + "Environment variables can only be strings.\nPlease add quotes to any boolean values to make them string " + "(eg, '{0}').\nThis warning will become an error in a future release. \r\n".format(instance) + ) + return True + + def validate_service_names(func): @wraps(func) def func_wrapper(config): @@ -259,15 +278,17 @@ def _parse_oneof_validator(error): def validate_against_fields_schema(config): schema_filename = "fields_schema.json" - return _validate_against_schema(config, schema_filename) + format_checkers = ["ports", "environment"] + return _validate_against_schema(config, schema_filename, format_checkers) def validate_against_service_schema(config, service_name): schema_filename = "service_schema.json" - return _validate_against_schema(config, schema_filename, service_name) + format_checkers = ["ports"] + return _validate_against_schema(config, schema_filename, format_checkers, service_name) -def _validate_against_schema(config, schema_filename, service_name=None): +def _validate_against_schema(config, schema_filename, format_checker=[], service_name=None): config_source_dir = os.path.dirname(os.path.abspath(__file__)) schema_file = os.path.join(config_source_dir, schema_filename) @@ -275,7 +296,7 @@ def _validate_against_schema(config, schema_filename, service_name=None): schema = json.load(schema_fh) resolver = RefResolver('file://' + config_source_dir + '/', schema) - validation_output = Draft4Validator(schema, resolver=resolver, format_checker=FormatChecker(["ports"])) + validation_output = Draft4Validator(schema, resolver=resolver, format_checker=FormatChecker(format_checker)) errors = [error for error in sorted(validation_output.iter_errors(config), key=str)] if errors: diff --git a/tests/unit/config_test.py b/tests/unit/config_test.py index 0c1f81baa64..f246d9f665b 100644 --- a/tests/unit/config_test.py +++ b/tests/unit/config_test.py @@ -270,20 +270,22 @@ def test_valid_config_oneof_string_or_list(self): ) self.assertEqual(service[0]['entrypoint'], entrypoint) - def test_config_environment_contains_boolean_validation_error(self): - expected_error_msg = "Service 'web' configuration key 'environment' contains an invalid type" - - with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): - config.load( - config.ConfigDetails( - {'web': { - 'image': 'busybox', - 'environment': {'SHOW_STUFF': True} - }}, - 'working_dir', - 'filename.yml' - ) + @mock.patch('compose.config.validation.log') + def test_logs_warning_for_boolean_in_environment(self, mock_logging): + expected_warning_msg = "Warning: There is a boolean value, True in the 'environment' key." + config.load( + config.ConfigDetails( + {'web': { + 'image': 'busybox', + 'environment': {'SHOW_STUFF': True} + }}, + 'working_dir', + 'filename.yml' ) + ) + + self.assertTrue(mock_logging.warn.called) + self.assertTrue(expected_warning_msg in mock_logging.warn.call_args[0][0]) class InterpolationTest(unittest.TestCase): From 4b2fd7699b1905de2b2f03be5d6a6ba442b5653f Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Thu, 10 Sep 2015 16:54:31 +0100 Subject: [PATCH 1219/4072] Relax constraints on key naming for environment One of the use cases is swarm requires at least : character, so going from conservative to relaxed. Signed-off-by: Mazz Mosley --- compose/config/fields_schema.json | 2 +- tests/unit/config_test.py | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/compose/config/fields_schema.json b/compose/config/fields_schema.json index 66cb2b41468..e79026265cb 100644 --- a/compose/config/fields_schema.json +++ b/compose/config/fields_schema.json @@ -39,7 +39,7 @@ { "type": "object", "patternProperties": { - "^[a-zA-Z0-9_]+$": { + "^[^-]+$": { "type": ["string", "number", "boolean"], "format": "environment" } diff --git a/tests/unit/config_test.py b/tests/unit/config_test.py index f246d9f665b..ff80270e6d1 100644 --- a/tests/unit/config_test.py +++ b/tests/unit/config_test.py @@ -287,6 +287,21 @@ def test_logs_warning_for_boolean_in_environment(self, mock_logging): self.assertTrue(mock_logging.warn.called) self.assertTrue(expected_warning_msg in mock_logging.warn.call_args[0][0]) + def test_config_invalid_environment_dict_key_raises_validation_error(self): + expected_error_msg = "Service 'web' configuration key 'environment' contains an invalid type" + + with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): + config.load( + config.ConfigDetails( + {'web': { + 'image': 'busybox', + 'environment': {'---': 'nope'} + }}, + 'working_dir', + 'filename.yml' + ) + ) + class InterpolationTest(unittest.TestCase): @mock.patch.dict(os.environ) From 2f4564961123feb2f033aa91ba1b2b7938b32c62 Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Mon, 17 Aug 2015 13:15:52 +0100 Subject: [PATCH 1220/4072] Handle invalid log_driver Now docker-py isn't hardcoding a list of valid log_drivers, we can expect an APIError in response rather than a ValueError if we send an invalid log_driver. Signed-off-by: Mazz Mosley --- tests/integration/service_test.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index bb30da1a1be..17fd0aaf1b4 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -864,7 +864,10 @@ def test_custom_container_name(self): def test_log_drive_invalid(self): service = self.create_service('web', log_driver='xxx') - self.assertRaises(APIError, lambda: create_and_start_container(service)) + expected_error_msg = "logger: no log driver named 'xxx' is registered" + + with self.assertRaisesRegexp(APIError, expected_error_msg): + create_and_start_container(service) def test_log_drive_empty_default_jsonfile(self): service = self.create_service('web') From fb96ed113a4757372e01d1403587af73c9e77bea Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Mon, 17 Aug 2015 13:17:01 +0100 Subject: [PATCH 1221/4072] Stop sending json-file by default By doing this we were over-riding any of the daemon's defaults. Instead we can send an empty string which docker-py sends on and the daemon interprets as, 'json-file' as a default if it hasn't got any other daemon level config options. Signed-off-by: Mazz Mosley --- compose/service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/service.py b/compose/service.py index 7406ad80dd7..7e035e29f09 100644 --- a/compose/service.py +++ b/compose/service.py @@ -657,7 +657,7 @@ def _get_container_host_config(self, override_options, one_off=False): cap_add = options.get('cap_add', None) cap_drop = options.get('cap_drop', None) log_config = LogConfig( - type=options.get('log_driver', 'json-file'), + type=options.get('log_driver', ""), config=options.get('log_opt', None) ) pid = options.get('pid', None) From 39786d4da7127bb1a0898da8c63ad77ec0adf8a3 Mon Sep 17 00:00:00 2001 From: Christophe Labouisse Date: Mon, 14 Sep 2015 15:02:15 +0200 Subject: [PATCH 1222/4072] Add new --pull option in build. Signed-off-by: Christophe Labouisse --- compose/cli/main.py | 4 +++- compose/project.py | 4 ++-- compose/service.py | 4 ++-- docs/reference/build.md | 1 + tests/integration/cli_test.py | 38 ++++++++++++++++++++++++++++++++++- 5 files changed, 45 insertions(+), 6 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 61461ae7bea..9b03ea67634 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -153,9 +153,11 @@ def build(self, project, options): Options: --no-cache Do not use cache when building the image. + --pull Always attempt to pull a newer version of the image. """ no_cache = bool(options.get('--no-cache', False)) - project.build(service_names=options['SERVICE'], no_cache=no_cache) + pull = bool(options.get('--pull', False)) + project.build(service_names=options['SERVICE'], no_cache=no_cache, pull=pull) def help(self, project, options): """ diff --git a/compose/project.py b/compose/project.py index 9a6e98e01d0..f34cc0c3495 100644 --- a/compose/project.py +++ b/compose/project.py @@ -257,10 +257,10 @@ def restart(self, service_names=None, **options): for service in self.get_services(service_names): service.restart(**options) - def build(self, service_names=None, no_cache=False): + def build(self, service_names=None, no_cache=False, pull=False): for service in self.get_services(service_names): if service.can_be_built(): - service.build(no_cache) + service.build(no_cache, pull) else: log.info('%s uses an image, skipping' % service.name) diff --git a/compose/service.py b/compose/service.py index 7406ad80dd7..d74c310b91b 100644 --- a/compose/service.py +++ b/compose/service.py @@ -700,7 +700,7 @@ def _get_container_host_config(self, override_options, one_off=False): security_opt=security_opt ) - def build(self, no_cache=False): + def build(self, no_cache=False, pull=False): log.info('Building %s' % self.name) path = self.options['build'] @@ -714,7 +714,7 @@ def build(self, no_cache=False): tag=self.image_name, stream=True, rm=True, - pull=False, + pull=pull, nocache=no_cache, dockerfile=self.options.get('dockerfile', None), ) diff --git a/docs/reference/build.md b/docs/reference/build.md index 77d87def49c..c427199fec5 100644 --- a/docs/reference/build.md +++ b/docs/reference/build.md @@ -16,6 +16,7 @@ Usage: build [options] [SERVICE...] Options: --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., diff --git a/tests/integration/cli_test.py b/tests/integration/cli_test.py index 4a80d336957..9dadd0368d3 100644 --- a/tests/integration/cli_test.py +++ b/tests/integration/cli_test.py @@ -98,20 +98,56 @@ def test_pull_with_digest(self, mock_logging): 'sha256:38a203e1986cf79639cfb9b2e1d6e773de84002feea2d4eb006b52004ee8502d)...') @mock.patch('sys.stdout', new_callable=StringIO) - def test_build_no_cache(self, mock_stdout): + def test_build_plain(self, mock_stdout): self.command.base_dir = 'tests/fixtures/simple-dockerfile' self.command.dispatch(['build', 'simple'], None) mock_stdout.truncate(0) cache_indicator = 'Using cache' + pull_indicator = 'Status: Image is up to date for busybox:latest' self.command.dispatch(['build', 'simple'], None) output = mock_stdout.getvalue() self.assertIn(cache_indicator, output) + self.assertNotIn(pull_indicator, output) + + @mock.patch('sys.stdout', new_callable=StringIO) + def test_build_no_cache(self, mock_stdout): + self.command.base_dir = 'tests/fixtures/simple-dockerfile' + self.command.dispatch(['build', 'simple'], None) mock_stdout.truncate(0) + cache_indicator = 'Using cache' + pull_indicator = 'Status: Image is up to date for busybox:latest' self.command.dispatch(['build', '--no-cache', 'simple'], None) output = mock_stdout.getvalue() self.assertNotIn(cache_indicator, output) + self.assertNotIn(pull_indicator, output) + + @mock.patch('sys.stdout', new_callable=StringIO) + def test_build_pull(self, mock_stdout): + self.command.base_dir = 'tests/fixtures/simple-dockerfile' + self.command.dispatch(['build', 'simple'], None) + + mock_stdout.truncate(0) + cache_indicator = 'Using cache' + pull_indicator = 'Status: Image is up to date for busybox:latest' + self.command.dispatch(['build', '--pull', 'simple'], None) + output = mock_stdout.getvalue() + self.assertIn(cache_indicator, output) + self.assertIn(pull_indicator, output) + + @mock.patch('sys.stdout', new_callable=StringIO) + def test_build_no_cache_pull(self, mock_stdout): + self.command.base_dir = 'tests/fixtures/simple-dockerfile' + self.command.dispatch(['build', 'simple'], None) + + mock_stdout.truncate(0) + cache_indicator = 'Using cache' + pull_indicator = 'Status: Image is up to date for busybox:latest' + self.command.dispatch(['build', '--no-cache', '--pull', 'simple'], None) + output = mock_stdout.getvalue() + self.assertNotIn(cache_indicator, output) + self.assertIn(pull_indicator, output) def test_up_detached(self): self.command.dispatch(['up', '-d'], None) From 7c32fcbcf58831d51e1cc981e4880ee554809ddd Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 10 Sep 2015 17:49:48 -0400 Subject: [PATCH 1223/4072] Add 1.4.1 release notes and download instructions. Signed-off-by: Daniel Nephin --- CHANGELOG.md | 16 ++++++++++++++++ docs/install.md | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f18ddbf8d5..a054a0aef4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,22 @@ Change log ========== +1.4.1 (2015-09-10) +------------------ + +The following bugs have been fixed: + +- Some configuration changes (notably changes to `links`, `volumes_from`, and + `net`) were not properly triggering a container recreate as part of + `docker-compose up`. +- `docker-compose up ` was showing logs for all services instead of + just the specified services. +- Containers with custom container names were showing up in logs as + `service_number` instead of their custom container name. +- When scaling a service sometimes containers would be recreated even when + the configuration had not changed. + + 1.4.0 (2015-08-04) ------------------ diff --git a/docs/install.md b/docs/install.md index 371d0a903f8..5496db2eede 100644 --- a/docs/install.md +++ b/docs/install.md @@ -52,7 +52,7 @@ To install Compose, do the following: 6. Test the installation. $ docker-compose --version - docker-compose version: 1.4.0 + docker-compose version: 1.4.1 ## Upgrading From bdfb21f0171ffa175be5414b192e9b88f7775d04 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 15 Sep 2015 20:46:22 -0400 Subject: [PATCH 1224/4072] Fixes #189 - stacktrace when ctrl-c stops logs Signed-off-by: Daniel Nephin --- compose/cli/multiplexer.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/compose/cli/multiplexer.py b/compose/cli/multiplexer.py index b502c351b72..4c73c6cdc6e 100644 --- a/compose/cli/multiplexer.py +++ b/compose/cli/multiplexer.py @@ -2,6 +2,8 @@ from threading import Thread +from six.moves import _thread as thread + try: from Queue import Queue, Empty except ImportError: @@ -38,6 +40,9 @@ def loop(self): yield item except Empty: pass + # See https://github.com/docker/compose/issues/189 + except thread.error: + raise KeyboardInterrupt() def _init_readers(self): for iterator in self.iterators: From bbc8765343f7824e2107bb78acb8814de3f1cb4e Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 16 Sep 2015 12:38:59 +0100 Subject: [PATCH 1225/4072] Fix typo in docs/index.md Signed-off-by: Aanand Prasad --- docs/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/index.md b/docs/index.md index 3180d7df0a3..0c919e488b1 100644 --- a/docs/index.md +++ b/docs/index.md @@ -139,7 +139,7 @@ This template 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 current directory on the host to ``/code` inside the container allowing you to modify the code without having to rebuild the image. +* Mounts the current directory on the host to `/code` inside the container allowing you to modify the code without having to rebuild the image. * Links the web container 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. From fb83b4c6a40406c968c6c6723259e25d10cc2237 Mon Sep 17 00:00:00 2001 From: Zachary Jaffee Date: Wed, 16 Sep 2015 11:01:43 -0400 Subject: [PATCH 1226/4072] updated wordpress format syntax Signed-off-by: Zachary Jaffee --- docs/completion.md | 2 +- docs/django.md | 2 +- docs/env.md | 2 +- docs/extends.md | 2 +- docs/index.md | 2 +- docs/install.md | 2 +- docs/production.md | 2 +- docs/rails.md | 2 +- docs/wordpress.md | 20 ++++++++++---------- docs/yml.md | 2 +- 10 files changed, 19 insertions(+), 19 deletions(-) diff --git a/docs/completion.md b/docs/completion.md index 7b8a6733e5c..bf8d15551ea 100644 --- a/docs/completion.md +++ b/docs/completion.md @@ -63,7 +63,7 @@ Enjoy working with Compose faster and with less typos! - [Installing Compose](install.md) - [Get started with Django](django.md) - [Get started with Rails](rails.md) -- [Get started with Wordpress](wordpress.md) +- [Get started with WordPress](wordpress.md) - [Command line reference](/reference) - [Yaml file reference](yml.md) - [Compose environment variables](env.md) diff --git a/docs/django.md b/docs/django.md index 7e476b35694..e52f50301dd 100644 --- a/docs/django.md +++ b/docs/django.md @@ -128,7 +128,7 @@ example, run `docker-compose up` and in another terminal run: - [Installing Compose](install.md) - [Get started with Django](django.md) - [Get started with Rails](rails.md) -- [Get started with Wordpress](wordpress.md) +- [Get started with WordPress](wordpress.md) - [Command line reference](/reference) - [Yaml file reference](yml.md) - [Compose environment variables](env.md) diff --git a/docs/env.md b/docs/env.md index 8ead34f01f7..a8e6e214ce5 100644 --- a/docs/env.md +++ b/docs/env.md @@ -43,7 +43,7 @@ Fully qualified container name, e.g. `DB_1_NAME=/myapp_web_1/myapp_db_1` - [Installing Compose](install.md) - [Get started with Django](django.md) - [Get started with Rails](rails.md) -- [Get started with Wordpress](wordpress.md) +- [Get started with WordPress](wordpress.md) - [Command line reference](/reference) - [Yaml file reference](yml.md) - [Compose command line completion](completion.md) diff --git a/docs/extends.md b/docs/extends.md index 18a072a82d7..7b4d5b20939 100644 --- a/docs/extends.md +++ b/docs/extends.md @@ -357,7 +357,7 @@ locally-defined bindings taking precedence: - [Installing Compose](install.md) - [Get started with Django](django.md) - [Get started with Rails](rails.md) -- [Get started with Wordpress](wordpress.md) +- [Get started with WordPress](wordpress.md) - [Command line reference](/reference) - [Yaml file reference](yml.md) - [Compose command line completion](completion.md) diff --git a/docs/index.md b/docs/index.md index 3180d7df0a3..0112d0aa477 100644 --- a/docs/index.md +++ b/docs/index.md @@ -52,7 +52,7 @@ Compose has commands for managing the whole lifecycle of your application: - [Installing Compose](install.md) - [Get started with Django](django.md) - [Get started with Rails](rails.md) -- [Get started with Wordpress](wordpress.md) +- [Get started with WordPress](wordpress.md) - [Command line reference](/reference) - [Yaml file reference](yml.md) - [Compose environment variables](env.md) diff --git a/docs/install.md b/docs/install.md index 371d0a903f8..b293246770e 100644 --- a/docs/install.md +++ b/docs/install.md @@ -96,7 +96,7 @@ To uninstall Docker Compose if you installed using `pip`: - [User guide](/) - [Get started with Django](django.md) - [Get started with Rails](rails.md) -- [Get started with Wordpress](wordpress.md) +- [Get started with WordPress](wordpress.md) - [Command line reference](/reference) - [Yaml file reference](yml.md) - [Compose environment variables](env.md) diff --git a/docs/production.md b/docs/production.md index 5a3a07e8e29..29e3fd34ec9 100644 --- a/docs/production.md +++ b/docs/production.md @@ -88,7 +88,7 @@ guide. - [Installing Compose](install.md) - [Get started with Django](django.md) - [Get started with Rails](rails.md) -- [Get started with Wordpress](wordpress.md) +- [Get started with WordPress](wordpress.md) - [Command line reference](/reference) - [Yaml file reference](yml.md) - [Compose environment variables](env.md) diff --git a/docs/rails.md b/docs/rails.md index 186f9b2bf24..0a164ca75e7 100644 --- a/docs/rails.md +++ b/docs/rails.md @@ -126,7 +126,7 @@ That's it. Your app should now be running on port 3000 on your Docker daemon. If - [Installing Compose](install.md) - [Get started with Django](django.md) - [Get started with Rails](rails.md) -- [Get started with Wordpress](wordpress.md) +- [Get started with WordPress](wordpress.md) - [Command line reference](/reference) - [Yaml file reference](yml.md) - [Compose environment variables](env.md) diff --git a/docs/wordpress.md b/docs/wordpress.md index ab22e2a0df8..8de5a26441d 100644 --- a/docs/wordpress.md +++ b/docs/wordpress.md @@ -1,7 +1,7 @@ -# Quickstart Guide: Compose and Wordpress +# Quickstart Guide: Compose and WordPress -You can use Compose to easily run Wordpress in an isolated environment built +You can use Compose to easily run WordPress in an isolated environment built with Docker containers. ## Define the project -First, [Install Compose](install.md) and then download Wordpress into the +First, [Install Compose](install.md) and then download WordPress into the current directory: $ curl https://wordpress.org/latest.tar.gz | tar -xvzf - @@ -36,7 +36,7 @@ your Dockerfile should be: ADD . /code This tells Docker how to build an image defining a container that contains PHP -and Wordpress. +and WordPress. Next you'll create a `docker-compose.yml` file that will start your web service and a separate MySQL instance: @@ -56,7 +56,7 @@ and a separate MySQL instance: MYSQL_DATABASE: wordpress Two supporting files are needed to get this working - first, `wp-config.php` is -the standard Wordpress config file with a single change to point the database +the standard WordPress config file with a single change to point the database configuration at the `db` container: Date: Tue, 18 Aug 2015 16:43:19 +0100 Subject: [PATCH 1227/4072] Use docker.client.create_host_config create_host_config from docker.utils will be deprecated so that the new create_host_config has access to the _version so we can ensure that network_mode only gets set to 'default' by default if the version is high enough and won't explode. Signed-off-by: Mazz Mosley --- compose/service.py | 3 +-- tests/integration/service_test.py | 7 +++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/compose/service.py b/compose/service.py index 7e035e29f09..6d3df1f7e35 100644 --- a/compose/service.py +++ b/compose/service.py @@ -11,7 +11,6 @@ import enum import six from docker.errors import APIError -from docker.utils import create_host_config from docker.utils import LogConfig from docker.utils.ports import build_port_bindings from docker.utils.ports import split_port @@ -678,7 +677,7 @@ def _get_container_host_config(self, override_options, one_off=False): devices = options.get('devices', None) - return create_host_config( + return self.client.create_host_config( links=self._get_links(link_to_self=one_off), port_bindings=port_bindings, binds=options.get('binds'), diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 17fd0aaf1b4..040098c9e71 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -813,6 +813,13 @@ def test_resolve_env(self): for k, v in {'FILE_DEF': 'F1', 'FILE_DEF_EMPTY': '', 'ENV_DEF': 'E3', 'NO_DEF': ''}.items(): self.assertEqual(env[k], v) + def test_with_high_enough_api_version_we_get_default_network_mode(self): + # TODO: remove this test once minimum docker version is 1.8.x + with mock.patch.object(self.client, '_version', '1.20'): + service = self.create_service('web') + service_config = service._get_container_host_config({}) + self.assertEquals(service_config['NetworkMode'], 'default') + def test_labels(self): labels_dict = { 'com.example.description': "Accounting webapp", From 6f6c04b5c938a7ee510d63bb36912a2e7513cb71 Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Wed, 16 Sep 2015 12:02:58 +0100 Subject: [PATCH 1228/4072] Test what we are sending, not what we get This is a unit test and we are mocking the client. The method to get the create_config_host now lives on the client, so we mock that too. So we can test to the boundary that the method is called with the arguments we expect. Signed-off-by: Mazz Mosley --- tests/unit/service_test.py | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index de973339b2d..7ba630fb443 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -2,7 +2,6 @@ from __future__ import unicode_literals import docker -from docker.utils import LogConfig from .. import mock from .. import unittest @@ -108,19 +107,33 @@ def test_split_domainname_none(self): self.assertFalse('domainname' in opts, 'domainname') 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) - opts = service._get_container_create_options({'some': 'overrides'}, 1) - self.assertEqual(opts['host_config']['MemorySwap'], 2000000000) - self.assertEqual(opts['host_config']['Memory'], 1000000000) + service._get_container_create_options({'some': 'overrides'}, 1) + + self.assertTrue(self.mock_client.create_host_config.called) + self.assertEqual( + self.mock_client.create_host_config.call_args[1]['mem_limit'], + 1000000000 + ) + self.assertEqual( + self.mock_client.create_host_config.call_args[1]['memswap_limit'], + 2000000000 + ) def test_log_opt(self): + self.mock_client.create_host_config.return_value = {} + log_opt = {'syslog-address': 'tcp://192.168.0.42:123'} service = Service(name='foo', image='foo', hostname='name', client=self.mock_client, log_driver='syslog', log_opt=log_opt) - opts = service._get_container_create_options({'some': 'overrides'}, 1) + service._get_container_create_options({'some': 'overrides'}, 1) - self.assertIsInstance(opts['host_config']['LogConfig'], LogConfig) - self.assertEqual(opts['host_config']['LogConfig'].type, 'syslog') - self.assertEqual(opts['host_config']['LogConfig'].config, log_opt) + self.assertTrue(self.mock_client.create_host_config.called) + self.assertEqual( + self.mock_client.create_host_config.call_args[1]['log_config'], + {'Type': 'syslog', 'Config': {'syslog-address': 'tcp://192.168.0.42:123'}} + ) def test_split_domainname_fqdn(self): service = Service( From 39ba2c5a7cb5a4f7cec1e5a28bd43dc95492b22d Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Thu, 17 Sep 2015 15:33:58 +0100 Subject: [PATCH 1229/4072] Fix leaky tests It was mocking self.client but relying on the call to utils.create_host_config which was not mocked. So now that function has moved to also be on self.client we need to redefine the test boundary, up to where we would call docker-py, not the result of docker-py. Signed-off-by: Mazz Mosley --- tests/unit/cli_test.py | 13 +++++++++---- tests/unit/service_test.py | 10 +++++----- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/tests/unit/cli_test.py b/tests/unit/cli_test.py index 1fd9f529ed6..d12f41955d0 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -144,8 +144,11 @@ def test_run_service_with_restart_always(self): '--rm': None, '--name': None, }) - _, _, call_kwargs = mock_client.create_container.mock_calls[0] - self.assertEquals(call_kwargs['host_config']['RestartPolicy']['Name'], 'always') + + self.assertEquals( + mock_client.create_host_config.call_args[1]['restart_policy']['Name'], + 'always' + ) command = TopLevelCommand() mock_client = mock.create_autospec(docker.Client) @@ -170,8 +173,10 @@ def test_run_service_with_restart_always(self): '--rm': True, '--name': None, }) - _, _, call_kwargs = mock_client.create_container.mock_calls[0] - self.assertFalse('RestartPolicy' in call_kwargs['host_config']) + + self.assertFalse( + mock_client.create_host_config.call_args[1].get('restart_policy') + ) def test_command_manula_and_service_ports_together(self): command = TopLevelCommand() diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 7ba630fb443..5f7ae948755 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -543,13 +543,13 @@ def test_mount_same_host_path_to_two_volumes(self): } } - create_options = service._get_container_create_options( + service._get_container_create_options( override_options={}, number=1, ) self.assertEqual( - set(create_options['host_config']['Binds']), + set(self.mock_client.create_host_config.call_args[1]['binds']), set([ '/host/path:/data1:rw', '/host/path:/data2:rw', @@ -581,14 +581,14 @@ def test_different_host_path_in_container_json(self): }, } - create_options = service._get_container_create_options( + service._get_container_create_options( override_options={}, number=1, previous_container=Container(self.mock_client, {'Id': '123123123'}), ) self.assertEqual( - create_options['host_config']['Binds'], + self.mock_client.create_host_config.call_args[1]['binds'], ['/mnt/sda1/host/path:/data:rw'], ) @@ -613,4 +613,4 @@ def create_container(*args, **kwargs): ).create_container() self.assertEqual(len(create_calls), 1) - self.assertEqual(create_calls[0][1]['host_config']['Binds'], volumes) + self.assertEqual(self.mock_client.create_host_config.call_args[1]['binds'], volumes) From 9be748f85c208da8c90636db400e418a5b0f353b Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 14 Sep 2015 18:42:53 -0400 Subject: [PATCH 1230/4072] Clean before doing a build so that we don't include stale build artifacts in the binaries. Signed-off-by: Daniel Nephin --- script/build-linux | 2 ++ script/build-osx | 2 ++ script/clean | 3 +++ 3 files changed, 7 insertions(+) diff --git a/script/build-linux b/script/build-linux index 4fdf1d926f6..7d89bd1e5b5 100755 --- a/script/build-linux +++ b/script/build-linux @@ -2,6 +2,8 @@ set -ex +./script/clean + TAG="docker-compose" docker build -t "$TAG" . docker run \ diff --git a/script/build-osx b/script/build-osx index e1cc7038ac8..11b6ecc694b 100755 --- a/script/build-osx +++ b/script/build-osx @@ -3,7 +3,9 @@ set -ex PATH="/usr/local/bin:$PATH" +./script/clean rm -rf venv + virtualenv -p /usr/local/bin/python venv venv/bin/pip install -r requirements.txt venv/bin/pip install -r requirements-build.txt diff --git a/script/clean b/script/clean index 07a9cff14df..08ba551ae97 100755 --- a/script/clean +++ b/script/clean @@ -1,3 +1,6 @@ #!/bin/sh +set -e + find . -type f -name '*.pyc' -delete +find -name __pycache__ -delete rm -rf docs/_site build dist docker-compose.egg-info From 2121f5117ea035c83d4ace97fad8f2db6582afc9 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 11 Sep 2015 18:20:35 -0400 Subject: [PATCH 1231/4072] Add docopt support for multiple files Signed-off-by: Daniel Nephin --- 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 61461ae7bea..3dd0c9fae37 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -96,7 +96,7 @@ class TopLevelCommand(Command): """Define and run multi-container applications with Docker. Usage: - docker-compose [options] [COMMAND] [ARGS...] + docker-compose [-f=...] [options] [COMMAND] [ARGS...] docker-compose -h|--help Options: From 258d0fa0c660813d8b6b3d8d17731cc56a5da321 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 11 Sep 2015 19:51:50 -0400 Subject: [PATCH 1232/4072] Remove some functions from Command class Signed-off-by: Daniel Nephin --- compose/cli/command.py | 92 +++++++++++++++++++++++------------------- tests/unit/cli_test.py | 39 ++++++++---------- 2 files changed, 66 insertions(+), 65 deletions(-) diff --git a/compose/cli/command.py b/compose/cli/command.py index 67176df2719..70b129d2961 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -55,53 +55,61 @@ def perform_command(self, options, handler, command_options): log.warn('The FIG_FILE environment variable is deprecated.') log.warn('Please use COMPOSE_FILE instead.') - explicit_config_path = options.get('--file') or os.environ.get('COMPOSE_FILE') or os.environ.get('FIG_FILE') - project = self.get_project( + explicit_config_path = ( + options.get('--file') or + os.environ.get('COMPOSE_FILE') or + os.environ.get('FIG_FILE')) + + project = get_project( + self.base_dir, explicit_config_path, project_name=options.get('--project-name'), verbose=options.get('--verbose')) handler(project, command_options) - def get_client(self, verbose=False): - client = docker_client() - if verbose: - version_info = six.iteritems(client.version()) - log.info("Compose version %s", __version__) - log.info("Docker base_url: %s", client.base_url) - log.info("Docker version: %s", - ", ".join("%s=%s" % item for item in version_info)) - return verbose_proxy.VerboseProxy('docker', client) - return client - def get_project(self, config_path=None, project_name=None, verbose=False): - config_details = config.find(self.base_dir, config_path) +def get_client(verbose=False): + client = docker_client() + if verbose: + version_info = six.iteritems(client.version()) + log.info("Compose version %s", __version__) + log.info("Docker base_url: %s", client.base_url) + log.info("Docker version: %s", + ", ".join("%s=%s" % item for item in version_info)) + return verbose_proxy.VerboseProxy('docker', client) + return client - try: - return Project.from_dicts( - self.get_project_name(config_details.working_dir, project_name), - config.load(config_details), - self.get_client(verbose=verbose)) - except ConfigError as e: - raise errors.UserError(six.text_type(e)) - - def get_project_name(self, working_dir, project_name=None): - def normalize_name(name): - return re.sub(r'[^a-z0-9]', '', name.lower()) - - if 'FIG_PROJECT_NAME' in os.environ: - log.warn('The FIG_PROJECT_NAME environment variable is deprecated.') - log.warn('Please use COMPOSE_PROJECT_NAME instead.') - - project_name = ( - project_name or - os.environ.get('COMPOSE_PROJECT_NAME') or - os.environ.get('FIG_PROJECT_NAME')) - if project_name is not None: - return normalize_name(project_name) - - project = os.path.basename(os.path.abspath(working_dir)) - if project: - return normalize_name(project) - - return 'default' + +def get_project(base_dir, config_path=None, project_name=None, verbose=False): + config_details = config.find(base_dir, config_path) + + try: + return Project.from_dicts( + get_project_name(config_details.working_dir, project_name), + config.load(config_details), + get_client(verbose=verbose)) + except ConfigError as e: + raise errors.UserError(six.text_type(e)) + + +def get_project_name(working_dir, project_name=None): + def normalize_name(name): + return re.sub(r'[^a-z0-9]', '', name.lower()) + + if 'FIG_PROJECT_NAME' in os.environ: + log.warn('The FIG_PROJECT_NAME environment variable is deprecated.') + log.warn('Please use COMPOSE_PROJECT_NAME instead.') + + project_name = ( + project_name or + os.environ.get('COMPOSE_PROJECT_NAME') or + os.environ.get('FIG_PROJECT_NAME')) + if project_name is not None: + return normalize_name(project_name) + + project = os.path.basename(os.path.abspath(working_dir)) + if project: + return normalize_name(project) + + return 'default' diff --git a/tests/unit/cli_test.py b/tests/unit/cli_test.py index d12f41955d0..321df97a53f 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -4,9 +4,12 @@ import os import docker +import py from .. import mock from .. import unittest +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 @@ -14,55 +17,45 @@ class CLITestCase(unittest.TestCase): - def test_default_project_name(self): - cwd = os.getcwd() - try: - os.chdir('tests/fixtures/simple-composefile') - command = TopLevelCommand() - project_name = command.get_project_name('.') + def test_default_project_name(self): + test_dir = py._path.local.LocalPath('tests/fixtures/simple-composefile') + with test_dir.as_cwd(): + project_name = get_project_name('.') self.assertEquals('simplecomposefile', project_name) - finally: - os.chdir(cwd) def test_project_name_with_explicit_base_dir(self): - command = TopLevelCommand() - command.base_dir = 'tests/fixtures/simple-composefile' - project_name = command.get_project_name(command.base_dir) + base_dir = 'tests/fixtures/simple-composefile' + project_name = get_project_name(base_dir) self.assertEquals('simplecomposefile', project_name) def test_project_name_with_explicit_uppercase_base_dir(self): - command = TopLevelCommand() - command.base_dir = 'tests/fixtures/UpperCaseDir' - project_name = command.get_project_name(command.base_dir) + base_dir = 'tests/fixtures/UpperCaseDir' + project_name = get_project_name(base_dir) self.assertEquals('uppercasedir', project_name) def test_project_name_with_explicit_project_name(self): - command = TopLevelCommand() name = 'explicit-project-name' - project_name = command.get_project_name(None, project_name=name) + project_name = get_project_name(None, project_name=name) self.assertEquals('explicitprojectname', project_name) def test_project_name_from_environment_old_var(self): - command = TopLevelCommand() name = 'namefromenv' with mock.patch.dict(os.environ): os.environ['FIG_PROJECT_NAME'] = name - project_name = command.get_project_name(None) + project_name = get_project_name(None) self.assertEquals(project_name, name) def test_project_name_from_environment_new_var(self): - command = TopLevelCommand() name = 'namefromenv' with mock.patch.dict(os.environ): os.environ['COMPOSE_PROJECT_NAME'] = name - project_name = command.get_project_name(None) + project_name = get_project_name(None) self.assertEquals(project_name, name) def test_get_project(self): - command = TopLevelCommand() - command.base_dir = 'tests/fixtures/longer-filename-composefile' - project = command.get_project() + base_dir = 'tests/fixtures/longer-filename-composefile' + project = get_project(base_dir) self.assertEqual(project.name, 'longerfilenamecomposefile') self.assertTrue(project.client) self.assertTrue(project.services) From 10b3188214fc6716387339bb0146d8d901962e93 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 11 Sep 2015 20:18:45 -0400 Subject: [PATCH 1233/4072] Support multiple config files Signed-off-by: Daniel Nephin --- compose/cli/command.py | 22 +++++---- compose/config/config.py | 68 +++++++++++++++++---------- tests/unit/config_test.py | 96 +++++++++++++++++++++------------------ 3 files changed, 106 insertions(+), 80 deletions(-) diff --git a/compose/cli/command.py b/compose/cli/command.py index 70b129d2961..2120ec4db53 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -51,24 +51,26 @@ def perform_command(self, options, handler, command_options): handler(None, command_options) return - if 'FIG_FILE' in os.environ: - log.warn('The FIG_FILE environment variable is deprecated.') - log.warn('Please use COMPOSE_FILE instead.') - - explicit_config_path = ( - options.get('--file') or - os.environ.get('COMPOSE_FILE') or - os.environ.get('FIG_FILE')) - project = get_project( self.base_dir, - explicit_config_path, + get_config_path(options.get('--file')), project_name=options.get('--project-name'), verbose=options.get('--verbose')) handler(project, command_options) +def get_config_path(file_option): + if file_option: + return file_option + + if 'FIG_FILE' in os.environ: + log.warn('The FIG_FILE environment variable is deprecated.') + log.warn('Please use COMPOSE_FILE instead.') + + return [os.environ.get('COMPOSE_FILE') or os.environ.get('FIG_FILE')] + + def get_client(verbose=False): client = docker_client() if verbose: diff --git a/compose/config/config.py b/compose/config/config.py index 840a28a1b55..204f70b6669 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -2,6 +2,7 @@ import os import sys from collections import namedtuple +from functools import reduce import six import yaml @@ -88,18 +89,24 @@ log = logging.getLogger(__name__) -ConfigDetails = namedtuple('ConfigDetails', 'config working_dir filename') +ConfigDetails = namedtuple('ConfigDetails', 'working_dir configs') +ConfigFile = namedtuple('ConfigFile', 'filename config') -def find(base_dir, filename): - if filename == '-': - return ConfigDetails(yaml.safe_load(sys.stdin), os.getcwd(), None) - if filename: - filename = os.path.join(base_dir, filename) +def find(base_dir, filenames): + if filenames == ['-']: + return ConfigDetails( + os.getcwd(), + [ConfigFile(None, yaml.safe_load(sys.stdin))]) + + if filenames: + filenames = [os.path.join(base_dir, f) for f in filenames] else: - filename = get_config_path(base_dir) - return ConfigDetails(load_yaml(filename), os.path.dirname(filename), filename) + filenames = [get_config_path(base_dir)] + return ConfigDetails( + os.path.dirname(filenames[0]), + [ConfigFile(f, load_yaml(f)) for f in filenames]) def get_config_path(base_dir): @@ -133,29 +140,40 @@ def pre_process_config(config): Pre validation checks and processing of the config file to interpolate env vars returning a config dict ready to be tested against the schema. """ - config = interpolate_environment_variables(config) - return config + return interpolate_environment_variables(config) def load(config_details): - config, working_dir, filename = config_details - - processed_config = pre_process_config(config) - validate_against_fields_schema(processed_config) + working_dir, configs = config_details - service_dicts = [] - - for service_name, service_dict in list(processed_config.items()): - loader = ServiceLoader( - working_dir=working_dir, - filename=filename, - service_name=service_name, - service_dict=service_dict) + def build_service(filename, service_name, service_dict): + loader = ServiceLoader(working_dir, filename, service_name, service_dict) service_dict = loader.make_service_dict() validate_paths(service_dict) - service_dicts.append(service_dict) - - return service_dicts + return service_dict + + def load_file(filename, config): + processed_config = pre_process_config(config) + validate_against_fields_schema(processed_config) + return [ + build_service(filename, name, service_config) + for name, service_config in processed_config.items() + ] + + def merge_services(base, override): + return { + name: merge_service_dicts(base.get(name, {}), override.get(name, {})) + for name in set(base) | set(override) + } + + def combine_configs(override, base): + service_dicts = load_file(base.filename, base.config) + if not override: + return service_dicts + + return merge_service_dicts(base.config, override.config) + + return reduce(combine_configs, configs, None) class ServiceLoader(object): diff --git a/tests/unit/config_test.py b/tests/unit/config_test.py index ff80270e6d1..0347e443f83 100644 --- a/tests/unit/config_test.py +++ b/tests/unit/config_test.py @@ -26,10 +26,16 @@ def service_sort(services): return sorted(services, key=itemgetter('name')) +def build_config_details(contents, working_dir, filename): + return config.ConfigDetails( + working_dir, + [config.ConfigFile(filename, contents)]) + + class ConfigTest(unittest.TestCase): def test_load(self): service_dicts = config.load( - config.ConfigDetails( + build_config_details( { 'foo': {'image': 'busybox'}, 'bar': {'image': 'busybox', 'environment': ['FOO=1']}, @@ -57,7 +63,7 @@ def test_load(self): def test_load_throws_error_when_not_dict(self): with self.assertRaises(ConfigurationError): config.load( - config.ConfigDetails( + build_config_details( {'web': 'busybox:latest'}, 'working_dir', 'filename.yml' @@ -68,7 +74,7 @@ def test_config_invalid_service_names(self): with self.assertRaises(ConfigurationError): for invalid_name in ['?not?allowed', ' ', '', '!', '/', '\xe2']: config.load( - config.ConfigDetails( + build_config_details( {invalid_name: {'image': 'busybox'}}, 'working_dir', 'filename.yml' @@ -79,7 +85,7 @@ def test_config_integer_service_name_raise_validation_error(self): expected_error_msg = "Service name: 1 needs to be a string, eg '1'" with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): config.load( - config.ConfigDetails( + build_config_details( {1: {'image': 'busybox'}}, 'working_dir', 'filename.yml' @@ -89,7 +95,7 @@ def test_config_integer_service_name_raise_validation_error(self): def test_config_valid_service_names(self): for valid_name in ['_', '-', '.__.', '_what-up.', 'what_.up----', 'whatup']: config.load( - config.ConfigDetails( + build_config_details( {valid_name: {'image': 'busybox'}}, 'tests/fixtures/extends', 'common.yml' @@ -101,7 +107,7 @@ def test_config_invalid_ports_format_validation(self): with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): for invalid_ports in [{"1": "8000"}, False, 0, "8000", 8000, ["8000", "8000"]]: config.load( - config.ConfigDetails( + build_config_details( {'web': {'image': 'busybox', 'ports': invalid_ports}}, 'working_dir', 'filename.yml' @@ -112,7 +118,7 @@ def test_config_valid_ports_format_validation(self): valid_ports = [["8000", "9000"], ["8000/8050"], ["8000"], [8000], ["49153-49154:3002-3003"]] for ports in valid_ports: config.load( - config.ConfigDetails( + build_config_details( {'web': {'image': 'busybox', 'ports': ports}}, 'working_dir', 'filename.yml' @@ -123,7 +129,7 @@ def test_config_hint(self): expected_error_msg = "(did you mean 'privileged'?)" with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): config.load( - config.ConfigDetails( + build_config_details( { 'foo': {'image': 'busybox', 'privilige': 'something'}, }, @@ -136,7 +142,7 @@ 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): config.load( - config.ConfigDetails( + build_config_details( { 'foo': {'image': 'busybox', 'build': '.'}, }, @@ -149,7 +155,7 @@ 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): config.load( - config.ConfigDetails( + build_config_details( { 'foo': {'image': 'busybox', 'links': 'an_link'}, }, @@ -162,7 +168,7 @@ def test_invalid_config_not_a_dictionary(self): expected_error_msg = "Top level object needs to be a dictionary." with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): config.load( - config.ConfigDetails( + build_config_details( ['foo', 'lol'], 'tests/fixtures/extends', 'filename.yml' @@ -173,7 +179,7 @@ def test_invalid_config_not_unique_items(self): expected_error_msg = "has non-unique elements" with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): config.load( - config.ConfigDetails( + build_config_details( { 'web': {'build': '.', 'devices': ['/dev/foo:/dev/foo', '/dev/foo:/dev/foo']} }, @@ -187,7 +193,7 @@ def test_invalid_list_of_strings_format(self): expected_error_msg += ", which is an invalid type, it should be a string" with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): config.load( - config.ConfigDetails( + build_config_details( { 'web': {'build': '.', 'command': [1]} }, @@ -200,7 +206,7 @@ def test_config_image_and_dockerfile_raise_validation_error(self): expected_error_msg = "Service 'web' has both an image and alternate Dockerfile." with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): config.load( - config.ConfigDetails( + build_config_details( {'web': {'image': 'busybox', 'dockerfile': 'Dockerfile.alt'}}, 'working_dir', 'filename.yml' @@ -212,7 +218,7 @@ def test_config_extra_hosts_string_raises_validation_error(self): with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): config.load( - config.ConfigDetails( + build_config_details( {'web': { 'image': 'busybox', 'extra_hosts': 'somehost:162.242.195.82' @@ -227,7 +233,7 @@ def test_config_extra_hosts_list_of_dicts_validation_error(self): with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): config.load( - config.ConfigDetails( + build_config_details( {'web': { 'image': 'busybox', 'extra_hosts': [ @@ -244,7 +250,7 @@ def test_valid_config_which_allows_two_type_definitions(self): expose_values = [["8000"], [8000]] for expose in expose_values: service = config.load( - config.ConfigDetails( + build_config_details( {'web': { 'image': 'busybox', 'expose': expose @@ -259,7 +265,7 @@ def test_valid_config_oneof_string_or_list(self): entrypoint_values = [["sh"], "sh"] for entrypoint in entrypoint_values: service = config.load( - config.ConfigDetails( + build_config_details( {'web': { 'image': 'busybox', 'entrypoint': entrypoint @@ -331,16 +337,16 @@ def test_config_file_with_environment_variable(self): def test_unset_variable_produces_warning(self): os.environ.pop('FOO', None) os.environ.pop('BAR', None) - config_details = config.ConfigDetails( - config={ + config_details = build_config_details( + { 'web': { 'image': '${FOO}', 'command': '${BAR}', 'container_name': '${BAR}', }, }, - working_dir='.', - filename=None, + '.', + None, ) with mock.patch('compose.config.interpolation.log') as log: @@ -355,7 +361,7 @@ def test_unset_variable_produces_warning(self): def test_invalid_interpolation(self): with self.assertRaises(config.ConfigurationError) as cm: config.load( - config.ConfigDetails( + build_config_details( {'web': {'image': '${'}}, 'working_dir', 'filename.yml' @@ -371,10 +377,10 @@ def test_invalid_interpolation(self): def test_volume_binding_with_environment_variable(self): os.environ['VOLUME_PATH'] = '/host/path' d = config.load( - config.ConfigDetails( - config={'foo': {'build': '.', 'volumes': ['${VOLUME_PATH}:/container/path']}}, - working_dir='.', - filename=None, + build_config_details( + {'foo': {'build': '.', 'volumes': ['${VOLUME_PATH}:/container/path']}}, + '.', + None, ) )[0] self.assertEqual(d['volumes'], ['/host/path:/container/path']) @@ -649,7 +655,7 @@ def test_validation_fails_with_just_memswap_limit(self): ) with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): config.load( - config.ConfigDetails( + build_config_details( { 'foo': {'image': 'busybox', 'memswap_limit': 2000000}, }, @@ -660,7 +666,7 @@ def test_validation_fails_with_just_memswap_limit(self): def test_validation_with_correct_memswap_values(self): service_dict = config.load( - config.ConfigDetails( + build_config_details( {'foo': {'image': 'busybox', 'mem_limit': 1000000, 'memswap_limit': 2000000}}, 'tests/fixtures/extends', 'common.yml' @@ -670,7 +676,7 @@ def test_validation_with_correct_memswap_values(self): def test_memswap_can_be_a_string(self): service_dict = config.load( - config.ConfigDetails( + build_config_details( {'foo': {'image': 'busybox', 'mem_limit': "1G", 'memswap_limit': "512M"}}, 'tests/fixtures/extends', 'common.yml' @@ -780,26 +786,26 @@ def test_resolve_path(self): os.environ['CONTAINERENV'] = '/host/tmp' service_dict = config.load( - config.ConfigDetails( - config={'foo': {'build': '.', 'volumes': ['$HOSTENV:$CONTAINERENV']}}, - working_dir="tests/fixtures/env", - filename=None, + build_config_details( + {'foo': {'build': '.', 'volumes': ['$HOSTENV:$CONTAINERENV']}}, + "tests/fixtures/env", + None, ) )[0] self.assertEqual(set(service_dict['volumes']), set(['/tmp:/host/tmp'])) service_dict = config.load( - config.ConfigDetails( - config={'foo': {'build': '.', 'volumes': ['/opt${HOSTENV}:/opt${CONTAINERENV}']}}, - working_dir="tests/fixtures/env", - filename=None, + build_config_details( + {'foo': {'build': '.', 'volumes': ['/opt${HOSTENV}:/opt${CONTAINERENV}']}}, + "tests/fixtures/env", + None, ) )[0] self.assertEqual(set(service_dict['volumes']), set(['/opt/tmp:/opt/host/tmp'])) def load_from_filename(filename): - return config.load(config.find('.', filename)) + return config.load(config.find('.', [filename])) class ExtendsTest(unittest.TestCase): @@ -885,7 +891,7 @@ def test_circular(self): def test_extends_validation_empty_dictionary(self): with self.assertRaisesRegexp(ConfigurationError, 'service'): config.load( - config.ConfigDetails( + build_config_details( { 'web': {'image': 'busybox', 'extends': {}}, }, @@ -897,7 +903,7 @@ def test_extends_validation_empty_dictionary(self): def test_extends_validation_missing_service_key(self): with self.assertRaisesRegexp(ConfigurationError, "'service' is a required property"): config.load( - config.ConfigDetails( + build_config_details( { 'web': {'image': 'busybox', 'extends': {'file': 'common.yml'}}, }, @@ -910,7 +916,7 @@ def test_extends_validation_invalid_key(self): expected_error_msg = "Unsupported config option for 'web' service: 'rogue_key'" with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): config.load( - config.ConfigDetails( + build_config_details( { 'web': { 'image': 'busybox', @@ -930,7 +936,7 @@ def test_extends_validation_sub_property_key(self): expected_error_msg = "Service 'web' configuration key 'extends' 'file' contains an invalid type" with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): config.load( - config.ConfigDetails( + build_config_details( { 'web': { 'image': 'busybox', @@ -955,7 +961,7 @@ def load_config(): def test_extends_validation_valid_config(self): service = config.load( - config.ConfigDetails( + build_config_details( { 'web': {'image': 'busybox', 'extends': {'service': 'web', 'file': 'common.yml'}}, }, @@ -1093,7 +1099,7 @@ def setUp(self): def test_nonexistent_path(self): with self.assertRaises(ConfigurationError): config.load( - config.ConfigDetails( + build_config_details( { 'foo': {'build': 'nonexistent.path'}, }, From c0c9a7c1e4d22980afb6e22817a960f7424f0eae Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 11 Sep 2015 20:50:31 -0400 Subject: [PATCH 1234/4072] Update integration tests for multiple file support Signed-off-by: Daniel Nephin --- compose/cli/command.py | 3 ++- tests/integration/cli_test.py | 7 ++++--- tests/integration/project_test.py | 7 +++++-- tests/integration/state_test.py | 8 +++++--- 4 files changed, 16 insertions(+), 9 deletions(-) diff --git a/compose/cli/command.py b/compose/cli/command.py index 2120ec4db53..950cb166e63 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -68,7 +68,8 @@ def get_config_path(file_option): log.warn('The FIG_FILE environment variable is deprecated.') log.warn('Please use COMPOSE_FILE instead.') - return [os.environ.get('COMPOSE_FILE') or os.environ.get('FIG_FILE')] + config_file = os.environ.get('COMPOSE_FILE') or os.environ.get('FIG_FILE') + return [config_file] if config_file else None def get_client(verbose=False): diff --git a/tests/integration/cli_test.py b/tests/integration/cli_test.py index 4a80d336957..8688fb8b4d6 100644 --- a/tests/integration/cli_test.py +++ b/tests/integration/cli_test.py @@ -9,6 +9,7 @@ from .. import mock from .testcases import DockerClientTestCase +from compose.cli.command import get_project from compose.cli.errors import UserError from compose.cli.main import TopLevelCommand from compose.project import NoSuchService @@ -38,7 +39,7 @@ def project(self): if hasattr(self, '_project'): return self._project - return self.command.get_project() + return get_project(self.command.base_dir) def test_help(self): old_base_dir = self.command.base_dir @@ -72,7 +73,7 @@ def test_ps_default_composefile(self, mock_stdout): def test_ps_alternate_composefile(self, mock_stdout): config_path = os.path.abspath( 'tests/fixtures/multiple-composefiles/compose2.yml') - self._project = self.command.get_project(config_path) + self._project = get_project(self.command.base_dir, [config_path]) self.command.base_dir = 'tests/fixtures/multiple-composefiles' self.command.dispatch(['-f', 'compose2.yml', 'up', '-d'], None) @@ -571,7 +572,7 @@ def get_port(number, mock_stdout, index=None): def test_env_file_relative_to_compose_file(self): config_path = os.path.abspath('tests/fixtures/env-file/docker-compose.yml') self.command.dispatch(['-f', config_path, 'up', '-d'], None) - self._project = self.command.get_project(config_path) + self._project = get_project(self.command.base_dir, [config_path]) containers = self.project.containers(stopped=True) self.assertEqual(len(containers), 1) diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index ad49ad10a8d..bd7ecccbe8a 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -1,7 +1,7 @@ from __future__ import unicode_literals from .testcases import DockerClientTestCase -from compose import config +from compose.config import config from compose.const import LABEL_PROJECT from compose.container import Container from compose.project import Project @@ -9,7 +9,10 @@ def build_service_dicts(service_config): - return config.load(config.ConfigDetails(service_config, 'working_dir', None)) + return config.load( + config.ConfigDetails( + 'working_dir', + [config.ConfigFile(None, service_config)])) class ProjectTest(DockerClientTestCase): diff --git a/tests/integration/state_test.py b/tests/integration/state_test.py index 93d0572a085..ef7276bd8d1 100644 --- a/tests/integration/state_test.py +++ b/tests/integration/state_test.py @@ -9,7 +9,7 @@ import tempfile from .testcases import DockerClientTestCase -from compose import config +from compose.config import config from compose.const import LABEL_CONFIG_HASH from compose.project import Project from compose.service import ConvergenceStrategy @@ -24,11 +24,13 @@ def run_up(self, cfg, **kwargs): return set(project.containers(stopped=True)) def make_project(self, cfg): + details = config.ConfigDetails( + 'working_dir', + [config.ConfigFile(None, cfg)]) return Project.from_dicts( name='composetest', client=self.client, - service_dicts=config.load(config.ConfigDetails(cfg, 'working_dir', None)) - ) + service_dicts=config.load(details)) class BasicProjectTest(ProjectTestCase): From 831276f53163c0999ec635d92629e6e1b4ba2683 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 14 Sep 2015 21:30:32 -0400 Subject: [PATCH 1235/4072] Move config_test to the correct package name. Signed-off-by: Daniel Nephin --- tests/unit/config/__init__.py | 0 tests/unit/{ => config}/config_test.py | 4 ++-- 2 files changed, 2 insertions(+), 2 deletions(-) create mode 100644 tests/unit/config/__init__.py rename tests/unit/{ => config}/config_test.py (99%) diff --git a/tests/unit/config/__init__.py b/tests/unit/config/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/unit/config_test.py b/tests/unit/config/config_test.py similarity index 99% rename from tests/unit/config_test.py rename to tests/unit/config/config_test.py index 0347e443f83..3542f272b8c 100644 --- a/tests/unit/config_test.py +++ b/tests/unit/config/config_test.py @@ -280,7 +280,7 @@ def test_valid_config_oneof_string_or_list(self): def test_logs_warning_for_boolean_in_environment(self, mock_logging): expected_warning_msg = "Warning: There is a boolean value, True in the 'environment' key." config.load( - config.ConfigDetails( + build_config_details( {'web': { 'image': 'busybox', 'environment': {'SHOW_STUFF': True} @@ -298,7 +298,7 @@ def test_config_invalid_environment_dict_key_raises_validation_error(self): with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): config.load( - config.ConfigDetails( + build_config_details( {'web': { 'image': 'busybox', 'environment': {'---': 'nope'} From 89be7f1fa76f53dbc082715eadec65e08f992e8a Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 14 Sep 2015 21:35:41 -0400 Subject: [PATCH 1236/4072] Unit tests for multiple files Signed-off-by: Daniel Nephin --- compose/config/config.py | 8 ++++--- tests/unit/config/config_test.py | 41 ++++++++++++++++++++++++++++++-- 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 204f70b6669..058183d97dd 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -166,14 +166,16 @@ def merge_services(base, override): for name in set(base) | set(override) } - def combine_configs(override, base): + def combine_configs(base, override): service_dicts = load_file(base.filename, base.config) if not override: return service_dicts - return merge_service_dicts(base.config, override.config) + return ConfigFile( + override.filename, + merge_services(base.config, override.config)) - return reduce(combine_configs, configs, None) + return reduce(combine_configs, configs + [None]) class ServiceLoader(object): diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 3542f272b8c..60f4bbe2223 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -5,10 +5,10 @@ import tempfile from operator import itemgetter -from .. import mock -from .. import unittest from compose.config import config from compose.config.errors import ConfigurationError +from tests import mock +from tests import unittest def make_service_dict(name, service_dict, working_dir, filename=None): @@ -92,6 +92,43 @@ def test_config_integer_service_name_raise_validation_error(self): ) ) + def test_load_with_multiple_files(self): + base_file = config.ConfigFile( + 'base.yaml', + { + 'web': { + 'image': 'example/web', + 'links': ['db'], + }, + 'db': { + 'image': 'example/db', + }, + }) + override_file = config.ConfigFile( + 'override.yaml', + { + 'web': { + 'build': '/', + 'volumes': ['/home/user/project:/code'], + }, + }) + details = config.ConfigDetails('.', [base_file, override_file]) + + service_dicts = config.load(details) + expected = [ + { + 'name': 'web', + 'build': '/', + 'links': ['db'], + 'volumes': ['/home/user/project:/code'], + }, + { + 'name': 'db', + 'image': 'example/db', + }, + ] + self.assertEqual(service_sort(service_dicts), service_sort(expected)) + def test_config_valid_service_names(self): for valid_name in ['_', '-', '.__.', '_what-up.', 'what_.up----', 'whatup']: config.load( From fe5daf860dfb341ba894d79d225689ed2e981064 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 15 Sep 2015 14:17:27 -0400 Subject: [PATCH 1237/4072] Move find_candidates_in_parent_dirs() into a config module so that config doesn't import from cli. Signed-off-by: Daniel Nephin --- compose/cli/utils.py | 19 ------------------- compose/config/config.py | 24 +++++++++++++++++++++--- 2 files changed, 21 insertions(+), 22 deletions(-) diff --git a/compose/cli/utils.py b/compose/cli/utils.py index 0b7ac683d1b..0a4416c0f32 100644 --- a/compose/cli/utils.py +++ b/compose/cli/utils.py @@ -36,25 +36,6 @@ def yesno(prompt, default=None): return None -def find_candidates_in_parent_dirs(filenames, path): - """ - Given a directory path to start, looks for filenames in the - directory, and then each parent directory successively, - until found. - - Returns tuple (candidates, path). - """ - candidates = [filename for filename in filenames - if os.path.exists(os.path.join(path, filename))] - - if len(candidates) == 0: - parent_dir = os.path.join(path, '..') - if os.path.abspath(parent_dir) != os.path.abspath(path): - return find_candidates_in_parent_dirs(filenames, parent_dir) - - return (candidates, path) - - def split_buffer(reader, separator): """ Given a generator which yields strings and a separator string, diff --git a/compose/config/config.py b/compose/config/config.py index 058183d97dd..2e4d0a75116 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -17,7 +17,6 @@ from .validation import validate_extends_file_path from .validation import validate_service_names from .validation import validate_top_level_object -from compose.cli.utils import find_candidates_in_parent_dirs DOCKER_CONFIG_KEYS = [ @@ -103,13 +102,13 @@ def find(base_dir, filenames): if filenames: filenames = [os.path.join(base_dir, f) for f in filenames] else: - filenames = [get_config_path(base_dir)] + filenames = get_default_config_path(base_dir) return ConfigDetails( os.path.dirname(filenames[0]), [ConfigFile(f, load_yaml(f)) for f in filenames]) -def get_config_path(base_dir): +def get_default_config_path(base_dir): (candidates, path) = find_candidates_in_parent_dirs(SUPPORTED_FILENAMES, base_dir) if len(candidates) == 0: @@ -133,6 +132,25 @@ def get_config_path(base_dir): return os.path.join(path, winner) +def find_candidates_in_parent_dirs(filenames, path): + """ + Given a directory path to start, looks for filenames in the + directory, and then each parent directory successively, + until found. + + Returns tuple (candidates, path). + """ + candidates = [filename for filename in filenames + if os.path.exists(os.path.join(path, filename))] + + if len(candidates) == 0: + parent_dir = os.path.join(path, '..') + if os.path.abspath(parent_dir) != os.path.abspath(path): + return find_candidates_in_parent_dirs(filenames, parent_dir) + + return (candidates, path) + + @validate_top_level_object @validate_service_names def pre_process_config(config): From 39ae85db8ad4aa6429d6c4863d67b21d1c93aac7 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 15 Sep 2015 14:43:18 -0400 Subject: [PATCH 1238/4072] Support a default docker-compose.override.yml for overrides Signed-off-by: Daniel Nephin --- compose/config/config.py | 16 +++++--- .../docker-compose.override.yml | 6 +++ .../override-files/docker-compose.yml | 10 +++++ tests/fixtures/override-files/extra.yml | 9 +++++ tests/integration/cli_test.py | 39 ++++++++++++++++++- tests/unit/config/config_test.py | 3 +- 6 files changed, 76 insertions(+), 7 deletions(-) create mode 100644 tests/fixtures/override-files/docker-compose.override.yml create mode 100644 tests/fixtures/override-files/docker-compose.yml create mode 100644 tests/fixtures/override-files/extra.yml diff --git a/compose/config/config.py b/compose/config/config.py index 2e4d0a75116..3ecdd29d7b0 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -77,6 +77,7 @@ 'fig.yaml', ] +DEFAULT_OVERRIDE_FILENAME = 'docker-compose.override.yml' PATH_START_CHARS = [ '/', @@ -102,16 +103,16 @@ def find(base_dir, filenames): if filenames: filenames = [os.path.join(base_dir, f) for f in filenames] else: - filenames = get_default_config_path(base_dir) + filenames = get_default_config_files(base_dir) return ConfigDetails( os.path.dirname(filenames[0]), [ConfigFile(f, load_yaml(f)) for f in filenames]) -def get_default_config_path(base_dir): +def get_default_config_files(base_dir): (candidates, path) = find_candidates_in_parent_dirs(SUPPORTED_FILENAMES, base_dir) - if len(candidates) == 0: + if not candidates: raise ComposeFileNotFound(SUPPORTED_FILENAMES) winner = candidates[0] @@ -129,7 +130,12 @@ def get_default_config_path(base_dir): log.warn("%s is deprecated and will not be supported in future. " "Please rename your config file to docker-compose.yml\n" % winner) - return os.path.join(path, winner) + return [os.path.join(path, winner)] + get_default_override_file(path) + + +def get_default_override_file(path): + override_filename = os.path.join(path, DEFAULT_OVERRIDE_FILENAME) + return [override_filename] if os.path.exists(override_filename) else [] def find_candidates_in_parent_dirs(filenames, path): @@ -143,7 +149,7 @@ def find_candidates_in_parent_dirs(filenames, path): candidates = [filename for filename in filenames if os.path.exists(os.path.join(path, filename))] - if len(candidates) == 0: + if not candidates: parent_dir = os.path.join(path, '..') if os.path.abspath(parent_dir) != os.path.abspath(path): return find_candidates_in_parent_dirs(filenames, parent_dir) diff --git a/tests/fixtures/override-files/docker-compose.override.yml b/tests/fixtures/override-files/docker-compose.override.yml new file mode 100644 index 00000000000..a03d3d6f5f0 --- /dev/null +++ b/tests/fixtures/override-files/docker-compose.override.yml @@ -0,0 +1,6 @@ + +web: + command: "top" + +db: + command: "top" diff --git a/tests/fixtures/override-files/docker-compose.yml b/tests/fixtures/override-files/docker-compose.yml new file mode 100644 index 00000000000..8eb43ddb06c --- /dev/null +++ b/tests/fixtures/override-files/docker-compose.yml @@ -0,0 +1,10 @@ + +web: + image: busybox:latest + command: "sleep 200" + links: + - db + +db: + image: busybox:latest + command: "sleep 200" diff --git a/tests/fixtures/override-files/extra.yml b/tests/fixtures/override-files/extra.yml new file mode 100644 index 00000000000..7b3ade9c2d1 --- /dev/null +++ b/tests/fixtures/override-files/extra.yml @@ -0,0 +1,9 @@ + +web: + links: + - db + - other + +other: + image: busybox:latest + command: "top" diff --git a/tests/integration/cli_test.py b/tests/integration/cli_test.py index 8688fb8b4d6..33fdda3bec4 100644 --- a/tests/integration/cli_test.py +++ b/tests/integration/cli_test.py @@ -549,7 +549,6 @@ def get_port(number, mock_stdout): self.assertEqual(get_port(3002), "0.0.0.0:49153") def test_port_with_scale(self): - self.command.base_dir = 'tests/fixtures/ports-composefile-scale' self.command.dispatch(['scale', 'simple=2'], None) containers = sorted( @@ -593,6 +592,44 @@ def test_home_and_env_var_in_volume_path(self): self.assertTrue(components[-2:] == ['home-dir', 'my-volume'], msg="Last two components differ: %s, %s" % (actual_host_path, expected_host_path)) + def test_up_with_default_override_file(self): + self.command.base_dir = 'tests/fixtures/override-files' + self.command.dispatch(['up', '-d'], None) + + containers = self.project.containers() + self.assertEqual(len(containers), 2) + + web, db = containers + self.assertEqual(web.human_readable_command, 'top') + self.assertEqual(db.human_readable_command, 'top') + + def test_up_with_multiple_files(self): + self.command.base_dir = 'tests/fixtures/override-files' + config_paths = [ + 'docker-compose.yml', + 'docker-compose.override.yml', + 'extra.yml', + + ] + self._project = get_project(self.command.base_dir, config_paths) + self.command.dispatch( + [ + '-f', config_paths[0], + '-f', config_paths[1], + '-f', config_paths[2], + 'up', '-d', + ], + None) + + containers = self.project.containers() + self.assertEqual(len(containers), 3) + + web, other, db = containers + self.assertEqual(web.human_readable_command, 'top') + self.assertTrue({'db', 'other'} <= set(web.links())) + self.assertEqual(db.human_readable_command, 'top') + self.assertEqual(other.human_readable_command, 'top') + def test_up_with_extends(self): self.command.base_dir = 'tests/fixtures/extends' self.command.dispatch(['up', '-d'], None) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 60f4bbe2223..38eb3de23c1 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1213,6 +1213,7 @@ def make_files(dirname, filenames): base_dir = tempfile.mkdtemp(dir=project_dir) else: base_dir = project_dir - return os.path.basename(config.get_config_path(base_dir)) + filename, = config.get_default_config_files(base_dir) + return os.path.basename(filename) finally: shutil.rmtree(project_dir) From be0611bf3e435c9431d023b7f1fdef0e487554b0 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 15 Sep 2015 14:47:33 -0400 Subject: [PATCH 1239/4072] Cleanup get_default_config_files tests. Signed-off-by: Daniel Nephin --- tests/unit/config/config_test.py | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 38eb3de23c1..79864ec7843 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1167,7 +1167,7 @@ def test_from_file(self): self.assertEquals(service_dict, [{'name': 'foo', 'build': self.abs_context_path}]) -class GetConfigPathTestCase(unittest.TestCase): +class GetDefaultConfigFilesTestCase(unittest.TestCase): files = [ 'docker-compose.yml', @@ -1177,25 +1177,21 @@ class GetConfigPathTestCase(unittest.TestCase): ] def test_get_config_path_default_file_in_basedir(self): - files = self.files - self.assertEqual('docker-compose.yml', get_config_filename_for_files(files[0:])) - self.assertEqual('docker-compose.yaml', get_config_filename_for_files(files[1:])) - self.assertEqual('fig.yml', get_config_filename_for_files(files[2:])) - self.assertEqual('fig.yaml', get_config_filename_for_files(files[3:])) + for index, filename in enumerate(self.files): + self.assertEqual( + filename, + get_config_filename_for_files(self.files[index:])) with self.assertRaises(config.ComposeFileNotFound): get_config_filename_for_files([]) def test_get_config_path_default_file_in_parent_dir(self): """Test with files placed in the subdir""" - files = self.files def get_config_in_subdir(files): return get_config_filename_for_files(files, subdir=True) - self.assertEqual('docker-compose.yml', get_config_in_subdir(files[0:])) - self.assertEqual('docker-compose.yaml', get_config_in_subdir(files[1:])) - self.assertEqual('fig.yml', get_config_in_subdir(files[2:])) - self.assertEqual('fig.yaml', get_config_in_subdir(files[3:])) + for index, filename in enumerate(self.files): + self.assertEqual(filename, get_config_in_subdir(self.files[index:])) with self.assertRaises(config.ComposeFileNotFound): get_config_in_subdir([]) From fd75e4bf6385d33165f1c91af2f63d9a8201e530 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 16 Sep 2015 15:03:55 -0400 Subject: [PATCH 1240/4072] Update docs about using multiple -f arguments Signed-off-by: Daniel Nephin --- docs/reference/docker-compose.md | 62 ++++++++++++++++++++++++++------ 1 file changed, 52 insertions(+), 10 deletions(-) diff --git a/docs/reference/docker-compose.md b/docs/reference/docker-compose.md index b43055fbeab..32fcbe70640 100644 --- a/docs/reference/docker-compose.md +++ b/docs/reference/docker-compose.md @@ -14,7 +14,7 @@ weight=-2 ``` Usage: - docker-compose [options] [COMMAND] [ARGS...] + docker-compose [-f=...] [options] [COMMAND] [ARGS...] docker-compose -h|--help Options: @@ -41,20 +41,62 @@ Commands: unpause Unpause services up Create and start containers migrate-to-labels Recreate containers to add labels + version Show the Docker-Compose version information ``` -The Docker Compose binary. You use this command to build and manage multiple services in Docker containers. +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. This -flag is optional. If you don't provide this flag. Compose looks for a file named -`docker-compose.yml` in the working directory. If the file is not found, -Compose looks in each parent directory successively, until it finds the file. +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. -Use a `-` as the filename to read configuration file from stdin. When stdin is -used all paths in the configuration are relative to the current working -directory. +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 subdirectories 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. + +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. -Each configuration can 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. ## Where to go next From 577439ea7f6b506e6905ca097abeff7ba82af5e6 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 17 Sep 2015 14:28:16 -0400 Subject: [PATCH 1241/4072] Add a debug log message for config filenames. Signed-off-by: Daniel Nephin --- compose/config/config.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/compose/config/config.py b/compose/config/config.py index 3ecdd29d7b0..56e6e796bab 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -104,6 +104,8 @@ def find(base_dir, filenames): filenames = [os.path.join(base_dir, f) for f in filenames] else: filenames = get_default_config_files(base_dir) + + log.debug("Using configuration files: {}".format(",".join(filenames))) return ConfigDetails( os.path.dirname(filenames[0]), [ConfigFile(f, load_yaml(f)) for f in filenames]) From eb20590ca66bb458504be60df7df948835a2eb45 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 29 Jul 2015 16:16:42 +0100 Subject: [PATCH 1242/4072] Use dockerswarm/dind image instead of doing docker-in-docker ourselves Signed-off-by: Aanand Prasad --- script/dind | 88 -------------------------------------------- script/test-versions | 36 ++++++++++++------ script/wrapdocker | 27 -------------- 3 files changed, 25 insertions(+), 126 deletions(-) delete mode 100755 script/dind delete mode 100755 script/wrapdocker diff --git a/script/dind b/script/dind deleted file mode 100755 index f8fae6379c0..00000000000 --- a/script/dind +++ /dev/null @@ -1,88 +0,0 @@ -#!/bin/bash -set -e - -# DinD: a wrapper script which allows docker to be run inside a docker container. -# Original version by Jerome Petazzoni -# See the blog post: http://blog.docker.com/2013/09/docker-can-now-run-within-docker/ -# -# This script should be executed inside a docker container in privilieged mode -# ('docker run --privileged', introduced in docker 0.6). - -# Usage: dind CMD [ARG...] - -# apparmor sucks and Docker needs to know that it's in a container (c) @tianon -export container=docker - -# First, make sure that cgroups are mounted correctly. -CGROUP=/cgroup - -mkdir -p "$CGROUP" - -if ! mountpoint -q "$CGROUP"; then - mount -n -t tmpfs -o uid=0,gid=0,mode=0755 cgroup $CGROUP || { - echo >&2 'Could not make a tmpfs mount. Did you use --privileged?' - exit 1 - } -fi - -if [ -d /sys/kernel/security ] && ! mountpoint -q /sys/kernel/security; then - mount -t securityfs none /sys/kernel/security || { - echo >&2 'Could not mount /sys/kernel/security.' - echo >&2 'AppArmor detection and -privileged mode might break.' - } -fi - -# Mount the cgroup hierarchies exactly as they are in the parent system. -for SUBSYS in $(cut -d: -f2 /proc/1/cgroup); do - mkdir -p "$CGROUP/$SUBSYS" - if ! mountpoint -q $CGROUP/$SUBSYS; then - mount -n -t cgroup -o "$SUBSYS" cgroup "$CGROUP/$SUBSYS" - fi - - # The two following sections address a bug which manifests itself - # by a cryptic "lxc-start: no ns_cgroup option specified" when - # trying to start containers withina container. - # The bug seems to appear when the cgroup hierarchies are not - # mounted on the exact same directories in the host, and in the - # container. - - # Named, control-less cgroups are mounted with "-o name=foo" - # (and appear as such under /proc//cgroup) but are usually - # mounted on a directory named "foo" (without the "name=" prefix). - # Systemd and OpenRC (and possibly others) both create such a - # cgroup. To avoid the aforementioned bug, we symlink "foo" to - # "name=foo". This shouldn't have any adverse effect. - name="${SUBSYS#name=}" - if [ "$name" != "$SUBSYS" ]; then - ln -s "$SUBSYS" "$CGROUP/$name" - fi - - # Likewise, on at least one system, it has been reported that - # systemd would mount the CPU and CPU accounting controllers - # (respectively "cpu" and "cpuacct") with "-o cpuacct,cpu" - # but on a directory called "cpu,cpuacct" (note the inversion - # in the order of the groups). This tries to work around it. - if [ "$SUBSYS" = 'cpuacct,cpu' ]; then - ln -s "$SUBSYS" "$CGROUP/cpu,cpuacct" - fi -done - -# Note: as I write those lines, the LXC userland tools cannot setup -# a "sub-container" properly if the "devices" cgroup is not in its -# own hierarchy. Let's detect this and issue a warning. -if ! grep -q :devices: /proc/1/cgroup; then - echo >&2 'WARNING: the "devices" cgroup should be in its own hierarchy.' -fi -if ! grep -qw devices /proc/1/cgroup; then - echo >&2 'WARNING: it looks like the "devices" cgroup is not mounted.' -fi - -# Mount /tmp -mount -t tmpfs none /tmp - -if [ $# -gt 0 ]; then - exec "$@" -fi - -echo >&2 'ERROR: No command specified.' -echo >&2 'You probably want to run hack/make.sh, or maybe a shell?' diff --git a/script/test-versions b/script/test-versions index 88d2554c2b7..577cf67e1fe 100755 --- a/script/test-versions +++ b/script/test-versions @@ -11,21 +11,35 @@ docker run --rm \ "$TAG" -e pre-commit if [ "$DOCKER_VERSIONS" == "" ]; then - DOCKER_VERSIONS="default" + DOCKER_VERSIONS="$DEFAULT_DOCKER_VERSION" elif [ "$DOCKER_VERSIONS" == "all" ]; then DOCKER_VERSIONS="$ALL_DOCKER_VERSIONS" fi for version in $DOCKER_VERSIONS; do >&2 echo "Running tests against Docker $version" - docker run \ - --rm \ - --privileged \ - --volume="/var/lib/docker" \ - --volume="${COVERAGE_DIR:-$(pwd)/coverage-html}:/code/coverage-html" \ - -e "DOCKER_VERSION=$version" \ - -e "DOCKER_DAEMON_ARGS" \ - --entrypoint="script/dind" \ - "$TAG" \ - script/wrapdocker tox -e py27,py34 -- "$@" + + ( + set -x + + daemon_container_id=$(\ + docker run \ + -d \ + --privileged \ + --volume="/var/lib/docker" \ + --expose="2375" \ + dockerswarm/dind:$version \ + docker -d -H tcp://0.0.0.0:2375 \ + ) + + docker run \ + --rm \ + --link="$daemon_container_id:docker" \ + --env="DOCKER_HOST=tcp://docker:2375" \ + --entrypoint="tox" \ + "$TAG" \ + -e py27,py34 -- "$@" + + docker rm -vf "$daemon_container_id" + ) done diff --git a/script/wrapdocker b/script/wrapdocker deleted file mode 100755 index ab89f5ed641..00000000000 --- a/script/wrapdocker +++ /dev/null @@ -1,27 +0,0 @@ -#!/bin/bash - -if [ "$DOCKER_VERSION" != "" ] && [ "$DOCKER_VERSION" != "default" ]; then - ln -fs "/usr/local/bin/docker-$DOCKER_VERSION" "/usr/local/bin/docker" -fi - -# If a pidfile is still around (for example after a container restart), -# delete it so that docker can start. -rm -rf /var/run/docker.pid -docker_command="docker -d $DOCKER_DAEMON_ARGS" ->&2 echo "Starting Docker with: $docker_command" -$docker_command &>/var/log/docker.log & -docker_pid=$! - ->&2 echo "Waiting for Docker to start..." -while ! docker ps &>/dev/null; do - if ! kill -0 "$docker_pid" &>/dev/null; then - >&2 echo "Docker failed to start" - cat /var/log/docker.log - exit 1 - fi - - sleep 1 -done - ->&2 echo ">" "$@" -exec "$@" From 9978c3ea52edbc99f2e293e98c4db5196972d655 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 31 Aug 2015 17:29:25 -0400 Subject: [PATCH 1243/4072] Update scriptests/test-versions to work with daemon args, and move docker version constants into tests-versions. Signed-off-by: Daniel Nephin --- Dockerfile | 11 ---------- script/test-versions | 51 ++++++++++++++++++++++++-------------------- tox.ini | 1 + 3 files changed, 29 insertions(+), 34 deletions(-) diff --git a/Dockerfile b/Dockerfile index ba508742de7..354ba00a4b9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -66,17 +66,6 @@ RUN set -ex; \ RUN echo "en_US.UTF-8 UTF-8" > /etc/locale.gen && locale-gen ENV LANG en_US.UTF-8 -ENV ALL_DOCKER_VERSIONS 1.7.1 1.8.2-rc1 - -RUN set -ex; \ - curl https://get.docker.com/builds/Linux/x86_64/docker-1.7.1 -o /usr/local/bin/docker-1.7.1; \ - chmod +x /usr/local/bin/docker-1.7.1; \ - curl https://test.docker.com/builds/Linux/x86_64/docker-1.8.2-rc1 -o /usr/local/bin/docker-1.8.2-rc1; \ - chmod +x /usr/local/bin/docker-1.8.2-rc1 - -# Set the default Docker to be run -RUN ln -s /usr/local/bin/docker-1.7.1 /usr/local/bin/docker - RUN useradd -d /home/user -m -s /bin/bash user WORKDIR /code/ diff --git a/script/test-versions b/script/test-versions index 577cf67e1fe..bebc5567272 100755 --- a/script/test-versions +++ b/script/test-versions @@ -10,36 +10,41 @@ docker run --rm \ --entrypoint="tox" \ "$TAG" -e pre-commit +ALL_DOCKER_VERSIONS="1.7.1 1.8.2" +DEFAULT_DOCKER_VERSION="1.8.2" + if [ "$DOCKER_VERSIONS" == "" ]; then DOCKER_VERSIONS="$DEFAULT_DOCKER_VERSION" elif [ "$DOCKER_VERSIONS" == "all" ]; then DOCKER_VERSIONS="$ALL_DOCKER_VERSIONS" fi + +BUILD_NUMBER=${BUILD_NUMBER-$USER} + for version in $DOCKER_VERSIONS; do >&2 echo "Running tests against Docker $version" - ( - set -x - - daemon_container_id=$(\ - docker run \ - -d \ - --privileged \ - --volume="/var/lib/docker" \ - --expose="2375" \ - dockerswarm/dind:$version \ - docker -d -H tcp://0.0.0.0:2375 \ - ) - - docker run \ - --rm \ - --link="$daemon_container_id:docker" \ - --env="DOCKER_HOST=tcp://docker:2375" \ - --entrypoint="tox" \ - "$TAG" \ - -e py27,py34 -- "$@" - - docker rm -vf "$daemon_container_id" - ) + daemon_container="compose-dind-$version-$BUILD_NUMBER" + trap "docker rm -vf $daemon_container" EXIT + + # TODO: remove when we stop testing against 1.7.x + daemon=$([[ "$version" == "1.7"* ]] && echo "-d" || echo "daemon") + + docker run \ + -d \ + --name "$daemon_container" \ + --privileged \ + --volume="/var/lib/docker" \ + dockerswarm/dind:$version \ + docker $daemon -H tcp://0.0.0.0:2375 $DOCKER_DAEMON_ARGS \ + + docker run \ + --rm \ + --link="$daemon_container:docker" \ + --env="DOCKER_HOST=tcp://docker:2375" \ + --entrypoint="tox" \ + "$TAG" \ + -e py27,py34 -- "$@" + done diff --git a/tox.ini b/tox.ini index 4cb933dd712..901c1851738 100644 --- a/tox.ini +++ b/tox.ini @@ -5,6 +5,7 @@ envlist = py27,py34,pre-commit usedevelop=True passenv = LD_LIBRARY_PATH + DOCKER_HOST setenv = HOME=/tmp deps = From 8b29a50b525c12e283db3b1aaecc1ab7d6ce6ef3 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 16 Sep 2015 16:45:26 -0400 Subject: [PATCH 1244/4072] Trim the dockerfile and re-use the virtualenv we already have. Signed-off-by: Daniel Nephin --- Dockerfile | 23 +++++++++++------------ script/build-linux | 6 +----- script/build-linux-inner | 5 +++-- 3 files changed, 15 insertions(+), 19 deletions(-) diff --git a/Dockerfile b/Dockerfile index 354ba00a4b9..c6dbdefd66b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,15 +10,16 @@ RUN set -ex; \ zlib1g-dev \ libssl-dev \ git \ - apt-transport-https \ ca-certificates \ curl \ - lxc \ - iptables \ libsqlite3-dev \ ; \ rm -rf /var/lib/apt/lists/* +RUN curl https://get.docker.com/builds/Linux/x86_64/docker-latest \ + -o /usr/local/bin/docker && \ + chmod +x /usr/local/bin/docker + # Build Python 2.7.9 from source RUN set -ex; \ curl -LO https://www.python.org/ftp/python/2.7.9/Python-2.7.9.tgz; \ @@ -69,19 +70,17 @@ ENV LANG en_US.UTF-8 RUN useradd -d /home/user -m -s /bin/bash user WORKDIR /code/ -RUN pip install tox +RUN pip install tox==2.1.1 ADD requirements.txt /code/ -RUN pip install -r requirements.txt - ADD requirements-dev.txt /code/ -RUN pip install -r requirements-dev.txt - -RUN pip install tox==2.1.1 +ADD .pre-commit-config.yaml /code/ +ADD setup.py /code/ +ADD tox.ini /code/ +ADD compose /code/compose/ +RUN tox --notest ADD . /code/ -RUN pip install --no-deps -e /code - RUN chown -R user /code/ -ENTRYPOINT ["/usr/local/bin/docker-compose"] +ENTRYPOINT ["/code/.tox/py27/bin/docker-compose"] diff --git a/script/build-linux b/script/build-linux index 4fdf1d926f6..bf966fc8ee7 100755 --- a/script/build-linux +++ b/script/build-linux @@ -4,8 +4,4 @@ set -ex TAG="docker-compose" docker build -t "$TAG" . -docker run \ - --rm \ - --volume="$(pwd):/code" \ - --entrypoint="script/build-linux-inner" \ - "$TAG" +docker run --rm --entrypoint="script/build-linux-inner" "$TAG" diff --git a/script/build-linux-inner b/script/build-linux-inner index e5d290ebaaf..1d0f790504a 100755 --- a/script/build-linux-inner +++ b/script/build-linux-inner @@ -3,11 +3,12 @@ set -ex TARGET=dist/docker-compose-Linux-x86_64 +VENV=/code/.tox/py27 mkdir -p `pwd`/dist chmod 777 `pwd`/dist -pip install -r requirements-build.txt -su -c "pyinstaller docker-compose.spec" user +$VENV/bin/pip install -r requirements-build.txt +su -c "$VENV/bin/pyinstaller docker-compose.spec" user mv dist/docker-compose $TARGET $TARGET version From aac916c73ed99e8a2615707e4550a7d21edea135 Mon Sep 17 00:00:00 2001 From: Mike Bailey Date: Fri, 31 Jul 2015 16:35:13 +1000 Subject: [PATCH 1245/4072] Alphabetise reference list Bring in line with Glossary. https://docs.docker.com/reference/glossary/ Alphabetising list makes makes parsing by humans easier. Signed-off-by: Mike Bailey Conflicts: docs/yml.md --- docs/yml.md | 310 ++++++++++++++++++++++++---------------------------- 1 file changed, 140 insertions(+), 170 deletions(-) diff --git a/docs/yml.md b/docs/yml.md index 2f1ae1a6ad3..77f76b10836 100644 --- a/docs/yml.md +++ b/docs/yml.md @@ -19,22 +19,6 @@ 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`. -Values for configuration options can contain environment variables, e.g. -`image: postgres:${POSTGRES_VERSION}`. For more details, see the section on -[variable substitution](#variable-substitution). - -### image - -Tag, partial image ID or digest. Can be local or remote - Compose will attempt to -pull if it doesn't exist locally. - - image: ubuntu - image: orchardup/postgresql - image: a4bc65fd - image: busybox@sha256:38a203e1986cf79639cfb9b2e1d6e773de84002feea2d4eb006b52004ee8502d - -Using `image` together with either `build` or `dockerfile` is not allowed. Attempting to do so results in an error. - ### build Path to a directory containing a Dockerfile. When the value supplied is a @@ -47,13 +31,17 @@ Compose will build and tag it with a generated name, and use that image thereaft Using `build` together with `image` is not allowed. Attempting to do so results in an error. -### dockerfile +### cap_add, cap_drop -Alternate Dockerfile. +Add or drop container capabilities. +See `man 7 capabilities` for a full list. -Compose will use an alternate file to build with. + cap_add: + - ALL - dockerfile: Dockerfile-alternate + cap_drop: + - NET_ADMIN + - SYS_ADMIN Using `dockerfile` together with `image` is not allowed. Attempting to do so results in an error. @@ -63,124 +51,71 @@ Override the default command. command: bundle exec thin -p 3000 - -### links - -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). - - links: - - db - - db:database - - redis - -An entry with the alias' name will be created in `/etc/hosts` inside containers -for this service, e.g: - - 172.17.2.186 db - 172.17.2.186 database - 172.17.2.187 redis - -Environment variables will also be created - see the [environment variable -reference](env.md) for details. - -### 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 - -### extra_hosts +### container_name -Add hostname mappings. Use the same values as the docker client `--add-host` parameter. +Specify a custom container name, rather than a generated default name. - extra_hosts: - - "somehost:162.242.195.82" - - "otherhost:50.31.209.229" + container_name: my-web-container -An entry with the ip address and hostname will be created in `/etc/hosts` inside containers for this service, e.g: +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. - 162.242.195.82 somehost - 50.31.209.229 otherhost +### devices -### ports +List of device mappings. Uses the same format as the `--device` docker +client create option. -Makes an exposed port accessible on a host and the port is available to -any client that can reach that host. Docker binds the exposed port to a random -port on the host within an *ephemeral port range* defined by -`/proc/sys/net/ipv4/ip_local_port_range`. You can also map to a specific port or range of ports. + devices: + - "/dev/ttyUSB0:/dev/ttyUSB0" -Acceptable formats for the `ports` value are: +### dns -* `containerPort` -* `ip:hostPort:containerPort` -* `ip::containerPort` -* `hostPort:containerPort` +Custom DNS servers. Can be a single value or a list. -You can specify a range for both the `hostPort` and the `containerPort` values. -When specifying ranges, the container port values in the range must match the -number of host port values in the range, for example, -`1234-1236:1234-1236/tcp`. Once a host is running, use the 'docker-compose port' command -to see the actual mapping. + dns: 8.8.8.8 + dns: + - 8.8.8.8 + - 9.9.9.9 -The following configuration shows examples of the port formats in use: +### dns_search - 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" +Custom DNS search domains. Can be a single value or a list. + dns_search: example.com + dns_search: + - dc1.example.com + - dc2.example.com -When mapping ports, in the `hostPort:containerPort` format, you may -experience erroneous results when using a container port lower than 60. This -happens because YAML parses numbers in the format `xx:yy` as sexagesimal (base -60). To avoid this problem, always explicitly specify your port -mappings as strings. +### dockerfile -### expose +Alternate Dockerfile. -Expose ports without publishing them to the host machine - they'll only be -accessible to linked services. Only the internal port can be specified. +Compose will use an alternate file to build with. - expose: - - "3000" - - "8000" + dockerfile: Dockerfile-alternate -### volumes +### env_file -Mount paths as volumes, optionally specifying a path on the host machine -(`HOST:CONTAINER`), or an access mode (`HOST:CONTAINER:ro`). +Add environment variables from a file. Can be a single value or a list. - volumes: - - /var/lib/mysql - - ./cache:/tmp/cache - - ~/configs:/etc/configs/:ro +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. -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 `..`. +Environment variables specified in `environment` override these values. -> Note: No path expansion will be done if you have also specified a -> `volume_driver`. + env_file: .env -### volumes_from + env_file: + - ./common.env + - ./apps/web.env + - /opt/secrets.env -Mount all of the volumes from another service or container. +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. - volumes_from: - - service_name - - container_name + # Set Rails/Rack environment + RACK_ENV=development ### environment @@ -201,27 +136,14 @@ machine Compose is running on, which can be helpful for secret or host-specific - SHOW=true - SESSION_SECRET -### 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 +### expose -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. +Expose ports without publishing them to the host machine - they'll only be +accessible to linked services. Only the internal port can be specified. - # Set Rails/Rack environment - RACK_ENV=development + expose: + - "3000" + - "8000" ### extends @@ -267,6 +189,40 @@ service within the current file. For more on `extends`, see the [tutorial](extends.md#example) and [reference](extends.md#reference). +### 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 + +### 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 + +Tag or partial image ID. Can be local or remote - Compose will attempt to +pull if it doesn't exist locally. + + image: ubuntu + image: orchardup/postgresql + image: a4bc65fd + ### labels Add metadata to containers using [Docker labels](http://docs.docker.com/userguide/labels-custom-metadata/). You can use either an array or a dictionary. @@ -283,15 +239,26 @@ It's recommended that you use reverse-DNS notation to prevent your labels from c - "com.example.department=Finance" - "com.example.label-with-empty-value" -### container_name +### links -Specify a custom container name, rather than a generated default name. +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). - container_name: my-web-container + links: + - db + - db:database + - redis -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. +An entry with the alias' name will be created in `/etc/hosts` inside containers +for this service, e.g: + + 172.17.2.186 db + 172.17.2.186 database + 172.17.2.187 redis + +Environment variables will also be created - see the [environment variable +reference](env.md) for details. ### log_driver @@ -336,51 +303,54 @@ 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. -### dns - -Custom DNS servers. Can be a single value or a list. +### ports - dns: 8.8.8.8 - dns: - - 8.8.8.8 - - 9.9.9.9 +Expose ports. Either specify both ports (`HOST:CONTAINER`), or just the container +port (a random host port will be chosen). -### cap_add, cap_drop +> **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. -Add or drop container capabilities. -See `man 7 capabilities` for a full list. + ports: + - "3000" + - "8000:8000" + - "49100:22" + - "127.0.0.1:8001:8001" - cap_add: - - ALL +### security_opt - cap_drop: - - NET_ADMIN - - SYS_ADMIN +Override the default labeling scheme for each container. -### dns_search + security_opt: + - label:user:USER + - label:role:ROLE -Custom DNS search domains. Can be a single value or a list. +### volumes - dns_search: example.com - dns_search: - - dc1.example.com - - dc2.example.com +Mount paths as volumes, optionally specifying a path on the host machine +(`HOST:CONTAINER`), or an access mode (`HOST:CONTAINER:ro`). -### devices + volumes: + - /var/lib/mysql + - ./cache:/tmp/cache + - ~/configs:/etc/configs/:ro -List of device mappings. Uses the same format as the `--device` docker -client create option. +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 `..`. - devices: - - "/dev/ttyUSB0:/dev/ttyUSB0" +> Note: No path expansion will be done if you have also specified a +> `volume_driver`. -### security_opt +### volumes_from -Override the default labeling scheme for each container. +Mount all of the volumes from another service or container. - security_opt: - - label:user:USER - - label:role:ROLE + volumes_from: + - service_name + - container_name ### working\_dir, entrypoint, user, hostname, domainname, mac\_address, mem\_limit, memswap\_limit, privileged, ipc, restart, stdin\_open, tty, cpu\_shares, cpuset, read\_only, volume\_driver From 0232fb10d7570ae8efb048e5bbefa0cff5729f29 Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Fri, 18 Sep 2015 11:30:24 +0100 Subject: [PATCH 1246/4072] Alphabetise run options Signed-off-by: Mazz Mosley --- docs/yml.md | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/docs/yml.md b/docs/yml.md index 77f76b10836..81357df3d66 100644 --- a/docs/yml.md +++ b/docs/yml.md @@ -352,7 +352,7 @@ Mount all of the volumes from another service or container. - service_name - container_name -### working\_dir, entrypoint, user, hostname, domainname, mac\_address, mem\_limit, memswap\_limit, privileged, ipc, restart, stdin\_open, tty, cpu\_shares, cpuset, read\_only, volume\_driver +### cpu\_shares, cpuset, domainname, entrypoint, hostname, ipc, mac\_address, mem\_limit, memswap\_limit, privileged, read\_only, restart, stdin\_open, tty, user, volume\_driver, working\_dir Each of these is a single value, analogous to its [docker run](https://docs.docker.com/reference/run/) counterpart. @@ -360,26 +360,24 @@ Each of these is a single value, analogous to its cpu_shares: 73 cpuset: 0,1 - working_dir: /code entrypoint: /code/entrypoint.sh user: postgresql + working_dir: /code - hostname: foo domainname: foo.com - + hostname: foo + ipc: host mac_address: 02:42:ac:11:65:43 mem_limit: 1000000000 memswap_limit: 2000000000 privileged: true - ipc: host - restart: always + read_only: true stdin_open: true tty: true - read_only: true volume_driver: mydriver From db433041b4aa7c1fa8d81c3f5163ec7f87d15c3f Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 18 Sep 2015 12:08:09 -0400 Subject: [PATCH 1247/4072] Restore the dist volume mount for building linux binaries. Signed-off-by: Daniel Nephin --- script/build-linux | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/script/build-linux b/script/build-linux index 7ce4ffc6611..4b8696216dc 100755 --- a/script/build-linux +++ b/script/build-linux @@ -6,4 +6,7 @@ set -ex TAG="docker-compose" docker build -t "$TAG" . -docker run --rm --entrypoint="script/build-linux-inner" "$TAG" +docker run \ + --rm --entrypoint="script/build-linux-inner" \ + -v $(pwd)/dist:/code/dist \ + "$TAG" From af7b98ff56517eb7ba0c83f9478f62867a92f078 Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Fri, 18 Sep 2015 18:12:30 +0200 Subject: [PATCH 1248/4072] Add bash completion for `docker-compose build --pull` Also adds a fix for an error message on some completions when no compose file is present: docker-compose build awk: cannot open docker-compose.yml (No such file or directory) 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 fe46a334edc..28d94394c2b 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -44,7 +44,7 @@ __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)}" | awk -F: -v key=": +$1:" '$0 ~ key {print $1}' + 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}' } # All services that are defined by a Dockerfile reference @@ -87,7 +87,7 @@ __docker_compose_services_stopped() { _docker_compose_build() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "--help --no-cache" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--help --no-cache --pull" -- "$cur" ) ) ;; *) __docker_compose_services_from_build From 006146b2cda8dac0c5b1c79c8eb9008d0a1ada27 Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Fri, 18 Sep 2015 17:45:35 +0200 Subject: [PATCH 1249/4072] Add bash completion for `docker-compose run --name` 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 fe46a334edc..ae57eaa3e6b 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -258,14 +258,14 @@ _docker_compose_run() { compopt -o nospace return ;; - --entrypoint|--user|-u) + --entrypoint|--name|--user|-u) return ;; esac case "$cur" in -*) - COMPREPLY=( $( compgen -W "-d --entrypoint -e --help --no-deps --rm --service-ports --publish -p -T --user -u" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "-d --entrypoint -e --help --name --no-deps --publish -p --rm --service-ports -T --user -u" -- "$cur" ) ) ;; *) __docker_compose_services_all From 5509990a7193985b72a22c0af000c5c1185ce4ed Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Fri, 18 Sep 2015 14:42:05 +0100 Subject: [PATCH 1250/4072] Ensure RefResolver works across operating systems Slashes, paths and a tale of woe. Validation now works on windows \o/ Signed-off-by: Mazz Mosley --- compose/config/validation.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/compose/config/validation.py b/compose/config/validation.py index 0258c5d94c4..959465e9872 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -1,6 +1,7 @@ import json import logging import os +import sys from functools import wraps from docker.utils.ports import split_port @@ -290,12 +291,20 @@ def validate_against_service_schema(config, service_name): def _validate_against_schema(config, schema_filename, format_checker=[], service_name=None): config_source_dir = os.path.dirname(os.path.abspath(__file__)) + + if sys.platform == "win32": + file_pre_fix = "///" + config_source_dir = config_source_dir.replace('\\', '/') + else: + file_pre_fix = "//" + + 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('file://' + config_source_dir + '/', schema) + 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)] From 3e4182a48077bb4bd602b7a79622265234d7c518 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 1 Sep 2015 17:40:56 -0700 Subject: [PATCH 1251/4072] Stub 'run' on Windows Adapted from @dopry's work in https://github.com/docker/compose/pull/1900 Signed-off-by: Aanand Prasad --- compose/cli/main.py | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 9b03ea67634..0fc09efe6cd 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -8,7 +8,6 @@ from inspect import getdoc from operator import attrgetter -import dockerpty from docker.errors import APIError from requests.exceptions import ReadTimeout @@ -31,6 +30,11 @@ from .utils import get_version_info from .utils import yesno +WINDOWS = (sys.platform == 'win32') + +if not WINDOWS: + import dockerpty + log = logging.getLogger(__name__) console_handler = logging.StreamHandler(sys.stderr) @@ -335,6 +339,14 @@ def run(self, project, options): """ service = project.get_service(options['SERVICE']) + detach = options['-d'] + + if WINDOWS 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['--allow-insecure-ssl']: log.warn(INSECURE_SSL_WARNING) @@ -349,7 +361,7 @@ def run(self, project, options): ) tty = True - if options['-d'] or options['-T'] or not sys.stdin.isatty(): + if detach or options['-T'] or not sys.stdin.isatty(): tty = False if options['COMMAND']: @@ -360,8 +372,8 @@ def run(self, project, options): container_options = { 'command': command, 'tty': tty, - 'stdin_open': not options['-d'], - 'detach': options['-d'], + 'stdin_open': not detach, + 'detach': detach, } if options['-e']: @@ -407,7 +419,7 @@ def run(self, project, options): raise e - if options['-d']: + if detach: service.start_container(container) print(container.name) else: From 4ae7f00412e539b9643e83c3eba65718af703f96 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 1 Sep 2015 17:41:09 -0700 Subject: [PATCH 1252/4072] Build Windows binary Signed-off-by: Aanand Prasad --- script/build-windows.ps1 | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 script/build-windows.ps1 diff --git a/script/build-windows.ps1 b/script/build-windows.ps1 new file mode 100644 index 00000000000..284e44f357b --- /dev/null +++ b/script/build-windows.ps1 @@ -0,0 +1,24 @@ +$ErrorActionPreference = "Stop" +Set-PSDebug -trace 1 + +# Remove virtualenv +if (Test-Path venv) { + Remove-Item -Recurse -Force .\venv +} + +# Remove .pyc files +Get-ChildItem -Recurse -Include *.pyc | foreach ($_) { Remove-Item $_.FullName } + +# Create virtualenv +virtualenv .\venv + +# Install dependencies +.\venv\Scripts\easy_install "http://sourceforge.net/projects/pywin32/files/pywin32/Build%20219/pywin32-219.win32-py2.7.exe/download" +.\venv\Scripts\pip install -r requirements.txt +.\venv\Scripts\pip install -r requirements-build.txt +.\venv\Scripts\pip install . + +# Build binary +.\venv\Scripts\pyinstaller .\docker-compose.spec +Move-Item -Force .\dist\docker-compose .\dist\docker-compose-Windows-x86_64.exe +.\dist\docker-compose-Windows-x86_64.exe --version From fb304981536722ef42c9688b0688d4be048ccfd1 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 18 Sep 2015 17:37:09 +0100 Subject: [PATCH 1253/4072] Catch WindowsError in call_silently Signed-off-by: Aanand Prasad --- compose/cli/utils.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/compose/cli/utils.py b/compose/cli/utils.py index 0b7ac683d1b..26a38af0662 100644 --- a/compose/cli/utils.py +++ b/compose/cli/utils.py @@ -85,7 +85,12 @@ def call_silently(*args, **kwargs): Like subprocess.call(), but redirects stdout and stderr to /dev/null. """ with open(os.devnull, 'w') as shutup: - return subprocess.call(*args, stdout=shutup, stderr=shutup, **kwargs) + try: + return subprocess.call(*args, stdout=shutup, stderr=shutup, **kwargs) + except WindowsError: + # On Windows, subprocess.call() can still raise exceptions. Normalize + # to POSIXy behaviour by returning a nonzero exit code. + return 1 def is_mac(): From 12b38adfac3b1d1f15addc41e4ecf698007a8717 Mon Sep 17 00:00:00 2001 From: Steve Durrheimer Date: Fri, 18 Sep 2015 22:42:19 +0200 Subject: [PATCH 1254/4072] Add zsh completion for 'docker-compose run --name' 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 58105dc221d..1ff1e7289a4 100644 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -250,6 +250,7 @@ __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' \ From 51ce16cf18812514320a7f3da59d6ee0fff2fb7c Mon Sep 17 00:00:00 2001 From: Steve Durrheimer Date: Fri, 18 Sep 2015 22:46:08 +0200 Subject: [PATCH 1255/4072] Add zsh completion for 'docker-compose build --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 58105dc221d..912a18287a7 100644 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -193,6 +193,7 @@ __docker-compose_subcommand() { _arguments \ $opts_help \ '--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 ;; (help) From 22bc174650fddb9b53ece787c489df7dabcef8d9 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 17 Sep 2015 15:07:53 -0400 Subject: [PATCH 1256/4072] Refactor config.load() to remove reduce() and document some types. Signed-off-by: Daniel Nephin --- compose/config/config.py | 49 ++++++++++++++++++++++++++++------------ 1 file changed, 34 insertions(+), 15 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 56e6e796bab..94c5ab95a60 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -2,7 +2,6 @@ import os import sys from collections import namedtuple -from functools import reduce import six import yaml @@ -89,9 +88,22 @@ log = logging.getLogger(__name__) -ConfigDetails = namedtuple('ConfigDetails', 'working_dir configs') +class ConfigDetails(namedtuple('_ConfigDetails', 'working_dir config_files')): + """ + :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` + """ + -ConfigFile = namedtuple('ConfigFile', 'filename config') +class ConfigFile(namedtuple('_ConfigFile', 'filename config')): + """ + :param filename: filename of the config file + :type filename: string + :param config: contents of the config file + :type config: :class:`dict` + """ def find(base_dir, filenames): @@ -170,10 +182,19 @@ def pre_process_config(config): def load(config_details): - working_dir, configs = config_details + """Load the configuration from a working directory and a list of + configuration files. Files are loaded in order, and merged on top + of each other to create the final configuration. + + Return a fully interpolated, extended and validated configuration. + """ def build_service(filename, service_name, service_dict): - loader = ServiceLoader(working_dir, filename, service_name, service_dict) + loader = ServiceLoader( + config_details.working_dir, + filename, + service_name, + service_dict) service_dict = loader.make_service_dict() validate_paths(service_dict) return service_dict @@ -187,21 +208,19 @@ def load_file(filename, config): ] def merge_services(base, override): + all_service_names = set(base) | set(override) return { name: merge_service_dicts(base.get(name, {}), override.get(name, {})) - for name in set(base) | set(override) + for name in all_service_names } - def combine_configs(base, override): - service_dicts = load_file(base.filename, base.config) - if not override: - return service_dicts - - return ConfigFile( - override.filename, - merge_services(base.config, override.config)) + config_file = config_details.config_files[0] + for next_file in config_details.config_files[1:]: + config_file = ConfigFile( + config_file.filename, + merge_services(config_file.config, next_file.config)) - return reduce(combine_configs, configs + [None]) + return load_file(config_file.filename, config_file.config) class ServiceLoader(object): From c9083e21c81576ba7b8f27dfd952f269cc25a7fd Mon Sep 17 00:00:00 2001 From: Vojta Orgon Date: Mon, 21 Sep 2015 11:59:23 +0200 Subject: [PATCH 1257/4072] Flag to skip all pull errors when pulling images. Signed-off-by: Vojta Orgon --- compose/cli/main.py | 2 ++ compose/project.py | 4 ++-- compose/service.py | 11 +++++++++-- contrib/completion/bash/docker-compose | 2 +- contrib/completion/zsh/_docker-compose | 1 + docs/reference/pull.md | 3 +++ .../simple-composefile/ignore-pull-failures.yml | 6 ++++++ tests/integration/cli_test.py | 7 +++++++ 8 files changed, 31 insertions(+), 5 deletions(-) create mode 100644 tests/fixtures/simple-composefile/ignore-pull-failures.yml diff --git a/compose/cli/main.py b/compose/cli/main.py index 9b03ea67634..f32b2a52923 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -270,6 +270,7 @@ def pull(self, project, options): Usage: pull [options] [SERVICE...] Options: + --ignore-pull-failures Pull what it can and ignores images with pull failures. --allow-insecure-ssl Deprecated - no effect. """ if options['--allow-insecure-ssl']: @@ -277,6 +278,7 @@ def pull(self, project, options): project.pull( service_names=options['SERVICE'], + ignore_pull_failures=options.get('--ignore-pull-failures') ) def rm(self, project, options): diff --git a/compose/project.py b/compose/project.py index f34cc0c3495..4750a7a9ae1 100644 --- a/compose/project.py +++ b/compose/project.py @@ -311,9 +311,9 @@ def _get_convergence_plans(self, services, strategy): return plans - def pull(self, service_names=None): + def pull(self, service_names=None, ignore_pull_failures=False): for service in self.get_services(service_names, include_deps=True): - service.pull() + service.pull(ignore_pull_failures) def containers(self, service_names=None, stopped=False, one_off=False): if service_names: diff --git a/compose/service.py b/compose/service.py index cf3b6270917..960d3936bf6 100644 --- a/compose/service.py +++ b/compose/service.py @@ -769,7 +769,7 @@ def specifies_host_port(self): return True return False - def pull(self): + def pull(self, ignore_pull_failures=False): if 'image' not in self.options: return @@ -781,7 +781,14 @@ def pull(self): tag=tag, stream=True, ) - stream_output(output, sys.stdout) + + try: + stream_output(output, sys.stdout) + except StreamOutputError as e: + if not ignore_pull_failures: + raise + else: + log.error(six.text_type(e)) class Net(object): diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index 28d94394c2b..ff09205cb78 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -212,7 +212,7 @@ _docker_compose_ps() { _docker_compose_pull() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "--help" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--help --ignore-pull-failures" -- "$cur" ) ) ;; *) __docker_compose_services_from_image diff --git a/contrib/completion/zsh/_docker-compose b/contrib/completion/zsh/_docker-compose index 58105dc221d..99cb4dc57fd 100644 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -237,6 +237,7 @@ __docker-compose_subcommand() { (pull) _arguments \ $opts_help \ + '--ignore-pull-failures[Pull what it can and ignores images with pull failures.]' \ '*:services:__docker-compose_services_from_image' && ret=0 ;; (rm) diff --git a/docs/reference/pull.md b/docs/reference/pull.md index d655dd93be6..5ec184b72c8 100644 --- a/docs/reference/pull.md +++ b/docs/reference/pull.md @@ -13,6 +13,9 @@ parent = "smn_compose_cli" ``` Usage: pull [options] [SERVICE...] + +Options: +--ignore-pull-failures Pull what it can and ignores images with pull failures. ``` Pulls service images. diff --git a/tests/fixtures/simple-composefile/ignore-pull-failures.yml b/tests/fixtures/simple-composefile/ignore-pull-failures.yml new file mode 100644 index 00000000000..a28f7922330 --- /dev/null +++ b/tests/fixtures/simple-composefile/ignore-pull-failures.yml @@ -0,0 +1,6 @@ +simple: + image: busybox:latest + command: top +another: + image: nonexisting-image:latest + command: top diff --git a/tests/integration/cli_test.py b/tests/integration/cli_test.py index 9dadd0368d3..56e65a6dd2e 100644 --- a/tests/integration/cli_test.py +++ b/tests/integration/cli_test.py @@ -97,6 +97,13 @@ def test_pull_with_digest(self, mock_logging): 'Pulling digest (busybox@' 'sha256:38a203e1986cf79639cfb9b2e1d6e773de84002feea2d4eb006b52004ee8502d)...') + @mock.patch('compose.service.log') + def test_pull_with_ignore_pull_failures(self, mock_logging): + self.command.dispatch(['-f', 'ignore-pull-failures.yml', 'pull', '--ignore-pull-failures'], None) + mock_logging.info.assert_any_call('Pulling simple (busybox:latest)...') + mock_logging.info.assert_any_call('Pulling another (nonexisting-image:latest)...') + mock_logging.error.assert_any_call('Error: image library/nonexisting-image:latest not found') + @mock.patch('sys.stdout', new_callable=StringIO) def test_build_plain(self, mock_stdout): self.command.base_dir = 'tests/fixtures/simple-dockerfile' From e5eaf68490098cb89cf9d6ad8b4eaa96bafd0450 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 21 Jul 2015 15:33:45 +0100 Subject: [PATCH 1258/4072] Remove custom docker client initialization logic Signed-off-by: Aanand Prasad --- compose/cli/docker_client.py | 32 +++++--------------------------- 1 file changed, 5 insertions(+), 27 deletions(-) diff --git a/compose/cli/docker_client.py b/compose/cli/docker_client.py index 601b0b9aabe..2c634f33767 100644 --- a/compose/cli/docker_client.py +++ b/compose/cli/docker_client.py @@ -1,9 +1,8 @@ import logging import os -import ssl from docker import Client -from docker import tls +from docker.utils import kwargs_from_env from ..const import HTTP_TIMEOUT @@ -15,31 +14,10 @@ def docker_client(): Returns a docker-py client configured using environment variables according to the same logic as the official Docker client. """ - cert_path = os.environ.get('DOCKER_CERT_PATH', '') - if cert_path == '': - cert_path = os.path.join(os.environ.get('HOME', ''), '.docker') - - base_url = os.environ.get('DOCKER_HOST') - api_version = os.environ.get('COMPOSE_API_VERSION', '1.19') - - tls_config = None - - if os.environ.get('DOCKER_TLS_VERIFY', '') != '': - parts = base_url.split('://', 1) - base_url = '%s://%s' % ('https', parts[1]) - - client_cert = (os.path.join(cert_path, 'cert.pem'), os.path.join(cert_path, 'key.pem')) - ca_cert = os.path.join(cert_path, 'ca.pem') - - tls_config = tls.TLSConfig( - ssl_version=ssl.PROTOCOL_TLSv1, - verify=True, - assert_hostname=False, - client_cert=client_cert, - ca_cert=ca_cert, - ) - if 'DOCKER_CLIENT_TIMEOUT' in os.environ: log.warn('The DOCKER_CLIENT_TIMEOUT environment variable is deprecated. Please use COMPOSE_HTTP_TIMEOUT instead.') - return Client(base_url=base_url, tls=tls_config, version=api_version, timeout=HTTP_TIMEOUT) + kwargs = kwargs_from_env(assert_hostname=False) + kwargs['version'] = os.environ.get('COMPOSE_API_VERSION', '1.19') + kwargs['timeout'] = HTTP_TIMEOUT + return Client(**kwargs) From 62ca8469b08250329fee613da704f0186dcdd488 Mon Sep 17 00:00:00 2001 From: Mary Anthony Date: Mon, 21 Sep 2015 08:57:01 -0700 Subject: [PATCH 1259/4072] Fixing misspelling of WordPress Signed-off-by: Mary Anthony --- docs/index.md | 4 ++-- docs/reference/overview.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/index.md b/docs/index.md index 21a8610e64e..67a6802b06b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -196,7 +196,7 @@ your services once you've finished with them: At this point, you have seen the basics of how Compose works. - Next, try the quick start guide for [Django](django.md), - [Rails](rails.md), or [Wordpress](wordpress.md). + [Rails](rails.md), or [WordPress](wordpress.md). - See the reference guides for complete details on the [commands](/reference), the [configuration file](yml.md) and [environment variables](env.md). @@ -224,7 +224,7 @@ For more information and resources, please visit the [Getting Help project page] - [Installing Compose](install.md) - [Get started with Django](django.md) - [Get started with Rails](rails.md) -- [Get started with Wordpress](wordpress.md) +- [Get started with WordPress](wordpress.md) - [Command line reference](/reference) - [Yaml file reference](yml.md) - [Compose environment variables](env.md) diff --git a/docs/reference/overview.md b/docs/reference/overview.md index 002607118d8..9f08246e096 100644 --- a/docs/reference/overview.md +++ b/docs/reference/overview.md @@ -83,7 +83,7 @@ it failed. Defaults to 60 seconds. - [Installing Compose](install.md) - [Get started with Django](django.md) - [Get started with Rails](rails.md) -- [Get started with Wordpress](wordpress.md) +- [Get started with WordPress](wordpress.md) - [Yaml file reference](yml.md) - [Compose environment variables](env.md) - [Compose command line completion](completion.md) From ac75d35927ad3d2eccfea36977f152890d4b5fc3 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 2 Sep 2015 16:08:18 -0400 Subject: [PATCH 1260/4072] Fix #1961 - docker-compose up should attach to all containers with no service names are specified, and add tests. Signed-off-by: Daniel Nephin --- compose/cli/main.py | 5 ++++- tests/integration/cli_test.py | 29 ++++++++++++++++++++++++----- tests/unit/cli/main_test.py | 10 ++++++++++ 3 files changed, 38 insertions(+), 6 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index d9bda293719..3504c24167a 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -541,8 +541,11 @@ def version(self, project, options): def build_log_printer(containers, service_names, monochrome): + if service_names: + containers = [c for c in containers if c.service in service_names] + return LogPrinter( - [c for c in containers if c.service in service_names], + containers, attach_params={"logs": True}, monochrome=monochrome) diff --git a/tests/integration/cli_test.py b/tests/integration/cli_test.py index f3b3b9f5fb7..c54a85bb2d1 100644 --- a/tests/integration/cli_test.py +++ b/tests/integration/cli_test.py @@ -4,6 +4,7 @@ import os import shlex +import mock from six import StringIO from mock import patch @@ -104,7 +105,7 @@ def test_build_no_cache(self, mock_stdout): output = mock_stdout.getvalue() self.assertNotIn(cache_indicator, output) - def test_up(self): + def test_up_detached(self): self.command.dispatch(['up', '-d'], None) service = self.project.get_service('simple') another = self.project.get_service('another') @@ -112,10 +113,28 @@ def test_up(self): self.assertEqual(len(another.containers()), 1) # Ensure containers don't have stdin and stdout connected in -d mode - config = service.containers()[0].inspect()['Config'] - self.assertFalse(config['AttachStderr']) - self.assertFalse(config['AttachStdout']) - self.assertFalse(config['AttachStdin']) + container, = service.containers() + self.assertFalse(container.get('Config.AttachStderr')) + self.assertFalse(container.get('Config.AttachStdout')) + self.assertFalse(container.get('Config.AttachStdin')) + + def test_up_attached(self): + with mock.patch( + 'compose.cli.main.attach_to_logs', + autospec=True + ) as mock_attach: + self.command.dispatch(['up'], None) + _, args, kwargs = mock_attach.mock_calls[0] + _project, log_printer, _names, _timeout = args + + service = self.project.get_service('simple') + another = self.project.get_service('another') + self.assertEqual(len(service.containers()), 1) + self.assertEqual(len(another.containers()), 1) + self.assertEqual( + set(log_printer.containers), + set(self.project.containers()) + ) def test_up_with_links(self): self.command.base_dir = 'tests/fixtures/links-composefile' diff --git a/tests/unit/cli/main_test.py b/tests/unit/cli/main_test.py index 817e8f49b6a..e3a4629e53b 100644 --- a/tests/unit/cli/main_test.py +++ b/tests/unit/cli/main_test.py @@ -31,6 +31,16 @@ def test_build_log_printer(self): log_printer = build_log_printer(containers, service_names, True) self.assertEqual(log_printer.containers, containers[:3]) + def test_build_log_printer_all_services(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) + self.assertEqual(log_printer.containers, containers) + def test_attach_to_logs(self): project = mock.create_autospec(Project) log_printer = mock.create_autospec(LogPrinter, containers=[]) From d01f712376f5ac6d41284fbf158ecb914fba5232 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 22 Sep 2015 12:24:56 -0400 Subject: [PATCH 1261/4072] Fix a test case that assumes busybox image id. Signed-off-by: Daniel Nephin --- tests/integration/service_test.py | 7 +++++-- tests/integration/testcases.py | 10 ++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index ec6428200fd..aec2caf1d21 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -10,6 +10,7 @@ from six import StringIO, text_type from .testcases import DockerClientTestCase +from .testcases import pull_busybox from compose import __version__ from compose.const import ( LABEL_CONTAINER_NUMBER, @@ -577,8 +578,10 @@ def test_port_with_explicit_interface(self): }) def test_create_with_image_id(self): - # Image id for the current busybox:latest - service = self.create_service('foo', image='8c2e06607696') + # Get image id for the current busybox:latest + pull_busybox(self.client) + image_id = self.client.inspect_image('busybox:latest')['Id'][:12] + service = self.create_service('foo', image=image_id) service.create_container() def test_scale(self): diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index 2a7c0a440d2..41b50a815e4 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -1,5 +1,8 @@ from __future__ import unicode_literals from __future__ import absolute_import + +from docker import errors + from compose.service import Service from compose.config import ServiceLoader from compose.const import LABEL_PROJECT @@ -8,6 +11,13 @@ from .. import unittest +def pull_busybox(client): + try: + client.inspect_image('busybox:latest') + except errors.APIError: + client.pull('busybox:latest', stream=False) + + class DockerClientTestCase(unittest.TestCase): @classmethod def setUpClass(cls): From 7b5d5fcd58c871385613be4fc0dc371e62132858 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 22 Sep 2015 10:31:42 -0400 Subject: [PATCH 1262/4072] Bump 1.4.2 Signed-off-by: Daniel Nephin --- CHANGES.md | 7 +++++++ compose/__init__.py | 2 +- docs/install.md | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index ad29512aad3..0353edc65b3 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,6 +1,13 @@ Change log ========== +1.4.2 (2015-09-22) +------------------ + +Fixes a regression in the 1.4.1 release that would cause `docker-compose up` +without the `-d` option to exit immediately. + + 1.4.1 (2015-09-10) ------------------ diff --git a/compose/__init__.py b/compose/__init__.py index 8d684354d3e..af2bdbf2424 100644 --- a/compose/__init__.py +++ b/compose/__init__.py @@ -1,3 +1,3 @@ from __future__ import unicode_literals -__version__ = '1.4.1' +__version__ = '1.4.2' diff --git a/docs/install.md b/docs/install.md index 3daf4d944d9..b74f8f620d1 100644 --- a/docs/install.md +++ b/docs/install.md @@ -53,7 +53,7 @@ To install Compose, do the following: 6. Test the installation. $ docker-compose --version - docker-compose version: 1.4.1 + docker-compose version: 1.4.2 ## Upgrading From c37a0c38a2c4c9c49c5e591427d382c8a046635d Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 22 Sep 2015 12:24:56 -0400 Subject: [PATCH 1263/4072] Fix a test case that assumes busybox image id. Signed-off-by: Daniel Nephin --- tests/integration/service_test.py | 7 +++++-- tests/integration/testcases.py | 9 +++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 040098c9e71..2c9c6fc204e 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -12,6 +12,7 @@ from .. import mock from .testcases import DockerClientTestCase +from .testcases import pull_busybox from compose import __version__ from compose.const import LABEL_CONTAINER_NUMBER from compose.const import LABEL_ONE_OFF @@ -549,8 +550,10 @@ def test_port_with_explicit_interface(self): }) def test_create_with_image_id(self): - # Image id for the current busybox:latest - service = self.create_service('foo', image='8c2e06607696') + # Get image id for the current busybox:latest + pull_busybox(self.client) + image_id = self.client.inspect_image('busybox:latest')['Id'][:12] + service = self.create_service('foo', image=image_id) service.create_container() def test_scale(self): diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index 4557c07b6a2..26a0a108a1c 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -1,6 +1,8 @@ from __future__ import absolute_import from __future__ import unicode_literals +from docker import errors + from .. import unittest from compose.cli.docker_client import docker_client from compose.config.config import ServiceLoader @@ -9,6 +11,13 @@ from compose.service import Service +def pull_busybox(client): + try: + client.inspect_image('busybox:latest') + except errors.APIError: + client.pull('busybox:latest', stream=False) + + class DockerClientTestCase(unittest.TestCase): @classmethod def setUpClass(cls): From 0058d5dd4f03317af0bacfb9e2d2e045b4bb1da0 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 22 Sep 2015 11:19:35 -0400 Subject: [PATCH 1264/4072] Add appveyor config Signed-off-by: Daniel Nephin --- appveyor.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 appveyor.yml diff --git a/appveyor.yml b/appveyor.yml new file mode 100644 index 00000000000..639591e9cf7 --- /dev/null +++ b/appveyor.yml @@ -0,0 +1,15 @@ + +install: + - "SET PATH=C:\\Python27-x64;C:\\Python27-x64\\Scripts;%PATH%" + - "python --version" + - "pip install tox==2.1.1" + + +# Build the binary after tests +build: false + +test_script: + - "tox -e py27,py34 -- tests/unit" + +after_test: + - ps: ".\\script\\build-windows.ps1" From d5991761cd678dc48f2924947056fe40415592d2 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 22 Sep 2015 14:31:55 -0400 Subject: [PATCH 1265/4072] Fix building the binary on appveyor, and have it create an artifact. Signed-off-by: Daniel Nephin --- appveyor.yml | 9 +++++++-- script/build-osx | 2 +- script/build-windows.ps1 | 15 ++++++++++++--- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/appveyor.yml b/appveyor.yml index 639591e9cf7..acf8bff34af 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -1,9 +1,10 @@ +version: '{branch}-{build}' + install: - "SET PATH=C:\\Python27-x64;C:\\Python27-x64\\Scripts;%PATH%" - "python --version" - - "pip install tox==2.1.1" - + - "pip install tox==2.1.1 virtualenv==13.1.2" # Build the binary after tests build: false @@ -13,3 +14,7 @@ test_script: after_test: - ps: ".\\script\\build-windows.ps1" + +artifacts: + - path: .\dist\docker-compose-Windows-x86_64.exe + name: "Compose Windows binary" diff --git a/script/build-osx b/script/build-osx index 11b6ecc694b..15a7bbc5418 100755 --- a/script/build-osx +++ b/script/build-osx @@ -9,7 +9,7 @@ rm -rf venv 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 . +venv/bin/pip install --no-deps . 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/build-windows.ps1 b/script/build-windows.ps1 index 284e44f357b..63be0865242 100644 --- a/script/build-windows.ps1 +++ b/script/build-windows.ps1 @@ -13,12 +13,21 @@ Get-ChildItem -Recurse -Include *.pyc | foreach ($_) { Remove-Item $_.FullName } virtualenv .\venv # Install dependencies -.\venv\Scripts\easy_install "http://sourceforge.net/projects/pywin32/files/pywin32/Build%20219/pywin32-219.win32-py2.7.exe/download" +.\venv\Scripts\pip install pypiwin32==219 .\venv\Scripts\pip install -r requirements.txt -.\venv\Scripts\pip install -r requirements-build.txt -.\venv\Scripts\pip install . +.\venv\Scripts\pip install --no-deps . + +# TODO: pip warns when installing from a git sha, so we need to set ErrorAction to +# 'Continue'. See +# https://github.com/pypa/pip/blob/fbc4b7ae5fee00f95bce9ba4b887b22681327bb1/pip/vcs/git.py#L77 +# This can be removed once pyinstaller 3.x is released and we upgrade +$ErrorActionPreference = "Continue" +.\venv\Scripts\pip install --allow-external pyinstaller -r requirements-build.txt # Build binary +# pyinstaller has lots of warnings, so we need to run with ErrorAction = Continue .\venv\Scripts\pyinstaller .\docker-compose.spec +$ErrorActionPreference = "Stop" + Move-Item -Force .\dist\docker-compose .\dist\docker-compose-Windows-x86_64.exe .\dist\docker-compose-Windows-x86_64.exe --version From 78c0734cbd3f553af628a5c19843dbc523cdf28f Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 22 Sep 2015 17:37:14 -0400 Subject: [PATCH 1266/4072] Disable some tests in windows for now. Signed-off-by: Daniel Nephin --- compose/cli/main.py | 6 +++--- compose/const.py | 2 ++ tests/unit/cli_test.py | 3 +++ tests/unit/config/config_test.py | 10 ++++++++++ tests/unit/service_test.py | 3 +++ 5 files changed, 21 insertions(+), 3 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index bb12b62c285..60e60b795d9 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -16,6 +16,7 @@ from ..config import parse_environment from ..const import DEFAULT_TIMEOUT from ..const import HTTP_TIMEOUT +from ..const import IS_WINDOWS_PLATFORM from ..progress_stream import StreamOutputError from ..project import ConfigurationError from ..project import NoSuchService @@ -30,9 +31,8 @@ from .utils import get_version_info from .utils import yesno -WINDOWS = (sys.platform == 'win32') -if not WINDOWS: +if not IS_WINDOWS_PLATFORM: import dockerpty log = logging.getLogger(__name__) @@ -343,7 +343,7 @@ def run(self, project, options): detach = options['-d'] - if WINDOWS and not detach: + 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`." diff --git a/compose/const.py b/compose/const.py index dbfa56b8cdd..b43e655b19d 100644 --- a/compose/const.py +++ b/compose/const.py @@ -1,4 +1,5 @@ import os +import sys DEFAULT_TIMEOUT = 10 LABEL_CONTAINER_NUMBER = 'com.docker.compose.container-number' @@ -8,3 +9,4 @@ LABEL_VERSION = 'com.docker.compose.version' LABEL_CONFIG_HASH = 'com.docker.compose.config-hash' HTTP_TIMEOUT = int(os.environ.get('COMPOSE_HTTP_TIMEOUT', os.environ.get('DOCKER_CLIENT_TIMEOUT', 60))) +IS_WINDOWS_PLATFORM = (sys.platform == 'win32') diff --git a/tests/unit/cli_test.py b/tests/unit/cli_test.py index 321df97a53f..0c78e6bbfe5 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -5,6 +5,7 @@ import docker import py +import pytest from .. import mock from .. import unittest @@ -13,6 +14,7 @@ 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 @@ -81,6 +83,7 @@ def test_command_help_nonexistent(self): with self.assertRaises(NoSuchCommand): 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): command = TopLevelCommand() diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 79864ec7843..2dfa764df2e 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -5,8 +5,11 @@ import tempfile from operator import itemgetter +import pytest + from compose.config import config from compose.config.errors import ConfigurationError +from compose.const import IS_WINDOWS_PLATFORM from tests import mock from tests import unittest @@ -92,6 +95,7 @@ def test_config_integer_service_name_raise_validation_error(self): ) ) + @pytest.mark.xfail(IS_WINDOWS_PLATFORM, reason='paths use slash') def test_load_with_multiple_files(self): base_file = config.ConfigFile( 'base.yaml', @@ -410,6 +414,7 @@ def test_invalid_interpolation(self): self.assertIn('in service "web"', cm.exception.msg) self.assertIn('"${"', cm.exception.msg) + @pytest.mark.xfail(IS_WINDOWS_PLATFORM, reason='paths use slash') @mock.patch.dict(os.environ) def test_volume_binding_with_environment_variable(self): os.environ['VOLUME_PATH'] = '/host/path' @@ -422,6 +427,7 @@ def test_volume_binding_with_environment_variable(self): )[0] self.assertEqual(d['volumes'], ['/host/path:/container/path']) + @pytest.mark.xfail(IS_WINDOWS_PLATFORM, reason='paths use slash') @mock.patch.dict(os.environ) def test_volume_binding_with_home(self): os.environ['HOME'] = '/home/user' @@ -817,6 +823,7 @@ def test_resolve_environment_from_file(self): {'FILE_DEF': 'F1', 'FILE_DEF_EMPTY': '', 'ENV_DEF': 'E3', 'NO_DEF': ''}, ) + @pytest.mark.xfail(IS_WINDOWS_PLATFORM, reason='paths use slash') @mock.patch.dict(os.environ) def test_resolve_path(self): os.environ['HOSTENV'] = '/tmp' @@ -1073,6 +1080,7 @@ def test_valid_interpolation_in_extended_service(self): for service in service_dicts: self.assertTrue(service['hostname'], expected_interpolated_value) + @pytest.mark.xfail(IS_WINDOWS_PLATFORM, reason='paths use slash') def test_volume_path(self): dicts = load_from_filename('tests/fixtures/volume-path/docker-compose.yml') @@ -1108,6 +1116,7 @@ def test_partial_service_config_in_extends_is_still_valid(self): self.assertEqual(dicts[0]['environment'], {'FOO': '1'}) +@pytest.mark.xfail(IS_WINDOWS_PLATFORM, reason='paths use slash') class ExpandPathTest(unittest.TestCase): working_dir = '/home/user/somedir' @@ -1129,6 +1138,7 @@ def test_expand_path_with_tilde(self): self.assertEqual(result, user_path + 'otherdir/somefile') +@pytest.mark.xfail(IS_WINDOWS_PLATFORM, reason='paths use slash') class BuildPathTest(unittest.TestCase): def setUp(self): self.abs_context_path = os.path.join(os.getcwd(), 'tests/fixtures/build-ctx') diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 5f7ae948755..a1c195acf00 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -2,9 +2,11 @@ from __future__ import unicode_literals import docker +import pytest from .. import mock from .. import unittest +from compose.const import IS_WINDOWS_PLATFORM from compose.const import LABEL_CONFIG_HASH from compose.const import LABEL_ONE_OFF from compose.const import LABEL_PROJECT @@ -439,6 +441,7 @@ def mock_get_image(images): raise NoSuchImageError() +@pytest.mark.xfail(IS_WINDOWS_PLATFORM, reason='paths use slash') class ServiceVolumesTest(unittest.TestCase): def setUp(self): From bd1373f52773be08f32d89e7ac207bbae89e0e66 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 22 Sep 2015 10:31:42 -0400 Subject: [PATCH 1267/4072] Bump 1.4.2 Signed-off-by: Daniel Nephin --- CHANGELOG.md | 6 ++++++ docs/install.md | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a054a0aef4b..598f5e57943 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,12 @@ Change log ========== +1.4.2 (2015-09-22) +------------------ + +- Fixed a regression in the 1.4.1 release that would cause `docker-compose up` + without the `-d` option to exit immediately. + 1.4.1 (2015-09-10) ------------------ diff --git a/docs/install.md b/docs/install.md index 517b2901bb1..bc1f8f78c18 100644 --- a/docs/install.md +++ b/docs/install.md @@ -52,7 +52,7 @@ To install Compose, do the following: 6. Test the installation. $ docker-compose --version - docker-compose version: 1.4.1 + docker-compose version: 1.4.2 ## Upgrading From 91b227d133a33e2dd0b8cc06bba33bf389ff7e3f Mon Sep 17 00:00:00 2001 From: Karol Duleba Date: Wed, 9 Sep 2015 22:30:36 +0100 Subject: [PATCH 1268/4072] Allow to extend service using shorthand notation. Closes #1989 Signed-off-by: Karol Duleba --- compose/config/config.py | 10 ++++++--- compose/config/fields_schema.json | 21 ++++++++++++------- .../extends/verbose-and-shorthand.yml | 15 +++++++++++++ tests/unit/config/config_test.py | 20 ++++++++++++++++++ 4 files changed, 56 insertions(+), 10 deletions(-) create mode 100644 tests/fixtures/extends/verbose-and-shorthand.yml diff --git a/compose/config/config.py b/compose/config/config.py index 94c5ab95a60..55c717f421c 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -275,15 +275,19 @@ def resolve_environment(self): self.service_dict['environment'] = env def validate_and_construct_extends(self): + extends = self.service_dict['extends'] + if not isinstance(extends, dict): + extends = {'service': extends} + validate_extends_file_path( self.service_name, - self.service_dict['extends'], + extends, self.filename ) self.extended_config_path = self.get_extended_config_path( - self.service_dict['extends'] + extends ) - self.extended_service_name = self.service_dict['extends']['service'] + self.extended_service_name = extends['service'] full_extended_config = pre_process_config( load_yaml(self.extended_config_path) diff --git a/compose/config/fields_schema.json b/compose/config/fields_schema.json index 6fce299cbb1..07b17cb22f7 100644 --- a/compose/config/fields_schema.json +++ b/compose/config/fields_schema.json @@ -57,14 +57,21 @@ }, "extends": { - "type": "object", + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", - "properties": { - "service": {"type": "string"}, - "file": {"type": "string"} - }, - "required": ["service"], - "additionalProperties": false + "properties": { + "service": {"type": "string"}, + "file": {"type": "string"} + }, + "required": ["service"], + "additionalProperties": false + } + ] }, "extra_hosts": {"$ref": "#/definitions/list_or_dict"}, diff --git a/tests/fixtures/extends/verbose-and-shorthand.yml b/tests/fixtures/extends/verbose-and-shorthand.yml new file mode 100644 index 00000000000..d381630275e --- /dev/null +++ b/tests/fixtures/extends/verbose-and-shorthand.yml @@ -0,0 +1,15 @@ +base: + image: busybox + environment: + - "BAR=1" + +verbose: + extends: + service: base + environment: + - "FOO=1" + +shorthand: + extends: base + environment: + - "FOO=2" diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 2dfa764df2e..2c3c5a3a12c 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1115,6 +1115,26 @@ 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'}) + def test_extended_service_with_verbose_and_shorthand_way(self): + services = load_from_filename('tests/fixtures/extends/verbose-and-shorthand.yml') + self.assertEqual(service_sort(services), service_sort([ + { + 'name': 'base', + 'image': 'busybox', + 'environment': {'BAR': '1'}, + }, + { + 'name': 'verbose', + 'image': 'busybox', + 'environment': {'BAR': '1', 'FOO': '1'}, + }, + { + 'name': 'shorthand', + 'image': 'busybox', + 'environment': {'BAR': '1', 'FOO': '2'}, + }, + ])) + @pytest.mark.xfail(IS_WINDOWS_PLATFORM, reason='paths use slash') class ExpandPathTest(unittest.TestCase): From bb470798d401cb6c991c4d51a0a14915b986b825 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 1 Oct 2015 12:26:55 +0100 Subject: [PATCH 1269/4072] Pass all DOCKER_ env vars to py.test This ensures that `tox` will run against SSL-protected Docker daemons. Signed-off-by: Aanand Prasad --- tox.ini | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tox.ini b/tox.ini index 901c1851738..dbf639201b1 100644 --- a/tox.ini +++ b/tox.ini @@ -6,6 +6,8 @@ usedevelop=True passenv = LD_LIBRARY_PATH DOCKER_HOST + DOCKER_CERT_PATH + DOCKER_TLS_VERIFY setenv = HOME=/tmp deps = From e38334efbdbac3a5e0a652d4771c2e51e1d73810 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 1 Oct 2015 12:22:59 +0100 Subject: [PATCH 1270/4072] Don't expand volume names Only expand volume host paths if they begin with a dot. This is a breaking change. The deprecation warning preparing users for this change has been removed. Signed-off-by: Aanand Prasad --- compose/config/config.py | 21 ++------- tests/unit/config/config_test.py | 76 +++++++++++++------------------- 2 files changed, 34 insertions(+), 63 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 94c5ab95a60..0444ba3a6bc 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -78,13 +78,6 @@ DEFAULT_OVERRIDE_FILENAME = 'docker-compose.override.yml' -PATH_START_CHARS = [ - '/', - '.', - '~', -] - - log = logging.getLogger(__name__) @@ -495,18 +488,10 @@ def resolve_volume_path(volume, working_dir, service_name): container_path = os.path.expanduser(container_path) if host_path is not None: - if not any(host_path.startswith(c) for c in PATH_START_CHARS): - log.warn( - 'Warning: the mapping "{0}:{1}" in the volumes config for ' - 'service "{2}" is ambiguous. In a future version of Docker, ' - 'it will designate a "named" volume ' - '(see https://github.com/docker/docker/pull/14242). ' - 'To prevent unexpected behaviour, change it to "./{0}:{1}"' - .format(host_path, container_path, service_name) - ) - + if host_path.startswith('.'): + host_path = expand_path(working_dir, host_path) host_path = os.path.expanduser(host_path) - return "%s:%s" % (expand_path(working_dir, host_path), container_path) + return "{}:{}".format(host_path, container_path) else: return container_path diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 2dfa764df2e..3269cdff87c 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -414,6 +414,12 @@ def test_invalid_interpolation(self): self.assertIn('in service "web"', cm.exception.msg) self.assertIn('"${"', cm.exception.msg) + +class VolumeConfigTest(unittest.TestCase): + def test_no_binding(self): + d = make_service_dict('foo', {'build': '.', 'volumes': ['/data']}, working_dir='.') + self.assertEqual(d['volumes'], ['/data']) + @pytest.mark.xfail(IS_WINDOWS_PLATFORM, reason='paths use slash') @mock.patch.dict(os.environ) def test_volume_binding_with_environment_variable(self): @@ -434,59 +440,39 @@ def test_volume_binding_with_home(self): d = make_service_dict('foo', {'build': '.', 'volumes': ['~:/container/path']}, working_dir='.') self.assertEqual(d['volumes'], ['/home/user:/container/path']) - @mock.patch.dict(os.environ) - def test_volume_binding_with_local_dir_name_raises_warning(self): - def make_dict(**config): - config['build'] = '.' - make_service_dict('foo', config, working_dir='.') - - with mock.patch('compose.config.config.log.warn') as warn: - make_dict(volumes=['/container/path']) - self.assertEqual(0, warn.call_count) - - make_dict(volumes=['/data:/container/path']) - self.assertEqual(0, warn.call_count) - - make_dict(volumes=['.:/container/path']) - self.assertEqual(0, warn.call_count) + def test_name_does_not_expand(self): + d = make_service_dict('foo', {'build': '.', 'volumes': ['mydatavolume:/data']}, working_dir='.') + self.assertEqual(d['volumes'], ['mydatavolume:/data']) - make_dict(volumes=['..:/container/path']) - self.assertEqual(0, warn.call_count) + def test_absolute_posix_path_does_not_expand(self): + d = make_service_dict('foo', {'build': '.', 'volumes': ['/var/lib/data:/data']}, working_dir='.') + self.assertEqual(d['volumes'], ['/var/lib/data:/data']) - make_dict(volumes=['./data:/container/path']) - self.assertEqual(0, warn.call_count) + def test_absolute_windows_path_does_not_expand(self): + d = make_service_dict('foo', {'build': '.', 'volumes': ['C:\\data:/data']}, working_dir='.') + self.assertEqual(d['volumes'], ['C:\\data:/data']) - make_dict(volumes=['../data:/container/path']) - self.assertEqual(0, warn.call_count) + @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') + self.assertEqual(d['volumes'], ['/home/me/myproject/data:/data']) - make_dict(volumes=['.profile:/container/path']) - self.assertEqual(0, warn.call_count) + d = make_service_dict('foo', {'build': '.', 'volumes': ['.:/data']}, working_dir='/home/me/myproject') + self.assertEqual(d['volumes'], ['/home/me/myproject:/data']) - make_dict(volumes=['~:/container/path']) - self.assertEqual(0, warn.call_count) + d = make_service_dict('foo', {'build': '.', 'volumes': ['../otherproject:/data']}, working_dir='/home/me/myproject') + self.assertEqual(d['volumes'], ['/home/me/otherproject:/data']) - make_dict(volumes=['~/data:/container/path']) - self.assertEqual(0, warn.call_count) + @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') + self.assertEqual(d['volumes'], ['C:\\Users\\me\\myproject\\data:/data']) - make_dict(volumes=['~tmp:/container/path']) - self.assertEqual(0, warn.call_count) + d = make_service_dict('foo', {'build': '.', 'volumes': ['.:/data']}, working_dir='C:\\Users\\me\\myproject') + self.assertEqual(d['volumes'], ['C:\\Users\\me\\myproject:/data']) - make_dict(volumes=['data:/container/path'], volume_driver='mydriver') - self.assertEqual(0, warn.call_count) - - make_dict(volumes=['data:/container/path']) - self.assertEqual(1, warn.call_count) - warning = warn.call_args[0][0] - self.assertIn('"data:/container/path"', warning) - self.assertIn('"./data:/container/path"', warning) - - def test_named_volume_with_driver_does_not_expand(self): - d = make_service_dict('foo', { - 'build': '.', - 'volumes': ['namedvolume:/data'], - 'volume_driver': 'foodriver', - }, working_dir='.') - self.assertEqual(d['volumes'], ['namedvolume:/data']) + 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) def test_home_directory_with_driver_does_not_expand(self): From f4cd5b1d45f0eee1a731af1664c75d27e9b4aa18 Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Tue, 22 Sep 2015 16:13:42 +0100 Subject: [PATCH 1271/4072] Handle windows volume paths When a relative path is expanded and we're on a windows platform, it expands to include the drive, eg C:\ , which was causing a ConfigError as we split on ":" in parse_volume_spec and that was giving too many parts. Use os.path.splitdrive instead of manually calculating the drive. This should help us deal with windows drives as part of the volume path better than us doing it manually. Signed-off-by: Mazz Mosley --- compose/config/config.py | 11 ++++++----- compose/const.py | 1 + compose/service.py | 14 ++++++++++++++ tests/unit/config/config_test.py | 15 +++++++++++++++ tests/unit/service_test.py | 15 +++++++++++++++ 5 files changed, 51 insertions(+), 5 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 0444ba3a6bc..9e9cb857fbf 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -526,12 +526,13 @@ def path_mappings_from_dict(d): return [join_path_mapping(v) for v in d.items()] -def split_path_mapping(string): - if ':' in string: - (host, container) = string.split(':', 1) - return (container, host) +def split_path_mapping(volume_path): + drive, volume_config = os.path.splitdrive(volume_path) + if ':' in volume_config: + (host, container) = volume_config.split(':', 1) + return (container, drive + host) else: - return (string, None) + return (volume_path, None) def join_path_mapping(pair): diff --git a/compose/const.py b/compose/const.py index b43e655b19d..b04b7e7e72e 100644 --- a/compose/const.py +++ b/compose/const.py @@ -2,6 +2,7 @@ import sys DEFAULT_TIMEOUT = 10 +IS_WINDOWS_PLATFORM = (sys.platform == "win32") LABEL_CONTAINER_NUMBER = 'com.docker.compose.container-number' LABEL_ONE_OFF = 'com.docker.compose.oneoff' LABEL_PROJECT = 'com.docker.compose.project' diff --git a/compose/service.py b/compose/service.py index 960d3936bf6..4df10fbb101 100644 --- a/compose/service.py +++ b/compose/service.py @@ -20,6 +20,7 @@ from .config import merge_environment from .config.validation import VALID_NAME_CHARS 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 @@ -937,7 +938,20 @@ def build_volume_binding(volume_spec): def parse_volume_spec(volume_config): + """ + A volume_config string, which is a path, split it into external:internal[:mode] + parts to be returned as a valid VolumeSpec tuple. + """ parts = volume_config.split(':') + + 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, volume_path = os.path.splitdrive(volume_config) + windows_parts = volume_path.split(":") + windows_parts[0] = os.path.join(drive, windows_parts[0]) + parts = windows_parts + if len(parts) > 3: raise ConfigError("Volume %s has incorrect format, should be " "external:internal[:mode]" % volume_config) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 3269cdff87c..cf299738f1a 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1124,6 +1124,21 @@ def test_expand_path_with_tilde(self): self.assertEqual(result, user_path + 'otherdir/somefile') +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" + ) + + mapping = config.split_path_mapping(windows_volume_path) + + self.assertEqual(mapping, expected_mapping) + + @pytest.mark.xfail(IS_WINDOWS_PLATFORM, reason='paths use slash') class BuildPathTest(unittest.TestCase): def setUp(self): diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index a1c195acf00..b0cee1ee160 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -466,6 +466,21 @@ def test_parse_volume_spec_too_many_parts(self): with self.assertRaises(ConfigError): parse_volume_spec('one:two:three:four') + @pytest.mark.xfail((not IS_WINDOWS_PLATFORM), reason='does not have a drive') + def test_parse_volume_windows_relative_path(self): + windows_relative_path = "c:\\Users\\msamblanet\\Documents\\anvil\\connect\\config:\\opt\\connect\\config:ro" + + spec = parse_volume_spec(windows_relative_path) + + self.assertEqual( + spec, + ( + "c:\\Users\\msamblanet\\Documents\\anvil\\connect\\config", + "\\opt\\connect\\config", + "ro" + ) + ) + def test_build_volume_binding(self): binding = build_volume_binding(parse_volume_spec('/outside:/inside')) self.assertEqual(binding, ('/inside', '/outside:/inside:rw')) From 58270d88592e6a097763ce0052ef6a8d22e9bbcb Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Wed, 23 Sep 2015 17:08:41 +0100 Subject: [PATCH 1272/4072] Remove duplicate and re-order alphabetically Signed-off-by: Mazz Mosley --- compose/const.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/compose/const.py b/compose/const.py index b04b7e7e72e..1b6894189e2 100644 --- a/compose/const.py +++ b/compose/const.py @@ -2,6 +2,7 @@ import sys DEFAULT_TIMEOUT = 10 +HTTP_TIMEOUT = int(os.environ.get('COMPOSE_HTTP_TIMEOUT', os.environ.get('DOCKER_CLIENT_TIMEOUT', 60))) IS_WINDOWS_PLATFORM = (sys.platform == "win32") LABEL_CONTAINER_NUMBER = 'com.docker.compose.container-number' LABEL_ONE_OFF = 'com.docker.compose.oneoff' @@ -9,5 +10,3 @@ LABEL_SERVICE = 'com.docker.compose.service' LABEL_VERSION = 'com.docker.compose.version' LABEL_CONFIG_HASH = 'com.docker.compose.config-hash' -HTTP_TIMEOUT = int(os.environ.get('COMPOSE_HTTP_TIMEOUT', os.environ.get('DOCKER_CLIENT_TIMEOUT', 60))) -IS_WINDOWS_PLATFORM = (sys.platform == 'win32') From 2276326d7ecc4b3bbc30d1acaaa0213669b7ad59 Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Thu, 1 Oct 2015 11:06:15 +0100 Subject: [PATCH 1273/4072] volume path compatibility with engine An expanded windows path of c:\shiny\thing:\shiny:rw needs to be changed to be linux style path, including the drive, like /c/shiny/thing /shiny to be mounted successfully by the engine. Signed-off-by: Mazz Mosley --- compose/service.py | 47 +++++++++++++++++++++++--------- tests/unit/config/config_test.py | 3 +- tests/unit/service_test.py | 7 ++--- 3 files changed, 38 insertions(+), 19 deletions(-) diff --git a/compose/service.py b/compose/service.py index 4df10fbb101..c9ca00ae414 100644 --- a/compose/service.py +++ b/compose/service.py @@ -937,33 +937,54 @@ def build_volume_binding(volume_spec): return volume_spec.internal, "{}:{}:{}".format(*volume_spec) -def parse_volume_spec(volume_config): +def normalize_paths_for_engine(external_path, internal_path): """ - A volume_config string, which is a path, split it into external:internal[:mode] - parts to be returned as a valid VolumeSpec tuple. + 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/ """ - parts = volume_config.split(':') + if IS_WINDOWS_PLATFORM: + if external_path: + drive, tail = os.path.splitdrive(external_path) + + if drive: + reformatted_drive = "/{}".format(drive.replace(":", "")) + external_path = reformatted_drive + tail + + external_path = "/".join(external_path.split("\\")) + + return external_path, "/".join(internal_path.split("\\")) + else: + return external_path, internal_path + +def parse_volume_spec(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, volume_path = os.path.splitdrive(volume_config) - windows_parts = volume_path.split(":") - windows_parts[0] = os.path.join(drive, windows_parts[0]) - parts = windows_parts + drive, tail = os.path.splitdrive(volume_config) + parts = tail.split(":") + + if drive: + parts[0] = drive + parts[0] + else: + parts = volume_config.split(':') if len(parts) > 3: raise ConfigError("Volume %s has incorrect format, should be " "external:internal[:mode]" % volume_config) if len(parts) == 1: - external = None - internal = os.path.normpath(parts[0]) + external, internal = normalize_paths_for_engine(None, os.path.normpath(parts[0])) else: - external = os.path.normpath(parts[0]) - internal = os.path.normpath(parts[1]) + external, internal = normalize_paths_for_engine(os.path.normpath(parts[0]), os.path.normpath(parts[1])) - mode = parts[2] if len(parts) == 3 else 'rw' + mode = 'rw' + if len(parts) == 3: + mode = parts[2] return VolumeSpec(external, internal, mode) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index cf299738f1a..c06a2a32792 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -420,7 +420,6 @@ def test_no_binding(self): d = make_service_dict('foo', {'build': '.', 'volumes': ['/data']}, working_dir='.') self.assertEqual(d['volumes'], ['/data']) - @pytest.mark.xfail(IS_WINDOWS_PLATFORM, reason='paths use slash') @mock.patch.dict(os.environ) def test_volume_binding_with_environment_variable(self): os.environ['VOLUME_PATH'] = '/host/path' @@ -433,7 +432,7 @@ def test_volume_binding_with_environment_variable(self): )[0] self.assertEqual(d['volumes'], ['/host/path:/container/path']) - @pytest.mark.xfail(IS_WINDOWS_PLATFORM, reason='paths use slash') + @pytest.mark.skipif(IS_WINDOWS_PLATFORM, reason='posix paths') @mock.patch.dict(os.environ) def test_volume_binding_with_home(self): os.environ['HOME'] = '/home/user' diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index b0cee1ee160..a3db0d43439 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -441,7 +441,6 @@ def mock_get_image(images): raise NoSuchImageError() -@pytest.mark.xfail(IS_WINDOWS_PLATFORM, reason='paths use slash') class ServiceVolumesTest(unittest.TestCase): def setUp(self): @@ -468,15 +467,15 @@ def test_parse_volume_spec_too_many_parts(self): @pytest.mark.xfail((not IS_WINDOWS_PLATFORM), reason='does not have a drive') def test_parse_volume_windows_relative_path(self): - windows_relative_path = "c:\\Users\\msamblanet\\Documents\\anvil\\connect\\config:\\opt\\connect\\config:ro" + windows_relative_path = "c:\\Users\\me\\Documents\\shiny\\config:\\opt\\shiny\\config:ro" spec = parse_volume_spec(windows_relative_path) self.assertEqual( spec, ( - "c:\\Users\\msamblanet\\Documents\\anvil\\connect\\config", - "\\opt\\connect\\config", + "/c/Users/me/Documents/shiny/config", + "/opt/shiny/config", "ro" ) ) From af8032a5f4a5075d71c220bfafbadcbbebbcb5b7 Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Thu, 1 Oct 2015 12:09:32 +0100 Subject: [PATCH 1274/4072] Fix incorrect test name I'd written relative, when I meant absolute. D'oh. Signed-off-by: Mazz Mosley --- tests/unit/service_test.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index a3db0d43439..c682b823773 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -466,10 +466,10 @@ def test_parse_volume_spec_too_many_parts(self): parse_volume_spec('one:two:three:four') @pytest.mark.xfail((not IS_WINDOWS_PLATFORM), reason='does not have a drive') - def test_parse_volume_windows_relative_path(self): - windows_relative_path = "c:\\Users\\me\\Documents\\shiny\\config:\\opt\\shiny\\config:ro" + def test_parse_volume_windows_absolute_path(self): + windows_absolute_path = "c:\\Users\\me\\Documents\\shiny\\config:\\opt\\shiny\\config:ro" - spec = parse_volume_spec(windows_relative_path) + spec = parse_volume_spec(windows_absolute_path) self.assertEqual( spec, From bef5c2238e7cefa12cb34b814dc536fa46e9773f Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Fri, 2 Oct 2015 15:29:26 +0100 Subject: [PATCH 1275/4072] Skip a test for now This needs resolving outside of this PR, as it is a much bigger piece of work. https://github.com/docker/compose/issues/2128 Signed-off-by: Mazz Mosley --- tests/unit/config/config_test.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index c06a2a32792..b505740f571 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -463,6 +463,7 @@ def test_relative_path_does_expand_posix(self): self.assertEqual(d['volumes'], ['/home/me/otherproject:/data']) @pytest.mark.skipif(not IS_WINDOWS_PLATFORM, reason='windows paths') + @pytest.mark.skipif(IS_WINDOWS_PLATFORM, reason='waiting for this to be resolved: https://github.com/docker/compose/issues/2128') def test_relative_path_does_expand_windows(self): 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']) From da91b81bb89907e5bffa6553540b248d0802a3ca Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 11 Sep 2015 01:44:25 -0400 Subject: [PATCH 1276/4072] Add scripts for automating parts of the release. Signed-off-by: Daniel Nephin --- project/RELEASE-PROCESS.md | 57 +++++------------- script/release/cherry-pick-pr | 28 +++++++++ script/release/make-branch | 96 +++++++++++++++++++++++++++++++ script/release/push-release | 71 +++++++++++++++++++++++ script/release/rebase-bump-commit | 39 +++++++++++++ script/release/utils.sh | 20 +++++++ 6 files changed, 269 insertions(+), 42 deletions(-) create mode 100755 script/release/cherry-pick-pr create mode 100755 script/release/make-branch create mode 100755 script/release/push-release create mode 100755 script/release/rebase-bump-commit create mode 100644 script/release/utils.sh diff --git a/project/RELEASE-PROCESS.md b/project/RELEASE-PROCESS.md index 966e06ee487..631691a0c61 100644 --- a/project/RELEASE-PROCESS.md +++ b/project/RELEASE-PROCESS.md @@ -3,11 +3,12 @@ Building a Compose release ## To get started with a new release -1. Create a `bump-$VERSION` branch off master: +Create a branch, update version, and add release notes by running `make-branch` - git checkout -b bump-$VERSION master + git checkout -b bump-$VERSION $BASE_VERSION -2. Merge in the `release` branch on the upstream repo, discarding its tree entirely: +`$BASE_VERSION` will default to master. Use the last version tag for a bug fix +release. git fetch origin git merge --strategy=ours origin/release @@ -58,38 +59,21 @@ Building a Compose release ## To release a version (whether RC or stable) -1. Check that CI is passing on the bump PR. - -2. Check out the bump branch: +Check out the bump branch and run the `push-release` script git checkout bump-$VERSION + ./script/release/push-release $VERSION -3. Build the Linux binary: - - script/build-linux - -4. Build the Mac binary in a Mountain Lion VM: - - script/prepare-osx - script/build-osx - -5. Test the binaries and/or get some other people to test them. - -6. Create a tag: - TAG=$VERSION # or $VERSION-rcN, if it's an RC - git tag $TAG +When prompted test the binaries. -7. Push the tag to the upstream repo: - git push git@github.com:docker/compose.git $TAG +1. Draft a release from the tag on GitHub (the script will open the window for + you) -8. Draft a release from the tag on GitHub. + In the "Tag version" dropdown, select the tag you just pushed. - - Go to https://github.com/docker/compose/releases and click "Draft a new release". - - In the "Tag version" dropdown, select the tag you just pushed. - -9. Paste in installation instructions and release notes. Here's an example - change the Compose version and Docker version as appropriate: +2. 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. @@ -108,24 +92,13 @@ Building a Compose release ...release notes go here... -10. Attach the binaries. - -11. Don’t publish it just yet! - -12. Upload the latest version to PyPi: - - python setup.py sdist upload - -13. Check that the pip package installs and runs (best done in a virtualenv): - - pip install -U docker-compose==$TAG - docker-compose version +3. Attach the binaries. -14. Publish the release on GitHub. +4. Publish the release on GitHub. -15. Check that both binaries download (following the install instructions) and run. +5. Check that both binaries download (following the install instructions) and run. -16. Email maintainers@dockerproject.org and engineering@docker.com about the new release. +6. Email maintainers@dockerproject.org and engineering@docker.com about the new release. ## If it’s a stable release (not an RC) diff --git a/script/release/cherry-pick-pr b/script/release/cherry-pick-pr new file mode 100755 index 00000000000..7062f7aa080 --- /dev/null +++ b/script/release/cherry-pick-pr @@ -0,0 +1,28 @@ +#!/bin/bash +# +# Cherry-pick a PR into the release branch +# + +set -e +set -o pipefail + + +function usage() { + >&2 cat << EOM +Cherry-pick commits from a github pull request. + +Usage: + + $0 +EOM + exit 1 +} + +[ -n "$1" ] || usage + + +REPO=docker/compose +GITHUB=https://github.com/$REPO/pull +PR=$1 +url="$GITHUB/$PR" +hub am -3 $url diff --git a/script/release/make-branch b/script/release/make-branch new file mode 100755 index 00000000000..99f711e1642 --- /dev/null +++ b/script/release/make-branch @@ -0,0 +1,96 @@ +#!/bin/bash +# +# Prepare a new release branch +# + +set -e +set -o pipefail + +. script/release/utils.sh + +REPO=git@github.com:docker/compose + + +function usage() { + >&2 cat << EOM +Create a new release branch `release-` + +Usage: + + $0 [] + +Options: + + version version string for the release (ex: 1.6.0) + base_version branch or tag to start from. Defaults to master. For + bug-fix releases use the previous stage release tag. + +EOM + exit 1 +} + +[ -n "$1" ] || usage +VERSION=$1 +BRANCH=bump-$VERSION + +if [ -z "$2" ]; then + BASE_VERSION="master" +else + BASE_VERSION=$2 +fi + + +DEFAULT_REMOTE=release +REMOTE=$(find_remote $REPO) +# If we don't have a docker origin add one +if [ -z "$REMOTE" ]; then + echo "Creating $DEFAULT_REMOTE remote" + git remote add ${DEFAULT_REMOTE} ${REPO} +fi + +# handle the difference between a branch and a tag +if [ -z "$(git name-rev $BASE_VERSION | grep tags)" ]; then + BASE_VERSION=$REMOTE/$BASE_VERSION +fi + +echo "Creating a release branch $VERSION from $BASE_VERSION" +read -n1 -r -p "Continue? (ctrl+c to cancel)" +git fetch $REMOTE -p +git checkout -b $BRANCH $BASE_VERSION + + +# Store the release version for this branch in git, so that other release +# scripts can use it +git config "branch.${BRANCH}.release" $VERSION + + +echo "Update versions in docs/install.md and compose/__init__.py" +# TODO: automate this +$EDITOR docs/install.md +$EDITOR compose/__init__.py + + +echo "Write release notes in CHANGELOG.md" +browser "https://github.com/docker/compose/issues?q=milestone%3A$VERSION+is%3Aclosed" +$EDITOR CHANGELOG.md + + +echo "Verify changes before commit. Exit the shell to commit changes" +git diff +$SHELL +git commit -a -m "Bump $VERSION" --signoff + + +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 -n1 -r -p "Enter the name of your github user: " GITHUB_USER + # assumes there is already a user remote somewhere + USER_REMOTE=$(find_remote $GITHUB_USER/compose) +fi + + +git push $USER_REMOTE +browser https://github.com/docker/compose/compare/docker:release...$GITHUB_USER:$BRANCH?expand=1 diff --git a/script/release/push-release b/script/release/push-release new file mode 100755 index 00000000000..276d67c1f4c --- /dev/null +++ b/script/release/push-release @@ -0,0 +1,71 @@ +#!/bin/bash +# +# Create the official release +# + +set -e +set -o pipefail + +. script/release/utils.sh + +function usage() { + >&2 cat << EOM +Publish a release by building all artifacts and pushing them. + +This script requires that 'git config branch.${BRANCH}.release' is set to the +release version for the release branch. + +EOM + exit 1 +} + +BRANCH="$(git rev-parse --abbrev-ref HEAD)" +VERSION="$(git config "branch.${BRANCH}.release")" || usage + +API=https://api.github.com/repos +REPO=docker/compose +GITHUB_REPO=git@github.com:$REPO + +# Check the build status is green +sha=$(git rev-parse HEAD) +url=$API/$REPO/statuses/$sha +build_status=$(curl -s $url | jq -r '.[0].state') +if [[ "$build_status" != "success" ]]; then + >&2 echo "Build status is $build_status, but it should be success." + exit -1 +fi + + +# Build the binaries and sdists +script/build-linux +# TODO: build osx binary +# script/prepare-osx +# script/build-osx +python setup.py sdist --formats=gztar,zip + + +echo "Test those binaries! Exit the shell to continue." +$SHELL + + +echo "Tagging the release as $VERSION" +git tag $VERSION +git push $GITHUB_REPO $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 "Uploading sdist to pypi" +python setup.py sdist upload + +echo "Testing pip package" +virtualenv venv-test +source venv-test/bin/activate +pip install docker-compose==$VERSION +docker-compose version +deactivate + +echo "Now publish the github release, and test the downloads." +echo "Email maintainers@dockerproject.org and engineering@docker.com about the new release. diff --git a/script/release/rebase-bump-commit b/script/release/rebase-bump-commit new file mode 100755 index 00000000000..732b319445d --- /dev/null +++ b/script/release/rebase-bump-commit @@ -0,0 +1,39 @@ +#!/bin/bash +# +# Move the "bump to " commit to the HEAD of the branch +# + +set -e + + +function usage() { + >&2 cat << EOM +Move the "bump to " commit to the HEAD of the branch + +This script requires that 'git config branch.${BRANCH}.release' is set to the +release version for the release branch. + +EOM + exit 1 +} + + +BRANCH="$(git rev-parse --abbrev-ref HEAD)" +VERSION="$(git config "branch.${BRANCH}.release")" || usage + + +COMMIT_MSG="Bump $VERSION" +sha=$(git log --grep $COMMIT_MSG --format="%H") +if [ -z "$sha" ]; then + >&2 echo "No commit with message \"$COMMIT_MSG\"" + exit 2 +fi +if [[ "$sha" == "$(git rev-parse HEAD)" ]]; then + >&2 echo "Bump commit already at HEAD" + exit 0 +fi + +commits=$(git log --format="%H" HEAD..$sha | wc -l) + +git rebase --onto $sha~1 HEAD~$commits $BRANCH +git cherry-pick $sha diff --git a/script/release/utils.sh b/script/release/utils.sh new file mode 100644 index 00000000000..d64d1161810 --- /dev/null +++ b/script/release/utils.sh @@ -0,0 +1,20 @@ +#!/bin/bash +# +# Util functions for release scritps +# + +set -e + + +function browser() { + local url=$1 + xdg-open $url || open $url +} + + +function find_remote() { + local url=$1 + for remote in $(git remote); do + git config --get remote.${remote}.url | grep $url > /dev/null && echo -n $remote + done +} From dc56e4f97ec83b2b2888b167e4bbb347d4bc9409 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 11 Sep 2015 02:02:01 -0400 Subject: [PATCH 1277/4072] Update release process docs to use scripts. Signed-off-by: Daniel Nephin --- project/RELEASE-PROCESS.md | 39 ++++++++++---------------------------- 1 file changed, 10 insertions(+), 29 deletions(-) diff --git a/project/RELEASE-PROCESS.md b/project/RELEASE-PROCESS.md index 631691a0c61..c9b7a78cff1 100644 --- a/project/RELEASE-PROCESS.md +++ b/project/RELEASE-PROCESS.md @@ -5,19 +5,18 @@ Building a Compose release Create a branch, update version, and add release notes by running `make-branch` - git checkout -b bump-$VERSION $BASE_VERSION + ./script/release/make-branch $VERSION [$BASE_VERSION] -`$BASE_VERSION` will default to master. Use the last version tag for a bug fix +`$BASE_VERSION` will default to master. Use the last version tag for a bug fix release. - git fetch origin - git merge --strategy=ours origin/release +As part of this script you'll be asked to: -3. Update the version in `docs/install.md` and `compose/__init__.py`. +1. Update the version in `docs/install.md` and `compose/__init__.py`. If the next release will be an RC, append `rcN`, e.g. `1.4.0rc1`. -4. Write release notes in `CHANGES.md`. +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. @@ -25,38 +24,20 @@ release. Improvements to the code are not worth mentioning. -5. Add a bump commit: - - git commit -am "Bump $VERSION" - -6. Push the bump branch to your fork: - - git push --set-upstream $USERNAME bump-$VERSION - -7. Open a PR from the bump branch against the `release` branch on the upstream repo, **not** against master. ## When a PR is merged into master that we want in the release -1. Check out the bump branch: +1. Check out the bump branch and run the cherry pick script git checkout bump-$VERSION + ./script/release/cherry-pick-pr $PR_NUMBER -2. Cherry-pick the merge commit, fixing any conflicts if necessary: - - git cherry-pick -xm1 $MERGE_COMMIT_HASH - -3. Add a signoff (it’s missing from merge commits): - - git commit --amend --signoff - -4. Move the bump commit back to the tip of the branch: - - git rebase --interactive $PARENT_OF_BUMP_COMMIT - -5. Force-push the bump branch to your fork: +2. When you are done cherry-picking branches move the bump version commit to HEAD + ./script/release/rebase-bump-commit git push --force $USERNAME bump-$VERSION + ## To release a version (whether RC or stable) Check out the bump branch and run the `push-release` script From 1a2a0dd53ded656cc734f454b016c91f0ee2da10 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 22 Sep 2015 10:10:11 -0400 Subject: [PATCH 1278/4072] Fix some bugs in the release scripts Signed-off-by: Daniel Nephin --- project/RELEASE-PROCESS.md | 19 +++++++++------ script/release/build-binaries | 21 ++++++++++++++++ script/release/cherry-pick-pr | 6 +++++ script/release/make-branch | 34 +++++++++++++------------- script/release/push-release | 40 +++++++++++++------------------ script/release/rebase-bump-commit | 7 +++--- script/release/utils.sh | 3 +++ 7 files changed, 78 insertions(+), 52 deletions(-) create mode 100755 script/release/build-binaries diff --git a/project/RELEASE-PROCESS.md b/project/RELEASE-PROCESS.md index c9b7a78cff1..810c309747f 100644 --- a/project/RELEASE-PROCESS.md +++ b/project/RELEASE-PROCESS.md @@ -7,7 +7,7 @@ Create a branch, update version, and add release notes by running `make-branch` ./script/release/make-branch $VERSION [$BASE_VERSION] -`$BASE_VERSION` will default to master. Use the last version tag for a bug fix +`$BASE_VERSION` will default to master. Use the last version tag for a bug fix release. As part of this script you'll be asked to: @@ -40,15 +40,14 @@ As part of this script you'll be asked to: ## To release a version (whether RC or stable) -Check out the bump branch and run the `push-release` script +Check out the bump branch and run the `build-binary` script git checkout bump-$VERSION - ./script/release/push-release $VERSION + ./script/release/build-binary When prompted test the binaries. - 1. Draft a release from the tag on GitHub (the script will open the window for you) @@ -75,11 +74,17 @@ When prompted test the binaries. 3. Attach the binaries. -4. Publish the release on GitHub. +4. If everything looks good, it's time to push the release. + + + ./script/release/push-release + + +5. Publish the release on GitHub. -5. Check that both binaries download (following the install instructions) and run. +6. Check that both binaries download (following the install instructions) and run. -6. Email maintainers@dockerproject.org and engineering@docker.com about the new release. +7. Email maintainers@dockerproject.org and engineering@docker.com about the new release. ## If it’s a stable release (not an RC) diff --git a/script/release/build-binaries b/script/release/build-binaries new file mode 100755 index 00000000000..9f65b45d27f --- /dev/null +++ b/script/release/build-binaries @@ -0,0 +1,21 @@ +#!/bin/bash +# +# Build the release binaries +# + +. "$(dirname "${BASH_SOURCE[0]}")/utils.sh" + +REPO=docker/compose + +# Build the binaries +script/clean +script/build-linux +# TODO: build osx binary +# script/prepare-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 "Create a github release" +# TODO: script more of this https://developer.github.com/v3/repos/releases/ +browser https://github.com/$REPO/releases/new diff --git a/script/release/cherry-pick-pr b/script/release/cherry-pick-pr index 7062f7aa080..604600872cf 100755 --- a/script/release/cherry-pick-pr +++ b/script/release/cherry-pick-pr @@ -20,6 +20,12 @@ EOM [ -n "$1" ] || usage +if [ -z "$(command -v hub 2> /dev/null)" ]; then + >&2 echo "$0 requires https://hub.github.com/." + >&2 echo "Please install it and ake sure it is available on your \$PATH." + exit 2 +fi + REPO=docker/compose GITHUB=https://github.com/$REPO/pull diff --git a/script/release/make-branch b/script/release/make-branch index 99f711e1642..66ed6bbf356 100755 --- a/script/release/make-branch +++ b/script/release/make-branch @@ -3,17 +3,11 @@ # Prepare a new release branch # -set -e -set -o pipefail - -. script/release/utils.sh - -REPO=git@github.com:docker/compose - +. "$(dirname "${BASH_SOURCE[0]}")/utils.sh" function usage() { >&2 cat << EOM -Create a new release branch `release-` +Create a new release branch 'release-' Usage: @@ -29,9 +23,12 @@ EOM exit 1 } + [ -n "$1" ] || usage VERSION=$1 BRANCH=bump-$VERSION +REPO=docker/compose +GITHUB_REPO=git@github.com:$REPO if [ -z "$2" ]; then BASE_VERSION="master" @@ -41,11 +38,11 @@ fi DEFAULT_REMOTE=release -REMOTE=$(find_remote $REPO) +REMOTE="$(find_remote "$GITHUB_REPO")" # If we don't have a docker origin add one if [ -z "$REMOTE" ]; then echo "Creating $DEFAULT_REMOTE remote" - git remote add ${DEFAULT_REMOTE} ${REPO} + git remote add ${DEFAULT_REMOTE} ${GITHUB_REPO} fi # handle the difference between a branch and a tag @@ -65,7 +62,6 @@ git config "branch.${BRANCH}.release" $VERSION echo "Update versions in docs/install.md and compose/__init__.py" -# TODO: automate this $EDITOR docs/install.md $EDITOR compose/__init__.py @@ -75,22 +71,26 @@ browser "https://github.com/docker/compose/issues?q=milestone%3A$VERSION+is%3Acl $EDITOR CHANGELOG.md -echo "Verify changes before commit. Exit the shell to commit changes" git diff -$SHELL -git commit -a -m "Bump $VERSION" --signoff +echo "Verify changes before commit. Exit the shell to commit changes" +$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) +USER_REMOTE="$(find_remote $GITHUB_USER/compose)" if [ -z "$USER_REMOTE" ]; then echo "No user remote found for $GITHUB_USER" - read -n1 -r -p "Enter the name of your github user: " GITHUB_USER + read -r -p "Enter the name of your github user: " GITHUB_USER # assumes there is already a user remote somewhere USER_REMOTE=$(find_remote $GITHUB_USER/compose) 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/docker/compose/compare/docker:release...$GITHUB_USER:$BRANCH?expand=1 +browser https://github.com/$REPO/compare/docker:release...$GITHUB_USER:$BRANCH?expand=1 diff --git a/script/release/push-release b/script/release/push-release index 276d67c1f4c..7c44866671e 100755 --- a/script/release/push-release +++ b/script/release/push-release @@ -3,10 +3,7 @@ # Create the official release # -set -e -set -o pipefail - -. script/release/utils.sh +. "$(dirname "${BASH_SOURCE[0]}")/utils.sh" function usage() { >&2 cat << EOM @@ -22,6 +19,13 @@ EOM BRANCH="$(git rev-parse --abbrev-ref HEAD)" VERSION="$(git config "branch.${BRANCH}.release")" || usage +if [ -z "$(command -v jq 2> /dev/null)" ]; then + >&2 echo "$0 requires https://stedolan.github.io/jq/" + >&2 echo "Please install it and ake sure it is available on your \$PATH." + exit 2 +fi + + API=https://api.github.com/repos REPO=docker/compose GITHUB_REPO=git@github.com:$REPO @@ -35,30 +39,18 @@ if [[ "$build_status" != "success" ]]; then exit -1 fi - -# Build the binaries and sdists -script/build-linux -# TODO: build osx binary -# script/prepare-osx -# script/build-osx -python setup.py sdist --formats=gztar,zip - - -echo "Test those binaries! Exit the shell to continue." -$SHELL - - echo "Tagging the release as $VERSION" git tag $VERSION git push $GITHUB_REPO $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 "Uploading sdist to pypi" -python setup.py sdist upload +python setup.py sdist + +if [ "$(command -v twine 2> /dev/null)" ]; then + twine upload ./dist/docker-compose-${VERSION}.tar.gz +else + python setup.py upload +fi echo "Testing pip package" virtualenv venv-test @@ -68,4 +60,4 @@ docker-compose version deactivate echo "Now publish the github release, and test the downloads." -echo "Email maintainers@dockerproject.org and engineering@docker.com about the new release. +echo "Email maintainers@dockerproject.org and engineering@docker.com about the new release." diff --git a/script/release/rebase-bump-commit b/script/release/rebase-bump-commit index 732b319445d..14ad22a9821 100755 --- a/script/release/rebase-bump-commit +++ b/script/release/rebase-bump-commit @@ -3,8 +3,7 @@ # Move the "bump to " commit to the HEAD of the branch # -set -e - +. "$(dirname "${BASH_SOURCE[0]}")/utils.sh" function usage() { >&2 cat << EOM @@ -23,7 +22,7 @@ VERSION="$(git config "branch.${BRANCH}.release")" || usage COMMIT_MSG="Bump $VERSION" -sha=$(git log --grep $COMMIT_MSG --format="%H") +sha="$(git log --grep "$COMMIT_MSG" --format="%H")" if [ -z "$sha" ]; then >&2 echo "No commit with message \"$COMMIT_MSG\"" exit 2 @@ -33,7 +32,7 @@ if [[ "$sha" == "$(git rev-parse HEAD)" ]]; then exit 0 fi -commits=$(git log --format="%H" HEAD..$sha | wc -l) +commits=$(git log --format="%H" "$sha..HEAD" | wc -l) git rebase --onto $sha~1 HEAD~$commits $BRANCH git cherry-pick $sha diff --git a/script/release/utils.sh b/script/release/utils.sh index d64d1161810..b4e5a2e6a05 100644 --- a/script/release/utils.sh +++ b/script/release/utils.sh @@ -4,6 +4,7 @@ # set -e +set -o pipefail function browser() { @@ -17,4 +18,6 @@ function find_remote() { for remote in $(git remote); do git config --get remote.${remote}.url | grep $url > /dev/null && echo -n $remote done + # Always return true, extra remotes cause it to return false + true } From 04375fd566a7f52485f7c28876dcf433e6c2fa34 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 2 Oct 2015 11:25:16 -0400 Subject: [PATCH 1279/4072] Restore notes about building non-linux binaries. Signed-off-by: Daniel Nephin --- project/RELEASE-PROCESS.md | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/project/RELEASE-PROCESS.md b/project/RELEASE-PROCESS.md index 810c309747f..30a9805af26 100644 --- a/project/RELEASE-PROCESS.md +++ b/project/RELEASE-PROCESS.md @@ -45,15 +45,23 @@ Check out the bump branch and run the `build-binary` script git checkout bump-$VERSION ./script/release/build-binary +When prompted build the non-linux binaries and test them. -When prompted test the binaries. +1. Build the Mac binary in a Mountain Lion VM: -1. Draft a release from the tag on GitHub (the script will open the window for + script/prepare-osx + script/build-osx + +2. Download the windows binary from AppVeyor + + https://ci.appveyor.com/project/docker/compose/build//artifacts + +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. -2. 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. @@ -72,19 +80,19 @@ When prompted test the binaries. ...release notes go here... -3. Attach the binaries. +5. Attach the binaries. -4. If everything looks good, it's time to push the release. +6. If everything looks good, it's time to push the release. ./script/release/push-release -5. Publish the release on GitHub. +7. Publish the release on GitHub. -6. Check that both binaries download (following the install instructions) and run. +8. Check that both binaries download (following the install instructions) and run. -7. Email maintainers@dockerproject.org and engineering@docker.com about the new release. +9. Email maintainers@dockerproject.org and engineering@docker.com about the new release. ## If it’s a stable release (not an RC) From 39cea970b8d161ce6986d5ad2f14b63cb3ff3094 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Sat, 25 Jul 2015 19:47:36 -0400 Subject: [PATCH 1280/4072] alpine docker image for running compose and a script to pull and run it with the correct volumes. Signed-off-by: Daniel Nephin --- .dockerignore | 2 +- Dockerfile.run | 15 +++++++++++++++ docs/install.md | 26 ++++++++++++++++++++++---- script/run | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 86 insertions(+), 5 deletions(-) create mode 100644 Dockerfile.run create mode 100755 script/run diff --git a/.dockerignore b/.dockerignore index 5a4da301b12..055ae7ed190 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,6 +4,6 @@ .tox build coverage-html -dist docs/_site venv +.tox diff --git a/Dockerfile.run b/Dockerfile.run new file mode 100644 index 00000000000..3c12fa18233 --- /dev/null +++ b/Dockerfile.run @@ -0,0 +1,15 @@ + +FROM alpine:edge +RUN apk -U add \ + python \ + py-pip + +COPY requirements.txt /code/requirements.txt +RUN pip install -r /code/requirements.txt + +ENV VERSION 1.4.0dev + +COPY dist/docker-compose-$VERSION.tar.gz /code/docker-compose/ +RUN pip install /code/docker-compose/docker-compose-$VERSION/ + +ENTRYPOINT ["/usr/bin/docker-compose"] diff --git a/docs/install.md b/docs/install.md index bc1f8f78c18..fd7b3cabf97 100644 --- a/docs/install.md +++ b/docs/install.md @@ -40,20 +40,38 @@ To install Compose, do the following: curl -L https://github.com/docker/compose/releases/download/VERSION_NUM/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose - If you have problems installing with `curl`, you can use `pip` instead: `pip install -U docker-compose` + If you have problems installing with `curl`, see + [Alternative Install Options](#alternative-install-options). -4. Apply executable permissions to the binary: +5. Apply executable permissions to the binary: $ chmod +x /usr/local/bin/docker-compose -5. Optionally, install [command completion](completion.md) for the +6. Optionally, install [command completion](completion.md) for the `bash` and `zsh` shell. -6. Test the installation. +7. Test the installation. $ docker-compose --version docker-compose version: 1.4.2 + +## Alternative install options + +### Install using pip + + $ sudo pip install -U docker-compose + + +### 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.5.0/compose-run > /usr/local/bin/docker-compose + $ chmod +x /usr/local/bin/docker-compose + + ## Upgrading If you're upgrading from Compose 1.2 or earlier, you'll need to remove or migrate diff --git a/script/run b/script/run new file mode 100755 index 00000000000..64718efdce9 --- /dev/null +++ b/script/run @@ -0,0 +1,48 @@ +#!/bin/bash +# +# Run docker-compose in a container +# +# This script will attempt to mirror the host paths by using volumes for the +# following paths: +# * $(pwd) +# * $(dirname $COMPOSE_FILE) if it's set +# * $HOME if it's set +# +# You can add additional volumes (or any docker run options) using +# the $COMPOSE_OPTIONS environment variable. +# + + +set -e + +VERSION="1.4.0dev" +# TODO: move this to an official repo +IMAGE="dnephin/docker-compose:$VERSION" + + +# Setup options for connecting to docker host +if [ -z "$DOCKER_HOST" ]; then + DOCKER_HOST="/var/run/docker.sock" +fi +if [ -S "$DOCKER_HOST" ]; then + DOCKER_ADDR="-v $DOCKER_HOST:$DOCKER_HOST -e DOCKER_HOST" +else + DOCKER_ADDR="-e DOCKER_HOST" +fi + + +# Setup volume mounts for compose config and context +VOLUMES="-v $(pwd):$(pwd)" +if [ -n "$COMPOSE_FILE" ]; then + compose_dir=$(dirname $COMPOSE_FILE) +fi +# TODO: also check --file argument +if [ -n "$compose_dir" ]; then + VOLUMES="$VOLUMES -v $compose_dir:$compose_dir" +fi +if [ -n "$HOME" ]; then + VOLUMES="$VOLUMES -v $HOME:$HOME" +fi + + +exec docker run --rm -ti $DOCKER_ADDR $COMPOSE_OPTIONS $VOLUMES -w $(pwd) $IMAGE $@ From e230142a2548ca32da4856bdf35663fad2bf4d27 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Sat, 3 Oct 2015 01:24:28 -0400 Subject: [PATCH 1281/4072] Reduce the scope of sys.stdout patching. Signed-off-by: Daniel Nephin --- tests/integration/cli_test.py | 51 +++++++++++++++---------------- tests/integration/service_test.py | 12 ++++---- 2 files changed, 30 insertions(+), 33 deletions(-) diff --git a/tests/integration/cli_test.py b/tests/integration/cli_test.py index 5dbe3397f59..3774eb88e63 100644 --- a/tests/integration/cli_test.py +++ b/tests/integration/cli_test.py @@ -52,32 +52,32 @@ def test_help(self): self.command.base_dir = old_base_dir # TODO: address the "Inappropriate ioctl for device" warnings in test output - @mock.patch('sys.stdout', new_callable=StringIO) - def test_ps(self, mock_stdout): + def test_ps(self): self.project.get_service('simple').create_container() - self.command.dispatch(['ps'], None) + with mock.patch('sys.stdout', new_callable=StringIO) as mock_stdout: + self.command.dispatch(['ps'], None) self.assertIn('simplecomposefile_simple_1', mock_stdout.getvalue()) - @mock.patch('sys.stdout', new_callable=StringIO) - def test_ps_default_composefile(self, mock_stdout): + def test_ps_default_composefile(self): self.command.base_dir = 'tests/fixtures/multiple-composefiles' - self.command.dispatch(['up', '-d'], None) - self.command.dispatch(['ps'], None) + with mock.patch('sys.stdout', new_callable=StringIO) as mock_stdout: + self.command.dispatch(['up', '-d'], None) + self.command.dispatch(['ps'], None) output = mock_stdout.getvalue() self.assertIn('multiplecomposefiles_simple_1', output) self.assertIn('multiplecomposefiles_another_1', output) self.assertNotIn('multiplecomposefiles_yetanother_1', output) - @mock.patch('sys.stdout', new_callable=StringIO) - def test_ps_alternate_composefile(self, mock_stdout): + def test_ps_alternate_composefile(self): config_path = os.path.abspath( 'tests/fixtures/multiple-composefiles/compose2.yml') self._project = get_project(self.command.base_dir, [config_path]) self.command.base_dir = 'tests/fixtures/multiple-composefiles' - self.command.dispatch(['-f', 'compose2.yml', 'up', '-d'], None) - self.command.dispatch(['-f', 'compose2.yml', 'ps'], None) + with mock.patch('sys.stdout', new_callable=StringIO) as mock_stdout: + self.command.dispatch(['-f', 'compose2.yml', 'up', '-d'], None) + self.command.dispatch(['-f', 'compose2.yml', 'ps'], None) output = mock_stdout.getvalue() self.assertNotIn('multiplecomposefiles_simple_1', output) @@ -105,54 +105,51 @@ def test_pull_with_ignore_pull_failures(self, mock_logging): mock_logging.info.assert_any_call('Pulling another (nonexisting-image:latest)...') mock_logging.error.assert_any_call('Error: image library/nonexisting-image:latest not found') - @mock.patch('sys.stdout', new_callable=StringIO) - def test_build_plain(self, mock_stdout): + def test_build_plain(self): self.command.base_dir = 'tests/fixtures/simple-dockerfile' self.command.dispatch(['build', 'simple'], None) - mock_stdout.truncate(0) cache_indicator = 'Using cache' pull_indicator = 'Status: Image is up to date for busybox:latest' - self.command.dispatch(['build', 'simple'], None) + + with mock.patch('sys.stdout', new_callable=StringIO) as mock_stdout: + self.command.dispatch(['build', 'simple'], None) output = mock_stdout.getvalue() self.assertIn(cache_indicator, output) self.assertNotIn(pull_indicator, output) - @mock.patch('sys.stdout', new_callable=StringIO) - def test_build_no_cache(self, mock_stdout): + def test_build_no_cache(self): self.command.base_dir = 'tests/fixtures/simple-dockerfile' self.command.dispatch(['build', 'simple'], None) - mock_stdout.truncate(0) cache_indicator = 'Using cache' pull_indicator = 'Status: Image is up to date for busybox:latest' - self.command.dispatch(['build', '--no-cache', 'simple'], None) + with mock.patch('sys.stdout', new_callable=StringIO) as mock_stdout: + self.command.dispatch(['build', '--no-cache', 'simple'], None) output = mock_stdout.getvalue() self.assertNotIn(cache_indicator, output) self.assertNotIn(pull_indicator, output) - @mock.patch('sys.stdout', new_callable=StringIO) - def test_build_pull(self, mock_stdout): + def test_build_pull(self): self.command.base_dir = 'tests/fixtures/simple-dockerfile' self.command.dispatch(['build', 'simple'], None) - mock_stdout.truncate(0) cache_indicator = 'Using cache' pull_indicator = 'Status: Image is up to date for busybox:latest' - self.command.dispatch(['build', '--pull', 'simple'], None) + with mock.patch('sys.stdout', new_callable=StringIO) as mock_stdout: + self.command.dispatch(['build', '--pull', 'simple'], None) output = mock_stdout.getvalue() self.assertIn(cache_indicator, output) self.assertIn(pull_indicator, output) - @mock.patch('sys.stdout', new_callable=StringIO) - def test_build_no_cache_pull(self, mock_stdout): + def test_build_no_cache_pull(self): self.command.base_dir = 'tests/fixtures/simple-dockerfile' self.command.dispatch(['build', 'simple'], None) - mock_stdout.truncate(0) cache_indicator = 'Using cache' pull_indicator = 'Status: Image is up to date for busybox:latest' - self.command.dispatch(['build', '--no-cache', '--pull', 'simple'], None) + with mock.patch('sys.stdout', new_callable=StringIO) as mock_stdout: + self.command.dispatch(['build', '--no-cache', '--pull', 'simple'], None) output = mock_stdout.getvalue() self.assertNotIn(cache_indicator, output) self.assertIn(pull_indicator, output) diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 2c9c6fc204e..7ea4aae511d 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -597,8 +597,7 @@ def test_scale_with_stopped_containers(self): self.assertNotIn('Creating', captured_output) self.assertIn('Starting', captured_output) - @mock.patch('sys.stdout', new_callable=StringIO) - def test_scale_with_stopped_containers_and_needing_creation(self, mock_stdout): + def test_scale_with_stopped_containers_and_needing_creation(self): """ Given there are some stopped containers and scale is called with a desired number that is greater than the number of stopped containers, @@ -611,7 +610,8 @@ def test_scale_with_stopped_containers_and_needing_creation(self, mock_stdout): for container in service.containers(): self.assertFalse(container.is_running) - service.scale(2) + with mock.patch('sys.stdout', new_callable=StringIO) as mock_stdout: + service.scale(2) self.assertEqual(len(service.containers()), 2) for container in service.containers(): @@ -621,8 +621,7 @@ def test_scale_with_stopped_containers_and_needing_creation(self, mock_stdout): self.assertIn('Creating', captured_output) self.assertIn('Starting', captured_output) - @mock.patch('sys.stdout', new_callable=StringIO) - def test_scale_with_api_returns_errors(self, mock_stdout): + def test_scale_with_api_returns_errors(self): """ Test that when scaling if the API returns an error, that error is handled and the remaining threads continue. @@ -635,7 +634,8 @@ def test_scale_with_api_returns_errors(self, mock_stdout): 'compose.container.Container.create', side_effect=APIError(message="testing", response={}, explanation="Boom")): - service.scale(3) + with mock.patch('sys.stdout', new_callable=StringIO) as mock_stdout: + service.scale(3) self.assertEqual(len(service.containers()), 1) self.assertTrue(service.containers()[0].is_running) From aefb7a44b20f51a72f9eead4297403579bed2c4f Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 5 Oct 2015 15:48:35 -0400 Subject: [PATCH 1282/4072] Refactor command class hierarchy to remove an unnecessary intermediate base class Command. Signed-off-by: Daniel Nephin --- compose/cli/command.py | 60 +++++++++++++++-------------------- compose/cli/docopt_command.py | 3 -- compose/cli/main.py | 35 ++++++++++++++------ 3 files changed, 51 insertions(+), 47 deletions(-) diff --git a/compose/cli/command.py b/compose/cli/command.py index 443b89c6113..5c233df722e 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -1,6 +1,7 @@ from __future__ import absolute_import from __future__ import unicode_literals +import contextlib import logging import os import re @@ -16,7 +17,6 @@ from ..project import Project from ..service import ConfigError from .docker_client import docker_client -from .docopt_command import DocoptCommand from .utils import call_silently from .utils import is_mac from .utils import is_ubuntu @@ -24,40 +24,32 @@ log = logging.getLogger(__name__) -class Command(DocoptCommand): - base_dir = '.' - - def dispatch(self, *args, **kwargs): - try: - super(Command, self).dispatch(*args, **kwargs) - 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', 'boot2docker']) == 0: - raise errors.ConnectionErrorDockerMachine() +@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.ConnectionErrorGeneric(self.get_client().base_url) - - def perform_command(self, options, handler, command_options): - if options['COMMAND'] in ('help', 'version'): - # Skip looking up the compose file. - handler(None, command_options) - return - - project = get_project( - self.base_dir, - get_config_path(options.get('--file')), - project_name=options.get('--project-name'), - verbose=options.get('--verbose')) - - handler(project, command_options) + raise errors.DockerNotFoundGeneric() + elif call_silently(['which', 'boot2docker']) == 0: + raise errors.ConnectionErrorDockerMachine() + else: + raise errors.ConnectionErrorGeneric(self.get_client().base_url) + + +def project_from_options(base_dir, options): + return get_project( + base_dir, + get_config_path(options.get('--file')), + project_name=options.get('--project-name'), + verbose=options.get('--verbose')) def get_config_path(file_option): diff --git a/compose/cli/docopt_command.py b/compose/cli/docopt_command.py index 27f4b2bd7f2..e3f4aa9e5b7 100644 --- a/compose/cli/docopt_command.py +++ b/compose/cli/docopt_command.py @@ -25,9 +25,6 @@ def sys_dispatch(self): def dispatch(self, argv, global_options): self.perform_command(*self.parse(argv, global_options)) - def perform_command(self, options, handler, command_options): - handler(command_options) - def parse(self, argv, global_options): options = docopt_full_help(getdoc(self), argv, **self.docopt_options()) command = options['COMMAND'] diff --git a/compose/cli/main.py b/compose/cli/main.py index 60e60b795d9..0f0a69cad63 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -23,7 +23,9 @@ from ..service import BuildError from ..service import ConvergenceStrategy from ..service import NeedsBuildError -from .command import Command +from .command import friendly_error_message +from .command import project_from_options +from .docopt_command import DocoptCommand from .docopt_command import NoSuchCommand from .errors import UserError from .formatter import Formatter @@ -89,6 +91,15 @@ def setup_logging(): logging.getLogger("requests").propagate = False +def setup_console_handler(verbose): + if verbose: + console_handler.setFormatter(logging.Formatter('%(name)s.%(funcName)s: %(message)s')) + console_handler.setLevel(logging.DEBUG) + else: + console_handler.setFormatter(logging.Formatter()) + console_handler.setLevel(logging.INFO) + + # stolen from docopt master def parse_doc_section(name, source): pattern = re.compile('^([^\n]*' + name + '[^\n]*\n?(?:[ \t].*?(?:\n|$))*)', @@ -96,7 +107,7 @@ def parse_doc_section(name, source): return [s.strip() for s in pattern.findall(source)] -class TopLevelCommand(Command): +class TopLevelCommand(DocoptCommand): """Define and run multi-container applications with Docker. Usage: @@ -130,20 +141,24 @@ class TopLevelCommand(Command): version Show the Docker-Compose version information """ + 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, *args, **kwargs): - if options.get('--verbose'): - console_handler.setFormatter(logging.Formatter('%(name)s.%(funcName)s: %(message)s')) - console_handler.setLevel(logging.DEBUG) - else: - console_handler.setFormatter(logging.Formatter()) - console_handler.setLevel(logging.INFO) + def perform_command(self, options, handler, command_options): + setup_console_handler(options.get('--verbose')) + + if options['COMMAND'] in ('help', 'version'): + # Skip looking up the compose file. + handler(None, command_options) + return - return super(TopLevelCommand, self).perform_command(options, *args, **kwargs) + project = project_from_options(self.base_dir, options) + with friendly_error_message(): + handler(project, command_options) def build(self, project, options): """ From fbaea58fc1a204d676ad098f6b51ba5de1aeccf1 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 5 Oct 2015 15:50:16 -0400 Subject: [PATCH 1283/4072] Fix #2133 - fix call to get_client() Signed-off-by: Daniel Nephin --- compose/cli/command.py | 4 ++-- tests/unit/cli/command_test.py | 22 ++++++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) create mode 100644 tests/unit/cli/command_test.py diff --git a/compose/cli/command.py b/compose/cli/command.py index 5c233df722e..1a9bc3dcbf3 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -38,10 +38,10 @@ def friendly_error_message(): raise errors.DockerNotFoundUbuntu() else: raise errors.DockerNotFoundGeneric() - elif call_silently(['which', 'boot2docker']) == 0: + elif call_silently(['which', 'docker-machine']) == 0: raise errors.ConnectionErrorDockerMachine() else: - raise errors.ConnectionErrorGeneric(self.get_client().base_url) + raise errors.ConnectionErrorGeneric(get_client().base_url) def project_from_options(base_dir, options): diff --git a/tests/unit/cli/command_test.py b/tests/unit/cli/command_test.py new file mode 100644 index 00000000000..0d4324e355c --- /dev/null +++ b/tests/unit/cli/command_test.py @@ -0,0 +1,22 @@ +from __future__ import absolute_import + +import pytest +from requests.exceptions import ConnectionError + +from compose.cli import errors +from compose.cli.command import friendly_error_message +from tests import mock +from tests import unittest + + +class FriendlyErrorMessageTestCase(unittest.TestCase): + + 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() From 018b1b1c0f21c8bb76efdc480447c7166e696242 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 6 Oct 2015 12:57:01 +0100 Subject: [PATCH 1284/4072] Add preparation instructions to Windows build script Signed-off-by: Aanand Prasad --- script/build-windows.ps1 | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/script/build-windows.ps1 b/script/build-windows.ps1 index 63be0865242..f7fd1589738 100644 --- a/script/build-windows.ps1 +++ b/script/build-windows.ps1 @@ -1,3 +1,33 @@ +# Builds the Windows binary. +# +# From a fresh 64-bit Windows 10 install, prepare the system as follows: +# +# 1. Install Git: +# +# http://git-scm.com/download/win +# +# 2. Install Python 2.7.10: +# +# https://www.python.org/downloads/ +# +# 3. Append ";C:\Python27;C:\Python27\Scripts" to the "Path" environment variable: +# +# https://www.microsoft.com/resources/documentation/windows/xp/all/proddocs/en-us/sysdm_advancd_environmnt_addchange_variable.mspx?mfr=true +# +# 4. In Powershell, run the following commands: +# +# $ pip install virtualenv +# $ Set-ExecutionPolicy -Scope CurrentUser RemoteSigned +# +# 5. Clone the repository: +# +# $ git clone https://github.com/docker/compose.git +# $ cd compose +# +# 6. Build the binary: +# +# .\script\build-windows.ps1 + $ErrorActionPreference = "Stop" Set-PSDebug -trace 1 From 5b55a08846088d6cdbc4aca08d3143f5c9c3d3b7 Mon Sep 17 00:00:00 2001 From: Vincent Demeester Date: Sat, 18 Jul 2015 11:38:46 +0200 Subject: [PATCH 1285/4072] Add support for ro option in volumes_from Fixes #1188 Signed-off-by: Vincent Demeester --- compose/project.py | 24 +++++++++----- compose/service.py | 52 ++++++++++++++++++++++++------- docs/yml.md | 4 ++- tests/integration/project_test.py | 5 +-- tests/integration/service_test.py | 7 +++-- tests/unit/project_test.py | 6 ++-- tests/unit/service_test.py | 34 ++++++++++++++++---- 7 files changed, 97 insertions(+), 35 deletions(-) diff --git a/compose/project.py b/compose/project.py index 4750a7a9ae1..919a201f1f0 100644 --- a/compose/project.py +++ b/compose/project.py @@ -17,8 +17,10 @@ from .service import ContainerNet from .service import ConvergenceStrategy from .service import Net +from .service import parse_volume_from_spec from .service import Service from .service import ServiceNet +from .service import VolumeFromSpec from .utils import parallel_execute @@ -34,12 +36,15 @@ def sort_service_dicts(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.split(':')[0] 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 service.get('volumes_from', []) or + name in get_service_names_from_volumes_from(service.get('volumes_from', [])) or name == get_service_name_from_net(service.get('net'))) ] @@ -176,20 +181,23 @@ def get_links(self, service_dict): def get_volumes_from(self, service_dict): volumes_from = [] if 'volumes_from' in service_dict: - for volume_name in service_dict.get('volumes_from', []): + for volume_from_config in service_dict.get('volumes_from', []): + volume_from_spec = parse_volume_from_spec(volume_from_config) + # Get service try: - service = self.get_service(volume_name) - volumes_from.append(service) + service_name = self.get_service(volume_from_spec.source) + volume_from_spec = VolumeFromSpec(service_name, volume_from_spec.mode) except NoSuchService: try: - container = Container.from_id(self.client, volume_name) - volumes_from.append(container) + container_name = Container.from_id(self.client, volume_from_spec.source) + volume_from_spec = VolumeFromSpec(container_name, volume_from_spec.mode) except APIError: raise ConfigurationError( 'Service "%s" mounts volumes from "%s", which is ' 'not the name of a service or container.' % ( - service_dict['name'], - volume_name)) + volume_from_config, + volume_from_spec.source)) + volumes_from.append(volume_from_spec) del service_dict['volumes_from'] return volumes_from diff --git a/compose/service.py b/compose/service.py index c9ca00ae414..79a138aac7c 100644 --- a/compose/service.py +++ b/compose/service.py @@ -6,7 +6,6 @@ import re import sys from collections import namedtuple -from operator import attrgetter import enum import six @@ -82,6 +81,9 @@ class NoSuchImageError(Exception): VolumeSpec = namedtuple('VolumeSpec', 'external internal mode') +VolumeFromSpec = namedtuple('VolumeFromSpec', 'source mode') + + ServiceName = namedtuple('ServiceName', 'project service number') @@ -519,7 +521,7 @@ def get_link_names(self): return [(service.name, alias) for service, alias in self.links] def get_volumes_from_names(self): - return [s.name for s in self.volumes_from if isinstance(s, Service)] + 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 @@ -559,16 +561,9 @@ def _get_links(self, link_to_self): def _get_volumes_from(self): volumes_from = [] - for volume_source in self.volumes_from: - if isinstance(volume_source, Service): - containers = volume_source.containers(stopped=True) - if not containers: - volumes_from.append(volume_source.create_container().id) - else: - volumes_from.extend(map(attrgetter('id'), containers)) - - elif isinstance(volume_source, Container): - volumes_from.append(volume_source.id) + for volume_from_spec in self.volumes_from: + volumes = build_volume_from(volume_from_spec) + volumes_from.extend(volumes) return volumes_from @@ -988,6 +983,39 @@ def parse_volume_spec(volume_config): return VolumeSpec(external, internal, mode) + +def build_volume_from(volume_from_spec): + volumes_from = [] + if isinstance(volume_from_spec.source, Service): + containers = volume_from_spec.source.containers(stopped=True) + if not containers: + volumes_from = ["{}:{}".format(volume_from_spec.source.create_container().id, volume_from_spec.mode)] + else: + volumes_from = ["{}:{}".format(container.id, volume_from_spec.mode) for container in containers] + elif isinstance(volume_from_spec.source, Container): + volumes_from = ["{}:{}".format(volume_from_spec.source.id, volume_from_spec.mode)] + return volumes_from + + +def parse_volume_from_spec(volume_from_config): + parts = volume_from_config.split(':') + if len(parts) > 2: + raise ConfigError("Volume %s has incorrect format, should be " + "external:internal[:mode]" % volume_from_config) + + if len(parts) == 1: + source = parts[0] + mode = 'rw' + else: + source, mode = parts + + if mode not in ('rw', 'ro'): + raise ConfigError("VolumeFrom %s has invalid mode (%s), should be " + "one of: rw, ro." % (volume_from_config, mode)) + + return VolumeFromSpec(source, mode) + + # Labels diff --git a/docs/yml.md b/docs/yml.md index 81357df3d66..12c9b554ac8 100644 --- a/docs/yml.md +++ b/docs/yml.md @@ -346,11 +346,13 @@ should always begin with `.` or `..`. ### volumes_from -Mount all of the volumes from another service or container. +Mount all of the volumes from another service or container, with the +supported flags by docker : ``ro``, ``rw``. volumes_from: - service_name - container_name + - service_name:rw ### cpu\_shares, cpuset, domainname, entrypoint, hostname, ipc, mac\_address, mem\_limit, memswap\_limit, privileged, read\_only, restart, stdin\_open, tty, user, volume\_driver, working\_dir diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index bd7ecccbe8a..ff50c80b2ab 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -6,6 +6,7 @@ from compose.container import Container from compose.project import Project from compose.service import ConvergenceStrategy +from compose.service import VolumeFromSpec def build_service_dicts(service_config): @@ -72,7 +73,7 @@ def test_volumes_from_service(self): ) db = project.get_service('db') data = project.get_service('data') - self.assertEqual(db.volumes_from, [data]) + self.assertEqual(db.volumes_from, [VolumeFromSpec(data, 'rw')]) def test_volumes_from_container(self): data_container = Container.create( @@ -93,7 +94,7 @@ def test_volumes_from_container(self): client=self.client, ) db = project.get_service('db') - self.assertEqual(db.volumes_from, [data_container]) + self.assertEqual(db._get_volumes_from(), [data_container.id + ':rw']) def test_net_from_service(self): project = Project.from_dicts( diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 2c9c6fc204e..306060960a2 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -25,6 +25,7 @@ from compose.service import ConvergencePlan from compose.service import Net from compose.service import Service +from compose.service import VolumeFromSpec def create_and_start_container(service, **override_options): @@ -272,12 +273,12 @@ def test_create_container_with_volumes_from(self): command=["top"], labels={LABEL_PROJECT: 'composetest'}, ) - host_service = self.create_service('host', volumes_from=[volume_service, volume_container_2]) + host_service = self.create_service('host', volumes_from=[VolumeFromSpec(volume_service, 'rw'), VolumeFromSpec(volume_container_2, 'rw')]) host_container = host_service.create_container() host_service.start_container(host_container) - self.assertIn(volume_container_1.id, + self.assertIn(volume_container_1.id + ':rw', host_container.get('HostConfig.VolumesFrom')) - self.assertIn(volume_container_2.id, + self.assertIn(volume_container_2.id + ':rw', host_container.get('HostConfig.VolumesFrom')) def test_execute_convergence_plan_recreate(self): diff --git a/tests/unit/project_test.py b/tests/unit/project_test.py index ce74eb30b70..f3cf9e2941c 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -168,7 +168,7 @@ def test_use_volumes_from_container(self): 'volumes_from': ['aaa'] } ], self.mock_client) - self.assertEqual(project.get_service('test')._get_volumes_from(), [container_id]) + self.assertEqual(project.get_service('test')._get_volumes_from(), [container_id + ":rw"]) def test_use_volumes_from_service_no_container(self): container_name = 'test_vol_1' @@ -191,7 +191,7 @@ def test_use_volumes_from_service_no_container(self): 'volumes_from': ['vol'] } ], self.mock_client) - self.assertEqual(project.get_service('test')._get_volumes_from(), [container_name]) + self.assertEqual(project.get_service('test')._get_volumes_from(), [container_name + ":rw"]) @mock.patch.object(Service, 'containers') def test_use_volumes_from_service_container(self, mock_return): @@ -211,7 +211,7 @@ def test_use_volumes_from_service_container(self, mock_return): 'volumes_from': ['vol'] } ], None) - self.assertEqual(project.get_service('test')._get_volumes_from(), container_ids) + self.assertEqual(project.get_service('test')._get_volumes_from(), [cid + ':rw' for cid in container_ids]) def test_net_unset(self): project = Project.from_dicts('test', [ diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index c682b823773..f85d34d2aca 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -24,6 +24,7 @@ from compose.service import parse_volume_spec from compose.service import Service from compose.service import ServiceNet +from compose.service import VolumeFromSpec class ServiceTest(unittest.TestCase): @@ -75,9 +76,18 @@ def test_get_volumes_from_container(self): service = Service( 'test', image='foo', - volumes_from=[mock.Mock(id=container_id, spec=Container)]) + volumes_from=[VolumeFromSpec(mock.Mock(id=container_id, spec=Container), 'rw')]) - self.assertEqual(service._get_volumes_from(), [container_id]) + self.assertEqual(service._get_volumes_from(), [container_id + ':rw']) + + def test_get_volumes_from_container_read_only(self): + container_id = 'aabbccddee' + service = Service( + 'test', + image='foo', + volumes_from=[VolumeFromSpec(mock.Mock(id=container_id, spec=Container), 'ro')]) + + self.assertEqual(service._get_volumes_from(), [container_id + ':ro']) def test_get_volumes_from_service_container_exists(self): container_ids = ['aabbccddee', '12345'] @@ -86,9 +96,21 @@ def test_get_volumes_from_service_container_exists(self): mock.Mock(id=container_id, spec=Container) for container_id in container_ids ] - service = Service('test', volumes_from=[from_service], image='foo') + service = Service('test', volumes_from=[VolumeFromSpec(from_service, 'rw')], image='foo') + + self.assertEqual(service._get_volumes_from(), [cid + ":rw" for cid in container_ids]) + + def test_get_volumes_from_service_container_exists_with_flags(self): + for mode in ['ro', 'rw', 'z', 'rw,z', 'z,rw']: + container_ids = ['aabbccddee:' + mode, '12345:' + mode] + from_service = mock.create_autospec(Service) + from_service.containers.return_value = [ + mock.Mock(id=container_id.split(':')[0], spec=Container) + for container_id in container_ids + ] + service = Service('test', volumes_from=[VolumeFromSpec(from_service, mode)], image='foo') - self.assertEqual(service._get_volumes_from(), container_ids) + self.assertEqual(service._get_volumes_from(), container_ids) def test_get_volumes_from_service_no_container(self): container_id = 'abababab' @@ -97,9 +119,9 @@ def test_get_volumes_from_service_no_container(self): from_service.create_container.return_value = mock.Mock( id=container_id, spec=Container) - service = Service('test', image='foo', volumes_from=[from_service]) + service = Service('test', image='foo', volumes_from=[VolumeFromSpec(from_service, 'rw')]) - self.assertEqual(service._get_volumes_from(), [container_id]) + self.assertEqual(service._get_volumes_from(), [container_id + ':rw']) from_service.create_container.assert_called_once_with() def test_split_domainname_none(self): From fe65c0258d2f3412a18c07e0f701be6b292c2286 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 2 Oct 2015 19:26:45 -0400 Subject: [PATCH 1286/4072] Remove unused attach_socket function from Container. Signed-off-by: Daniel Nephin --- compose/container.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/compose/container.py b/compose/container.py index 28af093d769..a03acf56fd1 100644 --- a/compose/container.py +++ b/compose/container.py @@ -212,9 +212,6 @@ def links(self): def attach(self, *args, **kwargs): return self.client.attach(self.id, *args, **kwargs) - def attach_socket(self, **kwargs): - return self.client.attach_socket(self.id, **kwargs) - def __repr__(self): return '' % (self.name, self.id[:6]) From 3661e8bc7419ae34e4639edec91df2e1db707312 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 2 Oct 2015 19:47:27 -0400 Subject: [PATCH 1287/4072] Fix build against the swarm cluster by joining buffered output before parsing json. Signed-off-by: Daniel Nephin --- compose/cli/log_printer.py | 4 ++-- compose/cli/utils.py | 26 ---------------------- compose/progress_stream.py | 6 +----- compose/service.py | 4 +++- compose/utils.py | 38 +++++++++++++++++++++++++++++++++ tests/integration/testcases.py | 6 ++++-- tests/unit/split_buffer_test.py | 2 +- 7 files changed, 49 insertions(+), 37 deletions(-) diff --git a/compose/cli/log_printer.py b/compose/cli/log_printer.py index 845f799b796..6e1499e1d53 100644 --- a/compose/cli/log_printer.py +++ b/compose/cli/log_printer.py @@ -6,8 +6,8 @@ from . import colors from .multiplexer import Multiplexer -from .utils import split_buffer from compose import utils +from compose.utils import split_buffer class LogPrinter(object): @@ -75,7 +75,7 @@ def build_no_log_generator(container, prefix, color_func): def build_log_generator(container, prefix, color_func): # Attach to container before log printer starts running stream = container.attach(stdout=True, stderr=True, stream=True, logs=True) - line_generator = split_buffer(stream, u'\n') + line_generator = split_buffer(stream) for line in line_generator: yield prefix + line diff --git a/compose/cli/utils.py b/compose/cli/utils.py index 5840f0a8cef..07510e2f31c 100644 --- a/compose/cli/utils.py +++ b/compose/cli/utils.py @@ -7,7 +7,6 @@ import ssl import subprocess -import six from docker import version as docker_py_version from six.moves import input @@ -36,31 +35,6 @@ def yesno(prompt, default=None): return None -def split_buffer(reader, separator): - """ - Given a generator which yields strings and a separator string, - joins all input, splits on the separator and yields each chunk. - - Unlike string.split(), each chunk includes the trailing - separator, except for the last one if none was found on the end - of the input. - """ - buffered = six.text_type('') - separator = six.text_type(separator) - - for data in reader: - buffered += data.decode('utf-8') - while True: - index = buffered.find(separator) - if index == -1: - break - yield buffered[:index + 1] - buffered = buffered[index + 1:] - - if len(buffered) > 0: - yield buffered - - def call_silently(*args, **kwargs): """ Like subprocess.call(), but redirects stdout and stderr to /dev/null. diff --git a/compose/progress_stream.py b/compose/progress_stream.py index c44b33e5614..ca8f3513503 100644 --- a/compose/progress_stream.py +++ b/compose/progress_stream.py @@ -1,7 +1,5 @@ import json -import six - from compose import utils @@ -16,9 +14,7 @@ def stream_output(output, stream): lines = {} diff = 0 - for chunk in output: - if six.PY3: - chunk = chunk.decode('utf-8') + for chunk in utils.stream_as_text(output): event = json.loads(chunk) all_events.append(event) diff --git a/compose/service.py b/compose/service.py index c9ca00ae414..bce2e534c9b 100644 --- a/compose/service.py +++ b/compose/service.py @@ -33,6 +33,8 @@ from .progress_stream import StreamOutputError from .utils import json_hash from .utils import parallel_execute +from .utils import split_buffer + log = logging.getLogger(__name__) @@ -722,7 +724,7 @@ def build(self, no_cache=False, pull=False): ) try: - all_events = stream_output(build_output, sys.stdout) + all_events = stream_output(split_buffer(build_output), sys.stdout) except StreamOutputError as e: raise BuildError(self, six.text_type(e)) diff --git a/compose/utils.py b/compose/utils.py index e0304ba5062..f201e2d6cfc 100644 --- a/compose/utils.py +++ b/compose/utils.py @@ -83,6 +83,44 @@ def get_output_stream(stream): return codecs.getwriter('utf-8')(stream) +def stream_as_text(stream): + """Given a stream of bytes or text, if any of the items in the stream + are bytes convert them to text. + + This function can be removed once docker-py returns text streams instead + of byte streams. + """ + for data in stream: + if not isinstance(data, six.text_type): + data = data.decode('utf-8') + yield data + + +def split_buffer(reader, separator=u'\n'): + """ + Given a generator which yields strings and a separator string, + joins all input, splits on the separator and yields each chunk. + + Unlike string.split(), each chunk includes the trailing + separator, except for the last one if none was found on the end + of the input. + """ + buffered = six.text_type('') + separator = six.text_type(separator) + + for data in stream_as_text(reader): + buffered += data + while True: + index = buffered.find(separator) + if index == -1: + break + yield buffered[:index + 1] + buffered = buffered[index + 1:] + + if len(buffered) > 0: + yield buffered + + def write_out_msg(stream, lines, msg_index, msg, status="done"): """ Using special ANSI code characters we can write out the msg over the top of diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index 26a0a108a1c..7dec3728b8b 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -9,6 +9,8 @@ from compose.const import LABEL_PROJECT from compose.progress_stream import stream_output from compose.service import Service +from compose.utils import split_buffer +from compose.utils import stream_as_text def pull_busybox(client): @@ -71,5 +73,5 @@ def create_service(self, name, **kwargs): def check_build(self, *args, **kwargs): kwargs.setdefault('rm', True) - build_output = self.client.build(*args, **kwargs) - stream_output(build_output, open('/dev/null', 'w')) + build_output = stream_as_text(self.client.build(*args, **kwargs)) + stream_output(split_buffer(build_output), open('/dev/null', 'w')) diff --git a/tests/unit/split_buffer_test.py b/tests/unit/split_buffer_test.py index 47c72f0865c..1775e4cb159 100644 --- a/tests/unit/split_buffer_test.py +++ b/tests/unit/split_buffer_test.py @@ -2,7 +2,7 @@ from __future__ import unicode_literals from .. import unittest -from compose.cli.utils import split_buffer +from compose.utils import split_buffer class SplitBufferTest(unittest.TestCase): From 15d0c60a73bf700400de826bd122f3f1c30bd0c0 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 5 Oct 2015 12:56:10 -0400 Subject: [PATCH 1288/4072] Fix split buffer with inconsistently delimited json objects. Signed-off-by: Daniel Nephin --- compose/progress_stream.py | 5 +--- compose/service.py | 3 +- compose/utils.py | 52 ++++++++++++++++++++++++++------- tests/integration/testcases.py | 6 ++-- tests/unit/split_buffer_test.py | 2 +- tests/unit/utils_test.py | 16 ++++++++++ 6 files changed, 62 insertions(+), 22 deletions(-) create mode 100644 tests/unit/utils_test.py diff --git a/compose/progress_stream.py b/compose/progress_stream.py index ca8f3513503..ac8e4b410ff 100644 --- a/compose/progress_stream.py +++ b/compose/progress_stream.py @@ -1,5 +1,3 @@ -import json - from compose import utils @@ -14,8 +12,7 @@ def stream_output(output, stream): lines = {} diff = 0 - for chunk in utils.stream_as_text(output): - event = json.loads(chunk) + for event in utils.json_stream(output): all_events.append(event) if 'progress' in event or 'progressDetail' in event: diff --git a/compose/service.py b/compose/service.py index bce2e534c9b..698ab4844f4 100644 --- a/compose/service.py +++ b/compose/service.py @@ -33,7 +33,6 @@ from .progress_stream import StreamOutputError from .utils import json_hash from .utils import parallel_execute -from .utils import split_buffer log = logging.getLogger(__name__) @@ -724,7 +723,7 @@ def build(self, no_cache=False, pull=False): ) try: - all_events = stream_output(split_buffer(build_output), sys.stdout) + all_events = stream_output(build_output, sys.stdout) except StreamOutputError as e: raise BuildError(self, six.text_type(e)) diff --git a/compose/utils.py b/compose/utils.py index f201e2d6cfc..c8fddc5f162 100644 --- a/compose/utils.py +++ b/compose/utils.py @@ -1,6 +1,7 @@ import codecs import hashlib import json +import json.decoder import logging import sys from threading import Thread @@ -13,6 +14,8 @@ log = logging.getLogger(__name__) +json_decoder = json.JSONDecoder() + def parallel_execute(objects, obj_callable, msg_index, msg): """ @@ -96,29 +99,56 @@ def stream_as_text(stream): yield data -def split_buffer(reader, separator=u'\n'): - """ - Given a generator which yields strings and a separator string, +def line_splitter(buffer, separator=u'\n'): + index = buffer.find(six.text_type(separator)) + if index == -1: + return None, None + return buffer[:index + 1], buffer[index + 1:] + + +def split_buffer(stream, splitter=None, decoder=lambda a: a): + """Given a generator which yields strings and a splitter function, joins all input, splits on the separator and yields each chunk. Unlike string.split(), each chunk includes the trailing separator, except for the last one if none was found on the end of the input. """ + splitter = splitter or line_splitter buffered = six.text_type('') - separator = six.text_type(separator) - for data in stream_as_text(reader): + for data in stream_as_text(stream): buffered += data while True: - index = buffered.find(separator) - if index == -1: + item, rest = splitter(buffered) + if not item: break - yield buffered[:index + 1] - buffered = buffered[index + 1:] - if len(buffered) > 0: - yield buffered + buffered = rest + yield item + + if buffered: + yield decoder(buffered) + + +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. + """ + try: + obj, index = json_decoder.raw_decode(buffer) + rest = buffer[json.decoder.WHITESPACE.match(buffer, index).end():] + return obj, rest + except ValueError: + return None, None + + +def json_stream(stream): + """Given a stream of text, return a stream of json objects. + This handles streams which are inconsistently buffered (some entries may + be newline delimited, and others are not). + """ + return split_buffer(stream_as_text(stream), json_splitter, json_decoder.decode) def write_out_msg(stream, lines, msg_index, msg, status="done"): diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index 7dec3728b8b..26a0a108a1c 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -9,8 +9,6 @@ from compose.const import LABEL_PROJECT from compose.progress_stream import stream_output from compose.service import Service -from compose.utils import split_buffer -from compose.utils import stream_as_text def pull_busybox(client): @@ -73,5 +71,5 @@ def create_service(self, name, **kwargs): def check_build(self, *args, **kwargs): kwargs.setdefault('rm', True) - build_output = stream_as_text(self.client.build(*args, **kwargs)) - stream_output(split_buffer(build_output), open('/dev/null', 'w')) + build_output = self.client.build(*args, **kwargs) + stream_output(build_output, open('/dev/null', 'w')) diff --git a/tests/unit/split_buffer_test.py b/tests/unit/split_buffer_test.py index 1775e4cb159..c41ea27d40f 100644 --- a/tests/unit/split_buffer_test.py +++ b/tests/unit/split_buffer_test.py @@ -47,7 +47,7 @@ def reader(): self.assert_produces(reader, [string]) def assert_produces(self, reader, expectations): - split = split_buffer(reader(), u'\n') + split = split_buffer(reader()) for (actual, expected) in zip(split, expectations): self.assertEqual(type(actual), type(expected)) diff --git a/tests/unit/utils_test.py b/tests/unit/utils_test.py new file mode 100644 index 00000000000..b272c7349a8 --- /dev/null +++ b/tests/unit/utils_test.py @@ -0,0 +1,16 @@ +from .. import unittest +from compose import utils + + +class JsonSplitterTestCase(unittest.TestCase): + + def test_json_splitter_no_object(self): + data = '{"foo": "bar' + self.assertEqual(utils.json_splitter(data), (None, None)) + + def test_json_splitter_with_object(self): + data = '{"foo": "bar"}\n \n{"next": "obj"}' + self.assertEqual( + utils.json_splitter(data), + ({'foo': 'bar'}, '{"next": "obj"}') + ) From 6edb6fa262396409839bf1b30c7f7a28651e0125 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 16 Sep 2015 18:58:34 -0400 Subject: [PATCH 1289/4072] Test against a list of versions generated from docker/docker tags. Signed-off-by: Daniel Nephin --- script/test-versions | 10 ++-- script/versions.py | 139 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+), 4 deletions(-) create mode 100755 script/versions.py diff --git a/script/test-versions b/script/test-versions index bebc5567272..89793359be5 100755 --- a/script/test-versions +++ b/script/test-versions @@ -10,13 +10,15 @@ docker run --rm \ --entrypoint="tox" \ "$TAG" -e pre-commit -ALL_DOCKER_VERSIONS="1.7.1 1.8.2" -DEFAULT_DOCKER_VERSION="1.8.2" +get_versions="docker run --rm + --entrypoint=/code/.tox/py27/bin/python + $TAG + /code/script/versions.py docker/docker" if [ "$DOCKER_VERSIONS" == "" ]; then - DOCKER_VERSIONS="$DEFAULT_DOCKER_VERSION" + DOCKER_VERSIONS="$($get_versions default)" elif [ "$DOCKER_VERSIONS" == "all" ]; then - DOCKER_VERSIONS="$ALL_DOCKER_VERSIONS" + DOCKER_VERSIONS="$($get_versions recent -n 2)" fi diff --git a/script/versions.py b/script/versions.py new file mode 100755 index 00000000000..513ca754c02 --- /dev/null +++ b/script/versions.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python +""" +Query the github API for the git tags of a project, and return a list of +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 version in the series. + +For example, if the list of versions is: + + 1.8.0-rc2 + 1.8.0-rc1 + 1.7.1 + 1.7.0 + 1.7.0-rc1 + 1.6.2 + 1.6.1 + +`default` would return `1.7.1` and +`recent -n 3` would return `1.8.0-rc2 1.7.1 1.6.2` +""" +from __future__ import print_function + +import argparse +import itertools +import operator +from collections import namedtuple + +import requests + + +GITHUB_API = 'https://api.github.com/repos' + + +class Version(namedtuple('_Version', 'major minor patch rc')): + + @classmethod + def parse(cls, version): + version = version.lstrip('v') + version, _, rc = version.partition('-') + major, minor, patch = version.split('.', 3) + return cls(int(major), int(minor), int(patch), rc) + + @property + def major_minor(self): + return self.major, self.minor + + @property + def order(self): + """Return a representation that allows this object to be sorted + correctly with the default comparator. + """ + # rc releases should appear before official releases + rc = (0, self.rc) if self.rc else (1, ) + return (self.major, self.minor, self.patch) + rc + + def __str__(self): + rc = '-{}'.format(self.rc) if self.rc else '' + return '.'.join(map(str, self[:3])) + rc + + +def group_versions(versions): + """Group versions by `major.minor` releases. + + Example: + + >>> group_versions([ + Version(1, 0, 0), + Version(2, 0, 0, 'rc1'), + Version(2, 0, 0), + Version(2, 1, 0), + ]) + + [ + [Version(1, 0, 0)], + [Version(2, 0, 0), Version(2, 0, 0, 'rc1')], + [Version(2, 1, 0)], + ] + """ + return list( + list(releases) + for _, releases + in itertools.groupby(versions, operator.attrgetter('major_minor')) + ) + + +def get_latest_versions(versions, num=1): + """Return a list of the most recent versions for each major.minor version + group. + """ + versions = group_versions(versions) + return [versions[index][0] for index in range(num)] + + +def get_default(versions): + """Return a :class:`Version` for the latest non-rc version.""" + for version in versions: + if not version.rc: + return version + + +def get_github_releases(project): + """Query the Github API for a list of version tags and return them in + sorted order. + + See https://developer.github.com/v3/repos/#list-tags + """ + url = '{}/{}/tags'.format(GITHUB_API, project) + response = requests.get(url) + response.raise_for_status() + versions = [Version.parse(tag['name']) for tag in response.json()] + return sorted(versions, reverse=True, key=operator.attrgetter('order')) + + +def parse_args(argv): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('project', help="Github project name (ex: docker/docker)") + parser.add_argument('command', choices=['recent', 'default']) + parser.add_argument('-n', '--num', type=int, default=2, + help="Number of versions to return from `recent`") + return parser.parse_args(argv) + + +def main(argv=None): + args = parse_args(argv) + versions = get_github_releases(args.project) + + if args.command == 'recent': + print(' '.join(map(str, get_latest_versions(versions, args.num)))) + elif args.command == 'default': + print(get_default(versions)) + else: + raise ValueError("Unknown command {}".format(args.command)) + + +if __name__ == "__main__": + main() From 97dc4895ac76c3517902a290f392e981526aa07c Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 6 Oct 2015 11:37:36 -0400 Subject: [PATCH 1290/4072] Remove unnecessary router.php from wordpress example. Signed-off-by: Daniel Nephin --- docs/wordpress.md | 21 +-------------------- 1 file changed, 1 insertion(+), 20 deletions(-) diff --git a/docs/wordpress.md b/docs/wordpress.md index 8de5a26441d..621459382be 100644 --- a/docs/wordpress.md +++ b/docs/wordpress.md @@ -55,7 +55,7 @@ and a separate MySQL instance: environment: MYSQL_DATABASE: wordpress -Two supporting files are needed to get this working - first, `wp-config.php` is +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: @@ -85,25 +85,6 @@ configuration at the `db` container: require_once(ABSPATH . 'wp-settings.php'); -Second, `router.php` tells PHP's built-in web server how to run WordPress: - - Date: Tue, 6 Oct 2015 15:18:58 -0400 Subject: [PATCH 1291/4072] Update release scripts for release image. Signed-off-by: Daniel Nephin --- Dockerfile.run | 6 ++---- docs/install.md | 2 +- project/RELEASE-PROCESS.md | 2 +- script/build-image | 16 ++++++++++++++++ script/release/build-binaries | 16 ++++++++++++++++ script/release/push-release | 3 +++ script/{run => run.sh} | 0 7 files changed, 39 insertions(+), 6 deletions(-) create mode 100755 script/build-image rename script/{run => run.sh} (100%) diff --git a/Dockerfile.run b/Dockerfile.run index 3c12fa18233..9f3745fefcc 100644 --- a/Dockerfile.run +++ b/Dockerfile.run @@ -7,9 +7,7 @@ RUN apk -U add \ COPY requirements.txt /code/requirements.txt RUN pip install -r /code/requirements.txt -ENV VERSION 1.4.0dev - -COPY dist/docker-compose-$VERSION.tar.gz /code/docker-compose/ -RUN pip install /code/docker-compose/docker-compose-$VERSION/ +ADD dist/docker-compose-release.tar.gz /code/docker-compose +RUN pip install /code/docker-compose/docker-compose-* ENTRYPOINT ["/usr/bin/docker-compose"] diff --git a/docs/install.md b/docs/install.md index fd7b3cabf97..be6a6b26af4 100644 --- a/docs/install.md +++ b/docs/install.md @@ -68,7 +68,7 @@ To install Compose, do the following: 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.0/compose-run > /usr/local/bin/docker-compose + $ curl -L https://github.com/docker/compose/releases/download/1.5.0/run.sh > /usr/local/bin/docker-compose $ chmod +x /usr/local/bin/docker-compose diff --git a/project/RELEASE-PROCESS.md b/project/RELEASE-PROCESS.md index 30a9805af26..85bbaf29500 100644 --- a/project/RELEASE-PROCESS.md +++ b/project/RELEASE-PROCESS.md @@ -80,7 +80,7 @@ When prompted build the non-linux binaries and test them. ...release notes go here... -5. Attach the binaries. +5. Attach the binaries and `script/run.sh` 6. If everything looks good, it's time to push the release. diff --git a/script/build-image b/script/build-image new file mode 100755 index 00000000000..d9faddc7bbf --- /dev/null +++ b/script/build-image @@ -0,0 +1,16 @@ +#!/bin/bash + +set -e + +if [ -z "$1" ]; then + >&2 echo "First argument must be image tag." + exit 1 +fi + +TAG=$1 +VERSION="$(python setup.py --version)" + +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/release/build-binaries b/script/release/build-binaries index 9f65b45d27f..083f8eb589c 100755 --- a/script/release/build-binaries +++ b/script/release/build-binaries @@ -5,6 +5,19 @@ . "$(dirname "${BASH_SOURCE[0]}")/utils.sh" +function usage() { + >&2 cat << EOM +Build binaries for the release. + +This script requires that 'git config branch.${BRANCH}.release' is set to the +release version for the release branch. + +EOM + exit 1 +} + +BRANCH="$(git rev-parse --abbrev-ref HEAD)" +VERSION="$(git config "branch.${BRANCH}.release")" || usage REPO=docker/compose # Build the binaries @@ -16,6 +29,9 @@ script/build-linux # 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 + echo "Create a github release" # TODO: script more of this https://developer.github.com/v3/repos/releases/ browser https://github.com/$REPO/releases/new diff --git a/script/release/push-release b/script/release/push-release index 7c44866671e..039436da0e2 100755 --- a/script/release/push-release +++ b/script/release/push-release @@ -46,6 +46,9 @@ git push $GITHUB_REPO $VERSION echo "Uploading sdist to pypi" python setup.py sdist +echo "Uploading the docker image" +docker push docker/compose:$VERSION + if [ "$(command -v twine 2> /dev/null)" ]; then twine upload ./dist/docker-compose-${VERSION}.tar.gz else diff --git a/script/run b/script/run.sh similarity index 100% rename from script/run rename to script/run.sh From 467c73186996465a7bb1e5873ab829d2d1c90f42 Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Fri, 25 Sep 2015 17:18:46 +0100 Subject: [PATCH 1292/4072] address PR feedback Signed-off-by: Mazz Mosley --- compose/project.py | 7 +++++-- compose/service.py | 5 +---- tests/integration/service_test.py | 8 +++++++- tests/unit/service_test.py | 2 +- 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/compose/project.py b/compose/project.py index 919a201f1f0..999c2890411 100644 --- a/compose/project.py +++ b/compose/project.py @@ -37,7 +37,10 @@ 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.split(':')[0] for volume_from in volumes_from] + return [ + parse_volume_from_spec(volume_from).source + for volume_from in volumes_from + ] def get_service_dependents(service_dict, services): name = service_dict['name'] @@ -195,7 +198,7 @@ def get_volumes_from(self, service_dict): raise ConfigurationError( 'Service "%s" mounts volumes from "%s", which is ' 'not the name of a service or container.' % ( - volume_from_config, + service_dict['name'], volume_from_spec.source)) volumes_from.append(volume_from_spec) del service_dict['volumes_from'] diff --git a/compose/service.py b/compose/service.py index 79a138aac7c..f2f82f6cece 100644 --- a/compose/service.py +++ b/compose/service.py @@ -6,6 +6,7 @@ import re import sys from collections import namedtuple +from operator import attrgetter import enum import six @@ -1009,10 +1010,6 @@ def parse_volume_from_spec(volume_from_config): else: source, mode = parts - if mode not in ('rw', 'ro'): - raise ConfigError("VolumeFrom %s has invalid mode (%s), should be " - "one of: rw, ro." % (volume_from_config, mode)) - return VolumeFromSpec(source, mode) diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 306060960a2..64ce2c6582d 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -273,7 +273,13 @@ def test_create_container_with_volumes_from(self): command=["top"], labels={LABEL_PROJECT: 'composetest'}, ) - host_service = self.create_service('host', volumes_from=[VolumeFromSpec(volume_service, 'rw'), VolumeFromSpec(volume_container_2, 'rw')]) + host_service = self.create_service( + 'host', + volumes_from=[ + VolumeFromSpec(volume_service, 'rw'), + VolumeFromSpec(volume_container_2, 'rw') + ] + ) host_container = host_service.create_container() host_service.start_container(host_container) self.assertIn(volume_container_1.id + ':rw', diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index f85d34d2aca..48e31b11b4b 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -379,7 +379,7 @@ def test_config_dict(self): client=self.mock_client, net=ServiceNet(Service('other')), links=[(Service('one'), 'one')], - volumes_from=[Service('two')]) + volumes_from=[VolumeFromSpec(Service('two'), 'rw')]) config_dict = service.config_dict() expected = { From 0ff84a78c64b241ffc9cb037db0b17044bb9941d Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Tue, 6 Oct 2015 13:10:38 +0100 Subject: [PATCH 1293/4072] Use multiple returns rather than overriding. Also added a doc string for clarity. Signed-off-by: Mazz Mosley --- compose/service.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/compose/service.py b/compose/service.py index f2f82f6cece..a24138540ae 100644 --- a/compose/service.py +++ b/compose/service.py @@ -986,16 +986,18 @@ def parse_volume_spec(volume_config): def build_volume_from(volume_from_spec): - volumes_from = [] + """ + volume_from can be either a service or a container. We want to return the + container.id and format it into a string complete with the mode. + """ if isinstance(volume_from_spec.source, Service): containers = volume_from_spec.source.containers(stopped=True) if not containers: - volumes_from = ["{}:{}".format(volume_from_spec.source.create_container().id, volume_from_spec.mode)] + return ["{}:{}".format(volume_from_spec.source.create_container().id, volume_from_spec.mode)] else: - volumes_from = ["{}:{}".format(container.id, volume_from_spec.mode) for container in containers] + return ["{}:{}".format(container.id, volume_from_spec.mode) for container in containers] elif isinstance(volume_from_spec.source, Container): - volumes_from = ["{}:{}".format(volume_from_spec.source.id, volume_from_spec.mode)] - return volumes_from + return ["{}:{}".format(volume_from_spec.source.id, volume_from_spec.mode)] def parse_volume_from_spec(volume_from_config): From f9028703f4a527dc05302999f8d21c18f84b7055 Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Tue, 6 Oct 2015 13:11:49 +0100 Subject: [PATCH 1294/4072] Pick the first container Rather than inefficiently looping through all the containers that a service has and overriding each volumes_from value, pick the first one and return that. Signed-off-by: Mazz Mosley --- compose/service.py | 5 +++-- tests/unit/project_test.py | 2 +- tests/unit/service_test.py | 4 ++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/compose/service.py b/compose/service.py index a24138540ae..0dbd7f8d1d1 100644 --- a/compose/service.py +++ b/compose/service.py @@ -994,8 +994,9 @@ def build_volume_from(volume_from_spec): containers = volume_from_spec.source.containers(stopped=True) if not containers: return ["{}:{}".format(volume_from_spec.source.create_container().id, volume_from_spec.mode)] - else: - return ["{}:{}".format(container.id, volume_from_spec.mode) for container in containers] + + container = containers[0] + return ["{}:{}".format(container.id, volume_from_spec.mode)] elif isinstance(volume_from_spec.source, Container): return ["{}:{}".format(volume_from_spec.source.id, volume_from_spec.mode)] diff --git a/tests/unit/project_test.py b/tests/unit/project_test.py index f3cf9e2941c..fc189fbb15c 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -211,7 +211,7 @@ def test_use_volumes_from_service_container(self, mock_return): 'volumes_from': ['vol'] } ], None) - self.assertEqual(project.get_service('test')._get_volumes_from(), [cid + ':rw' for cid in container_ids]) + self.assertEqual(project.get_service('test')._get_volumes_from(), [container_ids[0] + ':rw']) def test_net_unset(self): project = Project.from_dicts('test', [ diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 48e31b11b4b..19d25e2ed54 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -98,7 +98,7 @@ def test_get_volumes_from_service_container_exists(self): ] service = Service('test', volumes_from=[VolumeFromSpec(from_service, 'rw')], image='foo') - self.assertEqual(service._get_volumes_from(), [cid + ":rw" for cid in container_ids]) + self.assertEqual(service._get_volumes_from(), [container_ids[0] + ":rw"]) def test_get_volumes_from_service_container_exists_with_flags(self): for mode in ['ro', 'rw', 'z', 'rw,z', 'z,rw']: @@ -110,7 +110,7 @@ def test_get_volumes_from_service_container_exists_with_flags(self): ] service = Service('test', volumes_from=[VolumeFromSpec(from_service, mode)], image='foo') - self.assertEqual(service._get_volumes_from(), container_ids) + self.assertEqual(service._get_volumes_from(), [container_ids[0]]) def test_get_volumes_from_service_no_container(self): container_id = 'abababab' From 21a1affc6395a524273d5788cac5e0b7c92a50ce Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Tue, 6 Oct 2015 15:43:26 +0100 Subject: [PATCH 1295/4072] Re-word docs. Signed-off-by: Mazz Mosley --- docs/yml.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/yml.md b/docs/yml.md index 12c9b554ac8..a476fd33fa1 100644 --- a/docs/yml.md +++ b/docs/yml.md @@ -346,8 +346,8 @@ should always begin with `.` or `..`. ### volumes_from -Mount all of the volumes from another service or container, with the -supported flags by docker : ``ro``, ``rw``. +Mount all of the volumes from another service or container, optionally +specifying read-only access(``ro``) or read-write(``rw``). volumes_from: - service_name From 8efc39e616f3cc6f782b83abefe39f778fdf7731 Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Wed, 7 Oct 2015 14:59:08 +0100 Subject: [PATCH 1296/4072] Improve boolean warning message. Including examples of more boolean types, eg yes/N as it's not always immediately clear that they are treated as booleans. Signed-off-by: Mazz Mosley --- compose/config/validation.py | 4 ++-- tests/unit/config/config_test.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/compose/config/validation.py b/compose/config/validation.py index 959465e9872..0fef304a2a1 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -57,9 +57,9 @@ def format_boolean_in_environment(instance): """ if isinstance(instance, bool): log.warn( - "Warning: There is a boolean value, {0} in the 'environment' key.\n" + "Warning: There is a boolean value in the 'environment' key.\n" "Environment variables can only be strings.\nPlease add quotes to any boolean values to make them string " - "(eg, '{0}').\nThis warning will become an error in a future release. \r\n".format(instance) + "(eg, 'True', 'yes', 'N').\nThis 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 b505740f571..d3fb4d5f17b 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -319,7 +319,7 @@ def test_valid_config_oneof_string_or_list(self): @mock.patch('compose.config.validation.log') def test_logs_warning_for_boolean_in_environment(self, mock_logging): - expected_warning_msg = "Warning: There is a boolean value, True in the 'environment' key." + expected_warning_msg = "Warning: There is a boolean value in the 'environment' key." config.load( build_config_details( {'web': { From 34f5912bbcf5976043840482d13a2d777d40e752 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 7 Oct 2015 11:00:40 -0400 Subject: [PATCH 1297/4072] Update release script and run.sh image name. Signed-off-by: Daniel Nephin --- script/release/make-branch | 3 ++- script/run.sh | 5 ++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/script/release/make-branch b/script/release/make-branch index 66ed6bbf356..dde1fb65de4 100755 --- a/script/release/make-branch +++ b/script/release/make-branch @@ -61,9 +61,10 @@ git checkout -b $BRANCH $BASE_VERSION git config "branch.${BRANCH}.release" $VERSION -echo "Update versions in docs/install.md and compose/__init__.py" +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 echo "Write release notes in CHANGELOG.md" diff --git a/script/run.sh b/script/run.sh index 64718efdce9..cf46c143c38 100755 --- a/script/run.sh +++ b/script/run.sh @@ -15,9 +15,8 @@ set -e -VERSION="1.4.0dev" -# TODO: move this to an official repo -IMAGE="dnephin/docker-compose:$VERSION" +VERSION="1.5.0" +IMAGE="docker/compose:$VERSION" # Setup options for connecting to docker host From ad96e10938d98cefbbbe1a17774802f36f8b8ad8 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 6 Oct 2015 16:32:59 -0400 Subject: [PATCH 1298/4072] Add travis.yml for building binaries. Signed-off-by: Daniel Nephin --- .travis.yml | 19 +++++++++++++++++++ script/build-osx | 1 - script/prepare-osx | 2 +- script/travis/build-binary | 11 +++++++++++ script/travis/ci | 10 ++++++++++ script/travis/install | 9 +++++++++ 6 files changed, 50 insertions(+), 2 deletions(-) create mode 100644 .travis.yml create mode 100755 script/travis/build-binary create mode 100755 script/travis/ci create mode 100755 script/travis/install diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 00000000000..0f966f9da6d --- /dev/null +++ b/.travis.yml @@ -0,0 +1,19 @@ +sudo: required + +language: python + +services: + - docker + +matrix: + include: + - os: linux + - os: osx + language: generic + + +install: ./script/travis/install + +script: + - ./script/travis/ci + - ./script/travis/build-binary diff --git a/script/build-osx b/script/build-osx index 15a7bbc5418..042964e4beb 100755 --- a/script/build-osx +++ b/script/build-osx @@ -3,7 +3,6 @@ set -ex PATH="/usr/local/bin:$PATH" -./script/clean rm -rf venv virtualenv -p /usr/local/bin/python venv diff --git a/script/prepare-osx b/script/prepare-osx index ca2776b6417..10bbbecc3d7 100755 --- a/script/prepare-osx +++ b/script/prepare-osx @@ -24,7 +24,7 @@ if !(which brew); then ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)" fi -brew update +brew update > /dev/null if !(python_version | grep "$desired_python_version"); then if brew list | grep python; then diff --git a/script/travis/build-binary b/script/travis/build-binary new file mode 100755 index 00000000000..b3b7b925bec --- /dev/null +++ b/script/travis/build-binary @@ -0,0 +1,11 @@ +#!/bin/bash + +set -e + +if [[ "$TRAVIS_OS_NAME" == "linux" ]]; then + script/build-linux + # TODO: add script/build-image +else + script/prepare-osx + script/build-osx +fi diff --git a/script/travis/ci b/script/travis/ci new file mode 100755 index 00000000000..4cce1bc844e --- /dev/null +++ b/script/travis/ci @@ -0,0 +1,10 @@ +#!/bin/bash + +set -e + +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 +fi diff --git a/script/travis/install b/script/travis/install new file mode 100755 index 00000000000..a23667bffc5 --- /dev/null +++ b/script/travis/install @@ -0,0 +1,9 @@ +#!/bin/bash + +set -ex + +if [[ "$TRAVIS_OS_NAME" == "linux" ]]; then + pip install tox==2.1.1 +else + pip install --user tox==2.1.1 +fi From 9ce18849254b29111bfe08bf844e35122b0854e4 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 6 Oct 2015 20:50:40 -0400 Subject: [PATCH 1299/4072] Add upload to bintray from travis. Signed-off-by: Daniel Nephin --- .pre-commit-config.yaml | 1 + .travis.yml | 10 +++++++++ script/build-image | 1 - script/build-linux | 2 +- script/travis/bintray.json.tmpl | 29 ++++++++++++++++++++++++++ script/travis/build-binary | 6 ++++-- script/travis/render-bintray-config.py | 9 ++++++++ 7 files changed, 54 insertions(+), 4 deletions(-) create mode 100644 script/travis/bintray.json.tmpl create mode 100755 script/travis/render-bintray-config.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8913a05fd21..3fad8ddcbe8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,6 +5,7 @@ - id: check-docstring-first - id: check-merge-conflict - id: check-yaml + - id: check-json - id: debug-statements - id: end-of-file-fixer - id: flake8 diff --git a/.travis.yml b/.travis.yml index 0f966f9da6d..3310e2ad9ff 100644 --- a/.travis.yml +++ b/.travis.yml @@ -17,3 +17,13 @@ install: ./script/travis/install script: - ./script/travis/ci - ./script/travis/build-binary + +before_deploy: + - "./script/travis/render-bintray-config.py < ./script/travis/bintray.json.tmpl > ./bintray.json" + +deploy: + provider: bintray + user: docker-compose-roleuser + key: '$BINTRAY_API_KEY' + file: ./bintray.json + skip_cleanup: true diff --git a/script/build-image b/script/build-image index d9faddc7bbf..3ac9729b47a 100755 --- a/script/build-image +++ b/script/build-image @@ -13,4 +13,3 @@ VERSION="$(python setup.py --version)" 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 b/script/build-linux index 4b8696216dc..ade18bc5350 100755 --- a/script/build-linux +++ b/script/build-linux @@ -5,7 +5,7 @@ set -ex ./script/clean TAG="docker-compose" -docker build -t "$TAG" . +docker build -t "$TAG" . | tail -n 200 docker run \ --rm --entrypoint="script/build-linux-inner" \ -v $(pwd)/dist:/code/dist \ diff --git a/script/travis/bintray.json.tmpl b/script/travis/bintray.json.tmpl new file mode 100644 index 00000000000..7d0adbebcd5 --- /dev/null +++ b/script/travis/bintray.json.tmpl @@ -0,0 +1,29 @@ +{ + "package": { + "name": "${TRAVIS_OS_NAME}", + "repo": "master", + "subject": "docker-compose", + "desc": "Automated build of master branch from travis ci.", + "website_url": "https://github.com/docker/compose", + "issue_tracker_url": "https://github.com/docker/compose/issues", + "vcs_url": "https://github.com/docker/compose.git", + "licenses": ["Apache-2.0"] + }, + + "version": { + "name": "master", + "desc": "Automated build of the master branch.", + "released": "${DATE}", + "vcs_tag": "master" + }, + + "files": [ + { + "includePattern": "dist/(.*)", + "excludePattern": ".*\.tar.gz", + "uploadPattern": "$1", + "matrixParams": { "override": 1 } + } + ], + "publish": true +} diff --git a/script/travis/build-binary b/script/travis/build-binary index b3b7b925bec..0becee7f61d 100755 --- a/script/travis/build-binary +++ b/script/travis/build-binary @@ -1,10 +1,12 @@ #!/bin/bash -set -e +set -ex if [[ "$TRAVIS_OS_NAME" == "linux" ]]; then script/build-linux - # TODO: add script/build-image + script/build-image master + # TODO: requires auth + # docker push docker/compose:master else script/prepare-osx script/build-osx diff --git a/script/travis/render-bintray-config.py b/script/travis/render-bintray-config.py new file mode 100755 index 00000000000..6aa468d6dc5 --- /dev/null +++ b/script/travis/render-bintray-config.py @@ -0,0 +1,9 @@ +#!/usr/bin/env python +import datetime +import os.path +import sys + +os.environ['DATE'] = str(datetime.date.today()) + +for line in sys.stdin: + print os.path.expandvars(line), From 23fcace36c38813fbb78b9e06fc805b73d0e8f33 Mon Sep 17 00:00:00 2001 From: ronen barzel Date: Wed, 7 Oct 2015 11:14:53 -0700 Subject: [PATCH 1300/4072] Bug fix: Use app's Gemfile.lock in Dockerfile The Dockerfile should use the same Gemfile.lock as the app, to make sure the container gets the expected versions of gems installed. Aside from wanting that in principle, without it you can get mysterious gem dependency errors. Here's the scenario: 1. Suppose `Gemfile` includes `gem "some-active-gem", "~> 1.0" 2. When developing the app, you run `bundle install`, which installs the latest version--let's say, 1.0.1-and records it in `Gemfile.lock` 3. Suppose the developers of `some-active-gem` then release v1.0.2 4. Now build the container: docker runs `bundle install`, which installs v1.0.2 and records it in `Gemfile.lock` and then "ADD"s the app worktree, which replaces the `Gemfile.lock` with the one from the worktree that lists v1.0.1. 5. Immediately run your app and it fails with the error `Could not find some-active-gem-1.0.1 in any of the sources` which is a bit befuddling since you just saw it run bundle install so you expect all gem dependencies to be resolved properly. Signed-off-by: ronen barzel --- docs/rails.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/rails.md b/docs/rails.md index 0a164ca75e7..105f0f45e08 100644 --- a/docs/rails.md +++ b/docs/rails.md @@ -26,6 +26,7 @@ Dockerfile consists of: RUN mkdir /myapp WORKDIR /myapp ADD Gemfile /myapp/Gemfile + ADD Gemfile.lock /myapp/Gemfile.lock RUN bundle install ADD . /myapp From a3eb563f94edfbc7b3341e272f7931b9025496fa Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Thu, 8 Oct 2015 11:50:27 +0100 Subject: [PATCH 1301/4072] Put port ranges back in Signed-off-by: Mazz Mosley --- docs/yml.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/yml.md b/docs/yml.md index a476fd33fa1..c3d4a354a07 100644 --- a/docs/yml.md +++ b/docs/yml.md @@ -315,9 +315,12 @@ port (a random host port will be chosen). 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 From 94e6727831f8a6f1abdb49f5763af2b4cffbae3d Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 8 Oct 2015 14:46:31 -0400 Subject: [PATCH 1302/4072] Re-order docs Makefile for better caching. Signed-off-by: Daniel Nephin --- docs/Dockerfile | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/docs/Dockerfile b/docs/Dockerfile index d9add75c150..fcd64900b58 100644 --- a/docs/Dockerfile +++ b/docs/Dockerfile @@ -1,11 +1,6 @@ FROM docs/base:latest MAINTAINER Mary Anthony (@moxiegirl) -# To get the git info for this repo -COPY . /src - -COPY . /docs/content/compose/ - RUN svn checkout https://github.com/docker/docker/trunk/docs /docs/content/docker 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 @@ -13,6 +8,10 @@ RUN svn checkout https://github.com/docker/distribution/trunk/docs /docs/content 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 +# To get the git info for this repo +COPY . /src + +COPY . /docs/content/compose/ # Sed to process GitHub Markdown # 1-2 Remove comment code from metadata block From 0e9ec8aa74a57170251b0d0bc6e861218d2bbf67 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 7 Oct 2015 16:47:27 -0400 Subject: [PATCH 1303/4072] Add publish to bintray step to appveyor.yml Remove Set-PSDebug -trace to prevent the 9000+ lines of debug output from spamming the logs on appveyor. Signed-off-by: Daniel Nephin --- appveyor.yml | 12 ++++++++++-- script/build-windows.ps1 | 1 - 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/appveyor.yml b/appveyor.yml index acf8bff34af..b162db1e3a6 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -9,12 +9,20 @@ 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" - -after_test: - 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" diff --git a/script/build-windows.ps1 b/script/build-windows.ps1 index f7fd1589738..b35fad6f131 100644 --- a/script/build-windows.ps1 +++ b/script/build-windows.ps1 @@ -29,7 +29,6 @@ # .\script\build-windows.ps1 $ErrorActionPreference = "Stop" -Set-PSDebug -trace 1 # Remove virtualenv if (Test-Path venv) { From 6e838b5de17873957ede7068182b620b197d80e7 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 8 Oct 2015 10:50:15 -0400 Subject: [PATCH 1304/4072] Add link to master builds from install docs. Signed-off-by: Daniel Nephin --- docs/install.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/install.md b/docs/install.md index be6a6b26af4..4e541b8c3c6 100644 --- a/docs/install.md +++ b/docs/install.md @@ -71,6 +71,13 @@ To install compose as a container run: $ curl -L https://github.com/docker/compose/releases/download/1.5.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 From cd48a7026a2e8f97ad4d94548e2b59165a398d7a Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 8 Oct 2015 17:07:30 -0400 Subject: [PATCH 1305/4072] Cleanup doc reference links. Removed 'Compose command line completion' and 'Compose environment variables' from the list. command line completion is linked to from install docs, and environment variables are deprecated. Signed-off-by: Daniel Nephin --- docs/completion.md | 1 - docs/django.md | 2 -- docs/env.md | 4 ---- docs/extends.md | 1 - docs/index.md | 13 ------------- docs/install.md | 2 -- docs/production.md | 2 -- docs/rails.md | 2 -- docs/reference/overview.md | 5 ----- docs/wordpress.md | 2 -- docs/yml.md | 2 -- 11 files changed, 36 deletions(-) diff --git a/docs/completion.md b/docs/completion.md index bf8d15551ea..891813e9114 100644 --- a/docs/completion.md +++ b/docs/completion.md @@ -66,4 +66,3 @@ Enjoy working with Compose faster and with less typos! - [Get started with WordPress](wordpress.md) - [Command line reference](/reference) - [Yaml file reference](yml.md) -- [Compose environment variables](env.md) diff --git a/docs/django.md b/docs/django.md index e52f50301dd..b11e169358c 100644 --- a/docs/django.md +++ b/docs/django.md @@ -131,5 +131,3 @@ example, run `docker-compose up` and in another terminal run: - [Get started with WordPress](wordpress.md) - [Command line reference](/reference) - [Yaml file reference](yml.md) -- [Compose environment variables](env.md) -- [Compose command line completion](completion.md) diff --git a/docs/env.md b/docs/env.md index a8e6e214ce5..8886548e24d 100644 --- a/docs/env.md +++ b/docs/env.md @@ -41,9 +41,5 @@ Fully qualified container name, e.g. `DB_1_NAME=/myapp_web_1/myapp_db_1` - [User guide](/) - [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) - [Yaml file reference](yml.md) -- [Compose command line completion](completion.md) diff --git a/docs/extends.md b/docs/extends.md index 7b4d5b20939..8c35c7a66c0 100644 --- a/docs/extends.md +++ b/docs/extends.md @@ -360,4 +360,3 @@ locally-defined bindings taking precedence: - [Get started with WordPress](wordpress.md) - [Command line reference](/reference) - [Yaml file reference](yml.md) -- [Compose command line completion](completion.md) diff --git a/docs/index.md b/docs/index.md index 67a6802b06b..7900b4f08fd 100644 --- a/docs/index.md +++ b/docs/index.md @@ -55,8 +55,6 @@ Compose has commands for managing the whole lifecycle of your application: - [Get started with WordPress](wordpress.md) - [Command line reference](/reference) - [Yaml file reference](yml.md) -- [Compose environment variables](env.md) -- [Compose command line completion](completion.md) ## Quick start @@ -218,14 +216,3 @@ like-minded individuals, we have a number of open channels for communication. * 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/project/get-help/). - -## Where to go next - -- [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) -- [Yaml file reference](yml.md) -- [Compose environment variables](env.md) -- [Compose command line completion](completion.md) diff --git a/docs/install.md b/docs/install.md index be6a6b26af4..363a2b290f7 100644 --- a/docs/install.md +++ b/docs/install.md @@ -117,5 +117,3 @@ To uninstall Docker Compose if you installed using `pip`: - [Get started with WordPress](wordpress.md) - [Command line reference](/reference) - [Yaml file reference](yml.md) -- [Compose environment variables](env.md) -- [Compose command line completion](completion.md) diff --git a/docs/production.md b/docs/production.md index 29e3fd34ec9..3e4169e3084 100644 --- a/docs/production.md +++ b/docs/production.md @@ -91,5 +91,3 @@ guide. - [Get started with WordPress](wordpress.md) - [Command line reference](/reference) - [Yaml file reference](yml.md) -- [Compose environment variables](env.md) -- [Compose command line completion](completion.md) diff --git a/docs/rails.md b/docs/rails.md index 0a164ca75e7..c241041005b 100644 --- a/docs/rails.md +++ b/docs/rails.md @@ -129,5 +129,3 @@ That's it. Your app should now be running on port 3000 on your Docker daemon. If - [Get started with WordPress](wordpress.md) - [Command line reference](/reference) - [Yaml file reference](yml.md) -- [Compose environment variables](env.md) -- [Compose command line completion](completion.md) diff --git a/docs/reference/overview.md b/docs/reference/overview.md index 9f08246e096..f6496bf7868 100644 --- a/docs/reference/overview.md +++ b/docs/reference/overview.md @@ -81,9 +81,4 @@ it failed. Defaults to 60 seconds. - [User guide](/) - [Installing Compose](install.md) -- [Get started with Django](django.md) -- [Get started with Rails](rails.md) -- [Get started with WordPress](wordpress.md) - [Yaml file reference](yml.md) -- [Compose environment variables](env.md) -- [Compose command line completion](completion.md) diff --git a/docs/wordpress.md b/docs/wordpress.md index 621459382be..7ac06289991 100644 --- a/docs/wordpress.md +++ b/docs/wordpress.md @@ -100,5 +100,3 @@ database containers. If you're using [Docker Machine](https://docs.docker.com/ma - [Get started with WordPress](wordpress.md) - [Command line reference](/reference) - [Yaml file reference](yml.md) -- [Compose environment variables](env.md) -- [Compose command line completion](completion.md) diff --git a/docs/yml.md b/docs/yml.md index a476fd33fa1..185b31cfc1a 100644 --- a/docs/yml.md +++ b/docs/yml.md @@ -418,5 +418,3 @@ dollar sign (`$$`). - [Get started with Rails](rails.md) - [Get started with WordPress](wordpress.md) - [Command line reference](/reference) -- [Compose environment variables](env.md) -- [Compose command line completion](completion.md) From 182c2537d031f822229eca48b9a2b1985191f573 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 8 Oct 2015 17:22:42 -0400 Subject: [PATCH 1306/4072] Fix links between reference sections Signed-off-by: Daniel Nephin --- docs/reference/docker-compose.md | 4 ++-- docs/reference/index.md | 4 ++-- docs/reference/overview.md | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/reference/docker-compose.md b/docs/reference/docker-compose.md index 32fcbe70640..b7cca5b08e7 100644 --- a/docs/reference/docker-compose.md +++ b/docs/reference/docker-compose.md @@ -100,5 +100,5 @@ directory name. ## Where to go next -* [CLI environment variables](overview.md) -* [Command line reference](index.md) +* [CLI environment variables](/reference/overview.md) +* [Command line reference](/reference) diff --git a/docs/reference/index.md b/docs/reference/index.md index 7a1fb9b4448..961dbb86057 100644 --- a/docs/reference/index.md +++ b/docs/reference/index.md @@ -30,5 +30,5 @@ The following pages describe the usage information for the [docker-compose](/ref ## Where to go next -* [CLI environment variables](overview.md) -* [docker-compose Command](docker-compose.md) +* [CLI environment variables](/reference/overview) +* [docker-compose Command](/reference/docker-compose) diff --git a/docs/reference/overview.md b/docs/reference/overview.md index f6496bf7868..019525a581a 100644 --- a/docs/reference/overview.md +++ b/docs/reference/overview.md @@ -17,8 +17,8 @@ This section describes the subcommands you can use with the `docker-compose` com ## Commands -* [docker-compose Command](docker-compose.md) -* [CLI Reference](index.md) +* [docker-compose Command](/reference/docker-compose.md) +* [CLI Reference](/reference) ## Environment Variables From e90d2b418d7571cf32178d04258f04cecb70dd92 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 8 Oct 2015 18:32:05 -0400 Subject: [PATCH 1307/4072] Update title for command-line completion docs. Signed-off-by: Daniel Nephin --- docs/completion.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/completion.md b/docs/completion.md index 891813e9114..30c555c3a72 100644 --- a/docs/completion.md +++ b/docs/completion.md @@ -1,6 +1,6 @@ -# Command Completion +# Command-line Completion Compose comes with [command completion](http://en.wikipedia.org/wiki/Command-line_completion) for the bash and zsh shell. From 9b9c8f9cbcfca5458cfe54daff9a953d5969055a Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 8 Oct 2015 19:09:01 -0400 Subject: [PATCH 1308/4072] Clarify irc details, and remove "infancy" statement. Signed-off-by: Daniel Nephin --- docs/index.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/index.md b/docs/index.md index 7900b4f08fd..4b9f29d2044 100644 --- a/docs/index.md +++ b/docs/index.md @@ -205,13 +205,14 @@ Compose, please refer to the [CHANGELOG](https://github.com/docker/compose/blob/ ## Getting help -Docker Compose is still in its infancy and 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. +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 IRC. +* 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). From bc6b3f970b5fd0fa646dbf166d02d16b556731c5 Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Tue, 13 Oct 2015 17:03:09 +0100 Subject: [PATCH 1309/4072] container paths don't need to be expanded They should not ever be relative. Signed-off-by: Mazz Mosley --- compose/config/config.py | 1 - 1 file changed, 1 deletion(-) diff --git a/compose/config/config.py b/compose/config/config.py index 9e9cb857fbf..373299fd876 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -485,7 +485,6 @@ def resolve_volume_paths(service_dict, working_dir=None): def resolve_volume_path(volume, working_dir, service_name): container_path, host_path = split_path_mapping(volume) - container_path = os.path.expanduser(container_path) if host_path is not None: if host_path.startswith('.'): From 9aaecf95a490436deff190c77546162118145050 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 8 Oct 2015 11:10:51 -0400 Subject: [PATCH 1310/4072] Update pip install instructions to be more reliable. Signed-off-by: Daniel Nephin --- docs/install.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/install.md b/docs/install.md index 7842efb4c3f..a701f2985ca 100644 --- a/docs/install.md +++ b/docs/install.md @@ -60,7 +60,14 @@ To install Compose, do the following: ### Install using pip - $ sudo pip install -U docker-compose +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 ### Install as a container From c1d5ecaafe3e2e7b1f06342cbeeaef77d72fbac5 Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Tue, 13 Oct 2015 17:27:25 +0100 Subject: [PATCH 1311/4072] Workaround splitdrive limitations splitdrive doesn't handle relative paths, so if volume_path contains a relative path, we handle that differently and manually set drive to ''. Signed-off-by: Mazz Mosley --- compose/config/config.py | 13 ++++++++++++- tests/unit/config/config_test.py | 1 - 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 373299fd876..adba3bda50c 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -526,7 +526,18 @@ def path_mappings_from_dict(d): def split_path_mapping(volume_path): - drive, volume_config = os.path.splitdrive(volume_path) + """ + Ascertain if the volume_path contains a host path as well as a container + 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('~'): + drive, volume_config = '', volume_path + else: + drive, volume_config = os.path.splitdrive(volume_path) + if ':' in volume_config: (host, container) = volume_config.split(':', 1) return (container, drive + host) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index d3fb4d5f17b..0028210559e 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -463,7 +463,6 @@ def test_relative_path_does_expand_posix(self): self.assertEqual(d['volumes'], ['/home/me/otherproject:/data']) @pytest.mark.skipif(not IS_WINDOWS_PLATFORM, reason='windows paths') - @pytest.mark.skipif(IS_WINDOWS_PLATFORM, reason='waiting for this to be resolved: https://github.com/docker/compose/issues/2128') def test_relative_path_does_expand_windows(self): 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']) From 0e9c542865215a7ff8333e11e9eaa45b4e5a92c1 Mon Sep 17 00:00:00 2001 From: Mary Anthony Date: Tue, 13 Oct 2015 04:01:19 -0700 Subject: [PATCH 1312/4072] Updating to new tooling:supports Github source linking Fixing HEAD Updating to match daniel Fixing the index link Signed-off-by: Mary Anthony --- docs/Dockerfile | 16 ++------- docs/completion.md | 4 +-- docs/django.md | 4 +-- docs/env.md | 6 ++-- docs/extends.md | 2 +- docs/index.md | 4 +-- docs/install.md | 2 +- docs/pre-process.sh | 60 -------------------------------- docs/production.md | 2 +- docs/rails.md | 2 +- docs/reference/docker-compose.md | 4 +-- docs/reference/index.md | 34 +++++++++--------- docs/reference/overview.md | 12 +++---- docs/wordpress.md | 2 +- docs/yml.md | 6 ++-- 15 files changed, 46 insertions(+), 114 deletions(-) delete mode 100755 docs/pre-process.sh diff --git a/docs/Dockerfile b/docs/Dockerfile index fcd64900b58..0114f04e485 100644 --- a/docs/Dockerfile +++ b/docs/Dockerfile @@ -1,10 +1,11 @@ -FROM docs/base:latest +FROM docs/base:hugo-github-linking MAINTAINER Mary Anthony (@moxiegirl) -RUN svn checkout https://github.com/docker/docker/trunk/docs /docs/content/docker +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/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 @@ -12,14 +13,3 @@ RUN svn checkout https://github.com/docker/opensource/trunk/docs /docs/content COPY . /src COPY . /docs/content/compose/ - -# Sed to process GitHub Markdown -# 1-2 Remove comment code from metadata block -# 3 Change ](/word to ](/project/ in links -# 4 Change ](word.md) to ](/project/word) -# 5 Remove .md extension from link text -# 6 Change ](../ to ](/project/word) -# 7 Change ](../../ to ](/project/ --> not implemented -# -# -RUN /src/pre-process.sh /docs diff --git a/docs/completion.md b/docs/completion.md index 30c555c3a72..6e7b42c26e3 100644 --- a/docs/completion.md +++ b/docs/completion.md @@ -59,10 +59,10 @@ Enjoy working with Compose faster and with less typos! ## Compose documentation -- [User guide](/) +- [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) +- [Command line reference](./reference/index.md) - [Yaml file reference](yml.md) diff --git a/docs/django.md b/docs/django.md index b11e169358c..f4775c4ec47 100644 --- a/docs/django.md +++ b/docs/django.md @@ -124,10 +124,10 @@ example, run `docker-compose up` and in another terminal run: ## More Compose documentation -- [User guide](/) +- [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) +- [Command line reference](./reference/index.md) - [Yaml file reference](yml.md) diff --git a/docs/env.md b/docs/env.md index 8886548e24d..984a340bbdb 100644 --- a/docs/env.md +++ b/docs/env.md @@ -37,9 +37,9 @@ Fully qualified container name, e.g. `DB_1_NAME=/myapp_web_1/myapp_db_1` [Docker links]: http://docs.docker.com/userguide/dockerlinks/ -## Compose documentation +## Related Information -- [User guide](/) +- [User guide](index.md) - [Installing Compose](install.md) -- [Command line reference](/reference) +- [Command line reference](./reference/index.md) - [Yaml file reference](yml.md) diff --git a/docs/extends.md b/docs/extends.md index 8c35c7a66c0..88fb24a5723 100644 --- a/docs/extends.md +++ b/docs/extends.md @@ -358,5 +358,5 @@ locally-defined bindings taking precedence: - [Get started with Django](django.md) - [Get started with Rails](rails.md) - [Get started with WordPress](wordpress.md) -- [Command line reference](/reference) +- [Command line reference](./reference/index.md) - [Yaml file reference](yml.md) diff --git a/docs/index.md b/docs/index.md index 4b9f29d2044..bff741b6dd8 100644 --- a/docs/index.md +++ b/docs/index.md @@ -53,7 +53,7 @@ Compose has commands for managing the whole lifecycle of your application: - [Get started with Django](django.md) - [Get started with Rails](rails.md) - [Get started with WordPress](wordpress.md) -- [Command line reference](/reference) +- [Command line reference](./reference/index.md) - [Yaml file reference](yml.md) ## Quick start @@ -195,7 +195,7 @@ At this point, you have seen the basics of how Compose works. - Next, try the quick start guide for [Django](django.md), [Rails](rails.md), or [WordPress](wordpress.md). -- See the reference guides for complete details on the [commands](/reference), the +- See the reference guides for complete details on the [commands](./reference/index.md), the [configuration file](yml.md) and [environment variables](env.md). ## Release Notes diff --git a/docs/install.md b/docs/install.md index a701f2985ca..654f6421d8e 100644 --- a/docs/install.md +++ b/docs/install.md @@ -129,5 +129,5 @@ To uninstall Docker Compose if you installed using `pip`: - [Get started with Django](django.md) - [Get started with Rails](rails.md) - [Get started with WordPress](wordpress.md) -- [Command line reference](/reference) +- [Command line reference](./reference/index.md) - [Yaml file reference](yml.md) diff --git a/docs/pre-process.sh b/docs/pre-process.sh deleted file mode 100755 index f1f6b7fec61..00000000000 --- a/docs/pre-process.sh +++ /dev/null @@ -1,60 +0,0 @@ -#!/bin/bash -e - -# Populate an array with just docker dirs and one with content dirs -docker_dir=(`ls -d /docs/content/docker/*`) -content_dir=(`ls -d /docs/content/*`) - -# Loop content not of docker/ -# -# Sed to process GitHub Markdown -# 1-2 Remove comment code from metadata block -# 3 Remove .md extension from link text -# 4 Change ](/ to ](/project/ in links -# 5 Change ](word) to ](/project/word) -# 6 Change ](../../ to ](/project/ -# 7 Change ](../ to ](/project/word) -# -for i in "${content_dir[@]}" -do - : - case $i in - "/docs/content/windows") - ;; - "/docs/content/mac") - ;; - "/docs/content/linux") - ;; - "/docs/content/docker") - y=${i##*/} - find $i -type f -name "*.md" -exec sed -i.old \ - -e '/^/g' \ - -e '/^/g' {} \; - ;; - *) - y=${i##*/} - find $i -type f -name "*.md" -exec sed -i.old \ - -e '/^/g' \ - -e '/^/g' \ - -e 's/\(\]\)\([(]\)\(\/\)/\1\2\/'$y'\//g' \ - -e 's/\(\][(]\)\([A-z].*\)\(\.md\)/\1\/'$y'\/\2/g' \ - -e 's/\([(]\)\(.*\)\(\.md\)/\1\2/g' \ - -e 's/\(\][(]\)\(\.\/\)/\1\/'$y'\//g' \ - -e 's/\(\][(]\)\(\.\.\/\.\.\/\)/\1\/'$y'\//g' \ - -e 's/\(\][(]\)\(\.\.\/\)/\1\/'$y'\//g' {} \; - ;; - esac -done - -# -# Move docker directories to content -# -for i in "${docker_dir[@]}" -do - : - if [ -d $i ] - then - mv $i /docs/content/ - fi -done - -rm -rf /docs/content/docker diff --git a/docs/production.md b/docs/production.md index 3e4169e3084..5faa1c6962b 100644 --- a/docs/production.md +++ b/docs/production.md @@ -89,5 +89,5 @@ guide. - [Get started with Django](django.md) - [Get started with Rails](rails.md) - [Get started with WordPress](wordpress.md) -- [Command line reference](/reference) +- [Command line reference](./reference/index.md) - [Yaml file reference](yml.md) diff --git a/docs/rails.md b/docs/rails.md index 3782368d564..74c179b5997 100644 --- a/docs/rails.md +++ b/docs/rails.md @@ -128,5 +128,5 @@ That's it. Your app should now be running on port 3000 on your Docker daemon. If - [Get started with Django](django.md) - [Get started with Rails](rails.md) - [Get started with WordPress](wordpress.md) -- [Command line reference](/reference) +- [Command line reference](./reference/index.md) - [Yaml file reference](yml.md) diff --git a/docs/reference/docker-compose.md b/docs/reference/docker-compose.md index b7cca5b08e7..32fcbe70640 100644 --- a/docs/reference/docker-compose.md +++ b/docs/reference/docker-compose.md @@ -100,5 +100,5 @@ directory name. ## Where to go next -* [CLI environment variables](/reference/overview.md) -* [Command line reference](/reference) +* [CLI environment variables](overview.md) +* [Command line reference](index.md) diff --git a/docs/reference/index.md b/docs/reference/index.md index 961dbb86057..b2fb5bcadcf 100644 --- a/docs/reference/index.md +++ b/docs/reference/index.md @@ -11,24 +11,24 @@ parent = "smn_compose_ref" ## Compose CLI reference -The following pages describe the usage information for the [docker-compose](/reference/docker-compose.md) subcommands. You can also see this information by running `docker-compose [SUBCOMMAND] --help` from the command line. +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. -* [build](/reference/build.md) -* [help](/reference/help.md) -* [kill](/reference/kill.md) -* [ps](/reference/ps.md) -* [restart](/reference/restart.md) -* [run](/reference/run.md) -* [start](/reference/start.md) -* [up](/reference/up.md) -* [logs](/reference/logs.md) -* [port](/reference/port.md) -* [pull](/reference/pull.md) -* [rm](/reference/rm.md) -* [scale](/reference/scale.md) -* [stop](/reference/stop.md) +* [build](build.md) +* [help](help.md) +* [kill](kill.md) +* [ps](ps.md) +* [restart](restart.md) +* [run](run.md) +* [start](start.md) +* [up](up.md) +* [logs](logs.md) +* [port](port.md) +* [pull](pull.md) +* [rm](rm.md) +* [scale](scale.md) +* [stop](stop.md) ## Where to go next -* [CLI environment variables](/reference/overview) -* [docker-compose Command](/reference/docker-compose) +* [CLI environment variables](overview.md) +* [docker-compose Command](docker-compose.md) diff --git a/docs/reference/overview.md b/docs/reference/overview.md index 019525a581a..1a4c268b318 100644 --- a/docs/reference/overview.md +++ b/docs/reference/overview.md @@ -17,8 +17,8 @@ This section describes the subcommands you can use with the `docker-compose` com ## Commands -* [docker-compose Command](/reference/docker-compose.md) -* [CLI Reference](/reference) +* [docker-compose Command](docker-compose.md) +* [CLI Reference](index.md) ## Environment Variables @@ -77,8 +77,8 @@ Configures the time (in seconds) a request to the Docker daemon is allowed to ha it failed. Defaults to 60 seconds. -## Compose documentation +## Related Information -- [User guide](/) -- [Installing Compose](install.md) -- [Yaml file reference](yml.md) +- [User guide](../index.md) +- [Installing Compose](../install.md) +- [Yaml file reference](../yml.md) diff --git a/docs/wordpress.md b/docs/wordpress.md index 7ac06289991..8c407e4471b 100644 --- a/docs/wordpress.md +++ b/docs/wordpress.md @@ -98,5 +98,5 @@ database containers. If you're using [Docker Machine](https://docs.docker.com/ma - [Get started with Django](django.md) - [Get started with Rails](rails.md) - [Get started with WordPress](wordpress.md) -- [Command line reference](/reference) +- [Command line reference](./reference/index.md) - [Yaml file reference](yml.md) diff --git a/docs/yml.md b/docs/yml.md index 73fa35dfc17..f6ad8b1b75c 100644 --- a/docs/yml.md +++ b/docs/yml.md @@ -415,9 +415,11 @@ dollar sign (`$$`). ## Compose documentation -- [User guide](/) +- [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) +- [Command line reference](./reference/index.md) +- [Compose environment variables](env.md) +- [Compose command line completion](completion.md) From e9ee244e5aab6b686c5b9ae317c37f58d6a6891a Mon Sep 17 00:00:00 2001 From: Mary Anthony Date: Tue, 13 Oct 2015 15:06:37 -0700 Subject: [PATCH 1313/4072] Aaaaaaaaaaaargh Signed-off-by: Mary Anthony --- docs/yml.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/yml.md b/docs/yml.md index f6ad8b1b75c..209d2f180f4 100644 --- a/docs/yml.md +++ b/docs/yml.md @@ -421,5 +421,3 @@ dollar sign (`$$`). - [Get started with Rails](rails.md) - [Get started with WordPress](wordpress.md) - [Command line reference](./reference/index.md) -- [Compose environment variables](env.md) -- [Compose command line completion](completion.md) From 5fa5ea0e1637e02e162a4891b4ec84276e573878 Mon Sep 17 00:00:00 2001 From: Charles Chan Date: Sat, 5 Sep 2015 16:44:52 -0700 Subject: [PATCH 1314/4072] Touchup "Quickstart Guide: Compose and Django" Also incorporated the structural changes by @moxiegirl in PR #1994 as well as subsequent issues reported by @aanand. Signed-off-by: Charles Chan --- docs/django.md | 206 ++++++++++++++++++++++++++++--------------------- 1 file changed, 120 insertions(+), 86 deletions(-) diff --git a/docs/django.md b/docs/django.md index f4775c4ec47..c5e23e762d3 100644 --- a/docs/django.md +++ b/docs/django.md @@ -10,124 +10,158 @@ weight=4 -## Quickstart Guide: Compose and Django +# Quickstart Guide: Compose and Django - -This Quick-start Guide will demonstrate how to use Compose to set up and run a +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 [Compose installed](install.md). -### Define the project +## Define the project components -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: +For this project, you need to create a Dockerfile, a Python dependencies file, +and a `docker-compose.yml` file. - FROM python:2.7 - ENV PYTHONUNBUFFERED 1 - RUN mkdir /code - WORKDIR /code - ADD requirements.txt /code/ - RUN pip install -r requirements.txt - ADD . /code/ +1. Create an empty project directory. -This Dockerfile will define an image that is used to build a container that -includes your application and has Python installed alongside all of your Python -dependencies. For more information on how to write Dockerfiles, see the -[Docker user guide](https://docs.docker.com/userguide/dockerimages/#building-an-image-from-a-dockerfile) and the [Dockerfile reference](http://docs.docker.com/reference/builder/). + 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. -Second, you'll define your Python dependencies in a file called -`requirements.txt`: +2. Create a new file called `Dockerfile` in your project directory. - Django - psycopg2 + 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/userguide/dockerimages/#building-an-image-from-a-dockerfile) + and the [Dockerfile reference](http://docs.docker.com/reference/builder/). -Finally, this is all tied together with a file called `docker-compose.yml`. It -describes the services that comprise your app (here, a web server and database), -which Docker images they use, how they link together, what volumes will be -mounted inside the containers, and what ports they expose. +3. Add the following content to the `Dockerfile`. - db: - image: postgres - web: - build: . - command: python manage.py runserver 0.0.0.0:8000 - volumes: - - .:/code - ports: - - "8000:8000" - links: - - db + FROM python:2.7 + ENV PYTHONUNBUFFERED 1 + RUN mkdir /code + WORKDIR /code + ADD requirements.txt /code/ + RUN pip install -r requirements.txt + ADD . /code/ -See the [`docker-compose.yml` reference](yml.md) for more information on how -this file works. + 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. -### Build the project +4. Save and close the `Dockerfile`. -You can now start a Django project with `docker-compose run`: +5. Create a `requirements.txt` in your project directory. - $ docker-compose run web django-admin.py startproject composeexample . + This file is used by the `RUN pip install -r requirements.txt` command in your `Dockerfile`. -First, Compose will build an image for the `web` service using the `Dockerfile`. -It will then run `django-admin.py startproject composeexample .` inside a -container built using that image. +6. Add the required software in the file. -This will generate a Django app inside the current directory: + Django + psycopg2 - $ ls - Dockerfile docker-compose.yml composeexample manage.py requirements.txt +7. Save and close the `requirements.txt` file. -### Connect the database +8. Create a file called `docker-compose.yml` in your project directory. -Now you need to set up the database connection. Replace the `DATABASES = ...` -definition in `composeexample/settings.py` to read: + 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](yml.md) for more + information on how this file works. - DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.postgresql_psycopg2', - 'NAME': 'postgres', - 'USER': 'postgres', - 'HOST': 'db', - 'PORT': 5432, - } - } +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 + + 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 . -These settings are determined by the -[postgres](https://registry.hub.docker.com/_/postgres/) Docker image specified -in the Dockerfile. + 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 + Dockerfile docker-compose.yml composeexample manage.py requirements.txt + +## Connect the database + +In this section, you set up the database connection for Django. + +1. In your project dirctory, 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, + } + } -Then, run `docker-compose up`: + These settings are determined by the + [postgres](https://registry.hub.docker.com/_/postgres/) Docker image + specified in `docker-compose.yml`. - Recreating myapp_db_1... - Recreating myapp_web_1... - Attaching to myapp_db_1, myapp_web_1 - myapp_db_1 | - myapp_db_1 | PostgreSQL stand-alone backend 9.1.11 - myapp_db_1 | 2014-01-27 12:17:03 UTC LOG: database system is ready to accept connections - myapp_db_1 | 2014-01-27 12:17:03 UTC LOG: autovacuum launcher started - myapp_web_1 | Validating models... - myapp_web_1 | - myapp_web_1 | 0 errors found - myapp_web_1 | January 27, 2014 - 12:12:40 - myapp_web_1 | Django version 1.6.1, using settings 'composeexample.settings' - myapp_web_1 | Starting development server at http://0.0.0.0:8000/ - myapp_web_1 | Quit the server with CONTROL-C. +3. Save and close the file. -Your Django app should nw be running at port 8000 on your Docker daemon. If you are using a Docker Machine VM, you can use the `docker-machine ip MACHINE_NAME` to get the IP address. +4. Run the `docker-compose up` command. -You can also run management commands with Docker. To set up your database, for -example, run `docker-compose up` and in another terminal run: + $ 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. - $ docker-compose run web python manage.py syncdb + 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. ## More 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) -- [Yaml file reference](yml.md) +- [YAML file reference](yml.md) From c7ffbf97c8827025a5d7567cf076a83894eb256a Mon Sep 17 00:00:00 2001 From: Karol Duleba Date: Thu, 24 Sep 2015 22:49:38 +0100 Subject: [PATCH 1315/4072] Extend oneOf error handling. Issue #1989 Signed-off-by: Karol Duleba --- compose/config/validation.py | 18 +++++++++++++++++- tests/unit/config/config_test.py | 12 +++++++++--- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/compose/config/validation.py b/compose/config/validation.py index 959465e9872..33b660268c2 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -4,6 +4,7 @@ import sys from functools import wraps +import six from docker.utils.ports import split_port from jsonschema import Draft4Validator from jsonschema import FormatChecker @@ -162,10 +163,25 @@ def _parse_oneof_validator(error): Inspecting the context value of a ValidationError gives us information about which sub schema failed and which kind of error it is. """ + + required = [context for context in error.context if context.validator == 'required'] + if required: + return required[0].message + + additionalProperties = [context for context in error.context if context.validator == 'additionalProperties'] + if additionalProperties: + invalid_config_key = _parse_key_from_error_msg(additionalProperties[0]) + return "contains unsupported option: '{}'".format(invalid_config_key) + constraint = [context for context in error.context if len(context.path) > 0] if constraint: valid_types = _parse_valid_types_from_validator(constraint[0].validator_value) - msg = "contains {}, which is an invalid type, it should be {}".format( + invalid_config_key = "".join( + "'{}' ".format(fragment) for fragment in constraint[0].path + if isinstance(fragment, six.string_types) + ) + msg = "{}contains {}, which is an invalid type, it should be {}".format( + invalid_config_key, constraint[0].instance, valid_types ) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 2c3c5a3a12c..7b31038f555 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -335,7 +335,7 @@ def test_logs_warning_for_boolean_in_environment(self, mock_logging): self.assertTrue(expected_warning_msg in mock_logging.warn.call_args[0][0]) def test_config_invalid_environment_dict_key_raises_validation_error(self): - expected_error_msg = "Service 'web' configuration key 'environment' contains an invalid type" + expected_error_msg = "Service 'web' configuration key 'environment' contains unsupported option: '---'" with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): config.load( @@ -957,7 +957,10 @@ def test_extends_validation_missing_service_key(self): ) def test_extends_validation_invalid_key(self): - expected_error_msg = "Unsupported config option for 'web' service: 'rogue_key'" + expected_error_msg = ( + "Service 'web' configuration key 'extends' " + "contains unsupported option: 'rogue_key'" + ) with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): config.load( build_config_details( @@ -977,7 +980,10 @@ def test_extends_validation_invalid_key(self): ) def test_extends_validation_sub_property_key(self): - expected_error_msg = "Service 'web' configuration key 'extends' 'file' contains an invalid type" + 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): config.load( build_config_details( From e6344f819a01102248a1c1c6504e38a17eaa9b0e Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 9 Oct 2015 11:25:16 -0400 Subject: [PATCH 1316/4072] Rename yaml reference to compose file reference. Signed-off-by: Daniel Nephin --- docs/completion.md | 2 +- docs/django.md | 2 +- docs/env.md | 2 +- docs/extends.md | 2 +- docs/index.md | 2 +- docs/install.md | 2 +- docs/production.md | 2 +- docs/rails.md | 2 +- docs/reference/overview.md | 2 +- docs/wordpress.md | 2 +- docs/yml.md | 12 ++++++++---- 11 files changed, 18 insertions(+), 14 deletions(-) diff --git a/docs/completion.md b/docs/completion.md index 6e7b42c26e3..0234f0e9242 100644 --- a/docs/completion.md +++ b/docs/completion.md @@ -65,4 +65,4 @@ Enjoy working with Compose faster and with less typos! - [Get started with Rails](rails.md) - [Get started with WordPress](wordpress.md) - [Command line reference](./reference/index.md) -- [Yaml file reference](yml.md) +- [Compose file reference](yml.md) diff --git a/docs/django.md b/docs/django.md index c5e23e762d3..e6d31ea01c4 100644 --- a/docs/django.md +++ b/docs/django.md @@ -164,4 +164,4 @@ In this section, you set up the database connection for Django. - [Get started with Rails](rails.md) - [Get started with WordPress](wordpress.md) - [Command line reference](./reference/index.md) -- [YAML file reference](yml.md) +- [Compose file reference](yml.md) diff --git a/docs/env.md b/docs/env.md index 984a340bbdb..d32a8ba3f53 100644 --- a/docs/env.md +++ b/docs/env.md @@ -42,4 +42,4 @@ Fully qualified container name, e.g. `DB_1_NAME=/myapp_web_1/myapp_db_1` - [User guide](index.md) - [Installing Compose](install.md) - [Command line reference](./reference/index.md) -- [Yaml file reference](yml.md) +- [Compose file reference](yml.md) diff --git a/docs/extends.md b/docs/extends.md index 88fb24a5723..e9ea2073810 100644 --- a/docs/extends.md +++ b/docs/extends.md @@ -359,4 +359,4 @@ locally-defined bindings taking precedence: - [Get started with Rails](rails.md) - [Get started with WordPress](wordpress.md) - [Command line reference](./reference/index.md) -- [Yaml file reference](yml.md) +- [Compose file reference](yml.md) diff --git a/docs/index.md b/docs/index.md index bff741b6dd8..a881bfa2c0a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -54,7 +54,7 @@ Compose has commands for managing the whole lifecycle of your application: - [Get started with Rails](rails.md) - [Get started with WordPress](wordpress.md) - [Command line reference](./reference/index.md) -- [Yaml file reference](yml.md) +- [Compose file reference](yml.md) ## Quick start diff --git a/docs/install.md b/docs/install.md index 654f6421d8e..66ccfe7c72a 100644 --- a/docs/install.md +++ b/docs/install.md @@ -130,4 +130,4 @@ To uninstall Docker Compose if you installed using `pip`: - [Get started with Rails](rails.md) - [Get started with WordPress](wordpress.md) - [Command line reference](./reference/index.md) -- [Yaml file reference](yml.md) +- [Compose file reference](yml.md) diff --git a/docs/production.md b/docs/production.md index 5faa1c6962b..d18beb7b287 100644 --- a/docs/production.md +++ b/docs/production.md @@ -90,4 +90,4 @@ guide. - [Get started with Rails](rails.md) - [Get started with WordPress](wordpress.md) - [Command line reference](./reference/index.md) -- [Yaml file reference](yml.md) +- [Compose file reference](yml.md) diff --git a/docs/rails.md b/docs/rails.md index 74c179b5997..31d5a2253af 100644 --- a/docs/rails.md +++ b/docs/rails.md @@ -129,4 +129,4 @@ That's it. Your app should now be running on port 3000 on your Docker daemon. If - [Get started with Rails](rails.md) - [Get started with WordPress](wordpress.md) - [Command line reference](./reference/index.md) -- [Yaml file reference](yml.md) +- [Compose file reference](yml.md) diff --git a/docs/reference/overview.md b/docs/reference/overview.md index 1a4c268b318..51bc39b967d 100644 --- a/docs/reference/overview.md +++ b/docs/reference/overview.md @@ -81,4 +81,4 @@ it failed. Defaults to 60 seconds. - [User guide](../index.md) - [Installing Compose](../install.md) -- [Yaml file reference](../yml.md) +- [Compose file reference](../yml.md) diff --git a/docs/wordpress.md b/docs/wordpress.md index 8c407e4471b..b8c8f6b6c42 100644 --- a/docs/wordpress.md +++ b/docs/wordpress.md @@ -99,4 +99,4 @@ database containers. If you're using [Docker Machine](https://docs.docker.com/ma - [Get started with Rails](rails.md) - [Get started with WordPress](wordpress.md) - [Command line reference](./reference/index.md) -- [Yaml file reference](yml.md) +- [Compose file reference](yml.md) diff --git a/docs/yml.md b/docs/yml.md index 209d2f180f4..1b97d853e4f 100644 --- a/docs/yml.md +++ b/docs/yml.md @@ -1,15 +1,19 @@ -# docker-compose.yml reference +# 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`. Each service defined in `docker-compose.yml` must specify exactly one of `image` or `build`. Other keys are optional, and are analogous to their From 01f44efe0db34541a77db041fdc2093ca849aba9 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 9 Oct 2015 11:32:26 -0400 Subject: [PATCH 1317/4072] Re-arrange volume_driver in compose file reference. Signed-off-by: Daniel Nephin --- docs/yml.md | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/docs/yml.md b/docs/yml.md index 1b97d853e4f..4f10cf5b2b8 100644 --- a/docs/yml.md +++ b/docs/yml.md @@ -334,7 +334,7 @@ Override the default labeling scheme for each container. - label:user:USER - label:role:ROLE -### volumes +### volumes, volume\_driver Mount paths as volumes, optionally specifying a path on the host machine (`HOST:CONTAINER`), or an access mode (`HOST:CONTAINER:ro`). @@ -348,9 +348,19 @@ 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 `..`. +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`. +See [Docker Volumes](https://docs.docker.com/userguide/dockervolumes/) and +[Volume Plugins](https://docs.docker.com/extend/plugins_volume/) for more +information. + ### volumes_from Mount all of the volumes from another service or container, optionally @@ -361,7 +371,7 @@ specifying read-only access(``ro``) or read-write(``rw``). - container_name - service_name:rw -### cpu\_shares, cpuset, domainname, entrypoint, hostname, ipc, mac\_address, mem\_limit, memswap\_limit, privileged, read\_only, restart, stdin\_open, tty, user, volume\_driver, working\_dir +### cpu\_shares, cpuset, domainname, entrypoint, hostname, ipc, mac\_address, mem\_limit, memswap\_limit, privileged, read\_only, restart, stdin\_open, tty, user, working\_dir Each of these is a single value, analogous to its [docker run](https://docs.docker.com/reference/run/) counterpart. @@ -388,8 +398,6 @@ Each of these is a single value, analogous to its stdin_open: true tty: true - volume_driver: mydriver - ## Variable substitution Your configuration options can contain environment variables. Compose uses the From fbfbe60246de0a86f84da859c07ee1a64e32ea05 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 13 Oct 2015 15:23:27 -0400 Subject: [PATCH 1318/4072] Rename yml.md to compose-file.md and add an alias for the old url. Signed-off-by: Daniel Nephin --- docs/completion.md | 2 +- docs/{yml.md => compose-file.md} | 1 + docs/django.md | 2 +- docs/env.md | 2 +- docs/extends.md | 2 +- docs/index.md | 4 ++-- docs/install.md | 2 +- docs/production.md | 2 +- docs/rails.md | 2 +- docs/reference/overview.md | 2 +- docs/wordpress.md | 2 +- 11 files changed, 12 insertions(+), 11 deletions(-) rename docs/{yml.md => compose-file.md} (99%) diff --git a/docs/completion.md b/docs/completion.md index 0234f0e9242..bc8bedc96cc 100644 --- a/docs/completion.md +++ b/docs/completion.md @@ -65,4 +65,4 @@ Enjoy working with Compose faster and with less typos! - [Get started with Rails](rails.md) - [Get started with WordPress](wordpress.md) - [Command line reference](./reference/index.md) -- [Compose file reference](yml.md) +- [Compose file reference](compose-file.md) diff --git a/docs/yml.md b/docs/compose-file.md similarity index 99% rename from docs/yml.md rename to docs/compose-file.md index 4f10cf5b2b8..725130f7897 100644 --- a/docs/yml.md +++ b/docs/compose-file.md @@ -3,6 +3,7 @@ title = "Compose file reference" description = "Compose file reference" keywords = ["fig, composition, compose, docker"] +aliases = ["/compose/yml"] [menu.main] parent="smn_compose_ref" +++ diff --git a/docs/django.md b/docs/django.md index e6d31ea01c4..c7ebf58bfe4 100644 --- a/docs/django.md +++ b/docs/django.md @@ -164,4 +164,4 @@ In this section, you set up the database connection for Django. - [Get started with Rails](rails.md) - [Get started with WordPress](wordpress.md) - [Command line reference](./reference/index.md) -- [Compose file reference](yml.md) +- [Compose file reference](compose-file.md) diff --git a/docs/env.md b/docs/env.md index d32a8ba3f53..8f3cc3ccb13 100644 --- a/docs/env.md +++ b/docs/env.md @@ -42,4 +42,4 @@ Fully qualified container name, e.g. `DB_1_NAME=/myapp_web_1/myapp_db_1` - [User guide](index.md) - [Installing Compose](install.md) - [Command line reference](./reference/index.md) -- [Compose file reference](yml.md) +- [Compose file reference](compose-file.md) diff --git a/docs/extends.md b/docs/extends.md index e9ea2073810..d88ce61c5d9 100644 --- a/docs/extends.md +++ b/docs/extends.md @@ -359,4 +359,4 @@ locally-defined bindings taking precedence: - [Get started with Rails](rails.md) - [Get started with WordPress](wordpress.md) - [Command line reference](./reference/index.md) -- [Compose file reference](yml.md) +- [Compose file reference](compose-file.md) diff --git a/docs/index.md b/docs/index.md index a881bfa2c0a..2e10e080157 100644 --- a/docs/index.md +++ b/docs/index.md @@ -54,7 +54,7 @@ Compose has commands for managing the whole lifecycle of your application: - [Get started with Rails](rails.md) - [Get started with WordPress](wordpress.md) - [Command line reference](./reference/index.md) -- [Compose file reference](yml.md) +- [Compose file reference](compose-file.md) ## Quick start @@ -196,7 +196,7 @@ At this point, you have seen the basics of how Compose works. - Next, try the quick start guide for [Django](django.md), [Rails](rails.md), or [WordPress](wordpress.md). - See the reference guides for complete details on the [commands](./reference/index.md), the - [configuration file](yml.md) and [environment variables](env.md). + [configuration file](compose-file.md) and [environment variables](env.md). ## Release Notes diff --git a/docs/install.md b/docs/install.md index 66ccfe7c72a..2d4d6cadb63 100644 --- a/docs/install.md +++ b/docs/install.md @@ -130,4 +130,4 @@ To uninstall Docker Compose if you installed using `pip`: - [Get started with Rails](rails.md) - [Get started with WordPress](wordpress.md) - [Command line reference](./reference/index.md) -- [Compose file reference](yml.md) +- [Compose file reference](compose-file.md) diff --git a/docs/production.md b/docs/production.md index d18beb7b287..8793f9277e4 100644 --- a/docs/production.md +++ b/docs/production.md @@ -90,4 +90,4 @@ guide. - [Get started with Rails](rails.md) - [Get started with WordPress](wordpress.md) - [Command line reference](./reference/index.md) -- [Compose file reference](yml.md) +- [Compose file reference](compose-file.md) diff --git a/docs/rails.md b/docs/rails.md index 31d5a2253af..a33cac26edb 100644 --- a/docs/rails.md +++ b/docs/rails.md @@ -129,4 +129,4 @@ That's it. Your app should now be running on port 3000 on your Docker daemon. If - [Get started with Rails](rails.md) - [Get started with WordPress](wordpress.md) - [Command line reference](./reference/index.md) -- [Compose file reference](yml.md) +- [Compose file reference](compose-file.md) diff --git a/docs/reference/overview.md b/docs/reference/overview.md index 51bc39b967d..3f589a9ded9 100644 --- a/docs/reference/overview.md +++ b/docs/reference/overview.md @@ -81,4 +81,4 @@ it failed. Defaults to 60 seconds. - [User guide](../index.md) - [Installing Compose](../install.md) -- [Compose file reference](../yml.md) +- [Compose file reference](../compose-file.md) diff --git a/docs/wordpress.md b/docs/wordpress.md index b8c8f6b6c42..8c1f5b0acb2 100644 --- a/docs/wordpress.md +++ b/docs/wordpress.md @@ -99,4 +99,4 @@ database containers. If you're using [Docker Machine](https://docs.docker.com/ma - [Get started with Rails](rails.md) - [Get started with WordPress](wordpress.md) - [Command line reference](./reference/index.md) -- [Compose file reference](yml.md) +- [Compose file reference](compose-file.md) From f4efa293779e7b6a39fef2aab6ebad1b337007bc Mon Sep 17 00:00:00 2001 From: Mohit Soni Date: Wed, 30 Sep 2015 00:25:26 -0700 Subject: [PATCH 1319/4072] Added support for cgroup_parent This change adds cgroup-parent support to compose project. It allows each service to specify a 'cgroup_parent' option. Signed-off-by: Mohit Soni --- compose/config/config.py | 1 + compose/config/fields_schema.json | 1 + compose/service.py | 5 ++++- docs/compose-file.md | 6 ++++++ tests/unit/service_test.py | 7 +++++++ 5 files changed, 19 insertions(+), 1 deletion(-) diff --git a/compose/config/config.py b/compose/config/config.py index 9e9cb857fbf..1a995fa8e1c 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -21,6 +21,7 @@ DOCKER_CONFIG_KEYS = [ 'cap_add', 'cap_drop', + 'cgroup_parent', 'command', 'cpu_shares', 'cpuset', diff --git a/compose/config/fields_schema.json b/compose/config/fields_schema.json index 6fce299cbb1..da67774fdfb 100644 --- a/compose/config/fields_schema.json +++ b/compose/config/fields_schema.json @@ -17,6 +17,7 @@ "build": {"type": "string"}, "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"}, diff --git a/compose/service.py b/compose/service.py index 044b34ad5e7..0d89afc02d0 100644 --- a/compose/service.py +++ b/compose/service.py @@ -41,6 +41,7 @@ DOCKER_START_KEYS = [ 'cap_add', 'cap_drop', + 'cgroup_parent', 'devices', 'dns', 'dns_search', @@ -675,6 +676,7 @@ def _get_container_host_config(self, override_options, one_off=False): read_only = options.get('read_only', None) devices = options.get('devices', None) + cgroup_parent = options.get('cgroup_parent', None) return self.client.create_host_config( links=self._get_links(link_to_self=one_off), @@ -696,7 +698,8 @@ def _get_container_host_config(self, override_options, one_off=False): read_only=read_only, pid_mode=pid, security_opt=security_opt, - ipc_mode=options.get('ipc') + ipc_mode=options.get('ipc'), + cgroup_parent=cgroup_parent ) def build(self, no_cache=False, pull=False): diff --git a/docs/compose-file.md b/docs/compose-file.md index 725130f7897..67322335a78 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -56,6 +56,12 @@ Override the default command. 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. diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 19d25e2ed54..15a9b7c0375 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -353,6 +353,13 @@ def test_create_container_no_build(self): service.create_container(do_build=False) self.assertFalse(self.mock_client.build.called) + def test_create_container_no_build_cgroup_parent(self): + service = Service('foo', client=self.mock_client, build='.') + service.image = lambda: {'Id': 'abc123'} + + service.create_container(do_build=False, cgroup_parent='test') + 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='.') service.image = lambda *args, **kwargs: mock_get_image([]) From ca36628a0e3ff1f68a033ca2747cae62fae847c9 Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Wed, 14 Oct 2015 14:57:37 +0100 Subject: [PATCH 1320/4072] Test cgroup_parent option is being sent. Signed-off-by: Mazz Mosley --- tests/unit/service_test.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 15a9b7c0375..84ede755312 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -146,6 +146,18 @@ def test_memory_swap_limit(self): 2000000000 ) + 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._get_container_create_options({'some': 'overrides'}, 1) + + self.assertTrue(self.mock_client.create_host_config.called) + self.assertEqual( + self.mock_client.create_host_config.call_args[1]['cgroup_parent'], + 'test' + ) + def test_log_opt(self): self.mock_client.create_host_config.return_value = {} @@ -353,13 +365,6 @@ def test_create_container_no_build(self): service.create_container(do_build=False) self.assertFalse(self.mock_client.build.called) - def test_create_container_no_build_cgroup_parent(self): - service = Service('foo', client=self.mock_client, build='.') - service.image = lambda: {'Id': 'abc123'} - - service.create_container(do_build=False, cgroup_parent='test') - 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='.') service.image = lambda *args, **kwargs: mock_get_image([]) From 7c6e7e0dced516c860cd5a930217c2bcbcab556b Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 13 Oct 2015 14:24:19 -0400 Subject: [PATCH 1321/4072] Update docker-py to 1.5.0 Signed-off-by: Daniel Nephin --- requirements.txt | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 4f2ea9d14fe..daaaa950262 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ PyYAML==3.10 -docker-py==1.4.0 +docker-py==1.5.0 dockerpty==0.3.4 docopt==0.6.1 enum34==1.0.4 diff --git a/setup.py b/setup.py index 0313fbd052d..4020122b157 100644 --- a/setup.py +++ b/setup.py @@ -33,7 +33,7 @@ def find_version(*file_paths): 'requests >= 2.6.1, < 2.8', 'texttable >= 0.8.1, < 0.9', 'websocket-client >= 0.32.0, < 1.0', - 'docker-py >= 1.4.0, < 2', + 'docker-py >= 1.5.0, < 2', 'dockerpty >= 0.3.4, < 0.4', 'six >= 1.3.0, < 2', 'jsonschema >= 2.5.1, < 3', From f228173660feb9961e39637d8133cc66c3dc1b33 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 13 Oct 2015 17:31:44 -0400 Subject: [PATCH 1322/4072] Print docker version. Signed-off-by: Daniel Nephin --- script/ci | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/script/ci b/script/ci index 58144ea3b90..12dc3c473e9 100755 --- a/script/ci +++ b/script/ci @@ -6,7 +6,9 @@ # $ 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 -e +set -ex + +docker version export DOCKER_VERSIONS=all export DOCKER_DAEMON_ARGS="--storage-driver=overlay" From d5f5eb19243d7c2f04839b232067cdf028ba856d Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 7 Oct 2015 16:10:08 +0100 Subject: [PATCH 1323/4072] Enable use of Docker networking with the --x-networking flag Signed-off-by: Aanand Prasad --- compose/cli/command.py | 13 ++++-- compose/cli/main.py | 4 ++ compose/project.py | 58 +++++++++++++++++++++-- compose/service.py | 5 ++ docs/django.md | 7 +-- docs/index.md | 3 -- docs/networking.md | 84 ++++++++++++++++++++++++++++++++++ docs/rails.md | 4 +- docs/wordpress.md | 2 - script/build-windows.ps1 | 7 ++- tests/integration/cli_test.py | 43 +++++++++++++++++ tests/integration/testcases.py | 11 +++++ tests/unit/service_test.py | 20 ++++++++ tox.ini | 3 +- 14 files changed, 240 insertions(+), 24 deletions(-) create mode 100644 docs/networking.md diff --git a/compose/cli/command.py b/compose/cli/command.py index 1a9bc3dcbf3..dd7548b707b 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -49,7 +49,10 @@ def project_from_options(base_dir, options): base_dir, get_config_path(options.get('--file')), project_name=options.get('--project-name'), - verbose=options.get('--verbose')) + verbose=options.get('--verbose'), + use_networking=options.get('--x-networking'), + network_driver=options.get('--x-network-driver'), + ) def get_config_path(file_option): @@ -76,14 +79,18 @@ def get_client(verbose=False): return client -def get_project(base_dir, config_path=None, project_name=None, verbose=False): +def get_project(base_dir, config_path=None, project_name=None, verbose=False, + use_networking=False, network_driver=None): config_details = config.find(base_dir, config_path) try: return Project.from_dicts( get_project_name(config_details.working_dir, project_name), config.load(config_details), - get_client(verbose=verbose)) + get_client(verbose=verbose), + use_networking=use_networking, + network_driver=network_driver, + ) except ConfigError as e: raise errors.UserError(six.text_type(e)) diff --git a/compose/cli/main.py b/compose/cli/main.py index 0f0a69cad63..c800d95f98c 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -117,6 +117,10 @@ class TopLevelCommand(DocoptCommand): 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) + --x-networking (EXPERIMENTAL) Use new Docker networking functionality. + Requires Docker 1.9 or later. + --x-network-driver DRIVER (EXPERIMENTAL) Specify a network driver (default: "bridge"). + Requires Docker 1.9 or later. --verbose Show more output -v, --version Print version and exit diff --git a/compose/project.py b/compose/project.py index 999c2890411..0e20a4cee74 100644 --- a/compose/project.py +++ b/compose/project.py @@ -77,10 +77,12 @@ class Project(object): """ A collection of services. """ - def __init__(self, name, services, client): + def __init__(self, name, services, client, use_networking=False, network_driver=None): self.name = name self.services = services self.client = client + self.use_networking = use_networking + self.network_driver = network_driver or 'bridge' def labels(self, one_off=False): return [ @@ -89,11 +91,15 @@ def labels(self, one_off=False): ] @classmethod - def from_dicts(cls, name, service_dicts, client): + def from_dicts(cls, name, service_dicts, client, use_networking=False, network_driver=None): """ Construct a ServiceCollection from a list of dicts representing services. """ - project = cls(name, [], client) + project = cls(name, [], client, use_networking=use_networking, network_driver=network_driver) + + if use_networking: + remove_links(service_dicts) + for service_dict in sort_service_dicts(service_dicts): links = project.get_links(service_dict) volumes_from = project.get_volumes_from(service_dict) @@ -103,6 +109,7 @@ def from_dicts(cls, name, service_dicts, client): Service( client=client, project=name, + use_networking=use_networking, links=links, net=net, volumes_from=volumes_from, @@ -207,6 +214,8 @@ def get_volumes_from(self, service_dict): def get_net(self, service_dict): net = service_dict.pop('net', None) if not net: + if self.use_networking: + return Net(self.name) return Net(None) net_name = get_service_name_from_net(net) @@ -289,6 +298,9 @@ def up(self, plans = self._get_convergence_plans(services, strategy) + if self.use_networking: + self.ensure_network_exists() + return [ container for service in services @@ -350,6 +362,26 @@ def matches_service_names(container): return [c for c in containers if matches_service_names(c)] + def get_network(self): + networks = self.client.networks(names=[self.name]) + if networks: + return networks[0] + return None + + def ensure_network_exists(self): + # TODO: recreate network if driver has changed? + if self.get_network() is None: + log.info( + 'Creating network "{}" with driver "{}"' + .format(self.name, self.network_driver) + ) + self.client.create_network(self.name, driver=self.network_driver) + + def remove_network(self): + network = self.get_network() + if network: + self.client.remove_network(network['id']) + def _inject_deps(self, acc, service): dep_names = service.get_dependency_names() @@ -365,6 +397,26 @@ def _inject_deps(self, acc, service): return acc + dep_services +def remove_links(service_dicts): + services_with_links = [s for s in service_dicts if 'links' in s] + if not services_with_links: + return + + if len(services_with_links) == 1: + prefix = '"{}" defines'.format(services_with_links[0]['name']) + else: + prefix = 'Some services ({}) define'.format( + ", ".join('"{}"'.format(s['name']) for s in services_with_links)) + + log.warn( + '\n{} links, which are not compatible with Docker networking and will be ignored.\n' + 'Future versions of Docker will not support links - you should remove them for ' + 'forwards-compatibility.\n'.format(prefix)) + + for s in services_with_links: + del s['links'] + + class NoSuchService(Exception): def __init__(self, name): self.name = name diff --git a/compose/service.py b/compose/service.py index 0d89afc02d0..5f1d59468c1 100644 --- a/compose/service.py +++ b/compose/service.py @@ -113,6 +113,7 @@ def __init__( name, client=None, project='default', + use_networking=False, links=None, volumes_from=None, net=None, @@ -124,6 +125,7 @@ def __init__( self.name = name self.client = client self.project = project + self.use_networking = use_networking self.links = links or [] self.volumes_from = volumes_from or [] self.net = net or Net(None) @@ -602,6 +604,9 @@ def _get_container_create_options( container_options['hostname'] = parts[0] container_options['domainname'] = parts[2] + if 'hostname' not in container_options and self.use_networking: + container_options['hostname'] = self.name + if 'ports' in container_options or 'expose' in self.options: ports = [] all_ports = container_options.get('ports', []) + self.options.get('expose', []) diff --git a/docs/django.md b/docs/django.md index c7ebf58bfe4..2ebf4b4b97d 100644 --- a/docs/django.md +++ b/docs/django.md @@ -64,9 +64,8 @@ and a `docker-compose.yml` file. 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 + also describes which Docker images these services use, any volumes they might + need mounted inside the containers, and any ports they might expose. See the [`docker-compose.yml` reference](yml.md) for more information on how this file works. @@ -81,8 +80,6 @@ and a `docker-compose.yml` file. - .:/code ports: - "8000:8000" - links: - - db This file defines two services: The `db` service and the `web` service. diff --git a/docs/index.md b/docs/index.md index 2e10e080157..e19e7d7f445 100644 --- a/docs/index.md +++ b/docs/index.md @@ -128,8 +128,6 @@ Next, define a set of services using `docker-compose.yml`: - "5000:5000" volumes: - .:/code - links: - - redis redis: image: redis @@ -138,7 +136,6 @@ This template 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 current directory on the host to `/code` inside the container allowing you to modify the code without having to rebuild the image. -* Links the web container 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. diff --git a/docs/networking.md b/docs/networking.md new file mode 100644 index 00000000000..f4227917acf --- /dev/null +++ b/docs/networking.md @@ -0,0 +1,84 @@ + + + +# Networking in Compose + +> **Note:** Compose’s networking support is experimental, and must be explicitly enabled with the `docker-compose --x-networking` flag. + +Compose sets up a single default [network](http://TODO/docker-networking-docs) 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 service's 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 [CLI docs](cli.md#p-project-name-name) for how to override it. + +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 + +When you run `docker-compose --x-networking up`, the following happens: + +1. A network called `myapp` is created. +2. A container is created using `web`'s configuration. It joins the network `myapp` under the name `web`. +3. A container is created using `db`'s configuration. It joins the network `myapp` 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. + +## Configure how services are published + +By default, containers for each service are published on the network with the same name as the service. If you want to change the name, or stop containers from being discoverable at all, you can use the `hostname` option: + + web: + build: . + hostname: "my-web-application" + +This will also change the hostname inside the container, so that the `hostname` command will return `my-web-application`. + +## Scaling services + +If you create multiple containers for a service with `docker-compose scale`, each container will join the network with the same name. For example, if you run `docker-compose scale web=3`, then 3 containers will join the network under the name `web`. Inside any container on the network, looking up the name `web` will return the IP address of one of them, but Docker and Compose do not provide any guarantees about which one. + +This limitation will be addressed in a future version of Compose, where a load balancer will join under the service name and balance traffic between the service's containers in a configurable manner. + +## 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`. + +## 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](http://TODO/custom-driver-docs). + +You can specify which one to use with the `--x-network-driver` flag: + + $ docker-compose --x-networking --x-network-driver=overlay up + +## Multi-host networking + +(TODO: talk about Swarm and the overlay driver) + +## 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. + +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. + +If *all* services in an app specify the `net` option, a network will not be created at all. diff --git a/docs/rails.md b/docs/rails.md index a33cac26edb..9801ef7419f 100644 --- a/docs/rails.md +++ b/docs/rails.md @@ -37,7 +37,7 @@ Next, create a bootstrap `Gemfile` which just loads Rails. It'll be overwritten source 'https://rubygems.org' gem 'rails', '4.2.0' -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 expose the web app's port. db: image: postgres @@ -48,8 +48,6 @@ Finally, `docker-compose.yml` is where the magic happens. This file describes th - .:/myapp ports: - "3000:3000" - links: - - db ### Build the project diff --git a/docs/wordpress.md b/docs/wordpress.md index 8c1f5b0acb2..5c9bcdbd9f7 100644 --- a/docs/wordpress.md +++ b/docs/wordpress.md @@ -46,8 +46,6 @@ and a separate MySQL instance: command: php -S 0.0.0.0:8000 -t /code ports: - "8000:8000" - links: - - db volumes: - .:/code db: diff --git a/script/build-windows.ps1 b/script/build-windows.ps1 index b35fad6f131..6e8a7c5ae7f 100644 --- a/script/build-windows.ps1 +++ b/script/build-windows.ps1 @@ -42,15 +42,14 @@ Get-ChildItem -Recurse -Include *.pyc | foreach ($_) { Remove-Item $_.FullName } virtualenv .\venv # Install dependencies -.\venv\Scripts\pip install pypiwin32==219 -.\venv\Scripts\pip install -r requirements.txt -.\venv\Scripts\pip install --no-deps . - # TODO: pip warns when installing from a git sha, so we need to set ErrorAction to # 'Continue'. See # https://github.com/pypa/pip/blob/fbc4b7ae5fee00f95bce9ba4b887b22681327bb1/pip/vcs/git.py#L77 # This can be removed once pyinstaller 3.x is released and we upgrade $ErrorActionPreference = "Continue" +.\venv\Scripts\pip install pypiwin32==219 +.\venv\Scripts\pip install -r requirements.txt +.\venv\Scripts\pip install --no-deps . .\venv\Scripts\pip install --allow-external pyinstaller -r requirements-build.txt # Build binary diff --git a/tests/integration/cli_test.py b/tests/integration/cli_test.py index 3774eb88e63..a18b69f500b 100644 --- a/tests/integration/cli_test.py +++ b/tests/integration/cli_test.py @@ -185,6 +185,49 @@ def test_up_attached(self): set(self.project.containers()) ) + def test_up_without_networking(self): + self.require_engine_version("1.9") + + self.command.base_dir = 'tests/fixtures/links-composefile' + self.command.dispatch(['up', '-d'], None) + + networks = [n for n in self.client.networks(names=[self.project.name])] + self.assertEqual(len(networks), 0) + + for service in self.project.get_services(): + containers = service.containers() + self.assertEqual(len(containers), 1) + self.assertNotEqual(containers[0].get('Config.Hostname'), service.name) + + web_container = self.project.get_service('web').containers()[0] + self.assertTrue(web_container.get('HostConfig.Links')) + + def test_up_with_networking(self): + self.require_engine_version("1.9") + + self.command.base_dir = 'tests/fixtures/links-composefile' + self.command.dispatch(['--x-networking', 'up', '-d'], None) + + services = self.project.get_services() + + networks = [n for n in self.client.networks(names=[self.project.name])] + for n in networks: + self.addCleanup(self.client.remove_network, n['id']) + self.assertEqual(len(networks), 1) + self.assertEqual(networks[0]['driver'], 'bridge') + + network = self.client.inspect_network(networks[0]['id']) + self.assertEqual(len(network['containers']), len(services)) + + for service in services: + containers = service.containers() + self.assertEqual(len(containers), 1) + self.assertIn(containers[0].id, network['containers']) + self.assertEqual(containers[0].get('Config.Hostname'), service.name) + + web_container = self.project.get_service('web').containers()[0] + self.assertFalse(web_container.get('HostConfig.Links')) + def test_up_with_links(self): self.command.base_dir = 'tests/fixtures/links-composefile' self.command.dispatch(['up', '-d', 'web'], None) diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index 26a0a108a1c..a412fb04fb7 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -2,6 +2,8 @@ from __future__ import unicode_literals from docker import errors +from docker.utils import version_lt +from pytest import skip from .. import unittest from compose.cli.docker_client import docker_client @@ -73,3 +75,12 @@ def check_build(self, *args, **kwargs): kwargs.setdefault('rm', True) build_output = self.client.build(*args, **kwargs) stream_output(build_output, open('/dev/null', 'w')) + + def require_engine_version(self, minimum): + # Drop '-dev' or '-rcN' suffix + engine = self.client.version()['Version'].split('-', 1)[0] + if version_lt(engine, minimum): + skip( + "Engine version is too low ({} < {})" + .format(engine, minimum) + ) diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 84ede755312..c5e1a9fb060 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -203,6 +203,26 @@ def test_split_domainname_weird(self): self.assertEqual(opts['hostname'], 'name.sub', 'hostname') self.assertEqual(opts['domainname'], 'domain.tld', 'domainname') + def test_no_default_hostname_when_not_using_networking(self): + service = Service( + 'foo', + image='foo', + use_networking=False, + client=self.mock_client, + ) + opts = service._get_container_create_options({'image': 'foo'}, 1) + self.assertIsNone(opts.get('hostname')) + + def test_hostname_defaults_to_service_name_when_using_networking(self): + service = Service( + 'foo', + image='foo', + use_networking=True, + client=self.mock_client, + ) + opts = service._get_container_create_options({'image': 'foo'}, 1) + self.assertEqual(opts['hostname'], 'foo') + def test_get_container_create_options_with_name_option(self): service = Service( 'foo', diff --git a/tox.ini b/tox.ini index dbf639201b1..f05c5ed260c 100644 --- a/tox.ini +++ b/tox.ini @@ -11,9 +11,10 @@ passenv = setenv = HOME=/tmp deps = + -rrequirements.txt -rrequirements-dev.txt commands = - py.test -v \ + py.test -v -rxs \ --cov=compose \ --cov-report html \ --cov-report term \ From e2f792c4f43314fed2dca0f5a06ce7fecebead64 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 14 Oct 2015 12:09:50 -0400 Subject: [PATCH 1324/4072] If -x-networking is used, set the correct API version. Signed-off-by: Daniel Nephin --- compose/cli/command.py | 7 ++++--- compose/cli/docker_client.py | 9 +++++++-- tests/integration/cli_test.py | 11 +++++++---- 3 files changed, 18 insertions(+), 9 deletions(-) diff --git a/compose/cli/command.py b/compose/cli/command.py index dd7548b707b..525217ee75a 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -67,8 +67,8 @@ def get_config_path(file_option): return [config_file] if config_file else None -def get_client(verbose=False): - client = docker_client() +def get_client(verbose=False, version=None): + client = docker_client(version=version) if verbose: version_info = six.iteritems(client.version()) log.info("Compose version %s", __version__) @@ -83,11 +83,12 @@ def get_project(base_dir, config_path=None, project_name=None, verbose=False, use_networking=False, network_driver=None): config_details = config.find(base_dir, config_path) + api_version = '1.21' if use_networking else None try: return Project.from_dicts( get_project_name(config_details.working_dir, project_name), config.load(config_details), - get_client(verbose=verbose), + get_client(verbose=verbose, version=api_version), use_networking=use_networking, network_driver=network_driver, ) diff --git a/compose/cli/docker_client.py b/compose/cli/docker_client.py index 2c634f33767..734f4237b04 100644 --- a/compose/cli/docker_client.py +++ b/compose/cli/docker_client.py @@ -9,7 +9,10 @@ log = logging.getLogger(__name__) -def docker_client(): +DEFAULT_API_VERSION = '1.19' + + +def docker_client(version=None): """ Returns a docker-py client configured using environment variables according to the same logic as the official Docker client. @@ -18,6 +21,8 @@ def docker_client(): log.warn('The DOCKER_CLIENT_TIMEOUT environment variable is deprecated. Please use COMPOSE_HTTP_TIMEOUT instead.') kwargs = kwargs_from_env(assert_hostname=False) - kwargs['version'] = os.environ.get('COMPOSE_API_VERSION', '1.19') + kwargs['version'] = version or os.environ.get( + 'COMPOSE_API_VERSION', + DEFAULT_API_VERSION) kwargs['timeout'] = HTTP_TIMEOUT return Client(**kwargs) diff --git a/tests/integration/cli_test.py b/tests/integration/cli_test.py index a18b69f500b..78519d14180 100644 --- a/tests/integration/cli_test.py +++ b/tests/integration/cli_test.py @@ -10,6 +10,7 @@ from .. import mock from .testcases import DockerClientTestCase from compose.cli.command import get_project +from compose.cli.docker_client import docker_client from compose.cli.errors import UserError from compose.cli.main import TopLevelCommand from compose.project import NoSuchService @@ -190,8 +191,9 @@ def test_up_without_networking(self): self.command.base_dir = 'tests/fixtures/links-composefile' self.command.dispatch(['up', '-d'], None) + client = docker_client(version='1.21') - networks = [n for n in self.client.networks(names=[self.project.name])] + networks = client.networks(names=[self.project.name]) self.assertEqual(len(networks), 0) for service in self.project.get_services(): @@ -207,16 +209,17 @@ def test_up_with_networking(self): self.command.base_dir = 'tests/fixtures/links-composefile' self.command.dispatch(['--x-networking', 'up', '-d'], None) + client = docker_client(version='1.21') services = self.project.get_services() - networks = [n for n in self.client.networks(names=[self.project.name])] + networks = client.networks(names=[self.project.name]) for n in networks: - self.addCleanup(self.client.remove_network, n['id']) + self.addCleanup(client.remove_network, n['id']) self.assertEqual(len(networks), 1) self.assertEqual(networks[0]['driver'], 'bridge') - network = self.client.inspect_network(networks[0]['id']) + network = client.inspect_network(networks[0]['id']) self.assertEqual(len(network['containers']), len(services)) for service in services: From 709bd9c363c1f48138ab671b9505569057b5e04b Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 14 Oct 2015 12:37:43 -0400 Subject: [PATCH 1325/4072] Bump 1.5.0rc1 Signed-off-by: Daniel Nephin --- CHANGELOG.md | 48 +++++++++++++++++++++++++++++++++++++++++++++ compose/__init__.py | 2 +- docs/install.md | 4 ++-- script/run.sh | 2 +- 4 files changed, 52 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 598f5e57943..730cd30ef7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,54 @@ Change log ========== +1.5.0 (2015-10-13) +------------------ + +Major Features + +- Compose is now available on windows. +- Environment variable can be used in the compose file. See + https://github.com/docker/compose/blob/129092b7/docs/yml.md#variable-substitution +- Multiple compose files can be specified, allowing you to override + setting in the default compose file. See + https://github.com/docker/compose/blob/129092b7/docs/reference/docker-compose.md + for more details. +- Configuration validation is now a lot more strict +- `up` now waits for all services to exit before shutting down +- Support for the new docker networking can be enabled with + the `--x-networking` flag + +New Features + +- `volumes_from` now supports a mode option allowing for read-only + `volumes_from` +- Volumes that don't start with a path indicator (`.` or `/`) will now be + treated as a named volume. Previously this was a warning. +- `--pull` flag added to `build` +- `--ignore-pull-failures` flag added to `pull` +- Support for the `ipc` field added to the compose file +- Containers created by `run` can now be named with the `--name` flag +- If you install Compose with pip or use it as a library, it now + works with Python 3 +- `image` field now supports image digests (in addition to ids and tags) +- `ports` now supports ranges of ports +- `--publish` flag added to `run` +- New subcommands `pause` and `unpause` +- services may be extended from the same file without a `file` key in + `extends` +- Compose can be installed and run as a docker image. This is an experimental + feature. + + +Bug Fixes + +- Support all `log_drivers` +- Fixed `build` when running against swarm +- `~` is no longer expanded on the host when included as part of a container + volume path + + + 1.4.2 (2015-09-22) ------------------ diff --git a/compose/__init__.py b/compose/__init__.py index e3ace983566..06897c84481 100644 --- a/compose/__init__.py +++ b/compose/__init__.py @@ -1,3 +1,3 @@ from __future__ import unicode_literals -__version__ = '1.5.0dev' +__version__ = '1.5.0rc1' diff --git a/docs/install.md b/docs/install.md index 2d4d6cadb63..31b2ccad47a 100644 --- a/docs/install.md +++ b/docs/install.md @@ -53,7 +53,7 @@ To install Compose, do the following: 7. Test the installation. $ docker-compose --version - docker-compose version: 1.4.2 + docker-compose version: 1.5.0rc1 ## Alternative install options @@ -75,7 +75,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.0/run.sh > /usr/local/bin/docker-compose + $ curl -L https://github.com/docker/compose/releases/download/1.5.0rc1/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 cf46c143c38..68ee4faa5b8 100755 --- a/script/run.sh +++ b/script/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.5.0" +VERSION="1.5.0rc1" IMAGE="docker/compose:$VERSION" From 338f2f4507919bda988be076f1654b4eb9dea497 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 14 Oct 2015 15:14:04 -0400 Subject: [PATCH 1326/4072] Add a script to generate contributor list. Signed-off-by: Daniel Nephin --- project/RELEASE-PROCESS.md | 11 +++++++---- script/release/contributors | 27 +++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 4 deletions(-) create mode 100755 script/release/contributors diff --git a/project/RELEASE-PROCESS.md b/project/RELEASE-PROCESS.md index 85bbaf29500..a7fea69edf8 100644 --- a/project/RELEASE-PROCESS.md +++ b/project/RELEASE-PROCESS.md @@ -82,17 +82,20 @@ When prompted build the non-linux binaries and test them. 5. Attach the binaries and `script/run.sh` -6. If everything looks good, it's time to push the release. +6. Add "Thanks" with a list of contributors. The contributor list can be generated + by running `./script/release/contributors`. + +7. If everything looks good, it's time to push the release. ./script/release/push-release -7. Publish the release on GitHub. +8. Publish the release on GitHub. -8. Check that both binaries download (following the install instructions) and run. +9. Check that both binaries download (following the install instructions) and run. -9. Email maintainers@dockerproject.org and engineering@docker.com about the new release. +10. Email maintainers@dockerproject.org and engineering@docker.com about the new release. ## If it’s a stable release (not an RC) diff --git a/script/release/contributors b/script/release/contributors new file mode 100755 index 00000000000..bb9fe871caf --- /dev/null +++ b/script/release/contributors @@ -0,0 +1,27 @@ +#!/bin/bash +set -e + + +function usage() { + >&2 cat << EOM +Print the list of github contributors for the release + +Usage: + + $0 +EOM + exit 1 +} + +[[ -n "$1" ]] || usage +PREV_RELEASE=$1 +VERSION=HEAD +URL="https://api.github.com/repos/docker/compose/compare" + +curl -sf "$URL/$PREV_RELEASE...$VERSION" | \ + jq -r '.commits[].author.login' | \ + sort | \ + uniq -c | \ + sort -nr | \ + awk '{print "@"$2","}' | \ + xargs echo From 58e6d4487abca4f0da7260fac6e2e6ed503d8d3b Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 14 Oct 2015 15:16:58 -0400 Subject: [PATCH 1327/4072] Fix some release docs. Signed-off-by: Daniel Nephin --- project/RELEASE-PROCESS.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/project/RELEASE-PROCESS.md b/project/RELEASE-PROCESS.md index a7fea69edf8..ffa18077f4b 100644 --- a/project/RELEASE-PROCESS.md +++ b/project/RELEASE-PROCESS.md @@ -54,7 +54,7 @@ When prompted build the non-linux binaries and test them. 2. Download the windows binary from AppVeyor - https://ci.appveyor.com/project/docker/compose/build//artifacts + https://ci.appveyor.com/project/docker/compose 3. Draft a release from the tag on GitHub (the script will open the window for you) @@ -93,7 +93,7 @@ When prompted build the non-linux binaries and test them. 8. Publish the release on GitHub. -9. Check that both binaries download (following the install instructions) and run. +9. Check that all the binaries download (following the install instructions) and run. 10. Email maintainers@dockerproject.org and engineering@docker.com about the new release. From fbe8e769028cca49401101b0bfed88d6f838d186 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 14 Oct 2015 15:20:57 -0400 Subject: [PATCH 1328/4072] Add missing merge for release branch. Signed-off-by: Daniel Nephin --- script/release/make-branch | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/script/release/make-branch b/script/release/make-branch index dde1fb65de4..e2eae4d5f25 100755 --- a/script/release/make-branch +++ b/script/release/make-branch @@ -39,7 +39,7 @@ fi DEFAULT_REMOTE=release REMOTE="$(find_remote "$GITHUB_REPO")" -# If we don't have a docker origin add one +# If we don't have a docker remote add one if [ -z "$REMOTE" ]; then echo "Creating $DEFAULT_REMOTE remote" git remote add ${DEFAULT_REMOTE} ${GITHUB_REPO} @@ -55,6 +55,8 @@ read -n1 -r -p "Continue? (ctrl+c to cancel)" git fetch $REMOTE -p git checkout -b $BRANCH $BASE_VERSION +echo "Merging remote release branch into new release branch" +git merge --strategy=ours --no-edit $REMOTE/release # Store the release version for this branch in git, so that other release # scripts can use it From 7e59a7ea32e2eb8eace45d69c4f01689fbc81fab Mon Sep 17 00:00:00 2001 From: Tim Butler Date: Thu, 15 Oct 2015 17:09:57 +1000 Subject: [PATCH 1329/4072] Fix link to Release Process doc in README.md Signed-off-by: Tim Butler --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3c776a71c2e..d779d607c35 100644 --- a/README.md +++ b/README.md @@ -56,4 +56,4 @@ Want to help build Compose? Check out our [contributing documentation](https://g Releasing --------- -Releases are built by maintainers, following an outline of the [release process](https://github.com/docker/compose/blob/master/RELEASE_PROCESS.md). +Releases are built by maintainers, following an outline of the [release process](https://github.com/docker/compose/blob/master/project/RELEASE-PROCESS.md). From c6ff81fe302a6472037b9e8796544c3ce923314f Mon Sep 17 00:00:00 2001 From: Per Persson Date: Thu, 15 Oct 2015 15:13:27 +0200 Subject: [PATCH 1330/4072] Remove incorrectly placed comment I'm not sure if it should be there at all, but at least it should hardly be where it currently is located. Signed-off-by: Per Persson --- 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 67322335a78..90730fecd04 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -48,8 +48,6 @@ See `man 7 capabilities` for a full list. - NET_ADMIN - SYS_ADMIN -Using `dockerfile` together with `image` is not allowed. Attempting to do so results in an error. - ### command Override the default command. @@ -106,6 +104,8 @@ Compose will use an alternate file to build with. dockerfile: Dockerfile-alternate +Using `dockerfile` together with `image` is not allowed. Attempting to do so results in an error. + ### env_file Add environment variables from a file. Can be a single value or a list. From ccfb6e6fa863614190b8290aab8fe6bfed5f0d06 Mon Sep 17 00:00:00 2001 From: Karol Duleba Date: Wed, 14 Oct 2015 19:06:05 +0100 Subject: [PATCH 1331/4072] Docs for shorthand notation of extends. Issue #1989 Signed-off-by: Karol Duleba --- docs/extends.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/extends.md b/docs/extends.md index d88ce61c5d9..f0b9e9ea2d6 100644 --- a/docs/extends.md +++ b/docs/extends.md @@ -59,6 +59,10 @@ You can go further and define (or re-define) configuration locally in - 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: @@ -233,7 +237,8 @@ manually keep both environments in sync. ### Reference You can use `extends` on any service together with other configuration keys. It -always expects a dictionary that should always contain the key: `service` and optionally the `file` key. +expects a dictionary that contains a `service` key and optionally a `file` key. +The `extends` key can also take a string, whose value is the name of a `service` defined in the same file. The `file` key specifies the location of a Compose configuration file defining the extension. The `file` value can be an absolute or relative path. If you From 53fc6d06f69fbe78b4b175487bd6371d323588c9 Mon Sep 17 00:00:00 2001 From: Cameron Eagans Date: Thu, 15 Oct 2015 16:58:27 -0600 Subject: [PATCH 1332/4072] docker-compose pull SERVICE should not pull SERVICE's dependencies Signed-off-by: Cameron Eagans --- compose/project.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/project.py b/compose/project.py index 0e20a4cee74..fdd70caf116 100644 --- a/compose/project.py +++ b/compose/project.py @@ -335,7 +335,7 @@ def _get_convergence_plans(self, services, strategy): return plans def pull(self, service_names=None, ignore_pull_failures=False): - for service in self.get_services(service_names, include_deps=True): + for service in self.get_services(service_names, include_deps=False): service.pull(ignore_pull_failures) def containers(self, service_names=None, stopped=False, one_off=False): From 1ed23fb2de1b3e44658ab7439763b879f439acc7 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 16 Oct 2015 11:58:27 +0530 Subject: [PATCH 1333/4072] Revert networking-related changes to getting started guides Signed-off-by: Aanand Prasad --- docs/django.md | 7 +++++-- docs/rails.md | 4 +++- docs/wordpress.md | 2 ++ 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/docs/django.md b/docs/django.md index 2ebf4b4b97d..c7ebf58bfe4 100644 --- a/docs/django.md +++ b/docs/django.md @@ -64,8 +64,9 @@ and a `docker-compose.yml` file. 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, any volumes they might - need mounted inside the containers, and any ports they might + 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](yml.md) for more information on how this file works. @@ -80,6 +81,8 @@ and a `docker-compose.yml` file. - .:/code ports: - "8000:8000" + links: + - db This file defines two services: The `db` service and the `web` service. diff --git a/docs/rails.md b/docs/rails.md index 9801ef7419f..a33cac26edb 100644 --- a/docs/rails.md +++ b/docs/rails.md @@ -37,7 +37,7 @@ Next, create a bootstrap `Gemfile` which just loads Rails. It'll be overwritten source 'https://rubygems.org' gem 'rails', '4.2.0' -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 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 @@ -48,6 +48,8 @@ Finally, `docker-compose.yml` is where the magic happens. This file describes th - .:/myapp ports: - "3000:3000" + links: + - db ### Build the project diff --git a/docs/wordpress.md b/docs/wordpress.md index 5c9bcdbd9f7..8c1f5b0acb2 100644 --- a/docs/wordpress.md +++ b/docs/wordpress.md @@ -46,6 +46,8 @@ and a separate MySQL instance: command: php -S 0.0.0.0:8000 -t /code ports: - "8000:8000" + links: + - db volumes: - .:/code db: From 7b109bc02617222fe8b9ae64435b77920200a00e Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Fri, 16 Oct 2015 12:57:54 +0100 Subject: [PATCH 1334/4072] Attempt to document escaping env vars People are likely to run into their env vars being set to empty strings, if they're not aware that they need to escape them for Compose to not interpolate them. Signed-off-by: Mazz Mosley --- docs/compose-file.md | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/docs/compose-file.md b/docs/compose-file.md index 90730fecd04..b72a7cc4372 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -428,9 +428,18 @@ Both `$VARIABLE` and `${VARIABLE}` syntax are supported. Extended shell-style features, such as `${VARIABLE-default}` and `${VARIABLE/foo/bar}`, are not supported. -If you need to put a literal dollar sign in a configuration value, use a double -dollar sign (`$$`). +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 From 49ca23c0347a99670a156b8cf1d1f9647d664814 Mon Sep 17 00:00:00 2001 From: Tim Butler Date: Thu, 15 Oct 2015 17:09:57 +1000 Subject: [PATCH 1335/4072] Fix link to Release Process doc in README.md Signed-off-by: Tim Butler --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3c776a71c2e..d779d607c35 100644 --- a/README.md +++ b/README.md @@ -56,4 +56,4 @@ Want to help build Compose? Check out our [contributing documentation](https://g Releasing --------- -Releases are built by maintainers, following an outline of the [release process](https://github.com/docker/compose/blob/master/RELEASE_PROCESS.md). +Releases are built by maintainers, following an outline of the [release process](https://github.com/docker/compose/blob/master/project/RELEASE-PROCESS.md). From 6571e079b9e7f1a67679c2cdc6322657f5030378 Mon Sep 17 00:00:00 2001 From: Per Persson Date: Thu, 15 Oct 2015 15:13:27 +0200 Subject: [PATCH 1336/4072] Remove incorrectly placed comment I'm not sure if it should be there at all, but at least it should hardly be where it currently is located. Signed-off-by: Per Persson --- 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 67322335a78..90730fecd04 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -48,8 +48,6 @@ See `man 7 capabilities` for a full list. - NET_ADMIN - SYS_ADMIN -Using `dockerfile` together with `image` is not allowed. Attempting to do so results in an error. - ### command Override the default command. @@ -106,6 +104,8 @@ Compose will use an alternate file to build with. dockerfile: Dockerfile-alternate +Using `dockerfile` together with `image` is not allowed. Attempting to do so results in an error. + ### env_file Add environment variables from a file. Can be a single value or a list. From 558098d322131e100db52382b7826d02882efc4f Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 14 Oct 2015 15:14:04 -0400 Subject: [PATCH 1337/4072] Add a script to generate contributor list. Signed-off-by: Daniel Nephin --- project/RELEASE-PROCESS.md | 11 +++++++---- script/release/contributors | 27 +++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 4 deletions(-) create mode 100755 script/release/contributors diff --git a/project/RELEASE-PROCESS.md b/project/RELEASE-PROCESS.md index 85bbaf29500..a7fea69edf8 100644 --- a/project/RELEASE-PROCESS.md +++ b/project/RELEASE-PROCESS.md @@ -82,17 +82,20 @@ When prompted build the non-linux binaries and test them. 5. Attach the binaries and `script/run.sh` -6. If everything looks good, it's time to push the release. +6. Add "Thanks" with a list of contributors. The contributor list can be generated + by running `./script/release/contributors`. + +7. If everything looks good, it's time to push the release. ./script/release/push-release -7. Publish the release on GitHub. +8. Publish the release on GitHub. -8. Check that both binaries download (following the install instructions) and run. +9. Check that both binaries download (following the install instructions) and run. -9. Email maintainers@dockerproject.org and engineering@docker.com about the new release. +10. Email maintainers@dockerproject.org and engineering@docker.com about the new release. ## If it’s a stable release (not an RC) diff --git a/script/release/contributors b/script/release/contributors new file mode 100755 index 00000000000..bb9fe871caf --- /dev/null +++ b/script/release/contributors @@ -0,0 +1,27 @@ +#!/bin/bash +set -e + + +function usage() { + >&2 cat << EOM +Print the list of github contributors for the release + +Usage: + + $0 +EOM + exit 1 +} + +[[ -n "$1" ]] || usage +PREV_RELEASE=$1 +VERSION=HEAD +URL="https://api.github.com/repos/docker/compose/compare" + +curl -sf "$URL/$PREV_RELEASE...$VERSION" | \ + jq -r '.commits[].author.login' | \ + sort | \ + uniq -c | \ + sort -nr | \ + awk '{print "@"$2","}' | \ + xargs echo From b2f9c182f3029b087aee3f027f80254eea9ca284 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 14 Oct 2015 15:16:58 -0400 Subject: [PATCH 1338/4072] Fix some release docs. Signed-off-by: Daniel Nephin --- project/RELEASE-PROCESS.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/project/RELEASE-PROCESS.md b/project/RELEASE-PROCESS.md index a7fea69edf8..ffa18077f4b 100644 --- a/project/RELEASE-PROCESS.md +++ b/project/RELEASE-PROCESS.md @@ -54,7 +54,7 @@ When prompted build the non-linux binaries and test them. 2. Download the windows binary from AppVeyor - https://ci.appveyor.com/project/docker/compose/build//artifacts + https://ci.appveyor.com/project/docker/compose 3. Draft a release from the tag on GitHub (the script will open the window for you) @@ -93,7 +93,7 @@ When prompted build the non-linux binaries and test them. 8. Publish the release on GitHub. -9. Check that both binaries download (following the install instructions) and run. +9. Check that all the binaries download (following the install instructions) and run. 10. Email maintainers@dockerproject.org and engineering@docker.com about the new release. From 284cda087e822fac205396aaacd135eb30ea60c3 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 14 Oct 2015 15:20:57 -0400 Subject: [PATCH 1339/4072] Add missing merge for release branch. Signed-off-by: Daniel Nephin --- script/release/make-branch | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/script/release/make-branch b/script/release/make-branch index dde1fb65de4..e2eae4d5f25 100755 --- a/script/release/make-branch +++ b/script/release/make-branch @@ -39,7 +39,7 @@ fi DEFAULT_REMOTE=release REMOTE="$(find_remote "$GITHUB_REPO")" -# If we don't have a docker origin add one +# If we don't have a docker remote add one if [ -z "$REMOTE" ]; then echo "Creating $DEFAULT_REMOTE remote" git remote add ${DEFAULT_REMOTE} ${GITHUB_REPO} @@ -55,6 +55,8 @@ read -n1 -r -p "Continue? (ctrl+c to cancel)" git fetch $REMOTE -p git checkout -b $BRANCH $BASE_VERSION +echo "Merging remote release branch into new release branch" +git merge --strategy=ours --no-edit $REMOTE/release # Store the release version for this branch in git, so that other release # scripts can use it From 883f251e7d3f26e3fc5cc8658edde3fdbe6c97a1 Mon Sep 17 00:00:00 2001 From: Karol Duleba Date: Wed, 14 Oct 2015 19:06:05 +0100 Subject: [PATCH 1340/4072] Docs for shorthand notation of extends. Issue #1989 Signed-off-by: Karol Duleba --- docs/extends.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/extends.md b/docs/extends.md index d88ce61c5d9..f0b9e9ea2d6 100644 --- a/docs/extends.md +++ b/docs/extends.md @@ -59,6 +59,10 @@ You can go further and define (or re-define) configuration locally in - 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: @@ -233,7 +237,8 @@ manually keep both environments in sync. ### Reference You can use `extends` on any service together with other configuration keys. It -always expects a dictionary that should always contain the key: `service` and optionally the `file` key. +expects a dictionary that contains a `service` key and optionally a `file` key. +The `extends` key can also take a string, whose value is the name of a `service` defined in the same file. The `file` key specifies the location of a Compose configuration file defining the extension. The `file` value can be an absolute or relative path. If you From 46de4411a7a4591874cfc41241e9393cc4826ea0 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 16 Oct 2015 11:58:27 +0530 Subject: [PATCH 1341/4072] Revert networking-related changes to getting started guides Signed-off-by: Aanand Prasad --- docs/django.md | 7 +++++-- docs/rails.md | 4 +++- docs/wordpress.md | 2 ++ 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/docs/django.md b/docs/django.md index 2ebf4b4b97d..c7ebf58bfe4 100644 --- a/docs/django.md +++ b/docs/django.md @@ -64,8 +64,9 @@ and a `docker-compose.yml` file. 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, any volumes they might - need mounted inside the containers, and any ports they might + 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](yml.md) for more information on how this file works. @@ -80,6 +81,8 @@ and a `docker-compose.yml` file. - .:/code ports: - "8000:8000" + links: + - db This file defines two services: The `db` service and the `web` service. diff --git a/docs/rails.md b/docs/rails.md index 9801ef7419f..a33cac26edb 100644 --- a/docs/rails.md +++ b/docs/rails.md @@ -37,7 +37,7 @@ Next, create a bootstrap `Gemfile` which just loads Rails. It'll be overwritten source 'https://rubygems.org' gem 'rails', '4.2.0' -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 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 @@ -48,6 +48,8 @@ Finally, `docker-compose.yml` is where the magic happens. This file describes th - .:/myapp ports: - "3000:3000" + links: + - db ### Build the project diff --git a/docs/wordpress.md b/docs/wordpress.md index 5c9bcdbd9f7..8c1f5b0acb2 100644 --- a/docs/wordpress.md +++ b/docs/wordpress.md @@ -46,6 +46,8 @@ and a separate MySQL instance: command: php -S 0.0.0.0:8000 -t /code ports: - "8000:8000" + links: + - db volumes: - .:/code db: From 6048630a1194d439a698050aa423fb774bf7773c Mon Sep 17 00:00:00 2001 From: Cameron Eagans Date: Thu, 15 Oct 2015 16:58:27 -0600 Subject: [PATCH 1342/4072] docker-compose pull SERVICE should not pull SERVICE's dependencies Signed-off-by: Cameron Eagans --- compose/project.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/project.py b/compose/project.py index 0e20a4cee74..fdd70caf116 100644 --- a/compose/project.py +++ b/compose/project.py @@ -335,7 +335,7 @@ def _get_convergence_plans(self, services, strategy): return plans def pull(self, service_names=None, ignore_pull_failures=False): - for service in self.get_services(service_names, include_deps=True): + for service in self.get_services(service_names, include_deps=False): service.pull(ignore_pull_failures) def containers(self, service_names=None, stopped=False, one_off=False): From 26dc0b785b064a60d8438e386358a5c051a89907 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 16 Oct 2015 14:47:04 -0400 Subject: [PATCH 1343/4072] Give the user a better error message (without a stack trace) when there is a yaml error. Signed-off-by: Daniel Nephin --- compose/config/config.py | 5 +++-- tests/unit/config/config_test.py | 13 +++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 3bcd769ad56..59b98f60923 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -610,5 +610,6 @@ def load_yaml(filename): try: with open(filename, 'r') as fh: return yaml.safe_load(fh) - except IOError as e: - raise ConfigurationError(six.text_type(e)) + except (IOError, yaml.YAMLError) as e: + error_name = getattr(e, '__module__', '') + '.' + e.__class__.__name__ + raise ConfigurationError(u"{}: {}".format(error_name, e)) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 20ae7fa3050..b4bd9c7123e 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -5,6 +5,7 @@ import tempfile from operator import itemgetter +import py import pytest from compose.config import config @@ -349,6 +350,18 @@ def test_config_invalid_environment_dict_key_raises_validation_error(self): ) ) + def test_load_yaml_with_yaml_error(self): + tmpdir = py.test.ensuretemp('invalid_yaml_test') + invalid_yaml_file = tmpdir.join('docker-compose.yml') + invalid_yaml_file.write(""" + web: + this is bogus: ok: what + """) + with pytest.raises(ConfigurationError) as exc: + config.load_yaml(str(invalid_yaml_file)) + + assert 'line 3, column 32' in exc.exconly() + class InterpolationTest(unittest.TestCase): @mock.patch.dict(os.environ) From 49b98fa111bcea0086c35d91d0e42389139906a6 Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Fri, 16 Oct 2015 12:57:54 +0100 Subject: [PATCH 1344/4072] Attempt to document escaping env vars People are likely to run into their env vars being set to empty strings, if they're not aware that they need to escape them for Compose to not interpolate them. Signed-off-by: Mazz Mosley --- docs/compose-file.md | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/docs/compose-file.md b/docs/compose-file.md index 90730fecd04..b72a7cc4372 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -428,9 +428,18 @@ Both `$VARIABLE` and `${VARIABLE}` syntax are supported. Extended shell-style features, such as `${VARIABLE-default}` and `${VARIABLE/foo/bar}`, are not supported. -If you need to put a literal dollar sign in a configuration value, use a double -dollar sign (`$$`). +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 From 43a89e1702b15a3322b084bb9bcc390dd62ada7d Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Sat, 17 Oct 2015 09:26:32 -0700 Subject: [PATCH 1345/4072] bash completion for networking options Signed-off-by: Harald Albers --- contrib/completion/bash/docker-compose | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index e64b24a0099..0eed1f18b77 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -105,11 +105,15 @@ _docker_compose_docker_compose() { --project-name|-p) return ;; + --x-network-driver) + COMPREPLY=( $( compgen -W "bridge host none overlay" -- "$cur" ) ) + return + ;; esac case "$cur" in -*) - COMPREPLY=( $( compgen -W "--help -h --verbose --version -v --file -f --project-name -p" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--file -f --help -h --project-name -p --verbose --version -v --x-networking --x-network-driver" -- "$cur" ) ) ;; *) COMPREPLY=( $( compgen -W "${commands[*]}" -- "$cur" ) ) @@ -410,6 +414,9 @@ _docker_compose() { (( counter++ )) compose_project="${words[$counter]}" ;; + --x-network-driver) + (( counter++ )) + ;; -*) ;; *) From fa44a5fac23e5b5685d355c033e12d07d2e7f0ef Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Sat, 17 Oct 2015 09:51:37 -0700 Subject: [PATCH 1346/4072] fix problem with bash completion in old bash Signed-off-by: Harald Albers --- contrib/completion/bash/docker-compose | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index e64b24a0099..7184ec00d06 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -17,6 +17,12 @@ # . ~/.docker-compose-completion.sh +# suppress trailing whitespace +__docker_compose_nospace() { + # compopt is not available in ancient bash versions + 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. @@ -255,7 +261,7 @@ _docker_compose_run() { case "$prev" in -e) COMPREPLY=( $( compgen -e -- "$cur" ) ) - compopt -o nospace + __docker_compose_nospace return ;; --entrypoint|--name|--user|-u) @@ -291,7 +297,7 @@ _docker_compose_scale() { ;; *) COMPREPLY=( $(compgen -S "=" -W "$(___docker_compose_all_services_in_compose_file)" -- "$cur") ) - compopt -o nospace + __docker_compose_nospace ;; esac } From 258c8bc54d30622a8b731d1b8f50fdd5544d2da0 Mon Sep 17 00:00:00 2001 From: Viranch Mehta Date: Sun, 18 Oct 2015 00:40:51 +0530 Subject: [PATCH 1347/4072] Fix specifies_host_port() to handle port binding with host IP but no host port Signed-off-by: Viranch Mehta --- compose/service.py | 24 +++++++++++++-- tests/unit/service_test.py | 62 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 3 deletions(-) diff --git a/compose/service.py b/compose/service.py index 5f1d59468c1..aa2e4f7844d 100644 --- a/compose/service.py +++ b/compose/service.py @@ -770,10 +770,28 @@ def custom_container_name(self): return self.options.get('container_name') def specifies_host_port(self): - for port in self.options.get('ports', []): - if ':' in str(port): + def has_host_port(binding): + _, external_bindings = split_port(binding) + + # there are no external bindings + if external_bindings is None: + return False + + # we only need to check the first binding from the range + external_binding = external_bindings[0] + + # non-tuple binding means there is a host port specified + if not isinstance(external_binding, tuple): return True - return False + + # extract actual host port from tuple of (host_ip, host_port) + _, host_port = external_binding + if host_port is not None: + return True + + return False + + return any(has_host_port(binding) for binding in self.options.get('ports', [])) def pull(self, ignore_pull_failures=False): if 'image' not in self.options: diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index c5e1a9fb060..2c46ce4022c 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -444,6 +444,68 @@ def test_config_dict_with_net_from_container(self): } self.assertEqual(config_dict, expected) + def test_specifies_host_port_with_no_ports(self): + service = Service( + 'foo', + image='foo') + self.assertEqual(service.specifies_host_port(), False) + + def test_specifies_host_port_with_container_port(self): + service = Service( + 'foo', + image='foo', + ports=["2000"]) + self.assertEqual(service.specifies_host_port(), False) + + def test_specifies_host_port_with_host_port(self): + service = Service( + 'foo', + image='foo', + ports=["1000:2000"]) + self.assertEqual(service.specifies_host_port(), True) + + def test_specifies_host_port_with_host_ip_no_port(self): + service = Service( + 'foo', + image='foo', + ports=["127.0.0.1::2000"]) + self.assertEqual(service.specifies_host_port(), False) + + def test_specifies_host_port_with_host_ip_and_port(self): + service = Service( + 'foo', + image='foo', + ports=["127.0.0.1:1000:2000"]) + self.assertEqual(service.specifies_host_port(), True) + + def test_specifies_host_port_with_container_port_range(self): + service = Service( + 'foo', + image='foo', + ports=["2000-3000"]) + self.assertEqual(service.specifies_host_port(), False) + + def test_specifies_host_port_with_host_port_range(self): + service = Service( + 'foo', + image='foo', + ports=["1000-2000:2000-3000"]) + self.assertEqual(service.specifies_host_port(), True) + + def test_specifies_host_port_with_host_ip_no_port_range(self): + service = Service( + 'foo', + image='foo', + ports=["127.0.0.1::2000-3000"]) + self.assertEqual(service.specifies_host_port(), False) + + def test_specifies_host_port_with_host_ip_and_port_range(self): + service = Service( + 'foo', + image='foo', + ports=["127.0.0.1:1000-2000:2000-3000"]) + self.assertEqual(service.specifies_host_port(), True) + class NetTestCase(unittest.TestCase): From 07e9f6500ce2f695920d0a2786c308fba750cc2e Mon Sep 17 00:00:00 2001 From: Viranch Mehta Date: Sun, 18 Oct 2015 01:06:50 +0530 Subject: [PATCH 1348/4072] Pipe curl's download directly to extract/execute program to reduce number of commands Signed-off-by: Viranch Mehta --- Dockerfile | 22 +++++++--------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/Dockerfile b/Dockerfile index c6dbdefd66b..b28a438dca8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,46 +22,38 @@ RUN curl https://get.docker.com/builds/Linux/x86_64/docker-latest \ # Build Python 2.7.9 from source RUN set -ex; \ - curl -LO https://www.python.org/ftp/python/2.7.9/Python-2.7.9.tgz; \ - tar -xzf Python-2.7.9.tgz; \ + curl -L https://www.python.org/ftp/python/2.7.9/Python-2.7.9.tgz | tar -xz; \ cd Python-2.7.9; \ ./configure --enable-shared; \ make; \ make install; \ cd ..; \ - rm -rf /Python-2.7.9; \ - rm Python-2.7.9.tgz + rm -rf /Python-2.7.9 # Build python 3.4 from source RUN set -ex; \ - curl -LO https://www.python.org/ftp/python/3.4.3/Python-3.4.3.tgz; \ - tar -xzf Python-3.4.3.tgz; \ + curl -L https://www.python.org/ftp/python/3.4.3/Python-3.4.3.tgz | tar -xz; \ cd Python-3.4.3; \ ./configure --enable-shared; \ make; \ make install; \ cd ..; \ - rm -rf /Python-3.4.3; \ - rm Python-3.4.3.tgz + rm -rf /Python-3.4.3 # Make libpython findable ENV LD_LIBRARY_PATH /usr/local/lib # Install setuptools RUN set -ex; \ - curl -LO https://bootstrap.pypa.io/ez_setup.py; \ - python ez_setup.py; \ - rm ez_setup.py + curl -L https://bootstrap.pypa.io/ez_setup.py | python # Install pip RUN set -ex; \ - curl -LO https://pypi.python.org/packages/source/p/pip/pip-7.0.1.tar.gz; \ - tar -xzf pip-7.0.1.tar.gz; \ + curl -L https://pypi.python.org/packages/source/p/pip/pip-7.0.1.tar.gz | tar -xz; \ cd pip-7.0.1; \ python setup.py install; \ cd ..; \ - rm -rf pip-7.0.1; \ - rm pip-7.0.1.tar.gz + rm -rf pip-7.0.1 # Python3 requires a valid locale RUN echo "en_US.UTF-8 UTF-8" > /etc/locale.gen && locale-gen From 37921b40dd9ca76d585af6613388fe9fdc6e147c Mon Sep 17 00:00:00 2001 From: Nicolas Delaby Date: Mon, 19 Oct 2015 10:43:30 +0200 Subject: [PATCH 1349/4072] Add trove classifier to declare supported python versions. Signed-off-by: Nicolas Delaby --- setup.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/setup.py b/setup.py index 4020122b157..bf2ee07ff89 100644 --- a/setup.py +++ b/setup.py @@ -66,4 +66,8 @@ def find_version(*file_paths): [console_scripts] docker-compose=compose.cli.main:main """, + classifiers=[ + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3.4', + ], ) From ac06366ef9081fa83c8d1fef4c67e99f28bee8ea Mon Sep 17 00:00:00 2001 From: Steve Durrheimer Date: Thu, 15 Oct 2015 23:02:40 +0200 Subject: [PATCH 1350/4072] Add zsh completion for 'docker-compose --x-networking --x-network-driver' Signed-off-by: Steve Durrheimer --- contrib/completion/zsh/_docker-compose | 2 ++ 1 file changed, 2 insertions(+) diff --git a/contrib/completion/zsh/_docker-compose b/contrib/completion/zsh/_docker-compose index cefcb109e2f..d79b25d165f 100644 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -330,6 +330,8 @@ _docker-compose() { '(- :)'{-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:' \ + '--x-networking[(EXPERIMENTAL) Use new Docker networking functionality. Requires Docker 1.9 or later.]' \ + '--x-network-driver[(EXPERIMENTAL) Specify a network driver (default: "bridge"). Requires Docker 1.9 or later.]:Network Driver:(bridge host none overlay)' \ '(-): :->command' \ '(-)*:: :->option-or-argument' && ret=0 From 4d8e667c3e475e811b8072647833067740c810c2 Mon Sep 17 00:00:00 2001 From: Corin Lawson Date: Mon, 19 Oct 2015 21:47:01 +1100 Subject: [PATCH 1351/4072] Powershell script to run compose in a container. This script assumes the typical environment that Windows users operate, namely, VirtualBox running the boot2docker ISO, managed by docker-machine. I wrote this script for my Windows using colleagues and first placed it in the public domain as a gist: https://gist.github.com/au-phiware/25213e72c80040f398ba In short, that script works for me. I have adapted that script to use the (yet to be) official image (docker/compose:latest) and also added an extra environment variable to provide additional options to docker run. Signed-off-by: Corin Lawson --- script/run.ps1 | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 script/run.ps1 diff --git a/script/run.ps1 b/script/run.ps1 new file mode 100644 index 00000000000..47ec546925c --- /dev/null +++ b/script/run.ps1 @@ -0,0 +1,22 @@ +# Run docker-compose in a container via boot2docker. +# +# The current directory will be mirrored as a volume and additional +# volumes (or any other options) can be mounted by using +# $Env:DOCKER_COMPOSE_OPTIONS. + +if ($Env:DOCKER_COMPOSE_VERSION -eq $null -or $Env:DOCKER_COMPOSE_VERSION.Length -eq 0) { + $Env:DOCKER_COMPOSE_VERSION = "latest" +} + +if ($Env:DOCKER_COMPOSE_OPTIONS -eq $null) { + $Env:DOCKER_COMPOSE_OPTIONS = "" +} + +if (-not $Env:DOCKER_HOST) { + docker-machine env --shell=powershell default | Invoke-Expression + if (-not $?) { exit $LastExitCode } +} + +$local="/$($PWD -replace '^(.):(.*)$', '"$1".ToLower()+"$2".Replace("\","/")' | Invoke-Expression)" +docker run --rm -ti -v /var/run/docker.sock:/var/run/docker.sock -v "${local}:$local" -w "$local" $Env:DOCKER_COMPOSE_OPTIONS "docker/compose:$Env:DOCKER_COMPOSE_VERSION" $args +exit $LastExitCode From ff83c459d04491faedb69db9ef74f65e00c2d116 Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Mon, 19 Oct 2015 14:36:56 +0100 Subject: [PATCH 1352/4072] Improve error message for type constraints It was missing a space between the different types, when there were 3 possible type values. Signed-off-by: Mazz Mosley --- compose/config/validation.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/compose/config/validation.py b/compose/config/validation.py index 46b891b71b8..19faa0bcedd 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -143,7 +143,7 @@ def _parse_valid_types_from_validator(validator): if len(validator) >= 2: first_type = anglicize_validator(validator[0]) last_type = anglicize_validator(validator[-1]) - types_from_validator = "{}{}".format(first_type, ", ".join(validator[1:-1])) + types_from_validator = "{}".format(", ".join([first_type] + validator[1:-1])) msg = "{} or {}".format( types_from_validator, @@ -163,7 +163,6 @@ def _parse_oneof_validator(error): Inspecting the context value of a ValidationError gives us information about which sub schema failed and which kind of error it is. """ - required = [context for context in error.context if context.validator == 'required'] if required: return required[0].message From 6f45eb795976c6a4a8b7014b250ecc583b121d40 Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Sat, 17 Oct 2015 09:26:32 -0700 Subject: [PATCH 1353/4072] bash completion for networking options Signed-off-by: Harald Albers --- contrib/completion/bash/docker-compose | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index e64b24a0099..0eed1f18b77 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -105,11 +105,15 @@ _docker_compose_docker_compose() { --project-name|-p) return ;; + --x-network-driver) + COMPREPLY=( $( compgen -W "bridge host none overlay" -- "$cur" ) ) + return + ;; esac case "$cur" in -*) - COMPREPLY=( $( compgen -W "--help -h --verbose --version -v --file -f --project-name -p" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--file -f --help -h --project-name -p --verbose --version -v --x-networking --x-network-driver" -- "$cur" ) ) ;; *) COMPREPLY=( $( compgen -W "${commands[*]}" -- "$cur" ) ) @@ -410,6 +414,9 @@ _docker_compose() { (( counter++ )) compose_project="${words[$counter]}" ;; + --x-network-driver) + (( counter++ )) + ;; -*) ;; *) From 20d34c8b14113eb430c0d350c23f0088b6cd8047 Mon Sep 17 00:00:00 2001 From: Steve Durrheimer Date: Thu, 15 Oct 2015 23:02:40 +0200 Subject: [PATCH 1354/4072] Add zsh completion for 'docker-compose --x-networking --x-network-driver' Signed-off-by: Steve Durrheimer --- contrib/completion/zsh/_docker-compose | 2 ++ 1 file changed, 2 insertions(+) diff --git a/contrib/completion/zsh/_docker-compose b/contrib/completion/zsh/_docker-compose index cefcb109e2f..d79b25d165f 100644 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -330,6 +330,8 @@ _docker-compose() { '(- :)'{-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:' \ + '--x-networking[(EXPERIMENTAL) Use new Docker networking functionality. Requires Docker 1.9 or later.]' \ + '--x-network-driver[(EXPERIMENTAL) Specify a network driver (default: "bridge"). Requires Docker 1.9 or later.]:Network Driver:(bridge host none overlay)' \ '(-): :->command' \ '(-)*:: :->option-or-argument' && ret=0 From 08add665e98044279ead90e093d40eb2161efb27 Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Mon, 19 Oct 2015 15:15:24 +0100 Subject: [PATCH 1355/4072] Environment keys can contain empty values Environment keys that contain no value, get populated with values taken from the environment not from the build phase but from running the command `up`. Signed-off-by: Mazz Mosley --- compose/config/fields_schema.json | 2 +- compose/config/validation.py | 2 +- tests/unit/config/config_test.py | 17 +++++++++++++++++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/compose/config/fields_schema.json b/compose/config/fields_schema.json index cc37f444de6..e254e3539f7 100644 --- a/compose/config/fields_schema.json +++ b/compose/config/fields_schema.json @@ -41,7 +41,7 @@ "type": "object", "patternProperties": { "^[^-]+$": { - "type": ["string", "number", "boolean"], + "type": ["string", "number", "boolean", "null"], "format": "environment" } }, diff --git a/compose/config/validation.py b/compose/config/validation.py index 19faa0bcedd..427f21adbca 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -143,7 +143,7 @@ def _parse_valid_types_from_validator(validator): if len(validator) >= 2: first_type = anglicize_validator(validator[0]) last_type = anglicize_validator(validator[-1]) - types_from_validator = "{}".format(", ".join([first_type] + validator[1:-1])) + types_from_validator = ", ".join([first_type] + validator[1:-1]) msg = "{} or {}".format( types_from_validator, diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 20ae7fa3050..935e2f9d602 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -414,6 +414,23 @@ def test_invalid_interpolation(self): self.assertIn('in service "web"', cm.exception.msg) self.assertIn('"${"', cm.exception.msg) + def test_empty_environment_key_allowed(self): + service_dict = config.load( + build_config_details( + { + 'web': { + 'build': '.', + 'environment': { + 'POSTGRES_PASSWORD': '' + }, + }, + }, + '.', + None, + ) + )[0] + self.assertEquals(service_dict['environment']['POSTGRES_PASSWORD'], '') + class VolumeConfigTest(unittest.TestCase): def test_no_binding(self): From 514f0650b2a857d6516d6ceb69aba6335d5618f2 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 16 Oct 2015 14:47:04 -0400 Subject: [PATCH 1356/4072] Give the user a better error message (without a stack trace) when there is a yaml error. Signed-off-by: Daniel Nephin --- compose/config/config.py | 5 +++-- tests/unit/config/config_test.py | 13 +++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 3bcd769ad56..59b98f60923 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -610,5 +610,6 @@ def load_yaml(filename): try: with open(filename, 'r') as fh: return yaml.safe_load(fh) - except IOError as e: - raise ConfigurationError(six.text_type(e)) + except (IOError, yaml.YAMLError) as e: + error_name = getattr(e, '__module__', '') + '.' + e.__class__.__name__ + raise ConfigurationError(u"{}: {}".format(error_name, e)) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 20ae7fa3050..b4bd9c7123e 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -5,6 +5,7 @@ import tempfile from operator import itemgetter +import py import pytest from compose.config import config @@ -349,6 +350,18 @@ def test_config_invalid_environment_dict_key_raises_validation_error(self): ) ) + def test_load_yaml_with_yaml_error(self): + tmpdir = py.test.ensuretemp('invalid_yaml_test') + invalid_yaml_file = tmpdir.join('docker-compose.yml') + invalid_yaml_file.write(""" + web: + this is bogus: ok: what + """) + with pytest.raises(ConfigurationError) as exc: + config.load_yaml(str(invalid_yaml_file)) + + assert 'line 3, column 32' in exc.exconly() + class InterpolationTest(unittest.TestCase): @mock.patch.dict(os.environ) From 129e2f94826ad01b559c5b0e7f1ebc70ec7c97d8 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 16 Oct 2015 12:54:31 -0400 Subject: [PATCH 1357/4072] Fix check for tags. If there is a branch with the same name as a tag it fails without the --tags. This was only a problem when we're branching from a git tag. Signed-off-by: Daniel Nephin --- script/release/make-branch | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/release/make-branch b/script/release/make-branch index e2eae4d5f25..48fa771b4cb 100755 --- a/script/release/make-branch +++ b/script/release/make-branch @@ -46,7 +46,7 @@ if [ -z "$REMOTE" ]; then fi # handle the difference between a branch and a tag -if [ -z "$(git name-rev $BASE_VERSION | grep tags)" ]; then +if [ -z "$(git name-rev --tags $BASE_VERSION | grep tags)" ]; then BASE_VERSION=$REMOTE/$BASE_VERSION fi From 937e087c6cf78d3a12ce940ad96344fbb296b005 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 16 Oct 2015 15:45:56 -0400 Subject: [PATCH 1358/4072] Fixes #2203 - properly validate files when multiple files are used. Remove the single-use decorators so the functionality can be used directly as a function. Signed-off-by: Daniel Nephin --- compose/config/config.py | 22 +++++++-------------- compose/config/validation.py | 33 +++++++++++++------------------- tests/unit/config/config_test.py | 22 +++++++++++++++++++++ 3 files changed, 42 insertions(+), 35 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 59b98f60923..05e57258579 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -14,7 +14,6 @@ from .validation import validate_against_service_schema from .validation import validate_extended_service_exists from .validation import validate_extends_file_path -from .validation import validate_service_names from .validation import validate_top_level_object @@ -165,16 +164,6 @@ def find_candidates_in_parent_dirs(filenames, path): return (candidates, path) -@validate_top_level_object -@validate_service_names -def pre_process_config(config): - """ - Pre validation checks and processing of the config file to interpolate env - vars returning a config dict ready to be tested against the schema. - """ - return interpolate_environment_variables(config) - - def load(config_details): """Load the configuration from a working directory and a list of configuration files. Files are loaded in order, and merged on top @@ -194,7 +183,7 @@ def build_service(filename, service_name, service_dict): return service_dict def load_file(filename, config): - processed_config = pre_process_config(config) + processed_config = interpolate_environment_variables(config) validate_against_fields_schema(processed_config) return [ build_service(filename, name, service_config) @@ -209,7 +198,10 @@ def merge_services(base, override): } config_file = config_details.config_files[0] + validate_top_level_object(config_file.config) for next_file in config_details.config_files[1:]: + validate_top_level_object(next_file.config) + config_file = ConfigFile( config_file.filename, merge_services(config_file.config, next_file.config)) @@ -283,9 +275,9 @@ def validate_and_construct_extends(self): ) self.extended_service_name = extends['service'] - full_extended_config = pre_process_config( - load_yaml(self.extended_config_path) - ) + config = load_yaml(self.extended_config_path) + validate_top_level_object(config) + full_extended_config = interpolate_environment_variables(config) validate_extended_service_exists( self.extended_service_name, diff --git a/compose/config/validation.py b/compose/config/validation.py index 46b891b71b8..aea722864c2 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -2,7 +2,6 @@ import logging import os import sys -from functools import wraps import six from docker.utils.ports import split_port @@ -65,27 +64,21 @@ def format_boolean_in_environment(instance): return True -def validate_service_names(func): - @wraps(func) - def func_wrapper(config): - for service_name in config.keys(): - if type(service_name) is int: - raise ConfigurationError( - "Service name: {} needs to be a string, eg '{}'".format(service_name, service_name) - ) - return func(config) - return func_wrapper +def validate_service_names(config): + for service_name in config.keys(): + if not isinstance(service_name, six.string_types): + raise ConfigurationError( + "Service name: {} needs to be a string, eg '{}'".format( + service_name, + service_name)) -def validate_top_level_object(func): - @wraps(func) - def func_wrapper(config): - if not isinstance(config, dict): - raise ConfigurationError( - "Top level object needs to be a dictionary. Check your .yml file that you have defined a service at the top level." - ) - return func(config) - return func_wrapper +def validate_top_level_object(config): + if not isinstance(config, dict): + raise ConfigurationError( + "Top level object needs to be a dictionary. Check your .yml file " + "that you have defined a service at the top level.") + validate_service_names(config) def validate_extends_file_path(service_name, extends_options, filename): diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index b4bd9c7123e..e8caea814d2 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -134,6 +134,28 @@ def test_load_with_multiple_files(self): ] self.assertEqual(service_sort(service_dicts), service_sort(expected)) + def test_load_with_multiple_files_and_empty_override(self): + base_file = config.ConfigFile( + 'base.yaml', + {'web': {'image': 'example/web'}}) + override_file = config.ConfigFile('override.yaml', None) + details = config.ConfigDetails('.', [base_file, override_file]) + + with pytest.raises(ConfigurationError) as exc: + config.load(details) + assert 'Top level object needs to be a dictionary' in exc.exconly() + + def test_load_with_multiple_files_and_empty_base(self): + base_file = config.ConfigFile('base.yaml', None) + override_file = config.ConfigFile( + 'override.yaml', + {'web': {'image': 'example/web'}}) + details = config.ConfigDetails('.', [base_file, override_file]) + + with pytest.raises(ConfigurationError) as exc: + config.load(details) + assert 'Top level object needs to be a dictionary' in exc.exconly() + def test_config_valid_service_names(self): for valid_name in ['_', '-', '.__.', '_what-up.', 'what_.up----', 'whatup']: config.load( From 938d49cbdc81665de26c7148befc833b50e867e9 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 16 Oct 2015 11:18:07 -0400 Subject: [PATCH 1359/4072] Fixes #2205 - extends must be copied from override file. Signed-off-by: Daniel Nephin --- compose/config/config.py | 19 ++++++++++++---- tests/unit/config/config_test.py | 37 ++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 05e57258579..ff8861b51ab 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -193,7 +193,9 @@ def load_file(filename, config): def merge_services(base, override): all_service_names = set(base) | set(override) return { - name: merge_service_dicts(base.get(name, {}), override.get(name, {})) + name: merge_service_dicts_from_files( + base.get(name, {}), + override.get(name, {})) for name in all_service_names } @@ -270,9 +272,7 @@ def validate_and_construct_extends(self): extends, self.filename ) - self.extended_config_path = self.get_extended_config_path( - extends - ) + self.extended_config_path = self.get_extended_config_path(extends) self.extended_service_name = extends['service'] config = load_yaml(self.extended_config_path) @@ -355,6 +355,17 @@ def process_container_options(service_dict, working_dir=None): return service_dict +def merge_service_dicts_from_files(base, override): + """When merging services from multiple files we need to merge the `extends` + field. This is not handled by `merge_service_dicts()` which is used to + perform the `extends`. + """ + new_service = merge_service_dicts(base, override) + if 'extends' in override: + new_service['extends'] = override['extends'] + return new_service + + def merge_service_dicts(base, override): d = base.copy() diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index e8caea814d2..eea3451f914 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -156,6 +156,43 @@ def test_load_with_multiple_files_and_empty_base(self): config.load(details) assert 'Top level object needs to be a dictionary' in exc.exconly() + def test_load_with_multiple_files_and_extends_in_override_file(self): + base_file = config.ConfigFile( + 'base.yaml', + { + 'web': {'image': 'example/web'}, + }) + override_file = config.ConfigFile( + 'override.yaml', + { + 'web': { + 'extends': { + 'file': 'common.yml', + 'service': 'base', + }, + 'volumes': ['/home/user/project:/code'], + }, + }) + details = config.ConfigDetails('.', [base_file, override_file]) + + tmpdir = py.test.ensuretemp('config_test') + tmpdir.join('common.yml').write(""" + base: + labels: ['label=one'] + """) + with tmpdir.as_cwd(): + service_dicts = config.load(details) + + expected = [ + { + 'name': 'web', + 'image': 'example/web', + 'volumes': ['/home/user/project:/code'], + 'labels': {'label': 'one'}, + }, + ] + self.assertEqual(service_sort(service_dicts), service_sort(expected)) + def test_config_valid_service_names(self): for valid_name in ['_', '-', '.__.', '_what-up.', 'what_.up----', 'whatup']: config.load( From 24d4a1045a6317a612f856af3cc99e1b301eae49 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 16 Oct 2015 15:45:56 -0400 Subject: [PATCH 1360/4072] Fixes #2203 - properly validate files when multiple files are used. Remove the single-use decorators so the functionality can be used directly as a function. Signed-off-by: Daniel Nephin --- compose/config/config.py | 22 +++++++-------------- compose/config/validation.py | 33 +++++++++++++------------------- tests/unit/config/config_test.py | 22 +++++++++++++++++++++ 3 files changed, 42 insertions(+), 35 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 59b98f60923..05e57258579 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -14,7 +14,6 @@ from .validation import validate_against_service_schema from .validation import validate_extended_service_exists from .validation import validate_extends_file_path -from .validation import validate_service_names from .validation import validate_top_level_object @@ -165,16 +164,6 @@ def find_candidates_in_parent_dirs(filenames, path): return (candidates, path) -@validate_top_level_object -@validate_service_names -def pre_process_config(config): - """ - Pre validation checks and processing of the config file to interpolate env - vars returning a config dict ready to be tested against the schema. - """ - return interpolate_environment_variables(config) - - def load(config_details): """Load the configuration from a working directory and a list of configuration files. Files are loaded in order, and merged on top @@ -194,7 +183,7 @@ def build_service(filename, service_name, service_dict): return service_dict def load_file(filename, config): - processed_config = pre_process_config(config) + processed_config = interpolate_environment_variables(config) validate_against_fields_schema(processed_config) return [ build_service(filename, name, service_config) @@ -209,7 +198,10 @@ def merge_services(base, override): } config_file = config_details.config_files[0] + validate_top_level_object(config_file.config) for next_file in config_details.config_files[1:]: + validate_top_level_object(next_file.config) + config_file = ConfigFile( config_file.filename, merge_services(config_file.config, next_file.config)) @@ -283,9 +275,9 @@ def validate_and_construct_extends(self): ) self.extended_service_name = extends['service'] - full_extended_config = pre_process_config( - load_yaml(self.extended_config_path) - ) + config = load_yaml(self.extended_config_path) + validate_top_level_object(config) + full_extended_config = interpolate_environment_variables(config) validate_extended_service_exists( self.extended_service_name, diff --git a/compose/config/validation.py b/compose/config/validation.py index 46b891b71b8..aea722864c2 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -2,7 +2,6 @@ import logging import os import sys -from functools import wraps import six from docker.utils.ports import split_port @@ -65,27 +64,21 @@ def format_boolean_in_environment(instance): return True -def validate_service_names(func): - @wraps(func) - def func_wrapper(config): - for service_name in config.keys(): - if type(service_name) is int: - raise ConfigurationError( - "Service name: {} needs to be a string, eg '{}'".format(service_name, service_name) - ) - return func(config) - return func_wrapper +def validate_service_names(config): + for service_name in config.keys(): + if not isinstance(service_name, six.string_types): + raise ConfigurationError( + "Service name: {} needs to be a string, eg '{}'".format( + service_name, + service_name)) -def validate_top_level_object(func): - @wraps(func) - def func_wrapper(config): - if not isinstance(config, dict): - raise ConfigurationError( - "Top level object needs to be a dictionary. Check your .yml file that you have defined a service at the top level." - ) - return func(config) - return func_wrapper +def validate_top_level_object(config): + if not isinstance(config, dict): + raise ConfigurationError( + "Top level object needs to be a dictionary. Check your .yml file " + "that you have defined a service at the top level.") + validate_service_names(config) def validate_extends_file_path(service_name, extends_options, filename): diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index b4bd9c7123e..e8caea814d2 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -134,6 +134,28 @@ def test_load_with_multiple_files(self): ] self.assertEqual(service_sort(service_dicts), service_sort(expected)) + def test_load_with_multiple_files_and_empty_override(self): + base_file = config.ConfigFile( + 'base.yaml', + {'web': {'image': 'example/web'}}) + override_file = config.ConfigFile('override.yaml', None) + details = config.ConfigDetails('.', [base_file, override_file]) + + with pytest.raises(ConfigurationError) as exc: + config.load(details) + assert 'Top level object needs to be a dictionary' in exc.exconly() + + def test_load_with_multiple_files_and_empty_base(self): + base_file = config.ConfigFile('base.yaml', None) + override_file = config.ConfigFile( + 'override.yaml', + {'web': {'image': 'example/web'}}) + details = config.ConfigDetails('.', [base_file, override_file]) + + with pytest.raises(ConfigurationError) as exc: + config.load(details) + assert 'Top level object needs to be a dictionary' in exc.exconly() + def test_config_valid_service_names(self): for valid_name in ['_', '-', '.__.', '_what-up.', 'what_.up----', 'whatup']: config.load( From 5fdb75b541cee7a6a26e7b9e5a6483afebc88174 Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Mon, 19 Oct 2015 14:36:56 +0100 Subject: [PATCH 1361/4072] Improve error message for type constraints It was missing a space between the different types, when there were 3 possible type values. Signed-off-by: Mazz Mosley --- compose/config/validation.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/compose/config/validation.py b/compose/config/validation.py index aea722864c2..b21e12cb843 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -136,7 +136,7 @@ def _parse_valid_types_from_validator(validator): if len(validator) >= 2: first_type = anglicize_validator(validator[0]) last_type = anglicize_validator(validator[-1]) - types_from_validator = "{}{}".format(first_type, ", ".join(validator[1:-1])) + types_from_validator = "{}".format(", ".join([first_type] + validator[1:-1])) msg = "{} or {}".format( types_from_validator, @@ -156,7 +156,6 @@ def _parse_oneof_validator(error): Inspecting the context value of a ValidationError gives us information about which sub schema failed and which kind of error it is. """ - required = [context for context in error.context if context.validator == 'required'] if required: return required[0].message From 0e4f9c9a66f844cf725870c21cb5b5318b28ce16 Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Mon, 19 Oct 2015 15:15:24 +0100 Subject: [PATCH 1362/4072] Environment keys can contain empty values Environment keys that contain no value, get populated with values taken from the environment not from the build phase but from running the command `up`. Signed-off-by: Mazz Mosley --- compose/config/fields_schema.json | 2 +- compose/config/validation.py | 2 +- tests/unit/config/config_test.py | 17 +++++++++++++++++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/compose/config/fields_schema.json b/compose/config/fields_schema.json index cc37f444de6..e254e3539f7 100644 --- a/compose/config/fields_schema.json +++ b/compose/config/fields_schema.json @@ -41,7 +41,7 @@ "type": "object", "patternProperties": { "^[^-]+$": { - "type": ["string", "number", "boolean"], + "type": ["string", "number", "boolean", "null"], "format": "environment" } }, diff --git a/compose/config/validation.py b/compose/config/validation.py index b21e12cb843..8cfc405fe86 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -136,7 +136,7 @@ def _parse_valid_types_from_validator(validator): if len(validator) >= 2: first_type = anglicize_validator(validator[0]) last_type = anglicize_validator(validator[-1]) - types_from_validator = "{}".format(", ".join([first_type] + validator[1:-1])) + types_from_validator = ", ".join([first_type] + validator[1:-1]) msg = "{} or {}".format( types_from_validator, diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index e8caea814d2..e32e5b47c1a 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -449,6 +449,23 @@ def test_invalid_interpolation(self): self.assertIn('in service "web"', cm.exception.msg) self.assertIn('"${"', cm.exception.msg) + def test_empty_environment_key_allowed(self): + service_dict = config.load( + build_config_details( + { + 'web': { + 'build': '.', + 'environment': { + 'POSTGRES_PASSWORD': '' + }, + }, + }, + '.', + None, + ) + )[0] + self.assertEquals(service_dict['environment']['POSTGRES_PASSWORD'], '') + class VolumeConfigTest(unittest.TestCase): def test_no_binding(self): From bf672ec3405c31a4c9d64984876665c68128c7fe Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 16 Oct 2015 11:18:07 -0400 Subject: [PATCH 1363/4072] Fixes #2205 - extends must be copied from override file. Signed-off-by: Daniel Nephin --- compose/config/config.py | 19 ++++++++++++---- tests/unit/config/config_test.py | 37 ++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 05e57258579..ff8861b51ab 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -193,7 +193,9 @@ def load_file(filename, config): def merge_services(base, override): all_service_names = set(base) | set(override) return { - name: merge_service_dicts(base.get(name, {}), override.get(name, {})) + name: merge_service_dicts_from_files( + base.get(name, {}), + override.get(name, {})) for name in all_service_names } @@ -270,9 +272,7 @@ def validate_and_construct_extends(self): extends, self.filename ) - self.extended_config_path = self.get_extended_config_path( - extends - ) + self.extended_config_path = self.get_extended_config_path(extends) self.extended_service_name = extends['service'] config = load_yaml(self.extended_config_path) @@ -355,6 +355,17 @@ def process_container_options(service_dict, working_dir=None): return service_dict +def merge_service_dicts_from_files(base, override): + """When merging services from multiple files we need to merge the `extends` + field. This is not handled by `merge_service_dicts()` which is used to + perform the `extends`. + """ + new_service = merge_service_dicts(base, override) + if 'extends' in override: + new_service['extends'] = override['extends'] + return new_service + + def merge_service_dicts(base, override): d = base.copy() diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index e32e5b47c1a..c8b76f36d08 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -156,6 +156,43 @@ def test_load_with_multiple_files_and_empty_base(self): config.load(details) assert 'Top level object needs to be a dictionary' in exc.exconly() + def test_load_with_multiple_files_and_extends_in_override_file(self): + base_file = config.ConfigFile( + 'base.yaml', + { + 'web': {'image': 'example/web'}, + }) + override_file = config.ConfigFile( + 'override.yaml', + { + 'web': { + 'extends': { + 'file': 'common.yml', + 'service': 'base', + }, + 'volumes': ['/home/user/project:/code'], + }, + }) + details = config.ConfigDetails('.', [base_file, override_file]) + + tmpdir = py.test.ensuretemp('config_test') + tmpdir.join('common.yml').write(""" + base: + labels: ['label=one'] + """) + with tmpdir.as_cwd(): + service_dicts = config.load(details) + + expected = [ + { + 'name': 'web', + 'image': 'example/web', + 'volumes': ['/home/user/project:/code'], + 'labels': {'label': 'one'}, + }, + ] + self.assertEqual(service_sort(service_dicts), service_sort(expected)) + def test_config_valid_service_names(self): for valid_name in ['_', '-', '.__.', '_what-up.', 'what_.up----', 'whatup']: config.load( From 3f0e0835850462b750167f708d17793b45dc9ef6 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 20 Oct 2015 11:39:06 -0400 Subject: [PATCH 1364/4072] Force windows drives to be lowercase. Signed-off-by: Daniel Nephin --- compose/service.py | 2 +- tests/unit/config/config_test.py | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/compose/service.py b/compose/service.py index 5f1d59468c1..7daf7f2f9c4 100644 --- a/compose/service.py +++ b/compose/service.py @@ -952,7 +952,7 @@ def normalize_paths_for_engine(external_path, internal_path): drive, tail = os.path.splitdrive(external_path) if drive: - reformatted_drive = "/{}".format(drive.replace(":", "")) + reformatted_drive = "/{}".format(drive.lower().replace(":", "")) external_path = reformatted_drive + tail external_path = "/".join(external_path.split("\\")) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index c8b76f36d08..d15cd9a6894 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -537,8 +537,8 @@ def test_absolute_posix_path_does_not_expand(self): self.assertEqual(d['volumes'], ['/var/lib/data:/data']) def test_absolute_windows_path_does_not_expand(self): - d = make_service_dict('foo', {'build': '.', 'volumes': ['C:\\data:/data']}, working_dir='.') - self.assertEqual(d['volumes'], ['C:\\data:/data']) + d = make_service_dict('foo', {'build': '.', 'volumes': ['c:\\data:/data']}, working_dir='.') + self.assertEqual(d['volumes'], ['c:\\data:/data']) @pytest.mark.skipif(IS_WINDOWS_PLATFORM, reason='posix paths') def test_relative_path_does_expand_posix(self): @@ -553,14 +553,14 @@ def test_relative_path_does_expand_posix(self): @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') - self.assertEqual(d['volumes'], ['C:\\Users\\me\\myproject\\data:/data']) + 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') - self.assertEqual(d['volumes'], ['C:\\Users\\me\\myproject:/data']) + 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') - self.assertEqual(d['volumes'], ['C:\\Users\\me\\otherproject:/data']) + 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) def test_home_directory_with_driver_does_not_expand(self): From 5523c3d7457f69732e269f7dc45eec9808ba2170 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 20 Oct 2015 11:49:10 -0400 Subject: [PATCH 1365/4072] Minor refactor to use guard and replace instead of split+join Signed-off-by: Daniel Nephin --- compose/service.py | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/compose/service.py b/compose/service.py index 7daf7f2f9c4..f18afa485bf 100644 --- a/compose/service.py +++ b/compose/service.py @@ -943,23 +943,21 @@ def build_volume_binding(volume_spec): def normalize_paths_for_engine(external_path, internal_path): - """ - Windows paths, c:\my\path\shiny, need to be changed to be compatible with + """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 IS_WINDOWS_PLATFORM: - if external_path: - drive, tail = os.path.splitdrive(external_path) + if not IS_WINDOWS_PLATFORM: + return external_path, internal_path - if drive: - reformatted_drive = "/{}".format(drive.lower().replace(":", "")) - external_path = reformatted_drive + tail + if external_path: + drive, tail = os.path.splitdrive(external_path) - external_path = "/".join(external_path.split("\\")) + if drive: + external_path = '/' + drive.lower().rstrip(':') + tail - return external_path, "/".join(internal_path.split("\\")) - else: - return external_path, internal_path + external_path = external_path.replace('\\', '/') + + return external_path, internal_path.replace('\\', '/') def parse_volume_spec(volume_config): From b1f8ed84a303d5300d3b27a387799d582c55ae2d Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 20 Oct 2015 13:10:32 -0400 Subject: [PATCH 1366/4072] Cleanup some unit tests and whitespace. Remove some unnecessary newlines. Remove a unittest that was attempting to test behaviour that was removed a while ago, so isn't testing anything. Updated some unit tests to use mocks instead of a custom fake. Signed-off-by: Daniel Nephin --- compose/service.py | 12 +++------ tests/unit/service_test.py | 50 ++++++++++++++------------------------ 2 files changed, 21 insertions(+), 41 deletions(-) diff --git a/compose/service.py b/compose/service.py index 5f1d59468c1..7862867f284 100644 --- a/compose/service.py +++ b/compose/service.py @@ -300,9 +300,7 @@ def create_container(self, Create a container for this service. If the image doesn't exist, attempt to pull it. """ - self.ensure_image_exists( - do_build=do_build, - ) + self.ensure_image_exists(do_build=do_build) container_options = self._get_container_create_options( override_options, @@ -316,9 +314,7 @@ def create_container(self, return Container.create(self.client, **container_options) - def ensure_image_exists(self, - do_build=True): - + def ensure_image_exists(self, do_build=True): try: self.image() return @@ -403,9 +399,7 @@ def execute_convergence_plan(self, (action, containers) = plan if action == 'create': - container = self.create_container( - do_build=do_build, - ) + container = self.create_container(do_build=do_build) self.start_container(container) return [container] diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index c5e1a9fb060..03575f9374a 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -351,44 +351,37 @@ def test_parse_repository_tag(self): 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", "@")) - @mock.patch('compose.service.Container', autospec=True) - def test_create_container_latest_is_used_when_no_tag_specified(self, mock_container): - service = Service('foo', client=self.mock_client, image='someimage') - images = [] - - def pull(repo, tag=None, **kwargs): - self.assertEqual('someimage', repo) - self.assertEqual('latest', tag) - images.append({'Id': 'abc123'}) - return [] - - service.image = lambda *args, **kwargs: mock_get_image(images) - self.mock_client.pull = pull - - service.create_container() - self.assertEqual(1, len(images)) - def test_create_container_with_build(self): service = Service('foo', client=self.mock_client, build='.') - - images = [] - service.image = lambda *args, **kwargs: mock_get_image(images) - service.build = lambda: images.append({'Id': 'abc123'}) + self.mock_client.inspect_image.side_effect = [ + NoSuchImageError, + {'Id': 'abc123'}, + ] + self.mock_client.build.return_value = [ + '{"stream": "Successfully built abcd"}', + ] service.create_container(do_build=True) - self.assertEqual(1, len(images)) + self.mock_client.build.assert_called_once_with( + tag='default_foo', + dockerfile=None, + stream=True, + path='.', + pull=False, + nocache=False, + rm=True, + ) def test_create_container_no_build(self): service = Service('foo', client=self.mock_client, build='.') - service.image = lambda: {'Id': 'abc123'} + self.mock_client.inspect_image.return_value = {'Id': 'abc123'} service.create_container(do_build=False) 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='.') - service.image = lambda *args, **kwargs: mock_get_image([]) - + self.mock_client.inspect_image.side_effect = NoSuchImageError with self.assertRaises(NeedsBuildError): service.create_container(do_build=False) @@ -488,13 +481,6 @@ def test_net_service_no_containers(self): self.assertEqual(net.service_name, service_name) -def mock_get_image(images): - if images: - return images[0] - else: - raise NoSuchImageError() - - class ServiceVolumesTest(unittest.TestCase): def setUp(self): From 45056322748514cf66632409d7fe4ad12aeef533 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 19 Oct 2015 12:52:38 -0400 Subject: [PATCH 1367/4072] Some minor style cleanup - fixed a docstring to make it PEP257 compliant - wrapped some long lines - used a more specific error Signed-off-by: Daniel Nephin --- compose/config/config.py | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index ff8861b51ab..440f39203e6 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -212,9 +212,16 @@ def merge_services(base, override): class ServiceLoader(object): - def __init__(self, working_dir, filename, service_name, service_dict, already_seen=None): + def __init__( + self, + working_dir, + filename, + service_name, + service_dict, + already_seen=None + ): if working_dir is None: - raise Exception("No working_dir passed to ServiceLoader()") + raise ValueError("No working_dir passed to ServiceLoader()") self.working_dir = os.path.abspath(working_dir) @@ -311,33 +318,33 @@ def resolve_extends(self): return merge_service_dicts(other_service_dict, self.service_dict) def get_extended_config_path(self, extends_options): - """ - Service we are extending either has a value for 'file' set, which we + """Service we are extending either has a value for 'file' set, which we need to obtain a full path too or we are extending from a service defined in our own file. """ if 'file' in extends_options: - extends_from_filename = extends_options['file'] - return expand_path(self.working_dir, extends_from_filename) - + return expand_path(self.working_dir, extends_options['file']) return self.filename def signature(self, name): - return (self.filename, name) + return self.filename, name def validate_extended_service_dict(service_dict, filename, service): error_prefix = "Cannot extend service '%s' in %s:" % (service, filename) if 'links' in service_dict: - raise ConfigurationError("%s services with 'links' cannot be extended" % error_prefix) + raise ConfigurationError( + "%s services with 'links' cannot be extended" % error_prefix) if 'volumes_from' in service_dict: - raise ConfigurationError("%s services with 'volumes_from' cannot be extended" % error_prefix) + raise ConfigurationError( + "%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: - raise ConfigurationError("%s services with 'net: container' cannot be extended" % error_prefix) + raise ConfigurationError( + "%s services with 'net: container' cannot be extended" % error_prefix) def process_container_options(service_dict, working_dir=None): From b500fa235128d56ea41a8c018f75d81e8722f74d Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 19 Oct 2015 13:40:13 -0400 Subject: [PATCH 1368/4072] Refactor ServiceLoader to be immutable. Mutable objects are harder to debug and harder to reason about. ServiceLoader was almost immutable. There was just a single function which set fields for a second function. Instead of mutating the object, we can pass those values as parameters to the next function. Signed-off-by: Daniel Nephin --- compose/config/config.py | 89 +++++++++++++++++++--------------------- 1 file changed, 43 insertions(+), 46 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 440f39203e6..1a3b30accc4 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -239,80 +239,61 @@ def detect_cycle(self, name): raise CircularReference(self.already_seen + [self.signature(name)]) def make_service_dict(self): - self.resolve_environment() - if 'extends' in self.service_dict: - self.validate_and_construct_extends() - self.service_dict = self.resolve_extends() + service_dict = dict(self.service_dict) + env = resolve_environment(self.working_dir, self.service_dict) + if env: + service_dict['environment'] = env + service_dict.pop('env_file', None) - if not self.already_seen: - validate_against_service_schema(self.service_dict, self.service_name) - - return process_container_options(self.service_dict, working_dir=self.working_dir) - - def resolve_environment(self): - """ - Unpack any environment variables from an env_file, if set. - Interpolate environment values if set. - """ - if 'environment' not in self.service_dict and 'env_file' not in self.service_dict: - return + if 'extends' in service_dict: + service_dict = self.resolve_extends(*self.validate_and_construct_extends()) - env = {} - - if 'env_file' in self.service_dict: - for f in get_env_files(self.service_dict, working_dir=self.working_dir): - env.update(env_vars_from_file(f)) - del self.service_dict['env_file'] - - env.update(parse_environment(self.service_dict.get('environment'))) - env = dict(resolve_env_var(k, v) for k, v in six.iteritems(env)) + if not self.already_seen: + validate_against_service_schema(service_dict, self.service_name) - self.service_dict['environment'] = env + return process_container_options(service_dict, working_dir=self.working_dir) def validate_and_construct_extends(self): extends = self.service_dict['extends'] if not isinstance(extends, dict): extends = {'service': extends} - validate_extends_file_path( - self.service_name, - extends, - self.filename - ) - self.extended_config_path = self.get_extended_config_path(extends) - self.extended_service_name = extends['service'] + validate_extends_file_path(self.service_name, extends, self.filename) + config_path = self.get_extended_config_path(extends) + service_name = extends['service'] - config = load_yaml(self.extended_config_path) + config = load_yaml(config_path) validate_top_level_object(config) full_extended_config = interpolate_environment_variables(config) validate_extended_service_exists( - self.extended_service_name, + service_name, full_extended_config, - self.extended_config_path + config_path ) validate_against_fields_schema(full_extended_config) - self.extended_config = full_extended_config[self.extended_service_name] + service_config = full_extended_config[service_name] + return config_path, service_config, service_name - def resolve_extends(self): - other_working_dir = os.path.dirname(self.extended_config_path) + def resolve_extends(self, extended_config_path, service_config, service_name): + other_working_dir = os.path.dirname(extended_config_path) other_already_seen = self.already_seen + [self.signature(self.service_name)] other_loader = ServiceLoader( - working_dir=other_working_dir, - filename=self.extended_config_path, - service_name=self.service_name, - service_dict=self.extended_config, + other_working_dir, + extended_config_path, + self.service_name, + service_config, already_seen=other_already_seen, ) - other_loader.detect_cycle(self.extended_service_name) + other_loader.detect_cycle(service_name) other_service_dict = other_loader.make_service_dict() validate_extended_service_dict( other_service_dict, - filename=self.extended_config_path, - service=self.extended_service_name, + extended_config_path, + service_name, ) return merge_service_dicts(other_service_dict, self.service_dict) @@ -330,6 +311,22 @@ def signature(self, name): return self.filename, name +def resolve_environment(working_dir, service_dict): + """Unpack any environment variables from an env_file, if set. + Interpolate environment values if set. + """ + if 'environment' not in service_dict and 'env_file' not in service_dict: + return {} + + env = {} + if 'env_file' in service_dict: + for env_file in get_env_files(service_dict, working_dir=working_dir): + 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)) + + def validate_extended_service_dict(service_dict, filename, service): error_prefix = "Cannot extend service '%s' in %s:" % (service, filename) From 725088a18b81f0c1451006b5ea4999cae641aba6 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 20 Oct 2015 11:39:06 -0400 Subject: [PATCH 1369/4072] Force windows drives to be lowercase. Signed-off-by: Daniel Nephin --- compose/service.py | 2 +- tests/unit/config/config_test.py | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/compose/service.py b/compose/service.py index 5f1d59468c1..7daf7f2f9c4 100644 --- a/compose/service.py +++ b/compose/service.py @@ -952,7 +952,7 @@ def normalize_paths_for_engine(external_path, internal_path): drive, tail = os.path.splitdrive(external_path) if drive: - reformatted_drive = "/{}".format(drive.replace(":", "")) + reformatted_drive = "/{}".format(drive.lower().replace(":", "")) external_path = reformatted_drive + tail external_path = "/".join(external_path.split("\\")) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index c8b76f36d08..d15cd9a6894 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -537,8 +537,8 @@ def test_absolute_posix_path_does_not_expand(self): self.assertEqual(d['volumes'], ['/var/lib/data:/data']) def test_absolute_windows_path_does_not_expand(self): - d = make_service_dict('foo', {'build': '.', 'volumes': ['C:\\data:/data']}, working_dir='.') - self.assertEqual(d['volumes'], ['C:\\data:/data']) + d = make_service_dict('foo', {'build': '.', 'volumes': ['c:\\data:/data']}, working_dir='.') + self.assertEqual(d['volumes'], ['c:\\data:/data']) @pytest.mark.skipif(IS_WINDOWS_PLATFORM, reason='posix paths') def test_relative_path_does_expand_posix(self): @@ -553,14 +553,14 @@ def test_relative_path_does_expand_posix(self): @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') - self.assertEqual(d['volumes'], ['C:\\Users\\me\\myproject\\data:/data']) + 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') - self.assertEqual(d['volumes'], ['C:\\Users\\me\\myproject:/data']) + 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') - self.assertEqual(d['volumes'], ['C:\\Users\\me\\otherproject:/data']) + 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) def test_home_directory_with_driver_does_not_expand(self): From f290faf4ba8e4b98126909a4181534ff1fa20f30 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 20 Oct 2015 11:49:10 -0400 Subject: [PATCH 1370/4072] Minor refactor to use guard and replace instead of split+join Signed-off-by: Daniel Nephin --- compose/service.py | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/compose/service.py b/compose/service.py index 7daf7f2f9c4..f18afa485bf 100644 --- a/compose/service.py +++ b/compose/service.py @@ -943,23 +943,21 @@ def build_volume_binding(volume_spec): def normalize_paths_for_engine(external_path, internal_path): - """ - Windows paths, c:\my\path\shiny, need to be changed to be compatible with + """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 IS_WINDOWS_PLATFORM: - if external_path: - drive, tail = os.path.splitdrive(external_path) + if not IS_WINDOWS_PLATFORM: + return external_path, internal_path - if drive: - reformatted_drive = "/{}".format(drive.lower().replace(":", "")) - external_path = reformatted_drive + tail + if external_path: + drive, tail = os.path.splitdrive(external_path) - external_path = "/".join(external_path.split("\\")) + if drive: + external_path = '/' + drive.lower().rstrip(':') + tail - return external_path, "/".join(internal_path.split("\\")) - else: - return external_path, internal_path + external_path = external_path.replace('\\', '/') + + return external_path, internal_path.replace('\\', '/') def parse_volume_spec(volume_config): From 0fed5e686438aefd9968733fc3c480e7c1339568 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 21 Oct 2015 13:05:14 -0400 Subject: [PATCH 1371/4072] Use inspect network to query for an existing network. And more tests for get_network() Signed-off-by: Daniel Nephin --- compose/project.py | 9 +++++---- tests/integration/project_test.py | 17 +++++++++++++++++ 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/compose/project.py b/compose/project.py index fdd70caf116..d4934c268aa 100644 --- a/compose/project.py +++ b/compose/project.py @@ -5,6 +5,7 @@ from functools import reduce from docker.errors import APIError +from docker.errors import NotFound from .config import ConfigurationError from .config import get_service_name_from_net @@ -363,10 +364,10 @@ def matches_service_names(container): return [c for c in containers if matches_service_names(c)] def get_network(self): - networks = self.client.networks(names=[self.name]) - if networks: - return networks[0] - return None + try: + return self.client.inspect_network(self.name) + except NotFound: + return None def ensure_network_exists(self): # TODO: recreate network if driver has changed? diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index ff50c80b2ab..ac0f121cf2f 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -1,6 +1,7 @@ from __future__ import unicode_literals from .testcases import DockerClientTestCase +from compose.cli.docker_client import docker_client from compose.config import config from compose.const import LABEL_PROJECT from compose.container import Container @@ -96,6 +97,22 @@ def test_volumes_from_container(self): db = project.get_service('db') self.assertEqual(db._get_volumes_from(), [data_container.id + ':rw']) + def test_get_network_does_not_exist(self): + self.require_engine_version("1.9") + client = docker_client(version='1.21') + + project = Project('composetest', [], client) + assert project.get_network() is None + + def test_get_network(self): + self.require_engine_version("1.9") + client = docker_client(version='1.21') + + network_name = 'network_does_exist' + project = Project(network_name, [], client) + client.create_network(network_name) + assert project.get_network()['name'] == network_name + def test_net_from_service(self): project = Project.from_dicts( name='composetest', From 1bc3c97f2a770d8268b817f0d8f17160e7b322b2 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 21 Oct 2015 15:26:44 -0400 Subject: [PATCH 1372/4072] Make storage driver configurable in CI Signed-off-by: Daniel Nephin --- script/ci | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/script/ci b/script/ci index 12dc3c473e9..f30265c02a6 100755 --- a/script/ci +++ b/script/ci @@ -11,7 +11,9 @@ set -ex docker version export DOCKER_VERSIONS=all -export DOCKER_DAEMON_ARGS="--storage-driver=overlay" +STORAGE_DRIVER=${STORAGE_DRIVER:-overlay} +export DOCKER_DAEMON_ARGS="--storage-driver=$STORAGE_DRIVER" + GIT_VOLUME="--volumes-from=$(hostname)" . script/test-versions From f7100b2ef3cf7eb275548bf4c437653b266cf66d Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 21 Oct 2015 15:40:50 -0400 Subject: [PATCH 1373/4072] Change version check from engine version to api version. Signed-off-by: Daniel Nephin --- tests/integration/cli_test.py | 4 ++-- tests/integration/project_test.py | 4 ++-- tests/integration/testcases.py | 12 ++++-------- 3 files changed, 8 insertions(+), 12 deletions(-) diff --git a/tests/integration/cli_test.py b/tests/integration/cli_test.py index 78519d14180..19cc822ee1d 100644 --- a/tests/integration/cli_test.py +++ b/tests/integration/cli_test.py @@ -187,7 +187,7 @@ def test_up_attached(self): ) def test_up_without_networking(self): - self.require_engine_version("1.9") + self.require_api_version('1.21') self.command.base_dir = 'tests/fixtures/links-composefile' self.command.dispatch(['up', '-d'], None) @@ -205,7 +205,7 @@ def test_up_without_networking(self): self.assertTrue(web_container.get('HostConfig.Links')) def test_up_with_networking(self): - self.require_engine_version("1.9") + self.require_api_version('1.21') self.command.base_dir = 'tests/fixtures/links-composefile' self.command.dispatch(['--x-networking', 'up', '-d'], None) diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index ac0f121cf2f..fd45b9393f9 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -98,14 +98,14 @@ def test_volumes_from_container(self): self.assertEqual(db._get_volumes_from(), [data_container.id + ':rw']) def test_get_network_does_not_exist(self): - self.require_engine_version("1.9") + self.require_api_version('1.21') client = docker_client(version='1.21') project = Project('composetest', [], client) assert project.get_network() is None def test_get_network(self): - self.require_engine_version("1.9") + self.require_api_version('1.21') client = docker_client(version='1.21') network_name = 'network_does_exist' diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index a412fb04fb7..686a2b69a46 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -76,11 +76,7 @@ def check_build(self, *args, **kwargs): build_output = self.client.build(*args, **kwargs) stream_output(build_output, open('/dev/null', 'w')) - def require_engine_version(self, minimum): - # Drop '-dev' or '-rcN' suffix - engine = self.client.version()['Version'].split('-', 1)[0] - if version_lt(engine, minimum): - skip( - "Engine version is too low ({} < {})" - .format(engine, minimum) - ) + def require_api_version(self, minimum): + api_version = self.client.version()['ApiVersion'] + if version_lt(api_version, minimum): + skip("API version is too low ({} < {})".format(api_version, minimum)) From ae47435425e3922fac0cf4faaa89269ea0efc6e3 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 22 Oct 2015 12:12:43 -0400 Subject: [PATCH 1374/4072] Fix unicode in environment variables for python2. Signed-off-by: Daniel Nephin --- compose/config/config.py | 5 ++++- tests/fixtures/env/resolve.env | 2 +- tests/unit/cli_test.py | 5 +++-- tests/unit/config/config_test.py | 8 +++++++- 4 files changed, 15 insertions(+), 5 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 1a3b30accc4..40b4ffa48aa 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -1,3 +1,4 @@ +import codecs import logging import os import sys @@ -455,6 +456,8 @@ def parse_environment(environment): def split_env(env): + if isinstance(env, six.binary_type): + env = env.decode('utf-8') if '=' in env: return env.split('=', 1) else: @@ -477,7 +480,7 @@ def env_vars_from_file(filename): if not os.path.exists(filename): raise ConfigurationError("Couldn't find env file: %s" % filename) env = {} - for line in open(filename, 'r'): + for line in codecs.open(filename, 'r', 'utf-8'): line = line.strip() if line and not line.startswith('#'): k, v = split_env(line) diff --git a/tests/fixtures/env/resolve.env b/tests/fixtures/env/resolve.env index 720520d29e5..b4f76b29edc 100644 --- a/tests/fixtures/env/resolve.env +++ b/tests/fixtures/env/resolve.env @@ -1,4 +1,4 @@ -FILE_DEF=F1 +FILE_DEF=bär FILE_DEF_EMPTY= ENV_DEF NO_DEF diff --git a/tests/unit/cli_test.py b/tests/unit/cli_test.py index 0c78e6bbfe5..5b63d2e84a4 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -1,3 +1,4 @@ +# encoding: utf-8 from __future__ import absolute_import from __future__ import unicode_literals @@ -98,7 +99,7 @@ def test_run_with_environment_merged_with_options_list(self, mock_dockerpty): command.run(mock_project, { 'SERVICE': 'service', 'COMMAND': None, - '-e': ['BAR=NEW', 'OTHER=THREE'], + '-e': ['BAR=NEW', 'OTHER=bär'.encode('utf-8')], '--user': None, '--no-deps': None, '--allow-insecure-ssl': None, @@ -114,7 +115,7 @@ def test_run_with_environment_merged_with_options_list(self, mock_dockerpty): _, _, call_kwargs = mock_client.create_container.mock_calls[0] self.assertEqual( call_kwargs['environment'], - {'FOO': 'ONE', 'BAR': 'NEW', 'OTHER': 'THREE'}) + {'FOO': 'ONE', 'BAR': 'NEW', 'OTHER': u'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 d15cd9a6894..a54b006fa63 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1,3 +1,4 @@ +# encoding: utf-8 from __future__ import print_function import os @@ -894,7 +895,12 @@ def test_resolve_environment_from_file(self): ) self.assertEqual( service_dict['environment'], - {'FILE_DEF': 'F1', 'FILE_DEF_EMPTY': '', 'ENV_DEF': 'E3', 'NO_DEF': ''}, + { + 'FILE_DEF': u'bär', + 'FILE_DEF_EMPTY': '', + 'ENV_DEF': 'E3', + 'NO_DEF': '' + }, ) @pytest.mark.xfail(IS_WINDOWS_PLATFORM, reason='paths use slash') From cf197253cdc10c2860c558412d4646d1795b7160 Mon Sep 17 00:00:00 2001 From: Sven Dowideit Date: Thu, 22 Oct 2015 17:17:11 +1000 Subject: [PATCH 1375/4072] Possible link fixes Signed-off-by: Sven Dowideit --- docs/django.md | 2 +- docs/env.md | 2 +- docs/index.md | 2 +- docs/networking.md | 8 ++++---- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/django.md b/docs/django.md index c7ebf58bfe4..fd18784eccf 100644 --- a/docs/django.md +++ b/docs/django.md @@ -67,7 +67,7 @@ and a `docker-compose.yml` 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](yml.md) for more + 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. diff --git a/docs/env.md b/docs/env.md index 8f3cc3ccb13..d7b51ba2b51 100644 --- a/docs/env.md +++ b/docs/env.md @@ -11,7 +11,7 @@ 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](yml.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. 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/index.md b/docs/index.md index e19e7d7f445..62c78d68936 100644 --- a/docs/index.md +++ b/docs/index.md @@ -154,7 +154,7 @@ Now, when you run `docker-compose up`, Compose will pull a Redis image, build an If you're using [Docker Machine](https://docs.docker.com/machine), then `docker-machine ip MACHINE_VM` will tell you its address and you can open `http://MACHINE_VM_IP:5000` in a browser. -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 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`. You should get a message in your browser saying: diff --git a/docs/networking.md b/docs/networking.md index f4227917acf..9a6d792df46 100644 --- a/docs/networking.md +++ b/docs/networking.md @@ -12,11 +12,11 @@ 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:** Compose's networking support is experimental, and must be explicitly enabled with the `docker-compose --x-networking` flag. -Compose sets up a single default [network](http://TODO/docker-networking-docs) 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 service's name. +Compose sets up a single default [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 service's 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 [CLI docs](cli.md#p-project-name-name) for how to override it. +> **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. For example, suppose your app is in a directory called `myapp`, and your `docker-compose.yml` looks like this: @@ -65,7 +65,7 @@ Docker links are a one-way, single-host communication system. They should now be ## 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](http://TODO/custom-driver-docs). +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: From 0340361f56e7bf80fe0961d387b7d58fb7098e06 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 22 Sep 2015 16:42:43 -0400 Subject: [PATCH 1376/4072] Upgrade pyinstaller to 3.0 Signed-off-by: Daniel Nephin --- requirements-build.txt | 2 +- script/build-windows.ps1 | 8 ++------ 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/requirements-build.txt b/requirements-build.txt index 5da6fa49664..20aad4208c7 100644 --- a/requirements-build.txt +++ b/requirements-build.txt @@ -1 +1 @@ -git+https://github.com/pyinstaller/pyinstaller.git@12e40471c77f588ea5be352f7219c873ddaae056#egg=pyinstaller +pyinstaller==3.0 diff --git a/script/build-windows.ps1 b/script/build-windows.ps1 index 6e8a7c5ae7f..42a4a501c10 100644 --- a/script/build-windows.ps1 +++ b/script/build-windows.ps1 @@ -42,11 +42,6 @@ Get-ChildItem -Recurse -Include *.pyc | foreach ($_) { Remove-Item $_.FullName } virtualenv .\venv # Install dependencies -# TODO: pip warns when installing from a git sha, so we need to set ErrorAction to -# 'Continue'. See -# https://github.com/pypa/pip/blob/fbc4b7ae5fee00f95bce9ba4b887b22681327bb1/pip/vcs/git.py#L77 -# This can be removed once pyinstaller 3.x is released and we upgrade -$ErrorActionPreference = "Continue" .\venv\Scripts\pip install pypiwin32==219 .\venv\Scripts\pip install -r requirements.txt .\venv\Scripts\pip install --no-deps . @@ -54,8 +49,9 @@ $ErrorActionPreference = "Continue" # 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" -Move-Item -Force .\dist\docker-compose .\dist\docker-compose-Windows-x86_64.exe +Move-Item -Force .\dist\docker-compose.exe .\dist\docker-compose-Windows-x86_64.exe .\dist\docker-compose-Windows-x86_64.exe --version From f5ad36314387e2b97c2f1be3303da50ff5e4cfdb Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 21 Oct 2015 13:05:14 -0400 Subject: [PATCH 1377/4072] Use inspect network to query for an existing network. And more tests for get_network() Signed-off-by: Daniel Nephin --- compose/project.py | 9 +++++---- tests/integration/project_test.py | 17 +++++++++++++++++ 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/compose/project.py b/compose/project.py index fdd70caf116..d4934c268aa 100644 --- a/compose/project.py +++ b/compose/project.py @@ -5,6 +5,7 @@ from functools import reduce from docker.errors import APIError +from docker.errors import NotFound from .config import ConfigurationError from .config import get_service_name_from_net @@ -363,10 +364,10 @@ def matches_service_names(container): return [c for c in containers if matches_service_names(c)] def get_network(self): - networks = self.client.networks(names=[self.name]) - if networks: - return networks[0] - return None + try: + return self.client.inspect_network(self.name) + except NotFound: + return None def ensure_network_exists(self): # TODO: recreate network if driver has changed? diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index ff50c80b2ab..ac0f121cf2f 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -1,6 +1,7 @@ from __future__ import unicode_literals from .testcases import DockerClientTestCase +from compose.cli.docker_client import docker_client from compose.config import config from compose.const import LABEL_PROJECT from compose.container import Container @@ -96,6 +97,22 @@ def test_volumes_from_container(self): db = project.get_service('db') self.assertEqual(db._get_volumes_from(), [data_container.id + ':rw']) + def test_get_network_does_not_exist(self): + self.require_engine_version("1.9") + client = docker_client(version='1.21') + + project = Project('composetest', [], client) + assert project.get_network() is None + + def test_get_network(self): + self.require_engine_version("1.9") + client = docker_client(version='1.21') + + network_name = 'network_does_exist' + project = Project(network_name, [], client) + client.create_network(network_name) + assert project.get_network()['name'] == network_name + def test_net_from_service(self): project = Project.from_dicts( name='composetest', From 95a23eb6829a48298f7db7fb806fc944f0c6cc18 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 21 Oct 2015 15:40:50 -0400 Subject: [PATCH 1378/4072] Change version check from engine version to api version. Signed-off-by: Daniel Nephin --- tests/integration/cli_test.py | 4 ++-- tests/integration/project_test.py | 4 ++-- tests/integration/testcases.py | 12 ++++-------- 3 files changed, 8 insertions(+), 12 deletions(-) diff --git a/tests/integration/cli_test.py b/tests/integration/cli_test.py index 78519d14180..19cc822ee1d 100644 --- a/tests/integration/cli_test.py +++ b/tests/integration/cli_test.py @@ -187,7 +187,7 @@ def test_up_attached(self): ) def test_up_without_networking(self): - self.require_engine_version("1.9") + self.require_api_version('1.21') self.command.base_dir = 'tests/fixtures/links-composefile' self.command.dispatch(['up', '-d'], None) @@ -205,7 +205,7 @@ def test_up_without_networking(self): self.assertTrue(web_container.get('HostConfig.Links')) def test_up_with_networking(self): - self.require_engine_version("1.9") + self.require_api_version('1.21') self.command.base_dir = 'tests/fixtures/links-composefile' self.command.dispatch(['--x-networking', 'up', '-d'], None) diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index ac0f121cf2f..fd45b9393f9 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -98,14 +98,14 @@ def test_volumes_from_container(self): self.assertEqual(db._get_volumes_from(), [data_container.id + ':rw']) def test_get_network_does_not_exist(self): - self.require_engine_version("1.9") + self.require_api_version('1.21') client = docker_client(version='1.21') project = Project('composetest', [], client) assert project.get_network() is None def test_get_network(self): - self.require_engine_version("1.9") + self.require_api_version('1.21') client = docker_client(version='1.21') network_name = 'network_does_exist' diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index a412fb04fb7..686a2b69a46 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -76,11 +76,7 @@ def check_build(self, *args, **kwargs): build_output = self.client.build(*args, **kwargs) stream_output(build_output, open('/dev/null', 'w')) - def require_engine_version(self, minimum): - # Drop '-dev' or '-rcN' suffix - engine = self.client.version()['Version'].split('-', 1)[0] - if version_lt(engine, minimum): - skip( - "Engine version is too low ({} < {})" - .format(engine, minimum) - ) + def require_api_version(self, minimum): + api_version = self.client.version()['ApiVersion'] + if version_lt(api_version, minimum): + skip("API version is too low ({} < {})".format(api_version, minimum)) From e168fd03ca9ea4cdb2843f706b28a864ae669174 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 22 Oct 2015 12:12:43 -0400 Subject: [PATCH 1379/4072] Fix unicode in environment variables for python2. Signed-off-by: Daniel Nephin --- compose/config/config.py | 5 ++++- tests/fixtures/env/resolve.env | 2 +- tests/unit/cli_test.py | 5 +++-- tests/unit/config/config_test.py | 8 +++++++- 4 files changed, 15 insertions(+), 5 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index ff8861b51ab..21549e9b347 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -1,3 +1,4 @@ +import codecs import logging import os import sys @@ -451,6 +452,8 @@ def parse_environment(environment): def split_env(env): + if isinstance(env, six.binary_type): + env = env.decode('utf-8') if '=' in env: return env.split('=', 1) else: @@ -473,7 +476,7 @@ def env_vars_from_file(filename): if not os.path.exists(filename): raise ConfigurationError("Couldn't find env file: %s" % filename) env = {} - for line in open(filename, 'r'): + for line in codecs.open(filename, 'r', 'utf-8'): line = line.strip() if line and not line.startswith('#'): k, v = split_env(line) diff --git a/tests/fixtures/env/resolve.env b/tests/fixtures/env/resolve.env index 720520d29e5..b4f76b29edc 100644 --- a/tests/fixtures/env/resolve.env +++ b/tests/fixtures/env/resolve.env @@ -1,4 +1,4 @@ -FILE_DEF=F1 +FILE_DEF=bär FILE_DEF_EMPTY= ENV_DEF NO_DEF diff --git a/tests/unit/cli_test.py b/tests/unit/cli_test.py index 0c78e6bbfe5..5b63d2e84a4 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -1,3 +1,4 @@ +# encoding: utf-8 from __future__ import absolute_import from __future__ import unicode_literals @@ -98,7 +99,7 @@ def test_run_with_environment_merged_with_options_list(self, mock_dockerpty): command.run(mock_project, { 'SERVICE': 'service', 'COMMAND': None, - '-e': ['BAR=NEW', 'OTHER=THREE'], + '-e': ['BAR=NEW', 'OTHER=bär'.encode('utf-8')], '--user': None, '--no-deps': None, '--allow-insecure-ssl': None, @@ -114,7 +115,7 @@ def test_run_with_environment_merged_with_options_list(self, mock_dockerpty): _, _, call_kwargs = mock_client.create_container.mock_calls[0] self.assertEqual( call_kwargs['environment'], - {'FOO': 'ONE', 'BAR': 'NEW', 'OTHER': 'THREE'}) + {'FOO': 'ONE', 'BAR': 'NEW', 'OTHER': u'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 d15cd9a6894..a54b006fa63 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1,3 +1,4 @@ +# encoding: utf-8 from __future__ import print_function import os @@ -894,7 +895,12 @@ def test_resolve_environment_from_file(self): ) self.assertEqual( service_dict['environment'], - {'FILE_DEF': 'F1', 'FILE_DEF_EMPTY': '', 'ENV_DEF': 'E3', 'NO_DEF': ''}, + { + 'FILE_DEF': u'bär', + 'FILE_DEF_EMPTY': '', + 'ENV_DEF': 'E3', + 'NO_DEF': '' + }, ) @pytest.mark.xfail(IS_WINDOWS_PLATFORM, reason='paths use slash') From 88e53e177dce3982f7106597bb0c431345a7099e Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 22 Sep 2015 16:42:43 -0400 Subject: [PATCH 1380/4072] Upgrade pyinstaller to 3.0 Signed-off-by: Daniel Nephin --- requirements-build.txt | 2 +- script/build-windows.ps1 | 8 ++------ 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/requirements-build.txt b/requirements-build.txt index 5da6fa49664..20aad4208c7 100644 --- a/requirements-build.txt +++ b/requirements-build.txt @@ -1 +1 @@ -git+https://github.com/pyinstaller/pyinstaller.git@12e40471c77f588ea5be352f7219c873ddaae056#egg=pyinstaller +pyinstaller==3.0 diff --git a/script/build-windows.ps1 b/script/build-windows.ps1 index 6e8a7c5ae7f..42a4a501c10 100644 --- a/script/build-windows.ps1 +++ b/script/build-windows.ps1 @@ -42,11 +42,6 @@ Get-ChildItem -Recurse -Include *.pyc | foreach ($_) { Remove-Item $_.FullName } virtualenv .\venv # Install dependencies -# TODO: pip warns when installing from a git sha, so we need to set ErrorAction to -# 'Continue'. See -# https://github.com/pypa/pip/blob/fbc4b7ae5fee00f95bce9ba4b887b22681327bb1/pip/vcs/git.py#L77 -# This can be removed once pyinstaller 3.x is released and we upgrade -$ErrorActionPreference = "Continue" .\venv\Scripts\pip install pypiwin32==219 .\venv\Scripts\pip install -r requirements.txt .\venv\Scripts\pip install --no-deps . @@ -54,8 +49,9 @@ $ErrorActionPreference = "Continue" # 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" -Move-Item -Force .\dist\docker-compose .\dist\docker-compose-Windows-x86_64.exe +Move-Item -Force .\dist\docker-compose.exe .\dist\docker-compose-Windows-x86_64.exe .\dist\docker-compose-Windows-x86_64.exe --version From fe760a7b62719f61fccf30e4b806ffae39b163ae Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Mon, 26 Oct 2015 17:08:45 +0000 Subject: [PATCH 1381/4072] Include additional classifiers I've included Python 2/3 as they are not parent classifiers but sibling classifiers. They denote that this project will work with *some* versions of python and by having them, they'll show up for people searching for python 2 or 3 projects. According to the internet :) Signed-off-by: Mazz Mosley --- setup.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/setup.py b/setup.py index bf2ee07ff89..bd6f201d4af 100644 --- a/setup.py +++ b/setup.py @@ -67,7 +67,13 @@ def find_version(*file_paths): docker-compose=compose.cli.main:main """, classifiers=[ + 'Development Status :: 5 - Production/Stable', + 'Environment :: Console', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: Apache Software License', + 'Programming Language :: Python :: 2', 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3.4', ], ) From 7878d38deeac394e220cab294c3783eac63f17b5 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 26 Oct 2015 13:29:59 -0400 Subject: [PATCH 1382/4072] Fix running one-off containers with --x-networking by disabling linking to self. docker create fails if networking and links are used together. Signed-off-by: Daniel Nephin --- compose/service.py | 3 +++ tests/unit/service_test.py | 8 ++++++++ 2 files changed, 11 insertions(+) diff --git a/compose/service.py b/compose/service.py index 3bb47432bcd..370ab1eb56c 100644 --- a/compose/service.py +++ b/compose/service.py @@ -539,6 +539,9 @@ def _next_container_number(self, one_off=False): 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: for container in service.containers(): diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index b5bac2919cb..c7a5a35574c 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -499,6 +499,14 @@ def test_specifies_host_port_with_host_ip_and_port_range(self): 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), []) + class NetTestCase(unittest.TestCase): From 7603ebea9b2f484f3cf8f193f64748fb91b67bf6 Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Wed, 21 Oct 2015 17:28:16 +0100 Subject: [PATCH 1383/4072] Attach to a container's log_stream before they're started So we're not displaying output of all previous logs for a container, we attach, if possible, to a container before the container is started. LogPrinter checks if a container has a log_stream already attached and print from that rather than always attempting to attach one itself. Signed-off-by: Mazz Mosley --- compose/cli/log_printer.py | 10 ++++-- compose/cli/main.py | 11 +++++-- compose/container.py | 8 +++++ compose/project.py | 6 ++-- compose/service.py | 54 ++++++++++++++++++++++----------- tests/integration/state_test.py | 4 ++- 6 files changed, 66 insertions(+), 27 deletions(-) diff --git a/compose/cli/log_printer.py b/compose/cli/log_printer.py index 6e1499e1d53..66920726ce2 100644 --- a/compose/cli/log_printer.py +++ b/compose/cli/log_printer.py @@ -73,9 +73,13 @@ def build_no_log_generator(container, prefix, color_func): def build_log_generator(container, prefix, color_func): - # Attach to container before log printer starts running - stream = container.attach(stdout=True, stderr=True, stream=True, logs=True) - line_generator = split_buffer(stream) + # 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) + line_generator = split_buffer(stream) + else: + line_generator = split_buffer(container.log_stream) for line in line_generator: yield prefix + line diff --git a/compose/cli/main.py b/compose/cli/main.py index c800d95f98c..5505b89f514 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -565,16 +565,18 @@ def up(self, project, options): start_deps = not options['--no-deps'] service_names = options['SERVICE'] timeout = int(options.get('--timeout') or DEFAULT_TIMEOUT) + detached = options.get('-d') 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 + timeout=timeout, + detached=detached ) - if not options['-d']: + if not detached: log_printer = build_log_printer(to_attach, service_names, monochrome) attach_to_logs(project, log_printer, service_names, timeout) @@ -636,7 +638,10 @@ def convergence_strategy_from_opts(options): def build_log_printer(containers, service_names, monochrome): if service_names: - containers = [c for c in containers if c.service in service_names] + containers = [ + container + for container in containers if container.service in service_names + ] return LogPrinter(containers, monochrome=monochrome) diff --git a/compose/container.py b/compose/container.py index a03acf56fd1..64773b9e6a3 100644 --- a/compose/container.py +++ b/compose/container.py @@ -19,6 +19,7 @@ def __init__(self, client, dictionary, has_been_inspected=False): self.client = client self.dictionary = dictionary self.has_been_inspected = has_been_inspected + self.log_stream = None @classmethod def from_ps(cls, client, dictionary, **kwargs): @@ -146,6 +147,13 @@ def has_api_logs(self): log_type = self.log_driver return not log_type or log_type == 'json-file' + def attach_log_stream(self): + """A log stream can only be attached if the container uses a json-file + log driver. + """ + if self.has_api_logs: + self.log_stream = self.attach(stdout=True, stderr=True, stream=True) + def get(self, key): """Return a value from the container or None if the value is not set. diff --git a/compose/project.py b/compose/project.py index d4934c268aa..68edaddcb28 100644 --- a/compose/project.py +++ b/compose/project.py @@ -290,7 +290,8 @@ def up(self, start_deps=True, strategy=ConvergenceStrategy.changed, do_build=True, - timeout=DEFAULT_TIMEOUT): + timeout=DEFAULT_TIMEOUT, + detached=False): services = self.get_services(service_names, include_deps=start_deps) @@ -308,7 +309,8 @@ def up(self, for container in service.execute_convergence_plan( plans[service.name], do_build=do_build, - timeout=timeout + timeout=timeout, + detached=detached ) ] diff --git a/compose/service.py b/compose/service.py index 3bb47432bcd..aefeda318ef 100644 --- a/compose/service.py +++ b/compose/service.py @@ -395,11 +395,17 @@ def _containers_have_diverged(self, containers): def execute_convergence_plan(self, plan, do_build=True, - timeout=DEFAULT_TIMEOUT): + timeout=DEFAULT_TIMEOUT, + detached=False): (action, containers) = plan + should_attach_logs = not detached if action == 'create': container = self.create_container(do_build=do_build) + + if should_attach_logs: + container.attach_log_stream() + self.start_container(container) return [container] @@ -407,15 +413,16 @@ def execute_convergence_plan(self, elif action == 'recreate': return [ self.recreate_container( - c, - timeout=timeout + container, + timeout=timeout, + attach_logs=should_attach_logs ) - for c in containers + for container in containers ] elif action == 'start': - for c in containers: - self.start_container_if_stopped(c) + for container in containers: + self.start_container_if_stopped(container, attach_logs=should_attach_logs) return containers @@ -428,16 +435,7 @@ def execute_convergence_plan(self, else: raise Exception("Invalid action: {}".format(action)) - def recreate_container(self, - container, - timeout=DEFAULT_TIMEOUT): - """Recreate a container. - - The original container is renamed to a temporary name so that data - volumes can be copied to the new container, before the original - container is removed. - """ - log.info("Recreating %s" % container.name) + def _recreate_stop_container(self, container, timeout): try: container.stop(timeout=timeout) except APIError as e: @@ -448,26 +446,46 @@ def recreate_container(self, else: raise + def _recreate_rename_container(self, container): # Use a hopefully unique container name by prepending the short id self.client.rename( container.id, - '%s_%s' % (container.short_id, container.name)) + '%s_%s' % (container.short_id, container.name) + ) + + def recreate_container(self, + container, + timeout=DEFAULT_TIMEOUT, + attach_logs=False): + """Recreate a container. + + The original container is renamed to a temporary name so that data + volumes can be copied to the new container, before the original + container is removed. + """ + log.info("Recreating %s" % container.name) + self._recreate_stop_container(container, timeout) + self._recreate_rename_container(container) new_container = self.create_container( do_build=False, previous_container=container, number=container.labels.get(LABEL_CONTAINER_NUMBER), quiet=True, ) + if attach_logs: + new_container.attach_log_stream() self.start_container(new_container) container.remove() return new_container - def start_container_if_stopped(self, container): + def start_container_if_stopped(self, container, attach_logs=False): if container.is_running: return container else: log.info("Starting %s" % container.name) + if attach_logs: + container.attach_log_stream() return self.start_container(container) def start_container(self, container): diff --git a/tests/integration/state_test.py b/tests/integration/state_test.py index ef7276bd8d1..02e9d315264 100644 --- a/tests/integration/state_test.py +++ b/tests/integration/state_test.py @@ -18,6 +18,7 @@ class ProjectTestCase(DockerClientTestCase): def run_up(self, cfg, **kwargs): kwargs.setdefault('timeout', 1) + kwargs.setdefault('detached', True) project = self.make_project(cfg) project.up(**kwargs) @@ -184,7 +185,8 @@ def converge(service, do_build=True): """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) + containers, logging_threads = zip(*service.execute_convergence_plan(plan, do_build=do_build, timeout=1)) + return containers class ServiceStateTest(DockerClientTestCase): From bee063c07dd8ca8c7f0295a9a319c14b815c4422 Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Mon, 26 Oct 2015 10:27:57 +0000 Subject: [PATCH 1384/4072] Fix tests Signed-off-by: Mazz Mosley --- tests/integration/service_test.py | 1 + tests/integration/state_test.py | 3 +-- tests/unit/cli/log_printer_test.py | 1 + tests/unit/service_test.py | 4 +--- 4 files changed, 4 insertions(+), 5 deletions(-) diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 8a8e4d54d90..38d7d5b55bc 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -362,6 +362,7 @@ def test_execute_convergence_plan_with_image_declared_volume(self): new_container, = service.execute_convergence_plan( ConvergencePlan('recreate', [old_container])) + self.assertEqual(list(new_container.get('Volumes')), ['/data']) self.assertEqual(new_container.get('Volumes')['/data'], volume_path) diff --git a/tests/integration/state_test.py b/tests/integration/state_test.py index 02e9d315264..3230aefc61a 100644 --- a/tests/integration/state_test.py +++ b/tests/integration/state_test.py @@ -185,8 +185,7 @@ def converge(service, do_build=True): """Create a converge plan from a strategy and execute the plan.""" plan = service.convergence_plan(strategy) - containers, logging_threads = zip(*service.execute_convergence_plan(plan, do_build=do_build, timeout=1)) - return containers + return service.execute_convergence_plan(plan, do_build=do_build, timeout=1) class ServiceStateTest(DockerClientTestCase): diff --git a/tests/unit/cli/log_printer_test.py b/tests/unit/cli/log_printer_test.py index 2c916898073..575fcaf7b57 100644 --- a/tests/unit/cli/log_printer_test.py +++ b/tests/unit/cli/log_printer_test.py @@ -16,6 +16,7 @@ def build_mock_container(reader): name='myapp_web_1', name_without_project='web_1', has_api_logs=True, + log_stream=None, attach=reader, wait=mock.Mock(return_value=0), ) diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index b5bac2919cb..494c2cde789 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -323,9 +323,7 @@ def test_recreate_container(self, _): new_container = service.recreate_container(mock_container) mock_container.stop.assert_called_once_with(timeout=10) - self.mock_client.rename.assert_called_once_with( - mock_container.id, - '%s_%s' % (mock_container.short_id, mock_container.name)) + mock_container.rename_to_tmp_name.assert_called_once_with() new_container.start.assert_called_once_with() mock_container.remove.assert_called_once_with() From 30a84f1be6c68d1cbeaad865242f891f5cff82df Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Tue, 27 Oct 2015 11:55:35 +0000 Subject: [PATCH 1385/4072] Move rename functionality into Container Signed-off-by: Mazz Mosley --- compose/container.py | 9 +++++++++ compose/service.py | 9 +-------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/compose/container.py b/compose/container.py index 64773b9e6a3..dd69e8dddb3 100644 --- a/compose/container.py +++ b/compose/container.py @@ -192,6 +192,15 @@ def restart(self, **options): def remove(self, **options): return self.client.remove_container(self.id, **options) + def rename_to_tmp_name(self): + """Rename the container to a hopefully unique temporary container name + by prepending the short id. + """ + self.client.rename( + self.id, + '%s_%s' % (self.short_id, self.name) + ) + def inspect_if_not_inspected(self): if not self.has_been_inspected: self.inspect() diff --git a/compose/service.py b/compose/service.py index aefeda318ef..518a0d27429 100644 --- a/compose/service.py +++ b/compose/service.py @@ -446,13 +446,6 @@ def _recreate_stop_container(self, container, timeout): else: raise - def _recreate_rename_container(self, container): - # Use a hopefully unique container name by prepending the short id - self.client.rename( - container.id, - '%s_%s' % (container.short_id, container.name) - ) - def recreate_container(self, container, timeout=DEFAULT_TIMEOUT, @@ -466,7 +459,7 @@ def recreate_container(self, log.info("Recreating %s" % container.name) self._recreate_stop_container(container, timeout) - self._recreate_rename_container(container) + container.rename_to_tmp_name() new_container = self.create_container( do_build=False, previous_container=container, From 76d52b1c5f02e526010468c22d5a438883ab4777 Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Tue, 27 Oct 2015 11:59:09 +0000 Subject: [PATCH 1386/4072] Remove redundant try/except Code cleanup. We no longer need this as the api returns a 304 for any stopped containers, which doesn't raise an error. Signed-off-by: Mazz Mosley --- compose/service.py | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/compose/service.py b/compose/service.py index 518a0d27429..2ca004cfb91 100644 --- a/compose/service.py +++ b/compose/service.py @@ -435,17 +435,6 @@ def execute_convergence_plan(self, else: raise Exception("Invalid action: {}".format(action)) - def _recreate_stop_container(self, container, timeout): - try: - container.stop(timeout=timeout) - except APIError as e: - if (e.response.status_code == 500 - and e.explanation - and 'no such process' in str(e.explanation)): - pass - else: - raise - def recreate_container(self, container, timeout=DEFAULT_TIMEOUT, @@ -458,7 +447,7 @@ def recreate_container(self, """ log.info("Recreating %s" % container.name) - self._recreate_stop_container(container, timeout) + container.stop(timeout=timeout) container.rename_to_tmp_name() new_container = self.create_container( do_build=False, From a9b4fe768d1a49a6b6fad9b36e1e41cd6be3b756 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 26 Oct 2015 13:29:59 -0400 Subject: [PATCH 1387/4072] Fix running one-off containers with --x-networking by disabling linking to self. docker create fails if networking and links are used together. Signed-off-by: Daniel Nephin --- compose/service.py | 3 +++ tests/unit/service_test.py | 8 ++++++++ 2 files changed, 11 insertions(+) diff --git a/compose/service.py b/compose/service.py index f18afa485bf..43067d42c4b 100644 --- a/compose/service.py +++ b/compose/service.py @@ -545,6 +545,9 @@ def _next_container_number(self, one_off=False): 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: for container in service.containers(): diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index c5e1a9fb060..7149ff0eeb3 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -444,6 +444,14 @@ def test_config_dict_with_net_from_container(self): } self.assertEqual(config_dict, expected) + 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), []) + class NetTestCase(unittest.TestCase): From d6fa8596d22c6a330449ae808d6bb2ddbb5c2534 Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Wed, 21 Oct 2015 17:28:16 +0100 Subject: [PATCH 1388/4072] Attach to a container's log_stream before they're started So we're not displaying output of all previous logs for a container, we attach, if possible, to a container before the container is started. LogPrinter checks if a container has a log_stream already attached and print from that rather than always attempting to attach one itself. Signed-off-by: Mazz Mosley --- compose/cli/log_printer.py | 10 ++++-- compose/cli/main.py | 11 +++++-- compose/container.py | 8 +++++ compose/project.py | 6 ++-- compose/service.py | 58 +++++++++++++++++++++------------ tests/integration/state_test.py | 4 ++- 6 files changed, 67 insertions(+), 30 deletions(-) diff --git a/compose/cli/log_printer.py b/compose/cli/log_printer.py index 6e1499e1d53..66920726ce2 100644 --- a/compose/cli/log_printer.py +++ b/compose/cli/log_printer.py @@ -73,9 +73,13 @@ def build_no_log_generator(container, prefix, color_func): def build_log_generator(container, prefix, color_func): - # Attach to container before log printer starts running - stream = container.attach(stdout=True, stderr=True, stream=True, logs=True) - line_generator = split_buffer(stream) + # 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) + line_generator = split_buffer(stream) + else: + line_generator = split_buffer(container.log_stream) for line in line_generator: yield prefix + line diff --git a/compose/cli/main.py b/compose/cli/main.py index c800d95f98c..5505b89f514 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -565,16 +565,18 @@ def up(self, project, options): start_deps = not options['--no-deps'] service_names = options['SERVICE'] timeout = int(options.get('--timeout') or DEFAULT_TIMEOUT) + detached = options.get('-d') 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 + timeout=timeout, + detached=detached ) - if not options['-d']: + if not detached: log_printer = build_log_printer(to_attach, service_names, monochrome) attach_to_logs(project, log_printer, service_names, timeout) @@ -636,7 +638,10 @@ def convergence_strategy_from_opts(options): def build_log_printer(containers, service_names, monochrome): if service_names: - containers = [c for c in containers if c.service in service_names] + containers = [ + container + for container in containers if container.service in service_names + ] return LogPrinter(containers, monochrome=monochrome) diff --git a/compose/container.py b/compose/container.py index a03acf56fd1..64773b9e6a3 100644 --- a/compose/container.py +++ b/compose/container.py @@ -19,6 +19,7 @@ def __init__(self, client, dictionary, has_been_inspected=False): self.client = client self.dictionary = dictionary self.has_been_inspected = has_been_inspected + self.log_stream = None @classmethod def from_ps(cls, client, dictionary, **kwargs): @@ -146,6 +147,13 @@ def has_api_logs(self): log_type = self.log_driver return not log_type or log_type == 'json-file' + def attach_log_stream(self): + """A log stream can only be attached if the container uses a json-file + log driver. + """ + if self.has_api_logs: + self.log_stream = self.attach(stdout=True, stderr=True, stream=True) + def get(self, key): """Return a value from the container or None if the value is not set. diff --git a/compose/project.py b/compose/project.py index d4934c268aa..68edaddcb28 100644 --- a/compose/project.py +++ b/compose/project.py @@ -290,7 +290,8 @@ def up(self, start_deps=True, strategy=ConvergenceStrategy.changed, do_build=True, - timeout=DEFAULT_TIMEOUT): + timeout=DEFAULT_TIMEOUT, + detached=False): services = self.get_services(service_names, include_deps=start_deps) @@ -308,7 +309,8 @@ def up(self, for container in service.execute_convergence_plan( plans[service.name], do_build=do_build, - timeout=timeout + timeout=timeout, + detached=detached ) ] diff --git a/compose/service.py b/compose/service.py index 43067d42c4b..90ef709ec67 100644 --- a/compose/service.py +++ b/compose/service.py @@ -399,13 +399,17 @@ def _containers_have_diverged(self, containers): def execute_convergence_plan(self, plan, do_build=True, - timeout=DEFAULT_TIMEOUT): + timeout=DEFAULT_TIMEOUT, + detached=False): (action, containers) = plan + should_attach_logs = not detached if action == 'create': - container = self.create_container( - do_build=do_build, - ) + container = self.create_container(do_build=do_build) + + if should_attach_logs: + container.attach_log_stream() + self.start_container(container) return [container] @@ -413,15 +417,16 @@ def execute_convergence_plan(self, elif action == 'recreate': return [ self.recreate_container( - c, - timeout=timeout + container, + timeout=timeout, + attach_logs=should_attach_logs ) - for c in containers + for container in containers ] elif action == 'start': - for c in containers: - self.start_container_if_stopped(c) + for container in containers: + self.start_container_if_stopped(container, attach_logs=should_attach_logs) return containers @@ -434,16 +439,7 @@ def execute_convergence_plan(self, else: raise Exception("Invalid action: {}".format(action)) - def recreate_container(self, - container, - timeout=DEFAULT_TIMEOUT): - """Recreate a container. - - The original container is renamed to a temporary name so that data - volumes can be copied to the new container, before the original - container is removed. - """ - log.info("Recreating %s" % container.name) + def _recreate_stop_container(self, container, timeout): try: container.stop(timeout=timeout) except APIError as e: @@ -454,26 +450,46 @@ def recreate_container(self, else: raise + def _recreate_rename_container(self, container): # Use a hopefully unique container name by prepending the short id self.client.rename( container.id, - '%s_%s' % (container.short_id, container.name)) + '%s_%s' % (container.short_id, container.name) + ) + + def recreate_container(self, + container, + timeout=DEFAULT_TIMEOUT, + attach_logs=False): + """Recreate a container. + + The original container is renamed to a temporary name so that data + volumes can be copied to the new container, before the original + container is removed. + """ + log.info("Recreating %s" % container.name) + self._recreate_stop_container(container, timeout) + self._recreate_rename_container(container) new_container = self.create_container( do_build=False, previous_container=container, number=container.labels.get(LABEL_CONTAINER_NUMBER), quiet=True, ) + if attach_logs: + new_container.attach_log_stream() self.start_container(new_container) container.remove() return new_container - def start_container_if_stopped(self, container): + def start_container_if_stopped(self, container, attach_logs=False): if container.is_running: return container else: log.info("Starting %s" % container.name) + if attach_logs: + container.attach_log_stream() return self.start_container(container) def start_container(self, container): diff --git a/tests/integration/state_test.py b/tests/integration/state_test.py index ef7276bd8d1..02e9d315264 100644 --- a/tests/integration/state_test.py +++ b/tests/integration/state_test.py @@ -18,6 +18,7 @@ class ProjectTestCase(DockerClientTestCase): def run_up(self, cfg, **kwargs): kwargs.setdefault('timeout', 1) + kwargs.setdefault('detached', True) project = self.make_project(cfg) project.up(**kwargs) @@ -184,7 +185,8 @@ def converge(service, do_build=True): """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) + containers, logging_threads = zip(*service.execute_convergence_plan(plan, do_build=do_build, timeout=1)) + return containers class ServiceStateTest(DockerClientTestCase): From da41ed22f9358069daab28cb08ac55e0a31e4816 Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Mon, 26 Oct 2015 10:27:57 +0000 Subject: [PATCH 1389/4072] Fix tests Signed-off-by: Mazz Mosley --- tests/integration/service_test.py | 1 + tests/integration/state_test.py | 3 +-- tests/unit/cli/log_printer_test.py | 1 + tests/unit/service_test.py | 4 +--- 4 files changed, 4 insertions(+), 5 deletions(-) diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 8a8e4d54d90..38d7d5b55bc 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -362,6 +362,7 @@ def test_execute_convergence_plan_with_image_declared_volume(self): new_container, = service.execute_convergence_plan( ConvergencePlan('recreate', [old_container])) + self.assertEqual(list(new_container.get('Volumes')), ['/data']) self.assertEqual(new_container.get('Volumes')['/data'], volume_path) diff --git a/tests/integration/state_test.py b/tests/integration/state_test.py index 02e9d315264..3230aefc61a 100644 --- a/tests/integration/state_test.py +++ b/tests/integration/state_test.py @@ -185,8 +185,7 @@ def converge(service, do_build=True): """Create a converge plan from a strategy and execute the plan.""" plan = service.convergence_plan(strategy) - containers, logging_threads = zip(*service.execute_convergence_plan(plan, do_build=do_build, timeout=1)) - return containers + return service.execute_convergence_plan(plan, do_build=do_build, timeout=1) class ServiceStateTest(DockerClientTestCase): diff --git a/tests/unit/cli/log_printer_test.py b/tests/unit/cli/log_printer_test.py index 2c916898073..575fcaf7b57 100644 --- a/tests/unit/cli/log_printer_test.py +++ b/tests/unit/cli/log_printer_test.py @@ -16,6 +16,7 @@ def build_mock_container(reader): name='myapp_web_1', name_without_project='web_1', has_api_logs=True, + log_stream=None, attach=reader, wait=mock.Mock(return_value=0), ) diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 7149ff0eeb3..d86f80f7300 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -323,9 +323,7 @@ def test_recreate_container(self, _): new_container = service.recreate_container(mock_container) mock_container.stop.assert_called_once_with(timeout=10) - self.mock_client.rename.assert_called_once_with( - mock_container.id, - '%s_%s' % (mock_container.short_id, mock_container.name)) + mock_container.rename_to_tmp_name.assert_called_once_with() new_container.start.assert_called_once_with() mock_container.remove.assert_called_once_with() From 6f0096c87b6a33895752f1a5f6b914e40c2ceee1 Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Tue, 27 Oct 2015 11:55:35 +0000 Subject: [PATCH 1390/4072] Move rename functionality into Container Signed-off-by: Mazz Mosley --- compose/container.py | 9 +++++++++ compose/service.py | 9 +-------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/compose/container.py b/compose/container.py index 64773b9e6a3..dd69e8dddb3 100644 --- a/compose/container.py +++ b/compose/container.py @@ -192,6 +192,15 @@ def restart(self, **options): def remove(self, **options): return self.client.remove_container(self.id, **options) + def rename_to_tmp_name(self): + """Rename the container to a hopefully unique temporary container name + by prepending the short id. + """ + self.client.rename( + self.id, + '%s_%s' % (self.short_id, self.name) + ) + def inspect_if_not_inspected(self): if not self.has_been_inspected: self.inspect() diff --git a/compose/service.py b/compose/service.py index 90ef709ec67..ac0a84906b6 100644 --- a/compose/service.py +++ b/compose/service.py @@ -450,13 +450,6 @@ def _recreate_stop_container(self, container, timeout): else: raise - def _recreate_rename_container(self, container): - # Use a hopefully unique container name by prepending the short id - self.client.rename( - container.id, - '%s_%s' % (container.short_id, container.name) - ) - def recreate_container(self, container, timeout=DEFAULT_TIMEOUT, @@ -470,7 +463,7 @@ def recreate_container(self, log.info("Recreating %s" % container.name) self._recreate_stop_container(container, timeout) - self._recreate_rename_container(container) + container.rename_to_tmp_name() new_container = self.create_container( do_build=False, previous_container=container, From a772a0d7d7c454fe35cb65b40a616df5413555c8 Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Tue, 27 Oct 2015 11:59:09 +0000 Subject: [PATCH 1391/4072] Remove redundant try/except Code cleanup. We no longer need this as the api returns a 304 for any stopped containers, which doesn't raise an error. Signed-off-by: Mazz Mosley --- compose/service.py | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/compose/service.py b/compose/service.py index ac0a84906b6..dbcec103823 100644 --- a/compose/service.py +++ b/compose/service.py @@ -439,17 +439,6 @@ def execute_convergence_plan(self, else: raise Exception("Invalid action: {}".format(action)) - def _recreate_stop_container(self, container, timeout): - try: - container.stop(timeout=timeout) - except APIError as e: - if (e.response.status_code == 500 - and e.explanation - and 'no such process' in str(e.explanation)): - pass - else: - raise - def recreate_container(self, container, timeout=DEFAULT_TIMEOUT, @@ -462,7 +451,7 @@ def recreate_container(self, """ log.info("Recreating %s" % container.name) - self._recreate_stop_container(container, timeout) + container.stop(timeout=timeout) container.rename_to_tmp_name() new_container = self.create_container( do_build=False, From 29b0ffe5e99be0c49e750eb29b938972449c5bcc Mon Sep 17 00:00:00 2001 From: Sven Dowideit Date: Thu, 22 Oct 2015 17:17:11 +1000 Subject: [PATCH 1392/4072] Possible link fixes Signed-off-by: Sven Dowideit --- docs/django.md | 2 +- docs/env.md | 2 +- docs/index.md | 2 +- docs/networking.md | 8 ++++---- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/django.md b/docs/django.md index c7ebf58bfe4..fd18784eccf 100644 --- a/docs/django.md +++ b/docs/django.md @@ -67,7 +67,7 @@ and a `docker-compose.yml` 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](yml.md) for more + 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. diff --git a/docs/env.md b/docs/env.md index 8f3cc3ccb13..d7b51ba2b51 100644 --- a/docs/env.md +++ b/docs/env.md @@ -11,7 +11,7 @@ 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](yml.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. 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/index.md b/docs/index.md index e19e7d7f445..62c78d68936 100644 --- a/docs/index.md +++ b/docs/index.md @@ -154,7 +154,7 @@ Now, when you run `docker-compose up`, Compose will pull a Redis image, build an If you're using [Docker Machine](https://docs.docker.com/machine), then `docker-machine ip MACHINE_VM` will tell you its address and you can open `http://MACHINE_VM_IP:5000` in a browser. -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 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`. You should get a message in your browser saying: diff --git a/docs/networking.md b/docs/networking.md index f4227917acf..9a6d792df46 100644 --- a/docs/networking.md +++ b/docs/networking.md @@ -12,11 +12,11 @@ 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:** Compose's networking support is experimental, and must be explicitly enabled with the `docker-compose --x-networking` flag. -Compose sets up a single default [network](http://TODO/docker-networking-docs) 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 service's name. +Compose sets up a single default [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 service's 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 [CLI docs](cli.md#p-project-name-name) for how to override it. +> **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. For example, suppose your app is in a directory called `myapp`, and your `docker-compose.yml` looks like this: @@ -65,7 +65,7 @@ Docker links are a one-way, single-host communication system. They should now be ## 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](http://TODO/custom-driver-docs). +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: From 8cc8e614740a71234cb7d1d3d5456c2c316a022c Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 16 Oct 2015 12:55:32 -0400 Subject: [PATCH 1393/4072] Bump 1.5.0rc2 Signed-off-by: Daniel Nephin Fill out 1.5.0 release notes Signed-off-by: Aanand Prasad --- CHANGELOG.md | 106 ++++++++++++++++++++++++++++++-------------- compose/__init__.py | 2 +- docs/install.md | 4 +- script/run.sh | 2 +- 4 files changed, 77 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 730cd30ef7e..3b2ecd97e6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,48 +4,88 @@ Change log 1.5.0 (2015-10-13) ------------------ -Major Features +Major features: -- Compose is now available on windows. -- Environment variable can be used in the compose file. See +- Compose is now available for Windows. + +- Environment variables can be used in the Compose file. See https://github.com/docker/compose/blob/129092b7/docs/yml.md#variable-substitution + - Multiple compose files can be specified, allowing you to override - setting in the default compose file. See + settings in the default Compose file. See https://github.com/docker/compose/blob/129092b7/docs/reference/docker-compose.md for more details. -- Configuration validation is now a lot more strict -- `up` now waits for all services to exit before shutting down -- Support for the new docker networking can be enabled with - the `--x-networking` flag - -New Features - -- `volumes_from` now supports a mode option allowing for read-only - `volumes_from` -- Volumes that don't start with a path indicator (`.` or `/`) will now be - treated as a named volume. Previously this was a warning. -- `--pull` flag added to `build` -- `--ignore-pull-failures` flag added to `pull` -- Support for the `ipc` field added to the compose file -- Containers created by `run` can now be named with the `--name` flag -- If you install Compose with pip or use it as a library, it now - works with Python 3 -- `image` field now supports image digests (in addition to ids and tags) -- `ports` now supports ranges of ports -- `--publish` flag added to `run` -- New subcommands `pause` and `unpause` -- services may be extended from the same file without a `file` key in - `extends` -- Compose can be installed and run as a docker image. This is an experimental + +- Compose now produces better error messages when a file contains + invalid configuration. + +- `up` now waits for all services to exit before shutting down, + rather than shutting down as soon as one container exits. + +- Experimental support for the new docker networking system can be + enabled with the `--x-networking` flag. Read more here: + https://github.com/docker/docker/blob/8fee1c20/docs/userguide/dockernetworks.md + +New features: + +- You can now optionally pass a mode to `volumes_from`, e.g. + `volumes_from: ["servicename:ro"]`. + +- Since Docker now lets you create volumes with names, you can refer to those + volumes by name in `docker-compose.yml`. For example, + `volumes: ["mydatavolume:/data"]` will mount the volume named + `mydatavolume` at the path `/data` inside the container. + + If the first component of an entry in `volumes` starts with a `.`, `/` or + `~`, it is treated as a path and expansion of relative paths is performed as + necessary. Otherwise, it is treated as a volume name and passed straight + through to Docker. + + Read more on named volumes and volume drivers here: + https://github.com/docker/docker/blob/244d9c33/docs/userguide/dockervolumes.md + +- `docker-compose build --pull` instructs Compose to pull the base image for + each Dockerfile before building. + +- `docker-compose pull --ignore-pull-failures` instructs Compose to continue + if it fails to pull a single service's image, rather than aborting. + +- You can now specify an IPC namespace in `docker-compose.yml` with the `ipc` + option. + +- Containers created by `docker-compose run` can now be named with the + `--name` flag. + +- If you install Compose with pip or use it as a library, it now works with + Python 3. + +- `image` now supports image digests (in addition to ids and tags), e.g. + `image: "busybox@sha256:38a203e1986cf79639cfb9b2e1d6e773de84002feea2d4eb006b52004ee8502d"` + +- `ports` now supports ranges of ports, e.g. + + ports: + - "3000-3005" + - "9000-9001:8000-8001" + +- `docker-compose run` now supports a `-p|--publish` parameter, much like + `docker run -p`, for publishing specific ports to the host. + +- `docker-compose pause` and `docker-compose unpause` have been implemented, + analogous to `docker pause` and `docker unpause`. + +- When using `extends` to copy configuration from another service in the same + Compose file, you can omit the `file` option. + +- Compose can be installed and run as a Docker image. This is an experimental feature. +Bug fixes: -Bug Fixes +- All values for the `log_driver` option which are supported by the Docker + daemon are now supported by Compose. -- Support all `log_drivers` -- Fixed `build` when running against swarm -- `~` is no longer expanded on the host when included as part of a container - volume path +- `docker-compose build` can now be run successfully against a Swarm cluster. diff --git a/compose/__init__.py b/compose/__init__.py index 06897c84481..8ea59a363f1 100644 --- a/compose/__init__.py +++ b/compose/__init__.py @@ -1,3 +1,3 @@ from __future__ import unicode_literals -__version__ = '1.5.0rc1' +__version__ = '1.5.0rc2' diff --git a/docs/install.md b/docs/install.md index 31b2ccad47a..3c5dea5eba3 100644 --- a/docs/install.md +++ b/docs/install.md @@ -53,7 +53,7 @@ To install Compose, do the following: 7. Test the installation. $ docker-compose --version - docker-compose version: 1.5.0rc1 + docker-compose version: 1.5.0rc2 ## Alternative install options @@ -75,7 +75,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.0rc1/run.sh > /usr/local/bin/docker-compose + $ curl -L https://github.com/docker/compose/releases/download/1.5.0rc2/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 68ee4faa5b8..25fc8c07704 100755 --- a/script/run.sh +++ b/script/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.5.0rc1" +VERSION="1.5.0rc2" IMAGE="docker/compose:$VERSION" From 379af594dab93e608d9589bb41c755429190a71d Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Wed, 28 Oct 2015 14:59:43 +0000 Subject: [PATCH 1394/4072] Include link to github for code&issues Signed-off-by: Mazz Mosley --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index d779d607c35..ed176550dbb 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,8 @@ Installation and documentation - Full documentation is available on [Docker's website](http://docs.docker.com/compose/). - If you have any questions, you can talk in real-time with other developers in the #docker-compose IRC channel on Freenode. [Click here to join using IRCCloud.](https://www.irccloud.com/invite?hostname=irc.freenode.net&channel=%23docker-compose) +- Code repository for Compose is on [Github](https://github.com/docker/compose) +- If you find any problems please fill out an [issue](https://github.com/docker/compose/issues/new) Contributing ------------ From c4f0f24c57365a2bada4be295778bf93f506ece4 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 28 Oct 2015 12:01:34 -0400 Subject: [PATCH 1395/4072] Fix release script notes about software and typos. Signed-off-by: Daniel Nephin --- project/RELEASE-PROCESS.md | 12 ++++++++++-- script/release/cherry-pick-pr | 2 +- script/release/push-release | 5 ++++- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/project/RELEASE-PROCESS.md b/project/RELEASE-PROCESS.md index ffa18077f4b..040a2602be4 100644 --- a/project/RELEASE-PROCESS.md +++ b/project/RELEASE-PROCESS.md @@ -1,6 +1,14 @@ Building a Compose release ========================== +## Prerequisites + +The release scripts require the following tools installed on the host: + +* https://hub.github.com/ +* https://stedolan.github.io/jq/ +* http://pandoc.org/ + ## To get started with a new release Create a branch, update version, and add release notes by running `make-branch` @@ -40,10 +48,10 @@ As part of this script you'll be asked to: ## To release a version (whether RC or stable) -Check out the bump branch and run the `build-binary` script +Check out the bump branch and run the `build-binaries` script git checkout bump-$VERSION - ./script/release/build-binary + ./script/release/build-binaries When prompted build the non-linux binaries and test them. diff --git a/script/release/cherry-pick-pr b/script/release/cherry-pick-pr index 604600872cf..f4a5a7406b3 100755 --- a/script/release/cherry-pick-pr +++ b/script/release/cherry-pick-pr @@ -22,7 +22,7 @@ EOM if [ -z "$(command -v hub 2> /dev/null)" ]; then >&2 echo "$0 requires https://hub.github.com/." - >&2 echo "Please install it and ake sure it is available on your \$PATH." + >&2 echo "Please install it and make sure it is available on your \$PATH." exit 2 fi diff --git a/script/release/push-release b/script/release/push-release index 039436da0e2..9229f0934d4 100755 --- a/script/release/push-release +++ b/script/release/push-release @@ -34,7 +34,9 @@ GITHUB_REPO=git@github.com:$REPO sha=$(git rev-parse HEAD) url=$API/$REPO/statuses/$sha build_status=$(curl -s $url | jq -r '.[0].state') -if [[ "$build_status" != "success" ]]; then +if [ -n "$SKIP_BUILD_CHECK" ]; then + echo "Skipping build status check..." +elif [[ "$build_status" != "success" ]]; then >&2 echo "Build status is $build_status, but it should be success." exit -1 fi @@ -61,6 +63,7 @@ source venv-test/bin/activate pip install docker-compose==$VERSION docker-compose version deactivate +rm -rf venv-test echo "Now publish the github release, and test the downloads." echo "Email maintainers@dockerproject.org and engineering@docker.com about the new release." From bbc76e6034d426e75dba9f2c2517e7f82f7ea1f8 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 28 Oct 2015 12:11:29 -0400 Subject: [PATCH 1396/4072] Convert the README to rst and fix the logo url before packaging it up for pypi. Signed-off-by: Daniel Nephin --- .gitignore | 1 + MANIFEST.in | 2 ++ script/release/push-release | 15 +++++++++++---- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 1b0c50113fa..83a08a0e697 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ /dist /docs/_site /venv +README.rst diff --git a/MANIFEST.in b/MANIFEST.in index 43ae06d3e25..0342e35bea5 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -4,6 +4,8 @@ include requirements.txt include requirements-dev.txt include tox.ini include *.md +exclude README.md +include README.rst include compose/config/*.json recursive-include contrib/completion * recursive-include tests * diff --git a/script/release/push-release b/script/release/push-release index 9229f0934d4..ccdf2496077 100755 --- a/script/release/push-release +++ b/script/release/push-release @@ -21,11 +21,17 @@ VERSION="$(git config "branch.${BRANCH}.release")" || usage if [ -z "$(command -v jq 2> /dev/null)" ]; then >&2 echo "$0 requires https://stedolan.github.io/jq/" - >&2 echo "Please install it and ake sure it is available on your \$PATH." + >&2 echo "Please install it and make sure it is available on your \$PATH." exit 2 fi +if [ -z "$(command -v pandoc 2> /dev/null)" ]; then + >&2 echo "$0 requires http://pandoc.org/" + >&2 echo "Please install it and make sure it is available on your \$PATH." + exit 2 +fi + API=https://api.github.com/repos REPO=docker/compose GITHUB_REPO=git@github.com:$REPO @@ -45,12 +51,13 @@ echo "Tagging the release as $VERSION" git tag $VERSION git push $GITHUB_REPO $VERSION -echo "Uploading sdist to pypi" -python setup.py sdist - echo "Uploading the docker image" 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 +python setup.py sdist if [ "$(command -v twine 2> /dev/null)" ]; then twine upload ./dist/docker-compose-${VERSION}.tar.gz else From 830640534053df40d06e63e04b63c15d8929ae9c Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 28 Oct 2015 12:40:59 -0400 Subject: [PATCH 1397/4072] On error print daemon logs Signed-off-by: Daniel Nephin --- script/test-versions | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/script/test-versions b/script/test-versions index 89793359be5..43326ccb6b5 100755 --- a/script/test-versions +++ b/script/test-versions @@ -28,10 +28,15 @@ for version in $DOCKER_VERSIONS; do >&2 echo "Running tests against Docker $version" daemon_container="compose-dind-$version-$BUILD_NUMBER" - trap "docker rm -vf $daemon_container" EXIT - # TODO: remove when we stop testing against 1.7.x - daemon=$([[ "$version" == "1.7"* ]] && echo "-d" || echo "daemon") + function on_exit() { + if [[ "$?" != "0" ]]; then + docker logs "$daemon_container" + fi + docker rm -vf "$daemon_container" + } + + trap "on_exit" EXIT docker run \ -d \ @@ -39,7 +44,7 @@ for version in $DOCKER_VERSIONS; do --privileged \ --volume="/var/lib/docker" \ dockerswarm/dind:$version \ - docker $daemon -H tcp://0.0.0.0:2375 $DOCKER_DAEMON_ARGS \ + docker daemon -H tcp://0.0.0.0:2375 $DOCKER_DAEMON_ARGS \ docker run \ --rm \ From c341860d113e4ec042fa4d9bb7340fed9d9db66d Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Wed, 28 Oct 2015 16:48:35 +0000 Subject: [PATCH 1398/4072] Clarify `dockerfile` requires `build` key Credit to @funkyfuture for the first PR addressing the clarification. https://github.com/docker/compose/pull/1767 Signed-off-by: Mazz Mosley --- docs/compose-file.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/compose-file.md b/docs/compose-file.md index b72a7cc4372..d45916081dc 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -100,8 +100,10 @@ Custom DNS search domains. Can be a single value or a list. Alternate Dockerfile. -Compose will use an alternate file to build with. +Compose will use an alternate file to build with. A build path must also be +specified using the `build` key. + build: /path/to/build/dir dockerfile: Dockerfile-alternate Using `dockerfile` together with `image` is not allowed. Attempting to do so results in an error. From e13b8949b0546a4c2028b3457f5934c2ae4c1f0b Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Wed, 28 Oct 2015 16:27:14 +0000 Subject: [PATCH 1399/4072] Add cross references for env/cli Signed-off-by: Mazz Mosley --- docs/reference/docker-compose.md | 15 +++++++++------ docs/reference/overview.md | 9 +++++++-- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/docs/reference/docker-compose.md b/docs/reference/docker-compose.md index 32fcbe70640..8712072e564 100644 --- a/docs/reference/docker-compose.md +++ b/docs/reference/docker-compose.md @@ -87,15 +87,18 @@ 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 -`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. +`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. + +See also the `COMPOSE_FILE` [environment variable](overview.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. +directory name. See also the `COMPOSE_PROJECT_NAME` [environment variable]( +overview.md#compose-project-name) ## Where to go next diff --git a/docs/reference/overview.md b/docs/reference/overview.md index 3f589a9ded9..8e3967b22d1 100644 --- a/docs/reference/overview.md +++ b/docs/reference/overview.md @@ -32,11 +32,16 @@ Docker command-line client. If you're using `docker-machine`, then the `eval "$( 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 current working directory. +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 -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. +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 From 0ef3b47f74afe2a835ae0b9ad60c92b8c27612f2 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 28 Oct 2015 15:17:11 -0400 Subject: [PATCH 1400/4072] Update docs about networking for current release. Signed-off-by: Daniel Nephin --- docs/networking.md | 36 ++++++++++++++++++++++-------------- project/ISSUE-TRIAGE.md | 25 +++++++++++++------------ 2 files changed, 35 insertions(+), 26 deletions(-) diff --git a/docs/networking.md b/docs/networking.md index 9a6d792df46..718d56c7a26 100644 --- a/docs/networking.md +++ b/docs/networking.md @@ -14,7 +14,11 @@ weight=6 > **Note:** Compose's networking support is experimental, and must be explicitly enabled with the `docker-compose --x-networking` flag. -Compose sets up a single default [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 service's name. +Compose sets up a single default +[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. @@ -30,13 +34,23 @@ For example, suppose your app is in a directory called `myapp`, and your `docker When you run `docker-compose --x-networking up`, the following happens: 1. A network called `myapp` is created. -2. A container is created using `web`'s configuration. It joins the network `myapp` under the name `web`. -3. A container is created using `db`'s configuration. It joins the network `myapp` under the name `db`. +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`. -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. +Each container can now look up the hostname `myapp_web_1` or `myapp_db_1` 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 +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. @@ -45,19 +59,13 @@ If any containers have connections open to the old container, they will be close ## Configure how services are published -By default, containers for each service are published on the network with the same name as the service. If you want to change the name, or stop containers from being discoverable at all, you can use the `hostname` option: +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: . - hostname: "my-web-application" - -This will also change the hostname inside the container, so that the `hostname` command will return `my-web-application`. - -## Scaling services - -If you create multiple containers for a service with `docker-compose scale`, each container will join the network with the same name. For example, if you run `docker-compose scale web=3`, then 3 containers will join the network under the name `web`. Inside any container on the network, looking up the name `web` will return the IP address of one of them, but Docker and Compose do not provide any guarantees about which one. - -This limitation will be addressed in a future version of Compose, where a load balancer will join under the service name and balance traffic between the service's containers in a configurable manner. + container_name: "my-web-application" ## Links diff --git a/project/ISSUE-TRIAGE.md b/project/ISSUE-TRIAGE.md index 58312a60374..b89cdc240a9 100644 --- a/project/ISSUE-TRIAGE.md +++ b/project/ISSUE-TRIAGE.md @@ -20,15 +20,16 @@ The following labels are provided in additional to the standard labels: Most issues should fit into one of the following functional areas: -| Area | -|----------------| -| area/build | -| area/cli | -| area/config | -| area/logs | -| area/packaging | -| area/run | -| area/scale | -| area/tests | -| area/up | -| area/volumes | +| Area | +|-----------------| +| area/build | +| area/cli | +| area/config | +| area/logs | +| area/networking | +| area/packaging | +| area/run | +| area/scale | +| area/tests | +| area/up | +| area/volumes | From a71d9af522611947a74ffd7e0d45b720e9b69f98 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 28 Oct 2015 17:54:38 -0400 Subject: [PATCH 1401/4072] Disable a test against docker 1.8.3 because it fails due to a bug in docker engine. Signed-off-by: Daniel Nephin --- tests/integration/service_test.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 38d7d5b55bc..4ac04545e1c 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -693,10 +693,11 @@ def test_scale_with_desired_number_already_achieved(self, mock_log): @mock.patch('compose.service.log') def test_scale_with_custom_container_name_outputs_warning(self, mock_log): - """ - Test that calling scale on a service that has a custom container name + """Test that calling scale on a service that has a custom container name results in warning output. """ + # Disable this test against earlier versions because it is flaky + self.require_api_version('1.21') service = self.create_service('app', container_name='custom-container') self.assertEqual(service.custom_container_name(), 'custom-container') From 5dc14f39254a25cf87b104c9f2577f37e48c6de4 Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Tue, 27 Oct 2015 17:37:48 +0000 Subject: [PATCH 1402/4072] Handle non-ascii chars in volume directories Signed-off-by: Mazz Mosley --- compose/config/config.py | 2 +- tests/unit/config/config_test.py | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/compose/config/config.py b/compose/config/config.py index 40b4ffa48aa..5bc534fe9b2 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -505,7 +505,7 @@ def resolve_volume_path(volume, working_dir, service_name): if host_path.startswith('.'): host_path = expand_path(working_dir, host_path) host_path = os.path.expanduser(host_path) - return "{}:{}".format(host_path, container_path) + return u"{}:{}".format(host_path, container_path) else: return container_path diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index a54b006fa63..5ad7e1c0cf9 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -573,6 +573,11 @@ def test_home_directory_with_driver_does_not_expand(self): }, working_dir='.') self.assertEqual(d['volumes'], ['~:/data']) + def test_volume_path_with_non_ascii_directory(self): + volume = u'/Füü/data:/data' + container_path = config.resolve_volume_path(volume, ".", "test") + self.assertEqual(container_path, volume) + class MergePathMappingTest(object): def config_name(self): From eab9d86a3d016e7f846514ae81d7fd5b16c23257 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 29 Oct 2015 11:28:17 -0400 Subject: [PATCH 1403/4072] Logs are available for all log drivers except for none. Signed-off-by: Daniel Nephin --- compose/container.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/container.py b/compose/container.py index dd69e8dddb3..1ca483809ab 100644 --- a/compose/container.py +++ b/compose/container.py @@ -145,7 +145,7 @@ def log_driver(self): @property def has_api_logs(self): log_type = self.log_driver - return not log_type or log_type == 'json-file' + return not log_type or log_type != 'none' def attach_log_stream(self): """A log stream can only be attached if the container uses a json-file From 983dc12160915f1148c35296712290702cb32338 Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Thu, 29 Oct 2015 16:52:00 +0000 Subject: [PATCH 1404/4072] Clarify the command is an example Signed-off-by: Mazz Mosley --- docs/install.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/install.md b/docs/install.md index 2d4d6cadb63..944ce349d07 100644 --- a/docs/install.md +++ b/docs/install.md @@ -30,13 +30,14 @@ To install Compose, do the following: 3. Go to the Compose repository release page on GitHub. -4. Follow the instructions from the release page and run the `curl` command in your terminal. +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 command has the following format: + The following is an example command illustrating the format: curl -L https://github.com/docker/compose/releases/download/VERSION_NUM/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose From 596261e75985ffa7ed07c2dedfdc9d763e988db0 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 29 Oct 2015 17:56:01 +0100 Subject: [PATCH 1405/4072] Ensure network exists when calling run before up Otherwise the daemon will error out because the network doesn't exist yet. Signed-off-by: Joffrey F --- compose/cli/main.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/compose/cli/main.py b/compose/cli/main.py index 5505b89f514..4369aa707ad 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -380,6 +380,8 @@ def run(self, project, options): start_deps=True, strategy=ConvergenceStrategy.never, ) + elif project.use_networking: + project.ensure_network_exists() tty = True if detach or options['-T'] or not sys.stdin.isatty(): From d836973a04fbba01e693e74c4124f2dbceb4b57b Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 29 Oct 2015 14:06:50 -0400 Subject: [PATCH 1406/4072] Use colors when logging warnings or errors, so they are more obvious. Signed-off-by: Daniel Nephin --- compose/cli/formatter.py | 23 +++++++++++++++++++++++ compose/cli/main.py | 20 +++++++++++++------- compose/config/interpolation.py | 2 +- tests/unit/cli/main_test.py | 29 +++++++++++++++++++++++++++++ 4 files changed, 66 insertions(+), 8 deletions(-) diff --git a/compose/cli/formatter.py b/compose/cli/formatter.py index 9ed52c4aa51..d0ed0f87eb2 100644 --- a/compose/cli/formatter.py +++ b/compose/cli/formatter.py @@ -1,10 +1,13 @@ from __future__ import absolute_import from __future__ import unicode_literals +import logging import os import texttable +from compose.cli import colors + def get_tty_width(): tty_size = os.popen('stty size', 'r').read().split() @@ -15,6 +18,7 @@ def get_tty_width(): class Formatter(object): + """Format tabular data for printing.""" def table(self, headers, rows): table = texttable.Texttable(max_width=get_tty_width()) table.set_cols_dtype(['t' for h in headers]) @@ -23,3 +27,22 @@ def table(self, headers, rows): table.set_chars(['-', '|', '+', '-']) return table.draw() + + +class ConsoleWarningFormatter(logging.Formatter): + """A logging.Formatter which prints WARNING and ERROR messages with + a prefix of the log level colored appropriate for the log level. + """ + + def get_level_message(self, record): + separator = ': ' + if record.levelno == logging.WARNING: + return colors.yellow(record.levelname) + separator + if record.levelno == logging.ERROR: + return colors.red(record.levelname) + separator + + return '' + + def format(self, record): + message = super(ConsoleWarningFormatter, self).format(record) + return self.get_level_message(record) + message diff --git a/compose/cli/main.py b/compose/cli/main.py index 5505b89f514..1542f52fd3d 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -28,6 +28,7 @@ from .docopt_command import DocoptCommand from .docopt_command import NoSuchCommand from .errors import UserError +from .formatter import ConsoleWarningFormatter from .formatter import Formatter from .log_printer import LogPrinter from .utils import get_version_info @@ -41,7 +42,7 @@ console_handler = logging.StreamHandler(sys.stderr) INSECURE_SSL_WARNING = """ -Warning: --allow-insecure-ssl is deprecated and has no effect. +--allow-insecure-ssl is deprecated and has no effect. It will be removed in a future version of Compose. """ @@ -91,13 +92,18 @@ def setup_logging(): logging.getLogger("requests").propagate = False -def setup_console_handler(verbose): +def setup_console_handler(handler, verbose): + if handler.stream.isatty(): + format_class = ConsoleWarningFormatter + else: + format_class = logging.Formatter + if verbose: - console_handler.setFormatter(logging.Formatter('%(name)s.%(funcName)s: %(message)s')) - console_handler.setLevel(logging.DEBUG) + handler.setFormatter(format_class('%(name)s.%(funcName)s: %(message)s')) + handler.setLevel(logging.DEBUG) else: - console_handler.setFormatter(logging.Formatter()) - console_handler.setLevel(logging.INFO) + handler.setFormatter(format_class()) + handler.setLevel(logging.INFO) # stolen from docopt master @@ -153,7 +159,7 @@ def docopt_options(self): return options def perform_command(self, options, handler, command_options): - setup_console_handler(options.get('--verbose')) + setup_console_handler(console_handler, options.get('--verbose')) if options['COMMAND'] in ('help', 'version'): # Skip looking up the compose file. diff --git a/compose/config/interpolation.py b/compose/config/interpolation.py index f870ab4b27c..f8e1da610dc 100644 --- a/compose/config/interpolation.py +++ b/compose/config/interpolation.py @@ -78,7 +78,7 @@ def __getitem__(self, key): except KeyError: if key not in self.missing_keys: log.warn( - "The {} variable is not set. Substituting a blank string." + "The {} variable is not set. Defaulting to a blank string." .format(key) ) self.missing_keys.append(key) diff --git a/tests/unit/cli/main_test.py b/tests/unit/cli/main_test.py index a5b369808b7..ee837fcd45b 100644 --- a/tests/unit/cli/main_test.py +++ b/tests/unit/cli/main_test.py @@ -1,11 +1,15 @@ from __future__ import absolute_import +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 @@ -60,6 +64,31 @@ def test_attach_to_logs(self): timeout=timeout) +class SetupConsoleHandlerTestCase(unittest.TestCase): + + 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): + 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): + 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 + + class ConvergeStrategyFromOptsTestCase(unittest.TestCase): def test_invalid_opts(self): From 841ed4ed218e9d281a10a098d5f2ca81a5d4c46c Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 29 Oct 2015 14:15:08 -0400 Subject: [PATCH 1407/4072] Remove the duplicate 'Warning' prefix now that the logger adds the prefix. Signed-off-by: Daniel Nephin --- compose/cli/main.py | 5 ++--- compose/config/validation.py | 8 +++++--- compose/service.py | 2 +- tests/unit/cli/formatter_test.py | 35 ++++++++++++++++++++++++++++++++ tests/unit/config/config_test.py | 2 +- 5 files changed, 44 insertions(+), 8 deletions(-) create mode 100644 tests/unit/cli/formatter_test.py diff --git a/compose/cli/main.py b/compose/cli/main.py index 1542f52fd3d..34c63e77565 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -59,9 +59,8 @@ def main(): log.error(e.msg) sys.exit(1) except NoSuchCommand as e: - log.error("No such command: %s", e.command) - log.error("") - log.error("\n".join(parse_doc_section("commands:", getdoc(e.supercommand)))) + 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.error(e.explanation) diff --git a/compose/config/validation.py b/compose/config/validation.py index 8cfc405fe86..542081d5265 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -57,9 +57,11 @@ def format_boolean_in_environment(instance): """ if isinstance(instance, bool): log.warn( - "Warning: There is a boolean value in the 'environment' key.\n" - "Environment variables can only be strings.\nPlease add quotes to any boolean values to make them string " - "(eg, 'True', 'yes', 'N').\nThis warning will become an error in a future release. \r\n" + "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" + "This warning will become an error in a future release. \r\n" ) return True diff --git a/compose/service.py b/compose/service.py index ad29f87f478..8d716c0bb3c 100644 --- a/compose/service.py +++ b/compose/service.py @@ -862,7 +862,7 @@ def mode(self): if containers: return 'container:' + containers[0].id - log.warn("Warning: Service %s is trying to use reuse the network stack " + log.warn("Service %s is trying to use reuse the network stack " "of another service that is not running." % (self.id)) return None diff --git a/tests/unit/cli/formatter_test.py b/tests/unit/cli/formatter_test.py new file mode 100644 index 00000000000..1c3b6a68ef1 --- /dev/null +++ b/tests/unit/cli/formatter_test.py @@ -0,0 +1,35 @@ +from __future__ import absolute_import +from __future__ import unicode_literals + +import logging + +from compose.cli import colors +from compose.cli.formatter import ConsoleWarningFormatter +from tests import unittest + + +MESSAGE = 'this is the message' + + +def makeLogRecord(level): + return logging.LogRecord('name', level, 'pathame', 0, MESSAGE, (), None) + + +class ConsoleWarningFormatterTestCase(unittest.TestCase): + + def setUp(self): + self.formatter = ConsoleWarningFormatter() + + def test_format_warn(self): + output = self.formatter.format(makeLogRecord(logging.WARN)) + expected = colors.yellow('WARNING') + ': ' + assert output == expected + MESSAGE + + def test_format_error(self): + output = self.formatter.format(makeLogRecord(logging.ERROR)) + expected = colors.red('ERROR') + ': ' + assert output == expected + MESSAGE + + def test_format_info(self): + output = self.formatter.format(makeLogRecord(logging.INFO)) + assert output == MESSAGE diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 5ad7e1c0cf9..7246b661874 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -380,7 +380,7 @@ def test_valid_config_oneof_string_or_list(self): @mock.patch('compose.config.validation.log') def test_logs_warning_for_boolean_in_environment(self, mock_logging): - expected_warning_msg = "Warning: There is a boolean value in the 'environment' key." + expected_warning_msg = "There is a boolean value in the 'environment' key." config.load( build_config_details( {'web': { From 8156cdc56e5b5b1f440b9abbf54faaad14558c23 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 28 Oct 2015 17:54:38 -0400 Subject: [PATCH 1408/4072] Disable a test against docker 1.8.3 because it fails due to a bug in docker engine. Signed-off-by: Daniel Nephin --- tests/integration/service_test.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 38d7d5b55bc..4ac04545e1c 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -693,10 +693,11 @@ def test_scale_with_desired_number_already_achieved(self, mock_log): @mock.patch('compose.service.log') def test_scale_with_custom_container_name_outputs_warning(self, mock_log): - """ - Test that calling scale on a service that has a custom container name + """Test that calling scale on a service that has a custom container name results in warning output. """ + # Disable this test against earlier versions because it is flaky + self.require_api_version('1.21') service = self.create_service('app', container_name='custom-container') self.assertEqual(service.custom_container_name(), 'custom-container') From ce729b07216bd3f6e903829818e946282dbd7e51 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 28 Oct 2015 15:17:11 -0400 Subject: [PATCH 1409/4072] Update docs about networking for current release. Signed-off-by: Daniel Nephin --- docs/networking.md | 36 ++++++++++++++++++++++-------------- project/ISSUE-TRIAGE.md | 25 +++++++++++++------------ 2 files changed, 35 insertions(+), 26 deletions(-) diff --git a/docs/networking.md b/docs/networking.md index 9a6d792df46..718d56c7a26 100644 --- a/docs/networking.md +++ b/docs/networking.md @@ -14,7 +14,11 @@ weight=6 > **Note:** Compose's networking support is experimental, and must be explicitly enabled with the `docker-compose --x-networking` flag. -Compose sets up a single default [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 service's name. +Compose sets up a single default +[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. @@ -30,13 +34,23 @@ For example, suppose your app is in a directory called `myapp`, and your `docker When you run `docker-compose --x-networking up`, the following happens: 1. A network called `myapp` is created. -2. A container is created using `web`'s configuration. It joins the network `myapp` under the name `web`. -3. A container is created using `db`'s configuration. It joins the network `myapp` under the name `db`. +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`. -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. +Each container can now look up the hostname `myapp_web_1` or `myapp_db_1` 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 +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. @@ -45,19 +59,13 @@ If any containers have connections open to the old container, they will be close ## Configure how services are published -By default, containers for each service are published on the network with the same name as the service. If you want to change the name, or stop containers from being discoverable at all, you can use the `hostname` option: +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: . - hostname: "my-web-application" - -This will also change the hostname inside the container, so that the `hostname` command will return `my-web-application`. - -## Scaling services - -If you create multiple containers for a service with `docker-compose scale`, each container will join the network with the same name. For example, if you run `docker-compose scale web=3`, then 3 containers will join the network under the name `web`. Inside any container on the network, looking up the name `web` will return the IP address of one of them, but Docker and Compose do not provide any guarantees about which one. - -This limitation will be addressed in a future version of Compose, where a load balancer will join under the service name and balance traffic between the service's containers in a configurable manner. + container_name: "my-web-application" ## Links diff --git a/project/ISSUE-TRIAGE.md b/project/ISSUE-TRIAGE.md index 58312a60374..b89cdc240a9 100644 --- a/project/ISSUE-TRIAGE.md +++ b/project/ISSUE-TRIAGE.md @@ -20,15 +20,16 @@ The following labels are provided in additional to the standard labels: Most issues should fit into one of the following functional areas: -| Area | -|----------------| -| area/build | -| area/cli | -| area/config | -| area/logs | -| area/packaging | -| area/run | -| area/scale | -| area/tests | -| area/up | -| area/volumes | +| Area | +|-----------------| +| area/build | +| area/cli | +| area/config | +| area/logs | +| area/networking | +| area/packaging | +| area/run | +| area/scale | +| area/tests | +| area/up | +| area/volumes | From f67503d9fd2864fc16cf64811b1b38b6a58ff5e4 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 29 Oct 2015 11:28:17 -0400 Subject: [PATCH 1410/4072] Logs are available for all log drivers except for none. Signed-off-by: Daniel Nephin --- compose/container.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/container.py b/compose/container.py index dd69e8dddb3..1ca483809ab 100644 --- a/compose/container.py +++ b/compose/container.py @@ -145,7 +145,7 @@ def log_driver(self): @property def has_api_logs(self): log_type = self.log_driver - return not log_type or log_type == 'json-file' + return not log_type or log_type != 'none' def attach_log_stream(self): """A log stream can only be attached if the container uses a json-file From ab0ddb593f8a6a98c3bb85b8487574179f8f2262 Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Thu, 29 Oct 2015 16:52:00 +0000 Subject: [PATCH 1411/4072] Clarify the command is an example Signed-off-by: Mazz Mosley --- docs/install.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/install.md b/docs/install.md index 3c5dea5eba3..ea78948ed79 100644 --- a/docs/install.md +++ b/docs/install.md @@ -30,13 +30,14 @@ To install Compose, do the following: 3. Go to the Compose repository release page on GitHub. -4. Follow the instructions from the release page and run the `curl` command in your terminal. +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 command has the following format: + The following is an example command illustrating the format: curl -L https://github.com/docker/compose/releases/download/VERSION_NUM/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose From 072e7687ae1e710ee8b82b44f5baba8f20caa4cb Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 30 Oct 2015 14:15:47 +0100 Subject: [PATCH 1412/4072] Integration test for run command with networking enabled Signed-off-by: Joffrey F --- tests/integration/cli_test.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/integration/cli_test.py b/tests/integration/cli_test.py index 19cc822ee1d..45f45645f5b 100644 --- a/tests/integration/cli_test.py +++ b/tests/integration/cli_test.py @@ -508,6 +508,20 @@ def test_run_with_custom_name(self, _): container, = service.containers(stopped=True, one_off=True) self.assertEqual(container.name, name) + @mock.patch('dockerpty.start') + def test_run_with_networking(self, _): + self.require_api_version('1.21') + client = docker_client(version='1.21') + self.command.base_dir = 'tests/fixtures/simple-dockerfile' + self.command.dispatch(['--x-networking', 'run', 'simple', 'true'], None) + service = self.project.get_service('simple') + container, = service.containers(stopped=True, one_off=True) + networks = client.networks(names=[self.project.name]) + for n in networks: + self.addCleanup(client.remove_network, n['id']) + self.assertEqual(len(networks), 1) + self.assertEqual(container.human_readable_command, u'true') + def test_rm(self): service = self.project.get_service('simple') service.create_container() From 491d052088c3c5d66dba0bde175047748c44c73f Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 30 Oct 2015 11:53:36 -0400 Subject: [PATCH 1413/4072] Don't set a default network driver, let the server decide. Signed-off-by: Daniel Nephin --- compose/project.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/project.py b/compose/project.py index 68edaddcb28..1e01eaf6d24 100644 --- a/compose/project.py +++ b/compose/project.py @@ -83,7 +83,7 @@ def __init__(self, name, services, client, use_networking=False, network_driver= self.services = services self.client = client self.use_networking = use_networking - self.network_driver = network_driver or 'bridge' + self.network_driver = network_driver def labels(self, one_off=False): return [ From c7d164d01c82e1349a1d9ef2beae1754dbc3500a Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 27 Oct 2015 15:04:35 -0400 Subject: [PATCH 1414/4072] Fixes #1843, #1936 - chown files back to host user in django example. Also add a missing 'touch Gemfile.lock' to fix the rails tutorial. Signed-off-by: Daniel Nephin --- docs/django.md | 16 ++++++++++++++-- docs/rails.md | 14 ++++++++++++-- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/docs/django.md b/docs/django.md index fd18784eccf..2bb67399c52 100644 --- a/docs/django.md +++ b/docs/django.md @@ -110,8 +110,20 @@ In this step, you create a Django started project by building the image from the 3. After the `docker-compose` command completes, list the contents of your project. - $ ls - Dockerfile docker-compose.yml composeexample manage.py requirements.txt + $ 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 + + The files `django-admin` created are owned by root. This happens because + the container runs as the `root` user. + +4. Change the ownership of the new files. + + sudo chown -R $USER:$USER . + ## Connect the database diff --git a/docs/rails.md b/docs/rails.md index a33cac26edb..e81675c5374 100644 --- a/docs/rails.md +++ b/docs/rails.md @@ -37,6 +37,10 @@ Next, create a bootstrap `Gemfile` which just loads Rails. It'll be overwritten 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. db: @@ -69,6 +73,12 @@ image. Once it's done, you should have generated a fresh app: README.rdoc config.ru public Rakefile db test + +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. + + sudo chown -R $USER:$USER . + Uncomment the line in your new `Gemfile` which loads `therubyracer`, so you've got a Javascript runtime: @@ -80,6 +90,7 @@ rebuild.) $ docker-compose build + ### Connect the database The app is now bootable, but you're not quite there yet. By default, Rails @@ -87,8 +98,7 @@ 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. -Open up your newly-generated `database.yml` file. Replace its contents with the -following: +Replace the contents of `config/database.yml` with the following: development: &default adapter: postgresql From 9370cb033cc7a51e55144085d2e76795c64982af Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 29 Oct 2015 17:56:01 +0100 Subject: [PATCH 1415/4072] Ensure network exists when calling run before up Otherwise the daemon will error out because the network doesn't exist yet. Signed-off-by: Joffrey F --- compose/cli/main.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/compose/cli/main.py b/compose/cli/main.py index 5505b89f514..4369aa707ad 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -380,6 +380,8 @@ def run(self, project, options): start_deps=True, strategy=ConvergenceStrategy.never, ) + elif project.use_networking: + project.ensure_network_exists() tty = True if detach or options['-T'] or not sys.stdin.isatty(): From 1f26841e238e4d1f005d194cae6f076b93a3d8a2 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 30 Oct 2015 14:15:47 +0100 Subject: [PATCH 1416/4072] Integration test for run command with networking enabled Signed-off-by: Joffrey F --- tests/integration/cli_test.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/integration/cli_test.py b/tests/integration/cli_test.py index 19cc822ee1d..45f45645f5b 100644 --- a/tests/integration/cli_test.py +++ b/tests/integration/cli_test.py @@ -508,6 +508,20 @@ def test_run_with_custom_name(self, _): container, = service.containers(stopped=True, one_off=True) self.assertEqual(container.name, name) + @mock.patch('dockerpty.start') + def test_run_with_networking(self, _): + self.require_api_version('1.21') + client = docker_client(version='1.21') + self.command.base_dir = 'tests/fixtures/simple-dockerfile' + self.command.dispatch(['--x-networking', 'run', 'simple', 'true'], None) + service = self.project.get_service('simple') + container, = service.containers(stopped=True, one_off=True) + networks = client.networks(names=[self.project.name]) + for n in networks: + self.addCleanup(client.remove_network, n['id']) + self.assertEqual(len(networks), 1) + self.assertEqual(container.human_readable_command, u'true') + def test_rm(self): service = self.project.get_service('simple') service.create_container() From 4d613d3ba780f46c46669e829eadad1daf5e0cb4 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 29 Oct 2015 14:06:50 -0400 Subject: [PATCH 1417/4072] Use colors when logging warnings or errors, so they are more obvious. Signed-off-by: Daniel Nephin --- compose/cli/formatter.py | 23 +++++++++++++++++++++++ compose/cli/main.py | 20 +++++++++++++------- compose/config/interpolation.py | 2 +- tests/unit/cli/main_test.py | 29 +++++++++++++++++++++++++++++ 4 files changed, 66 insertions(+), 8 deletions(-) diff --git a/compose/cli/formatter.py b/compose/cli/formatter.py index 9ed52c4aa51..d0ed0f87eb2 100644 --- a/compose/cli/formatter.py +++ b/compose/cli/formatter.py @@ -1,10 +1,13 @@ from __future__ import absolute_import from __future__ import unicode_literals +import logging import os import texttable +from compose.cli import colors + def get_tty_width(): tty_size = os.popen('stty size', 'r').read().split() @@ -15,6 +18,7 @@ def get_tty_width(): class Formatter(object): + """Format tabular data for printing.""" def table(self, headers, rows): table = texttable.Texttable(max_width=get_tty_width()) table.set_cols_dtype(['t' for h in headers]) @@ -23,3 +27,22 @@ def table(self, headers, rows): table.set_chars(['-', '|', '+', '-']) return table.draw() + + +class ConsoleWarningFormatter(logging.Formatter): + """A logging.Formatter which prints WARNING and ERROR messages with + a prefix of the log level colored appropriate for the log level. + """ + + def get_level_message(self, record): + separator = ': ' + if record.levelno == logging.WARNING: + return colors.yellow(record.levelname) + separator + if record.levelno == logging.ERROR: + return colors.red(record.levelname) + separator + + return '' + + def format(self, record): + message = super(ConsoleWarningFormatter, self).format(record) + return self.get_level_message(record) + message diff --git a/compose/cli/main.py b/compose/cli/main.py index 4369aa707ad..2a5ab49af8e 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -28,6 +28,7 @@ from .docopt_command import DocoptCommand from .docopt_command import NoSuchCommand from .errors import UserError +from .formatter import ConsoleWarningFormatter from .formatter import Formatter from .log_printer import LogPrinter from .utils import get_version_info @@ -41,7 +42,7 @@ console_handler = logging.StreamHandler(sys.stderr) INSECURE_SSL_WARNING = """ -Warning: --allow-insecure-ssl is deprecated and has no effect. +--allow-insecure-ssl is deprecated and has no effect. It will be removed in a future version of Compose. """ @@ -91,13 +92,18 @@ def setup_logging(): logging.getLogger("requests").propagate = False -def setup_console_handler(verbose): +def setup_console_handler(handler, verbose): + if handler.stream.isatty(): + format_class = ConsoleWarningFormatter + else: + format_class = logging.Formatter + if verbose: - console_handler.setFormatter(logging.Formatter('%(name)s.%(funcName)s: %(message)s')) - console_handler.setLevel(logging.DEBUG) + handler.setFormatter(format_class('%(name)s.%(funcName)s: %(message)s')) + handler.setLevel(logging.DEBUG) else: - console_handler.setFormatter(logging.Formatter()) - console_handler.setLevel(logging.INFO) + handler.setFormatter(format_class()) + handler.setLevel(logging.INFO) # stolen from docopt master @@ -153,7 +159,7 @@ def docopt_options(self): return options def perform_command(self, options, handler, command_options): - setup_console_handler(options.get('--verbose')) + setup_console_handler(console_handler, options.get('--verbose')) if options['COMMAND'] in ('help', 'version'): # Skip looking up the compose file. diff --git a/compose/config/interpolation.py b/compose/config/interpolation.py index f870ab4b27c..f8e1da610dc 100644 --- a/compose/config/interpolation.py +++ b/compose/config/interpolation.py @@ -78,7 +78,7 @@ def __getitem__(self, key): except KeyError: if key not in self.missing_keys: log.warn( - "The {} variable is not set. Substituting a blank string." + "The {} variable is not set. Defaulting to a blank string." .format(key) ) self.missing_keys.append(key) diff --git a/tests/unit/cli/main_test.py b/tests/unit/cli/main_test.py index a5b369808b7..ee837fcd45b 100644 --- a/tests/unit/cli/main_test.py +++ b/tests/unit/cli/main_test.py @@ -1,11 +1,15 @@ from __future__ import absolute_import +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 @@ -60,6 +64,31 @@ def test_attach_to_logs(self): timeout=timeout) +class SetupConsoleHandlerTestCase(unittest.TestCase): + + 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): + 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): + 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 + + class ConvergeStrategyFromOptsTestCase(unittest.TestCase): def test_invalid_opts(self): From db164cefd336e152b12e3088aa92d274d852b157 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 29 Oct 2015 14:15:08 -0400 Subject: [PATCH 1418/4072] Remove the duplicate 'Warning' prefix now that the logger adds the prefix. Signed-off-by: Daniel Nephin --- compose/cli/main.py | 5 ++--- compose/config/validation.py | 8 +++++--- compose/service.py | 2 +- tests/unit/cli/formatter_test.py | 35 ++++++++++++++++++++++++++++++++ tests/unit/config/config_test.py | 2 +- 5 files changed, 44 insertions(+), 8 deletions(-) create mode 100644 tests/unit/cli/formatter_test.py diff --git a/compose/cli/main.py b/compose/cli/main.py index 2a5ab49af8e..b54b307ef20 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -59,9 +59,8 @@ def main(): log.error(e.msg) sys.exit(1) except NoSuchCommand as e: - log.error("No such command: %s", e.command) - log.error("") - log.error("\n".join(parse_doc_section("commands:", getdoc(e.supercommand)))) + 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.error(e.explanation) diff --git a/compose/config/validation.py b/compose/config/validation.py index 8cfc405fe86..542081d5265 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -57,9 +57,11 @@ def format_boolean_in_environment(instance): """ if isinstance(instance, bool): log.warn( - "Warning: There is a boolean value in the 'environment' key.\n" - "Environment variables can only be strings.\nPlease add quotes to any boolean values to make them string " - "(eg, 'True', 'yes', 'N').\nThis warning will become an error in a future release. \r\n" + "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" + "This warning will become an error in a future release. \r\n" ) return True diff --git a/compose/service.py b/compose/service.py index dbcec103823..66c90b0e035 100644 --- a/compose/service.py +++ b/compose/service.py @@ -848,7 +848,7 @@ def mode(self): if containers: return 'container:' + containers[0].id - log.warn("Warning: Service %s is trying to use reuse the network stack " + log.warn("Service %s is trying to use reuse the network stack " "of another service that is not running." % (self.id)) return None diff --git a/tests/unit/cli/formatter_test.py b/tests/unit/cli/formatter_test.py new file mode 100644 index 00000000000..1c3b6a68ef1 --- /dev/null +++ b/tests/unit/cli/formatter_test.py @@ -0,0 +1,35 @@ +from __future__ import absolute_import +from __future__ import unicode_literals + +import logging + +from compose.cli import colors +from compose.cli.formatter import ConsoleWarningFormatter +from tests import unittest + + +MESSAGE = 'this is the message' + + +def makeLogRecord(level): + return logging.LogRecord('name', level, 'pathame', 0, MESSAGE, (), None) + + +class ConsoleWarningFormatterTestCase(unittest.TestCase): + + def setUp(self): + self.formatter = ConsoleWarningFormatter() + + def test_format_warn(self): + output = self.formatter.format(makeLogRecord(logging.WARN)) + expected = colors.yellow('WARNING') + ': ' + assert output == expected + MESSAGE + + def test_format_error(self): + output = self.formatter.format(makeLogRecord(logging.ERROR)) + expected = colors.red('ERROR') + ': ' + assert output == expected + MESSAGE + + def test_format_info(self): + output = self.formatter.format(makeLogRecord(logging.INFO)) + assert output == MESSAGE diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index a54b006fa63..2835e9c805c 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -380,7 +380,7 @@ def test_valid_config_oneof_string_or_list(self): @mock.patch('compose.config.validation.log') def test_logs_warning_for_boolean_in_environment(self, mock_logging): - expected_warning_msg = "Warning: There is a boolean value in the 'environment' key." + expected_warning_msg = "There is a boolean value in the 'environment' key." config.load( build_config_details( {'web': { From d392f70cc6aa88f60f40cf2e58d9603ebac847ac Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 27 Oct 2015 15:04:35 -0400 Subject: [PATCH 1419/4072] Fixes #1843, #1936 - chown files back to host user in django example. Also add a missing 'touch Gemfile.lock' to fix the rails tutorial. Signed-off-by: Daniel Nephin --- docs/django.md | 16 ++++++++++++++-- docs/rails.md | 14 ++++++++++++-- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/docs/django.md b/docs/django.md index fd18784eccf..2bb67399c52 100644 --- a/docs/django.md +++ b/docs/django.md @@ -110,8 +110,20 @@ In this step, you create a Django started project by building the image from the 3. After the `docker-compose` command completes, list the contents of your project. - $ ls - Dockerfile docker-compose.yml composeexample manage.py requirements.txt + $ 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 + + The files `django-admin` created are owned by root. This happens because + the container runs as the `root` user. + +4. Change the ownership of the new files. + + sudo chown -R $USER:$USER . + ## Connect the database diff --git a/docs/rails.md b/docs/rails.md index a33cac26edb..e81675c5374 100644 --- a/docs/rails.md +++ b/docs/rails.md @@ -37,6 +37,10 @@ Next, create a bootstrap `Gemfile` which just loads Rails. It'll be overwritten 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. db: @@ -69,6 +73,12 @@ image. Once it's done, you should have generated a fresh app: README.rdoc config.ru public Rakefile db test + +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. + + sudo chown -R $USER:$USER . + Uncomment the line in your new `Gemfile` which loads `therubyracer`, so you've got a Javascript runtime: @@ -80,6 +90,7 @@ rebuild.) $ docker-compose build + ### Connect the database The app is now bootable, but you're not quite there yet. By default, Rails @@ -87,8 +98,7 @@ 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. -Open up your newly-generated `database.yml` file. Replace its contents with the -following: +Replace the contents of `config/database.yml` with the following: development: &default adapter: postgresql From 2f2e946907e463404e36525e91ec90f05b606e29 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 30 Oct 2015 11:53:36 -0400 Subject: [PATCH 1420/4072] Don't set a default network driver, let the server decide. Signed-off-by: Daniel Nephin --- compose/project.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/project.py b/compose/project.py index 68edaddcb28..1e01eaf6d24 100644 --- a/compose/project.py +++ b/compose/project.py @@ -83,7 +83,7 @@ def __init__(self, name, services, client, use_networking=False, network_driver= self.services = services self.client = client self.use_networking = use_networking - self.network_driver = network_driver or 'bridge' + self.network_driver = network_driver def labels(self, one_off=False): return [ From ed1b584c42c5acd6f33278df48939957720319b4 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 28 Oct 2015 12:01:34 -0400 Subject: [PATCH 1421/4072] Fix release script notes about software and typos. Signed-off-by: Daniel Nephin --- project/RELEASE-PROCESS.md | 12 ++++++++++-- script/release/cherry-pick-pr | 2 +- script/release/push-release | 5 ++++- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/project/RELEASE-PROCESS.md b/project/RELEASE-PROCESS.md index ffa18077f4b..040a2602be4 100644 --- a/project/RELEASE-PROCESS.md +++ b/project/RELEASE-PROCESS.md @@ -1,6 +1,14 @@ Building a Compose release ========================== +## Prerequisites + +The release scripts require the following tools installed on the host: + +* https://hub.github.com/ +* https://stedolan.github.io/jq/ +* http://pandoc.org/ + ## To get started with a new release Create a branch, update version, and add release notes by running `make-branch` @@ -40,10 +48,10 @@ As part of this script you'll be asked to: ## To release a version (whether RC or stable) -Check out the bump branch and run the `build-binary` script +Check out the bump branch and run the `build-binaries` script git checkout bump-$VERSION - ./script/release/build-binary + ./script/release/build-binaries When prompted build the non-linux binaries and test them. diff --git a/script/release/cherry-pick-pr b/script/release/cherry-pick-pr index 604600872cf..f4a5a7406b3 100755 --- a/script/release/cherry-pick-pr +++ b/script/release/cherry-pick-pr @@ -22,7 +22,7 @@ EOM if [ -z "$(command -v hub 2> /dev/null)" ]; then >&2 echo "$0 requires https://hub.github.com/." - >&2 echo "Please install it and ake sure it is available on your \$PATH." + >&2 echo "Please install it and make sure it is available on your \$PATH." exit 2 fi diff --git a/script/release/push-release b/script/release/push-release index 039436da0e2..9229f0934d4 100755 --- a/script/release/push-release +++ b/script/release/push-release @@ -34,7 +34,9 @@ GITHUB_REPO=git@github.com:$REPO sha=$(git rev-parse HEAD) url=$API/$REPO/statuses/$sha build_status=$(curl -s $url | jq -r '.[0].state') -if [[ "$build_status" != "success" ]]; then +if [ -n "$SKIP_BUILD_CHECK" ]; then + echo "Skipping build status check..." +elif [[ "$build_status" != "success" ]]; then >&2 echo "Build status is $build_status, but it should be success." exit -1 fi @@ -61,6 +63,7 @@ source venv-test/bin/activate pip install docker-compose==$VERSION docker-compose version deactivate +rm -rf venv-test echo "Now publish the github release, and test the downloads." echo "Email maintainers@dockerproject.org and engineering@docker.com about the new release." From 569ccbadec9edb396135ec991417e340d2bd56ee Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 28 Oct 2015 12:11:29 -0400 Subject: [PATCH 1422/4072] Convert the README to rst and fix the logo url before packaging it up for pypi. Signed-off-by: Daniel Nephin --- .gitignore | 1 + MANIFEST.in | 2 ++ script/release/push-release | 15 +++++++++++---- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 1b0c50113fa..83a08a0e697 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ /dist /docs/_site /venv +README.rst diff --git a/MANIFEST.in b/MANIFEST.in index 43ae06d3e25..0342e35bea5 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -4,6 +4,8 @@ include requirements.txt include requirements-dev.txt include tox.ini include *.md +exclude README.md +include README.rst include compose/config/*.json recursive-include contrib/completion * recursive-include tests * diff --git a/script/release/push-release b/script/release/push-release index 9229f0934d4..ccdf2496077 100755 --- a/script/release/push-release +++ b/script/release/push-release @@ -21,11 +21,17 @@ VERSION="$(git config "branch.${BRANCH}.release")" || usage if [ -z "$(command -v jq 2> /dev/null)" ]; then >&2 echo "$0 requires https://stedolan.github.io/jq/" - >&2 echo "Please install it and ake sure it is available on your \$PATH." + >&2 echo "Please install it and make sure it is available on your \$PATH." exit 2 fi +if [ -z "$(command -v pandoc 2> /dev/null)" ]; then + >&2 echo "$0 requires http://pandoc.org/" + >&2 echo "Please install it and make sure it is available on your \$PATH." + exit 2 +fi + API=https://api.github.com/repos REPO=docker/compose GITHUB_REPO=git@github.com:$REPO @@ -45,12 +51,13 @@ echo "Tagging the release as $VERSION" git tag $VERSION git push $GITHUB_REPO $VERSION -echo "Uploading sdist to pypi" -python setup.py sdist - echo "Uploading the docker image" 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 +python setup.py sdist if [ "$(command -v twine 2> /dev/null)" ]; then twine upload ./dist/docker-compose-${VERSION}.tar.gz else From 73ca4eb5991dda6986d4970635a2f6aea463f8e4 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 28 Oct 2015 12:40:59 -0400 Subject: [PATCH 1423/4072] On error print daemon logs Signed-off-by: Daniel Nephin --- script/test-versions | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/script/test-versions b/script/test-versions index 89793359be5..43326ccb6b5 100755 --- a/script/test-versions +++ b/script/test-versions @@ -28,10 +28,15 @@ for version in $DOCKER_VERSIONS; do >&2 echo "Running tests against Docker $version" daemon_container="compose-dind-$version-$BUILD_NUMBER" - trap "docker rm -vf $daemon_container" EXIT - # TODO: remove when we stop testing against 1.7.x - daemon=$([[ "$version" == "1.7"* ]] && echo "-d" || echo "daemon") + function on_exit() { + if [[ "$?" != "0" ]]; then + docker logs "$daemon_container" + fi + docker rm -vf "$daemon_container" + } + + trap "on_exit" EXIT docker run \ -d \ @@ -39,7 +44,7 @@ for version in $DOCKER_VERSIONS; do --privileged \ --volume="/var/lib/docker" \ dockerswarm/dind:$version \ - docker $daemon -H tcp://0.0.0.0:2375 $DOCKER_DAEMON_ARGS \ + docker daemon -H tcp://0.0.0.0:2375 $DOCKER_DAEMON_ARGS \ docker run \ --rm \ From bdb9a280bc8e42ac79dc453c44b4ceb74f1aaee6 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 21 Oct 2015 15:26:44 -0400 Subject: [PATCH 1424/4072] Make storage driver configurable in CI Signed-off-by: Daniel Nephin --- script/ci | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/script/ci b/script/ci index 12dc3c473e9..f30265c02a6 100755 --- a/script/ci +++ b/script/ci @@ -11,7 +11,9 @@ set -ex docker version export DOCKER_VERSIONS=all -export DOCKER_DAEMON_ARGS="--storage-driver=overlay" +STORAGE_DRIVER=${STORAGE_DRIVER:-overlay} +export DOCKER_DAEMON_ARGS="--storage-driver=$STORAGE_DRIVER" + GIT_VOLUME="--volumes-from=$(hostname)" . script/test-versions From be6b811c4e9a1e8f22f2216c128b9bc91f4ebfdd Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 29 Oct 2015 12:51:57 -0400 Subject: [PATCH 1425/4072] Bump 1.5.0rc3 Signed-off-by: Daniel Nephin --- CHANGELOG.md | 20 +++++++++++++++++--- compose/__init__.py | 2 +- docs/install.md | 4 ++-- script/run.sh | 2 +- 4 files changed, 21 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b2ecd97e6f..b0474ae2b3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,19 +1,33 @@ Change log ========== -1.5.0 (2015-10-13) +1.5.0 (2015-11-02) ------------------ +**Breaking changes:** + +With the introduction of variable substitution support in the Compose file, any +Compose file that uses an environment variable (`$VAR` or `${VAR}`) in the `command:` +or `entrypoint:` field will break. + +Previously these values were interpolated inside the container, with a value +from the container environment. In Compose 1.5.0, the values will be +interpolated on the host, with a value from the host environment. + +To migrate a Compose file to 1.5.0, escape the variables with an extra `$` +(ex: `$$VAR` or `$${VAR}`). See +https://github.com/docker/compose/blob/8cc8e61/docs/compose-file.md#variable-substitution + Major features: - Compose is now available for Windows. - Environment variables can be used in the Compose file. See - https://github.com/docker/compose/blob/129092b7/docs/yml.md#variable-substitution + https://github.com/docker/compose/blob/8cc8e61/docs/compose-file.md#variable-substitution - Multiple compose files can be specified, allowing you to override settings in the default Compose file. See - https://github.com/docker/compose/blob/129092b7/docs/reference/docker-compose.md + https://github.com/docker/compose/blob/8cc8e61/docs/reference/docker-compose.md for more details. - Compose now produces better error messages when a file contains diff --git a/compose/__init__.py b/compose/__init__.py index 8ea59a363f1..7199babb40d 100644 --- a/compose/__init__.py +++ b/compose/__init__.py @@ -1,3 +1,3 @@ from __future__ import unicode_literals -__version__ = '1.5.0rc2' +__version__ = '1.5.0rc3' diff --git a/docs/install.md b/docs/install.md index ea78948ed79..711902c7fa4 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.0rc2 + docker-compose version: 1.5.0rc3 ## Alternative install options @@ -76,7 +76,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.0rc2/run.sh > /usr/local/bin/docker-compose + $ curl -L https://github.com/docker/compose/releases/download/1.5.0rc3/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 25fc8c07704..9ed1ea74cf6 100755 --- a/script/run.sh +++ b/script/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.5.0rc2" +VERSION="1.5.0rc3" IMAGE="docker/compose:$VERSION" From 1b5b40761943bf9e358f7f70859d62097cdb4f1b Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 30 Oct 2015 15:26:56 -0400 Subject: [PATCH 1426/4072] Fix networking tests to work with new API in engine rc4 (https://github.com/docker/docker/pull/17536) Signed-off-by: Daniel Nephin --- tests/integration/cli_test.py | 12 ++++++------ tests/integration/project_test.py | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/integration/cli_test.py b/tests/integration/cli_test.py index 45f45645f5b..d621f2d1320 100644 --- a/tests/integration/cli_test.py +++ b/tests/integration/cli_test.py @@ -215,17 +215,17 @@ def test_up_with_networking(self): networks = client.networks(names=[self.project.name]) for n in networks: - self.addCleanup(client.remove_network, n['id']) + self.addCleanup(client.remove_network, n['Id']) self.assertEqual(len(networks), 1) - self.assertEqual(networks[0]['driver'], 'bridge') + self.assertEqual(networks[0]['Driver'], 'bridge') - network = client.inspect_network(networks[0]['id']) - self.assertEqual(len(network['containers']), len(services)) + network = client.inspect_network(networks[0]['Id']) + self.assertEqual(len(network['Containers']), len(services)) for service in services: containers = service.containers() self.assertEqual(len(containers), 1) - self.assertIn(containers[0].id, network['containers']) + self.assertIn(containers[0].id, network['Containers']) self.assertEqual(containers[0].get('Config.Hostname'), service.name) web_container = self.project.get_service('web').containers()[0] @@ -518,7 +518,7 @@ def test_run_with_networking(self, _): container, = service.containers(stopped=True, one_off=True) networks = client.networks(names=[self.project.name]) for n in networks: - self.addCleanup(client.remove_network, n['id']) + self.addCleanup(client.remove_network, n['Id']) self.assertEqual(len(networks), 1) self.assertEqual(container.human_readable_command, u'true') diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index fd45b9393f9..950523878e2 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -111,7 +111,7 @@ def test_get_network(self): network_name = 'network_does_exist' project = Project(network_name, [], client) client.create_network(network_name) - assert project.get_network()['name'] == network_name + assert project.get_network()['Name'] == network_name def test_net_from_service(self): project = Project.from_dicts( From abde64d610135e957aeb76958fdf384882fcc717 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 2 Nov 2015 13:43:29 -0500 Subject: [PATCH 1427/4072] On a test failure only show the last 100 lines of daemon output. Signed-off-by: Daniel Nephin --- script/build-linux-inner | 2 +- script/test-versions | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/script/build-linux-inner b/script/build-linux-inner index 1d0f790504a..01137ff2408 100755 --- a/script/build-linux-inner +++ b/script/build-linux-inner @@ -8,7 +8,7 @@ VENV=/code/.tox/py27 mkdir -p `pwd`/dist chmod 777 `pwd`/dist -$VENV/bin/pip install -r requirements-build.txt +$VENV/bin/pip install -q -r requirements-build.txt su -c "$VENV/bin/pyinstaller docker-compose.spec" user mv dist/docker-compose $TARGET $TARGET version diff --git a/script/test-versions b/script/test-versions index 43326ccb6b5..623b107b930 100755 --- a/script/test-versions +++ b/script/test-versions @@ -31,7 +31,7 @@ for version in $DOCKER_VERSIONS; do function on_exit() { if [[ "$?" != "0" ]]; then - docker logs "$daemon_container" + docker logs "$daemon_container" 2>&1 | tail -n 100 fi docker rm -vf "$daemon_container" } @@ -45,6 +45,7 @@ for version in $DOCKER_VERSIONS; do --volume="/var/lib/docker" \ dockerswarm/dind:$version \ docker daemon -H tcp://0.0.0.0:2375 $DOCKER_DAEMON_ARGS \ + 2>&1 | tail -n 10 docker run \ --rm \ From 53a0de7cf2231da88c7501bb367eb4e7f1c4d425 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 27 Oct 2015 16:29:36 -0400 Subject: [PATCH 1428/4072] Add missing title to compose file reference. Signed-off-by: Daniel Nephin --- docs/compose-file.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/compose-file.md b/docs/compose-file.md index d45916081dc..7723a784769 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -24,6 +24,11 @@ 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`. +## Service configuration reference + +This section contains a list of all configuration options supported by a service +definition. + ### build Path to a directory containing a Dockerfile. When the value supplied is a From 186d43c59f82e4210b379885368eb73a4162003d Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 9 Oct 2015 10:49:41 -0400 Subject: [PATCH 1429/4072] Extract the getting started guide from the index page. Signed-off-by: Daniel Nephin --- docs/completion.md | 2 +- docs/django.md | 1 + docs/extends.md | 1 + docs/gettingstarted.md | 163 +++++++++++++++++++++++++++++++++++++++++ docs/index.md | 139 +---------------------------------- docs/install.md | 1 + docs/production.md | 3 - docs/rails.md | 2 +- docs/wordpress.md | 2 +- 9 files changed, 170 insertions(+), 144 deletions(-) create mode 100644 docs/gettingstarted.md diff --git a/docs/completion.md b/docs/completion.md index bc8bedc96cc..3c2022d8278 100644 --- a/docs/completion.md +++ b/docs/completion.md @@ -5,7 +5,7 @@ description = "Compose CLI reference" keywords = ["fig, composition, compose, docker, orchestration, cli, reference"] [menu.main] parent="smn_workw_compose" -weight=3 +weight=10 +++ diff --git a/docs/django.md b/docs/django.md index 2bb67399c52..d4d2bd1ecf0 100644 --- a/docs/django.md +++ b/docs/django.md @@ -173,6 +173,7 @@ In this section, you set up the database connection for Django. - [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) diff --git a/docs/extends.md b/docs/extends.md index f0b9e9ea2d6..e63cf4662ec 100644 --- a/docs/extends.md +++ b/docs/extends.md @@ -360,6 +360,7 @@ locally-defined bindings taking precedence: - [User guide](/) - [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) diff --git a/docs/gettingstarted.md b/docs/gettingstarted.md new file mode 100644 index 00000000000..f2024b39ba7 --- /dev/null +++ b/docs/gettingstarted.md @@ -0,0 +1,163 @@ + + + +## Getting Started + +Let's get started with a walkthrough of getting a simple Python web app running +on Compose. It assumes a little knowledge of Python, but the concepts +demonstrated here should be understandable even if you're not familiar with +Python. + +### Installation and set-up + +First, [install Docker and Compose](install.md). + +Next, you'll want to make a directory for the project: + + $ mkdir composetest + $ cd composetest + +Inside this directory, create `app.py`, a simple Python web app that uses the Flask +framework and increments a value in Redis. Don't worry if you don't have Redis installed, docker is going to take care of that for you when we [define services](#define-services): + + 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) + +Next, define the Python dependencies in a file called `requirements.txt`: + + flask + redis + +### Create a Docker image + +Now, create a Docker image containing all of your app's dependencies. You +specify how to build the image using a file called +[`Dockerfile`](http://docs.docker.com/reference/builder/): + + 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](https://docs.docker.com/userguide/dockerimages/#building-an-image-from-a-dockerfile) and the [Dockerfile reference](http://docs.docker.com/reference/builder/). + +You can build the image by running `docker build -t web .`. + +### Define services + +Next, define a set of services using `docker-compose.yml`: + + web: + build: . + ports: + - "5000:5000" + volumes: + - .:/code + links: + - redis + redis: + image: redis + +This template 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 current directory on the host to `/code` inside the container allowing you to modify the code without having to rebuild the image. +* Links the web container 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. + +### Build and run your app with Compose + +Now, when you run `docker-compose up`, Compose will pull a Redis image, build an image for your code, and start everything up: + + $ 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 + +If you're using [Docker Machine](https://docs.docker.com/machine), then `docker-machine ip MACHINE_VM` will tell you its address and you can open `http://MACHINE_VM_IP:5000` in a browser. + +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`. + +You should get a message in your browser saying: + +`Hello World! I have been seen 1 times.` + +Refreshing the page will increment the number. + +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. + +- Next, try the quick start guide for [Django](django.md), + [Rails](rails.md), or [WordPress](wordpress.md). +- See the reference guides for complete details on the [commands](./reference/index.md), the + [configuration file](compose-file.md) and [environment variables](env.md). + +## More Compose documentation + +- [User guide](/) +- [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/index.md b/docs/index.md index 62c78d68936..19a6c801c2d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -50,150 +50,13 @@ Compose has commands for managing the whole lifecycle of your application: ## 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) - [Command line reference](./reference/index.md) - [Compose file reference](compose-file.md) -## Quick start - -Let's get started with a walkthrough of getting a simple Python web app running -on Compose. It assumes a little knowledge of Python, but the concepts -demonstrated here should be understandable even if you're not familiar with -Python. - -### Installation and set-up - -First, [install Docker and Compose](install.md). - -Next, you'll want to make a directory for the project: - - $ mkdir composetest - $ cd composetest - -Inside this directory, create `app.py`, a simple Python web app that uses the Flask -framework and increments a value in Redis. Don't worry if you don't have Redis installed, docker is going to take care of that for you when we [define services](#define-services): - - 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) - -Next, define the Python dependencies in a file called `requirements.txt`: - - flask - redis - -### Create a Docker image - -Now, create a Docker image containing all of your app's dependencies. You -specify how to build the image using a file called -[`Dockerfile`](http://docs.docker.com/reference/builder/): - - 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](https://docs.docker.com/userguide/dockerimages/#building-an-image-from-a-dockerfile) and the [Dockerfile reference](http://docs.docker.com/reference/builder/). - -You can build the image by running `docker build -t web .`. - -### Define services - -Next, define a set of services using `docker-compose.yml`: - - web: - build: . - ports: - - "5000:5000" - volumes: - - .:/code - redis: - image: redis - -This template 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 current directory on the host to `/code` inside the container allowing you to modify the code without having to rebuild the image. - -The `redis` service uses the latest public [Redis](https://registry.hub.docker.com/_/redis/) image pulled from the Docker Hub registry. - -### Build and run your app with Compose - -Now, when you run `docker-compose up`, Compose will pull a Redis image, build an image for your code, and start everything up: - - $ 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 - -If you're using [Docker Machine](https://docs.docker.com/machine), then `docker-machine ip MACHINE_VM` will tell you its address and you can open `http://MACHINE_VM_IP:5000` in a browser. - -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`. - -You should get a message in your browser saying: - -`Hello World! I have been seen 1 times.` - -Refreshing the page will increment the number. - -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. - -- Next, try the quick start guide for [Django](django.md), - [Rails](rails.md), or [WordPress](wordpress.md). -- See the reference guides for complete details on the [commands](./reference/index.md), the - [configuration file](compose-file.md) and [environment variables](env.md). ## Release Notes diff --git a/docs/install.md b/docs/install.md index 944ce349d07..e19bda0f3b7 100644 --- a/docs/install.md +++ b/docs/install.md @@ -127,6 +127,7 @@ To uninstall Docker Compose if you installed using `pip`: ## Where to go next - [User guide](/) +- [Getting Started](gettingstarted.md) - [Get started with Django](django.md) - [Get started with Rails](rails.md) - [Get started with WordPress](wordpress.md) diff --git a/docs/production.md b/docs/production.md index 8793f9277e4..0b0e46c3f00 100644 --- a/docs/production.md +++ b/docs/production.md @@ -86,8 +86,5 @@ guide. ## 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/rails.md b/docs/rails.md index e81675c5374..8e16af64230 100644 --- a/docs/rails.md +++ b/docs/rails.md @@ -135,8 +135,8 @@ That's it. Your app should now be running on port 3000 on your Docker daemon. If - [User guide](/) - [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/wordpress.md b/docs/wordpress.md index 8c1f5b0acb2..373ef4d0d51 100644 --- a/docs/wordpress.md +++ b/docs/wordpress.md @@ -95,8 +95,8 @@ database containers. If you're using [Docker Machine](https://docs.docker.com/ma - [User guide](/) - [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) From 86d845fde3ec63d762a2dbd0dbcfc21eb8011f52 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 9 Oct 2015 11:08:23 -0400 Subject: [PATCH 1430/4072] Flush out features and use cases. Signed-off-by: Daniel Nephin --- README.md | 17 ++++++---- docs/index.md | 94 ++++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 97 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index ed176550dbb..55346f24cc6 100644 --- a/README.md +++ b/README.md @@ -4,13 +4,15 @@ Docker Compose *(Previously known as Fig)* -Compose is a tool for defining and running multi-container applications with -Docker. With Compose, you define a multi-container application in a single -file, then spin your application up in a single command which does everything -that needs to be done to get it running. +Compose is a tool for defining and running multi-container Docker applications. +With Compose, you define a multi-container application in a compose +file then, using a single command, you create and start all the containers +from your configuration. To learn more about all the features of Compose +see [the list of features](#features) -Compose is great for development environments, staging servers, and CI. We don't -recommend that you use it in production yet. +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. @@ -33,6 +35,9 @@ A `docker-compose.yml` looks like this: redis: image: redis +For more information about the Compose file, see the +[Compose file reference](docs/yml.md) + Compose has commands for managing the whole lifecycle of your application: * Start, stop and rebuild services diff --git a/docs/index.md b/docs/index.md index 19a6c801c2d..ac7e07f9ba8 100644 --- a/docs/index.md +++ b/docs/index.md @@ -11,20 +11,22 @@ parent="smn_workw_compose" # Overview of Docker Compose -Compose is a tool for defining and running multi-container applications with -Docker. With Compose, you define a multi-container application in a single -file, then spin your application up in a single command which does everything -that needs to be done to get it running. +Compose is a tool for defining and running multi-container Docker applications. +With Compose, you define a multi-container application in a compose +file then, using a single command, you create and start all the containers +from your configuration. To learn more about all the features of Compose +see [the list of features](#features) -Compose is great for development environments, staging servers, and CI. We don't -recommend that you use it in production yet. +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: +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: @@ -40,6 +42,9 @@ A `docker-compose.yml` looks like this: redis: image: redis +For more information about the Compose file, see the +[Compose file reference](yml.md) + Compose has commands for managing the whole lifecycle of your application: * Start, stop and rebuild services @@ -57,11 +62,84 @@ Compose has commands for managing the whole lifecycle of your application: - [Command line reference](./reference/index.md) - [Compose file reference](compose-file.md) +## Features + +#### Preserve volume data + +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 to different environments + +> New in `docker-compose` 1.5 + +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. + +Compose files can also be extended from other files using the `extends` +field in a compose file, or by using multiple 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 it is often helpful to be able to run the +application and interact with it. If the application has any service dependencies +(databases, queues, caches, web services, etc) you need a way to document the +dependencies, configuration and operation of each. Compose provides a convenient +format for definition these dependencies (the [Compose file](yml.md)) and a CLI +tool for starting an isolated environment. Compose can replace a multi-page +"developer getting started guide" with a single machine readable configuration +file and a single command `docker-compose up`. + +### 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](yml.md) you can create and destroy these +environments in just a few commands: + + $ docker-compose up -d + $ ./run_tests + $ docker-compose stop + $ docker-compose rm -f + +### 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. +Compose can be used to deploy to a remote docker engine, for example a cloud +instance provisioned with [Docker Machine](https://docs.docker.com/machine/) or +a [Docker Swarm](https://docs.docker.com/swarm/) cluster. + +See [compose in production](production.md) for more details. + ## 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). +Compose, please refer to the +[CHANGELOG](https://github.com/docker/compose/blob/master/CHANGELOG.md). ## Getting help From db3b90b84efa1ddd39b282e67f775f75609d14a3 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 21 Oct 2015 16:28:47 -0400 Subject: [PATCH 1431/4072] Updates to gettingstarted guide from PR feedback. Signed-off-by: Daniel Nephin --- docs/gettingstarted.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/gettingstarted.md b/docs/gettingstarted.md index f2024b39ba7..9cc478d7e5c 100644 --- a/docs/gettingstarted.md +++ b/docs/gettingstarted.md @@ -21,13 +21,13 @@ Python. First, [install Docker and Compose](install.md). -Next, you'll want to make a directory for the project: +Create a directory for the project: $ mkdir composetest $ cd composetest Inside this directory, create `app.py`, a simple Python web app that uses the Flask -framework and increments a value in Redis. Don't worry if you don't have Redis installed, docker is going to take care of that for you when we [define services](#define-services): +framework and increments a value in Redis. from flask import Flask from redis import Redis @@ -74,7 +74,7 @@ You can build the image by running `docker build -t web .`. ### Define services -Next, define a set of services using `docker-compose.yml`: +Define a set of services using `docker-compose.yml`: web: build: . @@ -91,8 +91,8 @@ This template 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 current directory on the host to `/code` inside the container allowing you to modify the code without having to rebuild the image. -* Links the web container to the Redis service. +* 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. @@ -113,7 +113,7 @@ If you're using [Docker Machine](https://docs.docker.com/machine), then `docker- 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`. -You should get a message in your browser saying: +You will see a message in your browser saying: `Hello World! I have been seen 1 times.` From d9bc91b7cc0a8cc867caaac1bf36c1eab2b6cb4b Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 23 Oct 2015 16:51:03 -0400 Subject: [PATCH 1432/4072] Update intro docs based on feedback. Signed-off-by: Daniel Nephin --- README.md | 4 +- docs/gettingstarted.md | 207 +++++++++++++++++++++++------------------ docs/index.md | 50 ++++++---- 3 files changed, 147 insertions(+), 114 deletions(-) diff --git a/README.md b/README.md index 55346f24cc6..bd110307240 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,6 @@ Docker Compose ============== ![Docker Compose](logo.png?raw=true "Docker Compose Logo") -*(Previously known as Fig)* - Compose is a tool for defining and running multi-container Docker applications. With Compose, you define a multi-container application in a compose file then, using a single command, you create and start all the containers @@ -36,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/yml.md) +[Compose file reference](docs/compose-file.md) Compose has commands for managing the whole lifecycle of your application: diff --git a/docs/gettingstarted.md b/docs/gettingstarted.md index 9cc478d7e5c..f685bf3820a 100644 --- a/docs/gettingstarted.md +++ b/docs/gettingstarted.md @@ -10,84 +10,103 @@ weight=3 -## Getting Started +# Getting Started -Let's get started with a walkthrough of getting a simple Python web app running -on Compose. It assumes a little knowledge of Python, but the concepts -demonstrated here should be understandable even if you're not familiar with -Python. +On this page you build a simple Python web application running on 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. -### Installation and set-up +## Prerequisites -First, [install Docker and Compose](install.md). +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. -Create a directory for the project: +## Step 1: Setup - $ mkdir composetest - $ cd composetest +1. Create a directory for the project: -Inside this directory, create `app.py`, a simple Python web app that uses the Flask -framework and increments a value in Redis. + $ mkdir composetest + $ cd composetest - from flask import Flask - from redis import Redis +2. With your favorite text editor create a file called `app.py` in your project + directory. - app = Flask(__name__) - redis = Redis(host='redis', port=6379) + from flask import Flask + from redis import Redis - @app.route('/') - def hello(): - redis.incr('hits') - return 'Hello World! I have been seen %s times.' % redis.get('hits') + app = Flask(__name__) + redis = Redis(host='redis', port=6379) - if __name__ == "__main__": - app.run(host="0.0.0.0", debug=True) + @app.route('/') + def hello(): + redis.incr('hits') + return 'Hello World! I have been seen %s times.' % redis.get('hits') -Next, define the Python dependencies in a file called `requirements.txt`: + if __name__ == "__main__": + app.run(host="0.0.0.0", debug=True) - flask - redis +3. Create another file called `requirements.txt` in your project directory and + add the following: -### Create a Docker image + flask + redis -Now, create a Docker image containing all of your app's dependencies. You -specify how to build the image using a file called -[`Dockerfile`](http://docs.docker.com/reference/builder/): + These define the applications dependencies. - FROM python:2.7 - ADD . /code - WORKDIR /code - RUN pip install -r requirements.txt - CMD python app.py +## Step 2: Create a Docker image -This tells Docker to: +In this step, you build a new Docker image. The image contains all the +dependencies the Python application requires, including Python itself. -* 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` +1. In your project directory create a file named `Dockerfile` and add the + following: -For more information on how to write Dockerfiles, see the [Docker user guide](https://docs.docker.com/userguide/dockerimages/#building-an-image-from-a-dockerfile) and the [Dockerfile reference](http://docs.docker.com/reference/builder/). + FROM python:2.7 + ADD . /code + WORKDIR /code + RUN pip install -r requirements.txt + CMD python app.py -You can build the image by running `docker build -t web .`. + This tells Docker to: -### Define services + * 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](https://docs.docker.com/userguide/dockerimages/#building-an-image-from-a-dockerfile) and the [Dockerfile reference](http://docs.docker.com/reference/builder/). + +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`: - web: - build: . - ports: - - "5000:5000" - volumes: - - .:/code - links: - - redis - redis: - image: redis +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 -This template defines two services, `web` and `redis`. The `web` service: +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. @@ -96,68 +115,74 @@ This template defines two services, `web` and `redis`. The `web` service: The `redis` service uses the latest public [Redis](https://registry.hub.docker.com/_/redis/) image pulled from the Docker Hub registry. -### Build and run your app with Compose +## Step 4: Build and run your app with Compose -Now, when you run `docker-compose up`, Compose will pull a Redis image, build an image for your code, and start everything up: +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 + $ 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 -If you're using [Docker Machine](https://docs.docker.com/machine), then `docker-machine ip MACHINE_VM` will tell you its address and you can open `http://MACHINE_VM_IP:5000` in a browser. + Compose pulls a Redis image, builds an image for your code, and start the + services you defined. -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`. +2. Enter `http://0.0.0.0:5000/` in a browser to see the application running. -You will see a message in your browser saying: + 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. -`Hello World! I have been seen 1 times.` + 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. -Refreshing the page will increment the number. + 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 + $ 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 + $ 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 + $ 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). -- See the reference guides for complete details on the [commands](./reference/index.md), the - [configuration file](compose-file.md) and [environment variables](env.md). - -## More Compose documentation - -- [User guide](/) -- [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) +- [Explore the full list of Compose commands](./reference/index.md) +- [Compose configuration file reference](compose-file.md) diff --git a/docs/index.md b/docs/index.md index ac7e07f9ba8..6ea0e99ab55 100644 --- a/docs/index.md +++ b/docs/index.md @@ -43,7 +43,7 @@ A `docker-compose.yml` looks like this: image: redis For more information about the Compose file, see the -[Compose file reference](yml.md) +[Compose file reference](compose-file.md) Compose has commands for managing the whole lifecycle of your application: @@ -64,6 +64,12 @@ Compose has commands for managing the whole lifecycle of your application: ## Features +The features of Compose that make it effective are: + +* [Preserve volume data](#preserve-volume-data) +* [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) + #### Preserve volume data Compose preserves all volumes used by your services. When `docker-compose up` @@ -80,18 +86,15 @@ containers. Re-using containers means that you can make changes to your environment very quickly. -#### Variables and moving a composition to different environments - -> New in `docker-compose` 1.5 +#### 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. -Compose files can also be extended from other files using the `extends` -field in a compose file, or by using multiple files. See [extends](extends.md) -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 @@ -101,14 +104,19 @@ below. ### Development environments -When you're developing software it is often helpful to be able to run the -application and interact with it. If the application has any service dependencies -(databases, queues, caches, web services, etc) you need a way to document the -dependencies, configuration and operation of each. Compose provides a convenient -format for definition these dependencies (the [Compose file](yml.md)) and a CLI -tool for starting an isolated environment. Compose can replace a multi-page -"developer getting started guide" with a single machine readable configuration -file and a single command `docker-compose up`. +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 @@ -116,7 +124,7 @@ 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](yml.md) you can create and destroy these +environment in a [Compose file](compose-file.md) you can create and destroy these environments in just a few commands: $ docker-compose up -d @@ -128,11 +136,13 @@ environments in just a few commands: Compose has traditionally been focused on development and testing workflows, but with each release we're making progress on more production-oriented features. -Compose can be used to deploy to a remote docker engine, for example a cloud -instance provisioned with [Docker Machine](https://docs.docker.com/machine/) or -a [Docker Swarm](https://docs.docker.com/swarm/) cluster. +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. -See [compose in production](production.md) for more details. +For details on using production-oriented features, see +[compose in production](production.md) in this documentation. ## Release Notes From a3fb13e14195835d08006f23a9f26f6907f77262 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 27 Oct 2015 16:51:49 -0400 Subject: [PATCH 1433/4072] Add another feature to the docs - multiple environments per host. Signed-off-by: Daniel Nephin --- docs/index.md | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/docs/index.md b/docs/index.md index 6ea0e99ab55..ebc1320eaea 100644 --- a/docs/index.md +++ b/docs/index.md @@ -66,11 +66,29 @@ Compose has commands for managing the whole lifecycle of your application: The features of Compose that make it effective are: -* [Preserve volume data](#preserve-volume-data) +* [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) -#### Preserve volume data +#### 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 From c58cf036e34b34a7c435f0c4c80b13f058788d70 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 30 Oct 2015 13:27:06 -0400 Subject: [PATCH 1434/4072] Touch up intro paragraph with feedback from @moxiegirl. Signed-off-by: Daniel Nephin --- README.md | 6 +++--- docs/index.md | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index bd110307240..5052db39d51 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,10 @@ Docker Compose ![Docker Compose](logo.png?raw=true "Docker Compose Logo") Compose is a tool for defining and running multi-container Docker applications. -With Compose, you define a multi-container application in a compose -file then, using a single command, you create and start all the containers +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) +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 diff --git a/docs/index.md b/docs/index.md index ebc1320eaea..279154eef9f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -12,10 +12,10 @@ parent="smn_workw_compose" # Overview of Docker Compose Compose is a tool for defining and running multi-container Docker applications. -With Compose, you define a multi-container application in a compose -file then, using a single command, you create and start all the containers +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) +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 From 8057bb3fcce2c99126db365671753bc0af003d8c Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 2 Nov 2015 16:46:49 -0500 Subject: [PATCH 1435/4072] Update cli tests to use subprocess. Signed-off-by: Daniel Nephin --- tests/integration/cli_test.py | 459 ++++++++++++++++------------------ 1 file changed, 215 insertions(+), 244 deletions(-) diff --git a/tests/integration/cli_test.py b/tests/integration/cli_test.py index d621f2d1320..7ae187b22df 100644 --- a/tests/integration/cli_test.py +++ b/tests/integration/cli_test.py @@ -2,30 +2,32 @@ import os import shlex -import sys +import subprocess +from collections import namedtuple from operator import attrgetter -from six import StringIO +import pytest from .. import mock from .testcases import DockerClientTestCase from compose.cli.command import get_project from compose.cli.docker_client import docker_client -from compose.cli.errors import UserError -from compose.cli.main import TopLevelCommand -from compose.project import NoSuchService + + +ProcessResult = namedtuple('ProcessResult', 'stdout stderr') + + +BUILD_CACHE_TEXT = 'Using cache' +BUILD_PULL_TEXT = 'Status: Image is up to date for busybox:latest' class CLITestCase(DockerClientTestCase): + def setUp(self): super(CLITestCase, self).setUp() - self.old_sys_exit = sys.exit - sys.exit = lambda code=0: None - self.command = TopLevelCommand() - self.command.base_dir = 'tests/fixtures/simple-composefile' + self.base_dir = 'tests/fixtures/simple-composefile' def tearDown(self): - sys.exit = self.old_sys_exit self.project.kill() self.project.remove_stopped() for container in self.project.containers(stopped=True, one_off=True): @@ -34,129 +36,121 @@ def tearDown(self): @property def project(self): - # Hack: allow project to be overridden. This needs refactoring so that - # the project object is built exactly once, by the command object, and - # accessed by the test case object. - if hasattr(self, '_project'): - return self._project - - return get_project(self.command.base_dir) + # Hack: allow project to be overridden + if not hasattr(self, '_project'): + self._project = get_project(self.base_dir) + return self._project + + def dispatch(self, options, project_options=None, returncode=0): + project_options = project_options or [] + proc = subprocess.Popen( + ['docker-compose'] + project_options + options, + # Note: this might actually be a patched sys.stdout, so we have + # to specify it here, even though it's the default + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + cwd=self.base_dir) + print("Running process: %s" % proc.pid) + stdout, stderr = proc.communicate() + if proc.returncode != returncode: + print(stderr) + assert proc.returncode == returncode + return ProcessResult(stdout.decode('utf-8'), stderr.decode('utf-8')) def test_help(self): - old_base_dir = self.command.base_dir - self.command.base_dir = 'tests/fixtures/no-composefile' - with self.assertRaises(SystemExit) as exc_context: - self.command.dispatch(['help', 'up'], None) - self.assertIn('Usage: up [options] [SERVICE...]', str(exc_context.exception)) + old_base_dir = self.base_dir + self.base_dir = 'tests/fixtures/no-composefile' + result = self.dispatch(['help', 'up'], returncode=1) + assert 'Usage: up [options] [SERVICE...]' in result.stderr # self.project.kill() fails during teardown # unless there is a composefile. - self.command.base_dir = old_base_dir + self.base_dir = old_base_dir - # TODO: address the "Inappropriate ioctl for device" warnings in test output def test_ps(self): self.project.get_service('simple').create_container() - with mock.patch('sys.stdout', new_callable=StringIO) as mock_stdout: - self.command.dispatch(['ps'], None) - self.assertIn('simplecomposefile_simple_1', mock_stdout.getvalue()) + result = self.dispatch(['ps']) + assert 'simplecomposefile_simple_1' in result.stdout def test_ps_default_composefile(self): - self.command.base_dir = 'tests/fixtures/multiple-composefiles' - with mock.patch('sys.stdout', new_callable=StringIO) as mock_stdout: - self.command.dispatch(['up', '-d'], None) - self.command.dispatch(['ps'], None) + self.base_dir = 'tests/fixtures/multiple-composefiles' + self.dispatch(['up', '-d']) + result = self.dispatch(['ps']) - output = mock_stdout.getvalue() - self.assertIn('multiplecomposefiles_simple_1', output) - self.assertIn('multiplecomposefiles_another_1', output) - self.assertNotIn('multiplecomposefiles_yetanother_1', output) + self.assertIn('multiplecomposefiles_simple_1', result.stdout) + self.assertIn('multiplecomposefiles_another_1', result.stdout) + self.assertNotIn('multiplecomposefiles_yetanother_1', result.stdout) def test_ps_alternate_composefile(self): config_path = os.path.abspath( 'tests/fixtures/multiple-composefiles/compose2.yml') - self._project = get_project(self.command.base_dir, [config_path]) - - self.command.base_dir = 'tests/fixtures/multiple-composefiles' - with mock.patch('sys.stdout', new_callable=StringIO) as mock_stdout: - self.command.dispatch(['-f', 'compose2.yml', 'up', '-d'], None) - self.command.dispatch(['-f', 'compose2.yml', 'ps'], None) - - output = mock_stdout.getvalue() - self.assertNotIn('multiplecomposefiles_simple_1', output) - self.assertNotIn('multiplecomposefiles_another_1', output) - self.assertIn('multiplecomposefiles_yetanother_1', output) - - @mock.patch('compose.service.log') - def test_pull(self, mock_logging): - self.command.dispatch(['pull'], None) - mock_logging.info.assert_any_call('Pulling simple (busybox:latest)...') - mock_logging.info.assert_any_call('Pulling another (busybox:latest)...') - - @mock.patch('compose.service.log') - def test_pull_with_digest(self, mock_logging): - self.command.dispatch(['-f', 'digest.yml', 'pull'], None) - mock_logging.info.assert_any_call('Pulling simple (busybox:latest)...') - mock_logging.info.assert_any_call( - 'Pulling digest (busybox@' - 'sha256:38a203e1986cf79639cfb9b2e1d6e773de84002feea2d4eb006b52004ee8502d)...') - - @mock.patch('compose.service.log') - def test_pull_with_ignore_pull_failures(self, mock_logging): - self.command.dispatch(['-f', 'ignore-pull-failures.yml', 'pull', '--ignore-pull-failures'], None) - mock_logging.info.assert_any_call('Pulling simple (busybox:latest)...') - mock_logging.info.assert_any_call('Pulling another (nonexisting-image:latest)...') - mock_logging.error.assert_any_call('Error: image library/nonexisting-image:latest not found') + self._project = get_project(self.base_dir, [config_path]) - def test_build_plain(self): - self.command.base_dir = 'tests/fixtures/simple-dockerfile' - self.command.dispatch(['build', 'simple'], None) + self.base_dir = 'tests/fixtures/multiple-composefiles' + self.dispatch(['-f', 'compose2.yml', 'up', '-d']) + result = self.dispatch(['-f', 'compose2.yml', 'ps']) + + self.assertNotIn('multiplecomposefiles_simple_1', result.stdout) + self.assertNotIn('multiplecomposefiles_another_1', result.stdout) + self.assertIn('multiplecomposefiles_yetanother_1', result.stdout) + + def test_pull(self): + result = self.dispatch(['pull']) + assert sorted(result.stderr.split('\n'))[1:] == [ + 'Pulling another (busybox:latest)...', + 'Pulling simple (busybox:latest)...', + ] + + def test_pull_with_digest(self): + result = self.dispatch(['-f', 'digest.yml', 'pull']) - cache_indicator = 'Using cache' - pull_indicator = 'Status: Image is up to date for busybox:latest' + assert 'Pulling simple (busybox:latest)...' in result.stderr + assert ('Pulling digest (busybox@' + 'sha256:38a203e1986cf79639cfb9b2e1d6e773de84002feea2d4eb006b520' + '04ee8502d)...') in result.stderr - with mock.patch('sys.stdout', new_callable=StringIO) as mock_stdout: - self.command.dispatch(['build', 'simple'], None) - output = mock_stdout.getvalue() - self.assertIn(cache_indicator, output) - self.assertNotIn(pull_indicator, output) + def test_pull_with_ignore_pull_failures(self): + result = self.dispatch([ + '-f', 'ignore-pull-failures.yml', + '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:latest not found' in result.stderr + + def test_build_plain(self): + self.base_dir = 'tests/fixtures/simple-dockerfile' + self.dispatch(['build', 'simple']) + + result = self.dispatch(['build', 'simple']) + assert BUILD_CACHE_TEXT in result.stdout + assert BUILD_PULL_TEXT not in result.stdout def test_build_no_cache(self): - self.command.base_dir = 'tests/fixtures/simple-dockerfile' - self.command.dispatch(['build', 'simple'], None) + self.base_dir = 'tests/fixtures/simple-dockerfile' + self.dispatch(['build', 'simple']) - cache_indicator = 'Using cache' - pull_indicator = 'Status: Image is up to date for busybox:latest' - with mock.patch('sys.stdout', new_callable=StringIO) as mock_stdout: - self.command.dispatch(['build', '--no-cache', 'simple'], None) - output = mock_stdout.getvalue() - self.assertNotIn(cache_indicator, output) - self.assertNotIn(pull_indicator, output) + result = self.dispatch(['build', '--no-cache', 'simple']) + assert BUILD_CACHE_TEXT not in result.stdout + assert BUILD_PULL_TEXT not in result.stdout def test_build_pull(self): - self.command.base_dir = 'tests/fixtures/simple-dockerfile' - self.command.dispatch(['build', 'simple'], None) + self.base_dir = 'tests/fixtures/simple-dockerfile' + self.dispatch(['build', 'simple'], None) - cache_indicator = 'Using cache' - pull_indicator = 'Status: Image is up to date for busybox:latest' - with mock.patch('sys.stdout', new_callable=StringIO) as mock_stdout: - self.command.dispatch(['build', '--pull', 'simple'], None) - output = mock_stdout.getvalue() - self.assertIn(cache_indicator, output) - self.assertIn(pull_indicator, output) + result = self.dispatch(['build', '--pull', 'simple']) + assert BUILD_CACHE_TEXT in result.stdout + assert BUILD_PULL_TEXT in result.stdout def test_build_no_cache_pull(self): - self.command.base_dir = 'tests/fixtures/simple-dockerfile' - self.command.dispatch(['build', 'simple'], None) + self.base_dir = 'tests/fixtures/simple-dockerfile' + self.dispatch(['build', 'simple']) - cache_indicator = 'Using cache' - pull_indicator = 'Status: Image is up to date for busybox:latest' - with mock.patch('sys.stdout', new_callable=StringIO) as mock_stdout: - self.command.dispatch(['build', '--no-cache', '--pull', 'simple'], None) - output = mock_stdout.getvalue() - self.assertNotIn(cache_indicator, output) - self.assertIn(pull_indicator, output) + result = self.dispatch(['build', '--no-cache', '--pull', 'simple']) + assert BUILD_CACHE_TEXT not in result.stdout + assert BUILD_PULL_TEXT in result.stdout def test_up_detached(self): - self.command.dispatch(['up', '-d'], None) + self.dispatch(['up', '-d'], None) service = self.project.get_service('simple') another = self.project.get_service('another') self.assertEqual(len(service.containers()), 1) @@ -168,12 +162,14 @@ def test_up_detached(self): self.assertFalse(container.get('Config.AttachStdout')) self.assertFalse(container.get('Config.AttachStdin')) + # TODO: needs rework + @pytest.mark.skipif(True, reason="runs top") def test_up_attached(self): with mock.patch( 'compose.cli.main.attach_to_logs', autospec=True ) as mock_attach: - self.command.dispatch(['up'], None) + self.dispatch(['up'], None) _, args, kwargs = mock_attach.mock_calls[0] _project, log_printer, _names, _timeout = args @@ -189,8 +185,8 @@ def test_up_attached(self): def test_up_without_networking(self): self.require_api_version('1.21') - self.command.base_dir = 'tests/fixtures/links-composefile' - self.command.dispatch(['up', '-d'], None) + self.base_dir = 'tests/fixtures/links-composefile' + self.dispatch(['up', '-d'], None) client = docker_client(version='1.21') networks = client.networks(names=[self.project.name]) @@ -207,8 +203,8 @@ def test_up_without_networking(self): def test_up_with_networking(self): self.require_api_version('1.21') - self.command.base_dir = 'tests/fixtures/links-composefile' - self.command.dispatch(['--x-networking', 'up', '-d'], None) + self.base_dir = 'tests/fixtures/links-composefile' + self.dispatch(['--x-networking', 'up', '-d'], None) client = docker_client(version='1.21') services = self.project.get_services() @@ -232,8 +228,8 @@ def test_up_with_networking(self): self.assertFalse(web_container.get('HostConfig.Links')) def test_up_with_links(self): - self.command.base_dir = 'tests/fixtures/links-composefile' - self.command.dispatch(['up', '-d', 'web'], None) + self.base_dir = 'tests/fixtures/links-composefile' + self.dispatch(['up', '-d', 'web'], None) web = self.project.get_service('web') db = self.project.get_service('db') console = self.project.get_service('console') @@ -242,8 +238,8 @@ def test_up_with_links(self): self.assertEqual(len(console.containers()), 0) def test_up_with_no_deps(self): - self.command.base_dir = 'tests/fixtures/links-composefile' - self.command.dispatch(['up', '-d', '--no-deps', 'web'], None) + self.base_dir = 'tests/fixtures/links-composefile' + self.dispatch(['up', '-d', '--no-deps', 'web'], None) web = self.project.get_service('web') db = self.project.get_service('db') console = self.project.get_service('console') @@ -252,13 +248,13 @@ def test_up_with_no_deps(self): self.assertEqual(len(console.containers()), 0) def test_up_with_force_recreate(self): - self.command.dispatch(['up', '-d'], None) + self.dispatch(['up', '-d'], None) service = self.project.get_service('simple') self.assertEqual(len(service.containers()), 1) old_ids = [c.id for c in service.containers()] - self.command.dispatch(['up', '-d', '--force-recreate'], None) + self.dispatch(['up', '-d', '--force-recreate'], None) self.assertEqual(len(service.containers()), 1) new_ids = [c.id for c in service.containers()] @@ -266,13 +262,13 @@ def test_up_with_force_recreate(self): self.assertNotEqual(old_ids, new_ids) def test_up_with_no_recreate(self): - self.command.dispatch(['up', '-d'], None) + self.dispatch(['up', '-d'], None) service = self.project.get_service('simple') self.assertEqual(len(service.containers()), 1) old_ids = [c.id for c in service.containers()] - self.command.dispatch(['up', '-d', '--no-recreate'], None) + self.dispatch(['up', '-d', '--no-recreate'], None) self.assertEqual(len(service.containers()), 1) new_ids = [c.id for c in service.containers()] @@ -280,11 +276,12 @@ def test_up_with_no_recreate(self): self.assertEqual(old_ids, new_ids) def test_up_with_force_recreate_and_no_recreate(self): - with self.assertRaises(UserError): - self.command.dispatch(['up', '-d', '--force-recreate', '--no-recreate'], None) + self.dispatch( + ['up', '-d', '--force-recreate', '--no-recreate'], + returncode=1) def test_up_with_timeout(self): - self.command.dispatch(['up', '-d', '-t', '1'], None) + self.dispatch(['up', '-d', '-t', '1'], None) service = self.project.get_service('simple') another = self.project.get_service('another') self.assertEqual(len(service.containers()), 1) @@ -296,10 +293,9 @@ def test_up_with_timeout(self): self.assertFalse(config['AttachStdout']) self.assertFalse(config['AttachStdin']) - @mock.patch('dockerpty.start') - def test_run_service_without_links(self, mock_stdout): - self.command.base_dir = 'tests/fixtures/links-composefile' - self.command.dispatch(['run', 'console', '/bin/true'], None) + def test_run_service_without_links(self): + self.base_dir = 'tests/fixtures/links-composefile' + self.dispatch(['run', 'console', '/bin/true']) self.assertEqual(len(self.project.containers()), 0) # Ensure stdin/out was open @@ -309,44 +305,40 @@ def test_run_service_without_links(self, mock_stdout): self.assertTrue(config['AttachStdout']) self.assertTrue(config['AttachStdin']) - @mock.patch('dockerpty.start') - def test_run_service_with_links(self, _): - self.command.base_dir = 'tests/fixtures/links-composefile' - self.command.dispatch(['run', 'web', '/bin/true'], None) + def test_run_service_with_links(self): + self.base_dir = 'tests/fixtures/links-composefile' + 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) - @mock.patch('dockerpty.start') - def test_run_with_no_deps(self, _): - self.command.base_dir = 'tests/fixtures/links-composefile' - self.command.dispatch(['run', '--no-deps', 'web', '/bin/true'], None) + def test_run_with_no_deps(self): + self.base_dir = 'tests/fixtures/links-composefile' + self.dispatch(['run', '--no-deps', 'web', '/bin/true']) db = self.project.get_service('db') self.assertEqual(len(db.containers()), 0) - @mock.patch('dockerpty.start') - def test_run_does_not_recreate_linked_containers(self, _): - self.command.base_dir = 'tests/fixtures/links-composefile' - self.command.dispatch(['up', '-d', 'db'], None) + def test_run_does_not_recreate_linked_containers(self): + self.base_dir = 'tests/fixtures/links-composefile' + self.dispatch(['up', '-d', 'db']) db = self.project.get_service('db') self.assertEqual(len(db.containers()), 1) old_ids = [c.id for c in db.containers()] - self.command.dispatch(['run', 'web', '/bin/true'], None) + self.dispatch(['run', 'web', '/bin/true'], None) self.assertEqual(len(db.containers()), 1) new_ids = [c.id for c in db.containers()] self.assertEqual(old_ids, new_ids) - @mock.patch('dockerpty.start') - def test_run_without_command(self, _): - self.command.base_dir = 'tests/fixtures/commands-composefile' + def test_run_without_command(self): + self.base_dir = 'tests/fixtures/commands-composefile' self.check_build('tests/fixtures/simple-dockerfile', tag='composetest_test') - self.command.dispatch(['run', 'implicit'], None) + self.dispatch(['run', 'implicit']) service = self.project.get_service('implicit') containers = service.containers(stopped=True, one_off=True) self.assertEqual( @@ -354,7 +346,7 @@ def test_run_without_command(self, _): [u'/bin/sh -c echo "success"'], ) - self.command.dispatch(['run', 'explicit'], None) + self.dispatch(['run', 'explicit']) service = self.project.get_service('explicit') containers = service.containers(stopped=True, one_off=True) self.assertEqual( @@ -362,14 +354,10 @@ def test_run_without_command(self, _): [u'/bin/true'], ) - @mock.patch('dockerpty.start') - def test_run_service_with_entrypoint_overridden(self, _): - self.command.base_dir = 'tests/fixtures/dockerfile_with_entrypoint' + def test_run_service_with_entrypoint_overridden(self): + self.base_dir = 'tests/fixtures/dockerfile_with_entrypoint' name = 'service' - self.command.dispatch( - ['run', '--entrypoint', '/bin/echo', name, 'helloworld'], - None - ) + self.dispatch(['run', '--entrypoint', '/bin/echo', name, 'helloworld']) service = self.project.get_service(name) container = service.containers(stopped=True, one_off=True)[0] self.assertEqual( @@ -377,37 +365,34 @@ def test_run_service_with_entrypoint_overridden(self, _): [u'/bin/echo', u'helloworld'], ) - @mock.patch('dockerpty.start') - def test_run_service_with_user_overridden(self, _): - self.command.base_dir = 'tests/fixtures/user-composefile' + def test_run_service_with_user_overridden(self): + self.base_dir = 'tests/fixtures/user-composefile' name = 'service' user = 'sshd' - args = ['run', '--user={user}'.format(user=user), name] - self.command.dispatch(args, None) + 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] self.assertEqual(user, container.get('Config.User')) - @mock.patch('dockerpty.start') - def test_run_service_with_user_overridden_short_form(self, _): - self.command.base_dir = 'tests/fixtures/user-composefile' + def test_run_service_with_user_overridden_short_form(self): + self.base_dir = 'tests/fixtures/user-composefile' name = 'service' user = 'sshd' - args = ['run', '-u', user, name] - self.command.dispatch(args, None) + self.dispatch(['run', '-u', user, name], returncode=1) service = self.project.get_service(name) container = service.containers(stopped=True, one_off=True)[0] self.assertEqual(user, container.get('Config.User')) - @mock.patch('dockerpty.start') - def test_run_service_with_environement_overridden(self, _): + def test_run_service_with_environement_overridden(self): name = 'service' - self.command.base_dir = 'tests/fixtures/environment-composefile' - self.command.dispatch( - ['run', '-e', 'foo=notbar', '-e', 'allo=moto=bobo', - '-e', 'alpha=beta', name], - None - ) + self.base_dir = 'tests/fixtures/environment-composefile' + self.dispatch([ + 'run', '-e', 'foo=notbar', + '-e', 'allo=moto=bobo', + '-e', 'alpha=beta', + name, + '/bin/true', + ]) service = self.project.get_service(name) container = service.containers(stopped=True, one_off=True)[0] # env overriden @@ -419,11 +404,10 @@ def test_run_service_with_environement_overridden(self, _): # make sure a value with a = don't crash out self.assertEqual('moto=bobo', container.environment['allo']) - @mock.patch('dockerpty.start') - def test_run_service_without_map_ports(self, _): + def test_run_service_without_map_ports(self): # create one off container - self.command.base_dir = 'tests/fixtures/ports-composefile' - self.command.dispatch(['run', '-d', 'simple'], None) + self.base_dir = 'tests/fixtures/ports-composefile' + self.dispatch(['run', '-d', 'simple']) container = self.project.get_service('simple').containers(one_off=True)[0] # get port information @@ -437,12 +421,10 @@ def test_run_service_without_map_ports(self, _): self.assertEqual(port_random, None) self.assertEqual(port_assigned, None) - @mock.patch('dockerpty.start') - def test_run_service_with_map_ports(self, _): - + def test_run_service_with_map_ports(self): # create one off container - self.command.base_dir = 'tests/fixtures/ports-composefile' - self.command.dispatch(['run', '-d', '--service-ports', 'simple'], None) + 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] # get port information @@ -460,12 +442,10 @@ def test_run_service_with_map_ports(self, _): self.assertEqual(port_range[0], "0.0.0.0:49153") self.assertEqual(port_range[1], "0.0.0.0:49154") - @mock.patch('dockerpty.start') - def test_run_service_with_explicitly_maped_ports(self, _): - + def test_run_service_with_explicitly_maped_ports(self): # create one off container - self.command.base_dir = 'tests/fixtures/ports-composefile' - self.command.dispatch(['run', '-d', '-p', '30000:3000', '--publish', '30001:3001', 'simple'], None) + 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] # get port information @@ -479,12 +459,10 @@ def test_run_service_with_explicitly_maped_ports(self, _): self.assertEqual(port_short, "0.0.0.0:30000") self.assertEqual(port_full, "0.0.0.0:30001") - @mock.patch('dockerpty.start') - def test_run_service_with_explicitly_maped_ip_ports(self, _): - + def test_run_service_with_explicitly_maped_ip_ports(self): # create one off container - self.command.base_dir = 'tests/fixtures/ports-composefile' - self.command.dispatch(['run', '-d', '-p', '127.0.0.1:30000:3000', '--publish', '127.0.0.1:30001:3001', 'simple'], None) + 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) container = self.project.get_service('simple').containers(one_off=True)[0] # get port information @@ -498,22 +476,20 @@ def test_run_service_with_explicitly_maped_ip_ports(self, _): self.assertEqual(port_short, "127.0.0.1:30000") self.assertEqual(port_full, "127.0.0.1:30001") - @mock.patch('dockerpty.start') - def test_run_with_custom_name(self, _): - self.command.base_dir = 'tests/fixtures/environment-composefile' + def test_run_with_custom_name(self): + self.base_dir = 'tests/fixtures/environment-composefile' name = 'the-container-name' - self.command.dispatch(['run', '--name', name, 'service'], None) + self.dispatch(['run', '--name', name, 'service', '/bin/true']) service = self.project.get_service('service') container, = service.containers(stopped=True, one_off=True) self.assertEqual(container.name, name) - @mock.patch('dockerpty.start') - def test_run_with_networking(self, _): + def test_run_with_networking(self): self.require_api_version('1.21') client = docker_client(version='1.21') - self.command.base_dir = 'tests/fixtures/simple-dockerfile' - self.command.dispatch(['--x-networking', 'run', 'simple', 'true'], None) + self.base_dir = 'tests/fixtures/simple-dockerfile' + self.dispatch(['--x-networking', 'run', 'simple', 'true'], None) service = self.project.get_service('simple') container, = service.containers(stopped=True, one_off=True) networks = client.networks(names=[self.project.name]) @@ -527,71 +503,70 @@ def test_rm(self): service.create_container() service.kill() self.assertEqual(len(service.containers(stopped=True)), 1) - self.command.dispatch(['rm', '--force'], None) + self.dispatch(['rm', '--force'], None) self.assertEqual(len(service.containers(stopped=True)), 0) service = self.project.get_service('simple') service.create_container() service.kill() self.assertEqual(len(service.containers(stopped=True)), 1) - self.command.dispatch(['rm', '-f'], None) + self.dispatch(['rm', '-f'], None) self.assertEqual(len(service.containers(stopped=True)), 0) def test_stop(self): - self.command.dispatch(['up', '-d'], None) + self.dispatch(['up', '-d'], None) service = self.project.get_service('simple') self.assertEqual(len(service.containers()), 1) self.assertTrue(service.containers()[0].is_running) - self.command.dispatch(['stop', '-t', '1'], None) + self.dispatch(['stop', '-t', '1'], None) self.assertEqual(len(service.containers(stopped=True)), 1) self.assertFalse(service.containers(stopped=True)[0].is_running) def test_pause_unpause(self): - self.command.dispatch(['up', '-d'], None) + self.dispatch(['up', '-d'], None) service = self.project.get_service('simple') self.assertFalse(service.containers()[0].is_paused) - self.command.dispatch(['pause'], None) + self.dispatch(['pause'], None) self.assertTrue(service.containers()[0].is_paused) - self.command.dispatch(['unpause'], None) + self.dispatch(['unpause'], None) self.assertFalse(service.containers()[0].is_paused) def test_logs_invalid_service_name(self): - with self.assertRaises(NoSuchService): - self.command.dispatch(['logs', 'madeupname'], None) + self.dispatch(['logs', 'madeupname'], returncode=1) def test_kill(self): - self.command.dispatch(['up', '-d'], None) + self.dispatch(['up', '-d'], None) service = self.project.get_service('simple') self.assertEqual(len(service.containers()), 1) self.assertTrue(service.containers()[0].is_running) - self.command.dispatch(['kill'], None) + self.dispatch(['kill'], None) self.assertEqual(len(service.containers(stopped=True)), 1) self.assertFalse(service.containers(stopped=True)[0].is_running) def test_kill_signal_sigstop(self): - self.command.dispatch(['up', '-d'], None) + self.dispatch(['up', '-d'], None) service = self.project.get_service('simple') self.assertEqual(len(service.containers()), 1) self.assertTrue(service.containers()[0].is_running) - self.command.dispatch(['kill', '-s', 'SIGSTOP'], None) + self.dispatch(['kill', '-s', 'SIGSTOP'], None) self.assertEqual(len(service.containers()), 1) # The container is still running. It has only been paused self.assertTrue(service.containers()[0].is_running) def test_kill_stopped_service(self): - self.command.dispatch(['up', '-d'], None) + self.dispatch(['up', '-d'], None) service = self.project.get_service('simple') - self.command.dispatch(['kill', '-s', 'SIGSTOP'], None) + self.dispatch(['kill', '-s', 'SIGSTOP'], None) self.assertTrue(service.containers()[0].is_running) - self.command.dispatch(['kill', '-s', 'SIGKILL'], None) + self.dispatch(['kill', '-s', 'SIGKILL'], None) self.assertEqual(len(service.containers(stopped=True)), 1) self.assertFalse(service.containers(stopped=True)[0].is_running) @@ -601,7 +576,7 @@ def test_restart(self): container = service.create_container() service.start_container(container) started_at = container.dictionary['State']['StartedAt'] - self.command.dispatch(['restart', '-t', '1'], None) + self.dispatch(['restart', '-t', '1'], None) container.inspect() self.assertNotEqual( container.dictionary['State']['FinishedAt'], @@ -615,53 +590,51 @@ def test_restart(self): def test_scale(self): project = self.project - self.command.scale(project, {'SERVICE=NUM': ['simple=1']}) + self.dispatch(['scale', 'simple=1']) self.assertEqual(len(project.get_service('simple').containers()), 1) - self.command.scale(project, {'SERVICE=NUM': ['simple=3', 'another=2']}) + self.dispatch(['scale', 'simple=3', 'another=2']) self.assertEqual(len(project.get_service('simple').containers()), 3) self.assertEqual(len(project.get_service('another').containers()), 2) - self.command.scale(project, {'SERVICE=NUM': ['simple=1', 'another=1']}) + self.dispatch(['scale', 'simple=1', 'another=1']) self.assertEqual(len(project.get_service('simple').containers()), 1) self.assertEqual(len(project.get_service('another').containers()), 1) - self.command.scale(project, {'SERVICE=NUM': ['simple=1', 'another=1']}) + self.dispatch(['scale', 'simple=1', 'another=1']) self.assertEqual(len(project.get_service('simple').containers()), 1) self.assertEqual(len(project.get_service('another').containers()), 1) - self.command.scale(project, {'SERVICE=NUM': ['simple=0', 'another=0']}) + self.dispatch(['scale', 'simple=0', 'another=0']) self.assertEqual(len(project.get_service('simple').containers()), 0) self.assertEqual(len(project.get_service('another').containers()), 0) def test_port(self): - self.command.base_dir = 'tests/fixtures/ports-composefile' - self.command.dispatch(['up', '-d'], None) + self.base_dir = 'tests/fixtures/ports-composefile' + self.dispatch(['up', '-d'], None) container = self.project.get_service('simple').get_container() - @mock.patch('sys.stdout', new_callable=StringIO) - def get_port(number, mock_stdout): - self.command.dispatch(['port', 'simple', str(number)], None) - return mock_stdout.getvalue().rstrip() + def get_port(number): + result = self.dispatch(['port', 'simple', str(number)]) + return result.stdout.rstrip() self.assertEqual(get_port(3000), container.get_local_port(3000)) self.assertEqual(get_port(3001), "0.0.0.0:49152") self.assertEqual(get_port(3002), "0.0.0.0:49153") def test_port_with_scale(self): - self.command.base_dir = 'tests/fixtures/ports-composefile-scale' - self.command.dispatch(['scale', 'simple=2'], None) + self.base_dir = 'tests/fixtures/ports-composefile-scale' + self.dispatch(['scale', 'simple=2'], None) containers = sorted( self.project.containers(service_names=['simple']), key=attrgetter('name')) - @mock.patch('sys.stdout', new_callable=StringIO) - def get_port(number, mock_stdout, index=None): + def get_port(number, index=None): if index is None: - self.command.dispatch(['port', 'simple', str(number)], None) + result = self.dispatch(['port', 'simple', str(number)]) else: - self.command.dispatch(['port', '--index=' + str(index), 'simple', str(number)], None) - return mock_stdout.getvalue().rstrip() + result = self.dispatch(['port', '--index=' + str(index), 'simple', str(number)]) + return result.stdout.rstrip() self.assertEqual(get_port(3000), containers[0].get_local_port(3000)) self.assertEqual(get_port(3000, index=1), containers[0].get_local_port(3000)) @@ -670,8 +643,8 @@ def get_port(number, mock_stdout, index=None): def test_env_file_relative_to_compose_file(self): config_path = os.path.abspath('tests/fixtures/env-file/docker-compose.yml') - self.command.dispatch(['-f', config_path, 'up', '-d'], None) - self._project = get_project(self.command.base_dir, [config_path]) + self.dispatch(['-f', config_path, 'up', '-d'], None) + self._project = get_project(self.base_dir, [config_path]) containers = self.project.containers(stopped=True) self.assertEqual(len(containers), 1) @@ -681,20 +654,18 @@ def test_env_file_relative_to_compose_file(self): def test_home_and_env_var_in_volume_path(self): os.environ['VOLUME_NAME'] = 'my-volume' os.environ['HOME'] = '/tmp/home-dir' - expected_host_path = os.path.join(os.environ['HOME'], os.environ['VOLUME_NAME']) - self.command.base_dir = 'tests/fixtures/volume-path-interpolation' - self.command.dispatch(['up', '-d'], None) + self.base_dir = 'tests/fixtures/volume-path-interpolation' + self.dispatch(['up', '-d'], None) container = self.project.containers(stopped=True)[0] actual_host_path = container.get('Volumes')['/container-path'] components = actual_host_path.split('/') - self.assertTrue(components[-2:] == ['home-dir', 'my-volume'], - msg="Last two components differ: %s, %s" % (actual_host_path, expected_host_path)) + assert components[-2:] == ['home-dir', 'my-volume'] def test_up_with_default_override_file(self): - self.command.base_dir = 'tests/fixtures/override-files' - self.command.dispatch(['up', '-d'], None) + self.base_dir = 'tests/fixtures/override-files' + self.dispatch(['up', '-d'], None) containers = self.project.containers() self.assertEqual(len(containers), 2) @@ -704,15 +675,15 @@ def test_up_with_default_override_file(self): self.assertEqual(db.human_readable_command, 'top') def test_up_with_multiple_files(self): - self.command.base_dir = 'tests/fixtures/override-files' + self.base_dir = 'tests/fixtures/override-files' config_paths = [ 'docker-compose.yml', 'docker-compose.override.yml', 'extra.yml', ] - self._project = get_project(self.command.base_dir, config_paths) - self.command.dispatch( + self._project = get_project(self.base_dir, config_paths) + self.dispatch( [ '-f', config_paths[0], '-f', config_paths[1], @@ -731,8 +702,8 @@ def test_up_with_multiple_files(self): self.assertEqual(other.human_readable_command, 'top') def test_up_with_extends(self): - self.command.base_dir = 'tests/fixtures/extends' - self.command.dispatch(['up', '-d'], None) + self.base_dir = 'tests/fixtures/extends' + self.dispatch(['up', '-d'], None) self.assertEqual( set([s.name for s in self.project.services]), From 45635f709781f4fccec15af19fb60e8c96cf6639 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 2 Nov 2015 17:52:37 -0500 Subject: [PATCH 1436/4072] Move cli tests to a new testing package. These cli tests are now a different kind of that that run the compose binary. They are not the same as integration tests that test some internal interface. Signed-off-by: Daniel Nephin --- tests/acceptance/__init__.py | 0 tests/{integration => acceptance}/cli_test.py | 29 ++++--------------- .../fixtures/echo-services/docker-compose.yml | 6 ++++ 3 files changed, 12 insertions(+), 23 deletions(-) create mode 100644 tests/acceptance/__init__.py rename tests/{integration => acceptance}/cli_test.py (96%) create mode 100644 tests/fixtures/echo-services/docker-compose.yml diff --git a/tests/acceptance/__init__.py b/tests/acceptance/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/integration/cli_test.py b/tests/acceptance/cli_test.py similarity index 96% rename from tests/integration/cli_test.py rename to tests/acceptance/cli_test.py index 7ae187b22df..0add049ee81 100644 --- a/tests/integration/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -6,12 +6,10 @@ from collections import namedtuple from operator import attrgetter -import pytest - from .. import mock -from .testcases import DockerClientTestCase from compose.cli.command import get_project from compose.cli.docker_client import docker_client +from tests.integration.testcases import DockerClientTestCase ProcessResult = namedtuple('ProcessResult', 'stdout stderr') @@ -45,8 +43,6 @@ def dispatch(self, options, project_options=None, returncode=0): project_options = project_options or [] proc = subprocess.Popen( ['docker-compose'] + project_options + options, - # Note: this might actually be a patched sys.stdout, so we have - # to specify it here, even though it's the default stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=self.base_dir) @@ -150,7 +146,7 @@ def test_build_no_cache_pull(self): assert BUILD_PULL_TEXT in result.stdout def test_up_detached(self): - self.dispatch(['up', '-d'], None) + self.dispatch(['up', '-d']) service = self.project.get_service('simple') another = self.project.get_service('another') self.assertEqual(len(service.containers()), 1) @@ -162,25 +158,12 @@ def test_up_detached(self): self.assertFalse(container.get('Config.AttachStdout')) self.assertFalse(container.get('Config.AttachStdin')) - # TODO: needs rework - @pytest.mark.skipif(True, reason="runs top") def test_up_attached(self): - with mock.patch( - 'compose.cli.main.attach_to_logs', - autospec=True - ) as mock_attach: - self.dispatch(['up'], None) - _, args, kwargs = mock_attach.mock_calls[0] - _project, log_printer, _names, _timeout = args + self.base_dir = 'tests/fixtures/echo-services' + result = self.dispatch(['up', '--no-color']) - service = self.project.get_service('simple') - another = self.project.get_service('another') - self.assertEqual(len(service.containers()), 1) - self.assertEqual(len(another.containers()), 1) - self.assertEqual( - set(log_printer.containers), - set(self.project.containers()) - ) + assert 'simple_1 | simple' in result.stdout + assert 'another_1 | another' in result.stdout def test_up_without_networking(self): self.require_api_version('1.21') diff --git a/tests/fixtures/echo-services/docker-compose.yml b/tests/fixtures/echo-services/docker-compose.yml new file mode 100644 index 00000000000..8014f3d9167 --- /dev/null +++ b/tests/fixtures/echo-services/docker-compose.yml @@ -0,0 +1,6 @@ +simple: + image: busybox:latest + command: echo simple +another: + image: busybox:latest + command: echo another From 3d9e3d08779c38e52f9892eea20f05afb115ffa1 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 2 Nov 2015 13:33:13 -0500 Subject: [PATCH 1437/4072] Remove service.start_container() It has been an unnecessary wrapper around container.start() for a little while now, so we can call it directly. Signed-off-by: Daniel Nephin --- compose/cli/main.py | 2 +- compose/service.py | 13 ++++--------- tests/acceptance/cli_test.py | 2 +- tests/integration/resilience_test.py | 4 ++-- tests/integration/service_test.py | 27 ++++++++++++++------------- 5 files changed, 22 insertions(+), 26 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index b54b307ef20..11aeac38c20 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -448,7 +448,7 @@ def run(self, project, options): raise e if detach: - service.start_container(container) + container.start() print(container.name) else: dockerpty.start(project.client, container.id, interactive=not options['-T']) diff --git a/compose/service.py b/compose/service.py index 8d716c0bb3c..e121ee953dc 100644 --- a/compose/service.py +++ b/compose/service.py @@ -406,7 +406,7 @@ def execute_convergence_plan(self, if should_attach_logs: container.attach_log_stream() - self.start_container(container) + container.start() return [container] @@ -457,21 +457,16 @@ def recreate_container(self, ) if attach_logs: new_container.attach_log_stream() - self.start_container(new_container) + new_container.start() container.remove() return new_container def start_container_if_stopped(self, container, attach_logs=False): - if container.is_running: - return container - else: + if not container.is_running: log.info("Starting %s" % container.name) if attach_logs: container.attach_log_stream() - return self.start_container(container) - - def start_container(self, container): - container.start() + container.start() return container def remove_duplicate_containers(self, timeout=DEFAULT_TIMEOUT): diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 0add049ee81..41e9718be13 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -557,7 +557,7 @@ def test_kill_stopped_service(self): def test_restart(self): service = self.project.get_service('simple') container = service.create_container() - service.start_container(container) + container.start() started_at = container.dictionary['State']['StartedAt'] self.dispatch(['restart', '-t', '1'], None) container.inspect() diff --git a/tests/integration/resilience_test.py b/tests/integration/resilience_test.py index befd72c7f82..53aedfecf2c 100644 --- a/tests/integration/resilience_test.py +++ b/tests/integration/resilience_test.py @@ -13,7 +13,7 @@ def setUp(self): self.project = Project('composetest', [self.db], self.client) container = self.db.create_container() - self.db.start_container(container) + container.start() self.host_path = container.get('Volumes')['/var/db'] def test_successful_recreate(self): @@ -31,7 +31,7 @@ def test_create_failure(self): self.assertEqual(container.get('Volumes')['/var/db'], self.host_path) def test_start_failure(self): - with mock.patch('compose.service.Service.start_container', crash): + with mock.patch('compose.container.Container.start', crash): with self.assertRaises(Crash): self.project.up(strategy=ConvergenceStrategy.always) diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 4ac04545e1c..f083908b209 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -30,7 +30,8 @@ def create_and_start_container(service, **override_options): container = service.create_container(**override_options) - return service.start_container(container) + container.start() + return container class ServiceTest(DockerClientTestCase): @@ -115,19 +116,19 @@ def test_create_container_with_one_off_when_existing_container_is_running(self): def test_create_container_with_unspecified_volume(self): service = self.create_service('db', volumes=['/var/db']) container = service.create_container() - service.start_container(container) + container.start() self.assertIn('/var/db', container.get('Volumes')) def test_create_container_with_volume_driver(self): service = self.create_service('db', volume_driver='foodriver') container = service.create_container() - service.start_container(container) + container.start() self.assertEqual('foodriver', container.get('Config.VolumeDriver')) def test_create_container_with_cpu_shares(self): service = self.create_service('db', cpu_shares=73) container = service.create_container() - service.start_container(container) + container.start() self.assertEqual(container.get('HostConfig.CpuShares'), 73) def test_build_extra_hosts(self): @@ -165,7 +166,7 @@ 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) container = service.create_container() - service.start_container(container) + container.start() self.assertEqual(set(container.get('HostConfig.ExtraHosts')), set(extra_hosts)) def test_create_container_with_extra_hosts_dicts(self): @@ -173,33 +174,33 @@ def test_create_container_with_extra_hosts_dicts(self): extra_hosts_list = ['somehost:162.242.195.82', 'otherhost:50.31.209.229'] service = self.create_service('db', extra_hosts=extra_hosts) container = service.create_container() - service.start_container(container) + container.start() self.assertEqual(set(container.get('HostConfig.ExtraHosts')), set(extra_hosts_list)) def test_create_container_with_cpu_set(self): service = self.create_service('db', cpuset='0') container = service.create_container() - service.start_container(container) + container.start() self.assertEqual(container.get('HostConfig.CpusetCpus'), '0') def test_create_container_with_read_only_root_fs(self): read_only = True service = self.create_service('db', read_only=read_only) container = service.create_container() - service.start_container(container) + container.start() self.assertEqual(container.get('HostConfig.ReadonlyRootfs'), read_only, container.get('HostConfig')) def test_create_container_with_security_opt(self): security_opt = ['label:disable'] service = self.create_service('db', security_opt=security_opt) container = service.create_container() - service.start_container(container) + container.start() self.assertEqual(set(container.get('HostConfig.SecurityOpt')), set(security_opt)) def test_create_container_with_mac_address(self): service = self.create_service('db', mac_address='02:42:ac:11:65:43') container = service.create_container() - service.start_container(container) + container.start() self.assertEqual(container.inspect()['Config']['MacAddress'], '02:42:ac:11:65:43') def test_create_container_with_specified_volume(self): @@ -208,7 +209,7 @@ def test_create_container_with_specified_volume(self): service = self.create_service('db', volumes=['%s:%s' % (host_path, container_path)]) container = service.create_container() - service.start_container(container) + container.start() volumes = container.inspect()['Volumes'] self.assertIn(container_path, volumes) @@ -281,7 +282,7 @@ def test_create_container_with_volumes_from(self): ] ) host_container = host_service.create_container() - host_service.start_container(host_container) + host_container.start() self.assertIn(volume_container_1.id + ':rw', host_container.get('HostConfig.VolumesFrom')) self.assertIn(volume_container_2.id + ':rw', @@ -300,7 +301,7 @@ def test_execute_convergence_plan_recreate(self): self.assertEqual(old_container.get('Config.Cmd'), ['-d', '1']) self.assertIn('FOO=1', old_container.get('Config.Env')) self.assertEqual(old_container.name, 'composetest_db_1') - service.start_container(old_container) + old_container.start() old_container.inspect() # reload volume data volume_path = old_container.get('Volumes')['/etc'] From 7014cabb04fed3e31fe65c034604f1224195a2fa Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 9 Oct 2015 12:34:55 -0400 Subject: [PATCH 1438/4072] Remove duplication from extends docs. Start restructuring extends docs in preparation for adding documentation about using multiple compose files. Signed-off-by: Daniel Nephin --- docs/compose-file.md | 51 ++++----- docs/extends.md | 240 +++++++++++-------------------------------- 2 files changed, 80 insertions(+), 211 deletions(-) diff --git a/docs/compose-file.md b/docs/compose-file.md index 7723a784769..00b04d58a18 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -168,44 +168,29 @@ accessible to linked services. Only the internal port can be specified. Extend another service, in the current file or another, optionally overriding configuration. -Here's a simple example. Suppose we have 2 files - **common.yml** and -**development.yml**. We can use `extends` to define a service in -**development.yml** which uses configuration defined in **common.yml**: +You can use `extends` on any service together with other configuration keys. +The value must be a dictionary with the key: `service` and may optionally have +the `file` key. -**common.yml** + extends: + file: common.yml + service: webapp - webapp: - build: ./webapp - environment: - - DEBUG=false - - SEND_EMAILS=false +The `file` key specifies the location of a Compose configuration file defining +the service which is being extended. The `file` value can be an absolute or +relative path. If you specify a relative path, Docker Compose treats it as +relative to the location of the current file. If you don't specify a `file`, +Compose looks in the current configuration file. -**development.yml** +The `service` key specifies the name of the service to extend, for example `web` +or `database`. - web: - extends: - file: common.yml - service: webapp - ports: - - "8000:8000" - links: - - db - environment: - - DEBUG=true - db: - image: postgres - -Here, the `web` service in **development.yml** inherits the configuration of -the `webapp` service in **common.yml** - the `build` and `environment` keys - -and adds `ports` and `links` configuration. It overrides one of the defined -environment variables (DEBUG) with a new value, and the other one -(SEND_EMAILS) is left untouched. - -The `file` key is optional, if it is not set then Compose will look for the -service within 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 [tutorial](extends.md#example) and -[reference](extends.md#reference). +For more on `extends`, see the +[the extends documentation](extends.md#extending-services). ### external_links diff --git a/docs/extends.md b/docs/extends.md index e63cf4662ec..c97b2b4fab0 100644 --- a/docs/extends.md +++ b/docs/extends.md @@ -10,20 +10,29 @@ weight=2 -## Extending services in Compose +## Extending services and Compose files + +Compose supports two ways to sharing common configuration and +extend a service with that shared configuration. + +1. Extending individual services with [the `extends` field](#extending-services) +2. Extending entire compositions by + [exnteding compose files](#extending-compose-files) + +### 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 applications that reuse commonly-defined services. -Using `extends` you can define a service in one place and refer to it from -anywhere. +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. -Alternatively, you can deploy the same application to multiple environments with -a slightly different set of services in each case (or with changes to the -configuration of some services). Moreover, you can do so without copy-pasting -the configuration around. +> **Note:** `links` and `volumes_from` are never shared between services using +> `extends`. See +> [Adding and overriding configuration](#adding-and-overriding-configuration) +> for more information. -### Understand the extends configuration +#### Understand the extends configuration When defining any service in `docker-compose.yml`, you can declare that you are extending another service like this: @@ -77,183 +86,46 @@ You can also write other services and link your `web` service to them: db: image: postgres -For full details on how to use `extends`, refer to the [reference](#reference). - -### Example use case - -In this example, you’ll repurpose the example app from the [quick start -guide](/). (If you're not familiar with Compose, it's recommended that -you go through the quick start first.) This example assumes you want to use -Compose both to develop an application locally and then deploy it to a -production environment. - -The local and production environments are similar, but there are some -differences. In development, you mount the application code as a volume so that -it can pick up changes; in production, the code should be immutable from the -outside. This ensures it’s not accidentally changed. The development environment -uses a local Redis container, but in production another team manages the Redis -service, which is listening at `redis-production.example.com`. - -To configure with `extends` for this sample, you must: - -1. Define the web application as a Docker image in `Dockerfile` and a Compose - service in `common.yml`. - -2. Define the development environment in the standard Compose file, - `docker-compose.yml`. - - - Use `extends` to pull in the web service. - - Configure a volume to enable code reloading. - - Create an additional Redis service for the application to use locally. - -3. Define the production environment in a third Compose file, `production.yml`. - - - Use `extends` to pull in the web service. - - Configure the web service to talk to the external, production Redis service. - -#### Define the web app - -Defining the web application requires the following: - -1. Create an `app.py` file. - - This file contains a simple Python application that uses Flask to serve HTTP - and increments a counter in Redis: - - from flask import Flask - from redis import Redis - import os - - app = Flask(__name__) - redis = Redis(host=os.environ['REDIS_HOST'], port=6379) - - @app.route('/') - def hello(): - redis.incr('hits') - return 'Hello World! I have been seen %s times.\n' % redis.get('hits') - - if __name__ == "__main__": - app.run(host="0.0.0.0", debug=True) - - This code uses a `REDIS_HOST` environment variable to determine where to - find Redis. - -2. Define the Python dependencies in a `requirements.txt` file: - - flask - redis - -3. Create a `Dockerfile` to build an image containing the app: - - FROM python:2.7 - ADD . /code - WORKDIR /code - RUN pip install -r requirements.txt - CMD python app.py - -4. Create a Compose configuration file called `common.yml`: - - This configuration defines how to run the app. - - web: - build: . - ports: - - "5000:5000" - - Typically, you would have dropped this configuration into - `docker-compose.yml` file, but in order to pull it into multiple files with - `extends`, it needs to be in a separate file. - -#### Define the development environment - -1. Create a `docker-compose.yml` file. - - The `extends` option pulls in the `web` service from the `common.yml` file - you created in the previous section. +#### Example use case - web: - extends: - file: common.yml - service: web - volumes: - - .:/code - links: - - redis - environment: - - REDIS_HOST=redis - redis: - image: redis +Extending an individual service is useful when you have multiple services that +have a common configuration. In this example we have a composition that with +a web application and a queue worker. Both services use the same codebase and +share many configuration options. - The new addition defines a `web` service that: +In a **common.yml** we'll define the common configuration: - - Fetches the base configuration for `web` out of `common.yml`. - - Adds `volumes` and `links` configuration to the base (`common.yml`) - configuration. - - Sets the `REDIS_HOST` environment variable to point to the linked redis - container. This environment uses a stock `redis` image from the Docker Hub. - -2. Run `docker-compose up`. - - Compose creates, links, and starts a web and redis container linked together. - It mounts your application code inside the web container. - -3. Verify that the code is mounted by changing the message in - `app.py`—say, from `Hello world!` to `Hello from Compose!`. - - Don't forget to refresh your browser to see the change! - -#### Define the production environment - -You are almost done. Now, define your production environment: - -1. Create a `production.yml` file. - - As with `docker-compose.yml`, the `extends` option pulls in the `web` service - from `common.yml`. - - web: - extends: - file: common.yml - service: web - environment: - - REDIS_HOST=redis-production.example.com - -2. Run `docker-compose -f production.yml up`. - - Compose creates *just* a web container and configures the Redis connection via - the `REDIS_HOST` environment variable. This variable points to the production - Redis instance. - - > **Note**: If you try to load up the webapp in your browser you'll get an - > error—`redis-production.example.com` isn't actually a Redis server. - -You've now done a basic `extends` configuration. As your application develops, -you can make any necessary changes to the web service in `common.yml`. Compose -picks up both the development and production environments when you next run -`docker-compose`. You don't have to do any copy-and-paste, and you don't have to -manually keep both environments in sync. - - -### Reference + app: + build: . + environment: + CONFIG_FILE_PATH: /code/config + API_KEY: xxxyyy + cpu_shares: 5 -You can use `extends` on any service together with other configuration keys. It -expects a dictionary that contains a `service` key and optionally a `file` key. -The `extends` key can also take a string, whose value is the name of a `service` defined in the same file. +In a **docker-compose.yml** we'll define the concrete services which use the +common configuration: -The `file` key specifies the location of a Compose configuration file defining -the extension. The `file` value can be an absolute or relative path. If you -specify a relative path, Docker Compose treats it as relative to the location -of the current file. If you don't specify a `file`, Compose looks in the -current configuration file. -The `service` key specifies the name of the service to extend, for example `web` -or `database`. + webapp: + extends: + file: common.yml + service: app + command: /code/run_web_app + ports: + - 8080:8080 + links: + - queue + - db -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 them. + queue_worker: + extends: + file: common.yml + service: app + command: /code/run_worker + links: + - queue -#### Adding and overriding 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 @@ -282,6 +154,8 @@ listed below.** 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: . @@ -291,6 +165,9 @@ Compose to discard the other, if it was defined in the original service. # result image: redis + +Example of build replacing image: + # original service image: redis @@ -356,6 +233,13 @@ locally-defined bindings taking precedence: - /local-dir/bar:/bar - /local-dir/baz/:baz + +### Extending Compose files + +> **Note:** This feature is new in `docker-compose` 1.5 + + + ## Compose documentation - [User guide](/) From 1c4c7ccface1e71e462b1ff3d840c0d0804d230d Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 29 Oct 2015 15:21:06 -0400 Subject: [PATCH 1439/4072] Support a volume to the docs directory and add --watch, so docs can be refreshed. Signed-off-by: Daniel Nephin --- docs/Makefile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/Makefile b/docs/Makefile index 021e8f6e5ea..b9ef0548287 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -13,8 +13,8 @@ DOCKER_ENVS := \ -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 -# to allow `make DOCSDIR=docs docs-shell` (to create a bind mount in docs) -DOCS_MOUNT := $(if $(DOCSDIR),-v $(CURDIR)/$(DOCSDIR):/$(DOCSDIR)) +# 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 @@ -37,7 +37,7 @@ 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) + $(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) From c5cf5cfad45afb66f6b47fd6b7a0937e1e82f7f3 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 21 Oct 2015 17:17:38 -0400 Subject: [PATCH 1440/4072] Changes to production.md for working with multiple Compose files. Signed-off-by: Daniel Nephin --- docs/production.md | 30 +++++++++++------------------- 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/docs/production.md b/docs/production.md index 0b0e46c3f00..39f0e1fe197 100644 --- a/docs/production.md +++ b/docs/production.md @@ -12,11 +12,9 @@ weight=1 ## Using Compose in production -While **Compose is not yet considered production-ready**, if you'd like to experiment and learn more about using it in production deployments, this guide -can help. -The project is actively working towards becoming -production-ready; to learn more about the progress being made, check out the roadmap for details -on how it's coming along and what still needs to be done. +> 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 deploying to production, you'll almost certainly want to make changes to your app configuration that are more appropriate to a live environment. These @@ -30,22 +28,16 @@ changes may include: - 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 a separate Compose file, say -`production.yml`, which specifies production-appropriate configuration. +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. -> **Note:** The [extends](extends.md) keyword is useful for maintaining multiple -> Compose files which re-use common services without having to manually copy and -> paste. +Once you've got a second configuration file, tell Compose to use it with the +`-f` option: -Once you've got an alternate configuration file, make Compose use it -by setting the `COMPOSE_FILE` environment variable: - - $ export COMPOSE_FILE=production.yml - $ docker-compose up -d - -> **Note:** You can also use the file for a one-off command without setting -> an environment variable. You do this by passing the `-f` flag, e.g., -> `docker-compose -f production.yml up -d`. + $ docker-compose -f docker-compose.yml -f production.yml up -d ### Deploying changes From eab265befaa6c48d44ad2b9314ccc97ad6630f7b Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 9 Oct 2015 12:56:59 -0400 Subject: [PATCH 1441/4072] Document using multiple Compose files use cases. Signed-off-by: Daniel Nephin --- docs/compose-file.md | 18 ++-- docs/extends.md | 208 ++++++++++++++++++++++++++++++++++--------- docs/production.md | 3 + 3 files changed, 176 insertions(+), 53 deletions(-) diff --git a/docs/compose-file.md b/docs/compose-file.md index 00b04d58a18..4f8fc9e013c 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -169,21 +169,21 @@ 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 value must be a dictionary with the key: `service` and may optionally have -the `file` key. +The `extends` value must be a dictionary defined with a required `service` +and an optional `file` key. extends: file: common.yml service: webapp -The `file` key specifies the location of a Compose configuration file defining -the service which is being extended. The `file` value can be an absolute or -relative path. If you specify a relative path, Docker Compose treats it as -relative to the location of the current file. If you don't specify a `file`, -Compose looks in the current configuration file. +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. -The `service` key specifies the name of the service to extend, for example `web` -or `database`. +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` diff --git a/docs/extends.md b/docs/extends.md index c97b2b4fab0..58def22d7f4 100644 --- a/docs/extends.md +++ b/docs/extends.md @@ -10,16 +10,15 @@ weight=2 -## Extending services and Compose files +# Extending services and Compose files -Compose supports two ways to sharing common configuration and -extend a service with that shared configuration. +Compose supports two methods of sharing common configuration: 1. Extending individual services with [the `extends` field](#extending-services) 2. Extending entire compositions by - [exnteding compose files](#extending-compose-files) + [using multiple compose files](#multiple-compose-files) -### Extending services +## Extending services Docker Compose's `extends` keyword enables sharing of common configurations among different files, or even different projects entirely. Extending services @@ -30,9 +29,9 @@ 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. + > for more information. -#### Understand the extends configuration +### Understand the extends configuration When defining any service in `docker-compose.yml`, you can declare that you are extending another service like this: @@ -54,8 +53,8 @@ looks like this: - "/data" In this case, you'll get exactly the same result as if you wrote -`docker-compose.yml` with that `build`, `ports` and `volumes` configuration -defined directly under `web`. +`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`: @@ -86,14 +85,14 @@ You can also write other services and link your `web` service to them: db: image: postgres -#### Example use case +### Example use case Extending an individual service is useful when you have multiple services that -have a common configuration. In this example we have a composition that with -a web application and a queue worker. Both services use the same codebase and -share many configuration options. +have a common configuration. The example below is a composition 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'll define the common configuration: +In a **common.yml** we define the common configuration: app: build: . @@ -102,10 +101,9 @@ In a **common.yml** we'll define the common configuration: API_KEY: xxxyyy cpu_shares: 5 -In a **docker-compose.yml** we'll define the concrete services which use the +In a **docker-compose.yml** we define the concrete services which use the common configuration: - webapp: extends: file: common.yml @@ -121,11 +119,11 @@ common configuration: extends: file: common.yml service: app - command: /code/run_worker - links: - - queue + command: /code/run_worker + links: + - queue -#### Adding and overriding 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 @@ -134,13 +132,11 @@ 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. -If a configuration option is defined in both the original service and the local -service, the local value either *override*s or *extend*s the definition of the -original service. This works differently for other configuration options. +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. **This is the default behaviour - all exceptions are -listed below.** +replaces the old value. # original service command: python app.py @@ -195,8 +191,8 @@ For the **multi-value options** `ports`, `expose`, `external_links`, `dns` and - "4000" - "5000" -In the case of `environment` and `labels`, Compose "merges" entries together -with locally-defined values taking precedence: +In the case of `environment`, `labels`, `volumes` and `devices`, Compose +"merges" entries together with locally-defined values taking precedence: # original service environment: @@ -214,30 +210,154 @@ with locally-defined values taking precedence: - BAR=local - BAZ=local -Finally, for `volumes` and `devices`, Compose "merges" entries together with -locally-defined bindings taking precedence: - # original service - volumes: - - /original-dir/foo:/foo - - /original-dir/bar:/bar +## Multiple Compose files - # local service - volumes: - - /local-dir/bar:/bar - - /local-dir/baz/:baz +Using multiple Compose files enables you to customize a composition for +different environments or different workflows. - # result - volumes: - - /original-dir/foo:/foo - - /local-dir/bar:/bar - - /local-dir/baz/:baz +### 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 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. + +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 +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 +composition for different environments, and running administrative tasks +against a composition. + +#### Different environments + +A common use case for multiple files is changing a development composition +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 composition 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 -### Extending Compose files +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 composition. 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 -> **Note:** This feature is new in `docker-compose` 1.5 +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 ## Compose documentation diff --git a/docs/production.md b/docs/production.md index 39f0e1fe197..0a5e77b5226 100644 --- a/docs/production.md +++ b/docs/production.md @@ -39,6 +39,9 @@ Once you've got a second configuration file, tell Compose to use it with the $ 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 From 8bdde9a7313fbaf5221dff20b314aa0fed0dd66a Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 2 Nov 2015 15:14:42 -0500 Subject: [PATCH 1442/4072] Replace composition with Compose app. Signed-off-by: Daniel Nephin --- docs/extends.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/extends.md b/docs/extends.md index 58def22d7f4..e4d09af98de 100644 --- a/docs/extends.md +++ b/docs/extends.md @@ -15,8 +15,8 @@ weight=2 Compose supports two methods of sharing common configuration: 1. Extending individual services with [the `extends` field](#extending-services) -2. Extending entire compositions by - [using multiple compose files](#multiple-compose-files) +2. Extending entire Compose file by + [using multiple Compose files](#multiple-compose-files) ## Extending services @@ -88,7 +88,7 @@ You can also write other services and link your `web` service to them: ### Example use case Extending an individual service is useful when you have multiple services that -have a common configuration. The example below is a composition with +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. @@ -213,8 +213,8 @@ In the case of `environment`, `labels`, `volumes` and `devices`, Compose ## Multiple Compose files -Using multiple Compose files enables you to customize a composition for -different environments or different workflows. +Using multiple Compose files enables you to customize a Compose application +for different environments or different workflows. ### Understanding multiple Compose files @@ -248,12 +248,12 @@ relative to the base file. ### Example use case In this section are two common use cases for multiple compose files: changing a -composition for different environments, and running administrative tasks -against a composition. +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 composition +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: @@ -301,7 +301,7 @@ host, mounts our code as a volume, and builds the web image. When you run `docker-compose up` it reads the overrides automatically. -Now, it would be nice to use this composition in a production environment. So, +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). @@ -332,7 +332,7 @@ production. #### Administrative tasks Another common use case is running adhoc or administrative tasks against one -or more services in a composition. This example demonstrates running a +or more services in a Compose app. This example demonstrates running a database backup. Start with a **docker-compose.yml**. From e524cce222440f21740925a6e247b7d122f7c4c6 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 27 Oct 2015 16:29:36 -0400 Subject: [PATCH 1443/4072] Add missing title to compose file reference. Signed-off-by: Daniel Nephin --- docs/compose-file.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/compose-file.md b/docs/compose-file.md index b72a7cc4372..ffcda61cb97 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -24,6 +24,11 @@ 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`. +## Service configuration reference + +This section contains a list of all configuration options supported by a service +definition. + ### build Path to a directory containing a Dockerfile. When the value supplied is a From 8733d09a9c7b3a53fa7eef0d13d58c9dde7a4b30 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 9 Oct 2015 10:49:41 -0400 Subject: [PATCH 1444/4072] Extract the getting started guide from the index page. Signed-off-by: Daniel Nephin --- docs/completion.md | 2 +- docs/django.md | 1 + docs/extends.md | 1 + docs/gettingstarted.md | 163 +++++++++++++++++++++++++++++++++++++++++ docs/index.md | 139 +---------------------------------- docs/install.md | 1 + docs/production.md | 3 - docs/rails.md | 2 +- docs/wordpress.md | 2 +- 9 files changed, 170 insertions(+), 144 deletions(-) create mode 100644 docs/gettingstarted.md diff --git a/docs/completion.md b/docs/completion.md index bc8bedc96cc..3c2022d8278 100644 --- a/docs/completion.md +++ b/docs/completion.md @@ -5,7 +5,7 @@ description = "Compose CLI reference" keywords = ["fig, composition, compose, docker, orchestration, cli, reference"] [menu.main] parent="smn_workw_compose" -weight=3 +weight=10 +++ diff --git a/docs/django.md b/docs/django.md index 2bb67399c52..d4d2bd1ecf0 100644 --- a/docs/django.md +++ b/docs/django.md @@ -173,6 +173,7 @@ In this section, you set up the database connection for Django. - [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) diff --git a/docs/extends.md b/docs/extends.md index f0b9e9ea2d6..e63cf4662ec 100644 --- a/docs/extends.md +++ b/docs/extends.md @@ -360,6 +360,7 @@ locally-defined bindings taking precedence: - [User guide](/) - [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) diff --git a/docs/gettingstarted.md b/docs/gettingstarted.md new file mode 100644 index 00000000000..f2024b39ba7 --- /dev/null +++ b/docs/gettingstarted.md @@ -0,0 +1,163 @@ + + + +## Getting Started + +Let's get started with a walkthrough of getting a simple Python web app running +on Compose. It assumes a little knowledge of Python, but the concepts +demonstrated here should be understandable even if you're not familiar with +Python. + +### Installation and set-up + +First, [install Docker and Compose](install.md). + +Next, you'll want to make a directory for the project: + + $ mkdir composetest + $ cd composetest + +Inside this directory, create `app.py`, a simple Python web app that uses the Flask +framework and increments a value in Redis. Don't worry if you don't have Redis installed, docker is going to take care of that for you when we [define services](#define-services): + + 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) + +Next, define the Python dependencies in a file called `requirements.txt`: + + flask + redis + +### Create a Docker image + +Now, create a Docker image containing all of your app's dependencies. You +specify how to build the image using a file called +[`Dockerfile`](http://docs.docker.com/reference/builder/): + + 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](https://docs.docker.com/userguide/dockerimages/#building-an-image-from-a-dockerfile) and the [Dockerfile reference](http://docs.docker.com/reference/builder/). + +You can build the image by running `docker build -t web .`. + +### Define services + +Next, define a set of services using `docker-compose.yml`: + + web: + build: . + ports: + - "5000:5000" + volumes: + - .:/code + links: + - redis + redis: + image: redis + +This template 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 current directory on the host to `/code` inside the container allowing you to modify the code without having to rebuild the image. +* Links the web container 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. + +### Build and run your app with Compose + +Now, when you run `docker-compose up`, Compose will pull a Redis image, build an image for your code, and start everything up: + + $ 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 + +If you're using [Docker Machine](https://docs.docker.com/machine), then `docker-machine ip MACHINE_VM` will tell you its address and you can open `http://MACHINE_VM_IP:5000` in a browser. + +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`. + +You should get a message in your browser saying: + +`Hello World! I have been seen 1 times.` + +Refreshing the page will increment the number. + +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. + +- Next, try the quick start guide for [Django](django.md), + [Rails](rails.md), or [WordPress](wordpress.md). +- See the reference guides for complete details on the [commands](./reference/index.md), the + [configuration file](compose-file.md) and [environment variables](env.md). + +## More Compose documentation + +- [User guide](/) +- [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/index.md b/docs/index.md index 62c78d68936..19a6c801c2d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -50,150 +50,13 @@ Compose has commands for managing the whole lifecycle of your application: ## 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) - [Command line reference](./reference/index.md) - [Compose file reference](compose-file.md) -## Quick start - -Let's get started with a walkthrough of getting a simple Python web app running -on Compose. It assumes a little knowledge of Python, but the concepts -demonstrated here should be understandable even if you're not familiar with -Python. - -### Installation and set-up - -First, [install Docker and Compose](install.md). - -Next, you'll want to make a directory for the project: - - $ mkdir composetest - $ cd composetest - -Inside this directory, create `app.py`, a simple Python web app that uses the Flask -framework and increments a value in Redis. Don't worry if you don't have Redis installed, docker is going to take care of that for you when we [define services](#define-services): - - 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) - -Next, define the Python dependencies in a file called `requirements.txt`: - - flask - redis - -### Create a Docker image - -Now, create a Docker image containing all of your app's dependencies. You -specify how to build the image using a file called -[`Dockerfile`](http://docs.docker.com/reference/builder/): - - 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](https://docs.docker.com/userguide/dockerimages/#building-an-image-from-a-dockerfile) and the [Dockerfile reference](http://docs.docker.com/reference/builder/). - -You can build the image by running `docker build -t web .`. - -### Define services - -Next, define a set of services using `docker-compose.yml`: - - web: - build: . - ports: - - "5000:5000" - volumes: - - .:/code - redis: - image: redis - -This template 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 current directory on the host to `/code` inside the container allowing you to modify the code without having to rebuild the image. - -The `redis` service uses the latest public [Redis](https://registry.hub.docker.com/_/redis/) image pulled from the Docker Hub registry. - -### Build and run your app with Compose - -Now, when you run `docker-compose up`, Compose will pull a Redis image, build an image for your code, and start everything up: - - $ 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 - -If you're using [Docker Machine](https://docs.docker.com/machine), then `docker-machine ip MACHINE_VM` will tell you its address and you can open `http://MACHINE_VM_IP:5000` in a browser. - -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`. - -You should get a message in your browser saying: - -`Hello World! I have been seen 1 times.` - -Refreshing the page will increment the number. - -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. - -- Next, try the quick start guide for [Django](django.md), - [Rails](rails.md), or [WordPress](wordpress.md). -- See the reference guides for complete details on the [commands](./reference/index.md), the - [configuration file](compose-file.md) and [environment variables](env.md). ## Release Notes diff --git a/docs/install.md b/docs/install.md index 711902c7fa4..4eb0dc18691 100644 --- a/docs/install.md +++ b/docs/install.md @@ -127,6 +127,7 @@ To uninstall Docker Compose if you installed using `pip`: ## Where to go next - [User guide](/) +- [Getting Started](gettingstarted.md) - [Get started with Django](django.md) - [Get started with Rails](rails.md) - [Get started with WordPress](wordpress.md) diff --git a/docs/production.md b/docs/production.md index 8793f9277e4..0b0e46c3f00 100644 --- a/docs/production.md +++ b/docs/production.md @@ -86,8 +86,5 @@ guide. ## 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/rails.md b/docs/rails.md index e81675c5374..8e16af64230 100644 --- a/docs/rails.md +++ b/docs/rails.md @@ -135,8 +135,8 @@ That's it. Your app should now be running on port 3000 on your Docker daemon. If - [User guide](/) - [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/wordpress.md b/docs/wordpress.md index 8c1f5b0acb2..373ef4d0d51 100644 --- a/docs/wordpress.md +++ b/docs/wordpress.md @@ -95,8 +95,8 @@ database containers. If you're using [Docker Machine](https://docs.docker.com/ma - [User guide](/) - [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) From 09d2bdbb21135ef88b8eb296f2b6fcd4e6dc03f0 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 9 Oct 2015 11:08:23 -0400 Subject: [PATCH 1445/4072] Flush out features and use cases. Signed-off-by: Daniel Nephin --- README.md | 17 ++++++---- docs/index.md | 94 ++++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 97 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index d779d607c35..6b783bf1261 100644 --- a/README.md +++ b/README.md @@ -4,13 +4,15 @@ Docker Compose *(Previously known as Fig)* -Compose is a tool for defining and running multi-container applications with -Docker. With Compose, you define a multi-container application in a single -file, then spin your application up in a single command which does everything -that needs to be done to get it running. +Compose is a tool for defining and running multi-container Docker applications. +With Compose, you define a multi-container application in a compose +file then, using a single command, you create and start all the containers +from your configuration. To learn more about all the features of Compose +see [the list of features](#features) -Compose is great for development environments, staging servers, and CI. We don't -recommend that you use it in production yet. +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. @@ -33,6 +35,9 @@ A `docker-compose.yml` looks like this: redis: image: redis +For more information about the Compose file, see the +[Compose file reference](docs/yml.md) + Compose has commands for managing the whole lifecycle of your application: * Start, stop and rebuild services diff --git a/docs/index.md b/docs/index.md index 19a6c801c2d..ac7e07f9ba8 100644 --- a/docs/index.md +++ b/docs/index.md @@ -11,20 +11,22 @@ parent="smn_workw_compose" # Overview of Docker Compose -Compose is a tool for defining and running multi-container applications with -Docker. With Compose, you define a multi-container application in a single -file, then spin your application up in a single command which does everything -that needs to be done to get it running. +Compose is a tool for defining and running multi-container Docker applications. +With Compose, you define a multi-container application in a compose +file then, using a single command, you create and start all the containers +from your configuration. To learn more about all the features of Compose +see [the list of features](#features) -Compose is great for development environments, staging servers, and CI. We don't -recommend that you use it in production yet. +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: +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: @@ -40,6 +42,9 @@ A `docker-compose.yml` looks like this: redis: image: redis +For more information about the Compose file, see the +[Compose file reference](yml.md) + Compose has commands for managing the whole lifecycle of your application: * Start, stop and rebuild services @@ -57,11 +62,84 @@ Compose has commands for managing the whole lifecycle of your application: - [Command line reference](./reference/index.md) - [Compose file reference](compose-file.md) +## Features + +#### Preserve volume data + +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 to different environments + +> New in `docker-compose` 1.5 + +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. + +Compose files can also be extended from other files using the `extends` +field in a compose file, or by using multiple 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 it is often helpful to be able to run the +application and interact with it. If the application has any service dependencies +(databases, queues, caches, web services, etc) you need a way to document the +dependencies, configuration and operation of each. Compose provides a convenient +format for definition these dependencies (the [Compose file](yml.md)) and a CLI +tool for starting an isolated environment. Compose can replace a multi-page +"developer getting started guide" with a single machine readable configuration +file and a single command `docker-compose up`. + +### 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](yml.md) you can create and destroy these +environments in just a few commands: + + $ docker-compose up -d + $ ./run_tests + $ docker-compose stop + $ docker-compose rm -f + +### 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. +Compose can be used to deploy to a remote docker engine, for example a cloud +instance provisioned with [Docker Machine](https://docs.docker.com/machine/) or +a [Docker Swarm](https://docs.docker.com/swarm/) cluster. + +See [compose in production](production.md) for more details. + ## 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). +Compose, please refer to the +[CHANGELOG](https://github.com/docker/compose/blob/master/CHANGELOG.md). ## Getting help From bfb46b37d318eb7f07c031e59840e50bec1e14a2 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 21 Oct 2015 16:28:47 -0400 Subject: [PATCH 1446/4072] Updates to gettingstarted guide from PR feedback. Signed-off-by: Daniel Nephin --- docs/gettingstarted.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/gettingstarted.md b/docs/gettingstarted.md index f2024b39ba7..9cc478d7e5c 100644 --- a/docs/gettingstarted.md +++ b/docs/gettingstarted.md @@ -21,13 +21,13 @@ Python. First, [install Docker and Compose](install.md). -Next, you'll want to make a directory for the project: +Create a directory for the project: $ mkdir composetest $ cd composetest Inside this directory, create `app.py`, a simple Python web app that uses the Flask -framework and increments a value in Redis. Don't worry if you don't have Redis installed, docker is going to take care of that for you when we [define services](#define-services): +framework and increments a value in Redis. from flask import Flask from redis import Redis @@ -74,7 +74,7 @@ You can build the image by running `docker build -t web .`. ### Define services -Next, define a set of services using `docker-compose.yml`: +Define a set of services using `docker-compose.yml`: web: build: . @@ -91,8 +91,8 @@ This template 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 current directory on the host to `/code` inside the container allowing you to modify the code without having to rebuild the image. -* Links the web container to the Redis service. +* 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. @@ -113,7 +113,7 @@ If you're using [Docker Machine](https://docs.docker.com/machine), then `docker- 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`. -You should get a message in your browser saying: +You will see a message in your browser saying: `Hello World! I have been seen 1 times.` From 7ee36829ac87f6e02d14b09c00a63498832d12d3 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 23 Oct 2015 16:51:03 -0400 Subject: [PATCH 1447/4072] Update intro docs based on feedback. Signed-off-by: Daniel Nephin --- README.md | 4 +- docs/gettingstarted.md | 207 +++++++++++++++++++++++------------------ docs/index.md | 50 ++++++---- 3 files changed, 147 insertions(+), 114 deletions(-) diff --git a/README.md b/README.md index 6b783bf1261..f8a5050e7a0 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,6 @@ Docker Compose ============== ![Docker Compose](logo.png?raw=true "Docker Compose Logo") -*(Previously known as Fig)* - Compose is a tool for defining and running multi-container Docker applications. With Compose, you define a multi-container application in a compose file then, using a single command, you create and start all the containers @@ -36,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/yml.md) +[Compose file reference](docs/compose-file.md) Compose has commands for managing the whole lifecycle of your application: diff --git a/docs/gettingstarted.md b/docs/gettingstarted.md index 9cc478d7e5c..f685bf3820a 100644 --- a/docs/gettingstarted.md +++ b/docs/gettingstarted.md @@ -10,84 +10,103 @@ weight=3 -## Getting Started +# Getting Started -Let's get started with a walkthrough of getting a simple Python web app running -on Compose. It assumes a little knowledge of Python, but the concepts -demonstrated here should be understandable even if you're not familiar with -Python. +On this page you build a simple Python web application running on 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. -### Installation and set-up +## Prerequisites -First, [install Docker and Compose](install.md). +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. -Create a directory for the project: +## Step 1: Setup - $ mkdir composetest - $ cd composetest +1. Create a directory for the project: -Inside this directory, create `app.py`, a simple Python web app that uses the Flask -framework and increments a value in Redis. + $ mkdir composetest + $ cd composetest - from flask import Flask - from redis import Redis +2. With your favorite text editor create a file called `app.py` in your project + directory. - app = Flask(__name__) - redis = Redis(host='redis', port=6379) + from flask import Flask + from redis import Redis - @app.route('/') - def hello(): - redis.incr('hits') - return 'Hello World! I have been seen %s times.' % redis.get('hits') + app = Flask(__name__) + redis = Redis(host='redis', port=6379) - if __name__ == "__main__": - app.run(host="0.0.0.0", debug=True) + @app.route('/') + def hello(): + redis.incr('hits') + return 'Hello World! I have been seen %s times.' % redis.get('hits') -Next, define the Python dependencies in a file called `requirements.txt`: + if __name__ == "__main__": + app.run(host="0.0.0.0", debug=True) - flask - redis +3. Create another file called `requirements.txt` in your project directory and + add the following: -### Create a Docker image + flask + redis -Now, create a Docker image containing all of your app's dependencies. You -specify how to build the image using a file called -[`Dockerfile`](http://docs.docker.com/reference/builder/): + These define the applications dependencies. - FROM python:2.7 - ADD . /code - WORKDIR /code - RUN pip install -r requirements.txt - CMD python app.py +## Step 2: Create a Docker image -This tells Docker to: +In this step, you build a new Docker image. The image contains all the +dependencies the Python application requires, including Python itself. -* 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` +1. In your project directory create a file named `Dockerfile` and add the + following: -For more information on how to write Dockerfiles, see the [Docker user guide](https://docs.docker.com/userguide/dockerimages/#building-an-image-from-a-dockerfile) and the [Dockerfile reference](http://docs.docker.com/reference/builder/). + FROM python:2.7 + ADD . /code + WORKDIR /code + RUN pip install -r requirements.txt + CMD python app.py -You can build the image by running `docker build -t web .`. + This tells Docker to: -### Define services + * 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](https://docs.docker.com/userguide/dockerimages/#building-an-image-from-a-dockerfile) and the [Dockerfile reference](http://docs.docker.com/reference/builder/). + +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`: - web: - build: . - ports: - - "5000:5000" - volumes: - - .:/code - links: - - redis - redis: - image: redis +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 -This template defines two services, `web` and `redis`. The `web` service: +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. @@ -96,68 +115,74 @@ This template defines two services, `web` and `redis`. The `web` service: The `redis` service uses the latest public [Redis](https://registry.hub.docker.com/_/redis/) image pulled from the Docker Hub registry. -### Build and run your app with Compose +## Step 4: Build and run your app with Compose -Now, when you run `docker-compose up`, Compose will pull a Redis image, build an image for your code, and start everything up: +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 + $ 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 -If you're using [Docker Machine](https://docs.docker.com/machine), then `docker-machine ip MACHINE_VM` will tell you its address and you can open `http://MACHINE_VM_IP:5000` in a browser. + Compose pulls a Redis image, builds an image for your code, and start the + services you defined. -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`. +2. Enter `http://0.0.0.0:5000/` in a browser to see the application running. -You will see a message in your browser saying: + 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. -`Hello World! I have been seen 1 times.` + 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. -Refreshing the page will increment the number. + 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 + $ 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 + $ 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 + $ 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). -- See the reference guides for complete details on the [commands](./reference/index.md), the - [configuration file](compose-file.md) and [environment variables](env.md). - -## More Compose documentation - -- [User guide](/) -- [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) +- [Explore the full list of Compose commands](./reference/index.md) +- [Compose configuration file reference](compose-file.md) diff --git a/docs/index.md b/docs/index.md index ac7e07f9ba8..6ea0e99ab55 100644 --- a/docs/index.md +++ b/docs/index.md @@ -43,7 +43,7 @@ A `docker-compose.yml` looks like this: image: redis For more information about the Compose file, see the -[Compose file reference](yml.md) +[Compose file reference](compose-file.md) Compose has commands for managing the whole lifecycle of your application: @@ -64,6 +64,12 @@ Compose has commands for managing the whole lifecycle of your application: ## Features +The features of Compose that make it effective are: + +* [Preserve volume data](#preserve-volume-data) +* [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) + #### Preserve volume data Compose preserves all volumes used by your services. When `docker-compose up` @@ -80,18 +86,15 @@ containers. Re-using containers means that you can make changes to your environment very quickly. -#### Variables and moving a composition to different environments - -> New in `docker-compose` 1.5 +#### 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. -Compose files can also be extended from other files using the `extends` -field in a compose file, or by using multiple files. See [extends](extends.md) -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 @@ -101,14 +104,19 @@ below. ### Development environments -When you're developing software it is often helpful to be able to run the -application and interact with it. If the application has any service dependencies -(databases, queues, caches, web services, etc) you need a way to document the -dependencies, configuration and operation of each. Compose provides a convenient -format for definition these dependencies (the [Compose file](yml.md)) and a CLI -tool for starting an isolated environment. Compose can replace a multi-page -"developer getting started guide" with a single machine readable configuration -file and a single command `docker-compose up`. +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 @@ -116,7 +124,7 @@ 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](yml.md) you can create and destroy these +environment in a [Compose file](compose-file.md) you can create and destroy these environments in just a few commands: $ docker-compose up -d @@ -128,11 +136,13 @@ environments in just a few commands: Compose has traditionally been focused on development and testing workflows, but with each release we're making progress on more production-oriented features. -Compose can be used to deploy to a remote docker engine, for example a cloud -instance provisioned with [Docker Machine](https://docs.docker.com/machine/) or -a [Docker Swarm](https://docs.docker.com/swarm/) cluster. +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. -See [compose in production](production.md) for more details. +For details on using production-oriented features, see +[compose in production](production.md) in this documentation. ## Release Notes From 413921a287a55e62e9b6ed4f25f419fb7a7b7b1c Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 27 Oct 2015 16:51:49 -0400 Subject: [PATCH 1448/4072] Add another feature to the docs - multiple environments per host. Signed-off-by: Daniel Nephin --- docs/index.md | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/docs/index.md b/docs/index.md index 6ea0e99ab55..ebc1320eaea 100644 --- a/docs/index.md +++ b/docs/index.md @@ -66,11 +66,29 @@ Compose has commands for managing the whole lifecycle of your application: The features of Compose that make it effective are: -* [Preserve volume data](#preserve-volume-data) +* [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) -#### Preserve volume data +#### 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 From 83714fbac26cad2c1fa0b0add01eb04042f29758 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 30 Oct 2015 13:27:06 -0400 Subject: [PATCH 1449/4072] Touch up intro paragraph with feedback from @moxiegirl. Signed-off-by: Daniel Nephin --- README.md | 6 +++--- docs/index.md | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index f8a5050e7a0..4c967aebcc2 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,10 @@ Docker Compose ![Docker Compose](logo.png?raw=true "Docker Compose Logo") Compose is a tool for defining and running multi-container Docker applications. -With Compose, you define a multi-container application in a compose -file then, using a single command, you create and start all the containers +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) +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 diff --git a/docs/index.md b/docs/index.md index ebc1320eaea..279154eef9f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -12,10 +12,10 @@ parent="smn_workw_compose" # Overview of Docker Compose Compose is a tool for defining and running multi-container Docker applications. -With Compose, you define a multi-container application in a compose -file then, using a single command, you create and start all the containers +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) +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 From e503e085ac98e6e744020f5068be40412f443f67 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 3 Nov 2015 11:52:44 -0500 Subject: [PATCH 1450/4072] Re-order extends docs. Signed-off-by: Daniel Nephin --- docs/extends.md | 303 ++++++++++++++++++++++++------------------------ 1 file changed, 153 insertions(+), 150 deletions(-) diff --git a/docs/extends.md b/docs/extends.md index e4d09af98de..b21b6d76db8 100644 --- a/docs/extends.md +++ b/docs/extends.md @@ -14,9 +14,159 @@ weight=2 Compose supports two methods of sharing common configuration: -1. Extending individual services with [the `extends` field](#extending-services) -2. Extending entire Compose file by +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 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. + +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 +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 @@ -123,7 +273,7 @@ common configuration: links: - queue -### Adding and overriding 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 @@ -211,153 +361,6 @@ In the case of `environment`, `labels`, `volumes` and `devices`, Compose - BAZ=local -## 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 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. - -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 -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 ## Compose documentation From 621d1a51679e42a4fed2a1462332e03e6577d3f3 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 30 Oct 2015 15:26:56 -0400 Subject: [PATCH 1451/4072] Fix networking tests to work with new API in engine rc4 (https://github.com/docker/docker/pull/17536) Signed-off-by: Daniel Nephin --- tests/integration/cli_test.py | 12 ++++++------ tests/integration/project_test.py | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/integration/cli_test.py b/tests/integration/cli_test.py index 45f45645f5b..d621f2d1320 100644 --- a/tests/integration/cli_test.py +++ b/tests/integration/cli_test.py @@ -215,17 +215,17 @@ def test_up_with_networking(self): networks = client.networks(names=[self.project.name]) for n in networks: - self.addCleanup(client.remove_network, n['id']) + self.addCleanup(client.remove_network, n['Id']) self.assertEqual(len(networks), 1) - self.assertEqual(networks[0]['driver'], 'bridge') + self.assertEqual(networks[0]['Driver'], 'bridge') - network = client.inspect_network(networks[0]['id']) - self.assertEqual(len(network['containers']), len(services)) + network = client.inspect_network(networks[0]['Id']) + self.assertEqual(len(network['Containers']), len(services)) for service in services: containers = service.containers() self.assertEqual(len(containers), 1) - self.assertIn(containers[0].id, network['containers']) + self.assertIn(containers[0].id, network['Containers']) self.assertEqual(containers[0].get('Config.Hostname'), service.name) web_container = self.project.get_service('web').containers()[0] @@ -518,7 +518,7 @@ def test_run_with_networking(self, _): container, = service.containers(stopped=True, one_off=True) networks = client.networks(names=[self.project.name]) for n in networks: - self.addCleanup(client.remove_network, n['id']) + self.addCleanup(client.remove_network, n['Id']) self.assertEqual(len(networks), 1) self.assertEqual(container.human_readable_command, u'true') diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index fd45b9393f9..950523878e2 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -111,7 +111,7 @@ def test_get_network(self): network_name = 'network_does_exist' project = Project(network_name, [], client) client.create_network(network_name) - assert project.get_network()['name'] == network_name + assert project.get_network()['Name'] == network_name def test_net_from_service(self): project = Project.from_dicts( From 9286e62449da0322336728fc2035201939f6a903 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 2 Nov 2015 13:43:29 -0500 Subject: [PATCH 1452/4072] On a test failure only show the last 100 lines of daemon output. Signed-off-by: Daniel Nephin --- script/build-linux-inner | 2 +- script/test-versions | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/script/build-linux-inner b/script/build-linux-inner index 1d0f790504a..01137ff2408 100755 --- a/script/build-linux-inner +++ b/script/build-linux-inner @@ -8,7 +8,7 @@ VENV=/code/.tox/py27 mkdir -p `pwd`/dist chmod 777 `pwd`/dist -$VENV/bin/pip install -r requirements-build.txt +$VENV/bin/pip install -q -r requirements-build.txt su -c "$VENV/bin/pyinstaller docker-compose.spec" user mv dist/docker-compose $TARGET $TARGET version diff --git a/script/test-versions b/script/test-versions index 43326ccb6b5..623b107b930 100755 --- a/script/test-versions +++ b/script/test-versions @@ -31,7 +31,7 @@ for version in $DOCKER_VERSIONS; do function on_exit() { if [[ "$?" != "0" ]]; then - docker logs "$daemon_container" + docker logs "$daemon_container" 2>&1 | tail -n 100 fi docker rm -vf "$daemon_container" } @@ -45,6 +45,7 @@ for version in $DOCKER_VERSIONS; do --volume="/var/lib/docker" \ dockerswarm/dind:$version \ docker daemon -H tcp://0.0.0.0:2375 $DOCKER_DAEMON_ARGS \ + 2>&1 | tail -n 10 docker run \ --rm \ From bd3589689221af154f9b321c41f2d69b89aeb268 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 9 Oct 2015 12:34:55 -0400 Subject: [PATCH 1453/4072] Remove duplication from extends docs. Start restructuring extends docs in preparation for adding documentation about using multiple compose files. Signed-off-by: Daniel Nephin --- docs/compose-file.md | 51 ++++----- docs/extends.md | 240 +++++++++++-------------------------------- 2 files changed, 80 insertions(+), 211 deletions(-) diff --git a/docs/compose-file.md b/docs/compose-file.md index ffcda61cb97..33e7d2b53c6 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -166,44 +166,29 @@ accessible to linked services. Only the internal port can be specified. Extend another service, in the current file or another, optionally overriding configuration. -Here's a simple example. Suppose we have 2 files - **common.yml** and -**development.yml**. We can use `extends` to define a service in -**development.yml** which uses configuration defined in **common.yml**: +You can use `extends` on any service together with other configuration keys. +The value must be a dictionary with the key: `service` and may optionally have +the `file` key. -**common.yml** + extends: + file: common.yml + service: webapp - webapp: - build: ./webapp - environment: - - DEBUG=false - - SEND_EMAILS=false +The `file` key specifies the location of a Compose configuration file defining +the service which is being extended. The `file` value can be an absolute or +relative path. If you specify a relative path, Docker Compose treats it as +relative to the location of the current file. If you don't specify a `file`, +Compose looks in the current configuration file. -**development.yml** +The `service` key specifies the name of the service to extend, for example `web` +or `database`. - web: - extends: - file: common.yml - service: webapp - ports: - - "8000:8000" - links: - - db - environment: - - DEBUG=true - db: - image: postgres - -Here, the `web` service in **development.yml** inherits the configuration of -the `webapp` service in **common.yml** - the `build` and `environment` keys - -and adds `ports` and `links` configuration. It overrides one of the defined -environment variables (DEBUG) with a new value, and the other one -(SEND_EMAILS) is left untouched. - -The `file` key is optional, if it is not set then Compose will look for the -service within 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 [tutorial](extends.md#example) and -[reference](extends.md#reference). +For more on `extends`, see the +[the extends documentation](extends.md#extending-services). ### external_links diff --git a/docs/extends.md b/docs/extends.md index e63cf4662ec..c97b2b4fab0 100644 --- a/docs/extends.md +++ b/docs/extends.md @@ -10,20 +10,29 @@ weight=2 -## Extending services in Compose +## Extending services and Compose files + +Compose supports two ways to sharing common configuration and +extend a service with that shared configuration. + +1. Extending individual services with [the `extends` field](#extending-services) +2. Extending entire compositions by + [exnteding compose files](#extending-compose-files) + +### 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 applications that reuse commonly-defined services. -Using `extends` you can define a service in one place and refer to it from -anywhere. +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. -Alternatively, you can deploy the same application to multiple environments with -a slightly different set of services in each case (or with changes to the -configuration of some services). Moreover, you can do so without copy-pasting -the configuration around. +> **Note:** `links` and `volumes_from` are never shared between services using +> `extends`. See +> [Adding and overriding configuration](#adding-and-overriding-configuration) +> for more information. -### Understand the extends configuration +#### Understand the extends configuration When defining any service in `docker-compose.yml`, you can declare that you are extending another service like this: @@ -77,183 +86,46 @@ You can also write other services and link your `web` service to them: db: image: postgres -For full details on how to use `extends`, refer to the [reference](#reference). - -### Example use case - -In this example, you’ll repurpose the example app from the [quick start -guide](/). (If you're not familiar with Compose, it's recommended that -you go through the quick start first.) This example assumes you want to use -Compose both to develop an application locally and then deploy it to a -production environment. - -The local and production environments are similar, but there are some -differences. In development, you mount the application code as a volume so that -it can pick up changes; in production, the code should be immutable from the -outside. This ensures it’s not accidentally changed. The development environment -uses a local Redis container, but in production another team manages the Redis -service, which is listening at `redis-production.example.com`. - -To configure with `extends` for this sample, you must: - -1. Define the web application as a Docker image in `Dockerfile` and a Compose - service in `common.yml`. - -2. Define the development environment in the standard Compose file, - `docker-compose.yml`. - - - Use `extends` to pull in the web service. - - Configure a volume to enable code reloading. - - Create an additional Redis service for the application to use locally. - -3. Define the production environment in a third Compose file, `production.yml`. - - - Use `extends` to pull in the web service. - - Configure the web service to talk to the external, production Redis service. - -#### Define the web app - -Defining the web application requires the following: - -1. Create an `app.py` file. - - This file contains a simple Python application that uses Flask to serve HTTP - and increments a counter in Redis: - - from flask import Flask - from redis import Redis - import os - - app = Flask(__name__) - redis = Redis(host=os.environ['REDIS_HOST'], port=6379) - - @app.route('/') - def hello(): - redis.incr('hits') - return 'Hello World! I have been seen %s times.\n' % redis.get('hits') - - if __name__ == "__main__": - app.run(host="0.0.0.0", debug=True) - - This code uses a `REDIS_HOST` environment variable to determine where to - find Redis. - -2. Define the Python dependencies in a `requirements.txt` file: - - flask - redis - -3. Create a `Dockerfile` to build an image containing the app: - - FROM python:2.7 - ADD . /code - WORKDIR /code - RUN pip install -r requirements.txt - CMD python app.py - -4. Create a Compose configuration file called `common.yml`: - - This configuration defines how to run the app. - - web: - build: . - ports: - - "5000:5000" - - Typically, you would have dropped this configuration into - `docker-compose.yml` file, but in order to pull it into multiple files with - `extends`, it needs to be in a separate file. - -#### Define the development environment - -1. Create a `docker-compose.yml` file. - - The `extends` option pulls in the `web` service from the `common.yml` file - you created in the previous section. +#### Example use case - web: - extends: - file: common.yml - service: web - volumes: - - .:/code - links: - - redis - environment: - - REDIS_HOST=redis - redis: - image: redis +Extending an individual service is useful when you have multiple services that +have a common configuration. In this example we have a composition that with +a web application and a queue worker. Both services use the same codebase and +share many configuration options. - The new addition defines a `web` service that: +In a **common.yml** we'll define the common configuration: - - Fetches the base configuration for `web` out of `common.yml`. - - Adds `volumes` and `links` configuration to the base (`common.yml`) - configuration. - - Sets the `REDIS_HOST` environment variable to point to the linked redis - container. This environment uses a stock `redis` image from the Docker Hub. - -2. Run `docker-compose up`. - - Compose creates, links, and starts a web and redis container linked together. - It mounts your application code inside the web container. - -3. Verify that the code is mounted by changing the message in - `app.py`—say, from `Hello world!` to `Hello from Compose!`. - - Don't forget to refresh your browser to see the change! - -#### Define the production environment - -You are almost done. Now, define your production environment: - -1. Create a `production.yml` file. - - As with `docker-compose.yml`, the `extends` option pulls in the `web` service - from `common.yml`. - - web: - extends: - file: common.yml - service: web - environment: - - REDIS_HOST=redis-production.example.com - -2. Run `docker-compose -f production.yml up`. - - Compose creates *just* a web container and configures the Redis connection via - the `REDIS_HOST` environment variable. This variable points to the production - Redis instance. - - > **Note**: If you try to load up the webapp in your browser you'll get an - > error—`redis-production.example.com` isn't actually a Redis server. - -You've now done a basic `extends` configuration. As your application develops, -you can make any necessary changes to the web service in `common.yml`. Compose -picks up both the development and production environments when you next run -`docker-compose`. You don't have to do any copy-and-paste, and you don't have to -manually keep both environments in sync. - - -### Reference + app: + build: . + environment: + CONFIG_FILE_PATH: /code/config + API_KEY: xxxyyy + cpu_shares: 5 -You can use `extends` on any service together with other configuration keys. It -expects a dictionary that contains a `service` key and optionally a `file` key. -The `extends` key can also take a string, whose value is the name of a `service` defined in the same file. +In a **docker-compose.yml** we'll define the concrete services which use the +common configuration: -The `file` key specifies the location of a Compose configuration file defining -the extension. The `file` value can be an absolute or relative path. If you -specify a relative path, Docker Compose treats it as relative to the location -of the current file. If you don't specify a `file`, Compose looks in the -current configuration file. -The `service` key specifies the name of the service to extend, for example `web` -or `database`. + webapp: + extends: + file: common.yml + service: app + command: /code/run_web_app + ports: + - 8080:8080 + links: + - queue + - db -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 them. + queue_worker: + extends: + file: common.yml + service: app + command: /code/run_worker + links: + - queue -#### Adding and overriding 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 @@ -282,6 +154,8 @@ listed below.** 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: . @@ -291,6 +165,9 @@ Compose to discard the other, if it was defined in the original service. # result image: redis + +Example of build replacing image: + # original service image: redis @@ -356,6 +233,13 @@ locally-defined bindings taking precedence: - /local-dir/bar:/bar - /local-dir/baz/:baz + +### Extending Compose files + +> **Note:** This feature is new in `docker-compose` 1.5 + + + ## Compose documentation - [User guide](/) From 887c6753f800f1b11a49f17fe42559cf95c6ae61 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 29 Oct 2015 15:21:06 -0400 Subject: [PATCH 1454/4072] Support a volume to the docs directory and add --watch, so docs can be refreshed. Signed-off-by: Daniel Nephin --- docs/Makefile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/Makefile b/docs/Makefile index 021e8f6e5ea..b9ef0548287 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -13,8 +13,8 @@ DOCKER_ENVS := \ -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 -# to allow `make DOCSDIR=docs docs-shell` (to create a bind mount in docs) -DOCS_MOUNT := $(if $(DOCSDIR),-v $(CURDIR)/$(DOCSDIR):/$(DOCSDIR)) +# 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 @@ -37,7 +37,7 @@ 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) + $(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) From dbd6c62b70c451897178f6e56392947acafebea3 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 21 Oct 2015 17:17:38 -0400 Subject: [PATCH 1455/4072] Changes to production.md for working with multiple Compose files. Signed-off-by: Daniel Nephin --- docs/production.md | 30 +++++++++++------------------- 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/docs/production.md b/docs/production.md index 0b0e46c3f00..39f0e1fe197 100644 --- a/docs/production.md +++ b/docs/production.md @@ -12,11 +12,9 @@ weight=1 ## Using Compose in production -While **Compose is not yet considered production-ready**, if you'd like to experiment and learn more about using it in production deployments, this guide -can help. -The project is actively working towards becoming -production-ready; to learn more about the progress being made, check out the roadmap for details -on how it's coming along and what still needs to be done. +> 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 deploying to production, you'll almost certainly want to make changes to your app configuration that are more appropriate to a live environment. These @@ -30,22 +28,16 @@ changes may include: - 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 a separate Compose file, say -`production.yml`, which specifies production-appropriate configuration. +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. -> **Note:** The [extends](extends.md) keyword is useful for maintaining multiple -> Compose files which re-use common services without having to manually copy and -> paste. +Once you've got a second configuration file, tell Compose to use it with the +`-f` option: -Once you've got an alternate configuration file, make Compose use it -by setting the `COMPOSE_FILE` environment variable: - - $ export COMPOSE_FILE=production.yml - $ docker-compose up -d - -> **Note:** You can also use the file for a one-off command without setting -> an environment variable. You do this by passing the `-f` flag, e.g., -> `docker-compose -f production.yml up -d`. + $ docker-compose -f docker-compose.yml -f production.yml up -d ### Deploying changes From 58de4e0c267ebf34d053bf30e68acd1600544b68 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 9 Oct 2015 12:56:59 -0400 Subject: [PATCH 1456/4072] Document using multiple Compose files use cases. Signed-off-by: Daniel Nephin --- docs/compose-file.md | 18 ++-- docs/extends.md | 208 ++++++++++++++++++++++++++++++++++--------- docs/production.md | 3 + 3 files changed, 176 insertions(+), 53 deletions(-) diff --git a/docs/compose-file.md b/docs/compose-file.md index 33e7d2b53c6..034653efe8a 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -167,21 +167,21 @@ 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 value must be a dictionary with the key: `service` and may optionally have -the `file` key. +The `extends` value must be a dictionary defined with a required `service` +and an optional `file` key. extends: file: common.yml service: webapp -The `file` key specifies the location of a Compose configuration file defining -the service which is being extended. The `file` value can be an absolute or -relative path. If you specify a relative path, Docker Compose treats it as -relative to the location of the current file. If you don't specify a `file`, -Compose looks in the current configuration file. +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. -The `service` key specifies the name of the service to extend, for example `web` -or `database`. +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` diff --git a/docs/extends.md b/docs/extends.md index c97b2b4fab0..58def22d7f4 100644 --- a/docs/extends.md +++ b/docs/extends.md @@ -10,16 +10,15 @@ weight=2 -## Extending services and Compose files +# Extending services and Compose files -Compose supports two ways to sharing common configuration and -extend a service with that shared configuration. +Compose supports two methods of sharing common configuration: 1. Extending individual services with [the `extends` field](#extending-services) 2. Extending entire compositions by - [exnteding compose files](#extending-compose-files) + [using multiple compose files](#multiple-compose-files) -### Extending services +## Extending services Docker Compose's `extends` keyword enables sharing of common configurations among different files, or even different projects entirely. Extending services @@ -30,9 +29,9 @@ 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. + > for more information. -#### Understand the extends configuration +### Understand the extends configuration When defining any service in `docker-compose.yml`, you can declare that you are extending another service like this: @@ -54,8 +53,8 @@ looks like this: - "/data" In this case, you'll get exactly the same result as if you wrote -`docker-compose.yml` with that `build`, `ports` and `volumes` configuration -defined directly under `web`. +`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`: @@ -86,14 +85,14 @@ You can also write other services and link your `web` service to them: db: image: postgres -#### Example use case +### Example use case Extending an individual service is useful when you have multiple services that -have a common configuration. In this example we have a composition that with -a web application and a queue worker. Both services use the same codebase and -share many configuration options. +have a common configuration. The example below is a composition 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'll define the common configuration: +In a **common.yml** we define the common configuration: app: build: . @@ -102,10 +101,9 @@ In a **common.yml** we'll define the common configuration: API_KEY: xxxyyy cpu_shares: 5 -In a **docker-compose.yml** we'll define the concrete services which use the +In a **docker-compose.yml** we define the concrete services which use the common configuration: - webapp: extends: file: common.yml @@ -121,11 +119,11 @@ common configuration: extends: file: common.yml service: app - command: /code/run_worker - links: - - queue + command: /code/run_worker + links: + - queue -#### Adding and overriding 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 @@ -134,13 +132,11 @@ 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. -If a configuration option is defined in both the original service and the local -service, the local value either *override*s or *extend*s the definition of the -original service. This works differently for other configuration options. +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. **This is the default behaviour - all exceptions are -listed below.** +replaces the old value. # original service command: python app.py @@ -195,8 +191,8 @@ For the **multi-value options** `ports`, `expose`, `external_links`, `dns` and - "4000" - "5000" -In the case of `environment` and `labels`, Compose "merges" entries together -with locally-defined values taking precedence: +In the case of `environment`, `labels`, `volumes` and `devices`, Compose +"merges" entries together with locally-defined values taking precedence: # original service environment: @@ -214,30 +210,154 @@ with locally-defined values taking precedence: - BAR=local - BAZ=local -Finally, for `volumes` and `devices`, Compose "merges" entries together with -locally-defined bindings taking precedence: - # original service - volumes: - - /original-dir/foo:/foo - - /original-dir/bar:/bar +## Multiple Compose files - # local service - volumes: - - /local-dir/bar:/bar - - /local-dir/baz/:baz +Using multiple Compose files enables you to customize a composition for +different environments or different workflows. - # result - volumes: - - /original-dir/foo:/foo - - /local-dir/bar:/bar - - /local-dir/baz/:baz +### 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 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. + +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 +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 +composition for different environments, and running administrative tasks +against a composition. + +#### Different environments + +A common use case for multiple files is changing a development composition +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 composition 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 -### Extending Compose files +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 composition. 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 -> **Note:** This feature is new in `docker-compose` 1.5 +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 ## Compose documentation diff --git a/docs/production.md b/docs/production.md index 39f0e1fe197..0a5e77b5226 100644 --- a/docs/production.md +++ b/docs/production.md @@ -39,6 +39,9 @@ Once you've got a second configuration file, tell Compose to use it with the $ 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 From 62ebdce5a90ced63c27affcdf286189c11ea7885 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 2 Nov 2015 15:14:42 -0500 Subject: [PATCH 1457/4072] Replace composition with Compose app. Signed-off-by: Daniel Nephin --- docs/extends.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/extends.md b/docs/extends.md index 58def22d7f4..e4d09af98de 100644 --- a/docs/extends.md +++ b/docs/extends.md @@ -15,8 +15,8 @@ weight=2 Compose supports two methods of sharing common configuration: 1. Extending individual services with [the `extends` field](#extending-services) -2. Extending entire compositions by - [using multiple compose files](#multiple-compose-files) +2. Extending entire Compose file by + [using multiple Compose files](#multiple-compose-files) ## Extending services @@ -88,7 +88,7 @@ You can also write other services and link your `web` service to them: ### Example use case Extending an individual service is useful when you have multiple services that -have a common configuration. The example below is a composition with +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. @@ -213,8 +213,8 @@ In the case of `environment`, `labels`, `volumes` and `devices`, Compose ## Multiple Compose files -Using multiple Compose files enables you to customize a composition for -different environments or different workflows. +Using multiple Compose files enables you to customize a Compose application +for different environments or different workflows. ### Understanding multiple Compose files @@ -248,12 +248,12 @@ relative to the base file. ### Example use case In this section are two common use cases for multiple compose files: changing a -composition for different environments, and running administrative tasks -against a composition. +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 composition +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: @@ -301,7 +301,7 @@ host, mounts our code as a volume, and builds the web image. When you run `docker-compose up` it reads the overrides automatically. -Now, it would be nice to use this composition in a production environment. So, +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). @@ -332,7 +332,7 @@ production. #### Administrative tasks Another common use case is running adhoc or administrative tasks against one -or more services in a composition. This example demonstrates running a +or more services in a Compose app. This example demonstrates running a database backup. Start with a **docker-compose.yml**. From 40341674bd406c74eab83099cdc50684da3f45d4 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 3 Nov 2015 11:52:44 -0500 Subject: [PATCH 1458/4072] Re-order extends docs. Signed-off-by: Daniel Nephin --- docs/extends.md | 303 ++++++++++++++++++++++++------------------------ 1 file changed, 153 insertions(+), 150 deletions(-) diff --git a/docs/extends.md b/docs/extends.md index e4d09af98de..b21b6d76db8 100644 --- a/docs/extends.md +++ b/docs/extends.md @@ -14,9 +14,159 @@ weight=2 Compose supports two methods of sharing common configuration: -1. Extending individual services with [the `extends` field](#extending-services) -2. Extending entire Compose file by +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 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. + +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 +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 @@ -123,7 +273,7 @@ common configuration: links: - queue -### Adding and overriding 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 @@ -211,153 +361,6 @@ In the case of `environment`, `labels`, `volumes` and `devices`, Compose - BAZ=local -## 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 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. - -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 -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 ## Compose documentation From 77ff37a853b36e83de377cc83e7ff59eee11fc9c Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 3 Nov 2015 11:30:34 -0500 Subject: [PATCH 1459/4072] Bump 1.5.0 Signed-off-by: Daniel Nephin --- CHANGELOG.md | 2 +- compose/__init__.py | 2 +- docs/install.md | 4 ++-- script/run.sh | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b0474ae2b3a..a123c2a44d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ Change log ========== -1.5.0 (2015-11-02) +1.5.0 (2015-11-03) ------------------ **Breaking changes:** diff --git a/compose/__init__.py b/compose/__init__.py index 7199babb40d..2b8d5e72b27 100644 --- a/compose/__init__.py +++ b/compose/__init__.py @@ -1,3 +1,3 @@ from __future__ import unicode_literals -__version__ = '1.5.0rc3' +__version__ = '1.5.0' diff --git a/docs/install.md b/docs/install.md index 4eb0dc18691..c5304409c55 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.0rc3 + docker-compose version: 1.5.0 ## Alternative install options @@ -76,7 +76,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.0rc3/run.sh > /usr/local/bin/docker-compose + $ curl -L https://github.com/docker/compose/releases/download/1.5.0/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 9ed1ea74cf6..cf46c143c38 100755 --- a/script/run.sh +++ b/script/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.5.0rc3" +VERSION="1.5.0" IMAGE="docker/compose:$VERSION" From 385b4280a1f6d2b36dccb43a0f99858693ff673f Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 4 Nov 2015 15:33:37 -0500 Subject: [PATCH 1460/4072] Fix jenkins CI by using an older docker version to match the host. Signed-off-by: Daniel Nephin --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index b28a438dca8..acf9b6aebfa 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,7 +16,7 @@ RUN set -ex; \ ; \ rm -rf /var/lib/apt/lists/* -RUN curl https://get.docker.com/builds/Linux/x86_64/docker-latest \ +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 From 6b002fb9225907f1c9b48523879b1263b607bdeb Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 3 Nov 2015 11:30:34 -0500 Subject: [PATCH 1461/4072] Cherry-pick release notes froim 1.5.0 And bump version to 1.6.0dev Signed-off-by: Daniel Nephin --- CHANGELOG.md | 101 ++++++++++++++++++++++++++++++++++++++++++++ compose/__init__.py | 2 +- docs/install.md | 2 +- 3 files changed, 103 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 598f5e57943..dde425421b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,107 @@ Change log ========== +1.5.0 (2015-11-03) +------------------ + +**Breaking changes:** + +With the introduction of variable substitution support in the Compose file, any +Compose file that uses an environment variable (`$VAR` or `${VAR}`) in the `command:` +or `entrypoint:` field will break. + +Previously these values were interpolated inside the container, with a value +from the container environment. In Compose 1.5.0, the values will be +interpolated on the host, with a value from the host environment. + +To migrate a Compose file to 1.5.0, escape the variables with an extra `$` +(ex: `$$VAR` or `$${VAR}`). See +https://github.com/docker/compose/blob/8cc8e61/docs/compose-file.md#variable-substitution + +Major features: + +- Compose is now available for Windows. + +- Environment variables can be used in the Compose file. See + https://github.com/docker/compose/blob/8cc8e61/docs/compose-file.md#variable-substitution + +- Multiple compose files can be specified, allowing you to override + settings in the default Compose file. See + https://github.com/docker/compose/blob/8cc8e61/docs/reference/docker-compose.md + for more details. + +- Compose now produces better error messages when a file contains + invalid configuration. + +- `up` now waits for all services to exit before shutting down, + rather than shutting down as soon as one container exits. + +- Experimental support for the new docker networking system can be + enabled with the `--x-networking` flag. Read more here: + https://github.com/docker/docker/blob/8fee1c20/docs/userguide/dockernetworks.md + +New features: + +- You can now optionally pass a mode to `volumes_from`, e.g. + `volumes_from: ["servicename:ro"]`. + +- Since Docker now lets you create volumes with names, you can refer to those + volumes by name in `docker-compose.yml`. For example, + `volumes: ["mydatavolume:/data"]` will mount the volume named + `mydatavolume` at the path `/data` inside the container. + + If the first component of an entry in `volumes` starts with a `.`, `/` or + `~`, it is treated as a path and expansion of relative paths is performed as + necessary. Otherwise, it is treated as a volume name and passed straight + through to Docker. + + Read more on named volumes and volume drivers here: + https://github.com/docker/docker/blob/244d9c33/docs/userguide/dockervolumes.md + +- `docker-compose build --pull` instructs Compose to pull the base image for + each Dockerfile before building. + +- `docker-compose pull --ignore-pull-failures` instructs Compose to continue + if it fails to pull a single service's image, rather than aborting. + +- You can now specify an IPC namespace in `docker-compose.yml` with the `ipc` + option. + +- Containers created by `docker-compose run` can now be named with the + `--name` flag. + +- If you install Compose with pip or use it as a library, it now works with + Python 3. + +- `image` now supports image digests (in addition to ids and tags), e.g. + `image: "busybox@sha256:38a203e1986cf79639cfb9b2e1d6e773de84002feea2d4eb006b52004ee8502d"` + +- `ports` now supports ranges of ports, e.g. + + ports: + - "3000-3005" + - "9000-9001:8000-8001" + +- `docker-compose run` now supports a `-p|--publish` parameter, much like + `docker run -p`, for publishing specific ports to the host. + +- `docker-compose pause` and `docker-compose unpause` have been implemented, + analogous to `docker pause` and `docker unpause`. + +- When using `extends` to copy configuration from another service in the same + Compose file, you can omit the `file` option. + +- Compose can be installed and run as a Docker image. This is an experimental + feature. + +Bug fixes: + +- All values for the `log_driver` option which are supported by the Docker + daemon are now supported by Compose. + +- `docker-compose build` can now be run successfully against a Swarm cluster. + + 1.4.2 (2015-09-22) ------------------ diff --git a/compose/__init__.py b/compose/__init__.py index e3ace983566..7c16c97ba45 100644 --- a/compose/__init__.py +++ b/compose/__init__.py @@ -1,3 +1,3 @@ from __future__ import unicode_literals -__version__ = '1.5.0dev' +__version__ = '1.6.0dev' diff --git a/docs/install.md b/docs/install.md index e19bda0f3b7..c5304409c55 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.4.2 + docker-compose version: 1.5.0 ## Alternative install options From d18ad4c81221e13346b8840a35a3defb71d86378 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 3 Nov 2015 16:58:30 -0500 Subject: [PATCH 1462/4072] Fix rebase-bump-commit script when used with a final release. Previously it would find commits for RC releases, which broke the rebase. Signed-off-by: Daniel Nephin --- 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 14ad22a9821..23877bb5658 100755 --- a/script/release/rebase-bump-commit +++ b/script/release/rebase-bump-commit @@ -22,7 +22,7 @@ VERSION="$(git config "branch.${BRANCH}.release")" || usage COMMIT_MSG="Bump $VERSION" -sha="$(git log --grep "$COMMIT_MSG" --format="%H")" +sha="$(git log --grep "$COMMIT_MSG\$" --format="%H")" if [ -z "$sha" ]; then >&2 echo "No commit with message \"$COMMIT_MSG\"" exit 2 From 0227b3adbdec8595d013ce70364dff08a691ef4e Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 5 Nov 2015 10:26:54 -0500 Subject: [PATCH 1463/4072] Upgrade pyyaml to 3.11 Signed-off-by: Daniel Nephin --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index daaaa950262..60327d728de 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -PyYAML==3.10 +PyYAML==3.11 docker-py==1.5.0 dockerpty==0.3.4 docopt==0.6.1 From ce322047a052d96cf9a6f1dd65df66385b215e50 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 28 Oct 2015 13:16:19 -0400 Subject: [PATCH 1464/4072] Move config hash tests to service_test.py Signed-off-by: Daniel Nephin --- tests/integration/service_test.py | 37 +++++++++++++++++++++++++++++++ tests/integration/state_test.py | 36 ------------------------------ 2 files changed, 37 insertions(+), 36 deletions(-) diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index f083908b209..804f5219af5 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -14,6 +14,7 @@ from .testcases import DockerClientTestCase from .testcases import pull_busybox from compose import __version__ +from compose.const import LABEL_CONFIG_HASH from compose.const import LABEL_CONTAINER_NUMBER from compose.const import LABEL_ONE_OFF from compose.const import LABEL_PROJECT @@ -23,6 +24,7 @@ from compose.service import build_extra_hosts from compose.service import ConfigError from compose.service import ConvergencePlan +from compose.service import ConvergenceStrategy from compose.service import Net from compose.service import Service from compose.service import VolumeFromSpec @@ -930,3 +932,38 @@ def test_duplicate_containers(self): self.assertEqual(set(service.containers(stopped=True)), set([original, duplicate])) self.assertEqual(set(service.duplicate_containers()), set([duplicate])) + + +def converge(service, + strategy=ConvergenceStrategy.changed, + do_build=True): + """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) + + +class ConfigHashTest(DockerClientTestCase): + def test_no_config_hash_when_one_off(self): + web = self.create_service('web') + container = web.create_container(one_off=True) + self.assertNotIn(LABEL_CONFIG_HASH, container.labels) + + def test_no_config_hash_when_overriding_options(self): + web = self.create_service('web') + container = web.create_container(environment={'FOO': '1'}) + self.assertNotIn(LABEL_CONFIG_HASH, container.labels) + + def test_config_hash_with_custom_labels(self): + web = self.create_service('web', labels={'foo': '1'}) + container = converge(web)[0] + self.assertIn(LABEL_CONFIG_HASH, container.labels) + self.assertIn('foo', container.labels) + + def test_config_hash_sticks_around(self): + web = self.create_service('web', command=["top"]) + container = converge(web)[0] + self.assertIn(LABEL_CONFIG_HASH, container.labels) + + web = self.create_service('web', command=["top", "-d", "1"]) + container = converge(web)[0] + self.assertIn(LABEL_CONFIG_HASH, container.labels) diff --git a/tests/integration/state_test.py b/tests/integration/state_test.py index 3230aefc61a..cb9045726fc 100644 --- a/tests/integration/state_test.py +++ b/tests/integration/state_test.py @@ -10,7 +10,6 @@ from .testcases import DockerClientTestCase from compose.config import config -from compose.const import LABEL_CONFIG_HASH from compose.project import Project from compose.service import ConvergenceStrategy @@ -180,14 +179,6 @@ def test_service_removed_while_down(self): self.assertEqual(len(containers), 2) -def converge(service, - strategy=ConvergenceStrategy.changed, - do_build=True): - """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) - - class ServiceStateTest(DockerClientTestCase): """Test cases for Service.convergence_plan.""" @@ -278,30 +269,3 @@ def test_trigger_recreate_with_build(self): self.assertEqual(('recreate', [container]), web.convergence_plan()) finally: shutil.rmtree(context) - - -class ConfigHashTest(DockerClientTestCase): - def test_no_config_hash_when_one_off(self): - web = self.create_service('web') - container = web.create_container(one_off=True) - self.assertNotIn(LABEL_CONFIG_HASH, container.labels) - - def test_no_config_hash_when_overriding_options(self): - web = self.create_service('web') - container = web.create_container(environment={'FOO': '1'}) - self.assertNotIn(LABEL_CONFIG_HASH, container.labels) - - def test_config_hash_with_custom_labels(self): - web = self.create_service('web', labels={'foo': '1'}) - container = converge(web)[0] - self.assertIn(LABEL_CONFIG_HASH, container.labels) - self.assertIn('foo', container.labels) - - def test_config_hash_sticks_around(self): - web = self.create_service('web', command=["top"]) - container = converge(web)[0] - self.assertIn(LABEL_CONFIG_HASH, container.labels) - - web = self.create_service('web', command=["top", "-d", "1"]) - container = converge(web)[0] - self.assertIn(LABEL_CONFIG_HASH, container.labels) From 8ff960afd13b71bbe84fb7bef2120cc8f958de93 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 3 Nov 2015 20:00:54 -0500 Subject: [PATCH 1465/4072] Fix service recreate when image changes to build. Signed-off-by: Daniel Nephin --- compose/service.py | 13 ++++--- tests/integration/state_test.py | 65 ++++++++++++++++++-------------- tests/unit/config/config_test.py | 2 + 3 files changed, 46 insertions(+), 34 deletions(-) diff --git a/compose/service.py b/compose/service.py index e121ee953dc..e5a4cc4a0d4 100644 --- a/compose/service.py +++ b/compose/service.py @@ -414,6 +414,7 @@ def execute_convergence_plan(self, return [ self.recreate_container( container, + do_build=do_build, timeout=timeout, attach_logs=should_attach_logs ) @@ -435,10 +436,12 @@ def execute_convergence_plan(self, else: raise Exception("Invalid action: {}".format(action)) - def recreate_container(self, - container, - timeout=DEFAULT_TIMEOUT, - attach_logs=False): + def recreate_container( + self, + container, + do_build=False, + timeout=DEFAULT_TIMEOUT, + attach_logs=False): """Recreate a container. The original container is renamed to a temporary name so that data @@ -450,7 +453,7 @@ def recreate_container(self, container.stop(timeout=timeout) container.rename_to_tmp_name() new_container = self.create_container( - do_build=False, + do_build=do_build, previous_container=container, number=container.labels.get(LABEL_CONTAINER_NUMBER), quiet=True, diff --git a/tests/integration/state_test.py b/tests/integration/state_test.py index cb9045726fc..7830ba32cf1 100644 --- a/tests/integration/state_test.py +++ b/tests/integration/state_test.py @@ -4,9 +4,7 @@ """ from __future__ import unicode_literals -import os -import shutil -import tempfile +import py from .testcases import DockerClientTestCase from compose.config import config @@ -232,40 +230,49 @@ def test_trigger_recreate_with_image_change(self): image_id = self.client.images(name='busybox')[0]['Id'] self.client.tag(image_id, repository=repo, tag=tag) + self.addCleanup(self.client.remove_image, image) - try: - web = self.create_service('web', image=image) - container = web.create_container() - - # update the image - c = self.client.create_container(image, ['touch', '/hello.txt']) - self.client.commit(c, repository=repo, tag=tag) - self.client.remove_container(c) + web = self.create_service('web', image=image) + container = web.create_container() - web = self.create_service('web', image=image) - self.assertEqual(('recreate', [container]), web.convergence_plan()) + # update the image + c = self.client.create_container(image, ['touch', '/hello.txt']) + self.client.commit(c, repository=repo, tag=tag) + self.client.remove_container(c) - finally: - self.client.remove_image(image) + web = self.create_service('web', image=image) + self.assertEqual(('recreate', [container]), web.convergence_plan()) def test_trigger_recreate_with_build(self): - context = tempfile.mkdtemp() + context = py.test.ensuretemp('test_trigger_recreate_with_build') + self.addCleanup(context.remove) + base_image = "FROM busybox\nLABEL com.docker.compose.test_image=true\n" + dockerfile = context.join('Dockerfile') + dockerfile.write(base_image) - try: - dockerfile = os.path.join(context, 'Dockerfile') + web = self.create_service('web', build=str(context)) + container = web.create_container() + + dockerfile.write(base_image + 'CMD echo hello world\n') + web.build() - with open(dockerfile, 'w') as f: - f.write(base_image) + web = self.create_service('web', build=str(context)) + self.assertEqual(('recreate', [container]), web.convergence_plan()) - web = self.create_service('web', build=context) - container = web.create_container() + def test_image_changed_to_build(self): + context = py.test.ensuretemp('test_image_changed_to_build') + self.addCleanup(context.remove) + context.join('Dockerfile').write(""" + FROM busybox + LABEL com.docker.compose.test_image=true + """) - with open(dockerfile, 'w') as f: - f.write(base_image + 'CMD echo hello world\n') - web.build() + web = self.create_service('web', image='busybox') + container = web.create_container() - web = self.create_service('web', build=context) - self.assertEqual(('recreate', [container]), web.convergence_plan()) - finally: - shutil.rmtree(context) + web = self.create_service('web', build=str(context)) + plan = web.convergence_plan() + self.assertEqual(('recreate', [container]), plan) + containers = web.execute_convergence_plan(plan) + self.assertEqual(len(containers), 1) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 7246b661874..69b2358525f 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -177,6 +177,7 @@ def test_load_with_multiple_files_and_extends_in_override_file(self): details = config.ConfigDetails('.', [base_file, override_file]) tmpdir = py.test.ensuretemp('config_test') + self.addCleanup(tmpdir.remove) tmpdir.join('common.yml').write(""" base: labels: ['label=one'] @@ -412,6 +413,7 @@ def test_config_invalid_environment_dict_key_raises_validation_error(self): def test_load_yaml_with_yaml_error(self): tmpdir = py.test.ensuretemp('invalid_yaml_test') + self.addCleanup(tmpdir.remove) invalid_yaml_file = tmpdir.join('docker-compose.yml') invalid_yaml_file.write(""" web: From 26c7dd37126cca09b149d3de7f134d5c8a766fdd Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 4 Nov 2015 15:54:59 -0500 Subject: [PATCH 1466/4072] Handle non-utf8 unicode without raising an error. Signed-off-by: Daniel Nephin --- compose/config/config.py | 2 +- compose/utils.py | 2 +- tests/unit/utils_test.py | 16 ++++++++++++++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 5bc534fe9b2..ff3ae780c66 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -457,7 +457,7 @@ def parse_environment(environment): def split_env(env): if isinstance(env, six.binary_type): - env = env.decode('utf-8') + env = env.decode('utf-8', 'replace') if '=' in env: return env.split('=', 1) else: diff --git a/compose/utils.py b/compose/utils.py index c8fddc5f162..08f6034fcd4 100644 --- a/compose/utils.py +++ b/compose/utils.py @@ -95,7 +95,7 @@ def stream_as_text(stream): """ for data in stream: if not isinstance(data, six.text_type): - data = data.decode('utf-8') + data = data.decode('utf-8', 'replace') yield data diff --git a/tests/unit/utils_test.py b/tests/unit/utils_test.py index b272c7349a8..e3d0bc00b5e 100644 --- a/tests/unit/utils_test.py +++ b/tests/unit/utils_test.py @@ -1,3 +1,6 @@ +# encoding: utf-8 +from __future__ import unicode_literals + from .. import unittest from compose import utils @@ -14,3 +17,16 @@ def test_json_splitter_with_object(self): utils.json_splitter(data), ({'foo': 'bar'}, '{"next": "obj"}') ) + + +class StreamAsTextTestCase(unittest.TestCase): + + def test_stream_with_non_utf_unicode_character(self): + stream = [b'\xed\xf3\xf3'] + output, = utils.stream_as_text(stream) + assert output == '���' + + def test_stream_with_utf_character(self): + stream = ['ěĝ'.encode('utf-8')] + output, = utils.stream_as_text(stream) + assert output == 'ěĝ' From d32bb8efeea1e265d54f7cd7415db202d854f1f2 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 5 Nov 2015 15:33:42 -0500 Subject: [PATCH 1467/4072] Fix #1549 - flush after each line of logs. Includes some refactoring of log_printer_test to support checking for flush(), and so that each test calls the unit-under-test directly, instead of through a helper function. Signed-off-by: Daniel Nephin --- compose/cli/log_printer.py | 1 + tests/unit/cli/log_printer_test.py | 82 +++++++++++++++--------------- 2 files changed, 42 insertions(+), 41 deletions(-) diff --git a/compose/cli/log_printer.py b/compose/cli/log_printer.py index 66920726ce2..864657a4c68 100644 --- a/compose/cli/log_printer.py +++ b/compose/cli/log_printer.py @@ -26,6 +26,7 @@ def run(self): generators = list(self._make_log_generators(self.monochrome, prefix_width)) for line in Multiplexer(generators).loop(): self.output.write(line) + self.output.flush() def _make_log_generators(self, monochrome, prefix_width): def no_color(text): diff --git a/tests/unit/cli/log_printer_test.py b/tests/unit/cli/log_printer_test.py index 575fcaf7b57..5b04226cf06 100644 --- a/tests/unit/cli/log_printer_test.py +++ b/tests/unit/cli/log_printer_test.py @@ -1,13 +1,13 @@ from __future__ import absolute_import from __future__ import unicode_literals -import mock +import pytest import six from compose.cli.log_printer import LogPrinter from compose.cli.log_printer import wait_on_exit from compose.container import Container -from tests import unittest +from tests import mock def build_mock_container(reader): @@ -22,40 +22,52 @@ def build_mock_container(reader): ) -class LogPrinterTest(unittest.TestCase): - def get_default_output(self, monochrome=False): - def reader(*args, **kwargs): - yield b"hello\nworld" - container = build_mock_container(reader) - output = run_log_printer([container], monochrome=monochrome) - return output +@pytest.fixture +def output_stream(): + output = six.StringIO() + output.flush = mock.Mock() + return output + + +@pytest.fixture +def mock_container(): + def reader(*args, **kwargs): + yield b"hello\nworld" + return build_mock_container(reader) + - def test_single_container(self): - output = self.get_default_output() +class TestLogPrinter(object): - self.assertIn('hello', output) - self.assertIn('world', output) + def test_single_container(self, output_stream, mock_container): + LogPrinter([mock_container], output=output_stream).run() - def test_monochrome(self): - output = self.get_default_output(monochrome=True) - self.assertNotIn('\033[', output) + 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): - output = self.get_default_output() - self.assertIn('\033[', output) + def test_monochrome(self, output_stream, mock_container): + LogPrinter([mock_container], output=output_stream, monochrome=True).run() + assert '\033[' not in output_stream.getvalue() - def test_unicode(self): + def test_polychrome(self, output_stream, mock_container): + LogPrinter([mock_container], output=output_stream).run() + assert '\033[' in output_stream.getvalue() + + def test_unicode(self, output_stream): glyph = u'\u2022' def reader(*args, **kwargs): yield glyph.encode('utf-8') + b'\n' container = build_mock_container(reader) - output = run_log_printer([container]) + LogPrinter([container], output=output_stream).run() + output = output_stream.getvalue() if six.PY2: output = output.decode('utf-8') - self.assertIn(glyph, output) + assert glyph in output def test_wait_on_exit(self): exit_status = 3 @@ -65,24 +77,12 @@ def test_wait_on_exit(self): wait=mock.Mock(return_value=exit_status)) expected = '{} exited with code {}\n'.format(mock_container.name, exit_status) - self.assertEqual(expected, wait_on_exit(mock_container)) - - def test_generator_with_no_logs(self): - mock_container = mock.Mock( - spec=Container, - has_api_logs=False, - log_driver='none', - name_without_project='web_1', - wait=mock.Mock(return_value=0)) - - output = run_log_printer([mock_container]) - self.assertIn( - "WARNING: no logs are available with the 'none' log driver\n", - output - ) + 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() -def run_log_printer(containers, monochrome=False): - output = six.StringIO() - LogPrinter(containers, output=output, monochrome=monochrome).run() - return output.getvalue() + output = output_stream.getvalue() + assert "WARNING: no logs are available with the 'none' log driver\n" in output From 3456002aef26fd025819349f29e46d0062f9040a Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 5 Nov 2015 17:50:32 -0500 Subject: [PATCH 1468/4072] Don't set the hostname to the service name with networking. Signed-off-by: Daniel Nephin --- compose/service.py | 3 --- tests/acceptance/cli_test.py | 1 - tests/unit/service_test.py | 10 ---------- 3 files changed, 14 deletions(-) diff --git a/compose/service.py b/compose/service.py index e5a4cc4a0d4..c17d4f9149e 100644 --- a/compose/service.py +++ b/compose/service.py @@ -599,9 +599,6 @@ def _get_container_create_options( container_options['hostname'] = parts[0] container_options['domainname'] = parts[2] - if 'hostname' not in container_options and self.use_networking: - container_options['hostname'] = self.name - if 'ports' in container_options or 'expose' in self.options: ports = [] all_ports = container_options.get('ports', []) + self.options.get('expose', []) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 41e9718be13..fc68f9d8517 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -205,7 +205,6 @@ def test_up_with_networking(self): containers = service.containers() self.assertEqual(len(containers), 1) self.assertIn(containers[0].id, network['Containers']) - self.assertEqual(containers[0].get('Config.Hostname'), service.name) web_container = self.project.get_service('web').containers()[0] self.assertFalse(web_container.get('HostConfig.Links')) diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 8c5c888fad7..bc0db6fb8d7 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -213,16 +213,6 @@ def test_no_default_hostname_when_not_using_networking(self): opts = service._get_container_create_options({'image': 'foo'}, 1) self.assertIsNone(opts.get('hostname')) - def test_hostname_defaults_to_service_name_when_using_networking(self): - service = Service( - 'foo', - image='foo', - use_networking=True, - client=self.mock_client, - ) - opts = service._get_container_create_options({'image': 'foo'}, 1) - self.assertEqual(opts['hostname'], 'foo') - def test_get_container_create_options_with_name_option(self): service = Service( 'foo', From 0e19c92e82c75f821c231367b5cda88eefdf1427 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 4 Nov 2015 16:37:44 -0500 Subject: [PATCH 1469/4072] Make working_dir consistent in the config package. - make it a positional arg, since it's required - make it the first argument for all functions that require it - remove an unnecessary one-line function that was only called in one place Signed-off-by: Daniel Nephin --- compose/config/config.py | 33 ++++++++++++-------------------- tests/unit/config/config_test.py | 2 +- 2 files changed, 13 insertions(+), 22 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 5bc534fe9b2..141fa89df4d 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -252,7 +252,7 @@ def make_service_dict(self): if not self.already_seen: validate_against_service_schema(service_dict, self.service_name) - return process_container_options(service_dict, working_dir=self.working_dir) + return process_container_options(self.working_dir, service_dict) def validate_and_construct_extends(self): extends = self.service_dict['extends'] @@ -321,7 +321,7 @@ def resolve_environment(working_dir, service_dict): env = {} if 'env_file' in service_dict: - for env_file in get_env_files(service_dict, working_dir=working_dir): + for env_file in get_env_files(working_dir, service_dict): env.update(env_vars_from_file(env_file)) env.update(parse_environment(service_dict.get('environment'))) @@ -345,14 +345,14 @@ def validate_extended_service_dict(service_dict, filename, service): "%s services with 'net: container' cannot be extended" % error_prefix) -def process_container_options(service_dict, working_dir=None): - service_dict = service_dict.copy() +def process_container_options(working_dir, service_dict): + service_dict = dict(service_dict) if 'volumes' in service_dict and service_dict.get('volume_driver') is None: - service_dict['volumes'] = resolve_volume_paths(service_dict, working_dir=working_dir) + service_dict['volumes'] = resolve_volume_paths(working_dir, service_dict) if 'build' in service_dict: - service_dict['build'] = resolve_build_path(service_dict['build'], working_dir=working_dir) + service_dict['build'] = expand_path(working_dir, service_dict['build']) if 'labels' in service_dict: service_dict['labels'] = parse_labels(service_dict['labels']) @@ -428,7 +428,7 @@ def merge_environment(base, override): return env -def get_env_files(options, working_dir=None): +def get_env_files(working_dir, options): if 'env_file' not in options: return {} @@ -488,17 +488,14 @@ def env_vars_from_file(filename): return env -def resolve_volume_paths(service_dict, working_dir=None): - if working_dir is None: - raise Exception("No working_dir passed to resolve_volume_paths()") - +def resolve_volume_paths(working_dir, service_dict): return [ - resolve_volume_path(v, working_dir, service_dict['name']) - for v in service_dict['volumes'] + resolve_volume_path(working_dir, volume, service_dict['name']) + for volume in service_dict['volumes'] ] -def resolve_volume_path(volume, working_dir, service_name): +def resolve_volume_path(working_dir, volume, service_name): container_path, host_path = split_path_mapping(volume) if host_path is not None: @@ -510,12 +507,6 @@ def resolve_volume_path(volume, working_dir, service_name): return container_path -def resolve_build_path(build_path, working_dir=None): - if working_dir is None: - raise Exception("No working_dir passed to resolve_build_path") - return expand_path(working_dir, build_path) - - def validate_paths(service_dict): if 'build' in service_dict: build_path = service_dict['build'] @@ -582,7 +573,7 @@ def parse_labels(labels): return dict(split_label(e) for e in labels) if isinstance(labels, dict): - return labels + return dict(labels) def split_label(label): diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 69b2358525f..e0d2e870b8e 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -577,7 +577,7 @@ def test_home_directory_with_driver_does_not_expand(self): def test_volume_path_with_non_ascii_directory(self): volume = u'/Füü/data:/data' - container_path = config.resolve_volume_path(volume, ".", "test") + container_path = config.resolve_volume_path(".", volume, "test") self.assertEqual(container_path, volume) From ec22d98377eb5c12ba3bf5fac0eb4bff379e3242 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 4 Nov 2015 16:46:19 -0500 Subject: [PATCH 1470/4072] Use VolumeSpec instead of re-parsing the volume string. Signed-off-by: Daniel Nephin --- compose/service.py | 19 +++++++++++-------- tests/unit/service_test.py | 4 ++-- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/compose/service.py b/compose/service.py index e5a4cc4a0d4..16e3fd00bdf 100644 --- a/compose/service.py +++ b/compose/service.py @@ -911,14 +911,15 @@ def merge_volume_bindings(volumes_option, previous_container): """Return a list of volume bindings for a container. Container data volumes are replaced by those from the previous container. """ + volumes = [parse_volume_spec(volume) for volume in volumes_option or []] volume_bindings = dict( - build_volume_binding(parse_volume_spec(volume)) - for volume in volumes_option or [] - if ':' in volume) + build_volume_binding(volume) + for volume in volumes + if volume.external) if previous_container: volume_bindings.update( - get_container_data_volumes(previous_container, volumes_option)) + get_container_data_volumes(previous_container, volumes)) return list(volume_bindings.values()) @@ -929,12 +930,14 @@ def get_container_data_volumes(container, volumes_option): """ volumes = [] - volumes_option = volumes_option or [] container_volumes = container.get('Volumes') or {} - image_volumes = container.image_config['ContainerConfig'].get('Volumes') or {} + image_volumes = [ + parse_volume_spec(volume) + for volume in + container.image_config['ContainerConfig'].get('Volumes') or {} + ] - for volume in set(volumes_option + list(image_volumes)): - volume = parse_volume_spec(volume) + for volume in set(volumes_option + image_volumes): # No need to preserve host volumes if volume.external: continue diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 8c5c888fad7..ed02bb4c54a 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -593,11 +593,11 @@ def test_build_volume_binding(self): self.assertEqual(binding, ('/inside', '/outside:/inside:rw')) def test_get_container_data_volumes(self): - options = [ + options = [parse_volume_spec(v) for v in [ '/host/volume:/host/volume:ro', '/new/volume', '/existing/volume', - ] + ]] self.mock_client.inspect_image.return_value = { 'ContainerConfig': { From 7c2a16234f333102dfc21c0597f58c030b4a222e Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 6 Nov 2015 12:29:52 -0500 Subject: [PATCH 1471/4072] Recreate dependents when a dependency is created (not just when it's recreated). Signed-off-by: Daniel Nephin --- compose/project.py | 2 +- tests/integration/state_test.py | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/compose/project.py b/compose/project.py index 1e01eaf6d24..f0478203bb1 100644 --- a/compose/project.py +++ b/compose/project.py @@ -322,7 +322,7 @@ def _get_convergence_plans(self, services, strategy): name for name in service.get_dependency_names() if name in plans - and plans[name].action == 'recreate' + and plans[name].action in ('recreate', 'create') ] if updated_dependencies and strategy.allows_recreate: diff --git a/tests/integration/state_test.py b/tests/integration/state_test.py index 7830ba32cf1..1fecce87b84 100644 --- a/tests/integration/state_test.py +++ b/tests/integration/state_test.py @@ -176,6 +176,19 @@ def test_service_removed_while_down(self): containers = self.run_up(next_cfg) self.assertEqual(len(containers), 2) + def test_service_recreated_when_dependency_created(self): + containers = self.run_up(self.cfg, service_names=['web'], start_deps=False) + self.assertEqual(len(containers), 1) + + containers = self.run_up(self.cfg) + self.assertEqual(len(containers), 3) + + web, = [c for c in containers if c.service == 'web'] + nginx, = [c for c in containers if c.service == 'nginx'] + + self.assertEqual(web.links(), ['composetest_db_1', 'db', 'db_1']) + self.assertEqual(nginx.links(), ['composetest_web_1', 'web', 'web_1']) + class ServiceStateTest(DockerClientTestCase): """Test cases for Service.convergence_plan.""" From 1bfb71032650b9a8b4184316af906df382807d9d Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 9 Nov 2015 17:24:21 +0000 Subject: [PATCH 1472/4072] Fix parallel output We were outputting an extra line, which in *some* cases, on *some* terminals, was causing the output of parallel actions to get messed up. In particular, it would happen when the terminal had just been cleared or hadn't yet filled up with a screen's worth of text. Signed-off-by: Aanand Prasad --- compose/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/utils.py b/compose/utils.py index c8fddc5f162..14cca61b0d3 100644 --- a/compose/utils.py +++ b/compose/utils.py @@ -164,7 +164,7 @@ def write_out_msg(stream, lines, msg_index, msg, status="done"): stream.write("%c[%dA" % (27, diff)) # erase stream.write("%c[2K\r" % 27) - stream.write("{} {} ... {}\n".format(msg, obj_index, status)) + stream.write("{} {} ... {}\r".format(msg, obj_index, status)) # move back down stream.write("%c[%dB" % (27, diff)) else: From a1e140f5a3376812d3d13b011ac93760dfd4f1c2 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 9 Nov 2015 13:07:26 -0800 Subject: [PATCH 1473/4072] Update service config_dict computation to include volumes_from mode Ensure config_hash is updated when volumes_from mode is changed, and service is recreated on next up as a result. Signed-off-by: Joffrey F --- compose/service.py | 4 +++- tests/unit/service_test.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/compose/service.py b/compose/service.py index e5a4cc4a0d4..8d40ab10cb9 100644 --- a/compose/service.py +++ b/compose/service.py @@ -502,7 +502,9 @@ def config_dict(self): 'image_id': self.image()['Id'], 'links': self.get_link_names(), 'net': self.net.id, - 'volumes_from': self.get_volumes_from_names(), + 'volumes_from': [ + (v.source.name, v.mode) for v in self.volumes_from if isinstance(v.source, Service) + ], } def get_dependency_names(self): diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 8c5c888fad7..bd771225009 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -410,7 +410,7 @@ def test_config_dict(self): 'options': {'image': 'example.com/foo'}, 'links': [('one', 'one')], 'net': 'other', - 'volumes_from': ['two'], + 'volumes_from': [('two', 'rw')], } self.assertEqual(config_dict, expected) From 3474bb6cf5c1961deb20cd186cd2a2dbf3224f0c Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 6 Nov 2015 14:30:27 -0500 Subject: [PATCH 1474/4072] Cleanup workaround in testcase.py Signed-off-by: Daniel Nephin --- tests/integration/testcases.py | 37 ++++++++++++---------------------- 1 file changed, 13 insertions(+), 24 deletions(-) diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index 686a2b69a46..60e67b5bf4f 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -42,34 +42,23 @@ def create_service(self, name, **kwargs): if 'command' not in kwargs: kwargs['command'] = ["top"] - links = kwargs.get('links', None) - volumes_from = kwargs.get('volumes_from', None) - net = kwargs.get('net', None) - - workaround_options = ['links', 'volumes_from', 'net'] - for key in workaround_options: - try: - del kwargs[key] - except KeyError: - pass - - options = ServiceLoader(working_dir='.', filename=None, service_name=name, service_dict=kwargs).make_service_dict() + workaround_options = {} + for option in ['links', 'volumes_from', 'net']: + if option in kwargs: + workaround_options[option] = kwargs.pop(option, None) + + options = ServiceLoader( + working_dir='.', + filename=None, + service_name=name, + service_dict=kwargs + ).make_service_dict() + options.update(workaround_options) labels = options.setdefault('labels', {}) labels['com.docker.compose.test-name'] = self.id() - if links: - options['links'] = links - if volumes_from: - options['volumes_from'] = volumes_from - if net: - options['net'] = net - - return Service( - project='composetest', - client=self.client, - **options - ) + return Service(project='composetest', client=self.client, **options) def check_build(self, *args, **kwargs): kwargs.setdefault('rm', True) From 45724fc667ff54f2ae26e00c27f76d43556b9239 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 6 Nov 2015 13:56:25 -0500 Subject: [PATCH 1475/4072] Only create the default network if at least one service needs it. Signed-off-by: Daniel Nephin --- compose/project.py | 7 +++++-- tests/integration/project_test.py | 16 ++++++++++++++++ tests/unit/project_test.py | 28 ++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/compose/project.py b/compose/project.py index f0478203bb1..1f10934c50e 100644 --- a/compose/project.py +++ b/compose/project.py @@ -300,7 +300,7 @@ def up(self, plans = self._get_convergence_plans(services, strategy) - if self.use_networking: + if self.use_networking and self.uses_default_network(): self.ensure_network_exists() return [ @@ -383,7 +383,10 @@ def ensure_network_exists(self): def remove_network(self): network = self.get_network() if network: - self.client.remove_network(network['id']) + self.client.remove_network(network['Id']) + + def uses_default_network(self): + return any(service.net.mode == self.name for service in self.services) def _inject_deps(self, acc, service): dep_names = service.get_dependency_names() diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 950523878e2..2ce319005fa 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -7,6 +7,7 @@ from compose.container import Container from compose.project import Project from compose.service import ConvergenceStrategy +from compose.service import Net from compose.service import VolumeFromSpec @@ -111,6 +112,7 @@ def test_get_network(self): network_name = 'network_does_exist' project = Project(network_name, [], client) client.create_network(network_name) + self.addCleanup(client.remove_network, network_name) assert project.get_network()['Name'] == network_name def test_net_from_service(self): @@ -398,6 +400,20 @@ def test_project_up_with_no_deps(self): self.assertEqual(len(project.get_service('data').containers(stopped=True)), 1) self.assertEqual(len(project.get_service('console').containers()), 0) + def test_project_up_with_custom_network(self): + self.require_api_version('1.21') + client = docker_client(version='1.21') + network_name = 'composetest-custom' + + client.create_network(network_name) + self.addCleanup(client.remove_network, network_name) + + web = self.create_service('web', net=Net(network_name)) + project = Project('composetest', [web], client, use_networking=True) + project.up() + + assert project.get_network() is None + def test_unscale_after_restart(self): web = self.create_service('web') project = Project('composetest', [web], self.client) diff --git a/tests/unit/project_test.py b/tests/unit/project_test.py index fc189fbb15c..b38f5c783c8 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -7,6 +7,8 @@ from compose.const import LABEL_SERVICE from compose.container import Container from compose.project import Project +from compose.service import ContainerNet +from compose.service import Net from compose.service import Service @@ -263,6 +265,32 @@ def test_use_net_from_service(self): service = project.get_service('test') self.assertEqual(service.net.mode, 'container:' + container_name) + def test_uses_default_network_true(self): + web = Service('web', project='test', image="alpine", net=Net('test')) + db = Service('web', project='test', image="alpine", net=Net('other')) + project = Project('test', [web, db], None) + assert project.uses_default_network() + + def test_uses_default_network_custom_name(self): + web = Service('web', project='test', image="alpine", net=Net('other')) + project = Project('test', [web], None) + assert not project.uses_default_network() + + def test_uses_default_network_host(self): + web = Service('web', project='test', image="alpine", net=Net('host')) + project = Project('test', [web], None) + assert not project.uses_default_network() + + def test_uses_default_network_container(self): + container = mock.Mock(id='test') + web = Service( + 'web', + project='test', + image="alpine", + net=ContainerNet(container)) + project = Project('test', [web], None) + assert not project.uses_default_network() + def test_container_without_name(self): self.mock_client.containers.return_value = [ {'Image': 'busybox:latest', 'Id': '1', 'Name': '1'}, From c5c36d8b006d9694c34b06e434e08bb17b025250 Mon Sep 17 00:00:00 2001 From: Adrian Budau Date: Tue, 27 Oct 2015 02:00:51 -0700 Subject: [PATCH 1476/4072] Added --force-rm to compose build. It's a flag passed to docker build that removes the intermediate containers left behind on fail builds. Signed-off-by: Adrian Budau --- compose/cli/main.py | 4 ++- compose/project.py | 4 +-- compose/service.py | 3 +- contrib/completion/bash/docker-compose | 2 +- contrib/completion/zsh/_docker-compose | 1 + docs/reference/build.md | 1 + tests/acceptance/cli_test.py | 28 +++++++++++++++++++ .../simple-failing-dockerfile/Dockerfile | 7 +++++ .../docker-compose.yml | 2 ++ tests/unit/service_test.py | 1 + 10 files changed, 48 insertions(+), 5 deletions(-) create mode 100644 tests/fixtures/simple-failing-dockerfile/Dockerfile create mode 100644 tests/fixtures/simple-failing-dockerfile/docker-compose.yml diff --git a/compose/cli/main.py b/compose/cli/main.py index 11aeac38c20..b1aa9951b23 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -180,12 +180,14 @@ def build(self, project, options): 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. """ + force_rm = bool(options.get('--force-rm', False)) no_cache = bool(options.get('--no-cache', False)) pull = bool(options.get('--pull', False)) - project.build(service_names=options['SERVICE'], no_cache=no_cache, pull=pull) + project.build(service_names=options['SERVICE'], no_cache=no_cache, pull=pull, force_rm=force_rm) def help(self, project, options): """ diff --git a/compose/project.py b/compose/project.py index f0478203bb1..d2ba86b1bc3 100644 --- a/compose/project.py +++ b/compose/project.py @@ -278,10 +278,10 @@ def restart(self, service_names=None, **options): for service in self.get_services(service_names): service.restart(**options) - def build(self, service_names=None, no_cache=False, pull=False): + def build(self, service_names=None, no_cache=False, pull=False, force_rm=False): for service in self.get_services(service_names): if service.can_be_built(): - service.build(no_cache, pull) + service.build(no_cache, pull, force_rm) else: log.info('%s uses an image, skipping' % service.name) diff --git a/compose/service.py b/compose/service.py index f2861954067..2055a6fe173 100644 --- a/compose/service.py +++ b/compose/service.py @@ -701,7 +701,7 @@ def _get_container_host_config(self, override_options, one_off=False): cgroup_parent=cgroup_parent ) - def build(self, no_cache=False, pull=False): + def build(self, no_cache=False, pull=False, force_rm=False): log.info('Building %s' % self.name) path = self.options['build'] @@ -715,6 +715,7 @@ def build(self, no_cache=False, pull=False): tag=self.image_name, stream=True, rm=True, + forcerm=force_rm, pull=pull, nocache=no_cache, dockerfile=self.options.get('dockerfile', None), diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index e25a43a1846..f6f7ad40030 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -93,7 +93,7 @@ __docker_compose_services_stopped() { _docker_compose_build() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "--help --no-cache --pull" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--help --force-rm --no-cache --pull" -- "$cur" ) ) ;; *) __docker_compose_services_from_build diff --git a/contrib/completion/zsh/_docker-compose b/contrib/completion/zsh/_docker-compose index d79b25d165f..08d5150d9e1 100644 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -192,6 +192,7 @@ __docker-compose_subcommand() { (build) _arguments \ $opts_help \ + '--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:__docker-compose_services_from_build' && ret=0 diff --git a/docs/reference/build.md b/docs/reference/build.md index c427199fec5..84aefc253f1 100644 --- a/docs/reference/build.md +++ b/docs/reference/build.md @@ -15,6 +15,7 @@ parent = "smn_compose_cli" 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. ``` diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index fc68f9d8517..88a43d7f0e8 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -9,6 +9,7 @@ from .. import mock from compose.cli.command import get_project from compose.cli.docker_client import docker_client +from compose.container import Container from tests.integration.testcases import DockerClientTestCase @@ -145,6 +146,33 @@ def test_build_no_cache_pull(self): assert BUILD_CACHE_TEXT not in result.stdout assert BUILD_PULL_TEXT in result.stdout + def test_build_failed(self): + self.base_dir = 'tests/fixtures/simple-failing-dockerfile' + self.dispatch(['build', 'simple'], returncode=1) + + labels = ["com.docker.compose.test_failing_image=true"] + containers = [ + Container.from_ps(self.project.client, c) + for c in self.project.client.containers( + all=True, + filters={"label": labels}) + ] + assert len(containers) == 1 + + def test_build_failed_forcerm(self): + self.base_dir = 'tests/fixtures/simple-failing-dockerfile' + self.dispatch(['build', '--force-rm', 'simple'], returncode=1) + + labels = ["com.docker.compose.test_failing_image=true"] + + containers = [ + Container.from_ps(self.project.client, c) + for c in self.project.client.containers( + all=True, + filters={"label": labels}) + ] + assert not containers + def test_up_detached(self): self.dispatch(['up', '-d']) service = self.project.get_service('simple') diff --git a/tests/fixtures/simple-failing-dockerfile/Dockerfile b/tests/fixtures/simple-failing-dockerfile/Dockerfile new file mode 100644 index 00000000000..c2d06b1672c --- /dev/null +++ b/tests/fixtures/simple-failing-dockerfile/Dockerfile @@ -0,0 +1,7 @@ +FROM busybox:latest +LABEL com.docker.compose.test_image=true +LABEL com.docker.compose.test_failing_image=true +# With the following label the container wil be cleaned up automatically +# Must be kept in sync with LABEL_PROJECT from compose/const.py +LABEL com.docker.compose.project=composetest +RUN exit 1 diff --git a/tests/fixtures/simple-failing-dockerfile/docker-compose.yml b/tests/fixtures/simple-failing-dockerfile/docker-compose.yml new file mode 100644 index 00000000000..b0357541ee3 --- /dev/null +++ b/tests/fixtures/simple-failing-dockerfile/docker-compose.yml @@ -0,0 +1,2 @@ +simple: + build: . diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 50ce5da5e66..cacdcc77a31 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -356,6 +356,7 @@ def test_create_container_with_build(self): stream=True, path='.', pull=False, + forcerm=False, nocache=False, rm=True, ) From 133d213e78c060ad0e1448fb52086c120ffdd15d Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 10 Nov 2015 10:11:20 -0800 Subject: [PATCH 1477/4072] Use exit code 1 when encountering a ReadTimeout 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 11aeac38c20..95db45cec11 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -80,6 +80,7 @@ def main(): "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) def setup_logging(): From 338bbb5063d882bb751cae3a25651bcf6e61b679 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 10 Nov 2015 13:11:59 -0500 Subject: [PATCH 1478/4072] Re-order flags in bash completion and remove unnecessary variables from build command. Signed-off-by: Daniel Nephin --- compose/cli/main.py | 9 +++++---- contrib/completion/bash/docker-compose | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index b1aa9951b23..a4b774a94a5 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -184,10 +184,11 @@ def build(self, project, options): --no-cache Do not use cache when building the image. --pull Always attempt to pull a newer version of the image. """ - force_rm = bool(options.get('--force-rm', False)) - no_cache = bool(options.get('--no-cache', False)) - pull = bool(options.get('--pull', False)) - project.build(service_names=options['SERVICE'], no_cache=no_cache, pull=pull, force_rm=force_rm) + project.build( + service_names=options['SERVICE'], + no_cache=bool(options.get('--no-cache', False)), + pull=bool(options.get('--pull', False)), + force_rm=bool(options.get('--force-rm', False))) def help(self, project, options): """ diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index f6f7ad40030..b4f4387f3f3 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -93,7 +93,7 @@ __docker_compose_services_stopped() { _docker_compose_build() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "--help --force-rm --no-cache --pull" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--force-rm --help --no-cache --pull" -- "$cur" ) ) ;; *) __docker_compose_services_from_build From a8ac6e6f93be89ba82f60e7136a2ccd8fdec4798 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 4 Nov 2015 18:00:24 -0500 Subject: [PATCH 1479/4072] Add a warning when the host volume config is being ignored. Signed-off-by: Daniel Nephin --- compose/service.py | 29 ++++++++++++++++++++++++----- tests/integration/service_test.py | 27 +++++++++++++++++++++++++++ tests/unit/service_test.py | 12 ++++++------ 3 files changed, 57 insertions(+), 11 deletions(-) diff --git a/compose/service.py b/compose/service.py index 2055a6fe173..32f1932311d 100644 --- a/compose/service.py +++ b/compose/service.py @@ -918,8 +918,10 @@ def merge_volume_bindings(volumes_option, previous_container): 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) volume_bindings.update( - get_container_data_volumes(previous_container, volumes)) + build_volume_binding(volume) for volume in data_volumes) return list(volume_bindings.values()) @@ -929,7 +931,6 @@ def get_container_data_volumes(container, volumes_option): a mapping of volume bindings for those volumes. """ volumes = [] - container_volumes = container.get('Volumes') or {} image_volumes = [ parse_volume_spec(volume) @@ -949,9 +950,27 @@ def get_container_data_volumes(container, volumes_option): # Copy existing volume from old container volume = volume._replace(external=volume_path) - volumes.append(build_volume_binding(volume)) - - return dict(volumes) + volumes.append(volume) + + return volumes + + +def warn_on_masked_volume(volumes_option, container_volumes, service): + container_volumes = dict( + (volume.internal, volume.external) + for volume in container_volumes) + + for volume in volumes_option: + if container_volumes.get(volume.internal) != volume.external: + log.warn(( + "Service \"{service}\" is using volume \"{volume}\" from the " + "previous container. Host mapping \"{host_path}\" has no effect. " + "Remove the existing containers (with `docker-compose rm {service}`) " + "to use the host volume mapping." + ).format( + service=service, + volume=volume.internal, + host_path=volume.external)) def build_volume_binding(volume_spec): diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 804f5219af5..88214e83621 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -369,6 +369,33 @@ def test_execute_convergence_plan_with_image_declared_volume(self): self.assertEqual(list(new_container.get('Volumes')), ['/data']) self.assertEqual(new_container.get('Volumes')['/data'], volume_path) + def test_execute_convergence_plan_when_image_volume_masks_config(self): + service = Service( + project='composetest', + name='db', + client=self.client, + build='tests/fixtures/dockerfile-with-volume', + ) + + old_container = create_and_start_container(service) + self.assertEqual(list(old_container.get('Volumes').keys()), ['/data']) + volume_path = old_container.get('Volumes')['/data'] + + service.options['volumes'] = ['/tmp:/data'] + + with mock.patch('compose.service.log') as mock_log: + new_container, = service.execute_convergence_plan( + ConvergencePlan('recreate', [old_container])) + + mock_log.warn.assert_called_once_with(mock.ANY) + _, args, kwargs = mock_log.warn.mock_calls[0] + self.assertIn( + "Service \"db\" is using volume \"/data\" from the previous container", + args[0]) + + self.assertEqual(list(new_container.get('Volumes')), ['/data']) + self.assertEqual(new_container.get('Volumes')['/data'], volume_path) + def test_start_container_passes_through_options(self): db = self.create_service('db') create_and_start_container(db, environment={'FOO': 'BAR'}) diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index cacdcc77a31..b69e0996dc4 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -607,13 +607,13 @@ def test_get_container_data_volumes(self): }, }, has_been_inspected=True) - expected = { - '/existing/volume': '/var/lib/docker/aaaaaaaa:/existing/volume:rw', - '/mnt/image/data': '/var/lib/docker/cccccccc:/mnt/image/data:rw', - } + expected = [ + parse_volume_spec('/var/lib/docker/aaaaaaaa:/existing/volume:rw'), + parse_volume_spec('/var/lib/docker/cccccccc:/mnt/image/data:rw'), + ] - binds = get_container_data_volumes(container, options) - self.assertEqual(binds, expected) + volumes = get_container_data_volumes(container, options) + self.assertEqual(sorted(volumes), sorted(expected)) def test_merge_volume_bindings(self): options = [ From 22d90d21800bd5bf5c695f09cd3c98928781db9e Mon Sep 17 00:00:00 2001 From: Kevin Greene Date: Mon, 26 Oct 2015 17:39:50 -0400 Subject: [PATCH 1480/4072] Added ulimits functionality to docker compose Signed-off-by: Kevin Greene --- compose/config/config.py | 12 +++++++ compose/config/fields_schema.json | 19 ++++++++++ compose/service.py | 19 ++++++++++ docs/compose-file.md | 11 ++++++ tests/integration/service_test.py | 31 ++++++++++++++++ tests/unit/config/config_test.py | 60 +++++++++++++++++++++++++++++++ 6 files changed, 152 insertions(+) diff --git a/compose/config/config.py b/compose/config/config.py index 434589d312f..7931608d2b7 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -345,6 +345,15 @@ def validate_extended_service_dict(service_dict, filename, service): "%s services with 'net: container' 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 process_container_options(working_dir, service_dict): service_dict = dict(service_dict) @@ -357,6 +366,9 @@ def process_container_options(working_dir, service_dict): if 'labels' in service_dict: service_dict['labels'] = parse_labels(service_dict['labels']) + if 'ulimits' in service_dict: + validate_ulimits(service_dict['ulimits']) + return service_dict diff --git a/compose/config/fields_schema.json b/compose/config/fields_schema.json index e254e3539f7..f22b513aeb2 100644 --- a/compose/config/fields_schema.json +++ b/compose/config/fields_schema.json @@ -116,6 +116,25 @@ "security_opt": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, "stdin_open": {"type": "boolean"}, "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"}, diff --git a/compose/service.py b/compose/service.py index 2055a6fe173..9e0066b77d4 100644 --- a/compose/service.py +++ b/compose/service.py @@ -676,6 +676,7 @@ def _get_container_host_config(self, override_options, one_off=False): devices = options.get('devices', None) cgroup_parent = options.get('cgroup_parent', None) + ulimits = build_ulimits(options.get('ulimits', None)) return self.client.create_host_config( links=self._get_links(link_to_self=one_off), @@ -692,6 +693,7 @@ def _get_container_host_config(self, override_options, one_off=False): cap_drop=cap_drop, mem_limit=options.get('mem_limit'), memswap_limit=options.get('memswap_limit'), + ulimits=ulimits, log_config=log_config, extra_hosts=extra_hosts, read_only=read_only, @@ -1073,6 +1075,23 @@ def parse_restart_spec(restart_config): return {'Name': name, 'MaximumRetryCount': int(max_retry_count)} +# Ulimits + + +def build_ulimits(ulimit_config): + if not ulimit_config: + return None + ulimits = [] + for limit_name, soft_hard_values in six.iteritems(ulimit_config): + if isinstance(soft_hard_values, six.integer_types): + ulimits.append({'name': limit_name, 'soft': soft_hard_values, 'hard': soft_hard_values}) + elif isinstance(soft_hard_values, dict): + ulimit_dict = {'name': limit_name} + ulimit_dict.update(soft_hard_values) + ulimits.append(ulimit_dict) + + return ulimits + # Extra hosts diff --git a/docs/compose-file.md b/docs/compose-file.md index 4f8fc9e013c..3b36fa2bdb5 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -333,6 +333,17 @@ Override the default labeling scheme for each container. - label:user:USER - label:role:ROLE +### ulimits + +Override the default ulimits for a container. You can either use a number +to set the hard and soft limits, or specify them in a dictionary. + + ulimits: + nproc: 65535 + nofile: + soft: 20000 + hard: 40000 + ### volumes, volume\_driver Mount paths as volumes, optionally specifying a path on the host machine diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 804f5219af5..2f3be89a3b8 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -22,6 +22,7 @@ from compose.const import LABEL_VERSION from compose.container import Container from compose.service import build_extra_hosts +from compose.service import build_ulimits from compose.service import ConfigError from compose.service import ConvergencePlan from compose.service import ConvergenceStrategy @@ -164,6 +165,36 @@ def test_build_extra_hosts(self): {'www.example.com': '192.168.0.17', 'api.example.com': '192.168.0.18'}) + def sort_dicts_by_name(self, dictionary_list): + return sorted(dictionary_list, key=lambda k: k['name']) + + def test_build_ulimits_with_invalid_options(self): + self.assertRaises(ConfigError, lambda: build_ulimits({'nofile': {'soft': 10000, 'hard': 10}})) + + def test_build_ulimits_with_integers(self): + self.assertEqual(build_ulimits( + {'nofile': {'soft': 10000, 'hard': 20000}}), + [{'name': 'nofile', 'soft': 10000, 'hard': 20000}]) + self.assertEqual(self.sort_dicts_by_name(build_ulimits( + {'nofile': {'soft': 10000, 'hard': 20000}, 'nproc': {'soft': 65535, 'hard': 65535}})), + self.sort_dicts_by_name([{'name': 'nofile', 'soft': 10000, 'hard': 20000}, + {'name': 'nproc', 'soft': 65535, 'hard': 65535}])) + + def test_build_ulimits_with_dicts(self): + self.assertEqual(build_ulimits( + {'nofile': 20000}), + [{'name': 'nofile', 'soft': 20000, 'hard': 20000}]) + self.assertEqual(self.sort_dicts_by_name(build_ulimits( + {'nofile': 20000, 'nproc': 65535})), + self.sort_dicts_by_name([{'name': 'nofile', 'soft': 20000, 'hard': 20000}, + {'name': 'nproc', 'soft': 65535, 'hard': 65535}])) + + def test_build_ulimits_with_integers_and_dicts(self): + self.assertEqual(self.sort_dicts_by_name(build_ulimits( + {'nproc': 65535, 'nofile': {'soft': 10000, 'hard': 20000}})), + self.sort_dicts_by_name([{'name': 'nofile', 'soft': 10000, 'hard': 20000}, + {'name': 'nproc', 'soft': 65535, 'hard': 65535}])) + 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) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index e0d2e870b8e..f27329ba0e8 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -349,6 +349,66 @@ def test_config_extra_hosts_list_of_dicts_validation_error(self): ) ) + def test_config_ulimits_invalid_keys_validation_error(self): + expected_error_msg = "Service 'web' configuration key 'ulimits' contains unsupported option: 'not_soft_or_hard'" + + with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): + config.load( + build_config_details( + {'web': { + 'image': 'busybox', + 'ulimits': { + 'nofile': { + "not_soft_or_hard": 100, + "soft": 10000, + "hard": 20000, + } + } + }}, + 'working_dir', + 'filename.yml' + ) + ) + + def test_config_ulimits_required_keys_validation_error(self): + expected_error_msg = "Service 'web' configuration key 'ulimits' u?'hard' is a required property" + + with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): + config.load( + build_config_details( + {'web': { + 'image': 'busybox', + 'ulimits': { + 'nofile': { + "soft": 10000, + } + } + }}, + 'working_dir', + 'filename.yml' + ) + ) + + def test_config_ulimits_soft_greater_than_hard_error(self): + expected_error_msg = "cannot contain a 'soft' value higher than 'hard' value" + + with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): + config.load( + build_config_details( + {'web': { + 'image': 'busybox', + 'ulimits': { + 'nofile': { + "soft": 10000, + "hard": 1000 + } + } + }}, + 'working_dir', + 'filename.yml' + ) + ) + def test_valid_config_which_allows_two_type_definitions(self): expose_values = [["8000"], [8000]] for expose in expose_values: From 7365a398b327ecee9de01da5deab83275a87d779 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 10 Nov 2015 13:37:07 -0500 Subject: [PATCH 1481/4072] Update doc wording for ulimits. and move tests to the correct module Signed-off-by: Daniel Nephin --- docs/compose-file.md | 5 ++-- tests/integration/service_test.py | 31 ----------------------- tests/unit/service_test.py | 42 +++++++++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 33 deletions(-) diff --git a/docs/compose-file.md b/docs/compose-file.md index 3b36fa2bdb5..51d1f5e1aa1 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -335,8 +335,9 @@ Override the default labeling scheme for each container. ### ulimits -Override the default ulimits for a container. You can either use a number -to set the hard and soft limits, or specify them in a dictionary. +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 diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 2f3be89a3b8..804f5219af5 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -22,7 +22,6 @@ from compose.const import LABEL_VERSION from compose.container import Container from compose.service import build_extra_hosts -from compose.service import build_ulimits from compose.service import ConfigError from compose.service import ConvergencePlan from compose.service import ConvergenceStrategy @@ -165,36 +164,6 @@ def test_build_extra_hosts(self): {'www.example.com': '192.168.0.17', 'api.example.com': '192.168.0.18'}) - def sort_dicts_by_name(self, dictionary_list): - return sorted(dictionary_list, key=lambda k: k['name']) - - def test_build_ulimits_with_invalid_options(self): - self.assertRaises(ConfigError, lambda: build_ulimits({'nofile': {'soft': 10000, 'hard': 10}})) - - def test_build_ulimits_with_integers(self): - self.assertEqual(build_ulimits( - {'nofile': {'soft': 10000, 'hard': 20000}}), - [{'name': 'nofile', 'soft': 10000, 'hard': 20000}]) - self.assertEqual(self.sort_dicts_by_name(build_ulimits( - {'nofile': {'soft': 10000, 'hard': 20000}, 'nproc': {'soft': 65535, 'hard': 65535}})), - self.sort_dicts_by_name([{'name': 'nofile', 'soft': 10000, 'hard': 20000}, - {'name': 'nproc', 'soft': 65535, 'hard': 65535}])) - - def test_build_ulimits_with_dicts(self): - self.assertEqual(build_ulimits( - {'nofile': 20000}), - [{'name': 'nofile', 'soft': 20000, 'hard': 20000}]) - self.assertEqual(self.sort_dicts_by_name(build_ulimits( - {'nofile': 20000, 'nproc': 65535})), - self.sort_dicts_by_name([{'name': 'nofile', 'soft': 20000, 'hard': 20000}, - {'name': 'nproc', 'soft': 65535, 'hard': 65535}])) - - def test_build_ulimits_with_integers_and_dicts(self): - self.assertEqual(self.sort_dicts_by_name(build_ulimits( - {'nproc': 65535, 'nofile': {'soft': 10000, 'hard': 20000}})), - self.sort_dicts_by_name([{'name': 'nofile', 'soft': 10000, 'hard': 20000}, - {'name': 'nproc', 'soft': 65535, 'hard': 65535}])) - 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) diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index cacdcc77a31..52128d4600b 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -12,6 +12,7 @@ from compose.const import LABEL_PROJECT 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 ConfigError from compose.service import ContainerNet @@ -497,6 +498,47 @@ def test_get_links_with_networking(self): self.assertEqual(service._get_links(link_to_self=True), []) +def sort_by_name(dictionary_list): + return sorted(dictionary_list, key=lambda k: k['name']) + + +class BuildUlimitsTestCase(unittest.TestCase): + + def test_build_ulimits_with_dict(self): + ulimits = build_ulimits( + { + 'nofile': {'soft': 10000, 'hard': 20000}, + 'nproc': {'soft': 65535, 'hard': 65535} + } + ) + expected = [ + {'name': 'nofile', 'soft': 10000, 'hard': 20000}, + {'name': 'nproc', 'soft': 65535, 'hard': 65535} + ] + assert sort_by_name(ulimits) == sort_by_name(expected) + + def test_build_ulimits_with_ints(self): + ulimits = build_ulimits({'nofile': 20000, 'nproc': 65535}) + expected = [ + {'name': 'nofile', 'soft': 20000, 'hard': 20000}, + {'name': 'nproc', 'soft': 65535, 'hard': 65535} + ] + assert sort_by_name(ulimits) == sort_by_name(expected) + + def test_build_ulimits_with_integers_and_dicts(self): + ulimits = build_ulimits( + { + 'nproc': 65535, + 'nofile': {'soft': 10000, 'hard': 20000} + } + ) + expected = [ + {'name': 'nofile', 'soft': 10000, 'hard': 20000}, + {'name': 'nproc', 'soft': 65535, 'hard': 65535} + ] + assert sort_by_name(ulimits) == sort_by_name(expected) + + class NetTestCase(unittest.TestCase): def test_net(self): From 98ad5a05e4fb342ba4deed92754da51ca98973b3 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 6 Nov 2015 16:38:38 -0500 Subject: [PATCH 1482/4072] Validate additional files before merging them. Consolidates all the top level config handling into `process_config_file` which is now used for both files and merge sources. Signed-off-by: Daniel Nephin --- compose/cli/main.py | 2 +- compose/config/__init__.py | 1 - compose/config/config.py | 56 +++++++++++++++++--------------- compose/config/validation.py | 10 +----- tests/unit/config/config_test.py | 13 ++++++++ 5 files changed, 45 insertions(+), 37 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 08c1aee0752..806926d845c 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -13,12 +13,12 @@ from .. import __version__ from .. import legacy +from ..config import ConfigurationError from ..config import parse_environment from ..const import DEFAULT_TIMEOUT from ..const import HTTP_TIMEOUT from ..const import IS_WINDOWS_PLATFORM from ..progress_stream import StreamOutputError -from ..project import ConfigurationError from ..project import NoSuchService from ..service import BuildError from ..service import ConvergenceStrategy diff --git a/compose/config/__init__.py b/compose/config/__init__.py index de6f10c9498..ec607e087ec 100644 --- a/compose/config/__init__.py +++ b/compose/config/__init__.py @@ -1,5 +1,4 @@ # flake8: noqa -from .config import ConfigDetails 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 7931608d2b7..feef0387718 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -13,7 +13,6 @@ from .interpolation import interpolate_environment_variables from .validation import validate_against_fields_schema from .validation import validate_against_service_schema -from .validation import validate_extended_service_exists from .validation import validate_extends_file_path from .validation import validate_top_level_object @@ -99,6 +98,10 @@ class ConfigFile(namedtuple('_ConfigFile', 'filename config')): :type config: :class:`dict` """ + @classmethod + def from_filename(cls, filename): + return cls(filename, load_yaml(filename)) + def find(base_dir, filenames): if filenames == ['-']: @@ -114,7 +117,7 @@ def find(base_dir, filenames): log.debug("Using configuration files: {}".format(",".join(filenames))) return ConfigDetails( os.path.dirname(filenames[0]), - [ConfigFile(f, load_yaml(f)) for f in filenames]) + [ConfigFile.from_filename(f) for f in filenames]) def get_default_config_files(base_dir): @@ -183,12 +186,10 @@ def build_service(filename, service_name, service_dict): validate_paths(service_dict) return service_dict - def load_file(filename, config): - processed_config = interpolate_environment_variables(config) - validate_against_fields_schema(processed_config) + def build_services(filename, config): return [ build_service(filename, name, service_config) - for name, service_config in processed_config.items() + for name, service_config in config.items() ] def merge_services(base, override): @@ -200,16 +201,27 @@ def merge_services(base, override): for name in all_service_names } - config_file = config_details.config_files[0] - validate_top_level_object(config_file.config) + config_file = process_config_file(config_details.config_files[0]) for next_file in config_details.config_files[1:]: - validate_top_level_object(next_file.config) + next_file = process_config_file(next_file) + + config = merge_services(config_file.config, next_file.config) + config_file = config_file._replace(config=config) + + return build_services(config_file.filename, config_file.config) + + +def process_config_file(config_file, service_name=None): + validate_top_level_object(config_file.config) + processed_config = interpolate_environment_variables(config_file.config) + validate_against_fields_schema(processed_config) - config_file = ConfigFile( - config_file.filename, - merge_services(config_file.config, next_file.config)) + if service_name and service_name not in processed_config: + raise ConfigurationError( + "Cannot extend service '{}' in {}: Service not found".format( + service_name, config_file.filename)) - return load_file(config_file.filename, config_file.config) + return config_file._replace(config=processed_config) class ServiceLoader(object): @@ -259,22 +271,13 @@ def validate_and_construct_extends(self): if not isinstance(extends, dict): extends = {'service': extends} - validate_extends_file_path(self.service_name, extends, self.filename) config_path = self.get_extended_config_path(extends) service_name = extends['service'] - config = load_yaml(config_path) - validate_top_level_object(config) - full_extended_config = interpolate_environment_variables(config) - - validate_extended_service_exists( - service_name, - full_extended_config, - config_path - ) - validate_against_fields_schema(full_extended_config) - - service_config = full_extended_config[service_name] + extended_file = process_config_file( + ConfigFile.from_filename(config_path), + service_name=service_name) + service_config = extended_file.config[service_name] return config_path, service_config, service_name def resolve_extends(self, extended_config_path, service_config, service_name): @@ -304,6 +307,7 @@ def get_extended_config_path(self, extends_options): need to obtain a full path too or we are extending from a service defined in our own file. """ + validate_extends_file_path(self.service_name, extends_options, self.filename) if 'file' in extends_options: return expand_path(self.working_dir, extends_options['file']) return self.filename diff --git a/compose/config/validation.py b/compose/config/validation.py index 542081d5265..3bd404109dd 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -96,14 +96,6 @@ def validate_extends_file_path(service_name, extends_options, filename): ) -def validate_extended_service_exists(extended_service_name, full_extended_config, extended_config_path): - if extended_service_name not in full_extended_config: - msg = ( - "Cannot extend service '%s' in %s: Service not found" - ) % (extended_service_name, extended_config_path) - raise ConfigurationError(msg) - - 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: @@ -264,7 +256,7 @@ def _parse_oneof_validator(error): msg)) else: root_msgs.append( - "Service '{}' doesn\'t have any configuration options. " + "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(service_name)) elif error.validator == 'required': diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index f27329ba0e8..ab34f4dcb6c 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -195,6 +195,19 @@ def test_load_with_multiple_files_and_extends_in_override_file(self): ] self.assertEqual(service_sort(service_dicts), service_sort(expected)) + def test_load_with_multiple_files_and_invalid_override(self): + base_file = config.ConfigFile( + 'base.yaml', + {'web': {'image': 'example/web'}}) + override_file = config.ConfigFile( + 'override.yaml', + {'bogus': 'thing'}) + details = config.ConfigDetails('.', [base_file, override_file]) + + with pytest.raises(ConfigurationError) as exc: + config.load(details) + assert 'Service "bogus" doesn\'t have any configuration' in exc.exconly() + def test_config_valid_service_names(self): for valid_name in ['_', '-', '.__.', '_what-up.', 'what_.up----', 'whatup']: config.load( From a92d86308f7bc3e571f06df4990e609ec370bfc5 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 6 Nov 2015 17:18:47 -0500 Subject: [PATCH 1483/4072] Rename ServiceLoader to ServiceExtendsResolver ServiceLoader has evolved to be not really all that related to "loading" a service. It's responsibility is more to do with handling the `extends` field, which is only part of loading. The class and its primary method (make_service_dict()) were renamed to better reflect their responsibility. As part of that change process_container_options() was removed from make_service_dict() and renamed to process_service(). It contains logic for handling the non-extends options. This change allows us to remove the hacks from testcase.py and only call the functions we need to format a service dict correctly for integration tests. Signed-off-by: Daniel Nephin --- compose/config/config.py | 27 ++++++++++++--------------- tests/integration/service_test.py | 25 ++++++++++++++++++++++--- tests/integration/testcases.py | 21 +++++++-------------- tests/unit/config/config_test.py | 7 ++++--- 4 files changed, 45 insertions(+), 35 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index feef0387718..7846ea7b46f 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -177,12 +177,12 @@ def load(config_details): """ def build_service(filename, service_name, service_dict): - loader = ServiceLoader( + resolver = ServiceExtendsResolver( config_details.working_dir, filename, service_name, service_dict) - service_dict = loader.make_service_dict() + service_dict = process_service(config_details.working_dir, resolver.run()) validate_paths(service_dict) return service_dict @@ -224,7 +224,7 @@ def process_config_file(config_file, service_name=None): return config_file._replace(config=processed_config) -class ServiceLoader(object): +class ServiceExtendsResolver(object): def __init__( self, working_dir, @@ -234,7 +234,7 @@ def __init__( already_seen=None ): if working_dir is None: - raise ValueError("No working_dir passed to ServiceLoader()") + raise ValueError("No working_dir passed to ServiceExtendsResolver()") self.working_dir = os.path.abspath(working_dir) @@ -251,7 +251,7 @@ def detect_cycle(self, name): if self.signature(name) in self.already_seen: raise CircularReference(self.already_seen + [self.signature(name)]) - def make_service_dict(self): + def run(self): service_dict = dict(self.service_dict) env = resolve_environment(self.working_dir, self.service_dict) if env: @@ -264,7 +264,7 @@ def make_service_dict(self): if not self.already_seen: validate_against_service_schema(service_dict, self.service_name) - return process_container_options(self.working_dir, service_dict) + return service_dict def validate_and_construct_extends(self): extends = self.service_dict['extends'] @@ -281,19 +281,16 @@ def validate_and_construct_extends(self): return config_path, service_config, service_name def resolve_extends(self, extended_config_path, service_config, service_name): - other_working_dir = os.path.dirname(extended_config_path) - other_already_seen = self.already_seen + [self.signature(self.service_name)] - - other_loader = ServiceLoader( - other_working_dir, + resolver = ServiceExtendsResolver( + os.path.dirname(extended_config_path), extended_config_path, self.service_name, service_config, - already_seen=other_already_seen, + already_seen=self.already_seen + [self.signature(self.service_name)], ) - other_loader.detect_cycle(service_name) - other_service_dict = other_loader.make_service_dict() + resolver.detect_cycle(service_name) + other_service_dict = process_service(resolver.working_dir, resolver.run()) validate_extended_service_dict( other_service_dict, extended_config_path, @@ -358,7 +355,7 @@ def validate_ulimits(ulimit_config): "than 'hard' value".format(ulimit_config)) -def process_container_options(working_dir, service_dict): +def process_service(working_dir, service_dict): service_dict = dict(service_dict) if 'volumes' in service_dict and service_dict.get('volume_driver') is None: diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 804f5219af5..d4474dccab6 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -815,7 +815,13 @@ def test_env_from_file_combined_with_env(self): environment=['ONE=1', 'TWO=2', 'THREE=3'], env_file=['tests/fixtures/env/one.env', 'tests/fixtures/env/two.env']) env = create_and_start_container(service).environment - for k, v in {'ONE': '1', 'TWO': '2', 'THREE': '3', 'FOO': 'baz', 'DOO': 'dah'}.items(): + for k, v in { + 'ONE': '1', + 'TWO': '2', + 'THREE': '3', + 'FOO': 'baz', + 'DOO': 'dah' + }.items(): self.assertEqual(env[k], v) @mock.patch.dict(os.environ) @@ -823,9 +829,22 @@ def test_resolve_env(self): os.environ['FILE_DEF'] = 'E1' os.environ['FILE_DEF_EMPTY'] = 'E2' os.environ['ENV_DEF'] = 'E3' - service = self.create_service('web', environment={'FILE_DEF': 'F1', 'FILE_DEF_EMPTY': '', 'ENV_DEF': None, 'NO_DEF': None}) + service = self.create_service( + 'web', + environment={ + 'FILE_DEF': 'F1', + 'FILE_DEF_EMPTY': '', + 'ENV_DEF': None, + 'NO_DEF': None + } + ) env = create_and_start_container(service).environment - for k, v in {'FILE_DEF': 'F1', 'FILE_DEF_EMPTY': '', 'ENV_DEF': 'E3', 'NO_DEF': ''}.items(): + for k, v in { + 'FILE_DEF': 'F1', + 'FILE_DEF_EMPTY': '', + 'ENV_DEF': 'E3', + 'NO_DEF': '' + }.items(): self.assertEqual(env[k], v) def test_with_high_enough_api_version_we_get_default_network_mode(self): diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index 60e67b5bf4f..5ee6a421288 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -7,7 +7,8 @@ from .. import unittest from compose.cli.docker_client import docker_client -from compose.config.config import ServiceLoader +from compose.config.config import process_service +from compose.config.config import resolve_environment from compose.const import LABEL_PROJECT from compose.progress_stream import stream_output from compose.service import Service @@ -42,23 +43,15 @@ def create_service(self, name, **kwargs): if 'command' not in kwargs: kwargs['command'] = ["top"] - workaround_options = {} - for option in ['links', 'volumes_from', 'net']: - if option in kwargs: - workaround_options[option] = kwargs.pop(option, None) - - options = ServiceLoader( - working_dir='.', - filename=None, - service_name=name, - service_dict=kwargs - ).make_service_dict() - options.update(workaround_options) + # TODO: remove this once #2299 is fixed + kwargs['name'] = name + options = process_service('.', kwargs) + options['environment'] = resolve_environment('.', kwargs) labels = options.setdefault('labels', {}) labels['com.docker.compose.test-name'] = self.id() - return Service(project='composetest', client=self.client, **options) + return Service(client=self.client, project='composetest', **options) def check_build(self, *args, **kwargs): kwargs.setdefault('rm', True) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index ab34f4dcb6c..2835a4317f6 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -18,13 +18,14 @@ def make_service_dict(name, service_dict, working_dir, filename=None): """ - Test helper function to construct a ServiceLoader + Test helper function to construct a ServiceExtendsResolver """ - return config.ServiceLoader( + resolver = config.ServiceExtendsResolver( working_dir=working_dir, filename=filename, service_name=name, - service_dict=service_dict).make_service_dict() + service_dict=service_dict) + return config.process_service(working_dir, resolver.run()) def service_sort(services): From 5e97b806d51e72f282046231af417b4d647cf64f Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 9 Nov 2015 14:24:44 -0500 Subject: [PATCH 1484/4072] Fix a bug in ExtendsResolver where the service name of the extended service was wrong. This bug can be seen by the change to the test case. When the extended service uses a different name, the error was reported incorrectly. By fixing this bug we can simplify self.signature and self.detect_cycles to always use self.service_name. Signed-off-by: Daniel Nephin --- compose/config/config.py | 20 +++++++++++--------- tests/fixtures/extends/circle-1.yml | 2 +- tests/fixtures/extends/circle-2.yml | 2 +- tests/unit/config/config_test.py | 23 ++++++++++++----------- 4 files changed, 25 insertions(+), 22 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 7846ea7b46f..1ddb2abe046 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -247,11 +247,17 @@ def __init__( self.service_name = service_name self.service_dict['name'] = service_name - def detect_cycle(self, name): - if self.signature(name) in self.already_seen: - raise CircularReference(self.already_seen + [self.signature(name)]) + @property + def signature(self): + return self.filename, self.service_name + + def detect_cycle(self): + if self.signature in self.already_seen: + raise CircularReference(self.already_seen + [self.signature]) def run(self): + self.detect_cycle() + service_dict = dict(self.service_dict) env = resolve_environment(self.working_dir, self.service_dict) if env: @@ -284,12 +290,11 @@ def resolve_extends(self, extended_config_path, service_config, service_name): resolver = ServiceExtendsResolver( os.path.dirname(extended_config_path), extended_config_path, - self.service_name, + service_name, service_config, - already_seen=self.already_seen + [self.signature(self.service_name)], + already_seen=self.already_seen + [self.signature], ) - resolver.detect_cycle(service_name) other_service_dict = process_service(resolver.working_dir, resolver.run()) validate_extended_service_dict( other_service_dict, @@ -309,9 +314,6 @@ def get_extended_config_path(self, extends_options): return expand_path(self.working_dir, extends_options['file']) return self.filename - def signature(self, name): - return self.filename, name - def resolve_environment(working_dir, service_dict): """Unpack any environment variables from an env_file, if set. diff --git a/tests/fixtures/extends/circle-1.yml b/tests/fixtures/extends/circle-1.yml index a034e9619e0..d88ea61d0ea 100644 --- a/tests/fixtures/extends/circle-1.yml +++ b/tests/fixtures/extends/circle-1.yml @@ -5,7 +5,7 @@ bar: web: extends: file: circle-2.yml - service: web + service: other baz: image: busybox quux: diff --git a/tests/fixtures/extends/circle-2.yml b/tests/fixtures/extends/circle-2.yml index fa6ddefcc34..de05bc8da0a 100644 --- a/tests/fixtures/extends/circle-2.yml +++ b/tests/fixtures/extends/circle-2.yml @@ -2,7 +2,7 @@ foo: image: busybox bar: image: busybox -web: +other: extends: file: circle-1.yml service: web diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 2835a4317f6..71783168171 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1080,18 +1080,19 @@ def test_self_referencing_file(self): ])) def test_circular(self): - try: + with pytest.raises(config.CircularReference) as exc: load_from_filename('tests/fixtures/extends/circle-1.yml') - raise Exception("Expected config.CircularReference to be raised") - except config.CircularReference as e: - self.assertEqual( - [(os.path.basename(filename), service_name) for (filename, service_name) in e.trail], - [ - ('circle-1.yml', 'web'), - ('circle-2.yml', 'web'), - ('circle-1.yml', 'web'), - ], - ) + + path = [ + (os.path.basename(filename), service_name) + for (filename, service_name) in exc.value.trail + ] + expected = [ + ('circle-1.yml', 'web'), + ('circle-2.yml', 'other'), + ('circle-1.yml', 'web'), + ] + self.assertEqual(path, expected) def test_extends_validation_empty_dictionary(self): with self.assertRaisesRegexp(ConfigurationError, 'service'): From 1f7faadc7712f5bf734e6254d2a8d6f1427f5029 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 6 Nov 2015 11:57:27 -0500 Subject: [PATCH 1485/4072] Remove name from config schema. Refactors config validation of a service to use a ServiceConfig data object. Instead of passing around a bunch of related scalars, we can use the ServiceConfig object as a parameter to most of the service validation functions. This allows for a fix to the config schema, where the name is a field in the schema, but not actually in the configuration. My passing the name around as part of the ServiceConfig object, we don't need to add it to the config options. Fixes #2299 validate_against_service_schema() is moved from a conditional branch in ServiceExtendsResolver() to happen as one of the last steps after all configuration is merged. This schema only contains constraints which only need to be true at the very end of merging. Signed-off-by: Daniel Nephin --- compose/config/config.py | 103 +++++++++++++++-------------- compose/config/fields_schema.json | 1 - compose/config/service_schema.json | 6 -- compose/config/validation.py | 2 +- tests/integration/testcases.py | 9 ++- tests/unit/config/config_test.py | 10 +-- 6 files changed, 63 insertions(+), 68 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 1ddb2abe046..21788551da6 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -103,6 +103,20 @@ def from_filename(cls, filename): return cls(filename, load_yaml(filename)) +class ServiceConfig(namedtuple('_ServiceConfig', 'working_dir filename name config')): + + @classmethod + def with_abs_paths(cls, working_dir, filename, name, config): + if not working_dir: + raise ValueError("No working_dir for ServiceConfig.") + + return cls( + os.path.abspath(working_dir), + os.path.abspath(filename) if filename else filename, + name, + config) + + def find(base_dir, filenames): if filenames == ['-']: return ConfigDetails( @@ -177,19 +191,22 @@ def load(config_details): """ def build_service(filename, service_name, service_dict): - resolver = ServiceExtendsResolver( + service_config = ServiceConfig.with_abs_paths( config_details.working_dir, filename, service_name, service_dict) - service_dict = process_service(config_details.working_dir, resolver.run()) + resolver = ServiceExtendsResolver(service_config) + service_dict = process_service(resolver.run()) + validate_against_service_schema(service_dict, service_config.name) validate_paths(service_dict) + service_dict['name'] = service_config.name return service_dict - def build_services(filename, config): + def build_services(config_file): return [ - build_service(filename, name, service_config) - for name, service_config in config.items() + build_service(config_file.filename, name, service_dict) + for name, service_dict in config_file.config.items() ] def merge_services(base, override): @@ -208,7 +225,7 @@ def merge_services(base, override): config = merge_services(config_file.config, next_file.config) config_file = config_file._replace(config=config) - return build_services(config_file.filename, config_file.config) + return build_services(config_file) def process_config_file(config_file, service_name=None): @@ -225,31 +242,14 @@ def process_config_file(config_file, service_name=None): class ServiceExtendsResolver(object): - def __init__( - self, - working_dir, - filename, - service_name, - service_dict, - already_seen=None - ): - if working_dir is None: - raise ValueError("No working_dir passed to ServiceExtendsResolver()") - - self.working_dir = os.path.abspath(working_dir) - - if filename: - self.filename = os.path.abspath(filename) - else: - self.filename = filename + def __init__(self, service_config, already_seen=None): + self.service_config = service_config + self.working_dir = service_config.working_dir self.already_seen = already_seen or [] - self.service_dict = service_dict.copy() - self.service_name = service_name - self.service_dict['name'] = service_name @property def signature(self): - return self.filename, self.service_name + return self.service_config.filename, self.service_config.name def detect_cycle(self): if self.signature in self.already_seen: @@ -258,8 +258,8 @@ def detect_cycle(self): def run(self): self.detect_cycle() - service_dict = dict(self.service_dict) - env = resolve_environment(self.working_dir, self.service_dict) + service_dict = dict(self.service_config.config) + env = resolve_environment(self.working_dir, self.service_config.config) if env: service_dict['environment'] = env service_dict.pop('env_file', None) @@ -267,13 +267,10 @@ def run(self): if 'extends' in service_dict: service_dict = self.resolve_extends(*self.validate_and_construct_extends()) - if not self.already_seen: - validate_against_service_schema(service_dict, self.service_name) - - return service_dict + return self.service_config._replace(config=service_dict) def validate_and_construct_extends(self): - extends = self.service_dict['extends'] + extends = self.service_config.config['extends'] if not isinstance(extends, dict): extends = {'service': extends} @@ -286,33 +283,38 @@ def validate_and_construct_extends(self): service_config = extended_file.config[service_name] return config_path, service_config, service_name - def resolve_extends(self, extended_config_path, service_config, service_name): + def resolve_extends(self, extended_config_path, service_dict, service_name): resolver = ServiceExtendsResolver( - os.path.dirname(extended_config_path), - extended_config_path, - service_name, - service_config, - already_seen=self.already_seen + [self.signature], - ) - - other_service_dict = process_service(resolver.working_dir, resolver.run()) + ServiceConfig.with_abs_paths( + os.path.dirname(extended_config_path), + extended_config_path, + service_name, + service_dict), + already_seen=self.already_seen + [self.signature]) + + service_config = resolver.run() + other_service_dict = process_service(service_config) validate_extended_service_dict( other_service_dict, extended_config_path, service_name, ) - return merge_service_dicts(other_service_dict, self.service_dict) + return merge_service_dicts(other_service_dict, self.service_config.config) def get_extended_config_path(self, extends_options): """Service we are extending either has a value for 'file' set, which we need to obtain a full path too or we are extending from a service defined in our own file. """ - validate_extends_file_path(self.service_name, extends_options, self.filename) + filename = self.service_config.filename + validate_extends_file_path( + self.service_config.name, + extends_options, + filename) if 'file' in extends_options: return expand_path(self.working_dir, extends_options['file']) - return self.filename + return filename def resolve_environment(working_dir, service_dict): @@ -357,8 +359,9 @@ def validate_ulimits(ulimit_config): "than 'hard' value".format(ulimit_config)) -def process_service(working_dir, service_dict): - service_dict = dict(service_dict) +def process_service(service_config): + working_dir = service_config.working_dir + service_dict = dict(service_config.config) if 'volumes' in service_dict and service_dict.get('volume_driver') is None: service_dict['volumes'] = resolve_volume_paths(working_dir, service_dict) @@ -505,12 +508,12 @@ def env_vars_from_file(filename): def resolve_volume_paths(working_dir, service_dict): return [ - resolve_volume_path(working_dir, volume, service_dict['name']) + resolve_volume_path(working_dir, volume) for volume in service_dict['volumes'] ] -def resolve_volume_path(working_dir, volume, service_name): +def resolve_volume_path(working_dir, volume): container_path, host_path = split_path_mapping(volume) if host_path is not None: diff --git a/compose/config/fields_schema.json b/compose/config/fields_schema.json index f22b513aeb2..7723f2fbc3c 100644 --- a/compose/config/fields_schema.json +++ b/compose/config/fields_schema.json @@ -89,7 +89,6 @@ "mac_address": {"type": "string"}, "mem_limit": {"type": ["number", "string"]}, "memswap_limit": {"type": ["number", "string"]}, - "name": {"type": "string"}, "net": {"type": "string"}, "pid": {"type": ["string", "null"]}, diff --git a/compose/config/service_schema.json b/compose/config/service_schema.json index 5cb5d6d0701..221c5d8d747 100644 --- a/compose/config/service_schema.json +++ b/compose/config/service_schema.json @@ -3,12 +3,6 @@ "type": "object", - "properties": { - "name": {"type": "string"} - }, - - "required": ["name"], - "allOf": [ {"$ref": "fields_schema.json#/definitions/service"}, {"$ref": "#/definitions/service_constraints"} diff --git a/compose/config/validation.py b/compose/config/validation.py index 3bd404109dd..d3bcb35c4e7 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -195,7 +195,7 @@ def _parse_oneof_validator(error): for error in errors: # handle root level errors - if len(error.path) == 0 and not error.instance.get('name'): + if len(error.path) == 0 and not service_name: if error.validator == 'type': msg = "Top level object needs to be a dictionary. Check your .yml file that you have defined a service at the top level." root_msgs.append(msg) diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index 5ee6a421288..d63f0591603 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -9,6 +9,7 @@ from compose.cli.docker_client import docker_client from compose.config.config import process_service from compose.config.config import resolve_environment +from compose.config.config import ServiceConfig from compose.const import LABEL_PROJECT from compose.progress_stream import stream_output from compose.service import Service @@ -43,15 +44,13 @@ def create_service(self, name, **kwargs): if 'command' not in kwargs: kwargs['command'] = ["top"] - # TODO: remove this once #2299 is fixed - kwargs['name'] = name - - options = process_service('.', kwargs) + service_config = ServiceConfig('.', None, name, kwargs) + options = process_service(service_config) options['environment'] = resolve_environment('.', kwargs) labels = options.setdefault('labels', {}) labels['com.docker.compose.test-name'] = self.id() - return Service(client=self.client, project='composetest', **options) + return Service(name, client=self.client, project='composetest', **options) def check_build(self, *args, **kwargs): kwargs.setdefault('rm', True) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 71783168171..022ec7c7d68 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -20,12 +20,12 @@ def make_service_dict(name, service_dict, working_dir, filename=None): """ Test helper function to construct a ServiceExtendsResolver """ - resolver = config.ServiceExtendsResolver( + resolver = config.ServiceExtendsResolver(config.ServiceConfig( working_dir=working_dir, filename=filename, - service_name=name, - service_dict=service_dict) - return config.process_service(working_dir, resolver.run()) + name=name, + config=service_dict)) + return config.process_service(resolver.run()) def service_sort(services): @@ -651,7 +651,7 @@ def test_home_directory_with_driver_does_not_expand(self): def test_volume_path_with_non_ascii_directory(self): volume = u'/Füü/data:/data' - container_path = config.resolve_volume_path(".", volume, "test") + container_path = config.resolve_volume_path(".", volume) self.assertEqual(container_path, volume) From 19b2c41c7ee7887468d04ceb1fc90f05b232432a Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 9 Nov 2015 15:39:55 -0500 Subject: [PATCH 1486/4072] Add a test for invalid field 'name', and fix an existing test for invalid service names. Signed-off-by: Daniel Nephin --- tests/unit/config/config_test.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 022ec7c7d68..add7a5a48f1 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -77,8 +77,8 @@ def test_load_throws_error_when_not_dict(self): ) def test_config_invalid_service_names(self): - with self.assertRaises(ConfigurationError): - for invalid_name in ['?not?allowed', ' ', '', '!', '/', '\xe2']: + for invalid_name in ['?not?allowed', ' ', '', '!', '/', '\xe2']: + with pytest.raises(ConfigurationError): config.load( build_config_details( {invalid_name: {'image': 'busybox'}}, @@ -87,6 +87,16 @@ def test_config_invalid_service_names(self): ) ) + 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() + def test_config_integer_service_name_raise_validation_error(self): expected_error_msg = "Service name: 1 needs to be a string, eg '1'" with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): From b8b4c84573ec7d20524b7d2715d956fcc9f897a8 Mon Sep 17 00:00:00 2001 From: Yves Peter Date: Wed, 4 Nov 2015 23:40:57 +0100 Subject: [PATCH 1487/4072] Fixes #1490 progress_stream would print a lot of new lines on "docker-compose pull" if there's no tty. Signed-off-by: Yves Peter --- compose/progress_stream.py | 23 +++++++++++++--------- tests/unit/progress_stream_test.py | 31 ++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 9 deletions(-) diff --git a/compose/progress_stream.py b/compose/progress_stream.py index ac8e4b410ff..c729a6df0a8 100644 --- a/compose/progress_stream.py +++ b/compose/progress_stream.py @@ -14,8 +14,14 @@ def stream_output(output, stream): for event in utils.json_stream(output): all_events.append(event) + is_progress_event = 'progress' in event or 'progressDetail' in event - if 'progress' in event or 'progressDetail' in event: + if not is_progress_event: + print_output_event(event, stream, is_terminal) + stream.flush() + + # if it's a progress event and we have a terminal, then display the progress bars + elif is_terminal: image_id = event.get('id') if not image_id: continue @@ -27,17 +33,16 @@ def stream_output(output, stream): stream.write("\n") diff = 0 - if is_terminal: - # move cursor up `diff` rows - stream.write("%c[%dA" % (27, diff)) + # move cursor up `diff` rows + stream.write("%c[%dA" % (27, diff)) - print_output_event(event, stream, is_terminal) + print_output_event(event, stream, is_terminal) - if 'id' in event and is_terminal: - # move cursor back down - stream.write("%c[%dB" % (27, diff)) + if 'id' in event: + # move cursor back down + stream.write("%c[%dB" % (27, diff)) - stream.flush() + stream.flush() return all_events diff --git a/tests/unit/progress_stream_test.py b/tests/unit/progress_stream_test.py index d8f7ec83633..b01be11a860 100644 --- a/tests/unit/progress_stream_test.py +++ b/tests/unit/progress_stream_test.py @@ -34,3 +34,34 @@ def test_stream_output_null_total(self): ] events = progress_stream.stream_output(output, StringIO()) self.assertEqual(len(events), 1) + + def test_stream_output_progress_event_tty(self): + events = [ + b'{"status": "Already exists", "progressDetail": {}, "id": "8d05e3af52b0"}' + ] + + class TTYStringIO(StringIO): + def isatty(self): + return True + + output = TTYStringIO() + events = progress_stream.stream_output(events, output) + self.assertTrue(len(output.getvalue()) > 0) + + def test_stream_output_progress_event_no_tty(self): + events = [ + b'{"status": "Already exists", "progressDetail": {}, "id": "8d05e3af52b0"}' + ] + output = StringIO() + + events = progress_stream.stream_output(events, output) + self.assertEqual(len(output.getvalue()), 0) + + def test_stream_output_no_progress_event_no_tty(self): + events = [ + b'{"status": "Pulling from library/xy", "id": "latest"}' + ] + output = StringIO() + + events = progress_stream.stream_output(events, output) + self.assertTrue(len(output.getvalue()) > 0) From c573fcc70a297dbc8d971ad13a81bbaa88262dec Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 11 Nov 2015 11:21:24 -0800 Subject: [PATCH 1488/4072] Reorganize conditional branches to improve readability Signed-off-by: Joffrey F --- compose/progress_stream.py | 37 ++++++++++++++++++++----------------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/compose/progress_stream.py b/compose/progress_stream.py index c729a6df0a8..a6c8e0a2639 100644 --- a/compose/progress_stream.py +++ b/compose/progress_stream.py @@ -19,30 +19,33 @@ def stream_output(output, stream): if not is_progress_event: print_output_event(event, stream, is_terminal) stream.flush() + continue + + if not is_terminal: + continue # if it's a progress event and we have a terminal, then display the progress bars - elif is_terminal: - image_id = event.get('id') - if not image_id: - continue + image_id = event.get('id') + if not image_id: + continue - if image_id in lines: - diff = len(lines) - lines[image_id] - else: - lines[image_id] = len(lines) - stream.write("\n") - diff = 0 + if image_id in lines: + diff = len(lines) - lines[image_id] + else: + lines[image_id] = len(lines) + stream.write("\n") + diff = 0 - # move cursor up `diff` rows - stream.write("%c[%dA" % (27, diff)) + # move cursor up `diff` rows + stream.write("%c[%dA" % (27, diff)) - print_output_event(event, stream, is_terminal) + print_output_event(event, stream, is_terminal) - if 'id' in event: - # move cursor back down - stream.write("%c[%dB" % (27, diff)) + if 'id' in event: + # move cursor back down + stream.write("%c[%dB" % (27, diff)) - stream.flush() + stream.flush() return all_events From 513dfda218a96f89de69788db9fb39f007656356 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 11 Nov 2015 12:45:02 -0800 Subject: [PATCH 1489/4072] Allow dashes in environment variable names See http://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap08.html Environment variable names used by the utilities in the Shell and Utilities volume of POSIX.1-2008 consist solely of uppercase letters, digits, and the ( '_' ) from the characters defined in Portable Character Set and do not begin with a digit. Other characters may be permitted by an implementation; applications shall tolerate the presence of such names. Signed-off-by: Joffrey F --- compose/config/fields_schema.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/config/fields_schema.json b/compose/config/fields_schema.json index f22b513aeb2..454020a85ec 100644 --- a/compose/config/fields_schema.json +++ b/compose/config/fields_schema.json @@ -40,7 +40,7 @@ { "type": "object", "patternProperties": { - "^[^-]+$": { + ".+": { "type": ["string", "number", "boolean", "null"], "format": "environment" } From d6b44905f25d8d03a5057c942bc9a955da40be23 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 11 Nov 2015 12:52:30 -0800 Subject: [PATCH 1490/4072] Add test for environment variable dashes support Signed-off-by: Joffrey F --- tests/unit/config/config_test.py | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 71783168171..3adc02c8af9 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -470,20 +470,18 @@ def test_logs_warning_for_boolean_in_environment(self, mock_logging): self.assertTrue(mock_logging.warn.called) self.assertTrue(expected_warning_msg in mock_logging.warn.call_args[0][0]) - def test_config_invalid_environment_dict_key_raises_validation_error(self): - expected_error_msg = "Service 'web' configuration key 'environment' contains unsupported option: '---'" - - with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): - config.load( - build_config_details( - {'web': { - 'image': 'busybox', - 'environment': {'---': 'nope'} - }}, - 'working_dir', - 'filename.yml' - ) + def test_config_valid_environment_dict_key_contains_dashes(self): + services = config.load( + build_config_details( + {'web': { + 'image': 'busybox', + 'environment': {'SPRING_JPA_HIBERNATE_DDL-AUTO': 'none'} + }}, + 'working_dir', + 'filename.yml' ) + ) + self.assertEqual(services[0]['environment']['SPRING_JPA_HIBERNATE_DDL-AUTO'], 'none') def test_load_yaml_with_yaml_error(self): tmpdir = py.test.ensuretemp('invalid_yaml_test') From f7d808769405e52e4c84acdcf15875614cb71993 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 12 Nov 2015 12:40:36 -0500 Subject: [PATCH 1491/4072] Add ids to config schemas Also enforce a max complexity for functions and add some new tests for config. Signed-off-by: Daniel Nephin --- compose/config/fields_schema.json | 6 ++++-- compose/config/service_schema.json | 11 ++++------- compose/config/validation.py | 5 ++++- tests/unit/config/config_test.py | 24 ++++++++++++++++-------- tox.ini | 2 ++ 5 files changed, 30 insertions(+), 18 deletions(-) diff --git a/compose/config/fields_schema.json b/compose/config/fields_schema.json index 7723f2fbc3c..a174ba873cb 100644 --- a/compose/config/fields_schema.json +++ b/compose/config/fields_schema.json @@ -2,15 +2,18 @@ "$schema": "http://json-schema.org/draft-04/schema#", "type": "object", + "id": "fields_schema.json", "patternProperties": { "^[a-zA-Z0-9._-]+$": { "$ref": "#/definitions/service" } }, + "additionalProperties": false, "definitions": { "service": { + "id": "#/definitions/service", "type": "object", "properties": { @@ -167,6 +170,5 @@ ] } - }, - "additionalProperties": false + } } diff --git a/compose/config/service_schema.json b/compose/config/service_schema.json index 221c5d8d747..05774efdda7 100644 --- a/compose/config/service_schema.json +++ b/compose/config/service_schema.json @@ -1,15 +1,17 @@ { "$schema": "http://json-schema.org/draft-04/schema#", + "id": "service_schema.json", "type": "object", "allOf": [ {"$ref": "fields_schema.json#/definitions/service"}, - {"$ref": "#/definitions/service_constraints"} + {"$ref": "#/definitions/constraints"} ], "definitions": { - "service_constraints": { + "constraints": { + "id": "#/definitions/constraints", "anyOf": [ { "required": ["build"], @@ -21,13 +23,8 @@ {"required": ["build"]}, {"required": ["dockerfile"]} ]} - }, - { - "required": ["extends"], - "not": {"required": ["build", "image"]} } ] } } - } diff --git a/compose/config/validation.py b/compose/config/validation.py index d3bcb35c4e7..962d41e2fbe 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -307,7 +307,10 @@ def _validate_against_schema(config, schema_filename, format_checker=[], service schema = json.load(schema_fh) resolver = RefResolver(resolver_full_path, schema) - validation_output = Draft4Validator(schema, resolver=resolver, format_checker=FormatChecker(format_checker)) + validation_output = Draft4Validator( + schema, + resolver=resolver, + format_checker=FormatChecker(format_checker)) errors = [error for error in sorted(validation_output.iter_errors(config), key=str)] if errors: diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index add7a5a48f1..03e338cbbc1 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -78,14 +78,12 @@ def test_load_throws_error_when_not_dict(self): def test_config_invalid_service_names(self): for invalid_name in ['?not?allowed', ' ', '', '!', '/', '\xe2']: - with pytest.raises(ConfigurationError): - config.load( - build_config_details( - {invalid_name: {'image': 'busybox'}}, - 'working_dir', - 'filename.yml' - ) - ) + with pytest.raises(ConfigurationError) as exc: + config.load(build_config_details( + {invalid_name: {'image': 'busybox'}}, + 'working_dir', + 'filename.yml')) + assert 'Invalid service name \'%s\'' % invalid_name in exc.exconly() def test_load_with_invalid_field_name(self): config_details = build_config_details( @@ -97,6 +95,16 @@ def test_load_with_invalid_field_name(self): error_msg = "Unsupported config option for 'web' service: 'name'" assert error_msg in exc.exconly() + def test_load_invalid_service_definition(self): + config_details = build_config_details( + {'web': 'wrong'}, + 'working_dir', + 'filename.yml') + with pytest.raises(ConfigurationError) as exc: + config.load(config_details) + error_msg = "Service \"web\" doesn\'t have any configuration options" + assert error_msg in exc.exconly() + def test_config_integer_service_name_raise_validation_error(self): expected_error_msg = "Service name: 1 needs to be a string, eg '1'" with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): diff --git a/tox.ini b/tox.ini index f05c5ed260c..d1098a55a3e 100644 --- a/tox.ini +++ b/tox.ini @@ -43,4 +43,6 @@ directory = coverage-html [flake8] # Allow really long lines for now max-line-length = 140 +# Set this high for now +max-complexity = 20 exclude = compose/packages From fa96484d2835b8711e560d0c22626c67b99b2407 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 12 Nov 2015 12:43:29 -0500 Subject: [PATCH 1492/4072] Refactor process_errors into smaller functions So that it passed new max-complexity requirement Signed-off-by: Daniel Nephin --- compose/config/validation.py | 318 +++++++++++++++---------------- tests/unit/config/config_test.py | 2 +- 2 files changed, 150 insertions(+), 170 deletions(-) diff --git a/compose/config/validation.py b/compose/config/validation.py index 962d41e2fbe..2928238c34d 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -109,189 +109,169 @@ def anglicize_validator(validator): return 'a ' + validator -def process_errors(errors, service_name=None): +def handle_error_for_schema_with_id(error, service_name): + schema_id = error.schema['id'] + + if schema_id == 'fields_schema.json' and error.validator == 'additionalProperties': + return "Invalid service name '{}' - only {} characters are allowed".format( + # The service_name is the key to the json object + list(error.instance)[0], + VALID_NAME_CHARS) + + if schema_id == '#/definitions/constraints': + 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)) + if 'image' not in error.instance and 'build' not in error.instance: + return ( + "Service '{}' has neither an image nor a build path " + "specified. Exactly one must be provided.".format(service_name)) + 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 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) + + +def handle_generic_service_error(error, service_name): + config_key = " ".join("'%s'" % k for k in error.path) + msg_format = None + error_msg = error.message + + if error.validator == 'oneOf': + msg_format = "Service '{}' configuration key {} {}" + error_msg = _parse_oneof_validator(error) + + elif error.validator == 'type': + msg_format = ("Service '{}' configuration key {} contains an invalid " + "type, it should be {}") + 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, {}" + + 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]) + error_msg = "when defining '{}' you must set '{}' as well".format( + config_key, + required_keys) + + elif error.path: + msg_format = "Service '{}' configuration key {} value {}" + + if msg_format: + return msg_format.format(service_name, config_key, error_msg) + + return error.message + + +def parse_key_from_error_msg(error): + return error.message.split("'")[1] + + +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. """ - 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. + if not isinstance(validator, list): + return anglicize_validator(validator) + + if len(validator) == 1: + return anglicize_validator(validator[0]) + + return "{}, or {}".format( + ", ".join([anglicize_validator(validator[0])] + validator[1:-1]), + anglicize_validator(validator[-1])) + + +def _parse_oneof_validator(error): + """oneOf has multiple schemas, so we need to reason about which schema, sub + schema or constraint the validation is failing on. + Inspecting the context value of a ValidationError gives us information about + which sub schema failed and which kind of error it is. """ - def _parse_key_from_error_msg(error): - return error.message.split("'")[1] - - def _clean_error_message(message): - return message.replace("u'", "'") - - 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. - """ - if isinstance(validator, list): - if len(validator) >= 2: - first_type = anglicize_validator(validator[0]) - last_type = anglicize_validator(validator[-1]) - types_from_validator = ", ".join([first_type] + validator[1:-1]) - - msg = "{} or {}".format( - types_from_validator, - last_type - ) - else: - msg = "{}".format(anglicize_validator(validator[0])) - else: - msg = "{}".format(anglicize_validator(validator)) - - return msg - - def _parse_oneof_validator(error): - """ - oneOf has multiple schemas, so we need to reason about which schema, sub - schema or constraint the validation is failing on. - Inspecting the context value of a ValidationError gives us information about - which sub schema failed and which kind of error it is. - """ - required = [context for context in error.context if context.validator == 'required'] - if required: - return required[0].message - - additionalProperties = [context for context in error.context if context.validator == 'additionalProperties'] - if additionalProperties: - invalid_config_key = _parse_key_from_error_msg(additionalProperties[0]) + types = [] + for context in error.context: + + if context.validator == 'required': + return context.message + + if context.validator == 'additionalProperties': + invalid_config_key = parse_key_from_error_msg(context) return "contains unsupported option: '{}'".format(invalid_config_key) - constraint = [context for context in error.context if len(context.path) > 0] - if constraint: - valid_types = _parse_valid_types_from_validator(constraint[0].validator_value) - invalid_config_key = "".join( - "'{}' ".format(fragment) for fragment in constraint[0].path + if context.path: + invalid_config_key = " ".join( + "'{}' ".format(fragment) for fragment in context.path if isinstance(fragment, six.string_types) ) - msg = "{}contains {}, which is an invalid type, it should be {}".format( + return "{}contains {}, which is an invalid type, it should be {}".format( invalid_config_key, - constraint[0].instance, - valid_types - ) - return msg + context.instance, + _parse_valid_types_from_validator(context.validator_value)) - uniqueness = [context for context in error.context if context.validator == 'uniqueItems'] - if uniqueness: - msg = "contains non unique items, please remove duplicates from {}".format( - uniqueness[0].instance - ) - return msg - - types = [context.validator_value for context in error.context if context.validator == 'type'] - valid_types = _parse_valid_types_from_validator(types) - - msg = "contains an invalid type, it should be {}".format(valid_types) - - return msg - - root_msgs = [] - invalid_keys = [] - required = [] - type_errors = [] - other_errors = [] - - for error in errors: - # handle root level errors - if len(error.path) == 0 and not service_name: - if error.validator == 'type': - msg = "Top level object needs to be a dictionary. Check your .yml file that you have defined a service at the top level." - root_msgs.append(msg) - elif error.validator == 'additionalProperties': - invalid_service_name = _parse_key_from_error_msg(error) - msg = "Invalid service name '{}' - only {} characters are allowed".format(invalid_service_name, VALID_NAME_CHARS) - root_msgs.append(msg) - else: - root_msgs.append(_clean_error_message(error.message)) - - else: - if not service_name: - # field_schema errors will have service name on the path - service_name = error.path[0] - error.path.popleft() - else: - # service_schema errors have the service name passed in, as that - # is not available on error.path or necessarily error.instance - service_name = service_name - - if error.validator == 'additionalProperties': - invalid_config_key = _parse_key_from_error_msg(error) - invalid_keys.append(get_unsupported_config_msg(service_name, invalid_config_key)) - elif error.validator == 'anyOf': - if 'image' in error.instance and 'build' in error.instance: - required.append( - "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)) - elif 'image' not in error.instance and 'build' not in error.instance: - required.append( - "Service '{}' has neither an image nor a build path " - "specified. Exactly one must be provided.".format(service_name)) - elif 'image' in error.instance and 'dockerfile' in error.instance: - required.append( - "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)) - else: - required.append(_clean_error_message(error.message)) - elif error.validator == 'oneOf': - config_key = error.path[0] - msg = _parse_oneof_validator(error) - - type_errors.append("Service '{}' configuration key '{}' {}".format( - service_name, config_key, msg) - ) - elif error.validator == 'type': - msg = _parse_valid_types_from_validator(error.validator_value) - - if len(error.path) > 0: - config_key = " ".join(["'%s'" % k for k in error.path]) - type_errors.append( - "Service '{}' configuration key {} contains an invalid " - "type, it should be {}".format( - service_name, - config_key, - msg)) - else: - root_msgs.append( - "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(service_name)) - elif error.validator == 'required': - config_key = error.path[0] - required.append( - "Service '{}' option '{}' is invalid, {}".format( - service_name, - config_key, - _clean_error_message(error.message))) - elif error.validator == 'dependencies': - dependency_key = list(error.validator_value.keys())[0] - required_keys = ",".join(error.validator_value[dependency_key]) - required.append("Invalid '{}' configuration for '{}' service: when defining '{}' you must set '{}' as well".format( - dependency_key, service_name, dependency_key, required_keys)) - else: - config_key = " ".join(["'%s'" % k for k in error.path]) - err_msg = "Service '{}' configuration key {} value {}".format(service_name, config_key, error.message) - other_errors.append(err_msg) - - return "\n".join(root_msgs + invalid_keys + required + type_errors + other_errors) + if context.validator == 'uniqueItems': + return "contains non unique items, please remove duplicates from {}".format( + context.instance) + if context.validator == 'type': + types.append(context.validator_value) -def validate_against_fields_schema(config): - schema_filename = "fields_schema.json" - format_checkers = ["ports", "environment"] - return _validate_against_schema(config, schema_filename, format_checkers) + valid_types = _parse_valid_types_from_validator(types) + return "contains an invalid type, it should be {}".format(valid_types) -def validate_against_service_schema(config, service_name): - schema_filename = "service_schema.json" - format_checkers = ["ports"] - return _validate_against_schema(config, schema_filename, format_checkers, service_name) +def process_errors(errors, service_name=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() + + if 'id' in error.schema: + error_msg = handle_error_for_schema_with_id(error, service_name) + if error_msg: + return error_msg + return handle_generic_service_error(error, service_name) -def _validate_against_schema(config, schema_filename, format_checker=[], service_name=None): + return '\n'.join(format_error_message(error, service_name) for error in errors) + + +def validate_against_fields_schema(config): + return _validate_against_schema( + config, + "fields_schema.json", + ["ports", "environment"]) + + +def validate_against_service_schema(config, service_name): + return _validate_against_schema( + config, + "service_schema.json", + ["ports"], + service_name) + + +def _validate_against_schema( + config, + schema_filename, + format_checker=(), + service_name=None): config_source_dir = os.path.dirname(os.path.abspath(__file__)) if sys.platform == "win32": diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 03e338cbbc1..9abc58e47fb 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -867,7 +867,7 @@ def test_validation_fails_with_just_memswap_limit(self): a mem_limit """ expected_error_msg = ( - "Invalid 'memswap_limit' configuration for 'foo' service: when " + "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): From 589755d03448b04dff441895949116d647b64bc1 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 9 Nov 2015 20:01:20 -0500 Subject: [PATCH 1493/4072] Inclide the filename in validation errors. Signed-off-by: Daniel Nephin --- compose/config/config.py | 4 +- compose/config/interpolation.py | 7 --- compose/config/validation.py | 60 ++++++++++++------ tests/unit/config/config_test.py | 104 +++++++++++++++---------------- 4 files changed, 95 insertions(+), 80 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 21788551da6..2c1fdeb9cec 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -229,9 +229,9 @@ def merge_services(base, override): def process_config_file(config_file, service_name=None): - validate_top_level_object(config_file.config) + validate_top_level_object(config_file) processed_config = interpolate_environment_variables(config_file.config) - validate_against_fields_schema(processed_config) + validate_against_fields_schema(processed_config, config_file.filename) if service_name and service_name not in processed_config: raise ConfigurationError( diff --git a/compose/config/interpolation.py b/compose/config/interpolation.py index f8e1da610dc..ba7e35c1e58 100644 --- a/compose/config/interpolation.py +++ b/compose/config/interpolation.py @@ -18,13 +18,6 @@ def interpolate_environment_variables(config): def process_service(service_name, service_dict, mapping): - if not isinstance(service_dict, dict): - raise ConfigurationError( - 'Service "%s" doesn\'t have any configuration options. ' - 'All top level keys in your docker-compose.yml must map ' - 'to a dictionary of configuration options.' % service_name - ) - return dict( (key, interpolate_value(service_name, key, val, mapping)) for (key, val) in service_dict.items() diff --git a/compose/config/validation.py b/compose/config/validation.py index 2928238c34d..38866b0f4fe 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -66,21 +66,38 @@ def format_boolean_in_environment(instance): return True -def validate_service_names(config): - for service_name in config.keys(): +def validate_top_level_service_objects(config_file): + """Perform some high level validation of the service name and value. + + 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. + """ + for service_name, service_dict in config_file.config.items(): if not isinstance(service_name, six.string_types): raise ConfigurationError( - "Service name: {} needs to be a string, eg '{}'".format( + "In file '{}' service name: {} needs to be a string, eg '{}'".format( + config_file.filename, service_name, service_name)) + if not isinstance(service_dict, dict): + 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( + config_file.filename, + service_name)) + -def validate_top_level_object(config): - if not isinstance(config, dict): +def validate_top_level_object(config_file): + if not isinstance(config_file.config, dict): raise ConfigurationError( - "Top level object needs to be a dictionary. Check your .yml file " - "that you have defined a service at the top level.") - validate_service_names(config) + "Top level object in '{}' needs to be an object not '{}'. Check " + "that you have defined a service at the top level.".format( + config_file.filename, + type(config_file.config))) + validate_top_level_service_objects(config_file) def validate_extends_file_path(service_name, extends_options, filename): @@ -252,26 +269,28 @@ def format_error_message(error, service_name): return '\n'.join(format_error_message(error, service_name) for error in errors) -def validate_against_fields_schema(config): - return _validate_against_schema( +def validate_against_fields_schema(config, filename): + _validate_against_schema( config, "fields_schema.json", - ["ports", "environment"]) + format_checker=["ports", "environment"], + filename=filename) def validate_against_service_schema(config, service_name): - return _validate_against_schema( + _validate_against_schema( config, "service_schema.json", - ["ports"], - service_name) + format_checker=["ports"], + service_name=service_name) def _validate_against_schema( config, schema_filename, format_checker=(), - service_name=None): + service_name=None, + filename=None): config_source_dir = os.path.dirname(os.path.abspath(__file__)) if sys.platform == "win32": @@ -293,6 +312,11 @@ def _validate_against_schema( format_checker=FormatChecker(format_checker)) errors = [error for error in sorted(validation_output.iter_errors(config), key=str)] - if errors: - error_msg = process_errors(errors, service_name) - raise ConfigurationError("Validation failed, reason(s):\n{}".format(error_msg)) + if not errors: + return + + error_msg = process_errors(errors, service_name) + file_msg = " in file '{}'".format(filename) if filename else '' + raise ConfigurationError("Validation failed{}, reason(s):\n{}".format( + file_msg, + error_msg)) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 9abc58e47fb..84ed4943970 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -94,6 +94,7 @@ def test_load_with_invalid_field_name(self): 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() def test_load_invalid_service_definition(self): config_details = build_config_details( @@ -102,11 +103,12 @@ def test_load_invalid_service_definition(self): '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' doesn't have any configuration options" assert error_msg in exc.exconly() def test_config_integer_service_name_raise_validation_error(self): - expected_error_msg = "Service name: 1 needs to be a string, eg '1'" + expected_error_msg = ("In file 'filename.yml' service name: 1 needs to " + "be a string, eg '1'") with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): config.load( build_config_details( @@ -156,25 +158,26 @@ def test_load_with_multiple_files(self): def test_load_with_multiple_files_and_empty_override(self): base_file = config.ConfigFile( - 'base.yaml', + 'base.yml', {'web': {'image': 'example/web'}}) - override_file = config.ConfigFile('override.yaml', None) + override_file = config.ConfigFile('override.yml', None) details = config.ConfigDetails('.', [base_file, override_file]) with pytest.raises(ConfigurationError) as exc: config.load(details) - assert 'Top level object needs to be a dictionary' in exc.exconly() + error_msg = "Top level object in 'override.yml' needs to be an object" + assert error_msg in exc.exconly() def test_load_with_multiple_files_and_empty_base(self): - base_file = config.ConfigFile('base.yaml', None) + base_file = config.ConfigFile('base.yml', None) override_file = config.ConfigFile( - 'override.yaml', + 'override.yml', {'web': {'image': 'example/web'}}) details = config.ConfigDetails('.', [base_file, override_file]) with pytest.raises(ConfigurationError) as exc: config.load(details) - assert 'Top level object needs to be a dictionary' in exc.exconly() + assert "Top level object in 'base.yml' needs to be an object" in exc.exconly() def test_load_with_multiple_files_and_extends_in_override_file(self): base_file = config.ConfigFile( @@ -225,17 +228,17 @@ def test_load_with_multiple_files_and_invalid_override(self): with pytest.raises(ConfigurationError) as exc: config.load(details) - assert 'Service "bogus" doesn\'t have any configuration' in exc.exconly() + assert "service 'bogus' doesn't have any configuration" in exc.exconly() + assert "In file 'override.yaml'" in exc.exconly() def test_config_valid_service_names(self): for valid_name in ['_', '-', '.__.', '_what-up.', 'what_.up----', 'whatup']: - config.load( + services = config.load( build_config_details( {valid_name: {'image': 'busybox'}}, 'tests/fixtures/extends', - 'common.yml' - ) - ) + 'common.yml')) + assert services[0]['name'] == valid_name def test_config_invalid_ports_format_validation(self): expected_error_msg = "Service 'web' configuration key 'ports' contains an invalid type" @@ -300,7 +303,8 @@ def test_invalid_config_type_should_be_an_array(self): ) def test_invalid_config_not_a_dictionary(self): - expected_error_msg = "Top level object needs to be a dictionary." + expected_error_msg = ("Top level object in 'filename.yml' needs to be " + "an object.") with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): config.load( build_config_details( @@ -382,12 +386,13 @@ def test_config_extra_hosts_list_of_dicts_validation_error(self): ) def test_config_ulimits_invalid_keys_validation_error(self): - expected_error_msg = "Service 'web' configuration key 'ulimits' contains unsupported option: 'not_soft_or_hard'" + expected = ("Service 'web' configuration key 'ulimits' 'nofile' contains " + "unsupported option: 'not_soft_or_hard'") - with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): - config.load( - build_config_details( - {'web': { + with pytest.raises(ConfigurationError) as exc: + config.load(build_config_details( + { + 'web': { 'image': 'busybox', 'ulimits': { 'nofile': { @@ -396,50 +401,43 @@ def test_config_ulimits_invalid_keys_validation_error(self): "hard": 20000, } } - }}, - 'working_dir', - 'filename.yml' - ) - ) + } + }, + 'working_dir', + 'filename.yml')) + assert expected in exc.exconly() def test_config_ulimits_required_keys_validation_error(self): - expected_error_msg = "Service 'web' configuration key 'ulimits' u?'hard' is a required property" - with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): - config.load( - build_config_details( - {'web': { + with pytest.raises(ConfigurationError) as exc: + config.load(build_config_details( + { + 'web': { 'image': 'busybox', - 'ulimits': { - 'nofile': { - "soft": 10000, - } - } - }}, - 'working_dir', - 'filename.yml' - ) - ) + 'ulimits': {'nofile': {"soft": 10000}} + } + }, + 'working_dir', + 'filename.yml')) + assert "Service 'web' configuration key 'ulimits' 'nofile'" in exc.exconly() + assert "'hard' is a required property" in exc.exconly() def test_config_ulimits_soft_greater_than_hard_error(self): - expected_error_msg = "cannot contain a 'soft' value higher than 'hard' value" + expected = "cannot contain a 'soft' value higher than 'hard' value" - with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): - config.load( - build_config_details( - {'web': { + with pytest.raises(ConfigurationError) as exc: + config.load(build_config_details( + { + 'web': { 'image': 'busybox', 'ulimits': { - 'nofile': { - "soft": 10000, - "hard": 1000 - } + 'nofile': {"soft": 10000, "hard": 1000} } - }}, - 'working_dir', - 'filename.yml' - ) - ) + } + }, + 'working_dir', + 'filename.yml')) + assert expected in exc.exconly() def test_valid_config_which_allows_two_type_definitions(self): expose_values = [["8000"], [8000]] From 9c305ac10f6c6900e432602ccefa5e0bdd83f9e1 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 12 Nov 2015 13:26:13 -0500 Subject: [PATCH 1494/4072] Remove name field from the list of ALLOWED_KEYS Signed-off-by: Daniel Nephin --- compose/config/config.py | 1 - 1 file changed, 1 deletion(-) diff --git a/compose/config/config.py b/compose/config/config.py index 2c1fdeb9cec..201266208a2 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -65,7 +65,6 @@ 'dockerfile', 'expose', 'external_links', - 'name', ] From 718ae13ae1702ca5766a79b395c939023b7c15db Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 28 Oct 2015 13:16:19 -0400 Subject: [PATCH 1495/4072] Move config hash tests to service_test.py Signed-off-by: Daniel Nephin --- tests/integration/service_test.py | 37 +++++++++++++++++++++++++++++++ tests/integration/state_test.py | 36 ------------------------------ 2 files changed, 37 insertions(+), 36 deletions(-) diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 4ac04545e1c..df9190e7092 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -14,6 +14,7 @@ from .testcases import DockerClientTestCase from .testcases import pull_busybox from compose import __version__ +from compose.const import LABEL_CONFIG_HASH from compose.const import LABEL_CONTAINER_NUMBER from compose.const import LABEL_ONE_OFF from compose.const import LABEL_PROJECT @@ -23,6 +24,7 @@ from compose.service import build_extra_hosts from compose.service import ConfigError from compose.service import ConvergencePlan +from compose.service import ConvergenceStrategy from compose.service import Net from compose.service import Service from compose.service import VolumeFromSpec @@ -929,3 +931,38 @@ def test_duplicate_containers(self): self.assertEqual(set(service.containers(stopped=True)), set([original, duplicate])) self.assertEqual(set(service.duplicate_containers()), set([duplicate])) + + +def converge(service, + strategy=ConvergenceStrategy.changed, + do_build=True): + """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) + + +class ConfigHashTest(DockerClientTestCase): + def test_no_config_hash_when_one_off(self): + web = self.create_service('web') + container = web.create_container(one_off=True) + self.assertNotIn(LABEL_CONFIG_HASH, container.labels) + + def test_no_config_hash_when_overriding_options(self): + web = self.create_service('web') + container = web.create_container(environment={'FOO': '1'}) + self.assertNotIn(LABEL_CONFIG_HASH, container.labels) + + def test_config_hash_with_custom_labels(self): + web = self.create_service('web', labels={'foo': '1'}) + container = converge(web)[0] + self.assertIn(LABEL_CONFIG_HASH, container.labels) + self.assertIn('foo', container.labels) + + def test_config_hash_sticks_around(self): + web = self.create_service('web', command=["top"]) + container = converge(web)[0] + self.assertIn(LABEL_CONFIG_HASH, container.labels) + + web = self.create_service('web', command=["top", "-d", "1"]) + container = converge(web)[0] + self.assertIn(LABEL_CONFIG_HASH, container.labels) diff --git a/tests/integration/state_test.py b/tests/integration/state_test.py index 3230aefc61a..cb9045726fc 100644 --- a/tests/integration/state_test.py +++ b/tests/integration/state_test.py @@ -10,7 +10,6 @@ from .testcases import DockerClientTestCase from compose.config import config -from compose.const import LABEL_CONFIG_HASH from compose.project import Project from compose.service import ConvergenceStrategy @@ -180,14 +179,6 @@ def test_service_removed_while_down(self): self.assertEqual(len(containers), 2) -def converge(service, - strategy=ConvergenceStrategy.changed, - do_build=True): - """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) - - class ServiceStateTest(DockerClientTestCase): """Test cases for Service.convergence_plan.""" @@ -278,30 +269,3 @@ def test_trigger_recreate_with_build(self): self.assertEqual(('recreate', [container]), web.convergence_plan()) finally: shutil.rmtree(context) - - -class ConfigHashTest(DockerClientTestCase): - def test_no_config_hash_when_one_off(self): - web = self.create_service('web') - container = web.create_container(one_off=True) - self.assertNotIn(LABEL_CONFIG_HASH, container.labels) - - def test_no_config_hash_when_overriding_options(self): - web = self.create_service('web') - container = web.create_container(environment={'FOO': '1'}) - self.assertNotIn(LABEL_CONFIG_HASH, container.labels) - - def test_config_hash_with_custom_labels(self): - web = self.create_service('web', labels={'foo': '1'}) - container = converge(web)[0] - self.assertIn(LABEL_CONFIG_HASH, container.labels) - self.assertIn('foo', container.labels) - - def test_config_hash_sticks_around(self): - web = self.create_service('web', command=["top"]) - container = converge(web)[0] - self.assertIn(LABEL_CONFIG_HASH, container.labels) - - web = self.create_service('web', command=["top", "-d", "1"]) - container = converge(web)[0] - self.assertIn(LABEL_CONFIG_HASH, container.labels) From 23d4eda2a5de23a72842132f606e545811c93a85 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 3 Nov 2015 20:00:54 -0500 Subject: [PATCH 1496/4072] Fix service recreate when image changes to build. Signed-off-by: Daniel Nephin --- compose/service.py | 13 ++++--- tests/integration/state_test.py | 65 ++++++++++++++++++-------------- tests/unit/config/config_test.py | 2 + 3 files changed, 46 insertions(+), 34 deletions(-) diff --git a/compose/service.py b/compose/service.py index 66c90b0e035..17746c3b519 100644 --- a/compose/service.py +++ b/compose/service.py @@ -418,6 +418,7 @@ def execute_convergence_plan(self, return [ self.recreate_container( container, + do_build=do_build, timeout=timeout, attach_logs=should_attach_logs ) @@ -439,10 +440,12 @@ def execute_convergence_plan(self, else: raise Exception("Invalid action: {}".format(action)) - def recreate_container(self, - container, - timeout=DEFAULT_TIMEOUT, - attach_logs=False): + def recreate_container( + self, + container, + do_build=False, + timeout=DEFAULT_TIMEOUT, + attach_logs=False): """Recreate a container. The original container is renamed to a temporary name so that data @@ -454,7 +457,7 @@ def recreate_container(self, container.stop(timeout=timeout) container.rename_to_tmp_name() new_container = self.create_container( - do_build=False, + do_build=do_build, previous_container=container, number=container.labels.get(LABEL_CONTAINER_NUMBER), quiet=True, diff --git a/tests/integration/state_test.py b/tests/integration/state_test.py index cb9045726fc..7830ba32cf1 100644 --- a/tests/integration/state_test.py +++ b/tests/integration/state_test.py @@ -4,9 +4,7 @@ """ from __future__ import unicode_literals -import os -import shutil -import tempfile +import py from .testcases import DockerClientTestCase from compose.config import config @@ -232,40 +230,49 @@ def test_trigger_recreate_with_image_change(self): image_id = self.client.images(name='busybox')[0]['Id'] self.client.tag(image_id, repository=repo, tag=tag) + self.addCleanup(self.client.remove_image, image) - try: - web = self.create_service('web', image=image) - container = web.create_container() - - # update the image - c = self.client.create_container(image, ['touch', '/hello.txt']) - self.client.commit(c, repository=repo, tag=tag) - self.client.remove_container(c) + web = self.create_service('web', image=image) + container = web.create_container() - web = self.create_service('web', image=image) - self.assertEqual(('recreate', [container]), web.convergence_plan()) + # update the image + c = self.client.create_container(image, ['touch', '/hello.txt']) + self.client.commit(c, repository=repo, tag=tag) + self.client.remove_container(c) - finally: - self.client.remove_image(image) + web = self.create_service('web', image=image) + self.assertEqual(('recreate', [container]), web.convergence_plan()) def test_trigger_recreate_with_build(self): - context = tempfile.mkdtemp() + context = py.test.ensuretemp('test_trigger_recreate_with_build') + self.addCleanup(context.remove) + base_image = "FROM busybox\nLABEL com.docker.compose.test_image=true\n" + dockerfile = context.join('Dockerfile') + dockerfile.write(base_image) - try: - dockerfile = os.path.join(context, 'Dockerfile') + web = self.create_service('web', build=str(context)) + container = web.create_container() + + dockerfile.write(base_image + 'CMD echo hello world\n') + web.build() - with open(dockerfile, 'w') as f: - f.write(base_image) + web = self.create_service('web', build=str(context)) + self.assertEqual(('recreate', [container]), web.convergence_plan()) - web = self.create_service('web', build=context) - container = web.create_container() + def test_image_changed_to_build(self): + context = py.test.ensuretemp('test_image_changed_to_build') + self.addCleanup(context.remove) + context.join('Dockerfile').write(""" + FROM busybox + LABEL com.docker.compose.test_image=true + """) - with open(dockerfile, 'w') as f: - f.write(base_image + 'CMD echo hello world\n') - web.build() + web = self.create_service('web', image='busybox') + container = web.create_container() - web = self.create_service('web', build=context) - self.assertEqual(('recreate', [container]), web.convergence_plan()) - finally: - shutil.rmtree(context) + web = self.create_service('web', build=str(context)) + plan = web.convergence_plan() + self.assertEqual(('recreate', [container]), plan) + containers = web.execute_convergence_plan(plan) + self.assertEqual(len(containers), 1) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 2835e9c805c..fc5e22bf2b6 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -177,6 +177,7 @@ def test_load_with_multiple_files_and_extends_in_override_file(self): details = config.ConfigDetails('.', [base_file, override_file]) tmpdir = py.test.ensuretemp('config_test') + self.addCleanup(tmpdir.remove) tmpdir.join('common.yml').write(""" base: labels: ['label=one'] @@ -412,6 +413,7 @@ def test_config_invalid_environment_dict_key_raises_validation_error(self): def test_load_yaml_with_yaml_error(self): tmpdir = py.test.ensuretemp('invalid_yaml_test') + self.addCleanup(tmpdir.remove) invalid_yaml_file = tmpdir.join('docker-compose.yml') invalid_yaml_file.write(""" web: From cf933623682f5483ea41fbbb7cab5bca2402b996 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 9 Nov 2015 17:24:21 +0000 Subject: [PATCH 1497/4072] Fix parallel output We were outputting an extra line, which in *some* cases, on *some* terminals, was causing the output of parallel actions to get messed up. In particular, it would happen when the terminal had just been cleared or hadn't yet filled up with a screen's worth of text. Signed-off-by: Aanand Prasad --- compose/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/utils.py b/compose/utils.py index c8fddc5f162..14cca61b0d3 100644 --- a/compose/utils.py +++ b/compose/utils.py @@ -164,7 +164,7 @@ def write_out_msg(stream, lines, msg_index, msg, status="done"): stream.write("%c[%dA" % (27, diff)) # erase stream.write("%c[2K\r" % 27) - stream.write("{} {} ... {}\n".format(msg, obj_index, status)) + stream.write("{} {} ... {}\r".format(msg, obj_index, status)) # move back down stream.write("%c[%dB" % (27, diff)) else: From 3daecfa8e434966b00214aade861aafdb0236c3b Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 9 Nov 2015 13:07:26 -0800 Subject: [PATCH 1498/4072] Update service config_dict computation to include volumes_from mode Ensure config_hash is updated when volumes_from mode is changed, and service is recreated on next up as a result. Signed-off-by: Joffrey F --- compose/service.py | 4 +++- tests/unit/service_test.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/compose/service.py b/compose/service.py index 17746c3b519..d841c0cc67a 100644 --- a/compose/service.py +++ b/compose/service.py @@ -511,7 +511,9 @@ def config_dict(self): 'image_id': self.image()['Id'], 'links': self.get_link_names(), 'net': self.net.id, - 'volumes_from': self.get_volumes_from_names(), + 'volumes_from': [ + (v.source.name, v.mode) for v in self.volumes_from if isinstance(v.source, Service) + ], } def get_dependency_names(self): diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index d86f80f7300..c77c6a3642c 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -417,7 +417,7 @@ def test_config_dict(self): 'options': {'image': 'example.com/foo'}, 'links': [('one', 'one')], 'net': 'other', - 'volumes_from': ['two'], + 'volumes_from': [('two', 'rw')], } self.assertEqual(config_dict, expected) From e317d2db9dbe4a6fea5ceda4d5c0bc67a88b9d3e Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 2 Nov 2015 13:33:13 -0500 Subject: [PATCH 1499/4072] Remove service.start_container() It has been an unnecessary wrapper around container.start() for a little while now, so we can call it directly. Signed-off-by: Daniel Nephin --- compose/cli/main.py | 2 +- compose/service.py | 13 ++++--------- tests/integration/cli_test.py | 2 +- tests/integration/resilience_test.py | 4 ++-- tests/integration/service_test.py | 27 ++++++++++++++------------- 5 files changed, 22 insertions(+), 26 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index b54b307ef20..11aeac38c20 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -448,7 +448,7 @@ def run(self, project, options): raise e if detach: - service.start_container(container) + container.start() print(container.name) else: dockerpty.start(project.client, container.id, interactive=not options['-T']) diff --git a/compose/service.py b/compose/service.py index d841c0cc67a..19aa7838074 100644 --- a/compose/service.py +++ b/compose/service.py @@ -410,7 +410,7 @@ def execute_convergence_plan(self, if should_attach_logs: container.attach_log_stream() - self.start_container(container) + container.start() return [container] @@ -464,21 +464,16 @@ def recreate_container( ) if attach_logs: new_container.attach_log_stream() - self.start_container(new_container) + new_container.start() container.remove() return new_container def start_container_if_stopped(self, container, attach_logs=False): - if container.is_running: - return container - else: + if not container.is_running: log.info("Starting %s" % container.name) if attach_logs: container.attach_log_stream() - return self.start_container(container) - - def start_container(self, container): - container.start() + container.start() return container def remove_duplicate_containers(self, timeout=DEFAULT_TIMEOUT): diff --git a/tests/integration/cli_test.py b/tests/integration/cli_test.py index d621f2d1320..e8f0df8cc22 100644 --- a/tests/integration/cli_test.py +++ b/tests/integration/cli_test.py @@ -599,7 +599,7 @@ def test_kill_stopped_service(self): def test_restart(self): service = self.project.get_service('simple') container = service.create_container() - service.start_container(container) + container.start() started_at = container.dictionary['State']['StartedAt'] self.command.dispatch(['restart', '-t', '1'], None) container.inspect() diff --git a/tests/integration/resilience_test.py b/tests/integration/resilience_test.py index befd72c7f82..53aedfecf2c 100644 --- a/tests/integration/resilience_test.py +++ b/tests/integration/resilience_test.py @@ -13,7 +13,7 @@ def setUp(self): self.project = Project('composetest', [self.db], self.client) container = self.db.create_container() - self.db.start_container(container) + container.start() self.host_path = container.get('Volumes')['/var/db'] def test_successful_recreate(self): @@ -31,7 +31,7 @@ def test_create_failure(self): self.assertEqual(container.get('Volumes')['/var/db'], self.host_path) def test_start_failure(self): - with mock.patch('compose.service.Service.start_container', crash): + with mock.patch('compose.container.Container.start', crash): with self.assertRaises(Crash): self.project.up(strategy=ConvergenceStrategy.always) diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index df9190e7092..804f5219af5 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -32,7 +32,8 @@ def create_and_start_container(service, **override_options): container = service.create_container(**override_options) - return service.start_container(container) + container.start() + return container class ServiceTest(DockerClientTestCase): @@ -117,19 +118,19 @@ def test_create_container_with_one_off_when_existing_container_is_running(self): def test_create_container_with_unspecified_volume(self): service = self.create_service('db', volumes=['/var/db']) container = service.create_container() - service.start_container(container) + container.start() self.assertIn('/var/db', container.get('Volumes')) def test_create_container_with_volume_driver(self): service = self.create_service('db', volume_driver='foodriver') container = service.create_container() - service.start_container(container) + container.start() self.assertEqual('foodriver', container.get('Config.VolumeDriver')) def test_create_container_with_cpu_shares(self): service = self.create_service('db', cpu_shares=73) container = service.create_container() - service.start_container(container) + container.start() self.assertEqual(container.get('HostConfig.CpuShares'), 73) def test_build_extra_hosts(self): @@ -167,7 +168,7 @@ 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) container = service.create_container() - service.start_container(container) + container.start() self.assertEqual(set(container.get('HostConfig.ExtraHosts')), set(extra_hosts)) def test_create_container_with_extra_hosts_dicts(self): @@ -175,33 +176,33 @@ def test_create_container_with_extra_hosts_dicts(self): extra_hosts_list = ['somehost:162.242.195.82', 'otherhost:50.31.209.229'] service = self.create_service('db', extra_hosts=extra_hosts) container = service.create_container() - service.start_container(container) + container.start() self.assertEqual(set(container.get('HostConfig.ExtraHosts')), set(extra_hosts_list)) def test_create_container_with_cpu_set(self): service = self.create_service('db', cpuset='0') container = service.create_container() - service.start_container(container) + container.start() self.assertEqual(container.get('HostConfig.CpusetCpus'), '0') def test_create_container_with_read_only_root_fs(self): read_only = True service = self.create_service('db', read_only=read_only) container = service.create_container() - service.start_container(container) + container.start() self.assertEqual(container.get('HostConfig.ReadonlyRootfs'), read_only, container.get('HostConfig')) def test_create_container_with_security_opt(self): security_opt = ['label:disable'] service = self.create_service('db', security_opt=security_opt) container = service.create_container() - service.start_container(container) + container.start() self.assertEqual(set(container.get('HostConfig.SecurityOpt')), set(security_opt)) def test_create_container_with_mac_address(self): service = self.create_service('db', mac_address='02:42:ac:11:65:43') container = service.create_container() - service.start_container(container) + container.start() self.assertEqual(container.inspect()['Config']['MacAddress'], '02:42:ac:11:65:43') def test_create_container_with_specified_volume(self): @@ -210,7 +211,7 @@ def test_create_container_with_specified_volume(self): service = self.create_service('db', volumes=['%s:%s' % (host_path, container_path)]) container = service.create_container() - service.start_container(container) + container.start() volumes = container.inspect()['Volumes'] self.assertIn(container_path, volumes) @@ -283,7 +284,7 @@ def test_create_container_with_volumes_from(self): ] ) host_container = host_service.create_container() - host_service.start_container(host_container) + host_container.start() self.assertIn(volume_container_1.id + ':rw', host_container.get('HostConfig.VolumesFrom')) self.assertIn(volume_container_2.id + ':rw', @@ -302,7 +303,7 @@ def test_execute_convergence_plan_recreate(self): self.assertEqual(old_container.get('Config.Cmd'), ['-d', '1']) self.assertIn('FOO=1', old_container.get('Config.Env')) self.assertEqual(old_container.name, 'composetest_db_1') - service.start_container(old_container) + old_container.start() old_container.inspect() # reload volume data volume_path = old_container.get('Volumes')['/etc'] From 3c4bb5358e5ebee180c62a7d4d7cfa329a607f59 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 5 Nov 2015 10:26:54 -0500 Subject: [PATCH 1500/4072] Upgrade pyyaml to 3.11 Signed-off-by: Daniel Nephin --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index daaaa950262..60327d728de 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -PyYAML==3.10 +PyYAML==3.11 docker-py==1.5.0 dockerpty==0.3.4 docopt==0.6.1 From 0375dccf640c1f94482da4f3f8f7acfc6a924700 Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Tue, 27 Oct 2015 17:37:48 +0000 Subject: [PATCH 1501/4072] Handle non-ascii chars in volume directories Signed-off-by: Mazz Mosley --- compose/config/config.py | 2 +- tests/unit/config/config_test.py | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/compose/config/config.py b/compose/config/config.py index 21549e9b347..f664ec8d5d8 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -501,7 +501,7 @@ def resolve_volume_path(volume, working_dir, service_name): if host_path.startswith('.'): host_path = expand_path(working_dir, host_path) host_path = os.path.expanduser(host_path) - return "{}:{}".format(host_path, container_path) + return u"{}:{}".format(host_path, container_path) else: return container_path diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index fc5e22bf2b6..69b2358525f 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -575,6 +575,11 @@ def test_home_directory_with_driver_does_not_expand(self): }, working_dir='.') self.assertEqual(d['volumes'], ['~:/data']) + def test_volume_path_with_non_ascii_directory(self): + volume = u'/Füü/data:/data' + container_path = config.resolve_volume_path(volume, ".", "test") + self.assertEqual(container_path, volume) + class MergePathMappingTest(object): def config_name(self): From a5959d9be2167b5a9d671ca5611955eb34c20549 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 19 Oct 2015 12:52:38 -0400 Subject: [PATCH 1502/4072] Some minor style cleanup - fixed a docstring to make it PEP257 compliant - wrapped some long lines - used a more specific error Signed-off-by: Daniel Nephin --- compose/config/config.py | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index f664ec8d5d8..b9d71e9d782 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -213,9 +213,16 @@ def merge_services(base, override): class ServiceLoader(object): - def __init__(self, working_dir, filename, service_name, service_dict, already_seen=None): + def __init__( + self, + working_dir, + filename, + service_name, + service_dict, + already_seen=None + ): if working_dir is None: - raise Exception("No working_dir passed to ServiceLoader()") + raise ValueError("No working_dir passed to ServiceLoader()") self.working_dir = os.path.abspath(working_dir) @@ -312,33 +319,33 @@ def resolve_extends(self): return merge_service_dicts(other_service_dict, self.service_dict) def get_extended_config_path(self, extends_options): - """ - Service we are extending either has a value for 'file' set, which we + """Service we are extending either has a value for 'file' set, which we need to obtain a full path too or we are extending from a service defined in our own file. """ if 'file' in extends_options: - extends_from_filename = extends_options['file'] - return expand_path(self.working_dir, extends_from_filename) - + return expand_path(self.working_dir, extends_options['file']) return self.filename def signature(self, name): - return (self.filename, name) + return self.filename, name def validate_extended_service_dict(service_dict, filename, service): error_prefix = "Cannot extend service '%s' in %s:" % (service, filename) if 'links' in service_dict: - raise ConfigurationError("%s services with 'links' cannot be extended" % error_prefix) + raise ConfigurationError( + "%s services with 'links' cannot be extended" % error_prefix) if 'volumes_from' in service_dict: - raise ConfigurationError("%s services with 'volumes_from' cannot be extended" % error_prefix) + raise ConfigurationError( + "%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: - raise ConfigurationError("%s services with 'net: container' cannot be extended" % error_prefix) + raise ConfigurationError( + "%s services with 'net: container' cannot be extended" % error_prefix) def process_container_options(service_dict, working_dir=None): From 805ed344c010df3a0aa82acfd7c15beec7fbdbc3 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 19 Oct 2015 13:40:13 -0400 Subject: [PATCH 1503/4072] Refactor ServiceLoader to be immutable. Mutable objects are harder to debug and harder to reason about. ServiceLoader was almost immutable. There was just a single function which set fields for a second function. Instead of mutating the object, we can pass those values as parameters to the next function. Signed-off-by: Daniel Nephin --- compose/config/config.py | 89 +++++++++++++++++++--------------------- 1 file changed, 43 insertions(+), 46 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index b9d71e9d782..5bc534fe9b2 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -240,80 +240,61 @@ def detect_cycle(self, name): raise CircularReference(self.already_seen + [self.signature(name)]) def make_service_dict(self): - self.resolve_environment() - if 'extends' in self.service_dict: - self.validate_and_construct_extends() - self.service_dict = self.resolve_extends() + service_dict = dict(self.service_dict) + env = resolve_environment(self.working_dir, self.service_dict) + if env: + service_dict['environment'] = env + service_dict.pop('env_file', None) - if not self.already_seen: - validate_against_service_schema(self.service_dict, self.service_name) - - return process_container_options(self.service_dict, working_dir=self.working_dir) - - def resolve_environment(self): - """ - Unpack any environment variables from an env_file, if set. - Interpolate environment values if set. - """ - if 'environment' not in self.service_dict and 'env_file' not in self.service_dict: - return + if 'extends' in service_dict: + service_dict = self.resolve_extends(*self.validate_and_construct_extends()) - env = {} - - if 'env_file' in self.service_dict: - for f in get_env_files(self.service_dict, working_dir=self.working_dir): - env.update(env_vars_from_file(f)) - del self.service_dict['env_file'] - - env.update(parse_environment(self.service_dict.get('environment'))) - env = dict(resolve_env_var(k, v) for k, v in six.iteritems(env)) + if not self.already_seen: + validate_against_service_schema(service_dict, self.service_name) - self.service_dict['environment'] = env + return process_container_options(service_dict, working_dir=self.working_dir) def validate_and_construct_extends(self): extends = self.service_dict['extends'] if not isinstance(extends, dict): extends = {'service': extends} - validate_extends_file_path( - self.service_name, - extends, - self.filename - ) - self.extended_config_path = self.get_extended_config_path(extends) - self.extended_service_name = extends['service'] + validate_extends_file_path(self.service_name, extends, self.filename) + config_path = self.get_extended_config_path(extends) + service_name = extends['service'] - config = load_yaml(self.extended_config_path) + config = load_yaml(config_path) validate_top_level_object(config) full_extended_config = interpolate_environment_variables(config) validate_extended_service_exists( - self.extended_service_name, + service_name, full_extended_config, - self.extended_config_path + config_path ) validate_against_fields_schema(full_extended_config) - self.extended_config = full_extended_config[self.extended_service_name] + service_config = full_extended_config[service_name] + return config_path, service_config, service_name - def resolve_extends(self): - other_working_dir = os.path.dirname(self.extended_config_path) + def resolve_extends(self, extended_config_path, service_config, service_name): + other_working_dir = os.path.dirname(extended_config_path) other_already_seen = self.already_seen + [self.signature(self.service_name)] other_loader = ServiceLoader( - working_dir=other_working_dir, - filename=self.extended_config_path, - service_name=self.service_name, - service_dict=self.extended_config, + other_working_dir, + extended_config_path, + self.service_name, + service_config, already_seen=other_already_seen, ) - other_loader.detect_cycle(self.extended_service_name) + other_loader.detect_cycle(service_name) other_service_dict = other_loader.make_service_dict() validate_extended_service_dict( other_service_dict, - filename=self.extended_config_path, - service=self.extended_service_name, + extended_config_path, + service_name, ) return merge_service_dicts(other_service_dict, self.service_dict) @@ -331,6 +312,22 @@ def signature(self, name): return self.filename, name +def resolve_environment(working_dir, service_dict): + """Unpack any environment variables from an env_file, if set. + Interpolate environment values if set. + """ + if 'environment' not in service_dict and 'env_file' not in service_dict: + return {} + + env = {} + if 'env_file' in service_dict: + for env_file in get_env_files(service_dict, working_dir=working_dir): + 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)) + + def validate_extended_service_dict(service_dict, filename, service): error_prefix = "Cannot extend service '%s' in %s:" % (service, filename) From c4f59e731d540780a767d105bcb8d7d164ba4cd5 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 4 Nov 2015 16:37:44 -0500 Subject: [PATCH 1504/4072] Make working_dir consistent in the config package. - make it a positional arg, since it's required - make it the first argument for all functions that require it - remove an unnecessary one-line function that was only called in one place Signed-off-by: Daniel Nephin --- compose/config/config.py | 33 ++++++++++++-------------------- tests/unit/config/config_test.py | 2 +- 2 files changed, 13 insertions(+), 22 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 5bc534fe9b2..141fa89df4d 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -252,7 +252,7 @@ def make_service_dict(self): if not self.already_seen: validate_against_service_schema(service_dict, self.service_name) - return process_container_options(service_dict, working_dir=self.working_dir) + return process_container_options(self.working_dir, service_dict) def validate_and_construct_extends(self): extends = self.service_dict['extends'] @@ -321,7 +321,7 @@ def resolve_environment(working_dir, service_dict): env = {} if 'env_file' in service_dict: - for env_file in get_env_files(service_dict, working_dir=working_dir): + for env_file in get_env_files(working_dir, service_dict): env.update(env_vars_from_file(env_file)) env.update(parse_environment(service_dict.get('environment'))) @@ -345,14 +345,14 @@ def validate_extended_service_dict(service_dict, filename, service): "%s services with 'net: container' cannot be extended" % error_prefix) -def process_container_options(service_dict, working_dir=None): - service_dict = service_dict.copy() +def process_container_options(working_dir, service_dict): + service_dict = dict(service_dict) if 'volumes' in service_dict and service_dict.get('volume_driver') is None: - service_dict['volumes'] = resolve_volume_paths(service_dict, working_dir=working_dir) + service_dict['volumes'] = resolve_volume_paths(working_dir, service_dict) if 'build' in service_dict: - service_dict['build'] = resolve_build_path(service_dict['build'], working_dir=working_dir) + service_dict['build'] = expand_path(working_dir, service_dict['build']) if 'labels' in service_dict: service_dict['labels'] = parse_labels(service_dict['labels']) @@ -428,7 +428,7 @@ def merge_environment(base, override): return env -def get_env_files(options, working_dir=None): +def get_env_files(working_dir, options): if 'env_file' not in options: return {} @@ -488,17 +488,14 @@ def env_vars_from_file(filename): return env -def resolve_volume_paths(service_dict, working_dir=None): - if working_dir is None: - raise Exception("No working_dir passed to resolve_volume_paths()") - +def resolve_volume_paths(working_dir, service_dict): return [ - resolve_volume_path(v, working_dir, service_dict['name']) - for v in service_dict['volumes'] + resolve_volume_path(working_dir, volume, service_dict['name']) + for volume in service_dict['volumes'] ] -def resolve_volume_path(volume, working_dir, service_name): +def resolve_volume_path(working_dir, volume, service_name): container_path, host_path = split_path_mapping(volume) if host_path is not None: @@ -510,12 +507,6 @@ def resolve_volume_path(volume, working_dir, service_name): return container_path -def resolve_build_path(build_path, working_dir=None): - if working_dir is None: - raise Exception("No working_dir passed to resolve_build_path") - return expand_path(working_dir, build_path) - - def validate_paths(service_dict): if 'build' in service_dict: build_path = service_dict['build'] @@ -582,7 +573,7 @@ def parse_labels(labels): return dict(split_label(e) for e in labels) if isinstance(labels, dict): - return labels + return dict(labels) def split_label(label): diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 69b2358525f..e0d2e870b8e 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -577,7 +577,7 @@ def test_home_directory_with_driver_does_not_expand(self): def test_volume_path_with_non_ascii_directory(self): volume = u'/Füü/data:/data' - container_path = config.resolve_volume_path(volume, ".", "test") + container_path = config.resolve_volume_path(".", volume, "test") self.assertEqual(container_path, volume) From e6755d1e7c548ebc5f011fd623c3bba53a3bf4b4 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 4 Nov 2015 16:46:19 -0500 Subject: [PATCH 1505/4072] Use VolumeSpec instead of re-parsing the volume string. Signed-off-by: Daniel Nephin --- compose/service.py | 19 +++++++++++-------- tests/unit/service_test.py | 4 ++-- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/compose/service.py b/compose/service.py index 19aa7838074..a98cf49fefc 100644 --- a/compose/service.py +++ b/compose/service.py @@ -899,14 +899,15 @@ def merge_volume_bindings(volumes_option, previous_container): """Return a list of volume bindings for a container. Container data volumes are replaced by those from the previous container. """ + volumes = [parse_volume_spec(volume) for volume in volumes_option or []] volume_bindings = dict( - build_volume_binding(parse_volume_spec(volume)) - for volume in volumes_option or [] - if ':' in volume) + build_volume_binding(volume) + for volume in volumes + if volume.external) if previous_container: volume_bindings.update( - get_container_data_volumes(previous_container, volumes_option)) + get_container_data_volumes(previous_container, volumes)) return list(volume_bindings.values()) @@ -917,12 +918,14 @@ def get_container_data_volumes(container, volumes_option): """ volumes = [] - volumes_option = volumes_option or [] container_volumes = container.get('Volumes') or {} - image_volumes = container.image_config['ContainerConfig'].get('Volumes') or {} + image_volumes = [ + parse_volume_spec(volume) + for volume in + container.image_config['ContainerConfig'].get('Volumes') or {} + ] - for volume in set(volumes_option + list(image_volumes)): - volume = parse_volume_spec(volume) + for volume in set(volumes_option + image_volumes): # No need to preserve host volumes if volume.external: continue diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index c77c6a3642c..1a28ddf1f8d 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -545,11 +545,11 @@ def test_build_volume_binding(self): self.assertEqual(binding, ('/inside', '/outside:/inside:rw')) def test_get_container_data_volumes(self): - options = [ + options = [parse_volume_spec(v) for v in [ '/host/volume:/host/volume:ro', '/new/volume', '/existing/volume', - ] + ]] self.mock_client.inspect_image.return_value = { 'ContainerConfig': { From 3f14df374fa5e47558de2d376f69194033817407 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 4 Nov 2015 15:54:59 -0500 Subject: [PATCH 1506/4072] Handle non-utf8 unicode without raising an error. Signed-off-by: Daniel Nephin --- compose/config/config.py | 2 +- compose/utils.py | 2 +- tests/unit/utils_test.py | 16 ++++++++++++++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 141fa89df4d..434589d312f 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -457,7 +457,7 @@ def parse_environment(environment): def split_env(env): if isinstance(env, six.binary_type): - env = env.decode('utf-8') + env = env.decode('utf-8', 'replace') if '=' in env: return env.split('=', 1) else: diff --git a/compose/utils.py b/compose/utils.py index 14cca61b0d3..2c6c4584d29 100644 --- a/compose/utils.py +++ b/compose/utils.py @@ -95,7 +95,7 @@ def stream_as_text(stream): """ for data in stream: if not isinstance(data, six.text_type): - data = data.decode('utf-8') + data = data.decode('utf-8', 'replace') yield data diff --git a/tests/unit/utils_test.py b/tests/unit/utils_test.py index b272c7349a8..e3d0bc00b5e 100644 --- a/tests/unit/utils_test.py +++ b/tests/unit/utils_test.py @@ -1,3 +1,6 @@ +# encoding: utf-8 +from __future__ import unicode_literals + from .. import unittest from compose import utils @@ -14,3 +17,16 @@ def test_json_splitter_with_object(self): utils.json_splitter(data), ({'foo': 'bar'}, '{"next": "obj"}') ) + + +class StreamAsTextTestCase(unittest.TestCase): + + def test_stream_with_non_utf_unicode_character(self): + stream = [b'\xed\xf3\xf3'] + output, = utils.stream_as_text(stream) + assert output == '���' + + def test_stream_with_utf_character(self): + stream = ['ěĝ'.encode('utf-8')] + output, = utils.stream_as_text(stream) + assert output == 'ěĝ' From ba61a6c5fbad38a6fc157f7c19003feea0be32e4 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 5 Nov 2015 17:50:32 -0500 Subject: [PATCH 1507/4072] Don't set the hostname to the service name with networking. Signed-off-by: Daniel Nephin --- compose/service.py | 3 --- tests/integration/cli_test.py | 1 - tests/unit/service_test.py | 10 ---------- 3 files changed, 14 deletions(-) diff --git a/compose/service.py b/compose/service.py index a98cf49fefc..fc2677791e0 100644 --- a/compose/service.py +++ b/compose/service.py @@ -605,9 +605,6 @@ def _get_container_create_options( container_options['hostname'] = parts[0] container_options['domainname'] = parts[2] - if 'hostname' not in container_options and self.use_networking: - container_options['hostname'] = self.name - if 'ports' in container_options or 'expose' in self.options: ports = [] all_ports = container_options.get('ports', []) + self.options.get('expose', []) diff --git a/tests/integration/cli_test.py b/tests/integration/cli_test.py index e8f0df8cc22..279c97f5f30 100644 --- a/tests/integration/cli_test.py +++ b/tests/integration/cli_test.py @@ -226,7 +226,6 @@ def test_up_with_networking(self): containers = service.containers() self.assertEqual(len(containers), 1) self.assertIn(containers[0].id, network['Containers']) - self.assertEqual(containers[0].get('Config.Hostname'), service.name) web_container = self.project.get_service('web').containers()[0] self.assertFalse(web_container.get('HostConfig.Links')) diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 1a28ddf1f8d..90bad87ac97 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -213,16 +213,6 @@ def test_no_default_hostname_when_not_using_networking(self): opts = service._get_container_create_options({'image': 'foo'}, 1) self.assertIsNone(opts.get('hostname')) - def test_hostname_defaults_to_service_name_when_using_networking(self): - service = Service( - 'foo', - image='foo', - use_networking=True, - client=self.mock_client, - ) - opts = service._get_container_create_options({'image': 'foo'}, 1) - self.assertEqual(opts['hostname'], 'foo') - def test_get_container_create_options_with_name_option(self): service = Service( 'foo', From 886134c1f3ca27f6bb849551a5e5a31e3efc6e07 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 6 Nov 2015 12:29:52 -0500 Subject: [PATCH 1508/4072] Recreate dependents when a dependency is created (not just when it's recreated). Signed-off-by: Daniel Nephin --- compose/project.py | 2 +- tests/integration/state_test.py | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/compose/project.py b/compose/project.py index 1e01eaf6d24..f0478203bb1 100644 --- a/compose/project.py +++ b/compose/project.py @@ -322,7 +322,7 @@ def _get_convergence_plans(self, services, strategy): name for name in service.get_dependency_names() if name in plans - and plans[name].action == 'recreate' + and plans[name].action in ('recreate', 'create') ] if updated_dependencies and strategy.allows_recreate: diff --git a/tests/integration/state_test.py b/tests/integration/state_test.py index 7830ba32cf1..1fecce87b84 100644 --- a/tests/integration/state_test.py +++ b/tests/integration/state_test.py @@ -176,6 +176,19 @@ def test_service_removed_while_down(self): containers = self.run_up(next_cfg) self.assertEqual(len(containers), 2) + def test_service_recreated_when_dependency_created(self): + containers = self.run_up(self.cfg, service_names=['web'], start_deps=False) + self.assertEqual(len(containers), 1) + + containers = self.run_up(self.cfg) + self.assertEqual(len(containers), 3) + + web, = [c for c in containers if c.service == 'web'] + nginx, = [c for c in containers if c.service == 'nginx'] + + self.assertEqual(web.links(), ['composetest_db_1', 'db', 'db_1']) + self.assertEqual(nginx.links(), ['composetest_web_1', 'web', 'web_1']) + class ServiceStateTest(DockerClientTestCase): """Test cases for Service.convergence_plan.""" From 666c3cb1c76e6d69075337e523effce0d93b9e4d Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 10 Nov 2015 10:11:20 -0800 Subject: [PATCH 1509/4072] Use exit code 1 when encountering a ReadTimeout 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 11aeac38c20..95db45cec11 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -80,6 +80,7 @@ def main(): "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) def setup_logging(): From 7f2f4eef48c2cec4f8cf8337c4a9076820cc9e3e Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 2 Nov 2015 16:46:49 -0500 Subject: [PATCH 1510/4072] Update cli tests to use subprocess. Signed-off-by: Daniel Nephin --- tests/integration/cli_test.py | 459 ++++++++++++++++------------------ 1 file changed, 215 insertions(+), 244 deletions(-) diff --git a/tests/integration/cli_test.py b/tests/integration/cli_test.py index 279c97f5f30..7b7537e21e2 100644 --- a/tests/integration/cli_test.py +++ b/tests/integration/cli_test.py @@ -2,30 +2,32 @@ import os import shlex -import sys +import subprocess +from collections import namedtuple from operator import attrgetter -from six import StringIO +import pytest from .. import mock from .testcases import DockerClientTestCase from compose.cli.command import get_project from compose.cli.docker_client import docker_client -from compose.cli.errors import UserError -from compose.cli.main import TopLevelCommand -from compose.project import NoSuchService + + +ProcessResult = namedtuple('ProcessResult', 'stdout stderr') + + +BUILD_CACHE_TEXT = 'Using cache' +BUILD_PULL_TEXT = 'Status: Image is up to date for busybox:latest' class CLITestCase(DockerClientTestCase): + def setUp(self): super(CLITestCase, self).setUp() - self.old_sys_exit = sys.exit - sys.exit = lambda code=0: None - self.command = TopLevelCommand() - self.command.base_dir = 'tests/fixtures/simple-composefile' + self.base_dir = 'tests/fixtures/simple-composefile' def tearDown(self): - sys.exit = self.old_sys_exit self.project.kill() self.project.remove_stopped() for container in self.project.containers(stopped=True, one_off=True): @@ -34,129 +36,121 @@ def tearDown(self): @property def project(self): - # Hack: allow project to be overridden. This needs refactoring so that - # the project object is built exactly once, by the command object, and - # accessed by the test case object. - if hasattr(self, '_project'): - return self._project - - return get_project(self.command.base_dir) + # Hack: allow project to be overridden + if not hasattr(self, '_project'): + self._project = get_project(self.base_dir) + return self._project + + def dispatch(self, options, project_options=None, returncode=0): + project_options = project_options or [] + proc = subprocess.Popen( + ['docker-compose'] + project_options + options, + # Note: this might actually be a patched sys.stdout, so we have + # to specify it here, even though it's the default + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + cwd=self.base_dir) + print("Running process: %s" % proc.pid) + stdout, stderr = proc.communicate() + if proc.returncode != returncode: + print(stderr) + assert proc.returncode == returncode + return ProcessResult(stdout.decode('utf-8'), stderr.decode('utf-8')) def test_help(self): - old_base_dir = self.command.base_dir - self.command.base_dir = 'tests/fixtures/no-composefile' - with self.assertRaises(SystemExit) as exc_context: - self.command.dispatch(['help', 'up'], None) - self.assertIn('Usage: up [options] [SERVICE...]', str(exc_context.exception)) + old_base_dir = self.base_dir + self.base_dir = 'tests/fixtures/no-composefile' + result = self.dispatch(['help', 'up'], returncode=1) + assert 'Usage: up [options] [SERVICE...]' in result.stderr # self.project.kill() fails during teardown # unless there is a composefile. - self.command.base_dir = old_base_dir + self.base_dir = old_base_dir - # TODO: address the "Inappropriate ioctl for device" warnings in test output def test_ps(self): self.project.get_service('simple').create_container() - with mock.patch('sys.stdout', new_callable=StringIO) as mock_stdout: - self.command.dispatch(['ps'], None) - self.assertIn('simplecomposefile_simple_1', mock_stdout.getvalue()) + result = self.dispatch(['ps']) + assert 'simplecomposefile_simple_1' in result.stdout def test_ps_default_composefile(self): - self.command.base_dir = 'tests/fixtures/multiple-composefiles' - with mock.patch('sys.stdout', new_callable=StringIO) as mock_stdout: - self.command.dispatch(['up', '-d'], None) - self.command.dispatch(['ps'], None) + self.base_dir = 'tests/fixtures/multiple-composefiles' + self.dispatch(['up', '-d']) + result = self.dispatch(['ps']) - output = mock_stdout.getvalue() - self.assertIn('multiplecomposefiles_simple_1', output) - self.assertIn('multiplecomposefiles_another_1', output) - self.assertNotIn('multiplecomposefiles_yetanother_1', output) + self.assertIn('multiplecomposefiles_simple_1', result.stdout) + self.assertIn('multiplecomposefiles_another_1', result.stdout) + self.assertNotIn('multiplecomposefiles_yetanother_1', result.stdout) def test_ps_alternate_composefile(self): config_path = os.path.abspath( 'tests/fixtures/multiple-composefiles/compose2.yml') - self._project = get_project(self.command.base_dir, [config_path]) - - self.command.base_dir = 'tests/fixtures/multiple-composefiles' - with mock.patch('sys.stdout', new_callable=StringIO) as mock_stdout: - self.command.dispatch(['-f', 'compose2.yml', 'up', '-d'], None) - self.command.dispatch(['-f', 'compose2.yml', 'ps'], None) - - output = mock_stdout.getvalue() - self.assertNotIn('multiplecomposefiles_simple_1', output) - self.assertNotIn('multiplecomposefiles_another_1', output) - self.assertIn('multiplecomposefiles_yetanother_1', output) - - @mock.patch('compose.service.log') - def test_pull(self, mock_logging): - self.command.dispatch(['pull'], None) - mock_logging.info.assert_any_call('Pulling simple (busybox:latest)...') - mock_logging.info.assert_any_call('Pulling another (busybox:latest)...') - - @mock.patch('compose.service.log') - def test_pull_with_digest(self, mock_logging): - self.command.dispatch(['-f', 'digest.yml', 'pull'], None) - mock_logging.info.assert_any_call('Pulling simple (busybox:latest)...') - mock_logging.info.assert_any_call( - 'Pulling digest (busybox@' - 'sha256:38a203e1986cf79639cfb9b2e1d6e773de84002feea2d4eb006b52004ee8502d)...') - - @mock.patch('compose.service.log') - def test_pull_with_ignore_pull_failures(self, mock_logging): - self.command.dispatch(['-f', 'ignore-pull-failures.yml', 'pull', '--ignore-pull-failures'], None) - mock_logging.info.assert_any_call('Pulling simple (busybox:latest)...') - mock_logging.info.assert_any_call('Pulling another (nonexisting-image:latest)...') - mock_logging.error.assert_any_call('Error: image library/nonexisting-image:latest not found') + self._project = get_project(self.base_dir, [config_path]) - def test_build_plain(self): - self.command.base_dir = 'tests/fixtures/simple-dockerfile' - self.command.dispatch(['build', 'simple'], None) + self.base_dir = 'tests/fixtures/multiple-composefiles' + self.dispatch(['-f', 'compose2.yml', 'up', '-d']) + result = self.dispatch(['-f', 'compose2.yml', 'ps']) + + self.assertNotIn('multiplecomposefiles_simple_1', result.stdout) + self.assertNotIn('multiplecomposefiles_another_1', result.stdout) + self.assertIn('multiplecomposefiles_yetanother_1', result.stdout) + + def test_pull(self): + result = self.dispatch(['pull']) + assert sorted(result.stderr.split('\n'))[1:] == [ + 'Pulling another (busybox:latest)...', + 'Pulling simple (busybox:latest)...', + ] + + def test_pull_with_digest(self): + result = self.dispatch(['-f', 'digest.yml', 'pull']) - cache_indicator = 'Using cache' - pull_indicator = 'Status: Image is up to date for busybox:latest' + assert 'Pulling simple (busybox:latest)...' in result.stderr + assert ('Pulling digest (busybox@' + 'sha256:38a203e1986cf79639cfb9b2e1d6e773de84002feea2d4eb006b520' + '04ee8502d)...') in result.stderr - with mock.patch('sys.stdout', new_callable=StringIO) as mock_stdout: - self.command.dispatch(['build', 'simple'], None) - output = mock_stdout.getvalue() - self.assertIn(cache_indicator, output) - self.assertNotIn(pull_indicator, output) + def test_pull_with_ignore_pull_failures(self): + result = self.dispatch([ + '-f', 'ignore-pull-failures.yml', + '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:latest not found' in result.stderr + + def test_build_plain(self): + self.base_dir = 'tests/fixtures/simple-dockerfile' + self.dispatch(['build', 'simple']) + + result = self.dispatch(['build', 'simple']) + assert BUILD_CACHE_TEXT in result.stdout + assert BUILD_PULL_TEXT not in result.stdout def test_build_no_cache(self): - self.command.base_dir = 'tests/fixtures/simple-dockerfile' - self.command.dispatch(['build', 'simple'], None) + self.base_dir = 'tests/fixtures/simple-dockerfile' + self.dispatch(['build', 'simple']) - cache_indicator = 'Using cache' - pull_indicator = 'Status: Image is up to date for busybox:latest' - with mock.patch('sys.stdout', new_callable=StringIO) as mock_stdout: - self.command.dispatch(['build', '--no-cache', 'simple'], None) - output = mock_stdout.getvalue() - self.assertNotIn(cache_indicator, output) - self.assertNotIn(pull_indicator, output) + result = self.dispatch(['build', '--no-cache', 'simple']) + assert BUILD_CACHE_TEXT not in result.stdout + assert BUILD_PULL_TEXT not in result.stdout def test_build_pull(self): - self.command.base_dir = 'tests/fixtures/simple-dockerfile' - self.command.dispatch(['build', 'simple'], None) + self.base_dir = 'tests/fixtures/simple-dockerfile' + self.dispatch(['build', 'simple'], None) - cache_indicator = 'Using cache' - pull_indicator = 'Status: Image is up to date for busybox:latest' - with mock.patch('sys.stdout', new_callable=StringIO) as mock_stdout: - self.command.dispatch(['build', '--pull', 'simple'], None) - output = mock_stdout.getvalue() - self.assertIn(cache_indicator, output) - self.assertIn(pull_indicator, output) + result = self.dispatch(['build', '--pull', 'simple']) + assert BUILD_CACHE_TEXT in result.stdout + assert BUILD_PULL_TEXT in result.stdout def test_build_no_cache_pull(self): - self.command.base_dir = 'tests/fixtures/simple-dockerfile' - self.command.dispatch(['build', 'simple'], None) + self.base_dir = 'tests/fixtures/simple-dockerfile' + self.dispatch(['build', 'simple']) - cache_indicator = 'Using cache' - pull_indicator = 'Status: Image is up to date for busybox:latest' - with mock.patch('sys.stdout', new_callable=StringIO) as mock_stdout: - self.command.dispatch(['build', '--no-cache', '--pull', 'simple'], None) - output = mock_stdout.getvalue() - self.assertNotIn(cache_indicator, output) - self.assertIn(pull_indicator, output) + result = self.dispatch(['build', '--no-cache', '--pull', 'simple']) + assert BUILD_CACHE_TEXT not in result.stdout + assert BUILD_PULL_TEXT in result.stdout def test_up_detached(self): - self.command.dispatch(['up', '-d'], None) + self.dispatch(['up', '-d'], None) service = self.project.get_service('simple') another = self.project.get_service('another') self.assertEqual(len(service.containers()), 1) @@ -168,12 +162,14 @@ def test_up_detached(self): self.assertFalse(container.get('Config.AttachStdout')) self.assertFalse(container.get('Config.AttachStdin')) + # TODO: needs rework + @pytest.mark.skipif(True, reason="runs top") def test_up_attached(self): with mock.patch( 'compose.cli.main.attach_to_logs', autospec=True ) as mock_attach: - self.command.dispatch(['up'], None) + self.dispatch(['up'], None) _, args, kwargs = mock_attach.mock_calls[0] _project, log_printer, _names, _timeout = args @@ -189,8 +185,8 @@ def test_up_attached(self): def test_up_without_networking(self): self.require_api_version('1.21') - self.command.base_dir = 'tests/fixtures/links-composefile' - self.command.dispatch(['up', '-d'], None) + self.base_dir = 'tests/fixtures/links-composefile' + self.dispatch(['up', '-d'], None) client = docker_client(version='1.21') networks = client.networks(names=[self.project.name]) @@ -207,8 +203,8 @@ def test_up_without_networking(self): def test_up_with_networking(self): self.require_api_version('1.21') - self.command.base_dir = 'tests/fixtures/links-composefile' - self.command.dispatch(['--x-networking', 'up', '-d'], None) + self.base_dir = 'tests/fixtures/links-composefile' + self.dispatch(['--x-networking', 'up', '-d'], None) client = docker_client(version='1.21') services = self.project.get_services() @@ -231,8 +227,8 @@ def test_up_with_networking(self): self.assertFalse(web_container.get('HostConfig.Links')) def test_up_with_links(self): - self.command.base_dir = 'tests/fixtures/links-composefile' - self.command.dispatch(['up', '-d', 'web'], None) + self.base_dir = 'tests/fixtures/links-composefile' + self.dispatch(['up', '-d', 'web'], None) web = self.project.get_service('web') db = self.project.get_service('db') console = self.project.get_service('console') @@ -241,8 +237,8 @@ def test_up_with_links(self): self.assertEqual(len(console.containers()), 0) def test_up_with_no_deps(self): - self.command.base_dir = 'tests/fixtures/links-composefile' - self.command.dispatch(['up', '-d', '--no-deps', 'web'], None) + self.base_dir = 'tests/fixtures/links-composefile' + self.dispatch(['up', '-d', '--no-deps', 'web'], None) web = self.project.get_service('web') db = self.project.get_service('db') console = self.project.get_service('console') @@ -251,13 +247,13 @@ def test_up_with_no_deps(self): self.assertEqual(len(console.containers()), 0) def test_up_with_force_recreate(self): - self.command.dispatch(['up', '-d'], None) + self.dispatch(['up', '-d'], None) service = self.project.get_service('simple') self.assertEqual(len(service.containers()), 1) old_ids = [c.id for c in service.containers()] - self.command.dispatch(['up', '-d', '--force-recreate'], None) + self.dispatch(['up', '-d', '--force-recreate'], None) self.assertEqual(len(service.containers()), 1) new_ids = [c.id for c in service.containers()] @@ -265,13 +261,13 @@ def test_up_with_force_recreate(self): self.assertNotEqual(old_ids, new_ids) def test_up_with_no_recreate(self): - self.command.dispatch(['up', '-d'], None) + self.dispatch(['up', '-d'], None) service = self.project.get_service('simple') self.assertEqual(len(service.containers()), 1) old_ids = [c.id for c in service.containers()] - self.command.dispatch(['up', '-d', '--no-recreate'], None) + self.dispatch(['up', '-d', '--no-recreate'], None) self.assertEqual(len(service.containers()), 1) new_ids = [c.id for c in service.containers()] @@ -279,11 +275,12 @@ def test_up_with_no_recreate(self): self.assertEqual(old_ids, new_ids) def test_up_with_force_recreate_and_no_recreate(self): - with self.assertRaises(UserError): - self.command.dispatch(['up', '-d', '--force-recreate', '--no-recreate'], None) + self.dispatch( + ['up', '-d', '--force-recreate', '--no-recreate'], + returncode=1) def test_up_with_timeout(self): - self.command.dispatch(['up', '-d', '-t', '1'], None) + self.dispatch(['up', '-d', '-t', '1'], None) service = self.project.get_service('simple') another = self.project.get_service('another') self.assertEqual(len(service.containers()), 1) @@ -295,10 +292,9 @@ def test_up_with_timeout(self): self.assertFalse(config['AttachStdout']) self.assertFalse(config['AttachStdin']) - @mock.patch('dockerpty.start') - def test_run_service_without_links(self, mock_stdout): - self.command.base_dir = 'tests/fixtures/links-composefile' - self.command.dispatch(['run', 'console', '/bin/true'], None) + def test_run_service_without_links(self): + self.base_dir = 'tests/fixtures/links-composefile' + self.dispatch(['run', 'console', '/bin/true']) self.assertEqual(len(self.project.containers()), 0) # Ensure stdin/out was open @@ -308,44 +304,40 @@ def test_run_service_without_links(self, mock_stdout): self.assertTrue(config['AttachStdout']) self.assertTrue(config['AttachStdin']) - @mock.patch('dockerpty.start') - def test_run_service_with_links(self, _): - self.command.base_dir = 'tests/fixtures/links-composefile' - self.command.dispatch(['run', 'web', '/bin/true'], None) + def test_run_service_with_links(self): + self.base_dir = 'tests/fixtures/links-composefile' + 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) - @mock.patch('dockerpty.start') - def test_run_with_no_deps(self, _): - self.command.base_dir = 'tests/fixtures/links-composefile' - self.command.dispatch(['run', '--no-deps', 'web', '/bin/true'], None) + def test_run_with_no_deps(self): + self.base_dir = 'tests/fixtures/links-composefile' + self.dispatch(['run', '--no-deps', 'web', '/bin/true']) db = self.project.get_service('db') self.assertEqual(len(db.containers()), 0) - @mock.patch('dockerpty.start') - def test_run_does_not_recreate_linked_containers(self, _): - self.command.base_dir = 'tests/fixtures/links-composefile' - self.command.dispatch(['up', '-d', 'db'], None) + def test_run_does_not_recreate_linked_containers(self): + self.base_dir = 'tests/fixtures/links-composefile' + self.dispatch(['up', '-d', 'db']) db = self.project.get_service('db') self.assertEqual(len(db.containers()), 1) old_ids = [c.id for c in db.containers()] - self.command.dispatch(['run', 'web', '/bin/true'], None) + self.dispatch(['run', 'web', '/bin/true'], None) self.assertEqual(len(db.containers()), 1) new_ids = [c.id for c in db.containers()] self.assertEqual(old_ids, new_ids) - @mock.patch('dockerpty.start') - def test_run_without_command(self, _): - self.command.base_dir = 'tests/fixtures/commands-composefile' + def test_run_without_command(self): + self.base_dir = 'tests/fixtures/commands-composefile' self.check_build('tests/fixtures/simple-dockerfile', tag='composetest_test') - self.command.dispatch(['run', 'implicit'], None) + self.dispatch(['run', 'implicit']) service = self.project.get_service('implicit') containers = service.containers(stopped=True, one_off=True) self.assertEqual( @@ -353,7 +345,7 @@ def test_run_without_command(self, _): [u'/bin/sh -c echo "success"'], ) - self.command.dispatch(['run', 'explicit'], None) + self.dispatch(['run', 'explicit']) service = self.project.get_service('explicit') containers = service.containers(stopped=True, one_off=True) self.assertEqual( @@ -361,14 +353,10 @@ def test_run_without_command(self, _): [u'/bin/true'], ) - @mock.patch('dockerpty.start') - def test_run_service_with_entrypoint_overridden(self, _): - self.command.base_dir = 'tests/fixtures/dockerfile_with_entrypoint' + def test_run_service_with_entrypoint_overridden(self): + self.base_dir = 'tests/fixtures/dockerfile_with_entrypoint' name = 'service' - self.command.dispatch( - ['run', '--entrypoint', '/bin/echo', name, 'helloworld'], - None - ) + self.dispatch(['run', '--entrypoint', '/bin/echo', name, 'helloworld']) service = self.project.get_service(name) container = service.containers(stopped=True, one_off=True)[0] self.assertEqual( @@ -376,37 +364,34 @@ def test_run_service_with_entrypoint_overridden(self, _): [u'/bin/echo', u'helloworld'], ) - @mock.patch('dockerpty.start') - def test_run_service_with_user_overridden(self, _): - self.command.base_dir = 'tests/fixtures/user-composefile' + def test_run_service_with_user_overridden(self): + self.base_dir = 'tests/fixtures/user-composefile' name = 'service' user = 'sshd' - args = ['run', '--user={user}'.format(user=user), name] - self.command.dispatch(args, None) + 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] self.assertEqual(user, container.get('Config.User')) - @mock.patch('dockerpty.start') - def test_run_service_with_user_overridden_short_form(self, _): - self.command.base_dir = 'tests/fixtures/user-composefile' + def test_run_service_with_user_overridden_short_form(self): + self.base_dir = 'tests/fixtures/user-composefile' name = 'service' user = 'sshd' - args = ['run', '-u', user, name] - self.command.dispatch(args, None) + self.dispatch(['run', '-u', user, name], returncode=1) service = self.project.get_service(name) container = service.containers(stopped=True, one_off=True)[0] self.assertEqual(user, container.get('Config.User')) - @mock.patch('dockerpty.start') - def test_run_service_with_environement_overridden(self, _): + def test_run_service_with_environement_overridden(self): name = 'service' - self.command.base_dir = 'tests/fixtures/environment-composefile' - self.command.dispatch( - ['run', '-e', 'foo=notbar', '-e', 'allo=moto=bobo', - '-e', 'alpha=beta', name], - None - ) + self.base_dir = 'tests/fixtures/environment-composefile' + self.dispatch([ + 'run', '-e', 'foo=notbar', + '-e', 'allo=moto=bobo', + '-e', 'alpha=beta', + name, + '/bin/true', + ]) service = self.project.get_service(name) container = service.containers(stopped=True, one_off=True)[0] # env overriden @@ -418,11 +403,10 @@ def test_run_service_with_environement_overridden(self, _): # make sure a value with a = don't crash out self.assertEqual('moto=bobo', container.environment['allo']) - @mock.patch('dockerpty.start') - def test_run_service_without_map_ports(self, _): + def test_run_service_without_map_ports(self): # create one off container - self.command.base_dir = 'tests/fixtures/ports-composefile' - self.command.dispatch(['run', '-d', 'simple'], None) + self.base_dir = 'tests/fixtures/ports-composefile' + self.dispatch(['run', '-d', 'simple']) container = self.project.get_service('simple').containers(one_off=True)[0] # get port information @@ -436,12 +420,10 @@ def test_run_service_without_map_ports(self, _): self.assertEqual(port_random, None) self.assertEqual(port_assigned, None) - @mock.patch('dockerpty.start') - def test_run_service_with_map_ports(self, _): - + def test_run_service_with_map_ports(self): # create one off container - self.command.base_dir = 'tests/fixtures/ports-composefile' - self.command.dispatch(['run', '-d', '--service-ports', 'simple'], None) + 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] # get port information @@ -459,12 +441,10 @@ def test_run_service_with_map_ports(self, _): self.assertEqual(port_range[0], "0.0.0.0:49153") self.assertEqual(port_range[1], "0.0.0.0:49154") - @mock.patch('dockerpty.start') - def test_run_service_with_explicitly_maped_ports(self, _): - + def test_run_service_with_explicitly_maped_ports(self): # create one off container - self.command.base_dir = 'tests/fixtures/ports-composefile' - self.command.dispatch(['run', '-d', '-p', '30000:3000', '--publish', '30001:3001', 'simple'], None) + 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] # get port information @@ -478,12 +458,10 @@ def test_run_service_with_explicitly_maped_ports(self, _): self.assertEqual(port_short, "0.0.0.0:30000") self.assertEqual(port_full, "0.0.0.0:30001") - @mock.patch('dockerpty.start') - def test_run_service_with_explicitly_maped_ip_ports(self, _): - + def test_run_service_with_explicitly_maped_ip_ports(self): # create one off container - self.command.base_dir = 'tests/fixtures/ports-composefile' - self.command.dispatch(['run', '-d', '-p', '127.0.0.1:30000:3000', '--publish', '127.0.0.1:30001:3001', 'simple'], None) + 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) container = self.project.get_service('simple').containers(one_off=True)[0] # get port information @@ -497,22 +475,20 @@ def test_run_service_with_explicitly_maped_ip_ports(self, _): self.assertEqual(port_short, "127.0.0.1:30000") self.assertEqual(port_full, "127.0.0.1:30001") - @mock.patch('dockerpty.start') - def test_run_with_custom_name(self, _): - self.command.base_dir = 'tests/fixtures/environment-composefile' + def test_run_with_custom_name(self): + self.base_dir = 'tests/fixtures/environment-composefile' name = 'the-container-name' - self.command.dispatch(['run', '--name', name, 'service'], None) + self.dispatch(['run', '--name', name, 'service', '/bin/true']) service = self.project.get_service('service') container, = service.containers(stopped=True, one_off=True) self.assertEqual(container.name, name) - @mock.patch('dockerpty.start') - def test_run_with_networking(self, _): + def test_run_with_networking(self): self.require_api_version('1.21') client = docker_client(version='1.21') - self.command.base_dir = 'tests/fixtures/simple-dockerfile' - self.command.dispatch(['--x-networking', 'run', 'simple', 'true'], None) + self.base_dir = 'tests/fixtures/simple-dockerfile' + self.dispatch(['--x-networking', 'run', 'simple', 'true'], None) service = self.project.get_service('simple') container, = service.containers(stopped=True, one_off=True) networks = client.networks(names=[self.project.name]) @@ -526,71 +502,70 @@ def test_rm(self): service.create_container() service.kill() self.assertEqual(len(service.containers(stopped=True)), 1) - self.command.dispatch(['rm', '--force'], None) + self.dispatch(['rm', '--force'], None) self.assertEqual(len(service.containers(stopped=True)), 0) service = self.project.get_service('simple') service.create_container() service.kill() self.assertEqual(len(service.containers(stopped=True)), 1) - self.command.dispatch(['rm', '-f'], None) + self.dispatch(['rm', '-f'], None) self.assertEqual(len(service.containers(stopped=True)), 0) def test_stop(self): - self.command.dispatch(['up', '-d'], None) + self.dispatch(['up', '-d'], None) service = self.project.get_service('simple') self.assertEqual(len(service.containers()), 1) self.assertTrue(service.containers()[0].is_running) - self.command.dispatch(['stop', '-t', '1'], None) + self.dispatch(['stop', '-t', '1'], None) self.assertEqual(len(service.containers(stopped=True)), 1) self.assertFalse(service.containers(stopped=True)[0].is_running) def test_pause_unpause(self): - self.command.dispatch(['up', '-d'], None) + self.dispatch(['up', '-d'], None) service = self.project.get_service('simple') self.assertFalse(service.containers()[0].is_paused) - self.command.dispatch(['pause'], None) + self.dispatch(['pause'], None) self.assertTrue(service.containers()[0].is_paused) - self.command.dispatch(['unpause'], None) + self.dispatch(['unpause'], None) self.assertFalse(service.containers()[0].is_paused) def test_logs_invalid_service_name(self): - with self.assertRaises(NoSuchService): - self.command.dispatch(['logs', 'madeupname'], None) + self.dispatch(['logs', 'madeupname'], returncode=1) def test_kill(self): - self.command.dispatch(['up', '-d'], None) + self.dispatch(['up', '-d'], None) service = self.project.get_service('simple') self.assertEqual(len(service.containers()), 1) self.assertTrue(service.containers()[0].is_running) - self.command.dispatch(['kill'], None) + self.dispatch(['kill'], None) self.assertEqual(len(service.containers(stopped=True)), 1) self.assertFalse(service.containers(stopped=True)[0].is_running) def test_kill_signal_sigstop(self): - self.command.dispatch(['up', '-d'], None) + self.dispatch(['up', '-d'], None) service = self.project.get_service('simple') self.assertEqual(len(service.containers()), 1) self.assertTrue(service.containers()[0].is_running) - self.command.dispatch(['kill', '-s', 'SIGSTOP'], None) + self.dispatch(['kill', '-s', 'SIGSTOP'], None) self.assertEqual(len(service.containers()), 1) # The container is still running. It has only been paused self.assertTrue(service.containers()[0].is_running) def test_kill_stopped_service(self): - self.command.dispatch(['up', '-d'], None) + self.dispatch(['up', '-d'], None) service = self.project.get_service('simple') - self.command.dispatch(['kill', '-s', 'SIGSTOP'], None) + self.dispatch(['kill', '-s', 'SIGSTOP'], None) self.assertTrue(service.containers()[0].is_running) - self.command.dispatch(['kill', '-s', 'SIGKILL'], None) + self.dispatch(['kill', '-s', 'SIGKILL'], None) self.assertEqual(len(service.containers(stopped=True)), 1) self.assertFalse(service.containers(stopped=True)[0].is_running) @@ -600,7 +575,7 @@ def test_restart(self): container = service.create_container() container.start() started_at = container.dictionary['State']['StartedAt'] - self.command.dispatch(['restart', '-t', '1'], None) + self.dispatch(['restart', '-t', '1'], None) container.inspect() self.assertNotEqual( container.dictionary['State']['FinishedAt'], @@ -614,53 +589,51 @@ def test_restart(self): def test_scale(self): project = self.project - self.command.scale(project, {'SERVICE=NUM': ['simple=1']}) + self.dispatch(['scale', 'simple=1']) self.assertEqual(len(project.get_service('simple').containers()), 1) - self.command.scale(project, {'SERVICE=NUM': ['simple=3', 'another=2']}) + self.dispatch(['scale', 'simple=3', 'another=2']) self.assertEqual(len(project.get_service('simple').containers()), 3) self.assertEqual(len(project.get_service('another').containers()), 2) - self.command.scale(project, {'SERVICE=NUM': ['simple=1', 'another=1']}) + self.dispatch(['scale', 'simple=1', 'another=1']) self.assertEqual(len(project.get_service('simple').containers()), 1) self.assertEqual(len(project.get_service('another').containers()), 1) - self.command.scale(project, {'SERVICE=NUM': ['simple=1', 'another=1']}) + self.dispatch(['scale', 'simple=1', 'another=1']) self.assertEqual(len(project.get_service('simple').containers()), 1) self.assertEqual(len(project.get_service('another').containers()), 1) - self.command.scale(project, {'SERVICE=NUM': ['simple=0', 'another=0']}) + self.dispatch(['scale', 'simple=0', 'another=0']) self.assertEqual(len(project.get_service('simple').containers()), 0) self.assertEqual(len(project.get_service('another').containers()), 0) def test_port(self): - self.command.base_dir = 'tests/fixtures/ports-composefile' - self.command.dispatch(['up', '-d'], None) + self.base_dir = 'tests/fixtures/ports-composefile' + self.dispatch(['up', '-d'], None) container = self.project.get_service('simple').get_container() - @mock.patch('sys.stdout', new_callable=StringIO) - def get_port(number, mock_stdout): - self.command.dispatch(['port', 'simple', str(number)], None) - return mock_stdout.getvalue().rstrip() + def get_port(number): + result = self.dispatch(['port', 'simple', str(number)]) + return result.stdout.rstrip() self.assertEqual(get_port(3000), container.get_local_port(3000)) self.assertEqual(get_port(3001), "0.0.0.0:49152") self.assertEqual(get_port(3002), "0.0.0.0:49153") def test_port_with_scale(self): - self.command.base_dir = 'tests/fixtures/ports-composefile-scale' - self.command.dispatch(['scale', 'simple=2'], None) + self.base_dir = 'tests/fixtures/ports-composefile-scale' + self.dispatch(['scale', 'simple=2'], None) containers = sorted( self.project.containers(service_names=['simple']), key=attrgetter('name')) - @mock.patch('sys.stdout', new_callable=StringIO) - def get_port(number, mock_stdout, index=None): + def get_port(number, index=None): if index is None: - self.command.dispatch(['port', 'simple', str(number)], None) + result = self.dispatch(['port', 'simple', str(number)]) else: - self.command.dispatch(['port', '--index=' + str(index), 'simple', str(number)], None) - return mock_stdout.getvalue().rstrip() + result = self.dispatch(['port', '--index=' + str(index), 'simple', str(number)]) + return result.stdout.rstrip() self.assertEqual(get_port(3000), containers[0].get_local_port(3000)) self.assertEqual(get_port(3000, index=1), containers[0].get_local_port(3000)) @@ -669,8 +642,8 @@ def get_port(number, mock_stdout, index=None): def test_env_file_relative_to_compose_file(self): config_path = os.path.abspath('tests/fixtures/env-file/docker-compose.yml') - self.command.dispatch(['-f', config_path, 'up', '-d'], None) - self._project = get_project(self.command.base_dir, [config_path]) + self.dispatch(['-f', config_path, 'up', '-d'], None) + self._project = get_project(self.base_dir, [config_path]) containers = self.project.containers(stopped=True) self.assertEqual(len(containers), 1) @@ -680,20 +653,18 @@ def test_env_file_relative_to_compose_file(self): def test_home_and_env_var_in_volume_path(self): os.environ['VOLUME_NAME'] = 'my-volume' os.environ['HOME'] = '/tmp/home-dir' - expected_host_path = os.path.join(os.environ['HOME'], os.environ['VOLUME_NAME']) - self.command.base_dir = 'tests/fixtures/volume-path-interpolation' - self.command.dispatch(['up', '-d'], None) + self.base_dir = 'tests/fixtures/volume-path-interpolation' + self.dispatch(['up', '-d'], None) container = self.project.containers(stopped=True)[0] actual_host_path = container.get('Volumes')['/container-path'] components = actual_host_path.split('/') - self.assertTrue(components[-2:] == ['home-dir', 'my-volume'], - msg="Last two components differ: %s, %s" % (actual_host_path, expected_host_path)) + assert components[-2:] == ['home-dir', 'my-volume'] def test_up_with_default_override_file(self): - self.command.base_dir = 'tests/fixtures/override-files' - self.command.dispatch(['up', '-d'], None) + self.base_dir = 'tests/fixtures/override-files' + self.dispatch(['up', '-d'], None) containers = self.project.containers() self.assertEqual(len(containers), 2) @@ -703,15 +674,15 @@ def test_up_with_default_override_file(self): self.assertEqual(db.human_readable_command, 'top') def test_up_with_multiple_files(self): - self.command.base_dir = 'tests/fixtures/override-files' + self.base_dir = 'tests/fixtures/override-files' config_paths = [ 'docker-compose.yml', 'docker-compose.override.yml', 'extra.yml', ] - self._project = get_project(self.command.base_dir, config_paths) - self.command.dispatch( + self._project = get_project(self.base_dir, config_paths) + self.dispatch( [ '-f', config_paths[0], '-f', config_paths[1], @@ -730,8 +701,8 @@ def test_up_with_multiple_files(self): self.assertEqual(other.human_readable_command, 'top') def test_up_with_extends(self): - self.command.base_dir = 'tests/fixtures/extends' - self.command.dispatch(['up', '-d'], None) + self.base_dir = 'tests/fixtures/extends' + self.dispatch(['up', '-d'], None) self.assertEqual( set([s.name for s in self.project.services]), From 4105c3017c9a75b6044298daffe4a56d7409c2d5 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 2 Nov 2015 17:52:37 -0500 Subject: [PATCH 1511/4072] Move cli tests to a new testing package. These cli tests are now a different kind of that that run the compose binary. They are not the same as integration tests that test some internal interface. Signed-off-by: Daniel Nephin --- tests/acceptance/__init__.py | 0 tests/{integration => acceptance}/cli_test.py | 29 ++++--------------- .../fixtures/echo-services/docker-compose.yml | 6 ++++ 3 files changed, 12 insertions(+), 23 deletions(-) create mode 100644 tests/acceptance/__init__.py rename tests/{integration => acceptance}/cli_test.py (96%) create mode 100644 tests/fixtures/echo-services/docker-compose.yml diff --git a/tests/acceptance/__init__.py b/tests/acceptance/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/integration/cli_test.py b/tests/acceptance/cli_test.py similarity index 96% rename from tests/integration/cli_test.py rename to tests/acceptance/cli_test.py index 7b7537e21e2..fc68f9d8517 100644 --- a/tests/integration/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -6,12 +6,10 @@ from collections import namedtuple from operator import attrgetter -import pytest - from .. import mock -from .testcases import DockerClientTestCase from compose.cli.command import get_project from compose.cli.docker_client import docker_client +from tests.integration.testcases import DockerClientTestCase ProcessResult = namedtuple('ProcessResult', 'stdout stderr') @@ -45,8 +43,6 @@ def dispatch(self, options, project_options=None, returncode=0): project_options = project_options or [] proc = subprocess.Popen( ['docker-compose'] + project_options + options, - # Note: this might actually be a patched sys.stdout, so we have - # to specify it here, even though it's the default stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=self.base_dir) @@ -150,7 +146,7 @@ def test_build_no_cache_pull(self): assert BUILD_PULL_TEXT in result.stdout def test_up_detached(self): - self.dispatch(['up', '-d'], None) + self.dispatch(['up', '-d']) service = self.project.get_service('simple') another = self.project.get_service('another') self.assertEqual(len(service.containers()), 1) @@ -162,25 +158,12 @@ def test_up_detached(self): self.assertFalse(container.get('Config.AttachStdout')) self.assertFalse(container.get('Config.AttachStdin')) - # TODO: needs rework - @pytest.mark.skipif(True, reason="runs top") def test_up_attached(self): - with mock.patch( - 'compose.cli.main.attach_to_logs', - autospec=True - ) as mock_attach: - self.dispatch(['up'], None) - _, args, kwargs = mock_attach.mock_calls[0] - _project, log_printer, _names, _timeout = args + self.base_dir = 'tests/fixtures/echo-services' + result = self.dispatch(['up', '--no-color']) - service = self.project.get_service('simple') - another = self.project.get_service('another') - self.assertEqual(len(service.containers()), 1) - self.assertEqual(len(another.containers()), 1) - self.assertEqual( - set(log_printer.containers), - set(self.project.containers()) - ) + assert 'simple_1 | simple' in result.stdout + assert 'another_1 | another' in result.stdout def test_up_without_networking(self): self.require_api_version('1.21') diff --git a/tests/fixtures/echo-services/docker-compose.yml b/tests/fixtures/echo-services/docker-compose.yml new file mode 100644 index 00000000000..8014f3d9167 --- /dev/null +++ b/tests/fixtures/echo-services/docker-compose.yml @@ -0,0 +1,6 @@ +simple: + image: busybox:latest + command: echo simple +another: + image: busybox:latest + command: echo another From 8fb44db92bd35c0ab9145401a0a560bc0391761f Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 20 Oct 2015 13:10:32 -0400 Subject: [PATCH 1512/4072] Cleanup some unit tests and whitespace. Remove some unnecessary newlines. Remove a unittest that was attempting to test behaviour that was removed a while ago, so isn't testing anything. Updated some unit tests to use mocks instead of a custom fake. Signed-off-by: Daniel Nephin --- compose/service.py | 8 ++---- tests/unit/service_test.py | 50 ++++++++++++++------------------------ 2 files changed, 20 insertions(+), 38 deletions(-) diff --git a/compose/service.py b/compose/service.py index fc2677791e0..4a7169e96a5 100644 --- a/compose/service.py +++ b/compose/service.py @@ -300,9 +300,7 @@ def create_container(self, Create a container for this service. If the image doesn't exist, attempt to pull it. """ - self.ensure_image_exists( - do_build=do_build, - ) + self.ensure_image_exists(do_build=do_build) container_options = self._get_container_create_options( override_options, @@ -316,9 +314,7 @@ def create_container(self, return Container.create(self.client, **container_options) - def ensure_image_exists(self, - do_build=True): - + def ensure_image_exists(self, do_build=True): try: self.image() return diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 90bad87ac97..4c70beb70a3 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -339,44 +339,37 @@ def test_parse_repository_tag(self): 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", "@")) - @mock.patch('compose.service.Container', autospec=True) - def test_create_container_latest_is_used_when_no_tag_specified(self, mock_container): - service = Service('foo', client=self.mock_client, image='someimage') - images = [] - - def pull(repo, tag=None, **kwargs): - self.assertEqual('someimage', repo) - self.assertEqual('latest', tag) - images.append({'Id': 'abc123'}) - return [] - - service.image = lambda *args, **kwargs: mock_get_image(images) - self.mock_client.pull = pull - - service.create_container() - self.assertEqual(1, len(images)) - def test_create_container_with_build(self): service = Service('foo', client=self.mock_client, build='.') - - images = [] - service.image = lambda *args, **kwargs: mock_get_image(images) - service.build = lambda: images.append({'Id': 'abc123'}) + self.mock_client.inspect_image.side_effect = [ + NoSuchImageError, + {'Id': 'abc123'}, + ] + self.mock_client.build.return_value = [ + '{"stream": "Successfully built abcd"}', + ] service.create_container(do_build=True) - self.assertEqual(1, len(images)) + self.mock_client.build.assert_called_once_with( + tag='default_foo', + dockerfile=None, + stream=True, + path='.', + pull=False, + nocache=False, + rm=True, + ) def test_create_container_no_build(self): service = Service('foo', client=self.mock_client, build='.') - service.image = lambda: {'Id': 'abc123'} + self.mock_client.inspect_image.return_value = {'Id': 'abc123'} service.create_container(do_build=False) 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='.') - service.image = lambda *args, **kwargs: mock_get_image([]) - + self.mock_client.inspect_image.side_effect = NoSuchImageError with self.assertRaises(NeedsBuildError): service.create_container(do_build=False) @@ -484,13 +477,6 @@ def test_net_service_no_containers(self): self.assertEqual(net.service_name, service_name) -def mock_get_image(images): - if images: - return images[0] - else: - raise NoSuchImageError() - - class ServiceVolumesTest(unittest.TestCase): def setUp(self): From 4c2eb17ccd04e5da00366de2650acf20becf0200 Mon Sep 17 00:00:00 2001 From: Adrian Budau Date: Tue, 27 Oct 2015 02:00:51 -0700 Subject: [PATCH 1513/4072] Added --force-rm to compose build. It's a flag passed to docker build that removes the intermediate containers left behind on fail builds. Signed-off-by: Adrian Budau --- compose/cli/main.py | 4 ++- compose/project.py | 4 +-- compose/service.py | 3 +- contrib/completion/bash/docker-compose | 2 +- contrib/completion/zsh/_docker-compose | 1 + docs/reference/build.md | 1 + tests/acceptance/cli_test.py | 28 +++++++++++++++++++ .../simple-failing-dockerfile/Dockerfile | 7 +++++ .../docker-compose.yml | 2 ++ tests/unit/service_test.py | 1 + 10 files changed, 48 insertions(+), 5 deletions(-) create mode 100644 tests/fixtures/simple-failing-dockerfile/Dockerfile create mode 100644 tests/fixtures/simple-failing-dockerfile/docker-compose.yml diff --git a/compose/cli/main.py b/compose/cli/main.py index 95db45cec11..0360c8bef14 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -181,12 +181,14 @@ def build(self, project, options): 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. """ + force_rm = bool(options.get('--force-rm', False)) no_cache = bool(options.get('--no-cache', False)) pull = bool(options.get('--pull', False)) - project.build(service_names=options['SERVICE'], no_cache=no_cache, pull=pull) + project.build(service_names=options['SERVICE'], no_cache=no_cache, pull=pull, force_rm=force_rm) def help(self, project, options): """ diff --git a/compose/project.py b/compose/project.py index f0478203bb1..d2ba86b1bc3 100644 --- a/compose/project.py +++ b/compose/project.py @@ -278,10 +278,10 @@ def restart(self, service_names=None, **options): for service in self.get_services(service_names): service.restart(**options) - def build(self, service_names=None, no_cache=False, pull=False): + def build(self, service_names=None, no_cache=False, pull=False, force_rm=False): for service in self.get_services(service_names): if service.can_be_built(): - service.build(no_cache, pull) + service.build(no_cache, pull, force_rm) else: log.info('%s uses an image, skipping' % service.name) diff --git a/compose/service.py b/compose/service.py index 4a7169e96a5..fcf438c09fc 100644 --- a/compose/service.py +++ b/compose/service.py @@ -701,7 +701,7 @@ def _get_container_host_config(self, override_options, one_off=False): cgroup_parent=cgroup_parent ) - def build(self, no_cache=False, pull=False): + def build(self, no_cache=False, pull=False, force_rm=False): log.info('Building %s' % self.name) path = self.options['build'] @@ -715,6 +715,7 @@ def build(self, no_cache=False, pull=False): tag=self.image_name, stream=True, rm=True, + forcerm=force_rm, pull=pull, nocache=no_cache, dockerfile=self.options.get('dockerfile', None), diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index 0eed1f18b77..3deff9a4b8a 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -87,7 +87,7 @@ __docker_compose_services_stopped() { _docker_compose_build() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "--help --no-cache --pull" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--help --force-rm --no-cache --pull" -- "$cur" ) ) ;; *) __docker_compose_services_from_build diff --git a/contrib/completion/zsh/_docker-compose b/contrib/completion/zsh/_docker-compose index d79b25d165f..08d5150d9e1 100644 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -192,6 +192,7 @@ __docker-compose_subcommand() { (build) _arguments \ $opts_help \ + '--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:__docker-compose_services_from_build' && ret=0 diff --git a/docs/reference/build.md b/docs/reference/build.md index c427199fec5..84aefc253f1 100644 --- a/docs/reference/build.md +++ b/docs/reference/build.md @@ -15,6 +15,7 @@ parent = "smn_compose_cli" 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. ``` diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index fc68f9d8517..88a43d7f0e8 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -9,6 +9,7 @@ from .. import mock from compose.cli.command import get_project from compose.cli.docker_client import docker_client +from compose.container import Container from tests.integration.testcases import DockerClientTestCase @@ -145,6 +146,33 @@ def test_build_no_cache_pull(self): assert BUILD_CACHE_TEXT not in result.stdout assert BUILD_PULL_TEXT in result.stdout + def test_build_failed(self): + self.base_dir = 'tests/fixtures/simple-failing-dockerfile' + self.dispatch(['build', 'simple'], returncode=1) + + labels = ["com.docker.compose.test_failing_image=true"] + containers = [ + Container.from_ps(self.project.client, c) + for c in self.project.client.containers( + all=True, + filters={"label": labels}) + ] + assert len(containers) == 1 + + def test_build_failed_forcerm(self): + self.base_dir = 'tests/fixtures/simple-failing-dockerfile' + self.dispatch(['build', '--force-rm', 'simple'], returncode=1) + + labels = ["com.docker.compose.test_failing_image=true"] + + containers = [ + Container.from_ps(self.project.client, c) + for c in self.project.client.containers( + all=True, + filters={"label": labels}) + ] + assert not containers + def test_up_detached(self): self.dispatch(['up', '-d']) service = self.project.get_service('simple') diff --git a/tests/fixtures/simple-failing-dockerfile/Dockerfile b/tests/fixtures/simple-failing-dockerfile/Dockerfile new file mode 100644 index 00000000000..c2d06b1672c --- /dev/null +++ b/tests/fixtures/simple-failing-dockerfile/Dockerfile @@ -0,0 +1,7 @@ +FROM busybox:latest +LABEL com.docker.compose.test_image=true +LABEL com.docker.compose.test_failing_image=true +# With the following label the container wil be cleaned up automatically +# Must be kept in sync with LABEL_PROJECT from compose/const.py +LABEL com.docker.compose.project=composetest +RUN exit 1 diff --git a/tests/fixtures/simple-failing-dockerfile/docker-compose.yml b/tests/fixtures/simple-failing-dockerfile/docker-compose.yml new file mode 100644 index 00000000000..b0357541ee3 --- /dev/null +++ b/tests/fixtures/simple-failing-dockerfile/docker-compose.yml @@ -0,0 +1,2 @@ +simple: + build: . diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 4c70beb70a3..af7007d3908 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -356,6 +356,7 @@ def test_create_container_with_build(self): stream=True, path='.', pull=False, + forcerm=False, nocache=False, rm=True, ) From de08da278dcc9051d9b1040240cd9433f2266502 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 10 Nov 2015 13:11:59 -0500 Subject: [PATCH 1514/4072] Re-order flags in bash completion and remove unnecessary variables from build command. Signed-off-by: Daniel Nephin --- compose/cli/main.py | 9 +++++---- contrib/completion/bash/docker-compose | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 0360c8bef14..08c1aee0752 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -185,10 +185,11 @@ def build(self, project, options): --no-cache Do not use cache when building the image. --pull Always attempt to pull a newer version of the image. """ - force_rm = bool(options.get('--force-rm', False)) - no_cache = bool(options.get('--no-cache', False)) - pull = bool(options.get('--pull', False)) - project.build(service_names=options['SERVICE'], no_cache=no_cache, pull=pull, force_rm=force_rm) + project.build( + service_names=options['SERVICE'], + no_cache=bool(options.get('--no-cache', False)), + pull=bool(options.get('--pull', False)), + force_rm=bool(options.get('--force-rm', False))) def help(self, project, options): """ diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index 3deff9a4b8a..72e159e08a8 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -87,7 +87,7 @@ __docker_compose_services_stopped() { _docker_compose_build() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "--help --force-rm --no-cache --pull" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--force-rm --help --no-cache --pull" -- "$cur" ) ) ;; *) __docker_compose_services_from_build From 36176befb0ef4a16ef24b09234238363756689e5 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 5 Nov 2015 15:33:42 -0500 Subject: [PATCH 1515/4072] Fix #1549 - flush after each line of logs. Includes some refactoring of log_printer_test to support checking for flush(), and so that each test calls the unit-under-test directly, instead of through a helper function. Signed-off-by: Daniel Nephin --- compose/cli/log_printer.py | 1 + tests/unit/cli/log_printer_test.py | 82 +++++++++++++++--------------- 2 files changed, 42 insertions(+), 41 deletions(-) diff --git a/compose/cli/log_printer.py b/compose/cli/log_printer.py index 66920726ce2..864657a4c68 100644 --- a/compose/cli/log_printer.py +++ b/compose/cli/log_printer.py @@ -26,6 +26,7 @@ def run(self): generators = list(self._make_log_generators(self.monochrome, prefix_width)) for line in Multiplexer(generators).loop(): self.output.write(line) + self.output.flush() def _make_log_generators(self, monochrome, prefix_width): def no_color(text): diff --git a/tests/unit/cli/log_printer_test.py b/tests/unit/cli/log_printer_test.py index 575fcaf7b57..5b04226cf06 100644 --- a/tests/unit/cli/log_printer_test.py +++ b/tests/unit/cli/log_printer_test.py @@ -1,13 +1,13 @@ from __future__ import absolute_import from __future__ import unicode_literals -import mock +import pytest import six from compose.cli.log_printer import LogPrinter from compose.cli.log_printer import wait_on_exit from compose.container import Container -from tests import unittest +from tests import mock def build_mock_container(reader): @@ -22,40 +22,52 @@ def build_mock_container(reader): ) -class LogPrinterTest(unittest.TestCase): - def get_default_output(self, monochrome=False): - def reader(*args, **kwargs): - yield b"hello\nworld" - container = build_mock_container(reader) - output = run_log_printer([container], monochrome=monochrome) - return output +@pytest.fixture +def output_stream(): + output = six.StringIO() + output.flush = mock.Mock() + return output + + +@pytest.fixture +def mock_container(): + def reader(*args, **kwargs): + yield b"hello\nworld" + return build_mock_container(reader) + - def test_single_container(self): - output = self.get_default_output() +class TestLogPrinter(object): - self.assertIn('hello', output) - self.assertIn('world', output) + def test_single_container(self, output_stream, mock_container): + LogPrinter([mock_container], output=output_stream).run() - def test_monochrome(self): - output = self.get_default_output(monochrome=True) - self.assertNotIn('\033[', output) + 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): - output = self.get_default_output() - self.assertIn('\033[', output) + def test_monochrome(self, output_stream, mock_container): + LogPrinter([mock_container], output=output_stream, monochrome=True).run() + assert '\033[' not in output_stream.getvalue() - def test_unicode(self): + def test_polychrome(self, output_stream, mock_container): + LogPrinter([mock_container], output=output_stream).run() + assert '\033[' in output_stream.getvalue() + + def test_unicode(self, output_stream): glyph = u'\u2022' def reader(*args, **kwargs): yield glyph.encode('utf-8') + b'\n' container = build_mock_container(reader) - output = run_log_printer([container]) + LogPrinter([container], output=output_stream).run() + output = output_stream.getvalue() if six.PY2: output = output.decode('utf-8') - self.assertIn(glyph, output) + assert glyph in output def test_wait_on_exit(self): exit_status = 3 @@ -65,24 +77,12 @@ def test_wait_on_exit(self): wait=mock.Mock(return_value=exit_status)) expected = '{} exited with code {}\n'.format(mock_container.name, exit_status) - self.assertEqual(expected, wait_on_exit(mock_container)) - - def test_generator_with_no_logs(self): - mock_container = mock.Mock( - spec=Container, - has_api_logs=False, - log_driver='none', - name_without_project='web_1', - wait=mock.Mock(return_value=0)) - - output = run_log_printer([mock_container]) - self.assertIn( - "WARNING: no logs are available with the 'none' log driver\n", - output - ) + 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() -def run_log_printer(containers, monochrome=False): - output = six.StringIO() - LogPrinter(containers, output=output, monochrome=monochrome).run() - return output.getvalue() + output = output_stream.getvalue() + assert "WARNING: no logs are available with the 'none' log driver\n" in output From 0a96f86f7444841017c4fcd7063d6d22a2488a0b Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 6 Nov 2015 14:30:27 -0500 Subject: [PATCH 1516/4072] Cleanup workaround in testcase.py Signed-off-by: Daniel Nephin --- tests/integration/testcases.py | 37 ++++++++++++---------------------- 1 file changed, 13 insertions(+), 24 deletions(-) diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index 686a2b69a46..60e67b5bf4f 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -42,34 +42,23 @@ def create_service(self, name, **kwargs): if 'command' not in kwargs: kwargs['command'] = ["top"] - links = kwargs.get('links', None) - volumes_from = kwargs.get('volumes_from', None) - net = kwargs.get('net', None) - - workaround_options = ['links', 'volumes_from', 'net'] - for key in workaround_options: - try: - del kwargs[key] - except KeyError: - pass - - options = ServiceLoader(working_dir='.', filename=None, service_name=name, service_dict=kwargs).make_service_dict() + workaround_options = {} + for option in ['links', 'volumes_from', 'net']: + if option in kwargs: + workaround_options[option] = kwargs.pop(option, None) + + options = ServiceLoader( + working_dir='.', + filename=None, + service_name=name, + service_dict=kwargs + ).make_service_dict() + options.update(workaround_options) labels = options.setdefault('labels', {}) labels['com.docker.compose.test-name'] = self.id() - if links: - options['links'] = links - if volumes_from: - options['volumes_from'] = volumes_from - if net: - options['net'] = net - - return Service( - project='composetest', - client=self.client, - **options - ) + return Service(project='composetest', client=self.client, **options) def check_build(self, *args, **kwargs): kwargs.setdefault('rm', True) From 73ebd7e560d6ec9ce3473ed77a91336bbf379af6 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 6 Nov 2015 13:56:25 -0500 Subject: [PATCH 1517/4072] Only create the default network if at least one service needs it. Signed-off-by: Daniel Nephin --- compose/project.py | 7 +++++-- tests/integration/project_test.py | 16 ++++++++++++++++ tests/unit/project_test.py | 28 ++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/compose/project.py b/compose/project.py index d2ba86b1bc3..41af8626151 100644 --- a/compose/project.py +++ b/compose/project.py @@ -300,7 +300,7 @@ def up(self, plans = self._get_convergence_plans(services, strategy) - if self.use_networking: + if self.use_networking and self.uses_default_network(): self.ensure_network_exists() return [ @@ -383,7 +383,10 @@ def ensure_network_exists(self): def remove_network(self): network = self.get_network() if network: - self.client.remove_network(network['id']) + self.client.remove_network(network['Id']) + + def uses_default_network(self): + return any(service.net.mode == self.name for service in self.services) def _inject_deps(self, acc, service): dep_names = service.get_dependency_names() diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 950523878e2..2ce319005fa 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -7,6 +7,7 @@ from compose.container import Container from compose.project import Project from compose.service import ConvergenceStrategy +from compose.service import Net from compose.service import VolumeFromSpec @@ -111,6 +112,7 @@ def test_get_network(self): network_name = 'network_does_exist' project = Project(network_name, [], client) client.create_network(network_name) + self.addCleanup(client.remove_network, network_name) assert project.get_network()['Name'] == network_name def test_net_from_service(self): @@ -398,6 +400,20 @@ def test_project_up_with_no_deps(self): self.assertEqual(len(project.get_service('data').containers(stopped=True)), 1) self.assertEqual(len(project.get_service('console').containers()), 0) + def test_project_up_with_custom_network(self): + self.require_api_version('1.21') + client = docker_client(version='1.21') + network_name = 'composetest-custom' + + client.create_network(network_name) + self.addCleanup(client.remove_network, network_name) + + web = self.create_service('web', net=Net(network_name)) + project = Project('composetest', [web], client, use_networking=True) + project.up() + + assert project.get_network() is None + def test_unscale_after_restart(self): web = self.create_service('web') project = Project('composetest', [web], self.client) diff --git a/tests/unit/project_test.py b/tests/unit/project_test.py index fc189fbb15c..b38f5c783c8 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -7,6 +7,8 @@ from compose.const import LABEL_SERVICE from compose.container import Container from compose.project import Project +from compose.service import ContainerNet +from compose.service import Net from compose.service import Service @@ -263,6 +265,32 @@ def test_use_net_from_service(self): service = project.get_service('test') self.assertEqual(service.net.mode, 'container:' + container_name) + def test_uses_default_network_true(self): + web = Service('web', project='test', image="alpine", net=Net('test')) + db = Service('web', project='test', image="alpine", net=Net('other')) + project = Project('test', [web, db], None) + assert project.uses_default_network() + + def test_uses_default_network_custom_name(self): + web = Service('web', project='test', image="alpine", net=Net('other')) + project = Project('test', [web], None) + assert not project.uses_default_network() + + def test_uses_default_network_host(self): + web = Service('web', project='test', image="alpine", net=Net('host')) + project = Project('test', [web], None) + assert not project.uses_default_network() + + def test_uses_default_network_container(self): + container = mock.Mock(id='test') + web = Service( + 'web', + project='test', + image="alpine", + net=ContainerNet(container)) + project = Project('test', [web], None) + assert not project.uses_default_network() + def test_container_without_name(self): self.mock_client.containers.return_value = [ {'Image': 'busybox:latest', 'Id': '1', 'Name': '1'}, From 8444551373670035544edf7bc09fc870521884e6 Mon Sep 17 00:00:00 2001 From: Kevin Greene Date: Mon, 26 Oct 2015 17:39:50 -0400 Subject: [PATCH 1518/4072] Added ulimits functionality to docker compose Signed-off-by: Kevin Greene --- compose/config/config.py | 12 +++++++ compose/config/fields_schema.json | 19 ++++++++++ compose/service.py | 19 ++++++++++ docs/compose-file.md | 11 ++++++ tests/integration/service_test.py | 31 ++++++++++++++++ tests/unit/config/config_test.py | 60 +++++++++++++++++++++++++++++++ 6 files changed, 152 insertions(+) diff --git a/compose/config/config.py b/compose/config/config.py index 434589d312f..7931608d2b7 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -345,6 +345,15 @@ def validate_extended_service_dict(service_dict, filename, service): "%s services with 'net: container' 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 process_container_options(working_dir, service_dict): service_dict = dict(service_dict) @@ -357,6 +366,9 @@ def process_container_options(working_dir, service_dict): if 'labels' in service_dict: service_dict['labels'] = parse_labels(service_dict['labels']) + if 'ulimits' in service_dict: + validate_ulimits(service_dict['ulimits']) + return service_dict diff --git a/compose/config/fields_schema.json b/compose/config/fields_schema.json index e254e3539f7..f22b513aeb2 100644 --- a/compose/config/fields_schema.json +++ b/compose/config/fields_schema.json @@ -116,6 +116,25 @@ "security_opt": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, "stdin_open": {"type": "boolean"}, "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"}, diff --git a/compose/service.py b/compose/service.py index fcf438c09fc..f2fe66cdfb4 100644 --- a/compose/service.py +++ b/compose/service.py @@ -676,6 +676,7 @@ def _get_container_host_config(self, override_options, one_off=False): devices = options.get('devices', None) cgroup_parent = options.get('cgroup_parent', None) + ulimits = build_ulimits(options.get('ulimits', None)) return self.client.create_host_config( links=self._get_links(link_to_self=one_off), @@ -692,6 +693,7 @@ def _get_container_host_config(self, override_options, one_off=False): cap_drop=cap_drop, mem_limit=options.get('mem_limit'), memswap_limit=options.get('memswap_limit'), + ulimits=ulimits, log_config=log_config, extra_hosts=extra_hosts, read_only=read_only, @@ -1055,6 +1057,23 @@ def parse_restart_spec(restart_config): return {'Name': name, 'MaximumRetryCount': int(max_retry_count)} +# Ulimits + + +def build_ulimits(ulimit_config): + if not ulimit_config: + return None + ulimits = [] + for limit_name, soft_hard_values in six.iteritems(ulimit_config): + if isinstance(soft_hard_values, six.integer_types): + ulimits.append({'name': limit_name, 'soft': soft_hard_values, 'hard': soft_hard_values}) + elif isinstance(soft_hard_values, dict): + ulimit_dict = {'name': limit_name} + ulimit_dict.update(soft_hard_values) + ulimits.append(ulimit_dict) + + return ulimits + # Extra hosts diff --git a/docs/compose-file.md b/docs/compose-file.md index 034653efe8a..9370d9207ca 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -331,6 +331,17 @@ Override the default labeling scheme for each container. - label:user:USER - label:role:ROLE +### ulimits + +Override the default ulimits for a container. You can either use a number +to set the hard and soft limits, or specify them in a dictionary. + + ulimits: + nproc: 65535 + nofile: + soft: 20000 + hard: 40000 + ### volumes, volume\_driver Mount paths as volumes, optionally specifying a path on the host machine diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 804f5219af5..2f3be89a3b8 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -22,6 +22,7 @@ from compose.const import LABEL_VERSION from compose.container import Container from compose.service import build_extra_hosts +from compose.service import build_ulimits from compose.service import ConfigError from compose.service import ConvergencePlan from compose.service import ConvergenceStrategy @@ -164,6 +165,36 @@ def test_build_extra_hosts(self): {'www.example.com': '192.168.0.17', 'api.example.com': '192.168.0.18'}) + def sort_dicts_by_name(self, dictionary_list): + return sorted(dictionary_list, key=lambda k: k['name']) + + def test_build_ulimits_with_invalid_options(self): + self.assertRaises(ConfigError, lambda: build_ulimits({'nofile': {'soft': 10000, 'hard': 10}})) + + def test_build_ulimits_with_integers(self): + self.assertEqual(build_ulimits( + {'nofile': {'soft': 10000, 'hard': 20000}}), + [{'name': 'nofile', 'soft': 10000, 'hard': 20000}]) + self.assertEqual(self.sort_dicts_by_name(build_ulimits( + {'nofile': {'soft': 10000, 'hard': 20000}, 'nproc': {'soft': 65535, 'hard': 65535}})), + self.sort_dicts_by_name([{'name': 'nofile', 'soft': 10000, 'hard': 20000}, + {'name': 'nproc', 'soft': 65535, 'hard': 65535}])) + + def test_build_ulimits_with_dicts(self): + self.assertEqual(build_ulimits( + {'nofile': 20000}), + [{'name': 'nofile', 'soft': 20000, 'hard': 20000}]) + self.assertEqual(self.sort_dicts_by_name(build_ulimits( + {'nofile': 20000, 'nproc': 65535})), + self.sort_dicts_by_name([{'name': 'nofile', 'soft': 20000, 'hard': 20000}, + {'name': 'nproc', 'soft': 65535, 'hard': 65535}])) + + def test_build_ulimits_with_integers_and_dicts(self): + self.assertEqual(self.sort_dicts_by_name(build_ulimits( + {'nproc': 65535, 'nofile': {'soft': 10000, 'hard': 20000}})), + self.sort_dicts_by_name([{'name': 'nofile', 'soft': 10000, 'hard': 20000}, + {'name': 'nproc', 'soft': 65535, 'hard': 65535}])) + 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) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index e0d2e870b8e..f27329ba0e8 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -349,6 +349,66 @@ def test_config_extra_hosts_list_of_dicts_validation_error(self): ) ) + def test_config_ulimits_invalid_keys_validation_error(self): + expected_error_msg = "Service 'web' configuration key 'ulimits' contains unsupported option: 'not_soft_or_hard'" + + with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): + config.load( + build_config_details( + {'web': { + 'image': 'busybox', + 'ulimits': { + 'nofile': { + "not_soft_or_hard": 100, + "soft": 10000, + "hard": 20000, + } + } + }}, + 'working_dir', + 'filename.yml' + ) + ) + + def test_config_ulimits_required_keys_validation_error(self): + expected_error_msg = "Service 'web' configuration key 'ulimits' u?'hard' is a required property" + + with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): + config.load( + build_config_details( + {'web': { + 'image': 'busybox', + 'ulimits': { + 'nofile': { + "soft": 10000, + } + } + }}, + 'working_dir', + 'filename.yml' + ) + ) + + def test_config_ulimits_soft_greater_than_hard_error(self): + expected_error_msg = "cannot contain a 'soft' value higher than 'hard' value" + + with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): + config.load( + build_config_details( + {'web': { + 'image': 'busybox', + 'ulimits': { + 'nofile': { + "soft": 10000, + "hard": 1000 + } + } + }}, + 'working_dir', + 'filename.yml' + ) + ) + def test_valid_config_which_allows_two_type_definitions(self): expose_values = [["8000"], [8000]] for expose in expose_values: From 1208f92d9cca35f378d8d5c497710e5481e7d7b1 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 10 Nov 2015 13:37:07 -0500 Subject: [PATCH 1519/4072] Update doc wording for ulimits. and move tests to the correct module Signed-off-by: Daniel Nephin --- docs/compose-file.md | 5 ++-- tests/integration/service_test.py | 31 ----------------------- tests/unit/service_test.py | 42 +++++++++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 33 deletions(-) diff --git a/docs/compose-file.md b/docs/compose-file.md index 9370d9207ca..d5aeaa3f29f 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -333,8 +333,9 @@ Override the default labeling scheme for each container. ### ulimits -Override the default ulimits for a container. You can either use a number -to set the hard and soft limits, or specify them in a dictionary. +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 diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 2f3be89a3b8..804f5219af5 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -22,7 +22,6 @@ from compose.const import LABEL_VERSION from compose.container import Container from compose.service import build_extra_hosts -from compose.service import build_ulimits from compose.service import ConfigError from compose.service import ConvergencePlan from compose.service import ConvergenceStrategy @@ -165,36 +164,6 @@ def test_build_extra_hosts(self): {'www.example.com': '192.168.0.17', 'api.example.com': '192.168.0.18'}) - def sort_dicts_by_name(self, dictionary_list): - return sorted(dictionary_list, key=lambda k: k['name']) - - def test_build_ulimits_with_invalid_options(self): - self.assertRaises(ConfigError, lambda: build_ulimits({'nofile': {'soft': 10000, 'hard': 10}})) - - def test_build_ulimits_with_integers(self): - self.assertEqual(build_ulimits( - {'nofile': {'soft': 10000, 'hard': 20000}}), - [{'name': 'nofile', 'soft': 10000, 'hard': 20000}]) - self.assertEqual(self.sort_dicts_by_name(build_ulimits( - {'nofile': {'soft': 10000, 'hard': 20000}, 'nproc': {'soft': 65535, 'hard': 65535}})), - self.sort_dicts_by_name([{'name': 'nofile', 'soft': 10000, 'hard': 20000}, - {'name': 'nproc', 'soft': 65535, 'hard': 65535}])) - - def test_build_ulimits_with_dicts(self): - self.assertEqual(build_ulimits( - {'nofile': 20000}), - [{'name': 'nofile', 'soft': 20000, 'hard': 20000}]) - self.assertEqual(self.sort_dicts_by_name(build_ulimits( - {'nofile': 20000, 'nproc': 65535})), - self.sort_dicts_by_name([{'name': 'nofile', 'soft': 20000, 'hard': 20000}, - {'name': 'nproc', 'soft': 65535, 'hard': 65535}])) - - def test_build_ulimits_with_integers_and_dicts(self): - self.assertEqual(self.sort_dicts_by_name(build_ulimits( - {'nproc': 65535, 'nofile': {'soft': 10000, 'hard': 20000}})), - self.sort_dicts_by_name([{'name': 'nofile', 'soft': 10000, 'hard': 20000}, - {'name': 'nproc', 'soft': 65535, 'hard': 65535}])) - 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) diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index af7007d3908..f086b10755b 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -12,6 +12,7 @@ from compose.const import LABEL_PROJECT 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 ConfigError from compose.service import ContainerNet @@ -435,6 +436,47 @@ def test_get_links_with_networking(self): self.assertEqual(service._get_links(link_to_self=True), []) +def sort_by_name(dictionary_list): + return sorted(dictionary_list, key=lambda k: k['name']) + + +class BuildUlimitsTestCase(unittest.TestCase): + + def test_build_ulimits_with_dict(self): + ulimits = build_ulimits( + { + 'nofile': {'soft': 10000, 'hard': 20000}, + 'nproc': {'soft': 65535, 'hard': 65535} + } + ) + expected = [ + {'name': 'nofile', 'soft': 10000, 'hard': 20000}, + {'name': 'nproc', 'soft': 65535, 'hard': 65535} + ] + assert sort_by_name(ulimits) == sort_by_name(expected) + + def test_build_ulimits_with_ints(self): + ulimits = build_ulimits({'nofile': 20000, 'nproc': 65535}) + expected = [ + {'name': 'nofile', 'soft': 20000, 'hard': 20000}, + {'name': 'nproc', 'soft': 65535, 'hard': 65535} + ] + assert sort_by_name(ulimits) == sort_by_name(expected) + + def test_build_ulimits_with_integers_and_dicts(self): + ulimits = build_ulimits( + { + 'nproc': 65535, + 'nofile': {'soft': 10000, 'hard': 20000} + } + ) + expected = [ + {'name': 'nofile', 'soft': 10000, 'hard': 20000}, + {'name': 'nproc', 'soft': 65535, 'hard': 65535} + ] + assert sort_by_name(ulimits) == sort_by_name(expected) + + class NetTestCase(unittest.TestCase): def test_net(self): From 92d56fab475256a732ba4e98b993c5a5d626d0d5 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 4 Nov 2015 18:00:24 -0500 Subject: [PATCH 1520/4072] Add a warning when the host volume config is being ignored. Signed-off-by: Daniel Nephin --- compose/service.py | 29 ++++++++++++++++++++++++----- tests/integration/service_test.py | 27 +++++++++++++++++++++++++++ tests/unit/service_test.py | 12 ++++++------ 3 files changed, 57 insertions(+), 11 deletions(-) diff --git a/compose/service.py b/compose/service.py index f2fe66cdfb4..cc491599263 100644 --- a/compose/service.py +++ b/compose/service.py @@ -902,8 +902,10 @@ def merge_volume_bindings(volumes_option, previous_container): 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) volume_bindings.update( - get_container_data_volumes(previous_container, volumes)) + build_volume_binding(volume) for volume in data_volumes) return list(volume_bindings.values()) @@ -913,7 +915,6 @@ def get_container_data_volumes(container, volumes_option): a mapping of volume bindings for those volumes. """ volumes = [] - container_volumes = container.get('Volumes') or {} image_volumes = [ parse_volume_spec(volume) @@ -933,9 +934,27 @@ def get_container_data_volumes(container, volumes_option): # Copy existing volume from old container volume = volume._replace(external=volume_path) - volumes.append(build_volume_binding(volume)) - - return dict(volumes) + volumes.append(volume) + + return volumes + + +def warn_on_masked_volume(volumes_option, container_volumes, service): + container_volumes = dict( + (volume.internal, volume.external) + for volume in container_volumes) + + for volume in volumes_option: + if container_volumes.get(volume.internal) != volume.external: + log.warn(( + "Service \"{service}\" is using volume \"{volume}\" from the " + "previous container. Host mapping \"{host_path}\" has no effect. " + "Remove the existing containers (with `docker-compose rm {service}`) " + "to use the host volume mapping." + ).format( + service=service, + volume=volume.internal, + host_path=volume.external)) def build_volume_binding(volume_spec): diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 804f5219af5..88214e83621 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -369,6 +369,33 @@ def test_execute_convergence_plan_with_image_declared_volume(self): self.assertEqual(list(new_container.get('Volumes')), ['/data']) self.assertEqual(new_container.get('Volumes')['/data'], volume_path) + def test_execute_convergence_plan_when_image_volume_masks_config(self): + service = Service( + project='composetest', + name='db', + client=self.client, + build='tests/fixtures/dockerfile-with-volume', + ) + + old_container = create_and_start_container(service) + self.assertEqual(list(old_container.get('Volumes').keys()), ['/data']) + volume_path = old_container.get('Volumes')['/data'] + + service.options['volumes'] = ['/tmp:/data'] + + with mock.patch('compose.service.log') as mock_log: + new_container, = service.execute_convergence_plan( + ConvergencePlan('recreate', [old_container])) + + mock_log.warn.assert_called_once_with(mock.ANY) + _, args, kwargs = mock_log.warn.mock_calls[0] + self.assertIn( + "Service \"db\" is using volume \"/data\" from the previous container", + args[0]) + + self.assertEqual(list(new_container.get('Volumes')), ['/data']) + self.assertEqual(new_container.get('Volumes')['/data'], volume_path) + def test_start_container_passes_through_options(self): db = self.create_service('db') create_and_start_container(db, environment={'FOO': 'BAR'}) diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index f086b10755b..311d5c95e77 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -587,13 +587,13 @@ def test_get_container_data_volumes(self): }, }, has_been_inspected=True) - expected = { - '/existing/volume': '/var/lib/docker/aaaaaaaa:/existing/volume:rw', - '/mnt/image/data': '/var/lib/docker/cccccccc:/mnt/image/data:rw', - } + expected = [ + parse_volume_spec('/var/lib/docker/aaaaaaaa:/existing/volume:rw'), + parse_volume_spec('/var/lib/docker/cccccccc:/mnt/image/data:rw'), + ] - binds = get_container_data_volumes(container, options) - self.assertEqual(binds, expected) + volumes = get_container_data_volumes(container, options) + self.assertEqual(sorted(volumes), sorted(expected)) def test_merge_volume_bindings(self): options = [ From 3313dcb1ce7306fc6936e547c86f6cf53af08a31 Mon Sep 17 00:00:00 2001 From: Yves Peter Date: Wed, 4 Nov 2015 23:40:57 +0100 Subject: [PATCH 1521/4072] Fixes #1490 progress_stream would print a lot of new lines on "docker-compose pull" if there's no tty. Signed-off-by: Yves Peter --- compose/progress_stream.py | 23 +++++++++++++--------- tests/unit/progress_stream_test.py | 31 ++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 9 deletions(-) diff --git a/compose/progress_stream.py b/compose/progress_stream.py index ac8e4b410ff..c729a6df0a8 100644 --- a/compose/progress_stream.py +++ b/compose/progress_stream.py @@ -14,8 +14,14 @@ def stream_output(output, stream): for event in utils.json_stream(output): all_events.append(event) + is_progress_event = 'progress' in event or 'progressDetail' in event - if 'progress' in event or 'progressDetail' in event: + if not is_progress_event: + print_output_event(event, stream, is_terminal) + stream.flush() + + # if it's a progress event and we have a terminal, then display the progress bars + elif is_terminal: image_id = event.get('id') if not image_id: continue @@ -27,17 +33,16 @@ def stream_output(output, stream): stream.write("\n") diff = 0 - if is_terminal: - # move cursor up `diff` rows - stream.write("%c[%dA" % (27, diff)) + # move cursor up `diff` rows + stream.write("%c[%dA" % (27, diff)) - print_output_event(event, stream, is_terminal) + print_output_event(event, stream, is_terminal) - if 'id' in event and is_terminal: - # move cursor back down - stream.write("%c[%dB" % (27, diff)) + if 'id' in event: + # move cursor back down + stream.write("%c[%dB" % (27, diff)) - stream.flush() + stream.flush() return all_events diff --git a/tests/unit/progress_stream_test.py b/tests/unit/progress_stream_test.py index d8f7ec83633..b01be11a860 100644 --- a/tests/unit/progress_stream_test.py +++ b/tests/unit/progress_stream_test.py @@ -34,3 +34,34 @@ def test_stream_output_null_total(self): ] events = progress_stream.stream_output(output, StringIO()) self.assertEqual(len(events), 1) + + def test_stream_output_progress_event_tty(self): + events = [ + b'{"status": "Already exists", "progressDetail": {}, "id": "8d05e3af52b0"}' + ] + + class TTYStringIO(StringIO): + def isatty(self): + return True + + output = TTYStringIO() + events = progress_stream.stream_output(events, output) + self.assertTrue(len(output.getvalue()) > 0) + + def test_stream_output_progress_event_no_tty(self): + events = [ + b'{"status": "Already exists", "progressDetail": {}, "id": "8d05e3af52b0"}' + ] + output = StringIO() + + events = progress_stream.stream_output(events, output) + self.assertEqual(len(output.getvalue()), 0) + + def test_stream_output_no_progress_event_no_tty(self): + events = [ + b'{"status": "Pulling from library/xy", "id": "latest"}' + ] + output = StringIO() + + events = progress_stream.stream_output(events, output) + self.assertTrue(len(output.getvalue()) > 0) From ba90f55075a5e07ec23874707206260eb372734c Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 11 Nov 2015 11:21:24 -0800 Subject: [PATCH 1522/4072] Reorganize conditional branches to improve readability Signed-off-by: Joffrey F --- compose/progress_stream.py | 37 ++++++++++++++++++++----------------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/compose/progress_stream.py b/compose/progress_stream.py index c729a6df0a8..a6c8e0a2639 100644 --- a/compose/progress_stream.py +++ b/compose/progress_stream.py @@ -19,30 +19,33 @@ def stream_output(output, stream): if not is_progress_event: print_output_event(event, stream, is_terminal) stream.flush() + continue + + if not is_terminal: + continue # if it's a progress event and we have a terminal, then display the progress bars - elif is_terminal: - image_id = event.get('id') - if not image_id: - continue + image_id = event.get('id') + if not image_id: + continue - if image_id in lines: - diff = len(lines) - lines[image_id] - else: - lines[image_id] = len(lines) - stream.write("\n") - diff = 0 + if image_id in lines: + diff = len(lines) - lines[image_id] + else: + lines[image_id] = len(lines) + stream.write("\n") + diff = 0 - # move cursor up `diff` rows - stream.write("%c[%dA" % (27, diff)) + # move cursor up `diff` rows + stream.write("%c[%dA" % (27, diff)) - print_output_event(event, stream, is_terminal) + print_output_event(event, stream, is_terminal) - if 'id' in event: - # move cursor back down - stream.write("%c[%dB" % (27, diff)) + if 'id' in event: + # move cursor back down + stream.write("%c[%dB" % (27, diff)) - stream.flush() + stream.flush() return all_events From 83581c3a0ff393af4529734f079afcf26e5c6191 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 6 Nov 2015 16:38:38 -0500 Subject: [PATCH 1523/4072] Validate additional files before merging them. Consolidates all the top level config handling into `process_config_file` which is now used for both files and merge sources. Signed-off-by: Daniel Nephin --- compose/cli/main.py | 2 +- compose/config/__init__.py | 1 - compose/config/config.py | 56 +++++++++++++++++--------------- compose/config/validation.py | 10 +----- tests/unit/config/config_test.py | 13 ++++++++ 5 files changed, 45 insertions(+), 37 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 08c1aee0752..806926d845c 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -13,12 +13,12 @@ from .. import __version__ from .. import legacy +from ..config import ConfigurationError from ..config import parse_environment from ..const import DEFAULT_TIMEOUT from ..const import HTTP_TIMEOUT from ..const import IS_WINDOWS_PLATFORM from ..progress_stream import StreamOutputError -from ..project import ConfigurationError from ..project import NoSuchService from ..service import BuildError from ..service import ConvergenceStrategy diff --git a/compose/config/__init__.py b/compose/config/__init__.py index de6f10c9498..ec607e087ec 100644 --- a/compose/config/__init__.py +++ b/compose/config/__init__.py @@ -1,5 +1,4 @@ # flake8: noqa -from .config import ConfigDetails 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 7931608d2b7..feef0387718 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -13,7 +13,6 @@ from .interpolation import interpolate_environment_variables from .validation import validate_against_fields_schema from .validation import validate_against_service_schema -from .validation import validate_extended_service_exists from .validation import validate_extends_file_path from .validation import validate_top_level_object @@ -99,6 +98,10 @@ class ConfigFile(namedtuple('_ConfigFile', 'filename config')): :type config: :class:`dict` """ + @classmethod + def from_filename(cls, filename): + return cls(filename, load_yaml(filename)) + def find(base_dir, filenames): if filenames == ['-']: @@ -114,7 +117,7 @@ def find(base_dir, filenames): log.debug("Using configuration files: {}".format(",".join(filenames))) return ConfigDetails( os.path.dirname(filenames[0]), - [ConfigFile(f, load_yaml(f)) for f in filenames]) + [ConfigFile.from_filename(f) for f in filenames]) def get_default_config_files(base_dir): @@ -183,12 +186,10 @@ def build_service(filename, service_name, service_dict): validate_paths(service_dict) return service_dict - def load_file(filename, config): - processed_config = interpolate_environment_variables(config) - validate_against_fields_schema(processed_config) + def build_services(filename, config): return [ build_service(filename, name, service_config) - for name, service_config in processed_config.items() + for name, service_config in config.items() ] def merge_services(base, override): @@ -200,16 +201,27 @@ def merge_services(base, override): for name in all_service_names } - config_file = config_details.config_files[0] - validate_top_level_object(config_file.config) + config_file = process_config_file(config_details.config_files[0]) for next_file in config_details.config_files[1:]: - validate_top_level_object(next_file.config) + next_file = process_config_file(next_file) + + config = merge_services(config_file.config, next_file.config) + config_file = config_file._replace(config=config) + + return build_services(config_file.filename, config_file.config) + + +def process_config_file(config_file, service_name=None): + validate_top_level_object(config_file.config) + processed_config = interpolate_environment_variables(config_file.config) + validate_against_fields_schema(processed_config) - config_file = ConfigFile( - config_file.filename, - merge_services(config_file.config, next_file.config)) + if service_name and service_name not in processed_config: + raise ConfigurationError( + "Cannot extend service '{}' in {}: Service not found".format( + service_name, config_file.filename)) - return load_file(config_file.filename, config_file.config) + return config_file._replace(config=processed_config) class ServiceLoader(object): @@ -259,22 +271,13 @@ def validate_and_construct_extends(self): if not isinstance(extends, dict): extends = {'service': extends} - validate_extends_file_path(self.service_name, extends, self.filename) config_path = self.get_extended_config_path(extends) service_name = extends['service'] - config = load_yaml(config_path) - validate_top_level_object(config) - full_extended_config = interpolate_environment_variables(config) - - validate_extended_service_exists( - service_name, - full_extended_config, - config_path - ) - validate_against_fields_schema(full_extended_config) - - service_config = full_extended_config[service_name] + extended_file = process_config_file( + ConfigFile.from_filename(config_path), + service_name=service_name) + service_config = extended_file.config[service_name] return config_path, service_config, service_name def resolve_extends(self, extended_config_path, service_config, service_name): @@ -304,6 +307,7 @@ def get_extended_config_path(self, extends_options): need to obtain a full path too or we are extending from a service defined in our own file. """ + validate_extends_file_path(self.service_name, extends_options, self.filename) if 'file' in extends_options: return expand_path(self.working_dir, extends_options['file']) return self.filename diff --git a/compose/config/validation.py b/compose/config/validation.py index 542081d5265..3bd404109dd 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -96,14 +96,6 @@ def validate_extends_file_path(service_name, extends_options, filename): ) -def validate_extended_service_exists(extended_service_name, full_extended_config, extended_config_path): - if extended_service_name not in full_extended_config: - msg = ( - "Cannot extend service '%s' in %s: Service not found" - ) % (extended_service_name, extended_config_path) - raise ConfigurationError(msg) - - 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: @@ -264,7 +256,7 @@ def _parse_oneof_validator(error): msg)) else: root_msgs.append( - "Service '{}' doesn\'t have any configuration options. " + "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(service_name)) elif error.validator == 'required': diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index f27329ba0e8..ab34f4dcb6c 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -195,6 +195,19 @@ def test_load_with_multiple_files_and_extends_in_override_file(self): ] self.assertEqual(service_sort(service_dicts), service_sort(expected)) + def test_load_with_multiple_files_and_invalid_override(self): + base_file = config.ConfigFile( + 'base.yaml', + {'web': {'image': 'example/web'}}) + override_file = config.ConfigFile( + 'override.yaml', + {'bogus': 'thing'}) + details = config.ConfigDetails('.', [base_file, override_file]) + + with pytest.raises(ConfigurationError) as exc: + config.load(details) + assert 'Service "bogus" doesn\'t have any configuration' in exc.exconly() + def test_config_valid_service_names(self): for valid_name in ['_', '-', '.__.', '_what-up.', 'what_.up----', 'whatup']: config.load( From 87d79d4d993bc06c16ae19aba71aadd88292abb3 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 6 Nov 2015 17:18:47 -0500 Subject: [PATCH 1524/4072] Rename ServiceLoader to ServiceExtendsResolver ServiceLoader has evolved to be not really all that related to "loading" a service. It's responsibility is more to do with handling the `extends` field, which is only part of loading. The class and its primary method (make_service_dict()) were renamed to better reflect their responsibility. As part of that change process_container_options() was removed from make_service_dict() and renamed to process_service(). It contains logic for handling the non-extends options. This change allows us to remove the hacks from testcase.py and only call the functions we need to format a service dict correctly for integration tests. Signed-off-by: Daniel Nephin --- compose/config/config.py | 27 ++++++++++++--------------- tests/integration/service_test.py | 25 ++++++++++++++++++++++--- tests/integration/testcases.py | 21 +++++++-------------- tests/unit/config/config_test.py | 7 ++++--- 4 files changed, 45 insertions(+), 35 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index feef0387718..7846ea7b46f 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -177,12 +177,12 @@ def load(config_details): """ def build_service(filename, service_name, service_dict): - loader = ServiceLoader( + resolver = ServiceExtendsResolver( config_details.working_dir, filename, service_name, service_dict) - service_dict = loader.make_service_dict() + service_dict = process_service(config_details.working_dir, resolver.run()) validate_paths(service_dict) return service_dict @@ -224,7 +224,7 @@ def process_config_file(config_file, service_name=None): return config_file._replace(config=processed_config) -class ServiceLoader(object): +class ServiceExtendsResolver(object): def __init__( self, working_dir, @@ -234,7 +234,7 @@ def __init__( already_seen=None ): if working_dir is None: - raise ValueError("No working_dir passed to ServiceLoader()") + raise ValueError("No working_dir passed to ServiceExtendsResolver()") self.working_dir = os.path.abspath(working_dir) @@ -251,7 +251,7 @@ def detect_cycle(self, name): if self.signature(name) in self.already_seen: raise CircularReference(self.already_seen + [self.signature(name)]) - def make_service_dict(self): + def run(self): service_dict = dict(self.service_dict) env = resolve_environment(self.working_dir, self.service_dict) if env: @@ -264,7 +264,7 @@ def make_service_dict(self): if not self.already_seen: validate_against_service_schema(service_dict, self.service_name) - return process_container_options(self.working_dir, service_dict) + return service_dict def validate_and_construct_extends(self): extends = self.service_dict['extends'] @@ -281,19 +281,16 @@ def validate_and_construct_extends(self): return config_path, service_config, service_name def resolve_extends(self, extended_config_path, service_config, service_name): - other_working_dir = os.path.dirname(extended_config_path) - other_already_seen = self.already_seen + [self.signature(self.service_name)] - - other_loader = ServiceLoader( - other_working_dir, + resolver = ServiceExtendsResolver( + os.path.dirname(extended_config_path), extended_config_path, self.service_name, service_config, - already_seen=other_already_seen, + already_seen=self.already_seen + [self.signature(self.service_name)], ) - other_loader.detect_cycle(service_name) - other_service_dict = other_loader.make_service_dict() + resolver.detect_cycle(service_name) + other_service_dict = process_service(resolver.working_dir, resolver.run()) validate_extended_service_dict( other_service_dict, extended_config_path, @@ -358,7 +355,7 @@ def validate_ulimits(ulimit_config): "than 'hard' value".format(ulimit_config)) -def process_container_options(working_dir, service_dict): +def process_service(working_dir, service_dict): service_dict = dict(service_dict) if 'volumes' in service_dict and service_dict.get('volume_driver') is None: diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 88214e83621..aaa4f01ec07 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -842,7 +842,13 @@ def test_env_from_file_combined_with_env(self): environment=['ONE=1', 'TWO=2', 'THREE=3'], env_file=['tests/fixtures/env/one.env', 'tests/fixtures/env/two.env']) env = create_and_start_container(service).environment - for k, v in {'ONE': '1', 'TWO': '2', 'THREE': '3', 'FOO': 'baz', 'DOO': 'dah'}.items(): + for k, v in { + 'ONE': '1', + 'TWO': '2', + 'THREE': '3', + 'FOO': 'baz', + 'DOO': 'dah' + }.items(): self.assertEqual(env[k], v) @mock.patch.dict(os.environ) @@ -850,9 +856,22 @@ def test_resolve_env(self): os.environ['FILE_DEF'] = 'E1' os.environ['FILE_DEF_EMPTY'] = 'E2' os.environ['ENV_DEF'] = 'E3' - service = self.create_service('web', environment={'FILE_DEF': 'F1', 'FILE_DEF_EMPTY': '', 'ENV_DEF': None, 'NO_DEF': None}) + service = self.create_service( + 'web', + environment={ + 'FILE_DEF': 'F1', + 'FILE_DEF_EMPTY': '', + 'ENV_DEF': None, + 'NO_DEF': None + } + ) env = create_and_start_container(service).environment - for k, v in {'FILE_DEF': 'F1', 'FILE_DEF_EMPTY': '', 'ENV_DEF': 'E3', 'NO_DEF': ''}.items(): + for k, v in { + 'FILE_DEF': 'F1', + 'FILE_DEF_EMPTY': '', + 'ENV_DEF': 'E3', + 'NO_DEF': '' + }.items(): self.assertEqual(env[k], v) def test_with_high_enough_api_version_we_get_default_network_mode(self): diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index 60e67b5bf4f..5ee6a421288 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -7,7 +7,8 @@ from .. import unittest from compose.cli.docker_client import docker_client -from compose.config.config import ServiceLoader +from compose.config.config import process_service +from compose.config.config import resolve_environment from compose.const import LABEL_PROJECT from compose.progress_stream import stream_output from compose.service import Service @@ -42,23 +43,15 @@ def create_service(self, name, **kwargs): if 'command' not in kwargs: kwargs['command'] = ["top"] - workaround_options = {} - for option in ['links', 'volumes_from', 'net']: - if option in kwargs: - workaround_options[option] = kwargs.pop(option, None) - - options = ServiceLoader( - working_dir='.', - filename=None, - service_name=name, - service_dict=kwargs - ).make_service_dict() - options.update(workaround_options) + # TODO: remove this once #2299 is fixed + kwargs['name'] = name + options = process_service('.', kwargs) + options['environment'] = resolve_environment('.', kwargs) labels = options.setdefault('labels', {}) labels['com.docker.compose.test-name'] = self.id() - return Service(project='composetest', client=self.client, **options) + return Service(client=self.client, project='composetest', **options) def check_build(self, *args, **kwargs): kwargs.setdefault('rm', True) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index ab34f4dcb6c..2835a4317f6 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -18,13 +18,14 @@ def make_service_dict(name, service_dict, working_dir, filename=None): """ - Test helper function to construct a ServiceLoader + Test helper function to construct a ServiceExtendsResolver """ - return config.ServiceLoader( + resolver = config.ServiceExtendsResolver( working_dir=working_dir, filename=filename, service_name=name, - service_dict=service_dict).make_service_dict() + service_dict=service_dict) + return config.process_service(working_dir, resolver.run()) def service_sort(services): From 3a43110f062a00b7e1256d7594f0dd345447c6fc Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 9 Nov 2015 14:24:44 -0500 Subject: [PATCH 1525/4072] Fix a bug in ExtendsResolver where the service name of the extended service was wrong. This bug can be seen by the change to the test case. When the extended service uses a different name, the error was reported incorrectly. By fixing this bug we can simplify self.signature and self.detect_cycles to always use self.service_name. Signed-off-by: Daniel Nephin --- compose/config/config.py | 20 +++++++++++--------- tests/fixtures/extends/circle-1.yml | 2 +- tests/fixtures/extends/circle-2.yml | 2 +- tests/unit/config/config_test.py | 23 ++++++++++++----------- 4 files changed, 25 insertions(+), 22 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 7846ea7b46f..1ddb2abe046 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -247,11 +247,17 @@ def __init__( self.service_name = service_name self.service_dict['name'] = service_name - def detect_cycle(self, name): - if self.signature(name) in self.already_seen: - raise CircularReference(self.already_seen + [self.signature(name)]) + @property + def signature(self): + return self.filename, self.service_name + + def detect_cycle(self): + if self.signature in self.already_seen: + raise CircularReference(self.already_seen + [self.signature]) def run(self): + self.detect_cycle() + service_dict = dict(self.service_dict) env = resolve_environment(self.working_dir, self.service_dict) if env: @@ -284,12 +290,11 @@ def resolve_extends(self, extended_config_path, service_config, service_name): resolver = ServiceExtendsResolver( os.path.dirname(extended_config_path), extended_config_path, - self.service_name, + service_name, service_config, - already_seen=self.already_seen + [self.signature(self.service_name)], + already_seen=self.already_seen + [self.signature], ) - resolver.detect_cycle(service_name) other_service_dict = process_service(resolver.working_dir, resolver.run()) validate_extended_service_dict( other_service_dict, @@ -309,9 +314,6 @@ def get_extended_config_path(self, extends_options): return expand_path(self.working_dir, extends_options['file']) return self.filename - def signature(self, name): - return self.filename, name - def resolve_environment(working_dir, service_dict): """Unpack any environment variables from an env_file, if set. diff --git a/tests/fixtures/extends/circle-1.yml b/tests/fixtures/extends/circle-1.yml index a034e9619e0..d88ea61d0ea 100644 --- a/tests/fixtures/extends/circle-1.yml +++ b/tests/fixtures/extends/circle-1.yml @@ -5,7 +5,7 @@ bar: web: extends: file: circle-2.yml - service: web + service: other baz: image: busybox quux: diff --git a/tests/fixtures/extends/circle-2.yml b/tests/fixtures/extends/circle-2.yml index fa6ddefcc34..de05bc8da0a 100644 --- a/tests/fixtures/extends/circle-2.yml +++ b/tests/fixtures/extends/circle-2.yml @@ -2,7 +2,7 @@ foo: image: busybox bar: image: busybox -web: +other: extends: file: circle-1.yml service: web diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 2835a4317f6..71783168171 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1080,18 +1080,19 @@ def test_self_referencing_file(self): ])) def test_circular(self): - try: + with pytest.raises(config.CircularReference) as exc: load_from_filename('tests/fixtures/extends/circle-1.yml') - raise Exception("Expected config.CircularReference to be raised") - except config.CircularReference as e: - self.assertEqual( - [(os.path.basename(filename), service_name) for (filename, service_name) in e.trail], - [ - ('circle-1.yml', 'web'), - ('circle-2.yml', 'web'), - ('circle-1.yml', 'web'), - ], - ) + + path = [ + (os.path.basename(filename), service_name) + for (filename, service_name) in exc.value.trail + ] + expected = [ + ('circle-1.yml', 'web'), + ('circle-2.yml', 'other'), + ('circle-1.yml', 'web'), + ] + self.assertEqual(path, expected) def test_extends_validation_empty_dictionary(self): with self.assertRaisesRegexp(ConfigurationError, 'service'): From 7fc577c31de5f316de543576609c675b177291a7 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 6 Nov 2015 11:57:27 -0500 Subject: [PATCH 1526/4072] Remove name from config schema. Refactors config validation of a service to use a ServiceConfig data object. Instead of passing around a bunch of related scalars, we can use the ServiceConfig object as a parameter to most of the service validation functions. This allows for a fix to the config schema, where the name is a field in the schema, but not actually in the configuration. My passing the name around as part of the ServiceConfig object, we don't need to add it to the config options. Fixes #2299 validate_against_service_schema() is moved from a conditional branch in ServiceExtendsResolver() to happen as one of the last steps after all configuration is merged. This schema only contains constraints which only need to be true at the very end of merging. Signed-off-by: Daniel Nephin --- compose/config/config.py | 103 +++++++++++++++-------------- compose/config/fields_schema.json | 1 - compose/config/service_schema.json | 6 -- compose/config/validation.py | 2 +- tests/integration/testcases.py | 9 ++- tests/unit/config/config_test.py | 10 +-- 6 files changed, 63 insertions(+), 68 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 1ddb2abe046..21788551da6 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -103,6 +103,20 @@ def from_filename(cls, filename): return cls(filename, load_yaml(filename)) +class ServiceConfig(namedtuple('_ServiceConfig', 'working_dir filename name config')): + + @classmethod + def with_abs_paths(cls, working_dir, filename, name, config): + if not working_dir: + raise ValueError("No working_dir for ServiceConfig.") + + return cls( + os.path.abspath(working_dir), + os.path.abspath(filename) if filename else filename, + name, + config) + + def find(base_dir, filenames): if filenames == ['-']: return ConfigDetails( @@ -177,19 +191,22 @@ def load(config_details): """ def build_service(filename, service_name, service_dict): - resolver = ServiceExtendsResolver( + service_config = ServiceConfig.with_abs_paths( config_details.working_dir, filename, service_name, service_dict) - service_dict = process_service(config_details.working_dir, resolver.run()) + resolver = ServiceExtendsResolver(service_config) + service_dict = process_service(resolver.run()) + validate_against_service_schema(service_dict, service_config.name) validate_paths(service_dict) + service_dict['name'] = service_config.name return service_dict - def build_services(filename, config): + def build_services(config_file): return [ - build_service(filename, name, service_config) - for name, service_config in config.items() + build_service(config_file.filename, name, service_dict) + for name, service_dict in config_file.config.items() ] def merge_services(base, override): @@ -208,7 +225,7 @@ def merge_services(base, override): config = merge_services(config_file.config, next_file.config) config_file = config_file._replace(config=config) - return build_services(config_file.filename, config_file.config) + return build_services(config_file) def process_config_file(config_file, service_name=None): @@ -225,31 +242,14 @@ def process_config_file(config_file, service_name=None): class ServiceExtendsResolver(object): - def __init__( - self, - working_dir, - filename, - service_name, - service_dict, - already_seen=None - ): - if working_dir is None: - raise ValueError("No working_dir passed to ServiceExtendsResolver()") - - self.working_dir = os.path.abspath(working_dir) - - if filename: - self.filename = os.path.abspath(filename) - else: - self.filename = filename + def __init__(self, service_config, already_seen=None): + self.service_config = service_config + self.working_dir = service_config.working_dir self.already_seen = already_seen or [] - self.service_dict = service_dict.copy() - self.service_name = service_name - self.service_dict['name'] = service_name @property def signature(self): - return self.filename, self.service_name + return self.service_config.filename, self.service_config.name def detect_cycle(self): if self.signature in self.already_seen: @@ -258,8 +258,8 @@ def detect_cycle(self): def run(self): self.detect_cycle() - service_dict = dict(self.service_dict) - env = resolve_environment(self.working_dir, self.service_dict) + service_dict = dict(self.service_config.config) + env = resolve_environment(self.working_dir, self.service_config.config) if env: service_dict['environment'] = env service_dict.pop('env_file', None) @@ -267,13 +267,10 @@ def run(self): if 'extends' in service_dict: service_dict = self.resolve_extends(*self.validate_and_construct_extends()) - if not self.already_seen: - validate_against_service_schema(service_dict, self.service_name) - - return service_dict + return self.service_config._replace(config=service_dict) def validate_and_construct_extends(self): - extends = self.service_dict['extends'] + extends = self.service_config.config['extends'] if not isinstance(extends, dict): extends = {'service': extends} @@ -286,33 +283,38 @@ def validate_and_construct_extends(self): service_config = extended_file.config[service_name] return config_path, service_config, service_name - def resolve_extends(self, extended_config_path, service_config, service_name): + def resolve_extends(self, extended_config_path, service_dict, service_name): resolver = ServiceExtendsResolver( - os.path.dirname(extended_config_path), - extended_config_path, - service_name, - service_config, - already_seen=self.already_seen + [self.signature], - ) - - other_service_dict = process_service(resolver.working_dir, resolver.run()) + ServiceConfig.with_abs_paths( + os.path.dirname(extended_config_path), + extended_config_path, + service_name, + service_dict), + already_seen=self.already_seen + [self.signature]) + + service_config = resolver.run() + other_service_dict = process_service(service_config) validate_extended_service_dict( other_service_dict, extended_config_path, service_name, ) - return merge_service_dicts(other_service_dict, self.service_dict) + return merge_service_dicts(other_service_dict, self.service_config.config) def get_extended_config_path(self, extends_options): """Service we are extending either has a value for 'file' set, which we need to obtain a full path too or we are extending from a service defined in our own file. """ - validate_extends_file_path(self.service_name, extends_options, self.filename) + filename = self.service_config.filename + validate_extends_file_path( + self.service_config.name, + extends_options, + filename) if 'file' in extends_options: return expand_path(self.working_dir, extends_options['file']) - return self.filename + return filename def resolve_environment(working_dir, service_dict): @@ -357,8 +359,9 @@ def validate_ulimits(ulimit_config): "than 'hard' value".format(ulimit_config)) -def process_service(working_dir, service_dict): - service_dict = dict(service_dict) +def process_service(service_config): + working_dir = service_config.working_dir + service_dict = dict(service_config.config) if 'volumes' in service_dict and service_dict.get('volume_driver') is None: service_dict['volumes'] = resolve_volume_paths(working_dir, service_dict) @@ -505,12 +508,12 @@ def env_vars_from_file(filename): def resolve_volume_paths(working_dir, service_dict): return [ - resolve_volume_path(working_dir, volume, service_dict['name']) + resolve_volume_path(working_dir, volume) for volume in service_dict['volumes'] ] -def resolve_volume_path(working_dir, volume, service_name): +def resolve_volume_path(working_dir, volume): container_path, host_path = split_path_mapping(volume) if host_path is not None: diff --git a/compose/config/fields_schema.json b/compose/config/fields_schema.json index f22b513aeb2..7723f2fbc3c 100644 --- a/compose/config/fields_schema.json +++ b/compose/config/fields_schema.json @@ -89,7 +89,6 @@ "mac_address": {"type": "string"}, "mem_limit": {"type": ["number", "string"]}, "memswap_limit": {"type": ["number", "string"]}, - "name": {"type": "string"}, "net": {"type": "string"}, "pid": {"type": ["string", "null"]}, diff --git a/compose/config/service_schema.json b/compose/config/service_schema.json index 5cb5d6d0701..221c5d8d747 100644 --- a/compose/config/service_schema.json +++ b/compose/config/service_schema.json @@ -3,12 +3,6 @@ "type": "object", - "properties": { - "name": {"type": "string"} - }, - - "required": ["name"], - "allOf": [ {"$ref": "fields_schema.json#/definitions/service"}, {"$ref": "#/definitions/service_constraints"} diff --git a/compose/config/validation.py b/compose/config/validation.py index 3bd404109dd..d3bcb35c4e7 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -195,7 +195,7 @@ def _parse_oneof_validator(error): for error in errors: # handle root level errors - if len(error.path) == 0 and not error.instance.get('name'): + if len(error.path) == 0 and not service_name: if error.validator == 'type': msg = "Top level object needs to be a dictionary. Check your .yml file that you have defined a service at the top level." root_msgs.append(msg) diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index 5ee6a421288..d63f0591603 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -9,6 +9,7 @@ from compose.cli.docker_client import docker_client from compose.config.config import process_service from compose.config.config import resolve_environment +from compose.config.config import ServiceConfig from compose.const import LABEL_PROJECT from compose.progress_stream import stream_output from compose.service import Service @@ -43,15 +44,13 @@ def create_service(self, name, **kwargs): if 'command' not in kwargs: kwargs['command'] = ["top"] - # TODO: remove this once #2299 is fixed - kwargs['name'] = name - - options = process_service('.', kwargs) + service_config = ServiceConfig('.', None, name, kwargs) + options = process_service(service_config) options['environment'] = resolve_environment('.', kwargs) labels = options.setdefault('labels', {}) labels['com.docker.compose.test-name'] = self.id() - return Service(client=self.client, project='composetest', **options) + return Service(name, client=self.client, project='composetest', **options) def check_build(self, *args, **kwargs): kwargs.setdefault('rm', True) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 71783168171..022ec7c7d68 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -20,12 +20,12 @@ def make_service_dict(name, service_dict, working_dir, filename=None): """ Test helper function to construct a ServiceExtendsResolver """ - resolver = config.ServiceExtendsResolver( + resolver = config.ServiceExtendsResolver(config.ServiceConfig( working_dir=working_dir, filename=filename, - service_name=name, - service_dict=service_dict) - return config.process_service(working_dir, resolver.run()) + name=name, + config=service_dict)) + return config.process_service(resolver.run()) def service_sort(services): @@ -651,7 +651,7 @@ def test_home_directory_with_driver_does_not_expand(self): def test_volume_path_with_non_ascii_directory(self): volume = u'/Füü/data:/data' - container_path = config.resolve_volume_path(".", volume, "test") + container_path = config.resolve_volume_path(".", volume) self.assertEqual(container_path, volume) From 0ab76bb8bccf507dc17b2b016d5ecabc1fcb5dc6 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 9 Nov 2015 15:39:55 -0500 Subject: [PATCH 1527/4072] Add a test for invalid field 'name', and fix an existing test for invalid service names. Signed-off-by: Daniel Nephin --- tests/unit/config/config_test.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 022ec7c7d68..add7a5a48f1 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -77,8 +77,8 @@ def test_load_throws_error_when_not_dict(self): ) def test_config_invalid_service_names(self): - with self.assertRaises(ConfigurationError): - for invalid_name in ['?not?allowed', ' ', '', '!', '/', '\xe2']: + for invalid_name in ['?not?allowed', ' ', '', '!', '/', '\xe2']: + with pytest.raises(ConfigurationError): config.load( build_config_details( {invalid_name: {'image': 'busybox'}}, @@ -87,6 +87,16 @@ def test_config_invalid_service_names(self): ) ) + 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() + def test_config_integer_service_name_raise_validation_error(self): expected_error_msg = "Service name: 1 needs to be a string, eg '1'" with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): From 63c3e6f58c124d757705711dfc56b50130136615 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 11 Nov 2015 12:45:02 -0800 Subject: [PATCH 1528/4072] Allow dashes in environment variable names See http://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap08.html Environment variable names used by the utilities in the Shell and Utilities volume of POSIX.1-2008 consist solely of uppercase letters, digits, and the ( '_' ) from the characters defined in Portable Character Set and do not begin with a digit. Other characters may be permitted by an implementation; applications shall tolerate the presence of such names. Signed-off-by: Joffrey F --- compose/config/fields_schema.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/config/fields_schema.json b/compose/config/fields_schema.json index 7723f2fbc3c..3d592bbc75f 100644 --- a/compose/config/fields_schema.json +++ b/compose/config/fields_schema.json @@ -40,7 +40,7 @@ { "type": "object", "patternProperties": { - "^[^-]+$": { + ".+": { "type": ["string", "number", "boolean", "null"], "format": "environment" } From d52c969f947750efb85e10b7a598f3c7b92d3996 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 11 Nov 2015 12:52:30 -0800 Subject: [PATCH 1529/4072] Add test for environment variable dashes support Signed-off-by: Joffrey F --- tests/unit/config/config_test.py | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index add7a5a48f1..324061365f0 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -480,20 +480,18 @@ def test_logs_warning_for_boolean_in_environment(self, mock_logging): self.assertTrue(mock_logging.warn.called) self.assertTrue(expected_warning_msg in mock_logging.warn.call_args[0][0]) - def test_config_invalid_environment_dict_key_raises_validation_error(self): - expected_error_msg = "Service 'web' configuration key 'environment' contains unsupported option: '---'" - - with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): - config.load( - build_config_details( - {'web': { - 'image': 'busybox', - 'environment': {'---': 'nope'} - }}, - 'working_dir', - 'filename.yml' - ) + def test_config_valid_environment_dict_key_contains_dashes(self): + services = config.load( + build_config_details( + {'web': { + 'image': 'busybox', + 'environment': {'SPRING_JPA_HIBERNATE_DDL-AUTO': 'none'} + }}, + 'working_dir', + 'filename.yml' ) + ) + self.assertEqual(services[0]['environment']['SPRING_JPA_HIBERNATE_DDL-AUTO'], 'none') def test_load_yaml_with_yaml_error(self): tmpdir = py.test.ensuretemp('invalid_yaml_test') From 285e52cc7c17764b54a683e849787ee20c476349 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 12 Nov 2015 12:40:36 -0500 Subject: [PATCH 1530/4072] Add ids to config schemas Also enforce a max complexity for functions and add some new tests for config. Signed-off-by: Daniel Nephin --- compose/config/fields_schema.json | 6 ++++-- compose/config/service_schema.json | 11 ++++------- compose/config/validation.py | 5 ++++- tests/unit/config/config_test.py | 24 ++++++++++++++++-------- tox.ini | 2 ++ 5 files changed, 30 insertions(+), 18 deletions(-) diff --git a/compose/config/fields_schema.json b/compose/config/fields_schema.json index 3d592bbc75f..ca3b3a5029e 100644 --- a/compose/config/fields_schema.json +++ b/compose/config/fields_schema.json @@ -2,15 +2,18 @@ "$schema": "http://json-schema.org/draft-04/schema#", "type": "object", + "id": "fields_schema.json", "patternProperties": { "^[a-zA-Z0-9._-]+$": { "$ref": "#/definitions/service" } }, + "additionalProperties": false, "definitions": { "service": { + "id": "#/definitions/service", "type": "object", "properties": { @@ -167,6 +170,5 @@ ] } - }, - "additionalProperties": false + } } diff --git a/compose/config/service_schema.json b/compose/config/service_schema.json index 221c5d8d747..05774efdda7 100644 --- a/compose/config/service_schema.json +++ b/compose/config/service_schema.json @@ -1,15 +1,17 @@ { "$schema": "http://json-schema.org/draft-04/schema#", + "id": "service_schema.json", "type": "object", "allOf": [ {"$ref": "fields_schema.json#/definitions/service"}, - {"$ref": "#/definitions/service_constraints"} + {"$ref": "#/definitions/constraints"} ], "definitions": { - "service_constraints": { + "constraints": { + "id": "#/definitions/constraints", "anyOf": [ { "required": ["build"], @@ -21,13 +23,8 @@ {"required": ["build"]}, {"required": ["dockerfile"]} ]} - }, - { - "required": ["extends"], - "not": {"required": ["build", "image"]} } ] } } - } diff --git a/compose/config/validation.py b/compose/config/validation.py index d3bcb35c4e7..962d41e2fbe 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -307,7 +307,10 @@ def _validate_against_schema(config, schema_filename, format_checker=[], service schema = json.load(schema_fh) resolver = RefResolver(resolver_full_path, schema) - validation_output = Draft4Validator(schema, resolver=resolver, format_checker=FormatChecker(format_checker)) + validation_output = Draft4Validator( + schema, + resolver=resolver, + format_checker=FormatChecker(format_checker)) errors = [error for error in sorted(validation_output.iter_errors(config), key=str)] if errors: diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 324061365f0..0e2fb7d7242 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -78,14 +78,12 @@ def test_load_throws_error_when_not_dict(self): def test_config_invalid_service_names(self): for invalid_name in ['?not?allowed', ' ', '', '!', '/', '\xe2']: - with pytest.raises(ConfigurationError): - config.load( - build_config_details( - {invalid_name: {'image': 'busybox'}}, - 'working_dir', - 'filename.yml' - ) - ) + with pytest.raises(ConfigurationError) as exc: + config.load(build_config_details( + {invalid_name: {'image': 'busybox'}}, + 'working_dir', + 'filename.yml')) + assert 'Invalid service name \'%s\'' % invalid_name in exc.exconly() def test_load_with_invalid_field_name(self): config_details = build_config_details( @@ -97,6 +95,16 @@ def test_load_with_invalid_field_name(self): error_msg = "Unsupported config option for 'web' service: 'name'" assert error_msg in exc.exconly() + def test_load_invalid_service_definition(self): + config_details = build_config_details( + {'web': 'wrong'}, + 'working_dir', + 'filename.yml') + with pytest.raises(ConfigurationError) as exc: + config.load(config_details) + error_msg = "Service \"web\" doesn\'t have any configuration options" + assert error_msg in exc.exconly() + def test_config_integer_service_name_raise_validation_error(self): expected_error_msg = "Service name: 1 needs to be a string, eg '1'" with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): diff --git a/tox.ini b/tox.ini index f05c5ed260c..d1098a55a3e 100644 --- a/tox.ini +++ b/tox.ini @@ -43,4 +43,6 @@ directory = coverage-html [flake8] # Allow really long lines for now max-line-length = 140 +# Set this high for now +max-complexity = 20 exclude = compose/packages From 34166ef5a4a64cfd0cd13acb607fbd1b36412232 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 12 Nov 2015 12:43:29 -0500 Subject: [PATCH 1531/4072] Refactor process_errors into smaller functions So that it passed new max-complexity requirement Signed-off-by: Daniel Nephin --- compose/config/validation.py | 318 +++++++++++++++---------------- tests/unit/config/config_test.py | 2 +- 2 files changed, 150 insertions(+), 170 deletions(-) diff --git a/compose/config/validation.py b/compose/config/validation.py index 962d41e2fbe..2928238c34d 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -109,189 +109,169 @@ def anglicize_validator(validator): return 'a ' + validator -def process_errors(errors, service_name=None): +def handle_error_for_schema_with_id(error, service_name): + schema_id = error.schema['id'] + + if schema_id == 'fields_schema.json' and error.validator == 'additionalProperties': + return "Invalid service name '{}' - only {} characters are allowed".format( + # The service_name is the key to the json object + list(error.instance)[0], + VALID_NAME_CHARS) + + if schema_id == '#/definitions/constraints': + 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)) + if 'image' not in error.instance and 'build' not in error.instance: + return ( + "Service '{}' has neither an image nor a build path " + "specified. Exactly one must be provided.".format(service_name)) + 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 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) + + +def handle_generic_service_error(error, service_name): + config_key = " ".join("'%s'" % k for k in error.path) + msg_format = None + error_msg = error.message + + if error.validator == 'oneOf': + msg_format = "Service '{}' configuration key {} {}" + error_msg = _parse_oneof_validator(error) + + elif error.validator == 'type': + msg_format = ("Service '{}' configuration key {} contains an invalid " + "type, it should be {}") + 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, {}" + + 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]) + error_msg = "when defining '{}' you must set '{}' as well".format( + config_key, + required_keys) + + elif error.path: + msg_format = "Service '{}' configuration key {} value {}" + + if msg_format: + return msg_format.format(service_name, config_key, error_msg) + + return error.message + + +def parse_key_from_error_msg(error): + return error.message.split("'")[1] + + +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. """ - 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. + if not isinstance(validator, list): + return anglicize_validator(validator) + + if len(validator) == 1: + return anglicize_validator(validator[0]) + + return "{}, or {}".format( + ", ".join([anglicize_validator(validator[0])] + validator[1:-1]), + anglicize_validator(validator[-1])) + + +def _parse_oneof_validator(error): + """oneOf has multiple schemas, so we need to reason about which schema, sub + schema or constraint the validation is failing on. + Inspecting the context value of a ValidationError gives us information about + which sub schema failed and which kind of error it is. """ - def _parse_key_from_error_msg(error): - return error.message.split("'")[1] - - def _clean_error_message(message): - return message.replace("u'", "'") - - 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. - """ - if isinstance(validator, list): - if len(validator) >= 2: - first_type = anglicize_validator(validator[0]) - last_type = anglicize_validator(validator[-1]) - types_from_validator = ", ".join([first_type] + validator[1:-1]) - - msg = "{} or {}".format( - types_from_validator, - last_type - ) - else: - msg = "{}".format(anglicize_validator(validator[0])) - else: - msg = "{}".format(anglicize_validator(validator)) - - return msg - - def _parse_oneof_validator(error): - """ - oneOf has multiple schemas, so we need to reason about which schema, sub - schema or constraint the validation is failing on. - Inspecting the context value of a ValidationError gives us information about - which sub schema failed and which kind of error it is. - """ - required = [context for context in error.context if context.validator == 'required'] - if required: - return required[0].message - - additionalProperties = [context for context in error.context if context.validator == 'additionalProperties'] - if additionalProperties: - invalid_config_key = _parse_key_from_error_msg(additionalProperties[0]) + types = [] + for context in error.context: + + if context.validator == 'required': + return context.message + + if context.validator == 'additionalProperties': + invalid_config_key = parse_key_from_error_msg(context) return "contains unsupported option: '{}'".format(invalid_config_key) - constraint = [context for context in error.context if len(context.path) > 0] - if constraint: - valid_types = _parse_valid_types_from_validator(constraint[0].validator_value) - invalid_config_key = "".join( - "'{}' ".format(fragment) for fragment in constraint[0].path + if context.path: + invalid_config_key = " ".join( + "'{}' ".format(fragment) for fragment in context.path if isinstance(fragment, six.string_types) ) - msg = "{}contains {}, which is an invalid type, it should be {}".format( + return "{}contains {}, which is an invalid type, it should be {}".format( invalid_config_key, - constraint[0].instance, - valid_types - ) - return msg + context.instance, + _parse_valid_types_from_validator(context.validator_value)) - uniqueness = [context for context in error.context if context.validator == 'uniqueItems'] - if uniqueness: - msg = "contains non unique items, please remove duplicates from {}".format( - uniqueness[0].instance - ) - return msg - - types = [context.validator_value for context in error.context if context.validator == 'type'] - valid_types = _parse_valid_types_from_validator(types) - - msg = "contains an invalid type, it should be {}".format(valid_types) - - return msg - - root_msgs = [] - invalid_keys = [] - required = [] - type_errors = [] - other_errors = [] - - for error in errors: - # handle root level errors - if len(error.path) == 0 and not service_name: - if error.validator == 'type': - msg = "Top level object needs to be a dictionary. Check your .yml file that you have defined a service at the top level." - root_msgs.append(msg) - elif error.validator == 'additionalProperties': - invalid_service_name = _parse_key_from_error_msg(error) - msg = "Invalid service name '{}' - only {} characters are allowed".format(invalid_service_name, VALID_NAME_CHARS) - root_msgs.append(msg) - else: - root_msgs.append(_clean_error_message(error.message)) - - else: - if not service_name: - # field_schema errors will have service name on the path - service_name = error.path[0] - error.path.popleft() - else: - # service_schema errors have the service name passed in, as that - # is not available on error.path or necessarily error.instance - service_name = service_name - - if error.validator == 'additionalProperties': - invalid_config_key = _parse_key_from_error_msg(error) - invalid_keys.append(get_unsupported_config_msg(service_name, invalid_config_key)) - elif error.validator == 'anyOf': - if 'image' in error.instance and 'build' in error.instance: - required.append( - "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)) - elif 'image' not in error.instance and 'build' not in error.instance: - required.append( - "Service '{}' has neither an image nor a build path " - "specified. Exactly one must be provided.".format(service_name)) - elif 'image' in error.instance and 'dockerfile' in error.instance: - required.append( - "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)) - else: - required.append(_clean_error_message(error.message)) - elif error.validator == 'oneOf': - config_key = error.path[0] - msg = _parse_oneof_validator(error) - - type_errors.append("Service '{}' configuration key '{}' {}".format( - service_name, config_key, msg) - ) - elif error.validator == 'type': - msg = _parse_valid_types_from_validator(error.validator_value) - - if len(error.path) > 0: - config_key = " ".join(["'%s'" % k for k in error.path]) - type_errors.append( - "Service '{}' configuration key {} contains an invalid " - "type, it should be {}".format( - service_name, - config_key, - msg)) - else: - root_msgs.append( - "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(service_name)) - elif error.validator == 'required': - config_key = error.path[0] - required.append( - "Service '{}' option '{}' is invalid, {}".format( - service_name, - config_key, - _clean_error_message(error.message))) - elif error.validator == 'dependencies': - dependency_key = list(error.validator_value.keys())[0] - required_keys = ",".join(error.validator_value[dependency_key]) - required.append("Invalid '{}' configuration for '{}' service: when defining '{}' you must set '{}' as well".format( - dependency_key, service_name, dependency_key, required_keys)) - else: - config_key = " ".join(["'%s'" % k for k in error.path]) - err_msg = "Service '{}' configuration key {} value {}".format(service_name, config_key, error.message) - other_errors.append(err_msg) - - return "\n".join(root_msgs + invalid_keys + required + type_errors + other_errors) + if context.validator == 'uniqueItems': + return "contains non unique items, please remove duplicates from {}".format( + context.instance) + if context.validator == 'type': + types.append(context.validator_value) -def validate_against_fields_schema(config): - schema_filename = "fields_schema.json" - format_checkers = ["ports", "environment"] - return _validate_against_schema(config, schema_filename, format_checkers) + valid_types = _parse_valid_types_from_validator(types) + return "contains an invalid type, it should be {}".format(valid_types) -def validate_against_service_schema(config, service_name): - schema_filename = "service_schema.json" - format_checkers = ["ports"] - return _validate_against_schema(config, schema_filename, format_checkers, service_name) +def process_errors(errors, service_name=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() + + if 'id' in error.schema: + error_msg = handle_error_for_schema_with_id(error, service_name) + if error_msg: + return error_msg + return handle_generic_service_error(error, service_name) -def _validate_against_schema(config, schema_filename, format_checker=[], service_name=None): + return '\n'.join(format_error_message(error, service_name) for error in errors) + + +def validate_against_fields_schema(config): + return _validate_against_schema( + config, + "fields_schema.json", + ["ports", "environment"]) + + +def validate_against_service_schema(config, service_name): + return _validate_against_schema( + config, + "service_schema.json", + ["ports"], + service_name) + + +def _validate_against_schema( + config, + schema_filename, + format_checker=(), + service_name=None): config_source_dir = os.path.dirname(os.path.abspath(__file__)) if sys.platform == "win32": diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 0e2fb7d7242..ada5e9cae92 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -865,7 +865,7 @@ def test_validation_fails_with_just_memswap_limit(self): a mem_limit """ expected_error_msg = ( - "Invalid 'memswap_limit' configuration for 'foo' service: when " + "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): From 96e9b470597594beaade8e1851cbb2b3f5c3b37c Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 9 Nov 2015 20:01:20 -0500 Subject: [PATCH 1532/4072] Inclide the filename in validation errors. Signed-off-by: Daniel Nephin --- compose/config/config.py | 4 +- compose/config/interpolation.py | 7 --- compose/config/validation.py | 60 ++++++++++++------ tests/unit/config/config_test.py | 104 +++++++++++++++---------------- 4 files changed, 95 insertions(+), 80 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 21788551da6..2c1fdeb9cec 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -229,9 +229,9 @@ def merge_services(base, override): def process_config_file(config_file, service_name=None): - validate_top_level_object(config_file.config) + validate_top_level_object(config_file) processed_config = interpolate_environment_variables(config_file.config) - validate_against_fields_schema(processed_config) + validate_against_fields_schema(processed_config, config_file.filename) if service_name and service_name not in processed_config: raise ConfigurationError( diff --git a/compose/config/interpolation.py b/compose/config/interpolation.py index f8e1da610dc..ba7e35c1e58 100644 --- a/compose/config/interpolation.py +++ b/compose/config/interpolation.py @@ -18,13 +18,6 @@ def interpolate_environment_variables(config): def process_service(service_name, service_dict, mapping): - if not isinstance(service_dict, dict): - raise ConfigurationError( - 'Service "%s" doesn\'t have any configuration options. ' - 'All top level keys in your docker-compose.yml must map ' - 'to a dictionary of configuration options.' % service_name - ) - return dict( (key, interpolate_value(service_name, key, val, mapping)) for (key, val) in service_dict.items() diff --git a/compose/config/validation.py b/compose/config/validation.py index 2928238c34d..38866b0f4fe 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -66,21 +66,38 @@ def format_boolean_in_environment(instance): return True -def validate_service_names(config): - for service_name in config.keys(): +def validate_top_level_service_objects(config_file): + """Perform some high level validation of the service name and value. + + 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. + """ + for service_name, service_dict in config_file.config.items(): if not isinstance(service_name, six.string_types): raise ConfigurationError( - "Service name: {} needs to be a string, eg '{}'".format( + "In file '{}' service name: {} needs to be a string, eg '{}'".format( + config_file.filename, service_name, service_name)) + if not isinstance(service_dict, dict): + 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( + config_file.filename, + service_name)) + -def validate_top_level_object(config): - if not isinstance(config, dict): +def validate_top_level_object(config_file): + if not isinstance(config_file.config, dict): raise ConfigurationError( - "Top level object needs to be a dictionary. Check your .yml file " - "that you have defined a service at the top level.") - validate_service_names(config) + "Top level object in '{}' needs to be an object not '{}'. Check " + "that you have defined a service at the top level.".format( + config_file.filename, + type(config_file.config))) + validate_top_level_service_objects(config_file) def validate_extends_file_path(service_name, extends_options, filename): @@ -252,26 +269,28 @@ def format_error_message(error, service_name): return '\n'.join(format_error_message(error, service_name) for error in errors) -def validate_against_fields_schema(config): - return _validate_against_schema( +def validate_against_fields_schema(config, filename): + _validate_against_schema( config, "fields_schema.json", - ["ports", "environment"]) + format_checker=["ports", "environment"], + filename=filename) def validate_against_service_schema(config, service_name): - return _validate_against_schema( + _validate_against_schema( config, "service_schema.json", - ["ports"], - service_name) + format_checker=["ports"], + service_name=service_name) def _validate_against_schema( config, schema_filename, format_checker=(), - service_name=None): + service_name=None, + filename=None): config_source_dir = os.path.dirname(os.path.abspath(__file__)) if sys.platform == "win32": @@ -293,6 +312,11 @@ def _validate_against_schema( format_checker=FormatChecker(format_checker)) errors = [error for error in sorted(validation_output.iter_errors(config), key=str)] - if errors: - error_msg = process_errors(errors, service_name) - raise ConfigurationError("Validation failed, reason(s):\n{}".format(error_msg)) + if not errors: + return + + error_msg = process_errors(errors, service_name) + file_msg = " in file '{}'".format(filename) if filename else '' + raise ConfigurationError("Validation failed{}, reason(s):\n{}".format( + file_msg, + error_msg)) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index ada5e9cae92..3038af80d0c 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -94,6 +94,7 @@ def test_load_with_invalid_field_name(self): 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() def test_load_invalid_service_definition(self): config_details = build_config_details( @@ -102,11 +103,12 @@ def test_load_invalid_service_definition(self): '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' doesn't have any configuration options" assert error_msg in exc.exconly() def test_config_integer_service_name_raise_validation_error(self): - expected_error_msg = "Service name: 1 needs to be a string, eg '1'" + expected_error_msg = ("In file 'filename.yml' service name: 1 needs to " + "be a string, eg '1'") with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): config.load( build_config_details( @@ -156,25 +158,26 @@ def test_load_with_multiple_files(self): def test_load_with_multiple_files_and_empty_override(self): base_file = config.ConfigFile( - 'base.yaml', + 'base.yml', {'web': {'image': 'example/web'}}) - override_file = config.ConfigFile('override.yaml', None) + override_file = config.ConfigFile('override.yml', None) details = config.ConfigDetails('.', [base_file, override_file]) with pytest.raises(ConfigurationError) as exc: config.load(details) - assert 'Top level object needs to be a dictionary' in exc.exconly() + error_msg = "Top level object in 'override.yml' needs to be an object" + assert error_msg in exc.exconly() def test_load_with_multiple_files_and_empty_base(self): - base_file = config.ConfigFile('base.yaml', None) + base_file = config.ConfigFile('base.yml', None) override_file = config.ConfigFile( - 'override.yaml', + 'override.yml', {'web': {'image': 'example/web'}}) details = config.ConfigDetails('.', [base_file, override_file]) with pytest.raises(ConfigurationError) as exc: config.load(details) - assert 'Top level object needs to be a dictionary' in exc.exconly() + assert "Top level object in 'base.yml' needs to be an object" in exc.exconly() def test_load_with_multiple_files_and_extends_in_override_file(self): base_file = config.ConfigFile( @@ -225,17 +228,17 @@ def test_load_with_multiple_files_and_invalid_override(self): with pytest.raises(ConfigurationError) as exc: config.load(details) - assert 'Service "bogus" doesn\'t have any configuration' in exc.exconly() + assert "service 'bogus' doesn't have any configuration" in exc.exconly() + assert "In file 'override.yaml'" in exc.exconly() def test_config_valid_service_names(self): for valid_name in ['_', '-', '.__.', '_what-up.', 'what_.up----', 'whatup']: - config.load( + services = config.load( build_config_details( {valid_name: {'image': 'busybox'}}, 'tests/fixtures/extends', - 'common.yml' - ) - ) + 'common.yml')) + assert services[0]['name'] == valid_name def test_config_invalid_ports_format_validation(self): expected_error_msg = "Service 'web' configuration key 'ports' contains an invalid type" @@ -300,7 +303,8 @@ def test_invalid_config_type_should_be_an_array(self): ) def test_invalid_config_not_a_dictionary(self): - expected_error_msg = "Top level object needs to be a dictionary." + expected_error_msg = ("Top level object in 'filename.yml' needs to be " + "an object.") with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): config.load( build_config_details( @@ -382,12 +386,13 @@ def test_config_extra_hosts_list_of_dicts_validation_error(self): ) def test_config_ulimits_invalid_keys_validation_error(self): - expected_error_msg = "Service 'web' configuration key 'ulimits' contains unsupported option: 'not_soft_or_hard'" + expected = ("Service 'web' configuration key 'ulimits' 'nofile' contains " + "unsupported option: 'not_soft_or_hard'") - with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): - config.load( - build_config_details( - {'web': { + with pytest.raises(ConfigurationError) as exc: + config.load(build_config_details( + { + 'web': { 'image': 'busybox', 'ulimits': { 'nofile': { @@ -396,50 +401,43 @@ def test_config_ulimits_invalid_keys_validation_error(self): "hard": 20000, } } - }}, - 'working_dir', - 'filename.yml' - ) - ) + } + }, + 'working_dir', + 'filename.yml')) + assert expected in exc.exconly() def test_config_ulimits_required_keys_validation_error(self): - expected_error_msg = "Service 'web' configuration key 'ulimits' u?'hard' is a required property" - with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): - config.load( - build_config_details( - {'web': { + with pytest.raises(ConfigurationError) as exc: + config.load(build_config_details( + { + 'web': { 'image': 'busybox', - 'ulimits': { - 'nofile': { - "soft": 10000, - } - } - }}, - 'working_dir', - 'filename.yml' - ) - ) + 'ulimits': {'nofile': {"soft": 10000}} + } + }, + 'working_dir', + 'filename.yml')) + assert "Service 'web' configuration key 'ulimits' 'nofile'" in exc.exconly() + assert "'hard' is a required property" in exc.exconly() def test_config_ulimits_soft_greater_than_hard_error(self): - expected_error_msg = "cannot contain a 'soft' value higher than 'hard' value" + expected = "cannot contain a 'soft' value higher than 'hard' value" - with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): - config.load( - build_config_details( - {'web': { + with pytest.raises(ConfigurationError) as exc: + config.load(build_config_details( + { + 'web': { 'image': 'busybox', 'ulimits': { - 'nofile': { - "soft": 10000, - "hard": 1000 - } + 'nofile': {"soft": 10000, "hard": 1000} } - }}, - 'working_dir', - 'filename.yml' - ) - ) + } + }, + 'working_dir', + 'filename.yml')) + assert expected in exc.exconly() def test_valid_config_which_allows_two_type_definitions(self): expose_values = [["8000"], [8000]] From 82086a4e92aada343cf656e7c5c1e9f88d13533e Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 12 Nov 2015 13:26:13 -0500 Subject: [PATCH 1533/4072] Remove name field from the list of ALLOWED_KEYS Signed-off-by: Daniel Nephin --- compose/config/config.py | 1 - 1 file changed, 1 deletion(-) diff --git a/compose/config/config.py b/compose/config/config.py index 2c1fdeb9cec..201266208a2 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -65,7 +65,6 @@ 'dockerfile', 'expose', 'external_links', - 'name', ] From 4628e93fb2ee8e09f9410016574be58a5c2973ed Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 12 Nov 2015 13:23:04 -0500 Subject: [PATCH 1534/4072] Bump 1.5.1 Signed-off-by: Daniel Nephin --- CHANGELOG.md | 53 +++++++++++++++++++++++++++++++++++++++++++++ compose/__init__.py | 2 +- docs/install.md | 6 ++--- script/run.sh | 2 +- 4 files changed, 58 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a123c2a44d0..50aabcb8e99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,59 @@ Change log ========== +1.5.1 (2015-11-12) +------------------ + +- Add the `--force-rm` option to `build`. + +- Add the `ulimit` option for services in the Compose file. + +- Fixed a bug where `up` would error with "service needs to be built" if + a service changed from using `image` to using `build`. + +- Fixed a bug that would cause incorrect output of parallel operations + on some terminals. + +- Fixed a bug that prevented a container from being recreated when the + mode of a `volumes_from` was changed. + +- Fixed a regression in 1.5.0 where non-utf-8 unicode characters would cause + `up` or `logs` to crash. + +- Fixed a regression in 1.5.0 where Compose would use a success exit status + code when a command fails due to an HTTP timeout communicating with the + docker daemon. + +- Fixed a regression in 1.5.0 where `name` was being accepted as a valid + service option which would override the actual name of the service. + +- When using `--x-networking` Compose no longer sets the hostname to the + container name. + +- When using `--x-networking` Compose will only create the default network + 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. + +- Recreate a container if one of it's dependencies is being created. + Previously a container was only recreated if it's dependencies already + existed, but were being recreated as well. + +- Add a warning when a `volume` in the Compose file is being ignored + and masked by a container volume from a previous container. + +- Improve the output of `pull` when run without a tty. + +- When using multiple Compose files, validate each before attempting to merge + them together. Previously invalid files would result in not helpful errors. + +- Allow dashes in keys in the `environment` service option. + +- Improve validation error messages by including the filename as part of the + error message. + + 1.5.0 (2015-11-03) ------------------ diff --git a/compose/__init__.py b/compose/__init__.py index 2b8d5e72b27..5f2b332afbf 100644 --- a/compose/__init__.py +++ b/compose/__init__.py @@ -1,3 +1,3 @@ from __future__ import unicode_literals -__version__ = '1.5.0' +__version__ = '1.5.1' diff --git a/docs/install.md b/docs/install.md index c5304409c55..f8c9db63836 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/VERSION_NUM/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose + curl -L https://github.com/docker/compose/releases/download/1.5.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.5.0 + docker-compose version: 1.5.1 ## Alternative install options @@ -76,7 +76,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.0/run.sh > /usr/local/bin/docker-compose + $ curl -L https://github.com/docker/compose/releases/download/1.5.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 cf46c143c38..a9b954774fe 100755 --- a/script/run.sh +++ b/script/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.5.0" +VERSION="1.5.1" IMAGE="docker/compose:$VERSION" From ea4230e7a2f53a116c22dce20632cb5355cf4c07 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 10 Nov 2015 18:52:21 -0500 Subject: [PATCH 1535/4072] Handle both SIGINT and SIGTERM for docker-compose up. Signed-off-by: Daniel Nephin --- compose/cli/main.py | 21 +++++++---- tests/acceptance/cli_test.py | 70 +++++++++++++++++++++++++++++------- tests/unit/cli/main_test.py | 8 ++--- 3 files changed, 76 insertions(+), 23 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 806926d845c..7b1e0aa35d4 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -658,17 +658,24 @@ def build_log_printer(containers, service_names, monochrome): def attach_to_logs(project, log_printer, service_names, timeout): print("Attaching to", list_containers(log_printer.containers)) - try: - log_printer.run() - finally: - def handler(signal, frame): - project.kill(service_names=service_names) - sys.exit(0) - signal.signal(signal.SIGINT, handler) + def force_shutdown(signal, frame): + project.kill(service_names=service_names) + sys.exit(2) + + def shutdown(signal, frame): + set_signal_handler(force_shutdown) print("Gracefully stopping... (press Ctrl+C again to force)") project.stop(service_names=service_names, timeout=timeout) + set_signal_handler(shutdown) + log_printer.run() + + +def set_signal_handler(handler): + signal.signal(signal.SIGINT, handler) + signal.signal(signal.SIGTERM, handler) + def list_containers(containers): return ", ".join(c.name for c in containers) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 88a43d7f0e8..57f2039ef5a 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -2,7 +2,9 @@ import os import shlex +import signal import subprocess +import time from collections import namedtuple from operator import attrgetter @@ -20,6 +22,45 @@ BUILD_PULL_TEXT = 'Status: Image is up to date for busybox:latest' +def start_process(base_dir, options): + proc = subprocess.Popen( + ['docker-compose'] + options, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + cwd=base_dir) + print("Running process: %s" % proc.pid) + return proc + + +def wait_on_process(proc, returncode=0): + stdout, stderr = proc.communicate() + if proc.returncode != returncode: + print(stderr) + assert proc.returncode == returncode + return ProcessResult(stdout.decode('utf-8'), stderr.decode('utf-8')) + + +def wait_on_condition(condition, delay=0.1, timeout=5): + start_time = time.time() + while not condition(): + if time.time() - start_time > timeout: + raise AssertionError("Timeout: %s" % condition) + time.sleep(delay) + + +class ContainerCountCondition(object): + + def __init__(self, project, expected): + self.project = project + self.expected = expected + + def __call__(self): + return len(self.project.containers()) == self.expected + + def __str__(self): + return "waiting for counter count == %s" % self.expected + + class CLITestCase(DockerClientTestCase): def setUp(self): @@ -42,17 +83,8 @@ def project(self): def dispatch(self, options, project_options=None, returncode=0): project_options = project_options or [] - proc = subprocess.Popen( - ['docker-compose'] + project_options + options, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - cwd=self.base_dir) - print("Running process: %s" % proc.pid) - stdout, stderr = proc.communicate() - if proc.returncode != returncode: - print(stderr) - assert proc.returncode == returncode - return ProcessResult(stdout.decode('utf-8'), stderr.decode('utf-8')) + proc = start_process(self.base_dir, project_options + options) + return wait_on_process(proc, returncode=returncode) def test_help(self): old_base_dir = self.base_dir @@ -291,7 +323,7 @@ def test_up_with_force_recreate_and_no_recreate(self): returncode=1) def test_up_with_timeout(self): - self.dispatch(['up', '-d', '-t', '1'], None) + self.dispatch(['up', '-d', '-t', '1']) service = self.project.get_service('simple') another = self.project.get_service('another') self.assertEqual(len(service.containers()), 1) @@ -303,6 +335,20 @@ def test_up_with_timeout(self): self.assertFalse(config['AttachStdout']) self.assertFalse(config['AttachStdin']) + def test_up_handles_sigint(self): + proc = start_process(self.base_dir, ['up', '-t', '2']) + wait_on_condition(ContainerCountCondition(self.project, 2)) + + os.kill(proc.pid, signal.SIGINT) + 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)) + def test_run_service_without_links(self): self.base_dir = 'tests/fixtures/links-composefile' self.dispatch(['run', 'console', '/bin/true']) diff --git a/tests/unit/cli/main_test.py b/tests/unit/cli/main_test.py index ee837fcd45b..db37ac1af03 100644 --- a/tests/unit/cli/main_test.py +++ b/tests/unit/cli/main_test.py @@ -57,11 +57,11 @@ def test_attach_to_logs(self): with mock.patch('compose.cli.main.signal', autospec=True) as mock_signal: attach_to_logs(project, log_printer, service_names, timeout) - mock_signal.signal.assert_called_once_with(mock_signal.SIGINT, mock.ANY) + 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() - project.stop.assert_called_once_with( - service_names=service_names, - timeout=timeout) class SetupConsoleHandlerTestCase(unittest.TestCase): From 6236bb0019de51fe482e2ba6be8a99de471a6861 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 10 Nov 2015 19:43:05 -0500 Subject: [PATCH 1536/4072] Handle both SIGINT and SIGTERM for docker-compose run. Signed-off-by: Daniel Nephin --- compose/cli/main.py | 99 ++++++++++++++++++++---------------- tests/acceptance/cli_test.py | 47 +++++++++++++++++ 2 files changed, 102 insertions(+), 44 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 7b1e0aa35d4..9fef8d041b3 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -368,7 +368,6 @@ def run(self, project, options): allocates a TTY. """ service = project.get_service(options['SERVICE']) - detach = options['-d'] if IS_WINDOWS_PLATFORM and not detach: @@ -380,22 +379,6 @@ def run(self, project, options): if options['--allow-insecure-ssl']: log.warn(INSECURE_SSL_WARNING) - if not options['--no-deps']: - deps = service.get_linked_service_names() - - if len(deps) > 0: - project.up( - service_names=deps, - start_deps=True, - strategy=ConvergenceStrategy.never, - ) - elif project.use_networking: - project.ensure_network_exists() - - tty = True - if detach or options['-T'] or not sys.stdin.isatty(): - tty = False - if options['COMMAND']: command = [options['COMMAND']] + options['ARGS'] else: @@ -403,7 +386,7 @@ def run(self, project, options): container_options = { 'command': command, - 'tty': tty, + 'tty': not (detach or options['-T'] or not sys.stdin.isatty()), 'stdin_open': not detach, 'detach': detach, } @@ -435,31 +418,7 @@ def run(self, project, options): if options['--name']: container_options['name'] = options['--name'] - try: - container = service.create_container( - quiet=True, - one_off=True, - **container_options - ) - except APIError as e: - legacy.check_for_legacy_containers( - project.client, - project.name, - [service.name], - allow_one_off=False, - ) - - raise e - - if detach: - container.start() - print(container.name) - else: - dockerpty.start(project.client, container.id, interactive=not options['-T']) - exit_code = container.wait() - if options['--rm']: - project.client.remove_container(container.id) - sys.exit(exit_code) + run_one_off_container(container_options, project, service, options) def scale(self, project, options): """ @@ -647,6 +606,58 @@ def convergence_strategy_from_opts(options): return ConvergenceStrategy.changed +def run_one_off_container(container_options, project, service, options): + if not options['--no-deps']: + deps = service.get_linked_service_names() + if deps: + project.up( + service_names=deps, + start_deps=True, + strategy=ConvergenceStrategy.never) + + if project.use_networking: + project.ensure_network_exists() + + try: + container = service.create_container( + quiet=True, + one_off=True, + **container_options) + except APIError: + legacy.check_for_legacy_containers( + project.client, + project.name, + [service.name], + allow_one_off=False) + raise + + if options['-d']: + container.start() + print(container.name) + return + + def remove_container(force=False): + if options['--rm']: + project.client.remove_container(container.id, force=True) + + def force_shutdown(signal, frame): + project.client.kill(container.id) + remove_container(force=True) + sys.exit(2) + + def shutdown(signal, frame): + set_signal_handler(force_shutdown) + project.client.stop(container.id) + remove_container() + sys.exit(1) + + set_signal_handler(shutdown) + dockerpty.start(project.client, container.id, interactive=not options['-T']) + exit_code = container.wait() + remove_container() + sys.exit(exit_code) + + def build_log_printer(containers, service_names, monochrome): if service_names: containers = [ @@ -657,7 +668,6 @@ def build_log_printer(containers, service_names, monochrome): def attach_to_logs(project, log_printer, service_names, timeout): - print("Attaching to", list_containers(log_printer.containers)) def force_shutdown(signal, frame): project.kill(service_names=service_names) @@ -668,6 +678,7 @@ def shutdown(signal, frame): print("Gracefully stopping... (press Ctrl+C again to force)") project.stop(service_names=service_names, timeout=timeout) + print("Attaching to", list_containers(log_printer.containers)) set_signal_handler(shutdown) log_printer.run() diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 57f2039ef5a..b88ed280486 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -8,6 +8,8 @@ from collections import namedtuple from operator import attrgetter +from docker import errors + from .. import mock from compose.cli.command import get_project from compose.cli.docker_client import docker_client @@ -61,6 +63,25 @@ def __str__(self): return "waiting for counter count == %s" % self.expected +class ContainerStateCondition(object): + + def __init__(self, client, name, running): + self.client = client + self.name = name + self.running = running + + # State.Running == true + def __call__(self): + try: + container = self.client.inspect_container(self.name) + return container['State']['Running'] == self.running + except errors.APIError: + return False + + def __str__(self): + return "waiting for container to have state %s" % self.expected + + class CLITestCase(DockerClientTestCase): def setUp(self): @@ -554,6 +575,32 @@ def test_run_with_networking(self): self.assertEqual(len(networks), 1) self.assertEqual(container.human_readable_command, u'true') + def test_run_handles_sigint(self): + proc = start_process(self.base_dir, ['run', '-T', 'simple', 'top']) + wait_on_condition(ContainerStateCondition( + self.project.client, + 'simplecomposefile_simple_run_1', + running=True)) + + os.kill(proc.pid, signal.SIGINT) + wait_on_condition(ContainerStateCondition( + self.project.client, + 'simplecomposefile_simple_run_1', + running=False)) + + 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)) + + os.kill(proc.pid, signal.SIGTERM) + wait_on_condition(ContainerStateCondition( + self.project.client, + 'simplecomposefile_simple_run_1', + running=False)) + def test_rm(self): service = self.project.get_service('simple') service.create_container() From c99f2f8efd2927b01c1acdb990a83df8206a96f8 Mon Sep 17 00:00:00 2001 From: Stefan Scherer Date: Fri, 13 Nov 2015 08:33:51 +0100 Subject: [PATCH 1537/4072] Use uname to build target name for different platforms Signed-off-by: Stefan Scherer --- script/build-linux-inner | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/build-linux-inner b/script/build-linux-inner index 01137ff2408..47d5eb2e7e8 100755 --- a/script/build-linux-inner +++ b/script/build-linux-inner @@ -2,7 +2,7 @@ set -ex -TARGET=dist/docker-compose-Linux-x86_64 +TARGET=dist/docker-compose-$(uname -s)-$(uname -m) VENV=/code/.tox/py27 mkdir -p `pwd`/dist From e1308a8329de0ce12e4405677a1911a5db3bd33b Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 13 Nov 2015 10:49:17 -0500 Subject: [PATCH 1538/4072] Fix extra warnings on masked volumes. Signed-off-by: Daniel Nephin --- compose/service.py | 5 ++++- tests/unit/service_test.py | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/compose/service.py b/compose/service.py index 3b05264bb1f..148da4dbb40 100644 --- a/compose/service.py +++ b/compose/service.py @@ -963,7 +963,10 @@ def warn_on_masked_volume(volumes_option, container_volumes, service): for volume in container_volumes) for volume in volumes_option: - if container_volumes.get(volume.internal) != volume.external: + if ( + volume.internal in container_volumes and + container_volumes.get(volume.internal) != volume.external + ): log.warn(( "Service \"{service}\" is using volume \"{volume}\" from the " "previous container. Host mapping \"{host_path}\" has no effect. " diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 0cff9899077..808c391cd2a 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -26,6 +26,8 @@ from compose.service import Service from compose.service import ServiceNet from compose.service import VolumeFromSpec +from compose.service import VolumeSpec +from compose.service import warn_on_masked_volume class ServiceTest(unittest.TestCase): @@ -750,6 +752,39 @@ def test_different_host_path_in_container_json(self): ['/mnt/sda1/host/path:/data:rw'], ) + def test_warn_on_masked_volume_no_warning_when_no_container_volumes(self): + volumes_option = [VolumeSpec('/home/user', '/path', 'rw')] + container_volumes = [] + service = 'service_name' + + with mock.patch('compose.service.log') as mock_log: + warn_on_masked_volume(volumes_option, container_volumes, service) + + assert not mock_log.warn.called + + def test_warn_on_masked_volume_when_masked(self): + volumes_option = [VolumeSpec('/home/user', '/path', 'rw')] + container_volumes = [ + VolumeSpec('/var/lib/docker/path', '/path', 'rw'), + VolumeSpec('/var/lib/docker/path', '/other', 'rw'), + ] + service = 'service_name' + + with mock.patch('compose.service.log') as mock_log: + warn_on_masked_volume(volumes_option, container_volumes, service) + + mock_log.warn.called_once_with(mock.ANY) + + def test_warn_on_masked_no_warning_with_same_path(self): + volumes_option = [VolumeSpec('/home/user', '/path', 'rw')] + container_volumes = [VolumeSpec('/home/user', '/path', 'rw')] + service = 'service_name' + + with mock.patch('compose.service.log') as mock_log: + warn_on_masked_volume(volumes_option, container_volumes, service) + + assert not mock_log.warn.called + def test_create_with_special_volume_mode(self): self.mock_client.inspect_image.return_value = {'Id': 'imageid'} From 61f91ebff7c2158c4d2d51abc88c0e35e84cf256 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Seguin?= Date: Sat, 14 Nov 2015 12:19:57 +0100 Subject: [PATCH 1539/4072] Fix restart with stopped containers. Fixes #1814 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Stéphane Seguin --- compose/service.py | 2 +- tests/acceptance/cli_test.py | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/compose/service.py b/compose/service.py index 3b05264bb1f..4f449d150bb 100644 --- a/compose/service.py +++ b/compose/service.py @@ -185,7 +185,7 @@ def kill(self, **options): c.kill(**options) def restart(self, **options): - for c in self.containers(): + for c in self.containers(stopped=True): log.info("Restarting %s" % c.name) c.restart(**options) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 88a43d7f0e8..34a2d166e7d 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -597,6 +597,15 @@ def test_restart(self): started_at, ) + def test_restart_stopped_container(self): + service = self.project.get_service('simple') + container = service.create_container() + container.start() + container.kill() + self.assertEqual(len(service.containers(stopped=True)), 1) + self.dispatch(['restart', '-t', '1'], None) + self.assertEqual(len(service.containers(stopped=False)), 1) + def test_scale(self): project = self.project From 265828f4ebc383c18b251b153805ea084eaccf4d Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 16 Nov 2015 12:55:35 -0500 Subject: [PATCH 1540/4072] Fix texttable dep. 0.8.2 was removed from pypi. Signed-off-by: Daniel Nephin --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 60327d728de..659cb57f4ed 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,5 +6,5 @@ enum34==1.0.4 jsonschema==2.5.1 requests==2.7.0 six==1.7.3 -texttable==0.8.2 +texttable==0.8.4 websocket-client==0.32.0 From d4b98452012d930121231f3b7be9c2c1db8b8208 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 12 Nov 2015 17:29:58 -0500 Subject: [PATCH 1541/4072] Add the git sha to version output Signed-off-by: Daniel Nephin --- .gitignore | 1 + Dockerfile.run | 2 +- MANIFEST.in | 1 + compose/cli/command.py | 4 ++-- compose/cli/utils.py | 39 +++++++++++++++++++++++++++---------- docker-compose.spec | 24 ++++++++++++++++++----- script/build-image | 1 + script/build-linux | 1 + script/build-linux-inner | 1 + script/build-osx | 1 + script/build-windows.ps1 | 2 ++ script/release/push-release | 1 + script/write-git-sha | 7 +++++++ 13 files changed, 67 insertions(+), 18 deletions(-) create mode 100755 script/write-git-sha diff --git a/.gitignore b/.gitignore index 83a08a0e697..da72827974f 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ /docs/_site /venv README.rst +compose/GITSHA diff --git a/Dockerfile.run b/Dockerfile.run index 9f3745fefcc..792077ad7fe 100644 --- a/Dockerfile.run +++ b/Dockerfile.run @@ -8,6 +8,6 @@ COPY requirements.txt /code/requirements.txt RUN pip install -r /code/requirements.txt ADD dist/docker-compose-release.tar.gz /code/docker-compose -RUN pip install /code/docker-compose/docker-compose-* +RUN pip install --no-deps /code/docker-compose/docker-compose-* ENTRYPOINT ["/usr/bin/docker-compose"] diff --git a/MANIFEST.in b/MANIFEST.in index 0342e35bea5..8c6f932bab5 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -7,6 +7,7 @@ include *.md exclude README.md include README.rst include compose/config/*.json +include compose/GITSHA recursive-include contrib/completion * recursive-include tests * global-exclude *.pyc diff --git a/compose/cli/command.py b/compose/cli/command.py index 525217ee75a..6094b5305b5 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -12,12 +12,12 @@ from . import errors from . import verbose_proxy -from .. import __version__ from .. import config from ..project import Project from ..service import ConfigError 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 @@ -71,7 +71,7 @@ def get_client(verbose=False, version=None): client = docker_client(version=version) if verbose: version_info = six.iteritems(client.version()) - log.info("Compose version %s", __version__) + log.info(get_version_info('full')) log.info("Docker base_url: %s", client.base_url) log.info("Docker version: %s", ", ".join("%s=%s" % item for item in version_info)) diff --git a/compose/cli/utils.py b/compose/cli/utils.py index 07510e2f31c..dd859edc4b7 100644 --- a/compose/cli/utils.py +++ b/compose/cli/utils.py @@ -7,10 +7,10 @@ import ssl import subprocess -from docker import version as docker_py_version +import docker from six.moves import input -from .. import __version__ +import compose def yesno(prompt, default=None): @@ -57,13 +57,32 @@ def is_ubuntu(): def get_version_info(scope): - versioninfo = 'docker-compose version: %s' % __version__ + versioninfo = 'docker-compose version {}, build {}'.format( + compose.__version__, + get_build_version()) + if scope == 'compose': return versioninfo - elif scope == 'full': - return versioninfo + '\n' \ - + "docker-py version: %s\n" % docker_py_version \ - + "%s version: %s\n" % (platform.python_implementation(), platform.python_version()) \ - + "OpenSSL version: %s" % ssl.OPENSSL_VERSION - else: - raise RuntimeError('passed unallowed value to `cli.utils.get_version_info`') + if scope == 'full': + return ( + "{}\n" + "docker-py version: {}\n" + "{} version: {}\n" + "OpenSSL version: {}" + ).format( + versioninfo, + docker.version, + platform.python_implementation(), + platform.python_version(), + ssl.OPENSSL_VERSION) + + raise ValueError("{} is not a valid version scope".format(scope)) + + +def get_build_version(): + filename = os.path.join(os.path.dirname(compose.__file__), 'GITSHA') + if not os.path.exists(filename): + return 'unknown' + + with open(filename) as fh: + return fh.read().strip() diff --git a/docker-compose.spec b/docker-compose.spec index 678fc132386..24d03e05b2e 100644 --- a/docker-compose.spec +++ b/docker-compose.spec @@ -9,18 +9,32 @@ a = Analysis(['bin/docker-compose'], runtime_hooks=None, cipher=block_cipher) -pyz = PYZ(a.pure, - cipher=block_cipher) +pyz = PYZ(a.pure, cipher=block_cipher) exe = EXE(pyz, a.scripts, a.binaries, a.zipfiles, a.datas, - [('compose/config/fields_schema.json', 'compose/config/fields_schema.json', 'DATA')], - [('compose/config/service_schema.json', 'compose/config/service_schema.json', 'DATA')], + [ + ( + 'compose/config/fields_schema.json', + 'compose/config/fields_schema.json', + 'DATA' + ), + ( + 'compose/config/service_schema.json', + 'compose/config/service_schema.json', + 'DATA' + ), + ( + 'compose/GITSHA', + 'compose/GITSHA', + 'DATA' + ) + ], name='docker-compose', debug=False, strip=None, upx=True, - console=True ) + console=True) diff --git a/script/build-image b/script/build-image index 3ac9729b47a..897335054f8 100755 --- a/script/build-image +++ b/script/build-image @@ -10,6 +10,7 @@ fi TAG=$1 VERSION="$(python setup.py --version)" +./script/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 b/script/build-linux index ade18bc5350..47fb45e1749 100755 --- a/script/build-linux +++ b/script/build-linux @@ -9,4 +9,5 @@ docker build -t "$TAG" . | tail -n 200 docker run \ --rm --entrypoint="script/build-linux-inner" \ -v $(pwd)/dist:/code/dist \ + -v $(pwd)/.git:/code/.git \ "$TAG" diff --git a/script/build-linux-inner b/script/build-linux-inner index 01137ff2408..50b16dd866c 100755 --- a/script/build-linux-inner +++ b/script/build-linux-inner @@ -9,6 +9,7 @@ mkdir -p `pwd`/dist chmod 777 `pwd`/dist $VENV/bin/pip install -q -r requirements-build.txt +./script/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 042964e4beb..168fd43092d 100755 --- a/script/build-osx +++ b/script/build-osx @@ -9,6 +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 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/build-windows.ps1 b/script/build-windows.ps1 index 42a4a501c10..28011b1db2a 100644 --- a/script/build-windows.ps1 +++ b/script/build-windows.ps1 @@ -47,6 +47,8 @@ virtualenv .\venv .\venv\Scripts\pip install --no-deps . .\venv\Scripts\pip install --allow-external pyinstaller -r requirements-build.txt +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" diff --git a/script/release/push-release b/script/release/push-release index ccdf2496077..b754d40f04d 100755 --- a/script/release/push-release +++ b/script/release/push-release @@ -57,6 +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 python setup.py sdist if [ "$(command -v twine 2> /dev/null)" ]; then twine upload ./dist/docker-compose-${VERSION}.tar.gz diff --git a/script/write-git-sha b/script/write-git-sha new file mode 100755 index 00000000000..d16743c6f10 --- /dev/null +++ b/script/write-git-sha @@ -0,0 +1,7 @@ +#!/bin/bash +# +# Write the current commit sha to the file GITSHA. This file is included in +# packaging so that `docker-compose version` can include the git sha. +# +set -e +git rev-parse --short HEAD > compose/GITSHA From efbfa9e38fb4565953d608e504e2bdc79737d408 Mon Sep 17 00:00:00 2001 From: Simon van der Veldt Date: Wed, 18 Nov 2015 21:38:58 +0100 Subject: [PATCH 1542/4072] run.sh script: Also pass DOCKER_TLS_VERIFY and DOCKER_CERT_PATH env vars to compose container Signed-off-by: Simon van der Veldt --- script/run.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/run.sh b/script/run.sh index cf46c143c38..342188e88da 100755 --- a/script/run.sh +++ b/script/run.sh @@ -26,7 +26,7 @@ fi if [ -S "$DOCKER_HOST" ]; then DOCKER_ADDR="-v $DOCKER_HOST:$DOCKER_HOST -e DOCKER_HOST" else - DOCKER_ADDR="-e DOCKER_HOST" + DOCKER_ADDR="-e DOCKER_HOST -e DOCKER_TLS_VERIFY -e DOCKER_CERT_PATH" fi From c78c32c2e819cdbf83f9e9ed2dc16ff9e62b78dc Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 16 Nov 2015 12:35:26 -0500 Subject: [PATCH 1543/4072] Fixes #2398 - the build progress stream can contain empty json objects. Previously these empty objects would hit a bug in splitting objects causing it crash. With this fix the empty objects are returned properly. Signed-off-by: Daniel Nephin --- compose/utils.py | 12 ++++++------ tests/unit/utils_test.py | 28 ++++++++++++++++++++-------- 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/compose/utils.py b/compose/utils.py index 2c6c4584d29..a013035e916 100644 --- a/compose/utils.py +++ b/compose/utils.py @@ -102,7 +102,7 @@ def stream_as_text(stream): def line_splitter(buffer, separator=u'\n'): index = buffer.find(six.text_type(separator)) if index == -1: - return None, None + return None return buffer[:index + 1], buffer[index + 1:] @@ -120,11 +120,11 @@ def split_buffer(stream, splitter=None, decoder=lambda a: a): for data in stream_as_text(stream): buffered += data while True: - item, rest = splitter(buffered) - if not item: + buffer_split = splitter(buffered) + if buffer_split is None: break - buffered = rest + item, buffered = buffer_split yield item if buffered: @@ -140,7 +140,7 @@ def json_splitter(buffer): rest = buffer[json.decoder.WHITESPACE.match(buffer, index).end():] return obj, rest except ValueError: - return None, None + return None def json_stream(stream): @@ -148,7 +148,7 @@ def json_stream(stream): This handles streams which are inconsistently buffered (some entries may be newline delimited, and others are not). """ - return split_buffer(stream_as_text(stream), json_splitter, json_decoder.decode) + return split_buffer(stream, json_splitter, json_decoder.decode) def write_out_msg(stream, lines, msg_index, msg, status="done"): diff --git a/tests/unit/utils_test.py b/tests/unit/utils_test.py index e3d0bc00b5e..15999dde98a 100644 --- a/tests/unit/utils_test.py +++ b/tests/unit/utils_test.py @@ -1,25 +1,21 @@ # encoding: utf-8 from __future__ import unicode_literals -from .. import unittest from compose import utils -class JsonSplitterTestCase(unittest.TestCase): +class TestJsonSplitter(object): def test_json_splitter_no_object(self): data = '{"foo": "bar' - self.assertEqual(utils.json_splitter(data), (None, None)) + assert utils.json_splitter(data) is None def test_json_splitter_with_object(self): data = '{"foo": "bar"}\n \n{"next": "obj"}' - self.assertEqual( - utils.json_splitter(data), - ({'foo': 'bar'}, '{"next": "obj"}') - ) + assert utils.json_splitter(data) == ({'foo': 'bar'}, '{"next": "obj"}') -class StreamAsTextTestCase(unittest.TestCase): +class TestStreamAsText(object): def test_stream_with_non_utf_unicode_character(self): stream = [b'\xed\xf3\xf3'] @@ -30,3 +26,19 @@ def test_stream_with_utf_character(self): stream = ['ěĝ'.encode('utf-8')] output, = utils.stream_as_text(stream) assert output == 'ěĝ' + + +class TestJsonStream(object): + + def test_with_falsy_entries(self): + stream = [ + '{"one": "two"}\n{}\n', + "[1, 2, 3]\n[]\n", + ] + output = list(utils.json_stream(stream)) + assert output == [ + {'one': 'two'}, + {}, + [1, 2, 3], + [], + ] From 1e8f76767f80ecb4b7aa546eeb787102aff311e4 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 18 Nov 2015 14:51:01 -0500 Subject: [PATCH 1544/4072] Fix env_file and environment when used with extends. Signed-off-by: Daniel Nephin --- compose/config/config.py | 22 +++++++-------- tests/integration/testcases.py | 3 +- tests/unit/config/config_test.py | 48 ++++++++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 13 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 201266208a2..fa214767be7 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -257,16 +257,11 @@ def detect_cycle(self): def run(self): self.detect_cycle() - service_dict = dict(self.service_config.config) - env = resolve_environment(self.working_dir, self.service_config.config) - if env: - service_dict['environment'] = env - service_dict.pop('env_file', None) - - if 'extends' in service_dict: + if 'extends' in self.service_config.config: service_dict = self.resolve_extends(*self.validate_and_construct_extends()) + return self.service_config._replace(config=service_dict) - return self.service_config._replace(config=service_dict) + return self.service_config def validate_and_construct_extends(self): extends = self.service_config.config['extends'] @@ -316,16 +311,15 @@ def get_extended_config_path(self, extends_options): return filename -def resolve_environment(working_dir, service_dict): +def resolve_environment(service_config): """Unpack any environment variables from an env_file, if set. Interpolate environment values if set. """ - if 'environment' not in service_dict and 'env_file' not in service_dict: - return {} + service_dict = service_config.config env = {} if 'env_file' in service_dict: - for env_file in get_env_files(working_dir, service_dict): + for env_file in get_env_files(service_config.working_dir, service_dict): env.update(env_vars_from_file(env_file)) env.update(parse_environment(service_dict.get('environment'))) @@ -362,6 +356,10 @@ def process_service(service_config): working_dir = service_config.working_dir service_dict = dict(service_config.config) + if 'environment' in service_dict or 'env_file' in service_dict: + service_dict['environment'] = resolve_environment(service_config) + service_dict.pop('env_file', None) + if 'volumes' in service_dict and service_dict.get('volume_driver') is None: service_dict['volumes'] = resolve_volume_paths(working_dir, service_dict) diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index d63f0591603..de2d1a70156 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -46,7 +46,8 @@ def create_service(self, name, **kwargs): service_config = ServiceConfig('.', None, name, kwargs) options = process_service(service_config) - options['environment'] = resolve_environment('.', kwargs) + options['environment'] = resolve_environment( + service_config._replace(config=options)) labels = options.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 3038af80d0c..c69e34306c5 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1317,6 +1317,54 @@ def test_extended_service_with_verbose_and_shorthand_way(self): }, ])) + def test_extends_with_environment_and_env_files(self): + tmpdir = py.test.ensuretemp('test_extends_with_environment') + self.addCleanup(tmpdir.remove) + commondir = tmpdir.mkdir('common') + commondir.join('base.yml').write(""" + app: + image: 'example/app' + env_file: + - 'envs' + environment: + - SECRET + """) + tmpdir.join('docker-compose.yml').write(""" + ext: + extends: + file: common/base.yml + service: app + env_file: + - 'envs' + environment: + - THING + """) + commondir.join('envs').write(""" + COMMON_ENV_FILE=1 + """) + tmpdir.join('envs').write(""" + FROM_ENV_FILE=1 + """) + + expected = [ + { + 'name': 'ext', + 'image': 'example/app', + 'environment': { + 'SECRET': 'secret', + 'FROM_ENV_FILE': '1', + 'COMMON_ENV_FILE': '1', + 'THING': 'thing', + }, + }, + ] + with mock.patch.dict(os.environ): + os.environ['SECRET'] = 'secret' + os.environ['THING'] = 'thing' + config = load_from_filename(str(tmpdir.join('docker-compose.yml'))) + + assert config == expected + @pytest.mark.xfail(IS_WINDOWS_PLATFORM, reason='paths use slash') class ExpandPathTest(unittest.TestCase): From acf31181e8bff0481703c8f17f93c17ebec59506 Mon Sep 17 00:00:00 2001 From: Sven Dowideit Date: Wed, 18 Nov 2015 20:17:23 +1000 Subject: [PATCH 1545/4072] Some small changes to clear up docs-validation complaints Signed-off-by: Sven Dowideit --- docs/README.md | 9 +++++++++ docs/django.md | 2 +- docs/extends.md | 2 +- docs/install.md | 2 +- docs/rails.md | 2 +- docs/wordpress.md | 2 +- 6 files changed, 14 insertions(+), 5 deletions(-) diff --git a/docs/README.md b/docs/README.md index 8fbad30c58f..d8ab7c3e525 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,3 +1,12 @@ + + # Contributing to the Docker Compose documentation 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. diff --git a/docs/django.md b/docs/django.md index d4d2bd1ecf0..b503e57448a 100644 --- a/docs/django.md +++ b/docs/django.md @@ -171,7 +171,7 @@ In this section, you set up the database connection for Django. ## More Compose documentation -- [User guide](../index.md) +- [User guide](index.md) - [Installing Compose](install.md) - [Getting Started](gettingstarted.md) - [Get started with Rails](rails.md) diff --git a/docs/extends.md b/docs/extends.md index b21b6d76db8..011a7350bf3 100644 --- a/docs/extends.md +++ b/docs/extends.md @@ -365,7 +365,7 @@ In the case of `environment`, `labels`, `volumes` and `devices`, Compose ## Compose documentation -- [User guide](/) +- [User guide](index.md) - [Installing Compose](install.md) - [Getting Started](gettingstarted.md) - [Get started with Django](django.md) diff --git a/docs/install.md b/docs/install.md index c5304409c55..5f956359036 100644 --- a/docs/install.md +++ b/docs/install.md @@ -126,7 +126,7 @@ To uninstall Docker Compose if you installed using `pip`: ## Where to go next -- [User guide](/) +- [User guide](index.md) - [Getting Started](gettingstarted.md) - [Get started with Django](django.md) - [Get started with Rails](rails.md) diff --git a/docs/rails.md b/docs/rails.md index 8e16af64230..d3f1707ca8d 100644 --- a/docs/rails.md +++ b/docs/rails.md @@ -133,7 +133,7 @@ That's it. Your app should now be running on port 3000 on your Docker daemon. If ## More Compose documentation -- [User guide](/) +- [User guide](index.md) - [Installing Compose](install.md) - [Getting Started](gettingstarted.md) - [Get started with Django](django.md) diff --git a/docs/wordpress.md b/docs/wordpress.md index 373ef4d0d51..15746a754f5 100644 --- a/docs/wordpress.md +++ b/docs/wordpress.md @@ -93,7 +93,7 @@ database containers. If you're using [Docker Machine](https://docs.docker.com/ma ## More Compose documentation -- [User guide](/) +- [User guide](index.md) - [Installing Compose](install.md) - [Getting Started](gettingstarted.md) - [Get started with Django](django.md) From 3fdf0f43bef4a881b65de871b26ec6feffd98059 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 19 Nov 2015 12:55:03 -0500 Subject: [PATCH 1546/4072] Add note about required pip version. Signed-off-by: Daniel Nephin --- docs/install.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/install.md b/docs/install.md index 5f956359036..d394905d02b 100644 --- a/docs/install.md +++ b/docs/install.md @@ -70,6 +70,7 @@ to get started. $ pip install docker-compose +> **Note:** pip version 6.0 or greater is required ### Install as a container From 6224b6edd9b776fbeaf4526152e323c0538be8f2 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 19 Nov 2015 14:52:50 -0500 Subject: [PATCH 1547/4072] Fix use case link in readme. Signed-off-by: Daniel Nephin --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 5052db39d51..c9b4729a7e4 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](#common-use-cases). +[Common Use Cases](docs/index.md#common-use-cases). Using Compose is basically a three-step process. From d1adbb9b259c4582b77ac90a96fe2d071fb408aa Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 12 Nov 2015 20:44:05 -0500 Subject: [PATCH 1548/4072] Refactor parallel_execute. Signed-off-by: Daniel Nephin --- compose/container.py | 39 ++++++++++ compose/project.py | 34 ++------- compose/service.py | 47 ++++-------- compose/utils.py | 117 +++++++++++++++--------------- tests/integration/service_test.py | 27 ++++--- tox.ini | 2 +- 6 files changed, 138 insertions(+), 128 deletions(-) diff --git a/compose/container.py b/compose/container.py index 1ca483809ab..dde83bd35d6 100644 --- a/compose/container.py +++ b/compose/container.py @@ -1,6 +1,7 @@ from __future__ import absolute_import from __future__ import unicode_literals +import operator from functools import reduce import six @@ -8,6 +9,7 @@ from .const import LABEL_CONTAINER_NUMBER from .const import LABEL_PROJECT from .const import LABEL_SERVICE +from compose.utils import parallel_execute class Container(object): @@ -250,3 +252,40 @@ def get_container_name(container): # ps shortest_name = min(container['Names'], key=lambda n: len(n.split('/'))) return shortest_name.split('/')[-1] + + +def parallel_operation(containers, operation, options, message): + parallel_execute( + containers, + operator.methodcaller(operation, **options), + operator.attrgetter('name'), + message) + + +def parallel_remove(containers, options): + stopped_containers = [c for c in containers if not c.is_running] + 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') + + +def parallel_pause(containers, options): + parallel_operation(containers, 'pause', options, 'Pausing') + + +def parallel_unpause(containers, options): + parallel_operation(containers, 'unpause', options, 'Unpausing') + + +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 41af8626151..dc6dd32fdeb 100644 --- a/compose/project.py +++ b/compose/project.py @@ -7,6 +7,7 @@ from docker.errors import APIError from docker.errors import NotFound +from . import container from .config import ConfigurationError from .config import get_service_name_from_net from .const import DEFAULT_TIMEOUT @@ -22,7 +23,6 @@ from .service import Service from .service import ServiceNet from .service import VolumeFromSpec -from .utils import parallel_execute log = logging.getLogger(__name__) @@ -241,42 +241,22 @@ def start(self, service_names=None, **options): service.start(**options) def stop(self, service_names=None, **options): - parallel_execute( - objects=self.containers(service_names), - obj_callable=lambda c: c.stop(**options), - msg_index=lambda c: c.name, - msg="Stopping" - ) + container.parallel_stop(self.containers(service_names), options) def pause(self, service_names=None, **options): - for service in reversed(self.get_services(service_names)): - service.pause(**options) + container.parallel_pause(reversed(self.containers(service_names)), options) def unpause(self, service_names=None, **options): - for service in self.get_services(service_names): - service.unpause(**options) + container.parallel_unpause(self.containers(service_names), options) def kill(self, service_names=None, **options): - parallel_execute( - objects=self.containers(service_names), - obj_callable=lambda c: c.kill(**options), - msg_index=lambda c: c.name, - msg="Killing" - ) + container.parallel_kill(self.containers(service_names), options) def remove_stopped(self, service_names=None, **options): - all_containers = self.containers(service_names, stopped=True) - stopped_containers = [c for c in all_containers if not c.is_running] - parallel_execute( - objects=stopped_containers, - obj_callable=lambda c: c.remove(**options), - msg_index=lambda c: c.name, - msg="Removing" - ) + container.parallel_remove(self.containers(service_names, stopped=True), options) def restart(self, service_names=None, **options): - for service in self.get_services(service_names): - service.restart(**options) + container.parallel_restart(self.containers(service_names, stopped=True), options) def build(self, service_names=None, no_cache=False, pull=False, force_rm=False): for service in self.get_services(service_names): diff --git a/compose/service.py b/compose/service.py index b79fd900108..ab6f6dd6cf1 100644 --- a/compose/service.py +++ b/compose/service.py @@ -28,6 +28,9 @@ from .const import LABEL_SERVICE from .const import LABEL_VERSION from .container import Container +from .container import parallel_remove +from .container import parallel_start +from .container import parallel_stop from .legacy import check_for_legacy_containers from .progress_stream import stream_output from .progress_stream import StreamOutputError @@ -241,12 +244,7 @@ def create_and_start(service, number): else: containers_to_start = stopped_containers - parallel_execute( - objects=containers_to_start, - obj_callable=lambda c: c.start(), - msg_index=lambda c: c.name, - msg="Starting" - ) + parallel_start(containers_to_start, {}) num_running += len(containers_to_start) @@ -259,35 +257,22 @@ def create_and_start(service, number): ] parallel_execute( - objects=container_numbers, - obj_callable=lambda n: create_and_start(service=self, number=n), - msg_index=lambda n: n, - msg="Creating and starting" + container_numbers, + lambda n: create_and_start(service=self, number=n), + lambda n: n, + "Creating and starting" ) 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_execute( - objects=containers_to_stop, - obj_callable=lambda c: c.stop(timeout=timeout), - msg_index=lambda c: c.name, - msg="Stopping" - ) - - self.remove_stopped() - - def remove_stopped(self, **options): - containers = [c for c in self.containers(stopped=True) if not c.is_running] - - parallel_execute( - objects=containers, - obj_callable=lambda c: c.remove(**options), - msg_index=lambda c: c.name, - msg="Removing" - ) + 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), {}) def create_container(self, one_off=False, diff --git a/compose/utils.py b/compose/utils.py index a013035e916..716f6633fb7 100644 --- a/compose/utils.py +++ b/compose/utils.py @@ -17,58 +17,51 @@ json_decoder = json.JSONDecoder() -def parallel_execute(objects, obj_callable, msg_index, msg): - """ - For a given list of objects, call the callable passing in the first +def perform_operation(func, arg, callback, index): + try: + callback((index, func(arg))) + except Exception as e: + callback((index, e)) + + +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. """ + objects = list(objects) stream = get_output_stream(sys.stdout) - lines = [] + writer = ParallelStreamWriter(stream, msg) for obj in objects: - write_out_msg(stream, lines, msg_index(obj), msg) + writer.initialize(index_func(obj)) q = Queue() - def inner_execute_function(an_callable, parameter, msg_index): - error = None - try: - result = an_callable(parameter) - except APIError as e: - error = e.explanation - result = "error" - except Exception as e: - error = e - result = 'unexpected_exception' - - q.put((msg_index, result, error)) - - for an_object in objects: + # TODO: limit the number of threads #1828 + for obj in objects: t = Thread( - target=inner_execute_function, - args=(obj_callable, an_object, msg_index(an_object)), - ) + target=perform_operation, + args=(func, obj, q.put, index_func(obj))) t.daemon = True t.start() done = 0 errors = {} - total_to_execute = len(objects) - while done < total_to_execute: + while done < len(objects): try: - msg_index, result, error = q.get(timeout=1) - - if result == 'unexpected_exception': - errors[msg_index] = result, error - if result == 'error': - errors[msg_index] = result, error - write_out_msg(stream, lines, msg_index, msg, status='error') - else: - write_out_msg(stream, lines, msg_index, msg) - done += 1 + msg_index, result = q.get(timeout=1) except Empty: - pass + continue + + 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 + else: + writer.write(msg_index, 'done') + done += 1 if not errors: return @@ -80,6 +73,36 @@ def inner_execute_function(an_callable, parameter, msg_index): raise error +class ParallelStreamWriter(object): + """Write out messages for operations happening in parallel. + + Each operation has it's own line, and ANSI code characters are used + to jump to the correct line, and write over the line. + """ + + def __init__(self, stream, msg): + self.stream = stream + self.msg = msg + self.lines = [] + + def initialize(self, obj_index): + self.lines.append(obj_index) + self.stream.write("{} {} ... \r\n".format(self.msg, obj_index)) + self.stream.flush() + + def write(self, obj_index, status): + position = self.lines.index(obj_index) + diff = len(self.lines) - position + # move up + self.stream.write("%c[%dA" % (27, diff)) + # erase + self.stream.write("%c[2K\r" % 27) + self.stream.write("{} {} ... {}\r".format(self.msg, obj_index, status)) + # move back down + self.stream.write("%c[%dB" % (27, diff)) + self.stream.flush() + + def get_output_stream(stream): if six.PY3: return stream @@ -151,30 +174,6 @@ def json_stream(stream): return split_buffer(stream, json_splitter, json_decoder.decode) -def write_out_msg(stream, lines, msg_index, msg, status="done"): - """ - Using special ANSI code characters we can write out the msg over the top of - a previous status message, if it exists. - """ - obj_index = msg_index - if msg_index in lines: - position = lines.index(obj_index) - diff = len(lines) - position - # move up - stream.write("%c[%dA" % (27, diff)) - # erase - stream.write("%c[2K\r" % 27) - stream.write("{} {} ... {}\r".format(msg, obj_index, status)) - # move back down - stream.write("%c[%dB" % (27, diff)) - else: - diff = 0 - lines.append(obj_index) - stream.write("{} {} ... \r\n".format(msg, obj_index)) - - stream.flush() - - def json_hash(obj): dump = json.dumps(obj, sort_keys=True, separators=(',', ':')) h = hashlib.sha256() diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index aaa4f01ec07..34869ab8851 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -36,6 +36,12 @@ def create_and_start_container(service, **override_options): return container +def remove_stopped(service): + containers = [c for c in service.containers(stopped=True) if not c.is_running] + for container in containers: + container.remove() + + class ServiceTest(DockerClientTestCase): def test_containers(self): foo = self.create_service('foo') @@ -94,14 +100,14 @@ def test_kill_remove(self): create_and_start_container(service) self.assertEqual(len(service.containers()), 1) - service.remove_stopped() + remove_stopped(service) self.assertEqual(len(service.containers()), 1) service.kill() self.assertEqual(len(service.containers()), 0) self.assertEqual(len(service.containers(stopped=True)), 1) - service.remove_stopped() + remove_stopped(service) self.assertEqual(len(service.containers(stopped=True)), 0) def test_create_container_with_one_off(self): @@ -659,9 +665,8 @@ def test_scale_with_stopped_containers_and_needing_creation(self): self.assertIn('Creating', captured_output) self.assertIn('Starting', captured_output) - def test_scale_with_api_returns_errors(self): - """ - Test that when scaling if the API returns an error, that error is handled + def test_scale_with_api_error(self): + """Test that when scaling if the API returns an error, that error is handled and the remaining threads continue. """ service = self.create_service('web') @@ -670,7 +675,10 @@ def test_scale_with_api_returns_errors(self): with mock.patch( 'compose.container.Container.create', - side_effect=APIError(message="testing", response={}, explanation="Boom")): + side_effect=APIError( + message="testing", + response={}, + explanation="Boom")): with mock.patch('sys.stdout', new_callable=StringIO) as mock_stdout: service.scale(3) @@ -679,9 +687,8 @@ def test_scale_with_api_returns_errors(self): self.assertTrue(service.containers()[0].is_running) self.assertIn("ERROR: for 2 Boom", mock_stdout.getvalue()) - def test_scale_with_api_returns_unexpected_exception(self): - """ - Test that when scaling if the API returns an error, that is not of type + def test_scale_with_unexpected_exception(self): + """Test that when scaling if the API returns an error, that is not of type APIError, that error is re-raised. """ service = self.create_service('web') @@ -903,7 +910,7 @@ def test_labels(self): self.assertIn(pair, labels) service.kill() - service.remove_stopped() + remove_stopped(service) labels_list = ["%s=%s" % pair for pair in labels_dict.items()] diff --git a/tox.ini b/tox.ini index d1098a55a3e..9d45b0c7f59 100644 --- a/tox.ini +++ b/tox.ini @@ -44,5 +44,5 @@ directory = coverage-html # Allow really long lines for now max-line-length = 140 # Set this high for now -max-complexity = 20 +max-complexity = 12 exclude = compose/packages From 64447879d2f5a2fe5b8b50819b6620b759715a9d Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 17 Nov 2015 12:21:47 -0500 Subject: [PATCH 1549/4072] Reduce complexity of merge_service_dicts Signed-off-by: Daniel Nephin --- compose/config/config.py | 73 +++++++++++++++++++--------------------- 1 file changed, 34 insertions(+), 39 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 201266208a2..6c5654339cb 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -1,5 +1,6 @@ import codecs import logging +import operator import os import sys from collections import namedtuple @@ -389,54 +390,44 @@ def merge_service_dicts_from_files(base, override): def merge_service_dicts(base, override): - d = base.copy() + d = {} - if 'environment' in base or 'environment' in override: - d['environment'] = merge_environment( - base.get('environment'), - override.get('environment'), - ) - - path_mapping_keys = ['volumes', 'devices'] - - for key in path_mapping_keys: - if key in base or key in override: - d[key] = merge_path_mappings( - base.get(key), - override.get(key), - ) - - if 'labels' in base or 'labels' in override: - d['labels'] = merge_labels( - base.get('labels'), - override.get('labels'), - ) + 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)) - if 'image' in override and 'build' in d: - del d['build'] + merge_field('environment', merge_environment) + merge_field('labels', merge_labels) + merge_image_or_build(base, override, d) - if 'build' in override and 'image' in d: - del d['image'] + for field in ['volumes', 'devices']: + merge_field(field, merge_path_mappings) - list_keys = ['ports', 'expose', 'external_links'] + for field in ['ports', 'expose', 'external_links']: + merge_field(field, operator.add, default=[]) - for key in list_keys: - if key in base or key in override: - d[key] = base.get(key, []) + override.get(key, []) + for field in ['dns', 'dns_search']: + merge_field(field, merge_list_or_string) - list_or_string_keys = ['dns', 'dns_search'] + already_merged_keys = set(d) | {'image', 'build'} + for field in set(ALLOWED_KEYS) - already_merged_keys: + if field in base or field in override: + d[field] = override.get(field, base.get(field)) - for key in list_or_string_keys: - if key in base or key in override: - d[key] = to_list(base.get(key)) + to_list(override.get(key)) - - already_merged_keys = ['environment', 'labels'] + path_mapping_keys + list_keys + list_or_string_keys + return d - for k in set(ALLOWED_KEYS) - set(already_merged_keys): - if k in override: - d[k] = override[k] - return d +def merge_image_or_build(base, override, output): + if 'image' in override: + output['image'] = override['image'] + elif 'build' in override: + output['build'] = override['build'] + elif 'image' in base: + output['image'] = base['image'] + elif 'build' in base: + output['build'] = base['build'] def merge_environment(base, override): @@ -604,6 +595,10 @@ def expand_path(working_dir, path): return os.path.abspath(os.path.join(working_dir, os.path.expanduser(path))) +def merge_list_or_string(base, override): + return to_list(base) + to_list(override) + + def to_list(value): if value is None: return [] From 2351e11cc8410cee9472cda38b685204e6252084 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 20 Nov 2015 17:51:36 -0500 Subject: [PATCH 1550/4072] Make sure we always have the latest busybox image, so that build --pull tests don't flake. Signed-off-by: Daniel Nephin --- tests/acceptance/cli_test.py | 5 +++++ tests/integration/testcases.py | 6 +----- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 7ca6e8194b3..88ec457388e 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -15,6 +15,7 @@ from compose.cli.docker_client import docker_client from compose.container import Container from tests.integration.testcases import DockerClientTestCase +from tests.integration.testcases import pull_busybox ProcessResult = namedtuple('ProcessResult', 'stdout stderr') @@ -184,6 +185,8 @@ def test_build_no_cache(self): assert BUILD_PULL_TEXT not in result.stdout def test_build_pull(self): + # Make sure we have the latest busybox already + pull_busybox(self.client) self.base_dir = 'tests/fixtures/simple-dockerfile' self.dispatch(['build', 'simple'], None) @@ -192,6 +195,8 @@ def test_build_pull(self): assert BUILD_PULL_TEXT in result.stdout def test_build_no_cache_pull(self): + # Make sure we have the latest busybox already + pull_busybox(self.client) self.base_dir = 'tests/fixtures/simple-dockerfile' self.dispatch(['build', 'simple']) diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index d63f0591603..9b7b1f8213e 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -1,7 +1,6 @@ from __future__ import absolute_import from __future__ import unicode_literals -from docker import errors from docker.utils import version_lt from pytest import skip @@ -16,10 +15,7 @@ def pull_busybox(client): - try: - client.inspect_image('busybox:latest') - except errors.APIError: - client.pull('busybox:latest', stream=False) + client.pull('busybox:latest', stream=False) class DockerClientTestCase(unittest.TestCase): From 13081d4516d6d6b8ebfebc045e78e9be6bd96b74 Mon Sep 17 00:00:00 2001 From: Brandon Burton Date: Fri, 20 Nov 2015 16:02:37 -0800 Subject: [PATCH 1551/4072] Fixing matrix include so `os: linux` goes to trusty Signed-off-by: Brandon Burton --- .travis.yml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.travis.yml b/.travis.yml index 3310e2ad9ff..3bb365a1401 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,16 +2,14 @@ sudo: required language: python -services: - - docker - matrix: include: - os: linux + services: + - docker - os: osx language: generic - install: ./script/travis/install script: From b4edf0c45481acb8d780de9ff73156fb7c581337 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 23 Nov 2015 11:34:48 -0500 Subject: [PATCH 1552/4072] Move parallel_execute to a new module. Signed-off-by: Daniel Nephin --- compose/container.py | 39 ------------- compose/parallel.py | 135 +++++++++++++++++++++++++++++++++++++++++++ compose/project.py | 14 ++--- compose/service.py | 8 +-- compose/utils.py | 94 ------------------------------ 5 files changed, 146 insertions(+), 144 deletions(-) create mode 100644 compose/parallel.py diff --git a/compose/container.py b/compose/container.py index dde83bd35d6..1ca483809ab 100644 --- a/compose/container.py +++ b/compose/container.py @@ -1,7 +1,6 @@ from __future__ import absolute_import from __future__ import unicode_literals -import operator from functools import reduce import six @@ -9,7 +8,6 @@ from .const import LABEL_CONTAINER_NUMBER from .const import LABEL_PROJECT from .const import LABEL_SERVICE -from compose.utils import parallel_execute class Container(object): @@ -252,40 +250,3 @@ def get_container_name(container): # ps shortest_name = min(container['Names'], key=lambda n: len(n.split('/'))) return shortest_name.split('/')[-1] - - -def parallel_operation(containers, operation, options, message): - parallel_execute( - containers, - operator.methodcaller(operation, **options), - operator.attrgetter('name'), - message) - - -def parallel_remove(containers, options): - stopped_containers = [c for c in containers if not c.is_running] - 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') - - -def parallel_pause(containers, options): - parallel_operation(containers, 'pause', options, 'Pausing') - - -def parallel_unpause(containers, options): - parallel_operation(containers, 'unpause', options, 'Unpausing') - - -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/parallel.py b/compose/parallel.py new file mode 100644 index 00000000000..2735a397f3e --- /dev/null +++ b/compose/parallel.py @@ -0,0 +1,135 @@ +from __future__ import absolute_import +from __future__ import unicode_literals + +import operator +import sys +from threading import Thread + +from docker.errors import APIError +from six.moves.queue import Empty +from six.moves.queue import Queue + +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, index_func, msg): + """For a given list of objects, call the callable passing in the first + object we give it. + """ + objects = list(objects) + stream = get_output_stream(sys.stdout) + 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() + + done = 0 + errors = {} + + while done < len(objects): + try: + msg_index, result = q.get(timeout=1) + except Empty: + continue + + 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 + else: + writer.write(msg_index, 'done') + done += 1 + + if not errors: + return + + 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 + + +class ParallelStreamWriter(object): + """Write out messages for operations happening in parallel. + + Each operation has it's own line, and ANSI code characters are used + to jump to the correct line, and write over the line. + """ + + def __init__(self, stream, msg): + self.stream = stream + self.msg = msg + self.lines = [] + + def initialize(self, obj_index): + self.lines.append(obj_index) + self.stream.write("{} {} ... \r\n".format(self.msg, obj_index)) + self.stream.flush() + + def write(self, obj_index, status): + position = self.lines.index(obj_index) + diff = len(self.lines) - position + # move up + self.stream.write("%c[%dA" % (27, diff)) + # erase + self.stream.write("%c[2K\r" % 27) + self.stream.write("{} {} ... {}\r".format(self.msg, obj_index, status)) + # move back down + self.stream.write("%c[%dB" % (27, diff)) + self.stream.flush() + + +def parallel_operation(containers, operation, options, message): + parallel_execute( + containers, + operator.methodcaller(operation, **options), + operator.attrgetter('name'), + message) + + +def parallel_remove(containers, options): + stopped_containers = [c for c in containers if not c.is_running] + 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') + + +def parallel_pause(containers, options): + parallel_operation(containers, 'pause', options, 'Pausing') + + +def parallel_unpause(containers, options): + parallel_operation(containers, 'unpause', options, 'Unpausing') + + +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 dc6dd32fdeb..e29a2eb5a04 100644 --- a/compose/project.py +++ b/compose/project.py @@ -7,7 +7,7 @@ from docker.errors import APIError from docker.errors import NotFound -from . import container +from . import parallel from .config import ConfigurationError from .config import get_service_name_from_net from .const import DEFAULT_TIMEOUT @@ -241,22 +241,22 @@ def start(self, service_names=None, **options): service.start(**options) def stop(self, service_names=None, **options): - container.parallel_stop(self.containers(service_names), options) + parallel.parallel_stop(self.containers(service_names), options) def pause(self, service_names=None, **options): - container.parallel_pause(reversed(self.containers(service_names)), options) + parallel.parallel_pause(reversed(self.containers(service_names)), options) def unpause(self, service_names=None, **options): - container.parallel_unpause(self.containers(service_names), options) + parallel.parallel_unpause(self.containers(service_names), options) def kill(self, service_names=None, **options): - container.parallel_kill(self.containers(service_names), options) + parallel.parallel_kill(self.containers(service_names), options) def remove_stopped(self, service_names=None, **options): - container.parallel_remove(self.containers(service_names, stopped=True), options) + parallel.parallel_remove(self.containers(service_names, stopped=True), options) def restart(self, service_names=None, **options): - container.parallel_restart(self.containers(service_names, stopped=True), options) + parallel.parallel_restart(self.containers(service_names, stopped=True), options) def build(self, service_names=None, no_cache=False, pull=False, force_rm=False): for service in self.get_services(service_names): diff --git a/compose/service.py b/compose/service.py index ab6f6dd6cf1..dd2399ee348 100644 --- a/compose/service.py +++ b/compose/service.py @@ -28,14 +28,14 @@ from .const import LABEL_SERVICE from .const import LABEL_VERSION from .container import Container -from .container import parallel_remove -from .container import parallel_start -from .container import parallel_stop from .legacy import check_for_legacy_containers +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 -from .utils import parallel_execute log = logging.getLogger(__name__) diff --git a/compose/utils.py b/compose/utils.py index 716f6633fb7..362629bc2b0 100644 --- a/compose/utils.py +++ b/compose/utils.py @@ -2,107 +2,13 @@ import hashlib import json import json.decoder -import logging -import sys -from threading import Thread import six -from docker.errors import APIError -from six.moves.queue import Empty -from six.moves.queue import Queue -log = logging.getLogger(__name__) - json_decoder = json.JSONDecoder() -def perform_operation(func, arg, callback, index): - try: - callback((index, func(arg))) - except Exception as e: - callback((index, e)) - - -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. - """ - objects = list(objects) - stream = get_output_stream(sys.stdout) - 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() - - done = 0 - errors = {} - - while done < len(objects): - try: - msg_index, result = q.get(timeout=1) - except Empty: - continue - - 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 - else: - writer.write(msg_index, 'done') - done += 1 - - if not errors: - return - - 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 - - -class ParallelStreamWriter(object): - """Write out messages for operations happening in parallel. - - Each operation has it's own line, and ANSI code characters are used - to jump to the correct line, and write over the line. - """ - - def __init__(self, stream, msg): - self.stream = stream - self.msg = msg - self.lines = [] - - def initialize(self, obj_index): - self.lines.append(obj_index) - self.stream.write("{} {} ... \r\n".format(self.msg, obj_index)) - self.stream.flush() - - def write(self, obj_index, status): - position = self.lines.index(obj_index) - diff = len(self.lines) - position - # move up - self.stream.write("%c[%dA" % (27, diff)) - # erase - self.stream.write("%c[2K\r" % 27) - self.stream.write("{} {} ... {}\r".format(self.msg, obj_index, status)) - # move back down - self.stream.write("%c[%dB" % (27, diff)) - self.stream.flush() - - def get_output_stream(stream): if six.PY3: return stream From c9ca5e86b0e8e0e1c8cd2345da5a55739b430242 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 13 Nov 2015 17:39:02 -0500 Subject: [PATCH 1553/4072] Remove project name validation project name is already normalized to a valid name before creating a service. Signed-off-by: Daniel Nephin --- compose/service.py | 4 ---- tests/unit/service_test.py | 5 ----- 2 files changed, 9 deletions(-) diff --git a/compose/service.py b/compose/service.py index dd2399ee348..9004260f6e9 100644 --- a/compose/service.py +++ b/compose/service.py @@ -18,7 +18,6 @@ from . import __version__ from .config import DOCKER_CONFIG_KEYS from .config import merge_environment -from .config.validation import VALID_NAME_CHARS from .const import DEFAULT_TIMEOUT from .const import IS_WINDOWS_PLATFORM from .const import LABEL_CONFIG_HASH @@ -122,9 +121,6 @@ def __init__( net=None, **options ): - if not re.match('^%s+$' % VALID_NAME_CHARS, project): - raise ConfigError('Invalid project name "%s" - only %s are allowed' % (project, VALID_NAME_CHARS)) - self.name = name self.client = client self.project = project diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 808c391cd2a..78edf3bf735 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -35,11 +35,6 @@ class ServiceTest(unittest.TestCase): def setUp(self): self.mock_client = mock.create_autospec(docker.Client) - def test_project_validation(self): - self.assertRaises(ConfigError, lambda: Service(name='foo', project='>', image='foo')) - - Service(name='foo', project='bar.bar__', image='foo') - def test_containers(self): service = Service('db', self.mock_client, 'myproject', image='foo') self.mock_client.containers.return_value = [] From 068edfa31345760d334b952d27e546077b077388 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 13 Nov 2015 18:20:09 -0500 Subject: [PATCH 1554/4072] Move parsing of volumes_from to the last step of config parsing. Includes creating a new compose.config.types module for all the domain objects. Signed-off-by: Daniel Nephin --- compose/config/config.py | 19 +++++++++++++++++++ compose/config/types.py | 28 ++++++++++++++++++++++++++++ compose/project.py | 18 ++++++------------ compose/service.py | 19 +------------------ tests/integration/project_test.py | 2 +- tests/integration/service_test.py | 2 +- tests/unit/project_test.py | 23 +++++++++++++---------- tests/unit/service_test.py | 1 + tests/unit/sort_service_test.py | 7 ++++--- 9 files changed, 74 insertions(+), 45 deletions(-) create mode 100644 compose/config/types.py diff --git a/compose/config/config.py b/compose/config/config.py index 84b6748c974..8ec352ecc5e 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -1,3 +1,5 @@ +from __future__ import absolute_import + import codecs import logging import operator @@ -12,6 +14,7 @@ from .errors import ComposeFileNotFound from .errors import ConfigurationError from .interpolation import interpolate_environment_variables +from .types import VolumeFromSpec from .validation import validate_against_fields_schema from .validation import validate_against_service_schema from .validation import validate_extends_file_path @@ -198,8 +201,12 @@ def build_service(filename, service_name, service_dict): service_dict) resolver = ServiceExtendsResolver(service_config) service_dict = process_service(resolver.run()) + + # TODO: move to validate_service() validate_against_service_schema(service_dict, service_config.name) validate_paths(service_dict) + + service_dict = finalize_service(service_config._replace(config=service_dict)) service_dict['name'] = service_config.name return service_dict @@ -353,6 +360,7 @@ def validate_ulimits(ulimit_config): "than 'hard' value".format(ulimit_config)) +# TODO: rename to normalize_service def process_service(service_config): working_dir = service_config.working_dir service_dict = dict(service_config.config) @@ -370,12 +378,23 @@ def process_service(service_config): if 'labels' in service_dict: service_dict['labels'] = parse_labels(service_dict['labels']) + # TODO: move to a validate_service() if 'ulimits' in service_dict: validate_ulimits(service_dict['ulimits']) return service_dict +def finalize_service(service_config): + service_dict = dict(service_config.config) + + if 'volumes_from' in service_dict: + service_dict['volumes_from'] = [ + VolumeFromSpec.parse(vf) for vf in service_dict['volumes_from']] + + return service_dict + + def merge_service_dicts_from_files(base, override): """When merging services from multiple files we need to merge the `extends` field. This is not handled by `merge_service_dicts()` which is used to diff --git a/compose/config/types.py b/compose/config/types.py new file mode 100644 index 00000000000..73bfd4184ce --- /dev/null +++ b/compose/config/types.py @@ -0,0 +1,28 @@ +""" +Types for objects parsed from the configuration. +""" +from __future__ import absolute_import +from __future__ import unicode_literals + +from collections import namedtuple + +from compose.config.errors import ConfigurationError + + +class VolumeFromSpec(namedtuple('_VolumeFromSpec', 'source mode')): + + @classmethod + def parse(cls, volume_from_config): + parts = volume_from_config.split(':') + if len(parts) > 2: + raise ConfigurationError( + "volume_from {} has incorrect format, should be " + "service[:mode]".format(volume_from_config)) + + if len(parts) == 1: + source = parts[0] + mode = 'rw' + else: + source, mode = parts + + return cls(source, mode) diff --git a/compose/project.py b/compose/project.py index e29a2eb5a04..5caa1ea37f6 100644 --- a/compose/project.py +++ b/compose/project.py @@ -19,10 +19,8 @@ from .service import ContainerNet from .service import ConvergenceStrategy from .service import Net -from .service import parse_volume_from_spec from .service import Service from .service import ServiceNet -from .service import VolumeFromSpec log = logging.getLogger(__name__) @@ -38,10 +36,7 @@ def get_service_names(links): return [link.split(':')[0] for link in links] def get_service_names_from_volumes_from(volumes_from): - return [ - parse_volume_from_spec(volume_from).source - for volume_from in volumes_from - ] + return [volume_from.source for volume_from in volumes_from] def get_service_dependents(service_dict, services): name = service_dict['name'] @@ -192,16 +187,15 @@ def get_links(self, service_dict): def get_volumes_from(self, service_dict): volumes_from = [] if 'volumes_from' in service_dict: - for volume_from_config in service_dict.get('volumes_from', []): - volume_from_spec = parse_volume_from_spec(volume_from_config) + for volume_from_spec in service_dict.get('volumes_from', []): # Get service try: - service_name = self.get_service(volume_from_spec.source) - volume_from_spec = VolumeFromSpec(service_name, volume_from_spec.mode) + service = self.get_service(volume_from_spec.source) + volume_from_spec = volume_from_spec._replace(source=service) except NoSuchService: try: - container_name = Container.from_id(self.client, volume_from_spec.source) - volume_from_spec = VolumeFromSpec(container_name, volume_from_spec.mode) + container = Container.from_id(self.client, volume_from_spec.source) + volume_from_spec = volume_from_spec._replace(source=container) except APIError: raise ConfigurationError( 'Service "%s" mounts volumes from "%s", which is ' diff --git a/compose/service.py b/compose/service.py index 9004260f6e9..be0502c273e 100644 --- a/compose/service.py +++ b/compose/service.py @@ -70,6 +70,7 @@ def __init__(self, service, reason): self.reason = reason +# TODO: remove class ConfigError(ValueError): pass @@ -86,9 +87,6 @@ class NoSuchImageError(Exception): VolumeSpec = namedtuple('VolumeSpec', 'external internal mode') -VolumeFromSpec = namedtuple('VolumeFromSpec', 'source mode') - - ServiceName = namedtuple('ServiceName', 'project service number') @@ -1029,21 +1027,6 @@ def build_volume_from(volume_from_spec): return ["{}:{}".format(volume_from_spec.source.id, volume_from_spec.mode)] -def parse_volume_from_spec(volume_from_config): - parts = volume_from_config.split(':') - if len(parts) > 2: - raise ConfigError("Volume %s has incorrect format, should be " - "external:internal[:mode]" % volume_from_config) - - if len(parts) == 1: - source = parts[0] - mode = 'rw' - else: - source, mode = parts - - return VolumeFromSpec(source, mode) - - # Labels diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 2ce319005fa..d65d7ef0cfa 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -3,12 +3,12 @@ from .testcases import DockerClientTestCase from compose.cli.docker_client import docker_client from compose.config import config +from compose.config.types import VolumeFromSpec from compose.const import LABEL_PROJECT from compose.container import Container from compose.project import Project from compose.service import ConvergenceStrategy from compose.service import Net -from compose.service import VolumeFromSpec def build_service_dicts(service_config): diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 34869ab8851..34bf93fcbcd 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -14,6 +14,7 @@ from .testcases import DockerClientTestCase from .testcases import pull_busybox from compose import __version__ +from compose.config.types import VolumeFromSpec from compose.const import LABEL_CONFIG_HASH from compose.const import LABEL_CONTAINER_NUMBER from compose.const import LABEL_ONE_OFF @@ -27,7 +28,6 @@ from compose.service import ConvergenceStrategy from compose.service import Net from compose.service import Service -from compose.service import VolumeFromSpec def create_and_start_container(service, **override_options): diff --git a/tests/unit/project_test.py b/tests/unit/project_test.py index b38f5c783c8..f8178ed8b9a 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -4,6 +4,7 @@ from .. import mock from .. import unittest +from compose.config.types import VolumeFromSpec from compose.const import LABEL_SERVICE from compose.container import Container from compose.project import Project @@ -43,7 +44,7 @@ def test_from_dict_sorts_in_dependency_order(self): { 'name': 'db', 'image': 'busybox:latest', - 'volumes_from': ['volume'] + 'volumes_from': [VolumeFromSpec('volume', 'ro')] }, { 'name': 'volume', @@ -167,7 +168,7 @@ def test_use_volumes_from_container(self): { 'name': 'test', 'image': 'busybox:latest', - 'volumes_from': ['aaa'] + 'volumes_from': [VolumeFromSpec('aaa', 'rw')] } ], self.mock_client) self.assertEqual(project.get_service('test')._get_volumes_from(), [container_id + ":rw"]) @@ -190,17 +191,13 @@ def test_use_volumes_from_service_no_container(self): { 'name': 'test', 'image': 'busybox:latest', - 'volumes_from': ['vol'] + 'volumes_from': [VolumeFromSpec('vol', 'rw')] } ], self.mock_client) self.assertEqual(project.get_service('test')._get_volumes_from(), [container_name + ":rw"]) - @mock.patch.object(Service, 'containers') - def test_use_volumes_from_service_container(self, mock_return): + def test_use_volumes_from_service_container(self): container_ids = ['aabbccddee', '12345'] - mock_return.return_value = [ - mock.Mock(id=container_id, spec=Container) - for container_id in container_ids] project = Project.from_dicts('test', [ { @@ -210,10 +207,16 @@ def test_use_volumes_from_service_container(self, mock_return): { 'name': 'test', 'image': 'busybox:latest', - 'volumes_from': ['vol'] + 'volumes_from': [VolumeFromSpec('vol', 'rw')] } ], None) - self.assertEqual(project.get_service('test')._get_volumes_from(), [container_ids[0] + ':rw']) + with mock.patch.object(Service, 'containers') as mock_return: + mock_return.return_value = [ + mock.Mock(id=container_id, spec=Container) + for container_id in container_ids] + self.assertEqual( + project.get_service('test')._get_volumes_from(), + [container_ids[0] + ':rw']) def test_net_unset(self): project = Project.from_dicts('test', [ diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 78edf3bf735..efcc58e26ba 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -6,6 +6,7 @@ from .. import mock from .. import unittest +from compose.config.types import VolumeFromSpec from compose.const import IS_WINDOWS_PLATFORM from compose.const import LABEL_CONFIG_HASH from compose.const import LABEL_ONE_OFF diff --git a/tests/unit/sort_service_test.py b/tests/unit/sort_service_test.py index a7e522a1dd6..ef08828776e 100644 --- a/tests/unit/sort_service_test.py +++ b/tests/unit/sort_service_test.py @@ -1,4 +1,5 @@ from .. import unittest +from compose.config.types import VolumeFromSpec from compose.project import DependencyError from compose.project import sort_service_dicts @@ -73,7 +74,7 @@ def test_sort_service_dicts_4(self): }, { 'name': 'parent', - 'volumes_from': ['child'] + 'volumes_from': [VolumeFromSpec('child', 'rw')] }, { 'links': ['parent'], @@ -116,7 +117,7 @@ def test_sort_service_dicts_6(self): }, { 'name': 'parent', - 'volumes_from': ['child'] + 'volumes_from': [VolumeFromSpec('child', 'ro')] }, { 'name': 'child' @@ -141,7 +142,7 @@ def test_sort_service_dicts_7(self): }, { 'name': 'two', - 'volumes_from': ['one'] + 'volumes_from': [VolumeFromSpec('one', 'rw')] }, { 'name': 'one' From 12b82a20ff331f420c040dbb9cf1ea44fd74d7d5 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 13 Nov 2015 18:29:25 -0500 Subject: [PATCH 1555/4072] Move restart spec to the config.types module. Signed-off-by: Daniel Nephin --- compose/config/config.py | 4 ++++ compose/config/types.py | 17 +++++++++++++++++ compose/service.py | 22 +--------------------- tests/integration/service_test.py | 24 ++++++------------------ tests/unit/cli_test.py | 2 +- 5 files changed, 29 insertions(+), 40 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 8ec352ecc5e..9b03ea4ffc1 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -14,6 +14,7 @@ from .errors import ComposeFileNotFound from .errors import ConfigurationError from .interpolation import interpolate_environment_variables +from .types import parse_restart_spec from .types import VolumeFromSpec from .validation import validate_against_fields_schema from .validation import validate_against_service_schema @@ -392,6 +393,9 @@ def finalize_service(service_config): service_dict['volumes_from'] = [ VolumeFromSpec.parse(vf) for vf in service_dict['volumes_from']] + if 'restart' in service_dict: + service_dict['restart'] = parse_restart_spec(service_dict['restart']) + return service_dict diff --git a/compose/config/types.py b/compose/config/types.py index 73bfd4184ce..0ab53c825fd 100644 --- a/compose/config/types.py +++ b/compose/config/types.py @@ -26,3 +26,20 @@ def parse(cls, volume_from_config): source, mode = parts return cls(source, mode) + + +def parse_restart_spec(restart_config): + if not restart_config: + return None + parts = restart_config.split(':') + if len(parts) > 2: + raise ConfigurationError( + "Restart %s has incorrect format, should be " + "mode[:max_retry]" % restart_config) + if len(parts) == 2: + name, max_retry_count = parts + else: + name, = parts + max_retry_count = 0 + + return {'Name': name, 'MaximumRetryCount': int(max_retry_count)} diff --git a/compose/service.py b/compose/service.py index be0502c273e..33d9a7bec89 100644 --- a/compose/service.py +++ b/compose/service.py @@ -648,8 +648,6 @@ def _get_container_host_config(self, override_options, one_off=False): if isinstance(dns_search, six.string_types): dns_search = [dns_search] - restart = parse_restart_spec(options.get('restart', None)) - extra_hosts = build_extra_hosts(options.get('extra_hosts', None)) read_only = options.get('read_only', None) @@ -667,7 +665,7 @@ def _get_container_host_config(self, override_options, one_off=False): devices=devices, dns=dns, dns_search=dns_search, - restart_policy=restart, + restart_policy=options.get('restart'), cap_add=cap_add, cap_drop=cap_drop, mem_limit=options.get('mem_limit'), @@ -1043,24 +1041,6 @@ def build_container_labels(label_options, service_labels, number, config_hash): return labels -# Restart policy - - -def parse_restart_spec(restart_config): - if not restart_config: - return None - parts = restart_config.split(':') - if len(parts) > 2: - raise ConfigError("Restart %s has incorrect format, should be " - "mode[:max_retry]" % restart_config) - if len(parts) == 2: - name, max_retry_count = parts - else: - name, = parts - max_retry_count = 0 - - return {'Name': name, 'MaximumRetryCount': int(max_retry_count)} - # Ulimits diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 34bf93fcbcd..15d8ca07218 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -786,23 +786,21 @@ def test_dns_no_value(self): container = create_and_start_container(service) self.assertIsNone(container.get('HostConfig.Dns')) - def test_dns_single_value(self): - service = self.create_service('web', dns='8.8.8.8') - container = create_and_start_container(service) - self.assertEqual(container.get('HostConfig.Dns'), ['8.8.8.8']) - def test_dns_list(self): service = self.create_service('web', dns=['8.8.8.8', '9.9.9.9']) container = create_and_start_container(service) self.assertEqual(container.get('HostConfig.Dns'), ['8.8.8.8', '9.9.9.9']) def test_restart_always_value(self): - service = self.create_service('web', restart='always') + service = self.create_service('web', restart={'Name': 'always'}) container = create_and_start_container(service) self.assertEqual(container.get('HostConfig.RestartPolicy.Name'), 'always') def test_restart_on_failure_value(self): - service = self.create_service('web', restart='on-failure:5') + service = self.create_service('web', restart={ + 'Name': 'on-failure', + 'MaximumRetryCount': 5 + }) container = create_and_start_container(service) self.assertEqual(container.get('HostConfig.RestartPolicy.Name'), 'on-failure') self.assertEqual(container.get('HostConfig.RestartPolicy.MaximumRetryCount'), 5) @@ -817,17 +815,7 @@ def test_cap_drop_list(self): container = create_and_start_container(service) self.assertEqual(container.get('HostConfig.CapDrop'), ['SYS_ADMIN', 'NET_ADMIN']) - def test_dns_search_no_value(self): - service = self.create_service('web') - container = create_and_start_container(service) - self.assertIsNone(container.get('HostConfig.DnsSearch')) - - def test_dns_search_single_value(self): - service = self.create_service('web', dns_search='example.com') - container = create_and_start_container(service) - self.assertEqual(container.get('HostConfig.DnsSearch'), ['example.com']) - - def test_dns_search_list(self): + def test_dns_search(self): service = self.create_service('web', dns_search=['dc1.example.com', 'dc2.example.com']) container = create_and_start_container(service) self.assertEqual(container.get('HostConfig.DnsSearch'), ['dc1.example.com', 'dc2.example.com']) diff --git a/tests/unit/cli_test.py b/tests/unit/cli_test.py index 5b63d2e84a4..23dc42629c0 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -124,7 +124,7 @@ def test_run_service_with_restart_always(self): mock_project.get_service.return_value = Service( 'service', client=mock_client, - restart='always', + restart={'Name': 'always', 'MaximumRetryCount': 0}, image='someimage') command.run(mock_project, { 'SERVICE': 'service', From efec2aae6c86b6577f30451d54ac7030dbf39b13 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 13 Nov 2015 18:58:24 -0500 Subject: [PATCH 1556/4072] Fixes #2008 - re-use list_or_dict schema for all the types At the same time, moves extra_hosts validation to the config module. Signed-off-by: Daniel Nephin --- compose/config/config.py | 4 ++++ compose/config/fields_schema.json | 31 +++++++++++--------------- compose/config/types.py | 16 ++++++++++++++ compose/config/validation.py | 4 ++-- compose/service.py | 36 +++---------------------------- tests/integration/service_test.py | 33 ---------------------------- tests/unit/config/config_test.py | 25 ++++++++++++++++++++- tests/unit/config/types_test.py | 29 +++++++++++++++++++++++++ 8 files changed, 90 insertions(+), 88 deletions(-) create mode 100644 tests/unit/config/types_test.py diff --git a/compose/config/config.py b/compose/config/config.py index 9b03ea4ffc1..55adcaf28e6 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -14,6 +14,7 @@ from .errors import ComposeFileNotFound from .errors import ConfigurationError from .interpolation import interpolate_environment_variables +from .types import parse_extra_hosts from .types import parse_restart_spec from .types import VolumeFromSpec from .validation import validate_against_fields_schema @@ -379,6 +380,9 @@ def process_service(service_config): if 'labels' in service_dict: service_dict['labels'] = parse_labels(service_dict['labels']) + if 'extra_hosts' in service_dict: + service_dict['extra_hosts'] = parse_extra_hosts(service_dict['extra_hosts']) + # TODO: move to a validate_service() if 'ulimits' in service_dict: validate_ulimits(service_dict['ulimits']) diff --git a/compose/config/fields_schema.json b/compose/config/fields_schema.json index ca3b3a5029e..9cbcfd1b268 100644 --- a/compose/config/fields_schema.json +++ b/compose/config/fields_schema.json @@ -37,22 +37,7 @@ "domainname": {"type": "string"}, "entrypoint": {"$ref": "#/definitions/string_or_list"}, "env_file": {"$ref": "#/definitions/string_or_list"}, - - "environment": { - "oneOf": [ - { - "type": "object", - "patternProperties": { - ".+": { - "type": ["string", "number", "boolean", "null"], - "format": "environment" - } - }, - "additionalProperties": false - }, - {"type": "array", "items": {"type": "string"}, "uniqueItems": true} - ] - }, + "environment": {"$ref": "#/definitions/list_or_dict"}, "expose": { "type": "array", @@ -165,10 +150,18 @@ "list_or_dict": { "oneOf": [ - {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, - {"type": "object"} + { + "type": "object", + "patternProperties": { + ".+": { + "type": ["string", "number", "boolean", "null"], + "format": "bool-value-in-mapping" + } + }, + "additionalProperties": false + }, + {"type": "array", "items": {"type": "string"}, "uniqueItems": true} ] } - } } diff --git a/compose/config/types.py b/compose/config/types.py index 0ab53c825fd..b6add0894af 100644 --- a/compose/config/types.py +++ b/compose/config/types.py @@ -43,3 +43,19 @@ def parse_restart_spec(restart_config): max_retry_count = 0 return {'Name': name, 'MaximumRetryCount': int(max_retry_count)} + + +def parse_extra_hosts(extra_hosts_config): + if not extra_hosts_config: + return {} + + if isinstance(extra_hosts_config, dict): + return dict(extra_hosts_config) + + if isinstance(extra_hosts_config, list): + extra_hosts_dict = {} + for extra_hosts_line in extra_hosts_config: + # TODO: validate string contains ':' ? + host, ip = extra_hosts_line.split(':') + extra_hosts_dict[host.strip()] = ip.strip() + return extra_hosts_dict diff --git a/compose/config/validation.py b/compose/config/validation.py index 38866b0f4fe..38020366d75 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -49,7 +49,7 @@ def format_ports(instance): return True -@FormatChecker.cls_checks(format="environment") +@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. @@ -273,7 +273,7 @@ def validate_against_fields_schema(config, filename): _validate_against_schema( config, "fields_schema.json", - format_checker=["ports", "environment"], + format_checker=["ports", "bool-value-in-mapping"], filename=filename) diff --git a/compose/service.py b/compose/service.py index 33d9a7bec89..2bb0030f0b4 100644 --- a/compose/service.py +++ b/compose/service.py @@ -640,6 +640,7 @@ def _get_container_host_config(self, override_options, one_off=False): pid = options.get('pid', None) security_opt = options.get('security_opt', None) + # TODO: these options are already normalized by config dns = options.get('dns', None) if isinstance(dns, six.string_types): dns = [dns] @@ -648,9 +649,6 @@ def _get_container_host_config(self, override_options, one_off=False): if isinstance(dns_search, six.string_types): dns_search = [dns_search] - extra_hosts = build_extra_hosts(options.get('extra_hosts', None)) - read_only = options.get('read_only', None) - devices = options.get('devices', None) cgroup_parent = options.get('cgroup_parent', None) ulimits = build_ulimits(options.get('ulimits', None)) @@ -672,8 +670,8 @@ def _get_container_host_config(self, override_options, one_off=False): memswap_limit=options.get('memswap_limit'), ulimits=ulimits, log_config=log_config, - extra_hosts=extra_hosts, - read_only=read_only, + extra_hosts=options.get('extra_hosts'), + read_only=options.get('read_only'), pid_mode=pid, security_opt=security_opt, ipc_mode=options.get('ipc'), @@ -1057,31 +1055,3 @@ def build_ulimits(ulimit_config): ulimits.append(ulimit_dict) return ulimits - - -# Extra hosts - - -def build_extra_hosts(extra_hosts_config): - if not extra_hosts_config: - return {} - - if isinstance(extra_hosts_config, list): - extra_hosts_dict = {} - for extra_hosts_line in extra_hosts_config: - if not isinstance(extra_hosts_line, six.string_types): - raise ConfigError( - "extra_hosts_config \"%s\" must be either a list of strings or a string->string mapping," % - extra_hosts_config - ) - host, ip = extra_hosts_line.split(':') - extra_hosts_dict.update({host.strip(): ip.strip()}) - extra_hosts_config = extra_hosts_dict - - if isinstance(extra_hosts_config, dict): - return extra_hosts_config - - raise ConfigError( - "extra_hosts_config \"%s\" must be either a list of strings or a string->string mapping," % - extra_hosts_config - ) diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 15d8ca07218..27a2900502d 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -22,8 +22,6 @@ from compose.const import LABEL_SERVICE from compose.const import LABEL_VERSION from compose.container import Container -from compose.service import build_extra_hosts -from compose.service import ConfigError from compose.service import ConvergencePlan from compose.service import ConvergenceStrategy from compose.service import Net @@ -139,37 +137,6 @@ def test_create_container_with_cpu_shares(self): container.start() self.assertEqual(container.get('HostConfig.CpuShares'), 73) - def test_build_extra_hosts(self): - # string - self.assertRaises(ConfigError, lambda: build_extra_hosts("www.example.com: 192.168.0.17")) - - # list of strings - self.assertEqual(build_extra_hosts( - ["www.example.com:192.168.0.17"]), - {'www.example.com': '192.168.0.17'}) - self.assertEqual(build_extra_hosts( - ["www.example.com: 192.168.0.17"]), - {'www.example.com': '192.168.0.17'}) - self.assertEqual(build_extra_hosts( - ["www.example.com: 192.168.0.17", - "static.example.com:192.168.0.19", - "api.example.com: 192.168.0.18"]), - {'www.example.com': '192.168.0.17', - 'static.example.com': '192.168.0.19', - 'api.example.com': '192.168.0.18'}) - - # list of dictionaries - self.assertRaises(ConfigError, lambda: build_extra_hosts( - [{'www.example.com': '192.168.0.17'}, - {'api.example.com': '192.168.0.18'}])) - - # dictionaries - self.assertEqual(build_extra_hosts( - {'www.example.com': '192.168.0.17', - 'api.example.com': '192.168.0.18'}), - {'www.example.com': '192.168.0.17', - 'api.example.com': '192.168.0.18'}) - 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) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index c69e34306c5..f923fb370f3 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -32,7 +32,7 @@ def service_sort(services): return sorted(services, key=itemgetter('name')) -def build_config_details(contents, working_dir, filename): +def build_config_details(contents, working_dir='working_dir', filename='filename.yml'): return config.ConfigDetails( working_dir, [config.ConfigFile(filename, contents)]) @@ -512,6 +512,29 @@ def test_load_yaml_with_yaml_error(self): assert 'line 3, column 32' in exc.exconly() + def test_validate_extra_hosts_invalid(self): + with pytest.raises(ConfigurationError) as exc: + config.load(build_config_details({ + 'web': { + 'image': 'alpine', + 'extra_hosts': "www.example.com: 192.168.0.17", + } + })) + assert "'extra_hosts' contains an invalid type" in exc.exconly() + + def test_validate_extra_hosts_invalid_list(self): + with pytest.raises(ConfigurationError) as exc: + config.load(build_config_details({ + 'web': { + 'image': 'alpine', + 'extra_hosts': [ + {'www.example.com': '192.168.0.17'}, + {'api.example.com': '192.168.0.18'} + ], + } + })) + assert "which is an invalid type" in exc.exconly() + class InterpolationTest(unittest.TestCase): @mock.patch.dict(os.environ) diff --git a/tests/unit/config/types_test.py b/tests/unit/config/types_test.py new file mode 100644 index 00000000000..25692ca3742 --- /dev/null +++ b/tests/unit/config/types_test.py @@ -0,0 +1,29 @@ +from compose.config.types import parse_extra_hosts + + +def test_parse_extra_hosts_list(): + expected = {'www.example.com': '192.168.0.17'} + assert parse_extra_hosts(["www.example.com:192.168.0.17"]) == expected + + expected = {'www.example.com': '192.168.0.17'} + assert parse_extra_hosts(["www.example.com: 192.168.0.17"]) == expected + + assert parse_extra_hosts([ + "www.example.com: 192.168.0.17", + "static.example.com:192.168.0.19", + "api.example.com: 192.168.0.18" + ]) == { + 'www.example.com': '192.168.0.17', + 'static.example.com': '192.168.0.19', + 'api.example.com': '192.168.0.18' + } + + +def test_parse_extra_hosts_dict(): + assert parse_extra_hosts({ + 'www.example.com': '192.168.0.17', + 'api.example.com': '192.168.0.18' + }) == { + 'www.example.com': '192.168.0.17', + 'api.example.com': '192.168.0.18' + } From dac75b07dc16d9b6f08654301dd9ddd5d0b48393 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 13 Nov 2015 19:40:10 -0500 Subject: [PATCH 1557/4072] Move volume parsing to config.types module This removes the last of the old service.ConfigError Signed-off-by: Daniel Nephin --- compose/cli/command.py | 17 ++---- compose/config/config.py | 5 ++ compose/config/types.py | 59 +++++++++++++++++++ compose/service.py | 69 ++--------------------- tests/acceptance/cli_test.py | 2 +- tests/integration/project_test.py | 11 ++-- tests/integration/resilience_test.py | 6 +- tests/integration/service_test.py | 43 ++++++-------- tests/integration/testcases.py | 11 ++-- tests/unit/config/config_test.py | 38 +++++++------ tests/unit/config/types_test.py | 37 ++++++++++++ tests/unit/service_test.py | 84 +++++++--------------------- 12 files changed, 186 insertions(+), 196 deletions(-) diff --git a/compose/cli/command.py b/compose/cli/command.py index 6094b5305b5..157e00161b8 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -14,7 +14,6 @@ from . import verbose_proxy from .. import config from ..project import Project -from ..service import ConfigError from .docker_client import docker_client from .utils import call_silently from .utils import get_version_info @@ -84,16 +83,12 @@ def get_project(base_dir, config_path=None, project_name=None, verbose=False, config_details = config.find(base_dir, config_path) api_version = '1.21' if use_networking else None - try: - return Project.from_dicts( - get_project_name(config_details.working_dir, project_name), - config.load(config_details), - get_client(verbose=verbose, version=api_version), - use_networking=use_networking, - network_driver=network_driver, - ) - except ConfigError as e: - raise errors.UserError(six.text_type(e)) + return Project.from_dicts( + get_project_name(config_details.working_dir, project_name), + config.load(config_details), + get_client(verbose=verbose, version=api_version), + use_networking=use_networking, + network_driver=network_driver) def get_project_name(working_dir, project_name=None): diff --git a/compose/config/config.py b/compose/config/config.py index 55adcaf28e6..5b1de5efcbb 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -17,6 +17,7 @@ from .types import parse_extra_hosts from .types import parse_restart_spec 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_extends_file_path @@ -397,6 +398,10 @@ def finalize_service(service_config): service_dict['volumes_from'] = [ VolumeFromSpec.parse(vf) for vf in service_dict['volumes_from']] + if 'volumes' in service_dict: + service_dict['volumes'] = [ + VolumeSpec.parse(v) for v in service_dict['volumes']] + if 'restart' in service_dict: service_dict['restart'] = parse_restart_spec(service_dict['restart']) diff --git a/compose/config/types.py b/compose/config/types.py index b6add0894af..cec1f6cfdff 100644 --- a/compose/config/types.py +++ b/compose/config/types.py @@ -4,9 +4,11 @@ from __future__ import absolute_import from __future__ import unicode_literals +import os from collections import namedtuple from compose.config.errors import ConfigurationError +from compose.const import IS_WINDOWS_PLATFORM class VolumeFromSpec(namedtuple('_VolumeFromSpec', 'source mode')): @@ -59,3 +61,60 @@ def parse_extra_hosts(extra_hosts_config): host, ip = extra_hosts_line.split(':') extra_hosts_dict[host.strip()] = ip.strip() return extra_hosts_dict + + +def normalize_paths_for_engine(external_path, internal_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 + + if external_path: + drive, tail = os.path.splitdrive(external_path) + + if drive: + external_path = '/' + drive.lower().rstrip(':') + tail + + external_path = external_path.replace('\\', '/') + + return external_path, internal_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(':') + + if len(parts) > 3: + raise ConfigurationError( + "Volume %s has incorrect format, should be " + "external:internal[:mode]" % volume_config) + + if len(parts) == 1: + external, internal = normalize_paths_for_engine( + None, + os.path.normpath(parts[0])) + else: + external, internal = normalize_paths_for_engine( + os.path.normpath(parts[0]), + os.path.normpath(parts[1])) + + mode = 'rw' + if len(parts) == 3: + mode = parts[2] + + return cls(external, internal, mode) diff --git a/compose/service.py b/compose/service.py index 2bb0030f0b4..6340d074eab 100644 --- a/compose/service.py +++ b/compose/service.py @@ -2,7 +2,6 @@ from __future__ import unicode_literals import logging -import os import re import sys from collections import namedtuple @@ -18,8 +17,8 @@ from . import __version__ 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 @@ -70,11 +69,6 @@ def __init__(self, service, reason): self.reason = reason -# TODO: remove -class ConfigError(ValueError): - pass - - class NeedsBuildError(Exception): def __init__(self, service): self.service = service @@ -84,9 +78,6 @@ class NoSuchImageError(Exception): pass -VolumeSpec = namedtuple('VolumeSpec', 'external internal mode') - - ServiceName = namedtuple('ServiceName', 'project service number') @@ -598,8 +589,7 @@ def _get_container_create_options( if 'volumes' in container_options: container_options['volumes'] = dict( - (parse_volume_spec(v).internal, {}) - for v in container_options['volumes']) + (v.internal, {}) for v in container_options['volumes']) container_options['environment'] = merge_environment( self.options.get('environment'), @@ -884,11 +874,10 @@ def parse_repository_tag(repo_path): # Volumes -def merge_volume_bindings(volumes_option, previous_container): +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. """ - volumes = [parse_volume_spec(volume) for volume in volumes_option or []] volume_bindings = dict( build_volume_binding(volume) for volume in volumes @@ -910,7 +899,7 @@ def get_container_data_volumes(container, volumes_option): volumes = [] container_volumes = container.get('Volumes') or {} image_volumes = [ - parse_volume_spec(volume) + VolumeSpec.parse(volume) for volume in container.image_config['ContainerConfig'].get('Volumes') or {} ] @@ -957,56 +946,6 @@ def build_volume_binding(volume_spec): return volume_spec.internal, "{}:{}:{}".format(*volume_spec) -def normalize_paths_for_engine(external_path, internal_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 - - if external_path: - drive, tail = os.path.splitdrive(external_path) - - if drive: - external_path = '/' + drive.lower().rstrip(':') + tail - - external_path = external_path.replace('\\', '/') - - return external_path, internal_path.replace('\\', '/') - - -def parse_volume_spec(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(':') - - if len(parts) > 3: - raise ConfigError("Volume %s has incorrect format, should be " - "external:internal[:mode]" % volume_config) - - if len(parts) == 1: - external, internal = normalize_paths_for_engine(None, os.path.normpath(parts[0])) - else: - external, internal = normalize_paths_for_engine(os.path.normpath(parts[0]), os.path.normpath(parts[1])) - - mode = 'rw' - if len(parts) == 3: - mode = parts[2] - - return VolumeSpec(external, internal, mode) - - def build_volume_from(volume_from_spec): """ volume_from can be either a service or a container. We want to return the diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 7ca6e8194b3..73a1d66ce08 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -37,7 +37,7 @@ def start_process(base_dir, options): def wait_on_process(proc, returncode=0): stdout, stderr = proc.communicate() if proc.returncode != returncode: - print(stderr) + print(stderr.decode('utf-8')) assert proc.returncode == returncode return ProcessResult(stdout.decode('utf-8'), stderr.decode('utf-8')) diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index d65d7ef0cfa..443ff9783a2 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -4,6 +4,7 @@ from compose.cli.docker_client import docker_client from compose.config import config from compose.config.types import VolumeFromSpec +from compose.config.types import VolumeSpec from compose.const import LABEL_PROJECT from compose.container import Container from compose.project import Project @@ -214,7 +215,7 @@ def test_start_pause_unpause_stop_kill_remove(self): def test_project_up(self): web = self.create_service('web') - db = self.create_service('db', volumes=['/var/db']) + db = self.create_service('db', volumes=[VolumeSpec.parse('/var/db')]) project = Project('composetest', [web, db], self.client) project.start() self.assertEqual(len(project.containers()), 0) @@ -238,7 +239,7 @@ def test_project_up_starts_uncreated_services(self): def test_recreate_preserves_volumes(self): web = self.create_service('web') - db = self.create_service('db', volumes=['/etc']) + db = self.create_service('db', volumes=[VolumeSpec.parse('/etc')]) project = Project('composetest', [web, db], self.client) project.start() self.assertEqual(len(project.containers()), 0) @@ -257,7 +258,7 @@ def test_recreate_preserves_volumes(self): def test_project_up_with_no_recreate_running(self): web = self.create_service('web') - db = self.create_service('db', volumes=['/var/db']) + db = self.create_service('db', volumes=[VolumeSpec.parse('/var/db')]) project = Project('composetest', [web, db], self.client) project.start() self.assertEqual(len(project.containers()), 0) @@ -277,7 +278,7 @@ def test_project_up_with_no_recreate_running(self): def test_project_up_with_no_recreate_stopped(self): web = self.create_service('web') - db = self.create_service('db', volumes=['/var/db']) + db = self.create_service('db', volumes=[VolumeSpec.parse('/var/db')]) project = Project('composetest', [web, db], self.client) project.start() self.assertEqual(len(project.containers()), 0) @@ -316,7 +317,7 @@ def test_project_up_without_all_services(self): def test_project_up_starts_links(self): console = self.create_service('console') - db = self.create_service('db', volumes=['/var/db']) + db = self.create_service('db', volumes=[VolumeSpec.parse('/var/db')]) web = self.create_service('web', links=[(db, 'db')]) project = Project('composetest', [web, db, console], self.client) diff --git a/tests/integration/resilience_test.py b/tests/integration/resilience_test.py index 53aedfecf2c..7f75356d829 100644 --- a/tests/integration/resilience_test.py +++ b/tests/integration/resilience_test.py @@ -3,13 +3,17 @@ from .. import mock from .testcases import DockerClientTestCase +from compose.config.types import VolumeSpec from compose.project import Project from compose.service import ConvergenceStrategy class ResilienceTest(DockerClientTestCase): def setUp(self): - self.db = self.create_service('db', volumes=['/var/db'], command='top') + self.db = self.create_service( + 'db', + volumes=[VolumeSpec.parse('/var/db')], + command='top') self.project = Project('composetest', [self.db], self.client) container = self.db.create_container() diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 27a2900502d..5dd3d2e68a7 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -15,6 +15,7 @@ from .testcases import pull_busybox from compose import __version__ from compose.config.types import VolumeFromSpec +from compose.config.types import VolumeSpec from compose.const import LABEL_CONFIG_HASH from compose.const import LABEL_CONTAINER_NUMBER from compose.const import LABEL_ONE_OFF @@ -120,7 +121,7 @@ def test_create_container_with_one_off_when_existing_container_is_running(self): self.assertEqual(container.name, 'composetest_db_run_1') def test_create_container_with_unspecified_volume(self): - service = self.create_service('db', volumes=['/var/db']) + service = self.create_service('db', volumes=[VolumeSpec.parse('/var/db')]) container = service.create_container() container.start() self.assertIn('/var/db', container.get('Volumes')) @@ -182,7 +183,9 @@ def test_create_container_with_specified_volume(self): host_path = '/tmp/host-path' container_path = '/container-path' - service = self.create_service('db', volumes=['%s:%s' % (host_path, container_path)]) + service = self.create_service( + 'db', + volumes=[VolumeSpec(host_path, container_path, 'rw')]) container = service.create_container() container.start() @@ -195,11 +198,10 @@ def test_create_container_with_specified_volume(self): msg=("Last component differs: %s, %s" % (actual_host_path, host_path))) def test_recreate_preserves_volume_with_trailing_slash(self): - """ - When the Compose file specifies a trailing slash in the container path, make + """When the Compose file specifies a trailing slash in the container path, make sure we copy the volume over when recreating. """ - service = self.create_service('data', volumes=['/data/']) + service = self.create_service('data', volumes=[VolumeSpec.parse('/data/')]) old_container = create_and_start_container(service) volume_path = old_container.get('Volumes')['/data'] @@ -213,7 +215,7 @@ def test_duplicate_volume_trailing_slash(self): """ host_path = '/tmp/data' container_path = '/data' - volumes = ['{}:{}/'.format(host_path, container_path)] + volumes = [VolumeSpec.parse('{}:{}/'.format(host_path, container_path))] tmp_container = self.client.create_container( 'busybox', 'true', @@ -267,7 +269,7 @@ def test_execute_convergence_plan_recreate(self): service = self.create_service( 'db', environment={'FOO': '1'}, - volumes=['/etc'], + volumes=[VolumeSpec.parse('/etc')], entrypoint=['top'], command=['-d', '1'] ) @@ -305,7 +307,7 @@ def test_execute_convergence_plan_when_containers_are_stopped(self): service = self.create_service( 'db', environment={'FOO': '1'}, - volumes=['/var/db'], + volumes=[VolumeSpec.parse('/var/db')], entrypoint=['top'], command=['-d', '1'] ) @@ -343,10 +345,8 @@ def test_execute_convergence_plan_with_image_declared_volume(self): self.assertEqual(new_container.get('Volumes')['/data'], volume_path) def test_execute_convergence_plan_when_image_volume_masks_config(self): - service = Service( - project='composetest', - name='db', - client=self.client, + service = self.create_service( + 'db', build='tests/fixtures/dockerfile-with-volume', ) @@ -354,7 +354,7 @@ def test_execute_convergence_plan_when_image_volume_masks_config(self): self.assertEqual(list(old_container.get('Volumes').keys()), ['/data']) volume_path = old_container.get('Volumes')['/data'] - service.options['volumes'] = ['/tmp:/data'] + service.options['volumes'] = [VolumeSpec.parse('/tmp:/data')] with mock.patch('compose.service.log') as mock_log: new_container, = service.execute_convergence_plan( @@ -864,22 +864,11 @@ def test_labels(self): for pair in expected.items(): self.assertIn(pair, labels) - service.kill() - remove_stopped(service) - - labels_list = ["%s=%s" % pair for pair in labels_dict.items()] - - service = self.create_service('web', labels=labels_list) - labels = create_and_start_container(service).labels.items() - for pair in expected.items(): - self.assertIn(pair, labels) - def test_empty_labels(self): - labels_list = ['foo', 'bar'] - - service = self.create_service('web', labels=labels_list) + labels_dict = {'foo': '', 'bar': ''} + service = self.create_service('web', labels=labels_dict) labels = create_and_start_container(service).labels.items() - for name in labels_list: + for name in labels_dict: self.assertIn((name, ''), labels) def test_custom_container_name(self): diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index de2d1a70156..f5de50ee18c 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -7,9 +7,7 @@ from .. import unittest from compose.cli.docker_client import docker_client -from compose.config.config import process_service from compose.config.config import resolve_environment -from compose.config.config import ServiceConfig from compose.const import LABEL_PROJECT from compose.progress_stream import stream_output from compose.service import Service @@ -45,13 +43,12 @@ def create_service(self, name, **kwargs): kwargs['command'] = ["top"] service_config = ServiceConfig('.', None, name, kwargs) - options = process_service(service_config) - options['environment'] = resolve_environment( - service_config._replace(config=options)) - labels = options.setdefault('labels', {}) + kwargs['environment'] = resolve_environment(service_config) + + labels = dict(kwargs.setdefault('labels', {})) labels['com.docker.compose.test-name'] = self.id() - return Service(name, client=self.client, project='composetest', **options) + return Service(name, client=self.client, project='composetest', **kwargs) def check_build(self, *args, **kwargs): kwargs.setdefault('rm', True) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index f923fb370f3..b2a4cd68ffc 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -11,6 +11,7 @@ from compose.config import config 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 @@ -147,7 +148,7 @@ def test_load_with_multiple_files(self): 'name': 'web', 'build': '/', 'links': ['db'], - 'volumes': ['/home/user/project:/code'], + 'volumes': [VolumeSpec.parse('/home/user/project:/code')], }, { 'name': 'db', @@ -211,7 +212,7 @@ def test_load_with_multiple_files_and_extends_in_override_file(self): { 'name': 'web', 'image': 'example/web', - 'volumes': ['/home/user/project:/code'], + 'volumes': [VolumeSpec.parse('/home/user/project:/code')], 'labels': {'label': 'one'}, }, ] @@ -626,14 +627,11 @@ def test_no_binding(self): @mock.patch.dict(os.environ) def test_volume_binding_with_environment_variable(self): os.environ['VOLUME_PATH'] = '/host/path' - d = config.load( - build_config_details( - {'foo': {'build': '.', 'volumes': ['${VOLUME_PATH}:/container/path']}}, - '.', - None, - ) - )[0] - self.assertEqual(d['volumes'], ['/host/path:/container/path']) + d = config.load(build_config_details( + {'foo': {'build': '.', 'volumes': ['${VOLUME_PATH}:/container/path']}}, + '.', + ))[0] + self.assertEqual(d['volumes'], [VolumeSpec.parse('/host/path:/container/path')]) @pytest.mark.skipif(IS_WINDOWS_PLATFORM, reason='posix paths') @mock.patch.dict(os.environ) @@ -1031,19 +1029,21 @@ def test_resolve_path(self): build_config_details( {'foo': {'build': '.', 'volumes': ['$HOSTENV:$CONTAINERENV']}}, "tests/fixtures/env", - None, ) )[0] - self.assertEqual(set(service_dict['volumes']), set(['/tmp:/host/tmp'])) + self.assertEqual( + set(service_dict['volumes']), + set([VolumeSpec.parse('/tmp:/host/tmp')])) service_dict = config.load( build_config_details( {'foo': {'build': '.', 'volumes': ['/opt${HOSTENV}:/opt${CONTAINERENV}']}}, "tests/fixtures/env", - None, ) )[0] - self.assertEqual(set(service_dict['volumes']), set(['/opt/tmp:/opt/host/tmp'])) + self.assertEqual( + set(service_dict['volumes']), + set([VolumeSpec.parse('/opt/tmp:/opt/host/tmp')])) def load_from_filename(filename): @@ -1290,8 +1290,14 @@ def test_volume_path(self): dicts = load_from_filename('tests/fixtures/volume-path/docker-compose.yml') paths = [ - '%s:/foo' % os.path.abspath('tests/fixtures/volume-path/common/foo'), - '%s:/bar' % os.path.abspath('tests/fixtures/volume-path/bar'), + VolumeSpec( + os.path.abspath('tests/fixtures/volume-path/common/foo'), + '/foo', + 'rw'), + VolumeSpec( + os.path.abspath('tests/fixtures/volume-path/bar'), + '/bar', + 'rw') ] self.assertEqual(set(dicts[0]['volumes']), set(paths)) diff --git a/tests/unit/config/types_test.py b/tests/unit/config/types_test.py index 25692ca3742..4df665485e0 100644 --- a/tests/unit/config/types_test.py +++ b/tests/unit/config/types_test.py @@ -1,4 +1,9 @@ +import pytest + +from compose.config.errors import ConfigurationError from compose.config.types import parse_extra_hosts +from compose.config.types import VolumeSpec +from compose.const import IS_WINDOWS_PLATFORM def test_parse_extra_hosts_list(): @@ -27,3 +32,35 @@ def test_parse_extra_hosts_dict(): 'www.example.com': '192.168.0.17', 'api.example.com': '192.168.0.18' } + + +class TestVolumeSpec(object): + + def test_parse_volume_spec_only_one_path(self): + spec = VolumeSpec.parse('/the/volume') + assert spec == (None, '/the/volume', 'rw') + + def test_parse_volume_spec_internal_and_external(self): + spec = VolumeSpec.parse('external:interval') + assert spec == ('external', 'interval', 'rw') + + def test_parse_volume_spec_with_mode(self): + spec = VolumeSpec.parse('external:interval:ro') + assert spec == ('external', 'interval', 'ro') + + spec = VolumeSpec.parse('external:interval:z') + assert spec == ('external', 'interval', 'z') + + def test_parse_volume_spec_too_many_parts(self): + with pytest.raises(ConfigurationError) as exc: + 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) == ( + "/c/Users/me/Documents/shiny/config", + "/opt/shiny/config", + "ro" + ) diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index efcc58e26ba..a439f0da9e6 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -2,12 +2,11 @@ from __future__ import unicode_literals import docker -import pytest from .. import mock from .. import unittest from compose.config.types import VolumeFromSpec -from compose.const import IS_WINDOWS_PLATFORM +from compose.config.types import VolumeSpec from compose.const import LABEL_CONFIG_HASH from compose.const import LABEL_ONE_OFF from compose.const import LABEL_PROJECT @@ -15,7 +14,6 @@ from compose.container import Container from compose.service import build_ulimits from compose.service import build_volume_binding -from compose.service import ConfigError from compose.service import ContainerNet from compose.service import get_container_data_volumes from compose.service import merge_volume_bindings @@ -23,7 +21,6 @@ from compose.service import Net from compose.service import NoSuchImageError from compose.service import parse_repository_tag -from compose.service import parse_volume_spec from compose.service import Service from compose.service import ServiceNet from compose.service import VolumeFromSpec @@ -585,46 +582,12 @@ class ServiceVolumesTest(unittest.TestCase): def setUp(self): self.mock_client = mock.create_autospec(docker.Client) - def test_parse_volume_spec_only_one_path(self): - spec = parse_volume_spec('/the/volume') - self.assertEqual(spec, (None, '/the/volume', 'rw')) - - def test_parse_volume_spec_internal_and_external(self): - spec = parse_volume_spec('external:interval') - self.assertEqual(spec, ('external', 'interval', 'rw')) - - def test_parse_volume_spec_with_mode(self): - spec = parse_volume_spec('external:interval:ro') - self.assertEqual(spec, ('external', 'interval', 'ro')) - - spec = parse_volume_spec('external:interval:z') - self.assertEqual(spec, ('external', 'interval', 'z')) - - def test_parse_volume_spec_too_many_parts(self): - with self.assertRaises(ConfigError): - parse_volume_spec('one:two:three:four') - - @pytest.mark.xfail((not IS_WINDOWS_PLATFORM), reason='does not have a drive') - def test_parse_volume_windows_absolute_path(self): - windows_absolute_path = "c:\\Users\\me\\Documents\\shiny\\config:\\opt\\shiny\\config:ro" - - spec = parse_volume_spec(windows_absolute_path) - - self.assertEqual( - spec, - ( - "/c/Users/me/Documents/shiny/config", - "/opt/shiny/config", - "ro" - ) - ) - def test_build_volume_binding(self): - binding = build_volume_binding(parse_volume_spec('/outside:/inside')) - self.assertEqual(binding, ('/inside', '/outside:/inside:rw')) + binding = build_volume_binding(VolumeSpec.parse('/outside:/inside')) + assert binding == ('/inside', '/outside:/inside:rw') def test_get_container_data_volumes(self): - options = [parse_volume_spec(v) for v in [ + options = [VolumeSpec.parse(v) for v in [ '/host/volume:/host/volume:ro', '/new/volume', '/existing/volume', @@ -648,19 +611,19 @@ def test_get_container_data_volumes(self): }, has_been_inspected=True) expected = [ - parse_volume_spec('/var/lib/docker/aaaaaaaa:/existing/volume:rw'), - parse_volume_spec('/var/lib/docker/cccccccc:/mnt/image/data:rw'), + VolumeSpec.parse('/var/lib/docker/aaaaaaaa:/existing/volume:rw'), + VolumeSpec.parse('/var/lib/docker/cccccccc:/mnt/image/data:rw'), ] volumes = get_container_data_volumes(container, options) - self.assertEqual(sorted(volumes), sorted(expected)) + assert sorted(volumes) == sorted(expected) def test_merge_volume_bindings(self): options = [ - '/host/volume:/host/volume:ro', - '/host/rw/volume:/host/rw/volume', - '/new/volume', - '/existing/volume', + VolumeSpec.parse('/host/volume:/host/volume:ro'), + VolumeSpec.parse('/host/rw/volume:/host/rw/volume'), + VolumeSpec.parse('/new/volume'), + VolumeSpec.parse('/existing/volume'), ] self.mock_client.inspect_image.return_value = { @@ -686,8 +649,8 @@ def test_mount_same_host_path_to_two_volumes(self): 'web', image='busybox', volumes=[ - '/host/path:/data1', - '/host/path:/data2', + VolumeSpec.parse('/host/path:/data1'), + VolumeSpec.parse('/host/path:/data2'), ], client=self.mock_client, ) @@ -716,7 +679,7 @@ def test_different_host_path_in_container_json(self): service = Service( 'web', image='busybox', - volumes=['/host/path:/data'], + volumes=[VolumeSpec.parse('/host/path:/data')], client=self.mock_client, ) @@ -784,22 +747,17 @@ def test_warn_on_masked_no_warning_with_same_path(self): def test_create_with_special_volume_mode(self): self.mock_client.inspect_image.return_value = {'Id': 'imageid'} - create_calls = [] - - def create_container(*args, **kwargs): - create_calls.append((args, kwargs)) - return {'Id': 'containerid'} - - self.mock_client.create_container = create_container - - volumes = ['/tmp:/foo:z'] + self.mock_client.create_container.return_value = {'Id': 'containerid'} + volume = '/tmp:/foo:z' Service( 'web', client=self.mock_client, image='busybox', - volumes=volumes, + volumes=[VolumeSpec.parse(volume)], ).create_container() - self.assertEqual(len(create_calls), 1) - self.assertEqual(self.mock_client.create_host_config.call_args[1]['binds'], volumes) + assert self.mock_client.create_container.call_count == 1 + self.assertEqual( + self.mock_client.create_host_config.call_args[1]['binds'], + [volume]) From effa9834a5722d2f5f5738087f0207c1eca9fd0b Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 13 Nov 2015 19:49:14 -0500 Subject: [PATCH 1558/4072] Remove unnecessary intermediate variables in get_container_host_config. Signed-off-by: Daniel Nephin --- compose/service.py | 44 ++++++++++++-------------------------------- 1 file changed, 12 insertions(+), 32 deletions(-) diff --git a/compose/service.py b/compose/service.py index 6340d074eab..08d563e9303 100644 --- a/compose/service.py +++ b/compose/service.py @@ -496,7 +496,7 @@ 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/11943 + # TODO: this would benefit from github.com/docker/docker/pull/14699 # to remove the need to inspect every container def _next_container_number(self, one_off=False): containers = filter(None, [ @@ -618,54 +618,34 @@ def _get_container_create_options( def _get_container_host_config(self, override_options, one_off=False): options = dict(self.options, **override_options) - port_bindings = build_port_bindings(options.get('ports') or []) - privileged = options.get('privileged', False) - cap_add = options.get('cap_add', None) - cap_drop = options.get('cap_drop', None) log_config = LogConfig( type=options.get('log_driver', ""), config=options.get('log_opt', None) ) - pid = options.get('pid', None) - security_opt = options.get('security_opt', None) - - # TODO: these options are already normalized by config - dns = options.get('dns', None) - if isinstance(dns, six.string_types): - dns = [dns] - - dns_search = options.get('dns_search', None) - if isinstance(dns_search, six.string_types): - dns_search = [dns_search] - - devices = options.get('devices', None) - cgroup_parent = options.get('cgroup_parent', None) - ulimits = build_ulimits(options.get('ulimits', None)) - return self.client.create_host_config( links=self._get_links(link_to_self=one_off), - port_bindings=port_bindings, + port_bindings=build_port_bindings(options.get('ports') or []), binds=options.get('binds'), volumes_from=self._get_volumes_from(), - privileged=privileged, + privileged=options.get('privileged', False), network_mode=self.net.mode, - devices=devices, - dns=dns, - dns_search=dns_search, + devices=options.get('devices'), + dns=options.get('dns'), + dns_search=options.get('dns_search'), restart_policy=options.get('restart'), - cap_add=cap_add, - cap_drop=cap_drop, + cap_add=options.get('cap_add'), + cap_drop=options.get('cap_drop'), mem_limit=options.get('mem_limit'), memswap_limit=options.get('memswap_limit'), - ulimits=ulimits, + ulimits=build_ulimits(options.get('ulimits')), log_config=log_config, extra_hosts=options.get('extra_hosts'), read_only=options.get('read_only'), - pid_mode=pid, - security_opt=security_opt, + pid_mode=options.get('pid'), + security_opt=options.get('security_opt'), ipc_mode=options.get('ipc'), - cgroup_parent=cgroup_parent + cgroup_parent=options.get('cgroup_parent'), ) def build(self, no_cache=False, pull=False, force_rm=False): From 533f33271a9be73546673aa9aacd823ca8ea9c38 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 17 Nov 2015 13:35:28 -0500 Subject: [PATCH 1559/4072] Move service sorting to config package. Signed-off-by: Daniel Nephin --- compose/config/__init__.py | 1 - compose/config/config.py | 17 ++---- compose/config/errors.py | 4 ++ compose/config/sort_services.py | 55 +++++++++++++++++++ compose/project.py | 51 +---------------- tests/integration/testcases.py | 1 + tests/unit/config/config_test.py | 23 +++++++- .../sort_services_test.py} | 6 +- tests/unit/project_test.py | 23 -------- tests/unit/service_test.py | 2 - 10 files changed, 91 insertions(+), 92 deletions(-) create mode 100644 compose/config/sort_services.py rename tests/unit/{sort_service_test.py => config/sort_services_test.py} (98%) diff --git a/compose/config/__init__.py b/compose/config/__init__.py index ec607e087ec..6fe9ff9fb6a 100644 --- a/compose/config/__init__.py +++ b/compose/config/__init__.py @@ -2,7 +2,6 @@ from .config import ConfigurationError from .config import DOCKER_CONFIG_KEYS from .config import find -from .config import get_service_name_from_net from .config import load from .config import merge_environment from .config import parse_environment diff --git a/compose/config/config.py b/compose/config/config.py index 5b1de5efcbb..9d438ca127a 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -14,6 +14,8 @@ from .errors import ComposeFileNotFound from .errors import ConfigurationError from .interpolation import interpolate_environment_variables +from .sort_services import get_service_name_from_net +from .sort_services import sort_service_dicts from .types import parse_extra_hosts from .types import parse_restart_spec from .types import VolumeFromSpec @@ -214,10 +216,10 @@ def build_service(filename, service_name, service_dict): return service_dict def build_services(config_file): - return [ + return sort_service_dicts([ build_service(config_file.filename, name, service_dict) for name, service_dict in config_file.config.items() - ] + ]) def merge_services(base, override): all_service_names = set(base) | set(override) @@ -638,17 +640,6 @@ def to_list(value): return value -def get_service_name_from_net(net_config): - if not net_config: - return - - if not net_config.startswith('container:'): - return - - _, net_name = net_config.split(':', 1) - return net_name - - def load_yaml(filename): try: with open(filename, 'r') as fh: diff --git a/compose/config/errors.py b/compose/config/errors.py index 037b7ec84d7..6d6a69df9a7 100644 --- a/compose/config/errors.py +++ b/compose/config/errors.py @@ -6,6 +6,10 @@ def __str__(self): return self.msg +class DependencyError(ConfigurationError): + pass + + class CircularReference(ConfigurationError): def __init__(self, trail): self.trail = trail diff --git a/compose/config/sort_services.py b/compose/config/sort_services.py new file mode 100644 index 00000000000..5d9adab11fd --- /dev/null +++ b/compose/config/sort_services.py @@ -0,0 +1,55 @@ +from compose.config.errors import DependencyError + + +def get_service_name_from_net(net_config): + if not net_config: + return + + if not net_config.startswith('container:'): + return + + _, net_name = net_config.split(':', 1) + return net_name + + +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_net(service.get('net'))) + ] + + def visit(n): + if n['name'] in temporary_marked: + if n['name'] in get_service_names(n.get('links', [])): + 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 in unmarked: + temporary_marked.add(n['name']) + for m in get_service_dependents(n, services): + visit(m) + temporary_marked.remove(n['name']) + unmarked.remove(n) + sorted_services.insert(0, n) + + while unmarked: + visit(unmarked[-1]) + + return sorted_services diff --git a/compose/project.py b/compose/project.py index 5caa1ea37f6..30e81693c5b 100644 --- a/compose/project.py +++ b/compose/project.py @@ -9,7 +9,7 @@ from . import parallel from .config import ConfigurationError -from .config import get_service_name_from_net +from .config.sort_services import get_service_name_from_net from .const import DEFAULT_TIMEOUT from .const import LABEL_ONE_OFF from .const import LABEL_PROJECT @@ -26,49 +26,6 @@ log = logging.getLogger(__name__) -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_net(service.get('net'))) - ] - - def visit(n): - if n['name'] in temporary_marked: - if n['name'] in get_service_names(n.get('links', [])): - 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 in unmarked: - temporary_marked.add(n['name']) - for m in get_service_dependents(n, services): - visit(m) - temporary_marked.remove(n['name']) - unmarked.remove(n) - sorted_services.insert(0, n) - - while unmarked: - visit(unmarked[-1]) - - return sorted_services - - class Project(object): """ A collection of services. @@ -96,7 +53,7 @@ def from_dicts(cls, name, service_dicts, client, use_networking=False, network_d if use_networking: remove_links(service_dicts) - for service_dict in sort_service_dicts(service_dicts): + for service_dict in service_dicts: links = project.get_links(service_dict) volumes_from = project.get_volumes_from(service_dict) net = project.get_net(service_dict) @@ -404,7 +361,3 @@ def __init__(self, name): def __str__(self): return self.msg - - -class DependencyError(ConfigurationError): - pass diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index f5de50ee18c..a2218d6bc8c 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -8,6 +8,7 @@ from .. import unittest from compose.cli.docker_client import docker_client from compose.config.config import resolve_environment +from compose.config.config import ServiceConfig from compose.const import LABEL_PROJECT from compose.progress_stream import stream_output from compose.service import Service diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index b2a4cd68ffc..a5eeb64f9fc 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -77,7 +77,7 @@ def test_load_throws_error_when_not_dict(self): ) ) - def test_config_invalid_service_names(self): + def test_load_config_invalid_service_names(self): for invalid_name in ['?not?allowed', ' ', '', '!', '/', '\xe2']: with pytest.raises(ConfigurationError) as exc: config.load(build_config_details( @@ -232,6 +232,27 @@ def test_load_with_multiple_files_and_invalid_override(self): assert "service 'bogus' doesn't have any configuration" in exc.exconly() assert "In file 'override.yaml'" in exc.exconly() + def test_load_sorts_in_dependency_order(self): + config_details = build_config_details({ + 'web': { + 'image': 'busybox:latest', + 'links': ['db'], + }, + 'db': { + 'image': 'busybox:latest', + 'volumes_from': ['volume:ro'] + }, + 'volume': { + 'image': 'busybox:latest', + 'volumes': ['/tmp'], + } + }) + services = config.load(config_details) + + assert services[0]['name'] == 'volume' + assert services[1]['name'] == 'db' + assert services[2]['name'] == 'web' + def test_config_valid_service_names(self): for valid_name in ['_', '-', '.__.', '_what-up.', 'what_.up----', 'whatup']: services = config.load( diff --git a/tests/unit/sort_service_test.py b/tests/unit/config/sort_services_test.py similarity index 98% rename from tests/unit/sort_service_test.py rename to tests/unit/config/sort_services_test.py index ef08828776e..8d0c3ae4080 100644 --- a/tests/unit/sort_service_test.py +++ b/tests/unit/config/sort_services_test.py @@ -1,7 +1,7 @@ -from .. import unittest +from compose.config.errors import DependencyError +from compose.config.sort_services import sort_service_dicts from compose.config.types import VolumeFromSpec -from compose.project import DependencyError -from compose.project import sort_service_dicts +from tests import unittest class SortServiceTest(unittest.TestCase): diff --git a/tests/unit/project_test.py b/tests/unit/project_test.py index f8178ed8b9a..f4c6f8ca165 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -34,29 +34,6 @@ def test_from_dict(self): self.assertEqual(project.get_service('db').name, 'db') self.assertEqual(project.get_service('db').options['image'], 'busybox:latest') - def test_from_dict_sorts_in_dependency_order(self): - project = Project.from_dicts('composetest', [ - { - 'name': 'web', - 'image': 'busybox:latest', - 'links': ['db'], - }, - { - 'name': 'db', - 'image': 'busybox:latest', - 'volumes_from': [VolumeFromSpec('volume', 'ro')] - }, - { - 'name': 'volume', - 'image': 'busybox:latest', - 'volumes': ['/tmp'], - } - ], None) - - self.assertEqual(project.services[0].name, 'volume') - self.assertEqual(project.services[1].name, 'db') - self.assertEqual(project.services[2].name, 'web') - def test_from_config(self): dicts = [ { diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index a439f0da9e6..e87ce5920b7 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -23,8 +23,6 @@ from compose.service import parse_repository_tag from compose.service import Service from compose.service import ServiceNet -from compose.service import VolumeFromSpec -from compose.service import VolumeSpec from compose.service import warn_on_masked_volume From e40670207f358622e7e2b3f7b73f389dc2bbf63e Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 24 Nov 2015 10:40:36 -0500 Subject: [PATCH 1560/4072] Add missing assert and autospec. Signed-off-by: Daniel Nephin --- tests/unit/service_test.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 808c391cd2a..85d1479d5b1 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -757,7 +757,7 @@ def test_warn_on_masked_volume_no_warning_when_no_container_volumes(self): container_volumes = [] service = 'service_name' - with mock.patch('compose.service.log') as mock_log: + with mock.patch('compose.service.log', autospec=True) as mock_log: warn_on_masked_volume(volumes_option, container_volumes, service) assert not mock_log.warn.called @@ -770,17 +770,17 @@ def test_warn_on_masked_volume_when_masked(self): ] service = 'service_name' - with mock.patch('compose.service.log') as mock_log: + with mock.patch('compose.service.log', autospec=True) as mock_log: warn_on_masked_volume(volumes_option, container_volumes, service) - mock_log.warn.called_once_with(mock.ANY) + mock_log.warn.assert_called_once_with(mock.ANY) def test_warn_on_masked_no_warning_with_same_path(self): volumes_option = [VolumeSpec('/home/user', '/path', 'rw')] container_volumes = [VolumeSpec('/home/user', '/path', 'rw')] service = 'service_name' - with mock.patch('compose.service.log') as mock_log: + with mock.patch('compose.service.log', autospec=True) as mock_log: warn_on_masked_volume(volumes_option, container_volumes, service) assert not mock_log.warn.called From 6e89a5708fab417b54d8b5b498abe38e621afde9 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 12 Nov 2015 13:23:04 -0500 Subject: [PATCH 1561/4072] cherry-pick release notes from 1.5.1 Signed-off-by: Daniel Nephin --- CHANGELOG.md | 53 +++++++++++++++++++++++++++++++++++++++++++++++++ docs/install.md | 6 +++--- script/run.sh | 2 +- 3 files changed, 57 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dde425421b7..428e5a933c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,59 @@ Change log ========== +1.5.1 (2015-11-12) +------------------ + +- Add the `--force-rm` option to `build`. + +- Add the `ulimit` option for services in the Compose file. + +- Fixed a bug where `up` would error with "service needs to be built" if + a service changed from using `image` to using `build`. + +- Fixed a bug that would cause incorrect output of parallel operations + on some terminals. + +- Fixed a bug that prevented a container from being recreated when the + mode of a `volumes_from` was changed. + +- Fixed a regression in 1.5.0 where non-utf-8 unicode characters would cause + `up` or `logs` to crash. + +- Fixed a regression in 1.5.0 where Compose would use a success exit status + code when a command fails due to an HTTP timeout communicating with the + docker daemon. + +- Fixed a regression in 1.5.0 where `name` was being accepted as a valid + service option which would override the actual name of the service. + +- When using `--x-networking` Compose no longer sets the hostname to the + container name. + +- When using `--x-networking` Compose will only create the default network + 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. + +- Recreate a container if one of its dependencies is being created. + Previously a container was only recreated if it's dependencies already + existed, but were being recreated as well. + +- Add a warning when a `volume` in the Compose file is being ignored + and masked by a container volume from a previous container. + +- Improve the output of `pull` when run without a tty. + +- When using multiple Compose files, validate each before attempting to merge + them together. Previously invalid files would result in not helpful errors. + +- Allow dashes in keys in the `environment` service option. + +- Improve validation error messages by including the filename as part of the + error message. + + 1.5.0 (2015-11-03) ------------------ diff --git a/docs/install.md b/docs/install.md index d394905d02b..861954b4b54 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/VERSION_NUM/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose + curl -L https://github.com/docker/compose/releases/download/1.5.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.5.0 + docker-compose version: 1.5.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.0/run.sh > /usr/local/bin/docker-compose + $ curl -L https://github.com/docker/compose/releases/download/1.5.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 342188e88da..9563b2e9cc1 100755 --- a/script/run.sh +++ b/script/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.5.0" +VERSION="1.5.1" IMAGE="docker/compose:$VERSION" From e67bc2569ccc3c811d16cea284eca1f65a227c33 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 23 Nov 2015 13:24:57 -0500 Subject: [PATCH 1562/4072] Properly resolve environment from all sources. Split env resolving into two phases. The first phase is to expand the paths of env_files, which is done before merging extends. Once all files are merged together, the final phase is to read the env_files and use them as the base for environment variables. Signed-off-by: Daniel Nephin --- compose/config/config.py | 34 +++++------- tests/integration/testcases.py | 5 +- tests/unit/config/config_test.py | 94 +++++++++++++++++--------------- 3 files changed, 64 insertions(+), 69 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 9d438ca127a..369143311b3 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -324,16 +324,13 @@ def get_extended_config_path(self, extends_options): return filename -def resolve_environment(service_config): +def resolve_environment(service_dict): """Unpack any environment variables from an env_file, if set. Interpolate environment values if set. """ - service_dict = service_config.config - env = {} - if 'env_file' in service_dict: - for env_file in get_env_files(service_config.working_dir, service_dict): - env.update(env_vars_from_file(env_file)) + for env_file in service_dict.get('env_file', []): + 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)) @@ -370,9 +367,11 @@ def process_service(service_config): working_dir = service_config.working_dir service_dict = dict(service_config.config) - if 'environment' in service_dict or 'env_file' in service_dict: - service_dict['environment'] = resolve_environment(service_config) - service_dict.pop('env_file', None) + if 'env_file' in service_dict: + service_dict['env_file'] = [ + expand_path(working_dir, path) + for path in to_list(service_dict['env_file']) + ] if 'volumes' in service_dict and service_dict.get('volume_driver') is None: service_dict['volumes'] = resolve_volume_paths(working_dir, service_dict) @@ -396,6 +395,10 @@ def process_service(service_config): def finalize_service(service_config): 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.pop('env_file', None) + if 'volumes_from' in service_dict: service_dict['volumes_from'] = [ VolumeFromSpec.parse(vf) for vf in service_dict['volumes_from']] @@ -440,7 +443,7 @@ def merge_field(field, merge_func, default=None): for field in ['ports', 'expose', 'external_links']: merge_field(field, operator.add, default=[]) - for field in ['dns', 'dns_search']: + for field in ['dns', 'dns_search', 'env_file']: merge_field(field, merge_list_or_string) already_merged_keys = set(d) | {'image', 'build'} @@ -468,17 +471,6 @@ def merge_environment(base, override): return env -def get_env_files(working_dir, options): - if 'env_file' not in options: - return {} - - env_files = options.get('env_file', []) - if not isinstance(env_files, list): - env_files = [env_files] - - return [expand_path(working_dir, path) for path in env_files] - - def parse_environment(environment): if not environment: return {} diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index 334693f7060..9ea68e39c53 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -7,7 +7,6 @@ from .. import unittest from compose.cli.docker_client import docker_client from compose.config.config import resolve_environment -from compose.config.config import ServiceConfig from compose.const import LABEL_PROJECT from compose.progress_stream import stream_output from compose.service import Service @@ -39,9 +38,7 @@ def create_service(self, name, **kwargs): if 'command' not in kwargs: kwargs['command'] = ["top"] - service_config = ServiceConfig('.', None, name, kwargs) - kwargs['environment'] = resolve_environment(service_config) - + kwargs['environment'] = resolve_environment(kwargs) 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 a5eeb64f9fc..2cd26e8f775 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -10,6 +10,7 @@ import pytest from compose.config import config +from compose.config.config import resolve_environment from compose.config.errors import ConfigurationError from compose.config.types import VolumeSpec from compose.const import IS_WINDOWS_PLATFORM @@ -973,65 +974,54 @@ def test_resolve_environment(self): os.environ['FILE_DEF_EMPTY'] = 'E2' os.environ['ENV_DEF'] = 'E3' - service_dict = make_service_dict( - 'foo', { - 'build': '.', - 'environment': { - 'FILE_DEF': 'F1', - 'FILE_DEF_EMPTY': '', - 'ENV_DEF': None, - 'NO_DEF': None - }, + service_dict = { + 'build': '.', + 'environment': { + 'FILE_DEF': 'F1', + 'FILE_DEF_EMPTY': '', + 'ENV_DEF': None, + 'NO_DEF': None }, - 'tests/' - ) - + } self.assertEqual( - service_dict['environment'], + resolve_environment(service_dict), {'FILE_DEF': 'F1', 'FILE_DEF_EMPTY': '', 'ENV_DEF': 'E3', 'NO_DEF': ''}, ) - def test_env_from_file(self): - service_dict = make_service_dict( - 'foo', - {'build': '.', 'env_file': 'one.env'}, - 'tests/fixtures/env', - ) + def test_resolve_environment_from_env_file(self): self.assertEqual( - service_dict['environment'], + resolve_environment({'env_file': ['tests/fixtures/env/one.env']}), {'ONE': '2', 'TWO': '1', 'THREE': '3', 'FOO': 'bar'}, ) - def test_env_from_multiple_files(self): - service_dict = make_service_dict( - 'foo', - {'build': '.', 'env_file': ['one.env', 'two.env']}, - 'tests/fixtures/env', - ) + def test_resolve_environment_with_multiple_env_files(self): + service_dict = { + 'env_file': [ + 'tests/fixtures/env/one.env', + 'tests/fixtures/env/two.env' + ] + } self.assertEqual( - service_dict['environment'], + resolve_environment(service_dict), {'ONE': '2', 'TWO': '1', 'THREE': '3', 'FOO': 'baz', 'DOO': 'dah'}, ) - def test_env_nonexistent_file(self): - options = {'env_file': 'nonexistent.env'} - self.assertRaises( - ConfigurationError, - lambda: make_service_dict('foo', options, 'tests/fixtures/env'), - ) + def test_resolve_environment_nonexistent_file(self): + with pytest.raises(ConfigurationError) as exc: + config.load(build_config_details( + {'foo': {'image': 'example', 'env_file': 'nonexistent.env'}}, + working_dir='tests/fixtures/env')) + + assert 'Couldn\'t find env file' in exc.exconly() + assert 'nonexistent.env' in exc.exconly() @mock.patch.dict(os.environ) - def test_resolve_environment_from_file(self): + 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' - service_dict = make_service_dict( - 'foo', - {'build': '.', 'env_file': 'resolve.env'}, - 'tests/fixtures/env', - ) self.assertEqual( - service_dict['environment'], + resolve_environment({'env_file': ['tests/fixtures/env/resolve.env']}), { 'FILE_DEF': u'bär', 'FILE_DEF_EMPTY': '', @@ -1378,6 +1368,8 @@ def test_extends_with_environment_and_env_files(self): - 'envs' environment: - SECRET + - TEST_ONE=common + - TEST_TWO=common """) tmpdir.join('docker-compose.yml').write(""" ext: @@ -1388,12 +1380,20 @@ def test_extends_with_environment_and_env_files(self): - 'envs' environment: - THING + - TEST_ONE=top """) commondir.join('envs').write(""" - COMMON_ENV_FILE=1 + COMMON_ENV_FILE + TEST_ONE=common-env-file + TEST_TWO=common-env-file + TEST_THREE=common-env-file + TEST_FOUR=common-env-file """) tmpdir.join('envs').write(""" - FROM_ENV_FILE=1 + TOP_ENV_FILE + TEST_ONE=top-env-file + TEST_TWO=top-env-file + TEST_THREE=top-env-file """) expected = [ @@ -1402,15 +1402,21 @@ def test_extends_with_environment_and_env_files(self): 'image': 'example/app', 'environment': { 'SECRET': 'secret', - 'FROM_ENV_FILE': '1', - 'COMMON_ENV_FILE': '1', + 'TOP_ENV_FILE': 'secret', + 'COMMON_ENV_FILE': 'secret', 'THING': 'thing', + 'TEST_ONE': 'top', + 'TEST_TWO': 'common', + 'TEST_THREE': 'top-env-file', + 'TEST_FOUR': 'common-env-file', }, }, ] 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'))) assert config == expected From fd06d699f22fc9d473ac4201feba5847782688ec Mon Sep 17 00:00:00 2001 From: Sven Dowideit Date: Thu, 26 Nov 2015 20:30:12 +1000 Subject: [PATCH 1563/4072] Use FROM docs/base:latest again Signed-off-by: Sven Dowideit --- docs/Dockerfile | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/Dockerfile b/docs/Dockerfile index 0114f04e485..83b656333de 100644 --- a/docs/Dockerfile +++ b/docs/Dockerfile @@ -1,4 +1,4 @@ -FROM docs/base:hugo-github-linking +FROM docs/base:latest MAINTAINER Mary Anthony (@moxiegirl) RUN svn checkout https://github.com/docker/docker/trunk/docs /docs/content/engine @@ -9,7 +9,8 @@ RUN svn checkout https://github.com/kitematic/kitematic/trunk/docs /docs/content 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 +ENV PROJECT=compose # To get the git info for this repo COPY . /src -COPY . /docs/content/compose/ +COPY . /docs/content/$PROJECT/ From b85bfce65e26a85150be2073576e3ebe840f3ee1 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 26 Nov 2015 15:06:30 +0000 Subject: [PATCH 1564/4072] Fix ports validation test We were essentially only testing that *at least one* of the invalid values fails the validation check, rather than that *all* of them fail. Signed-off-by: Aanand Prasad --- 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 2cd26e8f775..7ddec8ab949 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -264,9 +264,8 @@ def test_config_valid_service_names(self): assert services[0]['name'] == valid_name def test_config_invalid_ports_format_validation(self): - expected_error_msg = "Service 'web' configuration key 'ports' contains an invalid type" - with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): - for invalid_ports in [{"1": "8000"}, False, 0, "8000", 8000, ["8000", "8000"]]: + for invalid_ports in [{"1": "8000"}, False, 0, "8000", 8000, ["8000", "8000"]]: + with pytest.raises(ConfigurationError): config.load( build_config_details( {'web': {'image': 'busybox', 'ports': invalid_ports}}, From f7239f41efe039137da352e249ae0914d683524f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Eckerstr=C3=B6m?= Date: Wed, 29 Apr 2015 10:22:24 +0200 Subject: [PATCH 1565/4072] Added support for url buid paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jonas Eckerström --- compose/config/config.py | 27 ++++++++++++++++++++++++--- tests/unit/config/config_test.py | 14 ++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 369143311b3..242e7af922d 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -76,6 +76,13 @@ 'external_links', ] +DOCKER_VALID_URL_PREFIXES = ( + 'http://', + 'https://', + 'git://', + 'github.com/', + 'git@', +) SUPPORTED_FILENAMES = [ 'docker-compose.yml', @@ -377,7 +384,7 @@ def process_service(service_config): service_dict['volumes'] = resolve_volume_paths(working_dir, service_dict) if 'build' in service_dict: - service_dict['build'] = expand_path(working_dir, service_dict['build']) + service_dict['build'] = resolve_build_path(working_dir, service_dict['build']) if 'labels' in service_dict: service_dict['labels'] = parse_labels(service_dict['labels']) @@ -539,11 +546,25 @@ def resolve_volume_path(working_dir, volume): return container_path +def resolve_build_path(working_dir, build_path): + if is_url(build_path): + return build_path + return expand_path(working_dir, build_path) + + +def is_url(build_path): + return build_path.startswith(DOCKER_VALID_URL_PREFIXES) + + def validate_paths(service_dict): if 'build' in service_dict: build_path = service_dict['build'] - if not os.path.exists(build_path) or not os.access(build_path, os.R_OK): - raise ConfigurationError("build path %s either does not exist or is not accessible." % build_path) + if ( + not is_url(build_path) and + (not os.path.exists(build_path) or not os.access(build_path, os.R_OK)) + ): + raise ConfigurationError( + "build path %s either does not exist or is not accessible." % build_path) def merge_path_mappings(base, override): diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 2cd26e8f775..6de794ade18 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1497,6 +1497,20 @@ def test_from_file(self): service_dict = load_from_filename('tests/fixtures/build-path/docker-compose.yml') self.assertEquals(service_dict, [{'name': 'foo', 'build': self.abs_context_path}]) + def test_valid_url_path(self): + valid_urls = [ + 'git://github.com/docker/docker', + 'git@github.com:docker/docker.git', + 'git@bitbucket.org:atlassianlabs/atlassian-docker.git', + 'https://github.com/docker/docker.git', + 'http://github.com/docker/docker.git', + ] + for valid_url in valid_urls: + service_dict = config.load(build_config_details({ + 'validurl': {'build': valid_url}, + }, '.', None)) + assert service_dict[0]['build'] == valid_url + class GetDefaultConfigFilesTestCase(unittest.TestCase): From 2ab3cb212a3c4c0e3b6f3daf6792d3e8cb60782c Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 19 Nov 2015 15:46:14 -0500 Subject: [PATCH 1566/4072] Add integration test and docs for build with a git url. Signed-off-by: Daniel Nephin --- compose/config/config.py | 3 ++- docs/compose-file.md | 11 +++++++---- tests/integration/service_test.py | 7 +++++++ tests/unit/config/config_test.py | 16 +++++++++++++++- 4 files changed, 31 insertions(+), 6 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 242e7af922d..c716393d45c 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -564,7 +564,8 @@ def validate_paths(service_dict): (not os.path.exists(build_path) or not os.access(build_path, os.R_OK)) ): raise ConfigurationError( - "build path %s either does not exist or is not accessible." % build_path) + "build path %s either does not exist, is not accessible, " + "or is not a valid URL." % build_path) def merge_path_mappings(base, override): diff --git a/docs/compose-file.md b/docs/compose-file.md index 51d1f5e1aa1..800d2aa9896 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -31,15 +31,18 @@ definition. ### build -Path to a directory containing a Dockerfile. When the value supplied is a -relative path, it is interpreted as relative to the location of the yml file -itself. This directory is also the build context that is sent to the Docker daemon. +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: /path/to/build/dir -Using `build` together with `image` is not allowed. Attempting to do so results in an error. +Using `build` together with `image` is not allowed. Attempting to do so results in +an error. ### cap_add, cap_drop diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 5dd3d2e68a7..b03baac595a 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -507,6 +507,13 @@ def test_build_non_ascii_filename(self): self.create_service('web', build=text_type(base_dir)).build() self.assertEqual(len(self.client.images(name='composetest_web')), 1) + def test_build_with_git_url(self): + build_url = "https://github.com/dnephin/docker-build-from-url.git" + service = self.create_service('buildwithurl', build=build_url) + self.addCleanup(self.client.remove_image, service.image_name) + service.build() + assert service.image() + def test_start_container_stays_unpriviliged(self): service = self.create_service('web') container = create_and_start_container(service).inspect() diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 6de794ade18..e15ac3502f7 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1497,13 +1497,14 @@ def test_from_file(self): service_dict = load_from_filename('tests/fixtures/build-path/docker-compose.yml') self.assertEquals(service_dict, [{'name': 'foo', 'build': self.abs_context_path}]) - def test_valid_url_path(self): + def test_valid_url_in_build_path(self): valid_urls = [ 'git://github.com/docker/docker', 'git@github.com:docker/docker.git', 'git@bitbucket.org:atlassianlabs/atlassian-docker.git', 'https://github.com/docker/docker.git', 'http://github.com/docker/docker.git', + 'github.com/docker/docker.git', ] for valid_url in valid_urls: service_dict = config.load(build_config_details({ @@ -1511,6 +1512,19 @@ def test_valid_url_path(self): }, '.', None)) assert service_dict[0]['build'] == valid_url + def test_invalid_url_in_build_path(self): + invalid_urls = [ + 'example.com/bogus', + 'ftp://example.com/', + '/path/does/not/exist', + ] + for invalid_url in invalid_urls: + with pytest.raises(ConfigurationError) as exc: + config.load(build_config_details({ + 'invalidurl': {'build': invalid_url}, + }, '.', None)) + assert 'build path' in exc.exconly() + class GetDefaultConfigFilesTestCase(unittest.TestCase): From d52508e2b1b8e59900d492cff69169fe4fed7d1f Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 26 Nov 2015 18:52:14 +0000 Subject: [PATCH 1567/4072] Refactor ports section of fields schema Signed-off-by: Aanand Prasad --- compose/config/fields_schema.json | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/compose/config/fields_schema.json b/compose/config/fields_schema.json index 9cbcfd1b268..3f1f10fa690 100644 --- a/compose/config/fields_schema.json +++ b/compose/config/fields_schema.json @@ -83,16 +83,8 @@ "ports": { "type": "array", "items": { - "oneOf": [ - { - "type": "string", - "format": "ports" - }, - { - "type": "number", - "format": "ports" - } - ] + "type": ["string", "number"], + "format": "ports" }, "uniqueItems": true }, From 374b16843fedca5908d166226e4d1fc9f455acfc Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 26 Nov 2015 18:54:30 +0000 Subject: [PATCH 1568/4072] Fix ports validation message - The `raises` kwarg to the `cls_check` decorator was being used incorrectly (it should be an exception class, not an object). - We need to check for `error.cause` and get the message out of the exception object. NB: The particular case where validation fails in the case of `ports` is only when ranges don't match in length - no further validation is currently performed client-side. Signed-off-by: Aanand Prasad --- compose/config/validation.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/compose/config/validation.py b/compose/config/validation.py index 38020366d75..24a45e768d7 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -36,16 +36,12 @@ VALID_NAME_CHARS = '[a-zA-Z0-9\._\-]' -@FormatChecker.cls_checks( - format="ports", - raises=ValidationError( - "Invalid port formatting, it should be " - "'[[remote_ip:]remote_port:]port[/protocol]'")) +@FormatChecker.cls_checks(format="ports", raises=ValidationError) def format_ports(instance): try: split_port(instance) - except ValueError: - return False + except ValueError as e: + raise ValidationError(six.text_type(e)) return True @@ -184,6 +180,10 @@ def handle_generic_service_error(error, service_name): config_key, required_keys) + elif error.cause: + error_msg = six.text_type(error.cause) + msg_format = "Service '{}' configuration key {} is invalid: {}" + elif error.path: msg_format = "Service '{}' configuration key {} value {}" From 042c7048f26713d18da559941bc25f974d60883c Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 26 Nov 2015 19:17:13 +0000 Subject: [PATCH 1569/4072] Split out ports validation tests into type, uniqueness, format Signed-off-by: Aanand Prasad --- tests/unit/config/config_test.py | 86 ++++++++++++++++++++++++-------- 1 file changed, 64 insertions(+), 22 deletions(-) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 7ddec8ab949..f85c52defad 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -263,28 +263,6 @@ def test_config_valid_service_names(self): 'common.yml')) assert services[0]['name'] == valid_name - def test_config_invalid_ports_format_validation(self): - for invalid_ports in [{"1": "8000"}, False, 0, "8000", 8000, ["8000", "8000"]]: - with pytest.raises(ConfigurationError): - config.load( - build_config_details( - {'web': {'image': 'busybox', 'ports': invalid_ports}}, - 'working_dir', - 'filename.yml' - ) - ) - - def test_config_valid_ports_format_validation(self): - valid_ports = [["8000", "9000"], ["8000/8050"], ["8000"], [8000], ["49153-49154:3002-3003"]] - for ports in valid_ports: - config.load( - build_config_details( - {'web': {'image': 'busybox', 'ports': ports}}, - 'working_dir', - 'filename.yml' - ) - ) - def test_config_hint(self): expected_error_msg = "(did you mean 'privileged'?)" with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): @@ -558,6 +536,70 @@ def test_validate_extra_hosts_invalid_list(self): assert "which is an invalid type" in exc.exconly() +class PortsTest(unittest.TestCase): + INVALID_PORTS_TYPES = [ + {"1": "8000"}, + False, + "8000", + 8000, + ] + + NON_UNIQUE_SINGLE_PORTS = [ + ["8000", "8000"], + ] + + INVALID_PORT_MAPPINGS = [ + ["8000-8001:8000"], + ] + + VALID_SINGLE_PORTS = [ + ["8000"], + ["8000/tcp"], + ["8000", "9000"], + [8000], + [8000, 9000], + ] + + VALID_PORT_MAPPINGS = [ + ["8000:8050"], + ["49153-49154:3002-3003"], + ] + + def test_config_invalid_ports_type_validation(self): + for invalid_ports in self.INVALID_PORTS_TYPES: + with pytest.raises(ConfigurationError) as exc: + self.check_config({'ports': invalid_ports}) + + assert "contains an invalid type" in exc.value.msg + + def test_config_non_unique_ports_validation(self): + for invalid_ports in self.NON_UNIQUE_SINGLE_PORTS: + with pytest.raises(ConfigurationError) as exc: + self.check_config({'ports': invalid_ports}) + + assert "non-unique" in exc.value.msg + + def test_config_invalid_ports_format_validation(self): + for invalid_ports in self.INVALID_PORT_MAPPINGS: + with pytest.raises(ConfigurationError) as exc: + self.check_config({'ports': invalid_ports}) + + assert "Port ranges don't match in length" in exc.value.msg + + def test_config_valid_ports_format_validation(self): + for valid_ports in self.VALID_SINGLE_PORTS + self.VALID_PORT_MAPPINGS: + self.check_config({'ports': valid_ports}) + + def check_config(self, cfg): + config.load( + build_config_details( + {'web': dict(image='busybox', **cfg)}, + 'working_dir', + 'filename.yml' + ) + ) + + class InterpolationTest(unittest.TestCase): @mock.patch.dict(os.environ) def test_config_file_with_environment_variable(self): From ccf548b98c5eca779b753c14439d83832e1f6b54 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 26 Nov 2015 19:17:58 +0000 Subject: [PATCH 1570/4072] Validate the 'expose' option Signed-off-by: Aanand Prasad --- compose/config/fields_schema.json | 5 ++++- compose/config/validation.py | 14 +++++++++++++- tests/unit/config/config_test.py | 27 +++++++++++++++++++++++++++ 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/compose/config/fields_schema.json b/compose/config/fields_schema.json index 3f1f10fa690..7d5220e3fea 100644 --- a/compose/config/fields_schema.json +++ b/compose/config/fields_schema.json @@ -41,7 +41,10 @@ "expose": { "type": "array", - "items": {"type": ["string", "number"]}, + "items": { + "type": ["string", "number"], + "format": "expose" + }, "uniqueItems": true }, diff --git a/compose/config/validation.py b/compose/config/validation.py index 24a45e768d7..d16bdb9d3e0 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -1,6 +1,7 @@ import json import logging import os +import re import sys import six @@ -34,6 +35,7 @@ VALID_NAME_CHARS = '[a-zA-Z0-9\._\-]' +VALID_EXPOSE_FORMAT = r'^\d+(\/[a-zA-Z]+)?$' @FormatChecker.cls_checks(format="ports", raises=ValidationError) @@ -45,6 +47,16 @@ def format_ports(instance): return True +@FormatChecker.cls_checks(format="expose", raises=ValidationError) +def format_expose(instance): + if isinstance(instance, six.string_types): + if not re.match(VALID_EXPOSE_FORMAT, instance): + raise ValidationError( + "should be of the format 'PORT[/PROTOCOL]'") + + return True + + @FormatChecker.cls_checks(format="bool-value-in-mapping") def format_boolean_in_environment(instance): """ @@ -273,7 +285,7 @@ def validate_against_fields_schema(config, filename): _validate_against_schema( config, "fields_schema.json", - format_checker=["ports", "bool-value-in-mapping"], + format_checker=["ports", "expose", "bool-value-in-mapping"], filename=filename) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index f85c52defad..6c445432379 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -590,6 +590,33 @@ def test_config_valid_ports_format_validation(self): for valid_ports in self.VALID_SINGLE_PORTS + self.VALID_PORT_MAPPINGS: self.check_config({'ports': valid_ports}) + def test_config_invalid_expose_type_validation(self): + for invalid_expose in self.INVALID_PORTS_TYPES: + with pytest.raises(ConfigurationError) as exc: + self.check_config({'expose': invalid_expose}) + + assert "contains an invalid type" in exc.value.msg + + def test_config_non_unique_expose_validation(self): + for invalid_expose in self.NON_UNIQUE_SINGLE_PORTS: + with pytest.raises(ConfigurationError) as exc: + self.check_config({'expose': invalid_expose}) + + assert "non-unique" in exc.value.msg + + def test_config_invalid_expose_format_validation(self): + # Valid port mappings ARE NOT valid 'expose' entries + for invalid_expose in self.INVALID_PORT_MAPPINGS + self.VALID_PORT_MAPPINGS: + with pytest.raises(ConfigurationError) as exc: + self.check_config({'expose': invalid_expose}) + + assert "should be of the format" in exc.value.msg + + def test_config_valid_expose_format_validation(self): + # Valid single ports ARE valid 'expose' entries + for valid_expose in self.VALID_SINGLE_PORTS: + self.check_config({'expose': valid_expose}) + def check_config(self, cfg): config.load( build_config_details( From 2f568984f73e7bade8d01127dd4e8cf7202eaaec Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 27 Nov 2015 11:52:25 -0500 Subject: [PATCH 1571/4072] Fixes #2368, removes the deprecated --allow-insecure-ssl flag. Signed-off-by: Daniel Nephin --- compose/cli/main.py | 17 ----------------- tests/unit/cli_test.py | 4 ---- 2 files changed, 21 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 9fef8d041b3..2eba265b6dc 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -41,11 +41,6 @@ log = logging.getLogger(__name__) console_handler = logging.StreamHandler(sys.stderr) -INSECURE_SSL_WARNING = """ ---allow-insecure-ssl is deprecated and has no effect. -It will be removed in a future version of Compose. -""" - def main(): setup_logging() @@ -303,11 +298,7 @@ def pull(self, project, options): Options: --ignore-pull-failures Pull what it can and ignores images with pull failures. - --allow-insecure-ssl Deprecated - no effect. """ - if options['--allow-insecure-ssl']: - log.warn(INSECURE_SSL_WARNING) - project.pull( service_names=options['SERVICE'], ignore_pull_failures=options.get('--ignore-pull-failures') @@ -352,7 +343,6 @@ def run(self, project, options): Usage: run [options] [-p PORT...] [-e KEY=VAL...] SERVICE [COMMAND] [ARGS...] Options: - --allow-insecure-ssl Deprecated - no effect. -d Detached mode: Run container in the background, print new container name. --name NAME Assign a name to the container @@ -376,9 +366,6 @@ def run(self, project, options): "Please pass the -d flag when using `docker-compose run`." ) - if options['--allow-insecure-ssl']: - log.warn(INSECURE_SSL_WARNING) - if options['COMMAND']: command = [options['COMMAND']] + options['ARGS'] else: @@ -514,7 +501,6 @@ def up(self, project, options): Usage: up [options] [SERVICE...] Options: - --allow-insecure-ssl Deprecated - no effect. -d Detached mode: Run containers in the background, print new container names. --no-color Produce monochrome output. @@ -528,9 +514,6 @@ def up(self, project, options): when attached or when containers are already running. (default: 10) """ - if options['--allow-insecure-ssl']: - log.warn(INSECURE_SSL_WARNING) - monochrome = options['--no-color'] start_deps = not options['--no-deps'] service_names = options['SERVICE'] diff --git a/tests/unit/cli_test.py b/tests/unit/cli_test.py index 23dc42629c0..c962d0070cd 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -102,7 +102,6 @@ def test_run_with_environment_merged_with_options_list(self, mock_dockerpty): '-e': ['BAR=NEW', 'OTHER=bär'.encode('utf-8')], '--user': None, '--no-deps': None, - '--allow-insecure-ssl': None, '-d': True, '-T': None, '--entrypoint': None, @@ -132,7 +131,6 @@ def test_run_service_with_restart_always(self): '-e': [], '--user': None, '--no-deps': None, - '--allow-insecure-ssl': None, '-d': True, '-T': None, '--entrypoint': None, @@ -161,7 +159,6 @@ def test_run_service_with_restart_always(self): '-e': [], '--user': None, '--no-deps': None, - '--allow-insecure-ssl': None, '-d': True, '-T': None, '--entrypoint': None, @@ -193,7 +190,6 @@ def test_command_manula_and_service_ports_together(self): '-e': [], '--user': None, '--no-deps': None, - '--allow-insecure-ssl': None, '-d': True, '-T': None, '--entrypoint': None, From a21f9993b3acf20efafad70b02da81debfd37830 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 27 Nov 2015 12:02:13 -0500 Subject: [PATCH 1572/4072] Remove migrate-to-labels. Signed-off-by: Daniel Nephin --- compose/cli/main.py | 47 +----- compose/legacy.py | 182 --------------------- compose/project.py | 8 - compose/service.py | 12 +- contrib/completion/bash/docker-compose | 10 -- contrib/completion/zsh/_docker-compose | 5 - docs/install.md | 2 +- docs/reference/docker-compose.md | 1 - tests/integration/legacy_test.py | 218 ------------------------- tests/unit/cli_test.py | 6 - 10 files changed, 7 insertions(+), 484 deletions(-) delete mode 100644 compose/legacy.py delete mode 100644 tests/integration/legacy_test.py diff --git a/compose/cli/main.py b/compose/cli/main.py index 9fef8d041b3..90b030177df 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -12,7 +12,6 @@ from requests.exceptions import ReadTimeout from .. import __version__ -from .. import legacy from ..config import ConfigurationError from ..config import parse_environment from ..const import DEFAULT_TIMEOUT @@ -55,7 +54,7 @@ def main(): except KeyboardInterrupt: log.error("\nAborting.") sys.exit(1) - except (UserError, NoSuchService, ConfigurationError, legacy.LegacyError) as e: + except (UserError, NoSuchService, ConfigurationError) as e: log.error(e.msg) sys.exit(1) except NoSuchCommand as e: @@ -147,9 +146,7 @@ class TopLevelCommand(DocoptCommand): stop Stop services unpause Unpause services up Create and start containers - migrate-to-labels Recreate containers to add labels version Show the Docker-Compose version information - """ base_dir = '.' @@ -550,32 +547,6 @@ def up(self, project, options): log_printer = build_log_printer(to_attach, service_names, monochrome) attach_to_logs(project, log_printer, service_names, timeout) - def migrate_to_labels(self, project, _options): - """ - Recreate containers to add labels - - If you're coming 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 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 myapp_web_1 myapp_db_1 ... - - Usage: migrate-to-labels - """ - legacy.migrate_project_to_labels(project) - def version(self, project, options): """ Show version informations @@ -618,18 +589,10 @@ def run_one_off_container(container_options, project, service, options): if project.use_networking: project.ensure_network_exists() - try: - container = service.create_container( - quiet=True, - one_off=True, - **container_options) - except APIError: - legacy.check_for_legacy_containers( - project.client, - project.name, - [service.name], - allow_one_off=False) - raise + container = service.create_container( + quiet=True, + one_off=True, + **container_options) if options['-d']: container.start() diff --git a/compose/legacy.py b/compose/legacy.py deleted file mode 100644 index 54162417897..00000000000 --- a/compose/legacy.py +++ /dev/null @@ -1,182 +0,0 @@ -import logging -import re - -from .const import LABEL_VERSION -from .container import Container -from .container import get_container_name - - -log = logging.getLogger(__name__) - - -# TODO: remove this section when migrate_project_to_labels is removed -NAME_RE = re.compile(r'^([^_]+)_([^_]+)_(run_)?(\d+)$') - -ERROR_MESSAGE_FORMAT = """ -Compose found the following containers without labels: - -{names_list} - -As of Compose 1.3.0, containers are identified with labels instead of naming -convention. If you want to continue using these containers, run: - - $ docker-compose migrate-to-labels - -Alternatively, remove them: - - $ docker rm -f {rm_args} -""" - -ONE_OFF_ADDENDUM_FORMAT = """ -You should also remove your one-off containers: - - $ docker rm -f {rm_args} -""" - -ONE_OFF_ERROR_MESSAGE_FORMAT = """ -Compose found the following containers without labels: - -{names_list} - -As of Compose 1.3.0, containers are identified with labels instead of naming convention. - -Remove them before continuing: - - $ docker rm -f {rm_args} -""" - - -def check_for_legacy_containers( - client, - project, - services, - allow_one_off=True): - """Check if there are containers named using the old naming convention - and warn the user that those containers may need to be migrated to - using labels, so that compose can find them. - """ - containers = get_legacy_containers(client, project, services, one_off=False) - - if containers: - one_off_containers = get_legacy_containers(client, project, services, one_off=True) - - raise LegacyContainersError( - [c.name for c in containers], - [c.name for c in one_off_containers], - ) - - if not allow_one_off: - one_off_containers = get_legacy_containers(client, project, services, one_off=True) - - if one_off_containers: - raise LegacyOneOffContainersError( - [c.name for c in one_off_containers], - ) - - -class LegacyError(Exception): - def __unicode__(self): - return self.msg - - __str__ = __unicode__ - - -class LegacyContainersError(LegacyError): - def __init__(self, names, one_off_names): - self.names = names - self.one_off_names = one_off_names - - self.msg = ERROR_MESSAGE_FORMAT.format( - names_list="\n".join(" {}".format(name) for name in names), - rm_args=" ".join(names), - ) - - if one_off_names: - self.msg += ONE_OFF_ADDENDUM_FORMAT.format(rm_args=" ".join(one_off_names)) - - -class LegacyOneOffContainersError(LegacyError): - def __init__(self, one_off_names): - self.one_off_names = one_off_names - - self.msg = ONE_OFF_ERROR_MESSAGE_FORMAT.format( - names_list="\n".join(" {}".format(name) for name in one_off_names), - rm_args=" ".join(one_off_names), - ) - - -def add_labels(project, container): - project_name, service_name, one_off, number = NAME_RE.match(container.name).groups() - if project_name != project.name or service_name not in project.service_names: - return - service = project.get_service(service_name) - service.recreate_container(container) - - -def migrate_project_to_labels(project): - log.info("Running migration to labels for project %s", project.name) - - containers = get_legacy_containers( - project.client, - project.name, - project.service_names, - one_off=False, - ) - - for container in containers: - add_labels(project, container) - - -def get_legacy_containers( - client, - project, - services, - one_off=False): - - return list(_get_legacy_containers_iter( - client, - project, - services, - one_off=one_off, - )) - - -def _get_legacy_containers_iter( - client, - project, - services, - one_off=False): - - containers = client.containers(all=True) - - for service in services: - for container in containers: - if LABEL_VERSION in (container.get('Labels') or {}): - continue - - name = get_container_name(container) - if has_container(project, service, name, one_off=one_off): - yield Container.from_ps(client, container) - - -def has_container(project, service, name, one_off=False): - if not name or not is_valid_name(name, one_off): - return False - container_project, container_service, _container_number = parse_name(name) - return container_project == project and container_service == service - - -def is_valid_name(name, one_off=False): - match = NAME_RE.match(name) - if match is None: - return False - if one_off: - return match.group(3) == 'run_' - else: - return match.group(3) is None - - -def parse_name(name): - match = NAME_RE.match(name) - (project, service_name, _, suffix) = match.groups() - return (project, service_name, int(suffix)) diff --git a/compose/project.py b/compose/project.py index 30e81693c5b..af40d820ae7 100644 --- a/compose/project.py +++ b/compose/project.py @@ -15,7 +15,6 @@ from .const import LABEL_PROJECT from .const import LABEL_SERVICE from .container import Container -from .legacy import check_for_legacy_containers from .service import ContainerNet from .service import ConvergenceStrategy from .service import Net @@ -287,13 +286,6 @@ def containers(self, service_names=None, stopped=False, one_off=False): def matches_service_names(container): return container.labels.get(LABEL_SERVICE) in service_names - if not containers: - check_for_legacy_containers( - self.client, - self.name, - self.service_names, - ) - return [c for c in containers if matches_service_names(c)] def get_network(self): diff --git a/compose/service.py b/compose/service.py index 08d563e9303..0b9a2aa3bc8 100644 --- a/compose/service.py +++ b/compose/service.py @@ -26,7 +26,6 @@ from .const import LABEL_SERVICE from .const import LABEL_VERSION from .container import Container -from .legacy import check_for_legacy_containers from .parallel import parallel_execute from .parallel import parallel_remove from .parallel import parallel_start @@ -122,21 +121,12 @@ def __init__( def containers(self, stopped=False, one_off=False, filters={}): filters.update({'label': self.labels(one_off=one_off)}) - containers = list(filter(None, [ + return list(filter(None, [ Container.from_ps(self.client, container) for container in self.client.containers( all=stopped, filters=filters)])) - if not containers: - check_for_legacy_containers( - self.client, - self.project, - [self.name], - ) - - return containers - def get_container(self, number=1): """Return a :class:`compose.container.Container` for this service. The container must be active, and match `number`. diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index b4f4387f3f3..c22e6abc3d3 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -164,15 +164,6 @@ _docker_compose_logs() { } -_docker_compose_migrate_to_labels() { - case "$cur" in - -*) - COMPREPLY=( $( compgen -W "--help" -- "$cur" ) ) - ;; - esac -} - - _docker_compose_pause() { case "$cur" in -*) @@ -385,7 +376,6 @@ _docker_compose() { help kill logs - migrate-to-labels pause port ps diff --git a/contrib/completion/zsh/_docker-compose b/contrib/completion/zsh/_docker-compose index 08d5150d9e1..0b50b5358b1 100644 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -212,11 +212,6 @@ __docker-compose_subcommand() { '--no-color[Produce monochrome output.]' \ '*:services:__docker-compose_services_all' && ret=0 ;; - (migrate-to-labels) - _arguments -A '-*' \ - $opts_help \ - '(-):Recreate containers to add labels' && ret=0 - ;; (pause) _arguments \ $opts_help \ diff --git a/docs/install.md b/docs/install.md index 861954b4b54..cc15cec1b3c 100644 --- a/docs/install.md +++ b/docs/install.md @@ -98,7 +98,7 @@ 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 migrate them with the following command: +to preserve) you can use compose 1.5.x to migrate them with the following command: $ docker-compose migrate-to-labels diff --git a/docs/reference/docker-compose.md b/docs/reference/docker-compose.md index 8712072e564..c19e428483f 100644 --- a/docs/reference/docker-compose.md +++ b/docs/reference/docker-compose.md @@ -40,7 +40,6 @@ Commands: stop Stop services unpause Unpause services up Create and start containers - migrate-to-labels Recreate containers to add labels version Show the Docker-Compose version information ``` diff --git a/tests/integration/legacy_test.py b/tests/integration/legacy_test.py deleted file mode 100644 index 3465d57f491..00000000000 --- a/tests/integration/legacy_test.py +++ /dev/null @@ -1,218 +0,0 @@ -import unittest - -from docker.errors import APIError - -from .. import mock -from .testcases import DockerClientTestCase -from compose import legacy -from compose.project import Project - - -class UtilitiesTestCase(unittest.TestCase): - def test_has_container(self): - self.assertTrue( - legacy.has_container("composetest", "web", "composetest_web_1", one_off=False), - ) - self.assertFalse( - legacy.has_container("composetest", "web", "composetest_web_run_1", one_off=False), - ) - - def test_has_container_one_off(self): - self.assertFalse( - legacy.has_container("composetest", "web", "composetest_web_1", one_off=True), - ) - self.assertTrue( - legacy.has_container("composetest", "web", "composetest_web_run_1", one_off=True), - ) - - def test_has_container_different_project(self): - self.assertFalse( - legacy.has_container("composetest", "web", "otherapp_web_1", one_off=False), - ) - self.assertFalse( - legacy.has_container("composetest", "web", "otherapp_web_run_1", one_off=True), - ) - - def test_has_container_different_service(self): - self.assertFalse( - legacy.has_container("composetest", "web", "composetest_db_1", one_off=False), - ) - self.assertFalse( - legacy.has_container("composetest", "web", "composetest_db_run_1", one_off=True), - ) - - def test_is_valid_name(self): - self.assertTrue( - legacy.is_valid_name("composetest_web_1", one_off=False), - ) - self.assertFalse( - legacy.is_valid_name("composetest_web_run_1", one_off=False), - ) - - def test_is_valid_name_one_off(self): - self.assertFalse( - legacy.is_valid_name("composetest_web_1", one_off=True), - ) - self.assertTrue( - legacy.is_valid_name("composetest_web_run_1", one_off=True), - ) - - def test_is_valid_name_invalid(self): - self.assertFalse( - legacy.is_valid_name("foo"), - ) - self.assertFalse( - legacy.is_valid_name("composetest_web_lol_1", one_off=True), - ) - - def test_get_legacy_containers(self): - client = mock.Mock() - client.containers.return_value = [ - { - "Id": "abc123", - "Image": "def456", - "Name": "composetest_web_1", - "Labels": None, - }, - { - "Id": "ghi789", - "Image": "def456", - "Name": None, - "Labels": None, - }, - { - "Id": "jkl012", - "Image": "def456", - "Labels": None, - }, - ] - - containers = legacy.get_legacy_containers(client, "composetest", ["web"]) - - self.assertEqual(len(containers), 1) - self.assertEqual(containers[0].id, 'abc123') - - -class LegacyTestCase(DockerClientTestCase): - - def setUp(self): - super(LegacyTestCase, self).setUp() - self.containers = [] - - db = self.create_service('db') - web = self.create_service('web', links=[(db, 'db')]) - nginx = self.create_service('nginx', links=[(web, 'web')]) - - self.services = [db, web, nginx] - self.project = Project('composetest', self.services, self.client) - - # Create a legacy container for each service - for service in self.services: - service.ensure_image_exists() - container = self.client.create_container( - name='{}_{}_1'.format(self.project.name, service.name), - **service.options - ) - self.client.start(container) - self.containers.append(container) - - # Create a single one-off legacy container - self.containers.append(self.client.create_container( - name='{}_{}_run_1'.format(self.project.name, db.name), - **self.services[0].options - )) - - def tearDown(self): - super(LegacyTestCase, self).tearDown() - for container in self.containers: - try: - self.client.kill(container) - except APIError: - pass - try: - self.client.remove_container(container) - except APIError: - pass - - def get_legacy_containers(self, **kwargs): - return legacy.get_legacy_containers( - self.client, - self.project.name, - [s.name for s in self.services], - **kwargs - ) - - def test_get_legacy_container_names(self): - self.assertEqual(len(self.get_legacy_containers()), len(self.services)) - - def test_get_legacy_container_names_one_off(self): - self.assertEqual(len(self.get_legacy_containers(one_off=True)), 1) - - def test_migration_to_labels(self): - # Trying to get the container list raises an exception - - with self.assertRaises(legacy.LegacyContainersError) as cm: - self.project.containers(stopped=True) - - self.assertEqual( - set(cm.exception.names), - set(['composetest_db_1', 'composetest_web_1', 'composetest_nginx_1']), - ) - - self.assertEqual( - set(cm.exception.one_off_names), - set(['composetest_db_run_1']), - ) - - # Migrate the containers - - legacy.migrate_project_to_labels(self.project) - - # Getting the list no longer raises an exception - - containers = self.project.containers(stopped=True) - self.assertEqual(len(containers), len(self.services)) - - def test_migration_one_off(self): - # We've already migrated - - legacy.migrate_project_to_labels(self.project) - - # Trying to create a one-off container results in a Docker API error - - with self.assertRaises(APIError) as cm: - self.project.get_service('db').create_container(one_off=True) - - # Checking for legacy one-off containers raises an exception - - with self.assertRaises(legacy.LegacyOneOffContainersError) as cm: - legacy.check_for_legacy_containers( - self.client, - self.project.name, - ['db'], - allow_one_off=False, - ) - - self.assertEqual( - set(cm.exception.one_off_names), - set(['composetest_db_run_1']), - ) - - # Remove the old one-off container - - c = self.client.inspect_container('composetest_db_run_1') - self.client.remove_container(c) - - # Checking no longer raises an exception - - legacy.check_for_legacy_containers( - self.client, - self.project.name, - ['db'], - allow_one_off=False, - ) - - # Creating a one-off container no longer results in an API error - - self.project.get_service('db').create_container(one_off=True) - self.assertIsInstance(self.client.inspect_container('composetest_db_run_1'), dict) diff --git a/tests/unit/cli_test.py b/tests/unit/cli_test.py index 23dc42629c0..2473a3406d0 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -74,12 +74,6 @@ def test_command_help(self): self.assertIn('Usage: up', str(ctx.exception)) - def test_command_help_dashes(self): - with self.assertRaises(SystemExit) as ctx: - TopLevelCommand().dispatch(['help', 'migrate-to-labels'], None) - - self.assertIn('Usage: migrate-to-labels', str(ctx.exception)) - def test_command_help_nonexistent(self): with self.assertRaises(NoSuchCommand): TopLevelCommand().dispatch(['help', 'nonexistent'], None) From 377f084dfe799d43798c9015081437f98228171d Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 27 Nov 2015 13:54:00 -0500 Subject: [PATCH 1573/4072] Increase timeout in tests. Signed-off-by: Daniel Nephin --- tests/acceptance/cli_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 282a5219591..99b78d081b9 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -43,7 +43,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=5): +def wait_on_condition(condition, delay=0.1, timeout=20): start_time = time.time() while not condition(): if time.time() - start_time > timeout: From 3f39ffe72e471c3ca06c8ac6330e2858ba66795e Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 21 Oct 2015 20:13:43 -0400 Subject: [PATCH 1574/4072] FAQ document for Compose Signed-off-by: Daniel Nephin --- docs/faq.md | 139 +++++++++++++++++++++++++ docs/index.md | 1 + script/travis/render-bintray-config.py | 4 +- 3 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 docs/faq.md diff --git a/docs/faq.md b/docs/faq.md new file mode 100644 index 00000000000..b36eb5ac5d3 --- /dev/null +++ b/docs/faq.md @@ -0,0 +1,139 @@ + + +# 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. + +## Why do my services take 10 seconds to 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`. + +* 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/docker-compose.md) or the [`COMPOSE_PROJECT_NAME` +environment variable](./reference/overview.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 +``` + +## 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 +`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/index.md b/docs/index.md index 279154eef9f..8b32a754149 100644 --- a/docs/index.md +++ b/docs/index.md @@ -59,6 +59,7 @@ Compose has commands for managing the whole lifecycle of your application: - [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) diff --git a/script/travis/render-bintray-config.py b/script/travis/render-bintray-config.py index 6aa468d6dc5..fc5d409a05c 100755 --- a/script/travis/render-bintray-config.py +++ b/script/travis/render-bintray-config.py @@ -1,4 +1,6 @@ #!/usr/bin/env python +from __future__ import print_function + import datetime import os.path import sys @@ -6,4 +8,4 @@ os.environ['DATE'] = str(datetime.date.today()) for line in sys.stdin: - print os.path.expandvars(line), + print(os.path.expandvars(line), end='') From 8d816fc2f39ae0c2e95c758c78e1108e72c09446 Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Wed, 28 Oct 2015 16:27:14 +0000 Subject: [PATCH 1575/4072] Add cross references for env/cli Signed-off-by: Mazz Mosley --- docs/reference/docker-compose.md | 15 +++++++++------ docs/reference/overview.md | 9 +++++++-- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/docs/reference/docker-compose.md b/docs/reference/docker-compose.md index 32fcbe70640..8712072e564 100644 --- a/docs/reference/docker-compose.md +++ b/docs/reference/docker-compose.md @@ -87,15 +87,18 @@ 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 -`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. +`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. + +See also the `COMPOSE_FILE` [environment variable](overview.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. +directory name. See also the `COMPOSE_PROJECT_NAME` [environment variable]( +overview.md#compose-project-name) ## Where to go next diff --git a/docs/reference/overview.md b/docs/reference/overview.md index 3f589a9ded9..8e3967b22d1 100644 --- a/docs/reference/overview.md +++ b/docs/reference/overview.md @@ -32,11 +32,16 @@ Docker command-line client. If you're using `docker-machine`, then the `eval "$( 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 current working directory. +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 -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. +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 From d28b2027b8f584c3492bdc6554b73e926a583356 Mon Sep 17 00:00:00 2001 From: Mazz Mosley Date: Wed, 28 Oct 2015 16:48:35 +0000 Subject: [PATCH 1576/4072] Clarify `dockerfile` requires `build` key Credit to @funkyfuture for the first PR addressing the clarification. https://github.com/docker/compose/pull/1767 Signed-off-by: Mazz Mosley --- docs/compose-file.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/compose-file.md b/docs/compose-file.md index d5aeaa3f29f..51d1f5e1aa1 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -105,8 +105,10 @@ Custom DNS search domains. Can be a single value or a list. Alternate Dockerfile. -Compose will use an alternate file to build with. +Compose will use an alternate file to build with. A build path must also be +specified using the `build` key. + build: /path/to/build/dir dockerfile: Dockerfile-alternate Using `dockerfile` together with `image` is not allowed. Attempting to do so results in an error. From c42918ec7c736b16394deeb30a7724ed5d403a52 Mon Sep 17 00:00:00 2001 From: Viranch Mehta Date: Sun, 18 Oct 2015 00:40:51 +0530 Subject: [PATCH 1577/4072] Fix specifies_host_port() to handle port binding with host IP but no host port Signed-off-by: Viranch Mehta --- compose/service.py | 24 +++++++++++++-- tests/unit/service_test.py | 62 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 3 deletions(-) diff --git a/compose/service.py b/compose/service.py index cc491599263..3b05264bb1f 100644 --- a/compose/service.py +++ b/compose/service.py @@ -767,10 +767,28 @@ def custom_container_name(self): return self.options.get('container_name') def specifies_host_port(self): - for port in self.options.get('ports', []): - if ':' in str(port): + def has_host_port(binding): + _, external_bindings = split_port(binding) + + # there are no external bindings + if external_bindings is None: + return False + + # we only need to check the first binding from the range + external_binding = external_bindings[0] + + # non-tuple binding means there is a host port specified + if not isinstance(external_binding, tuple): return True - return False + + # extract actual host port from tuple of (host_ip, host_port) + _, host_port = external_binding + if host_port is not None: + return True + + return False + + return any(has_host_port(binding) for binding in self.options.get('ports', [])) def pull(self, ignore_pull_failures=False): if 'image' not in self.options: diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 311d5c95e77..0cff9899077 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -427,6 +427,68 @@ def test_config_dict_with_net_from_container(self): } self.assertEqual(config_dict, expected) + def test_specifies_host_port_with_no_ports(self): + service = Service( + 'foo', + image='foo') + self.assertEqual(service.specifies_host_port(), False) + + def test_specifies_host_port_with_container_port(self): + service = Service( + 'foo', + image='foo', + ports=["2000"]) + self.assertEqual(service.specifies_host_port(), False) + + def test_specifies_host_port_with_host_port(self): + service = Service( + 'foo', + image='foo', + ports=["1000:2000"]) + self.assertEqual(service.specifies_host_port(), True) + + def test_specifies_host_port_with_host_ip_no_port(self): + service = Service( + 'foo', + image='foo', + ports=["127.0.0.1::2000"]) + self.assertEqual(service.specifies_host_port(), False) + + def test_specifies_host_port_with_host_ip_and_port(self): + service = Service( + 'foo', + image='foo', + ports=["127.0.0.1:1000:2000"]) + self.assertEqual(service.specifies_host_port(), True) + + def test_specifies_host_port_with_container_port_range(self): + service = Service( + 'foo', + image='foo', + ports=["2000-3000"]) + self.assertEqual(service.specifies_host_port(), False) + + def test_specifies_host_port_with_host_port_range(self): + service = Service( + 'foo', + image='foo', + ports=["1000-2000:2000-3000"]) + self.assertEqual(service.specifies_host_port(), True) + + def test_specifies_host_port_with_host_ip_no_port_range(self): + service = Service( + 'foo', + image='foo', + ports=["127.0.0.1::2000-3000"]) + self.assertEqual(service.specifies_host_port(), False) + + def test_specifies_host_port_with_host_ip_and_port_range(self): + service = Service( + 'foo', + image='foo', + 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', From 16a74f3797a9afb06a6f522f452e0426d873221f Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 16 Nov 2015 12:55:35 -0500 Subject: [PATCH 1578/4072] Fix texttable dep. 0.8.2 was removed from pypi. Signed-off-by: Daniel Nephin --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 60327d728de..659cb57f4ed 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,5 +6,5 @@ enum34==1.0.4 jsonschema==2.5.1 requests==2.7.0 six==1.7.3 -texttable==0.8.2 +texttable==0.8.4 websocket-client==0.32.0 From 8f70c8cdeb28f82d9ccf4f060ccd9b44d002c502 Mon Sep 17 00:00:00 2001 From: Simon van der Veldt Date: Wed, 18 Nov 2015 21:38:58 +0100 Subject: [PATCH 1579/4072] run.sh script: Also pass DOCKER_TLS_VERIFY and DOCKER_CERT_PATH env vars to compose container Signed-off-by: Simon van der Veldt --- script/run.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/run.sh b/script/run.sh index a9b954774fe..9563b2e9cc1 100755 --- a/script/run.sh +++ b/script/run.sh @@ -26,7 +26,7 @@ fi if [ -S "$DOCKER_HOST" ]; then DOCKER_ADDR="-v $DOCKER_HOST:$DOCKER_HOST -e DOCKER_HOST" else - DOCKER_ADDR="-e DOCKER_HOST" + DOCKER_ADDR="-e DOCKER_HOST -e DOCKER_TLS_VERIFY -e DOCKER_CERT_PATH" fi From 0117148a360ccc1bc783e16435314bd6c69eaf11 Mon Sep 17 00:00:00 2001 From: Stefan Scherer Date: Fri, 13 Nov 2015 08:33:51 +0100 Subject: [PATCH 1580/4072] Use uname to build target name for different platforms Signed-off-by: Stefan Scherer --- script/build-linux-inner | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/build-linux-inner b/script/build-linux-inner index 01137ff2408..47d5eb2e7e8 100755 --- a/script/build-linux-inner +++ b/script/build-linux-inner @@ -2,7 +2,7 @@ set -ex -TARGET=dist/docker-compose-Linux-x86_64 +TARGET=dist/docker-compose-$(uname -s)-$(uname -m) VENV=/code/.tox/py27 mkdir -p `pwd`/dist From 09f6a876cfc2c7cdbaba9383e0828d4435537bea Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 16 Nov 2015 12:35:26 -0500 Subject: [PATCH 1581/4072] Fixes #2398 - the build progress stream can contain empty json objects. Previously these empty objects would hit a bug in splitting objects causing it crash. With this fix the empty objects are returned properly. Signed-off-by: Daniel Nephin --- compose/utils.py | 12 ++++++------ tests/unit/utils_test.py | 28 ++++++++++++++++++++-------- 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/compose/utils.py b/compose/utils.py index 2c6c4584d29..a013035e916 100644 --- a/compose/utils.py +++ b/compose/utils.py @@ -102,7 +102,7 @@ def stream_as_text(stream): def line_splitter(buffer, separator=u'\n'): index = buffer.find(six.text_type(separator)) if index == -1: - return None, None + return None return buffer[:index + 1], buffer[index + 1:] @@ -120,11 +120,11 @@ def split_buffer(stream, splitter=None, decoder=lambda a: a): for data in stream_as_text(stream): buffered += data while True: - item, rest = splitter(buffered) - if not item: + buffer_split = splitter(buffered) + if buffer_split is None: break - buffered = rest + item, buffered = buffer_split yield item if buffered: @@ -140,7 +140,7 @@ def json_splitter(buffer): rest = buffer[json.decoder.WHITESPACE.match(buffer, index).end():] return obj, rest except ValueError: - return None, None + return None def json_stream(stream): @@ -148,7 +148,7 @@ def json_stream(stream): This handles streams which are inconsistently buffered (some entries may be newline delimited, and others are not). """ - return split_buffer(stream_as_text(stream), json_splitter, json_decoder.decode) + return split_buffer(stream, json_splitter, json_decoder.decode) def write_out_msg(stream, lines, msg_index, msg, status="done"): diff --git a/tests/unit/utils_test.py b/tests/unit/utils_test.py index e3d0bc00b5e..15999dde98a 100644 --- a/tests/unit/utils_test.py +++ b/tests/unit/utils_test.py @@ -1,25 +1,21 @@ # encoding: utf-8 from __future__ import unicode_literals -from .. import unittest from compose import utils -class JsonSplitterTestCase(unittest.TestCase): +class TestJsonSplitter(object): def test_json_splitter_no_object(self): data = '{"foo": "bar' - self.assertEqual(utils.json_splitter(data), (None, None)) + assert utils.json_splitter(data) is None def test_json_splitter_with_object(self): data = '{"foo": "bar"}\n \n{"next": "obj"}' - self.assertEqual( - utils.json_splitter(data), - ({'foo': 'bar'}, '{"next": "obj"}') - ) + assert utils.json_splitter(data) == ({'foo': 'bar'}, '{"next": "obj"}') -class StreamAsTextTestCase(unittest.TestCase): +class TestStreamAsText(object): def test_stream_with_non_utf_unicode_character(self): stream = [b'\xed\xf3\xf3'] @@ -30,3 +26,19 @@ def test_stream_with_utf_character(self): stream = ['ěĝ'.encode('utf-8')] output, = utils.stream_as_text(stream) assert output == 'ěĝ' + + +class TestJsonStream(object): + + def test_with_falsy_entries(self): + stream = [ + '{"one": "two"}\n{}\n', + "[1, 2, 3]\n[]\n", + ] + output = list(utils.json_stream(stream)) + assert output == [ + {'one': 'two'}, + {}, + [1, 2, 3], + [], + ] From 3a395892fc18bd097f767d6a420dc887f9e76e8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Seguin?= Date: Sat, 14 Nov 2015 12:19:57 +0100 Subject: [PATCH 1582/4072] Fix restart with stopped containers. Fixes #1814 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Stéphane Seguin --- compose/service.py | 2 +- tests/acceptance/cli_test.py | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/compose/service.py b/compose/service.py index 3b05264bb1f..4f449d150bb 100644 --- a/compose/service.py +++ b/compose/service.py @@ -185,7 +185,7 @@ def kill(self, **options): c.kill(**options) def restart(self, **options): - for c in self.containers(): + for c in self.containers(stopped=True): log.info("Restarting %s" % c.name) c.restart(**options) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 88a43d7f0e8..34a2d166e7d 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -597,6 +597,15 @@ def test_restart(self): started_at, ) + def test_restart_stopped_container(self): + service = self.project.get_service('simple') + container = service.create_container() + container.start() + container.kill() + self.assertEqual(len(service.containers(stopped=True)), 1) + self.dispatch(['restart', '-t', '1'], None) + self.assertEqual(len(service.containers(stopped=False)), 1) + def test_scale(self): project = self.project From e5a02d30524ac83fa0bc27e2697e1d2f69330658 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 13 Nov 2015 10:49:17 -0500 Subject: [PATCH 1583/4072] Fix extra warnings on masked volumes. Signed-off-by: Daniel Nephin --- compose/service.py | 5 ++++- tests/unit/service_test.py | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/compose/service.py b/compose/service.py index 4f449d150bb..b79fd900108 100644 --- a/compose/service.py +++ b/compose/service.py @@ -963,7 +963,10 @@ def warn_on_masked_volume(volumes_option, container_volumes, service): for volume in container_volumes) for volume in volumes_option: - if container_volumes.get(volume.internal) != volume.external: + if ( + volume.internal in container_volumes and + container_volumes.get(volume.internal) != volume.external + ): log.warn(( "Service \"{service}\" is using volume \"{volume}\" from the " "previous container. Host mapping \"{host_path}\" has no effect. " diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 0cff9899077..808c391cd2a 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -26,6 +26,8 @@ from compose.service import Service from compose.service import ServiceNet from compose.service import VolumeFromSpec +from compose.service import VolumeSpec +from compose.service import warn_on_masked_volume class ServiceTest(unittest.TestCase): @@ -750,6 +752,39 @@ def test_different_host_path_in_container_json(self): ['/mnt/sda1/host/path:/data:rw'], ) + def test_warn_on_masked_volume_no_warning_when_no_container_volumes(self): + volumes_option = [VolumeSpec('/home/user', '/path', 'rw')] + container_volumes = [] + service = 'service_name' + + with mock.patch('compose.service.log') as mock_log: + warn_on_masked_volume(volumes_option, container_volumes, service) + + assert not mock_log.warn.called + + def test_warn_on_masked_volume_when_masked(self): + volumes_option = [VolumeSpec('/home/user', '/path', 'rw')] + container_volumes = [ + VolumeSpec('/var/lib/docker/path', '/path', 'rw'), + VolumeSpec('/var/lib/docker/path', '/other', 'rw'), + ] + service = 'service_name' + + with mock.patch('compose.service.log') as mock_log: + warn_on_masked_volume(volumes_option, container_volumes, service) + + mock_log.warn.called_once_with(mock.ANY) + + def test_warn_on_masked_no_warning_with_same_path(self): + volumes_option = [VolumeSpec('/home/user', '/path', 'rw')] + container_volumes = [VolumeSpec('/home/user', '/path', 'rw')] + service = 'service_name' + + with mock.patch('compose.service.log') as mock_log: + warn_on_masked_volume(volumes_option, container_volumes, service) + + assert not mock_log.warn.called + def test_create_with_special_volume_mode(self): self.mock_client.inspect_image.return_value = {'Id': 'imageid'} From be5b7b6f0e3b8dd330b93523b7a98e47e8d9a833 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 10 Nov 2015 18:52:21 -0500 Subject: [PATCH 1584/4072] Handle both SIGINT and SIGTERM for docker-compose up. Signed-off-by: Daniel Nephin --- compose/cli/main.py | 21 +++++++---- tests/acceptance/cli_test.py | 70 +++++++++++++++++++++++++++++------- tests/unit/cli/main_test.py | 8 ++--- 3 files changed, 76 insertions(+), 23 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 806926d845c..7b1e0aa35d4 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -658,17 +658,24 @@ def build_log_printer(containers, service_names, monochrome): def attach_to_logs(project, log_printer, service_names, timeout): print("Attaching to", list_containers(log_printer.containers)) - try: - log_printer.run() - finally: - def handler(signal, frame): - project.kill(service_names=service_names) - sys.exit(0) - signal.signal(signal.SIGINT, handler) + def force_shutdown(signal, frame): + project.kill(service_names=service_names) + sys.exit(2) + + def shutdown(signal, frame): + set_signal_handler(force_shutdown) print("Gracefully stopping... (press Ctrl+C again to force)") project.stop(service_names=service_names, timeout=timeout) + set_signal_handler(shutdown) + log_printer.run() + + +def set_signal_handler(handler): + signal.signal(signal.SIGINT, handler) + signal.signal(signal.SIGTERM, handler) + def list_containers(containers): return ", ".join(c.name for c in containers) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 34a2d166e7d..3fda83291a1 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -2,7 +2,9 @@ import os import shlex +import signal import subprocess +import time from collections import namedtuple from operator import attrgetter @@ -20,6 +22,45 @@ BUILD_PULL_TEXT = 'Status: Image is up to date for busybox:latest' +def start_process(base_dir, options): + proc = subprocess.Popen( + ['docker-compose'] + options, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + cwd=base_dir) + print("Running process: %s" % proc.pid) + return proc + + +def wait_on_process(proc, returncode=0): + stdout, stderr = proc.communicate() + if proc.returncode != returncode: + print(stderr) + assert proc.returncode == returncode + return ProcessResult(stdout.decode('utf-8'), stderr.decode('utf-8')) + + +def wait_on_condition(condition, delay=0.1, timeout=5): + start_time = time.time() + while not condition(): + if time.time() - start_time > timeout: + raise AssertionError("Timeout: %s" % condition) + time.sleep(delay) + + +class ContainerCountCondition(object): + + def __init__(self, project, expected): + self.project = project + self.expected = expected + + def __call__(self): + return len(self.project.containers()) == self.expected + + def __str__(self): + return "waiting for counter count == %s" % self.expected + + class CLITestCase(DockerClientTestCase): def setUp(self): @@ -42,17 +83,8 @@ def project(self): def dispatch(self, options, project_options=None, returncode=0): project_options = project_options or [] - proc = subprocess.Popen( - ['docker-compose'] + project_options + options, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - cwd=self.base_dir) - print("Running process: %s" % proc.pid) - stdout, stderr = proc.communicate() - if proc.returncode != returncode: - print(stderr) - assert proc.returncode == returncode - return ProcessResult(stdout.decode('utf-8'), stderr.decode('utf-8')) + proc = start_process(self.base_dir, project_options + options) + return wait_on_process(proc, returncode=returncode) def test_help(self): old_base_dir = self.base_dir @@ -291,7 +323,7 @@ def test_up_with_force_recreate_and_no_recreate(self): returncode=1) def test_up_with_timeout(self): - self.dispatch(['up', '-d', '-t', '1'], None) + self.dispatch(['up', '-d', '-t', '1']) service = self.project.get_service('simple') another = self.project.get_service('another') self.assertEqual(len(service.containers()), 1) @@ -303,6 +335,20 @@ def test_up_with_timeout(self): self.assertFalse(config['AttachStdout']) self.assertFalse(config['AttachStdin']) + def test_up_handles_sigint(self): + proc = start_process(self.base_dir, ['up', '-t', '2']) + wait_on_condition(ContainerCountCondition(self.project, 2)) + + os.kill(proc.pid, signal.SIGINT) + 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)) + def test_run_service_without_links(self): self.base_dir = 'tests/fixtures/links-composefile' self.dispatch(['run', 'console', '/bin/true']) diff --git a/tests/unit/cli/main_test.py b/tests/unit/cli/main_test.py index ee837fcd45b..db37ac1af03 100644 --- a/tests/unit/cli/main_test.py +++ b/tests/unit/cli/main_test.py @@ -57,11 +57,11 @@ def test_attach_to_logs(self): with mock.patch('compose.cli.main.signal', autospec=True) as mock_signal: attach_to_logs(project, log_printer, service_names, timeout) - mock_signal.signal.assert_called_once_with(mock_signal.SIGINT, mock.ANY) + 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() - project.stop.assert_called_once_with( - service_names=service_names, - timeout=timeout) class SetupConsoleHandlerTestCase(unittest.TestCase): From 83760d0e9ee04ace2fa4da2efa13a3a933b62cd2 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 10 Nov 2015 19:43:05 -0500 Subject: [PATCH 1585/4072] Handle both SIGINT and SIGTERM for docker-compose run. Signed-off-by: Daniel Nephin --- compose/cli/main.py | 99 ++++++++++++++++++++---------------- tests/acceptance/cli_test.py | 47 +++++++++++++++++ 2 files changed, 102 insertions(+), 44 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 7b1e0aa35d4..9fef8d041b3 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -368,7 +368,6 @@ def run(self, project, options): allocates a TTY. """ service = project.get_service(options['SERVICE']) - detach = options['-d'] if IS_WINDOWS_PLATFORM and not detach: @@ -380,22 +379,6 @@ def run(self, project, options): if options['--allow-insecure-ssl']: log.warn(INSECURE_SSL_WARNING) - if not options['--no-deps']: - deps = service.get_linked_service_names() - - if len(deps) > 0: - project.up( - service_names=deps, - start_deps=True, - strategy=ConvergenceStrategy.never, - ) - elif project.use_networking: - project.ensure_network_exists() - - tty = True - if detach or options['-T'] or not sys.stdin.isatty(): - tty = False - if options['COMMAND']: command = [options['COMMAND']] + options['ARGS'] else: @@ -403,7 +386,7 @@ def run(self, project, options): container_options = { 'command': command, - 'tty': tty, + 'tty': not (detach or options['-T'] or not sys.stdin.isatty()), 'stdin_open': not detach, 'detach': detach, } @@ -435,31 +418,7 @@ def run(self, project, options): if options['--name']: container_options['name'] = options['--name'] - try: - container = service.create_container( - quiet=True, - one_off=True, - **container_options - ) - except APIError as e: - legacy.check_for_legacy_containers( - project.client, - project.name, - [service.name], - allow_one_off=False, - ) - - raise e - - if detach: - container.start() - print(container.name) - else: - dockerpty.start(project.client, container.id, interactive=not options['-T']) - exit_code = container.wait() - if options['--rm']: - project.client.remove_container(container.id) - sys.exit(exit_code) + run_one_off_container(container_options, project, service, options) def scale(self, project, options): """ @@ -647,6 +606,58 @@ def convergence_strategy_from_opts(options): return ConvergenceStrategy.changed +def run_one_off_container(container_options, project, service, options): + if not options['--no-deps']: + deps = service.get_linked_service_names() + if deps: + project.up( + service_names=deps, + start_deps=True, + strategy=ConvergenceStrategy.never) + + if project.use_networking: + project.ensure_network_exists() + + try: + container = service.create_container( + quiet=True, + one_off=True, + **container_options) + except APIError: + legacy.check_for_legacy_containers( + project.client, + project.name, + [service.name], + allow_one_off=False) + raise + + if options['-d']: + container.start() + print(container.name) + return + + def remove_container(force=False): + if options['--rm']: + project.client.remove_container(container.id, force=True) + + def force_shutdown(signal, frame): + project.client.kill(container.id) + remove_container(force=True) + sys.exit(2) + + def shutdown(signal, frame): + set_signal_handler(force_shutdown) + project.client.stop(container.id) + remove_container() + sys.exit(1) + + set_signal_handler(shutdown) + dockerpty.start(project.client, container.id, interactive=not options['-T']) + exit_code = container.wait() + remove_container() + sys.exit(exit_code) + + def build_log_printer(containers, service_names, monochrome): if service_names: containers = [ @@ -657,7 +668,6 @@ def build_log_printer(containers, service_names, monochrome): def attach_to_logs(project, log_printer, service_names, timeout): - print("Attaching to", list_containers(log_printer.containers)) def force_shutdown(signal, frame): project.kill(service_names=service_names) @@ -668,6 +678,7 @@ def shutdown(signal, frame): print("Gracefully stopping... (press Ctrl+C again to force)") project.stop(service_names=service_names, timeout=timeout) + print("Attaching to", list_containers(log_printer.containers)) set_signal_handler(shutdown) log_printer.run() diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 3fda83291a1..7ca6e8194b3 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -8,6 +8,8 @@ from collections import namedtuple from operator import attrgetter +from docker import errors + from .. import mock from compose.cli.command import get_project from compose.cli.docker_client import docker_client @@ -61,6 +63,25 @@ def __str__(self): return "waiting for counter count == %s" % self.expected +class ContainerStateCondition(object): + + def __init__(self, client, name, running): + self.client = client + self.name = name + self.running = running + + # State.Running == true + def __call__(self): + try: + container = self.client.inspect_container(self.name) + return container['State']['Running'] == self.running + except errors.APIError: + return False + + def __str__(self): + return "waiting for container to have state %s" % self.expected + + class CLITestCase(DockerClientTestCase): def setUp(self): @@ -554,6 +575,32 @@ def test_run_with_networking(self): self.assertEqual(len(networks), 1) self.assertEqual(container.human_readable_command, u'true') + def test_run_handles_sigint(self): + proc = start_process(self.base_dir, ['run', '-T', 'simple', 'top']) + wait_on_condition(ContainerStateCondition( + self.project.client, + 'simplecomposefile_simple_run_1', + running=True)) + + os.kill(proc.pid, signal.SIGINT) + wait_on_condition(ContainerStateCondition( + self.project.client, + 'simplecomposefile_simple_run_1', + running=False)) + + 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)) + + os.kill(proc.pid, signal.SIGTERM) + wait_on_condition(ContainerStateCondition( + self.project.client, + 'simplecomposefile_simple_run_1', + running=False)) + def test_rm(self): service = self.project.get_service('simple') service.create_container() From 8fb6fb7b19467e20189c710ebd7b65ec071d7adc Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 18 Nov 2015 14:51:01 -0500 Subject: [PATCH 1586/4072] Fix env_file and environment when used with extends. Signed-off-by: Daniel Nephin --- compose/config/config.py | 22 +++++++-------- tests/integration/testcases.py | 3 +- tests/unit/config/config_test.py | 48 ++++++++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 13 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 201266208a2..fa214767be7 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -257,16 +257,11 @@ def detect_cycle(self): def run(self): self.detect_cycle() - service_dict = dict(self.service_config.config) - env = resolve_environment(self.working_dir, self.service_config.config) - if env: - service_dict['environment'] = env - service_dict.pop('env_file', None) - - if 'extends' in service_dict: + if 'extends' in self.service_config.config: service_dict = self.resolve_extends(*self.validate_and_construct_extends()) + return self.service_config._replace(config=service_dict) - return self.service_config._replace(config=service_dict) + return self.service_config def validate_and_construct_extends(self): extends = self.service_config.config['extends'] @@ -316,16 +311,15 @@ def get_extended_config_path(self, extends_options): return filename -def resolve_environment(working_dir, service_dict): +def resolve_environment(service_config): """Unpack any environment variables from an env_file, if set. Interpolate environment values if set. """ - if 'environment' not in service_dict and 'env_file' not in service_dict: - return {} + service_dict = service_config.config env = {} if 'env_file' in service_dict: - for env_file in get_env_files(working_dir, service_dict): + for env_file in get_env_files(service_config.working_dir, service_dict): env.update(env_vars_from_file(env_file)) env.update(parse_environment(service_dict.get('environment'))) @@ -362,6 +356,10 @@ def process_service(service_config): working_dir = service_config.working_dir service_dict = dict(service_config.config) + if 'environment' in service_dict or 'env_file' in service_dict: + service_dict['environment'] = resolve_environment(service_config) + service_dict.pop('env_file', None) + if 'volumes' in service_dict and service_dict.get('volume_driver') is None: service_dict['volumes'] = resolve_volume_paths(working_dir, service_dict) diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index d63f0591603..de2d1a70156 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -46,7 +46,8 @@ def create_service(self, name, **kwargs): service_config = ServiceConfig('.', None, name, kwargs) options = process_service(service_config) - options['environment'] = resolve_environment('.', kwargs) + options['environment'] = resolve_environment( + service_config._replace(config=options)) labels = options.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 3038af80d0c..c69e34306c5 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1317,6 +1317,54 @@ def test_extended_service_with_verbose_and_shorthand_way(self): }, ])) + def test_extends_with_environment_and_env_files(self): + tmpdir = py.test.ensuretemp('test_extends_with_environment') + self.addCleanup(tmpdir.remove) + commondir = tmpdir.mkdir('common') + commondir.join('base.yml').write(""" + app: + image: 'example/app' + env_file: + - 'envs' + environment: + - SECRET + """) + tmpdir.join('docker-compose.yml').write(""" + ext: + extends: + file: common/base.yml + service: app + env_file: + - 'envs' + environment: + - THING + """) + commondir.join('envs').write(""" + COMMON_ENV_FILE=1 + """) + tmpdir.join('envs').write(""" + FROM_ENV_FILE=1 + """) + + expected = [ + { + 'name': 'ext', + 'image': 'example/app', + 'environment': { + 'SECRET': 'secret', + 'FROM_ENV_FILE': '1', + 'COMMON_ENV_FILE': '1', + 'THING': 'thing', + }, + }, + ] + with mock.patch.dict(os.environ): + os.environ['SECRET'] = 'secret' + os.environ['THING'] = 'thing' + config = load_from_filename(str(tmpdir.join('docker-compose.yml'))) + + assert config == expected + @pytest.mark.xfail(IS_WINDOWS_PLATFORM, reason='paths use slash') class ExpandPathTest(unittest.TestCase): From 9ce402495160c126ea0ec43c24b2c2f03ca56f2f Mon Sep 17 00:00:00 2001 From: Brandon Burton Date: Fri, 20 Nov 2015 16:02:37 -0800 Subject: [PATCH 1587/4072] Fixing matrix include so `os: linux` goes to trusty Signed-off-by: Brandon Burton --- .travis.yml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.travis.yml b/.travis.yml index 3310e2ad9ff..3bb365a1401 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,16 +2,14 @@ sudo: required language: python -services: - - docker - matrix: include: - os: linux + services: + - docker - os: osx language: generic - install: ./script/travis/install script: From 210a14cf28fa5e537cf81ffd23d5a29f86a1e298 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 19 Nov 2015 12:55:03 -0500 Subject: [PATCH 1588/4072] Add note about required pip version. Signed-off-by: Daniel Nephin --- docs/install.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/install.md b/docs/install.md index f8c9db63836..5bbd6e595e1 100644 --- a/docs/install.md +++ b/docs/install.md @@ -70,6 +70,7 @@ to get started. $ pip install docker-compose +> **Note:** pip version 6.0 or greater is required ### Install as a container From 844e2c3d268e2af8cdce556b1b53f8658c6e4881 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 19 Nov 2015 14:52:50 -0500 Subject: [PATCH 1589/4072] Fix use case link in readme. Signed-off-by: Daniel Nephin --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4c967aebcc2..b8a3f465994 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](#common-use-cases). +[Common Use Cases](docs/index.md#common-use-cases). Using Compose is basically a three-step process. From a264470cc05bd76fee567294e8801dfe7dadcdcf Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 20 Nov 2015 17:51:36 -0500 Subject: [PATCH 1590/4072] Make sure we always have the latest busybox image, so that build --pull tests don't flake. Signed-off-by: Daniel Nephin --- tests/acceptance/cli_test.py | 5 +++++ tests/integration/testcases.py | 6 +----- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 7ca6e8194b3..88ec457388e 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -15,6 +15,7 @@ from compose.cli.docker_client import docker_client from compose.container import Container from tests.integration.testcases import DockerClientTestCase +from tests.integration.testcases import pull_busybox ProcessResult = namedtuple('ProcessResult', 'stdout stderr') @@ -184,6 +185,8 @@ def test_build_no_cache(self): assert BUILD_PULL_TEXT not in result.stdout def test_build_pull(self): + # Make sure we have the latest busybox already + pull_busybox(self.client) self.base_dir = 'tests/fixtures/simple-dockerfile' self.dispatch(['build', 'simple'], None) @@ -192,6 +195,8 @@ def test_build_pull(self): assert BUILD_PULL_TEXT in result.stdout def test_build_no_cache_pull(self): + # Make sure we have the latest busybox already + pull_busybox(self.client) self.base_dir = 'tests/fixtures/simple-dockerfile' self.dispatch(['build', 'simple']) diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index de2d1a70156..2c5ca9fdd45 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -1,7 +1,6 @@ from __future__ import absolute_import from __future__ import unicode_literals -from docker import errors from docker.utils import version_lt from pytest import skip @@ -16,10 +15,7 @@ def pull_busybox(client): - try: - client.inspect_image('busybox:latest') - except errors.APIError: - client.pull('busybox:latest', stream=False) + client.pull('busybox:latest', stream=False) class DockerClientTestCase(unittest.TestCase): From 3b6cc7a7bbf6ce79fff98ca43b10778913a059dd Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 24 Nov 2015 10:40:36 -0500 Subject: [PATCH 1591/4072] Add missing assert and autospec. Signed-off-by: Daniel Nephin --- tests/unit/service_test.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 808c391cd2a..85d1479d5b1 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -757,7 +757,7 @@ def test_warn_on_masked_volume_no_warning_when_no_container_volumes(self): container_volumes = [] service = 'service_name' - with mock.patch('compose.service.log') as mock_log: + with mock.patch('compose.service.log', autospec=True) as mock_log: warn_on_masked_volume(volumes_option, container_volumes, service) assert not mock_log.warn.called @@ -770,17 +770,17 @@ def test_warn_on_masked_volume_when_masked(self): ] service = 'service_name' - with mock.patch('compose.service.log') as mock_log: + with mock.patch('compose.service.log', autospec=True) as mock_log: warn_on_masked_volume(volumes_option, container_volumes, service) - mock_log.warn.called_once_with(mock.ANY) + mock_log.warn.assert_called_once_with(mock.ANY) def test_warn_on_masked_no_warning_with_same_path(self): volumes_option = [VolumeSpec('/home/user', '/path', 'rw')] container_volumes = [VolumeSpec('/home/user', '/path', 'rw')] service = 'service_name' - with mock.patch('compose.service.log') as mock_log: + with mock.patch('compose.service.log', autospec=True) as mock_log: warn_on_masked_volume(volumes_option, container_volumes, service) assert not mock_log.warn.called From bea2072b95a2fe679c2d33a2d6c6672dacc4a52f Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 12 Nov 2015 17:29:58 -0500 Subject: [PATCH 1592/4072] Add the git sha to version output Signed-off-by: Daniel Nephin --- .gitignore | 1 + Dockerfile.run | 2 +- MANIFEST.in | 1 + compose/cli/command.py | 4 ++-- compose/cli/utils.py | 39 +++++++++++++++++++++++++++---------- docker-compose.spec | 24 ++++++++++++++++++----- script/build-image | 1 + script/build-linux | 1 + script/build-linux-inner | 1 + script/build-osx | 1 + script/build-windows.ps1 | 2 ++ script/release/push-release | 1 + script/write-git-sha | 7 +++++++ 13 files changed, 67 insertions(+), 18 deletions(-) create mode 100755 script/write-git-sha diff --git a/.gitignore b/.gitignore index 83a08a0e697..da72827974f 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ /docs/_site /venv README.rst +compose/GITSHA diff --git a/Dockerfile.run b/Dockerfile.run index 9f3745fefcc..792077ad7fe 100644 --- a/Dockerfile.run +++ b/Dockerfile.run @@ -8,6 +8,6 @@ COPY requirements.txt /code/requirements.txt RUN pip install -r /code/requirements.txt ADD dist/docker-compose-release.tar.gz /code/docker-compose -RUN pip install /code/docker-compose/docker-compose-* +RUN pip install --no-deps /code/docker-compose/docker-compose-* ENTRYPOINT ["/usr/bin/docker-compose"] diff --git a/MANIFEST.in b/MANIFEST.in index 0342e35bea5..8c6f932bab5 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -7,6 +7,7 @@ include *.md exclude README.md include README.rst include compose/config/*.json +include compose/GITSHA recursive-include contrib/completion * recursive-include tests * global-exclude *.pyc diff --git a/compose/cli/command.py b/compose/cli/command.py index 525217ee75a..6094b5305b5 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -12,12 +12,12 @@ from . import errors from . import verbose_proxy -from .. import __version__ from .. import config from ..project import Project from ..service import ConfigError 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 @@ -71,7 +71,7 @@ def get_client(verbose=False, version=None): client = docker_client(version=version) if verbose: version_info = six.iteritems(client.version()) - log.info("Compose version %s", __version__) + log.info(get_version_info('full')) log.info("Docker base_url: %s", client.base_url) log.info("Docker version: %s", ", ".join("%s=%s" % item for item in version_info)) diff --git a/compose/cli/utils.py b/compose/cli/utils.py index 07510e2f31c..dd859edc4b7 100644 --- a/compose/cli/utils.py +++ b/compose/cli/utils.py @@ -7,10 +7,10 @@ import ssl import subprocess -from docker import version as docker_py_version +import docker from six.moves import input -from .. import __version__ +import compose def yesno(prompt, default=None): @@ -57,13 +57,32 @@ def is_ubuntu(): def get_version_info(scope): - versioninfo = 'docker-compose version: %s' % __version__ + versioninfo = 'docker-compose version {}, build {}'.format( + compose.__version__, + get_build_version()) + if scope == 'compose': return versioninfo - elif scope == 'full': - return versioninfo + '\n' \ - + "docker-py version: %s\n" % docker_py_version \ - + "%s version: %s\n" % (platform.python_implementation(), platform.python_version()) \ - + "OpenSSL version: %s" % ssl.OPENSSL_VERSION - else: - raise RuntimeError('passed unallowed value to `cli.utils.get_version_info`') + if scope == 'full': + return ( + "{}\n" + "docker-py version: {}\n" + "{} version: {}\n" + "OpenSSL version: {}" + ).format( + versioninfo, + docker.version, + platform.python_implementation(), + platform.python_version(), + ssl.OPENSSL_VERSION) + + raise ValueError("{} is not a valid version scope".format(scope)) + + +def get_build_version(): + filename = os.path.join(os.path.dirname(compose.__file__), 'GITSHA') + if not os.path.exists(filename): + return 'unknown' + + with open(filename) as fh: + return fh.read().strip() diff --git a/docker-compose.spec b/docker-compose.spec index 678fc132386..24d03e05b2e 100644 --- a/docker-compose.spec +++ b/docker-compose.spec @@ -9,18 +9,32 @@ a = Analysis(['bin/docker-compose'], runtime_hooks=None, cipher=block_cipher) -pyz = PYZ(a.pure, - cipher=block_cipher) +pyz = PYZ(a.pure, cipher=block_cipher) exe = EXE(pyz, a.scripts, a.binaries, a.zipfiles, a.datas, - [('compose/config/fields_schema.json', 'compose/config/fields_schema.json', 'DATA')], - [('compose/config/service_schema.json', 'compose/config/service_schema.json', 'DATA')], + [ + ( + 'compose/config/fields_schema.json', + 'compose/config/fields_schema.json', + 'DATA' + ), + ( + 'compose/config/service_schema.json', + 'compose/config/service_schema.json', + 'DATA' + ), + ( + 'compose/GITSHA', + 'compose/GITSHA', + 'DATA' + ) + ], name='docker-compose', debug=False, strip=None, upx=True, - console=True ) + console=True) diff --git a/script/build-image b/script/build-image index 3ac9729b47a..897335054f8 100755 --- a/script/build-image +++ b/script/build-image @@ -10,6 +10,7 @@ fi TAG=$1 VERSION="$(python setup.py --version)" +./script/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 b/script/build-linux index ade18bc5350..47fb45e1749 100755 --- a/script/build-linux +++ b/script/build-linux @@ -9,4 +9,5 @@ docker build -t "$TAG" . | tail -n 200 docker run \ --rm --entrypoint="script/build-linux-inner" \ -v $(pwd)/dist:/code/dist \ + -v $(pwd)/.git:/code/.git \ "$TAG" diff --git a/script/build-linux-inner b/script/build-linux-inner index 47d5eb2e7e8..9bf7c95d9c7 100755 --- a/script/build-linux-inner +++ b/script/build-linux-inner @@ -9,6 +9,7 @@ mkdir -p `pwd`/dist chmod 777 `pwd`/dist $VENV/bin/pip install -q -r requirements-build.txt +./script/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 042964e4beb..168fd43092d 100755 --- a/script/build-osx +++ b/script/build-osx @@ -9,6 +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 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/build-windows.ps1 b/script/build-windows.ps1 index 42a4a501c10..28011b1db2a 100644 --- a/script/build-windows.ps1 +++ b/script/build-windows.ps1 @@ -47,6 +47,8 @@ virtualenv .\venv .\venv\Scripts\pip install --no-deps . .\venv\Scripts\pip install --allow-external pyinstaller -r requirements-build.txt +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" diff --git a/script/release/push-release b/script/release/push-release index ccdf2496077..b754d40f04d 100755 --- a/script/release/push-release +++ b/script/release/push-release @@ -57,6 +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 python setup.py sdist if [ "$(command -v twine 2> /dev/null)" ]; then twine upload ./dist/docker-compose-${VERSION}.tar.gz diff --git a/script/write-git-sha b/script/write-git-sha new file mode 100755 index 00000000000..d16743c6f10 --- /dev/null +++ b/script/write-git-sha @@ -0,0 +1,7 @@ +#!/bin/bash +# +# Write the current commit sha to the file GITSHA. This file is included in +# packaging so that `docker-compose version` can include the git sha. +# +set -e +git rev-parse --short HEAD > compose/GITSHA From 7e21b05f057911c11c447a463e5ab3e392a16640 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 13 Nov 2015 17:39:02 -0500 Subject: [PATCH 1593/4072] Remove project name validation project name is already normalized to a valid name before creating a service. Signed-off-by: Daniel Nephin --- compose/service.py | 4 ---- tests/unit/service_test.py | 5 ----- 2 files changed, 9 deletions(-) diff --git a/compose/service.py b/compose/service.py index b79fd900108..6fe0058fb09 100644 --- a/compose/service.py +++ b/compose/service.py @@ -18,7 +18,6 @@ from . import __version__ from .config import DOCKER_CONFIG_KEYS from .config import merge_environment -from .config.validation import VALID_NAME_CHARS from .const import DEFAULT_TIMEOUT from .const import IS_WINDOWS_PLATFORM from .const import LABEL_CONFIG_HASH @@ -119,9 +118,6 @@ def __init__( net=None, **options ): - if not re.match('^%s+$' % VALID_NAME_CHARS, project): - raise ConfigError('Invalid project name "%s" - only %s are allowed' % (project, VALID_NAME_CHARS)) - self.name = name self.client = client self.project = project diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 85d1479d5b1..98da5f186d9 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -35,11 +35,6 @@ class ServiceTest(unittest.TestCase): def setUp(self): self.mock_client = mock.create_autospec(docker.Client) - def test_project_validation(self): - self.assertRaises(ConfigError, lambda: Service(name='foo', project='>', image='foo')) - - Service(name='foo', project='bar.bar__', image='foo') - def test_containers(self): service = Service('db', self.mock_client, 'myproject', image='foo') self.mock_client.containers.return_value = [] From e549875e896ac49f203463f5a2997b2b0297e00e Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 13 Nov 2015 18:20:09 -0500 Subject: [PATCH 1594/4072] Move parsing of volumes_from to the last step of config parsing. Includes creating a new compose.config.types module for all the domain objects. Signed-off-by: Daniel Nephin --- compose/config/config.py | 19 +++++++++++++++++++ compose/config/types.py | 28 ++++++++++++++++++++++++++++ compose/project.py | 18 ++++++------------ compose/service.py | 19 +------------------ tests/integration/project_test.py | 2 +- tests/integration/service_test.py | 2 +- tests/unit/project_test.py | 23 +++++++++++++---------- tests/unit/service_test.py | 1 + tests/unit/sort_service_test.py | 7 ++++--- 9 files changed, 74 insertions(+), 45 deletions(-) create mode 100644 compose/config/types.py diff --git a/compose/config/config.py b/compose/config/config.py index fa214767be7..b21e639ff80 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -1,3 +1,5 @@ +from __future__ import absolute_import + import codecs import logging import os @@ -11,6 +13,7 @@ from .errors import ComposeFileNotFound from .errors import ConfigurationError from .interpolation import interpolate_environment_variables +from .types import VolumeFromSpec from .validation import validate_against_fields_schema from .validation import validate_against_service_schema from .validation import validate_extends_file_path @@ -197,8 +200,12 @@ def build_service(filename, service_name, service_dict): service_dict) resolver = ServiceExtendsResolver(service_config) service_dict = process_service(resolver.run()) + + # TODO: move to validate_service() validate_against_service_schema(service_dict, service_config.name) validate_paths(service_dict) + + service_dict = finalize_service(service_config._replace(config=service_dict)) service_dict['name'] = service_config.name return service_dict @@ -352,6 +359,7 @@ def validate_ulimits(ulimit_config): "than 'hard' value".format(ulimit_config)) +# TODO: rename to normalize_service def process_service(service_config): working_dir = service_config.working_dir service_dict = dict(service_config.config) @@ -369,12 +377,23 @@ def process_service(service_config): if 'labels' in service_dict: service_dict['labels'] = parse_labels(service_dict['labels']) + # TODO: move to a validate_service() if 'ulimits' in service_dict: validate_ulimits(service_dict['ulimits']) return service_dict +def finalize_service(service_config): + service_dict = dict(service_config.config) + + if 'volumes_from' in service_dict: + service_dict['volumes_from'] = [ + VolumeFromSpec.parse(vf) for vf in service_dict['volumes_from']] + + return service_dict + + def merge_service_dicts_from_files(base, override): """When merging services from multiple files we need to merge the `extends` field. This is not handled by `merge_service_dicts()` which is used to diff --git a/compose/config/types.py b/compose/config/types.py new file mode 100644 index 00000000000..73bfd4184ce --- /dev/null +++ b/compose/config/types.py @@ -0,0 +1,28 @@ +""" +Types for objects parsed from the configuration. +""" +from __future__ import absolute_import +from __future__ import unicode_literals + +from collections import namedtuple + +from compose.config.errors import ConfigurationError + + +class VolumeFromSpec(namedtuple('_VolumeFromSpec', 'source mode')): + + @classmethod + def parse(cls, volume_from_config): + parts = volume_from_config.split(':') + if len(parts) > 2: + raise ConfigurationError( + "volume_from {} has incorrect format, should be " + "service[:mode]".format(volume_from_config)) + + if len(parts) == 1: + source = parts[0] + mode = 'rw' + else: + source, mode = parts + + return cls(source, mode) diff --git a/compose/project.py b/compose/project.py index 41af8626151..69f08475270 100644 --- a/compose/project.py +++ b/compose/project.py @@ -18,10 +18,8 @@ from .service import ContainerNet from .service import ConvergenceStrategy from .service import Net -from .service import parse_volume_from_spec from .service import Service from .service import ServiceNet -from .service import VolumeFromSpec from .utils import parallel_execute @@ -38,10 +36,7 @@ def get_service_names(links): return [link.split(':')[0] for link in links] def get_service_names_from_volumes_from(volumes_from): - return [ - parse_volume_from_spec(volume_from).source - for volume_from in volumes_from - ] + return [volume_from.source for volume_from in volumes_from] def get_service_dependents(service_dict, services): name = service_dict['name'] @@ -192,16 +187,15 @@ def get_links(self, service_dict): def get_volumes_from(self, service_dict): volumes_from = [] if 'volumes_from' in service_dict: - for volume_from_config in service_dict.get('volumes_from', []): - volume_from_spec = parse_volume_from_spec(volume_from_config) + for volume_from_spec in service_dict.get('volumes_from', []): # Get service try: - service_name = self.get_service(volume_from_spec.source) - volume_from_spec = VolumeFromSpec(service_name, volume_from_spec.mode) + service = self.get_service(volume_from_spec.source) + volume_from_spec = volume_from_spec._replace(source=service) except NoSuchService: try: - container_name = Container.from_id(self.client, volume_from_spec.source) - volume_from_spec = VolumeFromSpec(container_name, volume_from_spec.mode) + container = Container.from_id(self.client, volume_from_spec.source) + volume_from_spec = volume_from_spec._replace(source=container) except APIError: raise ConfigurationError( 'Service "%s" mounts volumes from "%s", which is ' diff --git a/compose/service.py b/compose/service.py index 6fe0058fb09..3d41037773e 100644 --- a/compose/service.py +++ b/compose/service.py @@ -67,6 +67,7 @@ def __init__(self, service, reason): self.reason = reason +# TODO: remove class ConfigError(ValueError): pass @@ -83,9 +84,6 @@ class NoSuchImageError(Exception): VolumeSpec = namedtuple('VolumeSpec', 'external internal mode') -VolumeFromSpec = namedtuple('VolumeFromSpec', 'source mode') - - ServiceName = namedtuple('ServiceName', 'project service number') @@ -1044,21 +1042,6 @@ def build_volume_from(volume_from_spec): return ["{}:{}".format(volume_from_spec.source.id, volume_from_spec.mode)] -def parse_volume_from_spec(volume_from_config): - parts = volume_from_config.split(':') - if len(parts) > 2: - raise ConfigError("Volume %s has incorrect format, should be " - "external:internal[:mode]" % volume_from_config) - - if len(parts) == 1: - source = parts[0] - mode = 'rw' - else: - source, mode = parts - - return VolumeFromSpec(source, mode) - - # Labels diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 2ce319005fa..d65d7ef0cfa 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -3,12 +3,12 @@ from .testcases import DockerClientTestCase from compose.cli.docker_client import docker_client from compose.config import config +from compose.config.types import VolumeFromSpec from compose.const import LABEL_PROJECT from compose.container import Container from compose.project import Project from compose.service import ConvergenceStrategy from compose.service import Net -from compose.service import VolumeFromSpec def build_service_dicts(service_config): diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index aaa4f01ec07..7fbaae8c65c 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -14,6 +14,7 @@ from .testcases import DockerClientTestCase from .testcases import pull_busybox from compose import __version__ +from compose.config.types import VolumeFromSpec from compose.const import LABEL_CONFIG_HASH from compose.const import LABEL_CONTAINER_NUMBER from compose.const import LABEL_ONE_OFF @@ -27,7 +28,6 @@ from compose.service import ConvergenceStrategy from compose.service import Net from compose.service import Service -from compose.service import VolumeFromSpec def create_and_start_container(service, **override_options): diff --git a/tests/unit/project_test.py b/tests/unit/project_test.py index b38f5c783c8..f8178ed8b9a 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -4,6 +4,7 @@ from .. import mock from .. import unittest +from compose.config.types import VolumeFromSpec from compose.const import LABEL_SERVICE from compose.container import Container from compose.project import Project @@ -43,7 +44,7 @@ def test_from_dict_sorts_in_dependency_order(self): { 'name': 'db', 'image': 'busybox:latest', - 'volumes_from': ['volume'] + 'volumes_from': [VolumeFromSpec('volume', 'ro')] }, { 'name': 'volume', @@ -167,7 +168,7 @@ def test_use_volumes_from_container(self): { 'name': 'test', 'image': 'busybox:latest', - 'volumes_from': ['aaa'] + 'volumes_from': [VolumeFromSpec('aaa', 'rw')] } ], self.mock_client) self.assertEqual(project.get_service('test')._get_volumes_from(), [container_id + ":rw"]) @@ -190,17 +191,13 @@ def test_use_volumes_from_service_no_container(self): { 'name': 'test', 'image': 'busybox:latest', - 'volumes_from': ['vol'] + 'volumes_from': [VolumeFromSpec('vol', 'rw')] } ], self.mock_client) self.assertEqual(project.get_service('test')._get_volumes_from(), [container_name + ":rw"]) - @mock.patch.object(Service, 'containers') - def test_use_volumes_from_service_container(self, mock_return): + def test_use_volumes_from_service_container(self): container_ids = ['aabbccddee', '12345'] - mock_return.return_value = [ - mock.Mock(id=container_id, spec=Container) - for container_id in container_ids] project = Project.from_dicts('test', [ { @@ -210,10 +207,16 @@ def test_use_volumes_from_service_container(self, mock_return): { 'name': 'test', 'image': 'busybox:latest', - 'volumes_from': ['vol'] + 'volumes_from': [VolumeFromSpec('vol', 'rw')] } ], None) - self.assertEqual(project.get_service('test')._get_volumes_from(), [container_ids[0] + ':rw']) + with mock.patch.object(Service, 'containers') as mock_return: + mock_return.return_value = [ + mock.Mock(id=container_id, spec=Container) + for container_id in container_ids] + self.assertEqual( + project.get_service('test')._get_volumes_from(), + [container_ids[0] + ':rw']) def test_net_unset(self): project = Project.from_dicts('test', [ diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 98da5f186d9..83dd61589b7 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -6,6 +6,7 @@ from .. import mock from .. import unittest +from compose.config.types import VolumeFromSpec from compose.const import IS_WINDOWS_PLATFORM from compose.const import LABEL_CONFIG_HASH from compose.const import LABEL_ONE_OFF diff --git a/tests/unit/sort_service_test.py b/tests/unit/sort_service_test.py index a7e522a1dd6..ef08828776e 100644 --- a/tests/unit/sort_service_test.py +++ b/tests/unit/sort_service_test.py @@ -1,4 +1,5 @@ from .. import unittest +from compose.config.types import VolumeFromSpec from compose.project import DependencyError from compose.project import sort_service_dicts @@ -73,7 +74,7 @@ def test_sort_service_dicts_4(self): }, { 'name': 'parent', - 'volumes_from': ['child'] + 'volumes_from': [VolumeFromSpec('child', 'rw')] }, { 'links': ['parent'], @@ -116,7 +117,7 @@ def test_sort_service_dicts_6(self): }, { 'name': 'parent', - 'volumes_from': ['child'] + 'volumes_from': [VolumeFromSpec('child', 'ro')] }, { 'name': 'child' @@ -141,7 +142,7 @@ def test_sort_service_dicts_7(self): }, { 'name': 'two', - 'volumes_from': ['one'] + 'volumes_from': [VolumeFromSpec('one', 'rw')] }, { 'name': 'one' From b19315b57ea4bd3b4594ca4b5c8abf9d18a3d8a0 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 13 Nov 2015 18:29:25 -0500 Subject: [PATCH 1595/4072] Move restart spec to the config.types module. Signed-off-by: Daniel Nephin --- compose/config/config.py | 4 ++++ compose/config/types.py | 17 +++++++++++++++++ compose/service.py | 22 +--------------------- tests/integration/service_test.py | 24 ++++++------------------ tests/unit/cli_test.py | 2 +- 5 files changed, 29 insertions(+), 40 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index b21e639ff80..8b36c680653 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -13,6 +13,7 @@ from .errors import ComposeFileNotFound from .errors import ConfigurationError from .interpolation import interpolate_environment_variables +from .types import parse_restart_spec from .types import VolumeFromSpec from .validation import validate_against_fields_schema from .validation import validate_against_service_schema @@ -391,6 +392,9 @@ def finalize_service(service_config): service_dict['volumes_from'] = [ VolumeFromSpec.parse(vf) for vf in service_dict['volumes_from']] + if 'restart' in service_dict: + service_dict['restart'] = parse_restart_spec(service_dict['restart']) + return service_dict diff --git a/compose/config/types.py b/compose/config/types.py index 73bfd4184ce..0ab53c825fd 100644 --- a/compose/config/types.py +++ b/compose/config/types.py @@ -26,3 +26,20 @@ def parse(cls, volume_from_config): source, mode = parts return cls(source, mode) + + +def parse_restart_spec(restart_config): + if not restart_config: + return None + parts = restart_config.split(':') + if len(parts) > 2: + raise ConfigurationError( + "Restart %s has incorrect format, should be " + "mode[:max_retry]" % restart_config) + if len(parts) == 2: + name, max_retry_count = parts + else: + name, = parts + max_retry_count = 0 + + return {'Name': name, 'MaximumRetryCount': int(max_retry_count)} diff --git a/compose/service.py b/compose/service.py index 3d41037773e..85b6004bcb4 100644 --- a/compose/service.py +++ b/compose/service.py @@ -663,8 +663,6 @@ def _get_container_host_config(self, override_options, one_off=False): if isinstance(dns_search, six.string_types): dns_search = [dns_search] - restart = parse_restart_spec(options.get('restart', None)) - extra_hosts = build_extra_hosts(options.get('extra_hosts', None)) read_only = options.get('read_only', None) @@ -682,7 +680,7 @@ def _get_container_host_config(self, override_options, one_off=False): devices=devices, dns=dns, dns_search=dns_search, - restart_policy=restart, + restart_policy=options.get('restart'), cap_add=cap_add, cap_drop=cap_drop, mem_limit=options.get('mem_limit'), @@ -1058,24 +1056,6 @@ def build_container_labels(label_options, service_labels, number, config_hash): return labels -# Restart policy - - -def parse_restart_spec(restart_config): - if not restart_config: - return None - parts = restart_config.split(':') - if len(parts) > 2: - raise ConfigError("Restart %s has incorrect format, should be " - "mode[:max_retry]" % restart_config) - if len(parts) == 2: - name, max_retry_count = parts - else: - name, = parts - max_retry_count = 0 - - return {'Name': name, 'MaximumRetryCount': int(max_retry_count)} - # Ulimits diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 7fbaae8c65c..87744ad56f7 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -779,23 +779,21 @@ def test_dns_no_value(self): container = create_and_start_container(service) self.assertIsNone(container.get('HostConfig.Dns')) - def test_dns_single_value(self): - service = self.create_service('web', dns='8.8.8.8') - container = create_and_start_container(service) - self.assertEqual(container.get('HostConfig.Dns'), ['8.8.8.8']) - def test_dns_list(self): service = self.create_service('web', dns=['8.8.8.8', '9.9.9.9']) container = create_and_start_container(service) self.assertEqual(container.get('HostConfig.Dns'), ['8.8.8.8', '9.9.9.9']) def test_restart_always_value(self): - service = self.create_service('web', restart='always') + service = self.create_service('web', restart={'Name': 'always'}) container = create_and_start_container(service) self.assertEqual(container.get('HostConfig.RestartPolicy.Name'), 'always') def test_restart_on_failure_value(self): - service = self.create_service('web', restart='on-failure:5') + service = self.create_service('web', restart={ + 'Name': 'on-failure', + 'MaximumRetryCount': 5 + }) container = create_and_start_container(service) self.assertEqual(container.get('HostConfig.RestartPolicy.Name'), 'on-failure') self.assertEqual(container.get('HostConfig.RestartPolicy.MaximumRetryCount'), 5) @@ -810,17 +808,7 @@ def test_cap_drop_list(self): container = create_and_start_container(service) self.assertEqual(container.get('HostConfig.CapDrop'), ['SYS_ADMIN', 'NET_ADMIN']) - def test_dns_search_no_value(self): - service = self.create_service('web') - container = create_and_start_container(service) - self.assertIsNone(container.get('HostConfig.DnsSearch')) - - def test_dns_search_single_value(self): - service = self.create_service('web', dns_search='example.com') - container = create_and_start_container(service) - self.assertEqual(container.get('HostConfig.DnsSearch'), ['example.com']) - - def test_dns_search_list(self): + def test_dns_search(self): service = self.create_service('web', dns_search=['dc1.example.com', 'dc2.example.com']) container = create_and_start_container(service) self.assertEqual(container.get('HostConfig.DnsSearch'), ['dc1.example.com', 'dc2.example.com']) diff --git a/tests/unit/cli_test.py b/tests/unit/cli_test.py index 5b63d2e84a4..23dc42629c0 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -124,7 +124,7 @@ def test_run_service_with_restart_always(self): mock_project.get_service.return_value = Service( 'service', client=mock_client, - restart='always', + restart={'Name': 'always', 'MaximumRetryCount': 0}, image='someimage') command.run(mock_project, { 'SERVICE': 'service', From 5d39813e1bbf3b25f60c1e230d2493d5b3b5be37 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 13 Nov 2015 18:58:24 -0500 Subject: [PATCH 1596/4072] Fixes #2008 - re-use list_or_dict schema for all the types At the same time, moves extra_hosts validation to the config module. Signed-off-by: Daniel Nephin --- compose/config/config.py | 4 ++++ compose/config/fields_schema.json | 31 +++++++++++--------------- compose/config/types.py | 16 ++++++++++++++ compose/config/validation.py | 4 ++-- compose/service.py | 36 +++---------------------------- tests/integration/service_test.py | 33 ---------------------------- tests/unit/config/config_test.py | 25 ++++++++++++++++++++- tests/unit/config/types_test.py | 29 +++++++++++++++++++++++++ 8 files changed, 90 insertions(+), 88 deletions(-) create mode 100644 tests/unit/config/types_test.py diff --git a/compose/config/config.py b/compose/config/config.py index 8b36c680653..893784f8610 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -13,6 +13,7 @@ from .errors import ComposeFileNotFound from .errors import ConfigurationError from .interpolation import interpolate_environment_variables +from .types import parse_extra_hosts from .types import parse_restart_spec from .types import VolumeFromSpec from .validation import validate_against_fields_schema @@ -378,6 +379,9 @@ def process_service(service_config): if 'labels' in service_dict: service_dict['labels'] = parse_labels(service_dict['labels']) + if 'extra_hosts' in service_dict: + service_dict['extra_hosts'] = parse_extra_hosts(service_dict['extra_hosts']) + # TODO: move to a validate_service() if 'ulimits' in service_dict: validate_ulimits(service_dict['ulimits']) diff --git a/compose/config/fields_schema.json b/compose/config/fields_schema.json index ca3b3a5029e..9cbcfd1b268 100644 --- a/compose/config/fields_schema.json +++ b/compose/config/fields_schema.json @@ -37,22 +37,7 @@ "domainname": {"type": "string"}, "entrypoint": {"$ref": "#/definitions/string_or_list"}, "env_file": {"$ref": "#/definitions/string_or_list"}, - - "environment": { - "oneOf": [ - { - "type": "object", - "patternProperties": { - ".+": { - "type": ["string", "number", "boolean", "null"], - "format": "environment" - } - }, - "additionalProperties": false - }, - {"type": "array", "items": {"type": "string"}, "uniqueItems": true} - ] - }, + "environment": {"$ref": "#/definitions/list_or_dict"}, "expose": { "type": "array", @@ -165,10 +150,18 @@ "list_or_dict": { "oneOf": [ - {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, - {"type": "object"} + { + "type": "object", + "patternProperties": { + ".+": { + "type": ["string", "number", "boolean", "null"], + "format": "bool-value-in-mapping" + } + }, + "additionalProperties": false + }, + {"type": "array", "items": {"type": "string"}, "uniqueItems": true} ] } - } } diff --git a/compose/config/types.py b/compose/config/types.py index 0ab53c825fd..b6add0894af 100644 --- a/compose/config/types.py +++ b/compose/config/types.py @@ -43,3 +43,19 @@ def parse_restart_spec(restart_config): max_retry_count = 0 return {'Name': name, 'MaximumRetryCount': int(max_retry_count)} + + +def parse_extra_hosts(extra_hosts_config): + if not extra_hosts_config: + return {} + + if isinstance(extra_hosts_config, dict): + return dict(extra_hosts_config) + + if isinstance(extra_hosts_config, list): + extra_hosts_dict = {} + for extra_hosts_line in extra_hosts_config: + # TODO: validate string contains ':' ? + host, ip = extra_hosts_line.split(':') + extra_hosts_dict[host.strip()] = ip.strip() + return extra_hosts_dict diff --git a/compose/config/validation.py b/compose/config/validation.py index 38866b0f4fe..38020366d75 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -49,7 +49,7 @@ def format_ports(instance): return True -@FormatChecker.cls_checks(format="environment") +@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. @@ -273,7 +273,7 @@ def validate_against_fields_schema(config, filename): _validate_against_schema( config, "fields_schema.json", - format_checker=["ports", "environment"], + format_checker=["ports", "bool-value-in-mapping"], filename=filename) diff --git a/compose/service.py b/compose/service.py index 85b6004bcb4..dd243364617 100644 --- a/compose/service.py +++ b/compose/service.py @@ -655,6 +655,7 @@ def _get_container_host_config(self, override_options, one_off=False): pid = options.get('pid', None) security_opt = options.get('security_opt', None) + # TODO: these options are already normalized by config dns = options.get('dns', None) if isinstance(dns, six.string_types): dns = [dns] @@ -663,9 +664,6 @@ def _get_container_host_config(self, override_options, one_off=False): if isinstance(dns_search, six.string_types): dns_search = [dns_search] - extra_hosts = build_extra_hosts(options.get('extra_hosts', None)) - read_only = options.get('read_only', None) - devices = options.get('devices', None) cgroup_parent = options.get('cgroup_parent', None) ulimits = build_ulimits(options.get('ulimits', None)) @@ -687,8 +685,8 @@ def _get_container_host_config(self, override_options, one_off=False): memswap_limit=options.get('memswap_limit'), ulimits=ulimits, log_config=log_config, - extra_hosts=extra_hosts, - read_only=read_only, + extra_hosts=options.get('extra_hosts'), + read_only=options.get('read_only'), pid_mode=pid, security_opt=security_opt, ipc_mode=options.get('ipc'), @@ -1072,31 +1070,3 @@ def build_ulimits(ulimit_config): ulimits.append(ulimit_dict) return ulimits - - -# Extra hosts - - -def build_extra_hosts(extra_hosts_config): - if not extra_hosts_config: - return {} - - if isinstance(extra_hosts_config, list): - extra_hosts_dict = {} - for extra_hosts_line in extra_hosts_config: - if not isinstance(extra_hosts_line, six.string_types): - raise ConfigError( - "extra_hosts_config \"%s\" must be either a list of strings or a string->string mapping," % - extra_hosts_config - ) - host, ip = extra_hosts_line.split(':') - extra_hosts_dict.update({host.strip(): ip.strip()}) - extra_hosts_config = extra_hosts_dict - - if isinstance(extra_hosts_config, dict): - return extra_hosts_config - - raise ConfigError( - "extra_hosts_config \"%s\" must be either a list of strings or a string->string mapping," % - extra_hosts_config - ) diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 87744ad56f7..3831e95a5f0 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -22,8 +22,6 @@ from compose.const import LABEL_SERVICE from compose.const import LABEL_VERSION from compose.container import Container -from compose.service import build_extra_hosts -from compose.service import ConfigError from compose.service import ConvergencePlan from compose.service import ConvergenceStrategy from compose.service import Net @@ -133,37 +131,6 @@ def test_create_container_with_cpu_shares(self): container.start() self.assertEqual(container.get('HostConfig.CpuShares'), 73) - def test_build_extra_hosts(self): - # string - self.assertRaises(ConfigError, lambda: build_extra_hosts("www.example.com: 192.168.0.17")) - - # list of strings - self.assertEqual(build_extra_hosts( - ["www.example.com:192.168.0.17"]), - {'www.example.com': '192.168.0.17'}) - self.assertEqual(build_extra_hosts( - ["www.example.com: 192.168.0.17"]), - {'www.example.com': '192.168.0.17'}) - self.assertEqual(build_extra_hosts( - ["www.example.com: 192.168.0.17", - "static.example.com:192.168.0.19", - "api.example.com: 192.168.0.18"]), - {'www.example.com': '192.168.0.17', - 'static.example.com': '192.168.0.19', - 'api.example.com': '192.168.0.18'}) - - # list of dictionaries - self.assertRaises(ConfigError, lambda: build_extra_hosts( - [{'www.example.com': '192.168.0.17'}, - {'api.example.com': '192.168.0.18'}])) - - # dictionaries - self.assertEqual(build_extra_hosts( - {'www.example.com': '192.168.0.17', - 'api.example.com': '192.168.0.18'}), - {'www.example.com': '192.168.0.17', - 'api.example.com': '192.168.0.18'}) - 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) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index c69e34306c5..f923fb370f3 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -32,7 +32,7 @@ def service_sort(services): return sorted(services, key=itemgetter('name')) -def build_config_details(contents, working_dir, filename): +def build_config_details(contents, working_dir='working_dir', filename='filename.yml'): return config.ConfigDetails( working_dir, [config.ConfigFile(filename, contents)]) @@ -512,6 +512,29 @@ def test_load_yaml_with_yaml_error(self): assert 'line 3, column 32' in exc.exconly() + def test_validate_extra_hosts_invalid(self): + with pytest.raises(ConfigurationError) as exc: + config.load(build_config_details({ + 'web': { + 'image': 'alpine', + 'extra_hosts': "www.example.com: 192.168.0.17", + } + })) + assert "'extra_hosts' contains an invalid type" in exc.exconly() + + def test_validate_extra_hosts_invalid_list(self): + with pytest.raises(ConfigurationError) as exc: + config.load(build_config_details({ + 'web': { + 'image': 'alpine', + 'extra_hosts': [ + {'www.example.com': '192.168.0.17'}, + {'api.example.com': '192.168.0.18'} + ], + } + })) + assert "which is an invalid type" in exc.exconly() + class InterpolationTest(unittest.TestCase): @mock.patch.dict(os.environ) diff --git a/tests/unit/config/types_test.py b/tests/unit/config/types_test.py new file mode 100644 index 00000000000..25692ca3742 --- /dev/null +++ b/tests/unit/config/types_test.py @@ -0,0 +1,29 @@ +from compose.config.types import parse_extra_hosts + + +def test_parse_extra_hosts_list(): + expected = {'www.example.com': '192.168.0.17'} + assert parse_extra_hosts(["www.example.com:192.168.0.17"]) == expected + + expected = {'www.example.com': '192.168.0.17'} + assert parse_extra_hosts(["www.example.com: 192.168.0.17"]) == expected + + assert parse_extra_hosts([ + "www.example.com: 192.168.0.17", + "static.example.com:192.168.0.19", + "api.example.com: 192.168.0.18" + ]) == { + 'www.example.com': '192.168.0.17', + 'static.example.com': '192.168.0.19', + 'api.example.com': '192.168.0.18' + } + + +def test_parse_extra_hosts_dict(): + assert parse_extra_hosts({ + 'www.example.com': '192.168.0.17', + 'api.example.com': '192.168.0.18' + }) == { + 'www.example.com': '192.168.0.17', + 'api.example.com': '192.168.0.18' + } From 8572d5090366811834e9247fac17ade4a3d8a813 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 13 Nov 2015 19:40:10 -0500 Subject: [PATCH 1597/4072] Move volume parsing to config.types module This removes the last of the old service.ConfigError Signed-off-by: Daniel Nephin --- compose/cli/command.py | 17 ++---- compose/config/config.py | 5 ++ compose/config/types.py | 59 +++++++++++++++++++ compose/service.py | 69 ++--------------------- tests/acceptance/cli_test.py | 2 +- tests/integration/project_test.py | 11 ++-- tests/integration/resilience_test.py | 6 +- tests/integration/service_test.py | 43 ++++++-------- tests/integration/testcases.py | 11 ++-- tests/unit/config/config_test.py | 38 +++++++------ tests/unit/config/types_test.py | 37 ++++++++++++ tests/unit/service_test.py | 84 +++++++--------------------- 12 files changed, 186 insertions(+), 196 deletions(-) diff --git a/compose/cli/command.py b/compose/cli/command.py index 6094b5305b5..157e00161b8 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -14,7 +14,6 @@ from . import verbose_proxy from .. import config from ..project import Project -from ..service import ConfigError from .docker_client import docker_client from .utils import call_silently from .utils import get_version_info @@ -84,16 +83,12 @@ def get_project(base_dir, config_path=None, project_name=None, verbose=False, config_details = config.find(base_dir, config_path) api_version = '1.21' if use_networking else None - try: - return Project.from_dicts( - get_project_name(config_details.working_dir, project_name), - config.load(config_details), - get_client(verbose=verbose, version=api_version), - use_networking=use_networking, - network_driver=network_driver, - ) - except ConfigError as e: - raise errors.UserError(six.text_type(e)) + return Project.from_dicts( + get_project_name(config_details.working_dir, project_name), + config.load(config_details), + get_client(verbose=verbose, version=api_version), + use_networking=use_networking, + network_driver=network_driver) def get_project_name(working_dir, project_name=None): diff --git a/compose/config/config.py b/compose/config/config.py index 893784f8610..8bedeffe836 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -16,6 +16,7 @@ from .types import parse_extra_hosts from .types import parse_restart_spec 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_extends_file_path @@ -396,6 +397,10 @@ def finalize_service(service_config): service_dict['volumes_from'] = [ VolumeFromSpec.parse(vf) for vf in service_dict['volumes_from']] + if 'volumes' in service_dict: + service_dict['volumes'] = [ + VolumeSpec.parse(v) for v in service_dict['volumes']] + if 'restart' in service_dict: service_dict['restart'] = parse_restart_spec(service_dict['restart']) diff --git a/compose/config/types.py b/compose/config/types.py index b6add0894af..cec1f6cfdff 100644 --- a/compose/config/types.py +++ b/compose/config/types.py @@ -4,9 +4,11 @@ from __future__ import absolute_import from __future__ import unicode_literals +import os from collections import namedtuple from compose.config.errors import ConfigurationError +from compose.const import IS_WINDOWS_PLATFORM class VolumeFromSpec(namedtuple('_VolumeFromSpec', 'source mode')): @@ -59,3 +61,60 @@ def parse_extra_hosts(extra_hosts_config): host, ip = extra_hosts_line.split(':') extra_hosts_dict[host.strip()] = ip.strip() return extra_hosts_dict + + +def normalize_paths_for_engine(external_path, internal_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 + + if external_path: + drive, tail = os.path.splitdrive(external_path) + + if drive: + external_path = '/' + drive.lower().rstrip(':') + tail + + external_path = external_path.replace('\\', '/') + + return external_path, internal_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(':') + + if len(parts) > 3: + raise ConfigurationError( + "Volume %s has incorrect format, should be " + "external:internal[:mode]" % volume_config) + + if len(parts) == 1: + external, internal = normalize_paths_for_engine( + None, + os.path.normpath(parts[0])) + else: + external, internal = normalize_paths_for_engine( + os.path.normpath(parts[0]), + os.path.normpath(parts[1])) + + mode = 'rw' + if len(parts) == 3: + mode = parts[2] + + return cls(external, internal, mode) diff --git a/compose/service.py b/compose/service.py index dd243364617..eb411d8aa0c 100644 --- a/compose/service.py +++ b/compose/service.py @@ -2,7 +2,6 @@ from __future__ import unicode_literals import logging -import os import re import sys from collections import namedtuple @@ -18,8 +17,8 @@ from . import __version__ 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 @@ -67,11 +66,6 @@ def __init__(self, service, reason): self.reason = reason -# TODO: remove -class ConfigError(ValueError): - pass - - class NeedsBuildError(Exception): def __init__(self, service): self.service = service @@ -81,9 +75,6 @@ class NoSuchImageError(Exception): pass -VolumeSpec = namedtuple('VolumeSpec', 'external internal mode') - - ServiceName = namedtuple('ServiceName', 'project service number') @@ -613,8 +604,7 @@ def _get_container_create_options( if 'volumes' in container_options: container_options['volumes'] = dict( - (parse_volume_spec(v).internal, {}) - for v in container_options['volumes']) + (v.internal, {}) for v in container_options['volumes']) container_options['environment'] = merge_environment( self.options.get('environment'), @@ -899,11 +889,10 @@ def parse_repository_tag(repo_path): # Volumes -def merge_volume_bindings(volumes_option, previous_container): +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. """ - volumes = [parse_volume_spec(volume) for volume in volumes_option or []] volume_bindings = dict( build_volume_binding(volume) for volume in volumes @@ -925,7 +914,7 @@ def get_container_data_volumes(container, volumes_option): volumes = [] container_volumes = container.get('Volumes') or {} image_volumes = [ - parse_volume_spec(volume) + VolumeSpec.parse(volume) for volume in container.image_config['ContainerConfig'].get('Volumes') or {} ] @@ -972,56 +961,6 @@ def build_volume_binding(volume_spec): return volume_spec.internal, "{}:{}:{}".format(*volume_spec) -def normalize_paths_for_engine(external_path, internal_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 - - if external_path: - drive, tail = os.path.splitdrive(external_path) - - if drive: - external_path = '/' + drive.lower().rstrip(':') + tail - - external_path = external_path.replace('\\', '/') - - return external_path, internal_path.replace('\\', '/') - - -def parse_volume_spec(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(':') - - if len(parts) > 3: - raise ConfigError("Volume %s has incorrect format, should be " - "external:internal[:mode]" % volume_config) - - if len(parts) == 1: - external, internal = normalize_paths_for_engine(None, os.path.normpath(parts[0])) - else: - external, internal = normalize_paths_for_engine(os.path.normpath(parts[0]), os.path.normpath(parts[1])) - - mode = 'rw' - if len(parts) == 3: - mode = parts[2] - - return VolumeSpec(external, internal, mode) - - def build_volume_from(volume_from_spec): """ volume_from can be either a service or a container. We want to return the diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 88ec457388e..282a5219591 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -38,7 +38,7 @@ def start_process(base_dir, options): def wait_on_process(proc, returncode=0): stdout, stderr = proc.communicate() if proc.returncode != returncode: - print(stderr) + print(stderr.decode('utf-8')) assert proc.returncode == returncode return ProcessResult(stdout.decode('utf-8'), stderr.decode('utf-8')) diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index d65d7ef0cfa..443ff9783a2 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -4,6 +4,7 @@ from compose.cli.docker_client import docker_client from compose.config import config from compose.config.types import VolumeFromSpec +from compose.config.types import VolumeSpec from compose.const import LABEL_PROJECT from compose.container import Container from compose.project import Project @@ -214,7 +215,7 @@ def test_start_pause_unpause_stop_kill_remove(self): def test_project_up(self): web = self.create_service('web') - db = self.create_service('db', volumes=['/var/db']) + db = self.create_service('db', volumes=[VolumeSpec.parse('/var/db')]) project = Project('composetest', [web, db], self.client) project.start() self.assertEqual(len(project.containers()), 0) @@ -238,7 +239,7 @@ def test_project_up_starts_uncreated_services(self): def test_recreate_preserves_volumes(self): web = self.create_service('web') - db = self.create_service('db', volumes=['/etc']) + db = self.create_service('db', volumes=[VolumeSpec.parse('/etc')]) project = Project('composetest', [web, db], self.client) project.start() self.assertEqual(len(project.containers()), 0) @@ -257,7 +258,7 @@ def test_recreate_preserves_volumes(self): def test_project_up_with_no_recreate_running(self): web = self.create_service('web') - db = self.create_service('db', volumes=['/var/db']) + db = self.create_service('db', volumes=[VolumeSpec.parse('/var/db')]) project = Project('composetest', [web, db], self.client) project.start() self.assertEqual(len(project.containers()), 0) @@ -277,7 +278,7 @@ def test_project_up_with_no_recreate_running(self): def test_project_up_with_no_recreate_stopped(self): web = self.create_service('web') - db = self.create_service('db', volumes=['/var/db']) + db = self.create_service('db', volumes=[VolumeSpec.parse('/var/db')]) project = Project('composetest', [web, db], self.client) project.start() self.assertEqual(len(project.containers()), 0) @@ -316,7 +317,7 @@ def test_project_up_without_all_services(self): def test_project_up_starts_links(self): console = self.create_service('console') - db = self.create_service('db', volumes=['/var/db']) + db = self.create_service('db', volumes=[VolumeSpec.parse('/var/db')]) web = self.create_service('web', links=[(db, 'db')]) project = Project('composetest', [web, db, console], self.client) diff --git a/tests/integration/resilience_test.py b/tests/integration/resilience_test.py index 53aedfecf2c..7f75356d829 100644 --- a/tests/integration/resilience_test.py +++ b/tests/integration/resilience_test.py @@ -3,13 +3,17 @@ from .. import mock from .testcases import DockerClientTestCase +from compose.config.types import VolumeSpec from compose.project import Project from compose.service import ConvergenceStrategy class ResilienceTest(DockerClientTestCase): def setUp(self): - self.db = self.create_service('db', volumes=['/var/db'], command='top') + self.db = self.create_service( + 'db', + volumes=[VolumeSpec.parse('/var/db')], + command='top') self.project = Project('composetest', [self.db], self.client) container = self.db.create_container() diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 3831e95a5f0..6808280f06b 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -15,6 +15,7 @@ from .testcases import pull_busybox from compose import __version__ from compose.config.types import VolumeFromSpec +from compose.config.types import VolumeSpec from compose.const import LABEL_CONFIG_HASH from compose.const import LABEL_CONTAINER_NUMBER from compose.const import LABEL_ONE_OFF @@ -114,7 +115,7 @@ def test_create_container_with_one_off_when_existing_container_is_running(self): self.assertEqual(container.name, 'composetest_db_run_1') def test_create_container_with_unspecified_volume(self): - service = self.create_service('db', volumes=['/var/db']) + service = self.create_service('db', volumes=[VolumeSpec.parse('/var/db')]) container = service.create_container() container.start() self.assertIn('/var/db', container.get('Volumes')) @@ -176,7 +177,9 @@ def test_create_container_with_specified_volume(self): host_path = '/tmp/host-path' container_path = '/container-path' - service = self.create_service('db', volumes=['%s:%s' % (host_path, container_path)]) + service = self.create_service( + 'db', + volumes=[VolumeSpec(host_path, container_path, 'rw')]) container = service.create_container() container.start() @@ -189,11 +192,10 @@ def test_create_container_with_specified_volume(self): msg=("Last component differs: %s, %s" % (actual_host_path, host_path))) def test_recreate_preserves_volume_with_trailing_slash(self): - """ - When the Compose file specifies a trailing slash in the container path, make + """When the Compose file specifies a trailing slash in the container path, make sure we copy the volume over when recreating. """ - service = self.create_service('data', volumes=['/data/']) + service = self.create_service('data', volumes=[VolumeSpec.parse('/data/')]) old_container = create_and_start_container(service) volume_path = old_container.get('Volumes')['/data'] @@ -207,7 +209,7 @@ def test_duplicate_volume_trailing_slash(self): """ host_path = '/tmp/data' container_path = '/data' - volumes = ['{}:{}/'.format(host_path, container_path)] + volumes = [VolumeSpec.parse('{}:{}/'.format(host_path, container_path))] tmp_container = self.client.create_container( 'busybox', 'true', @@ -261,7 +263,7 @@ def test_execute_convergence_plan_recreate(self): service = self.create_service( 'db', environment={'FOO': '1'}, - volumes=['/etc'], + volumes=[VolumeSpec.parse('/etc')], entrypoint=['top'], command=['-d', '1'] ) @@ -299,7 +301,7 @@ def test_execute_convergence_plan_when_containers_are_stopped(self): service = self.create_service( 'db', environment={'FOO': '1'}, - volumes=['/var/db'], + volumes=[VolumeSpec.parse('/var/db')], entrypoint=['top'], command=['-d', '1'] ) @@ -337,10 +339,8 @@ def test_execute_convergence_plan_with_image_declared_volume(self): self.assertEqual(new_container.get('Volumes')['/data'], volume_path) def test_execute_convergence_plan_when_image_volume_masks_config(self): - service = Service( - project='composetest', - name='db', - client=self.client, + service = self.create_service( + 'db', build='tests/fixtures/dockerfile-with-volume', ) @@ -348,7 +348,7 @@ def test_execute_convergence_plan_when_image_volume_masks_config(self): self.assertEqual(list(old_container.get('Volumes').keys()), ['/data']) volume_path = old_container.get('Volumes')['/data'] - service.options['volumes'] = ['/tmp:/data'] + service.options['volumes'] = [VolumeSpec.parse('/tmp:/data')] with mock.patch('compose.service.log') as mock_log: new_container, = service.execute_convergence_plan( @@ -857,22 +857,11 @@ def test_labels(self): for pair in expected.items(): self.assertIn(pair, labels) - service.kill() - service.remove_stopped() - - labels_list = ["%s=%s" % pair for pair in labels_dict.items()] - - service = self.create_service('web', labels=labels_list) - labels = create_and_start_container(service).labels.items() - for pair in expected.items(): - self.assertIn(pair, labels) - def test_empty_labels(self): - labels_list = ['foo', 'bar'] - - service = self.create_service('web', labels=labels_list) + labels_dict = {'foo': '', 'bar': ''} + service = self.create_service('web', labels=labels_dict) labels = create_and_start_container(service).labels.items() - for name in labels_list: + for name in labels_dict: self.assertIn((name, ''), labels) def test_custom_container_name(self): diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index 2c5ca9fdd45..f0e5c6d85f7 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -6,9 +6,7 @@ from .. import unittest from compose.cli.docker_client import docker_client -from compose.config.config import process_service from compose.config.config import resolve_environment -from compose.config.config import ServiceConfig from compose.const import LABEL_PROJECT from compose.progress_stream import stream_output from compose.service import Service @@ -41,13 +39,12 @@ def create_service(self, name, **kwargs): kwargs['command'] = ["top"] service_config = ServiceConfig('.', None, name, kwargs) - options = process_service(service_config) - options['environment'] = resolve_environment( - service_config._replace(config=options)) - labels = options.setdefault('labels', {}) + kwargs['environment'] = resolve_environment(service_config) + + labels = dict(kwargs.setdefault('labels', {})) labels['com.docker.compose.test-name'] = self.id() - return Service(name, client=self.client, project='composetest', **options) + return Service(name, client=self.client, project='composetest', **kwargs) def check_build(self, *args, **kwargs): kwargs.setdefault('rm', True) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index f923fb370f3..b2a4cd68ffc 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -11,6 +11,7 @@ from compose.config import config 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 @@ -147,7 +148,7 @@ def test_load_with_multiple_files(self): 'name': 'web', 'build': '/', 'links': ['db'], - 'volumes': ['/home/user/project:/code'], + 'volumes': [VolumeSpec.parse('/home/user/project:/code')], }, { 'name': 'db', @@ -211,7 +212,7 @@ def test_load_with_multiple_files_and_extends_in_override_file(self): { 'name': 'web', 'image': 'example/web', - 'volumes': ['/home/user/project:/code'], + 'volumes': [VolumeSpec.parse('/home/user/project:/code')], 'labels': {'label': 'one'}, }, ] @@ -626,14 +627,11 @@ def test_no_binding(self): @mock.patch.dict(os.environ) def test_volume_binding_with_environment_variable(self): os.environ['VOLUME_PATH'] = '/host/path' - d = config.load( - build_config_details( - {'foo': {'build': '.', 'volumes': ['${VOLUME_PATH}:/container/path']}}, - '.', - None, - ) - )[0] - self.assertEqual(d['volumes'], ['/host/path:/container/path']) + d = config.load(build_config_details( + {'foo': {'build': '.', 'volumes': ['${VOLUME_PATH}:/container/path']}}, + '.', + ))[0] + self.assertEqual(d['volumes'], [VolumeSpec.parse('/host/path:/container/path')]) @pytest.mark.skipif(IS_WINDOWS_PLATFORM, reason='posix paths') @mock.patch.dict(os.environ) @@ -1031,19 +1029,21 @@ def test_resolve_path(self): build_config_details( {'foo': {'build': '.', 'volumes': ['$HOSTENV:$CONTAINERENV']}}, "tests/fixtures/env", - None, ) )[0] - self.assertEqual(set(service_dict['volumes']), set(['/tmp:/host/tmp'])) + self.assertEqual( + set(service_dict['volumes']), + set([VolumeSpec.parse('/tmp:/host/tmp')])) service_dict = config.load( build_config_details( {'foo': {'build': '.', 'volumes': ['/opt${HOSTENV}:/opt${CONTAINERENV}']}}, "tests/fixtures/env", - None, ) )[0] - self.assertEqual(set(service_dict['volumes']), set(['/opt/tmp:/opt/host/tmp'])) + self.assertEqual( + set(service_dict['volumes']), + set([VolumeSpec.parse('/opt/tmp:/opt/host/tmp')])) def load_from_filename(filename): @@ -1290,8 +1290,14 @@ def test_volume_path(self): dicts = load_from_filename('tests/fixtures/volume-path/docker-compose.yml') paths = [ - '%s:/foo' % os.path.abspath('tests/fixtures/volume-path/common/foo'), - '%s:/bar' % os.path.abspath('tests/fixtures/volume-path/bar'), + VolumeSpec( + os.path.abspath('tests/fixtures/volume-path/common/foo'), + '/foo', + 'rw'), + VolumeSpec( + os.path.abspath('tests/fixtures/volume-path/bar'), + '/bar', + 'rw') ] self.assertEqual(set(dicts[0]['volumes']), set(paths)) diff --git a/tests/unit/config/types_test.py b/tests/unit/config/types_test.py index 25692ca3742..4df665485e0 100644 --- a/tests/unit/config/types_test.py +++ b/tests/unit/config/types_test.py @@ -1,4 +1,9 @@ +import pytest + +from compose.config.errors import ConfigurationError from compose.config.types import parse_extra_hosts +from compose.config.types import VolumeSpec +from compose.const import IS_WINDOWS_PLATFORM def test_parse_extra_hosts_list(): @@ -27,3 +32,35 @@ def test_parse_extra_hosts_dict(): 'www.example.com': '192.168.0.17', 'api.example.com': '192.168.0.18' } + + +class TestVolumeSpec(object): + + def test_parse_volume_spec_only_one_path(self): + spec = VolumeSpec.parse('/the/volume') + assert spec == (None, '/the/volume', 'rw') + + def test_parse_volume_spec_internal_and_external(self): + spec = VolumeSpec.parse('external:interval') + assert spec == ('external', 'interval', 'rw') + + def test_parse_volume_spec_with_mode(self): + spec = VolumeSpec.parse('external:interval:ro') + assert spec == ('external', 'interval', 'ro') + + spec = VolumeSpec.parse('external:interval:z') + assert spec == ('external', 'interval', 'z') + + def test_parse_volume_spec_too_many_parts(self): + with pytest.raises(ConfigurationError) as exc: + 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) == ( + "/c/Users/me/Documents/shiny/config", + "/opt/shiny/config", + "ro" + ) diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 83dd61589b7..c87d31b1d8d 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -2,12 +2,11 @@ from __future__ import unicode_literals import docker -import pytest from .. import mock from .. import unittest from compose.config.types import VolumeFromSpec -from compose.const import IS_WINDOWS_PLATFORM +from compose.config.types import VolumeSpec from compose.const import LABEL_CONFIG_HASH from compose.const import LABEL_ONE_OFF from compose.const import LABEL_PROJECT @@ -15,7 +14,6 @@ from compose.container import Container from compose.service import build_ulimits from compose.service import build_volume_binding -from compose.service import ConfigError from compose.service import ContainerNet from compose.service import get_container_data_volumes from compose.service import merge_volume_bindings @@ -23,7 +21,6 @@ from compose.service import Net from compose.service import NoSuchImageError from compose.service import parse_repository_tag -from compose.service import parse_volume_spec from compose.service import Service from compose.service import ServiceNet from compose.service import VolumeFromSpec @@ -585,46 +582,12 @@ class ServiceVolumesTest(unittest.TestCase): def setUp(self): self.mock_client = mock.create_autospec(docker.Client) - def test_parse_volume_spec_only_one_path(self): - spec = parse_volume_spec('/the/volume') - self.assertEqual(spec, (None, '/the/volume', 'rw')) - - def test_parse_volume_spec_internal_and_external(self): - spec = parse_volume_spec('external:interval') - self.assertEqual(spec, ('external', 'interval', 'rw')) - - def test_parse_volume_spec_with_mode(self): - spec = parse_volume_spec('external:interval:ro') - self.assertEqual(spec, ('external', 'interval', 'ro')) - - spec = parse_volume_spec('external:interval:z') - self.assertEqual(spec, ('external', 'interval', 'z')) - - def test_parse_volume_spec_too_many_parts(self): - with self.assertRaises(ConfigError): - parse_volume_spec('one:two:three:four') - - @pytest.mark.xfail((not IS_WINDOWS_PLATFORM), reason='does not have a drive') - def test_parse_volume_windows_absolute_path(self): - windows_absolute_path = "c:\\Users\\me\\Documents\\shiny\\config:\\opt\\shiny\\config:ro" - - spec = parse_volume_spec(windows_absolute_path) - - self.assertEqual( - spec, - ( - "/c/Users/me/Documents/shiny/config", - "/opt/shiny/config", - "ro" - ) - ) - def test_build_volume_binding(self): - binding = build_volume_binding(parse_volume_spec('/outside:/inside')) - self.assertEqual(binding, ('/inside', '/outside:/inside:rw')) + binding = build_volume_binding(VolumeSpec.parse('/outside:/inside')) + assert binding == ('/inside', '/outside:/inside:rw') def test_get_container_data_volumes(self): - options = [parse_volume_spec(v) for v in [ + options = [VolumeSpec.parse(v) for v in [ '/host/volume:/host/volume:ro', '/new/volume', '/existing/volume', @@ -648,19 +611,19 @@ def test_get_container_data_volumes(self): }, has_been_inspected=True) expected = [ - parse_volume_spec('/var/lib/docker/aaaaaaaa:/existing/volume:rw'), - parse_volume_spec('/var/lib/docker/cccccccc:/mnt/image/data:rw'), + VolumeSpec.parse('/var/lib/docker/aaaaaaaa:/existing/volume:rw'), + VolumeSpec.parse('/var/lib/docker/cccccccc:/mnt/image/data:rw'), ] volumes = get_container_data_volumes(container, options) - self.assertEqual(sorted(volumes), sorted(expected)) + assert sorted(volumes) == sorted(expected) def test_merge_volume_bindings(self): options = [ - '/host/volume:/host/volume:ro', - '/host/rw/volume:/host/rw/volume', - '/new/volume', - '/existing/volume', + VolumeSpec.parse('/host/volume:/host/volume:ro'), + VolumeSpec.parse('/host/rw/volume:/host/rw/volume'), + VolumeSpec.parse('/new/volume'), + VolumeSpec.parse('/existing/volume'), ] self.mock_client.inspect_image.return_value = { @@ -686,8 +649,8 @@ def test_mount_same_host_path_to_two_volumes(self): 'web', image='busybox', volumes=[ - '/host/path:/data1', - '/host/path:/data2', + VolumeSpec.parse('/host/path:/data1'), + VolumeSpec.parse('/host/path:/data2'), ], client=self.mock_client, ) @@ -716,7 +679,7 @@ def test_different_host_path_in_container_json(self): service = Service( 'web', image='busybox', - volumes=['/host/path:/data'], + volumes=[VolumeSpec.parse('/host/path:/data')], client=self.mock_client, ) @@ -784,22 +747,17 @@ def test_warn_on_masked_no_warning_with_same_path(self): def test_create_with_special_volume_mode(self): self.mock_client.inspect_image.return_value = {'Id': 'imageid'} - create_calls = [] - - def create_container(*args, **kwargs): - create_calls.append((args, kwargs)) - return {'Id': 'containerid'} - - self.mock_client.create_container = create_container - - volumes = ['/tmp:/foo:z'] + self.mock_client.create_container.return_value = {'Id': 'containerid'} + volume = '/tmp:/foo:z' Service( 'web', client=self.mock_client, image='busybox', - volumes=volumes, + volumes=[VolumeSpec.parse(volume)], ).create_container() - self.assertEqual(len(create_calls), 1) - self.assertEqual(self.mock_client.create_host_config.call_args[1]['binds'], volumes) + assert self.mock_client.create_container.call_count == 1 + self.assertEqual( + self.mock_client.create_host_config.call_args[1]['binds'], + [volume]) From da27f8e7e244883eaf9dec24c00c7179b63a94f3 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 13 Nov 2015 19:49:14 -0500 Subject: [PATCH 1598/4072] Remove unnecessary intermediate variables in get_container_host_config. Signed-off-by: Daniel Nephin --- compose/service.py | 44 ++++++++++++-------------------------------- 1 file changed, 12 insertions(+), 32 deletions(-) diff --git a/compose/service.py b/compose/service.py index eb411d8aa0c..34eb4c9522b 100644 --- a/compose/service.py +++ b/compose/service.py @@ -511,7 +511,7 @@ 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/11943 + # TODO: this would benefit from github.com/docker/docker/pull/14699 # to remove the need to inspect every container def _next_container_number(self, one_off=False): containers = filter(None, [ @@ -633,54 +633,34 @@ def _get_container_create_options( def _get_container_host_config(self, override_options, one_off=False): options = dict(self.options, **override_options) - port_bindings = build_port_bindings(options.get('ports') or []) - privileged = options.get('privileged', False) - cap_add = options.get('cap_add', None) - cap_drop = options.get('cap_drop', None) log_config = LogConfig( type=options.get('log_driver', ""), config=options.get('log_opt', None) ) - pid = options.get('pid', None) - security_opt = options.get('security_opt', None) - - # TODO: these options are already normalized by config - dns = options.get('dns', None) - if isinstance(dns, six.string_types): - dns = [dns] - - dns_search = options.get('dns_search', None) - if isinstance(dns_search, six.string_types): - dns_search = [dns_search] - - devices = options.get('devices', None) - cgroup_parent = options.get('cgroup_parent', None) - ulimits = build_ulimits(options.get('ulimits', None)) - return self.client.create_host_config( links=self._get_links(link_to_self=one_off), - port_bindings=port_bindings, + port_bindings=build_port_bindings(options.get('ports') or []), binds=options.get('binds'), volumes_from=self._get_volumes_from(), - privileged=privileged, + privileged=options.get('privileged', False), network_mode=self.net.mode, - devices=devices, - dns=dns, - dns_search=dns_search, + devices=options.get('devices'), + dns=options.get('dns'), + dns_search=options.get('dns_search'), restart_policy=options.get('restart'), - cap_add=cap_add, - cap_drop=cap_drop, + cap_add=options.get('cap_add'), + cap_drop=options.get('cap_drop'), mem_limit=options.get('mem_limit'), memswap_limit=options.get('memswap_limit'), - ulimits=ulimits, + ulimits=build_ulimits(options.get('ulimits')), log_config=log_config, extra_hosts=options.get('extra_hosts'), read_only=options.get('read_only'), - pid_mode=pid, - security_opt=security_opt, + pid_mode=options.get('pid'), + security_opt=options.get('security_opt'), ipc_mode=options.get('ipc'), - cgroup_parent=cgroup_parent + cgroup_parent=options.get('cgroup_parent'), ) def build(self, no_cache=False, pull=False, force_rm=False): From 81f0e72bd2fa33327c7fe1ceb9303d0614ffd3bd Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 17 Nov 2015 13:35:28 -0500 Subject: [PATCH 1599/4072] Move service sorting to config package. Signed-off-by: Daniel Nephin --- compose/config/__init__.py | 1 - compose/config/config.py | 17 ++---- compose/config/errors.py | 4 ++ compose/config/sort_services.py | 55 +++++++++++++++++++ compose/project.py | 51 +---------------- tests/integration/testcases.py | 1 + tests/unit/config/config_test.py | 23 +++++++- .../sort_services_test.py} | 6 +- tests/unit/project_test.py | 23 -------- tests/unit/service_test.py | 2 - 10 files changed, 91 insertions(+), 92 deletions(-) create mode 100644 compose/config/sort_services.py rename tests/unit/{sort_service_test.py => config/sort_services_test.py} (98%) diff --git a/compose/config/__init__.py b/compose/config/__init__.py index ec607e087ec..6fe9ff9fb6a 100644 --- a/compose/config/__init__.py +++ b/compose/config/__init__.py @@ -2,7 +2,6 @@ from .config import ConfigurationError from .config import DOCKER_CONFIG_KEYS from .config import find -from .config import get_service_name_from_net from .config import load from .config import merge_environment from .config import parse_environment diff --git a/compose/config/config.py b/compose/config/config.py index 8bedeffe836..0ca6817e7b8 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -13,6 +13,8 @@ from .errors import ComposeFileNotFound from .errors import ConfigurationError from .interpolation import interpolate_environment_variables +from .sort_services import get_service_name_from_net +from .sort_services import sort_service_dicts from .types import parse_extra_hosts from .types import parse_restart_spec from .types import VolumeFromSpec @@ -213,10 +215,10 @@ def build_service(filename, service_name, service_dict): return service_dict def build_services(config_file): - return [ + return sort_service_dicts([ build_service(config_file.filename, name, service_dict) for name, service_dict in config_file.config.items() - ] + ]) def merge_services(base, override): all_service_names = set(base) | set(override) @@ -643,17 +645,6 @@ def to_list(value): return value -def get_service_name_from_net(net_config): - if not net_config: - return - - if not net_config.startswith('container:'): - return - - _, net_name = net_config.split(':', 1) - return net_name - - def load_yaml(filename): try: with open(filename, 'r') as fh: diff --git a/compose/config/errors.py b/compose/config/errors.py index 037b7ec84d7..6d6a69df9a7 100644 --- a/compose/config/errors.py +++ b/compose/config/errors.py @@ -6,6 +6,10 @@ def __str__(self): return self.msg +class DependencyError(ConfigurationError): + pass + + class CircularReference(ConfigurationError): def __init__(self, trail): self.trail = trail diff --git a/compose/config/sort_services.py b/compose/config/sort_services.py new file mode 100644 index 00000000000..5d9adab11fd --- /dev/null +++ b/compose/config/sort_services.py @@ -0,0 +1,55 @@ +from compose.config.errors import DependencyError + + +def get_service_name_from_net(net_config): + if not net_config: + return + + if not net_config.startswith('container:'): + return + + _, net_name = net_config.split(':', 1) + return net_name + + +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_net(service.get('net'))) + ] + + def visit(n): + if n['name'] in temporary_marked: + if n['name'] in get_service_names(n.get('links', [])): + 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 in unmarked: + temporary_marked.add(n['name']) + for m in get_service_dependents(n, services): + visit(m) + temporary_marked.remove(n['name']) + unmarked.remove(n) + sorted_services.insert(0, n) + + while unmarked: + visit(unmarked[-1]) + + return sorted_services diff --git a/compose/project.py b/compose/project.py index 69f08475270..53e53cb1a48 100644 --- a/compose/project.py +++ b/compose/project.py @@ -8,7 +8,7 @@ from docker.errors import NotFound from .config import ConfigurationError -from .config import get_service_name_from_net +from .config.sort_services import get_service_name_from_net from .const import DEFAULT_TIMEOUT from .const import LABEL_ONE_OFF from .const import LABEL_PROJECT @@ -26,49 +26,6 @@ log = logging.getLogger(__name__) -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_net(service.get('net'))) - ] - - def visit(n): - if n['name'] in temporary_marked: - if n['name'] in get_service_names(n.get('links', [])): - 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 in unmarked: - temporary_marked.add(n['name']) - for m in get_service_dependents(n, services): - visit(m) - temporary_marked.remove(n['name']) - unmarked.remove(n) - sorted_services.insert(0, n) - - while unmarked: - visit(unmarked[-1]) - - return sorted_services - - class Project(object): """ A collection of services. @@ -96,7 +53,7 @@ def from_dicts(cls, name, service_dicts, client, use_networking=False, network_d if use_networking: remove_links(service_dicts) - for service_dict in sort_service_dicts(service_dicts): + for service_dict in service_dicts: links = project.get_links(service_dict) volumes_from = project.get_volumes_from(service_dict) net = project.get_net(service_dict) @@ -424,7 +381,3 @@ def __init__(self, name): def __str__(self): return self.msg - - -class DependencyError(ConfigurationError): - pass diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index f0e5c6d85f7..334693f7060 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -7,6 +7,7 @@ from .. import unittest from compose.cli.docker_client import docker_client from compose.config.config import resolve_environment +from compose.config.config import ServiceConfig from compose.const import LABEL_PROJECT from compose.progress_stream import stream_output from compose.service import Service diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index b2a4cd68ffc..a5eeb64f9fc 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -77,7 +77,7 @@ def test_load_throws_error_when_not_dict(self): ) ) - def test_config_invalid_service_names(self): + def test_load_config_invalid_service_names(self): for invalid_name in ['?not?allowed', ' ', '', '!', '/', '\xe2']: with pytest.raises(ConfigurationError) as exc: config.load(build_config_details( @@ -232,6 +232,27 @@ def test_load_with_multiple_files_and_invalid_override(self): assert "service 'bogus' doesn't have any configuration" in exc.exconly() assert "In file 'override.yaml'" in exc.exconly() + def test_load_sorts_in_dependency_order(self): + config_details = build_config_details({ + 'web': { + 'image': 'busybox:latest', + 'links': ['db'], + }, + 'db': { + 'image': 'busybox:latest', + 'volumes_from': ['volume:ro'] + }, + 'volume': { + 'image': 'busybox:latest', + 'volumes': ['/tmp'], + } + }) + services = config.load(config_details) + + assert services[0]['name'] == 'volume' + assert services[1]['name'] == 'db' + assert services[2]['name'] == 'web' + def test_config_valid_service_names(self): for valid_name in ['_', '-', '.__.', '_what-up.', 'what_.up----', 'whatup']: services = config.load( diff --git a/tests/unit/sort_service_test.py b/tests/unit/config/sort_services_test.py similarity index 98% rename from tests/unit/sort_service_test.py rename to tests/unit/config/sort_services_test.py index ef08828776e..8d0c3ae4080 100644 --- a/tests/unit/sort_service_test.py +++ b/tests/unit/config/sort_services_test.py @@ -1,7 +1,7 @@ -from .. import unittest +from compose.config.errors import DependencyError +from compose.config.sort_services import sort_service_dicts from compose.config.types import VolumeFromSpec -from compose.project import DependencyError -from compose.project import sort_service_dicts +from tests import unittest class SortServiceTest(unittest.TestCase): diff --git a/tests/unit/project_test.py b/tests/unit/project_test.py index f8178ed8b9a..f4c6f8ca165 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -34,29 +34,6 @@ def test_from_dict(self): self.assertEqual(project.get_service('db').name, 'db') self.assertEqual(project.get_service('db').options['image'], 'busybox:latest') - def test_from_dict_sorts_in_dependency_order(self): - project = Project.from_dicts('composetest', [ - { - 'name': 'web', - 'image': 'busybox:latest', - 'links': ['db'], - }, - { - 'name': 'db', - 'image': 'busybox:latest', - 'volumes_from': [VolumeFromSpec('volume', 'ro')] - }, - { - 'name': 'volume', - 'image': 'busybox:latest', - 'volumes': ['/tmp'], - } - ], None) - - self.assertEqual(project.services[0].name, 'volume') - self.assertEqual(project.services[1].name, 'db') - self.assertEqual(project.services[2].name, 'web') - def test_from_config(self): dicts = [ { diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index c87d31b1d8d..1c8b441f343 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -23,8 +23,6 @@ from compose.service import parse_repository_tag from compose.service import Service from compose.service import ServiceNet -from compose.service import VolumeFromSpec -from compose.service import VolumeSpec from compose.service import warn_on_masked_volume From e760c42ae00b9e1cccf2aeff4a17a3f5a0bd8cbe Mon Sep 17 00:00:00 2001 From: jake-low Date: Wed, 2 Dec 2015 21:45:36 -0800 Subject: [PATCH 1600/4072] Stop warning about ".yaml" extension ".yaml" is the preferred extension according to http://www.yaml.org/faq.html Signed-off-by: jake-low --- compose/config/config.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index c716393d45c..853157ee89f 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -161,11 +161,6 @@ def get_default_config_files(base_dir): log.warn("Found multiple config files with supported names: %s", ", ".join(candidates)) log.warn("Using %s\n", winner) - if winner == 'docker-compose.yaml': - log.warn("Please be aware that .yml is the expected extension " - "in most cases, and using .yaml can cause compatibility " - "issues in future.\n") - if winner.startswith("fig."): log.warn("%s is deprecated and will not be supported in future. " "Please rename your config file to docker-compose.yml\n" % winner) From fa975d7fbefd9578b02287ad5a2763efa3a446c7 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 23 Nov 2015 13:24:57 -0500 Subject: [PATCH 1601/4072] Properly resolve environment from all sources. Split env resolving into two phases. The first phase is to expand the paths of env_files, which is done before merging extends. Once all files are merged together, the final phase is to read the env_files and use them as the base for environment variables. Signed-off-by: Daniel Nephin --- compose/config/config.py | 34 +++++------- tests/integration/testcases.py | 5 +- tests/unit/config/config_test.py | 94 +++++++++++++++++--------------- 3 files changed, 64 insertions(+), 69 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 0ca6817e7b8..cbebeca832f 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -323,16 +323,13 @@ def get_extended_config_path(self, extends_options): return filename -def resolve_environment(service_config): +def resolve_environment(service_dict): """Unpack any environment variables from an env_file, if set. Interpolate environment values if set. """ - service_dict = service_config.config - env = {} - if 'env_file' in service_dict: - for env_file in get_env_files(service_config.working_dir, service_dict): - env.update(env_vars_from_file(env_file)) + for env_file in service_dict.get('env_file', []): + 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)) @@ -369,9 +366,11 @@ def process_service(service_config): working_dir = service_config.working_dir service_dict = dict(service_config.config) - if 'environment' in service_dict or 'env_file' in service_dict: - service_dict['environment'] = resolve_environment(service_config) - service_dict.pop('env_file', None) + if 'env_file' in service_dict: + service_dict['env_file'] = [ + expand_path(working_dir, path) + for path in to_list(service_dict['env_file']) + ] if 'volumes' in service_dict and service_dict.get('volume_driver') is None: service_dict['volumes'] = resolve_volume_paths(working_dir, service_dict) @@ -395,6 +394,10 @@ def process_service(service_config): def finalize_service(service_config): 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.pop('env_file', None) + if 'volumes_from' in service_dict: service_dict['volumes_from'] = [ VolumeFromSpec.parse(vf) for vf in service_dict['volumes_from']] @@ -456,7 +459,7 @@ def merge_service_dicts(base, override): if key in base or key in override: d[key] = base.get(key, []) + override.get(key, []) - list_or_string_keys = ['dns', 'dns_search'] + list_or_string_keys = ['dns', 'dns_search', 'env_file'] for key in list_or_string_keys: if key in base or key in override: @@ -477,17 +480,6 @@ def merge_environment(base, override): return env -def get_env_files(working_dir, options): - if 'env_file' not in options: - return {} - - env_files = options.get('env_file', []) - if not isinstance(env_files, list): - env_files = [env_files] - - return [expand_path(working_dir, path) for path in env_files] - - def parse_environment(environment): if not environment: return {} diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index 334693f7060..9ea68e39c53 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -7,7 +7,6 @@ from .. import unittest from compose.cli.docker_client import docker_client from compose.config.config import resolve_environment -from compose.config.config import ServiceConfig from compose.const import LABEL_PROJECT from compose.progress_stream import stream_output from compose.service import Service @@ -39,9 +38,7 @@ def create_service(self, name, **kwargs): if 'command' not in kwargs: kwargs['command'] = ["top"] - service_config = ServiceConfig('.', None, name, kwargs) - kwargs['environment'] = resolve_environment(service_config) - + kwargs['environment'] = resolve_environment(kwargs) 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 a5eeb64f9fc..2cd26e8f775 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -10,6 +10,7 @@ import pytest from compose.config import config +from compose.config.config import resolve_environment from compose.config.errors import ConfigurationError from compose.config.types import VolumeSpec from compose.const import IS_WINDOWS_PLATFORM @@ -973,65 +974,54 @@ def test_resolve_environment(self): os.environ['FILE_DEF_EMPTY'] = 'E2' os.environ['ENV_DEF'] = 'E3' - service_dict = make_service_dict( - 'foo', { - 'build': '.', - 'environment': { - 'FILE_DEF': 'F1', - 'FILE_DEF_EMPTY': '', - 'ENV_DEF': None, - 'NO_DEF': None - }, + service_dict = { + 'build': '.', + 'environment': { + 'FILE_DEF': 'F1', + 'FILE_DEF_EMPTY': '', + 'ENV_DEF': None, + 'NO_DEF': None }, - 'tests/' - ) - + } self.assertEqual( - service_dict['environment'], + resolve_environment(service_dict), {'FILE_DEF': 'F1', 'FILE_DEF_EMPTY': '', 'ENV_DEF': 'E3', 'NO_DEF': ''}, ) - def test_env_from_file(self): - service_dict = make_service_dict( - 'foo', - {'build': '.', 'env_file': 'one.env'}, - 'tests/fixtures/env', - ) + def test_resolve_environment_from_env_file(self): self.assertEqual( - service_dict['environment'], + resolve_environment({'env_file': ['tests/fixtures/env/one.env']}), {'ONE': '2', 'TWO': '1', 'THREE': '3', 'FOO': 'bar'}, ) - def test_env_from_multiple_files(self): - service_dict = make_service_dict( - 'foo', - {'build': '.', 'env_file': ['one.env', 'two.env']}, - 'tests/fixtures/env', - ) + def test_resolve_environment_with_multiple_env_files(self): + service_dict = { + 'env_file': [ + 'tests/fixtures/env/one.env', + 'tests/fixtures/env/two.env' + ] + } self.assertEqual( - service_dict['environment'], + resolve_environment(service_dict), {'ONE': '2', 'TWO': '1', 'THREE': '3', 'FOO': 'baz', 'DOO': 'dah'}, ) - def test_env_nonexistent_file(self): - options = {'env_file': 'nonexistent.env'} - self.assertRaises( - ConfigurationError, - lambda: make_service_dict('foo', options, 'tests/fixtures/env'), - ) + def test_resolve_environment_nonexistent_file(self): + with pytest.raises(ConfigurationError) as exc: + config.load(build_config_details( + {'foo': {'image': 'example', 'env_file': 'nonexistent.env'}}, + working_dir='tests/fixtures/env')) + + assert 'Couldn\'t find env file' in exc.exconly() + assert 'nonexistent.env' in exc.exconly() @mock.patch.dict(os.environ) - def test_resolve_environment_from_file(self): + 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' - service_dict = make_service_dict( - 'foo', - {'build': '.', 'env_file': 'resolve.env'}, - 'tests/fixtures/env', - ) self.assertEqual( - service_dict['environment'], + resolve_environment({'env_file': ['tests/fixtures/env/resolve.env']}), { 'FILE_DEF': u'bär', 'FILE_DEF_EMPTY': '', @@ -1378,6 +1368,8 @@ def test_extends_with_environment_and_env_files(self): - 'envs' environment: - SECRET + - TEST_ONE=common + - TEST_TWO=common """) tmpdir.join('docker-compose.yml').write(""" ext: @@ -1388,12 +1380,20 @@ def test_extends_with_environment_and_env_files(self): - 'envs' environment: - THING + - TEST_ONE=top """) commondir.join('envs').write(""" - COMMON_ENV_FILE=1 + COMMON_ENV_FILE + TEST_ONE=common-env-file + TEST_TWO=common-env-file + TEST_THREE=common-env-file + TEST_FOUR=common-env-file """) tmpdir.join('envs').write(""" - FROM_ENV_FILE=1 + TOP_ENV_FILE + TEST_ONE=top-env-file + TEST_TWO=top-env-file + TEST_THREE=top-env-file """) expected = [ @@ -1402,15 +1402,21 @@ def test_extends_with_environment_and_env_files(self): 'image': 'example/app', 'environment': { 'SECRET': 'secret', - 'FROM_ENV_FILE': '1', - 'COMMON_ENV_FILE': '1', + 'TOP_ENV_FILE': 'secret', + 'COMMON_ENV_FILE': 'secret', 'THING': 'thing', + 'TEST_ONE': 'top', + 'TEST_TWO': 'common', + 'TEST_THREE': 'top-env-file', + 'TEST_FOUR': 'common-env-file', }, }, ] 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'))) assert config == expected From 0dbd99bad2c6b3d99beda32e705777a912231053 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Eckerstr=C3=B6m?= Date: Wed, 29 Apr 2015 10:22:24 +0200 Subject: [PATCH 1602/4072] Added support for url buid paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jonas Eckerström --- compose/config/config.py | 27 ++++++++++++++++++++++++--- tests/unit/config/config_test.py | 14 ++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index cbebeca832f..05ef8a59d8e 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -75,6 +75,13 @@ 'external_links', ] +DOCKER_VALID_URL_PREFIXES = ( + 'http://', + 'https://', + 'git://', + 'github.com/', + 'git@', +) SUPPORTED_FILENAMES = [ 'docker-compose.yml', @@ -376,7 +383,7 @@ def process_service(service_config): service_dict['volumes'] = resolve_volume_paths(working_dir, service_dict) if 'build' in service_dict: - service_dict['build'] = expand_path(working_dir, service_dict['build']) + service_dict['build'] = resolve_build_path(working_dir, service_dict['build']) if 'labels' in service_dict: service_dict['labels'] = parse_labels(service_dict['labels']) @@ -548,11 +555,25 @@ def resolve_volume_path(working_dir, volume): return container_path +def resolve_build_path(working_dir, build_path): + if is_url(build_path): + return build_path + return expand_path(working_dir, build_path) + + +def is_url(build_path): + return build_path.startswith(DOCKER_VALID_URL_PREFIXES) + + def validate_paths(service_dict): if 'build' in service_dict: build_path = service_dict['build'] - if not os.path.exists(build_path) or not os.access(build_path, os.R_OK): - raise ConfigurationError("build path %s either does not exist or is not accessible." % build_path) + if ( + not is_url(build_path) and + (not os.path.exists(build_path) or not os.access(build_path, os.R_OK)) + ): + raise ConfigurationError( + "build path %s either does not exist or is not accessible." % build_path) def merge_path_mappings(base, override): diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 2cd26e8f775..6de794ade18 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1497,6 +1497,20 @@ def test_from_file(self): service_dict = load_from_filename('tests/fixtures/build-path/docker-compose.yml') self.assertEquals(service_dict, [{'name': 'foo', 'build': self.abs_context_path}]) + def test_valid_url_path(self): + valid_urls = [ + 'git://github.com/docker/docker', + 'git@github.com:docker/docker.git', + 'git@bitbucket.org:atlassianlabs/atlassian-docker.git', + 'https://github.com/docker/docker.git', + 'http://github.com/docker/docker.git', + ] + for valid_url in valid_urls: + service_dict = config.load(build_config_details({ + 'validurl': {'build': valid_url}, + }, '.', None)) + assert service_dict[0]['build'] == valid_url + class GetDefaultConfigFilesTestCase(unittest.TestCase): From 69e956ce8b7d278dccf705d74280836f20fbf68a Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 19 Nov 2015 15:46:14 -0500 Subject: [PATCH 1603/4072] Add integration test and docs for build with a git url. Signed-off-by: Daniel Nephin --- compose/config/config.py | 3 ++- docs/compose-file.md | 11 +++++++---- tests/integration/service_test.py | 7 +++++++ tests/unit/config/config_test.py | 16 +++++++++++++++- 4 files changed, 31 insertions(+), 6 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 05ef8a59d8e..b5847d2ec71 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -573,7 +573,8 @@ def validate_paths(service_dict): (not os.path.exists(build_path) or not os.access(build_path, os.R_OK)) ): raise ConfigurationError( - "build path %s either does not exist or is not accessible." % build_path) + "build path %s either does not exist, is not accessible, " + "or is not a valid URL." % build_path) def merge_path_mappings(base, override): diff --git a/docs/compose-file.md b/docs/compose-file.md index 51d1f5e1aa1..800d2aa9896 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -31,15 +31,18 @@ definition. ### build -Path to a directory containing a Dockerfile. When the value supplied is a -relative path, it is interpreted as relative to the location of the yml file -itself. This directory is also the build context that is sent to the Docker daemon. +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: /path/to/build/dir -Using `build` together with `image` is not allowed. Attempting to do so results in an error. +Using `build` together with `image` is not allowed. Attempting to do so results in +an error. ### cap_add, cap_drop diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 6808280f06b..01133d585a2 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -501,6 +501,13 @@ def test_build_non_ascii_filename(self): self.create_service('web', build=text_type(base_dir)).build() self.assertEqual(len(self.client.images(name='composetest_web')), 1) + def test_build_with_git_url(self): + build_url = "https://github.com/dnephin/docker-build-from-url.git" + service = self.create_service('buildwithurl', build=build_url) + self.addCleanup(self.client.remove_image, service.image_name) + service.build() + assert service.image() + def test_start_container_stays_unpriviliged(self): service = self.create_service('web') container = create_and_start_container(service).inspect() diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 6de794ade18..e15ac3502f7 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1497,13 +1497,14 @@ def test_from_file(self): service_dict = load_from_filename('tests/fixtures/build-path/docker-compose.yml') self.assertEquals(service_dict, [{'name': 'foo', 'build': self.abs_context_path}]) - def test_valid_url_path(self): + def test_valid_url_in_build_path(self): valid_urls = [ 'git://github.com/docker/docker', 'git@github.com:docker/docker.git', 'git@bitbucket.org:atlassianlabs/atlassian-docker.git', 'https://github.com/docker/docker.git', 'http://github.com/docker/docker.git', + 'github.com/docker/docker.git', ] for valid_url in valid_urls: service_dict = config.load(build_config_details({ @@ -1511,6 +1512,19 @@ def test_valid_url_path(self): }, '.', None)) assert service_dict[0]['build'] == valid_url + def test_invalid_url_in_build_path(self): + invalid_urls = [ + 'example.com/bogus', + 'ftp://example.com/', + '/path/does/not/exist', + ] + for invalid_url in invalid_urls: + with pytest.raises(ConfigurationError) as exc: + config.load(build_config_details({ + 'invalidurl': {'build': invalid_url}, + }, '.', None)) + assert 'build path' in exc.exconly() + class GetDefaultConfigFilesTestCase(unittest.TestCase): From e67419065ac55e8e9aae9bce73021e7da571733c Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 26 Nov 2015 15:06:30 +0000 Subject: [PATCH 1604/4072] Fix ports validation test We were essentially only testing that *at least one* of the invalid values fails the validation check, rather than that *all* of them fail. Signed-off-by: Aanand Prasad --- 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 e15ac3502f7..a33944e77e6 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -264,9 +264,8 @@ def test_config_valid_service_names(self): assert services[0]['name'] == valid_name def test_config_invalid_ports_format_validation(self): - expected_error_msg = "Service 'web' configuration key 'ports' contains an invalid type" - with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): - for invalid_ports in [{"1": "8000"}, False, 0, "8000", 8000, ["8000", "8000"]]: + for invalid_ports in [{"1": "8000"}, False, 0, "8000", 8000, ["8000", "8000"]]: + with pytest.raises(ConfigurationError): config.load( build_config_details( {'web': {'image': 'busybox', 'ports': invalid_ports}}, From ab36c9c6cd5df22f3647b1c619b1ae4c6b604208 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 26 Nov 2015 18:52:14 +0000 Subject: [PATCH 1605/4072] Refactor ports section of fields schema Signed-off-by: Aanand Prasad --- compose/config/fields_schema.json | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/compose/config/fields_schema.json b/compose/config/fields_schema.json index 9cbcfd1b268..3f1f10fa690 100644 --- a/compose/config/fields_schema.json +++ b/compose/config/fields_schema.json @@ -83,16 +83,8 @@ "ports": { "type": "array", "items": { - "oneOf": [ - { - "type": "string", - "format": "ports" - }, - { - "type": "number", - "format": "ports" - } - ] + "type": ["string", "number"], + "format": "ports" }, "uniqueItems": true }, From 527bf3b0234e28d8de73d385c298ae1e0ecd15e2 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 26 Nov 2015 18:54:30 +0000 Subject: [PATCH 1606/4072] Fix ports validation message - The `raises` kwarg to the `cls_check` decorator was being used incorrectly (it should be an exception class, not an object). - We need to check for `error.cause` and get the message out of the exception object. NB: The particular case where validation fails in the case of `ports` is only when ranges don't match in length - no further validation is currently performed client-side. Signed-off-by: Aanand Prasad --- compose/config/validation.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/compose/config/validation.py b/compose/config/validation.py index 38020366d75..24a45e768d7 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -36,16 +36,12 @@ VALID_NAME_CHARS = '[a-zA-Z0-9\._\-]' -@FormatChecker.cls_checks( - format="ports", - raises=ValidationError( - "Invalid port formatting, it should be " - "'[[remote_ip:]remote_port:]port[/protocol]'")) +@FormatChecker.cls_checks(format="ports", raises=ValidationError) def format_ports(instance): try: split_port(instance) - except ValueError: - return False + except ValueError as e: + raise ValidationError(six.text_type(e)) return True @@ -184,6 +180,10 @@ def handle_generic_service_error(error, service_name): config_key, required_keys) + elif error.cause: + error_msg = six.text_type(error.cause) + msg_format = "Service '{}' configuration key {} is invalid: {}" + elif error.path: msg_format = "Service '{}' configuration key {} value {}" From e6fbca42a13c6eda9af333f8775b8369ed0ec585 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 26 Nov 2015 19:17:13 +0000 Subject: [PATCH 1607/4072] Split out ports validation tests into type, uniqueness, format Signed-off-by: Aanand Prasad --- tests/unit/config/config_test.py | 86 ++++++++++++++++++++++++-------- 1 file changed, 64 insertions(+), 22 deletions(-) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index a33944e77e6..863da3b2d97 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -263,28 +263,6 @@ def test_config_valid_service_names(self): 'common.yml')) assert services[0]['name'] == valid_name - def test_config_invalid_ports_format_validation(self): - for invalid_ports in [{"1": "8000"}, False, 0, "8000", 8000, ["8000", "8000"]]: - with pytest.raises(ConfigurationError): - config.load( - build_config_details( - {'web': {'image': 'busybox', 'ports': invalid_ports}}, - 'working_dir', - 'filename.yml' - ) - ) - - def test_config_valid_ports_format_validation(self): - valid_ports = [["8000", "9000"], ["8000/8050"], ["8000"], [8000], ["49153-49154:3002-3003"]] - for ports in valid_ports: - config.load( - build_config_details( - {'web': {'image': 'busybox', 'ports': ports}}, - 'working_dir', - 'filename.yml' - ) - ) - def test_config_hint(self): expected_error_msg = "(did you mean 'privileged'?)" with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): @@ -558,6 +536,70 @@ def test_validate_extra_hosts_invalid_list(self): assert "which is an invalid type" in exc.exconly() +class PortsTest(unittest.TestCase): + INVALID_PORTS_TYPES = [ + {"1": "8000"}, + False, + "8000", + 8000, + ] + + NON_UNIQUE_SINGLE_PORTS = [ + ["8000", "8000"], + ] + + INVALID_PORT_MAPPINGS = [ + ["8000-8001:8000"], + ] + + VALID_SINGLE_PORTS = [ + ["8000"], + ["8000/tcp"], + ["8000", "9000"], + [8000], + [8000, 9000], + ] + + VALID_PORT_MAPPINGS = [ + ["8000:8050"], + ["49153-49154:3002-3003"], + ] + + def test_config_invalid_ports_type_validation(self): + for invalid_ports in self.INVALID_PORTS_TYPES: + with pytest.raises(ConfigurationError) as exc: + self.check_config({'ports': invalid_ports}) + + assert "contains an invalid type" in exc.value.msg + + def test_config_non_unique_ports_validation(self): + for invalid_ports in self.NON_UNIQUE_SINGLE_PORTS: + with pytest.raises(ConfigurationError) as exc: + self.check_config({'ports': invalid_ports}) + + assert "non-unique" in exc.value.msg + + def test_config_invalid_ports_format_validation(self): + for invalid_ports in self.INVALID_PORT_MAPPINGS: + with pytest.raises(ConfigurationError) as exc: + self.check_config({'ports': invalid_ports}) + + assert "Port ranges don't match in length" in exc.value.msg + + def test_config_valid_ports_format_validation(self): + for valid_ports in self.VALID_SINGLE_PORTS + self.VALID_PORT_MAPPINGS: + self.check_config({'ports': valid_ports}) + + def check_config(self, cfg): + config.load( + build_config_details( + {'web': dict(image='busybox', **cfg)}, + 'working_dir', + 'filename.yml' + ) + ) + + class InterpolationTest(unittest.TestCase): @mock.patch.dict(os.environ) def test_config_file_with_environment_variable(self): From 96f4a42a3572ec05bb37116cd53664fb6df629f9 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 26 Nov 2015 19:17:58 +0000 Subject: [PATCH 1608/4072] Validate the 'expose' option Signed-off-by: Aanand Prasad --- compose/config/fields_schema.json | 5 ++++- compose/config/validation.py | 14 +++++++++++++- tests/unit/config/config_test.py | 27 +++++++++++++++++++++++++++ 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/compose/config/fields_schema.json b/compose/config/fields_schema.json index 3f1f10fa690..7d5220e3fea 100644 --- a/compose/config/fields_schema.json +++ b/compose/config/fields_schema.json @@ -41,7 +41,10 @@ "expose": { "type": "array", - "items": {"type": ["string", "number"]}, + "items": { + "type": ["string", "number"], + "format": "expose" + }, "uniqueItems": true }, diff --git a/compose/config/validation.py b/compose/config/validation.py index 24a45e768d7..d16bdb9d3e0 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -1,6 +1,7 @@ import json import logging import os +import re import sys import six @@ -34,6 +35,7 @@ VALID_NAME_CHARS = '[a-zA-Z0-9\._\-]' +VALID_EXPOSE_FORMAT = r'^\d+(\/[a-zA-Z]+)?$' @FormatChecker.cls_checks(format="ports", raises=ValidationError) @@ -45,6 +47,16 @@ def format_ports(instance): return True +@FormatChecker.cls_checks(format="expose", raises=ValidationError) +def format_expose(instance): + if isinstance(instance, six.string_types): + if not re.match(VALID_EXPOSE_FORMAT, instance): + raise ValidationError( + "should be of the format 'PORT[/PROTOCOL]'") + + return True + + @FormatChecker.cls_checks(format="bool-value-in-mapping") def format_boolean_in_environment(instance): """ @@ -273,7 +285,7 @@ def validate_against_fields_schema(config, filename): _validate_against_schema( config, "fields_schema.json", - format_checker=["ports", "bool-value-in-mapping"], + format_checker=["ports", "expose", "bool-value-in-mapping"], filename=filename) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 863da3b2d97..20705d55533 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -590,6 +590,33 @@ def test_config_valid_ports_format_validation(self): for valid_ports in self.VALID_SINGLE_PORTS + self.VALID_PORT_MAPPINGS: self.check_config({'ports': valid_ports}) + def test_config_invalid_expose_type_validation(self): + for invalid_expose in self.INVALID_PORTS_TYPES: + with pytest.raises(ConfigurationError) as exc: + self.check_config({'expose': invalid_expose}) + + assert "contains an invalid type" in exc.value.msg + + def test_config_non_unique_expose_validation(self): + for invalid_expose in self.NON_UNIQUE_SINGLE_PORTS: + with pytest.raises(ConfigurationError) as exc: + self.check_config({'expose': invalid_expose}) + + assert "non-unique" in exc.value.msg + + def test_config_invalid_expose_format_validation(self): + # Valid port mappings ARE NOT valid 'expose' entries + for invalid_expose in self.INVALID_PORT_MAPPINGS + self.VALID_PORT_MAPPINGS: + with pytest.raises(ConfigurationError) as exc: + self.check_config({'expose': invalid_expose}) + + assert "should be of the format" in exc.value.msg + + def test_config_valid_expose_format_validation(self): + # Valid single ports ARE valid 'expose' entries + for valid_expose in self.VALID_SINGLE_PORTS: + self.check_config({'expose': valid_expose}) + def check_config(self, cfg): config.load( build_config_details( From aaf66e34856361cfbf63638fa5bad93d6d75643a Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 21 Oct 2015 20:13:43 -0400 Subject: [PATCH 1609/4072] FAQ document for Compose Signed-off-by: Daniel Nephin --- docs/faq.md | 139 +++++++++++++++++++++++++ docs/index.md | 1 + script/travis/render-bintray-config.py | 4 +- 3 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 docs/faq.md diff --git a/docs/faq.md b/docs/faq.md new file mode 100644 index 00000000000..b36eb5ac5d3 --- /dev/null +++ b/docs/faq.md @@ -0,0 +1,139 @@ + + +# 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. + +## Why do my services take 10 seconds to 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`. + +* 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/docker-compose.md) or the [`COMPOSE_PROJECT_NAME` +environment variable](./reference/overview.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 +``` + +## 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 +`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/index.md b/docs/index.md index 279154eef9f..8b32a754149 100644 --- a/docs/index.md +++ b/docs/index.md @@ -59,6 +59,7 @@ Compose has commands for managing the whole lifecycle of your application: - [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) diff --git a/script/travis/render-bintray-config.py b/script/travis/render-bintray-config.py index 6aa468d6dc5..fc5d409a05c 100755 --- a/script/travis/render-bintray-config.py +++ b/script/travis/render-bintray-config.py @@ -1,4 +1,6 @@ #!/usr/bin/env python +from __future__ import print_function + import datetime import os.path import sys @@ -6,4 +8,4 @@ os.environ['DATE'] = str(datetime.date.today()) for line in sys.stdin: - print os.path.expandvars(line), + print(os.path.expandvars(line), end='') From 7240ff35eeac36d7fb53892af495bb172e0e00c2 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 2 Dec 2015 17:06:17 -0800 Subject: [PATCH 1610/4072] Bump 1.5.2 Signed-off-by: Daniel Nephin --- CHANGELOG.md | 22 ++++++++++++++++++++++ compose/__init__.py | 2 +- docs/install.md | 6 +++--- script/run.sh | 2 +- 4 files changed, 27 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 50aabcb8e99..955184272ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,28 @@ Change log ========== +1.5.2 (2015-12-03) +------------------ + +- Fixed a bug which broke the use of `environment` and `env_file` with + `extends`, and caused environment keys without values to have a `None` + value, instead of a value from the host environment. + +- Fixed a regression in 1.5.1 that caused a warning about volumes to be + raised incorrectly when containers were recreated. + +- Fixed a bug which prevented building a `Dockerfile` that used `ADD ` + +- Fixed a bug with `docker-compose restart` which prevented it from + starting stopped containers. + +- Fixed handling of SIGTERM and SIGINT to properly stop containers + +- Add support for using a url as the value of `build` + +- Improved the validation of the `expose` option + + 1.5.1 (2015-11-12) ------------------ diff --git a/compose/__init__.py b/compose/__init__.py index 5f2b332afbf..03d61530a91 100644 --- a/compose/__init__.py +++ b/compose/__init__.py @@ -1,3 +1,3 @@ from __future__ import unicode_literals -__version__ = '1.5.1' +__version__ = '1.5.2' diff --git a/docs/install.md b/docs/install.md index 5bbd6e595e1..6635ddf193b 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.5.1/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose + curl -L https://github.com/docker/compose/releases/download/1.5.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.5.1 + docker-compose version: 1.5.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.5.1/run.sh > /usr/local/bin/docker-compose + $ curl -L https://github.com/docker/compose/releases/download/1.5.2/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 9563b2e9cc1..c5f7cc865d5 100755 --- a/script/run.sh +++ b/script/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.5.1" +VERSION="1.5.2" IMAGE="docker/compose:$VERSION" From 7698da57ca9c8c17001a137dd128fca2881957ac Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Fri, 4 Dec 2015 16:50:50 +0100 Subject: [PATCH 1611/4072] update maintainers file for parsing this updates the MAINTAINERS file to the new format, so that it can be parsed and collected in the docker/opensource repository. Signed-off-by: Sebastiaan van Stijn --- MAINTAINERS | 50 ++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 46 insertions(+), 4 deletions(-) diff --git a/MAINTAINERS b/MAINTAINERS index 003242327a6..820b2f82999 100644 --- a/MAINTAINERS +++ b/MAINTAINERS @@ -1,4 +1,46 @@ -Aanand Prasad (@aanand) -Ben Firshman (@bfirsh) -Daniel Nephin (@dnephin) -Mazz Mosley (@mnowster) +# Compose maintainers file +# +# This file describes who runs the docker/compose project and how. +# This is a living document - if you see something out of date or missing, speak up! +# +# It is structured to be consumable by both humans and programs. +# To extract its contents programmatically, use any TOML-compliant parser. +# +# This file is compiled into the MAINTAINERS file in docker/opensource. +# +[Org] + [Org."Core maintainers"] + people = [ + "aanand", + "bfirsh", + "dnephin", + "mnowster", + ] + +[people] + +# A reference list of all people associated with the project. +# All other sections should refer to people by their canonical key +# in the people section. + + # ADD YOURSELF HERE IN ALPHABETICAL ORDER + + [people.aanand] + Name = "Aanand Prasad" + Email = "aanand.prasad@gmail.com" + GitHub = "aanand" + + [people.bfirsh] + Name = "Ben Firshman" + Email = "ben@firshman.co.uk" + GitHub = "bfirsh" + + [people.dnephin] + Name = "Daniel Nephin" + Email = "dnephin@gmail.com" + GitHub = "dnephin" + + [people.mnowster] + Name = "Mazz Mosley" + Email = "mazz@houseofmnowster.com" + GitHub = "mnowster" From de4a18ea6c1b4addfeb3aad32204a01a90a2e776 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 2 Dec 2015 17:06:17 -0800 Subject: [PATCH 1612/4072] Cherry-pick release notes for 1.5.2 Signed-off-by: Daniel Nephin --- CHANGELOG.md | 22 ++++++++++++++++++++++ docs/install.md | 6 +++--- script/run.sh | 2 +- 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 428e5a933c2..7b6e0dd396f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,28 @@ Change log ========== +1.5.2 (2015-12-03) +------------------ + +- Fixed a bug which broke the use of `environment` and `env_file` with + `extends`, and caused environment keys without values to have a `None` + value, instead of a value from the host environment. + +- Fixed a regression in 1.5.1 that caused a warning about volumes to be + raised incorrectly when containers were recreated. + +- Fixed a bug which prevented building a `Dockerfile` that used `ADD ` + +- Fixed a bug with `docker-compose restart` which prevented it from + starting stopped containers. + +- Fixed handling of SIGTERM and SIGINT to properly stop containers + +- Add support for using a url as the value of `build` + +- Improved the validation of the `expose` option + + 1.5.1 (2015-11-12) ------------------ diff --git a/docs/install.md b/docs/install.md index 861954b4b54..980d285c569 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.5.1/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose + curl -L https://github.com/docker/compose/releases/download/1.5.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.5.1 + docker-compose version: 1.5.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.5.1/run.sh > /usr/local/bin/docker-compose + $ curl -L https://github.com/docker/compose/releases/download/1.5.2/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 9563b2e9cc1..c5f7cc865d5 100755 --- a/script/run.sh +++ b/script/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.5.1" +VERSION="1.5.2" IMAGE="docker/compose:$VERSION" From 2525752a05464d964b184a8a70d2b96674a69bbc Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Mon, 7 Dec 2015 09:10:19 +0100 Subject: [PATCH 1613/4072] Use more robust download URL for completions Signed-off-by: Harald Albers --- docs/completion.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/completion.md b/docs/completion.md index 3c2022d8278..cac0d1a65b3 100644 --- a/docs/completion.md +++ b/docs/completion.md @@ -23,7 +23,7 @@ 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 | awk 'NR==1{print $NF}')/contrib/completion/bash/docker-compose > /etc/bash_completion.d/docker-compose + 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. @@ -32,7 +32,7 @@ Completion will be available upon next login. 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 | awk 'NR==1{print $NF}')/contrib/completion/zsh/_docker-compose > ~/.zsh/completion/_docker-compose + 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` From f2c232bb1013f6734f9556a67766373d08473c5b Mon Sep 17 00:00:00 2001 From: Nick Jones Date: Tue, 1 Dec 2015 16:20:36 +0000 Subject: [PATCH 1614/4072] Only allocate a tty if we detect one Signed-off-by: Nick Jones --- script/run.sh | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/script/run.sh b/script/run.sh index 9563b2e9cc1..6990799c54c 100755 --- a/script/run.sh +++ b/script/run.sh @@ -43,5 +43,11 @@ if [ -n "$HOME" ]; then VOLUMES="$VOLUMES -v $HOME:$HOME" fi +# Only allocate tty if we detect one +if [ -t 1 ]; then + DOCKER_RUN_OPTIONS="-ti" +else + DOCKER_RUN_OPTIONS="-i" +fi -exec docker run --rm -ti $DOCKER_ADDR $COMPOSE_OPTIONS $VOLUMES -w $(pwd) $IMAGE $@ +exec docker run --rm $DOCKER_RUN_OPTIONS $DOCKER_ADDR $COMPOSE_OPTIONS $VOLUMES -w $(pwd) $IMAGE $@ From 437f3f8adbc3012e07b4629e8a668a7bb18e8ddd Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Sat, 10 Oct 2015 14:41:17 -0400 Subject: [PATCH 1615/4072] Add docker-compose config subcommand. Signed-off-by: Daniel Nephin --- compose/cli/command.py | 5 ++- compose/cli/main.py | 38 ++++++++++++++++ tests/acceptance/cli_test.py | 44 +++++++++++++++---- .../fixtures/invalid-composefile/invalid.yml | 5 +++ 4 files changed, 82 insertions(+), 10 deletions(-) create mode 100644 tests/fixtures/invalid-composefile/invalid.yml diff --git a/compose/cli/command.py b/compose/cli/command.py index 157e00161b8..59f6c4bc98d 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -46,7 +46,7 @@ def friendly_error_message(): def project_from_options(base_dir, options): return get_project( base_dir, - get_config_path(options.get('--file')), + get_config_path_from_options(options), project_name=options.get('--project-name'), verbose=options.get('--verbose'), use_networking=options.get('--x-networking'), @@ -54,7 +54,8 @@ def project_from_options(base_dir, options): ) -def get_config_path(file_option): +def get_config_path_from_options(options): + file_option = options.get('--file') if file_option: return file_option diff --git a/compose/cli/main.py b/compose/cli/main.py index 62db5183d53..f30ea3340eb 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -8,10 +8,12 @@ from inspect import getdoc from operator import attrgetter +import yaml from docker.errors import APIError from requests.exceptions import ReadTimeout from .. import __version__ +from ..config import config from ..config import ConfigurationError from ..config import parse_environment from ..const import DEFAULT_TIMEOUT @@ -23,6 +25,7 @@ from ..service import ConvergenceStrategy 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 NoSuchCommand @@ -126,6 +129,7 @@ class TopLevelCommand(DocoptCommand): Commands: build Build or rebuild services + config Validate and view the compose file help Get help on a command kill Kill containers logs View output from containers @@ -158,6 +162,10 @@ def perform_command(self, options, handler, command_options): 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) @@ -183,6 +191,36 @@ def build(self, project, options): pull=bool(options.get('--pull', False)), force_rm=bool(options.get('--force-rm', False))) + def config(self, config_options, options): + """ + Validate and view the compose file. + + Usage: config [options] + + Options: + -q, --quiet Only validate the configuration, don't print + anything. + --services Print the service names, one per line. + + """ + config_path = get_config_path_from_options(config_options) + compose_config = config.load(config.find(self.base_dir, config_path)) + + if options['--quiet']: + return + + if options['--services']: + print('\n'.join(service['name'] for service in compose_config)) + return + + compose_config = dict( + (service.pop('name'), service) for service in compose_config) + print(yaml.dump( + compose_config, + default_flow_style=False, + indent=2, + width=80)) + def help(self, project, options): """ Get help on a command. diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 99b78d081b9..0d26ea1f2d2 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -7,6 +7,7 @@ import time from collections import namedtuple from operator import attrgetter +from textwrap import dedent from docker import errors @@ -90,10 +91,11 @@ def setUp(self): self.base_dir = 'tests/fixtures/simple-composefile' def tearDown(self): - self.project.kill() - self.project.remove_stopped() - for container in self.project.containers(stopped=True, one_off=True): - container.remove(force=True) + if self.base_dir: + self.project.kill() + self.project.remove_stopped() + for container in self.project.containers(stopped=True, one_off=True): + container.remove(force=True) super(CLITestCase, self).tearDown() @property @@ -109,13 +111,39 @@ def dispatch(self, options, project_options=None, returncode=0): return wait_on_process(proc, returncode=returncode) def test_help(self): - old_base_dir = self.base_dir self.base_dir = 'tests/fixtures/no-composefile' result = self.dispatch(['help', 'up'], returncode=1) assert 'Usage: up [options] [SERVICE...]' in result.stderr - # self.project.kill() fails during teardown - # unless there is a composefile. - self.base_dir = old_base_dir + # Prevent tearDown from trying to create a project + self.base_dir = None + + def test_config_list_services(self): + result = self.dispatch(['config', '--services']) + assert set(result.stdout.rstrip().split('\n')) == {'simple', 'another'} + + def test_config_quiet_with_error(self): + self.base_dir = None + result = self.dispatch([ + '-f', 'tests/fixtures/invalid-composefile/invalid.yml', + 'config', '-q' + ], returncode=1) + assert "'notaservice' doesn't have any configuration" in result.stderr + + def test_config_quiet(self): + assert self.dispatch(['config', '-q']).stdout == '' + + def test_config_default(self): + result = self.dispatch(['config']) + assert dedent(""" + simple: + command: top + image: busybox:latest + """).lstrip() in result.stdout + assert dedent(""" + another: + command: top + image: busybox:latest + """).lstrip() in result.stdout def test_ps(self): self.project.get_service('simple').create_container() diff --git a/tests/fixtures/invalid-composefile/invalid.yml b/tests/fixtures/invalid-composefile/invalid.yml new file mode 100644 index 00000000000..0e74be440a8 --- /dev/null +++ b/tests/fixtures/invalid-composefile/invalid.yml @@ -0,0 +1,5 @@ + +notaservice: oops + +web: + image: 'alpine:edge' From a5b48a3dc2b7e4545959801e5a669e7b6dbc2b23 Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Tue, 8 Dec 2015 10:23:55 -0800 Subject: [PATCH 1616/4072] Add bash completion for config subcommand Signed-off-by: Harald Albers --- contrib/completion/bash/docker-compose | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index c22e6abc3d3..497a818484a 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -102,6 +102,11 @@ _docker_compose_build() { } +_docker_compose_config() { + COMPREPLY=( $( compgen -W "--help --quiet -q --services" -- "$cur" ) ) +} + + _docker_compose_docker_compose() { case "$prev" in --file|-f) @@ -373,6 +378,7 @@ _docker_compose() { local commands=( build + config help kill logs From 999d15b2256cf279afc310ec8ae843f073a21d26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Seguin?= Date: Tue, 8 Dec 2015 21:11:05 +0100 Subject: [PATCH 1617/4072] Remove unused functions in service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Stéphane Seguin --- compose/service.py | 28 ------------------------ tests/acceptance/cli_test.py | 9 ++++++-- tests/integration/service_test.py | 36 ------------------------------- 3 files changed, 7 insertions(+), 66 deletions(-) diff --git a/compose/service.py b/compose/service.py index 0b9a2aa3bc8..0387b6e99e5 100644 --- a/compose/service.py +++ b/compose/service.py @@ -141,34 +141,6 @@ def start(self, **options): for c in self.containers(stopped=True): self.start_container_if_stopped(c, **options) - # TODO: remove these functions, project takes care of starting/stopping, - def stop(self, **options): - for c in self.containers(): - log.info("Stopping %s" % c.name) - c.stop(**options) - - def pause(self, **options): - for c in self.containers(filters={'status': 'running'}): - log.info("Pausing %s" % c.name) - c.pause(**options) - - def unpause(self, **options): - for c in self.containers(filters={'status': 'paused'}): - log.info("Unpausing %s" % c.name) - c.unpause() - - def kill(self, **options): - for c in self.containers(): - log.info("Killing %s" % c.name) - c.kill(**options) - - def restart(self, **options): - for c in self.containers(stopped=True): - log.info("Restarting %s" % c.name) - c.restart(**options) - - # end TODO - def scale(self, desired_num, timeout=DEFAULT_TIMEOUT): """ Adjusts the number of containers to the specified number and ensures diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 0d26ea1f2d2..66619629396 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -52,6 +52,11 @@ def wait_on_condition(condition, delay=0.1, timeout=20): time.sleep(delay) +def kill_service(service): + for container in service.containers(): + container.kill() + + class ContainerCountCondition(object): def __init__(self, project, expected): @@ -637,13 +642,13 @@ def test_run_handles_sigterm(self): def test_rm(self): service = self.project.get_service('simple') service.create_container() - service.kill() + kill_service(service) self.assertEqual(len(service.containers(stopped=True)), 1) self.dispatch(['rm', '--force'], None) self.assertEqual(len(service.containers(stopped=True)), 0) service = self.project.get_service('simple') service.create_container() - service.kill() + kill_service(service) self.assertEqual(len(service.containers(stopped=True)), 1) self.dispatch(['rm', '-f'], None) self.assertEqual(len(service.containers(stopped=True)), 0) diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index b03baac595a..5a809423a12 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -73,42 +73,6 @@ def test_project_is_added_to_container_name(self): create_and_start_container(service) self.assertEqual(service.containers()[0].name, 'composetest_web_1') - def test_start_stop(self): - service = self.create_service('scalingtest') - self.assertEqual(len(service.containers(stopped=True)), 0) - - service.create_container() - self.assertEqual(len(service.containers()), 0) - self.assertEqual(len(service.containers(stopped=True)), 1) - - service.start() - self.assertEqual(len(service.containers()), 1) - self.assertEqual(len(service.containers(stopped=True)), 1) - - service.stop(timeout=1) - self.assertEqual(len(service.containers()), 0) - self.assertEqual(len(service.containers(stopped=True)), 1) - - service.stop(timeout=1) - self.assertEqual(len(service.containers()), 0) - self.assertEqual(len(service.containers(stopped=True)), 1) - - def test_kill_remove(self): - service = self.create_service('scalingtest') - - create_and_start_container(service) - self.assertEqual(len(service.containers()), 1) - - remove_stopped(service) - self.assertEqual(len(service.containers()), 1) - - service.kill() - self.assertEqual(len(service.containers()), 0) - self.assertEqual(len(service.containers(stopped=True)), 1) - - remove_stopped(service) - self.assertEqual(len(service.containers(stopped=True)), 0) - def test_create_container_with_one_off(self): db = self.create_service('db') container = db.create_container(one_off=True) From 01bba5ea239fb747b9cd5ca34bf9c5f168f8a6d4 Mon Sep 17 00:00:00 2001 From: Steve Durrheimer Date: Wed, 9 Dec 2015 08:55:59 +0100 Subject: [PATCH 1618/4072] Add zsh completion for config subcommand 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 0b50b5358b1..67ca49bbb27 100644 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -197,6 +197,12 @@ __docker-compose_subcommand() { '--pull[Always attempt to pull a newer version of the image.]' \ '*:services:__docker-compose_services_from_build' && ret=0 ;; + (config) + _arguments \ + $opts_help \ + '(--quiet -q)'{--quiet,-q}"[Only validate the configuration, don't print anything.]" \ + '--services[Print the service names, one per line.]' && ret=0 + ;; (help) _arguments ':subcommand:__docker-compose_commands' && ret=0 ;; From fa3528ea2558cc4b1efca9ba5ea061b57191ae30 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 9 Dec 2015 16:32:39 -0800 Subject: [PATCH 1619/4072] Fix dns and dns_search when used strings and without extends. Signed-off-by: Daniel Nephin --- compose/config/config.py | 4 ++++ tests/unit/config/config_test.py | 21 +++++++++++++++++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 853157ee89f..a2ccecc4ff1 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -387,6 +387,10 @@ 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']: + if field in service_dict: + service_dict[field] = to_list(service_dict[field]) + # TODO: move to a validate_service() if 'ulimits' in service_dict: validate_ulimits(service_dict['ulimits']) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 20705d55533..2185b792039 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -535,6 +535,23 @@ def test_validate_extra_hosts_invalid_list(self): })) assert "which is an invalid type" in exc.exconly() + def test_normalize_dns_options(self): + actual = config.load(build_config_details({ + 'web': { + 'image': 'alpine', + 'dns': '8.8.8.8', + 'dns_search': 'domain.local', + } + })) + assert actual == [ + { + 'name': 'web', + 'image': 'alpine', + 'dns': ['8.8.8.8'], + 'dns_search': ['domain.local'], + } + ] + class PortsTest(unittest.TestCase): INVALID_PORTS_TYPES = [ @@ -1080,8 +1097,8 @@ def test_resolve_environment_nonexistent_file(self): {'foo': {'image': 'example', 'env_file': 'nonexistent.env'}}, working_dir='tests/fixtures/env')) - assert 'Couldn\'t find env file' in exc.exconly() - assert 'nonexistent.env' in exc.exconly() + assert 'Couldn\'t find env file' in exc.exconly() + assert 'nonexistent.env' in exc.exconly() @mock.patch.dict(os.environ) def test_resolve_environment_from_env_file_with_empty_values(self): From 1d3aeaaae7085995ab2f2d6b482b3dd64794d8aa Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 9 Dec 2015 16:03:26 -0800 Subject: [PATCH 1620/4072] Ignore extra coverge files These files are created because we run acceptance tests in a subprocess. They have the process id in their name, so they wont be removed by the normal coverage cleanup on each run. Signed-off-by: Daniel Nephin --- .gitignore | 2 +- script/clean | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index da72827974f..4b318e2328c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ *.egg-info *.pyc -/.coverage +.coverage* /.tox /build /coverage-html diff --git a/script/clean b/script/clean index 08ba551ae97..35faf4dba45 100755 --- a/script/clean +++ b/script/clean @@ -2,5 +2,6 @@ set -e find . -type f -name '*.pyc' -delete +find . -name .coverage.* -delete find -name __pycache__ -delete rm -rf docs/_site build dist docker-compose.egg-info From 6d709caaa50d094c4948355bfab24c97d8f2ed94 Mon Sep 17 00:00:00 2001 From: Dimitar Bonev Date: Thu, 10 Dec 2015 22:56:45 +0200 Subject: [PATCH 1621/4072] Fixes incorrect network name shown in the log when no driver is specified Signed-off-by: Dimitar Bonev --- compose/project.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/compose/project.py b/compose/project.py index af40d820ae7..84413174ef1 100644 --- a/compose/project.py +++ b/compose/project.py @@ -297,9 +297,13 @@ def get_network(self): def ensure_network_exists(self): # TODO: recreate network if driver has changed? if self.get_network() is None: + driver_name = 'the default driver' + if self.network_driver: + driver_name = 'driver "{}"'.format(self.network_driver) + log.info( - 'Creating network "{}" with driver "{}"' - .format(self.name, self.network_driver) + 'Creating network "{}" with {}' + .format(self.name, driver_name) ) self.client.create_network(self.name, driver=self.network_driver) From c8f266b637c1ec8c8a017d18d4f2bf4339055ae0 Mon Sep 17 00:00:00 2001 From: Jean Praloran Date: Wed, 16 Dec 2015 08:19:28 +1300 Subject: [PATCH 1622/4072] add restarting status for human_readable_state Signed-off-by: Jean Praloran --- compose/container.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/compose/container.py b/compose/container.py index 1ca483809ab..8f96a9447d2 100644 --- a/compose/container.py +++ b/compose/container.py @@ -115,6 +115,8 @@ def log_config(self): def human_readable_state(self): if self.is_paused: return 'Paused' + if self.is_restarting: + return 'Restarting' if self.is_running: return 'Ghost' if self.get('State.Ghost') else 'Up' else: @@ -134,6 +136,10 @@ def environment(self): def is_running(self): return self.get('State.Running') + @property + def is_restarting(self): + return self.get('State.Restarting') + @property def is_paused(self): return self.get('State.Paused') From bc843d67588c6305b82cd2eb7c057cfdef02a4eb Mon Sep 17 00:00:00 2001 From: Dimitar Bonev Date: Tue, 15 Dec 2015 20:05:22 +0200 Subject: [PATCH 1623/4072] Start, restart, pause and unpause exit with non-zero if nothing to do Signed-off-by: Dimitar Bonev --- compose/cli/main.py | 18 ++++++++++++++---- compose/project.py | 17 +++++++++++++---- compose/service.py | 4 +++- tests/acceptance/cli_test.py | 16 ++++++++++++++++ 4 files changed, 46 insertions(+), 9 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index f30ea3340eb..781c576206a 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -265,7 +265,8 @@ def pause(self, project, options): Usage: pause [SERVICE...] """ - project.pause(service_names=options['SERVICE']) + containers = project.pause(service_names=options['SERVICE']) + exit_if(not containers, 'No containers to pause', 1) def port(self, project, options): """ @@ -476,7 +477,8 @@ def start(self, project, options): Usage: start [SERVICE...] """ - project.start(service_names=options['SERVICE']) + containers = project.start(service_names=options['SERVICE']) + exit_if(not containers, 'No containers to start', 1) def stop(self, project, options): """ @@ -504,7 +506,8 @@ def restart(self, project, options): (default: 10) """ timeout = int(options.get('--timeout') or DEFAULT_TIMEOUT) - project.restart(service_names=options['SERVICE'], timeout=timeout) + containers = project.restart(service_names=options['SERVICE'], timeout=timeout) + exit_if(not containers, 'No containers to restart', 1) def unpause(self, project, options): """ @@ -512,7 +515,8 @@ def unpause(self, project, options): Usage: unpause [SERVICE...] """ - project.unpause(service_names=options['SERVICE']) + containers = project.unpause(service_names=options['SERVICE']) + exit_if(not containers, 'No containers to unpause', 1) def up(self, project, options): """ @@ -674,3 +678,9 @@ def set_signal_handler(handler): def list_containers(containers): return ", ".join(c.name for c in containers) + + +def exit_if(condition, message, exit_code): + if condition: + log.error(message) + raise SystemExit(exit_code) diff --git a/compose/project.py b/compose/project.py index 84413174ef1..1cb1daa7ad3 100644 --- a/compose/project.py +++ b/compose/project.py @@ -187,17 +187,24 @@ def get_net(self, service_dict): net_name)) def start(self, service_names=None, **options): + containers = [] for service in self.get_services(service_names): - service.start(**options) + service_containers = service.start(**options) + containers.extend(service_containers) + return containers def stop(self, service_names=None, **options): parallel.parallel_stop(self.containers(service_names), options) def pause(self, service_names=None, **options): - parallel.parallel_pause(reversed(self.containers(service_names)), options) + containers = self.containers(service_names) + parallel.parallel_pause(reversed(containers), options) + return containers def unpause(self, service_names=None, **options): - parallel.parallel_unpause(self.containers(service_names), options) + containers = self.containers(service_names) + parallel.parallel_unpause(containers, options) + return containers def kill(self, service_names=None, **options): parallel.parallel_kill(self.containers(service_names), options) @@ -206,7 +213,9 @@ def remove_stopped(self, service_names=None, **options): parallel.parallel_remove(self.containers(service_names, stopped=True), options) def restart(self, service_names=None, **options): - parallel.parallel_restart(self.containers(service_names, stopped=True), options) + containers = self.containers(service_names, stopped=True) + parallel.parallel_restart(containers, options) + return containers def build(self, service_names=None, no_cache=False, pull=False, force_rm=False): for service in self.get_services(service_names): diff --git a/compose/service.py b/compose/service.py index 0387b6e99e5..e04ef271744 100644 --- a/compose/service.py +++ b/compose/service.py @@ -138,8 +138,10 @@ def get_container(self, number=1): raise ValueError("No container found for %s_%s" % (self.name, number)) def start(self, **options): - for c in self.containers(stopped=True): + containers = self.containers(stopped=True) + for c in containers: self.start_container_if_stopped(c, **options) + return containers def scale(self, desired_num, timeout=DEFAULT_TIMEOUT): """ diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 66619629396..17b83fe3242 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -664,6 +664,10 @@ def test_stop(self): self.assertEqual(len(service.containers(stopped=True)), 1) self.assertFalse(service.containers(stopped=True)[0].is_running) + def test_start_no_containers(self): + result = self.dispatch(['start'], returncode=1) + assert 'No containers to start' in result.stderr + def test_pause_unpause(self): self.dispatch(['up', '-d'], None) service = self.project.get_service('simple') @@ -675,6 +679,14 @@ def test_pause_unpause(self): self.dispatch(['unpause'], None) self.assertFalse(service.containers()[0].is_paused) + def test_pause_no_containers(self): + result = self.dispatch(['pause'], returncode=1) + assert 'No containers to pause' in result.stderr + + def test_unpause_no_containers(self): + result = self.dispatch(['unpause'], returncode=1) + assert 'No containers to unpause' in result.stderr + def test_logs_invalid_service_name(self): self.dispatch(['logs', 'madeupname'], returncode=1) @@ -737,6 +749,10 @@ def test_restart_stopped_container(self): self.dispatch(['restart', '-t', '1'], None) self.assertEqual(len(service.containers(stopped=False)), 1) + def test_restart_no_containers(self): + result = self.dispatch(['restart'], returncode=1) + assert 'No containers to restart' in result.stderr + def test_scale(self): project = self.project From a5420412646d5d8912949d2b114f364162fd2cd1 Mon Sep 17 00:00:00 2001 From: Dimitar Bonev Date: Wed, 16 Dec 2015 21:25:30 +0200 Subject: [PATCH 1624/4072] Added support for cpu_quota flag Signed-off-by: Dimitar Bonev --- compose/config/config.py | 1 + compose/config/fields_schema.json | 1 + compose/service.py | 2 ++ docs/compose-file.md | 3 ++- tests/integration/service_test.py | 6 ++++++ 5 files changed, 12 insertions(+), 1 deletion(-) diff --git a/compose/config/config.py b/compose/config/config.py index a2ccecc4ff1..0c6448331c7 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -31,6 +31,7 @@ 'cap_drop', 'cgroup_parent', 'command', + 'cpu_quota', 'cpu_shares', 'cpuset', 'detach', diff --git a/compose/config/fields_schema.json b/compose/config/fields_schema.json index 7d5220e3fea..fdf56fd91d1 100644 --- a/compose/config/fields_schema.json +++ b/compose/config/fields_schema.json @@ -29,6 +29,7 @@ }, "container_name": {"type": "string"}, "cpu_shares": {"type": ["number", "string"]}, + "cpu_quota": {"type": ["number", "string"]}, "cpuset": {"type": "string"}, "devices": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, "dns": {"$ref": "#/definitions/string_or_list"}, diff --git a/compose/service.py b/compose/service.py index e04ef271744..3b54c2a7a71 100644 --- a/compose/service.py +++ b/compose/service.py @@ -59,6 +59,7 @@ 'restart', 'volumes_from', 'security_opt', + 'cpu_quota', ] @@ -610,6 +611,7 @@ def _get_container_host_config(self, override_options, one_off=False): security_opt=options.get('security_opt'), ipc_mode=options.get('ipc'), cgroup_parent=options.get('cgroup_parent'), + cpu_quota=options.get('cpu_quota'), ) def build(self, no_cache=False, pull=False, force_rm=False): diff --git a/docs/compose-file.md b/docs/compose-file.md index 800d2aa9896..c0f2efbe546 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -385,12 +385,13 @@ specifying read-only access(``ro``) or read-write(``rw``). - container_name - service_name:rw -### cpu\_shares, cpuset, domainname, entrypoint, hostname, ipc, mac\_address, mem\_limit, memswap\_limit, privileged, read\_only, restart, stdin\_open, tty, user, working\_dir +### cpu\_shares, cpu\_quota, cpuset, domainname, entrypoint, hostname, ipc, mac\_address, mem\_limit, memswap\_limit, privileged, read\_only, restart, stdin\_open, tty, user, working\_dir Each of these is a single value, analogous to its [docker run](https://docs.docker.com/reference/run/) counterpart. cpu_shares: 73 + cpu_quota: 50000 cpuset: 0,1 entrypoint: /code/entrypoint.sh diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 5a809423a12..59ba487a767 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -102,6 +102,12 @@ def test_create_container_with_cpu_shares(self): container.start() self.assertEqual(container.get('HostConfig.CpuShares'), 73) + def test_create_container_with_cpu_quota(self): + service = self.create_service('db', cpu_quota=40000) + container = service.create_container() + container.start() + self.assertEqual(container.get('HostConfig.CpuQuota'), 40000) + 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 2e9a49b4eb48d7611543bf5cb34130e8f5448dff Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 17 Dec 2015 17:50:45 +0000 Subject: [PATCH 1625/4072] Clarify behaviour of 'rm' Signed-off-by: Aanand Prasad --- compose/cli/main.py | 5 +++++ docs/reference/rm.md | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/compose/cli/main.py b/compose/cli/main.py index f30ea3340eb..8799880f5d1 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -343,6 +343,11 @@ def rm(self, project, options): """ Remove stopped service containers. + By default, volumes attached to containers will not be removed. You can see all + volumes with `docker volume ls`. + + Any data which is not in a volume will be lost. + Usage: rm [options] [SERVICE...] Options: diff --git a/docs/reference/rm.md b/docs/reference/rm.md index 2ed959e411c..f84792243fc 100644 --- a/docs/reference/rm.md +++ b/docs/reference/rm.md @@ -20,3 +20,8 @@ Options: ``` Removes stopped service containers. + +By default, volumes attached to containers will not be removed. You can see all +volumes with `docker volume ls`. + +Any data which is not in a volume will be lost. From 3c76d5a46770b68910223456226a5353ce2f51c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Seguin?= Date: Mon, 14 Dec 2015 22:46:13 +0100 Subject: [PATCH 1626/4072] Add docker-compose create command. Closes #1125 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Stéphane Seguin --- compose/cli/main.py | 22 +++++++++++ compose/project.py | 19 +++++++-- compose/service.py | 20 ++++++---- tests/acceptance/cli_test.py | 46 ++++++++++++++++++++++ tests/integration/project_test.py | 65 +++++++++++++++++++++++++++++++ tests/integration/service_test.py | 18 +++++++++ 6 files changed, 179 insertions(+), 11 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index f30ea3340eb..a98795e3080 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -130,6 +130,7 @@ class TopLevelCommand(DocoptCommand): Commands: build Build or rebuild services config Validate and view the compose file + create Create services help Get help on a command kill Kill containers logs View output from containers @@ -221,6 +222,27 @@ def config(self, config_options, options): indent=2, width=80)) + def create(self, project, options): + """ + 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 + """ + service_names = options['SERVICE'] + + project.create( + service_names=service_names, + strategy=convergence_strategy_from_opts(options), + do_build=not options['--no-build'] + ) + def help(self, project, options): """ Get help on a command. diff --git a/compose/project.py b/compose/project.py index 84413174ef1..d404685620e 100644 --- a/compose/project.py +++ b/compose/project.py @@ -123,6 +123,12 @@ def get_services(self, service_names=None, include_deps=False): [uniques.append(s) for s in services if s not in uniques] return uniques + def get_services_without_duplicate(self, service_names=None, include_deps=False): + services = self.get_services(service_names, include_deps) + for service in services: + service.remove_duplicate_containers() + return services + def get_links(self, service_dict): links = [] if 'links' in service_dict: @@ -215,6 +221,14 @@ def build(self, service_names=None, no_cache=False, pull=False, force_rm=False): else: log.info('%s uses an image, skipping' % service.name) + def create(self, service_names=None, strategy=ConvergenceStrategy.changed, do_build=True): + services = self.get_services_without_duplicate(service_names, include_deps=True) + + plans = self._get_convergence_plans(services, strategy) + + for service in services: + service.execute_convergence_plan(plans[service.name], do_build, detached=True, start=False) + def up(self, service_names=None, start_deps=True, @@ -223,10 +237,7 @@ def up(self, timeout=DEFAULT_TIMEOUT, detached=False): - services = self.get_services(service_names, include_deps=start_deps) - - for service in services: - service.remove_duplicate_containers() + services = self.get_services_without_duplicate(service_names, include_deps=start_deps) plans = self._get_convergence_plans(services, strategy) diff --git a/compose/service.py b/compose/service.py index 0387b6e99e5..791e57aefec 100644 --- a/compose/service.py +++ b/compose/service.py @@ -328,7 +328,8 @@ def execute_convergence_plan(self, plan, do_build=True, timeout=DEFAULT_TIMEOUT, - detached=False): + detached=False, + start=True): (action, containers) = plan should_attach_logs = not detached @@ -338,7 +339,8 @@ def execute_convergence_plan(self, if should_attach_logs: container.attach_log_stream() - container.start() + if start: + container.start() return [container] @@ -348,14 +350,16 @@ def execute_convergence_plan(self, container, do_build=do_build, timeout=timeout, - attach_logs=should_attach_logs + attach_logs=should_attach_logs, + start_new_container=start ) for container in containers ] elif action == 'start': - for container in containers: - self.start_container_if_stopped(container, attach_logs=should_attach_logs) + if start: + for container in containers: + self.start_container_if_stopped(container, attach_logs=should_attach_logs) return containers @@ -373,7 +377,8 @@ def recreate_container( container, do_build=False, timeout=DEFAULT_TIMEOUT, - attach_logs=False): + attach_logs=False, + start_new_container=True): """Recreate a container. The original container is renamed to a temporary name so that data @@ -392,7 +397,8 @@ def recreate_container( ) if attach_logs: new_container.attach_log_stream() - new_container.start() + if start_new_container: + new_container.start() container.remove() return new_container diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 66619629396..032b507d733 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -264,6 +264,52 @@ def test_build_failed_forcerm(self): ] assert not containers + def test_create(self): + self.dispatch(['create']) + service = self.project.get_service('simple') + another = self.project.get_service('another') + self.assertEqual(len(service.containers()), 0) + self.assertEqual(len(another.containers()), 0) + self.assertEqual(len(service.containers(stopped=True)), 1) + self.assertEqual(len(another.containers(stopped=True)), 1) + + def test_create_with_force_recreate(self): + self.dispatch(['create'], None) + service = self.project.get_service('simple') + self.assertEqual(len(service.containers()), 0) + self.assertEqual(len(service.containers(stopped=True)), 1) + + old_ids = [c.id for c in service.containers(stopped=True)] + + self.dispatch(['create', '--force-recreate'], None) + self.assertEqual(len(service.containers()), 0) + self.assertEqual(len(service.containers(stopped=True)), 1) + + new_ids = [c.id for c in service.containers(stopped=True)] + + self.assertNotEqual(old_ids, new_ids) + + def test_create_with_no_recreate(self): + self.dispatch(['create'], None) + service = self.project.get_service('simple') + self.assertEqual(len(service.containers()), 0) + self.assertEqual(len(service.containers(stopped=True)), 1) + + old_ids = [c.id for c in service.containers(stopped=True)] + + self.dispatch(['create', '--no-recreate'], None) + self.assertEqual(len(service.containers()), 0) + self.assertEqual(len(service.containers(stopped=True)), 1) + + new_ids = [c.id for c in service.containers(stopped=True)] + + self.assertEqual(old_ids, new_ids) + + def test_create_with_force_recreate_and_no_recreate(self): + self.dispatch( + ['create', '--force-recreate', '--no-recreate'], + returncode=1) + def test_up_detached(self): self.dispatch(['up', '-d']) service = self.project.get_service('simple') diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 443ff9783a2..229f653a437 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -213,6 +213,71 @@ def test_start_pause_unpause_stop_kill_remove(self): project.remove_stopped() self.assertEqual(len(project.containers(stopped=True)), 0) + def test_create(self): + web = self.create_service('web') + db = self.create_service('db', volumes=[VolumeSpec.parse('/var/db')]) + project = Project('composetest', [web, db], self.client) + + project.create(['db']) + self.assertEqual(len(project.containers()), 0) + self.assertEqual(len(project.containers(stopped=True)), 1) + self.assertEqual(len(db.containers()), 0) + self.assertEqual(len(db.containers(stopped=True)), 1) + self.assertEqual(len(web.containers(stopped=True)), 0) + + def test_create_twice(self): + web = self.create_service('web') + db = self.create_service('db', volumes=[VolumeSpec.parse('/var/db')]) + project = Project('composetest', [web, db], self.client) + + project.create(['db', 'web']) + project.create(['db', 'web']) + self.assertEqual(len(project.containers()), 0) + self.assertEqual(len(project.containers(stopped=True)), 2) + self.assertEqual(len(db.containers()), 0) + self.assertEqual(len(db.containers(stopped=True)), 1) + self.assertEqual(len(web.containers()), 0) + self.assertEqual(len(web.containers(stopped=True)), 1) + + def test_create_with_links(self): + db = self.create_service('db') + web = self.create_service('web', links=[(db, 'db')]) + project = Project('composetest', [db, web], self.client) + + project.create(['web']) + self.assertEqual(len(project.containers()), 0) + self.assertEqual(len(project.containers(stopped=True)), 2) + self.assertEqual(len(db.containers()), 0) + self.assertEqual(len(db.containers(stopped=True)), 1) + self.assertEqual(len(web.containers()), 0) + self.assertEqual(len(web.containers(stopped=True)), 1) + + def test_create_strategy_always(self): + db = self.create_service('db') + project = Project('composetest', [db], self.client) + project.create(['db']) + old_id = project.containers(stopped=True)[0].id + + project.create(['db'], strategy=ConvergenceStrategy.always) + self.assertEqual(len(project.containers()), 0) + self.assertEqual(len(project.containers(stopped=True)), 1) + + db_container = project.containers(stopped=True)[0] + self.assertNotEqual(db_container.id, old_id) + + def test_create_strategy_never(self): + db = self.create_service('db') + project = Project('composetest', [db], self.client) + project.create(['db']) + old_id = project.containers(stopped=True)[0].id + + project.create(['db'], strategy=ConvergenceStrategy.never) + self.assertEqual(len(project.containers()), 0) + self.assertEqual(len(project.containers(stopped=True)), 1) + + db_container = project.containers(stopped=True)[0] + self.assertEqual(db_container.id, old_id) + def test_project_up(self): web = self.create_service('web') db = self.create_service('db', volumes=[VolumeSpec.parse('/var/db')]) diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 5a809423a12..84ef696e7a2 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -333,6 +333,24 @@ def test_execute_convergence_plan_when_image_volume_masks_config(self): self.assertEqual(list(new_container.get('Volumes')), ['/data']) self.assertEqual(new_container.get('Volumes')['/data'], volume_path) + def test_execute_convergence_plan_without_start(self): + service = self.create_service( + 'db', + build='tests/fixtures/dockerfile-with-volume' + ) + + containers = service.execute_convergence_plan(ConvergencePlan('create', []), start=False) + self.assertEqual(len(service.containers()), 0) + self.assertEqual(len(service.containers(stopped=True)), 1) + + containers = service.execute_convergence_plan(ConvergencePlan('recreate', containers), start=False) + self.assertEqual(len(service.containers()), 0) + self.assertEqual(len(service.containers(stopped=True)), 1) + + service.execute_convergence_plan(ConvergencePlan('start', containers), start=False) + self.assertEqual(len(service.containers()), 0) + self.assertEqual(len(service.containers(stopped=True)), 1) + def test_start_container_passes_through_options(self): db = self.create_service('db') create_and_start_container(db, environment={'FOO': 'BAR'}) From 5ed559fa0ef89c944734226278155050f994ece0 Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Mon, 21 Dec 2015 01:52:54 +0100 Subject: [PATCH 1627/4072] Update links Updates some links to their new locations, and replaces some http:// with https:// links. Signed-off-by: Sebastiaan van Stijn --- CHANGELOG.md | 4 ++-- CONTRIBUTING.md | 2 +- README.md | 2 +- compose/cli/errors.py | 4 ++-- docs/compose-file.md | 10 +++++----- docs/django.md | 6 +++--- docs/env.md | 2 +- docs/gettingstarted.md | 2 +- docs/index.md | 2 +- docs/install.md | 6 +++--- docs/production.md | 4 ++-- docs/rails.md | 4 ++-- docs/wordpress.md | 8 ++++---- experimental/compose_swarm_networking.md | 4 ++-- 14 files changed, 30 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b6e0dd396f..79aee75fb51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -301,8 +301,8 @@ Several new configuration keys have been added to `docker-compose.yml`: - `pid: host`, like `docker run --pid=host`, lets you reuse the same PID namespace as the host machine. - `cpuset`, like `docker run --cpuset-cpus`, lets you specify which CPUs to allow execution in. - `read_only`, like `docker run --read-only`, lets you mount a container's filesystem as read-only. -- `security_opt`, like `docker run --security-opt`, lets you specify [security options](https://docs.docker.com/reference/run/#security-configuration). -- `log_driver`, like `docker run --log-driver`, lets you specify a [log driver](https://docs.docker.com/reference/run/#logging-drivers-log-driver). +- `security_opt`, like `docker run --security-opt`, lets you specify [security options](https://docs.docker.com/engine/reference/run/#security-configuration). +- `log_driver`, like `docker run --log-driver`, lets you specify a [log driver](https://docs.docker.com/engine/reference/run/#logging-drivers-log-driver). Many bugs have been fixed, including the following: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 62bf415c7ea..66224752db7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -43,7 +43,7 @@ To run the style checks at any time run `tox -e pre-commit`. ## Submitting a pull request -See Docker's [basic contribution workflow](https://docs.docker.com/project/make-a-contribution/#the-basic-contribution-workflow) for a guide on how to submit a pull request for code or documentation. +See Docker's [basic contribution workflow](https://docs.docker.com/opensource/workflow/make-a-contribution/#the-basic-contribution-workflow) for a guide on how to submit a pull request for code or documentation. ## Running the test suite diff --git a/README.md b/README.md index c9b4729a7e4..b60a7eee580 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ Compose has commands for managing the whole lifecycle of your application: Installation and documentation ------------------------------ -- Full documentation is available on [Docker's website](http://docs.docker.com/compose/). +- Full documentation is available on [Docker's website](https://docs.docker.com/compose/). - If you have any questions, you can talk in real-time with other developers in the #docker-compose IRC channel on Freenode. [Click here to join using IRCCloud.](https://www.irccloud.com/invite?hostname=irc.freenode.net&channel=%23docker-compose) - Code repository for Compose is on [Github](https://github.com/docker/compose) - If you find any problems please fill out an [issue](https://github.com/docker/compose/issues/new) diff --git a/compose/cli/errors.py b/compose/cli/errors.py index 244897f8abc..ca4413bd1a8 100644 --- a/compose/cli/errors.py +++ b/compose/cli/errors.py @@ -27,7 +27,7 @@ def __init__(self): super(DockerNotFoundUbuntu, self).__init__(""" Couldn't connect to Docker daemon. You might need to install Docker: - http://docs.docker.io/en/latest/installation/ubuntulinux/ + https://docs.docker.com/engine/installation/ubuntulinux/ """) @@ -36,7 +36,7 @@ def __init__(self): super(DockerNotFoundGeneric, self).__init__(""" Couldn't connect to Docker daemon. You might need to install Docker: - http://docs.docker.io/en/latest/installation/ + https://docs.docker.com/engine/installation/ """) diff --git a/docs/compose-file.md b/docs/compose-file.md index c0f2efbe546..2a6028b8cf2 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -231,7 +231,7 @@ pull if it doesn't exist locally. ### labels -Add metadata to containers using [Docker labels](http://docs.docker.com/userguide/labels-custom-metadata/). You can use either an array or a dictionary. +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. @@ -269,7 +269,7 @@ reference](env.md) for details. ### log_driver Specify a logging driver for the service's containers, as with the ``--log-driver`` -option for docker run ([documented here](https://docs.docker.com/reference/logging/overview/)). +option for docker run ([documented here](https://docs.docker.com/engine/reference/logging/overview/)). The default value is json-file. @@ -371,8 +371,8 @@ a `volume_driver`. > Note: No path expansion will be done if you have also specified a > `volume_driver`. -See [Docker Volumes](https://docs.docker.com/userguide/dockervolumes/) and -[Volume Plugins](https://docs.docker.com/extend/plugins_volume/) for more +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 @@ -388,7 +388,7 @@ specifying read-only access(``ro``) or read-write(``rw``). ### cpu\_shares, cpu\_quota, cpuset, domainname, entrypoint, hostname, ipc, mac\_address, mem\_limit, memswap\_limit, privileged, read\_only, restart, stdin\_open, tty, user, working\_dir Each of these is a single value, analogous to its -[docker run](https://docs.docker.com/reference/run/) counterpart. +[docker run](https://docs.docker.com/engine/reference/run/) counterpart. cpu_shares: 73 cpu_quota: 50000 diff --git a/docs/django.md b/docs/django.md index b503e57448a..2d4fdaf9959 100644 --- a/docs/django.md +++ b/docs/django.md @@ -30,8 +30,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/userguide/dockerimages/#building-an-image-from-a-dockerfile) - and the [Dockerfile reference](http://docs.docker.com/reference/builder/). + 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/). 3. Add the following content to the `Dockerfile`. @@ -144,7 +144,7 @@ In this section, you set up the database connection for Django. } These settings are determined by the - [postgres](https://registry.hub.docker.com/_/postgres/) Docker image + [postgres](https://hub.docker.com/_/postgres/) Docker image specified in `docker-compose.yml`. 3. Save and close the file. diff --git a/docs/env.md b/docs/env.md index d7b51ba2b51..c0e03a4e240 100644 --- a/docs/env.md +++ b/docs/env.md @@ -35,7 +35,7 @@ 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]: http://docs.docker.com/userguide/dockerlinks/ +[Docker links]: https://docs.docker.com/engine/userguide/networking/default_network/dockerlinks/ ## Related Information diff --git a/docs/gettingstarted.md b/docs/gettingstarted.md index f685bf3820a..bb3d51e92ff 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/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](https://docs.docker.com/engine/userguide/dockerimages/#building-an-image-from-a-dockerfile) and the [Dockerfile reference](http://docs.docker.com/reference/builder/). 2. Build the image. diff --git a/docs/index.md b/docs/index.md index 8b32a754149..36b93a39ead 100644 --- a/docs/index.md +++ b/docs/index.md @@ -183,4 +183,4 @@ individuals, we have a number of open channels for communication. * 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/project/get-help/). +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 7c73baaa91b..417a48c1845 100644 --- a/docs/install.md +++ b/docs/install.md @@ -20,11 +20,11 @@ To install Compose, do the following: 1. Install Docker Engine version 1.7.1 or greater: - * Mac OS X installation (Toolbox installation includes both Engine and Compose) + * Mac OS X installation (Toolbox installation includes both Engine and Compose) - * Ubuntu installation + * Ubuntu installation - * other system installations + * other system installations 2. Mac OS X users are done installing. Others should continue to the next step. diff --git a/docs/production.md b/docs/production.md index 0a5e77b5226..46e221bb29d 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](https://docs.docker.com/machine/) makes managing local and remote Docker hosts very easy, and is recommended even if you're not deploying remotely. @@ -69,7 +69,7 @@ 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](https://docs.docker.com/swarm/), 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 d3f1707ca8d..e3daff25c58 100644 --- a/docs/rails.md +++ b/docs/rails.md @@ -30,7 +30,7 @@ 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/userguide/dockerimages/#building-an-image-from-a-dockerfile) and the [Dockerfile reference](http://docs.docker.com/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`. @@ -128,7 +128,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](https://docs.docker.com/machine/), then `docker-machine ip MACHINE_VM` returns the Docker host IP address. ## More Compose documentation diff --git a/docs/wordpress.md b/docs/wordpress.md index 15746a754f5..840104915cd 100644 --- a/docs/wordpress.md +++ b/docs/wordpress.md @@ -28,9 +28,9 @@ to the name of your project. 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/userguide/dockerimages/#building-an-image-from-a-dockerfile) and the -[Dockerfile reference](http://docs.docker.com/reference/builder/). In this case, -your Dockerfile should be: +[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: FROM orchardup/php5 ADD . /code @@ -89,7 +89,7 @@ configuration at the `db` container: 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. +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. ## More Compose documentation diff --git a/experimental/compose_swarm_networking.md b/experimental/compose_swarm_networking.md index e3dcf6ccba1..b1fb25dc45b 100644 --- a/experimental/compose_swarm_networking.md +++ b/experimental/compose_swarm_networking.md @@ -15,9 +15,9 @@ Before you start, you’ll need to install the experimental build of Docker, and $ 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](http://docs.docker.com/machine/). +- To install Machine, follow the instructions [here](https://docs.docker.com/machine/install-machine/). -- To install Compose, follow the instructions [here](http://docs.docker.com/compose/install/). +- 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. From adde8058292076a564c6fce36f8fc75c2ac52381 Mon Sep 17 00:00:00 2001 From: Tomas Tomecek Date: Sat, 26 Dec 2015 11:03:58 +0100 Subject: [PATCH 1628/4072] allow running compose from git with: ``` $ git clone docker/compose && cd compose $ export PYTHONPATH="$PWD:$PYTHONPATH" $ python -m compose --help ``` Signed-off-by: Tomas Tomecek --- compose/__main__.py | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 compose/__main__.py diff --git a/compose/__main__.py b/compose/__main__.py new file mode 100644 index 00000000000..199ba2ae9b4 --- /dev/null +++ b/compose/__main__.py @@ -0,0 +1,3 @@ +from compose.cli.main import main + +main() From 39af6b653b1ca95463c699ad62f69c86adc79e95 Mon Sep 17 00:00:00 2001 From: Michael Gilliland Date: Mon, 28 Dec 2015 16:35:05 -0500 Subject: [PATCH 1629/4072] 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 2a6028b8cf2..b3ef89387c6 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 778c213dfc9c65ac27d4f527018eb66d841d890e Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 28 Dec 2015 16:57:55 -0500 Subject: [PATCH 1630/4072] Fix signal handlers by moving shutdown logic out of handler. Signed-off-by: Daniel Nephin --- compose/cli/main.py | 45 +++++++++++++++--------------------- compose/cli/signals.py | 18 +++++++++++++++ tests/acceptance/cli_test.py | 3 ++- tests/unit/cli/main_test.py | 2 +- 4 files changed, 40 insertions(+), 28 deletions(-) create mode 100644 compose/cli/signals.py diff --git a/compose/cli/main.py b/compose/cli/main.py index 4a766133fb4..e360dddd1a5 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -3,7 +3,6 @@ import logging import re -import signal import sys from inspect import getdoc from operator import attrgetter @@ -12,6 +11,7 @@ from docker.errors import APIError from requests.exceptions import ReadTimeout +from . import signals from .. import __version__ from ..config import config from ..config import ConfigurationError @@ -655,20 +655,19 @@ def remove_container(force=False): if options['--rm']: project.client.remove_container(container.id, force=True) - def force_shutdown(signal, frame): + signals.set_signal_handler_to_shutdown() + try: + try: + dockerpty.start(project.client, container.id, interactive=not options['-T']) + exit_code = container.wait() + except signals.ShutdownException: + project.client.stop(container.id) + exit_code = 1 + except signals.ShutdownException: project.client.kill(container.id) remove_container(force=True) sys.exit(2) - def shutdown(signal, frame): - set_signal_handler(force_shutdown) - project.client.stop(container.id) - remove_container() - sys.exit(1) - - set_signal_handler(shutdown) - dockerpty.start(project.client, container.id, interactive=not options['-T']) - exit_code = container.wait() remove_container() sys.exit(exit_code) @@ -683,25 +682,19 @@ def build_log_printer(containers, service_names, monochrome): def attach_to_logs(project, log_printer, service_names, timeout): + print("Attaching to", list_containers(log_printer.containers)) + signals.set_signal_handler_to_shutdown() - def force_shutdown(signal, frame): + try: + try: + log_printer.run() + except signals.ShutdownException: + print("Gracefully stopping... (press Ctrl+C again to force)") + project.stop(service_names=service_names, timeout=timeout) + except signals.ShutdownException: project.kill(service_names=service_names) sys.exit(2) - def shutdown(signal, frame): - set_signal_handler(force_shutdown) - print("Gracefully stopping... (press Ctrl+C again to force)") - project.stop(service_names=service_names, timeout=timeout) - - print("Attaching to", list_containers(log_printer.containers)) - set_signal_handler(shutdown) - log_printer.run() - - -def set_signal_handler(handler): - signal.signal(signal.SIGINT, handler) - signal.signal(signal.SIGTERM, handler) - def list_containers(containers): return ", ".join(c.name for c in containers) diff --git a/compose/cli/signals.py b/compose/cli/signals.py new file mode 100644 index 00000000000..38474ba44a4 --- /dev/null +++ b/compose/cli/signals.py @@ -0,0 +1,18 @@ +import signal + + +class ShutdownException(Exception): + pass + + +def shutdown(signal, frame): + raise ShutdownException() + + +def set_signal_handler(handler): + signal.signal(signal.SIGINT, handler) + signal.signal(signal.SIGTERM, handler) + + +def set_signal_handler_to_shutdown(): + set_signal_handler(shutdown) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index e6fa38a8b5e..694f8583ff7 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -86,7 +86,8 @@ def __call__(self): return False def __str__(self): - return "waiting for container to have state %s" % self.expected + state = 'running' if self.running else 'stopped' + return "waiting for container to be %s" % state class CLITestCase(DockerClientTestCase): diff --git a/tests/unit/cli/main_test.py b/tests/unit/cli/main_test.py index db37ac1af03..b63ac746822 100644 --- a/tests/unit/cli/main_test.py +++ b/tests/unit/cli/main_test.py @@ -54,7 +54,7 @@ def test_attach_to_logs(self): service_names = ['web', 'db'] timeout = 12 - with mock.patch('compose.cli.main.signal', autospec=True) as mock_signal: + 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 == [ From d4e913e42cf7141421d76407ce2ca22935bb1a25 Mon Sep 17 00:00:00 2001 From: Mary Anthony Date: Thu, 31 Dec 2015 19:04:38 -0800 Subject: [PATCH 1631/4072] Fixing TODO visible in docs Signed-off-by: Mary Anthony --- docs/networking.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/networking.md b/docs/networking.md index 718d56c7a26..91ac0b73b92 100644 --- a/docs/networking.md +++ b/docs/networking.md @@ -79,9 +79,11 @@ You can specify which one to use with the `--x-network-driver` flag: $ docker-compose --x-networking --x-network-driver=overlay up + ## Custom container network modes From 2acc29cf1c25be7078ebeaf70e9d45f8048198da Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 4 Jan 2016 15:35:57 -0500 Subject: [PATCH 1632/4072] Remove support for fig.yaml, FIG_FILE, and FIG_PROJECT_NAME. Signed-off-by: Daniel Nephin --- compose/cli/command.py | 15 ++------------- compose/config/config.py | 6 ------ contrib/completion/bash/docker-compose | 2 +- contrib/completion/zsh/_docker-compose | 2 +- tests/unit/cli_test.py | 7 ------- tests/unit/config/config_test.py | 2 -- 6 files changed, 4 insertions(+), 30 deletions(-) diff --git a/compose/cli/command.py b/compose/cli/command.py index 59f6c4bc98d..21d6ff0ddd2 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -59,11 +59,7 @@ def get_config_path_from_options(options): if file_option: return file_option - if 'FIG_FILE' in os.environ: - log.warn('The FIG_FILE environment variable is deprecated.') - log.warn('Please use COMPOSE_FILE instead.') - - config_file = os.environ.get('COMPOSE_FILE') or os.environ.get('FIG_FILE') + config_file = os.environ.get('COMPOSE_FILE') return [config_file] if config_file else None @@ -96,14 +92,7 @@ def get_project_name(working_dir, project_name=None): def normalize_name(name): return re.sub(r'[^a-z0-9]', '', name.lower()) - if 'FIG_PROJECT_NAME' in os.environ: - log.warn('The FIG_PROJECT_NAME environment variable is deprecated.') - log.warn('Please use COMPOSE_PROJECT_NAME instead.') - - project_name = ( - project_name or - os.environ.get('COMPOSE_PROJECT_NAME') or - os.environ.get('FIG_PROJECT_NAME')) + project_name = project_name or os.environ.get('COMPOSE_PROJECT_NAME') if project_name is not None: return normalize_name(project_name) diff --git a/compose/config/config.py b/compose/config/config.py index 0c6448331c7..195665b5185 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -88,8 +88,6 @@ SUPPORTED_FILENAMES = [ 'docker-compose.yml', 'docker-compose.yaml', - 'fig.yml', - 'fig.yaml', ] DEFAULT_OVERRIDE_FILENAME = 'docker-compose.override.yml' @@ -162,10 +160,6 @@ def get_default_config_files(base_dir): log.warn("Found multiple config files with supported names: %s", ", ".join(candidates)) log.warn("Using %s\n", winner) - if winner.startswith("fig."): - log.warn("%s is deprecated and will not be supported in future. " - "Please rename your config file to docker-compose.yml\n" % winner) - return [os.path.join(path, winner)] + get_default_override_file(path) diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index 497a818484a..c18e4f6df60 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -28,7 +28,7 @@ __docker_compose_nospace() { # 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 fig.y{,a}ml ; do + for file in docker-compose.y{,a}ml ; do [ -e $file ] && { echo $file return diff --git a/contrib/completion/zsh/_docker-compose b/contrib/completion/zsh/_docker-compose index 67ca49bbb27..35c2b996f69 100644 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -24,7 +24,7 @@ # 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 fig.y{,a}ml ; do + for file in docker-compose.y{,a}ml ; do [ -e $file ] && { echo $file return diff --git a/tests/unit/cli_test.py b/tests/unit/cli_test.py index 61cef6f6a39..a5767097691 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -42,13 +42,6 @@ def test_project_name_with_explicit_project_name(self): project_name = get_project_name(None, project_name=name) self.assertEquals('explicitprojectname', project_name) - def test_project_name_from_environment_old_var(self): - name = 'namefromenv' - with mock.patch.dict(os.environ): - os.environ['FIG_PROJECT_NAME'] = name - project_name = get_project_name(None) - self.assertEquals(project_name, name) - def test_project_name_from_environment_new_var(self): name = 'namefromenv' with mock.patch.dict(os.environ): diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 2185b792039..e975cb9d853 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1616,8 +1616,6 @@ class GetDefaultConfigFilesTestCase(unittest.TestCase): files = [ 'docker-compose.yml', 'docker-compose.yaml', - 'fig.yml', - 'fig.yaml', ] def test_get_config_path_default_file_in_basedir(self): From ad9011ed96dc35ef6880badd1068276a4493da37 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 5 Jan 2016 17:30:27 -0500 Subject: [PATCH 1633/4072] Don't warn when the container volume is specified as a compose option. Signed-off-by: Daniel Nephin --- compose/service.py | 1 + tests/unit/service_test.py | 12 ++++++++++++ 2 files changed, 13 insertions(+) diff --git a/compose/service.py b/compose/service.py index d1509871004..5540d7348cd 100644 --- a/compose/service.py +++ b/compose/service.py @@ -880,6 +880,7 @@ def warn_on_masked_volume(volumes_option, container_volumes, service): for volume in volumes_option: if ( + volume.external and volume.internal in container_volumes and container_volumes.get(volume.internal) != volume.external ): diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 1c8b441f343..637bf3bae57 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -742,6 +742,18 @@ def test_warn_on_masked_no_warning_with_same_path(self): assert not mock_log.warn.called + def test_warn_on_masked_no_warning_with_container_only_option(self): + volumes_option = [VolumeSpec(None, '/path', 'rw')] + container_volumes = [ + VolumeSpec('/var/lib/docker/volume/path', '/path', 'rw') + ] + service = 'service_name' + + with mock.patch('compose.service.log', autospec=True) as mock_log: + warn_on_masked_volume(volumes_option, container_volumes, service) + + assert not mock_log.warn.called + def test_create_with_special_volume_mode(self): self.mock_client.inspect_image.return_value = {'Id': 'imageid'} From 73de81b51c0f613ed543f4446a5d76ed9e788659 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 2 Oct 2015 19:04:39 -0400 Subject: [PATCH 1634/4072] Upgrade tests to use new Mounts in container inspect. Signed-off-by: Daniel Nephin --- compose/cli/docker_client.py | 2 +- compose/container.py | 6 ++++++ tests/acceptance/cli_test.py | 2 +- tests/integration/project_test.py | 20 +++++++++++++------- tests/integration/resilience_test.py | 8 ++++---- 5 files changed, 25 insertions(+), 13 deletions(-) diff --git a/compose/cli/docker_client.py b/compose/cli/docker_client.py index 734f4237b04..24828b11a17 100644 --- a/compose/cli/docker_client.py +++ b/compose/cli/docker_client.py @@ -9,7 +9,7 @@ log = logging.getLogger(__name__) -DEFAULT_API_VERSION = '1.19' +DEFAULT_API_VERSION = '1.20' def docker_client(version=None): diff --git a/compose/container.py b/compose/container.py index 8f96a9447d2..68218829bd5 100644 --- a/compose/container.py +++ b/compose/container.py @@ -177,6 +177,12 @@ def get_local_port(self, port, protocol='tcp'): port = self.ports.get("%s/%s" % (port, protocol)) return "{HostIp}:{HostPort}".format(**port[0]) if port else None + def get_mount(self, mount_dest): + for mount in self.get('Mounts'): + if mount['Destination'] == mount_dest: + return mount + return None + def start(self, **options): return self.client.start(self.id, **options) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index e6fa38a8b5e..81d34a1a76d 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -871,7 +871,7 @@ def test_home_and_env_var_in_volume_path(self): self.dispatch(['up', '-d'], None) container = self.project.containers(stopped=True)[0] - actual_host_path = container.get('Volumes')['/container-path'] + actual_host_path = container.get_mount('/container-path')['Source'] components = actual_host_path.split('/') assert components[-2:] == ['home-dir', 'my-volume'] diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 229f653a437..bff19d55632 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -331,15 +331,19 @@ def test_project_up_with_no_recreate_running(self): project.up(['db']) self.assertEqual(len(project.containers()), 1) old_db_id = project.containers()[0].id - db_volume_path = project.containers()[0].inspect()['Volumes']['/var/db'] + + container, = project.containers() + db_volume_path = container.get_mount('/var/db')['Source'] project.up(strategy=ConvergenceStrategy.never) self.assertEqual(len(project.containers()), 2) db_container = [c for c in project.containers() if 'db' in c.name][0] self.assertEqual(db_container.id, old_db_id) - self.assertEqual(db_container.inspect()['Volumes']['/var/db'], - db_volume_path) + mount, = db_container.get('Mounts') + self.assertEqual( + db_container.get_mount('/var/db')['Source'], + db_volume_path) def test_project_up_with_no_recreate_stopped(self): web = self.create_service('web') @@ -354,8 +358,9 @@ def test_project_up_with_no_recreate_stopped(self): old_containers = project.containers(stopped=True) self.assertEqual(len(old_containers), 1) - old_db_id = old_containers[0].id - db_volume_path = old_containers[0].inspect()['Volumes']['/var/db'] + old_container, = old_containers + old_db_id = old_container.id + db_volume_path = old_container.get_mount('/var/db')['Source'] project.up(strategy=ConvergenceStrategy.never) @@ -365,8 +370,9 @@ def test_project_up_with_no_recreate_stopped(self): db_container = [c for c in new_containers if 'db' in c.name][0] self.assertEqual(db_container.id, old_db_id) - self.assertEqual(db_container.inspect()['Volumes']['/var/db'], - db_volume_path) + self.assertEqual( + db_container.get_mount('/var/db')['Source'], + db_volume_path) def test_project_up_without_all_services(self): console = self.create_service('console') diff --git a/tests/integration/resilience_test.py b/tests/integration/resilience_test.py index 7f75356d829..5df751c770d 100644 --- a/tests/integration/resilience_test.py +++ b/tests/integration/resilience_test.py @@ -18,12 +18,12 @@ def setUp(self): container = self.db.create_container() container.start() - self.host_path = container.get('Volumes')['/var/db'] + self.host_path = container.get_mount('/var/db')['Source'] def test_successful_recreate(self): self.project.up(strategy=ConvergenceStrategy.always) container = self.db.containers()[0] - self.assertEqual(container.get('Volumes')['/var/db'], self.host_path) + self.assertEqual(container.get_mount('/var/db')['Source'], self.host_path) def test_create_failure(self): with mock.patch('compose.service.Service.create_container', crash): @@ -32,7 +32,7 @@ def test_create_failure(self): self.project.up() container = self.db.containers()[0] - self.assertEqual(container.get('Volumes')['/var/db'], self.host_path) + self.assertEqual(container.get_mount('/var/db')['Source'], self.host_path) def test_start_failure(self): with mock.patch('compose.container.Container.start', crash): @@ -41,7 +41,7 @@ def test_start_failure(self): self.project.up() container = self.db.containers()[0] - self.assertEqual(container.get('Volumes')['/var/db'], self.host_path) + self.assertEqual(container.get_mount('/var/db')['Source'], self.host_path) class Crash(Exception): From 3bdcc9d95459618a1301eb801348fc3805a764a6 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 6 Oct 2015 18:37:43 -0700 Subject: [PATCH 1635/4072] Update service tests to use mounts instead of volumes Signed-off-by: Joffrey F --- tests/integration/service_test.py | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index ff5716568d1..272fafdecd2 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -88,13 +88,13 @@ def test_create_container_with_unspecified_volume(self): service = self.create_service('db', volumes=[VolumeSpec.parse('/var/db')]) container = service.create_container() container.start() - self.assertIn('/var/db', container.get('Volumes')) + self.assertIsNotNone(container.get_mount('/var/db')) def test_create_container_with_volume_driver(self): service = self.create_service('db', volume_driver='foodriver') container = service.create_container() container.start() - self.assertEqual('foodriver', container.get('Config.VolumeDriver')) + self.assertEqual('foodriver', container.get('HostConfig.VolumeDriver')) def test_create_container_with_cpu_shares(self): service = self.create_service('db', cpu_shares=73) @@ -158,12 +158,10 @@ def test_create_container_with_specified_volume(self): volumes=[VolumeSpec(host_path, container_path, 'rw')]) container = service.create_container() container.start() - - volumes = container.inspect()['Volumes'] - self.assertIn(container_path, volumes) + self.assertIsNotNone(container.get_mount(container_path)) # Match the last component ("host-path"), because boot2docker symlinks /tmp - actual_host_path = volumes[container_path] + actual_host_path = container.get_mount(container_path)['Source'] self.assertTrue(path.basename(actual_host_path) == path.basename(host_path), msg=("Last component differs: %s, %s" % (actual_host_path, host_path))) @@ -173,10 +171,10 @@ def test_recreate_preserves_volume_with_trailing_slash(self): """ service = self.create_service('data', volumes=[VolumeSpec.parse('/data/')]) old_container = create_and_start_container(service) - volume_path = old_container.get('Volumes')['/data'] + volume_path = old_container.get_mount('/data')['Source'] new_container = service.recreate_container(old_container) - self.assertEqual(new_container.get('Volumes')['/data'], volume_path) + self.assertEqual(new_container.get_mount('/data')['Source'], volume_path) def test_duplicate_volume_trailing_slash(self): """ @@ -250,7 +248,7 @@ def test_execute_convergence_plan_recreate(self): self.assertEqual(old_container.name, 'composetest_db_1') old_container.start() old_container.inspect() # reload volume data - volume_path = old_container.get('Volumes')['/etc'] + volume_path = old_container.get_mount('/etc')['Source'] num_containers_before = len(self.client.containers(all=True)) @@ -262,7 +260,7 @@ def test_execute_convergence_plan_recreate(self): self.assertEqual(new_container.get('Config.Cmd'), ['-d', '1']) self.assertIn('FOO=2', new_container.get('Config.Env')) self.assertEqual(new_container.name, 'composetest_db_1') - self.assertEqual(new_container.get('Volumes')['/etc'], volume_path) + self.assertEqual(new_container.get_mount('/etc')['Source'], volume_path) self.assertIn( 'affinity:container==%s' % old_container.id, new_container.get('Config.Env')) @@ -305,14 +303,18 @@ def test_execute_convergence_plan_with_image_declared_volume(self): ) old_container = create_and_start_container(service) - self.assertEqual(list(old_container.get('Volumes').keys()), ['/data']) - volume_path = old_container.get('Volumes')['/data'] + self.assertEqual( + [mount['Destination'] for mount in old_container.get('Mounts')], ['/data'] + ) + volume_path = old_container.get_mount('/data')['Source'] new_container, = service.execute_convergence_plan( ConvergencePlan('recreate', [old_container])) - self.assertEqual(list(new_container.get('Volumes')), ['/data']) - self.assertEqual(new_container.get('Volumes')['/data'], volume_path) + self.assertEqual( + [mount['Destination'] for mount in new_container.get('Mounts')], ['/data'] + ) + self.assertEqual(new_container.get_mount('/data')['Source'], volume_path) def test_execute_convergence_plan_when_image_volume_masks_config(self): service = self.create_service( From afab5c76eaf28651181b391cacc4614954134a4b Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 8 Oct 2015 06:02:26 -0700 Subject: [PATCH 1636/4072] Update service volume tests to use mounts key Signed-off-by: Joffrey F --- tests/unit/service_test.py | 54 +++++++++++++++++++++++++++++++------- 1 file changed, 44 insertions(+), 10 deletions(-) diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 1c8b441f343..b2421a34d18 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -600,12 +600,33 @@ def test_get_container_data_volumes(self): } container = Container(self.mock_client, { 'Image': 'ababab', - 'Volumes': { - '/host/volume': '/host/volume', - '/existing/volume': '/var/lib/docker/aaaaaaaa', - '/removed/volume': '/var/lib/docker/bbbbbbbb', - '/mnt/image/data': '/var/lib/docker/cccccccc', - }, + 'Mounts': [ + { + 'Source': '/host/volume', + 'Destination': '/host/volume', + 'Mode': '', + 'RW': True, + 'Name': 'hostvolume', + }, { + 'Source': '/var/lib/docker/aaaaaaaa', + 'Destination': '/existing/volume', + 'Mode': '', + 'RW': True, + 'Name': 'existingvolume', + }, { + 'Source': '/var/lib/docker/bbbbbbbb', + 'Destination': '/removed/volume', + 'Mode': '', + 'RW': True, + 'Name': 'removedvolume', + }, { + 'Source': '/var/lib/docker/cccccccc', + 'Destination': '/mnt/image/data', + 'Mode': '', + 'RW': True, + 'Name': 'imagedata', + }, + ] }, has_been_inspected=True) expected = [ @@ -630,7 +651,13 @@ def test_merge_volume_bindings(self): intermediate_container = Container(self.mock_client, { 'Image': 'ababab', - 'Volumes': {'/existing/volume': '/var/lib/docker/aaaaaaaa'}, + 'Mounts': [{ + 'Source': '/var/lib/docker/aaaaaaaa', + 'Destination': '/existing/volume', + 'Mode': '', + 'RW': True, + 'Name': 'existingvolume', + }], }, has_been_inspected=True) expected = [ @@ -693,9 +720,16 @@ def test_different_host_path_in_container_json(self): self.mock_client.inspect_container.return_value = { 'Id': '123123123', 'Image': 'ababab', - 'Volumes': { - '/data': '/mnt/sda1/host/path', - }, + 'Mounts': [ + { + 'Destination': '/data', + 'Source': '/mnt/sda1/host/path', + 'Mode': '', + 'RW': True, + 'Driver': 'local', + 'Name': 'abcdefff1234' + }, + ] } service._get_container_create_options( From c64b7cbb10b9fd8f1e15d6f0e22a2de4abda7567 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 9 Oct 2015 15:42:15 -0400 Subject: [PATCH 1637/4072] Ignore errors from API about not being able to kill a container. Signed-off-by: Daniel Nephin --- tests/integration/testcases.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index 9ea68e39c53..04cbe3527ab 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -25,8 +25,7 @@ def tearDown(self): for c in self.client.containers( all=True, filters={'label': '%s=composetest' % LABEL_PROJECT}): - self.client.kill(c['Id']) - self.client.remove_container(c['Id']) + self.client.remove_container(c['Id'], force=True) for i in self.client.images( filters={'label': 'com.docker.compose.test_image'}): self.client.remove_image(i) From 97fe2ee40c7018db3cbf0c40feb556e9e7940689 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 4 Nov 2015 17:09:40 -0500 Subject: [PATCH 1638/4072] Don't preserve host volumes on container recreate. Fixes a regression after the API changed to use Mounts. Signed-off-by: Daniel Nephin --- compose/service.py | 14 ++++++++++---- tests/integration/service_test.py | 17 ++++++++++++----- tests/unit/service_test.py | 5 +++++ 3 files changed, 27 insertions(+), 9 deletions(-) diff --git a/compose/service.py b/compose/service.py index d1509871004..251620e972a 100644 --- a/compose/service.py +++ b/compose/service.py @@ -849,7 +849,13 @@ def get_container_data_volumes(container, volumes_option): a mapping of volume bindings for those volumes. """ volumes = [] - container_volumes = container.get('Volumes') or {} + volumes_option = volumes_option or [] + + container_mounts = dict( + (mount['Destination'], mount) + for mount in container.get('Mounts') or {} + ) + image_volumes = [ VolumeSpec.parse(volume) for volume in @@ -861,13 +867,13 @@ def get_container_data_volumes(container, volumes_option): if volume.external: continue - volume_path = container_volumes.get(volume.internal) + mount = container_mounts.get(volume.internal) # New volume, doesn't exist in the old container - if not volume_path: + if not mount: continue # Copy existing volume from old container - volume = volume._replace(external=volume_path) + volume = volume._replace(external=mount['Source']) volumes.append(volume) return volumes diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 272fafdecd2..41d0800d93f 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -312,7 +312,8 @@ def test_execute_convergence_plan_with_image_declared_volume(self): ConvergencePlan('recreate', [old_container])) self.assertEqual( - [mount['Destination'] for mount in new_container.get('Mounts')], ['/data'] + [mount['Destination'] for mount in new_container.get('Mounts')], + ['/data'] ) self.assertEqual(new_container.get_mount('/data')['Source'], volume_path) @@ -323,8 +324,11 @@ def test_execute_convergence_plan_when_image_volume_masks_config(self): ) old_container = create_and_start_container(service) - self.assertEqual(list(old_container.get('Volumes').keys()), ['/data']) - volume_path = old_container.get('Volumes')['/data'] + self.assertEqual( + [mount['Destination'] for mount in old_container.get('Mounts')], + ['/data'] + ) + volume_path = old_container.get_mount('/data')['Source'] service.options['volumes'] = [VolumeSpec.parse('/tmp:/data')] @@ -338,8 +342,11 @@ def test_execute_convergence_plan_when_image_volume_masks_config(self): "Service \"db\" is using volume \"/data\" from the previous container", args[0]) - self.assertEqual(list(new_container.get('Volumes')), ['/data']) - self.assertEqual(new_container.get('Volumes')['/data'], volume_path) + self.assertEqual( + [mount['Destination'] for mount in new_container.get('Mounts')], + ['/data'] + ) + self.assertEqual(new_container.get_mount('/data')['Source'], volume_path) def test_execute_convergence_plan_without_start(self): service = self.create_service( diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index b2421a34d18..87d6af59550 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -234,6 +234,7 @@ def test_get_container_create_options_does_not_mutate_options(self): prev_container = mock.Mock( id='ababab', image_config={'ContainerConfig': {}}) + prev_container.get.return_value = None opts = service._get_container_create_options( {}, @@ -575,6 +576,10 @@ def test_net_service_no_containers(self): self.assertEqual(net.service_name, service_name) +def build_mount(destination, source, mode='rw'): + return {'Source': source, 'Destination': destination, 'Mode': mode} + + class ServiceVolumesTest(unittest.TestCase): def setUp(self): From 4bf2f8c4f910b5216b2755377e7c394e7c5c7ee6 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 20 Oct 2015 12:49:45 -0400 Subject: [PATCH 1639/4072] Fix lookup of linked containers for API version 1.20 Signed-off-by: Daniel Nephin --- compose/container.py | 10 ---------- tests/acceptance/cli_test.py | 7 +++++-- tests/integration/project_test.py | 1 - tests/integration/service_test.py | 15 ++++++++------- tests/integration/state_test.py | 5 +++-- tests/integration/testcases.py | 10 ++++++++++ 6 files changed, 26 insertions(+), 22 deletions(-) diff --git a/compose/container.py b/compose/container.py index 68218829bd5..5730f224618 100644 --- a/compose/container.py +++ b/compose/container.py @@ -228,16 +228,6 @@ def inspect(self): self.has_been_inspected = True return self.dictionary - # TODO: only used by tests, move to test module - def links(self): - links = [] - for container in self.client.containers(): - for name in container['Names']: - bits = name.split('/') - if len(bits) > 2 and bits[1] == self.name: - links.append(bits[2]) - return links - def attach(self, *args, **kwargs): return self.client.attach(self.id, *args, **kwargs) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 81d34a1a76d..1885727a13b 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -16,6 +16,7 @@ from compose.cli.docker_client import docker_client from compose.container import Container from tests.integration.testcases import DockerClientTestCase +from tests.integration.testcases import get_links from tests.integration.testcases import pull_busybox @@ -909,7 +910,7 @@ def test_up_with_multiple_files(self): web, other, db = containers self.assertEqual(web.human_readable_command, 'top') - self.assertTrue({'db', 'other'} <= set(web.links())) + self.assertTrue({'db', 'other'} <= set(get_links(web))) self.assertEqual(db.human_readable_command, 'top') self.assertEqual(other.human_readable_command, 'top') @@ -931,7 +932,9 @@ def test_up_with_extends(self): self.assertEqual(len(containers), 2) web = containers[1] - self.assertEqual(set(web.links()), set(['db', 'mydb_1', 'extends_mydb_1'])) + self.assertEqual( + set(get_links(web)), + set(['db', 'mydb_1', 'extends_mydb_1'])) expected_env = set([ "FOO=1", diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index bff19d55632..d33cf535a31 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -340,7 +340,6 @@ def test_project_up_with_no_recreate_running(self): db_container = [c for c in project.containers() if 'db' in c.name][0] self.assertEqual(db_container.id, old_db_id) - mount, = db_container.get('Mounts') self.assertEqual( db_container.get_mount('/var/db')['Source'], db_volume_path) diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 41d0800d93f..c288a8adf56 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -12,6 +12,7 @@ from .. import mock from .testcases import DockerClientTestCase +from .testcases import get_links from .testcases import pull_busybox from compose import __version__ from compose.config.types import VolumeFromSpec @@ -88,7 +89,7 @@ def test_create_container_with_unspecified_volume(self): service = self.create_service('db', volumes=[VolumeSpec.parse('/var/db')]) container = service.create_container() container.start() - self.assertIsNotNone(container.get_mount('/var/db')) + assert container.get_mount('/var/db') def test_create_container_with_volume_driver(self): service = self.create_service('db', volume_driver='foodriver') @@ -158,7 +159,7 @@ def test_create_container_with_specified_volume(self): volumes=[VolumeSpec(host_path, container_path, 'rw')]) container = service.create_container() container.start() - self.assertIsNotNone(container.get_mount(container_path)) + assert container.get_mount(container_path) # Match the last component ("host-path"), because boot2docker symlinks /tmp actual_host_path = container.get_mount(container_path)['Source'] @@ -385,7 +386,7 @@ def test_start_container_creates_links(self): create_and_start_container(web) self.assertEqual( - set(web.containers()[0].links()), + set(get_links(web.containers()[0])), set([ 'composetest_db_1', 'db_1', 'composetest_db_2', 'db_2', @@ -401,7 +402,7 @@ def test_start_container_creates_links_with_names(self): create_and_start_container(web) self.assertEqual( - set(web.containers()[0].links()), + set(get_links(web.containers()[0])), set([ 'composetest_db_1', 'db_1', 'composetest_db_2', 'db_2', @@ -419,7 +420,7 @@ def test_start_container_with_external_links(self): create_and_start_container(web) self.assertEqual( - set(web.containers()[0].links()), + set(get_links(web.containers()[0])), set([ 'composetest_db_1', 'composetest_db_2', @@ -433,7 +434,7 @@ def test_start_normal_container_does_not_create_links_to_its_own_service(self): create_and_start_container(db) c = create_and_start_container(db) - self.assertEqual(set(c.links()), set([])) + self.assertEqual(set(get_links(c)), set([])) def test_start_one_off_container_creates_links_to_its_own_service(self): db = self.create_service('db') @@ -444,7 +445,7 @@ def test_start_one_off_container_creates_links_to_its_own_service(self): c = create_and_start_container(db, one_off=True) self.assertEqual( - set(c.links()), + set(get_links(c)), set([ 'composetest_db_1', 'db_1', 'composetest_db_2', 'db_2', diff --git a/tests/integration/state_test.py b/tests/integration/state_test.py index 1fecce87b84..a54eefa6a4c 100644 --- a/tests/integration/state_test.py +++ b/tests/integration/state_test.py @@ -7,6 +7,7 @@ import py from .testcases import DockerClientTestCase +from .testcases import get_links from compose.config import config from compose.project import Project from compose.service import ConvergenceStrategy @@ -186,8 +187,8 @@ def test_service_recreated_when_dependency_created(self): web, = [c for c in containers if c.service == 'web'] nginx, = [c for c in containers if c.service == 'nginx'] - self.assertEqual(web.links(), ['composetest_db_1', 'db', 'db_1']) - self.assertEqual(nginx.links(), ['composetest_web_1', 'web', 'web_1']) + self.assertEqual(set(get_links(web)), {'composetest_db_1', 'db', 'db_1'}) + self.assertEqual(set(get_links(nginx)), {'composetest_web_1', 'web', 'web_1'}) class ServiceStateTest(DockerClientTestCase): diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index 04cbe3527ab..469859b9be0 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -16,6 +16,16 @@ def pull_busybox(client): client.pull('busybox:latest', stream=False) +def get_links(container): + links = container.get('HostConfig.Links') or [] + + def format_link(link): + _, alias = link.split(':') + return alias.split('/')[-1] + + return [format_link(link) for link in links] + + class DockerClientTestCase(unittest.TestCase): @classmethod def setUpClass(cls): From b4be7b870fb99ed39eb47b5f5cc41d82c5af85e2 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 16 Nov 2015 19:21:56 -0800 Subject: [PATCH 1640/4072] Add support for declaring named volumes in compose files * Bump default API version to 1.21 (required for named volume management) * Introduce new, versioned compose file format while maintaining support for current (legacy) format * Test updates to reflect changes made to the internal API Signed-off-by: Joffrey F --- compose/cli/command.py | 5 +- compose/cli/docker_client.py | 3 +- compose/config/config.py | 79 +++++++++++++++++++++++++--- compose/config/fields_schema_v2.json | 43 +++++++++++++++ compose/config/validation.py | 9 ++-- compose/project.py | 31 +++++++++-- compose/volume.py | 19 +++++++ requirements.txt | 3 +- tests/integration/project_test.py | 25 +++++---- tests/integration/service_test.py | 1 + tests/integration/state_test.py | 4 +- tests/integration/testcases.py | 4 ++ tests/unit/config/config_test.py | 53 ++++++++++--------- tests/unit/project_test.py | 64 +++++++++++++++------- 14 files changed, 262 insertions(+), 81 deletions(-) create mode 100644 compose/config/fields_schema_v2.json create mode 100644 compose/volume.py diff --git a/compose/cli/command.py b/compose/cli/command.py index 21d6ff0ddd2..b278af3a6aa 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -80,12 +80,13 @@ def get_project(base_dir, config_path=None, project_name=None, verbose=False, config_details = config.find(base_dir, config_path) api_version = '1.21' if use_networking else None - return Project.from_dicts( + return Project.from_config( get_project_name(config_details.working_dir, project_name), config.load(config_details), get_client(verbose=verbose, version=api_version), use_networking=use_networking, - network_driver=network_driver) + network_driver=network_driver + ) def get_project_name(working_dir, project_name=None): diff --git a/compose/cli/docker_client.py b/compose/cli/docker_client.py index 24828b11a17..177d5d6c0b7 100644 --- a/compose/cli/docker_client.py +++ b/compose/cli/docker_client.py @@ -8,8 +8,7 @@ log = logging.getLogger(__name__) - -DEFAULT_API_VERSION = '1.20' +DEFAULT_API_VERSION = '1.21' def docker_client(version=None): diff --git a/compose/config/config.py b/compose/config/config.py index 195665b5185..1e82068f847 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -117,6 +117,17 @@ def from_filename(cls, filename): return cls(filename, load_yaml(filename)) +class Config(namedtuple('_Config', 'version services volumes')): + """ + :param version: configuration version + :type version: int + :param services: List of service description dictionaries + :type services: :class:`list` + :param volumes: List of volume description dictionaries + :type volumes: :class:`list` + """ + + class ServiceConfig(namedtuple('_ServiceConfig', 'working_dir filename name config')): @classmethod @@ -148,6 +159,24 @@ def find(base_dir, filenames): [ConfigFile.from_filename(f) for f in filenames]) +def get_config_version(config_details): + def get_version(config): + validate_top_level_object(config) + return config.config.get('version') + main_file = config_details.config_files[0] + version = get_version(main_file) + for next_file in config_details.config_files[1:]: + next_file_version = get_version(next_file) + if version != next_file_version: + raise ConfigurationError( + "Version mismatch: main file {0} specifies version {1} but " + "extension file {2} uses version {3}".format( + main_file.filename, version, next_file.filename, next_file_version + ) + ) + return version + + def get_default_config_files(base_dir): (candidates, path) = find_candidates_in_parent_dirs(SUPPORTED_FILENAMES, base_dir) @@ -194,10 +223,46 @@ def load(config_details): Return a fully interpolated, extended and validated configuration. """ + version = get_config_version(config_details) + processed_files = [] + for config_file in config_details.config_files: + processed_files.append( + process_config_file(config_file, version=version) + ) + config_details = config_details._replace(config_files=processed_files) + if not version or isinstance(version, dict): + service_dicts = load_services( + config_details.working_dir, config_details.config_files + ) + volumes = {} + elif version == 2: + config_files = [ + ConfigFile(f.filename, f.config.get('services', {})) + for f in config_details.config_files + ] + service_dicts = load_services( + config_details.working_dir, config_files + ) + volumes = load_volumes(config_details.config_files) + else: + raise ConfigurationError('Invalid config version provided: {0}'.format(version)) + + return Config(version, service_dicts, volumes) + + +def load_volumes(config_files): + volumes = {} + for config_file in config_files: + for name, volume_config in config_file.config.get('volumes', {}).items(): + volumes.update({name: volume_config}) + return volumes + + +def load_services(working_dir, config_files): def build_service(filename, service_name, service_dict): service_config = ServiceConfig.with_abs_paths( - config_details.working_dir, + working_dir, filename, service_name, service_dict) @@ -227,20 +292,20 @@ def merge_services(base, override): for name in all_service_names } - config_file = process_config_file(config_details.config_files[0]) - for next_file in config_details.config_files[1:]: - next_file = process_config_file(next_file) - + config_file = config_files[0] + for next_file in config_files[1:]: config = merge_services(config_file.config, next_file.config) config_file = config_file._replace(config=config) return build_services(config_file) -def process_config_file(config_file, service_name=None): +def process_config_file(config_file, service_name=None, version=None): validate_top_level_object(config_file) processed_config = interpolate_environment_variables(config_file.config) - validate_against_fields_schema(processed_config, config_file.filename) + validate_against_fields_schema( + processed_config, config_file.filename, version + ) if service_name and service_name not in processed_config: raise ConfigurationError( diff --git a/compose/config/fields_schema_v2.json b/compose/config/fields_schema_v2.json new file mode 100644 index 00000000000..2ca41c478cb --- /dev/null +++ b/compose/config/fields_schema_v2.json @@ -0,0 +1,43 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + + "type": "object", + "properties": { + "version": { + "enum": [2] + }, + "services": { + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "$ref": "fields_schema.json#/definitions/service" + } + } + }, + "volumes": { + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "$ref": "#/definitions/volume" + } + } + } + }, + + "definitions": { + "volume": { + "type": "object", + "properties": { + "driver": {"type": "string"}, + "driver_opts": { + "type": "object", + "patternProperties": { + "^.+$": {"type": ["boolean", "string", "number"]} + }, + "additionalProperties": false + } + } + } + }, + "additionalProperties": false +} diff --git a/compose/config/validation.py b/compose/config/validation.py index d16bdb9d3e0..861cb10f23f 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -281,11 +281,14 @@ def format_error_message(error, service_name): return '\n'.join(format_error_message(error, service_name) for error in errors) -def validate_against_fields_schema(config, filename): +def validate_against_fields_schema(config, filename, version=None): + schema_filename = "fields_schema.json" + if version: + schema_filename = "fields_schema_v{0}.json".format(version) _validate_against_schema( config, - "fields_schema.json", - format_checker=["ports", "expose", "bool-value-in-mapping"], + schema_filename, + format_checker=["ports", "environment", "bool-value-in-mapping"], filename=filename) diff --git a/compose/project.py b/compose/project.py index 76dccfe20f7..b7f33e3fa0c 100644 --- a/compose/project.py +++ b/compose/project.py @@ -20,6 +20,7 @@ from .service import Net from .service import Service from .service import ServiceNet +from .volume import Volume log = logging.getLogger(__name__) @@ -29,12 +30,13 @@ class Project(object): """ A collection of services. """ - def __init__(self, name, services, client, use_networking=False, network_driver=None): + def __init__(self, name, services, client, volumes=None, use_networking=False, network_driver=None): self.name = name self.services = services self.client = client self.use_networking = use_networking self.network_driver = network_driver + self.volumes = volumes or [] def labels(self, one_off=False): return [ @@ -43,16 +45,16 @@ def labels(self, one_off=False): ] @classmethod - def from_dicts(cls, name, service_dicts, client, use_networking=False, network_driver=None): + def from_config(cls, name, config_data, client, use_networking=False, network_driver=None): """ - Construct a ServiceCollection from a list of dicts representing services. + Construct a Project from a config.Config object. """ project = cls(name, [], client, use_networking=use_networking, network_driver=network_driver) if use_networking: - remove_links(service_dicts) + remove_links(config_data.services) - for service_dict in service_dicts: + for service_dict in config_data.services: links = project.get_links(service_dict) volumes_from = project.get_volumes_from(service_dict) net = project.get_net(service_dict) @@ -66,6 +68,14 @@ def from_dicts(cls, name, service_dicts, client, use_networking=False, network_d net=net, volumes_from=volumes_from, **service_dict)) + 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') + ) + ) return project @property @@ -218,6 +228,15 @@ def kill(self, service_names=None, **options): 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: + volume.create() + except NotFound: + raise ConfigurationError( + 'Volume %s sepcifies nonexistent driver %s' % (volume.name, volume.driver) + ) + def restart(self, service_names=None, **options): containers = self.containers(service_names, stopped=True) parallel.parallel_restart(containers, options) @@ -253,6 +272,8 @@ def up(self, if self.use_networking and self.uses_default_network(): self.ensure_network_exists() + self.initialize_volumes() + return [ container for service in services diff --git a/compose/volume.py b/compose/volume.py new file mode 100644 index 00000000000..304633d0455 --- /dev/null +++ b/compose/volume.py @@ -0,0 +1,19 @@ +from __future__ import unicode_literals + + +class Volume(object): + def __init__(self, client, project, name, driver=None, driver_opts=None): + self.client = client + self.project = project + self.name = name + self.driver = driver + self.driver_opts = driver_opts + + def create(self): + return self.client.create_volume(self.name, self.driver, self.driver_opts) + + def remove(self): + return self.client.remove_volume(self.name) + + def inspect(self): + return self.client.inspect_volume(self.name) diff --git a/requirements.txt b/requirements.txt index 659cb57f4ed..ac02b8db06a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ +-e git://github.com/docker/docker-py.git@881e24c231ab9921eb0cbd475e85706137983f89#egg=docker-py PyYAML==3.11 -docker-py==1.5.0 +# docker-py==1.5.1 dockerpty==0.3.4 docopt==0.6.1 enum34==1.0.4 diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index d33cf535a31..4107c6cff1a 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -69,9 +69,9 @@ def test_volumes_from_service(self): 'volumes_from': ['data'], }, }) - project = Project.from_dicts( + project = Project.from_config( name='composetest', - service_dicts=service_dicts, + config_data=service_dicts, client=self.client, ) db = project.get_service('db') @@ -86,9 +86,9 @@ def test_volumes_from_container(self): name='composetest_data_container', labels={LABEL_PROJECT: 'composetest'}, ) - project = Project.from_dicts( + project = Project.from_config( name='composetest', - service_dicts=build_service_dicts({ + config_data=build_service_dicts({ 'db': { 'image': 'busybox:latest', 'volumes_from': ['composetest_data_container'], @@ -117,9 +117,9 @@ def test_get_network(self): assert project.get_network()['Name'] == network_name def test_net_from_service(self): - project = Project.from_dicts( + project = Project.from_config( name='composetest', - service_dicts=build_service_dicts({ + config_data=build_service_dicts({ 'net': { 'image': 'busybox:latest', 'command': ["top"] @@ -149,9 +149,9 @@ def test_net_from_container(self): ) net_container.start() - project = Project.from_dicts( + project = Project.from_config( name='composetest', - service_dicts=build_service_dicts({ + config_data=build_service_dicts({ 'web': { 'image': 'busybox:latest', 'net': 'container:composetest_net_container' @@ -331,7 +331,6 @@ def test_project_up_with_no_recreate_running(self): project.up(['db']) self.assertEqual(len(project.containers()), 1) old_db_id = project.containers()[0].id - container, = project.containers() db_volume_path = container.get_mount('/var/db')['Source'] @@ -401,9 +400,9 @@ def test_project_up_starts_links(self): self.assertEqual(len(console.containers()), 0) def test_project_up_starts_depends(self): - project = Project.from_dicts( + project = Project.from_config( name='composetest', - service_dicts=build_service_dicts({ + config_data=build_service_dicts({ 'console': { 'image': 'busybox:latest', 'command': ["top"], @@ -436,9 +435,9 @@ def test_project_up_starts_depends(self): self.assertEqual(len(project.get_service('console').containers()), 0) def test_project_up_with_no_deps(self): - project = Project.from_dicts( + project = Project.from_config( name='composetest', - service_dicts=build_service_dicts({ + config_data=build_service_dicts({ 'console': { 'image': 'busybox:latest', 'command': ["top"], diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index c288a8adf56..4a0eaacb4fb 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -163,6 +163,7 @@ def test_create_container_with_specified_volume(self): # Match the last component ("host-path"), because boot2docker symlinks /tmp actual_host_path = container.get_mount(container_path)['Source'] + self.assertTrue(path.basename(actual_host_path) == path.basename(host_path), msg=("Last component differs: %s, %s" % (actual_host_path, host_path))) diff --git a/tests/integration/state_test.py b/tests/integration/state_test.py index a54eefa6a4c..d07dfa82a02 100644 --- a/tests/integration/state_test.py +++ b/tests/integration/state_test.py @@ -26,10 +26,10 @@ def make_project(self, cfg): details = config.ConfigDetails( 'working_dir', [config.ConfigFile(None, cfg)]) - return Project.from_dicts( + return Project.from_config( name='composetest', client=self.client, - service_dicts=config.load(details)) + config_data=config.load(details)) class BasicProjectTest(ProjectTestCase): diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index 469859b9be0..a9f5e7bbf5b 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -39,6 +39,10 @@ def tearDown(self): for i in self.client.images( filters={'label': 'com.docker.compose.test_image'}): self.client.remove_image(i) + volumes = self.client.volumes().get('Volumes') or [] + for v in volumes: + if 'composetests_' in v['Name']: + self.client.remove_volume(v['Name']) def create_service(self, name, **kwargs): if 'image' not in kwargs and 'build' not in kwargs: diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index e975cb9d853..426146ccf7a 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -51,7 +51,7 @@ def test_load(self): 'tests/fixtures/extends', 'common.yml' ) - ) + ).services self.assertEqual( service_sort(service_dicts), @@ -143,7 +143,7 @@ def test_load_with_multiple_files(self): }) details = config.ConfigDetails('.', [base_file, override_file]) - service_dicts = config.load(details) + service_dicts = config.load(details).services expected = [ { 'name': 'web', @@ -207,7 +207,7 @@ def test_load_with_multiple_files_and_extends_in_override_file(self): labels: ['label=one'] """) with tmpdir.as_cwd(): - service_dicts = config.load(details) + service_dicts = config.load(details).services expected = [ { @@ -260,7 +260,7 @@ def test_config_valid_service_names(self): build_config_details( {valid_name: {'image': 'busybox'}}, 'tests/fixtures/extends', - 'common.yml')) + 'common.yml')).services assert services[0]['name'] == valid_name def test_config_hint(self): @@ -451,7 +451,7 @@ def test_valid_config_which_allows_two_type_definitions(self): 'working_dir', 'filename.yml' ) - ) + ).services self.assertEqual(service[0]['expose'], expose) def test_valid_config_oneof_string_or_list(self): @@ -466,7 +466,7 @@ def test_valid_config_oneof_string_or_list(self): 'working_dir', 'filename.yml' ) - ) + ).services self.assertEqual(service[0]['entrypoint'], entrypoint) @mock.patch('compose.config.validation.log') @@ -496,7 +496,7 @@ def test_config_valid_environment_dict_key_contains_dashes(self): 'working_dir', 'filename.yml' ) - ) + ).services self.assertEqual(services[0]['environment']['SPRING_JPA_HIBERNATE_DDL-AUTO'], 'none') def test_load_yaml_with_yaml_error(self): @@ -655,7 +655,7 @@ def test_config_file_with_environment_variable(self): service_dicts = config.load( config.find('tests/fixtures/environment-interpolation', None), - ) + ).services self.assertEqual(service_dicts, [ { @@ -722,7 +722,7 @@ def test_empty_environment_key_allowed(self): '.', None, ) - )[0] + ).services[0] self.assertEquals(service_dict['environment']['POSTGRES_PASSWORD'], '') @@ -734,11 +734,15 @@ def test_no_binding(self): @mock.patch.dict(os.environ) def test_volume_binding_with_environment_variable(self): os.environ['VOLUME_PATH'] = '/host/path' - d = config.load(build_config_details( - {'foo': {'build': '.', 'volumes': ['${VOLUME_PATH}:/container/path']}}, - '.', - ))[0] - self.assertEqual(d['volumes'], [VolumeSpec.parse('/host/path:/container/path')]) + + d = config.load( + build_config_details( + {'foo': {'build': '.', 'volumes': ['${VOLUME_PATH}:/container/path']}}, + '.', + None, + ) + ).services[0] + self.assertEqual(d['volumes'], ['/host/path:/container/path']) @pytest.mark.skipif(IS_WINDOWS_PLATFORM, reason='posix paths') @mock.patch.dict(os.environ) @@ -1012,7 +1016,7 @@ def test_validation_with_correct_memswap_values(self): 'tests/fixtures/extends', 'common.yml' ) - ) + ).services self.assertEqual(service_dict[0]['memswap_limit'], 2000000) def test_memswap_can_be_a_string(self): @@ -1022,7 +1026,7 @@ def test_memswap_can_be_a_string(self): 'tests/fixtures/extends', 'common.yml' ) - ) + ).services self.assertEqual(service_dict[0]['memswap_limit'], "512M") @@ -1126,24 +1130,21 @@ def test_resolve_path(self): {'foo': {'build': '.', 'volumes': ['$HOSTENV:$CONTAINERENV']}}, "tests/fixtures/env", ) - )[0] - self.assertEqual( - set(service_dict['volumes']), - set([VolumeSpec.parse('/tmp:/host/tmp')])) + + ).services[0] + self.assertEqual(set(service_dict['volumes']), set(['/tmp:/host/tmp'])) service_dict = config.load( build_config_details( {'foo': {'build': '.', 'volumes': ['/opt${HOSTENV}:/opt${CONTAINERENV}']}}, "tests/fixtures/env", ) - )[0] - self.assertEqual( - set(service_dict['volumes']), - set([VolumeSpec.parse('/opt/tmp:/opt/host/tmp')])) + ).services[0] + self.assertEqual(set(service_dict['volumes']), set(['/opt/tmp:/opt/host/tmp'])) def load_from_filename(filename): - return config.load(config.find('.', [filename])) + return config.load(config.find('.', [filename])).services class ExtendsTest(unittest.TestCase): @@ -1313,7 +1314,7 @@ def test_extends_validation_valid_config(self): 'tests/fixtures/extends', 'common.yml' ) - ) + ).services self.assertEquals(len(service), 1) self.assertIsInstance(service[0], dict) diff --git a/tests/unit/project_test.py b/tests/unit/project_test.py index f4c6f8ca165..c0ed5e33a2c 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -4,6 +4,7 @@ from .. import mock from .. import unittest +from compose.config.config import Config from compose.config.types import VolumeFromSpec from compose.const import LABEL_SERVICE from compose.container import Container @@ -18,7 +19,7 @@ def setUp(self): self.mock_client = mock.create_autospec(docker.Client) def test_from_dict(self): - project = Project.from_dicts('composetest', [ + project = Project.from_config('composetest', Config(None, [ { 'name': 'web', 'image': 'busybox:latest' @@ -27,15 +28,38 @@ def test_from_dict(self): 'name': 'db', 'image': 'busybox:latest' }, - ], None) + ], None), None) self.assertEqual(len(project.services), 2) self.assertEqual(project.get_service('web').name, 'web') 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') + def test_from_dict_sorts_in_dependency_order(self): + project = Project.from_config('composetest', Config(None, [ + { + 'name': 'web', + 'image': 'busybox:latest', + 'links': ['db'], + }, + { + 'name': 'db', + 'image': 'busybox:latest', + 'volumes_from': ['volume'] + }, + { + 'name': 'volume', + 'image': 'busybox:latest', + 'volumes': ['/tmp'], + } + ], None), None) + + self.assertEqual(project.services[0].name, 'volume') + self.assertEqual(project.services[1].name, 'db') + self.assertEqual(project.services[2].name, 'web') + def test_from_config(self): - dicts = [ + dicts = Config(None, [ { 'name': 'web', 'image': 'busybox:latest', @@ -44,8 +68,8 @@ def test_from_config(self): 'name': 'db', 'image': 'busybox:latest', }, - ] - project = Project.from_dicts('composetest', dicts, None) + ], None) + project = Project.from_config('composetest', dicts, None) self.assertEqual(len(project.services), 2) self.assertEqual(project.get_service('web').name, 'web') self.assertEqual(project.get_service('web').options['image'], 'busybox:latest') @@ -141,13 +165,13 @@ def test_use_volumes_from_container(self): container_id = 'aabbccddee' container_dict = dict(Name='aaa', Id=container_id) self.mock_client.inspect_container.return_value = container_dict - project = Project.from_dicts('test', [ + project = Project.from_config('test', Config(None, [ { 'name': 'test', 'image': 'busybox:latest', 'volumes_from': [VolumeFromSpec('aaa', 'rw')] } - ], self.mock_client) + ], None), self.mock_client) self.assertEqual(project.get_service('test')._get_volumes_from(), [container_id + ":rw"]) def test_use_volumes_from_service_no_container(self): @@ -160,7 +184,7 @@ def test_use_volumes_from_service_no_container(self): "Image": 'busybox:latest' } ] - project = Project.from_dicts('test', [ + project = Project.from_config('test', Config(None, [ { 'name': 'vol', 'image': 'busybox:latest' @@ -170,13 +194,13 @@ def test_use_volumes_from_service_no_container(self): 'image': 'busybox:latest', 'volumes_from': [VolumeFromSpec('vol', 'rw')] } - ], self.mock_client) + ], None), self.mock_client) self.assertEqual(project.get_service('test')._get_volumes_from(), [container_name + ":rw"]) def test_use_volumes_from_service_container(self): container_ids = ['aabbccddee', '12345'] - project = Project.from_dicts('test', [ + project = Project.from_config('test', Config(None, [ { 'name': 'vol', 'image': 'busybox:latest' @@ -186,7 +210,7 @@ def test_use_volumes_from_service_container(self): 'image': 'busybox:latest', 'volumes_from': [VolumeFromSpec('vol', 'rw')] } - ], None) + ], None), None) with mock.patch.object(Service, 'containers') as mock_return: mock_return.return_value = [ mock.Mock(id=container_id, spec=Container) @@ -196,12 +220,12 @@ def test_use_volumes_from_service_container(self): [container_ids[0] + ':rw']) def test_net_unset(self): - project = Project.from_dicts('test', [ + project = Project.from_config('test', Config(None, [ { 'name': 'test', 'image': 'busybox:latest', } - ], self.mock_client) + ], None), self.mock_client) service = project.get_service('test') self.assertEqual(service.net.id, None) self.assertNotIn('NetworkMode', service._get_container_host_config({})) @@ -210,13 +234,13 @@ def test_use_net_from_container(self): container_id = 'aabbccddee' container_dict = dict(Name='aaa', Id=container_id) self.mock_client.inspect_container.return_value = container_dict - project = Project.from_dicts('test', [ + project = Project.from_config('test', Config(None, [ { 'name': 'test', 'image': 'busybox:latest', 'net': 'container:aaa' } - ], self.mock_client) + ], None), self.mock_client) service = project.get_service('test') self.assertEqual(service.net.mode, 'container:' + container_id) @@ -230,7 +254,7 @@ def test_use_net_from_service(self): "Image": 'busybox:latest' } ] - project = Project.from_dicts('test', [ + project = Project.from_config('test', Config(None, [ { 'name': 'aaa', 'image': 'busybox:latest' @@ -240,7 +264,7 @@ def test_use_net_from_service(self): 'image': 'busybox:latest', 'net': 'container:aaa' } - ], self.mock_client) + ], None), self.mock_client) service = project.get_service('test') self.assertEqual(service.net.mode, 'container:' + container_name) @@ -285,12 +309,12 @@ def test_container_without_name(self): }, }, } - project = Project.from_dicts( + project = Project.from_config( 'test', - [{ + Config(None, [{ 'name': 'web', 'image': 'busybox:latest', - }], + }], None), self.mock_client, ) self.assertEqual([c.id for c in project.containers()], ['1']) From b253efd8a77447c7ad4f6a6aa5101510704782b1 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 18 Nov 2015 17:45:10 -0800 Subject: [PATCH 1641/4072] Update docs to define and document new compose.yml file format Add volume configuration reference section. Signed-off-by: Joffrey F --- docs/compose-file.md | 86 ++++++++++++++++++++++++++++++++++++++++++++ docs/index.md | 26 ++++++++------ 2 files changed, 102 insertions(+), 10 deletions(-) diff --git a/docs/compose-file.md b/docs/compose-file.md index 2a6028b8cf2..29e0c647c3c 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -24,6 +24,64 @@ 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) + +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 + + ## Service configuration reference This section contains a list of all configuration options supported by a service @@ -413,6 +471,34 @@ Each of these is a single value, analogous to its 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](http://docs.docker.com/reference/commandline/volume/) +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. + + 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 + + ## Variable substitution Your configuration options can contain environment variables. Compose uses the diff --git a/docs/index.md b/docs/index.md index 36b93a39ead..6e8f2090c6a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -31,16 +31,22 @@ 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 + - logvolume01:/var/log + links: + - redis + redis: + image: redis + volumes: + logvolume01: + driver: default For more information about the Compose file, see the [Compose file reference](compose-file.md) From abe145bbe77d09a5b31ee1453a37938e132604e1 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 1 Dec 2015 17:26:32 -0800 Subject: [PATCH 1642/4072] Update config resolution to always use explicit version numbers Also includes several bugfixes for resolution and validation. Signed-off-by: Joffrey F --- compose/config/config.py | 46 +++++++++++++------ ...elds_schema.json => fields_schema_v1.json} | 2 +- compose/config/fields_schema_v2.json | 16 +++++-- compose/config/interpolation.py | 12 +++-- compose/config/service_schema.json | 2 +- compose/config/validation.py | 18 ++++---- compose/const.py | 1 + docker-compose.spec | 10 +++- 8 files changed, 73 insertions(+), 34 deletions(-) rename compose/config/{fields_schema.json => fields_schema_v1.json} (99%) diff --git a/compose/config/config.py b/compose/config/config.py index 1e82068f847..295dc494268 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -10,6 +10,7 @@ import six import yaml +from ..const import COMPOSEFILE_VERSIONS from .errors import CircularReference from .errors import ComposeFileNotFound from .errors import ConfigurationError @@ -24,6 +25,7 @@ from .validation import validate_against_service_schema from .validation import validate_extends_file_path from .validation import validate_top_level_object +from .validation import validate_top_level_service_objects DOCKER_CONFIG_KEYS = [ @@ -161,13 +163,24 @@ def find(base_dir, filenames): def get_config_version(config_details): def get_version(config): - validate_top_level_object(config) - return config.config.get('version') + if config.config is None: + return None + version = config.config.get('version', 1) + if isinstance(version, dict): + version = 1 + return version + main_file = config_details.config_files[0] + validate_top_level_object(main_file) version = get_version(main_file) for next_file in config_details.config_files[1:]: + validate_top_level_object(next_file) next_file_version = get_version(next_file) - if version != next_file_version: + if version is None: + version = next_file_version + continue + + if version != next_file_version and next_file_version is not None: raise ConfigurationError( "Version mismatch: main file {0} specifies version {1} but " "extension file {2} uses version {3}".format( @@ -224,6 +237,9 @@ def load(config_details): Return a fully interpolated, extended and validated configuration. """ version = get_config_version(config_details) + if version not in COMPOSEFILE_VERSIONS: + raise ConfigurationError('Invalid config version provided: {0}'.format(version)) + processed_files = [] for config_file in config_details.config_files: processed_files.append( @@ -231,9 +247,10 @@ def load(config_details): ) config_details = config_details._replace(config_files=processed_files) - if not version or isinstance(version, dict): + if version == 1: service_dicts = load_services( - config_details.working_dir, config_details.config_files + config_details.working_dir, config_details.config_files, + version ) volumes = {} elif version == 2: @@ -242,11 +259,9 @@ def load(config_details): for f in config_details.config_files ] service_dicts = load_services( - config_details.working_dir, config_files + config_details.working_dir, config_files, version ) volumes = load_volumes(config_details.config_files) - else: - raise ConfigurationError('Invalid config version provided: {0}'.format(version)) return Config(version, service_dicts, volumes) @@ -259,14 +274,14 @@ def load_volumes(config_files): return volumes -def load_services(working_dir, config_files): +def load_services(working_dir, config_files, version): def build_service(filename, service_name, service_dict): service_config = ServiceConfig.with_abs_paths( working_dir, filename, service_name, service_dict) - resolver = ServiceExtendsResolver(service_config) + resolver = ServiceExtendsResolver(service_config, version) service_dict = process_service(resolver.run()) # TODO: move to validate_service() @@ -301,8 +316,8 @@ def merge_services(base, override): def process_config_file(config_file, service_name=None, version=None): - validate_top_level_object(config_file) - processed_config = interpolate_environment_variables(config_file.config) + validate_top_level_service_objects(config_file, version) + processed_config = interpolate_environment_variables(config_file.config, version) validate_against_fields_schema( processed_config, config_file.filename, version ) @@ -316,10 +331,11 @@ def process_config_file(config_file, service_name=None, version=None): class ServiceExtendsResolver(object): - def __init__(self, service_config, already_seen=None): + def __init__(self, service_config, version, already_seen=None): self.service_config = service_config self.working_dir = service_config.working_dir self.already_seen = already_seen or [] + self.version = version @property def signature(self): @@ -348,7 +364,8 @@ def validate_and_construct_extends(self): extended_file = process_config_file( ConfigFile.from_filename(config_path), - service_name=service_name) + service_name=service_name, version=self.version + ) service_config = extended_file.config[service_name] return config_path, service_config, service_name @@ -359,6 +376,7 @@ def resolve_extends(self, extended_config_path, service_dict, service_name): extended_config_path, service_name, service_dict), + self.version, already_seen=self.already_seen + [self.signature]) service_config = resolver.run() diff --git a/compose/config/fields_schema.json b/compose/config/fields_schema_v1.json similarity index 99% rename from compose/config/fields_schema.json rename to compose/config/fields_schema_v1.json index fdf56fd91d1..6f0a3631988 100644 --- a/compose/config/fields_schema.json +++ b/compose/config/fields_schema_v1.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-04/schema#", "type": "object", - "id": "fields_schema.json", + "id": "fields_schema_v1.json", "patternProperties": { "^[a-zA-Z0-9._-]+$": { diff --git a/compose/config/fields_schema_v2.json b/compose/config/fields_schema_v2.json index 2ca41c478cb..49cab367084 100644 --- a/compose/config/fields_schema_v2.json +++ b/compose/config/fields_schema_v2.json @@ -1,38 +1,44 @@ { "$schema": "http://json-schema.org/draft-04/schema#", - "type": "object", + "id": "fields_schema_v2.json", + "properties": { "version": { "enum": [2] }, "services": { + "id": "#/properties/services", "type": "object", "patternProperties": { "^[a-zA-Z0-9._-]+$": { - "$ref": "fields_schema.json#/definitions/service" + "$ref": "fields_schema_v1.json#/definitions/service" } - } + }, + "additionalProperties": false }, "volumes": { + "id": "#/properties/volumes", "type": "object", "patternProperties": { "^[a-zA-Z0-9._-]+$": { "$ref": "#/definitions/volume" } - } + }, + "additionalProperties": false } }, "definitions": { "volume": { + "id": "#/definitions/volume", "type": "object", "properties": { "driver": {"type": "string"}, "driver_opts": { "type": "object", "patternProperties": { - "^.+$": {"type": ["boolean", "string", "number"]} + "^.+$": {"type": ["string", "number"]} }, "additionalProperties": false } diff --git a/compose/config/interpolation.py b/compose/config/interpolation.py index ba7e35c1e58..a8ff08d8ec9 100644 --- a/compose/config/interpolation.py +++ b/compose/config/interpolation.py @@ -8,13 +8,19 @@ log = logging.getLogger(__name__) -def interpolate_environment_variables(config): +def interpolate_environment_variables(config, version): mapping = BlankDefaultDict(os.environ) + service_dicts = config if version == 1 else config.get('services', {}) - return dict( + interpolated = dict( (service_name, process_service(service_name, service_dict, mapping)) - for (service_name, service_dict) in config.items() + for (service_name, service_dict) in service_dicts.items() ) + if version == 1: + return interpolated + result = dict(config) + result.update({'services': interpolated}) + return result def process_service(service_name, service_dict, mapping): diff --git a/compose/config/service_schema.json b/compose/config/service_schema.json index 05774efdda7..91a1e005053 100644 --- a/compose/config/service_schema.json +++ b/compose/config/service_schema.json @@ -5,7 +5,7 @@ "type": "object", "allOf": [ - {"$ref": "fields_schema.json#/definitions/service"}, + {"$ref": "fields_schema_v1.json#/definitions/service"}, {"$ref": "#/definitions/constraints"} ], diff --git a/compose/config/validation.py b/compose/config/validation.py index 861cb10f23f..617c95b6a99 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -74,14 +74,15 @@ def format_boolean_in_environment(instance): return True -def validate_top_level_service_objects(config_file): +def validate_top_level_service_objects(config_file, version): """Perform some high level validation of the service name and value. 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. """ - for service_name, service_dict in config_file.config.items(): + service_dicts = config_file.config if version == 1 else config_file.config.get('services', {}) + 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( @@ -105,7 +106,6 @@ def validate_top_level_object(config_file): "that you have defined a service at the top level.".format( config_file.filename, type(config_file.config))) - validate_top_level_service_objects(config_file) def validate_extends_file_path(service_name, extends_options, filename): @@ -134,10 +134,14 @@ def anglicize_validator(validator): return 'a ' + validator +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): schema_id = error.schema['id'] - if schema_id == 'fields_schema.json' and error.validator == 'additionalProperties': + if is_service_dict_schema(schema_id) and error.validator == 'additionalProperties': return "Invalid service name '{}' - only {} characters are allowed".format( # The service_name is the key to the json object list(error.instance)[0], @@ -281,10 +285,8 @@ def format_error_message(error, service_name): return '\n'.join(format_error_message(error, service_name) for error in errors) -def validate_against_fields_schema(config, filename, version=None): - schema_filename = "fields_schema.json" - if version: - schema_filename = "fields_schema_v{0}.json".format(version) +def validate_against_fields_schema(config, filename, version): + schema_filename = "fields_schema_v{0}.json".format(version) _validate_against_schema( config, schema_filename, diff --git a/compose/const.py b/compose/const.py index 1b6894189e2..9c607ca26d8 100644 --- a/compose/const.py +++ b/compose/const.py @@ -10,3 +10,4 @@ LABEL_SERVICE = 'com.docker.compose.service' LABEL_VERSION = 'com.docker.compose.version' LABEL_CONFIG_HASH = 'com.docker.compose.config-hash' +COMPOSEFILE_VERSIONS = (1, 2) diff --git a/docker-compose.spec b/docker-compose.spec index 24d03e05b2e..c760d7b4ce5 100644 --- a/docker-compose.spec +++ b/docker-compose.spec @@ -18,8 +18,13 @@ exe = EXE(pyz, a.datas, [ ( - 'compose/config/fields_schema.json', - 'compose/config/fields_schema.json', + 'compose/config/fields_schema_v1.json', + 'compose/config/fields_schema_v1.json', + 'DATA' + ), + ( + 'compose/config/fields_schema_v2.json', + 'compose/config/fields_schema_v2.json', 'DATA' ), ( @@ -33,6 +38,7 @@ exe = EXE(pyz, 'DATA' ) ], + name='docker-compose', debug=False, strip=None, From df6877a277e1c4bfce24a1fed0bdaa63bdcc84e2 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 1 Dec 2015 17:28:15 -0800 Subject: [PATCH 1643/4072] Use newer docker-py version Signed-off-by: Joffrey F --- requirements.txt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index ac02b8db06a..8c6d5f3ad40 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,5 @@ --e git://github.com/docker/docker-py.git@881e24c231ab9921eb0cbd475e85706137983f89#egg=docker-py PyYAML==3.11 -# docker-py==1.5.1 +docker-py==1.6.0 dockerpty==0.3.4 docopt==0.6.1 enum34==1.0.4 From ecef5d37a7eae730eed47648cbe4eb600eef3004 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 1 Dec 2015 17:28:42 -0800 Subject: [PATCH 1644/4072] Add v2 configuration tests Signed-off-by: Joffrey F --- compose/config/validation.py | 2 +- compose/service.py | 1 + tests/integration/project_test.py | 80 ++++++++++++++ tests/unit/config/config_test.py | 176 ++++++++++++++++++++++++++++-- tests/unit/project_test.py | 23 ---- 5 files changed, 251 insertions(+), 31 deletions(-) diff --git a/compose/config/validation.py b/compose/config/validation.py index 617c95b6a99..6e943974262 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -290,7 +290,7 @@ def validate_against_fields_schema(config, filename, version): _validate_against_schema( config, schema_filename, - format_checker=["ports", "environment", "bool-value-in-mapping"], + format_checker=["ports", "expose", "bool-value-in-mapping"], filename=filename) diff --git a/compose/service.py b/compose/service.py index 251620e972a..24fa63942dc 100644 --- a/compose/service.py +++ b/compose/service.py @@ -868,6 +868,7 @@ def get_container_data_volumes(container, volumes_option): continue mount = container_mounts.get(volume.internal) + # New volume, doesn't exist in the old container if not mount: continue diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 4107c6cff1a..b4acda40240 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -1,5 +1,7 @@ from __future__ import unicode_literals +import random + from .testcases import DockerClientTestCase from compose.cli.docker_client import docker_client from compose.config import config @@ -508,3 +510,81 @@ def test_unscale_after_restart(self): project.up() service = project.get_service('web') self.assertEqual(len(service.containers()), 1) + + def test_project_up_volumes(self): + vol_name = 'composetests_{0:x}'.format(random.getrandbits(32)) + config_data = config.Config( + 2, [{ + 'name': 'web', + 'image': 'busybox:latest', + 'command': 'top' + }], {vol_name: {'driver': 'local'}} + ) + + project = Project.from_config( + name='composetest', + config_data=config_data, client=self.client + ) + project.up() + self.assertEqual(len(project.containers()), 1) + + volume_data = self.client.inspect_volume(vol_name) + self.assertEqual(volume_data['Name'], vol_name) + self.assertEqual(volume_data['Driver'], 'local') + + def test_initialize_volumes(self): + vol_name = 'composetests_{0:x}'.format(random.getrandbits(32)) + config_data = config.Config( + 2, [{ + 'name': 'web', + 'image': 'busybox:latest', + 'command': 'top' + }], {vol_name: {}} + ) + + project = Project.from_config( + name='composetest', + config_data=config_data, client=self.client + ) + project.initialize_volumes() + + volume_data = self.client.inspect_volume(vol_name) + self.assertEqual(volume_data['Name'], vol_name) + self.assertEqual(volume_data['Driver'], 'local') + + def test_project_up_implicit_volume_driver(self): + vol_name = 'composetests_{0:x}'.format(random.getrandbits(32)) + config_data = config.Config( + 2, [{ + 'name': 'web', + 'image': 'busybox:latest', + 'command': 'top' + }], {vol_name: {}} + ) + + project = Project.from_config( + name='composetest', + config_data=config_data, client=self.client + ) + project.up() + + volume_data = self.client.inspect_volume(vol_name) + self.assertEqual(volume_data['Name'], vol_name) + self.assertEqual(volume_data['Driver'], 'local') + + def test_project_up_invalid_volume_driver(self): + vol_name = 'composetests_{0:x}'.format(random.getrandbits(32)) + config_data = config.Config( + 2, [{ + 'name': 'web', + 'image': 'busybox:latest', + 'command': 'top' + }], {vol_name: {'driver': 'foobar'}} + ) + + project = Project.from_config( + name='composetest', + config_data=config_data, client=self.client + ) + with self.assertRaises(config.ConfigurationError): + project.initialize_volumes() diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 426146ccf7a..dac573ed154 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -26,7 +26,7 @@ def make_service_dict(name, service_dict, working_dir, filename=None): working_dir=working_dir, filename=filename, name=name, - config=service_dict)) + config=service_dict), version=1) return config.process_service(resolver.run()) @@ -68,6 +68,85 @@ def test_load(self): ]) ) + def test_load_v2(self): + config_data = config.load( + build_config_details({ + 'version': 2, + 'services': { + 'foo': {'image': 'busybox'}, + 'bar': {'image': 'busybox', 'environment': ['FOO=1']}, + }, + 'volumes': { + 'hello': { + 'driver': 'default', + 'driver_opts': {'beep': 'boop'} + } + } + }, 'working_dir', 'filename.yml') + ) + service_dicts = config_data.services + volume_dict = config_data.volumes + self.assertEqual( + service_sort(service_dicts), + service_sort([ + { + 'name': 'bar', + 'image': 'busybox', + 'environment': {'FOO': '1'}, + }, + { + 'name': 'foo', + 'image': 'busybox', + } + ]) + ) + self.assertEqual(volume_dict, { + 'hello': { + 'driver': 'default', + 'driver_opts': {'beep': 'boop'} + } + }) + + def test_load_service_with_name_version(self): + config_data = config.load( + build_config_details({ + 'version': { + 'image': 'busybox' + } + }, 'working_dir', 'filename.yml') + ) + service_dicts = config_data.services + self.assertEqual( + service_sort(service_dicts), + service_sort([ + { + 'name': 'version', + 'image': 'busybox', + } + ]) + ) + + 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( @@ -78,6 +157,16 @@ def test_load_throws_error_when_not_dict(self): ) ) + def test_load_throws_error_when_not_dict_v2(self): + with self.assertRaises(ConfigurationError): + config.load( + build_config_details( + {'version': 2, 'services': {'web': 'busybox:latest'}}, + '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: @@ -87,6 +176,17 @@ def test_load_config_invalid_service_names(self): 'filename.yml')) assert 'Invalid service name \'%s\'' % invalid_name in exc.exconly() + def test_config_invalid_service_names_v2(self): + for invalid_name in ['?not?allowed', ' ', '', '!', '/', '\xe2']: + with pytest.raises(ConfigurationError) as exc: + config.load( + build_config_details({ + 'version': 2, + 'services': {invalid_name: {'image': 'busybox'}} + }, 'working_dir', 'filename.yml') + ) + 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'}}, @@ -120,6 +220,22 @@ def test_config_integer_service_name_raise_validation_error(self): ) ) + 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'") + + with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): + config.load( + build_config_details( + { + 'version': 2, + 'services': {1: {'image': 'busybox'}} + }, + 'working_dir', + 'filename.yml' + ) + ) + @pytest.mark.xfail(IS_WINDOWS_PLATFORM, reason='paths use slash') def test_load_with_multiple_files(self): base_file = config.ConfigFile( @@ -248,12 +364,55 @@ def test_load_sorts_in_dependency_order(self): 'volumes': ['/tmp'], } }) - services = config.load(config_details) + services = config.load(config_details).services assert services[0]['name'] == 'volume' assert services[1]['name'] == 'db' assert services[2]['name'] == 'web' + def test_load_with_multiple_files_v2(self): + base_file = config.ConfigFile( + 'base.yaml', + { + 'version': 2, + 'services': { + 'web': { + 'image': 'example/web', + 'links': ['db'], + }, + 'db': { + 'image': 'example/db', + } + }, + }) + override_file = config.ConfigFile( + 'override.yaml', + { + 'version': 2, + 'services': { + 'web': { + 'build': '/', + 'volumes': ['/home/user/project:/code'], + }, + } + }) + details = config.ConfigDetails('.', [base_file, override_file]) + + service_dicts = config.load(details).services + expected = [ + { + 'name': 'web', + 'build': os.path.abspath('/'), + 'links': ['db'], + 'volumes': [VolumeSpec.parse('/home/user/project:/code')], + }, + { + 'name': 'db', + 'image': 'example/db', + }, + ] + self.assertEqual(service_sort(service_dicts), service_sort(expected)) + def test_config_valid_service_names(self): for valid_name in ['_', '-', '.__.', '_what-up.', 'what_.up----', 'whatup']: services = config.load( @@ -742,7 +901,7 @@ def test_volume_binding_with_environment_variable(self): None, ) ).services[0] - self.assertEqual(d['volumes'], ['/host/path:/container/path']) + self.assertEqual(d['volumes'], [VolumeSpec.parse('/host/path:/container/path')]) @pytest.mark.skipif(IS_WINDOWS_PLATFORM, reason='posix paths') @mock.patch.dict(os.environ) @@ -1130,9 +1289,10 @@ def test_resolve_path(self): {'foo': {'build': '.', 'volumes': ['$HOSTENV:$CONTAINERENV']}}, "tests/fixtures/env", ) - ).services[0] - self.assertEqual(set(service_dict['volumes']), set(['/tmp:/host/tmp'])) + self.assertEqual( + set(service_dict['volumes']), + set([VolumeSpec.parse('/tmp:/host/tmp')])) service_dict = config.load( build_config_details( @@ -1140,7 +1300,9 @@ def test_resolve_path(self): "tests/fixtures/env", ) ).services[0] - self.assertEqual(set(service_dict['volumes']), set(['/opt/tmp:/opt/host/tmp'])) + self.assertEqual( + set(service_dict['volumes']), + set([VolumeSpec.parse('/opt/tmp:/opt/host/tmp')])) def load_from_filename(filename): @@ -1595,7 +1757,7 @@ def test_valid_url_in_build_path(self): for valid_url in valid_urls: service_dict = config.load(build_config_details({ 'validurl': {'build': valid_url}, - }, '.', None)) + }, '.', None)).services assert service_dict[0]['build'] == valid_url def test_invalid_url_in_build_path(self): diff --git a/tests/unit/project_test.py b/tests/unit/project_test.py index c0ed5e33a2c..4bf5f463659 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -35,29 +35,6 @@ def test_from_dict(self): self.assertEqual(project.get_service('db').name, 'db') self.assertEqual(project.get_service('db').options['image'], 'busybox:latest') - def test_from_dict_sorts_in_dependency_order(self): - project = Project.from_config('composetest', Config(None, [ - { - 'name': 'web', - 'image': 'busybox:latest', - 'links': ['db'], - }, - { - 'name': 'db', - 'image': 'busybox:latest', - 'volumes_from': ['volume'] - }, - { - 'name': 'volume', - 'image': 'busybox:latest', - 'volumes': ['/tmp'], - } - ], None), None) - - self.assertEqual(project.services[0].name, 'volume') - self.assertEqual(project.services[1].name, 'db') - self.assertEqual(project.services[2].name, 'web') - def test_from_config(self): dicts = Config(None, [ { From ec5111f1c29c5dd66d161906042ca39183ebd659 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 8 Dec 2015 17:21:20 -0800 Subject: [PATCH 1645/4072] Volumes are now prefixed with the project name When created through the compose file, volumes are prefixed with the name of the project they belong to + underscore, similarly to how containers are currently handled. Signed-off-by: Joffrey F --- compose/cli/main.py | 4 +-- compose/volume.py | 12 +++++-- tests/integration/project_test.py | 16 +++++---- tests/integration/testcases.py | 2 +- tests/integration/volume_test.py | 55 +++++++++++++++++++++++++++++++ 5 files changed, 77 insertions(+), 12 deletions(-) create mode 100644 tests/integration/volume_test.py diff --git a/compose/cli/main.py b/compose/cli/main.py index 4a766133fb4..006d33ecb64 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -211,11 +211,11 @@ def config(self, config_options, options): return if options['--services']: - print('\n'.join(service['name'] for service in compose_config)) + print('\n'.join(service['name'] for service in compose_config.services)) return compose_config = dict( - (service.pop('name'), service) for service in compose_config) + (service.pop('name'), service) for service in compose_config.services) print(yaml.dump( compose_config, default_flow_style=False, diff --git a/compose/volume.py b/compose/volume.py index 304633d0455..055bd6abde4 100644 --- a/compose/volume.py +++ b/compose/volume.py @@ -10,10 +10,16 @@ def __init__(self, client, project, name, driver=None, driver_opts=None): self.driver_opts = driver_opts def create(self): - return self.client.create_volume(self.name, self.driver, self.driver_opts) + return self.client.create_volume( + self.full_name, self.driver, self.driver_opts + ) def remove(self): - return self.client.remove_volume(self.name) + return self.client.remove_volume(self.full_name) def inspect(self): - return self.client.inspect_volume(self.name) + return self.client.inspect_volume(self.full_name) + + @property + def full_name(self): + return '{0}_{1}'.format(self.project, self.name) diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index b4acda40240..70def6177b8 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -513,6 +513,7 @@ def test_unscale_after_restart(self): def test_project_up_volumes(self): vol_name = 'composetests_{0:x}'.format(random.getrandbits(32)) + full_vol_name = 'composetest_{0}'.format(vol_name) config_data = config.Config( 2, [{ 'name': 'web', @@ -528,12 +529,13 @@ def test_project_up_volumes(self): project.up() self.assertEqual(len(project.containers()), 1) - volume_data = self.client.inspect_volume(vol_name) - self.assertEqual(volume_data['Name'], vol_name) + volume_data = self.client.inspect_volume(full_vol_name) + self.assertEqual(volume_data['Name'], full_vol_name) self.assertEqual(volume_data['Driver'], 'local') def test_initialize_volumes(self): vol_name = 'composetests_{0:x}'.format(random.getrandbits(32)) + full_vol_name = 'composetest_{0}'.format(vol_name) config_data = config.Config( 2, [{ 'name': 'web', @@ -548,12 +550,13 @@ def test_initialize_volumes(self): ) project.initialize_volumes() - volume_data = self.client.inspect_volume(vol_name) - self.assertEqual(volume_data['Name'], vol_name) + volume_data = self.client.inspect_volume(full_vol_name) + self.assertEqual(volume_data['Name'], full_vol_name) self.assertEqual(volume_data['Driver'], 'local') def test_project_up_implicit_volume_driver(self): vol_name = 'composetests_{0:x}'.format(random.getrandbits(32)) + full_vol_name = 'composetest_{0}'.format(vol_name) config_data = config.Config( 2, [{ 'name': 'web', @@ -568,12 +571,13 @@ def test_project_up_implicit_volume_driver(self): ) project.up() - volume_data = self.client.inspect_volume(vol_name) - self.assertEqual(volume_data['Name'], vol_name) + volume_data = self.client.inspect_volume(full_vol_name) + self.assertEqual(volume_data['Name'], full_vol_name) self.assertEqual(volume_data['Driver'], 'local') def test_project_up_invalid_volume_driver(self): vol_name = 'composetests_{0:x}'.format(random.getrandbits(32)) + config_data = config.Config( 2, [{ 'name': 'web', diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index a9f5e7bbf5b..8e0525eefd6 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -41,7 +41,7 @@ def tearDown(self): self.client.remove_image(i) volumes = self.client.volumes().get('Volumes') or [] for v in volumes: - if 'composetests_' in v['Name']: + if 'composetest_' in v['Name']: self.client.remove_volume(v['Name']) def create_service(self, name, **kwargs): diff --git a/tests/integration/volume_test.py b/tests/integration/volume_test.py new file mode 100644 index 00000000000..b60860400a3 --- /dev/null +++ b/tests/integration/volume_test.py @@ -0,0 +1,55 @@ +from __future__ import unicode_literals + +from docker.errors import DockerException + +from .testcases import DockerClientTestCase +from compose.volume import Volume + + +class VolumeTest(DockerClientTestCase): + def setUp(self): + self.tmp_volumes = [] + + def tearDown(self): + for volume in self.tmp_volumes: + try: + self.client.remove_volume(volume.full_name) + except DockerException: + pass + + def create_volume(self, name, driver=None, opts=None): + vol = Volume( + self.client, 'composetest', name, driver=driver, driver_opts=opts + ) + self.tmp_volumes.append(vol) + return vol + + def test_create_volume(self): + vol = self.create_volume('volume01') + vol.create() + info = self.client.inspect_volume(vol.full_name) + assert info['Name'] == vol.full_name + + def test_recreate_existing_volume(self): + vol = self.create_volume('volume01') + + vol.create() + info = self.client.inspect_volume(vol.full_name) + assert info['Name'] == vol.full_name + + vol.create() + info = self.client.inspect_volume(vol.full_name) + assert info['Name'] == vol.full_name + + def test_inspect_volume(self): + vol = self.create_volume('volume01') + vol.create() + info = vol.inspect() + assert info['Name'] == vol.full_name + + def test_remove_volume(self): + vol = Volume(self.client, 'composetest', 'volume01') + vol.create() + vol.remove() + volumes = self.client.volumes()['Volumes'] + assert len([v for v in volumes if v['Name'] == vol.full_name]) == 0 From 661519ac1c8fa7913cd9d289951c41447eeebff9 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 9 Dec 2015 14:47:09 -0800 Subject: [PATCH 1646/4072] Only test latest version in CI script Signed-off-by: Joffrey F --- script/test-versions | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/test-versions b/script/test-versions index 623b107b930..a905cedfddd 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="$($get_versions recent -n 2)" + DOCKER_VERSIONS="$($get_versions recent -n 1)" fi From f3a9533dc04cfa43be128d97bea3b30fc003b7c6 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 9 Dec 2015 17:06:56 -0800 Subject: [PATCH 1647/4072] version no longer optional arg for process_config_file Signed-off-by: Joffrey F --- compose/config/config.py | 4 ++-- compose/project.py | 2 +- tests/integration/project_test.py | 20 ++++++++++---------- tests/unit/config/config_test.py | 2 +- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 295dc494268..63ef44c3301 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -315,7 +315,7 @@ def merge_services(base, override): return build_services(config_file) -def process_config_file(config_file, service_name=None, version=None): +def process_config_file(config_file, version, service_name=None): validate_top_level_service_objects(config_file, version) processed_config = interpolate_environment_variables(config_file.config, version) validate_against_fields_schema( @@ -364,7 +364,7 @@ def validate_and_construct_extends(self): extended_file = process_config_file( ConfigFile.from_filename(config_path), - service_name=service_name, version=self.version + version=self.version, service_name=service_name ) service_config = extended_file.config[service_name] return config_path, service_config, service_name diff --git a/compose/project.py b/compose/project.py index b7f33e3fa0c..36855f16ba2 100644 --- a/compose/project.py +++ b/compose/project.py @@ -234,7 +234,7 @@ def initialize_volumes(self): volume.create() except NotFound: raise ConfigurationError( - 'Volume %s sepcifies nonexistent driver %s' % (volume.name, volume.driver) + 'Volume %s specifies nonexistent driver %s' % (volume.name, volume.driver) ) def restart(self, service_names=None, **options): diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 70def6177b8..f2c650843eb 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -512,14 +512,14 @@ def test_unscale_after_restart(self): self.assertEqual(len(service.containers()), 1) def test_project_up_volumes(self): - vol_name = 'composetests_{0:x}'.format(random.getrandbits(32)) + vol_name = '{0:x}'.format(random.getrandbits(32)) full_vol_name = 'composetest_{0}'.format(vol_name) config_data = config.Config( - 2, [{ + version=2, services=[{ 'name': 'web', 'image': 'busybox:latest', 'command': 'top' - }], {vol_name: {'driver': 'local'}} + }], volumes={vol_name: {'driver': 'local'}} ) project = Project.from_config( @@ -534,14 +534,14 @@ def test_project_up_volumes(self): self.assertEqual(volume_data['Driver'], 'local') def test_initialize_volumes(self): - vol_name = 'composetests_{0:x}'.format(random.getrandbits(32)) + vol_name = '{0:x}'.format(random.getrandbits(32)) full_vol_name = 'composetest_{0}'.format(vol_name) config_data = config.Config( - 2, [{ + version=2, services=[{ 'name': 'web', 'image': 'busybox:latest', 'command': 'top' - }], {vol_name: {}} + }], volumes={vol_name: {}} ) project = Project.from_config( @@ -555,14 +555,14 @@ def test_initialize_volumes(self): self.assertEqual(volume_data['Driver'], 'local') def test_project_up_implicit_volume_driver(self): - vol_name = 'composetests_{0:x}'.format(random.getrandbits(32)) + vol_name = '{0:x}'.format(random.getrandbits(32)) full_vol_name = 'composetest_{0}'.format(vol_name) config_data = config.Config( - 2, [{ + version=2, services=[{ 'name': 'web', 'image': 'busybox:latest', 'command': 'top' - }], {vol_name: {}} + }], volumes={vol_name: {}} ) project = Project.from_config( @@ -576,7 +576,7 @@ def test_project_up_implicit_volume_driver(self): self.assertEqual(volume_data['Driver'], 'local') def test_project_up_invalid_volume_driver(self): - vol_name = 'composetests_{0:x}'.format(random.getrandbits(32)) + vol_name = '{0:x}'.format(random.getrandbits(32)) config_data = config.Config( 2, [{ diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index dac573ed154..74978150ad5 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -702,7 +702,7 @@ def test_normalize_dns_options(self): 'dns_search': 'domain.local', } })) - assert actual == [ + assert actual.services == [ { 'name': 'web', 'image': 'alpine', From a7689f3da8a671b2a5dacd58ebc9259c86793f86 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 11 Dec 2015 17:21:04 -0800 Subject: [PATCH 1648/4072] Handle volume driver change error in config. Assume version=1 if file is empty in get_config_version Empty files are invalid anyway, so this simplifies the algorithm somewhat. https://github.com/docker/compose/pull/2421#discussion_r47223144 Don't leak version considerations in interpolation/service validation Signed-off-by: Joffrey F --- compose/config/config.py | 22 +++++++++++++----- compose/config/interpolation.py | 10 ++------ compose/config/validation.py | 10 ++++---- compose/project.py | 12 ++++++++++ tests/integration/project_test.py | 38 +++++++++++++++++++++++++++++-- tests/unit/config/config_test.py | 23 +++++++++++++++++++ 6 files changed, 94 insertions(+), 21 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 63ef44c3301..c77e6100854 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -118,6 +118,9 @@ class ConfigFile(namedtuple('_ConfigFile', 'filename config')): def from_filename(cls, filename): return cls(filename, load_yaml(filename)) + def get_service_dicts(self, version): + return self.config if version == 1 else self.config.get('services', {}) + class Config(namedtuple('_Config', 'version services volumes')): """ @@ -164,9 +167,11 @@ def find(base_dir, filenames): def get_config_version(config_details): def get_version(config): if config.config is None: - return None + return 1 version = config.config.get('version', 1) if isinstance(version, dict): + # in that case 'version' is probably a service name, so assume + # this is a legacy (version=1) file version = 1 return version @@ -176,9 +181,6 @@ def get_version(config): for next_file in config_details.config_files[1:]: validate_top_level_object(next_file) next_file_version = get_version(next_file) - if version is None: - version = next_file_version - continue if version != next_file_version and next_file_version is not None: raise ConfigurationError( @@ -316,8 +318,16 @@ def merge_services(base, override): def process_config_file(config_file, version, service_name=None): - validate_top_level_service_objects(config_file, version) - processed_config = interpolate_environment_variables(config_file.config, version) + service_dicts = config_file.get_service_dicts(version) + validate_top_level_service_objects( + config_file.filename, service_dicts + ) + interpolated_config = interpolate_environment_variables(service_dicts) + if version == 2: + processed_config = dict(config_file.config) + processed_config.update({'services': interpolated_config}) + if version == 1: + processed_config = interpolated_config validate_against_fields_schema( processed_config, config_file.filename, version ) diff --git a/compose/config/interpolation.py b/compose/config/interpolation.py index a8ff08d8ec9..12eb497b603 100644 --- a/compose/config/interpolation.py +++ b/compose/config/interpolation.py @@ -8,19 +8,13 @@ log = logging.getLogger(__name__) -def interpolate_environment_variables(config, version): +def interpolate_environment_variables(service_dicts): mapping = BlankDefaultDict(os.environ) - service_dicts = config if version == 1 else config.get('services', {}) - interpolated = dict( + return dict( (service_name, process_service(service_name, service_dict, mapping)) for (service_name, service_dict) in service_dicts.items() ) - if version == 1: - return interpolated - result = dict(config) - result.update({'services': interpolated}) - return result def process_service(service_name, service_dict, mapping): diff --git a/compose/config/validation.py b/compose/config/validation.py index 6e943974262..091014f6567 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -74,19 +74,18 @@ def format_boolean_in_environment(instance): return True -def validate_top_level_service_objects(config_file, version): +def validate_top_level_service_objects(filename, service_dicts): """Perform some high level validation of the service name and value. 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. """ - service_dicts = config_file.config if version == 1 else config_file.config.get('services', {}) 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( - config_file.filename, + filename, service_name, service_name)) @@ -95,8 +94,9 @@ def validate_top_level_service_objects(config_file, version): "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( - config_file.filename, - service_name)) + filename, service_name + ) + ) def validate_top_level_object(config_file): diff --git a/compose/project.py b/compose/project.py index 36855f16ba2..3801bbb9f87 100644 --- a/compose/project.py +++ b/compose/project.py @@ -236,6 +236,18 @@ def initialize_volumes(self): 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 restart(self, service_names=None, **options): containers = self.containers(service_names, stopped=True) diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index f2c650843eb..d51830bb0ca 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -579,11 +579,11 @@ def test_project_up_invalid_volume_driver(self): vol_name = '{0:x}'.format(random.getrandbits(32)) config_data = config.Config( - 2, [{ + version=2, services=[{ 'name': 'web', 'image': 'busybox:latest', 'command': 'top' - }], {vol_name: {'driver': 'foobar'}} + }], volumes={vol_name: {'driver': 'foobar'}} ) project = Project.from_config( @@ -592,3 +592,37 @@ def test_project_up_invalid_volume_driver(self): ) with self.assertRaises(config.ConfigurationError): project.initialize_volumes() + + def test_project_up_updated_driver(self): + vol_name = '{0:x}'.format(random.getrandbits(32)) + full_vol_name = 'composetest_{0}'.format(vol_name) + + config_data = config.Config( + version=2, services=[{ + 'name': 'web', + 'image': 'busybox:latest', + 'command': 'top' + }], volumes={vol_name: {'driver': 'local'}} + ) + project = Project.from_config( + name='composetest', + config_data=config_data, client=self.client + ) + project.initialize_volumes() + + 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: {'driver': 'smb'}} + ) + project = Project.from_config( + name='composetest', + config_data=config_data, client=self.client + ) + with self.assertRaises(config.ConfigurationError) as e: + project.initialize_volumes() + assert 'Configuration for volume {0} specifies driver smb'.format( + vol_name + ) in str(e.exception) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 74978150ad5..281e81d1e77 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -286,6 +286,18 @@ def test_load_with_multiple_files_and_empty_override(self): error_msg = "Top level object in 'override.yml' needs to be an object" assert error_msg in exc.exconly() + def test_load_with_multiple_files_and_empty_override_v2(self): + base_file = config.ConfigFile( + 'base.yml', + {'version': 2, 'services': {'web': {'image': 'example/web'}}}) + override_file = config.ConfigFile('override.yml', None) + details = config.ConfigDetails('.', [base_file, override_file]) + + with pytest.raises(ConfigurationError) as exc: + config.load(details) + error_msg = "Top level object in 'override.yml' needs to be an object" + assert error_msg in exc.exconly() + def test_load_with_multiple_files_and_empty_base(self): base_file = config.ConfigFile('base.yml', None) override_file = config.ConfigFile( @@ -297,6 +309,17 @@ def test_load_with_multiple_files_and_empty_base(self): config.load(details) assert "Top level object in 'base.yml' needs to be an object" in exc.exconly() + def test_load_with_multiple_files_and_empty_base_v2(self): + base_file = config.ConfigFile('base.yml', None) + override_file = config.ConfigFile( + 'override.tml', + {'version': 2, 'services': {'web': {'image': 'example/web'}}} + ) + details = config.ConfigDetails('.', [base_file, override_file]) + with pytest.raises(ConfigurationError) as exc: + config.load(details) + assert "Top level object in 'base.yml' needs to be an object" in exc.exconly() + def test_load_with_multiple_files_and_extends_in_override_file(self): base_file = config.ConfigFile( 'base.yaml', From 1dcdd98da4d8f5bce52eddf013875b730e2ed130 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 5 Jan 2016 15:24:58 -0800 Subject: [PATCH 1649/4072] Add TODO note to restore n-1 version testing after 1.10 release Signed-off-by: Joffrey F --- script/test-versions | 1 + 1 file changed, 1 insertion(+) diff --git a/script/test-versions b/script/test-versions index a905cedfddd..76e55e1193b 100755 --- a/script/test-versions +++ b/script/test-versions @@ -18,6 +18,7 @@ get_versions="docker run --rm if [ "$DOCKER_VERSIONS" == "" ]; then DOCKER_VERSIONS="$($get_versions default)" elif [ "$DOCKER_VERSIONS" == "all" ]; then + # TODO: `-n 2` when engine 1.10 releases DOCKER_VERSIONS="$($get_versions recent -n 1)" fi From c7b71422c0bea8ce19b8f28e426fe4ea597eb83c Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 6 Jan 2016 13:30:40 -0500 Subject: [PATCH 1650/4072] Fix extends with multiple files. Signed-off-by: Daniel Nephin --- compose/config/config.py | 2 ++ tests/unit/config/config_test.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/compose/config/config.py b/compose/config/config.py index 195665b5185..9e62ef1c7c8 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -422,6 +422,8 @@ def merge_service_dicts_from_files(base, override): new_service = merge_service_dicts(base, override) if 'extends' in override: new_service['extends'] = override['extends'] + elif 'extends' in base: + new_service['extends'] = base['extends'] return new_service diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index e975cb9d853..97a0838f86b 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -552,6 +552,37 @@ def test_normalize_dns_options(self): } ] + def test_merge_service_dicts_from_files_with_extends_in_base(self): + base = { + 'volumes': ['.:/app'], + 'extends': {'service': 'app'} + } + override = { + 'image': 'alpine:edge', + } + actual = config.merge_service_dicts_from_files(base, override) + assert actual == { + 'image': 'alpine:edge', + 'volumes': ['.:/app'], + 'extends': {'service': 'app'} + } + + def test_merge_service_dicts_from_files_with_extends_in_override(self): + base = { + 'volumes': ['.:/app'], + 'extends': {'service': 'app'} + } + override = { + 'image': 'alpine:edge', + 'extends': {'service': 'foo'} + } + actual = config.merge_service_dicts_from_files(base, override) + assert actual == { + 'image': 'alpine:edge', + 'volumes': ['.:/app'], + 'extends': {'service': 'foo'} + } + class PortsTest(unittest.TestCase): INVALID_PORTS_TYPES = [ From 77d2aae72dbed943e0b7ae58e392a5bca49a4263 Mon Sep 17 00:00:00 2001 From: Vincent Demeester Date: Wed, 23 Dec 2015 23:53:32 +0100 Subject: [PATCH 1651/4072] Fix typo in unpause reference doc Signed-off-by: Vincent Demeester --- docs/reference/unpause.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/unpause.md b/docs/reference/unpause.md index 6434b09ccc3..846b229e3cc 100644 --- a/docs/reference/unpause.md +++ b/docs/reference/unpause.md @@ -9,7 +9,7 @@ parent = "smn_compose_cli" +++ -# pause +# unpause ``` Usage: unpause [SERVICE...] From 4e75ed42319b372ac79c7b8762c5fec794afa841 Mon Sep 17 00:00:00 2001 From: Vincent Demeester Date: Wed, 23 Dec 2015 23:59:48 +0100 Subject: [PATCH 1652/4072] Add missing --name flag to run reference doc Signed-off-by: Vincent Demeester --- docs/reference/run.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/reference/run.md b/docs/reference/run.md index c1efb9a773e..21890c60a92 100644 --- a/docs/reference/run.md +++ b/docs/reference/run.md @@ -17,6 +17,7 @@ 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 From 0bca8d9cb39a01736f2ce043f2ea7b6407ffc281 Mon Sep 17 00:00:00 2001 From: Vincent Demeester Date: Wed, 6 Jan 2016 21:28:47 +0100 Subject: [PATCH 1653/4072] Add config and create to docs/reference Signed-off-by: Vincent Demeester --- docs/reference/config.md | 23 +++++++++++++++++++++++ docs/reference/create.md | 25 +++++++++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 docs/reference/config.md create mode 100644 docs/reference/create.md diff --git a/docs/reference/config.md b/docs/reference/config.md new file mode 100644 index 00000000000..1a9706f4da9 --- /dev/null +++ b/docs/reference/config.md @@ -0,0 +1,23 @@ + + +# 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 new file mode 100644 index 00000000000..a785e2c704b --- /dev/null +++ b/docs/reference/create.md @@ -0,0 +1,25 @@ + + +# create + +``` +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 +``` + +Creates containers for a service. From 475a09176850c3f6d9fd51fc6e82e03263a3d733 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 30 Oct 2015 16:22:51 -0400 Subject: [PATCH 1654/4072] Update pre-commit config to enforace that future imports exist in all files. Signed-off-by: Daniel Nephin --- .pre-commit-config.yaml | 7 ++++++- compose/__init__.py | 1 + compose/cli/colors.py | 1 + compose/cli/docker_client.py | 3 +++ compose/cli/errors.py | 1 + compose/cli/main.py | 1 + compose/cli/multiplexer.py | 1 + compose/cli/verbose_proxy.py | 3 +++ compose/config/__init__.py | 3 +++ compose/config/config.py | 1 + compose/config/errors.py | 4 ++++ compose/config/interpolation.py | 3 +++ compose/config/validation.py | 3 +++ compose/const.py | 3 +++ compose/progress_stream.py | 3 +++ compose/utils.py | 3 +++ script/travis/render-bintray-config.py | 2 ++ script/versions.py | 2 ++ tests/__init__.py | 3 +++ tests/acceptance/cli_test.py | 1 + tests/integration/project_test.py | 1 + tests/integration/state_test.py | 1 + tests/unit/cli/command_test.py | 1 + tests/unit/cli/main_test.py | 1 + tests/unit/config/config_test.py | 2 ++ tests/unit/config/sort_services_test.py | 3 +++ tests/unit/container_test.py | 1 + tests/unit/interpolation_test.py | 3 +++ tests/unit/multiplexer_test.py | 3 +++ tests/unit/project_test.py | 1 + tests/unit/utils_test.py | 1 + 31 files changed, 66 insertions(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3fad8ddcbe8..db2b6506bb1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,7 +14,12 @@ - id: requirements-txt-fixer - id: trailing-whitespace - repo: git://github.com/asottile/reorder_python_imports - sha: 3d86483455ab5bd06cc1069fdd5ac57be5463f10 + sha: v0.1.0 hooks: - id: reorder-python-imports language_version: 'python2.7' + args: + - --add-import + - from __future__ import absolute_import + - --add-import + - from __future__ import unicode_literals diff --git a/compose/__init__.py b/compose/__init__.py index 7c16c97ba45..3ba90fdef92 100644 --- a/compose/__init__.py +++ b/compose/__init__.py @@ -1,3 +1,4 @@ +from __future__ import absolute_import from __future__ import unicode_literals __version__ = '1.6.0dev' diff --git a/compose/cli/colors.py b/compose/cli/colors.py index af4a32ab452..3c18886f853 100644 --- a/compose/cli/colors.py +++ b/compose/cli/colors.py @@ -1,3 +1,4 @@ +from __future__ import absolute_import from __future__ import unicode_literals NAMES = [ 'grey', diff --git a/compose/cli/docker_client.py b/compose/cli/docker_client.py index 177d5d6c0b7..48ba97bdabe 100644 --- a/compose/cli/docker_client.py +++ b/compose/cli/docker_client.py @@ -1,3 +1,6 @@ +from __future__ import absolute_import +from __future__ import unicode_literals + import logging import os diff --git a/compose/cli/errors.py b/compose/cli/errors.py index ca4413bd1a8..03d6a50c6a1 100644 --- a/compose/cli/errors.py +++ b/compose/cli/errors.py @@ -1,4 +1,5 @@ from __future__ import absolute_import +from __future__ import unicode_literals from textwrap import dedent diff --git a/compose/cli/main.py b/compose/cli/main.py index 006d33ecb64..9ea9df71b89 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -1,3 +1,4 @@ +from __future__ import absolute_import from __future__ import print_function from __future__ import unicode_literals diff --git a/compose/cli/multiplexer.py b/compose/cli/multiplexer.py index 4c73c6cdc6e..5e8d91a47fe 100644 --- a/compose/cli/multiplexer.py +++ b/compose/cli/multiplexer.py @@ -1,4 +1,5 @@ from __future__ import absolute_import +from __future__ import unicode_literals from threading import Thread diff --git a/compose/cli/verbose_proxy.py b/compose/cli/verbose_proxy.py index 68dfabe521c..b1592eabe73 100644 --- a/compose/cli/verbose_proxy.py +++ b/compose/cli/verbose_proxy.py @@ -1,3 +1,6 @@ +from __future__ import absolute_import +from __future__ import unicode_literals + import functools import logging import pprint diff --git a/compose/config/__init__.py b/compose/config/__init__.py index 6fe9ff9fb6a..dd01f221eaa 100644 --- a/compose/config/__init__.py +++ b/compose/config/__init__.py @@ -1,4 +1,7 @@ # flake8: noqa +from __future__ import absolute_import +from __future__ import unicode_literals + 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 c77e6100854..61b40589e7a 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -1,4 +1,5 @@ from __future__ import absolute_import +from __future__ import unicode_literals import codecs import logging diff --git a/compose/config/errors.py b/compose/config/errors.py index 6d6a69df9a7..99129f3de0c 100644 --- a/compose/config/errors.py +++ b/compose/config/errors.py @@ -1,3 +1,7 @@ +from __future__ import absolute_import +from __future__ import unicode_literals + + class ConfigurationError(Exception): def __init__(self, msg): self.msg = msg diff --git a/compose/config/interpolation.py b/compose/config/interpolation.py index 12eb497b603..7a757644897 100644 --- a/compose/config/interpolation.py +++ b/compose/config/interpolation.py @@ -1,3 +1,6 @@ +from __future__ import absolute_import +from __future__ import unicode_literals + import logging import os from string import Template diff --git a/compose/config/validation.py b/compose/config/validation.py index 091014f6567..f2162a8740e 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -1,3 +1,6 @@ +from __future__ import absolute_import +from __future__ import unicode_literals + import json import logging import os diff --git a/compose/const.py b/compose/const.py index 9c607ca26d8..f1493cdd565 100644 --- a/compose/const.py +++ b/compose/const.py @@ -1,3 +1,6 @@ +from __future__ import absolute_import +from __future__ import unicode_literals + import os import sys diff --git a/compose/progress_stream.py b/compose/progress_stream.py index a6c8e0a2639..1f873d1d9f0 100644 --- a/compose/progress_stream.py +++ b/compose/progress_stream.py @@ -1,3 +1,6 @@ +from __future__ import absolute_import +from __future__ import unicode_literals + from compose import utils diff --git a/compose/utils.py b/compose/utils.py index 362629bc2b0..4a7df33467e 100644 --- a/compose/utils.py +++ b/compose/utils.py @@ -1,3 +1,6 @@ +from __future__ import absolute_import +from __future__ import unicode_literals + import codecs import hashlib import json diff --git a/script/travis/render-bintray-config.py b/script/travis/render-bintray-config.py index fc5d409a05c..c2b11ca3f2c 100755 --- a/script/travis/render-bintray-config.py +++ b/script/travis/render-bintray-config.py @@ -1,5 +1,7 @@ #!/usr/bin/env python from __future__ import print_function +from __future__ import absolute_import +from __future__ import unicode_literals import datetime import os.path diff --git a/script/versions.py b/script/versions.py index 513ca754c02..98f97ef3243 100755 --- a/script/versions.py +++ b/script/versions.py @@ -21,7 +21,9 @@ `default` would return `1.7.1` and `recent -n 3` would return `1.8.0-rc2 1.7.1 1.6.2` """ +from __future__ import absolute_import from __future__ import print_function +from __future__ import unicode_literals import argparse import itertools diff --git a/tests/__init__.py b/tests/__init__.py index d3cfb864913..1ac1b21cf74 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,3 +1,6 @@ +from __future__ import absolute_import +from __future__ import unicode_literals + import sys if sys.version_info >= (2, 7): diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 1885727a13b..6859c774148 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1,4 +1,5 @@ from __future__ import absolute_import +from __future__ import unicode_literals import os import shlex diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index d51830bb0ca..2cf5f556523 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -1,3 +1,4 @@ +from __future__ import absolute_import from __future__ import unicode_literals import random diff --git a/tests/integration/state_test.py b/tests/integration/state_test.py index d07dfa82a02..6e656c292c2 100644 --- a/tests/integration/state_test.py +++ b/tests/integration/state_test.py @@ -2,6 +2,7 @@ Integration tests which cover state convergence (aka smart recreate) performed by `docker-compose up`. """ +from __future__ import absolute_import from __future__ import unicode_literals import py diff --git a/tests/unit/cli/command_test.py b/tests/unit/cli/command_test.py index 0d4324e355c..1804467211a 100644 --- a/tests/unit/cli/command_test.py +++ b/tests/unit/cli/command_test.py @@ -1,4 +1,5 @@ from __future__ import absolute_import +from __future__ import unicode_literals import pytest from requests.exceptions import ConnectionError diff --git a/tests/unit/cli/main_test.py b/tests/unit/cli/main_test.py index db37ac1af03..ab23686698f 100644 --- a/tests/unit/cli/main_test.py +++ b/tests/unit/cli/main_test.py @@ -1,4 +1,5 @@ from __future__ import absolute_import +from __future__ import unicode_literals import logging diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 281e81d1e77..8cb5d9b26df 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1,5 +1,7 @@ # encoding: utf-8 +from __future__ import absolute_import from __future__ import print_function +from __future__ import unicode_literals import os import shutil diff --git a/tests/unit/config/sort_services_test.py b/tests/unit/config/sort_services_test.py index 8d0c3ae4080..c2ebbc67fa0 100644 --- a/tests/unit/config/sort_services_test.py +++ b/tests/unit/config/sort_services_test.py @@ -1,3 +1,6 @@ +from __future__ import absolute_import +from __future__ import unicode_literals + from compose.config.errors import DependencyError from compose.config.sort_services import sort_service_dicts from compose.config.types import VolumeFromSpec diff --git a/tests/unit/container_test.py b/tests/unit/container_test.py index 5f7bf1ea7e5..886911504c3 100644 --- a/tests/unit/container_test.py +++ b/tests/unit/container_test.py @@ -1,3 +1,4 @@ +from __future__ import absolute_import from __future__ import unicode_literals import docker diff --git a/tests/unit/interpolation_test.py b/tests/unit/interpolation_test.py index 7444884cb86..317982a9bf1 100644 --- a/tests/unit/interpolation_test.py +++ b/tests/unit/interpolation_test.py @@ -1,3 +1,6 @@ +from __future__ import absolute_import +from __future__ import unicode_literals + import unittest from compose.config.interpolation import BlankDefaultDict as bddict diff --git a/tests/unit/multiplexer_test.py b/tests/unit/multiplexer_test.py index d565d39d1b7..c56ece1bd05 100644 --- a/tests/unit/multiplexer_test.py +++ b/tests/unit/multiplexer_test.py @@ -1,3 +1,6 @@ +from __future__ import absolute_import +from __future__ import unicode_literals + import unittest from compose.cli.multiplexer import Multiplexer diff --git a/tests/unit/project_test.py b/tests/unit/project_test.py index 4bf5f463659..a182680b3e7 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -1,3 +1,4 @@ +from __future__ import absolute_import from __future__ import unicode_literals import docker diff --git a/tests/unit/utils_test.py b/tests/unit/utils_test.py index 15999dde98a..8ee37b07842 100644 --- a/tests/unit/utils_test.py +++ b/tests/unit/utils_test.py @@ -1,4 +1,5 @@ # encoding: utf-8 +from __future__ import absolute_import from __future__ import unicode_literals from compose import utils From bf1552da7982b22b874b1938af9bf80094c884e8 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 30 Oct 2015 16:50:31 -0400 Subject: [PATCH 1655/4072] Use json to encode invalid values in configuration errors so that the user sees a proper repr of the value. Signed-off-by: Daniel Nephin --- compose/__main__.py | 3 +++ compose/config/sort_services.py | 3 +++ compose/config/validation.py | 3 ++- compose/volume.py | 1 + script/travis/render-bintray-config.py | 2 +- tests/integration/volume_test.py | 1 + tests/unit/config/config_test.py | 4 +++- tests/unit/config/types_test.py | 3 +++ 8 files changed, 17 insertions(+), 3 deletions(-) diff --git a/compose/__main__.py b/compose/__main__.py index 199ba2ae9b4..27a7acbb8d1 100644 --- a/compose/__main__.py +++ b/compose/__main__.py @@ -1,3 +1,6 @@ +from __future__ import absolute_import +from __future__ import unicode_literals + from compose.cli.main import main main() diff --git a/compose/config/sort_services.py b/compose/config/sort_services.py index 5d9adab11fd..05552122742 100644 --- a/compose/config/sort_services.py +++ b/compose/config/sort_services.py @@ -1,3 +1,6 @@ +from __future__ import absolute_import +from __future__ import unicode_literals + from compose.config.errors import DependencyError diff --git a/compose/config/validation.py b/compose/config/validation.py index f2162a8740e..74dd461f347 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -254,7 +254,8 @@ def _parse_oneof_validator(error): ) return "{}contains {}, which is an invalid type, it should be {}".format( invalid_config_key, - context.instance, + # 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': diff --git a/compose/volume.py b/compose/volume.py index 055bd6abde4..fb8bd580980 100644 --- a/compose/volume.py +++ b/compose/volume.py @@ -1,3 +1,4 @@ +from __future__ import absolute_import from __future__ import unicode_literals diff --git a/script/travis/render-bintray-config.py b/script/travis/render-bintray-config.py index c2b11ca3f2c..b5364a0b6c5 100755 --- a/script/travis/render-bintray-config.py +++ b/script/travis/render-bintray-config.py @@ -1,6 +1,6 @@ #!/usr/bin/env python -from __future__ import print_function from __future__ import absolute_import +from __future__ import print_function from __future__ import unicode_literals import datetime diff --git a/tests/integration/volume_test.py b/tests/integration/volume_test.py index b60860400a3..8ae35378a18 100644 --- a/tests/integration/volume_test.py +++ b/tests/integration/volume_test.py @@ -1,3 +1,4 @@ +from __future__ import absolute_import from __future__ import unicode_literals from docker.errors import DockerException diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 8cb5d9b26df..abb891a2793 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -552,7 +552,9 @@ def test_config_extra_hosts_string_raises_validation_error(self): ) 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" + expected_error_msg = ( + "key 'extra_hosts' contains {\"somehost\": \"162.242.195.82\"}, " + "which is an invalid type, it should be a string") with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): config.load( diff --git a/tests/unit/config/types_test.py b/tests/unit/config/types_test.py index 4df665485e0..245b854ffc1 100644 --- a/tests/unit/config/types_test.py +++ b/tests/unit/config/types_test.py @@ -1,3 +1,6 @@ +from __future__ import absolute_import +from __future__ import unicode_literals + import pytest from compose.config.errors import ConfigurationError From ed5f7bd3949b289cebace496d9a40e67e05db466 Mon Sep 17 00:00:00 2001 From: Dimitar Bonev Date: Thu, 17 Dec 2015 23:23:00 +0200 Subject: [PATCH 1656/4072] log_driver and log_opt moved to logging key. Signed-off-by: Dimitar Bonev --- compose/config/config.py | 3 +- compose/config/fields_schema_v1.json | 11 +++++- compose/service.py | 25 +++++++++++-- docs/compose-file.md | 28 +++++++++----- tests/acceptance/cli_test.py | 37 +++++++++++++++++++ .../fixtures/logging-composefile/compose2.yml | 3 ++ .../logging-composefile/docker-compose.yml | 12 ++++++ tests/integration/service_test.py | 4 +- tests/unit/service_test.py | 3 +- 9 files changed, 105 insertions(+), 21 deletions(-) create mode 100644 tests/fixtures/logging-composefile/compose2.yml create mode 100644 tests/fixtures/logging-composefile/docker-compose.yml diff --git a/compose/config/config.py b/compose/config/config.py index 61b40589e7a..0012aefd7e2 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -51,8 +51,6 @@ 'ipc', 'labels', 'links', - 'log_driver', - 'log_opt', 'mac_address', 'mem_limit', 'memswap_limit', @@ -78,6 +76,7 @@ 'dockerfile', 'expose', 'external_links', + 'logging', ] DOCKER_VALID_URL_PREFIXES = ( diff --git a/compose/config/fields_schema_v1.json b/compose/config/fields_schema_v1.json index 6f0a3631988..790ace349c3 100644 --- a/compose/config/fields_schema_v1.json +++ b/compose/config/fields_schema_v1.json @@ -75,8 +75,15 @@ "labels": {"$ref": "#/definitions/list_or_dict"}, "links": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, - "log_driver": {"type": "string"}, - "log_opt": {"type": "object"}, + "logging": { + "type": "object", + + "properties": { + "driver": {"type": "string"}, + "options": {"type": "object"} + }, + "additionalProperties": false + }, "mac_address": {"type": "string"}, "mem_limit": {"type": ["number", "string"]}, diff --git a/compose/service.py b/compose/service.py index 24fa63942dc..366833dda77 100644 --- a/compose/service.py +++ b/compose/service.py @@ -510,6 +510,13 @@ def _get_volumes_from(self): return volumes_from + def get_logging_options(self): + logging_dict = self.options.get('logging', {}) + return { + 'log_driver': logging_dict.get('driver', ""), + 'log_opt': logging_dict.get('options', None) + } + def _get_container_create_options( self, override_options, @@ -523,6 +530,8 @@ def _get_container_create_options( for k in DOCKER_CONFIG_KEYS if k in self.options) container_options.update(override_options) + container_options.update(self.get_logging_options()) + if self.custom_container_name() and not one_off: container_options['name'] = self.custom_container_name() elif not container_options.get('name'): @@ -590,10 +599,9 @@ def _get_container_create_options( def _get_container_host_config(self, override_options, one_off=False): options = dict(self.options, **override_options) - log_config = LogConfig( - type=options.get('log_driver', ""), - config=options.get('log_opt', None) - ) + logging_dict = options.get('logging', None) + log_config = get_log_config(logging_dict) + return self.client.create_host_config( links=self._get_links(link_to_self=one_off), port_bindings=build_port_bindings(options.get('ports') or []), @@ -953,3 +961,12 @@ def build_ulimits(ulimit_config): ulimits.append(ulimit_dict) return ulimits + + +def get_log_config(logging_dict): + log_driver = logging_dict.get('driver', "") if logging_dict else "" + log_options = logging_dict.get('options', None) if logging_dict else None + return LogConfig( + type=log_driver, + config=log_options + ) diff --git a/docs/compose-file.md b/docs/compose-file.md index 29e0c647c3c..40a3cf02335 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -324,29 +324,37 @@ for this service, e.g: Environment variables will also be created - see the [environment variable reference](env.md) for details. -### log_driver +### logging -Specify 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/)). +Logging configuration for the service. This configuration replaces the previous +`log_driver` and `log_opt` keys. + + logging: + driver: log_driver + 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. - log_driver: "json-file" - log_driver: "syslog" - log_driver: "none" + 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. -### log_opt +Specify logging options for the logging driver with the ``options`` key, as with the ``--log-opt`` option for `docker run`. -Specify logging options with `log_opt` for the logging driver, as with the ``--log-opt`` option for `docker run`. Logging options are key value pairs. An example of `syslog` options: - log_driver: "syslog" - log_opt: + driver: "syslog" + options: syslog-address: "tcp://192.168.0.42:123" ### net diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 6859c774148..c5df30793af 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -716,6 +716,43 @@ def test_start_no_containers(self): result = self.dispatch(['start'], returncode=1) assert 'No containers to start' in result.stderr + def test_up_logging(self): + self.base_dir = 'tests/fixtures/logging-composefile' + self.dispatch(['up', '-d']) + simple = self.project.get_service('simple').containers()[0] + log_config = simple.get('HostConfig.LogConfig') + self.assertTrue(log_config) + self.assertEqual(log_config.get('Type'), 'none') + + another = self.project.get_service('another').containers()[0] + log_config = another.get('HostConfig.LogConfig') + self.assertTrue(log_config) + self.assertEqual(log_config.get('Type'), 'json-file') + self.assertEqual(log_config.get('Config')['max-size'], '10m') + + def test_up_logging_with_multiple_files(self): + self.base_dir = 'tests/fixtures/logging-composefile' + config_paths = [ + 'docker-compose.yml', + 'compose2.yml', + ] + self._project = get_project(self.base_dir, config_paths) + self.dispatch( + [ + '-f', config_paths[0], + '-f', config_paths[1], + 'up', '-d', + ], + None) + + containers = self.project.containers() + self.assertEqual(len(containers), 2) + + another = self.project.get_service('another').containers()[0] + log_config = another.get('HostConfig.LogConfig') + self.assertTrue(log_config) + self.assertEqual(log_config.get('Type'), 'none') + def test_pause_unpause(self): self.dispatch(['up', '-d'], None) service = self.project.get_service('simple') diff --git a/tests/fixtures/logging-composefile/compose2.yml b/tests/fixtures/logging-composefile/compose2.yml new file mode 100644 index 00000000000..ba5829691d7 --- /dev/null +++ b/tests/fixtures/logging-composefile/compose2.yml @@ -0,0 +1,3 @@ +another: + logging: + driver: "none" diff --git a/tests/fixtures/logging-composefile/docker-compose.yml b/tests/fixtures/logging-composefile/docker-compose.yml new file mode 100644 index 00000000000..877ee5e215c --- /dev/null +++ b/tests/fixtures/logging-composefile/docker-compose.yml @@ -0,0 +1,12 @@ +simple: + image: busybox:latest + command: top + logging: + driver: "none" +another: + image: busybox:latest + command: top + logging: + driver: "json-file" + options: + max-size: "10m" diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 4a0eaacb4fb..86bc4d9dbb1 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -888,7 +888,7 @@ def test_custom_container_name(self): self.assertNotEqual(one_off_container.name, 'my-web-container') def test_log_drive_invalid(self): - service = self.create_service('web', log_driver='xxx') + service = self.create_service('web', logging={'driver': 'xxx'}) expected_error_msg = "logger: no log driver named 'xxx' is registered" with self.assertRaisesRegexp(APIError, expected_error_msg): @@ -902,7 +902,7 @@ def test_log_drive_empty_default_jsonfile(self): self.assertFalse(log_config['Config']) def test_log_drive_none(self): - service = self.create_service('web', log_driver='none') + service = self.create_service('web', logging={'driver': 'none'}) log_config = create_and_start_container(service).log_config self.assertEqual('none', log_config['Type']) diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 87d6af59550..9cc35c7b2f5 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -156,7 +156,8 @@ def test_log_opt(self): self.mock_client.create_host_config.return_value = {} log_opt = {'syslog-address': 'tcp://192.168.0.42:123'} - service = Service(name='foo', image='foo', hostname='name', client=self.mock_client, log_driver='syslog', log_opt=log_opt) + logging = {'driver': 'syslog', 'options': log_opt} + service = Service(name='foo', image='foo', hostname='name', client=self.mock_client, logging=logging) service._get_container_create_options({'some': 'overrides'}, 1) self.assertTrue(self.mock_client.create_host_config.called) From 978e9cf38f057a8dc08fd74b969fd38873a27ba6 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 8 Jan 2016 13:10:05 +0000 Subject: [PATCH 1657/4072] Fix script/clean on systems where `find` requires a path argument Signed-off-by: Aanand Prasad --- script/clean | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/clean b/script/clean index 35faf4dba45..fb7ba3be2da 100755 --- a/script/clean +++ b/script/clean @@ -3,5 +3,5 @@ set -e find . -type f -name '*.pyc' -delete find . -name .coverage.* -delete -find -name __pycache__ -delete +find . -name __pycache__ -delete rm -rf docs/_site build dist docker-compose.egg-info From d1d3969661f549311bccde53703a2939402cf769 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 31 Aug 2015 14:31:20 -0400 Subject: [PATCH 1658/4072] Add docker-compose event Signed-off-by: Daniel Nephin --- compose/cli/main.py | 23 +++++++++++ compose/const.py | 1 + compose/project.py | 38 ++++++++++++++++- compose/utils.py | 4 ++ docs/reference/events.md | 34 ++++++++++++++++ docs/reference/index.md | 11 ++--- tests/acceptance/cli_test.py | 11 +++++ tests/unit/project_test.py | 79 ++++++++++++++++++++++++++++++++++++ 8 files changed, 195 insertions(+), 6 deletions(-) create mode 100644 docs/reference/events.md diff --git a/compose/cli/main.py b/compose/cli/main.py index 9ea9df71b89..d99816cfaf7 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -2,6 +2,7 @@ from __future__ import print_function from __future__ import unicode_literals +import json import logging import re import signal @@ -132,6 +133,7 @@ class TopLevelCommand(DocoptCommand): build Build or rebuild services config Validate and view the compose file create Create services + events Receive real time events from containers help Get help on a command kill Kill containers logs View output from containers @@ -244,6 +246,27 @@ def create(self, project, options): do_build=not options['--no-build'] ) + def events(self, project, options): + """ + Receive real time events from containers. + + Usage: events [options] [SERVICE...] + + Options: + --json Output events as a stream of json objects + """ + def format_event(event): + return ("{time}: service={service} event={event} " + "container={container} image={image}").format(**event) + + def json_format_event(event): + event['time'] = event['time'].isoformat() + return json.dumps(event) + + for event in project.events(): + formatter = json_format_event if options['--json'] else format_event + print(formatter(event)) + def help(self, project, options): """ Get help on a command. diff --git a/compose/const.py b/compose/const.py index f1493cdd565..84a5057a48a 100644 --- a/compose/const.py +++ b/compose/const.py @@ -6,6 +6,7 @@ DEFAULT_TIMEOUT = 10 HTTP_TIMEOUT = int(os.environ.get('COMPOSE_HTTP_TIMEOUT', 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' LABEL_ONE_OFF = 'com.docker.compose.oneoff' diff --git a/compose/project.py b/compose/project.py index 3801bbb9f87..b4eed7c8d77 100644 --- a/compose/project.py +++ b/compose/project.py @@ -1,6 +1,7 @@ from __future__ import absolute_import from __future__ import unicode_literals +import datetime import logging from functools import reduce @@ -11,6 +12,7 @@ from .config import ConfigurationError from .config.sort_services import get_service_name_from_net from .const import DEFAULT_TIMEOUT +from .const import IMAGE_EVENTS from .const import LABEL_ONE_OFF from .const import LABEL_PROJECT from .const import LABEL_SERVICE @@ -20,6 +22,7 @@ from .service import Net from .service import Service from .service import ServiceNet +from .utils import microseconds_from_time_nano from .volume import Volume @@ -267,7 +270,40 @@ def create(self, service_names=None, strategy=ConvergenceStrategy.changed, do_bu plans = self._get_convergence_plans(services, strategy) for service in services: - service.execute_convergence_plan(plans[service.name], do_build, detached=True, start=False) + service.execute_convergence_plan( + plans[service.name], + do_build, + detached=True, + start=False) + + def events(self): + def build_container_event(event, container): + time = datetime.datetime.fromtimestamp(event['time']) + time = time.replace( + microsecond=microseconds_from_time_nano(event['timeNano'])) + return { + 'service': container.service, + 'event': event['status'], + 'container': container.id, + 'image': event['from'], + 'time': time, + } + + service_names = set(self.service_names) + for event in self.client.events( + filters={'label': self.labels()}, + decode=True + ): + if event['status'] in IMAGE_EVENTS: + # We don't receive any image events because labels aren't applied + # to images + continue + + # TODO: get labels from the API v1.22 , see github issue 2618 + container = Container.from_id(self.client, event['id']) + if container.service not in service_names: + continue + yield build_container_event(event, container) def up(self, service_names=None, diff --git a/compose/utils.py b/compose/utils.py index 4a7df33467e..29d8a695d02 100644 --- a/compose/utils.py +++ b/compose/utils.py @@ -88,3 +88,7 @@ def json_hash(obj): h = hashlib.sha256() h.update(dump.encode('utf8')) return h.hexdigest() + + +def microseconds_from_time_nano(time_nano): + return int(time_nano % 1000000000 / 1000) diff --git a/docs/reference/events.md b/docs/reference/events.md new file mode 100644 index 00000000000..827258f2499 --- /dev/null +++ b/docs/reference/events.md @@ -0,0 +1,34 @@ + + +# 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/index.md b/docs/reference/index.md index b2fb5bcadcf..1635b60c735 100644 --- a/docs/reference/index.md +++ b/docs/reference/index.md @@ -14,19 +14,20 @@ parent = "smn_compose_ref" 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. * [build](build.md) +* [events](events.md) * [help](help.md) * [kill](kill.md) -* [ps](ps.md) -* [restart](restart.md) -* [run](run.md) -* [start](start.md) -* [up](up.md) * [logs](logs.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) +* [up](up.md) ## Where to go next diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 6859c774148..322d9b5a8e2 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1,6 +1,7 @@ from __future__ import absolute_import from __future__ import unicode_literals +import json import os import shlex import signal @@ -855,6 +856,16 @@ def get_port(number, index=None): self.assertEqual(get_port(3000, index=2), containers[1].get_local_port(3000)) self.assertEqual(get_port(3002), "") + def test_events_json(self): + events_proc = start_process(self.base_dir, ['events', '--json']) + self.dispatch(['up', '-d']) + wait_on_condition(ContainerCountCondition(self.project, 2)) + + 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['event'] for e in lines] == ['create', 'start', 'create', 'start'] + def test_env_file_relative_to_compose_file(self): config_path = os.path.abspath('tests/fixtures/env-file/docker-compose.yml') self.dispatch(['-f', config_path, 'up', '-d'], None) diff --git a/tests/unit/project_test.py b/tests/unit/project_test.py index a182680b3e7..a4b61b64d0e 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -1,6 +1,8 @@ from __future__ import absolute_import from __future__ import unicode_literals +import datetime + import docker from .. import mock @@ -197,6 +199,83 @@ def test_use_volumes_from_service_container(self): project.get_service('test')._get_volumes_from(), [container_ids[0] + ':rw']) + def test_events(self): + services = [Service(name='web'), Service(name='db')] + project = Project('test', services, self.mock_client) + self.mock_client.events.return_value = iter([ + { + 'status': 'create', + 'from': 'example/image', + 'id': 'abcde', + 'time': 1420092061, + 'timeNano': 14200920610000002000, + }, + { + 'status': 'attach', + 'from': 'example/image', + 'id': 'abcde', + 'time': 1420092061, + 'timeNano': 14200920610000003000, + }, + { + 'status': 'create', + 'from': 'example/other', + 'id': 'bdbdbd', + 'time': 1420092061, + 'timeNano': 14200920610000005000, + }, + { + 'status': 'create', + 'from': 'example/db', + 'id': 'ababa', + 'time': 1420092061, + 'timeNano': 14200920610000004000, + }, + ]) + + def dt_with_microseconds(dt, us): + return datetime.datetime.fromtimestamp(dt).replace(microsecond=us) + + def get_container(cid): + if cid == 'abcde': + labels = {LABEL_SERVICE: 'web'} + elif cid == 'ababa': + labels = {LABEL_SERVICE: 'db'} + else: + labels = {} + return {'Id': cid, 'Config': {'Labels': labels}} + + self.mock_client.inspect_container.side_effect = get_container + + events = project.events() + + events_list = list(events) + # Assert the return value is a generator + assert not list(events) + assert events_list == [ + { + 'service': 'web', + 'event': 'create', + 'container': 'abcde', + 'image': 'example/image', + 'time': dt_with_microseconds(1420092061, 2), + }, + { + 'service': 'web', + 'event': 'attach', + 'container': 'abcde', + 'image': 'example/image', + 'time': dt_with_microseconds(1420092061, 3), + }, + { + 'service': 'db', + 'event': 'create', + 'container': 'ababa', + 'image': 'example/db', + 'time': dt_with_microseconds(1420092061, 4), + }, + ] + def test_net_unset(self): project = Project.from_config('test', Config(None, [ { From 21aae13e77b43ce1cd3a07c660bb411f15993c27 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 8 Jan 2016 13:21:45 -0800 Subject: [PATCH 1659/4072] Move logging config changes to v2 spec Reorganize JSON schemas Update fixtures Update service validation function Signed-off-by: Joffrey F --- compose/config/config.py | 2 +- compose/config/fields_schema_v1.json | 161 +-------------- compose/config/fields_schema_v2.json | 2 +- compose/config/service_schema.json | 30 --- compose/config/service_schema_v1.json | 175 +++++++++++++++++ compose/config/service_schema_v2.json | 184 ++++++++++++++++++ compose/config/validation.py | 4 +- docker-compose.spec | 9 +- .../fixtures/logging-composefile/compose2.yml | 8 +- .../logging-composefile/docker-compose.yml | 26 +-- 10 files changed, 391 insertions(+), 210 deletions(-) delete mode 100644 compose/config/service_schema.json create mode 100644 compose/config/service_schema_v1.json create mode 100644 compose/config/service_schema_v2.json diff --git a/compose/config/config.py b/compose/config/config.py index 0012aefd7e2..0e7942595ae 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -287,7 +287,7 @@ def build_service(filename, service_name, service_dict): service_dict = process_service(resolver.run()) # TODO: move to validate_service() - validate_against_service_schema(service_dict, service_config.name) + validate_against_service_schema(service_dict, service_config.name, version) validate_paths(service_dict) service_dict = finalize_service(service_config._replace(config=service_dict)) diff --git a/compose/config/fields_schema_v1.json b/compose/config/fields_schema_v1.json index 790ace349c3..8f6a8c0ad26 100644 --- a/compose/config/fields_schema_v1.json +++ b/compose/config/fields_schema_v1.json @@ -6,165 +6,8 @@ "patternProperties": { "^[a-zA-Z0-9._-]+$": { - "$ref": "#/definitions/service" + "$ref": "service_schema_v1.json#/definitions/service" } }, - "additionalProperties": false, - - "definitions": { - "service": { - "id": "#/definitions/service", - "type": "object", - - "properties": { - "build": {"type": "string"}, - "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"}, - "devices": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, - "dns": {"$ref": "#/definitions/string_or_list"}, - "dns_search": {"$ref": "#/definitions/string_or_list"}, - "dockerfile": {"type": "string"}, - "domainname": {"type": "string"}, - "entrypoint": {"$ref": "#/definitions/string_or_list"}, - "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 - } - ] - }, - - "extra_hosts": {"$ref": "#/definitions/list_or_dict"}, - "external_links": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, - "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"]}, - "net": {"type": "string"}, - "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}, - "stdin_open": {"type": "boolean"}, - "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 - }, - - "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", "boolean", "null"], - "format": "bool-value-in-mapping" - } - }, - "additionalProperties": false - }, - {"type": "array", "items": {"type": "string"}, "uniqueItems": true} - ] - } - } + "additionalProperties": false } diff --git a/compose/config/fields_schema_v2.json b/compose/config/fields_schema_v2.json index 49cab367084..22ff839fb15 100644 --- a/compose/config/fields_schema_v2.json +++ b/compose/config/fields_schema_v2.json @@ -12,7 +12,7 @@ "type": "object", "patternProperties": { "^[a-zA-Z0-9._-]+$": { - "$ref": "fields_schema_v1.json#/definitions/service" + "$ref": "service_schema_v2.json#/definitions/service" } }, "additionalProperties": false diff --git a/compose/config/service_schema.json b/compose/config/service_schema.json deleted file mode 100644 index 91a1e005053..00000000000 --- a/compose/config/service_schema.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - "id": "service_schema.json", - - "type": "object", - - "allOf": [ - {"$ref": "fields_schema_v1.json#/definitions/service"}, - {"$ref": "#/definitions/constraints"} - ], - - "definitions": { - "constraints": { - "id": "#/definitions/constraints", - "anyOf": [ - { - "required": ["build"], - "not": {"required": ["image"]} - }, - { - "required": ["image"], - "not": {"anyOf": [ - {"required": ["build"]}, - {"required": ["dockerfile"]} - ]} - } - ] - } - } -} diff --git a/compose/config/service_schema_v1.json b/compose/config/service_schema_v1.json new file mode 100644 index 00000000000..d51c7f731b1 --- /dev/null +++ b/compose/config/service_schema_v1.json @@ -0,0 +1,175 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "id": "service_schema_v1.json", + + "type": "object", + + "allOf": [ + {"$ref": "#/definitions/service"}, + {"$ref": "#/definitions/constraints"} + ], + + "definitions": { + "service": { + "id": "#/definitions/service", + "type": "object", + + "properties": { + "build": {"type": "string"}, + "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"}, + "devices": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "dns": {"$ref": "#/definitions/string_or_list"}, + "dns_search": {"$ref": "#/definitions/string_or_list"}, + "dockerfile": {"type": "string"}, + "domainname": {"type": "string"}, + "entrypoint": {"$ref": "#/definitions/string_or_list"}, + "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 + } + ] + }, + + "extra_hosts": {"$ref": "#/definitions/list_or_dict"}, + "external_links": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "hostname": {"type": "string"}, + "image": {"type": "string"}, + "ipc": {"type": "string"}, + "labels": {"$ref": "#/definitions/list_or_dict"}, + "links": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "log_driver": {"type": "string"}, + "log_opt": {"type": "object"}, + "mac_address": {"type": "string"}, + "mem_limit": {"type": ["number", "string"]}, + "memswap_limit": {"type": ["number", "string"]}, + "net": {"type": "string"}, + "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}, + "stdin_open": {"type": "boolean"}, + "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 + }, + + "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", "boolean", "null"], + "format": "bool-value-in-mapping" + } + }, + "additionalProperties": false + }, + {"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"]} + ]} + } + ] + } + } +} diff --git a/compose/config/service_schema_v2.json b/compose/config/service_schema_v2.json new file mode 100644 index 00000000000..a64b3bdc0c1 --- /dev/null +++ b/compose/config/service_schema_v2.json @@ -0,0 +1,184 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "id": "service_schema_v2.json", + + "type": "object", + + "allOf": [ + {"$ref": "#/definitions/service"}, + {"$ref": "#/definitions/constraints"} + ], + + "definitions": { + "service": { + "id": "#/definitions/service", + "type": "object", + + "properties": { + "build": {"type": "string"}, + "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"}, + "devices": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "dns": {"$ref": "#/definitions/string_or_list"}, + "dns_search": {"$ref": "#/definitions/string_or_list"}, + "dockerfile": {"type": "string"}, + "domainname": {"type": "string"}, + "entrypoint": {"$ref": "#/definitions/string_or_list"}, + "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 + } + ] + }, + + "extra_hosts": {"$ref": "#/definitions/list_or_dict"}, + "external_links": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "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"]}, + "net": {"type": "string"}, + "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}, + "stdin_open": {"type": "boolean"}, + "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 + }, + + "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", "boolean", "null"], + "format": "bool-value-in-mapping" + } + }, + "additionalProperties": false + }, + {"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"]} + ]} + } + ] + } + } +} diff --git a/compose/config/validation.py b/compose/config/validation.py index 74dd461f347..fea9a22b88a 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -298,10 +298,10 @@ def validate_against_fields_schema(config, filename, version): filename=filename) -def validate_against_service_schema(config, service_name): +def validate_against_service_schema(config, service_name, version): _validate_against_schema( config, - "service_schema.json", + "service_schema_v{0}.json".format(version), format_checker=["ports"], service_name=service_name) diff --git a/docker-compose.spec b/docker-compose.spec index c760d7b4ce5..f7f2059fd32 100644 --- a/docker-compose.spec +++ b/docker-compose.spec @@ -28,8 +28,13 @@ exe = EXE(pyz, 'DATA' ), ( - 'compose/config/service_schema.json', - 'compose/config/service_schema.json', + 'compose/config/service_schema_v1.json', + 'compose/config/service_schema_v1.json', + 'DATA' + ), + ( + 'compose/config/service_schema_v2.json', + 'compose/config/service_schema_v2.json', 'DATA' ), ( diff --git a/tests/fixtures/logging-composefile/compose2.yml b/tests/fixtures/logging-composefile/compose2.yml index ba5829691d7..69889761be7 100644 --- a/tests/fixtures/logging-composefile/compose2.yml +++ b/tests/fixtures/logging-composefile/compose2.yml @@ -1,3 +1,5 @@ -another: - logging: - driver: "none" +version: 2 +services: + another: + logging: + driver: "none" diff --git a/tests/fixtures/logging-composefile/docker-compose.yml b/tests/fixtures/logging-composefile/docker-compose.yml index 877ee5e215c..0a73030ad6d 100644 --- a/tests/fixtures/logging-composefile/docker-compose.yml +++ b/tests/fixtures/logging-composefile/docker-compose.yml @@ -1,12 +1,14 @@ -simple: - image: busybox:latest - command: top - logging: - driver: "none" -another: - image: busybox:latest - command: top - logging: - driver: "json-file" - options: - max-size: "10m" +version: 2 +services: + simple: + image: busybox:latest + command: top + logging: + driver: "none" + another: + image: busybox:latest + command: top + logging: + driver: "json-file" + options: + max-size: "10m" From 46585fb8e17a4b6cb5763f055ef00d0aa3d952c9 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 8 Jan 2016 14:37:07 -0800 Subject: [PATCH 1660/4072] Support legacy logging options format Additional test for legacy compose file. Signed-off-by: Joffrey F --- compose/config/config.py | 10 ++++++++++ tests/acceptance/cli_test.py | 14 ++++++++++++++ .../logging-composefile-legacy/docker-compose.yml | 10 ++++++++++ 3 files changed, 34 insertions(+) create mode 100644 tests/fixtures/logging-composefile-legacy/docker-compose.yml diff --git a/compose/config/config.py b/compose/config/config.py index 0e7942595ae..c59f384d65b 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -504,6 +504,16 @@ def finalize_service(service_config): if 'restart' in service_dict: service_dict['restart'] = parse_restart_spec(service_dict['restart']) + if 'log_driver' in service_dict or 'log_opt' in service_dict: + if 'logging' not in service_dict: + service_dict['logging'] = {} + if 'log_driver' in service_dict: + service_dict['logging']['driver'] = service_dict['log_driver'] + del service_dict['log_driver'] + if 'log_opt' in service_dict: + service_dict['logging']['options'] = service_dict['log_opt'] + del service_dict['log_opt'] + return service_dict diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index c5df30793af..8abdf785408 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -730,6 +730,20 @@ def test_up_logging(self): self.assertEqual(log_config.get('Type'), 'json-file') self.assertEqual(log_config.get('Config')['max-size'], '10m') + def test_up_logging_legacy(self): + self.base_dir = 'tests/fixtures/logging-composefile-legacy' + self.dispatch(['up', '-d']) + simple = self.project.get_service('simple').containers()[0] + log_config = simple.get('HostConfig.LogConfig') + self.assertTrue(log_config) + self.assertEqual(log_config.get('Type'), 'none') + + another = self.project.get_service('another').containers()[0] + log_config = another.get('HostConfig.LogConfig') + self.assertTrue(log_config) + self.assertEqual(log_config.get('Type'), 'json-file') + self.assertEqual(log_config.get('Config')['max-size'], '10m') + def test_up_logging_with_multiple_files(self): self.base_dir = 'tests/fixtures/logging-composefile' config_paths = [ diff --git a/tests/fixtures/logging-composefile-legacy/docker-compose.yml b/tests/fixtures/logging-composefile-legacy/docker-compose.yml new file mode 100644 index 00000000000..ee99410790d --- /dev/null +++ b/tests/fixtures/logging-composefile-legacy/docker-compose.yml @@ -0,0 +1,10 @@ +simple: + image: busybox:latest + command: top + log_driver: "none" +another: + image: busybox:latest + command: top + log_driver: "json-file" + log_opt: + max-size: "10m" From d3cd038b845d9708c42b9281253be342e2fe97d0 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 8 Jan 2016 16:54:35 -0500 Subject: [PATCH 1661/4072] Update event field names to match the new API fields. Signed-off-by: Daniel Nephin --- compose/cli/main.py | 7 ++++-- compose/project.py | 12 ++++++---- tests/acceptance/cli_test.py | 22 +++++++++++++++++- tests/unit/project_test.py | 43 ++++++++++++++++++++++++++---------- 4 files changed, 65 insertions(+), 19 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index d99816cfaf7..d10b9582385 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -256,8 +256,10 @@ def events(self, project, options): --json Output events as a stream of json objects """ def format_event(event): - return ("{time}: service={service} event={event} " - "container={container} image={image}").format(**event) + attributes = ["%s=%s" % item for item in event['attributes'].items()] + return ("{time} {type} {action} {id} ({attrs})").format( + attrs=", ".join(sorted(attributes)), + **event) def json_format_event(event): event['time'] = event['time'].isoformat() @@ -266,6 +268,7 @@ def json_format_event(event): for event in project.events(): formatter = json_format_event if options['--json'] else format_event print(formatter(event)) + sys.stdout.flush() def help(self, project, options): """ diff --git a/compose/project.py b/compose/project.py index b4eed7c8d77..50f991be7fa 100644 --- a/compose/project.py +++ b/compose/project.py @@ -282,11 +282,15 @@ def build_container_event(event, container): time = time.replace( microsecond=microseconds_from_time_nano(event['timeNano'])) return { - 'service': container.service, - 'event': event['status'], - 'container': container.id, - 'image': event['from'], 'time': time, + 'type': 'container', + 'action': event['status'], + 'id': container.id, + 'service': container.service, + 'attributes': { + 'name': container.name, + 'image': event['from'], + } } service_names = set(self.service_names) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 322d9b5a8e2..db3e1b438c5 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1,6 +1,7 @@ from __future__ import absolute_import from __future__ import unicode_literals +import datetime import json import os import shlex @@ -864,7 +865,26 @@ def test_events_json(self): 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['event'] for e in lines] == ['create', 'start', 'create', 'start'] + assert [e['action'] for e in lines] == ['create', 'start', 'create', 'start'] + + def test_events_human_readable(self): + events_proc = start_process(self.base_dir, ['events']) + self.dispatch(['up', '-d', 'simple']) + wait_on_condition(ContainerCountCondition(self.project, 1)) + + os.kill(events_proc.pid, signal.SIGINT) + result = wait_on_process(events_proc, returncode=1) + lines = result.stdout.rstrip().split('\n') + assert len(lines) == 2 + + container, = self.project.containers() + expected_template = ( + ' container {} {} (image=busybox:latest, ' + 'name=simplecomposefile_simple_1)') + + 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()) def test_env_file_relative_to_compose_file(self): config_path = os.path.abspath('tests/fixtures/env-file/docker-compose.yml') diff --git a/tests/unit/project_test.py b/tests/unit/project_test.py index a4b61b64d0e..c8590a1f9a5 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -238,12 +238,19 @@ def dt_with_microseconds(dt, us): def get_container(cid): if cid == 'abcde': - labels = {LABEL_SERVICE: 'web'} + name = 'web' + labels = {LABEL_SERVICE: name} elif cid == 'ababa': - labels = {LABEL_SERVICE: 'db'} + name = 'db' + labels = {LABEL_SERVICE: name} else: labels = {} - return {'Id': cid, 'Config': {'Labels': labels}} + name = '' + return { + 'Id': cid, + 'Config': {'Labels': labels}, + 'Name': '/project_%s_1' % name, + } self.mock_client.inspect_container.side_effect = get_container @@ -254,24 +261,36 @@ def get_container(cid): assert not list(events) assert events_list == [ { + 'type': 'container', 'service': 'web', - 'event': 'create', - 'container': 'abcde', - 'image': 'example/image', + 'action': 'create', + 'id': 'abcde', + 'attributes': { + 'name': 'project_web_1', + 'image': 'example/image', + }, 'time': dt_with_microseconds(1420092061, 2), }, { + 'type': 'container', 'service': 'web', - 'event': 'attach', - 'container': 'abcde', - 'image': 'example/image', + 'action': 'attach', + 'id': 'abcde', + 'attributes': { + 'name': 'project_web_1', + 'image': 'example/image', + }, 'time': dt_with_microseconds(1420092061, 3), }, { + 'type': 'container', 'service': 'db', - 'event': 'create', - 'container': 'ababa', - 'image': 'example/db', + 'action': 'create', + 'id': 'ababa', + 'attributes': { + 'name': 'project_db_1', + 'image': 'example/db', + }, 'time': dt_with_microseconds(1420092061, 4), }, ] From f7a7e68df63dad82d7bc382cc8832510856c5296 Mon Sep 17 00:00:00 2001 From: Thomas Hourlier Date: Mon, 11 Jan 2016 09:48:52 -0800 Subject: [PATCH 1662/4072] Mount $HOME in /root to share docker config. Fixes #2630 Signed-off-by: Thomas Hourlier --- script/run.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/run.sh b/script/run.sh index 514a538951e..087c2692f7c 100755 --- a/script/run.sh +++ b/script/run.sh @@ -40,7 +40,7 @@ if [ -n "$compose_dir" ]; then VOLUMES="$VOLUMES -v $compose_dir:$compose_dir" fi if [ -n "$HOME" ]; then - VOLUMES="$VOLUMES -v $HOME:$HOME" + VOLUMES="$VOLUMES -v $HOME:$HOME -v $HOME:/root" # mount $HOME in /root to share docker.config fi # Only allocate tty if we detect one From c32991a8d491f8af1e4f3502bdb7bc8131922143 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 11 Jan 2016 15:39:59 -0800 Subject: [PATCH 1663/4072] Remove superfluous service code Signed-off-by: Joffrey F --- compose/service.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/compose/service.py b/compose/service.py index 366833dda77..9fd679f8d16 100644 --- a/compose/service.py +++ b/compose/service.py @@ -510,13 +510,6 @@ def _get_volumes_from(self): return volumes_from - def get_logging_options(self): - logging_dict = self.options.get('logging', {}) - return { - 'log_driver': logging_dict.get('driver', ""), - 'log_opt': logging_dict.get('options', None) - } - def _get_container_create_options( self, override_options, @@ -530,8 +523,6 @@ def _get_container_create_options( for k in DOCKER_CONFIG_KEYS if k in self.options) container_options.update(override_options) - container_options.update(self.get_logging_options()) - if self.custom_container_name() and not one_off: container_options['name'] = self.custom_container_name() elif not container_options.get('name'): From 46a474ecd9132fcf84eb5568df4245bd13e3ca26 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 11 Jan 2016 15:53:28 -0800 Subject: [PATCH 1664/4072] Move v1-v2 config normalization to separate function. Signed-off-by: Joffrey F --- compose/config/config.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/compose/config/config.py b/compose/config/config.py index c59f384d65b..2ca5fa22cd5 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -504,6 +504,10 @@ def finalize_service(service_config): if 'restart' in service_dict: service_dict['restart'] = parse_restart_spec(service_dict['restart']) + return normalize_v1_service_format(service_dict) + + +def normalize_v1_service_format(service_dict): if 'log_driver' in service_dict or 'log_opt' in service_dict: if 'logging' not in service_dict: service_dict['logging'] = {} From ca634649bbdb92cd7c52a98ee693d0c55a1e153b Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 11 Jan 2016 16:25:19 -0800 Subject: [PATCH 1665/4072] Changed logging override test into integration test Signed-off-by: Joffrey F --- tests/acceptance/cli_test.py | 23 -------- .../fixtures/logging-composefile/compose2.yml | 5 -- tests/integration/project_test.py | 53 +++++++++++++++++++ 3 files changed, 53 insertions(+), 28 deletions(-) delete mode 100644 tests/fixtures/logging-composefile/compose2.yml diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 8abdf785408..caadb62fc45 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -744,29 +744,6 @@ def test_up_logging_legacy(self): self.assertEqual(log_config.get('Type'), 'json-file') self.assertEqual(log_config.get('Config')['max-size'], '10m') - def test_up_logging_with_multiple_files(self): - self.base_dir = 'tests/fixtures/logging-composefile' - config_paths = [ - 'docker-compose.yml', - 'compose2.yml', - ] - self._project = get_project(self.base_dir, config_paths) - self.dispatch( - [ - '-f', config_paths[0], - '-f', config_paths[1], - 'up', '-d', - ], - None) - - containers = self.project.containers() - self.assertEqual(len(containers), 2) - - another = self.project.get_service('another').containers()[0] - log_config = another.get('HostConfig.LogConfig') - self.assertTrue(log_config) - self.assertEqual(log_config.get('Type'), 'none') - def test_pause_unpause(self): self.dispatch(['up', '-d'], None) service = self.project.get_service('simple') diff --git a/tests/fixtures/logging-composefile/compose2.yml b/tests/fixtures/logging-composefile/compose2.yml deleted file mode 100644 index 69889761be7..00000000000 --- a/tests/fixtures/logging-composefile/compose2.yml +++ /dev/null @@ -1,5 +0,0 @@ -version: 2 -services: - another: - logging: - driver: "none" diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 2cf5f556523..5a1444b67ac 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -3,6 +3,8 @@ import random +import py + from .testcases import DockerClientTestCase from compose.cli.docker_client import docker_client from compose.config import config @@ -534,6 +536,57 @@ def test_project_up_volumes(self): self.assertEqual(volume_data['Name'], full_vol_name) self.assertEqual(volume_data['Driver'], 'local') + def test_project_up_logging_with_multiple_files(self): + base_file = config.ConfigFile( + 'base.yml', + { + 'version': 2, + 'services': { + 'simple': {'image': 'busybox:latest', 'command': 'top'}, + 'another': { + 'image': 'busybox:latest', + 'command': 'top', + 'logging': { + 'driver': "json-file", + 'options': { + 'max-size': "10m" + } + } + } + } + + }) + override_file = config.ConfigFile( + 'override.yml', + { + 'version': 2, + 'services': { + 'another': { + 'logging': { + 'driver': "none" + } + } + } + + }) + details = config.ConfigDetails('.', [base_file, override_file]) + + tmpdir = py.test.ensuretemp('logging_test') + self.addCleanup(tmpdir.remove) + with tmpdir.as_cwd(): + 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), 2) + + another = project.get_service('another').containers()[0] + log_config = another.get('HostConfig.LogConfig') + self.assertTrue(log_config) + self.assertEqual(log_config.get('Type'), 'none') + def test_initialize_volumes(self): vol_name = '{0:x}'.format(random.getrandbits(32)) full_vol_name = 'composetest_{0}'.format(vol_name) From 12b5405420ebfd2a3b260f3a4421bfadd52e8a97 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 12 Jan 2016 13:27:18 -0500 Subject: [PATCH 1666/4072] Fix pre-commit on master. Signed-off-by: Daniel Nephin --- compose/cli/signals.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/compose/cli/signals.py b/compose/cli/signals.py index 38474ba44a4..68a0598e128 100644 --- a/compose/cli/signals.py +++ b/compose/cli/signals.py @@ -1,3 +1,6 @@ +from __future__ import absolute_import +from __future__ import unicode_literals + import signal From 6c205a8e017569f3a18ae697edf6310f221860f6 Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Tue, 12 Jan 2016 10:51:04 -0800 Subject: [PATCH 1667/4072] Add bash completion for `docker-compose events` 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 c18e4f6df60..2b1d689c7e6 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -133,6 +133,24 @@ _docker_compose_docker_compose() { } +_docker_compose_events() { + case "$prev" in + --json) + return + ;; + esac + + case "$cur" in + -*) + COMPREPLY=( $( compgen -W "--help --json" -- "$cur" ) ) + ;; + *) + __docker_compose_services_all + ;; + esac +} + + _docker_compose_help() { COMPREPLY=( $( compgen -W "${commands[*]}" -- "$cur" ) ) } @@ -379,6 +397,7 @@ _docker_compose() { local commands=( build config + events help kill logs From ed4db542d6f6d4ec062bb29d8c99a6ea5c9523d7 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 12 Jan 2016 14:02:30 -0500 Subject: [PATCH 1668/4072] Fix pep8 errors from the new pep8 release. Signed-off-by: Daniel Nephin --- compose/project.py | 4 ++-- compose/service.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/compose/project.py b/compose/project.py index 50f991be7fa..d41b1f52a84 100644 --- a/compose/project.py +++ b/compose/project.py @@ -344,8 +344,8 @@ def _get_convergence_plans(self, services, strategy): updated_dependencies = [ name for name in service.get_dependency_names() - if name in plans - and plans[name].action in ('recreate', 'create') + if name in plans and + plans[name].action in ('recreate', 'create') ] if updated_dependencies and strategy.allows_recreate: diff --git a/compose/service.py b/compose/service.py index 693e1980b8b..bd8143e7757 100644 --- a/compose/service.py +++ b/compose/service.py @@ -535,9 +535,9 @@ def _get_container_create_options( # unqualified hostname and a domainname unless domainname # was also given explicitly. This matches the behavior of # the official Docker CLI in that scenario. - if ('hostname' in container_options - and 'domainname' not in container_options - and '.' in container_options['hostname']): + if ('hostname' in container_options and + 'domainname' not in container_options and + '.' in container_options['hostname']): parts = container_options['hostname'].partition('.') container_options['hostname'] = parts[0] container_options['domainname'] = parts[2] From e98ab0e534a0a0b9ec86f18f0867b05188fc8b02 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 12 Jan 2016 11:24:06 -0500 Subject: [PATCH 1669/4072] Allow both image and build together. Signed-off-by: Daniel Nephin --- compose/config/config.py | 25 +++-- compose/config/service_schema_v2.json | 13 +-- compose/config/validation.py | 4 +- compose/service.py | 12 +-- tests/integration/service_test.py | 16 ++- tests/unit/config/config_test.py | 144 ++++++++++++++------------ tests/unit/service_test.py | 9 ++ 7 files changed, 121 insertions(+), 102 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index db2f67ea166..918946b3965 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -305,7 +305,8 @@ def merge_services(base, override): return { name: merge_service_dicts_from_files( base.get(name, {}), - override.get(name, {})) + override.get(name, {}), + version) for name in all_service_names } @@ -397,7 +398,10 @@ def resolve_extends(self, extended_config_path, service_dict, service_name): service_name, ) - return merge_service_dicts(other_service_dict, self.service_config.config) + return merge_service_dicts( + other_service_dict, + self.service_config.config, + self.version) def get_extended_config_path(self, extends_options): """Service we are extending either has a value for 'file' set, which we @@ -521,12 +525,12 @@ def normalize_v1_service_format(service_dict): return service_dict -def merge_service_dicts_from_files(base, override): +def merge_service_dicts_from_files(base, override, version): """When merging services from multiple files we need to merge the `extends` field. This is not handled by `merge_service_dicts()` which is used to perform the `extends`. """ - new_service = merge_service_dicts(base, override) + new_service = merge_service_dicts(base, override, version) if 'extends' in override: new_service['extends'] = override['extends'] elif 'extends' in base: @@ -534,7 +538,7 @@ def merge_service_dicts_from_files(base, override): return new_service -def merge_service_dicts(base, override): +def merge_service_dicts(base, override, version): d = {} def merge_field(field, merge_func, default=None): @@ -545,7 +549,6 @@ def merge_field(field, merge_func, default=None): merge_field('environment', merge_environment) merge_field('labels', merge_labels) - merge_image_or_build(base, override, d) for field in ['volumes', 'devices']: merge_field(field, merge_path_mappings) @@ -556,15 +559,19 @@ def merge_field(field, merge_func, default=None): for field in ['dns', 'dns_search', 'env_file']: merge_field(field, merge_list_or_string) - already_merged_keys = set(d) | {'image', 'build'} - for field in set(ALLOWED_KEYS) - already_merged_keys: + for field in set(ALLOWED_KEYS) - set(d): if field in base or field in override: d[field] = override.get(field, base.get(field)) + if version == 1: + legacy_v1_merge_image_or_build(d, base, override) + return d -def merge_image_or_build(base, override, output): +def legacy_v1_merge_image_or_build(output, base, override): + output.pop('image', None) + output.pop('build', None) if 'image' in override: output['image'] = override['image'] elif 'build' in override: diff --git a/compose/config/service_schema_v2.json b/compose/config/service_schema_v2.json index a64b3bdc0c1..d1d0854f3ee 100644 --- a/compose/config/service_schema_v2.json +++ b/compose/config/service_schema_v2.json @@ -167,17 +167,8 @@ "constraints": { "id": "#/definitions/constraints", "anyOf": [ - { - "required": ["build"], - "not": {"required": ["image"]} - }, - { - "required": ["image"], - "not": {"anyOf": [ - {"required": ["build"]}, - {"required": ["dockerfile"]} - ]} - } + {"required": ["build"]}, + {"required": ["image"]} ] } } diff --git a/compose/config/validation.py b/compose/config/validation.py index fea9a22b88a..e7006d5a4be 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -151,6 +151,7 @@ def handle_error_for_schema_with_id(error, service_name): VALID_NAME_CHARS) if schema_id == '#/definitions/constraints': + # TODO: only applies to v1 if 'image' in error.instance and 'build' in error.instance: return ( "Service '{}' has both an image and build path specified. " @@ -159,7 +160,8 @@ def handle_error_for_schema_with_id(error, service_name): if 'image' not in error.instance and 'build' not in error.instance: return ( "Service '{}' has neither an image nor a build path " - "specified. Exactly one must be provided.".format(service_name)) + "specified. At least one must be provided.".format(service_name)) + # TODO: only applies to v1 if 'image' in error.instance and 'dockerfile' in error.instance: return ( "Service '{}' has both an image and alternate Dockerfile. " diff --git a/compose/service.py b/compose/service.py index bd8143e7757..d5c36f1ad68 100644 --- a/compose/service.py +++ b/compose/service.py @@ -275,10 +275,7 @@ def image(self): @property def image_name(self): - if self.can_be_built(): - return self.full_name - else: - return self.options['image'] + return self.options.get('image', '{s.project}_{s.name}'.format(s=self)) def convergence_plan(self, strategy=ConvergenceStrategy.changed): containers = self.containers(stopped=True) @@ -665,13 +662,6 @@ def build(self, no_cache=False, pull=False, force_rm=False): def can_be_built(self): return 'build' in self.options - @property - def full_name(self): - """ - The tag to give to images built for this service. - """ - return '%s_%s' % (self.project, self.name) - def labels(self, one_off=False): return [ '{0}={1}'.format(LABEL_PROJECT, self.project), diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 86bc4d9dbb1..539be5a9b3a 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -491,7 +491,7 @@ def test_build(self): f.write("FROM busybox\n") self.create_service('web', build=base_dir).build() - self.assertEqual(len(self.client.images(name='composetest_web')), 1) + assert self.client.inspect_image('composetest_web') def test_build_non_ascii_filename(self): base_dir = tempfile.mkdtemp() @@ -504,7 +504,19 @@ def test_build_non_ascii_filename(self): f.write("hello world\n") self.create_service('web', build=text_type(base_dir)).build() - self.assertEqual(len(self.client.images(name='composetest_web')), 1) + assert self.client.inspect_image('composetest_web') + + def test_build_with_image_name(self): + base_dir = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, base_dir) + + with open(os.path.join(base_dir, 'Dockerfile'), 'w') as f: + f.write("FROM busybox\n") + + image_name = 'examples/composetest:latest' + self.addCleanup(self.client.remove_image, image_name) + self.create_service('web', build=base_dir, image=image_name).build() + assert self.client.inspect_image(image_name) def test_build_with_git_url(self): build_url = "https://github.com/dnephin/docker-build-from-url.git" diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 1c2876f5a14..a59d1d34ac6 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -19,6 +19,9 @@ from tests import mock from tests import unittest +DEFAULT_VERSION = 2 +V1 = 1 + def make_service_dict(name, service_dict, working_dir, filename=None): """ @@ -238,8 +241,7 @@ def test_config_integer_service_name_raise_validation_error_v2(self): ) ) - @pytest.mark.xfail(IS_WINDOWS_PLATFORM, reason='paths use slash') - def test_load_with_multiple_files(self): + def test_load_with_multiple_files_v1(self): base_file = config.ConfigFile( 'base.yaml', { @@ -265,7 +267,7 @@ def test_load_with_multiple_files(self): expected = [ { 'name': 'web', - 'build': '/', + 'build': os.path.abspath('/'), 'links': ['db'], 'volumes': [VolumeSpec.parse('/home/user/project:/code')], }, @@ -274,7 +276,7 @@ def test_load_with_multiple_files(self): 'image': 'example/db', }, ] - self.assertEqual(service_sort(service_dicts), service_sort(expected)) + assert service_sort(service_dicts) == service_sort(expected) def test_load_with_multiple_files_and_empty_override(self): base_file = config.ConfigFile( @@ -428,6 +430,7 @@ def test_load_with_multiple_files_v2(self): { 'name': 'web', 'build': os.path.abspath('/'), + 'image': 'example/web', 'links': ['db'], 'volumes': [VolumeSpec.parse('/home/user/project:/code')], }, @@ -436,7 +439,7 @@ def test_load_with_multiple_files_v2(self): 'image': 'example/db', }, ] - self.assertEqual(service_sort(service_dicts), service_sort(expected)) + assert service_sort(service_dicts) == service_sort(expected) def test_config_valid_service_names(self): for valid_name in ['_', '-', '.__.', '_what-up.', 'what_.up----', 'whatup']: @@ -525,16 +528,15 @@ def test_invalid_list_of_strings_format(self): ) ) - def test_config_image_and_dockerfile_raise_validation_error(self): - expected_error_msg = "Service 'web' has both an image and alternate Dockerfile." - with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): - config.load( - build_config_details( - {'web': {'image': 'busybox', 'dockerfile': 'Dockerfile.alt'}}, - 'working_dir', - 'filename.yml' - ) - ) + def test_load_config_dockerfile_without_build_raises_error(self): + with pytest.raises(ConfigurationError) as exc: + config.load(build_config_details({ + 'web': { + 'image': 'busybox', + '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" @@ -746,7 +748,10 @@ def test_merge_service_dicts_from_files_with_extends_in_base(self): override = { 'image': 'alpine:edge', } - actual = config.merge_service_dicts_from_files(base, override) + actual = config.merge_service_dicts_from_files( + base, + override, + DEFAULT_VERSION) assert actual == { 'image': 'alpine:edge', 'volumes': ['.:/app'], @@ -762,7 +767,10 @@ def test_merge_service_dicts_from_files_with_extends_in_override(self): 'image': 'alpine:edge', 'extends': {'service': 'foo'} } - actual = config.merge_service_dicts_from_files(base, override) + actual = config.merge_service_dicts_from_files( + base, + override, + DEFAULT_VERSION) assert actual == { 'image': 'alpine:edge', 'volumes': ['.:/app'], @@ -1023,43 +1031,43 @@ def config_name(self): return "" def test_empty(self): - service_dict = config.merge_service_dicts({}, {}) - self.assertNotIn(self.config_name(), service_dict) + service_dict = config.merge_service_dicts({}, {}, DEFAULT_VERSION) + 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.assertEqual(set(service_dict[self.config_name()]), set(['/foo:/code', '/data'])) + DEFAULT_VERSION) + 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.assertEqual(set(service_dict[self.config_name()]), set(['/bar:/code'])) + DEFAULT_VERSION) + 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.assertEqual(set(service_dict[self.config_name()]), set(['/bar:/code', '/data'])) + DEFAULT_VERSION) + 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.assertEqual(set(service_dict[self.config_name()]), set(['/bar:/code', '/quux:/data'])) + DEFAULT_VERSION) + 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.assertEqual(set(service_dict[self.config_name()]), set(['/bar:/code', '/data'])) + DEFAULT_VERSION) + assert set(service_dict[self.config_name()]) == set(['/bar:/code', '/data']) class MergeVolumesTest(unittest.TestCase, MergePathMappingTest): @@ -1075,63 +1083,62 @@ def config_name(self): class BuildOrImageMergeTest(unittest.TestCase): def test_merge_build_or_image_no_override(self): self.assertEqual( - config.merge_service_dicts({'build': '.'}, {}), + config.merge_service_dicts({'build': '.'}, {}, V1), {'build': '.'}, ) self.assertEqual( - config.merge_service_dicts({'image': 'redis'}, {}), + config.merge_service_dicts({'image': 'redis'}, {}, V1), {'image': 'redis'}, ) def test_merge_build_or_image_override_with_same(self): self.assertEqual( - config.merge_service_dicts({'build': '.'}, {'build': './web'}), + config.merge_service_dicts({'build': '.'}, {'build': './web'}, V1), {'build': './web'}, ) self.assertEqual( - config.merge_service_dicts({'image': 'redis'}, {'image': 'postgres'}), + config.merge_service_dicts({'image': 'redis'}, {'image': 'postgres'}, V1), {'image': 'postgres'}, ) def test_merge_build_or_image_override_with_other(self): self.assertEqual( - config.merge_service_dicts({'build': '.'}, {'image': 'redis'}), - {'image': 'redis'} + config.merge_service_dicts({'build': '.'}, {'image': 'redis'}, V1), + {'image': 'redis'}, ) self.assertEqual( - config.merge_service_dicts({'image': 'redis'}, {'build': '.'}), - {'build': '.'} + config.merge_service_dicts({'image': 'redis'}, {'build': '.'}, V1), + {'build': '.'}, ) class MergeListsTest(unittest.TestCase): def test_empty(self): - service_dict = config.merge_service_dicts({}, {}) - self.assertNotIn('ports', service_dict) + assert 'ports' not in config.merge_service_dicts({}, {}, DEFAULT_VERSION) def test_no_override(self): service_dict = config.merge_service_dicts( {'ports': ['10:8000', '9000']}, {}, - ) - self.assertEqual(set(service_dict['ports']), set(['10:8000', '9000'])) + DEFAULT_VERSION) + assert set(service_dict['ports']) == set(['10:8000', '9000']) def test_no_base(self): service_dict = config.merge_service_dicts( {}, {'ports': ['10:8000', '9000']}, - ) - self.assertEqual(set(service_dict['ports']), set(['10:8000', '9000'])) + DEFAULT_VERSION) + assert set(service_dict['ports']) == set(['10:8000', '9000']) def test_add_item(self): service_dict = config.merge_service_dicts( {'ports': ['10:8000', '9000']}, {'ports': ['20:8000']}, - ) - self.assertEqual(set(service_dict['ports']), set(['10:8000', '9000', '20:8000'])) + DEFAULT_VERSION) + assert set(service_dict['ports']) == set(['10:8000', '9000', '20:8000']) class MergeStringsOrListsTest(unittest.TestCase): @@ -1139,70 +1146,69 @@ def test_no_override(self): service_dict = config.merge_service_dicts( {'dns': '8.8.8.8'}, {}, - ) - self.assertEqual(set(service_dict['dns']), set(['8.8.8.8'])) + DEFAULT_VERSION) + assert set(service_dict['dns']) == set(['8.8.8.8']) def test_no_base(self): service_dict = config.merge_service_dicts( {}, {'dns': '8.8.8.8'}, - ) - self.assertEqual(set(service_dict['dns']), set(['8.8.8.8'])) + DEFAULT_VERSION) + assert set(service_dict['dns']) == set(['8.8.8.8']) def test_add_string(self): service_dict = config.merge_service_dicts( {'dns': ['8.8.8.8']}, {'dns': '9.9.9.9'}, - ) - self.assertEqual(set(service_dict['dns']), set(['8.8.8.8', '9.9.9.9'])) + DEFAULT_VERSION) + assert set(service_dict['dns']) == set(['8.8.8.8', '9.9.9.9']) def test_add_list(self): service_dict = config.merge_service_dicts( {'dns': '8.8.8.8'}, {'dns': ['9.9.9.9']}, - ) - self.assertEqual(set(service_dict['dns']), set(['8.8.8.8', '9.9.9.9'])) + DEFAULT_VERSION) + assert set(service_dict['dns']) == set(['8.8.8.8', '9.9.9.9']) class MergeLabelsTest(unittest.TestCase): def test_empty(self): - service_dict = config.merge_service_dicts({}, {}) - self.assertNotIn('labels', service_dict) + assert 'labels' not in config.merge_service_dicts({}, {}, DEFAULT_VERSION) def test_no_override(self): service_dict = config.merge_service_dicts( make_service_dict('foo', {'build': '.', 'labels': ['foo=1', 'bar']}, 'tests/'), make_service_dict('foo', {'build': '.'}, 'tests/'), - ) - self.assertEqual(service_dict['labels'], {'foo': '1', 'bar': ''}) + DEFAULT_VERSION) + assert service_dict['labels'] == {'foo': '1', 'bar': ''} def test_no_base(self): service_dict = config.merge_service_dicts( make_service_dict('foo', {'build': '.'}, 'tests/'), make_service_dict('foo', {'build': '.', 'labels': ['foo=2']}, 'tests/'), - ) - self.assertEqual(service_dict['labels'], {'foo': '2'}) + DEFAULT_VERSION) + assert service_dict['labels'] == {'foo': '2'} def test_override_explicit_value(self): service_dict = config.merge_service_dicts( make_service_dict('foo', {'build': '.', 'labels': ['foo=1', 'bar']}, 'tests/'), make_service_dict('foo', {'build': '.', 'labels': ['foo=2']}, 'tests/'), - ) - self.assertEqual(service_dict['labels'], {'foo': '2', 'bar': ''}) + DEFAULT_VERSION) + assert service_dict['labels'] == {'foo': '2', 'bar': ''} def test_add_explicit_value(self): service_dict = config.merge_service_dicts( make_service_dict('foo', {'build': '.', 'labels': ['foo=1', 'bar']}, 'tests/'), make_service_dict('foo', {'build': '.', 'labels': ['bar=2']}, 'tests/'), - ) - self.assertEqual(service_dict['labels'], {'foo': '1', 'bar': '2'}) + DEFAULT_VERSION) + assert service_dict['labels'] == {'foo': '1', 'bar': '2'} def test_remove_explicit_value(self): service_dict = config.merge_service_dicts( make_service_dict('foo', {'build': '.', 'labels': ['foo=1', 'bar=2']}, 'tests/'), make_service_dict('foo', {'build': '.', 'labels': ['bar']}, 'tests/'), - ) - self.assertEqual(service_dict['labels'], {'foo': '1', 'bar': ''}) + DEFAULT_VERSION) + assert service_dict['labels'] == {'foo': '1', 'bar': ''} class MemoryOptionsTest(unittest.TestCase): @@ -1541,10 +1547,12 @@ def test_extends_validation_valid_config(self): self.assertEquals(service[0]['command'], "/bin/true") def test_extended_service_with_invalid_config(self): - expected_error_msg = "Service 'myweb' has neither an image nor a build path specified" - - with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): + 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 + exc.exconly() + ) def test_extended_service_with_valid_config(self): service = load_from_filename('tests/fixtures/extends/service-with-valid-composite-extends.yml') diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index d7080faef35..63cf658ed93 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -492,6 +492,15 @@ def test_get_links_with_networking(self): 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) + assert service.image_name == image_name + + def test_image_name_default(self): + service = Service('foo', project='testing') + assert service.image_name == 'testing_foo' + def sort_by_name(dictionary_list): return sorted(dictionary_list, key=lambda k: k['name']) From b59387401c0f5c2eea11153eb024932db2748855 Mon Sep 17 00:00:00 2001 From: Steve Durrheimer Date: Tue, 12 Jan 2016 22:04:05 +0100 Subject: [PATCH 1670/4072] Add zsh completion for 'docker-compose events' 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 35c2b996f69..710dadd43e8 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 ;; + (events) + _arguments \ + $opts_help \ + '--json[Output events as a stream of json objects.]' \ + '*:services:__docker-compose_services_all' && ret=0 + ;; (help) _arguments ':subcommand:__docker-compose_commands' && ret=0 ;; From b786b47bc8425dd75d5f6a257d948d5fd8fda022 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 12 Jan 2016 17:35:48 +0000 Subject: [PATCH 1671/4072] Remove version checks from tests requiring API v1.21 Signed-off-by: Aanand Prasad --- tests/acceptance/cli_test.py | 21 ++++++--------------- tests/integration/project_test.py | 23 +++++++---------------- tests/integration/service_test.py | 2 -- 3 files changed, 13 insertions(+), 33 deletions(-) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 3a3c89b054a..7348edb0470 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -16,7 +16,6 @@ from .. import mock from compose.cli.command import get_project -from compose.cli.docker_client import docker_client from compose.container import Container from tests.integration.testcases import DockerClientTestCase from tests.integration.testcases import get_links @@ -336,13 +335,10 @@ def test_up_attached(self): assert 'another_1 | another' in result.stdout def test_up_without_networking(self): - self.require_api_version('1.21') - self.base_dir = 'tests/fixtures/links-composefile' self.dispatch(['up', '-d'], None) - client = docker_client(version='1.21') - networks = client.networks(names=[self.project.name]) + networks = self.client.networks(names=[self.project.name]) self.assertEqual(len(networks), 0) for service in self.project.get_services(): @@ -354,21 +350,18 @@ def test_up_without_networking(self): self.assertTrue(web_container.get('HostConfig.Links')) def test_up_with_networking(self): - self.require_api_version('1.21') - self.base_dir = 'tests/fixtures/links-composefile' self.dispatch(['--x-networking', 'up', '-d'], None) - client = docker_client(version='1.21') services = self.project.get_services() - networks = client.networks(names=[self.project.name]) + networks = self.client.networks(names=[self.project.name]) for n in networks: - self.addCleanup(client.remove_network, n['Id']) + self.addCleanup(self.client.remove_network, n['Id']) self.assertEqual(len(networks), 1) self.assertEqual(networks[0]['Driver'], 'bridge') - network = client.inspect_network(networks[0]['Id']) + network = self.client.inspect_network(networks[0]['Id']) self.assertEqual(len(network['Containers']), len(services)) for service in services: @@ -652,15 +645,13 @@ def test_run_with_custom_name(self): self.assertEqual(container.name, name) def test_run_with_networking(self): - self.require_api_version('1.21') - client = docker_client(version='1.21') self.base_dir = 'tests/fixtures/simple-dockerfile' self.dispatch(['--x-networking', 'run', 'simple', 'true'], None) service = self.project.get_service('simple') container, = service.containers(stopped=True, one_off=True) - networks = client.networks(names=[self.project.name]) + networks = self.client.networks(names=[self.project.name]) for n in networks: - self.addCleanup(client.remove_network, n['Id']) + self.addCleanup(self.client.remove_network, n['Id']) self.assertEqual(len(networks), 1) self.assertEqual(container.human_readable_command, u'true') diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 5a1444b67ac..1a342c8a42a 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -6,7 +6,6 @@ import py from .testcases import DockerClientTestCase -from compose.cli.docker_client import docker_client from compose.config import config from compose.config.types import VolumeFromSpec from compose.config.types import VolumeSpec @@ -105,20 +104,14 @@ def test_volumes_from_container(self): self.assertEqual(db._get_volumes_from(), [data_container.id + ':rw']) def test_get_network_does_not_exist(self): - self.require_api_version('1.21') - client = docker_client(version='1.21') - - project = Project('composetest', [], client) + project = Project('composetest', [], self.client) assert project.get_network() is None def test_get_network(self): - self.require_api_version('1.21') - client = docker_client(version='1.21') - network_name = 'network_does_exist' - project = Project(network_name, [], client) - client.create_network(network_name) - self.addCleanup(client.remove_network, network_name) + project = Project(network_name, [], self.client) + self.client.create_network(network_name) + self.addCleanup(self.client.remove_network, network_name) assert project.get_network()['Name'] == network_name def test_net_from_service(self): @@ -476,15 +469,13 @@ def test_project_up_with_no_deps(self): self.assertEqual(len(project.get_service('console').containers()), 0) def test_project_up_with_custom_network(self): - self.require_api_version('1.21') - client = docker_client(version='1.21') network_name = 'composetest-custom' - client.create_network(network_name) - self.addCleanup(client.remove_network, network_name) + self.client.create_network(network_name) + self.addCleanup(self.client.remove_network, network_name) web = self.create_service('web', net=Net(network_name)) - project = Project('composetest', [web], client, use_networking=True) + project = Project('composetest', [web], self.client, use_networking=True) project.up() assert project.get_network() is None diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 539be5a9b3a..3eb50942029 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -718,8 +718,6 @@ def test_scale_with_custom_container_name_outputs_warning(self, mock_log): """Test that calling scale on a service that has a custom container name results in warning output. """ - # Disable this test against earlier versions because it is flaky - self.require_api_version('1.21') service = self.create_service('app', container_name='custom-container') self.assertEqual(service.custom_container_name(), 'custom-container') From 1a66543461c610ff0b6c1dcf117ed965edd5c249 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 12 Jan 2016 16:25:15 +0000 Subject: [PATCH 1672/4072] Make the default network name '{project name}_default' Signed-off-by: Aanand Prasad --- compose/project.py | 14 +++++++++----- tests/acceptance/cli_test.py | 4 ++-- tests/integration/project_test.py | 8 ++++++-- tests/unit/project_test.py | 2 +- 4 files changed, 18 insertions(+), 10 deletions(-) diff --git a/compose/project.py b/compose/project.py index d41b1f52a84..71e353ecb87 100644 --- a/compose/project.py +++ b/compose/project.py @@ -185,7 +185,7 @@ def get_net(self, service_dict): net = service_dict.pop('net', None) if not net: if self.use_networking: - return Net(self.name) + return Net(self.default_network_name) return Net(None) net_name = get_service_name_from_net(net) @@ -383,7 +383,7 @@ def matches_service_names(container): def get_network(self): try: - return self.client.inspect_network(self.name) + return self.client.inspect_network(self.default_network_name) except NotFound: return None @@ -396,9 +396,9 @@ def ensure_network_exists(self): log.info( 'Creating network "{}" with {}' - .format(self.name, driver_name) + .format(self.default_network_name, driver_name) ) - self.client.create_network(self.name, driver=self.network_driver) + self.client.create_network(self.default_network_name, driver=self.network_driver) def remove_network(self): network = self.get_network() @@ -406,7 +406,11 @@ def remove_network(self): self.client.remove_network(network['Id']) def uses_default_network(self): - return any(service.net.mode == self.name for service in self.services) + return any(service.net.mode == self.default_network_name for service in self.services) + + @property + def default_network_name(self): + return '{}_default'.format(self.name) def _inject_deps(self, acc, service): dep_names = service.get_dependency_names() diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 7348edb0470..46ed4237d97 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -355,7 +355,7 @@ def test_up_with_networking(self): services = self.project.get_services() - networks = self.client.networks(names=[self.project.name]) + networks = self.client.networks(names=[self.project.default_network_name]) for n in networks: self.addCleanup(self.client.remove_network, n['Id']) self.assertEqual(len(networks), 1) @@ -649,7 +649,7 @@ def test_run_with_networking(self): self.dispatch(['--x-networking', '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.name]) + networks = self.client.networks(names=[self.project.default_network_name]) for n in networks: self.addCleanup(self.client.remove_network, n['Id']) self.assertEqual(len(networks), 1) diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 1a342c8a42a..a3e0f33a0f7 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -108,10 +108,14 @@ def test_get_network_does_not_exist(self): assert project.get_network() is None def test_get_network(self): - network_name = 'network_does_exist' - project = Project(network_name, [], self.client) + project_name = 'network_does_exist' + network_name = '{}_default'.format(project_name) + + project = Project(project_name, [], self.client) self.client.create_network(network_name) self.addCleanup(self.client.remove_network, network_name) + + assert isinstance(project.get_network(), dict) assert project.get_network()['Name'] == network_name def test_net_from_service(self): diff --git a/tests/unit/project_test.py b/tests/unit/project_test.py index c8590a1f9a5..63953376b4f 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -346,7 +346,7 @@ def test_use_net_from_service(self): self.assertEqual(service.net.mode, 'container:' + container_name) def test_uses_default_network_true(self): - web = Service('web', project='test', image="alpine", net=Net('test')) + web = Service('web', project='test', image="alpine", net=Net('test_default')) db = Service('web', project='test', image="alpine", net=Net('other')) project = Project('test', [web, db], None) assert project.uses_default_network() From a027a0079c17c87b1e531c82cf8250086dbfbf66 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 12 Jan 2016 16:26:20 +0000 Subject: [PATCH 1673/4072] Use networking for version 2 Compose files - Remove --x-networking and --x-network-driver - There's now no way to set a network driver - this will be added back with the 'networks' key Signed-off-by: Aanand Prasad --- compose/cli/command.py | 18 ++++++------------ compose/cli/main.py | 4 ---- compose/project.py | 5 +++-- contrib/completion/bash/docker-compose | 9 +-------- contrib/completion/zsh/_docker-compose | 2 -- tests/acceptance/cli_test.py | 12 ++++++------ tests/fixtures/v2-simple/docker-compose.yml | 8 ++++++++ tests/integration/testcases.py | 7 +++++++ tests/unit/project_test.py | 20 ++++++++++++++++++-- 9 files changed, 49 insertions(+), 36 deletions(-) create mode 100644 tests/fixtures/v2-simple/docker-compose.yml diff --git a/compose/cli/command.py b/compose/cli/command.py index b278af3a6aa..f14388c6a79 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -49,8 +49,6 @@ def project_from_options(base_dir, options): get_config_path_from_options(options), project_name=options.get('--project-name'), verbose=options.get('--verbose'), - use_networking=options.get('--x-networking'), - network_driver=options.get('--x-network-driver'), ) @@ -75,18 +73,14 @@ def get_client(verbose=False, version=None): return client -def get_project(base_dir, config_path=None, project_name=None, verbose=False, - use_networking=False, network_driver=None): +def get_project(base_dir, config_path=None, project_name=None, verbose=False): config_details = config.find(base_dir, config_path) + project_name = get_project_name(config_details.working_dir, project_name) + config_data = config.load(config_details) + api_version = '1.21' if config_data.version < 2 else None + client = get_client(verbose=verbose, version=api_version) - api_version = '1.21' if use_networking else None - return Project.from_config( - get_project_name(config_details.working_dir, project_name), - config.load(config_details), - get_client(verbose=verbose, version=api_version), - use_networking=use_networking, - network_driver=network_driver - ) + return Project.from_config(project_name, config_data, client) def get_project_name(working_dir, project_name=None): diff --git a/compose/cli/main.py b/compose/cli/main.py index 82cf05c9f2a..7c6d1cb5a3f 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -122,10 +122,6 @@ class TopLevelCommand(DocoptCommand): 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) - --x-networking (EXPERIMENTAL) Use new Docker networking functionality. - Requires Docker 1.9 or later. - --x-network-driver DRIVER (EXPERIMENTAL) Specify a network driver (default: "bridge"). - Requires Docker 1.9 or later. --verbose Show more output -v, --version Print version and exit diff --git a/compose/project.py b/compose/project.py index 71e353ecb87..fa3eace20a5 100644 --- a/compose/project.py +++ b/compose/project.py @@ -48,11 +48,12 @@ def labels(self, one_off=False): ] @classmethod - def from_config(cls, name, config_data, client, use_networking=False, network_driver=None): + def from_config(cls, name, config_data, client): """ Construct a Project from a config.Config object. """ - project = cls(name, [], client, use_networking=use_networking, network_driver=network_driver) + use_networking = (config_data.version and config_data.version >= 2) + project = cls(name, [], client, use_networking=use_networking) if use_networking: remove_links(config_data.services) diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index c18e4f6df60..caea2a23769 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -116,15 +116,11 @@ _docker_compose_docker_compose() { --project-name|-p) return ;; - --x-network-driver) - COMPREPLY=( $( compgen -W "bridge host none overlay" -- "$cur" ) ) - return - ;; esac case "$cur" in -*) - COMPREPLY=( $( compgen -W "--file -f --help -h --project-name -p --verbose --version -v --x-networking --x-network-driver" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--file -f --help -h --project-name -p --verbose --version -v" -- "$cur" ) ) ;; *) COMPREPLY=( $( compgen -W "${commands[*]}" -- "$cur" ) ) @@ -416,9 +412,6 @@ _docker_compose() { (( counter++ )) compose_project="${words[$counter]}" ;; - --x-network-driver) - (( counter++ )) - ;; -*) ;; *) diff --git a/contrib/completion/zsh/_docker-compose b/contrib/completion/zsh/_docker-compose index 35c2b996f69..2a19dd59733 100644 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -332,8 +332,6 @@ _docker-compose() { '(- :)'{-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:' \ - '--x-networking[(EXPERIMENTAL) Use new Docker networking functionality. Requires Docker 1.9 or later.]' \ - '--x-network-driver[(EXPERIMENTAL) Specify a network driver (default: "bridge"). Requires Docker 1.9 or later.]:Network Driver:(bridge host none overlay)' \ '(-): :->command' \ '(-)*:: :->option-or-argument' && ret=0 diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 46ed4237d97..25808c94b0e 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -338,7 +338,7 @@ def test_up_without_networking(self): self.base_dir = 'tests/fixtures/links-composefile' self.dispatch(['up', '-d'], None) - networks = self.client.networks(names=[self.project.name]) + networks = self.client.networks(names=[self.project.default_network_name]) self.assertEqual(len(networks), 0) for service in self.project.get_services(): @@ -350,8 +350,8 @@ def test_up_without_networking(self): self.assertTrue(web_container.get('HostConfig.Links')) def test_up_with_networking(self): - self.base_dir = 'tests/fixtures/links-composefile' - self.dispatch(['--x-networking', 'up', '-d'], None) + self.base_dir = 'tests/fixtures/v2-simple' + self.dispatch(['up', '-d'], None) services = self.project.get_services() @@ -369,7 +369,7 @@ def test_up_with_networking(self): self.assertEqual(len(containers), 1) self.assertIn(containers[0].id, network['Containers']) - web_container = self.project.get_service('web').containers()[0] + web_container = self.project.get_service('simple').containers()[0] self.assertFalse(web_container.get('HostConfig.Links')) def test_up_with_links(self): @@ -645,8 +645,8 @@ def test_run_with_custom_name(self): self.assertEqual(container.name, name) def test_run_with_networking(self): - self.base_dir = 'tests/fixtures/simple-dockerfile' - self.dispatch(['--x-networking', 'run', 'simple', 'true'], None) + 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_name]) diff --git a/tests/fixtures/v2-simple/docker-compose.yml b/tests/fixtures/v2-simple/docker-compose.yml new file mode 100644 index 00000000000..12a9de72ca9 --- /dev/null +++ b/tests/fixtures/v2-simple/docker-compose.yml @@ -0,0 +1,8 @@ +version: 2 +services: + simple: + image: busybox:latest + command: top + another: + image: busybox:latest + command: top diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index 8e0525eefd6..3002539e974 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -36,14 +36,21 @@ def tearDown(self): all=True, filters={'label': '%s=composetest' % LABEL_PROJECT}): self.client.remove_container(c['Id'], force=True) + for i in self.client.images( filters={'label': 'com.docker.compose.test_image'}): self.client.remove_image(i) + volumes = self.client.volumes().get('Volumes') or [] for v in volumes: if 'composetest_' in v['Name']: self.client.remove_volume(v['Name']) + networks = self.client.networks() + for n in networks: + if 'composetest_' in n['Name']: + self.client.remove_network(n['Name']) + def create_service(self, name, **kwargs): if 'image' not in kwargs and 'build' not in kwargs: kwargs['image'] = 'busybox:latest' diff --git a/tests/unit/project_test.py b/tests/unit/project_test.py index 63953376b4f..f63135ae68e 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -39,7 +39,7 @@ def test_from_dict(self): self.assertEqual(project.get_service('db').options['image'], 'busybox:latest') def test_from_config(self): - dicts = Config(None, [ + config = Config(None, [ { 'name': 'web', 'image': 'busybox:latest', @@ -49,12 +49,28 @@ def test_from_config(self): 'image': 'busybox:latest', }, ], None) - project = Project.from_config('composetest', dicts, None) + project = Project.from_config('composetest', config, None) self.assertEqual(len(project.services), 2) self.assertEqual(project.get_service('web').name, 'web') 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) + + def test_from_config_v2(self): + config = Config(2, [ + { + 'name': 'web', + 'image': 'busybox:latest', + }, + { + 'name': 'db', + 'image': 'busybox:latest', + }, + ], None) + project = Project.from_config('composetest', config, None) + self.assertEqual(len(project.services), 2) + self.assertTrue(project.use_networking) def test_get_service(self): web = Service( From 9e17cff0efaff14f2b03d5d0ba70559661733b26 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 12 Jan 2016 18:12:53 +0000 Subject: [PATCH 1674/4072] Refactor API version switching logic Signed-off-by: Aanand Prasad --- compose/cli/command.py | 6 +++++- compose/cli/docker_client.py | 7 ++----- compose/const.py | 7 +++++++ 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/compose/cli/command.py b/compose/cli/command.py index f14388c6a79..c1681ffc720 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -13,6 +13,7 @@ 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 @@ -77,7 +78,10 @@ def get_project(base_dir, config_path=None, project_name=None, verbose=False): config_details = config.find(base_dir, config_path) project_name = get_project_name(config_details.working_dir, project_name) config_data = config.load(config_details) - api_version = '1.21' if config_data.version < 2 else None + + api_version = os.environ.get( + 'COMPOSE_API_VERSION', + API_VERSIONS[config_data.version]) client = get_client(verbose=verbose, version=api_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 48ba97bdabe..611997dfa9e 100644 --- a/compose/cli/docker_client.py +++ b/compose/cli/docker_client.py @@ -11,8 +11,6 @@ log = logging.getLogger(__name__) -DEFAULT_API_VERSION = '1.21' - def docker_client(version=None): """ @@ -23,8 +21,7 @@ def docker_client(version=None): log.warn('The DOCKER_CLIENT_TIMEOUT environment variable is deprecated. Please use COMPOSE_HTTP_TIMEOUT instead.') kwargs = kwargs_from_env(assert_hostname=False) - kwargs['version'] = version or os.environ.get( - 'COMPOSE_API_VERSION', - DEFAULT_API_VERSION) + if version: + kwargs['version'] = version kwargs['timeout'] = HTTP_TIMEOUT return Client(**kwargs) diff --git a/compose/const.py b/compose/const.py index 84a5057a48a..331895b1047 100644 --- a/compose/const.py +++ b/compose/const.py @@ -15,3 +15,10 @@ LABEL_VERSION = 'com.docker.compose.version' LABEL_CONFIG_HASH = 'com.docker.compose.config-hash' COMPOSEFILE_VERSIONS = (1, 2) + +API_VERSIONS = { + 1: '1.21', + + # TODO: update to 1.22 when there's a Docker 1.10 build to test against + 2: '1.21', +} From 70cce961a8223403d36e8d209925e112f1ebd02f Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 12 Jan 2016 17:26:18 +0000 Subject: [PATCH 1675/4072] Don't allow links or external_links in v2 files Signed-off-by: Aanand Prasad --- compose/config/service_schema_v2.json | 2 -- tests/acceptance/cli_test.py | 13 ++++++++++++- tests/fixtures/v2-simple/links-invalid.yml | 10 ++++++++++ tests/unit/config/config_test.py | 4 +--- 4 files changed, 23 insertions(+), 6 deletions(-) create mode 100644 tests/fixtures/v2-simple/links-invalid.yml diff --git a/compose/config/service_schema_v2.json b/compose/config/service_schema_v2.json index d1d0854f3ee..47b195fcc03 100644 --- a/compose/config/service_schema_v2.json +++ b/compose/config/service_schema_v2.json @@ -66,12 +66,10 @@ }, "extra_hosts": {"$ref": "#/definitions/list_or_dict"}, - "external_links": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, "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", diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 25808c94b0e..eab4bdae978 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -372,7 +372,18 @@ def test_up_with_networking(self): web_container = self.project.get_service('simple').containers()[0] self.assertFalse(web_container.get('HostConfig.Links')) - def test_up_with_links(self): + 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) web = self.project.get_service('web') diff --git a/tests/fixtures/v2-simple/links-invalid.yml b/tests/fixtures/v2-simple/links-invalid.yml new file mode 100644 index 00000000000..422f9314eec --- /dev/null +++ b/tests/fixtures/v2-simple/links-invalid.yml @@ -0,0 +1,10 @@ +version: 2 +services: + simple: + image: busybox:latest + command: top + links: + - another + another: + image: busybox:latest + command: top diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index a59d1d34ac6..25483e52626 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -268,8 +268,8 @@ def test_load_with_multiple_files_v1(self): { 'name': 'web', 'build': os.path.abspath('/'), - 'links': ['db'], 'volumes': [VolumeSpec.parse('/home/user/project:/code')], + 'links': ['db'], }, { 'name': 'db', @@ -405,7 +405,6 @@ def test_load_with_multiple_files_v2(self): 'services': { 'web': { 'image': 'example/web', - 'links': ['db'], }, 'db': { 'image': 'example/db', @@ -431,7 +430,6 @@ def test_load_with_multiple_files_v2(self): 'name': 'web', 'build': os.path.abspath('/'), 'image': 'example/web', - 'links': ['db'], 'volumes': [VolumeSpec.parse('/home/user/project:/code')], }, { From 2f07e2ac3628617b9817dc9f7815d6c3a730aaac Mon Sep 17 00:00:00 2001 From: Dimitar Bonev Date: Thu, 7 Jan 2016 21:21:47 +0200 Subject: [PATCH 1676/4072] Ulimits are now merged into extended services Signed-off-by: Dimitar Bonev --- compose/config/config.py | 25 +++++++++++++------ .../extends/common-env-labels-ulimits.yml | 13 ++++++++++ tests/unit/config/config_test.py | 18 +++++++++++++ 3 files changed, 48 insertions(+), 8 deletions(-) create mode 100644 tests/fixtures/extends/common-env-labels-ulimits.yml diff --git a/compose/config/config.py b/compose/config/config.py index 918946b3965..11cc3ce9d92 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -547,8 +547,15 @@ def merge_field(field, merge_func, default=None): base.get(field, default), override.get(field, default)) - merge_field('environment', merge_environment) - merge_field('labels', merge_labels) + 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) for field in ['volumes', 'devices']: merge_field(field, merge_path_mappings) @@ -723,12 +730,6 @@ def join_path_mapping(pair): return ":".join((host, container)) -def merge_labels(base, override): - labels = parse_labels(base) - labels.update(parse_labels(override)) - return labels - - def parse_labels(labels): if not labels: return {} @@ -747,6 +748,14 @@ def split_label(label): return label, '' +def parse_ulimits(ulimits): + if not ulimits: + return {} + + if isinstance(ulimits, dict): + return dict(ulimits) + + def expand_path(working_dir, path): return os.path.abspath(os.path.join(working_dir, os.path.expanduser(path))) diff --git a/tests/fixtures/extends/common-env-labels-ulimits.yml b/tests/fixtures/extends/common-env-labels-ulimits.yml new file mode 100644 index 00000000000..09efb4e75d2 --- /dev/null +++ b/tests/fixtures/extends/common-env-labels-ulimits.yml @@ -0,0 +1,13 @@ +web: + extends: + file: common.yml + service: web + environment: + - FOO=2 + - BAZ=3 + labels: ['label=one'] + ulimits: + nproc: 65535 + memlock: + soft: 1024 + hard: 2048 diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index a59d1d34ac6..fb324c572b2 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1396,6 +1396,24 @@ def test_extends(self): } ])) + def test_merging_env_labels_ulimits(self): + service_dicts = load_from_filename('tests/fixtures/extends/common-env-labels-ulimits.yml') + + self.assertEqual(service_sort(service_dicts), service_sort([ + { + 'name': 'web', + 'image': 'busybox', + 'command': '/bin/true', + 'environment': { + "FOO": "2", + "BAR": "1", + "BAZ": "3", + }, + 'labels': {'label': 'one'}, + 'ulimits': {'nproc': 65535, 'memlock': {'soft': 1024, 'hard': 2048}} + } + ])) + def test_nested(self): service_dicts = load_from_filename('tests/fixtures/extends/nested.yml') From 3a46abd17fe0b631643a62e2ca5e51a6c9ced462 Mon Sep 17 00:00:00 2001 From: Dimitar Bonev Date: Sun, 10 Jan 2016 18:15:13 +0200 Subject: [PATCH 1677/4072] Allowed port range in exposed ports Signed-off-by: Dimitar Bonev --- compose/config/validation.py | 2 +- tests/acceptance/cli_test.py | 22 +++++++++++++++++++ .../expose-composefile/docker-compose.yml | 11 ++++++++++ 3 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/expose-composefile/docker-compose.yml diff --git a/compose/config/validation.py b/compose/config/validation.py index e7006d5a4be..74ae5c9c8bf 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -38,7 +38,7 @@ VALID_NAME_CHARS = '[a-zA-Z0-9\._\-]' -VALID_EXPOSE_FORMAT = r'^\d+(\/[a-zA-Z]+)?$' +VALID_EXPOSE_FORMAT = r'^\d+(\-\d+)?(\/[a-zA-Z]+)?$' @FormatChecker.cls_checks(format="ports", raises=ValidationError) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 3a3c89b054a..a882f9de223 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -642,6 +642,28 @@ def test_run_service_with_explicitly_maped_ip_ports(self): self.assertEqual(port_short, "127.0.0.1:30000") self.assertEqual(port_full, "127.0.0.1:30001") + def test_run_with_expose_ports(self): + # 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] + + ports = container.ports + self.assertEqual(len(ports), 9) + # exposed ports are not mapped to host ports + assert ports['3000/tcp'] is None + assert ports['3001/tcp'] is None + assert ports['3001/udp'] is None + assert ports['3002/tcp'] is None + assert ports['3003/tcp'] is None + assert ports['3004/tcp'] is None + assert ports['3005/tcp'] is None + assert ports['3006/udp'] is None + assert ports['3007/udp'] is None + + # close all one off containers we just created + container.stop() + def test_run_with_custom_name(self): self.base_dir = 'tests/fixtures/environment-composefile' name = 'the-container-name' diff --git a/tests/fixtures/expose-composefile/docker-compose.yml b/tests/fixtures/expose-composefile/docker-compose.yml new file mode 100644 index 00000000000..d14a468dec8 --- /dev/null +++ b/tests/fixtures/expose-composefile/docker-compose.yml @@ -0,0 +1,11 @@ + +simple: + image: busybox:latest + command: top + expose: + - '3000' + - '3001/tcp' + - '3001/udp' + - '3002-3003' + - '3004-3005/tcp' + - '3006-3007/udp' From 5d8c2d3cec432fd1853268804b21cdebe3ed81ce Mon Sep 17 00:00:00 2001 From: Jonathan Stewmon Date: Fri, 4 Dec 2015 16:40:09 -0600 Subject: [PATCH 1678/4072] add support for stop_signal to compose file Signed-off-by: Jonathan Stewmon --- compose/config/config.py | 1 + compose/config/service_schema_v1.json | 1 + compose/config/service_schema_v2.json | 1 + compose/container.py | 8 ++++++++ tests/acceptance/cli_test.py | 12 ++++++++++++ .../stop-signal-composefile/docker-compose.yml | 10 ++++++++++ tests/integration/service_test.py | 6 ++++++ 7 files changed, 39 insertions(+) create mode 100644 tests/fixtures/stop-signal-composefile/docker-compose.yml diff --git a/compose/config/config.py b/compose/config/config.py index 918946b3965..cdd35e12172 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -62,6 +62,7 @@ 'restart', 'security_opt', 'stdin_open', + 'stop_signal', 'tty', 'user', 'volume_driver', diff --git a/compose/config/service_schema_v1.json b/compose/config/service_schema_v1.json index d51c7f731b1..43e7c8b8d93 100644 --- a/compose/config/service_schema_v1.json +++ b/compose/config/service_schema_v1.json @@ -94,6 +94,7 @@ "restart": {"type": "string"}, "security_opt": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, "stdin_open": {"type": "boolean"}, + "stop_signal": {"type": "string"}, "tty": {"type": "boolean"}, "ulimits": { "type": "object", diff --git a/compose/config/service_schema_v2.json b/compose/config/service_schema_v2.json index 47b195fcc03..10331dccf87 100644 --- a/compose/config/service_schema_v2.json +++ b/compose/config/service_schema_v2.json @@ -101,6 +101,7 @@ "restart": {"type": "string"}, "security_opt": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, "stdin_open": {"type": "boolean"}, + "stop_signal": {"type": "string"}, "tty": {"type": "boolean"}, "ulimits": { "type": "object", diff --git a/compose/container.py b/compose/container.py index 5730f224618..2565c8ffc38 100644 --- a/compose/container.py +++ b/compose/container.py @@ -107,6 +107,10 @@ def format_port(private, public): def labels(self): return self.get('Config.Labels') or {} + @property + def stop_signal(self): + return self.get('Config.StopSignal') + @property def log_config(self): return self.get('HostConfig.LogConfig') or None @@ -132,6 +136,10 @@ def human_readable_command(self): def environment(self): return dict(var.split("=", 1) for var in self.get('Config.Env') or []) + @property + def exit_code(self): + return self.get('State.ExitCode') + @property def is_running(self): return self.get('State.Running') diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index eab4bdae978..90dca298e06 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -717,6 +717,18 @@ def test_stop(self): self.assertEqual(len(service.containers(stopped=True)), 1) self.assertFalse(service.containers(stopped=True)[0].is_running) + def test_stop_signal(self): + self.base_dir = 'tests/fixtures/stop-signal-composefile' + self.dispatch(['up', '-d'], None) + service = self.project.get_service('simple') + self.assertEqual(len(service.containers()), 1) + self.assertTrue(service.containers()[0].is_running) + + self.dispatch(['stop', '-t', '1'], None) + self.assertEqual(len(service.containers(stopped=True)), 1) + self.assertFalse(service.containers(stopped=True)[0].is_running) + self.assertEqual(service.containers(stopped=True)[0].exit_code, 0) + def test_start_no_containers(self): result = self.dispatch(['start'], returncode=1) assert 'No containers to start' in result.stderr diff --git a/tests/fixtures/stop-signal-composefile/docker-compose.yml b/tests/fixtures/stop-signal-composefile/docker-compose.yml new file mode 100644 index 00000000000..04f58aa98aa --- /dev/null +++ b/tests/fixtures/stop-signal-composefile/docker-compose.yml @@ -0,0 +1,10 @@ +simple: + image: busybox:latest + command: + - sh + - '-c' + - | + trap 'exit 0' SIGINT + trap 'exit 1' SIGTERM + while true; do :; done + stop_signal: SIGINT diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 3eb50942029..4818e47aa8a 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -887,6 +887,12 @@ def test_empty_labels(self): for name in labels_dict: self.assertIn((name, ''), labels) + def test_stop_signal(self): + stop_signal = 'SIGINT' + service = self.create_service('web', stop_signal=stop_signal) + container = create_and_start_container(service) + self.assertEqual(container.stop_signal, stop_signal) + 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') From 05935b5e5448436f210cb5488a65338aae6e722f Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 4 Jan 2016 15:10:32 -0800 Subject: [PATCH 1679/4072] Don't recreate pre-existing volumes. During the initialize_volumes phase, if a volume using the non-namespaced name already exists, don't create the namespaced equivalent. Signed-off-by: Joffrey F --- compose/project.py | 6 ++++++ compose/volume.py | 11 +++++++++++ tests/integration/project_test.py | 26 ++++++++++++++++++++++++-- tests/integration/volume_test.py | 10 ++++++++++ 4 files changed, 51 insertions(+), 2 deletions(-) diff --git a/compose/project.py b/compose/project.py index fa3eace20a5..f5bb8bebf20 100644 --- a/compose/project.py +++ b/compose/project.py @@ -235,6 +235,12 @@ def remove_stopped(self, service_names=None, **options): def initialize_volumes(self): try: for volume in self.volumes: + if volume.is_user_created: + log.info( + 'Found user-created volume "{0}". No new namespaced ' + 'volume will be created.'.format(volume.name) + ) + continue volume.create() except NotFound: raise ConfigurationError( diff --git a/compose/volume.py b/compose/volume.py index fb8bd580980..9bd98fa5b90 100644 --- a/compose/volume.py +++ b/compose/volume.py @@ -1,6 +1,8 @@ from __future__ import absolute_import from __future__ import unicode_literals +from docker.errors import NotFound + class Volume(object): def __init__(self, client, project, name, driver=None, driver_opts=None): @@ -21,6 +23,15 @@ def remove(self): def inspect(self): return self.client.inspect_volume(self.full_name) + @property + def is_user_created(self): + try: + self.client.inspect_volume(self.name) + except NotFound: + return False + + return True + @property def full_name(self): return '{0}_{1}'.format(self.project, self.name) diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index a3e0f33a0f7..d1b1fdf0747 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -4,6 +4,7 @@ import random import py +from docker.errors import NotFound from .testcases import DockerClientTestCase from compose.config import config @@ -624,7 +625,7 @@ def test_project_up_implicit_volume_driver(self): self.assertEqual(volume_data['Name'], full_vol_name) self.assertEqual(volume_data['Driver'], 'local') - def test_project_up_invalid_volume_driver(self): + def test_initialize_volumes_invalid_volume_driver(self): vol_name = '{0:x}'.format(random.getrandbits(32)) config_data = config.Config( @@ -642,7 +643,7 @@ def test_project_up_invalid_volume_driver(self): with self.assertRaises(config.ConfigurationError): project.initialize_volumes() - def test_project_up_updated_driver(self): + def test_initialize_volumes_updated_driver(self): vol_name = '{0:x}'.format(random.getrandbits(32)) full_vol_name = 'composetest_{0}'.format(vol_name) @@ -675,3 +676,24 @@ def test_project_up_updated_driver(self): assert 'Configuration for volume {0} specifies driver smb'.format( vol_name ) in str(e.exception) + + def test_initialize_volumes_user_created_volumes(self): + # Use composetest_ prefix so it gets garbage-collected in tearDown() + 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( + version=2, services=[{ + 'name': 'web', + 'image': 'busybox:latest', + 'command': 'top' + }], volumes={vol_name: {'driver': 'local'}} + ) + project = Project.from_config( + name='composetest', + config_data=config_data, client=self.client + ) + project.initialize_volumes() + + with self.assertRaises(NotFound): + self.client.inspect_volume(full_vol_name) diff --git a/tests/integration/volume_test.py b/tests/integration/volume_test.py index 8ae35378a18..8bcce0e14a0 100644 --- a/tests/integration/volume_test.py +++ b/tests/integration/volume_test.py @@ -54,3 +54,13 @@ def test_remove_volume(self): vol.remove() volumes = self.client.volumes()['Volumes'] assert len([v for v in volumes if v['Name'] == vol.full_name]) == 0 + + def test_is_user_created(self): + vol = Volume(self.client, 'composetest', 'uservolume01') + try: + self.client.create_volume('uservolume01') + assert vol.is_user_created is True + finally: + self.client.remove_volume('uservolume01') + vol2 = Volume(self.client, 'composetest', 'volume01') + assert vol2.is_user_created is False From 9cb58b796e2ea70f37ea857909fa4a3044d8861b Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 12 Jan 2016 16:53:49 -0800 Subject: [PATCH 1680/4072] Implement ability to specify external volumes External volumes are created and managed by the user. They are not namespaced. They are expected to exist at the beginning of the up phase. Signed-off-by: Joffrey F --- compose/config/fields_schema_v2.json | 7 +++++++ compose/project.py | 14 ++++++++++--- compose/volume.py | 21 ++++++++++++++----- tests/integration/project_test.py | 24 ++++++++++++++++++++-- tests/integration/volume_test.py | 30 ++++++++++++++++++---------- tests/unit/config/config_test.py | 18 +++++++++++++++++ 6 files changed, 93 insertions(+), 21 deletions(-) diff --git a/compose/config/fields_schema_v2.json b/compose/config/fields_schema_v2.json index 22ff839fb15..61bd7628b8b 100644 --- a/compose/config/fields_schema_v2.json +++ b/compose/config/fields_schema_v2.json @@ -41,6 +41,13 @@ "^.+$": {"type": ["string", "number"]} }, "additionalProperties": false + }, + "external": { + "type": ["boolean", "object"], + "properties": { + "name": {"type": "string"} + }, + "additionalProperties": false } } } diff --git a/compose/project.py b/compose/project.py index f5bb8bebf20..49b54e10fb5 100644 --- a/compose/project.py +++ b/compose/project.py @@ -77,7 +77,9 @@ def from_config(cls, name, config_data, client): project.volumes.append( Volume( client=client, project=name, name=vol_name, - driver=data.get('driver'), driver_opts=data.get('driver_opts') + driver=data.get('driver'), + driver_opts=data.get('driver_opts'), + external=data.get('external', False) ) ) return project @@ -235,11 +237,17 @@ def remove_stopped(self, service_names=None, **options): def initialize_volumes(self): try: for volume in self.volumes: - if volume.is_user_created: + if volume.external: log.info( - 'Found user-created volume "{0}". No new namespaced ' + 'Volume {0} declared as external. No new ' 'volume will be created.'.format(volume.name) ) + if not volume.exists(): + raise ConfigurationError( + 'Volume {0} declared as external, but could not be' + ' found. Please create the volume manually and try' + ' again.'.format(volume.full_name) + ) continue volume.create() except NotFound: diff --git a/compose/volume.py b/compose/volume.py index 9bd98fa5b90..64671ca9bf7 100644 --- a/compose/volume.py +++ b/compose/volume.py @@ -5,12 +5,19 @@ class Volume(object): - def __init__(self, client, project, name, driver=None, driver_opts=None): + def __init__(self, client, project, name, driver=None, driver_opts=None, + external=False): self.client = client self.project = project self.name = name self.driver = driver self.driver_opts = driver_opts + self.external_name = None + if external: + if isinstance(external, dict): + self.external_name = external.get('name') + else: + self.external_name = self.name def create(self): return self.client.create_volume( @@ -23,15 +30,19 @@ def remove(self): def inspect(self): return self.client.inspect_volume(self.full_name) - @property - def is_user_created(self): + def exists(self): try: - self.client.inspect_volume(self.name) + self.inspect() except NotFound: return False - return True + @property + def external(self): + return bool(self.external_name) + @property def full_name(self): + if self.external_name: + return self.external_name return '{0}_{1}'.format(self.project, self.name) diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index d1b1fdf0747..36b736b4239 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -677,7 +677,7 @@ def test_initialize_volumes_updated_driver(self): vol_name ) in str(e.exception) - def test_initialize_volumes_user_created_volumes(self): + def test_initialize_volumes_external_volumes(self): # Use composetest_ prefix so it gets garbage-collected in tearDown() vol_name = 'composetest_{0:x}'.format(random.getrandbits(32)) full_vol_name = 'composetest_{0}'.format(vol_name) @@ -687,7 +687,7 @@ def test_initialize_volumes_user_created_volumes(self): 'name': 'web', 'image': 'busybox:latest', 'command': 'top' - }], volumes={vol_name: {'driver': 'local'}} + }], volumes={vol_name: {'external': True}} ) project = Project.from_config( name='composetest', @@ -697,3 +697,23 @@ def test_initialize_volumes_user_created_volumes(self): with self.assertRaises(NotFound): self.client.inspect_volume(full_vol_name) + + def test_initialize_volumes_inexistent_external_volume(self): + vol_name = '{0:x}'.format(random.getrandbits(32)) + + config_data = config.Config( + version=2, services=[{ + 'name': 'web', + 'image': 'busybox:latest', + 'command': 'top' + }], volumes={vol_name: {'external': True}} + ) + project = Project.from_config( + name='composetest', + config_data=config_data, client=self.client + ) + with self.assertRaises(config.ConfigurationError) as e: + project.initialize_volumes() + assert 'Volume {0} declared as external'.format( + vol_name + ) in str(e.exception) diff --git a/tests/integration/volume_test.py b/tests/integration/volume_test.py index 8bcce0e14a0..fbb4aaa278e 100644 --- a/tests/integration/volume_test.py +++ b/tests/integration/volume_test.py @@ -18,9 +18,10 @@ def tearDown(self): except DockerException: pass - def create_volume(self, name, driver=None, opts=None): + def create_volume(self, name, driver=None, opts=None, external=False): vol = Volume( - self.client, 'composetest', name, driver=driver, driver_opts=opts + self.client, 'composetest', name, driver=driver, driver_opts=opts, + external=external ) self.tmp_volumes.append(vol) return vol @@ -55,12 +56,19 @@ def test_remove_volume(self): volumes = self.client.volumes()['Volumes'] assert len([v for v in volumes if v['Name'] == vol.full_name]) == 0 - def test_is_user_created(self): - vol = Volume(self.client, 'composetest', 'uservolume01') - try: - self.client.create_volume('uservolume01') - assert vol.is_user_created is True - finally: - self.client.remove_volume('uservolume01') - vol2 = Volume(self.client, 'composetest', 'volume01') - assert vol2.is_user_created is False + def test_external_volume(self): + vol = self.create_volume('volume01', external=True) + assert vol.external is True + assert vol.full_name == vol.name + vol.create() + info = vol.inspect() + assert info['Name'] == vol.name + + def test_external_aliased_volume(self): + alias_name = 'alias01' + vol = self.create_volume('volume01', external={'name': alias_name}) + assert vol.external is True + assert vol.full_name == alias_name + vol.create() + info = vol.inspect() + assert info['Name'] == alias_name diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 25483e52626..679125bc915 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -775,6 +775,24 @@ def test_merge_service_dicts_from_files_with_extends_in_override(self): 'extends': {'service': 'foo'} } + def test_external_volume_config(self): + config_details = build_config_details({ + 'version': 2, + 'services': { + 'bogus': {'image': 'busybox'} + }, + 'volumes': { + 'ext': {'external': True}, + 'ext2': {'external': {'name': 'aliased'}} + } + }) + config_result = config.load(config_details) + volumes = config_result.volumes + assert 'ext' in volumes + assert volumes['ext']['external'] is True + assert 'ext2' in volumes + assert volumes['ext2']['external']['name'] == 'aliased' + class PortsTest(unittest.TestCase): INVALID_PORTS_TYPES = [ From f774422d18c32f3d9233e7c697ccf65e30a3fc06 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 12 Jan 2016 16:58:24 -0800 Subject: [PATCH 1681/4072] Test Volume.exists() behavior Signed-off-by: Joffrey F --- tests/integration/volume_test.py | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/tests/integration/volume_test.py b/tests/integration/volume_test.py index fbb4aaa278e..2e65f0be34d 100644 --- a/tests/integration/volume_test.py +++ b/tests/integration/volume_test.py @@ -57,7 +57,7 @@ def test_remove_volume(self): assert len([v for v in volumes if v['Name'] == vol.full_name]) == 0 def test_external_volume(self): - vol = self.create_volume('volume01', external=True) + vol = self.create_volume('composetest_volume_ext', external=True) assert vol.external is True assert vol.full_name == vol.name vol.create() @@ -65,10 +65,28 @@ def test_external_volume(self): assert info['Name'] == vol.name def test_external_aliased_volume(self): - alias_name = 'alias01' + alias_name = 'composetest_alias01' vol = self.create_volume('volume01', external={'name': alias_name}) assert vol.external is True assert vol.full_name == alias_name vol.create() info = vol.inspect() assert info['Name'] == alias_name + + def test_exists(self): + vol = self.create_volume('volume01') + assert vol.exists() is False + vol.create() + assert vol.exists() is True + + def test_exists_external(self): + vol = self.create_volume('volume01', external=True) + assert vol.exists() is False + vol.create() + assert vol.exists() is True + + def test_exists_external_aliased(self): + vol = self.create_volume('volume01', external={'name': 'composetest_alias01'}) + assert vol.exists() is False + vol.create() + assert vol.exists() is True From bf48a781dbc4d82e8b9fa940522b68b78e4c12e3 Mon Sep 17 00:00:00 2001 From: Evgeniy Dobrohvalov Date: Fri, 2 Oct 2015 20:07:44 +0300 Subject: [PATCH 1682/4072] Add flag for stops all containers if any container was stopped. Signed-off-by: Evgeniy Dobrohvalov --- compose/cli/log_printer.py | 5 +++-- compose/cli/main.py | 38 ++++++++++++++++++++-------------- compose/cli/multiplexer.py | 8 +++++-- docs/reference/up.md | 28 ++++++++++++++----------- tests/unit/cli/main_test.py | 4 ++-- tests/unit/multiplexer_test.py | 18 ++++++++++++++++ 6 files changed, 68 insertions(+), 33 deletions(-) diff --git a/compose/cli/log_printer.py b/compose/cli/log_printer.py index 864657a4c68..85fef794f04 100644 --- a/compose/cli/log_printer.py +++ b/compose/cli/log_printer.py @@ -13,10 +13,11 @@ class LogPrinter(object): """Print logs from many containers to a single output stream.""" - def __init__(self, containers, output=sys.stdout, monochrome=False): + def __init__(self, containers, output=sys.stdout, monochrome=False, cascade_stop=False): self.containers = containers self.output = utils.get_output_stream(output) self.monochrome = monochrome + self.cascade_stop = cascade_stop def run(self): if not self.containers: @@ -24,7 +25,7 @@ def run(self): prefix_width = max_name_width(self.containers) generators = list(self._make_log_generators(self.monochrome, prefix_width)) - for line in Multiplexer(generators).loop(): + for line in Multiplexer(generators, cascade_stop=self.cascade_stop).loop(): self.output.write(line) self.output.flush() diff --git a/compose/cli/main.py b/compose/cli/main.py index 7c6d1cb5a3f..a46521f31b8 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -590,25 +590,33 @@ def up(self, project, options): Usage: up [options] [SERVICE...] Options: - -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. - Incompatible with --force-recreate. - --no-build Don't build an image, even if it's missing - -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 + --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) """ 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) detached = options.get('-d') + 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, @@ -619,7 +627,7 @@ def up(self, project, options): ) if not detached: - log_printer = build_log_printer(to_attach, service_names, monochrome) + log_printer = build_log_printer(to_attach, service_names, monochrome, cascade_stop) attach_to_logs(project, log_printer, service_names, timeout) def version(self, project, options): @@ -695,13 +703,13 @@ def remove_container(force=False): sys.exit(exit_code) -def build_log_printer(containers, service_names, monochrome): +def build_log_printer(containers, service_names, monochrome, cascade_stop): if service_names: containers = [ container for container in containers if container.service in service_names ] - return LogPrinter(containers, monochrome=monochrome) + return LogPrinter(containers, monochrome=monochrome, cascade_stop=cascade_stop) def attach_to_logs(project, log_printer, service_names, timeout): diff --git a/compose/cli/multiplexer.py b/compose/cli/multiplexer.py index 5e8d91a47fe..e6e63f24b50 100644 --- a/compose/cli/multiplexer.py +++ b/compose/cli/multiplexer.py @@ -20,8 +20,9 @@ class Multiplexer(object): parallel and yielding results as they come in. """ - def __init__(self, iterators): + def __init__(self, iterators, cascade_stop=False): self.iterators = iterators + self.cascade_stop = cascade_stop self._num_running = len(iterators) self.queue = Queue() @@ -36,7 +37,10 @@ def loop(self): raise exception if item is STOP: - self._num_running -= 1 + if self.cascade_stop is True: + break + else: + self._num_running -= 1 else: yield item except Empty: diff --git a/docs/reference/up.md b/docs/reference/up.md index 966aff1e959..a02358ec786 100644 --- a/docs/reference/up.md +++ b/docs/reference/up.md @@ -15,18 +15,22 @@ parent = "smn_compose_cli" Usage: up [options] [SERVICE...] Options: --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. - Incompatible with --force-recreate. ---no-build Don't build an image, even if it's missing --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 +--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/cli/main_test.py b/tests/unit/cli/main_test.py index f62b2bcc3b7..6f5dd3ca97c 100644 --- a/tests/unit/cli/main_test.py +++ b/tests/unit/cli/main_test.py @@ -36,7 +36,7 @@ def test_build_log_printer(self): mock_container('another', 1), ] service_names = ['web', 'db'] - log_printer = build_log_printer(containers, service_names, True) + log_printer = build_log_printer(containers, service_names, True, False) self.assertEqual(log_printer.containers, containers[:3]) def test_build_log_printer_all_services(self): @@ -46,7 +46,7 @@ def test_build_log_printer_all_services(self): mock_container('other', 1), ] service_names = [] - log_printer = build_log_printer(containers, service_names, True) + log_printer = build_log_printer(containers, service_names, True, False) self.assertEqual(log_printer.containers, containers) def test_attach_to_logs(self): diff --git a/tests/unit/multiplexer_test.py b/tests/unit/multiplexer_test.py index c56ece1bd05..750faad8823 100644 --- a/tests/unit/multiplexer_test.py +++ b/tests/unit/multiplexer_test.py @@ -2,6 +2,7 @@ from __future__ import unicode_literals import unittest +from time import sleep from compose.cli.multiplexer import Multiplexer @@ -46,3 +47,20 @@ def problematic_iterator(): with self.assertRaises(Problem): list(mux.loop()) + + def test_cascade_stop(self): + mux = Multiplexer([ + ((lambda x: sleep(0.01) or x)(x) for x in ['after 0.01 sec T1', + 'after 0.02 sec T1', + 'after 0.03 sec T1']), + ((lambda x: sleep(0.02) or x)(x) for x in ['after 0.02 sec T2', + 'after 0.04 sec T2', + 'after 0.06 sec T2']), + ], cascade_stop=True) + + self.assertEqual( + ['after 0.01 sec T1', + 'after 0.02 sec T1', + 'after 0.02 sec T2', + 'after 0.03 sec T1'], + sorted(list(mux.loop()))) From 8616b2de515f64d0d2541bcc61f8abcac15ac070 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 13 Jan 2016 11:22:57 -0800 Subject: [PATCH 1683/4072] Update error message when external volume is missing Signed-off-by: Joffrey F --- compose/project.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/compose/project.py b/compose/project.py index 49b54e10fb5..08843a6ee25 100644 --- a/compose/project.py +++ b/compose/project.py @@ -238,15 +238,18 @@ def initialize_volumes(self): try: for volume in self.volumes: if volume.external: - log.info( + log.debug( 'Volume {0} declared as external. No new ' 'volume will be created.'.format(volume.name) ) if not volume.exists(): raise ConfigurationError( - 'Volume {0} declared as external, but could not be' - ' found. Please create the volume manually and try' - ' again.'.format(volume.full_name) + '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() From d601199eb5d31da117179c113d77f9d070b3507b Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 13 Jan 2016 12:07:08 -0800 Subject: [PATCH 1684/4072] Normalize external_name Signed-off-by: Joffrey F --- compose/config/config.py | 7 +++++++ compose/project.py | 2 +- compose/volume.py | 9 ++------- tests/integration/project_test.py | 8 ++++++-- tests/integration/volume_test.py | 10 ++++++---- 5 files changed, 22 insertions(+), 14 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 918946b3965..88722318979 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -273,6 +273,13 @@ def load_volumes(config_files): for config_file in config_files: for name, volume_config in config_file.config.get('volumes', {}).items(): volumes.update({name: volume_config}) + external = volume_config.get('external') + if external: + if isinstance(external, dict): + volume_config['external_name'] = external.get('name') + else: + volume_config['external_name'] = name + return volumes diff --git a/compose/project.py b/compose/project.py index 08843a6ee25..e882713c2cc 100644 --- a/compose/project.py +++ b/compose/project.py @@ -79,7 +79,7 @@ def from_config(cls, name, config_data, client): client=client, project=name, name=vol_name, driver=data.get('driver'), driver_opts=data.get('driver_opts'), - external=data.get('external', False) + external_name=data.get('external_name') ) ) return project diff --git a/compose/volume.py b/compose/volume.py index 64671ca9bf7..b78aa029f9e 100644 --- a/compose/volume.py +++ b/compose/volume.py @@ -6,18 +6,13 @@ class Volume(object): def __init__(self, client, project, name, driver=None, driver_opts=None, - external=False): + external_name=None): self.client = client self.project = project self.name = name self.driver = driver self.driver_opts = driver_opts - self.external_name = None - if external: - if isinstance(external, dict): - self.external_name = external.get('name') - else: - self.external_name = self.name + self.external_name = external_name def create(self): return self.client.create_volume( diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 36b736b4239..467eb786153 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -687,7 +687,9 @@ def test_initialize_volumes_external_volumes(self): 'name': 'web', 'image': 'busybox:latest', 'command': 'top' - }], volumes={vol_name: {'external': True}} + }], volumes={ + vol_name: {'external': True, 'external_name': vol_name} + } ) project = Project.from_config( name='composetest', @@ -706,7 +708,9 @@ def test_initialize_volumes_inexistent_external_volume(self): 'name': 'web', 'image': 'busybox:latest', 'command': 'top' - }], volumes={vol_name: {'external': True}} + }], volumes={ + vol_name: {'external': True, 'external_name': vol_name} + } ) project = Project.from_config( name='composetest', diff --git a/tests/integration/volume_test.py b/tests/integration/volume_test.py index 2e65f0be34d..706179ed2f8 100644 --- a/tests/integration/volume_test.py +++ b/tests/integration/volume_test.py @@ -18,10 +18,12 @@ def tearDown(self): except DockerException: pass - def create_volume(self, name, driver=None, opts=None, external=False): + def create_volume(self, name, driver=None, opts=None, external=None): + if external and isinstance(external, bool): + external = name vol = Volume( self.client, 'composetest', name, driver=driver, driver_opts=opts, - external=external + external_name=external ) self.tmp_volumes.append(vol) return vol @@ -66,7 +68,7 @@ def test_external_volume(self): def test_external_aliased_volume(self): alias_name = 'composetest_alias01' - vol = self.create_volume('volume01', external={'name': alias_name}) + vol = self.create_volume('volume01', external=alias_name) assert vol.external is True assert vol.full_name == alias_name vol.create() @@ -86,7 +88,7 @@ def test_exists_external(self): assert vol.exists() is True def test_exists_external_aliased(self): - vol = self.create_volume('volume01', external={'name': 'composetest_alias01'}) + vol = self.create_volume('volume01', external='composetest_alias01') assert vol.exists() is False vol.create() assert vol.exists() is True From 85a210d9eb4d3f7f0de537469680724d94a8abe4 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 13 Jan 2016 20:34:07 +0000 Subject: [PATCH 1685/4072] Increase timeout on signal-handling tests 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 eab4bdae978..2f6dfba478d 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -454,14 +454,14 @@ def test_up_handles_sigint(self): wait_on_condition(ContainerCountCondition(self.project, 2)) os.kill(proc.pid, signal.SIGINT) - wait_on_condition(ContainerCountCondition(self.project, 0)) + wait_on_condition(ContainerCountCondition(self.project, 0), timeout=30) 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)) + wait_on_condition(ContainerCountCondition(self.project, 0), timeout=30) def test_run_service_without_links(self): self.base_dir = 'tests/fixtures/links-composefile' From e76b2679eb8f3117c7c0e0444e70bfc4d7fa502d Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 13 Jan 2016 13:52:59 -0800 Subject: [PATCH 1686/4072] external volume disallows other config keys Signed-off-by: Joffrey F --- compose/config/fields_schema_v2.json | 42 +++++++++++++++++----------- tests/unit/config/config_test.py | 13 +++++++++ 2 files changed, 38 insertions(+), 17 deletions(-) diff --git a/compose/config/fields_schema_v2.json b/compose/config/fields_schema_v2.json index 61bd7628b8b..310dbf96160 100644 --- a/compose/config/fields_schema_v2.json +++ b/compose/config/fields_schema_v2.json @@ -32,24 +32,32 @@ "definitions": { "volume": { "id": "#/definitions/volume", - "type": "object", - "properties": { - "driver": {"type": "string"}, - "driver_opts": { - "type": "object", - "patternProperties": { - "^.+$": {"type": ["string", "number"]} - }, - "additionalProperties": false + "oneOf": [{ + "type": "object", + "properties": { + "driver": {"type": "string"}, + "driver_opts": { + "type": "object", + "patternProperties": { + "^.+$": {"type": ["string", "number"]} + }, + "additionalProperties": false + } }, - "external": { - "type": ["boolean", "object"], - "properties": { - "name": {"type": "string"} - }, - "additionalProperties": false - } - } + "additionalProperties": false + }, { + "type": "object", + "properties": { + "external": { + "type": ["boolean", "object"], + "properties": { + "name": {"type": "string"} + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }] } }, "additionalProperties": false diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 679125bc915..b17598804e9 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -793,6 +793,19 @@ def test_external_volume_config(self): assert 'ext2' in volumes assert volumes['ext2']['external']['name'] == 'aliased' + def test_external_volume_invalid_config(self): + config_details = build_config_details({ + 'version': 2, + 'services': { + 'bogus': {'image': 'busybox'} + }, + 'volumes': { + 'ext': {'external': True, 'driver': 'foo'} + } + }) + with self.assertRaises(ConfigurationError): + config.load(config_details) + class PortsTest(unittest.TestCase): INVALID_PORTS_TYPES = [ From b6618815b91a8e01f87c691d67c1ea9839377e69 Mon Sep 17 00:00:00 2001 From: Jonathan Stewmon Date: Wed, 13 Jan 2016 16:18:00 -0600 Subject: [PATCH 1687/4072] update docker-py requirement to use master branch Signed-off-by: Jonathan Stewmon --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 8c6d5f3ad40..313c6b7acc3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,8 @@ PyYAML==3.11 -docker-py==1.6.0 dockerpty==0.3.4 docopt==0.6.1 enum34==1.0.4 +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 59a4ab9634ab227f6bd8a5f2e579661c64aa6813 Mon Sep 17 00:00:00 2001 From: "Michael A. Smith" Date: Tue, 12 Jan 2016 13:49:14 -0500 Subject: [PATCH 1688/4072] Allow Entrypoints to be Lists Signed-off-by: Michael A. Smith --- compose/config/service_schema_v1.json | 7 ++++++- compose/config/service_schema_v2.json | 7 ++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/compose/config/service_schema_v1.json b/compose/config/service_schema_v1.json index d51c7f731b1..cee6ad740d6 100644 --- a/compose/config/service_schema_v1.json +++ b/compose/config/service_schema_v1.json @@ -34,7 +34,12 @@ "dns_search": {"$ref": "#/definitions/string_or_list"}, "dockerfile": {"type": "string"}, "domainname": {"type": "string"}, - "entrypoint": {"$ref": "#/definitions/string_or_list"}, + "entrypoint": { + "oneOf": [ + {"type": "string"}, + {"type": "array", "items": {"type": "string"}} + ] + }, "env_file": {"$ref": "#/definitions/string_or_list"}, "environment": {"$ref": "#/definitions/list_or_dict"}, diff --git a/compose/config/service_schema_v2.json b/compose/config/service_schema_v2.json index 47b195fcc03..17a5387fe28 100644 --- a/compose/config/service_schema_v2.json +++ b/compose/config/service_schema_v2.json @@ -34,7 +34,12 @@ "dns_search": {"$ref": "#/definitions/string_or_list"}, "dockerfile": {"type": "string"}, "domainname": {"type": "string"}, - "entrypoint": {"$ref": "#/definitions/string_or_list"}, + "entrypoint": { + "oneOf": [ + {"type": "string"}, + {"type": "array", "items": {"type": "string"}} + ] + }, "env_file": {"$ref": "#/definitions/string_or_list"}, "environment": {"$ref": "#/definitions/list_or_dict"}, From 9bff308251d1629a9f7107adc5b25f71a3844e12 Mon Sep 17 00:00:00 2001 From: "Michael A. Smith" Date: Fri, 8 Jan 2016 15:46:49 -0500 Subject: [PATCH 1689/4072] Document Entrypoints and Commands as Lists Signed-off-by: Michael A. Smith --- docs/compose-file.md | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/docs/compose-file.md b/docs/compose-file.md index 40a3cf02335..4759cde0563 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -120,6 +120,10 @@ 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. @@ -174,6 +178,22 @@ specified using the `build` key. Using `dockerfile` together with `image` is not allowed. Attempting to do so results in an error. +### 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. @@ -451,7 +471,7 @@ specifying read-only access(``ro``) or read-write(``rw``). - container_name - service_name:rw -### cpu\_shares, cpu\_quota, cpuset, domainname, entrypoint, 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, 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. @@ -460,7 +480,6 @@ Each of these is a single value, analogous to its cpu_quota: 50000 cpuset: 0,1 - entrypoint: /code/entrypoint.sh user: postgresql working_dir: /code From 6877c6ca06ef605e1227b79092cf8054996df27a Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 13 Jan 2016 20:51:39 -0500 Subject: [PATCH 1690/4072] Fix flaky multiplex test. Signed-off-by: Daniel Nephin --- tests/unit/multiplexer_test.py | 23 +++++++++-------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/tests/unit/multiplexer_test.py b/tests/unit/multiplexer_test.py index 750faad8823..737ba25d6de 100644 --- a/tests/unit/multiplexer_test.py +++ b/tests/unit/multiplexer_test.py @@ -49,18 +49,13 @@ def problematic_iterator(): list(mux.loop()) def test_cascade_stop(self): - mux = Multiplexer([ - ((lambda x: sleep(0.01) or x)(x) for x in ['after 0.01 sec T1', - 'after 0.02 sec T1', - 'after 0.03 sec T1']), - ((lambda x: sleep(0.02) or x)(x) for x in ['after 0.02 sec T2', - 'after 0.04 sec T2', - 'after 0.06 sec T2']), - ], cascade_stop=True) + def fast_stream(): + for num in range(3): + yield "stream1 %s" % num - self.assertEqual( - ['after 0.01 sec T1', - 'after 0.02 sec T1', - 'after 0.02 sec T2', - 'after 0.03 sec T1'], - sorted(list(mux.loop()))) + 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()) From e41e6c1241a7e17fddd5c3abf536f462ed5ab1c4 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 13 Jan 2016 18:22:29 -0800 Subject: [PATCH 1691/4072] Properly validate volume definition Test valid empty volume definitions Signed-off-by: Joffrey F --- compose/config/config.py | 12 ++++++++++ compose/config/fields_schema_v2.json | 34 +++++++++++----------------- tests/unit/config/config_test.py | 17 ++++++++++++++ 3 files changed, 42 insertions(+), 21 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index d2b75e7171c..17fd2db4956 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -272,9 +272,21 @@ def load_volumes(config_files): volumes = {} for config_file in config_files: for name, volume_config in config_file.config.get('volumes', {}).items(): + if volume_config is None: + volumes.update({name: {}}) + continue + volumes.update({name: volume_config}) external = volume_config.get('external') if external: + if len(volume_config.keys()) > 1: + raise ConfigurationError( + 'Volume {0} declared as external but specifies' + ' additional attributes ({1}). '.format( + name, + ', '.join([k for k in volume_config.keys() if k != 'external']) + ) + ) if isinstance(external, dict): volume_config['external_name'] = external.get('name') else: diff --git a/compose/config/fields_schema_v2.json b/compose/config/fields_schema_v2.json index 310dbf96160..25126ed1618 100644 --- a/compose/config/fields_schema_v2.json +++ b/compose/config/fields_schema_v2.json @@ -32,32 +32,24 @@ "definitions": { "volume": { "id": "#/definitions/volume", - "oneOf": [{ - "type": "object", - "properties": { - "driver": {"type": "string"}, - "driver_opts": { - "type": "object", - "patternProperties": { - "^.+$": {"type": ["string", "number"]} - }, - "additionalProperties": false + "type": ["object", "null"], + "properties": { + "driver": {"type": "string"}, + "driver_opts": { + "type": "object", + "patternProperties": { + "^.+$": {"type": ["string", "number"]} } }, - "additionalProperties": false - }, { - "type": "object", - "properties": { - "external": { - "type": ["boolean", "object"], - "properties": { - "name": {"type": "string"} - }, - "additionalProperties": false + "external": { + "type": ["boolean", "object"], + "properties": { + "name": {"type": "string"} } }, "additionalProperties": false - }] + }, + "additionalProperties": false } }, "additionalProperties": false diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 05fea27d3ac..77c55ad9046 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -112,6 +112,23 @@ def test_load_v2(self): } }) + def test_named_volume_config_empty(self): + config_details = build_config_details({ + 'version': 2, + 'services': { + 'simple': {'image': 'busybox'} + }, + 'volumes': { + 'simple': None, + 'other': {}, + } + }) + config_result = config.load(config_details) + volumes = config_result.volumes + assert 'simple' in volumes + assert volumes['simple'] == {} + assert volumes['other'] == {} + def test_load_service_with_name_version(self): config_data = config.load( build_config_details({ From c8ed1568063d67595a0a38522d2d9355c637b899 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 27 Nov 2015 12:19:43 -0500 Subject: [PATCH 1692/4072] Adding docker-compose down Signed-off-by: Daniel Nephin --- compose/cli/main.py | 27 +++++++++++++++++++++++++++ compose/project.py | 20 ++++++++++++++++++++ compose/service.py | 21 +++++++++++++++++++++ tests/acceptance/cli_test.py | 8 ++++++++ tests/unit/service_test.py | 34 ++++++++++++++++++++++++++++++++++ 5 files changed, 110 insertions(+) diff --git a/compose/cli/main.py b/compose/cli/main.py index a46521f31b8..8c1a55ffeb4 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -25,6 +25,7 @@ from ..project import NoSuchService 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 @@ -129,6 +130,7 @@ class TopLevelCommand(DocoptCommand): 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 @@ -242,6 +244,22 @@ def create(self, project, options): do_build=not options['--no-build'] ) + def down(self, project, options): + """ + Stop containers and remove containers, networks, volumes, and images + created by `up`. + + 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 + """ + image_type = image_type_from_opt('--rmi', options['--rmi']) + project.down(image_type, options['--volumes']) + def events(self, project, options): """ Receive real time events from containers. @@ -660,6 +678,15 @@ def convergence_strategy_from_opts(options): return ConvergenceStrategy.changed +def image_type_from_opt(flag, value): + if not value: + return ImageType.none + try: + return ImageType[value] + except KeyError: + raise UserError("%s flag must be one of: all, local" % flag) + + def run_one_off_container(container_options, project, service, options): if not options['--no-deps']: deps = service.get_linked_service_names() diff --git a/compose/project.py b/compose/project.py index e882713c2cc..0774a4002a9 100644 --- a/compose/project.py +++ b/compose/project.py @@ -270,6 +270,24 @@ def initialize_volumes(self): ) ) + def down(self, remove_image_type, include_volumes): + self.stop() + self.remove_stopped() + self.remove_network() + + if include_volumes: + self.remove_volumes() + + self.remove_images(remove_image_type) + + def remove_images(self, remove_image_type): + for service in self.get_services(): + service.remove_image(remove_image_type) + + def remove_volumes(self): + for volume in self.volumes: + volume.remove() + def restart(self, service_names=None, **options): containers = self.containers(service_names, stopped=True) parallel.parallel_restart(containers, options) @@ -419,6 +437,8 @@ def ensure_network_exists(self): self.client.create_network(self.default_network_name, driver=self.network_driver) def remove_network(self): + if not self.use_networking: + return network = self.get_network() if network: self.client.remove_network(network['Id']) diff --git a/compose/service.py b/compose/service.py index d5c36f1ad68..1972b1b1493 100644 --- a/compose/service.py +++ b/compose/service.py @@ -98,6 +98,14 @@ def allows_recreate(self): return self is not type(self).never +@enum.unique +class ImageType(enum.Enum): + """Enumeration for the types of images known to compose.""" + none = 0 + local = 1 + all = 2 + + class Service(object): def __init__( self, @@ -672,6 +680,19 @@ def labels(self, one_off=False): def custom_container_name(self): return self.options.get('container_name') + def remove_image(self, image_type): + if not image_type or image_type == ImageType.none: + return False + if image_type == ImageType.local and self.options.get('image'): + return False + + try: + self.client.remove_image(self.image_name) + return True + except APIError as e: + log.error("Failed to remove image for service %s: %s", self.name, e) + return False + def specifies_host_port(self): def has_host_port(binding): _, external_bindings = split_port(binding) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 8b5892ab165..a06f14dccae 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -314,6 +314,14 @@ def test_create_with_force_recreate_and_no_recreate(self): ['create', '--force-recreate', '--no-recreate'], returncode=1) + def test_down_invalid_rmi_flag(self): + result = self.dispatch(['down', '--rmi', 'bogus'], returncode=1) + assert '--rmi flag must be' in result.stderr + + def test_down(self): + result = self.dispatch(['down']) + # TODO: + def test_up_detached(self): self.dispatch(['up', '-d']) service = self.project.get_service('simple') diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 63cf658ed93..fa58929b871 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -2,6 +2,7 @@ from __future__ import unicode_literals import docker +from docker.errors import APIError from .. import mock from .. import unittest @@ -16,6 +17,7 @@ from compose.service import build_volume_binding from compose.service import ContainerNet 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 @@ -422,6 +424,38 @@ def test_config_dict_with_net_from_container(self): } self.assertEqual(config_dict, expected) + def test_remove_image_none(self): + web = Service('web', image='example', client=self.mock_client) + assert not web.remove_image(ImageType.none) + assert not self.mock_client.remove_image.called + + def test_remove_image_local_with_image_name_doesnt_remove(self): + web = Service('web', image='example', client=self.mock_client) + assert not web.remove_image(ImageType.local) + assert not self.mock_client.remove_image.called + + def test_remove_image_local_without_image_name_does_remove(self): + web = Service('web', build='.', client=self.mock_client) + assert web.remove_image(ImageType.local) + self.mock_client.remove_image.assert_called_once_with(web.image_name) + + def test_remove_image_all_does_remove(self): + web = Service('web', image='example', client=self.mock_client) + assert web.remove_image(ImageType.all) + self.mock_client.remove_image.assert_called_once_with(web.image_name) + + def test_remove_image_with_error(self): + self.mock_client.remove_image.side_effect = error = APIError( + message="testing", + response={}, + explanation="Boom") + + web = Service('web', image='example', client=self.mock_client) + with mock.patch('compose.service.log', autospec=True) as mock_log: + assert not web.remove_image(ImageType.all) + mock_log.error.assert_called_once_with( + "Failed to remove image for service %s: %s", web.name, error) + def test_specifies_host_port_with_no_ports(self): service = Service( 'foo', From c64af0a459ed0a96c38547451a0b5da69c3da079 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 12 Jan 2016 17:33:17 -0500 Subject: [PATCH 1693/4072] Add an acceptance test and docs for the down subcommand Signed-off-by: Daniel Nephin --- compose/cli/main.py | 2 +- compose/parallel.py | 2 +- compose/project.py | 3 ++- compose/service.py | 1 + compose/volume.py | 9 ++++++++ docs/index.md | 3 +-- docs/reference/down.md | 26 ++++++++++++++++++++++ docs/reference/index.md | 5 +++++ tests/acceptance/cli_test.py | 12 ++++++++-- tests/fixtures/shutdown/Dockerfile | 4 ++++ tests/fixtures/shutdown/docker-compose.yml | 10 +++++++++ tests/integration/service_test.py | 12 +++++----- tests/unit/volume_test.py | 26 ++++++++++++++++++++++ 13 files changed, 102 insertions(+), 13 deletions(-) create mode 100644 docs/reference/down.md create mode 100644 tests/fixtures/shutdown/Dockerfile create mode 100644 tests/fixtures/shutdown/docker-compose.yml create mode 100644 tests/unit/volume_test.py diff --git a/compose/cli/main.py b/compose/cli/main.py index 8c1a55ffeb4..9957d391f03 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -247,7 +247,7 @@ def create(self, project, options): def down(self, project, options): """ Stop containers and remove containers, networks, volumes, and images - created by `up`. + created by `up`. Only containers and networks are removed by default. Usage: down [options] diff --git a/compose/parallel.py b/compose/parallel.py index 2735a397f3e..b8415e5e555 100644 --- a/compose/parallel.py +++ b/compose/parallel.py @@ -24,7 +24,7 @@ def parallel_execute(objects, func, index_func, msg): object we give it. """ objects = list(objects) - stream = get_output_stream(sys.stdout) + stream = get_output_stream(sys.stderr) writer = ParallelStreamWriter(stream, msg) for obj in objects: diff --git a/compose/project.py b/compose/project.py index 0774a4002a9..3ba9532f07e 100644 --- a/compose/project.py +++ b/compose/project.py @@ -272,7 +272,7 @@ def initialize_volumes(self): def down(self, remove_image_type, include_volumes): self.stop() - self.remove_stopped() + self.remove_stopped(v=include_volumes) self.remove_network() if include_volumes: @@ -441,6 +441,7 @@ def remove_network(self): return network = self.get_network() if network: + log.info("Removing network %s", self.default_network_name) self.client.remove_network(network['Id']) def uses_default_network(self): diff --git a/compose/service.py b/compose/service.py index 1972b1b1493..1c848ca39c0 100644 --- a/compose/service.py +++ b/compose/service.py @@ -686,6 +686,7 @@ def remove_image(self, image_type): if image_type == ImageType.local and self.options.get('image'): return False + log.info("Removing image %s", self.image_name) try: self.client.remove_image(self.image_name) return True diff --git a/compose/volume.py b/compose/volume.py index b78aa029f9e..469e406a871 100644 --- a/compose/volume.py +++ b/compose/volume.py @@ -1,9 +1,14 @@ from __future__ import absolute_import from __future__ import unicode_literals +import logging + from docker.errors import NotFound +log = logging.getLogger(__name__) + + class Volume(object): def __init__(self, client, project, name, driver=None, driver_opts=None, external_name=None): @@ -20,6 +25,10 @@ def create(self): ) def remove(self): + if self.external: + log.info("Volume %s is external, skipping", self.full_name) + return + log.info("Removing volume %s", self.full_name) return self.client.remove_volume(self.full_name) def inspect(self): diff --git a/docs/index.md b/docs/index.md index 6e8f2090c6a..887df99d6a9 100644 --- a/docs/index.md +++ b/docs/index.md @@ -154,8 +154,7 @@ environments in just a few commands: $ docker-compose up -d $ ./run_tests - $ docker-compose stop - $ docker-compose rm -f + $ docker-compose down ### Single host deployments diff --git a/docs/reference/down.md b/docs/reference/down.md new file mode 100644 index 00000000000..428e4e58a0f --- /dev/null +++ b/docs/reference/down.md @@ -0,0 +1,26 @@ + + +# 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 + +``` diff --git a/docs/reference/index.md b/docs/reference/index.md index 1635b60c735..5406b9c7d24 100644 --- a/docs/reference/index.md +++ b/docs/reference/index.md @@ -14,10 +14,14 @@ parent = "smn_compose_ref" 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. * [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) @@ -27,6 +31,7 @@ The following pages describe the usage information for the [docker-compose](dock * [scale](scale.md) * [start](start.md) * [stop](stop.md) +* [unpause](unpause.md) * [up](up.md) ## Where to go next diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index a06f14dccae..cb04918bd65 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -319,8 +319,16 @@ def test_down_invalid_rmi_flag(self): assert '--rmi flag must be' in result.stderr def test_down(self): - result = self.dispatch(['down']) - # TODO: + self.base_dir = 'tests/fixtures/shutdown' + self.dispatch(['up', '-d']) + wait_on_condition(ContainerCountCondition(self.project, 1)) + + result = self.dispatch(['down', '--rmi=local', '--volumes']) + assert 'Stopping shutdown_web_1' in result.stderr + assert 'Removing shutdown_web_1' in result.stderr + assert 'Removing volume shutdown_data' in result.stderr + assert 'Removing image shutdown_web' in result.stderr + assert 'Removing network shutdown_default' in result.stderr def test_up_detached(self): self.dispatch(['up', '-d']) diff --git a/tests/fixtures/shutdown/Dockerfile b/tests/fixtures/shutdown/Dockerfile new file mode 100644 index 00000000000..51ed0d90726 --- /dev/null +++ b/tests/fixtures/shutdown/Dockerfile @@ -0,0 +1,4 @@ + +FROM busybox:latest +RUN echo something +CMD top diff --git a/tests/fixtures/shutdown/docker-compose.yml b/tests/fixtures/shutdown/docker-compose.yml new file mode 100644 index 00000000000..c83c3d6370a --- /dev/null +++ b/tests/fixtures/shutdown/docker-compose.yml @@ -0,0 +1,10 @@ + +version: 2 + +volumes: + data: + driver: local + +services: + web: + build: . diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 4818e47aa8a..314076cdff7 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -616,13 +616,13 @@ def test_scale_with_stopped_containers(self): service.create_container(number=next_number) service.create_container(number=next_number + 1) - with mock.patch('sys.stdout', new_callable=StringIO) as mock_stdout: + with mock.patch('sys.stderr', new_callable=StringIO) as mock_stderr: service.scale(2) for container in service.containers(): self.assertTrue(container.is_running) self.assertTrue(container.number in valid_numbers) - captured_output = mock_stdout.getvalue() + captured_output = mock_stderr.getvalue() self.assertNotIn('Creating', captured_output) self.assertIn('Starting', captured_output) @@ -639,14 +639,14 @@ def test_scale_with_stopped_containers_and_needing_creation(self): for container in service.containers(): self.assertFalse(container.is_running) - with mock.patch('sys.stdout', new_callable=StringIO) as mock_stdout: + with mock.patch('sys.stderr', new_callable=StringIO) as mock_stderr: service.scale(2) self.assertEqual(len(service.containers()), 2) for container in service.containers(): self.assertTrue(container.is_running) - captured_output = mock_stdout.getvalue() + captured_output = mock_stderr.getvalue() self.assertIn('Creating', captured_output) self.assertIn('Starting', captured_output) @@ -665,12 +665,12 @@ def test_scale_with_api_error(self): response={}, explanation="Boom")): - with mock.patch('sys.stdout', new_callable=StringIO) as mock_stdout: + with mock.patch('sys.stderr', new_callable=StringIO) as mock_stderr: service.scale(3) self.assertEqual(len(service.containers()), 1) self.assertTrue(service.containers()[0].is_running) - self.assertIn("ERROR: for 2 Boom", mock_stdout.getvalue()) + self.assertIn("ERROR: for 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 diff --git a/tests/unit/volume_test.py b/tests/unit/volume_test.py new file mode 100644 index 00000000000..d7ad0792879 --- /dev/null +++ b/tests/unit/volume_test.py @@ -0,0 +1,26 @@ +from __future__ import absolute_import +from __future__ import unicode_literals + +import docker +import pytest + +from compose import volume +from tests import mock + + +@pytest.fixture +def mock_client(): + return mock.create_autospec(docker.Client) + + +class TestVolume(object): + + def test_remove_local_volume(self, mock_client): + vol = volume.Volume(mock_client, 'foo', 'project') + vol.remove() + mock_client.remove_volume.assert_called_once_with('foo_project') + + def test_remove_external_volume(self, mock_client): + vol = volume.Volume(mock_client, 'foo', 'project', external_name='data') + vol.remove() + assert not mock_client.remove_volume.called From de949284f5b126d07e42c2322f1c80c62f9c68a5 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 13 Jan 2016 12:55:59 -0500 Subject: [PATCH 1694/4072] Refactor config loading for handling volumes_from in v2. Signed-off-by: Daniel Nephin --- compose/config/config.py | 60 +++++++++++++++++----------------------- 1 file changed, 25 insertions(+), 35 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 7eb2ce2c2db..82ff40aa572 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -122,6 +122,9 @@ def from_filename(cls, filename): def get_service_dicts(self, version): return self.config if version == 1 else self.config.get('services', {}) + def get_volumes(self, version): + return {} if version == 1 else self.config.get('volumes', {}) + class Config(namedtuple('_Config', 'version services volumes')): """ @@ -243,41 +246,29 @@ def load(config_details): if version not in COMPOSEFILE_VERSIONS: raise ConfigurationError('Invalid config version provided: {0}'.format(version)) - processed_files = [] - for config_file in config_details.config_files: - processed_files.append( - process_config_file(config_file, version=version) - ) + processed_files = [ + process_config_file(config_file, version=version) + for config_file in config_details.config_files + ] config_details = config_details._replace(config_files=processed_files) - if version == 1: - service_dicts = load_services( - config_details.working_dir, config_details.config_files, - version - ) - volumes = {} - elif version == 2: - config_files = [ - ConfigFile(f.filename, f.config.get('services', {})) - for f in config_details.config_files - ] - service_dicts = load_services( - config_details.working_dir, config_files, version - ) - volumes = load_volumes(config_details.config_files) - + volumes = load_volumes(config_details.config_files) + service_dicts = load_services( + config_details.working_dir, + config_details.config_files[0].filename, + [file.get_service_dicts(version) for file in config_details.config_files], + version) return Config(version, service_dicts, volumes) def load_volumes(config_files): volumes = {} for config_file in config_files: - for name, volume_config in config_file.config.get('volumes', {}).items(): - if volume_config is None: - volumes.update({name: {}}) + for name, volume_config in config_file.get_volumes().items(): + volumes[name] = volume_config or {} + if not volume_config: continue - volumes.update({name: volume_config}) external = volume_config.get('external') if external: if len(volume_config.keys()) > 1: @@ -296,8 +287,8 @@ def load_volumes(config_files): return volumes -def load_services(working_dir, config_files, version): - def build_service(filename, service_name, service_dict): +def load_services(working_dir, filename, service_configs, version): + def build_service(service_name, service_dict): service_config = ServiceConfig.with_abs_paths( working_dir, filename, @@ -314,10 +305,10 @@ def build_service(filename, service_name, service_dict): service_dict['name'] = service_config.name return service_dict - def build_services(config_file): + def build_services(service_config): return sort_service_dicts([ - build_service(config_file.filename, name, service_dict) - for name, service_dict in config_file.config.items() + build_service(name, service_dict) + for name, service_dict in service_config.items() ]) def merge_services(base, override): @@ -330,12 +321,11 @@ def merge_services(base, override): for name in all_service_names } - config_file = config_files[0] - for next_file in config_files[1:]: - config = merge_services(config_file.config, next_file.config) - config_file = config_file._replace(config=config) + service_config = service_configs[0] + for next_config in service_configs[1:]: + service_config = merge_services(service_config, next_config) - return build_services(config_file) + return build_services(service_config) def process_config_file(config_file, version, service_name=None): From c3968a439fa5d00ecedeb37963f5e8c0763ef6d1 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 13 Jan 2016 13:28:39 -0500 Subject: [PATCH 1695/4072] Refactor config loading to move version check into ConfigFile. Adds the cached_property package. Signed-off-by: Daniel Nephin --- compose/config/config.py | 92 +++++++++++++++++++----------------- compose/config/validation.py | 11 ++--- requirements.txt | 1 + setup.py | 1 + 4 files changed, 55 insertions(+), 50 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 82ff40aa572..203c4e9e32c 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -10,6 +10,7 @@ import six import yaml +from cached_property import cached_property from ..const import COMPOSEFILE_VERSIONS from .errors import CircularReference @@ -119,11 +120,23 @@ class ConfigFile(namedtuple('_ConfigFile', 'filename config')): def from_filename(cls, filename): return cls(filename, load_yaml(filename)) - def get_service_dicts(self, version): - return self.config if version == 1 else self.config.get('services', {}) + @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 " + "version is the name of a service, and defaulting to " + "Compose file version 1".format(self.filename)) + return 1 + return version + + def get_service_dicts(self): + return self.config if self.version == 1 else self.config.get('services', {}) - def get_volumes(self, version): - return {} if version == 1 else self.config.get('volumes', {}) + def get_volumes(self): + return {} if self.version == 1 else self.config.get('volumes', {}) class Config(namedtuple('_Config', 'version services volumes')): @@ -168,32 +181,24 @@ def find(base_dir, filenames): [ConfigFile.from_filename(f) for f in filenames]) -def get_config_version(config_details): - def get_version(config): - if config.config is None: - return 1 - version = config.config.get('version', 1) - if isinstance(version, dict): - # in that case 'version' is probably a service name, so assume - # this is a legacy (version=1) file - version = 1 - return version - +def validate_config_version(config_details): main_file = config_details.config_files[0] validate_top_level_object(main_file) - version = get_version(main_file) for next_file in config_details.config_files[1:]: validate_top_level_object(next_file) - next_file_version = get_version(next_file) - if version != next_file_version and next_file_version is not None: + if main_file.version != next_file.version: raise ConfigurationError( - "Version mismatch: main file {0} specifies version {1} but " + "Version mismatch: file {0} specifies version {1} but " "extension file {2} uses version {3}".format( - main_file.filename, version, next_file.filename, next_file_version - ) - ) - return version + main_file.filename, + main_file.version, + 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): @@ -242,23 +247,22 @@ def load(config_details): Return a fully interpolated, extended and validated configuration. """ - version = get_config_version(config_details) - if version not in COMPOSEFILE_VERSIONS: - raise ConfigurationError('Invalid config version provided: {0}'.format(version)) + validate_config_version(config_details) processed_files = [ - process_config_file(config_file, version=version) + process_config_file(config_file) for config_file in config_details.config_files ] config_details = config_details._replace(config_files=processed_files) + main_file = config_details.config_files[0] volumes = load_volumes(config_details.config_files) service_dicts = load_services( config_details.working_dir, - config_details.config_files[0].filename, - [file.get_service_dicts(version) for file in config_details.config_files], - version) - return Config(version, service_dicts, volumes) + main_file.filename, + [file.get_service_dicts() for file in config_details.config_files], + main_file.version) + return Config(main_file.version, service_dicts, volumes) def load_volumes(config_files): @@ -328,27 +332,28 @@ def merge_services(base, override): return build_services(service_config) -def process_config_file(config_file, version, service_name=None): - service_dicts = config_file.get_service_dicts(version) - validate_top_level_service_objects( - config_file.filename, service_dicts - ) +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) + + # TODO: interpolate config in volumes/network sections as well interpolated_config = interpolate_environment_variables(service_dicts) - if version == 2: + + if config_file.version == 2: processed_config = dict(config_file.config) processed_config.update({'services': interpolated_config}) - if version == 1: + if config_file.version == 1: processed_config = interpolated_config - validate_against_fields_schema( - processed_config, config_file.filename, version - ) + + config_file = config_file._replace(config=processed_config) + validate_against_fields_schema(config_file) if service_name and service_name not in processed_config: raise ConfigurationError( "Cannot extend service '{}' in {}: Service not found".format( service_name, config_file.filename)) - return config_file._replace(config=processed_config) + return config_file class ServiceExtendsResolver(object): @@ -385,8 +390,7 @@ def validate_and_construct_extends(self): extended_file = process_config_file( ConfigFile.from_filename(config_path), - version=self.version, service_name=service_name - ) + service_name=service_name) service_config = extended_file.config[service_name] return config_path, service_config, service_name diff --git a/compose/config/validation.py b/compose/config/validation.py index 74ae5c9c8bf..0bf75691547 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -105,8 +105,7 @@ def validate_top_level_service_objects(filename, service_dicts): def validate_top_level_object(config_file): if not isinstance(config_file.config, dict): raise ConfigurationError( - "Top level object in '{}' needs to be an object not '{}'. Check " - "that you have defined a service at the top level.".format( + "Top level object in '{}' needs to be an object not '{}'.".format( config_file.filename, type(config_file.config))) @@ -291,13 +290,13 @@ def format_error_message(error, service_name): return '\n'.join(format_error_message(error, service_name) for error in errors) -def validate_against_fields_schema(config, filename, version): - schema_filename = "fields_schema_v{0}.json".format(version) +def validate_against_fields_schema(config_file): + schema_filename = "fields_schema_v{0}.json".format(config_file.version) _validate_against_schema( - config, + config_file.config, schema_filename, format_checker=["ports", "expose", "bool-value-in-mapping"], - filename=filename) + filename=config_file.filename) def validate_against_service_schema(config, service_name, version): diff --git a/requirements.txt b/requirements.txt index 313c6b7acc3..563baa10343 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ PyYAML==3.11 +cached-property==1.2.0 dockerpty==0.3.4 docopt==0.6.1 enum34==1.0.4 diff --git a/setup.py b/setup.py index bd6f201d4af..f159d2b1073 100644 --- a/setup.py +++ b/setup.py @@ -28,6 +28,7 @@ def find_version(*file_paths): install_requires = [ + 'cached-property >= 1.2.0', 'docopt >= 0.6.1, < 0.7', 'PyYAML >= 3.10, < 4', 'requests >= 2.6.1, < 2.8', From b76dc1e05ecfa2ab95ef58ae62072afbf354abda Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 13 Jan 2016 14:41:34 -0500 Subject: [PATCH 1696/4072] Require volumes_from a container to be explicit in V2 config. Signed-off-by: Daniel Nephin --- compose/config/config.py | 16 +++++--- compose/config/types.py | 44 +++++++++++++++++++-- compose/project.py | 52 ++++++++++++++----------- setup.py | 2 +- tests/integration/project_test.py | 2 +- tests/integration/service_test.py | 4 +- tests/unit/config/config_test.py | 2 +- tests/unit/config/sort_services_test.py | 6 +-- tests/unit/config/types_test.py | 45 +++++++++++++++++++++ tests/unit/project_test.py | 17 ++++---- tests/unit/service_test.py | 29 +++++++++++--- 11 files changed, 166 insertions(+), 53 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 203c4e9e32c..8383fb5c957 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -292,7 +292,7 @@ def load_volumes(config_files): def load_services(working_dir, filename, service_configs, version): - def build_service(service_name, service_dict): + def build_service(service_name, service_dict, service_names): service_config = ServiceConfig.with_abs_paths( working_dir, filename, @@ -305,13 +305,17 @@ def build_service(service_name, service_dict): validate_against_service_schema(service_dict, service_config.name, version) validate_paths(service_dict) - service_dict = finalize_service(service_config._replace(config=service_dict)) + service_dict = finalize_service( + service_config._replace(config=service_dict), + service_names, + version) service_dict['name'] = service_config.name return service_dict def build_services(service_config): + service_names = service_config.keys() return sort_service_dicts([ - build_service(name, service_dict) + build_service(name, service_dict, service_names) for name, service_dict in service_config.items() ]) @@ -504,7 +508,7 @@ def process_service(service_config): return service_dict -def finalize_service(service_config): +def finalize_service(service_config, service_names, version): service_dict = dict(service_config.config) if 'environment' in service_dict or 'env_file' in service_dict: @@ -513,7 +517,9 @@ def finalize_service(service_config): if 'volumes_from' in service_dict: service_dict['volumes_from'] = [ - VolumeFromSpec.parse(vf) for vf in service_dict['volumes_from']] + VolumeFromSpec.parse(vf, service_names, version) + for vf in service_dict['volumes_from'] + ] if 'volumes' in service_dict: service_dict['volumes'] = [ diff --git a/compose/config/types.py b/compose/config/types.py index cec1f6cfdff..64e356fac86 100644 --- a/compose/config/types.py +++ b/compose/config/types.py @@ -11,10 +11,16 @@ from compose.const import IS_WINDOWS_PLATFORM -class VolumeFromSpec(namedtuple('_VolumeFromSpec', 'source mode')): +class VolumeFromSpec(namedtuple('_VolumeFromSpec', 'source mode type')): + # TODO: drop service_names arg when v1 is removed @classmethod - def parse(cls, volume_from_config): + def parse(cls, volume_from_config, service_names, version): + func = cls.parse_v1 if version == 1 else cls.parse_v2 + return func(service_names, volume_from_config) + + @classmethod + def parse_v1(cls, service_names, volume_from_config): parts = volume_from_config.split(':') if len(parts) > 2: raise ConfigurationError( @@ -27,7 +33,39 @@ def parse(cls, volume_from_config): else: source, mode = parts - return cls(source, mode) + type = 'service' if source in service_names else 'container' + return cls(source, mode, type) + + @classmethod + def parse_v2(cls, service_names, volume_from_config): + parts = volume_from_config.split(':') + if len(parts) > 3: + raise ConfigurationError( + "volume_from {} has incorrect format, should be one of " + "'[:]' or " + "'container:[:]'".format(volume_from_config)) + + if len(parts) == 1: + source = parts[0] + return cls(source, 'rw', 'service') + + if len(parts) == 2: + if parts[0] == 'container': + type, source = parts + return cls(source, 'rw', type) + + source, mode = parts + return cls(source, mode, 'service') + + if len(parts) == 3: + type, source, mode = parts + if type not in ('service', 'container'): + raise ConfigurationError( + "Unknown volumes_from type '{}' in '{}'".format( + type, + volume_from_config)) + + return cls(source, mode, type) def parse_restart_spec(restart_config): diff --git a/compose/project.py b/compose/project.py index e882713c2cc..06f9eaea5b0 100644 --- a/compose/project.py +++ b/compose/project.py @@ -60,7 +60,7 @@ def from_config(cls, name, config_data, client): for service_dict in config_data.services: links = project.get_links(service_dict) - volumes_from = project.get_volumes_from(service_dict) + volumes_from = get_volumes_from(project, service_dict) net = project.get_net(service_dict) project.services.append( @@ -162,28 +162,6 @@ def get_links(self, service_dict): del service_dict['links'] return links - def get_volumes_from(self, service_dict): - volumes_from = [] - if 'volumes_from' in service_dict: - for volume_from_spec in service_dict.get('volumes_from', []): - # Get service - try: - service = self.get_service(volume_from_spec.source) - volume_from_spec = volume_from_spec._replace(source=service) - except NoSuchService: - try: - container = Container.from_id(self.client, volume_from_spec.source) - volume_from_spec = volume_from_spec._replace(source=container) - except APIError: - raise ConfigurationError( - 'Service "%s" mounts volumes from "%s", which is ' - 'not the name of a service or container.' % ( - service_dict['name'], - volume_from_spec.source)) - volumes_from.append(volume_from_spec) - del service_dict['volumes_from'] - return volumes_from - def get_net(self, service_dict): net = service_dict.pop('net', None) if not net: @@ -465,6 +443,34 @@ def remove_links(service_dicts): del s['links'] +def get_volumes_from(project, service_dict): + volumes_from = service_dict.pop('volumes_from', None) + if not volumes_from: + return [] + + def build_volume_from(spec): + if spec.type == 'service': + try: + return spec._replace(source=project.get_service(spec.source)) + except NoSuchService: + pass + + if spec.type == 'container': + try: + container = Container.from_id(project.client, spec.source) + return spec._replace(source=container) + except APIError: + pass + + raise ConfigurationError( + "Service \"{}\" mounts volumes from \"{}\", which is not the name " + "of a service or container.".format( + service_dict['name'], + spec.source)) + + return [build_volume_from(vf) for vf in volumes_from] + + class NoSuchService(Exception): def __init__(self, name): self.name = name diff --git a/setup.py b/setup.py index f159d2b1073..b365e05be74 100644 --- a/setup.py +++ b/setup.py @@ -28,7 +28,7 @@ def find_version(*file_paths): install_requires = [ - 'cached-property >= 1.2.0', + 'cached-property >= 1.2.0, < 2', 'docopt >= 0.6.1, < 0.7', 'PyYAML >= 3.10, < 4', 'requests >= 2.6.1, < 2.8', diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 467eb786153..535a97750d3 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -81,7 +81,7 @@ def test_volumes_from_service(self): ) db = project.get_service('db') data = project.get_service('data') - self.assertEqual(db.volumes_from, [VolumeFromSpec(data, 'rw')]) + self.assertEqual(db.volumes_from, [VolumeFromSpec(data, 'rw', 'service')]) def test_volumes_from_container(self): data_container = Container.create( diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 4818e47aa8a..4ba1b635b2c 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -224,8 +224,8 @@ def test_create_container_with_volumes_from(self): host_service = self.create_service( 'host', volumes_from=[ - VolumeFromSpec(volume_service, 'rw'), - VolumeFromSpec(volume_container_2, 'rw') + VolumeFromSpec(volume_service, 'rw', 'service'), + VolumeFromSpec(volume_container_2, 'rw', 'container') ] ) host_container = host_service.create_container() diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 77c55ad9046..f0d432d5820 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -19,7 +19,7 @@ from tests import mock from tests import unittest -DEFAULT_VERSION = 2 +DEFAULT_VERSION = V2 = 2 V1 = 1 diff --git a/tests/unit/config/sort_services_test.py b/tests/unit/config/sort_services_test.py index c2ebbc67fa0..ebe444fee2d 100644 --- a/tests/unit/config/sort_services_test.py +++ b/tests/unit/config/sort_services_test.py @@ -77,7 +77,7 @@ def test_sort_service_dicts_4(self): }, { 'name': 'parent', - 'volumes_from': [VolumeFromSpec('child', 'rw')] + 'volumes_from': [VolumeFromSpec('child', 'rw', 'service')] }, { 'links': ['parent'], @@ -120,7 +120,7 @@ def test_sort_service_dicts_6(self): }, { 'name': 'parent', - 'volumes_from': [VolumeFromSpec('child', 'ro')] + 'volumes_from': [VolumeFromSpec('child', 'ro', 'service')] }, { 'name': 'child' @@ -145,7 +145,7 @@ def test_sort_service_dicts_7(self): }, { 'name': 'two', - 'volumes_from': [VolumeFromSpec('one', 'rw')] + 'volumes_from': [VolumeFromSpec('one', 'rw', 'service')] }, { 'name': 'one' diff --git a/tests/unit/config/types_test.py b/tests/unit/config/types_test.py index 245b854ffc1..214a0e315bb 100644 --- a/tests/unit/config/types_test.py +++ b/tests/unit/config/types_test.py @@ -5,8 +5,11 @@ 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(): @@ -67,3 +70,45 @@ def test_parse_volume_windows_absolute_path(self): "/opt/shiny/config", "ro" ) + + +class TestVolumesFromSpec(object): + + services = ['servicea', 'serviceb'] + + def test_parse_v1_from_service(self): + volume_from = VolumeFromSpec.parse('servicea', self.services, V1) + assert volume_from == VolumeFromSpec('servicea', 'rw', 'service') + + def test_parse_v1_from_container(self): + volume_from = VolumeFromSpec.parse('foo:ro', self.services, V1) + assert volume_from == VolumeFromSpec('foo', 'ro', 'container') + + def test_parse_v1_invalid(self): + with pytest.raises(ConfigurationError): + VolumeFromSpec.parse('unknown:format:ro', self.services, V1) + + def test_parse_v2_from_service(self): + volume_from = VolumeFromSpec.parse('servicea', self.services, V2) + 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) + assert volume_from == VolumeFromSpec('servicea', 'ro', 'service') + + def test_parse_v2_from_container(self): + volume_from = VolumeFromSpec.parse('container:foo', self.services, V2) + 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) + 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) + 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) diff --git a/tests/unit/project_test.py b/tests/unit/project_test.py index f63135ae68e..861f965622c 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -165,10 +165,10 @@ def test_use_volumes_from_container(self): { 'name': 'test', 'image': 'busybox:latest', - 'volumes_from': [VolumeFromSpec('aaa', 'rw')] + 'volumes_from': [VolumeFromSpec('aaa', 'rw', 'container')] } ], None), self.mock_client) - self.assertEqual(project.get_service('test')._get_volumes_from(), [container_id + ":rw"]) + assert project.get_service('test')._get_volumes_from() == [container_id + ":rw"] def test_use_volumes_from_service_no_container(self): container_name = 'test_vol_1' @@ -188,10 +188,10 @@ def test_use_volumes_from_service_no_container(self): { 'name': 'test', 'image': 'busybox:latest', - 'volumes_from': [VolumeFromSpec('vol', 'rw')] + 'volumes_from': [VolumeFromSpec('vol', 'rw', 'service')] } ], None), self.mock_client) - self.assertEqual(project.get_service('test')._get_volumes_from(), [container_name + ":rw"]) + assert project.get_service('test')._get_volumes_from() == [container_name + ":rw"] def test_use_volumes_from_service_container(self): container_ids = ['aabbccddee', '12345'] @@ -204,16 +204,17 @@ def test_use_volumes_from_service_container(self): { 'name': 'test', 'image': 'busybox:latest', - 'volumes_from': [VolumeFromSpec('vol', 'rw')] + 'volumes_from': [VolumeFromSpec('vol', 'rw', 'service')] } ], None), None) with mock.patch.object(Service, 'containers') as mock_return: mock_return.return_value = [ mock.Mock(id=container_id, spec=Container) for container_id in container_ids] - self.assertEqual( - project.get_service('test')._get_volumes_from(), - [container_ids[0] + ':rw']) + assert ( + project.get_service('test')._get_volumes_from() == + [container_ids[0] + ':rw'] + ) def test_events(self): services = [Service(name='web'), Service(name='db')] diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 63cf658ed93..9845ebc6998 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -70,7 +70,11 @@ def test_get_volumes_from_container(self): service = Service( 'test', image='foo', - volumes_from=[VolumeFromSpec(mock.Mock(id=container_id, spec=Container), 'rw')]) + volumes_from=[ + VolumeFromSpec( + mock.Mock(id=container_id, spec=Container), + 'rw', + 'container')]) self.assertEqual(service._get_volumes_from(), [container_id + ':rw']) @@ -79,7 +83,11 @@ def test_get_volumes_from_container_read_only(self): service = Service( 'test', image='foo', - volumes_from=[VolumeFromSpec(mock.Mock(id=container_id, spec=Container), 'ro')]) + volumes_from=[ + VolumeFromSpec( + mock.Mock(id=container_id, spec=Container), + 'ro', + 'container')]) self.assertEqual(service._get_volumes_from(), [container_id + ':ro']) @@ -90,7 +98,10 @@ def test_get_volumes_from_service_container_exists(self): mock.Mock(id=container_id, spec=Container) for container_id in container_ids ] - service = Service('test', volumes_from=[VolumeFromSpec(from_service, 'rw')], image='foo') + service = Service( + 'test', + volumes_from=[VolumeFromSpec(from_service, 'rw', 'service')], + image='foo') self.assertEqual(service._get_volumes_from(), [container_ids[0] + ":rw"]) @@ -102,7 +113,10 @@ def test_get_volumes_from_service_container_exists_with_flags(self): mock.Mock(id=container_id.split(':')[0], spec=Container) for container_id in container_ids ] - service = Service('test', volumes_from=[VolumeFromSpec(from_service, mode)], image='foo') + service = Service( + 'test', + volumes_from=[VolumeFromSpec(from_service, mode, 'service')], + image='foo') self.assertEqual(service._get_volumes_from(), [container_ids[0]]) @@ -113,7 +127,10 @@ def test_get_volumes_from_service_no_container(self): from_service.create_container.return_value = mock.Mock( id=container_id, spec=Container) - service = Service('test', image='foo', volumes_from=[VolumeFromSpec(from_service, 'rw')]) + service = Service( + 'test', + image='foo', + volumes_from=[VolumeFromSpec(from_service, 'rw', 'service')]) self.assertEqual(service._get_volumes_from(), [container_id + ':rw']) from_service.create_container.assert_called_once_with() @@ -389,7 +406,7 @@ def test_config_dict(self): client=self.mock_client, net=ServiceNet(Service('other')), links=[(Service('one'), 'one')], - volumes_from=[VolumeFromSpec(Service('two'), 'rw')]) + volumes_from=[VolumeFromSpec(Service('two'), 'rw', 'service')]) config_dict = service.config_dict() expected = { From 69ed5f9c48a5fe419ac041239659fe69304fe4a9 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 17 Dec 2015 17:49:48 +0000 Subject: [PATCH 1697/4072] Specify networks in Compose file There's not yet a proper way for services to join networks Signed-off-by: Aanand Prasad --- compose/cli/main.py | 3 +- compose/config/config.py | 43 +-- compose/config/fields_schema_v2.json | 13 + compose/network.py | 57 ++++ compose/project.py | 127 ++++----- tests/acceptance/cli_test.py | 19 +- tests/fixtures/networks/docker-compose.yml | 7 + .../no-links-composefile/docker-compose.yml | 9 + tests/integration/project_test.py | 113 ++++---- tests/unit/project_test.py | 248 +++++++++++------- 10 files changed, 400 insertions(+), 239 deletions(-) create mode 100644 compose/network.py create mode 100644 tests/fixtures/networks/docker-compose.yml create mode 100644 tests/fixtures/no-links-composefile/docker-compose.yml diff --git a/compose/cli/main.py b/compose/cli/main.py index 9957d391f03..62abcb10a61 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -696,8 +696,7 @@ def run_one_off_container(container_options, project, service, options): start_deps=True, strategy=ConvergenceStrategy.never) - if project.use_networking: - project.ensure_network_exists() + project.initialize_networks() container = service.create_container( quiet=True, diff --git a/compose/config/config.py b/compose/config/config.py index 8383fb5c957..c8d93faf6dc 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -139,14 +139,16 @@ def get_volumes(self): return {} if self.version == 1 else self.config.get('volumes', {}) -class Config(namedtuple('_Config', 'version services volumes')): +class Config(namedtuple('_Config', 'version services volumes networks')): """ :param version: configuration version :type version: int :param services: List of service description dictionaries :type services: :class:`list` - :param volumes: List of volume description dictionaries - :type volumes: :class:`list` + :param volumes: Dictionary mapping volume names to description dictionaries + :type volumes: :class:`dict` + :param networks: Dictionary mapping network names to description dictionaries + :type networks: :class:`dict` """ @@ -256,39 +258,44 @@ 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) + volumes = load_mapping(config_details.config_files, 'volumes', 'Volume') + networks = load_mapping(config_details.config_files, '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) - return Config(main_file.version, service_dicts, volumes) + return Config(main_file.version, service_dicts, volumes, networks) -def load_volumes(config_files): - volumes = {} +def load_mapping(config_files, key, entity_type): + mapping = {} + for config_file in config_files: - for name, volume_config in config_file.get_volumes().items(): - volumes[name] = volume_config or {} - if not volume_config: + for name, config in config_file.config.get(key, {}).items(): + mapping[name] = config or {} + if not config: continue - external = volume_config.get('external') + external = config.get('external') if external: - if len(volume_config.keys()) > 1: + if len(config.keys()) > 1: raise ConfigurationError( - 'Volume {0} declared as external but specifies' - ' additional attributes ({1}). '.format( + '{} {} declared as external but specifies' + ' additional attributes ({}). '.format( + entity_type, name, - ', '.join([k for k in volume_config.keys() if k != 'external']) + ', '.join([k for k in config.keys() if k != 'external']) ) ) if isinstance(external, dict): - volume_config['external_name'] = external.get('name') + config['external_name'] = external.get('name') else: - volume_config['external_name'] = name + config['external_name'] = name + + mapping[name] = config - return volumes + return mapping def load_services(working_dir, filename, service_configs, version): diff --git a/compose/config/fields_schema_v2.json b/compose/config/fields_schema_v2.json index 25126ed1618..b0f304e8666 100644 --- a/compose/config/fields_schema_v2.json +++ b/compose/config/fields_schema_v2.json @@ -17,6 +17,15 @@ }, "additionalProperties": false }, + "networks": { + "id": "#/properties/networks", + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "$ref": "#/definitions/network" + } + } + }, "volumes": { "id": "#/properties/volumes", "type": "object", @@ -30,6 +39,10 @@ }, "definitions": { + "network": { + "id": "#/definitions/network", + "type": "object" + }, "volume": { "id": "#/definitions/volume", "type": ["object", "null"], diff --git a/compose/network.py b/compose/network.py new file mode 100644 index 00000000000..0b4e40c6031 --- /dev/null +++ b/compose/network.py @@ -0,0 +1,57 @@ +from __future__ import absolute_import +from __future__ import unicode_literals + +import logging + +from docker.errors import NotFound + +from .config import ConfigurationError + + +log = logging.getLogger(__name__) + + +class Network(object): + def __init__(self, client, project, name, driver=None, driver_opts=None): + self.client = client + self.project = project + self.name = name + self.driver = driver + self.driver_opts = driver_opts + + def ensure(self): + 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)) + except NotFound: + driver_name = 'the default driver' + if self.driver: + driver_name = 'driver "{}"'.format(self.driver) + + log.info( + 'Creating network "{}" with {}' + .format(self.full_name, driver_name) + ) + + self.client.create_network( + self.full_name, self.driver, self.driver_opts + ) + + def remove(self): + # TODO: don't remove external networks + log.info("Removing network {}".format(self.full_name)) + self.client.remove_network(self.full_name) + + def inspect(self): + return self.client.inspect_network(self.full_name) + + @property + def full_name(self): + return '{0}_{1}'.format(self.project, self.name) diff --git a/compose/project.py b/compose/project.py index 3e7c5afda5e..10d457a7f62 100644 --- a/compose/project.py +++ b/compose/project.py @@ -17,6 +17,7 @@ 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 ConvergenceStrategy from .service import Net @@ -33,12 +34,14 @@ class Project(object): """ A collection of services. """ - def __init__(self, name, services, client, volumes=None, use_networking=False, network_driver=None): + def __init__(self, name, services, client, networks=None, volumes=None, + use_networking=False, network_driver=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 [] def labels(self, one_off=False): @@ -55,9 +58,6 @@ def from_config(cls, name, config_data, client): use_networking = (config_data.version and config_data.version >= 2) project = cls(name, [], client, use_networking=use_networking) - if use_networking: - remove_links(config_data.services) - for service_dict in config_data.services: links = project.get_links(service_dict) volumes_from = get_volumes_from(project, service_dict) @@ -72,6 +72,16 @@ def from_config(cls, name, config_data, client): net=net, volumes_from=volumes_from, **service_dict)) + + if config_data.networks: + for network_name, data in config_data.networks.items(): + project.networks.append( + Network( + client=client, project=name, name=network_name, + driver=data.get('driver'), driver_opts=data.get('driver_opts') + ) + ) + if config_data.volumes: for vol_name, data in config_data.volumes.items(): project.volumes.append( @@ -82,6 +92,7 @@ def from_config(cls, name, config_data, client): external_name=data.get('external_name') ) ) + return project @property @@ -124,20 +135,18 @@ def get_services(self, service_names=None, include_deps=False): Raises NoSuchService if any of the named services do not exist. """ if service_names is None or len(service_names) == 0: - return self.get_services( - service_names=self.service_names, - include_deps=include_deps - ) - else: - unsorted = [self.get_service(name) for name in service_names] - services = [s for s in self.services if s in unsorted] + service_names = self.service_names + + unsorted = [self.get_service(name) for name in service_names] + services = [s for s in self.services if s in unsorted] - if include_deps: - services = reduce(self._inject_deps, services, []) + if include_deps: + services = reduce(self._inject_deps, services, []) - uniques = [] - [uniques.append(s) for s in services if s not in uniques] - return uniques + uniques = [] + [uniques.append(s) for s in services if s not in uniques] + + return uniques def get_services_without_duplicate(self, service_names=None, include_deps=False): services = self.get_services(service_names, include_deps) @@ -166,7 +175,7 @@ def get_net(self, service_dict): net = service_dict.pop('net', None) if not net: if self.use_networking: - return Net(self.default_network_name) + return Net(self.default_network.full_name) return Net(None) net_name = get_service_name_from_net(net) @@ -251,7 +260,7 @@ def initialize_volumes(self): def down(self, remove_image_type, include_volumes): self.stop() self.remove_stopped(v=include_volumes) - self.remove_network() + self.remove_default_network() if include_volumes: self.remove_volumes() @@ -262,10 +271,34 @@ def remove_images(self, remove_image_type): for service in self.get_services(): service.remove_image(remove_image_type) + def remove_default_network(self): + if not self.use_networking: + return + if self.uses_default_network(): + self.default_network.remove() + def remove_volumes(self): for volume in self.volumes: volume.remove() + def initialize_networks(self): + networks = self.networks + if self.uses_default_network(): + networks.append(self.default_network) + + for network in networks: + network.ensure() + + def uses_default_network(self): + return any( + service.net.mode == self.default_network.full_name + 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) @@ -335,9 +368,7 @@ def up(self, plans = self._get_convergence_plans(services, strategy) - if self.use_networking and self.uses_default_network(): - self.ensure_network_exists() - + self.initialize_networks() self.initialize_volumes() return [ @@ -395,40 +426,6 @@ def matches_service_names(container): return [c for c in containers if matches_service_names(c)] - def get_network(self): - try: - return self.client.inspect_network(self.default_network_name) - except NotFound: - return None - - def ensure_network_exists(self): - # TODO: recreate network if driver has changed? - if self.get_network() is None: - driver_name = 'the default driver' - if self.network_driver: - driver_name = 'driver "{}"'.format(self.network_driver) - - log.info( - 'Creating network "{}" with {}' - .format(self.default_network_name, driver_name) - ) - self.client.create_network(self.default_network_name, driver=self.network_driver) - - def remove_network(self): - if not self.use_networking: - return - network = self.get_network() - if network: - log.info("Removing network %s", self.default_network_name) - self.client.remove_network(network['Id']) - - def uses_default_network(self): - return any(service.net.mode == self.default_network_name for service in self.services) - - @property - def default_network_name(self): - return '{}_default'.format(self.name) - def _inject_deps(self, acc, service): dep_names = service.get_dependency_names() @@ -444,26 +441,6 @@ def _inject_deps(self, acc, service): return acc + dep_services -def remove_links(service_dicts): - services_with_links = [s for s in service_dicts if 'links' in s] - if not services_with_links: - return - - if len(services_with_links) == 1: - prefix = '"{}" defines'.format(services_with_links[0]['name']) - else: - prefix = 'Some services ({}) define'.format( - ", ".join('"{}"'.format(s['name']) for s in services_with_links)) - - log.warn( - '\n{} links, which are not compatible with Docker networking and will be ignored.\n' - 'Future versions of Docker will not support links - you should remove them for ' - 'forwards-compatibility.\n'.format(prefix)) - - for s in services_with_links: - del s['links'] - - def get_volumes_from(project, service_dict): volumes_from = service_dict.pop('volumes_from', None) if not volumes_from: diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index cb04918bd65..1e31988b2a2 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -354,7 +354,7 @@ def test_up_without_networking(self): self.base_dir = 'tests/fixtures/links-composefile' self.dispatch(['up', '-d'], None) - networks = self.client.networks(names=[self.project.default_network_name]) + networks = self.client.networks(names=[self.project.default_network.full_name]) self.assertEqual(len(networks), 0) for service in self.project.get_services(): @@ -371,7 +371,7 @@ def test_up_with_networking(self): services = self.project.get_services() - networks = self.client.networks(names=[self.project.default_network_name]) + networks = self.client.networks(names=[self.project.default_network.full_name]) for n in networks: self.addCleanup(self.client.remove_network, n['Id']) self.assertEqual(len(networks), 1) @@ -388,6 +388,19 @@ def test_up_with_networking(self): web_container = self.project.get_service('simple').containers()[0] self.assertFalse(web_container.get('HostConfig.Links')) + def test_up_with_networks(self): + self.base_dir = 'tests/fixtures/networks' + self.dispatch(['up', '-d'], None) + + networks = self.client.networks(names=[ + '{}_{}'.format(self.project.name, n) + for n in ['foo', 'bar']]) + + self.assertEqual(len(networks), 2) + + for net in networks: + self.assertEqual(net['Driver'], 'bridge') + def test_up_with_links_is_invalid(self): self.base_dir = 'tests/fixtures/v2-simple' @@ -698,7 +711,7 @@ def test_run_with_networking(self): 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_name]) + networks = self.client.networks(names=[self.project.default_network.full_name]) for n in networks: self.addCleanup(self.client.remove_network, n['Id']) self.assertEqual(len(networks), 1) diff --git a/tests/fixtures/networks/docker-compose.yml b/tests/fixtures/networks/docker-compose.yml new file mode 100644 index 00000000000..c0795526f20 --- /dev/null +++ b/tests/fixtures/networks/docker-compose.yml @@ -0,0 +1,7 @@ +version: 2 + +networks: + foo: + driver: + + bar: {} diff --git a/tests/fixtures/no-links-composefile/docker-compose.yml b/tests/fixtures/no-links-composefile/docker-compose.yml new file mode 100644 index 00000000000..75a6a085cdb --- /dev/null +++ b/tests/fixtures/no-links-composefile/docker-compose.yml @@ -0,0 +1,9 @@ +db: + image: busybox:latest + command: top +web: + image: busybox:latest + command: top +console: + image: busybox:latest + command: top diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 535a97750d3..ef8a084b775 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -14,7 +14,6 @@ from compose.container import Container from compose.project import Project from compose.service import ConvergenceStrategy -from compose.service import Net def build_service_dicts(service_config): @@ -104,21 +103,6 @@ def test_volumes_from_container(self): db = project.get_service('db') self.assertEqual(db._get_volumes_from(), [data_container.id + ':rw']) - def test_get_network_does_not_exist(self): - project = Project('composetest', [], self.client) - assert project.get_network() is None - - def test_get_network(self): - project_name = 'network_does_exist' - network_name = '{}_default'.format(project_name) - - project = Project(project_name, [], self.client) - self.client.create_network(network_name) - self.addCleanup(self.client.remove_network, network_name) - - assert isinstance(project.get_network(), dict) - assert project.get_network()['Name'] == network_name - def test_net_from_service(self): project = Project.from_config( name='composetest', @@ -473,18 +457,6 @@ def test_project_up_with_no_deps(self): self.assertEqual(len(project.get_service('data').containers(stopped=True)), 1) self.assertEqual(len(project.get_service('console').containers()), 0) - def test_project_up_with_custom_network(self): - network_name = 'composetest-custom' - - self.client.create_network(network_name) - self.addCleanup(self.client.remove_network, network_name) - - web = self.create_service('web', net=Net(network_name)) - project = Project('composetest', [web], self.client, use_networking=True) - project.up() - - assert project.get_network() is None - def test_unscale_after_restart(self): web = self.create_service('web') project = Project('composetest', [web], self.client) @@ -510,15 +482,50 @@ def test_unscale_after_restart(self): service = project.get_service('web') self.assertEqual(len(service.containers()), 1) + def test_project_up_networks(self): + config_data = config.Config( + version=2, + services=[{ + 'name': 'web', + 'image': 'busybox:latest', + 'command': 'top', + }], + volumes={}, + networks={ + 'foo': {'driver': 'bridge'}, + 'bar': {'driver': None}, + 'baz': {}, + }, + ) + + project = Project.from_config( + client=self.client, + name='composetest', + config_data=config_data, + ) + project.up() + self.assertEqual(len(project.containers()), 1) + + 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) + + foo_data = self.client.inspect_network('composetest_foo') + self.assertEqual(foo_data['Driver'], 'bridge') + 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( - version=2, services=[{ + version=2, + services=[{ 'name': 'web', 'image': 'busybox:latest', 'command': 'top' - }], volumes={vol_name: {'driver': 'local'}} + }], + volumes={vol_name: {'driver': 'local'}}, + networks={}, ) project = Project.from_config( @@ -587,11 +594,14 @@ 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( - version=2, services=[{ + version=2, + services=[{ 'name': 'web', 'image': 'busybox:latest', 'command': 'top' - }], volumes={vol_name: {}} + }], + volumes={vol_name: {}}, + networks={}, ) project = Project.from_config( @@ -608,11 +618,14 @@ 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( - version=2, services=[{ + version=2, + services=[{ 'name': 'web', 'image': 'busybox:latest', 'command': 'top' - }], volumes={vol_name: {}} + }], + volumes={vol_name: {}}, + networks={}, ) project = Project.from_config( @@ -629,11 +642,14 @@ def test_initialize_volumes_invalid_volume_driver(self): vol_name = '{0:x}'.format(random.getrandbits(32)) config_data = config.Config( - version=2, services=[{ + version=2, + services=[{ 'name': 'web', 'image': 'busybox:latest', 'command': 'top' - }], volumes={vol_name: {'driver': 'foobar'}} + }], + volumes={vol_name: {'driver': 'foobar'}}, + networks={}, ) project = Project.from_config( @@ -648,11 +664,14 @@ def test_initialize_volumes_updated_driver(self): full_vol_name = 'composetest_{0}'.format(vol_name) config_data = config.Config( - version=2, services=[{ + version=2, + services=[{ 'name': 'web', 'image': 'busybox:latest', 'command': 'top' - }], volumes={vol_name: {'driver': 'local'}} + }], + volumes={vol_name: {'driver': 'local'}}, + networks={}, ) project = Project.from_config( name='composetest', @@ -683,13 +702,16 @@ def test_initialize_volumes_external_volumes(self): full_vol_name = 'composetest_{0}'.format(vol_name) self.client.create_volume(vol_name) config_data = config.Config( - version=2, services=[{ + version=2, + services=[{ 'name': 'web', 'image': 'busybox:latest', 'command': 'top' - }], volumes={ + }], + volumes={ vol_name: {'external': True, 'external_name': vol_name} - } + }, + networks=None, ) project = Project.from_config( name='composetest', @@ -704,13 +726,16 @@ def test_initialize_volumes_inexistent_external_volume(self): vol_name = '{0:x}'.format(random.getrandbits(32)) config_data = config.Config( - version=2, services=[{ + version=2, + services=[{ 'name': 'web', 'image': 'busybox:latest', 'command': 'top' - }], volumes={ + }], + volumes={ vol_name: {'external': True, 'external_name': vol_name} - } + }, + networks=None, ) project = Project.from_config( name='composetest', diff --git a/tests/unit/project_test.py b/tests/unit/project_test.py index 861f965622c..470e51ad384 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -21,35 +21,27 @@ class ProjectTest(unittest.TestCase): def setUp(self): self.mock_client = mock.create_autospec(docker.Client) - def test_from_dict(self): - project = Project.from_config('composetest', Config(None, [ - { - 'name': 'web', - 'image': 'busybox:latest' - }, - { - 'name': 'db', - 'image': 'busybox:latest' - }, - ], None), None) - self.assertEqual(len(project.services), 2) - self.assertEqual(project.get_service('web').name, 'web') - 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') - def test_from_config(self): - config = Config(None, [ - { - 'name': 'web', - 'image': 'busybox:latest', - }, - { - 'name': 'db', - 'image': 'busybox:latest', - }, - ], None) - project = Project.from_config('composetest', config, None) + config = Config( + version=None, + services=[ + { + 'name': 'web', + 'image': 'busybox:latest', + }, + { + 'name': 'db', + 'image': 'busybox:latest', + }, + ], + networks=None, + volumes=None, + ) + project = Project.from_config( + name='composetest', + config_data=config, + client=None, + ) self.assertEqual(len(project.services), 2) self.assertEqual(project.get_service('web').name, 'web') self.assertEqual(project.get_service('web').options['image'], 'busybox:latest') @@ -58,16 +50,21 @@ def test_from_config(self): self.assertFalse(project.use_networking) def test_from_config_v2(self): - config = Config(2, [ - { - 'name': 'web', - 'image': 'busybox:latest', - }, - { - 'name': 'db', - 'image': 'busybox:latest', - }, - ], None) + config = Config( + version=2, + services=[ + { + 'name': 'web', + 'image': 'busybox:latest', + }, + { + 'name': 'db', + 'image': 'busybox:latest', + }, + ], + networks=None, + volumes=None, + ) project = Project.from_config('composetest', config, None) self.assertEqual(len(project.services), 2) self.assertTrue(project.use_networking) @@ -161,13 +158,20 @@ def test_use_volumes_from_container(self): container_id = 'aabbccddee' container_dict = dict(Name='aaa', Id=container_id) self.mock_client.inspect_container.return_value = container_dict - project = Project.from_config('test', Config(None, [ - { - 'name': 'test', - 'image': 'busybox:latest', - 'volumes_from': [VolumeFromSpec('aaa', 'rw', 'container')] - } - ], None), self.mock_client) + project = Project.from_config( + name='test', + client=self.mock_client, + config_data=Config( + version=None, + services=[{ + 'name': 'test', + 'image': 'busybox:latest', + 'volumes_from': [VolumeFromSpec('aaa', 'rw', 'container')] + }], + networks=None, + volumes=None, + ), + ) assert project.get_service('test')._get_volumes_from() == [container_id + ":rw"] def test_use_volumes_from_service_no_container(self): @@ -180,33 +184,51 @@ def test_use_volumes_from_service_no_container(self): "Image": 'busybox:latest' } ] - project = Project.from_config('test', Config(None, [ - { - 'name': 'vol', - 'image': 'busybox:latest' - }, - { - 'name': 'test', - 'image': 'busybox:latest', - 'volumes_from': [VolumeFromSpec('vol', 'rw', 'service')] - } - ], None), self.mock_client) + project = Project.from_config( + name='test', + client=self.mock_client, + config_data=Config( + version=None, + services=[ + { + 'name': 'vol', + 'image': 'busybox:latest' + }, + { + 'name': 'test', + 'image': 'busybox:latest', + 'volumes_from': [VolumeFromSpec('vol', 'rw', 'service')] + } + ], + networks=None, + volumes=None, + ), + ) assert project.get_service('test')._get_volumes_from() == [container_name + ":rw"] def test_use_volumes_from_service_container(self): container_ids = ['aabbccddee', '12345'] - project = Project.from_config('test', Config(None, [ - { - 'name': 'vol', - 'image': 'busybox:latest' - }, - { - 'name': 'test', - 'image': 'busybox:latest', - 'volumes_from': [VolumeFromSpec('vol', 'rw', 'service')] - } - ], None), None) + project = Project.from_config( + name='test', + client=None, + config_data=Config( + version=None, + services=[ + { + 'name': 'vol', + 'image': 'busybox:latest' + }, + { + 'name': 'test', + 'image': 'busybox:latest', + 'volumes_from': [VolumeFromSpec('vol', 'rw', 'service')] + } + ], + networks=None, + volumes=None, + ), + ) with mock.patch.object(Service, 'containers') as mock_return: mock_return.return_value = [ mock.Mock(id=container_id, spec=Container) @@ -313,12 +335,21 @@ def get_container(cid): ] def test_net_unset(self): - project = Project.from_config('test', Config(None, [ - { - 'name': 'test', - 'image': 'busybox:latest', - } - ], None), self.mock_client) + project = Project.from_config( + name='test', + client=self.mock_client, + config_data=Config( + version=None, + services=[ + { + 'name': 'test', + 'image': 'busybox:latest', + } + ], + networks=None, + volumes=None, + ), + ) service = project.get_service('test') self.assertEqual(service.net.id, None) self.assertNotIn('NetworkMode', service._get_container_host_config({})) @@ -327,13 +358,22 @@ def test_use_net_from_container(self): container_id = 'aabbccddee' container_dict = dict(Name='aaa', Id=container_id) self.mock_client.inspect_container.return_value = container_dict - project = Project.from_config('test', Config(None, [ - { - 'name': 'test', - 'image': 'busybox:latest', - 'net': 'container:aaa' - } - ], None), self.mock_client) + project = Project.from_config( + name='test', + client=self.mock_client, + config_data=Config( + version=None, + services=[ + { + 'name': 'test', + 'image': 'busybox:latest', + 'net': 'container:aaa' + }, + ], + networks=None, + volumes=None, + ), + ) service = project.get_service('test') self.assertEqual(service.net.mode, 'container:' + container_id) @@ -347,17 +387,26 @@ def test_use_net_from_service(self): "Image": 'busybox:latest' } ] - project = Project.from_config('test', Config(None, [ - { - 'name': 'aaa', - 'image': 'busybox:latest' - }, - { - 'name': 'test', - 'image': 'busybox:latest', - 'net': 'container:aaa' - } - ], None), self.mock_client) + project = Project.from_config( + name='test', + client=self.mock_client, + config_data=Config( + version=None, + services=[ + { + 'name': 'aaa', + 'image': 'busybox:latest' + }, + { + 'name': 'test', + 'image': 'busybox:latest', + 'net': 'container:aaa' + }, + ], + networks=None, + volumes=None, + ), + ) service = project.get_service('test') self.assertEqual(service.net.mode, 'container:' + container_name) @@ -403,11 +452,16 @@ def test_container_without_name(self): }, } project = Project.from_config( - 'test', - Config(None, [{ - 'name': 'web', - 'image': 'busybox:latest', - }], None), - self.mock_client, + name='test', + client=self.mock_client, + config_data=Config( + version=None, + services=[{ + 'name': 'web', + 'image': 'busybox:latest', + }], + networks=None, + volumes=None, + ), ) self.assertEqual([c.id for c in project.containers()], ['1']) From 35e347cf92b13a374ff767653aa8629ccdb64a71 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 13 Jan 2016 14:05:30 +0000 Subject: [PATCH 1698/4072] Disable the use of 'net' in v2 Signed-off-by: Aanand Prasad --- compose/config/service_schema_v2.json | 1 - tests/acceptance/cli_test.py | 24 +++++++++++++++++++ .../fixtures/net-container/docker-compose.yml | 7 ++++++ tests/fixtures/net-container/v2-invalid.yml | 10 ++++++++ 4 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/net-container/docker-compose.yml create mode 100644 tests/fixtures/net-container/v2-invalid.yml diff --git a/compose/config/service_schema_v2.json b/compose/config/service_schema_v2.json index c911502fe5e..1e54c666b6b 100644 --- a/compose/config/service_schema_v2.json +++ b/compose/config/service_schema_v2.json @@ -89,7 +89,6 @@ "mac_address": {"type": "string"}, "mem_limit": {"type": ["number", "string"]}, "memswap_limit": {"type": ["number", "string"]}, - "net": {"type": "string"}, "pid": {"type": ["string", "null"]}, "ports": { diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 1e31988b2a2..ceec400c9fb 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -422,6 +422,30 @@ def test_up_with_links_v1(self): self.assertEqual(len(db.containers()), 1) self.assertEqual(len(console.containers()), 0) + def test_up_with_net_is_invalid(self): + self.base_dir = 'tests/fixtures/net-container' + + result = self.dispatch( + ['-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 + + def test_up_with_net_v1(self): + self.base_dir = 'tests/fixtures/net-container' + self.dispatch(['up', '-d'], None) + + bar = self.project.get_service('bar') + bar_container = bar.containers()[0] + + foo = self.project.get_service('foo') + foo_container = foo.containers()[0] + + assert foo_container.get('HostConfig.NetworkMode') == \ + 'container:{}'.format(bar_container.id) + 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/net-container/docker-compose.yml b/tests/fixtures/net-container/docker-compose.yml new file mode 100644 index 00000000000..b5506e0e17b --- /dev/null +++ b/tests/fixtures/net-container/docker-compose.yml @@ -0,0 +1,7 @@ +foo: + image: busybox + command: top + net: "container:bar" +bar: + image: busybox + command: top diff --git a/tests/fixtures/net-container/v2-invalid.yml b/tests/fixtures/net-container/v2-invalid.yml new file mode 100644 index 00000000000..eac4b5f1880 --- /dev/null +++ b/tests/fixtures/net-container/v2-invalid.yml @@ -0,0 +1,10 @@ +version: 2 + +services: + foo: + image: busybox + command: top + bar: + image: busybox + command: top + net: "container:foo" From 3f9038aea9abfde49ee2b8745369d7d8647f3262 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 13 Jan 2016 14:15:02 +0000 Subject: [PATCH 1699/4072] Remove test duplication Signed-off-by: Aanand Prasad --- tests/acceptance/cli_test.py | 33 ++++++++++++++------------------- 1 file changed, 14 insertions(+), 19 deletions(-) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index ceec400c9fb..52e3d354e73 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -350,22 +350,7 @@ def test_up_attached(self): assert 'simple_1 | simple' in result.stdout assert 'another_1 | another' in result.stdout - def test_up_without_networking(self): - self.base_dir = 'tests/fixtures/links-composefile' - self.dispatch(['up', '-d'], None) - - networks = self.client.networks(names=[self.project.default_network.full_name]) - self.assertEqual(len(networks), 0) - - for service in self.project.get_services(): - containers = service.containers() - self.assertEqual(len(containers), 1) - self.assertNotEqual(containers[0].get('Config.Hostname'), service.name) - - web_container = self.project.get_service('web').containers()[0] - self.assertTrue(web_container.get('HostConfig.Links')) - - def test_up_with_networking(self): + def test_up(self): self.base_dir = 'tests/fixtures/v2-simple' self.dispatch(['up', '-d'], None) @@ -385,9 +370,6 @@ def test_up_with_networking(self): self.assertEqual(len(containers), 1) self.assertIn(containers[0].id, network['Containers']) - web_container = self.project.get_service('simple').containers()[0] - self.assertFalse(web_container.get('HostConfig.Links')) - def test_up_with_networks(self): self.base_dir = 'tests/fixtures/networks' self.dispatch(['up', '-d'], None) @@ -415,13 +397,26 @@ def test_up_with_links_is_invalid(self): 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]) + for n in networks: + self.addCleanup(self.client.remove_network, n['Id']) + self.assertEqual(len(networks), 0) + web = self.project.get_service('web') db = self.project.get_service('db') console = self.project.get_service('console') + + # console was not started self.assertEqual(len(web.containers()), 1) self.assertEqual(len(db.containers()), 1) self.assertEqual(len(console.containers()), 0) + # web has links + web_container = web.containers()[0] + self.assertTrue(web_container.get('HostConfig.Links')) + def test_up_with_net_is_invalid(self): self.base_dir = 'tests/fixtures/net-container' From 3eafdbb01bc9c2e72098aa30eb16f5e29d2228ab Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 13 Jan 2016 17:00:31 +0000 Subject: [PATCH 1700/4072] Connect services to networks with the 'networks' key Signed-off-by: Aanand Prasad --- compose/cli/main.py | 3 +- compose/config/service_schema_v2.json | 7 ++ compose/network.py | 1 + compose/project.py | 48 ++++++++++--- compose/service.py | 19 ++++-- tests/acceptance/cli_test.py | 68 +++++++++++++++---- tests/fixtures/networks/docker-compose.yml | 20 ++++-- tests/fixtures/networks/missing-network.yml | 10 +++ tests/fixtures/no-services/docker-compose.yml | 5 ++ tests/integration/resilience_test.py | 4 +- tests/integration/service_test.py | 33 ++++----- tests/unit/project_test.py | 55 +++++++++------ 12 files changed, 195 insertions(+), 78 deletions(-) create mode 100644 tests/fixtures/networks/missing-network.yml create mode 100644 tests/fixtures/no-services/docker-compose.yml diff --git a/compose/cli/main.py b/compose/cli/main.py index 62abcb10a61..473c6d605cd 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -704,7 +704,7 @@ def run_one_off_container(container_options, project, service, options): **container_options) if options['-d']: - container.start() + service.start_container(container) print(container.name) return @@ -716,6 +716,7 @@ def remove_container(force=False): try: try: dockerpty.start(project.client, container.id, interactive=not options['-T']) + service.connect_container_to_networks(container) exit_code = container.wait() except signals.ShutdownException: project.client.stop(container.id) diff --git a/compose/config/service_schema_v2.json b/compose/config/service_schema_v2.json index 1e54c666b6b..5f4e047816f 100644 --- a/compose/config/service_schema_v2.json +++ b/compose/config/service_schema_v2.json @@ -89,6 +89,13 @@ "mac_address": {"type": "string"}, "mem_limit": {"type": ["number", "string"]}, "memswap_limit": {"type": ["number", "string"]}, + + "networks": { + "type": "array", + "items": {"type": "string"}, + "uniqueItems": true + }, + "pid": {"type": ["string", "null"]}, "ports": { diff --git a/compose/network.py b/compose/network.py index 0b4e40c6031..a8f7e918dad 100644 --- a/compose/network.py +++ b/compose/network.py @@ -11,6 +11,7 @@ log = logging.getLogger(__name__) +# TODO: support external networks class Network(object): def __init__(self, client, project, name, driver=None, driver_opts=None): self.client = client diff --git a/compose/project.py b/compose/project.py index 10d457a7f62..292bf2f2e92 100644 --- a/compose/project.py +++ b/compose/project.py @@ -58,7 +58,21 @@ def from_config(cls, name, config_data, client): 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') + ) + ) + for service_dict in config_data.services: + networks = project.get_networks( + service_dict, + custom_networks + [project.default_network]) + links = project.get_links(service_dict) volumes_from = get_volumes_from(project, service_dict) net = project.get_net(service_dict) @@ -68,19 +82,15 @@ def from_config(cls, name, config_data, client): client=client, project=name, use_networking=use_networking, + networks=networks, links=links, net=net, volumes_from=volumes_from, **service_dict)) - if config_data.networks: - for network_name, data in config_data.networks.items(): - project.networks.append( - Network( - client=client, project=name, name=network_name, - driver=data.get('driver'), driver_opts=data.get('driver_opts') - ) - ) + project.networks += custom_networks + if project.uses_default_network(): + project.networks.append(project.default_network) if config_data.volumes: for vol_name, data in config_data.volumes.items(): @@ -154,6 +164,18 @@ def get_services_without_duplicate(self, service_names=None, include_deps=False) service.remove_duplicate_containers() return services + def get_networks(self, service_dict, network_definitions): + 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_links(self, service_dict): links = [] if 'links' in service_dict: @@ -172,10 +194,11 @@ def get_links(self, service_dict): return links def get_net(self, service_dict): + if self.use_networking: + return Net(None) + net = service_dict.pop('net', None) if not net: - if self.use_networking: - return Net(self.default_network.full_name) return Net(None) net_name = get_service_name_from_net(net) @@ -282,6 +305,9 @@ def remove_volumes(self): volume.remove() def initialize_networks(self): + if not self.use_networking: + return + networks = self.networks if self.uses_default_network(): networks.append(self.default_network) @@ -291,7 +317,7 @@ def initialize_networks(self): def uses_default_network(self): return any( - service.net.mode == self.default_network.full_name + self.default_network.full_name in service.networks for service in self.services ) diff --git a/compose/service.py b/compose/service.py index 1c848ca39c0..0a7f0d8e84b 100644 --- a/compose/service.py +++ b/compose/service.py @@ -116,6 +116,7 @@ def __init__( links=None, volumes_from=None, net=None, + networks=None, **options ): self.name = name @@ -125,6 +126,7 @@ def __init__( self.links = links or [] self.volumes_from = volumes_from or [] self.net = net or Net(None) + self.networks = networks or [] self.options = options def containers(self, stopped=False, one_off=False, filters={}): @@ -175,7 +177,7 @@ def scale(self, desired_num, timeout=DEFAULT_TIMEOUT): def create_and_start(service, number): container = service.create_container(number=number, quiet=True) - container.start() + service.start_container(container) return container running_containers = self.containers(stopped=False) @@ -348,7 +350,7 @@ def execute_convergence_plan(self, container.attach_log_stream() if start: - container.start() + self.start_container(container) return [container] @@ -406,7 +408,7 @@ def recreate_container( if attach_logs: new_container.attach_log_stream() if start_new_container: - new_container.start() + self.start_container(new_container) container.remove() return new_container @@ -415,9 +417,18 @@ def start_container_if_stopped(self, container, attach_logs=False): log.info("Starting %s" % container.name) if attach_logs: container.attach_log_stream() - container.start() + return self.start_container(container) + + def start_container(self, container): + container.start() + self.connect_container_to_networks(container) return container + 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) + def remove_duplicate_containers(self, timeout=DEFAULT_TIMEOUT): for c in self.duplicate_containers(): log.info('Removing %s' % c.name) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 52e3d354e73..ff9c34f1f76 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -103,8 +103,15 @@ def tearDown(self): if self.base_dir: self.project.kill() self.project.remove_stopped() + for container in self.project.containers(stopped=True, one_off=True): container.remove(force=True) + + networks = self.client.networks() + for n in networks: + if n['Name'].startswith('{}_'.format(self.project.name)): + self.client.remove_network(n['Name']) + super(CLITestCase, self).tearDown() @property @@ -357,12 +364,11 @@ def test_up(self): services = self.project.get_services() networks = self.client.networks(names=[self.project.default_network.full_name]) - for n in networks: - self.addCleanup(self.client.remove_network, n['Id']) self.assertEqual(len(networks), 1) self.assertEqual(networks[0]['Driver'], 'bridge') network = self.client.inspect_network(networks[0]['Id']) + # print self.project.services[0].containers()[0].get('NetworkSettings') self.assertEqual(len(network['Containers']), len(services)) for service in services: @@ -374,14 +380,52 @@ def test_up_with_networks(self): self.base_dir = 'tests/fixtures/networks' self.dispatch(['up', '-d'], None) - networks = self.client.networks(names=[ - '{}_{}'.format(self.project.name, n) - for n in ['foo', 'bar']]) + 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] - self.assertEqual(len(networks), 2) + back_network = [n for n in networks if n['Name'] == back_name][0] + front_network = [n for n in networks if n['Name'] == front_name][0] - for net in networks: - self.assertEqual(net['Driver'], 'bridge') + web_container = self.project.get_service('web').containers()[0] + app_container = self.project.get_service('app').containers()[0] + db_container = self.project.get_service('db').containers()[0] + + # db and app joined the back network + assert sorted(back_network['Containers']) == sorted([db_container.id, app_container.id]) + + # web and app joined the front network + assert sorted(front_network['Containers']) == sorted([web_container.id, app_container.id]) + + def test_up_missing_network(self): + self.base_dir = 'tests/fixtures/networks' + + result = self.dispatch( + ['-f', 'missing-network.yml', 'up', '-d'], + returncode=1) + + assert 'Service "web" uses an undefined network "foo"' in result.stderr + + def test_up_no_services(self): + self.base_dir = 'tests/fixtures/no-services' + self.dispatch(['up', '-d'], None) + + network_names = [ + 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'] + ] def test_up_with_links_is_invalid(self): self.base_dir = 'tests/fixtures/v2-simple' @@ -400,9 +444,7 @@ def test_up_with_links_v1(self): # No network was created networks = self.client.networks(names=[self.project.default_network.full_name]) - for n in networks: - self.addCleanup(self.client.remove_network, n['Id']) - self.assertEqual(len(networks), 0) + assert networks == [] web = self.project.get_service('web') db = self.project.get_service('db') @@ -731,8 +773,6 @@ def test_run_with_networking(self): 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]) - for n in networks: - self.addCleanup(self.client.remove_network, n['Id']) self.assertEqual(len(networks), 1) self.assertEqual(container.human_readable_command, u'true') @@ -890,7 +930,7 @@ def test_kill_stopped_service(self): def test_restart(self): service = self.project.get_service('simple') container = service.create_container() - container.start() + service.start_container(container) started_at = container.dictionary['State']['StartedAt'] self.dispatch(['restart', '-t', '1'], None) container.inspect() diff --git a/tests/fixtures/networks/docker-compose.yml b/tests/fixtures/networks/docker-compose.yml index c0795526f20..f1b79df09f5 100644 --- a/tests/fixtures/networks/docker-compose.yml +++ b/tests/fixtures/networks/docker-compose.yml @@ -1,7 +1,19 @@ version: 2 -networks: - foo: - driver: +services: + web: + image: busybox + command: top + networks: ["front"] + app: + image: busybox + command: top + networks: ["front", "back"] + db: + image: busybox + command: top + networks: ["back"] - bar: {} +networks: + front: {} + back: {} diff --git a/tests/fixtures/networks/missing-network.yml b/tests/fixtures/networks/missing-network.yml new file mode 100644 index 00000000000..666f7d34b22 --- /dev/null +++ b/tests/fixtures/networks/missing-network.yml @@ -0,0 +1,10 @@ +version: 2 + +services: + web: + image: busybox + command: top + networks: ["foo"] + +networks: + bar: {} diff --git a/tests/fixtures/no-services/docker-compose.yml b/tests/fixtures/no-services/docker-compose.yml new file mode 100644 index 00000000000..fa49878467a --- /dev/null +++ b/tests/fixtures/no-services/docker-compose.yml @@ -0,0 +1,5 @@ +version: 2 + +networks: + foo: {} + bar: {} diff --git a/tests/integration/resilience_test.py b/tests/integration/resilience_test.py index 5df751c770d..b544783a4e7 100644 --- a/tests/integration/resilience_test.py +++ b/tests/integration/resilience_test.py @@ -17,7 +17,7 @@ def setUp(self): self.project = Project('composetest', [self.db], self.client) container = self.db.create_container() - container.start() + self.db.start_container(container) self.host_path = container.get_mount('/var/db')['Source'] def test_successful_recreate(self): @@ -35,7 +35,7 @@ def test_create_failure(self): self.assertEqual(container.get_mount('/var/db')['Source'], self.host_path) def test_start_failure(self): - with mock.patch('compose.container.Container.start', crash): + with mock.patch('compose.service.Service.start_container', crash): with self.assertRaises(Crash): self.project.up(strategy=ConvergenceStrategy.always) diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 0ce4103e5df..37ceb65c486 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -32,14 +32,7 @@ def create_and_start_container(service, **override_options): container = service.create_container(**override_options) - container.start() - return container - - -def remove_stopped(service): - containers = [c for c in service.containers(stopped=True) if not c.is_running] - for container in containers: - container.remove() + return service.start_container(container) class ServiceTest(DockerClientTestCase): @@ -88,19 +81,19 @@ def test_create_container_with_one_off_when_existing_container_is_running(self): def test_create_container_with_unspecified_volume(self): service = self.create_service('db', volumes=[VolumeSpec.parse('/var/db')]) container = service.create_container() - container.start() + service.start_container(container) assert container.get_mount('/var/db') def test_create_container_with_volume_driver(self): service = self.create_service('db', volume_driver='foodriver') container = service.create_container() - container.start() + service.start_container(container) self.assertEqual('foodriver', container.get('HostConfig.VolumeDriver')) def test_create_container_with_cpu_shares(self): service = self.create_service('db', cpu_shares=73) container = service.create_container() - container.start() + service.start_container(container) self.assertEqual(container.get('HostConfig.CpuShares'), 73) def test_create_container_with_cpu_quota(self): @@ -113,7 +106,7 @@ 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) container = service.create_container() - container.start() + service.start_container(container) self.assertEqual(set(container.get('HostConfig.ExtraHosts')), set(extra_hosts)) def test_create_container_with_extra_hosts_dicts(self): @@ -121,33 +114,33 @@ def test_create_container_with_extra_hosts_dicts(self): extra_hosts_list = ['somehost:162.242.195.82', 'otherhost:50.31.209.229'] service = self.create_service('db', extra_hosts=extra_hosts) container = service.create_container() - container.start() + service.start_container(container) self.assertEqual(set(container.get('HostConfig.ExtraHosts')), set(extra_hosts_list)) def test_create_container_with_cpu_set(self): service = self.create_service('db', cpuset='0') container = service.create_container() - container.start() + service.start_container(container) self.assertEqual(container.get('HostConfig.CpusetCpus'), '0') def test_create_container_with_read_only_root_fs(self): read_only = True service = self.create_service('db', read_only=read_only) container = service.create_container() - container.start() + service.start_container(container) self.assertEqual(container.get('HostConfig.ReadonlyRootfs'), read_only, container.get('HostConfig')) def test_create_container_with_security_opt(self): security_opt = ['label:disable'] service = self.create_service('db', security_opt=security_opt) container = service.create_container() - container.start() + service.start_container(container) self.assertEqual(set(container.get('HostConfig.SecurityOpt')), set(security_opt)) def test_create_container_with_mac_address(self): service = self.create_service('db', mac_address='02:42:ac:11:65:43') container = service.create_container() - container.start() + service.start_container(container) self.assertEqual(container.inspect()['Config']['MacAddress'], '02:42:ac:11:65:43') def test_create_container_with_specified_volume(self): @@ -158,7 +151,7 @@ def test_create_container_with_specified_volume(self): 'db', volumes=[VolumeSpec(host_path, container_path, 'rw')]) container = service.create_container() - container.start() + service.start_container(container) assert container.get_mount(container_path) # Match the last component ("host-path"), because boot2docker symlinks /tmp @@ -229,7 +222,7 @@ def test_create_container_with_volumes_from(self): ] ) host_container = host_service.create_container() - host_container.start() + host_service.start_container(host_container) self.assertIn(volume_container_1.id + ':rw', host_container.get('HostConfig.VolumesFrom')) self.assertIn(volume_container_2.id + ':rw', @@ -248,7 +241,7 @@ def test_execute_convergence_plan_recreate(self): self.assertEqual(old_container.get('Config.Cmd'), ['-d', '1']) self.assertIn('FOO=1', old_container.get('Config.Env')) self.assertEqual(old_container.name, 'composetest_db_1') - old_container.start() + service.start_container(old_container) old_container.inspect() # reload volume data volume_path = old_container.get_mount('/etc')['Source'] diff --git a/tests/unit/project_test.py b/tests/unit/project_test.py index 470e51ad384..ffd4455f3ec 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -12,8 +12,6 @@ from compose.const import LABEL_SERVICE from compose.container import Container from compose.project import Project -from compose.service import ContainerNet -from compose.service import Net from compose.service import Service @@ -412,29 +410,42 @@ def test_use_net_from_service(self): self.assertEqual(service.net.mode, 'container:' + container_name) def test_uses_default_network_true(self): - web = Service('web', project='test', image="alpine", net=Net('test_default')) - db = Service('web', project='test', image="alpine", net=Net('other')) - project = Project('test', [web, db], None) - assert project.uses_default_network() + project = Project.from_config( + name='test', + client=self.mock_client, + config_data=Config( + version=2, + services=[ + { + 'name': 'foo', + 'image': 'busybox:latest' + }, + ], + networks=None, + volumes=None, + ), + ) - def test_uses_default_network_custom_name(self): - web = Service('web', project='test', image="alpine", net=Net('other')) - project = Project('test', [web], None) - assert not project.uses_default_network() + assert project.uses_default_network() - def test_uses_default_network_host(self): - web = Service('web', project='test', image="alpine", net=Net('host')) - project = Project('test', [web], None) - assert not project.uses_default_network() + def test_uses_default_network_false(self): + project = Project.from_config( + name='test', + client=self.mock_client, + config_data=Config( + version=2, + services=[ + { + 'name': 'foo', + 'image': 'busybox:latest', + 'networks': ['custom'] + }, + ], + networks={'custom': {}}, + volumes=None, + ), + ) - def test_uses_default_network_container(self): - container = mock.Mock(id='test') - web = Service( - 'web', - project='test', - image="alpine", - net=ContainerNet(container)) - project = Project('test', [web], None) assert not project.uses_default_network() def test_container_without_name(self): From 9c91cf29674f4e294f9d24a135933d2e7df4e546 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 14 Jan 2016 12:18:20 +0000 Subject: [PATCH 1701/4072] Test discoverability across multiple networks Signed-off-by: Aanand Prasad --- tests/acceptance/cli_test.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index ff9c34f1f76..4549a3cb78a 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -126,6 +126,20 @@ def dispatch(self, options, project_options=None, returncode=0): proc = start_process(self.base_dir, project_options + options) return wait_on_process(proc, returncode=returncode) + def execute(self, container, cmd): + # Remove once Hijack and CloseNotifier sign a peace treaty + self.client.close() + exc = self.client.exec_create(container.id, cmd) + self.client.exec_start(exc) + return self.client.exec_inspect(exc)['ExitCode'] + + def lookup(self, container, service_name): + exit_code = self.execute(container, [ + "nslookup", + "{}_{}_1".format(self.project.name, service_name) + ]) + return exit_code == 0 + def test_help(self): self.base_dir = 'tests/fixtures/no-composefile' result = self.dispatch(['help', 'up'], returncode=1) @@ -404,6 +418,13 @@ def test_up_with_networks(self): # web and app joined the front network assert sorted(front_network['Containers']) == sorted([web_container.id, app_container.id]) + # web can see app but not db + assert self.lookup(web_container, "app") + assert not self.lookup(web_container, "db") + + # app can see db + assert self.lookup(app_container, "db") + def test_up_missing_network(self): self.base_dir = 'tests/fixtures/networks' From e75629392d5274697a668e3f405cbb5e82d99676 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 14 Jan 2016 13:01:17 +0000 Subject: [PATCH 1702/4072] Don't join the bridge network by default in v2 Signed-off-by: Aanand Prasad --- compose/project.py | 18 ++++++++++-------- tests/acceptance/cli_test.py | 9 ++++++--- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/compose/project.py b/compose/project.py index 292bf2f2e92..8606c11e268 100644 --- a/compose/project.py +++ b/compose/project.py @@ -69,13 +69,18 @@ def from_config(cls, name, config_data, client): ) for service_dict in config_data.services: - networks = project.get_networks( - service_dict, - custom_networks + [project.default_network]) + if use_networking: + networks = project.get_networks( + service_dict, + custom_networks + [project.default_network]) + 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) - net = project.get_net(service_dict) project.services.append( Service( @@ -194,9 +199,6 @@ def get_links(self, service_dict): return links def get_net(self, service_dict): - if self.use_networking: - return Net(None) - net = service_dict.pop('net', None) if not net: return Net(None) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 4549a3cb78a..8db9727be32 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -382,13 +382,16 @@ def test_up(self): self.assertEqual(networks[0]['Driver'], 'bridge') network = self.client.inspect_network(networks[0]['Id']) - # print self.project.services[0].containers()[0].get('NetworkSettings') - self.assertEqual(len(network['Containers']), len(services)) for service in services: containers = service.containers() self.assertEqual(len(containers), 1) - self.assertIn(containers[0].id, network['Containers']) + + container = containers[0] + self.assertIn(container.id, network['Containers']) + + networks = container.get('NetworkSettings.Networks').keys() + self.assertEqual(networks, [network['Name']]) def test_up_with_networks(self): self.base_dir = 'tests/fixtures/networks' From ca68c9faa4d4a4e217c948c2dd5abf25f595c8d7 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 14 Jan 2016 13:16:02 +0000 Subject: [PATCH 1703/4072] Services can join 'bridge' or 'host' Signed-off-by: Aanand Prasad --- compose/project.py | 15 +++++++++------ tests/acceptance/cli_test.py | 19 +++++++++++++++++++ .../fixtures/networks/predefined-networks.yml | 17 +++++++++++++++++ 3 files changed, 45 insertions(+), 6 deletions(-) create mode 100644 tests/fixtures/networks/predefined-networks.yml diff --git a/compose/project.py b/compose/project.py index 8606c11e268..1b5d2eb94a5 100644 --- a/compose/project.py +++ b/compose/project.py @@ -172,13 +172,16 @@ def get_services_without_duplicate(self, service_names=None, include_deps=False) def get_networks(self, service_dict, network_definitions): 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) + if name in ['bridge', 'host']: + networks.append(name) else: - raise ConfigurationError( - 'Service "{}" uses an undefined network "{}"' - .format(service_dict['name'], name)) + 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_links(self, service_dict): diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 8db9727be32..90c50769801 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -437,6 +437,25 @@ def test_up_missing_network(self): assert 'Service "web" uses an undefined network "foo"' in result.stderr + def test_up_predefined_networks(self): + filename = 'predefined-networks.yml' + + self.base_dir = 'tests/fixtures/networks' + self._project = get_project(self.base_dir, [filename]) + + self.dispatch(['-f', filename, 'up', '-d'], None) + + networks = [ + n for n in self.client.networks() + if n['Name'].startswith('{}_'.format(self.project.name)) + ] + assert not networks + + for name in ['bridge', 'host', 'none']: + container = self.project.get_service(name).containers()[0] + assert container.get('NetworkSettings.Networks').keys() == [name] + assert container.get('HostConfig.NetworkMode') == name + def test_up_no_services(self): self.base_dir = 'tests/fixtures/no-services' self.dispatch(['up', '-d'], None) diff --git a/tests/fixtures/networks/predefined-networks.yml b/tests/fixtures/networks/predefined-networks.yml new file mode 100644 index 00000000000..d0fac377d41 --- /dev/null +++ b/tests/fixtures/networks/predefined-networks.yml @@ -0,0 +1,17 @@ +version: 2 + +services: + bridge: + image: busybox + command: top + networks: ["bridge"] + + host: + image: busybox + command: top + networks: ["host"] + + none: + image: busybox + command: top + networks: [] From 73fbd01cfe7c1e8ad7d297d7f7cfb8704aeb501d Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 14 Jan 2016 13:39:44 +0000 Subject: [PATCH 1704/4072] Support the 'external' option for networks Signed-off-by: Aanand Prasad --- compose/network.py | 25 +++++++++++++++++-- compose/project.py | 4 ++- tests/acceptance/cli_test.py | 23 +++++++++++++++++ tests/fixtures/networks/external-networks.yml | 16 ++++++++++++ 4 files changed, 65 insertions(+), 3 deletions(-) create mode 100644 tests/fixtures/networks/external-networks.yml diff --git a/compose/network.py b/compose/network.py index a8f7e918dad..b2ba2e9b776 100644 --- a/compose/network.py +++ b/compose/network.py @@ -11,16 +11,35 @@ log = logging.getLogger(__name__) -# TODO: support external networks class Network(object): - def __init__(self, client, project, name, driver=None, driver_opts=None): + def __init__(self, client, project, name, driver=None, driver_opts=None, + external_name=None): self.client = client self.project = project self.name = name self.driver = driver self.driver_opts = driver_opts + self.external_name = external_name def ensure(self): + if self.external_name: + try: + self.inspect() + log.debug( + 'Network {0} declared as external. No new ' + 'network will be created.'.format(self.name) + ) + except NotFound: + raise ConfigurationError( + 'Network {name} declared as external, but could' + ' not be found. Please create the network manually' + ' using `{command} {name}` and try again.'.format( + name=self.external_name, + command='docker network create' + ) + ) + return + try: data = self.inspect() if self.driver and data['Driver'] != self.driver: @@ -55,4 +74,6 @@ def inspect(self): @property def full_name(self): + if self.external_name: + return self.external_name return '{0}_{1}'.format(self.project, self.name) diff --git a/compose/project.py b/compose/project.py index 1b5d2eb94a5..6a171d514b7 100644 --- a/compose/project.py +++ b/compose/project.py @@ -64,7 +64,9 @@ def from_config(cls, name, config_data, client): custom_networks.append( Network( client=client, project=name, name=network_name, - driver=data.get('driver'), driver_opts=data.get('driver_opts') + driver=data.get('driver'), + driver_opts=data.get('driver_opts'), + external_name=data.get('external_name'), ) ) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 90c50769801..a7d5dbaae2c 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -456,6 +456,29 @@ def test_up_predefined_networks(self): assert container.get('NetworkSettings.Networks').keys() == [name] assert container.get('HostConfig.NetworkMode') == name + def test_up_external_networks(self): + filename = 'external-networks.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_names = ['{}_{}'.format(self.project.name, n) for n in ['foo', 'bar']] + for name in network_names: + self.client.create_network(name) + + self.dispatch(['-f', filename, 'up', '-d']) + container = self.project.containers()[0] + assert sorted(container.get('NetworkSettings.Networks').keys()) == sorted(network_names) + def test_up_no_services(self): self.base_dir = 'tests/fixtures/no-services' self.dispatch(['up', '-d'], None) diff --git a/tests/fixtures/networks/external-networks.yml b/tests/fixtures/networks/external-networks.yml new file mode 100644 index 00000000000..644e3dda9eb --- /dev/null +++ b/tests/fixtures/networks/external-networks.yml @@ -0,0 +1,16 @@ +version: 2 + +services: + web: + image: busybox + command: top + networks: + - networks_foo + - bar + +networks: + networks_foo: + external: true + bar: + external: + name: networks_bar From 87326c00ebc8380729f1999d5f20eddc265164e8 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 14 Jan 2016 14:51:42 +0000 Subject: [PATCH 1705/4072] Python 3 fixes Signed-off-by: Aanand Prasad --- 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 a7d5dbaae2c..9c9ced8ce79 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -390,7 +390,7 @@ def test_up(self): container = containers[0] self.assertIn(container.id, network['Containers']) - networks = container.get('NetworkSettings.Networks').keys() + networks = list(container.get('NetworkSettings.Networks')) self.assertEqual(networks, [network['Name']]) def test_up_with_networks(self): @@ -453,7 +453,7 @@ def test_up_predefined_networks(self): for name in ['bridge', 'host', 'none']: container = self.project.get_service(name).containers()[0] - assert container.get('NetworkSettings.Networks').keys() == [name] + assert list(container.get('NetworkSettings.Networks')) == [name] assert container.get('HostConfig.NetworkMode') == name def test_up_external_networks(self): @@ -477,7 +477,7 @@ def test_up_external_networks(self): self.dispatch(['-f', filename, 'up', '-d']) container = self.project.containers()[0] - assert sorted(container.get('NetworkSettings.Networks').keys()) == sorted(network_names) + assert sorted(list(container.get('NetworkSettings.Networks'))) == sorted(network_names) def test_up_no_services(self): self.base_dir = 'tests/fixtures/no-services' From 4e61377c6d912a4f5e454b6afcb7bde0416a83b3 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 14 Jan 2016 18:01:44 +0000 Subject: [PATCH 1706/4072] Move get_networks() out of Project class Signed-off-by: Aanand Prasad --- compose/project.py | 33 +++++++++++++++++---------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/compose/project.py b/compose/project.py index 6a171d514b7..933849c292d 100644 --- a/compose/project.py +++ b/compose/project.py @@ -72,7 +72,7 @@ def from_config(cls, name, config_data, client): for service_dict in config_data.services: if use_networking: - networks = project.get_networks( + networks = get_networks( service_dict, custom_networks + [project.default_network]) net = Net(networks[0]) if networks else Net("none") @@ -171,21 +171,6 @@ def get_services_without_duplicate(self, service_names=None, include_deps=False) service.remove_duplicate_containers() return services - def get_networks(self, service_dict, network_definitions): - networks = [] - for name in service_dict.pop('networks', ['default']): - if name in ['bridge', 'host']: - networks.append(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)) - return networks - def get_links(self, service_dict): links = [] if 'links' in service_dict: @@ -474,6 +459,22 @@ def _inject_deps(self, acc, service): return acc + dep_services +def get_networks(service_dict, network_definitions): + networks = [] + for name in service_dict.pop('networks', ['default']): + if name in ['bridge', 'host']: + networks.append(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)) + return networks + + def get_volumes_from(project, service_dict): volumes_from = service_dict.pop('volumes_from', None) if not volumes_from: From d98b64f6e7f75e801973e59a6723b5f75e724988 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 14 Jan 2016 18:09:45 +0000 Subject: [PATCH 1707/4072] Remove duplicated logic from initialize_networks() Signed-off-by: Aanand Prasad --- compose/project.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/compose/project.py b/compose/project.py index 933849c292d..12d52cc26a4 100644 --- a/compose/project.py +++ b/compose/project.py @@ -300,11 +300,7 @@ def initialize_networks(self): if not self.use_networking: return - networks = self.networks - if self.uses_default_network(): - networks.append(self.default_network) - - for network in networks: + for network in self.networks: network.ensure() def uses_default_network(self): From a7be0afa5b3a1b62324e22b0d81c51dc939966d6 Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Thu, 14 Jan 2016 10:32:06 -0800 Subject: [PATCH 1708/4072] bash completion for `docker-compose down` Signed-off-by: Harald Albers --- contrib/completion/bash/docker-compose | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index 3f6725170ae..36d1ef27fd2 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -129,6 +129,22 @@ _docker_compose_docker_compose() { } +_docker_compose_down() { + case "$prev" in + --rmi) + COMPREPLY=( $( compgen -W "all local" -- "$cur" ) ) + return + ;; + esac + + case "$cur" in + -*) + COMPREPLY=( $( compgen -W "--help --rmi --volumes -v" -- "$cur" ) ) + ;; + esac +} + + _docker_compose_events() { case "$prev" in --json) @@ -393,6 +409,7 @@ _docker_compose() { local commands=( build config + down events help kill From fca3e47a7519844704c79dac4fc55b6ce79e4923 Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Thu, 14 Jan 2016 10:43:53 -0800 Subject: [PATCH 1709/4072] bash completion for `docker-compose up --abort-on-container-exit` 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 3f6725170ae..b3ed84117cc 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -368,7 +368,7 @@ _docker_compose_up() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "-d --help --no-build --no-color --no-deps --no-recreate --force-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" -- "$cur" ) ) ;; *) __docker_compose_services_all From 79df2ebe1bbe81232acd84eeca7bf66af8e3004b Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 13 Jan 2016 15:19:02 -0500 Subject: [PATCH 1710/4072] Support variable interpolation for volumes and networks sections. Signed-off-by: Daniel Nephin --- compose/config/config.py | 21 +++++--- compose/config/interpolation.py | 29 +++++------ tests/unit/config/config_test.py | 16 +++--- tests/unit/config/interpolation_test.py | 69 +++++++++++++++++++++++++ 4 files changed, 103 insertions(+), 32 deletions(-) create mode 100644 tests/unit/config/interpolation_test.py diff --git a/compose/config/config.py b/compose/config/config.py index c8d93faf6dc..f6df3d3bf55 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -138,6 +138,9 @@ def get_service_dicts(self): def get_volumes(self): return {} if self.version == 1 else self.config.get('volumes', {}) + def get_networks(self): + return {} if self.version == 1 else self.config.get('networks', {}) + class Config(namedtuple('_Config', 'version services volumes networks')): """ @@ -258,8 +261,8 @@ 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, 'volumes', 'Volume') - networks = load_mapping(config_details.config_files, '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.filename, @@ -268,11 +271,11 @@ def load(config_details): return Config(main_file.version, service_dicts, volumes, networks) -def load_mapping(config_files, key, entity_type): +def load_mapping(config_files, get_func, entity_type): mapping = {} for config_file in config_files: - for name, config in config_file.config.get(key, {}).items(): + for name, config in getattr(config_file, get_func)().items(): mapping[name] = config or {} if not config: continue @@ -347,12 +350,16 @@ 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) - # TODO: interpolate config in volumes/network sections as well - interpolated_config = interpolate_environment_variables(service_dicts) + interpolated_config = interpolate_environment_variables(service_dicts, 'service') if config_file.version == 2: processed_config = dict(config_file.config) - processed_config.update({'services': interpolated_config}) + processed_config['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 diff --git a/compose/config/interpolation.py b/compose/config/interpolation.py index 7a757644897..e1c781fec67 100644 --- a/compose/config/interpolation.py +++ b/compose/config/interpolation.py @@ -11,35 +11,32 @@ log = logging.getLogger(__name__) -def interpolate_environment_variables(service_dicts): +def interpolate_environment_variables(config, section): mapping = BlankDefaultDict(os.environ) - return dict( - (service_name, process_service(service_name, service_dict, mapping)) - for (service_name, service_dict) in service_dicts.items() - ) - + def process_item(name, config_dict): + return dict( + (key, interpolate_value(name, key, val, section, mapping)) + for key, val in (config_dict or {}).items() + ) -def process_service(service_name, service_dict, mapping): return dict( - (key, interpolate_value(service_name, key, val, mapping)) - for (key, val) in service_dict.items() + (name, process_item(name, config_dict)) + for name, config_dict in config.items() ) -def interpolate_value(service_name, config_key, value, mapping): +def interpolate_value(name, config_key, value, section, mapping): try: return recursive_interpolate(value, mapping) except InvalidInterpolation as e: raise ConfigurationError( 'Invalid interpolation format for "{config_key}" option ' - 'in service "{service_name}": "{string}"' - .format( + 'in {section} "{name}": "{string}"'.format( config_key=config_key, - service_name=service_name, - string=e.string, - ) - ) + name=name, + section=section, + string=e.string)) def recursive_interpolate(obj, mapping): diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index f0d432d5820..f88166432cb 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -686,8 +686,8 @@ def test_logs_warning_for_boolean_in_environment(self, mock_logging): ) ) - self.assertTrue(mock_logging.warn.called) - self.assertTrue(expected_warning_msg in mock_logging.warn.call_args[0][0]) + assert mock_logging.warn.called + assert expected_warning_msg in mock_logging.warn.call_args[0][0] def test_config_valid_environment_dict_key_contains_dashes(self): services = config.load( @@ -1664,15 +1664,13 @@ def test_invalid_net_in_extended_service(self): load_from_filename('tests/fixtures/extends/invalid-net.yml') @mock.patch.dict(os.environ) - def test_valid_interpolation_in_extended_service(self): - os.environ.update( - HOSTNAME_VALUE="penguin", - ) + def test_load_config_runs_interpolation_in_extended_service(self): + os.environ.update(HOSTNAME_VALUE="penguin") expected_interpolated_value = "host-penguin" - - service_dicts = load_from_filename('tests/fixtures/extends/valid-interpolation.yml') + service_dicts = load_from_filename( + 'tests/fixtures/extends/valid-interpolation.yml') for service in service_dicts: - self.assertTrue(service['hostname'], expected_interpolated_value) + assert service['hostname'] == expected_interpolated_value @pytest.mark.xfail(IS_WINDOWS_PLATFORM, reason='paths use slash') def test_volume_path(self): diff --git a/tests/unit/config/interpolation_test.py b/tests/unit/config/interpolation_test.py new file mode 100644 index 00000000000..0691e88652f --- /dev/null +++ b/tests/unit/config/interpolation_test.py @@ -0,0 +1,69 @@ +from __future__ import absolute_import +from __future__ import unicode_literals + +import os + +import mock +import pytest + +from compose.config.interpolation import interpolate_environment_variables + + +@pytest.yield_fixture +def mock_env(): + with mock.patch.dict(os.environ): + os.environ['USER'] = 'jenny' + os.environ['FOO'] = 'bar' + yield + + +def test_interpolate_environment_variables_in_services(mock_env): + services = { + 'servivea': { + 'image': 'example:${USER}', + 'volumes': ['$FOO:/target'], + 'logging': { + 'driver': '${FOO}', + 'options': { + 'user': '$USER', + } + } + } + } + expected = { + 'servivea': { + 'image': 'example:jenny', + 'volumes': ['bar:/target'], + 'logging': { + 'driver': 'bar', + 'options': { + 'user': 'jenny', + } + } + } + } + assert interpolate_environment_variables(services, 'service') == expected + + +def test_interpolate_environment_variables_in_volumes(mock_env): + volumes = { + 'data': { + 'driver': '$FOO', + 'driver_opts': { + 'max': 2, + 'user': '${USER}' + } + }, + 'other': None, + } + expected = { + 'data': { + 'driver': 'bar', + 'driver_opts': { + 'max': 2, + 'user': 'jenny' + } + }, + 'other': {}, + } + assert interpolate_environment_variables(volumes, 'volume') == expected From 9cfa71ceee3cb164119b448edef8ac0bda63f751 Mon Sep 17 00:00:00 2001 From: Garrett Heel Date: Fri, 11 Dec 2015 15:19:51 -0800 Subject: [PATCH 1711/4072] Add support for build arguments Allows 'build' configuration option to be specified as an object and adds support for build args. Signed-off-by: Garrett Heel --- compose/config/config.py | 110 +++++++++++++++++++++----- compose/config/service_schema_v2.json | 15 +++- compose/config/validation.py | 17 +++- compose/service.py | 6 +- docs/compose-file.md | 67 +++++++++++++++- tests/integration/service_test.py | 32 +++++--- tests/integration/state_test.py | 6 +- tests/unit/config/config_test.py | 82 +++++++++++++++++-- tests/unit/service_test.py | 9 ++- 9 files changed, 296 insertions(+), 48 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index c8d93faf6dc..8200900f881 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -2,6 +2,7 @@ from __future__ import unicode_literals import codecs +import functools import logging import operator import os @@ -455,6 +456,12 @@ def resolve_environment(service_dict): return dict(resolve_env_var(k, v) for k, v in six.iteritems(env)) +def resolve_build_args(build): + args = {} + args.update(parse_build_arguments(build.get('args'))) + return dict(resolve_env_var(k, v) for k, v in six.iteritems(args)) + + def validate_extended_service_dict(service_dict, filename, service): error_prefix = "Cannot extend service '%s' in %s:" % (service, filename) @@ -492,12 +499,16 @@ def process_service(service_config): for path in to_list(service_dict['env_file']) ] + if 'build' in service_dict: + if isinstance(service_dict['build'], six.string_types): + service_dict['build'] = resolve_build_path(working_dir, service_dict['build']) + elif isinstance(service_dict['build'], dict) and 'context' in service_dict['build']: + path = service_dict['build']['context'] + service_dict['build']['context'] = resolve_build_path(working_dir, path) + if 'volumes' in service_dict and service_dict.get('volume_driver') is None: service_dict['volumes'] = resolve_volume_paths(working_dir, service_dict) - if 'build' in service_dict: - service_dict['build'] = resolve_build_path(working_dir, service_dict['build']) - if 'labels' in service_dict: service_dict['labels'] = parse_labels(service_dict['labels']) @@ -535,6 +546,8 @@ 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) + return normalize_v1_service_format(service_dict) @@ -599,10 +612,31 @@ def merge_mapping(mapping, parse_func): if version == 1: legacy_v1_merge_image_or_build(d, base, override) + else: + merge_build(d, base, override) return d +def merge_build(output, base, override): + build = {} + + 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 + + def legacy_v1_merge_image_or_build(output, base, override): output.pop('image', None) output.pop('build', None) @@ -622,29 +656,41 @@ def merge_environment(base, override): return env -def parse_environment(environment): - if not environment: +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) + else: + return label, '' + + +def parse_dict_or_list(split_func, type_name, arguments): + if not arguments: return {} - if isinstance(environment, list): - return dict(split_env(e) for e in environment) + if isinstance(arguments, list): + return dict(split_func(e) for e in arguments) - if isinstance(environment, dict): - return dict(environment) + if isinstance(arguments, dict): + return dict(arguments) raise ConfigurationError( - "environment \"%s\" must be a list or mapping," % - environment + "%s \"%s\" must be a list or mapping," % + (type_name, arguments) ) -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 +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') def resolve_env_var(key, val): @@ -690,6 +736,26 @@ def resolve_volume_path(working_dir, volume): return container_path +def normalize_build(service_dict, working_dir): + build = {} + + # supported in V1 only + if 'dockerfile' in service_dict: + build['dockerfile'] = service_dict.pop('dockerfile') + + if 'build' in service_dict: + # Shortcut where specifying a string is treated as the build context + if isinstance(service_dict['build'], six.string_types): + build['context'] = service_dict.pop('build') + else: + build.update(service_dict['build']) + if 'args' in build: + build['args'] = resolve_build_args(build) + + if build: + service_dict['build'] = build + + def resolve_build_path(working_dir, build_path): if is_url(build_path): return build_path @@ -702,7 +768,13 @@ def is_url(build_path): def validate_paths(service_dict): if 'build' in service_dict: - build_path = service_dict['build'] + build = service_dict.get('build', {}) + + if isinstance(build, six.string_types): + build_path = build + elif isinstance(build, dict) and 'context' in build: + build_path = build['context'] + if ( not is_url(build_path) and (not os.path.exists(build_path) or not os.access(build_path, os.R_OK)) diff --git a/compose/config/service_schema_v2.json b/compose/config/service_schema_v2.json index 5f4e047816f..23d0381c7c2 100644 --- a/compose/config/service_schema_v2.json +++ b/compose/config/service_schema_v2.json @@ -15,7 +15,20 @@ "type": "object", "properties": { - "build": {"type": "string"}, + "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"}, diff --git a/compose/config/validation.py b/compose/config/validation.py index 0bf75691547..639e8bed2b8 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -150,18 +150,29 @@ def handle_error_for_schema_with_id(error, service_name): 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 'build' in error.instance: + if 'image' in error.instance and context: 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)) - if 'image' not in error.instance and 'build' not in error.instance: + 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)) # TODO: only applies to v1 - if 'image' in error.instance and 'dockerfile' in error.instance: + if 'image' in error.instance and dockerfile: return ( "Service '{}' has both an image and alternate Dockerfile. " "A service can either be built to image or use an existing " diff --git a/compose/service.py b/compose/service.py index 0a7f0d8e84b..c91c3a58c9a 100644 --- a/compose/service.py +++ b/compose/service.py @@ -638,7 +638,8 @@ def _get_container_host_config(self, override_options, one_off=False): def build(self, no_cache=False, pull=False, force_rm=False): log.info('Building %s' % self.name) - path = self.options['build'] + 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: @@ -652,7 +653,8 @@ def build(self, no_cache=False, pull=False, force_rm=False): forcerm=force_rm, pull=pull, nocache=no_cache, - dockerfile=self.options.get('dockerfile', None), + dockerfile=build_opts.get('dockerfile', None), + buildargs=build_opts.get('args', None), ) try: diff --git a/docs/compose-file.md b/docs/compose-file.md index 4759cde0563..a9e54014864 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -37,7 +37,8 @@ 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) +named [volumes](#volume-configuration-reference) or +[build arguments](#args). Example: @@ -89,6 +90,30 @@ definition. ### build +Configuration options that are applied at build time. + +In version 1 this must be given as a string representing the context. + + build: . + +In version 2 this can alternatively be given as an object with extra options. + + version: 2 + services: + web: + build: . + + version: 2 + services: + web: + build: + context: . + dockerfile: Dockerfile-alternate + args: + buildno: 1 + +#### context + 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 @@ -99,9 +124,46 @@ Compose will build and tag it with a generated name, and use that image thereaft build: /path/to/build/dir -Using `build` together with `image` is not allowed. Attempting to do so results in + build: + context: /path/to/build/dir + +Using `context` together with `image` is not allowed. Attempting to do so results in an error. +#### dockerfile + +Alternate Dockerfile. + +Compose will use an alternate file to build with. A build path must also be +specified using the `build` key. + + build: + context: /path/to/build/dir + dockerfile: Dockerfile-alternate + +Using `dockerfile` together with `image` is not allowed. Attempting to do so results in an error. + +#### args + +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. + +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 + user: someuser + + build: + args: + - buildno=1 + - user=someuser + ### cap_add, cap_drop Add or drop container capabilities. @@ -194,6 +256,7 @@ The entrypoint can also be a list, in a manner similar to [dockerfile](https://d - memory_limit=-1 - vendor/bin/phpunit + ### env_file Add environment variables from a file. Can be a single value or a list. diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 37ceb65c486..0e91dcf7ce7 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -294,7 +294,7 @@ def test_execute_convergence_plan_with_image_declared_volume(self): project='composetest', name='db', client=self.client, - build='tests/fixtures/dockerfile-with-volume', + build={'context': 'tests/fixtures/dockerfile-with-volume'}, ) old_container = create_and_start_container(service) @@ -315,7 +315,7 @@ def test_execute_convergence_plan_with_image_declared_volume(self): def test_execute_convergence_plan_when_image_volume_masks_config(self): service = self.create_service( 'db', - build='tests/fixtures/dockerfile-with-volume', + build={'context': 'tests/fixtures/dockerfile-with-volume'}, ) old_container = create_and_start_container(service) @@ -346,7 +346,7 @@ def test_execute_convergence_plan_when_image_volume_masks_config(self): def test_execute_convergence_plan_without_start(self): service = self.create_service( 'db', - build='tests/fixtures/dockerfile-with-volume' + build={'context': 'tests/fixtures/dockerfile-with-volume'} ) containers = service.execute_convergence_plan(ConvergencePlan('create', []), start=False) @@ -450,7 +450,7 @@ def test_start_container_builds_images(self): service = Service( name='test', client=self.client, - build='tests/fixtures/simple-dockerfile', + build={'context': 'tests/fixtures/simple-dockerfile'}, project='composetest', ) container = create_and_start_container(service) @@ -463,7 +463,7 @@ def test_start_container_uses_tagged_image_if_it_exists(self): service = Service( name='test', client=self.client, - build='this/does/not/exist/and/will/throw/error', + build={'context': 'this/does/not/exist/and/will/throw/error'}, project='composetest', ) container = create_and_start_container(service) @@ -483,7 +483,7 @@ def test_build(self): with open(os.path.join(base_dir, 'Dockerfile'), 'w') as f: f.write("FROM busybox\n") - self.create_service('web', build=base_dir).build() + self.create_service('web', build={'context': base_dir}).build() assert self.client.inspect_image('composetest_web') def test_build_non_ascii_filename(self): @@ -496,7 +496,7 @@ def test_build_non_ascii_filename(self): with open(os.path.join(base_dir.encode('utf8'), b'foo\xE2bar'), 'w') as f: f.write("hello world\n") - self.create_service('web', build=text_type(base_dir)).build() + self.create_service('web', build={'context': text_type(base_dir)}).build() assert self.client.inspect_image('composetest_web') def test_build_with_image_name(self): @@ -508,16 +508,30 @@ def test_build_with_image_name(self): image_name = 'examples/composetest:latest' self.addCleanup(self.client.remove_image, image_name) - self.create_service('web', build=base_dir, image=image_name).build() + self.create_service('web', build={'context': base_dir}, image=image_name).build() assert self.client.inspect_image(image_name) def test_build_with_git_url(self): build_url = "https://github.com/dnephin/docker-build-from-url.git" - service = self.create_service('buildwithurl', build=build_url) + service = self.create_service('buildwithurl', build={'context': build_url}) self.addCleanup(self.client.remove_image, service.image_name) service.build() assert service.image() + def test_build_with_build_args(self): + base_dir = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, base_dir) + + with open(os.path.join(base_dir, 'Dockerfile'), 'w') as f: + f.write("FROM busybox\n") + f.write("ARG build_version\n") + + service = self.create_service('buildwithargs', + build={'context': text_type(base_dir), + 'args': {"build_version": "1"}}) + service.build() + assert service.image() + def test_start_container_stays_unpriviliged(self): service = self.create_service('web') container = create_and_start_container(service).inspect() diff --git a/tests/integration/state_test.py b/tests/integration/state_test.py index 6e656c292c2..36099d2dd55 100644 --- a/tests/integration/state_test.py +++ b/tests/integration/state_test.py @@ -266,13 +266,13 @@ def test_trigger_recreate_with_build(self): dockerfile = context.join('Dockerfile') dockerfile.write(base_image) - web = self.create_service('web', build=str(context)) + web = self.create_service('web', build={'context': str(context)}) container = web.create_container() dockerfile.write(base_image + 'CMD echo hello world\n') web.build() - web = self.create_service('web', build=str(context)) + web = self.create_service('web', build={'context': str(context)}) self.assertEqual(('recreate', [container]), web.convergence_plan()) def test_image_changed_to_build(self): @@ -286,7 +286,7 @@ def test_image_changed_to_build(self): web = self.create_service('web', image='busybox') container = web.create_container() - web = self.create_service('web', build=str(context)) + web = self.create_service('web', build={'context': str(context)}) plan = web.convergence_plan() self.assertEqual(('recreate', [container]), plan) containers = web.execute_convergence_plan(plan) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index f0d432d5820..ddb992fd593 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -12,6 +12,7 @@ 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.errors import ConfigurationError from compose.config.types import VolumeSpec @@ -284,7 +285,7 @@ def test_load_with_multiple_files_v1(self): expected = [ { 'name': 'web', - 'build': os.path.abspath('/'), + 'build': {'context': os.path.abspath('/')}, 'volumes': [VolumeSpec.parse('/home/user/project:/code')], 'links': ['db'], }, @@ -414,6 +415,59 @@ def test_load_sorts_in_dependency_order(self): assert services[1]['name'] == 'db' assert services[2]['name'] == 'web' + def test_config_build_configuration(self): + service = config.load( + build_config_details( + {'web': { + 'build': '.', + 'dockerfile': 'Dockerfile-alt' + }}, + 'tests/fixtures/extends', + 'filename.yml' + ) + ).services + self.assertTrue('context' in service[0]['build']) + self.assertEqual(service[0]['build']['dockerfile'], 'Dockerfile-alt') + + def test_config_build_configuration_v2(self): + service = config.load( + build_config_details( + { + 'version': 2, + 'services': { + 'web': { + 'build': '.', + 'dockerfile': 'Dockerfile-alt' + } + } + }, + 'tests/fixtures/extends', + 'filename.yml' + ) + ).services + self.assertTrue('context' in service[0]['build']) + self.assertEqual(service[0]['build']['dockerfile'], 'Dockerfile-alt') + + service = config.load( + build_config_details( + { + 'version': 2, + 'services': { + 'web': { + 'build': { + 'context': '.', + 'dockerfile': 'Dockerfile-alt' + } + } + } + }, + 'tests/fixtures/extends', + 'filename.yml' + ) + ).services + self.assertTrue('context' in service[0]['build']) + self.assertEqual(service[0]['build']['dockerfile'], 'Dockerfile-alt') + def test_load_with_multiple_files_v2(self): base_file = config.ConfigFile( 'base.yaml', @@ -445,7 +499,7 @@ def test_load_with_multiple_files_v2(self): expected = [ { 'name': 'web', - 'build': os.path.abspath('/'), + 'build': {'context': os.path.abspath('/')}, 'image': 'example/web', 'volumes': [VolumeSpec.parse('/home/user/project:/code')], }, @@ -1157,7 +1211,7 @@ def test_merge_build_or_image_override_with_other(self): self.assertEqual( config.merge_service_dicts({'image': 'redis'}, {'build': '.'}, V1), - {'build': '.'}, + {'build': '.'} ) @@ -1388,6 +1442,24 @@ def test_resolve_environment_from_env_file_with_empty_values(self): }, ) + @mock.patch.dict(os.environ) + def test_resolve_build_args(self): + os.environ['env_arg'] = 'value2' + + build = { + 'context': '.', + 'args': { + 'arg1': 'value1', + 'empty_arg': '', + 'env_arg': None, + 'no_env': None + } + } + self.assertEqual( + resolve_build_args(build), + {'arg1': 'value1', 'empty_arg': '', 'env_arg': 'value2', 'no_env': ''}, + ) + @pytest.mark.xfail(IS_WINDOWS_PLATFORM, reason='paths use slash') @mock.patch.dict(os.environ) def test_resolve_path(self): @@ -1873,7 +1945,7 @@ def test_absolute_path(self): def test_from_file(self): service_dict = load_from_filename('tests/fixtures/build-path/docker-compose.yml') - self.assertEquals(service_dict, [{'name': 'foo', 'build': self.abs_context_path}]) + self.assertEquals(service_dict, [{'name': 'foo', 'build': {'context': self.abs_context_path}}]) def test_valid_url_in_build_path(self): valid_urls = [ @@ -1888,7 +1960,7 @@ def test_valid_url_in_build_path(self): service_dict = config.load(build_config_details({ 'validurl': {'build': valid_url}, }, '.', None)).services - assert service_dict[0]['build'] == valid_url + assert service_dict[0]['build'] == {'context': valid_url} def test_invalid_url_in_build_path(self): invalid_urls = [ diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 9a3e13b45e7..c9244a47d4b 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -355,7 +355,7 @@ def test_parse_repository_tag(self): 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='.') + service = Service('foo', client=self.mock_client, build={'context': '.'}) self.mock_client.inspect_image.side_effect = [ NoSuchImageError, {'Id': 'abc123'}, @@ -374,17 +374,18 @@ def test_create_container_with_build(self): forcerm=False, nocache=False, rm=True, + buildargs=None, ) def test_create_container_no_build(self): - service = Service('foo', client=self.mock_client, build='.') + service = Service('foo', client=self.mock_client, build={'context': '.'}) self.mock_client.inspect_image.return_value = {'Id': 'abc123'} service.create_container(do_build=False) 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='.') + 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) @@ -394,7 +395,7 @@ def test_build_does_not_pull(self): b'{"stream": "Successfully built 12345"}', ] - service = Service('foo', client=self.mock_client, build='.') + service = Service('foo', client=self.mock_client, build={'context': '.'}) service.build() self.assertEqual(self.mock_client.build.call_count, 1) From 13063a96cbbc7848a87b1b3137fedbadc8fef188 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 14 Jan 2016 12:10:42 -0800 Subject: [PATCH 1712/4072] Fix handling of service.dockerfile key Made invalid in v2 format Doesn't break build config anymore Signed-off-by: Joffrey F --- compose/config/config.py | 6 ++++ compose/config/service_schema_v2.json | 1 - tests/unit/config/config_test.py | 42 +++++++++++++++++---------- 3 files changed, 33 insertions(+), 16 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 8200900f881..86f0aa3be76 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -562,6 +562,12 @@ def normalize_v1_service_format(service_dict): service_dict['logging']['options'] = service_dict['log_opt'] del service_dict['log_opt'] + if 'dockerfile' in service_dict: + service_dict['build'] = service_dict.get('build', {}) + service_dict['build'].update({ + 'dockerfile': service_dict.pop('dockerfile') + }) + return service_dict diff --git a/compose/config/service_schema_v2.json b/compose/config/service_schema_v2.json index 23d0381c7c2..8623507a3a0 100644 --- a/compose/config/service_schema_v2.json +++ b/compose/config/service_schema_v2.json @@ -45,7 +45,6 @@ "devices": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, "dns": {"$ref": "#/definitions/string_or_list"}, "dns_search": {"$ref": "#/definitions/string_or_list"}, - "dockerfile": {"type": "string"}, "domainname": {"type": "string"}, "entrypoint": { "oneOf": [ diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index ddb992fd593..5146df1e9d6 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -430,23 +430,35 @@ def test_config_build_configuration(self): self.assertEqual(service[0]['build']['dockerfile'], 'Dockerfile-alt') def test_config_build_configuration_v2(self): - service = config.load( - build_config_details( - { - 'version': 2, - 'services': { - 'web': { - 'build': '.', - 'dockerfile': 'Dockerfile-alt' + # service.dockerfile is invalid in v2 + with self.assertRaises(ConfigurationError): + config.load( + build_config_details( + { + 'version': 2, + 'services': { + 'web': { + 'build': '.', + 'dockerfile': 'Dockerfile-alt' + } } - } - }, - 'tests/fixtures/extends', - 'filename.yml' + }, + 'tests/fixtures/extends', + 'filename.yml' + ) ) - ).services - self.assertTrue('context' in service[0]['build']) - self.assertEqual(service[0]['build']['dockerfile'], 'Dockerfile-alt') + + service = config.load( + build_config_details({ + 'version': 2, + 'services': { + 'web': { + 'build': '.' + } + } + }, 'tests/fixtures/extends', 'filename.yml') + ).services[0] + self.assertTrue('context' in service['build']) service = config.load( build_config_details( From ce9f2681a2be99ed97915674de9dd26e441298d4 Mon Sep 17 00:00:00 2001 From: Clemens Gutweiler Date: Thu, 7 Jan 2016 17:59:51 +0100 Subject: [PATCH 1713/4072] Fixes #1422: ipv6 addr contains colons, so we split only by the first char. Signed-off-by: Clemens Gutweiler --- compose/config/types.py | 2 +- tests/unit/config/types_test.py | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/compose/config/types.py b/compose/config/types.py index cec1f6cfdff..437f41205ad 100644 --- a/compose/config/types.py +++ b/compose/config/types.py @@ -58,7 +58,7 @@ def parse_extra_hosts(extra_hosts_config): extra_hosts_dict = {} for extra_hosts_line in extra_hosts_config: # TODO: validate string contains ':' ? - host, ip = extra_hosts_line.split(':') + host, ip = extra_hosts_line.split(':', 1) extra_hosts_dict[host.strip()] = ip.strip() return extra_hosts_dict diff --git a/tests/unit/config/types_test.py b/tests/unit/config/types_test.py index 4df665485e0..702aa977efb 100644 --- a/tests/unit/config/types_test.py +++ b/tests/unit/config/types_test.py @@ -16,11 +16,13 @@ def test_parse_extra_hosts_list(): assert parse_extra_hosts([ "www.example.com: 192.168.0.17", "static.example.com:192.168.0.19", - "api.example.com: 192.168.0.18" + "api.example.com: 192.168.0.18", + "v6.example.com: ::1" ]) == { 'www.example.com': '192.168.0.17', 'static.example.com': '192.168.0.19', - 'api.example.com': '192.168.0.18' + 'api.example.com': '192.168.0.18', + 'v6.example.com': '::1' } From 1ae57d92d4081380759fc4f816975d1a3a4d459c Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 14 Jan 2016 13:12:39 -0800 Subject: [PATCH 1714/4072] Remove duplicate functions Signed-off-by: Joffrey F --- compose/config/config.py | 44 +++++++++------------------------------- docs/compose-file.md | 12 ----------- 2 files changed, 10 insertions(+), 46 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 86f0aa3be76..58b73dbe276 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -457,8 +457,7 @@ def resolve_environment(service_dict): def resolve_build_args(build): - args = {} - args.update(parse_build_arguments(build.get('args'))) + args = parse_build_arguments(build.get('args')) return dict(resolve_env_var(k, v) for k, v in six.iteritems(args)) @@ -699,6 +698,14 @@ def parse_dict_or_list(split_func, type_name, arguments): parse_labels = functools.partial(parse_dict_or_list, split_label, 'labels') +def parse_ulimits(ulimits): + if not ulimits: + return {} + + if isinstance(ulimits, dict): + return dict(ulimits) + + def resolve_env_var(key, val): if val is not None: return key, val @@ -743,13 +750,9 @@ def resolve_volume_path(working_dir, volume): def normalize_build(service_dict, working_dir): - build = {} - - # supported in V1 only - if 'dockerfile' in service_dict: - build['dockerfile'] = service_dict.pop('dockerfile') if 'build' in service_dict: + build = {} # Shortcut where specifying a string is treated as the build context if isinstance(service_dict['build'], six.string_types): build['context'] = service_dict.pop('build') @@ -758,7 +761,6 @@ def normalize_build(service_dict, working_dir): if 'args' in build: build['args'] = resolve_build_args(build) - if build: service_dict['build'] = build @@ -835,32 +837,6 @@ def join_path_mapping(pair): return ":".join((host, container)) -def parse_labels(labels): - if not labels: - return {} - - if isinstance(labels, list): - return dict(split_label(e) for e in labels) - - if isinstance(labels, dict): - return dict(labels) - - -def split_label(label): - if '=' in label: - return label.split('=', 1) - else: - return label, '' - - -def parse_ulimits(ulimits): - if not ulimits: - return {} - - if isinstance(ulimits, dict): - return dict(ulimits) - - def expand_path(working_dir, path): return os.path.abspath(os.path.join(working_dir, os.path.expanduser(path))) diff --git a/docs/compose-file.md b/docs/compose-file.md index a9e54014864..ecd135f1904 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -228,18 +228,6 @@ Custom DNS search domains. Can be a single value or a list. - dc1.example.com - dc2.example.com -### dockerfile - -Alternate Dockerfile. - -Compose will use an alternate file to build with. A build path must also be -specified using the `build` key. - - build: /path/to/build/dir - dockerfile: Dockerfile-alternate - -Using `dockerfile` together with `image` is not allowed. Attempting to do so results in an error. - ### entrypoint Override the default entrypoint. From b689c4a21888b751eb566630f6d441128d196cdd Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 14 Jan 2016 11:39:16 -0500 Subject: [PATCH 1715/4072] Move service validation to validate_service(). Signed-off-by: Daniel Nephin --- compose/config/config.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 4684161e3b6..a378f2762c3 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -312,15 +312,11 @@ def build_service(service_name, service_dict, service_names): resolver = ServiceExtendsResolver(service_config, version) service_dict = process_service(resolver.run()) - # TODO: move to validate_service() - validate_against_service_schema(service_dict, service_config.name, version) - validate_paths(service_dict) - + validate_service(service_dict, service_config.name, version) service_dict = finalize_service( service_config._replace(config=service_dict), service_names, version) - service_dict['name'] = service_config.name return service_dict def build_services(service_config): @@ -494,7 +490,14 @@ def validate_ulimits(ulimit_config): "than 'hard' value".format(ulimit_config)) -# TODO: rename to normalize_service +def validate_service(service_dict, service_name, version): + validate_against_service_schema(service_dict, service_name, version) + validate_paths(service_dict) + + if 'ulimits' in service_dict: + validate_ulimits(service_dict['ulimits']) + + def process_service(service_config): working_dir = service_config.working_dir service_dict = dict(service_config.config) @@ -525,10 +528,6 @@ def process_service(service_config): if field in service_dict: service_dict[field] = to_list(service_dict[field]) - # TODO: move to a validate_service() - if 'ulimits' in service_dict: - validate_ulimits(service_dict['ulimits']) - return service_dict @@ -554,6 +553,7 @@ def finalize_service(service_config, service_names, version): normalize_build(service_dict, service_config.working_dir) + service_dict['name'] = service_config.name return normalize_v1_service_format(service_dict) From b98e2169e6aebd786dab5554e91dce55c248abb9 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 14 Jan 2016 13:28:53 -0500 Subject: [PATCH 1716/4072] Error when the project name is invalid for the image name. Signed-off-by: Daniel Nephin --- compose/config/config.py | 12 ++++++++++++ tests/unit/config/config_test.py | 7 +++++++ 2 files changed, 19 insertions(+) diff --git a/compose/config/config.py b/compose/config/config.py index a378f2762c3..b38942253b2 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -6,6 +6,7 @@ import logging import operator import os +import string import sys from collections import namedtuple @@ -497,6 +498,13 @@ def validate_service(service_dict, service_name, version): if 'ulimits' in service_dict: validate_ulimits(service_dict['ulimits']) + if not service_dict.get('image') and has_uppercase(service_name): + raise ConfigurationError( + "Service '{name}' contains uppercase characters which are not valid " + "as part of an image name. Either use a lowercase service name or " + "use the `image` field to set a custom name for the service image." + .format(name=service_name)) + def process_service(service_config): working_dir = service_config.working_dir @@ -861,6 +869,10 @@ def to_list(value): return value +def has_uppercase(name): + return any(char in string.ascii_uppercase for char in name) + + def load_yaml(filename): try: with open(filename, 'r') as fh: diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 10c5bbb7b67..e24dc904113 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -544,6 +544,13 @@ def test_config_hint(self): ) ) + def test_load_errors_on_uppercase_with_no_image(self): + with pytest.raises(ConfigurationError) as exc: + config.load(build_config_details({ + 'Foo': {'build': '.'}, + }, '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): From 0f234154c24da87d524e39255e659ae340227278 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 14 Jan 2016 14:35:02 -0500 Subject: [PATCH 1717/4072] Remove all non-external networks on down. Also moves the shutdown test fixtures to be a more general v2-full fixture. Signed-off-by: Daniel Nephin --- compose/network.py | 5 ++++- compose/project.py | 8 ++++---- tests/acceptance/cli_test.py | 18 ++++++++++------- tests/fixtures/shutdown/docker-compose.yml | 10 ---------- .../fixtures/{shutdown => v2-full}/Dockerfile | 0 tests/fixtures/v2-full/docker-compose.yml | 20 +++++++++++++++++++ 6 files changed, 39 insertions(+), 22 deletions(-) delete mode 100644 tests/fixtures/shutdown/docker-compose.yml rename tests/fixtures/{shutdown => v2-full}/Dockerfile (100%) create mode 100644 tests/fixtures/v2-full/docker-compose.yml diff --git a/compose/network.py b/compose/network.py index b2ba2e9b776..eaad770cff7 100644 --- a/compose/network.py +++ b/compose/network.py @@ -65,7 +65,10 @@ def ensure(self): ) def remove(self): - # TODO: don't remove external networks + if self.external_name: + log.info("Network %s is external, skipping", self.full_name) + return + log.info("Removing network {}".format(self.full_name)) self.client.remove_network(self.full_name) diff --git a/compose/project.py b/compose/project.py index 12d52cc26a4..1322c9902a2 100644 --- a/compose/project.py +++ b/compose/project.py @@ -275,7 +275,7 @@ def initialize_volumes(self): def down(self, remove_image_type, include_volumes): self.stop() self.remove_stopped(v=include_volumes) - self.remove_default_network() + self.remove_networks() if include_volumes: self.remove_volumes() @@ -286,11 +286,11 @@ def remove_images(self, remove_image_type): for service in self.get_services(): service.remove_image(remove_image_type) - def remove_default_network(self): + def remove_networks(self): if not self.use_networking: return - if self.uses_default_network(): - self.default_network.remove() + for network in self.networks: + network.remove() def remove_volumes(self): for volume in self.volumes: diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 9c9ced8ce79..548c6b9394b 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -340,16 +340,20 @@ def test_down_invalid_rmi_flag(self): assert '--rmi flag must be' in result.stderr def test_down(self): - self.base_dir = 'tests/fixtures/shutdown' + self.base_dir = 'tests/fixtures/v2-full' self.dispatch(['up', '-d']) - wait_on_condition(ContainerCountCondition(self.project, 1)) + wait_on_condition(ContainerCountCondition(self.project, 2)) result = self.dispatch(['down', '--rmi=local', '--volumes']) - assert 'Stopping shutdown_web_1' in result.stderr - assert 'Removing shutdown_web_1' in result.stderr - assert 'Removing volume shutdown_data' in result.stderr - assert 'Removing image shutdown_web' in result.stderr - assert 'Removing network shutdown_default' in result.stderr + 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 volume v2full_data' in result.stderr + assert 'Removing image v2full_web' in result.stderr + assert 'Removing image busybox' not in result.stderr + assert 'Removing network v2full_default' in result.stderr + assert 'Removing network v2full_front' in result.stderr def test_up_detached(self): self.dispatch(['up', '-d']) diff --git a/tests/fixtures/shutdown/docker-compose.yml b/tests/fixtures/shutdown/docker-compose.yml deleted file mode 100644 index c83c3d6370a..00000000000 --- a/tests/fixtures/shutdown/docker-compose.yml +++ /dev/null @@ -1,10 +0,0 @@ - -version: 2 - -volumes: - data: - driver: local - -services: - web: - build: . diff --git a/tests/fixtures/shutdown/Dockerfile b/tests/fixtures/v2-full/Dockerfile similarity index 100% rename from tests/fixtures/shutdown/Dockerfile rename to tests/fixtures/v2-full/Dockerfile diff --git a/tests/fixtures/v2-full/docker-compose.yml b/tests/fixtures/v2-full/docker-compose.yml new file mode 100644 index 00000000000..86d1c2c2279 --- /dev/null +++ b/tests/fixtures/v2-full/docker-compose.yml @@ -0,0 +1,20 @@ + +version: 2 + +volumes: + data: + driver: local + +networks: + front: {} + +services: + web: + build: . + networks: + - front + - default + + other: + image: busybox:latest + command: top From 3021ee12fe021092673930bd0ad578783a51dffa Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 14 Jan 2016 15:09:04 -0500 Subject: [PATCH 1718/4072] Fix `config` command to print the new sections of the config Signed-off-by: Daniel Nephin --- compose/cli/main.py | 2 +- tests/acceptance/cli_test.py | 37 +++++++++++++++-------- tests/fixtures/v2-full/docker-compose.yml | 4 +++ 3 files changed, 30 insertions(+), 13 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 473c6d605cd..661c91f2a1f 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -217,7 +217,7 @@ def config(self, config_options, options): compose_config = dict( (service.pop('name'), service) for service in compose_config.services) - print(yaml.dump( + print(yaml.safe_dump( compose_config, default_flow_style=False, indent=2, diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 548c6b9394b..d93881997aa 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -10,8 +10,8 @@ import time from collections import namedtuple from operator import attrgetter -from textwrap import dedent +import yaml from docker import errors from .. import mock @@ -148,8 +148,9 @@ def test_help(self): self.base_dir = None 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')) == {'simple', 'another'} + assert set(result.stdout.rstrip().split('\n')) == {'web', 'other'} def test_config_quiet_with_error(self): self.base_dir = None @@ -160,20 +161,32 @@ def test_config_quiet_with_error(self): assert "'notaservice' doesn't have any configuration" in result.stderr def test_config_quiet(self): + self.base_dir = 'tests/fixtures/v2-full' assert self.dispatch(['config', '-q']).stdout == '' def test_config_default(self): + self.base_dir = 'tests/fixtures/v2-full' result = self.dispatch(['config']) - assert dedent(""" - simple: - command: top - image: busybox:latest - """).lstrip() in result.stdout - assert dedent(""" - another: - command: top - image: busybox:latest - """).lstrip() in result.stdout + # assert there are no python objects encoded in the output + assert '!!' not in result.stdout + + output = yaml.load(result.stdout) + expected = { + 'version': 2, + 'volumes': {'data': {'driver': 'local'}}, + 'networks': {'front': {}}, + 'services': { + 'web': { + 'build': os.path.abspath(self.base_dir), + 'networks': ['front', 'default'], + }, + 'other': { + 'image': 'busybox:latest', + 'command': 'top', + }, + }, + } + assert output == expected def test_ps(self): self.project.get_service('simple').create_container() diff --git a/tests/fixtures/v2-full/docker-compose.yml b/tests/fixtures/v2-full/docker-compose.yml index 86d1c2c2279..725296c99cc 100644 --- a/tests/fixtures/v2-full/docker-compose.yml +++ b/tests/fixtures/v2-full/docker-compose.yml @@ -14,7 +14,11 @@ services: networks: - front - default + volumes_from: + - other other: image: busybox:latest command: top + volumes: + - /data From 1bfbba36b27df69302f3a196834d32d3dc64987e Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 14 Jan 2016 17:30:48 -0500 Subject: [PATCH 1719/4072] Ensure that the config output by config command never contains python objects. Signed-off-by: Daniel Nephin --- compose/cli/main.py | 10 ++-------- compose/config/serialize.py | 30 ++++++++++++++++++++++++++++++ compose/config/types.py | 7 +++++++ compose/service.py | 20 +++++++++----------- tests/acceptance/cli_test.py | 6 +++++- 5 files changed, 53 insertions(+), 20 deletions(-) create mode 100644 compose/config/serialize.py diff --git a/compose/cli/main.py b/compose/cli/main.py index 661c91f2a1f..4be8536f4b7 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -9,7 +9,6 @@ from inspect import getdoc from operator import attrgetter -import yaml from docker.errors import APIError from requests.exceptions import ReadTimeout @@ -18,6 +17,7 @@ from ..config import config from ..config import ConfigurationError from ..config import parse_environment +from ..config.serialize import serialize_config from ..const import DEFAULT_TIMEOUT from ..const import HTTP_TIMEOUT from ..const import IS_WINDOWS_PLATFORM @@ -215,13 +215,7 @@ def config(self, config_options, options): print('\n'.join(service['name'] for service in compose_config.services)) return - compose_config = dict( - (service.pop('name'), service) for service in compose_config.services) - print(yaml.safe_dump( - compose_config, - default_flow_style=False, - indent=2, - width=80)) + print(serialize_config(compose_config)) def create(self, project, options): """ diff --git a/compose/config/serialize.py b/compose/config/serialize.py new file mode 100644 index 00000000000..06e0a027bb3 --- /dev/null +++ b/compose/config/serialize.py @@ -0,0 +1,30 @@ +from __future__ import absolute_import +from __future__ import unicode_literals + +import six +import yaml + +from compose.config import types + + +def serialize_config_type(dumper, data): + representer = dumper.represent_str if six.PY3 else dumper.represent_unicode + return representer(data.repr()) + + +yaml.SafeDumper.add_representer(types.VolumeFromSpec, serialize_config_type) +yaml.SafeDumper.add_representer(types.VolumeSpec, serialize_config_type) + + +def serialize_config(config): + output = { + 'version': config.version, + 'services': {service.pop('name'): service for service in config.services}, + 'networks': config.networks, + 'volumes': config.volumes, + } + return yaml.safe_dump( + output, + default_flow_style=False, + indent=2, + width=80) diff --git a/compose/config/types.py b/compose/config/types.py index c0adca6c723..b872cba9160 100644 --- a/compose/config/types.py +++ b/compose/config/types.py @@ -67,6 +67,9 @@ def parse_v2(cls, service_names, volume_from_config): return cls(source, mode, type) + def repr(self): + return '{v.type}:{v.source}:{v.mode}'.format(v=self) + def parse_restart_spec(restart_config): if not restart_config: @@ -156,3 +159,7 @@ def parse(cls, volume_config): mode = parts[2] return cls(external, internal, mode) + + 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/service.py b/compose/service.py index c91c3a58c9a..0866b83bbdc 100644 --- a/compose/service.py +++ b/compose/service.py @@ -460,7 +460,8 @@ def config_dict(self): 'links': self.get_link_names(), 'net': self.net.id, 'volumes_from': [ - (v.source.name, v.mode) for v in self.volumes_from if isinstance(v.source, Service) + (v.source.name, v.mode) + for v in self.volumes_from if isinstance(v.source, Service) ], } @@ -519,12 +520,7 @@ def _get_links(self, link_to_self): return links def _get_volumes_from(self): - volumes_from = [] - for volume_from_spec in self.volumes_from: - volumes = build_volume_from(volume_from_spec) - volumes_from.extend(volumes) - - return volumes_from + return [build_volume_from(spec) for spec in self.volumes_from] def _get_container_create_options( self, @@ -927,7 +923,7 @@ def warn_on_masked_volume(volumes_option, container_volumes, service): def build_volume_binding(volume_spec): - return volume_spec.internal, "{}:{}:{}".format(*volume_spec) + return volume_spec.internal, volume_spec.repr() def build_volume_from(volume_from_spec): @@ -938,12 +934,14 @@ def build_volume_from(volume_from_spec): if isinstance(volume_from_spec.source, Service): containers = volume_from_spec.source.containers(stopped=True) if not containers: - return ["{}:{}".format(volume_from_spec.source.create_container().id, volume_from_spec.mode)] + return "{}:{}".format( + volume_from_spec.source.create_container().id, + volume_from_spec.mode) container = containers[0] - return ["{}:{}".format(container.id, volume_from_spec.mode)] + return "{}:{}".format(container.id, volume_from_spec.mode) elif isinstance(volume_from_spec.source, Container): - return ["{}:{}".format(volume_from_spec.source.id, volume_from_spec.mode)] + return "{}:{}".format(volume_from_spec.source.id, volume_from_spec.mode) # Labels diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index d93881997aa..d910473a8c7 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -177,12 +177,16 @@ def test_config_default(self): 'networks': {'front': {}}, 'services': { 'web': { - 'build': os.path.abspath(self.base_dir), + 'build': { + 'context': os.path.abspath(self.base_dir), + }, 'networks': ['front', 'default'], + 'volumes_from': ['service:other:rw'], }, 'other': { 'image': 'busybox:latest', 'command': 'top', + 'volumes': ['/data:rw'], }, }, } From abd031cb3d0c49ca933d24d2d03a982692cfc6c4 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 14 Jan 2016 18:33:35 +0000 Subject: [PATCH 1720/4072] Containers join each network aliased to their service's name Signed-off-by: Aanand Prasad --- compose/const.py | 4 +--- compose/service.py | 12 +++++++++++- tests/acceptance/cli_test.py | 11 +++++------ 3 files changed, 17 insertions(+), 10 deletions(-) diff --git a/compose/const.py b/compose/const.py index 331895b1047..6ff108fbd1e 100644 --- a/compose/const.py +++ b/compose/const.py @@ -18,7 +18,5 @@ API_VERSIONS = { 1: '1.21', - - # TODO: update to 1.22 when there's a Docker 1.10 build to test against - 2: '1.21', + 2: '1.22', } diff --git a/compose/service.py b/compose/service.py index 0866b83bbdc..4409f903b14 100644 --- a/compose/service.py +++ b/compose/service.py @@ -427,7 +427,9 @@ def start_container(self, container): 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) + self.client.connect_container_to_network( + container.id, network, + aliases=[self.name]) def remove_duplicate_containers(self, timeout=DEFAULT_TIMEOUT): for c in self.duplicate_containers(): @@ -597,6 +599,8 @@ def _get_container_create_options( override_options, one_off=one_off) + container_options['networking_config'] = self._get_container_networking_config() + return container_options def _get_container_host_config(self, override_options, one_off=False): @@ -631,6 +635,12 @@ def _get_container_host_config(self, override_options, one_off=False): cpu_quota=options.get('cpu_quota'), ) + def _get_container_networking_config(self): + return self.client.create_networking_config({ + network_name: self.client.create_endpoint_config(aliases=[self.name]) + for network_name in self.networks + }) + 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 d910473a8c7..8f3cdf502bd 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -133,12 +133,8 @@ def execute(self, container, cmd): self.client.exec_start(exc) return self.client.exec_inspect(exc)['ExitCode'] - def lookup(self, container, service_name): - exit_code = self.execute(container, [ - "nslookup", - "{}_{}_1".format(self.project.name, service_name) - ]) - return exit_code == 0 + def lookup(self, container, hostname): + return self.execute(container, ["nslookup", hostname]) == 0 def test_help(self): self.base_dir = 'tests/fixtures/no-composefile' @@ -414,6 +410,9 @@ def test_up(self): networks = list(container.get('NetworkSettings.Networks')) self.assertEqual(networks, [network['Name']]) + for service in services: + assert self.lookup(container, service.name) + def test_up_with_networks(self): self.base_dir = 'tests/fixtures/networks' self.dispatch(['up', '-d'], None) From 406b6b28f498d814e3517fdf66eecae137bcd985 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 15 Jan 2016 00:10:57 +0000 Subject: [PATCH 1721/4072] Tag v2-only tests - Don't run them against Engine < 1.10 - Set the API version appropriately for the Engine version, so all tests use API version 1.22 against Engine 1.10 Signed-off-by: Aanand Prasad --- tests/acceptance/cli_test.py | 9 +++++++++ tests/integration/project_test.py | 10 ++++++++++ tests/integration/testcases.py | 31 ++++++++++++++++++++++++++++++- 3 files changed, 49 insertions(+), 1 deletion(-) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 8f3cdf502bd..5978dd5de85 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -20,6 +20,7 @@ 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_only ProcessResult = namedtuple('ProcessResult', 'stdout stderr') @@ -388,6 +389,7 @@ def test_up_attached(self): assert 'simple_1 | simple' in result.stdout assert 'another_1 | another' in result.stdout + @v2_only() def test_up(self): self.base_dir = 'tests/fixtures/v2-simple' self.dispatch(['up', '-d'], None) @@ -413,6 +415,7 @@ def test_up(self): for service in services: assert self.lookup(container, service.name) + @v2_only() def test_up_with_networks(self): self.base_dir = 'tests/fixtures/networks' self.dispatch(['up', '-d'], None) @@ -448,6 +451,7 @@ def test_up_with_networks(self): # app can see db assert self.lookup(app_container, "db") + @v2_only() def test_up_missing_network(self): self.base_dir = 'tests/fixtures/networks' @@ -457,6 +461,7 @@ def test_up_missing_network(self): assert 'Service "web" uses an undefined network "foo"' in result.stderr + @v2_only() def test_up_predefined_networks(self): filename = 'predefined-networks.yml' @@ -476,6 +481,7 @@ def test_up_predefined_networks(self): assert list(container.get('NetworkSettings.Networks')) == [name] assert container.get('HostConfig.NetworkMode') == name + @v2_only() def test_up_external_networks(self): filename = 'external-networks.yml' @@ -499,6 +505,7 @@ def test_up_external_networks(self): container = self.project.containers()[0] assert sorted(list(container.get('NetworkSettings.Networks'))) == sorted(network_names) + @v2_only() def test_up_no_services(self): self.base_dir = 'tests/fixtures/no-services' self.dispatch(['up', '-d'], None) @@ -513,6 +520,7 @@ def test_up_no_services(self): for name in ['bar', 'foo'] ] + @v2_only() def test_up_with_links_is_invalid(self): self.base_dir = 'tests/fixtures/v2-simple' @@ -853,6 +861,7 @@ def test_run_with_custom_name(self): container, = service.containers(stopped=True, one_off=True) 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) diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index ef8a084b775..d29d9f1e4cb 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -14,6 +14,7 @@ from compose.container import Container from compose.project import Project from compose.service import ConvergenceStrategy +from tests.integration.testcases import v2_only def build_service_dicts(service_config): @@ -482,6 +483,7 @@ def test_unscale_after_restart(self): service = project.get_service('web') self.assertEqual(len(service.containers()), 1) + @v2_only() def test_project_up_networks(self): config_data = config.Config( version=2, @@ -514,6 +516,7 @@ def test_project_up_networks(self): foo_data = self.client.inspect_network('composetest_foo') self.assertEqual(foo_data['Driver'], 'bridge') + @v2_only() def test_project_up_volumes(self): vol_name = '{0:x}'.format(random.getrandbits(32)) full_vol_name = 'composetest_{0}'.format(vol_name) @@ -539,6 +542,7 @@ def test_project_up_volumes(self): self.assertEqual(volume_data['Name'], full_vol_name) self.assertEqual(volume_data['Driver'], 'local') + @v2_only() def test_project_up_logging_with_multiple_files(self): base_file = config.ConfigFile( 'base.yml', @@ -590,6 +594,7 @@ def test_project_up_logging_with_multiple_files(self): self.assertTrue(log_config) self.assertEqual(log_config.get('Type'), 'none') + @v2_only() def test_initialize_volumes(self): vol_name = '{0:x}'.format(random.getrandbits(32)) full_vol_name = 'composetest_{0}'.format(vol_name) @@ -614,6 +619,7 @@ def test_initialize_volumes(self): self.assertEqual(volume_data['Name'], full_vol_name) self.assertEqual(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) @@ -638,6 +644,7 @@ def test_project_up_implicit_volume_driver(self): self.assertEqual(volume_data['Name'], full_vol_name) self.assertEqual(volume_data['Driver'], 'local') + @v2_only() def test_initialize_volumes_invalid_volume_driver(self): vol_name = '{0:x}'.format(random.getrandbits(32)) @@ -659,6 +666,7 @@ def test_initialize_volumes_invalid_volume_driver(self): with self.assertRaises(config.ConfigurationError): project.initialize_volumes() + @v2_only() def test_initialize_volumes_updated_driver(self): vol_name = '{0:x}'.format(random.getrandbits(32)) full_vol_name = 'composetest_{0}'.format(vol_name) @@ -696,6 +704,7 @@ def test_initialize_volumes_updated_driver(self): vol_name ) in str(e.exception) + @v2_only() def test_initialize_volumes_external_volumes(self): # Use composetest_ prefix so it gets garbage-collected in tearDown() vol_name = 'composetest_{0:x}'.format(random.getrandbits(32)) @@ -722,6 +731,7 @@ def test_initialize_volumes_external_volumes(self): with self.assertRaises(NotFound): self.client.inspect_volume(full_vol_name) + @v2_only() def test_initialize_volumes_inexistent_external_volume(self): vol_name = '{0:x}'.format(random.getrandbits(32)) diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index 3002539e974..5870946db57 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -1,12 +1,16 @@ from __future__ import absolute_import from __future__ import unicode_literals +import functools +import os + from docker.utils import version_lt from pytest import skip from .. import unittest from compose.cli.docker_client import docker_client from compose.config.config import resolve_environment +from compose.const import API_VERSIONS from compose.const import LABEL_PROJECT from compose.progress_stream import stream_output from compose.service import Service @@ -26,10 +30,35 @@ def format_link(link): return [format_link(link) for link in links] +def engine_version_too_low_for_v2(): + if 'DOCKER_VERSION' not in os.environ: + return False + version = os.environ['DOCKER_VERSION'].partition('-')[0] + return version_lt(version, '1.10') + + +def v2_only(): + def decorator(f): + @functools.wraps(f) + def wrapper(self, *args, **kwargs): + if engine_version_too_low_for_v2(): + skip("Engine version is too low") + return + return f(self, *args, **kwargs) + return wrapper + + return decorator + + class DockerClientTestCase(unittest.TestCase): @classmethod def setUpClass(cls): - cls.client = docker_client() + if engine_version_too_low_for_v2(): + version = API_VERSIONS[1] + else: + version = API_VERSIONS[2] + + cls.client = docker_client(version) def tearDown(self): for c in self.client.containers( From ab2d18851f9c42153d8460494c4020ea0ca5f079 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 15 Jan 2016 00:12:07 +0000 Subject: [PATCH 1722/4072] Test against a dev build of Engine 1.10 Signed-off-by: Aanand Prasad --- script/test-versions | 12 +++++++++--- tox.ini | 1 + 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/script/test-versions b/script/test-versions index 76e55e1193b..24412b91825 100755 --- a/script/test-versions +++ b/script/test-versions @@ -18,8 +18,7 @@ get_versions="docker run --rm if [ "$DOCKER_VERSIONS" == "" ]; then DOCKER_VERSIONS="$($get_versions default)" elif [ "$DOCKER_VERSIONS" == "all" ]; then - # TODO: `-n 2` when engine 1.10 releases - DOCKER_VERSIONS="$($get_versions recent -n 1)" + DOCKER_VERSIONS="1.9.1 1.10.0-dev" fi @@ -39,12 +38,18 @@ for version in $DOCKER_VERSIONS; do trap "on_exit" EXIT + if [[ $version == *"-dev" ]]; then + repo="dnephin/dind" + else + repo="dockerswarm/dind" + fi + docker run \ -d \ --name "$daemon_container" \ --privileged \ --volume="/var/lib/docker" \ - dockerswarm/dind:$version \ + "$repo:$version" \ docker daemon -H tcp://0.0.0.0:2375 $DOCKER_DAEMON_ARGS \ 2>&1 | tail -n 10 @@ -52,6 +57,7 @@ for version in $DOCKER_VERSIONS; do --rm \ --link="$daemon_container:docker" \ --env="DOCKER_HOST=tcp://docker:2375" \ + --env="DOCKER_VERSION=$version" \ --entrypoint="tox" \ "$TAG" \ -e py27,py34 -- "$@" diff --git a/tox.ini b/tox.ini index 9d45b0c7f59..dc85bc6da08 100644 --- a/tox.ini +++ b/tox.ini @@ -8,6 +8,7 @@ passenv = DOCKER_HOST DOCKER_CERT_PATH DOCKER_TLS_VERIFY + DOCKER_VERSION setenv = HOME=/tmp deps = From cba75627e18adf80f66b6d090800b2204cfa97e0 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 15 Jan 2016 00:12:46 +0000 Subject: [PATCH 1723/4072] Fix error when joining host/bridge network Signed-off-by: Aanand Prasad --- compose/service.py | 1 + 1 file changed, 1 insertion(+) diff --git a/compose/service.py b/compose/service.py index 4409f903b14..f5db07fb1c6 100644 --- a/compose/service.py +++ b/compose/service.py @@ -639,6 +639,7 @@ def _get_container_networking_config(self): return self.client.create_networking_config({ network_name: self.client.create_endpoint_config(aliases=[self.name]) 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 fbc275e06b39a9fe02d57cd23aae23d25a5a73c9 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 15 Jan 2016 00:33:04 +0000 Subject: [PATCH 1724/4072] Work around error message change in Engine Signed-off-by: Aanand Prasad --- tests/acceptance/cli_test.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 5978dd5de85..39e154ad585 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -238,7 +238,8 @@ def test_pull_with_ignore_pull_failures(self): assert 'Pulling simple (busybox:latest)...' in result.stderr assert 'Pulling another (nonexisting-image:latest)...' in result.stderr - assert 'Error: image library/nonexisting-image:latest not found' in result.stderr + assert 'Error: image library/nonexisting-image' in result.stderr + assert 'not found' in result.stderr def test_build_plain(self): self.base_dir = 'tests/fixtures/simple-dockerfile' From 4772815491e3fb1ae838f1e834cb160aedc1c19a Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 15 Jan 2016 00:33:44 +0000 Subject: [PATCH 1725/4072] Disable tests until Engine 1.10 change has been worked around Signed-off-by: Aanand Prasad --- tests/integration/service_test.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 0e91dcf7ce7..bce3999b21b 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -7,6 +7,7 @@ from os import path from docker.errors import APIError +from pytest import mark from six import StringIO from six import text_type @@ -371,6 +372,7 @@ def test_start_container_inherits_options_from_constructor(self): 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)]) @@ -387,6 +389,7 @@ def test_start_container_creates_links(self): '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')]) @@ -430,6 +433,7 @@ def test_start_normal_container_does_not_create_links_to_its_own_service(self): 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 de6d6a42d7ea112342a5ea419fc93af0fb5ecad6 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 15 Jan 2016 01:49:54 +0000 Subject: [PATCH 1726/4072] Tag some more v2-dependent tests Not clear why the config tests are v2-dependent; needs investigating Signed-off-by: Aanand Prasad --- tests/acceptance/cli_test.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 39e154ad585..231b78dbb4f 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -144,11 +144,15 @@ def test_help(self): # 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([ @@ -157,10 +161,14 @@ def test_config_quiet_with_error(self): ], returncode=1) assert "'notaservice' doesn't have any configuration" 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']) @@ -354,6 +362,7 @@ def test_down_invalid_rmi_flag(self): result = self.dispatch(['down', '--rmi', 'bogus'], returncode=1) assert '--rmi flag must be' in result.stderr + @v2_only() def test_down(self): self.base_dir = 'tests/fixtures/v2-full' self.dispatch(['up', '-d']) @@ -939,6 +948,7 @@ def test_start_no_containers(self): result = self.dispatch(['start'], returncode=1) assert 'No containers to start' in result.stderr + @v2_only() def test_up_logging(self): self.base_dir = 'tests/fixtures/logging-composefile' self.dispatch(['up', '-d']) From dc1104649f1207cfc4bc0402b0aee1243442f6b9 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 14 Jan 2016 22:28:20 -0500 Subject: [PATCH 1727/4072] 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 b38942253b2..ac5e8d174dd 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 merge_services(base, override): 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 @@ def validate_and_construct_extends(self): 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 @@ def resolve_extends(self, extended_config_path, service_dict, service_name): 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 @@ def resolve_extends(self, extended_config_path, service_dict, service_name): 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 e24dc904113..cc205136372 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -25,14 +25,15 @@ 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 @@ def test_extends_with_environment_and_env_files(self): 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 1728/4072] 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 d910473a8c7..2cce7803761 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 @@ def test_up_handles_sigint(self): 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 82b288b25b4306157b3edc3dfc5fca8b8285044a Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 15 Jan 2016 15:02:19 -0500 Subject: [PATCH 1729/4072] Fix linux master build. Signed-off-by: Daniel Nephin --- script/travis/build-binary | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/script/travis/build-binary b/script/travis/build-binary index 0becee7f61d..7cc1092ddc8 100755 --- a/script/travis/build-binary +++ b/script/travis/build-binary @@ -4,8 +4,8 @@ set -ex if [[ "$TRAVIS_OS_NAME" == "linux" ]]; then script/build-linux - script/build-image master - # TODO: requires auth + # TODO: requires auth to push, so disable for now + # script/build-image master # docker push docker/compose:master else script/prepare-osx From 89e31f7a8d50d743398815d3c2f0bde3c7fd9c01 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 14 Jan 2016 22:28:20 -0500 Subject: [PATCH 1730/4072] 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 b38942253b2..ac5e8d174dd 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 merge_services(base, override): 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 @@ def validate_and_construct_extends(self): 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 @@ def resolve_extends(self, extended_config_path, service_dict, service_name): 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 @@ def resolve_extends(self, extended_config_path, service_dict, service_name): 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 e24dc904113..cc205136372 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -25,14 +25,15 @@ 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 @@ def test_extends_with_environment_and_env_files(self): 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 34fd042dbf0e3de5223f1cc333cf94437d311845 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 15 Jan 2016 10:14:55 -0500 Subject: [PATCH 1731/4072] 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 231b78dbb4f..700e9cdfa59 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -48,7 +48,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: @@ -648,14 +648,14 @@ def test_up_handles_sigint(self): 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 1732/4072] 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 24412b91825..2e9c9167451 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 1733/4072] 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 01e5f896399..173666eb53d 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 1734/4072] 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 01e5f896399..bb9ffa8af30 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 1735/4072] 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 01e5f896399..a8a60b59e1a 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 fffedfc87b4aa53ee2fecc9ba950309025b8b8be Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 15 Jan 2016 19:04:01 -0500 Subject: [PATCH 1736/4072] 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 24412b91825..2e9c9167451 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 53d56ea2456813c910a6500fbad5841286c94db1 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 18 Jan 2016 16:15:52 +0000 Subject: [PATCH 1737/4072] 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 eaad770cff7..34159fd627c 100644 --- a/compose/network.py +++ b/compose/network.py @@ -44,11 +44,11 @@ def ensure(self): 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 1738/4072] 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 1322c9902a2..777e8f8209d 100644 --- a/compose/project.py +++ b/compose/project.py @@ -58,23 +58,24 @@ def from_config(cls, name, config_data, client): 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 @@ def from_config(cls, name, config_data, client): **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 700e9cdfa59..f5c163805db 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -409,6 +409,7 @@ def test_up(self): 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 @@ def test_up(self): 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 00000000000..275fae98db7 --- /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 a22d2483908889c627fe5cb87f32b6b252111048 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 18 Jan 2016 16:15:52 +0000 Subject: [PATCH 1739/4072] 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 eaad770cff7..34159fd627c 100644 --- a/compose/network.py +++ b/compose/network.py @@ -44,11 +44,11 @@ def ensure(self): 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 64fc2b85cb0e1d6d8910f648f03c4f05f7e6b50a Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 18 Jan 2016 16:16:24 +0000 Subject: [PATCH 1740/4072] 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 1322c9902a2..777e8f8209d 100644 --- a/compose/project.py +++ b/compose/project.py @@ -58,23 +58,24 @@ def from_config(cls, name, config_data, client): 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 @@ def from_config(cls, name, config_data, client): **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 700e9cdfa59..f5c163805db 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -409,6 +409,7 @@ def test_up(self): 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 @@ def test_up(self): 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 00000000000..275fae98db7 --- /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 1741/4072] 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 91ac0b73b92..f111f730eb8 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`. - -## Specifying the network driver +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. -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. + +To make use of this in Compose, specify a `networks` list with a single item `host`, `bridge` or `none`: -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. + app: + build: ./app + networks: ["host"] -If *all* services in an app specify the `net` option, a network will not be created at all. +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 1742/4072] 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 34159fd627c..4f4f552286e 100644 --- a/compose/network.py +++ b/compose/network.py @@ -4,6 +4,8 @@ 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 @@ 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 @@ def ensure(self): ) 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 @@ def full_name(self): 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 777e8f8209d..080c4c49ff5 100644 --- a/compose/project.py +++ b/compose/project.py @@ -64,6 +64,7 @@ def from_config(cls, name, config_data, client): 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 d29d9f1e4cb..d3fbb71eb12 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -516,6 +516,63 @@ def test_project_up_networks(self): 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 d4720f85ef98c5002140527a844284b86a9718d3 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 18 Jan 2016 15:04:10 +0000 Subject: [PATCH 1743/4072] 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 91ac0b73b92..f111f730eb8 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`. - -## Specifying the network driver +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. -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. + +To make use of this in Compose, specify a `networks` list with a single item `host`, `bridge` or `none`: -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. + app: + build: ./app + networks: ["host"] -If *all* services in an app specify the `net` option, a network will not be created at all. +There is no equivalent to `--net=container:CONTAINER_NAME` in the v2 Compose file format. You should instead use networks to enable communication. From 237f134a0096210f43abc1cb9c73ab40c7e8853e Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 18 Jan 2016 17:08:50 +0000 Subject: [PATCH 1744/4072] 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 34159fd627c..4f4f552286e 100644 --- a/compose/network.py +++ b/compose/network.py @@ -4,6 +4,8 @@ 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 @@ 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 @@ def ensure(self): ) 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 @@ def full_name(self): 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 777e8f8209d..080c4c49ff5 100644 --- a/compose/project.py +++ b/compose/project.py @@ -64,6 +64,7 @@ def from_config(cls, name, config_data, client): 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 d29d9f1e4cb..d3fbb71eb12 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -516,6 +516,63 @@ def test_project_up_networks(self): 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 d2556a1347a7a93171431b0d17da9f127142f6a0 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 15 Jan 2016 17:51:02 -0500 Subject: [PATCH 1745/4072] Bump 1.6.0-rc1 Signed-off-by: Daniel Nephin --- CHANGELOG.md | 108 ++++++++++++++++++++++++++++++++++++++++++++ compose/__init__.py | 2 +- docs/install.md | 6 +-- script/run.ps1 | 2 +- script/run.sh | 2 +- 5 files changed, 114 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 79aee75fb51..0251d669187 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,114 @@ 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 want to + keep using links, 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. + + +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. + + 1.5.2 (2015-12-03) ------------------ diff --git a/compose/__init__.py b/compose/__init__.py index 3ba90fdef92..52d0e7bfee7 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.6.0rc1' diff --git a/docs/install.md b/docs/install.md index 417a48c1845..944e9190abd 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.5.2/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose + curl -L https://github.com/docker/compose/releases/download/1.6.0rc1/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.5.2 + docker-compose version: 1.6.0rc1 ## 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.0rc1/run.sh > /usr/local/bin/docker-compose $ chmod +x /usr/local/bin/docker-compose ## Master builds diff --git a/script/run.ps1 b/script/run.ps1 index 47ec546925c..f4ff2abb6ad 100644 --- a/script/run.ps1 +++ b/script/run.ps1 @@ -5,7 +5,7 @@ # $Env:DOCKER_COMPOSE_OPTIONS. if ($Env:DOCKER_COMPOSE_VERSION -eq $null -or $Env:DOCKER_COMPOSE_VERSION.Length -eq 0) { - $Env:DOCKER_COMPOSE_VERSION = "latest" + $Env:DOCKER_COMPOSE_VERSION = "1.6.0rc1" } if ($Env:DOCKER_COMPOSE_OPTIONS -eq $null) { diff --git a/script/run.sh b/script/run.sh index 087c2692f7c..3fbc60e00a7 100755 --- a/script/run.sh +++ b/script/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.5.2" +VERSION="1.6.0rc1" IMAGE="docker/compose:$VERSION" From 66dd9ae9a49bb4483601a161adf7f45e73fb5710 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 18 Jan 2016 15:55:30 -0500 Subject: [PATCH 1746/4072] 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 bb9fe871caf..1e69b143fe9 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 b754d40f04d..7d9ec0a2c31 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 1747/4072] 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 611997dfa9e..b680616ef0f 100644 --- a/compose/cli/docker_client.py +++ b/compose/cli/docker_client.py @@ -5,9 +5,11 @@ 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 1748/4072] 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 f5c163805db..4c278aa4c81 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -528,6 +528,29 @@ def test_up_external_networks(self): 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 00000000000..7b0797e55c8 --- /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 1749/4072] 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 f5db07fb1c6..f72863c9a7e 100644 --- a/compose/service.py +++ b/compose/service.py @@ -502,24 +502,31 @@ def _get_links(self, link_to_self): 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 bce3999b21b..0e91dcf7ce7 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -7,7 +7,6 @@ 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 @@ def test_start_container_inherits_options_from_constructor(self): 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 @@ def test_start_container_creates_links(self): '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 @@ def test_start_normal_container_does_not_create_links_to_its_own_service(self): 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 1750/4072] 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 f72863c9a7e..ebbb20cfef9 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 0e91dcf7ce7..1847c3afd05 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -343,6 +343,31 @@ def test_execute_convergence_plan_when_image_volume_masks_config(self): ) 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 1751/4072] 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 00000000000..a961b0bd746 --- /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 1752/4072] 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 ac5e8d174dd..6bba53b8c16 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -27,6 +27,7 @@ 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 build_service(service_name, service_dict, service_names): 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 8623507a3a0..d4ec575a687 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 05552122742..ac0fa458538 100644 --- a/compose/config/sort_services.py +++ b/compose/config/sort_services.py @@ -33,7 +33,8 @@ def get_service_dependents(service_dict, 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 visit(n): 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 639e8bed2b8..6cce110566a 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 f72863c9a7e..1dfda06a668 100644 --- a/compose/service.py +++ b/compose/service.py @@ -471,7 +471,8 @@ def get_dependency_names(self): 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 cc205136372..0416d5b7635 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -894,8 +894,34 @@ def test_external_volume_invalid_config(self): '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): diff --git a/tests/unit/config/sort_services_test.py b/tests/unit/config/sort_services_test.py index ebe444fee2d..3279ece4907 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 @@ def test_sort_service_dicts_self_imports(self): 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 1753/4072] 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 6bba53b8c16..72ad50af53f 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -31,6 +31,7 @@ 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 6cce110566a..ecf8d4f9251 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 0416d5b7635..3c3c6326b1b 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -700,7 +700,7 @@ def test_config_ulimits_required_keys_validation_error(self): 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 1754/4072] 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 3279ece4907..f59906644af 100644 --- a/tests/unit/config/sort_services_test.py +++ b/tests/unit/config/sort_services_test.py @@ -6,10 +6,9 @@ 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 @@ def test_sort_service_dicts_1(self): ] 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 @@ def test_sort_service_dicts_2(self): ] 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 @@ def test_sort_service_dicts_3(self): ] 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 @@ def test_sort_service_dicts_4(self): ] 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 @@ def test_sort_service_dicts_5(self): ] 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 @@ def test_sort_service_dicts_6(self): ] 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 @@ def test_sort_service_dicts_7(self): ] 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 @@ def test_sort_service_dicts_circular_imports(self): }, ] - 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 @@ def test_sort_service_dicts_circular_imports_2(self): } ] - 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 @@ def test_sort_service_dicts_circular_imports_3(self): } ] - 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 @@ def test_sort_service_dicts_self_imports(self): }, ] - 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 1755/4072] 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 ecd135f1904..a13b39e402d 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 887df99d6a9..9cb594a74aa 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 1756/4072] =?UTF-8?q?Fix=20for=20extending=20services=20wr?= =?UTF-8?q?itten=20in=20v2=20format.=20Signed-off-by:=20Jure=20=C5=BDvelc?= =?UTF-8?q?=20?= 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 ac5e8d174dd..2f513fb5e8c 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -134,6 +134,9 @@ def version(self): 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 @@ def validate_and_construct_extends(self): 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 cc205136372..b5164adef2c 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1911,6 +1911,30 @@ def test_extends_with_mixed_versions_is_error(self): 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 1757/4072] 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 28011b1db2a..4a2bc1f7794 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 1758/4072] 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 d4ec575a687..94046d5b483 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 080c4c49ff5..0fea875a66e 100644 --- a/compose/project.py +++ b/compose/project.py @@ -78,12 +78,11 @@ def from_config(cls, name, config_data, client): 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 1dfda06a668..ea4e57d0983 100644 --- a/compose/service.py +++ b/compose/service.py @@ -426,10 +426,11 @@ def start_container(self, container): 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 @@ def _next_container_number(self, one_off=False): 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 @@ def _get_container_host_config(self, override_options, one_off=False): 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 4c278aa4c81..1806e70760a 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -461,6 +461,10 @@ def test_up_with_networks(self): 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 @@ def test_up_with_networks(self): # 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 @@ def test_up_no_services(self): 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 f1b79df09f5..5351c0f08e2 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 c9244a47d4b..4d9aec651c7 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -536,14 +536,6 @@ def test_specifies_host_port_with_host_ip_and_port_range(self): 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 1759/4072] 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 1dfda06a668..bc155cb3f34 100644 --- a/compose/service.py +++ b/compose/service.py @@ -228,11 +228,9 @@ def create_and_start(service, number): 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 0e91dcf7ce7..379e51ea0c6 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -746,6 +746,11 @@ def test_scale_sets_ports(self): 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 1760/4072] 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 bc155cb3f34..7859db76590 100644 --- a/compose/service.py +++ b/compose/service.py @@ -27,9 +27,7 @@ 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 @@ def create_and_start(service, number): 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 @@ def create_and_start(service, number): 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 1761/4072] 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 8afc59c1863..ebe7978c342 100644 --- a/compose/service.py +++ b/compose/service.py @@ -430,10 +430,12 @@ def start_container(self, container): 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 @@ def _next_container_number(self, one_off=False): 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 @@ def _get_container_create_options( 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 @@ def _get_container_host_config(self, override_options, one_off=False): 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 1806e70760a..6ae04ee5de9 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -903,14 +903,47 @@ def test_run_with_custom_name(self): 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 1762/4072] 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 ebe7978c342..1832284629a 100644 --- a/compose/service.py +++ b/compose/service.py @@ -656,13 +656,17 @@ def _get_container_host_config(self, override_options, one_off=False): ) 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 1763/4072] 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 4be8536f4b7..7409107df69 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -41,7 +41,7 @@ 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 remove_container(force=False): 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 563baa10343..45e18528183 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 a5767097691..f9e3fb8f73d 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -72,8 +72,8 @@ def test_command_help_nonexistent(self): 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 1764/4072] 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 ecd135f1904..ba6b4cb408e 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 f111f730eb8..a5b49c1ff8a 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 1765/4072] 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 ba6b4cb408e..d1c4f36f6f6 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 1766/4072] Update documentation for links Signed-off-by: Aanand Prasad --- docs/compose-file.md | 64 +++++++++++++++++++++++++++----------------- docs/env.md | 4 ++- docs/networking.md | 13 ++++++++- 3 files changed, 54 insertions(+), 27 deletions(-) diff --git a/docs/compose-file.md b/docs/compose-file.md index d1c4f36f6f6..5969b85502d 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`). - -- `external_links`: Links are deprecated, so you should use - [external networks](networking.md#using-externally-created-networks) to - communicate with containers outside the app. +- `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: + + 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 c0e03a4e240..7b7e1bc7c66 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 a5b49c1ff8a..1bdd7fe07e7 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 1767/4072] 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 1bdd7fe07e7..bcfd39e5f0c 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 1768/4072] 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 5969b85502d..67adeb82f09 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 1769/4072] 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 7409107df69..14febe25462 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -709,7 +709,12 @@ def remove_container(force=False): 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 1770/4072] 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 f9e3fb8f73d..fd52a3c1e2f 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -71,6 +71,37 @@ def test_command_help_nonexistent(self): 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 1771/4072] 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 45e18528183..ed3b86869b0 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 1772/4072] 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 b872cba9160..2bb2519d148 100644 --- a/compose/config/types.py +++ b/compose/config/types.py @@ -163,3 +163,9 @@ def parse(cls, volume_config): 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 080c4c49ff5..7415e824240 100644 --- a/compose/project.py +++ b/compose/project.py @@ -74,6 +74,17 @@ def from_config(cls, name, config_data, client): 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 @@ def from_config(cls, name, config_data, client): 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 @@ def from_config(cls, name, config_data, client): 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 d3fbb71eb12..aa0dd33f01f 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -813,3 +813,39 @@ def test_initialize_volumes_inexistent_external_volume(self): 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 ffd4455f3ec..f587d69709c 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -7,8 +7,10 @@ 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 @@ def test_container_without_name(self): ), ) 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 1773/4072] 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 b0f304e8666..c001df686e0 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 bcfd39e5f0c..1e662dd2caa 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 eb8ed2c72d3..fe60982003e 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -88,11 +88,26 @@ def test_load_v2(self): '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 @@ def test_load_v2(self): '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 @@ def test_load_throws_error_when_not_dict_v2(self): ) ) + 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 1774/4072] 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 2bb2519d148..2e648e5a993 100644 --- a/compose/config/types.py +++ b/compose/config/types.py @@ -166,6 +166,4 @@ def repr(self): @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 7415e824240..bed55925b7c 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 aa0dd33f01f..586f9444cad 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -846,6 +846,7 @@ def test_project_up_named_volumes_in_binds(self): 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 1775/4072] 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 72ad50af53f..62e9929f0e0 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -25,6 +25,7 @@ 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 ecf8d4f9251..5c2d69ec34a 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 bed55925b7c..48947eafe93 100644 --- a/compose/project.py +++ b/compose/project.py @@ -42,7 +42,7 @@ def __init__(self, name, services, client, networks=None, volumes=None, 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 @@ def from_config(cls, name, config_data, client): 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 @@ def from_config(cls, name, config_data, client): 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 @@ def remove_stopped(self, service_names=None, **options): 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 @@ def remove_networks(self): 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 3c3c6326b1b..10f6aad8614 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -523,6 +523,56 @@ def test_load_with_multiple_files_v2(self): ] 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 f587d69709c..ffd4455f3ec 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -7,10 +7,8 @@ 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 @@ def test_container_without_name(self): ), ) 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 1776/4072] 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 961d36bbd51..2e4e036c3cc 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 1777/4072] 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 eb8ed2c72d3..b4f92d76800 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1443,7 +1443,7 @@ def test_resolve_environment(self): } 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 @@ def test_resolve_environment_from_env_file_with_empty_values(self): 'FILE_DEF': u'bär', 'FILE_DEF_EMPTY': '', 'ENV_DEF': 'E3', - 'NO_DEF': '' }, ) @@ -1503,7 +1502,7 @@ def test_resolve_build_args(self): } 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 1778/4072] 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 379e51ea0c6..bf3ff610fc1 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -860,7 +860,6 @@ def test_resolve_env(self): '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 1779/4072] 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 2b020c372a4..8ba9548ff9f 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 1780/4072] 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 430170225cf..f67bc9f6462 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 1781/4072] 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 2b020c372a4..2c18bc04935 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 1782/4072] 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 1832284629a..166fb0b2f86 100644 --- a/compose/service.py +++ b/compose/service.py @@ -430,12 +430,16 @@ def start_container(self, container): 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 @@ def _next_container_number(self, one_off=False): 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 @@ def _get_container_create_options( 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 @@ def _get_container_host_config(self, override_options, one_off=False): 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 6ae04ee5de9..7bc72305ce4 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -420,8 +420,12 @@ def test_up(self): 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 1783/4072] 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 c19e428483f..44a466e3efb 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 1784/4072] 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 | 97 +++++++++++-- tests/unit/config/config_test.py | 131 +++++++++++++++++- tests/unit/config/sort_services_test.py | 4 +- tests/unit/project_test.py | 4 +- 16 files changed, 404 insertions(+), 86 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 8e7d96e2689..c1391c2fbe6 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -19,6 +19,7 @@ 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_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 94046d5b483..56c0cbf5c05 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 ac0fa458538..cf38a60317f 100644 --- a/compose/config/sort_services.py +++ b/compose/config/sort_services.py @@ -5,10 +5,18 @@ 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 get_service_dependents(service_dict, 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 5c2d69ec34a..dfc34d57534 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -15,6 +15,7 @@ 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 b51fcd7e99d..ef913d634fc 100644 --- a/compose/project.py +++ b/compose/project.py @@ -10,6 +10,7 @@ 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 @@ def from_config(cls, name, config_data, client): 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 @@ def get_links(self, service_dict): 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 @@ def _inject_deps(self, acc, service): 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 b2675ac9b1a..6b61755f276 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 -> 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" - net: host -> networks: ["host"] - net: bridge -> networks: ["bridge"] - net: none -> networks: ["none"] + If you're using `net: "container:[container name/id]"`, the value does not + need to change. - 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. + 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 1e662dd2caa..93533e9d1f5 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 7bc72305ce4..4b560efa11d 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -496,8 +496,29 @@ def test_up_missing_network(self): 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 @@ def test_up_predefined_networks(self): 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 00000000000..0a04f46801b --- /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 00000000000..9509837223b --- /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 00000000000..7ab63df8226 --- /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 d0fac377d41..00000000000 --- 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 586f9444cad..0945ebb8e55 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -4,10 +4,12 @@ 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,21 +106,25 @@ def test_volumes_from_container(self): 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({ - 'net': { - 'image': 'busybox:latest', - 'command': ["top"] - }, - 'web': { - 'image': 'busybox:latest', - 'net': 'container:net', - 'command': ["top"] + 'version': 2, + 'services': { + 'net': { + 'image': 'busybox:latest', + 'command': ["top"] + }, + 'web': { + 'image': 'busybox:latest', + 'network_mode': 'service:net', + 'command': ["top"] + }, }, }), - client=self.client, ) project.up() @@ -127,7 +133,28 @@ def test_net_from_service(self): net = project.get_service('net') self.assertEqual(web.net.mode, 'container:' + net.containers()[0].id) - def test_net_from_container(self): + @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', @@ -137,12 +164,24 @@ def test_net_from_container(self): ) 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({ + 'net': { + 'image': 'busybox:latest', + 'command': ["top"] + }, 'web': { 'image': 'busybox:latest', - 'net': 'container:composetest_net_container' + 'net': 'container:net', + 'command': ["top"] }, }), client=self.client, @@ -150,6 +189,40 @@ def test_net_from_container(self): project.up() + web = project.get_service('web') + net = project.get_service('net') + self.assertEqual(web.net.mode, 'container:' + net.containers()[0].id) + + 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', + 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) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 98fe77588f4..0d8f722499e 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1015,6 +1015,126 @@ def test_depends_on_unknown_service_errors(self): 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 @@ def test_invalid_volumes_from_in_extended_service(self): 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 f59906644af..c39ac022562 100644 --- a/tests/unit/config/sort_services_test.py +++ b/tests/unit/config/sort_services_test.py @@ -100,7 +100,7 @@ def test_sort_service_dicts_5(self): }, { 'name': 'parent', - 'net': 'container:child' + 'network_mode': 'service:child' }, { 'name': 'child' @@ -137,7 +137,7 @@ def test_sort_service_dicts_6(self): 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 ffd4455f3ec..3ad131f3ce1 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -365,7 +365,7 @@ def test_use_net_from_container(self): { 'name': 'test', 'image': 'busybox:latest', - 'net': 'container:aaa' + 'network_mode': 'container:aaa' }, ], networks=None, @@ -398,7 +398,7 @@ def test_use_net_from_service(self): { '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 1785/4072] 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 358ef5bcc4c..b2d86aa4caf 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 c51be49ec51..8e37d404a0f 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 0d8f722499e..5f8b097b9f7 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1762,6 +1762,7 @@ def test_extends(self): 'name': 'myweb', 'image': 'busybox', 'command': 'top', + 'network_mode': 'bridge', 'links': ['mydb:db'], 'environment': { "FOO": "1", @@ -1779,6 +1780,7 @@ def test_merging_env_labels_ulimits(self): 'name': 'web', 'image': 'busybox', 'command': '/bin/true', + 'network_mode': 'host', 'environment': { "FOO": "2", "BAR": "1", @@ -1797,6 +1799,7 @@ def test_nested(self): '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 1786/4072] 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 c1391c2fbe6..ffd805ad848 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -19,8 +19,8 @@ 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 cf38a60317f..9d29f329e4f 100644 --- a/compose/config/sort_services.py +++ b/compose/config/sort_services.py @@ -4,22 +4,22 @@ 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 get_service_dependents(service_dict, 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 dfc34d57534..05982020921 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -15,7 +15,7 @@ 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 ef913d634fc..e5b6faef3fa 100644 --- a/compose/project.py +++ b/compose/project.py @@ -10,8 +10,8 @@ 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_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 @@ def from_config(cls, name, config_data, client): 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 @@ def from_config(cls, name, config_data, client): use_networking=use_networking, networks=networks, links=links, - net=net, + network_mode=network_mode, volumes_from=volumes_from, **service_dict) ) @@ -197,27 +197,27 @@ def get_links(self, service_dict): 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 166fb0b2f86..106d5b26457 100644 --- a/compose/service.py +++ b/compose/service.py @@ -47,7 +47,6 @@ 'extra_hosts', 'ipc', 'read_only', - 'net', 'log_driver', 'log_opt', 'mem_limit', @@ -113,7 +112,7 @@ def __init__( use_networking=False, links=None, volumes_from=None, - net=None, + network_mode=None, networks=None, **options ): @@ -123,7 +122,7 @@ def __init__( 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 @@ def config_dict(self): '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 @@ def config_dict(self): } 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 @@ def _get_container_host_config(self, override_options, one_off=False): 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 @@ def pull(self, ignore_pull_failures=False): 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 @@ def mode(self): 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 0945ebb8e55..0c8c9a6aca3 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -131,7 +131,7 @@ def test_network_mode_from_service(self): 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 @@ def get_project(): 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 @@ def test_net_from_service_v1(self): 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 @@ def get_project(): 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 379e51ea0c6..cde50b104c6 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -26,7 +26,7 @@ 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 @@ def test_scale_with_immediate_exit(self): 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 3ad131f3ce1..21c6be475da 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -349,7 +349,7 @@ def test_net_unset(self): ), ) 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 @@ def test_use_net_from_container(self): ), ) 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 @@ def test_use_net_from_service(self): ) 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 4d9aec651c7..74e9f0f5316 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -15,16 +15,16 @@ 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 @@ def test_config_dict(self): '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 @@ def test_config_dict(self): } 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 @@ def test_config_dict_with_net_from_container(self): '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 @@ def test_build_ulimits_with_integers_and_dicts(self): 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 @@ def test_net_service(self): ] 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 20c936a25179cad087522e49e409e0ac3db7f96a Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 18 Jan 2016 15:55:30 -0500 Subject: [PATCH 1787/4072] 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 bb9fe871caf..1e69b143fe9 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 b754d40f04d..7d9ec0a2c31 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 d5f3826ec7374ce5b2bafc592e25268edddd9cce Mon Sep 17 00:00:00 2001 From: Steve Durrheimer Date: Sat, 16 Jan 2016 13:53:16 +0100 Subject: [PATCH 1788/4072] 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 01e5f896399..a8a60b59e1a 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 7dd5fd5763e68d607978367775475d47117f30e5 Mon Sep 17 00:00:00 2001 From: Steve Durrheimer Date: Sat, 16 Jan 2016 13:47:48 +0100 Subject: [PATCH 1789/4072] 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 a8a60b59e1a..c9a4f3dc6ec 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 8398382b65bd88033c28f831e22608d53915a4f6 Mon Sep 17 00:00:00 2001 From: Steve Durrheimer Date: Sat, 16 Jan 2016 13:22:02 +0100 Subject: [PATCH 1790/4072] 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 c9a4f3dc6ec..430170225cf 100644 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -304,12 +304,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 fbee4ce4b323a29052ffbb6441c0a1e283a1c2ee Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 18 Jan 2016 19:22:04 +0000 Subject: [PATCH 1791/4072] 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 611997dfa9e..b680616ef0f 100644 --- a/compose/cli/docker_client.py +++ b/compose/cli/docker_client.py @@ -5,9 +5,11 @@ 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 7442b416e8dcb1083fd6b570adcf340497045eb0 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 19 Jan 2016 10:57:12 +0000 Subject: [PATCH 1792/4072] 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 f5c163805db..4c278aa4c81 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -528,6 +528,29 @@ def test_up_external_networks(self): 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 00000000000..7b0797e55c8 --- /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 bb377d3fe600bd6e89dfb720cec4d309332aa626 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 19 Jan 2016 11:27:27 +0000 Subject: [PATCH 1793/4072] 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 f5db07fb1c6..f72863c9a7e 100644 --- a/compose/service.py +++ b/compose/service.py @@ -502,24 +502,31 @@ def _get_links(self, link_to_self): 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 bce3999b21b..0e91dcf7ce7 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -7,7 +7,6 @@ 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 @@ def test_start_container_inherits_options_from_constructor(self): 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 @@ def test_start_container_creates_links(self): '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 @@ def test_start_normal_container_does_not_create_links_to_its_own_service(self): 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 33bb7c4e026d46dda184d682c89fad7481ab1a77 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 18 Jan 2016 14:02:25 -0500 Subject: [PATCH 1794/4072] 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 00000000000..a961b0bd746 --- /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 6e5c312768dbcdd7c6432b26e14361117adf22d0 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 19 Jan 2016 15:41:45 -0500 Subject: [PATCH 1795/4072] 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 ac5e8d174dd..6bba53b8c16 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -27,6 +27,7 @@ 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 build_service(service_name, service_dict, service_names): 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 8623507a3a0..d4ec575a687 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 05552122742..ac0fa458538 100644 --- a/compose/config/sort_services.py +++ b/compose/config/sort_services.py @@ -33,7 +33,8 @@ def get_service_dependents(service_dict, 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 visit(n): 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 639e8bed2b8..6cce110566a 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 f72863c9a7e..1dfda06a668 100644 --- a/compose/service.py +++ b/compose/service.py @@ -471,7 +471,8 @@ def get_dependency_names(self): 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 cc205136372..0416d5b7635 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -894,8 +894,34 @@ def test_external_volume_invalid_config(self): '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): diff --git a/tests/unit/config/sort_services_test.py b/tests/unit/config/sort_services_test.py index ebe444fee2d..3279ece4907 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 @@ def test_sort_service_dicts_self_imports(self): 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 3f65bdcf46556e7e309fbccc8f531cba00115fd1 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 19 Jan 2016 15:47:57 -0500 Subject: [PATCH 1796/4072] 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 6bba53b8c16..72ad50af53f 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -31,6 +31,7 @@ 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 6cce110566a..ecf8d4f9251 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 0416d5b7635..3c3c6326b1b 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -700,7 +700,7 @@ def test_config_ulimits_required_keys_validation_error(self): 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 c52eed66b7291d4d7a4c70d07d90ea58db9f1c03 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 19 Jan 2016 15:52:17 -0500 Subject: [PATCH 1797/4072] 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 3279ece4907..f59906644af 100644 --- a/tests/unit/config/sort_services_test.py +++ b/tests/unit/config/sort_services_test.py @@ -6,10 +6,9 @@ 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 @@ def test_sort_service_dicts_1(self): ] 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 @@ def test_sort_service_dicts_2(self): ] 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 @@ def test_sort_service_dicts_3(self): ] 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 @@ def test_sort_service_dicts_4(self): ] 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 @@ def test_sort_service_dicts_5(self): ] 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 @@ def test_sort_service_dicts_6(self): ] 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 @@ def test_sort_service_dicts_7(self): ] 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 @@ def test_sort_service_dicts_circular_imports(self): }, ] - 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 @@ def test_sort_service_dicts_circular_imports_2(self): } ] - 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 @@ def test_sort_service_dicts_circular_imports_3(self): } ] - 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 @@ def test_sort_service_dicts_self_imports(self): }, ] - 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 c9ef1fa32f034a7a6bd84821187a2e8014e37c51 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 1798/4072] =?UTF-8?q?Fix=20for=20extending=20services=20wr?= =?UTF-8?q?itten=20in=20v2=20format.=20Signed-off-by:=20Jure=20=C5=BDvelc?= =?UTF-8?q?=20?= 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 72ad50af53f..961d36bbd51 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -136,6 +136,9 @@ def version(self): 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', {}) @@ -354,19 +357,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)) @@ -411,7 +414,8 @@ def validate_and_construct_extends(self): 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 3c3c6326b1b..eb8ed2c72d3 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1937,6 +1937,30 @@ def test_extends_with_mixed_versions_is_error(self): 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 76bc06b7295dd1ba765dfda78ba4cb7333a1397a Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 21 Jan 2016 11:38:01 +0000 Subject: [PATCH 1799/4072] 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 28011b1db2a..4a2bc1f7794 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 cbec6f88348783ca49ae66f95aaef2994c5b4866 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 20 Jan 2016 17:08:24 +0000 Subject: [PATCH 1800/4072] 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 d4ec575a687..94046d5b483 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 080c4c49ff5..0fea875a66e 100644 --- a/compose/project.py +++ b/compose/project.py @@ -78,12 +78,11 @@ def from_config(cls, name, config_data, client): 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 1dfda06a668..ea4e57d0983 100644 --- a/compose/service.py +++ b/compose/service.py @@ -426,10 +426,11 @@ def start_container(self, container): 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 @@ def _next_container_number(self, one_off=False): 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 @@ def _get_container_host_config(self, override_options, one_off=False): 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 4c278aa4c81..1806e70760a 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -461,6 +461,10 @@ def test_up_with_networks(self): 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 @@ def test_up_with_networks(self): # 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 @@ def test_up_no_services(self): 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 f1b79df09f5..5351c0f08e2 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 c9244a47d4b..4d9aec651c7 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -536,14 +536,6 @@ def test_specifies_host_port_with_host_ip_and_port_range(self): 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 513a6b35cca1ab49c15723c72e8db6a3c8b7155d Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 21 Jan 2016 15:19:55 +0000 Subject: [PATCH 1801/4072] 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 ea4e57d0983..2df95fc3005 100644 --- a/compose/service.py +++ b/compose/service.py @@ -228,11 +228,9 @@ def create_and_start(service, number): 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 0e91dcf7ce7..379e51ea0c6 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -746,6 +746,11 @@ def test_scale_sets_ports(self): 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 5cfd947f380632461380b1b627facea89a659d95 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 21 Jan 2016 15:28:40 +0000 Subject: [PATCH 1802/4072] 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 2df95fc3005..8afc59c1863 100644 --- a/compose/service.py +++ b/compose/service.py @@ -27,9 +27,7 @@ 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 @@ def create_and_start(service, number): 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 @@ def create_and_start(service, number): 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 6fe54f5c24adc839683efb449b09580bf4cac299 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 19 Jan 2016 13:23:25 +0000 Subject: [PATCH 1803/4072] 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 ecd135f1904..ba6b4cb408e 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 f111f730eb8..a5b49c1ff8a 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 e40de088f382d7f88b541084d8829c92cae576db Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 21 Jan 2016 13:55:40 +0000 Subject: [PATCH 1804/4072] 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 ba6b4cb408e..d1c4f36f6f6 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 18a1829db03aeeeac698e9e5f24ab27e75402a5a Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 21 Jan 2016 13:55:54 +0000 Subject: [PATCH 1805/4072] Update documentation for links Signed-off-by: Aanand Prasad --- docs/compose-file.md | 64 +++++++++++++++++++++++++++----------------- docs/env.md | 4 ++- docs/networking.md | 13 ++++++++- 3 files changed, 54 insertions(+), 27 deletions(-) diff --git a/docs/compose-file.md b/docs/compose-file.md index d1c4f36f6f6..5969b85502d 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`). - -- `external_links`: Links are deprecated, so you should use - [external networks](networking.md#using-externally-created-networks) to - communicate with containers outside the app. +- `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: + + 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 c0e03a4e240..7b7e1bc7c66 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 a5b49c1ff8a..1bdd7fe07e7 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 0952c1bb51e85baf2942473c7261a52928f9d5c9 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 21 Jan 2016 13:58:01 +0000 Subject: [PATCH 1806/4072] 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 1bdd7fe07e7..bcfd39e5f0c 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 38a6d04852e4056f7ee07fdba343f40875a6ef10 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 20 Jan 2016 11:07:28 -0800 Subject: [PATCH 1807/4072] 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 5969b85502d..67adeb82f09 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 6ca410fd6b993fae0d17b43e9e34e1f149098626 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 21 Jan 2016 16:49:50 +0000 Subject: [PATCH 1808/4072] 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 8afc59c1863..ebe7978c342 100644 --- a/compose/service.py +++ b/compose/service.py @@ -430,10 +430,12 @@ def start_container(self, container): 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 @@ def _next_container_number(self, one_off=False): 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 @@ def _get_container_create_options( 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 @@ def _get_container_host_config(self, override_options, one_off=False): 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 1806e70760a..6ae04ee5de9 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -903,14 +903,47 @@ def test_run_with_custom_name(self): 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 836ec709793c5a417a47063f262dce229f5cd144 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 21 Jan 2016 18:04:50 +0000 Subject: [PATCH 1809/4072] 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 ebe7978c342..1832284629a 100644 --- a/compose/service.py +++ b/compose/service.py @@ -656,13 +656,17 @@ def _get_container_host_config(self, override_options, one_off=False): ) 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 b1ebf5ce17288b95638dc8202c8eefb7d9c8b2cf Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 21 Jan 2016 18:34:18 +0000 Subject: [PATCH 1810/4072] 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 4be8536f4b7..7409107df69 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -41,7 +41,7 @@ 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 remove_container(force=False): 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 563baa10343..45e18528183 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 a5767097691..f9e3fb8f73d 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -72,8 +72,8 @@ def test_command_help_nonexistent(self): 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 bf068a828743caa7aee49b36ed205d7ffbb5a5d4 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 19 Jan 2016 15:03:41 -0500 Subject: [PATCH 1811/4072] 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 67adeb82f09..b2675ac9b1a 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -494,9 +494,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 @@ -504,11 +512,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 @@ -612,7 +620,7 @@ subcommand documentation for more information. 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: foobar ### driver_opts @@ -620,9 +628,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 ## external diff --git a/docs/index.md b/docs/index.md index 887df99d6a9..9cb594a74aa 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 833e16117ec942184f145d3388a9843123fa7349 Mon Sep 17 00:00:00 2001 From: Alf Lervag Date: Tue, 22 Dec 2015 11:43:48 +0100 Subject: [PATCH 1812/4072] 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 7409107df69..14febe25462 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -709,7 +709,12 @@ def remove_container(force=False): 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 d399b7893f14a9089720bd882c70c2a98d18c4ed Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 21 Jan 2016 19:15:15 +0000 Subject: [PATCH 1813/4072] 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 f9e3fb8f73d..fd52a3c1e2f 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -71,6 +71,37 @@ def test_command_help_nonexistent(self): 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 227fa5c0dee51683d34495ac1ca97ab63c94f2e6 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 21 Jan 2016 18:07:14 -0800 Subject: [PATCH 1814/4072] 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 45e18528183..ed3b86869b0 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 5545c55eccac1619bed2618621a2856bb79bc026 Mon Sep 17 00:00:00 2001 From: Dimitar Bonev Date: Fri, 22 Jan 2016 01:18:15 +0200 Subject: [PATCH 1815/4072] 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 b0f304e8666..c001df686e0 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 bcfd39e5f0c..1e662dd2caa 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 eb8ed2c72d3..fe60982003e 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -88,11 +88,26 @@ def test_load_v2(self): '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 @@ def test_load_v2(self): '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 @@ def test_load_throws_error_when_not_dict_v2(self): ) ) + 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 883227c4d841805c631c650bb96835515d59e0e9 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 25 Jan 2016 16:14:21 +0000 Subject: [PATCH 1816/4072] 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 1832284629a..166fb0b2f86 100644 --- a/compose/service.py +++ b/compose/service.py @@ -430,12 +430,16 @@ def start_container(self, container): 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 @@ def _next_container_number(self, one_off=False): 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 @@ def _get_container_create_options( 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 @@ def _get_container_host_config(self, override_options, one_off=False): 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 6ae04ee5de9..7bc72305ce4 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -420,8 +420,12 @@ def test_up(self): 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 a66bf72199f2b8cb14a9256f17c97c344535e323 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 21 Jan 2016 18:03:58 -0800 Subject: [PATCH 1817/4072] 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 b872cba9160..2bb2519d148 100644 --- a/compose/config/types.py +++ b/compose/config/types.py @@ -163,3 +163,9 @@ def parse(cls, volume_config): 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 0fea875a66e..bbc61a5bb5b 100644 --- a/compose/project.py +++ b/compose/project.py @@ -74,6 +74,17 @@ def from_config(cls, name, config_data, client): 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) @@ -85,6 +96,9 @@ def from_config(cls, name, config_data, client): links = project.get_links(service_dict) 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, @@ -94,23 +108,13 @@ def from_config(cls, name, config_data, client): 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 @@ -472,6 +476,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 d3fbb71eb12..aa0dd33f01f 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -813,3 +813,39 @@ def test_initialize_volumes_inexistent_external_volume(self): 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 ffd4455f3ec..f587d69709c 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -7,8 +7,10 @@ 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 @@ def test_container_without_name(self): ), ) 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 3da25aa463ade5813f32cbca6caedcb329d51b30 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 22 Jan 2016 16:05:21 -0800 Subject: [PATCH 1818/4072] 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 2bb2519d148..2e648e5a993 100644 --- a/compose/config/types.py +++ b/compose/config/types.py @@ -166,6 +166,4 @@ def repr(self): @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 bbc61a5bb5b..8e763abf606 100644 --- a/compose/project.py +++ b/compose/project.py @@ -477,7 +477,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), @@ -490,7 +491,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 aa0dd33f01f..586f9444cad 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -846,6 +846,7 @@ def test_project_up_named_volumes_in_binds(self): 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 3f28472ebc136d82731ca5a88c20cabc13864925 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 22 Jan 2016 17:42:24 -0800 Subject: [PATCH 1819/4072] 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 961d36bbd51..8e7d96e2689 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -25,6 +25,7 @@ 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 @@ -274,6 +275,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 ecf8d4f9251..5c2d69ec34a 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 8e763abf606..b51fcd7e99d 100644 --- a/compose/project.py +++ b/compose/project.py @@ -42,7 +42,7 @@ def __init__(self, name, services, client, networks=None, volumes=None, 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 @@ def from_config(cls, name, config_data, client): 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: @@ -97,7 +95,13 @@ def from_config(cls, name, config_data, client): 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( @@ -243,7 +247,7 @@ def remove_stopped(self, service_names=None, **options): 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 ' @@ -298,7 +302,7 @@ def remove_networks(self): network.remove() def remove_volumes(self): - for volume in self.volumes: + for volume in self.volumes.values(): volume.remove() def initialize_networks(self): @@ -476,26 +480,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 fe60982003e..98fe77588f4 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -564,6 +564,56 @@ def test_load_with_multiple_files_v2(self): ] 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 f587d69709c..ffd4455f3ec 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -7,10 +7,8 @@ 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 @@ def test_container_without_name(self): ), ) 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 2b7306967bd0672646002e938c63377e0a833732 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 25 Jan 2016 12:45:30 +0000 Subject: [PATCH 1820/4072] 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 | 97 +++++++++++-- tests/unit/config/config_test.py | 131 +++++++++++++++++- tests/unit/config/sort_services_test.py | 4 +- tests/unit/project_test.py | 4 +- 16 files changed, 404 insertions(+), 86 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 8e7d96e2689..c1391c2fbe6 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -19,6 +19,7 @@ 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_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 94046d5b483..56c0cbf5c05 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 ac0fa458538..cf38a60317f 100644 --- a/compose/config/sort_services.py +++ b/compose/config/sort_services.py @@ -5,10 +5,18 @@ 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 get_service_dependents(service_dict, 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 5c2d69ec34a..dfc34d57534 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -15,6 +15,7 @@ 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 b51fcd7e99d..ef913d634fc 100644 --- a/compose/project.py +++ b/compose/project.py @@ -10,6 +10,7 @@ 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 @@ def from_config(cls, name, config_data, client): 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 @@ def get_links(self, service_dict): 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 @@ def _inject_deps(self, acc, service): 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 b2675ac9b1a..6b61755f276 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 -> 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" - net: host -> networks: ["host"] - net: bridge -> networks: ["bridge"] - net: none -> networks: ["none"] + If you're using `net: "container:[container name/id]"`, the value does not + need to change. - 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. + 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 1e662dd2caa..93533e9d1f5 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 7bc72305ce4..4b560efa11d 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -496,8 +496,29 @@ def test_up_missing_network(self): 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 @@ def test_up_predefined_networks(self): 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 00000000000..0a04f46801b --- /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 00000000000..9509837223b --- /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 00000000000..7ab63df8226 --- /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 d0fac377d41..00000000000 --- 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 586f9444cad..0945ebb8e55 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -4,10 +4,12 @@ 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,21 +106,25 @@ def test_volumes_from_container(self): 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({ - 'net': { - 'image': 'busybox:latest', - 'command': ["top"] - }, - 'web': { - 'image': 'busybox:latest', - 'net': 'container:net', - 'command': ["top"] + 'version': 2, + 'services': { + 'net': { + 'image': 'busybox:latest', + 'command': ["top"] + }, + 'web': { + 'image': 'busybox:latest', + 'network_mode': 'service:net', + 'command': ["top"] + }, }, }), - client=self.client, ) project.up() @@ -127,7 +133,28 @@ def test_net_from_service(self): net = project.get_service('net') self.assertEqual(web.net.mode, 'container:' + net.containers()[0].id) - def test_net_from_container(self): + @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', @@ -137,12 +164,24 @@ def test_net_from_container(self): ) 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({ + 'net': { + 'image': 'busybox:latest', + 'command': ["top"] + }, 'web': { 'image': 'busybox:latest', - 'net': 'container:composetest_net_container' + 'net': 'container:net', + 'command': ["top"] }, }), client=self.client, @@ -150,6 +189,40 @@ def test_net_from_container(self): project.up() + web = project.get_service('web') + net = project.get_service('net') + self.assertEqual(web.net.mode, 'container:' + net.containers()[0].id) + + 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', + 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) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 98fe77588f4..0d8f722499e 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1015,6 +1015,126 @@ def test_depends_on_unknown_service_errors(self): 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 @@ def test_invalid_volumes_from_in_extended_service(self): 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 f59906644af..c39ac022562 100644 --- a/tests/unit/config/sort_services_test.py +++ b/tests/unit/config/sort_services_test.py @@ -100,7 +100,7 @@ def test_sort_service_dicts_5(self): }, { 'name': 'parent', - 'net': 'container:child' + 'network_mode': 'service:child' }, { 'name': 'child' @@ -137,7 +137,7 @@ def test_sort_service_dicts_6(self): 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 ffd4455f3ec..3ad131f3ce1 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -365,7 +365,7 @@ def test_use_net_from_container(self): { 'name': 'test', 'image': 'busybox:latest', - 'net': 'container:aaa' + 'network_mode': 'container:aaa' }, ], networks=None, @@ -398,7 +398,7 @@ def test_use_net_from_service(self): { 'name': 'test', 'image': 'busybox:latest', - 'net': 'container:aaa' + 'network_mode': 'service:aaa' }, ], networks=None, From 2b466858556efb990959dce36fd1301e87534700 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 25 Jan 2016 14:26:36 +0000 Subject: [PATCH 1821/4072] 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 358ef5bcc4c..b2d86aa4caf 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 c51be49ec51..8e37d404a0f 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 0d8f722499e..5f8b097b9f7 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1762,6 +1762,7 @@ def test_extends(self): 'name': 'myweb', 'image': 'busybox', 'command': 'top', + 'network_mode': 'bridge', 'links': ['mydb:db'], 'environment': { "FOO": "1", @@ -1779,6 +1780,7 @@ def test_merging_env_labels_ulimits(self): 'name': 'web', 'image': 'busybox', 'command': '/bin/true', + 'network_mode': 'host', 'environment': { "FOO": "2", "BAR": "1", @@ -1797,6 +1799,7 @@ def test_nested(self): 'name': 'myweb', 'image': 'busybox', 'command': '/bin/true', + 'network_mode': 'host', 'environment': { "FOO": "2", "BAR": "2", From 52e74ab7ade1dd70c98480d28bc05cd6fccc5ffc Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 25 Jan 2016 14:27:12 +0000 Subject: [PATCH 1822/4072] 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 c1391c2fbe6..ffd805ad848 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -19,8 +19,8 @@ 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 cf38a60317f..9d29f329e4f 100644 --- a/compose/config/sort_services.py +++ b/compose/config/sort_services.py @@ -4,22 +4,22 @@ 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 get_service_dependents(service_dict, 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 dfc34d57534..05982020921 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -15,7 +15,7 @@ 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 ef913d634fc..e5b6faef3fa 100644 --- a/compose/project.py +++ b/compose/project.py @@ -10,8 +10,8 @@ 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_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 @@ def from_config(cls, name, config_data, client): 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 @@ def from_config(cls, name, config_data, client): use_networking=use_networking, networks=networks, links=links, - net=net, + network_mode=network_mode, volumes_from=volumes_from, **service_dict) ) @@ -197,27 +197,27 @@ def get_links(self, service_dict): 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 166fb0b2f86..106d5b26457 100644 --- a/compose/service.py +++ b/compose/service.py @@ -47,7 +47,6 @@ 'extra_hosts', 'ipc', 'read_only', - 'net', 'log_driver', 'log_opt', 'mem_limit', @@ -113,7 +112,7 @@ def __init__( use_networking=False, links=None, volumes_from=None, - net=None, + network_mode=None, networks=None, **options ): @@ -123,7 +122,7 @@ def __init__( 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 @@ def config_dict(self): '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 @@ def config_dict(self): } 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 @@ def _get_container_host_config(self, override_options, one_off=False): 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 @@ def pull(self, ignore_pull_failures=False): 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 @@ def mode(self): 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 0945ebb8e55..0c8c9a6aca3 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -131,7 +131,7 @@ def test_network_mode_from_service(self): 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 @@ def get_project(): 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 @@ def test_net_from_service_v1(self): 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 @@ def get_project(): 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 379e51ea0c6..cde50b104c6 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -26,7 +26,7 @@ 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 @@ def test_scale_with_immediate_exit(self): 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 3ad131f3ce1..21c6be475da 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -349,7 +349,7 @@ def test_net_unset(self): ), ) 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 @@ def test_use_net_from_container(self): ), ) 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 @@ def test_use_net_from_service(self): ) 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 4d9aec651c7..74e9f0f5316 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -15,16 +15,16 @@ 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 @@ def test_config_dict(self): '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 @@ def test_config_dict(self): } 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 @@ def test_config_dict_with_net_from_container(self): '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 @@ def test_build_ulimits_with_integers_and_dicts(self): 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 @@ def test_net_service(self): ] 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 1823/4072] 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 6b61755f276..b2097e06e64 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 1824/4072] Update for links, external_links, network_mode Signed-off-by: Aanand Prasad --- .../migrate-compose-file-v1-to-v2.py | 45 ++++++++++++++----- 1 file changed, 35 insertions(+), 10 deletions(-) diff --git a/contrib/migration/migrate-compose-file-v1-to-v2.py b/contrib/migration/migrate-compose-file-v1-to-v2.py index a961b0bd746..011ce338dff 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)) - - # net is now networks + "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: - 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 1825/4072] 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 011ce338dff..e156d9e1124 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 1826/4072] 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 e156d9e1124..fb73811c879 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 1827/4072] 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 fb73811c879..23f6be3b60c 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 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 1828/4072] 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 23f6be3b60c..4f9be97f437 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 1829/4072] 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 23877bb5658..3c2ae72b12a 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 1830/4072] 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 56c0cbf5c05..ca9bb67155c 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 c0fe5459472b0a1c770cf0704cc58c1a1c4b2271 Mon Sep 17 00:00:00 2001 From: Tobias Munk Date: Mon, 25 Jan 2016 19:04:03 +0100 Subject: [PATCH 1831/4072] 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 c19e428483f..44a466e3efb 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 b84da7c78bacb28b62ec0b1ad34edd3330320e5b Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 26 Jan 2016 23:22:21 +0000 Subject: [PATCH 1832/4072] 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 44a466e3efb..6e7e4901fb5 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 7f4a94514bb48b18fdc5dbf1c282d094a097ce36 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 26 Jan 2016 23:22:21 +0000 Subject: [PATCH 1833/4072] 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 44a466e3efb..6e7e4901fb5 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 1834/4072] 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 ed3b86869b0..68ef9f3519b 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 1835/4072] 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 e5b6faef3fa..d2787ecfcb9 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 6b61755f276..afef0b1a3cd 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 4b560efa11d..30589bad7a0 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -495,22 +495,6 @@ def test_up_missing_network(self): 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 d765a3fb912f174d5b11b0b3f69d9a5caaa50c03 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 26 Jan 2016 18:48:43 +0000 Subject: [PATCH 1836/4072] 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 56c0cbf5c05..ca9bb67155c 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 16ef3d0eb882cfb96072ff92d13f124d6b5695df Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 26 Jan 2016 11:12:35 -0800 Subject: [PATCH 1837/4072] 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 ed3b86869b0..68ef9f3519b 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 297d20f085ec6ef6858212cf09eea2d7e51e7a43 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 27 Jan 2016 00:42:04 +0000 Subject: [PATCH 1838/4072] 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 e5b6faef3fa..d2787ecfcb9 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 6b61755f276..afef0b1a3cd 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 4b560efa11d..30589bad7a0 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -495,22 +495,6 @@ def test_up_missing_network(self): 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 695c692be6347d47669270958bb0bbe1c383773a Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 26 Jan 2016 11:09:28 +0000 Subject: [PATCH 1839/4072] Bump 1.6.0-rc2 Signed-off-by: Aanand Prasad --- CHANGELOG.md | 15 ++++++++++++--- compose/__init__.py | 2 +- docs/install.md | 6 +++--- script/run.sh | 2 +- 4 files changed, 17 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0251d669187..d115f05d33b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,9 +19,9 @@ Major Features: - 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 want to - keep using links, just leave your Compose file as it is and it'll continue - to work just the same. + 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. @@ -48,6 +48,10 @@ Major Features: 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: @@ -108,6 +112,11 @@ Bug Fixes: - 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 52d0e7bfee7..3c52ff7dfb8 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.0rc1' +__version__ = '1.6.0rc2' diff --git a/docs/install.md b/docs/install.md index 944e9190abd..c1dd9a491ad 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.0rc1/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose + curl -L https://github.com/docker/compose/releases/download/1.6.0rc2/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.0rc1 + docker-compose version: 1.6.0rc2 ## 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.0rc1/run.sh > /usr/local/bin/docker-compose + $ curl -L https://github.com/docker/compose/releases/download/1.6.0rc2/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 3fbc60e00a7..89655053d6b 100755 --- a/script/run.sh +++ b/script/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.6.0rc1" +VERSION="1.6.0rc2" IMAGE="docker/compose:$VERSION" From d3a1cea1709143feb0f2b490dadcf50090dbffc1 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 27 Jan 2016 02:34:59 +0000 Subject: [PATCH 1840/4072] 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 afef0b1a3cd..28fedf4238b 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 4537ec70cc0823e79c132b59c9767950b3d26a4f Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 27 Jan 2016 02:34:59 +0000 Subject: [PATCH 1841/4072] 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 afef0b1a3cd..28fedf4238b 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 1842/4072] 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 48fa771b4cb..82b6ada0805 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 1843/4072] 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 82b6ada0805..46ba6bbca99 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 1844/4072] 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 d8ab7c3e525..e60fa48cd58 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 cac0d1a65b3..2076d512c38 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 5b72cc2869f..cc21bd8a057 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 2d4fdaf9959..573ea3d97be 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 7b7e1bc7c66..c7b93c774a2 100644 --- a/docs/env.md +++ b/docs/env.md @@ -1,11 +1,11 @@ diff --git a/docs/extends.md b/docs/extends.md index 011a7350bf3..c9b65db831e 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 b36eb5ac5d3..264a27eeb7c 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 bb3d51e92ff..1939500c293 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 9cb594a74aa..61bc41ee7f8 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 417a48c1845..3aaca8fa11e 100644 --- a/docs/install.md +++ b/docs/install.md @@ -1,11 +1,11 @@ diff --git a/docs/networking.md b/docs/networking.md index 93533e9d1f5..fb34e3dea4e 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 00000000000..19f51ab4513 --- /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 46e221bb29d..d51ca549d96 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 e3daff25c58..f7634a6d609 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 5406b9c7d24..d1bd61c29f1 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 840104915cd..503622538cd 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 1845/4072] 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 | 38 +++++++++++-------- tests/acceptance/cli_test.py | 15 +++++++- .../sleeps-composefile/docker-compose.yml | 10 +++++ tests/unit/cli/main_test.py | 18 --------- 4 files changed, 45 insertions(+), 36 deletions(-) create mode 100644 tests/fixtures/sleeps-composefile/docker-compose.yml diff --git a/compose/cli/main.py b/compose/cli/main.py index 14febe25462..93c4729f8bb 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -2,6 +2,7 @@ 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 @@ def up(self, project, options): 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 - ) - - if not 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 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 30589bad7a0..b69ce8aa7e8 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 @@ def __init__(self, client, name, running): self.name = name self.running = running - # State.Running == true def __call__(self): try: container = self.client.inspect_container(self.name) @@ -707,6 +707,17 @@ def test_up_handles_sigterm(self): 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 00000000000..1eff7b7307f --- /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 6f5dd3ca97c..fd6c50028fe 100644 --- a/tests/unit/cli/main_test.py +++ b/tests/unit/cli/main_test.py @@ -6,12 +6,9 @@ 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 @@ def test_build_log_printer_all_services(self): 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 1846/4072] 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 c1681ffc720..2a0d8698441 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -92,7 +92,7 @@ def normalize_name(name): 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 fd52a3c1e2f..fec7cdbae5b 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -49,6 +49,13 @@ def test_project_name_from_environment_new_var(self): 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 1847/4072] 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 b60a7eee580..595ff1e1579 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 9249ec62c2304620433eb3a5ca4ae6a4dd44b5b8 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 26 Jan 2016 15:51:09 +0000 Subject: [PATCH 1848/4072] 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 28fedf4238b..5b72cc2869f 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -941,6 +941,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 36a10f8dd523cc97fdd66d4e4a48556e68c51eca Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 26 Jan 2016 15:53:02 +0000 Subject: [PATCH 1849/4072] Update for links, external_links, network_mode Signed-off-by: Aanand Prasad --- .../migrate-compose-file-v1-to-v2.py | 45 ++++++++++++++----- 1 file changed, 35 insertions(+), 10 deletions(-) diff --git a/contrib/migration/migrate-compose-file-v1-to-v2.py b/contrib/migration/migrate-compose-file-v1-to-v2.py index a961b0bd746..011ce338dff 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)) - - # net is now networks + "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: - 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 86bdab64abe6cea20d9b1ced97b3326f41f20f74 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 26 Jan 2016 15:53:25 +0000 Subject: [PATCH 1850/4072] 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 011ce338dff..e156d9e1124 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 1772909fe2a81adc818ed2e31229566917b48359 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 26 Jan 2016 15:54:18 +0000 Subject: [PATCH 1851/4072] 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 e156d9e1124..fb73811c879 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 be66779fe9ef3265e0453dba692f26b08cef2ded Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 26 Jan 2016 16:01:43 +0000 Subject: [PATCH 1852/4072] 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 fb73811c879..23f6be3b60c 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 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 89cca7bcb2acbf994c3936787927e7d315e5b4c9 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 26 Jan 2016 16:08:34 +0000 Subject: [PATCH 1853/4072] 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 23f6be3b60c..4f9be97f437 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 fbe8484377888f305a50ffdd09c9b62381ebc36f Mon Sep 17 00:00:00 2001 From: Mary Anthony Date: Sun, 24 Jan 2016 12:03:44 -0800 Subject: [PATCH 1854/4072] 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 d8ab7c3e525..e60fa48cd58 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 cac0d1a65b3..2076d512c38 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 5b72cc2869f..cc21bd8a057 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 2d4fdaf9959..573ea3d97be 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 7b7e1bc7c66..c7b93c774a2 100644 --- a/docs/env.md +++ b/docs/env.md @@ -1,11 +1,11 @@ diff --git a/docs/extends.md b/docs/extends.md index 011a7350bf3..c9b65db831e 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 b36eb5ac5d3..264a27eeb7c 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 bb3d51e92ff..1939500c293 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 9cb594a74aa..61bc41ee7f8 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 c1dd9a491ad..7563db6b064 100644 --- a/docs/install.md +++ b/docs/install.md @@ -1,11 +1,11 @@ diff --git a/docs/networking.md b/docs/networking.md index 93533e9d1f5..fb34e3dea4e 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 00000000000..19f51ab4513 --- /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 46e221bb29d..d51ca549d96 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 e3daff25c58..f7634a6d609 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 5406b9c7d24..d1bd61c29f1 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 840104915cd..503622538cd 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 24e71db345e031af1247fd7bafe9b09cb2f5ad73 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 19 Jan 2016 14:41:21 -0500 Subject: [PATCH 1855/4072] 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 106d5b26457..2ca0e64d942 100644 --- a/compose/service.py +++ b/compose/service.py @@ -917,6 +917,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 cde50b104c6..189eb9da980 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -343,6 +343,31 @@ def test_execute_convergence_plan_when_image_volume_masks_config(self): ) 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 8fb90bd73267fe880d7389ebc3b0af6da73df914 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 27 Jan 2016 13:26:32 +0000 Subject: [PATCH 1856/4072] 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 48fa771b4cb..82b6ada0805 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 110401b6f045dd9c9a7db40204654c06a450d9b7 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 27 Jan 2016 13:27:12 +0000 Subject: [PATCH 1857/4072] 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 82b6ada0805..46ba6bbca99 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 e925b8272b90aa39a27b104439c4b0abb08f97f4 Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Mon, 25 Jan 2016 10:34:29 +0100 Subject: [PATCH 1858/4072] 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 2b020c372a4..2c18bc04935 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 bbaae11a0f53ae7be59bf8f53428fd8b16fb8d19 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 29 Jan 2016 12:50:52 -0500 Subject: [PATCH 1859/4072] 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 | 38 +++++++++++-------- tests/acceptance/cli_test.py | 15 +++++++- .../sleeps-composefile/docker-compose.yml | 10 +++++ tests/unit/cli/main_test.py | 18 --------- 4 files changed, 45 insertions(+), 36 deletions(-) create mode 100644 tests/fixtures/sleeps-composefile/docker-compose.yml diff --git a/compose/cli/main.py b/compose/cli/main.py index 14febe25462..93c4729f8bb 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -2,6 +2,7 @@ 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 @@ def up(self, project, options): 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 - ) - - if not 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 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 30589bad7a0..b69ce8aa7e8 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 @@ def __init__(self, client, name, running): self.name = name self.running = running - # State.Running == true def __call__(self): try: container = self.client.inspect_container(self.name) @@ -707,6 +707,17 @@ def test_up_handles_sigterm(self): 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 00000000000..1eff7b7307f --- /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 6f5dd3ca97c..fd6c50028fe 100644 --- a/tests/unit/cli/main_test.py +++ b/tests/unit/cli/main_test.py @@ -6,12 +6,9 @@ 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 @@ def test_build_log_printer_all_services(self): 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 d3cd9213c1e8a47337c8720c1af01466ce314006 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 26 Jan 2016 17:41:26 +0000 Subject: [PATCH 1860/4072] 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 23877bb5658..3c2ae72b12a 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 c98c617c3004a5bb71dabdda1ff357bcfe1773d5 Mon Sep 17 00:00:00 2001 From: Steve Durrheimer Date: Mon, 25 Jan 2016 10:27:21 +0100 Subject: [PATCH 1861/4072] 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 430170225cf..f67bc9f6462 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 25df0d81472fdfa5c8cd54b7e8d344bdd2a2ec0c Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Mon, 25 Jan 2016 10:15:14 +0100 Subject: [PATCH 1862/4072] 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 2c18bc04935..3b135311a1d 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -113,6 +113,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) @@ -415,6 +427,7 @@ _docker_compose() { local commands=( build config + create down events help From 6928c24323af38fcce54078e51272be0951ca350 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 1 Feb 2016 16:30:39 -0500 Subject: [PATCH 1863/4072] 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 b162db1e3a6..489be02137b 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 1864/4072] 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 2e9c9167451..14a3e6e4d60 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 1865/4072] 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 5f8b097b9f7..87f10afb024 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -274,9 +274,7 @@ def test_load_invalid_service_definition(self): 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 @@ def test_config_integer_service_name_raise_validation_error(self): ) ) - 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 @@ def test_config_integer_service_name_raise_validation_error_v2(self): ) ) + 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 @@ def test_config_valid_service_names(self): 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 @@ def test_config_hint(self): ) ) + 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 @@ def test_load_errors_on_uppercase_with_no_image(self): }, '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 @@ def test_invalid_config_build_and_image_specified(self): ) ) + 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 @@ def test_invalid_config_type_should_be_an_array(self): ) ) + 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 @@ def test_invalid_config_not_a_dictionary(self): ) ) + 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 @@ def test_invalid_config_not_unique_items(self): ) ) + 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 @@ def test_invalid_list_of_strings_format(self): ) ) - 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 @@ def test_load_config_dockerfile_without_build_raises_error(self): '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 @@ def test_config_extra_hosts_string_raises_validation_error(self): ) ) - 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 @@ def test_config_extra_hosts_list_of_dicts_validation_error(self): ) ) - 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 @@ def test_config_ulimits_invalid_keys_validation_error(self): }, 'working_dir', 'filename.yml')) - assert expected in exc.exconly() - def test_config_ulimits_required_keys_validation_error(self): + 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 @@ def test_validation_fails_with_just_memswap_limit(self): 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 @@ def test_validation_fails_with_just_memswap_limit(self): ) ) + 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 @@ def test_circular(self): 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 @@ def test_extends_validation_empty_dictionary(self): ) ) + 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 @@ def test_extends_validation_missing_service_key(self): ) ) + 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 @@ def test_extends_validation_invalid_key(self): ) ) + 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 @@ def test_extends_validation_sub_property_key(self): ) ) + 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 @@ def test_extends_file_defaults_to_self(self): ])) 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 @@ def test_parent_build_path_dne(self): ]) 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 1866/4072] 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 05982020921..ba1ac52101d 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 87f10afb024..44f5c684311 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -253,15 +253,31 @@ def test_config_invalid_service_names_v2(self): 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 @@ def test_load_errors_on_uppercase_with_no_image(self): }, '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 @@ def test_invalid_config_build_and_image_specified_v1(self): ) ) - 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 @@ def test_invalid_config_type_should_be_an_array(self): ) ) - 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 @@ def test_invalid_list_of_strings_format(self): ) ) - 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 @@ def test_load_config_dockerfile_without_build_raises_error_v1(self): } })) - 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 @@ def test_config_extra_hosts_string_raises_validation_error(self): ) ) - 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 @@ def test_config_extra_hosts_list_of_dicts_validation_error(self): ) ) - 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 @@ def test_config_ulimits_invalid_keys_validation_error(self): '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 @@ def test_config_ulimits_required_keys_validation_error(self): }, '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 @@ def test_validate_extra_hosts_invalid(self): '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 @@ def test_validation_fails_with_just_memswap_limit(self): ) ) - 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 @@ def test_extends_validation_invalid_key(self): ) ) - 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 @@ def test_extends_validation_sub_property_key(self): ) ) - 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 @@ def test_extended_service_with_invalid_config(self): 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 1867/4072] 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 ffd805ad848..34168df5eaa 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -129,8 +129,6 @@ def from_filename(cls, filename): @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 1868/4072] 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 34168df5eaa..bda086b9bdd 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -14,6 +14,8 @@ 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 @@ def from_filename(cls, filename): @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_mapping(mapping, parse_func): 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 c001df686e0..7703adcd0d7 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 ca9bb67155c..8dd4faf5dca 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 2e648e5a993..9bda7180661 100644 --- a/compose/config/types.py +++ b/compose/config/types.py @@ -7,6 +7,7 @@ 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 6ff108fbd1e..d78a5fa7215 100644 --- a/compose/const.py +++ b/compose/const.py @@ -14,9 +14,12 @@ 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 d2787ecfcb9..6411f7cc38b 100644 --- a/compose/project.py +++ b/compose/project.py @@ -10,6 +10,7 @@ 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 @@ def from_config(cls, name, config_data, client): """ 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 @@ def from_config(cls, name, config_data, client): 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 f7f2059fd32..b3d8db39985 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 b69ce8aa7e8..447b1e3239b 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -177,7 +177,7 @@ def test_config_default(self): 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 0a04f46801b..7ba714e89db 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 0a73030ad6d..466d13e5b8f 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 eac4b5f1880..9b846295828 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 9509837223b..9fa7db820d0 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 275fae98db7..4bd0989b741 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 5351c0f08e2..c11fa6821df 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 7b0797e55c8..5c9426b8464 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 644e3dda9eb..db75b780660 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 666f7d34b22..41012535d14 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 7ab63df8226..e4d070b4444 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 fa49878467a..6e76ec0c5a9 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 1eff7b7307f..7c8d84f8d41 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 725296c99cc..a973dd0cf7f 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 12a9de72ca9..c99ae02fc76 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 422f9314eec..481aa404583 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 0c8c9a6aca3..180c9df1ba6 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -10,6 +10,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.types import VolumeFromSpec from compose.config.types import VolumeSpec from compose.const import LABEL_PROJECT @@ -112,7 +113,7 @@ def test_network_mode_from_service(self): name='composetest', client=self.client, config_data=build_service_dicts({ - 'version': 2, + 'version': V2_0, 'services': { 'net': { 'image': 'busybox:latest', @@ -139,7 +140,7 @@ def get_project(): 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 @@ def test_unscale_after_restart(self): @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 @@ def test_project_up_networks(self): @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 @@ 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( - version=2, + version=V2_0, services=[{ 'name': 'web', 'image': 'busybox:latest', @@ -677,7 +678,7 @@ def test_project_up_logging_with_multiple_files(self): base_file = config.ConfigFile( 'base.yml', { - 'version': 2, + 'version': V2_0, 'services': { 'simple': {'image': 'busybox:latest', 'command': 'top'}, 'another': { @@ -696,7 +697,7 @@ def test_project_up_logging_with_multiple_files(self): override_file = config.ConfigFile( 'override.yml', { - 'version': 2, + 'version': V2_0, 'services': { 'another': { 'logging': { @@ -729,7 +730,7 @@ 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( - version=2, + version=V2_0, services=[{ 'name': 'web', 'image': 'busybox:latest', @@ -754,7 +755,7 @@ 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( - version=2, + version=V2_0, services=[{ 'name': 'web', 'image': 'busybox:latest', @@ -779,7 +780,7 @@ def test_initialize_volumes_invalid_volume_driver(self): 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 @@ def test_initialize_volumes_updated_driver(self): 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 @@ def test_initialize_volumes_external_volumes(self): 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 @@ def test_initialize_volumes_inexistent_external_volume(self): 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 @@ def test_project_up_named_volumes_in_binds(self): 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 5870946db57..8e2f25937cf 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -10,6 +10,8 @@ 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 44f5c684311..302f4703fa0 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -14,14 +14,15 @@ 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 @@ def test_load(self): 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 @@ def test_load_v2(self): } }) + 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 @@ def test_load_throws_error_when_not_dict_v2(self): 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 @@ def test_load_throws_error_with_invalid_network_fields(self): 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 @@ def test_config_invalid_service_names_v2(self): 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 @@ def test_load_with_invalid_field_name(self): 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 @@ def test_config_integer_service_name_raise_validation_error_v2(self): config.load( build_config_details( { - 'version': 2, + 'version': '2', 'services': {1: {'image': 'busybox'}} }, 'working_dir', @@ -370,7 +417,7 @@ def test_load_with_multiple_files_and_empty_override(self): 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 @@ def test_load_with_multiple_files_and_empty_base_v2(self): 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 @@ def test_config_build_configuration_v2(self): config.load( build_config_details( { - 'version': 2, + 'version': '2', 'services': { 'web': { 'build': '.', @@ -509,7 +556,7 @@ def test_config_build_configuration_v2(self): service = config.load( build_config_details({ - 'version': 2, + 'version': '2', 'services': { 'web': { 'build': '.' @@ -522,7 +569,7 @@ def test_config_build_configuration_v2(self): service = config.load( build_config_details( { - 'version': 2, + 'version': '2', 'services': { 'web': { 'build': { @@ -543,7 +590,7 @@ def test_load_with_multiple_files_v2(self): base_file = config.ConfigFile( 'base.yaml', { - 'version': 2, + 'version': '2', 'services': { 'web': { 'image': 'example/web', @@ -556,7 +603,7 @@ def test_load_with_multiple_files_v2(self): override_file = config.ConfigFile( 'override.yaml', { - 'version': 2, + 'version': '2', 'services': { 'web': { 'build': '/', @@ -585,7 +632,7 @@ def test_undeclared_volume_v2(self): base_file = config.ConfigFile( 'base.yaml', { - 'version': 2, + 'version': '2', 'services': { 'web': { 'image': 'busybox:latest', @@ -601,7 +648,7 @@ def test_undeclared_volume_v2(self): base_file = config.ConfigFile( 'base.yaml', { - 'version': 2, + 'version': '2', 'services': { 'web': { 'image': 'busybox:latest', @@ -681,7 +728,7 @@ def test_invalid_config_v2(self): config.load( build_config_details( { - 'version': 2, + 'version': '2', 'services': { 'foo': {'image': 1}, }, @@ -1016,7 +1063,7 @@ def test_merge_service_dicts_from_files_with_extends_in_override(self): def test_external_volume_config(self): config_details = build_config_details({ - 'version': 2, + 'version': '2', 'services': { 'bogus': {'image': 'busybox'} }, @@ -1034,7 +1081,7 @@ def test_external_volume_config(self): def test_external_volume_invalid_config(self): config_details = build_config_details({ - 'version': 2, + 'version': '2', 'services': { 'bogus': {'image': 'busybox'} }, @@ -1047,7 +1094,7 @@ def test_external_volume_invalid_config(self): 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 @@ def test_depends_on_orders_services(self): 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 @@ def test_depends_on_unknown_service_errors(self): 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 @@ def test_network_mode_standard_v1(self): 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 @@ def test_network_mode_container_v1(self): 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 @@ def test_network_mode_service_v1(self): 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 @@ def test_network_mode_service_nonexistent(self): 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 @@ 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 + version: "2" services: web: extends: @@ -2224,7 +2271,7 @@ 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 + version: "2" services: web: extends: @@ -2233,7 +2280,7 @@ def test_extends_with_defined_version_passes(self): 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 50b7efcb511..c741a339f41 100644 --- a/tests/unit/config/types_test.py +++ b/tests/unit/config/types_test.py @@ -3,13 +3,13 @@ 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 @@ def test_parse_v1_invalid(self): 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 1869/4072] 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 | 71 +++++++++++++++++--------------- 5 files changed, 70 insertions(+), 41 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index bda086b9bdd..094c8b3a2f2 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -16,10 +16,10 @@ 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 @@ DEFAULT_OVERRIDE_FILENAME = 'docker-compose.override.yml' + log = logging.getLogger(__name__) @@ -131,7 +132,10 @@ def from_filename(cls, filename): @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 @@ def version(self): "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 99129f3de0c..f94ac7acd6d 100644 --- a/compose/config/errors.py +++ b/compose/config/errors.py @@ -2,6 +2,14 @@ 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 ba1ac52101d..6b24013525e 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -15,6 +15,7 @@ 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 d78a5fa7215..0e307835ca9 100644 --- a/compose/const.py +++ b/compose/const.py @@ -17,7 +17,6 @@ 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 302f4703fa0..3c012ea7949 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -17,6 +17,7 @@ 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 @@ def test_v1_file_version(self): 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 @@ def test_wrong_version_type(self): ) ) + 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 @@ def test_unsupported_version(self): ) ) + 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): - for version in [1, "1"]: - with pytest.raises(ConfigurationError): - config.load( - build_config_details( - { - 'version': version, - 'web': {'image': 'busybox'}, - }, - filename='filename.yml', - ) + 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 @@ def test_load_service_with_name_version(self): ]) ) - 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 1870/4072] 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 094c8b3a2f2..be680503b6b 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -138,9 +138,9 @@ def version(self): 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 3c012ea7949..af256f20cd4 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -232,13 +232,18 @@ def test_named_volume_config_empty(self): 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 1871/4072] 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 c9b65db831e..252ffdfc684 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 fb34e3dea4e..96cfa810c8b 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 d51ca549d96..27c686dd7f9 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 1872/4072] 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 cc21bd8a057..9bc725613d6 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 d6fa29480a8..528550c7fb9 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 1874/4072] 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 9bc725613d6..e5326566311 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 f4eeedeb494..55ba5f2d112 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 1875/4072] 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 95a3d64fe40..4067a4f0af4 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 3cb6a55328b..8fa629de26a 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 61bc41ee7f8..f5d84218f89 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 1876/4072] 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 be680503b6b..7a8e5533111 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -84,6 +84,7 @@ 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 1877/4072] 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 7a8e5533111..07f6229035a 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -84,10 +84,7 @@ ALLOWED_KEYS = DOCKER_CONFIG_KEYS + [ 'build', 'container_name', - 'depends_on', 'dockerfile', - 'expose', - 'external_links', 'logging', ] @@ -666,7 +663,14 @@ def merge_mapping(mapping, parse_func): 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 af256f20cd4..f667b3bba99 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -602,6 +602,7 @@ def test_load_with_multiple_files_v2(self): 'services': { 'web': { 'image': 'example/web', + 'depends_on': ['db'], }, 'db': { 'image': 'example/db', @@ -616,7 +617,11 @@ def test_load_with_multiple_files_v2(self): '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 @@ def test_load_with_multiple_files_v2(self): '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 @@ def test_extends_with_defined_version_passes(self): 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 1878/4072] 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 | 83 ++++++++++++++++++++++++++++------------ compose/config/types.py | 19 +++++++++ 2 files changed, 77 insertions(+), 25 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 07f6229035a..f362f1b8081 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -26,6 +26,7 @@ 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 -def merge_service_dicts(base, override, version): - d = {} +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_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_sequence(self, field, parse_func): + def parse_sequence_func(seq): + return to_mapping((parse_func(item) for item in seq), 'merge_field') - 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 + if not self.needs_merge(field): + return - merge_mapping('environment', parse_environment) - merge_mapping('labels', parse_labels) - merge_mapping('ulimits', parse_ulimits) + 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): + md = MergeDict(base, override) + + 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 9bda7180661..fc3347c86fa 100644 --- a/compose/config/types.py +++ b/compose/config/types.py @@ -168,3 +168,22 @@ def repr(self): @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 1879/4072] 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 c9b65db831e..8390767d7d4 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 f667b3bba99..8c9b73dc5fe 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -2313,7 +2313,7 @@ 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 + 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 1880/4072] 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 4f4f552286e..4f4e06b220a 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 6411f7cc38b..2e9cfb8f3b8 100644 --- a/compose/project.py +++ b/compose/project.py @@ -19,7 +19,9 @@ 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 @@ def from_config(cls, name, config_data, client): 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 @@ def from_config(cls, name, config_data, client): ) 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 @@ def from_config(cls, name, config_data, client): 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 @@ def get_links(self, service_dict): 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 @@ def initialize_volumes(self): 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 @@ def remove_images(self, remove_image_type): 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 @@ def up(self, plans = self._get_convergence_plans(services, strategy) - self.initialize_networks() + self.networks.initialize() self.initialize_volumes() return [ @@ -465,22 +428,6 @@ def _inject_deps(self, acc, service): 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 21c6be475da..bec238de657 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -45,7 +45,7 @@ def test_from_config(self): 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 @@ def test_from_config_v2(self): ) 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 @@ def test_uses_default_network_true(self): ), ) - 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 @@ def test_uses_default_network_false(self): ), ) - 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 1881/4072] 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 93c4729f8bb..5121d5c32e4 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 4f4e06b220a..36de45026e2 100644 --- a/compose/network.py +++ b/compose/network.py @@ -133,12 +133,17 @@ def __init__(self, networks, use_networking): @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 2e9cfb8f3b8..291e32b14db 100644 --- a/compose/project.py +++ b/compose/project.py @@ -351,13 +351,12 @@ def up(self, 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 @@ def up(self, ) ] + 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 447b1e3239b..6b9a28e58a2 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -406,7 +406,8 @@ def test_up(self): 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 @@ def test_up_with_default_network_config(self): 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 @@ def test_up_no_services(self): 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 180c9df1ba6..79644e59d10 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -565,6 +565,7 @@ def test_project_up_networks(self): 'name': 'web', 'image': 'busybox:latest', 'command': 'top', + 'networks': ['foo', 'bar', 'baz'], }], volumes={}, networks={ @@ -594,7 +595,11 @@ def test_project_up_networks(self): 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 1882/4072] 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 36de45026e2..82a78f3b53a 100644 --- a/compose/network.py +++ b/compose/network.py @@ -136,7 +136,7 @@ def from_services(cls, services, networks, use_networking): 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 @@ def initialize(self): 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 291e32b14db..a90ec2c3c76 100644 --- a/compose/project.py +++ b/compose/project.py @@ -96,6 +96,7 @@ def from_config(cls, name, config_data, client): 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 2ca0e64d942..e6aad3ae165 100644 --- a/compose/service.py +++ b/compose/service.py @@ -472,6 +472,7 @@ def config_dict(self): '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 6b9a28e58a2..032900d5179 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -406,7 +406,7 @@ def test_up(self): 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 @@ def test_up_with_default_network_config(self): 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 @@ def test_up_with_links_v1(self): 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 79644e59d10..45bae2c306c 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -832,7 +832,8 @@ def test_initialize_volumes_updated_driver(self): ) 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 74e9f0f5316..f34de3bf12c 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -266,7 +266,7 @@ def test_get_container_create_options_does_not_mutate_options(self): self.assertEqual( opts['labels'][LABEL_CONFIG_HASH], - '3c85881a8903b9d73a06c41860c8be08acce1494ab4cf8408375966dccd714de') + 'f8bfa1058ad1f4231372a0b1639f0dfdb574dafff4e8d7938049ae993f7cf1fc') self.assertEqual( opts['environment'], { @@ -417,9 +417,10 @@ def test_config_dict(self): '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 @@ def test_config_dict_with_network_mode_from_container(self): '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 1883/4072] 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 a90ec2c3c76..62e1d2cd3bc 100644 --- a/compose/project.py +++ b/compose/project.py @@ -6,7 +6,6 @@ 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 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 @@ def __init__(self, name, services, client, networks=None, volumes=None): 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 @@ def from_config(cls, name, config_data, client): 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 @@ def from_config(cls, name, config_data, client): 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 @@ def kill(self, service_names=None, **options): 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 @@ def remove_images(self, remove_image_type): 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 @@ def up(self, 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 469e406a871..2713fd32bf2 100644 --- a/compose/volume.py +++ b/compose/volume.py @@ -3,8 +3,10 @@ import logging +from docker.errors import APIError from docker.errors import NotFound +from .config import ConfigurationError log = logging.getLogger(__name__) @@ -50,3 +52,71 @@ def full_name(self): 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 45bae2c306c..6bb076a3ff8 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -749,7 +749,7 @@ def test_initialize_volumes(self): 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 @@ def test_initialize_volumes_invalid_volume_driver(self): 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 @@ def test_initialize_volumes_updated_driver(self): 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 @@ def test_initialize_volumes_updated_driver(self): 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 @@ def test_initialize_volumes_external_volumes(self): 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 @@ def test_initialize_volumes_inexistent_external_volume(self): 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 1884/4072] 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 e6aad3ae165..652ee7944df 100644 --- a/compose/service.py +++ b/compose/service.py @@ -424,8 +424,8 @@ def start_container_if_stopped(self, container, attach_logs=False): 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 1885/4072] 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 8fa629de26a..73596c18be4 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 428e4e58a0f..2495abeacef 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 9e9b36460ce90d8fc99badfd79096dc789d4267b Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 28 Jan 2016 14:44:51 +0000 Subject: [PATCH 1886/4072] 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 5f8b097b9f7..87f10afb024 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -274,9 +274,7 @@ def test_load_invalid_service_definition(self): 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 @@ def test_config_integer_service_name_raise_validation_error(self): ) ) - 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 @@ def test_config_integer_service_name_raise_validation_error_v2(self): ) ) + 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 @@ def test_config_valid_service_names(self): 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 @@ def test_config_hint(self): ) ) + 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 @@ def test_load_errors_on_uppercase_with_no_image(self): }, '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 @@ def test_invalid_config_build_and_image_specified(self): ) ) + 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 @@ def test_invalid_config_type_should_be_an_array(self): ) ) + 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 @@ def test_invalid_config_not_a_dictionary(self): ) ) + 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 @@ def test_invalid_config_not_unique_items(self): ) ) + 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 @@ def test_invalid_list_of_strings_format(self): ) ) - 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 @@ def test_load_config_dockerfile_without_build_raises_error(self): '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 @@ def test_config_extra_hosts_string_raises_validation_error(self): ) ) - 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 @@ def test_config_extra_hosts_list_of_dicts_validation_error(self): ) ) - 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 @@ def test_config_ulimits_invalid_keys_validation_error(self): }, 'working_dir', 'filename.yml')) - assert expected in exc.exconly() - def test_config_ulimits_required_keys_validation_error(self): + 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 @@ def test_validation_fails_with_just_memswap_limit(self): 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 @@ def test_validation_fails_with_just_memswap_limit(self): ) ) + 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 @@ def test_circular(self): 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 @@ def test_extends_validation_empty_dictionary(self): ) ) + 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 @@ def test_extends_validation_missing_service_key(self): ) ) + 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 @@ def test_extends_validation_invalid_key(self): ) ) + 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 @@ def test_extends_validation_sub_property_key(self): ) ) + 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 @@ def test_extends_file_defaults_to_self(self): ])) 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 @@ def test_parent_build_path_dne(self): ]) 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 e9ba06ed4b1cb4f51da7bd2ec4ee1c93b417bfe0 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 28 Jan 2016 15:20:46 +0000 Subject: [PATCH 1887/4072] 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 05982020921..ba1ac52101d 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 87f10afb024..44f5c684311 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -253,15 +253,31 @@ def test_config_invalid_service_names_v2(self): 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 @@ def test_load_errors_on_uppercase_with_no_image(self): }, '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 @@ def test_invalid_config_build_and_image_specified_v1(self): ) ) - 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 @@ def test_invalid_config_type_should_be_an_array(self): ) ) - 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 @@ def test_invalid_list_of_strings_format(self): ) ) - 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 @@ def test_load_config_dockerfile_without_build_raises_error_v1(self): } })) - 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 @@ def test_config_extra_hosts_string_raises_validation_error(self): ) ) - 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 @@ def test_config_extra_hosts_list_of_dicts_validation_error(self): ) ) - 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 @@ def test_config_ulimits_invalid_keys_validation_error(self): '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 @@ def test_config_ulimits_required_keys_validation_error(self): }, '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 @@ def test_validate_extra_hosts_invalid(self): '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 @@ def test_validation_fails_with_just_memswap_limit(self): ) ) - 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 @@ def test_extends_validation_invalid_key(self): ) ) - 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 @@ def test_extends_validation_sub_property_key(self): ) ) - 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 @@ def test_extended_service_with_invalid_config(self): 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 b2ee08f439bc2fb1bf699eb9ca5356e2485ba743 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 28 Jan 2016 16:26:25 +0000 Subject: [PATCH 1888/4072] 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 ffd805ad848..34168df5eaa 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -129,8 +129,6 @@ def from_filename(cls, filename): @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 ce0d469c18c4495d8420e47f81a7fd31a1a24795 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 29 Jan 2016 15:58:38 +0000 Subject: [PATCH 1889/4072] 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 34168df5eaa..bda086b9bdd 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -14,6 +14,8 @@ 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 @@ def from_filename(cls, filename): @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_mapping(mapping, parse_func): 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 c001df686e0..7703adcd0d7 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 ca9bb67155c..8dd4faf5dca 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 2e648e5a993..9bda7180661 100644 --- a/compose/config/types.py +++ b/compose/config/types.py @@ -7,6 +7,7 @@ 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 6ff108fbd1e..d78a5fa7215 100644 --- a/compose/const.py +++ b/compose/const.py @@ -14,9 +14,12 @@ 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 d2787ecfcb9..6411f7cc38b 100644 --- a/compose/project.py +++ b/compose/project.py @@ -10,6 +10,7 @@ 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 @@ def from_config(cls, name, config_data, client): """ 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 @@ def from_config(cls, name, config_data, client): 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 f7f2059fd32..b3d8db39985 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 b69ce8aa7e8..447b1e3239b 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -177,7 +177,7 @@ def test_config_default(self): 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 0a04f46801b..7ba714e89db 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 0a73030ad6d..466d13e5b8f 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 eac4b5f1880..9b846295828 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 9509837223b..9fa7db820d0 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 275fae98db7..4bd0989b741 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 5351c0f08e2..c11fa6821df 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 7b0797e55c8..5c9426b8464 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 644e3dda9eb..db75b780660 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 666f7d34b22..41012535d14 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 7ab63df8226..e4d070b4444 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 fa49878467a..6e76ec0c5a9 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 1eff7b7307f..7c8d84f8d41 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 725296c99cc..a973dd0cf7f 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 12a9de72ca9..c99ae02fc76 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 422f9314eec..481aa404583 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 0c8c9a6aca3..180c9df1ba6 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -10,6 +10,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.types import VolumeFromSpec from compose.config.types import VolumeSpec from compose.const import LABEL_PROJECT @@ -112,7 +113,7 @@ def test_network_mode_from_service(self): name='composetest', client=self.client, config_data=build_service_dicts({ - 'version': 2, + 'version': V2_0, 'services': { 'net': { 'image': 'busybox:latest', @@ -139,7 +140,7 @@ def get_project(): 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 @@ def test_unscale_after_restart(self): @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 @@ def test_project_up_networks(self): @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 @@ 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( - version=2, + version=V2_0, services=[{ 'name': 'web', 'image': 'busybox:latest', @@ -677,7 +678,7 @@ def test_project_up_logging_with_multiple_files(self): base_file = config.ConfigFile( 'base.yml', { - 'version': 2, + 'version': V2_0, 'services': { 'simple': {'image': 'busybox:latest', 'command': 'top'}, 'another': { @@ -696,7 +697,7 @@ def test_project_up_logging_with_multiple_files(self): override_file = config.ConfigFile( 'override.yml', { - 'version': 2, + 'version': V2_0, 'services': { 'another': { 'logging': { @@ -729,7 +730,7 @@ 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( - version=2, + version=V2_0, services=[{ 'name': 'web', 'image': 'busybox:latest', @@ -754,7 +755,7 @@ 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( - version=2, + version=V2_0, services=[{ 'name': 'web', 'image': 'busybox:latest', @@ -779,7 +780,7 @@ def test_initialize_volumes_invalid_volume_driver(self): 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 @@ def test_initialize_volumes_updated_driver(self): 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 @@ def test_initialize_volumes_external_volumes(self): 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 @@ def test_initialize_volumes_inexistent_external_volume(self): 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 @@ def test_project_up_named_volumes_in_binds(self): 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 5870946db57..8e2f25937cf 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -10,6 +10,8 @@ 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 44f5c684311..302f4703fa0 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -14,14 +14,15 @@ 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 @@ def test_load(self): 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 @@ def test_load_v2(self): } }) + 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 @@ def test_load_throws_error_when_not_dict_v2(self): 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 @@ def test_load_throws_error_with_invalid_network_fields(self): 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 @@ def test_config_invalid_service_names_v2(self): 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 @@ def test_load_with_invalid_field_name(self): 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 @@ def test_config_integer_service_name_raise_validation_error_v2(self): config.load( build_config_details( { - 'version': 2, + 'version': '2', 'services': {1: {'image': 'busybox'}} }, 'working_dir', @@ -370,7 +417,7 @@ def test_load_with_multiple_files_and_empty_override(self): 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 @@ def test_load_with_multiple_files_and_empty_base_v2(self): 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 @@ def test_config_build_configuration_v2(self): config.load( build_config_details( { - 'version': 2, + 'version': '2', 'services': { 'web': { 'build': '.', @@ -509,7 +556,7 @@ def test_config_build_configuration_v2(self): service = config.load( build_config_details({ - 'version': 2, + 'version': '2', 'services': { 'web': { 'build': '.' @@ -522,7 +569,7 @@ def test_config_build_configuration_v2(self): service = config.load( build_config_details( { - 'version': 2, + 'version': '2', 'services': { 'web': { 'build': { @@ -543,7 +590,7 @@ def test_load_with_multiple_files_v2(self): base_file = config.ConfigFile( 'base.yaml', { - 'version': 2, + 'version': '2', 'services': { 'web': { 'image': 'example/web', @@ -556,7 +603,7 @@ def test_load_with_multiple_files_v2(self): override_file = config.ConfigFile( 'override.yaml', { - 'version': 2, + 'version': '2', 'services': { 'web': { 'build': '/', @@ -585,7 +632,7 @@ def test_undeclared_volume_v2(self): base_file = config.ConfigFile( 'base.yaml', { - 'version': 2, + 'version': '2', 'services': { 'web': { 'image': 'busybox:latest', @@ -601,7 +648,7 @@ def test_undeclared_volume_v2(self): base_file = config.ConfigFile( 'base.yaml', { - 'version': 2, + 'version': '2', 'services': { 'web': { 'image': 'busybox:latest', @@ -681,7 +728,7 @@ def test_invalid_config_v2(self): config.load( build_config_details( { - 'version': 2, + 'version': '2', 'services': { 'foo': {'image': 1}, }, @@ -1016,7 +1063,7 @@ def test_merge_service_dicts_from_files_with_extends_in_override(self): def test_external_volume_config(self): config_details = build_config_details({ - 'version': 2, + 'version': '2', 'services': { 'bogus': {'image': 'busybox'} }, @@ -1034,7 +1081,7 @@ def test_external_volume_config(self): def test_external_volume_invalid_config(self): config_details = build_config_details({ - 'version': 2, + 'version': '2', 'services': { 'bogus': {'image': 'busybox'} }, @@ -1047,7 +1094,7 @@ def test_external_volume_invalid_config(self): 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 @@ def test_depends_on_orders_services(self): 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 @@ def test_depends_on_unknown_service_errors(self): 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 @@ def test_network_mode_standard_v1(self): 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 @@ def test_network_mode_container_v1(self): 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 @@ def test_network_mode_service_v1(self): 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 @@ def test_network_mode_service_nonexistent(self): 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 @@ 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 + version: "2" services: web: extends: @@ -2224,7 +2271,7 @@ 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 + version: "2" services: web: extends: @@ -2233,7 +2280,7 @@ def test_extends_with_defined_version_passes(self): 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 50b7efcb511..c741a339f41 100644 --- a/tests/unit/config/types_test.py +++ b/tests/unit/config/types_test.py @@ -3,13 +3,13 @@ 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 @@ def test_parse_v1_invalid(self): 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 f081376067b6fc1cb3e8fc52f52ff9148cab919a Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 29 Jan 2016 16:15:16 +0000 Subject: [PATCH 1890/4072] 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 | 71 +++++++++++++++++--------------- 5 files changed, 70 insertions(+), 41 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index bda086b9bdd..094c8b3a2f2 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -16,10 +16,10 @@ 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 @@ DEFAULT_OVERRIDE_FILENAME = 'docker-compose.override.yml' + log = logging.getLogger(__name__) @@ -131,7 +132,10 @@ def from_filename(cls, filename): @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 @@ def version(self): "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 99129f3de0c..f94ac7acd6d 100644 --- a/compose/config/errors.py +++ b/compose/config/errors.py @@ -2,6 +2,14 @@ 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 ba1ac52101d..6b24013525e 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -15,6 +15,7 @@ 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 d78a5fa7215..0e307835ca9 100644 --- a/compose/const.py +++ b/compose/const.py @@ -17,7 +17,6 @@ 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 302f4703fa0..3c012ea7949 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -17,6 +17,7 @@ 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 @@ def test_v1_file_version(self): 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 @@ def test_wrong_version_type(self): ) ) + 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 @@ def test_unsupported_version(self): ) ) + 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): - for version in [1, "1"]: - with pytest.raises(ConfigurationError): - config.load( - build_config_details( - { - 'version': version, - 'web': {'image': 'busybox'}, - }, - filename='filename.yml', - ) + 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 @@ def test_load_service_with_name_version(self): ]) ) - 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 8024f2f09e2969d6e359d62d2dc6488f25a8e248 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 29 Jan 2016 16:38:23 +0000 Subject: [PATCH 1891/4072] 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 094c8b3a2f2..be680503b6b 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -138,9 +138,9 @@ def version(self): 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 3c012ea7949..af256f20cd4 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -232,13 +232,18 @@ def test_named_volume_config_empty(self): 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 0c87e0b18fa354ddfe1543f5a2391db375fe80be Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 29 Jan 2016 14:41:18 -0500 Subject: [PATCH 1892/4072] 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 4f4f552286e..4f4e06b220a 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 6411f7cc38b..2e9cfb8f3b8 100644 --- a/compose/project.py +++ b/compose/project.py @@ -19,7 +19,9 @@ 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 @@ def from_config(cls, name, config_data, client): 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 @@ def from_config(cls, name, config_data, client): ) 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 @@ def from_config(cls, name, config_data, client): 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 @@ def get_links(self, service_dict): 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 @@ def initialize_volumes(self): 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 @@ def remove_images(self, remove_image_type): 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 @@ def up(self, plans = self._get_convergence_plans(services, strategy) - self.initialize_networks() + self.networks.initialize() self.initialize_volumes() return [ @@ -465,22 +428,6 @@ def _inject_deps(self, acc, service): 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 21c6be475da..bec238de657 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -45,7 +45,7 @@ def test_from_config(self): 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 @@ def test_from_config_v2(self): ) 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 @@ def test_uses_default_network_true(self): ), ) - 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 @@ def test_uses_default_network_false(self): ), ) - 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 3e8a4a5dc3000cfb4b64950e1f26248508b48e95 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 29 Jan 2016 16:00:50 -0500 Subject: [PATCH 1893/4072] 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 93c4729f8bb..5121d5c32e4 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 4f4e06b220a..36de45026e2 100644 --- a/compose/network.py +++ b/compose/network.py @@ -133,12 +133,17 @@ def __init__(self, networks, use_networking): @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 2e9cfb8f3b8..291e32b14db 100644 --- a/compose/project.py +++ b/compose/project.py @@ -351,13 +351,12 @@ def up(self, 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 @@ def up(self, ) ] + 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 447b1e3239b..6b9a28e58a2 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -406,7 +406,8 @@ def test_up(self): 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 @@ def test_up_with_default_network_config(self): 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 @@ def test_up_no_services(self): 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 180c9df1ba6..79644e59d10 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -565,6 +565,7 @@ def test_project_up_networks(self): 'name': 'web', 'image': 'busybox:latest', 'command': 'top', + 'networks': ['foo', 'bar', 'baz'], }], volumes={}, networks={ @@ -594,7 +595,11 @@ def test_project_up_networks(self): 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 3b1276bd4481247c5b6ce379bd6c8255a952b146 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 29 Jan 2016 16:30:24 -0500 Subject: [PATCH 1894/4072] 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 36de45026e2..82a78f3b53a 100644 --- a/compose/network.py +++ b/compose/network.py @@ -136,7 +136,7 @@ def from_services(cls, services, networks, use_networking): 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 @@ def initialize(self): 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 291e32b14db..a90ec2c3c76 100644 --- a/compose/project.py +++ b/compose/project.py @@ -96,6 +96,7 @@ def from_config(cls, name, config_data, client): 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 2ca0e64d942..e6aad3ae165 100644 --- a/compose/service.py +++ b/compose/service.py @@ -472,6 +472,7 @@ def config_dict(self): '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 6b9a28e58a2..032900d5179 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -406,7 +406,7 @@ def test_up(self): 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 @@ def test_up_with_default_network_config(self): 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 @@ def test_up_with_links_v1(self): 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 79644e59d10..45bae2c306c 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -832,7 +832,8 @@ def test_initialize_volumes_updated_driver(self): ) 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 74e9f0f5316..f34de3bf12c 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -266,7 +266,7 @@ def test_get_container_create_options_does_not_mutate_options(self): self.assertEqual( opts['labels'][LABEL_CONFIG_HASH], - '3c85881a8903b9d73a06c41860c8be08acce1494ab4cf8408375966dccd714de') + 'f8bfa1058ad1f4231372a0b1639f0dfdb574dafff4e8d7938049ae993f7cf1fc') self.assertEqual( opts['environment'], { @@ -417,9 +417,10 @@ def test_config_dict(self): '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 @@ def test_config_dict_with_network_mode_from_container(self): '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 61d00ebee4229dc1ead4721c5d4501eb1a86a297 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 29 Jan 2016 17:31:27 -0500 Subject: [PATCH 1895/4072] 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 a90ec2c3c76..62e1d2cd3bc 100644 --- a/compose/project.py +++ b/compose/project.py @@ -6,7 +6,6 @@ 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 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 @@ def __init__(self, name, services, client, networks=None, volumes=None): 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 @@ def from_config(cls, name, config_data, client): 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 @@ def from_config(cls, name, config_data, client): 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 @@ def kill(self, service_names=None, **options): 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 @@ def remove_images(self, remove_image_type): 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 @@ def up(self, 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 469e406a871..2713fd32bf2 100644 --- a/compose/volume.py +++ b/compose/volume.py @@ -3,8 +3,10 @@ import logging +from docker.errors import APIError from docker.errors import NotFound +from .config import ConfigurationError log = logging.getLogger(__name__) @@ -50,3 +52,71 @@ def full_name(self): 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 45bae2c306c..6bb076a3ff8 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -749,7 +749,7 @@ def test_initialize_volumes(self): 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 @@ def test_initialize_volumes_invalid_volume_driver(self): 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 @@ def test_initialize_volumes_updated_driver(self): 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 @@ def test_initialize_volumes_updated_driver(self): 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 @@ def test_initialize_volumes_external_volumes(self): 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 @@ def test_initialize_volumes_inexistent_external_volume(self): 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 6b59ba0c31cf5e9a5adff5d4770064159c10c6dc Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 28 Jan 2016 18:50:05 -0500 Subject: [PATCH 1896/4072] 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 c9b65db831e..252ffdfc684 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 fb34e3dea4e..96cfa810c8b 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 d51ca549d96..27c686dd7f9 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 009dbbe97116d6534c91c70e92eb2645c1b85f81 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 28 Jan 2016 18:53:40 -0500 Subject: [PATCH 1897/4072] 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 cc21bd8a057..9bc725613d6 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 d6fa29480a8..528550c7fb9 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 44c7d080bd5f6c3a0132034c90a11839466b4595 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 29 Jan 2016 13:00:43 -0500 Subject: [PATCH 1899/4072] 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 9bc725613d6..e5326566311 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 f4eeedeb494..55ba5f2d112 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 7f009aeeb959b7962542d5674819c57c5130fd1d Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 1 Feb 2016 12:10:21 -0500 Subject: [PATCH 1900/4072] 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 95a3d64fe40..4067a4f0af4 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 3cb6a55328b..8fa629de26a 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 61bc41ee7f8..f5d84218f89 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 3b7471ae848fd6405b0272448a854df94699c0cf Mon Sep 17 00:00:00 2001 From: Ryan Taylor Long Date: Thu, 28 Jan 2016 06:19:03 +0000 Subject: [PATCH 1901/4072] 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 be680503b6b..7a8e5533111 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -84,6 +84,7 @@ ALLOWED_KEYS = DOCKER_CONFIG_KEYS + [ 'build', 'container_name', + 'depends_on', 'dockerfile', 'expose', 'external_links', From aa5ff0546301c608e96e7eb73ef2aa081f5c1d93 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 29 Jan 2016 13:36:51 -0500 Subject: [PATCH 1902/4072] 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 7a8e5533111..07f6229035a 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -84,10 +84,7 @@ ALLOWED_KEYS = DOCKER_CONFIG_KEYS + [ 'build', 'container_name', - 'depends_on', 'dockerfile', - 'expose', - 'external_links', 'logging', ] @@ -666,7 +663,14 @@ def merge_mapping(mapping, parse_func): 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 af256f20cd4..f667b3bba99 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -602,6 +602,7 @@ def test_load_with_multiple_files_v2(self): 'services': { 'web': { 'image': 'example/web', + 'depends_on': ['db'], }, 'db': { 'image': 'example/db', @@ -616,7 +617,11 @@ def test_load_with_multiple_files_v2(self): '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 @@ def test_load_with_multiple_files_v2(self): '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 @@ def test_extends_with_defined_version_passes(self): 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 dc718eae65dda425cf04e8efc8cba74e494d5509 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 1 Feb 2016 13:47:13 -0500 Subject: [PATCH 1903/4072] 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 | 83 ++++++++++++++++++++++++++++------------ compose/config/types.py | 19 +++++++++ 2 files changed, 77 insertions(+), 25 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 07f6229035a..f362f1b8081 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -26,6 +26,7 @@ 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 -def merge_service_dicts(base, override, version): - d = {} +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_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_sequence(self, field, parse_func): + def parse_sequence_func(seq): + return to_mapping((parse_func(item) for item in seq), 'merge_field') - 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 + if not self.needs_merge(field): + return - merge_mapping('environment', parse_environment) - merge_mapping('labels', parse_labels) - merge_mapping('ulimits', parse_ulimits) + 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): + md = MergeDict(base, override) + + 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 9bda7180661..fc3347c86fa 100644 --- a/compose/config/types.py +++ b/compose/config/types.py @@ -168,3 +168,22 @@ def repr(self): @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 46f33f12b022fb9970e41029e3173e557b86f97c Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 1 Feb 2016 14:04:53 -0500 Subject: [PATCH 1904/4072] 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 4067a4f0af4..bceb02578a7 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 f667b3bba99..8c9b73dc5fe 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -2313,7 +2313,7 @@ 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 + version: "2" services: base: image: example From a59982eb1116df1e08ad0a1e807180719f2eb08c Mon Sep 17 00:00:00 2001 From: Mary Anthony Date: Tue, 2 Feb 2016 12:04:13 -0800 Subject: [PATCH 1905/4072] 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 8fa629de26a..73596c18be4 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 428e4e58a0f..2495abeacef 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 64336615cfc4b5cf690f426e313c1a2c0c6e7b0c Mon Sep 17 00:00:00 2001 From: Dimitar Bonev Date: Fri, 29 Jan 2016 14:15:38 +0200 Subject: [PATCH 1906/4072] 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 c1681ffc720..2a0d8698441 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -92,7 +92,7 @@ def normalize_name(name): 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 fd52a3c1e2f..fec7cdbae5b 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -49,6 +49,13 @@ def test_project_name_from_environment_new_var(self): 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 f612bc98d9da19e5b1b75a8239dd138c9d146c27 Mon Sep 17 00:00:00 2001 From: Spencer Rinehart Date: Wed, 3 Feb 2016 14:34:36 -0600 Subject: [PATCH 1907/4072] 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 e5326566311..c8f559920eb 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 1908/4072] 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 e5326566311..b86a8b4555d 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 413a55aa715b85b5e8f5cbbdf4f240ff377df930 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 2 Feb 2016 19:31:49 +0000 Subject: [PATCH 1909/4072] 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 e6aad3ae165..652ee7944df 100644 --- a/compose/service.py +++ b/compose/service.py @@ -424,8 +424,8 @@ def start_container_if_stopped(self, container, attach_logs=False): 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 6caa188730e66dfc328ecb0186c60c72ec1f0f1c Mon Sep 17 00:00:00 2001 From: Spencer Rinehart Date: Wed, 3 Feb 2016 14:34:36 -0600 Subject: [PATCH 1910/4072] 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 e5326566311..c8f559920eb 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 a7c298799130a8eb07844bd4ecbb7b7dc461b7f7 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 4 Feb 2016 12:17:20 -0500 Subject: [PATCH 1911/4072] 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 c8f559920eb..0b7c6dcbc05 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 7d819d18d6f..d625ca19422 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 f837d82ffce..bb3c5d713ff 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 a55210413c63f173b996bb85273ce7951b8683af Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 4 Feb 2016 12:17:20 -0500 Subject: [PATCH 1912/4072] 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 c8f559920eb..0b7c6dcbc05 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 7d819d18d6f..d625ca19422 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 f837d82ffce..bb3c5d713ff 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 1913/4072] 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 5121d5c32e4..deb1e9121cd 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -42,7 +42,7 @@ 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 remove_container(force=False): 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 68ef9f3519b..3fdd34ed027 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 b365e05be74..df4172ce635 100644 --- a/setup.py +++ b/setup.py @@ -34,8 +34,8 @@ def find_version(*file_paths): '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 fec7cdbae5b..69236e2e10d 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -79,8 +79,9 @@ def test_command_help_nonexistent(self): 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 @@ def test_run_interactive_passes_logs_false(self, mock_pseudo_terminal): '--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 19ae76a442078803e326a37abbbe3a991833123b Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 4 Feb 2016 16:01:11 +0000 Subject: [PATCH 1914/4072] 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 5121d5c32e4..deb1e9121cd 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -42,7 +42,7 @@ 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 remove_container(force=False): 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 68ef9f3519b..3fdd34ed027 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 b365e05be74..df4172ce635 100644 --- a/setup.py +++ b/setup.py @@ -34,8 +34,8 @@ def find_version(*file_paths): '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 fec7cdbae5b..69236e2e10d 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -79,8 +79,9 @@ def test_command_help_nonexistent(self): 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 @@ def test_run_interactive_passes_logs_false(self, mock_pseudo_terminal): '--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 8199c4a6e1bb1d332e5c4d305e7dd19c9235c5fa Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Wed, 3 Feb 2016 17:42:57 -0800 Subject: [PATCH 1915/4072] 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 0b7c6dcbc05..ec90ddcdcf2 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -864,21 +864,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 d99cad60e73ad81be3f621b6e9dfef9c9d9facb5 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 27 Jan 2016 13:30:41 +0000 Subject: [PATCH 1916/4072] Bump 1.6.0 Signed-off-by: Aanand Prasad --- compose/__init__.py | 2 +- docs/install.md | 6 +++--- script/run.sh | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/compose/__init__.py b/compose/__init__.py index 3c52ff7dfb8..268bb719e05 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.0rc2' +__version__ = '1.6.0' diff --git a/docs/install.md b/docs/install.md index 7563db6b064..b8979b95930 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.0rc2/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). @@ -54,7 +54,7 @@ which the release page specifies, in your terminal. 7. Test the installation. $ docker-compose --version - docker-compose version: 1.6.0rc2 + docker-compose version: 1.6.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.6.0rc2/run.sh > /usr/local/bin/docker-compose + $ curl -L https://github.com/docker/compose/releases/download/1.6.0/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 89655053d6b..784228e15b4 100755 --- a/script/run.sh +++ b/script/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.6.0rc2" +VERSION="1.6.0" IMAGE="docker/compose:$VERSION" From 869e815213569cec8592c6d0529104489ba557f2 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 4 Feb 2016 23:46:41 +0000 Subject: [PATCH 1917/4072] 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 79aee75fb51..d115f05d33b 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 3ba90fdef92..fedc90ff8cd 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 3aaca8fa11e..cc3890f9c20 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 1918/4072] 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 b680616ef0f..9e79fe77772 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 deb1e9121cd..282feebeb00 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 f94ac7acd6d..d5df7ae55ae 100644 --- a/compose/config/errors.py +++ b/compose/config/errors.py @@ -38,7 +38,8 @@ def msg(self): 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 652ee7944df..16582c19f53 100644 --- a/compose/service.py +++ b/compose/service.py @@ -195,7 +195,9 @@ def stop_and_remove(container): 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 032900d5179..56c4ce8d4c6 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -887,7 +887,12 @@ def test_run_service_with_explicitly_maped_ports(self): 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 6bb076a3ff8..6091bb2252d 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -242,19 +242,24 @@ def test_start_pause_unpause_stop_kill_remove(self): 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 189eb9da980..65428b7de4a 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -128,7 +128,7 @@ def test_create_container_with_read_only_root_fs(self): 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 @@ def test_execute_convergence_plan_without_start(self): 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 @@ def test_scale_sets_ports(self): 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 @@ def test_working_dir_param(self): 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 8c9b73dc5fe..0b3b0c75828 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1469,24 +1469,42 @@ def test_absolute_windows_path_does_not_expand(self): @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 886911504c3..e71defdda49 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 f34de3bf12c..5a2a88ce9fb 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -146,7 +146,13 @@ def test_split_domainname_none(self): 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 @@ def test_memory_swap_limit(self): 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 @@ def test_log_opt(self): 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 @@ def test_parse_repository_tag(self): 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 dc85bc6da08..5e89c037a4a 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 1919/4072] 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 ec90ddcdcf2..e0d447a53b4 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 1920/4072] 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 dc85bc6da08..a18bfda7ca8 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 1921/4072] 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 2e4e036c3cc..2eb3f2afda4 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 1922/4072] 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 8c9b73dc5fe..0fe1307edca 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1594,30 +1594,64 @@ def test_merge_build_or_image_override_with_other(self): ) -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 1923/4072] 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 f362f1b8081..d5d2547ebf8 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 1924/4072] 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 d5d2547ebf8..174dacab6eb 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -87,6 +87,7 @@ '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 1925/4072] 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 | 24 ++++++++++++++++-------- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/compose/container.py b/compose/container.py index 2565c8ffc38..3a1ce0b9fe9 100644 --- a/compose/container.py +++ b/compose/container.py @@ -60,7 +60,7 @@ def image_config(self): @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 886911504c3..189b0c9928e 100644 --- a/tests/unit/container_test.py +++ b/tests/unit/container_test.py @@ -12,8 +12,9 @@ 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 @@ def test_from_ps(self): 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']] - - container = Container.from_ps(None, - self.container_dict, - has_been_inspected=True) + 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) self.assertEqual(container.dictionary, { - "Id": "abc", + "Id": self.container_id, "Image": "busybox:latest", "Name": "/composetest_db_1", }) @@ -142,6 +146,10 @@ def test_get(self): 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 1926/4072] 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 0fe1307edca..bd57c4a7b28 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1506,57 +1506,54 @@ def test_volume_path_with_non_ascii_directory(self): 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 @@ def test_merge_build_or_image_override_with_other(self): 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 1927/4072] 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 d115f05d33b..8df63c5fd3b 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 1928/4072] 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 595ff1e1579..f8822151983 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 1929/4072] 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 ec90ddcdcf2..240fea1e194 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 1930/4072] 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 cc3890f9c20..c50d7649111 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 1931/4072] Merge build.args when merging services. Signed-off-by: Daniel Nephin --- compose/config/config.py | 31 +++++++++++++----------------- tests/unit/config/config_test.py | 33 ++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 18 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 102758e9d57..ddfcca50adf 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 = {} - - 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 + def to_dict(service): + build_config = service.get('build', {}) + if isinstance(build_config, six.string_types): + return {'context': build_config} + return build_config + + 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 e545aba73c6..e0456845721 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1079,6 +1079,39 @@ def test_merge_service_dicts_from_files_with_extends_in_override(self): '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 1932/4072] 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 102758e9d57..c87d73bb88c 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -72,6 +72,7 @@ '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 d220ec548e1..4d974d71084 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 8dd4faf5dca..d3b294d7741 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 652ee7944df..6472caeb15d 100644 --- a/compose/service.py +++ b/compose/service.py @@ -57,6 +57,7 @@ 'volumes_from', 'security_opt', 'cpu_quota', + 'shm_size', ] @@ -654,6 +655,7 @@ def _get_container_host_config(self, override_options, one_off=False): 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 b2524f66827..01fe3683b51 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 3fdd34ed027..2204e6d559e 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 37dc4a0e544..9871d4405c5 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -102,6 +102,12 @@ def test_create_container_with_cpu_quota(self): 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 1933/4072] 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 6472caeb15d..9dd2865b8d4 100644 --- a/compose/service.py +++ b/compose/service.py @@ -40,6 +40,7 @@ 'cap_add', 'cap_drop', 'cgroup_parent', + 'cpu_quota', 'devices', 'dns', 'dns_search', @@ -54,10 +55,9 @@ '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 1934/4072] 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 573ea3d97be..150d36317db 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 1935/4072] 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 83b656333de..5f32dc4dc16 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 1936/4072] 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 9871d4405c5..1cc6b2663d8 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -103,6 +103,7 @@ def test_create_container_with_cpu_quota(self): 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 1937/4072] 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 20aad4208c7..3f1dbd75b8c 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 1938/4072] 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 c342abc5659..19722b0a67d 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 d3b294d7741..f7a67818b19 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 6b24013525e..35727e2ccb8 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 e0456845721..7fecfed3735 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1169,6 +1169,17 @@ def test_depends_on_unknown_service_errors(self): 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 1939/4072] 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 2713fd32bf2..26fbda96fbd 100644 --- a/compose/volume.py +++ b/compose/volume.py @@ -64,12 +64,13 @@ 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')) + 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 @@ def initialize(self): ) ) 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 1940/4072] 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 deb1e9121cd..7413c53cfa8 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -19,6 +19,7 @@ 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 0e307835ca9..db5e2fb4f07 100644 --- a/compose/const.py +++ b/compose/const.py @@ -22,3 +22,8 @@ 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 1941/4072] 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 deb1e9121cd..1ffa9cc3b91 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 032900d5179..ea3d132a581 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -738,6 +738,15 @@ def test_run_service_with_links(self): 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 00000000000..2e14b94bbd8 --- /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 1942/4072] 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 7703adcd0d7..876065e5114 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 7fecfed3735..5f7633d9065 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -231,6 +231,20 @@ def test_named_volume_config_empty(self): 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 1943/4072] 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 087c2692f7c..5b07d1a957b 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 1944/4072] 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 5b07d1a957b..992e285e06d 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 1945/4072] 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 992e285e06d..07132a0cb4a 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 1946/4072] 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 4f9be97f437..387e9ef4a04 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 1947/4072] 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 | 66 ++++++++++++++++++++++--------- 6 files changed, 87 insertions(+), 46 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 5f32dc4dc16..b16d0d2c396 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 150d36317db..a127d0086a1 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 bb3c5d713ff..2efb715a883 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 f7634a6d609..145f53d8afd 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: - - $ ls - Dockerfile app docker-compose.yml tmp - Gemfile bin lib vendor - Gemfile.lock config log - README.rdoc config.ru public - Rakefile db test - - -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. - - sudo chown -R $USER:$USER . +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: @@ -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 1948/4072] 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 01fe3683b51..04733916fdb 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 1949/4072] 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 a127d0086a1..c8863b345ae 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 1939500c293..36577f07510 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 145f53d8afd..ccb0ab73f37 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 503622538cd..62aec25183c 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 1950/4072] 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 4f9be97f437..c690197bd62 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 1951/4072] 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 9dd2865b8d4..e54f29b0290 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 1cc6b2663d8..bcb8733541f 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -273,6 +273,30 @@ def test_execute_convergence_plan_recreate(self): 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 f34de3bf12c..603356bee92 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -691,8 +691,8 @@ def test_get_container_data_volumes(self): }, 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 @@ def test_merge_volume_bindings(self): 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 @@ def test_mount_same_host_path_to_two_volumes(self): ]), ) - 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 @@ def test_different_host_path_in_container_json(self): 'Mode': '', 'RW': True, 'Driver': 'local', - 'Name': 'abcdefff1234' + 'Name': volume_name, }, ] } @@ -799,9 +800,9 @@ def test_different_host_path_in_container_json(self): 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 1952/4072] 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 19722b0a67d..7b9e6f83d79 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -16,6 +16,7 @@ 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 876065e5114..7703adcd0d7 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 f7a67818b19..3196ca89e58 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 29d8a695d02..669df1d2083 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 5f7633d9065..1d6f1cbb09f 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -231,7 +231,7 @@ def test_named_volume_config_empty(self): 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 @@ def test_volume_invalid_driver_opt(self): '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 @@ def test_config_build_configuration_v2(self): 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 1953/4072] Only set a container affinity if there are volumes to copy over. Signed-off-by: Daniel Nephin --- compose/service.py | 28 ++++++++++-------- tests/unit/service_test.py | 58 ++++++++++++++++++++++++++++++++------ 2 files changed, 66 insertions(+), 20 deletions(-) diff --git a/compose/service.py b/compose/service.py index e54f29b0290..78eed4c4601 100644 --- a/compose/service.py +++ b/compose/service.py @@ -592,21 +592,20 @@ def _get_container_create_options( ports.append(port) container_options['ports'] = ports - override_options['binds'] = merge_volume_bindings( + container_options['environment'] = merge_environment( + self.options.get('environment'), + override_options.get('environment')) + + 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['environment'] = merge_environment( - self.options.get('environment'), - override_options.get('environment')) - - if previous_container: - container_options['environment']['affinity:container'] = ('=' + previous_container.id) - container_options['image'] = self.image_name container_options['labels'] = build_container_labels( @@ -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) + + if old_volumes: + affinity = {'affinity:container': '=' + previous_container.id} - return list(volume_bindings.values()) + 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 603356bee92..ce28a9ca4a7 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -267,13 +267,52 @@ def test_get_container_create_options_does_not_mutate_options(self): 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 @@ def test_get_container_data_volumes(self): '/host/volume:/host/volume:ro', '/new/volume', '/existing/volume', + 'named:/named/vol', ]] self.mock_client.inspect_image.return_value = { @@ -710,7 +750,8 @@ def test_merge_volume_bindings(self): '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 @@ def test_merge_volume_bindings(self): '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 1954/4072] 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 7b9e6f83d79..dbc6b6b22d6 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,15 +338,12 @@ def load_mapping(config_files, get_func, entity_type): mapping[name] = config - return mapping + if 'driver_opts' in config: + config['driver_opts'] = build_string_dict( + config['driver_opts'] + ) - -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 + return mapping def load_services(working_dir, config_file, service_configs): diff --git a/compose/utils.py b/compose/utils.py index 669df1d2083..494beea3415 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 1955/4072] 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 dbc6b6b22d6..1e077adcac6 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -31,6 +31,7 @@ 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 3196ca89e58..1c8022d3152 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 35727e2ccb8..53929150928 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 82a78f3b53a..99c04649b35 100644 --- a/compose/network.py +++ b/compose/network.py @@ -15,7 +15,7 @@ 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 @@ def __init__(self, client, project, name, driver=None, driver_opts=None, 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 62e1d2cd3bc..0394fa15a3f 100644 --- a/compose/project.py +++ b/compose/project.py @@ -69,11 +69,11 @@ def from_config(cls, name, config_data, client): 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 78eed4c4601..c597abd0846 100644 --- a/compose/service.py +++ b/compose/service.py @@ -124,7 +124,7 @@ def __init__( 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 @@ def start_container(self, container): 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 @@ def config_dict(self): '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 @@ def _next_container_number(self, one_off=False): 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 1956/4072] 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 53929150928..59ce9f54e72 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 0394fa15a3f..cfb11aa0558 100644 --- a/compose/project.py +++ b/compose/project.py @@ -73,7 +73,9 @@ def from_config(cls, name, config_data, client): 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 ea3d132a581..49048fb790c 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -445,6 +445,33 @@ def test_up_with_default_network_config(self): 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 00000000000..987b0809a54 --- /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 1d6f1cbb09f..88d46a143b7 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -556,6 +556,27 @@ def test_load_sorts_in_dependency_order(self): 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 1957/4072] 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 04733916fdb..2e9632c409a 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 1958/4072] 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 c597abd0846..5f40a4577c7 100644 --- a/compose/service.py +++ b/compose/service.py @@ -473,7 +473,7 @@ def config_dict(self): '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 1959/4072] 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 49048fb790c..4ba48d45a8a 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -445,6 +445,7 @@ def test_up_with_default_network_config(self): 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 1960/4072] 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 1e077adcac6..dbc6b6b22d6 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -31,7 +31,6 @@ 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 1c8022d3152..edccedc664b 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 59ce9f54e72..35727e2ccb8 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 99c04649b35..d17ed0805df 100644 --- a/compose/network.py +++ b/compose/network.py @@ -15,7 +15,7 @@ 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 @@ def __init__(self, client, project, name, driver=None, driver_opts=None, 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 @@ def initialize(self): 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 2e9632c409a..45e1ac0982e 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 987b0809a54..8cf7d5af941 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 88d46a143b7..1d6f1cbb09f 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -556,27 +556,6 @@ def test_load_sorts_in_dependency_order(self): 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 1961/4072] 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 dbc6b6b22d6..d0024e9cd50 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 d17ed0805df..135502cc033 100644 --- a/compose/network.py +++ b/compose/network.py @@ -162,10 +162,7 @@ def initialize(self): 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 45e1ac0982e..6441297fe37 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 4ba48d45a8a..318ab3d3f1a 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -185,7 +185,7 @@ def test_config_default(self): '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 6bb076a3ff8..6542fa18e2a 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -565,7 +565,7 @@ def test_project_up_networks(self): 'name': 'web', 'image': 'busybox:latest', 'command': 'top', - 'networks': ['foo', 'bar', 'baz'], + 'networks': {'foo': None, 'bar': None, 'baz': None}, }], volumes={}, networks={ @@ -598,7 +598,7 @@ def test_up_with_ipam_config(self): 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 1d6f1cbb09f..204003bce51 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -649,6 +649,42 @@ def test_load_with_buildargs(self): 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 bec238de657..c28c2152396 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -438,7 +438,7 @@ def test_uses_default_network_false(self): { '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 1962/4072] 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 5f40a4577c7..8b22b7d7580 100644 --- a/compose/service.py +++ b/compose/service.py @@ -516,7 +516,7 @@ def _get_aliases(self, container): 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 1963/4072] 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 6441297fe37..77c697344df 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 1964/4072] 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 77c697344df..55d4109dcb7 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 1965/4072] 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 6441297fe37..55d4109dcb7 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 1966/4072] 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 1ea4e25f30b..c6f378a9a34 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 d625ca19422..1fd6c116177 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 dc9544cab5f..40ce1e661c3 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 00000000000..2b609efaa9f --- /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 1967/4072] 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 d0024e9cd50..055ae18aca2 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -33,11 +33,11 @@ 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 merge_services(base, override): 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 e1c781fec67..1e56ebb6685 100644 --- a/compose/config/interpolation.py +++ b/compose/config/interpolation.py @@ -21,7 +21,7 @@ def process_item(name, config_dict): ) 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 35727e2ccb8..557e576832b 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. - - 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 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) + + +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): + 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))) + + for key, value in config.items(): + if not isinstance(key, six.string_types): raise ConfigurationError( - "In file '{}' service name: {} needs to be a string, eg '{}'".format( - filename, - service_name, - 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(service_dict, dict): + if not isinstance(value, (dict, type(None))): 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}' 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 318ab3d3f1a..f43926939da 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -159,7 +159,7 @@ def test_config_quiet_with_error(self): '-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 204003bce51..c58ddc607b8 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -231,7 +231,7 @@ def test_named_volume_config_empty(self): 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 @@ def test_volume_invalid_driver_opt(self): 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 @@ def test_load_invalid_service_definition(self): '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 @@ def test_config_integer_service_name_raise_validation_error(self): ) ) - 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 @@ def test_config_integer_service_name_raise_validation_error_v2(self): ) ) - 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 @@ def test_load_with_multiple_files_and_invalid_override(self): 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 1968/4072] 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 557e576832b..6dc72f5663b 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 55d4109dcb7..f446e2ab3ca 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 f43926939da..6c5b7818b9d 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -159,7 +159,7 @@ def test_config_quiet_with_error(self): '-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 c58ddc607b8..1f5183d7809 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -268,7 +268,7 @@ def test_named_volume_invalid_type_list(self): }) 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 @@ def test_networks_invalid_type_list(self): }) 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 @@ def test_load_invalid_service_definition(self): '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 @@ def test_config_integer_service_name_raise_validation_error(self): ) ) - 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 @@ def test_config_integer_service_name_raise_validation_error_v2(self): ) ) - 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 @@ def test_load_with_multiple_files_and_invalid_override(self): 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 1969/4072] 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 35727e2ccb8..fc737a4fd15 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 204003bce51..ea90c50eada 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -371,6 +371,27 @@ def test_load_invalid_service_definition(self): 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 1970/4072] 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 2204e6d559e..5f55ba8ad25 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 1971/4072] 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 4e2083cbc29..60ee5c930cd 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 ce37d794f75..4d3bb7be74e 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1100,7 +1100,7 @@ def test_valid_config_oneof_string_or_list(self): @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 1972/4072] 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 85fef794f04..b7abc007edf 100644 --- a/compose/cli/log_printer.py +++ b/compose/cli/log_printer.py @@ -5,6 +5,7 @@ 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 @@ def no_color(text): 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 cc15fa051e1..5a7ac8d47d1 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 68a0598e128..808700df33e 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 318ab3d3f1a..23427e99a9c 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -746,6 +746,12 @@ def test_up_handles_force_shutdown(self): 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 1973/4072] 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 b7abc007edf..85fef794f04 100644 --- a/compose/cli/log_printer.py +++ b/compose/cli/log_printer.py @@ -5,7 +5,6 @@ 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 @@ def no_color(text): 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 5a7ac8d47d1..3c4b5721db2 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -662,6 +662,10 @@ def up(self, project, options): 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 808700df33e..68a0598e128 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 1974/4072] 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 f446e2ab3ca..514e6a03915 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 1975/4072] 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 055ae18aca2..98b825ec2d8 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 bcb8733541f..129d996d9dc 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -916,6 +916,7 @@ def test_resolve_env(self): '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 4d3bb7be74e..446fc5600ca 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1975,7 +1975,7 @@ def test_resolve_environment(self): } 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 @@ def test_resolve_environment_from_env_file_with_empty_values(self): 'FILE_DEF': u'bär', 'FILE_DEF_EMPTY': '', 'ENV_DEF': 'E3', + 'NO_DEF': '' }, ) @@ -2034,7 +2035,7 @@ def test_resolve_build_args(self): } 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 1976/4072] 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 98b825ec2d8..4e91a3af239 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 3a1ce0b9fe9..c96b63ef441 100644 --- a/compose/container.py +++ b/compose/container.py @@ -134,7 +134,11 @@ def human_readable_command(self): @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 8b22b7d7580..01f17a126a8 100644 --- a/compose/service.py +++ b/compose/service.py @@ -622,6 +622,8 @@ def _get_container_create_options( 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 129d996d9dc..968c0947c71 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -916,7 +916,7 @@ def test_resolve_env(self): '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 69236e2e10d..26ae4e30065 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -138,9 +138,10 @@ def test_run_with_environment_merged_with_options_list(self, mock_pseudo_termina }) _, _, 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 446fc5600ca..11bc7f0b732 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1975,7 +1975,7 @@ def test_resolve_environment(self): } 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 @@ def test_resolve_environment_from_env_file_with_empty_values(self): 'FILE_DEF': u'bär', 'FILE_DEF_EMPTY': '', 'ENV_DEF': 'E3', - 'NO_DEF': '' + 'NO_DEF': None }, ) @@ -2035,7 +2035,7 @@ def test_resolve_build_args(self): } 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 ce28a9ca4a7..321ebad05e3 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -267,7 +267,7 @@ def test_get_container_create_options_does_not_mutate_options(self): 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 @@ def container_get(key): 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 @@ def test_get_container_create_options_no_affinity_without_binds(self): {}, 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 f5533c1ed835a2578ab11afbf45570cc7e080c8d Mon Sep 17 00:00:00 2001 From: jrabbit Date: Sat, 23 Jan 2016 15:58:06 -0500 Subject: [PATCH 1977/4072] 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 f362f1b8081..a42f11a60aa 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -491,12 +491,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): @@ -806,7 +812,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 34d8f9b55af621ee9f60732d973cae5d95c1525e Mon Sep 17 00:00:00 2001 From: jrabbit Date: Sat, 23 Jan 2016 16:19:17 -0500 Subject: [PATCH 1978/4072] 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 8c9b73dc5fe..6cb93288585 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1776,7 +1776,7 @@ def test_resolve_environment(self): } 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): @@ -1817,7 +1817,6 @@ def test_resolve_environment_from_env_file_with_empty_values(self): 'FILE_DEF': u'bär', 'FILE_DEF_EMPTY': '', 'ENV_DEF': 'E3', - 'NO_DEF': '' }, ) @@ -1836,7 +1835,7 @@ def test_resolve_build_args(self): } 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 c1959152631cffda875a9dfe99a5fa0900d18bf4 Mon Sep 17 00:00:00 2001 From: jrabbit Date: Sun, 24 Jan 2016 15:25:06 -0500 Subject: [PATCH 1979/4072] 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 189eb9da980..37dc4a0e544 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -885,7 +885,6 @@ def test_resolve_env(self): 'FILE_DEF': 'F1', 'FILE_DEF_EMPTY': '', 'ENV_DEF': 'E3', - 'NO_DEF': '' }.items(): self.assertEqual(env[k], v) From abec6f58910f5d670493ce23e2de14de286ba248 Mon Sep 17 00:00:00 2001 From: jrabbit Date: Sat, 6 Feb 2016 02:54:06 -0500 Subject: [PATCH 1980/4072] 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 a42f11a60aa..27f5ff6a287 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -491,18 +491,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): @@ -812,7 +806,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 a716bdc4828edcb1ea140a401396624c2e3be5d1 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 2 Feb 2016 17:55:21 +0000 Subject: [PATCH 1981/4072] 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 1ea4e25f30b..c6f378a9a34 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 d625ca19422..1fd6c116177 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 dc9544cab5f..40ce1e661c3 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 00000000000..2b609efaa9f --- /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 c1be49ad53efa5c9ab79d6675a20abc737ecfafc 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 1982/4072] 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 b60a7eee580..f8822151983 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/index.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 8548b75582c8f87113c17b2b5996bcf1ea516e27 Mon Sep 17 00:00:00 2001 From: Lukas Waslowski Date: Mon, 8 Feb 2016 14:29:03 +0100 Subject: [PATCH 1983/4072] 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 6cb93288585..d756b6f68ce 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1594,30 +1594,64 @@ def test_merge_build_or_image_override_with_other(self): ) -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 c77a8cfe3bf09d5cccd8fe77f3b7696cac2be400 Mon Sep 17 00:00:00 2001 From: Lukas Waslowski Date: Mon, 8 Feb 2016 15:17:21 +0100 Subject: [PATCH 1984/4072] 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 27f5ff6a287..2faa12b2dbe 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 ad00f3dd219cec792a2089025917daea461145d3 Mon Sep 17 00:00:00 2001 From: Lukas Waslowski Date: Mon, 8 Feb 2016 15:33:26 +0100 Subject: [PATCH 1985/4072] 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 2faa12b2dbe..102758e9d57 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -87,6 +87,7 @@ 'container_name', 'dockerfile', 'logging', + 'network_mode', ] DOCKER_VALID_URL_PREFIXES = ( From edcbe2eb4d30d2b2c7bcbe03d1c83ed69af5b714 Mon Sep 17 00:00:00 2001 From: cr7pt0gr4ph7 Date: Mon, 8 Feb 2016 21:57:15 +0100 Subject: [PATCH 1986/4072] 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 d756b6f68ce..e545aba73c6 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1506,57 +1506,54 @@ def test_volume_path_with_non_ascii_directory(self): 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 @@ def test_merge_build_or_image_override_with_other(self): 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 deeca57a0d7df41a2bac9e6277421179a2fbaece Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 8 Feb 2016 12:18:48 -0500 Subject: [PATCH 1987/4072] 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 | 24 ++++++++++++++++-------- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/compose/container.py b/compose/container.py index 2565c8ffc38..3a1ce0b9fe9 100644 --- a/compose/container.py +++ b/compose/container.py @@ -60,7 +60,7 @@ def image_config(self): @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 886911504c3..189b0c9928e 100644 --- a/tests/unit/container_test.py +++ b/tests/unit/container_test.py @@ -12,8 +12,9 @@ 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 @@ def test_from_ps(self): 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']] - - container = Container.from_ps(None, - self.container_dict, - has_been_inspected=True) + 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) self.assertEqual(container.dictionary, { - "Id": "abc", + "Id": self.container_id, "Image": "busybox:latest", "Name": "/composetest_db_1", }) @@ -142,6 +146,10 @@ def test_get(self): 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 db12794b1cf004f3d338764a75ac1eebf658ef2f Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 8 Feb 2016 18:15:21 -0500 Subject: [PATCH 1988/4072] 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 d115f05d33b..8df63c5fd3b 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 cea7911f56979485d44186f17ca68aa7a950c106 Mon Sep 17 00:00:00 2001 From: Yohan Graterol Date: Tue, 9 Feb 2016 18:40:44 -0500 Subject: [PATCH 1989/4072] 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 ec90ddcdcf2..240fea1e194 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 2ced83e3d9dd0036bc540a2dfd456abd3a305870 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 10 Feb 2016 13:32:04 -0500 Subject: [PATCH 1990/4072] 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 102758e9d57..37a94498ae8 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -878,6 +878,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 8dd4faf5dca..f5ebbe432e4 100644 --- a/compose/config/service_schema_v2.0.json +++ b/compose/config/service_schema_v2.0.json @@ -195,7 +195,12 @@ "anyOf": [ {"required": ["build"]}, {"required": ["image"]} - ] + ], + "properties": { + "build": { + "required": ["context"] + } + } } } } diff --git a/compose/config/validation.py b/compose/config/validation.py index 6b24013525e..35727e2ccb8 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 e545aba73c6..b77aab4ff85 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1136,6 +1136,17 @@ def test_depends_on_unknown_service_errors(self): 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 155efd28fabfddd1871f8266caff613e5e1742ad Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 10 Feb 2016 13:54:40 -0500 Subject: [PATCH 1991/4072] Merge build.args when merging services. Signed-off-by: Daniel Nephin --- compose/config/config.py | 31 +++++++++++++----------------- tests/unit/config/config_test.py | 33 ++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 18 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 37a94498ae8..2745ca4295e 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 = {} - - 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 + def to_dict(service): + build_config = service.get('build', {}) + if isinstance(build_config, six.string_types): + return {'context': build_config} + return build_config + + 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 b77aab4ff85..7fecfed3735 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1079,6 +1079,39 @@ def test_merge_service_dicts_from_files_with_extends_in_override(self): '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 c7687592ff638517a49f2972988ce22bac9825b3 Mon Sep 17 00:00:00 2001 From: Manuel Kaufmann Date: Wed, 10 Feb 2016 18:58:01 -0500 Subject: [PATCH 1992/4072] 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 573ea3d97be..150d36317db 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 3eac70a9d31edf5c326ec4fc4603ff23c1df8781 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 10 Feb 2016 16:35:27 -0800 Subject: [PATCH 1993/4072] 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 deb1e9121cd..7413c53cfa8 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -19,6 +19,7 @@ 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 0e307835ca9..db5e2fb4f07 100644 --- a/compose/const.py +++ b/compose/const.py @@ -22,3 +22,8 @@ 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 a1d6e3b9e3ba5d9724dca434c05f0c7503f23d18 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 10 Feb 2016 16:49:50 -0800 Subject: [PATCH 1994/4072] 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 2713fd32bf2..26fbda96fbd 100644 --- a/compose/volume.py +++ b/compose/volume.py @@ -64,12 +64,13 @@ 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')) + 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 @@ def initialize(self): ) ) continue + log.info( + 'Creating volume "{0}" with {1} driver'.format( + volume.full_name, volume.driver or 'default' + ) + ) volume.create() except NotFound: raise ConfigurationError( From 4f7c950ca81a4d2428463f3aa9cacead458b5167 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 11 Feb 2016 13:10:29 -0500 Subject: [PATCH 1995/4072] 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 20aad4208c7..3f1dbd75b8c 100644 --- a/requirements-build.txt +++ b/requirements-build.txt @@ -1 +1 @@ -pyinstaller==3.0 +pyinstaller==3.1.1 From dd9a8d6eee7bee298a0ea540c254420ddbf4b074 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 11 Feb 2016 16:31:08 -0800 Subject: [PATCH 1996/4072] 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 7413c53cfa8..cc15fa051e1 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -703,7 +703,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 032900d5179..ea3d132a581 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -738,6 +738,15 @@ def test_run_service_with_links(self): 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 00000000000..2e14b94bbd8 --- /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 7c95c733a9a6060dc538ad1fc2a3e78c718bfb62 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 11 Feb 2016 19:53:28 -0500 Subject: [PATCH 1997/4072] Only set a container affinity if there are volumes to copy over. Signed-off-by: Daniel Nephin --- compose/service.py | 28 ++++++++++-------- tests/unit/service_test.py | 60 ++++++++++++++++++++++++++++++++------ 2 files changed, 67 insertions(+), 21 deletions(-) diff --git a/compose/service.py b/compose/service.py index 652ee7944df..38a305d36e3 100644 --- a/compose/service.py +++ b/compose/service.py @@ -591,21 +591,20 @@ def _get_container_create_options( ports.append(port) container_options['ports'] = ports - override_options['binds'] = merge_volume_bindings( + container_options['environment'] = merge_environment( + self.options.get('environment'), + override_options.get('environment')) + + 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['environment'] = merge_environment( - self.options.get('environment'), - override_options.get('environment')) - - if previous_container: - container_options['environment']['affinity:container'] = ('=' + previous_container.id) - container_options['image'] = self.image_name container_options['labels'] = build_container_labels( @@ -875,18 +874,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) + + if old_volumes: + affinity = {'affinity:container': '=' + previous_container.id} - return list(volume_bindings.values()) + 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 f34de3bf12c..62f7f004259 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -267,13 +267,52 @@ def test_get_container_create_options_does_not_mutate_options(self): 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 @@ def test_get_container_data_volumes(self): '/host/volume:/host/volume:ro', '/new/volume', '/existing/volume', + 'named:/named/vol', ]] self.mock_client.inspect_image.return_value = { @@ -710,7 +750,8 @@ def test_merge_volume_bindings(self): '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 @@ def test_merge_volume_bindings(self): '/var/lib/docker/aaaaaaaa:/existing/volume:rw', ] - binds = merge_volume_bindings(options, intermediate_container) - self.assertEqual(set(binds), set(expected)) + 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 d78ea85301deff1305bbff4c3d1a6e5de0341d6c Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 12 Feb 2016 10:41:27 -0800 Subject: [PATCH 1998/4072] 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 7703adcd0d7..876065e5114 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 7fecfed3735..5f7633d9065 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -231,6 +231,20 @@ def test_named_volume_config_empty(self): 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 2f7a77e954a38cc227beaa829c32982d5b1dcc61 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 12 Feb 2016 16:10:36 -0800 Subject: [PATCH 1999/4072] 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 2745ca4295e..08324c49615 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -30,6 +30,7 @@ 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 @@ -535,6 +536,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 f5ebbe432e4..1fcfc19d35b 100644 --- a/compose/config/service_schema_v2.0.json +++ b/compose/config/service_schema_v2.0.json @@ -107,9 +107,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 35727e2ccb8..53929150928 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 82a78f3b53a..99c04649b35 100644 --- a/compose/network.py +++ b/compose/network.py @@ -15,7 +15,7 @@ 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 @@ def __init__(self, client, project, name, driver=None, driver_opts=None, 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 62e1d2cd3bc..0394fa15a3f 100644 --- a/compose/project.py +++ b/compose/project.py @@ -69,11 +69,11 @@ def from_config(cls, name, config_data, client): 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 38a305d36e3..b2e02cc8411 100644 --- a/compose/service.py +++ b/compose/service.py @@ -123,7 +123,7 @@ def __init__( 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={}): @@ -431,14 +431,14 @@ def start_container(self, container): 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), ) @@ -472,7 +472,7 @@ def config_dict(self): '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) @@ -513,9 +513,9 @@ def _next_container_number(self, one_off=False): 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 c686be8fd3f5b709cd244072f6d854de7056926e Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 12 Feb 2016 16:58:29 -0800 Subject: [PATCH 2000/4072] 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 53929150928..59ce9f54e72 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 0394fa15a3f..cfb11aa0558 100644 --- a/compose/project.py +++ b/compose/project.py @@ -73,7 +73,9 @@ def from_config(cls, name, config_data, client): 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 ea3d132a581..49048fb790c 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -445,6 +445,33 @@ def test_up_with_default_network_config(self): 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 00000000000..987b0809a54 --- /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 5f7633d9065..4061272c074 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -543,6 +543,27 @@ def test_load_sorts_in_dependency_order(self): 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 353da73eaba566e78857cc7d8965eb6e68218c4b Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 12 Feb 2016 17:17:31 -0800 Subject: [PATCH 2001/4072] 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 240fea1e194..a6963b6c0d0 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 6ac6860dda78503ffe105a77bffaa042ebfbc1d1 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 12 Feb 2016 17:20:18 -0800 Subject: [PATCH 2002/4072] 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 b2e02cc8411..9710620147c 100644 --- a/compose/service.py +++ b/compose/service.py @@ -472,7 +472,7 @@ def config_dict(self): '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 42cb719b528f258bd343053c5b67ea660506e393 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 12 Feb 2016 17:48:42 -0800 Subject: [PATCH 2003/4072] 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 49048fb790c..4ba48d45a8a 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -445,6 +445,7 @@ def test_up_with_default_network_config(self): 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 e5689afe4cd0343e848f0974d7bb4109cb7c5785 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 18 Feb 2016 12:04:07 -0800 Subject: [PATCH 2004/4072] 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 08324c49615..2745ca4295e 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -30,7 +30,6 @@ 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 @@ -536,8 +535,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 1fcfc19d35b..98ae90a27a6 100644 --- a/compose/config/service_schema_v2.0.json +++ b/compose/config/service_schema_v2.0.json @@ -107,18 +107,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 59ce9f54e72..35727e2ccb8 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 99c04649b35..d17ed0805df 100644 --- a/compose/network.py +++ b/compose/network.py @@ -15,7 +15,7 @@ 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 @@ def __init__(self, client, project, name, driver=None, driver_opts=None, 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 @@ def initialize(self): 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 a6963b6c0d0..38960cc7210 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 987b0809a54..8cf7d5af941 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 4061272c074..5f7633d9065 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -543,27 +543,6 @@ def test_load_sorts_in_dependency_order(self): 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 f4a22b94eddbe610acf214861c8b2c1690d32dfd Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 18 Feb 2016 14:52:52 -0800 Subject: [PATCH 2005/4072] 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 | 64 +++++++++++++++++++++++++++++++ tests/unit/project_test.py | 2 +- 7 files changed, 83 insertions(+), 10 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 2745ca4295e..2a0df9453ed 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -601,6 +601,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']) @@ -690,6 +693,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']: @@ -699,7 +703,6 @@ def merge_service_dicts(base, override, version): 'depends_on', 'expose', 'external_links', - 'networks', 'ports', 'volumes_from', ]: @@ -787,6 +790,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 d17ed0805df..135502cc033 100644 --- a/compose/network.py +++ b/compose/network.py @@ -162,10 +162,7 @@ def initialize(self): 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 38960cc7210..c2c0f195269 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 4ba48d45a8a..318ab3d3f1a 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -185,7 +185,7 @@ def test_config_default(self): '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 6bb076a3ff8..6542fa18e2a 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -565,7 +565,7 @@ def test_project_up_networks(self): 'name': 'web', 'image': 'busybox:latest', 'command': 'top', - 'networks': ['foo', 'bar', 'baz'], + 'networks': {'foo': None, 'bar': None, 'baz': None}, }], volumes={}, networks={ @@ -598,7 +598,7 @@ def test_up_with_ipam_config(self): 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 5f7633d9065..59dd7364893 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -608,6 +608,70 @@ def test_config_build_configuration_v2(self): 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_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 bec238de657..c28c2152396 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -438,7 +438,7 @@ def test_uses_default_network_false(self): { 'name': 'foo', 'image': 'busybox:latest', - 'networks': ['custom'] + 'networks': {'custom': None} }, ], networks={'custom': {}}, From 654b3710f7833ad6034a1306e5ad5d6b95c8cf0d Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 18 Feb 2016 15:28:12 -0800 Subject: [PATCH 2006/4072] 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 9710620147c..129c26753a9 100644 --- a/compose/service.py +++ b/compose/service.py @@ -515,7 +515,7 @@ def _get_aliases(self, container): 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 ed5fedf516effbb0b819887d84af7bc267c5998b Mon Sep 17 00:00:00 2001 From: Chia-liang Kao Date: Sun, 14 Feb 2016 01:42:51 +0800 Subject: [PATCH 2007/4072] 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 784228e15b4..16778cdd921 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 674e541cf73b999f49b33e0e8430c18600352561 Mon Sep 17 00:00:00 2001 From: Chia-liang Kao Date: Sun, 14 Feb 2016 01:43:06 +0800 Subject: [PATCH 2008/4072] 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 16778cdd921..a6d04885da7 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 f0a8c65b0599a3a9c55fe570d28e40339d40b102 Mon Sep 17 00:00:00 2001 From: Chia-liang Kao Date: Sun, 14 Feb 2016 01:57:04 +0800 Subject: [PATCH 2009/4072] 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 a6d04885da7..749481f6c2e 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 f59fef09a6d0a50a62cb0562fc7d941fb9ee93ed Mon Sep 17 00:00:00 2001 From: Anthon van der Neut Date: Tue, 16 Feb 2016 14:46:47 +0100 Subject: [PATCH 2010/4072] 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 4f9be97f437..387e9ef4a04 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 13ec3d0217939e375fca87ea1899d766a1b30021 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 16 Feb 2016 14:24:17 -0500 Subject: [PATCH 2011/4072] 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 | 17 +++++++++-------- 3 files changed, 34 insertions(+), 9 deletions(-) diff --git a/compose/service.py b/compose/service.py index 129c26753a9..f9424e8fa82 100644 --- a/compose/service.py +++ b/compose/service.py @@ -927,7 +927,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 37dc4a0e544..647d36daa33 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -266,6 +266,30 @@ def test_execute_convergence_plan_recreate(self): 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 62f7f004259..ce28a9ca4a7 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -731,8 +731,8 @@ def test_get_container_data_volumes(self): }, 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) @@ -765,7 +765,7 @@ def test_merge_volume_bindings(self): 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, affinity = merge_volume_bindings(options, previous_container) @@ -803,13 +803,14 @@ def test_mount_same_host_path_to_two_volumes(self): ]), ) - 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', @@ -830,7 +831,7 @@ def test_different_host_path_in_container_json(self): 'Mode': '', 'RW': True, 'Driver': 'local', - 'Name': 'abcdefff1234' + 'Name': volume_name, }, ] } @@ -841,9 +842,9 @@ def test_different_host_path_in_container_json(self): 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 6d2aa80435282890af79ff73a78854c1a904bba6 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 16 Feb 2016 16:34:31 -0500 Subject: [PATCH 2012/4072] 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 c2c0f195269..f27457fcb17 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -646,7 +646,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 8d7b1e9047fc62897560541800ce7bf76b937547 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 16 Feb 2016 16:38:31 -0500 Subject: [PATCH 2013/4072] 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 150d36317db..e616d0e1283 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 1939500c293..36577f07510 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 f7634a6d609..8b7b4fd91f7 100644 --- a/docs/rails.md +++ b/docs/rails.md @@ -43,17 +43,19 @@ You'll need an empty `Gemfile.lock` in order to build our `Dockerfile`. 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 - 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 503622538cd..62aec25183c 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 fcf78fe3dea7df1dd20189dc2c168ab2a0ff62ac Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 16 Feb 2016 16:48:04 -0800 Subject: [PATCH 2014/4072] 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 | 15 ++++++++++++++- 5 files changed, 44 insertions(+), 5 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 2a0df9453ed..46ee2e28e4d 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -16,6 +16,7 @@ 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 @@ -291,7 +292,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, @@ -335,6 +336,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( @@ -854,7 +863,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 876065e5114..7703adcd0d7 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 98ae90a27a6..4c5c40fbc6c 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 29d8a695d02..669df1d2083 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 59dd7364893..204003bce51 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -231,7 +231,7 @@ def test_named_volume_config_empty(self): 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 @@ def test_volume_invalid_driver_opt(self): '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() From 1e29ad9fc7d7e096ce15ba0c5f07ffbee9d3a651 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 18 Feb 2016 10:53:40 -0800 Subject: [PATCH 2015/4072] 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 46ee2e28e4d..119e98f46a8 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -292,8 +292,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, @@ -333,15 +337,12 @@ def load_mapping(config_files, get_func, entity_type): mapping[name] = config - return mapping + if 'driver_opts' in config: + config['driver_opts'] = build_string_dict( + config['driver_opts'] + ) - -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 + return mapping def load_services(working_dir, config_file, service_configs): diff --git a/compose/utils.py b/compose/utils.py index 669df1d2083..494beea3415 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 cfda9d844ef7d09ed4ad8b55fadc7d652694412f Mon Sep 17 00:00:00 2001 From: Anthon van der Neut Date: Wed, 17 Feb 2016 09:56:49 +0100 Subject: [PATCH 2016/4072] 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 387e9ef4a04..c1785b0daee 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 7d22809ef4bbfa26dabdb92c06331f95fccf69b9 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 16 Feb 2016 17:30:23 -0500 Subject: [PATCH 2017/4072] 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 119e98f46a8..1daae98183e 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -33,11 +33,11 @@ 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 @@ -387,22 +387,31 @@ def merge_services(base, override): 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 e1c781fec67..1e56ebb6685 100644 --- a/compose/config/interpolation.py +++ b/compose/config/interpolation.py @@ -21,7 +21,7 @@ def process_item(name, config_dict): ) 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 35727e2ccb8..557e576832b 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. - - 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 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) + + +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): + 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))) + + for key, value in config.items(): + if not isinstance(key, six.string_types): raise ConfigurationError( - "In file '{}' service name: {} needs to be a string, eg '{}'".format( - filename, - service_name, - 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(service_dict, dict): + if not isinstance(value, (dict, type(None))): 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}' 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 318ab3d3f1a..f43926939da 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -159,7 +159,7 @@ def test_config_quiet_with_error(self): '-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 204003bce51..c58ddc607b8 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -231,7 +231,7 @@ def test_named_volume_config_empty(self): 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 @@ def test_volume_invalid_driver_opt(self): 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 @@ def test_load_invalid_service_definition(self): '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 @@ def test_config_integer_service_name_raise_validation_error(self): ) ) - 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 @@ def test_config_integer_service_name_raise_validation_error_v2(self): ) ) - 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 @@ def test_load_with_multiple_files_and_invalid_override(self): 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 ea8032c115ad85637edf3043d2c69edb5399368f Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 19 Feb 2016 12:35:05 -0500 Subject: [PATCH 2018/4072] Make config validation error messages more consistent. Signed-off-by: Daniel Nephin --- compose/config/validation.py | 33 ++++++++++++++++---------------- tests/acceptance/cli_test.py | 2 +- tests/unit/config/config_test.py | 21 +++++++++++--------- 3 files changed, 29 insertions(+), 27 deletions(-) diff --git a/compose/config/validation.py b/compose/config/validation.py index 557e576832b..6dc72f5663b 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/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index f43926939da..6c5b7818b9d 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -159,7 +159,7 @@ def test_config_quiet_with_error(self): '-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 c58ddc607b8..1f5183d7809 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -268,7 +268,7 @@ def test_named_volume_invalid_type_list(self): }) 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 @@ def test_networks_invalid_type_list(self): }) 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 @@ def test_load_invalid_service_definition(self): '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 @@ def test_config_integer_service_name_raise_validation_error(self): ) ) - 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 @@ def test_config_integer_service_name_raise_validation_error_v2(self): ) ) - 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 @@ def test_load_with_multiple_files_and_invalid_override(self): 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 61906ac2ff6d8f90b59e33b28b95fe50c67c254d Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 18 Feb 2016 17:17:20 -0800 Subject: [PATCH 2019/4072] 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 f27457fcb17..f218dc4f926 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -555,10 +555,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 @@ -580,11 +581,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 f7c923062d24e4eae5559b173af8c244d0ee9657 Mon Sep 17 00:00:00 2001 From: Victoria Bialas Date: Thu, 18 Feb 2016 17:49:28 -0800 Subject: [PATCH 2020/4072] 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 f218dc4f926..f0b7b673bf3 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 f8e3c46fbb6134d694fb98f1c3bc899a2f13f357 Mon Sep 17 00:00:00 2001 From: Victoria Bialas Date: Thu, 18 Feb 2016 18:05:30 -0800 Subject: [PATCH 2021/4072] 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 f0b7b673bf3..485429b51bc 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 b79ad5f9663178eb3019ca53b54a818914c3039e Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 19 Feb 2016 14:22:55 -0500 Subject: [PATCH 2022/4072] 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 6dc72f5663b..4e2083cbc29 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -332,6 +332,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 1f5183d7809..ce37d794f75 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -394,6 +394,27 @@ def test_load_invalid_service_definition(self): config.load(config_details) assert "service 'web' must be a mapping not a string." 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 4aae2c3b7b009d97b200c40fabf980342e62481b Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 22 Feb 2016 12:56:54 -0800 Subject: [PATCH 2023/4072] 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 3fdd34ed027..5f55ba8ad25 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ PyYAML==3.11 cached-property==1.2.0 -docker-py==1.7.0 +docker-py==1.7.1 dockerpty==0.4.1 docopt==0.6.1 enum34==1.0.4 From 73c2f8ee37bf5c7cf5d9277b8c99dcfda0105621 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 22 Feb 2016 14:48:56 -0800 Subject: [PATCH 2024/4072] 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 4e2083cbc29..60ee5c930cd 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 ce37d794f75..4d3bb7be74e 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1100,7 +1100,7 @@ def test_valid_config_oneof_string_or_list(self): @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 155d813606a5f1a9492a630807b12c999ef9b709 Mon Sep 17 00:00:00 2001 From: Richard Bann Date: Thu, 18 Feb 2016 12:13:16 +0100 Subject: [PATCH 2025/4072] 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 85fef794f04..b7abc007edf 100644 --- a/compose/cli/log_printer.py +++ b/compose/cli/log_printer.py @@ -5,6 +5,7 @@ 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 @@ def no_color(text): 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 cc15fa051e1..5a7ac8d47d1 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 68a0598e128..808700df33e 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 6c5b7818b9d..28f5155aa83 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -746,6 +746,12 @@ def test_up_handles_force_shutdown(self): 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 daebf74d6cd888473868d98815b68f92739a7e53 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 22 Feb 2016 16:46:09 -0800 Subject: [PATCH 2026/4072] 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 b7abc007edf..85fef794f04 100644 --- a/compose/cli/log_printer.py +++ b/compose/cli/log_printer.py @@ -5,7 +5,6 @@ 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 @@ def no_color(text): 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 5a7ac8d47d1..3c4b5721db2 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -662,6 +662,10 @@ def up(self, project, options): 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 808700df33e..68a0598e128 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 bcd5286cd38a51336e151ff0640a3312095f4818 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 22 Feb 2016 17:30:42 -0800 Subject: [PATCH 2027/4072] 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 1daae98183e..a05bf8ebdc0 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -511,12 +511,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): @@ -826,7 +826,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 647d36daa33..abbf1978b49 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -909,6 +909,7 @@ def test_resolve_env(self): '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 4d3bb7be74e..446fc5600ca 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1975,7 +1975,7 @@ def test_resolve_environment(self): } 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 @@ def test_resolve_environment_from_env_file_with_empty_values(self): 'FILE_DEF': u'bär', 'FILE_DEF_EMPTY': '', 'ENV_DEF': 'E3', + 'NO_DEF': '' }, ) @@ -2034,7 +2035,7 @@ def test_resolve_build_args(self): } 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 9d7dbe3857ef0c5c79229747e26caa68b4f0d228 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 22 Feb 2016 17:47:51 -0800 Subject: [PATCH 2028/4072] 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 a05bf8ebdc0..48b34318b6a 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -826,7 +826,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 3a1ce0b9fe9..c96b63ef441 100644 --- a/compose/container.py +++ b/compose/container.py @@ -134,7 +134,11 @@ def human_readable_command(self): @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 f9424e8fa82..4e169daaedf 100644 --- a/compose/service.py +++ b/compose/service.py @@ -621,6 +621,8 @@ def _get_container_create_options( 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): @@ -1018,3 +1020,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 abbf1978b49..bbfcd8ec902 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -909,7 +909,7 @@ def test_resolve_env(self): '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 69236e2e10d..26ae4e30065 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -138,9 +138,10 @@ def test_run_with_environment_merged_with_options_list(self, mock_pseudo_termina }) _, _, 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 446fc5600ca..11bc7f0b732 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1975,7 +1975,7 @@ def test_resolve_environment(self): } 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 @@ def test_resolve_environment_from_env_file_with_empty_values(self): 'FILE_DEF': u'bär', 'FILE_DEF_EMPTY': '', 'ENV_DEF': 'E3', - 'NO_DEF': '' + 'NO_DEF': None }, ) @@ -2035,7 +2035,7 @@ def test_resolve_build_args(self): } 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 ce28a9ca4a7..321ebad05e3 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -267,7 +267,7 @@ def test_get_container_create_options_does_not_mutate_options(self): 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 @@ def container_get(key): 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 @@ def test_get_container_create_options_no_affinity_without_binds(self): {}, 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 2029/4072] 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 | 195 ++++++++++++++++++------------ 9 files changed, 125 insertions(+), 81 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 c8863b345ae..fb1fa214183 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 36577f07510..60482bce50b 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 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 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 zcmaI71ys~gw>LaPBPAgyEh#PCt^@!x9*S5}n5dPe*V003agNWW7B01(CCzj|mW@J~9JNKpZRa~_#@ z;_4p2!wke~nz_c1fks=M=$eT}*SAFj*Yc~ca?FD>ZArj>;KqvTF+I= zM8y?EzaCkhkW_$K!8SYpxV+=~a3n|x1;SYay7k8lMcCCqY7G1bvlR1TvMB925D*5RHoP%qx5A@}jN8S_ z0k(+;p~s1q%lX(i6K1uxsSPW#@ODn%h}^6WQRzF&(p3t(y8W}VRm6QMGnhe6mF2x_ z%A2Rtuf2y7(Un<5ayt&DSVWS^F_c&YlSzja+ON5A!I^gJshsld$G=lOmOiOr{N=$l zv)sZn#@%+{As4?@oJ@uB)wEdW%Y2*o+^2ckVo5*wgnKO?t6NL_EN!paQ!TgJO_Q8( za3;(8ytBNnb0z#OFXvxIVwHW)(Z{XA7;4GXaw;tEK84J`D^8}Ipc98xpc0Guq2R(f zFbgGz7s8rJ5n4%FmzT6GnIaY*g6|+S*s`qNA z63=mD#_@p|cTCTEJ@w!(J43G_?&brhncYgTMw?DN^M_9nPASrgtE0*ZXj|M8$0%29 z;!6nA^RS_FYOkT#Hj&hwi{i9CslpUN;ioHj;%@Jf-X=1-^n7PKyxFZYY=e=YF0FXm9P)k_bYr4P|2)r4Lk(Oc z0;*=+di!w)Mp&s?ind^~H{V0y>%>+=MIV+oyNkwGx@7{$gox zDTfZ%Y9D2Y-Ag7;YVGa>!PNz{Kf#Miei@&&cs22ZzM+L##42bAFhiB_Vn3w0EicV< zu4Z@l!vHs=?-si8h`Qb^-Fw^T=ocRLn1b*msrz#MPN$*y;9NCtr7Bggp{;LFHJ^Az zFIN>hmViM(-~5Y;#yn1N(@vQU-RZ4_@H2BDo-c-S)da7Up5V5?aIDm_iJkW1W@Rj) zxarwfV5x9nn&IA8e(fjw(mmScdmowJrR%%`mq!egy%*A_UO$^JLH=u_u6@y+chZv& zE5R&(Ewu6-R(A9cb_!&ca6)kp+Qf5u7Tarz?j+wxUArK)CV*V*d89P8o3NzM&XfK(CdbW;Qnw-LTqw>*}aW@n|2m@c}oMx9@g@>S5)txqzvl z1G|~k$^kjs&d%w5Yq)1~IVu_kIW|X?319OE>h>5rI)yQdyV6Tz8=+ef2OrcB4lYkE z!}f@Moma&Z?h`#bnYcH0Dr3RdgAdFiwLh7K`xwFdbo(L6B<-{@IvdR_F$)_lWM>{U zShtM+qpp2f-LlaJW@$WpS(Pt z{TF#qbRTn5GKqjuPx!_=QuqE9$+OcnJ`t+*vpmNK_Wx2r-MQ!v`gvxvbVM%NPMlZ; zi^7IQO`Ak6x{FTh1@n^?Yzn?V!0arE;+^E2fnKc za^ETW%y{B@ts!IkPkz6ARz_%FtRL6%Svl5pCvix+dj(eX(MSb34lAC9*?Eyt#6mo& zJY;)YiZ&KG&u-@UMBqgg{xc<6(vqcub?hbNA;IlcGDA^AfV|OTQF7GO%1Kb3ggA)2 ziR6UHFX0%RwuSvEPzakI9OpbJysyGn^kc?4;E+25K|Qu>#u}7G}Er#7^EtFp#owBb)BcP2Tmd4o(GG+085$ zpnPO`$hC5w*?mg5C0i7VOF3UT7@LORksQz_Zi~0cE$$TUMKD_8pkN&!gaT#|vt2{A zVwhRV61vmmVQbYI!ZIBIAI$uyz4v*9u4Enl!?-`KH0$f4I30q4X06e$QfvFHap?st zm^o_x=Qlc4$FWi??Z;AQuBJuc+m?%|E6KmT0l+)n<%+2h8_Ab<}pUH3rzXn;TB29Fu^SX^5C*I zu`BItFU(3p$}?&N8tPvN(>xD_zisKexLrZ*U?m6dA&5R{`R;#94PAc@`NhyKrL0CY z81CSd*vQ_@EasHSELL#_r+zFlk zy+bIeWaXNc4S$$!KDJ~vVCb`(j{o@`MMFH78kp;$P~MKd3mL-+BGuNOtf0v2`hV@M z)@1$KCgy}i|6@ozwSRS!*(s$RyI8^h`U0hx>f~|bl6Pc!>x|b{V)KmG)ESjGF)?Ie z2^Kz3R=;3P+O#(S&!CxosG^SzL!gK59eRViP>AC3{e`rK+*a^IQgtx^J12>LO2 zHK}XCb=l^oqe=1M9^_{Mwr+_90OGz15`=1PvBtsmS4rQnitQPaIe(d+-e>9HDN5-b zld!6d{Dv=A#y|amP&fT2&aaU7?#a8Yrl#&-1xM0}*jIi4!1K%%uc-l=x5?=YYq%e*7W2)S<5j->0L1X~dkqcOn%t04v}9%22L)L}x{> zbh&KMRx@46dM|4~ey>m3gtGC|{Q?eRX=xfd$m1x9pS*4i272n_@T2hU`pV}&Rb?H!H=1o$tC zW2@*IRls$%UL^Q^AwbC=4hDYm-(*z`JkaqElw=X}M4a||%vwFid(UgoR?nF8I<_kb zS$@OO$Hi{N0`BicEuTeS6{!!C>}ec4XFDg;?n6{2xQGCM;pb1<+InItR=(be^Bxr6 zq`LAG^ORJ_h+8aF@h1(apw9qB1&1oDcq)OkiGEmUlV7(#n-9y7nS@xB585(mwO z##IiS?~i?9KPAZ5g8&?)4En^f;d9 zR(RQ7IN78DdYy~?hBO|09*iEA*LG9@fR-QYeK4p_sJ@^F3wfYPF5PeAdF;=pfXd

zKnQ6NNOBadY-8zom!mt4|BQp@ji)LJH!1{zV1-ZF5|RG? zn40!Q6Q01)t-ReNd!JM5kL&6as~ho4IatwUJC z8a!jeCF}l7%lQRQ0nQ)%*PHv&virHmkgG=SM-Ty&mtrKrjV>V}F}Gd!D~L-NONax; zP*MQkx85eU6PkSpW0ROa0FaGU;c1B_ES-__+W3^6X9-R?i->*$^oF1_kGQ@AepCA{ z8BQIJpGNIGYHLIHdZ#z)ONR3(KkiO%5}yNKC2^?QJgD@bW3Nl|DnRM8+sg8sxOXY^ zdI=Rx44~_^tc0M58d8b5l!q#mA`~fT8H^|fKrmc9zPgU}J-B|??EH#4quOER$ewnL zCM#)Yyz1yLLHWW$>#qroIGsC~YvyRq^@|Lj2g8ngki;nd=)GtC4*ui{iEfGvVD?EA2J`~8M!hb74(#>u002GgZOhi0OD zl^SxZC;(wx1OPv69@TLqoHgRoPzDyb=8mQ*)}Z{&sfs)gqy*?;bZ<7@LyQ~Suq4KR zXm6A%%wOSopv=_12cU^L1AJ~4x{jU=;YQH~yq<}FM#^~$Ed$F!m$(2|fMC85!OgiV zYbfBkKj3Ao`zZN$>an{&t!JP8|y<5V$J9w|1bieYrpt4SCJ^Rn_2mEhebf2jH7?`BtYwEbP@i ze1E_veZ@X8lGX4JhyG=DX6Wj(+pf%ZO`!oxmYZq`omrB z5d_jRuvM@g>$of^Xc!IBJ1(FfbS)^aLOkU{7Xzf%yKk+y!uA>pM{t-G`gS8G7H&I> zW=gz5hM#=#EhI6sJz2SWDRtJ#enYcUiab1H$H~pWQ%TPN;1A$J6l=1dxhh&r<)L6c zJ&ZI+uXOyTUUP2Ks#jfk0u%$l^QV6%7a*sgwr&O`#i3>r1$=P%r5!r}3PgL-f8b?+ zFqRlPw~noYW#58DkX+dJMenYvN;ZHPfEaOg76&43JztGwCsOEDfK`d8d=z9wKjw%G zKzI(o02YVP>5ilDzU&aT2qHnv{=x=?!x#^4bhf$_;r!};ROZ3_s+}ST?rbE=r#)F$ z8uLxvC6tfWx|1j=-fa^PZaDw+aD4kcG^}#;_rmJ-k^Kcv^R2+rgBcn^w=b^$HXy2J z^y-ttx27Higjqw<>t-z00L#gTWQS%^6cPZ?g2F#YS?5Q8JC@z@L-LSwLPKdFw*dG} zPvy!6rMkcOYXHz;IW@U-zz|1XLkYz?dVaFh>#|Y_<}-`JOHr3Go^+7l9Cv=We}@3q zoF9P4$f)~ne#{9b&&ZilUuX)$RwOD<%I1AoCcE2zbqse*Dk9~Pj-KPI!!$A+12m$T}xI(<&5)mPv9v`ivJQdL{o2vo>pHtvanKW#HnSCSAV{;k=#-|rVWP% z7pftF+mCU>=Muqz;;Y1sU3!h0jPHW3n{<+D1>`I!o_vg0QtER05)1ETBZ$@mbAE+Y zrwnr5e)>?Q0D0W}u3>8Rj|xWZCsJ>?oXTSlI)DiIRQNnxwr%?stvUYP4$`c#Vu^p6CBJw`b!md9Gz(O~Sl5a5<3rEmsFKg4rkGc*Ws=GK z1CJ`2^JnE|dG&?FIN2d=*_ATIYmU}J$P#S(f6-wlEP!sYAVHbci zq4>)$yHtoIdA_W<8N!u@ryluGL2S8>7eY?LtX813EYWF5vbQKy z=WqV;uWu!@HMTT8fQi&h!Tkz)14(_2B}IGC|M2=BL20hgV`mk-CCS}7A$NRKVw5#E z$Jx5d+c)k~0zUVDstHhw7%Sxm*s;=MZs7Y8EBOZz^IIB%zOzq99>Odh!qclX#ao%@ zE%s-nI8OnrGn4)08&XsPWj)=CtVb-7f$X3rhZ)qTd%7B**a3EIY^>Z342wQY%?g`T zlzWzgkg}}4wjsiy13?yhE>Oz$^>LIuJ=Slh$rGjm{$;*h=iPC&3I{U{0QiU6g*2Bq zRNhFgfX>#cWZvf(4DvSe*47{PrUE5&jhMI3DSv?pi@=$|Re52E7owWvSr{~oC>Hf z9#Mi*Z%ZU1HYKIv$tX-#uC0xLK6J|4hEAiO1ONN0@2~dEI*Ha!e_6|YO3h;$^0G*mRSHf8*mAnH z6h_7q!yy+6tArbx3fWX*@<$qnK_Q3$z;5QYa0>uW>=WEM{z!38Rs(7Loz{2OeulDh zbH9KbFTtOfv%TS`)YQ~Qyf2Gz#S!4PKNtIduJ9}icON?C|GH+g!c~Jy`rp@9`1U`g z{>OFy>5l#vMaqbcS9-1c3Tj-o3Y%Ym;CjKSi^&HiK{tiBWXBrpr+$gkS>!hdba?x+&xBq0&D-q5UfH(I@&^CgeY zoC&3V)be$_vVWF6q5T9QJTXx0*&2@O;vt1~uLO>faZXW-aIh-0xdqRc|0|7uD;#`4 z8vpfUUIx46C75K|5j1syV`CVjtJf#`Kyd0BrwgV6fZ-th<>e?i%l6&($JE7g*9YO+ z$`em>aX`oE8K~a}?0(;9@Vc|4SE6CT(PnJ><;$00Jv~x+_0%!C={HkA^%fpHZ`q)_ z0Vb@z7~!9emzRCTv&K1hCg~4$?wAi=$J^#8#t?V&-1sG4=Zb+?^5`jWq0SC5Bey8j zB5}QY!dWFcd9P;TxbmKOV}Yy7kK>&EB@ z(xdd3UQHWURm*-N@F&Q32+{xrQs|BgV&}m==skxsW3tG@MOi0|oj8T30Cv3+p4@-y z!6Hm1@XA?%?2*PfsLL#ypg@&jnQb&gV|z%=4j>$+5S%IBeTkE7QJ$4$i3#w?k+VP$pI2sh- zyg5%FV~Gqf{mEu^wqHZ+8gK#of4Urf3=7 zt*CuzKb^Zx2Y9{Ii4eA=ZCyAsnc5K<(o6ij-UhPLTiG46t%4fF+)sN(=yD?Q8u1sWl zyEUnNATxLziLb(tl9&F0mT_xhmY-(_Y5X^&#$}WW({r@IxhXvQkf7^u?As&Ntj(Y3 zM)bCR$k}{gTuEPYK?S`~UXpC$4(#e%b3K!{?%&j*O*&<+tt8*L6cKjDQX$%TI()z9 zwC633yR*?iz@9=qBy=|TiXl?~S(%4XL;$9?DYN5uc2WF+9%tjiXbd0GqTU%$P!ezp zOvLKD{~8D{67bk16yLTSY(ur{Ef5~AWBO^D_qyZMW&dtg{*CLiY>AqeeG=@?Fg~nJ zXyLI{3X)#k17ugqZV!NoxtnZA;|G>d3ao6SnwPr z8BJMtFgV5EuH5eFkqFA3m==vTq#=OocFa zM=ffrlcnwefJ+eE)BI|)B0}fJ%x^n6+YGdYaxUc5AuD%fz{JA&f;R^=P`v^PA8&e& z+OpaLjUz&S|NT8Z83yi-xY}VhW+l zkD-gjGKJbZ$9iC%ZuWrQ->W6FOg1$d~K0^Dku^OGuuR}s*9XA!J zjg~oKfyCx^<$DJn~~lj;8$fin6%1{@1=3l_W=-psS0`9^H4XRmrNfi zqTmU*y7?&;o!rVy{WxE#OVFp8=P%_UywDV7D%>i(NX`*SsH* zy0$+vY2d3dmQ0OLCJXkk|JmaahaOdNM5rlEr+5+pes&boz}!h@{cbjhPhETIda+DY zkRXBG;Ng}8?7m%&kn%8JZ%|X|#^iQgWkVqvTu}2Fax49}K<$FcywrcW=c-nGDA0|O zZ6E}_Z%~bKDwx0beW=M5X#=&8x|2t1yBJKDsdTEv>{o$=8C5D~sNwF1>-yBn{7h(Ah6#H*yy{}QFU&JwbsnQmfrb2gb$q%l}Gw-b+ChoI; zJ`*@!m!_(=7P+5X-Bom4>wCuaMe}BhQxDPUu~Y zR+OmyGBjDAHQrMIsuF)E=w<0?-c{`A3c3olku6fK|D2-R@T{}t1J5l(ioVHXWt6Qb z6`gq+G*PiAGK={W&k@G_(p{oJE8X6ZNVoq|QW-&HX0B<`d85T{c)t?kHW9~>kBmai z8;9f3$J8LbUv8?ONaBua&qW|y7pM_LrudQfSn#LR!mF0FY3}ndlhSrYnnO=?bmKwm z7sQgONP{$D4)KHG3;B0*vM1~)xlJd&JCAnjl<#9Zm3W5wy%`A$tcTg85bD!9Ncy&8^E#G#$*W`h%qkd0X-8&Gp@ot)Zh`C0#h1DRyYAg%w6B`+P z4EaQE6iO-_MNhOv@9dBRvsW73IX~Vm6=XRF#p<`;(OI{!4PWb5!(Lorv3|wZRAl0P zLkRo$&^h=Gub&X>$giiiwLDs!M=$ont{6CPhfvgv*kCOHwqE$&dXL!`tBv63U4P!` zAL{lK&)Y?mjM(>kh8LmrmbR(_X?a94wJU;L8s7f7Ql5+Iz-Kx5K<{hO4uAH18UNqU z#_vuUBHkbD)#M?CYTLhk!7un;Ft>ive00YE_X(qS}YQW2MK)2yI(Y7bih&{gv5cUNjqFh(T#%L zglKwxc5OF;&`ls#POstiuTQ_PXSs03L-5l974ilf*2|C$7u+o!o{`I3vWVIAqE7`} zue%-kE@DT^VfVj9s))8T&oC9bERi~U%yx)dBu-8w2yteN|GX-m>p+RJk}$W3geEP40%u(NqR3a`lh z)dned?Al@tU@YDjnsF0!$rih)yPJ<_ebviXtt9}Dnh#8`6LqF-SxPO&g;* zzt~Y^>e0H?>LzX+`IDdNMW(r6*y?TPMCT5BxE&s)Gun@=7M@#=i5uq&g+q_IMJifl z3ICo%BHh89@5x=~fg8OD1qswb*_%BnbT>8%om;OuNYYhHXt3PtC(%}%Eo#qRbb`5S z-pySiA}zOJc1pK~ydR=xJ)xzpX3s-BPF-o3qzm)|t zoB~cr^++EJg);xR{W2=dbWb|uj9Zg0Ao zW7jJ8j9@tI@x!X}T@d8L@Jd*=u6-IgmBW(# z*Mm#-lpLNeJ2NgW3W202uY-_VV0)H1_WDCX{y&Hg!GSNy4N;G;+0KC zmwWq(O1BN|AsYI)*aRr|INROJF4b&Ks#z&cc!7A{)uLS4Ox3vuR=Kb;&FtF5HO_Z& zZ(i!2C8&GK1kty$`=U)OU^(+uR%RThODT7e_mA7ri0PmG4)uumk{7j+K6IsN7AB(& zMS(W!DiI%+^XOF(XoSl82>!r9UU0oE&@480m@A0(z9c_{lA`p0*!UrI!GOu=m%CM3 z_r=~Gx_0Bas(}{xXrkC$hqY^HPu!}F>qBE$P(W+{rryE0&`KIfhdfq=nu+Mbv$(^$ zp2lLZ+Ma&?riXabeMY{uV3{EC6XumY^5e<9r}-Qsjxrr_|48#u>dwsx@0ey+&!nF0qmeaVDjSS$uxTVfX`6t6h;-zY zUDTkkX(g-*2*rjId+6nt>z_l|VqwrfMN_@N3iT#&#E9QlA7A|@KM-I%&95JGy+_AX zeDU1u)&nNua;Ef-c1&OQ{-FquvcBk8{LUp*>;es?^I=z0qniy?@@Vxvb)D-2_U1)Y zdpBW+uh@bR0D9#SK!5Um#S!5?X@F_rExp$79cCLKqEc)619^KAjUHP1E~;jDO+?VE z&9g(oPifZmT)uVVIOG7EH;4zjGRY;X+UGikAxg=pRg*F1!@B$CsJmxZxFzlCg5&ST zAQ+F-WjVQ;x?sHT3nu~IhYCNyQIXg!X4V=EyR$dY6(=4VuX9tudKT~a zRvFyQTRwSXpWFq{gXJrPriUq=_#D7z0aA>6HBu$Vf7B~`2n#$O>wK}bTrbX6N^S~; zf*hYM4B>Z>g+zHu#Ft!n(}+cxQ#FQ+Cls!u;2V^Cf1yiqc*cXo5$X0H{H99ye(Ch!IltJ0#Nxwc3q>^N)kXDod=9%%3C- z&|=qGoJicLwH3cBWtnoJU?8xV^<}w-e?E%*p^UP5dg+p1-$wAmnLKV_xiS;em7IlS zGhuEPF+7FNp-wN%ilN(4S0)n$Cy;46T?U0a;bkvXw7t>eTXM@F1ZUz&ucIFs2S4X< zA0~-F%Z(gzXe$M(mDi8|I!SSv0?peICzapp zq(05f`@UW}Haa^X+YK=*#mR(bT}~&w2;@MBq5Evsv3C^d3#{|Zrr&o)o88|rA(0hB zfc!K?@h}^&^KAsN28%ymN%bTGHC@Z!&8OYwTo$)xdeJ3w(JP`Ojy!KH`-+qDnGR0XTzs_6^jJw=1%7;yv-y%`3X&rp^ths-pI;oef0L- zZ(}D5T7C)JX!=({d@;GAUY_fzQ5CN*^TFW)$?>kazqBru4*0)RC`Q>2We`j)6 zh<0b&qViORZV8l7zh*$&eNH#_B@F#JiuE0mG!ChN`Hm_{m6G!f%@jX7-w^Cs79jYf zD8&AE_CRrAL0VzLD^|xf4MscB{6m6qLR5`Ze*~w@@yT}-2br-=3^sJ(`}S(Dm)2&It>~xj zc+9)i!}3WJ5K|yDJGU*P;XScMnc6k>7bSnBg_%KA&Y?v=@Gd6B;|R296G_@nQ_~vBw`R(Je|CAI00Bf|Ip$Fih&fTswoJMGCXJ+YF z9C~bN)%R6;x_w}!fm!FN9h;|reJTr^4nlFkuPBkYK(G zJ@)n;DYPth57W$$r5EE3>?W z++tIb^8m9MuV6zW6V(6gZc}Atewo_5Z2xRl{q_J`FsL&an5IsZv?FphBwM%V+N6}0 zm`Re~>AiBwdC{TAm8t#Ox`=Y1E%?M}d~#tzspK%(do1_&y?3ThFp~7C%!Hfga~%2V zL5BU-${mr%j#>wNw^GlnF1E=!VtEx?mFt=3=+7N8YEn(x-rn=Xe8ln6KgPH*^?S)X zn`~)h5z>fEI=xRE)ZI2c!72F(@||or_x1n;%M*vreYM~G^6qj@OlNwJ!3DuZ`m*Rb zybmJu?nRQ0fvou|4&W=2ZoI&5$|{FUtemY>;rNkV$`5`UD6sMolbEgLY6ahP%$sT1 zB;ix0tCrS>cW?zNO7yz6tI2GyN(De6Cbu%})SpZq!A&ZkIi3KL$?9qZ3hu{+g@olf z#@_5dl%*C8zW)8(*sz})`U^KqgwmPkCh^7MPuyaUu4goFhWs}eM=R;{*>a6hu&%AIHkFJabIU=2Y;s@mo}FY;o+*rcK-sD1}E) zw~0;$yYAz+t4Iiso0DiW))o?lPidM1S9P~q$&ukU)9Ce8`0*RtD?L(57nz_=+r+NK zU($1z(dL)u8}ANRpdMOT9$;`$NDPnc4PFvG*Y6=lt433OuKH;h8$WxG`}m`QF{R!e z#!+d?z2vmC3IP3)fqFr24QSdUa@5>3!dC4;i;b95StU|V!({dEG`^GoW z(mUUldL&qdr6uInT@NP)bGGRdt6jO{x<7RJGd=<~%0X&uRm^Qx&sp1T5)K!>*BzeX zoH?ScN)Eq=`Cx=&-mM@g+pKVPu%A7N$ zL3FnXX{UKAbpsv){!Yn!rD8@ze{b`d`hn4JDmOfi)K{gLZQII6zaH^9b#3x6jw{KO zW1HNyn}k9Gn#hCuUPL_`IL8R#BfOM$!-Z2}}osgY)&%sE>mt#R?{Y#*}oXrOU84l<@T7 z-K{fB%TA(9}BRX%}Ak;fzcM;haEgCZ<#MEeS=w zGJg*~^?Xkh|0U!#h;}sq(Uh*HYF`b?#I;um4 zozD@O0(G1y+?qrRVk%|4BoK!DsGF`h46jF1rU@c{#E3TT22b2AD(n+`s-r`_`F%gc z;652K9$8Q%NA!}JeWtz8Ggs@^2 zhi9H^idS|4N!1p-E1&%7J$|-Y<#83uaF0SiZcE3nDK>nlQ-XbSGeuF)CE;UZT*2pl z!%jRzCNbhgJZLrdr{Oc=6`T{^t;Q<^&y+LA!m}3%Z`z!&#U;j8b&hs==JjWpA5=df}@et?> z;{8p^*a-deSGBz|W%M}e5yIm#0#v5tW^PIo9+ z?$c{aSbAAEL3ywju37w1&=Fspp&Uc)@0}mePudn^Gd~5N+<$cGEDWdH{!lDJ&f>R2 za7Ejs@4wi-GccN-omPSw-9Wm=qgY&P7Qb)o*qXFNTl>*6 zyCPt{z&HL@#Ue-mANN$l;$U%*cYNTgKd<@Yl#o~%k0nFb6>i^z`+L+0q@_;_R}b}_ z*`u7?7F7Z&N(rN*gT`~Fk(5ncX+4bV^YRS*oZlz&NKDv#(4mN!BXZ)!j8PxW)Vb8Lv=d!Mxv zG#V-lc|qc2F=VTB^td z9J9Jgev4J&bi8-fQ{!^ma=kptXXI=Q7~A!s3fzHyfW}p4lh!_x$*z)}S_7rH(BmkB zvTG9y-!O)hTo6fnWf99j>rlI-p4GVzp%%XW(u1i&ejwSEQx}9|X&L3| z09}&Gy2|nO_mK9TdFi+_^kG#sqdR_U@M)OtP#(BaJJ~~ zI>F5B?CG;8B7E^U7<^{>5z*tMs>s`>?heVq{YcG9)OpgL!c9z`M9;SWyEbI_`YFk2bBFPcp*VArF1(o z$?j86b+>UyIqet(hAtn8wWy=NYMEg*F?6^@+QtpPvi(!`lKOA<3Lw>AkJi*Hpf5!P zlfOYR45cG6`22G}RV&x#4(K{dzS(u6?J2Qzy+&e!`n%mEK?dc!xuUczGbErenK+CoMk|f8dzEEH>z1H!XP^3Z2@wsEWM+4mP=Gz z$t(0o%>4@44Ihl9*yWf4^GQ!%`AC$g$0t|ly{EV;k9BeSvNco&y1TPEEzOE{Z-;<& z9d6wgi-%y@gIB@b0oh^j>=-W-oGfbVI?+ zX|6$(oZo&o!(J761&cO@CwHv_Cb??>57hMa-o)eN!^2zlmg^iSGQmLaUmZL{LBQRV ztDU7T*%BK<9#Hm`pQG5NloE>MMWS0))_;w?Q1^c;wS1>z6W{&rk_B4Ddd9bV|9lm@ zx~ro0vv2 z$)4SYNIkYe^JVZ!_5PVqH&k)`(om`wH+n>dLm5p#R02!SxV-B71MlrzBu*zAiJE4J z(%FhR^muAsJGGLR{LMAQcf#(2uAuw~7U6P_-*2}!pT{^qhrGerje{9^rP390)@S;y zo;!ZK;>nO#^*SoqJTXF`Ib(rlVJkHSvYW*keR#W_jE@-Gvohxvx_oBSbalbOJ{_vV z+?1>P@}P;^SJ_zkZKlUb36Td*gt?TCeVq_}BtC`x3RH28SCP6>->rNraH+P_ypA8F zs!ZiQ>+Y32kB{s3Fh#Zm?T{Hr4Y8YbHCz7qQ!<4drMI5tqvi&(FFQHC8iV%IS9ZQP zcA-DKN1ieCW7ZzsTGSbOvu_9A$`v?=MG9ne_akF|41+0=vHh+SZRI%j3e#vDwU~xM zzecH*9@SzgPG0tse!@P~_*Vtv#NO5lT7#s%1YijQ0;$u*WrSj?ajgTBQ9Lfe?@xEW zXJP5C^&kY=+dVX4xK zA6|_=cI$ZTYP;#0!OIY98TYDL9J#IO6kS%YWH>j@_^C;0@TWK#1gc*5??x1O-*q(3 zhWc_bG>kqiB1l6cw5@hj?7^L^^`?ko=yZJ@^6QsaB6gGBj$x#DqNW)s;8xJEt9S}g zXm=dyfr5`H1Tekz4MFj6s3f7;y<*)6vvSVGL_z?Ou9~(X(VbGtU+sC&4D#d{&y1Zj zQ5c43_h*`SV~Uf4+&Oxrv zRMEyCp3tO!Bv75$?|b4zK$w*}x&S1yj^8}7JVb3Y`-(Q^BkG&h9+Qp^d0lfLY!-8fGyEQzmsDSfm!HQ=Nb2) z!uRfkMr?EdF z3dpKYeKW+jo)F25i1lyysC*-`?3*jp_T`v0Hy^2Uvkr02UipI6Sn2lL>tV@Q_O>`pNiCKhYe2ceP0LXIrpod;@`Ngu7kHc zU28K{nIJdwWRVu`YoKZt5mx)C{3g8ykWQOM? zenGOp>89ZP9ONt`J32Cv4*ff{dnH5-l(bt5w4>8{$)%}pSL-wFzqkh+xnmZDRVe&u z;4;|2zllR4m_H%T$Z-)Ec7?8re#<&Ve;!lLyd{qP8_iO%jB9QsqUU!uorPTMPrnyc zSczYLYkPjaD0fic)bX-(A;0EJk#Bv>MN;=cwb4hsjx%*Q?V~hgagCji+VXJ z6<#(z+0R6Ix{e1TU+Y({bN?S_Ul|rxv!&a(dvFgB9D)SbV8I=NC&As_LjobVySuw< zfB?bW8n?z9cj()E--9IDh6aQSn;HkRVDZDmKKUx+QY}Kho@oSec;j%vkE{g2e~2!j;$?)vJ(XM+ zmV}KTSH#D&@sYbJK&@HCR0?p)QfqG>H*xR^oLXdwXlOq_LW%zI%j1pEs{A$8puaNI z*X@0}{MH(V$Aq?m^E3T68n_qPI*&*>`M$_~l+igne@J0XlwUgeC4`FmD=^JlkchV0 zlJ%y<3^7d`)x)7YG1(^RWDRC;7$v&>F^ZG9emKaLB?_%C5Lv$L-~h!83tr4~%n5Hh zkGT1bRhh6S4wVF7$2ni1O7+S zkPg9cqEuo7&66LzS?M-(`{jJ1MUoo!wb=)l>jNi$%YAB>sc-HUE`_*q1Pe0odV)(l z-Em`fs^$ z&?7l$mIYcR6FVfBFWONmj^OvA9guq_=ddiNmvSmD$=&QKwKaJ@OP7sL+jwP^k1EQI zW2mil*k4BK*np=bgUiXoIPuPrS-Vdc0kVazzaZ2mFWV#%23i;t30_dSG?;^*VbxQ*Ymtb zh?cp?x&0vMy3Ug1fIL?-q?;0Ppe;)kHUtHJcmyc+AOh^$HA~zL@!DHms?0L7jh2BO z9XDr{x29S^X)d+_29 zp*rpNy{>CO^7%@0`T8$_9D);g^`Z-tTF9NIkm>c301j_#uCE-t8tD3Ztx}E)l`QI6 z%cX3^%|~&-0JmAmZoEKu{#9$l^+Q9Wv?}xo3z)oz2TP%r+!~}le>*7G<#R%?9wt6s zPkW(cOB}c+3*TekU;Ty@EMD+#-)N6L=;}BSxi>l#ArBIv)b8t zGbqFB(4pxiS!3@XD1n9w@yS25!1|2_u0!yvN1Pp*G)sNCblR;K)Kb)44 zkRkOmNDnMSxN-0~3^sU8s^eZBHdZ`ewX~#rqu!fcGBlv9d~5N^6f5vcTPmkWnhIES zo!s}QvSuA5*|vkddpAL>^t5DT$#(RK!WTmQ)>e>@fN;J;nQY+tzy)!N>`!e;kdraliLGExQrZ#{B^%1$QzZIVj*@q4#B`66 zfe#yb#~gohjbN36q3Vg=$I!idT@xL8($xYw}w z4Lwuu08hJ;ax*BQL?`s=L;VG@Zu`I3G>eI%9pq@ZB7Am;VEkZjxYAxA`D6d@vl-?t*_=^@rh z0#G7?(_M!47^846vb87|jX{jGQYV%1N8~jZ)v&@IIP)8+10x&{qBc?YEOSr6DW!1HiuTUd-?#m?-AX#EPF&Mt z(k#H-=%RzRMBB?SU4i$=Whx^VB?l`h32n;m@(r_C6(UY>$W*Y!yGE)uTII}{)fZ!= zS#aRiCtZ)*GPL^&z>!XH1|=-n&B${X@YbNseJfhq#lgC;rE~}E&AmSh8EEydu3CbH z$A-CVH5~MIQJ%h`*as2zC<)v9U>*?_6%7wq#sMA^0pnWS!Wr{uk=_H|yDPID1(2B4 z5@W-qN66&#P5t&$)^g_lo)`c}3;_LswhDUU1Im1sd|{VDxtz#UFaJU-g0e}W z-0s%_FTbw;>(8M_E?)SgQ1<2z0Q9&9^kfK*m_6k`{&@NPe=dCq3wqoCzI5o#-N65S z+o8vF{%REbM5?>NpJxiGw$@h>()eM&-+A12Y2fzAAsG4PuzFceiYFSPWHF-yKhjF>Ekhr}DU<4p zekh9-Dz@!cdmmZUL|X1oPe&SqXZJgAm`^^HJ($UWBY`Ev2N+!;t`z zvh*?ubx7Iud&kwUCQJ*Tx8A)RHll*HVCf_l>slKFFs*8}mswl4HGPvIVaSR)snSp( zFVp6(b`5;o>PZy?xDx%{!wBK2P79MmW&{6eL3_vWDhM%bG29DZ_y+(rF}}@3mX|_E zQ-@l#?5HG9?oFKB8(g`9acedsjR>{u-=y?R?g3s{%6nrGq(eJSuFswMNW6X;0x2qA zjN4=jIPqG+RVAH9+(~Wu%Mrha{JUa=D>Vf26qrilv>|HlYfngV0trgCC*GU|&Pj!u z(Vneu3(iJ9(=}aF`<~afi($lX3n#0&K|O+!c(p&<{UY1DnG7tag)Y@q-WRZZoY_Ya zq^&W--^T{nTjPfJ)lP!7%wMM%uFXl9tKeVMwYo4CJs{W)jvG{@ z2T9%$4X|4?M(edT60__(?eNVdX-E?uxAP09v=zH}rdQNNMjF*Q?<}t|X2>N#{0_O& zQYN;gD)SsxX~-4-jQp zP2IfXIM3O-D(um1xWr$Xa-2l7cB)EikQ@1Kkwr8CuVX?}9-f@M9W z9kD-yL|WyJMXp-g1#EfFapG>ZJ><#S?$ny8QMy^TeA2us-}?x8h^+Z?e?3-u(2{aD z(lVw3sSFl(VtoQ*h$L~@3mDQvdjeHN6C+?vf%#sxQbZKi=91OoBgAt&6A>;Z(GWCwgJ_=x1YsvD!WeM6$_QK_|(gelC*z!$yd=Nme3>!bj{wQZ3Gm$&wM6-~JtIGT^1V540u^WYZrw zi3KR#^xfD-8@7=&2LxhCpk;&yZ+2luN4Yd)GhOUQ^@1!f$QC$wM7b7uY@FZSWX*zQ zUKeOE6+D0J+jJbKlyI+qE~U9{ZQbl8Jxm=#^Ecye&!TYw^vmUgvMEC5 zg-zXAo*lN#TWkk-w7Hk?9hYlRUHk@5PSZU;W!2FyLL63cSVV;KEOwk7`o|7+@9NC6 zfMJFfl=PhrNPptM%)p~nuEK~uIxF9R;^pJx8qa@`dw_T$ngkLuCC z>0-tOpjhM<^1gbmZxCz+Ze2(|ab;CICTnZx9G!S@v^Z}$^Z~jYT`!iyn`Z0I<5JF$ zJsdPl4t>mm+!Ee^=DyOp_%wD@7qv{D8jNf8AKLs4p+{e;7t#FO0gF-Zc_PD%j>?n? zFf@TrLS&|j$88;U52x*+RE`V?=e|anpg$MFitRqD7D<8uUkl+FCOFUlf=DOHb*?Lu zv-ju$tTxkgb%Iv(pv*z`tg4~S;hREk{k!}1a2qDqw%#p#p0PSsKk?}zkunstFzFkR z+toQ?&eeO6AZK>T`2)$)IQ<2EPgWYVZ7zSet?;Y10X8o>m3#WDZ5OTv;AW{;TVz#X z%gxGJ>y}cmH^g?grD~SaT^aVYAI1b6;rn}Hr({L7F4=j&@(^ZblgGB>4FRw*BW^db zswflq@x;Q#U&LmOl%2q;BJ#!#nz95scOqveKWscqICBMhx7ibkWzG4nyo(eD8Ow7M zX!}E=rKN*k*{TCNy4G;Sa?DjaeNUG;cCXi9tlkCW)+B$KOwN5L@8Y{ZRoOwPQHqjX z{H11M4d}Hqu4elBn?rsBkr>7RbJw`{_|r_E$ICQ~>DQVf{O zNo$0O9h|iGoSSZh9SJ;Y7BdmMVeR!+B=$keJ0vOF6-eS&*9SHk?~Ej z)+@8=2Ek5P?Uabhm91l+m$NG`kV1GA`3HX__S#j-Cb%jRIa_St3(i2-HSpC?B*3X0 zL^S?-f`hzCC$l*bvE;eve5uT2p}szT@WIGEXl=(+<0@Qx z{P{xnG)%8eC%WlvS?CDpfLy35`|51<&EM1ez^V<24G0YQO&Tm$a$fb2Ku3ZxT8}@A z@}LdbZ9H7PnHOyQ^5l5LYm_ekB*tKjo8S#j+%>93KijfGkZhov7?(+44RF4<^4VgWwzi1_*+Uzb_7k%mR#L1G81oHw0a{} zA?uEJV`@)7Li-ya+arw;P^BV!@uKL_8vR^2vu?JL?lBntG*NDAlzhK>grV+o5?YWPUcuC5*)}6Da;rTggB>pno(6BdC{7s#Z zor)D0>K}Ih7mhDfTK|o|n;rMRyPfW zaOfQJksa|5;;0DEkj-Mdc@#(Jujx8(qyc%-Bc6#h+-{V0lurc80Bsi6hVRTDC}>&& zHDF3MH<*n5Jx0#Chf{7M{ak;i=sQ2=7B4AvsnfB8_u0%_%-cYQ{AhLYf6N%el>HWQ zz`Ur0j1)kLx|k{jQf4ES!xkJbNoBaC)0MsGI^smQs8n|&~fTYHX z8rOJ%gQowDd`g?A7u&|%M%K`$5LK5lGd}#Mz%y;j{3}g2ZnyNi*&C3}E}K(U9Ohm1 zP~g7Ic;GaF_$$EZWEgCsYtTxlytACjY3>GHmgZXLo}d_VK+i4A?04i`00U|O%b3l5 zA9OUrFk?~2dAKLLJ+RE%Te|LfEm*o#GiAWuF2x|WQFOmSFfrx2&}879ak^~$ zEDNUJIt22%LT*Pc$$kFTS2%lfvnak?P^W)huizwceyG_EV=7cqVQRWVnGo}Y!N2H& z%~Pwuy*6E3nbw2QZk740=;Rc^O0XRbjOS90c$Y9

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 ccb0ab73f37..a8fc383e767 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 62aec25183c..62f50c24900 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: - - $ curl https://wordpress.org/latest.tar.gz | tar -xvzf - - -This will create a directory called `wordpress`. If you wish, you can rename it -to the name of your project. - -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: - - FROM orchardup/php5 - ADD . /code - -This tells Docker how to build an image defining a container that contains PHP -and WordPress. - -Next you'll create a `docker-compose.yml` file that will start your web service -and a separate MySQL instance: - - 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 -configuration at the `db` container: - - + +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 e08409f18d0d72f4e3201cfe664bbfa55dd7bc31 Mon Sep 17 00:00:00 2001 From: Mary Anthony Date: Wed, 10 Feb 2016 20:47:15 -0800 Subject: [PATCH 2030/4072] 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 83b656333de..5f32dc4dc16 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 a75c16cb1be80f2c8e94120b7a79e3ddbd901419 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 23 Feb 2016 11:31:22 -0800 Subject: [PATCH 2031/4072] Bump 1.6.1 Signed-off-by: Daniel Nephin --- CHANGELOG.md | 55 ++++++++++++++++++++++++++++++++++++++++++++ compose/__init__.py | 2 +- docs/compose-file.md | 2 +- docs/install.md | 6 ++--- script/run.sh | 2 +- 5 files changed, 61 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8df63c5fd3b..7d553cc2ab0 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/compose/__init__.py b/compose/__init__.py index 268bb719e05..942062f51b1 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.0' +__version__ = '1.6.1' diff --git a/docs/compose-file.md b/docs/compose-file.md index 485429b51bc..97b8ba51f36 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/docs/install.md b/docs/install.md index b8979b95930..a51befca806 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.6.0/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 749481f6c2e..3e30dd15b72 100755 --- a/script/run.sh +++ b/script/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.6.0" +VERSION="1.6.1" IMAGE="docker/compose:$VERSION" From 97bbee19b7f16055c42d819bf005853159740b19 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 23 Feb 2016 14:55:06 -0800 Subject: [PATCH 2032/4072] 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 5f55ba8ad25..e25386d243f 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 2033/4072] 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 8df63c5fd3b..7d553cc2ab0 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 c50d7649111..b7607a675f1 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 07132a0cb4a..3e30dd15b72 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 796dc91eb149f2a592f9f69ca1fa3fcc057f4af2 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 23 Feb 2016 14:55:06 -0800 Subject: [PATCH 2034/4072] 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 5f55ba8ad25..e25386d243f 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 4d720279a0a1152af70dee720a67c395392aaff3 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 23 Feb 2016 15:50:59 -0800 Subject: [PATCH 2035/4072] Bump 1.6.2 Signed-off-by: Aanand Prasad --- CHANGELOG.md | 6 ++++++ compose/__init__.py | 2 +- docs/install.md | 6 +++--- script/run.sh | 2 +- 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d553cc2ab0..8b93087f078 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/compose/__init__.py b/compose/__init__.py index 942062f51b1..83b4c7e6a38 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.1' +__version__ = '1.6.2' diff --git a/docs/install.md b/docs/install.md index a51befca806..a7a4539b246 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.sh b/script/run.sh index 3e30dd15b72..212f9b977aa 100755 --- a/script/run.sh +++ b/script/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.6.1" +VERSION="1.6.2" 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 2036/4072] 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 4e91a3af239..3994a3323d3 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -31,12 +31,12 @@ 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 edccedc664b..e8ceb4c2b07 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 7703adcd0d7..00000000000 --- 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 60ee5c930cd..d7ca270ca3c 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -385,21 +385,17 @@ def format_error_message(error): 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 b3d8db39985..4282400eda9 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 2037/4072] 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 4d974d71084..affc19f0fce 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 8f6a8c0ad26..00000000000 --- 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 d7ca270ca3c..07ec04ef7db 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -388,7 +388,7 @@ def format_error_message(error): 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 4282400eda9..3a165dd6724 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 2038/4072] 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 01f17a126a8..dd3e8828209 100644 --- a/compose/service.py +++ b/compose/service.py @@ -566,8 +566,7 @@ def _get_container_create_options( 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 @@ def _get_container_create_options( 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_env(key, value): 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 2039/4072] 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 9d29f329e4f..20ac4461b37 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 a18bfda7ca8..7984775d57d 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 2040/4072] 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 dd3e8828209..2fbea8d1f45 100644 --- a/compose/service.py +++ b/compose/service.py @@ -162,11 +162,11 @@ def scale(self, desired_num, timeout=DEFAULT_TIMEOUT): - 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 @@ def get_link_names(self): 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 @@ def _get_container_create_options( 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 @@ def labels(self, one_off=False): '{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 968c0947c71..35696ea3d42 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -782,7 +782,7 @@ def test_scale_with_custom_container_name_outputs_warning(self, mock_log): 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 @@ def test_stop_signal(self): 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 2041/4072] 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 3994a3323d3..850af31c90e 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -31,8 +31,8 @@ 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 affc19f0fce..cde8c8e56ec 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 e8ceb4c2b07..54bfc978ddc 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 07ec04ef7db..4eafe7b5c9c 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -14,6 +14,7 @@ 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)) + + 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)) - def format_error_message(error): - path = path_prefix + list(error.path) + 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)) - if 'id' in error.schema: - error_msg = handle_error_for_schema_with_id(error, path) - if error_msg: - return error_msg - return handle_generic_service_error(error, path) +def process_config_schema_errors(error): + path = list(error.path) - return '\n'.join(format_error_message(error) for error in errors) + 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__)) - if sys.platform == "win32": - file_pre_fix = "///" - config_source_dir = config_source_dir.replace('\\', '/') - else: - file_pre_fix = "//" +def get_schema_path(): + return os.path.dirname(os.path.abspath(__file__)) - 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) +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) - 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 get_resolver_path(): + schema_path = get_schema_path() + if sys.platform == "win32": + scheme = "///" + # TODO: why is this necessary? + schema_path = schema_path.replace('\\', '/') + else: + scheme = "//" + return "file:{}{}/".format(scheme, schema_path) + + +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 11bc7f0b732..f8f224a08f8 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -342,20 +342,17 @@ def test_load_config_invalid_service_names(self): 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 @@ def test_load_dockerfile_without_context(self): }) 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 @@ def test_extended_service_with_invalid_config(self): 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 2042/4072] 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 489be02137b..e4f39544a0a 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 040a2602be4..14d93088108 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 47fb45e1749..1a4cd4d9bf5 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 4a2bc1f7794..db643274c3a 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 f30265c02a6..f73be84285d 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 083f8eb589c..3a57b89a893 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 7cc1092ddc8..065244bf5fa 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 2043/4072] 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 14d93088108..4b78a9ba0b3 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 80b3d0131e2..00000000000 --- 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 46ba6bbca99..86b4c9f64b4 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 903be76fc3d..00000000000 --- 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 2044/4072] 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 66224752db7..50e58ddca61 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 4b78a9ba0b3..de94aae84db 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 897335054f8..bdd98f03e76 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 9bf7c95d9c7..bf515060a02 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 168fd43092d..3de3457621c 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 f73be84285d..7b3489a1b20 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 3a57b89a893..d076197cbd4 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 7d9ec0a2c31..33d0d7772db 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 14a3e6e4d60..08bf1618829 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 00000000000..c5927b2c9a3 --- /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 bdb3579b01f..fa741a19d88 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 065244bf5fa..7707a1eee82 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 2045/4072] 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 bceb02578a7..9ecccd8a23e 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 2046/4072] 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 514e6a03915..ad48b29f0bf 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 2047/4072] 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 514e6a03915..1323131ed33 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 2048/4072] 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 7d553cc2ab0..8b93087f078 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 b7607a675f1..eee0c203e05 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 3e30dd15b72..212f9b977aa 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 2049/4072] 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 73596c18be4..a42243fcb96 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 2050/4072] 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 e3f4aa9e5b7..d2900b39202 100644 --- a/compose/cli/docopt_command.py +++ b/compose/cli/docopt_command.py @@ -43,6 +43,10 @@ def parse(self, argv, global_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) diff --git a/compose/cli/main.py b/compose/cli/main.py index 6a04f9f0089..2c0fda1cf6c 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -43,7 +43,7 @@ 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 @@ def json_format_event(event): 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 c96b63ef441..6dac949993a 100644 --- a/compose/container.py +++ b/compose/container.py @@ -216,6 +216,12 @@ def restart(self, **options): 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 00000000000..6c0eeb04dc2 --- /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 02f82872732..2b61898e7d9 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -752,6 +752,24 @@ def test_up_handles_abort_on_container_exit(self): 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 2051/4072] 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 e1b0c916601..a24b9733bb9 100644 --- a/compose/service.py +++ b/compose/service.py @@ -223,7 +223,7 @@ def stop_and_remove(container): 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 4bb625a1bc7..6d0c97db3fa 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -735,7 +735,7 @@ def test_scale_with_api_error(self): 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 2052/4072] 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 a42243fcb96..201549f1834 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 2053/4072] 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 3bb365a1401..fbf2696466d 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 de94aae84db..930af15a8be 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 d076197cbd4..9d4a606e252 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 86b4c9f64b4..7ccf3f055b5 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 7d0adbebcd5..f9728558a61 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 2054/4072] 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 850af31c90e..f34809a9ec7 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -88,6 +88,8 @@ '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 c2ca8e6ee0d..420db60b6bf 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1248,6 +1248,24 @@ def test_merge_build_args(self): } } + 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 2055/4072] 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 cde8c8e56ec..36a93793893 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 54bfc978ddc..28209ced8dc 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 4eafe7b5c9c..088bec3fc46 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 c2ca8e6ee0d..8a523fea8a7 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1095,22 +1095,18 @@ def test_valid_config_oneof_string_or_list(self): ).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} + } + }) + + with pytest.raises(ConfigurationError) as exc: + config.load(config_details) - assert mock_logging.warn.called - assert expected_warning_msg in mock_logging.warn.call_args[0][0] + 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 2056/4072] =?UTF-8?q?Add=20-f,=20--follow=20flag=20as=20op?= =?UTF-8?q?tion=20on=20logs.=20Closes=20#2187=20Signed-off-by:=20St=C3=A9p?= =?UTF-8?q?hane=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 85fef794f04..6fd5ca5ddf0 100644 --- a/compose/cli/log_printer.py +++ b/compose/cli/log_printer.py @@ -13,11 +13,13 @@ 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 @@ def no_color(text): 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 6a04f9f0089..1e6d0a55b62 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -328,13 +328,15 @@ def logs(self, project, options): 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 @@ def up(self, project, options): 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 remove_container(force=False): 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 5b241ea70b7..4f0d5730453 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 e25386d243f..b31840c8596 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 02f82872732..60a5693bd20 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -398,6 +398,8 @@ def test_up_attached(self): 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 @@ def test_unpause_no_containers(self): 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 00000000000..0af9d805cca --- /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 5b04226cf06..bed0eae83f7 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 reader(*args, **kwargs): 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 @@ def test_single_container(self, output_stream, mock_container): # 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 @@ def test_generator_with_no_logs(self, mock_container, output_stream): 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 fd6c50028fe..bddb9f178f7 100644 --- a/tests/unit/cli/main_test.py +++ b/tests/unit/cli/main_test.py @@ -33,7 +33,7 @@ def test_build_log_printer(self): 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 @@ def test_build_log_printer_all_services(self): 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 2057/4072] 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 6fd5ca5ddf0..adef19ed0cb 100644 --- a/compose/cli/log_printer.py +++ b/compose/cli/log_printer.py @@ -13,13 +13,19 @@ 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 @@ def no_color(text): 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 1e6d0a55b62..fb309ac584e 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -328,15 +328,17 @@ def logs(self, project, options): 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 4f0d5730453..8135f4c9768 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 60a5693bd20..78e17e44c90 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1163,6 +1163,14 @@ def test_logs_unfollow(self): 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 2058/4072] =?UTF-8?q?Add=20--tail=20flag=20as=20option=20o?= =?UTF-8?q?n=20logs.=20Closes=20#265=20Signed-off-by:=20St=C3=A9phane=20Se?= =?UTF-8?q?guin=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 adef19ed0cb..6a8553c530b 100644 --- a/compose/cli/log_printer.py +++ b/compose/cli/log_printer.py @@ -19,13 +19,16 @@ def __init__(self, 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 @@ def no_color(text): 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 fb309ac584e..8cbdce01923 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -329,16 +329,24 @@ def logs(self, project, options): 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 8135f4c9768..745d24f7fec 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 78e17e44c90..8e74307502c 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1171,6 +1171,14 @@ def test_logs_timestamps(self): 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 00000000000..80d8feaecdd --- /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 bed0eae83f7..d55936395a5 100644 --- a/tests/unit/cli/log_printer_test.py +++ b/tests/unit/cli/log_printer_test.py @@ -47,7 +47,7 @@ def test_single_container(self, output_stream, mock_container): # 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 2059/4072] 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 6a8553c530b..326676ba1fd 100644 --- a/compose/cli/log_printer.py +++ b/compose/cli/log_printer.py @@ -18,17 +18,13 @@ def __init__(self, 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 @@ def no_color(text): 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 8cbdce01923..a3aabd7abf0 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -337,16 +337,19 @@ def logs(self, project, options): 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 @@ def up(self, project, options): 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 remove_container(force=False): 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 d55936395a5..54fef0b2358 100644 --- a/tests/unit/cli/log_printer_test.py +++ b/tests/unit/cli/log_printer_test.py @@ -39,7 +39,7 @@ def reader(*args, **kwargs): 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 @@ def test_single_container(self, output_stream, mock_container): 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 bddb9f178f7..e7c52003e2c 100644 --- a/tests/unit/cli/main_test.py +++ b/tests/unit/cli/main_test.py @@ -33,7 +33,7 @@ def test_build_log_printer(self): 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 @@ def test_build_log_printer_all_services(self): 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 2060/4072] 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 2c0fda1cf6c..0a917720fbb 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 e6e63f24b50..ae8aa59165c 100644 --- a/compose/cli/multiplexer.py +++ b/compose/cli/multiplexer.py @@ -10,6 +10,7 @@ except ImportError: from queue import Queue, Empty # Python 3.x +from compose.cli.signals import ShutdownException STOP = object() @@ -47,7 +48,7 @@ def loop(self): 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 b8415e5e555..4810a106440 100644 --- a/compose/parallel.py +++ b/compose/parallel.py @@ -6,9 +6,11 @@ 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 2061/4072] Clarify depends_on logic Signed-off-by: Aanand Prasad --- docs/compose-file.md | 5 +++ docs/faq.md | 70 ++-------------------------------- docs/startup-order.md | 88 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 96 insertions(+), 67 deletions(-) create mode 100644 docs/startup-order.md diff --git a/docs/compose-file.md b/docs/compose-file.md index 3e7acf704f4..24e4516037a 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 201549f1834..45885255f84 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. - -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`. +## 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? diff --git a/docs/startup-order.md b/docs/startup-order.md new file mode 100644 index 00000000000..c67e18295a1 --- /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 2062/4072] 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 3b135311a1d..d926d648e8d 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 2063/4072] 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 be33ce52350..afb777becd6 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -25,6 +25,7 @@ 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 @@ def create(self, project, options): 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 @@ def up(self, project, options): 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 @@ def up(self, project, options): 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 cfb11aa0558..c964417fff4 100644 --- a/compose/project.py +++ b/compose/project.py @@ -21,6 +21,7 @@ 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 @@ def build(self, service_names=None, no_cache=False, pull=False, force_rm=False): 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 @@ def up(self, 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 a24b9733bb9..e2ddeb5a26e 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 @@ def stop_and_remove(container): 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 @@ def create_container(self, 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 @@ def _containers_have_diverged(self, containers): def execute_convergence_plan(self, plan, - do_build=True, + do_build=BuildAction.none, timeout=DEFAULT_TIMEOUT, detached=False, start=True): @@ -392,7 +409,7 @@ def execute_convergence_plan(self, 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 4f1e065e7ce..8631aa9291d 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -2,6 +2,7 @@ from __future__ import unicode_literals import docker +import pytest from docker.errors import APIError from .. import mock @@ -15,6 +16,7 @@ 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 @@ def test_create_container_with_build(self): '{"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 @@ def test_create_container_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=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 2064/4072] 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 e2ddeb5a26e..7ee441f2c85 100644 --- a/compose/service.py +++ b/compose/service.py @@ -294,9 +294,9 @@ def ensure_image_exists(self, do_build=BuildAction.none): 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 a785e2c704b..5065e8bebe5 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 a02358ec786..07ee82f9345 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 8631aa9291d..199aeeb4382 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -433,7 +433,7 @@ def test_create_container_with_build(self): 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 2065/4072] 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 db2b6506bb1..e37677c6934 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 00000000000..dd0b668ed4c --- /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 8915733c378..8400ba1fab5 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -7,6 +7,7 @@ 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 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 @@ def test_containers_with_extra_service(self): ) 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 @@ def test_volumes_from_container(self): ) 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 @@ def test_network_mode_from_service(self): 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 @@ def test_network_mode_from_container(self): 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 @@ def get_project(): 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 @@ def test_net_from_container_v1(self): 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 @@ def test_project_up_starts_links(self): 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 @@ def test_project_up_starts_depends(self): 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 c2ca8e6ee0d..04f299c62c3 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -11,6 +11,7 @@ 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 2066/4072] 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 d2900b39202..5b50189c8bf 100644 --- a/compose/cli/docopt_command.py +++ b/compose/cli/docopt_command.py @@ -20,12 +20,12 @@ def docopt_options(self): 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 26ae4e30065..3fc0a9855aa 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -66,17 +66,17 @@ def test_get_project(self): 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 2067/4072] 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 3fc0a9855aa..cf3c8e8f200 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -110,39 +110,6 @@ def test_run_interactive_passes_logs_false(self, mock_pseudo_terminal, mock_run_ _, _, 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 2068/4072] 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 cf3c8e8f200..cbe9ea6f90f 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -10,13 +10,14 @@ 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 @@ def test_command_help_nonexistent(self): 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 @@ def test_run_interactive_passes_logs_false(self, mock_pseudo_terminal, mock_run_ 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 @@ 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', - client=mock_client, - restart='always', - image='someimage') - command.run(mock_project, { + command.run(project, { 'SERVICE': 'service', 'COMMAND': None, '-e': [], @@ -168,17 +169,16 @@ def test_run_service_with_restart_always(self): 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 2069/4072] 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 2a0d8698441..98de21044d3 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 6360fe54ae0..e1170be904c 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 1804467211a..1ca671fedbb 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 @@ def test_dispatch_generic_connection_error(self): ): 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 2070/4072] 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 28209ced8dc..a4a30a5f4df 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 08d52553c40..d0e8242033d 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -404,7 +404,7 @@ def test_load_with_empty_build_args(self): 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 @@ def test_load_with_buildargs(self): 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 2071/4072] 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 26fbda96fbd..254c2c2861c 100644 --- a/compose/volume.py +++ b/compose/volume.py @@ -3,7 +3,6 @@ import logging -from docker.errors import APIError from docker.errors import NotFound from .config import ConfigurationError @@ -82,12 +81,13 @@ def remove(self): 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 @@ def initialize(self): ) ) 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 2072/4072] 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 254c2c2861c..17e90087672 100644 --- a/compose/volume.py +++ b/compose/volume.py @@ -107,7 +107,7 @@ def initialize(self): 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 8400ba1fab5..daeb9c81d94 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -839,6 +839,44 @@ def test_initialize_volumes_updated_driver(self): 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 2073/4072] 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 2efb715a883..03ade35664e 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 2074/4072] 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 24e4516037a..d8e98fbc8b3 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 2075/4072] 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 24e4516037a..d6cb92cf988 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 2076/4072] 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 5b50189c8bf..809a4b7455e 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 @@ def parse(self, argv): 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" + + if not hasattr(command_class, command): + raise NoSuchCommand(command, command_class) - return getattr(self, command) + return getattr(command_class, command) class NoSuchCommand(Exception): diff --git a/compose/cli/main.py b/compose/cli/main.py index afb777becd6..0584bf1a13c 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -3,6 +3,7 @@ from __future__ import unicode_literals import contextlib +import functools import json import logging import re @@ -33,7 +34,8 @@ 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 @@ 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 @@ def exec_command(self, project, options): 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 @@ def up(self, project, options): 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 cbe9ea6f90f..c609d83244b 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -64,26 +64,20 @@ def test_get_project(self): 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 @@ def test_run_interactive_passes_logs_false(self, mock_pseudo_terminal, mock_run_ 'service': {'image': 'busybox'} }), ) + command = TopLevelCommand(project) with pytest.raises(SystemExit): command.run(project, { @@ -126,7 +121,7 @@ def test_run_service_with_restart_always(self): }), ) - command = TopLevelCommand() + command = TopLevelCommand(project) command.run(project, { 'SERVICE': 'service', 'COMMAND': None, @@ -147,7 +142,7 @@ def test_run_service_with_restart_always(self): 'always' ) - command = TopLevelCommand() + command = TopLevelCommand(project) command.run(project, { 'SERVICE': 'service', 'COMMAND': None, @@ -168,7 +163,6 @@ def test_run_service_with_restart_always(self): ) def test_command_manula_and_service_ports_together(self): - command = TopLevelCommand() project = Project.from_config( name='composetest', client=None, @@ -176,6 +170,7 @@ def test_command_manula_and_service_ports_together(self): '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 2077/4072] 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 0584bf1a13c..b020a14b76b 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 @@ def build(self, project, options): --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 @@ def config(self, config_options, options): """ 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 @@ def config(self, config_options, options): print(serialize_config(compose_config)) - def create(self, project, options): + def create(self, options): """ Creates containers for a service. @@ -270,13 +269,13 @@ def create(self, project, options): """ 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 @@ def down(self, project, options): -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 @@ def json_format_event(event): 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 @@ def exec_command(self, project, options): 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 @@ def exec_command(self, project, options): 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 @@ def help(cls, options): 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 @@ def kill(self, project, options): """ 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 @@ def logs(self, project, options): --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 @@ def logs(self, project, options): 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 @@ def port(self, project, options): 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 @@ def port(self, project, options): 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 @@ def ps(self, project, options): -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 @@ def ps(self, project, options): ]) print(Formatter().table(headers, rows)) - def pull(self, project, options): + def pull(self, options): """ Pulls images for services. @@ -497,12 +496,12 @@ def pull(self, project, options): 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 @@ def rm(self, project, options): -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 @@ def run(self, project, options): -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 @@ def run(self, project, options): 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 @@ def scale(self, project, options): 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 @@ def stop(self, project, options): (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 @@ def restart(self, project, options): (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 @@ def up(self, project, options): 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 @@ def up(self, project, options): 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 c609d83244b..1d7c13e7e93 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -89,7 +89,7 @@ def test_run_interactive_passes_logs_false(self, mock_pseudo_terminal, mock_run_ command = TopLevelCommand(project) with pytest.raises(SystemExit): - command.run(project, { + command.run({ 'SERVICE': 'service', 'COMMAND': None, '-e': [], @@ -122,7 +122,7 @@ def test_run_service_with_restart_always(self): ) command = TopLevelCommand(project) - command.run(project, { + command.run({ 'SERVICE': 'service', 'COMMAND': None, '-e': [], @@ -143,7 +143,7 @@ def test_run_service_with_restart_always(self): ) command = TopLevelCommand(project) - command.run(project, { + command.run({ 'SERVICE': 'service', 'COMMAND': None, '-e': [], @@ -173,7 +173,7 @@ def test_command_manula_and_service_ports_together(self): 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 2078/4072] 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 e7c52003e2c..9b24776f8f5 100644 --- a/tests/unit/cli/main_test.py +++ b/tests/unit/cli/main_test.py @@ -3,6 +3,8 @@ 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 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 @@ def test_build_log_printer(self): ] 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 @@ def test_build_log_printer_all_services(self): ] 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): - def setUp(self): - self.stream = mock.Mock() - self.stream.isatty.return_value = True - self.handler = logging.StreamHandler(stream=self.stream) +class TestSetupConsoleHandlerTestCase(object): - 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_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_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_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_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 2079/4072] Improve handling of connection errors and error messages. Signed-off-by: Daniel Nephin --- compose/cli/command.py | 35 ++-------- compose/cli/errors.py | 121 +++++++++++++++++++++++++-------- compose/cli/main.py | 39 ++--------- tests/unit/cli/command_test.py | 16 ----- tests/unit/cli/errors_test.py | 51 ++++++++++++++ 5 files changed, 151 insertions(+), 111 deletions(-) create mode 100644 tests/unit/cli/errors_test.py diff --git a/compose/cli/command.py b/compose/cli/command.py index 98de21044d3..55f6df01a3f 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 03d6a50c6a1..a16cad2f59c 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 @@ def __unicode__(self): __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: +class ConnectionError(Exception): + pass + + +@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() + + +def log_api_error(e, client_version): + if 'client is newer than server' not in e.explanation: + log.error(e.explanation) + return + + 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)) + + +def exit_with_error(msg): + log.error(dedent(msg).strip()) + raise ConnectionError() + + +docker_not_found_mac = """ + Couldn't connect to Docker daemon. You might need to install docker-osx: - https://github.com/noplay/docker-osx - """) + https://github.com/noplay/docker-osx +""" -class DockerNotFoundUbuntu(UserError): - def __init__(self): - super(DockerNotFoundUbuntu, self).__init__(""" - Couldn't connect to Docker daemon. You might need to install Docker: +docker_not_found_ubuntu = """ + Couldn't connect to Docker daemon. You might need to install Docker: - https://docs.docker.com/engine/installation/ubuntulinux/ - """) + https://docs.docker.com/engine/installation/ubuntulinux/ +""" -class DockerNotFoundGeneric(UserError): - def __init__(self): - super(DockerNotFoundGeneric, self).__init__(""" - Couldn't connect to Docker daemon. You might need to install Docker: +docker_not_found_generic = """ + Couldn't connect to Docker daemon. You might need to install Docker: - https://docs.docker.com/engine/installation/ - """) + https://docs.docker.com/engine/installation/ +""" -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`. - """) +conn_error_docker_machine = """ + Couldn't connect to Docker daemon - you might need to run `docker-machine start default`. +""" -class ConnectionErrorGeneric(UserError): - def __init__(self, url): - super(ConnectionErrorGeneric, self).__init__(""" - Couldn't connect to Docker daemon at %s - is it running? +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. - """ % url) + 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 b020a14b76b..6636216828c 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -11,18 +11,14 @@ 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 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 @@ 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 1ca671fedbb..b524a5f3ba3 100644 --- a/tests/unit/cli/command_test.py +++ b/tests/unit/cli/command_test.py @@ -4,28 +4,12 @@ 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 00000000000..a99356b441a --- /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 2080/4072] 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 a4a30a5f4df..33afc9b2c38 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 135502cc033..81e3b5bf39f 100644 --- a/compose/network.py +++ b/compose/network.py @@ -159,26 +159,26 @@ def initialize(self): 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 7ee441f2c85..fad1c4d936d 100644 --- a/compose/service.py +++ b/compose/service.py @@ -451,7 +451,10 @@ def start_container(self, container): 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 @@ def connect_container_to_networks(self, container): 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 1fd6c116177..e38e56902c1 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 b31840c8596..074864d47fb 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 24682125e4d..c94578a1bc7 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -475,6 +475,30 @@ def test_up_with_network_aliases(self): 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 00000000000..f820ff6a4a4 --- /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 daeb9c81d94..710da9a328c 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -5,6 +5,7 @@ import py import pytest +from docker.errors import APIError from docker.errors import NotFound from ..helpers import build_config @@ -650,6 +651,96 @@ def test_up_with_ipam_config(self): }], } + @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 2081/4072] 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 40ce1e661c3..9acf64e5687 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 2082/4072] 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 4810a106440..439f0f44bc8 100644 --- a/compose/parallel.py +++ b/compose/parallel.py @@ -14,68 +14,98 @@ 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)) - q = Queue() +def _no_deps(x): + return [] - # 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 +def setup_queue(objects, func, get_deps, get_name): + if get_deps is None: + get_deps = _no_deps + + 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 @@ def __init__(self, stream, msg): 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 c964417fff4..3de68b2c627 100644 --- a/compose/project.py +++ b/compose/project.py @@ -3,6 +3,7 @@ import datetime import logging +import operator from functools import reduce from docker.errors import APIError @@ -200,13 +201,40 @@ def get_network_mode(self, service_dict, networks): 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 @@ def up(self, 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 fad1c4d936d..30d28e4c685 100644 --- a/compose/service.py +++ b/compose/service.py @@ -436,9 +436,10 @@ def recreate_container( 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 c94578a1bc7..825b97bed07 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -8,6 +8,7 @@ import signal import subprocess import time +from collections import Counter from collections import namedtuple from operator import attrgetter @@ -1346,7 +1347,7 @@ def test_events_json(self): 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 2083/4072] 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 710da9a328c..393f4f11ba0 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -5,7 +5,6 @@ import py import pytest -from docker.errors import APIError from docker.errors import NotFound from ..helpers import build_config @@ -738,8 +737,7 @@ def test_up_with_network_static_addresses_missing_subnet(self): 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 2084/4072] 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 439f0f44bc8..879d183e818 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 2085/4072] Refactor log printing to support containers that are started later. Signed-off-by: Daniel Nephin --- compose/cli/log_printer.py | 170 ++++++++++++++++++++++------- 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, 174 insertions(+), 168 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 326676ba1fd..29a6159d98c 100644 --- a/compose/cli/log_printer.py +++ b/compose/cli/log_printer.py @@ -3,66 +3,127 @@ 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 + # TODO: this needs more logic + # TODO: does consume_queue need to yield Nones to get to this point? + if not thread_map: + return - 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) +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 build_log_prefix(container, prefix_width): - return container.name_without_project.ljust(prefix_width) + ' | ' +def tail_container_logs(container, presenter, queue, log_args): + generator = get_log_generator(container) -def max_name_width(containers): - """Calculate the maximum width of container names so we can make the log - prefixes line up like so: + try: + for item in generator(container, log_args): + queue.put((item, None)) - db_1 | Listening - web_1 | Listening - """ - return max(len(container.name_without_project) for container in containers) + 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 ae8aa59165c..00000000000 --- 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 3de68b2c627..1169f7dbe2a 100644 --- a/compose/project.py +++ b/compose/project.py @@ -309,7 +309,8 @@ def build_container_event(event, container): '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 54fef0b2358..81c694124ca 100644 --- a/tests/unit/cli/log_printer_test.py +++ b/tests/unit/cli/log_printer_test.py @@ -3,8 +3,11 @@ 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 reader(*args, **kwargs): 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 @@ def test_generator_with_no_logs(self, mock_container, output_stream): 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 737ba25d6de..00000000000 --- 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 c28c2152396..a815acdaa6c 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -307,6 +307,7 @@ def get_container(cid): 'image': 'example/image', }, 'time': dt_with_microseconds(1420092061, 2), + 'container': Container(None, {'Id': 'abcde'}), }, { 'type': 'container', @@ -318,6 +319,7 @@ def get_container(cid): 'image': 'example/image', }, 'time': dt_with_microseconds(1420092061, 3), + 'container': Container(None, {'Id': 'abcde'}), }, { 'type': 'container', @@ -329,6 +331,7 @@ def get_container(cid): '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 2086/4072] 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 | 118 +++++++++++++++-------------- tests/unit/cli/main_test.py | 14 ++-- 6 files changed, 213 insertions(+), 128 deletions(-) diff --git a/compose/cli/log_printer.py b/compose/cli/log_printer.py index 29a6159d98c..fc36a6bca52 100644 --- a/compose/cli/log_printer.py +++ b/compose/cli/log_printer.py @@ -2,6 +2,7 @@ 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.utils import split_buffer -STOP = object() - - class LogPresenter(object): def __init__(self, prefix_width, color_func): @@ -79,51 +77,74 @@ def run(self): 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_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 +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): 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 6636216828c..da622bc171d 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -35,6 +35,7 @@ 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 @@ def format_event(event): 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 @@ def logs(self, options): """ 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 @@ def logs(self, options): '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 @@ def up(self, options): 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 @@ def up(self, options): 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 @@ def up(self, options): 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 remove_container(force=False): 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 1169f7dbe2a..b40a9c38cef 100644 --- a/compose/project.py +++ b/compose/project.py @@ -324,6 +324,7 @@ def build_container_event(event, container): 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 825b97bed07..c2116553e46 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1188,7 +1188,7 @@ def test_logs_invalid_service_name(self): 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 @@ def test_logs_follow(self): 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']) - result = self.dispatch(['logs']) + proc = start_process(self.base_dir, ['logs', '-f']) - assert result.stdout.count('\n') >= 1 - assert 'exited with code 0' not in result.stdout + 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 '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 81c694124ca..7be1d3039ce 100644 --- a/tests/unit/cli/log_printer_test.py +++ b/tests/unit/cli/log_printer_test.py @@ -5,9 +5,11 @@ 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_unicode(self, output_stream): - glyph = u'\u2022' +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 - 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') +class TestBuildLogGenerator(object): - assert glyph in output + 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} - def test_wait_on_exit(self): - exit_status = 3 - mock_container = mock.Mock( - spec=Container, - name='cname', - wait=mock.Mock(return_value=exit_status)) + 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) - expected = '{} exited with code {}\n'.format(mock_container.name, exit_status) - assert expected == wait_on_exit(mock_container) + def test_with_log_stream(self, mock_container): + mock_container.log_stream = iter([b"hello\nworld"]) + log_args = {'follow': True} - 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() + 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\n' + mock_container.log_stream = iter([glyph.encode('utf-8')]) - 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 Problem(Exception): 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 Problem(Exception): 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 @@ def test_item_is_stop_without_cascade_stop(self): 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 9b24776f8f5..dc527880086 100644 --- a/tests/unit/cli/main_test.py +++ b/tests/unit/cli/main_test.py @@ -8,8 +8,8 @@ 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 @@ def test_build_log_printer(self): 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 2087/4072] 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 b40a9c38cef..9a2b46e1b0a 100644 --- a/compose/project.py +++ b/compose/project.py @@ -324,8 +324,11 @@ def build_container_event(event, container): 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 2088/4072] 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 c2116553e46..d3d4b3c06c1 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1222,6 +1222,15 @@ def test_logs_default(self): 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 2089/4072] 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 fc36a6bca52..22312c0084c 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 7be1d3039ce..33b2f1669c5 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 @@ def test_no_log_stream(self, mock_container): 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 @@ def test_with_log_stream(self, mock_container): 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 Problem(Exception): 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 @@ def test_item_is_stop_without_cascade_stop(self): 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 @@ def test_item_is_stop_with_cascade_stop(self): 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 2090/4072] 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 da622bc171d..468e10c4ebe 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -709,7 +709,7 @@ def up(self, options): 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 9a2b46e1b0a..4e25e498cb8 100644 --- a/compose/project.py +++ b/compose/project.py @@ -295,7 +295,7 @@ def create( 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 @@ def build_container_event(event, container): '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 2091/4072] 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 d3d4b3c06c1..095fb3f1716 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -78,21 +78,20 @@ def __str__(self): 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 @@ def test_run_handles_sigint(self): 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 @@ def test_logs_follow_logs_from_new_containers(self): 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 2092/4072] 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 22312c0084c..367a534ebeb 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 33b2f1669c5..ab48eefc0a1 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 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 @@ def test_unicode(self, output_stream): 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 2093/4072] 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 81e3b5bf39f..affba7c2d9c 100644 --- a/compose/network.py +++ b/compose/network.py @@ -149,7 +149,10 @@ def remove(self): 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 17e90087672..f440ba40c8a 100644 --- a/compose/volume.py +++ b/compose/volume.py @@ -76,7 +76,10 @@ def from_config(cls, name, config_data, client): 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 c28c2152396..bc6421a55fd 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -4,6 +4,7 @@ import datetime import docker +from docker.errors import NotFound from .. import mock from .. import unittest @@ -12,6 +13,7 @@ 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 @@ def test_container_without_name(self): ), ) 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 2094/4072] 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 367a534ebeb..b48462ff57a 100644 --- a/compose/cli/log_printer.py +++ b/compose/cli/log_printer.py @@ -88,7 +88,10 @@ def run(self): 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 468e10c4ebe..52b4a03bb0a 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -389,7 +389,7 @@ def logs(self, options): } print("Attaching to", list_containers(containers)) log_printer_from_project( - project, + self.project, containers, options['--no-color'], log_args).run() @@ -708,10 +708,7 @@ def up(self, options): 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 @@ def up(self, options): 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 095fb3f1716..ab74f14e670 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -396,8 +396,8 @@ def test_up_attached(self): 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 2095/4072] 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 6636216828c..146b77b4cc4 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -527,6 +527,7 @@ def run(self, options): 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 @@ def run(self, options): 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 21890c60a92..863544246d2 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 825b97bed07..a712de8a6c0 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1025,6 +1025,24 @@ def test_run_with_custom_name(self): 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 00000000000..dc3ea86a0fd --- /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 1d7c13e7e93..e0ada460d64 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -102,6 +102,7 @@ def test_run_interactive_passes_logs_false(self, mock_pseudo_terminal, mock_run_ '--publish': [], '--rm': None, '--name': None, + '--workdir': None, }) _, _, call_kwargs = mock_run_operation.mock_calls[0] @@ -135,6 +136,7 @@ def test_run_service_with_restart_always(self): '--publish': [], '--rm': None, '--name': None, + '--workdir': None, }) self.assertEquals( @@ -156,6 +158,7 @@ def test_run_service_with_restart_always(self): '--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 2096/4072] 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 146b77b4cc4..486fb151659 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -538,48 +538,18 @@ def run(self, options): "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 2097/4072] 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 6636216828c..110ff6df161 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -252,13 +252,15 @@ def down(self, options): 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 @@ def exec_command(self, options): 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 @@ def up(self, 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 """ 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 @@ def up(self, options): 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 3de68b2c627..49cbfbf7286 100644 --- a/compose/project.py +++ b/compose/project.py @@ -252,9 +252,11 @@ def kill(self, service_names=None, **options): 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 @@ def up(self, 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 @@ def up(self, 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 @@ def pull(self, service_names=None, ignore_pull_failures=False): for service in self.get_services(service_names, include_deps=False): service.pull(ignore_pull_failures) + def _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 d926d648e8d..0769e65718d 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 2495abeacef..e8b1db59746 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 07ee82f9345..3951f879258 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 393f4f11ba0..9839bf8fc0e 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -7,6 +7,7 @@ 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.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 @@ def test_project_up_named_volumes_in_binds(self): 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 2098/4072] 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 a16cad2f59c..668f8444131 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 2099/4072] 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 d6cb92cf988..85875512e7e 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 e38e56902c1..bc56829439c 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 2100/4072] 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 a16cad2f59c..2f2907ae378 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 a99356b441a..71fa9dee5de 100644 --- a/tests/unit/cli/errors_test.py +++ b/tests/unit/cli/errors_test.py @@ -37,13 +37,13 @@ def test_generic_connection_error(self, mock_logging): 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 2101/4072] 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 eee0c203e05..10841cdfd3c 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 2102/4072] 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 10841cdfd3c..95416e7ae8b 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 2103/4072] 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 0769e65718d..528970b40c3 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 2104/4072] 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 9839bf8fc0e..d1732d1e4d9 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -659,6 +659,7 @@ def test_up_with_network_static_addresses(self): services=[{ 'name': 'web', 'image': 'busybox:latest', + 'command': 'top', 'networks': { 'static_test': { 'ipv4_address': '172.16.100.100', @@ -690,7 +691,7 @@ def test_up_with_network_static_addresses(self): 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 2105/4072] 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 96fc5f8f5bb..2c68d36db96 100644 --- a/compose/cli/errors.py +++ b/compose/cli/errors.py @@ -3,12 +3,14 @@ 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 2106/4072] 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 f34809a9ec7..961d0b57f26 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 36a93793893..9fad7d00d21 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 33afc9b2c38..e84d13179f6 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 30d28e4c685..f8b13607eb6 100644 --- a/compose/service.py +++ b/compose/service.py @@ -668,6 +668,7 @@ def _get_container_host_config(self, override_options, one_off=False): 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 85875512e7e..09de5615988 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 9ecccd8a23e..6f457391f57 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 074864d47fb..2b7c85e6a4a 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 6d0c97db3fa..22cbfcee104 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -875,6 +875,11 @@ def test_dns_search(self): 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 d0e8242033d..e3dac160c30 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1194,6 +1194,21 @@ def test_normalize_dns_options(self): } ] + 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 2107/4072] 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 30d28e4c685..3f77ce4e742 100644 --- a/compose/service.py +++ b/compose/service.py @@ -498,7 +498,7 @@ def config_dict(self): '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 199aeeb4382..45836e01cd1 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -285,7 +285,7 @@ def test_get_container_create_options_does_not_mutate_options(self): 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 @@ def test_config_dict(self): 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 @@ def test_config_dict(self): '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 @@ def test_config_dict_with_network_mode_from_container(self): '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 2108/4072] 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 f06b32801cd..ee1eed5398e 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1226,6 +1226,10 @@ def test_logs_follow_logs_from_new_containers(self): '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 2109/4072] 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 55f6df01a3f..7e219e11081 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -4,6 +4,7 @@ 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 9e79fe77772..5663a57c95d 100644 --- a/compose/cli/docker_client.py +++ b/compose/cli/docker_client.py @@ -14,7 +14,7 @@ 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 e1170be904c..ca88276e77c 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 2110/4072] 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 ea3054abbb4..5ca8d23dbf9 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -491,8 +491,12 @@ def rm(self, options): 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 @@ def rm(self, options): 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 dbfe6a121d1..298396dec7b 100644 --- a/compose/project.py +++ b/compose/project.py @@ -47,10 +47,12 @@ def __init__(self, name, services, client, networks=None, volumes=None): 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 @@ def unpause(self, service_names=None, **options): 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 f84792243fc..97698b58b73 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 f06b32801cd..778c8ff4a88 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1125,6 +1125,28 @@ def test_rm(self): 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 2111/4072] 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 5ca8d23dbf9..f481d584724 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -22,6 +22,7 @@ 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 @@ def ps(self, options): """ 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 @@ def rm(self, options): 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 @@ def rm(self, options): 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 298396dec7b..aef556e921c 100644 --- a/compose/project.py +++ b/compose/project.py @@ -6,6 +6,7 @@ import operator from functools import reduce +import enum from docker.errors import APIError from . import parallel @@ -35,6 +36,20 @@ 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 @@ def __init__(self, name, services, client, networks=None, volumes=None): 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 @@ def kill(self, service_names=None, **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) + 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 778c8ff4a88..382fa8870dd 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -18,6 +18,7 @@ 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 @@ def tearDown(self): 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 @@ def test_run_service_without_links(self): 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 @@ def test_run_without_command(self): 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 @@ def test_run_without_command(self): 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 @@ def test_run_service_with_entrypoint_overridden(self): 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 @@ def test_run_service_with_user_overridden(self): 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 @@ def test_run_service_with_user_overridden_short_form(self): 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 @@ def test_run_service_with_environement_overridden(self): '/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 @@ def test_run_service_without_map_ports(self): # 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 @@ def test_run_service_with_map_ports(self): # 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 @@ def test_run_service_with_explicitly_maped_ports(self): # 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 @@ def test_run_service_with_explicitly_maped_ip_ports(self): '--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 @@ def test_run_with_expose_ports(self): # 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 @@ def test_run_with_custom_name(self): 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 @@ def test_run_interactive_connects_to_network(self): 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 @@ def test_run_detached_connects_to_network(self): 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 @@ def test_rm_all(self): 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 6d0c97db3fa..2682e59daa3 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -24,6 +24,7 @@ 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 @@ def test_containers_one_off(self): 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 @@ def test_start_one_off_container_creates_links_to_its_own_service(self): 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 199aeeb4382..0e3e8a86c84 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -14,6 +14,7 @@ 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 @@ def test_get_container_create_options_with_name_option(self): 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 2112/4072] 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 aef556e921c..c3283db982d 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 @@ def __init__(self, name, services, client, networks=None, volumes=None): 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 @@ def unpause(self, service_names=None, **options): 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 @@ def pull(self, service_names=None, ignore_pull_failures=False): for service in self.get_services(service_names, include_deps=False): service.pull(ignore_pull_failures) - def _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 @@ def _labeled_containers(self, stopped=False, one_off=False): 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 2113/4072] 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 f481d584724..a978579c0f8 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -495,7 +495,14 @@ def rm(self, options): -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 2114/4072] 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 c3283db982d..f0b4f1c68ef 100644 --- a/compose/project.py +++ b/compose/project.py @@ -276,7 +276,7 @@ def remove_stopped(self, service_names=None, one_off=OneOffFilter.exclude, **opt 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 382fa8870dd..15351502366 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -366,14 +366,19 @@ def test_down_invalid_rmi_flag(self): @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 2115/4072] 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 f0b4f1c68ef..8aa487319f6 100644 --- a/compose/project.py +++ b/compose/project.py @@ -239,8 +239,8 @@ def get_deps(service): 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 @@ def remove_stopped(self, service_names=None, one_off=OneOffFilter.exclude, **opt ), 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 15351502366..b81af68d00d 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -371,14 +371,17 @@ def test_down(self): 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 2116/4072] 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 9fad7d00d21..36a93793893 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 22cbfcee104..e3485346652 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -28,6 +28,7 @@ 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 @@ def test_dns_search(self): 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 e3dac160c30..04d82c8112f 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1196,9 +1196,12 @@ def test_normalize_dns_options(self): 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 2117/4072] 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 879d183e818..c629a1abfa3 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 ready(obj): ) 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 36099d2dd55..07b28e78431 100644 --- a/tests/integration/state_test.py +++ b/tests/integration/state_test.py @@ -38,8 +38,8 @@ def setUp(self): 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 2118/4072] 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 55f6df01a3f..730cd115379 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -12,6 +12,7 @@ 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 9e79fe77772..cff28f8c76d 100644 --- a/compose/cli/docker_client.py +++ b/compose/cli/docker_client.py @@ -3,9 +3,11 @@ 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 @@ 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 a978579c0f8..17c2ac45f18 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 2119/4072] 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 | 22 +++++++-------- 3 files changed, 37 insertions(+), 50 deletions(-) diff --git a/compose/cli/command.py b/compose/cli/command.py index 730cd115379..63d387f0c23 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -12,7 +12,7 @@ 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 cff28f8c76d..c8159ad496c 100644 --- a/compose/cli/docker_client.py +++ b/compose/cli/docker_client.py @@ -3,7 +3,6 @@ import logging import os -from collections import namedtuple from docker import Client from docker.errors import TLSParameterError @@ -16,24 +15,27 @@ 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') - # def has_config(self): - # return ( - # self.tls or self.ca_cert or self.cert or self.key or self.verify - # ) + 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 + ) + 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 17c2ac45f18..331476e2180 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 - - --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 + -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 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 2120/4072] 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 c8159ad496c..e2848a90b17 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 331476e2180..6eada097f4c 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 2121/4072] 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 e2848a90b17..d47bd2dbbe8 100644 --- a/compose/cli/docker_client.py +++ b/compose/cli/docker_client.py @@ -8,6 +8,7 @@ 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 2122/4072] 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 2b7c85e6a4a..88367353815 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 2123/4072] 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 d47bd2dbbe8..deb56866019 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 00000000000..e69de29bb2d diff --git a/tests/fixtures/tls/cert.pem b/tests/fixtures/tls/cert.pem new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/fixtures/tls/key.key b/tests/fixtures/tls/key.key new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/unit/cli/docker_client_test.py b/tests/unit/cli/docker_client_test.py index d497495b40c..b55f1d17999 100644 --- a/tests/unit/cli/docker_client_test.py +++ b/tests/unit/cli/docker_client_test.py @@ -3,7 +3,11 @@ 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 2124/4072] 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 62f50c24900..fcfaef19194 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 2125/4072] 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 09de5615988..e9ec0a2de56 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 bc56829439c..9739a088406 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 2b609efaa9f..ece721939de 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 2126/4072] 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 528970b40c3..c1c06045ff3 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 2127/4072] 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 c1c06045ff3..e7dba344e1c 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 2128/4072] 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 09f2817a6eb..d59fa56575b 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 2129/4072] 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 e7dba344e1c..3b9c9aedbfd 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 2130/4072] 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 e7dba344e1c..043edafdcf0 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 2131/4072] 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 e7dba344e1c..054ccafcbe1 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 2132/4072] 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 b0541406fc0..e2ef1161d55 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -6,6 +6,7 @@ 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 @@ def test_custom_container_name(self): 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 2133/4072] 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 e7dba344e1c..9476d854d6a 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 2134/4072] 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 88367353815..91d0487cdc6 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 df4172ce635..7caae97d2da 100644 --- a/setup.py +++ b/setup.py @@ -34,7 +34,7 @@ def find_version(*file_paths): '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 2135/4072] 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 63d387f0c23..3fcfbb4b3e6 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -21,7 +21,7 @@ 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 6eada097f4c..3fa3e3a0181 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -222,7 +222,7 @@ def config(self, config_options, options): --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 dd01f221eaa..7cf71eb98bd 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 961d0b57f26..c9c4e3084f9 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -17,6 +17,7 @@ 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 merge_services(base, override): 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 @@ def validate_and_construct_extends(self): 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 @@ def resolve_extends(self, extended_config_path, service_dict, service_name): 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 00000000000..45f1c43fb7d --- /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 1e56ebb6685..b76638d930f 100644 --- a/compose/config/interpolation.py +++ b/compose/config/interpolation.py @@ -2,17 +2,17 @@ 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 707c249266e..9d50ea99f0e 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -15,7 +15,7 @@ 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 @@ def test_env_file_relative_to_compose_file(self): 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 dd0b668ed4c..2c3d5a98b52 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 e2ef1161d55..a1857b58a5b 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -12,6 +12,7 @@ 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 @@ def test_env_from_file_combined_with_env(self): }.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 e0ada460d64..fd8aa95c14a 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -11,6 +11,7 @@ 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 @@ def test_project_name_with_explicit_project_name(self): 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 04d82c8112f..9bd76fb9a1b 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -23,6 +23,7 @@ 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 @@ def check_config(self, cfg): 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 @@ def test_config_file_with_environment_variable(self): } ]) - @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 @@ def test_unset_variable_produces_warning(self): 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 @@ def test_no_binding(self): 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 @@ def test_volume_binding_with_environment_variable(self): 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 @@ def test_relative_path_does_expand_windows(self): 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 @@ def test_parse_environment_invalid(self): 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 @@ def test_resolve_environment_nonexistent_file(self): 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 @@ def test_resolve_environment_from_env_file_with_empty_values(self): }, ) - @mock.patch.dict(os.environ) + @clear_environment def test_resolve_build_args(self): os.environ['env_arg'] = 'value2' @@ -2106,7 +2107,7 @@ def test_resolve_build_args(self): ) @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 @@ def test_invalid_net_in_extended_service(self): 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 @@ def test_extended_service_with_verbose_and_shorthand_way(self): }, ])) + @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 @@ def test_extends_with_environment_and_env_files(self): }, }, ] - 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 0691e88652f..b4ba7b4020d 100644 --- a/tests/unit/config/interpolation_test.py +++ b/tests/unit/config/interpolation_test.py @@ -6,12 +6,14 @@ 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 317982a9bf1..b19fcdaca58 100644 --- a/tests/unit/interpolation_test.py +++ b/tests/unit/interpolation_test.py @@ -3,7 +3,7 @@ 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 2136/4072] 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 e37677c6934..1ae45decbc4 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 2137/4072] 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 3fcfbb4b3e6..8c0bf07ffd0 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 c9c4e3084f9..7db66004fd8 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -114,14 +114,24 @@ 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 build_service(service_name, service_dict, service_names): 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 build_service(service_name, service_dict, service_names): 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 merge_services(base, override): 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 @@ def validate_and_construct_extends(self): 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 @@ def resolve_extends(self, extended_config_path, service_dict, service_name): service_dict), self.config_file, already_seen=self.already_seen + [self.signature], + environment=self.environment ) service_config = resolver.run() @@ -518,7 +530,7 @@ def get_extended_config_path(self, extends_options): 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 45f1c43fb7d..87b41223b60 100644 --- a/compose/config/environment.py +++ b/compose/config/environment.py @@ -29,24 +29,10 @@ def __getitem__(self, key): 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 @@ def load_environment_file(self, path): ) 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 b76638d930f..63020d91ad7 100644 --- a/compose/config/interpolation.py +++ b/compose/config/interpolation.py @@ -6,17 +6,15 @@ 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 2138/4072] 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 1ae45decbc4..462fcc4c6f4 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 9d50ea99f0e..707c249266e 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -15,7 +15,7 @@ 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 @@ def test_env_file_relative_to_compose_file(self): 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 2c3d5a98b52..dd0b668ed4c 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 a1857b58a5b..e2ef1161d55 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -12,7 +12,6 @@ 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 @@ def test_env_from_file_combined_with_env(self): }.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 8e2f25937cf..aaa002f341a 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -12,6 +12,7 @@ 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 @@ def create_service(self, name, **kwargs): 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 fd8aa95c14a..d8e0b33fb6f 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -11,7 +11,6 @@ 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 @@ def test_project_name_with_explicit_project_name(self): 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 9bd76fb9a1b..6dc7dbcad6d 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_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 @@ def check_config(self, cfg): 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 @@ def test_config_file_with_environment_variable(self): } ]) - @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 @@ def test_unset_variable_produces_warning(self): 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 @@ def test_unset_variable_produces_warning(self): 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 @@ def test_no_binding(self): 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 @@ def test_volume_binding_with_environment_variable(self): 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 @@ def test_relative_path_does_expand_windows(self): 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 @@ def test_parse_environment_invalid(self): 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 @@ def test_resolve_environment(self): }, } 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 @@ def test_resolve_environment_nonexistent_file(self): 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 @@ def test_resolve_environment_from_env_file_with_empty_values(self): }, ) - @clear_environment + @mock.patch.dict(os.environ) def test_resolve_build_args(self): os.environ['env_arg'] = 'value2' @@ -2102,12 +2104,12 @@ def test_resolve_build_args(self): } } 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 @@ def test_invalid_net_in_extended_service(self): 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 @@ def test_extended_service_with_verbose_and_shorthand_way(self): }, ])) - @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 b4ba7b4020d..f83caea9138 100644 --- a/tests/unit/config/interpolation_test.py +++ b/tests/unit/config/interpolation_test.py @@ -13,7 +13,6 @@ @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 2139/4072] 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 462fcc4c6f4..0e7b9d5f3bb 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 b524a5f3ba3..11fea16f606 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 2140/4072] 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 8c0bf07ffd0..07293f74a42 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 7db66004fd8..a50efdf8ec1 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 ..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 @@ def __new__(cls, working_dir, config_files): 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 @@ def __init__(self, service_config, config_file, environment=None, already_seen=N 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 87b41223b60..8066c50fc53 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 @@ def __getitem__(self, key): 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 aaa002f341a..98e0540f1b3 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -90,7 +90,7 @@ def create_service(self, name, **kwargs): 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 6dc7dbcad6d..daf724a8836 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -2042,7 +2042,7 @@ def test_resolve_environment(self): }, } 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 @@ def test_resolve_environment_from_env_file_with_empty_values(self): 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 @@ def test_resolve_build_args(self): } } 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 f83caea9138..282ba779a64 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 b19fcdaca58..c3050c2caa4 100644 --- a/tests/unit/interpolation_test.py +++ b/tests/unit/interpolation_test.py @@ -3,7 +3,7 @@ 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 2141/4072] 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 00000000000..996c886cb28 --- /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 00000000000..aa8e4409ebc --- /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 daf724a8836..913cbed9c6c 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1582,6 +1582,19 @@ def check_config(self, cfg): 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 2142/4072] 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 8066c50fc53..17eacf1f5ae 100644 --- a/compose/config/environment.py +++ b/compose/config/environment.py @@ -49,7 +49,7 @@ def from_env_file(cls, base_dir): 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 d8e0b33fb6f..bd35dc06f83 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -3,6 +3,8 @@ from __future__ import unicode_literals import os +import shutil +import tempfile import docker import py @@ -57,6 +59,22 @@ def test_project_name_with_empty_environment_var(self): 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 2143/4072] 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 00000000000..3dec592f301 --- /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 f5d84218f89..f1b710794ef 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 2144/4072] 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 e1170be904c..6f7fb791990 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 2145/4072] 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 07293f74a42..d726c7b3d5b 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -9,6 +9,7 @@ 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 3dec592f301..6d12d228ced 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 2146/4072] 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 d726c7b3d5b..73eccc96c1b 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -20,22 +20,23 @@ 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 3fa3e3a0181..8348b8c375d 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -17,6 +17,7 @@ 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 @@ def config(self, config_options, options): --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 a50efdf8ec1..47cb233122e 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 @@ def with_abs_paths(cls, working_dir, filename, name, config): 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 dd0b668ed4c..4b422a6a0a6 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 11fea16f606..3502d636950 100644 --- a/tests/unit/cli/command_test.py +++ b/tests/unit/cli/command_test.py @@ -6,6 +6,7 @@ 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 913cbed9c6c..1115287155f 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1584,8 +1584,11 @@ def check_config(self, cfg): 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 @@ def test_config_file_with_environment_file(self): @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 @@ def test_config_file_with_environment_variable(self): ) 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 @@ def test_resolve_path(self): 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 2147/4072] 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 17eacf1f5ae..7f7269b7c0e 100644 --- a/compose/config/environment.py +++ b/compose/config/environment.py @@ -7,6 +7,7 @@ import six +from ..const import IS_WINDOWS_PLATFORM from .errors import ConfigurationError log = logging.getLogger(__name__) @@ -58,6 +59,11 @@ def __getitem__(self, key): 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 2148/4072] 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 73eccc96c1b..b7160deec18 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 deb56866019..f782a1ae69f 100644 --- a/compose/cli/docker_client.py +++ b/compose/cli/docker_client.py @@ -2,7 +2,6 @@ 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 db5e2fb4f07..9e00d96e9fd 100644 --- a/compose/const.py +++ b/compose/const.py @@ -5,7 +5,7 @@ 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 6d12d228ced..a285a7908d7 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 98e0540f1b3..e8b2f35dc85 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -61,7 +61,7 @@ def setUpClass(cls): 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 b55f1d17999..56bab19c3f5 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 2149/4072] 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 7f7269b7c0e..8d2f5e3653c 100644 --- a/compose/config/environment.py +++ b/compose/config/environment.py @@ -72,3 +72,19 @@ def __getitem__(self, key): 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 2150/4072] 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 47cb233122e..dc3f56ea9c7 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 8d2f5e3653c..ad5c0b3daf3 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 e8b2f35dc85..8d69d53194c 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -90,7 +90,9 @@ def create_service(self, name, **kwargs): 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 1115287155f..2bbbe6145b9 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 @@ def test_resolve_environment(self): }, } 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 @@ def test_resolve_environment_from_env_file_with_empty_values(self): 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 282ba779a64..42b5db6e937 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 1ad88662c04c0b8a8a119f051323af93c8a4d0ab Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 23 Mar 2016 22:05:23 -0400 Subject: [PATCH 2151/4072] Bump 1.7.0 Signed-off-by: Daniel Nephin --- CHANGELOG.md | 88 +++++++++++++++++++++++++++++++++++++++++++++ compose/__init__.py | 2 +- docs/install.md | 6 ++-- script/run/run.sh | 2 +- 4 files changed, 93 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b93087f078..c6fd6247e92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,94 @@ Change log ========== +1.7.0 (2016-03-23) +------------------ + +**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 fedc90ff8cd..e605b856c39 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.7.0rc1' diff --git a/docs/install.md b/docs/install.md index 95416e7ae8b..e8fede82aa5 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.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.6.2 + docker-compose version: 1.7.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.6.2/run.sh > /usr/local/bin/docker-compose + $ curl -L https://github.com/docker/compose/releases/download/1.7.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 212f9b977aa..8a88c0bbfb9 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-rc1" IMAGE="docker/compose:$VERSION" From 0f1fb42326cb000efe6f06f7c1974430c474afe0 Mon Sep 17 00:00:00 2001 From: Steve Durrheimer Date: Fri, 25 Mar 2016 18:52:28 +0100 Subject: [PATCH 2152/4072] 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 f67bc9f6462..64e7942866e 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 2153/4072] 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 f67bc9f6462..a40f1010096 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 2154/4072] 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 f67bc9f6462..ecd8db9390c 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 2155/4072] 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 f67bc9f6462..d837e61e49f 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 2156/4072] 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 f67bc9f6462..3e3f24d0c91 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 2157/4072] 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 a8fc383e767..eef6b2f4bfc 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 2158/4072] 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 f782a1ae69f..83cd8626c75 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 56bab19c3f5..f4476ad3b3a 100644 --- a/tests/unit/cli/docker_client_test.py +++ b/tests/unit/cli/docker_client_test.py @@ -103,3 +103,31 @@ def test_tls_client_missing_key(self): 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 2159/4072] 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 83cd8626c75..e9f39d010dd 100644 --- a/compose/cli/docker_client.py +++ b/compose/cli/docker_client.py @@ -7,7 +7,6 @@ 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 91d0487cdc6..4bee21ef458 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 2160/4072] 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 4bee21ef458..898df3732b6 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 f4476ad3b3a..5334a9440a5 100644 --- a/tests/unit/cli/docker_client_test.py +++ b/tests/unit/cli/docker_client_test.py @@ -104,28 +104,6 @@ def test_tls_client_missing_key(self): 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 2161/4072] 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 e9f39d010dd..0c0113bb70f 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 2162/4072] 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 e9ec0a2de56..5aef5aca96c 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 2163/4072] 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 898df3732b6..76f224fbe79 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 2164/4072] 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 67903492eb5..c57397bd00e 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 2165/4072] 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 6eada097f4c..cf92a57b08a 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -355,10 +355,14 @@ def help(cls, options): """ 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 2166/4072] 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 c57397bd00e..287e54680e6 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 6dac949993a..2c16863df95 100644 --- a/compose/container.py +++ b/compose/container.py @@ -39,7 +39,7 @@ def from_ps(cls, client, dictionary, **kwargs): @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 e2ef1161d55..0a109ada328 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -769,17 +769,17 @@ def test_scale_with_desired_number_already_achieved(self, mock_log): 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 0d381951cc1..b6a52e08d05 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -270,12 +270,21 @@ def test_events(self): '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 2167/4072] 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 76f224fbe79..b9b0f403626 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 2168/4072] 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 e0ada460d64..b1475f84176 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -64,12 +64,6 @@ def test_get_project(self): 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 2169/4072] 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 | 66 ++++++++++++++++++++++++++++++--------------- compose/service.py | 3 +++ 2 files changed, 47 insertions(+), 22 deletions(-) diff --git a/compose/parallel.py b/compose/parallel.py index c629a1abfa3..79699236d8a 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.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 - - def do_op(obj): + 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)) - 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 ed45f07818f..05cfc7c61cb 100644 --- a/compose/service.py +++ b/compose/service.py @@ -135,6 +135,9 @@ def __init__( 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 2170/4072] Abort operations if their dependencies fail Signed-off-by: Aanand Prasad --- compose/parallel.py | 86 ++++++++++++++++++++++--------------- tests/unit/parallel_test.py | 73 +++++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 35 deletions(-) create mode 100644 tests/unit/parallel_test.py diff --git a/compose/parallel.py b/compose/parallel.py index 79699236d8a..745d4635194 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,58 +74,72 @@ 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) - ) + 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)) - while len(finished) < len(objects): - for obj in filter(ready, objects): + 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=producer, args=(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 + try: + event = results.get(timeout=1) + except Empty: + continue - obj = event[0] + obj, _, exception = event + if exception is None: log.debug('Finished processing: {}'.format(obj)) finished.add(obj) - output.put(event) + else: + log.debug('Failed: {}'.format(obj)) + failed.add(obj) - def producer(obj): - try: - result = func(obj) - results.put((obj, result, None)) - except Exception as e: - results.put((obj, None, e)) + output.put(event) - t = Thread(target=consumer) - t.daemon = True - t.start() - return output +class UpstreamError(Exception): + pass class ParallelStreamWriter(object): diff --git a/tests/unit/parallel_test.py b/tests/unit/parallel_test.py new file mode 100644 index 00000000000..6be56015242 --- /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 2171/4072] 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 745d4635194..8172d8eadf5 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 2172/4072] 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 6be56015242..889af4e2fcd 100644 --- a/tests/unit/parallel_test.py +++ b/tests/unit/parallel_test.py @@ -22,6 +22,10 @@ } +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 process(x): 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 process(x): 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 2173/4072] 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 889af4e2fcd..9ed1b3623d1 100644 --- a/tests/unit/parallel_test.py +++ b/tests/unit/parallel_test.py @@ -5,6 +5,8 @@ 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 process(x): ) 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 2174/4072] 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 8172d8eadf5..b3ca01530c0 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 - - 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) +def feed_queue(objects, func, get_deps, results, started, finished, failed): + pending = set(objects) - started - finished - failed + log.debug('Pending: {}'.format(pending)) - try: - event = results.get(timeout=1) - except Empty: - continue + for obj in pending: + deps = get_deps(obj) - 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 2175/4072] 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 b3ca01530c0..f400b223571 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 2176/4072] 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 f400b223571..e360ca357a6 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 2177/4072] 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 8aa487319f6..0d891e45584 100644 --- a/compose/project.py +++ b/compose/project.py @@ -309,12 +309,13 @@ def create( ): 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 @@ def up(self, 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 05cfc7c61cb..e0f23888220 100644 --- a/compose/service.py +++ b/compose/service.py @@ -254,7 +254,6 @@ def stop_and_remove(container): def create_container(self, one_off=False, - do_build=BuildAction.none, previous_container=None, number=None, quiet=False, @@ -263,7 +262,9 @@ def create_container(self, 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 @@ def _containers_have_diverged(self, containers): def execute_convergence_plan(self, plan, - do_build=BuildAction.none, timeout=DEFAULT_TIMEOUT, detached=False, start=True): @@ -371,7 +371,7 @@ def execute_convergence_plan(self, 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 @@ def execute_convergence_plan(self, return [ self.recreate_container( container, - do_build=do_build, timeout=timeout, attach_logs=should_attach_logs, start_new_container=start @@ -412,7 +411,6 @@ def execute_convergence_plan(self, def recreate_container( self, container, - do_build=BuildAction.none, timeout=DEFAULT_TIMEOUT, attach_logs=False, start_new_container=True): @@ -427,7 +425,6 @@ def recreate_container( 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 0a109ada328..df50d513a9e 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -1037,12 +1037,10 @@ def test_duplicate_containers(self): 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 5231237ab74..fe3794dafbd 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -420,7 +420,7 @@ def test_parse_repository_tag(self): 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 @@ def test_create_container_with_build(self): ] 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 @@ def test_create_container_with_build(self): 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 @@ def test_create_container_force_build(self): ] 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 c6c1afd5688b4e90dac0acec0c8d520ab69edcd9 Mon Sep 17 00:00:00 2001 From: Steve Durrheimer Date: Fri, 25 Mar 2016 18:52:28 +0100 Subject: [PATCH 2178/4072] 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 f67bc9f6462..64e7942866e 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 94afcfaf9d602aa7dd358473c2d8872e5e631afc Mon Sep 17 00:00:00 2001 From: Steve Durrheimer Date: Fri, 25 Mar 2016 19:03:36 +0100 Subject: [PATCH 2179/4072] 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 64e7942866e..3772bf632d4 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 e863894e2d665d1accb08224403da420329dcf33 Mon Sep 17 00:00:00 2001 From: Steve Durrheimer Date: Fri, 25 Mar 2016 19:09:53 +0100 Subject: [PATCH 2180/4072] 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 3772bf632d4..ddd68774fc1 100644 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -247,7 +247,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 d434098b9491875275e9f29a2dfde75aa4aff0f2 Mon Sep 17 00:00:00 2001 From: Steve Durrheimer Date: Fri, 25 Mar 2016 19:15:34 +0100 Subject: [PATCH 2181/4072] 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 ddd68774fc1..7054f94da7e 100644 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -329,6 +329,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 bd0f6d8d7b94e622c4d7807ffe56e2b4b45f50c7 Mon Sep 17 00:00:00 2001 From: Steve Durrheimer Date: Fri, 25 Mar 2016 19:25:33 +0100 Subject: [PATCH 2182/4072] 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 7054f94da7e..ec9cb682f7c 100644 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -290,15 +290,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 44715f18bd8f867567de6baaf18115a63853f3b0 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 28 Mar 2016 17:39:20 -0700 Subject: [PATCH 2183/4072] 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 f782a1ae69f..83cd8626c75 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 56bab19c3f5..f4476ad3b3a 100644 --- a/tests/unit/cli/docker_client_test.py +++ b/tests/unit/cli/docker_client_test.py @@ -103,3 +103,31 @@ def test_tls_client_missing_key(self): 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 a2adf31caaabc08c8a1fadd05a8aa0502abe4d2a Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 29 Mar 2016 18:01:27 -0700 Subject: [PATCH 2184/4072] 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 83cd8626c75..e9f39d010dd 100644 --- a/compose/cli/docker_client.py +++ b/compose/cli/docker_client.py @@ -7,7 +7,6 @@ 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 91d0487cdc6..4bee21ef458 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 98d7a1e9dd1c6b69f911bc5966caf732a20a7fea Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 29 Mar 2016 18:05:37 -0700 Subject: [PATCH 2185/4072] 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 4bee21ef458..898df3732b6 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 f4476ad3b3a..5334a9440a5 100644 --- a/tests/unit/cli/docker_client_test.py +++ b/tests/unit/cli/docker_client_test.py @@ -104,28 +104,6 @@ def test_tls_client_missing_key(self): 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 7dd29e8239fc66c88226e326ed94315262f70d29 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 30 Mar 2016 11:11:19 -0700 Subject: [PATCH 2186/4072] 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 e9f39d010dd..0c0113bb70f 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 1dea8abe69234714c3f39b2e8286abeb1ef1582c Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 30 Mar 2016 11:58:28 -0700 Subject: [PATCH 2187/4072] 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 898df3732b6..76f224fbe79 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 73d57a1acbdab8109fb7ae54dae3e41a89b3d551 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 5 Apr 2016 11:26:23 -0400 Subject: [PATCH 2188/4072] Prevent unnecessary inspection of containers when created from an inspect. Signed-off-by: Daniel Nephin --- compose/container.py | 2 +- tests/integration/service_test.py | 12 ++++++------ tests/unit/project_test.py | 9 +++++++++ 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/compose/container.py b/compose/container.py index 6dac949993a..2c16863df95 100644 --- a/compose/container.py +++ b/compose/container.py @@ -39,7 +39,7 @@ def from_ps(cls, client, dictionary, **kwargs): @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 e2ef1161d55..0a109ada328 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -769,17 +769,17 @@ def test_scale_with_desired_number_already_achieved(self, mock_log): 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 0d381951cc1..b6a52e08d05 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -270,12 +270,21 @@ def test_events(self): '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 b865f35f1792f6f927a8d3b48cc0d68c5d79e8ca Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 6 Apr 2016 13:28:45 -0700 Subject: [PATCH 2189/4072] 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 76f224fbe79..b9b0f403626 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 cdef2b5e3bc8cbffeea8d13d81eef1cc1f6d1a6e Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 8 Apr 2016 12:58:19 +0100 Subject: [PATCH 2190/4072] 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 | 66 ++++++++++++++++++++++++++++++--------------- compose/service.py | 3 +++ 2 files changed, 47 insertions(+), 22 deletions(-) diff --git a/compose/parallel.py b/compose/parallel.py index c629a1abfa3..79699236d8a 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.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 - - def do_op(obj): + 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)) - 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 ed45f07818f..05cfc7c61cb 100644 --- a/compose/service.py +++ b/compose/service.py @@ -135,6 +135,9 @@ def __init__( 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 09e359fc8dc43ff025cba4e7cd3fb08c07ca1ba3 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 8 Apr 2016 17:46:13 +0100 Subject: [PATCH 2191/4072] Abort operations if their dependencies fail Signed-off-by: Aanand Prasad --- compose/parallel.py | 86 ++++++++++++++++++++++--------------- tests/unit/parallel_test.py | 73 +++++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 35 deletions(-) create mode 100644 tests/unit/parallel_test.py diff --git a/compose/parallel.py b/compose/parallel.py index 79699236d8a..745d4635194 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,58 +74,72 @@ 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) - ) + 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)) - while len(finished) < len(objects): - for obj in filter(ready, objects): + 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=producer, args=(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 + try: + event = results.get(timeout=1) + except Empty: + continue - obj = event[0] + obj, _, exception = event + if exception is None: log.debug('Finished processing: {}'.format(obj)) finished.add(obj) - output.put(event) + else: + log.debug('Failed: {}'.format(obj)) + failed.add(obj) - def producer(obj): - try: - result = func(obj) - results.put((obj, result, None)) - except Exception as e: - results.put((obj, None, e)) + output.put(event) - t = Thread(target=consumer) - t.daemon = True - t.start() - return output +class UpstreamError(Exception): + pass class ParallelStreamWriter(object): diff --git a/tests/unit/parallel_test.py b/tests/unit/parallel_test.py new file mode 100644 index 00000000000..6be56015242 --- /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 d3899418b74b34613a8d0ab85114eb31c4a0713e Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 8 Apr 2016 18:30:28 +0100 Subject: [PATCH 2192/4072] 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 745d4635194..8172d8eadf5 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 868133e881df2b065f02334bac50707e4400faa8 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 8 Apr 2016 18:48:07 +0100 Subject: [PATCH 2193/4072] 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 6be56015242..889af4e2fcd 100644 --- a/tests/unit/parallel_test.py +++ b/tests/unit/parallel_test.py @@ -22,6 +22,10 @@ } +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 process(x): 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 process(x): func=process, get_name=lambda obj: obj, msg="Processing", - get_deps=lambda obj: deps[obj], + get_deps=get_deps, ) assert log == [cache] From a81b9dc6a0cb642b7350005af290cd7ac7105847 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 8 Apr 2016 18:53:16 +0100 Subject: [PATCH 2194/4072] 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 889af4e2fcd..9ed1b3623d1 100644 --- a/tests/unit/parallel_test.py +++ b/tests/unit/parallel_test.py @@ -5,6 +5,8 @@ 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 process(x): ) 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 79edda680449eff24f1333246e98c9492dc9b681 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 8 Apr 2016 18:54:02 +0100 Subject: [PATCH 2195/4072] 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 8172d8eadf5..b3ca01530c0 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 - - 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) +def feed_queue(objects, func, get_deps, results, started, finished, failed): + pending = set(objects) - started - finished - failed + log.debug('Pending: {}'.format(pending)) - try: - event = results.get(timeout=1) - except Empty: - continue + for obj in pending: + deps = get_deps(obj) - 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 de6496c6c9cb76c4c6dec800834ec11d4c1b2003 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 8 Apr 2016 19:06:07 +0100 Subject: [PATCH 2196/4072] 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 b3ca01530c0..f400b223571 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 68b4ef6cf25daeb013a9a2372fc40f0db9d32f6d Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 8 Apr 2016 19:07:40 +0100 Subject: [PATCH 2197/4072] 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 f400b223571..e360ca357a6 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 275b54641a9210a33ad41cd8c36ba98f6357a044 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 8 Apr 2016 11:59:06 -0400 Subject: [PATCH 2198/4072] 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 8aa487319f6..0d891e45584 100644 --- a/compose/project.py +++ b/compose/project.py @@ -309,12 +309,13 @@ def create( ): 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 @@ def up(self, 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 05cfc7c61cb..e0f23888220 100644 --- a/compose/service.py +++ b/compose/service.py @@ -254,7 +254,6 @@ def stop_and_remove(container): def create_container(self, one_off=False, - do_build=BuildAction.none, previous_container=None, number=None, quiet=False, @@ -263,7 +262,9 @@ def create_container(self, 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 @@ def _containers_have_diverged(self, containers): def execute_convergence_plan(self, plan, - do_build=BuildAction.none, timeout=DEFAULT_TIMEOUT, detached=False, start=True): @@ -371,7 +371,7 @@ def execute_convergence_plan(self, 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 @@ def execute_convergence_plan(self, return [ self.recreate_container( container, - do_build=do_build, timeout=timeout, attach_logs=should_attach_logs, start_new_container=start @@ -412,7 +411,6 @@ def execute_convergence_plan(self, def recreate_container( self, container, - do_build=BuildAction.none, timeout=DEFAULT_TIMEOUT, attach_logs=False, start_new_container=True): @@ -427,7 +425,6 @@ def recreate_container( 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 0a109ada328..df50d513a9e 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -1037,12 +1037,10 @@ def test_duplicate_containers(self): 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 5231237ab74..fe3794dafbd 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -420,7 +420,7 @@ def test_parse_repository_tag(self): 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 @@ def test_create_container_with_build(self): ] 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 @@ def test_create_container_with_build(self): 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 @@ def test_create_container_force_build(self): ] 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 2199/4072] Updated Wordpress tutorial The new tutorial now uses official Wordpress Docker Image. Signed-off-by: Sanyam Kapoor <1sanyamkapoor@gmail.com> --- docs/wordpress.md | 117 +++++++++++----------------------------------- 1 file changed, 27 insertions(+), 90 deletions(-) diff --git a/docs/wordpress.md b/docs/wordpress.md index fcfaef19194..c257ad1a1c8 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: - - $ 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) + 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 -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 2200/4072] 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 fb1fa214183..6a222697ec0 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 c257ad1a1c8..b39a8bbbe68 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 2201/4072] 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 e360ca357a6..ace1f029c88 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 2202/4072] 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 ace1f029c88..d9c24ab662a 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 @@ def pending(self): 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 2203/4072] 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 d9c24ab662a..ee3d5777b10 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 @@ def pending(self): 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 9ed1b3623d1..45b0db1db45 100644 --- a/tests/unit/parallel_test.py +++ b/tests/unit/parallel_test.py @@ -5,7 +5,7 @@ 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 process(x): 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 2204/4072] 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 8348b8c375d..839d97e8a1d 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -264,18 +264,29 @@ def create(self, 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. + 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 @@ def pull(self, options): 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 @@ def rm(self, options): 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 e8b1db59746..ffe88b4e05f 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 97698b58b73..8285a4ae52a 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 2205/4072] 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 ee3d5777b10..63417dcb0a4 100644 --- a/compose/parallel.py +++ b/compose/parallel.py @@ -17,6 +17,8 @@ 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 d03f4e4b325f175bcd8de2b0bdbb196b9601f3bb Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 11 Apr 2016 12:37:00 +0100 Subject: [PATCH 2206/4072] 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 e360ca357a6..ace1f029c88 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 720dc893e2ab8411dd1c242944dc128675113137 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 11 Apr 2016 12:49:04 +0100 Subject: [PATCH 2207/4072] 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 ace1f029c88..d9c24ab662a 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 @@ def pending(self): 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 ebae76bee8a550018624a057ca39a83b195dccab Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 11 Apr 2016 13:03:35 +0100 Subject: [PATCH 2208/4072] 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 d9c24ab662a..ee3d5777b10 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 @@ def pending(self): 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 9ed1b3623d1..45b0db1db45 100644 --- a/tests/unit/parallel_test.py +++ b/tests/unit/parallel_test.py @@ -5,7 +5,7 @@ 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 process(x): 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 2160c787e3fde64af1c90ce32dcc92b63eaf3c73 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 11 Apr 2016 17:05:52 +0100 Subject: [PATCH 2209/4072] 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 ee3d5777b10..63417dcb0a4 100644 --- a/compose/parallel.py +++ b/compose/parallel.py @@ -17,6 +17,8 @@ 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 ea2d526246becbfa9cd38486958448bc1c6f606b Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 8 Apr 2016 15:48:36 -0400 Subject: [PATCH 2210/4072] Bump 1.7.0-rc2 Signed-off-by: Daniel Nephin --- CHANGELOG.md | 2 +- compose/__init__.py | 2 +- script/run/run.sh | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c6fd6247e92..ebc8c8fd980 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ Change log ========== -1.7.0 (2016-03-23) +1.7.0 (2016-04-08) ------------------ **Breaking Changes** diff --git a/compose/__init__.py b/compose/__init__.py index e605b856c39..c63fcb4cfff 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.0rc1' +__version__ = '1.7.0rc2' diff --git a/script/run/run.sh b/script/run/run.sh index 8a88c0bbfb9..fd66a37c0f4 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.7.0-rc1" +VERSION="1.7.0-rc2" IMAGE="docker/compose:$VERSION" From 7781f62ddf54fa635890c1772e1729ff5461fd55 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 12 Apr 2016 12:03:16 +0100 Subject: [PATCH 2211/4072] 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 707c249266e..53ff66bbba1 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1257,13 +1257,14 @@ def test_logs_follow_logs_from_new_containers(self): '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 0af9d805cca..b719c91e079 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 2212/4072] 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 182e79ed523..9700d592721 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -5,6 +5,7 @@ import os import shutil import tempfile +from StringIO import StringIO import docker import py @@ -82,6 +83,12 @@ def test_get_project(self): 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 2213/4072] 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 9700d592721..2c90b29b72c 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -5,7 +5,7 @@ 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 2214/4072] 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 e0f23888220..054082fc9c5 100644 --- a/compose/service.py +++ b/compose/service.py @@ -179,7 +179,7 @@ def scale(self, desired_num, timeout=DEFAULT_TIMEOUT): '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 fe3794dafbd..d3fcb49a7fb 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -642,6 +642,26 @@ def test_image_name_default(self): 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 0d7bf73446cd597e263f23b85c9fd1ea352735a6 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 13 Apr 2016 11:37:49 -0400 Subject: [PATCH 2215/4072] Bump 1.7.0 Signed-off-by: Daniel Nephin --- CHANGELOG.md | 2 +- compose/__init__.py | 2 +- script/run/run.sh | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ebc8c8fd980..8ee45386a18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ Change log ========== -1.7.0 (2016-04-08) +1.7.0 (2016-04-13) ------------------ **Breaking Changes** diff --git a/compose/__init__.py b/compose/__init__.py index c63fcb4cfff..b2062199a07 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.0rc2' +__version__ = '1.7.0' diff --git a/script/run/run.sh b/script/run/run.sh index fd66a37c0f4..98d32c5f8d3 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.7.0-rc2" +VERSION="1.7.0" IMAGE="docker/compose:$VERSION" From 50287722f2dd9df322122395e76e7778e185cdec Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 13 Apr 2016 12:57:22 -0400 Subject: [PATCH 2216/4072] 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 8b93087f078..8ee45386a18 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 fedc90ff8cd..1052c0670bf 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 212f9b977aa..98d32c5f8d3 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 2217/4072] 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 f8822151983..93550f5ac3d 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 2218/4072] Only disconnect if we don't already have the short id alias. Signed-off-by: Daniel Nephin --- compose/service.py | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/compose/service.py b/compose/service.py index 054082fc9c5..49eee104145 100644 --- a/compose/service.py +++ b/compose/service.py @@ -453,20 +453,21 @@ def connect_container_to_networks(self, container): connected_networks = container.get('NetworkSettings.Networks') for network, netdefs in self.networks.items(): - aliases = netdefs.get('aliases', []) - ipv4_address = netdefs.get('ipv4_address', None) - ipv6_address = netdefs.get('ipv6_address', None) if network in connected_networks: + if short_id_alias_exists(container, network): + continue + self.client.disconnect_container_from_network( - container.id, network) + container.id, + network) + 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 @@ def pull(self, ignore_pull_failures=False): 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 2219/4072] 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 49eee104145..e6ea923318a 100644 --- a/compose/service.py +++ b/compose/service.py @@ -461,10 +461,9 @@ def connect_container_to_networks(self, container): 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 @@ def _next_container_number(self, one_off=False): 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 @@ def _get_container_create_options( 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 2220/4072] 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 839d97e8a1d..29d808ce3f8 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 2221/4072] 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 dc3f56ea9c7..bd6e54fa2d5 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -726,7 +726,7 @@ def parse_sequence_func(seq): 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 2222/4072] 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 e6ea923318a..8b9f64f0f93 100644 --- a/compose/service.py +++ b/compose/service.py @@ -535,7 +535,7 @@ def _next_container_number(self, one_off=False): 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 d1732d1e4d9..c413b9aa0b8 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -565,7 +565,11 @@ def test_project_up_networks(self): '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 @@ def test_project_up_networks(self): 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 d3fcb49a7fb..a259c476fb5 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -663,6 +663,35 @@ def test_only_log_warning_when_host_ports_clash(self, mock_log): '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 2223/4072] 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 dc3f56ea9c7..3f76277cb89 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -37,6 +37,7 @@ 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 088bec3fc46..e4b3a253076 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 2224/4072] 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 2bbbe6145b9..8bf41632655 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1360,6 +1360,17 @@ def test_depends_on_unknown_service_errors(self): 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 2225/4072] 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 e4b3a253076..8c89cdf2b32 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 edfeb8b2313..cea740cb7b1 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 8bf41632655..488305586ee 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1366,7 +1366,7 @@ def test_linked_service_is_undefined(self): 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 2226/4072] 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 53ff66bbba1..0b49efa015d 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -140,8 +140,8 @@ def lookup(self, container, hostname): 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 2227/4072] 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 5aef5aca96c..fc806a2903c 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 2228/4072] 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 8c89cdf2b32..726750a3dcf 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 2229/4072] 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 d5df7ae55ae..d14cbbdd0c2 100644 --- a/compose/config/errors.py +++ b/compose/config/errors.py @@ -3,10 +3,11 @@ 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 726750a3dcf..7452e9849bb 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 2230/4072] 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 7caae97d2da..de009146dd1 100644 --- a/setup.py +++ b/setup.py @@ -34,7 +34,7 @@ def find_version(*file_paths): '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 2231/4072] 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 acf9b6aebfa..63fac3eb38a 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 2232/4072] 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 0b49efa015d..4d1990be115 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -145,15 +145,11 @@ def test_help(self): # 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 @@ def test_config_quiet_with_error(self): ], 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 2233/4072] 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 4d1990be115..2a5a860444f 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -675,9 +675,7 @@ def test_up_with_net_is_invalid(self): ['-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 2234/4072] 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 06e0a027bb3..be6ba7204bb 100644 --- a/compose/config/serialize.py +++ b/compose/config/serialize.py @@ -5,6 +5,8 @@ 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 @@ def serialize_config_type(dumper, data): 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 2a5a860444f..f7c958dd8ff 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -190,6 +190,31 @@ def test_config_default(self): } 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 00000000000..8646c4edb91 --- /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 2235/4072] 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 be6ba7204bb..1b498c01633 100644 --- a/compose/config/serialize.py +++ b/compose/config/serialize.py @@ -19,12 +19,14 @@ def serialize_config_type(dumper, data): 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 fc3347c86fa..e6a3dea053e 100644 --- a/compose/config/types.py +++ b/compose/config/types.py @@ -7,6 +7,8 @@ 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 f7c958dd8ff..515acb042b2 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -190,6 +190,33 @@ def test_config_default(self): } 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 00000000000..2d10aa39705 --- /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 2236/4072] 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 dd859edc4b7..fff4a543f4c 100644 --- a/compose/cli/utils.py +++ b/compose/cli/utils.py @@ -12,6 +12,13 @@ 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 2237/4072] 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 b16d0d2c396..86ed32bc8e5 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 2238/4072] 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 ae787c9b7a2..b86c34f865e 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -426,7 +426,8 @@ def logs(self, options): 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 2239/4072] 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 b7160deec18..8ac3aff4fd2 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -21,12 +21,15 @@ 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 515acb042b2..a02d0e99eb5 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -145,6 +145,13 @@ def test_help(self): # 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 2240/4072] 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 a285a7908d7..be2625f889a 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 2241/4072] 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 b9b0f403626..eb5275f4e19 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 de009146dd1..0b37c1dd4e5 100644 --- a/setup.py +++ b/setup.py @@ -34,7 +34,7 @@ def find_version(*file_paths): '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 2242/4072] 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 0d891e45584..64ca7be7692 100644 --- a/compose/project.py +++ b/compose/project.py @@ -342,7 +342,10 @@ def build_container_event(event, container): 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 2243/4072] 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 ad5c0b3daf3..ff08b7714b0 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 2244/4072] 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 b86c34f865e..34e7f35c764 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -24,6 +24,7 @@ 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 63417dcb0a4..50b2dbeaf40 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 64ca7be7692..d965c4a399c 100644 --- a/compose/project.py +++ b/compose/project.py @@ -390,13 +390,18 @@ def do(service): 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 @@ def __init__(self, name): 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 c413b9aa0b8..7ef492a561e 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -19,6 +19,7 @@ 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 @@ def test_up_with_network_static_addresses_missing_subnet(self): 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 45b0db1db45..479c0f1d371 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 2245/4072] 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 fc806a2903c..4902e8ddf85 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 9cfbfd55c4cf512c8a77e8bf94163fcc79db9f7c Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 18 Mar 2016 13:00:55 +0000 Subject: [PATCH 2246/4072] 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 707c249266e..d25995ab495 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -145,15 +145,11 @@ def test_help(self): # 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 @@ def test_config_quiet_with_error(self): ], 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 8a9ab69a1c91025edb460dafa35a855d158b4d9a Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 18 Mar 2016 13:01:35 +0000 Subject: [PATCH 2247/4072] 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 d25995ab495..cad82bec8a2 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -675,9 +675,7 @@ def test_up_with_net_is_invalid(self): ['-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 a2ded237e4f40efca98b4049b25c3d5291dc2c73 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 18 Mar 2016 13:14:33 +0000 Subject: [PATCH 2248/4072] 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 06e0a027bb3..be6ba7204bb 100644 --- a/compose/config/serialize.py +++ b/compose/config/serialize.py @@ -5,6 +5,8 @@ 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 @@ def serialize_config_type(dumper, data): 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 cad82bec8a2..ddbe262e41d 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -190,6 +190,31 @@ def test_config_default(self): } 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 00000000000..8646c4edb91 --- /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 1e164ca802b91cf5c3160eeb936eff7f5ddf79cc Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 26 Apr 2016 17:30:04 +0100 Subject: [PATCH 2249/4072] 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 be6ba7204bb..1b498c01633 100644 --- a/compose/config/serialize.py +++ b/compose/config/serialize.py @@ -19,12 +19,14 @@ def serialize_config_type(dumper, data): 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 fc3347c86fa..e6a3dea053e 100644 --- a/compose/config/types.py +++ b/compose/config/types.py @@ -7,6 +7,8 @@ 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 ddbe262e41d..b0faf610689 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -190,6 +190,33 @@ def test_config_default(self): } 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 00000000000..2d10aa39705 --- /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 9cf483e224469b4b3114ffa2c42fbc1f0db4637a Mon Sep 17 00:00:00 2001 From: Tony Witherspoon Date: Mon, 4 Apr 2016 13:15:28 -0400 Subject: [PATCH 2250/4072] 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 8348b8c375d..cc996c6a6a0 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -361,10 +361,14 @@ def help(cls, options): """ 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 65b0e5973b748f679ae8203148394d77d957015f Mon Sep 17 00:00:00 2001 From: Tony Witherspoon Date: Thu, 7 Apr 2016 12:42:14 -0400 Subject: [PATCH 2251/4072] 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 bd35dc06f83..182e79ed523 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -82,12 +82,6 @@ def test_get_project(self): 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 e5f1429ce10beaa44a808f9337eb7b09234677a9 Mon Sep 17 00:00:00 2001 From: Tony Witherspoon Date: Tue, 12 Apr 2016 11:47:15 -0400 Subject: [PATCH 2252/4072] 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 182e79ed523..9700d592721 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -5,6 +5,7 @@ import os import shutil import tempfile +from StringIO import StringIO import docker import py @@ -82,6 +83,12 @@ def test_get_project(self): 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 3368887a291f1c4b1e5d90aef3cea56529b0ff5f Mon Sep 17 00:00:00 2001 From: Tony Witherspoon Date: Tue, 12 Apr 2016 12:29:59 -0400 Subject: [PATCH 2253/4072] 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 9700d592721..2c90b29b72c 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -5,7 +5,7 @@ import os import shutil import tempfile -from StringIO import StringIO +from io import StringIO import docker import py From a86a195c5098b6f410d7b0d874c212e28860f5da Mon Sep 17 00:00:00 2001 From: Vladimir Lagunov Date: Fri, 15 Apr 2016 15:11:50 +0300 Subject: [PATCH 2254/4072] 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 dc3f56ea9c7..bd6e54fa2d5 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -726,7 +726,7 @@ def parse_sequence_func(seq): 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 4e8b01728346a3b74a043fcd4f40271bb5a185d1 Mon Sep 17 00:00:00 2001 From: johnharris85 Date: Fri, 15 Apr 2016 13:30:13 +0100 Subject: [PATCH 2255/4072] 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 cc996c6a6a0..ad3c0863177 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 250a7a530b6101d2e26328e4135714686e649c64 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 8 Apr 2016 15:45:03 -0400 Subject: [PATCH 2256/4072] Only disconnect if we don't already have the short id alias. Signed-off-by: Daniel Nephin --- compose/service.py | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/compose/service.py b/compose/service.py index e0f23888220..acb9feaddbc 100644 --- a/compose/service.py +++ b/compose/service.py @@ -453,20 +453,21 @@ def connect_container_to_networks(self, container): connected_networks = container.get('NetworkSettings.Networks') for network, netdefs in self.networks.items(): - aliases = netdefs.get('aliases', []) - ipv4_address = netdefs.get('ipv4_address', None) - ipv6_address = netdefs.get('ipv6_address', None) if network in connected_networks: + if short_id_alias_exists(container, network): + continue + self.client.disconnect_container_from_network( - container.id, network) + container.id, + network) + 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 @@ def pull(self, ignore_pull_failures=False): 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 5852db4d7283789c57cd317e8eaf048004521489 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 11 Apr 2016 13:22:37 -0400 Subject: [PATCH 2257/4072] 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 acb9feaddbc..9afd1f62b75 100644 --- a/compose/service.py +++ b/compose/service.py @@ -461,10 +461,9 @@ def connect_container_to_networks(self, container): 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 @@ def _next_container_number(self, one_off=False): 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 @@ def _get_container_create_options( 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 2a8c2c8ad6a6901514fbd966706912b9dffb94f4 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 15 Apr 2016 15:42:36 -0400 Subject: [PATCH 2258/4072] 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 9afd1f62b75..e8624fa66ac 100644 --- a/compose/service.py +++ b/compose/service.py @@ -535,7 +535,7 @@ def _next_container_number(self, one_off=False): 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 d1732d1e4d9..c413b9aa0b8 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -565,7 +565,11 @@ def test_project_up_networks(self): '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 @@ def test_project_up_networks(self): 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 fe3794dafbd..1994993c6e1 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -643,6 +643,35 @@ def test_image_name_default(self): assert service.image_name == 'testing_foo' +class TestServiceNetwork(object): + + def test_connect_container_to_networks_short_aliase_exists(self): + mock_client = mock.create_autospec(docker.Client) + service = Service( + 'db', + mock_client, + 'myproject', + image='foo', + networks={'project_default': {}}) + container = Container( + None, + { + 'Id': 'abcdef', + 'NetworkSettings': { + 'Networks': { + 'project_default': { + 'Aliases': ['analias', 'abcdef'], + }, + }, + }, + }, + True) + service.connect_container_to_networks(container) + + assert not mock_client.disconnect_container_from_network.call_count + assert not mock_client.connect_container_to_network.call_count + + def sort_by_name(dictionary_list): return sorted(dictionary_list, key=lambda k: k['name']) From e4d2d7ed8a134bbd947e02f2dd8a201dc2649677 Mon Sep 17 00:00:00 2001 From: johnharris85 Date: Sun, 17 Apr 2016 14:00:07 -0400 Subject: [PATCH 2259/4072] 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 bd6e54fa2d5..e52de4bf8d3 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -37,6 +37,7 @@ 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 088bec3fc46..e4b3a253076 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 f7cd94d4a95ce51fe70c142d79f989d6c8566c2a Mon Sep 17 00:00:00 2001 From: johnharris85 Date: Sun, 17 Apr 2016 14:01:06 -0400 Subject: [PATCH 2260/4072] 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 2bbbe6145b9..8bf41632655 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1360,6 +1360,17 @@ def test_depends_on_unknown_service_errors(self): 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 f655a8af9567d58e9cfc0adff77960a42f7fd4be Mon Sep 17 00:00:00 2001 From: johnharris85 Date: Sun, 17 Apr 2016 15:25:06 -0400 Subject: [PATCH 2261/4072] 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 e4b3a253076..8c89cdf2b32 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 edfeb8b2313..cea740cb7b1 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 8bf41632655..488305586ee 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1366,7 +1366,7 @@ def test_linked_service_is_undefined(self): build_config_details({ 'version': '2', 'services': { - 'web': {'image': 'busybox', 'links': ['db']}, + 'web': {'image': 'busybox', 'links': ['db:db']}, }, }) ) From 0c1c338a02a8d4d4a76a04a6639b202fcc327fd3 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 19 Apr 2016 17:39:29 -0700 Subject: [PATCH 2262/4072] 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 7caae97d2da..de009146dd1 100644 --- a/setup.py +++ b/setup.py @@ -34,7 +34,7 @@ def find_version(*file_paths): '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 b334b6f059c7b3db359c85059dcf01f89c7a3224 Mon Sep 17 00:00:00 2001 From: Patrice FERLET Date: Wed, 20 Apr 2016 13:23:37 +0200 Subject: [PATCH 2263/4072] 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 b0faf610689..862a54f2995 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -140,8 +140,8 @@ def lookup(self, container, hostname): 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 85b85bc675d4625d678f9bfa38fe5739af183a4c Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Wed, 20 Apr 2016 16:22:24 -0700 Subject: [PATCH 2264/4072] 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 8c89cdf2b32..726750a3dcf 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 70a605acac3895468ee66a3ee809841c31763f63 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Wed, 20 Apr 2016 16:35:22 -0700 Subject: [PATCH 2265/4072] 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 d5df7ae55ae..d14cbbdd0c2 100644 --- a/compose/config/errors.py +++ b/compose/config/errors.py @@ -3,10 +3,11 @@ 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 726750a3dcf..7452e9849bb 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 b7f9fc4b289b7e8f21ce037f11987339fde2e70c Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 25 Apr 2016 17:58:20 -0700 Subject: [PATCH 2266/4072] 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 dd859edc4b7..fff4a543f4c 100644 --- a/compose/cli/utils.py +++ b/compose/cli/utils.py @@ -12,6 +12,13 @@ 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 d0b46ca9b205236fe8809c6b10f351feabd367f1 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 26 Apr 2016 11:58:41 -0400 Subject: [PATCH 2267/4072] 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 acf9b6aebfa..63fac3eb38a 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 11d8093fc8538379e1e693e6bd4ba1cacdf10839 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 26 Apr 2016 12:21:47 -0700 Subject: [PATCH 2268/4072] 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 b7160deec18..8ac3aff4fd2 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -21,12 +21,15 @@ 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 862a54f2995..1a4f9f53b3c 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -145,6 +145,13 @@ def test_help(self): # 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 2a08d4731eaf9697e69e4d46ff945593166c54db Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 26 Apr 2016 15:52:25 -0700 Subject: [PATCH 2269/4072] 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 0d891e45584..64ca7be7692 100644 --- a/compose/project.py +++ b/compose/project.py @@ -342,7 +342,10 @@ def build_container_event(event, container): 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 6bfdde685598ba2be48abffc96aa111052b21494 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 2270/4072] 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 a285a7908d7..be2625f889a 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 f316b448c2242c01167d13c47d5c8691ef8221b1 Mon Sep 17 00:00:00 2001 From: Aaron Nall Date: Wed, 27 Apr 2016 22:44:28 +0000 Subject: [PATCH 2271/4072] 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 ad3c0863177..7d43a98588e 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -415,7 +415,8 @@ def logs(self, options): 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 47a40d42c723cb603fb9f5d9608f6b4d8da8b06b Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 29 Apr 2016 16:37:26 -0700 Subject: [PATCH 2272/4072] 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 b9b0f403626..eb5275f4e19 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 de009146dd1..0b37c1dd4e5 100644 --- a/setup.py +++ b/setup.py @@ -34,7 +34,7 @@ def find_version(*file_paths): '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 3c424b709ea9092a71b46a04795c041e01e1f549 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 29 Apr 2016 19:42:07 -0700 Subject: [PATCH 2273/4072] 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 7d43a98588e..c0de1782513 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -24,6 +24,7 @@ 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 63417dcb0a4..50b2dbeaf40 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 64ca7be7692..d965c4a399c 100644 --- a/compose/project.py +++ b/compose/project.py @@ -390,13 +390,18 @@ def do(service): 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 @@ def __init__(self, name): 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 c413b9aa0b8..7ef492a561e 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -19,6 +19,7 @@ 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 @@ def test_up_with_network_static_addresses_missing_subnet(self): 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 45b0db1db45..479c0f1d371 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 0a9ab358bf7b4ff55764f8e0246cb380e6e7a2c8 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 4 May 2016 11:50:15 -0700 Subject: [PATCH 2274/4072] Bump 1.7.1 Signed-off-by: Joffrey F --- CHANGELOG.md | 41 +++++++++++++++++++++++++++++++++++++++++ compose/__init__.py | 2 +- docs/install.md | 6 +++--- script/run/run.sh | 2 +- 4 files changed, 46 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ee45386a18..0064a5cce8c 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/compose/__init__.py b/compose/__init__.py index b2062199a07..6c5bb8e7976 100644 --- a/compose/__init__.py +++ b/compose/__init__.py @@ -1,4 +1,4 @@ from __future__ import absolute_import from __future__ import unicode_literals -__version__ = '1.7.0' +__version__ = '1.7.1' diff --git a/docs/install.md b/docs/install.md index e8fede82aa5..76e4a868717 100644 --- a/docs/install.md +++ b/docs/install.md @@ -39,7 +39,7 @@ which the release page specifies, in your terminal. The following is an example command illustrating the format: - curl -L https://github.com/docker/compose/releases/download/1.7.0/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose + curl -L https://github.com/docker/compose/releases/download/1.7.1/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose If you have problems installing with `curl`, see [Alternative Install Options](#alternative-install-options). @@ -54,7 +54,7 @@ which the release page specifies, in your terminal. 7. Test the installation. $ docker-compose --version - docker-compose version: 1.7.0 + docker-compose version: 1.7.1 ## Alternative install options @@ -77,7 +77,7 @@ to get started. Compose can also be run inside a container, from a small bash script wrapper. To install compose as a container run: - $ curl -L https://github.com/docker/compose/releases/download/1.7.0/run.sh > /usr/local/bin/docker-compose + $ curl -L https://github.com/docker/compose/releases/download/1.7.1/run.sh > /usr/local/bin/docker-compose $ chmod +x /usr/local/bin/docker-compose ## Master builds diff --git a/script/run/run.sh b/script/run/run.sh index 98d32c5f8d3..c0ecc3dd424 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.7.0" +VERSION="1.7.1" IMAGE="docker/compose:$VERSION" From 790280ba7251da22ad0e3a7aa1ca4222376b963f Mon Sep 17 00:00:00 2001 From: Mihai Date: Thu, 5 May 2016 13:39:12 +0300 Subject: [PATCH 2275/4072] Ignore error output of `stty size` when stdin is not a terminal. Fixes #1876 Signed-off-by: Mihai Ciumeica --- compose/cli/formatter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/cli/formatter.py b/compose/cli/formatter.py index d0ed0f87eb2..0fa51d06a0a 100644 --- a/compose/cli/formatter.py +++ b/compose/cli/formatter.py @@ -10,7 +10,7 @@ def get_tty_width(): - tty_size = os.popen('stty size', 'r').read().split() + tty_size = os.popen('stty size 2> /dev/null', 'r').read().split() if len(tty_size) != 2: return 0 _, width = tty_size From 4b01f6dcd657636a6d05f453dfd58a6d3826ca5e Mon Sep 17 00:00:00 2001 From: Anton Simernia Date: Mon, 9 May 2016 18:15:32 +0700 Subject: [PATCH 2276/4072] 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 d965c4a399c..1b7fde23bc4 100644 --- a/compose/project.py +++ b/compose/project.py @@ -539,4 +539,5 @@ def __str__(self): 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 2277/4072] 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 98f97ef3243..45ead14387a 100755 --- a/script/test/versions.py +++ b/script/test/versions.py @@ -28,6 +28,7 @@ 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 2278/4072] 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 34e7f35c764..3ab2f96540d 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -334,6 +334,13 @@ def exec_command(self, options): """ 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 @@ def exec_command(self, options): 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 2279/4072] 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 3ab2f96540d..afde71506b7 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -532,17 +532,15 @@ def rm(self, options): 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 a02d0e99eb5..dfd75625ccf 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1192,8 +1192,6 @@ def test_rm_all(self): 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 2280/4072] 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 e52de4bf8d3..6cfce5da412 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -3,6 +3,7 @@ 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 488305586ee..26a1e08a6ab 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -2658,8 +2658,6 @@ def test_expand_path_with_tilde(self): 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 2281/4072] 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 fff4a543f4c..b58b50ef953 100644 --- a/compose/cli/utils.py +++ b/compose/cli/utils.py @@ -6,9 +6,9 @@ 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 2282/4072] 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 26a1e08a6ab..ccb3bcfe177 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -2664,7 +2664,15 @@ def test_split_path_mapping_with_windows_path(self): 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 2283/4072] 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 10bbbecc3d7..39941de27f4 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 2284/4072] 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 6cfce5da412..e1466f060d0 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 7ef492a561e..6e82e931f87 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -834,6 +834,42 @@ def test_project_up_logging_with_multiple_files(self): 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 ccb3bcfe177..24ece49946d 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 2285/4072] 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 e1466f060d0..97c427b9193 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -4,7 +4,6 @@ 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 2286/4072] 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 00000000000..a2e74f0a968 --- /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 2287/4072] 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 66747fbd541..763cafc4fc9 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 ec9cb682f7c..0da217dcbae 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 2288/4072] 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 8b9f64f0f93..64a464536bd 100644 --- a/compose/service.py +++ b/compose/service.py @@ -701,6 +701,12 @@ def build(self, no_cache=False, pull=False, force_rm=False): 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 @@ def build(self, no_cache=False, pull=False, force_rm=False): 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 a259c476fb5..ae2cab20838 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -445,7 +445,7 @@ def test_create_container(self): forcerm=False, nocache=False, rm=True, - buildargs=None, + buildargs={}, ) def test_ensure_image_exists_no_build(self): @@ -481,7 +481,33 @@ def test_ensure_image_exists_force_build(self): 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 2289/4072] 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 dfd75625ccf..4efaf0cf4d4 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1473,6 +1473,17 @@ def test_events_json(self): 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 @@ def test_events_human_readable(self): 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 2290/4072] 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 64a464536bd..8b9f64f0f93 100644 --- a/compose/service.py +++ b/compose/service.py @@ -701,12 +701,6 @@ def build(self, no_cache=False, pull=False, force_rm=False): 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 @@ def build(self, no_cache=False, pull=False, force_rm=False): 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 494beea3415..1e01fcb62f1 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 24ece49946d..3e5a7face61 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -715,7 +715,7 @@ def test_build_args_allow_empty_properties(self): ).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 ae2cab20838..a259c476fb5 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -445,7 +445,7 @@ def test_create_container(self): forcerm=False, nocache=False, rm=True, - buildargs={}, + buildargs=None, ) def test_ensure_image_exists_no_build(self): @@ -481,33 +481,7 @@ def test_ensure_image_exists_force_build(self): 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 2291/4072] 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 60482bce50b..ff944177b60 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 2292/4072] 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 1e01fcb62f1..925a8e791a5 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 3e5a7face61..0abb8daea94 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -717,6 +717,34 @@ def test_build_args_allow_empty_properties(self): 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 bc5246366fcd3842716abe4f7afc9ef8822730d0 Mon Sep 17 00:00:00 2001 From: Daniil Guzanov Date: Mon, 30 May 2016 12:21:25 +0300 Subject: [PATCH 2293/4072] Add issue link for zero-downtime deploys Signed-off-by: Daniil Guzanov --- ROADMAP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 287e54680e6..c2184e56a2c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -16,7 +16,7 @@ Some specific things we are considering: - It should roll back to a known good state if it fails. - It should allow a user to check the actions it is about to perform before running them. - It should be possible to partially modify the config file for different environments (dev/test/staging/prod), passing in e.g. custom ports, volume mount paths, or volume drivers. ([#1377](https://github.com/docker/compose/issues/1377)) -- Compose should recommend a technique for zero-downtime deploys. +- Compose should recommend a technique for zero-downtime deploys. ([#1786](https://github.com/docker/compose/issues/1786)) - It should be possible to continuously attempt to keep an application in the correct state, instead of just performing `up` a single time. ## Integration with Swarm From dd3590180da36f5359d6463003b49ea2fca90315 Mon Sep 17 00:00:00 2001 From: Sven Dowideit Date: Tue, 31 May 2016 21:18:42 +0000 Subject: [PATCH 2294/4072] 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 86ed32bc8e5..7b5a3b24654 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 b9ef0548287..e6629289b51 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 6a222697ec0..b4bcee97ef3 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 ff944177b60..8c706e4f099 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 55ba5f2d112..b1f01b3b6af 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 03ade35664e..ef07a45be5a 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 9acf64e5687..cfb8729363a 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 eef6b2f4bfc..f54d8286ac4 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 ece721939de..bbab6908793 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 11a2eab549f426904614cd93c34a03a6608235ee Mon Sep 17 00:00:00 2001 From: Milind Shakya Date: Thu, 2 Jun 2016 16:27:47 -0700 Subject: [PATCH 2295/4072] Replace assertEquals with assertEqual since the former is getting deprecated soon. Also, fix pep8 E309. Signed-off-by: Milind Shakya --- tests/acceptance/cli_test.py | 8 ++++---- tests/integration/service_test.py | 4 +++- tests/unit/cli_test.py | 14 +++++++------- tests/unit/config/config_test.py | 28 ++++++++++++++++++++-------- 4 files changed, 34 insertions(+), 20 deletions(-) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index dfd75625ccf..568b8efc254 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -842,8 +842,8 @@ def test_exec_without_tty(self): self.assertEqual(len(self.project.containers()), 1) stdout, stderr = self.dispatch(['exec', '-T', 'console', 'ls', '-1d', '/']) - self.assertEquals(stdout, "/\n") - self.assertEquals(stderr, "") + self.assertEqual(stdout, "/\n") + self.assertEqual(stderr, "") def test_exec_custom_user(self): self.base_dir = 'tests/fixtures/links-composefile' @@ -851,8 +851,8 @@ def test_exec_custom_user(self): 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, "") + self.assertEqual(stdout, "operator\n") + self.assertEqual(stderr, "") def test_run_service_without_links(self): self.base_dir = 'tests/fixtures/links-composefile' diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index df50d513a9e..38a8cc2b8d9 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -39,6 +39,7 @@ def create_and_start_container(service, **override_options): class ServiceTest(DockerClientTestCase): + def test_containers(self): foo = self.create_service('foo') bar = self.create_service('bar') @@ -940,7 +941,7 @@ def test_with_high_enough_api_version_we_get_default_network_mode(self): with mock.patch.object(self.client, '_version', '1.20'): service = self.create_service('web') service_config = service._get_container_host_config({}) - self.assertEquals(service_config['NetworkMode'], 'default') + self.assertEqual(service_config['NetworkMode'], 'default') def test_labels(self): labels_dict = { @@ -1044,6 +1045,7 @@ def converge(service, strategy=ConvergenceStrategy.changed): class ConfigHashTest(DockerClientTestCase): + def test_no_config_hash_when_one_off(self): web = self.create_service('web') container = web.create_container(one_off=True) diff --git a/tests/unit/cli_test.py b/tests/unit/cli_test.py index 2c90b29b72c..25afdbcc19d 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -29,36 +29,36 @@ def test_default_project_name(self): test_dir = py._path.local.LocalPath('tests/fixtures/simple-composefile') with test_dir.as_cwd(): project_name = get_project_name('.') - self.assertEquals('simplecomposefile', project_name) + self.assertEqual('simplecomposefile', project_name) def test_project_name_with_explicit_base_dir(self): base_dir = 'tests/fixtures/simple-composefile' project_name = get_project_name(base_dir) - self.assertEquals('simplecomposefile', project_name) + self.assertEqual('simplecomposefile', project_name) def test_project_name_with_explicit_uppercase_base_dir(self): base_dir = 'tests/fixtures/UpperCaseDir' project_name = get_project_name(base_dir) - self.assertEquals('uppercasedir', project_name) + self.assertEqual('uppercasedir', project_name) def test_project_name_with_explicit_project_name(self): name = 'explicit-project-name' project_name = get_project_name(None, project_name=name) - self.assertEquals('explicitprojectname', project_name) + self.assertEqual('explicitprojectname', project_name) @mock.patch.dict(os.environ) def test_project_name_from_environment_new_var(self): name = 'namefromenv' os.environ['COMPOSE_PROJECT_NAME'] = name project_name = get_project_name(None) - self.assertEquals(project_name, name) + self.assertEqual(project_name, name) def test_project_name_with_empty_environment_var(self): 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) + self.assertEqual('simplecomposefile', project_name) @mock.patch.dict(os.environ) def test_project_name_with_environment_file(self): @@ -158,7 +158,7 @@ def test_run_service_with_restart_always(self): '--workdir': None, }) - self.assertEquals( + self.assertEqual( mock_client.create_host_config.call_args[1]['restart_policy']['Name'], 'always' ) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 24ece49946d..49b109fdfbb 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -48,6 +48,7 @@ def service_sort(services): class ConfigTest(unittest.TestCase): + def test_load(self): service_dicts = config.load( build_config_details( @@ -1384,6 +1385,7 @@ def test_load_dockerfile_without_context(self): class NetworkModeTest(unittest.TestCase): + def test_network_mode_standard(self): config_data = config.load(build_config_details({ 'version': '2', @@ -1595,6 +1597,7 @@ def check_config(self, cfg): class InterpolationTest(unittest.TestCase): + @mock.patch.dict(os.environ) def test_config_file_with_environment_file(self): project_dir = 'tests/fixtures/default-env-file' @@ -1692,10 +1695,11 @@ def test_empty_environment_key_allowed(self): None, ) ).services[0] - self.assertEquals(service_dict['environment']['POSTGRES_PASSWORD'], '') + self.assertEqual(service_dict['environment']['POSTGRES_PASSWORD'], '') class VolumeConfigTest(unittest.TestCase): + def test_no_binding(self): d = make_service_dict('foo', {'build': '.', 'volumes': ['/data']}, working_dir='.') self.assertEqual(d['volumes'], ['/data']) @@ -1840,6 +1844,7 @@ class MergeDevicesTest(unittest.TestCase, MergePathMappingTest): class BuildOrImageMergeTest(unittest.TestCase): + def test_merge_build_or_image_no_override(self): self.assertEqual( config.merge_service_dicts({'build': '.'}, {}, V1), @@ -1928,6 +1933,7 @@ class MergeNetworksTest(unittest.TestCase, MergeListsTest): class MergeStringsOrListsTest(unittest.TestCase): + def test_no_override(self): service_dict = config.merge_service_dicts( {'dns': '8.8.8.8'}, @@ -1958,6 +1964,7 @@ def test_add_list(self): class MergeLabelsTest(unittest.TestCase): + def test_empty(self): assert 'labels' not in config.merge_service_dicts({}, {}, DEFAULT_VERSION) @@ -1998,6 +2005,7 @@ def test_remove_explicit_value(self): class MemoryOptionsTest(unittest.TestCase): + def test_validation_fails_with_just_memswap_limit(self): """ When you set a 'memswap_limit' it is invalid config unless you also set @@ -2040,6 +2048,7 @@ def test_memswap_can_be_a_string(self): class EnvTest(unittest.TestCase): + def test_parse_environment_as_list(self): environment = [ 'NORMAL=F1', @@ -2185,6 +2194,7 @@ def load_from_filename(filename): class ExtendsTest(unittest.TestCase): + def test_extends(self): service_dicts = load_from_filename('tests/fixtures/extends/docker-compose.yml') @@ -2376,9 +2386,9 @@ def test_extends_validation_valid_config(self): ) ).services - self.assertEquals(len(service), 1) + self.assertEqual(len(service), 1) self.assertIsInstance(service[0], dict) - self.assertEquals(service[0]['command'], "/bin/true") + self.assertEqual(service[0]['command'], "/bin/true") def test_extended_service_with_invalid_config(self): with pytest.raises(ConfigurationError) as exc: @@ -2390,7 +2400,7 @@ def test_extended_service_with_invalid_config(self): def test_extended_service_with_valid_config(self): service = load_from_filename('tests/fixtures/extends/service-with-valid-composite-extends.yml') - self.assertEquals(service[0]['command'], "top") + self.assertEqual(service[0]['command'], "top") def test_extends_file_defaults_to_self(self): """ @@ -2622,7 +2632,7 @@ def test_extends_with_defined_version_passes(self): """) service = load_from_filename(str(tmpdir.join('docker-compose.yml'))) - self.assertEquals(service[0]['command'], "top") + self.assertEqual(service[0]['command'], "top") def test_extends_with_depends_on(self): tmpdir = py.test.ensuretemp('test_extends_with_defined_version') @@ -2666,6 +2676,7 @@ def test_expand_path_with_tilde(self): class VolumePathTest(unittest.TestCase): + 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" @@ -2685,6 +2696,7 @@ def test_split_path_mapping_with_windows_path_in_container(self): @pytest.mark.xfail(IS_WINDOWS_PLATFORM, reason='paths use slash') class BuildPathTest(unittest.TestCase): + def setUp(self): self.abs_context_path = os.path.join(os.getcwd(), 'tests/fixtures/build-ctx') @@ -2707,7 +2719,7 @@ def test_relative_path(self): {'build': relative_build_path}, working_dir='tests/fixtures/build-path' ) - self.assertEquals(service_dict['build'], self.abs_context_path) + self.assertEqual(service_dict['build'], self.abs_context_path) def test_absolute_path(self): service_dict = make_service_dict( @@ -2715,11 +2727,11 @@ def test_absolute_path(self): {'build': self.abs_context_path}, working_dir='tests/fixtures/build-path' ) - self.assertEquals(service_dict['build'], self.abs_context_path) + self.assertEqual(service_dict['build'], self.abs_context_path) def test_from_file(self): service_dict = load_from_filename('tests/fixtures/build-path/docker-compose.yml') - self.assertEquals(service_dict, [{'name': 'foo', 'build': {'context': self.abs_context_path}}]) + self.assertEqual(service_dict, [{'name': 'foo', 'build': {'context': self.abs_context_path}}]) def test_valid_url_in_build_path(self): valid_urls = [ From ea640f38217e5d3796bbca49a5a1870582139d8d Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 17 May 2016 16:32:54 -0700 Subject: [PATCH 2296/4072] 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 1b498c01633..52de77b8359 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 a02d0e99eb5..6bb111ef9af 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -224,6 +224,20 @@ def test_config_restart(self): '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 2297/4072] 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 50e58ddca61..16bccf98b72 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 2298/4072] 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 b55f250a8b6..501269b7a24 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 2299/4072] 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 b58b50ef953..cc2b680de5c 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 2300/4072] 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 97c427b9193..7a2b3d36641 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 24ece49946d..89c424a4ce7 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -2682,6 +2682,13 @@ def test_split_path_mapping_with_windows_path_in_container(self): 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 2301/4072] 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 df50d513a9e..1b21e1e9c20 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -593,12 +593,12 @@ def test_build_with_build_args(self): 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 2302/4072] 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 055ae7ed190..e79da86201f 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 00000000000..a6d0d2d13c7 --- /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 8ac3aff4fd2..44112fce626 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 afde71506b7..3e440463015 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -14,10 +14,10 @@ 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 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 @@ def build(self, options): 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 @@ def config(self, config_options, options): --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 @@ def pull(self, options): 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 52de77b8359..b788a55de86 100644 --- a/compose/config/serialize.py +++ b/compose/config/serialize.py @@ -18,7 +18,7 @@ def serialize_config_type(dumper, data): 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 1f873d1d9f0..a0f5601f111 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 1b7fde23bc4..676b6ae8c90 100644 --- a/compose/project.py +++ b/compose/project.py @@ -440,6 +440,10 @@ def pull(self, service_names=None, ignore_pull_failures=False): for service in self.get_services(service_names, include_deps=False): service.pull(ignore_pull_failures) + def 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 8b9f64f0f93..af572e5b5f7 100644 --- a/compose/service.py +++ b/compose/service.py @@ -15,6 +15,7 @@ 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 @@ def pull(self, ignore_pull_failures=False): 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 2303/4072] Support entrypoint, labels, and user in the bundle. Signed-off-by: Daniel Nephin --- .dockerignore | 1 - compose/bundle.py | 62 ++++++++++++++++++++++++++++++++++++++--------- 2 files changed, 50 insertions(+), 13 deletions(-) diff --git a/.dockerignore b/.dockerignore index e79da86201f..055ae7ed190 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 a6d0d2d13c7..e93c5bd9c3e 100644 --- a/compose/bundle.py +++ b/compose/bundle.py @@ -5,11 +5,13 @@ 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 @@ 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: + }) + continue + + if key in SERVICE_KEYS: container_config[SERVICE_KEYS[key]] = value - else: - log.warn("Unsupported key '{}' in services.{} - ignoring".format(key, name)) + 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 2304/4072] 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 28adff3f349..50fc84e17f6 100644 --- a/tests/unit/cli/command_test.py +++ b/tests/unit/cli/command_test.py @@ -55,6 +55,7 @@ def test_get_tls_version_default(self): 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 2305/4072] 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 b4bcee97ef3..1cf2a5675c5 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 8c706e4f099..249bff725e6 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 f54d8286ac4..267776872e9 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 80afbd3961800532d184a5b04b57a2f411bdb8c3 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 14 Jun 2016 12:23:04 -0700 Subject: [PATCH 2306/4072] 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 28adff3f349..50fc84e17f6 100644 --- a/tests/unit/cli/command_test.py +++ b/tests/unit/cli/command_test.py @@ -55,6 +55,7 @@ def test_get_tls_version_default(self): 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 9bf6bc6dbdb42c61defe97b98ec83f5f6ba63b98 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 14 Jun 2016 11:33:45 -0700 Subject: [PATCH 2307/4072] Bump 1.8.0-rc1 Signed-off-by: Joffrey F --- CHANGELOG.md | 91 +++++++++++++++++++++++++++++++++++++++++++++ compose/__init__.py | 2 +- docs/install.md | 6 +-- script/run/run.sh | 2 +- 4 files changed, 96 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ee45386a18..39ac86982c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,97 @@ 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) +----------------- + +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/compose/__init__.py b/compose/__init__.py index 1052c0670bf..1dd11e7914b 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 95416e7ae8b..5191a4b58ad 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.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.6.2 + 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.6.2/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 98d32c5f8d3..f9199ce1581 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 020d46ff21764a57fa21e4f3ccc1ba09a1345049 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 14 Jun 2016 16:03:56 -0700 Subject: [PATCH 2308/4072] 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 e93c5bd9c3e..965d65c876c 100644 --- a/compose/bundle.py +++ b/compose/bundle.py @@ -40,6 +40,22 @@ 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 3e440463015..25ee90507a8 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -15,6 +15,7 @@ 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 @@ 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. + 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 @@ def bundle(self, config_options, options): 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 2309/4072] 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 25ee90507a8..96ad847f201 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -664,7 +664,7 @@ def run(self, options): 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 2310/4072] 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 df50d513a9e..1801f5bfc78 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -397,7 +397,7 @@ def test_execute_convergence_plan_when_host_volume_is_removed(self): 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 2311/4072] 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 763cafc4fc9..de25cd5702e 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 2312/4072] 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 763cafc4fc9..058ede10595 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 2313/4072] 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 b55f250a8b6..682dacd3f32 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 2314/4072] 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 0da217dcbae..539482e5b1a 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 2315/4072] 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 0da217dcbae..42876ec985a 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 2316/4072] 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 0da217dcbae..f75d412d84c 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 2317/4072] 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 f75d412d84c..1286b21cee2 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 2318/4072] 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 1286b21cee2..b5e4476221a 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 2319/4072] 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 b5e4476221a..2b82d4eb09d 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 2320/4072] 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 2b82d4eb09d..b6cdb0a655c 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 2321/4072] 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 b6cdb0a655c..54bea10efce 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 2322/4072] 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 54bea10efce..e6990b77e3f 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 2323/4072] 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 e6990b77e3f..eeed07a6692 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 2324/4072] 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 eeed07a6692..7fc692cf7c1 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 2325/4072] 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 7fc692cf7c1..27b17b07539 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 2326/4072] 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 27b17b07539..c7df4b4467c 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 2327/4072] 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 c7df4b4467c..77348d5c402 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 2328/4072] 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 77348d5c402..a59abe290c0 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 2329/4072] 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 25ee90507a8..ae0175d5f8e 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -217,7 +217,7 @@ def build(self, options): 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 @@ def bundle(self, config_options, options): --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 2330/4072] 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 068d0efc1c8..85d5776b303 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1135,7 +1135,10 @@ def test_run_interactive_connects_to_network(self): ] 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 @@ def test_run_detached_connects_to_network(self): ] 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 2331/4072] 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 965d65c876c..8a1e859e317 100644 --- a/compose/bundle.py +++ b/compose/bundle.py @@ -57,17 +57,7 @@ def __init__(self, needs_push, needs_pull): 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 00000000000..ff4c0dceb58 --- /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 b01be11a860..c0cb906dd16 100644 --- a/tests/unit/progress_stream_test.py +++ b/tests/unit/progress_stream_test.py @@ -65,3 +65,23 @@ def test_stream_output_no_progress_event_no_tty(self): 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 2332/4072] 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 8a1e859e317..44f6954b71e 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 068d0efc1c8..7aef7b52dd1 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -12,6 +12,7 @@ from collections import namedtuple from operator import attrgetter +import py import yaml from docker import errors @@ -378,6 +379,32 @@ def test_build_failed_forcerm(self): ] 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 00000000000..b701351209e --- /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 2333/4072] 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 eb5275f4e19..160bd0eff3f 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 0b37c1dd4e5..3696adc6241 100644 --- a/setup.py +++ b/setup.py @@ -34,7 +34,7 @@ def find_version(*file_paths): '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 2334/4072] 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 539482e5b1a..2995932d156 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 2335/4072] 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 eb5275f4e19..d4748aa1652 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 2336/4072] 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 0adfdca8475..0201bcb28ac 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 2337/4072] 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 8c10b5f3f88..464cf271639 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 2338/4072] 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 ae0175d5f8e..5d2b4fa20a3 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 ff08b7714b0..5d6b5af690c 100644 --- a/compose/config/environment.py +++ b/compose/config/environment.py @@ -60,6 +60,18 @@ def _initialize(): 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 7b1785eef11..43fe5650ec7 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1216,6 +1216,14 @@ def test_run_handles_sigterm(self): '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 2339/4072] 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 5d2b4fa20a3..f4c1716770d 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -19,6 +19,7 @@ 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 2340/4072] 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 43fe5650ec7..646626546bc 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1220,9 +1220,13 @@ def test_run_handles_sigterm(self): 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 2341/4072] 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 4cce1bc844e..cd4fcc6d1bf 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 a23667bffc5..d4b34786cf3 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 2342/4072] 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 f4c1716770d..ff6dba11d21 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 2343/4072] 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 7a2b3d36641..d3ab1d4be3d 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -70,6 +70,7 @@ '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 e84d13179f6..c08fa4d7a02 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 af572e5b5f7..73381466632 100644 --- a/compose/service.py +++ b/compose/service.py @@ -53,6 +53,7 @@ 'log_opt', 'mem_limit', 'memswap_limit', + 'oom_score_adj', 'pid', 'privileged', 'restart', @@ -695,6 +696,7 @@ def _get_container_host_config(self, override_options, one_off=False): 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 464cf271639..f7b5a931ce3 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 1801f5bfc78..02f9cc0ba0c 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -854,6 +854,11 @@ def test_restart_always_value(self): 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 2dad224ba94..1be8aefa207 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1243,6 +1243,26 @@ def test_tmpfs_option(self): } ] + 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 2344/4072] 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 2c68d36db96..89a7a9492b1 100644 --- a/compose/cli/errors.py +++ b/compose/cli/errors.py @@ -15,6 +15,7 @@ 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 @@ def exit_with_error(msg): 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 cc2b680de5c..bf5df80ca7c 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 2345/4072] 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 646626546bc..a8fd3249d0c 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1164,7 +1164,7 @@ def test_run_interactive_connects_to_network(self): 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 @@ def test_run_detached_connects_to_network(self): 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 44e82edc5f11d02c9207b184d2748b5b4734c06f Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 29 Jun 2016 11:08:08 -0700 Subject: [PATCH 2346/4072] Merge pull request #3221 from aeriksson/fix-zsh-autocomplete Fix zsh autocomplete (cherry picked from commit c0237a487b31e1d3e4b67fb180bd7db219e5d858) Signed-off-by: Aanand Prasad --- contrib/completion/zsh/_docker-compose | 159 ++++++++++++++++--------- 1 file changed, 105 insertions(+), 54 deletions(-) diff --git a/contrib/completion/zsh/_docker-compose b/contrib/completion/zsh/_docker-compose index 0da217dcbae..a59abe290c0 100644 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -19,52 +19,49 @@ # * @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 +__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() { +# All services defined in 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_q config --services \ + | grep -Ev "^(${already_selected})$" } # All services, even those without an existing container __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 " " "|") # 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_q config \ + | sed -n -e '/^services:/,/^[^ ]/p' \ + | sed -n 's/^ //p' \ + | awk '/^[a-zA-Z0-9]/{printf "\n"};{printf $0;next;}' \ + | grep " \+$1:" \ + | cut -d: -f1 \ + | grep -Ev "^(${already_selected})$" } # All services that are defined by a Dockerfile reference __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 @@ -74,7 +71,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 @@ -96,7 +93,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 @@ -185,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 @@ -193,7 +200,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 ;; @@ -206,21 +213,23 @@ __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]" \ + $opts_force_recreate \ + $opts_no_recreate \ + $opts_no_build \ + "(--no-build)--build[Build images before creating containers.]" \ '*:services:__docker-compose_services_all' && 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 + "--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.]" \ + $opts_remove_orphans && 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) @@ -230,7 +239,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 @@ -248,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 @@ -261,8 +270,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 ;; @@ -282,7 +291,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) @@ -291,14 +300,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: ' \ - "--no-deps[Don't start linked services.]" \ - '(-p --publish)'{-p,--publish=-}"[Run command with manually mapped container's port(s) to the host.]" \ + '--name=[Assign a name to the container]:name: ' \ + $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.]" \ '-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 @@ -306,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) @@ -317,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 +337,16 @@ __docker-compose_subcommand() { (up) _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.]" \ - "--no-recreate[If containers already exist, don't recreate them.]" \ - "--no-build[Don't build an image, even if it's missing]" \ + '(--abort-on-container-exit)-d[Detached mode: Run containers in the background, print new container names. Incompatible with --abort-on-container-exit.]' \ + $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}"[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: " \ + $opts_remove_orphans \ '*:services:__docker-compose_services_all' && ret=0 ;; (version) @@ -366,16 +376,57 @@ _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 - 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 33cc601176addaedf44d8f3bafd576f38bd6312e Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 14 Jun 2016 16:03:56 -0700 Subject: [PATCH 2347/4072] 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 e93c5bd9c3e..965d65c876c 100644 --- a/compose/bundle.py +++ b/compose/bundle.py @@ -40,6 +40,22 @@ 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 3e440463015..25ee90507a8 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -15,6 +15,7 @@ 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 @@ 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. + 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 @@ def bundle(self, config_options, options): 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 db02c9f5372855a406227ccbc396237724a753bd Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 14 Jun 2016 16:55:48 -0400 Subject: [PATCH 2348/4072] 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 965d65c876c..8a1e859e317 100644 --- a/compose/bundle.py +++ b/compose/bundle.py @@ -57,17 +57,7 @@ def __init__(self, needs_push, needs_pull): 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 00000000000..ff4c0dceb58 --- /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 b01be11a860..c0cb906dd16 100644 --- a/tests/unit/progress_stream_test.py +++ b/tests/unit/progress_stream_test.py @@ -65,3 +65,23 @@ def test_stream_output_no_progress_event_no_tty(self): 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 a7fc3e222012a926b0f7bb94fc96d1109266fe6d Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 16 Jun 2016 15:15:17 -0400 Subject: [PATCH 2349/4072] 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 8a1e859e317..44f6954b71e 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 068d0efc1c8..7aef7b52dd1 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -12,6 +12,7 @@ from collections import namedtuple from operator import attrgetter +import py import yaml from docker import errors @@ -378,6 +379,32 @@ def test_build_failed_forcerm(self): ] 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 00000000000..b701351209e --- /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 6fd77fa698c97f17b6eb14677207fdeee011dc5e Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 16 Jun 2016 15:10:56 -0700 Subject: [PATCH 2350/4072] 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 eb5275f4e19..160bd0eff3f 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 0b37c1dd4e5..3696adc6241 100644 --- a/setup.py +++ b/setup.py @@ -34,7 +34,7 @@ def find_version(*file_paths): '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 a3e30c3eedf93978781d8c560e0d6ed534fd904d Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Wed, 22 Jun 2016 17:04:22 +0200 Subject: [PATCH 2351/4072] 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 763cafc4fc9..de25cd5702e 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 85e3ad2655c6ba4cd8ab0a5d7276a5a6beac3201 Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Wed, 22 Jun 2016 17:19:20 +0200 Subject: [PATCH 2352/4072] 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 de25cd5702e..0adfdca8475 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -316,6 +316,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) @@ -480,6 +492,7 @@ _docker_compose() { port ps pull + push restart rm run From 72849d99c0015c9c5f70e11e568df70a21211d7c Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 22 Jun 2016 14:53:45 -0700 Subject: [PATCH 2353/4072] 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 25ee90507a8..ae0175d5f8e 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -217,7 +217,7 @@ def build(self, options): 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 @@ def bundle(self, config_options, options): --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 21d114b879314a29d199536dca2e4ac52587c045 Mon Sep 17 00:00:00 2001 From: Ke Xu Date: Mon, 27 Jun 2016 17:48:14 +0900 Subject: [PATCH 2354/4072] 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 a59abe290c0..d9c37be4e5e 100644 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -204,6 +204,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 6e3d82eea6cfae1d48d196c7f8360485e3a0c76f Mon Sep 17 00:00:00 2001 From: Ke Xu Date: Wed, 29 Jun 2016 11:01:50 +0900 Subject: [PATCH 2355/4072] 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 d9c37be4e5e..540fb423137 100644 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -207,7 +207,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 05bf9a054ad629b5afe6b215c00c0151b3f2259b Mon Sep 17 00:00:00 2001 From: Ke Xu Date: Mon, 27 Jun 2016 18:00:52 +0900 Subject: [PATCH 2356/4072] 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 540fb423137..2947cef3824 100644 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -292,6 +292,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 6b71645ed7ab4159ccfbb4d64f18de88a50b9db0 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 27 Jun 2016 16:21:19 -0700 Subject: [PATCH 2357/4072] 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 7aef7b52dd1..7b1785eef11 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1162,7 +1162,10 @@ def test_run_interactive_connects_to_network(self): ] 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): @@ -1179,7 +1182,10 @@ def test_run_detached_connects_to_network(self): ] 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 70da16103aaf4753494cc380f4e5d73910772ddb Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 22 Apr 2016 14:45:51 -0700 Subject: [PATCH 2358/4072] 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 ae0175d5f8e..5d2b4fa20a3 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 ff08b7714b0..5d6b5af690c 100644 --- a/compose/config/environment.py +++ b/compose/config/environment.py @@ -60,6 +60,18 @@ def _initialize(): 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 7b1785eef11..43fe5650ec7 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1216,6 +1216,14 @@ def test_run_handles_sigterm(self): '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 217f762a60b0e43b831f23c80d57379456814ed0 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 30 Jun 2016 15:18:05 -0700 Subject: [PATCH 2359/4072] 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 5d2b4fa20a3..f4c1716770d 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -19,6 +19,7 @@ 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 244b3036252e1b25cb37386532c1c75130cc0ed9 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 30 Jun 2016 16:26:12 -0700 Subject: [PATCH 2360/4072] 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 43fe5650ec7..646626546bc 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1220,9 +1220,13 @@ def test_run_handles_sigterm(self): 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 d8ec9c1572115f95ee5d3446c0c605b27c95790f Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 30 Jun 2016 18:40:39 -0400 Subject: [PATCH 2361/4072] 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 4cce1bc844e..cd4fcc6d1bf 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 a23667bffc5..d4b34786cf3 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 e4159cfd42727d77f7ca2c90cb4ea494b233e980 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 6 Jul 2016 09:56:25 -0700 Subject: [PATCH 2362/4072] 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 646626546bc..a8fd3249d0c 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1164,7 +1164,7 @@ def test_run_interactive_connects_to_network(self): 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 @@ def test_run_detached_connects_to_network(self): 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 6021237a698934b5807dd6554f002bd46364daab Mon Sep 17 00:00:00 2001 From: Victoria Bialas Date: Tue, 14 Jun 2016 12:24:30 -0700 Subject: [PATCH 2363/4072] 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 b4bcee97ef3..1cf2a5675c5 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 8c706e4f099..249bff725e6 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 f54d8286ac4..267776872e9 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 dbf40d82442892feb0e94ce54fd94e513d2a57dd Mon Sep 17 00:00:00 2001 From: Jonathan Giannuzzi Date: Mon, 20 Jun 2016 14:48:45 +0200 Subject: [PATCH 2364/4072] 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 df50d513a9e..1801f5bfc78 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -397,7 +397,7 @@ def test_execute_convergence_plan_when_host_volume_is_removed(self): 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 408e4719e1c26258165f2e9f69552a017eef8131 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 29 Jun 2016 10:25:47 -0400 Subject: [PATCH 2365/4072] 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 160bd0eff3f..60260e1c27c 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.9.0rc2 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 08127625a0de1a50bf4e1a1f9861c669fe778be4 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 6 Jul 2016 18:01:27 -0700 Subject: [PATCH 2366/4072] 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 792077ad7fe..4e76d64ffac 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 801167d2710f35b73985e1ebbbab9b47c681c9b6 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 6 Jul 2016 18:01:27 -0700 Subject: [PATCH 2367/4072] 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 792077ad7fe..4e76d64ffac 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 c72c966abcebf15496af8ef29c8143967089aee3 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 6 Jul 2016 16:08:07 -0700 Subject: [PATCH 2368/4072] 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 39ac86982c3..afa35820f68 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 1dd11e7914b..bf8a6f3068b 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 5191a4b58ad..d1a11ab55be 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 f9199ce1581..caf6ed11944 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 49d4fd27952433feb20bc22117aba4766c15c1c1 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 7 Jul 2016 11:41:11 -0700 Subject: [PATCH 2369/4072] 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 8ee45386a18..0064a5cce8c 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 95416e7ae8b..76e4a868717 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 2370/4072] 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 3e0873c49ab..bed6be79455 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 9e00d96e9fd..b930e0bf0e7 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 2371/4072] 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 89a7a9492b1..5af3ede9fff 100644 --- a/compose/cli/errors.py +++ b/compose/cli/errors.py @@ -13,7 +13,6 @@ 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 5334a9440a5..74669d4a593 100644 --- a/tests/unit/cli/docker_client_test.py +++ b/tests/unit/cli/docker_client_test.py @@ -6,6 +6,7 @@ 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 @@ def test_docker_client_no_home(self): 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 2372/4072] 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 c924d89dcb3..ed15d6a5954 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -32,6 +32,7 @@ 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 00000000000..9f68760d327 --- /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 50b2dbeaf40..7ac66b37a01 100644 --- a/compose/parallel.py +++ b/compose/parallel.py @@ -12,6 +12,7 @@ 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 73381466632..60343542b08 100644 --- a/compose/service.py +++ b/compose/service.py @@ -27,6 +27,7 @@ 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 @@ def create_container(self, 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 @@ def start_container_if_stopped(self, container, attach_logs=False, quiet=False): 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 7d2f03d355a..97ad7476381 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -738,7 +738,10 @@ def test_scale_with_api_error(self): 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 2373/4072] 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 c08fa4d7a02..ac46944cc1b 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 affba7c2d9c..8962a8920c7 100644 --- a/compose/network.py +++ b/compose/network.py @@ -15,7 +15,7 @@ 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 @@ def __init__(self, client, project, name, driver=None, driver_opts=None, 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 @@ def ensure(self): 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 f7b5a931ce3..59fcf3317a6 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 a8fd3249d0c..dad23bec59b 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -576,6 +576,24 @@ def test_up_with_network_aliases(self): 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 00000000000..1fa339b1f85 --- /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 6e82e931f87..80915c1aeb9 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -756,6 +756,33 @@ def test_up_with_network_static_addresses_missing_subnet(self): 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 1be8aefa207..d88c1d47e9d 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -101,6 +101,10 @@ def test_load_v2(self): {'subnet': '172.28.0.0/16'} ] } + }, + 'internal': { + 'driver': 'bridge', + 'internal': True } } }, 'working_dir', 'filename.yml') @@ -140,6 +144,10 @@ def test_load_v2(self): {'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 2374/4072] 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 c924d89dcb3..e33bbe6edde 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -668,8 +668,10 @@ def run(self, options): '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 dad23bec59b..a0d1702cb3d 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -4,7 +4,6 @@ import datetime import json import os -import shlex import signal import subprocess import time @@ -983,16 +982,54 @@ def test_run_without_command(self): [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 786315020e8..00000000000 --- 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 00000000000..e9880973fb2 --- /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 e7454e59b0f..49f4416c8cc 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 00000000000..8318e61f31b --- /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 2375/4072] 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 59fcf3317a6..d286257d35f 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 2376/4072] 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 d286257d35f..fce3f1bcc17 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 2377/4072] 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 fce3f1bcc17..d6d0cadc7b1 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 2378/4072] 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 dad23bec59b..84d401e3350 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -114,6 +114,8 @@ def tearDown(self): 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 2379/4072] 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 8d69d53194c..3e33a6c0f71 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -63,6 +63,10 @@ def setUpClass(cls): 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 2380/4072] 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 b544783a4e7..2a2d1b56eab 100644 --- a/tests/integration/resilience_test.py +++ b/tests/integration/resilience_test.py @@ -20,6 +20,11 @@ def setUp(self): 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 2381/4072] 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 706179ed2f8..04922ccdc7d 100644 --- a/tests/integration/volume_test.py +++ b/tests/integration/volume_test.py @@ -17,6 +17,7 @@ def tearDown(self): 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 2382/4072] 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 04922ccdc7d..a75250ac713 100644 --- a/tests/integration/volume_test.py +++ b/tests/integration/volume_test.py @@ -18,6 +18,7 @@ def tearDown(self): 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 958758ff6d6e8fddc45c51a92f11ef8893f29a1d Mon Sep 17 00:00:00 2001 From: Nikola Kovacs Date: Fri, 22 Jul 2016 17:42:42 +0200 Subject: [PATCH 2383/4072] Remove anonymous volumes when using run --rm. Named volumes will not be removed. This is consistent with the behavior of docker run --rm. Fixes #2419, #3611 Signed-off-by: Nikola Kovacs --- compose/cli/main.py | 2 +- tests/acceptance/cli_test.py | 30 ++++++++++++++++++++++++ tests/fixtures/volume/docker-compose.yml | 11 +++++++++ 3 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/volume/docker-compose.yml diff --git a/compose/cli/main.py b/compose/cli/main.py index 3f153d0d8fb..d920779346f 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -947,7 +947,7 @@ def run_one_off_container(container_options, project, service, options): def remove_container(force=False): if options['--rm']: - project.client.remove_container(container.id, force=True) + project.client.remove_container(container.id, force=True, v=True) signals.set_signal_handler_to_shutdown() try: diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 7641870b62d..e9b114a0e86 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -984,6 +984,36 @@ def test_run_without_command(self): [u'/bin/true'], ) + def test_run_rm(self): + self.base_dir = 'tests/fixtures/volume' + proc = start_process(self.base_dir, ['run', '--rm', 'test']) + wait_on_condition(ContainerStateCondition( + self.project.client, + 'volume_test_run_1', + 'running')) + service = self.project.get_service('test') + containers = service.containers(one_off=OneOffFilter.only) + self.assertEqual(len(containers), 1) + mounts = containers[0].get('Mounts') + for mount in mounts: + if mount['Destination'] == '/container-path': + anonymousName = mount['Name'] + break + os.kill(proc.pid, signal.SIGINT) + wait_on_process(proc, 1) + + self.assertEqual(len(service.containers(stopped=True, one_off=OneOffFilter.only)), 0) + + volumes = self.client.volumes()['Volumes'] + assert volumes is not None + for volume in service.options.get('volumes'): + if volume.internal == '/container-named-path': + name = volume.external + break + volumeNames = [v['Name'] for v in volumes] + assert name in volumeNames + assert anonymousName not in volumeNames + def test_run_service_with_dockerfile_entrypoint(self): self.base_dir = 'tests/fixtures/entrypoint-dockerfile' self.dispatch(['run', 'test']) diff --git a/tests/fixtures/volume/docker-compose.yml b/tests/fixtures/volume/docker-compose.yml new file mode 100644 index 00000000000..4335b0a094f --- /dev/null +++ b/tests/fixtures/volume/docker-compose.yml @@ -0,0 +1,11 @@ +version: '2' +services: + test: + image: busybox + command: top + volumes: + - /container-path + - testvolume:/container-named-path + +volumes: + testvolume: {} From 07e2426d89750d8eddabe9537d527ac46197e753 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 25 Jul 2016 13:38:04 +0100 Subject: [PATCH 2384/4072] 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 b1fb25dc45b..905f52f8007 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 2385/4072] 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 676b6ae8c90..256fb9c08a0 100644 --- a/compose/project.py +++ b/compose/project.py @@ -369,6 +369,8 @@ def up(self, detached=False, remove_orphans=False): + warn_for_swarm_mode(self.client) + self.initialize() self.find_orphan_containers(remove_orphans) @@ -533,6 +535,20 @@ def build_volume_from(spec): 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 b6a52e08d05..9569adc907f 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -510,3 +510,35 @@ def test_down_with_no_resources(self): 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 2386/4072] 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 256fb9c08a0..f85e285f3de 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 00000000000..0958e1ef8aa --- /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 2387/4072] Rename --fetch-digests to --push-images and remove auto-pull Signed-off-by: Aanand Prasad --- compose/bundle.py | 39 ++++++++++++++++++--------------------- compose/cli/main.py | 37 +++++++++++++++++++++++++++---------- tests/unit/bundle_test.py | 34 ++++++++++------------------------ 3 files changed, 55 insertions(+), 55 deletions(-) diff --git a/compose/bundle.py b/compose/bundle.py index 44f6954b71e..afbdabfa81f 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) + if 'build' not in service.options: + raise NeedsPull(service.image_name) - return fetch_image_digest(service) + if not allow_push: + raise NeedsPush(service.image_name) + return push_image(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 + +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 3f153d0d8fb..db06a5e143b 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -223,15 +223,16 @@ def bundle(self, config_options, options): 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 @@ def bundle(self, config_options, options): 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 @@ def list_images(images): 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)] - - paras.append("If this is OK, run `docker-compose bundle --fetch-digests`.") + command_hint = ( + "Use `docker-compose pull {}` to pull them. " + .format(" ".join(sorted(e.needs_pull))) + ) + + paras += [ + "The following images need to be pulled:", + list_images(e.needs_pull), + command_hint, + ] raise UserError("\n\n".join(paras)) diff --git a/tests/unit/bundle_test.py b/tests/unit/bundle_test.py index ff4c0dceb58..223b3b07a2c 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): +def test_push_image_with_saved_digest(mock_service): + mock_service.options['build'] = '.' mock_service.options['image'] = image_id = 'abcd' - mock_service.pull.return_value = expected = 'sha256:thedigest' + 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.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) + digest = bundle.push_image(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) + mock_service.push.assert_called_once_with() + assert not mock_service.client.push.called -def test_fetch_image_digest_for_build(mock_service): +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': ['digest1']} + mock_service.image.return_value = {'RepoDigests': []} - 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 + 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 2388/4072] 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 0958e1ef8aa..a56adb020c3 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 2389/4072] 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 a56adb020c3..19322824221 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 2390/4072] 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 19322824221..5ca2c1ec3cc 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 2391/4072] 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 00000000000..fca93a8aa66 --- /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 00000000000..bdc3112e83e --- /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 2392/4072] 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 60260e1c27c..831ed65a9eb 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 3696adc6241..5cb52dae4a3 100644 --- a/setup.py +++ b/setup.py @@ -34,7 +34,7 @@ def find_version(*file_paths): '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 2393/4072] 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 bed6be79455..ce191fbf549 100644 --- a/compose/cli/docker_client.py +++ b/compose/cli/docker_client.py @@ -10,6 +10,7 @@ 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 bf5df80ca7c..f60f61cd042 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 74669d4a593..fc914791caf 100644 --- a/tests/unit/cli/docker_client_test.py +++ b/tests/unit/cli/docker_client_test.py @@ -2,10 +2,12 @@ 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 @@ def test_custom_timeout_error(self): 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 2394/4072] 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 0201bcb28ac..991f6572941 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 2947cef3824..928e28defa9 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 f7853a30bdb7177808a6f7366395d7bcbdcdff52 Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Wed, 29 Jun 2016 08:45:36 -0700 Subject: [PATCH 2395/4072] 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 0adfdca8475..0201bcb28ac 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 e1b7510e4a8588df320f87ec4e24623a8e9493c4 Mon Sep 17 00:00:00 2001 From: Tomas Tomecek Date: Thu, 21 Apr 2016 14:03:02 +0200 Subject: [PATCH 2396/4072] 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 f4c1716770d..7ca9eac957a 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -32,6 +32,7 @@ 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 00000000000..9f68760d327 --- /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 50b2dbeaf40..7ac66b37a01 100644 --- a/compose/parallel.py +++ b/compose/parallel.py @@ -12,6 +12,7 @@ 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 af572e5b5f7..7bb36cd6452 100644 --- a/compose/service.py +++ b/compose/service.py @@ -27,6 +27,7 @@ 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 @@ -277,7 +278,11 @@ def create_container(self, 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: @@ -447,7 +452,10 @@ def start_container_if_stopped(self, container, attach_logs=False, quiet=False): 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 1801f5bfc78..053dee1b530 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -738,7 +738,10 @@ def test_scale_with_api_error(self): 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 4fb7033d9cf6905a6f54d804b4ca6c48827f6fea Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 20 Jul 2016 12:23:51 +0100 Subject: [PATCH 2397/4072] 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 b55f250a8b6..0f1fccb31df 100644 --- a/docs/compose-file.md +++ b/docs/compose-file.md @@ -274,6 +274,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 @@ -293,6 +298,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 ad19ff6c67554168966ffaaeaa0f2c82231ca35d Mon Sep 17 00:00:00 2001 From: Jarrod Pooler Date: Fri, 8 Jul 2016 15:56:15 -0400 Subject: [PATCH 2398/4072] 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 0f1fccb31df..ea1671da038 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 8f842d55d70574ea29f635e7ab7dbaa1e64649f6 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 20 Jul 2016 12:47:47 +0100 Subject: [PATCH 2399/4072] 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 ea1671da038..d33bc20806b 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 2ecbf25445856fcf1b06a2ff56f04e4ad628042a Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 11 Jul 2016 16:13:16 -0400 Subject: [PATCH 2400/4072] 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 7ca9eac957a..3aa1459a384 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -670,8 +670,10 @@ def run(self, options): 'can not be used togather' ) - 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 a8fd3249d0c..ffba3002d36 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -4,7 +4,6 @@ import datetime import json import os -import shlex import signal import subprocess import time @@ -965,16 +964,54 @@ def test_run_without_command(self): [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 786315020e8..00000000000 --- 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 00000000000..e9880973fb2 --- /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 e7454e59b0f..49f4416c8cc 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 00000000000..8318e61f31b --- /dev/null +++ b/tests/fixtures/entrypoint-dockerfile/docker-compose.yml @@ -0,0 +1,4 @@ +version: "2" +services: + test: + build: . From f9c5816ab8dc7de4a11457966b98d5f658889c3e Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 7 Jul 2016 11:58:10 -0700 Subject: [PATCH 2401/4072] 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 3e0873c49ab..bed6be79455 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 9e00d96e9fd..b930e0bf0e7 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 b72f911ccf10bb20b9c681c2f0b02b873a29fef4 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Thu, 7 Jul 2016 12:08:47 -0700 Subject: [PATCH 2402/4072] 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 2c68d36db96..ba81867ddb3 100644 --- a/compose/cli/errors.py +++ b/compose/cli/errors.py @@ -13,7 +13,6 @@ 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_mac from .utils import is_ubuntu @@ -46,7 +45,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() if call_silently(['which', 'docker']) != 0: @@ -66,13 +65,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 5334a9440a5..74669d4a593 100644 --- a/tests/unit/cli/docker_client_test.py +++ b/tests/unit/cli/docker_client_test.py @@ -6,6 +6,7 @@ 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 @@ def test_docker_client_no_home(self): 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 0488dd3709f3005ce975c3c6bbc1f0688db0cf0e Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 25 Jul 2016 16:48:28 +0100 Subject: [PATCH 2403/4072] Rename --fetch-digests to --push-images and remove auto-pull Signed-off-by: Aanand Prasad --- compose/bundle.py | 39 ++++++++++++++++++--------------------- compose/cli/main.py | 37 +++++++++++++++++++++++++++---------- tests/unit/bundle_test.py | 34 ++++++++++------------------------ 3 files changed, 55 insertions(+), 55 deletions(-) diff --git a/compose/bundle.py b/compose/bundle.py index 44f6954b71e..afbdabfa81f 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) + if 'build' not in service.options: + raise NeedsPull(service.image_name) - return fetch_image_digest(service) + if not allow_push: + raise NeedsPush(service.image_name) + return push_image(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 + +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 3aa1459a384..b487bb7cee9 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -223,15 +223,16 @@ def bundle(self, config_options, options): 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 @@ def bundle(self, config_options, options): 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 @@ def list_images(images): 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)] - - paras.append("If this is OK, run `docker-compose bundle --fetch-digests`.") + command_hint = ( + "Use `docker-compose pull {}` to pull them. " + .format(" ".join(sorted(e.needs_pull))) + ) + + paras += [ + "The following images need to be pulled:", + list_images(e.needs_pull), + command_hint, + ] raise UserError("\n\n".join(paras)) diff --git a/tests/unit/bundle_test.py b/tests/unit/bundle_test.py index ff4c0dceb58..223b3b07a2c 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): +def test_push_image_with_saved_digest(mock_service): + mock_service.options['build'] = '.' mock_service.options['image'] = image_id = 'abcd' - mock_service.pull.return_value = expected = 'sha256:thedigest' + 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.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) + digest = bundle.push_image(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) + mock_service.push.assert_called_once_with() + assert not mock_service.client.push.called -def test_fetch_image_digest_for_build(mock_service): +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': ['digest1']} + mock_service.image.return_value = {'RepoDigests': []} - 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 + mock_service.client.pull.assert_called_once_with(digest) def test_to_bundle(): From 606358cfb7979cd9275ea78e50e7ca22b0a7f429 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 26 Jul 2016 13:07:38 -0700 Subject: [PATCH 2404/4072] 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 60260e1c27c..831ed65a9eb 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 3696adc6241..5cb52dae4a3 100644 --- a/setup.py +++ b/setup.py @@ -34,7 +34,7 @@ def find_version(*file_paths): '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 6246a2592ee95a12450c9d6487ed7fe0c877a9ea Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 24 Jun 2016 11:10:18 -0400 Subject: [PATCH 2405/4072] 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 00000000000..fca93a8aa66 --- /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 00000000000..bdc3112e83e --- /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 cb076a57b9261907e61716ab073565b20cc95d0a Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Tue, 5 Jul 2016 17:29:28 +0100 Subject: [PATCH 2406/4072] 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 ba81867ddb3..5af3ede9fff 100644 --- a/compose/cli/errors.py +++ b/compose/cli/errors.py @@ -14,6 +14,7 @@ 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 @@ -47,16 +48,7 @@ def handle_connection_errors(client): if e.args and isinstance(e.args[0], ReadTimeoutError): log_timeout_error(client.timeout) 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() @@ -96,6 +88,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: @@ -121,6 +127,10 @@ def exit_with_error(msg): 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 cc2b680de5c..bf5df80ca7c 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 cd267d5121d747b282792202bac703e2b958fe97 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Sun, 24 Jul 2016 18:57:36 +0100 Subject: [PATCH 2407/4072] 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 bed6be79455..ce191fbf549 100644 --- a/compose/cli/docker_client.py +++ b/compose/cli/docker_client.py @@ -10,6 +10,7 @@ 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 bf5df80ca7c..f60f61cd042 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 74669d4a593..fc914791caf 100644 --- a/tests/unit/cli/docker_client_test.py +++ b/tests/unit/cli/docker_client_test.py @@ -2,10 +2,12 @@ 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 @@ def test_custom_timeout_error(self): 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 c392acc56b1e9aee7810df6bf2671c1c040ba05e Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 1 Jul 2016 13:54:27 -0700 Subject: [PATCH 2408/4072] 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 676b6ae8c90..256fb9c08a0 100644 --- a/compose/project.py +++ b/compose/project.py @@ -369,6 +369,8 @@ def up(self, detached=False, remove_orphans=False): + warn_for_swarm_mode(self.client) + self.initialize() self.find_orphan_containers(remove_orphans) @@ -533,6 +535,20 @@ def build_volume_from(spec): 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 b6a52e08d05..9569adc907f 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -510,3 +510,35 @@ def test_down_with_no_resources(self): 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 35ed1899819fb7f7a8acfd316aa0b76307671fed Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Mon, 25 Jul 2016 13:46:50 +0100 Subject: [PATCH 2409/4072] 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 256fb9c08a0..f85e285f3de 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 00000000000..0958e1ef8aa --- /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 1fb5c4b15a6643f2b6a80858633343ba2f0d61c9 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 26 Jul 2016 11:51:16 +0100 Subject: [PATCH 2410/4072] 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 0958e1ef8aa..a56adb020c3 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 87b6b3c1390da4f508a6e9285a9122800581f669 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 26 Jul 2016 11:51:30 +0100 Subject: [PATCH 2411/4072] 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 a56adb020c3..19322824221 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.

Msz>%Ih=Gco4~ge)=VM)2vp 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≪CBLH-)(%UXH$ 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 z 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�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

+ +> **Note:** Some configuration options are not yet supported in the DAB format, +> including volume mounts. From 6f3e4bbc6c6bc81c5d4d12331c7540e7dac60b20 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 26 Jul 2016 11:59:04 +0100 Subject: [PATCH 2412/4072] 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 19322824221..5ca2c1ec3cc 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 cefa239c2e86ca0cbc356e4b69f809743c15c75e Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 27 Jul 2016 15:55:47 +0100 Subject: [PATCH 2413/4072] 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 afa35820f68..8ec7d5b57b9 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 bf8a6f3068b..f9f0e6a6ef5 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 d1a11ab55be..2099b71c155 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 caf6ed11944..c2c01db627e 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 ec825af3d336a55297250b3a2af63b48fabed177 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 27 Jul 2016 18:26:40 +0100 Subject: [PATCH 2414/4072] 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 09a9ced81e9..2c70d31ac18 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 e115eaf6fc3682a0bae0b5148f0793346950e9da Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 27 Jul 2016 18:26:40 +0100 Subject: [PATCH 2415/4072] 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 09a9ced81e9..2c70d31ac18 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 f3628c7a5e14b545a8f3d93f5f03681d2e5de4ef Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 27 Jul 2016 11:55:49 -0700 Subject: [PATCH 2416/4072] 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 f9f0e6a6ef5..c550f990ca9 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 2099b71c155..bb7f07b3d1d 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 c2c01db627e..6205747af6f 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 22c0779a498ee701c22b857669d3f43a0d404f27 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 14 Jun 2016 11:33:45 -0700 Subject: [PATCH 2417/4072] 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 0064a5cce8c..39ac86982c3 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 1052c0670bf..1dd11e7914b 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 76e4a868717..5191a4b58ad 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 98d32c5f8d3..f9199ce1581 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 2418/4072] 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 39ac86982c3..afa35820f68 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 1dd11e7914b..bf8a6f3068b 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 5191a4b58ad..d1a11ab55be 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 f9199ce1581..caf6ed11944 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 2419/4072] 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 afa35820f68..8ec7d5b57b9 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 bf8a6f3068b..f9f0e6a6ef5 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 d1a11ab55be..2099b71c155 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 caf6ed11944..c2c01db627e 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 2420/4072] 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 f9f0e6a6ef5..c550f990ca9 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 2099b71c155..bb7f07b3d1d 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 c2c01db627e..6205747af6f 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 2421/4072] 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 c550f990ca9..6e61065278b 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 2422/4072] 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 5af3ede9fff..f9a20b9ec1c 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 fc914791caf..3430c25c654 100644 --- a/tests/unit/cli/docker_client_test.py +++ b/tests/unit/cli/docker_client_test.py @@ -42,6 +42,14 @@ def test_custom_timeout_error(self): 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 2423/4072] 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 925a8e791a5..eea73be1ddf 100644 --- a/compose/utils.py +++ b/compose/utils.py @@ -5,11 +5,13 @@ 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 8ee37b07842..85231957e3c 100644 --- a/tests/unit/utils_test.py +++ b/tests/unit/utils_test.py @@ -15,6 +15,10 @@ def test_json_splitter_with_object(self): 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 @@ def test_with_falsy_entries(self): [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 2424/4072] 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 5ca2c1ec3cc..096c9ec3e28 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 2425/4072] 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 2c70d31ac18..020354283f6 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -8,6 +8,7 @@ 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 7641870b62d..3939a97b4f1 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -154,6 +154,19 @@ def test_shorthand_host_opt(self): 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 00000000000..495fcaae5c0 --- /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 2426/4072] 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 db06a5e143b..20200b09b3f 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -23,6 +23,7 @@ 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 9f68760d327..376cc555886 100644 --- a/compose/errors.py +++ b/compose/errors.py @@ -5,3 +5,8 @@ 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 eea73be1ddf..6d9a9fdcee9 100644 --- a/compose/utils.py +++ b/compose/utils.py @@ -9,6 +9,8 @@ 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 2427/4072] 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 bbab6908793..f956f8c2480 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 2428/4072] 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 db06a5e143b..7655fe9c010 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -615,8 +615,7 @@ def rm(self, options): 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 8285a4ae52a..6351e6cf555 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 2429/4072] 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 ce191fbf549..b196d3036fb 100644 --- a/compose/cli/docker_client.py +++ b/compose/cli/docker_client.py @@ -11,15 +11,16 @@ 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 f60f61cd042..e10a36747c0 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 3430c25c654..aaa935afab9 100644 --- a/tests/unit/cli/docker_client_test.py +++ b/tests/unit/cli/docker_client_test.py @@ -144,3 +144,16 @@ def test_assert_hostname_explicit_skip(self): 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 00000000000..066fb359544 --- /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 2430/4072] 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 d3ab1d4be3d..91c2f6a62b9 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -69,6 +69,7 @@ '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 36a93793893..94354cda712 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 ac46944cc1b..59caac9b7f9 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 60343542b08..d0cdeeb353a 100644 --- a/compose/service.py +++ b/compose/service.py @@ -55,6 +55,7 @@ 'mem_limit', 'memswap_limit', 'oom_score_adj', + 'mem_swappiness', 'pid', 'privileged', 'restart', @@ -704,7 +705,8 @@ def _get_container_host_config(self, override_options, one_off=False): 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 d6d0cadc7b1..9f4bd121316 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 97ad7476381..24dec983f13 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -852,6 +852,11 @@ def test_dns_list(self): 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 d88c1d47e9d..02810d2b87f 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1271,6 +1271,24 @@ def test_oom_score_adj_option(self): } ] + 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 2431/4072] 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 00000000000..fa29520b5f7 --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1,8 @@ +// Only run on Linux atm +wrappedNode(label: 'docker') { + deleteDir() + stage "checkout" + checkout scm + + documentationChecker("docs") +} From 0839fb93c1338c97774a6e73976711113c731740 Mon Sep 17 00:00:00 2001 From: Natanael Copa Date: Mon, 22 Aug 2016 14:39:02 -0700 Subject: [PATCH 2432/4072] Use posix shell instead of bash for run.sh There are no bash specific uses in the script so we can use posix shell. Signed-off-by: Natanael Copa --- 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 6205747af6f..49baa41c3f0 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/bin/sh # # Run docker-compose in a container # From 817c76c8e9ebbeb864ef69e8e03819fefb1a784d Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 8 Sep 2016 11:17:39 -0700 Subject: [PATCH 2433/4072] 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 afbdabfa81f..854cc79954d 100644 --- a/compose/bundle.py +++ b/compose/bundle.py @@ -46,8 +46,9 @@ def __init__(self, image_name): 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 2434/4072] 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 91c2f6a62b9..d36aefa5d00 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -61,6 +61,7 @@ '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 59caac9b7f9..76688916925 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 d0cdeeb353a..b5a35b7e824 100644 --- a/compose/service.py +++ b/compose/service.py @@ -48,6 +48,7 @@ 'dns_search', 'env_file', 'extra_hosts', + 'group_add', 'ipc', 'read_only', 'log_driver', @@ -706,7 +707,8 @@ def _get_container_host_config(self, override_options, one_off=False): 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 9f4bd121316..384649b14e7 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 24dec983f13..a5ca81ee354 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -867,6 +867,14 @@ def test_oom_score_adj_value(self): 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 02810d2b87f..837630c1647 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1289,6 +1289,26 @@ def test_swappiness_option(self): } ] + 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 2435/4072] 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 831ed65a9eb..7f28514b1b5 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 5cb52dae4a3..34b40273817 100644 --- a/setup.py +++ b/setup.py @@ -34,7 +34,7 @@ def find_version(*file_paths): '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 2436/4072] 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 71fa9dee5de..1d454a08185 100644 --- a/tests/unit/cli/errors_test.py +++ b/tests/unit/cli/errors_test.py @@ -32,7 +32,7 @@ def test_generic_connection_error(self, mock_logging): 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 2437/4072] 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 2c16863df95..bda4e659fb2 100644 --- a/compose/container.py +++ b/compose/container.py @@ -163,7 +163,7 @@ def log_driver(self): @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 47f60de8f91..62e3aa2cfc1 100644 --- a/tests/unit/container_test.py +++ b/tests/unit/container_test.py @@ -150,6 +150,34 @@ def test_short_id(self): 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 2438/4072] 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 b48462ff57a..299ddea465c 100644 --- a/compose/cli/log_printer.py +++ b/compose/cli/log_printer.py @@ -6,6 +6,7 @@ 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 ab48eefc0a1..b908eb68b62 100644 --- a/tests/unit/cli/log_printer_test.py +++ b/tests/unit/cli/log_printer_test.py @@ -4,7 +4,9 @@ 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 2439/4072] 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 91c2f6a62b9..32eda81fe2f 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -15,6 +15,7 @@ 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 @@ def version(self): 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 00000000000..de4ddf2509b --- /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 b788a55de86..0e6efbdf1c8 100644 --- a/compose/config/serialize.py +++ b/compose/config/serialize.py @@ -7,6 +7,7 @@ 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 b930e0bf0e7..e7b1ae97a92 100644 --- a/compose/const.py +++ b/compose/const.py @@ -16,13 +16,16 @@ 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 d0cdeeb353a..31ea9e0f822 100644 --- a/compose/service.py +++ b/compose/service.py @@ -477,7 +477,9 @@ def connect_container_to_networks(self, container): 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 9f4bd121316..c8fa112d9c0 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 80915c1aeb9..2241f70f00b 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -13,6 +13,7 @@ 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.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 @@ def test_up_with_network_static_addresses_missing_subnet(self): 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 3e33a6c0f71..c7743fb83b8 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -12,6 +12,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.environment import Environment from compose.const import API_VERSIONS from compose.const import LABEL_PROJECT @@ -33,18 +34,22 @@ def format_link(link): 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 wrapper(self, *args, **kwargs): 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 02810d2b87f..8087c7730aa 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.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 @@ def test_valid_versions(self): 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 @@ def test_unsupported_version(self): with pytest.raises(ConfigurationError) as excinfo: config.load( build_config_details( - {'version': '2.1'}, + {'version': '2.18'}, filename='filename.yml', ) ) @@ -344,6 +347,35 @@ def test_load_throws_error_with_invalid_network_fields(self): }, '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 2440/4072] 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 2241f70f00b..4427fe6b997 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -783,6 +783,17 @@ def test_up_with_network_link_local_ips(self): ) 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 2441/4072] 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 b196d3036fb..7950c2423e6 100644 --- a/compose/cli/docker_client.py +++ b/compose/cli/docker_client.py @@ -9,6 +9,7 @@ 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 aaa935afab9..6cdb7da5761 100644 --- a/tests/unit/cli/docker_client_test.py +++ b/tests/unit/cli/docker_client_test.py @@ -60,6 +60,14 @@ def test_user_agent(self): ) 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 2442/4072] 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 7f28514b1b5..7acdd130ba0 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 34b40273817..80258fbdcb0 100644 --- a/setup.py +++ b/setup.py @@ -34,7 +34,7 @@ def find_version(*file_paths): '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 2443/4072] 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 8ec7d5b57b9..cd4a2370564 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 2444/4072] 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 91c2f6a62b9..2789e9edcf9 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -3,7 +3,6 @@ import functools import logging -import ntpath import os import string import sys @@ -16,6 +15,7 @@ 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 e6a3dea053e..9664b580299 100644 --- a/compose/config/types.py +++ b/compose/config/types.py @@ -12,6 +12,7 @@ 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 @@ def parse(cls, volume_config): "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 @@ def parse(cls, volume_config): 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 6d9a9fdcee9..8f05e3081ff 100644 --- a/compose/utils.py +++ b/compose/utils.py @@ -6,6 +6,7 @@ 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 c741a339f41..8dfa65d5204 100644 --- a/tests/unit/config/types_test.py +++ b/tests/unit/config/types_test.py @@ -9,7 +9,6 @@ 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 @@ def test_parse_volume_spec_too_many_parts(self): 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 2445/4072] 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 0e6efbdf1c8..95b1387fcc6 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 c8fa112d9c0..625e5bf6d9b 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 3939a97b4f1..2247ffff0f3 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -257,7 +257,7 @@ def test_config_v1(self): 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 2446/4072] 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 b5a35b7e824..1759bf7d44a 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 3939a97b4f1..67cca8c7548 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 operator import attrgetter import py +import six import yaml from docker import errors @@ -1286,6 +1288,23 @@ def test_run_handles_sigterm(self): '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 00000000000..a41af4f0741 --- /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 b801f275d7e953fe59a8f2f0ef7559ffafb54be1 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 27 Jul 2016 16:00:53 +0100 Subject: [PATCH 2447/4072] 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 0201bcb28ac..991f6572941 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 2947cef3824..928e28defa9 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 093b040b8e99166eb6dada08eafd81787a16bebd Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Fri, 29 Jul 2016 17:12:40 +0100 Subject: [PATCH 2448/4072] 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 5af3ede9fff..f9a20b9ec1c 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 fc914791caf..3430c25c654 100644 --- a/tests/unit/cli/docker_client_test.py +++ b/tests/unit/cli/docker_client_test.py @@ -42,6 +42,14 @@ def test_custom_timeout_error(self): 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 1c07d6453f26e13b6335e009d04d6737e7a3f0af Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 29 Jul 2016 14:05:59 -0700 Subject: [PATCH 2449/4072] 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 925a8e791a5..eea73be1ddf 100644 --- a/compose/utils.py +++ b/compose/utils.py @@ -5,11 +5,13 @@ 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 8ee37b07842..85231957e3c 100644 --- a/tests/unit/utils_test.py +++ b/tests/unit/utils_test.py @@ -15,6 +15,10 @@ def test_json_splitter_with_object(self): 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 @@ def test_with_falsy_entries(self): [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 bd7db570bd9985956472a3db3a064525111f2b20 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 1 Aug 2016 11:50:57 -0700 Subject: [PATCH 2450/4072] 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 b487bb7cee9..68b509b611d 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -23,6 +23,7 @@ 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 9f68760d327..376cc555886 100644 --- a/compose/errors.py +++ b/compose/errors.py @@ -5,3 +5,8 @@ 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 eea73be1ddf..6d9a9fdcee9 100644 --- a/compose/utils.py +++ b/compose/utils.py @@ -9,6 +9,8 @@ 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 4b17aa1b9ee7a03828dc43c14a08b28594fa4734 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 2451/4072] 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 68b509b611d..dc093f4f629 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -616,8 +616,7 @@ def rm(self, options): 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 8285a4ae52a..6351e6cf555 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 9e18929d603a4c4fe02602d5e0c4ab15a53c1c58 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 9 Aug 2016 15:13:01 -0700 Subject: [PATCH 2452/4072] 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 ce191fbf549..b196d3036fb 100644 --- a/compose/cli/docker_client.py +++ b/compose/cli/docker_client.py @@ -11,15 +11,16 @@ 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 f60f61cd042..e10a36747c0 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 3430c25c654..aaa935afab9 100644 --- a/tests/unit/cli/docker_client_test.py +++ b/tests/unit/cli/docker_client_test.py @@ -144,3 +144,16 @@ def test_assert_hostname_explicit_skip(self): 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 00000000000..066fb359544 --- /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 20a511e961030f70b93a8fe21ed32a8d1648e225 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 8 Sep 2016 11:17:39 -0700 Subject: [PATCH 2453/4072] 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 afbdabfa81f..854cc79954d 100644 --- a/compose/bundle.py +++ b/compose/bundle.py @@ -46,8 +46,9 @@ def __init__(self, image_name): 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 fd7c16f1a4f509b425bd6fb21b2f033873a04225 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 8 Sep 2016 16:39:30 -0700 Subject: [PATCH 2454/4072] 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 7a2b3d36641..0f7e420dd26 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -3,7 +3,6 @@ import functools import logging -import ntpath import os import string import sys @@ -16,6 +15,7 @@ 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 @@ -940,13 +940,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 e6a3dea053e..9664b580299 100644 --- a/compose/config/types.py +++ b/compose/config/types.py @@ -12,6 +12,7 @@ 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 @@ def parse(cls, volume_config): "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 @@ def parse(cls, volume_config): 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 6d9a9fdcee9..8f05e3081ff 100644 --- a/compose/utils.py +++ b/compose/utils.py @@ -6,6 +6,7 @@ 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 c741a339f41..8dfa65d5204 100644 --- a/tests/unit/config/types_test.py +++ b/tests/unit/config/types_test.py @@ -9,7 +9,6 @@ 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 @@ def test_parse_volume_spec_too_many_parts(self): 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 6abdd9cc326741ea661dcfa487a301633564afcc Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 9 Sep 2016 17:19:14 -0700 Subject: [PATCH 2455/4072] 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 831ed65a9eb..7f28514b1b5 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 5cb52dae4a3..34b40273817 100644 --- a/setup.py +++ b/setup.py @@ -34,7 +34,7 @@ def find_version(*file_paths): '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 016197c16eb64edfd9c19a8fabc9e2318a12cb78 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 12 Sep 2016 15:49:54 -0700 Subject: [PATCH 2456/4072] 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 b48462ff57a..299ddea465c 100644 --- a/compose/cli/log_printer.py +++ b/compose/cli/log_printer.py @@ -6,6 +6,7 @@ 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 ab48eefc0a1..b908eb68b62 100644 --- a/tests/unit/cli/log_printer_test.py +++ b/tests/unit/cli/log_printer_test.py @@ -4,7 +4,9 @@ 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 070f8b399225fe69a6b7eaa4024d8faa6f56e877 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 12 Sep 2016 16:02:58 -0700 Subject: [PATCH 2457/4072] 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 2c16863df95..bda4e659fb2 100644 --- a/compose/container.py +++ b/compose/container.py @@ -163,7 +163,7 @@ def log_driver(self): @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 47f60de8f91..62e3aa2cfc1 100644 --- a/tests/unit/container_test.py +++ b/tests/unit/container_test.py @@ -150,6 +150,34 @@ def test_short_id(self): 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 613e060f0dd64ce45d5e6bea4e20ec5162eddce5 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Tue, 13 Sep 2016 14:50:37 +0100 Subject: [PATCH 2458/4072] 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 71fa9dee5de..1d454a08185 100644 --- a/tests/unit/cli/errors_test.py +++ b/tests/unit/cli/errors_test.py @@ -32,7 +32,7 @@ def test_generic_connection_error(self, mock_logging): 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 e8903da96c7a634bbcc5e25f05dc471dff927c69 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 15 Sep 2016 17:59:20 -0700 Subject: [PATCH 2459/4072] 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 b196d3036fb..7950c2423e6 100644 --- a/compose/cli/docker_client.py +++ b/compose/cli/docker_client.py @@ -9,6 +9,7 @@ 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 aaa935afab9..6cdb7da5761 100644 --- a/tests/unit/cli/docker_client_test.py +++ b/tests/unit/cli/docker_client_test.py @@ -60,6 +60,14 @@ def test_user_agent(self): ) 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 e216a31f1e7d1c93844043802083197696dff74e Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 16 Sep 2016 14:12:15 -0700 Subject: [PATCH 2460/4072] 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 7f28514b1b5..7acdd130ba0 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 34b40273817..80258fbdcb0 100644 --- a/setup.py +++ b/setup.py @@ -34,7 +34,7 @@ def find_version(*file_paths): '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 5667de87e88e0abcc876647ff7e73510de8b878a Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 22 Sep 2016 12:34:59 -0700 Subject: [PATCH 2461/4072] 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 1e69b143fe9..4657dd8051d 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 2462/4072] 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 928e28defa9..fae758426bc 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 de90765531da174283f931cb8d24f81807a12986 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 21 Sep 2016 15:02:41 -0700 Subject: [PATCH 2463/4072] Bump 1.8.1 Signed-off-by: Joffrey F --- CHANGELOG.md | 41 +++++++++++++++++++++++++++++++++++++++++ compose/__init__.py | 2 +- docs/install.md | 6 +++--- script/run/run.sh | 2 +- 4 files changed, 46 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ec7d5b57b9..7176cb5430d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,47 @@ Change log ========== +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) ----------------- diff --git a/compose/__init__.py b/compose/__init__.py index c550f990ca9..2dc38341366 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.8.1' diff --git a/docs/install.md b/docs/install.md index bb7f07b3d1d..db38bbe3183 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/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose + curl -L https://github.com/docker/compose/releases/download/1.8.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.8.0 + docker-compose version: 1.8.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.8.0/run.sh > /usr/local/bin/docker-compose + $ curl -L https://github.com/docker/compose/releases/download/1.8.1/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 6205747af6f..a16f2ea82ab 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.8.0" +VERSION="1.8.1" IMAGE="docker/compose:$VERSION" From b05c6c6fe98f11ade0b7c503cb9d398bec288d14 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 28 Sep 2016 13:13:04 -0700 Subject: [PATCH 2464/4072] Fix openssl dependency in OSX binary build Signed-off-by: Joffrey F --- script/setup/osx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/setup/osx b/script/setup/osx index 39941de27f4..38222881edc 100755 --- a/script/setup/osx +++ b/script/setup/osx @@ -15,7 +15,7 @@ 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.2h" -desired_openssl_brew_version="1.0.2h" +desired_openssl_brew_version="1.0.2h_1" openssl_formula="https://raw.githubusercontent.com/Homebrew/homebrew-core/30d3766453347f6e22b3ed6c74bb926d6def2eb5/Formula/openssl.rb" PATH="/usr/local/bin:$PATH" From dc8a39f70d66948bcd220d1693ed8ab167fa3513 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 28 Sep 2016 15:31:58 -0700 Subject: [PATCH 2465/4072] 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 de4ddf2509b..243759fa61a 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 c461220f55b..f4f4b90dd8a 100644 --- a/compose/service.py +++ b/compose/service.py @@ -682,7 +682,7 @@ def _get_container_host_config(self, override_options, one_off=False): 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 @@ def _get_container_host_config(self, override_options, one_off=False): 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 4427fe6b997..8588c6b14de 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -794,6 +794,49 @@ def test_up_with_network_link_local_ips(self): 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 88b990e527d..d9bc576476a 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -351,7 +351,7 @@ def test_load_config_link_local_ips_network(self): base_file = config.ConfigFile( 'base.yaml', { - 'version': '2.1', + 'version': V2_1, 'services': { 'web': { 'image': 'example/web', @@ -1330,7 +1330,7 @@ def test_group_add_option(self): 'image': 'alpine', 'group_add': ["docker", 777] } - } + } })) assert actual.services == [ @@ -1341,6 +1341,25 @@ def test_group_add_option(self): } ] + 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 2466/4072] 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 7950c2423e6..b196d3036fb 100644 --- a/compose/cli/docker_client.py +++ b/compose/cli/docker_client.py @@ -9,7 +9,6 @@ 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 6cdb7da5761..aaa935afab9 100644 --- a/tests/unit/cli/docker_client_test.py +++ b/tests/unit/cli/docker_client_test.py @@ -60,14 +60,6 @@ def test_user_agent(self): ) 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 2467/4072] 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 aea1e0949f5..4d32b50c4f6 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -413,31 +413,35 @@ def merge_services(base, override): 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 63020d91ad7..cb841437cac 100644 --- a/compose/config/interpolation.py +++ b/compose/config/interpolation.py @@ -7,14 +7,35 @@ 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 process_item(name, config_dict): ) -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 42b5db6e937..224444950ee 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 c3050c2caa4..00000000000 --- 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 2468/4072] 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 fae758426bc..ceb7d0f587c 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 2469/4072] 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 7b5a3b24654..00000000000 --- 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 e6629289b51..00000000000 --- 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 e60fa48cd58..03d2e3a77ed 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 096c9ec3e28..00000000000 --- 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 2076d512c38..00000000000 --- 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 cfc242ce858..00000000000 --- 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 1cf2a5675c5..00000000000 --- 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 be2625f889a..00000000000 --- 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 a2e74f0a968..00000000000 --- 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 6f457391f57..00000000000 --- 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 45885255f84..00000000000 --- 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 249bff725e6..00000000000 --- 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 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 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 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 diff --git a/docs/index.md b/docs/index.md deleted file mode 100644 index f1b710794ef..00000000000 --- 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 bb7f07b3d1d..00000000000 --- 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 b1f01b3b6af..00000000000 --- 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 9739a088406..00000000000 --- 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 ef07a45be5a..00000000000 --- 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 cfb8729363a..00000000000 --- 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 267776872e9..00000000000 --- 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 84aefc253f1..00000000000 --- 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 fca93a8aa66..00000000000 --- 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 1a9706f4da9..00000000000 --- 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 5065e8bebe5..00000000000 --- 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 ffe88b4e05f..00000000000 --- 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 22516debdc9..00000000000 --- 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 827258f2499..00000000000 --- 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 6c0eeb04dc2..00000000000 --- 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 613708ed2f0..00000000000 --- 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 2ac3676af0b..00000000000 --- 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 dc4bf23a1b5..00000000000 --- 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 745d24f7fec..00000000000 --- 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 d59fa56575b..00000000000 --- 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 a0ffab03596..00000000000 --- 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 c946a97d390..00000000000 --- 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 546d68e76ce..00000000000 --- 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 5ec184b72c8..00000000000 --- 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 bdc3112e83e..00000000000 --- 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 bbd4a68b0fb..00000000000 --- 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 6351e6cf555..00000000000 --- 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 863544246d2..00000000000 --- 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 75140ee9e50..00000000000 --- 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 f0bdd5a97c5..00000000000 --- 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 ec7e6688a51..00000000000 --- 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 846b229e3cc..00000000000 --- 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 3951f879258..00000000000 --- 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 c67e18295a1..00000000000 --- 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 f956f8c2480..00000000000 --- 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 b39a8bbbe68..00000000000 --- 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 2470/4072] 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 03d2e3a77ed..50c91d2074f 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 28546d1f81bfbc3f7c71762896c845edc49f065c Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 5 Oct 2016 15:46:24 -0700 Subject: [PATCH 2471/4072] Use latest OpenSSL version (1.0.2j) when building Mac binary on Travis 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 38222881edc..e6ab62a8473 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_1" +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 b50b14f937455889fa0d62c451941d58b3cec124 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 28 Sep 2016 13:13:04 -0700 Subject: [PATCH 2472/4072] 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 39941de27f4..e6ab62a8473 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 2473/4072] 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 cb841437cac..1b270b9eab6 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 224444950ee..fd40153d21f 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 2474/4072] 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 d0ed0f87eb2..5f580645910 100644 --- a/compose/cli/formatter.py +++ b/compose/cli/formatter.py @@ -4,6 +4,7 @@ import logging import os +import six import texttable from compose.cli import colors @@ -44,5 +45,7 @@ def get_level_message(self, record): 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 1c3b6a68ef1..4aa025e693b 100644 --- a/tests/unit/cli/formatter_test.py +++ b/tests/unit/cli/formatter_test.py @@ -11,8 +11,8 @@ 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 @@ def setUp(self): 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 2475/4072] 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 de4ddf2509b..617f8ebe0a2 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 8962a8920c7..c3af9aa1dee 100644 --- a/compose/network.py +++ b/compose/network.py @@ -15,7 +15,7 @@ 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 @@ def __init__(self, client, project, name, driver=None, driver_opts=None, 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 @@ def ensure(self): 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 4427fe6b997..94eac530952 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -721,6 +721,51 @@ def test_up_with_network_static_addresses(self): 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 2476/4072] 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 aea1e0949f5..8582d83c8f5 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 to_dict(service): 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 88b990e527d..d9269ab4370 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1330,7 +1330,7 @@ def test_group_add_option(self): 'image': 'alpine', 'group_add': ["docker", 777] } - } + } })) assert actual.services == [ @@ -1429,6 +1429,162 @@ def test_merge_logging_v1(self): '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 2477/4072] 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 4d32b50c4f6..b3e01778936 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 de4ddf2509b..980e4ba102d 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 8962a8920c7..796836fe7ed 100644 --- a/compose/network.py +++ b/compose/network.py @@ -15,7 +15,7 @@ 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 @@ def __init__(self, client, project, name, driver=None, driver_opts=None, 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 @@ def ensure(self): 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 f440ba40c8a..1fd1d51c97f 100644 --- a/compose/volume.py +++ b/compose/volume.py @@ -12,17 +12,18 @@ 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 @@ def from_config(cls, name, config_data, client): 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 2247ffff0f3..54737c7a721 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -22,6 +22,7 @@ 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 @@ def test_up_with_external_default_network(self): 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 00000000000..fdb24f652d7 --- /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 00000000000..a5f33a5aaa4 --- /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 4427fe6b997..149facfe8b6 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -821,6 +821,42 @@ def test_project_up_with_network_internal(self): 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 @@ def test_project_up_volumes(self): 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 88b990e527d..7a8832d2033 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -376,6 +376,59 @@ def test_load_config_link_local_ips_network(self): } } + 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 2478/4072] 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 9664b580299..c450a0f984c 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 0c7c17bd3db..e2c0279806f 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -236,6 +236,10 @@ def test_config_restart(self): '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 2d10aa39705..ecfdfbf5377 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 2479/4072] 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 f9a20b9ec1c..4fdec08a85b 100644 --- a/compose/cli/errors.py +++ b/compose/cli/errors.py @@ -17,6 +17,7 @@ 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: +def docker_not_found_msg(problem): + return "{} You might need to install Docker:\n\n{}".format( + problem, docker_install_url()) - https://docs.docker.com/engine/installation/mac/ -""" - - -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 e10a36747c0..580bd1b073d 100644 --- a/compose/cli/utils.py +++ b/compose/cli/utils.py @@ -11,6 +11,7 @@ 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 2480/4072] 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 438753a2659..cbbb1325d4c 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -7,6 +7,7 @@ import json import logging import re +import subprocess import sys from inspect import getdoc from operator import attrgetter @@ -406,11 +407,6 @@ def exec_command(self, options): 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 @@ def exec_command(self, options): 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 @@ def run(self, options): 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 remove_container(force=False): 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 2481/4072] 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 cbbb1325d4c..58b95c2f6d6 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -6,6 +6,7 @@ import functools import json import logging +import pipes import re import subprocess import sys @@ -415,7 +416,7 @@ def exec_command(self, options): 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 @@ def exec_command(self, options): 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 remove_container(force=False): 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 2482/4072] 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 7acdd130ba0..474efbdf321 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 80258fbdcb0..442bc80c94f 100644 --- a/setup.py +++ b/setup.py @@ -31,10 +31,10 @@ def find_version(*file_paths): '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 086ae04b9e01c06e9614e58b5b9e402a8f5298c2 Mon Sep 17 00:00:00 2001 From: Nicolas Barbey Date: Mon, 17 Oct 2016 15:02:48 +0200 Subject: [PATCH 2483/4072] Fix TypeError : unorderable types: str() < int() While merging list items into a set, strings and ints are compared which is not possible. We cast everything to strings to avoid the issue. The issue was seen with python 3.5 while overriding configuration files with heterogenous port types (int in one file, string in another). Signed-off-by: Nicolas Barbey --- compose/config/config.py | 2 ++ tests/unit/config/config_test.py | 38 ++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/compose/config/config.py b/compose/config/config.py index be73e1dee72..91f6ac9a030 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -778,6 +778,8 @@ def merge_service_dicts(base, override, version): def merge_unique_items_lists(base, override): + override = [str(o) for o in override] + base = [str(b) for b in base] return sorted(set().union(base, override)) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index d9269ab4370..3fcfec162a9 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1378,6 +1378,44 @@ def test_merge_service_dicts_from_files_with_extends_in_override(self): 'extends': {'service': 'foo'} } + def test_merge_service_dicts_heterogeneous(self): + base = { + 'volumes': ['.:/app'], + 'ports': ['5432'] + } + override = { + 'image': 'alpine:edge', + 'ports': [5432] + } + actual = config.merge_service_dicts_from_files( + base, + override, + DEFAULT_VERSION) + assert actual == { + 'image': 'alpine:edge', + 'volumes': ['.:/app'], + 'ports': ['5432'] + } + + def test_merge_service_dicts_heterogeneous_2(self): + base = { + 'volumes': ['.:/app'], + 'ports': [5432] + } + override = { + 'image': 'alpine:edge', + 'ports': ['5432'] + } + actual = config.merge_service_dicts_from_files( + base, + override, + DEFAULT_VERSION) + assert actual == { + 'image': 'alpine:edge', + 'volumes': ['.:/app'], + 'ports': ['5432'] + } + def test_merge_build_args(self): base = { 'build': { From efb09af271a1522914431818409b1c16c4bd24a9 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 10 Oct 2016 17:44:49 -0700 Subject: [PATCH 2484/4072] 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 870bbad9c37..437ed3892a7 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 c450a0f984c..4c106747f96 100644 --- a/compose/config/types.py +++ b/compose/config/types.py @@ -5,6 +5,7 @@ from __future__ import unicode_literals import os +import re from collections import namedtuple import six @@ -14,6 +15,8 @@ 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 @@ def _parse_unix(cls, volume_config): 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 @@ def separate_next_section(volume_config): 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 @@ def separate_next_section(volume_config): ) 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 @@ def repr(self): @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 8dfa65d5204..114273520e9 100644 --- a/tests/unit/config/types_test.py +++ b/tests/unit/config/types_test.py @@ -63,35 +63,67 @@ def test_parse_volume_spec_too_many_parts(self): 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_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(self): + 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 a259c476fb5..1d5aa10fb7f 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -786,7 +786,7 @@ def setUp(self): 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 @@ def test_get_container_data_volumes(self): 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 @@ def test_mount_same_host_path_to_two_volumes(self): '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 @@ def test_create_with_special_volume_mode(self): '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 2485/4072] 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 00d68aa4f5c..e581a4fe6bf 100644 --- a/compose/network.py +++ b/compose/network.py @@ -26,6 +26,7 @@ def __init__(self, client, project, name, driver=None, driver_opts=None, self.external_name = external_name self.internal = internal self.enable_ipv6 = enable_ipv6 + self.labels = labels def ensure(self): if self.external_name: From 28788bd9b5f708c4dc96ba3921a0b63d3bd829a2 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 19 Oct 2016 14:12:20 -0700 Subject: [PATCH 2486/4072] Bump 1.9.0-rc1 Signed-off-by: Joffrey F --- CHANGELOG.md | 97 ++++++++++++++++++++++++++++++++++++++ compose/__init__.py | 2 +- script/release/make-branch | 4 +- script/run/run.sh | 2 +- 4 files changed, 101 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ec7d5b57b9..85448fbcb67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,103 @@ Change log ========== +1.9.0 (2016-10-20) +----------------- + +**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. + + +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) ----------------- diff --git a/compose/__init__.py b/compose/__init__.py index 6e61065278b..d74e1aa3a4f 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.9.0-rc1' diff --git a/script/release/make-branch b/script/release/make-branch index 7ccf3f055b5..bf81219d0bf 100755 --- a/script/release/make-branch +++ b/script/release/make-branch @@ -65,8 +65,8 @@ git config "branch.${BRANCH}.release" $VERSION editor=${EDITOR:-vim} -echo "Update versions in docs/install.md, compose/__init__.py, script/run/run.sh" -$editor docs/install.md +echo "Update versions in compose/__init__.py, script/run/run.sh" +# $editor docs/install.md $editor compose/__init__.py $editor script/run/run.sh diff --git a/script/run/run.sh b/script/run/run.sh index 6205747af6f..dec83b54b2d 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.8.0" +VERSION="1.9.0-rc1" IMAGE="docker/compose:$VERSION" From f039c8b43cae5fab4b8a22004ae6ab56d4d698b8 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 20 Oct 2016 17:39:55 -0700 Subject: [PATCH 2487/4072] 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 930af15a8be..c1834f2fbbb 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. - -4. Paste in installation instructions and release notes. Here's an example - change the Compose version and Docker version as appropriate: + 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. - Firstly, note that Compose 1.5.0 requires Docker 1.8.0 or later. +4. Paste in installation instructions and release notes. Here's an example - + change the Compose version and Docker version as appropriate: - 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. + 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)**. - Otherwise, you can use the usual commands to install/upgrade. Either download the binary: + 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. - 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 + Alternatively, you can use the usual commands to install or upgrade Compose: - Or install the PyPi package: + ``` + 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 + ``` - 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 2488/4072] 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 93550f5ac3d..5cf69b05c69 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 2489/4072] 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 3a165dd6724..e57d6b7a16d 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 2490/4072] 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 f85e285f3de..60647fe95cb 100644 --- a/compose/project.py +++ b/compose/project.py @@ -538,6 +538,10 @@ def build_volume_from(spec): 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 2491/4072] 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 f30b9054396..3561a8cf258 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 2492/4072] 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 4fdec08a85b..5b977095568 100644 --- a/compose/cli/errors.py +++ b/compose/cli/errors.py @@ -4,6 +4,7 @@ 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.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 58b95c2f6d6..08e58e3722c 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -10,6 +10,7 @@ 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 1d454a08185..a7b57562f2c 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 2493/4072] 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 e3c34af9c2a..3afefb5aba2 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -826,9 +826,9 @@ def test_up_with_network_link_local_ips(self): 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 @@ def test_up_with_isolation(self): 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 2494/4072] 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 6205747af6f..5872b081a2d 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 2495/4072] 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 fa29520b5f7..5de9a3fb1c2 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 08bf1618829..7151a75e1e3 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 2496/4072] 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 474efbdf321..e72a88e786b 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 442bc80c94f..19ff5c6ae88 100644 --- a/setup.py +++ b/setup.py @@ -34,7 +34,7 @@ def find_version(*file_paths): '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 455fde15c6ff93359b50a1d111c9e049f87e198b Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 27 Oct 2016 12:13:32 -0700 Subject: [PATCH 2497/4072] 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 474efbdf321..e72a88e786b 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 442bc80c94f..19ff5c6ae88 100644 --- a/setup.py +++ b/setup.py @@ -34,7 +34,7 @@ def find_version(*file_paths): '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 ca529d36f8cb755fd64761dcc08d61e3629f732d Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 24 Oct 2016 11:51:45 -0700 Subject: [PATCH 2498/4072] 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 f30b9054396..3561a8cf258 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 29f9594ab93b6cfc4436db43304642bd578615c2 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 24 Oct 2016 12:12:19 -0700 Subject: [PATCH 2499/4072] 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 4fdec08a85b..5b977095568 100644 --- a/compose/cli/errors.py +++ b/compose/cli/errors.py @@ -4,6 +4,7 @@ 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.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 58b95c2f6d6..08e58e3722c 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -10,6 +10,7 @@ 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 1d454a08185..a7b57562f2c 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 a406378a1f555322c79ef4a7d5310fcf6c776f6c Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 24 Oct 2016 11:36:44 -0700 Subject: [PATCH 2500/4072] 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 f85e285f3de..60647fe95cb 100644 --- a/compose/project.py +++ b/compose/project.py @@ -538,6 +538,10 @@ def build_volume_from(spec): 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 77b5ac4e5464071e89ba8aebb22286b303f85d79 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 24 Oct 2016 13:58:45 -0700 Subject: [PATCH 2501/4072] 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 e3c34af9c2a..3afefb5aba2 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -826,9 +826,9 @@ def test_up_with_network_link_local_ips(self): 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 @@ def test_up_with_isolation(self): 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 252d15a4a92ecc20dd65af6f8e0b73393b1c447c Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 24 Oct 2016 10:55:04 -0700 Subject: [PATCH 2502/4072] 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 3a165dd6724..e57d6b7a16d 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 a2da43b997208eb7af90217252ae3cea0efef4ea Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 27 Oct 2016 12:21:05 -0700 Subject: [PATCH 2503/4072] Bump 1.9.0-rc2 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 d74e1aa3a4f..c968d317302 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.0-rc1' +__version__ = '1.9.0-rc2' diff --git a/script/run/run.sh b/script/run/run.sh index dec83b54b2d..f426d6759c5 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.9.0-rc1" +VERSION="1.9.0-rc2" IMAGE="docker/compose:$VERSION" From ba43d08fbdc1e7ec25978cc9068c3a9a7be91aab Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 1 Nov 2016 11:09:29 -0700 Subject: [PATCH 2504/4072] 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 e581a4fe6bf..8e38401ca7d 100644 --- a/compose/network.py +++ b/compose/network.py @@ -12,6 +12,10 @@ 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 00000000000..4720b053053 --- /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 2505/4072] 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 e72a88e786b..933146c72b3 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 19ff5c6ae88..672ea80e5ee 100644 --- a/setup.py +++ b/setup.py @@ -34,7 +34,7 @@ def find_version(*file_paths): '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 e002171ab1afcb4493343012814d526ed7b483dd Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 2 Nov 2016 16:43:29 -0700 Subject: [PATCH 2506/4072] 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 e72a88e786b..933146c72b3 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 19ff5c6ae88..672ea80e5ee 100644 --- a/setup.py +++ b/setup.py @@ -34,7 +34,7 @@ def find_version(*file_paths): '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 e6985de97104fcb5d95fb137b210178758825054 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 1 Nov 2016 11:09:29 -0700 Subject: [PATCH 2507/4072] 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 e581a4fe6bf..8e38401ca7d 100644 --- a/compose/network.py +++ b/compose/network.py @@ -12,6 +12,10 @@ 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 00000000000..4720b053053 --- /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 f9dfb006b59efeaea337c0de6fcc6c209f47c8eb Mon Sep 17 00:00:00 2001 From: Mike Dougherty Date: Fri, 22 Apr 2016 12:56:07 -0700 Subject: [PATCH 2508/4072] 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 fa29520b5f7..5de9a3fb1c2 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 08bf1618829..7151a75e1e3 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 fcd38d3c4b1c4d8788dba802217779272362ccee Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 2 Nov 2016 17:00:05 -0700 Subject: [PATCH 2509/4072] Bump 1.9.0-rc3 Signed-off-by: Joffrey F --- CHANGELOG.md | 3 +++ compose/__init__.py | 2 +- script/run/run.sh | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 85448fbcb67..937f3858944 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,9 @@ Bug Fixes - 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) ----------------- diff --git a/compose/__init__.py b/compose/__init__.py index c968d317302..753bda04091 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.0-rc2' +__version__ = '1.9.0-rc3' diff --git a/script/run/run.sh b/script/run/run.sh index f426d6759c5..663e5592d7a 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.9.0-rc2" +VERSION="1.9.0-rc3" IMAGE="docker/compose:$VERSION" From da1508051dcc7f106f891078b688bc31db7e43c5 Mon Sep 17 00:00:00 2001 From: Mike Dougherty Date: Thu, 3 Nov 2016 13:59:56 -0700 Subject: [PATCH 2510/4072] 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 5de9a3fb1c2..e2f86daad60 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 2511/4072] 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 437ed3892a7..9d23b34da03 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 d205e28253e..66ae0147427 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1657,6 +1657,51 @@ def test_merge_logging_v2_no_override_options(self): } } + 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 2512/4072] 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 8e38401ca7d..06935a46b00 100644 --- a/compose/network.py +++ b/compose/network.py @@ -53,14 +53,7 @@ def ensure(self): 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 f4c037d223d4b485d03d7d0862ce5e2ecfc323e5 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 4 Nov 2016 10:51:14 -0700 Subject: [PATCH 2513/4072] 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 8e38401ca7d..06935a46b00 100644 --- a/compose/network.py +++ b/compose/network.py @@ -53,14 +53,7 @@ def ensure(self): 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 6d02f3fb2303b1611b13196ae36ae6d9267848f5 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 3 Nov 2016 16:28:44 -0700 Subject: [PATCH 2514/4072] 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 437ed3892a7..9d23b34da03 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 d205e28253e..66ae0147427 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1657,6 +1657,51 @@ def test_merge_logging_v2_no_override_options(self): } } + 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 181a4e990eeb2ff2f92bf41486f5d31f35d38137 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 8 Nov 2016 15:22:19 -0800 Subject: [PATCH 2515/4072] Bump 1.9.0-rc4 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 753bda04091..2638cfd5a9e 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.0-rc3' +__version__ = '1.9.0-rc4' diff --git a/script/run/run.sh b/script/run/run.sh index 663e5592d7a..c205cf0f38c 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.9.0-rc3" +VERSION="1.9.0-rc4" IMAGE="docker/compose:$VERSION" From ba249e51796e6b35ed13e5f03716a9520a2dacfc Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 9 Nov 2016 15:10:02 +0000 Subject: [PATCH 2516/4072] 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 66ae0147427..51c5e226b21 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -2493,6 +2493,15 @@ def test_resolve_environment_from_env_file(self): {'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 2517/4072] 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 9d23b34da03..57039e69396 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -83,6 +83,7 @@ '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 3561a8cf258..fc95f2cbaf0 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 760d29a7bc6..c917aac4588 100644 --- a/compose/service.py +++ b/compose/service.py @@ -62,6 +62,7 @@ 'restart', 'security_opt', 'shm_size', + 'sysctls', 'volumes_from', ] @@ -707,6 +708,7 @@ def _get_container_host_config(self, override_options, one_off=False): 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 2518/4072] 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 e2f86daad60..51136b1f78e 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 2519/4072] 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 760d29a7bc6..ad42670625d 100644 --- a/compose/service.py +++ b/compose/service.py @@ -10,6 +10,7 @@ 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 @@ def pull(self, ignore_pull_failures=False): 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 a7cd78f1866..f153bd95b89 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -330,12 +330,13 @@ def test_pull_with_digest(self): 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 2520/4072] 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 06935a46b00..3b57cb94ed5 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 4720b053053..12d06f415e2 100644 --- a/tests/unit/network_test.py +++ b/tests/unit/network_test.py @@ -37,7 +37,9 @@ def test_check_remote_network_config_whitelist(self): 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 @@ def test_check_remote_network_config_options_mismatch(self): 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 7e40754ffceb8e43584039fafd39394f64adf395 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 14 Nov 2016 18:03:26 -0800 Subject: [PATCH 2521/4072] 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 760d29a7bc6..ad42670625d 100644 --- a/compose/service.py +++ b/compose/service.py @@ -10,6 +10,7 @@ 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 @@ def pull(self, ignore_pull_failures=False): 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 a7cd78f1866..f153bd95b89 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -330,12 +330,13 @@ def test_pull_with_digest(self): 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 efda1efffef4823537004e833532b8367c939263 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 9 Nov 2016 15:38:09 -0800 Subject: [PATCH 2522/4072] 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 06935a46b00..3b57cb94ed5 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 4720b053053..12d06f415e2 100644 --- a/tests/unit/network_test.py +++ b/tests/unit/network_test.py @@ -37,7 +37,9 @@ def test_check_remote_network_config_whitelist(self): 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 @@ def test_check_remote_network_config_options_mismatch(self): 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 4d85caf1439476884d29f4decf9e78603ced8fd5 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 14 Nov 2016 17:23:25 -0800 Subject: [PATCH 2523/4072] 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 5de9a3fb1c2..19ccb4bbc96 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -10,7 +10,7 @@ def checkDocs = { -> } def buildImage = { -> - wrappedNode(label: "linux && !zfs") { + wrappedNode(label: "ubuntu && !zfs", cleanWorkspace: true) { stage("build image") { deleteDir(); checkout(scm) def imageName = "dockerbuildbot/compose:${gitCommit()}" @@ -37,7 +37,7 @@ def runTests = { Map settings -> } { -> - wrappedNode(label: "linux && !zfs") { + wrappedNode(label: "ubuntu && !zfs", cleanWorkspace: true) { 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() From 46f034705eba65ad7cdb3ee04ae50b51ab9af1c6 Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 9 Nov 2016 15:10:02 +0000 Subject: [PATCH 2524/4072] 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 66ae0147427..51c5e226b21 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -2493,6 +2493,15 @@ def test_resolve_environment_from_env_file(self): {'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 25853874c457851245cc2b4d5beb26f41a28cded Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 15 Nov 2016 15:03:43 -0800 Subject: [PATCH 2525/4072] Bump 1.9.0 Signed-off-by: Joffrey F --- CHANGELOG.md | 2 +- compose/__init__.py | 2 +- script/run/run.sh | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 937f3858944..174878909e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ Change log ========== -1.9.0 (2016-10-20) +1.9.0 (2016-11-16) ----------------- **Breaking changes** diff --git a/compose/__init__.py b/compose/__init__.py index 2638cfd5a9e..072a6a0b294 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.0-rc4' +__version__ = '1.9.0' diff --git a/script/run/run.sh b/script/run/run.sh index c205cf0f38c..85c7e720eef 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.9.0-rc4" +VERSION="1.9.0" IMAGE="docker/compose:$VERSION" From d717c88b6e81f5eb0769bc0670a6b78de842b2ce Mon Sep 17 00:00:00 2001 From: Aanand Prasad Date: Wed, 9 Nov 2016 17:16:36 +0000 Subject: [PATCH 2526/4072] 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 9d23b34da03..fb77436d131 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -15,6 +15,7 @@ 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 @@ def version(self): 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 00000000000..9ac31b1f861 --- /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 d14cbbdd0c2..16ed01b8614 100644 --- a/compose/config/errors.py +++ b/compose/config/errors.py @@ -3,8 +3,8 @@ 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 95b1387fcc6..768f3d4738c 100644 --- a/compose/config/serialize.py +++ b/compose/config/serialize.py @@ -6,7 +6,6 @@ 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 e7b1ae97a92..3da39855156 100644 --- a/compose/const.py +++ b/compose/const.py @@ -17,15 +17,18 @@ 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 f153bd95b89..e9a41691917 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -285,6 +285,61 @@ def test_config_v1(self): '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 00000000000..1187dd7b2ff --- /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 51c5e226b21..114145e1efe 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -18,6 +18,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.environment import Environment from compose.config.errors import ConfigurationError from compose.config.errors import VERSION_EXPLANATION @@ -156,9 +157,14 @@ def test_valid_versions(self): 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 2527/4072] 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 fb77436d131..940b9eb0643 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 2528/4072] 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 00000000000..16ef8a6dc91 --- /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 00000000000..e9fe6c24c57 --- /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 2529/4072] 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 08e58e3722c..cf53f6aa45d 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -24,7 +24,6 @@ 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 @@ def scale(self, options): -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 @@ def stop(self, options): -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 @@ def restart(self, options): -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 @@ def up(self, options): 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 7ac66b37a01..267188728cf 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 60647fe95cb..eef2f3b851b 100644 --- a/compose/project.py +++ b/compose/project.py @@ -14,7 +14,6 @@ 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 @@ def get_deps(container): 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 @@ def remove_images(self, remove_image_type): 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 @@ def up(self, start_deps=True, strategy=ConvergenceStrategy.changed, do_build=BuildAction.none, - timeout=DEFAULT_TIMEOUT, + timeout=None, detached=False, remove_orphans=False): @@ -506,6 +510,14 @@ def _inject_deps(self, acc, service): 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 ad42670625d..39737694de4 100644 --- a/compose/service.py +++ b/compose/service.py @@ -17,6 +17,7 @@ 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 @@ def start(self, **options): 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 @@ def create_and_start(service, number): 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 @@ def _containers_have_diverged(self, containers): def execute_convergence_plan(self, plan, - timeout=DEFAULT_TIMEOUT, + timeout=None, detached=False, start=True): (action, containers) = plan @@ -421,7 +422,7 @@ def execute_convergence_plan(self, def recreate_container( self, container, - timeout=DEFAULT_TIMEOUT, + timeout=None, attach_logs=False, start_new_container=True): """Recreate a container. @@ -432,7 +433,7 @@ def recreate_container( """ 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 @@ def recreate_container( 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 @@ def connect_container_to_networks(self, container): 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 e9fe6c24c57..9915932c300 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 2530/4072] Changelog update Signed-off-by: Joffrey F --- CHANGELOG.md | 100 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ec7d5b57b9..174878909e4 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 2531/4072] 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 940b9eb0643..e5a37e84119 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -17,6 +17,7 @@ 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 @@ '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 9ac31b1f861..4edd8dd44ea 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 39737694de4..cf52d4893dd 100644 --- a/compose/service.py +++ b/compose/service.py @@ -17,7 +17,6 @@ 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 .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 @@ def recreate_container( 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 8f05e3081ff..b8bdf732f91 100644 --- a/compose/utils.py +++ b/compose/utils.py @@ -11,6 +11,7 @@ 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 933146c72b3..63469799d17 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 e9a41691917..97518f4f04a 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -21,6 +21,7 @@ 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 @@ def test_config_v3(self): }, 'healthcheck': { - 'command': 'cat /etc/passwd', - 'interval': '10s', - 'timeout': '1s', + 'test': 'cat /etc/passwd', + 'interval': 10000000000, + 'timeout': 1000000000, 'retries': 5, }, @@ -925,6 +926,49 @@ def test_up_with_net_v1(self): 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 00000000000..2c45b8d8cd4 --- /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 1187dd7b2ff..b4d1b6422f3 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 114145e1efe..f7df3aee4a2 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -24,6 +24,7 @@ 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 @@ def test_invalid_url_in_build_path(self): 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 2532/4072] 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 940b9eb0643..5215b361f4b 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 eef2f3b851b..0178bbab060 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 2533/4072] 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 174878909e4..9780df98aea 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 33d0d7772db..d5ae3de9dab 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 2534/4072] 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 a0f5601f111..5314f89fdb3 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 2535/4072] 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 cf53f6aa45d..c25ccbfa485 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -988,6 +988,7 @@ def remove_container(force=False): 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 b0b9a3703ddfb0ce357ca09233e5f02caccf03ef Mon Sep 17 00:00:00 2001 From: Bheesham Persaud Date: Sat, 3 Dec 2016 02:17:01 -0500 Subject: [PATCH 2536/4072] Add fish completion to contrib. Signed-off-by: Bheesham Persaud --- contrib/completion/fish/docker-compose.fish | 24 +++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 contrib/completion/fish/docker-compose.fish diff --git a/contrib/completion/fish/docker-compose.fish b/contrib/completion/fish/docker-compose.fish new file mode 100644 index 00000000000..69ecc505685 --- /dev/null +++ b/contrib/completion/fish/docker-compose.fish @@ -0,0 +1,24 @@ +# Tab completion for docker-compose (https://github.com/docker/compose). +# Version: 1.9.0 + +complete -e -c docker-compose + +for line in (docker-compose --help | \ + string match -r '^\s+\w+\s+[^\n]+' | \ + string trim) + set -l doc (string split -m 1 ' ' -- $line) + complete -c docker-compose -n '__fish_use_subcommand' -xa $doc[1] --description $doc[2] +end + +complete -c docker-compose -s f -l file -r -d 'Specify an alternate compose file' +complete -c docker-compose -s p -l project-name -x -d 'Specify an alternate project name' +complete -c docker-compose -l verbose -d 'Show more output' +complete -c docker-compose -s H -l host -x -d 'Daemon socket to connect to' +complete -c docker-compose -l tls -d 'Use TLS; implied by --tlsverify' +complete -c docker-compose -l tlscacert -r -d 'Trust certs signed only by this CA' +complete -c docker-compose -l tlscert -r -d 'Path to TLS certificate file' +complete -c docker-compose -l tlskey -r -d 'Path to TLS key file' +complete -c docker-compose -l tlsverify -d 'Use TLS and verify the remote' +complete -c docker-compose -l skip-hostname-check -d "Don't check the daemon's hostname against the name specified in the client certificate (for example if your docker host is an IP address)" +complete -c docker-compose -s h -l help -d 'Print usage' +complete -c docker-compose -s v -l version -d 'Print version and exit' From fbcc1510cca36c6e2e049b7692b11eccc8c33327 Mon Sep 17 00:00:00 2001 From: Danny Guo Date: Fri, 17 Jun 2016 18:41:40 -0400 Subject: [PATCH 2537/4072] Handle giving help a nonexistent command The CLI would show an unhandled exception when running: $ docker-compose help foobar Now, it lists the commands. Signed-off-by: Danny Guo --- .gitignore | 3 +++ compose/cli/main.py | 15 ++++++--------- tests/acceptance/cli_test.py | 6 ++++++ 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/.gitignore b/.gitignore index 4b318e2328c..ef04ca15fa9 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,6 @@ /venv README.rst compose/GITSHA +*.swo +*.swp +.DS_Store diff --git a/compose/cli/main.py b/compose/cli/main.py index cf53f6aa45d..43b9a374fe9 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -58,9 +58,8 @@ def main(): - command = dispatch() - try: + command = dispatch() command() except (KeyboardInterrupt, signals.ShutdownException): log.error("Aborting.") @@ -78,6 +77,10 @@ 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 NoSuchCommand as e: + commands = "\n".join(parse_doc_section("commands:", getdoc(e.supercommand))) + log.error("No such command: %s\n\n%s", e.command, commands) + sys.exit(1) except (errors.ConnectionError, StreamParseError): sys.exit(1) @@ -88,13 +91,7 @@ def dispatch(): 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) - + options, handler, command_options = dispatcher.parse(sys.argv[1:]) setup_console_handler(console_handler, options.get('--verbose')) return functools.partial(perform_command, options, handler, command_options) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index e9a41691917..2dfda03f852 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -150,6 +150,12 @@ def test_help(self): # Prevent tearDown from trying to create a project self.base_dir = None + def test_help_nonexistent(self): + self.base_dir = 'tests/fixtures/no-composefile' + result = self.dispatch(['help', 'foobar'], returncode=1) + assert 'No such command' in result.stderr + self.base_dir = None + def test_shorthand_host_opt(self): self.dispatch( ['-H={0}'.format(os.environ.get('DOCKER_HOST', 'unix://')), From 62f8b1402e1bbe726cf83ea204077754385fe53a Mon Sep 17 00:00:00 2001 From: Yong Wen Chua Date: Mon, 5 Dec 2016 14:25:56 +0800 Subject: [PATCH 2538/4072] 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 5215b361f4b..b83b12bff35 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -86,6 +86,7 @@ '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 3561a8cf258..b5d614d6ef2 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 9ac31b1f861..297c60be035 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 39737694de4..6a62067b8c1 100644 --- a/compose/service.py +++ b/compose/service.py @@ -64,6 +64,7 @@ 'restart', 'security_opt', 'shm_size', + 'userns_mode', 'volumes_from', ] @@ -720,7 +721,8 @@ def _get_container_host_config(self, override_options, one_off=False): 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 a5ca81ee354..09758eee940 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -30,6 +30,7 @@ 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 @@ def test_pid_mode_host(self): 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 2539/4072] 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 6e61065278b..6f05b282f26 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 2540/4072] Increase minimum version for v3. Signed-off-by: Daniel Nephin --- compose/const.py | 4 ++-- tests/acceptance/cli_test.py | 2 ++ tests/integration/testcases.py | 21 ++++++++++----------- 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/compose/const.py b/compose/const.py index 3da39855156..ca8d7fe512b 100644 --- a/compose/const.py +++ b/compose/const.py @@ -23,12 +23,12 @@ 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 e9a41691917..0d5d5058823 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -26,6 +26,7 @@ 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 @@ def test_config_v1(self): '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 c7743fb83b8..f6bc402bf7f 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 wrapper(self, *args, **kwargs): return decorator +def v2_only(): + return build_version_required_decorator((V1,)) + + 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 build_version_required_decorator((V1, V2_0)) - return decorator + +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 2541/4072] 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 b196d3036fb..018d24513dd 100644 --- a/compose/cli/docker_client.py +++ b/compose/cli/docker_client.py @@ -3,7 +3,7 @@ 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 3b57cb94ed5..8a29b73e235 100644 --- a/compose/network.py +++ b/compose/network.py @@ -4,8 +4,8 @@ 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 cf52d4893dd..c32788fe2ec 100644 --- a/compose/service.py +++ b/compose/service.py @@ -11,7 +11,7 @@ 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 63469799d17..8f6fe1695c9 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 672ea80e5ee..9dba2167054 100644 --- a/setup.py +++ b/setup.py @@ -34,7 +34,7 @@ def find_version(*file_paths): '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 223b3b07a2c..a279cab050a 100644 --- a/tests/unit/bundle_test.py +++ b/tests/unit/bundle_test.py @@ -15,7 +15,7 @@ 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 2c90b29b72c..f9b60bff53b 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -97,7 +97,7 @@ def test_command_help_nonexistent(self): @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 @@ def test_run_interactive_passes_logs_false(self, mock_pseudo_terminal, mock_run_ 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 62e3aa2cfc1..04f43016291 100644 --- a/tests/unit/container_test.py +++ b/tests/unit/container_test.py @@ -98,7 +98,7 @@ def test_name_without_project_custom_container_name(self): 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 9569adc907f..9a12438f2d1 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -19,7 +19,7 @@ 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 1d5aa10fb7f..2d5b176195a 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -34,7 +34,7 @@ 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 @@ def test_only_log_warning_when_host_ports_clash(self, mock_log): 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 @@ def test_network_mode_container(self): 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 @@ def test_network_mode_service(self): 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 d7ad0792879..24829192a17 100644 --- a/tests/unit/volume_test.py +++ b/tests/unit/volume_test.py @@ -10,7 +10,7 @@ @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 2542/4072] 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 856b8f93c90..2d0ce715572 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -316,8 +316,8 @@ def test_config_v3(self): 'memory': '50M', }, 'reservations': { - 'cpus': '0.0001', - 'memory': '20M', + 'cpus': '0.0001', + 'memory': '20M', }, }, 'restart_policy': { @@ -928,6 +928,7 @@ def test_up_with_net_v1(self): 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 @@ def condition(): 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 2543/4072] 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 8a29b73e235..eb76e292095 100644 --- a/compose/network.py +++ b/compose/network.py @@ -6,6 +6,7 @@ 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 @@ def ensure(self): 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 2544/4072] 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 8b8ceac07db..1f93347f980 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 2545/4072] 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 3c18886f853..6677a376a1c 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 8f6fe1695c9..bae5d9ea1e2 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 9dba2167054..8b4cf709e02 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 2546/4072] 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 ca8d7fe512b..1b1be5c7686 100644 --- a/compose/const.py +++ b/compose/const.py @@ -11,7 +11,9 @@ 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 eb76e292095..d98f68d2fb7 100644 --- a/compose/network.py +++ b/compose/network.py @@ -7,8 +7,11 @@ 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 @@ def ensure(self): 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 @@ def full_name(self): 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 1fd1d51c97f..ab6a88fac8c 100644 --- a/compose/volume.py +++ b/compose/volume.py @@ -4,8 +4,11 @@ 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 @@ def __init__(self, client, project, name, driver=None, driver_opts=None, 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 @@ def full_name(self): 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 2d0ce715572..b9766226d9f 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -850,8 +850,8 @@ def test_up_with_network_labels(self): ] 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 @@ def test_up_with_volume_labels(self): ] 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 00000000000..2ff610fbf81 --- /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 3afefb5aba2..de0732393b9 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -942,8 +942,8 @@ def test_project_up_with_network_label(self): ] 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 @@ def test_project_up_with_volume_labels(self): 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 a75250ac713..add169623b0 100644 --- a/tests/integration/volume_test.py +++ b/tests/integration/volume_test.py @@ -4,6 +4,8 @@ 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 @@ def test_exists_external_aliased(self): 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 2547/4072] 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 1f93347f980..6212058c427 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 2548/4072] 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 e3862b6ea06..ee8c88a925a 100644 --- a/compose/service.py +++ b/compose/service.py @@ -10,6 +10,7 @@ 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 @@ def ensure_image_exists(self, do_build=BuildAction.none): 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 f01ecda83cb442294e275c82985b235d5b176300 Mon Sep 17 00:00:00 2001 From: Allan de Queiroz Date: Wed, 4 Jan 2017 11:40:41 +0000 Subject: [PATCH 2549/4072] Fixing bash path problem, avoiding 'docker-compose-completion.sh: bash: bad interpreter: No such file or directory' error Signed-off-by: Allan de Queiroz --- 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 991f6572941..9c1b5bf495e 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -1,4 +1,4 @@ -#!bash +#!/bin/bash # # bash completion for docker-compose # From 2648af6807f83f0dd85b236e89e4bc3ee5db15fc Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Tue, 22 Nov 2016 11:09:56 +0000 Subject: [PATCH 2550/4072] 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 4e76d64ffac..c6852af1483 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 bdd98f03e76..28aa2047aeb 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 d5ae3de9dab..d1a9e3f6648 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 00000000000..3c6e79cf31d --- /dev/null +++ b/setup.cfg @@ -0,0 +1,2 @@ +[bdist_wheel] +universal=1 diff --git a/setup.py b/setup.py index 8b4cf709e02..00ca9f4c7b4 100644 --- a/setup.py +++ b/setup.py @@ -4,10 +4,12 @@ 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 @@ def find_version(*file_paths): 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 @@ def find_version(*file_paths): 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 2551/4072] 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 3057f2deeb0..5ab9f71fbb0 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 2552/4072] 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 e57d6b7a16d..ec5a2039cc9 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 2553/4072] 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 dccd11e0ded..73a34017293 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 7452e9849bb..3f23f0a7aea 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 376cc555886..415b41e7f04 100644 --- a/compose/errors.py +++ b/compose/errors.py @@ -10,3 +10,24 @@ def __init__(self, reason): 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 267188728cf..b2654dcfd91 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 0178bbab060..d99ef7c93f1 100644 --- a/compose/project.py +++ b/compose/project.py @@ -227,7 +227,10 @@ def start_service(service): 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 @@ def stop(self, service_names=None, one_off=OneOffFilter.exclude, **options): 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 @@ def do(service): ) 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 e3862b6ea06..1338f15400f 100644 --- a/compose/service.py +++ b/compose/service.py @@ -28,6 +28,8 @@ 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 @@ 'volumes_from', ] +CONDITION_STARTED = 'service_started' +CONDITION_HEALTHY = 'service_healthy' + class BuildError(Exception): def __init__(self, service, reason): @@ -533,10 +538,38 @@ def config_dict(self): 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 @@ def push(self, ignore_push_failures=False): 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 b9766226d9f..77d578404db 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -928,7 +928,7 @@ def test_up_with_net_v1(self): 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 de0732393b9..855974de106 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -19,6 +19,8 @@ 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 @@ def test_project_up_orphans(self): 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 f7df3aee4a2..7a68333ff51 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -920,7 +920,10 @@ def test_load_with_multiple_files_v2(self): '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 @@ def test_extends_with_depends_on(self): 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 479c0f1d371..2a50b718948 100644 --- a/tests/unit/parallel_test.py +++ b/tests/unit/parallel_test.py @@ -25,7 +25,7 @@ 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 2554/4072] 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 77d578404db..b9766226d9f 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -928,7 +928,7 @@ def test_up_with_net_v1(self): 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 7a68333ff51..31a888ed007 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -921,8 +921,8 @@ def test_load_with_multiple_files_v2(self): '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 @@ def test_extends_with_depends_on(self): """) 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 2555/4072] 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 73a34017293..fd935591f6a 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 5ab9f71fbb0..d0d5233a543 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 1f93347f980..8d075d47ac3 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 855974de106..c5e3cf50ffb 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -1463,7 +1463,7 @@ def test_project_up_no_healthcheck_dependency(self): 'image': 'busybox:latest', 'command': 'top', 'healthcheck': { - 'disabled': True + 'disable': True }, }, 'svc2': { From f90618fc43471355702c703909cc2d92813498f2 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 4 Jan 2017 13:14:23 -0800 Subject: [PATCH 2556/4072] 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 73a34017293..fd935591f6a 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 5ab9f71fbb0..d0d5233a543 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 1f93347f980..8d075d47ac3 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 855974de106..c5e3cf50ffb 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -1463,7 +1463,7 @@ def test_project_up_no_healthcheck_dependency(self): 'image': 'busybox:latest', 'command': 'top', 'healthcheck': { - 'disabled': True + 'disable': True }, }, 'svc2': { From ecff6f1a9a6f56eeaff378da814dafde9dd53b02 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 4 Jan 2017 13:47:13 -0800 Subject: [PATCH 2557/4072] Bump 1.10.0-rc1 Signed-off-by: Joffrey F --- CHANGELOG.md | 40 ++++++++++++++++++++++++++++++++++++++++ compose/__init__.py | 2 +- script/run/run.sh | 2 +- 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9780df98aea..3653a4343d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,46 @@ 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. + + - Added support for the `stop_grace_period` option in service definitions. + +#### 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 + +### 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). + + 1.9.0 (2016-11-16) ----------------- diff --git a/compose/__init__.py b/compose/__init__.py index 6f05b282f26..f1bad0e1a6a 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.10.0-rc1' diff --git a/script/run/run.sh b/script/run/run.sh index 5872b081a2d..5354b5f0d4f 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.8.0" +VERSION="1.10.0-rc1" IMAGE="docker/compose:$VERSION" From 1be41f59c9119c72b3c39045e4e4031608fa18df Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 4 Jan 2017 14:30:20 -0800 Subject: [PATCH 2558/4072] 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 76688916925..77494715cd2 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 d0d5233a543..97ec5fa1d3c 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 8d075d47ac3..2b410446a4b 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 2559/4072] 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 fd935591f6a..c11460fa593 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 5d6b5af690c..7b92693002c 100644 --- a/compose/config/environment.py +++ b/compose/config/environment.py @@ -105,3 +105,14 @@ def get(self, key, *args, **kwargs): 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 00000000000..20446d2bf2d --- /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 2560/4072] 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 77494715cd2..59c7b30c965 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 97ec5fa1d3c..d1ffff89a22 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 2b410446a4b..194fd8e6e51 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 2561/4072] 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 bae5d9ea1e2..4b7c7b76050 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 8b4cf709e02..7954d92bba9 100644 --- a/setup.py +++ b/setup.py @@ -35,7 +35,7 @@ def find_version(*file_paths): '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 344f015a22901b0b489f814ec1772e05b89981f1 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 6 Jan 2017 17:45:57 -0800 Subject: [PATCH 2562/4072] 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 bae5d9ea1e2..4b7c7b76050 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 8b4cf709e02..7954d92bba9 100644 --- a/setup.py +++ b/setup.py @@ -35,7 +35,7 @@ def find_version(*file_paths): '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 de38c023ce37c83700e187c0fd959aa2e3beb5fb Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 4 Jan 2017 15:26:11 -0800 Subject: [PATCH 2563/4072] 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 fd935591f6a..c11460fa593 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 5d6b5af690c..7b92693002c 100644 --- a/compose/config/environment.py +++ b/compose/config/environment.py @@ -105,3 +105,14 @@ def get(self, key, *args, **kwargs): 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 00000000000..20446d2bf2d --- /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 22b837975d71da99e9019a48556a1e460f8b929a Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 4 Jan 2017 14:30:20 -0800 Subject: [PATCH 2564/4072] 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 76688916925..77494715cd2 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 d0d5233a543..97ec5fa1d3c 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 8d075d47ac3..2b410446a4b 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 91851cd5ae8890ec3430f624041384a483d7cd40 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Wed, 28 Dec 2016 15:16:53 -0500 Subject: [PATCH 2565/4072] 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 2b410446a4b..df321b20cde 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 cbb730172ff94ea584c6d85b73c96aef35bd058a Mon Sep 17 00:00:00 2001 From: Jun Guo Date: Wed, 4 Jan 2017 15:31:12 +0800 Subject: [PATCH 2566/4072] 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 1338f15400f..20a40c684f7 100644 --- a/compose/service.py +++ b/compose/service.py @@ -10,6 +10,7 @@ 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 @@ -323,11 +324,8 @@ def ensure_image_exists(self, do_build=BuildAction.none): 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 52792b7a963af9c593e61c78c7f0c7f62550a85b Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 10 Jan 2017 14:57:32 -0800 Subject: [PATCH 2567/4072] 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 0ceb2a22a6e..2f2ba7429d1 100644 --- a/setup.py +++ b/setup.py @@ -53,7 +53,9 @@ def find_version(*file_paths): 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 @@ def find_version(*file_paths): 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 340a3fc09c02816420b6234ae57820f724884710 Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Tue, 22 Nov 2016 11:09:56 +0000 Subject: [PATCH 2568/4072] 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 4e76d64ffac..c6852af1483 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 bdd98f03e76..28aa2047aeb 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 d5ae3de9dab..d1a9e3f6648 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 00000000000..3c6e79cf31d --- /dev/null +++ b/setup.cfg @@ -0,0 +1,2 @@ +[bdist_wheel] +universal=1 diff --git a/setup.py b/setup.py index 7954d92bba9..0ceb2a22a6e 100644 --- a/setup.py +++ b/setup.py @@ -4,10 +4,12 @@ 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 @@ def find_version(*file_paths): 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 @@ def find_version(*file_paths): 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 740a6842e80ed64d416917a28c9ecfd5fd2d741e Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 10 Jan 2017 14:57:32 -0800 Subject: [PATCH 2569/4072] 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 0ceb2a22a6e..2f2ba7429d1 100644 --- a/setup.py +++ b/setup.py @@ -53,7 +53,9 @@ def find_version(*file_paths): 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 @@ def find_version(*file_paths): 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 2570/4072] 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 c6852af1483..de46e35e5f5 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 28aa2047aeb..3590ce14e41 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 71dd874600a5b5f0f8145289ef2607f352e817b8 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 10 Jan 2017 16:43:26 -0800 Subject: [PATCH 2571/4072] 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 c6852af1483..de46e35e5f5 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 28aa2047aeb..3590ce14e41 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 fb241d0906cd667fec0a36449883d905a00992a1 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 9 Jan 2017 16:39:37 -0800 Subject: [PATCH 2572/4072] Bump 1.10.0-rc2 Signed-off-by: Joffrey F --- CHANGELOG.md | 12 ++++++++++-- compose/__init__.py | 2 +- script/run/run.sh | 2 +- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3653a4343d0..f14bc99e83d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,8 +12,6 @@ Change log version requires to be used with Docker Engine 1.13 or above and is specifically designed to work with the `docker stack` commands. - - Added support for the `stop_grace_period` option in service definitions. - #### Compose file version 2.1 and up - Healthcheck configuration can now be done in the service definition using @@ -30,6 +28,10 @@ Change log - 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. @@ -40,6 +42,12 @@ Change log - 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 f1bad0e1a6a..476033b2a73 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.0-rc1' +__version__ = '1.10.0-rc2' diff --git a/script/run/run.sh b/script/run/run.sh index 5354b5f0d4f..51385a57510 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.10.0-rc1" +VERSION="1.10.0-rc2" IMAGE="docker/compose:$VERSION" From 29b46d5b26055ade86a2e0e608342dd298e5a8c3 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 11 Jan 2017 15:39:48 -0800 Subject: [PATCH 2573/4072] 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 d1a9e3f6648..9db6f68941c 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 2574/4072] 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 768f3d4738c..05ac0d60df8 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 31a888ed007..ca7c61683b0 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -22,6 +22,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.types import VolumeSpec from compose.const import IS_WINDOWS_PLATFORM from compose.utils import nanoseconds_from_time_seconds @@ -3269,3 +3270,33 @@ def make_files(dirname, filenames): 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 2575/4072] 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 1b1be5c7686..354c6d76a34 100644 --- a/compose/const.py +++ b/compose/const.py @@ -5,7 +5,7 @@ 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 2576/4072] 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 b2654dcfd91..e495410cff8 100644 --- a/compose/parallel.py +++ b/compose/parallel.py @@ -12,6 +12,8 @@ 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 c5e3cf50ffb..ee2b7817bdc 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -1443,7 +1443,7 @@ def test_project_up_unhealthy_dependency(self): 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 @@ def test_project_up_no_healthcheck_dependency(self): 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 ab97716a95cedaba6ea17a980994ea7d778aa4a1 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 11 Jan 2017 15:39:48 -0800 Subject: [PATCH 2577/4072] 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 d1a9e3f6648..9db6f68941c 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 76678747c77868d78fb85cfcf3fbc8c6a591f553 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 11 Jan 2017 16:25:40 -0800 Subject: [PATCH 2578/4072] 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 768f3d4738c..05ac0d60df8 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 31a888ed007..ca7c61683b0 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -22,6 +22,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.types import VolumeSpec from compose.const import IS_WINDOWS_PLATFORM from compose.utils import nanoseconds_from_time_seconds @@ -3269,3 +3270,33 @@ def make_files(dirname, filenames): 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 4302861b21dc56017d01b77a6ab528d2d86bf072 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 17 Jan 2017 13:22:16 -0800 Subject: [PATCH 2579/4072] 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 b2654dcfd91..e495410cff8 100644 --- a/compose/parallel.py +++ b/compose/parallel.py @@ -12,6 +12,8 @@ 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 c5e3cf50ffb..ee2b7817bdc 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -1443,7 +1443,7 @@ def test_project_up_unhealthy_dependency(self): 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 @@ def test_project_up_no_healthcheck_dependency(self): 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 4bd6f1a0d834296e80425db1bfaf1536ea540697 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 17 Jan 2017 13:25:28 -0800 Subject: [PATCH 2580/4072] Bump 1.10.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 476033b2a73..4c7da2aca69 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.0-rc2' +__version__ = '1.10.0' diff --git a/script/run/run.sh b/script/run/run.sh index 51385a57510..f965b9f0a38 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.10.0-rc2" +VERSION="1.10.0" IMAGE="docker/compose:$VERSION" From 1a02121ab55876f92bcceb62d1e81b7f114f0c79 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 18 Jan 2017 17:52:03 -0800 Subject: [PATCH 2581/4072] 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 c11460fa593..7e77421e520 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 @@ def parse_dict_or_list(split_func, type_name, arguments): 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 ca7c61683b0..ab8bfcfcc46 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1713,6 +1713,40 @@ def test_merge_logging_v2_no_override(self): } } + 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 2582/4072] 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 45ead14387a..0c3b8162dbb 100755 --- a/script/test/versions.py +++ b/script/test/versions.py @@ -5,7 +5,7 @@ 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 2583/4072] 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 7c645ca2511..6699f880735 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 6f05b282f26..38417836476 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 2584/4072] 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 ae4c0530063..584b6ef5d8c 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 b9766226d9f..ce31dd18f3b 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -295,7 +295,13 @@ def test_config_v3(self): 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 b4d1b6422f3..a1661ab9363 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 f6bc402bf7f..230bd2d9250 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -13,6 +13,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.environment import Environment from compose.const import API_VERSIONS from compose.const import LABEL_PROJECT @@ -36,13 +37,15 @@ def format_link(link): 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 2585/4072] 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 05ac0d60df8..9ea287a468a 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 b9766226d9f..287c043c0b6 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -262,6 +262,20 @@ def test_config_external_network(self): } } + 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 00000000000..da711ac42bb --- /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 00000000000..05c6c4844fe --- /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 2586/4072] 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 584b6ef5d8c..fbcd8bb859a 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 2587/4072] 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 20a40c684f7..724e05652c0 100644 --- a/compose/service.py +++ b/compose/service.py @@ -22,6 +22,7 @@ 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 @@ def build(self, no_cache=False, pull=False, force_rm=False): 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 2588/4072] 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 9ea287a468a..3745de82dbc 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 41a06c9502e..58160c80265 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -353,8 +353,8 @@ def test_config_v3(self): '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 ab8bfcfcc46..d7947a4e802 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -23,6 +23,7 @@ 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 @@ def test_denormalize_depends_on_v2_1(self): } 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 2589/4072] 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 c25ccbfa485..db068272a8e 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -14,6 +14,30 @@ 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 2590/4072] 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 3f1dbd75b8c..27f610ca940 100644 --- a/requirements-build.txt +++ b/requirements-build.txt @@ -1 +1 @@ -pyinstaller==3.1.1 +pyinstaller==3.2.1 From 5b912082e1fac0da43313c552baf857a50a38e76 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 18 Jan 2017 17:52:03 -0800 Subject: [PATCH 2591/4072] 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 c11460fa593..7e77421e520 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 @@ def parse_dict_or_list(split_func, type_name, arguments): 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 ca7c61683b0..ab8bfcfcc46 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1713,6 +1713,40 @@ def test_merge_logging_v2_no_override(self): } } + 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 e035931f2ee073df04c4d1b4abfa0b0cf1dbde4c Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 19 Jan 2017 15:41:31 -0800 Subject: [PATCH 2592/4072] Fix volume definition in v3 schema Signed-off-by: Joffrey F --- compose/config/config_schema_v3.0.json | 7 ++++--- tests/acceptance/cli_test.py | 8 +++++++- tests/fixtures/v3-full/docker-compose.yml | 4 ++++ tests/integration/testcases.py | 7 +++++-- 4 files changed, 20 insertions(+), 6 deletions(-) diff --git a/compose/config/config_schema_v3.0.json b/compose/config/config_schema_v3.0.json index df321b20cde..584b6ef5d8c 100644 --- a/compose/config/config_schema_v3.0.json +++ b/compose/config/config_schema_v3.0.json @@ -328,10 +328,11 @@ "type": ["boolean", "object"], "properties": { "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 b9766226d9f..ce31dd18f3b 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -295,7 +295,13 @@ def test_config_v3(self): 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 b4d1b6422f3..a1661ab9363 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 f6bc402bf7f..230bd2d9250 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -13,6 +13,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.environment import Environment from compose.const import API_VERSIONS from compose.const import LABEL_PROJECT @@ -36,13 +37,15 @@ def format_link(link): 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 ce2219ec37c7a2a7eb747bf45d5a8a26ed6dff75 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 20 Jan 2017 12:55:59 -0500 Subject: [PATCH 2593/4072] 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 584b6ef5d8c..fbcd8bb859a 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 10365278cc36ce974468fe042af1054c364f89d2 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 20 Jan 2017 15:05:53 -0800 Subject: [PATCH 2594/4072] 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 20a40c684f7..724e05652c0 100644 --- a/compose/service.py +++ b/compose/service.py @@ -22,6 +22,7 @@ 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 @@ def build(self, no_cache=False, pull=False, force_rm=False): 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 d454a1d3fb1d7138d2b0b30f6f44fb520ce9e248 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 24 Jan 2017 15:02:27 -0800 Subject: [PATCH 2595/4072] 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 c25ccbfa485..db068272a8e 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -14,6 +14,30 @@ 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 507e0d7a64ebe20249eb30b471c3d392d10d7a9b Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 25 Jan 2017 18:00:09 -0800 Subject: [PATCH 2596/4072] 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 05ac0d60df8..edf5535378a 100644 --- a/compose/config/serialize.py +++ b/compose/config/serialize.py @@ -52,6 +52,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() @@ -68,4 +87,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 ce31dd18f3b..7a2b7fb572f 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -339,8 +339,8 @@ def test_config_v3(self): '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 ab8bfcfcc46..d7947a4e802 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -23,6 +23,7 @@ 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 @@ def test_denormalize_depends_on_v2_1(self): } 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 2593366a3ef1fb0673049687f0ca6733a28cf03f Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 30 Jan 2017 16:26:35 -0800 Subject: [PATCH 2597/4072] 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 4b7c7b76050..3b06bff45d7 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 2f2ba7429d1..0b1d4e08fb4 100644 --- a/setup.py +++ b/setup.py @@ -37,7 +37,7 @@ def find_version(*file_paths): '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 586637b6a885ce6e0117ab83416e203621a97c3f Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 30 Jan 2017 16:26:35 -0800 Subject: [PATCH 2598/4072] 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 4b7c7b76050..3b06bff45d7 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 2f2ba7429d1..0b1d4e08fb4 100644 --- a/setup.py +++ b/setup.py @@ -37,7 +37,7 @@ def find_version(*file_paths): '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 2599/4072] 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 00000000000..16616498e5d --- /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 2600/4072] 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 7e77421e520..3ca994a79ee 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -12,10 +12,12 @@ 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 @@ 'privileged', 'read_only', 'restart', + 'secrets', 'security_opt', 'shm_size', 'stdin_open', @@ -202,8 +205,11 @@ def get_volumes(self): 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 16616498e5d..c43f296b55a 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 4c106747f96..17d5c8b373d 100644 --- a/compose/config/types.py +++ b/compose/config/types.py @@ -10,8 +10,8 @@ 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 @@ def repr(self): @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 1b1be5c7686..0f2b00c4857 100644 --- a/compose/const.py +++ b/compose/const.py @@ -20,12 +20,14 @@ 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_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 2601/4072] 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 3ca994a79ee..0e8b52e79f5 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 0f2b00c4857..3f8f90ab551 100644 --- a/compose/const.py +++ b/compose/const.py @@ -16,6 +16,8 @@ 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 d99ef7c93f1..22576e8689d 100644 --- a/compose/project.py +++ b/compose/project.py @@ -104,6 +104,11 @@ def from_config(cls, name, config_data, client): 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 @@ def from_config(cls, name, config_data, client): links=links, network_mode=network_mode, volumes_from=volumes_from, + secrets=secrets, **service_dict) ) @@ -553,6 +559,27 @@ def build_volume_from(spec): 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 724e05652c0..9f2fc68b43c 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 const from . import progress_stream from .config import DOCKER_CONFIG_KEYS from .config import merge_environment @@ -139,6 +140,7 @@ def __init__( volumes_from=None, network_mode=None, networks=None, + secrets=None, **options ): self.name = name @@ -149,6 +151,7 @@ def __init__( 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 @@ def _get_container_create_options( 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 @@ def _get_container_host_config(self, override_options, one_off=False): 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 a279cab050a..21bdb31b0e6 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 9a12438f2d1..32d0adfafce 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -36,6 +36,7 @@ def test_from_config(self): ], networks=None, volumes=None, + secrets=None, ) project = Project.from_config( name='composetest', @@ -64,6 +65,7 @@ def test_from_config_v2(self): ], networks=None, volumes=None, + secrets=None, ) project = Project.from_config('composetest', config, None) self.assertEqual(len(project.services), 2) @@ -170,6 +172,7 @@ def test_use_volumes_from_container(self): }], networks=None, volumes=None, + secrets=None, ), ) assert project.get_service('test')._get_volumes_from() == [container_id + ":rw"] @@ -202,6 +205,7 @@ def test_use_volumes_from_service_no_container(self): ], networks=None, volumes=None, + secrets=None, ), ) assert project.get_service('test')._get_volumes_from() == [container_name + ":rw"] @@ -227,6 +231,7 @@ def test_use_volumes_from_service_container(self): ], networks=None, volumes=None, + secrets=None, ), ) with mock.patch.object(Service, 'containers') as mock_return: @@ -360,6 +365,7 @@ def test_net_unset(self): ], networks=None, volumes=None, + secrets=None, ), ) service = project.get_service('test') @@ -384,6 +390,7 @@ def test_use_net_from_container(self): ], networks=None, volumes=None, + secrets=None, ), ) service = project.get_service('test') @@ -417,6 +424,7 @@ def test_use_net_from_service(self): ], networks=None, volumes=None, + secrets=None, ), ) @@ -437,6 +445,7 @@ def test_uses_default_network_true(self): ], networks=None, volumes=None, + secrets=None, ), ) @@ -457,6 +466,7 @@ def test_uses_default_network_false(self): ], networks={'custom': {}}, volumes=None, + secrets=None, ), ) @@ -487,6 +497,7 @@ def test_container_without_name(self): }], networks=None, volumes=None, + secrets=None, ), ) self.assertEqual([c.id for c in project.containers()], ['1']) @@ -503,6 +514,7 @@ def test_down_with_no_resources(self): }], 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 2602/4072] 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 22576e8689d..e522e2ecf3f 100644 --- a/compose/project.py +++ b/compose/project.py @@ -106,7 +106,7 @@ def from_config(cls, name, config_data, client): 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 00000000000..f9dc20149ee --- /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 ee2b7817bdc..30b107e8780 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 @@ 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.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 @@ def test_containers_with_extra_service(self): 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 @@ def test_volumes_from_container(self): ) 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 @@ def test_network_mode_from_service(self): 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 @@ def test_network_mode_from_container(self): 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 @@ def get_project(): 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 @@ def test_net_from_container_v1(self): 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 @@ def test_project_up_starts_links(self): 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 @@ def test_project_up_starts_depends(self): 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 @@ def test_unscale_after_restart(self): @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 @@ def test_project_up_networks(self): 'baz': {'aliases': ['extra']}, }, }], - volumes={}, networks={ 'foo': {'driver': 'bridge'}, 'bar': {'driver': None}, @@ -610,14 +622,13 @@ def test_project_up_networks(self): @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 @@ def test_up_with_ipam_config(self): @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 @@ def test_up_with_network_static_addresses(self): } }, }], - volumes={}, networks={ 'static_test': { 'driver': 'bridge', @@ -726,7 +736,7 @@ def test_up_with_network_static_addresses(self): @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 @@ def test_up_with_enable_ipv6(self): } }, }], - volumes={}, networks={ 'static_test': { 'driver': 'bridge', @@ -770,7 +779,7 @@ def test_up_with_enable_ipv6(self): @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 @@ def test_up_with_network_static_addresses_missing_subnet(self): } }, }], - volumes={}, networks={ 'static_test': { 'driver': 'bridge', @@ -807,7 +815,7 @@ def test_up_with_network_static_addresses_missing_subnet(self): @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 @@ def test_up_with_network_link_local_ips(self): } } }], - volumes={}, networks={ 'linklocaltest': {'driver': 'bridge'} } @@ -844,15 +851,13 @@ def test_up_with_network_link_local_ips(self): @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 @@ def test_up_with_isolation(self): @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 @@ def test_up_with_invalid_isolation(self): @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 @@ def test_project_up_with_network_label(self): 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 @@ def test_project_up_with_network_label(self): 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 @@ def test_project_up_volumes(self): 'command': 'top' }], volumes={vol_name: {'driver': 'local'}}, - networks={}, ) project = Project.from_config( @@ -979,7 +979,7 @@ def test_project_up_with_volume_labels(self): volume_name = 'volume_with_label' - config_data = config.Config( + config_data = build_config( version=V2_0, services=[{ 'name': 'web', @@ -993,7 +993,6 @@ def test_project_up_with_volume_labels(self): } } }, - networks={}, ) project = Project.from_config( @@ -1106,7 +1105,7 @@ def test_project_up_port_mappings_with_multiple_files(self): 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 @@ def test_initialize_volumes(self): 'command': 'top' }], volumes={vol_name: {}}, - networks={}, ) project = Project.from_config( @@ -1124,14 +1122,14 @@ def test_initialize_volumes(self): 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 @@ def test_project_up_implicit_volume_driver(self): 'command': 'top' }], volumes={vol_name: {}}, - networks={}, ) project = Project.from_config( @@ -1152,11 +1149,45 @@ def test_project_up_implicit_volume_driver(self): 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 @@ def test_initialize_volumes_invalid_volume_driver(self): 'command': 'top' }], volumes={vol_name: {'driver': 'foobar'}}, - networks={}, ) project = Project.from_config( @@ -1179,7 +1209,7 @@ def test_initialize_volumes_updated_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', @@ -1187,7 +1217,6 @@ def test_initialize_volumes_updated_driver(self): 'command': 'top' }], volumes={vol_name: {'driver': 'local'}}, - networks={}, ) project = Project.from_config( name='composetest', @@ -1218,7 +1247,7 @@ 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( + config_data = build_config( version=V2_0, services=[{ 'name': 'web', @@ -1226,7 +1255,6 @@ def test_initialize_volumes_updated_blank_driver(self): 'command': 'top' }], volumes={vol_name: {'driver': 'local'}}, - networks={}, ) project = Project.from_config( name='composetest', @@ -1257,7 +1285,7 @@ def test_initialize_volumes_external_volumes(self): 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 @@ def test_initialize_volumes_external_volumes(self): volumes={ vol_name: {'external': True, 'external_name': vol_name} }, - networks=None, ) project = Project.from_config( name='composetest', @@ -1282,7 +1309,7 @@ def test_initialize_volumes_external_volumes(self): 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 @@ def test_initialize_volumes_inexistent_external_volume(self): volumes={ vol_name: {'external': True, 'external_name': vol_name} }, - networks=None, ) project = Project.from_config( name='composetest', @@ -1349,7 +1375,7 @@ def test_project_up_orphans(self): } } - 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 @@ def test_project_up_orphans(self): 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 230bd2d9250..efc1551b4e5 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 2603/4072] 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 e522e2ecf3f..0330ab80f20 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 2604/4072] 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 c43f296b55a..b7037485f97 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 2605/4072] 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 30b107e8780..28762cd2071 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -1151,6 +1151,8 @@ def test_project_up_implicit_volume_driver(self): @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 @@ def test_project_up_with_secrets(self): 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 @@ def test_project_up_healthy_dependency(self): } } } - 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 @@ def test_project_up_unhealthy_dependency(self): } } } - 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 @@ def test_project_up_no_healthcheck_dependency(self): } } } - 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 @@ def test_project_up_no_healthcheck_dependency(self): 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 2606/4072] 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 6677a376a1c..f1251e43134 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 b25273892d7c03d8bfa25239b1fc5d5a2e43353d Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 30 Jan 2017 16:20:59 -0800 Subject: [PATCH 2607/4072] Bump 1.10.1 Signed-off-by: Joffrey F --- CHANGELOG.md | 23 +++++++++++++++++++++++ compose/__init__.py | 2 +- script/run/run.sh | 2 +- 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f14bc99e83d..768e6c49a4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,29 @@ Change log ========== +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) ------------------- diff --git a/compose/__init__.py b/compose/__init__.py index 4c7da2aca69..6d2e41a20e3 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.0' +__version__ = '1.10.1' diff --git a/script/run/run.sh b/script/run/run.sh index f965b9f0a38..c43055e7bc5 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.10.0" +VERSION="1.10.1" IMAGE="docker/compose:$VERSION" From 84774cacd210bb176c2daf73106c7dc849a6a0d7 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 30 Jan 2017 15:16:09 -0800 Subject: [PATCH 2608/4072] 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 63fac3eb38a..a03e151063b 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 2609/4072] 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 db068272a8e..e2ebce48eb1 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 @@ def restart(self, options): 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 991f6572941..77d02b4283b 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 ceb7d0f587c..66d924f73a1 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 58160c80265..160e1913dea 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1907,3 +1907,23 @@ def test_up_with_extends(self): "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 00000000000..d632a836e9c --- /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 2610/4072] 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 0e8b52e79f5..746f63d539b 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -78,7 +78,7 @@ '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 d7947a4e802..860e58353d6 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1748,6 +1748,24 @@ def test_merge_depends_on_mixed_syntax(self): } } + 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 2611/4072] 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 0e8b52e79f5..63ee25ab2a3 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 00000000000..4c311e62caa --- /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 00000000000..11bc9f09da8 --- /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 d7947a4e802..a3be6df8a5c 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -3098,6 +3098,19 @@ def test_extends_with_depends_on(self): '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 2612/4072] 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 7b92693002c..4ba228c8a1f 100644 --- a/compose/config/environment.py +++ b/compose/config/environment.py @@ -2,6 +2,7 @@ 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 9a59a9c3ff6dd7b27530d51c53008219525609d8 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 30 Jan 2017 16:20:59 -0800 Subject: [PATCH 2613/4072] 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 6699f880735..d0681e8af8f 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 8f72dadd75b7ebee311dd4da65a3c7f7216b9f28 Mon Sep 17 00:00:00 2001 From: Kevin Jing Qiu Date: Thu, 26 Jan 2017 14:23:12 -0500 Subject: [PATCH 2614/4072] 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 7b92693002c..4ba228c8a1f 100644 --- a/compose/config/environment.py +++ b/compose/config/environment.py @@ -2,6 +2,7 @@ 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 0519afd5d38b692648239eb36dbebadf4dc74a4a Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 6 Jun 2016 15:48:47 -0700 Subject: [PATCH 2615/4072] 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 3f1dbd75b8c..27f610ca940 100644 --- a/requirements-build.txt +++ b/requirements-build.txt @@ -1 +1 @@ -pyinstaller==3.1.1 +pyinstaller==3.2.1 From 6e9a894ccfcf0a92460a6b86a60e0c4ad8b73318 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 30 Jan 2017 15:16:09 -0800 Subject: [PATCH 2616/4072] 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 63fac3eb38a..a03e151063b 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 0ea24e7a805a84f82111de7224961b4ac0c3ee3d Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 3 Feb 2017 15:26:34 -0800 Subject: [PATCH 2617/4072] 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 38417836476..ae8d759d486 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 5872b081a2d..9de11d5fa6d 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 b392b6e12ed724ce39e0e65fd5582b70c47803af Mon Sep 17 00:00:00 2001 From: fate-grand-order Date: Tue, 7 Feb 2017 15:59:34 +0800 Subject: [PATCH 2618/4072] 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 7c645ca2511..e969b4537f8 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 2619/4072] 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 854cc79954d..505ce91fed0 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 ac235a1f8571067e810fe0f9ba808e2c29bb9cc9 Mon Sep 17 00:00:00 2001 From: Jean-Christophe Berthon Date: Wed, 8 Feb 2017 10:39:04 +0000 Subject: [PATCH 2620/4072] Add support to build docker-compose on ARM 32bit Added a new Dockerfile (Dockerfile.armhf) specific for ARM 32 bit. The Dockerfile was updated compare to default one: - Base image is armhf/debian instead of debian - Docker binary is downloaded with the correct arch (although it does not seems to be used) Signed-off-by: Jean-Christophe Berthon --- Dockerfile.armhf | 71 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 Dockerfile.armhf diff --git a/Dockerfile.armhf b/Dockerfile.armhf new file mode 100644 index 00000000000..9fd697155d9 --- /dev/null +++ b/Dockerfile.armhf @@ -0,0 +1,71 @@ +FROM armhf/debian:wheezy + +RUN set -ex; \ + apt-get update -qq; \ + apt-get install -y \ + locales \ + gcc \ + make \ + zlib1g \ + zlib1g-dev \ + libssl-dev \ + git \ + ca-certificates \ + curl \ + libsqlite3-dev \ + libbz2-dev \ + ; \ + rm -rf /var/lib/apt/lists/* + +RUN curl https://get.docker.com/builds/Linux/armel/docker-1.8.3 \ + -o /usr/local/bin/docker && \ + chmod +x /usr/local/bin/docker + +# Build Python 2.7.13 from source +RUN set -ex; \ + curl -L https://www.python.org/ftp/python/2.7.13/Python-2.7.13.tgz | tar -xz; \ + cd Python-2.7.13; \ + ./configure --enable-shared; \ + make; \ + make install; \ + cd ..; \ + rm -rf /Python-2.7.13 + +# Build python 3.4 from source +RUN set -ex; \ + curl -L https://www.python.org/ftp/python/3.4.6/Python-3.4.6.tgz | tar -xz; \ + cd Python-3.4.6; \ + ./configure --enable-shared; \ + make; \ + make install; \ + cd ..; \ + rm -rf /Python-3.4.6 + +# Make libpython findable +ENV LD_LIBRARY_PATH /usr/local/lib + +# Install pip +RUN set -ex; \ + curl -L https://bootstrap.pypa.io/get-pip.py | python + +# Python3 requires a valid locale +RUN echo "en_US.UTF-8 UTF-8" > /etc/locale.gen && locale-gen +ENV LANG en_US.UTF-8 + +RUN useradd -d /home/user -m -s /bin/bash user +WORKDIR /code/ + +RUN pip install tox==2.1.1 + +ADD requirements.txt /code/ +ADD requirements-dev.txt /code/ +ADD .pre-commit-config.yaml /code/ +ADD setup.py /code/ +ADD tox.ini /code/ +ADD compose /code/compose/ +RUN tox --notest + +ADD . /code/ +RUN chown -R user /code/ + +ENTRYPOINT ["/code/.tox/py27/bin/docker-compose"] From 5193e56a653fa95d1b91b08ad08201aaebe34923 Mon Sep 17 00:00:00 2001 From: Jean-Christophe Berthon Date: Wed, 8 Feb 2017 12:33:57 +0000 Subject: [PATCH 2621/4072] Add first attempt at supporting test suite on ARM Modify `script/test/default` so it supports a first attempt at testing on ARM. Call the script with: `$ DOCKERFILE=Dockerfile.armhf script/test/default` to use the Dockerfile.armhf instead of the default Dockerfile for building the image. However, running the script is not working fully. The problem is that `dockerswarm/dind` does not provide an ARM image and therefore cannot be executed. If that is fixed then we should be able to change the script in order to use the ARM image instead of the default x86_64 image for running further tests. Signed-off-by: Jean-Christophe Berthon --- script/test/default | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/script/test/default b/script/test/default index fa741a19d88..aabb4e426f4 100755 --- a/script/test/default +++ b/script/test/default @@ -5,11 +5,15 @@ set -ex TAG="docker-compose:$(git rev-parse --short HEAD)" +# By default use the Dockerfile, but can be overriden to use an alternative file +# e.g DOCKERFILE=Dockerfile.armhf script/test/default +DOCKERFILE="${DOCKERFILE:-Dockerfile}" + rm -rf coverage-html # Create the host directory so it's owned by $USER mkdir -p coverage-html -docker build -t "$TAG" . +docker build -f ${DOCKERFILE} -t "$TAG" . GIT_VOLUME="--volume=$(pwd)/.git:/code/.git" . script/test/all From 6de18066586c01eb03091c80da30cbeef681afdd Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 8 Feb 2017 11:30:29 -0800 Subject: [PATCH 2622/4072] 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 ae8d759d486..d7468af2593 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 9de11d5fa6d..b45630f075d 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 979a0d53f7e989f13dc77865c7d1f4775f97319e Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 3 Feb 2017 15:26:34 -0800 Subject: [PATCH 2623/4072] 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 38417836476..ae8d759d486 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 5872b081a2d..9de11d5fa6d 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 2624/4072] 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 ae8d759d486..d7468af2593 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 9de11d5fa6d..b45630f075d 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 2625/4072] 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 6699f880735..d0681e8af8f 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 2626/4072] 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 d7468af2593..b2ca86f86fc 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 b45630f075d..4e173894d41 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 2627/4072] 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 0330ab80f20..133071e7de7 100644 --- a/compose/project.py +++ b/compose/project.py @@ -365,7 +365,7 @@ def build_container_event(event, container): # 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 2628/4072] 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 ae85674bd10..09a717bea8b 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -186,11 +186,6 @@ def version(self): 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 ec5a2039cc9..ef0e2593e02 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 666b21f28c9..ef57bb57ea2 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -19,6 +19,7 @@ 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 @@ def test_valid_versions(self): 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 c79a1c72886ed6326d0b1ce87499de4496662218 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Thu, 9 Feb 2017 10:27:06 -0500 Subject: [PATCH 2629/4072] 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 ae85674bd10..09a717bea8b 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -186,11 +186,6 @@ def version(self): 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 ec5a2039cc9..ef0e2593e02 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 666b21f28c9..ef57bb57ea2 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -19,6 +19,7 @@ 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 @@ def test_valid_versions(self): 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 7c5d5e403111441dabd9c199e6a065649167c700 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 9 Feb 2017 10:17:28 -0800 Subject: [PATCH 2630/4072] Bump 1.11.1 Signed-off-by: Joffrey F --- CHANGELOG.md | 8 ++++++++ compose/__init__.py | 2 +- script/run/run.sh | 2 +- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d0681e8af8f..e9f466b383f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,14 @@ Change log ========== +1.11.1 (2017-02-09) +------------------- + +### Bugfixes + +- Fixed a bug where the 3.1 file format was not being recognized as valid + by the Compose parser + 1.11.0 (2017-02-08) ------------------- diff --git a/compose/__init__.py b/compose/__init__.py index d7468af2593..8caa1fd2357 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.11.1' diff --git a/script/run/run.sh b/script/run/run.sh index b45630f075d..77ee0a019b2 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.11.0" +VERSION="1.11.1" IMAGE="docker/compose:$VERSION" From dc5b3f3b3eb53ce747003089c8ae5ff21f4b1f70 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 10 Feb 2017 17:05:33 -0500 Subject: [PATCH 2631/4072] 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 09a717bea8b..4c9cf423bb4 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 17d5c8b373d..f86c03199bb 100644 --- a/compose/config/types.py +++ b/compose/config/types.py @@ -253,3 +253,11 @@ def parse(cls, spec): @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 0b1d4e08fb4..eafbc356f70 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 @@ def find_version(*file_paths): 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 ef57bb57ea2..d4d1ad2c456 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -13,6 +13,7 @@ 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 @@ def test_load_dockerfile_without_context(self): 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 2632/4072] 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 5cf69b05c69..35a10b908d1 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 2633/4072] 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 3745de82dbc..46d283f080c 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 f86c03199bb..811e6c1fc37 100644 --- a/compose/config/types.py +++ b/compose/config/types.py @@ -256,8 +256,5 @@ def merge_field(self): 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 d4d1ad2c456..c26272d9e8f 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 @@ def test_merge_pid(self): '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 @@ def test_denormalize_healthcheck(self): 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 2634/4072] 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 e69de29bb2d..c5db44558ea 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 e2ebce48eb1..51ba36a094b 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -14,30 +14,6 @@ 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 2635/4072] 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 b4e5a2e6a05..321c1fb7b8c 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 2636/4072] 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 160e1913dea..8366ca75e29 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1234,7 +1234,7 @@ def test_run_service_with_user_overridden_short_form(self): 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 @@ def test_run_service_with_environement_overridden(self): ]) 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 @@ def test_run_service_with_map_ports(self): 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 @@ def test_run_service_with_explicitly_maped_ports(self): 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 f9b60bff53b..317650cb57d 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -184,7 +184,7 @@ def test_run_service_with_restart_always(self): 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, From e0e862c0420f0fb6dea24b0a8dff198e5fc80cc1 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 16 Feb 2017 17:05:09 -0800 Subject: [PATCH 2637/4072] Update docker SDK 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 3b06bff45d7..53b9294ce1e 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.2 +docker==2.1.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 eafbc356f70..13fe59b224d 100644 --- a/setup.py +++ b/setup.py @@ -37,7 +37,7 @@ def find_version(*file_paths): '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.2, < 3.0', + 'docker >= 2.1.0, < 3.0', 'dockerpty >= 0.4.1, < 0.5', 'six >= 1.3.0, < 2', 'jsonschema >= 2.5.1, < 3', From 5198a5d33e1d0054b84cbc723b1a0359c7ad9719 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 16 Feb 2017 17:05:09 -0800 Subject: [PATCH 2638/4072] Update docker SDK 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 3b06bff45d7..53b9294ce1e 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.2 +docker==2.1.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 0b1d4e08fb4..f664d83a48c 100644 --- a/setup.py +++ b/setup.py @@ -37,7 +37,7 @@ def find_version(*file_paths): '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.2, < 3.0', + 'docker >= 2.1.0, < 3.0', 'dockerpty >= 0.4.1, < 0.5', 'six >= 1.3.0, < 2', 'jsonschema >= 2.5.1, < 3', From 0d668aa446cb472a954391979c29f002dc3b7580 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 15 Feb 2017 16:07:08 -0800 Subject: [PATCH 2639/4072] 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 e69de29bb2d..c5db44558ea 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 e2ebce48eb1..51ba36a094b 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -14,30 +14,6 @@ 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 1d88989ff537f32df6ab5b52e39392d883e867a8 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 10 Feb 2017 17:05:33 -0500 Subject: [PATCH 2640/4072] 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 09a717bea8b..4c9cf423bb4 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 17d5c8b373d..f86c03199bb 100644 --- a/compose/config/types.py +++ b/compose/config/types.py @@ -253,3 +253,11 @@ def parse(cls, spec): @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 f664d83a48c..13fe59b224d 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 @@ def find_version(*file_paths): 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 ef57bb57ea2..d4d1ad2c456 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -13,6 +13,7 @@ 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 @@ def test_load_dockerfile_without_context(self): 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 b9e9177ba98cbd26cd0fb549d3ed91ed76d7a8b6 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 13 Feb 2017 16:04:06 -0800 Subject: [PATCH 2641/4072] 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 3745de82dbc..46d283f080c 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 f86c03199bb..811e6c1fc37 100644 --- a/compose/config/types.py +++ b/compose/config/types.py @@ -256,8 +256,5 @@ def merge_field(self): 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 d4d1ad2c456..c26272d9e8f 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 @@ def test_merge_pid(self): '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 @@ def test_denormalize_healthcheck(self): 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 dfed245b57e9430994741ab05558627e6827d7fa Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 16 Feb 2017 17:14:34 -0800 Subject: [PATCH 2642/4072] Bump 1.11.2 Signed-off-by: Joffrey F --- CHANGELOG.md | 21 +++++++++++++++++++++ compose/__init__.py | 2 +- script/run/run.sh | 2 +- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9f466b383f..14197cc03c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,27 @@ Change log ========== +1.11.2 (2017-02-17) +------------------- + +### Bugfixes + +- Fixed a bug that was preventing secrets configuration from being + loaded properly + +- Fixed a bug where the `docker-compose config` command would fail + if the config file contained secrets definitions + +- Fixed an issue where Compose on some linux distributions would + pick up and load an outdated version of the requests library + +- Fixed an issue where socket-type files inside a build folder + would cause `docker-compose` to crash when trying to build that + service + +- Fixed an issue where recursive wildcard patterns `**` were not being + recognized in `.dockerignore` files. + 1.11.1 (2017-02-09) ------------------- diff --git a/compose/__init__.py b/compose/__init__.py index 8caa1fd2357..a66471983ec 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.1' +__version__ = '1.11.2' diff --git a/script/run/run.sh b/script/run/run.sh index 77ee0a019b2..f5d9ae86da7 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.11.1" +VERSION="1.11.2" IMAGE="docker/compose:$VERSION" From 39b2c6636efc3573a73ffb966d49ac747dbef987 Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Tue, 21 Feb 2017 17:54:20 +0100 Subject: [PATCH 2643/4072] Fix treatment of global arguments in bash completion 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 77d02b4283b..3cf37a707fc 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -18,7 +18,7 @@ __docker_compose_q() { - docker-compose 2>/dev/null $daemon_options "$@" + docker-compose 2>/dev/null "${daemon_options[@]}" "$@" } # Transforms a multiline list of strings into a single line string From c2553ac777b3442d82a6202af1a2838590b80374 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 21 Feb 2017 14:05:47 -0800 Subject: [PATCH 2644/4072] Update versions.py script to support new engine versioning schema Signed-off-by: Joffrey F --- script/test/versions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/script/test/versions.py b/script/test/versions.py index 0c3b8162dbb..97383ad99fb 100755 --- a/script/test/versions.py +++ b/script/test/versions.py @@ -44,7 +44,7 @@ def parse(cls, version): version = version.lstrip('v') version, _, rc = version.partition('-') major, minor, patch = version.split('.', 3) - return cls(int(major), int(minor), int(patch), rc) + return cls(major, minor, patch, rc) @property def major_minor(self): @@ -57,7 +57,7 @@ def order(self): """ # rc releases should appear before official releases rc = (0, self.rc) if self.rc else (1, ) - return (self.major, self.minor, self.patch) + rc + return (int(self.major), int(self.minor), int(self.patch)) + rc def __str__(self): rc = '-{}'.format(self.rc) if self.rc else '' From e4a87397af90a06ce9d72d3698bff93e6f26db27 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 21 Feb 2017 14:56:07 -0800 Subject: [PATCH 2645/4072] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 35a10b908d1..d43bd8c4c59 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ Installation and documentation Contributing ------------ -[![Build Status](http://jenkins.dockerproject.org/buildStatus/icon?job=Compose%20Master)](http://jenkins.dockerproject.org/job/Compose%20Master/) +[![Build Status](https://jenkins.dockerproject.org/buildStatus/icon?job=docker/compose/master)](https://jenkins.dockerproject.org/job/docker/job/compose/job/master/) Want to help build Compose? Check out our [contributing documentation](https://github.com/docker/compose/blob/master/CONTRIBUTING.md). From 5b6191e6536abc4bbbae902c384bfa531315a897 Mon Sep 17 00:00:00 2001 From: Joey Payne Date: Mon, 20 Feb 2017 13:18:52 -0700 Subject: [PATCH 2646/4072] Add cache_from to build opts Signed-off-by: Joey Payne --- compose/config/config_schema_v3.1.json | 3 ++- compose/service.py | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/compose/config/config_schema_v3.1.json b/compose/config/config_schema_v3.1.json index b7037485f97..77c2d35cbc7 100644 --- a/compose/config/config_schema_v3.1.json +++ b/compose/config/config_schema_v3.1.json @@ -71,7 +71,8 @@ "properties": { "context": {"type": "string"}, "dockerfile": {"type": "string"}, - "args": {"$ref": "#/definitions/list_or_dict"} + "args": {"$ref": "#/definitions/list_or_dict"}, + "cache_from": {"type": "#/definitions/list_of_strings"} }, "additionalProperties": false } diff --git a/compose/service.py b/compose/service.py index 9f2fc68b43c..023efa27483 100644 --- a/compose/service.py +++ b/compose/service.py @@ -802,6 +802,7 @@ def build(self, no_cache=False, pull=False, force_rm=False): nocache=no_cache, dockerfile=build_opts.get('dockerfile', None), buildargs=build_opts.get('args', None), + cache_from=build_opts.get('cache_from', None), ) try: From 33fcfca0409612df38caedb51126d83c51522c7d Mon Sep 17 00:00:00 2001 From: Joey Payne Date: Mon, 20 Feb 2017 13:20:13 -0700 Subject: [PATCH 2647/4072] Add test for cache_from Signed-off-by: Joey Payne --- tests/integration/service_test.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 09758eee940..cb6e5d31884 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -32,6 +32,7 @@ from compose.service import Service from tests.integration.testcases import v2_1_only from tests.integration.testcases import v2_only +from tests.integration.testcases import v3_only def create_and_start_container(service, **override_options): @@ -946,6 +947,20 @@ def test_env_from_file_combined_with_env(self): }.items(): self.assertEqual(env[k], v) + @v3_only() + def test_build_with_cachefrom(self): + base_dir = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, base_dir) + + with open(os.path.join(base_dir, 'Dockerfile'), 'w') as f: + f.write("FROM busybox\n") + + service = self.create_service('cache_from', + build={'context': base_dir, + 'cache_from': ['build1']}) + service.build() + assert service.image() + @mock.patch.dict(os.environ) def test_resolve_env(self): os.environ['FILE_DEF'] = 'E1' From c64f7dde0486b6c2ceac5a3bc186bbfef3a3f572 Mon Sep 17 00:00:00 2001 From: Joey Payne Date: Mon, 20 Feb 2017 13:39:32 -0700 Subject: [PATCH 2648/4072] Fix failing unit tests Signed-off-by: Joey Payne --- tests/unit/service_test.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 2d5b176195a..0a66e4f3e26 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -446,6 +446,7 @@ def test_create_container(self): nocache=False, rm=True, buildargs=None, + cache_from=None, ) def test_ensure_image_exists_no_build(self): @@ -482,6 +483,7 @@ def test_ensure_image_exists_force_build(self): nocache=False, rm=True, buildargs=None, + cache_from=None, ) def test_build_does_not_pull(self): From 3621787a74773cf749bb606e79108a42c2167151 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 22 Feb 2017 15:21:03 -0800 Subject: [PATCH 2649/4072] Check for divergent containers when scaling up Signed-off-by: Joffrey F --- compose/service.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/compose/service.py b/compose/service.py index 9f2fc68b43c..fb51ef09769 100644 --- a/compose/service.py +++ b/compose/service.py @@ -226,9 +226,20 @@ def stop_and_remove(container): if num_running != len(all_containers): # we have some stopped containers, let's start them up again + stopped_containers = [ + c for c in all_containers if not c.is_running + ] + + # Remove containers that have diverged + divergent_containers = [ + c for c in stopped_containers if self._containers_have_diverged([c]) + ] stopped_containers = sorted( - (c for c in all_containers if not c.is_running), - key=attrgetter('number')) + set(stopped_containers) - set(divergent_containers), + key=attrgetter('number') + ) + for c in divergent_containers: + c.remove() num_stopped = len(stopped_containers) From 8b920494327852a70b6b61bfd9a8d29bdf9b63c9 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 22 Feb 2017 16:21:23 -0800 Subject: [PATCH 2650/4072] Detect the service that causes the invalid service name error Signed-off-by: Joffrey F --- compose/config/validation.py | 9 ++++++--- tests/unit/config/config_test.py | 14 ++++++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/compose/config/validation.py b/compose/config/validation.py index 3f23f0a7aea..d4d29565f38 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -211,9 +211,12 @@ def handle_error_for_schema_with_id(error, path): if is_service_dict_schema(schema_id) and error.validator == 'additionalProperties': return "Invalid service name '{}' - only {} characters are allowed".format( - # The service_name is the key to the json object - list(error.instance)[0], - VALID_NAME_CHARS) + # The service_name is one of the keys in the json object + [i for i in list(error.instance) if not i or any(filter( + lambda c: not re.match(VALID_NAME_CHARS, c), i + ))][0], + VALID_NAME_CHARS + ) if error.validator == 'additionalProperties': if schema_id == '#/definitions/service': diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index c26272d9e8f..e5104f7692d 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -554,6 +554,20 @@ def test_config_integer_service_name_raise_validation_error_v2(self): excinfo.exconly() ) + def test_config_invalid_service_name_raise_validation_error(self): + with pytest.raises(ConfigurationError) as excinfo: + config.load( + build_config_details({ + 'version': '2', + 'services': { + 'test_app': {'build': '.'}, + 'mong\\o': {'image': 'mongo'}, + } + }) + ) + + assert 'Invalid service name \'mong\\o\'' in excinfo.exconly() + def test_load_with_multiple_files_v1(self): base_file = config.ConfigFile( 'base.yaml', From 2943d2e61d061f6a1b261ffbb18b1c31386a56ea Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Thu, 23 Feb 2017 14:41:08 +0100 Subject: [PATCH 2651/4072] Activate bash completion for Windows executable 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 3cf37a707fc..79f0fc31392 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -571,4 +571,4 @@ _docker_compose() { return 0 } -complete -F _docker_compose docker-compose +complete -F _docker_compose docker-compose docker-compose.exe From 21529169adcec669904cbea5141d0f90452d8361 Mon Sep 17 00:00:00 2001 From: Evan Shaw Date: Sat, 4 Feb 2017 13:26:45 +1300 Subject: [PATCH 2652/4072] Pull services in parallel Updates #1652 Signed-off-by: Evan Shaw --- compose/project.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/compose/project.py b/compose/project.py index 133071e7de7..84accacb5f6 100644 --- a/compose/project.py +++ b/compose/project.py @@ -455,9 +455,16 @@ def _get_convergence_plans(self, services, strategy): return plans def pull(self, service_names=None, ignore_pull_failures=False): - for service in self.get_services(service_names, include_deps=False): + def pull_service(service): service.pull(ignore_pull_failures) + services = self.get_services(service_names, include_deps=False) + parallel.parallel_execute( + services, + pull_service, + operator.attrgetter('name'), + 'Pulling') + 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) From 05aa8c72857dcc058b3aeb772a6864435a2071a5 Mon Sep 17 00:00:00 2001 From: Evan Shaw Date: Mon, 13 Feb 2017 16:15:49 +1300 Subject: [PATCH 2653/4072] Add optional limit to the number of parallel operations Signed-off-by: Evan Shaw --- compose/parallel.py | 41 +++++++++++++++++++++++++------------ compose/project.py | 3 ++- tests/unit/parallel_test.py | 2 +- 3 files changed, 31 insertions(+), 15 deletions(-) diff --git a/compose/parallel.py b/compose/parallel.py index e495410cff8..94c479b1558 100644 --- a/compose/parallel.py +++ b/compose/parallel.py @@ -4,6 +4,7 @@ import logging import operator import sys +from threading import Semaphore from threading import Thread from docker.errors import APIError @@ -23,7 +24,7 @@ STOP = object() -def parallel_execute(objects, func, get_name, msg, get_deps=None): +def parallel_execute(objects, func, get_name, msg, get_deps=None, limit=None): """Runs func on objects in parallel while ensuring that func is ran on object only after it is ran on all its dependencies. @@ -37,7 +38,7 @@ def parallel_execute(objects, func, get_name, msg, get_deps=None): for obj in objects: writer.initialize(get_name(obj)) - events = parallel_execute_iter(objects, func, get_deps) + events = parallel_execute_iter(objects, func, get_deps, limit) errors = {} results = [] @@ -94,7 +95,15 @@ def pending(self): return set(self.objects) - self.started - self.finished - self.failed -def parallel_execute_iter(objects, func, get_deps): +class NoLimit(object): + def __enter__(self): + pass + + def __exit__(self, *ex): + pass + + +def parallel_execute_iter(objects, func, get_deps, limit): """ Runs func on objects in parallel while ensuring that func is ran on object only after it is ran on all its dependencies. @@ -113,11 +122,16 @@ def parallel_execute_iter(objects, func, get_deps): if get_deps is None: get_deps = _no_deps + if limit is None: + limiter = NoLimit() + else: + limiter = Semaphore(limit) + results = Queue() state = State(objects) while True: - feed_queue(objects, func, get_deps, results, state) + feed_queue(objects, func, get_deps, results, state, limiter) try: event = results.get(timeout=0.1) @@ -141,19 +155,20 @@ def parallel_execute_iter(objects, func, get_deps): yield event -def producer(obj, func, results): +def producer(obj, func, results, limiter): """ The entry point for a producer thread which runs func on a single object. Places a tuple on the results queue once func has either returned or raised. """ - try: - result = func(obj) - results.put((obj, result, None)) - except Exception as e: - results.put((obj, None, e)) + with limiter: + try: + result = func(obj) + results.put((obj, result, None)) + except Exception as e: + results.put((obj, None, e)) -def feed_queue(objects, func, get_deps, results, state): +def feed_queue(objects, func, get_deps, results, state, limiter): """ Starts producer threads for any objects which are ready to be processed (i.e. they have no dependencies which haven't been successfully processed). @@ -177,7 +192,7 @@ def feed_queue(objects, func, get_deps, results, state): ) for dep, ready_check in deps ): log.debug('Starting producer thread for {}'.format(obj)) - t = Thread(target=producer, args=(obj, func, results)) + t = Thread(target=producer, args=(obj, func, results, limiter)) t.daemon = True t.start() state.started.add(obj) @@ -199,7 +214,7 @@ class UpstreamError(Exception): class ParallelStreamWriter(object): """Write out messages for operations happening in parallel. - Each operation has it's own line, and ANSI code characters are used + Each operation has its own line, and ANSI code characters are used to jump to the correct line, and write over the line. """ diff --git a/compose/project.py b/compose/project.py index 84accacb5f6..8d34d8eae49 100644 --- a/compose/project.py +++ b/compose/project.py @@ -463,7 +463,8 @@ def pull_service(service): services, pull_service, operator.attrgetter('name'), - 'Pulling') + 'Pulling', + limit=5) def push(self, service_names=None, ignore_push_failures=False): for service in self.get_services(service_names, include_deps=False): diff --git a/tests/unit/parallel_test.py b/tests/unit/parallel_test.py index 2a50b718948..6b8045f143d 100644 --- a/tests/unit/parallel_test.py +++ b/tests/unit/parallel_test.py @@ -82,7 +82,7 @@ def process(x): events = [ (obj, result, type(exception)) for obj, result, exception - in parallel_execute_iter(objects, process, get_deps) + in parallel_execute_iter(objects, process, get_deps, None) ] assert (cache, None, type(None)) in events From f85da99ef3273794e855afda8678174419d3bf4f Mon Sep 17 00:00:00 2001 From: Evan Shaw Date: Mon, 13 Feb 2017 16:16:07 +1300 Subject: [PATCH 2654/4072] Silence service pull output when pulling in parallel Signed-off-by: Evan Shaw --- compose/project.py | 2 +- compose/service.py | 10 ++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/compose/project.py b/compose/project.py index 8d34d8eae49..d02d8ece3cf 100644 --- a/compose/project.py +++ b/compose/project.py @@ -456,7 +456,7 @@ def _get_convergence_plans(self, services, strategy): def pull(self, service_names=None, ignore_pull_failures=False): def pull_service(service): - service.pull(ignore_pull_failures) + service.pull(ignore_pull_failures, True) services = self.get_services(service_names, include_deps=False) parallel.parallel_execute( diff --git a/compose/service.py b/compose/service.py index 023efa27483..0eadadfb9cf 100644 --- a/compose/service.py +++ b/compose/service.py @@ -886,17 +886,19 @@ def has_host_port(binding): return any(has_host_port(binding) for binding in self.options.get('ports', [])) - def pull(self, ignore_pull_failures=False): + def pull(self, ignore_pull_failures=False, silent=False): if 'image' not in self.options: return 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)) + if not silent: + log.info('Pulling %s (%s%s%s)...' % (self.name, repo, separator, tag)) try: output = self.client.pull(repo, tag=tag, stream=True) - return progress_stream.get_digest_from_pull( - stream_output(output, sys.stdout)) + if not silent: + return progress_stream.get_digest_from_pull( + stream_output(output, sys.stdout)) except (StreamOutputError, NotFound) as e: if not ignore_pull_failures: raise From c6a271e57c241ec99ebcf096ef23c8ef8e4e9137 Mon Sep 17 00:00:00 2001 From: Evan Shaw Date: Mon, 13 Feb 2017 16:22:55 +1300 Subject: [PATCH 2655/4072] Hide parallel pull behind --parallel flag Signed-off-by: Evan Shaw --- compose/cli/main.py | 4 +++- compose/project.py | 25 +++++++++++++++---------- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 51ba36a094b..423e214e9ec 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -602,10 +602,12 @@ def pull(self, options): Options: --ignore-pull-failures Pull what it can and ignores images with pull failures. + --parallel Pull multiple images in parallel. """ self.project.pull( service_names=options['SERVICE'], - ignore_pull_failures=options.get('--ignore-pull-failures') + ignore_pull_failures=options.get('--ignore-pull-failures'), + in_parallel=options.get('--parallel') ) def push(self, options): diff --git a/compose/project.py b/compose/project.py index d02d8ece3cf..c08fbc3626f 100644 --- a/compose/project.py +++ b/compose/project.py @@ -454,17 +454,22 @@ def _get_convergence_plans(self, services, strategy): return plans - def pull(self, service_names=None, ignore_pull_failures=False): - def pull_service(service): - service.pull(ignore_pull_failures, True) - + def pull(self, service_names=None, ignore_pull_failures=False, in_parallel=False): services = self.get_services(service_names, include_deps=False) - parallel.parallel_execute( - services, - pull_service, - operator.attrgetter('name'), - 'Pulling', - limit=5) + + if in_parallel: + def pull_service(service): + service.pull(ignore_pull_failures, True) + + parallel.parallel_execute( + services, + pull_service, + operator.attrgetter('name'), + 'Pulling', + limit=5) + else: + for service in services: + service.pull(ignore_pull_failures) def push(self, service_names=None, ignore_push_failures=False): for service in self.get_services(service_names, include_deps=False): From b4b221f6a3d57f3af46d7be735bec624363a4465 Mon Sep 17 00:00:00 2001 From: Evan Shaw Date: Tue, 14 Feb 2017 10:52:57 +1300 Subject: [PATCH 2656/4072] Update to address code review feedback Signed-off-by: Evan Shaw --- compose/cli/main.py | 2 +- compose/project.py | 4 ++-- compose/service.py | 7 ++++++- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 423e214e9ec..ae6b0ac6960 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -607,7 +607,7 @@ def pull(self, options): self.project.pull( service_names=options['SERVICE'], ignore_pull_failures=options.get('--ignore-pull-failures'), - in_parallel=options.get('--parallel') + parallel_pull=options.get('--parallel') ) def push(self, options): diff --git a/compose/project.py b/compose/project.py index c08fbc3626f..5c21f3bf0ff 100644 --- a/compose/project.py +++ b/compose/project.py @@ -454,10 +454,10 @@ def _get_convergence_plans(self, services, strategy): return plans - def pull(self, service_names=None, ignore_pull_failures=False, in_parallel=False): + def pull(self, service_names=None, ignore_pull_failures=False, parallel_pull=False): services = self.get_services(service_names, include_deps=False) - if in_parallel: + if parallel_pull: def pull_service(service): service.pull(ignore_pull_failures, True) diff --git a/compose/service.py b/compose/service.py index 0eadadfb9cf..5305a151ab7 100644 --- a/compose/service.py +++ b/compose/service.py @@ -2,6 +2,7 @@ from __future__ import unicode_literals import logging +import os import re import sys from collections import namedtuple @@ -896,7 +897,11 @@ def pull(self, ignore_pull_failures=False, silent=False): log.info('Pulling %s (%s%s%s)...' % (self.name, repo, separator, tag)) try: output = self.client.pull(repo, tag=tag, stream=True) - if not silent: + if silent: + with open(os.devnull, 'w') as devnull: + return progress_stream.get_digest_from_pull( + stream_output(output, devnull)) + else: return progress_stream.get_digest_from_pull( stream_output(output, sys.stdout)) except (StreamOutputError, NotFound) as e: From e29e3f8da4b5f0e2f9a32206abd7efd2f1333928 Mon Sep 17 00:00:00 2001 From: Evan Shaw Date: Sat, 25 Feb 2017 13:14:32 +1300 Subject: [PATCH 2657/4072] Test for parallel_execute with limit Signed-off-by: Evan Shaw --- tests/unit/parallel_test.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/unit/parallel_test.py b/tests/unit/parallel_test.py index 6b8045f143d..d10948eb072 100644 --- a/tests/unit/parallel_test.py +++ b/tests/unit/parallel_test.py @@ -1,6 +1,8 @@ from __future__ import absolute_import from __future__ import unicode_literals +from threading import Lock + import six from docker.errors import APIError @@ -40,6 +42,30 @@ def test_parallel_execute(): assert errors == {} +def test_parallel_execute_with_limit(): + limit = 1 + tasks = 20 + lock = Lock() + + def f(obj): + locked = lock.acquire(False) + # we should always get the lock because we're the only thread running + assert locked + lock.release() + return None + + results, errors = parallel_execute( + objects=list(range(tasks)), + func=f, + get_name=six.text_type, + msg="Testing", + limit=limit, + ) + + assert results == tasks*[None] + assert errors == {} + + def test_parallel_execute_with_deps(): log = [] From a507c7f72088fc4ccf48ea6f34482813c1fb9f81 Mon Sep 17 00:00:00 2001 From: Evan Shaw Date: Sun, 26 Feb 2017 19:19:38 +1300 Subject: [PATCH 2658/4072] Colorize statuses in parallel_execute output 'ok' displays in green 'error' displays in red Signed-off-by: Evan Shaw --- compose/parallel.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/compose/parallel.py b/compose/parallel.py index e495410cff8..cdeb07255df 100644 --- a/compose/parallel.py +++ b/compose/parallel.py @@ -11,6 +11,8 @@ from six.moves.queue import Empty from six.moves.queue import Queue +from compose.cli.colors import green +from compose.cli.colors import red from compose.cli.signals import ShutdownException from compose.errors import HealthCheckFailed from compose.errors import NoHealthCheckConfigured @@ -45,16 +47,16 @@ def parallel_execute(objects, func, get_name, msg, get_deps=None): for obj, result, exception in events: if exception is None: - writer.write(get_name(obj), 'done') + writer.write(get_name(obj), green('done')) results.append(result) elif isinstance(exception, APIError): errors[get_name(obj)] = exception.explanation - writer.write(get_name(obj), 'error') + writer.write(get_name(obj), red('error')) elif isinstance(exception, (OperationFailedError, HealthCheckFailed, NoHealthCheckConfigured)): errors[get_name(obj)] = exception.msg - writer.write(get_name(obj), 'error') + writer.write(get_name(obj), red('error')) elif isinstance(exception, UpstreamError): - writer.write(get_name(obj), 'error') + writer.write(get_name(obj), red('error')) else: errors[get_name(obj)] = exception error_to_reraise = exception From a73190e1cc2c57ba2ac0361b376b2adc5c4dabeb Mon Sep 17 00:00:00 2001 From: "Nathan J. Mehl" Date: Fri, 27 Jan 2017 08:56:02 -0800 Subject: [PATCH 2659/4072] Add support for returning the exit value of a specific container Current best practice for using docker-compose as a tool for continuous integration requires fragile shell pipelines to query the exit status of composed containers, e.g.: http://stackoverflow.com/questions/29568352/using-docker-compose-with-ci-how-to-deal-with-exit-codes-and-daemonized-linked http://blog.ministryofprogramming.com/docker-compose-and-exit-codes/ This PR adds a `--forward-exitval ` flag that allows `docker-compose up` to return the exit value of a specified container. The container may optionally have a number specified (foo_2) otherwise the first is defaulted to. Signed-off-by: Nathan J. Mehl --- compose/cli/main.py | 47 ++++++++++++++++++- contrib/completion/bash/docker-compose | 2 +- tests/acceptance/cli_test.py | 10 ++++ .../forward-exitval/docker-compose.yml | 6 +++ 4 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 tests/fixtures/forward-exitval/docker-compose.yml diff --git a/compose/cli/main.py b/compose/cli/main.py index 51ba36a094b..bbd6952a0a5 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -854,6 +854,8 @@ def up(self, options): running. (default: 10) --remove-orphans Remove containers for services not defined in the Compose file + --forward-exitval SERVICE Return the exit value of the selected service container. + Requires --abort-on-container-exit. """ start_deps = not options['--no-deps'] cascade_stop = options['--abort-on-container-exit'] @@ -861,10 +863,14 @@ def up(self, options): timeout = timeout_from_opts(options) remove_orphans = options['--remove-orphans'] detached = options.get('-d') + forward_exitval = container_exitval_from_opts(options) if detached and cascade_stop: raise UserError("--abort-on-container-exit and -d cannot be combined.") + if forward_exitval and not cascade_stop: + raise UserError("--forward-exitval requires --abort-on-container-exit.") + with up_shutdown_context(self.project, service_names, timeout, detached): to_attach = self.project.up( service_names=service_names, @@ -878,9 +884,11 @@ def up(self, options): if detached: return + all_containers = filter_containers_to_service_names(to_attach, service_names) + log_printer = log_printer_from_project( self.project, - filter_containers_to_service_names(to_attach, service_names), + all_containers, options['--no-color'], {'follow': True}, cascade_stop, @@ -891,6 +899,22 @@ def up(self, options): if cascade_stop: print("Aborting on container exit...") self.project.stop(service_names=service_names, timeout=timeout) + if forward_exitval: + def is_us(container): + return container.name_without_project == forward_exitval + candidates = filter(is_us, all_containers) + if not candidates: + log.error('No containers matching the spec "%s" were run.', + forward_exitval) + sys.exit(2) + if len(candidates) > 1: + log.error('Multiple (%d) containers matching the spec "%s" ' + 'were found; cannot forward exit code because we ' + 'do not know which one to.', len(candidates), + forward_exitval) + sys.exit(2) + exit_code = candidates[0].inspect()['State']['ExitCode'] + sys.exit(exit_code) @classmethod def version(cls, options): @@ -923,6 +947,27 @@ def convergence_strategy_from_opts(options): return ConvergenceStrategy.changed +def container_exitval_from_opts(options): + """ Assemble a container name suitable for mapping into the + output of filter_containers_to_service_names. If the + container name ends in an underscore followed by a + positive integer, the user has deliberately specified + a container number and we believe her. Otherwise, append + `_1` to the name so as to return the exit value of the + first such named container. + """ + container_name = options.get('--forward-exitval') + if not container_name: + return None + segments = container_name.split('_') + if segments[-1].isdigit() and int(segments[-1]) > 0: + return '_'.join(segments) + else: + log.warn('"%s" does not specify a container number, ' + 'defaulting to "%s_1"', container_name, container_name) + return '_'.join(segments + ['1']) + + def timeout_from_opts(options): timeout = options.get('--timeout') return None if timeout is None else int(timeout) diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index 79f0fc31392..979942f9763 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -467,7 +467,7 @@ _docker_compose_up() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "--abort-on-container-exit --build -d --force-recreate --help --no-build --no-color --no-deps --no-recreate --timeout -t --remove-orphans" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--forward-exitval --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 diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 8366ca75e29..6e03c448ca6 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1927,3 +1927,13 @@ def test_top_processes_running(self): self.dispatch(['up', '-d']) result = self.dispatch(['top']) assert result.stdout.count("top") == 4 + + def test_forward_exitval(self): + self.base_dir = 'tests/fixtures/forward-exitval' + proc = start_process( + self.base_dir, + ['up', '--abort-on-container-exit', '--forward-exitval', 'another']) + + result = wait_on_process(proc, returncode=1) + + assert 'forwardexitval_another_1 exited with code 1' in result.stdout diff --git a/tests/fixtures/forward-exitval/docker-compose.yml b/tests/fixtures/forward-exitval/docker-compose.yml new file mode 100644 index 00000000000..687e78b97c6 --- /dev/null +++ b/tests/fixtures/forward-exitval/docker-compose.yml @@ -0,0 +1,6 @@ +simple: + image: busybox:latest + command: sh -c "echo hello && tail -f /dev/null" +another: + image: busybox:latest + command: /bin/false From cffb76d4d9a731495195dec7acaab0b1b438f47c Mon Sep 17 00:00:00 2001 From: "Nathan J. Mehl" Date: Mon, 27 Feb 2017 07:21:47 -0800 Subject: [PATCH 2660/4072] Address comments - set flag name to `--exit-code-from` (and rename some variable, function and test names to match) - force cascade_stop to true when exit-code-from flag is set - use lambda in filter statement - check that selected container name is in the project before running - remove fancy parsing of service name to container mappings: if there are multiple containers in a service, return the first nonzero exit value if any - flake8 changes Signed-off-by: Nathan J. Mehl --- compose/cli/main.py | 78 +++++++++---------- contrib/completion/bash/docker-compose | 2 +- tests/acceptance/cli_test.py | 6 +- .../docker-compose.yml | 0 4 files changed, 42 insertions(+), 44 deletions(-) rename tests/fixtures/{forward-exitval => exit-code-from}/docker-compose.yml (100%) diff --git a/compose/cli/main.py b/compose/cli/main.py index bbd6952a0a5..f1d343f3a29 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -854,23 +854,20 @@ def up(self, options): running. (default: 10) --remove-orphans Remove containers for services not defined in the Compose file - --forward-exitval SERVICE Return the exit value of the selected service container. + --exit-code-from SERVICE Return the exit code of the selected service container. Requires --abort-on-container-exit. """ start_deps = not options['--no-deps'] + exit_value_from = exitval_from_opts(options, self.project) cascade_stop = options['--abort-on-container-exit'] service_names = options['SERVICE'] timeout = timeout_from_opts(options) remove_orphans = options['--remove-orphans'] detached = options.get('-d') - forward_exitval = container_exitval_from_opts(options) if detached and cascade_stop: raise UserError("--abort-on-container-exit and -d cannot be combined.") - if forward_exitval and not cascade_stop: - raise UserError("--forward-exitval requires --abort-on-container-exit.") - with up_shutdown_context(self.project, service_names, timeout, detached): to_attach = self.project.up( service_names=service_names, @@ -884,11 +881,11 @@ def up(self, options): if detached: return - all_containers = filter_containers_to_service_names(to_attach, service_names) + attached_containers = filter_containers_to_service_names(to_attach, service_names) log_printer = log_printer_from_project( self.project, - all_containers, + attached_containers, options['--no-color'], {'follow': True}, cascade_stop, @@ -899,22 +896,31 @@ def up(self, options): if cascade_stop: print("Aborting on container exit...") self.project.stop(service_names=service_names, timeout=timeout) - if forward_exitval: - def is_us(container): - return container.name_without_project == forward_exitval - candidates = filter(is_us, all_containers) + if exit_value_from: + candidates = filter( + lambda c: c.service == exit_value_from, + attached_containers) if not candidates: log.error('No containers matching the spec "%s" were run.', - forward_exitval) + exit_value_from) sys.exit(2) if len(candidates) > 1: - log.error('Multiple (%d) containers matching the spec "%s" ' - 'were found; cannot forward exit code because we ' - 'do not know which one to.', len(candidates), - forward_exitval) - sys.exit(2) - exit_code = candidates[0].inspect()['State']['ExitCode'] - sys.exit(exit_code) + exit_values = [] + for candidate in candidates: + exit_val = candidate.inspect()['State']['ExitCode'] + if exit_val: + exit_values.append(exit_val) + if exit_values: + log.warn('Multiple (%d) containers matching the service name "%s" ' + 'were found and at least one exited nonzero; returning ' + 'the first non-zero exit code. See above for detailed ' + 'exit statuses.', len(candidates), exit_value_from) + sys.exit(exit_values[0]) + else: + exit_value = candidates[0].inspect()['State']['ExitCode'] + log.error('Returning exit value %d from container %s.', exit_value, + candidates[0].name_without_project) + sys.exit(exit_value) @classmethod def version(cls, options): @@ -947,32 +953,24 @@ def convergence_strategy_from_opts(options): return ConvergenceStrategy.changed -def container_exitval_from_opts(options): - """ Assemble a container name suitable for mapping into the - output of filter_containers_to_service_names. If the - container name ends in an underscore followed by a - positive integer, the user has deliberately specified - a container number and we believe her. Otherwise, append - `_1` to the name so as to return the exit value of the - first such named container. - """ - container_name = options.get('--forward-exitval') - if not container_name: - return None - segments = container_name.split('_') - if segments[-1].isdigit() and int(segments[-1]) > 0: - return '_'.join(segments) - else: - log.warn('"%s" does not specify a container number, ' - 'defaulting to "%s_1"', container_name, container_name) - return '_'.join(segments + ['1']) - - def timeout_from_opts(options): timeout = options.get('--timeout') return None if timeout is None else int(timeout) +def exitval_from_opts(options, project): + exit_value_from = options.get('--exit-code-from') + if exit_value_from: + if not options.get('--abort-on-container-exit'): + log.warn('using --exit-code-from implies --abort-on-container-exit') + options['--abort-on-container-exit'] = True + if exit_value_from not in [s.name for s in project.get_services()]: + log.error('No service named "%s" was found in your compose file.', + exit_value_from) + sys.exit(2) + return exit_value_from + + def image_type_from_opt(flag, value): if not value: return ImageType.none diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index 979942f9763..3acbb5806bb 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -467,7 +467,7 @@ _docker_compose_up() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "--forward-exitval --abort-on-container-exit --build -d --force-recreate --help --no-build --no-color --no-deps --no-recreate --timeout -t --remove-orphans" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--exit-code-from --abort-on-container-exit --build -d --force-recreate --help --no-build --no-color --no-deps --no-recreate --timeout -t --remove-orphans" -- "$cur" ) ) ;; *) __docker_compose_services_all diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 6e03c448ca6..42c4de3849b 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1929,11 +1929,11 @@ def test_top_processes_running(self): assert result.stdout.count("top") == 4 def test_forward_exitval(self): - self.base_dir = 'tests/fixtures/forward-exitval' + self.base_dir = 'tests/fixtures/exit-code-from' proc = start_process( self.base_dir, - ['up', '--abort-on-container-exit', '--forward-exitval', 'another']) + ['up', '--abort-on-container-exit', '--exit-code-from', 'another']) result = wait_on_process(proc, returncode=1) - assert 'forwardexitval_another_1 exited with code 1' in result.stdout + assert 'exitcodefrom_another_1 exited with code 1' in result.stdout diff --git a/tests/fixtures/forward-exitval/docker-compose.yml b/tests/fixtures/exit-code-from/docker-compose.yml similarity index 100% rename from tests/fixtures/forward-exitval/docker-compose.yml rename to tests/fixtures/exit-code-from/docker-compose.yml From 1f9fb2745673abd4e9562b9691f618484fed1714 Mon Sep 17 00:00:00 2001 From: David McKay Date: Tue, 18 Oct 2016 11:31:02 +0100 Subject: [PATCH 2661/4072] Allowing running containers to be rm'd by stop flag Signed-off-by: David McKay --- compose/cli/main.py | 10 ++++++++++ tests/acceptance/cli_test.py | 5 +++++ 2 files changed, 15 insertions(+) diff --git a/compose/cli/main.py b/compose/cli/main.py index 51ba36a094b..49877eb7046 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -635,6 +635,7 @@ def rm(self, options): Options: -f, --force Don't ask to confirm removal + -s, --stop Stop the containers, if required, before removing -v Remove any anonymous volumes attached to containers -a, --all Deprecated - no effect. """ @@ -645,6 +646,15 @@ def rm(self, options): ) one_off = OneOffFilter.include + if options.get('--stop'): + running_containers = self.project.containers( + service_names=options['SERVICE'], stopped=False, one_off=one_off + ) + self.project.stop( + service_names=running_containers, + one_off=one_off + ) + all_containers = self.project.containers( 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 8366ca75e29..6426e8cbf4f 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1498,6 +1498,11 @@ def test_rm(self): self.assertEqual(len(service.containers(stopped=True)), 1) self.dispatch(['rm', '-f'], None) self.assertEqual(len(service.containers(stopped=True)), 0) + service = self.project.get_service('simple') + service.create_container() + self.dispatch(['rm', '-fs'], None) + simple = self.project.get_service('simple') + self.assertEqual(len(simple.containers()), 0) def test_rm_all(self): service = self.project.get_service('simple') From 11788ef0ff0cbdb3e0ae264e3123c677b189dc54 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 27 Feb 2017 18:22:47 -0800 Subject: [PATCH 2662/4072] Add support for expanded port syntax in 3.1 format Signed-off-by: Joffrey F --- compose/config/config.py | 36 ++++++- compose/config/config_schema_v3.1.json | 17 +++- compose/config/serialize.py | 14 ++- compose/config/types.py | 59 ++++++++++++ compose/service.py | 25 ++++- tests/acceptance/cli_test.py | 13 +++ .../ports-composefile/expanded-notation.yml | 15 +++ tests/unit/config/config_test.py | 93 +++++++++++++++---- tests/unit/config/types_test.py | 44 +++++++++ tests/unit/service_test.py | 21 +++++ 10 files changed, 308 insertions(+), 29 deletions(-) create mode 100644 tests/fixtures/ports-composefile/expanded-notation.yml diff --git a/compose/config/config.py b/compose/config/config.py index 4c9cf423bb4..bb7d18a1c57 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -35,6 +35,7 @@ from .types import parse_extra_hosts from .types import parse_restart_spec from .types import ServiceLink +from .types import ServicePort from .types import VolumeFromSpec from .types import VolumeSpec from .validation import match_named_volumes @@ -683,10 +684,25 @@ def process_service(service_config): service_dict[field] = to_list(service_dict[field]) service_dict = process_healthcheck(service_dict, service_config.name) + service_dict = process_ports(service_dict) return service_dict +def process_ports(service_dict): + if 'ports' not in service_dict: + return service_dict + + ports = [] + for port_definition in service_dict['ports']: + if isinstance(port_definition, ServicePort): + ports.append(port_definition) + else: + ports.extend(ServicePort.parse(port_definition)) + service_dict['ports'] = ports + return service_dict + + 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([ @@ -864,7 +880,7 @@ def merge_service_dicts(base, override, version): md.merge_field(field, merge_path_mappings) for field in [ - 'ports', 'cap_add', 'cap_drop', 'expose', 'external_links', + 'cap_add', 'cap_drop', 'expose', 'external_links', 'security_opt', 'volumes_from', ]: md.merge_field(field, merge_unique_items_lists, default=[]) @@ -873,6 +889,7 @@ def merge_service_dicts(base, override, version): md.merge_field(field, merge_list_or_string) md.merge_field('logging', merge_logging, default={}) + merge_ports(md, base, override) for field in set(ALLOWED_KEYS) - set(md): md.merge_scalar(field) @@ -889,6 +906,23 @@ def merge_unique_items_lists(base, override): return sorted(set().union(base, override)) +def merge_ports(md, base, override): + def parse_sequence_func(seq): + acc = [] + for item in seq: + acc.extend(ServicePort.parse(item)) + return to_mapping(acc, 'merge_field') + + field = 'ports' + + if not md.needs_merge(field): + return + + merged = parse_sequence_func(md.base.get(field, [])) + merged.update(parse_sequence_func(md.override.get(field, []))) + md[field] = [item for item in sorted(merged.values())] + + def merge_build(output, base, override): def to_dict(service): build_config = service.get('build', {}) diff --git a/compose/config/config_schema_v3.1.json b/compose/config/config_schema_v3.1.json index 77c2d35cbc7..219ccdd488a 100644 --- a/compose/config/config_schema_v3.1.json +++ b/compose/config/config_schema_v3.1.json @@ -168,8 +168,21 @@ "ports": { "type": "array", "items": { - "type": ["string", "number"], - "format": "ports" + "oneOf": [ + {"type": "number", "format": "ports"}, + {"type": "string", "format": "ports"}, + { + "type": "object", + "properties": { + "mode": {"type": "string"}, + "target": {"type": "integer"}, + "published": {"type": "integer"}, + "protocol": {"type": "string"} + }, + "required": ["target"], + "additionalProperties": false + } + ] }, "uniqueItems": true }, diff --git a/compose/config/serialize.py b/compose/config/serialize.py index 46d283f080c..58581f7cc58 100644 --- a/compose/config/serialize.py +++ b/compose/config/serialize.py @@ -7,6 +7,7 @@ from compose.config import types from compose.config.config import V1 from compose.config.config import V2_1 +from compose.config.config import V3_1 def serialize_config_type(dumper, data): @@ -14,8 +15,14 @@ def serialize_config_type(dumper, data): return representer(data.repr()) +def serialize_dict_type(dumper, data): + return dumper.represent_dict(data.repr()) + + yaml.SafeDumper.add_representer(types.VolumeFromSpec, serialize_config_type) yaml.SafeDumper.add_representer(types.VolumeSpec, serialize_config_type) +yaml.SafeDumper.add_representer(types.ServiceSecret, serialize_dict_type) +yaml.SafeDumper.add_representer(types.ServicePort, serialize_dict_type) def denormalize_config(config): @@ -102,7 +109,10 @@ 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']) + if 'ports' in service_dict and version != V3_1: + service_dict['ports'] = map( + lambda p: p.legacy_repr() if isinstance(p, types.ServicePort) else p, + service_dict['ports'] + ) return service_dict diff --git a/compose/config/types.py b/compose/config/types.py index 811e6c1fc37..aa1edcf0b17 100644 --- a/compose/config/types.py +++ b/compose/config/types.py @@ -9,6 +9,7 @@ from collections import namedtuple import six +from docker.utils.ports import build_port_bindings from ..const import COMPOSEFILE_V1 as V1 from .errors import ConfigurationError @@ -258,3 +259,61 @@ def repr(self): return dict( [(k, v) for k, v in self._asdict().items() if v is not None] ) + + +class ServicePort(namedtuple('_ServicePort', 'target published protocol mode external_ip')): + + @classmethod + def parse(cls, spec): + if not isinstance(spec, dict): + result = [] + for k, v in build_port_bindings([spec]).items(): + if '/' in k: + target, proto = k.split('/', 1) + else: + target, proto = (k, None) + for pub in v: + if pub is None: + result.append( + cls(target, None, proto, None, None) + ) + elif isinstance(pub, tuple): + result.append( + cls(target, pub[1], proto, None, pub[0]) + ) + else: + result.append( + cls(target, pub, proto, None, None) + ) + return result + + return [cls( + spec.get('target'), + spec.get('published'), + spec.get('protocol'), + spec.get('mode'), + None + )] + + @property + def merge_field(self): + return (self.target, self.published) + + def repr(self): + return dict( + [(k, v) for k, v in self._asdict().items() if v is not None] + ) + + def legacy_repr(self): + return normalize_port_dict(self.repr()) + + +def normalize_port_dict(port): + return '{external_ip}{has_ext_ip}{published}{is_pub}{target}/{protocol}'.format( + published=port.get('published', ''), + is_pub=(':' if port.get('published') else ''), + target=port.get('target'), + protocol=port.get('protocol', 'tcp'), + external_ip=port.get('external_ip', ''), + has_ext_ip=(':' if port.get('external_ip') else ''), + ) diff --git a/compose/service.py b/compose/service.py index 0f0dc57dd0b..ef593d84d9b 100644 --- a/compose/service.py +++ b/compose/service.py @@ -21,6 +21,7 @@ from . import progress_stream from .config import DOCKER_CONFIG_KEYS from .config import merge_environment +from .config.types import ServicePort from .config.types import VolumeSpec from .const import DEFAULT_TIMEOUT from .const import IS_WINDOWS_PLATFORM @@ -693,7 +694,7 @@ def _get_container_create_options( if 'ports' in container_options or 'expose' in self.options: container_options['ports'] = build_container_ports( - container_options, + formatted_ports(container_options.get('ports', [])), self.options) container_options['environment'] = merge_environment( @@ -747,7 +748,9 @@ def _get_container_host_config(self, override_options, one_off=False): 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 []), + port_bindings=build_port_bindings( + formatted_ports(options.get('ports', [])) + ), binds=options.get('binds'), volumes_from=self._get_volumes_from(), privileged=options.get('privileged', False), @@ -875,7 +878,10 @@ def remove_image(self, image_type): def specifies_host_port(self): def has_host_port(binding): - _, external_bindings = split_port(binding) + if isinstance(binding, dict): + external_bindings = binding.get('published') + else: + _, external_bindings = split_port(binding) # there are no external bindings if external_bindings is None: @@ -1214,12 +1220,21 @@ def format_env(key, value): return '{key}={value}'.format(key=key, value=value) return [format_env(*item) for item in environment.items()] + # Ports +def formatted_ports(ports): + result = [] + for port in ports: + if isinstance(port, ServicePort): + result.append(port.legacy_repr()) + else: + result.append(port) + return result -def build_container_ports(container_options, options): +def build_container_ports(container_ports, options): ports = [] - all_ports = container_options.get('ports', []) + options.get('expose', []) + all_ports = container_ports + options.get('expose', []) for port_range in all_ports: internal_range, _ = split_port(port_range) for port in internal_range: diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 8366ca75e29..c1605aa7665 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1759,6 +1759,19 @@ def get_port(number): self.assertEqual(get_port(3001), "0.0.0.0:49152") self.assertEqual(get_port(3002), "0.0.0.0:49153") + def test_expanded_port(self): + self.base_dir = 'tests/fixtures/ports-composefile' + self.dispatch(['-f', 'expanded-notation.yml', 'up', '-d']) + container = self.project.get_service('simple').get_container() + + def get_port(number): + result = self.dispatch(['port', 'simple', str(number)]) + return result.stdout.rstrip() + + self.assertEqual(get_port(3000), container.get_local_port(3000)) + self.assertEqual(get_port(3001), "0.0.0.0:49152") + self.assertEqual(get_port(3002), "0.0.0.0:49153") + def test_port_with_scale(self): self.base_dir = 'tests/fixtures/ports-composefile-scale' self.dispatch(['scale', 'simple=2'], None) diff --git a/tests/fixtures/ports-composefile/expanded-notation.yml b/tests/fixtures/ports-composefile/expanded-notation.yml new file mode 100644 index 00000000000..46d587363f4 --- /dev/null +++ b/tests/fixtures/ports-composefile/expanded-notation.yml @@ -0,0 +1,15 @@ +version: '3.1' +services: + simple: + image: busybox:latest + command: top + ports: + - target: 3000 + - target: 3001 + published: 49152 + - target: 3002 + published: 49153 + protocol: tcp + - target: 3003 + published: 49154 + protocol: udp diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index e5104f7692d..4348129c8f0 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -10,6 +10,7 @@ import py import pytest +import yaml from ...helpers import build_config_details from compose.config import config @@ -25,6 +26,7 @@ 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_config from compose.config.serialize import serialize_ns_time_value from compose.config.types import VolumeSpec from compose.const import IS_WINDOWS_PLATFORM @@ -1737,6 +1739,30 @@ def test_merge_logging_v2_no_override(self): } } + def test_merge_mixed_ports(self): + base = { + 'image': 'busybox:latest', + 'command': 'top', + 'ports': [ + { + 'target': '1245', + 'published': '1245', + 'protocol': 'tcp', + } + ] + } + + override = { + 'ports': ['1245:1245/udp'] + } + + actual = config.merge_service_dicts(base, override, V3_1) + assert actual == { + 'image': 'busybox:latest', + 'command': 'top', + 'ports': [types.ServicePort('1245', '1245', 'udp', None, None)] + } + def test_merge_depends_on_no_override(self): base = { 'image': 'busybox', @@ -2210,7 +2236,10 @@ def test_config_file_with_environment_file(self): self.assertEqual(service_dicts[0], { 'name': 'web', 'image': 'alpine:latest', - 'ports': ['5643', '9999'], + 'ports': [ + types.ServicePort.parse('5643')[0], + types.ServicePort.parse('9999')[0] + ], 'command': 'true' }) @@ -2233,7 +2262,7 @@ def test_config_file_with_environment_variable(self): { 'name': 'web', 'image': 'busybox', - 'ports': ['80:8000'], + 'ports': types.ServicePort.parse('80:8000'), 'labels': {'mylabel': 'myvalue'}, 'hostname': 'host-', 'command': '${ESCAPED}', @@ -2515,13 +2544,37 @@ class MergePortsTest(unittest.TestCase, MergeListsTest): base_config = ['10:8000', '9000'] override_config = ['20:8000'] + def merged_config(self): + return self.convert(self.base_config) | self.convert(self.override_config) + + def convert(self, port_config): + return set(config.merge_service_dicts( + {self.config_name: port_config}, + {self.config_name: []}, + DEFAULT_VERSION + )[self.config_name]) + def test_duplicate_port_mappings(self): 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]) == self.convert(self.base_config) + + def test_no_override(self): + service_dict = config.merge_service_dicts( + {self.config_name: self.base_config}, + {}, + DEFAULT_VERSION) + assert set(service_dict[self.config_name]) == self.convert(self.base_config) + + def test_no_base(self): + service_dict = config.merge_service_dicts( + {}, + {self.config_name: self.base_config}, + DEFAULT_VERSION) + assert set(service_dict[self.config_name]) == self.convert(self.base_config) class MergeNetworksTest(unittest.TestCase, MergeListsTest): @@ -3542,23 +3595,25 @@ def test_denormalize_healthcheck(self): assert denormalized_service['healthcheck']['interval'] == '100s' assert denormalized_service['healthcheck']['timeout'] == '30s' - def test_denormalize_secrets(self): + def test_serialize_secrets(self): service_dict = { - 'name': 'web', 'image': 'example/web', 'secrets': [ - types.ServiceSecret('one', None, None, None, None), - types.ServiceSecret('source', 'target', '100', '200', 0o777), - ], + {'source': 'one'}, + { + 'source': 'source', + 'target': 'target', + 'uid': '100', + 'gid': '200', + 'mode': 0o777, + } + ] } - denormalized_service = denormalize_service_dict(service_dict, V3_1) - assert secret_sort(denormalized_service['secrets']) == secret_sort([ - {'source': 'one'}, - { - 'source': 'source', - 'target': 'target', - 'uid': '100', - 'gid': '200', - 'mode': 0o777, - }, - ]) + config_dict = config.load(build_config_details({ + 'version': '3.1', + 'services': {'web': service_dict} + })) + + serialized_config = yaml.load(serialize_config(config_dict)) + serialized_service = serialized_config['services']['web'] + assert secret_sort(serialized_service['secrets']) == secret_sort(service_dict['secrets']) diff --git a/tests/unit/config/types_test.py b/tests/unit/config/types_test.py index 114273520e9..22d7aa88a62 100644 --- a/tests/unit/config/types_test.py +++ b/tests/unit/config/types_test.py @@ -7,6 +7,7 @@ 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 ServicePort from compose.config.types import VolumeFromSpec from compose.config.types import VolumeSpec @@ -41,6 +42,49 @@ def test_parse_extra_hosts_dict(): } +class TestServicePort(object): + def test_parse_dict(self): + data = { + 'target': 8000, + 'published': 8000, + 'protocol': 'udp', + 'mode': 'global', + } + ports = ServicePort.parse(data) + assert len(ports) == 1 + assert ports[0].repr() == data + + def test_parse_simple_target_port(self): + ports = ServicePort.parse(8000) + assert len(ports) == 1 + assert ports[0].target == '8000' + + def test_parse_complete_port_definition(self): + port_def = '1.1.1.1:3000:3000/udp' + ports = ServicePort.parse(port_def) + assert len(ports) == 1 + assert ports[0].repr() == { + 'target': '3000', + 'published': '3000', + 'external_ip': '1.1.1.1', + 'protocol': 'udp', + } + assert ports[0].legacy_repr() == port_def + + def test_parse_port_range(self): + ports = ServicePort.parse('25000-25001:4000-4001') + assert len(ports) == 2 + reprs = [p.repr() for p in ports] + assert { + 'target': '4000', + 'published': '25000' + } in reprs + assert { + 'target': '4001', + 'published': '25001' + } in reprs + + class TestVolumeSpec(object): def test_parse_volume_spec_only_one_path(self): diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 0a66e4f3e26..4d81623baf6 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -7,6 +7,7 @@ from .. import mock from .. import unittest +from compose.config.types import ServicePort from compose.config.types import VolumeFromSpec from compose.config.types import VolumeSpec from compose.const import LABEL_CONFIG_HASH @@ -19,6 +20,7 @@ from compose.service import build_volume_binding from compose.service import BuildAction from compose.service import ContainerNetworkMode +from compose.service import formatted_ports from compose.service import get_container_data_volumes from compose.service import ImageType from compose.service import merge_volume_bindings @@ -778,6 +780,25 @@ def test_network_mode_service_no_containers(self): self.assertEqual(network_mode.service_name, service_name) +class ServicePortsTest(unittest.TestCase): + def test_formatted_ports(self): + ports = [ + '3000', + '0.0.0.0:4025-4030:23000-23005', + ServicePort(6000, None, None, None, None), + ServicePort(8080, 8080, None, None, None), + ServicePort('20000', '20000', 'udp', 'ingress', None), + ServicePort(30000, '30000', 'tcp', None, '127.0.0.1'), + ] + formatted = formatted_ports(ports) + assert ports[0] in formatted + assert ports[1] in formatted + assert '6000/tcp' in formatted + assert '8080:8080/tcp' in formatted + assert '20000:20000/udp' in formatted + assert '127.0.0.1:30000:30000/tcp' in formatted + + def build_mount(destination, source, mode='rw'): return {'Source': source, 'Destination': destination, 'Mode': mode} From d67261f26ec6eb128657f99b8dbb82a828298822 Mon Sep 17 00:00:00 2001 From: Evan Shaw Date: Thu, 2 Mar 2017 10:03:31 +1300 Subject: [PATCH 2663/4072] Add --volumes flag to config command Closes #3609 Signed-off-by: Evan Shaw --- compose/cli/main.py | 5 +++++ tests/acceptance/cli_test.py | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/compose/cli/main.py b/compose/cli/main.py index 51ba36a094b..4c36955046a 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -301,6 +301,7 @@ def config(self, config_options, options): -q, --quiet Only validate the configuration, don't print anything. --services Print the service names, one per line. + --volumes Print the volume names, one per line. """ compose_config = get_config_from_options(self.project_dir, config_options) @@ -312,6 +313,10 @@ def config(self, config_options, options): print('\n'.join(service['name'] for service in compose_config.services)) return + if options['--volumes']: + print('\n'.join(volume for volume in compose_config.volumes)) + return + print(serialize_config(compose_config)) def create(self, options): diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 8366ca75e29..ee895b409f5 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -177,6 +177,11 @@ def test_config_list_services(self): result = self.dispatch(['config', '--services']) assert set(result.stdout.rstrip().split('\n')) == {'web', 'other'} + def test_config_list_volumes(self): + self.base_dir = 'tests/fixtures/v2-full' + result = self.dispatch(['config', '--volumes']) + assert set(result.stdout.rstrip().split('\n')) == {'data'} + def test_config_quiet_with_error(self): self.base_dir = None result = self.dispatch([ From 6c3641fdb7c757980dcd04b44d0a573536e149b8 Mon Sep 17 00:00:00 2001 From: kbroadwater Date: Fri, 10 Feb 2017 10:35:08 -0800 Subject: [PATCH 2664/4072] Returing 1 when a container exits with a non-zero exit code with --abort-on-container-exit is set. Signed-off-by: Kevin Broadwater Catching the first container to exit Signed-off-by: Kevin Broadwater Addressing feedback and fixing tests Signed-off-by: Kevin Broadwater Adding break and removing extra fixture files Signed-off-by: Kevin Broadwater Moving break Signed-off-by: Kevin Broadwater --- compose/cli/log_printer.py | 21 +++++++++++-------- compose/cli/main.py | 9 +++++++- tests/acceptance/cli_test.py | 14 ++++++++++--- .../docker-compose.yml | 6 ++++++ .../docker-compose.yml | 6 ++++++ tests/unit/cli/log_printer_test.py | 6 ++++-- 6 files changed, 47 insertions(+), 15 deletions(-) create mode 100644 tests/fixtures/abort-on-container-exit-0/docker-compose.yml create mode 100644 tests/fixtures/abort-on-container-exit-1/docker-compose.yml diff --git a/compose/cli/log_printer.py b/compose/cli/log_printer.py index 299ddea465c..043d3d068b2 100644 --- a/compose/cli/log_printer.py +++ b/compose/cli/log_printer.py @@ -87,6 +87,13 @@ def run(self): for line in consume_queue(queue, self.cascade_stop): remove_stopped_threads(thread_map) + if self.cascade_stop: + matching_container = [cont.name for cont in self.containers if cont.name == line] + if line in matching_container: + # Returning the name of the container that started the + # the cascade_stop so we can return the correct exit code + return line + if not line: if not thread_map: # There are no running containers left to tail, so exit @@ -132,8 +139,8 @@ def exception(cls, exc): return cls(None, None, exc) @classmethod - def stop(cls): - return cls(None, True, None) + def stop(cls, item=None): + return cls(item, True, None) def tail_container_logs(container, presenter, queue, log_args): @@ -145,10 +152,9 @@ def tail_container_logs(container, presenter, queue, log_args): except Exception as 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()) + queue.put(QueueItem.stop(container.name)) def get_log_generator(container): @@ -228,10 +234,7 @@ def consume_queue(queue, cascade_stop): if item.exc: raise item.exc - if item.is_stop: - if cascade_stop: - raise StopIteration - else: - continue + if item.is_stop and not cascade_stop: + continue yield item.item diff --git a/compose/cli/main.py b/compose/cli/main.py index d0cf03cba61..f5d2429f318 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -890,11 +890,18 @@ def up(self, options): cascade_stop, event_stream=self.project.events(service_names=service_names)) print("Attaching to", list_containers(log_printer.containers)) - log_printer.run() + cascade_starter = log_printer.run() if cascade_stop: print("Aborting on container exit...") + exit_code = 0 + for e in self.project.containers(service_names=options['SERVICE'], stopped=True): + if (not e.is_running and cascade_starter == e.name): + if not e.exit_code == 0: + exit_code = e.exit_code + break self.project.stop(service_names=service_names, timeout=timeout) + sys.exit(exit_code) @classmethod def version(cls, options): diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 417eadb5c33..9379a6c3103 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1085,10 +1085,18 @@ def test_up_handles_force_shutdown(self): 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']) + self.base_dir = 'tests/fixtures/abort-on-container-exit-0' + proc = start_process(self.base_dir, ['up', '--abort-on-container-exit']) + wait_on_condition(ContainerCountCondition(self.project, 0)) + proc.wait() + self.assertEqual(proc.returncode, 0) + + def test_up_handles_abort_on_container_exit_code(self): + self.base_dir = 'tests/fixtures/abort-on-container-exit-1' + proc = start_process(self.base_dir, ['up', '--abort-on-container-exit']) wait_on_condition(ContainerCountCondition(self.project, 0)) + proc.wait() + self.assertEqual(proc.returncode, 1) def test_exec_without_tty(self): self.base_dir = 'tests/fixtures/links-composefile' diff --git a/tests/fixtures/abort-on-container-exit-0/docker-compose.yml b/tests/fixtures/abort-on-container-exit-0/docker-compose.yml new file mode 100644 index 00000000000..ce41697bcd9 --- /dev/null +++ b/tests/fixtures/abort-on-container-exit-0/docker-compose.yml @@ -0,0 +1,6 @@ +simple: + image: busybox:latest + command: top +another: + image: busybox:latest + command: ls . diff --git a/tests/fixtures/abort-on-container-exit-1/docker-compose.yml b/tests/fixtures/abort-on-container-exit-1/docker-compose.yml new file mode 100644 index 00000000000..7ec9b7e117f --- /dev/null +++ b/tests/fixtures/abort-on-container-exit-1/docker-compose.yml @@ -0,0 +1,6 @@ +simple: + image: busybox:latest + command: top +another: + image: busybox:latest + command: ls /thecakeisalie diff --git a/tests/unit/cli/log_printer_test.py b/tests/unit/cli/log_printer_test.py index b908eb68b62..d0c4b56be22 100644 --- a/tests/unit/cli/log_printer_test.py +++ b/tests/unit/cli/log_printer_test.py @@ -187,11 +187,13 @@ def test_item_is_stop_without_cascade_stop(self): assert next(generator) == 'b' def test_item_is_stop_with_cascade_stop(self): + """Return the name of the container that caused the cascade_stop""" queue = Queue() - for item in QueueItem.stop(), QueueItem.new('a'), QueueItem.new('b'): + for item in QueueItem.stop('foobar-1'), QueueItem.new('a'), QueueItem.new('b'): queue.put(item) - assert list(consume_queue(queue, True)) == [] + generator = consume_queue(queue, True) + assert next(generator) is 'foobar-1' def test_item_is_none_when_timeout_is_hit(self): queue = Queue() From 33f510b3409a55fd7a4e9050853e14cbd853d18e Mon Sep 17 00:00:00 2001 From: Jesus Tinoco Date: Sat, 11 Jun 2016 01:12:02 +0200 Subject: [PATCH 2665/4072] 3501 - Add a new command option (images) Signed-off-by: Jesus Rodriguez Tinoco --- compose/cli/main.py | 38 ++++++++++++++++++++++++++ contrib/completion/bash/docker-compose | 10 +++++++ tests/acceptance/cli_test.py | 27 ++++++++++++++++++ 3 files changed, 75 insertions(+) diff --git a/compose/cli/main.py b/compose/cli/main.py index a7aec945e74..ba053e23376 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -175,6 +175,7 @@ class TopLevelCommand(object): events Receive real time events from containers exec Execute a command in a running container help Get help on a command + images List images kill Kill containers logs View output from containers pause Pause services @@ -481,6 +482,43 @@ def help(cls, options): print(getdoc(subject)) + def images(self, options): + """ + List images. + Usage: images [options] [SERVICE...] + + Options: + -q Only display IDs + """ + containers = sorted( + self.project.containers(service_names=options['SERVICE'], stopped=True) + + self.project.containers(service_names=options['SERVICE'], one_off=OneOffFilter.only), + key=attrgetter('name')) + + if options['-q']: + for container in containers: + print(str.split(str(container.image), ':')[1]) + else: + headers = [ + 'Repository', + 'Tag', + 'Image Id', + 'Size' + ] + rows = [] + for container in containers: + image_config = container.image_config + repo_tags = str.split(str(image_config['RepoTags'][0]), ':') + image_id = str.split(str(container.image), ':')[1][0:12] + size = round(int(image_config['Size'])/float(1 << 20), 1) + rows.append([ + repo_tags[0], + repo_tags[1], + image_id, + size + ]) + print(Formatter().table(headers, rows)) + def kill(self, options): """ Force stop service containers. diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index f4b9342f34a..fa099eac41b 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -220,6 +220,16 @@ _docker_compose_help() { COMPREPLY=( $( compgen -W "${commands[*]}" -- "$cur" ) ) } +_docker_compose_images() { + case "$cur" in + -*) + COMPREPLY=( $( compgen -W "--help -q" -- "$cur" ) ) + ;; + *) + __docker_compose_services_all + ;; + esac +} _docker_compose_kill() { case "$prev" in diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index cc7bc5dfe48..26baf337788 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1986,3 +1986,30 @@ def test_forward_exitval(self): result = wait_on_process(proc, returncode=1) assert 'exitcodefrom_another_1 exited with code 1' in result.stdout + + def test_images(self): + self.project.get_service('simple').create_container() + result = self.dispatch(['images']) + assert 'simplecomposefile_simple' in result.stdout + + def test_images_default_composefile(self): + self.base_dir = 'tests/fixtures/multiple-composefiles' + self.dispatch(['up', '-d']) + result = self.dispatch(['images']) + + self.assertIn('multiplecomposefiles_simple', result.stdout) + self.assertIn('multiplecomposefiles_another', result.stdout) + self.assertNotIn('multiplecomposefiles_yetanother', result.stdout) + + def test_images_alternate_composefile(self): + config_path = os.path.abspath( + 'tests/fixtures/multiple-composefiles/compose2.yml') + self._project = get_project(self.base_dir, [config_path]) + + self.base_dir = 'tests/fixtures/multiple-composefiles' + self.dispatch(['-f', 'compose2.yml', 'up', '-d']) + result = self.dispatch(['-f', 'compose2.yml', 'images']) + + self.assertNotIn('multiplecomposefiles_simple', result.stdout) + self.assertNotIn('multiplecomposefiles_another', result.stdout) + self.assertIn('multiplecomposefiles_yetanother', result.stdout) From 90beeaf21c15de1c0da55eb1abc22820450c3ab8 Mon Sep 17 00:00:00 2001 From: Jesus Tinoco Date: Sat, 11 Jun 2016 02:10:52 +0200 Subject: [PATCH 2666/4072] 3501 - Remove a test and fix other Signed-off-by: Jesus Rodriguez Tinoco --- tests/acceptance/cli_test.py | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 26baf337788..ef907d541c5 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1990,26 +1990,11 @@ def test_forward_exitval(self): def test_images(self): self.project.get_service('simple').create_container() result = self.dispatch(['images']) - assert 'simplecomposefile_simple' in result.stdout + assert 'busybox' in result.stdout def test_images_default_composefile(self): self.base_dir = 'tests/fixtures/multiple-composefiles' self.dispatch(['up', '-d']) result = self.dispatch(['images']) - self.assertIn('multiplecomposefiles_simple', result.stdout) - self.assertIn('multiplecomposefiles_another', result.stdout) - self.assertNotIn('multiplecomposefiles_yetanother', result.stdout) - - def test_images_alternate_composefile(self): - config_path = os.path.abspath( - 'tests/fixtures/multiple-composefiles/compose2.yml') - self._project = get_project(self.base_dir, [config_path]) - - self.base_dir = 'tests/fixtures/multiple-composefiles' - self.dispatch(['-f', 'compose2.yml', 'up', '-d']) - result = self.dispatch(['-f', 'compose2.yml', 'images']) - - self.assertNotIn('multiplecomposefiles_simple', result.stdout) - self.assertNotIn('multiplecomposefiles_another', result.stdout) - self.assertIn('multiplecomposefiles_yetanother', result.stdout) + self.assertIn('busybox', result.stdout) From 815a3af6d21b12a6b16fa52db85eec354d27626d Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 2 Mar 2017 14:59:36 -0800 Subject: [PATCH 2667/4072] Fix docstring for images command Signed-off-by: Joffrey F --- compose/cli/main.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index ba053e23376..894dd9abb31 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -484,7 +484,7 @@ def help(cls, options): def images(self, options): """ - List images. + List images used by the created containers. Usage: images [options] [SERVICE...] Options: @@ -510,7 +510,7 @@ def images(self, options): image_config = container.image_config repo_tags = str.split(str(image_config['RepoTags'][0]), ':') image_id = str.split(str(container.image), ':')[1][0:12] - size = round(int(image_config['Size'])/float(1 << 20), 1) + size = round(int(image_config['Size']) / float(1 << 20), 1) rows.append([ repo_tags[0], repo_tags[1], From 11329e779bb986709add9eabfa90f28920a322ec Mon Sep 17 00:00:00 2001 From: stefan Date: Thu, 30 Jun 2016 14:07:57 +0200 Subject: [PATCH 2668/4072] added failing test that ensures that named volume will not be printed with a mode suffix Signed-off-by: stefan --- tests/acceptance/cli_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) mode change 100644 => 100755 tests/acceptance/cli_test.py diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py old mode 100644 new mode 100755 index cc7bc5dfe48..7cc5adb9276 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -222,7 +222,7 @@ def test_config_default(self): 'other': { 'image': 'busybox:latest', 'command': 'top', - 'volumes': ['/data:rw'], + 'volumes': ['/data'], }, }, } From 6a151aac046b18f4fe2ac46f79dc9c8ee83aa33a Mon Sep 17 00:00:00 2001 From: Yaroslav Molochko Date: Wed, 23 Nov 2016 12:12:57 -0800 Subject: [PATCH 2669/4072] introducing pids_limit, fix for #4178 Signed-off-by: Yaroslav Molochko --- compose/config/config.py | 1 + compose/config/config_schema_v2.1.json | 1 + compose/config/config_schema_v3.0.json | 1 + compose/service.py | 2 ++ tests/integration/service_test.py | 7 +++++++ 5 files changed, 12 insertions(+) diff --git a/compose/config/config.py b/compose/config/config.py index 003b2e2f4a0..a106a758a93 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -87,6 +87,7 @@ 'secrets', 'security_opt', 'shm_size', + 'pids_limit', 'stdin_open', 'stop_signal', 'sysctls', diff --git a/compose/config/config_schema_v2.1.json b/compose/config/config_schema_v2.1.json index d1ffff89a22..0f4455a7dbf 100644 --- a/compose/config/config_schema_v2.1.json +++ b/compose/config/config_schema_v2.1.json @@ -216,6 +216,7 @@ "security_opt": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, "shm_size": {"type": ["number", "string"]}, "sysctls": {"$ref": "#/definitions/list_or_dict"}, + "pids_limit": {"type": ["number", "string"]}, "stdin_open": {"type": "boolean"}, "stop_grace_period": {"type": "string", "format": "duration"}, "stop_signal": {"type": "string"}, diff --git a/compose/config/config_schema_v3.0.json b/compose/config/config_schema_v3.0.json index fbcd8bb859a..02a4e851d94 100644 --- a/compose/config/config_schema_v3.0.json +++ b/compose/config/config_schema_v3.0.json @@ -168,6 +168,7 @@ "security_opt": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, "shm_size": {"type": ["number", "string"]}, "sysctls": {"$ref": "#/definitions/list_or_dict"}, + "pids_limit": {"type": ["number", "string"]}, "stdin_open": {"type": "boolean"}, "stop_grace_period": {"type": "string", "format": "duration"}, "stop_signal": {"type": "string"}, diff --git a/compose/service.py b/compose/service.py index b42094e6814..c924f9ece51 100644 --- a/compose/service.py +++ b/compose/service.py @@ -66,6 +66,7 @@ 'oom_score_adj', 'mem_swappiness', 'pid', + 'pids_limit', 'privileged', 'restart', 'security_opt', @@ -772,6 +773,7 @@ def _get_container_host_config(self, override_options, one_off=False): cpu_quota=options.get('cpu_quota'), shm_size=options.get('shm_size'), sysctls=options.get('sysctls'), + pids_limit=options.get('pids_limit'), tmpfs=options.get('tmpfs'), oom_score_adj=options.get('oom_score_adj'), mem_swappiness=options.get('mem_swappiness'), diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 734da5dfa79..5e381501157 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -115,6 +115,13 @@ def test_create_container_with_shm_size(self): service.start_container(container) self.assertEqual(container.get('HostConfig.ShmSize'), 67108864) + def test_create_container_with_pids_limit(self): + self.require_api_version('1.23') + service = self.create_service('db', pids_limit=10) + container = service.create_container() + service.start_container(container) + self.assertEqual(container.get('HostConfig.PidsLimit'), 10) + 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 83388ec31ae0e81fec0c6904f74e04345f1fb069 Mon Sep 17 00:00:00 2001 From: Piotr Szymanski Date: Tue, 18 Oct 2016 19:05:32 +0200 Subject: [PATCH 2670/4072] enable -v flag for docker-compose run command Give user ability to attach volumes while running containers with docker-compose run command. Example is given in the test implementation, command is compatible with the one provided by docker engine. Signed-off-by: Piotr Szymanski --- compose/cli/main.py | 8 ++++- tests/acceptance/cli_test.py | 34 +++++++++++++++++++ .../docker-compose.yml | 2 ++ .../files/example.txt | 1 + tests/unit/cli_test.py | 3 ++ 5 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/simple-composefile-volume-ready/docker-compose.yml create mode 100644 tests/fixtures/simple-composefile-volume-ready/files/example.txt diff --git a/compose/cli/main.py b/compose/cli/main.py index a7aec945e74..a1644829364 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -24,6 +24,7 @@ from ..config import parse_environment from ..config.environment import Environment from ..config.serialize import serialize_config +from ..config.types import VolumeSpec from ..const import IS_WINDOWS_PLATFORM from ..errors import StreamParseError from ..progress_stream import StreamOutputError @@ -678,7 +679,7 @@ def run(self, options): running. If you do not want to start linked services, use `docker-compose run --no-deps SERVICE COMMAND [ARGS...]`. - Usage: run [options] [-p PORT...] [-e KEY=VAL...] SERVICE [COMMAND] [ARGS...] + Usage: run [options] [-v VOLUME...] [-p PORT...] [-e KEY=VAL...] SERVICE [COMMAND] [ARGS...] Options: -d Detached mode: Run container in the background, print @@ -692,6 +693,7 @@ def run(self, 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. + -v, --volume=[] Bind mount a volume (default []) -T Disable pseudo-tty allocation. By default `docker-compose run` allocates a TTY. -w, --workdir="" Working directory inside the container @@ -1035,6 +1037,10 @@ def build_container_options(options, detach, command): if options['--workdir']: container_options['working_dir'] = options['--workdir'] + if options['--volume']: + volumes = [VolumeSpec.parse(i) for i in options['--volume']] + container_options['volumes'] = volumes + return container_options diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index cc7bc5dfe48..0ddada5a0a7 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -5,6 +5,7 @@ import datetime import json import os +import os.path import signal import subprocess import time @@ -557,6 +558,39 @@ def test_create_with_no_recreate(self): self.assertEqual(old_ids, new_ids) + def test_run_one_off_with_volume(self): + self.base_dir = 'tests/fixtures/simple-composefile-volume-ready' + volume_path = os.path.abspath(os.path.join(os.getcwd(), self.base_dir, 'files')) + cmd_result = self.dispatch([ + 'run', + '-v', '{}:/data'.format(volume_path), + 'simple', + 'cat', '/data/example.txt' + ]) + assert cmd_result.stdout.strip() == 'FILE_CONTENT' + + def test_run_one_off_with_multiple_volumes(self): + self.base_dir = 'tests/fixtures/simple-composefile-volume-ready' + volume_path = os.path.abspath(os.path.join(os.getcwd(), self.base_dir, 'files')) + + cmd_result = self.dispatch([ + 'run', + '-v', '{}:/data'.format(volume_path), + '-v', '{}:/data1'.format(volume_path), + 'simple', + 'cat', '/data/example.txt' + ]) + assert cmd_result.stdout.strip() == 'FILE_CONTENT' + + cmd_result = self.dispatch([ + 'run', + '-v', '{}:/data'.format(volume_path), + '-v', '{}:/data1'.format(volume_path), + 'simple', + 'cat', '/data1/example.txt' + ]) + assert cmd_result.stdout.strip() == 'FILE_CONTENT' + def test_create_with_force_recreate_and_no_recreate(self): self.dispatch( ['create', '--force-recreate', '--no-recreate'], diff --git a/tests/fixtures/simple-composefile-volume-ready/docker-compose.yml b/tests/fixtures/simple-composefile-volume-ready/docker-compose.yml new file mode 100644 index 00000000000..98a7d23b723 --- /dev/null +++ b/tests/fixtures/simple-composefile-volume-ready/docker-compose.yml @@ -0,0 +1,2 @@ +simple: + image: busybox:latest diff --git a/tests/fixtures/simple-composefile-volume-ready/files/example.txt b/tests/fixtures/simple-composefile-volume-ready/files/example.txt new file mode 100644 index 00000000000..edb4d33905a --- /dev/null +++ b/tests/fixtures/simple-composefile-volume-ready/files/example.txt @@ -0,0 +1 @@ +FILE_CONTENT diff --git a/tests/unit/cli_test.py b/tests/unit/cli_test.py index fb0511f0a1a..f9ce240a37d 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -119,6 +119,7 @@ def test_run_interactive_passes_logs_false(self, mock_pseudo_terminal, mock_run_ '--entrypoint': None, '--service-ports': None, '--publish': [], + '--volume': [], '--rm': None, '--name': None, '--workdir': None, @@ -153,6 +154,7 @@ def test_run_service_with_restart_always(self): '--entrypoint': None, '--service-ports': None, '--publish': [], + '--volume': [], '--rm': None, '--name': None, '--workdir': None, @@ -175,6 +177,7 @@ def test_run_service_with_restart_always(self): '--entrypoint': None, '--service-ports': None, '--publish': [], + '--volume': [], '--rm': True, '--name': None, '--workdir': None, From 44653f28126b731d9bc7390a0eeae66b7bc19a78 Mon Sep 17 00:00:00 2001 From: Marcos Lilljedahl Date: Fri, 19 Aug 2016 03:04:46 -0300 Subject: [PATCH 2671/4072] Improve Dockerfile.run This dockerfile generates a more lightweight image that works with the current official dynamically generated binaries Signed-off-by: Marcos Lilljedahl Change image script to use new Dockerfile.run image without building compose Signed-off-by: Marcos Lilljedahl Apply suggested fixes Signed-off-by: Marcos Lilljedahl --- Dockerfile.run | 20 ++++++++++---------- script/build/image | 4 +++- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/Dockerfile.run b/Dockerfile.run index de46e35e5f5..5d246e9e6cf 100644 --- a/Dockerfile.run +++ b/Dockerfile.run @@ -1,14 +1,14 @@ +FROM alpine:3.4 -FROM alpine:3.4 -ARG version -RUN apk -U add \ - python \ - py-pip +ENV GLIBC 2.23-r3 -COPY requirements.txt /code/requirements.txt -RUN pip install -r /code/requirements.txt +RUN apk update && apk add --no-cache openssl ca-certificates && \ + wget -q -O /etc/apk/keys/sgerrand.rsa.pub https://raw.githubusercontent.com/sgerrand/alpine-pkg-glibc/master/sgerrand.rsa.pub && \ + wget https://github.com/sgerrand/alpine-pkg-glibc/releases/download/$GLIBC/glibc-$GLIBC.apk && \ + apk add --no-cache glibc-$GLIBC.apk && rm glibc-$GLIBC.apk && \ + ln -s /lib/libz.so.1 /usr/glibc-compat/lib/ && \ + ln -s /lib/libc.musl-x86_64.so.1 /usr/glibc-compat/lib -COPY dist/docker_compose-${version}-py2.py3-none-any.whl /code/ -RUN pip install --no-deps /code/docker_compose-${version}-py2.py3-none-any.whl +COPY dist/docker-compose-Linux-x86_64 /usr/local/bin/docker-compose -ENTRYPOINT ["/usr/bin/docker-compose"] +ENTRYPOINT ["docker-compose"] diff --git a/script/build/image b/script/build/image index 3590ce14e41..a3198c99f19 100755 --- a/script/build/image +++ b/script/build/image @@ -8,8 +8,10 @@ if [ -z "$1" ]; then fi TAG=$1 + VERSION="$(python setup.py --version)" ./script/build/write-git-sha python setup.py sdist bdist_wheel -docker build --build-arg version=$VERSION -t docker/compose:$TAG -f Dockerfile.run . +./script/build/linux +docker build -t docker/compose:$TAG -f Dockerfile.run . From c7b8278e78197b87399977c8a512ef72631816a3 Mon Sep 17 00:00:00 2001 From: George Lester Date: Wed, 13 Jul 2016 02:05:08 -0700 Subject: [PATCH 2672/4072] Implemented dns_opt Signed-off-by: George Lester --- compose/config/config.py | 1 + compose/config/config_schema_v2.0.json | 7 +++++++ compose/service.py | 3 ++- tests/integration/service_test.py | 8 ++++++++ tests/unit/config/config_test.py | 20 +++++++++++++++++++- 5 files changed, 37 insertions(+), 2 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 003b2e2f4a0..72d2e8e43e5 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -61,6 +61,7 @@ 'devices', 'dns', 'dns_search', + 'dns_opt', 'domainname', 'entrypoint', 'env_file', diff --git a/compose/config/config_schema_v2.0.json b/compose/config/config_schema_v2.0.json index 59c7b30c965..da0105aea08 100644 --- a/compose/config/config_schema_v2.0.json +++ b/compose/config/config_schema_v2.0.json @@ -81,6 +81,13 @@ "devices": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, "dns": {"$ref": "#/definitions/string_or_list"}, "dns_search": {"$ref": "#/definitions/string_or_list"}, + "dns_opt": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + }, "domainname": {"type": "string"}, "entrypoint": { "oneOf": [ diff --git a/compose/service.py b/compose/service.py index b42094e6814..0b6200dd52e 100644 --- a/compose/service.py +++ b/compose/service.py @@ -54,6 +54,7 @@ 'devices', 'dns', 'dns_search', + 'dns_opt', 'env_file', 'extra_hosts', 'group_add', @@ -755,7 +756,7 @@ def _get_container_host_config(self, override_options, one_off=False): network_mode=self.network_mode.mode, devices=options.get('devices'), dns=options.get('dns'), - dns_search=options.get('dns_search'), + dns_opt=options.get('dns_opt'), restart_policy=options.get('restart'), cap_add=options.get('cap_add'), cap_drop=options.get('cap_drop'), diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 734da5dfa79..f3dc346d166 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -890,6 +890,14 @@ def test_group_add_value(self): self.assertTrue("root" in host_container_groupadd) self.assertTrue("1" in host_container_groupadd) + def test_dns_opt_value(self): + service = self.create_service('web', dns_opt=["use-vc", "no-tld-query"]) + container = create_and_start_container(service) + + dns_opt = container.get('HostConfig.DNSOptions') + self.assertTrue("use-vc" in dns_opt) + self.assertTrue("no-tld-query" in dns_opt) + 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 aaa7fbf8d66..728206d5759 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1411,7 +1411,6 @@ def test_swappiness_option(self): ] def test_group_add_option(self): - actual = config.load(build_config_details({ 'version': '2', 'services': { @@ -1430,6 +1429,25 @@ def test_group_add_option(self): } ] + def test_dns_opt_option(self): + actual = config.load(build_config_details({ + 'version': '2', + 'services': { + 'web': { + 'image': 'alpine', + 'dns_opt': ["use-vc", "no-tld-query"] + } + } + })) + + assert actual.services == [ + { + 'name': 'web', + 'image': 'alpine', + 'dns_opt': ["use-vc", "no-tld-query"] + } + ] + def test_isolation_option(self): actual = config.load(build_config_details({ 'version': V2_1, From 7512dccaa85e1c891aca12c87fe0647676e34890 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 3 Mar 2017 15:40:10 -0800 Subject: [PATCH 2673/4072] Add dns_opt to 2.1 schema Signed-off-by: Joffrey F --- compose/config/config_schema_v2.0.json | 14 +++++++------- compose/config/config_schema_v2.1.json | 7 +++++++ compose/service.py | 1 + tests/integration/service_test.py | 10 +++++----- 4 files changed, 20 insertions(+), 12 deletions(-) diff --git a/compose/config/config_schema_v2.0.json b/compose/config/config_schema_v2.0.json index da0105aea08..d20a0d89aa0 100644 --- a/compose/config/config_schema_v2.0.json +++ b/compose/config/config_schema_v2.0.json @@ -80,14 +80,14 @@ "depends_on": {"$ref": "#/definitions/list_of_strings"}, "devices": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, "dns": {"$ref": "#/definitions/string_or_list"}, + "dns_opt": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + }, "dns_search": {"$ref": "#/definitions/string_or_list"}, - "dns_opt": { - "type": "array", - "items": { - "type": "string" - }, - "uniqueItems": true - }, "domainname": {"type": "string"}, "entrypoint": { "oneOf": [ diff --git a/compose/config/config_schema_v2.1.json b/compose/config/config_schema_v2.1.json index d1ffff89a22..3b01ddf69ba 100644 --- a/compose/config/config_schema_v2.1.json +++ b/compose/config/config_schema_v2.1.json @@ -100,6 +100,13 @@ ] }, "devices": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "dns_opt": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + }, "dns": {"$ref": "#/definitions/string_or_list"}, "dns_search": {"$ref": "#/definitions/string_or_list"}, "domainname": {"type": "string"}, diff --git a/compose/service.py b/compose/service.py index 0b6200dd52e..353c96af4e4 100644 --- a/compose/service.py +++ b/compose/service.py @@ -757,6 +757,7 @@ def _get_container_host_config(self, override_options, one_off=False): devices=options.get('devices'), dns=options.get('dns'), dns_opt=options.get('dns_opt'), + dns_search=options.get('dns_search'), restart_policy=options.get('restart'), cap_add=options.get('cap_add'), cap_drop=options.get('cap_drop'), diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index f3dc346d166..910f2c69c85 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -887,16 +887,16 @@ def test_group_add_value(self): 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) + assert "root" in host_container_groupadd + assert "1" in host_container_groupadd def test_dns_opt_value(self): service = self.create_service('web', dns_opt=["use-vc", "no-tld-query"]) container = create_and_start_container(service) - dns_opt = container.get('HostConfig.DNSOptions') - self.assertTrue("use-vc" in dns_opt) - self.assertTrue("no-tld-query" in dns_opt) + dns_opt = container.get('HostConfig.DnsOptions') + assert 'use-vc' in dns_opt + assert 'no-tld-query' in dns_opt def test_restart_on_failure_value(self): service = self.create_service('web', restart={ From b6fb3f263421ebb5b8ec685665b17063b1fc2982 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 2 Mar 2017 16:47:02 -0800 Subject: [PATCH 2674/4072] pids_limit not yet supported for swarm services - removing from v3 format Signed-off-by: Joffrey F --- compose/config/config_schema_v3.0.json | 1 - tests/integration/service_test.py | 3 ++- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/compose/config/config_schema_v3.0.json b/compose/config/config_schema_v3.0.json index 02a4e851d94..fbcd8bb859a 100644 --- a/compose/config/config_schema_v3.0.json +++ b/compose/config/config_schema_v3.0.json @@ -168,7 +168,6 @@ "security_opt": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, "shm_size": {"type": ["number", "string"]}, "sysctls": {"$ref": "#/definitions/list_or_dict"}, - "pids_limit": {"type": ["number", "string"]}, "stdin_open": {"type": "boolean"}, "stop_grace_period": {"type": "string", "format": "duration"}, "stop_signal": {"type": "string"}, diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 5e381501157..436d2db1003 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -115,12 +115,13 @@ def test_create_container_with_shm_size(self): service.start_container(container) self.assertEqual(container.get('HostConfig.ShmSize'), 67108864) + @pytest.mark.xfail(True, reason='Some kernels/configs do not support pids_limit') def test_create_container_with_pids_limit(self): self.require_api_version('1.23') service = self.create_service('db', pids_limit=10) container = service.create_container() service.start_container(container) - self.assertEqual(container.get('HostConfig.PidsLimit'), 10) + assert container.get('HostConfig.PidsLimit') == 10 def test_create_container_with_extra_hosts_list(self): extra_hosts = ['somehost:162.242.195.82', 'otherhost:50.31.209.229'] From 83728d2bcc3b56648ee53b177e1ad1ad1d586e17 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 2 Mar 2017 15:27:04 -0800 Subject: [PATCH 2675/4072] Do not add mode in volume representation if it's not a host binding Signed-off-by: Joffrey F --- compose/config/types.py | 3 ++- tests/acceptance/cli_test.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) mode change 100755 => 100644 tests/acceptance/cli_test.py diff --git a/compose/config/types.py b/compose/config/types.py index 811e6c1fc37..f4d2c26d95b 100644 --- a/compose/config/types.py +++ b/compose/config/types.py @@ -203,7 +203,8 @@ def parse(cls, volume_config, normalize=False): def repr(self): external = self.external + ':' if self.external else '' - return '{ext}{v.internal}:{v.mode}'.format(ext=external, v=self) + mode = ':' + self.mode if self.external else '' + return '{ext}{v.internal}{mode}'.format(mode=mode, ext=external, v=self) @property def is_named_volume(self): diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py old mode 100755 new mode 100644 index 7cc5adb9276..115dc643908 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -299,7 +299,7 @@ def test_config_v1(self): }, 'volume': { 'image': 'busybox', - 'volumes': ['/data:rw'], + 'volumes': ['/data'], 'network_mode': 'bridge', }, 'app': { From 8f8678987b4496384452143b6eeec88d51b14510 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 6 Mar 2017 15:56:41 -0800 Subject: [PATCH 2676/4072] Improve readability of code and output for the images command Signed-off-by: Joffrey F --- compose/cli/main.py | 13 ++++++++----- compose/cli/utils.py | 13 +++++++++++++ tests/acceptance/cli_test.py | 5 ++++- 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 894dd9abb31..98fc4e451da 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -47,6 +47,7 @@ from .log_printer import build_log_presenters from .log_printer import LogPrinter from .utils import get_version_info +from .utils import human_readable_file_size from .utils import yesno @@ -496,10 +497,11 @@ def images(self, options): key=attrgetter('name')) if options['-q']: - for container in containers: - print(str.split(str(container.image), ':')[1]) + for image in set(c.image for c in containers): + print(image.split(':')[1]) else: headers = [ + 'Container', 'Repository', 'Tag', 'Image Id', @@ -508,10 +510,11 @@ def images(self, options): rows = [] for container in containers: image_config = container.image_config - repo_tags = str.split(str(image_config['RepoTags'][0]), ':') - image_id = str.split(str(container.image), ':')[1][0:12] - size = round(int(image_config['Size']) / float(1 << 20), 1) + repo_tags = image_config['RepoTags'][0].split(':') + image_id = image_config['Id'].split(':')[1][:12] + size = human_readable_file_size(image_config['Size']) rows.append([ + container.name, repo_tags[0], repo_tags[1], image_id, diff --git a/compose/cli/utils.py b/compose/cli/utils.py index 580bd1b073d..4d4fc4c1814 100644 --- a/compose/cli/utils.py +++ b/compose/cli/utils.py @@ -2,6 +2,7 @@ from __future__ import division from __future__ import unicode_literals +import math import os import platform import ssl @@ -135,3 +136,15 @@ def unquote_path(s): if s[0] == '"' and s[-1] == '"': return s[1:-1] return s + + +def human_readable_file_size(size): + suffixes = ['B', 'kB', 'MB', 'GB', 'TB', 'PB', 'EB', ] + order = int(math.log(size, 2) / 10) if size else 0 + if order >= len(suffixes): + order = len(suffixes) - 1 + + return '{0:.3g} {1}'.format( + size / float(1 << (order * 10)), + suffixes[order] + ) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index ef907d541c5..bec83ba1c13 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1991,10 +1991,13 @@ def test_images(self): self.project.get_service('simple').create_container() result = self.dispatch(['images']) assert 'busybox' in result.stdout + assert 'simplecomposefile_simple_1' in result.stdout def test_images_default_composefile(self): self.base_dir = 'tests/fixtures/multiple-composefiles' self.dispatch(['up', '-d']) result = self.dispatch(['images']) - self.assertIn('busybox', result.stdout) + assert 'busybox' in result.stdout + assert 'multiplecomposefiles_another_1' in result.stdout + assert 'multiplecomposefiles_simple_1' in result.stdout From bf7c2bc0f885fbbcf33d6ed964bafaf41b28d3c4 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 2 Mar 2017 16:59:00 -0800 Subject: [PATCH 2677/4072] Use create_host_file in run -v tests to ensure file availability Signed-off-by: Joffrey F --- tests/acceptance/cli_test.py | 31 +++++++++++++++++++------------ tests/helpers.py | 29 +++++++++++++++++++++++++++++ tests/integration/project_test.py | 28 +--------------------------- 3 files changed, 49 insertions(+), 39 deletions(-) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 0ddada5a0a7..4e3c070e84c 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -19,6 +19,7 @@ from docker import errors from .. import mock +from ..helpers import create_host_file from compose.cli.command import get_project from compose.container import Container from compose.project import OneOffFilter @@ -561,35 +562,41 @@ def test_create_with_no_recreate(self): def test_run_one_off_with_volume(self): self.base_dir = 'tests/fixtures/simple-composefile-volume-ready' volume_path = os.path.abspath(os.path.join(os.getcwd(), self.base_dir, 'files')) - cmd_result = self.dispatch([ + create_host_file(self.client, os.path.join(volume_path, 'example.txt')) + + self.dispatch([ 'run', '-v', '{}:/data'.format(volume_path), 'simple', - 'cat', '/data/example.txt' - ]) - assert cmd_result.stdout.strip() == 'FILE_CONTENT' + 'test', '-f', '/data/example.txt' + ], returncode=0) + # FIXME: does not work with Python 3 + # assert cmd_result.stdout.strip() == 'FILE_CONTENT' def test_run_one_off_with_multiple_volumes(self): self.base_dir = 'tests/fixtures/simple-composefile-volume-ready' volume_path = os.path.abspath(os.path.join(os.getcwd(), self.base_dir, 'files')) + create_host_file(self.client, os.path.join(volume_path, 'example.txt')) - cmd_result = self.dispatch([ + self.dispatch([ 'run', '-v', '{}:/data'.format(volume_path), '-v', '{}:/data1'.format(volume_path), 'simple', - 'cat', '/data/example.txt' - ]) - assert cmd_result.stdout.strip() == 'FILE_CONTENT' + 'test', '-f', '/data/example.txt' + ], returncode=0) + # FIXME: does not work with Python 3 + # assert cmd_result.stdout.strip() == 'FILE_CONTENT' - cmd_result = self.dispatch([ + self.dispatch([ 'run', '-v', '{}:/data'.format(volume_path), '-v', '{}:/data1'.format(volume_path), 'simple', - 'cat', '/data1/example.txt' - ]) - assert cmd_result.stdout.strip() == 'FILE_CONTENT' + 'test', '-f' '/data1/example.txt' + ], returncode=0) + # FIXME: does not work with Python 3 + # assert cmd_result.stdout.strip() == 'FILE_CONTENT' def test_create_with_force_recreate_and_no_recreate(self): self.dispatch( diff --git a/tests/helpers.py b/tests/helpers.py index 4b422a6a0a6..59efd2557c4 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -1,6 +1,8 @@ from __future__ import absolute_import from __future__ import unicode_literals +import os + from compose.config.config import ConfigDetails from compose.config.config import ConfigFile from compose.config.config import load @@ -15,3 +17,30 @@ def build_config_details(contents, working_dir='working_dir', filename='filename working_dir, [ConfigFile(filename, contents)], ) + + +def create_host_file(client, filename): + dirname = os.path.dirname(filename) + + with open(filename, 'r') as fh: + content = fh.read() + + container = client.create_container( + 'busybox:latest', + ['sh', '-c', 'echo -n "{}" > {}'.format(content, filename)], + volumes={dirname: {}}, + host_config=client.create_host_config( + binds={dirname: {'bind': dirname, 'ro': False}}, + network_mode='none', + ), + ) + try: + client.start(container) + exitcode = client.wait(container) + + if exitcode != 0: + output = client.logs(container) + raise Exception( + "Container exited with code {}:\n{}".format(exitcode, output)) + finally: + client.remove_container(container, force=True) diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 28762cd2071..f0d21456b5c 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -10,6 +10,7 @@ from .. import mock from ..helpers import build_config as load_config +from ..helpers import create_host_file from .testcases import DockerClientTestCase from compose.config import config from compose.config import ConfigurationError @@ -1517,30 +1518,3 @@ def test_project_up_no_healthcheck_dependency(self): 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 ec252350ae09d4028a960dcf50515e360b26cf72 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 2 Mar 2017 16:03:35 -0800 Subject: [PATCH 2678/4072] Add mem_reservation option to service config in 2.0 and 2.1 formats Signed-off-by: Joffrey F --- compose/config/config.py | 3 ++- compose/config/config_schema_v2.0.json | 3 ++- compose/config/config_schema_v2.1.json | 3 ++- compose/service.py | 4 +++- tests/integration/service_test.py | 5 +++++ tests/unit/service_test.py | 14 ++++++++++++++ 6 files changed, 28 insertions(+), 4 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 003b2e2f4a0..a6ea43cbadc 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -74,7 +74,8 @@ 'labels', 'links', 'mac_address', - 'mem_limit', + 'mem_limit' + 'mem_reservation', 'memswap_limit', 'mem_swappiness', 'net', diff --git a/compose/config/config_schema_v2.0.json b/compose/config/config_schema_v2.0.json index 59c7b30c965..3871dbf26a3 100644 --- a/compose/config/config_schema_v2.0.json +++ b/compose/config/config_schema_v2.0.json @@ -138,8 +138,9 @@ "mac_address": {"type": "string"}, "mem_limit": {"type": ["number", "string"]}, - "memswap_limit": {"type": ["number", "string"]}, + "mem_reservation": {"type": ["string", "integer"]}, "mem_swappiness": {"type": "integer"}, + "memswap_limit": {"type": ["number", "string"]}, "network_mode": {"type": "string"}, "networks": { diff --git a/compose/config/config_schema_v2.1.json b/compose/config/config_schema_v2.1.json index d1ffff89a22..05509ff1a6e 100644 --- a/compose/config/config_schema_v2.1.json +++ b/compose/config/config_schema_v2.1.json @@ -161,8 +161,9 @@ "mac_address": {"type": "string"}, "mem_limit": {"type": ["number", "string"]}, - "memswap_limit": {"type": ["number", "string"]}, + "mem_reservation": {"type": ["string", "integer"]}, "mem_swappiness": {"type": "integer"}, + "memswap_limit": {"type": ["number", "string"]}, "network_mode": {"type": "string"}, "networks": { diff --git a/compose/service.py b/compose/service.py index b42094e6814..3266f4f6cf8 100644 --- a/compose/service.py +++ b/compose/service.py @@ -62,9 +62,10 @@ 'log_driver', 'log_opt', 'mem_limit', + 'mem_reservation', 'memswap_limit', - 'oom_score_adj', 'mem_swappiness', + 'oom_score_adj', 'pid', 'privileged', 'restart', @@ -760,6 +761,7 @@ def _get_container_host_config(self, override_options, one_off=False): cap_add=options.get('cap_add'), cap_drop=options.get('cap_drop'), mem_limit=options.get('mem_limit'), + mem_reservation=options.get('mem_reservation'), memswap_limit=options.get('memswap_limit'), ulimits=build_ulimits(options.get('ulimits')), log_config=log_config, diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 734da5dfa79..082bff93ca3 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -872,6 +872,11 @@ def test_mem_swappiness(self): container = create_and_start_container(service) self.assertEqual(container.get('HostConfig.MemorySwappiness'), 11) + def test_mem_reservation(self): + service = self.create_service('web', mem_reservation='20m') + container = create_and_start_container(service) + assert container.get('HostConfig.MemoryReservation') == 20 * 1024 * 1024 + def test_restart_always_value(self): service = self.create_service('web', restart={'Name': 'always'}) container = create_and_start_container(service) diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 0a66e4f3e26..6d2962fb935 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -168,6 +168,20 @@ def test_memory_swap_limit(self): 2000000000 ) + def test_mem_reservation(self): + self.mock_client.create_host_config.return_value = {} + + service = Service( + name='foo', + image='foo', + hostname='name', + client=self.mock_client, + mem_reservation='512m' + ) + service._get_container_create_options({'some': 'overrides'}, 1) + assert self.mock_client.create_host_config.called is True + assert self.mock_client.create_host_config.call_args[1]['mem_reservation'] == '512m' + def test_cgroup_parent(self): self.mock_client.create_host_config.return_value = {} From 449dcc9d7b10a05aa8a682c6d2f5d1a686eb8843 Mon Sep 17 00:00:00 2001 From: Dat Tran Date: Thu, 11 Aug 2016 07:54:28 -0700 Subject: [PATCH 2679/4072] support --build-arg for build command Signed-off-by: Dat Tran --- compose/cli/main.py | 20 ++++++++++++++------ compose/config/__init__.py | 1 + compose/config/config.py | 6 ++++++ compose/project.py | 4 ++-- compose/service.py | 12 +++++++++--- tests/unit/config/config_test.py | 19 +++++++++++++++++++ tests/unit/service_test.py | 19 +++++++++++++++++++ 7 files changed, 70 insertions(+), 11 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 810b13f5475..78e3d84b565 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -209,18 +209,26 @@ def build(self, options): e.g. `composetest_db`. If you change a service's `Dockerfile` or the contents of its build directory, you can run `docker-compose build` to rebuild it. - Usage: build [options] [SERVICE...] + Usage: build [options] [--build-arg key=val...] [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. + --force-rm Always remove intermediate containers. + --no-cache Do not use cache when building the image. + --pull Always attempt to pull a newer version of the image. + --build-arg key=val Set build-time variables for one service. """ + service_names = options['SERVICE'] + build_args = options.get('--build-arg', None) + + if not service_names and build_args: + raise UserError("Need service name for --build-arg option") + self.project.build( - service_names=options['SERVICE'], + service_names=service_names, no_cache=bool(options.get('--no-cache', False)), pull=bool(options.get('--pull', False)), - force_rm=bool(options.get('--force-rm', False))) + force_rm=bool(options.get('--force-rm', False)), + build_args=build_args) def bundle(self, config_options, options): """ diff --git a/compose/config/__init__.py b/compose/config/__init__.py index 7cf71eb98bd..b6e5e8d383a 100644 --- a/compose/config/__init__.py +++ b/compose/config/__init__.py @@ -7,5 +7,6 @@ from .config import DOCKER_CONFIG_KEYS from .config import find from .config import load +from .config import merge_build_args from .config import merge_environment from .config import parse_environment diff --git a/compose/config/config.py b/compose/config/config.py index dbf64bae274..718d3bf02c0 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -602,6 +602,12 @@ def resolve_environment(service_dict, environment=None): return dict(resolve_env_var(k, v, environment) for k, v in six.iteritems(env)) +def merge_build_args(base, override, environment): + override_args = parse_build_arguments(override) + override_dict = dict(resolve_env_var(k, v, environment) for k, v in six.iteritems(override_args)) + base.update(override_dict) + + def resolve_build_args(build, environment): args = parse_build_arguments(build.get('args')) return dict(resolve_env_var(k, v, environment) for k, v in six.iteritems(args)) diff --git a/compose/project.py b/compose/project.py index 5c21f3bf0ff..a75d71efc71 100644 --- a/compose/project.py +++ b/compose/project.py @@ -307,10 +307,10 @@ def restart(self, service_names=None, **options): 'Restarting') return containers - def build(self, service_names=None, no_cache=False, pull=False, force_rm=False): + def build(self, service_names=None, no_cache=False, pull=False, force_rm=False, build_args=None): for service in self.get_services(service_names): if service.can_be_built(): - service.build(no_cache, pull, force_rm) + service.build(no_cache, pull, force_rm, build_args) else: log.info('%s uses an image, skipping' % service.name) diff --git a/compose/service.py b/compose/service.py index 712d5ac1597..a889dd58cb6 100644 --- a/compose/service.py +++ b/compose/service.py @@ -21,6 +21,7 @@ from . import const from . import progress_stream from .config import DOCKER_CONFIG_KEYS +from .config import merge_build_args from .config import merge_environment from .config.types import ServicePort from .config.types import VolumeSpec @@ -803,13 +804,18 @@ def build_spec(secret): return [build_spec(secret) for secret in self.secrets] - def build(self, no_cache=False, pull=False, force_rm=False): + def build(self, no_cache=False, pull=False, force_rm=False, build_args=None): log.info('Building %s' % self.name) build_opts = self.options.get('build', {}) - path = build_opts.get('context') + + self_args_opts = build_opts.get('args', None) + if self_args_opts and build_args: + merge_build_args(self_args_opts, build_args, self.options.get('environment')) + # python2 os.stat() doesn't support unicode on some UNIX, so we # encode it to a bytestring to be safe + path = build_opts.get('context') if not six.PY3 and not IS_WINDOWS_PLATFORM: path = path.encode('utf8') @@ -822,8 +828,8 @@ def build(self, no_cache=False, pull=False, force_rm=False): pull=pull, nocache=no_cache, dockerfile=build_opts.get('dockerfile', None), - buildargs=build_opts.get('args', None), cache_from=build_opts.get('cache_from', None), + buildargs=self_args_opts ) try: diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index d016ae4e21e..3e3bd2bbf1a 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -15,6 +15,7 @@ from ...helpers import build_config_details from compose.config import config from compose.config import types +from compose.config.config import merge_build_args from compose.config.config import resolve_build_args from compose.config.config import resolve_environment from compose.config.config import V1 @@ -2881,6 +2882,24 @@ def test_resolve_build_args(self): {'arg1': 'value1', 'empty_arg': '', 'env_arg': 'value2', 'no_env': None}, ) + @mock.patch.dict(os.environ) + def test_merge_build_args(self): + os.environ['env_arg'] = 'value2' + + base = { + 'arg1': 'arg1_value', + 'arg2': 'arg2_value' + } + override = { + 'arg1': 'arg1_new_value', + 'arg2': 'arg2_value' + } + self.assertEqual(base['arg1'], 'arg1_value') + + merge_build_args(base, override, os.environ) + + self.assertEqual(base['arg1'], 'arg1_new_value') + @pytest.mark.xfail(IS_WINDOWS_PLATFORM, reason='paths use slash') @mock.patch.dict(os.environ) def test_resolve_path(self): diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 5dc5265e65c..ca741041489 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -513,6 +513,25 @@ def test_build_does_not_pull(self): self.assertEqual(self.mock_client.build.call_count, 1) self.assertFalse(self.mock_client.build.call_args[1]['pull']) + def test_build_with_override_build_args(self): + self.mock_client.build.return_value = [ + b'{"stream": "Successfully built 12345"}', + ] + + build_args = [ + 'arg1=arg1_new_value', + 'arg2=arg2_value' + ] + service = Service('foo', client=self.mock_client, + build={'context': '.', 'args': {'arg1': 'arg1', 'arg2': 'arg2'}}) + service.build(build_args=build_args) + + called_build_args = self.mock_client.build.call_args[1]['buildargs'] + + for arg in called_build_args: + if "arg1=" in arg: + self.assertEquals(arg, 'arg1=arg1_new_value') + def test_config_dict(self): self.mock_client.inspect_image.return_value = {'Id': 'abcd'} service = Service( From d5a2d37d059cd1de5865fc5eaa05461d94aadfff Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 7 Mar 2017 17:47:41 -0800 Subject: [PATCH 2680/4072] Properly resolve build args against host environment values Signed-off-by: Joffrey F --- compose/cli/main.py | 4 +++ compose/config/__init__.py | 2 +- compose/config/config.py | 12 ++----- compose/service.py | 11 +++--- tests/integration/service_test.py | 20 ++++++++++- tests/unit/config/config_test.py | 58 ++----------------------------- tests/unit/service_test.py | 18 +++++----- 7 files changed, 43 insertions(+), 82 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 78e3d84b565..4d31e25e845 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -22,6 +22,7 @@ from ..bundle import serialize_bundle from ..config import ConfigurationError from ..config import parse_environment +from ..config import resolve_build_args from ..config.environment import Environment from ..config.serialize import serialize_config from ..config.types import VolumeSpec @@ -219,6 +220,9 @@ def build(self, options): """ service_names = options['SERVICE'] build_args = options.get('--build-arg', None) + if build_args: + environment = Environment.from_env_file(self.project_dir) + build_args = resolve_build_args(build_args, environment) if not service_names and build_args: raise UserError("Need service name for --build-arg option") diff --git a/compose/config/__init__.py b/compose/config/__init__.py index b6e5e8d383a..b629edf66f3 100644 --- a/compose/config/__init__.py +++ b/compose/config/__init__.py @@ -7,6 +7,6 @@ from .config import DOCKER_CONFIG_KEYS from .config import find from .config import load -from .config import merge_build_args from .config import merge_environment from .config import parse_environment +from .config import resolve_build_args diff --git a/compose/config/config.py b/compose/config/config.py index 718d3bf02c0..c85ffdabb6b 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -602,14 +602,8 @@ def resolve_environment(service_dict, environment=None): return dict(resolve_env_var(k, v, environment) for k, v in six.iteritems(env)) -def merge_build_args(base, override, environment): - override_args = parse_build_arguments(override) - override_dict = dict(resolve_env_var(k, v, environment) for k, v in six.iteritems(override_args)) - base.update(override_dict) - - -def resolve_build_args(build, environment): - args = parse_build_arguments(build.get('args')) +def resolve_build_args(buildargs, environment): + args = parse_build_arguments(buildargs) return dict(resolve_env_var(k, v, environment) for k, v in six.iteritems(args)) @@ -1057,7 +1051,7 @@ def normalize_build(service_dict, working_dir, environment): build.update(service_dict['build']) if 'args' in build: build['args'] = build_string_dict( - resolve_build_args(build, environment) + resolve_build_args(build.get('args'), environment) ) service_dict['build'] = build diff --git a/compose/service.py b/compose/service.py index a889dd58cb6..29ee704762b 100644 --- a/compose/service.py +++ b/compose/service.py @@ -21,7 +21,6 @@ from . import const from . import progress_stream from .config import DOCKER_CONFIG_KEYS -from .config import merge_build_args from .config import merge_environment from .config.types import ServicePort from .config.types import VolumeSpec @@ -804,14 +803,14 @@ def build_spec(secret): return [build_spec(secret) for secret in self.secrets] - def build(self, no_cache=False, pull=False, force_rm=False, build_args=None): + def build(self, no_cache=False, pull=False, force_rm=False, build_args_override=None): log.info('Building %s' % self.name) build_opts = self.options.get('build', {}) - self_args_opts = build_opts.get('args', None) - if self_args_opts and build_args: - merge_build_args(self_args_opts, build_args, self.options.get('environment')) + build_args = build_opts.get('args', {}).copy() + if build_args_override: + build_args.update(build_args_override) # python2 os.stat() doesn't support unicode on some UNIX, so we # encode it to a bytestring to be safe @@ -829,7 +828,7 @@ def build(self, no_cache=False, pull=False, force_rm=False, build_args=None): nocache=no_cache, dockerfile=build_opts.get('dockerfile', None), cache_from=build_opts.get('cache_from', None), - buildargs=self_args_opts + buildargs=build_args ) try: diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index ddc2f3baeef..12ec8a9934a 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -597,12 +597,30 @@ def test_build_with_build_args(self): with open(os.path.join(base_dir, 'Dockerfile'), 'w') as f: f.write("FROM busybox\n") f.write("ARG build_version\n") + f.write("RUN echo ${build_version}\n") service = self.create_service('buildwithargs', build={'context': text_type(base_dir), 'args': {"build_version": "1"}}) service.build() assert service.image() + assert "build_version=1" in service.image()['ContainerConfig']['Cmd'] + + def test_build_with_build_args_override(self): + base_dir = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, base_dir) + + with open(os.path.join(base_dir, 'Dockerfile'), 'w') as f: + f.write("FROM busybox\n") + f.write("ARG build_version\n") + f.write("RUN echo ${build_version}\n") + + service = self.create_service('buildwithargs', + build={'context': text_type(base_dir), + 'args': {"build_version": "1"}}) + service.build(build_args_override={'build_version': '2'}) + assert service.image() + assert "build_version=2" in service.image()['ContainerConfig']['Cmd'] def test_start_container_stays_unprivileged(self): service = self.create_service('web') @@ -1057,7 +1075,7 @@ def test_custom_container_name(self): 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") + @pytest.mark.skipif(True, reason="Broken on 1.11.0 - 17.03.0") 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" diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 3e3bd2bbf1a..d7d342afa69 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -15,7 +15,6 @@ from ...helpers import build_config_details from compose.config import config from compose.config import types -from compose.config.config import merge_build_args from compose.config.config import resolve_build_args from compose.config.config import resolve_environment from compose.config.config import V1 @@ -1523,7 +1522,7 @@ def test_merge_service_dicts_heterogeneous(self): assert actual == { 'image': 'alpine:edge', 'volumes': ['.:/app'], - 'ports': ['5432'] + 'ports': types.ServicePort.parse('5432') } def test_merge_service_dicts_heterogeneous_2(self): @@ -1542,40 +1541,7 @@ def test_merge_service_dicts_heterogeneous_2(self): assert actual == { 'image': 'alpine:edge', 'volumes': ['.:/app'], - 'ports': ['5432'] - } - - 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', - }, - } + 'ports': types.ServicePort.parse('5432') } def test_merge_logging_v1(self): @@ -2878,28 +2844,10 @@ def test_resolve_build_args(self): } } self.assertEqual( - resolve_build_args(build, Environment.from_env_file(build['context'])), + resolve_build_args(build['args'], Environment.from_env_file(build['context'])), {'arg1': 'value1', 'empty_arg': '', 'env_arg': 'value2', 'no_env': None}, ) - @mock.patch.dict(os.environ) - def test_merge_build_args(self): - os.environ['env_arg'] = 'value2' - - base = { - 'arg1': 'arg1_value', - 'arg2': 'arg2_value' - } - override = { - 'arg1': 'arg1_new_value', - 'arg2': 'arg2_value' - } - self.assertEqual(base['arg1'], 'arg1_value') - - merge_build_args(base, override, os.environ) - - self.assertEqual(base['arg1'], 'arg1_new_value') - @pytest.mark.xfail(IS_WINDOWS_PLATFORM, reason='paths use slash') @mock.patch.dict(os.environ) def test_resolve_path(self): diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index ca741041489..b3c8c4d7de1 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -461,7 +461,7 @@ def test_create_container(self): forcerm=False, nocache=False, rm=True, - buildargs=None, + buildargs={}, cache_from=None, ) @@ -498,7 +498,7 @@ def test_ensure_image_exists_force_build(self): forcerm=False, nocache=False, rm=True, - buildargs=None, + buildargs={}, cache_from=None, ) @@ -518,19 +518,17 @@ def test_build_with_override_build_args(self): b'{"stream": "Successfully built 12345"}', ] - build_args = [ - 'arg1=arg1_new_value', - 'arg2=arg2_value' - ] + build_args = { + 'arg1': 'arg1_new_value', + } service = Service('foo', client=self.mock_client, build={'context': '.', 'args': {'arg1': 'arg1', 'arg2': 'arg2'}}) - service.build(build_args=build_args) + service.build(build_args_override=build_args) called_build_args = self.mock_client.build.call_args[1]['buildargs'] - for arg in called_build_args: - if "arg1=" in arg: - self.assertEquals(arg, 'arg1=arg1_new_value') + assert called_build_args['arg1'] == build_args['arg1'] + assert called_build_args['arg2'] == 'arg2' def test_config_dict(self): self.mock_client.inspect_image.return_value = {'Id': 'abcd'} From 0652530ee94c5c7b15fb2d340c5bf8d9a5564300 Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Wed, 8 Mar 2017 12:49:31 -0800 Subject: [PATCH 2681/4072] Add bash completion for `run --volume` 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 fa099eac41b..59eb5349981 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -375,14 +375,14 @@ _docker_compose_run() { __docker_compose_nospace return ;; - --entrypoint|--name|--user|-u|--workdir|-w) + --entrypoint|--name|--user|-u|--volume|-v|--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 --workdir -w" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "-d --entrypoint -e --help --name --no-deps --publish -p --rm --service-ports -T --user -u --volume -v --workdir -w" -- "$cur" ) ) ;; *) __docker_compose_services_all From 38087a288889bae4b8ce13b15e7e7b0ca392c071 Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Wed, 8 Mar 2017 13:07:25 -0800 Subject: [PATCH 2682/4072] Fix bash completion for `docker-compose images` Signed-off-by: Harald Albers --- contrib/completion/bash/docker-compose | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index fa099eac41b..49a76d001b4 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -221,14 +221,14 @@ _docker_compose_help() { } _docker_compose_images() { - case "$cur" in - -*) - COMPREPLY=( $( compgen -W "--help -q" -- "$cur" ) ) - ;; - *) - __docker_compose_services_all - ;; - esac + case "$cur" in + -*) + COMPREPLY=( $( compgen -W "--help -q" -- "$cur" ) ) + ;; + *) + __docker_compose_services_all + ;; + esac } _docker_compose_kill() { @@ -508,6 +508,7 @@ _docker_compose() { events exec help + images kill logs pause From 1899eac2ba428f2dfc0329823489549cb6159674 Mon Sep 17 00:00:00 2001 From: Henrik Holst Date: Sat, 17 Dec 2016 17:39:58 +0100 Subject: [PATCH 2683/4072] Fixes https://github.com/docker/compose/issues/4099 Signed-off-by: Henrik Holst --- 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 020354283f6..26ff2cae5fb 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -54,7 +54,8 @@ def get_config_path_from_options(base_dir, options, environment): config_files = environment.get('COMPOSE_FILE') if config_files: - return config_files.split(os.pathsep) + pathsep = environment.get('COMPOSE_FILE_SEPARATOR', os.pathsep) + return config_files.split(pathsep) return None From ac12ab95c4c99a60811f4c34e49efcceb9a13c8e Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 8 Mar 2017 14:30:15 -0800 Subject: [PATCH 2684/4072] Rename COMPOSE_FILE_SEPARATOR -> COMPOSE_PATH_SEPARATOR Add unit test Signed-off-by: Joffrey F --- compose/cli/command.py | 2 +- tests/unit/cli/command_test.py | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/compose/cli/command.py b/compose/cli/command.py index 26ff2cae5fb..c74e585dea0 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -54,7 +54,7 @@ def get_config_path_from_options(base_dir, options, environment): config_files = environment.get('COMPOSE_FILE') if config_files: - pathsep = environment.get('COMPOSE_FILE_SEPARATOR', os.pathsep) + pathsep = environment.get('COMPOSE_PATH_SEPARATOR', os.pathsep) return config_files.split(pathsep) return None diff --git a/tests/unit/cli/command_test.py b/tests/unit/cli/command_test.py index 50fc84e17f6..3655c432e98 100644 --- a/tests/unit/cli/command_test.py +++ b/tests/unit/cli/command_test.py @@ -45,6 +45,15 @@ def test_multiple_path_from_env_windows(self): '.', {}, environment ) == ['one.yml', 'two.yml'] + def test_multiple_path_from_env_custom_separator(self): + with mock.patch.dict(os.environ): + os.environ['COMPOSE_PATH_SEPARATOR'] = '^' + os.environ['COMPOSE_FILE'] = 'c:\\one.yml^.\\semi;colon.yml' + environment = Environment.from_env_file('.') + assert get_config_path_from_options( + '.', {}, environment + ) == ['c:\\one.yml', '.\\semi;colon.yml'] + def test_no_path(self): environment = Environment.from_env_file('.') assert not get_config_path_from_options('.', {}, environment) From 707210ae95e2e3961e2eb41312d3c06ca56d15a8 Mon Sep 17 00:00:00 2001 From: Dimitar Bonev Date: Thu, 7 Jan 2016 22:15:02 +0200 Subject: [PATCH 2685/4072] Ability to change working directory via a CLI flag Signed-off-by: Dimitar Bonev --- compose/cli/command.py | 7 ++++--- compose/cli/main.py | 2 ++ compose/config/config.py | 6 +++--- tests/acceptance/cli_test.py | 20 ++++++++++++++++++- .../docker-compose.yml | 2 ++ tests/unit/config/config_test.py | 10 ++++++++-- 6 files changed, 38 insertions(+), 9 deletions(-) create mode 100644 tests/fixtures/build-path-override-dir/docker-compose.yml diff --git a/compose/cli/command.py b/compose/cli/command.py index 020354283f6..f116a52787e 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -33,7 +33,8 @@ def project_from_options(project_dir, options): verbose=options.get('--verbose'), host=host, tls_config=tls_config_from_options(options), - environment=environment + environment=environment, + override_dir=options.get('--project-directory'), ) @@ -93,10 +94,10 @@ def get_client(environment, verbose=False, version=None, tls_config=None, host=N def get_project(project_dir, config_path=None, project_name=None, verbose=False, - host=None, tls_config=None, environment=None): + host=None, tls_config=None, environment=None, override_dir=None): if not environment: environment = Environment.from_env_file(project_dir) - config_details = config.find(project_dir, config_path, environment) + config_details = config.find(project_dir, config_path, environment, override_dir) project_name = get_project_name( config_details.working_dir, project_name, environment ) diff --git a/compose/cli/main.py b/compose/cli/main.py index 4d31e25e845..84786abc707 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -168,6 +168,8 @@ class TopLevelCommand(object): --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) + --project-directory PATH Specify an alternate working directory + (default: the path of the compose file) Commands: build Build or rebuild services diff --git a/compose/config/config.py b/compose/config/config.py index c85ffdabb6b..5d74fc76fa1 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -235,10 +235,10 @@ def with_abs_paths(cls, working_dir, filename, name, config): config) -def find(base_dir, filenames, environment): +def find(base_dir, filenames, environment, override_dir='.'): if filenames == ['-']: return ConfigDetails( - os.getcwd(), + os.path.abspath(override_dir), [ConfigFile(None, yaml.safe_load(sys.stdin))], environment ) @@ -250,7 +250,7 @@ def find(base_dir, filenames, environment): log.debug("Using configuration files: {}".format(",".join(filenames))) return ConfigDetails( - os.path.dirname(filenames[0]), + override_dir or os.path.dirname(filenames[0]), [ConfigFile.from_filename(f) for f in filenames], environment ) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 31853a11be8..6a498e250c0 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -107,6 +107,7 @@ class CLITestCase(DockerClientTestCase): def setUp(self): super(CLITestCase, self).setUp() self.base_dir = 'tests/fixtures/simple-composefile' + self.override_dir = None def tearDown(self): if self.base_dir: @@ -129,7 +130,7 @@ def tearDown(self): def project(self): # Hack: allow project to be overridden if not hasattr(self, '_project'): - self._project = get_project(self.base_dir) + self._project = get_project(self.base_dir, override_dir=self.override_dir) return self._project def dispatch(self, options, project_options=None, returncode=0): @@ -518,6 +519,23 @@ def test_bundle_with_digests(self): }, } + def test_build_override_dir(self): + self.base_dir = 'tests/fixtures/build-path-override-dir' + self.override_dir = os.path.abspath('tests/fixtures') + result = self.dispatch([ + '--project-directory', self.override_dir, + 'build']) + + assert 'Successfully built' in result.stdout + + def test_build_override_dir_invalid_path(self): + config_path = os.path.abspath('tests/fixtures/build-path-override-dir/docker-compose.yml') + result = self.dispatch([ + '-f', config_path, + 'build'], returncode=1) + + assert 'does not exist, is not accessible, or is not a valid URL' in result.stderr + def test_create(self): self.dispatch(['create']) service = self.project.get_service('simple') diff --git a/tests/fixtures/build-path-override-dir/docker-compose.yml b/tests/fixtures/build-path-override-dir/docker-compose.yml new file mode 100644 index 00000000000..15dbb3e68ea --- /dev/null +++ b/tests/fixtures/build-path-override-dir/docker-compose.yml @@ -0,0 +1,2 @@ +foo: + build: ./build-ctx/ diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index d7d342afa69..1b98a5ecea8 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -2875,9 +2875,9 @@ def test_resolve_path(self): set([VolumeSpec.parse('/opt/tmp:/opt/host/tmp')])) -def load_from_filename(filename): +def load_from_filename(filename, override_dir=None): return config.load( - config.find('.', [filename], Environment.from_env_file('.')) + config.find('.', [filename], Environment.from_env_file('.'), override_dir=override_dir) ).services @@ -3443,6 +3443,12 @@ def test_from_file(self): service_dict = load_from_filename('tests/fixtures/build-path/docker-compose.yml') self.assertEqual(service_dict, [{'name': 'foo', 'build': {'context': self.abs_context_path}}]) + def test_from_file_override_dir(self): + override_dir = os.path.join(os.getcwd(), 'tests/fixtures/') + service_dict = load_from_filename( + 'tests/fixtures/build-path-override-dir/docker-compose.yml', override_dir=override_dir) + self.assertEquals(service_dict, [{'name': 'foo', 'build': {'context': self.abs_context_path}}]) + def test_valid_url_in_build_path(self): valid_urls = [ 'git://github.com/docker/docker', From 5ea916733495a3e09726b96c4716a87da60f8bb2 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 8 Mar 2017 18:29:09 -0800 Subject: [PATCH 2686/4072] Prevent service to create a container if it is referencing itself in an external link Signed-off-by: Joffrey F --- compose/service.py | 13 ++++++++++++- tests/unit/service_test.py | 9 +++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/compose/service.py b/compose/service.py index 29ee704762b..b9f77beb9e5 100644 --- a/compose/service.py +++ b/compose/service.py @@ -22,6 +22,7 @@ from . import progress_stream from .config import DOCKER_CONFIG_KEYS from .config import merge_environment +from .config.errors import DependencyError from .config.types import ServicePort from .config.types import VolumeSpec from .const import DEFAULT_TIMEOUT @@ -872,7 +873,17 @@ 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) + container_name = build_container_name( + self.project, self.name, number, one_off, + ) + ext_links_origins = [l.split(':')[0] for l in self.options.get('external_links', [])] + if container_name in ext_links_origins: + raise DependencyError( + 'Service {0} has a self-referential external link: {1}'.format( + self.name, container_name + ) + ) + return container_name def remove_image(self, image_type): if not image_type or image_type == ImageType.none: diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index b3c8c4d7de1..f3f3a2a83e1 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -7,6 +7,7 @@ from .. import mock from .. import unittest +from compose.config.errors import DependencyError from compose.config.types import ServicePort from compose.config.types import VolumeFromSpec from compose.config.types import VolumeSpec @@ -170,6 +171,14 @@ def test_memory_swap_limit(self): 2000000000 ) + def test_self_reference_external_link(self): + service = Service( + name='foo', + external_links=['default_foo_1'] + ) + with self.assertRaises(DependencyError): + service.get_container_name(1) + def test_mem_reservation(self): self.mock_client.create_host_config.return_value = {} From ba0468395b1418ab434f1c0d82e840131ca479a8 Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Thu, 9 Mar 2017 10:21:53 +0100 Subject: [PATCH 2687/4072] Add bash completion for `docker-compose rm --stop` Signed-off-by: Harald Albers --- contrib/completion/bash/docker-compose | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index 9012ec86ee3..2591f500bc9 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -36,6 +36,18 @@ __docker_compose_to_extglob() { echo "@($extglob)" } +# Determines whether the option passed as the first argument exist on +# the commandline. The option may be a pattern, e.g. `--force|-f`. +__docker_compose_has_option() { + local pattern="$1" + for (( i=2; i < $cword; ++i)); do + if [[ ${words[$i]} =~ ^($pattern)$ ]] ; then + return 0 + fi + done + return 1 +} + # suppress trailing whitespace __docker_compose_nospace() { # compopt is not available in ancient bash versions @@ -359,10 +371,14 @@ _docker_compose_restart() { _docker_compose_rm() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "--force -f --help -v" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--force -f --help --stop -s -v" -- "$cur" ) ) ;; *) - __docker_compose_services_stopped + if __docker_compose_has_option "--stop|-s" ; then + __docker_compose_services_all + else + __docker_compose_services_stopped + fi ;; esac } From c3bcd59aeb909d98637476a2a968c8daa66ad788 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 8 Mar 2017 18:51:07 -0800 Subject: [PATCH 2688/4072] Avoid encoding crash in log_api_error Signed-off-by: Joffrey F --- compose/cli/errors.py | 11 ++++++++--- tests/unit/cli/errors_test.py | 16 ++++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/compose/cli/errors.py b/compose/cli/errors.py index 5b977095568..23e065c99b5 100644 --- a/compose/cli/errors.py +++ b/compose/cli/errors.py @@ -7,6 +7,7 @@ from distutils.spawn import find_executable from textwrap import dedent +import six from docker.errors import APIError from requests.exceptions import ConnectionError as RequestsConnectionError from requests.exceptions import ReadTimeout @@ -68,14 +69,18 @@ def log_timeout_error(timeout): def log_api_error(e, client_version): - if b'client is newer than server' not in e.explanation: - log.error(e.explanation) + explanation = e.explanation + if isinstance(explanation, six.binary_type): + explanation = explanation.decode('utf-8') + + if 'client is newer than server' not in explanation: + log.error(explanation) return version = API_VERSION_TO_ENGINE_VERSION.get(client_version) if not version: # They've set a custom API version - log.error(e.explanation) + log.error(explanation) return log.error( diff --git a/tests/unit/cli/errors_test.py b/tests/unit/cli/errors_test.py index a7b57562f2c..7406a88803f 100644 --- a/tests/unit/cli/errors_test.py +++ b/tests/unit/cli/errors_test.py @@ -42,10 +42,26 @@ def test_api_error_version_mismatch(self, mock_logging): _, 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_mismatch_unicode_explanation(self, mock_logging): + with pytest.raises(errors.ConnectionError): + with handle_connection_errors(mock.Mock(api_version='1.22')): + raise APIError(None, None, u"client is newer than server") + + _, args, _ = mock_logging.error.mock_calls[0] + assert "Docker Engine of version 1.10.0 or greater" in args[0] + def test_api_error_version_other(self, mock_logging): msg = b"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.decode('utf-8')) + + def test_api_error_version_other_unicode_explanation(self, mock_logging): + msg = u"Something broke!" + with pytest.raises(errors.ConnectionError): + with handle_connection_errors(mock.Mock(api_version='1.22')): + raise APIError(None, None, msg) + mock_logging.error.assert_called_once_with(msg) From 23b873c2ceaf02ec0b57a299d76f58752ae910ea Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 10 Mar 2017 14:32:55 -0800 Subject: [PATCH 2689/4072] Add "secrets" section to docker-compose config output when applicable Signed-off-by: Joffrey F --- compose/config/errors.py | 2 +- compose/config/serialize.py | 24 +++++++++--------------- tests/unit/config/config_test.py | 8 +++++++- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/compose/config/errors.py b/compose/config/errors.py index 16ed01b8614..0f78d4a94eb 100644 --- a/compose/config/errors.py +++ b/compose/config/errors.py @@ -4,7 +4,7 @@ VERSION_EXPLANATION = ( 'You might be seeing this error because you\'re using the wrong Compose file version. ' - 'Either specify a supported version ("2.0", "2.1", "3.0") and place your ' + 'Either specify a supported version ("2.0", "2.1", "3.0", "3.1") and place your ' 'service definitions under the `services` key, or omit the `version` key ' '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 58581f7cc58..6e2ad590654 100644 --- a/compose/config/serialize.py +++ b/compose/config/serialize.py @@ -26,34 +26,28 @@ def serialize_dict_type(dumper, data): def denormalize_config(config): + result = {'version': V2_1 if config.version == V1 else config.version} denormalized_services = [ denormalize_service_dict(service_dict, config.version) for service_dict in config.services ] - services = { + result['services'] = { service_dict.pop('name'): service_dict for service_dict in denormalized_services } - networks = config.networks.copy() - for net_name, net_conf in networks.items(): + result['networks'] = config.networks.copy() + for net_name, net_conf in result['networks'].items(): if 'external_name' in net_conf: del net_conf['external_name'] - volumes = config.volumes.copy() - for vol_name, vol_conf in volumes.items(): + result['volumes'] = config.volumes.copy() + for vol_name, vol_conf in result['volumes'].items(): if 'external_name' in vol_conf: del vol_conf['external_name'] - version = config.version - if version == V1: - version = V2_1 - - return { - 'version': version, - 'services': services, - 'networks': networks, - 'volumes': volumes, - } + if config.version in (V3_1,): + result['secrets'] = config.secrets + return result def serialize_config(config): diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 1b98a5ecea8..fe896d8b5ba 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -3650,11 +3650,17 @@ def test_serialize_secrets(self): } ] } + secrets_dict = { + 'one': {'file': '/one.txt'}, + 'source': {'file': '/source.pem'} + } config_dict = config.load(build_config_details({ 'version': '3.1', - 'services': {'web': service_dict} + 'services': {'web': service_dict}, + 'secrets': secrets_dict })) serialized_config = yaml.load(serialize_config(config_dict)) serialized_service = serialized_config['services']['web'] assert secret_sort(serialized_service['secrets']) == secret_sort(service_dict['secrets']) + assert serialized_config['secrets'] == secrets_dict From a2e32b8166e0259fbb1773845cef0d3a54f55df6 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 13 Mar 2017 11:23:17 -0700 Subject: [PATCH 2690/4072] Add missing comma in DOCKER_CONFIG_KEYS Signed-off-by: Joffrey F --- compose/config/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/config/config.py b/compose/config/config.py index 5d74fc76fa1..4655fbdfc66 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -76,7 +76,7 @@ 'labels', 'links', 'mac_address', - 'mem_limit' + 'mem_limit', 'mem_reservation', 'memswap_limit', 'mem_swappiness', From 963b672cbd6445f047f719ff22f7d6d69ee19adf Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 10 Mar 2017 16:06:35 -0800 Subject: [PATCH 2691/4072] Ensure network config matches remote for all properties Signed-off-by: Joffrey F --- compose/network.py | 58 +++++++++++++++++++++++++----- tests/unit/network_test.py | 74 +++++++++++++++++++++++++++++++++++--- 2 files changed, 119 insertions(+), 13 deletions(-) diff --git a/compose/network.py b/compose/network.py index d98f68d2fb7..053fdacd87f 100644 --- a/compose/network.py +++ b/compose/network.py @@ -126,22 +126,64 @@ def create_ipam_config_from_dict(ipam_dict): ) +class NetworkConfigChangedError(ConfigurationError): + def __init__(self, net_name, property_name): + super(NetworkConfigChangedError, self).__init__( + 'Network "{}" needs to be recreated - {} has changed'.format( + net_name, property_name + ) + ) + + +def check_remote_ipam_config(remote, local): + remote_ipam = remote.get('IPAM') + ipam_dict = create_ipam_config_from_dict(local.ipam) + if local.ipam.get('driver') and local.ipam.get('driver') != remote_ipam.get('Driver'): + raise NetworkConfigChangedError(local.full_name, 'IPAM driver') + if len(ipam_dict['Config']) != 0: + if len(ipam_dict['Config']) != len(remote_ipam['Config']): + raise NetworkConfigChangedError(local.full_name, 'IPAM configs') + remote_configs = sorted(remote_ipam['Config'], key='Subnet') + local_configs = sorted(ipam_dict['Config'], key='Subnet') + while local_configs: + lc = local_configs.pop() + rc = remote_configs.pop() + if lc.get('Subnet') != rc.get('Subnet'): + raise NetworkConfigChangedError(local.full_name, 'IPAM config subnet') + if lc.get('Gateway') is not None and lc.get('Gateway') != rc.get('Gateway'): + raise NetworkConfigChangedError(local.full_name, 'IPAM config gateway') + if lc.get('IPRange') != rc.get('IPRange'): + raise NetworkConfigChangedError(local.full_name, 'IPAM config ip_range') + if sorted(lc.get('AuxiliaryAddresses')) != sorted(rc.get('AuxiliaryAddresses')): + raise NetworkConfigChangedError(local.full_name, 'IPAM config aux_addresses') + + def check_remote_network_config(remote, local): if local.driver and remote.get('Driver') != local.driver: - raise ConfigurationError( - 'Network "{}" needs to be recreated - driver has changed' - .format(local.full_name) - ) + raise NetworkConfigChangedError(local.full_name, 'driver') local_opts = local.driver_opts or {} 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_opts.get(k) != local_opts.get(k): - raise ConfigurationError( - 'Network "{}" needs to be recreated - options have changed' - .format(local.full_name) - ) + raise NetworkConfigChangedError(local.full_name, 'option "{}"'.format(k)) + + if local.ipam is not None: + check_remote_ipam_config(remote, local) + + if local.internal is not None and local.internal != remote.get('Internal', False): + raise NetworkConfigChangedError(local.full_name, 'internal') + if local.enable_ipv6 is not None and local.enable_ipv6 != remote.get('EnableIPv6', False): + raise NetworkConfigChangedError(local.full_name, 'enable_ipv6') + + local_labels = local.labels or {} + remote_labels = remote.get('Labels', {}) + for k in set.union(set(remote_labels.keys()), set(local_labels.keys())): + if k.startswith('com.docker.compose.'): # We are only interested in user-specified labels + continue + if remote_labels.get(k) != local_labels.get(k): + raise NetworkConfigChangedError(local.full_name, 'label "{}"'.format(k)) def build_networks(name, config_data, client): diff --git a/tests/unit/network_test.py b/tests/unit/network_test.py index 12d06f415e2..a325f1948f3 100644 --- a/tests/unit/network_test.py +++ b/tests/unit/network_test.py @@ -4,20 +4,62 @@ import pytest from .. import unittest -from compose.config import ConfigurationError from compose.network import check_remote_network_config from compose.network import Network +from compose.network import NetworkConfigChangedError class NetworkTest(unittest.TestCase): def test_check_remote_network_config_success(self): options = {'com.docker.network.driver.foo': 'bar'} + ipam_config = { + 'driver': 'default', + 'config': [ + {'subnet': '172.0.0.1/16', }, + { + 'subnet': '156.0.0.1/25', + 'gateway': '156.0.0.1', + 'aux_addresses': ['11.0.0.1', '24.25.26.27'], + 'ip_range': '156.0.0.1-254' + } + ] + } + labels = { + 'com.project.tests.istest': 'true', + 'com.project.sound.track': 'way out of here', + } + remote_labels = labels.copy() + remote_labels.update({ + 'com.docker.compose.project': 'compose_test', + 'com.docker.compose.network': 'net1', + }) net = Network( None, 'compose_test', 'net1', 'bridge', - options + options, enable_ipv6=True, ipam=ipam_config, + labels=labels ) check_remote_network_config( - {'Driver': 'bridge', 'Options': options}, net + { + 'Driver': 'bridge', + 'Options': options, + 'EnableIPv6': True, + 'Internal': False, + 'Attachable': True, + 'IPAM': { + 'Driver': 'default', + 'Config': [{ + 'Subnet': '156.0.0.1/25', + 'Gateway': '156.0.0.1', + 'AuxiliaryAddresses': ['24.25.26.27', '11.0.0.1'], + 'IPRange': '156.0.0.1-254' + }, { + 'Subnet': '172.0.0.1/16', + 'Gateway': '172.0.0.1' + }], + }, + 'Labels': remote_labels + }, + net ) def test_check_remote_network_config_whitelist(self): @@ -36,20 +78,42 @@ def test_check_remote_network_config_whitelist(self): def test_check_remote_network_config_driver_mismatch(self): net = Network(None, 'compose_test', 'net1', 'overlay') - with pytest.raises(ConfigurationError): + with pytest.raises(NetworkConfigChangedError) as e: check_remote_network_config( {'Driver': 'bridge', 'Options': {}}, net ) + assert 'driver has changed' in str(e.value) + def test_check_remote_network_config_options_mismatch(self): net = Network(None, 'compose_test', 'net1', 'overlay') - with pytest.raises(ConfigurationError): + with pytest.raises(NetworkConfigChangedError) as e: check_remote_network_config({'Driver': 'overlay', 'Options': { 'com.docker.network.driver.foo': 'baz' }}, net) + assert 'option "com.docker.network.driver.foo" has changed' in str(e.value) + 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 ) + + def test_check_remote_network_labels_mismatch(self): + net = Network(None, 'compose_test', 'net1', 'overlay', labels={ + 'com.project.touhou.character': 'sakuya.izayoi' + }) + remote = { + 'Driver': 'overlay', + 'Options': None, + 'Labels': { + 'com.docker.compose.network': 'net1', + 'com.docker.compose.project': 'compose_test', + 'com.project.touhou.character': 'marisa.kirisame', + } + } + with pytest.raises(NetworkConfigChangedError) as e: + check_remote_network_config(remote, net) + + assert 'label "com.project.touhou.character" has changed' in str(e.value) From a6db78e5d4c3e4233d78508d6b851cd1bd80638a Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 10 Mar 2017 14:50:26 -0800 Subject: [PATCH 2692/4072] Enable variable substitution in config.secrets Signed-off-by: Joffrey F --- compose/config/config.py | 9 +++++ tests/unit/config/config_test.py | 52 +++++++++++++++++-------- tests/unit/config/interpolation_test.py | 27 ++++++++++++- 3 files changed, 70 insertions(+), 18 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 5d74fc76fa1..413f1d3191a 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -218,6 +218,8 @@ class Config(namedtuple('_Config', 'version services volumes networks secrets')) :type volumes: :class:`dict` :param networks: Dictionary mapping network names to description dictionaries :type networks: :class:`dict` + :param secrets: Dictionary mapping secret names to description dictionaries + :type secrets: :class:`dict` """ @@ -491,6 +493,13 @@ def process_config_file(config_file, environment, service_name=None): config_file.get_networks(), 'network', environment) + if config_file.version in (V3_1,): + processed_config['secrets'] = interpolate_config_section( + config_file, + config_file.get_secrets(), + 'secrets', + environment + ) elif config_file.version == V1: processed_config = services else: diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index fe896d8b5ba..93bae972610 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1821,6 +1821,23 @@ def test_merge_depends_on_mixed_syntax(self): } } + def test_empty_environment_key_allowed(self): + service_dict = config.load( + build_config_details( + { + 'web': { + 'build': '.', + 'environment': { + 'POSTGRES_PASSWORD': '' + }, + }, + }, + '.', + None, + ) + ).services[0] + self.assertEqual(service_dict['environment']['POSTGRES_PASSWORD'], '') + def test_merge_pid(self): # Regression: https://github.com/docker/compose/issues/4184 base = { @@ -2335,22 +2352,23 @@ def test_invalid_interpolation(self): self.assertIn('in service "web"', cm.exception.msg) self.assertIn('"${"', cm.exception.msg) - def test_empty_environment_key_allowed(self): - service_dict = config.load( - build_config_details( - { - 'web': { - 'build': '.', - 'environment': { - 'POSTGRES_PASSWORD': '' - }, - }, - }, - '.', - None, - ) - ).services[0] - self.assertEqual(service_dict['environment']['POSTGRES_PASSWORD'], '') + @mock.patch.dict(os.environ) + def test_interpolation_secrets_section(self): + os.environ['FOO'] = 'baz.bar' + config_dict = config.load(build_config_details({ + 'version': '3.1', + 'secrets': { + 'secretdata': { + 'external': {'name': '$FOO'} + } + } + })) + assert config_dict.secrets == { + 'secretdata': { + 'external': {'name': 'baz.bar'}, + 'external_name': 'baz.bar' + } + } class VolumeConfigTest(unittest.TestCase): @@ -3663,4 +3681,4 @@ def test_serialize_secrets(self): serialized_config = yaml.load(serialize_config(config_dict)) serialized_service = serialized_config['services']['web'] assert secret_sort(serialized_service['secrets']) == secret_sort(service_dict['secrets']) - assert serialized_config['secrets'] == secrets_dict + assert 'secrets' in serialized_config diff --git a/tests/unit/config/interpolation_test.py b/tests/unit/config/interpolation_test.py index fd40153d21f..256c74d9bed 100644 --- a/tests/unit/config/interpolation_test.py +++ b/tests/unit/config/interpolation_test.py @@ -75,7 +75,32 @@ def test_interpolate_environment_variables_in_volumes(mock_env): }, 'other': {}, } - value = interpolate_environment_variables("2.0", volumes, 'volume', mock_env) + value = interpolate_environment_variables("2.0", volumes, 'volume', mock_env) + assert value == expected + + +def test_interpolate_environment_variables_in_secrets(mock_env): + secrets = { + 'secretservice': { + 'file': '$FOO', + 'labels': { + 'max': 2, + 'user': '${USER}' + } + }, + 'other': None, + } + expected = { + 'secretservice': { + 'file': 'bar', + 'labels': { + 'max': 2, + 'user': 'jenny' + } + }, + 'other': {}, + } + value = interpolate_environment_variables("3.1", secrets, 'volume', mock_env) assert value == expected From 56357d6117f3fed4bff373b000b882221aa4a5eb Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 14 Mar 2017 14:21:19 -0700 Subject: [PATCH 2693/4072] Do not raise a broken pipe error when receiving SIGPIPE from grep or head Signed-off-by: Joffrey F --- compose/cli/main.py | 1 + compose/cli/signals.py | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/compose/cli/main.py b/compose/cli/main.py index 84786abc707..63a0036b4e1 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -61,6 +61,7 @@ def main(): + signals.ignore_sigpipe() try: command = dispatch() command() diff --git a/compose/cli/signals.py b/compose/cli/signals.py index 68a0598e128..9b360c44e91 100644 --- a/compose/cli/signals.py +++ b/compose/cli/signals.py @@ -3,6 +3,8 @@ import signal +from ..const import IS_WINDOWS_PLATFORM + class ShutdownException(Exception): pass @@ -19,3 +21,10 @@ def set_signal_handler(handler): def set_signal_handler_to_shutdown(): set_signal_handler(shutdown) + + +def ignore_sigpipe(): + # Restore default behavior for SIGPIPE instead of raising + # an exception when encountered. + if not IS_WINDOWS_PLATFORM: + signal.signal(signal.SIGPIPE, signal.SIG_DFL) From 0ba1f61e9b971f71f3964104d6064066c433fb6a Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 13 Mar 2017 15:09:59 -0400 Subject: [PATCH 2694/4072] Add schema for v3.2, and revert some changes made to v3.1 after it was released. Signed-off-by: Daniel Nephin --- compose/config/config_schema_v3.1.json | 20 +- compose/config/config_schema_v3.2.json | 473 +++++++++++++++++++++++++ 2 files changed, 476 insertions(+), 17 deletions(-) create mode 100644 compose/config/config_schema_v3.2.json diff --git a/compose/config/config_schema_v3.1.json b/compose/config/config_schema_v3.1.json index 219ccdd488a..b7037485f97 100644 --- a/compose/config/config_schema_v3.1.json +++ b/compose/config/config_schema_v3.1.json @@ -71,8 +71,7 @@ "properties": { "context": {"type": "string"}, "dockerfile": {"type": "string"}, - "args": {"$ref": "#/definitions/list_or_dict"}, - "cache_from": {"type": "#/definitions/list_of_strings"} + "args": {"$ref": "#/definitions/list_or_dict"} }, "additionalProperties": false } @@ -168,21 +167,8 @@ "ports": { "type": "array", "items": { - "oneOf": [ - {"type": "number", "format": "ports"}, - {"type": "string", "format": "ports"}, - { - "type": "object", - "properties": { - "mode": {"type": "string"}, - "target": {"type": "integer"}, - "published": {"type": "integer"}, - "protocol": {"type": "string"} - }, - "required": ["target"], - "additionalProperties": false - } - ] + "type": ["string", "number"], + "format": "ports" }, "uniqueItems": true }, diff --git a/compose/config/config_schema_v3.2.json b/compose/config/config_schema_v3.2.json new file mode 100644 index 00000000000..e47c879a4da --- /dev/null +++ b/compose/config/config_schema_v3.2.json @@ -0,0 +1,473 @@ +{ + "$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/secret" + } + }, + "additionalProperties": false + } + }, + + "additionalProperties": false, + + "definitions": { + + "service": { + "id": "#/definitions/service", + "type": "object", + + "properties": { + "deploy": {"$ref": "#/definitions/deployment"}, + "build": { + "oneOf": [ + {"type": "string"}, + { + "type": "object", + "properties": { + "context": {"type": "string"}, + "dockerfile": {"type": "string"}, + "args": {"$ref": "#/definitions/list_or_dict"}, + "cache_from": {"$ref": "#/definitions/list_of_strings"} + }, + "additionalProperties": false + } + ] + }, + "cap_add": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "cap_drop": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "cgroup_parent": {"type": "string"}, + "command": { + "oneOf": [ + {"type": "string"}, + {"type": "array", "items": {"type": "string"}} + ] + }, + "container_name": {"type": "string"}, + "depends_on": {"$ref": "#/definitions/list_of_strings"}, + "devices": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "dns": {"$ref": "#/definitions/string_or_list"}, + "dns_search": {"$ref": "#/definitions/string_or_list"}, + "domainname": {"type": "string"}, + "entrypoint": { + "oneOf": [ + {"type": "string"}, + {"type": "array", "items": {"type": "string"}} + ] + }, + "env_file": {"$ref": "#/definitions/string_or_list"}, + "environment": {"$ref": "#/definitions/list_or_dict"}, + + "expose": { + "type": "array", + "items": { + "type": ["string", "number"], + "format": "expose" + }, + "uniqueItems": true + }, + + "external_links": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "extra_hosts": {"$ref": "#/definitions/list_or_dict"}, + "healthcheck": {"$ref": "#/definitions/healthcheck"}, + "hostname": {"type": "string"}, + "image": {"type": "string"}, + "ipc": {"type": "string"}, + "labels": {"$ref": "#/definitions/list_or_dict"}, + "links": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + + "logging": { + "type": "object", + + "properties": { + "driver": {"type": "string"}, + "options": { + "type": "object", + "patternProperties": { + "^.+$": {"type": ["string", "number", "null"]} + } + } + }, + "additionalProperties": false + }, + + "mac_address": {"type": "string"}, + "network_mode": {"type": "string"}, + + "networks": { + "oneOf": [ + {"$ref": "#/definitions/list_of_strings"}, + { + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "oneOf": [ + { + "type": "object", + "properties": { + "aliases": {"$ref": "#/definitions/list_of_strings"}, + "ipv4_address": {"type": "string"}, + "ipv6_address": {"type": "string"} + }, + "additionalProperties": false + }, + {"type": "null"} + ] + } + }, + "additionalProperties": false + } + ] + }, + "pid": {"type": ["string", "null"]}, + + "ports": { + "type": "array", + "items": { + "oneOf": [ + {"type": "number", "format": "ports"}, + {"type": "string", "format": "ports"}, + { + "type": "object", + "properties": { + "mode": {"type": "string"}, + "target": {"type": "integer"}, + "published": {"type": "integer"}, + "protocol": {"type": "string"} + }, + "additionalProperties": false + } + ] + }, + "uniqueItems": true + }, + + "privileged": {"type": "boolean"}, + "read_only": {"type": "boolean"}, + "restart": {"type": "string"}, + "security_opt": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "shm_size": {"type": ["number", "string"]}, + "secrets": { + "type": "array", + "items": { + "oneOf": [ + {"type": "string"}, + { + "type": "object", + "properties": { + "source": {"type": "string"}, + "target": {"type": "string"}, + "uid": {"type": "string"}, + "gid": {"type": "string"}, + "mode": {"type": "number"} + } + } + ] + } + }, + "sysctls": {"$ref": "#/definitions/list_or_dict"}, + "stdin_open": {"type": "boolean"}, + "stop_grace_period": {"type": "string", "format": "duration"}, + "stop_signal": {"type": "string"}, + "tmpfs": {"$ref": "#/definitions/string_or_list"}, + "tty": {"type": "boolean"}, + "ulimits": { + "type": "object", + "patternProperties": { + "^[a-z]+$": { + "oneOf": [ + {"type": "integer"}, + { + "type":"object", + "properties": { + "hard": {"type": "integer"}, + "soft": {"type": "integer"} + }, + "required": ["soft", "hard"], + "additionalProperties": false + } + ] + } + } + }, + "user": {"type": "string"}, + "userns_mode": {"type": "string"}, + "volumes": { + "type": "array", + "items": { + "oneOf": [ + {"type": "string"}, + { + "type": "object", + "required": ["type"], + "properties": { + "type": {"type": "string"}, + "source": {"type": "string"}, + "target": {"type": "string"}, + "read_only": {"type": "boolean"}, + "bind": { + "type": "object", + "properties": { + "propagation": {"type": "string"} + } + }, + "volume": { + "type": "object", + "properties": { + "nocopy": {"type": "boolean"} + } + } + } + } + ], + "uniqueItems": true + } + }, + "working_dir": {"type": "string"} + }, + "additionalProperties": false + }, + + "healthcheck": { + "id": "#/definitions/healthcheck", + "type": "object", + "additionalProperties": false, + "properties": { + "disable": {"type": "boolean"}, + "interval": {"type": "string"}, + "retries": {"type": "number"}, + "test": { + "oneOf": [ + {"type": "string"}, + {"type": "array", "items": {"type": "string"}} + ] + }, + "timeout": {"type": "string"} + } + }, + "deployment": { + "id": "#/definitions/deployment", + "type": ["object", "null"], + "properties": { + "mode": {"type": "string"}, + "endpoint_mode": {"type": "string"}, + "replicas": {"type": "integer"}, + "labels": {"$ref": "#/definitions/list_or_dict"}, + "update_config": { + "type": "object", + "properties": { + "parallelism": {"type": "integer"}, + "delay": {"type": "string", "format": "duration"}, + "failure_action": {"type": "string"}, + "monitor": {"type": "string", "format": "duration"}, + "max_failure_ratio": {"type": "number"} + }, + "additionalProperties": false + }, + "resources": { + "type": "object", + "properties": { + "limits": {"$ref": "#/definitions/resource"}, + "reservations": {"$ref": "#/definitions/resource"} + } + }, + "restart_policy": { + "type": "object", + "properties": { + "condition": {"type": "string"}, + "delay": {"type": "string", "format": "duration"}, + "max_attempts": {"type": "integer"}, + "window": {"type": "string", "format": "duration"} + }, + "additionalProperties": false + }, + "placement": { + "type": "object", + "properties": { + "constraints": {"type": "array", "items": {"type": "string"}} + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + + "resource": { + "id": "#/definitions/resource", + "type": "object", + "properties": { + "cpus": {"type": "string"}, + "memory": {"type": "string"} + }, + "additionalProperties": false + }, + + "network": { + "id": "#/definitions/network", + "type": ["object", "null"], + "properties": { + "driver": {"type": "string"}, + "driver_opts": { + "type": "object", + "patternProperties": { + "^.+$": {"type": ["string", "number"]} + } + }, + "ipam": { + "type": "object", + "properties": { + "driver": {"type": "string"}, + "config": { + "type": "array", + "items": { + "type": "object", + "properties": { + "subnet": {"type": "string"} + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + }, + "external": { + "type": ["boolean", "object"], + "properties": { + "name": {"type": "string"} + }, + "additionalProperties": false + }, + "internal": {"type": "boolean"}, + "attachable": {"type": "boolean"}, + "labels": {"$ref": "#/definitions/list_or_dict"} + }, + "additionalProperties": false + }, + + "volume": { + "id": "#/definitions/volume", + "type": ["object", "null"], + "properties": { + "driver": {"type": "string"}, + "driver_opts": { + "type": "object", + "patternProperties": { + "^.+$": {"type": ["string", "number"]} + } + }, + "external": { + "type": ["boolean", "object"], + "properties": { + "name": {"type": "string"} + }, + "additionalProperties": false + }, + "labels": {"$ref": "#/definitions/list_or_dict"} + }, + "additionalProperties": false + }, + + "secret": { + "id": "#/definitions/secret", + "type": "object", + "properties": { + "file": {"type": "string"}, + "external": { + "type": ["boolean", "object"], + "properties": { + "name": {"type": "string"} + } + }, + "labels": {"$ref": "#/definitions/list_or_dict"} + }, + "additionalProperties": false + }, + + "string_or_list": { + "oneOf": [ + {"type": "string"}, + {"$ref": "#/definitions/list_of_strings"} + ] + }, + + "list_of_strings": { + "type": "array", + "items": {"type": "string"}, + "uniqueItems": true + }, + + "list_or_dict": { + "oneOf": [ + { + "type": "object", + "patternProperties": { + ".+": { + "type": ["string", "number", "null"] + } + }, + "additionalProperties": false + }, + {"type": "array", "items": {"type": "string"}, "uniqueItems": true} + ] + }, + + "constraints": { + "service": { + "id": "#/definitions/constraints/service", + "anyOf": [ + {"required": ["build"]}, + {"required": ["image"]} + ], + "properties": { + "build": { + "required": ["context"] + } + } + } + } + } +} From 2acf286ed611651fda1a87360d04de2ddca6e649 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Mon, 13 Mar 2017 15:57:13 -0400 Subject: [PATCH 2695/4072] Support V3.2 Signed-off-by: Daniel Nephin --- compose/config/config.py | 37 +++++++------------ compose/config/serialize.py | 8 ++-- compose/config/validation.py | 18 ++++++--- compose/const.py | 4 ++ .../ports-composefile/expanded-notation.yml | 2 +- tests/integration/project_test.py | 6 +-- tests/integration/testcases.py | 8 ++-- tests/unit/config/config_test.py | 10 ++--- tests/unit/config/types_test.py | 4 +- 9 files changed, 49 insertions(+), 48 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 3d7cbe8d67e..5a0a3c84773 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -13,11 +13,8 @@ from cached_property import cached_property from . import types +from .. import const 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 @@ -185,10 +182,10 @@ def version(self): .format(self.filename, VERSION_EXPLANATION)) if version == '2': - version = V2_0 + version = const.COMPOSEFILE_V2_0 if version == '3': - version = V3_0 + version = const.COMPOSEFILE_V3_0 return version @@ -205,7 +202,7 @@ 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', {}) + return {} if self.version < const.COMPOSEFILE_V3_1 else self.config.get('secrets', {}) class Config(namedtuple('_Config', 'version services volumes networks secrets')): @@ -427,7 +424,7 @@ def build_service(service_name, service_dict, service_names): service_dict = process_service(resolver.run()) service_config = service_config._replace(config=service_dict) - validate_service(service_config, service_names, config_file.version) + validate_service(service_config, service_names, config_file) service_dict = finalize_service( service_config, service_names, @@ -480,7 +477,7 @@ def process_config_file(config_file, environment, service_name=None): 'service', environment) - if config_file.version in (V2_0, V2_1, V3_0, V3_1): + if config_file.version != V1: processed_config = dict(config_file.config) processed_config['services'] = services processed_config['volumes'] = interpolate_config_section( @@ -493,19 +490,13 @@ def process_config_file(config_file, environment, service_name=None): config_file.get_networks(), 'network', environment) - if config_file.version in (V3_1,): - processed_config['secrets'] = interpolate_config_section( - config_file, - config_file.get_secrets(), - 'secrets', - environment - ) - elif config_file.version == V1: - processed_config = services + processed_config['secrets'] = interpolate_config_section( + config_file, + config_file.get_secrets(), + 'secrets', + environment) else: - raise ConfigurationError( - 'Version in "{}" is unsupported. {}' - .format(config_file.filename, VERSION_EXPLANATION)) + processed_config = services config_file = config_file._replace(config=processed_config) validate_against_config_schema(config_file) @@ -642,9 +633,9 @@ def validate_extended_service_dict(service_dict, filename, service): "%s services with 'depends_on' cannot be extended" % error_prefix) -def validate_service(service_config, service_names, version): +def validate_service(service_config, service_names, config_file): service_dict, service_name = service_config.config, service_config.name - validate_service_constraints(service_dict, service_name, version) + validate_service_constraints(service_dict, service_name, config_file) validate_paths(service_dict) validate_ulimits(service_config) diff --git a/compose/config/serialize.py b/compose/config/serialize.py index 6e2ad590654..46e1d9f44ad 100644 --- a/compose/config/serialize.py +++ b/compose/config/serialize.py @@ -4,10 +4,10 @@ import six import yaml +from compose import const from compose.config import types -from compose.config.config import V1 -from compose.config.config import V2_1 -from compose.config.config import V3_1 +from compose.const import COMPOSEFILE_V1 as V1 +from compose.const import COMPOSEFILE_V2_1 as V2_1 def serialize_config_type(dumper, data): @@ -103,7 +103,7 @@ def denormalize_service_dict(service_dict, version): service_dict['healthcheck']['timeout'] ) - if 'ports' in service_dict and version != V3_1: + if 'ports' in service_dict and version < const.COMPOSEFILE_V3_2: service_dict['ports'] = map( lambda p: p.legacy_repr() if isinstance(p, types.ServicePort) else p, service_dict['ports'] diff --git a/compose/config/validation.py b/compose/config/validation.py index d4d29565f38..1df6dd6b7c7 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -365,7 +365,7 @@ def process_config_schema_errors(error): def validate_against_config_schema(config_file): - schema = load_jsonschema(config_file.version) + schema = load_jsonschema(config_file) format_checker = FormatChecker(["ports", "expose"]) validator = Draft4Validator( schema, @@ -377,11 +377,12 @@ def validate_against_config_schema(config_file): config_file.filename) -def validate_service_constraints(config, service_name, version): +def validate_service_constraints(config, service_name, config_file): def handler(errors): - return process_service_constraint_errors(errors, service_name, version) + return process_service_constraint_errors( + errors, service_name, config_file.version) - schema = load_jsonschema(version) + schema = load_jsonschema(config_file) validator = Draft4Validator(schema['definitions']['constraints']['service']) handle_errors(validator.iter_errors(config), handler, None) @@ -390,10 +391,15 @@ def get_schema_path(): return os.path.dirname(os.path.abspath(__file__)) -def load_jsonschema(version): +def load_jsonschema(config_file): filename = os.path.join( get_schema_path(), - "config_schema_v{0}.json".format(version)) + "config_schema_v{0}.json".format(config_file.version)) + + if not os.path.exists(filename): + raise ConfigurationError( + 'Version in "{}" is unsupported. {}' + .format(config_file.filename, VERSION_EXPLANATION)) with open(filename, "r") as fh: return json.load(fh) diff --git a/compose/const.py b/compose/const.py index e694dbdae53..8de69344509 100644 --- a/compose/const.py +++ b/compose/const.py @@ -21,8 +21,10 @@ COMPOSEFILE_V1 = '1' COMPOSEFILE_V2_0 = '2.0' COMPOSEFILE_V2_1 = '2.1' + COMPOSEFILE_V3_0 = '3.0' COMPOSEFILE_V3_1 = '3.1' +COMPOSEFILE_V3_2 = '3.2' API_VERSIONS = { COMPOSEFILE_V1: '1.21', @@ -30,6 +32,7 @@ COMPOSEFILE_V2_1: '1.24', COMPOSEFILE_V3_0: '1.25', COMPOSEFILE_V3_1: '1.25', + COMPOSEFILE_V3_2: '1.25', } API_VERSION_TO_ENGINE_VERSION = { @@ -38,4 +41,5 @@ API_VERSIONS[COMPOSEFILE_V2_1]: '1.12.0', API_VERSIONS[COMPOSEFILE_V3_0]: '1.13.0', API_VERSIONS[COMPOSEFILE_V3_1]: '1.13.0', + API_VERSIONS[COMPOSEFILE_V3_2]: '1.13.0', } diff --git a/tests/fixtures/ports-composefile/expanded-notation.yml b/tests/fixtures/ports-composefile/expanded-notation.yml index 46d587363f4..6fbe59176cb 100644 --- a/tests/fixtures/ports-composefile/expanded-notation.yml +++ b/tests/fixtures/ports-composefile/expanded-notation.yml @@ -1,4 +1,4 @@ -version: '3.1' +version: '3.2' services: simple: image: busybox:latest diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index f0d21456b5c..e8dbe8fbf7e 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -15,11 +15,11 @@ 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 COMPOSEFILE_V2_0 as V2_0 +from compose.const import COMPOSEFILE_V2_1 as V2_1 +from compose.const import COMPOSEFILE_V3_1 as V3_1 from compose.const import LABEL_PROJECT from compose.const import LABEL_SERVICE from compose.container import Container diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index efc1551b4e5..38fdcc6605b 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -10,12 +10,12 @@ 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.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 COMPOSEFILE_V1 as V1 +from compose.const import COMPOSEFILE_V2_0 as V2_0 +from compose.const import COMPOSEFILE_V2_0 as V2_1 +from compose.const import COMPOSEFILE_V3_0 as V3_0 from compose.const import LABEL_PROJECT from compose.progress_stream import stream_output from compose.service import Service diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 93bae972610..c86485d7bd7 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -17,11 +17,6 @@ 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 -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 @@ -29,6 +24,11 @@ from compose.config.serialize import serialize_config from compose.config.serialize import serialize_ns_time_value from compose.config.types import VolumeSpec +from compose.const import COMPOSEFILE_V1 as V1 +from compose.const import COMPOSEFILE_V2_0 as V2_0 +from compose.const import COMPOSEFILE_V2_1 as V2_1 +from compose.const import COMPOSEFILE_V3_0 as V3_0 +from compose.const import COMPOSEFILE_V3_1 as V3_1 from compose.const import IS_WINDOWS_PLATFORM from compose.utils import nanoseconds_from_time_seconds from tests import mock diff --git a/tests/unit/config/types_test.py b/tests/unit/config/types_test.py index 22d7aa88a62..66588d62942 100644 --- a/tests/unit/config/types_test.py +++ b/tests/unit/config/types_test.py @@ -3,13 +3,13 @@ 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 ServicePort from compose.config.types import VolumeFromSpec from compose.config.types import VolumeSpec +from compose.const import COMPOSEFILE_V1 as V1 +from compose.const import COMPOSEFILE_V2_0 as V2_0 def test_parse_extra_hosts_list(): From 7b1900951199628f879acce219a26619827a0b35 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 15 Mar 2017 17:11:53 -0700 Subject: [PATCH 2696/4072] Fix a handful of issues with 3.2 schema Signed-off-by: Joffrey F --- compose/config/config.py | 11 ++++++----- compose/config/config_schema_v3.2.json | 5 ++--- compose/config/serialize.py | 7 ++++--- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 5a0a3c84773..22591941562 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -490,11 +490,12 @@ def process_config_file(config_file, environment, service_name=None): config_file.get_networks(), 'network', environment) - processed_config['secrets'] = interpolate_config_section( - config_file, - config_file.get_secrets(), - 'secrets', - environment) + if config_file.version in (const.COMPOSEFILE_V3_1, const.COMPOSEFILE_V3_2): + processed_config['secrets'] = interpolate_config_section( + config_file, + config_file.get_secrets(), + 'secrets', + environment) else: processed_config = services diff --git a/compose/config/config_schema_v3.2.json b/compose/config/config_schema_v3.2.json index e47c879a4da..ea702fcd581 100644 --- a/compose/config/config_schema_v3.2.json +++ b/compose/config/config_schema_v3.2.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-04/schema#", - "id": "config_schema_v3.1.json", + "id": "config_schema_v3.2.json", "type": "object", "required": ["version"], @@ -169,8 +169,7 @@ "type": "array", "items": { "oneOf": [ - {"type": "number", "format": "ports"}, - {"type": "string", "format": "ports"}, + {"type": ["string", "number"], "format": "ports"}, { "type": "object", "properties": { diff --git a/compose/config/serialize.py b/compose/config/serialize.py index 46e1d9f44ad..1de1f14fbe6 100644 --- a/compose/config/serialize.py +++ b/compose/config/serialize.py @@ -4,10 +4,11 @@ import six import yaml -from compose import const from compose.config import types from compose.const import COMPOSEFILE_V1 as V1 from compose.const import COMPOSEFILE_V2_1 as V2_1 +from compose.const import COMPOSEFILE_V3_1 as V3_1 +from compose.const import COMPOSEFILE_V3_1 as V3_2 def serialize_config_type(dumper, data): @@ -45,7 +46,7 @@ def denormalize_config(config): if 'external_name' in vol_conf: del vol_conf['external_name'] - if config.version in (V3_1,): + if config.version in (V3_1, V3_2): result['secrets'] = config.secrets return result @@ -103,7 +104,7 @@ def denormalize_service_dict(service_dict, version): service_dict['healthcheck']['timeout'] ) - if 'ports' in service_dict and version < const.COMPOSEFILE_V3_2: + if 'ports' in service_dict and version not in (V3_2,): service_dict['ports'] = map( lambda p: p.legacy_repr() if isinstance(p, types.ServicePort) else p, service_dict['ports'] From 583c673c8a1cdf5473c93d28f95433fffec55daf Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Thu, 16 Mar 2017 11:29:10 +0100 Subject: [PATCH 2697/4072] Add bash completion for `build --build-arg` Signed-off-by: Harald Albers --- contrib/completion/bash/docker-compose | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index 2591f500bc9..d39a6da25bb 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -110,9 +110,17 @@ __docker_compose_services_stopped() { _docker_compose_build() { + case "$prev" in + --build-arg) + COMPREPLY=( $( compgen -e -- "$cur" ) ) + __docker_compose_nospace + return + ;; + esac + case "$cur" in -*) - COMPREPLY=( $( compgen -W "--force-rm --help --no-cache --pull" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--build-arg --force-rm --help --no-cache --pull" -- "$cur" ) ) ;; *) __docker_compose_services_from_build From de2dd5b3d380cbbe50a2c040e55b94cd4fe2c259 Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Thu, 16 Mar 2017 15:47:01 +0100 Subject: [PATCH 2698/4072] Add bash completion for `--project-directory` Signed-off-by: Harald Albers --- contrib/completion/bash/docker-compose | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index 2591f500bc9..e6e5bd6cd6a 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -160,6 +160,10 @@ _docker_compose_docker_compose() { _filedir "y?(a)ml" return ;; + --project-directory) + _filedir -d + return + ;; $(__docker_compose_to_extglob "$daemon_options_with_args") ) return ;; @@ -554,6 +558,7 @@ _docker_compose() { local daemon_options_with_args=" --file -f --host -H + --project-directory --project-name -p --tlscacert --tlscert From 6a773a018e1ca917d8c05abe9b6d5fceda845e6e Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Thu, 16 Mar 2017 16:01:43 +0100 Subject: [PATCH 2699/4072] Rename variables in bash completion The old names were supposed to designate variables that get propagated to the Docker daemon. This is not true for all contained options and therefore confused me. The new names focus on the placement after the top level command. Signed-off-by: Harald Albers --- contrib/completion/bash/docker-compose | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index e6e5bd6cd6a..89485e24a43 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -18,7 +18,7 @@ __docker_compose_q() { - docker-compose 2>/dev/null "${daemon_options[@]}" "$@" + docker-compose 2>/dev/null "${top_level_options[@]}" "$@" } # Transforms a multiline list of strings into a single line string @@ -164,14 +164,14 @@ _docker_compose_docker_compose() { _filedir -d return ;; - $(__docker_compose_to_extglob "$daemon_options_with_args") ) + $(__docker_compose_to_extglob "$top_level_options_with_args") ) return ;; esac case "$cur" in -*) - COMPREPLY=( $( compgen -W "$daemon_boolean_options $daemon_options_with_args --help -h --verbose --version -v" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "$top_level_boolean_options $top_level_options_with_args --help -h --verbose --version -v" -- "$cur" ) ) ;; *) COMPREPLY=( $( compgen -W "${commands[*]}" -- "$cur" ) ) @@ -550,12 +550,12 @@ _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=" + local top_level_boolean_options=" --skip-hostname-check --tls --tlsverify " - local daemon_options_with_args=" + local top_level_options_with_args=" --file -f --host -H --project-directory @@ -572,19 +572,19 @@ _docker_compose() { # search subcommand and invoke its handler. # special treatment of some top-level options local command='docker_compose' - local daemon_options=() + local top_level_options=() local counter=1 while [ $counter -lt $cword ]; do case "${words[$counter]}" in - $(__docker_compose_to_extglob "$daemon_boolean_options") ) + $(__docker_compose_to_extglob "$top_level_boolean_options") ) local opt=${words[counter]} - daemon_options+=($opt) + top_level_options+=($opt) ;; - $(__docker_compose_to_extglob "$daemon_options_with_args") ) + $(__docker_compose_to_extglob "$top_level_options_with_args") ) local opt=${words[counter]} local arg=${words[++counter]} - daemon_options+=($opt $arg) + top_level_options+=($opt $arg) ;; -*) ;; From 8471276e73e7fe431e6e47d119d1899775ab1b4c Mon Sep 17 00:00:00 2001 From: fate-grand-order Date: Fri, 17 Mar 2017 11:35:10 +0800 Subject: [PATCH 2700/4072] fix misspell "compatibility" in script/ci Signed-off-by: fate-grand-order --- script/ci | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/ci b/script/ci index 7b3489a1b20..34bf9a4be65 100755 --- a/script/ci +++ b/script/ci @@ -1,6 +1,6 @@ #!/bin/bash # -# Backwards compatiblity for jenkins +# Backwards compatibility for jenkins # # TODO: remove this script after all current PRs and jenkins are updated with # the new script/test/ci change From 1a7e01c39abcf90ad70dbf0d20e9206e1bc151b1 Mon Sep 17 00:00:00 2001 From: King Chung Huang Date: Sat, 14 Jan 2017 10:54:29 -0700 Subject: [PATCH 2701/4072] Add image digest arguments to config serialization Add arguments for image digests in the config.serialize module to optionally pin images to digests, like bundles. Signed-off-by: King Chung Huang --- compose/config/serialize.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/compose/config/serialize.py b/compose/config/serialize.py index 1de1f14fbe6..5b36124d0fa 100644 --- a/compose/config/serialize.py +++ b/compose/config/serialize.py @@ -26,10 +26,13 @@ def serialize_dict_type(dumper, data): yaml.SafeDumper.add_representer(types.ServicePort, serialize_dict_type) -def denormalize_config(config): +def denormalize_config(config, image_digests=None): result = {'version': V2_1 if config.version == V1 else config.version} denormalized_services = [ - denormalize_service_dict(service_dict, config.version) + denormalize_service_dict( + service_dict, + config.version, + image_digests[service_dict['name']] if image_digests else None) for service_dict in config.services ] result['services'] = { @@ -51,9 +54,9 @@ def denormalize_config(config): return result -def serialize_config(config): +def serialize_config(config, image_digests=None): return yaml.safe_dump( - denormalize_config(config), + denormalize_config(config, image_digests), default_flow_style=False, indent=2, width=80) @@ -78,9 +81,12 @@ def serialize_ns_time_value(value): return '{0}{1}'.format(*result) -def denormalize_service_dict(service_dict, version): +def denormalize_service_dict(service_dict, version, image_digest=None): service_dict = service_dict.copy() + if image_digest: + service_dict['image'] = image_digest + if 'restart' in service_dict: service_dict['restart'] = types.serialize_restart_spec( service_dict['restart'] From 1da3ac4715589cc59a0811becab04e363b1de9d3 Mon Sep 17 00:00:00 2001 From: King Chung Huang Date: Sat, 14 Jan 2017 10:56:45 -0700 Subject: [PATCH 2702/4072] Add --resolve-image-digests argument to config command Add a --resolve-image-digests argument to the config command that pins images to a specific image digest, just like the bundle command. This can be used to pin images in compose files being used to deploy stacks in Docker 1.13. Signed-off-by: King Chung Huang --- compose/cli/main.py | 53 ++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 48 insertions(+), 5 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 63a0036b4e1..2d763a9225b 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -313,13 +313,56 @@ def config(self, config_options, options): Usage: config [options] Options: - -q, --quiet Only validate the configuration, don't print - anything. - --services Print the service names, one per line. - --volumes Print the volume names, one per line. + --resolve-image-digests Pin image tags to digests. + -q, --quiet Only validate the configuration, don't print + anything. + --services Print the service names, one per line. + --volumes Print the volume names, one per line. """ + compose_config = get_config_from_options(self.project_dir, config_options) + image_digests = None + + if options['--resolve-image-digests']: + self.project = project_from_options('.', config_options) + + with errors.handle_connection_errors(self.project.client): + try: + image_digests = get_image_digests( + self.project, + allow_push=False + ) + except MissingDigests as e: + def list_images(images): + return "\n".join(" {}".format(name) for name in sorted(images)) + + paras = ["Some images are missing digests."] + + if e.needs_push: + command_hint = ( + "Use `docker-compose push {}` to push them. " + .format(" ".join(sorted(e.needs_push))) + ) + paras += [ + "The following images can be pushed:", + list_images(e.needs_push), + command_hint, + ] + + if e.needs_pull: + command_hint = ( + "Use `docker-compose pull {}` to pull them. " + .format(" ".join(sorted(e.needs_pull))) + ) + + paras += [ + "The following images need to be pulled:", + list_images(e.needs_pull), + command_hint, + ] + + raise UserError("\n\n".join(paras)) if options['--quiet']: return @@ -332,7 +375,7 @@ def config(self, config_options, options): print('\n'.join(volume for volume in compose_config.volumes)) return - print(serialize_config(compose_config)) + print(serialize_config(compose_config, image_digests)) def create(self, options): """ From 0464476f0857e527b19b68f5046ab742ebe3b138 Mon Sep 17 00:00:00 2001 From: King Chung Huang Date: Sat, 14 Jan 2017 11:24:05 -0700 Subject: [PATCH 2703/4072] Add unit test for image digests in config Add two unit tests to validate that the denormalize_service_dict function still works without passing a third argument for image_digest, and correctly uses an image digest if one is provided. Signed-off-by: King Chung Huang --- tests/unit/config/config_test.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index c86485d7bd7..49da2b47311 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -3654,6 +3654,25 @@ def test_denormalize_healthcheck(self): assert denormalized_service['healthcheck']['interval'] == '100s' assert denormalized_service['healthcheck']['timeout'] == '30s' + def test_denormalize_image_has_digest(self): + service_dict = { + 'image': 'busybox' + } + image_digest = 'busybox@sha256:abcde' + + assert denormalize_service_dict(service_dict, V3_0, image_digest) == { + 'image': 'busybox@sha256:abcde' + } + + def test_denormalize_image_no_digest(self): + service_dict = { + 'image': 'busybox' + } + + assert denormalize_service_dict(service_dict, V3_0) == { + 'image': 'busybox' + } + def test_serialize_secrets(self): service_dict = { 'image': 'example/web', From 962ba5b9379c46e54d3f8a31d3e61930b90ccbfa Mon Sep 17 00:00:00 2001 From: King Chung Huang Date: Sun, 12 Feb 2017 11:18:11 -0700 Subject: [PATCH 2704/4072] Extract image tag to digest code into a function Extract the code in bundle() and config() that translates image tags into digests into a function named image_digests_for_project(). Signed-off-by: King Chung Huang --- compose/cli/main.py | 115 ++++++++++++++++---------------------------- 1 file changed, 41 insertions(+), 74 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 2d763a9225b..84cae9f539f 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -263,43 +263,7 @@ def bundle(self, config_options, options): if not output: output = "{}.dab".format(self.project.name) - with errors.handle_connection_errors(self.project.client): - try: - image_digests = get_image_digests( - self.project, - allow_push=options['--push-images'], - ) - except MissingDigests as e: - def list_images(images): - return "\n".join(" {}".format(name) for name in sorted(images)) - - paras = ["Some images are missing digests."] - - if e.needs_push: - command_hint = ( - "Use `docker-compose push {}` to push them. " - "You can do this automatically with `docker-compose bundle --push-images`." - .format(" ".join(sorted(e.needs_push))) - ) - paras += [ - "The following images can be pushed:", - list_images(e.needs_push), - command_hint, - ] - - if e.needs_pull: - command_hint = ( - "Use `docker-compose pull {}` to pull them. " - .format(" ".join(sorted(e.needs_pull))) - ) - - paras += [ - "The following images need to be pulled:", - list_images(e.needs_pull), - command_hint, - ] - - raise UserError("\n\n".join(paras)) + image_digests = image_digests_for_project(self.project, options['--push-images']) with open(output, 'w') as f: f.write(serialize_bundle(compose_config, image_digests)) @@ -326,43 +290,7 @@ def config(self, config_options, options): if options['--resolve-image-digests']: self.project = project_from_options('.', config_options) - - with errors.handle_connection_errors(self.project.client): - try: - image_digests = get_image_digests( - self.project, - allow_push=False - ) - except MissingDigests as e: - def list_images(images): - return "\n".join(" {}".format(name) for name in sorted(images)) - - paras = ["Some images are missing digests."] - - if e.needs_push: - command_hint = ( - "Use `docker-compose push {}` to push them. " - .format(" ".join(sorted(e.needs_push))) - ) - paras += [ - "The following images can be pushed:", - list_images(e.needs_push), - command_hint, - ] - - if e.needs_pull: - command_hint = ( - "Use `docker-compose pull {}` to pull them. " - .format(" ".join(sorted(e.needs_pull))) - ) - - paras += [ - "The following images need to be pulled:", - list_images(e.needs_pull), - command_hint, - ] - - raise UserError("\n\n".join(paras)) + image_digests = image_digests_for_project(self.project) if options['--quiet']: return @@ -1077,6 +1005,45 @@ def timeout_from_opts(options): return None if timeout is None else int(timeout) +def image_digests_for_project(project, allow_push=False): + with errors.handle_connection_errors(project.client): + try: + return get_image_digests( + project, + allow_push=allow_push + ) + except MissingDigests as e: + def list_images(images): + return "\n".join(" {}".format(name) for name in sorted(images)) + + paras = ["Some images are missing digests."] + + if e.needs_push: + command_hint = ( + "Use `docker-compose push {}` to push them. " + .format(" ".join(sorted(e.needs_push))) + ) + paras += [ + "The following images can be pushed:", + list_images(e.needs_push), + command_hint, + ] + + if e.needs_pull: + command_hint = ( + "Use `docker-compose pull {}` to pull them. " + .format(" ".join(sorted(e.needs_pull))) + ) + + paras += [ + "The following images need to be pulled:", + list_images(e.needs_pull), + command_hint, + ] + + raise UserError("\n\n".join(paras)) + + def exitval_from_opts(options, project): exit_value_from = options.get('--exit-code-from') if exit_value_from: From 69d0c0e3a00df6bf0d4e4701d125436aa34393ad Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 17 Mar 2017 18:22:44 -0700 Subject: [PATCH 2705/4072] Add support for expanded mount/volume notation Signed-off-by: Joffrey F --- compose/config/config.py | 14 ++++- tests/acceptance/cli_test.py | 7 ++- tests/fixtures/v3-full/docker-compose.yml | 13 ++++- tests/unit/config/config_test.py | 62 +++++++++++++++++++++++ 4 files changed, 92 insertions(+), 4 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 22591941562..8cbaae272ca 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -1030,7 +1030,13 @@ def resolve_volume_paths(working_dir, service_dict): def resolve_volume_path(working_dir, volume): - container_path, host_path = split_path_mapping(volume) + if isinstance(volume, dict): + host_path = volume.get('source') + container_path = volume.get('target') + if host_path and volume.get('read_only'): + container_path += ':ro' + else: + container_path, host_path = split_path_mapping(volume) if host_path is not None: if host_path.startswith('.'): @@ -1112,6 +1118,8 @@ def split_path_mapping(volume_path): path. Using splitdrive so windows absolute paths won't cause issues with splitting on ':'. """ + if isinstance(volume_path, dict): + return (volume_path.get('target'), volume_path) drive, volume_config = splitdrive(volume_path) if ':' in volume_config: @@ -1123,7 +1131,9 @@ def split_path_mapping(volume_path): def join_path_mapping(pair): (container, host) = pair - if host is None: + if isinstance(host, dict): + return host + elif host is None: return container else: return ":".join((host, container)) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 6a498e250c0..14e6f73362a 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -321,7 +321,7 @@ def test_config_v3(self): result = self.dispatch(['config']) assert yaml.load(result.stdout) == { - 'version': '3.0', + 'version': '3.2', 'networks': {}, 'volumes': { 'foobar': { @@ -371,6 +371,11 @@ def test_config_v3(self): 'timeout': '1s', 'retries': 5, }, + 'volumes': [ + '/host/path:/container/path:ro', + 'foobar:/container/volumepath:rw', + '/anonymous' + ], 'stop_grace_period': '20s', }, diff --git a/tests/fixtures/v3-full/docker-compose.yml b/tests/fixtures/v3-full/docker-compose.yml index a1661ab9363..27f3c6e04fa 100644 --- a/tests/fixtures/v3-full/docker-compose.yml +++ b/tests/fixtures/v3-full/docker-compose.yml @@ -1,4 +1,4 @@ -version: "3" +version: "3.2" services: web: image: busybox @@ -34,6 +34,17 @@ services: timeout: 1s retries: 5 + volumes: + - source: /host/path + target: /container/path + type: bind + read_only: true + - source: foobar + type: volume + target: /container/volumepath + - type: volume + target: /anonymous + stop_grace_period: 20s volumes: foobar: diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index c86485d7bd7..c9eb3796e00 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -29,6 +29,7 @@ from compose.const import COMPOSEFILE_V2_1 as V2_1 from compose.const import COMPOSEFILE_V3_0 as V3_0 from compose.const import COMPOSEFILE_V3_1 as V3_1 +from compose.const import COMPOSEFILE_V3_2 as V3_2 from compose.const import IS_WINDOWS_PLATFORM from compose.utils import nanoseconds_from_time_seconds from tests import mock @@ -964,6 +965,44 @@ def test_load_with_multiple_files_v2(self): ] assert service_sort(service_dicts) == service_sort(expected) + @mock.patch.dict(os.environ) + def test_load_with_multiple_files_v3_2(self): + os.environ['COMPOSE_CONVERT_WINDOWS_PATHS'] = 'true' + base_file = config.ConfigFile( + 'base.yaml', + { + 'version': '3.2', + 'services': { + 'web': { + 'image': 'example/web', + 'volumes': [ + {'source': '/a', 'target': '/b', 'type': 'bind'}, + {'source': 'vol', 'target': '/x', 'type': 'volume', 'read_only': True} + ] + } + }, + 'volumes': {'vol': {}} + } + ) + + override_file = config.ConfigFile( + 'override.yaml', + { + 'version': '3.2', + 'services': { + 'web': { + 'volumes': ['/c:/b', '/anonymous'] + } + } + } + ) + details = config.ConfigDetails('.', [base_file, override_file]) + service_dicts = config.load(details).services + svc_volumes = map(lambda v: v.repr(), service_dicts[0]['volumes']) + assert sorted(svc_volumes) == sorted( + ['/anonymous', '/c:/b:rw', 'vol:/x:ro'] + ) + def test_undeclared_volume_v2(self): base_file = config.ConfigFile( 'base.yaml', @@ -1544,6 +1583,29 @@ def test_merge_service_dicts_heterogeneous_2(self): 'ports': types.ServicePort.parse('5432') } + def test_merge_service_dicts_heterogeneous_volumes(self): + base = { + 'volumes': ['/a:/b', '/x:/z'], + } + + override = { + 'image': 'alpine:edge', + 'volumes': [ + {'source': '/e', 'target': '/b', 'type': 'bind'}, + {'source': '/c', 'target': '/d', 'type': 'bind'} + ] + } + + actual = config.merge_service_dicts_from_files( + base, override, V3_2 + ) + + assert actual['volumes'] == [ + {'source': '/e', 'target': '/b', 'type': 'bind'}, + {'source': '/c', 'target': '/d', 'type': 'bind'}, + '/x:/z' + ] + def test_merge_logging_v1(self): base = { 'image': 'alpine:edge', From 442a5f6eeb8ef2dc21fb0f580206f47beaa5ee76 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 17 Mar 2017 18:22:44 -0700 Subject: [PATCH 2706/4072] Add support for expanded mount/volume notation Signed-off-by: Joffrey F --- compose/config/config.py | 14 ++++- tests/acceptance/cli_test.py | 7 ++- tests/fixtures/v3-full/docker-compose.yml | 13 ++++- tests/unit/config/config_test.py | 62 +++++++++++++++++++++++ 4 files changed, 92 insertions(+), 4 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 22591941562..8cbaae272ca 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -1030,7 +1030,13 @@ def resolve_volume_paths(working_dir, service_dict): def resolve_volume_path(working_dir, volume): - container_path, host_path = split_path_mapping(volume) + if isinstance(volume, dict): + host_path = volume.get('source') + container_path = volume.get('target') + if host_path and volume.get('read_only'): + container_path += ':ro' + else: + container_path, host_path = split_path_mapping(volume) if host_path is not None: if host_path.startswith('.'): @@ -1112,6 +1118,8 @@ def split_path_mapping(volume_path): path. Using splitdrive so windows absolute paths won't cause issues with splitting on ':'. """ + if isinstance(volume_path, dict): + return (volume_path.get('target'), volume_path) drive, volume_config = splitdrive(volume_path) if ':' in volume_config: @@ -1123,7 +1131,9 @@ def split_path_mapping(volume_path): def join_path_mapping(pair): (container, host) = pair - if host is None: + if isinstance(host, dict): + return host + elif host is None: return container else: return ":".join((host, container)) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 6a498e250c0..14e6f73362a 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -321,7 +321,7 @@ def test_config_v3(self): result = self.dispatch(['config']) assert yaml.load(result.stdout) == { - 'version': '3.0', + 'version': '3.2', 'networks': {}, 'volumes': { 'foobar': { @@ -371,6 +371,11 @@ def test_config_v3(self): 'timeout': '1s', 'retries': 5, }, + 'volumes': [ + '/host/path:/container/path:ro', + 'foobar:/container/volumepath:rw', + '/anonymous' + ], 'stop_grace_period': '20s', }, diff --git a/tests/fixtures/v3-full/docker-compose.yml b/tests/fixtures/v3-full/docker-compose.yml index a1661ab9363..27f3c6e04fa 100644 --- a/tests/fixtures/v3-full/docker-compose.yml +++ b/tests/fixtures/v3-full/docker-compose.yml @@ -1,4 +1,4 @@ -version: "3" +version: "3.2" services: web: image: busybox @@ -34,6 +34,17 @@ services: timeout: 1s retries: 5 + volumes: + - source: /host/path + target: /container/path + type: bind + read_only: true + - source: foobar + type: volume + target: /container/volumepath + - type: volume + target: /anonymous + stop_grace_period: 20s volumes: foobar: diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 49da2b47311..195efe3b95b 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -29,6 +29,7 @@ from compose.const import COMPOSEFILE_V2_1 as V2_1 from compose.const import COMPOSEFILE_V3_0 as V3_0 from compose.const import COMPOSEFILE_V3_1 as V3_1 +from compose.const import COMPOSEFILE_V3_2 as V3_2 from compose.const import IS_WINDOWS_PLATFORM from compose.utils import nanoseconds_from_time_seconds from tests import mock @@ -964,6 +965,44 @@ def test_load_with_multiple_files_v2(self): ] assert service_sort(service_dicts) == service_sort(expected) + @mock.patch.dict(os.environ) + def test_load_with_multiple_files_v3_2(self): + os.environ['COMPOSE_CONVERT_WINDOWS_PATHS'] = 'true' + base_file = config.ConfigFile( + 'base.yaml', + { + 'version': '3.2', + 'services': { + 'web': { + 'image': 'example/web', + 'volumes': [ + {'source': '/a', 'target': '/b', 'type': 'bind'}, + {'source': 'vol', 'target': '/x', 'type': 'volume', 'read_only': True} + ] + } + }, + 'volumes': {'vol': {}} + } + ) + + override_file = config.ConfigFile( + 'override.yaml', + { + 'version': '3.2', + 'services': { + 'web': { + 'volumes': ['/c:/b', '/anonymous'] + } + } + } + ) + details = config.ConfigDetails('.', [base_file, override_file]) + service_dicts = config.load(details).services + svc_volumes = map(lambda v: v.repr(), service_dicts[0]['volumes']) + assert sorted(svc_volumes) == sorted( + ['/anonymous', '/c:/b:rw', 'vol:/x:ro'] + ) + def test_undeclared_volume_v2(self): base_file = config.ConfigFile( 'base.yaml', @@ -1544,6 +1583,29 @@ def test_merge_service_dicts_heterogeneous_2(self): 'ports': types.ServicePort.parse('5432') } + def test_merge_service_dicts_heterogeneous_volumes(self): + base = { + 'volumes': ['/a:/b', '/x:/z'], + } + + override = { + 'image': 'alpine:edge', + 'volumes': [ + {'source': '/e', 'target': '/b', 'type': 'bind'}, + {'source': '/c', 'target': '/d', 'type': 'bind'} + ] + } + + actual = config.merge_service_dicts_from_files( + base, override, V3_2 + ) + + assert actual['volumes'] == [ + {'source': '/e', 'target': '/b', 'type': 'bind'}, + {'source': '/c', 'target': '/d', 'type': 'bind'}, + '/x:/z' + ] + def test_merge_logging_v1(self): base = { 'image': 'alpine:edge', From 5e0a7939458ee9c1533724b15b27e82b86864418 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Sat, 18 Mar 2017 16:41:48 -0700 Subject: [PATCH 2707/4072] Bump 1.12.0-rc1 Signed-off-by: Joffrey F --- CHANGELOG.md | 120 ++++++++++++++++++++++++++++++++++++++++++++ compose/__init__.py | 2 +- script/run/run.sh | 2 +- 3 files changed, 122 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c07e9ca2ce..581d45bbf4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,126 @@ Change log ========== +1.12.0 (2017-03-21) +------------------- + +### New features + +#### Compose file version 3.2 + +- Introduced version 3.2 of the `docker-compose.yml` specification. + +- Added support for `cache_from` in the `build` section of services + +- Added support for the new expanded ports syntax in service definitions + +- Added support for the new expanded volumes syntax in service definitions + +#### Compose file version 2.1 + +- Added support for `pids_limit` in service definitions + +#### Compose file version 2.0 and up + +- Added `--volumes` option to `docker-compose config` that lists named + volumes declared for that project + +- Added support for `mem_reservation` in service definitions (2.x only) + +- Added support for `dns_opt` in service definitions (2.x only) + +#### All formats + +- Added a new `docker-compose images` command that lists images used by + the current project's containers + +- Added a `--stop` (shorthand `-s`) option to `docker-compose rm` that stops + the running containers before removing them + +- Added a `--resolve-image-digests` option to `docker-compose config` that + pins the image version for each service to a permanent digest + +- Added a `--exit-code-from SERVICE` option to `docker-compose up`. When + used, `docker-compose` will exit on any container's exit with the code + corresponding to the specified service's exit code + +- Added a `--parallel` option to `docker-compose pull` that enables images + for multiple services to be pulled simultaneously + +- Added a `--build-arg` option to `docker-compose build` + +- Added a `--volume ` (shorthand `-v`) option to + `docker-compose run` to declare runtime volumes to be mounted + +- Added a `--project-directory PATH` option to `docker-compose` that will + affect path resolution for the project + +- When using `--abort-on-container-exit` in `docker-compose up`, the exit + code for the container that caused the abort will be the exit code of + the `docker-compose up` command + +- Users can now configure which path separator character they want to use + to separate the `COMPOSE_FILE` environment value using the + `COMPOSE_PATH_SEPARATOR` environment variable + +- Added support for port range to single port in port mappings + (e.g. `8000-8010:80`) + +### Bugfixes + +- `docker-compose run --rm` now removes anonymous volumes after execution, + matching the behavior of `docker run --rm`. + +- Fixed a bug where override files containing port lists would cause a + TypeError to be raised + +- Fixed a bug where scaling services up or down would sometimes re-use + obsolete containers + +- Fixed a bug where the output of `docker-compose config` would be invalid + if the project declared anonymous volumes + +- Variable interpolation now properly occurs in the `secrets` section of + the Compose file + +- The `secrets` section now properly appears in the output of + `docker-compose config` + +- Fixed a bug where changes to some networks properties would not be + detected against previously created networks + +- Fixed a bug where `docker-compose` would crash when trying to write into + a closed pipe + +1.11.2 (2017-02-17) +------------------- + +### Bugfixes + +- Fixed a bug that was preventing secrets configuration from being + loaded properly + +- Fixed a bug where the `docker-compose config` command would fail + if the config file contained secrets definitions + +- Fixed an issue where Compose on some linux distributions would + pick up and load an outdated version of the requests library + +- Fixed an issue where socket-type files inside a build folder + would cause `docker-compose` to crash when trying to build that + service + +- Fixed an issue where recursive wildcard patterns `**` were not being + recognized in `.dockerignore` files. + +1.11.1 (2017-02-09) +------------------- + +### Bugfixes + +- Fixed a bug where the 3.1 file format was not being recognized as valid + by the Compose parser + 1.11.0 (2017-02-08) ------------------- diff --git a/compose/__init__.py b/compose/__init__.py index b2ca86f86fc..502e9cc4f0e 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.12.0dev' +__version__ = '1.12.0-rc1' diff --git a/script/run/run.sh b/script/run/run.sh index 31c5d3151e1..192b31219a9 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.12.0dev" +VERSION="1.12.0-rc1" IMAGE="docker/compose:$VERSION" From 5fdbd5026a4b4f73f9628e6c81cc11cdded76d2c Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Sun, 19 Mar 2017 08:00:35 -0700 Subject: [PATCH 2708/4072] Add bash completion for `config --resolve-image-digests|--volumes"` 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 4d134b5cb73..1cd5a496f1f 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -142,7 +142,7 @@ _docker_compose_bundle() { _docker_compose_config() { - COMPREPLY=( $( compgen -W "--help --quiet -q --services" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--help --quiet -q --resolve-image-digests --services --volumes" -- "$cur" ) ) } From 02c294ee287ead04bc3163bca43c0c54c058d62c Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Mon, 20 Mar 2017 16:27:56 +0100 Subject: [PATCH 2709/4072] Fix bash completion for `up --exit-code-from` `--exit-code-from` requires an argument. Also corrected sort order. Signed-off-by: Harald Albers --- contrib/completion/bash/docker-compose | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index 4d134b5cb73..c8d08ce8977 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -498,6 +498,10 @@ _docker_compose_unpause() { _docker_compose_up() { case "$prev" in + --exit-code-from) + __docker_compose_services_all + return + ;; --timeout|-t) return ;; @@ -505,7 +509,7 @@ _docker_compose_up() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "--exit-code-from --abort-on-container-exit --build -d --force-recreate --help --no-build --no-color --no-deps --no-recreate --timeout -t --remove-orphans" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--abort-on-container-exit --build -d --exit-code-from --force-recreate --help --no-build --no-color --no-deps --no-recreate --timeout -t --remove-orphans" -- "$cur" ) ) ;; *) __docker_compose_services_all From 0cf5f28f17cd0784f3a331eb1fcfef43846edbfb Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Mon, 20 Mar 2017 16:37:41 +0100 Subject: [PATCH 2710/4072] Add bash completion for `pull --parallel` 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 4d134b5cb73..b1d0a393b36 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -341,7 +341,7 @@ _docker_compose_ps() { _docker_compose_pull() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "--help --ignore-pull-failures" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--help --ignore-pull-failures --parallel" -- "$cur" ) ) ;; *) __docker_compose_services_from_image From 732bf52a4e40f8a974262896e5fffb5f9c4e80d8 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 22 Mar 2017 14:09:50 -0700 Subject: [PATCH 2711/4072] The interval is too damn small Signed-off-by: Joffrey F --- tests/integration/project_test.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index e8dbe8fbf7e..4551898516f 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -1419,7 +1419,7 @@ def test_project_up_healthy_dependency(self): 'test': 'exit 0', 'retries': 1, 'timeout': '10s', - 'interval': '0.1s' + 'interval': '1s' }, }, 'svc2': { @@ -1456,7 +1456,7 @@ def test_project_up_unhealthy_dependency(self): 'test': 'exit 1', 'retries': 1, 'timeout': '10s', - 'interval': '0.1s' + 'interval': '1s' }, }, 'svc2': { From a0add5cc129f3f7a4d43db777155daafccac371b Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 21 Mar 2017 17:23:29 -0700 Subject: [PATCH 2712/4072] Fix ports reparsing for service extends Signed-off-by: Joffrey F --- compose/config/types.py | 4 ++++ tests/unit/config/config_test.py | 24 +++++++++++++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/compose/config/types.py b/compose/config/types.py index a8d366fba31..96846b5ba69 100644 --- a/compose/config/types.py +++ b/compose/config/types.py @@ -266,6 +266,10 @@ class ServicePort(namedtuple('_ServicePort', 'target published protocol mode ext @classmethod def parse(cls, spec): + if isinstance(spec, cls): + # WHen extending a service with ports, the port definitions have already been parsed + return [spec] + if not isinstance(spec, dict): result = [] for k, v in build_port_bindings([spec]).items(): diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 195efe3b95b..4db87ecb652 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -3403,7 +3403,7 @@ def test_extends_with_defined_version_passes(self): self.assertEqual(service[0]['command'], "top") def test_extends_with_depends_on(self): - tmpdir = py.test.ensuretemp('test_extends_with_defined_version') + tmpdir = py.test.ensuretemp('test_extends_with_depends_on') self.addCleanup(tmpdir.remove) tmpdir.join('docker-compose.yml').write(""" version: "2" @@ -3435,6 +3435,28 @@ def test_extends_with_healthcheck(self): } }] + def test_extends_with_ports(self): + tmpdir = py.test.ensuretemp('test_extends_with_ports') + self.addCleanup(tmpdir.remove) + tmpdir.join('docker-compose.yml').write(""" + version: '2' + + services: + a: + image: nginx + ports: + - 80 + + b: + extends: + service: a + """) + services = load_from_filename(str(tmpdir.join('docker-compose.yml'))) + + assert len(services) == 2 + for svc in services: + assert svc['ports'] == [types.ServicePort('80', None, None, None, None)] + @pytest.mark.xfail(IS_WINDOWS_PLATFORM, reason='paths use slash') class ExpandPathTest(unittest.TestCase): From f55c9d42013e8fbb5285bc402d8248a846485217 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 22 Mar 2017 17:27:11 -0700 Subject: [PATCH 2713/4072] Recognize COMPOSE_TLS_VERSION env var in tls_config_from_options Signed-off-by: Joffrey F --- compose/cli/command.py | 19 +------------------ compose/cli/docker_client.py | 27 ++++++++++++++++++++++++--- tests/unit/cli/command_test.py | 20 -------------------- tests/unit/cli/docker_client_test.py | 28 ++++++++++++++++++++++++++++ 4 files changed, 53 insertions(+), 41 deletions(-) diff --git a/compose/cli/command.py b/compose/cli/command.py index 4e272264885..ccc76ceb4e0 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -4,7 +4,6 @@ import logging import os import re -import ssl import six @@ -15,6 +14,7 @@ from ..const import API_VERSIONS from ..project import Project from .docker_client import docker_client +from .docker_client import get_tls_version from .docker_client import tls_config_from_options from .utils import get_version_info @@ -60,23 +60,6 @@ def get_config_path_from_options(base_dir, options, environment): return None -def get_tls_version(environment): - compose_tls_version = environment.get('COMPOSE_TLS_VERSION', None) - if not compose_tls_version: - return None - - 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 ' - 'version of Python or OpenSSL. Falling back to TLSv1 (default).' - .format(compose_tls_version) - ) - return None - - return getattr(ssl, tls_attr_name) - - def get_client(environment, verbose=False, version=None, tls_config=None, host=None, tls_version=None): diff --git a/compose/cli/docker_client.py b/compose/cli/docker_client.py index 018d24513dd..44c7ad91d8f 100644 --- a/compose/cli/docker_client.py +++ b/compose/cli/docker_client.py @@ -2,6 +2,7 @@ from __future__ import unicode_literals import logging +import ssl from docker import APIClient from docker.errors import TLSParameterError @@ -16,7 +17,24 @@ log = logging.getLogger(__name__) -def tls_config_from_options(options): +def get_tls_version(environment): + compose_tls_version = environment.get('COMPOSE_TLS_VERSION', None) + if not compose_tls_version: + return None + + 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 ' + 'version of Python or OpenSSL. Falling back to TLSv1 (default).' + .format(compose_tls_version) + ) + return None + + return getattr(ssl, tls_attr_name) + + +def tls_config_from_options(options, environment=None): tls = options.get('--tls', False) ca_cert = unquote_path(options.get('--tlscacert')) cert = unquote_path(options.get('--tlscert')) @@ -24,7 +42,9 @@ def tls_config_from_options(options): verify = options.get('--tlsverify') skip_hostname_check = options.get('--skip-hostname-check', False) - advanced_opts = any([ca_cert, cert, key, verify]) + tls_version = get_tls_version(environment or {}) + + advanced_opts = any([ca_cert, cert, key, verify, tls_version]) if tls is True and not advanced_opts: return True @@ -35,7 +55,8 @@ def tls_config_from_options(options): return TLSConfig( client_cert=client_cert, verify=verify, ca_cert=ca_cert, - assert_hostname=False if skip_hostname_check else None + assert_hostname=False if skip_hostname_check else None, + ssl_version=tls_version ) return None diff --git a/tests/unit/cli/command_test.py b/tests/unit/cli/command_test.py index 3655c432e98..c64a0401b62 100644 --- a/tests/unit/cli/command_test.py +++ b/tests/unit/cli/command_test.py @@ -2,12 +2,10 @@ from __future__ import unicode_literals import os -import ssl import pytest from compose.cli.command import get_config_path_from_options -from compose.cli.command import get_tls_version from compose.config.environment import Environment from compose.const import IS_WINDOWS_PLATFORM from tests import mock @@ -57,21 +55,3 @@ def test_multiple_path_from_env_custom_separator(self): def test_no_path(self): environment = Environment.from_env_file('.') assert not get_config_path_from_options('.', {}, environment) - - -class TestGetTlsVersion(object): - def test_get_tls_version_default(self): - 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 - - def test_get_tls_version_unavailable(self): - environment = {'COMPOSE_TLS_VERSION': 'TLSv5_5'} - with mock.patch('compose.cli.command.log') as mock_log: - tls_version = get_tls_version(environment) - mock_log.warn.assert_called_once_with(mock.ANY) - assert tls_version is None diff --git a/tests/unit/cli/docker_client_test.py b/tests/unit/cli/docker_client_test.py index aaa935afab9..482ad985026 100644 --- a/tests/unit/cli/docker_client_test.py +++ b/tests/unit/cli/docker_client_test.py @@ -3,6 +3,7 @@ import os import platform +import ssl import docker import pytest @@ -10,6 +11,7 @@ import compose from compose.cli import errors from compose.cli.docker_client import docker_client +from compose.cli.docker_client import get_tls_version from compose.cli.docker_client import tls_config_from_options from tests import mock from tests import unittest @@ -157,3 +159,29 @@ def test_tls_client_and_ca_quoted_paths(self): assert result.cert == (self.client_cert, self.key) assert result.ca_cert == self.ca_cert assert result.verify is True + + def test_tls_simple_with_tls_version(self): + tls_version = 'TLSv1' + options = {'--tls': True} + environment = {'COMPOSE_TLS_VERSION': tls_version} + result = tls_config_from_options(options, environment) + assert isinstance(result, docker.tls.TLSConfig) + assert result.ssl_version == ssl.PROTOCOL_TLSv1 + + +class TestGetTlsVersion(object): + def test_get_tls_version_default(self): + 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 + + def test_get_tls_version_unavailable(self): + environment = {'COMPOSE_TLS_VERSION': 'TLSv5_5'} + with mock.patch('compose.cli.docker_client.log') as mock_log: + tls_version = get_tls_version(environment) + mock_log.warn.assert_called_once_with(mock.ANY) + assert tls_version is None From eab1ee0eaf76921ba016e81741ccf57dbc0217b4 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 22 Mar 2017 17:04:26 -0700 Subject: [PATCH 2714/4072] Support 'nocopy' mode for expanded volume syntax Signed-off-by: Joffrey F --- compose/config/config.py | 7 +++++-- tests/acceptance/cli_test.py | 3 ++- tests/fixtures/v3-full/docker-compose.yml | 5 +++++ 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 8cbaae272ca..72687d756c3 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -1033,8 +1033,11 @@ def resolve_volume_path(working_dir, volume): if isinstance(volume, dict): host_path = volume.get('source') container_path = volume.get('target') - if host_path and volume.get('read_only'): - container_path += ':ro' + if host_path: + if volume.get('read_only'): + container_path += ':ro' + if volume.get('volume', {}).get('nocopy'): + container_path += ':nocopy' else: container_path, host_path = split_path_mapping(volume) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 14e6f73362a..bceb102a2ca 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -374,7 +374,8 @@ def test_config_v3(self): 'volumes': [ '/host/path:/container/path:ro', 'foobar:/container/volumepath:rw', - '/anonymous' + '/anonymous', + 'foobar:/container/volumepath2:nocopy' ], 'stop_grace_period': '20s', diff --git a/tests/fixtures/v3-full/docker-compose.yml b/tests/fixtures/v3-full/docker-compose.yml index 27f3c6e04fa..2bc0e248d13 100644 --- a/tests/fixtures/v3-full/docker-compose.yml +++ b/tests/fixtures/v3-full/docker-compose.yml @@ -44,6 +44,11 @@ services: target: /container/volumepath - type: volume target: /anonymous + - type: volume + source: foobar + target: /container/volumepath2 + volume: + nocopy: true stop_grace_period: 20s volumes: From 14115a3570f51f5fe391f02583b8b452ba86136e Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 22 Mar 2017 17:27:11 -0700 Subject: [PATCH 2715/4072] Recognize COMPOSE_TLS_VERSION env var in tls_config_from_options Signed-off-by: Joffrey F --- compose/cli/command.py | 19 +------------------ compose/cli/docker_client.py | 27 ++++++++++++++++++++++++--- tests/unit/cli/command_test.py | 20 -------------------- tests/unit/cli/docker_client_test.py | 28 ++++++++++++++++++++++++++++ 4 files changed, 53 insertions(+), 41 deletions(-) diff --git a/compose/cli/command.py b/compose/cli/command.py index 4e272264885..ccc76ceb4e0 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -4,7 +4,6 @@ import logging import os import re -import ssl import six @@ -15,6 +14,7 @@ from ..const import API_VERSIONS from ..project import Project from .docker_client import docker_client +from .docker_client import get_tls_version from .docker_client import tls_config_from_options from .utils import get_version_info @@ -60,23 +60,6 @@ def get_config_path_from_options(base_dir, options, environment): return None -def get_tls_version(environment): - compose_tls_version = environment.get('COMPOSE_TLS_VERSION', None) - if not compose_tls_version: - return None - - 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 ' - 'version of Python or OpenSSL. Falling back to TLSv1 (default).' - .format(compose_tls_version) - ) - return None - - return getattr(ssl, tls_attr_name) - - def get_client(environment, verbose=False, version=None, tls_config=None, host=None, tls_version=None): diff --git a/compose/cli/docker_client.py b/compose/cli/docker_client.py index 018d24513dd..44c7ad91d8f 100644 --- a/compose/cli/docker_client.py +++ b/compose/cli/docker_client.py @@ -2,6 +2,7 @@ from __future__ import unicode_literals import logging +import ssl from docker import APIClient from docker.errors import TLSParameterError @@ -16,7 +17,24 @@ log = logging.getLogger(__name__) -def tls_config_from_options(options): +def get_tls_version(environment): + compose_tls_version = environment.get('COMPOSE_TLS_VERSION', None) + if not compose_tls_version: + return None + + 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 ' + 'version of Python or OpenSSL. Falling back to TLSv1 (default).' + .format(compose_tls_version) + ) + return None + + return getattr(ssl, tls_attr_name) + + +def tls_config_from_options(options, environment=None): tls = options.get('--tls', False) ca_cert = unquote_path(options.get('--tlscacert')) cert = unquote_path(options.get('--tlscert')) @@ -24,7 +42,9 @@ def tls_config_from_options(options): verify = options.get('--tlsverify') skip_hostname_check = options.get('--skip-hostname-check', False) - advanced_opts = any([ca_cert, cert, key, verify]) + tls_version = get_tls_version(environment or {}) + + advanced_opts = any([ca_cert, cert, key, verify, tls_version]) if tls is True and not advanced_opts: return True @@ -35,7 +55,8 @@ def tls_config_from_options(options): return TLSConfig( client_cert=client_cert, verify=verify, ca_cert=ca_cert, - assert_hostname=False if skip_hostname_check else None + assert_hostname=False if skip_hostname_check else None, + ssl_version=tls_version ) return None diff --git a/tests/unit/cli/command_test.py b/tests/unit/cli/command_test.py index 3655c432e98..c64a0401b62 100644 --- a/tests/unit/cli/command_test.py +++ b/tests/unit/cli/command_test.py @@ -2,12 +2,10 @@ from __future__ import unicode_literals import os -import ssl import pytest from compose.cli.command import get_config_path_from_options -from compose.cli.command import get_tls_version from compose.config.environment import Environment from compose.const import IS_WINDOWS_PLATFORM from tests import mock @@ -57,21 +55,3 @@ def test_multiple_path_from_env_custom_separator(self): def test_no_path(self): environment = Environment.from_env_file('.') assert not get_config_path_from_options('.', {}, environment) - - -class TestGetTlsVersion(object): - def test_get_tls_version_default(self): - 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 - - def test_get_tls_version_unavailable(self): - environment = {'COMPOSE_TLS_VERSION': 'TLSv5_5'} - with mock.patch('compose.cli.command.log') as mock_log: - tls_version = get_tls_version(environment) - mock_log.warn.assert_called_once_with(mock.ANY) - assert tls_version is None diff --git a/tests/unit/cli/docker_client_test.py b/tests/unit/cli/docker_client_test.py index aaa935afab9..482ad985026 100644 --- a/tests/unit/cli/docker_client_test.py +++ b/tests/unit/cli/docker_client_test.py @@ -3,6 +3,7 @@ import os import platform +import ssl import docker import pytest @@ -10,6 +11,7 @@ import compose from compose.cli import errors from compose.cli.docker_client import docker_client +from compose.cli.docker_client import get_tls_version from compose.cli.docker_client import tls_config_from_options from tests import mock from tests import unittest @@ -157,3 +159,29 @@ def test_tls_client_and_ca_quoted_paths(self): assert result.cert == (self.client_cert, self.key) assert result.ca_cert == self.ca_cert assert result.verify is True + + def test_tls_simple_with_tls_version(self): + tls_version = 'TLSv1' + options = {'--tls': True} + environment = {'COMPOSE_TLS_VERSION': tls_version} + result = tls_config_from_options(options, environment) + assert isinstance(result, docker.tls.TLSConfig) + assert result.ssl_version == ssl.PROTOCOL_TLSv1 + + +class TestGetTlsVersion(object): + def test_get_tls_version_default(self): + 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 + + def test_get_tls_version_unavailable(self): + environment = {'COMPOSE_TLS_VERSION': 'TLSv5_5'} + with mock.patch('compose.cli.docker_client.log') as mock_log: + tls_version = get_tls_version(environment) + mock_log.warn.assert_called_once_with(mock.ANY) + assert tls_version is None From 6cbedb78ccaf4d9ca38d6969970172af41f61518 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 22 Mar 2017 14:09:50 -0700 Subject: [PATCH 2716/4072] The interval is too damn small Signed-off-by: Joffrey F --- tests/integration/project_test.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index e8dbe8fbf7e..4551898516f 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -1419,7 +1419,7 @@ def test_project_up_healthy_dependency(self): 'test': 'exit 0', 'retries': 1, 'timeout': '10s', - 'interval': '0.1s' + 'interval': '1s' }, }, 'svc2': { @@ -1456,7 +1456,7 @@ def test_project_up_unhealthy_dependency(self): 'test': 'exit 1', 'retries': 1, 'timeout': '10s', - 'interval': '0.1s' + 'interval': '1s' }, }, 'svc2': { From 0a55b070025a974841b610a68f201cc337e8db64 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 21 Mar 2017 17:23:29 -0700 Subject: [PATCH 2717/4072] Fix ports reparsing for service extends Signed-off-by: Joffrey F --- compose/config/types.py | 4 ++++ tests/unit/config/config_test.py | 24 +++++++++++++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/compose/config/types.py b/compose/config/types.py index a8d366fba31..96846b5ba69 100644 --- a/compose/config/types.py +++ b/compose/config/types.py @@ -266,6 +266,10 @@ class ServicePort(namedtuple('_ServicePort', 'target published protocol mode ext @classmethod def parse(cls, spec): + if isinstance(spec, cls): + # WHen extending a service with ports, the port definitions have already been parsed + return [spec] + if not isinstance(spec, dict): result = [] for k, v in build_port_bindings([spec]).items(): diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 195efe3b95b..4db87ecb652 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -3403,7 +3403,7 @@ def test_extends_with_defined_version_passes(self): self.assertEqual(service[0]['command'], "top") def test_extends_with_depends_on(self): - tmpdir = py.test.ensuretemp('test_extends_with_defined_version') + tmpdir = py.test.ensuretemp('test_extends_with_depends_on') self.addCleanup(tmpdir.remove) tmpdir.join('docker-compose.yml').write(""" version: "2" @@ -3435,6 +3435,28 @@ def test_extends_with_healthcheck(self): } }] + def test_extends_with_ports(self): + tmpdir = py.test.ensuretemp('test_extends_with_ports') + self.addCleanup(tmpdir.remove) + tmpdir.join('docker-compose.yml').write(""" + version: '2' + + services: + a: + image: nginx + ports: + - 80 + + b: + extends: + service: a + """) + services = load_from_filename(str(tmpdir.join('docker-compose.yml'))) + + assert len(services) == 2 + for svc in services: + assert svc['ports'] == [types.ServicePort('80', None, None, None, None)] + @pytest.mark.xfail(IS_WINDOWS_PLATFORM, reason='paths use slash') class ExpandPathTest(unittest.TestCase): From 747186f9008609d7ff2ddfede71ab0916c4a5718 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 22 Mar 2017 17:04:26 -0700 Subject: [PATCH 2718/4072] Support 'nocopy' mode for expanded volume syntax Signed-off-by: Joffrey F --- compose/config/config.py | 7 +++++-- tests/acceptance/cli_test.py | 3 ++- tests/fixtures/v3-full/docker-compose.yml | 5 +++++ 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 8cbaae272ca..72687d756c3 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -1033,8 +1033,11 @@ def resolve_volume_path(working_dir, volume): if isinstance(volume, dict): host_path = volume.get('source') container_path = volume.get('target') - if host_path and volume.get('read_only'): - container_path += ':ro' + if host_path: + if volume.get('read_only'): + container_path += ':ro' + if volume.get('volume', {}).get('nocopy'): + container_path += ':nocopy' else: container_path, host_path = split_path_mapping(volume) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 14e6f73362a..bceb102a2ca 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -374,7 +374,8 @@ def test_config_v3(self): 'volumes': [ '/host/path:/container/path:ro', 'foobar:/container/volumepath:rw', - '/anonymous' + '/anonymous', + 'foobar:/container/volumepath2:nocopy' ], 'stop_grace_period': '20s', diff --git a/tests/fixtures/v3-full/docker-compose.yml b/tests/fixtures/v3-full/docker-compose.yml index 27f3c6e04fa..2bc0e248d13 100644 --- a/tests/fixtures/v3-full/docker-compose.yml +++ b/tests/fixtures/v3-full/docker-compose.yml @@ -44,6 +44,11 @@ services: target: /container/volumepath - type: volume target: /anonymous + - type: volume + source: foobar + target: /container/volumepath2 + volume: + nocopy: true stop_grace_period: 20s volumes: From cd3343440ce3e5a7f91d1e0f76c95a29ebb18031 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 21 Mar 2017 16:28:53 -0700 Subject: [PATCH 2719/4072] Change docker-py dependency error to a warning, update fix command Signed-off-by: Joffrey F --- compose/cli/__init__.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/compose/cli/__init__.py b/compose/cli/__init__.py index c5db44558ea..4061e709139 100644 --- a/compose/cli/__init__.py +++ b/compose/cli/__init__.py @@ -20,16 +20,15 @@ list(filter(lambda p: p.startswith(b'docker-py=='), packages)) ) > 0 if dockerpy_installed: - from .colors import red + from .colors import yellow print( - red('ERROR:'), + yellow('WARNING:'), "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", + "may be polluting the namespace. " + "If you're experiencing crashes, run the following command to remedy the issue:\n" + "pip uninstall docker-py; pip uninstall docker; pip install docker", file=sys.stderr ) - sys.exit(1) except OSError: # pip command is not available, which indicates it's probably the binary From 962330120dd9294c663ca1a80f263d34c71e58dc Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 23 Mar 2017 16:43:15 -0700 Subject: [PATCH 2720/4072] Ignore unicode error in subprocess call Signed-off-by: Joffrey F --- compose/cli/__init__.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/compose/cli/__init__.py b/compose/cli/__init__.py index 4061e709139..1fe9aab8df4 100644 --- a/compose/cli/__init__.py +++ b/compose/cli/__init__.py @@ -34,3 +34,9 @@ # pip command is not available, which indicates it's probably the binary # distribution of Compose which is not affected pass +except UnicodeDecodeError: + # ref: https://github.com/docker/compose/issues/4663 + # This could be caused by a number of things, but it seems to be a + # python 2 + MacOS interaction. It's not ideal to ignore this, but at least + # it doesn't make the program unusable. + pass From da466b391470333492a56395569812653ed6658f Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 21 Mar 2017 16:28:53 -0700 Subject: [PATCH 2721/4072] Change docker-py dependency error to a warning, update fix command Signed-off-by: Joffrey F --- compose/cli/__init__.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/compose/cli/__init__.py b/compose/cli/__init__.py index c5db44558ea..4061e709139 100644 --- a/compose/cli/__init__.py +++ b/compose/cli/__init__.py @@ -20,16 +20,15 @@ list(filter(lambda p: p.startswith(b'docker-py=='), packages)) ) > 0 if dockerpy_installed: - from .colors import red + from .colors import yellow print( - red('ERROR:'), + yellow('WARNING:'), "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", + "may be polluting the namespace. " + "If you're experiencing crashes, run the following command to remedy the issue:\n" + "pip uninstall docker-py; pip uninstall docker; pip install docker", file=sys.stderr ) - sys.exit(1) except OSError: # pip command is not available, which indicates it's probably the binary From 7575f4006939bdb346a793e6747d3f132c00ecb8 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 23 Mar 2017 16:43:15 -0700 Subject: [PATCH 2722/4072] Ignore unicode error in subprocess call Signed-off-by: Joffrey F --- compose/cli/__init__.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/compose/cli/__init__.py b/compose/cli/__init__.py index 4061e709139..1fe9aab8df4 100644 --- a/compose/cli/__init__.py +++ b/compose/cli/__init__.py @@ -34,3 +34,9 @@ # pip command is not available, which indicates it's probably the binary # distribution of Compose which is not affected pass +except UnicodeDecodeError: + # ref: https://github.com/docker/compose/issues/4663 + # This could be caused by a number of things, but it seems to be a + # python 2 + MacOS interaction. It's not ideal to ignore this, but at least + # it doesn't make the program unusable. + pass From 08dc2a41524e2bb476aec34a9864e13e325665c3 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 23 Mar 2017 15:21:40 -0700 Subject: [PATCH 2723/4072] Bump 1.12.0-rc2 Signed-off-by: Joffrey F --- CHANGELOG.md | 3 +++ compose/__init__.py | 2 +- script/run/run.sh | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 581d45bbf4f..0aa3acf65a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -92,6 +92,9 @@ Change log - Fixed a bug where `docker-compose` would crash when trying to write into a closed pipe +- Fixed an issue where Compose would not pick up on the value of + COMPOSE_TLS_VERSION when used in combination with command-line TLS flags + 1.11.2 (2017-02-17) ------------------- diff --git a/compose/__init__.py b/compose/__init__.py index 502e9cc4f0e..4399b28af6d 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.12.0-rc1' +__version__ = '1.12.0-rc2' diff --git a/script/run/run.sh b/script/run/run.sh index 192b31219a9..62c065bbc5f 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.12.0-rc1" +VERSION="1.12.0-rc2" IMAGE="docker/compose:$VERSION" From 0e5acfa16caa66e7eff6042e2473b2fdca6870af Mon Sep 17 00:00:00 2001 From: King Chung Huang Date: Fri, 24 Mar 2017 13:49:54 -0600 Subject: [PATCH 2724/4072] Merge deploy key in service dicts Update merge_service_dicts() to merge deploy mappings. Compose file version 3 added the deploy key to service dicts to specify configs related to Docker services. Signed-off-by: King Chung Huang --- compose/config/config.py | 2 ++ tests/unit/config/config_test.py | 51 ++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/compose/config/config.py b/compose/config/config.py index 72687d756c3..3292845f561 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -879,6 +879,7 @@ def merge_service_dicts(base, override, version): md.merge_mapping('depends_on', parse_depends_on) md.merge_sequence('links', ServiceLink.parse) md.merge_sequence('secrets', types.ServiceSecret.parse) + md.merge_mapping('deploy', parse_deploy) for field in ['volumes', 'devices']: md.merge_field(field, merge_path_mappings) @@ -1003,6 +1004,7 @@ def parse_dict_or_list(split_func, type_name, arguments): parse_depends_on = functools.partial( parse_dict_or_list, lambda k: (k, {'condition': 'service_started'}), 'depends_on' ) +parse_deploy = functools.partial(parse_dict_or_list, split_kv, 'deploy') def parse_ulimits(ulimits): diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 4db87ecb652..88a47580141 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1950,6 +1950,57 @@ def test_merge_secrets_override(self): actual = config.merge_service_dicts(base, override, V3_1) assert actual['secrets'] == override['secrets'] + def test_merge_deploy(self): + base = { + 'image': 'busybox', + } + override = { + 'deploy': { + 'mode': 'global', + 'restart_policy': { + 'condition': 'on-failure' + } + } + } + actual = config.merge_service_dicts(base, override, V3_0) + assert actual['deploy'] == override['deploy'] + + def test_merge_deploy_override(self): + base = { + 'image': 'busybox', + 'deploy': { + 'mode': 'global', + 'restart_policy': { + 'condition': 'on-failure' + }, + 'placement': { + 'constraints': [ + 'node.role == manager' + ] + } + } + } + override = { + 'deploy': { + 'mode': 'replicated', + 'restart_policy': { + 'condition': 'any' + } + } + } + actual = config.merge_service_dicts(base, override, V3_0) + assert actual['deploy'] == { + 'mode': 'replicated', + 'restart_policy': { + 'condition': 'any' + }, + 'placement': { + 'constraints': [ + 'node.role == manager' + ] + } + } + def test_external_volume_config(self): config_details = build_config_details({ 'version': '2', From 48831a8d5fd5b76875d4cde909125cc1945d5317 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 21 Mar 2017 12:39:32 -0700 Subject: [PATCH 2725/4072] Bump docker SDK dependency Update invalid ports test Signed-off-by: Joffrey F --- requirements.txt | 2 +- setup.py | 2 +- tests/unit/config/config_test.py | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/requirements.txt b/requirements.txt index 53b9294ce1e..f8061af83be 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.1.0 +docker==2.2.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 13fe59b224d..19a0d4aa00d 100644 --- a/setup.py +++ b/setup.py @@ -37,7 +37,7 @@ def find_version(*file_paths): 'requests >= 2.6.1, != 2.11.0, < 2.12', 'texttable >= 0.8.1, < 0.9', 'websocket-client >= 0.32.0, < 1.0', - 'docker >= 2.1.0, < 3.0', + 'docker >= 2.2.1, < 3.0', 'dockerpty >= 0.4.1, < 0.5', 'six >= 1.3.0, < 2', 'jsonschema >= 2.5.1, < 3', diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 195efe3b95b..bf456760150 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -2249,7 +2249,8 @@ class PortsTest(unittest.TestCase): ] INVALID_PORT_MAPPINGS = [ - ["8000-8001:8000"], + ["8000-8004:8000-8002"], + ["4242:4242-4244"], ] VALID_SINGLE_PORTS = [ From f6fc8b582224fea3823e439d0cf9b6f3435ae680 Mon Sep 17 00:00:00 2001 From: Steve Durrheimer Date: Thu, 30 Mar 2017 21:32:33 +0200 Subject: [PATCH 2726/4072] Add zsh completion for 'docker-compose images' Signed-off-by: Steve Durrheimer --- contrib/completion/zsh/_docker-compose | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/contrib/completion/zsh/_docker-compose b/contrib/completion/zsh/_docker-compose index 66d924f73a1..b40d440f9e2 100644 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -3,11 +3,6 @@ # Description # ----------- # zsh completion for docker-compose -# https://github.com/sdurrheimer/docker-compose-zsh-completion -# ------------------------------------------------------------------------- -# Version -# ------- -# 1.5.0 # ------------------------------------------------------------------------- # Authors # ------- @@ -253,6 +248,12 @@ __docker-compose_subcommand() { (help) _arguments ':subcommand:__docker-compose_commands' && ret=0 ;; + (images) + _arguments \ + $opts_help \ + '-q[Only display IDs]' \ + '*:services:__docker-compose_services_all' && ret=0 + ;; (kill) _arguments \ $opts_help \ From 9fc3cc9c3c0bb4cfa85548fed5c98690d741f48b Mon Sep 17 00:00:00 2001 From: Steve Durrheimer Date: Thu, 30 Mar 2017 21:38:14 +0200 Subject: [PATCH 2727/4072] Add zsh completion for 'docker-compose run -v --volume' 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 66d924f73a1..2a3792fefac 100644 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -309,16 +309,17 @@ __docker-compose_subcommand() { (run) _arguments \ $opts_help \ + $opts_no_deps \ '-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: ' \ - $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.]" \ '-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' \ + '(-v --volume)*'{-v,--volume=}'[Bind mount a volume]:volume: ' \ '(-w --workdir)'{-w,--workdir=}'[Working directory inside the container]:workdir: ' \ '(-):services:__docker-compose_services' \ '(-):command: _command_names -e' \ From 29d23c27939a5347c3be3e11d9f8c2b679032b4f Mon Sep 17 00:00:00 2001 From: Steve Durrheimer Date: Thu, 30 Mar 2017 22:05:36 +0200 Subject: [PATCH 2728/4072] Add zsh completion for 'docker-compose build --build-arg' 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 66d924f73a1..50ba589d88d 100644 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -199,6 +199,7 @@ __docker-compose_subcommand() { (build) _arguments \ $opts_help \ + "*--build-arg=[Set build-time variables for one service.]:=: " \ '--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.]' \ From be8fd5810e9d0557bc8f2d03241ea35d793a5466 Mon Sep 17 00:00:00 2001 From: Steve Durrheimer Date: Thu, 30 Mar 2017 22:10:20 +0200 Subject: [PATCH 2729/4072] Add zsh completion for 'docker-compose config --resolve-image-digests --volumes' Signed-off-by: Steve Durrheimer --- 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 66d924f73a1..b6f34897a83 100644 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -214,7 +214,9 @@ __docker-compose_subcommand() { _arguments \ $opts_help \ '(--quiet -q)'{--quiet,-q}"[Only validate the configuration, don't print anything.]" \ - '--services[Print the service names, one per line.]' && ret=0 + '--resolve-image-digests[Pin image tags to digests.]' \ + '--services[Print the service names, one per line.]' \ + '--volumes[Print the volume names, one per line.]' && ret=0 ;; (create) _arguments \ From 65b919cf08d1c760fe8c4a6aa7057377667183e8 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 3 Apr 2017 11:54:12 -0700 Subject: [PATCH 2730/4072] Add 3.2 schema to docker-compose.spec Signed-off-by: Joffrey F --- compose/config/errors.py | 4 ++-- docker-compose.spec | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/compose/config/errors.py b/compose/config/errors.py index 0f78d4a94eb..9b82df0ab55 100644 --- a/compose/config/errors.py +++ b/compose/config/errors.py @@ -4,8 +4,8 @@ VERSION_EXPLANATION = ( '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", "3.1") and place your ' - 'service definitions under the `services` key, or omit the `version` key ' + 'Either specify a supported version ("2.0", "2.1", "3.0", "3.1", "3.2") 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/docker-compose.spec b/docker-compose.spec index ef0e2593e02..f4280dd425b 100644 --- a/docker-compose.spec +++ b/docker-compose.spec @@ -42,6 +42,11 @@ exe = EXE(pyz, 'compose/config/config_schema_v3.1.json', 'DATA' ), + ( + 'compose/config/config_schema_v3.2.json', + 'compose/config/config_schema_v3.2.json', + 'DATA' + ), ( 'compose/GITSHA', 'compose/GITSHA', From bc1a876937122eafff2d898c85cb1682a24ad958 Mon Sep 17 00:00:00 2001 From: King Chung Huang Date: Fri, 24 Mar 2017 13:49:54 -0600 Subject: [PATCH 2731/4072] Merge deploy key in service dicts Update merge_service_dicts() to merge deploy mappings. Compose file version 3 added the deploy key to service dicts to specify configs related to Docker services. Signed-off-by: King Chung Huang --- compose/config/config.py | 2 ++ tests/unit/config/config_test.py | 51 ++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/compose/config/config.py b/compose/config/config.py index 72687d756c3..3292845f561 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -879,6 +879,7 @@ def merge_service_dicts(base, override, version): md.merge_mapping('depends_on', parse_depends_on) md.merge_sequence('links', ServiceLink.parse) md.merge_sequence('secrets', types.ServiceSecret.parse) + md.merge_mapping('deploy', parse_deploy) for field in ['volumes', 'devices']: md.merge_field(field, merge_path_mappings) @@ -1003,6 +1004,7 @@ def parse_dict_or_list(split_func, type_name, arguments): parse_depends_on = functools.partial( parse_dict_or_list, lambda k: (k, {'condition': 'service_started'}), 'depends_on' ) +parse_deploy = functools.partial(parse_dict_or_list, split_kv, 'deploy') def parse_ulimits(ulimits): diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 4db87ecb652..88a47580141 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1950,6 +1950,57 @@ def test_merge_secrets_override(self): actual = config.merge_service_dicts(base, override, V3_1) assert actual['secrets'] == override['secrets'] + def test_merge_deploy(self): + base = { + 'image': 'busybox', + } + override = { + 'deploy': { + 'mode': 'global', + 'restart_policy': { + 'condition': 'on-failure' + } + } + } + actual = config.merge_service_dicts(base, override, V3_0) + assert actual['deploy'] == override['deploy'] + + def test_merge_deploy_override(self): + base = { + 'image': 'busybox', + 'deploy': { + 'mode': 'global', + 'restart_policy': { + 'condition': 'on-failure' + }, + 'placement': { + 'constraints': [ + 'node.role == manager' + ] + } + } + } + override = { + 'deploy': { + 'mode': 'replicated', + 'restart_policy': { + 'condition': 'any' + } + } + } + actual = config.merge_service_dicts(base, override, V3_0) + assert actual['deploy'] == { + 'mode': 'replicated', + 'restart_policy': { + 'condition': 'any' + }, + 'placement': { + 'constraints': [ + 'node.role == manager' + ] + } + } + def test_external_volume_config(self): config_details = build_config_details({ 'version': '2', From f9a9d9748985ab9c3b77ee86603f4695ba979422 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 3 Apr 2017 11:54:12 -0700 Subject: [PATCH 2732/4072] Add 3.2 schema to docker-compose.spec Signed-off-by: Joffrey F --- compose/config/errors.py | 4 ++-- docker-compose.spec | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/compose/config/errors.py b/compose/config/errors.py index 0f78d4a94eb..9b82df0ab55 100644 --- a/compose/config/errors.py +++ b/compose/config/errors.py @@ -4,8 +4,8 @@ VERSION_EXPLANATION = ( '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", "3.1") and place your ' - 'service definitions under the `services` key, or omit the `version` key ' + 'Either specify a supported version ("2.0", "2.1", "3.0", "3.1", "3.2") 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/docker-compose.spec b/docker-compose.spec index ef0e2593e02..f4280dd425b 100644 --- a/docker-compose.spec +++ b/docker-compose.spec @@ -42,6 +42,11 @@ exe = EXE(pyz, 'compose/config/config_schema_v3.1.json', 'DATA' ), + ( + 'compose/config/config_schema_v3.2.json', + 'compose/config/config_schema_v3.2.json', + 'DATA' + ), ( 'compose/GITSHA', 'compose/GITSHA', From 21cdd3f6d7957ea7c3c5e6c6404d7b486e9546c8 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 21 Mar 2017 12:39:32 -0700 Subject: [PATCH 2733/4072] Bump docker SDK dependency Update invalid ports test Signed-off-by: Joffrey F --- requirements.txt | 2 +- setup.py | 2 +- tests/unit/config/config_test.py | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/requirements.txt b/requirements.txt index 53b9294ce1e..f8061af83be 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.1.0 +docker==2.2.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 13fe59b224d..19a0d4aa00d 100644 --- a/setup.py +++ b/setup.py @@ -37,7 +37,7 @@ def find_version(*file_paths): 'requests >= 2.6.1, != 2.11.0, < 2.12', 'texttable >= 0.8.1, < 0.9', 'websocket-client >= 0.32.0, < 1.0', - 'docker >= 2.1.0, < 3.0', + 'docker >= 2.2.1, < 3.0', 'dockerpty >= 0.4.1, < 0.5', 'six >= 1.3.0, < 2', 'jsonschema >= 2.5.1, < 3', diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 88a47580141..b7e4cc9bfac 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -2300,7 +2300,8 @@ class PortsTest(unittest.TestCase): ] INVALID_PORT_MAPPINGS = [ - ["8000-8001:8000"], + ["8000-8004:8000-8002"], + ["4242:4242-4244"], ] VALID_SINGLE_PORTS = [ From 752d8d165f1bbce74a44a7a310fbdd193c5bdb20 Mon Sep 17 00:00:00 2001 From: Steve Durrheimer Date: Thu, 30 Mar 2017 21:32:33 +0200 Subject: [PATCH 2734/4072] Add zsh completion for 'docker-compose images' Signed-off-by: Steve Durrheimer --- contrib/completion/zsh/_docker-compose | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/contrib/completion/zsh/_docker-compose b/contrib/completion/zsh/_docker-compose index 66d924f73a1..b40d440f9e2 100644 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -3,11 +3,6 @@ # Description # ----------- # zsh completion for docker-compose -# https://github.com/sdurrheimer/docker-compose-zsh-completion -# ------------------------------------------------------------------------- -# Version -# ------- -# 1.5.0 # ------------------------------------------------------------------------- # Authors # ------- @@ -253,6 +248,12 @@ __docker-compose_subcommand() { (help) _arguments ':subcommand:__docker-compose_commands' && ret=0 ;; + (images) + _arguments \ + $opts_help \ + '-q[Only display IDs]' \ + '*:services:__docker-compose_services_all' && ret=0 + ;; (kill) _arguments \ $opts_help \ From cfdc9d67c8dd65f2df095b55a4feaf5a1561f392 Mon Sep 17 00:00:00 2001 From: Steve Durrheimer Date: Thu, 30 Mar 2017 21:38:14 +0200 Subject: [PATCH 2735/4072] Add zsh completion for 'docker-compose run -v --volume' 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 b40d440f9e2..1b22550f2b0 100644 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -310,16 +310,17 @@ __docker-compose_subcommand() { (run) _arguments \ $opts_help \ + $opts_no_deps \ '-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: ' \ - $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.]" \ '-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' \ + '(-v --volume)*'{-v,--volume=}'[Bind mount a volume]:volume: ' \ '(-w --workdir)'{-w,--workdir=}'[Working directory inside the container]:workdir: ' \ '(-):services:__docker-compose_services' \ '(-):command: _command_names -e' \ From ba787a45b9df45d57bd746d732dea9275e430e59 Mon Sep 17 00:00:00 2001 From: Steve Durrheimer Date: Thu, 30 Mar 2017 22:05:36 +0200 Subject: [PATCH 2736/4072] Add zsh completion for 'docker-compose build --build-arg' 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 1b22550f2b0..2818e111b84 100644 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -194,6 +194,7 @@ __docker-compose_subcommand() { (build) _arguments \ $opts_help \ + "*--build-arg=[Set build-time variables for one service.]:=: " \ '--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.]' \ From c9a9f5ab03a3268186d21bfdeaacbe11bc5c8f1b Mon Sep 17 00:00:00 2001 From: Steve Durrheimer Date: Thu, 30 Mar 2017 22:10:20 +0200 Subject: [PATCH 2737/4072] Add zsh completion for 'docker-compose config --resolve-image-digests --volumes' Signed-off-by: Steve Durrheimer --- 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 2818e111b84..8513884bcdd 100644 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -210,7 +210,9 @@ __docker-compose_subcommand() { _arguments \ $opts_help \ '(--quiet -q)'{--quiet,-q}"[Only validate the configuration, don't print anything.]" \ - '--services[Print the service names, one per line.]' && ret=0 + '--resolve-image-digests[Pin image tags to digests.]' \ + '--services[Print the service names, one per line.]' \ + '--volumes[Print the volume names, one per line.]' && ret=0 ;; (create) _arguments \ From d917ca3e4e234ba378fc1e629de7962031a4694a Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Mon, 20 Mar 2017 16:37:41 +0100 Subject: [PATCH 2738/4072] Add bash completion for `pull --parallel` 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 4d134b5cb73..b1d0a393b36 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -341,7 +341,7 @@ _docker_compose_ps() { _docker_compose_pull() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "--help --ignore-pull-failures" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--help --ignore-pull-failures --parallel" -- "$cur" ) ) ;; *) __docker_compose_services_from_image From 5b6ddd5e30e0b73154c8492ab84832afc15a0b87 Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Mon, 20 Mar 2017 16:27:56 +0100 Subject: [PATCH 2739/4072] Fix bash completion for `up --exit-code-from` `--exit-code-from` requires an argument. Also corrected sort order. Signed-off-by: Harald Albers --- contrib/completion/bash/docker-compose | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index b1d0a393b36..20588522cb4 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -498,6 +498,10 @@ _docker_compose_unpause() { _docker_compose_up() { case "$prev" in + --exit-code-from) + __docker_compose_services_all + return + ;; --timeout|-t) return ;; @@ -505,7 +509,7 @@ _docker_compose_up() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "--exit-code-from --abort-on-container-exit --build -d --force-recreate --help --no-build --no-color --no-deps --no-recreate --timeout -t --remove-orphans" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--abort-on-container-exit --build -d --exit-code-from --force-recreate --help --no-build --no-color --no-deps --no-recreate --timeout -t --remove-orphans" -- "$cur" ) ) ;; *) __docker_compose_services_all From 7a0e56a0c76e284dd11992f1b4afae793ef71dab Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Sun, 19 Mar 2017 08:00:35 -0700 Subject: [PATCH 2740/4072] Add bash completion for `config --resolve-image-digests|--volumes"` 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 20588522cb4..739ba39b047 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -142,7 +142,7 @@ _docker_compose_bundle() { _docker_compose_config() { - COMPREPLY=( $( compgen -W "--help --quiet -q --services" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--help --quiet -q --resolve-image-digests --services --volumes" -- "$cur" ) ) } From b195a09a5473708ea0979cf52b0bb9de536a2daf Mon Sep 17 00:00:00 2001 From: fate-grand-order Date: Fri, 17 Mar 2017 11:35:10 +0800 Subject: [PATCH 2741/4072] fix misspell "compatibility" in script/ci Signed-off-by: fate-grand-order --- script/ci | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/ci b/script/ci index 7b3489a1b20..34bf9a4be65 100755 --- a/script/ci +++ b/script/ci @@ -1,6 +1,6 @@ #!/bin/bash # -# Backwards compatiblity for jenkins +# Backwards compatibility for jenkins # # TODO: remove this script after all current PRs and jenkins are updated with # the new script/test/ci change From b31ff33aca5f20292ac1d0b68712f917c538af43 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 3 Apr 2017 12:46:56 -0700 Subject: [PATCH 2742/4072] Bump 1.12.0 Signed-off-by: Joffrey F --- CHANGELOG.md | 5 ++++- compose/__init__.py | 2 +- script/run/run.sh | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0aa3acf65a0..8d49ddea792 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ Change log ========== -1.12.0 (2017-03-21) +1.12.0 (2017-04-04) ------------------- ### New features @@ -74,6 +74,9 @@ Change log - Fixed a bug where override files containing port lists would cause a TypeError to be raised +- Fixed a bug where the `deploy` key would be missing from the output of + `docker-compose config` + - Fixed a bug where scaling services up or down would sometimes re-use obsolete containers diff --git a/compose/__init__.py b/compose/__init__.py index 4399b28af6d..bf126ebb742 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.12.0-rc2' +__version__ = '1.12.0' diff --git a/script/run/run.sh b/script/run/run.sh index 62c065bbc5f..9fd097d7510 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.12.0-rc2" +VERSION="1.12.0" IMAGE="docker/compose:$VERSION" From e7e159076b3ffd6d624467b7d6d999701fef662d Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 3 Apr 2017 14:15:36 -0700 Subject: [PATCH 2743/4072] Prevent pip version checks when calling `pip freeze` Signed-off-by: Joffrey F --- compose/cli/__init__.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/compose/cli/__init__.py b/compose/cli/__init__.py index 1fe9aab8df4..379059c1a46 100644 --- a/compose/cli/__init__.py +++ b/compose/cli/__init__.py @@ -2,6 +2,7 @@ from __future__ import print_function from __future__ import unicode_literals +import os import subprocess import sys @@ -12,8 +13,12 @@ # 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 + env = os.environ.copy() + env[str('PIP_DISABLE_PIP_VERSION_CHECK')] = str('1') + s_cmd = subprocess.Popen( - ['pip', 'freeze'], stderr=subprocess.PIPE, stdout=subprocess.PIPE + ['pip', 'freeze'], stderr=subprocess.PIPE, stdout=subprocess.PIPE, + env=env ) packages = s_cmd.communicate()[0].splitlines() dockerpy_installed = len( From dd862b28c035cf5a165575241f2607c5f7dc0abc Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 5 Apr 2017 13:57:56 -0700 Subject: [PATCH 2744/4072] 1.13.0dev Signed-off-by: Joffrey F --- CHANGELOG.md | 126 ++++++++++++++++++++++++++++++++++++++++++++ compose/__init__.py | 2 +- 2 files changed, 127 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c07e9ca2ce..8d49ddea792 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,132 @@ Change log ========== +1.12.0 (2017-04-04) +------------------- + +### New features + +#### Compose file version 3.2 + +- Introduced version 3.2 of the `docker-compose.yml` specification. + +- Added support for `cache_from` in the `build` section of services + +- Added support for the new expanded ports syntax in service definitions + +- Added support for the new expanded volumes syntax in service definitions + +#### Compose file version 2.1 + +- Added support for `pids_limit` in service definitions + +#### Compose file version 2.0 and up + +- Added `--volumes` option to `docker-compose config` that lists named + volumes declared for that project + +- Added support for `mem_reservation` in service definitions (2.x only) + +- Added support for `dns_opt` in service definitions (2.x only) + +#### All formats + +- Added a new `docker-compose images` command that lists images used by + the current project's containers + +- Added a `--stop` (shorthand `-s`) option to `docker-compose rm` that stops + the running containers before removing them + +- Added a `--resolve-image-digests` option to `docker-compose config` that + pins the image version for each service to a permanent digest + +- Added a `--exit-code-from SERVICE` option to `docker-compose up`. When + used, `docker-compose` will exit on any container's exit with the code + corresponding to the specified service's exit code + +- Added a `--parallel` option to `docker-compose pull` that enables images + for multiple services to be pulled simultaneously + +- Added a `--build-arg` option to `docker-compose build` + +- Added a `--volume ` (shorthand `-v`) option to + `docker-compose run` to declare runtime volumes to be mounted + +- Added a `--project-directory PATH` option to `docker-compose` that will + affect path resolution for the project + +- When using `--abort-on-container-exit` in `docker-compose up`, the exit + code for the container that caused the abort will be the exit code of + the `docker-compose up` command + +- Users can now configure which path separator character they want to use + to separate the `COMPOSE_FILE` environment value using the + `COMPOSE_PATH_SEPARATOR` environment variable + +- Added support for port range to single port in port mappings + (e.g. `8000-8010:80`) + +### Bugfixes + +- `docker-compose run --rm` now removes anonymous volumes after execution, + matching the behavior of `docker run --rm`. + +- Fixed a bug where override files containing port lists would cause a + TypeError to be raised + +- Fixed a bug where the `deploy` key would be missing from the output of + `docker-compose config` + +- Fixed a bug where scaling services up or down would sometimes re-use + obsolete containers + +- Fixed a bug where the output of `docker-compose config` would be invalid + if the project declared anonymous volumes + +- Variable interpolation now properly occurs in the `secrets` section of + the Compose file + +- The `secrets` section now properly appears in the output of + `docker-compose config` + +- Fixed a bug where changes to some networks properties would not be + detected against previously created networks + +- Fixed a bug where `docker-compose` would crash when trying to write into + a closed pipe + +- Fixed an issue where Compose would not pick up on the value of + COMPOSE_TLS_VERSION when used in combination with command-line TLS flags + +1.11.2 (2017-02-17) +------------------- + +### Bugfixes + +- Fixed a bug that was preventing secrets configuration from being + loaded properly + +- Fixed a bug where the `docker-compose config` command would fail + if the config file contained secrets definitions + +- Fixed an issue where Compose on some linux distributions would + pick up and load an outdated version of the requests library + +- Fixed an issue where socket-type files inside a build folder + would cause `docker-compose` to crash when trying to build that + service + +- Fixed an issue where recursive wildcard patterns `**` were not being + recognized in `.dockerignore` files. + +1.11.1 (2017-02-09) +------------------- + +### Bugfixes + +- Fixed a bug where the 3.1 file format was not being recognized as valid + by the Compose parser + 1.11.0 (2017-02-08) ------------------- diff --git a/compose/__init__.py b/compose/__init__.py index b2ca86f86fc..d387af24edf 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.12.0dev' +__version__ = '1.13.0dev' From 3b02426236787032185feafb507da480e6d2df62 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 7 Apr 2017 12:19:37 -0700 Subject: [PATCH 2745/4072] Fix Config.find for Compose files in nested folders Signed-off-by: Joffrey F --- compose/config/config.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 3292845f561..0c514763ae7 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -234,10 +234,10 @@ def with_abs_paths(cls, working_dir, filename, name, config): config) -def find(base_dir, filenames, environment, override_dir='.'): +def find(base_dir, filenames, environment, override_dir=None): if filenames == ['-']: return ConfigDetails( - os.path.abspath(override_dir), + os.path.abspath(override_dir) if override_dir else os.getcwd(), [ConfigFile(None, yaml.safe_load(sys.stdin))], environment ) @@ -249,7 +249,7 @@ def find(base_dir, filenames, environment, override_dir='.'): log.debug("Using configuration files: {}".format(",".join(filenames))) return ConfigDetails( - override_dir or os.path.dirname(filenames[0]), + override_dir if override_dir else os.path.dirname(filenames[0]), [ConfigFile.from_filename(f) for f in filenames], environment ) From 72a2ea9d8605b6b1b204c2347e3a4ed687d63730 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 7 Apr 2017 16:52:46 -0700 Subject: [PATCH 2746/4072] Fix serializer bug (python 3) Signed-off-by: Joffrey F --- compose/config/serialize.py | 8 ++++---- tests/unit/config/config_test.py | 12 ++++++++++++ 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/compose/config/serialize.py b/compose/config/serialize.py index 5b36124d0fa..e364117e71b 100644 --- a/compose/config/serialize.py +++ b/compose/config/serialize.py @@ -111,9 +111,9 @@ def denormalize_service_dict(service_dict, version, image_digest=None): ) if 'ports' in service_dict and version not in (V3_2,): - service_dict['ports'] = map( - lambda p: p.legacy_repr() if isinstance(p, types.ServicePort) else p, - service_dict['ports'] - ) + service_dict['ports'] = [ + p.legacy_repr() if isinstance(p, types.ServicePort) else p + for p in service_dict['ports'] + ] return service_dict diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index b7e4cc9bfac..6bf4986ff56 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -3837,3 +3837,15 @@ def test_serialize_secrets(self): serialized_service = serialized_config['services']['web'] assert secret_sort(serialized_service['secrets']) == secret_sort(service_dict['secrets']) assert 'secrets' in serialized_config + + def test_serialize_ports(self): + config_dict = config.Config(version='2.0', services=[ + { + 'ports': [types.ServicePort('80', '8080', None, None, None)], + 'image': 'alpine', + 'name': 'web' + } + ], volumes={}, networks={}, secrets={}) + + serialized_config = yaml.load(serialize_config(config_dict)) + assert '8080:80/tcp' in serialized_config['services']['web']['ports'] From 5a4293848cb970dac1ca17074a3bd14b2eb2268b Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 7 Apr 2017 19:17:11 -0700 Subject: [PATCH 2747/4072] Add 2.2 schema for API 1.25 features support Signed-off-by: Joffrey F --- compose/config/config_schema_v2.2.json | 386 +++++++++++++++++++++++++ compose/config/serialize.py | 3 +- compose/const.py | 3 + docker-compose.spec | 5 + 4 files changed, 396 insertions(+), 1 deletion(-) create mode 100644 compose/config/config_schema_v2.2.json diff --git a/compose/config/config_schema_v2.2.json b/compose/config/config_schema_v2.2.json new file mode 100644 index 00000000000..daa07d149e2 --- /dev/null +++ b/compose/config/config_schema_v2.2.json @@ -0,0 +1,386 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "id": "config_schema_v2.2.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": { + "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_opt": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + }, + "dns": {"$ref": "#/definitions/string_or_list"}, + "dns_search": {"$ref": "#/definitions/string_or_list"}, + "domainname": {"type": "string"}, + "entrypoint": { + "oneOf": [ + {"type": "string"}, + {"type": "array", "items": {"type": "string"}} + ] + }, + "env_file": {"$ref": "#/definitions/string_or_list"}, + "environment": {"$ref": "#/definitions/list_or_dict"}, + + "expose": { + "type": "array", + "items": { + "type": ["string", "number"], + "format": "expose" + }, + "uniqueItems": true + }, + + "extends": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + + "properties": { + "service": {"type": "string"}, + "file": {"type": "string"} + }, + "required": ["service"], + "additionalProperties": false + } + ] + }, + + "external_links": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "extra_hosts": {"$ref": "#/definitions/list_or_dict"}, + "healthcheck": {"$ref": "#/definitions/healthcheck"}, + "hostname": {"type": "string"}, + "image": {"type": "string"}, + "init": {"type": ["boolean", "string"]}, + "ipc": {"type": "string"}, + "isolation": {"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"]}, + "mem_reservation": {"type": ["string", "integer"]}, + "mem_swappiness": {"type": "integer"}, + "memswap_limit": {"type": ["number", "string"]}, + "network_mode": {"type": "string"}, + + "networks": { + "oneOf": [ + {"$ref": "#/definitions/list_of_strings"}, + { + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "oneOf": [ + { + "type": "object", + "properties": { + "aliases": {"$ref": "#/definitions/list_of_strings"}, + "ipv4_address": {"type": "string"}, + "ipv6_address": {"type": "string"}, + "link_local_ips": {"$ref": "#/definitions/list_of_strings"} + }, + "additionalProperties": false + }, + {"type": "null"} + ] + } + }, + "additionalProperties": false + } + ] + }, + "oom_score_adj": {"type": "integer", "minimum": -1000, "maximum": 1000}, + "group_add": { + "type": "array", + "items": { + "type": ["string", "number"] + }, + "uniqueItems": true + }, + "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"]}, + "sysctls": {"$ref": "#/definitions/list_or_dict"}, + "pids_limit": {"type": ["number", "string"]}, + "stdin_open": {"type": "boolean"}, + "stop_grace_period": {"type": "string", "format": "duration"}, + "stop_signal": {"type": "string"}, + "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}, + "volume_driver": {"type": "string"}, + "volumes_from": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "working_dir": {"type": "string"} + }, + + "dependencies": { + "memswap_limit": ["mem_limit"] + }, + "additionalProperties": false + }, + + "healthcheck": { + "id": "#/definitions/healthcheck", + "type": "object", + "additionalProperties": false, + "properties": { + "disable": {"type": "boolean"}, + "interval": {"type": "string"}, + "retries": {"type": "number"}, + "test": { + "oneOf": [ + {"type": "string"}, + {"type": "array", "items": {"type": "string"}} + ] + }, + "timeout": {"type": "string"} + } + }, + + "network": { + "id": "#/definitions/network", + "type": "object", + "properties": { + "driver": {"type": "string"}, + "driver_opts": { + "type": "object", + "patternProperties": { + "^.+$": {"type": ["string", "number"]} + } + }, + "ipam": { + "type": "object", + "properties": { + "driver": {"type": "string"}, + "config": { + "type": "array" + } + }, + "additionalProperties": false + }, + "external": { + "type": ["boolean", "object"], + "properties": { + "name": {"type": "string"} + }, + "additionalProperties": false + }, + "internal": {"type": "boolean"}, + "enable_ipv6": {"type": "boolean"}, + "labels": {"$ref": "#/definitions/list_or_dict"} + }, + "additionalProperties": false + }, + + "volume": { + "id": "#/definitions/volume", + "type": ["object", "null"], + "properties": { + "driver": {"type": "string"}, + "driver_opts": { + "type": "object", + "patternProperties": { + "^.+$": {"type": ["string", "number"]} + } + }, + "external": { + "type": ["boolean", "object"], + "properties": { + "name": {"type": "string"} + }, + "additionalProperties": false + }, + "labels": {"$ref": "#/definitions/list_or_dict"} + }, + "additionalProperties": false + }, + + "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 5b36124d0fa..979de4bf749 100644 --- a/compose/config/serialize.py +++ b/compose/config/serialize.py @@ -7,6 +7,7 @@ from compose.config import types from compose.const import COMPOSEFILE_V1 as V1 from compose.const import COMPOSEFILE_V2_1 as V2_1 +from compose.const import COMPOSEFILE_V2_1 as V2_2 from compose.const import COMPOSEFILE_V3_1 as V3_1 from compose.const import COMPOSEFILE_V3_1 as V3_2 @@ -95,7 +96,7 @@ def denormalize_service_dict(service_dict, version, image_digest=None): 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: + if 'depends_on' in service_dict and version not in (V2_1, V2_2): service_dict['depends_on'] = sorted([ svc for svc in service_dict['depends_on'].keys() ]) diff --git a/compose/const.py b/compose/const.py index 8de69344509..573136d5d18 100644 --- a/compose/const.py +++ b/compose/const.py @@ -21,6 +21,7 @@ COMPOSEFILE_V1 = '1' COMPOSEFILE_V2_0 = '2.0' COMPOSEFILE_V2_1 = '2.1' +COMPOSEFILE_V2_2 = '2.2' COMPOSEFILE_V3_0 = '3.0' COMPOSEFILE_V3_1 = '3.1' @@ -30,6 +31,7 @@ COMPOSEFILE_V1: '1.21', COMPOSEFILE_V2_0: '1.22', COMPOSEFILE_V2_1: '1.24', + COMPOSEFILE_V2_2: '1.25', COMPOSEFILE_V3_0: '1.25', COMPOSEFILE_V3_1: '1.25', COMPOSEFILE_V3_2: '1.25', @@ -39,6 +41,7 @@ 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_V2_2]: '1.13.0', API_VERSIONS[COMPOSEFILE_V3_0]: '1.13.0', API_VERSIONS[COMPOSEFILE_V3_1]: '1.13.0', API_VERSIONS[COMPOSEFILE_V3_2]: '1.13.0', diff --git a/docker-compose.spec b/docker-compose.spec index f4280dd425b..21b3c1742cf 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_v2.2.json', + 'compose/config/config_schema_v2.2.json', + 'DATA' + ), ( 'compose/config/config_schema_v3.0.json', 'compose/config/config_schema_v3.0.json', From cc966a7e19f288fb1f91e61ec76670e72b885d4d Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 7 Apr 2017 20:01:33 -0700 Subject: [PATCH 2748/4072] Add support for init and init_path Signed-off-by: Joffrey F --- compose/config/config.py | 1 + compose/service.py | 24 +++++++++++++----------- tests/integration/service_test.py | 16 ++++++++++++++++ tests/integration/testcases.py | 6 +++--- 4 files changed, 33 insertions(+), 14 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 3292845f561..e8fbe708377 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -108,6 +108,7 @@ 'log_opt', 'logging', 'network_mode', + 'init', ] DOCKER_VALID_URL_PREFIXES = ( diff --git a/compose/service.py b/compose/service.py index b9f77beb9e5..f8c549381de 100644 --- a/compose/service.py +++ b/compose/service.py @@ -48,7 +48,7 @@ log = logging.getLogger(__name__) -DOCKER_START_KEYS = [ +HOST_CONFIG_KEYS = [ 'cap_add', 'cap_drop', 'cgroup_parent', @@ -60,6 +60,7 @@ 'env_file', 'extra_hosts', 'group_add', + 'init', 'ipc', 'read_only', 'log_driver', @@ -729,8 +730,8 @@ def _get_container_create_options( number, self.config_hash if add_config_hash else None) - # Delete options which are only used when starting - for key in DOCKER_START_KEYS: + # Delete options which are only used in HostConfig + for key in HOST_CONFIG_KEYS: container_options.pop(key, None) container_options['host_config'] = self._get_container_host_config( @@ -750,8 +751,12 @@ def _get_container_host_config(self, override_options, one_off=False): logging_dict = options.get('logging', None) log_config = get_log_config(logging_dict) + init_path = None + if isinstance(options.get('init'), six.string_types): + init_path = options.get('init') + options['init'] = True - host_config = self.client.create_host_config( + return self.client.create_host_config( links=self._get_links(link_to_self=one_off), port_bindings=build_port_bindings( formatted_ports(options.get('ports', [])) @@ -786,15 +791,12 @@ def _get_container_host_config(self, override_options, one_off=False): oom_score_adj=options.get('oom_score_adj'), mem_swappiness=options.get('mem_swappiness'), group_add=options.get('group_add'), - userns_mode=options.get('userns_mode') + userns_mode=options.get('userns_mode'), + init=options.get('init', None), + init_path=init_path, + isolation=options.get('isolation'), ) - # 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 get_secret_volumes(self): def build_spec(secret): target = '{}/{}'.format( diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 12ec8a9934a..63607175583 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -4,6 +4,7 @@ import os import shutil import tempfile +from distutils.spawn import find_executable from os import path import pytest @@ -115,6 +116,21 @@ def test_create_container_with_shm_size(self): service.start_container(container) self.assertEqual(container.get('HostConfig.ShmSize'), 67108864) + def test_create_container_with_init_bool(self): + self.require_api_version('1.25') + service = self.create_service('db', init=True) + container = service.create_container() + service.start_container(container) + assert container.get('HostConfig.Init') is True + + def test_create_container_with_init_path(self): + self.require_api_version('1.25') + docker_init_path = find_executable('docker-init') + service = self.create_service('db', init=docker_init_path) + container = service.create_container() + service.start_container(container) + assert container.get('HostConfig.InitPath') == docker_init_path + @pytest.mark.xfail(True, reason='Some kernels/configs do not support pids_limit') def test_create_container_with_pids_limit(self): self.require_api_version('1.23') diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index 38fdcc6605b..a5fe999d979 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -15,7 +15,7 @@ from compose.const import COMPOSEFILE_V1 as V1 from compose.const import COMPOSEFILE_V2_0 as V2_0 from compose.const import COMPOSEFILE_V2_0 as V2_1 -from compose.const import COMPOSEFILE_V3_0 as V3_0 +from compose.const import COMPOSEFILE_V3_2 as V3_2 from compose.const import LABEL_PROJECT from compose.progress_stream import stream_output from compose.service import Service @@ -37,7 +37,7 @@ def format_link(link): def engine_max_version(): if 'DOCKER_VERSION' not in os.environ: - return V3_0 + return V3_2 version = os.environ['DOCKER_VERSION'].partition('-')[0] if version_lt(version, '1.10'): return V1 @@ -45,7 +45,7 @@ def engine_max_version(): return V2_0 if version_lt(version, '1.13'): return V2_1 - return V3_0 + return V3_2 def build_version_required_decorator(ignored_versions): From 843a0d82a0a0664ebb9cff8a221bde81f6fe8eb6 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 7 Apr 2017 19:02:39 -0700 Subject: [PATCH 2749/4072] Add support for IPAM options in v2 format Signed-off-by: Joffrey F --- compose/config/config_schema_v2.0.json | 7 ++++++ compose/config/config_schema_v2.1.json | 7 ++++++ compose/network.py | 7 ++++++ tests/integration/project_test.py | 35 ++++++++++++++++++++++++++ 4 files changed, 56 insertions(+) diff --git a/compose/config/config_schema_v2.0.json b/compose/config/config_schema_v2.0.json index 54e9b331444..f3688685b39 100644 --- a/compose/config/config_schema_v2.0.json +++ b/compose/config/config_schema_v2.0.json @@ -253,6 +253,13 @@ "driver": {"type": "string"}, "config": { "type": "array" + }, + "options": { + "type": "object", + "patternProperties": { + "^.+$": {"type": "string"} + }, + "additionalProperties": false } }, "additionalProperties": false diff --git a/compose/config/config_schema_v2.1.json b/compose/config/config_schema_v2.1.json index 0f87be24eee..aa59d181ee5 100644 --- a/compose/config/config_schema_v2.1.json +++ b/compose/config/config_schema_v2.1.json @@ -298,6 +298,13 @@ "driver": {"type": "string"}, "config": { "type": "array" + }, + "options": { + "type": "object", + "patternProperties": { + "^.+$": {"type": "string"} + }, + "additionalProperties": false } }, "additionalProperties": false diff --git a/compose/network.py b/compose/network.py index 053fdacd87f..4aeff2d1e5c 100644 --- a/compose/network.py +++ b/compose/network.py @@ -123,6 +123,7 @@ def create_ipam_config_from_dict(ipam_dict): ) for config in ipam_dict.get('config', []) ], + options=ipam_dict.get('options') ) @@ -157,6 +158,12 @@ def check_remote_ipam_config(remote, local): if sorted(lc.get('AuxiliaryAddresses')) != sorted(rc.get('AuxiliaryAddresses')): raise NetworkConfigChangedError(local.full_name, 'IPAM config aux_addresses') + remote_opts = remote_ipam.get('Options', {}) + local_opts = local.ipam.get('options', {}) + for k in set.union(set(remote_opts.keys()), set(local_opts.keys())): + if remote_opts.get(k) != local_opts.get(k): + raise NetworkConfigChangedError(local.full_name, 'IPAM option "{}"'.format(k)) + def check_remote_network_config(remote, local): if local.driver and remote.get('Driver') != local.driver: diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 4551898516f..2eae88ec547 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -681,6 +681,41 @@ def test_up_with_ipam_config(self): }], } + @v2_only() + def test_up_with_ipam_options(self): + config_data = build_config( + version=V2_0, + services=[{ + 'name': 'web', + 'image': 'busybox:latest', + 'networks': {'front': None}, + }], + networks={ + 'front': { + 'driver': 'bridge', + 'ipam': { + 'driver': 'default', + 'options': { + "com.docker.compose.network.test": "9-29-045" + } + }, + }, + }, + ) + + 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['IPAM']['Options'] == { + "com.docker.compose.network.test": "9-29-045" + } + @v2_only() def test_up_with_network_static_addresses(self): config_data = build_config( From 0f00aa409861f4f7820a21055d25ec1f312db7d0 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 10 Apr 2017 17:32:10 -0700 Subject: [PATCH 2750/4072] Convert paths to unicode in get_config_path_from_options if needed Signed-off-by: Joffrey F --- compose/cli/command.py | 7 +++++-- tests/unit/cli/command_test.py | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/compose/cli/command.py b/compose/cli/command.py index ccc76ceb4e0..e1ae690c0eb 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -49,14 +49,17 @@ def get_config_from_options(base_dir, options): def get_config_path_from_options(base_dir, options, environment): + def unicode_paths(paths): + return [p.decode('utf-8') if isinstance(p, six.binary_type) else p for p in paths] + file_option = options.get('--file') if file_option: - return file_option + return unicode_paths(file_option) config_files = environment.get('COMPOSE_FILE') if config_files: pathsep = environment.get('COMPOSE_PATH_SEPARATOR', os.pathsep) - return config_files.split(pathsep) + return unicode_paths(config_files.split(pathsep)) return None diff --git a/tests/unit/cli/command_test.py b/tests/unit/cli/command_test.py index c64a0401b62..3a9844c4f7c 100644 --- a/tests/unit/cli/command_test.py +++ b/tests/unit/cli/command_test.py @@ -1,9 +1,11 @@ +# ~*~ encoding: utf-8 ~*~ from __future__ import absolute_import from __future__ import unicode_literals import os import pytest +import six from compose.cli.command import get_config_path_from_options from compose.config.environment import Environment @@ -55,3 +57,20 @@ def test_multiple_path_from_env_custom_separator(self): def test_no_path(self): environment = Environment.from_env_file('.') assert not get_config_path_from_options('.', {}, environment) + + def test_unicode_path_from_options(self): + paths = [b'\xe5\xb0\xb1\xe5\x90\x83\xe9\xa5\xad/docker-compose.yml'] + opts = {'--file': paths} + environment = Environment.from_env_file('.') + assert get_config_path_from_options( + '.', opts, environment + ) == ['就吃饭/docker-compose.yml'] + + @pytest.mark.skipif(six.PY3, reason='Env values in Python 3 are already Unicode') + def test_unicode_path_from_env(self): + with mock.patch.dict(os.environ): + os.environ['COMPOSE_FILE'] = b'\xe5\xb0\xb1\xe5\x90\x83\xe9\xa5\xad/docker-compose.yml' + environment = Environment.from_env_file('.') + assert get_config_path_from_options( + '.', {}, environment + ) == ['就吃饭/docker-compose.yml'] From 1891b2b78ce977512885b289fe310221ce88efae Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 11 Apr 2017 14:45:14 -0700 Subject: [PATCH 2751/4072] Fix ServicePort.legacy_repr bug for `ext_ip::target` notation Signed-off-by: Joffrey F --- compose/config/types.py | 4 ++-- tests/unit/config/types_test.py | 10 ++++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/compose/config/types.py b/compose/config/types.py index 96846b5ba69..dd61a879630 100644 --- a/compose/config/types.py +++ b/compose/config/types.py @@ -267,7 +267,7 @@ class ServicePort(namedtuple('_ServicePort', 'target published protocol mode ext @classmethod def parse(cls, spec): if isinstance(spec, cls): - # WHen extending a service with ports, the port definitions have already been parsed + # When extending a service with ports, the port definitions have already been parsed return [spec] if not isinstance(spec, dict): @@ -316,7 +316,7 @@ def legacy_repr(self): def normalize_port_dict(port): return '{external_ip}{has_ext_ip}{published}{is_pub}{target}/{protocol}'.format( published=port.get('published', ''), - is_pub=(':' if port.get('published') else ''), + is_pub=(':' if port.get('published') or port.get('external_ip') else ''), target=port.get('target'), protocol=port.get('protocol', 'tcp'), external_ip=port.get('external_ip', ''), diff --git a/tests/unit/config/types_test.py b/tests/unit/config/types_test.py index 66588d62942..83d6270d28b 100644 --- a/tests/unit/config/types_test.py +++ b/tests/unit/config/types_test.py @@ -71,6 +71,16 @@ def test_parse_complete_port_definition(self): } assert ports[0].legacy_repr() == port_def + def test_parse_ext_ip_no_published_port(self): + port_def = '1.1.1.1::3000' + ports = ServicePort.parse(port_def) + assert len(ports) == 1 + assert ports[0].legacy_repr() == port_def + '/tcp' + assert ports[0].repr() == { + 'target': '3000', + 'external_ip': '1.1.1.1', + } + def test_parse_port_range(self): ports = ServicePort.parse('25000-25001:4000-4001') assert len(ports) == 2 From c817dedef77f1741a3e9361b525184e26f5ec4d9 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 13 Apr 2017 16:52:40 -0700 Subject: [PATCH 2752/4072] Repair bad imports Signed-off-by: Joffrey F --- compose/config/serialize.py | 4 ++-- tests/acceptance/cli_test.py | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/compose/config/serialize.py b/compose/config/serialize.py index caf4f1fbef5..aaaf053995c 100644 --- a/compose/config/serialize.py +++ b/compose/config/serialize.py @@ -7,9 +7,9 @@ from compose.config import types from compose.const import COMPOSEFILE_V1 as V1 from compose.const import COMPOSEFILE_V2_1 as V2_1 -from compose.const import COMPOSEFILE_V2_1 as V2_2 +from compose.const import COMPOSEFILE_V2_2 as V2_2 from compose.const import COMPOSEFILE_V3_1 as V3_1 -from compose.const import COMPOSEFILE_V3_1 as V3_2 +from compose.const import COMPOSEFILE_V3_2 as V3_2 def serialize_config_type(dumper, data): diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index bceb102a2ca..bfc96340210 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -323,6 +323,7 @@ def test_config_v3(self): assert yaml.load(result.stdout) == { 'version': '3.2', 'networks': {}, + 'secrets': {}, 'volumes': { 'foobar': { 'labels': { From 1bd9083de670f8402d5a066a1c84025e81bcdf76 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 14 Apr 2017 15:30:40 -0700 Subject: [PATCH 2753/4072] Do not wait for exec output when using detached mode Signed-off-by: Joffrey F --- 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 84cae9f539f..53ff84f4537 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -439,7 +439,7 @@ def exec_command(self, options): exec_id = container.create_exec(command, **create_exec_options) if detach: - container.start_exec(exec_id, tty=tty) + container.start_exec(exec_id, tty=tty, stream=True) return signals.set_signal_handler_to_shutdown() From 78ee6123330e9ae71b6ef3d82035d2d87b00dca7 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 17 Apr 2017 19:03:56 -0700 Subject: [PATCH 2754/4072] Implement --scale option on up command, allow scale config in v2.2 format docker-compose scale modified to reuse code between up and scale Signed-off-by: Joffrey F --- compose/cli/main.py | 36 +++-- compose/config/config_schema_v2.2.json | 1 + compose/parallel.py | 4 - compose/project.py | 20 ++- compose/service.py | 192 +++++++++++++----------- tests/acceptance/cli_test.py | 27 ++++ tests/fixtures/scale/docker-compose.yml | 9 ++ tests/integration/project_test.py | 28 ++++ 8 files changed, 211 insertions(+), 106 deletions(-) create mode 100644 tests/fixtures/scale/docker-compose.yml diff --git a/compose/cli/main.py b/compose/cli/main.py index 53ff84f4537..e018a0174b4 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -771,15 +771,7 @@ def scale(self, options): """ timeout = timeout_from_opts(options) - for s in options['SERVICE=NUM']: - if '=' not in s: - raise UserError('Arguments to scale should be in the form service=num') - service_name, num = s.split('=', 1) - try: - num = int(num) - except ValueError: - raise UserError('Number of containers for service "%s" is not a ' - 'number' % service_name) + for service_name, num in parse_scale_args(options['SERVICE=NUM']).items(): self.project.get_service(service_name).scale(num, timeout=timeout) def start(self, options): @@ -875,7 +867,7 @@ def up(self, options): If you want to force Compose to stop and recreate all containers, use the `--force-recreate` flag. - Usage: up [options] [SERVICE...] + Usage: up [options] [--scale SERVICE=NUM...] [SERVICE...] Options: -d Detached mode: Run containers in the background, @@ -898,7 +890,9 @@ def up(self, options): --remove-orphans Remove containers for services not defined in the Compose file --exit-code-from SERVICE Return the exit code of the selected service container. - Requires --abort-on-container-exit. + Implies --abort-on-container-exit. + --scale SERVICE=NUM Scale SERVICE to NUM instances. Overrides the `scale` + setting in the Compose file if present. """ start_deps = not options['--no-deps'] exit_value_from = exitval_from_opts(options, self.project) @@ -919,7 +913,9 @@ def up(self, options): do_build=build_action_from_opts(options), timeout=timeout, detached=detached, - remove_orphans=remove_orphans) + remove_orphans=remove_orphans, + scale_override=parse_scale_args(options['--scale']), + ) if detached: return @@ -1238,3 +1234,19 @@ def call_docker(args): log.debug(" ".join(map(pipes.quote, args))) return subprocess.call(args) + + +def parse_scale_args(options): + res = {} + for s in options: + if '=' not in s: + raise UserError('Arguments to scale should be in the form service=num') + service_name, num = s.split('=', 1) + try: + num = int(num) + except ValueError: + raise UserError( + 'Number of containers for service "%s" is not a number' % service_name + ) + res[service_name] = num + return res diff --git a/compose/config/config_schema_v2.2.json b/compose/config/config_schema_v2.2.json index daa07d149e2..390d3efa96f 100644 --- a/compose/config/config_schema_v2.2.json +++ b/compose/config/config_schema_v2.2.json @@ -222,6 +222,7 @@ "privileged": {"type": "boolean"}, "read_only": {"type": "boolean"}, "restart": {"type": "string"}, + "scale": {"type": "integer"}, "security_opt": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, "shm_size": {"type": ["number", "string"]}, "sysctls": {"$ref": "#/definitions/list_or_dict"}, diff --git a/compose/parallel.py b/compose/parallel.py index fde723f336c..34fef71db75 100644 --- a/compose/parallel.py +++ b/compose/parallel.py @@ -260,10 +260,6 @@ def parallel_remove(containers, options): parallel_operation(stopped_containers, 'remove', options, 'Removing') -def parallel_start(containers, options): - parallel_operation(containers, 'start', options, 'Starting') - - def parallel_pause(containers, options): parallel_operation(containers, 'pause', options, 'Pausing') diff --git a/compose/project.py b/compose/project.py index a75d71efc71..85322876443 100644 --- a/compose/project.py +++ b/compose/project.py @@ -380,13 +380,17 @@ def up(self, do_build=BuildAction.none, timeout=None, detached=False, - remove_orphans=False): + remove_orphans=False, + scale_override=None): warn_for_swarm_mode(self.client) self.initialize() self.find_orphan_containers(remove_orphans) + if scale_override is None: + scale_override = {} + services = self.get_services_without_duplicate( service_names, include_deps=start_deps) @@ -399,7 +403,8 @@ def do(service): return service.execute_convergence_plan( plans[service.name], timeout=timeout, - detached=detached + detached=detached, + scale_override=scale_override.get(service.name) ) def get_deps(service): @@ -589,10 +594,13 @@ def get_secrets(service, service_secrets, secret_defs): 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)) + 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')}) diff --git a/compose/service.py b/compose/service.py index f8c549381de..4fa3aeabf94 100644 --- a/compose/service.py +++ b/compose/service.py @@ -38,7 +38,6 @@ from .errors import NoHealthCheckConfigured from .errors import OperationFailedError from .parallel import parallel_execute -from .parallel import parallel_start from .progress_stream import stream_output from .progress_stream import StreamOutputError from .utils import json_hash @@ -148,6 +147,7 @@ def __init__( network_mode=None, networks=None, secrets=None, + scale=None, **options ): self.name = name @@ -159,6 +159,7 @@ def __init__( self.network_mode = network_mode or NetworkMode(None) self.networks = networks or {} self.secrets = secrets or [] + self.scale_num = scale or 1 self.options = options def __repr__(self): @@ -189,16 +190,7 @@ def start(self, **options): self.start_container_if_stopped(c, **options) return containers - def scale(self, desired_num, timeout=None): - """ - Adjusts the number of containers to the specified number and ensures - they are running. - - - creates containers until there are at least `desired_num` - - stops containers until there are at most `desired_num` running - - starts containers until there are at least `desired_num` running - - removes all stopped containers - """ + def show_scale_warnings(self, desired_num): 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. ' @@ -210,14 +202,18 @@ def scale(self, desired_num, timeout=None): 'for this service are created on a single host, the port will clash.' % self.name) - def create_and_start(service, number): - container = service.create_container(number=number, quiet=True) - service.start_container(container) - return container + def scale(self, desired_num, timeout=None): + """ + Adjusts the number of containers to the specified number and ensures + they are running. - def stop_and_remove(container): - container.stop(timeout=self.stop_timeout(timeout)) - container.remove() + - creates containers until there are at least `desired_num` + - stops containers until there are at most `desired_num` running + - starts containers until there are at least `desired_num` running + - removes all stopped containers + """ + + self.show_scale_warnings(desired_num) running_containers = self.containers(stopped=False) num_running = len(running_containers) @@ -228,11 +224,10 @@ def stop_and_remove(container): return if desired_num > num_running: - # we need to start/create until we have desired_num all_containers = self.containers(stopped=True) if num_running != len(all_containers): - # we have some stopped containers, let's start them up again + # we have some stopped containers, check for divergences stopped_containers = [ c for c in all_containers if not c.is_running ] @@ -241,38 +236,14 @@ def stop_and_remove(container): divergent_containers = [ c for c in stopped_containers if self._containers_have_diverged([c]) ] - stopped_containers = sorted( - set(stopped_containers) - set(divergent_containers), - key=attrgetter('number') - ) for c in divergent_containers: c.remove() - num_stopped = len(stopped_containers) - - if num_stopped + num_running > desired_num: - num_to_start = desired_num - num_running - containers_to_start = stopped_containers[:num_to_start] - else: - containers_to_start = stopped_containers - - parallel_start(containers_to_start, {}) - - num_running += len(containers_to_start) + all_containers = list(set(all_containers) - set(divergent_containers)) - num_to_create = desired_num - num_running - next_number = self._next_container_number() - container_numbers = [ - number for number in range( - next_number, next_number + num_to_create - ) - ] - - parallel_execute( - container_numbers, - lambda n: create_and_start(service=self, number=n), - lambda n: self.get_container_name(n), - "Creating and starting" + sorted_containers = sorted(all_containers, key=attrgetter('number')) + self._execute_convergence_start( + sorted_containers, desired_num, timeout, True, True ) if desired_num < num_running: @@ -282,12 +253,7 @@ def stop_and_remove(container): running_containers, key=attrgetter('number')) - parallel_execute( - sorted_running_containers[-num_to_stop:], - stop_and_remove, - lambda c: c.name, - "Stopping and removing", - ) + self._downscale(sorted_running_containers[-num_to_stop:], timeout) def create_container(self, one_off=False, @@ -400,51 +366,109 @@ def _containers_have_diverged(self, containers): return has_diverged - def execute_convergence_plan(self, - plan, - timeout=None, - detached=False, - start=True): - (action, containers) = plan - should_attach_logs = not detached - - if action == 'create': - container = self.create_container() + def _execute_convergence_create(self, scale, detached, start): + i = self._next_container_number() - if should_attach_logs: - container.attach_log_stream() + def create_and_start(service, n): + container = service.create_container(number=n) + if not detached: + container.attach_log_stream() + if start: + self.start_container(container) + return container - if start: - self.start_container(container) + return parallel_execute( + range(i, i + scale), + lambda n: create_and_start(self, n), + lambda n: self.get_container_name(n), + "Creating" + )[0] - return [container] + def _execute_convergence_recreate(self, containers, scale, timeout, detached, start): + if len(containers) > scale: + self._downscale(containers[scale:], timeout) + containers = containers[:scale] - elif action == 'recreate': - return [ - self.recreate_container( - container, - timeout=timeout, - attach_logs=should_attach_logs, + def recreate(container): + return self.recreate_container( + container, timeout=timeout, attach_logs=not detached, start_new_container=start ) - for container in containers - ] + containers = parallel_execute( + containers, + recreate, + lambda c: c.name, + "Recreating" + )[0] + if len(containers) < scale: + containers.extend(self._execute_convergence_create( + scale - len(containers), detached, start + )) + return containers - elif action == 'start': + def _execute_convergence_start(self, containers, scale, timeout, detached, start): + if len(containers) > scale: + self._downscale(containers[scale:], timeout) + containers = containers[:scale] if start: - for container in containers: - self.start_container_if_stopped(container, attach_logs=should_attach_logs) - + parallel_execute( + containers, + lambda c: self.start_container_if_stopped(c, attach_logs=not detached), + lambda c: c.name, + "Starting" + ) + if len(containers) < scale: + containers.extend(self._execute_convergence_create( + scale - len(containers), detached, start + )) return containers - elif action == 'noop': + def _downscale(self, containers, timeout=None): + def stop_and_remove(container): + container.stop(timeout=self.stop_timeout(timeout)) + container.remove() + + parallel_execute( + containers, + stop_and_remove, + lambda c: c.name, + "Stopping and removing", + ) + + def execute_convergence_plan(self, plan, timeout=None, detached=False, + start=True, scale_override=None): + (action, containers) = plan + scale = scale_override if scale_override is not None else self.scale_num + containers = sorted(containers, key=attrgetter('number')) + + self.show_scale_warnings(scale) + + if action == 'create': + return self._execute_convergence_create( + scale, detached, start + ) + + if action == 'recreate': + return self._execute_convergence_recreate( + containers, scale, timeout, detached, start + ) + + if action == 'start': + return self._execute_convergence_start( + containers, scale, timeout, detached, start + ) + + if action == 'noop': + if scale != len(containers): + return self._execute_convergence_start( + containers, scale, timeout, detached, start + ) for c in containers: log.info("%s is up-to-date" % c.name) return containers - else: - raise Exception("Invalid action: {}".format(action)) + raise Exception("Invalid action: {}".format(action)) def recreate_container( self, diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index bfc96340210..43dc216ba4e 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1866,6 +1866,33 @@ def test_scale(self): self.assertEqual(len(project.get_service('simple').containers()), 0) self.assertEqual(len(project.get_service('another').containers()), 0) + def test_up_scale(self): + self.base_dir = 'tests/fixtures/scale' + project = self.project + self.dispatch(['up', '-d']) + assert len(project.get_service('web').containers()) == 2 + assert len(project.get_service('db').containers()) == 1 + + self.dispatch(['up', '-d', '--scale', 'web=1']) + assert len(project.get_service('web').containers()) == 1 + assert len(project.get_service('db').containers()) == 1 + + self.dispatch(['up', '-d', '--scale', 'web=3']) + assert len(project.get_service('web').containers()) == 3 + assert len(project.get_service('db').containers()) == 1 + + self.dispatch(['up', '-d', '--scale', 'web=1', '--scale', 'db=2']) + assert len(project.get_service('web').containers()) == 1 + assert len(project.get_service('db').containers()) == 2 + + self.dispatch(['up', '-d']) + assert len(project.get_service('web').containers()) == 2 + assert len(project.get_service('db').containers()) == 1 + + self.dispatch(['up', '-d', '--scale', 'web=0', '--scale', 'db=0']) + assert len(project.get_service('web').containers()) == 0 + assert len(project.get_service('db').containers()) == 0 + def test_port(self): self.base_dir = 'tests/fixtures/ports-composefile' self.dispatch(['up', '-d'], None) diff --git a/tests/fixtures/scale/docker-compose.yml b/tests/fixtures/scale/docker-compose.yml new file mode 100644 index 00000000000..a0d3b771f16 --- /dev/null +++ b/tests/fixtures/scale/docker-compose.yml @@ -0,0 +1,9 @@ +version: '2.2' +services: + web: + image: busybox + command: top + scale: 2 + db: + image: busybox + command: top diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 2eae88ec547..afb408c8314 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -19,6 +19,7 @@ from compose.config.types import VolumeSpec from compose.const import COMPOSEFILE_V2_0 as V2_0 from compose.const import COMPOSEFILE_V2_1 as V2_1 +from compose.const import COMPOSEFILE_V2_2 as V2_2 from compose.const import COMPOSEFILE_V3_1 as V3_1 from compose.const import LABEL_PROJECT from compose.const import LABEL_SERVICE @@ -1137,6 +1138,33 @@ def test_project_up_port_mappings_with_multiple_files(self): containers = project.containers() self.assertEqual(len(containers), 1) + def test_project_up_config_scale(self): + config_data = build_config( + version=V2_2, + services=[{ + 'name': 'web', + 'image': 'busybox:latest', + 'command': 'top', + 'scale': 3 + }] + ) + + project = Project.from_config( + name='composetest', config_data=config_data, client=self.client + ) + project.up() + assert len(project.containers()) == 3 + + project.up(scale_override={'web': 2}) + assert len(project.containers()) == 2 + + project.up(scale_override={'web': 4}) + assert len(project.containers()) == 4 + + project.stop() + project.up() + assert len(project.containers()) == 3 + @v2_only() def test_initialize_volumes(self): vol_name = '{0:x}'.format(random.getrandbits(32)) From afb2b6c51c68cb65410f656ce123d97171e8aa64 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 18 Apr 2017 12:53:43 -0700 Subject: [PATCH 2755/4072] Properly relay errors in execute_convergence_plan Signed-off-by: Joffrey F --- compose/service.py | 20 +++++++++++++++----- tests/acceptance/cli_test.py | 2 +- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/compose/service.py b/compose/service.py index 4fa3aeabf94..65eded8ec2f 100644 --- a/compose/service.py +++ b/compose/service.py @@ -377,12 +377,16 @@ def create_and_start(service, n): self.start_container(container) return container - return parallel_execute( + containers, errors = parallel_execute( range(i, i + scale), lambda n: create_and_start(self, n), lambda n: self.get_container_name(n), "Creating" - )[0] + ) + if errors: + raise OperationFailedError(errors.values()[0]) + + return containers def _execute_convergence_recreate(self, containers, scale, timeout, detached, start): if len(containers) > scale: @@ -394,12 +398,14 @@ def recreate(container): container, timeout=timeout, attach_logs=not detached, start_new_container=start ) - containers = parallel_execute( + containers, errors = parallel_execute( containers, recreate, lambda c: c.name, "Recreating" - )[0] + ) + if errors: + raise OperationFailedError(errors.values()[0]) if len(containers) < scale: containers.extend(self._execute_convergence_create( scale - len(containers), detached, start @@ -411,12 +417,16 @@ def _execute_convergence_start(self, containers, scale, timeout, detached, start self._downscale(containers[scale:], timeout) containers = containers[:scale] if start: - parallel_execute( + _, errors = parallel_execute( containers, lambda c: self.start_container_if_stopped(c, attach_logs=not detached), lambda c: c.name, "Starting" ) + + if errors: + raise OperationFailedError(errors.values()[0]) + if len(containers) < scale: containers.extend(self._execute_convergence_create( scale - len(containers), detached, start diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 43dc216ba4e..c4b24b4b5aa 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -151,7 +151,7 @@ def lookup(self, container, hostname): def test_help(self): self.base_dir = 'tests/fixtures/no-composefile' result = self.dispatch(['help', 'up'], returncode=0) - assert 'Usage: up [options] [SERVICE...]' in result.stdout + assert 'Usage: up [options] [--scale SERVICE=NUM...] [SERVICE...]' in result.stdout # Prevent tearDown from trying to create a project self.base_dir = None From 2ba4e5e8ec0b93c7614fa4e2ef8bf3f67a4c0d12 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 19 Apr 2017 16:47:43 -0700 Subject: [PATCH 2756/4072] Prevent `docker-compose scale` to be used with a v2.2 config file Signed-off-by: Joffrey F --- compose/cli/main.py | 7 ++++++ compose/project.py | 5 +++-- compose/service.py | 13 +++++------ tests/acceptance/cli_test.py | 36 ++++++++++++++++++++++++++----- tests/integration/project_test.py | 6 +++--- tests/integration/service_test.py | 18 +++++++++------- 6 files changed, 61 insertions(+), 24 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index e018a0174b4..0fdf3c28a10 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -26,6 +26,7 @@ from ..config.environment import Environment from ..config.serialize import serialize_config from ..config.types import VolumeSpec +from ..const import COMPOSEFILE_V2_2 as V2_2 from ..const import IS_WINDOWS_PLATFORM from ..errors import StreamParseError from ..progress_stream import StreamOutputError @@ -771,6 +772,12 @@ def scale(self, options): """ timeout = timeout_from_opts(options) + if self.project.config_version == V2_2: + raise UserError( + 'The scale command is incompatible with the v2.2 format. ' + 'Use the up command with the --scale flag instead.' + ) + for service_name, num in parse_scale_args(options['SERVICE=NUM']).items(): self.project.get_service(service_name).scale(num, timeout=timeout) diff --git a/compose/project.py b/compose/project.py index 85322876443..e80b10455db 100644 --- a/compose/project.py +++ b/compose/project.py @@ -57,12 +57,13 @@ class Project(object): """ A collection of services. """ - def __init__(self, name, services, client, networks=None, volumes=None): + def __init__(self, name, services, client, networks=None, volumes=None, config_version=None): self.name = name self.services = services self.client = client self.volumes = volumes or ProjectVolumes({}) self.networks = networks or ProjectNetworks({}, False) + self.config_version = config_version def labels(self, one_off=OneOffFilter.exclude): labels = ['{0}={1}'.format(LABEL_PROJECT, self.name)] @@ -82,7 +83,7 @@ def from_config(cls, name, config_data, client): networks, use_networking) volumes = ProjectVolumes.from_config(name, config_data, client) - project = cls(name, [], client, project_networks, volumes) + project = cls(name, [], client, project_networks, volumes, config_data.version) for service_dict in config_data.services: service_dict = dict(service_dict) diff --git a/compose/service.py b/compose/service.py index 65eded8ec2f..e903115af57 100644 --- a/compose/service.py +++ b/compose/service.py @@ -383,8 +383,8 @@ def create_and_start(service, n): lambda n: self.get_container_name(n), "Creating" ) - if errors: - raise OperationFailedError(errors.values()[0]) + for error in errors.values(): + raise OperationFailedError(error) return containers @@ -404,8 +404,9 @@ def recreate(container): lambda c: c.name, "Recreating" ) - if errors: - raise OperationFailedError(errors.values()[0]) + for error in errors.values(): + raise OperationFailedError(error) + if len(containers) < scale: containers.extend(self._execute_convergence_create( scale - len(containers), detached, start @@ -424,8 +425,8 @@ def _execute_convergence_start(self, containers, scale, timeout, detached, start "Starting" ) - if errors: - raise OperationFailedError(errors.values()[0]) + for error in errors.values(): + raise OperationFailedError(error) if len(containers) < scale: containers.extend(self._execute_convergence_create( diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index c4b24b4b5aa..75b15ae65a2 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1866,9 +1866,27 @@ def test_scale(self): self.assertEqual(len(project.get_service('simple').containers()), 0) self.assertEqual(len(project.get_service('another').containers()), 0) - def test_up_scale(self): + def test_scale_v2_2(self): + self.base_dir = 'tests/fixtures/scale' + result = self.dispatch(['scale', 'web=1'], returncode=1) + assert 'incompatible with the v2.2 format' in result.stderr + + def test_up_scale_scale_up(self): + self.base_dir = 'tests/fixtures/scale' + project = self.project + + self.dispatch(['up', '-d']) + assert len(project.get_service('web').containers()) == 2 + assert len(project.get_service('db').containers()) == 1 + + self.dispatch(['up', '-d', '--scale', 'web=3']) + assert len(project.get_service('web').containers()) == 3 + assert len(project.get_service('db').containers()) == 1 + + def test_up_scale_scale_down(self): self.base_dir = 'tests/fixtures/scale' project = self.project + self.dispatch(['up', '-d']) assert len(project.get_service('web').containers()) == 2 assert len(project.get_service('db').containers()) == 1 @@ -1877,13 +1895,21 @@ def test_up_scale(self): assert len(project.get_service('web').containers()) == 1 assert len(project.get_service('db').containers()) == 1 - self.dispatch(['up', '-d', '--scale', 'web=3']) + def test_up_scale_reset(self): + self.base_dir = 'tests/fixtures/scale' + project = self.project + + self.dispatch(['up', '-d', '--scale', 'web=3', '--scale', 'db=3']) assert len(project.get_service('web').containers()) == 3 + assert len(project.get_service('db').containers()) == 3 + + self.dispatch(['up', '-d']) + assert len(project.get_service('web').containers()) == 2 assert len(project.get_service('db').containers()) == 1 - self.dispatch(['up', '-d', '--scale', 'web=1', '--scale', 'db=2']) - assert len(project.get_service('web').containers()) == 1 - assert len(project.get_service('db').containers()) == 2 + def test_up_scale_to_zero(self): + self.base_dir = 'tests/fixtures/scale' + project = self.project self.dispatch(['up', '-d']) assert len(project.get_service('web').containers()) == 2 diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index afb408c8314..b69b0456599 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -565,12 +565,12 @@ def test_unscale_after_restart(self): self.assertEqual(len(service.containers()), 3) project.up() service = project.get_service('web') - self.assertEqual(len(service.containers()), 3) + self.assertEqual(len(service.containers()), 1) service.scale(1) self.assertEqual(len(service.containers()), 1) - project.up() + project.up(scale_override={'web': 3}) service = project.get_service('web') - self.assertEqual(len(service.containers()), 1) + self.assertEqual(len(service.containers()), 3) # does scale=0 ,makes any sense? after recreating at least 1 container is running service.scale(0) project.up() diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 63607175583..87549c50680 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -26,6 +26,7 @@ from compose.const import LABEL_SERVICE from compose.const import LABEL_VERSION from compose.container import Container +from compose.errors import OperationFailedError from compose.project import OneOffFilter from compose.service import ConvergencePlan from compose.service import ConvergenceStrategy @@ -777,15 +778,15 @@ def test_scale_with_api_error(self): message="testing", response={}, explanation="Boom")): - with mock.patch('sys.stderr', new_callable=StringIO) as mock_stderr: - service.scale(3) + with pytest.raises(OperationFailedError): + service.scale(3) - self.assertEqual(len(service.containers()), 1) - self.assertTrue(service.containers()[0].is_running) - self.assertIn( - "ERROR: for composetest_web_2 Cannot create container for service web: Boom", - mock_stderr.getvalue() + assert len(service.containers()) == 1 + assert service.containers()[0].is_running + assert ( + "ERROR: for composetest_web_2 Cannot create container for service" + " web: Boom" in mock_stderr.getvalue() ) def test_scale_with_unexpected_exception(self): @@ -837,7 +838,8 @@ def test_scale_with_custom_container_name_outputs_warning(self, mock_log): service = self.create_service('app', container_name='custom-container') self.assertEqual(service.custom_container_name, 'custom-container') - service.scale(3) + with pytest.raises(OperationFailedError): + service.scale(3) captured_output = mock_log.warn.call_args[0][0] From c8a7891cc8a635367d595b844496f9807c1f610c Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 17 Apr 2017 19:03:56 -0700 Subject: [PATCH 2757/4072] Implement --scale option on up command, allow scale config in v2.2 format docker-compose scale modified to reuse code between up and scale Signed-off-by: Joffrey F --- compose/cli/main.py | 36 +++-- compose/config/config_schema_v2.2.json | 1 + compose/parallel.py | 4 - compose/project.py | 20 ++- compose/service.py | 192 +++++++++++++----------- tests/acceptance/cli_test.py | 27 ++++ tests/fixtures/scale/docker-compose.yml | 9 ++ tests/integration/project_test.py | 28 ++++ 8 files changed, 211 insertions(+), 106 deletions(-) create mode 100644 tests/fixtures/scale/docker-compose.yml diff --git a/compose/cli/main.py b/compose/cli/main.py index 53ff84f4537..e018a0174b4 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -771,15 +771,7 @@ def scale(self, options): """ timeout = timeout_from_opts(options) - for s in options['SERVICE=NUM']: - if '=' not in s: - raise UserError('Arguments to scale should be in the form service=num') - service_name, num = s.split('=', 1) - try: - num = int(num) - except ValueError: - raise UserError('Number of containers for service "%s" is not a ' - 'number' % service_name) + for service_name, num in parse_scale_args(options['SERVICE=NUM']).items(): self.project.get_service(service_name).scale(num, timeout=timeout) def start(self, options): @@ -875,7 +867,7 @@ def up(self, options): If you want to force Compose to stop and recreate all containers, use the `--force-recreate` flag. - Usage: up [options] [SERVICE...] + Usage: up [options] [--scale SERVICE=NUM...] [SERVICE...] Options: -d Detached mode: Run containers in the background, @@ -898,7 +890,9 @@ def up(self, options): --remove-orphans Remove containers for services not defined in the Compose file --exit-code-from SERVICE Return the exit code of the selected service container. - Requires --abort-on-container-exit. + Implies --abort-on-container-exit. + --scale SERVICE=NUM Scale SERVICE to NUM instances. Overrides the `scale` + setting in the Compose file if present. """ start_deps = not options['--no-deps'] exit_value_from = exitval_from_opts(options, self.project) @@ -919,7 +913,9 @@ def up(self, options): do_build=build_action_from_opts(options), timeout=timeout, detached=detached, - remove_orphans=remove_orphans) + remove_orphans=remove_orphans, + scale_override=parse_scale_args(options['--scale']), + ) if detached: return @@ -1238,3 +1234,19 @@ def call_docker(args): log.debug(" ".join(map(pipes.quote, args))) return subprocess.call(args) + + +def parse_scale_args(options): + res = {} + for s in options: + if '=' not in s: + raise UserError('Arguments to scale should be in the form service=num') + service_name, num = s.split('=', 1) + try: + num = int(num) + except ValueError: + raise UserError( + 'Number of containers for service "%s" is not a number' % service_name + ) + res[service_name] = num + return res diff --git a/compose/config/config_schema_v2.2.json b/compose/config/config_schema_v2.2.json index daa07d149e2..390d3efa96f 100644 --- a/compose/config/config_schema_v2.2.json +++ b/compose/config/config_schema_v2.2.json @@ -222,6 +222,7 @@ "privileged": {"type": "boolean"}, "read_only": {"type": "boolean"}, "restart": {"type": "string"}, + "scale": {"type": "integer"}, "security_opt": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, "shm_size": {"type": ["number", "string"]}, "sysctls": {"$ref": "#/definitions/list_or_dict"}, diff --git a/compose/parallel.py b/compose/parallel.py index fde723f336c..34fef71db75 100644 --- a/compose/parallel.py +++ b/compose/parallel.py @@ -260,10 +260,6 @@ def parallel_remove(containers, options): parallel_operation(stopped_containers, 'remove', options, 'Removing') -def parallel_start(containers, options): - parallel_operation(containers, 'start', options, 'Starting') - - def parallel_pause(containers, options): parallel_operation(containers, 'pause', options, 'Pausing') diff --git a/compose/project.py b/compose/project.py index a75d71efc71..85322876443 100644 --- a/compose/project.py +++ b/compose/project.py @@ -380,13 +380,17 @@ def up(self, do_build=BuildAction.none, timeout=None, detached=False, - remove_orphans=False): + remove_orphans=False, + scale_override=None): warn_for_swarm_mode(self.client) self.initialize() self.find_orphan_containers(remove_orphans) + if scale_override is None: + scale_override = {} + services = self.get_services_without_duplicate( service_names, include_deps=start_deps) @@ -399,7 +403,8 @@ def do(service): return service.execute_convergence_plan( plans[service.name], timeout=timeout, - detached=detached + detached=detached, + scale_override=scale_override.get(service.name) ) def get_deps(service): @@ -589,10 +594,13 @@ def get_secrets(service, service_secrets, secret_defs): 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)) + 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')}) diff --git a/compose/service.py b/compose/service.py index f8c549381de..4fa3aeabf94 100644 --- a/compose/service.py +++ b/compose/service.py @@ -38,7 +38,6 @@ from .errors import NoHealthCheckConfigured from .errors import OperationFailedError from .parallel import parallel_execute -from .parallel import parallel_start from .progress_stream import stream_output from .progress_stream import StreamOutputError from .utils import json_hash @@ -148,6 +147,7 @@ def __init__( network_mode=None, networks=None, secrets=None, + scale=None, **options ): self.name = name @@ -159,6 +159,7 @@ def __init__( self.network_mode = network_mode or NetworkMode(None) self.networks = networks or {} self.secrets = secrets or [] + self.scale_num = scale or 1 self.options = options def __repr__(self): @@ -189,16 +190,7 @@ def start(self, **options): self.start_container_if_stopped(c, **options) return containers - def scale(self, desired_num, timeout=None): - """ - Adjusts the number of containers to the specified number and ensures - they are running. - - - creates containers until there are at least `desired_num` - - stops containers until there are at most `desired_num` running - - starts containers until there are at least `desired_num` running - - removes all stopped containers - """ + def show_scale_warnings(self, desired_num): 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. ' @@ -210,14 +202,18 @@ def scale(self, desired_num, timeout=None): 'for this service are created on a single host, the port will clash.' % self.name) - def create_and_start(service, number): - container = service.create_container(number=number, quiet=True) - service.start_container(container) - return container + def scale(self, desired_num, timeout=None): + """ + Adjusts the number of containers to the specified number and ensures + they are running. - def stop_and_remove(container): - container.stop(timeout=self.stop_timeout(timeout)) - container.remove() + - creates containers until there are at least `desired_num` + - stops containers until there are at most `desired_num` running + - starts containers until there are at least `desired_num` running + - removes all stopped containers + """ + + self.show_scale_warnings(desired_num) running_containers = self.containers(stopped=False) num_running = len(running_containers) @@ -228,11 +224,10 @@ def stop_and_remove(container): return if desired_num > num_running: - # we need to start/create until we have desired_num all_containers = self.containers(stopped=True) if num_running != len(all_containers): - # we have some stopped containers, let's start them up again + # we have some stopped containers, check for divergences stopped_containers = [ c for c in all_containers if not c.is_running ] @@ -241,38 +236,14 @@ def stop_and_remove(container): divergent_containers = [ c for c in stopped_containers if self._containers_have_diverged([c]) ] - stopped_containers = sorted( - set(stopped_containers) - set(divergent_containers), - key=attrgetter('number') - ) for c in divergent_containers: c.remove() - num_stopped = len(stopped_containers) - - if num_stopped + num_running > desired_num: - num_to_start = desired_num - num_running - containers_to_start = stopped_containers[:num_to_start] - else: - containers_to_start = stopped_containers - - parallel_start(containers_to_start, {}) - - num_running += len(containers_to_start) + all_containers = list(set(all_containers) - set(divergent_containers)) - num_to_create = desired_num - num_running - next_number = self._next_container_number() - container_numbers = [ - number for number in range( - next_number, next_number + num_to_create - ) - ] - - parallel_execute( - container_numbers, - lambda n: create_and_start(service=self, number=n), - lambda n: self.get_container_name(n), - "Creating and starting" + sorted_containers = sorted(all_containers, key=attrgetter('number')) + self._execute_convergence_start( + sorted_containers, desired_num, timeout, True, True ) if desired_num < num_running: @@ -282,12 +253,7 @@ def stop_and_remove(container): running_containers, key=attrgetter('number')) - parallel_execute( - sorted_running_containers[-num_to_stop:], - stop_and_remove, - lambda c: c.name, - "Stopping and removing", - ) + self._downscale(sorted_running_containers[-num_to_stop:], timeout) def create_container(self, one_off=False, @@ -400,51 +366,109 @@ def _containers_have_diverged(self, containers): return has_diverged - def execute_convergence_plan(self, - plan, - timeout=None, - detached=False, - start=True): - (action, containers) = plan - should_attach_logs = not detached - - if action == 'create': - container = self.create_container() + def _execute_convergence_create(self, scale, detached, start): + i = self._next_container_number() - if should_attach_logs: - container.attach_log_stream() + def create_and_start(service, n): + container = service.create_container(number=n) + if not detached: + container.attach_log_stream() + if start: + self.start_container(container) + return container - if start: - self.start_container(container) + return parallel_execute( + range(i, i + scale), + lambda n: create_and_start(self, n), + lambda n: self.get_container_name(n), + "Creating" + )[0] - return [container] + def _execute_convergence_recreate(self, containers, scale, timeout, detached, start): + if len(containers) > scale: + self._downscale(containers[scale:], timeout) + containers = containers[:scale] - elif action == 'recreate': - return [ - self.recreate_container( - container, - timeout=timeout, - attach_logs=should_attach_logs, + def recreate(container): + return self.recreate_container( + container, timeout=timeout, attach_logs=not detached, start_new_container=start ) - for container in containers - ] + containers = parallel_execute( + containers, + recreate, + lambda c: c.name, + "Recreating" + )[0] + if len(containers) < scale: + containers.extend(self._execute_convergence_create( + scale - len(containers), detached, start + )) + return containers - elif action == 'start': + def _execute_convergence_start(self, containers, scale, timeout, detached, start): + if len(containers) > scale: + self._downscale(containers[scale:], timeout) + containers = containers[:scale] if start: - for container in containers: - self.start_container_if_stopped(container, attach_logs=should_attach_logs) - + parallel_execute( + containers, + lambda c: self.start_container_if_stopped(c, attach_logs=not detached), + lambda c: c.name, + "Starting" + ) + if len(containers) < scale: + containers.extend(self._execute_convergence_create( + scale - len(containers), detached, start + )) return containers - elif action == 'noop': + def _downscale(self, containers, timeout=None): + def stop_and_remove(container): + container.stop(timeout=self.stop_timeout(timeout)) + container.remove() + + parallel_execute( + containers, + stop_and_remove, + lambda c: c.name, + "Stopping and removing", + ) + + def execute_convergence_plan(self, plan, timeout=None, detached=False, + start=True, scale_override=None): + (action, containers) = plan + scale = scale_override if scale_override is not None else self.scale_num + containers = sorted(containers, key=attrgetter('number')) + + self.show_scale_warnings(scale) + + if action == 'create': + return self._execute_convergence_create( + scale, detached, start + ) + + if action == 'recreate': + return self._execute_convergence_recreate( + containers, scale, timeout, detached, start + ) + + if action == 'start': + return self._execute_convergence_start( + containers, scale, timeout, detached, start + ) + + if action == 'noop': + if scale != len(containers): + return self._execute_convergence_start( + containers, scale, timeout, detached, start + ) for c in containers: log.info("%s is up-to-date" % c.name) return containers - else: - raise Exception("Invalid action: {}".format(action)) + raise Exception("Invalid action: {}".format(action)) def recreate_container( self, diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index bfc96340210..43dc216ba4e 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1866,6 +1866,33 @@ def test_scale(self): self.assertEqual(len(project.get_service('simple').containers()), 0) self.assertEqual(len(project.get_service('another').containers()), 0) + def test_up_scale(self): + self.base_dir = 'tests/fixtures/scale' + project = self.project + self.dispatch(['up', '-d']) + assert len(project.get_service('web').containers()) == 2 + assert len(project.get_service('db').containers()) == 1 + + self.dispatch(['up', '-d', '--scale', 'web=1']) + assert len(project.get_service('web').containers()) == 1 + assert len(project.get_service('db').containers()) == 1 + + self.dispatch(['up', '-d', '--scale', 'web=3']) + assert len(project.get_service('web').containers()) == 3 + assert len(project.get_service('db').containers()) == 1 + + self.dispatch(['up', '-d', '--scale', 'web=1', '--scale', 'db=2']) + assert len(project.get_service('web').containers()) == 1 + assert len(project.get_service('db').containers()) == 2 + + self.dispatch(['up', '-d']) + assert len(project.get_service('web').containers()) == 2 + assert len(project.get_service('db').containers()) == 1 + + self.dispatch(['up', '-d', '--scale', 'web=0', '--scale', 'db=0']) + assert len(project.get_service('web').containers()) == 0 + assert len(project.get_service('db').containers()) == 0 + def test_port(self): self.base_dir = 'tests/fixtures/ports-composefile' self.dispatch(['up', '-d'], None) diff --git a/tests/fixtures/scale/docker-compose.yml b/tests/fixtures/scale/docker-compose.yml new file mode 100644 index 00000000000..a0d3b771f16 --- /dev/null +++ b/tests/fixtures/scale/docker-compose.yml @@ -0,0 +1,9 @@ +version: '2.2' +services: + web: + image: busybox + command: top + scale: 2 + db: + image: busybox + command: top diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 2eae88ec547..afb408c8314 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -19,6 +19,7 @@ from compose.config.types import VolumeSpec from compose.const import COMPOSEFILE_V2_0 as V2_0 from compose.const import COMPOSEFILE_V2_1 as V2_1 +from compose.const import COMPOSEFILE_V2_2 as V2_2 from compose.const import COMPOSEFILE_V3_1 as V3_1 from compose.const import LABEL_PROJECT from compose.const import LABEL_SERVICE @@ -1137,6 +1138,33 @@ def test_project_up_port_mappings_with_multiple_files(self): containers = project.containers() self.assertEqual(len(containers), 1) + def test_project_up_config_scale(self): + config_data = build_config( + version=V2_2, + services=[{ + 'name': 'web', + 'image': 'busybox:latest', + 'command': 'top', + 'scale': 3 + }] + ) + + project = Project.from_config( + name='composetest', config_data=config_data, client=self.client + ) + project.up() + assert len(project.containers()) == 3 + + project.up(scale_override={'web': 2}) + assert len(project.containers()) == 2 + + project.up(scale_override={'web': 4}) + assert len(project.containers()) == 4 + + project.stop() + project.up() + assert len(project.containers()) == 3 + @v2_only() def test_initialize_volumes(self): vol_name = '{0:x}'.format(random.getrandbits(32)) From 457c16a7b1248ed177c5f9fa444c65f36329917e Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 18 Apr 2017 12:53:43 -0700 Subject: [PATCH 2758/4072] Properly relay errors in execute_convergence_plan Signed-off-by: Joffrey F --- compose/service.py | 20 +++++++++++++++----- tests/acceptance/cli_test.py | 2 +- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/compose/service.py b/compose/service.py index 4fa3aeabf94..65eded8ec2f 100644 --- a/compose/service.py +++ b/compose/service.py @@ -377,12 +377,16 @@ def create_and_start(service, n): self.start_container(container) return container - return parallel_execute( + containers, errors = parallel_execute( range(i, i + scale), lambda n: create_and_start(self, n), lambda n: self.get_container_name(n), "Creating" - )[0] + ) + if errors: + raise OperationFailedError(errors.values()[0]) + + return containers def _execute_convergence_recreate(self, containers, scale, timeout, detached, start): if len(containers) > scale: @@ -394,12 +398,14 @@ def recreate(container): container, timeout=timeout, attach_logs=not detached, start_new_container=start ) - containers = parallel_execute( + containers, errors = parallel_execute( containers, recreate, lambda c: c.name, "Recreating" - )[0] + ) + if errors: + raise OperationFailedError(errors.values()[0]) if len(containers) < scale: containers.extend(self._execute_convergence_create( scale - len(containers), detached, start @@ -411,12 +417,16 @@ def _execute_convergence_start(self, containers, scale, timeout, detached, start self._downscale(containers[scale:], timeout) containers = containers[:scale] if start: - parallel_execute( + _, errors = parallel_execute( containers, lambda c: self.start_container_if_stopped(c, attach_logs=not detached), lambda c: c.name, "Starting" ) + + if errors: + raise OperationFailedError(errors.values()[0]) + if len(containers) < scale: containers.extend(self._execute_convergence_create( scale - len(containers), detached, start diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 43dc216ba4e..c4b24b4b5aa 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -151,7 +151,7 @@ def lookup(self, container, hostname): def test_help(self): self.base_dir = 'tests/fixtures/no-composefile' result = self.dispatch(['help', 'up'], returncode=0) - assert 'Usage: up [options] [SERVICE...]' in result.stdout + assert 'Usage: up [options] [--scale SERVICE=NUM...] [SERVICE...]' in result.stdout # Prevent tearDown from trying to create a project self.base_dir = None From ef40e3c6b99e24c580eabd57580778d60ae79d99 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 19 Apr 2017 16:47:43 -0700 Subject: [PATCH 2759/4072] Prevent `docker-compose scale` to be used with a v2.2 config file Signed-off-by: Joffrey F --- compose/cli/main.py | 7 ++++++ compose/project.py | 5 +++-- compose/service.py | 13 +++++------ tests/acceptance/cli_test.py | 36 ++++++++++++++++++++++++++----- tests/integration/project_test.py | 6 +++--- tests/integration/service_test.py | 18 +++++++++------- 6 files changed, 61 insertions(+), 24 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index e018a0174b4..0fdf3c28a10 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -26,6 +26,7 @@ from ..config.environment import Environment from ..config.serialize import serialize_config from ..config.types import VolumeSpec +from ..const import COMPOSEFILE_V2_2 as V2_2 from ..const import IS_WINDOWS_PLATFORM from ..errors import StreamParseError from ..progress_stream import StreamOutputError @@ -771,6 +772,12 @@ def scale(self, options): """ timeout = timeout_from_opts(options) + if self.project.config_version == V2_2: + raise UserError( + 'The scale command is incompatible with the v2.2 format. ' + 'Use the up command with the --scale flag instead.' + ) + for service_name, num in parse_scale_args(options['SERVICE=NUM']).items(): self.project.get_service(service_name).scale(num, timeout=timeout) diff --git a/compose/project.py b/compose/project.py index 85322876443..e80b10455db 100644 --- a/compose/project.py +++ b/compose/project.py @@ -57,12 +57,13 @@ class Project(object): """ A collection of services. """ - def __init__(self, name, services, client, networks=None, volumes=None): + def __init__(self, name, services, client, networks=None, volumes=None, config_version=None): self.name = name self.services = services self.client = client self.volumes = volumes or ProjectVolumes({}) self.networks = networks or ProjectNetworks({}, False) + self.config_version = config_version def labels(self, one_off=OneOffFilter.exclude): labels = ['{0}={1}'.format(LABEL_PROJECT, self.name)] @@ -82,7 +83,7 @@ def from_config(cls, name, config_data, client): networks, use_networking) volumes = ProjectVolumes.from_config(name, config_data, client) - project = cls(name, [], client, project_networks, volumes) + project = cls(name, [], client, project_networks, volumes, config_data.version) for service_dict in config_data.services: service_dict = dict(service_dict) diff --git a/compose/service.py b/compose/service.py index 65eded8ec2f..e903115af57 100644 --- a/compose/service.py +++ b/compose/service.py @@ -383,8 +383,8 @@ def create_and_start(service, n): lambda n: self.get_container_name(n), "Creating" ) - if errors: - raise OperationFailedError(errors.values()[0]) + for error in errors.values(): + raise OperationFailedError(error) return containers @@ -404,8 +404,9 @@ def recreate(container): lambda c: c.name, "Recreating" ) - if errors: - raise OperationFailedError(errors.values()[0]) + for error in errors.values(): + raise OperationFailedError(error) + if len(containers) < scale: containers.extend(self._execute_convergence_create( scale - len(containers), detached, start @@ -424,8 +425,8 @@ def _execute_convergence_start(self, containers, scale, timeout, detached, start "Starting" ) - if errors: - raise OperationFailedError(errors.values()[0]) + for error in errors.values(): + raise OperationFailedError(error) if len(containers) < scale: containers.extend(self._execute_convergence_create( diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index c4b24b4b5aa..75b15ae65a2 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1866,9 +1866,27 @@ def test_scale(self): self.assertEqual(len(project.get_service('simple').containers()), 0) self.assertEqual(len(project.get_service('another').containers()), 0) - def test_up_scale(self): + def test_scale_v2_2(self): + self.base_dir = 'tests/fixtures/scale' + result = self.dispatch(['scale', 'web=1'], returncode=1) + assert 'incompatible with the v2.2 format' in result.stderr + + def test_up_scale_scale_up(self): + self.base_dir = 'tests/fixtures/scale' + project = self.project + + self.dispatch(['up', '-d']) + assert len(project.get_service('web').containers()) == 2 + assert len(project.get_service('db').containers()) == 1 + + self.dispatch(['up', '-d', '--scale', 'web=3']) + assert len(project.get_service('web').containers()) == 3 + assert len(project.get_service('db').containers()) == 1 + + def test_up_scale_scale_down(self): self.base_dir = 'tests/fixtures/scale' project = self.project + self.dispatch(['up', '-d']) assert len(project.get_service('web').containers()) == 2 assert len(project.get_service('db').containers()) == 1 @@ -1877,13 +1895,21 @@ def test_up_scale(self): assert len(project.get_service('web').containers()) == 1 assert len(project.get_service('db').containers()) == 1 - self.dispatch(['up', '-d', '--scale', 'web=3']) + def test_up_scale_reset(self): + self.base_dir = 'tests/fixtures/scale' + project = self.project + + self.dispatch(['up', '-d', '--scale', 'web=3', '--scale', 'db=3']) assert len(project.get_service('web').containers()) == 3 + assert len(project.get_service('db').containers()) == 3 + + self.dispatch(['up', '-d']) + assert len(project.get_service('web').containers()) == 2 assert len(project.get_service('db').containers()) == 1 - self.dispatch(['up', '-d', '--scale', 'web=1', '--scale', 'db=2']) - assert len(project.get_service('web').containers()) == 1 - assert len(project.get_service('db').containers()) == 2 + def test_up_scale_to_zero(self): + self.base_dir = 'tests/fixtures/scale' + project = self.project self.dispatch(['up', '-d']) assert len(project.get_service('web').containers()) == 2 diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index afb408c8314..b69b0456599 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -565,12 +565,12 @@ def test_unscale_after_restart(self): self.assertEqual(len(service.containers()), 3) project.up() service = project.get_service('web') - self.assertEqual(len(service.containers()), 3) + self.assertEqual(len(service.containers()), 1) service.scale(1) self.assertEqual(len(service.containers()), 1) - project.up() + project.up(scale_override={'web': 3}) service = project.get_service('web') - self.assertEqual(len(service.containers()), 1) + self.assertEqual(len(service.containers()), 3) # does scale=0 ,makes any sense? after recreating at least 1 container is running service.scale(0) project.up() diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 63607175583..87549c50680 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -26,6 +26,7 @@ from compose.const import LABEL_SERVICE from compose.const import LABEL_VERSION from compose.container import Container +from compose.errors import OperationFailedError from compose.project import OneOffFilter from compose.service import ConvergencePlan from compose.service import ConvergenceStrategy @@ -777,15 +778,15 @@ def test_scale_with_api_error(self): message="testing", response={}, explanation="Boom")): - with mock.patch('sys.stderr', new_callable=StringIO) as mock_stderr: - service.scale(3) + with pytest.raises(OperationFailedError): + service.scale(3) - self.assertEqual(len(service.containers()), 1) - self.assertTrue(service.containers()[0].is_running) - self.assertIn( - "ERROR: for composetest_web_2 Cannot create container for service web: Boom", - mock_stderr.getvalue() + assert len(service.containers()) == 1 + assert service.containers()[0].is_running + assert ( + "ERROR: for composetest_web_2 Cannot create container for service" + " web: Boom" in mock_stderr.getvalue() ) def test_scale_with_unexpected_exception(self): @@ -837,7 +838,8 @@ def test_scale_with_custom_container_name_outputs_warning(self, mock_log): service = self.create_service('app', container_name='custom-container') self.assertEqual(service.custom_container_name, 'custom-container') - service.scale(3) + with pytest.raises(OperationFailedError): + service.scale(3) captured_output = mock_log.warn.call_args[0][0] From 38af51314e295ad14d24c2a323a164a787e77bf7 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 21 Apr 2017 14:43:03 -0700 Subject: [PATCH 2760/4072] Bump 1.13.0-rc1 Signed-off-by: Joffrey F --- CHANGELOG.md | 49 ++++++++++++++++++++++++++++++++++++++++++++- compose/__init__.py | 2 +- script/run/run.sh | 2 +- 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d49ddea792..a8f64d75755 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,53 @@ Change log ========== +1.13.0 (2017-05-01) +------------------- + +### Breaking changes + +- `docker-compose up` now resets a service's scaling to its default value. + You can use the newly introduced `--scale` option to specify a custom + scale value + +### New features + +#### Compose file version 2.2 + +- Introduced version 2.2 of the `docker-compose.yml` specification. This + version requires to be used with Docker Engine 1.13.0 or above + +- Added support for `init` in service definitions. + +- Added support for `scale` in service definitions. The configuration's value + can be overridden using the `--scale` flag in `docker-compose up`. + Please note that the `scale` command is disabled for this file format + +#### Compose file version 2.x + +- Added support for `options` in the `ipam` section of network definitions + +### Bugfixes + +- Fixed a bug where paths provided to compose via the `-f` option were not + being resolved properly + +- Fixed a bug where the `ext_ip::target_port` notation in the ports section + was incorrectly marked as invalid + +- Fixed an issue where the `exec` command would sometimes not return control + to the terminal when using the `-d` flag + +- Fixed a bug where secrets were missing from the output of the `config` + command for v3.2 files + +- Fixed an issue where `docker-compose` would hang if no internet connection + was available + +- Fixed an issue where paths containing unicode characters passed via the `-f` + flag were causing Compose to crash + + 1.12.0 (2017-04-04) ------------------- @@ -8,7 +55,7 @@ Change log #### Compose file version 3.2 -- Introduced version 3.2 of the `docker-compose.yml` specification. +- Introduced version 3.2 of the `docker-compose.yml` specification - Added support for `cache_from` in the `build` section of services diff --git a/compose/__init__.py b/compose/__init__.py index d387af24edf..f80467115b3 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.13.0dev' +__version__ = '1.13.0-rc1' diff --git a/script/run/run.sh b/script/run/run.sh index 31c5d3151e1..beff0c6b92f 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.12.0dev" +VERSION="1.13.0-rc1" IMAGE="docker/compose:$VERSION" From ce0599d912857c8a24a260ff46cf99cf39d12289 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 24 Apr 2017 13:29:18 -0700 Subject: [PATCH 2761/4072] Add missing IPAM options to v2.2 spec Signed-off-by: Joffrey F --- compose/config/config_schema_v2.2.json | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/compose/config/config_schema_v2.2.json b/compose/config/config_schema_v2.2.json index daa07d149e2..3c4844a50e2 100644 --- a/compose/config/config_schema_v2.2.json +++ b/compose/config/config_schema_v2.2.json @@ -299,6 +299,13 @@ "driver": {"type": "string"}, "config": { "type": "array" + }, + "options": { + "type": "object", + "patternProperties": { + "^.+$": {"type": "string"} + }, + "additionalProperties": false } }, "additionalProperties": false From 0dc25f1cdf96df7536a0793898c60691c0b35b89 Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Tue, 25 Apr 2017 13:19:22 +0200 Subject: [PATCH 2762/4072] Add bash completion for `docker-compose up --scale` Signed-off-by: Harald Albers --- contrib/completion/bash/docker-compose | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index 739ba39b047..2c2be61c758 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -498,10 +498,19 @@ _docker_compose_unpause() { _docker_compose_up() { case "$prev" in + =) + COMPREPLY=("$cur") + return + ;; --exit-code-from) __docker_compose_services_all return ;; + --scale) + COMPREPLY=( $(compgen -S "=" -W "$(___docker_compose_all_services_in_compose_file)" -- "$cur") ) + __docker_compose_nospace + return + ;; --timeout|-t) return ;; @@ -509,7 +518,7 @@ _docker_compose_up() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "--abort-on-container-exit --build -d --exit-code-from --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 --exit-code-from --force-recreate --help --no-build --no-color --no-deps --no-recreate --remove-orphans --scale --timeout -t" -- "$cur" ) ) ;; *) __docker_compose_services_all From 397faf6172120b329906c0b19f5af762fa457944 Mon Sep 17 00:00:00 2001 From: Duncan Paterson Date: Tue, 25 Apr 2017 15:05:09 +0100 Subject: [PATCH 2763/4072] Fixes --exit-code-from - tried to find the length and array index of a filter object Signed-off-by: Duncan Paterson --- compose/cli/main.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 53ff84f4537..9b40599cf42 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -941,9 +941,9 @@ def up(self, options): exit_code = 0 if exit_value_from: - candidates = filter( + candidates = list(filter( lambda c: c.service == exit_value_from, - attached_containers) + attached_containers)) if not candidates: log.error( 'No containers matching the spec "{0}" ' From a0cf0a2009b0c8770e908f7522e5cc37e3e72d79 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 25 Apr 2017 14:51:53 -0700 Subject: [PATCH 2764/4072] Avoid rebinding tmpfs data volumes when recreating containers Signed-off-by: Joffrey F --- compose/service.py | 12 +++++++++--- tests/integration/project_test.py | 18 ++++++++++++++++++ tests/unit/service_test.py | 16 ++++++++++------ 3 files changed, 37 insertions(+), 9 deletions(-) diff --git a/compose/service.py b/compose/service.py index f8c549381de..52218872a27 100644 --- a/compose/service.py +++ b/compose/service.py @@ -16,6 +16,7 @@ from docker.types import LogConfig from docker.utils.ports import build_port_bindings from docker.utils.ports import split_port +from docker.utils.utils import convert_tmpfs_mounts from . import __version__ from . import const @@ -709,6 +710,7 @@ def _get_container_create_options( binds, affinity = merge_volume_bindings( container_options.get('volumes') or [], + self.options.get('tmpfs') or [], previous_container) override_options['binds'] = binds container_options['environment'].update(affinity) @@ -1091,7 +1093,7 @@ def parse_repository_tag(repo_path): # Volumes -def merge_volume_bindings(volumes, previous_container): +def merge_volume_bindings(volumes, tmpfs, previous_container): """Return a list of volume bindings for a container. Container data volumes are replaced by those from the previous container. """ @@ -1103,7 +1105,7 @@ def merge_volume_bindings(volumes, previous_container): if volume.external) if previous_container: - old_volumes = get_container_data_volumes(previous_container, volumes) + old_volumes = get_container_data_volumes(previous_container, volumes, tmpfs) warn_on_masked_volume(volumes, old_volumes, previous_container.service) volume_bindings.update( build_volume_binding(volume) for volume in old_volumes) @@ -1114,7 +1116,7 @@ def merge_volume_bindings(volumes, previous_container): return list(volume_bindings.values()), affinity -def get_container_data_volumes(container, volumes_option): +def get_container_data_volumes(container, volumes_option, tmpfs_option): """Find the container data volumes that are in `volumes_option`, and return a mapping of volume bindings for those volumes. """ @@ -1137,6 +1139,10 @@ def get_container_data_volumes(container, volumes_option): if volume.external: continue + # Attempting to rebind tmpfs volumes breaks: https://github.com/docker/compose/issues/4751 + if volume.internal in convert_tmpfs_mounts(tmpfs_option).keys(): + continue + mount = container_mounts.get(volume.internal) # New volume, doesn't exist in the old container diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 2eae88ec547..2a29b1b6dec 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -551,6 +551,24 @@ def test_project_up_with_no_deps(self): self.assertEqual(len(project.get_service('data').containers(stopped=True)), 1) self.assertEqual(len(project.get_service('console').containers()), 0) + def test_project_up_recreate_with_tmpfs_volume(self): + # https://github.com/docker/compose/issues/4751 + project = Project.from_config( + name='composetest', + config_data=load_config({ + 'version': '2.1', + 'services': { + 'foo': { + 'image': 'busybox:latest', + 'tmpfs': ['/dev/shm'], + 'volumes': ['/dev/shm'] + } + } + }), client=self.client + ) + project.up() + project.up(strategy=ConvergenceStrategy.always) + def test_unscale_after_restart(self): web = self.create_service('web') project = Project('composetest', [web], self.client) diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index f3f3a2a83e1..c32c3633943 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -858,6 +858,7 @@ def test_get_container_data_volumes(self): '/new/volume', '/existing/volume', 'named:/named/vol', + '/dev/tmpfs' ]] self.mock_client.inspect_image.return_value = { @@ -903,15 +904,18 @@ def test_get_container_data_volumes(self): VolumeSpec.parse('imagedata:/mnt/image/data:rw'), ] - volumes = get_container_data_volumes(container, options) + volumes = get_container_data_volumes(container, options, ['/dev/tmpfs']) assert sorted(volumes) == sorted(expected) def test_merge_volume_bindings(self): options = [ - 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), + VolumeSpec.parse(v, True) for v in [ + '/host/volume:/host/volume:ro', + '/host/rw/volume:/host/rw/volume', + '/new/volume', + '/existing/volume', + '/dev/tmpfs' + ] ] self.mock_client.inspect_image.return_value = { @@ -936,7 +940,7 @@ def test_merge_volume_bindings(self): 'existingvolume:/existing/volume:rw', ] - binds, affinity = merge_volume_bindings(options, previous_container) + binds, affinity = merge_volume_bindings(options, ['/dev/tmpfs'], previous_container) assert sorted(binds) == sorted(expected) assert affinity == {'affinity:container': '=cdefab'} From d0acefd4507712c47e506a96d8463db89c69b3b3 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 26 Apr 2017 13:34:45 -0700 Subject: [PATCH 2765/4072] Prevent NoneType error when remote IPAM options is None Signed-off-by: Joffrey F --- compose/network.py | 4 ++-- tests/unit/network_test.py | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/compose/network.py b/compose/network.py index 4aeff2d1e5c..ea6f49631f3 100644 --- a/compose/network.py +++ b/compose/network.py @@ -158,8 +158,8 @@ def check_remote_ipam_config(remote, local): if sorted(lc.get('AuxiliaryAddresses')) != sorted(rc.get('AuxiliaryAddresses')): raise NetworkConfigChangedError(local.full_name, 'IPAM config aux_addresses') - remote_opts = remote_ipam.get('Options', {}) - local_opts = local.ipam.get('options', {}) + remote_opts = remote_ipam.get('Options') or {} + local_opts = local.ipam.get('options') or {} for k in set.union(set(remote_opts.keys()), set(local_opts.keys())): if remote_opts.get(k) != local_opts.get(k): raise NetworkConfigChangedError(local.full_name, 'IPAM option "{}"'.format(k)) diff --git a/tests/unit/network_test.py b/tests/unit/network_test.py index a325f1948f3..d1cf2ccf955 100644 --- a/tests/unit/network_test.py +++ b/tests/unit/network_test.py @@ -100,6 +100,44 @@ def test_check_remote_network_config_null_remote(self): {'Driver': 'overlay', 'Options': None}, net ) + def test_check_remote_network_config_null_remote_ipam_options(self): + ipam_config = { + 'driver': 'default', + 'config': [ + {'subnet': '172.0.0.1/16', }, + { + 'subnet': '156.0.0.1/25', + 'gateway': '156.0.0.1', + 'aux_addresses': ['11.0.0.1', '24.25.26.27'], + 'ip_range': '156.0.0.1-254' + } + ] + } + net = Network( + None, 'compose_test', 'net1', 'bridge', ipam=ipam_config, + ) + + check_remote_network_config( + { + 'Driver': 'bridge', + 'Attachable': True, + 'IPAM': { + 'Driver': 'default', + 'Config': [{ + 'Subnet': '156.0.0.1/25', + 'Gateway': '156.0.0.1', + 'AuxiliaryAddresses': ['24.25.26.27', '11.0.0.1'], + 'IPRange': '156.0.0.1-254' + }, { + 'Subnet': '172.0.0.1/16', + 'Gateway': '172.0.0.1' + }], + 'Options': None + }, + }, + net + ) + def test_check_remote_network_labels_mismatch(self): net = Network(None, 'compose_test', 'net1', 'overlay', labels={ 'com.project.touhou.character': 'sakuya.izayoi' From 28b868848d004e71a3e09042c9674a27bb869c1d Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 26 Apr 2017 15:00:41 -0700 Subject: [PATCH 2766/4072] Add deprecation warning to scale command Signed-off-by: Joffrey F --- compose/cli/main.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/compose/cli/main.py b/compose/cli/main.py index 0fdf3c28a10..b43cba2a904 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -764,6 +764,9 @@ def scale(self, options): $ docker-compose scale web=2 worker=3 + This command is deprecated. Use the up command with the `--scale` flag + instead. + Usage: scale [options] [SERVICE=NUM...] Options: @@ -777,6 +780,11 @@ def scale(self, options): 'The scale command is incompatible with the v2.2 format. ' 'Use the up command with the --scale flag instead.' ) + else: + log.warn( + 'The scale command is deprecated. ' + 'Use the up command with the --scale flag instead.' + ) for service_name, num in parse_scale_args(options['SERVICE=NUM']).items(): self.project.get_service(service_name).scale(num, timeout=timeout) From 76641cba9cf33e51559c3410686e4a9783941b95 Mon Sep 17 00:00:00 2001 From: mrfly Date: Fri, 28 Apr 2017 16:58:08 +0800 Subject: [PATCH 2767/4072] Not colon but a dot. hum... Signed-off-by: wrfly --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d43bd8c4c59..e3ca8f833e0 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ 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: +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: From 5b3b6ebf9402967fc49b57d86b920990cb067023 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 24 Apr 2017 13:29:18 -0700 Subject: [PATCH 2768/4072] Add missing IPAM options to v2.2 spec Signed-off-by: Joffrey F --- compose/config/config_schema_v2.2.json | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/compose/config/config_schema_v2.2.json b/compose/config/config_schema_v2.2.json index 390d3efa96f..a178fccc40a 100644 --- a/compose/config/config_schema_v2.2.json +++ b/compose/config/config_schema_v2.2.json @@ -300,6 +300,13 @@ "driver": {"type": "string"}, "config": { "type": "array" + }, + "options": { + "type": "object", + "patternProperties": { + "^.+$": {"type": "string"} + }, + "additionalProperties": false } }, "additionalProperties": false From 90634533fce3c18e201506cac53ff0b567c046dd Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Tue, 25 Apr 2017 13:19:22 +0200 Subject: [PATCH 2769/4072] Add bash completion for `docker-compose up --scale` Signed-off-by: Harald Albers --- contrib/completion/bash/docker-compose | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index 739ba39b047..2c2be61c758 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -498,10 +498,19 @@ _docker_compose_unpause() { _docker_compose_up() { case "$prev" in + =) + COMPREPLY=("$cur") + return + ;; --exit-code-from) __docker_compose_services_all return ;; + --scale) + COMPREPLY=( $(compgen -S "=" -W "$(___docker_compose_all_services_in_compose_file)" -- "$cur") ) + __docker_compose_nospace + return + ;; --timeout|-t) return ;; @@ -509,7 +518,7 @@ _docker_compose_up() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "--abort-on-container-exit --build -d --exit-code-from --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 --exit-code-from --force-recreate --help --no-build --no-color --no-deps --no-recreate --remove-orphans --scale --timeout -t" -- "$cur" ) ) ;; *) __docker_compose_services_all From da78c2c1dba6f809aaa846f6690a2fa55b497f33 Mon Sep 17 00:00:00 2001 From: Duncan Paterson Date: Tue, 25 Apr 2017 15:05:09 +0100 Subject: [PATCH 2770/4072] Fixes --exit-code-from - tried to find the length and array index of a filter object Signed-off-by: Duncan Paterson --- compose/cli/main.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 0fdf3c28a10..9df3c82adb8 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -944,9 +944,9 @@ def up(self, options): exit_code = 0 if exit_value_from: - candidates = filter( + candidates = list(filter( lambda c: c.service == exit_value_from, - attached_containers) + attached_containers)) if not candidates: log.error( 'No containers matching the spec "{0}" ' From bbdbc359248dd3c5f00d92538698317b46be49f4 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 25 Apr 2017 14:51:53 -0700 Subject: [PATCH 2771/4072] Avoid rebinding tmpfs data volumes when recreating containers Signed-off-by: Joffrey F --- compose/service.py | 12 +++++++++--- tests/integration/project_test.py | 18 ++++++++++++++++++ tests/unit/service_test.py | 16 ++++++++++------ 3 files changed, 37 insertions(+), 9 deletions(-) diff --git a/compose/service.py b/compose/service.py index e903115af57..8699372ed12 100644 --- a/compose/service.py +++ b/compose/service.py @@ -16,6 +16,7 @@ from docker.types import LogConfig from docker.utils.ports import build_port_bindings from docker.utils.ports import split_port +from docker.utils.utils import convert_tmpfs_mounts from . import __version__ from . import const @@ -744,6 +745,7 @@ def _get_container_create_options( binds, affinity = merge_volume_bindings( container_options.get('volumes') or [], + self.options.get('tmpfs') or [], previous_container) override_options['binds'] = binds container_options['environment'].update(affinity) @@ -1126,7 +1128,7 @@ def parse_repository_tag(repo_path): # Volumes -def merge_volume_bindings(volumes, previous_container): +def merge_volume_bindings(volumes, tmpfs, previous_container): """Return a list of volume bindings for a container. Container data volumes are replaced by those from the previous container. """ @@ -1138,7 +1140,7 @@ def merge_volume_bindings(volumes, previous_container): if volume.external) if previous_container: - old_volumes = get_container_data_volumes(previous_container, volumes) + old_volumes = get_container_data_volumes(previous_container, volumes, tmpfs) warn_on_masked_volume(volumes, old_volumes, previous_container.service) volume_bindings.update( build_volume_binding(volume) for volume in old_volumes) @@ -1149,7 +1151,7 @@ def merge_volume_bindings(volumes, previous_container): return list(volume_bindings.values()), affinity -def get_container_data_volumes(container, volumes_option): +def get_container_data_volumes(container, volumes_option, tmpfs_option): """Find the container data volumes that are in `volumes_option`, and return a mapping of volume bindings for those volumes. """ @@ -1172,6 +1174,10 @@ def get_container_data_volumes(container, volumes_option): if volume.external: continue + # Attempting to rebind tmpfs volumes breaks: https://github.com/docker/compose/issues/4751 + if volume.internal in convert_tmpfs_mounts(tmpfs_option).keys(): + continue + mount = container_mounts.get(volume.internal) # New volume, doesn't exist in the old container diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index b69b0456599..69f06b75c2a 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -552,6 +552,24 @@ def test_project_up_with_no_deps(self): self.assertEqual(len(project.get_service('data').containers(stopped=True)), 1) self.assertEqual(len(project.get_service('console').containers()), 0) + def test_project_up_recreate_with_tmpfs_volume(self): + # https://github.com/docker/compose/issues/4751 + project = Project.from_config( + name='composetest', + config_data=load_config({ + 'version': '2.1', + 'services': { + 'foo': { + 'image': 'busybox:latest', + 'tmpfs': ['/dev/shm'], + 'volumes': ['/dev/shm'] + } + } + }), client=self.client + ) + project.up() + project.up(strategy=ConvergenceStrategy.always) + def test_unscale_after_restart(self): web = self.create_service('web') project = Project('composetest', [web], self.client) diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index f3f3a2a83e1..c32c3633943 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -858,6 +858,7 @@ def test_get_container_data_volumes(self): '/new/volume', '/existing/volume', 'named:/named/vol', + '/dev/tmpfs' ]] self.mock_client.inspect_image.return_value = { @@ -903,15 +904,18 @@ def test_get_container_data_volumes(self): VolumeSpec.parse('imagedata:/mnt/image/data:rw'), ] - volumes = get_container_data_volumes(container, options) + volumes = get_container_data_volumes(container, options, ['/dev/tmpfs']) assert sorted(volumes) == sorted(expected) def test_merge_volume_bindings(self): options = [ - 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), + VolumeSpec.parse(v, True) for v in [ + '/host/volume:/host/volume:ro', + '/host/rw/volume:/host/rw/volume', + '/new/volume', + '/existing/volume', + '/dev/tmpfs' + ] ] self.mock_client.inspect_image.return_value = { @@ -936,7 +940,7 @@ def test_merge_volume_bindings(self): 'existingvolume:/existing/volume:rw', ] - binds, affinity = merge_volume_bindings(options, previous_container) + binds, affinity = merge_volume_bindings(options, ['/dev/tmpfs'], previous_container) assert sorted(binds) == sorted(expected) assert affinity == {'affinity:container': '=cdefab'} From f2d3ac680ecfab2e6a9163745aad3f16095c0b01 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 26 Apr 2017 13:34:45 -0700 Subject: [PATCH 2772/4072] Prevent NoneType error when remote IPAM options is None Signed-off-by: Joffrey F --- compose/network.py | 4 ++-- tests/unit/network_test.py | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/compose/network.py b/compose/network.py index 4aeff2d1e5c..ea6f49631f3 100644 --- a/compose/network.py +++ b/compose/network.py @@ -158,8 +158,8 @@ def check_remote_ipam_config(remote, local): if sorted(lc.get('AuxiliaryAddresses')) != sorted(rc.get('AuxiliaryAddresses')): raise NetworkConfigChangedError(local.full_name, 'IPAM config aux_addresses') - remote_opts = remote_ipam.get('Options', {}) - local_opts = local.ipam.get('options', {}) + remote_opts = remote_ipam.get('Options') or {} + local_opts = local.ipam.get('options') or {} for k in set.union(set(remote_opts.keys()), set(local_opts.keys())): if remote_opts.get(k) != local_opts.get(k): raise NetworkConfigChangedError(local.full_name, 'IPAM option "{}"'.format(k)) diff --git a/tests/unit/network_test.py b/tests/unit/network_test.py index a325f1948f3..d1cf2ccf955 100644 --- a/tests/unit/network_test.py +++ b/tests/unit/network_test.py @@ -100,6 +100,44 @@ def test_check_remote_network_config_null_remote(self): {'Driver': 'overlay', 'Options': None}, net ) + def test_check_remote_network_config_null_remote_ipam_options(self): + ipam_config = { + 'driver': 'default', + 'config': [ + {'subnet': '172.0.0.1/16', }, + { + 'subnet': '156.0.0.1/25', + 'gateway': '156.0.0.1', + 'aux_addresses': ['11.0.0.1', '24.25.26.27'], + 'ip_range': '156.0.0.1-254' + } + ] + } + net = Network( + None, 'compose_test', 'net1', 'bridge', ipam=ipam_config, + ) + + check_remote_network_config( + { + 'Driver': 'bridge', + 'Attachable': True, + 'IPAM': { + 'Driver': 'default', + 'Config': [{ + 'Subnet': '156.0.0.1/25', + 'Gateway': '156.0.0.1', + 'AuxiliaryAddresses': ['24.25.26.27', '11.0.0.1'], + 'IPRange': '156.0.0.1-254' + }, { + 'Subnet': '172.0.0.1/16', + 'Gateway': '172.0.0.1' + }], + 'Options': None + }, + }, + net + ) + def test_check_remote_network_labels_mismatch(self): net = Network(None, 'compose_test', 'net1', 'overlay', labels={ 'com.project.touhou.character': 'sakuya.izayoi' From 3e66c68f9f25cb5e340a5d51f9a5337e958f3ea5 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 1 May 2017 14:29:37 -0700 Subject: [PATCH 2773/4072] Fix external secrets serialization Signed-off-by: Joffrey F --- compose/config/serialize.py | 5 ++++- tests/unit/config/config_test.py | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/compose/config/serialize.py b/compose/config/serialize.py index aaaf053995c..040973ae080 100644 --- a/compose/config/serialize.py +++ b/compose/config/serialize.py @@ -51,7 +51,10 @@ def denormalize_config(config, image_digests=None): del vol_conf['external_name'] if config.version in (V3_1, V3_2): - result['secrets'] = config.secrets + result['secrets'] = config.secrets.copy() + for secret_name, secret_conf in result['secrets'].items(): + if 'external_name' in secret_conf: + del secret_conf['external_name'] return result diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 6bf4986ff56..d3087fffe46 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -3825,7 +3825,8 @@ def test_serialize_secrets(self): } secrets_dict = { 'one': {'file': '/one.txt'}, - 'source': {'file': '/source.pem'} + 'source': {'file': '/source.pem'}, + 'two': {'external': True}, } config_dict = config.load(build_config_details({ 'version': '3.1', @@ -3837,6 +3838,7 @@ def test_serialize_secrets(self): serialized_service = serialized_config['services']['web'] assert secret_sort(serialized_service['secrets']) == secret_sort(service_dict['secrets']) assert 'secrets' in serialized_config + assert serialized_config['secrets']['two'] == secrets_dict['two'] def test_serialize_ports(self): config_dict = config.Config(version='2.0', services=[ From f94cf103d6eb82f35d7cd42bf72191127528e643 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 1 May 2017 14:29:37 -0700 Subject: [PATCH 2774/4072] Fix external secrets serialization Signed-off-by: Joffrey F --- compose/config/serialize.py | 5 ++++- tests/unit/config/config_test.py | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/compose/config/serialize.py b/compose/config/serialize.py index aaaf053995c..040973ae080 100644 --- a/compose/config/serialize.py +++ b/compose/config/serialize.py @@ -51,7 +51,10 @@ def denormalize_config(config, image_digests=None): del vol_conf['external_name'] if config.version in (V3_1, V3_2): - result['secrets'] = config.secrets + result['secrets'] = config.secrets.copy() + for secret_name, secret_conf in result['secrets'].items(): + if 'external_name' in secret_conf: + del secret_conf['external_name'] return result diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 6bf4986ff56..d3087fffe46 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -3825,7 +3825,8 @@ def test_serialize_secrets(self): } secrets_dict = { 'one': {'file': '/one.txt'}, - 'source': {'file': '/source.pem'} + 'source': {'file': '/source.pem'}, + 'two': {'external': True}, } config_dict = config.load(build_config_details({ 'version': '3.1', @@ -3837,6 +3838,7 @@ def test_serialize_secrets(self): serialized_service = serialized_config['services']['web'] assert secret_sort(serialized_service['secrets']) == secret_sort(service_dict['secrets']) assert 'secrets' in serialized_config + assert serialized_config['secrets']['two'] == secrets_dict['two'] def test_serialize_ports(self): config_dict = config.Config(version='2.0', services=[ From 1719ceb881890f84d97df7eaa2d000b205879cc8 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 1 May 2017 14:38:12 -0700 Subject: [PATCH 2775/4072] Bump 1.13.0 Signed-off-by: Joffrey F --- CHANGELOG.md | 11 ++++++++++- compose/__init__.py | 2 +- script/run/run.sh | 2 +- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a8f64d75755..d1da62e3486 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ Change log ========== -1.13.0 (2017-05-01) +1.13.0 (2017-05-02) ------------------- ### Breaking changes @@ -47,6 +47,15 @@ Change log - Fixed an issue where paths containing unicode characters passed via the `-f` flag were causing Compose to crash +- Fixed an issue where the output of `docker-compose config` would be invalid + if the Compose file contained external secrets + +- Fixed a bug where using `--exit-code-from` with `up` would fail if Compose + was installed in a Python 3 environment + +- Fixed a bug where recreating containers using a combination of `tmpfs` and + `volumes` would result in an invalid config state + 1.12.0 (2017-04-04) ------------------- diff --git a/compose/__init__.py b/compose/__init__.py index f80467115b3..1f4c85725ed 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.13.0-rc1' +__version__ = '1.13.0' diff --git a/script/run/run.sh b/script/run/run.sh index beff0c6b92f..e697d1f6dca 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.13.0-rc1" +VERSION="1.13.0" IMAGE="docker/compose:$VERSION" From 353c3a7b7a5ce51ef9e0007801ecae9fe031b222 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 1 May 2017 16:48:16 -0700 Subject: [PATCH 2776/4072] Script downloading release binaries from bintray and appveyor Signed-off-by: Joffrey F --- script/release/download-binaries | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100755 script/release/download-binaries diff --git a/script/release/download-binaries b/script/release/download-binaries new file mode 100755 index 00000000000..5d01f5f75e2 --- /dev/null +++ b/script/release/download-binaries @@ -0,0 +1,32 @@ +#!/bin/bash + +function usage() { + >&2 cat << EOM +Download Linux, Mac OS and Windows binaries from remote endpoints + +Usage: + + $0 + +Options: + + version version string for the release (ex: 1.6.0) + +EOM + exit 1 +} + + +[ -n "$1" ] || usage +VERSION=$1 +BASE_BINTRAY_URL=https://dl.bintray.com/docker-compose/bump-$VERSION/ +DESTINATION=binaries-$VERSION +APPVEYOR_URL=https://ci.appveyor.com/api/projects/docker/compose/\ +artifacts/dist%2Fdocker-compose-Windows-x86_64.exe?branch=bump-$VERSION + +mkdir $DESTINATION + + +wget -O $DESTINATION/docker-compose-Darwin-x86_64 $BASE_BINTRAY_URL/docker-compose-Darwin-x86_64 +wget -O $DESTINATION/docker-compose-Linux-x86_64 $BASE_BINTRAY_URL/docker-compose-Linux-x86_64 +wget -O $DESTINATION/docker-compose-Windows-x86_64.exe $APPVEYOR_URL From ebff0d915a7a9ef27f6c6bdc802e4718e10bcaa2 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 4 May 2017 12:47:44 -0700 Subject: [PATCH 2777/4072] 1.14.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 1f4c85725ed..69307d60e81 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.13.0' +__version__ = '1.14.0dev' From f3d09df19b04b80ebffa8072cb9da5383d8fb497 Mon Sep 17 00:00:00 2001 From: Michael Friis Date: Thu, 4 May 2017 18:08:33 -0700 Subject: [PATCH 2778/4072] add exception for windows networking Signed-off-by: Michael Friis --- compose/network.py | 1 + 1 file changed, 1 insertion(+) diff --git a/compose/network.py b/compose/network.py index ea6f49631f3..532686d761c 100644 --- a/compose/network.py +++ b/compose/network.py @@ -18,6 +18,7 @@ OPTS_EXCEPTIONS = [ 'com.docker.network.driver.overlay.vxlanid_list', + 'com.docker.network.windowsshim.hnsid' ] From babd7530c31d28394943a4e82b8acca29b9058ff Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 5 May 2017 11:40:50 -0700 Subject: [PATCH 2779/4072] New network config whitelist option in unit test Signed-off-by: Joffrey F --- tests/unit/network_test.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/unit/network_test.py b/tests/unit/network_test.py index d1cf2ccf955..4b40ea88436 100644 --- a/tests/unit/network_test.py +++ b/tests/unit/network_test.py @@ -66,7 +66,8 @@ 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' + 'com.docker.network.driver.foo': 'bar', + 'com.docker.network.windowsshim.hnsid': 'aac3fd4887daaec1e3b', } net = Network( None, 'compose_test', 'net1', 'overlay', From 07680b77a63d628119ff2eb9cbcab65331262349 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 5 May 2017 16:40:45 -0700 Subject: [PATCH 2780/4072] Use different method to compute ServicePort.repr Workaround for https://bugs.python.org/issue24931 Signed-off-by: Joffrey F --- compose/config/types.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/config/types.py b/compose/config/types.py index dd61a879630..5d3bb5cb758 100644 --- a/compose/config/types.py +++ b/compose/config/types.py @@ -306,7 +306,7 @@ def merge_field(self): def repr(self): return dict( - [(k, v) for k, v in self._asdict().items() if v is not None] + [(k, v) for k, v in zip(self._fields, self) if v is not None] ) def legacy_repr(self): From e74a5e449cad55c2b9faf8752e7cf213e98ceace Mon Sep 17 00:00:00 2001 From: Victoria Bialas Date: Mon, 8 May 2017 16:31:19 -0700 Subject: [PATCH 2781/4072] Updated CLI help for docker-compose pull command removed reference to docker-stack.yml in pull command help referenced generic Compose file, consistent naming in Help, init caps Signed-off-by: Victoria Bialas --- compose/cli/main.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 37e299d94e1..5b65e5dd9ef 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -171,12 +171,12 @@ class TopLevelCommand(object): in the client certificate (for example if your docker host is an IP address) --project-directory PATH Specify an alternate working directory - (default: the path of the compose file) + (default: the path of the Compose file) Commands: build Build or rebuild services bundle Generate a Docker bundle from the Compose file - config Validate and view the compose file + 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 @@ -273,7 +273,7 @@ def bundle(self, config_options, options): def config(self, config_options, options): """ - Validate and view the compose file. + Validate and view the Compose file. Usage: config [options] @@ -627,7 +627,7 @@ def ps(self, options): def pull(self, options): """ - Pulls images for services. + Pulls images for services defined in a Compose file, but does not start the containers. Usage: pull [options] [SERVICE...] From 16bbe5d99c96b53036df85a89512edb5cf87adce Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Thu, 11 May 2017 18:19:01 +0200 Subject: [PATCH 2782/4072] Add docker-compose exec -u to docs and completion Signed-off-by: Harald Albers --- compose/cli/main.py | 2 +- contrib/completion/bash/docker-compose | 4 ++-- contrib/completion/zsh/_docker-compose | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 5b65e5dd9ef..49800ba5326 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -391,7 +391,7 @@ def exec_command(self, options): Options: -d Detached mode: Run command in the background. --privileged Give extended privileges to the process. - --user USER Run the command as this user. + -u, --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 diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index 2c2be61c758..57dfd51f5a5 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -224,14 +224,14 @@ _docker_compose_events() { _docker_compose_exec() { case "$prev" in - --index|--user) + --index|--user|-u) return ;; esac case "$cur" in -*) - COMPREPLY=( $( compgen -W "-d --help --index --privileged -T --user" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "-d --help --index --privileged -T --user -u" -- "$cur" ) ) ;; *) __docker_compose_services_running diff --git a/contrib/completion/zsh/_docker-compose b/contrib/completion/zsh/_docker-compose index 8513884bcdd..f53f963347e 100644 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -241,7 +241,7 @@ __docker-compose_subcommand() { $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' \ + '(-u --user)'{-u,--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' \ From 59a4f554b95aa7ba63c7ceae70f97f724ee279fd Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 12 May 2017 17:39:56 -0700 Subject: [PATCH 2783/4072] Prevent dependencies rescaling when executing `docker-compose run` Signed-off-by: Joffrey F --- compose/cli/main.py | 4 +++- compose/project.py | 6 ++++-- compose/service.py | 15 ++++++++++----- tests/acceptance/cli_test.py | 11 +++++++++++ 4 files changed, 28 insertions(+), 8 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 5b65e5dd9ef..36de3efab8b 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -1138,7 +1138,9 @@ def run_one_off_container(container_options, project, service, options): project.up( service_names=deps, start_deps=True, - strategy=ConvergenceStrategy.never) + strategy=ConvergenceStrategy.never, + rescale=False + ) project.initialize() diff --git a/compose/project.py b/compose/project.py index e80b10455db..b282f718de5 100644 --- a/compose/project.py +++ b/compose/project.py @@ -382,7 +382,8 @@ def up(self, timeout=None, detached=False, remove_orphans=False, - scale_override=None): + scale_override=None, + rescale=True): warn_for_swarm_mode(self.client) @@ -405,7 +406,8 @@ def do(service): plans[service.name], timeout=timeout, detached=detached, - scale_override=scale_override.get(service.name) + scale_override=scale_override.get(service.name), + rescale=rescale ) def get_deps(service): diff --git a/compose/service.py b/compose/service.py index 8699372ed12..edd0a376478 100644 --- a/compose/service.py +++ b/compose/service.py @@ -390,7 +390,7 @@ def create_and_start(service, n): return containers def _execute_convergence_recreate(self, containers, scale, timeout, detached, start): - if len(containers) > scale: + if scale is not None and len(containers) > scale: self._downscale(containers[scale:], timeout) containers = containers[:scale] @@ -408,14 +408,14 @@ def recreate(container): for error in errors.values(): raise OperationFailedError(error) - if len(containers) < scale: + if scale is not None and len(containers) < scale: containers.extend(self._execute_convergence_create( scale - len(containers), detached, start )) return containers def _execute_convergence_start(self, containers, scale, timeout, detached, start): - if len(containers) > scale: + if scale is not None and len(containers) > scale: self._downscale(containers[scale:], timeout) containers = containers[:scale] if start: @@ -429,7 +429,7 @@ def _execute_convergence_start(self, containers, scale, timeout, detached, start for error in errors.values(): raise OperationFailedError(error) - if len(containers) < scale: + if scale is not None and len(containers) < scale: containers.extend(self._execute_convergence_create( scale - len(containers), detached, start )) @@ -448,7 +448,7 @@ def stop_and_remove(container): ) def execute_convergence_plan(self, plan, timeout=None, detached=False, - start=True, scale_override=None): + start=True, scale_override=None, rescale=True): (action, containers) = plan scale = scale_override if scale_override is not None else self.scale_num containers = sorted(containers, key=attrgetter('number')) @@ -460,6 +460,11 @@ def execute_convergence_plan(self, plan, timeout=None, detached=False, scale, detached, start ) + # The create action needs always needs an initial scale, but otherwise, + # we set scale to none in no-rescale scenarios (`run` dependencies) + if not rescale: + scale = None + if action == 'recreate': return self._execute_convergence_recreate( containers, scale, timeout, detached, start diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 75b15ae65a2..30eff1b6aff 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1211,6 +1211,17 @@ def test_run_service_with_dependencies(self): self.assertEqual(len(db.containers()), 1) self.assertEqual(len(console.containers()), 0) + def test_run_service_with_scaled_dependencies(self): + self.base_dir = 'tests/fixtures/v2-dependencies' + self.dispatch(['up', '-d', '--scale', 'db=2', '--scale', 'console=0']) + db = self.project.get_service('db') + console = self.project.get_service('console') + assert len(db.containers()) == 2 + assert len(console.containers()) == 0 + self.dispatch(['run', 'web', '/bin/true'], None) + assert len(db.containers()) == 2 + assert 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']) From 3a76f95e284f7011c96e3482a91c8b558787d745 Mon Sep 17 00:00:00 2001 From: mengskysama Date: Mon, 15 May 2017 15:22:22 +0800 Subject: [PATCH 2784/4072] fix python3.x _asdict() return None Signed-off-by: mengskysama --- compose/config/types.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/config/types.py b/compose/config/types.py index 5d3bb5cb758..d853d84f455 100644 --- a/compose/config/types.py +++ b/compose/config/types.py @@ -258,7 +258,7 @@ def merge_field(self): def repr(self): return dict( - [(k, v) for k, v in self._asdict().items() if v is not None] + [(k, v) for k, v in zip(self._fields, self) if v is not None] ) From 0043dc4fab4f330b1c6d923c38cf01a93ad6172a Mon Sep 17 00:00:00 2001 From: Alexey Rokhin Date: Wed, 17 May 2017 14:50:29 +0300 Subject: [PATCH 2785/4072] Add cpu_count, cpu_percent, cpus parameters. Signed-off-by: Alexey Rokhin --- compose/config/config.py | 3 +++ compose/config/config_schema_v2.2.json | 3 +++ compose/service.py | 10 ++++++++++ setup.py | 2 +- tests/integration/service_test.py | 25 +++++++++++++++++++++++++ tests/integration/testcases.py | 5 ++++- tests/unit/config/config_test.py | 4 ++++ 7 files changed, 50 insertions(+), 2 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index f1195c8ec67..056847a85b8 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -52,8 +52,11 @@ 'cap_drop', 'cgroup_parent', 'command', + 'cpu_count', + 'cpu_percent', 'cpu_quota', 'cpu_shares', + 'cpus', 'cpuset', 'detach', 'devices', diff --git a/compose/config/config_schema_v2.2.json b/compose/config/config_schema_v2.2.json index a178fccc40a..bbf312c6a8c 100644 --- a/compose/config/config_schema_v2.2.json +++ b/compose/config/config_schema_v2.2.json @@ -74,8 +74,11 @@ ] }, "container_name": {"type": "string"}, + "cpu_count": {"type": "integer", "minimum": 0}, + "cpu_percent": {"type": "integer", "minimum": 0, "maximum": 100}, "cpu_shares": {"type": ["number", "string"]}, "cpu_quota": {"type": ["number", "string"]}, + "cpus": {"type": ["number", "string"], "minimum": 0}, "cpuset": {"type": "string"}, "depends_on": { "oneOf": [ diff --git a/compose/service.py b/compose/service.py index edd0a376478..dc653b1652e 100644 --- a/compose/service.py +++ b/compose/service.py @@ -52,7 +52,10 @@ 'cap_add', 'cap_drop', 'cgroup_parent', + 'cpu_count', + 'cpu_percent', 'cpu_quota', + 'cpus', 'devices', 'dns', 'dns_search', @@ -798,6 +801,10 @@ def _get_container_host_config(self, override_options, one_off=False): init_path = options.get('init') options['init'] = True + nano_cpus = None + if options.has_key('cpus'): + nano_cpus = int(options.get('cpus') * 1000000000) + return self.client.create_host_config( links=self._get_links(link_to_self=one_off), port_bindings=build_port_bindings( @@ -837,6 +844,9 @@ def _get_container_host_config(self, override_options, one_off=False): init=options.get('init', None), init_path=init_path, isolation=options.get('isolation'), + cpu_count=options.get('cpu_count'), + cpu_percent=options.get('cpu_percent'), + nano_cpus=nano_cpus, ) def get_secret_volumes(self): diff --git a/setup.py b/setup.py index 19a0d4aa00d..8dbb337cc24 100644 --- a/setup.py +++ b/setup.py @@ -37,7 +37,7 @@ def find_version(*file_paths): 'requests >= 2.6.1, != 2.11.0, < 2.12', 'texttable >= 0.8.1, < 0.9', 'websocket-client >= 0.32.0, < 1.0', - 'docker >= 2.2.1, < 3.0', + 'docker >= 2.3.0, < 3.0', 'dockerpty >= 0.4.1, < 0.5', 'six >= 1.3.0, < 2', 'jsonschema >= 2.5.1, < 3', diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 87549c50680..4ee9c3d1996 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -33,6 +33,7 @@ from compose.service import NetworkMode from compose.service import Service from tests.integration.testcases import v2_1_only +from tests.integration.testcases import v2_2_only from tests.integration.testcases import v2_only from tests.integration.testcases import v3_only @@ -110,6 +111,30 @@ def test_create_container_with_cpu_quota(self): container.start() self.assertEqual(container.get('HostConfig.CpuQuota'), 40000) + @v2_2_only() + def test_create_container_with_cpu_count(self): + self.require_api_version('1.25') + service = self.create_service('db', cpu_count=2) + container = service.create_container() + service.start_container(container) + self.assertEqual(container.get('HostConfig.CpuCount'), 2) + + @v2_2_only() + def test_create_container_with_cpu_percent(self): + self.require_api_version('1.25') + service = self.create_service('db', cpu_percent=12) + container = service.create_container() + service.start_container(container) + self.assertEqual(container.get('HostConfig.CpuPercent'), 12) + + @v2_2_only() + def test_create_container_with_cpus(self): + self.require_api_version('1.25') + service = self.create_service('db', cpus=1) + container = service.create_container() + service.start_container(container) + self.assertEqual(container.get('HostConfig.NanoCpus'), 1000000000) + def test_create_container_with_shm_size(self): self.require_api_version('1.22') service = self.create_service('db', shm_size=67108864) diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index a5fe999d979..1bed6e8ff7a 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -15,6 +15,7 @@ from compose.const import COMPOSEFILE_V1 as V1 from compose.const import COMPOSEFILE_V2_0 as V2_0 from compose.const import COMPOSEFILE_V2_0 as V2_1 +from compose.const import COMPOSEFILE_V2_2 as V2_2 from compose.const import COMPOSEFILE_V3_2 as V3_2 from compose.const import LABEL_PROJECT from compose.progress_stream import stream_output @@ -69,9 +70,11 @@ def v2_only(): def v2_1_only(): return build_version_required_decorator((V1, V2_0)) +def v2_2_only(): + return build_version_required_decorator((V1, V2_0, V2_1)) def v3_only(): - return build_version_required_decorator((V1, V2_0, V2_1)) + return build_version_required_decorator((V1, V2_0, V2_1, V2_2)) class DockerClientTestCase(unittest.TestCase): diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index d3087fffe46..e66e952f83f 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -27,6 +27,7 @@ from compose.const import COMPOSEFILE_V1 as V1 from compose.const import COMPOSEFILE_V2_0 as V2_0 from compose.const import COMPOSEFILE_V2_1 as V2_1 +from compose.const import COMPOSEFILE_V2_2 as V2_2 from compose.const import COMPOSEFILE_V3_0 as V3_0 from compose.const import COMPOSEFILE_V3_1 as V3_1 from compose.const import COMPOSEFILE_V3_2 as V3_2 @@ -174,6 +175,9 @@ def test_valid_versions(self): cfg = config.load(build_config_details({'version': '2.1'})) assert cfg.version == V2_1 + cfg = config.load(build_config_details({'version': '2.2'})) + assert cfg.version == V2_2 + for version in ['3', '3.0']: cfg = config.load(build_config_details({'version': version})) assert cfg.version == V3_0 From faaca661acdfb3cbf2bea4f069a0b54f49f5ff27 Mon Sep 17 00:00:00 2001 From: Alexey Rokhin Date: Wed, 17 May 2017 15:10:44 +0300 Subject: [PATCH 2786/4072] Fix cpu option checking. Signed-off-by: Alexey Rokhin --- compose/service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/service.py b/compose/service.py index dc653b1652e..3956a478217 100644 --- a/compose/service.py +++ b/compose/service.py @@ -802,7 +802,7 @@ def _get_container_host_config(self, override_options, one_off=False): options['init'] = True nano_cpus = None - if options.has_key('cpus'): + if 'cpus' in options: nano_cpus = int(options.get('cpus') * 1000000000) return self.client.create_host_config( From 3e39aa07097d47ca0627641149bcba3ebcc071f3 Mon Sep 17 00:00:00 2001 From: Alexey Rokhin Date: Wed, 17 May 2017 15:21:35 +0300 Subject: [PATCH 2787/4072] Fix testcases.py formatting Signed-off-by: Alexey Rokhin --- tests/integration/testcases.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index 1bed6e8ff7a..57814872cfa 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -70,9 +70,11 @@ def v2_only(): def v2_1_only(): return build_version_required_decorator((V1, V2_0)) + def v2_2_only(): return build_version_required_decorator((V1, V2_0, V2_1)) + def v3_only(): return build_version_required_decorator((V1, V2_0, V2_1, V2_2)) From 61ed6ca8a407d8d611ed83632aa1c8d5eb256601 Mon Sep 17 00:00:00 2001 From: Alexey Rokhin Date: Wed, 17 May 2017 16:18:28 +0300 Subject: [PATCH 2788/4072] skip cpu_percent test for Linux Signed-off-by: Alexey Rokhin --- 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 4ee9c3d1996..a1a7497a63f 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -25,6 +25,7 @@ from compose.const import LABEL_PROJECT from compose.const import LABEL_SERVICE from compose.const import LABEL_VERSION +from compose.const import IS_WINDOWS_PLATFORM from compose.container import Container from compose.errors import OperationFailedError from compose.project import OneOffFilter @@ -120,6 +121,7 @@ def test_create_container_with_cpu_count(self): self.assertEqual(container.get('HostConfig.CpuCount'), 2) @v2_2_only() + @pytest.mark.skipif(not IS_WINDOWS_PLATFORM, reason='cpu_percent is not supported for Linux') def test_create_container_with_cpu_percent(self): self.require_api_version('1.25') service = self.create_service('db', cpu_percent=12) From bffdb7a349f4db65535dd4fce0b946c30c4d3030 Mon Sep 17 00:00:00 2001 From: Alexey Rokhin Date: Wed, 17 May 2017 16:42:43 +0300 Subject: [PATCH 2789/4072] service_test.py reorder imports Signed-off-by: Alexey Rokhin --- 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 a1a7497a63f..a5b5bda5727 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -19,13 +19,13 @@ from compose import __version__ from compose.config.types import VolumeFromSpec from compose.config.types import VolumeSpec +from compose.const import IS_WINDOWS_PLATFORM from compose.const import LABEL_CONFIG_HASH from compose.const import LABEL_CONTAINER_NUMBER from compose.const import LABEL_ONE_OFF from compose.const import LABEL_PROJECT from compose.const import LABEL_SERVICE from compose.const import LABEL_VERSION -from compose.const import IS_WINDOWS_PLATFORM from compose.container import Container from compose.errors import OperationFailedError from compose.project import OneOffFilter From b7c688cc375c5e62a557b68afbf3422a885bba43 Mon Sep 17 00:00:00 2001 From: Alexey Rokhin Date: Wed, 17 May 2017 23:21:47 +0300 Subject: [PATCH 2790/4072] Implement review suggestions. Signed-off-by: Alexey Rokhin --- compose/config/config_schema_v2.2.json | 2 +- compose/service.py | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/compose/config/config_schema_v2.2.json b/compose/config/config_schema_v2.2.json index bbf312c6a8c..a585f2a8c87 100644 --- a/compose/config/config_schema_v2.2.json +++ b/compose/config/config_schema_v2.2.json @@ -78,7 +78,7 @@ "cpu_percent": {"type": "integer", "minimum": 0, "maximum": 100}, "cpu_shares": {"type": ["number", "string"]}, "cpu_quota": {"type": ["number", "string"]}, - "cpus": {"type": ["number", "string"], "minimum": 0}, + "cpus": {"type": "number", "minimum": 0}, "cpuset": {"type": "string"}, "depends_on": { "oneOf": [ diff --git a/compose/service.py b/compose/service.py index 3956a478217..515992ad46d 100644 --- a/compose/service.py +++ b/compose/service.py @@ -803,7 +803,10 @@ def _get_container_host_config(self, override_options, one_off=False): nano_cpus = None if 'cpus' in options: - nano_cpus = int(options.get('cpus') * 1000000000) + nano_cpus = options.get('cpus') * 1000000000 + if isinstance(nano_cpus, float) and not nano_cpus.is_integer(): + raise ValueError("cpus is too precise") + nano_cpus = int(nano_cpus) return self.client.create_host_config( links=self._get_links(link_to_self=one_off), From 61e54514c417a8ea9ec9b713dd81831a44dbea9f Mon Sep 17 00:00:00 2001 From: Alexey Rokhin Date: Thu, 18 May 2017 01:43:04 +0300 Subject: [PATCH 2791/4072] move cpus validation to validation.py Signed-off-by: Alexey Rokhin --- compose/config/config.py | 2 ++ compose/config/validation.py | 11 +++++++++++ compose/const.py | 1 + compose/service.py | 6 ++---- 4 files changed, 16 insertions(+), 4 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 056847a85b8..4fddac82270 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -38,6 +38,7 @@ from .validation import match_named_volumes from .validation import validate_against_config_schema from .validation import validate_config_section +from .validation import validate_cpu from .validation import validate_depends_on from .validation import validate_extends_file_path from .validation import validate_links @@ -643,6 +644,7 @@ def validate_service(service_config, service_names, config_file): validate_service_constraints(service_dict, service_name, config_file) validate_paths(service_dict) + validate_cpu(service_config) validate_ulimits(service_config) validate_network_mode(service_config, service_names) validate_depends_on(service_config, service_names) diff --git a/compose/config/validation.py b/compose/config/validation.py index 1df6dd6b7c7..856f811c510 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -15,6 +15,7 @@ from jsonschema import ValidationError from ..const import COMPOSEFILE_V1 as V1 +from ..const import NANOCPUS_SCALE from .errors import ConfigurationError from .errors import VERSION_EXPLANATION from .sort_services import get_service_name_from_network_mode @@ -387,6 +388,16 @@ def handler(errors): handle_errors(validator.iter_errors(config), handler, None) +def validate_cpu(service_config): + cpus = service_config.config.get('cpus') + if not cpus: + return + nano_cpus = cpus * NANOCPUS_SCALE + if isinstance(nano_cpus, float) and not nano_cpus.is_integer(): + raise ConfigurationError( + "cpus must have nine or less digits after decimal point") + + def get_schema_path(): return os.path.dirname(os.path.abspath(__file__)) diff --git a/compose/const.py b/compose/const.py index 573136d5d18..36703138acb 100644 --- a/compose/const.py +++ b/compose/const.py @@ -15,6 +15,7 @@ LABEL_VERSION = 'com.docker.compose.version' LABEL_VOLUME = 'com.docker.compose.volume' LABEL_CONFIG_HASH = 'com.docker.compose.config-hash' +NANOCPUS_SCALE = 1000000000 SECRETS_PATH = '/run/secrets' diff --git a/compose/service.py b/compose/service.py index 515992ad46d..19873d5e5cd 100644 --- a/compose/service.py +++ b/compose/service.py @@ -34,6 +34,7 @@ from .const import LABEL_PROJECT from .const import LABEL_SERVICE from .const import LABEL_VERSION +from .const import NANOCPUS_SCALE from .container import Container from .errors import HealthCheckFailed from .errors import NoHealthCheckConfigured @@ -803,10 +804,7 @@ def _get_container_host_config(self, override_options, one_off=False): nano_cpus = None if 'cpus' in options: - nano_cpus = options.get('cpus') * 1000000000 - if isinstance(nano_cpus, float) and not nano_cpus.is_integer(): - raise ValueError("cpus is too precise") - nano_cpus = int(nano_cpus) + nano_cpus = int(options.get('cpus') * NANOCPUS_SCALE) return self.client.create_host_config( links=self._get_links(link_to_self=one_off), From d906f9ce92fe66ad89d4d0a95480e13f0aac9215 Mon Sep 17 00:00:00 2001 From: Colin Hebert Date: Thu, 13 Apr 2017 21:51:41 +1000 Subject: [PATCH 2792/4072] Add support for labels during build Signed-off-by: Colin Hebert --- compose/config/config_schema_v3.2.json | 1 + compose/service.py | 1 + 2 files changed, 2 insertions(+) diff --git a/compose/config/config_schema_v3.2.json b/compose/config/config_schema_v3.2.json index ea702fcd581..70ff6ce0564 100644 --- a/compose/config/config_schema_v3.2.json +++ b/compose/config/config_schema_v3.2.json @@ -72,6 +72,7 @@ "context": {"type": "string"}, "dockerfile": {"type": "string"}, "args": {"$ref": "#/definitions/list_or_dict"}, + "labels": {"$ref": "#/definitions/list_or_dict"}, "cache_from": {"$ref": "#/definitions/list_of_strings"} }, "additionalProperties": false diff --git a/compose/service.py b/compose/service.py index 19873d5e5cd..dcbbe251ed0 100644 --- a/compose/service.py +++ b/compose/service.py @@ -884,6 +884,7 @@ def build(self, no_cache=False, pull=False, force_rm=False, build_args_override= nocache=no_cache, dockerfile=build_opts.get('dockerfile', None), cache_from=build_opts.get('cache_from', None), + labels=build_opts.get('labels', None), buildargs=build_args ) From 7fca689efd90427f546cce18e3bbc022ffde412b Mon Sep 17 00:00:00 2001 From: Colin Hebert Date: Thu, 13 Apr 2017 22:21:33 +1000 Subject: [PATCH 2793/4072] Update tests to show labels set to None Signed-off-by: Colin Hebert --- tests/unit/service_test.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index c32c3633943..7b7a078f8cd 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -471,6 +471,7 @@ def test_create_container(self): nocache=False, rm=True, buildargs={}, + labels=None, cache_from=None, ) @@ -508,6 +509,7 @@ def test_ensure_image_exists_force_build(self): nocache=False, rm=True, buildargs={}, + labels=None, cache_from=None, ) From 37de55865b22852ce4bf29b2218dc8d1f2fc8824 Mon Sep 17 00:00:00 2001 From: Colin Hebert Date: Thu, 13 Apr 2017 22:35:21 +1000 Subject: [PATCH 2794/4072] Add tests for the labels Signed-off-by: Colin Hebert --- tests/unit/config/config_test.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index e66e952f83f..3d42b8392e2 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -825,6 +825,34 @@ def test_load_with_buildargs(self): assert service['build']['args']['opt1'] == '42' assert service['build']['args']['opt2'] == 'foobar' + def test_load_with_labels(self): + service = config.load( + build_config_details( + { + 'version': '3.2', + 'services': { + 'web': { + 'build': { + 'context': '.', + 'dockerfile': 'Dockerfile-alt', + 'labels': { + 'label1': 42, + 'label2': 'foobar' + } + } + } + } + }, + 'tests/fixtures/extends', + 'filename.yml' + ) + ).services[0] + assert 'labels' in service['build'] + assert 'label1' in service['build']['labels'] + assert isinstance(service['build']['labels']['label1'], str) + assert service['build']['labels']['label1'] == '42' + assert service['build']['labels']['label2'] == 'foobar' + def test_build_args_allow_empty_properties(self): service = config.load( build_config_details( From 9d78258b444f9038d80b0543e93fa5424ba4f0d7 Mon Sep 17 00:00:00 2001 From: Colin Hebert Date: Thu, 13 Apr 2017 22:40:07 +1000 Subject: [PATCH 2795/4072] Fix test type Signed-off-by: Colin Hebert --- tests/unit/config/config_test.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 3d42b8392e2..bc51600357b 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -849,8 +849,7 @@ def test_load_with_labels(self): ).services[0] assert 'labels' in service['build'] assert 'label1' in service['build']['labels'] - assert isinstance(service['build']['labels']['label1'], str) - assert service['build']['labels']['label1'] == '42' + assert service['build']['labels']['label1'] == 42 assert service['build']['labels']['label2'] == 'foobar' def test_build_args_allow_empty_properties(self): From fa77856c8641c076d8d3652bbe354a7cebfab172 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 23 May 2017 11:47:59 -0700 Subject: [PATCH 2796/4072] Add 3.3 format support Remove build.labels field from 3.2 schema Signed-off-by: Joffrey F --- compose/config/config_schema_v3.2.json | 1 - compose/config/config_schema_v3.3.json | 534 +++++++++++++++++++++++++ compose/config/serialize.py | 5 +- compose/const.py | 3 + docker-compose.spec | 5 + tests/unit/config/config_test.py | 5 +- 6 files changed, 548 insertions(+), 5 deletions(-) create mode 100644 compose/config/config_schema_v3.3.json diff --git a/compose/config/config_schema_v3.2.json b/compose/config/config_schema_v3.2.json index 70ff6ce0564..ea702fcd581 100644 --- a/compose/config/config_schema_v3.2.json +++ b/compose/config/config_schema_v3.2.json @@ -72,7 +72,6 @@ "context": {"type": "string"}, "dockerfile": {"type": "string"}, "args": {"$ref": "#/definitions/list_or_dict"}, - "labels": {"$ref": "#/definitions/list_or_dict"}, "cache_from": {"$ref": "#/definitions/list_of_strings"} }, "additionalProperties": false diff --git a/compose/config/config_schema_v3.3.json b/compose/config/config_schema_v3.3.json new file mode 100644 index 00000000000..e69116c3889 --- /dev/null +++ b/compose/config/config_schema_v3.3.json @@ -0,0 +1,534 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "id": "config_schema_v3.3.json", + "type": "object", + "required": ["version"], + + "properties": { + "version": { + "type": "string" + }, + + "services": { + "id": "#/properties/services", + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "$ref": "#/definitions/service" + } + }, + "additionalProperties": false + }, + + "networks": { + "id": "#/properties/networks", + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "$ref": "#/definitions/network" + } + } + }, + + "volumes": { + "id": "#/properties/volumes", + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "$ref": "#/definitions/volume" + } + }, + "additionalProperties": false + }, + + "secrets": { + "id": "#/properties/secrets", + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "$ref": "#/definitions/secret" + } + }, + "additionalProperties": false + }, + + "configs": { + "id": "#/properties/configs", + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "$ref": "#/definitions/config" + } + }, + "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"}, + "labels": {"$ref": "#/definitions/list_or_dict"}, + "cache_from": {"$ref": "#/definitions/list_of_strings"} + }, + "additionalProperties": false + } + ] + }, + "cap_add": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "cap_drop": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "cgroup_parent": {"type": "string"}, + "command": { + "oneOf": [ + {"type": "string"}, + {"type": "array", "items": {"type": "string"}} + ] + }, + "configs": { + "type": "array", + "items": { + "oneOf": [ + {"type": "string"}, + { + "type": "object", + "properties": { + "source": {"type": "string"}, + "target": {"type": "string"}, + "uid": {"type": "string"}, + "gid": {"type": "string"}, + "mode": {"type": "number"} + } + } + ] + } + }, + "container_name": {"type": "string"}, + "credential_spec": {"type": "object", "properties": { + "file": {"type": "string"}, + "registry": {"type": "string"} + }}, + "depends_on": {"$ref": "#/definitions/list_of_strings"}, + "devices": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "dns": {"$ref": "#/definitions/string_or_list"}, + "dns_search": {"$ref": "#/definitions/string_or_list"}, + "domainname": {"type": "string"}, + "entrypoint": { + "oneOf": [ + {"type": "string"}, + {"type": "array", "items": {"type": "string"}} + ] + }, + "env_file": {"$ref": "#/definitions/string_or_list"}, + "environment": {"$ref": "#/definitions/list_or_dict"}, + + "expose": { + "type": "array", + "items": { + "type": ["string", "number"], + "format": "expose" + }, + "uniqueItems": true + }, + + "external_links": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "extra_hosts": {"$ref": "#/definitions/list_or_dict"}, + "healthcheck": {"$ref": "#/definitions/healthcheck"}, + "hostname": {"type": "string"}, + "image": {"type": "string"}, + "ipc": {"type": "string"}, + "labels": {"$ref": "#/definitions/list_or_dict"}, + "links": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + + "logging": { + "type": "object", + + "properties": { + "driver": {"type": "string"}, + "options": { + "type": "object", + "patternProperties": { + "^.+$": {"type": ["string", "number", "null"]} + } + } + }, + "additionalProperties": false + }, + + "mac_address": {"type": "string"}, + "network_mode": {"type": "string"}, + + "networks": { + "oneOf": [ + {"$ref": "#/definitions/list_of_strings"}, + { + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "oneOf": [ + { + "type": "object", + "properties": { + "aliases": {"$ref": "#/definitions/list_of_strings"}, + "ipv4_address": {"type": "string"}, + "ipv6_address": {"type": "string"} + }, + "additionalProperties": false + }, + {"type": "null"} + ] + } + }, + "additionalProperties": false + } + ] + }, + "pid": {"type": ["string", "null"]}, + + "ports": { + "type": "array", + "items": { + "oneOf": [ + {"type": "number", "format": "ports"}, + {"type": "string", "format": "ports"}, + { + "type": "object", + "properties": { + "mode": {"type": "string"}, + "target": {"type": "integer"}, + "published": {"type": "integer"}, + "protocol": {"type": "string"} + }, + "additionalProperties": false + } + ] + }, + "uniqueItems": true + }, + + "privileged": {"type": "boolean"}, + "read_only": {"type": "boolean"}, + "restart": {"type": "string"}, + "security_opt": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "shm_size": {"type": ["number", "string"]}, + "secrets": { + "type": "array", + "items": { + "oneOf": [ + {"type": "string"}, + { + "type": "object", + "properties": { + "source": {"type": "string"}, + "target": {"type": "string"}, + "uid": {"type": "string"}, + "gid": {"type": "string"}, + "mode": {"type": "number"} + } + } + ] + } + }, + "sysctls": {"$ref": "#/definitions/list_or_dict"}, + "stdin_open": {"type": "boolean"}, + "stop_grace_period": {"type": "string", "format": "duration"}, + "stop_signal": {"type": "string"}, + "tmpfs": {"$ref": "#/definitions/string_or_list"}, + "tty": {"type": "boolean"}, + "ulimits": { + "type": "object", + "patternProperties": { + "^[a-z]+$": { + "oneOf": [ + {"type": "integer"}, + { + "type":"object", + "properties": { + "hard": {"type": "integer"}, + "soft": {"type": "integer"} + }, + "required": ["soft", "hard"], + "additionalProperties": false + } + ] + } + } + }, + "user": {"type": "string"}, + "userns_mode": {"type": "string"}, + "volumes": { + "type": "array", + "items": { + "oneOf": [ + {"type": "string"}, + { + "type": "object", + "required": ["type"], + "properties": { + "type": {"type": "string"}, + "source": {"type": "string"}, + "target": {"type": "string"}, + "read_only": {"type": "boolean"}, + "consistency": {"type": "string"}, + "bind": { + "type": "object", + "properties": { + "propagation": {"type": "string"} + } + }, + "volume": { + "type": "object", + "properties": { + "nocopy": {"type": "boolean"} + } + } + } + } + ], + "uniqueItems": true + } + }, + "working_dir": {"type": "string"} + }, + "additionalProperties": false + }, + + "healthcheck": { + "id": "#/definitions/healthcheck", + "type": "object", + "additionalProperties": false, + "properties": { + "disable": {"type": "boolean"}, + "interval": {"type": "string"}, + "retries": {"type": "number"}, + "test": { + "oneOf": [ + {"type": "string"}, + {"type": "array", "items": {"type": "string"}} + ] + }, + "timeout": {"type": "string"} + } + }, + "deployment": { + "id": "#/definitions/deployment", + "type": ["object", "null"], + "properties": { + "mode": {"type": "string"}, + "endpoint_mode": {"type": "string"}, + "replicas": {"type": "integer"}, + "labels": {"$ref": "#/definitions/list_or_dict"}, + "update_config": { + "type": "object", + "properties": { + "parallelism": {"type": "integer"}, + "delay": {"type": "string", "format": "duration"}, + "failure_action": {"type": "string"}, + "monitor": {"type": "string", "format": "duration"}, + "max_failure_ratio": {"type": "number"} + }, + "additionalProperties": false + }, + "resources": { + "type": "object", + "properties": { + "limits": {"$ref": "#/definitions/resource"}, + "reservations": {"$ref": "#/definitions/resource"} + } + }, + "restart_policy": { + "type": "object", + "properties": { + "condition": {"type": "string"}, + "delay": {"type": "string", "format": "duration"}, + "max_attempts": {"type": "integer"}, + "window": {"type": "string", "format": "duration"} + }, + "additionalProperties": false + }, + "placement": { + "type": "object", + "properties": { + "constraints": {"type": "array", "items": {"type": "string"}}, + "preferences": { + "type": "array", + "items": { + "type": "object", + "properties": { + "spread": {"type": "string"} + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + + "resource": { + "id": "#/definitions/resource", + "type": "object", + "properties": { + "cpus": {"type": "string"}, + "memory": {"type": "string"} + }, + "additionalProperties": false + }, + + "network": { + "id": "#/definitions/network", + "type": ["object", "null"], + "properties": { + "driver": {"type": "string"}, + "driver_opts": { + "type": "object", + "patternProperties": { + "^.+$": {"type": ["string", "number"]} + } + }, + "ipam": { + "type": "object", + "properties": { + "driver": {"type": "string"}, + "config": { + "type": "array", + "items": { + "type": "object", + "properties": { + "subnet": {"type": "string"} + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + }, + "external": { + "type": ["boolean", "object"], + "properties": { + "name": {"type": "string"} + }, + "additionalProperties": false + }, + "internal": {"type": "boolean"}, + "attachable": {"type": "boolean"}, + "labels": {"$ref": "#/definitions/list_or_dict"} + }, + "additionalProperties": false + }, + + "volume": { + "id": "#/definitions/volume", + "type": ["object", "null"], + "properties": { + "driver": {"type": "string"}, + "driver_opts": { + "type": "object", + "patternProperties": { + "^.+$": {"type": ["string", "number"]} + } + }, + "external": { + "type": ["boolean", "object"], + "properties": { + "name": {"type": "string"} + }, + "additionalProperties": false + }, + "labels": {"$ref": "#/definitions/list_or_dict"} + }, + "additionalProperties": false + }, + + "secret": { + "id": "#/definitions/secret", + "type": "object", + "properties": { + "file": {"type": "string"}, + "external": { + "type": ["boolean", "object"], + "properties": { + "name": {"type": "string"} + } + }, + "labels": {"$ref": "#/definitions/list_or_dict"} + }, + "additionalProperties": false + }, + + "config": { + "id": "#/definitions/config", + "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"] + } + } + } + } + } +} diff --git a/compose/config/serialize.py b/compose/config/serialize.py index 040973ae080..ac78b77a2db 100644 --- a/compose/config/serialize.py +++ b/compose/config/serialize.py @@ -10,6 +10,7 @@ from compose.const import COMPOSEFILE_V2_2 as V2_2 from compose.const import COMPOSEFILE_V3_1 as V3_1 from compose.const import COMPOSEFILE_V3_2 as V3_2 +from compose.const import COMPOSEFILE_V3_3 as V3_3 def serialize_config_type(dumper, data): @@ -50,7 +51,7 @@ def denormalize_config(config, image_digests=None): if 'external_name' in vol_conf: del vol_conf['external_name'] - if config.version in (V3_1, V3_2): + if config.version in (V3_1, V3_2, V3_3): result['secrets'] = config.secrets.copy() for secret_name, secret_conf in result['secrets'].items(): if 'external_name' in secret_conf: @@ -114,7 +115,7 @@ def denormalize_service_dict(service_dict, version, image_digest=None): service_dict['healthcheck']['timeout'] ) - if 'ports' in service_dict and version not in (V3_2,): + if 'ports' in service_dict and version not in (V3_2, V3_3): service_dict['ports'] = [ p.legacy_repr() if isinstance(p, types.ServicePort) else p for p in service_dict['ports'] diff --git a/compose/const.py b/compose/const.py index 36703138acb..36f213897a2 100644 --- a/compose/const.py +++ b/compose/const.py @@ -27,6 +27,7 @@ COMPOSEFILE_V3_0 = '3.0' COMPOSEFILE_V3_1 = '3.1' COMPOSEFILE_V3_2 = '3.2' +COMPOSEFILE_V3_3 = '3.3' API_VERSIONS = { COMPOSEFILE_V1: '1.21', @@ -36,6 +37,7 @@ COMPOSEFILE_V3_0: '1.25', COMPOSEFILE_V3_1: '1.25', COMPOSEFILE_V3_2: '1.25', + COMPOSEFILE_V3_3: '1.30', } API_VERSION_TO_ENGINE_VERSION = { @@ -46,4 +48,5 @@ API_VERSIONS[COMPOSEFILE_V3_0]: '1.13.0', API_VERSIONS[COMPOSEFILE_V3_1]: '1.13.0', API_VERSIONS[COMPOSEFILE_V3_2]: '1.13.0', + API_VERSIONS[COMPOSEFILE_V3_3]: '17.06.0', } diff --git a/docker-compose.spec b/docker-compose.spec index 21b3c1742cf..8e0d51ae5f8 100644 --- a/docker-compose.spec +++ b/docker-compose.spec @@ -52,6 +52,11 @@ exe = EXE(pyz, 'compose/config/config_schema_v3.2.json', 'DATA' ), + ( + 'compose/config/config_schema_v3.3.json', + 'compose/config/config_schema_v3.3.json', + 'DATA' + ), ( 'compose/GITSHA', 'compose/GITSHA', diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index bc51600357b..357244c2c33 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -31,6 +31,7 @@ from compose.const import COMPOSEFILE_V3_0 as V3_0 from compose.const import COMPOSEFILE_V3_1 as V3_1 from compose.const import COMPOSEFILE_V3_2 as V3_2 +from compose.const import COMPOSEFILE_V3_3 as V3_3 from compose.const import IS_WINDOWS_PLATFORM from compose.utils import nanoseconds_from_time_seconds from tests import mock @@ -825,11 +826,11 @@ def test_load_with_buildargs(self): assert service['build']['args']['opt1'] == '42' assert service['build']['args']['opt2'] == 'foobar' - def test_load_with_labels(self): + def test_load_with_build_labels(self): service = config.load( build_config_details( { - 'version': '3.2', + 'version': V3_3, 'services': { 'web': { 'build': { From 9334f29898fba51f7d44c6368b4d8df54b6cfc2e Mon Sep 17 00:00:00 2001 From: Eli Atzaba Date: Sat, 29 Apr 2017 02:00:52 +0300 Subject: [PATCH 2797/4072] Fix for yaml extention does not work with override file Signed-off-by: Eli Atzaba --- compose/config/config.py | 9 ++++++--- tests/acceptance/cli_test.py | 16 ++++++++++++++++ .../docker-compose.override.yaml | 3 +++ .../override-yaml-files/docker-compose.yml | 10 ++++++++++ 4 files changed, 35 insertions(+), 3 deletions(-) create mode 100644 tests/fixtures/override-yaml-files/docker-compose.override.yaml create mode 100644 tests/fixtures/override-yaml-files/docker-compose.yml diff --git a/compose/config/config.py b/compose/config/config.py index 4fddac82270..861a3e9bf8d 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -128,7 +128,7 @@ 'docker-compose.yaml', ] -DEFAULT_OVERRIDE_FILENAME = 'docker-compose.override.yml' +DEFAULT_OVERRIDE_FILENAMES = ('docker-compose.override.yml', 'docker-compose.override.yaml') log = logging.getLogger(__name__) @@ -292,8 +292,11 @@ def get_default_config_files(base_dir): def get_default_override_file(path): - override_filename = os.path.join(path, DEFAULT_OVERRIDE_FILENAME) - return [override_filename] if os.path.exists(override_filename) else [] + for default_override_filename in DEFAULT_OVERRIDE_FILENAMES: + override_filename = os.path.join(path, default_override_filename) + if os.path.exists(override_filename): + return [override_filename] + return [] def find_candidates_in_parent_dirs(filenames, path): diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 30eff1b6aff..f6c074364ed 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -2149,3 +2149,19 @@ def test_images_default_composefile(self): assert 'busybox' in result.stdout assert 'multiplecomposefiles_another_1' in result.stdout assert 'multiplecomposefiles_simple_1' in result.stdout + + def test_up_with_override_yaml(self): + self.base_dir = 'tests/fixtures/override-yaml-files' + self._project = get_project(self.base_dir, []) + self.dispatch( + [ + 'up', '-d', + ], + None) + + containers = self.project.containers() + self.assertEqual(len(containers), 2) + + web, db = containers + self.assertEqual(web.human_readable_command, 'sleep 100') + self.assertEqual(db.human_readable_command, 'top') diff --git a/tests/fixtures/override-yaml-files/docker-compose.override.yaml b/tests/fixtures/override-yaml-files/docker-compose.override.yaml new file mode 100644 index 00000000000..58c67348295 --- /dev/null +++ b/tests/fixtures/override-yaml-files/docker-compose.override.yaml @@ -0,0 +1,3 @@ + +db: + command: "top" diff --git a/tests/fixtures/override-yaml-files/docker-compose.yml b/tests/fixtures/override-yaml-files/docker-compose.yml new file mode 100644 index 00000000000..5f2909d69e7 --- /dev/null +++ b/tests/fixtures/override-yaml-files/docker-compose.yml @@ -0,0 +1,10 @@ + +web: + image: busybox:latest + command: "sleep 100" + links: + - db + +db: + image: busybox:latest + command: "sleep 200" From 0d0c0454e90ab40a72600844b8eba10314304869 Mon Sep 17 00:00:00 2001 From: Eli Atzaba Date: Sun, 7 May 2017 18:03:14 +0300 Subject: [PATCH 2798/4072] Raise exception when override.yaml & override.yml coexist Signed-off-by: Eli Atzaba --- compose/config/config.py | 12 +++++++----- compose/config/errors.py | 12 ++++++++++++ tests/acceptance/cli_test.py | 8 +++++++- .../docker-compose.override.yaml | 3 +++ .../docker-compose.override.yml | 3 +++ .../duplicate-override-yaml-files/docker-compose.yml | 10 ++++++++++ 6 files changed, 42 insertions(+), 6 deletions(-) create mode 100644 tests/fixtures/duplicate-override-yaml-files/docker-compose.override.yaml create mode 100644 tests/fixtures/duplicate-override-yaml-files/docker-compose.override.yml create mode 100644 tests/fixtures/duplicate-override-yaml-files/docker-compose.yml diff --git a/compose/config/config.py b/compose/config/config.py index 861a3e9bf8d..2a81b93da27 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -24,6 +24,7 @@ from .errors import CircularReference from .errors import ComposeFileNotFound from .errors import ConfigurationError +from .errors import DuplicateOverrideFileFound from .errors import VERSION_EXPLANATION from .interpolation import interpolate_environment_variables from .sort_services import get_container_name_from_network_mode @@ -292,11 +293,12 @@ def get_default_config_files(base_dir): def get_default_override_file(path): - for default_override_filename in DEFAULT_OVERRIDE_FILENAMES: - override_filename = os.path.join(path, default_override_filename) - if os.path.exists(override_filename): - return [override_filename] - return [] + override_files_in_path = [os.path.join(path, override_filename) for override_filename + in DEFAULT_OVERRIDE_FILENAMES + if os.path.exists(os.path.join(path, override_filename))] + if len(override_files_in_path) > 1: + raise DuplicateOverrideFileFound(override_files_in_path) + return override_files_in_path def find_candidates_in_parent_dirs(filenames, path): diff --git a/compose/config/errors.py b/compose/config/errors.py index 9b82df0ab55..060564fc49c 100644 --- a/compose/config/errors.py +++ b/compose/config/errors.py @@ -44,3 +44,15 @@ def __init__(self, supported_filenames): Supported filenames: %s """ % ", ".join(supported_filenames)) + + +class DuplicateOverrideFileFound(ConfigurationError): + def __init__(self, override_filenames): + self.override_filenames = override_filenames + + @property + def msg(self): + return """ + Unable to determine with duplicate override files, only a single override file can be used. + Found: %s + """ % ", ".join(self.override_filenames) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index f6c074364ed..1ba64201f92 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -21,6 +21,7 @@ from .. import mock from ..helpers import create_host_file from compose.cli.command import get_project +from compose.config.errors import DuplicateOverrideFileFound from compose.container import Container from compose.project import OneOffFilter from compose.utils import nanoseconds_from_time_seconds @@ -31,7 +32,6 @@ from tests.integration.testcases import v2_only from tests.integration.testcases import v3_only - ProcessResult = namedtuple('ProcessResult', 'stdout stderr') @@ -2165,3 +2165,9 @@ def test_up_with_override_yaml(self): web, db = containers self.assertEqual(web.human_readable_command, 'sleep 100') self.assertEqual(db.human_readable_command, 'top') + + def test_up_with_duplicate_override_yaml_files(self): + self.base_dir = 'tests/fixtures/duplicate-override-yaml-files' + with self.assertRaises(DuplicateOverrideFileFound): + get_project(self.base_dir, []) + self.base_dir = None diff --git a/tests/fixtures/duplicate-override-yaml-files/docker-compose.override.yaml b/tests/fixtures/duplicate-override-yaml-files/docker-compose.override.yaml new file mode 100644 index 00000000000..58c67348295 --- /dev/null +++ b/tests/fixtures/duplicate-override-yaml-files/docker-compose.override.yaml @@ -0,0 +1,3 @@ + +db: + command: "top" diff --git a/tests/fixtures/duplicate-override-yaml-files/docker-compose.override.yml b/tests/fixtures/duplicate-override-yaml-files/docker-compose.override.yml new file mode 100644 index 00000000000..f1b8ef181f7 --- /dev/null +++ b/tests/fixtures/duplicate-override-yaml-files/docker-compose.override.yml @@ -0,0 +1,3 @@ + +db: + command: "sleep 300" diff --git a/tests/fixtures/duplicate-override-yaml-files/docker-compose.yml b/tests/fixtures/duplicate-override-yaml-files/docker-compose.yml new file mode 100644 index 00000000000..5f2909d69e7 --- /dev/null +++ b/tests/fixtures/duplicate-override-yaml-files/docker-compose.yml @@ -0,0 +1,10 @@ + +web: + image: busybox:latest + command: "sleep 100" + links: + - db + +db: + image: busybox:latest + command: "sleep 200" From 7f1f450080c1029cb2b3c008ca527c00338147f4 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 23 May 2017 12:14:32 -0700 Subject: [PATCH 2799/4072] Rewrite duplicate override error message Signed-off-by: Joffrey F --- compose/config/errors.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/compose/config/errors.py b/compose/config/errors.py index 060564fc49c..ac1d3ac1976 100644 --- a/compose/config/errors.py +++ b/compose/config/errors.py @@ -49,10 +49,7 @@ def __init__(self, supported_filenames): class DuplicateOverrideFileFound(ConfigurationError): def __init__(self, override_filenames): self.override_filenames = override_filenames - - @property - def msg(self): - return """ - Unable to determine with duplicate override files, only a single override file can be used. - Found: %s - """ % ", ".join(self.override_filenames) + super(DuplicateOverrideFileFound, self).__init__( + "Multiple override files found: {}. You may only use a single " + "override file.".format(", ".join(override_filenames)) + ) From 7dafbd1e9c700318b4eafc7622cdda462ba603fd Mon Sep 17 00:00:00 2001 From: Pascal Vibet Date: Wed, 26 Apr 2017 13:50:22 +0200 Subject: [PATCH 2800/4072] If COMPOSE_FILE is define then set this variable to the container Signed-off-by: Pascal Vibet --- script/run/run.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/script/run/run.sh b/script/run/run.sh index e697d1f6dca..d1e1fabaaca 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -35,6 +35,7 @@ if [ "$(pwd)" != '/' ]; then VOLUMES="-v $(pwd):$(pwd)" fi if [ -n "$COMPOSE_FILE" ]; then + COMPOSE_OPTIONS="$COMPOSE_OPTIONS -e COMPOSE_FILE=$COMPOSE_FILE" compose_dir=$(realpath $(dirname $COMPOSE_FILE)) fi # TODO: also check --file argument From 244209814b958fda48b0684e3b67097647363a9f Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 23 May 2017 12:38:54 -0700 Subject: [PATCH 2801/4072] Fix improper use of project.stop Add some better test coverage for rm --stop Signed-off-by: Joffrey F --- compose/cli/main.py | 8 +------- tests/acceptance/cli_test.py | 18 +++++++++++++++++- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index c91b8d8989a..cfca0f94902 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -680,13 +680,7 @@ def rm(self, options): one_off = OneOffFilter.include if options.get('--stop'): - running_containers = self.project.containers( - service_names=options['SERVICE'], stopped=False, one_off=one_off - ) - self.project.stop( - service_names=running_containers, - one_off=one_off - ) + self.project.stop(service_names=options['SERVICE'], one_off=one_off) 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 30eff1b6aff..b85caaf7a71 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1627,8 +1627,24 @@ def test_rm(self): service = self.project.get_service('simple') service.create_container() self.dispatch(['rm', '-fs'], None) + self.assertEqual(len(service.containers(stopped=True)), 0) + + def test_rm_stop(self): + self.dispatch(['up', '-d'], None) simple = self.project.get_service('simple') - self.assertEqual(len(simple.containers()), 0) + another = self.project.get_service('another') + assert len(simple.containers()) == 1 + assert len(another.containers()) == 1 + self.dispatch(['rm', '-fs'], None) + assert len(simple.containers(stopped=True)) == 0 + assert len(another.containers(stopped=True)) == 0 + + self.dispatch(['up', '-d'], None) + assert len(simple.containers()) == 1 + assert len(another.containers()) == 1 + self.dispatch(['rm', '-fs', 'another'], None) + assert len(simple.containers()) == 1 + assert len(another.containers(stopped=True)) == 0 def test_rm_all(self): service = self.project.get_service('simple') From 062812e4aa20b50b8879a32a2def3f8908c19f10 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 23 May 2017 15:17:16 -0700 Subject: [PATCH 2802/4072] Merge all fields inside build dict Signed-off-by: Joffrey F --- compose/config/config.py | 2 + tests/unit/config/config_test.py | 68 ++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/compose/config/config.py b/compose/config/config.py index 2a81b93da27..8dac4fb3328 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -952,6 +952,8 @@ def to_dict(service): md.merge_scalar('context') md.merge_scalar('dockerfile') md.merge_mapping('args', parse_build_arguments) + md.merge_field('cache_from', merge_unique_items_lists, default=[]) + md.merge_mapping('labels', parse_labels) return dict(md) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 357244c2c33..d8973484d25 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -2847,6 +2847,74 @@ def test_remove_explicit_value(self): assert service_dict['labels'] == {'foo': '1', 'bar': ''} +class MergeBuildTest(unittest.TestCase): + def test_full(self): + base = { + 'context': '.', + 'dockerfile': 'Dockerfile', + 'args': { + 'x': '1', + 'y': '2', + }, + 'cache_from': ['ubuntu'], + 'labels': ['com.docker.compose.test=true'] + } + + override = { + 'context': './prod', + 'dockerfile': 'Dockerfile.prod', + 'args': ['x=12'], + 'cache_from': ['debian'], + 'labels': { + 'com.docker.compose.test': 'false', + 'com.docker.compose.prod': 'true', + } + } + + result = config.merge_build(None, {'build': base}, {'build': override}) + assert result['context'] == override['context'] + assert result['dockerfile'] == override['dockerfile'] + assert result['args'] == {'x': '12', 'y': '2'} + assert set(result['cache_from']) == set(['ubuntu', 'debian']) + assert result['labels'] == override['labels'] + + def test_empty_override(self): + base = { + 'context': '.', + 'dockerfile': 'Dockerfile', + 'args': { + 'x': '1', + 'y': '2', + }, + 'cache_from': ['ubuntu'], + 'labels': { + 'com.docker.compose.test': 'true' + } + } + + override = {} + + result = config.merge_build(None, {'build': base}, {'build': override}) + assert result == base + + def test_empty_base(self): + base = {} + + override = { + 'context': './prod', + 'dockerfile': 'Dockerfile.prod', + 'args': {'x': '12'}, + 'cache_from': ['debian'], + 'labels': { + 'com.docker.compose.test': 'false', + 'com.docker.compose.prod': 'true', + } + } + + result = config.merge_build(None, {'build': base}, {'build': override}) + assert result == override + + class MemoryOptionsTest(unittest.TestCase): def test_validation_fails_with_just_memswap_limit(self): From 2ba9cd73d154d301fb0e9b7cbaee159f232c4dec Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 22 May 2017 14:52:57 -0700 Subject: [PATCH 2803/4072] Network label mismatch now prints a warning instead of raising an error Signed-off-by: Joffrey F --- compose/network.py | 7 +++++-- tests/unit/network_test.py | 7 +++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/compose/network.py b/compose/network.py index 532686d761c..fec83916234 100644 --- a/compose/network.py +++ b/compose/network.py @@ -188,10 +188,13 @@ def check_remote_network_config(remote, local): local_labels = local.labels or {} remote_labels = remote.get('Labels', {}) for k in set.union(set(remote_labels.keys()), set(local_labels.keys())): - if k.startswith('com.docker.compose.'): # We are only interested in user-specified labels + if k.startswith('com.docker.'): # We are only interested in user-specified labels continue if remote_labels.get(k) != local_labels.get(k): - raise NetworkConfigChangedError(local.full_name, 'label "{}"'.format(k)) + log.warn( + 'Network {}: label "{}" has changed. It may need to be' + ' recreated.'.format(local.full_name, k) + ) def build_networks(name, config_data, client): diff --git a/tests/unit/network_test.py b/tests/unit/network_test.py index 4b40ea88436..b27339af89c 100644 --- a/tests/unit/network_test.py +++ b/tests/unit/network_test.py @@ -3,6 +3,7 @@ import pytest +from .. import mock from .. import unittest from compose.network import check_remote_network_config from compose.network import Network @@ -152,7 +153,9 @@ def test_check_remote_network_labels_mismatch(self): 'com.project.touhou.character': 'marisa.kirisame', } } - with pytest.raises(NetworkConfigChangedError) as e: + with mock.patch('compose.network.log') as mock_log: check_remote_network_config(remote, net) - assert 'label "com.project.touhou.character" has changed' in str(e.value) + mock_log.warn.assert_called_once_with(mock.ANY) + _, args, kwargs = mock_log.warn.mock_calls[0] + assert 'label "com.project.touhou.character" has changed' in args[0] From 49605f27153a0a17d23dca07610b25cc15283e82 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 23 May 2017 15:53:06 -0700 Subject: [PATCH 2804/4072] Add support for build labels in 2.1 and 2.2 format Add cache_from in 2.2 format Add integration test for build labels Signed-off-by: Joffrey F --- compose/config/config_schema_v2.1.json | 3 ++- compose/config/config_schema_v2.2.json | 4 +++- tests/integration/service_test.py | 15 +++++++++++++++ 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/compose/config/config_schema_v2.1.json b/compose/config/config_schema_v2.1.json index aa59d181ee5..9004000ea71 100644 --- a/compose/config/config_schema_v2.1.json +++ b/compose/config/config_schema_v2.1.json @@ -58,7 +58,8 @@ "properties": { "context": {"type": "string"}, "dockerfile": {"type": "string"}, - "args": {"$ref": "#/definitions/list_or_dict"} + "args": {"$ref": "#/definitions/list_or_dict"}, + "labels": {"$ref": "#/definitions/list_or_dict"} }, "additionalProperties": false } diff --git a/compose/config/config_schema_v2.2.json b/compose/config/config_schema_v2.2.json index a585f2a8c87..e8edb60eda5 100644 --- a/compose/config/config_schema_v2.2.json +++ b/compose/config/config_schema_v2.2.json @@ -58,7 +58,9 @@ "properties": { "context": {"type": "string"}, "dockerfile": {"type": "string"}, - "args": {"$ref": "#/definitions/list_or_dict"} + "args": {"$ref": "#/definitions/list_or_dict"}, + "labels": {"$ref": "#/definitions/list_or_dict"}, + "cache_from": {"$ref": "#/definitions/list_of_strings"} }, "additionalProperties": false } diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index a5b5bda5727..178df13239d 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -666,6 +666,21 @@ def test_build_with_build_args_override(self): assert service.image() assert "build_version=2" in service.image()['ContainerConfig']['Cmd'] + def test_build_with_build_labels(self): + base_dir = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, base_dir) + + with open(os.path.join(base_dir, 'Dockerfile'), 'w') as f: + f.write('FROM busybox\n') + + service = self.create_service('buildlabels', build={ + 'context': text_type(base_dir), + 'labels': {'com.docker.compose.test': 'true'} + }) + service.build() + assert service.image() + assert service.image()['Config']['Labels']['com.docker.compose.test'] == 'true' + def test_start_container_stays_unprivileged(self): service = self.create_service('web') container = create_and_start_container(service).inspect() From 73d7865da8463e3ae635a43ca661aa3dcc2d079a Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 23 May 2017 16:30:48 -0700 Subject: [PATCH 2805/4072] Add partial support (docker-compose config and warnings) for v3.3 credential_spec Signed-off-by: Joffrey F --- compose/config/config.py | 42 +++++++++++++++++++++++--------- tests/unit/config/config_test.py | 17 +++++++++++++ 2 files changed, 47 insertions(+), 12 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 2a81b93da27..fa373bb1078 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -108,6 +108,7 @@ ALLOWED_KEYS = DOCKER_CONFIG_KEYS + [ 'build', 'container_name', + 'credential_spec', 'dockerfile', 'log_driver', 'log_opt', @@ -320,6 +321,27 @@ def find_candidates_in_parent_dirs(filenames, path): return (candidates, path) +def check_swarm_only_config(service_dicts): + warning_template = ( + "Some services ({services}) use the '{key}' key, which will be ignored. " + "Compose does not support '{key}' configuration - use " + "`docker stack deploy` to deploy to a swarm." + ) + + def check_swarm_only_key(service_dicts, key): + services = [s for s in service_dicts if s.get(key)] + if services: + log.warn( + warning_template.format( + services=", ".join(sorted(s['name'] for s in services)), + key=key + ) + ) + + check_swarm_only_key(service_dicts, 'deploy') + check_swarm_only_key(service_dicts, 'credential_spec') + + def load(config_details): """Load the configuration from a working directory and a list of configuration files. Files are loaded in order, and merged on top @@ -349,13 +371,7 @@ 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 " - "`docker stack deploy` to deploy to a swarm." - .format(", ".join(sorted(s['name'] for s in services_using_deploy)))) + check_swarm_only_config(service_dicts) return Config(main_file.version, service_dicts, volumes, networks, secrets) @@ -884,7 +900,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('ulimits', parse_flat_dict) md.merge_mapping('networks', parse_networks) md.merge_mapping('sysctls', parse_sysctls) md.merge_mapping('depends_on', parse_depends_on) @@ -1018,12 +1034,14 @@ def parse_dict_or_list(split_func, type_name, arguments): parse_deploy = functools.partial(parse_dict_or_list, split_kv, 'deploy') -def parse_ulimits(ulimits): - if not ulimits: +def parse_flat_dict(d): + if not d: return {} - if isinstance(ulimits, dict): - return dict(ulimits) + if isinstance(d, dict): + return dict(d) + + raise ConfigurationError("Invalid type: expected mapping") def resolve_env_var(key, val, environment): diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 357244c2c33..9dbce18d267 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -2033,6 +2033,23 @@ def test_merge_deploy_override(self): } } + def test_merge_credential_spec(self): + base = { + 'image': 'bb', + 'credential_spec': { + 'file': '/hello-world', + } + } + + override = { + 'credential_spec': { + 'registry': 'revolution.com', + } + } + + actual = config.merge_service_dicts(base, override, V3_3) + assert actual['credential_spec'] == override['credential_spec'] + def test_external_volume_config(self): config_details = build_config_details({ 'version': '2', From 10cda3dabfd013fdf914ae3f505c591f6960b59b Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Mon, 29 May 2017 14:01:50 +0200 Subject: [PATCH 2806/4072] Add Joffrey to maintainers Signed-off-by: Sebastiaan van Stijn --- MAINTAINERS | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/MAINTAINERS b/MAINTAINERS index 820b2f82999..89f5b4124c9 100644 --- a/MAINTAINERS +++ b/MAINTAINERS @@ -15,6 +15,7 @@ "bfirsh", "dnephin", "mnowster", + "shin-", ] [people] @@ -44,3 +45,8 @@ Name = "Mazz Mosley" Email = "mazz@houseofmnowster.com" GitHub = "mnowster" + + [People.shin-] + Name = "Joffrey F" + Email = "joffrey@docker.com" + GitHub = "shin-" From ffb8f9f1b478065bcd1db3280c461996168d6935 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 17 Apr 2017 19:03:56 -0700 Subject: [PATCH 2807/4072] Implement --scale option on up command, allow scale config in v2.2 format docker-compose scale modified to reuse code between up and scale Signed-off-by: Joffrey F --- compose/service.py | 20 +++++--------------- tests/acceptance/cli_test.py | 16 ++++++++++++++++ 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/compose/service.py b/compose/service.py index 8699372ed12..1e85772b77b 100644 --- a/compose/service.py +++ b/compose/service.py @@ -378,16 +378,12 @@ def create_and_start(service, n): self.start_container(container) return container - containers, errors = parallel_execute( + return parallel_execute( range(i, i + scale), lambda n: create_and_start(self, n), lambda n: self.get_container_name(n), "Creating" - ) - for error in errors.values(): - raise OperationFailedError(error) - - return containers + )[0] def _execute_convergence_recreate(self, containers, scale, timeout, detached, start): if len(containers) > scale: @@ -399,15 +395,12 @@ def recreate(container): container, timeout=timeout, attach_logs=not detached, start_new_container=start ) - containers, errors = parallel_execute( + containers = parallel_execute( containers, recreate, lambda c: c.name, "Recreating" - ) - for error in errors.values(): - raise OperationFailedError(error) - + )[0] if len(containers) < scale: containers.extend(self._execute_convergence_create( scale - len(containers), detached, start @@ -419,16 +412,13 @@ def _execute_convergence_start(self, containers, scale, timeout, detached, start self._downscale(containers[scale:], timeout) containers = containers[:scale] if start: - _, errors = parallel_execute( + parallel_execute( containers, lambda c: self.start_container_if_stopped(c, attach_logs=not detached), lambda c: c.name, "Starting" ) - for error in errors.values(): - raise OperationFailedError(error) - if len(containers) < scale: containers.extend(self._execute_convergence_create( scale - len(containers), detached, start diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 75b15ae65a2..c4806ad2c14 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1866,6 +1866,7 @@ def test_scale(self): self.assertEqual(len(project.get_service('simple').containers()), 0) self.assertEqual(len(project.get_service('another').containers()), 0) +<<<<<<< 10267a83dc79ba0f8cebe17b561c05367b947247 def test_scale_v2_2(self): self.base_dir = 'tests/fixtures/scale' result = self.dispatch(['scale', 'web=1'], returncode=1) @@ -1887,6 +1888,11 @@ def test_up_scale_scale_down(self): self.base_dir = 'tests/fixtures/scale' project = self.project +======= + def test_up_scale(self): + self.base_dir = 'tests/fixtures/scale' + project = self.project +>>>>>>> Implement --scale option on up command, allow scale config in v2.2 format self.dispatch(['up', '-d']) assert len(project.get_service('web').containers()) == 2 assert len(project.get_service('db').containers()) == 1 @@ -1895,6 +1901,7 @@ def test_up_scale_scale_down(self): assert len(project.get_service('web').containers()) == 1 assert len(project.get_service('db').containers()) == 1 +<<<<<<< 10267a83dc79ba0f8cebe17b561c05367b947247 def test_up_scale_reset(self): self.base_dir = 'tests/fixtures/scale' project = self.project @@ -1910,6 +1917,15 @@ def test_up_scale_reset(self): def test_up_scale_to_zero(self): self.base_dir = 'tests/fixtures/scale' project = self.project +======= + self.dispatch(['up', '-d', '--scale', 'web=3']) + assert len(project.get_service('web').containers()) == 3 + assert len(project.get_service('db').containers()) == 1 + + self.dispatch(['up', '-d', '--scale', 'web=1', '--scale', 'db=2']) + assert len(project.get_service('web').containers()) == 1 + assert len(project.get_service('db').containers()) == 2 +>>>>>>> Implement --scale option on up command, allow scale config in v2.2 format self.dispatch(['up', '-d']) assert len(project.get_service('web').containers()) == 2 From 1646e7559129926f0b7a340577d7dbf864d267a6 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 18 Apr 2017 12:53:43 -0700 Subject: [PATCH 2808/4072] Properly relay errors in execute_convergence_plan Signed-off-by: Joffrey F --- compose/service.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/compose/service.py b/compose/service.py index 1e85772b77b..13b327616b6 100644 --- a/compose/service.py +++ b/compose/service.py @@ -378,12 +378,16 @@ def create_and_start(service, n): self.start_container(container) return container - return parallel_execute( + containers, errors = parallel_execute( range(i, i + scale), lambda n: create_and_start(self, n), lambda n: self.get_container_name(n), "Creating" - )[0] + ) + if errors: + raise OperationFailedError(errors.values()[0]) + + return containers def _execute_convergence_recreate(self, containers, scale, timeout, detached, start): if len(containers) > scale: @@ -395,12 +399,14 @@ def recreate(container): container, timeout=timeout, attach_logs=not detached, start_new_container=start ) - containers = parallel_execute( + containers, errors = parallel_execute( containers, recreate, lambda c: c.name, "Recreating" - )[0] + ) + if errors: + raise OperationFailedError(errors.values()[0]) if len(containers) < scale: containers.extend(self._execute_convergence_create( scale - len(containers), detached, start @@ -412,13 +418,16 @@ def _execute_convergence_start(self, containers, scale, timeout, detached, start self._downscale(containers[scale:], timeout) containers = containers[:scale] if start: - parallel_execute( + _, errors = parallel_execute( containers, lambda c: self.start_container_if_stopped(c, attach_logs=not detached), lambda c: c.name, "Starting" ) + if errors: + raise OperationFailedError(errors.values()[0]) + if len(containers) < scale: containers.extend(self._execute_convergence_create( scale - len(containers), detached, start From 1be40656a142abdc00d2b1dd8c6a413230e2f530 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 19 Apr 2017 16:47:43 -0700 Subject: [PATCH 2809/4072] Prevent `docker-compose scale` to be used with a v2.2 config file Signed-off-by: Joffrey F --- compose/service.py | 13 +++++++------ tests/acceptance/cli_test.py | 16 ---------------- 2 files changed, 7 insertions(+), 22 deletions(-) diff --git a/compose/service.py b/compose/service.py index 13b327616b6..8699372ed12 100644 --- a/compose/service.py +++ b/compose/service.py @@ -384,8 +384,8 @@ def create_and_start(service, n): lambda n: self.get_container_name(n), "Creating" ) - if errors: - raise OperationFailedError(errors.values()[0]) + for error in errors.values(): + raise OperationFailedError(error) return containers @@ -405,8 +405,9 @@ def recreate(container): lambda c: c.name, "Recreating" ) - if errors: - raise OperationFailedError(errors.values()[0]) + for error in errors.values(): + raise OperationFailedError(error) + if len(containers) < scale: containers.extend(self._execute_convergence_create( scale - len(containers), detached, start @@ -425,8 +426,8 @@ def _execute_convergence_start(self, containers, scale, timeout, detached, start "Starting" ) - if errors: - raise OperationFailedError(errors.values()[0]) + for error in errors.values(): + raise OperationFailedError(error) if len(containers) < scale: containers.extend(self._execute_convergence_create( diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index c4806ad2c14..75b15ae65a2 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1866,7 +1866,6 @@ def test_scale(self): self.assertEqual(len(project.get_service('simple').containers()), 0) self.assertEqual(len(project.get_service('another').containers()), 0) -<<<<<<< 10267a83dc79ba0f8cebe17b561c05367b947247 def test_scale_v2_2(self): self.base_dir = 'tests/fixtures/scale' result = self.dispatch(['scale', 'web=1'], returncode=1) @@ -1888,11 +1887,6 @@ def test_up_scale_scale_down(self): self.base_dir = 'tests/fixtures/scale' project = self.project -======= - def test_up_scale(self): - self.base_dir = 'tests/fixtures/scale' - project = self.project ->>>>>>> Implement --scale option on up command, allow scale config in v2.2 format self.dispatch(['up', '-d']) assert len(project.get_service('web').containers()) == 2 assert len(project.get_service('db').containers()) == 1 @@ -1901,7 +1895,6 @@ def test_up_scale(self): assert len(project.get_service('web').containers()) == 1 assert len(project.get_service('db').containers()) == 1 -<<<<<<< 10267a83dc79ba0f8cebe17b561c05367b947247 def test_up_scale_reset(self): self.base_dir = 'tests/fixtures/scale' project = self.project @@ -1917,15 +1910,6 @@ def test_up_scale_reset(self): def test_up_scale_to_zero(self): self.base_dir = 'tests/fixtures/scale' project = self.project -======= - self.dispatch(['up', '-d', '--scale', 'web=3']) - assert len(project.get_service('web').containers()) == 3 - assert len(project.get_service('db').containers()) == 1 - - self.dispatch(['up', '-d', '--scale', 'web=1', '--scale', 'db=2']) - assert len(project.get_service('web').containers()) == 1 - assert len(project.get_service('db').containers()) == 2 ->>>>>>> Implement --scale option on up command, allow scale config in v2.2 format self.dispatch(['up', '-d']) assert len(project.get_service('web').containers()) == 2 From d3ad2ae7fe97de3fbdc37f31bcd10076e2516edc Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 26 Apr 2017 15:00:41 -0700 Subject: [PATCH 2810/4072] Add deprecation warning to scale command Signed-off-by: Joffrey F --- compose/cli/main.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/compose/cli/main.py b/compose/cli/main.py index 9df3c82adb8..37e299d94e1 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -764,6 +764,9 @@ def scale(self, options): $ docker-compose scale web=2 worker=3 + This command is deprecated. Use the up command with the `--scale` flag + instead. + Usage: scale [options] [SERVICE=NUM...] Options: @@ -777,6 +780,11 @@ def scale(self, options): 'The scale command is incompatible with the v2.2 format. ' 'Use the up command with the --scale flag instead.' ) + else: + log.warn( + 'The scale command is deprecated. ' + 'Use the up command with the --scale flag instead.' + ) for service_name, num in parse_scale_args(options['SERVICE=NUM']).items(): self.project.get_service(service_name).scale(num, timeout=timeout) From b1e3228d19e6bd50e84671cb58f817a048ecc299 Mon Sep 17 00:00:00 2001 From: mrfly Date: Fri, 28 Apr 2017 16:58:08 +0800 Subject: [PATCH 2811/4072] Not colon but a dot. hum... Signed-off-by: wrfly --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d43bd8c4c59..e3ca8f833e0 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ 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: +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: From e27dfe8ccdb7e067c346ff05c75c4bbf8b6de05e Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 1 May 2017 16:48:16 -0700 Subject: [PATCH 2812/4072] Script downloading release binaries from bintray and appveyor Signed-off-by: Joffrey F --- script/release/download-binaries | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100755 script/release/download-binaries diff --git a/script/release/download-binaries b/script/release/download-binaries new file mode 100755 index 00000000000..5d01f5f75e2 --- /dev/null +++ b/script/release/download-binaries @@ -0,0 +1,32 @@ +#!/bin/bash + +function usage() { + >&2 cat << EOM +Download Linux, Mac OS and Windows binaries from remote endpoints + +Usage: + + $0 + +Options: + + version version string for the release (ex: 1.6.0) + +EOM + exit 1 +} + + +[ -n "$1" ] || usage +VERSION=$1 +BASE_BINTRAY_URL=https://dl.bintray.com/docker-compose/bump-$VERSION/ +DESTINATION=binaries-$VERSION +APPVEYOR_URL=https://ci.appveyor.com/api/projects/docker/compose/\ +artifacts/dist%2Fdocker-compose-Windows-x86_64.exe?branch=bump-$VERSION + +mkdir $DESTINATION + + +wget -O $DESTINATION/docker-compose-Darwin-x86_64 $BASE_BINTRAY_URL/docker-compose-Darwin-x86_64 +wget -O $DESTINATION/docker-compose-Linux-x86_64 $BASE_BINTRAY_URL/docker-compose-Linux-x86_64 +wget -O $DESTINATION/docker-compose-Windows-x86_64.exe $APPVEYOR_URL From 57f647f03f847f74c3e0d00de56932ca8a00f21f Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 4 May 2017 12:47:44 -0700 Subject: [PATCH 2813/4072] 1.14.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 1f4c85725ed..69307d60e81 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.13.0' +__version__ = '1.14.0dev' From 9c0bbaad36ec5f7196db15ee5d1c568009c760a8 Mon Sep 17 00:00:00 2001 From: Michael Friis Date: Thu, 4 May 2017 18:08:33 -0700 Subject: [PATCH 2814/4072] add exception for windows networking Signed-off-by: Michael Friis --- compose/network.py | 1 + 1 file changed, 1 insertion(+) diff --git a/compose/network.py b/compose/network.py index ea6f49631f3..532686d761c 100644 --- a/compose/network.py +++ b/compose/network.py @@ -18,6 +18,7 @@ OPTS_EXCEPTIONS = [ 'com.docker.network.driver.overlay.vxlanid_list', + 'com.docker.network.windowsshim.hnsid' ] From 570cf951ac325ab80ddad000a855e36bee6b9fac Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 5 May 2017 11:40:50 -0700 Subject: [PATCH 2815/4072] New network config whitelist option in unit test Signed-off-by: Joffrey F --- tests/unit/network_test.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/unit/network_test.py b/tests/unit/network_test.py index d1cf2ccf955..4b40ea88436 100644 --- a/tests/unit/network_test.py +++ b/tests/unit/network_test.py @@ -66,7 +66,8 @@ 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' + 'com.docker.network.driver.foo': 'bar', + 'com.docker.network.windowsshim.hnsid': 'aac3fd4887daaec1e3b', } net = Network( None, 'compose_test', 'net1', 'overlay', From a5837ba358a8ba402f2e31f5223b6da754238bb7 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 5 May 2017 16:40:45 -0700 Subject: [PATCH 2816/4072] Use different method to compute ServicePort.repr Workaround for https://bugs.python.org/issue24931 Signed-off-by: Joffrey F --- compose/config/types.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/config/types.py b/compose/config/types.py index dd61a879630..5d3bb5cb758 100644 --- a/compose/config/types.py +++ b/compose/config/types.py @@ -306,7 +306,7 @@ def merge_field(self): def repr(self): return dict( - [(k, v) for k, v in self._asdict().items() if v is not None] + [(k, v) for k, v in zip(self._fields, self) if v is not None] ) def legacy_repr(self): From f1fd9eb1d0f783a54ad4ebb89da341c20b4fcf5e Mon Sep 17 00:00:00 2001 From: Victoria Bialas Date: Mon, 8 May 2017 16:31:19 -0700 Subject: [PATCH 2817/4072] Updated CLI help for docker-compose pull command removed reference to docker-stack.yml in pull command help referenced generic Compose file, consistent naming in Help, init caps Signed-off-by: Victoria Bialas --- compose/cli/main.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 37e299d94e1..5b65e5dd9ef 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -171,12 +171,12 @@ class TopLevelCommand(object): in the client certificate (for example if your docker host is an IP address) --project-directory PATH Specify an alternate working directory - (default: the path of the compose file) + (default: the path of the Compose file) Commands: build Build or rebuild services bundle Generate a Docker bundle from the Compose file - config Validate and view the compose file + 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 @@ -273,7 +273,7 @@ def bundle(self, config_options, options): def config(self, config_options, options): """ - Validate and view the compose file. + Validate and view the Compose file. Usage: config [options] @@ -627,7 +627,7 @@ def ps(self, options): def pull(self, options): """ - Pulls images for services. + Pulls images for services defined in a Compose file, but does not start the containers. Usage: pull [options] [SERVICE...] From 50437bd6eabdef4d58e76ea756e638e8ff9af7db Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Thu, 11 May 2017 18:19:01 +0200 Subject: [PATCH 2818/4072] Add docker-compose exec -u to docs and completion Signed-off-by: Harald Albers --- compose/cli/main.py | 2 +- contrib/completion/bash/docker-compose | 4 ++-- contrib/completion/zsh/_docker-compose | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 5b65e5dd9ef..49800ba5326 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -391,7 +391,7 @@ def exec_command(self, options): Options: -d Detached mode: Run command in the background. --privileged Give extended privileges to the process. - --user USER Run the command as this user. + -u, --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 diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index 2c2be61c758..57dfd51f5a5 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -224,14 +224,14 @@ _docker_compose_events() { _docker_compose_exec() { case "$prev" in - --index|--user) + --index|--user|-u) return ;; esac case "$cur" in -*) - COMPREPLY=( $( compgen -W "-d --help --index --privileged -T --user" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "-d --help --index --privileged -T --user -u" -- "$cur" ) ) ;; *) __docker_compose_services_running diff --git a/contrib/completion/zsh/_docker-compose b/contrib/completion/zsh/_docker-compose index 8513884bcdd..f53f963347e 100644 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -241,7 +241,7 @@ __docker-compose_subcommand() { $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' \ + '(-u --user)'{-u,--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' \ From 9daced4c0433441bd36824b0af06ed0d31392158 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 12 May 2017 17:39:56 -0700 Subject: [PATCH 2819/4072] Prevent dependencies rescaling when executing `docker-compose run` Signed-off-by: Joffrey F --- compose/cli/main.py | 4 +++- compose/project.py | 6 ++++-- compose/service.py | 15 ++++++++++----- tests/acceptance/cli_test.py | 11 +++++++++++ 4 files changed, 28 insertions(+), 8 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 49800ba5326..c91b8d8989a 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -1138,7 +1138,9 @@ def run_one_off_container(container_options, project, service, options): project.up( service_names=deps, start_deps=True, - strategy=ConvergenceStrategy.never) + strategy=ConvergenceStrategy.never, + rescale=False + ) project.initialize() diff --git a/compose/project.py b/compose/project.py index e80b10455db..b282f718de5 100644 --- a/compose/project.py +++ b/compose/project.py @@ -382,7 +382,8 @@ def up(self, timeout=None, detached=False, remove_orphans=False, - scale_override=None): + scale_override=None, + rescale=True): warn_for_swarm_mode(self.client) @@ -405,7 +406,8 @@ def do(service): plans[service.name], timeout=timeout, detached=detached, - scale_override=scale_override.get(service.name) + scale_override=scale_override.get(service.name), + rescale=rescale ) def get_deps(service): diff --git a/compose/service.py b/compose/service.py index 8699372ed12..edd0a376478 100644 --- a/compose/service.py +++ b/compose/service.py @@ -390,7 +390,7 @@ def create_and_start(service, n): return containers def _execute_convergence_recreate(self, containers, scale, timeout, detached, start): - if len(containers) > scale: + if scale is not None and len(containers) > scale: self._downscale(containers[scale:], timeout) containers = containers[:scale] @@ -408,14 +408,14 @@ def recreate(container): for error in errors.values(): raise OperationFailedError(error) - if len(containers) < scale: + if scale is not None and len(containers) < scale: containers.extend(self._execute_convergence_create( scale - len(containers), detached, start )) return containers def _execute_convergence_start(self, containers, scale, timeout, detached, start): - if len(containers) > scale: + if scale is not None and len(containers) > scale: self._downscale(containers[scale:], timeout) containers = containers[:scale] if start: @@ -429,7 +429,7 @@ def _execute_convergence_start(self, containers, scale, timeout, detached, start for error in errors.values(): raise OperationFailedError(error) - if len(containers) < scale: + if scale is not None and len(containers) < scale: containers.extend(self._execute_convergence_create( scale - len(containers), detached, start )) @@ -448,7 +448,7 @@ def stop_and_remove(container): ) def execute_convergence_plan(self, plan, timeout=None, detached=False, - start=True, scale_override=None): + start=True, scale_override=None, rescale=True): (action, containers) = plan scale = scale_override if scale_override is not None else self.scale_num containers = sorted(containers, key=attrgetter('number')) @@ -460,6 +460,11 @@ def execute_convergence_plan(self, plan, timeout=None, detached=False, scale, detached, start ) + # The create action needs always needs an initial scale, but otherwise, + # we set scale to none in no-rescale scenarios (`run` dependencies) + if not rescale: + scale = None + if action == 'recreate': return self._execute_convergence_recreate( containers, scale, timeout, detached, start diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 75b15ae65a2..30eff1b6aff 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1211,6 +1211,17 @@ def test_run_service_with_dependencies(self): self.assertEqual(len(db.containers()), 1) self.assertEqual(len(console.containers()), 0) + def test_run_service_with_scaled_dependencies(self): + self.base_dir = 'tests/fixtures/v2-dependencies' + self.dispatch(['up', '-d', '--scale', 'db=2', '--scale', 'console=0']) + db = self.project.get_service('db') + console = self.project.get_service('console') + assert len(db.containers()) == 2 + assert len(console.containers()) == 0 + self.dispatch(['run', 'web', '/bin/true'], None) + assert len(db.containers()) == 2 + assert 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']) From 511b981f11719f5deffbf9b25d829c02fb96d219 Mon Sep 17 00:00:00 2001 From: mengskysama Date: Mon, 15 May 2017 15:22:22 +0800 Subject: [PATCH 2820/4072] fix python3.x _asdict() return None Signed-off-by: mengskysama --- compose/config/types.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/config/types.py b/compose/config/types.py index 5d3bb5cb758..d853d84f455 100644 --- a/compose/config/types.py +++ b/compose/config/types.py @@ -258,7 +258,7 @@ def merge_field(self): def repr(self): return dict( - [(k, v) for k, v in self._asdict().items() if v is not None] + [(k, v) for k, v in zip(self._fields, self) if v is not None] ) From 93d1ce5a55d6341fc0a63f518405cff73671026e Mon Sep 17 00:00:00 2001 From: Alexey Rokhin Date: Wed, 17 May 2017 14:50:29 +0300 Subject: [PATCH 2821/4072] Add cpu_count, cpu_percent, cpus parameters. Signed-off-by: Alexey Rokhin --- compose/config/config.py | 3 +++ compose/config/config_schema_v2.2.json | 3 +++ compose/service.py | 10 ++++++++++ setup.py | 2 +- tests/integration/service_test.py | 25 +++++++++++++++++++++++++ tests/integration/testcases.py | 5 ++++- tests/unit/config/config_test.py | 4 ++++ 7 files changed, 50 insertions(+), 2 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index f1195c8ec67..056847a85b8 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -52,8 +52,11 @@ 'cap_drop', 'cgroup_parent', 'command', + 'cpu_count', + 'cpu_percent', 'cpu_quota', 'cpu_shares', + 'cpus', 'cpuset', 'detach', 'devices', diff --git a/compose/config/config_schema_v2.2.json b/compose/config/config_schema_v2.2.json index a178fccc40a..bbf312c6a8c 100644 --- a/compose/config/config_schema_v2.2.json +++ b/compose/config/config_schema_v2.2.json @@ -74,8 +74,11 @@ ] }, "container_name": {"type": "string"}, + "cpu_count": {"type": "integer", "minimum": 0}, + "cpu_percent": {"type": "integer", "minimum": 0, "maximum": 100}, "cpu_shares": {"type": ["number", "string"]}, "cpu_quota": {"type": ["number", "string"]}, + "cpus": {"type": ["number", "string"], "minimum": 0}, "cpuset": {"type": "string"}, "depends_on": { "oneOf": [ diff --git a/compose/service.py b/compose/service.py index edd0a376478..dc653b1652e 100644 --- a/compose/service.py +++ b/compose/service.py @@ -52,7 +52,10 @@ 'cap_add', 'cap_drop', 'cgroup_parent', + 'cpu_count', + 'cpu_percent', 'cpu_quota', + 'cpus', 'devices', 'dns', 'dns_search', @@ -798,6 +801,10 @@ def _get_container_host_config(self, override_options, one_off=False): init_path = options.get('init') options['init'] = True + nano_cpus = None + if options.has_key('cpus'): + nano_cpus = int(options.get('cpus') * 1000000000) + return self.client.create_host_config( links=self._get_links(link_to_self=one_off), port_bindings=build_port_bindings( @@ -837,6 +844,9 @@ def _get_container_host_config(self, override_options, one_off=False): init=options.get('init', None), init_path=init_path, isolation=options.get('isolation'), + cpu_count=options.get('cpu_count'), + cpu_percent=options.get('cpu_percent'), + nano_cpus=nano_cpus, ) def get_secret_volumes(self): diff --git a/setup.py b/setup.py index 19a0d4aa00d..8dbb337cc24 100644 --- a/setup.py +++ b/setup.py @@ -37,7 +37,7 @@ def find_version(*file_paths): 'requests >= 2.6.1, != 2.11.0, < 2.12', 'texttable >= 0.8.1, < 0.9', 'websocket-client >= 0.32.0, < 1.0', - 'docker >= 2.2.1, < 3.0', + 'docker >= 2.3.0, < 3.0', 'dockerpty >= 0.4.1, < 0.5', 'six >= 1.3.0, < 2', 'jsonschema >= 2.5.1, < 3', diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 87549c50680..4ee9c3d1996 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -33,6 +33,7 @@ from compose.service import NetworkMode from compose.service import Service from tests.integration.testcases import v2_1_only +from tests.integration.testcases import v2_2_only from tests.integration.testcases import v2_only from tests.integration.testcases import v3_only @@ -110,6 +111,30 @@ def test_create_container_with_cpu_quota(self): container.start() self.assertEqual(container.get('HostConfig.CpuQuota'), 40000) + @v2_2_only() + def test_create_container_with_cpu_count(self): + self.require_api_version('1.25') + service = self.create_service('db', cpu_count=2) + container = service.create_container() + service.start_container(container) + self.assertEqual(container.get('HostConfig.CpuCount'), 2) + + @v2_2_only() + def test_create_container_with_cpu_percent(self): + self.require_api_version('1.25') + service = self.create_service('db', cpu_percent=12) + container = service.create_container() + service.start_container(container) + self.assertEqual(container.get('HostConfig.CpuPercent'), 12) + + @v2_2_only() + def test_create_container_with_cpus(self): + self.require_api_version('1.25') + service = self.create_service('db', cpus=1) + container = service.create_container() + service.start_container(container) + self.assertEqual(container.get('HostConfig.NanoCpus'), 1000000000) + def test_create_container_with_shm_size(self): self.require_api_version('1.22') service = self.create_service('db', shm_size=67108864) diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index a5fe999d979..1bed6e8ff7a 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -15,6 +15,7 @@ from compose.const import COMPOSEFILE_V1 as V1 from compose.const import COMPOSEFILE_V2_0 as V2_0 from compose.const import COMPOSEFILE_V2_0 as V2_1 +from compose.const import COMPOSEFILE_V2_2 as V2_2 from compose.const import COMPOSEFILE_V3_2 as V3_2 from compose.const import LABEL_PROJECT from compose.progress_stream import stream_output @@ -69,9 +70,11 @@ def v2_only(): def v2_1_only(): return build_version_required_decorator((V1, V2_0)) +def v2_2_only(): + return build_version_required_decorator((V1, V2_0, V2_1)) def v3_only(): - return build_version_required_decorator((V1, V2_0, V2_1)) + return build_version_required_decorator((V1, V2_0, V2_1, V2_2)) class DockerClientTestCase(unittest.TestCase): diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index d3087fffe46..e66e952f83f 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -27,6 +27,7 @@ from compose.const import COMPOSEFILE_V1 as V1 from compose.const import COMPOSEFILE_V2_0 as V2_0 from compose.const import COMPOSEFILE_V2_1 as V2_1 +from compose.const import COMPOSEFILE_V2_2 as V2_2 from compose.const import COMPOSEFILE_V3_0 as V3_0 from compose.const import COMPOSEFILE_V3_1 as V3_1 from compose.const import COMPOSEFILE_V3_2 as V3_2 @@ -174,6 +175,9 @@ def test_valid_versions(self): cfg = config.load(build_config_details({'version': '2.1'})) assert cfg.version == V2_1 + cfg = config.load(build_config_details({'version': '2.2'})) + assert cfg.version == V2_2 + for version in ['3', '3.0']: cfg = config.load(build_config_details({'version': version})) assert cfg.version == V3_0 From 2d4fc2cd512758b68500bc0327c44be0ee1f7fb3 Mon Sep 17 00:00:00 2001 From: Alexey Rokhin Date: Wed, 17 May 2017 15:10:44 +0300 Subject: [PATCH 2822/4072] Fix cpu option checking. Signed-off-by: Alexey Rokhin --- compose/service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/service.py b/compose/service.py index dc653b1652e..3956a478217 100644 --- a/compose/service.py +++ b/compose/service.py @@ -802,7 +802,7 @@ def _get_container_host_config(self, override_options, one_off=False): options['init'] = True nano_cpus = None - if options.has_key('cpus'): + if 'cpus' in options: nano_cpus = int(options.get('cpus') * 1000000000) return self.client.create_host_config( From e621117ab23c9f7bfc24c8a1e68ef056ac9d69b1 Mon Sep 17 00:00:00 2001 From: Alexey Rokhin Date: Wed, 17 May 2017 15:21:35 +0300 Subject: [PATCH 2823/4072] Fix testcases.py formatting Signed-off-by: Alexey Rokhin --- tests/integration/testcases.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index 1bed6e8ff7a..57814872cfa 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -70,9 +70,11 @@ def v2_only(): def v2_1_only(): return build_version_required_decorator((V1, V2_0)) + def v2_2_only(): return build_version_required_decorator((V1, V2_0, V2_1)) + def v3_only(): return build_version_required_decorator((V1, V2_0, V2_1, V2_2)) From 56f63c858609e4b0a5a1f5604a7d15eaa02b8071 Mon Sep 17 00:00:00 2001 From: Alexey Rokhin Date: Wed, 17 May 2017 16:18:28 +0300 Subject: [PATCH 2824/4072] skip cpu_percent test for Linux Signed-off-by: Alexey Rokhin --- 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 4ee9c3d1996..a1a7497a63f 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -25,6 +25,7 @@ from compose.const import LABEL_PROJECT from compose.const import LABEL_SERVICE from compose.const import LABEL_VERSION +from compose.const import IS_WINDOWS_PLATFORM from compose.container import Container from compose.errors import OperationFailedError from compose.project import OneOffFilter @@ -120,6 +121,7 @@ def test_create_container_with_cpu_count(self): self.assertEqual(container.get('HostConfig.CpuCount'), 2) @v2_2_only() + @pytest.mark.skipif(not IS_WINDOWS_PLATFORM, reason='cpu_percent is not supported for Linux') def test_create_container_with_cpu_percent(self): self.require_api_version('1.25') service = self.create_service('db', cpu_percent=12) From aeeed0cf2fee5e9dc2150b968fa5949d7a468180 Mon Sep 17 00:00:00 2001 From: Alexey Rokhin Date: Wed, 17 May 2017 16:42:43 +0300 Subject: [PATCH 2825/4072] service_test.py reorder imports Signed-off-by: Alexey Rokhin --- 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 a1a7497a63f..a5b5bda5727 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -19,13 +19,13 @@ from compose import __version__ from compose.config.types import VolumeFromSpec from compose.config.types import VolumeSpec +from compose.const import IS_WINDOWS_PLATFORM from compose.const import LABEL_CONFIG_HASH from compose.const import LABEL_CONTAINER_NUMBER from compose.const import LABEL_ONE_OFF from compose.const import LABEL_PROJECT from compose.const import LABEL_SERVICE from compose.const import LABEL_VERSION -from compose.const import IS_WINDOWS_PLATFORM from compose.container import Container from compose.errors import OperationFailedError from compose.project import OneOffFilter From b815a00e33166f6bd3b014d4a8a1a1a3b3a367b0 Mon Sep 17 00:00:00 2001 From: Alexey Rokhin Date: Wed, 17 May 2017 23:21:47 +0300 Subject: [PATCH 2826/4072] Implement review suggestions. Signed-off-by: Alexey Rokhin --- compose/config/config_schema_v2.2.json | 2 +- compose/service.py | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/compose/config/config_schema_v2.2.json b/compose/config/config_schema_v2.2.json index bbf312c6a8c..a585f2a8c87 100644 --- a/compose/config/config_schema_v2.2.json +++ b/compose/config/config_schema_v2.2.json @@ -78,7 +78,7 @@ "cpu_percent": {"type": "integer", "minimum": 0, "maximum": 100}, "cpu_shares": {"type": ["number", "string"]}, "cpu_quota": {"type": ["number", "string"]}, - "cpus": {"type": ["number", "string"], "minimum": 0}, + "cpus": {"type": "number", "minimum": 0}, "cpuset": {"type": "string"}, "depends_on": { "oneOf": [ diff --git a/compose/service.py b/compose/service.py index 3956a478217..515992ad46d 100644 --- a/compose/service.py +++ b/compose/service.py @@ -803,7 +803,10 @@ def _get_container_host_config(self, override_options, one_off=False): nano_cpus = None if 'cpus' in options: - nano_cpus = int(options.get('cpus') * 1000000000) + nano_cpus = options.get('cpus') * 1000000000 + if isinstance(nano_cpus, float) and not nano_cpus.is_integer(): + raise ValueError("cpus is too precise") + nano_cpus = int(nano_cpus) return self.client.create_host_config( links=self._get_links(link_to_self=one_off), From 201919824f5afd0f73c9d787aec23f4e7004bb0e Mon Sep 17 00:00:00 2001 From: Alexey Rokhin Date: Thu, 18 May 2017 01:43:04 +0300 Subject: [PATCH 2827/4072] move cpus validation to validation.py Signed-off-by: Alexey Rokhin --- compose/config/config.py | 2 ++ compose/config/validation.py | 11 +++++++++++ compose/const.py | 1 + compose/service.py | 6 ++---- 4 files changed, 16 insertions(+), 4 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 056847a85b8..4fddac82270 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -38,6 +38,7 @@ from .validation import match_named_volumes from .validation import validate_against_config_schema from .validation import validate_config_section +from .validation import validate_cpu from .validation import validate_depends_on from .validation import validate_extends_file_path from .validation import validate_links @@ -643,6 +644,7 @@ def validate_service(service_config, service_names, config_file): validate_service_constraints(service_dict, service_name, config_file) validate_paths(service_dict) + validate_cpu(service_config) validate_ulimits(service_config) validate_network_mode(service_config, service_names) validate_depends_on(service_config, service_names) diff --git a/compose/config/validation.py b/compose/config/validation.py index 1df6dd6b7c7..856f811c510 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -15,6 +15,7 @@ from jsonschema import ValidationError from ..const import COMPOSEFILE_V1 as V1 +from ..const import NANOCPUS_SCALE from .errors import ConfigurationError from .errors import VERSION_EXPLANATION from .sort_services import get_service_name_from_network_mode @@ -387,6 +388,16 @@ def handler(errors): handle_errors(validator.iter_errors(config), handler, None) +def validate_cpu(service_config): + cpus = service_config.config.get('cpus') + if not cpus: + return + nano_cpus = cpus * NANOCPUS_SCALE + if isinstance(nano_cpus, float) and not nano_cpus.is_integer(): + raise ConfigurationError( + "cpus must have nine or less digits after decimal point") + + def get_schema_path(): return os.path.dirname(os.path.abspath(__file__)) diff --git a/compose/const.py b/compose/const.py index 573136d5d18..36703138acb 100644 --- a/compose/const.py +++ b/compose/const.py @@ -15,6 +15,7 @@ LABEL_VERSION = 'com.docker.compose.version' LABEL_VOLUME = 'com.docker.compose.volume' LABEL_CONFIG_HASH = 'com.docker.compose.config-hash' +NANOCPUS_SCALE = 1000000000 SECRETS_PATH = '/run/secrets' diff --git a/compose/service.py b/compose/service.py index 515992ad46d..19873d5e5cd 100644 --- a/compose/service.py +++ b/compose/service.py @@ -34,6 +34,7 @@ from .const import LABEL_PROJECT from .const import LABEL_SERVICE from .const import LABEL_VERSION +from .const import NANOCPUS_SCALE from .container import Container from .errors import HealthCheckFailed from .errors import NoHealthCheckConfigured @@ -803,10 +804,7 @@ def _get_container_host_config(self, override_options, one_off=False): nano_cpus = None if 'cpus' in options: - nano_cpus = options.get('cpus') * 1000000000 - if isinstance(nano_cpus, float) and not nano_cpus.is_integer(): - raise ValueError("cpus is too precise") - nano_cpus = int(nano_cpus) + nano_cpus = int(options.get('cpus') * NANOCPUS_SCALE) return self.client.create_host_config( links=self._get_links(link_to_self=one_off), From d10d64ac82ba46e0cb237e77d2ef846383afe09a Mon Sep 17 00:00:00 2001 From: Colin Hebert Date: Thu, 13 Apr 2017 21:51:41 +1000 Subject: [PATCH 2828/4072] Add support for labels during build Signed-off-by: Colin Hebert --- compose/config/config_schema_v3.2.json | 1 + compose/service.py | 1 + 2 files changed, 2 insertions(+) diff --git a/compose/config/config_schema_v3.2.json b/compose/config/config_schema_v3.2.json index ea702fcd581..70ff6ce0564 100644 --- a/compose/config/config_schema_v3.2.json +++ b/compose/config/config_schema_v3.2.json @@ -72,6 +72,7 @@ "context": {"type": "string"}, "dockerfile": {"type": "string"}, "args": {"$ref": "#/definitions/list_or_dict"}, + "labels": {"$ref": "#/definitions/list_or_dict"}, "cache_from": {"$ref": "#/definitions/list_of_strings"} }, "additionalProperties": false diff --git a/compose/service.py b/compose/service.py index 19873d5e5cd..dcbbe251ed0 100644 --- a/compose/service.py +++ b/compose/service.py @@ -884,6 +884,7 @@ def build(self, no_cache=False, pull=False, force_rm=False, build_args_override= nocache=no_cache, dockerfile=build_opts.get('dockerfile', None), cache_from=build_opts.get('cache_from', None), + labels=build_opts.get('labels', None), buildargs=build_args ) From 3f920d515da065f1c19ab53e7eedd2d24b9e9bdc Mon Sep 17 00:00:00 2001 From: Colin Hebert Date: Thu, 13 Apr 2017 22:21:33 +1000 Subject: [PATCH 2829/4072] Update tests to show labels set to None Signed-off-by: Colin Hebert --- tests/unit/service_test.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index c32c3633943..7b7a078f8cd 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -471,6 +471,7 @@ def test_create_container(self): nocache=False, rm=True, buildargs={}, + labels=None, cache_from=None, ) @@ -508,6 +509,7 @@ def test_ensure_image_exists_force_build(self): nocache=False, rm=True, buildargs={}, + labels=None, cache_from=None, ) From 67e48ae4cbd649229c4c1bc81685987abfbff97f Mon Sep 17 00:00:00 2001 From: Colin Hebert Date: Thu, 13 Apr 2017 22:35:21 +1000 Subject: [PATCH 2830/4072] Add tests for the labels Signed-off-by: Colin Hebert --- tests/unit/config/config_test.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index e66e952f83f..3d42b8392e2 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -825,6 +825,34 @@ def test_load_with_buildargs(self): assert service['build']['args']['opt1'] == '42' assert service['build']['args']['opt2'] == 'foobar' + def test_load_with_labels(self): + service = config.load( + build_config_details( + { + 'version': '3.2', + 'services': { + 'web': { + 'build': { + 'context': '.', + 'dockerfile': 'Dockerfile-alt', + 'labels': { + 'label1': 42, + 'label2': 'foobar' + } + } + } + } + }, + 'tests/fixtures/extends', + 'filename.yml' + ) + ).services[0] + assert 'labels' in service['build'] + assert 'label1' in service['build']['labels'] + assert isinstance(service['build']['labels']['label1'], str) + assert service['build']['labels']['label1'] == '42' + assert service['build']['labels']['label2'] == 'foobar' + def test_build_args_allow_empty_properties(self): service = config.load( build_config_details( From 2182329dae52e17984c31b91ab4ab92b7087b273 Mon Sep 17 00:00:00 2001 From: Colin Hebert Date: Thu, 13 Apr 2017 22:40:07 +1000 Subject: [PATCH 2831/4072] Fix test type Signed-off-by: Colin Hebert --- tests/unit/config/config_test.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 3d42b8392e2..bc51600357b 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -849,8 +849,7 @@ def test_load_with_labels(self): ).services[0] assert 'labels' in service['build'] assert 'label1' in service['build']['labels'] - assert isinstance(service['build']['labels']['label1'], str) - assert service['build']['labels']['label1'] == '42' + assert service['build']['labels']['label1'] == 42 assert service['build']['labels']['label2'] == 'foobar' def test_build_args_allow_empty_properties(self): From 2ffa67cf92cc0466f6aafbd59314a779bc6f4880 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 23 May 2017 11:47:59 -0700 Subject: [PATCH 2832/4072] Add 3.3 format support Remove build.labels field from 3.2 schema Signed-off-by: Joffrey F --- compose/config/config_schema_v3.2.json | 1 - compose/config/config_schema_v3.3.json | 534 +++++++++++++++++++++++++ compose/config/serialize.py | 5 +- compose/const.py | 3 + docker-compose.spec | 5 + tests/unit/config/config_test.py | 5 +- 6 files changed, 548 insertions(+), 5 deletions(-) create mode 100644 compose/config/config_schema_v3.3.json diff --git a/compose/config/config_schema_v3.2.json b/compose/config/config_schema_v3.2.json index 70ff6ce0564..ea702fcd581 100644 --- a/compose/config/config_schema_v3.2.json +++ b/compose/config/config_schema_v3.2.json @@ -72,7 +72,6 @@ "context": {"type": "string"}, "dockerfile": {"type": "string"}, "args": {"$ref": "#/definitions/list_or_dict"}, - "labels": {"$ref": "#/definitions/list_or_dict"}, "cache_from": {"$ref": "#/definitions/list_of_strings"} }, "additionalProperties": false diff --git a/compose/config/config_schema_v3.3.json b/compose/config/config_schema_v3.3.json new file mode 100644 index 00000000000..e69116c3889 --- /dev/null +++ b/compose/config/config_schema_v3.3.json @@ -0,0 +1,534 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "id": "config_schema_v3.3.json", + "type": "object", + "required": ["version"], + + "properties": { + "version": { + "type": "string" + }, + + "services": { + "id": "#/properties/services", + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "$ref": "#/definitions/service" + } + }, + "additionalProperties": false + }, + + "networks": { + "id": "#/properties/networks", + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "$ref": "#/definitions/network" + } + } + }, + + "volumes": { + "id": "#/properties/volumes", + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "$ref": "#/definitions/volume" + } + }, + "additionalProperties": false + }, + + "secrets": { + "id": "#/properties/secrets", + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "$ref": "#/definitions/secret" + } + }, + "additionalProperties": false + }, + + "configs": { + "id": "#/properties/configs", + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "$ref": "#/definitions/config" + } + }, + "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"}, + "labels": {"$ref": "#/definitions/list_or_dict"}, + "cache_from": {"$ref": "#/definitions/list_of_strings"} + }, + "additionalProperties": false + } + ] + }, + "cap_add": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "cap_drop": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "cgroup_parent": {"type": "string"}, + "command": { + "oneOf": [ + {"type": "string"}, + {"type": "array", "items": {"type": "string"}} + ] + }, + "configs": { + "type": "array", + "items": { + "oneOf": [ + {"type": "string"}, + { + "type": "object", + "properties": { + "source": {"type": "string"}, + "target": {"type": "string"}, + "uid": {"type": "string"}, + "gid": {"type": "string"}, + "mode": {"type": "number"} + } + } + ] + } + }, + "container_name": {"type": "string"}, + "credential_spec": {"type": "object", "properties": { + "file": {"type": "string"}, + "registry": {"type": "string"} + }}, + "depends_on": {"$ref": "#/definitions/list_of_strings"}, + "devices": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "dns": {"$ref": "#/definitions/string_or_list"}, + "dns_search": {"$ref": "#/definitions/string_or_list"}, + "domainname": {"type": "string"}, + "entrypoint": { + "oneOf": [ + {"type": "string"}, + {"type": "array", "items": {"type": "string"}} + ] + }, + "env_file": {"$ref": "#/definitions/string_or_list"}, + "environment": {"$ref": "#/definitions/list_or_dict"}, + + "expose": { + "type": "array", + "items": { + "type": ["string", "number"], + "format": "expose" + }, + "uniqueItems": true + }, + + "external_links": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "extra_hosts": {"$ref": "#/definitions/list_or_dict"}, + "healthcheck": {"$ref": "#/definitions/healthcheck"}, + "hostname": {"type": "string"}, + "image": {"type": "string"}, + "ipc": {"type": "string"}, + "labels": {"$ref": "#/definitions/list_or_dict"}, + "links": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + + "logging": { + "type": "object", + + "properties": { + "driver": {"type": "string"}, + "options": { + "type": "object", + "patternProperties": { + "^.+$": {"type": ["string", "number", "null"]} + } + } + }, + "additionalProperties": false + }, + + "mac_address": {"type": "string"}, + "network_mode": {"type": "string"}, + + "networks": { + "oneOf": [ + {"$ref": "#/definitions/list_of_strings"}, + { + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "oneOf": [ + { + "type": "object", + "properties": { + "aliases": {"$ref": "#/definitions/list_of_strings"}, + "ipv4_address": {"type": "string"}, + "ipv6_address": {"type": "string"} + }, + "additionalProperties": false + }, + {"type": "null"} + ] + } + }, + "additionalProperties": false + } + ] + }, + "pid": {"type": ["string", "null"]}, + + "ports": { + "type": "array", + "items": { + "oneOf": [ + {"type": "number", "format": "ports"}, + {"type": "string", "format": "ports"}, + { + "type": "object", + "properties": { + "mode": {"type": "string"}, + "target": {"type": "integer"}, + "published": {"type": "integer"}, + "protocol": {"type": "string"} + }, + "additionalProperties": false + } + ] + }, + "uniqueItems": true + }, + + "privileged": {"type": "boolean"}, + "read_only": {"type": "boolean"}, + "restart": {"type": "string"}, + "security_opt": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "shm_size": {"type": ["number", "string"]}, + "secrets": { + "type": "array", + "items": { + "oneOf": [ + {"type": "string"}, + { + "type": "object", + "properties": { + "source": {"type": "string"}, + "target": {"type": "string"}, + "uid": {"type": "string"}, + "gid": {"type": "string"}, + "mode": {"type": "number"} + } + } + ] + } + }, + "sysctls": {"$ref": "#/definitions/list_or_dict"}, + "stdin_open": {"type": "boolean"}, + "stop_grace_period": {"type": "string", "format": "duration"}, + "stop_signal": {"type": "string"}, + "tmpfs": {"$ref": "#/definitions/string_or_list"}, + "tty": {"type": "boolean"}, + "ulimits": { + "type": "object", + "patternProperties": { + "^[a-z]+$": { + "oneOf": [ + {"type": "integer"}, + { + "type":"object", + "properties": { + "hard": {"type": "integer"}, + "soft": {"type": "integer"} + }, + "required": ["soft", "hard"], + "additionalProperties": false + } + ] + } + } + }, + "user": {"type": "string"}, + "userns_mode": {"type": "string"}, + "volumes": { + "type": "array", + "items": { + "oneOf": [ + {"type": "string"}, + { + "type": "object", + "required": ["type"], + "properties": { + "type": {"type": "string"}, + "source": {"type": "string"}, + "target": {"type": "string"}, + "read_only": {"type": "boolean"}, + "consistency": {"type": "string"}, + "bind": { + "type": "object", + "properties": { + "propagation": {"type": "string"} + } + }, + "volume": { + "type": "object", + "properties": { + "nocopy": {"type": "boolean"} + } + } + } + } + ], + "uniqueItems": true + } + }, + "working_dir": {"type": "string"} + }, + "additionalProperties": false + }, + + "healthcheck": { + "id": "#/definitions/healthcheck", + "type": "object", + "additionalProperties": false, + "properties": { + "disable": {"type": "boolean"}, + "interval": {"type": "string"}, + "retries": {"type": "number"}, + "test": { + "oneOf": [ + {"type": "string"}, + {"type": "array", "items": {"type": "string"}} + ] + }, + "timeout": {"type": "string"} + } + }, + "deployment": { + "id": "#/definitions/deployment", + "type": ["object", "null"], + "properties": { + "mode": {"type": "string"}, + "endpoint_mode": {"type": "string"}, + "replicas": {"type": "integer"}, + "labels": {"$ref": "#/definitions/list_or_dict"}, + "update_config": { + "type": "object", + "properties": { + "parallelism": {"type": "integer"}, + "delay": {"type": "string", "format": "duration"}, + "failure_action": {"type": "string"}, + "monitor": {"type": "string", "format": "duration"}, + "max_failure_ratio": {"type": "number"} + }, + "additionalProperties": false + }, + "resources": { + "type": "object", + "properties": { + "limits": {"$ref": "#/definitions/resource"}, + "reservations": {"$ref": "#/definitions/resource"} + } + }, + "restart_policy": { + "type": "object", + "properties": { + "condition": {"type": "string"}, + "delay": {"type": "string", "format": "duration"}, + "max_attempts": {"type": "integer"}, + "window": {"type": "string", "format": "duration"} + }, + "additionalProperties": false + }, + "placement": { + "type": "object", + "properties": { + "constraints": {"type": "array", "items": {"type": "string"}}, + "preferences": { + "type": "array", + "items": { + "type": "object", + "properties": { + "spread": {"type": "string"} + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + + "resource": { + "id": "#/definitions/resource", + "type": "object", + "properties": { + "cpus": {"type": "string"}, + "memory": {"type": "string"} + }, + "additionalProperties": false + }, + + "network": { + "id": "#/definitions/network", + "type": ["object", "null"], + "properties": { + "driver": {"type": "string"}, + "driver_opts": { + "type": "object", + "patternProperties": { + "^.+$": {"type": ["string", "number"]} + } + }, + "ipam": { + "type": "object", + "properties": { + "driver": {"type": "string"}, + "config": { + "type": "array", + "items": { + "type": "object", + "properties": { + "subnet": {"type": "string"} + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + }, + "external": { + "type": ["boolean", "object"], + "properties": { + "name": {"type": "string"} + }, + "additionalProperties": false + }, + "internal": {"type": "boolean"}, + "attachable": {"type": "boolean"}, + "labels": {"$ref": "#/definitions/list_or_dict"} + }, + "additionalProperties": false + }, + + "volume": { + "id": "#/definitions/volume", + "type": ["object", "null"], + "properties": { + "driver": {"type": "string"}, + "driver_opts": { + "type": "object", + "patternProperties": { + "^.+$": {"type": ["string", "number"]} + } + }, + "external": { + "type": ["boolean", "object"], + "properties": { + "name": {"type": "string"} + }, + "additionalProperties": false + }, + "labels": {"$ref": "#/definitions/list_or_dict"} + }, + "additionalProperties": false + }, + + "secret": { + "id": "#/definitions/secret", + "type": "object", + "properties": { + "file": {"type": "string"}, + "external": { + "type": ["boolean", "object"], + "properties": { + "name": {"type": "string"} + } + }, + "labels": {"$ref": "#/definitions/list_or_dict"} + }, + "additionalProperties": false + }, + + "config": { + "id": "#/definitions/config", + "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"] + } + } + } + } + } +} diff --git a/compose/config/serialize.py b/compose/config/serialize.py index 040973ae080..ac78b77a2db 100644 --- a/compose/config/serialize.py +++ b/compose/config/serialize.py @@ -10,6 +10,7 @@ from compose.const import COMPOSEFILE_V2_2 as V2_2 from compose.const import COMPOSEFILE_V3_1 as V3_1 from compose.const import COMPOSEFILE_V3_2 as V3_2 +from compose.const import COMPOSEFILE_V3_3 as V3_3 def serialize_config_type(dumper, data): @@ -50,7 +51,7 @@ def denormalize_config(config, image_digests=None): if 'external_name' in vol_conf: del vol_conf['external_name'] - if config.version in (V3_1, V3_2): + if config.version in (V3_1, V3_2, V3_3): result['secrets'] = config.secrets.copy() for secret_name, secret_conf in result['secrets'].items(): if 'external_name' in secret_conf: @@ -114,7 +115,7 @@ def denormalize_service_dict(service_dict, version, image_digest=None): service_dict['healthcheck']['timeout'] ) - if 'ports' in service_dict and version not in (V3_2,): + if 'ports' in service_dict and version not in (V3_2, V3_3): service_dict['ports'] = [ p.legacy_repr() if isinstance(p, types.ServicePort) else p for p in service_dict['ports'] diff --git a/compose/const.py b/compose/const.py index 36703138acb..36f213897a2 100644 --- a/compose/const.py +++ b/compose/const.py @@ -27,6 +27,7 @@ COMPOSEFILE_V3_0 = '3.0' COMPOSEFILE_V3_1 = '3.1' COMPOSEFILE_V3_2 = '3.2' +COMPOSEFILE_V3_3 = '3.3' API_VERSIONS = { COMPOSEFILE_V1: '1.21', @@ -36,6 +37,7 @@ COMPOSEFILE_V3_0: '1.25', COMPOSEFILE_V3_1: '1.25', COMPOSEFILE_V3_2: '1.25', + COMPOSEFILE_V3_3: '1.30', } API_VERSION_TO_ENGINE_VERSION = { @@ -46,4 +48,5 @@ API_VERSIONS[COMPOSEFILE_V3_0]: '1.13.0', API_VERSIONS[COMPOSEFILE_V3_1]: '1.13.0', API_VERSIONS[COMPOSEFILE_V3_2]: '1.13.0', + API_VERSIONS[COMPOSEFILE_V3_3]: '17.06.0', } diff --git a/docker-compose.spec b/docker-compose.spec index 21b3c1742cf..8e0d51ae5f8 100644 --- a/docker-compose.spec +++ b/docker-compose.spec @@ -52,6 +52,11 @@ exe = EXE(pyz, 'compose/config/config_schema_v3.2.json', 'DATA' ), + ( + 'compose/config/config_schema_v3.3.json', + 'compose/config/config_schema_v3.3.json', + 'DATA' + ), ( 'compose/GITSHA', 'compose/GITSHA', diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index bc51600357b..357244c2c33 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -31,6 +31,7 @@ from compose.const import COMPOSEFILE_V3_0 as V3_0 from compose.const import COMPOSEFILE_V3_1 as V3_1 from compose.const import COMPOSEFILE_V3_2 as V3_2 +from compose.const import COMPOSEFILE_V3_3 as V3_3 from compose.const import IS_WINDOWS_PLATFORM from compose.utils import nanoseconds_from_time_seconds from tests import mock @@ -825,11 +826,11 @@ def test_load_with_buildargs(self): assert service['build']['args']['opt1'] == '42' assert service['build']['args']['opt2'] == 'foobar' - def test_load_with_labels(self): + def test_load_with_build_labels(self): service = config.load( build_config_details( { - 'version': '3.2', + 'version': V3_3, 'services': { 'web': { 'build': { From d0b80f537bee70e2cf312c091966ceabf7547436 Mon Sep 17 00:00:00 2001 From: Eli Atzaba Date: Sat, 29 Apr 2017 02:00:52 +0300 Subject: [PATCH 2833/4072] Fix for yaml extention does not work with override file Signed-off-by: Eli Atzaba --- compose/config/config.py | 9 ++++++--- tests/acceptance/cli_test.py | 16 ++++++++++++++++ .../docker-compose.override.yaml | 3 +++ .../override-yaml-files/docker-compose.yml | 10 ++++++++++ 4 files changed, 35 insertions(+), 3 deletions(-) create mode 100644 tests/fixtures/override-yaml-files/docker-compose.override.yaml create mode 100644 tests/fixtures/override-yaml-files/docker-compose.yml diff --git a/compose/config/config.py b/compose/config/config.py index 4fddac82270..861a3e9bf8d 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -128,7 +128,7 @@ 'docker-compose.yaml', ] -DEFAULT_OVERRIDE_FILENAME = 'docker-compose.override.yml' +DEFAULT_OVERRIDE_FILENAMES = ('docker-compose.override.yml', 'docker-compose.override.yaml') log = logging.getLogger(__name__) @@ -292,8 +292,11 @@ def get_default_config_files(base_dir): def get_default_override_file(path): - override_filename = os.path.join(path, DEFAULT_OVERRIDE_FILENAME) - return [override_filename] if os.path.exists(override_filename) else [] + for default_override_filename in DEFAULT_OVERRIDE_FILENAMES: + override_filename = os.path.join(path, default_override_filename) + if os.path.exists(override_filename): + return [override_filename] + return [] def find_candidates_in_parent_dirs(filenames, path): diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 30eff1b6aff..f6c074364ed 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -2149,3 +2149,19 @@ def test_images_default_composefile(self): assert 'busybox' in result.stdout assert 'multiplecomposefiles_another_1' in result.stdout assert 'multiplecomposefiles_simple_1' in result.stdout + + def test_up_with_override_yaml(self): + self.base_dir = 'tests/fixtures/override-yaml-files' + self._project = get_project(self.base_dir, []) + self.dispatch( + [ + 'up', '-d', + ], + None) + + containers = self.project.containers() + self.assertEqual(len(containers), 2) + + web, db = containers + self.assertEqual(web.human_readable_command, 'sleep 100') + self.assertEqual(db.human_readable_command, 'top') diff --git a/tests/fixtures/override-yaml-files/docker-compose.override.yaml b/tests/fixtures/override-yaml-files/docker-compose.override.yaml new file mode 100644 index 00000000000..58c67348295 --- /dev/null +++ b/tests/fixtures/override-yaml-files/docker-compose.override.yaml @@ -0,0 +1,3 @@ + +db: + command: "top" diff --git a/tests/fixtures/override-yaml-files/docker-compose.yml b/tests/fixtures/override-yaml-files/docker-compose.yml new file mode 100644 index 00000000000..5f2909d69e7 --- /dev/null +++ b/tests/fixtures/override-yaml-files/docker-compose.yml @@ -0,0 +1,10 @@ + +web: + image: busybox:latest + command: "sleep 100" + links: + - db + +db: + image: busybox:latest + command: "sleep 200" From 88fa8db79aade1af516ec7f99b9a902cc0696ee8 Mon Sep 17 00:00:00 2001 From: Eli Atzaba Date: Sun, 7 May 2017 18:03:14 +0300 Subject: [PATCH 2834/4072] Raise exception when override.yaml & override.yml coexist Signed-off-by: Eli Atzaba --- compose/config/config.py | 12 +++++++----- compose/config/errors.py | 12 ++++++++++++ tests/acceptance/cli_test.py | 8 +++++++- .../docker-compose.override.yaml | 3 +++ .../docker-compose.override.yml | 3 +++ .../duplicate-override-yaml-files/docker-compose.yml | 10 ++++++++++ 6 files changed, 42 insertions(+), 6 deletions(-) create mode 100644 tests/fixtures/duplicate-override-yaml-files/docker-compose.override.yaml create mode 100644 tests/fixtures/duplicate-override-yaml-files/docker-compose.override.yml create mode 100644 tests/fixtures/duplicate-override-yaml-files/docker-compose.yml diff --git a/compose/config/config.py b/compose/config/config.py index 861a3e9bf8d..2a81b93da27 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -24,6 +24,7 @@ from .errors import CircularReference from .errors import ComposeFileNotFound from .errors import ConfigurationError +from .errors import DuplicateOverrideFileFound from .errors import VERSION_EXPLANATION from .interpolation import interpolate_environment_variables from .sort_services import get_container_name_from_network_mode @@ -292,11 +293,12 @@ def get_default_config_files(base_dir): def get_default_override_file(path): - for default_override_filename in DEFAULT_OVERRIDE_FILENAMES: - override_filename = os.path.join(path, default_override_filename) - if os.path.exists(override_filename): - return [override_filename] - return [] + override_files_in_path = [os.path.join(path, override_filename) for override_filename + in DEFAULT_OVERRIDE_FILENAMES + if os.path.exists(os.path.join(path, override_filename))] + if len(override_files_in_path) > 1: + raise DuplicateOverrideFileFound(override_files_in_path) + return override_files_in_path def find_candidates_in_parent_dirs(filenames, path): diff --git a/compose/config/errors.py b/compose/config/errors.py index 9b82df0ab55..060564fc49c 100644 --- a/compose/config/errors.py +++ b/compose/config/errors.py @@ -44,3 +44,15 @@ def __init__(self, supported_filenames): Supported filenames: %s """ % ", ".join(supported_filenames)) + + +class DuplicateOverrideFileFound(ConfigurationError): + def __init__(self, override_filenames): + self.override_filenames = override_filenames + + @property + def msg(self): + return """ + Unable to determine with duplicate override files, only a single override file can be used. + Found: %s + """ % ", ".join(self.override_filenames) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index f6c074364ed..1ba64201f92 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -21,6 +21,7 @@ from .. import mock from ..helpers import create_host_file from compose.cli.command import get_project +from compose.config.errors import DuplicateOverrideFileFound from compose.container import Container from compose.project import OneOffFilter from compose.utils import nanoseconds_from_time_seconds @@ -31,7 +32,6 @@ from tests.integration.testcases import v2_only from tests.integration.testcases import v3_only - ProcessResult = namedtuple('ProcessResult', 'stdout stderr') @@ -2165,3 +2165,9 @@ def test_up_with_override_yaml(self): web, db = containers self.assertEqual(web.human_readable_command, 'sleep 100') self.assertEqual(db.human_readable_command, 'top') + + def test_up_with_duplicate_override_yaml_files(self): + self.base_dir = 'tests/fixtures/duplicate-override-yaml-files' + with self.assertRaises(DuplicateOverrideFileFound): + get_project(self.base_dir, []) + self.base_dir = None diff --git a/tests/fixtures/duplicate-override-yaml-files/docker-compose.override.yaml b/tests/fixtures/duplicate-override-yaml-files/docker-compose.override.yaml new file mode 100644 index 00000000000..58c67348295 --- /dev/null +++ b/tests/fixtures/duplicate-override-yaml-files/docker-compose.override.yaml @@ -0,0 +1,3 @@ + +db: + command: "top" diff --git a/tests/fixtures/duplicate-override-yaml-files/docker-compose.override.yml b/tests/fixtures/duplicate-override-yaml-files/docker-compose.override.yml new file mode 100644 index 00000000000..f1b8ef181f7 --- /dev/null +++ b/tests/fixtures/duplicate-override-yaml-files/docker-compose.override.yml @@ -0,0 +1,3 @@ + +db: + command: "sleep 300" diff --git a/tests/fixtures/duplicate-override-yaml-files/docker-compose.yml b/tests/fixtures/duplicate-override-yaml-files/docker-compose.yml new file mode 100644 index 00000000000..5f2909d69e7 --- /dev/null +++ b/tests/fixtures/duplicate-override-yaml-files/docker-compose.yml @@ -0,0 +1,10 @@ + +web: + image: busybox:latest + command: "sleep 100" + links: + - db + +db: + image: busybox:latest + command: "sleep 200" From d2a8a9edaaf40542645ba341d04e944dcbd5f675 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 23 May 2017 12:14:32 -0700 Subject: [PATCH 2835/4072] Rewrite duplicate override error message Signed-off-by: Joffrey F --- compose/config/errors.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/compose/config/errors.py b/compose/config/errors.py index 060564fc49c..ac1d3ac1976 100644 --- a/compose/config/errors.py +++ b/compose/config/errors.py @@ -49,10 +49,7 @@ def __init__(self, supported_filenames): class DuplicateOverrideFileFound(ConfigurationError): def __init__(self, override_filenames): self.override_filenames = override_filenames - - @property - def msg(self): - return """ - Unable to determine with duplicate override files, only a single override file can be used. - Found: %s - """ % ", ".join(self.override_filenames) + super(DuplicateOverrideFileFound, self).__init__( + "Multiple override files found: {}. You may only use a single " + "override file.".format(", ".join(override_filenames)) + ) From c9ff9023b265644ee6053d8946e7eb39f47aa840 Mon Sep 17 00:00:00 2001 From: Pascal Vibet Date: Wed, 26 Apr 2017 13:50:22 +0200 Subject: [PATCH 2836/4072] If COMPOSE_FILE is define then set this variable to the container Signed-off-by: Pascal Vibet --- script/run/run.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/script/run/run.sh b/script/run/run.sh index e697d1f6dca..d1e1fabaaca 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -35,6 +35,7 @@ if [ "$(pwd)" != '/' ]; then VOLUMES="-v $(pwd):$(pwd)" fi if [ -n "$COMPOSE_FILE" ]; then + COMPOSE_OPTIONS="$COMPOSE_OPTIONS -e COMPOSE_FILE=$COMPOSE_FILE" compose_dir=$(realpath $(dirname $COMPOSE_FILE)) fi # TODO: also check --file argument From d29ed0d3e49ccaa59d401a3fc29d4d599fb60fc1 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 23 May 2017 12:38:54 -0700 Subject: [PATCH 2837/4072] Fix improper use of project.stop Add some better test coverage for rm --stop Signed-off-by: Joffrey F --- compose/cli/main.py | 8 +------- tests/acceptance/cli_test.py | 18 +++++++++++++++++- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index c91b8d8989a..cfca0f94902 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -680,13 +680,7 @@ def rm(self, options): one_off = OneOffFilter.include if options.get('--stop'): - running_containers = self.project.containers( - service_names=options['SERVICE'], stopped=False, one_off=one_off - ) - self.project.stop( - service_names=running_containers, - one_off=one_off - ) + self.project.stop(service_names=options['SERVICE'], one_off=one_off) 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 1ba64201f92..89f4f288b03 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1627,8 +1627,24 @@ def test_rm(self): service = self.project.get_service('simple') service.create_container() self.dispatch(['rm', '-fs'], None) + self.assertEqual(len(service.containers(stopped=True)), 0) + + def test_rm_stop(self): + self.dispatch(['up', '-d'], None) simple = self.project.get_service('simple') - self.assertEqual(len(simple.containers()), 0) + another = self.project.get_service('another') + assert len(simple.containers()) == 1 + assert len(another.containers()) == 1 + self.dispatch(['rm', '-fs'], None) + assert len(simple.containers(stopped=True)) == 0 + assert len(another.containers(stopped=True)) == 0 + + self.dispatch(['up', '-d'], None) + assert len(simple.containers()) == 1 + assert len(another.containers()) == 1 + self.dispatch(['rm', '-fs', 'another'], None) + assert len(simple.containers()) == 1 + assert len(another.containers(stopped=True)) == 0 def test_rm_all(self): service = self.project.get_service('simple') From 150c44dc364dcb24b3b6b8256e7ec0fce95225fb Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 23 May 2017 15:17:16 -0700 Subject: [PATCH 2838/4072] Merge all fields inside build dict Signed-off-by: Joffrey F --- compose/config/config.py | 2 + tests/unit/config/config_test.py | 68 ++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/compose/config/config.py b/compose/config/config.py index 2a81b93da27..8dac4fb3328 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -952,6 +952,8 @@ def to_dict(service): md.merge_scalar('context') md.merge_scalar('dockerfile') md.merge_mapping('args', parse_build_arguments) + md.merge_field('cache_from', merge_unique_items_lists, default=[]) + md.merge_mapping('labels', parse_labels) return dict(md) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 357244c2c33..d8973484d25 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -2847,6 +2847,74 @@ def test_remove_explicit_value(self): assert service_dict['labels'] == {'foo': '1', 'bar': ''} +class MergeBuildTest(unittest.TestCase): + def test_full(self): + base = { + 'context': '.', + 'dockerfile': 'Dockerfile', + 'args': { + 'x': '1', + 'y': '2', + }, + 'cache_from': ['ubuntu'], + 'labels': ['com.docker.compose.test=true'] + } + + override = { + 'context': './prod', + 'dockerfile': 'Dockerfile.prod', + 'args': ['x=12'], + 'cache_from': ['debian'], + 'labels': { + 'com.docker.compose.test': 'false', + 'com.docker.compose.prod': 'true', + } + } + + result = config.merge_build(None, {'build': base}, {'build': override}) + assert result['context'] == override['context'] + assert result['dockerfile'] == override['dockerfile'] + assert result['args'] == {'x': '12', 'y': '2'} + assert set(result['cache_from']) == set(['ubuntu', 'debian']) + assert result['labels'] == override['labels'] + + def test_empty_override(self): + base = { + 'context': '.', + 'dockerfile': 'Dockerfile', + 'args': { + 'x': '1', + 'y': '2', + }, + 'cache_from': ['ubuntu'], + 'labels': { + 'com.docker.compose.test': 'true' + } + } + + override = {} + + result = config.merge_build(None, {'build': base}, {'build': override}) + assert result == base + + def test_empty_base(self): + base = {} + + override = { + 'context': './prod', + 'dockerfile': 'Dockerfile.prod', + 'args': {'x': '12'}, + 'cache_from': ['debian'], + 'labels': { + 'com.docker.compose.test': 'false', + 'com.docker.compose.prod': 'true', + } + } + + result = config.merge_build(None, {'build': base}, {'build': override}) + assert result == override + + class MemoryOptionsTest(unittest.TestCase): def test_validation_fails_with_just_memswap_limit(self): From f6aa53ea6c8d9444d2537a3905783d67d2432e6e Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 22 May 2017 14:52:57 -0700 Subject: [PATCH 2839/4072] Network label mismatch now prints a warning instead of raising an error Signed-off-by: Joffrey F --- compose/network.py | 7 +++++-- tests/unit/network_test.py | 7 +++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/compose/network.py b/compose/network.py index 532686d761c..fec83916234 100644 --- a/compose/network.py +++ b/compose/network.py @@ -188,10 +188,13 @@ def check_remote_network_config(remote, local): local_labels = local.labels or {} remote_labels = remote.get('Labels', {}) for k in set.union(set(remote_labels.keys()), set(local_labels.keys())): - if k.startswith('com.docker.compose.'): # We are only interested in user-specified labels + if k.startswith('com.docker.'): # We are only interested in user-specified labels continue if remote_labels.get(k) != local_labels.get(k): - raise NetworkConfigChangedError(local.full_name, 'label "{}"'.format(k)) + log.warn( + 'Network {}: label "{}" has changed. It may need to be' + ' recreated.'.format(local.full_name, k) + ) def build_networks(name, config_data, client): diff --git a/tests/unit/network_test.py b/tests/unit/network_test.py index 4b40ea88436..b27339af89c 100644 --- a/tests/unit/network_test.py +++ b/tests/unit/network_test.py @@ -3,6 +3,7 @@ import pytest +from .. import mock from .. import unittest from compose.network import check_remote_network_config from compose.network import Network @@ -152,7 +153,9 @@ def test_check_remote_network_labels_mismatch(self): 'com.project.touhou.character': 'marisa.kirisame', } } - with pytest.raises(NetworkConfigChangedError) as e: + with mock.patch('compose.network.log') as mock_log: check_remote_network_config(remote, net) - assert 'label "com.project.touhou.character" has changed' in str(e.value) + mock_log.warn.assert_called_once_with(mock.ANY) + _, args, kwargs = mock_log.warn.mock_calls[0] + assert 'label "com.project.touhou.character" has changed' in args[0] From 5fb767505554852ab396a82db3eb17c983089b91 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 23 May 2017 15:53:06 -0700 Subject: [PATCH 2840/4072] Add support for build labels in 2.1 and 2.2 format Add cache_from in 2.2 format Add integration test for build labels Signed-off-by: Joffrey F --- compose/config/config_schema_v2.1.json | 3 ++- compose/config/config_schema_v2.2.json | 4 +++- tests/integration/service_test.py | 15 +++++++++++++++ 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/compose/config/config_schema_v2.1.json b/compose/config/config_schema_v2.1.json index aa59d181ee5..9004000ea71 100644 --- a/compose/config/config_schema_v2.1.json +++ b/compose/config/config_schema_v2.1.json @@ -58,7 +58,8 @@ "properties": { "context": {"type": "string"}, "dockerfile": {"type": "string"}, - "args": {"$ref": "#/definitions/list_or_dict"} + "args": {"$ref": "#/definitions/list_or_dict"}, + "labels": {"$ref": "#/definitions/list_or_dict"} }, "additionalProperties": false } diff --git a/compose/config/config_schema_v2.2.json b/compose/config/config_schema_v2.2.json index a585f2a8c87..e8edb60eda5 100644 --- a/compose/config/config_schema_v2.2.json +++ b/compose/config/config_schema_v2.2.json @@ -58,7 +58,9 @@ "properties": { "context": {"type": "string"}, "dockerfile": {"type": "string"}, - "args": {"$ref": "#/definitions/list_or_dict"} + "args": {"$ref": "#/definitions/list_or_dict"}, + "labels": {"$ref": "#/definitions/list_or_dict"}, + "cache_from": {"$ref": "#/definitions/list_of_strings"} }, "additionalProperties": false } diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index a5b5bda5727..178df13239d 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -666,6 +666,21 @@ def test_build_with_build_args_override(self): assert service.image() assert "build_version=2" in service.image()['ContainerConfig']['Cmd'] + def test_build_with_build_labels(self): + base_dir = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, base_dir) + + with open(os.path.join(base_dir, 'Dockerfile'), 'w') as f: + f.write('FROM busybox\n') + + service = self.create_service('buildlabels', build={ + 'context': text_type(base_dir), + 'labels': {'com.docker.compose.test': 'true'} + }) + service.build() + assert service.image() + assert service.image()['Config']['Labels']['com.docker.compose.test'] == 'true' + def test_start_container_stays_unprivileged(self): service = self.create_service('web') container = create_and_start_container(service).inspect() From 909ef7f4352ee7a107bd6880beaa939a19f385a7 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 23 May 2017 16:30:48 -0700 Subject: [PATCH 2841/4072] Add partial support (docker-compose config and warnings) for v3.3 credential_spec Signed-off-by: Joffrey F --- compose/config/config.py | 42 +++++++++++++++++++++++--------- tests/unit/config/config_test.py | 17 +++++++++++++ 2 files changed, 47 insertions(+), 12 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 8dac4fb3328..44f84ac82de 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -108,6 +108,7 @@ ALLOWED_KEYS = DOCKER_CONFIG_KEYS + [ 'build', 'container_name', + 'credential_spec', 'dockerfile', 'log_driver', 'log_opt', @@ -320,6 +321,27 @@ def find_candidates_in_parent_dirs(filenames, path): return (candidates, path) +def check_swarm_only_config(service_dicts): + warning_template = ( + "Some services ({services}) use the '{key}' key, which will be ignored. " + "Compose does not support '{key}' configuration - use " + "`docker stack deploy` to deploy to a swarm." + ) + + def check_swarm_only_key(service_dicts, key): + services = [s for s in service_dicts if s.get(key)] + if services: + log.warn( + warning_template.format( + services=", ".join(sorted(s['name'] for s in services)), + key=key + ) + ) + + check_swarm_only_key(service_dicts, 'deploy') + check_swarm_only_key(service_dicts, 'credential_spec') + + def load(config_details): """Load the configuration from a working directory and a list of configuration files. Files are loaded in order, and merged on top @@ -349,13 +371,7 @@ 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 " - "`docker stack deploy` to deploy to a swarm." - .format(", ".join(sorted(s['name'] for s in services_using_deploy)))) + check_swarm_only_config(service_dicts) return Config(main_file.version, service_dicts, volumes, networks, secrets) @@ -884,7 +900,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('ulimits', parse_flat_dict) md.merge_mapping('networks', parse_networks) md.merge_mapping('sysctls', parse_sysctls) md.merge_mapping('depends_on', parse_depends_on) @@ -1020,12 +1036,14 @@ def parse_dict_or_list(split_func, type_name, arguments): parse_deploy = functools.partial(parse_dict_or_list, split_kv, 'deploy') -def parse_ulimits(ulimits): - if not ulimits: +def parse_flat_dict(d): + if not d: return {} - if isinstance(ulimits, dict): - return dict(ulimits) + if isinstance(d, dict): + return dict(d) + + raise ConfigurationError("Invalid type: expected mapping") def resolve_env_var(key, val, environment): diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index d8973484d25..d1160c76730 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -2033,6 +2033,23 @@ def test_merge_deploy_override(self): } } + def test_merge_credential_spec(self): + base = { + 'image': 'bb', + 'credential_spec': { + 'file': '/hello-world', + } + } + + override = { + 'credential_spec': { + 'registry': 'revolution.com', + } + } + + actual = config.merge_service_dicts(base, override, V3_3) + assert actual['credential_spec'] == override['credential_spec'] + def test_external_volume_config(self): config_details = build_config_details({ 'version': '2', From e6000051f7f1ac86cc668bcf927dc9e58389f617 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 26 May 2017 14:39:47 -0700 Subject: [PATCH 2842/4072] Bump 1.14.0-rc1 Signed-off-by: Joffrey F --- CHANGELOG.md | 49 +++++++++++++++++++++++++++++++++++++++++++++ compose/__init__.py | 2 +- script/run/run.sh | 2 +- 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d1da62e3486..748cce08a6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,55 @@ Change log ========== +1.14.0 (2017-06-06) +------------------- + +### New features + +#### Compose file version 3.3 + +- Introduced version 3.3 of the `docker-compose.yml` specification. + This version requires to be used with Docker Engine 17.06.0 or above. + Note: the `credential_spec` key only applies to Swarm services and will + be ignored by Compose + +#### Compose file version 2.2 + +- Added the following parameters in service definitions: `cpu_count`, + `cpu_percent`, `cpus` + +#### Compose file version 2.1 + +- Added support for build labels. This feature is also available in the + 2.2 and 3.3 formats. + +#### All formats + +- Added shorthand `-u` for `--user` flag in `docker-compose exec` + +- Differences in labels between the Compose file and remote network + will now print a warning instead of preventing redeployment. + +### Bugfixes + +- Fixed a bug where service's dependencies were being rescaled to their + default scale when running a `docker-compose run` command + +- Fixed a bug where `docker-compose rm` with the `--stop` flag was not + behaving properly when provided with a list of services to remove + +- Fixed a bug where `cache_from` in the build section would be ignored when + using more than one Compose file. + +- Fixed a bug where override files would not be picked up by Compose if they + had the `.yaml` extension + +- Fixed a bug on Windows Engine where networks would be incorrectly flagged + for recreation + +- Fixed a bug where services declaring ports would cause crashes on some + versions of Python 3 + 1.13.0 (2017-05-02) ------------------- diff --git a/compose/__init__.py b/compose/__init__.py index 69307d60e81..44521646020 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.14.0dev' +__version__ = '1.14.0-rc1' diff --git a/script/run/run.sh b/script/run/run.sh index d1e1fabaaca..45063abd5c9 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.13.0" +VERSION="1.14.0-rc1" IMAGE="docker/compose:$VERSION" From bcc4a76ea025209387e0e63c4def4c6427b1c0c5 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 5 Jun 2017 11:51:43 -0700 Subject: [PATCH 2843/4072] Bump docker version in requirements.txt Signed-off-by: Joffrey F --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index f8061af83be..c4545de1e1f 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.2.1 +docker==2.3.0 dockerpty==0.4.1 docopt==0.6.1 enum34==1.0.4; python_version < '3.4' From 36af86b9b2c5a1f00bd0e566a21184e956012074 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 5 Jun 2017 14:33:20 -0700 Subject: [PATCH 2844/4072] Always convert port values in ServicePort to integer Signed-off-by: Joffrey F --- compose/config/types.py | 16 ++++++++++++++++ tests/unit/config/types_test.py | 21 +++++++++++++-------- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/compose/config/types.py b/compose/config/types.py index d853d84f455..85daa70b00b 100644 --- a/compose/config/types.py +++ b/compose/config/types.py @@ -263,6 +263,22 @@ def repr(self): class ServicePort(namedtuple('_ServicePort', 'target published protocol mode external_ip')): + def __new__(cls, target, published, *args, **kwargs): + try: + if target: + target = int(target) + except ValueError: + raise ConfigurationError('Invalid target port: {}'.format(target)) + + try: + if published: + published = int(published) + except ValueError: + raise ConfigurationError('Invalid published port: {}'.format(published)) + + return super(ServicePort, cls).__new__( + cls, target, published, *args, **kwargs + ) @classmethod def parse(cls, spec): diff --git a/tests/unit/config/types_test.py b/tests/unit/config/types_test.py index 83d6270d28b..10b698fe3d5 100644 --- a/tests/unit/config/types_test.py +++ b/tests/unit/config/types_test.py @@ -57,15 +57,15 @@ def test_parse_dict(self): def test_parse_simple_target_port(self): ports = ServicePort.parse(8000) assert len(ports) == 1 - assert ports[0].target == '8000' + assert ports[0].target == 8000 def test_parse_complete_port_definition(self): port_def = '1.1.1.1:3000:3000/udp' ports = ServicePort.parse(port_def) assert len(ports) == 1 assert ports[0].repr() == { - 'target': '3000', - 'published': '3000', + 'target': 3000, + 'published': 3000, 'external_ip': '1.1.1.1', 'protocol': 'udp', } @@ -77,7 +77,7 @@ def test_parse_ext_ip_no_published_port(self): assert len(ports) == 1 assert ports[0].legacy_repr() == port_def + '/tcp' assert ports[0].repr() == { - 'target': '3000', + 'target': 3000, 'external_ip': '1.1.1.1', } @@ -86,14 +86,19 @@ def test_parse_port_range(self): assert len(ports) == 2 reprs = [p.repr() for p in ports] assert { - 'target': '4000', - 'published': '25000' + 'target': 4000, + 'published': 25000 } in reprs assert { - 'target': '4001', - 'published': '25001' + 'target': 4001, + 'published': 25001 } in reprs + def test_parse_invalid_port(self): + port_def = '4000p' + with pytest.raises(ConfigurationError): + ServicePort.parse(port_def) + class TestVolumeSpec(object): From 39c1cb598853598baed918b79028704db5252fc3 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 5 Jun 2017 16:37:20 -0700 Subject: [PATCH 2845/4072] Partial support for service configs Signed-off-by: Joffrey F --- compose/config/config.py | 51 +++++++++++++++++-------------------- compose/config/serialize.py | 26 ++++++++----------- compose/config/types.py | 11 ++++++-- 3 files changed, 43 insertions(+), 45 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 44f84ac82de..fd933d93937 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -211,8 +211,11 @@ def get_networks(self): def get_secrets(self): return {} if self.version < const.COMPOSEFILE_V3_1 else self.config.get('secrets', {}) + def get_configs(self): + return {} if self.version < const.COMPOSEFILE_V3_3 else self.config.get('configs', {}) -class Config(namedtuple('_Config', 'version services volumes networks secrets')): + +class Config(namedtuple('_Config', 'version services volumes networks secrets configs')): """ :param version: configuration version :type version: int @@ -224,6 +227,8 @@ class Config(namedtuple('_Config', 'version services volumes networks secrets')) :type networks: :class:`dict` :param secrets: Dictionary mapping secret names to description dictionaries :type secrets: :class:`dict` + :param configs: Dictionary mapping config names to description dictionaries + :type configs: :class:`dict` """ @@ -340,6 +345,7 @@ def check_swarm_only_key(service_dicts, key): check_swarm_only_key(service_dicts, 'deploy') check_swarm_only_key(service_dicts, 'credential_spec') + check_swarm_only_key(service_dicts, 'configs') def load(config_details): @@ -364,7 +370,12 @@ def load(config_details): networks = load_mapping( config_details.config_files, 'get_networks', 'Network' ) - secrets = load_secrets(config_details.config_files, config_details.working_dir) + secrets = load_mapping( + config_details.config_files, 'get_secrets', 'Secret', config_details.working_dir + ) + configs = load_mapping( + config_details.config_files, 'get_configs', 'Config', config_details.working_dir + ) service_dicts = load_services(config_details, main_file) if main_file.version != V1: @@ -373,10 +384,10 @@ def load(config_details): check_swarm_only_config(service_dicts) - return Config(main_file.version, service_dicts, volumes, networks, secrets) + return Config(main_file.version, service_dicts, volumes, networks, secrets, configs) -def load_mapping(config_files, get_func, entity_type): +def load_mapping(config_files, get_func, entity_type, working_dir=None): mapping = {} for config_file in config_files: @@ -401,6 +412,9 @@ def load_mapping(config_files, get_func, entity_type): if 'labels' in config: config['labels'] = parse_labels(config['labels']) + if 'file' in config: + config['file'] = expand_path(working_dir, config['file']) + return mapping @@ -414,29 +428,6 @@ def validate_external(entity_type, name, config): 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( @@ -815,6 +806,11 @@ def finalize_service(service_config, service_names, version, environment): types.ServiceSecret.parse(s) for s in service_dict['secrets'] ] + if 'configs' in service_dict: + service_dict['configs'] = [ + types.ServiceConfig.parse(c) for c in service_dict['configs'] + ] + normalize_build(service_dict, service_config.working_dir, environment) service_dict['name'] = service_config.name @@ -906,6 +902,7 @@ def merge_service_dicts(base, override, version): md.merge_mapping('depends_on', parse_depends_on) md.merge_sequence('links', ServiceLink.parse) md.merge_sequence('secrets', types.ServiceSecret.parse) + md.merge_sequence('configs', types.ServiceConfig.parse) md.merge_mapping('deploy', parse_deploy) for field in ['volumes', 'devices']: diff --git a/compose/config/serialize.py b/compose/config/serialize.py index ac78b77a2db..beafe02b965 100644 --- a/compose/config/serialize.py +++ b/compose/config/serialize.py @@ -8,7 +8,6 @@ from compose.const import COMPOSEFILE_V1 as V1 from compose.const import COMPOSEFILE_V2_1 as V2_1 from compose.const import COMPOSEFILE_V2_2 as V2_2 -from compose.const import COMPOSEFILE_V3_1 as V3_1 from compose.const import COMPOSEFILE_V3_2 as V3_2 from compose.const import COMPOSEFILE_V3_3 as V3_3 @@ -25,6 +24,7 @@ def serialize_dict_type(dumper, data): yaml.SafeDumper.add_representer(types.VolumeFromSpec, serialize_config_type) yaml.SafeDumper.add_representer(types.VolumeSpec, serialize_config_type) yaml.SafeDumper.add_representer(types.ServiceSecret, serialize_dict_type) +yaml.SafeDumper.add_representer(types.ServiceConfig, serialize_dict_type) yaml.SafeDumper.add_representer(types.ServicePort, serialize_dict_type) @@ -41,21 +41,15 @@ def denormalize_config(config, image_digests=None): service_dict.pop('name'): service_dict for service_dict in denormalized_services } - result['networks'] = config.networks.copy() - for net_name, net_conf in result['networks'].items(): - if 'external_name' in net_conf: - del net_conf['external_name'] - - result['volumes'] = config.volumes.copy() - for vol_name, vol_conf in result['volumes'].items(): - if 'external_name' in vol_conf: - del vol_conf['external_name'] - - if config.version in (V3_1, V3_2, V3_3): - result['secrets'] = config.secrets.copy() - for secret_name, secret_conf in result['secrets'].items(): - if 'external_name' in secret_conf: - del secret_conf['external_name'] + for key in ('networks', 'volumes', 'secrets', 'configs'): + config_dict = getattr(config, key) + if not config_dict: + continue + result[key] = config_dict.copy() + for name, conf in result[key].items(): + if 'external_name' in conf: + del conf['external_name'] + return result diff --git a/compose/config/types.py b/compose/config/types.py index 85daa70b00b..6d3ca3f3b64 100644 --- a/compose/config/types.py +++ b/compose/config/types.py @@ -238,8 +238,7 @@ def merge_field(self): return self.alias -class ServiceSecret(namedtuple('_ServiceSecret', 'source target uid gid mode')): - +class ServiceConfigBase(namedtuple('_ServiceConfigBase', 'source target uid gid mode')): @classmethod def parse(cls, spec): if isinstance(spec, six.string_types): @@ -262,6 +261,14 @@ def repr(self): ) +class ServiceSecret(ServiceConfigBase): + pass + + +class ServiceConfig(ServiceConfigBase): + pass + + class ServicePort(namedtuple('_ServicePort', 'target published protocol mode external_ip')): def __new__(cls, target, published, *args, **kwargs): try: From bd8d77feaef4631f889860c8d86d66521aca4b09 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 5 Jun 2017 16:57:09 -0700 Subject: [PATCH 2846/4072] Add configs tests Signed-off-by: Joffrey F --- tests/unit/bundle_test.py | 4 +- tests/unit/config/config_test.py | 168 ++++++++++++++++++++++++++++++- tests/unit/project_test.py | 12 +++ 3 files changed, 182 insertions(+), 2 deletions(-) diff --git a/tests/unit/bundle_test.py b/tests/unit/bundle_test.py index 21bdb31b0e6..3c6e9ec5373 100644 --- a/tests/unit/bundle_test.py +++ b/tests/unit/bundle_test.py @@ -78,7 +78,9 @@ def test_to_bundle(): services=services, volumes={'special': {}}, networks={'extra': {}}, - secrets={}) + secrets={}, + configs={} + ) with mock.patch('compose.bundle.log.warn', autospec=True) as mock_log: output = bundle.to_bundle(config, image_digests) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index d1160c76730..d92a35c005a 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1982,6 +1982,38 @@ def test_merge_secrets_override(self): actual = config.merge_service_dicts(base, override, V3_1) assert actual['secrets'] == override['secrets'] + def test_merge_different_configs(self): + base = { + 'image': 'busybox', + 'configs': [ + {'source': 'src.txt'} + ] + } + override = {'configs': ['other-src.txt']} + + actual = config.merge_service_dicts(base, override, V3_3) + assert secret_sort(actual['configs']) == secret_sort([ + {'source': 'src.txt'}, + {'source': 'other-src.txt'} + ]) + + def test_merge_configs_override(self): + base = { + 'image': 'busybox', + 'configs': ['src.txt'], + } + override = { + 'configs': [ + { + 'source': 'src.txt', + 'target': 'data.txt', + 'mode': 0o400 + } + ] + } + actual = config.merge_service_dicts(base, override, V3_3) + assert actual['configs'] == override['configs'] + def test_merge_deploy(self): base = { 'image': 'busybox', @@ -2214,6 +2246,91 @@ def test_load_secrets_multi_file(self): ] assert service_sort(service_dicts) == service_sort(expected) + def test_load_configs(self): + base_file = config.ConfigFile( + 'base.yaml', + { + 'version': '3.3', + 'services': { + 'web': { + 'image': 'example/web', + 'configs': [ + 'one', + { + 'source': 'source', + 'target': 'target', + 'uid': '100', + 'gid': '200', + 'mode': 0o777, + }, + ], + }, + }, + 'configs': { + 'one': {'file': 'secret.txt'}, + }, + }) + details = config.ConfigDetails('.', [base_file]) + service_dicts = config.load(details).services + expected = [ + { + 'name': 'web', + 'image': 'example/web', + 'configs': [ + types.ServiceConfig('one', None, None, None, None), + types.ServiceConfig('source', 'target', '100', '200', 0o777), + ], + }, + ] + assert service_sort(service_dicts) == service_sort(expected) + + def test_load_configs_multi_file(self): + base_file = config.ConfigFile( + 'base.yaml', + { + 'version': '3.3', + 'services': { + 'web': { + 'image': 'example/web', + 'configs': ['one'], + }, + }, + 'configs': { + 'one': {'file': 'secret.txt'}, + }, + }) + override_file = config.ConfigFile( + 'base.yaml', + { + 'version': '3.3', + 'services': { + 'web': { + 'configs': [ + { + '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', + 'configs': [ + types.ServiceConfig('one', None, None, None, None), + types.ServiceConfig('source', 'target', '100', '200', 0o777), + ], + }, + ] + assert service_sort(service_dicts) == service_sort(expected) + class NetworkModeTest(unittest.TestCase): @@ -2533,6 +2650,24 @@ def test_interpolation_secrets_section(self): } } + @mock.patch.dict(os.environ) + def test_interpolation_configs_section(self): + os.environ['FOO'] = 'baz.bar' + config_dict = config.load(build_config_details({ + 'version': '3.3', + 'configs': { + 'configdata': { + 'external': {'name': '$FOO'} + } + } + })) + assert config_dict.configs == { + 'configdata': { + 'external': {'name': 'baz.bar'}, + 'external_name': 'baz.bar' + } + } + class VolumeConfigTest(unittest.TestCase): @@ -3964,7 +4099,38 @@ def test_serialize_ports(self): 'image': 'alpine', 'name': 'web' } - ], volumes={}, networks={}, secrets={}) + ], volumes={}, networks={}, secrets={}, configs={}) serialized_config = yaml.load(serialize_config(config_dict)) assert '8080:80/tcp' in serialized_config['services']['web']['ports'] + + def test_serialize_configs(self): + service_dict = { + 'image': 'example/web', + 'configs': [ + {'source': 'one'}, + { + 'source': 'source', + 'target': 'target', + 'uid': '100', + 'gid': '200', + 'mode': 0o777, + } + ] + } + configs_dict = { + 'one': {'file': '/one.txt'}, + 'source': {'file': '/source.pem'}, + 'two': {'external': True}, + } + config_dict = config.load(build_config_details({ + 'version': '3.3', + 'services': {'web': service_dict}, + 'configs': configs_dict + })) + + serialized_config = yaml.load(serialize_config(config_dict)) + serialized_service = serialized_config['services']['web'] + assert secret_sort(serialized_service['configs']) == secret_sort(service_dict['configs']) + assert 'configs' in serialized_config + assert serialized_config['configs']['two'] == configs_dict['two'] diff --git a/tests/unit/project_test.py b/tests/unit/project_test.py index 32d0adfafce..c5366c395ca 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -37,6 +37,7 @@ def test_from_config(self): networks=None, volumes=None, secrets=None, + configs=None, ) project = Project.from_config( name='composetest', @@ -66,6 +67,7 @@ def test_from_config_v2(self): networks=None, volumes=None, secrets=None, + configs=None, ) project = Project.from_config('composetest', config, None) self.assertEqual(len(project.services), 2) @@ -173,6 +175,7 @@ def test_use_volumes_from_container(self): networks=None, volumes=None, secrets=None, + configs=None, ), ) assert project.get_service('test')._get_volumes_from() == [container_id + ":rw"] @@ -206,6 +209,7 @@ def test_use_volumes_from_service_no_container(self): networks=None, volumes=None, secrets=None, + configs=None, ), ) assert project.get_service('test')._get_volumes_from() == [container_name + ":rw"] @@ -232,6 +236,7 @@ def test_use_volumes_from_service_container(self): networks=None, volumes=None, secrets=None, + configs=None, ), ) with mock.patch.object(Service, 'containers') as mock_return: @@ -366,6 +371,7 @@ def test_net_unset(self): networks=None, volumes=None, secrets=None, + configs=None, ), ) service = project.get_service('test') @@ -391,6 +397,7 @@ def test_use_net_from_container(self): networks=None, volumes=None, secrets=None, + configs=None, ), ) service = project.get_service('test') @@ -425,6 +432,7 @@ def test_use_net_from_service(self): networks=None, volumes=None, secrets=None, + configs=None, ), ) @@ -446,6 +454,7 @@ def test_uses_default_network_true(self): networks=None, volumes=None, secrets=None, + configs=None, ), ) @@ -467,6 +476,7 @@ def test_uses_default_network_false(self): networks={'custom': {}}, volumes=None, secrets=None, + configs=None, ), ) @@ -498,6 +508,7 @@ def test_container_without_name(self): networks=None, volumes=None, secrets=None, + configs=None, ), ) self.assertEqual([c.id for c in project.containers()], ['1']) @@ -515,6 +526,7 @@ def test_down_with_no_resources(self): networks={'default': {}}, volumes={'data': {}}, secrets=None, + configs=None, ), ) self.mock_client.remove_network.side_effect = NotFound(None, None, 'oops') From 0fa716352282cd022fc23f98e6673033ffbc59e4 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 5 Jun 2017 16:57:24 -0700 Subject: [PATCH 2847/4072] Interpolate configs values Signed-off-by: Joffrey F --- compose/config/config.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/compose/config/config.py b/compose/config/config.py index fd933d93937..b8bffc66040 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -507,12 +507,20 @@ def process_config_file(config_file, environment, service_name=None): config_file.get_networks(), 'network', environment) - if config_file.version in (const.COMPOSEFILE_V3_1, const.COMPOSEFILE_V3_2): + if config_file.version in (const.COMPOSEFILE_V3_1, const.COMPOSEFILE_V3_2, + const.COMPOSEFILE_V3_3): processed_config['secrets'] = interpolate_config_section( config_file, config_file.get_secrets(), 'secrets', environment) + if config_file.version in (const.COMPOSEFILE_V3_3): + processed_config['configs'] = interpolate_config_section( + config_file, + config_file.get_configs(), + 'configs', + environment + ) else: processed_config = services From 8512b33e24657efbdb05869967f1bb593d70f44b Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 5 Jun 2017 19:34:53 -0700 Subject: [PATCH 2848/4072] Remedy test failures Signed-off-by: Joffrey F --- tests/acceptance/cli_test.py | 6 ------ tests/integration/project_test.py | 4 +++- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 89f4f288b03..dd95fb5450f 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -258,8 +258,6 @@ def test_config_restart(self): 'restart': '' }, }, - 'networks': {}, - 'volumes': {}, } def test_config_external_network(self): @@ -311,8 +309,6 @@ def test_config_v1(self): 'network_mode': 'service:net', }, }, - 'networks': {}, - 'volumes': {}, } @v3_only() @@ -322,8 +318,6 @@ def test_config_v3(self): assert yaml.load(result.stdout) == { 'version': '3.2', - 'networks': {}, - 'secrets': {}, 'volumes': { 'foobar': { 'labels': { diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 69f06b75c2a..6c5f719ed37 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -40,7 +40,9 @@ def build_config(**kwargs): services=kwargs.get('services'), volumes=kwargs.get('volumes'), networks=kwargs.get('networks'), - secrets=kwargs.get('secrets')) + secrets=kwargs.get('secrets'), + configs=kwargs.get('configs'), + ) class ProjectTest(DockerClientTestCase): From ff720ba6b29a6bfb5b081233a7b5d5a0e17b9646 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 5 Jun 2017 11:51:43 -0700 Subject: [PATCH 2849/4072] Bump docker version in requirements.txt Signed-off-by: Joffrey F --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index f8061af83be..c4545de1e1f 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.2.1 +docker==2.3.0 dockerpty==0.4.1 docopt==0.6.1 enum34==1.0.4; python_version < '3.4' From bfc7ac4995851097503d78d5af80610adcad3aa6 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 5 Jun 2017 14:33:20 -0700 Subject: [PATCH 2850/4072] Always convert port values in ServicePort to integer Signed-off-by: Joffrey F --- compose/config/types.py | 16 ++++++++++++++++ tests/unit/config/types_test.py | 21 +++++++++++++-------- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/compose/config/types.py b/compose/config/types.py index d853d84f455..85daa70b00b 100644 --- a/compose/config/types.py +++ b/compose/config/types.py @@ -263,6 +263,22 @@ def repr(self): class ServicePort(namedtuple('_ServicePort', 'target published protocol mode external_ip')): + def __new__(cls, target, published, *args, **kwargs): + try: + if target: + target = int(target) + except ValueError: + raise ConfigurationError('Invalid target port: {}'.format(target)) + + try: + if published: + published = int(published) + except ValueError: + raise ConfigurationError('Invalid published port: {}'.format(published)) + + return super(ServicePort, cls).__new__( + cls, target, published, *args, **kwargs + ) @classmethod def parse(cls, spec): diff --git a/tests/unit/config/types_test.py b/tests/unit/config/types_test.py index 83d6270d28b..10b698fe3d5 100644 --- a/tests/unit/config/types_test.py +++ b/tests/unit/config/types_test.py @@ -57,15 +57,15 @@ def test_parse_dict(self): def test_parse_simple_target_port(self): ports = ServicePort.parse(8000) assert len(ports) == 1 - assert ports[0].target == '8000' + assert ports[0].target == 8000 def test_parse_complete_port_definition(self): port_def = '1.1.1.1:3000:3000/udp' ports = ServicePort.parse(port_def) assert len(ports) == 1 assert ports[0].repr() == { - 'target': '3000', - 'published': '3000', + 'target': 3000, + 'published': 3000, 'external_ip': '1.1.1.1', 'protocol': 'udp', } @@ -77,7 +77,7 @@ def test_parse_ext_ip_no_published_port(self): assert len(ports) == 1 assert ports[0].legacy_repr() == port_def + '/tcp' assert ports[0].repr() == { - 'target': '3000', + 'target': 3000, 'external_ip': '1.1.1.1', } @@ -86,14 +86,19 @@ def test_parse_port_range(self): assert len(ports) == 2 reprs = [p.repr() for p in ports] assert { - 'target': '4000', - 'published': '25000' + 'target': 4000, + 'published': 25000 } in reprs assert { - 'target': '4001', - 'published': '25001' + 'target': 4001, + 'published': 25001 } in reprs + def test_parse_invalid_port(self): + port_def = '4000p' + with pytest.raises(ConfigurationError): + ServicePort.parse(port_def) + class TestVolumeSpec(object): From 70b2e64c1b126d59c0b6e19333a67df597936e11 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 5 Jun 2017 16:37:20 -0700 Subject: [PATCH 2851/4072] Partial support for service configs Signed-off-by: Joffrey F --- compose/config/config.py | 51 +++++++++++++++++-------------------- compose/config/serialize.py | 26 ++++++++----------- compose/config/types.py | 11 ++++++-- 3 files changed, 43 insertions(+), 45 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 44f84ac82de..fd933d93937 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -211,8 +211,11 @@ def get_networks(self): def get_secrets(self): return {} if self.version < const.COMPOSEFILE_V3_1 else self.config.get('secrets', {}) + def get_configs(self): + return {} if self.version < const.COMPOSEFILE_V3_3 else self.config.get('configs', {}) -class Config(namedtuple('_Config', 'version services volumes networks secrets')): + +class Config(namedtuple('_Config', 'version services volumes networks secrets configs')): """ :param version: configuration version :type version: int @@ -224,6 +227,8 @@ class Config(namedtuple('_Config', 'version services volumes networks secrets')) :type networks: :class:`dict` :param secrets: Dictionary mapping secret names to description dictionaries :type secrets: :class:`dict` + :param configs: Dictionary mapping config names to description dictionaries + :type configs: :class:`dict` """ @@ -340,6 +345,7 @@ def check_swarm_only_key(service_dicts, key): check_swarm_only_key(service_dicts, 'deploy') check_swarm_only_key(service_dicts, 'credential_spec') + check_swarm_only_key(service_dicts, 'configs') def load(config_details): @@ -364,7 +370,12 @@ def load(config_details): networks = load_mapping( config_details.config_files, 'get_networks', 'Network' ) - secrets = load_secrets(config_details.config_files, config_details.working_dir) + secrets = load_mapping( + config_details.config_files, 'get_secrets', 'Secret', config_details.working_dir + ) + configs = load_mapping( + config_details.config_files, 'get_configs', 'Config', config_details.working_dir + ) service_dicts = load_services(config_details, main_file) if main_file.version != V1: @@ -373,10 +384,10 @@ def load(config_details): check_swarm_only_config(service_dicts) - return Config(main_file.version, service_dicts, volumes, networks, secrets) + return Config(main_file.version, service_dicts, volumes, networks, secrets, configs) -def load_mapping(config_files, get_func, entity_type): +def load_mapping(config_files, get_func, entity_type, working_dir=None): mapping = {} for config_file in config_files: @@ -401,6 +412,9 @@ def load_mapping(config_files, get_func, entity_type): if 'labels' in config: config['labels'] = parse_labels(config['labels']) + if 'file' in config: + config['file'] = expand_path(working_dir, config['file']) + return mapping @@ -414,29 +428,6 @@ def validate_external(entity_type, name, config): 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( @@ -815,6 +806,11 @@ def finalize_service(service_config, service_names, version, environment): types.ServiceSecret.parse(s) for s in service_dict['secrets'] ] + if 'configs' in service_dict: + service_dict['configs'] = [ + types.ServiceConfig.parse(c) for c in service_dict['configs'] + ] + normalize_build(service_dict, service_config.working_dir, environment) service_dict['name'] = service_config.name @@ -906,6 +902,7 @@ def merge_service_dicts(base, override, version): md.merge_mapping('depends_on', parse_depends_on) md.merge_sequence('links', ServiceLink.parse) md.merge_sequence('secrets', types.ServiceSecret.parse) + md.merge_sequence('configs', types.ServiceConfig.parse) md.merge_mapping('deploy', parse_deploy) for field in ['volumes', 'devices']: diff --git a/compose/config/serialize.py b/compose/config/serialize.py index ac78b77a2db..beafe02b965 100644 --- a/compose/config/serialize.py +++ b/compose/config/serialize.py @@ -8,7 +8,6 @@ from compose.const import COMPOSEFILE_V1 as V1 from compose.const import COMPOSEFILE_V2_1 as V2_1 from compose.const import COMPOSEFILE_V2_2 as V2_2 -from compose.const import COMPOSEFILE_V3_1 as V3_1 from compose.const import COMPOSEFILE_V3_2 as V3_2 from compose.const import COMPOSEFILE_V3_3 as V3_3 @@ -25,6 +24,7 @@ def serialize_dict_type(dumper, data): yaml.SafeDumper.add_representer(types.VolumeFromSpec, serialize_config_type) yaml.SafeDumper.add_representer(types.VolumeSpec, serialize_config_type) yaml.SafeDumper.add_representer(types.ServiceSecret, serialize_dict_type) +yaml.SafeDumper.add_representer(types.ServiceConfig, serialize_dict_type) yaml.SafeDumper.add_representer(types.ServicePort, serialize_dict_type) @@ -41,21 +41,15 @@ def denormalize_config(config, image_digests=None): service_dict.pop('name'): service_dict for service_dict in denormalized_services } - result['networks'] = config.networks.copy() - for net_name, net_conf in result['networks'].items(): - if 'external_name' in net_conf: - del net_conf['external_name'] - - result['volumes'] = config.volumes.copy() - for vol_name, vol_conf in result['volumes'].items(): - if 'external_name' in vol_conf: - del vol_conf['external_name'] - - if config.version in (V3_1, V3_2, V3_3): - result['secrets'] = config.secrets.copy() - for secret_name, secret_conf in result['secrets'].items(): - if 'external_name' in secret_conf: - del secret_conf['external_name'] + for key in ('networks', 'volumes', 'secrets', 'configs'): + config_dict = getattr(config, key) + if not config_dict: + continue + result[key] = config_dict.copy() + for name, conf in result[key].items(): + if 'external_name' in conf: + del conf['external_name'] + return result diff --git a/compose/config/types.py b/compose/config/types.py index 85daa70b00b..6d3ca3f3b64 100644 --- a/compose/config/types.py +++ b/compose/config/types.py @@ -238,8 +238,7 @@ def merge_field(self): return self.alias -class ServiceSecret(namedtuple('_ServiceSecret', 'source target uid gid mode')): - +class ServiceConfigBase(namedtuple('_ServiceConfigBase', 'source target uid gid mode')): @classmethod def parse(cls, spec): if isinstance(spec, six.string_types): @@ -262,6 +261,14 @@ def repr(self): ) +class ServiceSecret(ServiceConfigBase): + pass + + +class ServiceConfig(ServiceConfigBase): + pass + + class ServicePort(namedtuple('_ServicePort', 'target published protocol mode external_ip')): def __new__(cls, target, published, *args, **kwargs): try: From bf3b62e2ff72e47a7c39d882051c0b435c72852f Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 5 Jun 2017 16:57:09 -0700 Subject: [PATCH 2852/4072] Add configs tests Signed-off-by: Joffrey F --- tests/unit/bundle_test.py | 4 +- tests/unit/config/config_test.py | 168 ++++++++++++++++++++++++++++++- tests/unit/project_test.py | 12 +++ 3 files changed, 182 insertions(+), 2 deletions(-) diff --git a/tests/unit/bundle_test.py b/tests/unit/bundle_test.py index 21bdb31b0e6..3c6e9ec5373 100644 --- a/tests/unit/bundle_test.py +++ b/tests/unit/bundle_test.py @@ -78,7 +78,9 @@ def test_to_bundle(): services=services, volumes={'special': {}}, networks={'extra': {}}, - secrets={}) + secrets={}, + configs={} + ) with mock.patch('compose.bundle.log.warn', autospec=True) as mock_log: output = bundle.to_bundle(config, image_digests) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index d1160c76730..d92a35c005a 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1982,6 +1982,38 @@ def test_merge_secrets_override(self): actual = config.merge_service_dicts(base, override, V3_1) assert actual['secrets'] == override['secrets'] + def test_merge_different_configs(self): + base = { + 'image': 'busybox', + 'configs': [ + {'source': 'src.txt'} + ] + } + override = {'configs': ['other-src.txt']} + + actual = config.merge_service_dicts(base, override, V3_3) + assert secret_sort(actual['configs']) == secret_sort([ + {'source': 'src.txt'}, + {'source': 'other-src.txt'} + ]) + + def test_merge_configs_override(self): + base = { + 'image': 'busybox', + 'configs': ['src.txt'], + } + override = { + 'configs': [ + { + 'source': 'src.txt', + 'target': 'data.txt', + 'mode': 0o400 + } + ] + } + actual = config.merge_service_dicts(base, override, V3_3) + assert actual['configs'] == override['configs'] + def test_merge_deploy(self): base = { 'image': 'busybox', @@ -2214,6 +2246,91 @@ def test_load_secrets_multi_file(self): ] assert service_sort(service_dicts) == service_sort(expected) + def test_load_configs(self): + base_file = config.ConfigFile( + 'base.yaml', + { + 'version': '3.3', + 'services': { + 'web': { + 'image': 'example/web', + 'configs': [ + 'one', + { + 'source': 'source', + 'target': 'target', + 'uid': '100', + 'gid': '200', + 'mode': 0o777, + }, + ], + }, + }, + 'configs': { + 'one': {'file': 'secret.txt'}, + }, + }) + details = config.ConfigDetails('.', [base_file]) + service_dicts = config.load(details).services + expected = [ + { + 'name': 'web', + 'image': 'example/web', + 'configs': [ + types.ServiceConfig('one', None, None, None, None), + types.ServiceConfig('source', 'target', '100', '200', 0o777), + ], + }, + ] + assert service_sort(service_dicts) == service_sort(expected) + + def test_load_configs_multi_file(self): + base_file = config.ConfigFile( + 'base.yaml', + { + 'version': '3.3', + 'services': { + 'web': { + 'image': 'example/web', + 'configs': ['one'], + }, + }, + 'configs': { + 'one': {'file': 'secret.txt'}, + }, + }) + override_file = config.ConfigFile( + 'base.yaml', + { + 'version': '3.3', + 'services': { + 'web': { + 'configs': [ + { + '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', + 'configs': [ + types.ServiceConfig('one', None, None, None, None), + types.ServiceConfig('source', 'target', '100', '200', 0o777), + ], + }, + ] + assert service_sort(service_dicts) == service_sort(expected) + class NetworkModeTest(unittest.TestCase): @@ -2533,6 +2650,24 @@ def test_interpolation_secrets_section(self): } } + @mock.patch.dict(os.environ) + def test_interpolation_configs_section(self): + os.environ['FOO'] = 'baz.bar' + config_dict = config.load(build_config_details({ + 'version': '3.3', + 'configs': { + 'configdata': { + 'external': {'name': '$FOO'} + } + } + })) + assert config_dict.configs == { + 'configdata': { + 'external': {'name': 'baz.bar'}, + 'external_name': 'baz.bar' + } + } + class VolumeConfigTest(unittest.TestCase): @@ -3964,7 +4099,38 @@ def test_serialize_ports(self): 'image': 'alpine', 'name': 'web' } - ], volumes={}, networks={}, secrets={}) + ], volumes={}, networks={}, secrets={}, configs={}) serialized_config = yaml.load(serialize_config(config_dict)) assert '8080:80/tcp' in serialized_config['services']['web']['ports'] + + def test_serialize_configs(self): + service_dict = { + 'image': 'example/web', + 'configs': [ + {'source': 'one'}, + { + 'source': 'source', + 'target': 'target', + 'uid': '100', + 'gid': '200', + 'mode': 0o777, + } + ] + } + configs_dict = { + 'one': {'file': '/one.txt'}, + 'source': {'file': '/source.pem'}, + 'two': {'external': True}, + } + config_dict = config.load(build_config_details({ + 'version': '3.3', + 'services': {'web': service_dict}, + 'configs': configs_dict + })) + + serialized_config = yaml.load(serialize_config(config_dict)) + serialized_service = serialized_config['services']['web'] + assert secret_sort(serialized_service['configs']) == secret_sort(service_dict['configs']) + assert 'configs' in serialized_config + assert serialized_config['configs']['two'] == configs_dict['two'] diff --git a/tests/unit/project_test.py b/tests/unit/project_test.py index 32d0adfafce..c5366c395ca 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -37,6 +37,7 @@ def test_from_config(self): networks=None, volumes=None, secrets=None, + configs=None, ) project = Project.from_config( name='composetest', @@ -66,6 +67,7 @@ def test_from_config_v2(self): networks=None, volumes=None, secrets=None, + configs=None, ) project = Project.from_config('composetest', config, None) self.assertEqual(len(project.services), 2) @@ -173,6 +175,7 @@ def test_use_volumes_from_container(self): networks=None, volumes=None, secrets=None, + configs=None, ), ) assert project.get_service('test')._get_volumes_from() == [container_id + ":rw"] @@ -206,6 +209,7 @@ def test_use_volumes_from_service_no_container(self): networks=None, volumes=None, secrets=None, + configs=None, ), ) assert project.get_service('test')._get_volumes_from() == [container_name + ":rw"] @@ -232,6 +236,7 @@ def test_use_volumes_from_service_container(self): networks=None, volumes=None, secrets=None, + configs=None, ), ) with mock.patch.object(Service, 'containers') as mock_return: @@ -366,6 +371,7 @@ def test_net_unset(self): networks=None, volumes=None, secrets=None, + configs=None, ), ) service = project.get_service('test') @@ -391,6 +397,7 @@ def test_use_net_from_container(self): networks=None, volumes=None, secrets=None, + configs=None, ), ) service = project.get_service('test') @@ -425,6 +432,7 @@ def test_use_net_from_service(self): networks=None, volumes=None, secrets=None, + configs=None, ), ) @@ -446,6 +454,7 @@ def test_uses_default_network_true(self): networks=None, volumes=None, secrets=None, + configs=None, ), ) @@ -467,6 +476,7 @@ def test_uses_default_network_false(self): networks={'custom': {}}, volumes=None, secrets=None, + configs=None, ), ) @@ -498,6 +508,7 @@ def test_container_without_name(self): networks=None, volumes=None, secrets=None, + configs=None, ), ) self.assertEqual([c.id for c in project.containers()], ['1']) @@ -515,6 +526,7 @@ def test_down_with_no_resources(self): networks={'default': {}}, volumes={'data': {}}, secrets=None, + configs=None, ), ) self.mock_client.remove_network.side_effect = NotFound(None, None, 'oops') From e7b74804623b505f0221598e7c5ca8e2f858d406 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 5 Jun 2017 16:57:24 -0700 Subject: [PATCH 2853/4072] Interpolate configs values Signed-off-by: Joffrey F --- compose/config/config.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/compose/config/config.py b/compose/config/config.py index fd933d93937..b8bffc66040 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -507,12 +507,20 @@ def process_config_file(config_file, environment, service_name=None): config_file.get_networks(), 'network', environment) - if config_file.version in (const.COMPOSEFILE_V3_1, const.COMPOSEFILE_V3_2): + if config_file.version in (const.COMPOSEFILE_V3_1, const.COMPOSEFILE_V3_2, + const.COMPOSEFILE_V3_3): processed_config['secrets'] = interpolate_config_section( config_file, config_file.get_secrets(), 'secrets', environment) + if config_file.version in (const.COMPOSEFILE_V3_3): + processed_config['configs'] = interpolate_config_section( + config_file, + config_file.get_configs(), + 'configs', + environment + ) else: processed_config = services From a85dddf83d17985cf14c1a9a5f1316cb45805ae3 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 5 Jun 2017 19:34:53 -0700 Subject: [PATCH 2854/4072] Remedy test failures Signed-off-by: Joffrey F --- tests/acceptance/cli_test.py | 6 ------ tests/integration/project_test.py | 4 +++- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 89f4f288b03..dd95fb5450f 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -258,8 +258,6 @@ def test_config_restart(self): 'restart': '' }, }, - 'networks': {}, - 'volumes': {}, } def test_config_external_network(self): @@ -311,8 +309,6 @@ def test_config_v1(self): 'network_mode': 'service:net', }, }, - 'networks': {}, - 'volumes': {}, } @v3_only() @@ -322,8 +318,6 @@ def test_config_v3(self): assert yaml.load(result.stdout) == { 'version': '3.2', - 'networks': {}, - 'secrets': {}, 'volumes': { 'foobar': { 'labels': { diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 69f06b75c2a..6c5f719ed37 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -40,7 +40,9 @@ def build_config(**kwargs): services=kwargs.get('services'), volumes=kwargs.get('volumes'), networks=kwargs.get('networks'), - secrets=kwargs.get('secrets')) + secrets=kwargs.get('secrets'), + configs=kwargs.get('configs'), + ) class ProjectTest(DockerClientTestCase): From cfe152f907bb9b2c1ad430a5bf11ed4b8b9bdf4e Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 6 Jun 2017 12:36:26 -0700 Subject: [PATCH 2855/4072] Bump 1.14.0-rc2 Signed-off-by: Joffrey F --- CHANGELOG.md | 7 +++++-- compose/__init__.py | 2 +- script/run/run.sh | 2 +- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 748cce08a6f..02e439e4f5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,8 +10,8 @@ Change log - Introduced version 3.3 of the `docker-compose.yml` specification. This version requires to be used with Docker Engine 17.06.0 or above. - Note: the `credential_spec` key only applies to Swarm services and will - be ignored by Compose + Note: the `credential_spec` and `configs` keys only apply to Swarm services + and will be ignored by Compose #### Compose file version 2.2 @@ -50,6 +50,9 @@ Change log - Fixed a bug where services declaring ports would cause crashes on some versions of Python 3 +- Fixed a bug where the output of `docker-compose config` would sometimes + contain invalid port definitions + 1.13.0 (2017-05-02) ------------------- diff --git a/compose/__init__.py b/compose/__init__.py index 44521646020..9bbae98d5ff 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.14.0-rc1' +__version__ = '1.14.0-rc2' diff --git a/script/run/run.sh b/script/run/run.sh index 45063abd5c9..ef2f63d8fff 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.14.0-rc1" +VERSION="1.14.0-rc2" IMAGE="docker/compose:$VERSION" From e8e2eb6e59f9351919eec9903d6ed5dd43c27826 Mon Sep 17 00:00:00 2001 From: Stefan Pietsch Date: Tue, 30 May 2017 23:54:01 +0200 Subject: [PATCH 2856/4072] check hash sums of downloaded files Signed-off-by: Stefan Pietsch --- Dockerfile | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index a03e151063b..154d515108e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,34 +19,47 @@ RUN set -ex; \ RUN curl https://get.docker.com/builds/Linux/x86_64/docker-1.8.3 \ -o /usr/local/bin/docker && \ + SHA256=f024bc65c45a3778cf07213d26016075e8172de8f6e4b5702bedde06c241650f; \ + echo "${SHA256} /usr/local/bin/docker" | sha256sum -c - && \ chmod +x /usr/local/bin/docker # Build Python 2.7.13 from source RUN set -ex; \ - curl -L https://www.python.org/ftp/python/2.7.13/Python-2.7.13.tgz | tar -xz; \ + curl -LO https://www.python.org/ftp/python/2.7.13/Python-2.7.13.tgz && \ + SHA256=a4f05a0720ce0fd92626f0278b6b433eee9a6173ddf2bced7957dfb599a5ece1; \ + echo "${SHA256} Python-2.7.13.tgz" | sha256sum -c - && \ + tar -xzf Python-2.7.13.tgz; \ cd Python-2.7.13; \ ./configure --enable-shared; \ make; \ make install; \ cd ..; \ - rm -rf /Python-2.7.13 + rm -rf /Python-2.7.13; \ + rm Python-2.7.13.tgz # Build python 3.4 from source RUN set -ex; \ - curl -L https://www.python.org/ftp/python/3.4.6/Python-3.4.6.tgz | tar -xz; \ + curl -LO https://www.python.org/ftp/python/3.4.6/Python-3.4.6.tgz && \ + SHA256=fe59daced99549d1d452727c050ae486169e9716a890cffb0d468b376d916b48; \ + echo "${SHA256} Python-3.4.6.tgz" | sha256sum -c - && \ + tar -xzf Python-3.4.6.tgz; \ cd Python-3.4.6; \ ./configure --enable-shared; \ make; \ make install; \ cd ..; \ - rm -rf /Python-3.4.6 + rm -rf /Python-3.4.6; \ + rm Python-3.4.6.tgz # Make libpython findable ENV LD_LIBRARY_PATH /usr/local/lib # Install pip RUN set -ex; \ - curl -L https://bootstrap.pypa.io/get-pip.py | python + curl -LO https://bootstrap.pypa.io/get-pip.py && \ + SHA256=19dae841a150c86e2a09d475b5eb0602861f2a5b7761ec268049a662dbd2bd0c; \ + echo "${SHA256} get-pip.py" | sha256sum -c - && \ + python get-pip.py # Python3 requires a valid locale RUN echo "en_US.UTF-8 UTF-8" > /etc/locale.gen && locale-gen From 821bd54663d2dbdcbc2ce4a239b6ac1a987bd4cb Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 5 Jun 2017 19:26:37 -0700 Subject: [PATCH 2857/4072] Take editions into account when selecting test engine versions Get candidates from moby/moby and docker/docker-ce repos Signed-off-by: Joffrey F --- script/test/all | 2 +- script/test/versions.py | 30 +++++++++++++++++++++--------- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/script/test/all b/script/test/all index 7151a75e1e3..0c6ea606579 100755 --- a/script/test/all +++ 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/test/versions.py docker/docker" + /code/script/test/versions.py docker/docker-ce,moby/moby" if [ "$DOCKER_VERSIONS" == "" ]; then DOCKER_VERSIONS="$($get_versions default)" diff --git a/script/test/versions.py b/script/test/versions.py index 97383ad99fb..46872ed9a6d 100755 --- a/script/test/versions.py +++ b/script/test/versions.py @@ -37,14 +37,22 @@ GITHUB_API = 'https://api.github.com/repos' -class Version(namedtuple('_Version', 'major minor patch rc')): +class Version(namedtuple('_Version', 'major minor patch rc edition')): @classmethod def parse(cls, version): + edition = None version = version.lstrip('v') version, _, rc = version.partition('-') + if rc: + if 'rc' not in rc: + edition = rc + rc = None + elif '-' in rc: + edition, rc = rc.split('-') + major, minor, patch = version.split('.', 3) - return cls(major, minor, patch, rc) + return cls(major, minor, patch, rc, edition) @property def major_minor(self): @@ -61,7 +69,8 @@ def order(self): def __str__(self): rc = '-{}'.format(self.rc) if self.rc else '' - return '.'.join(map(str, self[:3])) + rc + edition = '-{}'.format(self.edition) if self.edition else '' + return '.'.join(map(str, self[:3])) + edition + rc def group_versions(versions): @@ -94,6 +103,7 @@ def get_latest_versions(versions, num=1): group. """ versions = group_versions(versions) + num = min(len(versions), num) return [versions[index][0] for index in range(num)] @@ -112,16 +122,18 @@ def get_versions(tags): print("Skipping invalid tag: {name}".format(**tag), file=sys.stderr) -def get_github_releases(project): +def get_github_releases(projects): """Query the Github API for a list of version tags and return them in sorted order. See https://developer.github.com/v3/repos/#list-tags """ - url = '{}/{}/tags'.format(GITHUB_API, project) - response = requests.get(url) - response.raise_for_status() - versions = get_versions(response.json()) + versions = [] + for project in projects: + url = '{}/{}/tags'.format(GITHUB_API, project) + response = requests.get(url) + response.raise_for_status() + versions.extend(get_versions(response.json())) return sorted(versions, reverse=True, key=operator.attrgetter('order')) @@ -136,7 +148,7 @@ def parse_args(argv): def main(argv=None): args = parse_args(argv) - versions = get_github_releases(args.project) + versions = get_github_releases(args.project.split(',')) if args.command == 'recent': print(' '.join(map(str, get_latest_versions(versions, args.num)))) From 0bcfc721f8e4b3a6d12dfa91b72137af86dbd97e Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 6 Jun 2017 16:13:34 -0700 Subject: [PATCH 2858/4072] s/docker daemon/dockerd/ Signed-off-by: Joffrey F --- script/test/all | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/test/all b/script/test/all index 0c6ea606579..1200c496e27 100755 --- a/script/test/all +++ b/script/test/all @@ -48,7 +48,7 @@ for version in $DOCKER_VERSIONS; do --privileged \ --volume="/var/lib/docker" \ "$repo:$version" \ - docker daemon -H tcp://0.0.0.0:2375 $DOCKER_DAEMON_ARGS \ + dockerd -H tcp://0.0.0.0:2375 $DOCKER_DAEMON_ARGS \ 2>&1 | tail -n 10 docker run \ From f3c1c8d15860ecd24635f06550897092e5fd9db1 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 9 Jun 2017 16:58:24 -0700 Subject: [PATCH 2859/4072] ServicePort merge_field should account for external IP and protocol Signed-off-by: Joffrey F --- compose/config/types.py | 2 +- tests/unit/config/config_test.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/compose/config/types.py b/compose/config/types.py index 6d3ca3f3b64..4509bfe67cb 100644 --- a/compose/config/types.py +++ b/compose/config/types.py @@ -325,7 +325,7 @@ def parse(cls, spec): @property def merge_field(self): - return (self.target, self.published) + return (self.target, self.published, self.external_ip, self.protocol) def repr(self): return dict( diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index d92a35c005a..87bdd8bca9f 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1865,7 +1865,7 @@ def test_merge_mixed_ports(self): { 'target': '1245', 'published': '1245', - 'protocol': 'tcp', + 'protocol': 'udp', } ] } From d527f24ff05e1c62bc635b278f0c0928ce30dc6f Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 9 Jun 2017 16:59:09 -0700 Subject: [PATCH 2860/4072] Fix `ps` output to show all ports Signed-off-by: Joffrey F --- compose/container.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/compose/container.py b/compose/container.py index bda4e659fb2..4bc7f54f9de 100644 --- a/compose/container.py +++ b/compose/container.py @@ -96,12 +96,16 @@ def ports(self): def human_readable_ports(self): def format_port(private, public): if not public: - return private - return '{HostIp}:{HostPort}->{private}'.format( - private=private, **public[0]) - - return ', '.join(format_port(*item) - for item in sorted(six.iteritems(self.ports))) + return [private] + return [ + '{HostIp}:{HostPort}->{private}'.format(private=private, **pub) + for pub in public + ] + + return ', '.join( + ','.join(format_port(*item)) + for item in sorted(six.iteritems(self.ports)) + ) @property def labels(self): From e9b6cc23fcf01d4768c7e082b7bc91b43ff84e7e Mon Sep 17 00:00:00 2001 From: Joel Barciauskas Date: Wed, 12 Apr 2017 17:45:09 -0400 Subject: [PATCH 2861/4072] Add --quiet parameter to docker-compose pull, using existing silent flag Signed-off-by: Joel Barciauskas --- compose/cli/main.py | 4 +++- compose/project.py | 6 +++--- tests/acceptance/cli_test.py | 4 ++++ 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index cfca0f94902..20f3b55b465 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -634,11 +634,13 @@ def pull(self, options): Options: --ignore-pull-failures Pull what it can and ignores images with pull failures. --parallel Pull multiple images in parallel. + --quiet Pull without printing progress information """ self.project.pull( service_names=options['SERVICE'], ignore_pull_failures=options.get('--ignore-pull-failures'), - parallel_pull=options.get('--parallel') + parallel_pull=options.get('--parallel'), + silent=options.get('--quiet'), ) def push(self, options): diff --git a/compose/project.py b/compose/project.py index b282f718de5..3ad971488f0 100644 --- a/compose/project.py +++ b/compose/project.py @@ -462,12 +462,12 @@ def _get_convergence_plans(self, services, strategy): return plans - def pull(self, service_names=None, ignore_pull_failures=False, parallel_pull=False): + def pull(self, service_names=None, ignore_pull_failures=False, parallel_pull=False, silent=False): services = self.get_services(service_names, include_deps=False) if parallel_pull: def pull_service(service): - service.pull(ignore_pull_failures, True) + service.pull(ignore_pull_failures, True, silent=silent) parallel.parallel_execute( services, @@ -477,7 +477,7 @@ def pull_service(service): limit=5) else: for service in services: - service.pull(ignore_pull_failures) + service.pull(ignore_pull_failures, silent=silent) def push(self, service_names=None, ignore_push_failures=False): for service in self.get_services(service_names, include_deps=False): diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index dd95fb5450f..9a1f5364b72 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -431,6 +431,10 @@ def test_pull_with_ignore_pull_failures(self): assert ('repository nonexisting-image not found' in result.stderr or 'image library/nonexisting-image:latest not found' in result.stderr) + def test_pull_with_quiet(self): + assert self.dispatch(['pull', '--quiet']).stderr == '' + assert self.dispatch(['pull', '--quiet']).stdout == '' + def test_build_plain(self): self.base_dir = 'tests/fixtures/simple-dockerfile' self.dispatch(['build', 'simple']) From 7a4c328c41a7aff7e4894bb6013d80cf7051ebd0 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 22 May 2017 14:58:51 -0700 Subject: [PATCH 2862/4072] Rewriting tests to be UCP/Swarm compatible - Event may contain more information in some cases. Don't assume order or format - Don't assume ports are always exposed on 0.0.0.0 by default - Absence of HostConfig in a create payload sometimes causes an error at the engine level - In Swarm, volume names are prefixed by "/" - When testing against Swarm, the default network driver is overlay - Ensure custom test networks are always attachable - Handle Swarm network names - Some params moved to host config in recent (1.21+) version - Conditional test skips for Swarm environments Signed-off-by: Joffrey F --- compose/service.py | 6 + tests/acceptance/cli_test.py | 147 ++++++++++-------- .../docker-compose.override.yml | 7 +- .../override-files/docker-compose.yml | 10 +- tests/fixtures/override-files/extra.yml | 9 +- tests/helpers.py | 35 +++++ tests/integration/project_test.py | 134 +++++++++------- tests/integration/service_test.py | 90 +++++++---- tests/integration/state_test.py | 2 +- tests/integration/testcases.py | 15 +- tests/integration/volume_test.py | 21 ++- 11 files changed, 316 insertions(+), 160 deletions(-) diff --git a/compose/service.py b/compose/service.py index dcbbe251ed0..03c41ce6758 100644 --- a/compose/service.py +++ b/compose/service.py @@ -56,7 +56,9 @@ 'cpu_count', 'cpu_percent', 'cpu_quota', + 'cpu_shares', 'cpus', + 'cpuset', 'devices', 'dns', 'dns_search', @@ -83,6 +85,7 @@ 'sysctls', 'userns_mode', 'volumes_from', + 'volume_driver', ] CONDITION_STARTED = 'service_started' @@ -848,6 +851,9 @@ def _get_container_host_config(self, override_options, one_off=False): cpu_count=options.get('cpu_count'), cpu_percent=options.get('cpu_percent'), nano_cpus=nano_cpus, + volume_driver=options.get('volume_driver'), + cpuset_cpus=options.get('cpuset'), + cpu_shares=options.get('cpu_shares'), ) def get_secret_volumes(self): diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 9a1f5364b72..ba0b5388803 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -20,6 +20,8 @@ from .. import mock from ..helpers import create_host_file +from ..helpers import is_cluster +from ..helpers import no_cluster from compose.cli.command import get_project from compose.config.errors import DuplicateOverrideFileFound from compose.container import Container @@ -28,6 +30,7 @@ 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 SWARM_SKIP_RM_VOLUMES from tests.integration.testcases import v2_1_only from tests.integration.testcases import v2_only from tests.integration.testcases import v3_only @@ -68,7 +71,8 @@ def wait_on_condition(condition, delay=0.1, timeout=40): def kill_service(service): for container in service.containers(): - container.kill() + if container.is_running: + container.kill() class ContainerCountCondition(object): @@ -78,7 +82,7 @@ def __init__(self, project, expected): self.expected = expected def __call__(self): - return len(self.project.containers()) == self.expected + return len([c for c in self.project.containers() if c.is_running]) == self.expected def __str__(self): return "waiting for counter count == %s" % self.expected @@ -116,11 +120,14 @@ def tearDown(self): for container in self.project.containers(stopped=True, one_off=OneOffFilter.only): container.remove(force=True) - networks = self.client.networks() for n in networks: - if n['Name'].startswith('{}_'.format(self.project.name)): + if n['Name'].split('/')[-1].startswith('{}_'.format(self.project.name)): self.client.remove_network(n['Name']) + volumes = self.client.volumes().get('Volumes') or [] + for v in volumes: + if v['Name'].split('/')[-1].startswith('{}_'.format(self.project.name)): + self.client.remove_volume(v['Name']) if hasattr(self, '_project'): del self._project @@ -175,7 +182,10 @@ def test_host_not_reachable(self): 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') + container = self.client.create_container( + 'busybox', 'true', name='composetest_data_container', + host_config={} + ) self.addCleanup(self.client.remove_container, container) result = self.dispatch(['-H=tcp://doesnotexist:8000', 'ps'], returncode=1) @@ -545,42 +555,48 @@ def test_create(self): self.dispatch(['create']) service = self.project.get_service('simple') another = self.project.get_service('another') - self.assertEqual(len(service.containers()), 0) - self.assertEqual(len(another.containers()), 0) - self.assertEqual(len(service.containers(stopped=True)), 1) - self.assertEqual(len(another.containers(stopped=True)), 1) + service_containers = service.containers(stopped=True) + another_containers = another.containers(stopped=True) + assert len(service_containers) == 1 + assert len(another_containers) == 1 + assert not service_containers[0].is_running + assert not another_containers[0].is_running def test_create_with_force_recreate(self): self.dispatch(['create'], None) service = self.project.get_service('simple') - self.assertEqual(len(service.containers()), 0) - self.assertEqual(len(service.containers(stopped=True)), 1) + service_containers = service.containers(stopped=True) + assert len(service_containers) == 1 + assert not service_containers[0].is_running old_ids = [c.id for c in service.containers(stopped=True)] self.dispatch(['create', '--force-recreate'], None) - self.assertEqual(len(service.containers()), 0) - self.assertEqual(len(service.containers(stopped=True)), 1) + service_containers = service.containers(stopped=True) + assert len(service_containers) == 1 + assert not service_containers[0].is_running - new_ids = [c.id for c in service.containers(stopped=True)] + new_ids = [c.id for c in service_containers] - self.assertNotEqual(old_ids, new_ids) + assert old_ids != new_ids def test_create_with_no_recreate(self): self.dispatch(['create'], None) service = self.project.get_service('simple') - self.assertEqual(len(service.containers()), 0) - self.assertEqual(len(service.containers(stopped=True)), 1) + service_containers = service.containers(stopped=True) + assert len(service_containers) == 1 + assert not service_containers[0].is_running old_ids = [c.id for c in service.containers(stopped=True)] self.dispatch(['create', '--no-recreate'], None) - self.assertEqual(len(service.containers()), 0) - self.assertEqual(len(service.containers(stopped=True)), 1) + service_containers = service.containers(stopped=True) + assert len(service_containers) == 1 + assert not service_containers[0].is_running - new_ids = [c.id for c in service.containers(stopped=True)] + new_ids = [c.id for c in service_containers] - self.assertEqual(old_ids, new_ids) + assert old_ids == new_ids def test_run_one_off_with_volume(self): self.base_dir = 'tests/fixtures/simple-composefile-volume-ready' @@ -687,7 +703,7 @@ def test_up(self): 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') + assert networks[0]['Driver'] == 'bridge' if not is_cluster(self.client) else 'overlay' assert 'com.docker.network.bridge.enable_icc' not in networks[0]['Options'] network = self.client.inspect_network(networks[0]['Id']) @@ -733,11 +749,11 @@ def test_up_with_network_aliases(self): networks = [ n for n in self.client.networks() - if n['Name'].startswith('{}_'.format(self.project.name)) + if n['Name'].split('/')[-1].startswith('{}_'.format(self.project.name)) ] # Two networks were created: back and front - assert sorted(n['Name'] for n in networks) == [back_name, front_name] + assert sorted(n['Name'].split('/')[-1] for n in networks) == [back_name, front_name] web_container = self.project.get_service('web').containers()[0] back_aliases = web_container.get( @@ -761,11 +777,11 @@ def test_up_with_network_internal(self): networks = [ n for n in self.client.networks() - if n['Name'].startswith('{}_'.format(self.project.name)) + if n['Name'].split('/')[-1].startswith('{}_'.format(self.project.name)) ] # One network was created: internal - assert sorted(n['Name'] for n in networks) == [internal_net] + assert sorted(n['Name'].split('/')[-1] for n in networks) == [internal_net] assert networks[0]['Internal'] is True @@ -780,11 +796,11 @@ def test_up_with_network_static_addresses(self): networks = [ n for n in self.client.networks() - if n['Name'].startswith('{}_'.format(self.project.name)) + if n['Name'].split('/')[-1].startswith('{}_'.format(self.project.name)) ] # One networks was created: front - assert sorted(n['Name'] for n in networks) == [static_net] + assert sorted(n['Name'].split('/')[-1] for n in networks) == [static_net] web_container = self.project.get_service('web').containers()[0] ipam_config = web_container.get( @@ -803,11 +819,11 @@ def test_up_with_networks(self): networks = [ n for n in self.client.networks() - if n['Name'].startswith('{}_'.format(self.project.name)) + if n['Name'].split('/')[-1].startswith('{}_'.format(self.project.name)) ] # Two networks were created: back and front - assert sorted(n['Name'] for n in networks) == [back_name, front_name] + assert sorted(n['Name'].split('/')[-1] for n in networks) == [back_name, front_name] back_network = [n for n in networks if n['Name'] == back_name][0] front_network = [n for n in networks if n['Name'] == front_name][0] @@ -847,8 +863,12 @@ def test_up_missing_network(self): assert 'Service "web" uses an undefined network "foo"' in result.stderr @v2_only() + @no_cluster('container networks not supported in Swarm') def test_up_with_network_mode(self): - c = self.client.create_container('busybox', 'top', name='composetest_network_mode_container') + c = self.client.create_container( + 'busybox', 'top', name='composetest_network_mode_container', + host_config={} + ) self.addCleanup(self.client.remove_container, c, force=True) self.client.start(c) container_mode_source = 'container:{}'.format(c['Id']) @@ -862,7 +882,7 @@ def test_up_with_network_mode(self): networks = [ n for n in self.client.networks() - if n['Name'].startswith('{}_'.format(self.project.name)) + if n['Name'].split('/')[-1].startswith('{}_'.format(self.project.name)) ] assert not networks @@ -899,7 +919,7 @@ def test_up_external_networks(self): network_names = ['{}_{}'.format(self.project.name, n) for n in ['foo', 'bar']] for name in network_names: - self.client.create_network(name) + self.client.create_network(name, attachable=True) self.dispatch(['-f', filename, 'up', '-d']) container = self.project.containers()[0] @@ -917,12 +937,12 @@ def test_up_with_external_default_network(self): networks = [ n['Name'] for n in self.client.networks() - if n['Name'].startswith('{}_'.format(self.project.name)) + if n['Name'].split('/')[-1].startswith('{}_'.format(self.project.name)) ] assert not networks network_name = 'composetest_external_network' - self.client.create_network(network_name) + self.client.create_network(network_name, attachable=True) self.dispatch(['-f', filename, 'up', '-d']) container = self.project.containers()[0] @@ -941,10 +961,10 @@ def test_up_with_network_labels(self): networks = [ n for n in self.client.networks() - if n['Name'].startswith('{}_'.format(self.project.name)) + if n['Name'].split('/')[-1].startswith('{}_'.format(self.project.name)) ] - assert [n['Name'] for n in networks] == [network_with_label] + assert [n['Name'].split('/')[-1] for n in networks] == [network_with_label] assert 'label_key' in networks[0]['Labels'] assert networks[0]['Labels']['label_key'] == 'label_val' @@ -961,10 +981,10 @@ def test_up_with_volume_labels(self): volumes = [ v for v in self.client.volumes().get('Volumes', []) - if v['Name'].startswith('{}_'.format(self.project.name)) + if v['Name'].split('/')[-1].startswith('{}_'.format(self.project.name)) ] - assert [v['Name'] for v in volumes] == [volume_with_label] + assert set([v['Name'].split('/')[-1] for v in volumes]) == set([volume_with_label]) assert 'label_key' in volumes[0]['Labels'] assert volumes[0]['Labels']['label_key'] == 'label_val' @@ -975,7 +995,7 @@ def test_up_no_services(self): network_names = [ n['Name'] for n in self.client.networks() - if n['Name'].startswith('{}_'.format(self.project.name)) + if n['Name'].split('/')[-1].startswith('{}_'.format(self.project.name)) ] assert network_names == [] @@ -1010,6 +1030,7 @@ def test_up_with_net_is_invalid(self): assert "Unsupported config option for services.bar: 'net'" in result.stderr + @no_cluster("Legacy networking not supported on Swarm") def test_up_with_net_v1(self): self.base_dir = 'tests/fixtures/net-container' self.dispatch(['up', '-d'], None) @@ -1261,6 +1282,7 @@ def test_run_without_command(self): [u'/bin/true'], ) + @py.test.mark.skipif(SWARM_SKIP_RM_VOLUMES, reason='Swarm DELETE /containers/ bug') def test_run_rm(self): self.base_dir = 'tests/fixtures/volume' proc = start_process(self.base_dir, ['run', '--rm', 'test']) @@ -1274,7 +1296,7 @@ def test_run_rm(self): mounts = containers[0].get('Mounts') for mount in mounts: if mount['Destination'] == '/container-path': - anonymousName = mount['Name'] + anonymous_name = mount['Name'] break os.kill(proc.pid, signal.SIGINT) wait_on_process(proc, 1) @@ -1287,9 +1309,11 @@ def test_run_rm(self): if volume.internal == '/container-named-path': name = volume.external break - volumeNames = [v['Name'] for v in volumes] - assert name in volumeNames - assert anonymousName not in volumeNames + volume_names = [v['Name'].split('/')[-1] for v in volumes] + assert name in volume_names + if not is_cluster(self.client): + # The `-v` flag for `docker rm` in Swarm seems to be broken + assert anonymous_name not in volume_names def test_run_service_with_dockerfile_entrypoint(self): self.base_dir = 'tests/fixtures/entrypoint-dockerfile' @@ -1411,11 +1435,10 @@ def test_run_service_with_map_ports(self): container.stop() # check the ports - self.assertNotEqual(port_random, None) - self.assertIn("0.0.0.0", port_random) - self.assertEqual(port_assigned, "0.0.0.0:49152") - self.assertEqual(port_range[0], "0.0.0.0:49153") - self.assertEqual(port_range[1], "0.0.0.0:49154") + assert port_random is not None + assert port_assigned.endswith(':49152') + assert port_range[0].endswith(':49153') + assert port_range[1].endswith(':49154') def test_run_service_with_explicitly_mapped_ports(self): # create one off container @@ -1431,8 +1454,8 @@ def test_run_service_with_explicitly_mapped_ports(self): container.stop() # check the ports - self.assertEqual(port_short, "0.0.0.0:30000") - self.assertEqual(port_full, "0.0.0.0:30001") + assert port_short.endswith(':30000') + assert port_full.endswith(':30001') def test_run_service_with_explicitly_mapped_ip_ports(self): # create one off container @@ -1953,9 +1976,9 @@ def get_port(number): result = self.dispatch(['port', 'simple', str(number)]) return result.stdout.rstrip() - self.assertEqual(get_port(3000), container.get_local_port(3000)) - self.assertEqual(get_port(3001), "0.0.0.0:49152") - self.assertEqual(get_port(3002), "0.0.0.0:49153") + assert get_port(3000) == container.get_local_port(3000) + assert ':49152' in get_port(3001) + assert ':49153' in get_port(3002) def test_expanded_port(self): self.base_dir = 'tests/fixtures/ports-composefile' @@ -1966,9 +1989,9 @@ def get_port(number): result = self.dispatch(['port', 'simple', str(number)]) return result.stdout.rstrip() - self.assertEqual(get_port(3000), container.get_local_port(3000)) - self.assertEqual(get_port(3001), "0.0.0.0:49152") - self.assertEqual(get_port(3002), "0.0.0.0:49153") + assert get_port(3000) == container.get_local_port(3000) + assert ':49152' in get_port(3001) + assert ':49153' in get_port(3002) def test_port_with_scale(self): self.base_dir = 'tests/fixtures/ports-composefile-scale' @@ -2021,12 +2044,14 @@ def has_timestamp(string): assert len(lines) == 2 container, = self.project.containers() - expected_template = ( - ' container {} {} (image=busybox:latest, ' - 'name=simplecomposefile_simple_1)') + expected_template = ' container {} {}' + expected_meta_info = ['image=busybox:latest', 'name=simplecomposefile_simple_1'] assert expected_template.format('create', container.id) in lines[0] assert expected_template.format('start', container.id) in lines[1] + for line in lines: + for info in expected_meta_info: + assert info in line assert has_timestamp(lines[0]) @@ -2069,7 +2094,6 @@ def test_up_with_multiple_files(self): 'docker-compose.yml', 'docker-compose.override.yml', 'extra.yml', - ] self._project = get_project(self.base_dir, config_paths) self.dispatch( @@ -2086,7 +2110,6 @@ def test_up_with_multiple_files(self): web, other, db = containers self.assertEqual(web.human_readable_command, 'top') - self.assertTrue({'db', 'other'} <= set(get_links(web))) self.assertEqual(db.human_readable_command, 'top') self.assertEqual(other.human_readable_command, 'top') diff --git a/tests/fixtures/override-files/docker-compose.override.yml b/tests/fixtures/override-files/docker-compose.override.yml index a03d3d6f5f0..b2c54060124 100644 --- a/tests/fixtures/override-files/docker-compose.override.yml +++ b/tests/fixtures/override-files/docker-compose.override.yml @@ -1,6 +1,7 @@ - -web: +version: '2.2' +services: + web: command: "top" -db: + db: command: "top" diff --git a/tests/fixtures/override-files/docker-compose.yml b/tests/fixtures/override-files/docker-compose.yml index 8eb43ddb06c..6c3d4e17230 100644 --- a/tests/fixtures/override-files/docker-compose.yml +++ b/tests/fixtures/override-files/docker-compose.yml @@ -1,10 +1,10 @@ - -web: +version: '2.2' +services: + web: image: busybox:latest command: "sleep 200" - links: + depends_on: - db - -db: + db: image: busybox:latest command: "sleep 200" diff --git a/tests/fixtures/override-files/extra.yml b/tests/fixtures/override-files/extra.yml index 7b3ade9c2d1..492c379526e 100644 --- a/tests/fixtures/override-files/extra.yml +++ b/tests/fixtures/override-files/extra.yml @@ -1,9 +1,10 @@ - -web: - links: +version: '2.2' +services: + web: + depends_on: - db - other -other: + other: image: busybox:latest command: "top" diff --git a/tests/helpers.py b/tests/helpers.py index 59efd2557c4..662353c93b3 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -1,8 +1,12 @@ from __future__ import absolute_import from __future__ import unicode_literals +import functools import os +from docker.errors import APIError +from pytest import skip + from compose.config.config import ConfigDetails from compose.config.config import ConfigFile from compose.config.config import load @@ -44,3 +48,34 @@ def create_host_file(client, filename): "Container exited with code {}:\n{}".format(exitcode, output)) finally: client.remove_container(container, force=True) + + +def is_cluster(client): + nodes = None + + def get_nodes_number(): + try: + return len(client.nodes()) + except APIError: + # If the Engine is not part of a Swarm, the SDK will raise + # an APIError + return 0 + + if nodes is None: + # Only make the API call if the value hasn't been cached yet + nodes = get_nodes_number() + + return nodes > 1 + + +def no_cluster(reason): + def decorator(f): + @functools.wraps(f) + def wrapper(self, *args, **kwargs): + if is_cluster(self.client): + skip("Test will not be run in cluster mode: %s" % reason) + return + return f(self, *args, **kwargs) + return wrapper + + return decorator diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 6c5f719ed37..6731f25dd3a 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -6,12 +6,16 @@ import py import pytest +from docker.errors import APIError from docker.errors import NotFound from .. import mock from ..helpers import build_config as load_config from ..helpers import create_host_file +from ..helpers import is_cluster +from ..helpers import no_cluster from .testcases import DockerClientTestCase +from .testcases import SWARM_SKIP_CONTAINERS_ALL from compose.config import config from compose.config import ConfigurationError from compose.config import types @@ -57,6 +61,20 @@ def test_containers(self): containers = project.containers() self.assertEqual(len(containers), 2) + @pytest.mark.skipif(SWARM_SKIP_CONTAINERS_ALL, reason='Swarm /containers/json bug') + def test_containers_stopped(self): + web = self.create_service('web') + db = self.create_service('db') + project = Project('composetest', [web, db], self.client) + + project.up() + assert len(project.containers()) == 2 + assert len(project.containers(stopped=True)) == 2 + + project.stop() + assert len(project.containers()) == 0 + assert len(project.containers(stopped=True)) == 2 + def test_containers_with_service_names(self): web = self.create_service('web') db = self.create_service('db') @@ -110,6 +128,7 @@ def test_volumes_from_container(self): volumes=['/var/data'], name='composetest_data_container', labels={LABEL_PROJECT: 'composetest'}, + host_config={}, ) project = Project.from_config( name='composetest', @@ -125,6 +144,7 @@ def test_volumes_from_container(self): self.assertEqual(db._get_volumes_from(), [data_container.id + ':rw']) @v2_only() + @no_cluster('container networks not supported in Swarm') def test_network_mode_from_service(self): project = Project.from_config( name='composetest', @@ -152,6 +172,7 @@ def test_network_mode_from_service(self): self.assertEqual(web.network_mode.mode, 'container:' + net.containers()[0].id) @v2_only() + @no_cluster('container networks not supported in Swarm') def test_network_mode_from_container(self): def get_project(): return Project.from_config( @@ -179,6 +200,7 @@ def get_project(): name='composetest_net_container', command='top', labels={LABEL_PROJECT: 'composetest'}, + host_config={}, ) net_container.start() @@ -188,6 +210,7 @@ def get_project(): web = project.get_service('web') self.assertEqual(web.network_mode.mode, 'container:' + net_container.id) + @no_cluster('container networks not supported in Swarm') def test_net_from_service_v1(self): project = Project.from_config( name='composetest', @@ -211,6 +234,7 @@ def test_net_from_service_v1(self): net = project.get_service('net') self.assertEqual(web.network_mode.mode, 'container:' + net.containers()[0].id) + @no_cluster('container networks not supported in Swarm') def test_net_from_container_v1(self): def get_project(): return Project.from_config( @@ -235,6 +259,7 @@ def get_project(): name='composetest_net_container', command='top', labels={LABEL_PROJECT: 'composetest'}, + host_config={}, ) net_container.start() @@ -260,12 +285,12 @@ def test_start_pause_unpause_stop_kill_remove(self): project.start(service_names=['web']) self.assertEqual( - set(c.name for c in project.containers()), + set(c.name for c in project.containers() if c.is_running), set([web_container_1.name, web_container_2.name])) project.start() self.assertEqual( - set(c.name for c in project.containers()), + set(c.name for c in project.containers() if c.is_running), set([web_container_1.name, web_container_2.name, db_container.name])) project.pause(service_names=['web']) @@ -285,10 +310,12 @@ def test_start_pause_unpause_stop_kill_remove(self): self.assertEqual(len([c.name for c in project.containers() if c.is_paused]), 0) project.stop(service_names=['web'], timeout=1) - self.assertEqual(set(c.name for c in project.containers()), set([db_container.name])) + self.assertEqual( + set(c.name for c in project.containers() if c.is_running), set([db_container.name]) + ) project.kill(service_names=['db']) - self.assertEqual(len(project.containers()), 0) + self.assertEqual(len([c for c in project.containers() if c.is_running]), 0) self.assertEqual(len(project.containers(stopped=True)), 3) project.remove_stopped(service_names=['web']) @@ -303,11 +330,13 @@ def test_create(self): project = Project('composetest', [web, db], self.client) project.create(['db']) - self.assertEqual(len(project.containers()), 0) - self.assertEqual(len(project.containers(stopped=True)), 1) - self.assertEqual(len(db.containers()), 0) - self.assertEqual(len(db.containers(stopped=True)), 1) - self.assertEqual(len(web.containers(stopped=True)), 0) + containers = project.containers(stopped=True) + assert len(containers) == 1 + assert not containers[0].is_running + db_containers = db.containers(stopped=True) + assert len(db_containers) == 1 + assert not db_containers[0].is_running + assert len(web.containers(stopped=True)) == 0 def test_create_twice(self): web = self.create_service('web') @@ -316,12 +345,14 @@ def test_create_twice(self): project.create(['db', 'web']) project.create(['db', 'web']) - self.assertEqual(len(project.containers()), 0) - self.assertEqual(len(project.containers(stopped=True)), 2) - self.assertEqual(len(db.containers()), 0) - self.assertEqual(len(db.containers(stopped=True)), 1) - self.assertEqual(len(web.containers()), 0) - self.assertEqual(len(web.containers(stopped=True)), 1) + containers = project.containers(stopped=True) + assert len(containers) == 2 + db_containers = db.containers(stopped=True) + assert len(db_containers) == 1 + assert not db_containers[0].is_running + web_containers = web.containers(stopped=True) + assert len(web_containers) == 1 + assert not web_containers[0].is_running def test_create_with_links(self): db = self.create_service('db') @@ -329,12 +360,11 @@ def test_create_with_links(self): project = Project('composetest', [db, web], self.client) project.create(['web']) - self.assertEqual(len(project.containers()), 0) - self.assertEqual(len(project.containers(stopped=True)), 2) - self.assertEqual(len(db.containers()), 0) - self.assertEqual(len(db.containers(stopped=True)), 1) - self.assertEqual(len(web.containers()), 0) - self.assertEqual(len(web.containers(stopped=True)), 1) + # self.assertEqual(len(project.containers()), 0) + assert len(project.containers(stopped=True)) == 2 + assert not [c for c in project.containers(stopped=True) if c.is_running] + assert len(db.containers(stopped=True)) == 1 + assert len(web.containers(stopped=True)) == 1 def test_create_strategy_always(self): db = self.create_service('db') @@ -343,11 +373,11 @@ def test_create_strategy_always(self): old_id = project.containers(stopped=True)[0].id project.create(['db'], strategy=ConvergenceStrategy.always) - self.assertEqual(len(project.containers()), 0) - self.assertEqual(len(project.containers(stopped=True)), 1) + assert len(project.containers(stopped=True)) == 1 db_container = project.containers(stopped=True)[0] - self.assertNotEqual(db_container.id, old_id) + assert not db_container.is_running + assert db_container.id != old_id def test_create_strategy_never(self): db = self.create_service('db') @@ -356,11 +386,11 @@ def test_create_strategy_never(self): old_id = project.containers(stopped=True)[0].id project.create(['db'], strategy=ConvergenceStrategy.never) - self.assertEqual(len(project.containers()), 0) - self.assertEqual(len(project.containers(stopped=True)), 1) + assert len(project.containers(stopped=True)) == 1 db_container = project.containers(stopped=True)[0] - self.assertEqual(db_container.id, old_id) + assert not db_container.is_running + assert db_container.id == old_id def test_project_up(self): web = self.create_service('web') @@ -550,8 +580,8 @@ def test_project_up_with_no_deps(self): self.assertEqual(len(project.containers(stopped=True)), 2) self.assertEqual(len(project.get_service('web').containers()), 0) self.assertEqual(len(project.get_service('db').containers()), 1) - self.assertEqual(len(project.get_service('data').containers()), 0) self.assertEqual(len(project.get_service('data').containers(stopped=True)), 1) + assert not project.get_service('data').containers(stopped=True)[0].is_running self.assertEqual(len(project.get_service('console').containers()), 0) def test_project_up_recreate_with_tmpfs_volume(self): @@ -737,10 +767,10 @@ def test_up_with_ipam_options(self): "com.docker.compose.network.test": "9-29-045" } - @v2_only() + @v2_1_only() def test_up_with_network_static_addresses(self): config_data = build_config( - version=V2_0, + version=V2_1, services=[{ 'name': 'web', 'image': 'busybox:latest', @@ -766,7 +796,8 @@ def test_up_with_network_static_addresses(self): {"subnet": "fe80::/64", "gateway": "fe80::1001:1"} ] - } + }, + 'enable_ipv6': True, } } ) @@ -777,13 +808,8 @@ def test_up_with_network_static_addresses(self): ) project.up(detached=True) - 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', {})) @@ -825,7 +851,7 @@ def test_up_with_enable_ipv6(self): config_data=config_data, ) project.up(detached=True) - network = self.client.networks(names=['static_test'])[0] + network = [n for n in self.client.networks() if 'static_test' in n['Name']][0] service_container = project.get_service('web').containers()[0] assert network['EnableIPv6'] is True @@ -1026,8 +1052,8 @@ def test_project_up_volumes(self): project.up() self.assertEqual(len(project.containers()), 1) - volume_data = self.client.inspect_volume(full_vol_name) - self.assertEqual(volume_data['Name'], full_vol_name) + volume_data = self.get_volume_data(full_vol_name) + assert volume_data['Name'].split('/')[-1] == full_vol_name self.assertEqual(volume_data['Driver'], 'local') @v2_1_only() @@ -1062,10 +1088,12 @@ def test_project_up_with_volume_labels(self): volumes = [ v for v in self.client.volumes().get('Volumes', []) - if v['Name'].startswith('composetest_') + if v['Name'].split('/')[-1].startswith('composetest_') ] - assert [v['Name'] for v in volumes] == ['composetest_{}'.format(volume_name)] + assert set([v['Name'].split('/')[-1] for v in volumes]) == set( + ['composetest_{}'.format(volume_name)] + ) assert 'label_key' in volumes[0]['Labels'] assert volumes[0]['Labels']['label_key'] == 'label_val' @@ -1205,8 +1233,8 @@ def test_initialize_volumes(self): ) project.volumes.initialize() - volume_data = self.client.inspect_volume(full_vol_name) - assert volume_data['Name'] == full_vol_name + volume_data = self.get_volume_data(full_vol_name) + assert volume_data['Name'].split('/')[-1] == full_vol_name assert volume_data['Driver'] == 'local' @v2_only() @@ -1229,8 +1257,8 @@ def test_project_up_implicit_volume_driver(self): ) project.up() - volume_data = self.client.inspect_volume(full_vol_name) - self.assertEqual(volume_data['Name'], full_vol_name) + volume_data = self.get_volume_data(full_vol_name) + assert volume_data['Name'].split('/')[-1] == full_vol_name self.assertEqual(volume_data['Driver'], 'local') @v3_only() @@ -1287,10 +1315,11 @@ def test_initialize_volumes_invalid_volume_driver(self): name='composetest', config_data=config_data, client=self.client ) - with self.assertRaises(config.ConfigurationError): + with self.assertRaises(APIError if is_cluster(self.client) else config.ConfigurationError): project.volumes.initialize() @v2_only() + @no_cluster('inspect volume by name defect on Swarm Classic') def test_initialize_volumes_updated_driver(self): vol_name = '{0:x}'.format(random.getrandbits(32)) full_vol_name = 'composetest_{0}'.format(vol_name) @@ -1310,8 +1339,8 @@ def test_initialize_volumes_updated_driver(self): ) project.volumes.initialize() - volume_data = self.client.inspect_volume(full_vol_name) - self.assertEqual(volume_data['Name'], full_vol_name) + volume_data = self.get_volume_data(full_vol_name) + assert volume_data['Name'].split('/')[-1] == full_vol_name self.assertEqual(volume_data['Driver'], 'local') config_data = config_data._replace( @@ -1348,8 +1377,8 @@ def test_initialize_volumes_updated_blank_driver(self): ) project.volumes.initialize() - volume_data = self.client.inspect_volume(full_vol_name) - self.assertEqual(volume_data['Name'], full_vol_name) + volume_data = self.get_volume_data(full_vol_name) + assert volume_data['Name'].split('/')[-1] == full_vol_name self.assertEqual(volume_data['Driver'], 'local') config_data = config_data._replace( @@ -1361,11 +1390,12 @@ def test_initialize_volumes_updated_blank_driver(self): client=self.client ) project.volumes.initialize() - volume_data = self.client.inspect_volume(full_vol_name) - self.assertEqual(volume_data['Name'], full_vol_name) + volume_data = self.get_volume_data(full_vol_name) + assert volume_data['Name'].split('/')[-1] == full_vol_name self.assertEqual(volume_data['Driver'], 'local') @v2_only() + @no_cluster('inspect volume by name defect on Swarm Classic') def test_initialize_volumes_external_volumes(self): # Use composetest_ prefix so it gets garbage-collected in tearDown() vol_name = 'composetest_{0:x}'.format(random.getrandbits(32)) diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 178df13239d..baf21af3ce8 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -13,9 +13,13 @@ from six import text_type from .. import mock +from ..helpers import is_cluster +from ..helpers import no_cluster from .testcases import DockerClientTestCase from .testcases import get_links from .testcases import pull_busybox +from .testcases import SWARM_SKIP_CONTAINERS_ALL +from .testcases import SWARM_SKIP_CPU_SHARES from compose import __version__ from compose.config.types import VolumeFromSpec from compose.config.types import VolumeSpec @@ -100,6 +104,7 @@ def test_create_container_with_volume_driver(self): service.start_container(container) self.assertEqual('foodriver', container.get('HostConfig.VolumeDriver')) + @pytest.mark.skipif(SWARM_SKIP_CPU_SHARES, reason='Swarm --cpu-shares bug') def test_create_container_with_cpu_shares(self): service = self.create_service('db', cpu_shares=73) container = service.create_container() @@ -151,6 +156,7 @@ def test_create_container_with_init_bool(self): service.start_container(container) assert container.get('HostConfig.Init') is True + @pytest.mark.xfail(True, reason='Option has been removed in Engine 17.06.0') def test_create_container_with_init_path(self): self.require_api_version('1.25') docker_init_path = find_executable('docker-init') @@ -249,6 +255,7 @@ def test_duplicate_volume_trailing_slash(self): 'busybox', 'true', volumes={container_path: {}}, labels={'com.docker.compose.test_image': 'true'}, + host_config={} ) image = self.client.commit(tmp_container)['Id'] @@ -278,6 +285,7 @@ def test_create_container_with_volumes_from(self): image='busybox:latest', command=["top"], labels={LABEL_PROJECT: 'composetest'}, + host_config={}, ) host_service = self.create_service( 'host', @@ -321,9 +329,15 @@ def test_execute_convergence_plan_recreate(self): self.assertIn('FOO=2', new_container.get('Config.Env')) self.assertEqual(new_container.name, 'composetest_db_1') self.assertEqual(new_container.get_mount('/etc')['Source'], volume_path) - self.assertIn( - 'affinity:container==%s' % old_container.id, - new_container.get('Config.Env')) + if not is_cluster(self.client): + assert ( + 'affinity:container==%s' % old_container.id in + new_container.get('Config.Env') + ) + else: + # In Swarm, the env marker is consumed and the container should be deployed + # on the same node. + assert old_container.get('Node.Name') == new_container.get('Node.Name') self.assertEqual(len(self.client.containers(all=True)), num_containers_before) self.assertNotEqual(old_container.id, new_container.id) @@ -350,8 +364,13 @@ def test_execute_convergence_plan_recreate_twice(self): 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')) + if not is_cluster(self.client): + assert ('affinity:container==%s' % orig_container.id in + new_container.get('Config.Env')) + else: + # In Swarm, the env marker is consumed and the container should be deployed + # on the same node. + assert orig_container.get('Node.Name') == new_container.get('Node.Name') orig_container = new_container @@ -464,18 +483,21 @@ def test_execute_convergence_plan_without_start(self): ) containers = service.execute_convergence_plan(ConvergencePlan('create', []), start=False) - self.assertEqual(len(service.containers()), 0) - self.assertEqual(len(service.containers(stopped=True)), 1) + service_containers = service.containers(stopped=True) + assert len(service_containers) == 1 + assert not service_containers[0].is_running containers = service.execute_convergence_plan( ConvergencePlan('recreate', containers), start=False) - self.assertEqual(len(service.containers()), 0) - self.assertEqual(len(service.containers(stopped=True)), 1) + service_containers = service.containers(stopped=True) + assert len(service_containers) == 1 + assert not service_containers[0].is_running service.execute_convergence_plan(ConvergencePlan('start', containers), start=False) - self.assertEqual(len(service.containers()), 0) - self.assertEqual(len(service.containers(stopped=True)), 1) + service_containers = service.containers(stopped=True) + assert len(service_containers) == 1 + assert not service_containers[0].is_running def test_start_container_passes_through_options(self): db = self.create_service('db') @@ -487,6 +509,7 @@ def test_start_container_inherits_options_from_constructor(self): create_and_start_container(db) self.assertEqual(db.containers()[0].environment['FOO'], 'BAR') + @no_cluster('No legacy links support in Swarm') def test_start_container_creates_links(self): db = self.create_service('db') web = self.create_service('web', links=[(db, None)]) @@ -503,6 +526,7 @@ def test_start_container_creates_links(self): 'db']) ) + @no_cluster('No legacy links support in Swarm') def test_start_container_creates_links_with_names(self): db = self.create_service('db') web = self.create_service('web', links=[(db, 'custom_link_name')]) @@ -519,6 +543,7 @@ def test_start_container_creates_links_with_names(self): 'custom_link_name']) ) + @no_cluster('No legacy links support in Swarm') def test_start_container_with_external_links(self): db = self.create_service('db') web = self.create_service('web', external_links=['composetest_db_1', @@ -537,6 +562,7 @@ def test_start_container_with_external_links(self): 'db_3']), ) + @no_cluster('No legacy links support in Swarm') def test_start_normal_container_does_not_create_links_to_its_own_service(self): db = self.create_service('db') @@ -546,6 +572,7 @@ def test_start_normal_container_does_not_create_links_to_its_own_service(self): c = create_and_start_container(db) self.assertEqual(set(get_links(c)), set([])) + @no_cluster('No legacy links support in Swarm') def test_start_one_off_container_creates_links_to_its_own_service(self): db = self.create_service('db') @@ -572,7 +599,7 @@ def test_start_container_builds_images(self): container = create_and_start_container(service) container.wait() self.assertIn(b'success', container.logs()) - self.assertEqual(len(self.client.images(name='composetest_test')), 1) + assert len(self.client.images(name='composetest_test')) >= 1 def test_start_container_uses_tagged_image_if_it_exists(self): self.check_build('tests/fixtures/simple-dockerfile', tag='composetest_test') @@ -719,20 +746,27 @@ def test_port_with_explicit_interface(self): '0.0.0.0:9001:9000/udp', ]) container = create_and_start_container(service).inspect() - self.assertEqual(container['NetworkSettings']['Ports'], { - '8000/tcp': [ - { - 'HostIp': '127.0.0.1', - 'HostPort': '8001', - }, - ], - '9000/udp': [ - { - 'HostIp': '0.0.0.0', - 'HostPort': '9001', - }, - ], - }) + assert container['NetworkSettings']['Ports']['8000/tcp'] == [{ + 'HostIp': '127.0.0.1', + 'HostPort': '8001', + }] + assert container['NetworkSettings']['Ports']['9000/udp'][0]['HostPort'] == '9001' + if not is_cluster(self.client): + assert container['NetworkSettings']['Ports']['9000/udp'][0]['HostIp'] == '0.0.0.0' + # self.assertEqual(container['NetworkSettings']['Ports'], { + # '8000/tcp': [ + # { + # 'HostIp': '127.0.0.1', + # 'HostPort': '8001', + # }, + # ], + # '9000/udp': [ + # { + # 'HostIp': '0.0.0.0', + # 'HostPort': '9001', + # }, + # ], + # }) def test_create_with_image_id(self): # Get image id for the current busybox:latest @@ -760,6 +794,10 @@ def test_scale(self): service.scale(0) self.assertEqual(len(service.containers()), 0) + @pytest.mark.skipif( + SWARM_SKIP_CONTAINERS_ALL, + reason='Swarm /containers/json bug' + ) def test_scale_with_stopped_containers(self): """ Given there are some stopped containers and scale is called with a diff --git a/tests/integration/state_test.py b/tests/integration/state_test.py index 07b28e78431..0dd5f44ad49 100644 --- a/tests/integration/state_test.py +++ b/tests/integration/state_test.py @@ -251,7 +251,7 @@ def test_trigger_recreate_with_image_change(self): container = web.create_container() # update the image - c = self.client.create_container(image, ['touch', '/hello.txt']) + c = self.client.create_container(image, ['touch', '/hello.txt'], host_config={}) self.client.commit(c, repository=repo, tag=tag) self.client.remove_container(c) diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index 57814872cfa..1e0d6321502 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -8,6 +8,7 @@ from pytest import skip from .. import unittest +from ..helpers import is_cluster from compose.cli.docker_client import docker_client from compose.config.config import resolve_environment from compose.config.environment import Environment @@ -21,6 +22,10 @@ from compose.progress_stream import stream_output from compose.service import Service +SWARM_SKIP_CONTAINERS_ALL = os.environ.get('SWARM_SKIP_CONTAINERS_ALL', '0') != '0' +SWARM_SKIP_CPU_SHARES = os.environ.get('SWARM_SKIP_CPU_SHARES', '0') != '0' +SWARM_SKIP_RM_VOLUMES = os.environ.get('SWARM_SKIP_RM_VOLUMES', '0') != '0' + def pull_busybox(client): client.pull('busybox:latest', stream=False) @@ -97,7 +102,7 @@ def tearDown(self): for i in self.client.images( filters={'label': 'com.docker.compose.test_image'}): - self.client.remove_image(i) + self.client.remove_image(i, force=True) volumes = self.client.volumes().get('Volumes') or [] for v in volumes: @@ -133,3 +138,11 @@ def require_api_version(self, minimum): api_version = self.client.version()['ApiVersion'] if version_lt(api_version, minimum): skip("API version is too low ({} < {})".format(api_version, minimum)) + + def get_volume_data(self, volume_name): + if not is_cluster(self.client): + return self.client.inspect_volume(volume_name) + + volumes = self.client.volumes(filters={'name': volume_name})['Volumes'] + assert len(volumes) > 0 + return self.client.inspect_volume(volumes[0]['Name']) diff --git a/tests/integration/volume_test.py b/tests/integration/volume_test.py index add169623b0..772631a5b26 100644 --- a/tests/integration/volume_test.py +++ b/tests/integration/volume_test.py @@ -3,6 +3,7 @@ from docker.errors import DockerException +from ..helpers import no_cluster from .testcases import DockerClientTestCase from compose.const import LABEL_PROJECT from compose.const import LABEL_VOLUME @@ -35,26 +36,28 @@ def create_volume(self, name, driver=None, opts=None, external=None): def test_create_volume(self): vol = self.create_volume('volume01') vol.create() - info = self.client.inspect_volume(vol.full_name) - assert info['Name'] == vol.full_name + info = self.get_volume_data(vol.full_name) + assert info['Name'].split('/')[-1] == vol.full_name def test_recreate_existing_volume(self): vol = self.create_volume('volume01') vol.create() - info = self.client.inspect_volume(vol.full_name) - assert info['Name'] == vol.full_name + info = self.get_volume_data(vol.full_name) + assert info['Name'].split('/')[-1] == vol.full_name vol.create() - info = self.client.inspect_volume(vol.full_name) - assert info['Name'] == vol.full_name + info = self.get_volume_data(vol.full_name) + assert info['Name'].split('/')[-1] == vol.full_name + @no_cluster('inspect volume by name defect on Swarm Classic') def test_inspect_volume(self): vol = self.create_volume('volume01') vol.create() info = vol.inspect() assert info['Name'] == vol.full_name + @no_cluster('remove volume by name defect on Swarm Classic') def test_remove_volume(self): vol = Volume(self.client, 'composetest', 'volume01') vol.create() @@ -62,6 +65,7 @@ def test_remove_volume(self): volumes = self.client.volumes()['Volumes'] assert len([v for v in volumes if v['Name'] == vol.full_name]) == 0 + @no_cluster('inspect volume by name defect on Swarm Classic') def test_external_volume(self): vol = self.create_volume('composetest_volume_ext', external=True) assert vol.external is True @@ -70,6 +74,7 @@ def test_external_volume(self): info = vol.inspect() assert info['Name'] == vol.name + @no_cluster('inspect volume by name defect on Swarm Classic') def test_external_aliased_volume(self): alias_name = 'composetest_alias01' vol = self.create_volume('volume01', external=alias_name) @@ -79,24 +84,28 @@ def test_external_aliased_volume(self): info = vol.inspect() assert info['Name'] == alias_name + @no_cluster('inspect volume by name defect on Swarm Classic') def test_exists(self): vol = self.create_volume('volume01') assert vol.exists() is False vol.create() assert vol.exists() is True + @no_cluster('inspect volume by name defect on Swarm Classic') def test_exists_external(self): vol = self.create_volume('volume01', external=True) assert vol.exists() is False vol.create() assert vol.exists() is True + @no_cluster('inspect volume by name defect on Swarm Classic') def test_exists_external_aliased(self): vol = self.create_volume('volume01', external='composetest_alias01') assert vol.exists() is False vol.create() assert vol.exists() is True + @no_cluster('inspect volume by name defect on Swarm Classic') def test_volume_default_labels(self): vol = self.create_volume('volume01') vol.create() From 78bb879419d8b6811b295d68cb26e0b825d3f02e Mon Sep 17 00:00:00 2001 From: NikitaVlaznev Date: Mon, 19 Jun 2017 17:05:19 +0300 Subject: [PATCH 2863/4072] Fix double silent argument value Fix for "TypeError: pull() got multiple values for keyword argument 'silent'." This change https://github.com/docker/compose/commit/e9b6cc23fcf01d4768c7e082b7bc91b43ff84e7e caused additional value to be passed for the 'silent' argument, that was already passed there: https://github.com/docker/compose/commit/f85da99ef3273794e855afda8678174419d3bf4f Signed-off-by: Nikita Vlaznev --- compose/project.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/project.py b/compose/project.py index 3ad971488f0..7f7ade9a2ca 100644 --- a/compose/project.py +++ b/compose/project.py @@ -467,7 +467,7 @@ def pull(self, service_names=None, ignore_pull_failures=False, parallel_pull=Fal if parallel_pull: def pull_service(service): - service.pull(ignore_pull_failures, True, silent=silent) + service.pull(ignore_pull_failures, silent=silent) parallel.parallel_execute( services, From 5c3d0db3f2eb3aa9ad056c9ec9337295b260a352 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 9 Jun 2017 16:58:24 -0700 Subject: [PATCH 2864/4072] ServicePort merge_field should account for external IP and protocol Signed-off-by: Joffrey F --- compose/config/types.py | 2 +- tests/unit/config/config_test.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/compose/config/types.py b/compose/config/types.py index 6d3ca3f3b64..4509bfe67cb 100644 --- a/compose/config/types.py +++ b/compose/config/types.py @@ -325,7 +325,7 @@ def parse(cls, spec): @property def merge_field(self): - return (self.target, self.published) + return (self.target, self.published, self.external_ip, self.protocol) def repr(self): return dict( diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index d92a35c005a..87bdd8bca9f 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1865,7 +1865,7 @@ def test_merge_mixed_ports(self): { 'target': '1245', 'published': '1245', - 'protocol': 'tcp', + 'protocol': 'udp', } ] } From abac2eea37d55bfef8ca443f3f79ccbdb0949db3 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 9 Jun 2017 16:59:09 -0700 Subject: [PATCH 2865/4072] Fix `ps` output to show all ports Signed-off-by: Joffrey F --- compose/container.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/compose/container.py b/compose/container.py index bda4e659fb2..4bc7f54f9de 100644 --- a/compose/container.py +++ b/compose/container.py @@ -96,12 +96,16 @@ def ports(self): def human_readable_ports(self): def format_port(private, public): if not public: - return private - return '{HostIp}:{HostPort}->{private}'.format( - private=private, **public[0]) - - return ', '.join(format_port(*item) - for item in sorted(six.iteritems(self.ports))) + return [private] + return [ + '{HostIp}:{HostPort}->{private}'.format(private=private, **pub) + for pub in public + ] + + return ', '.join( + ','.join(format_port(*item)) + for item in sorted(six.iteritems(self.ports)) + ) @property def labels(self): From cffce0880befc1426348eeb8a734b8baa8f790c5 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 15 Jun 2017 17:05:23 -0700 Subject: [PATCH 2866/4072] Bump 1.14.0 Signed-off-by: Joffrey F --- CHANGELOG.md | 5 ++++- compose/__init__.py | 2 +- script/run/run.sh | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 02e439e4f5a..cced3804cea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ Change log ========== -1.14.0 (2017-06-06) +1.14.0 (2017-06-19) ------------------- ### New features @@ -41,6 +41,9 @@ Change log - Fixed a bug where `cache_from` in the build section would be ignored when using more than one Compose file. +- Fixed a bug that prevented binding the same port to different IPs when + using more than one Compose file. + - Fixed a bug where override files would not be picked up by Compose if they had the `.yaml` extension diff --git a/compose/__init__.py b/compose/__init__.py index 9bbae98d5ff..f6ed1f463b1 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.14.0-rc2' +__version__ = '1.14.0' diff --git a/script/run/run.sh b/script/run/run.sh index ef2f63d8fff..e4a2f4199f4 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.14.0-rc2" +VERSION="1.14.0" IMAGE="docker/compose:$VERSION" From 73dfdfffb06c1210de9c5a72f9d421a8729fbbb0 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 26 May 2017 14:39:47 -0700 Subject: [PATCH 2867/4072] Bump 1.14.0-rc1 Signed-off-by: Joffrey F --- CHANGELOG.md | 49 +++++++++++++++++++++++++++++++++++++++++++++ compose/__init__.py | 2 +- script/run/run.sh | 2 +- 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d1da62e3486..748cce08a6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,55 @@ Change log ========== +1.14.0 (2017-06-06) +------------------- + +### New features + +#### Compose file version 3.3 + +- Introduced version 3.3 of the `docker-compose.yml` specification. + This version requires to be used with Docker Engine 17.06.0 or above. + Note: the `credential_spec` key only applies to Swarm services and will + be ignored by Compose + +#### Compose file version 2.2 + +- Added the following parameters in service definitions: `cpu_count`, + `cpu_percent`, `cpus` + +#### Compose file version 2.1 + +- Added support for build labels. This feature is also available in the + 2.2 and 3.3 formats. + +#### All formats + +- Added shorthand `-u` for `--user` flag in `docker-compose exec` + +- Differences in labels between the Compose file and remote network + will now print a warning instead of preventing redeployment. + +### Bugfixes + +- Fixed a bug where service's dependencies were being rescaled to their + default scale when running a `docker-compose run` command + +- Fixed a bug where `docker-compose rm` with the `--stop` flag was not + behaving properly when provided with a list of services to remove + +- Fixed a bug where `cache_from` in the build section would be ignored when + using more than one Compose file. + +- Fixed a bug where override files would not be picked up by Compose if they + had the `.yaml` extension + +- Fixed a bug on Windows Engine where networks would be incorrectly flagged + for recreation + +- Fixed a bug where services declaring ports would cause crashes on some + versions of Python 3 + 1.13.0 (2017-05-02) ------------------- diff --git a/compose/__init__.py b/compose/__init__.py index 69307d60e81..44521646020 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.14.0dev' +__version__ = '1.14.0-rc1' diff --git a/script/run/run.sh b/script/run/run.sh index d1e1fabaaca..45063abd5c9 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.13.0" +VERSION="1.14.0-rc1" IMAGE="docker/compose:$VERSION" From d81a27be02babeb27eb77badf34a0c3f1219fa43 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 6 Jun 2017 12:36:26 -0700 Subject: [PATCH 2868/4072] Bump 1.14.0-rc2 Signed-off-by: Joffrey F --- CHANGELOG.md | 7 +++++-- compose/__init__.py | 2 +- script/run/run.sh | 2 +- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 748cce08a6f..02e439e4f5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,8 +10,8 @@ Change log - Introduced version 3.3 of the `docker-compose.yml` specification. This version requires to be used with Docker Engine 17.06.0 or above. - Note: the `credential_spec` key only applies to Swarm services and will - be ignored by Compose + Note: the `credential_spec` and `configs` keys only apply to Swarm services + and will be ignored by Compose #### Compose file version 2.2 @@ -50,6 +50,9 @@ Change log - Fixed a bug where services declaring ports would cause crashes on some versions of Python 3 +- Fixed a bug where the output of `docker-compose config` would sometimes + contain invalid port definitions + 1.13.0 (2017-05-02) ------------------- diff --git a/compose/__init__.py b/compose/__init__.py index 44521646020..9bbae98d5ff 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.14.0-rc1' +__version__ = '1.14.0-rc2' diff --git a/script/run/run.sh b/script/run/run.sh index 45063abd5c9..ef2f63d8fff 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.14.0-rc1" +VERSION="1.14.0-rc2" IMAGE="docker/compose:$VERSION" From f9bd31adad18f4b6a977ce0e9b3bbecdaf5f54e6 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 15 Jun 2017 17:05:23 -0700 Subject: [PATCH 2869/4072] Bump 1.14.0 Signed-off-by: Joffrey F --- CHANGELOG.md | 5 ++++- compose/__init__.py | 2 +- script/run/run.sh | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 02e439e4f5a..cced3804cea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ Change log ========== -1.14.0 (2017-06-06) +1.14.0 (2017-06-19) ------------------- ### New features @@ -41,6 +41,9 @@ Change log - Fixed a bug where `cache_from` in the build section would be ignored when using more than one Compose file. +- Fixed a bug that prevented binding the same port to different IPs when + using more than one Compose file. + - Fixed a bug where override files would not be picked up by Compose if they had the `.yaml` extension diff --git a/compose/__init__.py b/compose/__init__.py index 9bbae98d5ff..f6ed1f463b1 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.14.0-rc2' +__version__ = '1.14.0' diff --git a/script/run/run.sh b/script/run/run.sh index ef2f63d8fff..e4a2f4199f4 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.14.0-rc2" +VERSION="1.14.0" IMAGE="docker/compose:$VERSION" From c38eaeaba342d586c8986482b745006efb801665 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 19 Jun 2017 13:52:56 -0700 Subject: [PATCH 2870/4072] 1.15.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 f6ed1f463b1..1898479bfd1 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.14.0' +__version__ = '1.15.0dev' From f2054f1a7d9e9cb6dbcf2a39fb8ccc3f480a432b Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 23 Jun 2017 15:04:18 -0700 Subject: [PATCH 2871/4072] Fix ports sorting on Python 3 Signed-off-by: Joffrey F --- compose/config/config.py | 2 +- tests/unit/config/config_test.py | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/compose/config/config.py b/compose/config/config.py index b8bffc66040..fdb20df19c9 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -959,7 +959,7 @@ def parse_sequence_func(seq): merged = parse_sequence_func(md.base.get(field, [])) merged.update(parse_sequence_func(md.override.get(field, []))) - md[field] = [item for item in sorted(merged.values())] + md[field] = [item for item in sorted(merged.values(), key=lambda x: x.target)] def merge_build(output, base, override): diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 87bdd8bca9f..6178447ae64 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1615,6 +1615,22 @@ def test_merge_service_dicts_heterogeneous_2(self): 'ports': types.ServicePort.parse('5432') } + def test_merge_service_dicts_ports_sorting(self): + base = { + 'ports': [5432] + } + override = { + 'image': 'alpine:edge', + 'ports': ['5432/udp'] + } + actual = config.merge_service_dicts_from_files( + base, + override, + DEFAULT_VERSION) + assert len(actual['ports']) == 2 + assert types.ServicePort.parse('5432')[0] in actual['ports'] + assert types.ServicePort.parse('5432/udp')[0] in actual['ports'] + def test_merge_service_dicts_heterogeneous_volumes(self): base = { 'volumes': ['/a:/b', '/x:/z'], From 4dd54b83e8a4b0a3f61c42f985edf0ba11fe11a4 Mon Sep 17 00:00:00 2001 From: dinesh Date: Tue, 28 Mar 2017 18:25:27 +0530 Subject: [PATCH 2872/4072] Add storage_opt in v2.1 Signed-off-by: dinesh --- compose/config/config_schema_v2.1.json | 1 + compose/service.py | 2 ++ tests/integration/service_test.py | 7 +++++++ 3 files changed, 10 insertions(+) diff --git a/compose/config/config_schema_v2.1.json b/compose/config/config_schema_v2.1.json index 9004000ea71..5aed9f7b189 100644 --- a/compose/config/config_schema_v2.1.json +++ b/compose/config/config_schema_v2.1.json @@ -229,6 +229,7 @@ "stdin_open": {"type": "boolean"}, "stop_grace_period": {"type": "string", "format": "duration"}, "stop_signal": {"type": "string"}, + "storage_opt": {"type": "object"}, "tmpfs": {"$ref": "#/definitions/string_or_list"}, "tty": {"type": "boolean"}, "ulimits": { diff --git a/compose/service.py b/compose/service.py index 03c41ce6758..7ee63771ad8 100644 --- a/compose/service.py +++ b/compose/service.py @@ -82,6 +82,7 @@ 'restart', 'security_opt', 'shm_size', + 'storage_opt', 'sysctls', 'userns_mode', 'volumes_from', @@ -854,6 +855,7 @@ def _get_container_host_config(self, override_options, one_off=False): volume_driver=options.get('volume_driver'), cpuset_cpus=options.get('cpuset'), cpu_shares=options.get('cpu_shares'), + storage_opt=options.get('storage_opt') ) def get_secret_volumes(self): diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index baf21af3ce8..e0aac2147e7 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -208,6 +208,13 @@ def test_create_container_with_security_opt(self): service.start_container(container) self.assertEqual(set(container.get('HostConfig.SecurityOpt')), set(security_opt)) + def test_create_container_with_storage_opt(self): + storage_opt = {'size': '1G'} + service = self.create_service('db', storage_opt=storage_opt) + container = service.create_container() + service.start_container(container) + self.assertEqual(container.get('HostConfig.StorageOpt'), storage_opt) + def test_create_container_with_mac_address(self): service = self.create_service('db', mac_address='02:42:ac:11:65:43') container = service.create_container() From 515526f0fff188ae656055fa5634347133c18f91 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 23 Jun 2017 15:28:35 -0700 Subject: [PATCH 2873/4072] Ignore test failures in storage_opt test Signed-off-by: Joffrey F --- 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 e0aac2147e7..c406a8d5e0e 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -208,6 +208,7 @@ def test_create_container_with_security_opt(self): service.start_container(container) self.assertEqual(set(container.get('HostConfig.SecurityOpt')), set(security_opt)) + @pytest.mark.xfail(True, reason='Not supported on most drivers') def test_create_container_with_storage_opt(self): storage_opt = {'size': '1G'} service = self.create_service('db', storage_opt=storage_opt) From 5c5a40c3375693cadaab6d1449e13f4a4bda6047 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 23 Jun 2017 15:33:02 -0700 Subject: [PATCH 2874/4072] Add storage_opt to 2.2 schema Signed-off-by: Joffrey F --- compose/config/config_schema_v2.2.json | 1 + 1 file changed, 1 insertion(+) diff --git a/compose/config/config_schema_v2.2.json b/compose/config/config_schema_v2.2.json index e8edb60eda5..87ba26ae494 100644 --- a/compose/config/config_schema_v2.2.json +++ b/compose/config/config_schema_v2.2.json @@ -235,6 +235,7 @@ "stdin_open": {"type": "boolean"}, "stop_grace_period": {"type": "string", "format": "duration"}, "stop_signal": {"type": "string"}, + "storage_opt": {"type": "object"}, "tmpfs": {"$ref": "#/definitions/string_or_list"}, "tty": {"type": "boolean"}, "ulimits": { From 300b879d442a932c79187dad3383daf52299eae4 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 28 Jun 2017 14:31:59 -0700 Subject: [PATCH 2875/4072] Bump docker Python SDK version -> 2.4.2 Signed-off-by: Joffrey F --- compose/config/types.py | 38 +++++++++++++++++++++----------------- requirements.txt | 2 +- setup.py | 2 +- 3 files changed, 23 insertions(+), 19 deletions(-) diff --git a/compose/config/types.py b/compose/config/types.py index 4509bfe67cb..be26971c45b 100644 --- a/compose/config/types.py +++ b/compose/config/types.py @@ -295,24 +295,28 @@ def parse(cls, spec): if not isinstance(spec, dict): result = [] - for k, v in build_port_bindings([spec]).items(): - if '/' in k: - target, proto = k.split('/', 1) - else: - target, proto = (k, None) - for pub in v: - if pub is None: - result.append( - cls(target, None, proto, None, None) - ) - elif isinstance(pub, tuple): - result.append( - cls(target, pub[1], proto, None, pub[0]) - ) + try: + for k, v in build_port_bindings([spec]).items(): + if '/' in k: + target, proto = k.split('/', 1) else: - result.append( - cls(target, pub, proto, None, None) - ) + target, proto = (k, None) + for pub in v: + if pub is None: + result.append( + cls(target, None, proto, None, None) + ) + elif isinstance(pub, tuple): + result.append( + cls(target, pub[1], proto, None, pub[0]) + ) + else: + result.append( + cls(target, pub, proto, None, None) + ) + except ValueError as e: + raise ConfigurationError(str(e)) + return result return [cls( diff --git a/requirements.txt b/requirements.txt index c4545de1e1f..4d506b9f4e5 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.3.0 +docker==2.4.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 8dbb337cc24..0d5bd6adc7f 100644 --- a/setup.py +++ b/setup.py @@ -37,7 +37,7 @@ def find_version(*file_paths): 'requests >= 2.6.1, != 2.11.0, < 2.12', 'texttable >= 0.8.1, < 0.9', 'websocket-client >= 0.32.0, < 1.0', - 'docker >= 2.3.0, < 3.0', + 'docker >= 2.4.2, < 3.0', 'dockerpty >= 0.4.1, < 0.5', 'six >= 1.3.0, < 2', 'jsonschema >= 2.5.1, < 3', From 90e5d3447e917017e4c4c53225a3f30ccbf0db2a Mon Sep 17 00:00:00 2001 From: Evan Shaw Date: Sat, 1 Jul 2017 13:40:02 +1200 Subject: [PATCH 2876/4072] Always silence pull output with --parallel This is how things were prior to the addition of the --quiet flag. Making it not silent produces output that's weird and difficult to read. Signed-off-by: Evan Shaw --- compose/project.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/project.py b/compose/project.py index 7f7ade9a2ca..7951d29742d 100644 --- a/compose/project.py +++ b/compose/project.py @@ -467,7 +467,7 @@ def pull(self, service_names=None, ignore_pull_failures=False, parallel_pull=Fal if parallel_pull: def pull_service(service): - service.pull(ignore_pull_failures, silent=silent) + service.pull(ignore_pull_failures, True) parallel.parallel_execute( services, From 259b96748c48748627873852c9f30efd7a69d3a1 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 15 Jun 2017 17:01:41 -0700 Subject: [PATCH 2877/4072] Add support for service:name pid config Signed-off-by: Joffrey F --- compose/config/config.py | 2 + compose/config/sort_services.py | 1 + compose/config/validation.py | 15 +++++++ compose/project.py | 26 ++++++++++++ compose/service.py | 49 +++++++++++++++++++++- tests/acceptance/cli_test.py | 25 +++++++++++ tests/fixtures/pid-mode/docker-compose.yml | 17 ++++++++ tests/integration/service_test.py | 5 ++- 8 files changed, 137 insertions(+), 3 deletions(-) create mode 100644 tests/fixtures/pid-mode/docker-compose.yml diff --git a/compose/config/config.py b/compose/config/config.py index fdb20df19c9..fb54425666a 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -44,6 +44,7 @@ from .validation import validate_extends_file_path from .validation import validate_links from .validation import validate_network_mode +from .validation import validate_pid_mode from .validation import validate_service_constraints from .validation import validate_top_level_object from .validation import validate_ulimits @@ -667,6 +668,7 @@ def validate_service(service_config, service_names, config_file): validate_cpu(service_config) validate_ulimits(service_config) validate_network_mode(service_config, service_names) + validate_pid_mode(service_config, service_names) validate_depends_on(service_config, service_names) validate_links(service_config, service_names) diff --git a/compose/config/sort_services.py b/compose/config/sort_services.py index 20ac4461b37..42f548a6dd1 100644 --- a/compose/config/sort_services.py +++ b/compose/config/sort_services.py @@ -38,6 +38,7 @@ def get_service_dependents(service_dict, 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 == get_service_name_from_network_mode(service.get('pid')) or name in service.get('depends_on', [])) ] diff --git a/compose/config/validation.py b/compose/config/validation.py index 856f811c510..0b7961e5a7b 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -172,6 +172,21 @@ def validate_network_mode(service_config, service_names): "is undefined.".format(s=service_config, dep=dependency)) +def validate_pid_mode(service_config, service_names): + pid_mode = service_config.config.get('pid') + if not pid_mode: + return + + dependency = get_service_name_from_network_mode(pid_mode) + if not dependency: + return + if dependency not in service_names: + raise ConfigurationError( + "Service '{s.name}' uses the PID namespace of service '{dep}' which " + "is undefined.".format(s=service_config, dep=dependency) + ) + + def validate_links(service_config, service_names): for link in service_config.config.get('links', []): if link.split(':')[0] not in service_names: diff --git a/compose/project.py b/compose/project.py index 7951d29742d..28af45c7135 100644 --- a/compose/project.py +++ b/compose/project.py @@ -24,10 +24,13 @@ from .network import ProjectNetworks from .service import BuildAction from .service import ContainerNetworkMode +from .service import ContainerPidMode from .service import ConvergenceStrategy from .service import NetworkMode +from .service import PidMode from .service import Service from .service import ServiceNetworkMode +from .service import ServicePidMode from .utils import microseconds_from_time_nano from .volume import ProjectVolumes @@ -97,6 +100,7 @@ def from_config(cls, name, config_data, client): network_mode = project.get_network_mode( service_dict, list(service_networks.keys()) ) + pid_mode = project.get_pid_mode(service_dict) volumes_from = get_volumes_from(project, service_dict) if config_data.version != V1: @@ -121,6 +125,7 @@ def from_config(cls, name, config_data, client): network_mode=network_mode, volumes_from=volumes_from, secrets=secrets, + pid_mode=pid_mode, **service_dict) ) @@ -224,6 +229,27 @@ def get_network_mode(self, service_dict, networks): return NetworkMode(network_mode) + def get_pid_mode(self, service_dict): + pid_mode = service_dict.pop('pid', None) + if not pid_mode: + return PidMode(None) + + service_name = get_service_name_from_network_mode(pid_mode) + if service_name: + return ServicePidMode(self.get_service(service_name)) + + container_name = get_container_name_from_network_mode(pid_mode) + if container_name: + try: + return ContainerPidMode(Container.from_id(self.client, container_name)) + except APIError: + raise ConfigurationError( + "Service '{name}' uses the PID namespace of container '{dep}' which " + "does not exist.".format(name=service_dict['name'], dep=container_name) + ) + + return PidMode(pid_mode) + def start(self, service_names=None, **options): containers = [] diff --git a/compose/service.py b/compose/service.py index 7ee63771ad8..c4fd96c43a8 100644 --- a/compose/service.py +++ b/compose/service.py @@ -157,6 +157,7 @@ def __init__( networks=None, secrets=None, scale=None, + pid_mode=None, **options ): self.name = name @@ -166,6 +167,7 @@ def __init__( self.links = links or [] self.volumes_from = volumes_from or [] self.network_mode = network_mode or NetworkMode(None) + self.pid_mode = pid_mode or PidMode(None) self.networks = networks or {} self.secrets = secrets or [] self.scale_num = scale or 1 @@ -607,15 +609,19 @@ def config_dict(self): def get_dependency_names(self): net_name = self.network_mode.service_name + pid_namespace = self.pid_mode.service_name return ( self.get_linked_service_names() + self.get_volumes_from_names() + ([net_name] if net_name else []) + + ([pid_namespace] if pid_namespace else []) + list(self.options.get('depends_on', {}).keys()) ) def get_dependency_configs(self): net_name = self.network_mode.service_name + pid_namespace = self.pid_mode.service_name + configs = dict( [(name, None) for name in self.get_linked_service_names()] ) @@ -623,6 +629,7 @@ def get_dependency_configs(self): [(name, None) for name in self.get_volumes_from_names()] )) configs.update({net_name: None} if net_name else {}) + configs.update({pid_namespace: None} if pid_namespace else {}) configs.update(self.options.get('depends_on', {})) for svc, config in self.options.get('depends_on', {}).items(): if config['condition'] == CONDITION_STARTED: @@ -833,7 +840,7 @@ def _get_container_host_config(self, override_options, one_off=False): log_config=log_config, extra_hosts=options.get('extra_hosts'), read_only=options.get('read_only'), - pid_mode=options.get('pid'), + pid_mode=self.pid_mode.mode, security_opt=options.get('security_opt'), ipc_mode=options.get('ipc'), cgroup_parent=options.get('cgroup_parent'), @@ -1056,6 +1063,46 @@ def short_id_alias_exists(container, network): return container.short_id in aliases +class PidMode(object): + def __init__(self, mode): + self._mode = mode + + @property + def mode(self): + return self._mode + + @property + def service_name(self): + return None + + +class ServicePidMode(PidMode): + def __init__(self, service): + self.service = service + + @property + def service_name(self): + return self.service.name + + @property + def mode(self): + containers = self.service.containers() + if containers: + return 'container:' + containers[0].id + + log.warn( + "Service %s is trying to use reuse the PID namespace " + "of another service that is not running." % (self.service_name) + ) + return None + + +class ContainerPidMode(PidMode): + def __init__(self, container): + self.container = container + self._mode = 'container:{}'.format(container.id) + + class NetworkMode(object): """A `standard` network mode (ex: host, bridge)""" diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index ba0b5388803..9058fa35b34 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1183,6 +1183,31 @@ def test_up_handles_abort_on_container_exit_code(self): proc.wait() self.assertEqual(proc.returncode, 1) + @v2_only() + def test_up_with_pid_mode(self): + c = self.client.create_container( + 'busybox', 'top', name='composetest_pid_mode_container', + host_config={} + ) + self.addCleanup(self.client.remove_container, c, force=True) + self.client.start(c) + container_mode_source = 'container:{}'.format(c['Id']) + + self.base_dir = 'tests/fixtures/pid-mode' + + self.dispatch(['up', '-d'], None) + + service_mode_source = 'container:{}'.format( + self.project.get_service('container').containers()[0].id) + service_mode_container = self.project.get_service('service').containers()[0] + assert service_mode_container.get('HostConfig.PidMode') == service_mode_source + + container_mode_container = self.project.get_service('container').containers()[0] + assert container_mode_container.get('HostConfig.PidMode') == container_mode_source + + host_mode_container = self.project.get_service('host').containers()[0] + assert host_mode_container.get('HostConfig.PidMode') == 'host' + def test_exec_without_tty(self): self.base_dir = 'tests/fixtures/links-composefile' self.dispatch(['up', '-d', 'console']) diff --git a/tests/fixtures/pid-mode/docker-compose.yml b/tests/fixtures/pid-mode/docker-compose.yml new file mode 100644 index 00000000000..fece5a9f08b --- /dev/null +++ b/tests/fixtures/pid-mode/docker-compose.yml @@ -0,0 +1,17 @@ +version: "2.2" + +services: + service: + image: busybox + command: top + pid: "service:container" + + container: + image: busybox + command: top + pid: "container:composetest_pid_mode_container" + + host: + image: busybox + command: top + pid: host diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index c406a8d5e0e..ccd6c8b005c 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -36,6 +36,7 @@ from compose.service import ConvergencePlan from compose.service import ConvergenceStrategy from compose.service import NetworkMode +from compose.service import PidMode from compose.service import Service from tests.integration.testcases import v2_1_only from tests.integration.testcases import v2_2_only @@ -968,12 +969,12 @@ def test_network_mode_host(self): self.assertEqual(container.get('HostConfig.NetworkMode'), 'host') def test_pid_mode_none_defined(self): - service = self.create_service('web', pid=None) + service = self.create_service('web', pid_mode=None) container = create_and_start_container(service) self.assertEqual(container.get('HostConfig.PidMode'), '') def test_pid_mode_host(self): - service = self.create_service('web', pid='host') + service = self.create_service('web', pid_mode=PidMode('host')) container = create_and_start_container(service) self.assertEqual(container.get('HostConfig.PidMode'), 'host') From 9cdbb953ba627280770341eb48eecf17fdfdfe28 Mon Sep 17 00:00:00 2001 From: Evan Shaw Date: Sat, 25 Feb 2017 13:48:02 +1300 Subject: [PATCH 2878/4072] Align status output for parallel_execute Previously docker-compose would output lines that looked like: Starting service ... done Starting short ... Starting service-with-a-long-name ... done It's difficult to scan down this output and get an idea of what's happening. Now the statuses are aligned, and output looks like this: Starting service ... done Starting short ... Starting service-with-a-long-name ... done To me, this is quite a bit easier to read. Signed-off-by: Evan Shaw --- compose/parallel.py | 18 +++++++++++++----- tests/unit/parallel_test.py | 15 +++++++++++++++ 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/compose/parallel.py b/compose/parallel.py index 34fef71db75..a611fd6e0b0 100644 --- a/compose/parallel.py +++ b/compose/parallel.py @@ -38,7 +38,8 @@ def parallel_execute(objects, func, get_name, msg, get_deps=None, limit=None): writer = ParallelStreamWriter(stream, msg) for obj in objects: - writer.initialize(get_name(obj)) + writer.add_object(get_name(obj)) + writer.write_initial() events = parallel_execute_iter(objects, func, get_deps, limit) @@ -224,12 +225,18 @@ def __init__(self, stream, msg): self.stream = stream self.msg = msg self.lines = [] + self.width = 0 - def initialize(self, obj_index): + def add_object(self, obj_index): + self.lines.append(obj_index) + self.width = max(self.width, len(obj_index)) + + def write_initial(self): if self.msg is None: return - self.lines.append(obj_index) - self.stream.write("{} {} ... \r\n".format(self.msg, obj_index)) + for line in self.lines: + self.stream.write("{} {:<{width}} ... \r\n".format(self.msg, line, + width=self.width)) self.stream.flush() def write(self, obj_index, status): @@ -241,7 +248,8 @@ def write(self, obj_index, status): self.stream.write("%c[%dA" % (27, diff)) # erase self.stream.write("%c[2K\r" % 27) - self.stream.write("{} {} ... {}\r".format(self.msg, obj_index, status)) + self.stream.write("{} {:<{width}} ... {}\r".format(self.msg, obj_index, + status, width=self.width)) # move back down self.stream.write("%c[%dB" % (27, diff)) self.stream.flush() diff --git a/tests/unit/parallel_test.py b/tests/unit/parallel_test.py index d10948eb072..73728fdfd87 100644 --- a/tests/unit/parallel_test.py +++ b/tests/unit/parallel_test.py @@ -115,3 +115,18 @@ def process(x): assert (data_volume, None, APIError) in events assert (db, None, UpstreamError) in events assert (web, None, UpstreamError) in events + + +def test_parallel_execute_alignment(capsys): + results, errors = parallel_execute( + objects=["short", "a very long name"], + func=lambda x: x, + get_name=six.text_type, + msg="Aligning", + ) + + assert errors == {} + + _, err = capsys.readouterr() + a, b = err.split('\n')[:2] + assert a.index('...') == b.index('...') From 2dd5c7d51a878f358261e67105c13c91c7f4ae41 Mon Sep 17 00:00:00 2001 From: Andy Neff Date: Tue, 16 May 2017 14:21:18 -0400 Subject: [PATCH 2879/4072] Change --volume behavior to add instead of replace mounts Signed-off-by: Andy Neff --- compose/service.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/compose/service.py b/compose/service.py index c4fd96c43a8..326f505200a 100644 --- a/compose/service.py +++ b/compose/service.py @@ -736,6 +736,8 @@ def _get_container_create_options( container_options = dict( (k, self.options[k]) for k in DOCKER_CONFIG_KEYS if k in self.options) + override_options['volumes'] = (container_options.get('volumes', []) + + override_options.get('volumes', [])) container_options.update(override_options) if not container_options.get('name'): From 2b7ed24bc0e86ee882106a4f7337d6f890252c2d Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 23 Jun 2017 13:38:38 -0700 Subject: [PATCH 2880/4072] Fix override volume merging + add acceptance test Signed-off-by: Joffrey F --- compose/service.py | 8 +++- tests/acceptance/cli_test.py | 37 ++++++++++++++++--- .../docker-compose.merge.yml | 9 +++++ 3 files changed, 46 insertions(+), 8 deletions(-) create mode 100644 tests/fixtures/simple-composefile-volume-ready/docker-compose.merge.yml diff --git a/compose/service.py b/compose/service.py index 326f505200a..300ec2852eb 100644 --- a/compose/service.py +++ b/compose/service.py @@ -736,8 +736,7 @@ def _get_container_create_options( container_options = dict( (k, self.options[k]) for k in DOCKER_CONFIG_KEYS if k in self.options) - override_options['volumes'] = (container_options.get('volumes', []) + - override_options.get('volumes', [])) + override_volumes = override_options.pop('volumes', []) container_options.update(override_options) if not container_options.get('name'): @@ -761,6 +760,11 @@ def _get_container_create_options( formatted_ports(container_options.get('ports', [])), self.options) + if 'volumes' in container_options or override_volumes: + container_options['volumes'] = list(set( + container_options.get('volumes', []) + override_volumes + )) + container_options['environment'] = merge_environment( self.options.get('environment'), override_options.get('environment')) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 9058fa35b34..9d2de622dc8 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -609,8 +609,13 @@ def test_run_one_off_with_volume(self): 'simple', 'test', '-f', '/data/example.txt' ], returncode=0) - # FIXME: does not work with Python 3 - # assert cmd_result.stdout.strip() == 'FILE_CONTENT' + + service = self.project.get_service('simple') + container_data = service.containers(one_off=OneOffFilter.only, stopped=True)[0] + mount = container_data.get('Mounts')[0] + assert mount['Source'] == volume_path + assert mount['Destination'] == '/data' + assert mount['Type'] == 'bind' def test_run_one_off_with_multiple_volumes(self): self.base_dir = 'tests/fixtures/simple-composefile-volume-ready' @@ -624,8 +629,6 @@ def test_run_one_off_with_multiple_volumes(self): 'simple', 'test', '-f', '/data/example.txt' ], returncode=0) - # FIXME: does not work with Python 3 - # assert cmd_result.stdout.strip() == 'FILE_CONTENT' self.dispatch([ 'run', @@ -634,8 +637,30 @@ def test_run_one_off_with_multiple_volumes(self): 'simple', 'test', '-f' '/data1/example.txt' ], returncode=0) - # FIXME: does not work with Python 3 - # assert cmd_result.stdout.strip() == 'FILE_CONTENT' + + def test_run_one_off_with_volume_merge(self): + self.base_dir = 'tests/fixtures/simple-composefile-volume-ready' + volume_path = os.path.abspath(os.path.join(os.getcwd(), self.base_dir, 'files')) + create_host_file(self.client, os.path.join(volume_path, 'example.txt')) + + self.dispatch([ + '-f', 'docker-compose.merge.yml', + 'run', + '-v', '{}:/data'.format(volume_path), + 'simple', + 'test', '-f', '/data/example.txt' + ], returncode=0) + + service = self.project.get_service('simple') + container_data = service.containers(one_off=OneOffFilter.only, stopped=True)[0] + mounts = container_data.get('Mounts') + assert len(mounts) == 2 + config_mount = [m for m in mounts if m['Destination'] == '/data1'][0] + override_mount = [m for m in mounts if m['Destination'] == '/data'][0] + + assert config_mount['Type'] == 'volume' + assert override_mount['Source'] == volume_path + assert override_mount['Type'] == 'bind' def test_create_with_force_recreate_and_no_recreate(self): self.dispatch( diff --git a/tests/fixtures/simple-composefile-volume-ready/docker-compose.merge.yml b/tests/fixtures/simple-composefile-volume-ready/docker-compose.merge.yml new file mode 100644 index 00000000000..fe717151677 --- /dev/null +++ b/tests/fixtures/simple-composefile-volume-ready/docker-compose.merge.yml @@ -0,0 +1,9 @@ +version: '2.2' +services: + simple: + image: busybox:latest + volumes: + - datastore:/data1 + +volumes: + datastore: From 55bd02f303dd5f4e12e3299d11dee109c246f793 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 3 Jul 2017 17:12:39 -0700 Subject: [PATCH 2881/4072] `scale` property should be merged according to standard scalar rules Signed-off-by: Joffrey F --- compose/config/config.py | 1 + tests/unit/config/config_test.py | 13 +++++++++++++ 2 files changed, 14 insertions(+) diff --git a/compose/config/config.py b/compose/config/config.py index fb54425666a..86cf1b39d94 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -116,6 +116,7 @@ 'logging', 'network_mode', 'init', + 'scale', ] DOCKER_VALID_URL_PREFIXES = ( diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 6178447ae64..721a428e184 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -2098,6 +2098,19 @@ def test_merge_credential_spec(self): actual = config.merge_service_dicts(base, override, V3_3) assert actual['credential_spec'] == override['credential_spec'] + def test_merge_scale(self): + base = { + 'image': 'bar', + 'scale': 2, + } + + override = { + 'scale': 4, + } + + actual = config.merge_service_dicts(base, override, V2_2) + assert actual == {'image': 'bar', 'scale': 4} + def test_external_volume_config(self): config_details = build_config_details({ 'version': '2', From e33041582fc7dda54a1412f1e56209aa8671c280 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 5 Jul 2017 15:13:45 -0700 Subject: [PATCH 2882/4072] Add "network" field to build configuration Signed-off-by: Joffrey F --- compose/config/config.py | 1 + compose/config/config_schema_v2.2.json | 3 ++- compose/service.py | 3 ++- tests/integration/service_test.py | 24 ++++++++++++++++++++++++ tests/unit/service_test.py | 2 ++ 5 files changed, 31 insertions(+), 2 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 86cf1b39d94..4be25188201 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -975,6 +975,7 @@ def to_dict(service): md = MergeDict(to_dict(base), to_dict(override)) md.merge_scalar('context') md.merge_scalar('dockerfile') + md.merge_scalar('network') md.merge_mapping('args', parse_build_arguments) md.merge_field('cache_from', merge_unique_items_lists, default=[]) md.merge_mapping('labels', parse_labels) diff --git a/compose/config/config_schema_v2.2.json b/compose/config/config_schema_v2.2.json index 87ba26ae494..9181e606b26 100644 --- a/compose/config/config_schema_v2.2.json +++ b/compose/config/config_schema_v2.2.json @@ -60,7 +60,8 @@ "dockerfile": {"type": "string"}, "args": {"$ref": "#/definitions/list_or_dict"}, "labels": {"$ref": "#/definitions/list_or_dict"}, - "cache_from": {"$ref": "#/definitions/list_of_strings"} + "cache_from": {"$ref": "#/definitions/list_of_strings"}, + "network": {"type": "string"} }, "additionalProperties": false } diff --git a/compose/service.py b/compose/service.py index 300ec2852eb..53ad4636242 100644 --- a/compose/service.py +++ b/compose/service.py @@ -906,7 +906,8 @@ def build(self, no_cache=False, pull=False, force_rm=False, build_args_override= dockerfile=build_opts.get('dockerfile', None), cache_from=build_opts.get('cache_from', None), labels=build_opts.get('labels', None), - buildargs=build_args + buildargs=build_args, + network_mode=build_opts.get('network', None), ) try: diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index ccd6c8b005c..350f7398b27 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -717,6 +717,30 @@ def test_build_with_build_labels(self): assert service.image() assert service.image()['Config']['Labels']['com.docker.compose.test'] == 'true' + def test_build_with_network(self): + base_dir = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, base_dir) + with open(os.path.join(base_dir, 'Dockerfile'), 'w') as f: + f.write('FROM busybox\n') + f.write('RUN ping -c1 google.local\n') + + net_container = self.client.create_container( + 'busybox', 'top', host_config=self.client.create_host_config( + extra_hosts={'google.local': '8.8.8.8'} + ), name='composetest_build_network' + ) + + self.addCleanup(self.client.remove_container, net_container, force=True) + self.client.start(net_container) + + service = self.create_service('buildwithnet', build={ + 'context': text_type(base_dir), + 'network': 'container:{}'.format(net_container['Id']) + }) + + service.build() + assert service.image() + def test_start_container_stays_unprivileged(self): service = self.create_service('web') container = create_and_start_container(service).inspect() diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 7b7a078f8cd..2b0a2762dc8 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -473,6 +473,7 @@ def test_create_container(self): buildargs={}, labels=None, cache_from=None, + network_mode=None, ) def test_ensure_image_exists_no_build(self): @@ -511,6 +512,7 @@ def test_ensure_image_exists_force_build(self): buildargs={}, labels=None, cache_from=None, + network_mode=None, ) def test_build_does_not_pull(self): From abd395a5f33ca68a50a19292538db4297562f9fd Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 3 Jul 2017 15:32:22 -0700 Subject: [PATCH 2883/4072] Add 'socks' extra to help with proxy environment. SOCKS support will be included in the bundled (binary) version Update some packages in requirements.txt and add some implicit deps Signed-off-by: Joffrey F --- requirements.txt | 22 ++++++++++++++-------- setup.py | 1 + 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/requirements.txt b/requirements.txt index 4d506b9f4e5..844921ffd11 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,16 +1,22 @@ -PyYAML==3.11 +PySocks==1.6.7 +PyYAML==3.12 backports.ssl-match-hostname==3.5.0.1; python_version < '3' -cached-property==1.2.0 -colorama==0.3.7 +cached-property==1.3.0 +certifi==2017.4.17 +chardet==3.0.4 +colorama==0.3.9 docker==2.4.2 +docker-pycreds==0.2.1 dockerpty==0.4.1 -docopt==0.6.1 -enum34==1.0.4; python_version < '3.4' +docopt==0.6.2 +enum34==1.1.6; python_version < '3.4' functools32==3.2.3.post2; python_version < '3.2' -ipaddress==1.0.16 -jsonschema==2.5.1 +idna==2.5 +ipaddress==1.0.18 +jsonschema==2.6.0 pypiwin32==219; sys_platform == 'win32' requests==2.11.1 six==1.10.0 -texttable==0.8.4 +texttable==0.8.8 +urllib3==1.21.1 websocket-client==0.32.0 diff --git a/setup.py b/setup.py index 0d5bd6adc7f..dab7a6eea2c 100644 --- a/setup.py +++ b/setup.py @@ -56,6 +56,7 @@ def find_version(*file_paths): ':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'], + 'socks': ['PySocks >= 1.5.6, != 1.5.7, < 2'], } From d94fa5428ebe514ee2cb1da53486f0e8f028966c Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 5 Jul 2017 19:21:07 -0700 Subject: [PATCH 2884/4072] Make sure y/n values are quoted in serialized output Signed-off-by: Joffrey F --- compose/config/serialize.py | 15 ++++++++++++++- tests/unit/config/config_test.py | 18 ++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/compose/config/serialize.py b/compose/config/serialize.py index beafe02b965..306f86969b7 100644 --- a/compose/config/serialize.py +++ b/compose/config/serialize.py @@ -21,11 +21,23 @@ def serialize_dict_type(dumper, data): return dumper.represent_dict(data.repr()) +def serialize_string(dumper, data): + """ Ensure boolean-like strings are quoted in the output """ + representer = dumper.represent_str if six.PY3 else dumper.represent_unicode + if data.lower() in ('y', 'n', 'yes', 'no', 'on', 'off', 'true', 'false'): + # Empirically only y/n appears to be an issue, but this might change + # depending on which PyYaml version is being used. Err on safe side. + return dumper.represent_scalar('tag:yaml.org,2002:str', data, style='"') + return representer(data) + + yaml.SafeDumper.add_representer(types.VolumeFromSpec, serialize_config_type) yaml.SafeDumper.add_representer(types.VolumeSpec, serialize_config_type) yaml.SafeDumper.add_representer(types.ServiceSecret, serialize_dict_type) yaml.SafeDumper.add_representer(types.ServiceConfig, serialize_dict_type) yaml.SafeDumper.add_representer(types.ServicePort, serialize_dict_type) +yaml.SafeDumper.add_representer(str, serialize_string) +yaml.SafeDumper.add_representer(six.text_type, serialize_string) def denormalize_config(config, image_digests=None): @@ -58,7 +70,8 @@ def serialize_config(config, image_digests=None): denormalize_config(config, image_digests), default_flow_style=False, indent=2, - width=80) + width=80 + ) def serialize_ns_time_value(value): diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 721a428e184..6731a6bbcbe 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -4163,3 +4163,21 @@ def test_serialize_configs(self): assert secret_sort(serialized_service['configs']) == secret_sort(service_dict['configs']) assert 'configs' in serialized_config assert serialized_config['configs']['two'] == configs_dict['two'] + + def test_serialize_bool_string(self): + cfg = { + 'version': '2.2', + 'services': { + 'web': { + 'image': 'example/web', + 'command': 'true', + 'environment': {'FOO': 'Y', 'BAR': 'on'} + } + } + } + config_dict = config.load(build_config_details(cfg)) + + serialized_config = serialize_config(config_dict) + assert 'command: "true"\n' in serialized_config + assert 'FOO: "Y"\n' in serialized_config + assert 'BAR: "on"\n' in serialized_config From 36772b555c976d81daca120ac15320fe13a605ee Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 6 Jul 2017 17:54:45 -0700 Subject: [PATCH 2885/4072] Code warning for the well-intentioned folks that keep wanting to change this Signed-off-by: Joffrey F --- compose/cli/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/compose/cli/__init__.py b/compose/cli/__init__.py index 379059c1a46..2574a311f24 100644 --- a/compose/cli/__init__.py +++ b/compose/cli/__init__.py @@ -17,6 +17,8 @@ env[str('PIP_DISABLE_PIP_VERSION_CHECK')] = str('1') s_cmd = subprocess.Popen( + # DO NOT replace this call with a `sys.executable` call. It breaks the binary + # distribution (with the binary calling itself recursively over and over). ['pip', 'freeze'], stderr=subprocess.PIPE, stdout=subprocess.PIPE, env=env ) From e52f019ac89cda861720aca3c1f25de58e1acf11 Mon Sep 17 00:00:00 2001 From: Vadim Semenov Date: Thu, 15 Jun 2017 16:55:18 +0300 Subject: [PATCH 2886/4072] Optimize "extends" without file specification Loading the same config file add about 100ms per each extension service, which results in painfully slow CLI calls when a config consists of a couple of dozens of services. This patch makes Compose re-use config files. Signed-off-by: Vadim Semenov --- compose/config/config.py | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 4be25188201..2b1b99104b2 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -570,12 +570,21 @@ def validate_and_construct_extends(self): 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( - extends_file, self.environment, service_name=service_name - ) - service_config = extended_file.get_service(service_name) + if config_path == self.service_config.filename: + try: + service_config = self.config_file.get_service(service_name) + except KeyError: + raise ConfigurationError( + "Cannot extend service '{}' in {}: Service not found".format( + service_name, config_path) + ) + else: + extends_file = ConfigFile.from_filename(config_path) + validate_config_version([self.config_file, extends_file]) + extended_file = process_config_file( + extends_file, self.environment, service_name=service_name + ) + service_config = extended_file.get_service(service_name) return config_path, service_config, service_name From 8f0ef26a734df37ea830956f25aee7ab9b464a04 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 6 Jul 2017 17:25:41 -0700 Subject: [PATCH 2887/4072] Improved version comparisons throughout the codebase Signed-off-by: Joffrey F --- compose/config/config.py | 17 ++++++----- compose/config/errors.py | 2 +- compose/config/interpolation.py | 3 +- compose/config/serialize.py | 9 +++--- compose/const.py | 18 +++++++----- compose/version.py | 10 +++++++ tests/acceptance/cli_test.py | 9 ++++-- tests/integration/project_test.py | 22 +++++++------- tests/integration/testcases.py | 39 +++++++++++-------------- tests/unit/bundle_test.py | 3 +- tests/unit/config/config_test.py | 8 ++--- tests/unit/config/interpolation_test.py | 8 +++-- tests/unit/project_test.py | 28 +++++++++--------- 13 files changed, 97 insertions(+), 79 deletions(-) create mode 100644 compose/version.py diff --git a/compose/config/config.py b/compose/config/config.py index 2b1b99104b2..f5053af8acc 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -18,6 +18,7 @@ from ..utils import build_string_dict from ..utils import parse_nanoseconds_int from ..utils import splitdrive +from ..version import ComposeVersion from .environment import env_vars_from_file from .environment import Environment from .environment import split_env @@ -188,15 +189,16 @@ def version(self): if version == '1': raise ConfigurationError( 'Version in "{}" is invalid. {}' - .format(self.filename, VERSION_EXPLANATION)) + .format(self.filename, VERSION_EXPLANATION) + ) if version == '2': - version = const.COMPOSEFILE_V2_0 + return const.COMPOSEFILE_V2_0 if version == '3': - version = const.COMPOSEFILE_V3_0 + return const.COMPOSEFILE_V3_0 - return version + return ComposeVersion(version) def get_service(self, name): return self.get_service_dicts()[name] @@ -496,7 +498,7 @@ def process_config_file(config_file, environment, service_name=None): 'service', environment) - if config_file.version != V1: + if config_file.version > V1: processed_config = dict(config_file.config) processed_config['services'] = services processed_config['volumes'] = interpolate_config_section( @@ -509,14 +511,13 @@ def process_config_file(config_file, environment, service_name=None): config_file.get_networks(), 'network', environment) - if config_file.version in (const.COMPOSEFILE_V3_1, const.COMPOSEFILE_V3_2, - const.COMPOSEFILE_V3_3): + if config_file.version >= const.COMPOSEFILE_V3_1: processed_config['secrets'] = interpolate_config_section( config_file, config_file.get_secrets(), 'secrets', environment) - if config_file.version in (const.COMPOSEFILE_V3_3): + if config_file.version >= const.COMPOSEFILE_V3_3: processed_config['configs'] = interpolate_config_section( config_file, config_file.get_configs(), diff --git a/compose/config/errors.py b/compose/config/errors.py index ac1d3ac1976..f5c038088d3 100644 --- a/compose/config/errors.py +++ b/compose/config/errors.py @@ -4,7 +4,7 @@ VERSION_EXPLANATION = ( '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", "3.1", "3.2") and place ' + 'Either specify a supported version (e.g "2.2" or "3.3") 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/interpolation.py b/compose/config/interpolation.py index 1b270b9eab6..b13ac591aad 100644 --- a/compose/config/interpolation.py +++ b/compose/config/interpolation.py @@ -7,7 +7,6 @@ import six from .errors import ConfigurationError -from compose.const import COMPOSEFILE_V1 as V1 from compose.const import COMPOSEFILE_V2_0 as V2_0 @@ -28,7 +27,7 @@ def interpolate(self, string): def interpolate_environment_variables(version, config, section, environment): - if version in (V2_0, V1): + if version <= V2_0: interpolator = Interpolator(Template, environment) else: interpolator = Interpolator(TemplateWithDefaults, environment) diff --git a/compose/config/serialize.py b/compose/config/serialize.py index 306f86969b7..84521848db4 100644 --- a/compose/config/serialize.py +++ b/compose/config/serialize.py @@ -7,9 +7,8 @@ from compose.config import types from compose.const import COMPOSEFILE_V1 as V1 from compose.const import COMPOSEFILE_V2_1 as V2_1 -from compose.const import COMPOSEFILE_V2_2 as V2_2 +from compose.const import COMPOSEFILE_V3_0 as V3_0 from compose.const import COMPOSEFILE_V3_2 as V3_2 -from compose.const import COMPOSEFILE_V3_3 as V3_3 def serialize_config_type(dumper, data): @@ -41,7 +40,7 @@ def serialize_string(dumper, data): def denormalize_config(config, image_digests=None): - result = {'version': V2_1 if config.version == V1 else config.version} + result = {'version': str(V2_1) if config.version == V1 else str(config.version)} denormalized_services = [ denormalize_service_dict( service_dict, @@ -107,7 +106,7 @@ def denormalize_service_dict(service_dict, version, image_digest=None): if version == V1 and 'network_mode' not in service_dict: service_dict['network_mode'] = 'bridge' - if 'depends_on' in service_dict and version not in (V2_1, V2_2): + if 'depends_on' in service_dict and (version < V2_1 or version >= V3_0): service_dict['depends_on'] = sorted([ svc for svc in service_dict['depends_on'].keys() ]) @@ -122,7 +121,7 @@ def denormalize_service_dict(service_dict, version, image_digest=None): service_dict['healthcheck']['timeout'] ) - if 'ports' in service_dict and version not in (V3_2, V3_3): + if 'ports' in service_dict and version < V3_2: service_dict['ports'] = [ p.legacy_repr() if isinstance(p, types.ServicePort) else p for p in service_dict['ports'] diff --git a/compose/const.py b/compose/const.py index 36f213897a2..e46de8a733d 100644 --- a/compose/const.py +++ b/compose/const.py @@ -3,6 +3,8 @@ import sys +from .version import ComposeVersion + DEFAULT_TIMEOUT = 10 HTTP_TIMEOUT = 60 IMAGE_EVENTS = ['delete', 'import', 'load', 'pull', 'push', 'save', 'tag', 'untag'] @@ -19,15 +21,15 @@ SECRETS_PATH = '/run/secrets' -COMPOSEFILE_V1 = '1' -COMPOSEFILE_V2_0 = '2.0' -COMPOSEFILE_V2_1 = '2.1' -COMPOSEFILE_V2_2 = '2.2' +COMPOSEFILE_V1 = ComposeVersion('1') +COMPOSEFILE_V2_0 = ComposeVersion('2.0') +COMPOSEFILE_V2_1 = ComposeVersion('2.1') +COMPOSEFILE_V2_2 = ComposeVersion('2.2') -COMPOSEFILE_V3_0 = '3.0' -COMPOSEFILE_V3_1 = '3.1' -COMPOSEFILE_V3_2 = '3.2' -COMPOSEFILE_V3_3 = '3.3' +COMPOSEFILE_V3_0 = ComposeVersion('3.0') +COMPOSEFILE_V3_1 = ComposeVersion('3.1') +COMPOSEFILE_V3_2 = ComposeVersion('3.2') +COMPOSEFILE_V3_3 = ComposeVersion('3.3') API_VERSIONS = { COMPOSEFILE_V1: '1.21', diff --git a/compose/version.py b/compose/version.py new file mode 100644 index 00000000000..0532e16c717 --- /dev/null +++ b/compose/version.py @@ -0,0 +1,10 @@ +from __future__ import absolute_import +from __future__ import unicode_literals + +from distutils.version import LooseVersion + + +class ComposeVersion(LooseVersion): + """ A hashable version object """ + def __hash__(self): + return hash(self.vstring) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 9d2de622dc8..343e4974f9f 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -850,8 +850,13 @@ def test_up_with_networks(self): # Two networks were created: back and front assert sorted(n['Name'].split('/')[-1] for n in networks) == [back_name, front_name] - back_network = [n for n in networks if n['Name'] == back_name][0] - front_network = [n for n in networks if n['Name'] == front_name][0] + # lookup by ID instead of name in case of duplicates + back_network = self.client.inspect_network( + [n for n in networks if n['Name'] == back_name][0]['Id'] + ) + front_network = self.client.inspect_network( + [n for n in networks if n['Name'] == front_name][0]['Id'] + ) web_container = self.project.get_service('web').containers()[0] app_container = self.project.get_service('app').containers()[0] diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 6731f25dd3a..ce95c5f21c4 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -34,6 +34,7 @@ from compose.project import ProjectError from compose.service import ConvergenceStrategy from tests.integration.testcases import v2_1_only +from tests.integration.testcases import v2_2_only from tests.integration.testcases import v2_only from tests.integration.testcases import v3_only @@ -150,7 +151,7 @@ def test_network_mode_from_service(self): name='composetest', client=self.client, config_data=load_config({ - 'version': V2_0, + 'version': str(V2_0), 'services': { 'net': { 'image': 'busybox:latest', @@ -178,7 +179,7 @@ def get_project(): return Project.from_config( name='composetest', config_data=load_config({ - 'version': V2_0, + 'version': str(V2_0), 'services': { 'web': { 'image': 'busybox:latest', @@ -820,7 +821,7 @@ def test_up_with_network_static_addresses(self): def test_up_with_enable_ipv6(self): self.require_api_version('1.23') config_data = build_config( - version=V2_0, + version=V2_1, services=[{ 'name': 'web', 'image': 'busybox:latest', @@ -1003,7 +1004,7 @@ def test_project_up_with_network_label(self): network_name = 'network_with_label' config_data = build_config( - version=V2_0, + version=V2_1, services=[{ 'name': 'web', 'image': 'busybox:latest', @@ -1063,7 +1064,7 @@ def test_project_up_with_volume_labels(self): volume_name = 'volume_with_label' config_data = build_config( - version=V2_0, + version=V2_1, services=[{ 'name': 'web', 'image': 'busybox:latest', @@ -1103,7 +1104,7 @@ def test_project_up_logging_with_multiple_files(self): base_file = config.ConfigFile( 'base.yml', { - 'version': V2_0, + 'version': str(V2_0), 'services': { 'simple': {'image': 'busybox:latest', 'command': 'top'}, 'another': { @@ -1122,7 +1123,7 @@ def test_project_up_logging_with_multiple_files(self): override_file = config.ConfigFile( 'override.yml', { - 'version': V2_0, + 'version': str(V2_0), 'services': { 'another': { 'logging': { @@ -1155,7 +1156,7 @@ def test_project_up_port_mappings_with_multiple_files(self): base_file = config.ConfigFile( 'base.yml', { - 'version': V2_0, + 'version': str(V2_0), 'services': { 'simple': { 'image': 'busybox:latest', @@ -1168,7 +1169,7 @@ def test_project_up_port_mappings_with_multiple_files(self): override_file = config.ConfigFile( 'override.yml', { - 'version': V2_0, + 'version': str(V2_0), 'services': { 'simple': { 'ports': ['1234:1234'] @@ -1186,6 +1187,7 @@ def test_project_up_port_mappings_with_multiple_files(self): containers = project.containers() self.assertEqual(len(containers), 1) + @v2_2_only() def test_project_up_config_scale(self): config_data = build_config( version=V2_2, @@ -1454,7 +1456,7 @@ def test_project_up_named_volumes_in_binds(self): base_file = config.ConfigFile( 'base.yml', { - 'version': V2_0, + 'version': str(V2_0), 'services': { 'simple': { 'image': 'busybox:latest', diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index 1e0d6321502..7d600f32300 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -1,11 +1,10 @@ from __future__ import absolute_import from __future__ import unicode_literals -import functools import os +import pytest from docker.utils import version_lt -from pytest import skip from .. import unittest from ..helpers import is_cluster @@ -17,7 +16,8 @@ from compose.const import COMPOSEFILE_V2_0 as V2_0 from compose.const import COMPOSEFILE_V2_0 as V2_1 from compose.const import COMPOSEFILE_V2_2 as V2_2 -from compose.const import COMPOSEFILE_V3_2 as V3_2 +from compose.const import COMPOSEFILE_V3_0 as V3_0 +from compose.const import COMPOSEFILE_V3_3 as V3_3 from compose.const import LABEL_PROJECT from compose.progress_stream import stream_output from compose.service import Service @@ -43,7 +43,7 @@ def format_link(link): def engine_max_version(): if 'DOCKER_VERSION' not in os.environ: - return V3_2 + return V3_3 version = os.environ['DOCKER_VERSION'].partition('-')[0] if version_lt(version, '1.10'): return V1 @@ -51,37 +51,32 @@ def engine_max_version(): return V2_0 if version_lt(version, '1.13'): return V2_1 - return V3_2 + if version_lt(version, '17.06'): + return V2_2 + return V3_3 -def build_version_required_decorator(ignored_versions): - def decorator(f): - @functools.wraps(f) - def wrapper(self, *args, **kwargs): - 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 - - return decorator +def min_version_skip(version): + return pytest.mark.skipif( + engine_max_version() < version, + reason="Engine version %s is too low" % version + ) def v2_only(): - return build_version_required_decorator((V1,)) + return min_version_skip(V2_0) def v2_1_only(): - return build_version_required_decorator((V1, V2_0)) + return min_version_skip(V2_1) def v2_2_only(): - return build_version_required_decorator((V1, V2_0, V2_1)) + return min_version_skip(V2_0) def v3_only(): - return build_version_required_decorator((V1, V2_0, V2_1, V2_2)) + return min_version_skip(V3_0) class DockerClientTestCase(unittest.TestCase): @@ -137,7 +132,7 @@ def check_build(self, *args, **kwargs): def require_api_version(self, minimum): api_version = self.client.version()['ApiVersion'] if version_lt(api_version, minimum): - skip("API version is too low ({} < {})".format(api_version, minimum)) + pytest.skip("API version is too low ({} < {})".format(api_version, minimum)) def get_volume_data(self, volume_name): if not is_cluster(self.client): diff --git a/tests/unit/bundle_test.py b/tests/unit/bundle_test.py index 3c6e9ec5373..8477952022c 100644 --- a/tests/unit/bundle_test.py +++ b/tests/unit/bundle_test.py @@ -9,6 +9,7 @@ from compose import service from compose.cli.errors import UserError from compose.config.config import Config +from compose.const import COMPOSEFILE_V2_0 as V2_0 @pytest.fixture @@ -74,7 +75,7 @@ def test_to_bundle(): {'name': 'b', 'build': './b'}, ] config = Config( - version=2, + version=V2_0, services=services, volumes={'special': {}}, networks={'extra': {}}, diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 6731a6bbcbe..ac742a19912 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -378,7 +378,7 @@ def test_load_config_link_local_ips_network(self): base_file = config.ConfigFile( 'base.yaml', { - 'version': V2_1, + 'version': str(V2_1), 'services': { 'web': { 'image': 'example/web', @@ -830,7 +830,7 @@ def test_load_with_build_labels(self): service = config.load( build_config_details( { - 'version': V3_3, + 'version': str(V3_3), 'services': { 'web': { 'build': { @@ -1523,7 +1523,7 @@ def test_dns_opt_option(self): def test_isolation_option(self): actual = config.load(build_config_details({ - 'version': V2_1, + 'version': str(V2_1), 'services': { 'web': { 'image': 'win10', @@ -4122,7 +4122,7 @@ def test_serialize_secrets(self): assert serialized_config['secrets']['two'] == secrets_dict['two'] def test_serialize_ports(self): - config_dict = config.Config(version='2.0', services=[ + config_dict = config.Config(version=V2_0, services=[ { 'ports': [types.ServicePort('80', '8080', None, None, None)], 'image': 'alpine', diff --git a/tests/unit/config/interpolation_test.py b/tests/unit/config/interpolation_test.py index 256c74d9bed..018a5621a4c 100644 --- a/tests/unit/config/interpolation_test.py +++ b/tests/unit/config/interpolation_test.py @@ -8,6 +8,8 @@ from compose.config.interpolation import Interpolator from compose.config.interpolation import InvalidInterpolation from compose.config.interpolation import TemplateWithDefaults +from compose.const import COMPOSEFILE_V2_0 as V2_0 +from compose.const import COMPOSEFILE_V3_1 as V3_1 @pytest.fixture @@ -50,7 +52,7 @@ def test_interpolate_environment_variables_in_services(mock_env): } } } - value = interpolate_environment_variables("2.0", services, 'service', mock_env) + value = interpolate_environment_variables(V2_0, services, 'service', mock_env) assert value == expected @@ -75,7 +77,7 @@ def test_interpolate_environment_variables_in_volumes(mock_env): }, 'other': {}, } - value = interpolate_environment_variables("2.0", volumes, 'volume', mock_env) + value = interpolate_environment_variables(V2_0, volumes, 'volume', mock_env) assert value == expected @@ -100,7 +102,7 @@ def test_interpolate_environment_variables_in_secrets(mock_env): }, 'other': {}, } - value = interpolate_environment_variables("3.1", secrets, 'volume', mock_env) + value = interpolate_environment_variables(V3_1, secrets, 'volume', mock_env) assert value == expected diff --git a/tests/unit/project_test.py b/tests/unit/project_test.py index c5366c395ca..e5f1a175f26 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -10,6 +10,8 @@ from .. import unittest from compose.config.config import Config from compose.config.types import VolumeFromSpec +from compose.const import COMPOSEFILE_V1 as V1 +from compose.const import COMPOSEFILE_V2_0 as V2_0 from compose.const import LABEL_SERVICE from compose.container import Container from compose.project import Project @@ -21,9 +23,9 @@ class ProjectTest(unittest.TestCase): def setUp(self): self.mock_client = mock.create_autospec(docker.APIClient) - def test_from_config(self): + def test_from_config_v1(self): config = Config( - version=None, + version=V1, services=[ { 'name': 'web', @@ -53,7 +55,7 @@ def test_from_config(self): def test_from_config_v2(self): config = Config( - version=2, + version=V2_0, services=[ { 'name': 'web', @@ -166,7 +168,7 @@ def test_use_volumes_from_container(self): name='test', client=self.mock_client, config_data=Config( - version=None, + version=V2_0, services=[{ 'name': 'test', 'image': 'busybox:latest', @@ -194,7 +196,7 @@ def test_use_volumes_from_service_no_container(self): name='test', client=self.mock_client, config_data=Config( - version=None, + version=V2_0, services=[ { 'name': 'vol', @@ -221,7 +223,7 @@ def test_use_volumes_from_service_container(self): name='test', client=None, config_data=Config( - version=None, + version=V2_0, services=[ { 'name': 'vol', @@ -361,7 +363,7 @@ def test_net_unset(self): name='test', client=self.mock_client, config_data=Config( - version=None, + version=V1, services=[ { 'name': 'test', @@ -386,7 +388,7 @@ def test_use_net_from_container(self): name='test', client=self.mock_client, config_data=Config( - version=None, + version=V2_0, services=[ { 'name': 'test', @@ -417,7 +419,7 @@ def test_use_net_from_service(self): name='test', client=self.mock_client, config_data=Config( - version=None, + version=V2_0, services=[ { 'name': 'aaa', @@ -444,7 +446,7 @@ def test_uses_default_network_true(self): name='test', client=self.mock_client, config_data=Config( - version=2, + version=V2_0, services=[ { 'name': 'foo', @@ -465,7 +467,7 @@ def test_uses_default_network_false(self): name='test', client=self.mock_client, config_data=Config( - version=2, + version=V2_0, services=[ { 'name': 'foo', @@ -500,7 +502,7 @@ def test_container_without_name(self): name='test', client=self.mock_client, config_data=Config( - version=None, + version=V2_0, services=[{ 'name': 'web', 'image': 'busybox:latest', @@ -518,7 +520,7 @@ def test_down_with_no_resources(self): name='test', client=self.mock_client, config_data=Config( - version='2', + version=V2_0, services=[{ 'name': 'web', 'image': 'busybox:latest', From 50d405fea33b9eac47954d507c967aa530a465f4 Mon Sep 17 00:00:00 2001 From: Alexey Rokhin Date: Wed, 17 May 2017 16:18:28 +0300 Subject: [PATCH 2888/4072] skip cpu_percent test for Linux Signed-off-by: Alexey Rokhin --- 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 178df13239d..79dd4f2837b 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -26,6 +26,7 @@ from compose.const import LABEL_PROJECT from compose.const import LABEL_SERVICE from compose.const import LABEL_VERSION +from compose.const import IS_WINDOWS_PLATFORM from compose.container import Container from compose.errors import OperationFailedError from compose.project import OneOffFilter From 5067f7a77ba7b0e367d49e19d5a252c31db72003 Mon Sep 17 00:00:00 2001 From: Alexey Rokhin Date: Wed, 17 May 2017 16:42:43 +0300 Subject: [PATCH 2889/4072] service_test.py reorder imports Signed-off-by: Alexey Rokhin --- 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 79dd4f2837b..178df13239d 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -26,7 +26,6 @@ from compose.const import LABEL_PROJECT from compose.const import LABEL_SERVICE from compose.const import LABEL_VERSION -from compose.const import IS_WINDOWS_PLATFORM from compose.container import Container from compose.errors import OperationFailedError from compose.project import OneOffFilter From 645d35612d636e3812abc039bf0c38b0a9a92417 Mon Sep 17 00:00:00 2001 From: Colin Hebert Date: Thu, 13 Apr 2017 21:51:41 +1000 Subject: [PATCH 2890/4072] Add support for labels during build Signed-off-by: Colin Hebert --- compose/config/config_schema_v3.2.json | 1 + 1 file changed, 1 insertion(+) diff --git a/compose/config/config_schema_v3.2.json b/compose/config/config_schema_v3.2.json index ea702fcd581..70ff6ce0564 100644 --- a/compose/config/config_schema_v3.2.json +++ b/compose/config/config_schema_v3.2.json @@ -72,6 +72,7 @@ "context": {"type": "string"}, "dockerfile": {"type": "string"}, "args": {"$ref": "#/definitions/list_or_dict"}, + "labels": {"$ref": "#/definitions/list_or_dict"}, "cache_from": {"$ref": "#/definitions/list_of_strings"} }, "additionalProperties": false From 74f5037f785fe400fb403e433f3c160c50967143 Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Mon, 29 May 2017 14:01:50 +0200 Subject: [PATCH 2891/4072] Add Joffrey to maintainers Signed-off-by: Sebastiaan van Stijn --- MAINTAINERS | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/MAINTAINERS b/MAINTAINERS index 820b2f82999..89f5b4124c9 100644 --- a/MAINTAINERS +++ b/MAINTAINERS @@ -15,6 +15,7 @@ "bfirsh", "dnephin", "mnowster", + "shin-", ] [people] @@ -44,3 +45,8 @@ Name = "Mazz Mosley" Email = "mazz@houseofmnowster.com" GitHub = "mnowster" + + [People.shin-] + Name = "Joffrey F" + Email = "joffrey@docker.com" + GitHub = "shin-" From 33c7c750e81528fb4c3a6a650821a722505726b5 Mon Sep 17 00:00:00 2001 From: Stefan Pietsch Date: Tue, 30 May 2017 23:54:01 +0200 Subject: [PATCH 2892/4072] check hash sums of downloaded files Signed-off-by: Stefan Pietsch --- Dockerfile | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index a03e151063b..154d515108e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,34 +19,47 @@ RUN set -ex; \ RUN curl https://get.docker.com/builds/Linux/x86_64/docker-1.8.3 \ -o /usr/local/bin/docker && \ + SHA256=f024bc65c45a3778cf07213d26016075e8172de8f6e4b5702bedde06c241650f; \ + echo "${SHA256} /usr/local/bin/docker" | sha256sum -c - && \ chmod +x /usr/local/bin/docker # Build Python 2.7.13 from source RUN set -ex; \ - curl -L https://www.python.org/ftp/python/2.7.13/Python-2.7.13.tgz | tar -xz; \ + curl -LO https://www.python.org/ftp/python/2.7.13/Python-2.7.13.tgz && \ + SHA256=a4f05a0720ce0fd92626f0278b6b433eee9a6173ddf2bced7957dfb599a5ece1; \ + echo "${SHA256} Python-2.7.13.tgz" | sha256sum -c - && \ + tar -xzf Python-2.7.13.tgz; \ cd Python-2.7.13; \ ./configure --enable-shared; \ make; \ make install; \ cd ..; \ - rm -rf /Python-2.7.13 + rm -rf /Python-2.7.13; \ + rm Python-2.7.13.tgz # Build python 3.4 from source RUN set -ex; \ - curl -L https://www.python.org/ftp/python/3.4.6/Python-3.4.6.tgz | tar -xz; \ + curl -LO https://www.python.org/ftp/python/3.4.6/Python-3.4.6.tgz && \ + SHA256=fe59daced99549d1d452727c050ae486169e9716a890cffb0d468b376d916b48; \ + echo "${SHA256} Python-3.4.6.tgz" | sha256sum -c - && \ + tar -xzf Python-3.4.6.tgz; \ cd Python-3.4.6; \ ./configure --enable-shared; \ make; \ make install; \ cd ..; \ - rm -rf /Python-3.4.6 + rm -rf /Python-3.4.6; \ + rm Python-3.4.6.tgz # Make libpython findable ENV LD_LIBRARY_PATH /usr/local/lib # Install pip RUN set -ex; \ - curl -L https://bootstrap.pypa.io/get-pip.py | python + curl -LO https://bootstrap.pypa.io/get-pip.py && \ + SHA256=19dae841a150c86e2a09d475b5eb0602861f2a5b7761ec268049a662dbd2bd0c; \ + echo "${SHA256} get-pip.py" | sha256sum -c - && \ + python get-pip.py # Python3 requires a valid locale RUN echo "en_US.UTF-8 UTF-8" > /etc/locale.gen && locale-gen From 85d2c0a31475fa372388cbb176a3c88425e576ec Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 5 Jun 2017 19:26:37 -0700 Subject: [PATCH 2893/4072] Take editions into account when selecting test engine versions Get candidates from moby/moby and docker/docker-ce repos Signed-off-by: Joffrey F --- script/test/all | 2 +- script/test/versions.py | 30 +++++++++++++++++++++--------- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/script/test/all b/script/test/all index 7151a75e1e3..0c6ea606579 100755 --- a/script/test/all +++ 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/test/versions.py docker/docker" + /code/script/test/versions.py docker/docker-ce,moby/moby" if [ "$DOCKER_VERSIONS" == "" ]; then DOCKER_VERSIONS="$($get_versions default)" diff --git a/script/test/versions.py b/script/test/versions.py index 97383ad99fb..46872ed9a6d 100755 --- a/script/test/versions.py +++ b/script/test/versions.py @@ -37,14 +37,22 @@ GITHUB_API = 'https://api.github.com/repos' -class Version(namedtuple('_Version', 'major minor patch rc')): +class Version(namedtuple('_Version', 'major minor patch rc edition')): @classmethod def parse(cls, version): + edition = None version = version.lstrip('v') version, _, rc = version.partition('-') + if rc: + if 'rc' not in rc: + edition = rc + rc = None + elif '-' in rc: + edition, rc = rc.split('-') + major, minor, patch = version.split('.', 3) - return cls(major, minor, patch, rc) + return cls(major, minor, patch, rc, edition) @property def major_minor(self): @@ -61,7 +69,8 @@ def order(self): def __str__(self): rc = '-{}'.format(self.rc) if self.rc else '' - return '.'.join(map(str, self[:3])) + rc + edition = '-{}'.format(self.edition) if self.edition else '' + return '.'.join(map(str, self[:3])) + edition + rc def group_versions(versions): @@ -94,6 +103,7 @@ def get_latest_versions(versions, num=1): group. """ versions = group_versions(versions) + num = min(len(versions), num) return [versions[index][0] for index in range(num)] @@ -112,16 +122,18 @@ def get_versions(tags): print("Skipping invalid tag: {name}".format(**tag), file=sys.stderr) -def get_github_releases(project): +def get_github_releases(projects): """Query the Github API for a list of version tags and return them in sorted order. See https://developer.github.com/v3/repos/#list-tags """ - url = '{}/{}/tags'.format(GITHUB_API, project) - response = requests.get(url) - response.raise_for_status() - versions = get_versions(response.json()) + versions = [] + for project in projects: + url = '{}/{}/tags'.format(GITHUB_API, project) + response = requests.get(url) + response.raise_for_status() + versions.extend(get_versions(response.json())) return sorted(versions, reverse=True, key=operator.attrgetter('order')) @@ -136,7 +148,7 @@ def parse_args(argv): def main(argv=None): args = parse_args(argv) - versions = get_github_releases(args.project) + versions = get_github_releases(args.project.split(',')) if args.command == 'recent': print(' '.join(map(str, get_latest_versions(versions, args.num)))) From 86a0e36348c618fc6994b8d4164da8294a0f38da Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 6 Jun 2017 16:13:34 -0700 Subject: [PATCH 2894/4072] s/docker daemon/dockerd/ Signed-off-by: Joffrey F --- script/test/all | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/test/all b/script/test/all index 0c6ea606579..1200c496e27 100755 --- a/script/test/all +++ b/script/test/all @@ -48,7 +48,7 @@ for version in $DOCKER_VERSIONS; do --privileged \ --volume="/var/lib/docker" \ "$repo:$version" \ - docker daemon -H tcp://0.0.0.0:2375 $DOCKER_DAEMON_ARGS \ + dockerd -H tcp://0.0.0.0:2375 $DOCKER_DAEMON_ARGS \ 2>&1 | tail -n 10 docker run \ From 59c4c2388e7828a57e18cf8b47089af78f9ac6b6 Mon Sep 17 00:00:00 2001 From: Joel Barciauskas Date: Wed, 12 Apr 2017 17:45:09 -0400 Subject: [PATCH 2895/4072] Add --quiet parameter to docker-compose pull, using existing silent flag Signed-off-by: Joel Barciauskas --- compose/cli/main.py | 4 +++- compose/project.py | 6 +++--- tests/acceptance/cli_test.py | 4 ++++ 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index cfca0f94902..20f3b55b465 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -634,11 +634,13 @@ def pull(self, options): Options: --ignore-pull-failures Pull what it can and ignores images with pull failures. --parallel Pull multiple images in parallel. + --quiet Pull without printing progress information """ self.project.pull( service_names=options['SERVICE'], ignore_pull_failures=options.get('--ignore-pull-failures'), - parallel_pull=options.get('--parallel') + parallel_pull=options.get('--parallel'), + silent=options.get('--quiet'), ) def push(self, options): diff --git a/compose/project.py b/compose/project.py index b282f718de5..3ad971488f0 100644 --- a/compose/project.py +++ b/compose/project.py @@ -462,12 +462,12 @@ def _get_convergence_plans(self, services, strategy): return plans - def pull(self, service_names=None, ignore_pull_failures=False, parallel_pull=False): + def pull(self, service_names=None, ignore_pull_failures=False, parallel_pull=False, silent=False): services = self.get_services(service_names, include_deps=False) if parallel_pull: def pull_service(service): - service.pull(ignore_pull_failures, True) + service.pull(ignore_pull_failures, True, silent=silent) parallel.parallel_execute( services, @@ -477,7 +477,7 @@ def pull_service(service): limit=5) else: for service in services: - service.pull(ignore_pull_failures) + service.pull(ignore_pull_failures, silent=silent) def push(self, service_names=None, ignore_push_failures=False): for service in self.get_services(service_names, include_deps=False): diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index dd95fb5450f..9a1f5364b72 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -431,6 +431,10 @@ def test_pull_with_ignore_pull_failures(self): assert ('repository nonexisting-image not found' in result.stderr or 'image library/nonexisting-image:latest not found' in result.stderr) + def test_pull_with_quiet(self): + assert self.dispatch(['pull', '--quiet']).stderr == '' + assert self.dispatch(['pull', '--quiet']).stdout == '' + def test_build_plain(self): self.base_dir = 'tests/fixtures/simple-dockerfile' self.dispatch(['build', 'simple']) From a0119ae1a5a8344801d534ddfbdc1a5156a3fe32 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 22 May 2017 14:58:51 -0700 Subject: [PATCH 2896/4072] Rewriting tests to be UCP/Swarm compatible - Event may contain more information in some cases. Don't assume order or format - Don't assume ports are always exposed on 0.0.0.0 by default - Absence of HostConfig in a create payload sometimes causes an error at the engine level - In Swarm, volume names are prefixed by "/" - When testing against Swarm, the default network driver is overlay - Ensure custom test networks are always attachable - Handle Swarm network names - Some params moved to host config in recent (1.21+) version - Conditional test skips for Swarm environments Signed-off-by: Joffrey F --- compose/service.py | 6 + tests/acceptance/cli_test.py | 147 ++++++++++-------- .../docker-compose.override.yml | 7 +- .../override-files/docker-compose.yml | 10 +- tests/fixtures/override-files/extra.yml | 9 +- tests/helpers.py | 35 +++++ tests/integration/project_test.py | 134 +++++++++------- tests/integration/service_test.py | 90 +++++++---- tests/integration/state_test.py | 2 +- tests/integration/testcases.py | 15 +- tests/integration/volume_test.py | 21 ++- 11 files changed, 316 insertions(+), 160 deletions(-) diff --git a/compose/service.py b/compose/service.py index dcbbe251ed0..03c41ce6758 100644 --- a/compose/service.py +++ b/compose/service.py @@ -56,7 +56,9 @@ 'cpu_count', 'cpu_percent', 'cpu_quota', + 'cpu_shares', 'cpus', + 'cpuset', 'devices', 'dns', 'dns_search', @@ -83,6 +85,7 @@ 'sysctls', 'userns_mode', 'volumes_from', + 'volume_driver', ] CONDITION_STARTED = 'service_started' @@ -848,6 +851,9 @@ def _get_container_host_config(self, override_options, one_off=False): cpu_count=options.get('cpu_count'), cpu_percent=options.get('cpu_percent'), nano_cpus=nano_cpus, + volume_driver=options.get('volume_driver'), + cpuset_cpus=options.get('cpuset'), + cpu_shares=options.get('cpu_shares'), ) def get_secret_volumes(self): diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 9a1f5364b72..ba0b5388803 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -20,6 +20,8 @@ from .. import mock from ..helpers import create_host_file +from ..helpers import is_cluster +from ..helpers import no_cluster from compose.cli.command import get_project from compose.config.errors import DuplicateOverrideFileFound from compose.container import Container @@ -28,6 +30,7 @@ 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 SWARM_SKIP_RM_VOLUMES from tests.integration.testcases import v2_1_only from tests.integration.testcases import v2_only from tests.integration.testcases import v3_only @@ -68,7 +71,8 @@ def wait_on_condition(condition, delay=0.1, timeout=40): def kill_service(service): for container in service.containers(): - container.kill() + if container.is_running: + container.kill() class ContainerCountCondition(object): @@ -78,7 +82,7 @@ def __init__(self, project, expected): self.expected = expected def __call__(self): - return len(self.project.containers()) == self.expected + return len([c for c in self.project.containers() if c.is_running]) == self.expected def __str__(self): return "waiting for counter count == %s" % self.expected @@ -116,11 +120,14 @@ def tearDown(self): for container in self.project.containers(stopped=True, one_off=OneOffFilter.only): container.remove(force=True) - networks = self.client.networks() for n in networks: - if n['Name'].startswith('{}_'.format(self.project.name)): + if n['Name'].split('/')[-1].startswith('{}_'.format(self.project.name)): self.client.remove_network(n['Name']) + volumes = self.client.volumes().get('Volumes') or [] + for v in volumes: + if v['Name'].split('/')[-1].startswith('{}_'.format(self.project.name)): + self.client.remove_volume(v['Name']) if hasattr(self, '_project'): del self._project @@ -175,7 +182,10 @@ def test_host_not_reachable(self): 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') + container = self.client.create_container( + 'busybox', 'true', name='composetest_data_container', + host_config={} + ) self.addCleanup(self.client.remove_container, container) result = self.dispatch(['-H=tcp://doesnotexist:8000', 'ps'], returncode=1) @@ -545,42 +555,48 @@ def test_create(self): self.dispatch(['create']) service = self.project.get_service('simple') another = self.project.get_service('another') - self.assertEqual(len(service.containers()), 0) - self.assertEqual(len(another.containers()), 0) - self.assertEqual(len(service.containers(stopped=True)), 1) - self.assertEqual(len(another.containers(stopped=True)), 1) + service_containers = service.containers(stopped=True) + another_containers = another.containers(stopped=True) + assert len(service_containers) == 1 + assert len(another_containers) == 1 + assert not service_containers[0].is_running + assert not another_containers[0].is_running def test_create_with_force_recreate(self): self.dispatch(['create'], None) service = self.project.get_service('simple') - self.assertEqual(len(service.containers()), 0) - self.assertEqual(len(service.containers(stopped=True)), 1) + service_containers = service.containers(stopped=True) + assert len(service_containers) == 1 + assert not service_containers[0].is_running old_ids = [c.id for c in service.containers(stopped=True)] self.dispatch(['create', '--force-recreate'], None) - self.assertEqual(len(service.containers()), 0) - self.assertEqual(len(service.containers(stopped=True)), 1) + service_containers = service.containers(stopped=True) + assert len(service_containers) == 1 + assert not service_containers[0].is_running - new_ids = [c.id for c in service.containers(stopped=True)] + new_ids = [c.id for c in service_containers] - self.assertNotEqual(old_ids, new_ids) + assert old_ids != new_ids def test_create_with_no_recreate(self): self.dispatch(['create'], None) service = self.project.get_service('simple') - self.assertEqual(len(service.containers()), 0) - self.assertEqual(len(service.containers(stopped=True)), 1) + service_containers = service.containers(stopped=True) + assert len(service_containers) == 1 + assert not service_containers[0].is_running old_ids = [c.id for c in service.containers(stopped=True)] self.dispatch(['create', '--no-recreate'], None) - self.assertEqual(len(service.containers()), 0) - self.assertEqual(len(service.containers(stopped=True)), 1) + service_containers = service.containers(stopped=True) + assert len(service_containers) == 1 + assert not service_containers[0].is_running - new_ids = [c.id for c in service.containers(stopped=True)] + new_ids = [c.id for c in service_containers] - self.assertEqual(old_ids, new_ids) + assert old_ids == new_ids def test_run_one_off_with_volume(self): self.base_dir = 'tests/fixtures/simple-composefile-volume-ready' @@ -687,7 +703,7 @@ def test_up(self): 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') + assert networks[0]['Driver'] == 'bridge' if not is_cluster(self.client) else 'overlay' assert 'com.docker.network.bridge.enable_icc' not in networks[0]['Options'] network = self.client.inspect_network(networks[0]['Id']) @@ -733,11 +749,11 @@ def test_up_with_network_aliases(self): networks = [ n for n in self.client.networks() - if n['Name'].startswith('{}_'.format(self.project.name)) + if n['Name'].split('/')[-1].startswith('{}_'.format(self.project.name)) ] # Two networks were created: back and front - assert sorted(n['Name'] for n in networks) == [back_name, front_name] + assert sorted(n['Name'].split('/')[-1] for n in networks) == [back_name, front_name] web_container = self.project.get_service('web').containers()[0] back_aliases = web_container.get( @@ -761,11 +777,11 @@ def test_up_with_network_internal(self): networks = [ n for n in self.client.networks() - if n['Name'].startswith('{}_'.format(self.project.name)) + if n['Name'].split('/')[-1].startswith('{}_'.format(self.project.name)) ] # One network was created: internal - assert sorted(n['Name'] for n in networks) == [internal_net] + assert sorted(n['Name'].split('/')[-1] for n in networks) == [internal_net] assert networks[0]['Internal'] is True @@ -780,11 +796,11 @@ def test_up_with_network_static_addresses(self): networks = [ n for n in self.client.networks() - if n['Name'].startswith('{}_'.format(self.project.name)) + if n['Name'].split('/')[-1].startswith('{}_'.format(self.project.name)) ] # One networks was created: front - assert sorted(n['Name'] for n in networks) == [static_net] + assert sorted(n['Name'].split('/')[-1] for n in networks) == [static_net] web_container = self.project.get_service('web').containers()[0] ipam_config = web_container.get( @@ -803,11 +819,11 @@ def test_up_with_networks(self): networks = [ n for n in self.client.networks() - if n['Name'].startswith('{}_'.format(self.project.name)) + if n['Name'].split('/')[-1].startswith('{}_'.format(self.project.name)) ] # Two networks were created: back and front - assert sorted(n['Name'] for n in networks) == [back_name, front_name] + assert sorted(n['Name'].split('/')[-1] for n in networks) == [back_name, front_name] back_network = [n for n in networks if n['Name'] == back_name][0] front_network = [n for n in networks if n['Name'] == front_name][0] @@ -847,8 +863,12 @@ def test_up_missing_network(self): assert 'Service "web" uses an undefined network "foo"' in result.stderr @v2_only() + @no_cluster('container networks not supported in Swarm') def test_up_with_network_mode(self): - c = self.client.create_container('busybox', 'top', name='composetest_network_mode_container') + c = self.client.create_container( + 'busybox', 'top', name='composetest_network_mode_container', + host_config={} + ) self.addCleanup(self.client.remove_container, c, force=True) self.client.start(c) container_mode_source = 'container:{}'.format(c['Id']) @@ -862,7 +882,7 @@ def test_up_with_network_mode(self): networks = [ n for n in self.client.networks() - if n['Name'].startswith('{}_'.format(self.project.name)) + if n['Name'].split('/')[-1].startswith('{}_'.format(self.project.name)) ] assert not networks @@ -899,7 +919,7 @@ def test_up_external_networks(self): network_names = ['{}_{}'.format(self.project.name, n) for n in ['foo', 'bar']] for name in network_names: - self.client.create_network(name) + self.client.create_network(name, attachable=True) self.dispatch(['-f', filename, 'up', '-d']) container = self.project.containers()[0] @@ -917,12 +937,12 @@ def test_up_with_external_default_network(self): networks = [ n['Name'] for n in self.client.networks() - if n['Name'].startswith('{}_'.format(self.project.name)) + if n['Name'].split('/')[-1].startswith('{}_'.format(self.project.name)) ] assert not networks network_name = 'composetest_external_network' - self.client.create_network(network_name) + self.client.create_network(network_name, attachable=True) self.dispatch(['-f', filename, 'up', '-d']) container = self.project.containers()[0] @@ -941,10 +961,10 @@ def test_up_with_network_labels(self): networks = [ n for n in self.client.networks() - if n['Name'].startswith('{}_'.format(self.project.name)) + if n['Name'].split('/')[-1].startswith('{}_'.format(self.project.name)) ] - assert [n['Name'] for n in networks] == [network_with_label] + assert [n['Name'].split('/')[-1] for n in networks] == [network_with_label] assert 'label_key' in networks[0]['Labels'] assert networks[0]['Labels']['label_key'] == 'label_val' @@ -961,10 +981,10 @@ def test_up_with_volume_labels(self): volumes = [ v for v in self.client.volumes().get('Volumes', []) - if v['Name'].startswith('{}_'.format(self.project.name)) + if v['Name'].split('/')[-1].startswith('{}_'.format(self.project.name)) ] - assert [v['Name'] for v in volumes] == [volume_with_label] + assert set([v['Name'].split('/')[-1] for v in volumes]) == set([volume_with_label]) assert 'label_key' in volumes[0]['Labels'] assert volumes[0]['Labels']['label_key'] == 'label_val' @@ -975,7 +995,7 @@ def test_up_no_services(self): network_names = [ n['Name'] for n in self.client.networks() - if n['Name'].startswith('{}_'.format(self.project.name)) + if n['Name'].split('/')[-1].startswith('{}_'.format(self.project.name)) ] assert network_names == [] @@ -1010,6 +1030,7 @@ def test_up_with_net_is_invalid(self): assert "Unsupported config option for services.bar: 'net'" in result.stderr + @no_cluster("Legacy networking not supported on Swarm") def test_up_with_net_v1(self): self.base_dir = 'tests/fixtures/net-container' self.dispatch(['up', '-d'], None) @@ -1261,6 +1282,7 @@ def test_run_without_command(self): [u'/bin/true'], ) + @py.test.mark.skipif(SWARM_SKIP_RM_VOLUMES, reason='Swarm DELETE /containers/ bug') def test_run_rm(self): self.base_dir = 'tests/fixtures/volume' proc = start_process(self.base_dir, ['run', '--rm', 'test']) @@ -1274,7 +1296,7 @@ def test_run_rm(self): mounts = containers[0].get('Mounts') for mount in mounts: if mount['Destination'] == '/container-path': - anonymousName = mount['Name'] + anonymous_name = mount['Name'] break os.kill(proc.pid, signal.SIGINT) wait_on_process(proc, 1) @@ -1287,9 +1309,11 @@ def test_run_rm(self): if volume.internal == '/container-named-path': name = volume.external break - volumeNames = [v['Name'] for v in volumes] - assert name in volumeNames - assert anonymousName not in volumeNames + volume_names = [v['Name'].split('/')[-1] for v in volumes] + assert name in volume_names + if not is_cluster(self.client): + # The `-v` flag for `docker rm` in Swarm seems to be broken + assert anonymous_name not in volume_names def test_run_service_with_dockerfile_entrypoint(self): self.base_dir = 'tests/fixtures/entrypoint-dockerfile' @@ -1411,11 +1435,10 @@ def test_run_service_with_map_ports(self): container.stop() # check the ports - self.assertNotEqual(port_random, None) - self.assertIn("0.0.0.0", port_random) - self.assertEqual(port_assigned, "0.0.0.0:49152") - self.assertEqual(port_range[0], "0.0.0.0:49153") - self.assertEqual(port_range[1], "0.0.0.0:49154") + assert port_random is not None + assert port_assigned.endswith(':49152') + assert port_range[0].endswith(':49153') + assert port_range[1].endswith(':49154') def test_run_service_with_explicitly_mapped_ports(self): # create one off container @@ -1431,8 +1454,8 @@ def test_run_service_with_explicitly_mapped_ports(self): container.stop() # check the ports - self.assertEqual(port_short, "0.0.0.0:30000") - self.assertEqual(port_full, "0.0.0.0:30001") + assert port_short.endswith(':30000') + assert port_full.endswith(':30001') def test_run_service_with_explicitly_mapped_ip_ports(self): # create one off container @@ -1953,9 +1976,9 @@ def get_port(number): result = self.dispatch(['port', 'simple', str(number)]) return result.stdout.rstrip() - self.assertEqual(get_port(3000), container.get_local_port(3000)) - self.assertEqual(get_port(3001), "0.0.0.0:49152") - self.assertEqual(get_port(3002), "0.0.0.0:49153") + assert get_port(3000) == container.get_local_port(3000) + assert ':49152' in get_port(3001) + assert ':49153' in get_port(3002) def test_expanded_port(self): self.base_dir = 'tests/fixtures/ports-composefile' @@ -1966,9 +1989,9 @@ def get_port(number): result = self.dispatch(['port', 'simple', str(number)]) return result.stdout.rstrip() - self.assertEqual(get_port(3000), container.get_local_port(3000)) - self.assertEqual(get_port(3001), "0.0.0.0:49152") - self.assertEqual(get_port(3002), "0.0.0.0:49153") + assert get_port(3000) == container.get_local_port(3000) + assert ':49152' in get_port(3001) + assert ':49153' in get_port(3002) def test_port_with_scale(self): self.base_dir = 'tests/fixtures/ports-composefile-scale' @@ -2021,12 +2044,14 @@ def has_timestamp(string): assert len(lines) == 2 container, = self.project.containers() - expected_template = ( - ' container {} {} (image=busybox:latest, ' - 'name=simplecomposefile_simple_1)') + expected_template = ' container {} {}' + expected_meta_info = ['image=busybox:latest', 'name=simplecomposefile_simple_1'] assert expected_template.format('create', container.id) in lines[0] assert expected_template.format('start', container.id) in lines[1] + for line in lines: + for info in expected_meta_info: + assert info in line assert has_timestamp(lines[0]) @@ -2069,7 +2094,6 @@ def test_up_with_multiple_files(self): 'docker-compose.yml', 'docker-compose.override.yml', 'extra.yml', - ] self._project = get_project(self.base_dir, config_paths) self.dispatch( @@ -2086,7 +2110,6 @@ def test_up_with_multiple_files(self): web, other, db = containers self.assertEqual(web.human_readable_command, 'top') - self.assertTrue({'db', 'other'} <= set(get_links(web))) self.assertEqual(db.human_readable_command, 'top') self.assertEqual(other.human_readable_command, 'top') diff --git a/tests/fixtures/override-files/docker-compose.override.yml b/tests/fixtures/override-files/docker-compose.override.yml index a03d3d6f5f0..b2c54060124 100644 --- a/tests/fixtures/override-files/docker-compose.override.yml +++ b/tests/fixtures/override-files/docker-compose.override.yml @@ -1,6 +1,7 @@ - -web: +version: '2.2' +services: + web: command: "top" -db: + db: command: "top" diff --git a/tests/fixtures/override-files/docker-compose.yml b/tests/fixtures/override-files/docker-compose.yml index 8eb43ddb06c..6c3d4e17230 100644 --- a/tests/fixtures/override-files/docker-compose.yml +++ b/tests/fixtures/override-files/docker-compose.yml @@ -1,10 +1,10 @@ - -web: +version: '2.2' +services: + web: image: busybox:latest command: "sleep 200" - links: + depends_on: - db - -db: + db: image: busybox:latest command: "sleep 200" diff --git a/tests/fixtures/override-files/extra.yml b/tests/fixtures/override-files/extra.yml index 7b3ade9c2d1..492c379526e 100644 --- a/tests/fixtures/override-files/extra.yml +++ b/tests/fixtures/override-files/extra.yml @@ -1,9 +1,10 @@ - -web: - links: +version: '2.2' +services: + web: + depends_on: - db - other -other: + other: image: busybox:latest command: "top" diff --git a/tests/helpers.py b/tests/helpers.py index 59efd2557c4..662353c93b3 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -1,8 +1,12 @@ from __future__ import absolute_import from __future__ import unicode_literals +import functools import os +from docker.errors import APIError +from pytest import skip + from compose.config.config import ConfigDetails from compose.config.config import ConfigFile from compose.config.config import load @@ -44,3 +48,34 @@ def create_host_file(client, filename): "Container exited with code {}:\n{}".format(exitcode, output)) finally: client.remove_container(container, force=True) + + +def is_cluster(client): + nodes = None + + def get_nodes_number(): + try: + return len(client.nodes()) + except APIError: + # If the Engine is not part of a Swarm, the SDK will raise + # an APIError + return 0 + + if nodes is None: + # Only make the API call if the value hasn't been cached yet + nodes = get_nodes_number() + + return nodes > 1 + + +def no_cluster(reason): + def decorator(f): + @functools.wraps(f) + def wrapper(self, *args, **kwargs): + if is_cluster(self.client): + skip("Test will not be run in cluster mode: %s" % reason) + return + return f(self, *args, **kwargs) + return wrapper + + return decorator diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 6c5f719ed37..6731f25dd3a 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -6,12 +6,16 @@ import py import pytest +from docker.errors import APIError from docker.errors import NotFound from .. import mock from ..helpers import build_config as load_config from ..helpers import create_host_file +from ..helpers import is_cluster +from ..helpers import no_cluster from .testcases import DockerClientTestCase +from .testcases import SWARM_SKIP_CONTAINERS_ALL from compose.config import config from compose.config import ConfigurationError from compose.config import types @@ -57,6 +61,20 @@ def test_containers(self): containers = project.containers() self.assertEqual(len(containers), 2) + @pytest.mark.skipif(SWARM_SKIP_CONTAINERS_ALL, reason='Swarm /containers/json bug') + def test_containers_stopped(self): + web = self.create_service('web') + db = self.create_service('db') + project = Project('composetest', [web, db], self.client) + + project.up() + assert len(project.containers()) == 2 + assert len(project.containers(stopped=True)) == 2 + + project.stop() + assert len(project.containers()) == 0 + assert len(project.containers(stopped=True)) == 2 + def test_containers_with_service_names(self): web = self.create_service('web') db = self.create_service('db') @@ -110,6 +128,7 @@ def test_volumes_from_container(self): volumes=['/var/data'], name='composetest_data_container', labels={LABEL_PROJECT: 'composetest'}, + host_config={}, ) project = Project.from_config( name='composetest', @@ -125,6 +144,7 @@ def test_volumes_from_container(self): self.assertEqual(db._get_volumes_from(), [data_container.id + ':rw']) @v2_only() + @no_cluster('container networks not supported in Swarm') def test_network_mode_from_service(self): project = Project.from_config( name='composetest', @@ -152,6 +172,7 @@ def test_network_mode_from_service(self): self.assertEqual(web.network_mode.mode, 'container:' + net.containers()[0].id) @v2_only() + @no_cluster('container networks not supported in Swarm') def test_network_mode_from_container(self): def get_project(): return Project.from_config( @@ -179,6 +200,7 @@ def get_project(): name='composetest_net_container', command='top', labels={LABEL_PROJECT: 'composetest'}, + host_config={}, ) net_container.start() @@ -188,6 +210,7 @@ def get_project(): web = project.get_service('web') self.assertEqual(web.network_mode.mode, 'container:' + net_container.id) + @no_cluster('container networks not supported in Swarm') def test_net_from_service_v1(self): project = Project.from_config( name='composetest', @@ -211,6 +234,7 @@ def test_net_from_service_v1(self): net = project.get_service('net') self.assertEqual(web.network_mode.mode, 'container:' + net.containers()[0].id) + @no_cluster('container networks not supported in Swarm') def test_net_from_container_v1(self): def get_project(): return Project.from_config( @@ -235,6 +259,7 @@ def get_project(): name='composetest_net_container', command='top', labels={LABEL_PROJECT: 'composetest'}, + host_config={}, ) net_container.start() @@ -260,12 +285,12 @@ def test_start_pause_unpause_stop_kill_remove(self): project.start(service_names=['web']) self.assertEqual( - set(c.name for c in project.containers()), + set(c.name for c in project.containers() if c.is_running), set([web_container_1.name, web_container_2.name])) project.start() self.assertEqual( - set(c.name for c in project.containers()), + set(c.name for c in project.containers() if c.is_running), set([web_container_1.name, web_container_2.name, db_container.name])) project.pause(service_names=['web']) @@ -285,10 +310,12 @@ def test_start_pause_unpause_stop_kill_remove(self): self.assertEqual(len([c.name for c in project.containers() if c.is_paused]), 0) project.stop(service_names=['web'], timeout=1) - self.assertEqual(set(c.name for c in project.containers()), set([db_container.name])) + self.assertEqual( + set(c.name for c in project.containers() if c.is_running), set([db_container.name]) + ) project.kill(service_names=['db']) - self.assertEqual(len(project.containers()), 0) + self.assertEqual(len([c for c in project.containers() if c.is_running]), 0) self.assertEqual(len(project.containers(stopped=True)), 3) project.remove_stopped(service_names=['web']) @@ -303,11 +330,13 @@ def test_create(self): project = Project('composetest', [web, db], self.client) project.create(['db']) - self.assertEqual(len(project.containers()), 0) - self.assertEqual(len(project.containers(stopped=True)), 1) - self.assertEqual(len(db.containers()), 0) - self.assertEqual(len(db.containers(stopped=True)), 1) - self.assertEqual(len(web.containers(stopped=True)), 0) + containers = project.containers(stopped=True) + assert len(containers) == 1 + assert not containers[0].is_running + db_containers = db.containers(stopped=True) + assert len(db_containers) == 1 + assert not db_containers[0].is_running + assert len(web.containers(stopped=True)) == 0 def test_create_twice(self): web = self.create_service('web') @@ -316,12 +345,14 @@ def test_create_twice(self): project.create(['db', 'web']) project.create(['db', 'web']) - self.assertEqual(len(project.containers()), 0) - self.assertEqual(len(project.containers(stopped=True)), 2) - self.assertEqual(len(db.containers()), 0) - self.assertEqual(len(db.containers(stopped=True)), 1) - self.assertEqual(len(web.containers()), 0) - self.assertEqual(len(web.containers(stopped=True)), 1) + containers = project.containers(stopped=True) + assert len(containers) == 2 + db_containers = db.containers(stopped=True) + assert len(db_containers) == 1 + assert not db_containers[0].is_running + web_containers = web.containers(stopped=True) + assert len(web_containers) == 1 + assert not web_containers[0].is_running def test_create_with_links(self): db = self.create_service('db') @@ -329,12 +360,11 @@ def test_create_with_links(self): project = Project('composetest', [db, web], self.client) project.create(['web']) - self.assertEqual(len(project.containers()), 0) - self.assertEqual(len(project.containers(stopped=True)), 2) - self.assertEqual(len(db.containers()), 0) - self.assertEqual(len(db.containers(stopped=True)), 1) - self.assertEqual(len(web.containers()), 0) - self.assertEqual(len(web.containers(stopped=True)), 1) + # self.assertEqual(len(project.containers()), 0) + assert len(project.containers(stopped=True)) == 2 + assert not [c for c in project.containers(stopped=True) if c.is_running] + assert len(db.containers(stopped=True)) == 1 + assert len(web.containers(stopped=True)) == 1 def test_create_strategy_always(self): db = self.create_service('db') @@ -343,11 +373,11 @@ def test_create_strategy_always(self): old_id = project.containers(stopped=True)[0].id project.create(['db'], strategy=ConvergenceStrategy.always) - self.assertEqual(len(project.containers()), 0) - self.assertEqual(len(project.containers(stopped=True)), 1) + assert len(project.containers(stopped=True)) == 1 db_container = project.containers(stopped=True)[0] - self.assertNotEqual(db_container.id, old_id) + assert not db_container.is_running + assert db_container.id != old_id def test_create_strategy_never(self): db = self.create_service('db') @@ -356,11 +386,11 @@ def test_create_strategy_never(self): old_id = project.containers(stopped=True)[0].id project.create(['db'], strategy=ConvergenceStrategy.never) - self.assertEqual(len(project.containers()), 0) - self.assertEqual(len(project.containers(stopped=True)), 1) + assert len(project.containers(stopped=True)) == 1 db_container = project.containers(stopped=True)[0] - self.assertEqual(db_container.id, old_id) + assert not db_container.is_running + assert db_container.id == old_id def test_project_up(self): web = self.create_service('web') @@ -550,8 +580,8 @@ def test_project_up_with_no_deps(self): self.assertEqual(len(project.containers(stopped=True)), 2) self.assertEqual(len(project.get_service('web').containers()), 0) self.assertEqual(len(project.get_service('db').containers()), 1) - self.assertEqual(len(project.get_service('data').containers()), 0) self.assertEqual(len(project.get_service('data').containers(stopped=True)), 1) + assert not project.get_service('data').containers(stopped=True)[0].is_running self.assertEqual(len(project.get_service('console').containers()), 0) def test_project_up_recreate_with_tmpfs_volume(self): @@ -737,10 +767,10 @@ def test_up_with_ipam_options(self): "com.docker.compose.network.test": "9-29-045" } - @v2_only() + @v2_1_only() def test_up_with_network_static_addresses(self): config_data = build_config( - version=V2_0, + version=V2_1, services=[{ 'name': 'web', 'image': 'busybox:latest', @@ -766,7 +796,8 @@ def test_up_with_network_static_addresses(self): {"subnet": "fe80::/64", "gateway": "fe80::1001:1"} ] - } + }, + 'enable_ipv6': True, } } ) @@ -777,13 +808,8 @@ def test_up_with_network_static_addresses(self): ) project.up(detached=True) - 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', {})) @@ -825,7 +851,7 @@ def test_up_with_enable_ipv6(self): config_data=config_data, ) project.up(detached=True) - network = self.client.networks(names=['static_test'])[0] + network = [n for n in self.client.networks() if 'static_test' in n['Name']][0] service_container = project.get_service('web').containers()[0] assert network['EnableIPv6'] is True @@ -1026,8 +1052,8 @@ def test_project_up_volumes(self): project.up() self.assertEqual(len(project.containers()), 1) - volume_data = self.client.inspect_volume(full_vol_name) - self.assertEqual(volume_data['Name'], full_vol_name) + volume_data = self.get_volume_data(full_vol_name) + assert volume_data['Name'].split('/')[-1] == full_vol_name self.assertEqual(volume_data['Driver'], 'local') @v2_1_only() @@ -1062,10 +1088,12 @@ def test_project_up_with_volume_labels(self): volumes = [ v for v in self.client.volumes().get('Volumes', []) - if v['Name'].startswith('composetest_') + if v['Name'].split('/')[-1].startswith('composetest_') ] - assert [v['Name'] for v in volumes] == ['composetest_{}'.format(volume_name)] + assert set([v['Name'].split('/')[-1] for v in volumes]) == set( + ['composetest_{}'.format(volume_name)] + ) assert 'label_key' in volumes[0]['Labels'] assert volumes[0]['Labels']['label_key'] == 'label_val' @@ -1205,8 +1233,8 @@ def test_initialize_volumes(self): ) project.volumes.initialize() - volume_data = self.client.inspect_volume(full_vol_name) - assert volume_data['Name'] == full_vol_name + volume_data = self.get_volume_data(full_vol_name) + assert volume_data['Name'].split('/')[-1] == full_vol_name assert volume_data['Driver'] == 'local' @v2_only() @@ -1229,8 +1257,8 @@ def test_project_up_implicit_volume_driver(self): ) project.up() - volume_data = self.client.inspect_volume(full_vol_name) - self.assertEqual(volume_data['Name'], full_vol_name) + volume_data = self.get_volume_data(full_vol_name) + assert volume_data['Name'].split('/')[-1] == full_vol_name self.assertEqual(volume_data['Driver'], 'local') @v3_only() @@ -1287,10 +1315,11 @@ def test_initialize_volumes_invalid_volume_driver(self): name='composetest', config_data=config_data, client=self.client ) - with self.assertRaises(config.ConfigurationError): + with self.assertRaises(APIError if is_cluster(self.client) else config.ConfigurationError): project.volumes.initialize() @v2_only() + @no_cluster('inspect volume by name defect on Swarm Classic') def test_initialize_volumes_updated_driver(self): vol_name = '{0:x}'.format(random.getrandbits(32)) full_vol_name = 'composetest_{0}'.format(vol_name) @@ -1310,8 +1339,8 @@ def test_initialize_volumes_updated_driver(self): ) project.volumes.initialize() - volume_data = self.client.inspect_volume(full_vol_name) - self.assertEqual(volume_data['Name'], full_vol_name) + volume_data = self.get_volume_data(full_vol_name) + assert volume_data['Name'].split('/')[-1] == full_vol_name self.assertEqual(volume_data['Driver'], 'local') config_data = config_data._replace( @@ -1348,8 +1377,8 @@ def test_initialize_volumes_updated_blank_driver(self): ) project.volumes.initialize() - volume_data = self.client.inspect_volume(full_vol_name) - self.assertEqual(volume_data['Name'], full_vol_name) + volume_data = self.get_volume_data(full_vol_name) + assert volume_data['Name'].split('/')[-1] == full_vol_name self.assertEqual(volume_data['Driver'], 'local') config_data = config_data._replace( @@ -1361,11 +1390,12 @@ def test_initialize_volumes_updated_blank_driver(self): client=self.client ) project.volumes.initialize() - volume_data = self.client.inspect_volume(full_vol_name) - self.assertEqual(volume_data['Name'], full_vol_name) + volume_data = self.get_volume_data(full_vol_name) + assert volume_data['Name'].split('/')[-1] == full_vol_name self.assertEqual(volume_data['Driver'], 'local') @v2_only() + @no_cluster('inspect volume by name defect on Swarm Classic') def test_initialize_volumes_external_volumes(self): # Use composetest_ prefix so it gets garbage-collected in tearDown() vol_name = 'composetest_{0:x}'.format(random.getrandbits(32)) diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 178df13239d..baf21af3ce8 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -13,9 +13,13 @@ from six import text_type from .. import mock +from ..helpers import is_cluster +from ..helpers import no_cluster from .testcases import DockerClientTestCase from .testcases import get_links from .testcases import pull_busybox +from .testcases import SWARM_SKIP_CONTAINERS_ALL +from .testcases import SWARM_SKIP_CPU_SHARES from compose import __version__ from compose.config.types import VolumeFromSpec from compose.config.types import VolumeSpec @@ -100,6 +104,7 @@ def test_create_container_with_volume_driver(self): service.start_container(container) self.assertEqual('foodriver', container.get('HostConfig.VolumeDriver')) + @pytest.mark.skipif(SWARM_SKIP_CPU_SHARES, reason='Swarm --cpu-shares bug') def test_create_container_with_cpu_shares(self): service = self.create_service('db', cpu_shares=73) container = service.create_container() @@ -151,6 +156,7 @@ def test_create_container_with_init_bool(self): service.start_container(container) assert container.get('HostConfig.Init') is True + @pytest.mark.xfail(True, reason='Option has been removed in Engine 17.06.0') def test_create_container_with_init_path(self): self.require_api_version('1.25') docker_init_path = find_executable('docker-init') @@ -249,6 +255,7 @@ def test_duplicate_volume_trailing_slash(self): 'busybox', 'true', volumes={container_path: {}}, labels={'com.docker.compose.test_image': 'true'}, + host_config={} ) image = self.client.commit(tmp_container)['Id'] @@ -278,6 +285,7 @@ def test_create_container_with_volumes_from(self): image='busybox:latest', command=["top"], labels={LABEL_PROJECT: 'composetest'}, + host_config={}, ) host_service = self.create_service( 'host', @@ -321,9 +329,15 @@ def test_execute_convergence_plan_recreate(self): self.assertIn('FOO=2', new_container.get('Config.Env')) self.assertEqual(new_container.name, 'composetest_db_1') self.assertEqual(new_container.get_mount('/etc')['Source'], volume_path) - self.assertIn( - 'affinity:container==%s' % old_container.id, - new_container.get('Config.Env')) + if not is_cluster(self.client): + assert ( + 'affinity:container==%s' % old_container.id in + new_container.get('Config.Env') + ) + else: + # In Swarm, the env marker is consumed and the container should be deployed + # on the same node. + assert old_container.get('Node.Name') == new_container.get('Node.Name') self.assertEqual(len(self.client.containers(all=True)), num_containers_before) self.assertNotEqual(old_container.id, new_container.id) @@ -350,8 +364,13 @@ def test_execute_convergence_plan_recreate_twice(self): 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')) + if not is_cluster(self.client): + assert ('affinity:container==%s' % orig_container.id in + new_container.get('Config.Env')) + else: + # In Swarm, the env marker is consumed and the container should be deployed + # on the same node. + assert orig_container.get('Node.Name') == new_container.get('Node.Name') orig_container = new_container @@ -464,18 +483,21 @@ def test_execute_convergence_plan_without_start(self): ) containers = service.execute_convergence_plan(ConvergencePlan('create', []), start=False) - self.assertEqual(len(service.containers()), 0) - self.assertEqual(len(service.containers(stopped=True)), 1) + service_containers = service.containers(stopped=True) + assert len(service_containers) == 1 + assert not service_containers[0].is_running containers = service.execute_convergence_plan( ConvergencePlan('recreate', containers), start=False) - self.assertEqual(len(service.containers()), 0) - self.assertEqual(len(service.containers(stopped=True)), 1) + service_containers = service.containers(stopped=True) + assert len(service_containers) == 1 + assert not service_containers[0].is_running service.execute_convergence_plan(ConvergencePlan('start', containers), start=False) - self.assertEqual(len(service.containers()), 0) - self.assertEqual(len(service.containers(stopped=True)), 1) + service_containers = service.containers(stopped=True) + assert len(service_containers) == 1 + assert not service_containers[0].is_running def test_start_container_passes_through_options(self): db = self.create_service('db') @@ -487,6 +509,7 @@ def test_start_container_inherits_options_from_constructor(self): create_and_start_container(db) self.assertEqual(db.containers()[0].environment['FOO'], 'BAR') + @no_cluster('No legacy links support in Swarm') def test_start_container_creates_links(self): db = self.create_service('db') web = self.create_service('web', links=[(db, None)]) @@ -503,6 +526,7 @@ def test_start_container_creates_links(self): 'db']) ) + @no_cluster('No legacy links support in Swarm') def test_start_container_creates_links_with_names(self): db = self.create_service('db') web = self.create_service('web', links=[(db, 'custom_link_name')]) @@ -519,6 +543,7 @@ def test_start_container_creates_links_with_names(self): 'custom_link_name']) ) + @no_cluster('No legacy links support in Swarm') def test_start_container_with_external_links(self): db = self.create_service('db') web = self.create_service('web', external_links=['composetest_db_1', @@ -537,6 +562,7 @@ def test_start_container_with_external_links(self): 'db_3']), ) + @no_cluster('No legacy links support in Swarm') def test_start_normal_container_does_not_create_links_to_its_own_service(self): db = self.create_service('db') @@ -546,6 +572,7 @@ def test_start_normal_container_does_not_create_links_to_its_own_service(self): c = create_and_start_container(db) self.assertEqual(set(get_links(c)), set([])) + @no_cluster('No legacy links support in Swarm') def test_start_one_off_container_creates_links_to_its_own_service(self): db = self.create_service('db') @@ -572,7 +599,7 @@ def test_start_container_builds_images(self): container = create_and_start_container(service) container.wait() self.assertIn(b'success', container.logs()) - self.assertEqual(len(self.client.images(name='composetest_test')), 1) + assert len(self.client.images(name='composetest_test')) >= 1 def test_start_container_uses_tagged_image_if_it_exists(self): self.check_build('tests/fixtures/simple-dockerfile', tag='composetest_test') @@ -719,20 +746,27 @@ def test_port_with_explicit_interface(self): '0.0.0.0:9001:9000/udp', ]) container = create_and_start_container(service).inspect() - self.assertEqual(container['NetworkSettings']['Ports'], { - '8000/tcp': [ - { - 'HostIp': '127.0.0.1', - 'HostPort': '8001', - }, - ], - '9000/udp': [ - { - 'HostIp': '0.0.0.0', - 'HostPort': '9001', - }, - ], - }) + assert container['NetworkSettings']['Ports']['8000/tcp'] == [{ + 'HostIp': '127.0.0.1', + 'HostPort': '8001', + }] + assert container['NetworkSettings']['Ports']['9000/udp'][0]['HostPort'] == '9001' + if not is_cluster(self.client): + assert container['NetworkSettings']['Ports']['9000/udp'][0]['HostIp'] == '0.0.0.0' + # self.assertEqual(container['NetworkSettings']['Ports'], { + # '8000/tcp': [ + # { + # 'HostIp': '127.0.0.1', + # 'HostPort': '8001', + # }, + # ], + # '9000/udp': [ + # { + # 'HostIp': '0.0.0.0', + # 'HostPort': '9001', + # }, + # ], + # }) def test_create_with_image_id(self): # Get image id for the current busybox:latest @@ -760,6 +794,10 @@ def test_scale(self): service.scale(0) self.assertEqual(len(service.containers()), 0) + @pytest.mark.skipif( + SWARM_SKIP_CONTAINERS_ALL, + reason='Swarm /containers/json bug' + ) def test_scale_with_stopped_containers(self): """ Given there are some stopped containers and scale is called with a diff --git a/tests/integration/state_test.py b/tests/integration/state_test.py index 07b28e78431..0dd5f44ad49 100644 --- a/tests/integration/state_test.py +++ b/tests/integration/state_test.py @@ -251,7 +251,7 @@ def test_trigger_recreate_with_image_change(self): container = web.create_container() # update the image - c = self.client.create_container(image, ['touch', '/hello.txt']) + c = self.client.create_container(image, ['touch', '/hello.txt'], host_config={}) self.client.commit(c, repository=repo, tag=tag) self.client.remove_container(c) diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index 57814872cfa..1e0d6321502 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -8,6 +8,7 @@ from pytest import skip from .. import unittest +from ..helpers import is_cluster from compose.cli.docker_client import docker_client from compose.config.config import resolve_environment from compose.config.environment import Environment @@ -21,6 +22,10 @@ from compose.progress_stream import stream_output from compose.service import Service +SWARM_SKIP_CONTAINERS_ALL = os.environ.get('SWARM_SKIP_CONTAINERS_ALL', '0') != '0' +SWARM_SKIP_CPU_SHARES = os.environ.get('SWARM_SKIP_CPU_SHARES', '0') != '0' +SWARM_SKIP_RM_VOLUMES = os.environ.get('SWARM_SKIP_RM_VOLUMES', '0') != '0' + def pull_busybox(client): client.pull('busybox:latest', stream=False) @@ -97,7 +102,7 @@ def tearDown(self): for i in self.client.images( filters={'label': 'com.docker.compose.test_image'}): - self.client.remove_image(i) + self.client.remove_image(i, force=True) volumes = self.client.volumes().get('Volumes') or [] for v in volumes: @@ -133,3 +138,11 @@ def require_api_version(self, minimum): api_version = self.client.version()['ApiVersion'] if version_lt(api_version, minimum): skip("API version is too low ({} < {})".format(api_version, minimum)) + + def get_volume_data(self, volume_name): + if not is_cluster(self.client): + return self.client.inspect_volume(volume_name) + + volumes = self.client.volumes(filters={'name': volume_name})['Volumes'] + assert len(volumes) > 0 + return self.client.inspect_volume(volumes[0]['Name']) diff --git a/tests/integration/volume_test.py b/tests/integration/volume_test.py index add169623b0..772631a5b26 100644 --- a/tests/integration/volume_test.py +++ b/tests/integration/volume_test.py @@ -3,6 +3,7 @@ from docker.errors import DockerException +from ..helpers import no_cluster from .testcases import DockerClientTestCase from compose.const import LABEL_PROJECT from compose.const import LABEL_VOLUME @@ -35,26 +36,28 @@ def create_volume(self, name, driver=None, opts=None, external=None): def test_create_volume(self): vol = self.create_volume('volume01') vol.create() - info = self.client.inspect_volume(vol.full_name) - assert info['Name'] == vol.full_name + info = self.get_volume_data(vol.full_name) + assert info['Name'].split('/')[-1] == vol.full_name def test_recreate_existing_volume(self): vol = self.create_volume('volume01') vol.create() - info = self.client.inspect_volume(vol.full_name) - assert info['Name'] == vol.full_name + info = self.get_volume_data(vol.full_name) + assert info['Name'].split('/')[-1] == vol.full_name vol.create() - info = self.client.inspect_volume(vol.full_name) - assert info['Name'] == vol.full_name + info = self.get_volume_data(vol.full_name) + assert info['Name'].split('/')[-1] == vol.full_name + @no_cluster('inspect volume by name defect on Swarm Classic') def test_inspect_volume(self): vol = self.create_volume('volume01') vol.create() info = vol.inspect() assert info['Name'] == vol.full_name + @no_cluster('remove volume by name defect on Swarm Classic') def test_remove_volume(self): vol = Volume(self.client, 'composetest', 'volume01') vol.create() @@ -62,6 +65,7 @@ def test_remove_volume(self): volumes = self.client.volumes()['Volumes'] assert len([v for v in volumes if v['Name'] == vol.full_name]) == 0 + @no_cluster('inspect volume by name defect on Swarm Classic') def test_external_volume(self): vol = self.create_volume('composetest_volume_ext', external=True) assert vol.external is True @@ -70,6 +74,7 @@ def test_external_volume(self): info = vol.inspect() assert info['Name'] == vol.name + @no_cluster('inspect volume by name defect on Swarm Classic') def test_external_aliased_volume(self): alias_name = 'composetest_alias01' vol = self.create_volume('volume01', external=alias_name) @@ -79,24 +84,28 @@ def test_external_aliased_volume(self): info = vol.inspect() assert info['Name'] == alias_name + @no_cluster('inspect volume by name defect on Swarm Classic') def test_exists(self): vol = self.create_volume('volume01') assert vol.exists() is False vol.create() assert vol.exists() is True + @no_cluster('inspect volume by name defect on Swarm Classic') def test_exists_external(self): vol = self.create_volume('volume01', external=True) assert vol.exists() is False vol.create() assert vol.exists() is True + @no_cluster('inspect volume by name defect on Swarm Classic') def test_exists_external_aliased(self): vol = self.create_volume('volume01', external='composetest_alias01') assert vol.exists() is False vol.create() assert vol.exists() is True + @no_cluster('inspect volume by name defect on Swarm Classic') def test_volume_default_labels(self): vol = self.create_volume('volume01') vol.create() From 3bd5a374290831d6c2f090c6e3e80454d0ffa8bb Mon Sep 17 00:00:00 2001 From: NikitaVlaznev Date: Mon, 19 Jun 2017 17:05:19 +0300 Subject: [PATCH 2897/4072] Fix double silent argument value Fix for "TypeError: pull() got multiple values for keyword argument 'silent'." This change https://github.com/docker/compose/commit/e9b6cc23fcf01d4768c7e082b7bc91b43ff84e7e caused additional value to be passed for the 'silent' argument, that was already passed there: https://github.com/docker/compose/commit/f85da99ef3273794e855afda8678174419d3bf4f Signed-off-by: Nikita Vlaznev --- compose/project.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/project.py b/compose/project.py index 3ad971488f0..7f7ade9a2ca 100644 --- a/compose/project.py +++ b/compose/project.py @@ -467,7 +467,7 @@ def pull(self, service_names=None, ignore_pull_failures=False, parallel_pull=Fal if parallel_pull: def pull_service(service): - service.pull(ignore_pull_failures, True, silent=silent) + service.pull(ignore_pull_failures, silent=silent) parallel.parallel_execute( services, From bb4adf2b0f385712e6385901532c738d4e3cc477 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 19 Jun 2017 13:52:56 -0700 Subject: [PATCH 2898/4072] 1.15.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 f6ed1f463b1..1898479bfd1 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.14.0' +__version__ = '1.15.0dev' From 1dfdbe6f94db1d51ff5bec90a5785f3db031ba28 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 23 Jun 2017 15:04:18 -0700 Subject: [PATCH 2899/4072] Fix ports sorting on Python 3 Signed-off-by: Joffrey F --- compose/config/config.py | 2 +- tests/unit/config/config_test.py | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/compose/config/config.py b/compose/config/config.py index b8bffc66040..fdb20df19c9 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -959,7 +959,7 @@ def parse_sequence_func(seq): merged = parse_sequence_func(md.base.get(field, [])) merged.update(parse_sequence_func(md.override.get(field, []))) - md[field] = [item for item in sorted(merged.values())] + md[field] = [item for item in sorted(merged.values(), key=lambda x: x.target)] def merge_build(output, base, override): diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 87bdd8bca9f..6178447ae64 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1615,6 +1615,22 @@ def test_merge_service_dicts_heterogeneous_2(self): 'ports': types.ServicePort.parse('5432') } + def test_merge_service_dicts_ports_sorting(self): + base = { + 'ports': [5432] + } + override = { + 'image': 'alpine:edge', + 'ports': ['5432/udp'] + } + actual = config.merge_service_dicts_from_files( + base, + override, + DEFAULT_VERSION) + assert len(actual['ports']) == 2 + assert types.ServicePort.parse('5432')[0] in actual['ports'] + assert types.ServicePort.parse('5432/udp')[0] in actual['ports'] + def test_merge_service_dicts_heterogeneous_volumes(self): base = { 'volumes': ['/a:/b', '/x:/z'], From 6a957294dff00f905ce6bf5b695753f6558393be Mon Sep 17 00:00:00 2001 From: dinesh Date: Tue, 28 Mar 2017 18:25:27 +0530 Subject: [PATCH 2900/4072] Add storage_opt in v2.1 Signed-off-by: dinesh --- compose/config/config_schema_v2.1.json | 1 + compose/service.py | 2 ++ tests/integration/service_test.py | 7 +++++++ 3 files changed, 10 insertions(+) diff --git a/compose/config/config_schema_v2.1.json b/compose/config/config_schema_v2.1.json index 9004000ea71..5aed9f7b189 100644 --- a/compose/config/config_schema_v2.1.json +++ b/compose/config/config_schema_v2.1.json @@ -229,6 +229,7 @@ "stdin_open": {"type": "boolean"}, "stop_grace_period": {"type": "string", "format": "duration"}, "stop_signal": {"type": "string"}, + "storage_opt": {"type": "object"}, "tmpfs": {"$ref": "#/definitions/string_or_list"}, "tty": {"type": "boolean"}, "ulimits": { diff --git a/compose/service.py b/compose/service.py index 03c41ce6758..7ee63771ad8 100644 --- a/compose/service.py +++ b/compose/service.py @@ -82,6 +82,7 @@ 'restart', 'security_opt', 'shm_size', + 'storage_opt', 'sysctls', 'userns_mode', 'volumes_from', @@ -854,6 +855,7 @@ def _get_container_host_config(self, override_options, one_off=False): volume_driver=options.get('volume_driver'), cpuset_cpus=options.get('cpuset'), cpu_shares=options.get('cpu_shares'), + storage_opt=options.get('storage_opt') ) def get_secret_volumes(self): diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index baf21af3ce8..e0aac2147e7 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -208,6 +208,13 @@ def test_create_container_with_security_opt(self): service.start_container(container) self.assertEqual(set(container.get('HostConfig.SecurityOpt')), set(security_opt)) + def test_create_container_with_storage_opt(self): + storage_opt = {'size': '1G'} + service = self.create_service('db', storage_opt=storage_opt) + container = service.create_container() + service.start_container(container) + self.assertEqual(container.get('HostConfig.StorageOpt'), storage_opt) + def test_create_container_with_mac_address(self): service = self.create_service('db', mac_address='02:42:ac:11:65:43') container = service.create_container() From e22524474aff36461b74c69458c79c5d92553e6c Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 23 Jun 2017 15:28:35 -0700 Subject: [PATCH 2901/4072] Ignore test failures in storage_opt test Signed-off-by: Joffrey F --- 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 e0aac2147e7..c406a8d5e0e 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -208,6 +208,7 @@ def test_create_container_with_security_opt(self): service.start_container(container) self.assertEqual(set(container.get('HostConfig.SecurityOpt')), set(security_opt)) + @pytest.mark.xfail(True, reason='Not supported on most drivers') def test_create_container_with_storage_opt(self): storage_opt = {'size': '1G'} service = self.create_service('db', storage_opt=storage_opt) From b4eaddf9849d42a0d9a4d8db92956bbde8018314 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 23 Jun 2017 15:33:02 -0700 Subject: [PATCH 2902/4072] Add storage_opt to 2.2 schema Signed-off-by: Joffrey F --- compose/config/config_schema_v2.2.json | 1 + 1 file changed, 1 insertion(+) diff --git a/compose/config/config_schema_v2.2.json b/compose/config/config_schema_v2.2.json index e8edb60eda5..87ba26ae494 100644 --- a/compose/config/config_schema_v2.2.json +++ b/compose/config/config_schema_v2.2.json @@ -235,6 +235,7 @@ "stdin_open": {"type": "boolean"}, "stop_grace_period": {"type": "string", "format": "duration"}, "stop_signal": {"type": "string"}, + "storage_opt": {"type": "object"}, "tmpfs": {"$ref": "#/definitions/string_or_list"}, "tty": {"type": "boolean"}, "ulimits": { From 5ee7aacca0f6db7d44683d34b5f775017540fe0a Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 28 Jun 2017 14:31:59 -0700 Subject: [PATCH 2903/4072] Bump docker Python SDK version -> 2.4.2 Signed-off-by: Joffrey F --- compose/config/types.py | 38 +++++++++++++++++++++----------------- requirements.txt | 2 +- setup.py | 2 +- 3 files changed, 23 insertions(+), 19 deletions(-) diff --git a/compose/config/types.py b/compose/config/types.py index 4509bfe67cb..be26971c45b 100644 --- a/compose/config/types.py +++ b/compose/config/types.py @@ -295,24 +295,28 @@ def parse(cls, spec): if not isinstance(spec, dict): result = [] - for k, v in build_port_bindings([spec]).items(): - if '/' in k: - target, proto = k.split('/', 1) - else: - target, proto = (k, None) - for pub in v: - if pub is None: - result.append( - cls(target, None, proto, None, None) - ) - elif isinstance(pub, tuple): - result.append( - cls(target, pub[1], proto, None, pub[0]) - ) + try: + for k, v in build_port_bindings([spec]).items(): + if '/' in k: + target, proto = k.split('/', 1) else: - result.append( - cls(target, pub, proto, None, None) - ) + target, proto = (k, None) + for pub in v: + if pub is None: + result.append( + cls(target, None, proto, None, None) + ) + elif isinstance(pub, tuple): + result.append( + cls(target, pub[1], proto, None, pub[0]) + ) + else: + result.append( + cls(target, pub, proto, None, None) + ) + except ValueError as e: + raise ConfigurationError(str(e)) + return result return [cls( diff --git a/requirements.txt b/requirements.txt index c4545de1e1f..4d506b9f4e5 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.3.0 +docker==2.4.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 8dbb337cc24..0d5bd6adc7f 100644 --- a/setup.py +++ b/setup.py @@ -37,7 +37,7 @@ def find_version(*file_paths): 'requests >= 2.6.1, != 2.11.0, < 2.12', 'texttable >= 0.8.1, < 0.9', 'websocket-client >= 0.32.0, < 1.0', - 'docker >= 2.3.0, < 3.0', + 'docker >= 2.4.2, < 3.0', 'dockerpty >= 0.4.1, < 0.5', 'six >= 1.3.0, < 2', 'jsonschema >= 2.5.1, < 3', From a891fc1d9a4193dea7dabf1f6cf045d0daf6877e Mon Sep 17 00:00:00 2001 From: Evan Shaw Date: Sat, 1 Jul 2017 13:40:02 +1200 Subject: [PATCH 2904/4072] Always silence pull output with --parallel This is how things were prior to the addition of the --quiet flag. Making it not silent produces output that's weird and difficult to read. Signed-off-by: Evan Shaw --- compose/project.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/project.py b/compose/project.py index 7f7ade9a2ca..7951d29742d 100644 --- a/compose/project.py +++ b/compose/project.py @@ -467,7 +467,7 @@ def pull(self, service_names=None, ignore_pull_failures=False, parallel_pull=Fal if parallel_pull: def pull_service(service): - service.pull(ignore_pull_failures, silent=silent) + service.pull(ignore_pull_failures, True) parallel.parallel_execute( services, From 41976b0f7f8191d1cbc1ae1d9c3b6932d075dc12 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 15 Jun 2017 17:01:41 -0700 Subject: [PATCH 2905/4072] Add support for service:name pid config Signed-off-by: Joffrey F --- compose/config/config.py | 2 + compose/config/sort_services.py | 1 + compose/config/validation.py | 15 +++++++ compose/project.py | 26 ++++++++++++ compose/service.py | 49 +++++++++++++++++++++- tests/acceptance/cli_test.py | 25 +++++++++++ tests/fixtures/pid-mode/docker-compose.yml | 17 ++++++++ tests/integration/service_test.py | 5 ++- 8 files changed, 137 insertions(+), 3 deletions(-) create mode 100644 tests/fixtures/pid-mode/docker-compose.yml diff --git a/compose/config/config.py b/compose/config/config.py index fdb20df19c9..fb54425666a 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -44,6 +44,7 @@ from .validation import validate_extends_file_path from .validation import validate_links from .validation import validate_network_mode +from .validation import validate_pid_mode from .validation import validate_service_constraints from .validation import validate_top_level_object from .validation import validate_ulimits @@ -667,6 +668,7 @@ def validate_service(service_config, service_names, config_file): validate_cpu(service_config) validate_ulimits(service_config) validate_network_mode(service_config, service_names) + validate_pid_mode(service_config, service_names) validate_depends_on(service_config, service_names) validate_links(service_config, service_names) diff --git a/compose/config/sort_services.py b/compose/config/sort_services.py index 20ac4461b37..42f548a6dd1 100644 --- a/compose/config/sort_services.py +++ b/compose/config/sort_services.py @@ -38,6 +38,7 @@ def get_service_dependents(service_dict, 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 == get_service_name_from_network_mode(service.get('pid')) or name in service.get('depends_on', [])) ] diff --git a/compose/config/validation.py b/compose/config/validation.py index 856f811c510..0b7961e5a7b 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -172,6 +172,21 @@ def validate_network_mode(service_config, service_names): "is undefined.".format(s=service_config, dep=dependency)) +def validate_pid_mode(service_config, service_names): + pid_mode = service_config.config.get('pid') + if not pid_mode: + return + + dependency = get_service_name_from_network_mode(pid_mode) + if not dependency: + return + if dependency not in service_names: + raise ConfigurationError( + "Service '{s.name}' uses the PID namespace of service '{dep}' which " + "is undefined.".format(s=service_config, dep=dependency) + ) + + def validate_links(service_config, service_names): for link in service_config.config.get('links', []): if link.split(':')[0] not in service_names: diff --git a/compose/project.py b/compose/project.py index 7951d29742d..28af45c7135 100644 --- a/compose/project.py +++ b/compose/project.py @@ -24,10 +24,13 @@ from .network import ProjectNetworks from .service import BuildAction from .service import ContainerNetworkMode +from .service import ContainerPidMode from .service import ConvergenceStrategy from .service import NetworkMode +from .service import PidMode from .service import Service from .service import ServiceNetworkMode +from .service import ServicePidMode from .utils import microseconds_from_time_nano from .volume import ProjectVolumes @@ -97,6 +100,7 @@ def from_config(cls, name, config_data, client): network_mode = project.get_network_mode( service_dict, list(service_networks.keys()) ) + pid_mode = project.get_pid_mode(service_dict) volumes_from = get_volumes_from(project, service_dict) if config_data.version != V1: @@ -121,6 +125,7 @@ def from_config(cls, name, config_data, client): network_mode=network_mode, volumes_from=volumes_from, secrets=secrets, + pid_mode=pid_mode, **service_dict) ) @@ -224,6 +229,27 @@ def get_network_mode(self, service_dict, networks): return NetworkMode(network_mode) + def get_pid_mode(self, service_dict): + pid_mode = service_dict.pop('pid', None) + if not pid_mode: + return PidMode(None) + + service_name = get_service_name_from_network_mode(pid_mode) + if service_name: + return ServicePidMode(self.get_service(service_name)) + + container_name = get_container_name_from_network_mode(pid_mode) + if container_name: + try: + return ContainerPidMode(Container.from_id(self.client, container_name)) + except APIError: + raise ConfigurationError( + "Service '{name}' uses the PID namespace of container '{dep}' which " + "does not exist.".format(name=service_dict['name'], dep=container_name) + ) + + return PidMode(pid_mode) + def start(self, service_names=None, **options): containers = [] diff --git a/compose/service.py b/compose/service.py index 7ee63771ad8..c4fd96c43a8 100644 --- a/compose/service.py +++ b/compose/service.py @@ -157,6 +157,7 @@ def __init__( networks=None, secrets=None, scale=None, + pid_mode=None, **options ): self.name = name @@ -166,6 +167,7 @@ def __init__( self.links = links or [] self.volumes_from = volumes_from or [] self.network_mode = network_mode or NetworkMode(None) + self.pid_mode = pid_mode or PidMode(None) self.networks = networks or {} self.secrets = secrets or [] self.scale_num = scale or 1 @@ -607,15 +609,19 @@ def config_dict(self): def get_dependency_names(self): net_name = self.network_mode.service_name + pid_namespace = self.pid_mode.service_name return ( self.get_linked_service_names() + self.get_volumes_from_names() + ([net_name] if net_name else []) + + ([pid_namespace] if pid_namespace else []) + list(self.options.get('depends_on', {}).keys()) ) def get_dependency_configs(self): net_name = self.network_mode.service_name + pid_namespace = self.pid_mode.service_name + configs = dict( [(name, None) for name in self.get_linked_service_names()] ) @@ -623,6 +629,7 @@ def get_dependency_configs(self): [(name, None) for name in self.get_volumes_from_names()] )) configs.update({net_name: None} if net_name else {}) + configs.update({pid_namespace: None} if pid_namespace else {}) configs.update(self.options.get('depends_on', {})) for svc, config in self.options.get('depends_on', {}).items(): if config['condition'] == CONDITION_STARTED: @@ -833,7 +840,7 @@ def _get_container_host_config(self, override_options, one_off=False): log_config=log_config, extra_hosts=options.get('extra_hosts'), read_only=options.get('read_only'), - pid_mode=options.get('pid'), + pid_mode=self.pid_mode.mode, security_opt=options.get('security_opt'), ipc_mode=options.get('ipc'), cgroup_parent=options.get('cgroup_parent'), @@ -1056,6 +1063,46 @@ def short_id_alias_exists(container, network): return container.short_id in aliases +class PidMode(object): + def __init__(self, mode): + self._mode = mode + + @property + def mode(self): + return self._mode + + @property + def service_name(self): + return None + + +class ServicePidMode(PidMode): + def __init__(self, service): + self.service = service + + @property + def service_name(self): + return self.service.name + + @property + def mode(self): + containers = self.service.containers() + if containers: + return 'container:' + containers[0].id + + log.warn( + "Service %s is trying to use reuse the PID namespace " + "of another service that is not running." % (self.service_name) + ) + return None + + +class ContainerPidMode(PidMode): + def __init__(self, container): + self.container = container + self._mode = 'container:{}'.format(container.id) + + class NetworkMode(object): """A `standard` network mode (ex: host, bridge)""" diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index ba0b5388803..9058fa35b34 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1183,6 +1183,31 @@ def test_up_handles_abort_on_container_exit_code(self): proc.wait() self.assertEqual(proc.returncode, 1) + @v2_only() + def test_up_with_pid_mode(self): + c = self.client.create_container( + 'busybox', 'top', name='composetest_pid_mode_container', + host_config={} + ) + self.addCleanup(self.client.remove_container, c, force=True) + self.client.start(c) + container_mode_source = 'container:{}'.format(c['Id']) + + self.base_dir = 'tests/fixtures/pid-mode' + + self.dispatch(['up', '-d'], None) + + service_mode_source = 'container:{}'.format( + self.project.get_service('container').containers()[0].id) + service_mode_container = self.project.get_service('service').containers()[0] + assert service_mode_container.get('HostConfig.PidMode') == service_mode_source + + container_mode_container = self.project.get_service('container').containers()[0] + assert container_mode_container.get('HostConfig.PidMode') == container_mode_source + + host_mode_container = self.project.get_service('host').containers()[0] + assert host_mode_container.get('HostConfig.PidMode') == 'host' + def test_exec_without_tty(self): self.base_dir = 'tests/fixtures/links-composefile' self.dispatch(['up', '-d', 'console']) diff --git a/tests/fixtures/pid-mode/docker-compose.yml b/tests/fixtures/pid-mode/docker-compose.yml new file mode 100644 index 00000000000..fece5a9f08b --- /dev/null +++ b/tests/fixtures/pid-mode/docker-compose.yml @@ -0,0 +1,17 @@ +version: "2.2" + +services: + service: + image: busybox + command: top + pid: "service:container" + + container: + image: busybox + command: top + pid: "container:composetest_pid_mode_container" + + host: + image: busybox + command: top + pid: host diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index c406a8d5e0e..ccd6c8b005c 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -36,6 +36,7 @@ from compose.service import ConvergencePlan from compose.service import ConvergenceStrategy from compose.service import NetworkMode +from compose.service import PidMode from compose.service import Service from tests.integration.testcases import v2_1_only from tests.integration.testcases import v2_2_only @@ -968,12 +969,12 @@ def test_network_mode_host(self): self.assertEqual(container.get('HostConfig.NetworkMode'), 'host') def test_pid_mode_none_defined(self): - service = self.create_service('web', pid=None) + service = self.create_service('web', pid_mode=None) container = create_and_start_container(service) self.assertEqual(container.get('HostConfig.PidMode'), '') def test_pid_mode_host(self): - service = self.create_service('web', pid='host') + service = self.create_service('web', pid_mode=PidMode('host')) container = create_and_start_container(service) self.assertEqual(container.get('HostConfig.PidMode'), 'host') From 154adc580776cd3a9742ad48a142e7c2918da60f Mon Sep 17 00:00:00 2001 From: Evan Shaw Date: Sat, 25 Feb 2017 13:48:02 +1300 Subject: [PATCH 2906/4072] Align status output for parallel_execute Previously docker-compose would output lines that looked like: Starting service ... done Starting short ... Starting service-with-a-long-name ... done It's difficult to scan down this output and get an idea of what's happening. Now the statuses are aligned, and output looks like this: Starting service ... done Starting short ... Starting service-with-a-long-name ... done To me, this is quite a bit easier to read. Signed-off-by: Evan Shaw --- compose/parallel.py | 18 +++++++++++++----- tests/unit/parallel_test.py | 15 +++++++++++++++ 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/compose/parallel.py b/compose/parallel.py index 34fef71db75..a611fd6e0b0 100644 --- a/compose/parallel.py +++ b/compose/parallel.py @@ -38,7 +38,8 @@ def parallel_execute(objects, func, get_name, msg, get_deps=None, limit=None): writer = ParallelStreamWriter(stream, msg) for obj in objects: - writer.initialize(get_name(obj)) + writer.add_object(get_name(obj)) + writer.write_initial() events = parallel_execute_iter(objects, func, get_deps, limit) @@ -224,12 +225,18 @@ def __init__(self, stream, msg): self.stream = stream self.msg = msg self.lines = [] + self.width = 0 - def initialize(self, obj_index): + def add_object(self, obj_index): + self.lines.append(obj_index) + self.width = max(self.width, len(obj_index)) + + def write_initial(self): if self.msg is None: return - self.lines.append(obj_index) - self.stream.write("{} {} ... \r\n".format(self.msg, obj_index)) + for line in self.lines: + self.stream.write("{} {:<{width}} ... \r\n".format(self.msg, line, + width=self.width)) self.stream.flush() def write(self, obj_index, status): @@ -241,7 +248,8 @@ def write(self, obj_index, status): self.stream.write("%c[%dA" % (27, diff)) # erase self.stream.write("%c[2K\r" % 27) - self.stream.write("{} {} ... {}\r".format(self.msg, obj_index, status)) + self.stream.write("{} {:<{width}} ... {}\r".format(self.msg, obj_index, + status, width=self.width)) # move back down self.stream.write("%c[%dB" % (27, diff)) self.stream.flush() diff --git a/tests/unit/parallel_test.py b/tests/unit/parallel_test.py index d10948eb072..73728fdfd87 100644 --- a/tests/unit/parallel_test.py +++ b/tests/unit/parallel_test.py @@ -115,3 +115,18 @@ def process(x): assert (data_volume, None, APIError) in events assert (db, None, UpstreamError) in events assert (web, None, UpstreamError) in events + + +def test_parallel_execute_alignment(capsys): + results, errors = parallel_execute( + objects=["short", "a very long name"], + func=lambda x: x, + get_name=six.text_type, + msg="Aligning", + ) + + assert errors == {} + + _, err = capsys.readouterr() + a, b = err.split('\n')[:2] + assert a.index('...') == b.index('...') From 4796e04cae551a37f6305888a1bb871475e1570f Mon Sep 17 00:00:00 2001 From: Andy Neff Date: Tue, 16 May 2017 14:21:18 -0400 Subject: [PATCH 2907/4072] Change --volume behavior to add instead of replace mounts Signed-off-by: Andy Neff --- compose/service.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/compose/service.py b/compose/service.py index c4fd96c43a8..326f505200a 100644 --- a/compose/service.py +++ b/compose/service.py @@ -736,6 +736,8 @@ def _get_container_create_options( container_options = dict( (k, self.options[k]) for k in DOCKER_CONFIG_KEYS if k in self.options) + override_options['volumes'] = (container_options.get('volumes', []) + + override_options.get('volumes', [])) container_options.update(override_options) if not container_options.get('name'): From ec4ba7752f3633b2bcd4bf0b33baef625bb1309a Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 23 Jun 2017 13:38:38 -0700 Subject: [PATCH 2908/4072] Fix override volume merging + add acceptance test Signed-off-by: Joffrey F --- compose/service.py | 8 +++- tests/acceptance/cli_test.py | 37 ++++++++++++++++--- .../docker-compose.merge.yml | 9 +++++ 3 files changed, 46 insertions(+), 8 deletions(-) create mode 100644 tests/fixtures/simple-composefile-volume-ready/docker-compose.merge.yml diff --git a/compose/service.py b/compose/service.py index 326f505200a..300ec2852eb 100644 --- a/compose/service.py +++ b/compose/service.py @@ -736,8 +736,7 @@ def _get_container_create_options( container_options = dict( (k, self.options[k]) for k in DOCKER_CONFIG_KEYS if k in self.options) - override_options['volumes'] = (container_options.get('volumes', []) + - override_options.get('volumes', [])) + override_volumes = override_options.pop('volumes', []) container_options.update(override_options) if not container_options.get('name'): @@ -761,6 +760,11 @@ def _get_container_create_options( formatted_ports(container_options.get('ports', [])), self.options) + if 'volumes' in container_options or override_volumes: + container_options['volumes'] = list(set( + container_options.get('volumes', []) + override_volumes + )) + container_options['environment'] = merge_environment( self.options.get('environment'), override_options.get('environment')) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 9058fa35b34..9d2de622dc8 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -609,8 +609,13 @@ def test_run_one_off_with_volume(self): 'simple', 'test', '-f', '/data/example.txt' ], returncode=0) - # FIXME: does not work with Python 3 - # assert cmd_result.stdout.strip() == 'FILE_CONTENT' + + service = self.project.get_service('simple') + container_data = service.containers(one_off=OneOffFilter.only, stopped=True)[0] + mount = container_data.get('Mounts')[0] + assert mount['Source'] == volume_path + assert mount['Destination'] == '/data' + assert mount['Type'] == 'bind' def test_run_one_off_with_multiple_volumes(self): self.base_dir = 'tests/fixtures/simple-composefile-volume-ready' @@ -624,8 +629,6 @@ def test_run_one_off_with_multiple_volumes(self): 'simple', 'test', '-f', '/data/example.txt' ], returncode=0) - # FIXME: does not work with Python 3 - # assert cmd_result.stdout.strip() == 'FILE_CONTENT' self.dispatch([ 'run', @@ -634,8 +637,30 @@ def test_run_one_off_with_multiple_volumes(self): 'simple', 'test', '-f' '/data1/example.txt' ], returncode=0) - # FIXME: does not work with Python 3 - # assert cmd_result.stdout.strip() == 'FILE_CONTENT' + + def test_run_one_off_with_volume_merge(self): + self.base_dir = 'tests/fixtures/simple-composefile-volume-ready' + volume_path = os.path.abspath(os.path.join(os.getcwd(), self.base_dir, 'files')) + create_host_file(self.client, os.path.join(volume_path, 'example.txt')) + + self.dispatch([ + '-f', 'docker-compose.merge.yml', + 'run', + '-v', '{}:/data'.format(volume_path), + 'simple', + 'test', '-f', '/data/example.txt' + ], returncode=0) + + service = self.project.get_service('simple') + container_data = service.containers(one_off=OneOffFilter.only, stopped=True)[0] + mounts = container_data.get('Mounts') + assert len(mounts) == 2 + config_mount = [m for m in mounts if m['Destination'] == '/data1'][0] + override_mount = [m for m in mounts if m['Destination'] == '/data'][0] + + assert config_mount['Type'] == 'volume' + assert override_mount['Source'] == volume_path + assert override_mount['Type'] == 'bind' def test_create_with_force_recreate_and_no_recreate(self): self.dispatch( diff --git a/tests/fixtures/simple-composefile-volume-ready/docker-compose.merge.yml b/tests/fixtures/simple-composefile-volume-ready/docker-compose.merge.yml new file mode 100644 index 00000000000..fe717151677 --- /dev/null +++ b/tests/fixtures/simple-composefile-volume-ready/docker-compose.merge.yml @@ -0,0 +1,9 @@ +version: '2.2' +services: + simple: + image: busybox:latest + volumes: + - datastore:/data1 + +volumes: + datastore: From 0916f124d0d35bc0145b11b82b4721db10c779f1 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 3 Jul 2017 17:12:39 -0700 Subject: [PATCH 2909/4072] `scale` property should be merged according to standard scalar rules Signed-off-by: Joffrey F --- compose/config/config.py | 1 + tests/unit/config/config_test.py | 13 +++++++++++++ 2 files changed, 14 insertions(+) diff --git a/compose/config/config.py b/compose/config/config.py index fb54425666a..86cf1b39d94 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -116,6 +116,7 @@ 'logging', 'network_mode', 'init', + 'scale', ] DOCKER_VALID_URL_PREFIXES = ( diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 6178447ae64..721a428e184 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -2098,6 +2098,19 @@ def test_merge_credential_spec(self): actual = config.merge_service_dicts(base, override, V3_3) assert actual['credential_spec'] == override['credential_spec'] + def test_merge_scale(self): + base = { + 'image': 'bar', + 'scale': 2, + } + + override = { + 'scale': 4, + } + + actual = config.merge_service_dicts(base, override, V2_2) + assert actual == {'image': 'bar', 'scale': 4} + def test_external_volume_config(self): config_details = build_config_details({ 'version': '2', From d475e0c1e3df983406962addd5e778d8d29ba7b2 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 5 Jul 2017 15:13:45 -0700 Subject: [PATCH 2910/4072] Add "network" field to build configuration Signed-off-by: Joffrey F --- compose/config/config.py | 1 + compose/config/config_schema_v2.2.json | 3 ++- compose/service.py | 3 ++- tests/integration/service_test.py | 24 ++++++++++++++++++++++++ tests/unit/service_test.py | 2 ++ 5 files changed, 31 insertions(+), 2 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 86cf1b39d94..4be25188201 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -975,6 +975,7 @@ def to_dict(service): md = MergeDict(to_dict(base), to_dict(override)) md.merge_scalar('context') md.merge_scalar('dockerfile') + md.merge_scalar('network') md.merge_mapping('args', parse_build_arguments) md.merge_field('cache_from', merge_unique_items_lists, default=[]) md.merge_mapping('labels', parse_labels) diff --git a/compose/config/config_schema_v2.2.json b/compose/config/config_schema_v2.2.json index 87ba26ae494..9181e606b26 100644 --- a/compose/config/config_schema_v2.2.json +++ b/compose/config/config_schema_v2.2.json @@ -60,7 +60,8 @@ "dockerfile": {"type": "string"}, "args": {"$ref": "#/definitions/list_or_dict"}, "labels": {"$ref": "#/definitions/list_or_dict"}, - "cache_from": {"$ref": "#/definitions/list_of_strings"} + "cache_from": {"$ref": "#/definitions/list_of_strings"}, + "network": {"type": "string"} }, "additionalProperties": false } diff --git a/compose/service.py b/compose/service.py index 300ec2852eb..53ad4636242 100644 --- a/compose/service.py +++ b/compose/service.py @@ -906,7 +906,8 @@ def build(self, no_cache=False, pull=False, force_rm=False, build_args_override= dockerfile=build_opts.get('dockerfile', None), cache_from=build_opts.get('cache_from', None), labels=build_opts.get('labels', None), - buildargs=build_args + buildargs=build_args, + network_mode=build_opts.get('network', None), ) try: diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index ccd6c8b005c..350f7398b27 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -717,6 +717,30 @@ def test_build_with_build_labels(self): assert service.image() assert service.image()['Config']['Labels']['com.docker.compose.test'] == 'true' + def test_build_with_network(self): + base_dir = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, base_dir) + with open(os.path.join(base_dir, 'Dockerfile'), 'w') as f: + f.write('FROM busybox\n') + f.write('RUN ping -c1 google.local\n') + + net_container = self.client.create_container( + 'busybox', 'top', host_config=self.client.create_host_config( + extra_hosts={'google.local': '8.8.8.8'} + ), name='composetest_build_network' + ) + + self.addCleanup(self.client.remove_container, net_container, force=True) + self.client.start(net_container) + + service = self.create_service('buildwithnet', build={ + 'context': text_type(base_dir), + 'network': 'container:{}'.format(net_container['Id']) + }) + + service.build() + assert service.image() + def test_start_container_stays_unprivileged(self): service = self.create_service('web') container = create_and_start_container(service).inspect() diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 7b7a078f8cd..2b0a2762dc8 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -473,6 +473,7 @@ def test_create_container(self): buildargs={}, labels=None, cache_from=None, + network_mode=None, ) def test_ensure_image_exists_no_build(self): @@ -511,6 +512,7 @@ def test_ensure_image_exists_force_build(self): buildargs={}, labels=None, cache_from=None, + network_mode=None, ) def test_build_does_not_pull(self): From af182bd3cca710680fb941d7fff7029071e3316f Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 3 Jul 2017 15:32:22 -0700 Subject: [PATCH 2911/4072] Add 'socks' extra to help with proxy environment. SOCKS support will be included in the bundled (binary) version Update some packages in requirements.txt and add some implicit deps Signed-off-by: Joffrey F --- requirements.txt | 22 ++++++++++++++-------- setup.py | 1 + 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/requirements.txt b/requirements.txt index 4d506b9f4e5..844921ffd11 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,16 +1,22 @@ -PyYAML==3.11 +PySocks==1.6.7 +PyYAML==3.12 backports.ssl-match-hostname==3.5.0.1; python_version < '3' -cached-property==1.2.0 -colorama==0.3.7 +cached-property==1.3.0 +certifi==2017.4.17 +chardet==3.0.4 +colorama==0.3.9 docker==2.4.2 +docker-pycreds==0.2.1 dockerpty==0.4.1 -docopt==0.6.1 -enum34==1.0.4; python_version < '3.4' +docopt==0.6.2 +enum34==1.1.6; python_version < '3.4' functools32==3.2.3.post2; python_version < '3.2' -ipaddress==1.0.16 -jsonschema==2.5.1 +idna==2.5 +ipaddress==1.0.18 +jsonschema==2.6.0 pypiwin32==219; sys_platform == 'win32' requests==2.11.1 six==1.10.0 -texttable==0.8.4 +texttable==0.8.8 +urllib3==1.21.1 websocket-client==0.32.0 diff --git a/setup.py b/setup.py index 0d5bd6adc7f..dab7a6eea2c 100644 --- a/setup.py +++ b/setup.py @@ -56,6 +56,7 @@ def find_version(*file_paths): ':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'], + 'socks': ['PySocks >= 1.5.6, != 1.5.7, < 2'], } From 2d21bf6a50a7cda0b7c99d7605b0b2c2a89a191d Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 5 Jul 2017 19:21:07 -0700 Subject: [PATCH 2912/4072] Make sure y/n values are quoted in serialized output Signed-off-by: Joffrey F --- compose/config/serialize.py | 15 ++++++++++++++- tests/unit/config/config_test.py | 18 ++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/compose/config/serialize.py b/compose/config/serialize.py index beafe02b965..306f86969b7 100644 --- a/compose/config/serialize.py +++ b/compose/config/serialize.py @@ -21,11 +21,23 @@ def serialize_dict_type(dumper, data): return dumper.represent_dict(data.repr()) +def serialize_string(dumper, data): + """ Ensure boolean-like strings are quoted in the output """ + representer = dumper.represent_str if six.PY3 else dumper.represent_unicode + if data.lower() in ('y', 'n', 'yes', 'no', 'on', 'off', 'true', 'false'): + # Empirically only y/n appears to be an issue, but this might change + # depending on which PyYaml version is being used. Err on safe side. + return dumper.represent_scalar('tag:yaml.org,2002:str', data, style='"') + return representer(data) + + yaml.SafeDumper.add_representer(types.VolumeFromSpec, serialize_config_type) yaml.SafeDumper.add_representer(types.VolumeSpec, serialize_config_type) yaml.SafeDumper.add_representer(types.ServiceSecret, serialize_dict_type) yaml.SafeDumper.add_representer(types.ServiceConfig, serialize_dict_type) yaml.SafeDumper.add_representer(types.ServicePort, serialize_dict_type) +yaml.SafeDumper.add_representer(str, serialize_string) +yaml.SafeDumper.add_representer(six.text_type, serialize_string) def denormalize_config(config, image_digests=None): @@ -58,7 +70,8 @@ def serialize_config(config, image_digests=None): denormalize_config(config, image_digests), default_flow_style=False, indent=2, - width=80) + width=80 + ) def serialize_ns_time_value(value): diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 721a428e184..6731a6bbcbe 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -4163,3 +4163,21 @@ def test_serialize_configs(self): assert secret_sort(serialized_service['configs']) == secret_sort(service_dict['configs']) assert 'configs' in serialized_config assert serialized_config['configs']['two'] == configs_dict['two'] + + def test_serialize_bool_string(self): + cfg = { + 'version': '2.2', + 'services': { + 'web': { + 'image': 'example/web', + 'command': 'true', + 'environment': {'FOO': 'Y', 'BAR': 'on'} + } + } + } + config_dict = config.load(build_config_details(cfg)) + + serialized_config = serialize_config(config_dict) + assert 'command: "true"\n' in serialized_config + assert 'FOO: "Y"\n' in serialized_config + assert 'BAR: "on"\n' in serialized_config From c41057aa523b62504a61d46cc4b05f387bc3c988 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 6 Jul 2017 17:54:45 -0700 Subject: [PATCH 2913/4072] Code warning for the well-intentioned folks that keep wanting to change this Signed-off-by: Joffrey F --- compose/cli/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/compose/cli/__init__.py b/compose/cli/__init__.py index 379059c1a46..2574a311f24 100644 --- a/compose/cli/__init__.py +++ b/compose/cli/__init__.py @@ -17,6 +17,8 @@ env[str('PIP_DISABLE_PIP_VERSION_CHECK')] = str('1') s_cmd = subprocess.Popen( + # DO NOT replace this call with a `sys.executable` call. It breaks the binary + # distribution (with the binary calling itself recursively over and over). ['pip', 'freeze'], stderr=subprocess.PIPE, stdout=subprocess.PIPE, env=env ) From 6ff6528d45ea2fe6cc511dff75250995f9913c9b Mon Sep 17 00:00:00 2001 From: Vadim Semenov Date: Thu, 15 Jun 2017 16:55:18 +0300 Subject: [PATCH 2914/4072] Optimize "extends" without file specification Loading the same config file add about 100ms per each extension service, which results in painfully slow CLI calls when a config consists of a couple of dozens of services. This patch makes Compose re-use config files. Signed-off-by: Vadim Semenov --- compose/config/config.py | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 4be25188201..2b1b99104b2 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -570,12 +570,21 @@ def validate_and_construct_extends(self): 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( - extends_file, self.environment, service_name=service_name - ) - service_config = extended_file.get_service(service_name) + if config_path == self.service_config.filename: + try: + service_config = self.config_file.get_service(service_name) + except KeyError: + raise ConfigurationError( + "Cannot extend service '{}' in {}: Service not found".format( + service_name, config_path) + ) + else: + extends_file = ConfigFile.from_filename(config_path) + validate_config_version([self.config_file, extends_file]) + extended_file = process_config_file( + extends_file, self.environment, service_name=service_name + ) + service_config = extended_file.get_service(service_name) return config_path, service_config, service_name From 56a23bfcd2eec0140589d4b3223e28d47d89fcdb Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 6 Jul 2017 17:25:41 -0700 Subject: [PATCH 2915/4072] Improved version comparisons throughout the codebase Signed-off-by: Joffrey F --- compose/config/config.py | 17 ++++++----- compose/config/errors.py | 2 +- compose/config/interpolation.py | 3 +- compose/config/serialize.py | 9 +++--- compose/const.py | 18 +++++++----- compose/version.py | 10 +++++++ tests/acceptance/cli_test.py | 9 ++++-- tests/integration/project_test.py | 22 +++++++------- tests/integration/testcases.py | 39 +++++++++++-------------- tests/unit/bundle_test.py | 3 +- tests/unit/config/config_test.py | 8 ++--- tests/unit/config/interpolation_test.py | 8 +++-- tests/unit/project_test.py | 28 +++++++++--------- 13 files changed, 97 insertions(+), 79 deletions(-) create mode 100644 compose/version.py diff --git a/compose/config/config.py b/compose/config/config.py index 2b1b99104b2..f5053af8acc 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -18,6 +18,7 @@ from ..utils import build_string_dict from ..utils import parse_nanoseconds_int from ..utils import splitdrive +from ..version import ComposeVersion from .environment import env_vars_from_file from .environment import Environment from .environment import split_env @@ -188,15 +189,16 @@ def version(self): if version == '1': raise ConfigurationError( 'Version in "{}" is invalid. {}' - .format(self.filename, VERSION_EXPLANATION)) + .format(self.filename, VERSION_EXPLANATION) + ) if version == '2': - version = const.COMPOSEFILE_V2_0 + return const.COMPOSEFILE_V2_0 if version == '3': - version = const.COMPOSEFILE_V3_0 + return const.COMPOSEFILE_V3_0 - return version + return ComposeVersion(version) def get_service(self, name): return self.get_service_dicts()[name] @@ -496,7 +498,7 @@ def process_config_file(config_file, environment, service_name=None): 'service', environment) - if config_file.version != V1: + if config_file.version > V1: processed_config = dict(config_file.config) processed_config['services'] = services processed_config['volumes'] = interpolate_config_section( @@ -509,14 +511,13 @@ def process_config_file(config_file, environment, service_name=None): config_file.get_networks(), 'network', environment) - if config_file.version in (const.COMPOSEFILE_V3_1, const.COMPOSEFILE_V3_2, - const.COMPOSEFILE_V3_3): + if config_file.version >= const.COMPOSEFILE_V3_1: processed_config['secrets'] = interpolate_config_section( config_file, config_file.get_secrets(), 'secrets', environment) - if config_file.version in (const.COMPOSEFILE_V3_3): + if config_file.version >= const.COMPOSEFILE_V3_3: processed_config['configs'] = interpolate_config_section( config_file, config_file.get_configs(), diff --git a/compose/config/errors.py b/compose/config/errors.py index ac1d3ac1976..f5c038088d3 100644 --- a/compose/config/errors.py +++ b/compose/config/errors.py @@ -4,7 +4,7 @@ VERSION_EXPLANATION = ( '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", "3.1", "3.2") and place ' + 'Either specify a supported version (e.g "2.2" or "3.3") 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/interpolation.py b/compose/config/interpolation.py index 1b270b9eab6..b13ac591aad 100644 --- a/compose/config/interpolation.py +++ b/compose/config/interpolation.py @@ -7,7 +7,6 @@ import six from .errors import ConfigurationError -from compose.const import COMPOSEFILE_V1 as V1 from compose.const import COMPOSEFILE_V2_0 as V2_0 @@ -28,7 +27,7 @@ def interpolate(self, string): def interpolate_environment_variables(version, config, section, environment): - if version in (V2_0, V1): + if version <= V2_0: interpolator = Interpolator(Template, environment) else: interpolator = Interpolator(TemplateWithDefaults, environment) diff --git a/compose/config/serialize.py b/compose/config/serialize.py index 306f86969b7..84521848db4 100644 --- a/compose/config/serialize.py +++ b/compose/config/serialize.py @@ -7,9 +7,8 @@ from compose.config import types from compose.const import COMPOSEFILE_V1 as V1 from compose.const import COMPOSEFILE_V2_1 as V2_1 -from compose.const import COMPOSEFILE_V2_2 as V2_2 +from compose.const import COMPOSEFILE_V3_0 as V3_0 from compose.const import COMPOSEFILE_V3_2 as V3_2 -from compose.const import COMPOSEFILE_V3_3 as V3_3 def serialize_config_type(dumper, data): @@ -41,7 +40,7 @@ def serialize_string(dumper, data): def denormalize_config(config, image_digests=None): - result = {'version': V2_1 if config.version == V1 else config.version} + result = {'version': str(V2_1) if config.version == V1 else str(config.version)} denormalized_services = [ denormalize_service_dict( service_dict, @@ -107,7 +106,7 @@ def denormalize_service_dict(service_dict, version, image_digest=None): if version == V1 and 'network_mode' not in service_dict: service_dict['network_mode'] = 'bridge' - if 'depends_on' in service_dict and version not in (V2_1, V2_2): + if 'depends_on' in service_dict and (version < V2_1 or version >= V3_0): service_dict['depends_on'] = sorted([ svc for svc in service_dict['depends_on'].keys() ]) @@ -122,7 +121,7 @@ def denormalize_service_dict(service_dict, version, image_digest=None): service_dict['healthcheck']['timeout'] ) - if 'ports' in service_dict and version not in (V3_2, V3_3): + if 'ports' in service_dict and version < V3_2: service_dict['ports'] = [ p.legacy_repr() if isinstance(p, types.ServicePort) else p for p in service_dict['ports'] diff --git a/compose/const.py b/compose/const.py index 36f213897a2..e46de8a733d 100644 --- a/compose/const.py +++ b/compose/const.py @@ -3,6 +3,8 @@ import sys +from .version import ComposeVersion + DEFAULT_TIMEOUT = 10 HTTP_TIMEOUT = 60 IMAGE_EVENTS = ['delete', 'import', 'load', 'pull', 'push', 'save', 'tag', 'untag'] @@ -19,15 +21,15 @@ SECRETS_PATH = '/run/secrets' -COMPOSEFILE_V1 = '1' -COMPOSEFILE_V2_0 = '2.0' -COMPOSEFILE_V2_1 = '2.1' -COMPOSEFILE_V2_2 = '2.2' +COMPOSEFILE_V1 = ComposeVersion('1') +COMPOSEFILE_V2_0 = ComposeVersion('2.0') +COMPOSEFILE_V2_1 = ComposeVersion('2.1') +COMPOSEFILE_V2_2 = ComposeVersion('2.2') -COMPOSEFILE_V3_0 = '3.0' -COMPOSEFILE_V3_1 = '3.1' -COMPOSEFILE_V3_2 = '3.2' -COMPOSEFILE_V3_3 = '3.3' +COMPOSEFILE_V3_0 = ComposeVersion('3.0') +COMPOSEFILE_V3_1 = ComposeVersion('3.1') +COMPOSEFILE_V3_2 = ComposeVersion('3.2') +COMPOSEFILE_V3_3 = ComposeVersion('3.3') API_VERSIONS = { COMPOSEFILE_V1: '1.21', diff --git a/compose/version.py b/compose/version.py new file mode 100644 index 00000000000..0532e16c717 --- /dev/null +++ b/compose/version.py @@ -0,0 +1,10 @@ +from __future__ import absolute_import +from __future__ import unicode_literals + +from distutils.version import LooseVersion + + +class ComposeVersion(LooseVersion): + """ A hashable version object """ + def __hash__(self): + return hash(self.vstring) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 9d2de622dc8..343e4974f9f 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -850,8 +850,13 @@ def test_up_with_networks(self): # Two networks were created: back and front assert sorted(n['Name'].split('/')[-1] for n in networks) == [back_name, front_name] - back_network = [n for n in networks if n['Name'] == back_name][0] - front_network = [n for n in networks if n['Name'] == front_name][0] + # lookup by ID instead of name in case of duplicates + back_network = self.client.inspect_network( + [n for n in networks if n['Name'] == back_name][0]['Id'] + ) + front_network = self.client.inspect_network( + [n for n in networks if n['Name'] == front_name][0]['Id'] + ) web_container = self.project.get_service('web').containers()[0] app_container = self.project.get_service('app').containers()[0] diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 6731f25dd3a..ce95c5f21c4 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -34,6 +34,7 @@ from compose.project import ProjectError from compose.service import ConvergenceStrategy from tests.integration.testcases import v2_1_only +from tests.integration.testcases import v2_2_only from tests.integration.testcases import v2_only from tests.integration.testcases import v3_only @@ -150,7 +151,7 @@ def test_network_mode_from_service(self): name='composetest', client=self.client, config_data=load_config({ - 'version': V2_0, + 'version': str(V2_0), 'services': { 'net': { 'image': 'busybox:latest', @@ -178,7 +179,7 @@ def get_project(): return Project.from_config( name='composetest', config_data=load_config({ - 'version': V2_0, + 'version': str(V2_0), 'services': { 'web': { 'image': 'busybox:latest', @@ -820,7 +821,7 @@ def test_up_with_network_static_addresses(self): def test_up_with_enable_ipv6(self): self.require_api_version('1.23') config_data = build_config( - version=V2_0, + version=V2_1, services=[{ 'name': 'web', 'image': 'busybox:latest', @@ -1003,7 +1004,7 @@ def test_project_up_with_network_label(self): network_name = 'network_with_label' config_data = build_config( - version=V2_0, + version=V2_1, services=[{ 'name': 'web', 'image': 'busybox:latest', @@ -1063,7 +1064,7 @@ def test_project_up_with_volume_labels(self): volume_name = 'volume_with_label' config_data = build_config( - version=V2_0, + version=V2_1, services=[{ 'name': 'web', 'image': 'busybox:latest', @@ -1103,7 +1104,7 @@ def test_project_up_logging_with_multiple_files(self): base_file = config.ConfigFile( 'base.yml', { - 'version': V2_0, + 'version': str(V2_0), 'services': { 'simple': {'image': 'busybox:latest', 'command': 'top'}, 'another': { @@ -1122,7 +1123,7 @@ def test_project_up_logging_with_multiple_files(self): override_file = config.ConfigFile( 'override.yml', { - 'version': V2_0, + 'version': str(V2_0), 'services': { 'another': { 'logging': { @@ -1155,7 +1156,7 @@ def test_project_up_port_mappings_with_multiple_files(self): base_file = config.ConfigFile( 'base.yml', { - 'version': V2_0, + 'version': str(V2_0), 'services': { 'simple': { 'image': 'busybox:latest', @@ -1168,7 +1169,7 @@ def test_project_up_port_mappings_with_multiple_files(self): override_file = config.ConfigFile( 'override.yml', { - 'version': V2_0, + 'version': str(V2_0), 'services': { 'simple': { 'ports': ['1234:1234'] @@ -1186,6 +1187,7 @@ def test_project_up_port_mappings_with_multiple_files(self): containers = project.containers() self.assertEqual(len(containers), 1) + @v2_2_only() def test_project_up_config_scale(self): config_data = build_config( version=V2_2, @@ -1454,7 +1456,7 @@ def test_project_up_named_volumes_in_binds(self): base_file = config.ConfigFile( 'base.yml', { - 'version': V2_0, + 'version': str(V2_0), 'services': { 'simple': { 'image': 'busybox:latest', diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index 1e0d6321502..7d600f32300 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -1,11 +1,10 @@ from __future__ import absolute_import from __future__ import unicode_literals -import functools import os +import pytest from docker.utils import version_lt -from pytest import skip from .. import unittest from ..helpers import is_cluster @@ -17,7 +16,8 @@ from compose.const import COMPOSEFILE_V2_0 as V2_0 from compose.const import COMPOSEFILE_V2_0 as V2_1 from compose.const import COMPOSEFILE_V2_2 as V2_2 -from compose.const import COMPOSEFILE_V3_2 as V3_2 +from compose.const import COMPOSEFILE_V3_0 as V3_0 +from compose.const import COMPOSEFILE_V3_3 as V3_3 from compose.const import LABEL_PROJECT from compose.progress_stream import stream_output from compose.service import Service @@ -43,7 +43,7 @@ def format_link(link): def engine_max_version(): if 'DOCKER_VERSION' not in os.environ: - return V3_2 + return V3_3 version = os.environ['DOCKER_VERSION'].partition('-')[0] if version_lt(version, '1.10'): return V1 @@ -51,37 +51,32 @@ def engine_max_version(): return V2_0 if version_lt(version, '1.13'): return V2_1 - return V3_2 + if version_lt(version, '17.06'): + return V2_2 + return V3_3 -def build_version_required_decorator(ignored_versions): - def decorator(f): - @functools.wraps(f) - def wrapper(self, *args, **kwargs): - 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 - - return decorator +def min_version_skip(version): + return pytest.mark.skipif( + engine_max_version() < version, + reason="Engine version %s is too low" % version + ) def v2_only(): - return build_version_required_decorator((V1,)) + return min_version_skip(V2_0) def v2_1_only(): - return build_version_required_decorator((V1, V2_0)) + return min_version_skip(V2_1) def v2_2_only(): - return build_version_required_decorator((V1, V2_0, V2_1)) + return min_version_skip(V2_0) def v3_only(): - return build_version_required_decorator((V1, V2_0, V2_1, V2_2)) + return min_version_skip(V3_0) class DockerClientTestCase(unittest.TestCase): @@ -137,7 +132,7 @@ def check_build(self, *args, **kwargs): def require_api_version(self, minimum): api_version = self.client.version()['ApiVersion'] if version_lt(api_version, minimum): - skip("API version is too low ({} < {})".format(api_version, minimum)) + pytest.skip("API version is too low ({} < {})".format(api_version, minimum)) def get_volume_data(self, volume_name): if not is_cluster(self.client): diff --git a/tests/unit/bundle_test.py b/tests/unit/bundle_test.py index 3c6e9ec5373..8477952022c 100644 --- a/tests/unit/bundle_test.py +++ b/tests/unit/bundle_test.py @@ -9,6 +9,7 @@ from compose import service from compose.cli.errors import UserError from compose.config.config import Config +from compose.const import COMPOSEFILE_V2_0 as V2_0 @pytest.fixture @@ -74,7 +75,7 @@ def test_to_bundle(): {'name': 'b', 'build': './b'}, ] config = Config( - version=2, + version=V2_0, services=services, volumes={'special': {}}, networks={'extra': {}}, diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 6731a6bbcbe..ac742a19912 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -378,7 +378,7 @@ def test_load_config_link_local_ips_network(self): base_file = config.ConfigFile( 'base.yaml', { - 'version': V2_1, + 'version': str(V2_1), 'services': { 'web': { 'image': 'example/web', @@ -830,7 +830,7 @@ def test_load_with_build_labels(self): service = config.load( build_config_details( { - 'version': V3_3, + 'version': str(V3_3), 'services': { 'web': { 'build': { @@ -1523,7 +1523,7 @@ def test_dns_opt_option(self): def test_isolation_option(self): actual = config.load(build_config_details({ - 'version': V2_1, + 'version': str(V2_1), 'services': { 'web': { 'image': 'win10', @@ -4122,7 +4122,7 @@ def test_serialize_secrets(self): assert serialized_config['secrets']['two'] == secrets_dict['two'] def test_serialize_ports(self): - config_dict = config.Config(version='2.0', services=[ + config_dict = config.Config(version=V2_0, services=[ { 'ports': [types.ServicePort('80', '8080', None, None, None)], 'image': 'alpine', diff --git a/tests/unit/config/interpolation_test.py b/tests/unit/config/interpolation_test.py index 256c74d9bed..018a5621a4c 100644 --- a/tests/unit/config/interpolation_test.py +++ b/tests/unit/config/interpolation_test.py @@ -8,6 +8,8 @@ from compose.config.interpolation import Interpolator from compose.config.interpolation import InvalidInterpolation from compose.config.interpolation import TemplateWithDefaults +from compose.const import COMPOSEFILE_V2_0 as V2_0 +from compose.const import COMPOSEFILE_V3_1 as V3_1 @pytest.fixture @@ -50,7 +52,7 @@ def test_interpolate_environment_variables_in_services(mock_env): } } } - value = interpolate_environment_variables("2.0", services, 'service', mock_env) + value = interpolate_environment_variables(V2_0, services, 'service', mock_env) assert value == expected @@ -75,7 +77,7 @@ def test_interpolate_environment_variables_in_volumes(mock_env): }, 'other': {}, } - value = interpolate_environment_variables("2.0", volumes, 'volume', mock_env) + value = interpolate_environment_variables(V2_0, volumes, 'volume', mock_env) assert value == expected @@ -100,7 +102,7 @@ def test_interpolate_environment_variables_in_secrets(mock_env): }, 'other': {}, } - value = interpolate_environment_variables("3.1", secrets, 'volume', mock_env) + value = interpolate_environment_variables(V3_1, secrets, 'volume', mock_env) assert value == expected diff --git a/tests/unit/project_test.py b/tests/unit/project_test.py index c5366c395ca..e5f1a175f26 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -10,6 +10,8 @@ from .. import unittest from compose.config.config import Config from compose.config.types import VolumeFromSpec +from compose.const import COMPOSEFILE_V1 as V1 +from compose.const import COMPOSEFILE_V2_0 as V2_0 from compose.const import LABEL_SERVICE from compose.container import Container from compose.project import Project @@ -21,9 +23,9 @@ class ProjectTest(unittest.TestCase): def setUp(self): self.mock_client = mock.create_autospec(docker.APIClient) - def test_from_config(self): + def test_from_config_v1(self): config = Config( - version=None, + version=V1, services=[ { 'name': 'web', @@ -53,7 +55,7 @@ def test_from_config(self): def test_from_config_v2(self): config = Config( - version=2, + version=V2_0, services=[ { 'name': 'web', @@ -166,7 +168,7 @@ def test_use_volumes_from_container(self): name='test', client=self.mock_client, config_data=Config( - version=None, + version=V2_0, services=[{ 'name': 'test', 'image': 'busybox:latest', @@ -194,7 +196,7 @@ def test_use_volumes_from_service_no_container(self): name='test', client=self.mock_client, config_data=Config( - version=None, + version=V2_0, services=[ { 'name': 'vol', @@ -221,7 +223,7 @@ def test_use_volumes_from_service_container(self): name='test', client=None, config_data=Config( - version=None, + version=V2_0, services=[ { 'name': 'vol', @@ -361,7 +363,7 @@ def test_net_unset(self): name='test', client=self.mock_client, config_data=Config( - version=None, + version=V1, services=[ { 'name': 'test', @@ -386,7 +388,7 @@ def test_use_net_from_container(self): name='test', client=self.mock_client, config_data=Config( - version=None, + version=V2_0, services=[ { 'name': 'test', @@ -417,7 +419,7 @@ def test_use_net_from_service(self): name='test', client=self.mock_client, config_data=Config( - version=None, + version=V2_0, services=[ { 'name': 'aaa', @@ -444,7 +446,7 @@ def test_uses_default_network_true(self): name='test', client=self.mock_client, config_data=Config( - version=2, + version=V2_0, services=[ { 'name': 'foo', @@ -465,7 +467,7 @@ def test_uses_default_network_false(self): name='test', client=self.mock_client, config_data=Config( - version=2, + version=V2_0, services=[ { 'name': 'foo', @@ -500,7 +502,7 @@ def test_container_without_name(self): name='test', client=self.mock_client, config_data=Config( - version=None, + version=V2_0, services=[{ 'name': 'web', 'image': 'busybox:latest', @@ -518,7 +520,7 @@ def test_down_with_no_resources(self): name='test', client=self.mock_client, config_data=Config( - version='2', + version=V2_0, services=[{ 'name': 'web', 'image': 'busybox:latest', From 344a69331cb3842c6f2f9f18eef22dea10ca21a5 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 11 Jul 2017 19:07:12 -0700 Subject: [PATCH 2916/4072] Bump 1.15.0-rc1 Signed-off-by: Joffrey F --- CHANGELOG.md | 44 ++++++++++++++++++++++++++++++++++ compose/__init__.py | 2 +- script/run/run.sh | 2 +- tests/integration/testcases.py | 5 ++-- 4 files changed, 49 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cced3804cea..c4b97a756a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,50 @@ Change log ========== +1.15.0 (2017-07-18) +------------------- + +### New features + +#### Compose file version 2.2 + +- Added support for the `network` parameter in build configurations. + +#### Compose file version 2.1 and up + +- The `pid` option in a service's definition now supports a `service:` + value. + +- Added support for the `storage_opt` parameter in in service definitions. + This option is not available for the v3 format + +#### All formats + +- Added `--quiet` flag to `docker-compose pull`, suppressing progress output + +- Some improvements to CLI output + +### Bugfixes + +- Volumes specified through the `--volume` flag of `docker-compose run` now + complement volumes declared in the service's defintion instead of replacing + them + +- Fixed a bug where using multiple Compose files would unset the scale value + defined inside the Compose file. + +- Fixed an issue where the `credHelpers` entries in the `config.json` file + were not being honored by Compose + +- Fixed a bug where using multiple Compose files with port declarations + would cause failures in Python 3 environments + +- Fixed a bug where some proxy-related options present in the user's + environment would prevent Compose from running + +- Fixed an issue where the output of `docker-compose config` would be invalid + if the original file used `Y` or `N` values + 1.14.0 (2017-06-19) ------------------- diff --git a/compose/__init__.py b/compose/__init__.py index 1898479bfd1..c040b295f03 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.15.0dev' +__version__ = '1.15.0-rc1' diff --git a/script/run/run.sh b/script/run/run.sh index e4a2f4199f4..4f0f764b3e6 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.14.0" +VERSION="1.15.0-rc1" IMAGE="docker/compose:$VERSION" diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index 7d600f32300..fd30744b16d 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -17,6 +17,7 @@ from compose.const import COMPOSEFILE_V2_0 as V2_1 from compose.const import COMPOSEFILE_V2_2 as V2_2 from compose.const import COMPOSEFILE_V3_0 as V3_0 +from compose.const import COMPOSEFILE_V3_2 as V3_2 from compose.const import COMPOSEFILE_V3_3 as V3_3 from compose.const import LABEL_PROJECT from compose.progress_stream import stream_output @@ -52,7 +53,7 @@ def engine_max_version(): if version_lt(version, '1.13'): return V2_1 if version_lt(version, '17.06'): - return V2_2 + return V3_2 return V3_3 @@ -72,7 +73,7 @@ def v2_1_only(): def v2_2_only(): - return min_version_skip(V2_0) + return min_version_skip(V2_2) def v3_only(): From 6a4adb64f9e992eed24a69220f7f7efd19c90f5c Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 19 Jul 2017 15:01:39 -0700 Subject: [PATCH 2917/4072] Some more test adjustments for Swarm support Signed-off-by: Joffrey F --- .dockerignore | 2 ++ tests/acceptance/cli_test.py | 26 +++++++++----- .../ports-composefile/expanded-notation.yml | 6 ++-- tests/helpers.py | 35 ------------------ tests/integration/project_test.py | 4 +-- tests/integration/service_test.py | 23 +++++++++--- tests/integration/testcases.py | 36 ++++++++++++++++++- tests/integration/volume_test.py | 2 +- tox.ini | 2 ++ 9 files changed, 82 insertions(+), 54 deletions(-) diff --git a/.dockerignore b/.dockerignore index 055ae7ed190..eccd86dda41 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,3 +7,5 @@ coverage-html docs/_site venv .tox +**/__pycache__ +*.pyc diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 343e4974f9f..fc05de3514b 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -20,8 +20,6 @@ from .. import mock from ..helpers import create_host_file -from ..helpers import is_cluster -from ..helpers import no_cluster from compose.cli.command import get_project from compose.config.errors import DuplicateOverrideFileFound from compose.container import Container @@ -29,6 +27,8 @@ 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 is_cluster +from tests.integration.testcases import no_cluster from tests.integration.testcases import pull_busybox from tests.integration.testcases import SWARM_SKIP_RM_VOLUMES from tests.integration.testcases import v2_1_only @@ -116,7 +116,7 @@ def setUp(self): def tearDown(self): if self.base_dir: self.project.kill() - self.project.remove_stopped() + self.project.down(None, True) for container in self.project.containers(stopped=True, one_off=OneOffFilter.only): container.remove(force=True) @@ -1214,6 +1214,7 @@ def test_up_handles_abort_on_container_exit_code(self): self.assertEqual(proc.returncode, 1) @v2_only() + @no_cluster('Container PID mode does not work across clusters') def test_up_with_pid_mode(self): c = self.client.create_container( 'busybox', 'top', name='composetest_pid_mode_container', @@ -1244,8 +1245,8 @@ def test_exec_without_tty(self): self.assertEqual(len(self.project.containers()), 1) stdout, stderr = self.dispatch(['exec', '-T', 'console', 'ls', '-1d', '/']) - self.assertEqual(stdout, "/\n") self.assertEqual(stderr, "") + self.assertEqual(stdout, "/\n") def test_exec_custom_user(self): self.base_dir = 'tests/fixtures/links-composefile' @@ -1826,7 +1827,13 @@ def test_logs_follow(self): result = self.dispatch(['logs', '-f']) - assert result.stdout.count('\n') == 5 + if not is_cluster(self.client): + assert result.stdout.count('\n') == 5 + else: + # Sometimes logs are picked up from old containers that haven't yet + # been removed (removal in Swarm is async) + assert result.stdout.count('\n') >= 5 + assert 'simple' in result.stdout assert 'another' in result.stdout assert 'exited with code 0' in result.stdout @@ -1882,7 +1889,10 @@ def test_logs_tail(self): self.dispatch(['up']) result = self.dispatch(['logs', '--tail', '2']) - assert result.stdout.count('\n') == 3 + assert 'c\n' in result.stdout + assert 'd\n' in result.stdout + assert 'a\n' not in result.stdout + assert 'b\n' not in result.stdout def test_kill(self): self.dispatch(['up', '-d'], None) @@ -2045,8 +2055,8 @@ def get_port(number): return result.stdout.rstrip() assert get_port(3000) == container.get_local_port(3000) - assert ':49152' in get_port(3001) - assert ':49153' in get_port(3002) + assert ':53222' in get_port(3001) + assert ':53223' in get_port(3002) def test_port_with_scale(self): self.base_dir = 'tests/fixtures/ports-composefile-scale' diff --git a/tests/fixtures/ports-composefile/expanded-notation.yml b/tests/fixtures/ports-composefile/expanded-notation.yml index 6fbe59176cb..09a7a2bf998 100644 --- a/tests/fixtures/ports-composefile/expanded-notation.yml +++ b/tests/fixtures/ports-composefile/expanded-notation.yml @@ -6,10 +6,10 @@ services: ports: - target: 3000 - target: 3001 - published: 49152 + published: 53222 - target: 3002 - published: 49153 + published: 53223 protocol: tcp - target: 3003 - published: 49154 + published: 53224 protocol: udp diff --git a/tests/helpers.py b/tests/helpers.py index 662353c93b3..59efd2557c4 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -1,12 +1,8 @@ from __future__ import absolute_import from __future__ import unicode_literals -import functools import os -from docker.errors import APIError -from pytest import skip - from compose.config.config import ConfigDetails from compose.config.config import ConfigFile from compose.config.config import load @@ -48,34 +44,3 @@ def create_host_file(client, filename): "Container exited with code {}:\n{}".format(exitcode, output)) finally: client.remove_container(container, force=True) - - -def is_cluster(client): - nodes = None - - def get_nodes_number(): - try: - return len(client.nodes()) - except APIError: - # If the Engine is not part of a Swarm, the SDK will raise - # an APIError - return 0 - - if nodes is None: - # Only make the API call if the value hasn't been cached yet - nodes = get_nodes_number() - - return nodes > 1 - - -def no_cluster(reason): - def decorator(f): - @functools.wraps(f) - def wrapper(self, *args, **kwargs): - if is_cluster(self.client): - skip("Test will not be run in cluster mode: %s" % reason) - return - return f(self, *args, **kwargs) - return wrapper - - return decorator diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index ce95c5f21c4..5ead7b8e71e 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -12,8 +12,6 @@ from .. import mock from ..helpers import build_config as load_config from ..helpers import create_host_file -from ..helpers import is_cluster -from ..helpers import no_cluster from .testcases import DockerClientTestCase from .testcases import SWARM_SKIP_CONTAINERS_ALL from compose.config import config @@ -33,6 +31,8 @@ from compose.project import Project from compose.project import ProjectError from compose.service import ConvergenceStrategy +from tests.integration.testcases import is_cluster +from tests.integration.testcases import no_cluster from tests.integration.testcases import v2_1_only from tests.integration.testcases import v2_2_only from tests.integration.testcases import v2_only diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 350f7398b27..ff75015df5c 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -13,8 +13,6 @@ from six import text_type from .. import mock -from ..helpers import is_cluster -from ..helpers import no_cluster from .testcases import DockerClientTestCase from .testcases import get_links from .testcases import pull_busybox @@ -38,6 +36,8 @@ from compose.service import NetworkMode from compose.service import PidMode from compose.service import Service +from tests.integration.testcases import is_cluster +from tests.integration.testcases import no_cluster from tests.integration.testcases import v2_1_only from tests.integration.testcases import v2_2_only from tests.integration.testcases import v2_only @@ -635,7 +635,10 @@ def test_build(self): with open(os.path.join(base_dir, 'Dockerfile'), 'w') as f: f.write("FROM busybox\n") - self.create_service('web', build={'context': base_dir}).build() + service = self.create_service('web', build={'context': base_dir}) + service.build() + self.addCleanup(self.client.remove_image, service.image_name) + assert self.client.inspect_image('composetest_web') def test_build_non_ascii_filename(self): @@ -648,7 +651,9 @@ def test_build_non_ascii_filename(self): with open(os.path.join(base_dir.encode('utf8'), b'foo\xE2bar'), 'w') as f: f.write("hello world\n") - self.create_service('web', build={'context': text_type(base_dir)}).build() + service = self.create_service('web', build={'context': text_type(base_dir)}) + service.build() + self.addCleanup(self.client.remove_image, service.image_name) assert self.client.inspect_image('composetest_web') def test_build_with_image_name(self): @@ -683,6 +688,7 @@ def test_build_with_build_args(self): build={'context': text_type(base_dir), 'args': {"build_version": "1"}}) service.build() + self.addCleanup(self.client.remove_image, service.image_name) assert service.image() assert "build_version=1" in service.image()['ContainerConfig']['Cmd'] @@ -699,6 +705,8 @@ def test_build_with_build_args_override(self): build={'context': text_type(base_dir), 'args': {"build_version": "1"}}) service.build(build_args_override={'build_version': '2'}) + self.addCleanup(self.client.remove_image, service.image_name) + assert service.image() assert "build_version=2" in service.image()['ContainerConfig']['Cmd'] @@ -714,9 +722,12 @@ def test_build_with_build_labels(self): 'labels': {'com.docker.compose.test': 'true'} }) service.build() + self.addCleanup(self.client.remove_image, service.image_name) + assert service.image() assert service.image()['Config']['Labels']['com.docker.compose.test'] == 'true' + @no_cluster('Container networks not on Swarm') def test_build_with_network(self): base_dir = tempfile.mkdtemp() self.addCleanup(shutil.rmtree, base_dir) @@ -739,6 +750,8 @@ def test_build_with_network(self): }) service.build() + self.addCleanup(self.client.remove_image, service.image_name) + assert service.image() def test_start_container_stays_unprivileged(self): @@ -1130,6 +1143,8 @@ def test_build_with_cachefrom(self): build={'context': base_dir, 'cache_from': ['build1']}) service.build() + self.addCleanup(self.client.remove_image, service.image_name) + assert service.image() @mock.patch.dict(os.environ) diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index 7d600f32300..53361a8206f 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -1,13 +1,14 @@ from __future__ import absolute_import from __future__ import unicode_literals +import functools import os import pytest +from docker.errors import APIError from docker.utils import version_lt from .. import unittest -from ..helpers import is_cluster from compose.cli.docker_client import docker_client from compose.config.config import resolve_environment from compose.config.environment import Environment @@ -25,6 +26,7 @@ SWARM_SKIP_CONTAINERS_ALL = os.environ.get('SWARM_SKIP_CONTAINERS_ALL', '0') != '0' SWARM_SKIP_CPU_SHARES = os.environ.get('SWARM_SKIP_CPU_SHARES', '0') != '0' SWARM_SKIP_RM_VOLUMES = os.environ.get('SWARM_SKIP_RM_VOLUMES', '0') != '0' +SWARM_ASSUME_MULTINODE = os.environ.get('SWARM_ASSUME_MULTINODE', '0') != '0' def pull_busybox(client): @@ -141,3 +143,35 @@ def get_volume_data(self, volume_name): volumes = self.client.volumes(filters={'name': volume_name})['Volumes'] assert len(volumes) > 0 return self.client.inspect_volume(volumes[0]['Name']) + + +def is_cluster(client): + if SWARM_ASSUME_MULTINODE: + return True + + def get_nodes_number(): + try: + return len(client.nodes()) + except APIError: + # If the Engine is not part of a Swarm, the SDK will raise + # an APIError + return 0 + + if not hasattr(is_cluster, 'nodes') or is_cluster.nodes is None: + # Only make the API call if the value hasn't been cached yet + is_cluster.nodes = get_nodes_number() + + return is_cluster.nodes > 1 + + +def no_cluster(reason): + def decorator(f): + @functools.wraps(f) + def wrapper(self, *args, **kwargs): + if is_cluster(self.client): + pytest.skip("Test will not be run in cluster mode: %s" % reason) + return + return f(self, *args, **kwargs) + return wrapper + + return decorator diff --git a/tests/integration/volume_test.py b/tests/integration/volume_test.py index 772631a5b26..ecc71d0b127 100644 --- a/tests/integration/volume_test.py +++ b/tests/integration/volume_test.py @@ -3,8 +3,8 @@ from docker.errors import DockerException -from ..helpers import no_cluster from .testcases import DockerClientTestCase +from .testcases import no_cluster from compose.const import LABEL_PROJECT from compose.const import LABEL_VOLUME from compose.volume import Volume diff --git a/tox.ini b/tox.ini index 61bc0574583..749be3faaea 100644 --- a/tox.ini +++ b/tox.ini @@ -9,6 +9,8 @@ passenv = DOCKER_CERT_PATH DOCKER_TLS_VERIFY DOCKER_VERSION + SWARM_SKIP_* + SWARM_ASSUME_MULTINODE setenv = HOME=/tmp deps = From 6ed507d865346b21c190020991a57a7df0d1b932 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 20 Jul 2017 14:09:12 -0700 Subject: [PATCH 2918/4072] Scripts build and push compose-tests image Signed-off-by: Joffrey F --- script/build/test-image | 17 +++++++++++++++++ script/release/build-binaries | 3 +++ script/release/push-release | 4 ++++ 3 files changed, 24 insertions(+) create mode 100755 script/build/test-image diff --git a/script/build/test-image b/script/build/test-image new file mode 100755 index 00000000000..216d63f9c32 --- /dev/null +++ b/script/build/test-image @@ -0,0 +1,17 @@ +#!/bin/bash + +set -e + +if [ -z "$1" ]; then + >&2 echo "First argument must be image tag." + exit 1 +fi + +TAG=$1 + +docker build -t docker-compose-tests:tmp . +ctnr_id=$(docker create --entrypoint=tox docker-compose-tests:tmp) +docker commit $ctnr_id docker/compose-tests:latest +docker tag docker/compose-tests:latest docker/compose-tests:$TAG +docker rm -f $ctnr_id +docker rmi -f docker-compose-tests:tmp \ No newline at end of file diff --git a/script/release/build-binaries b/script/release/build-binaries index 9d4a606e252..a39b186d97b 100755 --- a/script/release/build-binaries +++ b/script/release/build-binaries @@ -27,6 +27,9 @@ script/build/linux echo "Building the container distribution" script/build/image $VERSION +echo "Building the compose-tests image" +script/build/test-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 diff --git a/script/release/push-release b/script/release/push-release index 9db6f68941c..0578aaff82f 100755 --- a/script/release/push-release +++ b/script/release/push-release @@ -54,6 +54,10 @@ git push $GITHUB_REPO $VERSION echo "Uploading the docker image" docker push docker/compose:$VERSION +echo "Uploading the compose-tests image" +docker push docker/compose-tests:latest +docker push docker/compose-tests:$VERSION + echo "Uploading package to PyPI" pandoc -f markdown -t rst README.md -o README.rst sed -i -e 's/logo.png?raw=true/https:\/\/github.com\/docker\/compose\/raw\/master\/logo.png?raw=true/' README.rst From 4a650081991e1d0a44053ac7bb4f6712831aa213 Mon Sep 17 00:00:00 2001 From: Yong Wen Chua Date: Mon, 10 Jul 2017 12:35:34 +0800 Subject: [PATCH 2919/4072] Add Compose v2.3 Signed-off-by: Yong Wen Chua --- compose/config/config_schema_v2.3.json | 401 +++++++++++++++++++++++++ compose/const.py | 3 + docker-compose.spec | 5 + tests/integration/testcases.py | 7 +- tests/unit/config/config_test.py | 4 + 5 files changed, 419 insertions(+), 1 deletion(-) create mode 100644 compose/config/config_schema_v2.3.json diff --git a/compose/config/config_schema_v2.3.json b/compose/config/config_schema_v2.3.json new file mode 100644 index 00000000000..abcc2ded262 --- /dev/null +++ b/compose/config/config_schema_v2.3.json @@ -0,0 +1,401 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "id": "config_schema_v2.3.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"}, + "labels": {"$ref": "#/definitions/list_or_dict"}, + "cache_from": {"$ref": "#/definitions/list_of_strings"}, + "network": {"type": "string"} + }, + "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_count": {"type": "integer", "minimum": 0}, + "cpu_percent": {"type": "integer", "minimum": 0, "maximum": 100}, + "cpu_shares": {"type": ["number", "string"]}, + "cpu_quota": {"type": ["number", "string"]}, + "cpus": {"type": "number", "minimum": 0}, + "cpuset": {"type": "string"}, + "depends_on": { + "oneOf": [ + {"$ref": "#/definitions/list_of_strings"}, + { + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "type": "object", + "additionalProperties": false, + "properties": { + "condition": { + "type": "string", + "enum": ["service_started", "service_healthy"] + } + }, + "required": ["condition"] + } + } + } + ] + }, + "devices": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "dns_opt": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + }, + "dns": {"$ref": "#/definitions/string_or_list"}, + "dns_search": {"$ref": "#/definitions/string_or_list"}, + "domainname": {"type": "string"}, + "entrypoint": { + "oneOf": [ + {"type": "string"}, + {"type": "array", "items": {"type": "string"}} + ] + }, + "env_file": {"$ref": "#/definitions/string_or_list"}, + "environment": {"$ref": "#/definitions/list_or_dict"}, + + "expose": { + "type": "array", + "items": { + "type": ["string", "number"], + "format": "expose" + }, + "uniqueItems": true + }, + + "extends": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + + "properties": { + "service": {"type": "string"}, + "file": {"type": "string"} + }, + "required": ["service"], + "additionalProperties": false + } + ] + }, + + "external_links": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "extra_hosts": {"$ref": "#/definitions/list_or_dict"}, + "healthcheck": {"$ref": "#/definitions/healthcheck"}, + "hostname": {"type": "string"}, + "image": {"type": "string"}, + "init": {"type": ["boolean", "string"]}, + "ipc": {"type": "string"}, + "isolation": {"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"]}, + "mem_reservation": {"type": ["string", "integer"]}, + "mem_swappiness": {"type": "integer"}, + "memswap_limit": {"type": ["number", "string"]}, + "network_mode": {"type": "string"}, + + "networks": { + "oneOf": [ + {"$ref": "#/definitions/list_of_strings"}, + { + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "oneOf": [ + { + "type": "object", + "properties": { + "aliases": {"$ref": "#/definitions/list_of_strings"}, + "ipv4_address": {"type": "string"}, + "ipv6_address": {"type": "string"}, + "link_local_ips": {"$ref": "#/definitions/list_of_strings"} + }, + "additionalProperties": false + }, + {"type": "null"} + ] + } + }, + "additionalProperties": false + } + ] + }, + "oom_score_adj": {"type": "integer", "minimum": -1000, "maximum": 1000}, + "group_add": { + "type": "array", + "items": { + "type": ["string", "number"] + }, + "uniqueItems": true + }, + "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"}, + "scale": {"type": "integer"}, + "security_opt": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "shm_size": {"type": ["number", "string"]}, + "sysctls": {"$ref": "#/definitions/list_or_dict"}, + "pids_limit": {"type": ["number", "string"]}, + "stdin_open": {"type": "boolean"}, + "stop_grace_period": {"type": "string", "format": "duration"}, + "stop_signal": {"type": "string"}, + "storage_opt": {"type": "object"}, + "tmpfs": {"$ref": "#/definitions/string_or_list"}, + "tty": {"type": "boolean"}, + "ulimits": { + "type": "object", + "patternProperties": { + "^[a-z]+$": { + "oneOf": [ + {"type": "integer"}, + { + "type":"object", + "properties": { + "hard": {"type": "integer"}, + "soft": {"type": "integer"} + }, + "required": ["soft", "hard"], + "additionalProperties": false + } + ] + } + } + }, + "user": {"type": "string"}, + "userns_mode": {"type": "string"}, + "volumes": {"type": "array", "items": {"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 + }, + + "healthcheck": { + "id": "#/definitions/healthcheck", + "type": "object", + "additionalProperties": false, + "properties": { + "disable": {"type": "boolean"}, + "interval": {"type": "string"}, + "retries": {"type": "number"}, + "test": { + "oneOf": [ + {"type": "string"}, + {"type": "array", "items": {"type": "string"}} + ] + }, + "timeout": {"type": "string"} + } + }, + + "network": { + "id": "#/definitions/network", + "type": "object", + "properties": { + "driver": {"type": "string"}, + "driver_opts": { + "type": "object", + "patternProperties": { + "^.+$": {"type": ["string", "number"]} + } + }, + "ipam": { + "type": "object", + "properties": { + "driver": {"type": "string"}, + "config": { + "type": "array" + }, + "options": { + "type": "object", + "patternProperties": { + "^.+$": {"type": "string"} + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "external": { + "type": ["boolean", "object"], + "properties": { + "name": {"type": "string"} + }, + "additionalProperties": false + }, + "internal": {"type": "boolean"}, + "enable_ipv6": {"type": "boolean"}, + "labels": {"$ref": "#/definitions/list_or_dict"} + }, + "additionalProperties": false + }, + + "volume": { + "id": "#/definitions/volume", + "type": ["object", "null"], + "properties": { + "driver": {"type": "string"}, + "driver_opts": { + "type": "object", + "patternProperties": { + "^.+$": {"type": ["string", "number"]} + } + }, + "external": { + "type": ["boolean", "object"], + "properties": { + "name": {"type": "string"} + }, + "additionalProperties": false + }, + "labels": {"$ref": "#/definitions/list_or_dict"} + }, + "additionalProperties": false + }, + + "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/const.py b/compose/const.py index e46de8a733d..6ea0ea79c5b 100644 --- a/compose/const.py +++ b/compose/const.py @@ -25,6 +25,7 @@ COMPOSEFILE_V2_0 = ComposeVersion('2.0') COMPOSEFILE_V2_1 = ComposeVersion('2.1') COMPOSEFILE_V2_2 = ComposeVersion('2.2') +COMPOSEFILE_V2_3 = ComposeVersion('2.3') COMPOSEFILE_V3_0 = ComposeVersion('3.0') COMPOSEFILE_V3_1 = ComposeVersion('3.1') @@ -36,6 +37,7 @@ COMPOSEFILE_V2_0: '1.22', COMPOSEFILE_V2_1: '1.24', COMPOSEFILE_V2_2: '1.25', + COMPOSEFILE_V2_3: '1.30', COMPOSEFILE_V3_0: '1.25', COMPOSEFILE_V3_1: '1.25', COMPOSEFILE_V3_2: '1.25', @@ -47,6 +49,7 @@ API_VERSIONS[COMPOSEFILE_V2_0]: '1.10.0', API_VERSIONS[COMPOSEFILE_V2_1]: '1.12.0', API_VERSIONS[COMPOSEFILE_V2_2]: '1.13.0', + API_VERSIONS[COMPOSEFILE_V2_3]: '17.06.0', API_VERSIONS[COMPOSEFILE_V3_0]: '1.13.0', API_VERSIONS[COMPOSEFILE_V3_1]: '1.13.0', API_VERSIONS[COMPOSEFILE_V3_2]: '1.13.0', diff --git a/docker-compose.spec b/docker-compose.spec index 8e0d51ae5f8..8dc70c22624 100644 --- a/docker-compose.spec +++ b/docker-compose.spec @@ -37,6 +37,11 @@ exe = EXE(pyz, 'compose/config/config_schema_v2.2.json', 'DATA' ), + ( + 'compose/config/config_schema_v2.3.json', + 'compose/config/config_schema_v2.3.json', + 'DATA' + ), ( 'compose/config/config_schema_v3.0.json', 'compose/config/config_schema_v3.0.json', diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index 53361a8206f..9595fb290ad 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -17,6 +17,7 @@ from compose.const import COMPOSEFILE_V2_0 as V2_0 from compose.const import COMPOSEFILE_V2_0 as V2_1 from compose.const import COMPOSEFILE_V2_2 as V2_2 +from compose.const import COMPOSEFILE_V2_3 as V2_3 from compose.const import COMPOSEFILE_V3_0 as V3_0 from compose.const import COMPOSEFILE_V3_3 as V3_3 from compose.const import LABEL_PROJECT @@ -74,7 +75,11 @@ def v2_1_only(): def v2_2_only(): - return min_version_skip(V2_0) + return min_version_skip(V2_2) + + +def v2_3_only(): + return min_version_skip(V2_3) def v3_only(): diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index ac742a19912..9d42f2b595f 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -28,6 +28,7 @@ from compose.const import COMPOSEFILE_V2_0 as V2_0 from compose.const import COMPOSEFILE_V2_1 as V2_1 from compose.const import COMPOSEFILE_V2_2 as V2_2 +from compose.const import COMPOSEFILE_V2_3 as V2_3 from compose.const import COMPOSEFILE_V3_0 as V3_0 from compose.const import COMPOSEFILE_V3_1 as V3_1 from compose.const import COMPOSEFILE_V3_2 as V3_2 @@ -179,6 +180,9 @@ def test_valid_versions(self): cfg = config.load(build_config_details({'version': '2.2'})) assert cfg.version == V2_2 + cfg = config.load(build_config_details({'version': '2.3'})) + assert cfg.version == V2_3 + for version in ['3', '3.0']: cfg = config.load(build_config_details({'version': version})) assert cfg.version == V3_0 From 1ecf51c2098c02507ad3382b13fef4cdd494cc56 Mon Sep 17 00:00:00 2001 From: Yong Wen Chua Date: Mon, 10 Jul 2017 13:02:47 +0800 Subject: [PATCH 2920/4072] Add `target` to service build configuration Signed-off-by: Yong Wen Chua --- compose/config/config.py | 1 + compose/config/config_schema_v2.3.json | 3 ++- compose/service.py | 1 + tests/integration/service_test.py | 22 ++++++++++++++++++++++ tests/unit/service_test.py | 2 ++ 5 files changed, 28 insertions(+), 1 deletion(-) diff --git a/compose/config/config.py b/compose/config/config.py index f5053af8acc..659b6cd5928 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -986,6 +986,7 @@ def to_dict(service): md.merge_scalar('context') md.merge_scalar('dockerfile') md.merge_scalar('network') + md.merge_scalar('target') md.merge_mapping('args', parse_build_arguments) md.merge_field('cache_from', merge_unique_items_lists, default=[]) md.merge_mapping('labels', parse_labels) diff --git a/compose/config/config_schema_v2.3.json b/compose/config/config_schema_v2.3.json index abcc2ded262..87734027640 100644 --- a/compose/config/config_schema_v2.3.json +++ b/compose/config/config_schema_v2.3.json @@ -61,7 +61,8 @@ "args": {"$ref": "#/definitions/list_or_dict"}, "labels": {"$ref": "#/definitions/list_or_dict"}, "cache_from": {"$ref": "#/definitions/list_of_strings"}, - "network": {"type": "string"} + "network": {"type": "string"}, + "target": {"type": "string"} }, "additionalProperties": false } diff --git a/compose/service.py b/compose/service.py index 53ad4636242..4a55951a41f 100644 --- a/compose/service.py +++ b/compose/service.py @@ -908,6 +908,7 @@ def build(self, no_cache=False, pull=False, force_rm=False, build_args_override= labels=build_opts.get('labels', None), buildargs=build_args, network_mode=build_opts.get('network', None), + target=build_opts.get('target', None), ) try: diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index ff75015df5c..4a5ec5654f1 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -40,6 +40,7 @@ from tests.integration.testcases import no_cluster from tests.integration.testcases import v2_1_only from tests.integration.testcases import v2_2_only +from tests.integration.testcases import v2_3_only from tests.integration.testcases import v2_only from tests.integration.testcases import v3_only @@ -754,6 +755,27 @@ def test_build_with_network(self): assert service.image() + @v2_3_only() + def test_build_with_target(self): + self.require_api_version('1.30') + base_dir = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, base_dir) + + with open(os.path.join(base_dir, 'Dockerfile'), 'w') as f: + f.write('FROM busybox as one\n') + f.write('LABEL com.docker.compose.test.target=one\n') + f.write('FROM busybox as two\n') + f.write('LABEL com.docker.compose.test.target=two\n') + + service = self.create_service('buildlabels', build={ + 'context': text_type(base_dir), + 'target': 'one' + }) + + service.build() + assert service.image() + assert service.image()['Config']['Labels']['com.docker.compose.test.target'] == 'one' + def test_start_container_stays_unprivileged(self): service = self.create_service('web') container = create_and_start_container(service).inspect() diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 2b0a2762dc8..0293695abbb 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -474,6 +474,7 @@ def test_create_container(self): labels=None, cache_from=None, network_mode=None, + target=None, ) def test_ensure_image_exists_no_build(self): @@ -513,6 +514,7 @@ def test_ensure_image_exists_force_build(self): labels=None, cache_from=None, network_mode=None, + target=None, ) def test_build_does_not_pull(self): From b8719c4b11e0f16bda0bdf6b4bea31b7262a59ea Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Fri, 14 Jul 2017 11:34:00 +0200 Subject: [PATCH 2921/4072] Add bash completion for `pull --quiet` 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 57dfd51f5a5..d283a041aa0 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -341,7 +341,7 @@ _docker_compose_ps() { _docker_compose_pull() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "--help --ignore-pull-failures --parallel" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--help --ignore-pull-failures --parallel --quiet" -- "$cur" ) ) ;; *) __docker_compose_services_from_image From 103f7963c406f08a7dcde9a51e3747ff46d1f61a Mon Sep 17 00:00:00 2001 From: Kirin Rastogi Date: Tue, 25 Jul 2017 11:08:01 -0400 Subject: [PATCH 2922/4072] Add exclusion for networkname Signed-off-by: Kirin Rastogi Signed-off-by: Kirin Rastogi --- compose/network.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/compose/network.py b/compose/network.py index fec83916234..0f42eb20a11 100644 --- a/compose/network.py +++ b/compose/network.py @@ -18,7 +18,8 @@ OPTS_EXCEPTIONS = [ 'com.docker.network.driver.overlay.vxlanid_list', - 'com.docker.network.windowsshim.hnsid' + 'com.docker.network.windowsshim.hnsid', + 'com.docker.network.windowsshim.networkname' ] From 31b161045df859e3ca4f4aea865ac2f6aea993c3 Mon Sep 17 00:00:00 2001 From: Alexey Rokhin Date: Wed, 17 May 2017 16:18:28 +0300 Subject: [PATCH 2923/4072] skip cpu_percent test for Linux Signed-off-by: Alexey Rokhin --- 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 350f7398b27..feec6a870dc 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -30,6 +30,7 @@ from compose.const import LABEL_PROJECT from compose.const import LABEL_SERVICE from compose.const import LABEL_VERSION +from compose.const import IS_WINDOWS_PLATFORM from compose.container import Container from compose.errors import OperationFailedError from compose.project import OneOffFilter From fd862f9ca77dcbcf15d818da0d8ce6dea76244e9 Mon Sep 17 00:00:00 2001 From: Alexey Rokhin Date: Wed, 17 May 2017 16:42:43 +0300 Subject: [PATCH 2924/4072] service_test.py reorder imports Signed-off-by: Alexey Rokhin --- 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 feec6a870dc..350f7398b27 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -30,7 +30,6 @@ from compose.const import LABEL_PROJECT from compose.const import LABEL_SERVICE from compose.const import LABEL_VERSION -from compose.const import IS_WINDOWS_PLATFORM from compose.container import Container from compose.errors import OperationFailedError from compose.project import OneOffFilter From 24065a15d89fe060330d9732295e2a1eecb3b793 Mon Sep 17 00:00:00 2001 From: Joel Barciauskas Date: Wed, 12 Apr 2017 17:45:09 -0400 Subject: [PATCH 2925/4072] Add --quiet parameter to docker-compose pull, using existing silent flag Signed-off-by: Joel Barciauskas --- compose/project.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/project.py b/compose/project.py index 28af45c7135..a2a398b37a8 100644 --- a/compose/project.py +++ b/compose/project.py @@ -493,7 +493,7 @@ def pull(self, service_names=None, ignore_pull_failures=False, parallel_pull=Fal if parallel_pull: def pull_service(service): - service.pull(ignore_pull_failures, True) + service.pull(ignore_pull_failures, True, silent=silent) parallel.parallel_execute( services, From 051726696b83dedf1a1cc6e6ef796666cfa875b1 Mon Sep 17 00:00:00 2001 From: NikitaVlaznev Date: Mon, 19 Jun 2017 17:05:19 +0300 Subject: [PATCH 2926/4072] Fix double silent argument value Fix for "TypeError: pull() got multiple values for keyword argument 'silent'." This change https://github.com/docker/compose/commit/e9b6cc23fcf01d4768c7e082b7bc91b43ff84e7e caused additional value to be passed for the 'silent' argument, that was already passed there: https://github.com/docker/compose/commit/f85da99ef3273794e855afda8678174419d3bf4f Signed-off-by: Nikita Vlaznev --- compose/project.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/project.py b/compose/project.py index a2a398b37a8..e07514895b5 100644 --- a/compose/project.py +++ b/compose/project.py @@ -493,7 +493,7 @@ def pull(self, service_names=None, ignore_pull_failures=False, parallel_pull=Fal if parallel_pull: def pull_service(service): - service.pull(ignore_pull_failures, True, silent=silent) + service.pull(ignore_pull_failures, silent=silent) parallel.parallel_execute( services, From 85b908ebefd2069ccafd2f44af5bced1888ee8b0 Mon Sep 17 00:00:00 2001 From: Evan Shaw Date: Sat, 1 Jul 2017 13:40:02 +1200 Subject: [PATCH 2927/4072] Always silence pull output with --parallel This is how things were prior to the addition of the --quiet flag. Making it not silent produces output that's weird and difficult to read. Signed-off-by: Evan Shaw --- compose/project.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/project.py b/compose/project.py index e07514895b5..28af45c7135 100644 --- a/compose/project.py +++ b/compose/project.py @@ -493,7 +493,7 @@ def pull(self, service_names=None, ignore_pull_failures=False, parallel_pull=Fal if parallel_pull: def pull_service(service): - service.pull(ignore_pull_failures, silent=silent) + service.pull(ignore_pull_failures, True) parallel.parallel_execute( services, From c923ea1320d0a31cc8d62edeb5c60867155ef1d0 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 19 Jul 2017 15:01:39 -0700 Subject: [PATCH 2928/4072] Some more test adjustments for Swarm support Signed-off-by: Joffrey F --- .dockerignore | 2 ++ tests/acceptance/cli_test.py | 26 +++++++++----- .../ports-composefile/expanded-notation.yml | 6 ++-- tests/helpers.py | 35 ------------------ tests/integration/project_test.py | 4 +-- tests/integration/service_test.py | 23 +++++++++--- tests/integration/testcases.py | 36 ++++++++++++++++++- tests/integration/volume_test.py | 2 +- tox.ini | 2 ++ 9 files changed, 82 insertions(+), 54 deletions(-) diff --git a/.dockerignore b/.dockerignore index 055ae7ed190..eccd86dda41 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,3 +7,5 @@ coverage-html docs/_site venv .tox +**/__pycache__ +*.pyc diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 343e4974f9f..fc05de3514b 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -20,8 +20,6 @@ from .. import mock from ..helpers import create_host_file -from ..helpers import is_cluster -from ..helpers import no_cluster from compose.cli.command import get_project from compose.config.errors import DuplicateOverrideFileFound from compose.container import Container @@ -29,6 +27,8 @@ 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 is_cluster +from tests.integration.testcases import no_cluster from tests.integration.testcases import pull_busybox from tests.integration.testcases import SWARM_SKIP_RM_VOLUMES from tests.integration.testcases import v2_1_only @@ -116,7 +116,7 @@ def setUp(self): def tearDown(self): if self.base_dir: self.project.kill() - self.project.remove_stopped() + self.project.down(None, True) for container in self.project.containers(stopped=True, one_off=OneOffFilter.only): container.remove(force=True) @@ -1214,6 +1214,7 @@ def test_up_handles_abort_on_container_exit_code(self): self.assertEqual(proc.returncode, 1) @v2_only() + @no_cluster('Container PID mode does not work across clusters') def test_up_with_pid_mode(self): c = self.client.create_container( 'busybox', 'top', name='composetest_pid_mode_container', @@ -1244,8 +1245,8 @@ def test_exec_without_tty(self): self.assertEqual(len(self.project.containers()), 1) stdout, stderr = self.dispatch(['exec', '-T', 'console', 'ls', '-1d', '/']) - self.assertEqual(stdout, "/\n") self.assertEqual(stderr, "") + self.assertEqual(stdout, "/\n") def test_exec_custom_user(self): self.base_dir = 'tests/fixtures/links-composefile' @@ -1826,7 +1827,13 @@ def test_logs_follow(self): result = self.dispatch(['logs', '-f']) - assert result.stdout.count('\n') == 5 + if not is_cluster(self.client): + assert result.stdout.count('\n') == 5 + else: + # Sometimes logs are picked up from old containers that haven't yet + # been removed (removal in Swarm is async) + assert result.stdout.count('\n') >= 5 + assert 'simple' in result.stdout assert 'another' in result.stdout assert 'exited with code 0' in result.stdout @@ -1882,7 +1889,10 @@ def test_logs_tail(self): self.dispatch(['up']) result = self.dispatch(['logs', '--tail', '2']) - assert result.stdout.count('\n') == 3 + assert 'c\n' in result.stdout + assert 'd\n' in result.stdout + assert 'a\n' not in result.stdout + assert 'b\n' not in result.stdout def test_kill(self): self.dispatch(['up', '-d'], None) @@ -2045,8 +2055,8 @@ def get_port(number): return result.stdout.rstrip() assert get_port(3000) == container.get_local_port(3000) - assert ':49152' in get_port(3001) - assert ':49153' in get_port(3002) + assert ':53222' in get_port(3001) + assert ':53223' in get_port(3002) def test_port_with_scale(self): self.base_dir = 'tests/fixtures/ports-composefile-scale' diff --git a/tests/fixtures/ports-composefile/expanded-notation.yml b/tests/fixtures/ports-composefile/expanded-notation.yml index 6fbe59176cb..09a7a2bf998 100644 --- a/tests/fixtures/ports-composefile/expanded-notation.yml +++ b/tests/fixtures/ports-composefile/expanded-notation.yml @@ -6,10 +6,10 @@ services: ports: - target: 3000 - target: 3001 - published: 49152 + published: 53222 - target: 3002 - published: 49153 + published: 53223 protocol: tcp - target: 3003 - published: 49154 + published: 53224 protocol: udp diff --git a/tests/helpers.py b/tests/helpers.py index 662353c93b3..59efd2557c4 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -1,12 +1,8 @@ from __future__ import absolute_import from __future__ import unicode_literals -import functools import os -from docker.errors import APIError -from pytest import skip - from compose.config.config import ConfigDetails from compose.config.config import ConfigFile from compose.config.config import load @@ -48,34 +44,3 @@ def create_host_file(client, filename): "Container exited with code {}:\n{}".format(exitcode, output)) finally: client.remove_container(container, force=True) - - -def is_cluster(client): - nodes = None - - def get_nodes_number(): - try: - return len(client.nodes()) - except APIError: - # If the Engine is not part of a Swarm, the SDK will raise - # an APIError - return 0 - - if nodes is None: - # Only make the API call if the value hasn't been cached yet - nodes = get_nodes_number() - - return nodes > 1 - - -def no_cluster(reason): - def decorator(f): - @functools.wraps(f) - def wrapper(self, *args, **kwargs): - if is_cluster(self.client): - skip("Test will not be run in cluster mode: %s" % reason) - return - return f(self, *args, **kwargs) - return wrapper - - return decorator diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index ce95c5f21c4..5ead7b8e71e 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -12,8 +12,6 @@ from .. import mock from ..helpers import build_config as load_config from ..helpers import create_host_file -from ..helpers import is_cluster -from ..helpers import no_cluster from .testcases import DockerClientTestCase from .testcases import SWARM_SKIP_CONTAINERS_ALL from compose.config import config @@ -33,6 +31,8 @@ from compose.project import Project from compose.project import ProjectError from compose.service import ConvergenceStrategy +from tests.integration.testcases import is_cluster +from tests.integration.testcases import no_cluster from tests.integration.testcases import v2_1_only from tests.integration.testcases import v2_2_only from tests.integration.testcases import v2_only diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 350f7398b27..ff75015df5c 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -13,8 +13,6 @@ from six import text_type from .. import mock -from ..helpers import is_cluster -from ..helpers import no_cluster from .testcases import DockerClientTestCase from .testcases import get_links from .testcases import pull_busybox @@ -38,6 +36,8 @@ from compose.service import NetworkMode from compose.service import PidMode from compose.service import Service +from tests.integration.testcases import is_cluster +from tests.integration.testcases import no_cluster from tests.integration.testcases import v2_1_only from tests.integration.testcases import v2_2_only from tests.integration.testcases import v2_only @@ -635,7 +635,10 @@ def test_build(self): with open(os.path.join(base_dir, 'Dockerfile'), 'w') as f: f.write("FROM busybox\n") - self.create_service('web', build={'context': base_dir}).build() + service = self.create_service('web', build={'context': base_dir}) + service.build() + self.addCleanup(self.client.remove_image, service.image_name) + assert self.client.inspect_image('composetest_web') def test_build_non_ascii_filename(self): @@ -648,7 +651,9 @@ def test_build_non_ascii_filename(self): with open(os.path.join(base_dir.encode('utf8'), b'foo\xE2bar'), 'w') as f: f.write("hello world\n") - self.create_service('web', build={'context': text_type(base_dir)}).build() + service = self.create_service('web', build={'context': text_type(base_dir)}) + service.build() + self.addCleanup(self.client.remove_image, service.image_name) assert self.client.inspect_image('composetest_web') def test_build_with_image_name(self): @@ -683,6 +688,7 @@ def test_build_with_build_args(self): build={'context': text_type(base_dir), 'args': {"build_version": "1"}}) service.build() + self.addCleanup(self.client.remove_image, service.image_name) assert service.image() assert "build_version=1" in service.image()['ContainerConfig']['Cmd'] @@ -699,6 +705,8 @@ def test_build_with_build_args_override(self): build={'context': text_type(base_dir), 'args': {"build_version": "1"}}) service.build(build_args_override={'build_version': '2'}) + self.addCleanup(self.client.remove_image, service.image_name) + assert service.image() assert "build_version=2" in service.image()['ContainerConfig']['Cmd'] @@ -714,9 +722,12 @@ def test_build_with_build_labels(self): 'labels': {'com.docker.compose.test': 'true'} }) service.build() + self.addCleanup(self.client.remove_image, service.image_name) + assert service.image() assert service.image()['Config']['Labels']['com.docker.compose.test'] == 'true' + @no_cluster('Container networks not on Swarm') def test_build_with_network(self): base_dir = tempfile.mkdtemp() self.addCleanup(shutil.rmtree, base_dir) @@ -739,6 +750,8 @@ def test_build_with_network(self): }) service.build() + self.addCleanup(self.client.remove_image, service.image_name) + assert service.image() def test_start_container_stays_unprivileged(self): @@ -1130,6 +1143,8 @@ def test_build_with_cachefrom(self): build={'context': base_dir, 'cache_from': ['build1']}) service.build() + self.addCleanup(self.client.remove_image, service.image_name) + assert service.image() @mock.patch.dict(os.environ) diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index fd30744b16d..1b451ef3c28 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -1,13 +1,14 @@ from __future__ import absolute_import from __future__ import unicode_literals +import functools import os import pytest +from docker.errors import APIError from docker.utils import version_lt from .. import unittest -from ..helpers import is_cluster from compose.cli.docker_client import docker_client from compose.config.config import resolve_environment from compose.config.environment import Environment @@ -26,6 +27,7 @@ SWARM_SKIP_CONTAINERS_ALL = os.environ.get('SWARM_SKIP_CONTAINERS_ALL', '0') != '0' SWARM_SKIP_CPU_SHARES = os.environ.get('SWARM_SKIP_CPU_SHARES', '0') != '0' SWARM_SKIP_RM_VOLUMES = os.environ.get('SWARM_SKIP_RM_VOLUMES', '0') != '0' +SWARM_ASSUME_MULTINODE = os.environ.get('SWARM_ASSUME_MULTINODE', '0') != '0' def pull_busybox(client): @@ -142,3 +144,35 @@ def get_volume_data(self, volume_name): volumes = self.client.volumes(filters={'name': volume_name})['Volumes'] assert len(volumes) > 0 return self.client.inspect_volume(volumes[0]['Name']) + + +def is_cluster(client): + if SWARM_ASSUME_MULTINODE: + return True + + def get_nodes_number(): + try: + return len(client.nodes()) + except APIError: + # If the Engine is not part of a Swarm, the SDK will raise + # an APIError + return 0 + + if not hasattr(is_cluster, 'nodes') or is_cluster.nodes is None: + # Only make the API call if the value hasn't been cached yet + is_cluster.nodes = get_nodes_number() + + return is_cluster.nodes > 1 + + +def no_cluster(reason): + def decorator(f): + @functools.wraps(f) + def wrapper(self, *args, **kwargs): + if is_cluster(self.client): + pytest.skip("Test will not be run in cluster mode: %s" % reason) + return + return f(self, *args, **kwargs) + return wrapper + + return decorator diff --git a/tests/integration/volume_test.py b/tests/integration/volume_test.py index 772631a5b26..ecc71d0b127 100644 --- a/tests/integration/volume_test.py +++ b/tests/integration/volume_test.py @@ -3,8 +3,8 @@ from docker.errors import DockerException -from ..helpers import no_cluster from .testcases import DockerClientTestCase +from .testcases import no_cluster from compose.const import LABEL_PROJECT from compose.const import LABEL_VOLUME from compose.volume import Volume diff --git a/tox.ini b/tox.ini index 61bc0574583..749be3faaea 100644 --- a/tox.ini +++ b/tox.ini @@ -9,6 +9,8 @@ passenv = DOCKER_CERT_PATH DOCKER_TLS_VERIFY DOCKER_VERSION + SWARM_SKIP_* + SWARM_ASSUME_MULTINODE setenv = HOME=/tmp deps = From 686a533c9f76ed5994222351f45beb3da7b7040b Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 20 Jul 2017 14:09:12 -0700 Subject: [PATCH 2929/4072] Scripts build and push compose-tests image Signed-off-by: Joffrey F --- script/build/test-image | 17 +++++++++++++++++ script/release/build-binaries | 3 +++ script/release/push-release | 4 ++++ 3 files changed, 24 insertions(+) create mode 100755 script/build/test-image diff --git a/script/build/test-image b/script/build/test-image new file mode 100755 index 00000000000..216d63f9c32 --- /dev/null +++ b/script/build/test-image @@ -0,0 +1,17 @@ +#!/bin/bash + +set -e + +if [ -z "$1" ]; then + >&2 echo "First argument must be image tag." + exit 1 +fi + +TAG=$1 + +docker build -t docker-compose-tests:tmp . +ctnr_id=$(docker create --entrypoint=tox docker-compose-tests:tmp) +docker commit $ctnr_id docker/compose-tests:latest +docker tag docker/compose-tests:latest docker/compose-tests:$TAG +docker rm -f $ctnr_id +docker rmi -f docker-compose-tests:tmp \ No newline at end of file diff --git a/script/release/build-binaries b/script/release/build-binaries index 9d4a606e252..a39b186d97b 100755 --- a/script/release/build-binaries +++ b/script/release/build-binaries @@ -27,6 +27,9 @@ script/build/linux echo "Building the container distribution" script/build/image $VERSION +echo "Building the compose-tests image" +script/build/test-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 diff --git a/script/release/push-release b/script/release/push-release index 9db6f68941c..0578aaff82f 100755 --- a/script/release/push-release +++ b/script/release/push-release @@ -54,6 +54,10 @@ git push $GITHUB_REPO $VERSION echo "Uploading the docker image" docker push docker/compose:$VERSION +echo "Uploading the compose-tests image" +docker push docker/compose-tests:latest +docker push docker/compose-tests:$VERSION + echo "Uploading package to PyPI" pandoc -f markdown -t rst README.md -o README.rst sed -i -e 's/logo.png?raw=true/https:\/\/github.com\/docker\/compose\/raw\/master\/logo.png?raw=true/' README.rst From 0e9308085094ab94db90cad0e1b12507f438de9a Mon Sep 17 00:00:00 2001 From: Kirin Rastogi Date: Tue, 25 Jul 2017 11:08:01 -0400 Subject: [PATCH 2930/4072] Add exclusion for networkname Signed-off-by: Kirin Rastogi Signed-off-by: Kirin Rastogi --- compose/network.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/compose/network.py b/compose/network.py index fec83916234..0f42eb20a11 100644 --- a/compose/network.py +++ b/compose/network.py @@ -18,7 +18,8 @@ OPTS_EXCEPTIONS = [ 'com.docker.network.driver.overlay.vxlanid_list', - 'com.docker.network.windowsshim.hnsid' + 'com.docker.network.windowsshim.hnsid', + 'com.docker.network.windowsshim.networkname' ] From ade23b585ef3fdbdb2734ffced1008bb951ef7eb Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 25 Jul 2017 16:02:11 -0700 Subject: [PATCH 2931/4072] Bump 1.15.0 Signed-off-by: Joffrey F --- CHANGELOG.md | 5 ++++- compose/__init__.py | 2 +- script/run/run.sh | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c4b97a756a1..9289227826d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ Change log ========== -1.15.0 (2017-07-18) +1.15.0 (2017-07-26) ------------------- ### New features @@ -45,6 +45,9 @@ Change log - Fixed an issue where the output of `docker-compose config` would be invalid if the original file used `Y` or `N` values +- Fixed an issue preventing `up` operations on a previously created stack on + Windows Engine. + 1.14.0 (2017-06-19) ------------------- diff --git a/compose/__init__.py b/compose/__init__.py index c040b295f03..f238607c0ee 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.15.0-rc1' +__version__ = '1.15.0' diff --git a/script/run/run.sh b/script/run/run.sh index 4f0f764b3e6..47a81c7f874 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.15.0-rc1" +VERSION="1.15.0" IMAGE="docker/compose:$VERSION" From 9502408ff065239f3d29848a4c0d00d7c4dd29a4 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 26 Jul 2017 16:44:54 -0700 Subject: [PATCH 2932/4072] Fix test issues with Engine 17.07 RC1 Signed-off-by: Joffrey F --- tests/acceptance/cli_test.py | 3 ++- tests/integration/service_test.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index fc05de3514b..f7ecba9f52a 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -439,7 +439,8 @@ def test_pull_with_ignore_pull_failures(self): assert 'Pulling simple (busybox:latest)...' in result.stderr assert 'Pulling another (nonexisting-image:latest)...' in result.stderr assert ('repository nonexisting-image not found' in result.stderr or - 'image library/nonexisting-image:latest not found' in result.stderr) + 'image library/nonexisting-image:latest not found' in result.stderr or + 'pull access denied for nonexisting-image' in result.stderr) def test_pull_with_quiet(self): assert self.dispatch(['pull', '--quiet']).stderr == '' diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 4a5ec5654f1..3a585ec016a 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -210,7 +210,8 @@ def test_create_container_with_security_opt(self): service.start_container(container) self.assertEqual(set(container.get('HostConfig.SecurityOpt')), set(security_opt)) - @pytest.mark.xfail(True, reason='Not supported on most drivers') + # @pytest.mark.xfail(True, reason='Not supported on most drivers') + @pytest.mark.skipif(True, reason='https://github.com/moby/moby/issues/34270') def test_create_container_with_storage_opt(self): storage_opt = {'size': '1G'} service = self.create_service('db', storage_opt=storage_opt) From 7abae9f5368fed01e9d30cce915bbfd71df471c8 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 25 Jul 2017 16:42:31 -0700 Subject: [PATCH 2933/4072] Escape dollar sign in serialized config output Signed-off-by: Joffrey F --- compose/config/serialize.py | 5 ++++- tests/unit/config/config_test.py | 22 ++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/compose/config/serialize.py b/compose/config/serialize.py index 84521848db4..dca0affb2a3 100644 --- a/compose/config/serialize.py +++ b/compose/config/serialize.py @@ -21,8 +21,11 @@ def serialize_dict_type(dumper, data): def serialize_string(dumper, data): - """ Ensure boolean-like strings are quoted in the output """ + """ Ensure boolean-like strings are quoted in the output and escape $ characters """ representer = dumper.represent_str if six.PY3 else dumper.represent_unicode + + data = data.replace('$', '$$') + if data.lower() in ('y', 'n', 'yes', 'no', 'on', 'off', 'true', 'false'): # Empirically only y/n appears to be an issue, but this might change # depending on which PyYaml version is being used. Err on safe side. diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 9d42f2b595f..63cb7eaefb6 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -4185,3 +4185,25 @@ def test_serialize_bool_string(self): assert 'command: "true"\n' in serialized_config assert 'FOO: "Y"\n' in serialized_config assert 'BAR: "on"\n' in serialized_config + + def test_serialize_escape_dollar_sign(self): + cfg = { + 'version': '2.2', + 'services': { + 'web': { + 'image': 'busybox', + 'command': 'echo $$FOO', + 'environment': { + 'CURRENCY': '$$' + }, + 'entrypoint': ['$$SHELL', '-c'], + } + } + } + config_dict = config.load(build_config_details(cfg)) + + serialized_config = yaml.load(serialize_config(config_dict)) + serialized_service = serialized_config['services']['web'] + assert serialized_service['environment']['CURRENCY'] == '$$' + assert serialized_service['command'] == 'echo $$FOO' + assert serialized_service['entrypoint'][0] == '$$SHELL' From 8102f02cfc885099af5b09b15d941621927bde3e Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 25 Jul 2017 16:27:10 -0700 Subject: [PATCH 2934/4072] 0 is a valid value for a published port Signed-off-by: Joffrey F --- compose/config/types.py | 2 +- tests/unit/config/types_test.py | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/compose/config/types.py b/compose/config/types.py index be26971c45b..c410343b886 100644 --- a/compose/config/types.py +++ b/compose/config/types.py @@ -343,7 +343,7 @@ def legacy_repr(self): def normalize_port_dict(port): return '{external_ip}{has_ext_ip}{published}{is_pub}{target}/{protocol}'.format( published=port.get('published', ''), - is_pub=(':' if port.get('published') or port.get('external_ip') else ''), + is_pub=(':' if port.get('published') is not None or port.get('external_ip') else ''), target=port.get('target'), protocol=port.get('protocol', 'tcp'), external_ip=port.get('external_ip', ''), diff --git a/tests/unit/config/types_test.py b/tests/unit/config/types_test.py index 10b698fe3d5..3a43f727bd9 100644 --- a/tests/unit/config/types_test.py +++ b/tests/unit/config/types_test.py @@ -81,6 +81,12 @@ def test_parse_ext_ip_no_published_port(self): 'external_ip': '1.1.1.1', } + def test_repr_published_port_0(self): + port_def = '0:4000' + ports = ServicePort.parse(port_def) + assert len(ports) == 1 + assert ports[0].legacy_repr() == port_def + '/tcp' + def test_parse_port_range(self): ports = ServicePort.parse('25000-25001:4000-4001') assert len(ports) == 2 From d668fd1c67702ae09235f14106e0af5dc3136aff Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 11 Jul 2017 19:07:12 -0700 Subject: [PATCH 2935/4072] Bump 1.15.0-rc1 Signed-off-by: Joffrey F --- CHANGELOG.md | 44 ++++++++++++++++++++++++++++++++++ compose/__init__.py | 2 +- script/run/run.sh | 2 +- tests/integration/testcases.py | 3 ++- 4 files changed, 48 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cced3804cea..c4b97a756a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,50 @@ Change log ========== +1.15.0 (2017-07-18) +------------------- + +### New features + +#### Compose file version 2.2 + +- Added support for the `network` parameter in build configurations. + +#### Compose file version 2.1 and up + +- The `pid` option in a service's definition now supports a `service:` + value. + +- Added support for the `storage_opt` parameter in in service definitions. + This option is not available for the v3 format + +#### All formats + +- Added `--quiet` flag to `docker-compose pull`, suppressing progress output + +- Some improvements to CLI output + +### Bugfixes + +- Volumes specified through the `--volume` flag of `docker-compose run` now + complement volumes declared in the service's defintion instead of replacing + them + +- Fixed a bug where using multiple Compose files would unset the scale value + defined inside the Compose file. + +- Fixed an issue where the `credHelpers` entries in the `config.json` file + were not being honored by Compose + +- Fixed a bug where using multiple Compose files with port declarations + would cause failures in Python 3 environments + +- Fixed a bug where some proxy-related options present in the user's + environment would prevent Compose from running + +- Fixed an issue where the output of `docker-compose config` would be invalid + if the original file used `Y` or `N` values + 1.14.0 (2017-06-19) ------------------- diff --git a/compose/__init__.py b/compose/__init__.py index 1898479bfd1..c040b295f03 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.15.0dev' +__version__ = '1.15.0-rc1' diff --git a/script/run/run.sh b/script/run/run.sh index e4a2f4199f4..4f0f764b3e6 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.14.0" +VERSION="1.15.0-rc1" IMAGE="docker/compose:$VERSION" diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index 9595fb290ad..b1763b113cc 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -19,6 +19,7 @@ from compose.const import COMPOSEFILE_V2_2 as V2_2 from compose.const import COMPOSEFILE_V2_3 as V2_3 from compose.const import COMPOSEFILE_V3_0 as V3_0 +from compose.const import COMPOSEFILE_V3_2 as V3_2 from compose.const import COMPOSEFILE_V3_3 as V3_3 from compose.const import LABEL_PROJECT from compose.progress_stream import stream_output @@ -55,7 +56,7 @@ def engine_max_version(): if version_lt(version, '1.13'): return V2_1 if version_lt(version, '17.06'): - return V2_2 + return V3_2 return V3_3 From f71293bb76376d04ab57878ce7dfc180a4e4fd5e Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 25 Jul 2017 16:02:11 -0700 Subject: [PATCH 2936/4072] Bump 1.15.0 Signed-off-by: Joffrey F --- CHANGELOG.md | 5 ++++- compose/__init__.py | 2 +- script/run/run.sh | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c4b97a756a1..9289227826d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ Change log ========== -1.15.0 (2017-07-18) +1.15.0 (2017-07-26) ------------------- ### New features @@ -45,6 +45,9 @@ Change log - Fixed an issue where the output of `docker-compose config` would be invalid if the original file used `Y` or `N` values +- Fixed an issue preventing `up` operations on a previously created stack on + Windows Engine. + 1.14.0 (2017-06-19) ------------------- diff --git a/compose/__init__.py b/compose/__init__.py index c040b295f03..f238607c0ee 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.15.0-rc1' +__version__ = '1.15.0' diff --git a/script/run/run.sh b/script/run/run.sh index 4f0f764b3e6..47a81c7f874 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.15.0-rc1" +VERSION="1.15.0" IMAGE="docker/compose:$VERSION" From a7dae73aa3f5ad82d7b39797f61545068628dbf2 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 26 Jul 2017 18:21:30 -0700 Subject: [PATCH 2937/4072] 1.16.0-dev 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 f238607c0ee..cedb7cf040d 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.15.0' +__version__ = '1.16.0-dev' From 8ea0e8e053773e1a2b62e6cd221efe72b8763dbc Mon Sep 17 00:00:00 2001 From: Carl George Date: Wed, 26 Jul 2017 16:50:38 -0500 Subject: [PATCH 2938/4072] only require colorama on windows Colorama is only useful on Windows by design. Since it has no effect on other platforms, it makes sense to not require it universally. Signed-off-by: Carl George --- compose/cli/colors.py | 6 ++++-- requirements.txt | 2 +- setup.py | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/compose/cli/colors.py b/compose/cli/colors.py index f1251e43134..cb30e361598 100644 --- a/compose/cli/colors.py +++ b/compose/cli/colors.py @@ -1,7 +1,7 @@ from __future__ import absolute_import from __future__ import unicode_literals -import colorama +from ..const import IS_WINDOWS_PLATFORM NAMES = [ 'grey', @@ -33,7 +33,9 @@ def make_color_fn(code): return lambda s: ansi_color(code, s) -colorama.init(strip=False) +if IS_WINDOWS_PLATFORM: + import colorama + colorama.init(strip=False) for (name, code) in get_pairs(): globals()[name] = make_color_fn(code) diff --git a/requirements.txt b/requirements.txt index 844921ffd11..81dcdf08c7f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ backports.ssl-match-hostname==3.5.0.1; python_version < '3' cached-property==1.3.0 certifi==2017.4.17 chardet==3.0.4 -colorama==0.3.9 +colorama==0.3.9; sys_platform == 'win32' docker==2.4.2 docker-pycreds==0.2.1 dockerpty==0.4.1 diff --git a/setup.py b/setup.py index dab7a6eea2c..a3072c76ea0 100644 --- a/setup.py +++ b/setup.py @@ -31,7 +31,6 @@ 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', @@ -56,6 +55,7 @@ def find_version(*file_paths): ':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'], + ':sys_platform == "win32"': ['colorama >= 0.3.7, < 0.4'], 'socks': ['PySocks >= 1.5.6, != 1.5.7, < 2'], } From 0c4fc93895b18f11b5ab5b63923c85d3324ea8f3 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 2 Aug 2017 19:55:58 -0700 Subject: [PATCH 2939/4072] Use newer versions of pre-commit hooks Signed-off-by: Joffrey F --- .pre-commit-config.yaml | 4 ++-- bin/docker-compose | 3 +++ requirements.txt | 4 ++-- script/build/test-image | 2 +- script/setup/osx | 1 - tests/fixtures/default-env-file/.env | 2 +- tests/fixtures/env-file/test.env | 2 +- 7 files changed, 10 insertions(+), 8 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0e7b9d5f3bb..b7bcc8466c5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,5 +1,5 @@ - repo: git://github.com/pre-commit/pre-commit-hooks - sha: 'v0.4.2' + sha: 'v0.9.1' hooks: - id: check-added-large-files - id: check-docstring-first @@ -14,7 +14,7 @@ - id: requirements-txt-fixer - id: trailing-whitespace - repo: git://github.com/asottile/reorder_python_imports - sha: v0.1.0 + sha: v0.3.5 hooks: - id: reorder-python-imports language_version: 'python2.7' diff --git a/bin/docker-compose b/bin/docker-compose index 5976e1d4aa5..aeb53870303 100755 --- a/bin/docker-compose +++ b/bin/docker-compose @@ -1,3 +1,6 @@ #!/usr/bin/env python +from __future__ import absolute_import +from __future__ import unicode_literals + from compose.cli.main import main main() diff --git a/requirements.txt b/requirements.txt index 81dcdf08c7f..826c31eb127 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,3 @@ -PySocks==1.6.7 -PyYAML==3.12 backports.ssl-match-hostname==3.5.0.1; python_version < '3' cached-property==1.3.0 certifi==2017.4.17 @@ -15,6 +13,8 @@ idna==2.5 ipaddress==1.0.18 jsonschema==2.6.0 pypiwin32==219; sys_platform == 'win32' +PySocks==1.6.7 +PyYAML==3.12 requests==2.11.1 six==1.10.0 texttable==0.8.8 diff --git a/script/build/test-image b/script/build/test-image index 216d63f9c32..a2eb62cdf78 100755 --- a/script/build/test-image +++ b/script/build/test-image @@ -14,4 +14,4 @@ ctnr_id=$(docker create --entrypoint=tox docker-compose-tests:tmp) docker commit $ctnr_id docker/compose-tests:latest docker tag docker/compose-tests:latest docker/compose-tests:$TAG docker rm -f $ctnr_id -docker rmi -f docker-compose-tests:tmp \ No newline at end of file +docker rmi -f docker-compose-tests:tmp diff --git a/script/setup/osx b/script/setup/osx index e6ab62a8473..e0c2bd0a24e 100755 --- a/script/setup/osx +++ b/script/setup/osx @@ -50,4 +50,3 @@ echo "*** Using $(openssl_version)" if !(which virtualenv); then pip install virtualenv fi - diff --git a/tests/fixtures/default-env-file/.env b/tests/fixtures/default-env-file/.env index 996c886cb28..9056de724cd 100644 --- a/tests/fixtures/default-env-file/.env +++ b/tests/fixtures/default-env-file/.env @@ -1,4 +1,4 @@ IMAGE=alpine:latest COMMAND=true PORT1=5643 -PORT2=9999 \ No newline at end of file +PORT2=9999 diff --git a/tests/fixtures/env-file/test.env b/tests/fixtures/env-file/test.env index c9604dad5b3..d99cd41a4b7 100644 --- a/tests/fixtures/env-file/test.env +++ b/tests/fixtures/env-file/test.env @@ -1 +1 @@ -FOO=1 \ No newline at end of file +FOO=1 From 3ea8a20cfa37e9d3e44fe0c8b3ddd3ebef2e69a9 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 2 Aug 2017 19:23:39 -0700 Subject: [PATCH 2940/4072] Fix ServiceExtendsResolver same-file detection Signed-off-by: Joffrey F --- compose/config/config.py | 2 +- tests/unit/config/config_test.py | 36 ++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/compose/config/config.py b/compose/config/config.py index 659b6cd5928..cb25a25a6b1 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -571,7 +571,7 @@ def validate_and_construct_extends(self): config_path = self.get_extended_config_path(extends) service_name = extends['service'] - if config_path == self.service_config.filename: + if config_path == self.config_file.filename: try: service_config = self.config_file.get_service(service_name) except KeyError: diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 63cb7eaefb6..fd06db7d139 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -702,6 +702,42 @@ def test_load_with_multiple_files_and_extends_in_override_file(self): ] self.assertEqual(service_sort(service_dicts), service_sort(expected)) + def test_load_mixed_extends_resolution(self): + main_file = config.ConfigFile( + 'main.yml', { + 'version': '2.2', + 'services': { + 'prodweb': { + 'extends': { + 'service': 'web', + 'file': 'base.yml' + }, + 'environment': {'PROD': 'true'}, + }, + }, + } + ) + + tmpdir = pytest.ensuretemp('config_test') + self.addCleanup(tmpdir.remove) + tmpdir.join('base.yml').write(""" + version: '2.2' + services: + base: + image: base + web: + extends: base + """) + + details = config.ConfigDetails('.', [main_file]) + with tmpdir.as_cwd(): + service_dicts = config.load(details).services + assert service_dicts[0] == { + 'name': 'prodweb', + 'image': 'base', + 'environment': {'PROD': 'true'}, + } + def test_load_with_multiple_files_and_invalid_override(self): base_file = config.ConfigFile( 'base.yaml', From f9aaa72c54957ee834141bdf7f7ec9b656f26697 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 2 Aug 2017 17:02:30 -0700 Subject: [PATCH 2941/4072] Bump texttable 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 826c31eb127..d1778990f41 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,6 +17,6 @@ PySocks==1.6.7 PyYAML==3.12 requests==2.11.1 six==1.10.0 -texttable==0.8.8 +texttable==0.9.1 urllib3==1.21.1 websocket-client==0.32.0 diff --git a/setup.py b/setup.py index a3072c76ea0..16493f52b96 100644 --- a/setup.py +++ b/setup.py @@ -34,7 +34,7 @@ def find_version(*file_paths): 'docopt >= 0.6.1, < 0.7', 'PyYAML >= 3.10, < 4', 'requests >= 2.6.1, != 2.11.0, < 2.12', - 'texttable >= 0.8.1, < 0.9', + 'texttable >= 0.9.0, < 0.10', 'websocket-client >= 0.32.0, < 1.0', 'docker >= 2.4.2, < 3.0', 'dockerpty >= 0.4.1, < 0.5', From b0b671dbf21cbbe749f209ac4217d3e35a92fdaa Mon Sep 17 00:00:00 2001 From: Cecile Tonglet Date: Fri, 23 Jun 2017 15:17:38 +0200 Subject: [PATCH 2942/4072] Add a flag --no-ansi to remove control characters on parallel executions Signed-off-by: Cecile Tonglet --- compose/cli/command.py | 5 +++-- compose/cli/main.py | 1 + compose/parallel.py | 45 ++++++++++++++++++++++++------------- compose/project.py | 32 ++++++++++++++++---------- compose/service.py | 12 +++++++--- tests/unit/parallel_test.py | 16 +++++++++++++ 6 files changed, 78 insertions(+), 33 deletions(-) diff --git a/compose/cli/command.py b/compose/cli/command.py index e1ae690c0eb..f5330d1c202 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -31,6 +31,7 @@ def project_from_options(project_dir, options): get_config_path_from_options(project_dir, options, environment), project_name=options.get('--project-name'), verbose=options.get('--verbose'), + noansi=options.get('--no-ansi'), host=host, tls_config=tls_config_from_options(options), environment=environment, @@ -81,7 +82,7 @@ def get_client(environment, verbose=False, version=None, tls_config=None, host=N def get_project(project_dir, config_path=None, project_name=None, verbose=False, - host=None, tls_config=None, environment=None, override_dir=None): + noansi=False, host=None, tls_config=None, environment=None, override_dir=None): if not environment: environment = Environment.from_env_file(project_dir) config_details = config.find(project_dir, config_path, environment, override_dir) @@ -100,7 +101,7 @@ def get_project(project_dir, config_path=None, project_name=None, verbose=False, ) with errors.handle_connection_errors(client): - return Project.from_config(project_name, config_data, client) + return Project.from_config(project_name, config_data, client, noansi=noansi) def get_project_name(working_dir, project_name=None, environment=None): diff --git a/compose/cli/main.py b/compose/cli/main.py index 20f3b55b465..c0cf8747fa0 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -159,6 +159,7 @@ class TopLevelCommand(object): -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 + --no-ansi Do not print ANSI control characters -v, --version Print version and exit -H, --host HOST Daemon socket to connect to diff --git a/compose/parallel.py b/compose/parallel.py index a611fd6e0b0..89d074e35de 100644 --- a/compose/parallel.py +++ b/compose/parallel.py @@ -26,7 +26,7 @@ STOP = object() -def parallel_execute(objects, func, get_name, msg, get_deps=None, limit=None): +def parallel_execute(objects, func, get_name, msg, get_deps=None, limit=None, noansi=False): """Runs func on objects in parallel while ensuring that func is ran on object only after it is ran on all its dependencies. @@ -36,7 +36,7 @@ def parallel_execute(objects, func, get_name, msg, get_deps=None, limit=None): objects = list(objects) stream = get_output_stream(sys.stderr) - writer = ParallelStreamWriter(stream, msg) + writer = ParallelStreamWriter(stream, msg, noansi) for obj in objects: writer.add_object(get_name(obj)) writer.write_initial() @@ -221,11 +221,12 @@ class ParallelStreamWriter(object): to jump to the correct line, and write over the line. """ - def __init__(self, stream, msg): + def __init__(self, stream, msg, noansi): self.stream = stream self.msg = msg self.lines = [] self.width = 0 + self.noansi = noansi def add_object(self, obj_index): self.lines.append(obj_index) @@ -239,9 +240,7 @@ def write_initial(self): width=self.width)) self.stream.flush() - def write(self, obj_index, status): - if self.msg is None: - return + def _write_ansi(self, obj_index, status): position = self.lines.index(obj_index) diff = len(self.lines) - position # move up @@ -254,27 +253,41 @@ def write(self, obj_index, status): self.stream.write("%c[%dB" % (27, diff)) self.stream.flush() + def _write_noansi(self, obj_index, status): + self.stream.write("{} {:<{width}} ... {}\r\n".format(self.msg, obj_index, + status, width=self.width)) + self.stream.flush() + + def write(self, obj_index, status): + if self.msg is None: + return + if self.noansi: + self._write_noansi(obj_index, status) + else: + self._write_ansi(obj_index, status) + -def parallel_operation(containers, operation, options, message): +def parallel_operation(containers, operation, options, message, noansi=False): parallel_execute( containers, operator.methodcaller(operation, **options), operator.attrgetter('name'), - message) + message, + noansi=noansi) -def parallel_remove(containers, options): +def parallel_remove(containers, options, noansi=False): stopped_containers = [c for c in containers if not c.is_running] - parallel_operation(stopped_containers, 'remove', options, 'Removing') + parallel_operation(stopped_containers, 'remove', options, 'Removing', noansi=noansi) -def parallel_pause(containers, options): - parallel_operation(containers, 'pause', options, 'Pausing') +def parallel_pause(containers, options, noansi=False): + parallel_operation(containers, 'pause', options, 'Pausing', noansi=noansi) -def parallel_unpause(containers, options): - parallel_operation(containers, 'unpause', options, 'Unpausing') +def parallel_unpause(containers, options, noansi=False): + parallel_operation(containers, 'unpause', options, 'Unpausing', noansi=noansi) -def parallel_kill(containers, options): - parallel_operation(containers, 'kill', options, 'Killing') +def parallel_kill(containers, options, noansi=False): + parallel_operation(containers, 'kill', options, 'Killing', noansi=noansi) diff --git a/compose/project.py b/compose/project.py index 28af45c7135..9ea6ff6bb64 100644 --- a/compose/project.py +++ b/compose/project.py @@ -60,13 +60,15 @@ class Project(object): """ A collection of services. """ - def __init__(self, name, services, client, networks=None, volumes=None, config_version=None): + def __init__(self, name, services, client, networks=None, volumes=None, config_version=None, + noansi=False): self.name = name self.services = services self.client = client self.volumes = volumes or ProjectVolumes({}) self.networks = networks or ProjectNetworks({}, False) self.config_version = config_version + self.noansi = noansi def labels(self, one_off=OneOffFilter.exclude): labels = ['{0}={1}'.format(LABEL_PROJECT, self.name)] @@ -75,7 +77,7 @@ def labels(self, one_off=OneOffFilter.exclude): return labels @classmethod - def from_config(cls, name, config_data, client): + def from_config(cls, name, config_data, client, noansi=False): """ Construct a Project from a config.Config object. """ @@ -86,7 +88,7 @@ def from_config(cls, name, config_data, client): networks, use_networking) volumes = ProjectVolumes.from_config(name, config_data, client) - project = cls(name, [], client, project_networks, volumes, config_data.version) + project = cls(name, [], client, project_networks, volumes, config_data.version, noansi=noansi) for service_dict in config_data.services: service_dict = dict(service_dict) @@ -126,6 +128,7 @@ def from_config(cls, name, config_data, client): volumes_from=volumes_from, secrets=secrets, pid_mode=pid_mode, + noansi=noansi, **service_dict) ) @@ -270,7 +273,8 @@ def get_deps(service): start_service, operator.attrgetter('name'), 'Starting', - get_deps) + get_deps, + noansi=self.noansi) return containers @@ -288,25 +292,26 @@ def get_deps(container): self.build_container_operation_with_timeout_func('stop', options), operator.attrgetter('name'), 'Stopping', - get_deps) + get_deps, + noansi=self.noansi) def pause(self, service_names=None, **options): containers = self.containers(service_names) - parallel.parallel_pause(reversed(containers), options) + parallel.parallel_pause(reversed(containers), options, noansi=self.noansi) return containers def unpause(self, service_names=None, **options): containers = self.containers(service_names) - parallel.parallel_unpause(containers, options) + parallel.parallel_unpause(containers, options, noansi=self.noansi) return containers def kill(self, service_names=None, **options): - parallel.parallel_kill(self.containers(service_names), options) + parallel.parallel_kill(self.containers(service_names), options, noansi=self.noansi) 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) + ), options, noansi=self.noansi) def down(self, remove_image_type, include_volumes, remove_orphans=False): self.stop(one_off=OneOffFilter.include) @@ -331,7 +336,8 @@ def restart(self, service_names=None, **options): containers, self.build_container_operation_with_timeout_func('restart', options), operator.attrgetter('name'), - 'Restarting') + 'Restarting', + noansi=self.noansi) return containers def build(self, service_names=None, no_cache=False, pull=False, force_rm=False, build_args=None): @@ -447,7 +453,8 @@ def get_deps(service): do, operator.attrgetter('name'), None, - get_deps + get_deps, + noansi=self.noansi, ) if errors: raise ProjectError( @@ -500,7 +507,8 @@ def pull_service(service): pull_service, operator.attrgetter('name'), 'Pulling', - limit=5) + limit=5, + noansi=self.noansi) else: for service in services: service.pull(ignore_pull_failures, silent=silent) diff --git a/compose/service.py b/compose/service.py index 4a55951a41f..f647b8404fd 100644 --- a/compose/service.py +++ b/compose/service.py @@ -158,6 +158,7 @@ def __init__( secrets=None, scale=None, pid_mode=None, + noansi=False, **options ): self.name = name @@ -171,6 +172,7 @@ def __init__( self.networks = networks or {} self.secrets = secrets or [] self.scale_num = scale or 1 + self.noansi = noansi self.options = options def __repr__(self): @@ -392,7 +394,8 @@ def create_and_start(service, n): range(i, i + scale), lambda n: create_and_start(self, n), lambda n: self.get_container_name(n), - "Creating" + "Creating", + noansi=self.noansi, ) for error in errors.values(): raise OperationFailedError(error) @@ -413,7 +416,8 @@ def recreate(container): containers, recreate, lambda c: c.name, - "Recreating" + "Recreating", + noansi=self.noansi, ) for error in errors.values(): raise OperationFailedError(error) @@ -433,7 +437,8 @@ def _execute_convergence_start(self, containers, scale, timeout, detached, start containers, lambda c: self.start_container_if_stopped(c, attach_logs=not detached), lambda c: c.name, - "Starting" + "Starting", + noansi=self.noansi, ) for error in errors.values(): @@ -455,6 +460,7 @@ def stop_and_remove(container): stop_and_remove, lambda c: c.name, "Stopping and removing", + noansi=self.noansi, ) def execute_convergence_plan(self, plan, timeout=None, detached=False, diff --git a/tests/unit/parallel_test.py b/tests/unit/parallel_test.py index 73728fdfd87..519c66669be 100644 --- a/tests/unit/parallel_test.py +++ b/tests/unit/parallel_test.py @@ -130,3 +130,19 @@ def test_parallel_execute_alignment(capsys): _, err = capsys.readouterr() a, b = err.split('\n')[:2] assert a.index('...') == b.index('...') + + +def test_parallel_execute_alignment_noansi(capsys): + results, errors = parallel_execute( + objects=["short", "a very long name"], + func=lambda x: x, + get_name=six.text_type, + msg="Aligning", + noansi=True, + ) + + assert errors == {} + + _, err = capsys.readouterr() + a, b, c, d = err.split('\n')[:4] + assert a.index('...') == b.index('...') == c.index('...') == d.index('...') From 454b063fed20ba58338dd9754f3503fe17a1c1b8 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 9 Aug 2017 16:46:47 -0700 Subject: [PATCH 2943/4072] Keep no-ansi parameter in the CLI scope Signed-off-by: Joffrey F --- compose/cli/command.py | 5 ++--- compose/cli/main.py | 7 +++++++ compose/parallel.py | 33 +++++++++++++++++++-------------- compose/project.py | 26 +++++++++++--------------- compose/service.py | 6 ------ tests/acceptance/cli_test.py | 8 ++++++++ tests/unit/parallel_test.py | 5 +++-- 7 files changed, 50 insertions(+), 40 deletions(-) diff --git a/compose/cli/command.py b/compose/cli/command.py index f5330d1c202..e1ae690c0eb 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -31,7 +31,6 @@ def project_from_options(project_dir, options): get_config_path_from_options(project_dir, options, environment), project_name=options.get('--project-name'), verbose=options.get('--verbose'), - noansi=options.get('--no-ansi'), host=host, tls_config=tls_config_from_options(options), environment=environment, @@ -82,7 +81,7 @@ def get_client(environment, verbose=False, version=None, tls_config=None, host=N def get_project(project_dir, config_path=None, project_name=None, verbose=False, - noansi=False, host=None, tls_config=None, environment=None, override_dir=None): + host=None, tls_config=None, environment=None, override_dir=None): if not environment: environment = Environment.from_env_file(project_dir) config_details = config.find(project_dir, config_path, environment, override_dir) @@ -101,7 +100,7 @@ def get_project(project_dir, config_path=None, project_name=None, verbose=False, ) with errors.handle_connection_errors(client): - return Project.from_config(project_name, config_data, client, noansi=noansi) + return Project.from_config(project_name, config_data, client) def get_project_name(working_dir, project_name=None, environment=None): diff --git a/compose/cli/main.py b/compose/cli/main.py index c0cf8747fa0..2bb53f95e66 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -98,6 +98,7 @@ def dispatch(): options, handler, command_options = dispatcher.parse(sys.argv[1:]) setup_console_handler(console_handler, options.get('--verbose')) + setup_parallel_logger(options.get('--no-ansi')) return functools.partial(perform_command, options, handler, command_options) @@ -127,6 +128,12 @@ def setup_logging(): logging.getLogger("requests").propagate = False +def setup_parallel_logger(noansi): + if noansi: + import compose.parallel + compose.parallel.ParallelStreamWriter.set_noansi() + + def setup_console_handler(handler, verbose): if handler.stream.isatty(): format_class = ConsoleWarningFormatter diff --git a/compose/parallel.py b/compose/parallel.py index 89d074e35de..1cf1fb094e0 100644 --- a/compose/parallel.py +++ b/compose/parallel.py @@ -26,7 +26,7 @@ STOP = object() -def parallel_execute(objects, func, get_name, msg, get_deps=None, limit=None, noansi=False): +def parallel_execute(objects, func, get_name, msg, get_deps=None, limit=None): """Runs func on objects in parallel while ensuring that func is ran on object only after it is ran on all its dependencies. @@ -36,7 +36,7 @@ def parallel_execute(objects, func, get_name, msg, get_deps=None, limit=None, no objects = list(objects) stream = get_output_stream(sys.stderr) - writer = ParallelStreamWriter(stream, msg, noansi) + writer = ParallelStreamWriter(stream, msg) for obj in objects: writer.add_object(get_name(obj)) writer.write_initial() @@ -221,12 +221,17 @@ class ParallelStreamWriter(object): to jump to the correct line, and write over the line. """ - def __init__(self, stream, msg, noansi): + noansi = False + + @classmethod + def set_noansi(cls, value=True): + cls.noansi = value + + def __init__(self, stream, msg): self.stream = stream self.msg = msg self.lines = [] self.width = 0 - self.noansi = noansi def add_object(self, obj_index): self.lines.append(obj_index) @@ -267,27 +272,27 @@ def write(self, obj_index, status): self._write_ansi(obj_index, status) -def parallel_operation(containers, operation, options, message, noansi=False): +def parallel_operation(containers, operation, options, message): parallel_execute( containers, operator.methodcaller(operation, **options), operator.attrgetter('name'), message, - noansi=noansi) + ) -def parallel_remove(containers, options, noansi=False): +def parallel_remove(containers, options): stopped_containers = [c for c in containers if not c.is_running] - parallel_operation(stopped_containers, 'remove', options, 'Removing', noansi=noansi) + parallel_operation(stopped_containers, 'remove', options, 'Removing') -def parallel_pause(containers, options, noansi=False): - parallel_operation(containers, 'pause', options, 'Pausing', noansi=noansi) +def parallel_pause(containers, options): + parallel_operation(containers, 'pause', options, 'Pausing') -def parallel_unpause(containers, options, noansi=False): - parallel_operation(containers, 'unpause', options, 'Unpausing', noansi=noansi) +def parallel_unpause(containers, options): + parallel_operation(containers, 'unpause', options, 'Unpausing') -def parallel_kill(containers, options, noansi=False): - parallel_operation(containers, 'kill', options, 'Killing', noansi=noansi) +def parallel_kill(containers, options): + parallel_operation(containers, 'kill', options, 'Killing') diff --git a/compose/project.py b/compose/project.py index 9ea6ff6bb64..86fbda6eefa 100644 --- a/compose/project.py +++ b/compose/project.py @@ -60,15 +60,13 @@ class Project(object): """ A collection of services. """ - def __init__(self, name, services, client, networks=None, volumes=None, config_version=None, - noansi=False): + def __init__(self, name, services, client, networks=None, volumes=None, config_version=None): self.name = name self.services = services self.client = client self.volumes = volumes or ProjectVolumes({}) self.networks = networks or ProjectNetworks({}, False) self.config_version = config_version - self.noansi = noansi def labels(self, one_off=OneOffFilter.exclude): labels = ['{0}={1}'.format(LABEL_PROJECT, self.name)] @@ -77,7 +75,7 @@ def labels(self, one_off=OneOffFilter.exclude): return labels @classmethod - def from_config(cls, name, config_data, client, noansi=False): + def from_config(cls, name, config_data, client): """ Construct a Project from a config.Config object. """ @@ -88,7 +86,7 @@ def from_config(cls, name, config_data, client, noansi=False): networks, use_networking) volumes = ProjectVolumes.from_config(name, config_data, client) - project = cls(name, [], client, project_networks, volumes, config_data.version, noansi=noansi) + project = cls(name, [], client, project_networks, volumes, config_data.version) for service_dict in config_data.services: service_dict = dict(service_dict) @@ -128,7 +126,6 @@ def from_config(cls, name, config_data, client, noansi=False): volumes_from=volumes_from, secrets=secrets, pid_mode=pid_mode, - noansi=noansi, **service_dict) ) @@ -274,7 +271,7 @@ def get_deps(service): operator.attrgetter('name'), 'Starting', get_deps, - noansi=self.noansi) + ) return containers @@ -293,25 +290,25 @@ def get_deps(container): operator.attrgetter('name'), 'Stopping', get_deps, - noansi=self.noansi) + ) def pause(self, service_names=None, **options): containers = self.containers(service_names) - parallel.parallel_pause(reversed(containers), options, noansi=self.noansi) + parallel.parallel_pause(reversed(containers), options) return containers def unpause(self, service_names=None, **options): containers = self.containers(service_names) - parallel.parallel_unpause(containers, options, noansi=self.noansi) + parallel.parallel_unpause(containers, options) return containers def kill(self, service_names=None, **options): - parallel.parallel_kill(self.containers(service_names), options, noansi=self.noansi) + parallel.parallel_kill(self.containers(service_names), 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, noansi=self.noansi) + ), options) def down(self, remove_image_type, include_volumes, remove_orphans=False): self.stop(one_off=OneOffFilter.include) @@ -337,7 +334,7 @@ def restart(self, service_names=None, **options): self.build_container_operation_with_timeout_func('restart', options), operator.attrgetter('name'), 'Restarting', - noansi=self.noansi) + ) return containers def build(self, service_names=None, no_cache=False, pull=False, force_rm=False, build_args=None): @@ -454,7 +451,6 @@ def get_deps(service): operator.attrgetter('name'), None, get_deps, - noansi=self.noansi, ) if errors: raise ProjectError( @@ -508,7 +504,7 @@ def pull_service(service): operator.attrgetter('name'), 'Pulling', limit=5, - noansi=self.noansi) + ) else: for service in services: service.pull(ignore_pull_failures, silent=silent) diff --git a/compose/service.py b/compose/service.py index f647b8404fd..04460ea3f00 100644 --- a/compose/service.py +++ b/compose/service.py @@ -158,7 +158,6 @@ def __init__( secrets=None, scale=None, pid_mode=None, - noansi=False, **options ): self.name = name @@ -172,7 +171,6 @@ def __init__( self.networks = networks or {} self.secrets = secrets or [] self.scale_num = scale or 1 - self.noansi = noansi self.options = options def __repr__(self): @@ -395,7 +393,6 @@ def create_and_start(service, n): lambda n: create_and_start(self, n), lambda n: self.get_container_name(n), "Creating", - noansi=self.noansi, ) for error in errors.values(): raise OperationFailedError(error) @@ -417,7 +414,6 @@ def recreate(container): recreate, lambda c: c.name, "Recreating", - noansi=self.noansi, ) for error in errors.values(): raise OperationFailedError(error) @@ -438,7 +434,6 @@ def _execute_convergence_start(self, containers, scale, timeout, detached, start lambda c: self.start_container_if_stopped(c, attach_logs=not detached), lambda c: c.name, "Starting", - noansi=self.noansi, ) for error in errors.values(): @@ -460,7 +455,6 @@ def stop_and_remove(container): stop_and_remove, lambda c: c.name, "Stopping and removing", - noansi=self.noansi, ) def execute_convergence_plan(self, plan, timeout=None, detached=False, diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index f7ecba9f52a..adf645c2f39 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -751,6 +751,14 @@ def test_up(self): for service in services: assert self.lookup(container, service.name) + @v2_only() + def test_up_no_ansi(self): + self.base_dir = 'tests/fixtures/v2-simple' + result = self.dispatch(['--no-ansi', 'up', '-d'], None) + assert "%c[2K\r" % 27 not in result.stderr + assert "%c[1A" % 27 not in result.stderr + assert "%c[1B" % 27 not in result.stderr + @v2_only() def test_up_with_default_network_config(self): filename = 'default-network-config.yml' diff --git a/tests/unit/parallel_test.py b/tests/unit/parallel_test.py index 519c66669be..f82858eab0c 100644 --- a/tests/unit/parallel_test.py +++ b/tests/unit/parallel_test.py @@ -8,6 +8,7 @@ from compose.parallel import parallel_execute from compose.parallel import parallel_execute_iter +from compose.parallel import ParallelStreamWriter from compose.parallel import UpstreamError @@ -62,7 +63,7 @@ def f(obj): limit=limit, ) - assert results == tasks*[None] + assert results == tasks * [None] assert errors == {} @@ -133,12 +134,12 @@ def test_parallel_execute_alignment(capsys): def test_parallel_execute_alignment_noansi(capsys): + ParallelStreamWriter.set_noansi() results, errors = parallel_execute( objects=["short", "a very long name"], func=lambda x: x, get_name=six.text_type, msg="Aligning", - noansi=True, ) assert errors == {} From 6e802df80948696739d6aaf721d8bcf8c3bbe6a1 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 9 Aug 2017 18:59:17 -0700 Subject: [PATCH 2944/4072] Add support for blkio config keys Signed-off-by: Joffrey F --- compose/config/config.py | 50 ++++++++++++++++++++++++-- compose/config/config_schema_v2.0.json | 44 +++++++++++++++++++++++ compose/config/config_schema_v2.1.json | 45 +++++++++++++++++++++++ compose/config/config_schema_v2.2.json | 45 +++++++++++++++++++++++ compose/config/config_schema_v2.3.json | 45 +++++++++++++++++++++++ compose/service.py | 28 ++++++++++++++- compose/utils.py | 10 ++++++ tests/integration/service_test.py | 28 +++++++++++++++ tests/unit/config/config_test.py | 47 ++++++++++++++++++++++++ 9 files changed, 339 insertions(+), 3 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index cb25a25a6b1..fb376b32536 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -16,6 +16,7 @@ from .. import const from ..const import COMPOSEFILE_V1 as V1 from ..utils import build_string_dict +from ..utils import parse_bytes from ..utils import parse_nanoseconds_int from ..utils import splitdrive from ..version import ComposeVersion @@ -108,6 +109,7 @@ ] ALLOWED_KEYS = DOCKER_CONFIG_KEYS + [ + 'blkio_config', 'build', 'container_name', 'credential_spec', @@ -726,8 +728,9 @@ def process_service(service_config): if field in service_dict: service_dict[field] = to_list(service_dict[field]) - service_dict = process_healthcheck(service_dict, service_config.name) - service_dict = process_ports(service_dict) + service_dict = process_blkio_config(process_ports( + process_healthcheck(service_dict, service_config.name) + )) return service_dict @@ -754,6 +757,28 @@ def process_depends_on(service_dict): return service_dict +def process_blkio_config(service_dict): + if not service_dict.get('blkio_config'): + return service_dict + + for field in ['device_read_bps', 'device_write_bps']: + if field in service_dict['blkio_config']: + for v in service_dict['blkio_config'].get(field, []): + v['rate'] = parse_bytes(v.get('rate', 0)) + + for field in ['device_read_iops', 'device_write_iops']: + if field in service_dict['blkio_config']: + for v in service_dict['blkio_config'].get(field, []): + try: + v['rate'] = int(v.get('rate', 0)) + except ValueError: + raise ConfigurationError( + 'Invalid IOPS value: "{}". Must be a positive integer.'.format(v.get('rate')) + ) + + return service_dict + + def process_healthcheck(service_dict, service_name): if 'healthcheck' not in service_dict: return service_dict @@ -940,6 +965,7 @@ def merge_service_dicts(base, override, version): md.merge_field('logging', merge_logging, default={}) merge_ports(md, base, override) + md.merge_field('blkio_config', merge_blkio_config, default={}) for field in set(ALLOWED_KEYS) - set(md): md.merge_scalar(field) @@ -993,6 +1019,26 @@ def to_dict(service): return dict(md) +def merge_blkio_config(base, override): + md = MergeDict(base, override) + md.merge_scalar('weight') + + def merge_blkio_limits(base, override): + index = dict((b['path'], b) for b in base) + for o in override: + index[o['path']] = o + + return sorted(list(index.values()), key=lambda x: x['path']) + + for field in [ + "device_read_bps", "device_read_iops", "device_write_bps", + "device_write_iops", "weight_device", + ]: + md.merge_field(field, merge_blkio_limits, default=[]) + + return dict(md) + + def merge_logging(base, override): md = MergeDict(base, override) md.merge_scalar('driver') diff --git a/compose/config/config_schema_v2.0.json b/compose/config/config_schema_v2.0.json index f3688685b39..14bafab4033 100644 --- a/compose/config/config_schema_v2.0.json +++ b/compose/config/config_schema_v2.0.json @@ -50,6 +50,33 @@ "type": "object", "properties": { + "blkio_config": { + "type": "object", + "properties": { + "device_read_bps": { + "type": "array", + "items": {"$ref": "#/definitions/blkio_limit"} + }, + "device_read_iops": { + "type": "array", + "items": {"$ref": "#/definitions/blkio_limit"} + }, + "device_write_bps": { + "type": "array", + "items": {"$ref": "#/definitions/blkio_limit"} + }, + "device_write_iops": { + "type": "array", + "items": {"$ref": "#/definitions/blkio_limit"} + }, + "weight": {"type": "integer"}, + "weight_device": { + "type": "array", + "items": {"$ref": "#/definitions/blkio_weight"} + } + }, + "additionalProperties": false + }, "build": { "oneOf": [ {"type": "string"}, @@ -326,6 +353,23 @@ ] }, + "blkio_limit": { + "type": "object", + "properties": { + "path": {"type": "string"}, + "rate": {"type": ["integer", "string"]} + }, + "additionalProperties": false + }, + "blkio_weight": { + "type": "object", + "properties": { + "path": {"type": "string"}, + "weight": {"type": "integer"} + }, + "additionalProperties": false + }, + "constraints": { "service": { "id": "#/definitions/constraints/service", diff --git a/compose/config/config_schema_v2.1.json b/compose/config/config_schema_v2.1.json index 5aed9f7b189..9d45c324c57 100644 --- a/compose/config/config_schema_v2.1.json +++ b/compose/config/config_schema_v2.1.json @@ -50,6 +50,34 @@ "type": "object", "properties": { + "blkio_config": { + "type": "object", + "properties": { + "device_read_bps": { + "type": "array", + "items": {"$ref": "#/definitions/blkio_limit"} + }, + "device_read_iops": { + "type": "array", + "items": {"$ref": "#/definitions/blkio_limit"} + }, + "device_write_bps": { + "type": "array", + "items": {"$ref": "#/definitions/blkio_limit"} + }, + "device_write_iops": { + "type": "array", + "items": {"$ref": "#/definitions/blkio_limit"} + }, + "weight": {"type": "integer"}, + "weight_device": { + "type": "array", + "items": {"$ref": "#/definitions/blkio_weight"} + } + }, + "additionalProperties": false + }, + "build": { "oneOf": [ {"type": "string"}, @@ -376,6 +404,23 @@ ] }, + "blkio_limit": { + "type": "object", + "properties": { + "path": {"type": "string"}, + "rate": {"type": ["integer", "string"]} + }, + "additionalProperties": false + }, + "blkio_weight": { + "type": "object", + "properties": { + "path": {"type": "string"}, + "weight": {"type": "integer"} + }, + "additionalProperties": false + }, + "constraints": { "service": { "id": "#/definitions/constraints/service", diff --git a/compose/config/config_schema_v2.2.json b/compose/config/config_schema_v2.2.json index 9181e606b26..9544170181f 100644 --- a/compose/config/config_schema_v2.2.json +++ b/compose/config/config_schema_v2.2.json @@ -50,6 +50,34 @@ "type": "object", "properties": { + "blkio_config": { + "type": "object", + "properties": { + "device_read_bps": { + "type": "array", + "items": {"$ref": "#/definitions/blkio_limit"} + }, + "device_read_iops": { + "type": "array", + "items": {"$ref": "#/definitions/blkio_limit"} + }, + "device_write_bps": { + "type": "array", + "items": {"$ref": "#/definitions/blkio_limit"} + }, + "device_write_iops": { + "type": "array", + "items": {"$ref": "#/definitions/blkio_limit"} + }, + "weight": {"type": "integer"}, + "weight_device": { + "type": "array", + "items": {"$ref": "#/definitions/blkio_weight"} + } + }, + "additionalProperties": false + }, + "build": { "oneOf": [ {"type": "string"}, @@ -383,6 +411,23 @@ ] }, + "blkio_limit": { + "type": "object", + "properties": { + "path": {"type": "string"}, + "rate": {"type": ["integer", "string"]} + }, + "additionalProperties": false + }, + "blkio_weight": { + "type": "object", + "properties": { + "path": {"type": "string"}, + "weight": {"type": "integer"} + }, + "additionalProperties": false + }, + "constraints": { "service": { "id": "#/definitions/constraints/service", diff --git a/compose/config/config_schema_v2.3.json b/compose/config/config_schema_v2.3.json index 87734027640..10a61186e3d 100644 --- a/compose/config/config_schema_v2.3.json +++ b/compose/config/config_schema_v2.3.json @@ -50,6 +50,34 @@ "type": "object", "properties": { + "blkio_config": { + "type": "object", + "properties": { + "device_read_bps": { + "type": "array", + "items": {"$ref": "#/definitions/blkio_limit"} + }, + "device_read_iops": { + "type": "array", + "items": {"$ref": "#/definitions/blkio_limit"} + }, + "device_write_bps": { + "type": "array", + "items": {"$ref": "#/definitions/blkio_limit"} + }, + "device_write_iops": { + "type": "array", + "items": {"$ref": "#/definitions/blkio_limit"} + }, + "weight": {"type": "integer"}, + "weight_device": { + "type": "array", + "items": {"$ref": "#/definitions/blkio_weight"} + } + }, + "additionalProperties": false + }, + "build": { "oneOf": [ {"type": "string"}, @@ -384,6 +412,23 @@ ] }, + "blkio_limit": { + "type": "object", + "properties": { + "path": {"type": "string"}, + "rate": {"type": ["integer", "string"]} + }, + "additionalProperties": false + }, + "blkio_weight": { + "type": "object", + "properties": { + "path": {"type": "string"}, + "weight": {"type": "integer"} + }, + "additionalProperties": false + }, + "constraints": { "service": { "id": "#/definitions/constraints/service", diff --git a/compose/service.py b/compose/service.py index 04460ea3f00..2829240f256 100644 --- a/compose/service.py +++ b/compose/service.py @@ -813,6 +813,7 @@ def _get_container_host_config(self, override_options, one_off=False): options = dict(self.options, **override_options) logging_dict = options.get('logging', None) + blkio_config = convert_blkio_config(options.get('blkio_config', None)) log_config = get_log_config(logging_dict) init_path = None if isinstance(options.get('init'), six.string_types): @@ -868,7 +869,13 @@ def _get_container_host_config(self, override_options, one_off=False): volume_driver=options.get('volume_driver'), cpuset_cpus=options.get('cpuset'), cpu_shares=options.get('cpu_shares'), - storage_opt=options.get('storage_opt') + storage_opt=options.get('storage_opt'), + blkio_weight=blkio_config.get('weight'), + blkio_weight_device=blkio_config.get('weight_device'), + device_read_bps=blkio_config.get('device_read_bps'), + device_read_iops=blkio_config.get('device_read_iops'), + device_write_bps=blkio_config.get('device_write_bps'), + device_write_iops=blkio_config.get('device_write_iops'), ) def get_secret_volumes(self): @@ -1395,3 +1402,22 @@ def build_container_ports(container_ports, options): port = tuple(port.split('/')) ports.append(port) return ports + + +def convert_blkio_config(blkio_config): + result = {} + if blkio_config is None: + return result + + result['weight'] = blkio_config.get('weight') + for field in [ + "device_read_bps", "device_read_iops", "device_write_bps", + "device_write_iops", "weight_device", + ]: + if field not in blkio_config: + continue + arr = [] + for item in blkio_config[field]: + arr.append(dict([(k.capitalize(), v) for k, v in item.items()])) + result[field] = arr + return result diff --git a/compose/utils.py b/compose/utils.py index b8bdf732f91..183a4504d46 100644 --- a/compose/utils.py +++ b/compose/utils.py @@ -9,7 +9,10 @@ import ntpath import six +from docker.errors import DockerException +from docker.utils import parse_bytes as sdk_parse_bytes +from .config.errors import ConfigurationError from .errors import StreamParseError from .timeparse import timeparse @@ -133,3 +136,10 @@ def splitdrive(path): if path[0] in ['.', '\\', '/', '~']: return ('', path) return ntpath.splitdrive(path) + + +def parse_bytes(n): + try: + return sdk_parse_bytes(n) + except DockerException: + raise ConfigurationError('Invalid format for bytes value: {}'.format(n)) diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 3a585ec016a..8fb2251bf23 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -203,6 +203,34 @@ def test_create_container_with_read_only_root_fs(self): service.start_container(container) assert container.get('HostConfig.ReadonlyRootfs') == read_only + def test_create_container_with_blkio_config(self): + blkio_config = { + 'weight': 300, + 'weight_device': [{'path': '/dev/sda', 'weight': 200}], + 'device_read_bps': [{'path': '/dev/sda', 'rate': 1024 * 1024 * 100}], + 'device_read_iops': [{'path': '/dev/sda', 'rate': 1000}], + 'device_write_bps': [{'path': '/dev/sda', 'rate': 1024 * 1024}], + 'device_write_iops': [{'path': '/dev/sda', 'rate': 800}] + } + service = self.create_service('web', blkio_config=blkio_config) + container = service.create_container() + assert container.get('HostConfig.BlkioWeight') == 300 + assert container.get('HostConfig.BlkioWeightDevice') == [{ + 'Path': '/dev/sda', 'Weight': 200 + }] + assert container.get('HostConfig.BlkioDeviceReadBps') == [{ + 'Path': '/dev/sda', 'Rate': 1024 * 1024 * 100 + }] + assert container.get('HostConfig.BlkioDeviceWriteBps') == [{ + 'Path': '/dev/sda', 'Rate': 1024 * 1024 + }] + assert container.get('HostConfig.BlkioDeviceReadIOps') == [{ + 'Path': '/dev/sda', 'Rate': 1000 + }] + assert container.get('HostConfig.BlkioDeviceWriteIOps') == [{ + 'Path': '/dev/sda', 'Rate': 800 + }] + def test_create_container_with_security_opt(self): security_opt = ['label:disable'] service = self.create_service('db', security_opt=security_opt) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index fd06db7d139..8861baa9842 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -2151,6 +2151,53 @@ def test_merge_scale(self): actual = config.merge_service_dicts(base, override, V2_2) assert actual == {'image': 'bar', 'scale': 4} + def test_merge_blkio_config(self): + base = { + 'image': 'bar', + 'blkio_config': { + 'weight': 300, + 'weight_device': [ + {'path': '/dev/sda1', 'weight': 200} + ], + 'device_read_iops': [ + {'path': '/dev/sda1', 'rate': 300} + ], + 'device_write_iops': [ + {'path': '/dev/sda1', 'rate': 1000} + ] + } + } + + override = { + 'blkio_config': { + 'weight': 450, + 'weight_device': [ + {'path': '/dev/sda2', 'weight': 400} + ], + 'device_read_iops': [ + {'path': '/dev/sda1', 'rate': 2000} + ], + 'device_read_bps': [ + {'path': '/dev/sda1', 'rate': 1024} + ] + } + } + + actual = config.merge_service_dicts(base, override, V2_2) + assert actual == { + 'image': 'bar', + 'blkio_config': { + 'weight': override['blkio_config']['weight'], + 'weight_device': ( + base['blkio_config']['weight_device'] + + override['blkio_config']['weight_device'] + ), + 'device_read_iops': override['blkio_config']['device_read_iops'], + 'device_read_bps': override['blkio_config']['device_read_bps'], + 'device_write_iops': base['blkio_config']['device_write_iops'] + } + } + def test_external_volume_config(self): config_details = build_config_details({ 'version': '2', From dc6bb7020dfb5e9ae9607697cc8b850c8c21bf46 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 2 Aug 2017 16:59:43 -0700 Subject: [PATCH 2945/4072] UCP 2.2.0 test fixes Signed-off-by: Joffrey F --- tests/acceptance/cli_test.py | 16 +++++++++------- tests/helpers.py | 4 ++++ tests/integration/project_test.py | 3 ++- tests/integration/service_test.py | 8 ++++++-- tests/integration/state_test.py | 17 ++++++++++++++++- tests/integration/testcases.py | 6 +++++- tox.ini | 1 + 7 files changed, 43 insertions(+), 12 deletions(-) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index adf645c2f39..81bce546038 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -451,7 +451,6 @@ def test_build_plain(self): self.dispatch(['build', 'simple']) result = self.dispatch(['build', 'simple']) - assert BUILD_CACHE_TEXT in result.stdout assert BUILD_PULL_TEXT not in result.stdout def test_build_no_cache(self): @@ -469,7 +468,9 @@ def test_build_pull(self): self.dispatch(['build', 'simple'], None) result = self.dispatch(['build', '--pull', 'simple']) - assert BUILD_CACHE_TEXT in result.stdout + if not is_cluster(self.client): + # If previous build happened on another node, cache won't be available + assert BUILD_CACHE_TEXT in result.stdout assert BUILD_PULL_TEXT in result.stdout def test_build_no_cache_pull(self): @@ -602,11 +603,12 @@ def test_create_with_no_recreate(self): def test_run_one_off_with_volume(self): self.base_dir = 'tests/fixtures/simple-composefile-volume-ready' volume_path = os.path.abspath(os.path.join(os.getcwd(), self.base_dir, 'files')) - create_host_file(self.client, os.path.join(volume_path, 'example.txt')) + node = create_host_file(self.client, os.path.join(volume_path, 'example.txt')) self.dispatch([ 'run', '-v', '{}:/data'.format(volume_path), + '-e', 'constraint:node=={}'.format(node if node is not None else '*'), 'simple', 'test', '-f', '/data/example.txt' ], returncode=0) @@ -621,12 +623,13 @@ def test_run_one_off_with_volume(self): def test_run_one_off_with_multiple_volumes(self): self.base_dir = 'tests/fixtures/simple-composefile-volume-ready' volume_path = os.path.abspath(os.path.join(os.getcwd(), self.base_dir, 'files')) - create_host_file(self.client, os.path.join(volume_path, 'example.txt')) + node = create_host_file(self.client, os.path.join(volume_path, 'example.txt')) self.dispatch([ 'run', '-v', '{}:/data'.format(volume_path), '-v', '{}:/data1'.format(volume_path), + '-e', 'constraint:node=={}'.format(node if node is not None else '*'), 'simple', 'test', '-f', '/data/example.txt' ], returncode=0) @@ -635,6 +638,7 @@ def test_run_one_off_with_multiple_volumes(self): 'run', '-v', '{}:/data'.format(volume_path), '-v', '{}:/data1'.format(volume_path), + '-e', 'constraint:node=={}'.format(node if node is not None else '*'), 'simple', 'test', '-f' '/data1/example.txt' ], returncode=0) @@ -1376,9 +1380,7 @@ def test_run_rm(self): break volume_names = [v['Name'].split('/')[-1] for v in volumes] assert name in volume_names - if not is_cluster(self.client): - # The `-v` flag for `docker rm` in Swarm seems to be broken - assert anonymous_name not in volume_names + assert anonymous_name not in volume_names def test_run_service_with_dockerfile_entrypoint(self): self.base_dir = 'tests/fixtures/entrypoint-dockerfile' diff --git a/tests/helpers.py b/tests/helpers.py index 59efd2557c4..a93de993f13 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -42,5 +42,9 @@ def create_host_file(client, filename): output = client.logs(container) raise Exception( "Container exited with code {}:\n{}".format(exitcode, output)) + + container_info = client.inspect_container(container) + if 'Node' in container_info: + return container_info['Node']['Name'] finally: client.remove_container(container, force=True) diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 5ead7b8e71e..4e44c7f6bf5 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -1265,7 +1265,7 @@ def test_project_up_implicit_volume_driver(self): @v3_only() def test_project_up_with_secrets(self): - create_host_file(self.client, os.path.abspath('tests/fixtures/secrets/default')) + node = create_host_file(self.client, os.path.abspath('tests/fixtures/secrets/default')) config_data = build_config( version=V3_1, @@ -1276,6 +1276,7 @@ def test_project_up_with_secrets(self): 'secrets': [ types.ServiceSecret.parse({'source': 'super', 'target': 'special'}), ], + 'environment': ['constraint:node=={}'.format(node if node is not None else '*')] }], secrets={ 'super': { diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 8fb2251bf23..2abb12c342c 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -325,13 +325,15 @@ def test_create_container_with_volumes_from(self): command=["top"], labels={LABEL_PROJECT: 'composetest'}, host_config={}, + environment=['affinity:container=={}'.format(volume_container_1.id)], ) host_service = self.create_service( 'host', volumes_from=[ VolumeFromSpec(volume_service, 'rw', 'service'), VolumeFromSpec(volume_container_2, 'rw', 'container') - ] + ], + environment=['affinity:container=={}'.format(volume_container_1.id)], ) host_container = host_service.create_container() host_service.start_container(host_container) @@ -785,6 +787,7 @@ def test_build_with_network(self): assert service.image() @v2_3_only() + @no_cluster('Not supported on UCP 2.2.0-beta1') # FIXME: remove once support is added def test_build_with_target(self): self.require_api_version('1.30') base_dir = tempfile.mkdtemp() @@ -792,11 +795,12 @@ def test_build_with_target(self): with open(os.path.join(base_dir, 'Dockerfile'), 'w') as f: f.write('FROM busybox as one\n') + f.write('LABEL com.docker.compose.test=true\n') f.write('LABEL com.docker.compose.test.target=one\n') f.write('FROM busybox as two\n') f.write('LABEL com.docker.compose.test.target=two\n') - service = self.create_service('buildlabels', build={ + service = self.create_service('buildtarget', build={ 'context': text_type(base_dir), 'target': 'one' }) diff --git a/tests/integration/state_test.py b/tests/integration/state_test.py index 0dd5f44ad49..047dc704695 100644 --- a/tests/integration/state_test.py +++ b/tests/integration/state_test.py @@ -6,9 +6,11 @@ from __future__ import unicode_literals import py +from docker.errors import ImageNotFound from .testcases import DockerClientTestCase from .testcases import get_links +from .testcases import no_cluster from compose.config import config from compose.project import Project from compose.service import ConvergenceStrategy @@ -243,21 +245,34 @@ def test_trigger_recreate_with_image_change(self): tag = 'latest' image = '{}:{}'.format(repo, tag) + def safe_remove_image(image): + try: + self.client.remove_image(image) + except ImageNotFound: + pass + image_id = self.client.images(name='busybox')[0]['Id'] self.client.tag(image_id, repository=repo, tag=tag) - self.addCleanup(self.client.remove_image, image) + self.addCleanup(safe_remove_image, image) web = self.create_service('web', image=image) container = web.create_container() # update the image c = self.client.create_container(image, ['touch', '/hello.txt'], host_config={}) + + # In the case of a cluster, there's a chance we pick up the old image when + # calculating the new hash. To circumvent that, untag the old image first + # See also: https://github.com/moby/moby/issues/26852 + self.client.remove_image(image, force=True) + self.client.commit(c, repository=repo, tag=tag) self.client.remove_container(c) web = self.create_service('web', image=image) self.assertEqual(('recreate', [container]), web.convergence_plan()) + @no_cluster('Can not guarantee the build will be run on the same node the service is deployed') def test_trigger_recreate_with_build(self): context = py.test.ensuretemp('test_trigger_recreate_with_build') self.addCleanup(context.remove) diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index b1763b113cc..b72fb53a81f 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -105,7 +105,11 @@ def tearDown(self): for i in self.client.images( filters={'label': 'com.docker.compose.test_image'}): - self.client.remove_image(i, force=True) + try: + self.client.remove_image(i, force=True) + except APIError as e: + if e.is_server_error(): + pass volumes = self.client.volumes().get('Volumes') or [] for v in volumes: diff --git a/tox.ini b/tox.ini index 749be3faaea..e4f31ec8554 100644 --- a/tox.ini +++ b/tox.ini @@ -18,6 +18,7 @@ deps = -rrequirements-dev.txt commands = py.test -v \ + --full-trace \ --cov=compose \ --cov-report html \ --cov-report term \ From 3fbfb3a5dd62b5ad97f385c0163d7a030934c511 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 10 Aug 2017 10:59:23 -0700 Subject: [PATCH 2946/4072] Prevent null logging options in `docker-compose config` output Signed-off-by: Joffrey F --- compose/config/config.py | 4 ++-- tests/unit/config/config_test.py | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index fb376b32536..f3b8e42fd79 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -1044,8 +1044,8 @@ def merge_logging(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') + elif override.get('options'): + md['options'] = override.get('options', {}) return dict(md) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 8861baa9842..8a1e16f8a6f 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1864,7 +1864,6 @@ def test_merge_logging_v2_no_override_options(self): 'image': 'alpine:edge', 'logging': { 'driver': 'syslog', - 'options': None } } From 8c3097129901ba1c3e70fa8a40fb8e6ad3898fa4 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 9 Aug 2017 19:43:08 -0700 Subject: [PATCH 2947/4072] Add support for v3.4 files and custom volume names Signed-off-by: Joffrey F --- compose/config/config.py | 7 +- compose/config/config_schema_v3.4.json | 538 +++++++++++++++++++++++++ compose/config/serialize.py | 4 + compose/const.py | 3 + compose/volume.py | 20 +- tests/unit/volume_test.py | 2 +- 6 files changed, 559 insertions(+), 15 deletions(-) create mode 100644 compose/config/config_schema_v3.4.json diff --git a/compose/config/config.py b/compose/config/config.py index f3b8e42fd79..aa829a40e85 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -404,11 +404,12 @@ def load_mapping(config_files, get_func, entity_type, working_dir=None): external = config.get('external') if external: + name_field = 'name' if entity_type == 'Volume' else 'external_name' validate_external(entity_type, name, config) if isinstance(external, dict): - config['external_name'] = external.get('name') - else: - config['external_name'] = name + config[name_field] = external.get('name') + elif not config.get('name'): + config[name_field] = name if 'driver_opts' in config: config['driver_opts'] = build_string_dict( diff --git a/compose/config/config_schema_v3.4.json b/compose/config/config_schema_v3.4.json new file mode 100644 index 00000000000..ce9512076b5 --- /dev/null +++ b/compose/config/config_schema_v3.4.json @@ -0,0 +1,538 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "id": "config_schema_v3.4.json", + "type": "object", + "required": ["version"], + + "properties": { + "version": { + "type": "string" + }, + + "services": { + "id": "#/properties/services", + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "$ref": "#/definitions/service" + } + }, + "additionalProperties": false + }, + + "networks": { + "id": "#/properties/networks", + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "$ref": "#/definitions/network" + } + } + }, + + "volumes": { + "id": "#/properties/volumes", + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "$ref": "#/definitions/volume" + } + }, + "additionalProperties": false + }, + + "secrets": { + "id": "#/properties/secrets", + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "$ref": "#/definitions/secret" + } + }, + "additionalProperties": false + }, + + "configs": { + "id": "#/properties/configs", + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "$ref": "#/definitions/config" + } + }, + "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"}, + "labels": {"$ref": "#/definitions/list_or_dict"}, + "cache_from": {"$ref": "#/definitions/list_of_strings"} + }, + "additionalProperties": false + } + ] + }, + "cap_add": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "cap_drop": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "cgroup_parent": {"type": "string"}, + "command": { + "oneOf": [ + {"type": "string"}, + {"type": "array", "items": {"type": "string"}} + ] + }, + "configs": { + "type": "array", + "items": { + "oneOf": [ + {"type": "string"}, + { + "type": "object", + "properties": { + "source": {"type": "string"}, + "target": {"type": "string"}, + "uid": {"type": "string"}, + "gid": {"type": "string"}, + "mode": {"type": "number"} + } + } + ] + } + }, + "container_name": {"type": "string"}, + "credential_spec": {"type": "object", "properties": { + "file": {"type": "string"}, + "registry": {"type": "string"} + }}, + "depends_on": {"$ref": "#/definitions/list_of_strings"}, + "devices": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "dns": {"$ref": "#/definitions/string_or_list"}, + "dns_search": {"$ref": "#/definitions/string_or_list"}, + "domainname": {"type": "string"}, + "entrypoint": { + "oneOf": [ + {"type": "string"}, + {"type": "array", "items": {"type": "string"}} + ] + }, + "env_file": {"$ref": "#/definitions/string_or_list"}, + "environment": {"$ref": "#/definitions/list_or_dict"}, + + "expose": { + "type": "array", + "items": { + "type": ["string", "number"], + "format": "expose" + }, + "uniqueItems": true + }, + + "external_links": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "extra_hosts": {"$ref": "#/definitions/list_or_dict"}, + "healthcheck": {"$ref": "#/definitions/healthcheck"}, + "hostname": {"type": "string"}, + "image": {"type": "string"}, + "ipc": {"type": "string"}, + "labels": {"$ref": "#/definitions/list_or_dict"}, + "links": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + + "logging": { + "type": "object", + + "properties": { + "driver": {"type": "string"}, + "options": { + "type": "object", + "patternProperties": { + "^.+$": {"type": ["string", "number", "null"]} + } + } + }, + "additionalProperties": false + }, + + "mac_address": {"type": "string"}, + "network_mode": {"type": "string"}, + + "networks": { + "oneOf": [ + {"$ref": "#/definitions/list_of_strings"}, + { + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "oneOf": [ + { + "type": "object", + "properties": { + "aliases": {"$ref": "#/definitions/list_of_strings"}, + "ipv4_address": {"type": "string"}, + "ipv6_address": {"type": "string"} + }, + "additionalProperties": false + }, + {"type": "null"} + ] + } + }, + "additionalProperties": false + } + ] + }, + "pid": {"type": ["string", "null"]}, + + "ports": { + "type": "array", + "items": { + "oneOf": [ + {"type": "number", "format": "ports"}, + {"type": "string", "format": "ports"}, + { + "type": "object", + "properties": { + "mode": {"type": "string"}, + "target": {"type": "integer"}, + "published": {"type": "integer"}, + "protocol": {"type": "string"} + }, + "additionalProperties": false + } + ] + }, + "uniqueItems": true + }, + + "privileged": {"type": "boolean"}, + "read_only": {"type": "boolean"}, + "restart": {"type": "string"}, + "security_opt": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "shm_size": {"type": ["number", "string"]}, + "secrets": { + "type": "array", + "items": { + "oneOf": [ + {"type": "string"}, + { + "type": "object", + "properties": { + "source": {"type": "string"}, + "target": {"type": "string"}, + "uid": {"type": "string"}, + "gid": {"type": "string"}, + "mode": {"type": "number"} + } + } + ] + } + }, + "sysctls": {"$ref": "#/definitions/list_or_dict"}, + "stdin_open": {"type": "boolean"}, + "stop_grace_period": {"type": "string", "format": "duration"}, + "stop_signal": {"type": "string"}, + "tmpfs": {"$ref": "#/definitions/string_or_list"}, + "tty": {"type": "boolean"}, + "ulimits": { + "type": "object", + "patternProperties": { + "^[a-z]+$": { + "oneOf": [ + {"type": "integer"}, + { + "type":"object", + "properties": { + "hard": {"type": "integer"}, + "soft": {"type": "integer"} + }, + "required": ["soft", "hard"], + "additionalProperties": false + } + ] + } + } + }, + "user": {"type": "string"}, + "userns_mode": {"type": "string"}, + "volumes": { + "type": "array", + "items": { + "oneOf": [ + {"type": "string"}, + { + "type": "object", + "required": ["type"], + "properties": { + "type": {"type": "string"}, + "source": {"type": "string"}, + "target": {"type": "string"}, + "read_only": {"type": "boolean"}, + "consistency": {"type": "string"}, + "bind": { + "type": "object", + "properties": { + "propagation": {"type": "string"} + } + }, + "volume": { + "type": "object", + "properties": { + "nocopy": {"type": "boolean"} + } + } + } + } + ], + "uniqueItems": true + } + }, + "working_dir": {"type": "string"} + }, + "additionalProperties": false + }, + + "healthcheck": { + "id": "#/definitions/healthcheck", + "type": "object", + "additionalProperties": false, + "properties": { + "disable": {"type": "boolean"}, + "interval": {"type": "string"}, + "retries": {"type": "number"}, + "test": { + "oneOf": [ + {"type": "string"}, + {"type": "array", "items": {"type": "string"}} + ] + }, + "timeout": {"type": "string"} + } + }, + "deployment": { + "id": "#/definitions/deployment", + "type": ["object", "null"], + "properties": { + "mode": {"type": "string"}, + "endpoint_mode": {"type": "string"}, + "replicas": {"type": "integer"}, + "labels": {"$ref": "#/definitions/list_or_dict"}, + "update_config": { + "type": "object", + "properties": { + "parallelism": {"type": "integer"}, + "delay": {"type": "string", "format": "duration"}, + "failure_action": {"type": "string"}, + "monitor": {"type": "string", "format": "duration"}, + "max_failure_ratio": {"type": "number"}, + "order": {"type": "string", "enum": [ + "start-first", "stop-first" + ]} + }, + "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"}}, + "preferences": { + "type": "array", + "items": { + "type": "object", + "properties": { + "spread": {"type": "string"} + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + + "resource": { + "id": "#/definitions/resource", + "type": "object", + "properties": { + "cpus": {"type": "string"}, + "memory": {"type": "string"} + }, + "additionalProperties": false + }, + + "network": { + "id": "#/definitions/network", + "type": ["object", "null"], + "properties": { + "driver": {"type": "string"}, + "driver_opts": { + "type": "object", + "patternProperties": { + "^.+$": {"type": ["string", "number"]} + } + }, + "ipam": { + "type": "object", + "properties": { + "driver": {"type": "string"}, + "config": { + "type": "array", + "items": { + "type": "object", + "properties": { + "subnet": {"type": "string"} + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + }, + "external": { + "type": ["boolean", "object"], + "properties": { + "name": {"type": "string"} + }, + "additionalProperties": false + }, + "internal": {"type": "boolean"}, + "attachable": {"type": "boolean"}, + "labels": {"$ref": "#/definitions/list_or_dict"} + }, + "additionalProperties": false + }, + + "volume": { + "id": "#/definitions/volume", + "type": ["object", "null"], + "properties": { + "name": {"type": "string"}, + "driver": {"type": "string"}, + "driver_opts": { + "type": "object", + "patternProperties": { + "^.+$": {"type": ["string", "number"]} + } + }, + "external": { + "type": ["boolean", "object"], + "properties": { + "name": {"type": "string"} + }, + "additionalProperties": false + }, + "labels": {"$ref": "#/definitions/list_or_dict"} + }, + "additionalProperties": false + }, + + "secret": { + "id": "#/definitions/secret", + "type": "object", + "properties": { + "file": {"type": "string"}, + "external": { + "type": ["boolean", "object"], + "properties": { + "name": {"type": "string"} + } + }, + "labels": {"$ref": "#/definitions/list_or_dict"} + }, + "additionalProperties": false + }, + + "config": { + "id": "#/definitions/config", + "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"] + } + } + } + } + } +} diff --git a/compose/config/serialize.py b/compose/config/serialize.py index dca0affb2a3..732c7ebc0e7 100644 --- a/compose/config/serialize.py +++ b/compose/config/serialize.py @@ -9,6 +9,7 @@ from compose.const import COMPOSEFILE_V2_1 as V2_1 from compose.const import COMPOSEFILE_V3_0 as V3_0 from compose.const import COMPOSEFILE_V3_2 as V3_2 +from compose.const import COMPOSEFILE_V3_2 as V3_4 def serialize_config_type(dumper, data): @@ -64,6 +65,9 @@ def denormalize_config(config, image_digests=None): if 'external_name' in conf: del conf['external_name'] + if 'name' in conf and config.version < V3_4: + del conf['name'] + return result diff --git a/compose/const.py b/compose/const.py index 6ea0ea79c5b..b5970f82ae9 100644 --- a/compose/const.py +++ b/compose/const.py @@ -31,6 +31,7 @@ COMPOSEFILE_V3_1 = ComposeVersion('3.1') COMPOSEFILE_V3_2 = ComposeVersion('3.2') COMPOSEFILE_V3_3 = ComposeVersion('3.3') +COMPOSEFILE_V3_4 = ComposeVersion('3.4') API_VERSIONS = { COMPOSEFILE_V1: '1.21', @@ -42,6 +43,7 @@ COMPOSEFILE_V3_1: '1.25', COMPOSEFILE_V3_2: '1.25', COMPOSEFILE_V3_3: '1.30', + COMPOSEFILE_V3_4: '1.30', } API_VERSION_TO_ENGINE_VERSION = { @@ -54,4 +56,5 @@ API_VERSIONS[COMPOSEFILE_V3_1]: '1.13.0', API_VERSIONS[COMPOSEFILE_V3_2]: '1.13.0', API_VERSIONS[COMPOSEFILE_V3_3]: '17.06.0', + API_VERSIONS[COMPOSEFILE_V3_4]: '17.06.0', } diff --git a/compose/volume.py b/compose/volume.py index ab6a88fac8c..da8ba25cab6 100644 --- a/compose/volume.py +++ b/compose/volume.py @@ -15,14 +15,15 @@ class Volume(object): def __init__(self, client, project, name, driver=None, driver_opts=None, - external_name=None, labels=None): + external=False, labels=None, custom_name=False): self.client = client self.project = project self.name = name self.driver = driver self.driver_opts = driver_opts - self.external_name = external_name + self.external = external self.labels = labels + self.custom_name = custom_name def create(self): return self.client.create_volume( @@ -46,14 +47,10 @@ def exists(self): return False return True - @property - def external(self): - return bool(self.external_name) - @property def full_name(self): - if self.external_name: - return self.external_name + if self.custom_name: + return self.name return '{0}_{1}'.format(self.project, self.name) @property @@ -80,11 +77,12 @@ def from_config(cls, name, config_data, client): vol_name: Volume( client=client, project=name, - name=vol_name, + name=data.get('name', vol_name), driver=data.get('driver'), driver_opts=data.get('driver_opts'), - external_name=data.get('external_name'), - labels=data.get('labels') + custom_name=data.get('name') is not None, + labels=data.get('labels'), + external=bool(data.get('external', False)) ) for vol_name, data in config_volumes.items() } diff --git a/tests/unit/volume_test.py b/tests/unit/volume_test.py index 24829192a17..457d8558174 100644 --- a/tests/unit/volume_test.py +++ b/tests/unit/volume_test.py @@ -21,6 +21,6 @@ def test_remove_local_volume(self, mock_client): mock_client.remove_volume.assert_called_once_with('foo_project') def test_remove_external_volume(self, mock_client): - vol = volume.Volume(mock_client, 'foo', 'project', external_name='data') + vol = volume.Volume(mock_client, 'foo', 'project', external=True) vol.remove() assert not mock_client.remove_volume.called From 22d9a258f481d6bcef189bc41f88d386ee6c1c21 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 10 Aug 2017 12:09:05 -0700 Subject: [PATCH 2948/4072] v2 custom volume name support Signed-off-by: Joffrey F --- compose/config/config_schema_v2.1.json | 3 ++- compose/config/config_schema_v2.2.json | 3 ++- compose/config/config_schema_v2.3.json | 3 ++- compose/config/serialize.py | 7 +++++-- tests/acceptance/cli_test.py | 6 ++++-- tests/integration/project_test.py | 4 ++-- tests/integration/volume_test.py | 19 +++++++++++++++---- 7 files changed, 32 insertions(+), 13 deletions(-) diff --git a/compose/config/config_schema_v2.1.json b/compose/config/config_schema_v2.1.json index 9d45c324c57..8a5e128347c 100644 --- a/compose/config/config_schema_v2.1.json +++ b/compose/config/config_schema_v2.1.json @@ -371,7 +371,8 @@ }, "additionalProperties": false }, - "labels": {"$ref": "#/definitions/list_or_dict"} + "labels": {"$ref": "#/definitions/list_or_dict"}, + "name": {"type": "string"} }, "additionalProperties": false }, diff --git a/compose/config/config_schema_v2.2.json b/compose/config/config_schema_v2.2.json index 9544170181f..58ba409ff1b 100644 --- a/compose/config/config_schema_v2.2.json +++ b/compose/config/config_schema_v2.2.json @@ -378,7 +378,8 @@ }, "additionalProperties": false }, - "labels": {"$ref": "#/definitions/list_or_dict"} + "labels": {"$ref": "#/definitions/list_or_dict"}, + "name": {"type": "string"} }, "additionalProperties": false }, diff --git a/compose/config/config_schema_v2.3.json b/compose/config/config_schema_v2.3.json index 10a61186e3d..789adf4abab 100644 --- a/compose/config/config_schema_v2.3.json +++ b/compose/config/config_schema_v2.3.json @@ -379,7 +379,8 @@ }, "additionalProperties": false }, - "labels": {"$ref": "#/definitions/list_or_dict"} + "labels": {"$ref": "#/definitions/list_or_dict"}, + "name": {"type": "string"} }, "additionalProperties": false }, diff --git a/compose/config/serialize.py b/compose/config/serialize.py index 732c7ebc0e7..0f0cb7f5075 100644 --- a/compose/config/serialize.py +++ b/compose/config/serialize.py @@ -65,8 +65,11 @@ def denormalize_config(config, image_digests=None): if 'external_name' in conf: del conf['external_name'] - if 'name' in conf and config.version < V3_4: - del conf['name'] + if 'name' in conf: + if config.version < V2_1 or (config.version > V3_0 and config.version < V3_4): + del conf['name'] + elif 'external' in conf: + conf['external'] = True return result diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 81bce546038..bee7b74a2e5 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -291,10 +291,12 @@ def test_config_external_volume(self): assert 'volumes' in json_result assert json_result['volumes'] == { 'foo': { - 'external': True + 'external': True, + 'name': 'foo', }, 'bar': { - 'external': {'name': 'some_bar'} + 'external': True, + 'name': 'some_bar', } } diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 4e44c7f6bf5..953dd52beb8 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -1412,7 +1412,7 @@ def test_initialize_volumes_external_volumes(self): 'command': 'top' }], volumes={ - vol_name: {'external': True, 'external_name': vol_name} + vol_name: {'external': True, 'name': vol_name} }, ) project = Project.from_config( @@ -1436,7 +1436,7 @@ def test_initialize_volumes_inexistent_external_volume(self): 'command': 'top' }], volumes={ - vol_name: {'external': True, 'external_name': vol_name} + vol_name: {'external': True, 'name': vol_name} }, ) project = Project.from_config( diff --git a/tests/integration/volume_test.py b/tests/integration/volume_test.py index ecc71d0b127..2a521d4c5b1 100644 --- a/tests/integration/volume_test.py +++ b/tests/integration/volume_test.py @@ -1,6 +1,7 @@ from __future__ import absolute_import from __future__ import unicode_literals +import six from docker.errors import DockerException from .testcases import DockerClientTestCase @@ -23,12 +24,15 @@ def tearDown(self): 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): - external = name + def create_volume(self, name, driver=None, opts=None, external=None, custom_name=False): + if external: + custom_name = True + if isinstance(external, six.text_type): + name = external + vol = Volume( self.client, 'composetest', name, driver=driver, driver_opts=opts, - external_name=external + external=bool(external), custom_name=custom_name ) self.tmp_volumes.append(vol) return vol @@ -39,6 +43,13 @@ def test_create_volume(self): info = self.get_volume_data(vol.full_name) assert info['Name'].split('/')[-1] == vol.full_name + def test_create_volume_custom_name(self): + vol = self.create_volume('volume01', custom_name=True) + assert vol.name == vol.full_name + vol.create() + info = self.get_volume_data(vol.full_name) + assert info['Name'].split('/')[-1] == vol.name + def test_recreate_existing_volume(self): vol = self.create_volume('volume01') From 7210fdb21cf1875161005938cd1ba4c33dbc0f2e Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 11 Aug 2017 15:52:43 -0700 Subject: [PATCH 2949/4072] Add support for start_period in healthcheck config Improve merging strategy for healthcheck configs Signed-off-by: Joffrey F --- compose/config/config.py | 25 +++++---- compose/config/config_schema_v2.3.json | 1 + compose/config/serialize.py | 4 ++ compose/utils.py | 5 +- tests/integration/service_test.py | 19 +++++++ tests/unit/config/config_test.py | 77 +++++++++++++++++++++++++- 6 files changed, 117 insertions(+), 14 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index aa829a40e85..0c2ab1ab767 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -797,16 +797,12 @@ def process_healthcheck(service_dict, service_name): elif 'test' in raw: hc['test'] = raw['test'] - if 'interval' in raw: - 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: - 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'] + for field in ['interval', 'timeout', 'start_period']: + if field in raw: + if not isinstance(raw[field], six.integer_types): + hc[field] = parse_nanoseconds_int(raw[field]) + else: # Conversion has been done previously + hc[field] = raw[field] if 'retries' in raw: hc['retries'] = raw['retries'] @@ -967,6 +963,7 @@ def merge_service_dicts(base, override, version): md.merge_field('logging', merge_logging, default={}) merge_ports(md, base, override) md.merge_field('blkio_config', merge_blkio_config, default={}) + md.merge_field('healthcheck', merge_healthchecks, default={}) for field in set(ALLOWED_KEYS) - set(md): md.merge_scalar(field) @@ -985,6 +982,14 @@ def merge_unique_items_lists(base, override): return sorted(set().union(base, override)) +def merge_healthchecks(base, override): + if override.get('disabled') is True: + return override + result = base.copy() + result.update(override) + return result + + def merge_ports(md, base, override): def parse_sequence_func(seq): acc = [] diff --git a/compose/config/config_schema_v2.3.json b/compose/config/config_schema_v2.3.json index 789adf4abab..7a9bdfdf1d7 100644 --- a/compose/config/config_schema_v2.3.json +++ b/compose/config/config_schema_v2.3.json @@ -309,6 +309,7 @@ "disable": {"type": "boolean"}, "interval": {"type": "string"}, "retries": {"type": "number"}, + "start_period": {"type": "string"}, "test": { "oneOf": [ {"type": "string"}, diff --git a/compose/config/serialize.py b/compose/config/serialize.py index 0f0cb7f5075..daddff69507 100644 --- a/compose/config/serialize.py +++ b/compose/config/serialize.py @@ -131,6 +131,10 @@ def denormalize_service_dict(service_dict, version, image_digest=None): service_dict['healthcheck']['timeout'] ) + if 'start_period' in service_dict['healthcheck']: + service_dict['healthcheck']['start_period'] = serialize_ns_time_value( + service_dict['healthcheck']['start_period'] + ) if 'ports' in service_dict and version < V3_2: service_dict['ports'] = [ p.legacy_repr() if isinstance(p, types.ServicePort) else p diff --git a/compose/utils.py b/compose/utils.py index 183a4504d46..1ede4d37d84 100644 --- a/compose/utils.py +++ b/compose/utils.py @@ -14,6 +14,7 @@ from .config.errors import ConfigurationError from .errors import StreamParseError +from .timeparse import MULTIPLIERS from .timeparse import timeparse @@ -112,7 +113,7 @@ def microseconds_from_time_nano(time_nano): def nanoseconds_from_time_seconds(time_seconds): - return time_seconds * 1000000000 + return int(time_seconds / MULTIPLIERS['nano']) def parse_seconds_float(value): @@ -123,7 +124,7 @@ def parse_nanoseconds_int(value): parsed = timeparse(value or '') if parsed is None: return None - return int(parsed * 1000000000) + return nanoseconds_from_time_seconds(parsed) def build_string_dict(source_dict): diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 2abb12c342c..84b54fe419d 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -36,6 +36,7 @@ from compose.service import NetworkMode from compose.service import PidMode from compose.service import Service +from compose.utils import parse_nanoseconds_int from tests.integration.testcases import is_cluster from tests.integration.testcases import no_cluster from tests.integration.testcases import v2_1_only @@ -270,6 +271,24 @@ def test_create_container_with_specified_volume(self): self.assertTrue(path.basename(actual_host_path) == path.basename(host_path), msg=("Last component differs: %s, %s" % (actual_host_path, host_path))) + def test_create_container_with_healthcheck_config(self): + one_second = parse_nanoseconds_int('1s') + healthcheck = { + 'test': ['true'], + 'interval': 2 * one_second, + 'timeout': 5 * one_second, + 'retries': 5, + 'start_period': 2 * one_second + } + service = self.create_service('db', healthcheck=healthcheck) + container = service.create_container() + remote_healthcheck = container.get('Config.Healthcheck') + assert remote_healthcheck['Test'] == healthcheck['test'] + assert remote_healthcheck['Interval'] == healthcheck['interval'] + assert remote_healthcheck['Timeout'] == healthcheck['timeout'] + assert remote_healthcheck['Retries'] == healthcheck['retries'] + assert remote_healthcheck['StartPeriod'] == healthcheck['start_period'] + def test_recreate_preserves_volume_with_trailing_slash(self): """When the Compose file specifies a trailing slash in the container path, make sure we copy the volume over when recreating. diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 8a1e16f8a6f..4e355d3bfe2 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -2197,6 +2197,75 @@ def test_merge_blkio_config(self): } } + def test_merge_healthcheck_config(self): + base = { + 'image': 'bar', + 'healthcheck': { + 'start_period': 1000, + 'interval': 3000, + 'test': ['true'] + } + } + + override = { + 'healthcheck': { + 'interval': 5000, + 'timeout': 10000, + 'test': ['echo', 'OK'], + } + } + + actual = config.merge_service_dicts(base, override, V2_3) + assert actual['healthcheck'] == { + 'start_period': base['healthcheck']['start_period'], + 'test': override['healthcheck']['test'], + 'interval': override['healthcheck']['interval'], + 'timeout': override['healthcheck']['timeout'], + } + + def test_merge_healthcheck_override_disables(self): + base = { + 'image': 'bar', + 'healthcheck': { + 'start_period': 1000, + 'interval': 3000, + 'timeout': 2000, + 'retries': 3, + 'test': ['true'] + } + } + + override = { + 'healthcheck': { + 'disabled': True + } + } + + actual = config.merge_service_dicts(base, override, V2_3) + assert actual['healthcheck'] == {'disabled': True} + + def test_merge_healthcheck_override_enables(self): + base = { + 'image': 'bar', + 'healthcheck': { + 'disabled': True + } + } + + override = { + 'healthcheck': { + 'disabled': False, + 'start_period': 1000, + 'interval': 3000, + 'timeout': 2000, + 'retries': 3, + 'test': ['true'] + } + } + + actual = config.merge_service_dicts(base, override, V2_3) + assert actual['healthcheck'] == override['healthcheck'] + def test_external_volume_config(self): config_details = build_config_details({ 'version': '2', @@ -4008,6 +4077,7 @@ def test_healthcheck(self): 'interval': '1s', 'timeout': '1m', 'retries': 3, + 'start_period': '10s' }}, '.', ) @@ -4017,6 +4087,7 @@ def test_healthcheck(self): 'interval': nanoseconds_from_time_seconds(1), 'timeout': nanoseconds_from_time_seconds(60), 'retries': 3, + 'start_period': nanoseconds_from_time_seconds(10) } def test_disable(self): @@ -4147,15 +4218,17 @@ def test_denormalize_healthcheck(self): 'test': 'exit 1', 'interval': '1m40s', 'timeout': '30s', - 'retries': 5 + 'retries': 5, + 'start_period': '2s90ms' } } processed_service = config.process_service(config.ServiceConfig( '.', 'test', 'test', service_dict )) - denormalized_service = denormalize_service_dict(processed_service, V2_1) + denormalized_service = denormalize_service_dict(processed_service, V2_3) assert denormalized_service['healthcheck']['interval'] == '100s' assert denormalized_service['healthcheck']['timeout'] == '30s' + assert denormalized_service['healthcheck']['start_period'] == '2090ms' def test_denormalize_image_has_digest(self): service_dict = { From 390ba801a3299b1a73d31e739afb0992115a3901 Mon Sep 17 00:00:00 2001 From: aronahl Date: Wed, 9 Aug 2017 19:44:12 -0400 Subject: [PATCH 2950/4072] Fix exit code 0 upon parallel pull failure. Signed-off-by: Aaron Nall --- compose/project.py | 4 +++- tests/acceptance/cli_test.py | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/compose/project.py b/compose/project.py index 86fbda6eefa..2310a2fcc68 100644 --- a/compose/project.py +++ b/compose/project.py @@ -498,13 +498,15 @@ def pull(self, service_names=None, ignore_pull_failures=False, parallel_pull=Fal def pull_service(service): service.pull(ignore_pull_failures, True) - parallel.parallel_execute( + _, errors = parallel.parallel_execute( services, pull_service, operator.attrgetter('name'), 'Pulling', limit=5, ) + if len(errors): + raise ProjectError(b"\n".join(errors.values())) else: for service in services: service.pull(ignore_pull_failures, silent=silent) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index bee7b74a2e5..78d1c1eb181 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -6,6 +6,7 @@ import json import os import os.path +import re import signal import subprocess import time @@ -448,6 +449,20 @@ def test_pull_with_quiet(self): assert self.dispatch(['pull', '--quiet']).stderr == '' assert self.dispatch(['pull', '--quiet']).stdout == '' + def test_pull_with_parallel_failure(self): + result = self.dispatch([ + '-f', 'ignore-pull-failures.yml', 'pull', '--parallel'], + returncode=1 + ) + + self.assertRegexpMatches(result.stderr, re.compile('^Pulling simple', re.MULTILINE)) + self.assertRegexpMatches(result.stderr, re.compile('^Pulling another', re.MULTILINE)) + self.assertRegexpMatches(result.stderr, + re.compile('^ERROR: for another .*does not exist.*', re.MULTILINE)) + self.assertRegexpMatches(result.stderr, + re.compile('''^(ERROR: )?(b')?.* nonexisting-image''', + re.MULTILINE)) + def test_build_plain(self): self.base_dir = 'tests/fixtures/simple-dockerfile' self.dispatch(['build', 'simple']) From bd3feae62b9bf58616cddddd9dd7ca2ec6a5e552 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 17 Aug 2017 14:31:20 -0700 Subject: [PATCH 2951/4072] Bump python SDK version -> 2.5.0 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 d1778990f41..d0c0e941bc2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ cached-property==1.3.0 certifi==2017.4.17 chardet==3.0.4 colorama==0.3.9; sys_platform == 'win32' -docker==2.4.2 +docker==2.5.0 docker-pycreds==0.2.1 dockerpty==0.4.1 docopt==0.6.2 diff --git a/setup.py b/setup.py index 16493f52b96..9721fd38483 100644 --- a/setup.py +++ b/setup.py @@ -36,7 +36,7 @@ def find_version(*file_paths): 'requests >= 2.6.1, != 2.11.0, < 2.12', 'texttable >= 0.9.0, < 0.10', 'websocket-client >= 0.32.0, < 1.0', - 'docker >= 2.4.2, < 3.0', + 'docker >= 2.5.0, < 3.0', 'dockerpty >= 0.4.1, < 0.5', 'six >= 1.3.0, < 2', 'jsonschema >= 2.5.1, < 3', From f1083087dfe7ea2fe729d8be8ee037e166481930 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 17 Aug 2017 15:52:08 -0700 Subject: [PATCH 2952/4072] Update schemas to prevent invalid properties in deploy.resources Signed-off-by: Joffrey F --- compose/config/config_schema_v3.0.json | 3 ++- compose/config/config_schema_v3.1.json | 3 ++- compose/config/config_schema_v3.2.json | 3 ++- compose/config/config_schema_v3.3.json | 3 ++- compose/config/config_schema_v3.4.json | 7 +++++-- 5 files changed, 13 insertions(+), 6 deletions(-) diff --git a/compose/config/config_schema_v3.0.json b/compose/config/config_schema_v3.0.json index fbcd8bb859a..f39344cfbe7 100644 --- a/compose/config/config_schema_v3.0.json +++ b/compose/config/config_schema_v3.0.json @@ -240,7 +240,8 @@ "properties": { "limits": {"$ref": "#/definitions/resource"}, "reservations": {"$ref": "#/definitions/resource"} - } + }, + "additionalProperties": false }, "restart_policy": { "type": "object", diff --git a/compose/config/config_schema_v3.1.json b/compose/config/config_schema_v3.1.json index b7037485f97..719c0fa7acc 100644 --- a/compose/config/config_schema_v3.1.json +++ b/compose/config/config_schema_v3.1.json @@ -269,7 +269,8 @@ "properties": { "limits": {"$ref": "#/definitions/resource"}, "reservations": {"$ref": "#/definitions/resource"} - } + }, + "additionalProperties": false }, "restart_policy": { "type": "object", diff --git a/compose/config/config_schema_v3.2.json b/compose/config/config_schema_v3.2.json index ea702fcd581..175e061ad8f 100644 --- a/compose/config/config_schema_v3.2.json +++ b/compose/config/config_schema_v3.2.json @@ -312,7 +312,8 @@ "properties": { "limits": {"$ref": "#/definitions/resource"}, "reservations": {"$ref": "#/definitions/resource"} - } + }, + "additionalProperties": false }, "restart_policy": { "type": "object", diff --git a/compose/config/config_schema_v3.3.json b/compose/config/config_schema_v3.3.json index e69116c3889..f1eb9a66103 100644 --- a/compose/config/config_schema_v3.3.json +++ b/compose/config/config_schema_v3.3.json @@ -348,7 +348,8 @@ "properties": { "limits": {"$ref": "#/definitions/resource"}, "reservations": {"$ref": "#/definitions/resource"} - } + }, + "additionalProperties": false }, "restart_policy": { "type": "object", diff --git a/compose/config/config_schema_v3.4.json b/compose/config/config_schema_v3.4.json index ce9512076b5..5a110a8880d 100644 --- a/compose/config/config_schema_v3.4.json +++ b/compose/config/config_schema_v3.4.json @@ -84,7 +84,9 @@ "dockerfile": {"type": "string"}, "args": {"$ref": "#/definitions/list_or_dict"}, "labels": {"$ref": "#/definitions/list_or_dict"}, - "cache_from": {"$ref": "#/definitions/list_of_strings"} + "cache_from": {"$ref": "#/definitions/list_of_strings"}, + "network": {"type": "string"}, + "target": {"type": "string"} }, "additionalProperties": false } @@ -351,7 +353,8 @@ "properties": { "limits": {"$ref": "#/definitions/resource"}, "reservations": {"$ref": "#/definitions/resource"} - } + }, + "additionalProperties": false }, "restart_policy": { "type": "object", From ec5d8264c956541a4dd1309da6ba93687d246904 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 17 Apr 2017 19:03:56 -0700 Subject: [PATCH 2953/4072] Implement --scale option on up command, allow scale config in v2.2 format docker-compose scale modified to reuse code between up and scale Signed-off-by: Joffrey F --- compose/service.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/compose/service.py b/compose/service.py index 53ad4636242..c8c2bd9827b 100644 --- a/compose/service.py +++ b/compose/service.py @@ -392,7 +392,7 @@ def create_and_start(service, n): range(i, i + scale), lambda n: create_and_start(self, n), lambda n: self.get_container_name(n), - "Creating" + "Creating", ) for error in errors.values(): raise OperationFailedError(error) @@ -413,7 +413,7 @@ def recreate(container): containers, recreate, lambda c: c.name, - "Recreating" + "Recreating", ) for error in errors.values(): raise OperationFailedError(error) @@ -433,7 +433,7 @@ def _execute_convergence_start(self, containers, scale, timeout, detached, start containers, lambda c: self.start_container_if_stopped(c, attach_logs=not detached), lambda c: c.name, - "Starting" + "Starting", ) for error in errors.values(): @@ -868,7 +868,7 @@ def _get_container_host_config(self, override_options, one_off=False): volume_driver=options.get('volume_driver'), cpuset_cpus=options.get('cpuset'), cpu_shares=options.get('cpu_shares'), - storage_opt=options.get('storage_opt') + storage_opt=options.get('storage_opt'), ) def get_secret_volumes(self): @@ -905,9 +905,7 @@ def build(self, no_cache=False, pull=False, force_rm=False, build_args_override= nocache=no_cache, dockerfile=build_opts.get('dockerfile', None), cache_from=build_opts.get('cache_from', None), - labels=build_opts.get('labels', None), - buildargs=build_args, - network_mode=build_opts.get('network', None), + buildargs=build_args ) try: From 92873edd65ac6f56f2fe57842bfde42baea67853 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 1 May 2017 14:29:37 -0700 Subject: [PATCH 2954/4072] Fix external secrets serialization Signed-off-by: Joffrey F --- compose/config/serialize.py | 1 + 1 file changed, 1 insertion(+) diff --git a/compose/config/serialize.py b/compose/config/serialize.py index 84521848db4..3fdd4d39298 100644 --- a/compose/config/serialize.py +++ b/compose/config/serialize.py @@ -52,6 +52,7 @@ def denormalize_config(config, image_digests=None): service_dict.pop('name'): service_dict for service_dict in denormalized_services } + for key in ('networks', 'volumes', 'secrets', 'configs'): config_dict = getattr(config, key) if not config_dict: From c006add122a85bdf5cf2f981aa91692c9ad2f49f Mon Sep 17 00:00:00 2001 From: Yong Wen Chua Date: Mon, 10 Jul 2017 12:35:34 +0800 Subject: [PATCH 2955/4072] Add Compose v2.3 Signed-off-by: Yong Wen Chua --- compose/config/config_schema_v2.3.json | 401 +++++++++++++++++++++++++ compose/const.py | 3 + docker-compose.spec | 5 + tests/integration/testcases.py | 5 + tests/unit/config/config_test.py | 4 + 5 files changed, 418 insertions(+) create mode 100644 compose/config/config_schema_v2.3.json diff --git a/compose/config/config_schema_v2.3.json b/compose/config/config_schema_v2.3.json new file mode 100644 index 00000000000..abcc2ded262 --- /dev/null +++ b/compose/config/config_schema_v2.3.json @@ -0,0 +1,401 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "id": "config_schema_v2.3.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"}, + "labels": {"$ref": "#/definitions/list_or_dict"}, + "cache_from": {"$ref": "#/definitions/list_of_strings"}, + "network": {"type": "string"} + }, + "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_count": {"type": "integer", "minimum": 0}, + "cpu_percent": {"type": "integer", "minimum": 0, "maximum": 100}, + "cpu_shares": {"type": ["number", "string"]}, + "cpu_quota": {"type": ["number", "string"]}, + "cpus": {"type": "number", "minimum": 0}, + "cpuset": {"type": "string"}, + "depends_on": { + "oneOf": [ + {"$ref": "#/definitions/list_of_strings"}, + { + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "type": "object", + "additionalProperties": false, + "properties": { + "condition": { + "type": "string", + "enum": ["service_started", "service_healthy"] + } + }, + "required": ["condition"] + } + } + } + ] + }, + "devices": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "dns_opt": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + }, + "dns": {"$ref": "#/definitions/string_or_list"}, + "dns_search": {"$ref": "#/definitions/string_or_list"}, + "domainname": {"type": "string"}, + "entrypoint": { + "oneOf": [ + {"type": "string"}, + {"type": "array", "items": {"type": "string"}} + ] + }, + "env_file": {"$ref": "#/definitions/string_or_list"}, + "environment": {"$ref": "#/definitions/list_or_dict"}, + + "expose": { + "type": "array", + "items": { + "type": ["string", "number"], + "format": "expose" + }, + "uniqueItems": true + }, + + "extends": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + + "properties": { + "service": {"type": "string"}, + "file": {"type": "string"} + }, + "required": ["service"], + "additionalProperties": false + } + ] + }, + + "external_links": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "extra_hosts": {"$ref": "#/definitions/list_or_dict"}, + "healthcheck": {"$ref": "#/definitions/healthcheck"}, + "hostname": {"type": "string"}, + "image": {"type": "string"}, + "init": {"type": ["boolean", "string"]}, + "ipc": {"type": "string"}, + "isolation": {"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"]}, + "mem_reservation": {"type": ["string", "integer"]}, + "mem_swappiness": {"type": "integer"}, + "memswap_limit": {"type": ["number", "string"]}, + "network_mode": {"type": "string"}, + + "networks": { + "oneOf": [ + {"$ref": "#/definitions/list_of_strings"}, + { + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "oneOf": [ + { + "type": "object", + "properties": { + "aliases": {"$ref": "#/definitions/list_of_strings"}, + "ipv4_address": {"type": "string"}, + "ipv6_address": {"type": "string"}, + "link_local_ips": {"$ref": "#/definitions/list_of_strings"} + }, + "additionalProperties": false + }, + {"type": "null"} + ] + } + }, + "additionalProperties": false + } + ] + }, + "oom_score_adj": {"type": "integer", "minimum": -1000, "maximum": 1000}, + "group_add": { + "type": "array", + "items": { + "type": ["string", "number"] + }, + "uniqueItems": true + }, + "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"}, + "scale": {"type": "integer"}, + "security_opt": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "shm_size": {"type": ["number", "string"]}, + "sysctls": {"$ref": "#/definitions/list_or_dict"}, + "pids_limit": {"type": ["number", "string"]}, + "stdin_open": {"type": "boolean"}, + "stop_grace_period": {"type": "string", "format": "duration"}, + "stop_signal": {"type": "string"}, + "storage_opt": {"type": "object"}, + "tmpfs": {"$ref": "#/definitions/string_or_list"}, + "tty": {"type": "boolean"}, + "ulimits": { + "type": "object", + "patternProperties": { + "^[a-z]+$": { + "oneOf": [ + {"type": "integer"}, + { + "type":"object", + "properties": { + "hard": {"type": "integer"}, + "soft": {"type": "integer"} + }, + "required": ["soft", "hard"], + "additionalProperties": false + } + ] + } + } + }, + "user": {"type": "string"}, + "userns_mode": {"type": "string"}, + "volumes": {"type": "array", "items": {"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 + }, + + "healthcheck": { + "id": "#/definitions/healthcheck", + "type": "object", + "additionalProperties": false, + "properties": { + "disable": {"type": "boolean"}, + "interval": {"type": "string"}, + "retries": {"type": "number"}, + "test": { + "oneOf": [ + {"type": "string"}, + {"type": "array", "items": {"type": "string"}} + ] + }, + "timeout": {"type": "string"} + } + }, + + "network": { + "id": "#/definitions/network", + "type": "object", + "properties": { + "driver": {"type": "string"}, + "driver_opts": { + "type": "object", + "patternProperties": { + "^.+$": {"type": ["string", "number"]} + } + }, + "ipam": { + "type": "object", + "properties": { + "driver": {"type": "string"}, + "config": { + "type": "array" + }, + "options": { + "type": "object", + "patternProperties": { + "^.+$": {"type": "string"} + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "external": { + "type": ["boolean", "object"], + "properties": { + "name": {"type": "string"} + }, + "additionalProperties": false + }, + "internal": {"type": "boolean"}, + "enable_ipv6": {"type": "boolean"}, + "labels": {"$ref": "#/definitions/list_or_dict"} + }, + "additionalProperties": false + }, + + "volume": { + "id": "#/definitions/volume", + "type": ["object", "null"], + "properties": { + "driver": {"type": "string"}, + "driver_opts": { + "type": "object", + "patternProperties": { + "^.+$": {"type": ["string", "number"]} + } + }, + "external": { + "type": ["boolean", "object"], + "properties": { + "name": {"type": "string"} + }, + "additionalProperties": false + }, + "labels": {"$ref": "#/definitions/list_or_dict"} + }, + "additionalProperties": false + }, + + "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/const.py b/compose/const.py index e46de8a733d..6ea0ea79c5b 100644 --- a/compose/const.py +++ b/compose/const.py @@ -25,6 +25,7 @@ COMPOSEFILE_V2_0 = ComposeVersion('2.0') COMPOSEFILE_V2_1 = ComposeVersion('2.1') COMPOSEFILE_V2_2 = ComposeVersion('2.2') +COMPOSEFILE_V2_3 = ComposeVersion('2.3') COMPOSEFILE_V3_0 = ComposeVersion('3.0') COMPOSEFILE_V3_1 = ComposeVersion('3.1') @@ -36,6 +37,7 @@ COMPOSEFILE_V2_0: '1.22', COMPOSEFILE_V2_1: '1.24', COMPOSEFILE_V2_2: '1.25', + COMPOSEFILE_V2_3: '1.30', COMPOSEFILE_V3_0: '1.25', COMPOSEFILE_V3_1: '1.25', COMPOSEFILE_V3_2: '1.25', @@ -47,6 +49,7 @@ API_VERSIONS[COMPOSEFILE_V2_0]: '1.10.0', API_VERSIONS[COMPOSEFILE_V2_1]: '1.12.0', API_VERSIONS[COMPOSEFILE_V2_2]: '1.13.0', + API_VERSIONS[COMPOSEFILE_V2_3]: '17.06.0', API_VERSIONS[COMPOSEFILE_V3_0]: '1.13.0', API_VERSIONS[COMPOSEFILE_V3_1]: '1.13.0', API_VERSIONS[COMPOSEFILE_V3_2]: '1.13.0', diff --git a/docker-compose.spec b/docker-compose.spec index 8e0d51ae5f8..8dc70c22624 100644 --- a/docker-compose.spec +++ b/docker-compose.spec @@ -37,6 +37,11 @@ exe = EXE(pyz, 'compose/config/config_schema_v2.2.json', 'DATA' ), + ( + 'compose/config/config_schema_v2.3.json', + 'compose/config/config_schema_v2.3.json', + 'DATA' + ), ( 'compose/config/config_schema_v3.0.json', 'compose/config/config_schema_v3.0.json', diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index 1b451ef3c28..b1763b113cc 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -17,6 +17,7 @@ from compose.const import COMPOSEFILE_V2_0 as V2_0 from compose.const import COMPOSEFILE_V2_0 as V2_1 from compose.const import COMPOSEFILE_V2_2 as V2_2 +from compose.const import COMPOSEFILE_V2_3 as V2_3 from compose.const import COMPOSEFILE_V3_0 as V3_0 from compose.const import COMPOSEFILE_V3_2 as V3_2 from compose.const import COMPOSEFILE_V3_3 as V3_3 @@ -78,6 +79,10 @@ def v2_2_only(): return min_version_skip(V2_2) +def v2_3_only(): + return min_version_skip(V2_3) + + def v3_only(): return min_version_skip(V3_0) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index ac742a19912..9d42f2b595f 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -28,6 +28,7 @@ from compose.const import COMPOSEFILE_V2_0 as V2_0 from compose.const import COMPOSEFILE_V2_1 as V2_1 from compose.const import COMPOSEFILE_V2_2 as V2_2 +from compose.const import COMPOSEFILE_V2_3 as V2_3 from compose.const import COMPOSEFILE_V3_0 as V3_0 from compose.const import COMPOSEFILE_V3_1 as V3_1 from compose.const import COMPOSEFILE_V3_2 as V3_2 @@ -179,6 +180,9 @@ def test_valid_versions(self): cfg = config.load(build_config_details({'version': '2.2'})) assert cfg.version == V2_2 + cfg = config.load(build_config_details({'version': '2.3'})) + assert cfg.version == V2_3 + for version in ['3', '3.0']: cfg = config.load(build_config_details({'version': version})) assert cfg.version == V3_0 From 16f8953c786c48fc9febe01bd8b03107598e81d9 Mon Sep 17 00:00:00 2001 From: Yong Wen Chua Date: Mon, 10 Jul 2017 13:02:47 +0800 Subject: [PATCH 2956/4072] Add `target` to service build configuration Signed-off-by: Yong Wen Chua --- compose/config/config.py | 1 + compose/config/config_schema_v2.3.json | 3 ++- compose/service.py | 5 ++++- tests/integration/service_test.py | 22 ++++++++++++++++++++++ tests/unit/service_test.py | 2 ++ 5 files changed, 31 insertions(+), 2 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index f5053af8acc..659b6cd5928 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -986,6 +986,7 @@ def to_dict(service): md.merge_scalar('context') md.merge_scalar('dockerfile') md.merge_scalar('network') + md.merge_scalar('target') md.merge_mapping('args', parse_build_arguments) md.merge_field('cache_from', merge_unique_items_lists, default=[]) md.merge_mapping('labels', parse_labels) diff --git a/compose/config/config_schema_v2.3.json b/compose/config/config_schema_v2.3.json index abcc2ded262..87734027640 100644 --- a/compose/config/config_schema_v2.3.json +++ b/compose/config/config_schema_v2.3.json @@ -61,7 +61,8 @@ "args": {"$ref": "#/definitions/list_or_dict"}, "labels": {"$ref": "#/definitions/list_or_dict"}, "cache_from": {"$ref": "#/definitions/list_of_strings"}, - "network": {"type": "string"} + "network": {"type": "string"}, + "target": {"type": "string"} }, "additionalProperties": false } diff --git a/compose/service.py b/compose/service.py index c8c2bd9827b..c43f635b2bd 100644 --- a/compose/service.py +++ b/compose/service.py @@ -905,7 +905,10 @@ def build(self, no_cache=False, pull=False, force_rm=False, build_args_override= nocache=no_cache, dockerfile=build_opts.get('dockerfile', None), cache_from=build_opts.get('cache_from', None), - buildargs=build_args + labels=build_opts.get('labels', None), + buildargs=build_args, + network_mode=build_opts.get('network', None), + target=build_opts.get('target', None), ) try: diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index ff75015df5c..4a5ec5654f1 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -40,6 +40,7 @@ from tests.integration.testcases import no_cluster from tests.integration.testcases import v2_1_only from tests.integration.testcases import v2_2_only +from tests.integration.testcases import v2_3_only from tests.integration.testcases import v2_only from tests.integration.testcases import v3_only @@ -754,6 +755,27 @@ def test_build_with_network(self): assert service.image() + @v2_3_only() + def test_build_with_target(self): + self.require_api_version('1.30') + base_dir = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, base_dir) + + with open(os.path.join(base_dir, 'Dockerfile'), 'w') as f: + f.write('FROM busybox as one\n') + f.write('LABEL com.docker.compose.test.target=one\n') + f.write('FROM busybox as two\n') + f.write('LABEL com.docker.compose.test.target=two\n') + + service = self.create_service('buildlabels', build={ + 'context': text_type(base_dir), + 'target': 'one' + }) + + service.build() + assert service.image() + assert service.image()['Config']['Labels']['com.docker.compose.test.target'] == 'one' + def test_start_container_stays_unprivileged(self): service = self.create_service('web') container = create_and_start_container(service).inspect() diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 2b0a2762dc8..0293695abbb 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -474,6 +474,7 @@ def test_create_container(self): labels=None, cache_from=None, network_mode=None, + target=None, ) def test_ensure_image_exists_no_build(self): @@ -513,6 +514,7 @@ def test_ensure_image_exists_force_build(self): labels=None, cache_from=None, network_mode=None, + target=None, ) def test_build_does_not_pull(self): From cf0afb071da46c7bec1741d126be050a7a3d35fa Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Fri, 14 Jul 2017 11:34:00 +0200 Subject: [PATCH 2957/4072] Add bash completion for `pull --quiet` 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 57dfd51f5a5..d283a041aa0 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -341,7 +341,7 @@ _docker_compose_ps() { _docker_compose_pull() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "--help --ignore-pull-failures --parallel" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--help --ignore-pull-failures --parallel --quiet" -- "$cur" ) ) ;; *) __docker_compose_services_from_image From 73fd0abd5b33ee93d4e86764decdbe7d1c2584d6 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 26 Jul 2017 16:44:54 -0700 Subject: [PATCH 2958/4072] Fix test issues with Engine 17.07 RC1 Signed-off-by: Joffrey F --- tests/acceptance/cli_test.py | 3 ++- tests/integration/service_test.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index fc05de3514b..f7ecba9f52a 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -439,7 +439,8 @@ def test_pull_with_ignore_pull_failures(self): assert 'Pulling simple (busybox:latest)...' in result.stderr assert 'Pulling another (nonexisting-image:latest)...' in result.stderr assert ('repository nonexisting-image not found' in result.stderr or - 'image library/nonexisting-image:latest not found' in result.stderr) + 'image library/nonexisting-image:latest not found' in result.stderr or + 'pull access denied for nonexisting-image' in result.stderr) def test_pull_with_quiet(self): assert self.dispatch(['pull', '--quiet']).stderr == '' diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 4a5ec5654f1..3a585ec016a 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -210,7 +210,8 @@ def test_create_container_with_security_opt(self): service.start_container(container) self.assertEqual(set(container.get('HostConfig.SecurityOpt')), set(security_opt)) - @pytest.mark.xfail(True, reason='Not supported on most drivers') + # @pytest.mark.xfail(True, reason='Not supported on most drivers') + @pytest.mark.skipif(True, reason='https://github.com/moby/moby/issues/34270') def test_create_container_with_storage_opt(self): storage_opt = {'size': '1G'} service = self.create_service('db', storage_opt=storage_opt) From 770d94376a6d2a35e0f81f8652ac4b435844ec23 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 25 Jul 2017 16:42:31 -0700 Subject: [PATCH 2959/4072] Escape dollar sign in serialized config output Signed-off-by: Joffrey F --- compose/config/serialize.py | 5 ++++- tests/unit/config/config_test.py | 22 ++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/compose/config/serialize.py b/compose/config/serialize.py index 3fdd4d39298..86fdac38f62 100644 --- a/compose/config/serialize.py +++ b/compose/config/serialize.py @@ -21,8 +21,11 @@ def serialize_dict_type(dumper, data): def serialize_string(dumper, data): - """ Ensure boolean-like strings are quoted in the output """ + """ Ensure boolean-like strings are quoted in the output and escape $ characters """ representer = dumper.represent_str if six.PY3 else dumper.represent_unicode + + data = data.replace('$', '$$') + if data.lower() in ('y', 'n', 'yes', 'no', 'on', 'off', 'true', 'false'): # Empirically only y/n appears to be an issue, but this might change # depending on which PyYaml version is being used. Err on safe side. diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 9d42f2b595f..63cb7eaefb6 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -4185,3 +4185,25 @@ def test_serialize_bool_string(self): assert 'command: "true"\n' in serialized_config assert 'FOO: "Y"\n' in serialized_config assert 'BAR: "on"\n' in serialized_config + + def test_serialize_escape_dollar_sign(self): + cfg = { + 'version': '2.2', + 'services': { + 'web': { + 'image': 'busybox', + 'command': 'echo $$FOO', + 'environment': { + 'CURRENCY': '$$' + }, + 'entrypoint': ['$$SHELL', '-c'], + } + } + } + config_dict = config.load(build_config_details(cfg)) + + serialized_config = yaml.load(serialize_config(config_dict)) + serialized_service = serialized_config['services']['web'] + assert serialized_service['environment']['CURRENCY'] == '$$' + assert serialized_service['command'] == 'echo $$FOO' + assert serialized_service['entrypoint'][0] == '$$SHELL' From 1ae83d41398d55dcf2387e27199338e86010dd39 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 25 Jul 2017 16:27:10 -0700 Subject: [PATCH 2960/4072] 0 is a valid value for a published port Signed-off-by: Joffrey F --- compose/config/types.py | 2 +- tests/unit/config/types_test.py | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/compose/config/types.py b/compose/config/types.py index be26971c45b..c410343b886 100644 --- a/compose/config/types.py +++ b/compose/config/types.py @@ -343,7 +343,7 @@ def legacy_repr(self): def normalize_port_dict(port): return '{external_ip}{has_ext_ip}{published}{is_pub}{target}/{protocol}'.format( published=port.get('published', ''), - is_pub=(':' if port.get('published') or port.get('external_ip') else ''), + is_pub=(':' if port.get('published') is not None or port.get('external_ip') else ''), target=port.get('target'), protocol=port.get('protocol', 'tcp'), external_ip=port.get('external_ip', ''), diff --git a/tests/unit/config/types_test.py b/tests/unit/config/types_test.py index 10b698fe3d5..3a43f727bd9 100644 --- a/tests/unit/config/types_test.py +++ b/tests/unit/config/types_test.py @@ -81,6 +81,12 @@ def test_parse_ext_ip_no_published_port(self): 'external_ip': '1.1.1.1', } + def test_repr_published_port_0(self): + port_def = '0:4000' + ports = ServicePort.parse(port_def) + assert len(ports) == 1 + assert ports[0].legacy_repr() == port_def + '/tcp' + def test_parse_port_range(self): ports = ServicePort.parse('25000-25001:4000-4001') assert len(ports) == 2 From e0f7b075b8c02b40da3e58757d6bf6bc7e9588ed Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 26 Jul 2017 18:21:30 -0700 Subject: [PATCH 2961/4072] 1.16.0-dev 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 f238607c0ee..cedb7cf040d 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.15.0' +__version__ = '1.16.0-dev' From bbebf518cfc9b975f04883ef4b3a9a89c071eab2 Mon Sep 17 00:00:00 2001 From: Carl George Date: Wed, 26 Jul 2017 16:50:38 -0500 Subject: [PATCH 2962/4072] only require colorama on windows Colorama is only useful on Windows by design. Since it has no effect on other platforms, it makes sense to not require it universally. Signed-off-by: Carl George --- compose/cli/colors.py | 6 ++++-- requirements.txt | 2 +- setup.py | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/compose/cli/colors.py b/compose/cli/colors.py index f1251e43134..cb30e361598 100644 --- a/compose/cli/colors.py +++ b/compose/cli/colors.py @@ -1,7 +1,7 @@ from __future__ import absolute_import from __future__ import unicode_literals -import colorama +from ..const import IS_WINDOWS_PLATFORM NAMES = [ 'grey', @@ -33,7 +33,9 @@ def make_color_fn(code): return lambda s: ansi_color(code, s) -colorama.init(strip=False) +if IS_WINDOWS_PLATFORM: + import colorama + colorama.init(strip=False) for (name, code) in get_pairs(): globals()[name] = make_color_fn(code) diff --git a/requirements.txt b/requirements.txt index 844921ffd11..81dcdf08c7f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ backports.ssl-match-hostname==3.5.0.1; python_version < '3' cached-property==1.3.0 certifi==2017.4.17 chardet==3.0.4 -colorama==0.3.9 +colorama==0.3.9; sys_platform == 'win32' docker==2.4.2 docker-pycreds==0.2.1 dockerpty==0.4.1 diff --git a/setup.py b/setup.py index dab7a6eea2c..a3072c76ea0 100644 --- a/setup.py +++ b/setup.py @@ -31,7 +31,6 @@ 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', @@ -56,6 +55,7 @@ def find_version(*file_paths): ':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'], + ':sys_platform == "win32"': ['colorama >= 0.3.7, < 0.4'], 'socks': ['PySocks >= 1.5.6, != 1.5.7, < 2'], } From 4652d3c38a9e3899e77f40f035523650ad625d68 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 2 Aug 2017 19:55:58 -0700 Subject: [PATCH 2963/4072] Use newer versions of pre-commit hooks Signed-off-by: Joffrey F --- .pre-commit-config.yaml | 4 ++-- bin/docker-compose | 3 +++ requirements.txt | 4 ++-- script/build/test-image | 2 +- script/setup/osx | 1 - tests/fixtures/default-env-file/.env | 2 +- tests/fixtures/env-file/test.env | 2 +- 7 files changed, 10 insertions(+), 8 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0e7b9d5f3bb..b7bcc8466c5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,5 +1,5 @@ - repo: git://github.com/pre-commit/pre-commit-hooks - sha: 'v0.4.2' + sha: 'v0.9.1' hooks: - id: check-added-large-files - id: check-docstring-first @@ -14,7 +14,7 @@ - id: requirements-txt-fixer - id: trailing-whitespace - repo: git://github.com/asottile/reorder_python_imports - sha: v0.1.0 + sha: v0.3.5 hooks: - id: reorder-python-imports language_version: 'python2.7' diff --git a/bin/docker-compose b/bin/docker-compose index 5976e1d4aa5..aeb53870303 100755 --- a/bin/docker-compose +++ b/bin/docker-compose @@ -1,3 +1,6 @@ #!/usr/bin/env python +from __future__ import absolute_import +from __future__ import unicode_literals + from compose.cli.main import main main() diff --git a/requirements.txt b/requirements.txt index 81dcdf08c7f..826c31eb127 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,3 @@ -PySocks==1.6.7 -PyYAML==3.12 backports.ssl-match-hostname==3.5.0.1; python_version < '3' cached-property==1.3.0 certifi==2017.4.17 @@ -15,6 +13,8 @@ idna==2.5 ipaddress==1.0.18 jsonschema==2.6.0 pypiwin32==219; sys_platform == 'win32' +PySocks==1.6.7 +PyYAML==3.12 requests==2.11.1 six==1.10.0 texttable==0.8.8 diff --git a/script/build/test-image b/script/build/test-image index 216d63f9c32..a2eb62cdf78 100755 --- a/script/build/test-image +++ b/script/build/test-image @@ -14,4 +14,4 @@ ctnr_id=$(docker create --entrypoint=tox docker-compose-tests:tmp) docker commit $ctnr_id docker/compose-tests:latest docker tag docker/compose-tests:latest docker/compose-tests:$TAG docker rm -f $ctnr_id -docker rmi -f docker-compose-tests:tmp \ No newline at end of file +docker rmi -f docker-compose-tests:tmp diff --git a/script/setup/osx b/script/setup/osx index e6ab62a8473..e0c2bd0a24e 100755 --- a/script/setup/osx +++ b/script/setup/osx @@ -50,4 +50,3 @@ echo "*** Using $(openssl_version)" if !(which virtualenv); then pip install virtualenv fi - diff --git a/tests/fixtures/default-env-file/.env b/tests/fixtures/default-env-file/.env index 996c886cb28..9056de724cd 100644 --- a/tests/fixtures/default-env-file/.env +++ b/tests/fixtures/default-env-file/.env @@ -1,4 +1,4 @@ IMAGE=alpine:latest COMMAND=true PORT1=5643 -PORT2=9999 \ No newline at end of file +PORT2=9999 diff --git a/tests/fixtures/env-file/test.env b/tests/fixtures/env-file/test.env index c9604dad5b3..d99cd41a4b7 100644 --- a/tests/fixtures/env-file/test.env +++ b/tests/fixtures/env-file/test.env @@ -1 +1 @@ -FOO=1 \ No newline at end of file +FOO=1 From 467e0d0d31fadb1726e376cc67530e7f750c8633 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 2 Aug 2017 19:23:39 -0700 Subject: [PATCH 2964/4072] Fix ServiceExtendsResolver same-file detection Signed-off-by: Joffrey F --- compose/config/config.py | 2 +- tests/unit/config/config_test.py | 36 ++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/compose/config/config.py b/compose/config/config.py index 659b6cd5928..cb25a25a6b1 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -571,7 +571,7 @@ def validate_and_construct_extends(self): config_path = self.get_extended_config_path(extends) service_name = extends['service'] - if config_path == self.service_config.filename: + if config_path == self.config_file.filename: try: service_config = self.config_file.get_service(service_name) except KeyError: diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 63cb7eaefb6..fd06db7d139 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -702,6 +702,42 @@ def test_load_with_multiple_files_and_extends_in_override_file(self): ] self.assertEqual(service_sort(service_dicts), service_sort(expected)) + def test_load_mixed_extends_resolution(self): + main_file = config.ConfigFile( + 'main.yml', { + 'version': '2.2', + 'services': { + 'prodweb': { + 'extends': { + 'service': 'web', + 'file': 'base.yml' + }, + 'environment': {'PROD': 'true'}, + }, + }, + } + ) + + tmpdir = pytest.ensuretemp('config_test') + self.addCleanup(tmpdir.remove) + tmpdir.join('base.yml').write(""" + version: '2.2' + services: + base: + image: base + web: + extends: base + """) + + details = config.ConfigDetails('.', [main_file]) + with tmpdir.as_cwd(): + service_dicts = config.load(details).services + assert service_dicts[0] == { + 'name': 'prodweb', + 'image': 'base', + 'environment': {'PROD': 'true'}, + } + def test_load_with_multiple_files_and_invalid_override(self): base_file = config.ConfigFile( 'base.yaml', From 7feb2685d21b6149e4109a55c2762ebf1d69e8d9 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 2 Aug 2017 17:02:30 -0700 Subject: [PATCH 2965/4072] Bump texttable 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 826c31eb127..d1778990f41 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,6 +17,6 @@ PySocks==1.6.7 PyYAML==3.12 requests==2.11.1 six==1.10.0 -texttable==0.8.8 +texttable==0.9.1 urllib3==1.21.1 websocket-client==0.32.0 diff --git a/setup.py b/setup.py index a3072c76ea0..16493f52b96 100644 --- a/setup.py +++ b/setup.py @@ -34,7 +34,7 @@ def find_version(*file_paths): 'docopt >= 0.6.1, < 0.7', 'PyYAML >= 3.10, < 4', 'requests >= 2.6.1, != 2.11.0, < 2.12', - 'texttable >= 0.8.1, < 0.9', + 'texttable >= 0.9.0, < 0.10', 'websocket-client >= 0.32.0, < 1.0', 'docker >= 2.4.2, < 3.0', 'dockerpty >= 0.4.1, < 0.5', From 444d88872059ab87b41e5416682b7793e8845c8e Mon Sep 17 00:00:00 2001 From: Cecile Tonglet Date: Fri, 23 Jun 2017 15:17:38 +0200 Subject: [PATCH 2966/4072] Add a flag --no-ansi to remove control characters on parallel executions Signed-off-by: Cecile Tonglet --- compose/cli/command.py | 5 +++-- compose/cli/main.py | 1 + compose/parallel.py | 45 ++++++++++++++++++++++++------------- compose/project.py | 32 ++++++++++++++++---------- compose/service.py | 6 +++++ tests/unit/parallel_test.py | 16 +++++++++++++ 6 files changed, 75 insertions(+), 30 deletions(-) diff --git a/compose/cli/command.py b/compose/cli/command.py index e1ae690c0eb..f5330d1c202 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -31,6 +31,7 @@ def project_from_options(project_dir, options): get_config_path_from_options(project_dir, options, environment), project_name=options.get('--project-name'), verbose=options.get('--verbose'), + noansi=options.get('--no-ansi'), host=host, tls_config=tls_config_from_options(options), environment=environment, @@ -81,7 +82,7 @@ def get_client(environment, verbose=False, version=None, tls_config=None, host=N def get_project(project_dir, config_path=None, project_name=None, verbose=False, - host=None, tls_config=None, environment=None, override_dir=None): + noansi=False, host=None, tls_config=None, environment=None, override_dir=None): if not environment: environment = Environment.from_env_file(project_dir) config_details = config.find(project_dir, config_path, environment, override_dir) @@ -100,7 +101,7 @@ def get_project(project_dir, config_path=None, project_name=None, verbose=False, ) with errors.handle_connection_errors(client): - return Project.from_config(project_name, config_data, client) + return Project.from_config(project_name, config_data, client, noansi=noansi) def get_project_name(working_dir, project_name=None, environment=None): diff --git a/compose/cli/main.py b/compose/cli/main.py index 20f3b55b465..c0cf8747fa0 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -159,6 +159,7 @@ class TopLevelCommand(object): -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 + --no-ansi Do not print ANSI control characters -v, --version Print version and exit -H, --host HOST Daemon socket to connect to diff --git a/compose/parallel.py b/compose/parallel.py index a611fd6e0b0..89d074e35de 100644 --- a/compose/parallel.py +++ b/compose/parallel.py @@ -26,7 +26,7 @@ STOP = object() -def parallel_execute(objects, func, get_name, msg, get_deps=None, limit=None): +def parallel_execute(objects, func, get_name, msg, get_deps=None, limit=None, noansi=False): """Runs func on objects in parallel while ensuring that func is ran on object only after it is ran on all its dependencies. @@ -36,7 +36,7 @@ def parallel_execute(objects, func, get_name, msg, get_deps=None, limit=None): objects = list(objects) stream = get_output_stream(sys.stderr) - writer = ParallelStreamWriter(stream, msg) + writer = ParallelStreamWriter(stream, msg, noansi) for obj in objects: writer.add_object(get_name(obj)) writer.write_initial() @@ -221,11 +221,12 @@ class ParallelStreamWriter(object): to jump to the correct line, and write over the line. """ - def __init__(self, stream, msg): + def __init__(self, stream, msg, noansi): self.stream = stream self.msg = msg self.lines = [] self.width = 0 + self.noansi = noansi def add_object(self, obj_index): self.lines.append(obj_index) @@ -239,9 +240,7 @@ def write_initial(self): width=self.width)) self.stream.flush() - def write(self, obj_index, status): - if self.msg is None: - return + def _write_ansi(self, obj_index, status): position = self.lines.index(obj_index) diff = len(self.lines) - position # move up @@ -254,27 +253,41 @@ def write(self, obj_index, status): self.stream.write("%c[%dB" % (27, diff)) self.stream.flush() + def _write_noansi(self, obj_index, status): + self.stream.write("{} {:<{width}} ... {}\r\n".format(self.msg, obj_index, + status, width=self.width)) + self.stream.flush() + + def write(self, obj_index, status): + if self.msg is None: + return + if self.noansi: + self._write_noansi(obj_index, status) + else: + self._write_ansi(obj_index, status) + -def parallel_operation(containers, operation, options, message): +def parallel_operation(containers, operation, options, message, noansi=False): parallel_execute( containers, operator.methodcaller(operation, **options), operator.attrgetter('name'), - message) + message, + noansi=noansi) -def parallel_remove(containers, options): +def parallel_remove(containers, options, noansi=False): stopped_containers = [c for c in containers if not c.is_running] - parallel_operation(stopped_containers, 'remove', options, 'Removing') + parallel_operation(stopped_containers, 'remove', options, 'Removing', noansi=noansi) -def parallel_pause(containers, options): - parallel_operation(containers, 'pause', options, 'Pausing') +def parallel_pause(containers, options, noansi=False): + parallel_operation(containers, 'pause', options, 'Pausing', noansi=noansi) -def parallel_unpause(containers, options): - parallel_operation(containers, 'unpause', options, 'Unpausing') +def parallel_unpause(containers, options, noansi=False): + parallel_operation(containers, 'unpause', options, 'Unpausing', noansi=noansi) -def parallel_kill(containers, options): - parallel_operation(containers, 'kill', options, 'Killing') +def parallel_kill(containers, options, noansi=False): + parallel_operation(containers, 'kill', options, 'Killing', noansi=noansi) diff --git a/compose/project.py b/compose/project.py index 28af45c7135..9ea6ff6bb64 100644 --- a/compose/project.py +++ b/compose/project.py @@ -60,13 +60,15 @@ class Project(object): """ A collection of services. """ - def __init__(self, name, services, client, networks=None, volumes=None, config_version=None): + def __init__(self, name, services, client, networks=None, volumes=None, config_version=None, + noansi=False): self.name = name self.services = services self.client = client self.volumes = volumes or ProjectVolumes({}) self.networks = networks or ProjectNetworks({}, False) self.config_version = config_version + self.noansi = noansi def labels(self, one_off=OneOffFilter.exclude): labels = ['{0}={1}'.format(LABEL_PROJECT, self.name)] @@ -75,7 +77,7 @@ def labels(self, one_off=OneOffFilter.exclude): return labels @classmethod - def from_config(cls, name, config_data, client): + def from_config(cls, name, config_data, client, noansi=False): """ Construct a Project from a config.Config object. """ @@ -86,7 +88,7 @@ def from_config(cls, name, config_data, client): networks, use_networking) volumes = ProjectVolumes.from_config(name, config_data, client) - project = cls(name, [], client, project_networks, volumes, config_data.version) + project = cls(name, [], client, project_networks, volumes, config_data.version, noansi=noansi) for service_dict in config_data.services: service_dict = dict(service_dict) @@ -126,6 +128,7 @@ def from_config(cls, name, config_data, client): volumes_from=volumes_from, secrets=secrets, pid_mode=pid_mode, + noansi=noansi, **service_dict) ) @@ -270,7 +273,8 @@ def get_deps(service): start_service, operator.attrgetter('name'), 'Starting', - get_deps) + get_deps, + noansi=self.noansi) return containers @@ -288,25 +292,26 @@ def get_deps(container): self.build_container_operation_with_timeout_func('stop', options), operator.attrgetter('name'), 'Stopping', - get_deps) + get_deps, + noansi=self.noansi) def pause(self, service_names=None, **options): containers = self.containers(service_names) - parallel.parallel_pause(reversed(containers), options) + parallel.parallel_pause(reversed(containers), options, noansi=self.noansi) return containers def unpause(self, service_names=None, **options): containers = self.containers(service_names) - parallel.parallel_unpause(containers, options) + parallel.parallel_unpause(containers, options, noansi=self.noansi) return containers def kill(self, service_names=None, **options): - parallel.parallel_kill(self.containers(service_names), options) + parallel.parallel_kill(self.containers(service_names), options, noansi=self.noansi) 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) + ), options, noansi=self.noansi) def down(self, remove_image_type, include_volumes, remove_orphans=False): self.stop(one_off=OneOffFilter.include) @@ -331,7 +336,8 @@ def restart(self, service_names=None, **options): containers, self.build_container_operation_with_timeout_func('restart', options), operator.attrgetter('name'), - 'Restarting') + 'Restarting', + noansi=self.noansi) return containers def build(self, service_names=None, no_cache=False, pull=False, force_rm=False, build_args=None): @@ -447,7 +453,8 @@ def get_deps(service): do, operator.attrgetter('name'), None, - get_deps + get_deps, + noansi=self.noansi, ) if errors: raise ProjectError( @@ -500,7 +507,8 @@ def pull_service(service): pull_service, operator.attrgetter('name'), 'Pulling', - limit=5) + limit=5, + noansi=self.noansi) else: for service in services: service.pull(ignore_pull_failures, silent=silent) diff --git a/compose/service.py b/compose/service.py index c43f635b2bd..22aae08b7a5 100644 --- a/compose/service.py +++ b/compose/service.py @@ -158,6 +158,7 @@ def __init__( secrets=None, scale=None, pid_mode=None, + noansi=False, **options ): self.name = name @@ -171,6 +172,7 @@ def __init__( self.networks = networks or {} self.secrets = secrets or [] self.scale_num = scale or 1 + self.noansi = noansi self.options = options def __repr__(self): @@ -393,6 +395,7 @@ def create_and_start(service, n): lambda n: create_and_start(self, n), lambda n: self.get_container_name(n), "Creating", + noansi=self.noansi, ) for error in errors.values(): raise OperationFailedError(error) @@ -414,6 +417,7 @@ def recreate(container): recreate, lambda c: c.name, "Recreating", + noansi=self.noansi, ) for error in errors.values(): raise OperationFailedError(error) @@ -434,6 +438,7 @@ def _execute_convergence_start(self, containers, scale, timeout, detached, start lambda c: self.start_container_if_stopped(c, attach_logs=not detached), lambda c: c.name, "Starting", + noansi=self.noansi, ) for error in errors.values(): @@ -455,6 +460,7 @@ def stop_and_remove(container): stop_and_remove, lambda c: c.name, "Stopping and removing", + noansi=self.noansi, ) def execute_convergence_plan(self, plan, timeout=None, detached=False, diff --git a/tests/unit/parallel_test.py b/tests/unit/parallel_test.py index 73728fdfd87..519c66669be 100644 --- a/tests/unit/parallel_test.py +++ b/tests/unit/parallel_test.py @@ -130,3 +130,19 @@ def test_parallel_execute_alignment(capsys): _, err = capsys.readouterr() a, b = err.split('\n')[:2] assert a.index('...') == b.index('...') + + +def test_parallel_execute_alignment_noansi(capsys): + results, errors = parallel_execute( + objects=["short", "a very long name"], + func=lambda x: x, + get_name=six.text_type, + msg="Aligning", + noansi=True, + ) + + assert errors == {} + + _, err = capsys.readouterr() + a, b, c, d = err.split('\n')[:4] + assert a.index('...') == b.index('...') == c.index('...') == d.index('...') From 7882f1fb06da723d957f9038c8a4f64e0cc451a0 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 9 Aug 2017 16:46:47 -0700 Subject: [PATCH 2967/4072] Keep no-ansi parameter in the CLI scope Signed-off-by: Joffrey F --- compose/cli/command.py | 5 ++--- compose/cli/main.py | 7 +++++++ compose/parallel.py | 33 +++++++++++++++++++-------------- compose/project.py | 26 +++++++++++--------------- compose/service.py | 6 ------ tests/acceptance/cli_test.py | 8 ++++++++ tests/unit/parallel_test.py | 5 +++-- 7 files changed, 50 insertions(+), 40 deletions(-) diff --git a/compose/cli/command.py b/compose/cli/command.py index f5330d1c202..e1ae690c0eb 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -31,7 +31,6 @@ def project_from_options(project_dir, options): get_config_path_from_options(project_dir, options, environment), project_name=options.get('--project-name'), verbose=options.get('--verbose'), - noansi=options.get('--no-ansi'), host=host, tls_config=tls_config_from_options(options), environment=environment, @@ -82,7 +81,7 @@ def get_client(environment, verbose=False, version=None, tls_config=None, host=N def get_project(project_dir, config_path=None, project_name=None, verbose=False, - noansi=False, host=None, tls_config=None, environment=None, override_dir=None): + host=None, tls_config=None, environment=None, override_dir=None): if not environment: environment = Environment.from_env_file(project_dir) config_details = config.find(project_dir, config_path, environment, override_dir) @@ -101,7 +100,7 @@ def get_project(project_dir, config_path=None, project_name=None, verbose=False, ) with errors.handle_connection_errors(client): - return Project.from_config(project_name, config_data, client, noansi=noansi) + return Project.from_config(project_name, config_data, client) def get_project_name(working_dir, project_name=None, environment=None): diff --git a/compose/cli/main.py b/compose/cli/main.py index c0cf8747fa0..2bb53f95e66 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -98,6 +98,7 @@ def dispatch(): options, handler, command_options = dispatcher.parse(sys.argv[1:]) setup_console_handler(console_handler, options.get('--verbose')) + setup_parallel_logger(options.get('--no-ansi')) return functools.partial(perform_command, options, handler, command_options) @@ -127,6 +128,12 @@ def setup_logging(): logging.getLogger("requests").propagate = False +def setup_parallel_logger(noansi): + if noansi: + import compose.parallel + compose.parallel.ParallelStreamWriter.set_noansi() + + def setup_console_handler(handler, verbose): if handler.stream.isatty(): format_class = ConsoleWarningFormatter diff --git a/compose/parallel.py b/compose/parallel.py index 89d074e35de..1cf1fb094e0 100644 --- a/compose/parallel.py +++ b/compose/parallel.py @@ -26,7 +26,7 @@ STOP = object() -def parallel_execute(objects, func, get_name, msg, get_deps=None, limit=None, noansi=False): +def parallel_execute(objects, func, get_name, msg, get_deps=None, limit=None): """Runs func on objects in parallel while ensuring that func is ran on object only after it is ran on all its dependencies. @@ -36,7 +36,7 @@ def parallel_execute(objects, func, get_name, msg, get_deps=None, limit=None, no objects = list(objects) stream = get_output_stream(sys.stderr) - writer = ParallelStreamWriter(stream, msg, noansi) + writer = ParallelStreamWriter(stream, msg) for obj in objects: writer.add_object(get_name(obj)) writer.write_initial() @@ -221,12 +221,17 @@ class ParallelStreamWriter(object): to jump to the correct line, and write over the line. """ - def __init__(self, stream, msg, noansi): + noansi = False + + @classmethod + def set_noansi(cls, value=True): + cls.noansi = value + + def __init__(self, stream, msg): self.stream = stream self.msg = msg self.lines = [] self.width = 0 - self.noansi = noansi def add_object(self, obj_index): self.lines.append(obj_index) @@ -267,27 +272,27 @@ def write(self, obj_index, status): self._write_ansi(obj_index, status) -def parallel_operation(containers, operation, options, message, noansi=False): +def parallel_operation(containers, operation, options, message): parallel_execute( containers, operator.methodcaller(operation, **options), operator.attrgetter('name'), message, - noansi=noansi) + ) -def parallel_remove(containers, options, noansi=False): +def parallel_remove(containers, options): stopped_containers = [c for c in containers if not c.is_running] - parallel_operation(stopped_containers, 'remove', options, 'Removing', noansi=noansi) + parallel_operation(stopped_containers, 'remove', options, 'Removing') -def parallel_pause(containers, options, noansi=False): - parallel_operation(containers, 'pause', options, 'Pausing', noansi=noansi) +def parallel_pause(containers, options): + parallel_operation(containers, 'pause', options, 'Pausing') -def parallel_unpause(containers, options, noansi=False): - parallel_operation(containers, 'unpause', options, 'Unpausing', noansi=noansi) +def parallel_unpause(containers, options): + parallel_operation(containers, 'unpause', options, 'Unpausing') -def parallel_kill(containers, options, noansi=False): - parallel_operation(containers, 'kill', options, 'Killing', noansi=noansi) +def parallel_kill(containers, options): + parallel_operation(containers, 'kill', options, 'Killing') diff --git a/compose/project.py b/compose/project.py index 9ea6ff6bb64..86fbda6eefa 100644 --- a/compose/project.py +++ b/compose/project.py @@ -60,15 +60,13 @@ class Project(object): """ A collection of services. """ - def __init__(self, name, services, client, networks=None, volumes=None, config_version=None, - noansi=False): + def __init__(self, name, services, client, networks=None, volumes=None, config_version=None): self.name = name self.services = services self.client = client self.volumes = volumes or ProjectVolumes({}) self.networks = networks or ProjectNetworks({}, False) self.config_version = config_version - self.noansi = noansi def labels(self, one_off=OneOffFilter.exclude): labels = ['{0}={1}'.format(LABEL_PROJECT, self.name)] @@ -77,7 +75,7 @@ def labels(self, one_off=OneOffFilter.exclude): return labels @classmethod - def from_config(cls, name, config_data, client, noansi=False): + def from_config(cls, name, config_data, client): """ Construct a Project from a config.Config object. """ @@ -88,7 +86,7 @@ def from_config(cls, name, config_data, client, noansi=False): networks, use_networking) volumes = ProjectVolumes.from_config(name, config_data, client) - project = cls(name, [], client, project_networks, volumes, config_data.version, noansi=noansi) + project = cls(name, [], client, project_networks, volumes, config_data.version) for service_dict in config_data.services: service_dict = dict(service_dict) @@ -128,7 +126,6 @@ def from_config(cls, name, config_data, client, noansi=False): volumes_from=volumes_from, secrets=secrets, pid_mode=pid_mode, - noansi=noansi, **service_dict) ) @@ -274,7 +271,7 @@ def get_deps(service): operator.attrgetter('name'), 'Starting', get_deps, - noansi=self.noansi) + ) return containers @@ -293,25 +290,25 @@ def get_deps(container): operator.attrgetter('name'), 'Stopping', get_deps, - noansi=self.noansi) + ) def pause(self, service_names=None, **options): containers = self.containers(service_names) - parallel.parallel_pause(reversed(containers), options, noansi=self.noansi) + parallel.parallel_pause(reversed(containers), options) return containers def unpause(self, service_names=None, **options): containers = self.containers(service_names) - parallel.parallel_unpause(containers, options, noansi=self.noansi) + parallel.parallel_unpause(containers, options) return containers def kill(self, service_names=None, **options): - parallel.parallel_kill(self.containers(service_names), options, noansi=self.noansi) + parallel.parallel_kill(self.containers(service_names), 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, noansi=self.noansi) + ), options) def down(self, remove_image_type, include_volumes, remove_orphans=False): self.stop(one_off=OneOffFilter.include) @@ -337,7 +334,7 @@ def restart(self, service_names=None, **options): self.build_container_operation_with_timeout_func('restart', options), operator.attrgetter('name'), 'Restarting', - noansi=self.noansi) + ) return containers def build(self, service_names=None, no_cache=False, pull=False, force_rm=False, build_args=None): @@ -454,7 +451,6 @@ def get_deps(service): operator.attrgetter('name'), None, get_deps, - noansi=self.noansi, ) if errors: raise ProjectError( @@ -508,7 +504,7 @@ def pull_service(service): operator.attrgetter('name'), 'Pulling', limit=5, - noansi=self.noansi) + ) else: for service in services: service.pull(ignore_pull_failures, silent=silent) diff --git a/compose/service.py b/compose/service.py index 22aae08b7a5..c43f635b2bd 100644 --- a/compose/service.py +++ b/compose/service.py @@ -158,7 +158,6 @@ def __init__( secrets=None, scale=None, pid_mode=None, - noansi=False, **options ): self.name = name @@ -172,7 +171,6 @@ def __init__( self.networks = networks or {} self.secrets = secrets or [] self.scale_num = scale or 1 - self.noansi = noansi self.options = options def __repr__(self): @@ -395,7 +393,6 @@ def create_and_start(service, n): lambda n: create_and_start(self, n), lambda n: self.get_container_name(n), "Creating", - noansi=self.noansi, ) for error in errors.values(): raise OperationFailedError(error) @@ -417,7 +414,6 @@ def recreate(container): recreate, lambda c: c.name, "Recreating", - noansi=self.noansi, ) for error in errors.values(): raise OperationFailedError(error) @@ -438,7 +434,6 @@ def _execute_convergence_start(self, containers, scale, timeout, detached, start lambda c: self.start_container_if_stopped(c, attach_logs=not detached), lambda c: c.name, "Starting", - noansi=self.noansi, ) for error in errors.values(): @@ -460,7 +455,6 @@ def stop_and_remove(container): stop_and_remove, lambda c: c.name, "Stopping and removing", - noansi=self.noansi, ) def execute_convergence_plan(self, plan, timeout=None, detached=False, diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index f7ecba9f52a..adf645c2f39 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -751,6 +751,14 @@ def test_up(self): for service in services: assert self.lookup(container, service.name) + @v2_only() + def test_up_no_ansi(self): + self.base_dir = 'tests/fixtures/v2-simple' + result = self.dispatch(['--no-ansi', 'up', '-d'], None) + assert "%c[2K\r" % 27 not in result.stderr + assert "%c[1A" % 27 not in result.stderr + assert "%c[1B" % 27 not in result.stderr + @v2_only() def test_up_with_default_network_config(self): filename = 'default-network-config.yml' diff --git a/tests/unit/parallel_test.py b/tests/unit/parallel_test.py index 519c66669be..f82858eab0c 100644 --- a/tests/unit/parallel_test.py +++ b/tests/unit/parallel_test.py @@ -8,6 +8,7 @@ from compose.parallel import parallel_execute from compose.parallel import parallel_execute_iter +from compose.parallel import ParallelStreamWriter from compose.parallel import UpstreamError @@ -62,7 +63,7 @@ def f(obj): limit=limit, ) - assert results == tasks*[None] + assert results == tasks * [None] assert errors == {} @@ -133,12 +134,12 @@ def test_parallel_execute_alignment(capsys): def test_parallel_execute_alignment_noansi(capsys): + ParallelStreamWriter.set_noansi() results, errors = parallel_execute( objects=["short", "a very long name"], func=lambda x: x, get_name=six.text_type, msg="Aligning", - noansi=True, ) assert errors == {} From 6361d907f6510971ab196e73c7b721422ef7b05f Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 9 Aug 2017 18:59:17 -0700 Subject: [PATCH 2968/4072] Add support for blkio config keys Signed-off-by: Joffrey F --- compose/config/config.py | 50 ++++++++++++++++++++++++-- compose/config/config_schema_v2.0.json | 44 +++++++++++++++++++++++ compose/config/config_schema_v2.1.json | 45 +++++++++++++++++++++++ compose/config/config_schema_v2.2.json | 45 +++++++++++++++++++++++ compose/config/config_schema_v2.3.json | 45 +++++++++++++++++++++++ compose/service.py | 26 ++++++++++++++ compose/utils.py | 10 ++++++ tests/integration/service_test.py | 28 +++++++++++++++ tests/unit/config/config_test.py | 47 ++++++++++++++++++++++++ 9 files changed, 338 insertions(+), 2 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index cb25a25a6b1..fb376b32536 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -16,6 +16,7 @@ from .. import const from ..const import COMPOSEFILE_V1 as V1 from ..utils import build_string_dict +from ..utils import parse_bytes from ..utils import parse_nanoseconds_int from ..utils import splitdrive from ..version import ComposeVersion @@ -108,6 +109,7 @@ ] ALLOWED_KEYS = DOCKER_CONFIG_KEYS + [ + 'blkio_config', 'build', 'container_name', 'credential_spec', @@ -726,8 +728,9 @@ def process_service(service_config): if field in service_dict: service_dict[field] = to_list(service_dict[field]) - service_dict = process_healthcheck(service_dict, service_config.name) - service_dict = process_ports(service_dict) + service_dict = process_blkio_config(process_ports( + process_healthcheck(service_dict, service_config.name) + )) return service_dict @@ -754,6 +757,28 @@ def process_depends_on(service_dict): return service_dict +def process_blkio_config(service_dict): + if not service_dict.get('blkio_config'): + return service_dict + + for field in ['device_read_bps', 'device_write_bps']: + if field in service_dict['blkio_config']: + for v in service_dict['blkio_config'].get(field, []): + v['rate'] = parse_bytes(v.get('rate', 0)) + + for field in ['device_read_iops', 'device_write_iops']: + if field in service_dict['blkio_config']: + for v in service_dict['blkio_config'].get(field, []): + try: + v['rate'] = int(v.get('rate', 0)) + except ValueError: + raise ConfigurationError( + 'Invalid IOPS value: "{}". Must be a positive integer.'.format(v.get('rate')) + ) + + return service_dict + + def process_healthcheck(service_dict, service_name): if 'healthcheck' not in service_dict: return service_dict @@ -940,6 +965,7 @@ def merge_service_dicts(base, override, version): md.merge_field('logging', merge_logging, default={}) merge_ports(md, base, override) + md.merge_field('blkio_config', merge_blkio_config, default={}) for field in set(ALLOWED_KEYS) - set(md): md.merge_scalar(field) @@ -993,6 +1019,26 @@ def to_dict(service): return dict(md) +def merge_blkio_config(base, override): + md = MergeDict(base, override) + md.merge_scalar('weight') + + def merge_blkio_limits(base, override): + index = dict((b['path'], b) for b in base) + for o in override: + index[o['path']] = o + + return sorted(list(index.values()), key=lambda x: x['path']) + + for field in [ + "device_read_bps", "device_read_iops", "device_write_bps", + "device_write_iops", "weight_device", + ]: + md.merge_field(field, merge_blkio_limits, default=[]) + + return dict(md) + + def merge_logging(base, override): md = MergeDict(base, override) md.merge_scalar('driver') diff --git a/compose/config/config_schema_v2.0.json b/compose/config/config_schema_v2.0.json index f3688685b39..14bafab4033 100644 --- a/compose/config/config_schema_v2.0.json +++ b/compose/config/config_schema_v2.0.json @@ -50,6 +50,33 @@ "type": "object", "properties": { + "blkio_config": { + "type": "object", + "properties": { + "device_read_bps": { + "type": "array", + "items": {"$ref": "#/definitions/blkio_limit"} + }, + "device_read_iops": { + "type": "array", + "items": {"$ref": "#/definitions/blkio_limit"} + }, + "device_write_bps": { + "type": "array", + "items": {"$ref": "#/definitions/blkio_limit"} + }, + "device_write_iops": { + "type": "array", + "items": {"$ref": "#/definitions/blkio_limit"} + }, + "weight": {"type": "integer"}, + "weight_device": { + "type": "array", + "items": {"$ref": "#/definitions/blkio_weight"} + } + }, + "additionalProperties": false + }, "build": { "oneOf": [ {"type": "string"}, @@ -326,6 +353,23 @@ ] }, + "blkio_limit": { + "type": "object", + "properties": { + "path": {"type": "string"}, + "rate": {"type": ["integer", "string"]} + }, + "additionalProperties": false + }, + "blkio_weight": { + "type": "object", + "properties": { + "path": {"type": "string"}, + "weight": {"type": "integer"} + }, + "additionalProperties": false + }, + "constraints": { "service": { "id": "#/definitions/constraints/service", diff --git a/compose/config/config_schema_v2.1.json b/compose/config/config_schema_v2.1.json index 5aed9f7b189..9d45c324c57 100644 --- a/compose/config/config_schema_v2.1.json +++ b/compose/config/config_schema_v2.1.json @@ -50,6 +50,34 @@ "type": "object", "properties": { + "blkio_config": { + "type": "object", + "properties": { + "device_read_bps": { + "type": "array", + "items": {"$ref": "#/definitions/blkio_limit"} + }, + "device_read_iops": { + "type": "array", + "items": {"$ref": "#/definitions/blkio_limit"} + }, + "device_write_bps": { + "type": "array", + "items": {"$ref": "#/definitions/blkio_limit"} + }, + "device_write_iops": { + "type": "array", + "items": {"$ref": "#/definitions/blkio_limit"} + }, + "weight": {"type": "integer"}, + "weight_device": { + "type": "array", + "items": {"$ref": "#/definitions/blkio_weight"} + } + }, + "additionalProperties": false + }, + "build": { "oneOf": [ {"type": "string"}, @@ -376,6 +404,23 @@ ] }, + "blkio_limit": { + "type": "object", + "properties": { + "path": {"type": "string"}, + "rate": {"type": ["integer", "string"]} + }, + "additionalProperties": false + }, + "blkio_weight": { + "type": "object", + "properties": { + "path": {"type": "string"}, + "weight": {"type": "integer"} + }, + "additionalProperties": false + }, + "constraints": { "service": { "id": "#/definitions/constraints/service", diff --git a/compose/config/config_schema_v2.2.json b/compose/config/config_schema_v2.2.json index 9181e606b26..9544170181f 100644 --- a/compose/config/config_schema_v2.2.json +++ b/compose/config/config_schema_v2.2.json @@ -50,6 +50,34 @@ "type": "object", "properties": { + "blkio_config": { + "type": "object", + "properties": { + "device_read_bps": { + "type": "array", + "items": {"$ref": "#/definitions/blkio_limit"} + }, + "device_read_iops": { + "type": "array", + "items": {"$ref": "#/definitions/blkio_limit"} + }, + "device_write_bps": { + "type": "array", + "items": {"$ref": "#/definitions/blkio_limit"} + }, + "device_write_iops": { + "type": "array", + "items": {"$ref": "#/definitions/blkio_limit"} + }, + "weight": {"type": "integer"}, + "weight_device": { + "type": "array", + "items": {"$ref": "#/definitions/blkio_weight"} + } + }, + "additionalProperties": false + }, + "build": { "oneOf": [ {"type": "string"}, @@ -383,6 +411,23 @@ ] }, + "blkio_limit": { + "type": "object", + "properties": { + "path": {"type": "string"}, + "rate": {"type": ["integer", "string"]} + }, + "additionalProperties": false + }, + "blkio_weight": { + "type": "object", + "properties": { + "path": {"type": "string"}, + "weight": {"type": "integer"} + }, + "additionalProperties": false + }, + "constraints": { "service": { "id": "#/definitions/constraints/service", diff --git a/compose/config/config_schema_v2.3.json b/compose/config/config_schema_v2.3.json index 87734027640..10a61186e3d 100644 --- a/compose/config/config_schema_v2.3.json +++ b/compose/config/config_schema_v2.3.json @@ -50,6 +50,34 @@ "type": "object", "properties": { + "blkio_config": { + "type": "object", + "properties": { + "device_read_bps": { + "type": "array", + "items": {"$ref": "#/definitions/blkio_limit"} + }, + "device_read_iops": { + "type": "array", + "items": {"$ref": "#/definitions/blkio_limit"} + }, + "device_write_bps": { + "type": "array", + "items": {"$ref": "#/definitions/blkio_limit"} + }, + "device_write_iops": { + "type": "array", + "items": {"$ref": "#/definitions/blkio_limit"} + }, + "weight": {"type": "integer"}, + "weight_device": { + "type": "array", + "items": {"$ref": "#/definitions/blkio_weight"} + } + }, + "additionalProperties": false + }, + "build": { "oneOf": [ {"type": "string"}, @@ -384,6 +412,23 @@ ] }, + "blkio_limit": { + "type": "object", + "properties": { + "path": {"type": "string"}, + "rate": {"type": ["integer", "string"]} + }, + "additionalProperties": false + }, + "blkio_weight": { + "type": "object", + "properties": { + "path": {"type": "string"}, + "weight": {"type": "integer"} + }, + "additionalProperties": false + }, + "constraints": { "service": { "id": "#/definitions/constraints/service", diff --git a/compose/service.py b/compose/service.py index c43f635b2bd..2829240f256 100644 --- a/compose/service.py +++ b/compose/service.py @@ -813,6 +813,7 @@ def _get_container_host_config(self, override_options, one_off=False): options = dict(self.options, **override_options) logging_dict = options.get('logging', None) + blkio_config = convert_blkio_config(options.get('blkio_config', None)) log_config = get_log_config(logging_dict) init_path = None if isinstance(options.get('init'), six.string_types): @@ -869,6 +870,12 @@ def _get_container_host_config(self, override_options, one_off=False): cpuset_cpus=options.get('cpuset'), cpu_shares=options.get('cpu_shares'), storage_opt=options.get('storage_opt'), + blkio_weight=blkio_config.get('weight'), + blkio_weight_device=blkio_config.get('weight_device'), + device_read_bps=blkio_config.get('device_read_bps'), + device_read_iops=blkio_config.get('device_read_iops'), + device_write_bps=blkio_config.get('device_write_bps'), + device_write_iops=blkio_config.get('device_write_iops'), ) def get_secret_volumes(self): @@ -1395,3 +1402,22 @@ def build_container_ports(container_ports, options): port = tuple(port.split('/')) ports.append(port) return ports + + +def convert_blkio_config(blkio_config): + result = {} + if blkio_config is None: + return result + + result['weight'] = blkio_config.get('weight') + for field in [ + "device_read_bps", "device_read_iops", "device_write_bps", + "device_write_iops", "weight_device", + ]: + if field not in blkio_config: + continue + arr = [] + for item in blkio_config[field]: + arr.append(dict([(k.capitalize(), v) for k, v in item.items()])) + result[field] = arr + return result diff --git a/compose/utils.py b/compose/utils.py index b8bdf732f91..183a4504d46 100644 --- a/compose/utils.py +++ b/compose/utils.py @@ -9,7 +9,10 @@ import ntpath import six +from docker.errors import DockerException +from docker.utils import parse_bytes as sdk_parse_bytes +from .config.errors import ConfigurationError from .errors import StreamParseError from .timeparse import timeparse @@ -133,3 +136,10 @@ def splitdrive(path): if path[0] in ['.', '\\', '/', '~']: return ('', path) return ntpath.splitdrive(path) + + +def parse_bytes(n): + try: + return sdk_parse_bytes(n) + except DockerException: + raise ConfigurationError('Invalid format for bytes value: {}'.format(n)) diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 3a585ec016a..8fb2251bf23 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -203,6 +203,34 @@ def test_create_container_with_read_only_root_fs(self): service.start_container(container) assert container.get('HostConfig.ReadonlyRootfs') == read_only + def test_create_container_with_blkio_config(self): + blkio_config = { + 'weight': 300, + 'weight_device': [{'path': '/dev/sda', 'weight': 200}], + 'device_read_bps': [{'path': '/dev/sda', 'rate': 1024 * 1024 * 100}], + 'device_read_iops': [{'path': '/dev/sda', 'rate': 1000}], + 'device_write_bps': [{'path': '/dev/sda', 'rate': 1024 * 1024}], + 'device_write_iops': [{'path': '/dev/sda', 'rate': 800}] + } + service = self.create_service('web', blkio_config=blkio_config) + container = service.create_container() + assert container.get('HostConfig.BlkioWeight') == 300 + assert container.get('HostConfig.BlkioWeightDevice') == [{ + 'Path': '/dev/sda', 'Weight': 200 + }] + assert container.get('HostConfig.BlkioDeviceReadBps') == [{ + 'Path': '/dev/sda', 'Rate': 1024 * 1024 * 100 + }] + assert container.get('HostConfig.BlkioDeviceWriteBps') == [{ + 'Path': '/dev/sda', 'Rate': 1024 * 1024 + }] + assert container.get('HostConfig.BlkioDeviceReadIOps') == [{ + 'Path': '/dev/sda', 'Rate': 1000 + }] + assert container.get('HostConfig.BlkioDeviceWriteIOps') == [{ + 'Path': '/dev/sda', 'Rate': 800 + }] + def test_create_container_with_security_opt(self): security_opt = ['label:disable'] service = self.create_service('db', security_opt=security_opt) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index fd06db7d139..8861baa9842 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -2151,6 +2151,53 @@ def test_merge_scale(self): actual = config.merge_service_dicts(base, override, V2_2) assert actual == {'image': 'bar', 'scale': 4} + def test_merge_blkio_config(self): + base = { + 'image': 'bar', + 'blkio_config': { + 'weight': 300, + 'weight_device': [ + {'path': '/dev/sda1', 'weight': 200} + ], + 'device_read_iops': [ + {'path': '/dev/sda1', 'rate': 300} + ], + 'device_write_iops': [ + {'path': '/dev/sda1', 'rate': 1000} + ] + } + } + + override = { + 'blkio_config': { + 'weight': 450, + 'weight_device': [ + {'path': '/dev/sda2', 'weight': 400} + ], + 'device_read_iops': [ + {'path': '/dev/sda1', 'rate': 2000} + ], + 'device_read_bps': [ + {'path': '/dev/sda1', 'rate': 1024} + ] + } + } + + actual = config.merge_service_dicts(base, override, V2_2) + assert actual == { + 'image': 'bar', + 'blkio_config': { + 'weight': override['blkio_config']['weight'], + 'weight_device': ( + base['blkio_config']['weight_device'] + + override['blkio_config']['weight_device'] + ), + 'device_read_iops': override['blkio_config']['device_read_iops'], + 'device_read_bps': override['blkio_config']['device_read_bps'], + 'device_write_iops': base['blkio_config']['device_write_iops'] + } + } + def test_external_volume_config(self): config_details = build_config_details({ 'version': '2', From b893797e03f33733f3678fc115bca9cabca505d1 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 2 Aug 2017 16:59:43 -0700 Subject: [PATCH 2969/4072] UCP 2.2.0 test fixes Signed-off-by: Joffrey F --- tests/acceptance/cli_test.py | 16 +++++++++------- tests/helpers.py | 4 ++++ tests/integration/project_test.py | 3 ++- tests/integration/service_test.py | 8 ++++++-- tests/integration/state_test.py | 17 ++++++++++++++++- tests/integration/testcases.py | 6 +++++- tox.ini | 1 + 7 files changed, 43 insertions(+), 12 deletions(-) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index adf645c2f39..81bce546038 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -451,7 +451,6 @@ def test_build_plain(self): self.dispatch(['build', 'simple']) result = self.dispatch(['build', 'simple']) - assert BUILD_CACHE_TEXT in result.stdout assert BUILD_PULL_TEXT not in result.stdout def test_build_no_cache(self): @@ -469,7 +468,9 @@ def test_build_pull(self): self.dispatch(['build', 'simple'], None) result = self.dispatch(['build', '--pull', 'simple']) - assert BUILD_CACHE_TEXT in result.stdout + if not is_cluster(self.client): + # If previous build happened on another node, cache won't be available + assert BUILD_CACHE_TEXT in result.stdout assert BUILD_PULL_TEXT in result.stdout def test_build_no_cache_pull(self): @@ -602,11 +603,12 @@ def test_create_with_no_recreate(self): def test_run_one_off_with_volume(self): self.base_dir = 'tests/fixtures/simple-composefile-volume-ready' volume_path = os.path.abspath(os.path.join(os.getcwd(), self.base_dir, 'files')) - create_host_file(self.client, os.path.join(volume_path, 'example.txt')) + node = create_host_file(self.client, os.path.join(volume_path, 'example.txt')) self.dispatch([ 'run', '-v', '{}:/data'.format(volume_path), + '-e', 'constraint:node=={}'.format(node if node is not None else '*'), 'simple', 'test', '-f', '/data/example.txt' ], returncode=0) @@ -621,12 +623,13 @@ def test_run_one_off_with_volume(self): def test_run_one_off_with_multiple_volumes(self): self.base_dir = 'tests/fixtures/simple-composefile-volume-ready' volume_path = os.path.abspath(os.path.join(os.getcwd(), self.base_dir, 'files')) - create_host_file(self.client, os.path.join(volume_path, 'example.txt')) + node = create_host_file(self.client, os.path.join(volume_path, 'example.txt')) self.dispatch([ 'run', '-v', '{}:/data'.format(volume_path), '-v', '{}:/data1'.format(volume_path), + '-e', 'constraint:node=={}'.format(node if node is not None else '*'), 'simple', 'test', '-f', '/data/example.txt' ], returncode=0) @@ -635,6 +638,7 @@ def test_run_one_off_with_multiple_volumes(self): 'run', '-v', '{}:/data'.format(volume_path), '-v', '{}:/data1'.format(volume_path), + '-e', 'constraint:node=={}'.format(node if node is not None else '*'), 'simple', 'test', '-f' '/data1/example.txt' ], returncode=0) @@ -1376,9 +1380,7 @@ def test_run_rm(self): break volume_names = [v['Name'].split('/')[-1] for v in volumes] assert name in volume_names - if not is_cluster(self.client): - # The `-v` flag for `docker rm` in Swarm seems to be broken - assert anonymous_name not in volume_names + assert anonymous_name not in volume_names def test_run_service_with_dockerfile_entrypoint(self): self.base_dir = 'tests/fixtures/entrypoint-dockerfile' diff --git a/tests/helpers.py b/tests/helpers.py index 59efd2557c4..a93de993f13 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -42,5 +42,9 @@ def create_host_file(client, filename): output = client.logs(container) raise Exception( "Container exited with code {}:\n{}".format(exitcode, output)) + + container_info = client.inspect_container(container) + if 'Node' in container_info: + return container_info['Node']['Name'] finally: client.remove_container(container, force=True) diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 5ead7b8e71e..4e44c7f6bf5 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -1265,7 +1265,7 @@ def test_project_up_implicit_volume_driver(self): @v3_only() def test_project_up_with_secrets(self): - create_host_file(self.client, os.path.abspath('tests/fixtures/secrets/default')) + node = create_host_file(self.client, os.path.abspath('tests/fixtures/secrets/default')) config_data = build_config( version=V3_1, @@ -1276,6 +1276,7 @@ def test_project_up_with_secrets(self): 'secrets': [ types.ServiceSecret.parse({'source': 'super', 'target': 'special'}), ], + 'environment': ['constraint:node=={}'.format(node if node is not None else '*')] }], secrets={ 'super': { diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 8fb2251bf23..2abb12c342c 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -325,13 +325,15 @@ def test_create_container_with_volumes_from(self): command=["top"], labels={LABEL_PROJECT: 'composetest'}, host_config={}, + environment=['affinity:container=={}'.format(volume_container_1.id)], ) host_service = self.create_service( 'host', volumes_from=[ VolumeFromSpec(volume_service, 'rw', 'service'), VolumeFromSpec(volume_container_2, 'rw', 'container') - ] + ], + environment=['affinity:container=={}'.format(volume_container_1.id)], ) host_container = host_service.create_container() host_service.start_container(host_container) @@ -785,6 +787,7 @@ def test_build_with_network(self): assert service.image() @v2_3_only() + @no_cluster('Not supported on UCP 2.2.0-beta1') # FIXME: remove once support is added def test_build_with_target(self): self.require_api_version('1.30') base_dir = tempfile.mkdtemp() @@ -792,11 +795,12 @@ def test_build_with_target(self): with open(os.path.join(base_dir, 'Dockerfile'), 'w') as f: f.write('FROM busybox as one\n') + f.write('LABEL com.docker.compose.test=true\n') f.write('LABEL com.docker.compose.test.target=one\n') f.write('FROM busybox as two\n') f.write('LABEL com.docker.compose.test.target=two\n') - service = self.create_service('buildlabels', build={ + service = self.create_service('buildtarget', build={ 'context': text_type(base_dir), 'target': 'one' }) diff --git a/tests/integration/state_test.py b/tests/integration/state_test.py index 0dd5f44ad49..047dc704695 100644 --- a/tests/integration/state_test.py +++ b/tests/integration/state_test.py @@ -6,9 +6,11 @@ from __future__ import unicode_literals import py +from docker.errors import ImageNotFound from .testcases import DockerClientTestCase from .testcases import get_links +from .testcases import no_cluster from compose.config import config from compose.project import Project from compose.service import ConvergenceStrategy @@ -243,21 +245,34 @@ def test_trigger_recreate_with_image_change(self): tag = 'latest' image = '{}:{}'.format(repo, tag) + def safe_remove_image(image): + try: + self.client.remove_image(image) + except ImageNotFound: + pass + image_id = self.client.images(name='busybox')[0]['Id'] self.client.tag(image_id, repository=repo, tag=tag) - self.addCleanup(self.client.remove_image, image) + self.addCleanup(safe_remove_image, image) web = self.create_service('web', image=image) container = web.create_container() # update the image c = self.client.create_container(image, ['touch', '/hello.txt'], host_config={}) + + # In the case of a cluster, there's a chance we pick up the old image when + # calculating the new hash. To circumvent that, untag the old image first + # See also: https://github.com/moby/moby/issues/26852 + self.client.remove_image(image, force=True) + self.client.commit(c, repository=repo, tag=tag) self.client.remove_container(c) web = self.create_service('web', image=image) self.assertEqual(('recreate', [container]), web.convergence_plan()) + @no_cluster('Can not guarantee the build will be run on the same node the service is deployed') def test_trigger_recreate_with_build(self): context = py.test.ensuretemp('test_trigger_recreate_with_build') self.addCleanup(context.remove) diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index b1763b113cc..b72fb53a81f 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -105,7 +105,11 @@ def tearDown(self): for i in self.client.images( filters={'label': 'com.docker.compose.test_image'}): - self.client.remove_image(i, force=True) + try: + self.client.remove_image(i, force=True) + except APIError as e: + if e.is_server_error(): + pass volumes = self.client.volumes().get('Volumes') or [] for v in volumes: diff --git a/tox.ini b/tox.ini index 749be3faaea..e4f31ec8554 100644 --- a/tox.ini +++ b/tox.ini @@ -18,6 +18,7 @@ deps = -rrequirements-dev.txt commands = py.test -v \ + --full-trace \ --cov=compose \ --cov-report html \ --cov-report term \ From b2a3566cf5b9f83c23cdc3461a05877a003d8aab Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 10 Aug 2017 10:59:23 -0700 Subject: [PATCH 2970/4072] Prevent null logging options in `docker-compose config` output Signed-off-by: Joffrey F --- compose/config/config.py | 4 ++-- tests/unit/config/config_test.py | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index fb376b32536..f3b8e42fd79 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -1044,8 +1044,8 @@ def merge_logging(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') + elif override.get('options'): + md['options'] = override.get('options', {}) return dict(md) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 8861baa9842..8a1e16f8a6f 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1864,7 +1864,6 @@ def test_merge_logging_v2_no_override_options(self): 'image': 'alpine:edge', 'logging': { 'driver': 'syslog', - 'options': None } } From b25eb084aefd17229305b084263d2e829d7a522c Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 9 Aug 2017 19:43:08 -0700 Subject: [PATCH 2971/4072] Add support for v3.4 files and custom volume names Signed-off-by: Joffrey F --- compose/config/config.py | 7 +- compose/config/config_schema_v3.4.json | 538 +++++++++++++++++++++++++ compose/config/serialize.py | 4 + compose/const.py | 3 + compose/volume.py | 20 +- tests/unit/volume_test.py | 2 +- 6 files changed, 559 insertions(+), 15 deletions(-) create mode 100644 compose/config/config_schema_v3.4.json diff --git a/compose/config/config.py b/compose/config/config.py index f3b8e42fd79..aa829a40e85 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -404,11 +404,12 @@ def load_mapping(config_files, get_func, entity_type, working_dir=None): external = config.get('external') if external: + name_field = 'name' if entity_type == 'Volume' else 'external_name' validate_external(entity_type, name, config) if isinstance(external, dict): - config['external_name'] = external.get('name') - else: - config['external_name'] = name + config[name_field] = external.get('name') + elif not config.get('name'): + config[name_field] = name if 'driver_opts' in config: config['driver_opts'] = build_string_dict( diff --git a/compose/config/config_schema_v3.4.json b/compose/config/config_schema_v3.4.json new file mode 100644 index 00000000000..ce9512076b5 --- /dev/null +++ b/compose/config/config_schema_v3.4.json @@ -0,0 +1,538 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "id": "config_schema_v3.4.json", + "type": "object", + "required": ["version"], + + "properties": { + "version": { + "type": "string" + }, + + "services": { + "id": "#/properties/services", + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "$ref": "#/definitions/service" + } + }, + "additionalProperties": false + }, + + "networks": { + "id": "#/properties/networks", + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "$ref": "#/definitions/network" + } + } + }, + + "volumes": { + "id": "#/properties/volumes", + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "$ref": "#/definitions/volume" + } + }, + "additionalProperties": false + }, + + "secrets": { + "id": "#/properties/secrets", + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "$ref": "#/definitions/secret" + } + }, + "additionalProperties": false + }, + + "configs": { + "id": "#/properties/configs", + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "$ref": "#/definitions/config" + } + }, + "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"}, + "labels": {"$ref": "#/definitions/list_or_dict"}, + "cache_from": {"$ref": "#/definitions/list_of_strings"} + }, + "additionalProperties": false + } + ] + }, + "cap_add": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "cap_drop": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "cgroup_parent": {"type": "string"}, + "command": { + "oneOf": [ + {"type": "string"}, + {"type": "array", "items": {"type": "string"}} + ] + }, + "configs": { + "type": "array", + "items": { + "oneOf": [ + {"type": "string"}, + { + "type": "object", + "properties": { + "source": {"type": "string"}, + "target": {"type": "string"}, + "uid": {"type": "string"}, + "gid": {"type": "string"}, + "mode": {"type": "number"} + } + } + ] + } + }, + "container_name": {"type": "string"}, + "credential_spec": {"type": "object", "properties": { + "file": {"type": "string"}, + "registry": {"type": "string"} + }}, + "depends_on": {"$ref": "#/definitions/list_of_strings"}, + "devices": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "dns": {"$ref": "#/definitions/string_or_list"}, + "dns_search": {"$ref": "#/definitions/string_or_list"}, + "domainname": {"type": "string"}, + "entrypoint": { + "oneOf": [ + {"type": "string"}, + {"type": "array", "items": {"type": "string"}} + ] + }, + "env_file": {"$ref": "#/definitions/string_or_list"}, + "environment": {"$ref": "#/definitions/list_or_dict"}, + + "expose": { + "type": "array", + "items": { + "type": ["string", "number"], + "format": "expose" + }, + "uniqueItems": true + }, + + "external_links": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "extra_hosts": {"$ref": "#/definitions/list_or_dict"}, + "healthcheck": {"$ref": "#/definitions/healthcheck"}, + "hostname": {"type": "string"}, + "image": {"type": "string"}, + "ipc": {"type": "string"}, + "labels": {"$ref": "#/definitions/list_or_dict"}, + "links": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + + "logging": { + "type": "object", + + "properties": { + "driver": {"type": "string"}, + "options": { + "type": "object", + "patternProperties": { + "^.+$": {"type": ["string", "number", "null"]} + } + } + }, + "additionalProperties": false + }, + + "mac_address": {"type": "string"}, + "network_mode": {"type": "string"}, + + "networks": { + "oneOf": [ + {"$ref": "#/definitions/list_of_strings"}, + { + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "oneOf": [ + { + "type": "object", + "properties": { + "aliases": {"$ref": "#/definitions/list_of_strings"}, + "ipv4_address": {"type": "string"}, + "ipv6_address": {"type": "string"} + }, + "additionalProperties": false + }, + {"type": "null"} + ] + } + }, + "additionalProperties": false + } + ] + }, + "pid": {"type": ["string", "null"]}, + + "ports": { + "type": "array", + "items": { + "oneOf": [ + {"type": "number", "format": "ports"}, + {"type": "string", "format": "ports"}, + { + "type": "object", + "properties": { + "mode": {"type": "string"}, + "target": {"type": "integer"}, + "published": {"type": "integer"}, + "protocol": {"type": "string"} + }, + "additionalProperties": false + } + ] + }, + "uniqueItems": true + }, + + "privileged": {"type": "boolean"}, + "read_only": {"type": "boolean"}, + "restart": {"type": "string"}, + "security_opt": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "shm_size": {"type": ["number", "string"]}, + "secrets": { + "type": "array", + "items": { + "oneOf": [ + {"type": "string"}, + { + "type": "object", + "properties": { + "source": {"type": "string"}, + "target": {"type": "string"}, + "uid": {"type": "string"}, + "gid": {"type": "string"}, + "mode": {"type": "number"} + } + } + ] + } + }, + "sysctls": {"$ref": "#/definitions/list_or_dict"}, + "stdin_open": {"type": "boolean"}, + "stop_grace_period": {"type": "string", "format": "duration"}, + "stop_signal": {"type": "string"}, + "tmpfs": {"$ref": "#/definitions/string_or_list"}, + "tty": {"type": "boolean"}, + "ulimits": { + "type": "object", + "patternProperties": { + "^[a-z]+$": { + "oneOf": [ + {"type": "integer"}, + { + "type":"object", + "properties": { + "hard": {"type": "integer"}, + "soft": {"type": "integer"} + }, + "required": ["soft", "hard"], + "additionalProperties": false + } + ] + } + } + }, + "user": {"type": "string"}, + "userns_mode": {"type": "string"}, + "volumes": { + "type": "array", + "items": { + "oneOf": [ + {"type": "string"}, + { + "type": "object", + "required": ["type"], + "properties": { + "type": {"type": "string"}, + "source": {"type": "string"}, + "target": {"type": "string"}, + "read_only": {"type": "boolean"}, + "consistency": {"type": "string"}, + "bind": { + "type": "object", + "properties": { + "propagation": {"type": "string"} + } + }, + "volume": { + "type": "object", + "properties": { + "nocopy": {"type": "boolean"} + } + } + } + } + ], + "uniqueItems": true + } + }, + "working_dir": {"type": "string"} + }, + "additionalProperties": false + }, + + "healthcheck": { + "id": "#/definitions/healthcheck", + "type": "object", + "additionalProperties": false, + "properties": { + "disable": {"type": "boolean"}, + "interval": {"type": "string"}, + "retries": {"type": "number"}, + "test": { + "oneOf": [ + {"type": "string"}, + {"type": "array", "items": {"type": "string"}} + ] + }, + "timeout": {"type": "string"} + } + }, + "deployment": { + "id": "#/definitions/deployment", + "type": ["object", "null"], + "properties": { + "mode": {"type": "string"}, + "endpoint_mode": {"type": "string"}, + "replicas": {"type": "integer"}, + "labels": {"$ref": "#/definitions/list_or_dict"}, + "update_config": { + "type": "object", + "properties": { + "parallelism": {"type": "integer"}, + "delay": {"type": "string", "format": "duration"}, + "failure_action": {"type": "string"}, + "monitor": {"type": "string", "format": "duration"}, + "max_failure_ratio": {"type": "number"}, + "order": {"type": "string", "enum": [ + "start-first", "stop-first" + ]} + }, + "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"}}, + "preferences": { + "type": "array", + "items": { + "type": "object", + "properties": { + "spread": {"type": "string"} + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + + "resource": { + "id": "#/definitions/resource", + "type": "object", + "properties": { + "cpus": {"type": "string"}, + "memory": {"type": "string"} + }, + "additionalProperties": false + }, + + "network": { + "id": "#/definitions/network", + "type": ["object", "null"], + "properties": { + "driver": {"type": "string"}, + "driver_opts": { + "type": "object", + "patternProperties": { + "^.+$": {"type": ["string", "number"]} + } + }, + "ipam": { + "type": "object", + "properties": { + "driver": {"type": "string"}, + "config": { + "type": "array", + "items": { + "type": "object", + "properties": { + "subnet": {"type": "string"} + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + }, + "external": { + "type": ["boolean", "object"], + "properties": { + "name": {"type": "string"} + }, + "additionalProperties": false + }, + "internal": {"type": "boolean"}, + "attachable": {"type": "boolean"}, + "labels": {"$ref": "#/definitions/list_or_dict"} + }, + "additionalProperties": false + }, + + "volume": { + "id": "#/definitions/volume", + "type": ["object", "null"], + "properties": { + "name": {"type": "string"}, + "driver": {"type": "string"}, + "driver_opts": { + "type": "object", + "patternProperties": { + "^.+$": {"type": ["string", "number"]} + } + }, + "external": { + "type": ["boolean", "object"], + "properties": { + "name": {"type": "string"} + }, + "additionalProperties": false + }, + "labels": {"$ref": "#/definitions/list_or_dict"} + }, + "additionalProperties": false + }, + + "secret": { + "id": "#/definitions/secret", + "type": "object", + "properties": { + "file": {"type": "string"}, + "external": { + "type": ["boolean", "object"], + "properties": { + "name": {"type": "string"} + } + }, + "labels": {"$ref": "#/definitions/list_or_dict"} + }, + "additionalProperties": false + }, + + "config": { + "id": "#/definitions/config", + "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"] + } + } + } + } + } +} diff --git a/compose/config/serialize.py b/compose/config/serialize.py index 86fdac38f62..6efe5fc9f42 100644 --- a/compose/config/serialize.py +++ b/compose/config/serialize.py @@ -9,6 +9,7 @@ from compose.const import COMPOSEFILE_V2_1 as V2_1 from compose.const import COMPOSEFILE_V3_0 as V3_0 from compose.const import COMPOSEFILE_V3_2 as V3_2 +from compose.const import COMPOSEFILE_V3_2 as V3_4 def serialize_config_type(dumper, data): @@ -65,6 +66,9 @@ def denormalize_config(config, image_digests=None): if 'external_name' in conf: del conf['external_name'] + if 'name' in conf and config.version < V3_4: + del conf['name'] + return result diff --git a/compose/const.py b/compose/const.py index 6ea0ea79c5b..b5970f82ae9 100644 --- a/compose/const.py +++ b/compose/const.py @@ -31,6 +31,7 @@ COMPOSEFILE_V3_1 = ComposeVersion('3.1') COMPOSEFILE_V3_2 = ComposeVersion('3.2') COMPOSEFILE_V3_3 = ComposeVersion('3.3') +COMPOSEFILE_V3_4 = ComposeVersion('3.4') API_VERSIONS = { COMPOSEFILE_V1: '1.21', @@ -42,6 +43,7 @@ COMPOSEFILE_V3_1: '1.25', COMPOSEFILE_V3_2: '1.25', COMPOSEFILE_V3_3: '1.30', + COMPOSEFILE_V3_4: '1.30', } API_VERSION_TO_ENGINE_VERSION = { @@ -54,4 +56,5 @@ API_VERSIONS[COMPOSEFILE_V3_1]: '1.13.0', API_VERSIONS[COMPOSEFILE_V3_2]: '1.13.0', API_VERSIONS[COMPOSEFILE_V3_3]: '17.06.0', + API_VERSIONS[COMPOSEFILE_V3_4]: '17.06.0', } diff --git a/compose/volume.py b/compose/volume.py index ab6a88fac8c..da8ba25cab6 100644 --- a/compose/volume.py +++ b/compose/volume.py @@ -15,14 +15,15 @@ class Volume(object): def __init__(self, client, project, name, driver=None, driver_opts=None, - external_name=None, labels=None): + external=False, labels=None, custom_name=False): self.client = client self.project = project self.name = name self.driver = driver self.driver_opts = driver_opts - self.external_name = external_name + self.external = external self.labels = labels + self.custom_name = custom_name def create(self): return self.client.create_volume( @@ -46,14 +47,10 @@ def exists(self): return False return True - @property - def external(self): - return bool(self.external_name) - @property def full_name(self): - if self.external_name: - return self.external_name + if self.custom_name: + return self.name return '{0}_{1}'.format(self.project, self.name) @property @@ -80,11 +77,12 @@ def from_config(cls, name, config_data, client): vol_name: Volume( client=client, project=name, - name=vol_name, + name=data.get('name', vol_name), driver=data.get('driver'), driver_opts=data.get('driver_opts'), - external_name=data.get('external_name'), - labels=data.get('labels') + custom_name=data.get('name') is not None, + labels=data.get('labels'), + external=bool(data.get('external', False)) ) for vol_name, data in config_volumes.items() } diff --git a/tests/unit/volume_test.py b/tests/unit/volume_test.py index 24829192a17..457d8558174 100644 --- a/tests/unit/volume_test.py +++ b/tests/unit/volume_test.py @@ -21,6 +21,6 @@ def test_remove_local_volume(self, mock_client): mock_client.remove_volume.assert_called_once_with('foo_project') def test_remove_external_volume(self, mock_client): - vol = volume.Volume(mock_client, 'foo', 'project', external_name='data') + vol = volume.Volume(mock_client, 'foo', 'project', external=True) vol.remove() assert not mock_client.remove_volume.called From 41a5a4a3217e367df30c6a8242a815802057fe28 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 10 Aug 2017 12:09:05 -0700 Subject: [PATCH 2972/4072] v2 custom volume name support Signed-off-by: Joffrey F --- compose/config/config_schema_v2.1.json | 3 ++- compose/config/config_schema_v2.2.json | 3 ++- compose/config/config_schema_v2.3.json | 3 ++- compose/config/serialize.py | 7 +++++-- tests/acceptance/cli_test.py | 6 ++++-- tests/integration/project_test.py | 4 ++-- tests/integration/volume_test.py | 19 +++++++++++++++---- 7 files changed, 32 insertions(+), 13 deletions(-) diff --git a/compose/config/config_schema_v2.1.json b/compose/config/config_schema_v2.1.json index 9d45c324c57..8a5e128347c 100644 --- a/compose/config/config_schema_v2.1.json +++ b/compose/config/config_schema_v2.1.json @@ -371,7 +371,8 @@ }, "additionalProperties": false }, - "labels": {"$ref": "#/definitions/list_or_dict"} + "labels": {"$ref": "#/definitions/list_or_dict"}, + "name": {"type": "string"} }, "additionalProperties": false }, diff --git a/compose/config/config_schema_v2.2.json b/compose/config/config_schema_v2.2.json index 9544170181f..58ba409ff1b 100644 --- a/compose/config/config_schema_v2.2.json +++ b/compose/config/config_schema_v2.2.json @@ -378,7 +378,8 @@ }, "additionalProperties": false }, - "labels": {"$ref": "#/definitions/list_or_dict"} + "labels": {"$ref": "#/definitions/list_or_dict"}, + "name": {"type": "string"} }, "additionalProperties": false }, diff --git a/compose/config/config_schema_v2.3.json b/compose/config/config_schema_v2.3.json index 10a61186e3d..789adf4abab 100644 --- a/compose/config/config_schema_v2.3.json +++ b/compose/config/config_schema_v2.3.json @@ -379,7 +379,8 @@ }, "additionalProperties": false }, - "labels": {"$ref": "#/definitions/list_or_dict"} + "labels": {"$ref": "#/definitions/list_or_dict"}, + "name": {"type": "string"} }, "additionalProperties": false }, diff --git a/compose/config/serialize.py b/compose/config/serialize.py index 6efe5fc9f42..1c52fc0567e 100644 --- a/compose/config/serialize.py +++ b/compose/config/serialize.py @@ -66,8 +66,11 @@ def denormalize_config(config, image_digests=None): if 'external_name' in conf: del conf['external_name'] - if 'name' in conf and config.version < V3_4: - del conf['name'] + if 'name' in conf: + if config.version < V2_1 or (config.version > V3_0 and config.version < V3_4): + del conf['name'] + elif 'external' in conf: + conf['external'] = True return result diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 81bce546038..bee7b74a2e5 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -291,10 +291,12 @@ def test_config_external_volume(self): assert 'volumes' in json_result assert json_result['volumes'] == { 'foo': { - 'external': True + 'external': True, + 'name': 'foo', }, 'bar': { - 'external': {'name': 'some_bar'} + 'external': True, + 'name': 'some_bar', } } diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 4e44c7f6bf5..953dd52beb8 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -1412,7 +1412,7 @@ def test_initialize_volumes_external_volumes(self): 'command': 'top' }], volumes={ - vol_name: {'external': True, 'external_name': vol_name} + vol_name: {'external': True, 'name': vol_name} }, ) project = Project.from_config( @@ -1436,7 +1436,7 @@ def test_initialize_volumes_inexistent_external_volume(self): 'command': 'top' }], volumes={ - vol_name: {'external': True, 'external_name': vol_name} + vol_name: {'external': True, 'name': vol_name} }, ) project = Project.from_config( diff --git a/tests/integration/volume_test.py b/tests/integration/volume_test.py index ecc71d0b127..2a521d4c5b1 100644 --- a/tests/integration/volume_test.py +++ b/tests/integration/volume_test.py @@ -1,6 +1,7 @@ from __future__ import absolute_import from __future__ import unicode_literals +import six from docker.errors import DockerException from .testcases import DockerClientTestCase @@ -23,12 +24,15 @@ def tearDown(self): 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): - external = name + def create_volume(self, name, driver=None, opts=None, external=None, custom_name=False): + if external: + custom_name = True + if isinstance(external, six.text_type): + name = external + vol = Volume( self.client, 'composetest', name, driver=driver, driver_opts=opts, - external_name=external + external=bool(external), custom_name=custom_name ) self.tmp_volumes.append(vol) return vol @@ -39,6 +43,13 @@ def test_create_volume(self): info = self.get_volume_data(vol.full_name) assert info['Name'].split('/')[-1] == vol.full_name + def test_create_volume_custom_name(self): + vol = self.create_volume('volume01', custom_name=True) + assert vol.name == vol.full_name + vol.create() + info = self.get_volume_data(vol.full_name) + assert info['Name'].split('/')[-1] == vol.name + def test_recreate_existing_volume(self): vol = self.create_volume('volume01') From 43cb1f3dff53b30ba74934622b24b489c5e2b8b1 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 11 Aug 2017 15:52:43 -0700 Subject: [PATCH 2973/4072] Add support for start_period in healthcheck config Improve merging strategy for healthcheck configs Signed-off-by: Joffrey F --- compose/config/config.py | 25 +++++---- compose/config/config_schema_v2.3.json | 1 + compose/config/serialize.py | 4 ++ compose/utils.py | 5 +- tests/integration/service_test.py | 19 +++++++ tests/unit/config/config_test.py | 77 +++++++++++++++++++++++++- 6 files changed, 117 insertions(+), 14 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index aa829a40e85..0c2ab1ab767 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -797,16 +797,12 @@ def process_healthcheck(service_dict, service_name): elif 'test' in raw: hc['test'] = raw['test'] - if 'interval' in raw: - 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: - 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'] + for field in ['interval', 'timeout', 'start_period']: + if field in raw: + if not isinstance(raw[field], six.integer_types): + hc[field] = parse_nanoseconds_int(raw[field]) + else: # Conversion has been done previously + hc[field] = raw[field] if 'retries' in raw: hc['retries'] = raw['retries'] @@ -967,6 +963,7 @@ def merge_service_dicts(base, override, version): md.merge_field('logging', merge_logging, default={}) merge_ports(md, base, override) md.merge_field('blkio_config', merge_blkio_config, default={}) + md.merge_field('healthcheck', merge_healthchecks, default={}) for field in set(ALLOWED_KEYS) - set(md): md.merge_scalar(field) @@ -985,6 +982,14 @@ def merge_unique_items_lists(base, override): return sorted(set().union(base, override)) +def merge_healthchecks(base, override): + if override.get('disabled') is True: + return override + result = base.copy() + result.update(override) + return result + + def merge_ports(md, base, override): def parse_sequence_func(seq): acc = [] diff --git a/compose/config/config_schema_v2.3.json b/compose/config/config_schema_v2.3.json index 789adf4abab..7a9bdfdf1d7 100644 --- a/compose/config/config_schema_v2.3.json +++ b/compose/config/config_schema_v2.3.json @@ -309,6 +309,7 @@ "disable": {"type": "boolean"}, "interval": {"type": "string"}, "retries": {"type": "number"}, + "start_period": {"type": "string"}, "test": { "oneOf": [ {"type": "string"}, diff --git a/compose/config/serialize.py b/compose/config/serialize.py index 1c52fc0567e..606dd761409 100644 --- a/compose/config/serialize.py +++ b/compose/config/serialize.py @@ -132,6 +132,10 @@ def denormalize_service_dict(service_dict, version, image_digest=None): service_dict['healthcheck']['timeout'] ) + if 'start_period' in service_dict['healthcheck']: + service_dict['healthcheck']['start_period'] = serialize_ns_time_value( + service_dict['healthcheck']['start_period'] + ) if 'ports' in service_dict and version < V3_2: service_dict['ports'] = [ p.legacy_repr() if isinstance(p, types.ServicePort) else p diff --git a/compose/utils.py b/compose/utils.py index 183a4504d46..1ede4d37d84 100644 --- a/compose/utils.py +++ b/compose/utils.py @@ -14,6 +14,7 @@ from .config.errors import ConfigurationError from .errors import StreamParseError +from .timeparse import MULTIPLIERS from .timeparse import timeparse @@ -112,7 +113,7 @@ def microseconds_from_time_nano(time_nano): def nanoseconds_from_time_seconds(time_seconds): - return time_seconds * 1000000000 + return int(time_seconds / MULTIPLIERS['nano']) def parse_seconds_float(value): @@ -123,7 +124,7 @@ def parse_nanoseconds_int(value): parsed = timeparse(value or '') if parsed is None: return None - return int(parsed * 1000000000) + return nanoseconds_from_time_seconds(parsed) def build_string_dict(source_dict): diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 2abb12c342c..84b54fe419d 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -36,6 +36,7 @@ from compose.service import NetworkMode from compose.service import PidMode from compose.service import Service +from compose.utils import parse_nanoseconds_int from tests.integration.testcases import is_cluster from tests.integration.testcases import no_cluster from tests.integration.testcases import v2_1_only @@ -270,6 +271,24 @@ def test_create_container_with_specified_volume(self): self.assertTrue(path.basename(actual_host_path) == path.basename(host_path), msg=("Last component differs: %s, %s" % (actual_host_path, host_path))) + def test_create_container_with_healthcheck_config(self): + one_second = parse_nanoseconds_int('1s') + healthcheck = { + 'test': ['true'], + 'interval': 2 * one_second, + 'timeout': 5 * one_second, + 'retries': 5, + 'start_period': 2 * one_second + } + service = self.create_service('db', healthcheck=healthcheck) + container = service.create_container() + remote_healthcheck = container.get('Config.Healthcheck') + assert remote_healthcheck['Test'] == healthcheck['test'] + assert remote_healthcheck['Interval'] == healthcheck['interval'] + assert remote_healthcheck['Timeout'] == healthcheck['timeout'] + assert remote_healthcheck['Retries'] == healthcheck['retries'] + assert remote_healthcheck['StartPeriod'] == healthcheck['start_period'] + def test_recreate_preserves_volume_with_trailing_slash(self): """When the Compose file specifies a trailing slash in the container path, make sure we copy the volume over when recreating. diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 8a1e16f8a6f..4e355d3bfe2 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -2197,6 +2197,75 @@ def test_merge_blkio_config(self): } } + def test_merge_healthcheck_config(self): + base = { + 'image': 'bar', + 'healthcheck': { + 'start_period': 1000, + 'interval': 3000, + 'test': ['true'] + } + } + + override = { + 'healthcheck': { + 'interval': 5000, + 'timeout': 10000, + 'test': ['echo', 'OK'], + } + } + + actual = config.merge_service_dicts(base, override, V2_3) + assert actual['healthcheck'] == { + 'start_period': base['healthcheck']['start_period'], + 'test': override['healthcheck']['test'], + 'interval': override['healthcheck']['interval'], + 'timeout': override['healthcheck']['timeout'], + } + + def test_merge_healthcheck_override_disables(self): + base = { + 'image': 'bar', + 'healthcheck': { + 'start_period': 1000, + 'interval': 3000, + 'timeout': 2000, + 'retries': 3, + 'test': ['true'] + } + } + + override = { + 'healthcheck': { + 'disabled': True + } + } + + actual = config.merge_service_dicts(base, override, V2_3) + assert actual['healthcheck'] == {'disabled': True} + + def test_merge_healthcheck_override_enables(self): + base = { + 'image': 'bar', + 'healthcheck': { + 'disabled': True + } + } + + override = { + 'healthcheck': { + 'disabled': False, + 'start_period': 1000, + 'interval': 3000, + 'timeout': 2000, + 'retries': 3, + 'test': ['true'] + } + } + + actual = config.merge_service_dicts(base, override, V2_3) + assert actual['healthcheck'] == override['healthcheck'] + def test_external_volume_config(self): config_details = build_config_details({ 'version': '2', @@ -4008,6 +4077,7 @@ def test_healthcheck(self): 'interval': '1s', 'timeout': '1m', 'retries': 3, + 'start_period': '10s' }}, '.', ) @@ -4017,6 +4087,7 @@ def test_healthcheck(self): 'interval': nanoseconds_from_time_seconds(1), 'timeout': nanoseconds_from_time_seconds(60), 'retries': 3, + 'start_period': nanoseconds_from_time_seconds(10) } def test_disable(self): @@ -4147,15 +4218,17 @@ def test_denormalize_healthcheck(self): 'test': 'exit 1', 'interval': '1m40s', 'timeout': '30s', - 'retries': 5 + 'retries': 5, + 'start_period': '2s90ms' } } processed_service = config.process_service(config.ServiceConfig( '.', 'test', 'test', service_dict )) - denormalized_service = denormalize_service_dict(processed_service, V2_1) + denormalized_service = denormalize_service_dict(processed_service, V2_3) assert denormalized_service['healthcheck']['interval'] == '100s' assert denormalized_service['healthcheck']['timeout'] == '30s' + assert denormalized_service['healthcheck']['start_period'] == '2090ms' def test_denormalize_image_has_digest(self): service_dict = { From f1baee3292f02c8507b2addf538742100abd94c7 Mon Sep 17 00:00:00 2001 From: aronahl Date: Wed, 9 Aug 2017 19:44:12 -0400 Subject: [PATCH 2974/4072] Fix exit code 0 upon parallel pull failure. Signed-off-by: Aaron Nall --- compose/project.py | 4 +++- tests/acceptance/cli_test.py | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/compose/project.py b/compose/project.py index 86fbda6eefa..2310a2fcc68 100644 --- a/compose/project.py +++ b/compose/project.py @@ -498,13 +498,15 @@ def pull(self, service_names=None, ignore_pull_failures=False, parallel_pull=Fal def pull_service(service): service.pull(ignore_pull_failures, True) - parallel.parallel_execute( + _, errors = parallel.parallel_execute( services, pull_service, operator.attrgetter('name'), 'Pulling', limit=5, ) + if len(errors): + raise ProjectError(b"\n".join(errors.values())) else: for service in services: service.pull(ignore_pull_failures, silent=silent) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index bee7b74a2e5..78d1c1eb181 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -6,6 +6,7 @@ import json import os import os.path +import re import signal import subprocess import time @@ -448,6 +449,20 @@ def test_pull_with_quiet(self): assert self.dispatch(['pull', '--quiet']).stderr == '' assert self.dispatch(['pull', '--quiet']).stdout == '' + def test_pull_with_parallel_failure(self): + result = self.dispatch([ + '-f', 'ignore-pull-failures.yml', 'pull', '--parallel'], + returncode=1 + ) + + self.assertRegexpMatches(result.stderr, re.compile('^Pulling simple', re.MULTILINE)) + self.assertRegexpMatches(result.stderr, re.compile('^Pulling another', re.MULTILINE)) + self.assertRegexpMatches(result.stderr, + re.compile('^ERROR: for another .*does not exist.*', re.MULTILINE)) + self.assertRegexpMatches(result.stderr, + re.compile('''^(ERROR: )?(b')?.* nonexisting-image''', + re.MULTILINE)) + def test_build_plain(self): self.base_dir = 'tests/fixtures/simple-dockerfile' self.dispatch(['build', 'simple']) From f2aebf8004ce70b8af9464973b0b74b82d25f264 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 17 Aug 2017 14:31:20 -0700 Subject: [PATCH 2975/4072] Bump python SDK version -> 2.5.0 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 d1778990f41..d0c0e941bc2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ cached-property==1.3.0 certifi==2017.4.17 chardet==3.0.4 colorama==0.3.9; sys_platform == 'win32' -docker==2.4.2 +docker==2.5.0 docker-pycreds==0.2.1 dockerpty==0.4.1 docopt==0.6.2 diff --git a/setup.py b/setup.py index 16493f52b96..9721fd38483 100644 --- a/setup.py +++ b/setup.py @@ -36,7 +36,7 @@ def find_version(*file_paths): 'requests >= 2.6.1, != 2.11.0, < 2.12', 'texttable >= 0.9.0, < 0.10', 'websocket-client >= 0.32.0, < 1.0', - 'docker >= 2.4.2, < 3.0', + 'docker >= 2.5.0, < 3.0', 'dockerpty >= 0.4.1, < 0.5', 'six >= 1.3.0, < 2', 'jsonschema >= 2.5.1, < 3', From 7611492f9c1193543e39ec1259e63007bbead6d7 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 17 Aug 2017 15:52:08 -0700 Subject: [PATCH 2976/4072] Update schemas to prevent invalid properties in deploy.resources Signed-off-by: Joffrey F --- compose/config/config_schema_v3.0.json | 3 ++- compose/config/config_schema_v3.1.json | 3 ++- compose/config/config_schema_v3.2.json | 3 ++- compose/config/config_schema_v3.3.json | 3 ++- compose/config/config_schema_v3.4.json | 7 +++++-- 5 files changed, 13 insertions(+), 6 deletions(-) diff --git a/compose/config/config_schema_v3.0.json b/compose/config/config_schema_v3.0.json index fbcd8bb859a..f39344cfbe7 100644 --- a/compose/config/config_schema_v3.0.json +++ b/compose/config/config_schema_v3.0.json @@ -240,7 +240,8 @@ "properties": { "limits": {"$ref": "#/definitions/resource"}, "reservations": {"$ref": "#/definitions/resource"} - } + }, + "additionalProperties": false }, "restart_policy": { "type": "object", diff --git a/compose/config/config_schema_v3.1.json b/compose/config/config_schema_v3.1.json index b7037485f97..719c0fa7acc 100644 --- a/compose/config/config_schema_v3.1.json +++ b/compose/config/config_schema_v3.1.json @@ -269,7 +269,8 @@ "properties": { "limits": {"$ref": "#/definitions/resource"}, "reservations": {"$ref": "#/definitions/resource"} - } + }, + "additionalProperties": false }, "restart_policy": { "type": "object", diff --git a/compose/config/config_schema_v3.2.json b/compose/config/config_schema_v3.2.json index 70ff6ce0564..b26b2c6c6f1 100644 --- a/compose/config/config_schema_v3.2.json +++ b/compose/config/config_schema_v3.2.json @@ -313,7 +313,8 @@ "properties": { "limits": {"$ref": "#/definitions/resource"}, "reservations": {"$ref": "#/definitions/resource"} - } + }, + "additionalProperties": false }, "restart_policy": { "type": "object", diff --git a/compose/config/config_schema_v3.3.json b/compose/config/config_schema_v3.3.json index e69116c3889..f1eb9a66103 100644 --- a/compose/config/config_schema_v3.3.json +++ b/compose/config/config_schema_v3.3.json @@ -348,7 +348,8 @@ "properties": { "limits": {"$ref": "#/definitions/resource"}, "reservations": {"$ref": "#/definitions/resource"} - } + }, + "additionalProperties": false }, "restart_policy": { "type": "object", diff --git a/compose/config/config_schema_v3.4.json b/compose/config/config_schema_v3.4.json index ce9512076b5..5a110a8880d 100644 --- a/compose/config/config_schema_v3.4.json +++ b/compose/config/config_schema_v3.4.json @@ -84,7 +84,9 @@ "dockerfile": {"type": "string"}, "args": {"$ref": "#/definitions/list_or_dict"}, "labels": {"$ref": "#/definitions/list_or_dict"}, - "cache_from": {"$ref": "#/definitions/list_of_strings"} + "cache_from": {"$ref": "#/definitions/list_of_strings"}, + "network": {"type": "string"}, + "target": {"type": "string"} }, "additionalProperties": false } @@ -351,7 +353,8 @@ "properties": { "limits": {"$ref": "#/definitions/resource"}, "reservations": {"$ref": "#/definitions/resource"} - } + }, + "additionalProperties": false }, "restart_policy": { "type": "object", From 7805960a73cbcc58eec49cfbf86fdc66045a57fd Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 18 Aug 2017 15:37:14 -0700 Subject: [PATCH 2977/4072] Bump 1.16.0-rc1 Signed-off-by: Joffrey F --- CHANGELOG.md | 60 +++++++++++++++++++++++++++++++++++++++++++++ compose/__init__.py | 2 +- script/run/run.sh | 2 +- 3 files changed, 62 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9289227826d..0790f61848b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,66 @@ Change log ========== +1.16.0 (2017-08-31) +------------------- + +### New features + +#### Compose file version 3.4 + +- Introduced version 3.4 of the `docker-compose.yml` specification. + This version requires to be used with Docker Engine 17.06.0 or above. + +#### Compose file version 2.3 + +- Introduced version 2.3 of the `docker-compose.yml` specification. + This version requires to be used with Docker Engine 17.06.0 or above. + +- Added support for the `target` parameter in network configurations + (also available in 3.4) + +- Added support for the `start_period` parameter in healthcheck + configurations + +#### Compose file version 2.x + +- Added support for the `blkio_config` parameter in service definitions + +- Added support for setting a custom name in volume definitions using + the `name` parameter (not available for version 2.0) + +#### All formats + +- Added new CLI flag `--no-ansi` to suppress ANSI control characters in + output + +### Bugfixes + +- Fixed a bug where nested `extends` instructions weren't resolved + properly, causing "file not found" errors + +- Fixed several issues with `.dockerignore` parsing + +- Fixed issues where logs of TTY-enabled services were being printed + incorrectly and causing `MemoryError` exceptions + +- The `$` character in the output of `docker-compose config` is now + properly escaped + +- Fixed a bug where running `docker-compose top` would sometimes fail + with an uncaught exception + +- Fixed a bug where `docker-compose pull` with the `--parallel` flag + would return a `0` exit code when failing + +- Fixed an issue where keys in `deploy.resources` were not being validated + +- Fixed an issue where the `logging` options in the output of + `docker-compose config` would be set to `null`, an invalid value + +- Fixed the output of `docker-compose config` when a port definition used + `0` as the value for the published port + 1.15.0 (2017-07-26) ------------------- diff --git a/compose/__init__.py b/compose/__init__.py index cedb7cf040d..b090ccfa218 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.16.0-dev' +__version__ = '1.16.0-rc1' diff --git a/script/run/run.sh b/script/run/run.sh index 47a81c7f874..bf9a26cb8fc 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.15.0" +VERSION="1.16.0" IMAGE="docker/compose:$VERSION" From 415c5ddde4e0eb3ea0b675a8cb7fe761e27f1ddd Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 22 Aug 2017 17:14:21 -0700 Subject: [PATCH 2978/4072] Bump docker SDK -> 2.5.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 d0c0e941bc2..beeaa28517f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ cached-property==1.3.0 certifi==2017.4.17 chardet==3.0.4 colorama==0.3.9; sys_platform == 'win32' -docker==2.5.0 +docker==2.5.1 docker-pycreds==0.2.1 dockerpty==0.4.1 docopt==0.6.2 diff --git a/setup.py b/setup.py index 9721fd38483..192a0f6afdf 100644 --- a/setup.py +++ b/setup.py @@ -36,7 +36,7 @@ def find_version(*file_paths): 'requests >= 2.6.1, != 2.11.0, < 2.12', 'texttable >= 0.9.0, < 0.10', 'websocket-client >= 0.32.0, < 1.0', - 'docker >= 2.5.0, < 3.0', + 'docker >= 2.5.1, < 3.0', 'dockerpty >= 0.4.1, < 0.5', 'six >= 1.3.0, < 2', 'jsonschema >= 2.5.1, < 3', From 7813d0a8fa1c216d56712e5c6be5dcd46e7ef51e Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 24 Aug 2017 12:21:31 -0700 Subject: [PATCH 2979/4072] Account for repo tag values that may contain a port Signed-off-by: Joffrey F --- 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 2bb53f95e66..c07de53f245 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -506,7 +506,7 @@ def images(self, options): rows = [] for container in containers: image_config = container.image_config - repo_tags = image_config['RepoTags'][0].split(':') + repo_tags = image_config['RepoTags'][0].rsplit(':', 1) image_id = image_config['Id'].split(':')[1][:12] size = human_readable_file_size(image_config['Size']) rows.append([ From e370a22104fffe48fb958fa262d44f6211248049 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 23 Aug 2017 17:45:56 -0700 Subject: [PATCH 2980/4072] Rename 3.4 schema to 3.4-beta Signed-off-by: Joffrey F --- ...{config_schema_v3.4.json => config_schema_v3.4-beta.json} | 2 +- compose/const.py | 2 +- docker-compose.spec | 5 +++++ 3 files changed, 7 insertions(+), 2 deletions(-) rename compose/config/{config_schema_v3.4.json => config_schema_v3.4-beta.json} (99%) diff --git a/compose/config/config_schema_v3.4.json b/compose/config/config_schema_v3.4-beta.json similarity index 99% rename from compose/config/config_schema_v3.4.json rename to compose/config/config_schema_v3.4-beta.json index 5a110a8880d..190c05f2c14 100644 --- a/compose/config/config_schema_v3.4.json +++ b/compose/config/config_schema_v3.4-beta.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-04/schema#", - "id": "config_schema_v3.4.json", + "id": "config_schema_v3.4-beta.json", "type": "object", "required": ["version"], diff --git a/compose/const.py b/compose/const.py index b5970f82ae9..809f7c7d4e7 100644 --- a/compose/const.py +++ b/compose/const.py @@ -31,7 +31,7 @@ COMPOSEFILE_V3_1 = ComposeVersion('3.1') COMPOSEFILE_V3_2 = ComposeVersion('3.2') COMPOSEFILE_V3_3 = ComposeVersion('3.3') -COMPOSEFILE_V3_4 = ComposeVersion('3.4') +COMPOSEFILE_V3_4 = ComposeVersion('3.4-beta') API_VERSIONS = { COMPOSEFILE_V1: '1.21', diff --git a/docker-compose.spec b/docker-compose.spec index 8dc70c22624..fe5651f6a2f 100644 --- a/docker-compose.spec +++ b/docker-compose.spec @@ -62,6 +62,11 @@ exe = EXE(pyz, 'compose/config/config_schema_v3.3.json', 'DATA' ), + ( + 'compose/config/config_schema_v3.4-beta.json', + 'compose/config/config_schema_v3.4-beta.json', + 'DATA' + ), ( 'compose/GITSHA', 'compose/GITSHA', From ebbf48e606c42a0844102d8644cf8eb365d9c686 Mon Sep 17 00:00:00 2001 From: Cecile Tonglet Date: Tue, 22 Aug 2017 15:37:32 +0200 Subject: [PATCH 2981/4072] Actually test there is no control characters Signed-off-by: Cecile Tonglet --- tests/unit/parallel_test.py | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/tests/unit/parallel_test.py b/tests/unit/parallel_test.py index f82858eab0c..3a60f01a69c 100644 --- a/tests/unit/parallel_test.py +++ b/tests/unit/parallel_test.py @@ -133,17 +133,31 @@ def test_parallel_execute_alignment(capsys): assert a.index('...') == b.index('...') -def test_parallel_execute_alignment_noansi(capsys): +def test_parallel_execute_ansi(capsys): + ParallelStreamWriter.set_noansi(value=False) + results, errors = parallel_execute( + objects=["something", "something more"], + func=lambda x: x, + get_name=six.text_type, + msg="Control characters", + ) + + assert errors == {} + + _, err = capsys.readouterr() + assert "\x1b" in err + + +def test_parallel_execute_noansi(capsys): ParallelStreamWriter.set_noansi() results, errors = parallel_execute( - objects=["short", "a very long name"], + objects=["something", "something more"], func=lambda x: x, get_name=six.text_type, - msg="Aligning", + msg="Control characters", ) assert errors == {} _, err = capsys.readouterr() - a, b, c, d = err.split('\n')[:4] - assert a.index('...') == b.index('...') == c.index('...') == d.index('...') + assert "\x1b" not in err From 9e88b15172f6523538c2ede5a46201dd5f65e891 Mon Sep 17 00:00:00 2001 From: Cecile Tonglet Date: Tue, 22 Aug 2017 15:46:55 +0200 Subject: [PATCH 2982/4072] Fix --no-ansi flag not working properly Signed-off-by: Cecile Tonglet --- compose/parallel.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/compose/parallel.py b/compose/parallel.py index 1cf1fb094e0..d455711ddc9 100644 --- a/compose/parallel.py +++ b/compose/parallel.py @@ -49,16 +49,16 @@ def parallel_execute(objects, func, get_name, msg, get_deps=None, limit=None): for obj, result, exception in events: if exception is None: - writer.write(get_name(obj), green('done')) + writer.write(get_name(obj), 'done', green) results.append(result) elif isinstance(exception, APIError): errors[get_name(obj)] = exception.explanation - writer.write(get_name(obj), red('error')) + writer.write(get_name(obj), 'error', red) elif isinstance(exception, (OperationFailedError, HealthCheckFailed, NoHealthCheckConfigured)): errors[get_name(obj)] = exception.msg - writer.write(get_name(obj), red('error')) + writer.write(get_name(obj), 'error', red) elif isinstance(exception, UpstreamError): - writer.write(get_name(obj), red('error')) + writer.write(get_name(obj), 'error', red) else: errors[get_name(obj)] = exception error_to_reraise = exception @@ -263,13 +263,13 @@ def _write_noansi(self, obj_index, status): status, width=self.width)) self.stream.flush() - def write(self, obj_index, status): + def write(self, obj_index, status, color_func): if self.msg is None: return if self.noansi: self._write_noansi(obj_index, status) else: - self._write_ansi(obj_index, status) + self._write_ansi(obj_index, color_func(status)) def parallel_operation(containers, operation, options, message): From 60eed707d1c8c129031bc747edb405aef2eac1d5 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 24 Aug 2017 12:46:22 -0700 Subject: [PATCH 2983/4072] Remove all colors in output when --no-ansi is set Signed-off-by: Joffrey F --- compose/cli/main.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index c07de53f245..83bc7d58cb0 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -97,8 +97,10 @@ def dispatch(): {'options_first': True, 'version': get_version_info('compose')}) options, handler, command_options = dispatcher.parse(sys.argv[1:]) - setup_console_handler(console_handler, options.get('--verbose')) + setup_console_handler(console_handler, options.get('--verbose'), options.get('--no-ansi')) setup_parallel_logger(options.get('--no-ansi')) + if options.get('--no-ansi'): + command_options['--no-color'] = True return functools.partial(perform_command, options, handler, command_options) @@ -134,8 +136,8 @@ def setup_parallel_logger(noansi): compose.parallel.ParallelStreamWriter.set_noansi() -def setup_console_handler(handler, verbose): - if handler.stream.isatty(): +def setup_console_handler(handler, verbose, noansi=False): + if handler.stream.isatty() and noansi is False: format_class = ConsoleWarningFormatter else: format_class = logging.Formatter From 28e1c85c3b2c2b884318fa4a62bc4446463f263f Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Fri, 25 Aug 2017 09:42:37 +0200 Subject: [PATCH 2984/4072] Add bash completion for `--no-ansi` Signed-off-by: Harald Albers --- contrib/completion/bash/docker-compose | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index d283a041aa0..33c2e2e536a 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -179,7 +179,7 @@ _docker_compose_docker_compose() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "$top_level_boolean_options $top_level_options_with_args --help -h --verbose --version -v" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "$top_level_boolean_options $top_level_options_with_args --help -h --no-ansi --verbose --version -v" -- "$cur" ) ) ;; *) COMPREPLY=( $( compgen -W "${commands[*]}" -- "$cur" ) ) @@ -569,8 +569,10 @@ _docker_compose() { version ) - # options for the docker daemon that have to be passed to secondary calls to - # docker-compose executed by this script + # Options for the docker daemon that have to be passed to secondary calls to + # docker-compose executed by this script. + # Other global otions that are not relevant for secondary calls are defined in + # `_docker_compose_docker_compose`. local top_level_boolean_options=" --skip-hostname-check --tls From ff05f1ed1530c8f35c7a08092063e35bd0cd5a45 Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Fri, 25 Aug 2017 09:59:39 +0200 Subject: [PATCH 2985/4072] Add bash completion for `create --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 33c2e2e536a..9de156403fd 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -149,7 +149,7 @@ _docker_compose_config() { _docker_compose_create() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "--force-recreate --help --no-build --no-recreate" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--build --force-recreate --help --no-build --no-recreate" -- "$cur" ) ) ;; *) __docker_compose_services_all From c8daf17db6ea7a180d622c7c9e6bae15751635af Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 25 Aug 2017 15:39:23 -0700 Subject: [PATCH 2986/4072] Handle unicode errors in LogPrinter Signed-off-by: Joffrey F --- compose/cli/log_printer.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/compose/cli/log_printer.py b/compose/cli/log_printer.py index 043d3d068b2..60bba8da6f1 100644 --- a/compose/cli/log_printer.py +++ b/compose/cli/log_printer.py @@ -102,8 +102,18 @@ def run(self): # active containers to tail, so continue continue + self.write(line) + + def write(self, line): + try: self.output.write(line) - self.output.flush() + except UnicodeEncodeError: + # This may happen if the user's locale settings don't support UTF-8 + # and UTF-8 characters are present in the log line. The following + # will output a "degraded" log with unsupported characters + # replaced by `?` + self.output.write(line.encode('ascii', 'replace').decode()) + self.output.flush() def remove_stopped_threads(thread_map): From d2543c830df406cb8bbb2e329ba2210c516cbc94 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 22 Aug 2017 17:14:21 -0700 Subject: [PATCH 2987/4072] Bump docker SDK -> 2.5.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 d0c0e941bc2..beeaa28517f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ cached-property==1.3.0 certifi==2017.4.17 chardet==3.0.4 colorama==0.3.9; sys_platform == 'win32' -docker==2.5.0 +docker==2.5.1 docker-pycreds==0.2.1 dockerpty==0.4.1 docopt==0.6.2 diff --git a/setup.py b/setup.py index 9721fd38483..192a0f6afdf 100644 --- a/setup.py +++ b/setup.py @@ -36,7 +36,7 @@ def find_version(*file_paths): 'requests >= 2.6.1, != 2.11.0, < 2.12', 'texttable >= 0.9.0, < 0.10', 'websocket-client >= 0.32.0, < 1.0', - 'docker >= 2.5.0, < 3.0', + 'docker >= 2.5.1, < 3.0', 'dockerpty >= 0.4.1, < 0.5', 'six >= 1.3.0, < 2', 'jsonschema >= 2.5.1, < 3', From 5f84c0c27afe7ac93e50c89de329748a88a7560a Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 23 Aug 2017 17:45:56 -0700 Subject: [PATCH 2988/4072] Rename 3.4 schema to 3.4-beta Signed-off-by: Joffrey F --- ...{config_schema_v3.4.json => config_schema_v3.4-beta.json} | 2 +- compose/const.py | 2 +- docker-compose.spec | 5 +++++ 3 files changed, 7 insertions(+), 2 deletions(-) rename compose/config/{config_schema_v3.4.json => config_schema_v3.4-beta.json} (99%) diff --git a/compose/config/config_schema_v3.4.json b/compose/config/config_schema_v3.4-beta.json similarity index 99% rename from compose/config/config_schema_v3.4.json rename to compose/config/config_schema_v3.4-beta.json index 5a110a8880d..190c05f2c14 100644 --- a/compose/config/config_schema_v3.4.json +++ b/compose/config/config_schema_v3.4-beta.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-04/schema#", - "id": "config_schema_v3.4.json", + "id": "config_schema_v3.4-beta.json", "type": "object", "required": ["version"], diff --git a/compose/const.py b/compose/const.py index b5970f82ae9..809f7c7d4e7 100644 --- a/compose/const.py +++ b/compose/const.py @@ -31,7 +31,7 @@ COMPOSEFILE_V3_1 = ComposeVersion('3.1') COMPOSEFILE_V3_2 = ComposeVersion('3.2') COMPOSEFILE_V3_3 = ComposeVersion('3.3') -COMPOSEFILE_V3_4 = ComposeVersion('3.4') +COMPOSEFILE_V3_4 = ComposeVersion('3.4-beta') API_VERSIONS = { COMPOSEFILE_V1: '1.21', diff --git a/docker-compose.spec b/docker-compose.spec index 8dc70c22624..fe5651f6a2f 100644 --- a/docker-compose.spec +++ b/docker-compose.spec @@ -62,6 +62,11 @@ exe = EXE(pyz, 'compose/config/config_schema_v3.3.json', 'DATA' ), + ( + 'compose/config/config_schema_v3.4-beta.json', + 'compose/config/config_schema_v3.4-beta.json', + 'DATA' + ), ( 'compose/GITSHA', 'compose/GITSHA', From 177499b6de4b7f5d0bf99549dc3a3f0302ec359d Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 24 Aug 2017 12:21:31 -0700 Subject: [PATCH 2989/4072] Account for repo tag values that may contain a port Signed-off-by: Joffrey F --- 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 2bb53f95e66..c07de53f245 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -506,7 +506,7 @@ def images(self, options): rows = [] for container in containers: image_config = container.image_config - repo_tags = image_config['RepoTags'][0].split(':') + repo_tags = image_config['RepoTags'][0].rsplit(':', 1) image_id = image_config['Id'].split(':')[1][:12] size = human_readable_file_size(image_config['Size']) rows.append([ From c8193821ed7f6420d45da01e30c5c5d76402c3bd Mon Sep 17 00:00:00 2001 From: Cecile Tonglet Date: Tue, 22 Aug 2017 15:37:32 +0200 Subject: [PATCH 2990/4072] Actually test there is no control characters Signed-off-by: Cecile Tonglet --- tests/unit/parallel_test.py | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/tests/unit/parallel_test.py b/tests/unit/parallel_test.py index f82858eab0c..3a60f01a69c 100644 --- a/tests/unit/parallel_test.py +++ b/tests/unit/parallel_test.py @@ -133,17 +133,31 @@ def test_parallel_execute_alignment(capsys): assert a.index('...') == b.index('...') -def test_parallel_execute_alignment_noansi(capsys): +def test_parallel_execute_ansi(capsys): + ParallelStreamWriter.set_noansi(value=False) + results, errors = parallel_execute( + objects=["something", "something more"], + func=lambda x: x, + get_name=six.text_type, + msg="Control characters", + ) + + assert errors == {} + + _, err = capsys.readouterr() + assert "\x1b" in err + + +def test_parallel_execute_noansi(capsys): ParallelStreamWriter.set_noansi() results, errors = parallel_execute( - objects=["short", "a very long name"], + objects=["something", "something more"], func=lambda x: x, get_name=six.text_type, - msg="Aligning", + msg="Control characters", ) assert errors == {} _, err = capsys.readouterr() - a, b, c, d = err.split('\n')[:4] - assert a.index('...') == b.index('...') == c.index('...') == d.index('...') + assert "\x1b" not in err From fb531ceaa3e50dcb684c5ba4e5332c9d71b519d9 Mon Sep 17 00:00:00 2001 From: Cecile Tonglet Date: Tue, 22 Aug 2017 15:46:55 +0200 Subject: [PATCH 2991/4072] Fix --no-ansi flag not working properly Signed-off-by: Cecile Tonglet --- compose/parallel.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/compose/parallel.py b/compose/parallel.py index 1cf1fb094e0..d455711ddc9 100644 --- a/compose/parallel.py +++ b/compose/parallel.py @@ -49,16 +49,16 @@ def parallel_execute(objects, func, get_name, msg, get_deps=None, limit=None): for obj, result, exception in events: if exception is None: - writer.write(get_name(obj), green('done')) + writer.write(get_name(obj), 'done', green) results.append(result) elif isinstance(exception, APIError): errors[get_name(obj)] = exception.explanation - writer.write(get_name(obj), red('error')) + writer.write(get_name(obj), 'error', red) elif isinstance(exception, (OperationFailedError, HealthCheckFailed, NoHealthCheckConfigured)): errors[get_name(obj)] = exception.msg - writer.write(get_name(obj), red('error')) + writer.write(get_name(obj), 'error', red) elif isinstance(exception, UpstreamError): - writer.write(get_name(obj), red('error')) + writer.write(get_name(obj), 'error', red) else: errors[get_name(obj)] = exception error_to_reraise = exception @@ -263,13 +263,13 @@ def _write_noansi(self, obj_index, status): status, width=self.width)) self.stream.flush() - def write(self, obj_index, status): + def write(self, obj_index, status, color_func): if self.msg is None: return if self.noansi: self._write_noansi(obj_index, status) else: - self._write_ansi(obj_index, status) + self._write_ansi(obj_index, color_func(status)) def parallel_operation(containers, operation, options, message): From c49837fae0fa82fe43c4fa855ec0737455096d44 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 24 Aug 2017 12:46:22 -0700 Subject: [PATCH 2992/4072] Remove all colors in output when --no-ansi is set Signed-off-by: Joffrey F --- compose/cli/main.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index c07de53f245..83bc7d58cb0 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -97,8 +97,10 @@ def dispatch(): {'options_first': True, 'version': get_version_info('compose')}) options, handler, command_options = dispatcher.parse(sys.argv[1:]) - setup_console_handler(console_handler, options.get('--verbose')) + setup_console_handler(console_handler, options.get('--verbose'), options.get('--no-ansi')) setup_parallel_logger(options.get('--no-ansi')) + if options.get('--no-ansi'): + command_options['--no-color'] = True return functools.partial(perform_command, options, handler, command_options) @@ -134,8 +136,8 @@ def setup_parallel_logger(noansi): compose.parallel.ParallelStreamWriter.set_noansi() -def setup_console_handler(handler, verbose): - if handler.stream.isatty(): +def setup_console_handler(handler, verbose, noansi=False): + if handler.stream.isatty() and noansi is False: format_class = ConsoleWarningFormatter else: format_class = logging.Formatter From e62c4033260a07a7b4b4a5eb6fadcbc1b14bf252 Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Fri, 25 Aug 2017 09:42:37 +0200 Subject: [PATCH 2993/4072] Add bash completion for `--no-ansi` Signed-off-by: Harald Albers --- contrib/completion/bash/docker-compose | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index d283a041aa0..33c2e2e536a 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -179,7 +179,7 @@ _docker_compose_docker_compose() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "$top_level_boolean_options $top_level_options_with_args --help -h --verbose --version -v" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "$top_level_boolean_options $top_level_options_with_args --help -h --no-ansi --verbose --version -v" -- "$cur" ) ) ;; *) COMPREPLY=( $( compgen -W "${commands[*]}" -- "$cur" ) ) @@ -569,8 +569,10 @@ _docker_compose() { version ) - # options for the docker daemon that have to be passed to secondary calls to - # docker-compose executed by this script + # Options for the docker daemon that have to be passed to secondary calls to + # docker-compose executed by this script. + # Other global otions that are not relevant for secondary calls are defined in + # `_docker_compose_docker_compose`. local top_level_boolean_options=" --skip-hostname-check --tls From b28bcd613a02aae376a0f16e3d7a457a91347734 Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Fri, 25 Aug 2017 09:59:39 +0200 Subject: [PATCH 2994/4072] Add bash completion for `create --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 33c2e2e536a..9de156403fd 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -149,7 +149,7 @@ _docker_compose_config() { _docker_compose_create() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "--force-recreate --help --no-build --no-recreate" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--build --force-recreate --help --no-build --no-recreate" -- "$cur" ) ) ;; *) __docker_compose_services_all From abdeed7bb6ab4e384a9a93fa7020a5258c704ce6 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 25 Aug 2017 15:39:23 -0700 Subject: [PATCH 2995/4072] Handle unicode errors in LogPrinter Signed-off-by: Joffrey F --- compose/cli/log_printer.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/compose/cli/log_printer.py b/compose/cli/log_printer.py index 043d3d068b2..60bba8da6f1 100644 --- a/compose/cli/log_printer.py +++ b/compose/cli/log_printer.py @@ -102,8 +102,18 @@ def run(self): # active containers to tail, so continue continue + self.write(line) + + def write(self, line): + try: self.output.write(line) - self.output.flush() + except UnicodeEncodeError: + # This may happen if the user's locale settings don't support UTF-8 + # and UTF-8 characters are present in the log line. The following + # will output a "degraded" log with unsupported characters + # replaced by `?` + self.output.write(line.encode('ascii', 'replace').decode()) + self.output.flush() def remove_stopped_threads(thread_map): From 5cc23c540c95e40d137b77e2aa419ce62c1f17c5 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 25 Aug 2017 16:06:42 -0700 Subject: [PATCH 2996/4072] Bump 1.16.0-rc2 Signed-off-by: Joffrey F --- CHANGELOG.md | 12 ++++++------ compose/__init__.py | 2 +- script/run/run.sh | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0790f61848b..3a92bf10a53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,18 +6,12 @@ Change log ### New features -#### Compose file version 3.4 - -- Introduced version 3.4 of the `docker-compose.yml` specification. - This version requires to be used with Docker Engine 17.06.0 or above. - #### Compose file version 2.3 - Introduced version 2.3 of the `docker-compose.yml` specification. This version requires to be used with Docker Engine 17.06.0 or above. - Added support for the `target` parameter in network configurations - (also available in 3.4) - Added support for the `start_period` parameter in healthcheck configurations @@ -44,6 +38,9 @@ Change log - Fixed issues where logs of TTY-enabled services were being printed incorrectly and causing `MemoryError` exceptions +- Fixed a bug where printing application logs would sometimes be interrupted + by a `UnicodeEncodeError` exception on Python 3 + - The `$` character in the output of `docker-compose config` is now properly escaped @@ -58,6 +55,9 @@ Change log - Fixed an issue where the `logging` options in the output of `docker-compose config` would be set to `null`, an invalid value +- Fixed the output of the `docker-compose images` command when an image + would come from a private repository using an explicit port number + - Fixed the output of `docker-compose config` when a port definition used `0` as the value for the published port diff --git a/compose/__init__.py b/compose/__init__.py index b090ccfa218..4e2793b1aeb 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.16.0-rc1' +__version__ = '1.16.0-rc2' diff --git a/script/run/run.sh b/script/run/run.sh index bf9a26cb8fc..0f0d3d9d92d 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.16.0" +VERSION="1.16.0-rc2" IMAGE="docker/compose:$VERSION" From 40c05cfc1e33077f7fe35ebabf64af3968ef58c8 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 28 Aug 2017 17:35:55 -0700 Subject: [PATCH 2997/4072] Add --no-start flag to up command. Deprecate create command. Signed-off-by: Joffrey F --- compose/cli/main.py | 18 ++++++++++++++++-- compose/project.py | 6 ++++-- tests/acceptance/cli_test.py | 25 +++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 4 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 83bc7d58cb0..21bf1f30887 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -319,6 +319,7 @@ def config(self, config_options, options): def create(self, options): """ Creates containers for a service. + This command is deprecated. Use the `up` command with `--no-start` instead. Usage: create [options] [SERVICE...] @@ -332,6 +333,11 @@ def create(self, options): """ service_names = options['SERVICE'] + log.warn( + 'The create command is deprecated. ' + 'Use the up command with the --no-start flag instead.' + ) + self.project.create( service_names=service_names, strategy=convergence_strategy_from_opts(options), @@ -902,6 +908,7 @@ def up(self, options): --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-start Don't start the services after creating them. --build Build images before starting containers. --abort-on-container-exit Stops all containers if any container was stopped. Incompatible with -d. @@ -922,10 +929,16 @@ def up(self, options): timeout = timeout_from_opts(options) remove_orphans = options['--remove-orphans'] detached = options.get('-d') + no_start = options.get('--no-start') - if detached and cascade_stop: + if detached and (cascade_stop or exit_value_from): raise UserError("--abort-on-container-exit and -d cannot be combined.") + if no_start: + for excluded in ['-d', '--abort-on-container-exit', '--exit-code-from']: + if options.get(excluded): + raise UserError('--no-start and {} cannot be combined.'.format(excluded)) + with up_shutdown_context(self.project, service_names, timeout, detached): to_attach = self.project.up( service_names=service_names, @@ -936,9 +949,10 @@ def up(self, options): detached=detached, remove_orphans=remove_orphans, scale_override=parse_scale_args(options['--scale']), + start=not no_start ) - if detached: + if detached or no_start: return attached_containers = filter_containers_to_service_names(to_attach, service_names) diff --git a/compose/project.py b/compose/project.py index 2310a2fcc68..c8b57edd209 100644 --- a/compose/project.py +++ b/compose/project.py @@ -412,7 +412,8 @@ def up(self, detached=False, remove_orphans=False, scale_override=None, - rescale=True): + rescale=True, + start=True): warn_for_swarm_mode(self.client) @@ -436,7 +437,8 @@ def do(service): timeout=timeout, detached=detached, scale_override=scale_override.get(service.name), - rescale=rescale + rescale=rescale, + start=start ) def get_deps(service): diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 78d1c1eb181..b8cece49530 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -772,6 +772,31 @@ def test_up(self): for service in services: assert self.lookup(container, service.name) + @v2_only() + def test_up_no_start(self): + self.base_dir = 'tests/fixtures/v2-full' + self.dispatch(['up', '--no-start'], None) + + services = self.project.get_services() + + default_network = self.project.networks.networks['default'].full_name + front_network = self.project.networks.networks['front'].full_name + networks = self.client.networks(names=[default_network, front_network]) + assert len(networks) == 2 + + for service in services: + containers = service.containers(stopped=True) + assert len(containers) == 1 + + container = containers[0] + assert not container.is_running + assert container.get('State.Status') == 'created' + + volumes = self.project.volumes.volumes + assert 'data' in volumes + volume = volumes['data'] + assert volume.exists() + @v2_only() def test_up_no_ansi(self): self.base_dir = 'tests/fixtures/v2-simple' From 3e725162ec70b591344cf6e896d7f2e31c3551e2 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 28 Aug 2017 18:18:31 -0700 Subject: [PATCH 2998/4072] Reduce up() cyclomatic complexity Signed-off-by: Joffrey F --- compose/cli/main.py | 62 +++++++++++++++++++++++++-------------------- 1 file changed, 35 insertions(+), 27 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 21bf1f30887..face38e6d33 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -969,33 +969,10 @@ def up(self, options): if cascade_stop: print("Aborting on container exit...") - - exit_code = 0 - if exit_value_from: - candidates = list(filter( - lambda c: c.service == exit_value_from, - attached_containers)) - if not candidates: - log.error( - 'No containers matching the spec "{0}" ' - 'were run.'.format(exit_value_from) - ) - exit_code = 2 - elif len(candidates) > 1: - exit_values = filter( - lambda e: e != 0, - [c.inspect()['State']['ExitCode'] for c in candidates] - ) - - exit_code = exit_values[0] - else: - exit_code = candidates[0].inspect()['State']['ExitCode'] - else: - for e in self.project.containers(service_names=options['SERVICE'], stopped=True): - if (not e.is_running and cascade_starter == e.name): - if not e.exit_code == 0: - exit_code = e.exit_code - break + all_containers = self.project.containers(service_names=options['SERVICE'], stopped=True) + exit_code = compute_exit_code( + exit_value_from, attached_containers, cascade_starter, all_containers + ) self.project.stop(service_names=service_names, timeout=timeout) sys.exit(exit_code) @@ -1016,6 +993,37 @@ def version(cls, options): print(get_version_info('full')) +def compute_exit_code(exit_value_from, attached_containers, cascade_starter, all_containers): + exit_code = 0 + if exit_value_from: + candidates = list(filter( + lambda c: c.service == exit_value_from, + attached_containers)) + if not candidates: + log.error( + 'No containers matching the spec "{0}" ' + 'were run.'.format(exit_value_from) + ) + exit_code = 2 + elif len(candidates) > 1: + exit_values = filter( + lambda e: e != 0, + [c.inspect()['State']['ExitCode'] for c in candidates] + ) + + exit_code = exit_values[0] + else: + exit_code = candidates[0].inspect()['State']['ExitCode'] + else: + for e in all_containers: + if (not e.is_running and cascade_starter == e.name): + if not e.exit_code == 0: + exit_code = e.exit_code + break + + return exit_code + + def convergence_strategy_from_opts(options): no_recreate = options['--no-recreate'] force_recreate = options['--force-recreate'] From 07d5042859d9b23613175ac21a1961b07d9ccc65 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 30 Aug 2017 15:52:29 -0700 Subject: [PATCH 2999/4072] Bump 1.16.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 4e2793b1aeb..02c325eb8c8 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.16.0-rc2' +__version__ = '1.16.0' diff --git a/script/run/run.sh b/script/run/run.sh index 0f0d3d9d92d..bf9a26cb8fc 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.16.0-rc2" +VERSION="1.16.0" IMAGE="docker/compose:$VERSION" From ec1f6c36eb0b2916d490a493738b283815453d95 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 25 Aug 2017 18:09:06 -0700 Subject: [PATCH 3000/4072] Add support for extension fields in v2.x and v3.4 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_v2.2.json | 1 + compose/config/config_schema_v2.3.json | 1 + compose/config/config_schema_v3.4-beta.json | 1 + compose/config/validation.py | 10 ++++++++++ tests/unit/config/config_test.py | 14 +++++++++++++- 7 files changed, 28 insertions(+), 1 deletion(-) diff --git a/compose/config/config_schema_v2.0.json b/compose/config/config_schema_v2.0.json index 14bafab4033..2ad62ac5221 100644 --- a/compose/config/config_schema_v2.0.json +++ b/compose/config/config_schema_v2.0.json @@ -41,6 +41,7 @@ } }, + "patternProperties": {"^x-": {}}, "additionalProperties": false, "definitions": { diff --git a/compose/config/config_schema_v2.1.json b/compose/config/config_schema_v2.1.json index 8a5e128347c..24e6ba02cea 100644 --- a/compose/config/config_schema_v2.1.json +++ b/compose/config/config_schema_v2.1.json @@ -41,6 +41,7 @@ } }, + "patternProperties": {"^x-": {}}, "additionalProperties": false, "definitions": { diff --git a/compose/config/config_schema_v2.2.json b/compose/config/config_schema_v2.2.json index 58ba409ff1b..86fc5df95d1 100644 --- a/compose/config/config_schema_v2.2.json +++ b/compose/config/config_schema_v2.2.json @@ -41,6 +41,7 @@ } }, + "patternProperties": {"^x-": {}}, "additionalProperties": false, "definitions": { diff --git a/compose/config/config_schema_v2.3.json b/compose/config/config_schema_v2.3.json index 7a9bdfdf1d7..a790bb4050c 100644 --- a/compose/config/config_schema_v2.3.json +++ b/compose/config/config_schema_v2.3.json @@ -41,6 +41,7 @@ } }, + "patternProperties": {"^x-": {}}, "additionalProperties": false, "definitions": { diff --git a/compose/config/config_schema_v3.4-beta.json b/compose/config/config_schema_v3.4-beta.json index 190c05f2c14..cba063202f0 100644 --- a/compose/config/config_schema_v3.4-beta.json +++ b/compose/config/config_schema_v3.4-beta.json @@ -64,6 +64,7 @@ } }, + "patternProperties": {"^x-": {}}, "additionalProperties": false, "definitions": { diff --git a/compose/config/validation.py b/compose/config/validation.py index 0b7961e5a7b..c6722a14d19 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -239,6 +239,16 @@ def handle_error_for_schema_with_id(error, path): invalid_config_key = parse_key_from_error_msg(error) return get_unsupported_config_msg(path, invalid_config_key) + if schema_id.startswith('config_schema_v'): + invalid_config_key = parse_key_from_error_msg(error) + return ('Invalid top-level property "{key}". Valid top-level ' + 'sections for this Compose file are: {properties}, and ' + 'extensions starting with "x-".\n\n{explanation}').format( + key=invalid_config_key, + properties=', '.join(error.schema['properties'].keys()), + explanation=VERSION_EXPLANATION + ) + if not error.path: return '{}\n\n{}'.format(error.message, VERSION_EXPLANATION) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 4e355d3bfe2..08f2f9a55b6 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -251,7 +251,7 @@ def test_v1_file_with_version_is_invalid(self): ) ) - assert 'Additional properties are not allowed' in excinfo.exconly() + assert 'Invalid top-level property "web"' in excinfo.exconly() assert VERSION_EXPLANATION in excinfo.exconly() def test_named_volume_config_empty(self): @@ -773,6 +773,18 @@ def test_load_sorts_in_dependency_order(self): assert services[1]['name'] == 'db' assert services[2]['name'] == 'web' + def test_load_with_extensions(self): + config_details = build_config_details({ + 'version': '2.3', + 'x-data': { + 'lambda': 3, + 'excess': [True, {}] + } + }) + + config_data = config.load(config_details) + assert config_data.services == [] + def test_config_build_configuration(self): service = config.load( build_config_details( From dc4b77deb0380b73f08dd9dab4ab621d790dbe56 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 30 Aug 2017 15:52:29 -0700 Subject: [PATCH 3001/4072] Bump 1.16.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 cedb7cf040d..02c325eb8c8 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.16.0-dev' +__version__ = '1.16.0' diff --git a/script/run/run.sh b/script/run/run.sh index 47a81c7f874..bf9a26cb8fc 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.15.0" +VERSION="1.16.0" IMAGE="docker/compose:$VERSION" From cfb0fda1b185c6826c58c3909404cd8416f76c5d Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 25 Aug 2017 16:06:42 -0700 Subject: [PATCH 3002/4072] Bump 1.16.0-rc2 Signed-off-by: Joffrey F --- CHANGELOG.md | 60 +++++++++++++++++++++++++++++++++++++++++++++++ script/run/run.sh | 2 +- 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9289227826d..3a92bf10a53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,66 @@ Change log ========== +1.16.0 (2017-08-31) +------------------- + +### New features + +#### Compose file version 2.3 + +- Introduced version 2.3 of the `docker-compose.yml` specification. + This version requires to be used with Docker Engine 17.06.0 or above. + +- Added support for the `target` parameter in network configurations + +- Added support for the `start_period` parameter in healthcheck + configurations + +#### Compose file version 2.x + +- Added support for the `blkio_config` parameter in service definitions + +- Added support for setting a custom name in volume definitions using + the `name` parameter (not available for version 2.0) + +#### All formats + +- Added new CLI flag `--no-ansi` to suppress ANSI control characters in + output + +### Bugfixes + +- Fixed a bug where nested `extends` instructions weren't resolved + properly, causing "file not found" errors + +- Fixed several issues with `.dockerignore` parsing + +- Fixed issues where logs of TTY-enabled services were being printed + incorrectly and causing `MemoryError` exceptions + +- Fixed a bug where printing application logs would sometimes be interrupted + by a `UnicodeEncodeError` exception on Python 3 + +- The `$` character in the output of `docker-compose config` is now + properly escaped + +- Fixed a bug where running `docker-compose top` would sometimes fail + with an uncaught exception + +- Fixed a bug where `docker-compose pull` with the `--parallel` flag + would return a `0` exit code when failing + +- Fixed an issue where keys in `deploy.resources` were not being validated + +- Fixed an issue where the `logging` options in the output of + `docker-compose config` would be set to `null`, an invalid value + +- Fixed the output of the `docker-compose images` command when an image + would come from a private repository using an explicit port number + +- Fixed the output of `docker-compose config` when a port definition used + `0` as the value for the published port + 1.15.0 (2017-07-26) ------------------- diff --git a/script/run/run.sh b/script/run/run.sh index bf9a26cb8fc..0f0d3d9d92d 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.16.0" +VERSION="1.16.0-rc2" IMAGE="docker/compose:$VERSION" From cb82e3d192753b18b5dd3f1f559ea2ef53ec7f86 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 18 Aug 2017 15:37:14 -0700 Subject: [PATCH 3003/4072] Bump 1.16.0-rc1 Signed-off-by: Joffrey F --- 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 0f0d3d9d92d..bf9a26cb8fc 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.16.0-rc2" +VERSION="1.16.0" IMAGE="docker/compose:$VERSION" From d1eabcecb651fcd7d069cbfecd34f4863955d9df Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 31 Aug 2017 14:48:45 -0700 Subject: [PATCH 3004/4072] 1.17.0 dev 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 02c325eb8c8..0b9282521c1 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.16.0' +__version__ = '1.17.0dev' From 36e5985b01e85888ec68bca87e7a1cfa4703e2e5 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 1 Sep 2017 12:14:56 -0700 Subject: [PATCH 3005/4072] Update release process with most recent changes Signed-off-by: Joffrey F --- project/RELEASE-PROCESS.md | 33 +++++++++++++++++++++------------ script/release/make-branch | 3 +-- 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/project/RELEASE-PROCESS.md b/project/RELEASE-PROCESS.md index c1834f2fbbb..5b30545f459 100644 --- a/project/RELEASE-PROCESS.md +++ b/project/RELEASE-PROCESS.md @@ -24,7 +24,7 @@ As part of this script you'll be asked to: If the next release will be an RC, append `-rcN`, e.g. `1.4.0-rc1`. -2. Write release notes in `CHANGES.md`. +2. Write release notes in `CHANGELOG.md`. Almost every feature enhancement should be mentioned, with the most visible/exciting ones first. Use descriptive sentences and give context @@ -67,16 +67,13 @@ 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 Travis - build has finished, otherwise you'll be downloading an old binary. +1. Download the different platform binaries by running the following script: - https://dl.bintray.com/docker-compose/$BRANCH_NAME/ + `./script/release/download-binaries $VERSION` -2. Download the windows binary from AppVeyor + The binaries for Linux, OSX and Windows will be downloaded in the `binaries-$VERSION` folder. - https://ci.appveyor.com/project/docker/compose - -3. Draft a release from the tag on GitHub (the script will open the window for +3. Draft a release from the tag on GitHub (the `build-binaries` script will open the window for you) The tag will only be present on Github when you run the `push-release` @@ -87,18 +84,30 @@ When prompted build the non-linux binaries and test them. 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)**. - 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. + Docker for Mac and Windows will automatically install the latest version of Docker Engine for you. Alternatively, you can use the usual commands to install or upgrade Compose: ``` - curl -L https://github.com/docker/compose/releases/download/1.9.0/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose + curl -L https://github.com/docker/compose/releases/download/1.16.0/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose chmod +x /usr/local/bin/docker-compose ``` See the [install docs](https://docs.docker.com/compose/install/) for more install options and instructions. - Here's what's new: + ## Compose file format compatibility matrix + + | Compose file format | Docker Engine | + | --- | --- | + | 3.3 | 17.06.0+ | + | 3.0 – 3.2 | 1.13.0+ | + | 2.3| 17.06.0+ | + | 2.2 | 1.13.0+ | + | 2.1 | 1.12.0+ | + | 2.0 | 1.10.0+ | + | 1.0 | 1.9.1+ | + + ## Changes ...release notes go here... @@ -119,7 +128,7 @@ When prompted build the non-linux binaries and test them. 9. Check that all the binaries download (following the install instructions) and run. -10. Email maintainers@dockerproject.org and engineering@docker.com about the new release. +10. Announce the release on the appropriate Slack channel(s). ## If it’s a stable release (not an RC) diff --git a/script/release/make-branch b/script/release/make-branch index 7ccf3f055b5..b8a0cd31ee7 100755 --- a/script/release/make-branch +++ b/script/release/make-branch @@ -65,8 +65,7 @@ git config "branch.${BRANCH}.release" $VERSION editor=${EDITOR:-vim} -echo "Update versions in docs/install.md, compose/__init__.py, script/run/run.sh" -$editor docs/install.md +echo "Update versions in compose/__init__.py, script/run/run.sh" $editor compose/__init__.py $editor script/run/run.sh From fc426e273db9fc29ce3469b4bff50730118bc386 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 31 Aug 2017 15:37:33 -0700 Subject: [PATCH 3006/4072] Merge extra_hosts instead of overwrite Signed-off-by: Joffrey F --- compose/config/config.py | 4 +--- tests/unit/config/config_test.py | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 0c2ab1ab767..0fddfd3a456 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -717,9 +717,6 @@ def process_service(service_config): if 'labels' in service_dict: service_dict['labels'] = parse_labels(service_dict['labels']) - 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'])) @@ -947,6 +944,7 @@ def merge_service_dicts(base, override, version): md.merge_sequence('secrets', types.ServiceSecret.parse) md.merge_sequence('configs', types.ServiceConfig.parse) md.merge_mapping('deploy', parse_deploy) + md.merge_mapping('extra_hosts', parse_extra_hosts) for field in ['volumes', 'devices']: md.merge_field(field, merge_path_mappings) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 08f2f9a55b6..14dd0117981 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -2209,6 +2209,24 @@ def test_merge_blkio_config(self): } } + def test_merge_extra_hosts(self): + base = { + 'image': 'bar', + 'extra_hosts': { + 'foo': '1.2.3.4', + } + } + + override = { + 'extra_hosts': ['bar:5.6.7.8', 'foo:127.0.0.1'] + } + + actual = config.merge_service_dicts(base, override, V2_0) + assert actual['extra_hosts'] == { + 'foo': '127.0.0.1', + 'bar': '5.6.7.8', + } + def test_merge_healthcheck_config(self): base = { 'image': 'bar', From 241931f77605ff2c29de248ec0b7ef00e032d6f3 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 31 Aug 2017 15:37:33 -0700 Subject: [PATCH 3007/4072] Merge extra_hosts instead of overwrite Signed-off-by: Joffrey F --- compose/config/config.py | 4 +--- tests/unit/config/config_test.py | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 0c2ab1ab767..0fddfd3a456 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -717,9 +717,6 @@ def process_service(service_config): if 'labels' in service_dict: service_dict['labels'] = parse_labels(service_dict['labels']) - 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'])) @@ -947,6 +944,7 @@ def merge_service_dicts(base, override, version): md.merge_sequence('secrets', types.ServiceSecret.parse) md.merge_sequence('configs', types.ServiceConfig.parse) md.merge_mapping('deploy', parse_deploy) + md.merge_mapping('extra_hosts', parse_extra_hosts) for field in ['volumes', 'devices']: md.merge_field(field, merge_path_mappings) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 4e355d3bfe2..64429015739 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -2197,6 +2197,24 @@ def test_merge_blkio_config(self): } } + def test_merge_extra_hosts(self): + base = { + 'image': 'bar', + 'extra_hosts': { + 'foo': '1.2.3.4', + } + } + + override = { + 'extra_hosts': ['bar:5.6.7.8', 'foo:127.0.0.1'] + } + + actual = config.merge_service_dicts(base, override, V2_0) + assert actual['extra_hosts'] == { + 'foo': '127.0.0.1', + 'bar': '5.6.7.8', + } + def test_merge_healthcheck_config(self): base = { 'image': 'bar', From 4900f099916f31047cdc8b35b8432babe42f87ef Mon Sep 17 00:00:00 2001 From: Andrew Hsu Date: Fri, 1 Sep 2017 13:11:10 -0700 Subject: [PATCH 3008/4072] Bump 1.16.1 Signed-off-by: Andrew Hsu --- CHANGELOG.md | 8 ++++++++ compose/__init__.py | 2 +- script/run/run.sh | 2 +- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a92bf10a53..5583768559c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,14 @@ Change log ========== +1.16.1 (2017-09-01) +------------------- + +### Bugfixes + +- Fixed bug that prevented using `extra_hosts` in several configuration files. + + 1.16.0 (2017-08-31) ------------------- diff --git a/compose/__init__.py b/compose/__init__.py index 02c325eb8c8..2e41ca89650 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.16.0' +__version__ = '1.16.1' diff --git a/script/run/run.sh b/script/run/run.sh index bf9a26cb8fc..f1754d05a43 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.16.0" +VERSION="1.16.1" IMAGE="docker/compose:$VERSION" From 432dffc7106849b4d143f99059ee284f5afaacf6 Mon Sep 17 00:00:00 2001 From: Vincent Demeester Date: Tue, 19 Sep 2017 18:27:02 +0200 Subject: [PATCH 3009/4072] Sync composefile v3.2 schema with `docker/cli` Signed-off-by: Vincent Demeester --- compose/config/config_schema_v3.2.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/compose/config/config_schema_v3.2.json b/compose/config/config_schema_v3.2.json index 175e061ad8f..8d850d5d2e9 100644 --- a/compose/config/config_schema_v3.2.json +++ b/compose/config/config_schema_v3.2.json @@ -169,7 +169,8 @@ "type": "array", "items": { "oneOf": [ - {"type": ["string", "number"], "format": "ports"}, + {"type": "number", "format": "ports"}, + {"type": "string", "format": "ports"}, { "type": "object", "properties": { @@ -248,6 +249,7 @@ "source": {"type": "string"}, "target": {"type": "string"}, "read_only": {"type": "boolean"}, + "consistency": {"type": "string"}, "bind": { "type": "object", "properties": { From 3654f3ac4844580f922663b2fa1a4a84692119ad Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 26 Sep 2017 16:25:05 -0700 Subject: [PATCH 3010/4072] Fix oneOf validator parser to correctly process uniqueItems errors Signed-off-by: Joffrey F --- compose/config/validation.py | 15 +++++++-------- tests/unit/config/config_test.py | 25 ++++++++++++++++++++----- 2 files changed, 27 insertions(+), 13 deletions(-) diff --git a/compose/config/validation.py b/compose/config/validation.py index c6722a14d19..940775a2097 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -325,7 +325,6 @@ 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 @@ -337,6 +336,13 @@ def _parse_oneof_validator(error): invalid_config_key = parse_key_from_error_msg(context) return (None, "contains unsupported option: '{}'".format(invalid_config_key)) + if context.validator == 'uniqueItems': + return ( + path_string(context.path) if context.path else None, + "contains non-unique items, please remove duplicates from {}".format( + context.instance), + ) + if context.path: return ( path_string(context.path), @@ -345,13 +351,6 @@ def _parse_oneof_validator(error): _parse_valid_types_from_validator(context.validator_value)), ) - if context.validator == 'uniqueItems': - return ( - None, - "contains non unique items, please remove duplicates from {}".format( - context.instance), - ) - if context.validator == 'type': types.append(context.validator_value) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 14dd0117981..de9a6130257 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -581,6 +581,20 @@ def test_config_invalid_service_name_raise_validation_error(self): assert 'Invalid service name \'mong\\o\'' in excinfo.exconly() + def test_config_duplicate_cache_from_values_validation_error(self): + with pytest.raises(ConfigurationError) as exc: + config.load( + build_config_details({ + 'version': '2.3', + 'services': { + 'test': {'build': {'context': '.', 'cache_from': ['a', 'b', 'a']}} + } + + }) + ) + + assert 'build.cache_from contains non-unique items' in exc.exconly() + def test_load_with_multiple_files_v1(self): base_file = config.ConfigFile( 'base.yaml', @@ -2751,11 +2765,12 @@ def test_config_valid_expose_format_validation(self): def check_config(self, cfg): config.load( - build_config_details( - {'web': dict(image='busybox', **cfg)}, - 'working_dir', - 'filename.yml' - ) + build_config_details({ + 'version': '2.3', + 'services': { + 'web': dict(image='busybox', **cfg) + }, + }, 'working_dir', 'filename.yml') ) From 6222d1cc8b28b5211093245525a18205d836761d Mon Sep 17 00:00:00 2001 From: French Ben Date: Mon, 18 Sep 2017 16:30:32 -0700 Subject: [PATCH 3011/4072] Simple patch to allow s390x images to be built Needs integration with CI and s390x machine integration Signed-off-by: French Ben --- Dockerfile.s390x | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 Dockerfile.s390x diff --git a/Dockerfile.s390x b/Dockerfile.s390x new file mode 100644 index 00000000000..aa71e27bc3d --- /dev/null +++ b/Dockerfile.s390x @@ -0,0 +1,6 @@ +FROM s390x/python:3.6.2-slim +ARG COMPOSE_VERSION=1.16.1 + +RUN pip install --no-cache-dir docker-compose==$COMPOSE_VERSION + +ENTRYPOINT ["docker-compose"] From b2a03265e48748aef26565df73a682d2c2b851ef Mon Sep 17 00:00:00 2001 From: French Ben Date: Tue, 19 Sep 2017 10:14:55 -0700 Subject: [PATCH 3012/4072] Use slim alpine instead of bulky debian Signed-off-by: French Ben --- Dockerfile.s390x | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/Dockerfile.s390x b/Dockerfile.s390x index aa71e27bc3d..3b19bb390b9 100644 --- a/Dockerfile.s390x +++ b/Dockerfile.s390x @@ -1,6 +1,15 @@ -FROM s390x/python:3.6.2-slim +FROM s390x/alpine:3.6 + ARG COMPOSE_VERSION=1.16.1 -RUN pip install --no-cache-dir docker-compose==$COMPOSE_VERSION +RUN apk add --update --no-cache \ + python \ + py-pip \ + && pip install --no-cache-dir docker-compose==$COMPOSE_VERSION \ + && rm -rf /var/cache/apk/* + +WORKDIR /data +VOLUME /data + ENTRYPOINT ["docker-compose"] From 7f82a2857264b4d0929c931c0ae6d6d318ce4bbd Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 26 Sep 2017 17:19:29 -0700 Subject: [PATCH 3013/4072] Revert 3.4-beta temp rename Signed-off-by: Joffrey F --- ...nfig_schema_v3.4-beta.json => config_schema_v3.4.json} | 8 +++++--- compose/const.py | 2 +- docker-compose.spec | 4 ++-- 3 files changed, 8 insertions(+), 6 deletions(-) rename compose/config/{config_schema_v3.4-beta.json => config_schema_v3.4.json} (98%) diff --git a/compose/config/config_schema_v3.4-beta.json b/compose/config/config_schema_v3.4.json similarity index 98% rename from compose/config/config_schema_v3.4-beta.json rename to compose/config/config_schema_v3.4.json index cba063202f0..dae7d7d2345 100644 --- a/compose/config/config_schema_v3.4-beta.json +++ b/compose/config/config_schema_v3.4.json @@ -1,6 +1,7 @@ + { "$schema": "http://json-schema.org/draft-04/schema#", - "id": "config_schema_v3.4-beta.json", + "id": "config_schema_v3.4.json", "type": "object", "required": ["version"], @@ -316,7 +317,7 @@ "additionalProperties": false, "properties": { "disable": {"type": "boolean"}, - "interval": {"type": "string"}, + "interval": {"type": "string", "format": "duration"}, "retries": {"type": "number"}, "test": { "oneOf": [ @@ -324,7 +325,8 @@ {"type": "array", "items": {"type": "string"}} ] }, - "timeout": {"type": "string"} + "timeout": {"type": "string", "format": "duration"}, + "start_period": {"type": "string", "format": "duration"} } }, "deployment": { diff --git a/compose/const.py b/compose/const.py index 809f7c7d4e7..b5970f82ae9 100644 --- a/compose/const.py +++ b/compose/const.py @@ -31,7 +31,7 @@ COMPOSEFILE_V3_1 = ComposeVersion('3.1') COMPOSEFILE_V3_2 = ComposeVersion('3.2') COMPOSEFILE_V3_3 = ComposeVersion('3.3') -COMPOSEFILE_V3_4 = ComposeVersion('3.4-beta') +COMPOSEFILE_V3_4 = ComposeVersion('3.4') API_VERSIONS = { COMPOSEFILE_V1: '1.21', diff --git a/docker-compose.spec b/docker-compose.spec index fe5651f6a2f..9c46421f0a5 100644 --- a/docker-compose.spec +++ b/docker-compose.spec @@ -63,8 +63,8 @@ exe = EXE(pyz, 'DATA' ), ( - 'compose/config/config_schema_v3.4-beta.json', - 'compose/config/config_schema_v3.4-beta.json', + 'compose/config/config_schema_v3.4.json', + 'compose/config/config_schema_v3.4.json', 'DATA' ), ( From 9430e5bf9d39bb72cd5f0914a1f52c3999234c08 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 27 Sep 2017 18:24:46 -0700 Subject: [PATCH 3014/4072] Avoid import ConfigurationError inside compose.utils (circular import) Signed-off-by: Joffrey F --- compose/config/config.py | 5 ++++- compose/utils.py | 3 +-- tests/unit/utils_test.py | 8 ++++++++ 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 0fddfd3a456..b90ab030588 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -762,7 +762,10 @@ def process_blkio_config(service_dict): for field in ['device_read_bps', 'device_write_bps']: if field in service_dict['blkio_config']: for v in service_dict['blkio_config'].get(field, []): - v['rate'] = parse_bytes(v.get('rate', 0)) + rate = v.get('rate', 0) + v['rate'] = parse_bytes(rate) + if v['rate'] is None: + raise ConfigurationError('Invalid format for bytes value: "{}"'.format(rate)) for field in ['device_read_iops', 'device_write_iops']: if field in service_dict['blkio_config']: diff --git a/compose/utils.py b/compose/utils.py index 1ede4d37d84..197ae6eb29a 100644 --- a/compose/utils.py +++ b/compose/utils.py @@ -12,7 +12,6 @@ from docker.errors import DockerException from docker.utils import parse_bytes as sdk_parse_bytes -from .config.errors import ConfigurationError from .errors import StreamParseError from .timeparse import MULTIPLIERS from .timeparse import timeparse @@ -143,4 +142,4 @@ def parse_bytes(n): try: return sdk_parse_bytes(n) except DockerException: - raise ConfigurationError('Invalid format for bytes value: {}'.format(n)) + return None diff --git a/tests/unit/utils_test.py b/tests/unit/utils_test.py index 85231957e3c..84becb97554 100644 --- a/tests/unit/utils_test.py +++ b/tests/unit/utils_test.py @@ -60,3 +60,11 @@ def test_with_leading_whitespace(self): {'three': 'four'}, {'x': 2} ] + + +class TestParseBytes(object): + def test_parse_bytes(self): + assert utils.parse_bytes('123kb') == 123 * 1024 + assert utils.parse_bytes(123) == 123 + assert utils.parse_bytes('foobar') is None + assert utils.parse_bytes('123') == 123 From dc838067fd4ccdf381108acb03bac261924fd297 Mon Sep 17 00:00:00 2001 From: Marc van den Hoogen Date: Fri, 18 Aug 2017 13:40:11 +0200 Subject: [PATCH 3015/4072] Add shm_size to build-options (issue #3866) * Add shm_size to build configuration * Make it possible to enlarge/customize shm size during build * Value in bytes, or use string like "512M" or "1G" ... * Add to compose format 2.3 and (provisionally) >=3.5 format * Add automated test for shm_size in build-opts Signed-off-by: Marc van den Hoogen Made unit tests compatible with previously added shm_size build-option Signed-off-by: Marc van den Hoogen Also support shm_size build-opt when conf override Signed-off-by: Marc van den Hoogen Automated test for shm_size build-option Signed-off-by: Marc van den Hoogen Schema 3.4, add shm_size to schema 2.3, updated const.py Signed-off-by: Marc van den Hoogen Corrected typo in config_schema_v3.4 Signed-off-by: Marc van den Hoogen Add support for g/m/k units for shm_size in build-opts Signed-off-by: Marc van den Hoogen Reorder imports in service.py Signed-off-by: Marc van den Hoogen --- compose/config/config.py | 1 + compose/config/config_schema_v2.3.json | 3 +- compose/config/config_schema_v3.5.json | 542 ++++++++++++++++++ compose/const.py | 3 + compose/service.py | 2 + tests/acceptance/cli_test.py | 6 + tests/fixtures/build-shm-size/Dockerfile | 4 + .../build-shm-size/docker-compose.yml | 7 + tests/unit/service_test.py | 2 + 9 files changed, 569 insertions(+), 1 deletion(-) create mode 100644 compose/config/config_schema_v3.5.json create mode 100644 tests/fixtures/build-shm-size/Dockerfile create mode 100644 tests/fixtures/build-shm-size/docker-compose.yml diff --git a/compose/config/config.py b/compose/config/config.py index b90ab030588..f16dd01b3f5 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -1020,6 +1020,7 @@ def to_dict(service): md.merge_scalar('dockerfile') md.merge_scalar('network') md.merge_scalar('target') + md.merge_scalar('shm_size') md.merge_mapping('args', parse_build_arguments) md.merge_field('cache_from', merge_unique_items_lists, default=[]) md.merge_mapping('labels', parse_labels) diff --git a/compose/config/config_schema_v2.3.json b/compose/config/config_schema_v2.3.json index a790bb4050c..ceaf44954eb 100644 --- a/compose/config/config_schema_v2.3.json +++ b/compose/config/config_schema_v2.3.json @@ -91,7 +91,8 @@ "labels": {"$ref": "#/definitions/list_or_dict"}, "cache_from": {"$ref": "#/definitions/list_of_strings"}, "network": {"type": "string"}, - "target": {"type": "string"} + "target": {"type": "string"}, + "shm_size": {"type": ["integer", "string"]} }, "additionalProperties": false } diff --git a/compose/config/config_schema_v3.5.json b/compose/config/config_schema_v3.5.json new file mode 100644 index 00000000000..fa95d6a2457 --- /dev/null +++ b/compose/config/config_schema_v3.5.json @@ -0,0 +1,542 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "id": "config_schema_v3.5.json", + "type": "object", + "required": ["version"], + + "properties": { + "version": { + "type": "string" + }, + + "services": { + "id": "#/properties/services", + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "$ref": "#/definitions/service" + } + }, + "additionalProperties": false + }, + + "networks": { + "id": "#/properties/networks", + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "$ref": "#/definitions/network" + } + } + }, + + "volumes": { + "id": "#/properties/volumes", + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "$ref": "#/definitions/volume" + } + }, + "additionalProperties": false + }, + + "secrets": { + "id": "#/properties/secrets", + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "$ref": "#/definitions/secret" + } + }, + "additionalProperties": false + }, + + "configs": { + "id": "#/properties/configs", + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "$ref": "#/definitions/config" + } + }, + "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"}, + "labels": {"$ref": "#/definitions/list_or_dict"}, + "cache_from": {"$ref": "#/definitions/list_of_strings"}, + "network": {"type": "string"}, + "target": {"type": "string"}, + "shm_size": {"type": ["integer", "string"]} + }, + "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"}} + ] + }, + "configs": { + "type": "array", + "items": { + "oneOf": [ + {"type": "string"}, + { + "type": "object", + "properties": { + "source": {"type": "string"}, + "target": {"type": "string"}, + "uid": {"type": "string"}, + "gid": {"type": "string"}, + "mode": {"type": "number"} + } + } + ] + } + }, + "container_name": {"type": "string"}, + "credential_spec": {"type": "object", "properties": { + "file": {"type": "string"}, + "registry": {"type": "string"} + }}, + "depends_on": {"$ref": "#/definitions/list_of_strings"}, + "devices": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "dns": {"$ref": "#/definitions/string_or_list"}, + "dns_search": {"$ref": "#/definitions/string_or_list"}, + "domainname": {"type": "string"}, + "entrypoint": { + "oneOf": [ + {"type": "string"}, + {"type": "array", "items": {"type": "string"}} + ] + }, + "env_file": {"$ref": "#/definitions/string_or_list"}, + "environment": {"$ref": "#/definitions/list_or_dict"}, + + "expose": { + "type": "array", + "items": { + "type": ["string", "number"], + "format": "expose" + }, + "uniqueItems": true + }, + + "external_links": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "extra_hosts": {"$ref": "#/definitions/list_or_dict"}, + "healthcheck": {"$ref": "#/definitions/healthcheck"}, + "hostname": {"type": "string"}, + "image": {"type": "string"}, + "ipc": {"type": "string"}, + "labels": {"$ref": "#/definitions/list_or_dict"}, + "links": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + + "logging": { + "type": "object", + + "properties": { + "driver": {"type": "string"}, + "options": { + "type": "object", + "patternProperties": { + "^.+$": {"type": ["string", "number", "null"]} + } + } + }, + "additionalProperties": false + }, + + "mac_address": {"type": "string"}, + "network_mode": {"type": "string"}, + + "networks": { + "oneOf": [ + {"$ref": "#/definitions/list_of_strings"}, + { + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "oneOf": [ + { + "type": "object", + "properties": { + "aliases": {"$ref": "#/definitions/list_of_strings"}, + "ipv4_address": {"type": "string"}, + "ipv6_address": {"type": "string"} + }, + "additionalProperties": false + }, + {"type": "null"} + ] + } + }, + "additionalProperties": false + } + ] + }, + "pid": {"type": ["string", "null"]}, + + "ports": { + "type": "array", + "items": { + "oneOf": [ + {"type": "number", "format": "ports"}, + {"type": "string", "format": "ports"}, + { + "type": "object", + "properties": { + "mode": {"type": "string"}, + "target": {"type": "integer"}, + "published": {"type": "integer"}, + "protocol": {"type": "string"} + }, + "additionalProperties": false + } + ] + }, + "uniqueItems": true + }, + + "privileged": {"type": "boolean"}, + "read_only": {"type": "boolean"}, + "restart": {"type": "string"}, + "security_opt": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "shm_size": {"type": ["number", "string"]}, + "secrets": { + "type": "array", + "items": { + "oneOf": [ + {"type": "string"}, + { + "type": "object", + "properties": { + "source": {"type": "string"}, + "target": {"type": "string"}, + "uid": {"type": "string"}, + "gid": {"type": "string"}, + "mode": {"type": "number"} + } + } + ] + } + }, + "sysctls": {"$ref": "#/definitions/list_or_dict"}, + "stdin_open": {"type": "boolean"}, + "stop_grace_period": {"type": "string", "format": "duration"}, + "stop_signal": {"type": "string"}, + "tmpfs": {"$ref": "#/definitions/string_or_list"}, + "tty": {"type": "boolean"}, + "ulimits": { + "type": "object", + "patternProperties": { + "^[a-z]+$": { + "oneOf": [ + {"type": "integer"}, + { + "type":"object", + "properties": { + "hard": {"type": "integer"}, + "soft": {"type": "integer"} + }, + "required": ["soft", "hard"], + "additionalProperties": false + } + ] + } + } + }, + "user": {"type": "string"}, + "userns_mode": {"type": "string"}, + "volumes": { + "type": "array", + "items": { + "oneOf": [ + {"type": "string"}, + { + "type": "object", + "required": ["type"], + "properties": { + "type": {"type": "string"}, + "source": {"type": "string"}, + "target": {"type": "string"}, + "read_only": {"type": "boolean"}, + "consistency": {"type": "string"}, + "bind": { + "type": "object", + "properties": { + "propagation": {"type": "string"} + } + }, + "volume": { + "type": "object", + "properties": { + "nocopy": {"type": "boolean"} + } + } + } + } + ], + "uniqueItems": true + } + }, + "working_dir": {"type": "string"} + }, + "additionalProperties": false + }, + + "healthcheck": { + "id": "#/definitions/healthcheck", + "type": "object", + "additionalProperties": false, + "properties": { + "disable": {"type": "boolean"}, + "interval": {"type": "string"}, + "retries": {"type": "number"}, + "test": { + "oneOf": [ + {"type": "string"}, + {"type": "array", "items": {"type": "string"}} + ] + }, + "timeout": {"type": "string"} + } + }, + "deployment": { + "id": "#/definitions/deployment", + "type": ["object", "null"], + "properties": { + "mode": {"type": "string"}, + "endpoint_mode": {"type": "string"}, + "replicas": {"type": "integer"}, + "labels": {"$ref": "#/definitions/list_or_dict"}, + "update_config": { + "type": "object", + "properties": { + "parallelism": {"type": "integer"}, + "delay": {"type": "string", "format": "duration"}, + "failure_action": {"type": "string"}, + "monitor": {"type": "string", "format": "duration"}, + "max_failure_ratio": {"type": "number"}, + "order": {"type": "string", "enum": [ + "start-first", "stop-first" + ]} + }, + "additionalProperties": false + }, + "resources": { + "type": "object", + "properties": { + "limits": {"$ref": "#/definitions/resource"}, + "reservations": {"$ref": "#/definitions/resource"} + }, + "additionalProperties": false + }, + "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"}}, + "preferences": { + "type": "array", + "items": { + "type": "object", + "properties": { + "spread": {"type": "string"} + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + + "resource": { + "id": "#/definitions/resource", + "type": "object", + "properties": { + "cpus": {"type": "string"}, + "memory": {"type": "string"} + }, + "additionalProperties": false + }, + + "network": { + "id": "#/definitions/network", + "type": ["object", "null"], + "properties": { + "driver": {"type": "string"}, + "driver_opts": { + "type": "object", + "patternProperties": { + "^.+$": {"type": ["string", "number"]} + } + }, + "ipam": { + "type": "object", + "properties": { + "driver": {"type": "string"}, + "config": { + "type": "array", + "items": { + "type": "object", + "properties": { + "subnet": {"type": "string"} + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + }, + "external": { + "type": ["boolean", "object"], + "properties": { + "name": {"type": "string"} + }, + "additionalProperties": false + }, + "internal": {"type": "boolean"}, + "attachable": {"type": "boolean"}, + "labels": {"$ref": "#/definitions/list_or_dict"} + }, + "additionalProperties": false + }, + + "volume": { + "id": "#/definitions/volume", + "type": ["object", "null"], + "properties": { + "name": {"type": "string"}, + "driver": {"type": "string"}, + "driver_opts": { + "type": "object", + "patternProperties": { + "^.+$": {"type": ["string", "number"]} + } + }, + "external": { + "type": ["boolean", "object"], + "properties": { + "name": {"type": "string"} + }, + "additionalProperties": false + }, + "labels": {"$ref": "#/definitions/list_or_dict"} + }, + "additionalProperties": false + }, + + "secret": { + "id": "#/definitions/secret", + "type": "object", + "properties": { + "file": {"type": "string"}, + "external": { + "type": ["boolean", "object"], + "properties": { + "name": {"type": "string"} + } + }, + "labels": {"$ref": "#/definitions/list_or_dict"} + }, + "additionalProperties": false + }, + + "config": { + "id": "#/definitions/config", + "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"] + } + } + } + } + } +} diff --git a/compose/const.py b/compose/const.py index b5970f82ae9..2ac08b89a70 100644 --- a/compose/const.py +++ b/compose/const.py @@ -32,6 +32,7 @@ COMPOSEFILE_V3_2 = ComposeVersion('3.2') COMPOSEFILE_V3_3 = ComposeVersion('3.3') COMPOSEFILE_V3_4 = ComposeVersion('3.4') +COMPOSEFILE_V3_5 = ComposeVersion('3.5') API_VERSIONS = { COMPOSEFILE_V1: '1.21', @@ -44,6 +45,7 @@ COMPOSEFILE_V3_2: '1.25', COMPOSEFILE_V3_3: '1.30', COMPOSEFILE_V3_4: '1.30', + COMPOSEFILE_V3_5: '1.30', } API_VERSION_TO_ENGINE_VERSION = { @@ -57,4 +59,5 @@ API_VERSIONS[COMPOSEFILE_V3_2]: '1.13.0', API_VERSIONS[COMPOSEFILE_V3_3]: '17.06.0', API_VERSIONS[COMPOSEFILE_V3_4]: '17.06.0', + API_VERSIONS[COMPOSEFILE_V3_5]: '17.06.0', } diff --git a/compose/service.py b/compose/service.py index 2829240f256..28c03276365 100644 --- a/compose/service.py +++ b/compose/service.py @@ -43,6 +43,7 @@ from .progress_stream import stream_output from .progress_stream import StreamOutputError from .utils import json_hash +from .utils import parse_bytes from .utils import parse_seconds_float @@ -916,6 +917,7 @@ def build(self, no_cache=False, pull=False, force_rm=False, build_args_override= buildargs=build_args, network_mode=build_opts.get('network', None), target=build_opts.get('target', None), + shmsize=parse_bytes(build_opts.get('shm_size')) if build_opts.get('shm_size') else None, ) try: diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index b8cece49530..d84c471539d 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -527,6 +527,12 @@ def test_build_failed_forcerm(self): ] assert not containers + def test_build_shm_size_build_option(self): + pull_busybox(self.client) + self.base_dir = 'tests/fixtures/build-shm-size' + result = self.dispatch(['build', '--no-cache'], None) + assert 'shm_size: 96' in result.stdout + def test_bundle_with_digests(self): self.base_dir = 'tests/fixtures/bundle-with-digests/' tmpdir = py.test.ensuretemp('cli_test_bundle') diff --git a/tests/fixtures/build-shm-size/Dockerfile b/tests/fixtures/build-shm-size/Dockerfile new file mode 100644 index 00000000000..f91733d6301 --- /dev/null +++ b/tests/fixtures/build-shm-size/Dockerfile @@ -0,0 +1,4 @@ +FROM busybox + +# Report the shm_size (through the size of /dev/shm) +RUN echo "shm_size:" $(df -h /dev/shm | tail -n 1 | awk '{print $2}') diff --git a/tests/fixtures/build-shm-size/docker-compose.yml b/tests/fixtures/build-shm-size/docker-compose.yml new file mode 100644 index 00000000000..238a513223b --- /dev/null +++ b/tests/fixtures/build-shm-size/docker-compose.yml @@ -0,0 +1,7 @@ +version: '3.5' + +services: + custom_shm_size: + build: + context: . + shm_size: 100663296 # =96M diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 0293695abbb..43ccf081c30 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -475,6 +475,7 @@ def test_create_container(self): cache_from=None, network_mode=None, target=None, + shmsize=None, ) def test_ensure_image_exists_no_build(self): @@ -515,6 +516,7 @@ def test_ensure_image_exists_force_build(self): cache_from=None, network_mode=None, target=None, + shmsize=None ) def test_build_does_not_pull(self): From aab0891a078f13ef03b405d3827a5cd265ca4486 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 13 Oct 2017 15:24:34 -0700 Subject: [PATCH 3016/4072] Temporary xfails for engine bug Signed-off-by: Joffrey F --- tests/acceptance/cli_test.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index d84c471539d..bd09664cbe8 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -14,7 +14,7 @@ from collections import namedtuple from operator import attrgetter -import py +import pytest import six import yaml from docker import errors @@ -500,6 +500,7 @@ def test_build_no_cache_pull(self): assert BUILD_CACHE_TEXT not in result.stdout assert BUILD_PULL_TEXT in result.stdout + @pytest.mark.xfail(reason='17.10.0 RC bug remove after GA https://github.com/moby/moby/issues/35116') def test_build_failed(self): self.base_dir = 'tests/fixtures/simple-failing-dockerfile' self.dispatch(['build', 'simple'], returncode=1) @@ -513,6 +514,7 @@ def test_build_failed(self): ] assert len(containers) == 1 + @pytest.mark.xfail(reason='17.10.0 RC bug remove after GA https://github.com/moby/moby/issues/35116') def test_build_failed_forcerm(self): self.base_dir = 'tests/fixtures/simple-failing-dockerfile' self.dispatch(['build', '--force-rm', 'simple'], returncode=1) @@ -535,7 +537,7 @@ def test_build_shm_size_build_option(self): def test_bundle_with_digests(self): self.base_dir = 'tests/fixtures/bundle-with-digests/' - tmpdir = py.test.ensuretemp('cli_test_bundle') + tmpdir = pytest.ensuretemp('cli_test_bundle') self.addCleanup(tmpdir.remove) filename = str(tmpdir.join('example.dab')) @@ -1399,7 +1401,7 @@ def test_run_without_command(self): [u'/bin/true'], ) - @py.test.mark.skipif(SWARM_SKIP_RM_VOLUMES, reason='Swarm DELETE /containers/ bug') + @pytest.mark.skipif(SWARM_SKIP_RM_VOLUMES, reason='Swarm DELETE /containers/ bug') def test_run_rm(self): self.base_dir = 'tests/fixtures/volume' proc = start_process(self.base_dir, ['run', '--rm', 'test']) From 53e0378379c381b4a67e66b719b4c16e97973eab Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 27 Sep 2017 17:10:13 -0700 Subject: [PATCH 3017/4072] Mount with same container path and different mode should override Signed-off-by: Joffrey F --- compose/config/config.py | 35 ++++++++++++++++++++--------- tests/unit/config/config_test.py | 38 +++++++++++++++++++++++++++++--- 2 files changed, 60 insertions(+), 13 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index f16dd01b3f5..948e2376e97 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -1137,24 +1137,30 @@ def resolve_volume_paths(working_dir, service_dict): def resolve_volume_path(working_dir, volume): + mount_params = None if isinstance(volume, dict): - host_path = volume.get('source') container_path = volume.get('target') + host_path = volume.get('source') + mode = None if host_path: if volume.get('read_only'): - container_path += ':ro' + mode = 'ro' if volume.get('volume', {}).get('nocopy'): - container_path += ':nocopy' + mode = 'nocopy' + mount_params = (host_path, mode) else: - container_path, host_path = split_path_mapping(volume) + container_path, mount_params = split_path_mapping(volume) - if host_path is not None: + if mount_params is not None: + host_path, mode = mount_params + if host_path is None: + return container_path if host_path.startswith('.'): host_path = expand_path(working_dir, host_path) host_path = os.path.expanduser(host_path) - return u"{}:{}".format(host_path, container_path) - else: - return container_path + return u"{}:{}{}".format(host_path, container_path, (':' + mode if mode else '')) + + return container_path def normalize_build(service_dict, working_dir, environment): @@ -1234,7 +1240,12 @@ def split_path_mapping(volume_path): if ':' in volume_config: (host, container) = volume_config.split(':', 1) - return (container, drive + host) + container_drive, container_path = splitdrive(container) + mode = None + if ':' in container_path: + container_path, mode = container_path.rsplit(':', 1) + + return (container_drive + container_path, (drive + host, mode)) else: return (volume_path, None) @@ -1246,7 +1257,11 @@ def join_path_mapping(pair): elif host is None: return container else: - return ":".join((host, container)) + host, mode = host + result = ":".join((host, container)) + if mode: + result += ":" + mode + return result def expand_path(working_dir, path): diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index de9a6130257..c5e40130d79 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1101,6 +1101,38 @@ def test_load_with_multiple_files_v3_2(self): ['/anonymous', '/c:/b:rw', 'vol:/x:ro'] ) + @mock.patch.dict(os.environ) + def test_volume_mode_override(self): + os.environ['COMPOSE_CONVERT_WINDOWS_PATHS'] = 'true' + base_file = config.ConfigFile( + 'base.yaml', + { + 'version': '2.3', + 'services': { + 'web': { + 'image': 'example/web', + 'volumes': ['/c:/b:rw'] + } + }, + } + ) + + override_file = config.ConfigFile( + 'override.yaml', + { + 'version': '2.3', + 'services': { + 'web': { + 'volumes': ['/c:/b:ro'] + } + } + } + ) + details = config.ConfigDetails('.', [base_file, override_file]) + service_dicts = config.load(details).services + svc_volumes = list(map(lambda v: v.repr(), service_dicts[0]['volumes'])) + assert svc_volumes == ['/c:/b:ro'] + def test_undeclared_volume_v2(self): base_file = config.ConfigFile( 'base.yaml', @@ -4018,7 +4050,7 @@ class VolumePathTest(unittest.TestCase): 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" - expected_mapping = ("/opt/connect/config:ro", host_path) + expected_mapping = ("/opt/connect/config", (host_path, 'ro')) mapping = config.split_path_mapping(windows_volume_path) assert mapping == expected_mapping @@ -4026,7 +4058,7 @@ def test_split_path_mapping_with_windows_path(self): 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) + expected_mapping = (container_path, (host_path, None)) mapping = config.split_path_mapping('{0}:{1}'.format(host_path, container_path)) assert mapping == expected_mapping @@ -4034,7 +4066,7 @@ def test_split_path_mapping_with_windows_path_in_container(self): def test_split_path_mapping_with_root_mount(self): host_path = '/' container_path = '/var/hostroot' - expected_mapping = (container_path, host_path) + expected_mapping = (container_path, (host_path, None)) mapping = config.split_path_mapping('{0}:{1}'.format(host_path, container_path)) assert mapping == expected_mapping From edcb8aea7bd5879df8376be0daeacd4b9e4e70b5 Mon Sep 17 00:00:00 2001 From: Andrea Giardini Date: Wed, 20 Sep 2017 23:05:29 +0200 Subject: [PATCH 3018/4072] Fix secret location with absolute paths Signed-off-by: Andrea Giardini --- compose/service.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/compose/service.py b/compose/service.py index 28c03276365..aecafc8ca46 100644 --- a/compose/service.py +++ b/compose/service.py @@ -881,9 +881,12 @@ def _get_container_host_config(self, override_options, one_off=False): def get_secret_volumes(self): def build_spec(secret): - target = '{}/{}'.format( - const.SECRETS_PATH, - secret['secret'].target or secret['secret'].source) + if secret['secret'].target is not None and secret['secret'].target.startswith('/'): + target = secret['secret'].target + else: + 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] From 08714ef79649ee9c3c7252f23b9194a34e18d542 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 13 Oct 2017 17:02:01 -0700 Subject: [PATCH 3019/4072] Add get_secret_volumes unit tests Signed-off-by: Joffrey F --- compose/service.py | 12 ++++----- tests/unit/service_test.py | 55 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 6 deletions(-) diff --git a/compose/service.py b/compose/service.py index aecafc8ca46..1a18c66549d 100644 --- a/compose/service.py +++ b/compose/service.py @@ -881,12 +881,12 @@ def _get_container_host_config(self, override_options, one_off=False): def get_secret_volumes(self): def build_spec(secret): - if secret['secret'].target is not None and secret['secret'].target.startswith('/'): - target = secret['secret'].target - else: - target = '{}/{}'.format( - const.SECRETS_PATH, - secret['secret'].target or secret['secret'].source) + target = secret['secret'].target + if target is None: + target = '{}/{}'.format(const.SECRETS_PATH, secret['secret'].source) + elif not os.path.isabs(target): + target = '{}/{}'.format(const.SECRETS_PATH, target) + return VolumeSpec(secret['file'], target, 'ro') return [build_spec(secret) for secret in self.secrets] diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 43ccf081c30..7d61807ba00 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -9,12 +9,14 @@ from .. import unittest from compose.config.errors import DependencyError from compose.config.types import ServicePort +from compose.config.types import ServiceSecret from compose.config.types import VolumeFromSpec from compose.config.types import VolumeSpec from compose.const import LABEL_CONFIG_HASH from compose.const import LABEL_ONE_OFF from compose.const import LABEL_PROJECT from compose.const import LABEL_SERVICE +from compose.const import SECRETS_PATH from compose.container import Container from compose.project import OneOffFilter from compose.service import build_ulimits @@ -1089,3 +1091,56 @@ def test_create_with_special_volume_mode(self): self.assertEqual( self.mock_client.create_host_config.call_args[1]['binds'], [volume]) + + +class ServiceSecretTest(unittest.TestCase): + def setUp(self): + self.mock_client = mock.create_autospec(docker.APIClient) + + def test_get_secret_volumes(self): + secret1 = { + 'secret': ServiceSecret.parse({'source': 'secret1', 'target': 'b.txt'}), + 'file': 'a.txt' + } + service = Service( + 'web', + client=self.mock_client, + image='busybox', + secrets=[secret1] + ) + volumes = service.get_secret_volumes() + + assert volumes[0].external == secret1['file'] + assert volumes[0].internal == '{}/{}'.format(SECRETS_PATH, secret1['secret'].target) + + def test_get_secret_volumes_abspath(self): + secret1 = { + 'secret': ServiceSecret.parse({'source': 'secret1', 'target': '/d.txt'}), + 'file': 'c.txt' + } + service = Service( + 'web', + client=self.mock_client, + image='busybox', + secrets=[secret1] + ) + volumes = service.get_secret_volumes() + + assert volumes[0].external == secret1['file'] + assert volumes[0].internal == secret1['secret'].target + + def test_get_secret_volumes_no_target(self): + secret1 = { + 'secret': ServiceSecret.parse({'source': 'secret1'}), + 'file': 'c.txt' + } + service = Service( + 'web', + client=self.mock_client, + image='busybox', + secrets=[secret1] + ) + volumes = service.get_secret_volumes() + + assert volumes[0].external == secret1['file'] + assert volumes[0].internal == '{}/{}'.format(SECRETS_PATH, secret1['secret'].source) From 6de646d3b01e642877e298afea60b563ab71dbe2 Mon Sep 17 00:00:00 2001 From: Guillermo Arribas Date: Fri, 6 Oct 2017 19:12:59 -0300 Subject: [PATCH 3020/4072] Build labels option: array form produces unmarshal error (fixes #5183) Signed-off-by: Guillermo Arribas --- compose/service.py | 3 ++- tests/integration/service_test.py | 19 ++++++++++++++++++- tests/unit/service_test.py | 4 ++-- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/compose/service.py b/compose/service.py index 1a18c66549d..e2f72aa5ac3 100644 --- a/compose/service.py +++ b/compose/service.py @@ -23,6 +23,7 @@ from . import progress_stream from .config import DOCKER_CONFIG_KEYS from .config import merge_environment +from .config.config import parse_labels from .config.errors import DependencyError from .config.types import ServicePort from .config.types import VolumeSpec @@ -916,7 +917,7 @@ def build(self, no_cache=False, pull=False, force_rm=False, build_args_override= nocache=no_cache, dockerfile=build_opts.get('dockerfile', None), cache_from=build_opts.get('cache_from', None), - labels=build_opts.get('labels', None), + labels=parse_labels(build_opts.get('labels', None)), buildargs=build_args, network_mode=build_opts.get('network', None), target=build_opts.get('target', None), diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 84b54fe419d..a71bc407c1e 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -761,7 +761,7 @@ def test_build_with_build_args_override(self): assert service.image() assert "build_version=2" in service.image()['ContainerConfig']['Cmd'] - def test_build_with_build_labels(self): + def test_build_with_build_labels_dict(self): base_dir = tempfile.mkdtemp() self.addCleanup(shutil.rmtree, base_dir) @@ -778,6 +778,23 @@ def test_build_with_build_labels(self): assert service.image() assert service.image()['Config']['Labels']['com.docker.compose.test'] == 'true' + def test_build_with_build_labels_list(self): + base_dir = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, base_dir) + + with open(os.path.join(base_dir, 'Dockerfile'), 'w') as f: + f.write('FROM busybox\n') + + service = self.create_service('buildlabels', build={ + 'context': text_type(base_dir), + 'labels': ['com.docker.compose.test=true'] + }) + service.build() + self.addCleanup(self.client.remove_image, service.image_name) + + assert service.image() + assert service.image()['Config']['Labels']['com.docker.compose.test'] == 'true' + @no_cluster('Container networks not on Swarm') def test_build_with_network(self): base_dir = tempfile.mkdtemp() diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 7d61807ba00..5c5c2bf677e 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -473,7 +473,7 @@ def test_create_container(self): nocache=False, rm=True, buildargs={}, - labels=None, + labels={}, cache_from=None, network_mode=None, target=None, @@ -514,7 +514,7 @@ def test_ensure_image_exists_force_build(self): nocache=False, rm=True, buildargs={}, - labels=None, + labels={}, cache_from=None, network_mode=None, target=None, From 57eb1c463f6f6f6be98c0f8371b69352e45d29c6 Mon Sep 17 00:00:00 2001 From: Guillermo Arribas Date: Tue, 10 Oct 2017 11:55:14 -0300 Subject: [PATCH 3021/4072] Progress markers are not shown correctly for docker-compose up (fixes #4801) Signed-off-by: Guillermo Arribas --- compose/parallel.py | 23 ++++++++++++++++------- compose/project.py | 25 ++++++++++++++++++++++++- compose/service.py | 23 ++++++++++++----------- tests/unit/service_test.py | 2 +- 4 files changed, 53 insertions(+), 20 deletions(-) diff --git a/compose/parallel.py b/compose/parallel.py index d455711ddc9..f271561ff05 100644 --- a/compose/parallel.py +++ b/compose/parallel.py @@ -26,7 +26,7 @@ STOP = object() -def parallel_execute(objects, func, get_name, msg, get_deps=None, limit=None): +def parallel_execute(objects, func, get_name, msg, get_deps=None, limit=None, parent_objects=None): """Runs func on objects in parallel while ensuring that func is ran on object only after it is ran on all its dependencies. @@ -37,9 +37,19 @@ def parallel_execute(objects, func, get_name, msg, get_deps=None, limit=None): stream = get_output_stream(sys.stderr) writer = ParallelStreamWriter(stream, msg) - for obj in objects: + + if parent_objects: + display_objects = list(parent_objects) + else: + display_objects = objects + + for obj in display_objects: writer.add_object(get_name(obj)) - writer.write_initial() + + # write data in a second loop to consider all objects for width alignment + # and avoid duplicates when parent_objects exists + for obj in objects: + writer.write_initial(get_name(obj)) events = parallel_execute_iter(objects, func, get_deps, limit) @@ -237,12 +247,11 @@ def add_object(self, obj_index): self.lines.append(obj_index) self.width = max(self.width, len(obj_index)) - def write_initial(self): + def write_initial(self, obj_index): if self.msg is None: return - for line in self.lines: - self.stream.write("{} {:<{width}} ... \r\n".format(self.msg, line, - width=self.width)) + self.stream.write("{} {:<{width}} ... \r\n".format( + self.msg, self.lines[self.lines.index(obj_index)], width=self.width)) self.stream.flush() def _write_ansi(self, obj_index, status): diff --git a/compose/project.py b/compose/project.py index c8b57edd209..f6bd30a8869 100644 --- a/compose/project.py +++ b/compose/project.py @@ -29,6 +29,7 @@ from .service import NetworkMode from .service import PidMode from .service import Service +from .service import ServiceName from .service import ServiceNetworkMode from .service import ServicePidMode from .utils import microseconds_from_time_nano @@ -190,6 +191,25 @@ def get_services_without_duplicate(self, service_names=None, include_deps=False) service.remove_duplicate_containers() return services + def get_scaled_services(self, services, scale_override): + """ + Returns a list of this project's services as scaled ServiceName objects. + + services: a list of Service objects + scale_override: a dict with the scale to apply to each service (k: service_name, v: scale) + """ + service_names = [] + for service in services: + if service.name in scale_override: + scale = scale_override[service.name] + else: + scale = service.scale_num + + for i in range(1, scale + 1): + service_names.append(ServiceName(self.name, service.name, i)) + + return service_names + def get_links(self, service_dict): links = [] if 'links' in service_dict: @@ -430,15 +450,18 @@ def up(self, for svc in services: svc.ensure_image_exists(do_build=do_build) plans = self._get_convergence_plans(services, strategy) + scaled_services = self.get_scaled_services(services, scale_override) def do(service): + return service.execute_convergence_plan( plans[service.name], timeout=timeout, detached=detached, scale_override=scale_override.get(service.name), rescale=rescale, - start=start + start=start, + project_services=scaled_services ) def get_deps(service): diff --git a/compose/service.py b/compose/service.py index 1a18c66549d..48d428cb827 100644 --- a/compose/service.py +++ b/compose/service.py @@ -378,11 +378,11 @@ def _containers_have_diverged(self, containers): return has_diverged - def _execute_convergence_create(self, scale, detached, start): + def _execute_convergence_create(self, scale, detached, start, project_services=None): i = self._next_container_number() def create_and_start(service, n): - container = service.create_container(number=n) + container = service.create_container(number=n, quiet=True) if not detached: container.attach_log_stream() if start: @@ -390,10 +390,11 @@ def create_and_start(service, n): return container containers, errors = parallel_execute( - range(i, i + scale), - lambda n: create_and_start(self, n), - lambda n: self.get_container_name(n), + [ServiceName(self.project, self.name, index) for index in range(i, i + scale)], + lambda service_name: create_and_start(self, service_name.number), + lambda service_name: self.get_container_name(service_name.service, service_name.number), "Creating", + parent_objects=project_services ) for error in errors.values(): raise OperationFailedError(error) @@ -432,7 +433,7 @@ def _execute_convergence_start(self, containers, scale, timeout, detached, start if start: _, errors = parallel_execute( containers, - lambda c: self.start_container_if_stopped(c, attach_logs=not detached), + lambda c: self.start_container_if_stopped(c, attach_logs=not detached, quiet=True), lambda c: c.name, "Starting", ) @@ -459,7 +460,7 @@ def stop_and_remove(container): ) def execute_convergence_plan(self, plan, timeout=None, detached=False, - start=True, scale_override=None, rescale=True): + start=True, scale_override=None, rescale=True, project_services=None): (action, containers) = plan scale = scale_override if scale_override is not None else self.scale_num containers = sorted(containers, key=attrgetter('number')) @@ -468,7 +469,7 @@ def execute_convergence_plan(self, plan, timeout=None, detached=False, if action == 'create': return self._execute_convergence_create( - scale, detached, start + scale, detached, start, project_services ) # The create action needs always needs an initial scale, but otherwise, @@ -741,7 +742,7 @@ def _get_container_create_options( container_options.update(override_options) if not container_options.get('name'): - container_options['name'] = self.get_container_name(number, one_off) + container_options['name'] = self.get_container_name(self.name, number, one_off) container_options.setdefault('detach', True) @@ -960,12 +961,12 @@ def labels(self, one_off=False): def custom_container_name(self): return self.options.get('container_name') - def get_container_name(self, number, one_off=False): + def get_container_name(self, service_name, number, one_off=False): if self.custom_container_name and not one_off: return self.custom_container_name container_name = build_container_name( - self.project, self.name, number, one_off, + self.project, service_name, number, one_off, ) ext_links_origins = [l.split(':')[0] for l in self.options.get('external_links', [])] if container_name in ext_links_origins: diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 7d61807ba00..0bf0280de2e 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -179,7 +179,7 @@ def test_self_reference_external_link(self): external_links=['default_foo_1'] ) with self.assertRaises(DependencyError): - service.get_container_name(1) + service.get_container_name('foo', 1) def test_mem_reservation(self): self.mock_client.create_host_config.return_value = {} From 7df1b53ad2708ebcf57858fd93867be601c2d733 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 16 Oct 2017 11:43:06 -0700 Subject: [PATCH 3022/4072] Move build labels parsing to config module Signed-off-by: Joffrey F --- compose/config/config.py | 12 ++++++------ compose/service.py | 3 +-- tests/integration/service_test.py | 19 +------------------ tests/unit/config/config_test.py | 24 +++++++++++++++++++++++- tests/unit/service_test.py | 4 ++-- 5 files changed, 33 insertions(+), 29 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 948e2376e97..68b2be3a61e 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -707,16 +707,16 @@ def process_service(service_config): if 'build' in service_dict: if isinstance(service_dict['build'], six.string_types): service_dict['build'] = resolve_build_path(working_dir, service_dict['build']) - elif isinstance(service_dict['build'], dict) and 'context' in service_dict['build']: - path = service_dict['build']['context'] - service_dict['build']['context'] = resolve_build_path(working_dir, path) + elif isinstance(service_dict['build'], dict): + if 'context' in service_dict['build']: + path = service_dict['build']['context'] + service_dict['build']['context'] = resolve_build_path(working_dir, path) + if 'labels' in service_dict['build']: + service_dict['build']['labels'] = parse_labels(service_dict['build']['labels']) if 'volumes' in service_dict and service_dict.get('volume_driver') is None: service_dict['volumes'] = resolve_volume_paths(working_dir, service_dict) - if 'labels' in service_dict: - service_dict['labels'] = parse_labels(service_dict['labels']) - if 'sysctls' in service_dict: service_dict['sysctls'] = build_string_dict(parse_sysctls(service_dict['sysctls'])) diff --git a/compose/service.py b/compose/service.py index e2f72aa5ac3..1a18c66549d 100644 --- a/compose/service.py +++ b/compose/service.py @@ -23,7 +23,6 @@ from . import progress_stream from .config import DOCKER_CONFIG_KEYS from .config import merge_environment -from .config.config import parse_labels from .config.errors import DependencyError from .config.types import ServicePort from .config.types import VolumeSpec @@ -917,7 +916,7 @@ def build(self, no_cache=False, pull=False, force_rm=False, build_args_override= nocache=no_cache, dockerfile=build_opts.get('dockerfile', None), cache_from=build_opts.get('cache_from', None), - labels=parse_labels(build_opts.get('labels', None)), + labels=build_opts.get('labels', None), buildargs=build_args, network_mode=build_opts.get('network', None), target=build_opts.get('target', None), diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index a71bc407c1e..84b54fe419d 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -761,7 +761,7 @@ def test_build_with_build_args_override(self): assert service.image() assert "build_version=2" in service.image()['ContainerConfig']['Cmd'] - def test_build_with_build_labels_dict(self): + def test_build_with_build_labels(self): base_dir = tempfile.mkdtemp() self.addCleanup(shutil.rmtree, base_dir) @@ -778,23 +778,6 @@ def test_build_with_build_labels_dict(self): assert service.image() assert service.image()['Config']['Labels']['com.docker.compose.test'] == 'true' - def test_build_with_build_labels_list(self): - base_dir = tempfile.mkdtemp() - self.addCleanup(shutil.rmtree, base_dir) - - with open(os.path.join(base_dir, 'Dockerfile'), 'w') as f: - f.write('FROM busybox\n') - - service = self.create_service('buildlabels', build={ - 'context': text_type(base_dir), - 'labels': ['com.docker.compose.test=true'] - }) - service.build() - self.addCleanup(self.client.remove_image, service.image_name) - - assert service.image() - assert service.image()['Config']['Labels']['com.docker.compose.test'] == 'true' - @no_cluster('Container networks not on Swarm') def test_build_with_network(self): base_dir = tempfile.mkdtemp() diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index c5e40130d79..8f2266ed8d5 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -892,7 +892,7 @@ def test_load_with_buildargs(self): assert service['build']['args']['opt1'] == '42' assert service['build']['args']['opt2'] == 'foobar' - def test_load_with_build_labels(self): + def test_load_build_labels_dict(self): service = config.load( build_config_details( { @@ -919,6 +919,28 @@ def test_load_with_build_labels(self): assert service['build']['labels']['label1'] == 42 assert service['build']['labels']['label2'] == 'foobar' + def test_load_build_labels_list(self): + base_file = config.ConfigFile( + 'base.yml', + { + 'version': '2.3', + 'services': { + 'web': { + 'build': { + 'context': '.', + 'labels': ['foo=bar', 'baz=true', 'foobar=1'] + }, + }, + }, + } + ) + + details = config.ConfigDetails('.', [base_file]) + service = config.load(details).services[0] + assert service['build']['labels'] == { + 'foo': 'bar', 'baz': 'true', 'foobar': '1' + } + def test_build_args_allow_empty_properties(self): service = config.load( build_config_details( diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 5c5c2bf677e..7d61807ba00 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -473,7 +473,7 @@ def test_create_container(self): nocache=False, rm=True, buildargs={}, - labels={}, + labels=None, cache_from=None, network_mode=None, target=None, @@ -514,7 +514,7 @@ def test_ensure_image_exists_force_build(self): nocache=False, rm=True, buildargs={}, - labels={}, + labels=None, cache_from=None, network_mode=None, target=None, From aaa0773b4b6ce44a3c1494d8eb2e25406f0593f9 Mon Sep 17 00:00:00 2001 From: Guillermo Arribas Date: Wed, 11 Oct 2017 13:56:15 -0300 Subject: [PATCH 3023/4072] Config command generates invalid volumes (fixes #5176) Signed-off-by: Guillermo Arribas --- compose/config/config.py | 19 +++---- compose/config/serialize.py | 4 +- tests/acceptance/cli_test.py | 54 +++++++++++++++++-- .../volumes/external-volumes-v2-x.yml | 17 ++++++ ...al-volumes.yml => external-volumes-v2.yml} | 2 +- .../volumes/external-volumes-v3-4.yml | 17 ++++++ .../volumes/external-volumes-v3-x.yml | 16 ++++++ 7 files changed, 114 insertions(+), 15 deletions(-) create mode 100644 tests/fixtures/volumes/external-volumes-v2-x.yml rename tests/fixtures/volumes/{external-volumes.yml => external-volumes-v2.yml} (92%) create mode 100644 tests/fixtures/volumes/external-volumes-v3-4.yml create mode 100644 tests/fixtures/volumes/external-volumes-v3-x.yml diff --git a/compose/config/config.py b/compose/config/config.py index 68b2be3a61e..7bb57076e10 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -15,6 +15,9 @@ from . import types from .. import const from ..const import COMPOSEFILE_V1 as V1 +from ..const import COMPOSEFILE_V2_1 as V2_1 +from ..const import COMPOSEFILE_V3_0 as V3_0 +from ..const import COMPOSEFILE_V3_4 as V3_4 from ..utils import build_string_dict from ..utils import parse_bytes from ..utils import parse_nanoseconds_int @@ -405,7 +408,7 @@ def load_mapping(config_files, get_func, entity_type, working_dir=None): external = config.get('external') if external: name_field = 'name' if entity_type == 'Volume' else 'external_name' - validate_external(entity_type, name, config) + validate_external(entity_type, name, config, config_file.version) if isinstance(external, dict): config[name_field] = external.get('name') elif not config.get('name'): @@ -425,14 +428,12 @@ def load_mapping(config_files, get_func, entity_type, working_dir=None): 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 validate_external(entity_type, name, config, version): + if (version < V2_1 or (version >= V3_0 and version < V3_4)) and len(config.keys()) > 1: + raise ConfigurationError( + "{} {} declared as external but specifies additional attributes " + "({}).".format( + entity_type, name, ', '.join(k for k in config if k != 'external'))) def load_services(config_details, config_file): diff --git a/compose/config/serialize.py b/compose/config/serialize.py index daddff69507..44878a47c61 100644 --- a/compose/config/serialize.py +++ b/compose/config/serialize.py @@ -9,7 +9,7 @@ from compose.const import COMPOSEFILE_V2_1 as V2_1 from compose.const import COMPOSEFILE_V3_0 as V3_0 from compose.const import COMPOSEFILE_V3_2 as V3_2 -from compose.const import COMPOSEFILE_V3_2 as V3_4 +from compose.const import COMPOSEFILE_V3_4 as V3_4 def serialize_config_type(dumper, data): @@ -66,7 +66,7 @@ def denormalize_config(config, image_digests=None): del conf['external_name'] if 'name' in conf: - if config.version < V2_1 or (config.version > V3_0 and config.version < V3_4): + if config.version < V2_1 or (config.version >= V3_0 and config.version < V3_4): del conf['name'] elif 'external' in conf: conf['external'] = True diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index bd09664cbe8..bc10de3e00a 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -285,15 +285,63 @@ def test_config_external_network(self): } } - def test_config_external_volume(self): + def test_config_external_volume_v2(self): self.base_dir = 'tests/fixtures/volumes' - result = self.dispatch(['-f', 'external-volumes.yml', 'config']) + result = self.dispatch(['-f', 'external-volumes-v2.yml', 'config']) json_result = yaml.load(result.stdout) assert 'volumes' in json_result assert json_result['volumes'] == { 'foo': { 'external': True, - 'name': 'foo', + }, + 'bar': { + 'external': { + 'name': 'some_bar', + }, + } + } + + def test_config_external_volume_v2_x(self): + self.base_dir = 'tests/fixtures/volumes' + result = self.dispatch(['-f', 'external-volumes-v2-x.yml', 'config']) + json_result = yaml.load(result.stdout) + assert 'volumes' in json_result + assert json_result['volumes'] == { + 'foo': { + 'external': True, + 'name': 'some_foo', + }, + 'bar': { + 'external': True, + 'name': 'some_bar', + } + } + + def test_config_external_volume_v3_x(self): + self.base_dir = 'tests/fixtures/volumes' + result = self.dispatch(['-f', 'external-volumes-v3-x.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_external_volume_v3_4(self): + self.base_dir = 'tests/fixtures/volumes' + result = self.dispatch(['-f', 'external-volumes-v3-4.yml', 'config']) + json_result = yaml.load(result.stdout) + assert 'volumes' in json_result + assert json_result['volumes'] == { + 'foo': { + 'external': True, + 'name': 'some_foo', }, 'bar': { 'external': True, diff --git a/tests/fixtures/volumes/external-volumes-v2-x.yml b/tests/fixtures/volumes/external-volumes-v2-x.yml new file mode 100644 index 00000000000..3b736c5f481 --- /dev/null +++ b/tests/fixtures/volumes/external-volumes-v2-x.yml @@ -0,0 +1,17 @@ +version: "2.1" + +services: + web: + image: busybox + command: top + volumes: + - foo:/var/lib/ + - bar:/etc/ + +volumes: + foo: + external: true + name: some_foo + bar: + external: + name: some_bar diff --git a/tests/fixtures/volumes/external-volumes.yml b/tests/fixtures/volumes/external-volumes-v2.yml similarity index 92% rename from tests/fixtures/volumes/external-volumes.yml rename to tests/fixtures/volumes/external-volumes-v2.yml index 05c6c4844fe..4025b53b19f 100644 --- a/tests/fixtures/volumes/external-volumes.yml +++ b/tests/fixtures/volumes/external-volumes-v2.yml @@ -1,4 +1,4 @@ -version: "2.1" +version: "2" services: web: diff --git a/tests/fixtures/volumes/external-volumes-v3-4.yml b/tests/fixtures/volumes/external-volumes-v3-4.yml new file mode 100644 index 00000000000..76c8421dc54 --- /dev/null +++ b/tests/fixtures/volumes/external-volumes-v3-4.yml @@ -0,0 +1,17 @@ +version: "3.4" + +services: + web: + image: busybox + command: top + volumes: + - foo:/var/lib/ + - bar:/etc/ + +volumes: + foo: + external: true + name: some_foo + bar: + external: + name: some_bar diff --git a/tests/fixtures/volumes/external-volumes-v3-x.yml b/tests/fixtures/volumes/external-volumes-v3-x.yml new file mode 100644 index 00000000000..903fee64728 --- /dev/null +++ b/tests/fixtures/volumes/external-volumes-v3-x.yml @@ -0,0 +1,16 @@ +version: "3.0" + +services: + web: + image: busybox + command: top + volumes: + - foo:/var/lib/ + - bar:/etc/ + +volumes: + foo: + external: true + bar: + external: + name: some_bar From d2cbf33412b7386237e870f9232f14c6353d279f Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 16 Oct 2017 12:41:29 -0700 Subject: [PATCH 3024/4072] Add specific handling for pywintypes.error Signed-off-by: Joffrey F --- compose/cli/errors.py | 20 ++++++++++++++++++++ tests/unit/cli/errors_test.py | 21 +++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/compose/cli/errors.py b/compose/cli/errors.py index 23e065c99b5..1506aa66078 100644 --- a/compose/cli/errors.py +++ b/compose/cli/errors.py @@ -57,6 +57,26 @@ def handle_connection_errors(client): except (ReadTimeout, socket.timeout) as e: log_timeout_error(client.timeout) raise ConnectionError() + except Exception as e: + if is_windows(): + import pywintypes + if isinstance(e, pywintypes.error): + log_windows_pipe_error(e) + raise ConnectionError() + raise + + +def log_windows_pipe_error(exc): + if exc.winerror == 232: # https://github.com/docker/compose/issues/5005 + log.error( + "The current Compose file version is not compatible with your engine version. " + "Please upgrade your Compose file to a more recent version, or set " + "a COMPOSE_API_VERSION in your environment." + ) + else: + log.error( + "Windows named pipe error: {} (code: {})".format(exc.strerror, exc.winerror) + ) def log_timeout_error(timeout): diff --git a/tests/unit/cli/errors_test.py b/tests/unit/cli/errors_test.py index 7406a88803f..68326d1c753 100644 --- a/tests/unit/cli/errors_test.py +++ b/tests/unit/cli/errors_test.py @@ -7,6 +7,7 @@ from compose.cli import errors from compose.cli.errors import handle_connection_errors +from compose.const import IS_WINDOWS_PLATFORM from tests import mock @@ -65,3 +66,23 @@ def test_api_error_version_other_unicode_explanation(self, mock_logging): raise APIError(None, None, msg) mock_logging.error.assert_called_once_with(msg) + + @pytest.mark.skipif(not IS_WINDOWS_PLATFORM, reason='Needs pywin32') + def test_windows_pipe_error_no_data(self, mock_logging): + import pywintypes + with pytest.raises(errors.ConnectionError): + with handle_connection_errors(mock.Mock(api_version='1.22')): + raise pywintypes.error(232, 'WriteFile', 'The pipe is being closed.') + + _, args, _ = mock_logging.error.mock_calls[0] + assert "The current Compose file version is not compatible with your engine version." in args[0] + + @pytest.mark.skipif(not IS_WINDOWS_PLATFORM, reason='Needs pywin32') + def test_windows_pipe_error_misc(self, mock_logging): + import pywintypes + with pytest.raises(errors.ConnectionError): + with handle_connection_errors(mock.Mock(api_version='1.22')): + raise pywintypes.error(231, 'WriteFile', 'The pipe is busy.') + + _, args, _ = mock_logging.error.mock_calls[0] + assert "Windows named pipe error: The pipe is busy. (code: 231)" == args[0] From f965d2b374d54fa231dfe66ce405029dff01f600 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 16 Oct 2017 13:54:30 -0700 Subject: [PATCH 3025/4072] Add check_duplicate=True when creating network 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 0f42eb20a11..2e0a7e6ecdb 100644 --- a/compose/network.py +++ b/compose/network.py @@ -79,6 +79,7 @@ def ensure(self): enable_ipv6=self.enable_ipv6, labels=self._labels, attachable=version_gte(self.client._version, '1.24') or None, + check_duplicate=True, ) def remove(self): From f64b48f0deb7e684d92dc82f752ff7edf47a6e02 Mon Sep 17 00:00:00 2001 From: Alexey Rokhin Date: Wed, 17 May 2017 15:21:35 +0300 Subject: [PATCH 3026/4072] Fix testcases.py formatting Signed-off-by: Alexey Rokhin --- tests/integration/testcases.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index b72fb53a81f..8435f97ddcf 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -75,6 +75,7 @@ def v2_1_only(): return min_version_skip(V2_1) + def v2_2_only(): return min_version_skip(V2_2) @@ -83,6 +84,7 @@ def v2_3_only(): return min_version_skip(V2_3) + def v3_only(): return min_version_skip(V3_0) From 390821e31c1425546892ca58667c11880fb3e66d Mon Sep 17 00:00:00 2001 From: Alexey Rokhin Date: Wed, 17 May 2017 16:18:28 +0300 Subject: [PATCH 3027/4072] skip cpu_percent test for Linux Signed-off-by: Alexey Rokhin --- 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 84b54fe419d..28cca4aad2e 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -28,6 +28,7 @@ from compose.const import LABEL_PROJECT from compose.const import LABEL_SERVICE from compose.const import LABEL_VERSION +from compose.const import IS_WINDOWS_PLATFORM from compose.container import Container from compose.errors import OperationFailedError from compose.project import OneOffFilter From 0367995b8fc7d8bf0e5bddaa6ec1f1a51d918dd6 Mon Sep 17 00:00:00 2001 From: Alexey Rokhin Date: Wed, 17 May 2017 16:42:43 +0300 Subject: [PATCH 3028/4072] service_test.py reorder imports Signed-off-by: Alexey Rokhin --- 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 28cca4aad2e..84b54fe419d 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -28,7 +28,6 @@ from compose.const import LABEL_PROJECT from compose.const import LABEL_SERVICE from compose.const import LABEL_VERSION -from compose.const import IS_WINDOWS_PLATFORM from compose.container import Container from compose.errors import OperationFailedError from compose.project import OneOffFilter From 2eed6939f19b6f7675c907c86ed9277013265e19 Mon Sep 17 00:00:00 2001 From: Joel Barciauskas Date: Wed, 12 Apr 2017 17:45:09 -0400 Subject: [PATCH 3029/4072] Add --quiet parameter to docker-compose pull, using existing silent flag Signed-off-by: Joel Barciauskas --- compose/project.py | 2 +- tests/acceptance/cli_test.py | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/compose/project.py b/compose/project.py index 2310a2fcc68..1faf97d40cd 100644 --- a/compose/project.py +++ b/compose/project.py @@ -496,7 +496,7 @@ def pull(self, service_names=None, ignore_pull_failures=False, parallel_pull=Fal if parallel_pull: def pull_service(service): - service.pull(ignore_pull_failures, True) + service.pull(ignore_pull_failures, True, silent=silent) _, errors = parallel.parallel_execute( services, diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 78d1c1eb181..e721b940f57 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -463,6 +463,10 @@ def test_pull_with_parallel_failure(self): re.compile('''^(ERROR: )?(b')?.* nonexisting-image''', re.MULTILINE)) + def test_pull_with_quiet(self): + assert self.dispatch(['pull', '--quiet']).stderr == '' + assert self.dispatch(['pull', '--quiet']).stdout == '' + def test_build_plain(self): self.base_dir = 'tests/fixtures/simple-dockerfile' self.dispatch(['build', 'simple']) From d960f5151efb738acb71d4ea196348d12d7707fa Mon Sep 17 00:00:00 2001 From: NikitaVlaznev Date: Mon, 19 Jun 2017 17:05:19 +0300 Subject: [PATCH 3030/4072] Fix double silent argument value Fix for "TypeError: pull() got multiple values for keyword argument 'silent'." This change https://github.com/docker/compose/commit/e9b6cc23fcf01d4768c7e082b7bc91b43ff84e7e caused additional value to be passed for the 'silent' argument, that was already passed there: https://github.com/docker/compose/commit/f85da99ef3273794e855afda8678174419d3bf4f Signed-off-by: Nikita Vlaznev --- compose/project.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/project.py b/compose/project.py index 1faf97d40cd..59d58f2f679 100644 --- a/compose/project.py +++ b/compose/project.py @@ -496,7 +496,7 @@ def pull(self, service_names=None, ignore_pull_failures=False, parallel_pull=Fal if parallel_pull: def pull_service(service): - service.pull(ignore_pull_failures, True, silent=silent) + service.pull(ignore_pull_failures, silent=silent) _, errors = parallel.parallel_execute( services, From 4bd2aa3d74c12625b06d1e403e1a1900eff7d11c Mon Sep 17 00:00:00 2001 From: Evan Shaw Date: Sat, 1 Jul 2017 13:40:02 +1200 Subject: [PATCH 3031/4072] Always silence pull output with --parallel This is how things were prior to the addition of the --quiet flag. Making it not silent produces output that's weird and difficult to read. Signed-off-by: Evan Shaw --- compose/project.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/project.py b/compose/project.py index 59d58f2f679..2310a2fcc68 100644 --- a/compose/project.py +++ b/compose/project.py @@ -496,7 +496,7 @@ def pull(self, service_names=None, ignore_pull_failures=False, parallel_pull=Fal if parallel_pull: def pull_service(service): - service.pull(ignore_pull_failures, silent=silent) + service.pull(ignore_pull_failures, True) _, errors = parallel.parallel_execute( services, From 376389d7a5fdc29da56e79125728a6b4b071df67 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 28 Aug 2017 17:35:55 -0700 Subject: [PATCH 3032/4072] Add --no-start flag to up command. Deprecate create command. Signed-off-by: Joffrey F --- compose/cli/main.py | 18 ++++++++++++++++-- compose/project.py | 6 ++++-- tests/acceptance/cli_test.py | 25 +++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 4 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 83bc7d58cb0..21bf1f30887 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -319,6 +319,7 @@ def config(self, config_options, options): def create(self, options): """ Creates containers for a service. + This command is deprecated. Use the `up` command with `--no-start` instead. Usage: create [options] [SERVICE...] @@ -332,6 +333,11 @@ def create(self, options): """ service_names = options['SERVICE'] + log.warn( + 'The create command is deprecated. ' + 'Use the up command with the --no-start flag instead.' + ) + self.project.create( service_names=service_names, strategy=convergence_strategy_from_opts(options), @@ -902,6 +908,7 @@ def up(self, options): --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-start Don't start the services after creating them. --build Build images before starting containers. --abort-on-container-exit Stops all containers if any container was stopped. Incompatible with -d. @@ -922,10 +929,16 @@ def up(self, options): timeout = timeout_from_opts(options) remove_orphans = options['--remove-orphans'] detached = options.get('-d') + no_start = options.get('--no-start') - if detached and cascade_stop: + if detached and (cascade_stop or exit_value_from): raise UserError("--abort-on-container-exit and -d cannot be combined.") + if no_start: + for excluded in ['-d', '--abort-on-container-exit', '--exit-code-from']: + if options.get(excluded): + raise UserError('--no-start and {} cannot be combined.'.format(excluded)) + with up_shutdown_context(self.project, service_names, timeout, detached): to_attach = self.project.up( service_names=service_names, @@ -936,9 +949,10 @@ def up(self, options): detached=detached, remove_orphans=remove_orphans, scale_override=parse_scale_args(options['--scale']), + start=not no_start ) - if detached: + if detached or no_start: return attached_containers = filter_containers_to_service_names(to_attach, service_names) diff --git a/compose/project.py b/compose/project.py index 2310a2fcc68..c8b57edd209 100644 --- a/compose/project.py +++ b/compose/project.py @@ -412,7 +412,8 @@ def up(self, detached=False, remove_orphans=False, scale_override=None, - rescale=True): + rescale=True, + start=True): warn_for_swarm_mode(self.client) @@ -436,7 +437,8 @@ def do(service): timeout=timeout, detached=detached, scale_override=scale_override.get(service.name), - rescale=rescale + rescale=rescale, + start=start ) def get_deps(service): diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index e721b940f57..3a5e17ad841 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -776,6 +776,31 @@ def test_up(self): for service in services: assert self.lookup(container, service.name) + @v2_only() + def test_up_no_start(self): + self.base_dir = 'tests/fixtures/v2-full' + self.dispatch(['up', '--no-start'], None) + + services = self.project.get_services() + + default_network = self.project.networks.networks['default'].full_name + front_network = self.project.networks.networks['front'].full_name + networks = self.client.networks(names=[default_network, front_network]) + assert len(networks) == 2 + + for service in services: + containers = service.containers(stopped=True) + assert len(containers) == 1 + + container = containers[0] + assert not container.is_running + assert container.get('State.Status') == 'created' + + volumes = self.project.volumes.volumes + assert 'data' in volumes + volume = volumes['data'] + assert volume.exists() + @v2_only() def test_up_no_ansi(self): self.base_dir = 'tests/fixtures/v2-simple' From 3cb22fa94a9747f9aa42d689cb35c555638ee7c9 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 28 Aug 2017 18:18:31 -0700 Subject: [PATCH 3033/4072] Reduce up() cyclomatic complexity Signed-off-by: Joffrey F --- compose/cli/main.py | 62 +++++++++++++++++++++++++-------------------- 1 file changed, 35 insertions(+), 27 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 21bf1f30887..face38e6d33 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -969,33 +969,10 @@ def up(self, options): if cascade_stop: print("Aborting on container exit...") - - exit_code = 0 - if exit_value_from: - candidates = list(filter( - lambda c: c.service == exit_value_from, - attached_containers)) - if not candidates: - log.error( - 'No containers matching the spec "{0}" ' - 'were run.'.format(exit_value_from) - ) - exit_code = 2 - elif len(candidates) > 1: - exit_values = filter( - lambda e: e != 0, - [c.inspect()['State']['ExitCode'] for c in candidates] - ) - - exit_code = exit_values[0] - else: - exit_code = candidates[0].inspect()['State']['ExitCode'] - else: - for e in self.project.containers(service_names=options['SERVICE'], stopped=True): - if (not e.is_running and cascade_starter == e.name): - if not e.exit_code == 0: - exit_code = e.exit_code - break + all_containers = self.project.containers(service_names=options['SERVICE'], stopped=True) + exit_code = compute_exit_code( + exit_value_from, attached_containers, cascade_starter, all_containers + ) self.project.stop(service_names=service_names, timeout=timeout) sys.exit(exit_code) @@ -1016,6 +993,37 @@ def version(cls, options): print(get_version_info('full')) +def compute_exit_code(exit_value_from, attached_containers, cascade_starter, all_containers): + exit_code = 0 + if exit_value_from: + candidates = list(filter( + lambda c: c.service == exit_value_from, + attached_containers)) + if not candidates: + log.error( + 'No containers matching the spec "{0}" ' + 'were run.'.format(exit_value_from) + ) + exit_code = 2 + elif len(candidates) > 1: + exit_values = filter( + lambda e: e != 0, + [c.inspect()['State']['ExitCode'] for c in candidates] + ) + + exit_code = exit_values[0] + else: + exit_code = candidates[0].inspect()['State']['ExitCode'] + else: + for e in all_containers: + if (not e.is_running and cascade_starter == e.name): + if not e.exit_code == 0: + exit_code = e.exit_code + break + + return exit_code + + def convergence_strategy_from_opts(options): no_recreate = options['--no-recreate'] force_recreate = options['--force-recreate'] From 2f61a1dac4abcc22c93d985c73595770ded8c3db Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 25 Aug 2017 18:09:06 -0700 Subject: [PATCH 3034/4072] Add support for extension fields in v2.x and v3.4 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_v2.2.json | 1 + compose/config/config_schema_v2.3.json | 1 + compose/config/config_schema_v3.4-beta.json | 1 + compose/config/validation.py | 10 ++++++++++ tests/unit/config/config_test.py | 14 +++++++++++++- 7 files changed, 28 insertions(+), 1 deletion(-) diff --git a/compose/config/config_schema_v2.0.json b/compose/config/config_schema_v2.0.json index 14bafab4033..2ad62ac5221 100644 --- a/compose/config/config_schema_v2.0.json +++ b/compose/config/config_schema_v2.0.json @@ -41,6 +41,7 @@ } }, + "patternProperties": {"^x-": {}}, "additionalProperties": false, "definitions": { diff --git a/compose/config/config_schema_v2.1.json b/compose/config/config_schema_v2.1.json index 8a5e128347c..24e6ba02cea 100644 --- a/compose/config/config_schema_v2.1.json +++ b/compose/config/config_schema_v2.1.json @@ -41,6 +41,7 @@ } }, + "patternProperties": {"^x-": {}}, "additionalProperties": false, "definitions": { diff --git a/compose/config/config_schema_v2.2.json b/compose/config/config_schema_v2.2.json index 58ba409ff1b..86fc5df95d1 100644 --- a/compose/config/config_schema_v2.2.json +++ b/compose/config/config_schema_v2.2.json @@ -41,6 +41,7 @@ } }, + "patternProperties": {"^x-": {}}, "additionalProperties": false, "definitions": { diff --git a/compose/config/config_schema_v2.3.json b/compose/config/config_schema_v2.3.json index 7a9bdfdf1d7..a790bb4050c 100644 --- a/compose/config/config_schema_v2.3.json +++ b/compose/config/config_schema_v2.3.json @@ -41,6 +41,7 @@ } }, + "patternProperties": {"^x-": {}}, "additionalProperties": false, "definitions": { diff --git a/compose/config/config_schema_v3.4-beta.json b/compose/config/config_schema_v3.4-beta.json index 190c05f2c14..cba063202f0 100644 --- a/compose/config/config_schema_v3.4-beta.json +++ b/compose/config/config_schema_v3.4-beta.json @@ -64,6 +64,7 @@ } }, + "patternProperties": {"^x-": {}}, "additionalProperties": false, "definitions": { diff --git a/compose/config/validation.py b/compose/config/validation.py index 0b7961e5a7b..c6722a14d19 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -239,6 +239,16 @@ def handle_error_for_schema_with_id(error, path): invalid_config_key = parse_key_from_error_msg(error) return get_unsupported_config_msg(path, invalid_config_key) + if schema_id.startswith('config_schema_v'): + invalid_config_key = parse_key_from_error_msg(error) + return ('Invalid top-level property "{key}". Valid top-level ' + 'sections for this Compose file are: {properties}, and ' + 'extensions starting with "x-".\n\n{explanation}').format( + key=invalid_config_key, + properties=', '.join(error.schema['properties'].keys()), + explanation=VERSION_EXPLANATION + ) + if not error.path: return '{}\n\n{}'.format(error.message, VERSION_EXPLANATION) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 64429015739..14dd0117981 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -251,7 +251,7 @@ def test_v1_file_with_version_is_invalid(self): ) ) - assert 'Additional properties are not allowed' in excinfo.exconly() + assert 'Invalid top-level property "web"' in excinfo.exconly() assert VERSION_EXPLANATION in excinfo.exconly() def test_named_volume_config_empty(self): @@ -773,6 +773,18 @@ def test_load_sorts_in_dependency_order(self): assert services[1]['name'] == 'db' assert services[2]['name'] == 'web' + def test_load_with_extensions(self): + config_details = build_config_details({ + 'version': '2.3', + 'x-data': { + 'lambda': 3, + 'excess': [True, {}] + } + }) + + config_data = config.load(config_details) + assert config_data.services == [] + def test_config_build_configuration(self): service = config.load( build_config_details( From eab333adb1ca12672bd9a5eb93bbe6b24614862b Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 1 Sep 2017 12:14:56 -0700 Subject: [PATCH 3035/4072] Update release process with most recent changes Signed-off-by: Joffrey F --- project/RELEASE-PROCESS.md | 33 +++++++++++++++++++++------------ script/release/make-branch | 3 +-- 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/project/RELEASE-PROCESS.md b/project/RELEASE-PROCESS.md index c1834f2fbbb..5b30545f459 100644 --- a/project/RELEASE-PROCESS.md +++ b/project/RELEASE-PROCESS.md @@ -24,7 +24,7 @@ As part of this script you'll be asked to: If the next release will be an RC, append `-rcN`, e.g. `1.4.0-rc1`. -2. Write release notes in `CHANGES.md`. +2. Write release notes in `CHANGELOG.md`. Almost every feature enhancement should be mentioned, with the most visible/exciting ones first. Use descriptive sentences and give context @@ -67,16 +67,13 @@ 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 Travis - build has finished, otherwise you'll be downloading an old binary. +1. Download the different platform binaries by running the following script: - https://dl.bintray.com/docker-compose/$BRANCH_NAME/ + `./script/release/download-binaries $VERSION` -2. Download the windows binary from AppVeyor + The binaries for Linux, OSX and Windows will be downloaded in the `binaries-$VERSION` folder. - https://ci.appveyor.com/project/docker/compose - -3. Draft a release from the tag on GitHub (the script will open the window for +3. Draft a release from the tag on GitHub (the `build-binaries` script will open the window for you) The tag will only be present on Github when you run the `push-release` @@ -87,18 +84,30 @@ When prompted build the non-linux binaries and test them. 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)**. - 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. + Docker for Mac and Windows will automatically install the latest version of Docker Engine for you. Alternatively, you can use the usual commands to install or upgrade Compose: ``` - curl -L https://github.com/docker/compose/releases/download/1.9.0/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose + curl -L https://github.com/docker/compose/releases/download/1.16.0/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose chmod +x /usr/local/bin/docker-compose ``` See the [install docs](https://docs.docker.com/compose/install/) for more install options and instructions. - Here's what's new: + ## Compose file format compatibility matrix + + | Compose file format | Docker Engine | + | --- | --- | + | 3.3 | 17.06.0+ | + | 3.0 – 3.2 | 1.13.0+ | + | 2.3| 17.06.0+ | + | 2.2 | 1.13.0+ | + | 2.1 | 1.12.0+ | + | 2.0 | 1.10.0+ | + | 1.0 | 1.9.1+ | + + ## Changes ...release notes go here... @@ -119,7 +128,7 @@ When prompted build the non-linux binaries and test them. 9. Check that all the binaries download (following the install instructions) and run. -10. Email maintainers@dockerproject.org and engineering@docker.com about the new release. +10. Announce the release on the appropriate Slack channel(s). ## If it’s a stable release (not an RC) diff --git a/script/release/make-branch b/script/release/make-branch index 7ccf3f055b5..b8a0cd31ee7 100755 --- a/script/release/make-branch +++ b/script/release/make-branch @@ -65,8 +65,7 @@ git config "branch.${BRANCH}.release" $VERSION editor=${EDITOR:-vim} -echo "Update versions in docs/install.md, compose/__init__.py, script/run/run.sh" -$editor docs/install.md +echo "Update versions in compose/__init__.py, script/run/run.sh" $editor compose/__init__.py $editor script/run/run.sh From 1610af7e9f833a317f0ecee5d52ccf038a250f95 Mon Sep 17 00:00:00 2001 From: Vincent Demeester Date: Tue, 19 Sep 2017 18:27:02 +0200 Subject: [PATCH 3036/4072] Sync composefile v3.2 schema with `docker/cli` Signed-off-by: Vincent Demeester --- compose/config/config_schema_v3.2.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/compose/config/config_schema_v3.2.json b/compose/config/config_schema_v3.2.json index b26b2c6c6f1..2ca8e92dbe0 100644 --- a/compose/config/config_schema_v3.2.json +++ b/compose/config/config_schema_v3.2.json @@ -170,7 +170,8 @@ "type": "array", "items": { "oneOf": [ - {"type": ["string", "number"], "format": "ports"}, + {"type": "number", "format": "ports"}, + {"type": "string", "format": "ports"}, { "type": "object", "properties": { @@ -249,6 +250,7 @@ "source": {"type": "string"}, "target": {"type": "string"}, "read_only": {"type": "boolean"}, + "consistency": {"type": "string"}, "bind": { "type": "object", "properties": { From 49b1ac57c315ca8fad4d77253cc536333e51ac6d Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 26 Sep 2017 16:25:05 -0700 Subject: [PATCH 3037/4072] Fix oneOf validator parser to correctly process uniqueItems errors Signed-off-by: Joffrey F --- compose/config/validation.py | 15 +++++++-------- tests/unit/config/config_test.py | 25 ++++++++++++++++++++----- 2 files changed, 27 insertions(+), 13 deletions(-) diff --git a/compose/config/validation.py b/compose/config/validation.py index c6722a14d19..940775a2097 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -325,7 +325,6 @@ 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 @@ -337,6 +336,13 @@ def _parse_oneof_validator(error): invalid_config_key = parse_key_from_error_msg(context) return (None, "contains unsupported option: '{}'".format(invalid_config_key)) + if context.validator == 'uniqueItems': + return ( + path_string(context.path) if context.path else None, + "contains non-unique items, please remove duplicates from {}".format( + context.instance), + ) + if context.path: return ( path_string(context.path), @@ -345,13 +351,6 @@ def _parse_oneof_validator(error): _parse_valid_types_from_validator(context.validator_value)), ) - if context.validator == 'uniqueItems': - return ( - None, - "contains non unique items, please remove duplicates from {}".format( - context.instance), - ) - if context.validator == 'type': types.append(context.validator_value) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 14dd0117981..de9a6130257 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -581,6 +581,20 @@ def test_config_invalid_service_name_raise_validation_error(self): assert 'Invalid service name \'mong\\o\'' in excinfo.exconly() + def test_config_duplicate_cache_from_values_validation_error(self): + with pytest.raises(ConfigurationError) as exc: + config.load( + build_config_details({ + 'version': '2.3', + 'services': { + 'test': {'build': {'context': '.', 'cache_from': ['a', 'b', 'a']}} + } + + }) + ) + + assert 'build.cache_from contains non-unique items' in exc.exconly() + def test_load_with_multiple_files_v1(self): base_file = config.ConfigFile( 'base.yaml', @@ -2751,11 +2765,12 @@ def test_config_valid_expose_format_validation(self): def check_config(self, cfg): config.load( - build_config_details( - {'web': dict(image='busybox', **cfg)}, - 'working_dir', - 'filename.yml' - ) + build_config_details({ + 'version': '2.3', + 'services': { + 'web': dict(image='busybox', **cfg) + }, + }, 'working_dir', 'filename.yml') ) From 4bc9d9dbafc9e66294977952d9662223dd99f95f Mon Sep 17 00:00:00 2001 From: French Ben Date: Mon, 18 Sep 2017 16:30:32 -0700 Subject: [PATCH 3038/4072] Simple patch to allow s390x images to be built Needs integration with CI and s390x machine integration Signed-off-by: French Ben --- Dockerfile.s390x | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 Dockerfile.s390x diff --git a/Dockerfile.s390x b/Dockerfile.s390x new file mode 100644 index 00000000000..aa71e27bc3d --- /dev/null +++ b/Dockerfile.s390x @@ -0,0 +1,6 @@ +FROM s390x/python:3.6.2-slim +ARG COMPOSE_VERSION=1.16.1 + +RUN pip install --no-cache-dir docker-compose==$COMPOSE_VERSION + +ENTRYPOINT ["docker-compose"] From c5e871c5a55e873ae1931efec8ccedc649b522a7 Mon Sep 17 00:00:00 2001 From: French Ben Date: Tue, 19 Sep 2017 10:14:55 -0700 Subject: [PATCH 3039/4072] Use slim alpine instead of bulky debian Signed-off-by: French Ben --- Dockerfile.s390x | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/Dockerfile.s390x b/Dockerfile.s390x index aa71e27bc3d..3b19bb390b9 100644 --- a/Dockerfile.s390x +++ b/Dockerfile.s390x @@ -1,6 +1,15 @@ -FROM s390x/python:3.6.2-slim +FROM s390x/alpine:3.6 + ARG COMPOSE_VERSION=1.16.1 -RUN pip install --no-cache-dir docker-compose==$COMPOSE_VERSION +RUN apk add --update --no-cache \ + python \ + py-pip \ + && pip install --no-cache-dir docker-compose==$COMPOSE_VERSION \ + && rm -rf /var/cache/apk/* + +WORKDIR /data +VOLUME /data + ENTRYPOINT ["docker-compose"] From fa63e235202f1e8bd61a1f10d9e908f4cb6a29d3 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 26 Sep 2017 17:19:29 -0700 Subject: [PATCH 3040/4072] Revert 3.4-beta temp rename Signed-off-by: Joffrey F --- ...nfig_schema_v3.4-beta.json => config_schema_v3.4.json} | 8 +++++--- compose/const.py | 2 +- docker-compose.spec | 4 ++-- 3 files changed, 8 insertions(+), 6 deletions(-) rename compose/config/{config_schema_v3.4-beta.json => config_schema_v3.4.json} (98%) diff --git a/compose/config/config_schema_v3.4-beta.json b/compose/config/config_schema_v3.4.json similarity index 98% rename from compose/config/config_schema_v3.4-beta.json rename to compose/config/config_schema_v3.4.json index cba063202f0..dae7d7d2345 100644 --- a/compose/config/config_schema_v3.4-beta.json +++ b/compose/config/config_schema_v3.4.json @@ -1,6 +1,7 @@ + { "$schema": "http://json-schema.org/draft-04/schema#", - "id": "config_schema_v3.4-beta.json", + "id": "config_schema_v3.4.json", "type": "object", "required": ["version"], @@ -316,7 +317,7 @@ "additionalProperties": false, "properties": { "disable": {"type": "boolean"}, - "interval": {"type": "string"}, + "interval": {"type": "string", "format": "duration"}, "retries": {"type": "number"}, "test": { "oneOf": [ @@ -324,7 +325,8 @@ {"type": "array", "items": {"type": "string"}} ] }, - "timeout": {"type": "string"} + "timeout": {"type": "string", "format": "duration"}, + "start_period": {"type": "string", "format": "duration"} } }, "deployment": { diff --git a/compose/const.py b/compose/const.py index 809f7c7d4e7..b5970f82ae9 100644 --- a/compose/const.py +++ b/compose/const.py @@ -31,7 +31,7 @@ COMPOSEFILE_V3_1 = ComposeVersion('3.1') COMPOSEFILE_V3_2 = ComposeVersion('3.2') COMPOSEFILE_V3_3 = ComposeVersion('3.3') -COMPOSEFILE_V3_4 = ComposeVersion('3.4-beta') +COMPOSEFILE_V3_4 = ComposeVersion('3.4') API_VERSIONS = { COMPOSEFILE_V1: '1.21', diff --git a/docker-compose.spec b/docker-compose.spec index fe5651f6a2f..9c46421f0a5 100644 --- a/docker-compose.spec +++ b/docker-compose.spec @@ -63,8 +63,8 @@ exe = EXE(pyz, 'DATA' ), ( - 'compose/config/config_schema_v3.4-beta.json', - 'compose/config/config_schema_v3.4-beta.json', + 'compose/config/config_schema_v3.4.json', + 'compose/config/config_schema_v3.4.json', 'DATA' ), ( From ce19f431583733a8e0d8b2aa066af1be0a163d52 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 27 Sep 2017 18:24:46 -0700 Subject: [PATCH 3041/4072] Avoid import ConfigurationError inside compose.utils (circular import) Signed-off-by: Joffrey F --- compose/config/config.py | 5 ++++- compose/utils.py | 3 +-- tests/unit/utils_test.py | 8 ++++++++ 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 0fddfd3a456..b90ab030588 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -762,7 +762,10 @@ def process_blkio_config(service_dict): for field in ['device_read_bps', 'device_write_bps']: if field in service_dict['blkio_config']: for v in service_dict['blkio_config'].get(field, []): - v['rate'] = parse_bytes(v.get('rate', 0)) + rate = v.get('rate', 0) + v['rate'] = parse_bytes(rate) + if v['rate'] is None: + raise ConfigurationError('Invalid format for bytes value: "{}"'.format(rate)) for field in ['device_read_iops', 'device_write_iops']: if field in service_dict['blkio_config']: diff --git a/compose/utils.py b/compose/utils.py index 1ede4d37d84..197ae6eb29a 100644 --- a/compose/utils.py +++ b/compose/utils.py @@ -12,7 +12,6 @@ from docker.errors import DockerException from docker.utils import parse_bytes as sdk_parse_bytes -from .config.errors import ConfigurationError from .errors import StreamParseError from .timeparse import MULTIPLIERS from .timeparse import timeparse @@ -143,4 +142,4 @@ def parse_bytes(n): try: return sdk_parse_bytes(n) except DockerException: - raise ConfigurationError('Invalid format for bytes value: {}'.format(n)) + return None diff --git a/tests/unit/utils_test.py b/tests/unit/utils_test.py index 85231957e3c..84becb97554 100644 --- a/tests/unit/utils_test.py +++ b/tests/unit/utils_test.py @@ -60,3 +60,11 @@ def test_with_leading_whitespace(self): {'three': 'four'}, {'x': 2} ] + + +class TestParseBytes(object): + def test_parse_bytes(self): + assert utils.parse_bytes('123kb') == 123 * 1024 + assert utils.parse_bytes(123) == 123 + assert utils.parse_bytes('foobar') is None + assert utils.parse_bytes('123') == 123 From 38073bbd9fa8a461452db28b89f22617910c43ff Mon Sep 17 00:00:00 2001 From: Marc van den Hoogen Date: Fri, 18 Aug 2017 13:40:11 +0200 Subject: [PATCH 3042/4072] Add shm_size to build-options (issue #3866) * Add shm_size to build configuration * Make it possible to enlarge/customize shm size during build * Value in bytes, or use string like "512M" or "1G" ... * Add to compose format 2.3 and (provisionally) >=3.5 format * Add automated test for shm_size in build-opts Signed-off-by: Marc van den Hoogen Made unit tests compatible with previously added shm_size build-option Signed-off-by: Marc van den Hoogen Also support shm_size build-opt when conf override Signed-off-by: Marc van den Hoogen Automated test for shm_size build-option Signed-off-by: Marc van den Hoogen Schema 3.4, add shm_size to schema 2.3, updated const.py Signed-off-by: Marc van den Hoogen Corrected typo in config_schema_v3.4 Signed-off-by: Marc van den Hoogen Add support for g/m/k units for shm_size in build-opts Signed-off-by: Marc van den Hoogen Reorder imports in service.py Signed-off-by: Marc van den Hoogen --- compose/config/config.py | 1 + compose/config/config_schema_v2.3.json | 3 +- compose/config/config_schema_v3.5.json | 542 ++++++++++++++++++ compose/const.py | 3 + compose/service.py | 2 + tests/acceptance/cli_test.py | 6 + tests/fixtures/build-shm-size/Dockerfile | 4 + .../build-shm-size/docker-compose.yml | 7 + tests/unit/service_test.py | 2 + 9 files changed, 569 insertions(+), 1 deletion(-) create mode 100644 compose/config/config_schema_v3.5.json create mode 100644 tests/fixtures/build-shm-size/Dockerfile create mode 100644 tests/fixtures/build-shm-size/docker-compose.yml diff --git a/compose/config/config.py b/compose/config/config.py index b90ab030588..f16dd01b3f5 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -1020,6 +1020,7 @@ def to_dict(service): md.merge_scalar('dockerfile') md.merge_scalar('network') md.merge_scalar('target') + md.merge_scalar('shm_size') md.merge_mapping('args', parse_build_arguments) md.merge_field('cache_from', merge_unique_items_lists, default=[]) md.merge_mapping('labels', parse_labels) diff --git a/compose/config/config_schema_v2.3.json b/compose/config/config_schema_v2.3.json index a790bb4050c..ceaf44954eb 100644 --- a/compose/config/config_schema_v2.3.json +++ b/compose/config/config_schema_v2.3.json @@ -91,7 +91,8 @@ "labels": {"$ref": "#/definitions/list_or_dict"}, "cache_from": {"$ref": "#/definitions/list_of_strings"}, "network": {"type": "string"}, - "target": {"type": "string"} + "target": {"type": "string"}, + "shm_size": {"type": ["integer", "string"]} }, "additionalProperties": false } diff --git a/compose/config/config_schema_v3.5.json b/compose/config/config_schema_v3.5.json new file mode 100644 index 00000000000..fa95d6a2457 --- /dev/null +++ b/compose/config/config_schema_v3.5.json @@ -0,0 +1,542 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "id": "config_schema_v3.5.json", + "type": "object", + "required": ["version"], + + "properties": { + "version": { + "type": "string" + }, + + "services": { + "id": "#/properties/services", + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "$ref": "#/definitions/service" + } + }, + "additionalProperties": false + }, + + "networks": { + "id": "#/properties/networks", + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "$ref": "#/definitions/network" + } + } + }, + + "volumes": { + "id": "#/properties/volumes", + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "$ref": "#/definitions/volume" + } + }, + "additionalProperties": false + }, + + "secrets": { + "id": "#/properties/secrets", + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "$ref": "#/definitions/secret" + } + }, + "additionalProperties": false + }, + + "configs": { + "id": "#/properties/configs", + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "$ref": "#/definitions/config" + } + }, + "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"}, + "labels": {"$ref": "#/definitions/list_or_dict"}, + "cache_from": {"$ref": "#/definitions/list_of_strings"}, + "network": {"type": "string"}, + "target": {"type": "string"}, + "shm_size": {"type": ["integer", "string"]} + }, + "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"}} + ] + }, + "configs": { + "type": "array", + "items": { + "oneOf": [ + {"type": "string"}, + { + "type": "object", + "properties": { + "source": {"type": "string"}, + "target": {"type": "string"}, + "uid": {"type": "string"}, + "gid": {"type": "string"}, + "mode": {"type": "number"} + } + } + ] + } + }, + "container_name": {"type": "string"}, + "credential_spec": {"type": "object", "properties": { + "file": {"type": "string"}, + "registry": {"type": "string"} + }}, + "depends_on": {"$ref": "#/definitions/list_of_strings"}, + "devices": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "dns": {"$ref": "#/definitions/string_or_list"}, + "dns_search": {"$ref": "#/definitions/string_or_list"}, + "domainname": {"type": "string"}, + "entrypoint": { + "oneOf": [ + {"type": "string"}, + {"type": "array", "items": {"type": "string"}} + ] + }, + "env_file": {"$ref": "#/definitions/string_or_list"}, + "environment": {"$ref": "#/definitions/list_or_dict"}, + + "expose": { + "type": "array", + "items": { + "type": ["string", "number"], + "format": "expose" + }, + "uniqueItems": true + }, + + "external_links": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "extra_hosts": {"$ref": "#/definitions/list_or_dict"}, + "healthcheck": {"$ref": "#/definitions/healthcheck"}, + "hostname": {"type": "string"}, + "image": {"type": "string"}, + "ipc": {"type": "string"}, + "labels": {"$ref": "#/definitions/list_or_dict"}, + "links": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + + "logging": { + "type": "object", + + "properties": { + "driver": {"type": "string"}, + "options": { + "type": "object", + "patternProperties": { + "^.+$": {"type": ["string", "number", "null"]} + } + } + }, + "additionalProperties": false + }, + + "mac_address": {"type": "string"}, + "network_mode": {"type": "string"}, + + "networks": { + "oneOf": [ + {"$ref": "#/definitions/list_of_strings"}, + { + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "oneOf": [ + { + "type": "object", + "properties": { + "aliases": {"$ref": "#/definitions/list_of_strings"}, + "ipv4_address": {"type": "string"}, + "ipv6_address": {"type": "string"} + }, + "additionalProperties": false + }, + {"type": "null"} + ] + } + }, + "additionalProperties": false + } + ] + }, + "pid": {"type": ["string", "null"]}, + + "ports": { + "type": "array", + "items": { + "oneOf": [ + {"type": "number", "format": "ports"}, + {"type": "string", "format": "ports"}, + { + "type": "object", + "properties": { + "mode": {"type": "string"}, + "target": {"type": "integer"}, + "published": {"type": "integer"}, + "protocol": {"type": "string"} + }, + "additionalProperties": false + } + ] + }, + "uniqueItems": true + }, + + "privileged": {"type": "boolean"}, + "read_only": {"type": "boolean"}, + "restart": {"type": "string"}, + "security_opt": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "shm_size": {"type": ["number", "string"]}, + "secrets": { + "type": "array", + "items": { + "oneOf": [ + {"type": "string"}, + { + "type": "object", + "properties": { + "source": {"type": "string"}, + "target": {"type": "string"}, + "uid": {"type": "string"}, + "gid": {"type": "string"}, + "mode": {"type": "number"} + } + } + ] + } + }, + "sysctls": {"$ref": "#/definitions/list_or_dict"}, + "stdin_open": {"type": "boolean"}, + "stop_grace_period": {"type": "string", "format": "duration"}, + "stop_signal": {"type": "string"}, + "tmpfs": {"$ref": "#/definitions/string_or_list"}, + "tty": {"type": "boolean"}, + "ulimits": { + "type": "object", + "patternProperties": { + "^[a-z]+$": { + "oneOf": [ + {"type": "integer"}, + { + "type":"object", + "properties": { + "hard": {"type": "integer"}, + "soft": {"type": "integer"} + }, + "required": ["soft", "hard"], + "additionalProperties": false + } + ] + } + } + }, + "user": {"type": "string"}, + "userns_mode": {"type": "string"}, + "volumes": { + "type": "array", + "items": { + "oneOf": [ + {"type": "string"}, + { + "type": "object", + "required": ["type"], + "properties": { + "type": {"type": "string"}, + "source": {"type": "string"}, + "target": {"type": "string"}, + "read_only": {"type": "boolean"}, + "consistency": {"type": "string"}, + "bind": { + "type": "object", + "properties": { + "propagation": {"type": "string"} + } + }, + "volume": { + "type": "object", + "properties": { + "nocopy": {"type": "boolean"} + } + } + } + } + ], + "uniqueItems": true + } + }, + "working_dir": {"type": "string"} + }, + "additionalProperties": false + }, + + "healthcheck": { + "id": "#/definitions/healthcheck", + "type": "object", + "additionalProperties": false, + "properties": { + "disable": {"type": "boolean"}, + "interval": {"type": "string"}, + "retries": {"type": "number"}, + "test": { + "oneOf": [ + {"type": "string"}, + {"type": "array", "items": {"type": "string"}} + ] + }, + "timeout": {"type": "string"} + } + }, + "deployment": { + "id": "#/definitions/deployment", + "type": ["object", "null"], + "properties": { + "mode": {"type": "string"}, + "endpoint_mode": {"type": "string"}, + "replicas": {"type": "integer"}, + "labels": {"$ref": "#/definitions/list_or_dict"}, + "update_config": { + "type": "object", + "properties": { + "parallelism": {"type": "integer"}, + "delay": {"type": "string", "format": "duration"}, + "failure_action": {"type": "string"}, + "monitor": {"type": "string", "format": "duration"}, + "max_failure_ratio": {"type": "number"}, + "order": {"type": "string", "enum": [ + "start-first", "stop-first" + ]} + }, + "additionalProperties": false + }, + "resources": { + "type": "object", + "properties": { + "limits": {"$ref": "#/definitions/resource"}, + "reservations": {"$ref": "#/definitions/resource"} + }, + "additionalProperties": false + }, + "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"}}, + "preferences": { + "type": "array", + "items": { + "type": "object", + "properties": { + "spread": {"type": "string"} + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + + "resource": { + "id": "#/definitions/resource", + "type": "object", + "properties": { + "cpus": {"type": "string"}, + "memory": {"type": "string"} + }, + "additionalProperties": false + }, + + "network": { + "id": "#/definitions/network", + "type": ["object", "null"], + "properties": { + "driver": {"type": "string"}, + "driver_opts": { + "type": "object", + "patternProperties": { + "^.+$": {"type": ["string", "number"]} + } + }, + "ipam": { + "type": "object", + "properties": { + "driver": {"type": "string"}, + "config": { + "type": "array", + "items": { + "type": "object", + "properties": { + "subnet": {"type": "string"} + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + }, + "external": { + "type": ["boolean", "object"], + "properties": { + "name": {"type": "string"} + }, + "additionalProperties": false + }, + "internal": {"type": "boolean"}, + "attachable": {"type": "boolean"}, + "labels": {"$ref": "#/definitions/list_or_dict"} + }, + "additionalProperties": false + }, + + "volume": { + "id": "#/definitions/volume", + "type": ["object", "null"], + "properties": { + "name": {"type": "string"}, + "driver": {"type": "string"}, + "driver_opts": { + "type": "object", + "patternProperties": { + "^.+$": {"type": ["string", "number"]} + } + }, + "external": { + "type": ["boolean", "object"], + "properties": { + "name": {"type": "string"} + }, + "additionalProperties": false + }, + "labels": {"$ref": "#/definitions/list_or_dict"} + }, + "additionalProperties": false + }, + + "secret": { + "id": "#/definitions/secret", + "type": "object", + "properties": { + "file": {"type": "string"}, + "external": { + "type": ["boolean", "object"], + "properties": { + "name": {"type": "string"} + } + }, + "labels": {"$ref": "#/definitions/list_or_dict"} + }, + "additionalProperties": false + }, + + "config": { + "id": "#/definitions/config", + "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"] + } + } + } + } + } +} diff --git a/compose/const.py b/compose/const.py index b5970f82ae9..2ac08b89a70 100644 --- a/compose/const.py +++ b/compose/const.py @@ -32,6 +32,7 @@ COMPOSEFILE_V3_2 = ComposeVersion('3.2') COMPOSEFILE_V3_3 = ComposeVersion('3.3') COMPOSEFILE_V3_4 = ComposeVersion('3.4') +COMPOSEFILE_V3_5 = ComposeVersion('3.5') API_VERSIONS = { COMPOSEFILE_V1: '1.21', @@ -44,6 +45,7 @@ COMPOSEFILE_V3_2: '1.25', COMPOSEFILE_V3_3: '1.30', COMPOSEFILE_V3_4: '1.30', + COMPOSEFILE_V3_5: '1.30', } API_VERSION_TO_ENGINE_VERSION = { @@ -57,4 +59,5 @@ API_VERSIONS[COMPOSEFILE_V3_2]: '1.13.0', API_VERSIONS[COMPOSEFILE_V3_3]: '17.06.0', API_VERSIONS[COMPOSEFILE_V3_4]: '17.06.0', + API_VERSIONS[COMPOSEFILE_V3_5]: '17.06.0', } diff --git a/compose/service.py b/compose/service.py index 2829240f256..28c03276365 100644 --- a/compose/service.py +++ b/compose/service.py @@ -43,6 +43,7 @@ from .progress_stream import stream_output from .progress_stream import StreamOutputError from .utils import json_hash +from .utils import parse_bytes from .utils import parse_seconds_float @@ -916,6 +917,7 @@ def build(self, no_cache=False, pull=False, force_rm=False, build_args_override= buildargs=build_args, network_mode=build_opts.get('network', None), target=build_opts.get('target', None), + shmsize=parse_bytes(build_opts.get('shm_size')) if build_opts.get('shm_size') else None, ) try: diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 3a5e17ad841..ca4bd9ee7f3 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -531,6 +531,12 @@ def test_build_failed_forcerm(self): ] assert not containers + def test_build_shm_size_build_option(self): + pull_busybox(self.client) + self.base_dir = 'tests/fixtures/build-shm-size' + result = self.dispatch(['build', '--no-cache'], None) + assert 'shm_size: 96' in result.stdout + def test_bundle_with_digests(self): self.base_dir = 'tests/fixtures/bundle-with-digests/' tmpdir = py.test.ensuretemp('cli_test_bundle') diff --git a/tests/fixtures/build-shm-size/Dockerfile b/tests/fixtures/build-shm-size/Dockerfile new file mode 100644 index 00000000000..f91733d6301 --- /dev/null +++ b/tests/fixtures/build-shm-size/Dockerfile @@ -0,0 +1,4 @@ +FROM busybox + +# Report the shm_size (through the size of /dev/shm) +RUN echo "shm_size:" $(df -h /dev/shm | tail -n 1 | awk '{print $2}') diff --git a/tests/fixtures/build-shm-size/docker-compose.yml b/tests/fixtures/build-shm-size/docker-compose.yml new file mode 100644 index 00000000000..238a513223b --- /dev/null +++ b/tests/fixtures/build-shm-size/docker-compose.yml @@ -0,0 +1,7 @@ +version: '3.5' + +services: + custom_shm_size: + build: + context: . + shm_size: 100663296 # =96M diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 0293695abbb..43ccf081c30 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -475,6 +475,7 @@ def test_create_container(self): cache_from=None, network_mode=None, target=None, + shmsize=None, ) def test_ensure_image_exists_no_build(self): @@ -515,6 +516,7 @@ def test_ensure_image_exists_force_build(self): cache_from=None, network_mode=None, target=None, + shmsize=None ) def test_build_does_not_pull(self): From aecd0a948336dc16bddd67ec81075288b2f8dd14 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 13 Oct 2017 15:24:34 -0700 Subject: [PATCH 3043/4072] Temporary xfails for engine bug Signed-off-by: Joffrey F --- tests/acceptance/cli_test.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index ca4bd9ee7f3..b598d99d540 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -14,7 +14,7 @@ from collections import namedtuple from operator import attrgetter -import py +import pytest import six import yaml from docker import errors @@ -504,6 +504,7 @@ def test_build_no_cache_pull(self): assert BUILD_CACHE_TEXT not in result.stdout assert BUILD_PULL_TEXT in result.stdout + @pytest.mark.xfail(reason='17.10.0 RC bug remove after GA https://github.com/moby/moby/issues/35116') def test_build_failed(self): self.base_dir = 'tests/fixtures/simple-failing-dockerfile' self.dispatch(['build', 'simple'], returncode=1) @@ -517,6 +518,7 @@ def test_build_failed(self): ] assert len(containers) == 1 + @pytest.mark.xfail(reason='17.10.0 RC bug remove after GA https://github.com/moby/moby/issues/35116') def test_build_failed_forcerm(self): self.base_dir = 'tests/fixtures/simple-failing-dockerfile' self.dispatch(['build', '--force-rm', 'simple'], returncode=1) @@ -539,7 +541,7 @@ def test_build_shm_size_build_option(self): def test_bundle_with_digests(self): self.base_dir = 'tests/fixtures/bundle-with-digests/' - tmpdir = py.test.ensuretemp('cli_test_bundle') + tmpdir = pytest.ensuretemp('cli_test_bundle') self.addCleanup(tmpdir.remove) filename = str(tmpdir.join('example.dab')) @@ -1403,7 +1405,7 @@ def test_run_without_command(self): [u'/bin/true'], ) - @py.test.mark.skipif(SWARM_SKIP_RM_VOLUMES, reason='Swarm DELETE /containers/ bug') + @pytest.mark.skipif(SWARM_SKIP_RM_VOLUMES, reason='Swarm DELETE /containers/ bug') def test_run_rm(self): self.base_dir = 'tests/fixtures/volume' proc = start_process(self.base_dir, ['run', '--rm', 'test']) From f74838676dd1cf759bbb0125505c08baea8ca8f3 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 27 Sep 2017 17:10:13 -0700 Subject: [PATCH 3044/4072] Mount with same container path and different mode should override Signed-off-by: Joffrey F --- compose/config/config.py | 35 ++++++++++++++++++++--------- tests/unit/config/config_test.py | 38 +++++++++++++++++++++++++++++--- 2 files changed, 60 insertions(+), 13 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index f16dd01b3f5..948e2376e97 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -1137,24 +1137,30 @@ def resolve_volume_paths(working_dir, service_dict): def resolve_volume_path(working_dir, volume): + mount_params = None if isinstance(volume, dict): - host_path = volume.get('source') container_path = volume.get('target') + host_path = volume.get('source') + mode = None if host_path: if volume.get('read_only'): - container_path += ':ro' + mode = 'ro' if volume.get('volume', {}).get('nocopy'): - container_path += ':nocopy' + mode = 'nocopy' + mount_params = (host_path, mode) else: - container_path, host_path = split_path_mapping(volume) + container_path, mount_params = split_path_mapping(volume) - if host_path is not None: + if mount_params is not None: + host_path, mode = mount_params + if host_path is None: + return container_path if host_path.startswith('.'): host_path = expand_path(working_dir, host_path) host_path = os.path.expanduser(host_path) - return u"{}:{}".format(host_path, container_path) - else: - return container_path + return u"{}:{}{}".format(host_path, container_path, (':' + mode if mode else '')) + + return container_path def normalize_build(service_dict, working_dir, environment): @@ -1234,7 +1240,12 @@ def split_path_mapping(volume_path): if ':' in volume_config: (host, container) = volume_config.split(':', 1) - return (container, drive + host) + container_drive, container_path = splitdrive(container) + mode = None + if ':' in container_path: + container_path, mode = container_path.rsplit(':', 1) + + return (container_drive + container_path, (drive + host, mode)) else: return (volume_path, None) @@ -1246,7 +1257,11 @@ def join_path_mapping(pair): elif host is None: return container else: - return ":".join((host, container)) + host, mode = host + result = ":".join((host, container)) + if mode: + result += ":" + mode + return result def expand_path(working_dir, path): diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index de9a6130257..c5e40130d79 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1101,6 +1101,38 @@ def test_load_with_multiple_files_v3_2(self): ['/anonymous', '/c:/b:rw', 'vol:/x:ro'] ) + @mock.patch.dict(os.environ) + def test_volume_mode_override(self): + os.environ['COMPOSE_CONVERT_WINDOWS_PATHS'] = 'true' + base_file = config.ConfigFile( + 'base.yaml', + { + 'version': '2.3', + 'services': { + 'web': { + 'image': 'example/web', + 'volumes': ['/c:/b:rw'] + } + }, + } + ) + + override_file = config.ConfigFile( + 'override.yaml', + { + 'version': '2.3', + 'services': { + 'web': { + 'volumes': ['/c:/b:ro'] + } + } + } + ) + details = config.ConfigDetails('.', [base_file, override_file]) + service_dicts = config.load(details).services + svc_volumes = list(map(lambda v: v.repr(), service_dicts[0]['volumes'])) + assert svc_volumes == ['/c:/b:ro'] + def test_undeclared_volume_v2(self): base_file = config.ConfigFile( 'base.yaml', @@ -4018,7 +4050,7 @@ class VolumePathTest(unittest.TestCase): 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" - expected_mapping = ("/opt/connect/config:ro", host_path) + expected_mapping = ("/opt/connect/config", (host_path, 'ro')) mapping = config.split_path_mapping(windows_volume_path) assert mapping == expected_mapping @@ -4026,7 +4058,7 @@ def test_split_path_mapping_with_windows_path(self): 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) + expected_mapping = (container_path, (host_path, None)) mapping = config.split_path_mapping('{0}:{1}'.format(host_path, container_path)) assert mapping == expected_mapping @@ -4034,7 +4066,7 @@ def test_split_path_mapping_with_windows_path_in_container(self): def test_split_path_mapping_with_root_mount(self): host_path = '/' container_path = '/var/hostroot' - expected_mapping = (container_path, host_path) + expected_mapping = (container_path, (host_path, None)) mapping = config.split_path_mapping('{0}:{1}'.format(host_path, container_path)) assert mapping == expected_mapping From 07b30e314592154188d38bc5d0e7167e5c3e7228 Mon Sep 17 00:00:00 2001 From: Andrea Giardini Date: Wed, 20 Sep 2017 23:05:29 +0200 Subject: [PATCH 3045/4072] Fix secret location with absolute paths Signed-off-by: Andrea Giardini --- compose/service.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/compose/service.py b/compose/service.py index 28c03276365..aecafc8ca46 100644 --- a/compose/service.py +++ b/compose/service.py @@ -881,9 +881,12 @@ def _get_container_host_config(self, override_options, one_off=False): def get_secret_volumes(self): def build_spec(secret): - target = '{}/{}'.format( - const.SECRETS_PATH, - secret['secret'].target or secret['secret'].source) + if secret['secret'].target is not None and secret['secret'].target.startswith('/'): + target = secret['secret'].target + else: + 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] From 96882268de43d4a32a708c5ddffb9c219fc65664 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 13 Oct 2017 17:02:01 -0700 Subject: [PATCH 3046/4072] Add get_secret_volumes unit tests Signed-off-by: Joffrey F --- compose/service.py | 12 ++++----- tests/unit/service_test.py | 55 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 6 deletions(-) diff --git a/compose/service.py b/compose/service.py index aecafc8ca46..1a18c66549d 100644 --- a/compose/service.py +++ b/compose/service.py @@ -881,12 +881,12 @@ def _get_container_host_config(self, override_options, one_off=False): def get_secret_volumes(self): def build_spec(secret): - if secret['secret'].target is not None and secret['secret'].target.startswith('/'): - target = secret['secret'].target - else: - target = '{}/{}'.format( - const.SECRETS_PATH, - secret['secret'].target or secret['secret'].source) + target = secret['secret'].target + if target is None: + target = '{}/{}'.format(const.SECRETS_PATH, secret['secret'].source) + elif not os.path.isabs(target): + target = '{}/{}'.format(const.SECRETS_PATH, target) + return VolumeSpec(secret['file'], target, 'ro') return [build_spec(secret) for secret in self.secrets] diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 43ccf081c30..7d61807ba00 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -9,12 +9,14 @@ from .. import unittest from compose.config.errors import DependencyError from compose.config.types import ServicePort +from compose.config.types import ServiceSecret from compose.config.types import VolumeFromSpec from compose.config.types import VolumeSpec from compose.const import LABEL_CONFIG_HASH from compose.const import LABEL_ONE_OFF from compose.const import LABEL_PROJECT from compose.const import LABEL_SERVICE +from compose.const import SECRETS_PATH from compose.container import Container from compose.project import OneOffFilter from compose.service import build_ulimits @@ -1089,3 +1091,56 @@ def test_create_with_special_volume_mode(self): self.assertEqual( self.mock_client.create_host_config.call_args[1]['binds'], [volume]) + + +class ServiceSecretTest(unittest.TestCase): + def setUp(self): + self.mock_client = mock.create_autospec(docker.APIClient) + + def test_get_secret_volumes(self): + secret1 = { + 'secret': ServiceSecret.parse({'source': 'secret1', 'target': 'b.txt'}), + 'file': 'a.txt' + } + service = Service( + 'web', + client=self.mock_client, + image='busybox', + secrets=[secret1] + ) + volumes = service.get_secret_volumes() + + assert volumes[0].external == secret1['file'] + assert volumes[0].internal == '{}/{}'.format(SECRETS_PATH, secret1['secret'].target) + + def test_get_secret_volumes_abspath(self): + secret1 = { + 'secret': ServiceSecret.parse({'source': 'secret1', 'target': '/d.txt'}), + 'file': 'c.txt' + } + service = Service( + 'web', + client=self.mock_client, + image='busybox', + secrets=[secret1] + ) + volumes = service.get_secret_volumes() + + assert volumes[0].external == secret1['file'] + assert volumes[0].internal == secret1['secret'].target + + def test_get_secret_volumes_no_target(self): + secret1 = { + 'secret': ServiceSecret.parse({'source': 'secret1'}), + 'file': 'c.txt' + } + service = Service( + 'web', + client=self.mock_client, + image='busybox', + secrets=[secret1] + ) + volumes = service.get_secret_volumes() + + assert volumes[0].external == secret1['file'] + assert volumes[0].internal == '{}/{}'.format(SECRETS_PATH, secret1['secret'].source) From 2f2259f2d236285b0024d9100e9152e1b81fd63f Mon Sep 17 00:00:00 2001 From: Guillermo Arribas Date: Fri, 6 Oct 2017 19:12:59 -0300 Subject: [PATCH 3047/4072] Build labels option: array form produces unmarshal error (fixes #5183) Signed-off-by: Guillermo Arribas --- compose/service.py | 3 ++- tests/integration/service_test.py | 19 ++++++++++++++++++- tests/unit/service_test.py | 4 ++-- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/compose/service.py b/compose/service.py index 1a18c66549d..e2f72aa5ac3 100644 --- a/compose/service.py +++ b/compose/service.py @@ -23,6 +23,7 @@ from . import progress_stream from .config import DOCKER_CONFIG_KEYS from .config import merge_environment +from .config.config import parse_labels from .config.errors import DependencyError from .config.types import ServicePort from .config.types import VolumeSpec @@ -916,7 +917,7 @@ def build(self, no_cache=False, pull=False, force_rm=False, build_args_override= nocache=no_cache, dockerfile=build_opts.get('dockerfile', None), cache_from=build_opts.get('cache_from', None), - labels=build_opts.get('labels', None), + labels=parse_labels(build_opts.get('labels', None)), buildargs=build_args, network_mode=build_opts.get('network', None), target=build_opts.get('target', None), diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 84b54fe419d..a71bc407c1e 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -761,7 +761,7 @@ def test_build_with_build_args_override(self): assert service.image() assert "build_version=2" in service.image()['ContainerConfig']['Cmd'] - def test_build_with_build_labels(self): + def test_build_with_build_labels_dict(self): base_dir = tempfile.mkdtemp() self.addCleanup(shutil.rmtree, base_dir) @@ -778,6 +778,23 @@ def test_build_with_build_labels(self): assert service.image() assert service.image()['Config']['Labels']['com.docker.compose.test'] == 'true' + def test_build_with_build_labels_list(self): + base_dir = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, base_dir) + + with open(os.path.join(base_dir, 'Dockerfile'), 'w') as f: + f.write('FROM busybox\n') + + service = self.create_service('buildlabels', build={ + 'context': text_type(base_dir), + 'labels': ['com.docker.compose.test=true'] + }) + service.build() + self.addCleanup(self.client.remove_image, service.image_name) + + assert service.image() + assert service.image()['Config']['Labels']['com.docker.compose.test'] == 'true' + @no_cluster('Container networks not on Swarm') def test_build_with_network(self): base_dir = tempfile.mkdtemp() diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 7d61807ba00..5c5c2bf677e 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -473,7 +473,7 @@ def test_create_container(self): nocache=False, rm=True, buildargs={}, - labels=None, + labels={}, cache_from=None, network_mode=None, target=None, @@ -514,7 +514,7 @@ def test_ensure_image_exists_force_build(self): nocache=False, rm=True, buildargs={}, - labels=None, + labels={}, cache_from=None, network_mode=None, target=None, From f6d7eeb129087e4f960946778a4f2ec72cad6ef1 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 16 Oct 2017 11:43:06 -0700 Subject: [PATCH 3048/4072] Move build labels parsing to config module Signed-off-by: Joffrey F --- compose/config/config.py | 12 ++++++------ compose/service.py | 3 +-- tests/integration/service_test.py | 19 +------------------ tests/unit/config/config_test.py | 24 +++++++++++++++++++++++- tests/unit/service_test.py | 4 ++-- 5 files changed, 33 insertions(+), 29 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 948e2376e97..68b2be3a61e 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -707,16 +707,16 @@ def process_service(service_config): if 'build' in service_dict: if isinstance(service_dict['build'], six.string_types): service_dict['build'] = resolve_build_path(working_dir, service_dict['build']) - elif isinstance(service_dict['build'], dict) and 'context' in service_dict['build']: - path = service_dict['build']['context'] - service_dict['build']['context'] = resolve_build_path(working_dir, path) + elif isinstance(service_dict['build'], dict): + if 'context' in service_dict['build']: + path = service_dict['build']['context'] + service_dict['build']['context'] = resolve_build_path(working_dir, path) + if 'labels' in service_dict['build']: + service_dict['build']['labels'] = parse_labels(service_dict['build']['labels']) if 'volumes' in service_dict and service_dict.get('volume_driver') is None: service_dict['volumes'] = resolve_volume_paths(working_dir, service_dict) - if 'labels' in service_dict: - service_dict['labels'] = parse_labels(service_dict['labels']) - if 'sysctls' in service_dict: service_dict['sysctls'] = build_string_dict(parse_sysctls(service_dict['sysctls'])) diff --git a/compose/service.py b/compose/service.py index e2f72aa5ac3..1a18c66549d 100644 --- a/compose/service.py +++ b/compose/service.py @@ -23,7 +23,6 @@ from . import progress_stream from .config import DOCKER_CONFIG_KEYS from .config import merge_environment -from .config.config import parse_labels from .config.errors import DependencyError from .config.types import ServicePort from .config.types import VolumeSpec @@ -917,7 +916,7 @@ def build(self, no_cache=False, pull=False, force_rm=False, build_args_override= nocache=no_cache, dockerfile=build_opts.get('dockerfile', None), cache_from=build_opts.get('cache_from', None), - labels=parse_labels(build_opts.get('labels', None)), + labels=build_opts.get('labels', None), buildargs=build_args, network_mode=build_opts.get('network', None), target=build_opts.get('target', None), diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index a71bc407c1e..84b54fe419d 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -761,7 +761,7 @@ def test_build_with_build_args_override(self): assert service.image() assert "build_version=2" in service.image()['ContainerConfig']['Cmd'] - def test_build_with_build_labels_dict(self): + def test_build_with_build_labels(self): base_dir = tempfile.mkdtemp() self.addCleanup(shutil.rmtree, base_dir) @@ -778,23 +778,6 @@ def test_build_with_build_labels_dict(self): assert service.image() assert service.image()['Config']['Labels']['com.docker.compose.test'] == 'true' - def test_build_with_build_labels_list(self): - base_dir = tempfile.mkdtemp() - self.addCleanup(shutil.rmtree, base_dir) - - with open(os.path.join(base_dir, 'Dockerfile'), 'w') as f: - f.write('FROM busybox\n') - - service = self.create_service('buildlabels', build={ - 'context': text_type(base_dir), - 'labels': ['com.docker.compose.test=true'] - }) - service.build() - self.addCleanup(self.client.remove_image, service.image_name) - - assert service.image() - assert service.image()['Config']['Labels']['com.docker.compose.test'] == 'true' - @no_cluster('Container networks not on Swarm') def test_build_with_network(self): base_dir = tempfile.mkdtemp() diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index c5e40130d79..8f2266ed8d5 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -892,7 +892,7 @@ def test_load_with_buildargs(self): assert service['build']['args']['opt1'] == '42' assert service['build']['args']['opt2'] == 'foobar' - def test_load_with_build_labels(self): + def test_load_build_labels_dict(self): service = config.load( build_config_details( { @@ -919,6 +919,28 @@ def test_load_with_build_labels(self): assert service['build']['labels']['label1'] == 42 assert service['build']['labels']['label2'] == 'foobar' + def test_load_build_labels_list(self): + base_file = config.ConfigFile( + 'base.yml', + { + 'version': '2.3', + 'services': { + 'web': { + 'build': { + 'context': '.', + 'labels': ['foo=bar', 'baz=true', 'foobar=1'] + }, + }, + }, + } + ) + + details = config.ConfigDetails('.', [base_file]) + service = config.load(details).services[0] + assert service['build']['labels'] == { + 'foo': 'bar', 'baz': 'true', 'foobar': '1' + } + def test_build_args_allow_empty_properties(self): service = config.load( build_config_details( diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 5c5c2bf677e..7d61807ba00 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -473,7 +473,7 @@ def test_create_container(self): nocache=False, rm=True, buildargs={}, - labels={}, + labels=None, cache_from=None, network_mode=None, target=None, @@ -514,7 +514,7 @@ def test_ensure_image_exists_force_build(self): nocache=False, rm=True, buildargs={}, - labels={}, + labels=None, cache_from=None, network_mode=None, target=None, From b9bae89f1d25c05ddd3438f6968c96ef3a9fb16b Mon Sep 17 00:00:00 2001 From: Guillermo Arribas Date: Wed, 11 Oct 2017 13:56:15 -0300 Subject: [PATCH 3049/4072] Config command generates invalid volumes (fixes #5176) Signed-off-by: Guillermo Arribas --- compose/config/config.py | 19 +++---- compose/config/serialize.py | 4 +- tests/acceptance/cli_test.py | 54 +++++++++++++++++-- .../volumes/external-volumes-v2-x.yml | 17 ++++++ ...al-volumes.yml => external-volumes-v2.yml} | 2 +- .../volumes/external-volumes-v3-4.yml | 17 ++++++ .../volumes/external-volumes-v3-x.yml | 16 ++++++ 7 files changed, 114 insertions(+), 15 deletions(-) create mode 100644 tests/fixtures/volumes/external-volumes-v2-x.yml rename tests/fixtures/volumes/{external-volumes.yml => external-volumes-v2.yml} (92%) create mode 100644 tests/fixtures/volumes/external-volumes-v3-4.yml create mode 100644 tests/fixtures/volumes/external-volumes-v3-x.yml diff --git a/compose/config/config.py b/compose/config/config.py index 68b2be3a61e..7bb57076e10 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -15,6 +15,9 @@ from . import types from .. import const from ..const import COMPOSEFILE_V1 as V1 +from ..const import COMPOSEFILE_V2_1 as V2_1 +from ..const import COMPOSEFILE_V3_0 as V3_0 +from ..const import COMPOSEFILE_V3_4 as V3_4 from ..utils import build_string_dict from ..utils import parse_bytes from ..utils import parse_nanoseconds_int @@ -405,7 +408,7 @@ def load_mapping(config_files, get_func, entity_type, working_dir=None): external = config.get('external') if external: name_field = 'name' if entity_type == 'Volume' else 'external_name' - validate_external(entity_type, name, config) + validate_external(entity_type, name, config, config_file.version) if isinstance(external, dict): config[name_field] = external.get('name') elif not config.get('name'): @@ -425,14 +428,12 @@ def load_mapping(config_files, get_func, entity_type, working_dir=None): 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 validate_external(entity_type, name, config, version): + if (version < V2_1 or (version >= V3_0 and version < V3_4)) and len(config.keys()) > 1: + raise ConfigurationError( + "{} {} declared as external but specifies additional attributes " + "({}).".format( + entity_type, name, ', '.join(k for k in config if k != 'external'))) def load_services(config_details, config_file): diff --git a/compose/config/serialize.py b/compose/config/serialize.py index 606dd761409..2b8c73f14c1 100644 --- a/compose/config/serialize.py +++ b/compose/config/serialize.py @@ -9,7 +9,7 @@ from compose.const import COMPOSEFILE_V2_1 as V2_1 from compose.const import COMPOSEFILE_V3_0 as V3_0 from compose.const import COMPOSEFILE_V3_2 as V3_2 -from compose.const import COMPOSEFILE_V3_2 as V3_4 +from compose.const import COMPOSEFILE_V3_4 as V3_4 def serialize_config_type(dumper, data): @@ -67,7 +67,7 @@ def denormalize_config(config, image_digests=None): del conf['external_name'] if 'name' in conf: - if config.version < V2_1 or (config.version > V3_0 and config.version < V3_4): + if config.version < V2_1 or (config.version >= V3_0 and config.version < V3_4): del conf['name'] elif 'external' in conf: conf['external'] = True diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index b598d99d540..43cc89e3683 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -285,15 +285,63 @@ def test_config_external_network(self): } } - def test_config_external_volume(self): + def test_config_external_volume_v2(self): self.base_dir = 'tests/fixtures/volumes' - result = self.dispatch(['-f', 'external-volumes.yml', 'config']) + result = self.dispatch(['-f', 'external-volumes-v2.yml', 'config']) json_result = yaml.load(result.stdout) assert 'volumes' in json_result assert json_result['volumes'] == { 'foo': { 'external': True, - 'name': 'foo', + }, + 'bar': { + 'external': { + 'name': 'some_bar', + }, + } + } + + def test_config_external_volume_v2_x(self): + self.base_dir = 'tests/fixtures/volumes' + result = self.dispatch(['-f', 'external-volumes-v2-x.yml', 'config']) + json_result = yaml.load(result.stdout) + assert 'volumes' in json_result + assert json_result['volumes'] == { + 'foo': { + 'external': True, + 'name': 'some_foo', + }, + 'bar': { + 'external': True, + 'name': 'some_bar', + } + } + + def test_config_external_volume_v3_x(self): + self.base_dir = 'tests/fixtures/volumes' + result = self.dispatch(['-f', 'external-volumes-v3-x.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_external_volume_v3_4(self): + self.base_dir = 'tests/fixtures/volumes' + result = self.dispatch(['-f', 'external-volumes-v3-4.yml', 'config']) + json_result = yaml.load(result.stdout) + assert 'volumes' in json_result + assert json_result['volumes'] == { + 'foo': { + 'external': True, + 'name': 'some_foo', }, 'bar': { 'external': True, diff --git a/tests/fixtures/volumes/external-volumes-v2-x.yml b/tests/fixtures/volumes/external-volumes-v2-x.yml new file mode 100644 index 00000000000..3b736c5f481 --- /dev/null +++ b/tests/fixtures/volumes/external-volumes-v2-x.yml @@ -0,0 +1,17 @@ +version: "2.1" + +services: + web: + image: busybox + command: top + volumes: + - foo:/var/lib/ + - bar:/etc/ + +volumes: + foo: + external: true + name: some_foo + bar: + external: + name: some_bar diff --git a/tests/fixtures/volumes/external-volumes.yml b/tests/fixtures/volumes/external-volumes-v2.yml similarity index 92% rename from tests/fixtures/volumes/external-volumes.yml rename to tests/fixtures/volumes/external-volumes-v2.yml index 05c6c4844fe..4025b53b19f 100644 --- a/tests/fixtures/volumes/external-volumes.yml +++ b/tests/fixtures/volumes/external-volumes-v2.yml @@ -1,4 +1,4 @@ -version: "2.1" +version: "2" services: web: diff --git a/tests/fixtures/volumes/external-volumes-v3-4.yml b/tests/fixtures/volumes/external-volumes-v3-4.yml new file mode 100644 index 00000000000..76c8421dc54 --- /dev/null +++ b/tests/fixtures/volumes/external-volumes-v3-4.yml @@ -0,0 +1,17 @@ +version: "3.4" + +services: + web: + image: busybox + command: top + volumes: + - foo:/var/lib/ + - bar:/etc/ + +volumes: + foo: + external: true + name: some_foo + bar: + external: + name: some_bar diff --git a/tests/fixtures/volumes/external-volumes-v3-x.yml b/tests/fixtures/volumes/external-volumes-v3-x.yml new file mode 100644 index 00000000000..903fee64728 --- /dev/null +++ b/tests/fixtures/volumes/external-volumes-v3-x.yml @@ -0,0 +1,16 @@ +version: "3.0" + +services: + web: + image: busybox + command: top + volumes: + - foo:/var/lib/ + - bar:/etc/ + +volumes: + foo: + external: true + bar: + external: + name: some_bar From 9e1388eba64c26328af700d357bd71eabe7fc6bd Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 16 Oct 2017 12:41:29 -0700 Subject: [PATCH 3050/4072] Add specific handling for pywintypes.error Signed-off-by: Joffrey F --- compose/cli/errors.py | 20 ++++++++++++++++++++ tests/unit/cli/errors_test.py | 21 +++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/compose/cli/errors.py b/compose/cli/errors.py index 23e065c99b5..1506aa66078 100644 --- a/compose/cli/errors.py +++ b/compose/cli/errors.py @@ -57,6 +57,26 @@ def handle_connection_errors(client): except (ReadTimeout, socket.timeout) as e: log_timeout_error(client.timeout) raise ConnectionError() + except Exception as e: + if is_windows(): + import pywintypes + if isinstance(e, pywintypes.error): + log_windows_pipe_error(e) + raise ConnectionError() + raise + + +def log_windows_pipe_error(exc): + if exc.winerror == 232: # https://github.com/docker/compose/issues/5005 + log.error( + "The current Compose file version is not compatible with your engine version. " + "Please upgrade your Compose file to a more recent version, or set " + "a COMPOSE_API_VERSION in your environment." + ) + else: + log.error( + "Windows named pipe error: {} (code: {})".format(exc.strerror, exc.winerror) + ) def log_timeout_error(timeout): diff --git a/tests/unit/cli/errors_test.py b/tests/unit/cli/errors_test.py index 7406a88803f..68326d1c753 100644 --- a/tests/unit/cli/errors_test.py +++ b/tests/unit/cli/errors_test.py @@ -7,6 +7,7 @@ from compose.cli import errors from compose.cli.errors import handle_connection_errors +from compose.const import IS_WINDOWS_PLATFORM from tests import mock @@ -65,3 +66,23 @@ def test_api_error_version_other_unicode_explanation(self, mock_logging): raise APIError(None, None, msg) mock_logging.error.assert_called_once_with(msg) + + @pytest.mark.skipif(not IS_WINDOWS_PLATFORM, reason='Needs pywin32') + def test_windows_pipe_error_no_data(self, mock_logging): + import pywintypes + with pytest.raises(errors.ConnectionError): + with handle_connection_errors(mock.Mock(api_version='1.22')): + raise pywintypes.error(232, 'WriteFile', 'The pipe is being closed.') + + _, args, _ = mock_logging.error.mock_calls[0] + assert "The current Compose file version is not compatible with your engine version." in args[0] + + @pytest.mark.skipif(not IS_WINDOWS_PLATFORM, reason='Needs pywin32') + def test_windows_pipe_error_misc(self, mock_logging): + import pywintypes + with pytest.raises(errors.ConnectionError): + with handle_connection_errors(mock.Mock(api_version='1.22')): + raise pywintypes.error(231, 'WriteFile', 'The pipe is busy.') + + _, args, _ = mock_logging.error.mock_calls[0] + assert "Windows named pipe error: The pipe is busy. (code: 231)" == args[0] From 2556668c8c98a33a43a6bd69fe19c07f890fca8f Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 16 Oct 2017 13:54:30 -0700 Subject: [PATCH 3051/4072] Add check_duplicate=True when creating network 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 0f42eb20a11..2e0a7e6ecdb 100644 --- a/compose/network.py +++ b/compose/network.py @@ -79,6 +79,7 @@ def ensure(self): enable_ipv6=self.enable_ipv6, labels=self._labels, attachable=version_gte(self.client._version, '1.24') or None, + check_duplicate=True, ) def remove(self): From 63b5722b16c3dd57b331d5508eb2a2d13e44cbbd Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 17 Oct 2017 13:36:06 -0700 Subject: [PATCH 3052/4072] flake8 Signed-off-by: Joffrey F --- tests/acceptance/cli_test.py | 4 ---- tests/integration/testcases.py | 2 -- 2 files changed, 6 deletions(-) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 43cc89e3683..8ba43b00f05 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -493,10 +493,6 @@ def test_pull_with_ignore_pull_failures(self): 'image library/nonexisting-image:latest not found' in result.stderr or 'pull access denied for nonexisting-image' in result.stderr) - def test_pull_with_quiet(self): - assert self.dispatch(['pull', '--quiet']).stderr == '' - assert self.dispatch(['pull', '--quiet']).stdout == '' - def test_pull_with_parallel_failure(self): result = self.dispatch([ '-f', 'ignore-pull-failures.yml', 'pull', '--parallel'], diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index 8435f97ddcf..b72fb53a81f 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -75,7 +75,6 @@ def v2_1_only(): return min_version_skip(V2_1) - def v2_2_only(): return min_version_skip(V2_2) @@ -84,7 +83,6 @@ def v2_3_only(): return min_version_skip(V2_3) - def v3_only(): return min_version_skip(V3_0) From a0f95afcd1cdf781e7eccdc9ed65d27c260e8534 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 16 Oct 2017 16:50:51 -0700 Subject: [PATCH 3053/4072] Bump 1.17.0-rc1 Signed-off-by: Joffrey F --- CHANGELOG.md | 61 +++++++++++++++++++++++++++++++++++++++++++-- compose/__init__.py | 2 +- script/run/run.sh | 2 +- 3 files changed, 61 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5583768559c..cff19d8793c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,64 @@ Change log ========== +1.17.0 (2017-11-03) +------------------- + +### New features + +#### Compose file version 3.4 + +- Introduced version 3.4 of the `docker-compose.yml` specification. + This version requires to be used with Docker Engine 17.06.0 or above. + +- Added support for `cache_from`, `network` and `target` options in build + configurations + +- Added support for the `order` parameter in the `update_config` section + +- Added support for setting a custom name in volume definitions using + the `name` parameter + +#### Compose file version 2.3 + +- Added support for `shm_size` option in build configuration + +#### Compose file version 2.x + +- Added support for extension fields (`x-*`). Also available for v3.4 files + +#### All formats + +- Added new `--no-start` to the `up` command, allowing users to create all + resources (networks, volumes, containers) without starting services. + The `create` command is deprecated in favor of this new option + +### Bugfixes + +- Fixed a bug where `extra_hosts` values would be overridden by extension + files instead of merging together + +- Fixed a bug where the validation for v3.2 files would prevent using the + `consistency` field in service volume definitions + +- Fixed a bug that would cause a crash when configuration fields expecting + unique items would contain duplicates + +- Fixed a bug where mount overrides with a different mode would create a + duplicate entry instead of overriding the original entry + +- Fixed a bug where build labels declared as a list wouldn't be properly + parsed + +- Fixed a bug where the output of `docker-compose config` would be invalid + for some versions if the file contained custom-named external volumes + +- Improved error handling when issuing a build command on Windows using an + unsupported file version + +- Fixed an issue where networks with identical names would sometimes be + created when running `up` commands concurrently. + 1.16.1 (2017-09-01) ------------------- @@ -8,7 +66,6 @@ Change log - Fixed bug that prevented using `extra_hosts` in several configuration files. - 1.16.0 (2017-08-31) ------------------- @@ -19,7 +76,7 @@ Change log - Introduced version 2.3 of the `docker-compose.yml` specification. This version requires to be used with Docker Engine 17.06.0 or above. -- Added support for the `target` parameter in network configurations +- Added support for the `target` parameter in build configurations - Added support for the `start_period` parameter in healthcheck configurations diff --git a/compose/__init__.py b/compose/__init__.py index 2e41ca89650..86542ec44f4 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.16.1' +__version__ = '1.17.0-rc1' diff --git a/script/run/run.sh b/script/run/run.sh index f1754d05a43..498226288c1 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.16.1" +VERSION="1.17.0-rc1" IMAGE="docker/compose:$VERSION" From 7f1dc09404f60d704f47bd03bea7f2e86de55cae Mon Sep 17 00:00:00 2001 From: Alexey Rokhin Date: Wed, 17 May 2017 15:21:35 +0300 Subject: [PATCH 3054/4072] Fix testcases.py formatting Signed-off-by: Alexey Rokhin --- tests/integration/testcases.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index b72fb53a81f..8435f97ddcf 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -75,6 +75,7 @@ def v2_1_only(): return min_version_skip(V2_1) + def v2_2_only(): return min_version_skip(V2_2) @@ -83,6 +84,7 @@ def v2_3_only(): return min_version_skip(V2_3) + def v3_only(): return min_version_skip(V3_0) From 11bd32b597ca0f710a68a0e3d02908a97542e2ca Mon Sep 17 00:00:00 2001 From: Alexey Rokhin Date: Wed, 17 May 2017 16:18:28 +0300 Subject: [PATCH 3055/4072] skip cpu_percent test for Linux Signed-off-by: Alexey Rokhin --- 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 84b54fe419d..28cca4aad2e 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -28,6 +28,7 @@ from compose.const import LABEL_PROJECT from compose.const import LABEL_SERVICE from compose.const import LABEL_VERSION +from compose.const import IS_WINDOWS_PLATFORM from compose.container import Container from compose.errors import OperationFailedError from compose.project import OneOffFilter From 3089eda5ab58b8898d82d5dca98a184fbcb133d9 Mon Sep 17 00:00:00 2001 From: Alexey Rokhin Date: Wed, 17 May 2017 16:42:43 +0300 Subject: [PATCH 3056/4072] service_test.py reorder imports Signed-off-by: Alexey Rokhin --- 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 28cca4aad2e..84b54fe419d 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -28,7 +28,6 @@ from compose.const import LABEL_PROJECT from compose.const import LABEL_SERVICE from compose.const import LABEL_VERSION -from compose.const import IS_WINDOWS_PLATFORM from compose.container import Container from compose.errors import OperationFailedError from compose.project import OneOffFilter From aee944393e7e27d2ebd6744a90c388134b2a542d Mon Sep 17 00:00:00 2001 From: Joel Barciauskas Date: Wed, 12 Apr 2017 17:45:09 -0400 Subject: [PATCH 3057/4072] Add --quiet parameter to docker-compose pull, using existing silent flag Signed-off-by: Joel Barciauskas --- compose/project.py | 2 +- tests/acceptance/cli_test.py | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/compose/project.py b/compose/project.py index 2310a2fcc68..1faf97d40cd 100644 --- a/compose/project.py +++ b/compose/project.py @@ -496,7 +496,7 @@ def pull(self, service_names=None, ignore_pull_failures=False, parallel_pull=Fal if parallel_pull: def pull_service(service): - service.pull(ignore_pull_failures, True) + service.pull(ignore_pull_failures, True, silent=silent) _, errors = parallel.parallel_execute( services, diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 78d1c1eb181..e721b940f57 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -463,6 +463,10 @@ def test_pull_with_parallel_failure(self): re.compile('''^(ERROR: )?(b')?.* nonexisting-image''', re.MULTILINE)) + def test_pull_with_quiet(self): + assert self.dispatch(['pull', '--quiet']).stderr == '' + assert self.dispatch(['pull', '--quiet']).stdout == '' + def test_build_plain(self): self.base_dir = 'tests/fixtures/simple-dockerfile' self.dispatch(['build', 'simple']) From f8b2981fb9b641a92902c2b8d05d5a007a040b0a Mon Sep 17 00:00:00 2001 From: NikitaVlaznev Date: Mon, 19 Jun 2017 17:05:19 +0300 Subject: [PATCH 3058/4072] Fix double silent argument value Fix for "TypeError: pull() got multiple values for keyword argument 'silent'." This change https://github.com/docker/compose/commit/e9b6cc23fcf01d4768c7e082b7bc91b43ff84e7e caused additional value to be passed for the 'silent' argument, that was already passed there: https://github.com/docker/compose/commit/f85da99ef3273794e855afda8678174419d3bf4f Signed-off-by: Nikita Vlaznev --- compose/project.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/project.py b/compose/project.py index 1faf97d40cd..59d58f2f679 100644 --- a/compose/project.py +++ b/compose/project.py @@ -496,7 +496,7 @@ def pull(self, service_names=None, ignore_pull_failures=False, parallel_pull=Fal if parallel_pull: def pull_service(service): - service.pull(ignore_pull_failures, True, silent=silent) + service.pull(ignore_pull_failures, silent=silent) _, errors = parallel.parallel_execute( services, From 5b4573e7e5e379c0777cb8e38a71d612fe354db5 Mon Sep 17 00:00:00 2001 From: Evan Shaw Date: Sat, 1 Jul 2017 13:40:02 +1200 Subject: [PATCH 3059/4072] Always silence pull output with --parallel This is how things were prior to the addition of the --quiet flag. Making it not silent produces output that's weird and difficult to read. Signed-off-by: Evan Shaw --- compose/project.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/project.py b/compose/project.py index 59d58f2f679..2310a2fcc68 100644 --- a/compose/project.py +++ b/compose/project.py @@ -496,7 +496,7 @@ def pull(self, service_names=None, ignore_pull_failures=False, parallel_pull=Fal if parallel_pull: def pull_service(service): - service.pull(ignore_pull_failures, silent=silent) + service.pull(ignore_pull_failures, True) _, errors = parallel.parallel_execute( services, From 9587556e8f7d3ef745399f8423627a9a54b3dfdd Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 28 Aug 2017 17:35:55 -0700 Subject: [PATCH 3060/4072] Add --no-start flag to up command. Deprecate create command. Signed-off-by: Joffrey F --- compose/cli/main.py | 18 ++++++++++++++++-- compose/project.py | 6 ++++-- tests/acceptance/cli_test.py | 25 +++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 4 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 83bc7d58cb0..21bf1f30887 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -319,6 +319,7 @@ def config(self, config_options, options): def create(self, options): """ Creates containers for a service. + This command is deprecated. Use the `up` command with `--no-start` instead. Usage: create [options] [SERVICE...] @@ -332,6 +333,11 @@ def create(self, options): """ service_names = options['SERVICE'] + log.warn( + 'The create command is deprecated. ' + 'Use the up command with the --no-start flag instead.' + ) + self.project.create( service_names=service_names, strategy=convergence_strategy_from_opts(options), @@ -902,6 +908,7 @@ def up(self, options): --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-start Don't start the services after creating them. --build Build images before starting containers. --abort-on-container-exit Stops all containers if any container was stopped. Incompatible with -d. @@ -922,10 +929,16 @@ def up(self, options): timeout = timeout_from_opts(options) remove_orphans = options['--remove-orphans'] detached = options.get('-d') + no_start = options.get('--no-start') - if detached and cascade_stop: + if detached and (cascade_stop or exit_value_from): raise UserError("--abort-on-container-exit and -d cannot be combined.") + if no_start: + for excluded in ['-d', '--abort-on-container-exit', '--exit-code-from']: + if options.get(excluded): + raise UserError('--no-start and {} cannot be combined.'.format(excluded)) + with up_shutdown_context(self.project, service_names, timeout, detached): to_attach = self.project.up( service_names=service_names, @@ -936,9 +949,10 @@ def up(self, options): detached=detached, remove_orphans=remove_orphans, scale_override=parse_scale_args(options['--scale']), + start=not no_start ) - if detached: + if detached or no_start: return attached_containers = filter_containers_to_service_names(to_attach, service_names) diff --git a/compose/project.py b/compose/project.py index 2310a2fcc68..c8b57edd209 100644 --- a/compose/project.py +++ b/compose/project.py @@ -412,7 +412,8 @@ def up(self, detached=False, remove_orphans=False, scale_override=None, - rescale=True): + rescale=True, + start=True): warn_for_swarm_mode(self.client) @@ -436,7 +437,8 @@ def do(service): timeout=timeout, detached=detached, scale_override=scale_override.get(service.name), - rescale=rescale + rescale=rescale, + start=start ) def get_deps(service): diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index e721b940f57..3a5e17ad841 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -776,6 +776,31 @@ def test_up(self): for service in services: assert self.lookup(container, service.name) + @v2_only() + def test_up_no_start(self): + self.base_dir = 'tests/fixtures/v2-full' + self.dispatch(['up', '--no-start'], None) + + services = self.project.get_services() + + default_network = self.project.networks.networks['default'].full_name + front_network = self.project.networks.networks['front'].full_name + networks = self.client.networks(names=[default_network, front_network]) + assert len(networks) == 2 + + for service in services: + containers = service.containers(stopped=True) + assert len(containers) == 1 + + container = containers[0] + assert not container.is_running + assert container.get('State.Status') == 'created' + + volumes = self.project.volumes.volumes + assert 'data' in volumes + volume = volumes['data'] + assert volume.exists() + @v2_only() def test_up_no_ansi(self): self.base_dir = 'tests/fixtures/v2-simple' From 42aa1c34475f121dbc0710312a6645ee1cc11a9a Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 28 Aug 2017 18:18:31 -0700 Subject: [PATCH 3061/4072] Reduce up() cyclomatic complexity Signed-off-by: Joffrey F --- compose/cli/main.py | 62 +++++++++++++++++++++++++-------------------- 1 file changed, 35 insertions(+), 27 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 21bf1f30887..face38e6d33 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -969,33 +969,10 @@ def up(self, options): if cascade_stop: print("Aborting on container exit...") - - exit_code = 0 - if exit_value_from: - candidates = list(filter( - lambda c: c.service == exit_value_from, - attached_containers)) - if not candidates: - log.error( - 'No containers matching the spec "{0}" ' - 'were run.'.format(exit_value_from) - ) - exit_code = 2 - elif len(candidates) > 1: - exit_values = filter( - lambda e: e != 0, - [c.inspect()['State']['ExitCode'] for c in candidates] - ) - - exit_code = exit_values[0] - else: - exit_code = candidates[0].inspect()['State']['ExitCode'] - else: - for e in self.project.containers(service_names=options['SERVICE'], stopped=True): - if (not e.is_running and cascade_starter == e.name): - if not e.exit_code == 0: - exit_code = e.exit_code - break + all_containers = self.project.containers(service_names=options['SERVICE'], stopped=True) + exit_code = compute_exit_code( + exit_value_from, attached_containers, cascade_starter, all_containers + ) self.project.stop(service_names=service_names, timeout=timeout) sys.exit(exit_code) @@ -1016,6 +993,37 @@ def version(cls, options): print(get_version_info('full')) +def compute_exit_code(exit_value_from, attached_containers, cascade_starter, all_containers): + exit_code = 0 + if exit_value_from: + candidates = list(filter( + lambda c: c.service == exit_value_from, + attached_containers)) + if not candidates: + log.error( + 'No containers matching the spec "{0}" ' + 'were run.'.format(exit_value_from) + ) + exit_code = 2 + elif len(candidates) > 1: + exit_values = filter( + lambda e: e != 0, + [c.inspect()['State']['ExitCode'] for c in candidates] + ) + + exit_code = exit_values[0] + else: + exit_code = candidates[0].inspect()['State']['ExitCode'] + else: + for e in all_containers: + if (not e.is_running and cascade_starter == e.name): + if not e.exit_code == 0: + exit_code = e.exit_code + break + + return exit_code + + def convergence_strategy_from_opts(options): no_recreate = options['--no-recreate'] force_recreate = options['--force-recreate'] From 8c6f2217c45dd004b4fa10cca1cc7023a6f241ea Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 25 Aug 2017 18:09:06 -0700 Subject: [PATCH 3062/4072] Add support for extension fields in v2.x and v3.4 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_v2.2.json | 1 + compose/config/config_schema_v2.3.json | 1 + compose/config/config_schema_v3.4-beta.json | 1 + compose/config/validation.py | 10 ++++++++++ tests/unit/config/config_test.py | 14 +++++++++++++- 7 files changed, 28 insertions(+), 1 deletion(-) diff --git a/compose/config/config_schema_v2.0.json b/compose/config/config_schema_v2.0.json index 14bafab4033..2ad62ac5221 100644 --- a/compose/config/config_schema_v2.0.json +++ b/compose/config/config_schema_v2.0.json @@ -41,6 +41,7 @@ } }, + "patternProperties": {"^x-": {}}, "additionalProperties": false, "definitions": { diff --git a/compose/config/config_schema_v2.1.json b/compose/config/config_schema_v2.1.json index 8a5e128347c..24e6ba02cea 100644 --- a/compose/config/config_schema_v2.1.json +++ b/compose/config/config_schema_v2.1.json @@ -41,6 +41,7 @@ } }, + "patternProperties": {"^x-": {}}, "additionalProperties": false, "definitions": { diff --git a/compose/config/config_schema_v2.2.json b/compose/config/config_schema_v2.2.json index 58ba409ff1b..86fc5df95d1 100644 --- a/compose/config/config_schema_v2.2.json +++ b/compose/config/config_schema_v2.2.json @@ -41,6 +41,7 @@ } }, + "patternProperties": {"^x-": {}}, "additionalProperties": false, "definitions": { diff --git a/compose/config/config_schema_v2.3.json b/compose/config/config_schema_v2.3.json index 7a9bdfdf1d7..a790bb4050c 100644 --- a/compose/config/config_schema_v2.3.json +++ b/compose/config/config_schema_v2.3.json @@ -41,6 +41,7 @@ } }, + "patternProperties": {"^x-": {}}, "additionalProperties": false, "definitions": { diff --git a/compose/config/config_schema_v3.4-beta.json b/compose/config/config_schema_v3.4-beta.json index 190c05f2c14..cba063202f0 100644 --- a/compose/config/config_schema_v3.4-beta.json +++ b/compose/config/config_schema_v3.4-beta.json @@ -64,6 +64,7 @@ } }, + "patternProperties": {"^x-": {}}, "additionalProperties": false, "definitions": { diff --git a/compose/config/validation.py b/compose/config/validation.py index 0b7961e5a7b..c6722a14d19 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -239,6 +239,16 @@ def handle_error_for_schema_with_id(error, path): invalid_config_key = parse_key_from_error_msg(error) return get_unsupported_config_msg(path, invalid_config_key) + if schema_id.startswith('config_schema_v'): + invalid_config_key = parse_key_from_error_msg(error) + return ('Invalid top-level property "{key}". Valid top-level ' + 'sections for this Compose file are: {properties}, and ' + 'extensions starting with "x-".\n\n{explanation}').format( + key=invalid_config_key, + properties=', '.join(error.schema['properties'].keys()), + explanation=VERSION_EXPLANATION + ) + if not error.path: return '{}\n\n{}'.format(error.message, VERSION_EXPLANATION) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 64429015739..14dd0117981 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -251,7 +251,7 @@ def test_v1_file_with_version_is_invalid(self): ) ) - assert 'Additional properties are not allowed' in excinfo.exconly() + assert 'Invalid top-level property "web"' in excinfo.exconly() assert VERSION_EXPLANATION in excinfo.exconly() def test_named_volume_config_empty(self): @@ -773,6 +773,18 @@ def test_load_sorts_in_dependency_order(self): assert services[1]['name'] == 'db' assert services[2]['name'] == 'web' + def test_load_with_extensions(self): + config_details = build_config_details({ + 'version': '2.3', + 'x-data': { + 'lambda': 3, + 'excess': [True, {}] + } + }) + + config_data = config.load(config_details) + assert config_data.services == [] + def test_config_build_configuration(self): service = config.load( build_config_details( From d48296213b76d891db2bfdd7a3fa2eab028d80c3 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 1 Sep 2017 12:14:56 -0700 Subject: [PATCH 3063/4072] Update release process with most recent changes Signed-off-by: Joffrey F --- project/RELEASE-PROCESS.md | 33 +++++++++++++++++++++------------ script/release/make-branch | 3 +-- 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/project/RELEASE-PROCESS.md b/project/RELEASE-PROCESS.md index c1834f2fbbb..5b30545f459 100644 --- a/project/RELEASE-PROCESS.md +++ b/project/RELEASE-PROCESS.md @@ -24,7 +24,7 @@ As part of this script you'll be asked to: If the next release will be an RC, append `-rcN`, e.g. `1.4.0-rc1`. -2. Write release notes in `CHANGES.md`. +2. Write release notes in `CHANGELOG.md`. Almost every feature enhancement should be mentioned, with the most visible/exciting ones first. Use descriptive sentences and give context @@ -67,16 +67,13 @@ 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 Travis - build has finished, otherwise you'll be downloading an old binary. +1. Download the different platform binaries by running the following script: - https://dl.bintray.com/docker-compose/$BRANCH_NAME/ + `./script/release/download-binaries $VERSION` -2. Download the windows binary from AppVeyor + The binaries for Linux, OSX and Windows will be downloaded in the `binaries-$VERSION` folder. - https://ci.appveyor.com/project/docker/compose - -3. Draft a release from the tag on GitHub (the script will open the window for +3. Draft a release from the tag on GitHub (the `build-binaries` script will open the window for you) The tag will only be present on Github when you run the `push-release` @@ -87,18 +84,30 @@ When prompted build the non-linux binaries and test them. 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)**. - 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. + Docker for Mac and Windows will automatically install the latest version of Docker Engine for you. Alternatively, you can use the usual commands to install or upgrade Compose: ``` - curl -L https://github.com/docker/compose/releases/download/1.9.0/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose + curl -L https://github.com/docker/compose/releases/download/1.16.0/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose chmod +x /usr/local/bin/docker-compose ``` See the [install docs](https://docs.docker.com/compose/install/) for more install options and instructions. - Here's what's new: + ## Compose file format compatibility matrix + + | Compose file format | Docker Engine | + | --- | --- | + | 3.3 | 17.06.0+ | + | 3.0 – 3.2 | 1.13.0+ | + | 2.3| 17.06.0+ | + | 2.2 | 1.13.0+ | + | 2.1 | 1.12.0+ | + | 2.0 | 1.10.0+ | + | 1.0 | 1.9.1+ | + + ## Changes ...release notes go here... @@ -119,7 +128,7 @@ When prompted build the non-linux binaries and test them. 9. Check that all the binaries download (following the install instructions) and run. -10. Email maintainers@dockerproject.org and engineering@docker.com about the new release. +10. Announce the release on the appropriate Slack channel(s). ## If it’s a stable release (not an RC) diff --git a/script/release/make-branch b/script/release/make-branch index 7ccf3f055b5..b8a0cd31ee7 100755 --- a/script/release/make-branch +++ b/script/release/make-branch @@ -65,8 +65,7 @@ git config "branch.${BRANCH}.release" $VERSION editor=${EDITOR:-vim} -echo "Update versions in docs/install.md, compose/__init__.py, script/run/run.sh" -$editor docs/install.md +echo "Update versions in compose/__init__.py, script/run/run.sh" $editor compose/__init__.py $editor script/run/run.sh From 158a78657854a3f82c94861752996a1951c8d02d Mon Sep 17 00:00:00 2001 From: Vincent Demeester Date: Tue, 19 Sep 2017 18:27:02 +0200 Subject: [PATCH 3064/4072] Sync composefile v3.2 schema with `docker/cli` Signed-off-by: Vincent Demeester --- compose/config/config_schema_v3.2.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/compose/config/config_schema_v3.2.json b/compose/config/config_schema_v3.2.json index b26b2c6c6f1..2ca8e92dbe0 100644 --- a/compose/config/config_schema_v3.2.json +++ b/compose/config/config_schema_v3.2.json @@ -170,7 +170,8 @@ "type": "array", "items": { "oneOf": [ - {"type": ["string", "number"], "format": "ports"}, + {"type": "number", "format": "ports"}, + {"type": "string", "format": "ports"}, { "type": "object", "properties": { @@ -249,6 +250,7 @@ "source": {"type": "string"}, "target": {"type": "string"}, "read_only": {"type": "boolean"}, + "consistency": {"type": "string"}, "bind": { "type": "object", "properties": { From 1da5b54d75024bbe5ceae9a296200681caaa77fd Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 26 Sep 2017 16:25:05 -0700 Subject: [PATCH 3065/4072] Fix oneOf validator parser to correctly process uniqueItems errors Signed-off-by: Joffrey F --- compose/config/validation.py | 15 +++++++-------- tests/unit/config/config_test.py | 25 ++++++++++++++++++++----- 2 files changed, 27 insertions(+), 13 deletions(-) diff --git a/compose/config/validation.py b/compose/config/validation.py index c6722a14d19..940775a2097 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -325,7 +325,6 @@ 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 @@ -337,6 +336,13 @@ def _parse_oneof_validator(error): invalid_config_key = parse_key_from_error_msg(context) return (None, "contains unsupported option: '{}'".format(invalid_config_key)) + if context.validator == 'uniqueItems': + return ( + path_string(context.path) if context.path else None, + "contains non-unique items, please remove duplicates from {}".format( + context.instance), + ) + if context.path: return ( path_string(context.path), @@ -345,13 +351,6 @@ def _parse_oneof_validator(error): _parse_valid_types_from_validator(context.validator_value)), ) - if context.validator == 'uniqueItems': - return ( - None, - "contains non unique items, please remove duplicates from {}".format( - context.instance), - ) - if context.validator == 'type': types.append(context.validator_value) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 14dd0117981..de9a6130257 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -581,6 +581,20 @@ def test_config_invalid_service_name_raise_validation_error(self): assert 'Invalid service name \'mong\\o\'' in excinfo.exconly() + def test_config_duplicate_cache_from_values_validation_error(self): + with pytest.raises(ConfigurationError) as exc: + config.load( + build_config_details({ + 'version': '2.3', + 'services': { + 'test': {'build': {'context': '.', 'cache_from': ['a', 'b', 'a']}} + } + + }) + ) + + assert 'build.cache_from contains non-unique items' in exc.exconly() + def test_load_with_multiple_files_v1(self): base_file = config.ConfigFile( 'base.yaml', @@ -2751,11 +2765,12 @@ def test_config_valid_expose_format_validation(self): def check_config(self, cfg): config.load( - build_config_details( - {'web': dict(image='busybox', **cfg)}, - 'working_dir', - 'filename.yml' - ) + build_config_details({ + 'version': '2.3', + 'services': { + 'web': dict(image='busybox', **cfg) + }, + }, 'working_dir', 'filename.yml') ) From 53928a17c0d36c3e1a9b835febdb8e88797196b7 Mon Sep 17 00:00:00 2001 From: French Ben Date: Mon, 18 Sep 2017 16:30:32 -0700 Subject: [PATCH 3066/4072] Simple patch to allow s390x images to be built Needs integration with CI and s390x machine integration Signed-off-by: French Ben --- Dockerfile.s390x | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 Dockerfile.s390x diff --git a/Dockerfile.s390x b/Dockerfile.s390x new file mode 100644 index 00000000000..aa71e27bc3d --- /dev/null +++ b/Dockerfile.s390x @@ -0,0 +1,6 @@ +FROM s390x/python:3.6.2-slim +ARG COMPOSE_VERSION=1.16.1 + +RUN pip install --no-cache-dir docker-compose==$COMPOSE_VERSION + +ENTRYPOINT ["docker-compose"] From cb2d65556b316b58debeb6e5f5ccc3010d0171d7 Mon Sep 17 00:00:00 2001 From: French Ben Date: Tue, 19 Sep 2017 10:14:55 -0700 Subject: [PATCH 3067/4072] Use slim alpine instead of bulky debian Signed-off-by: French Ben --- Dockerfile.s390x | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/Dockerfile.s390x b/Dockerfile.s390x index aa71e27bc3d..3b19bb390b9 100644 --- a/Dockerfile.s390x +++ b/Dockerfile.s390x @@ -1,6 +1,15 @@ -FROM s390x/python:3.6.2-slim +FROM s390x/alpine:3.6 + ARG COMPOSE_VERSION=1.16.1 -RUN pip install --no-cache-dir docker-compose==$COMPOSE_VERSION +RUN apk add --update --no-cache \ + python \ + py-pip \ + && pip install --no-cache-dir docker-compose==$COMPOSE_VERSION \ + && rm -rf /var/cache/apk/* + +WORKDIR /data +VOLUME /data + ENTRYPOINT ["docker-compose"] From 78fe655dbce206e3e6e64ce3e62af94267782487 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 26 Sep 2017 17:19:29 -0700 Subject: [PATCH 3068/4072] Revert 3.4-beta temp rename Signed-off-by: Joffrey F --- ...nfig_schema_v3.4-beta.json => config_schema_v3.4.json} | 8 +++++--- compose/const.py | 2 +- docker-compose.spec | 4 ++-- 3 files changed, 8 insertions(+), 6 deletions(-) rename compose/config/{config_schema_v3.4-beta.json => config_schema_v3.4.json} (98%) diff --git a/compose/config/config_schema_v3.4-beta.json b/compose/config/config_schema_v3.4.json similarity index 98% rename from compose/config/config_schema_v3.4-beta.json rename to compose/config/config_schema_v3.4.json index cba063202f0..dae7d7d2345 100644 --- a/compose/config/config_schema_v3.4-beta.json +++ b/compose/config/config_schema_v3.4.json @@ -1,6 +1,7 @@ + { "$schema": "http://json-schema.org/draft-04/schema#", - "id": "config_schema_v3.4-beta.json", + "id": "config_schema_v3.4.json", "type": "object", "required": ["version"], @@ -316,7 +317,7 @@ "additionalProperties": false, "properties": { "disable": {"type": "boolean"}, - "interval": {"type": "string"}, + "interval": {"type": "string", "format": "duration"}, "retries": {"type": "number"}, "test": { "oneOf": [ @@ -324,7 +325,8 @@ {"type": "array", "items": {"type": "string"}} ] }, - "timeout": {"type": "string"} + "timeout": {"type": "string", "format": "duration"}, + "start_period": {"type": "string", "format": "duration"} } }, "deployment": { diff --git a/compose/const.py b/compose/const.py index 809f7c7d4e7..b5970f82ae9 100644 --- a/compose/const.py +++ b/compose/const.py @@ -31,7 +31,7 @@ COMPOSEFILE_V3_1 = ComposeVersion('3.1') COMPOSEFILE_V3_2 = ComposeVersion('3.2') COMPOSEFILE_V3_3 = ComposeVersion('3.3') -COMPOSEFILE_V3_4 = ComposeVersion('3.4-beta') +COMPOSEFILE_V3_4 = ComposeVersion('3.4') API_VERSIONS = { COMPOSEFILE_V1: '1.21', diff --git a/docker-compose.spec b/docker-compose.spec index fe5651f6a2f..9c46421f0a5 100644 --- a/docker-compose.spec +++ b/docker-compose.spec @@ -63,8 +63,8 @@ exe = EXE(pyz, 'DATA' ), ( - 'compose/config/config_schema_v3.4-beta.json', - 'compose/config/config_schema_v3.4-beta.json', + 'compose/config/config_schema_v3.4.json', + 'compose/config/config_schema_v3.4.json', 'DATA' ), ( From 21d597c2b4b2c2293bf02a22db6664ae0af7bc62 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 27 Sep 2017 18:24:46 -0700 Subject: [PATCH 3069/4072] Avoid import ConfigurationError inside compose.utils (circular import) Signed-off-by: Joffrey F --- compose/config/config.py | 5 ++++- compose/utils.py | 3 +-- tests/unit/utils_test.py | 8 ++++++++ 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 0fddfd3a456..b90ab030588 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -762,7 +762,10 @@ def process_blkio_config(service_dict): for field in ['device_read_bps', 'device_write_bps']: if field in service_dict['blkio_config']: for v in service_dict['blkio_config'].get(field, []): - v['rate'] = parse_bytes(v.get('rate', 0)) + rate = v.get('rate', 0) + v['rate'] = parse_bytes(rate) + if v['rate'] is None: + raise ConfigurationError('Invalid format for bytes value: "{}"'.format(rate)) for field in ['device_read_iops', 'device_write_iops']: if field in service_dict['blkio_config']: diff --git a/compose/utils.py b/compose/utils.py index 1ede4d37d84..197ae6eb29a 100644 --- a/compose/utils.py +++ b/compose/utils.py @@ -12,7 +12,6 @@ from docker.errors import DockerException from docker.utils import parse_bytes as sdk_parse_bytes -from .config.errors import ConfigurationError from .errors import StreamParseError from .timeparse import MULTIPLIERS from .timeparse import timeparse @@ -143,4 +142,4 @@ def parse_bytes(n): try: return sdk_parse_bytes(n) except DockerException: - raise ConfigurationError('Invalid format for bytes value: {}'.format(n)) + return None diff --git a/tests/unit/utils_test.py b/tests/unit/utils_test.py index 85231957e3c..84becb97554 100644 --- a/tests/unit/utils_test.py +++ b/tests/unit/utils_test.py @@ -60,3 +60,11 @@ def test_with_leading_whitespace(self): {'three': 'four'}, {'x': 2} ] + + +class TestParseBytes(object): + def test_parse_bytes(self): + assert utils.parse_bytes('123kb') == 123 * 1024 + assert utils.parse_bytes(123) == 123 + assert utils.parse_bytes('foobar') is None + assert utils.parse_bytes('123') == 123 From c7cdd63acf77147fcb3d53112c8c56931d671151 Mon Sep 17 00:00:00 2001 From: Marc van den Hoogen Date: Fri, 18 Aug 2017 13:40:11 +0200 Subject: [PATCH 3070/4072] Add shm_size to build-options (issue #3866) * Add shm_size to build configuration * Make it possible to enlarge/customize shm size during build * Value in bytes, or use string like "512M" or "1G" ... * Add to compose format 2.3 and (provisionally) >=3.5 format * Add automated test for shm_size in build-opts Signed-off-by: Marc van den Hoogen Made unit tests compatible with previously added shm_size build-option Signed-off-by: Marc van den Hoogen Also support shm_size build-opt when conf override Signed-off-by: Marc van den Hoogen Automated test for shm_size build-option Signed-off-by: Marc van den Hoogen Schema 3.4, add shm_size to schema 2.3, updated const.py Signed-off-by: Marc van den Hoogen Corrected typo in config_schema_v3.4 Signed-off-by: Marc van den Hoogen Add support for g/m/k units for shm_size in build-opts Signed-off-by: Marc van den Hoogen Reorder imports in service.py Signed-off-by: Marc van den Hoogen --- compose/config/config.py | 1 + compose/config/config_schema_v2.3.json | 3 +- compose/config/config_schema_v3.5.json | 542 ++++++++++++++++++ compose/const.py | 3 + compose/service.py | 2 + tests/acceptance/cli_test.py | 6 + tests/fixtures/build-shm-size/Dockerfile | 4 + .../build-shm-size/docker-compose.yml | 7 + tests/unit/service_test.py | 2 + 9 files changed, 569 insertions(+), 1 deletion(-) create mode 100644 compose/config/config_schema_v3.5.json create mode 100644 tests/fixtures/build-shm-size/Dockerfile create mode 100644 tests/fixtures/build-shm-size/docker-compose.yml diff --git a/compose/config/config.py b/compose/config/config.py index b90ab030588..f16dd01b3f5 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -1020,6 +1020,7 @@ def to_dict(service): md.merge_scalar('dockerfile') md.merge_scalar('network') md.merge_scalar('target') + md.merge_scalar('shm_size') md.merge_mapping('args', parse_build_arguments) md.merge_field('cache_from', merge_unique_items_lists, default=[]) md.merge_mapping('labels', parse_labels) diff --git a/compose/config/config_schema_v2.3.json b/compose/config/config_schema_v2.3.json index a790bb4050c..ceaf44954eb 100644 --- a/compose/config/config_schema_v2.3.json +++ b/compose/config/config_schema_v2.3.json @@ -91,7 +91,8 @@ "labels": {"$ref": "#/definitions/list_or_dict"}, "cache_from": {"$ref": "#/definitions/list_of_strings"}, "network": {"type": "string"}, - "target": {"type": "string"} + "target": {"type": "string"}, + "shm_size": {"type": ["integer", "string"]} }, "additionalProperties": false } diff --git a/compose/config/config_schema_v3.5.json b/compose/config/config_schema_v3.5.json new file mode 100644 index 00000000000..fa95d6a2457 --- /dev/null +++ b/compose/config/config_schema_v3.5.json @@ -0,0 +1,542 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "id": "config_schema_v3.5.json", + "type": "object", + "required": ["version"], + + "properties": { + "version": { + "type": "string" + }, + + "services": { + "id": "#/properties/services", + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "$ref": "#/definitions/service" + } + }, + "additionalProperties": false + }, + + "networks": { + "id": "#/properties/networks", + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "$ref": "#/definitions/network" + } + } + }, + + "volumes": { + "id": "#/properties/volumes", + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "$ref": "#/definitions/volume" + } + }, + "additionalProperties": false + }, + + "secrets": { + "id": "#/properties/secrets", + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "$ref": "#/definitions/secret" + } + }, + "additionalProperties": false + }, + + "configs": { + "id": "#/properties/configs", + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "$ref": "#/definitions/config" + } + }, + "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"}, + "labels": {"$ref": "#/definitions/list_or_dict"}, + "cache_from": {"$ref": "#/definitions/list_of_strings"}, + "network": {"type": "string"}, + "target": {"type": "string"}, + "shm_size": {"type": ["integer", "string"]} + }, + "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"}} + ] + }, + "configs": { + "type": "array", + "items": { + "oneOf": [ + {"type": "string"}, + { + "type": "object", + "properties": { + "source": {"type": "string"}, + "target": {"type": "string"}, + "uid": {"type": "string"}, + "gid": {"type": "string"}, + "mode": {"type": "number"} + } + } + ] + } + }, + "container_name": {"type": "string"}, + "credential_spec": {"type": "object", "properties": { + "file": {"type": "string"}, + "registry": {"type": "string"} + }}, + "depends_on": {"$ref": "#/definitions/list_of_strings"}, + "devices": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "dns": {"$ref": "#/definitions/string_or_list"}, + "dns_search": {"$ref": "#/definitions/string_or_list"}, + "domainname": {"type": "string"}, + "entrypoint": { + "oneOf": [ + {"type": "string"}, + {"type": "array", "items": {"type": "string"}} + ] + }, + "env_file": {"$ref": "#/definitions/string_or_list"}, + "environment": {"$ref": "#/definitions/list_or_dict"}, + + "expose": { + "type": "array", + "items": { + "type": ["string", "number"], + "format": "expose" + }, + "uniqueItems": true + }, + + "external_links": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "extra_hosts": {"$ref": "#/definitions/list_or_dict"}, + "healthcheck": {"$ref": "#/definitions/healthcheck"}, + "hostname": {"type": "string"}, + "image": {"type": "string"}, + "ipc": {"type": "string"}, + "labels": {"$ref": "#/definitions/list_or_dict"}, + "links": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + + "logging": { + "type": "object", + + "properties": { + "driver": {"type": "string"}, + "options": { + "type": "object", + "patternProperties": { + "^.+$": {"type": ["string", "number", "null"]} + } + } + }, + "additionalProperties": false + }, + + "mac_address": {"type": "string"}, + "network_mode": {"type": "string"}, + + "networks": { + "oneOf": [ + {"$ref": "#/definitions/list_of_strings"}, + { + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "oneOf": [ + { + "type": "object", + "properties": { + "aliases": {"$ref": "#/definitions/list_of_strings"}, + "ipv4_address": {"type": "string"}, + "ipv6_address": {"type": "string"} + }, + "additionalProperties": false + }, + {"type": "null"} + ] + } + }, + "additionalProperties": false + } + ] + }, + "pid": {"type": ["string", "null"]}, + + "ports": { + "type": "array", + "items": { + "oneOf": [ + {"type": "number", "format": "ports"}, + {"type": "string", "format": "ports"}, + { + "type": "object", + "properties": { + "mode": {"type": "string"}, + "target": {"type": "integer"}, + "published": {"type": "integer"}, + "protocol": {"type": "string"} + }, + "additionalProperties": false + } + ] + }, + "uniqueItems": true + }, + + "privileged": {"type": "boolean"}, + "read_only": {"type": "boolean"}, + "restart": {"type": "string"}, + "security_opt": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "shm_size": {"type": ["number", "string"]}, + "secrets": { + "type": "array", + "items": { + "oneOf": [ + {"type": "string"}, + { + "type": "object", + "properties": { + "source": {"type": "string"}, + "target": {"type": "string"}, + "uid": {"type": "string"}, + "gid": {"type": "string"}, + "mode": {"type": "number"} + } + } + ] + } + }, + "sysctls": {"$ref": "#/definitions/list_or_dict"}, + "stdin_open": {"type": "boolean"}, + "stop_grace_period": {"type": "string", "format": "duration"}, + "stop_signal": {"type": "string"}, + "tmpfs": {"$ref": "#/definitions/string_or_list"}, + "tty": {"type": "boolean"}, + "ulimits": { + "type": "object", + "patternProperties": { + "^[a-z]+$": { + "oneOf": [ + {"type": "integer"}, + { + "type":"object", + "properties": { + "hard": {"type": "integer"}, + "soft": {"type": "integer"} + }, + "required": ["soft", "hard"], + "additionalProperties": false + } + ] + } + } + }, + "user": {"type": "string"}, + "userns_mode": {"type": "string"}, + "volumes": { + "type": "array", + "items": { + "oneOf": [ + {"type": "string"}, + { + "type": "object", + "required": ["type"], + "properties": { + "type": {"type": "string"}, + "source": {"type": "string"}, + "target": {"type": "string"}, + "read_only": {"type": "boolean"}, + "consistency": {"type": "string"}, + "bind": { + "type": "object", + "properties": { + "propagation": {"type": "string"} + } + }, + "volume": { + "type": "object", + "properties": { + "nocopy": {"type": "boolean"} + } + } + } + } + ], + "uniqueItems": true + } + }, + "working_dir": {"type": "string"} + }, + "additionalProperties": false + }, + + "healthcheck": { + "id": "#/definitions/healthcheck", + "type": "object", + "additionalProperties": false, + "properties": { + "disable": {"type": "boolean"}, + "interval": {"type": "string"}, + "retries": {"type": "number"}, + "test": { + "oneOf": [ + {"type": "string"}, + {"type": "array", "items": {"type": "string"}} + ] + }, + "timeout": {"type": "string"} + } + }, + "deployment": { + "id": "#/definitions/deployment", + "type": ["object", "null"], + "properties": { + "mode": {"type": "string"}, + "endpoint_mode": {"type": "string"}, + "replicas": {"type": "integer"}, + "labels": {"$ref": "#/definitions/list_or_dict"}, + "update_config": { + "type": "object", + "properties": { + "parallelism": {"type": "integer"}, + "delay": {"type": "string", "format": "duration"}, + "failure_action": {"type": "string"}, + "monitor": {"type": "string", "format": "duration"}, + "max_failure_ratio": {"type": "number"}, + "order": {"type": "string", "enum": [ + "start-first", "stop-first" + ]} + }, + "additionalProperties": false + }, + "resources": { + "type": "object", + "properties": { + "limits": {"$ref": "#/definitions/resource"}, + "reservations": {"$ref": "#/definitions/resource"} + }, + "additionalProperties": false + }, + "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"}}, + "preferences": { + "type": "array", + "items": { + "type": "object", + "properties": { + "spread": {"type": "string"} + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + + "resource": { + "id": "#/definitions/resource", + "type": "object", + "properties": { + "cpus": {"type": "string"}, + "memory": {"type": "string"} + }, + "additionalProperties": false + }, + + "network": { + "id": "#/definitions/network", + "type": ["object", "null"], + "properties": { + "driver": {"type": "string"}, + "driver_opts": { + "type": "object", + "patternProperties": { + "^.+$": {"type": ["string", "number"]} + } + }, + "ipam": { + "type": "object", + "properties": { + "driver": {"type": "string"}, + "config": { + "type": "array", + "items": { + "type": "object", + "properties": { + "subnet": {"type": "string"} + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + }, + "external": { + "type": ["boolean", "object"], + "properties": { + "name": {"type": "string"} + }, + "additionalProperties": false + }, + "internal": {"type": "boolean"}, + "attachable": {"type": "boolean"}, + "labels": {"$ref": "#/definitions/list_or_dict"} + }, + "additionalProperties": false + }, + + "volume": { + "id": "#/definitions/volume", + "type": ["object", "null"], + "properties": { + "name": {"type": "string"}, + "driver": {"type": "string"}, + "driver_opts": { + "type": "object", + "patternProperties": { + "^.+$": {"type": ["string", "number"]} + } + }, + "external": { + "type": ["boolean", "object"], + "properties": { + "name": {"type": "string"} + }, + "additionalProperties": false + }, + "labels": {"$ref": "#/definitions/list_or_dict"} + }, + "additionalProperties": false + }, + + "secret": { + "id": "#/definitions/secret", + "type": "object", + "properties": { + "file": {"type": "string"}, + "external": { + "type": ["boolean", "object"], + "properties": { + "name": {"type": "string"} + } + }, + "labels": {"$ref": "#/definitions/list_or_dict"} + }, + "additionalProperties": false + }, + + "config": { + "id": "#/definitions/config", + "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"] + } + } + } + } + } +} diff --git a/compose/const.py b/compose/const.py index b5970f82ae9..2ac08b89a70 100644 --- a/compose/const.py +++ b/compose/const.py @@ -32,6 +32,7 @@ COMPOSEFILE_V3_2 = ComposeVersion('3.2') COMPOSEFILE_V3_3 = ComposeVersion('3.3') COMPOSEFILE_V3_4 = ComposeVersion('3.4') +COMPOSEFILE_V3_5 = ComposeVersion('3.5') API_VERSIONS = { COMPOSEFILE_V1: '1.21', @@ -44,6 +45,7 @@ COMPOSEFILE_V3_2: '1.25', COMPOSEFILE_V3_3: '1.30', COMPOSEFILE_V3_4: '1.30', + COMPOSEFILE_V3_5: '1.30', } API_VERSION_TO_ENGINE_VERSION = { @@ -57,4 +59,5 @@ API_VERSIONS[COMPOSEFILE_V3_2]: '1.13.0', API_VERSIONS[COMPOSEFILE_V3_3]: '17.06.0', API_VERSIONS[COMPOSEFILE_V3_4]: '17.06.0', + API_VERSIONS[COMPOSEFILE_V3_5]: '17.06.0', } diff --git a/compose/service.py b/compose/service.py index 2829240f256..28c03276365 100644 --- a/compose/service.py +++ b/compose/service.py @@ -43,6 +43,7 @@ from .progress_stream import stream_output from .progress_stream import StreamOutputError from .utils import json_hash +from .utils import parse_bytes from .utils import parse_seconds_float @@ -916,6 +917,7 @@ def build(self, no_cache=False, pull=False, force_rm=False, build_args_override= buildargs=build_args, network_mode=build_opts.get('network', None), target=build_opts.get('target', None), + shmsize=parse_bytes(build_opts.get('shm_size')) if build_opts.get('shm_size') else None, ) try: diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 3a5e17ad841..ca4bd9ee7f3 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -531,6 +531,12 @@ def test_build_failed_forcerm(self): ] assert not containers + def test_build_shm_size_build_option(self): + pull_busybox(self.client) + self.base_dir = 'tests/fixtures/build-shm-size' + result = self.dispatch(['build', '--no-cache'], None) + assert 'shm_size: 96' in result.stdout + def test_bundle_with_digests(self): self.base_dir = 'tests/fixtures/bundle-with-digests/' tmpdir = py.test.ensuretemp('cli_test_bundle') diff --git a/tests/fixtures/build-shm-size/Dockerfile b/tests/fixtures/build-shm-size/Dockerfile new file mode 100644 index 00000000000..f91733d6301 --- /dev/null +++ b/tests/fixtures/build-shm-size/Dockerfile @@ -0,0 +1,4 @@ +FROM busybox + +# Report the shm_size (through the size of /dev/shm) +RUN echo "shm_size:" $(df -h /dev/shm | tail -n 1 | awk '{print $2}') diff --git a/tests/fixtures/build-shm-size/docker-compose.yml b/tests/fixtures/build-shm-size/docker-compose.yml new file mode 100644 index 00000000000..238a513223b --- /dev/null +++ b/tests/fixtures/build-shm-size/docker-compose.yml @@ -0,0 +1,7 @@ +version: '3.5' + +services: + custom_shm_size: + build: + context: . + shm_size: 100663296 # =96M diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 0293695abbb..43ccf081c30 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -475,6 +475,7 @@ def test_create_container(self): cache_from=None, network_mode=None, target=None, + shmsize=None, ) def test_ensure_image_exists_no_build(self): @@ -515,6 +516,7 @@ def test_ensure_image_exists_force_build(self): cache_from=None, network_mode=None, target=None, + shmsize=None ) def test_build_does_not_pull(self): From 3436145764eb22263dbf9f6b5f2ff6086f767472 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 13 Oct 2017 15:24:34 -0700 Subject: [PATCH 3071/4072] Temporary xfails for engine bug Signed-off-by: Joffrey F --- tests/acceptance/cli_test.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index ca4bd9ee7f3..b598d99d540 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -14,7 +14,7 @@ from collections import namedtuple from operator import attrgetter -import py +import pytest import six import yaml from docker import errors @@ -504,6 +504,7 @@ def test_build_no_cache_pull(self): assert BUILD_CACHE_TEXT not in result.stdout assert BUILD_PULL_TEXT in result.stdout + @pytest.mark.xfail(reason='17.10.0 RC bug remove after GA https://github.com/moby/moby/issues/35116') def test_build_failed(self): self.base_dir = 'tests/fixtures/simple-failing-dockerfile' self.dispatch(['build', 'simple'], returncode=1) @@ -517,6 +518,7 @@ def test_build_failed(self): ] assert len(containers) == 1 + @pytest.mark.xfail(reason='17.10.0 RC bug remove after GA https://github.com/moby/moby/issues/35116') def test_build_failed_forcerm(self): self.base_dir = 'tests/fixtures/simple-failing-dockerfile' self.dispatch(['build', '--force-rm', 'simple'], returncode=1) @@ -539,7 +541,7 @@ def test_build_shm_size_build_option(self): def test_bundle_with_digests(self): self.base_dir = 'tests/fixtures/bundle-with-digests/' - tmpdir = py.test.ensuretemp('cli_test_bundle') + tmpdir = pytest.ensuretemp('cli_test_bundle') self.addCleanup(tmpdir.remove) filename = str(tmpdir.join('example.dab')) @@ -1403,7 +1405,7 @@ def test_run_without_command(self): [u'/bin/true'], ) - @py.test.mark.skipif(SWARM_SKIP_RM_VOLUMES, reason='Swarm DELETE /containers/ bug') + @pytest.mark.skipif(SWARM_SKIP_RM_VOLUMES, reason='Swarm DELETE /containers/ bug') def test_run_rm(self): self.base_dir = 'tests/fixtures/volume' proc = start_process(self.base_dir, ['run', '--rm', 'test']) From 8c38651196c626c64ff22a22a8d606ed9d88e305 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 27 Sep 2017 17:10:13 -0700 Subject: [PATCH 3072/4072] Mount with same container path and different mode should override Signed-off-by: Joffrey F --- compose/config/config.py | 35 ++++++++++++++++++++--------- tests/unit/config/config_test.py | 38 +++++++++++++++++++++++++++++--- 2 files changed, 60 insertions(+), 13 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index f16dd01b3f5..948e2376e97 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -1137,24 +1137,30 @@ def resolve_volume_paths(working_dir, service_dict): def resolve_volume_path(working_dir, volume): + mount_params = None if isinstance(volume, dict): - host_path = volume.get('source') container_path = volume.get('target') + host_path = volume.get('source') + mode = None if host_path: if volume.get('read_only'): - container_path += ':ro' + mode = 'ro' if volume.get('volume', {}).get('nocopy'): - container_path += ':nocopy' + mode = 'nocopy' + mount_params = (host_path, mode) else: - container_path, host_path = split_path_mapping(volume) + container_path, mount_params = split_path_mapping(volume) - if host_path is not None: + if mount_params is not None: + host_path, mode = mount_params + if host_path is None: + return container_path if host_path.startswith('.'): host_path = expand_path(working_dir, host_path) host_path = os.path.expanduser(host_path) - return u"{}:{}".format(host_path, container_path) - else: - return container_path + return u"{}:{}{}".format(host_path, container_path, (':' + mode if mode else '')) + + return container_path def normalize_build(service_dict, working_dir, environment): @@ -1234,7 +1240,12 @@ def split_path_mapping(volume_path): if ':' in volume_config: (host, container) = volume_config.split(':', 1) - return (container, drive + host) + container_drive, container_path = splitdrive(container) + mode = None + if ':' in container_path: + container_path, mode = container_path.rsplit(':', 1) + + return (container_drive + container_path, (drive + host, mode)) else: return (volume_path, None) @@ -1246,7 +1257,11 @@ def join_path_mapping(pair): elif host is None: return container else: - return ":".join((host, container)) + host, mode = host + result = ":".join((host, container)) + if mode: + result += ":" + mode + return result def expand_path(working_dir, path): diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index de9a6130257..c5e40130d79 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1101,6 +1101,38 @@ def test_load_with_multiple_files_v3_2(self): ['/anonymous', '/c:/b:rw', 'vol:/x:ro'] ) + @mock.patch.dict(os.environ) + def test_volume_mode_override(self): + os.environ['COMPOSE_CONVERT_WINDOWS_PATHS'] = 'true' + base_file = config.ConfigFile( + 'base.yaml', + { + 'version': '2.3', + 'services': { + 'web': { + 'image': 'example/web', + 'volumes': ['/c:/b:rw'] + } + }, + } + ) + + override_file = config.ConfigFile( + 'override.yaml', + { + 'version': '2.3', + 'services': { + 'web': { + 'volumes': ['/c:/b:ro'] + } + } + } + ) + details = config.ConfigDetails('.', [base_file, override_file]) + service_dicts = config.load(details).services + svc_volumes = list(map(lambda v: v.repr(), service_dicts[0]['volumes'])) + assert svc_volumes == ['/c:/b:ro'] + def test_undeclared_volume_v2(self): base_file = config.ConfigFile( 'base.yaml', @@ -4018,7 +4050,7 @@ class VolumePathTest(unittest.TestCase): 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" - expected_mapping = ("/opt/connect/config:ro", host_path) + expected_mapping = ("/opt/connect/config", (host_path, 'ro')) mapping = config.split_path_mapping(windows_volume_path) assert mapping == expected_mapping @@ -4026,7 +4058,7 @@ def test_split_path_mapping_with_windows_path(self): 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) + expected_mapping = (container_path, (host_path, None)) mapping = config.split_path_mapping('{0}:{1}'.format(host_path, container_path)) assert mapping == expected_mapping @@ -4034,7 +4066,7 @@ def test_split_path_mapping_with_windows_path_in_container(self): def test_split_path_mapping_with_root_mount(self): host_path = '/' container_path = '/var/hostroot' - expected_mapping = (container_path, host_path) + expected_mapping = (container_path, (host_path, None)) mapping = config.split_path_mapping('{0}:{1}'.format(host_path, container_path)) assert mapping == expected_mapping From 18df4915f21fb43100b24f181e851a3f0a14eb9a Mon Sep 17 00:00:00 2001 From: Andrea Giardini Date: Wed, 20 Sep 2017 23:05:29 +0200 Subject: [PATCH 3073/4072] Fix secret location with absolute paths Signed-off-by: Andrea Giardini --- compose/service.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/compose/service.py b/compose/service.py index 28c03276365..aecafc8ca46 100644 --- a/compose/service.py +++ b/compose/service.py @@ -881,9 +881,12 @@ def _get_container_host_config(self, override_options, one_off=False): def get_secret_volumes(self): def build_spec(secret): - target = '{}/{}'.format( - const.SECRETS_PATH, - secret['secret'].target or secret['secret'].source) + if secret['secret'].target is not None and secret['secret'].target.startswith('/'): + target = secret['secret'].target + else: + 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] From c4a8cb30ffba475169b01b8d0b653dc07fa5ab60 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 13 Oct 2017 17:02:01 -0700 Subject: [PATCH 3074/4072] Add get_secret_volumes unit tests Signed-off-by: Joffrey F --- compose/service.py | 12 ++++----- tests/unit/service_test.py | 55 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 6 deletions(-) diff --git a/compose/service.py b/compose/service.py index aecafc8ca46..1a18c66549d 100644 --- a/compose/service.py +++ b/compose/service.py @@ -881,12 +881,12 @@ def _get_container_host_config(self, override_options, one_off=False): def get_secret_volumes(self): def build_spec(secret): - if secret['secret'].target is not None and secret['secret'].target.startswith('/'): - target = secret['secret'].target - else: - target = '{}/{}'.format( - const.SECRETS_PATH, - secret['secret'].target or secret['secret'].source) + target = secret['secret'].target + if target is None: + target = '{}/{}'.format(const.SECRETS_PATH, secret['secret'].source) + elif not os.path.isabs(target): + target = '{}/{}'.format(const.SECRETS_PATH, target) + return VolumeSpec(secret['file'], target, 'ro') return [build_spec(secret) for secret in self.secrets] diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 43ccf081c30..7d61807ba00 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -9,12 +9,14 @@ from .. import unittest from compose.config.errors import DependencyError from compose.config.types import ServicePort +from compose.config.types import ServiceSecret from compose.config.types import VolumeFromSpec from compose.config.types import VolumeSpec from compose.const import LABEL_CONFIG_HASH from compose.const import LABEL_ONE_OFF from compose.const import LABEL_PROJECT from compose.const import LABEL_SERVICE +from compose.const import SECRETS_PATH from compose.container import Container from compose.project import OneOffFilter from compose.service import build_ulimits @@ -1089,3 +1091,56 @@ def test_create_with_special_volume_mode(self): self.assertEqual( self.mock_client.create_host_config.call_args[1]['binds'], [volume]) + + +class ServiceSecretTest(unittest.TestCase): + def setUp(self): + self.mock_client = mock.create_autospec(docker.APIClient) + + def test_get_secret_volumes(self): + secret1 = { + 'secret': ServiceSecret.parse({'source': 'secret1', 'target': 'b.txt'}), + 'file': 'a.txt' + } + service = Service( + 'web', + client=self.mock_client, + image='busybox', + secrets=[secret1] + ) + volumes = service.get_secret_volumes() + + assert volumes[0].external == secret1['file'] + assert volumes[0].internal == '{}/{}'.format(SECRETS_PATH, secret1['secret'].target) + + def test_get_secret_volumes_abspath(self): + secret1 = { + 'secret': ServiceSecret.parse({'source': 'secret1', 'target': '/d.txt'}), + 'file': 'c.txt' + } + service = Service( + 'web', + client=self.mock_client, + image='busybox', + secrets=[secret1] + ) + volumes = service.get_secret_volumes() + + assert volumes[0].external == secret1['file'] + assert volumes[0].internal == secret1['secret'].target + + def test_get_secret_volumes_no_target(self): + secret1 = { + 'secret': ServiceSecret.parse({'source': 'secret1'}), + 'file': 'c.txt' + } + service = Service( + 'web', + client=self.mock_client, + image='busybox', + secrets=[secret1] + ) + volumes = service.get_secret_volumes() + + assert volumes[0].external == secret1['file'] + assert volumes[0].internal == '{}/{}'.format(SECRETS_PATH, secret1['secret'].source) From 0d0da0760c3422e6164ec39c3e8edd77ac6d28d3 Mon Sep 17 00:00:00 2001 From: Guillermo Arribas Date: Fri, 6 Oct 2017 19:12:59 -0300 Subject: [PATCH 3075/4072] Build labels option: array form produces unmarshal error (fixes #5183) Signed-off-by: Guillermo Arribas --- compose/service.py | 3 ++- tests/integration/service_test.py | 19 ++++++++++++++++++- tests/unit/service_test.py | 4 ++-- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/compose/service.py b/compose/service.py index 1a18c66549d..e2f72aa5ac3 100644 --- a/compose/service.py +++ b/compose/service.py @@ -23,6 +23,7 @@ from . import progress_stream from .config import DOCKER_CONFIG_KEYS from .config import merge_environment +from .config.config import parse_labels from .config.errors import DependencyError from .config.types import ServicePort from .config.types import VolumeSpec @@ -916,7 +917,7 @@ def build(self, no_cache=False, pull=False, force_rm=False, build_args_override= nocache=no_cache, dockerfile=build_opts.get('dockerfile', None), cache_from=build_opts.get('cache_from', None), - labels=build_opts.get('labels', None), + labels=parse_labels(build_opts.get('labels', None)), buildargs=build_args, network_mode=build_opts.get('network', None), target=build_opts.get('target', None), diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 84b54fe419d..a71bc407c1e 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -761,7 +761,7 @@ def test_build_with_build_args_override(self): assert service.image() assert "build_version=2" in service.image()['ContainerConfig']['Cmd'] - def test_build_with_build_labels(self): + def test_build_with_build_labels_dict(self): base_dir = tempfile.mkdtemp() self.addCleanup(shutil.rmtree, base_dir) @@ -778,6 +778,23 @@ def test_build_with_build_labels(self): assert service.image() assert service.image()['Config']['Labels']['com.docker.compose.test'] == 'true' + def test_build_with_build_labels_list(self): + base_dir = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, base_dir) + + with open(os.path.join(base_dir, 'Dockerfile'), 'w') as f: + f.write('FROM busybox\n') + + service = self.create_service('buildlabels', build={ + 'context': text_type(base_dir), + 'labels': ['com.docker.compose.test=true'] + }) + service.build() + self.addCleanup(self.client.remove_image, service.image_name) + + assert service.image() + assert service.image()['Config']['Labels']['com.docker.compose.test'] == 'true' + @no_cluster('Container networks not on Swarm') def test_build_with_network(self): base_dir = tempfile.mkdtemp() diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 7d61807ba00..5c5c2bf677e 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -473,7 +473,7 @@ def test_create_container(self): nocache=False, rm=True, buildargs={}, - labels=None, + labels={}, cache_from=None, network_mode=None, target=None, @@ -514,7 +514,7 @@ def test_ensure_image_exists_force_build(self): nocache=False, rm=True, buildargs={}, - labels=None, + labels={}, cache_from=None, network_mode=None, target=None, From f680d46d9af868163d3a4887678d3531e88001e0 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 16 Oct 2017 11:43:06 -0700 Subject: [PATCH 3076/4072] Move build labels parsing to config module Signed-off-by: Joffrey F --- compose/config/config.py | 12 ++++++------ compose/service.py | 3 +-- tests/integration/service_test.py | 19 +------------------ tests/unit/config/config_test.py | 24 +++++++++++++++++++++++- tests/unit/service_test.py | 4 ++-- 5 files changed, 33 insertions(+), 29 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 948e2376e97..68b2be3a61e 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -707,16 +707,16 @@ def process_service(service_config): if 'build' in service_dict: if isinstance(service_dict['build'], six.string_types): service_dict['build'] = resolve_build_path(working_dir, service_dict['build']) - elif isinstance(service_dict['build'], dict) and 'context' in service_dict['build']: - path = service_dict['build']['context'] - service_dict['build']['context'] = resolve_build_path(working_dir, path) + elif isinstance(service_dict['build'], dict): + if 'context' in service_dict['build']: + path = service_dict['build']['context'] + service_dict['build']['context'] = resolve_build_path(working_dir, path) + if 'labels' in service_dict['build']: + service_dict['build']['labels'] = parse_labels(service_dict['build']['labels']) if 'volumes' in service_dict and service_dict.get('volume_driver') is None: service_dict['volumes'] = resolve_volume_paths(working_dir, service_dict) - if 'labels' in service_dict: - service_dict['labels'] = parse_labels(service_dict['labels']) - if 'sysctls' in service_dict: service_dict['sysctls'] = build_string_dict(parse_sysctls(service_dict['sysctls'])) diff --git a/compose/service.py b/compose/service.py index e2f72aa5ac3..1a18c66549d 100644 --- a/compose/service.py +++ b/compose/service.py @@ -23,7 +23,6 @@ from . import progress_stream from .config import DOCKER_CONFIG_KEYS from .config import merge_environment -from .config.config import parse_labels from .config.errors import DependencyError from .config.types import ServicePort from .config.types import VolumeSpec @@ -917,7 +916,7 @@ def build(self, no_cache=False, pull=False, force_rm=False, build_args_override= nocache=no_cache, dockerfile=build_opts.get('dockerfile', None), cache_from=build_opts.get('cache_from', None), - labels=parse_labels(build_opts.get('labels', None)), + labels=build_opts.get('labels', None), buildargs=build_args, network_mode=build_opts.get('network', None), target=build_opts.get('target', None), diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index a71bc407c1e..84b54fe419d 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -761,7 +761,7 @@ def test_build_with_build_args_override(self): assert service.image() assert "build_version=2" in service.image()['ContainerConfig']['Cmd'] - def test_build_with_build_labels_dict(self): + def test_build_with_build_labels(self): base_dir = tempfile.mkdtemp() self.addCleanup(shutil.rmtree, base_dir) @@ -778,23 +778,6 @@ def test_build_with_build_labels_dict(self): assert service.image() assert service.image()['Config']['Labels']['com.docker.compose.test'] == 'true' - def test_build_with_build_labels_list(self): - base_dir = tempfile.mkdtemp() - self.addCleanup(shutil.rmtree, base_dir) - - with open(os.path.join(base_dir, 'Dockerfile'), 'w') as f: - f.write('FROM busybox\n') - - service = self.create_service('buildlabels', build={ - 'context': text_type(base_dir), - 'labels': ['com.docker.compose.test=true'] - }) - service.build() - self.addCleanup(self.client.remove_image, service.image_name) - - assert service.image() - assert service.image()['Config']['Labels']['com.docker.compose.test'] == 'true' - @no_cluster('Container networks not on Swarm') def test_build_with_network(self): base_dir = tempfile.mkdtemp() diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index c5e40130d79..8f2266ed8d5 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -892,7 +892,7 @@ def test_load_with_buildargs(self): assert service['build']['args']['opt1'] == '42' assert service['build']['args']['opt2'] == 'foobar' - def test_load_with_build_labels(self): + def test_load_build_labels_dict(self): service = config.load( build_config_details( { @@ -919,6 +919,28 @@ def test_load_with_build_labels(self): assert service['build']['labels']['label1'] == 42 assert service['build']['labels']['label2'] == 'foobar' + def test_load_build_labels_list(self): + base_file = config.ConfigFile( + 'base.yml', + { + 'version': '2.3', + 'services': { + 'web': { + 'build': { + 'context': '.', + 'labels': ['foo=bar', 'baz=true', 'foobar=1'] + }, + }, + }, + } + ) + + details = config.ConfigDetails('.', [base_file]) + service = config.load(details).services[0] + assert service['build']['labels'] == { + 'foo': 'bar', 'baz': 'true', 'foobar': '1' + } + def test_build_args_allow_empty_properties(self): service = config.load( build_config_details( diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 5c5c2bf677e..7d61807ba00 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -473,7 +473,7 @@ def test_create_container(self): nocache=False, rm=True, buildargs={}, - labels={}, + labels=None, cache_from=None, network_mode=None, target=None, @@ -514,7 +514,7 @@ def test_ensure_image_exists_force_build(self): nocache=False, rm=True, buildargs={}, - labels={}, + labels=None, cache_from=None, network_mode=None, target=None, From 0ec77bf7d58f6a273cb8ae0b65a26f15e13bd6e0 Mon Sep 17 00:00:00 2001 From: Guillermo Arribas Date: Wed, 11 Oct 2017 13:56:15 -0300 Subject: [PATCH 3077/4072] Config command generates invalid volumes (fixes #5176) Signed-off-by: Guillermo Arribas --- compose/config/config.py | 19 +++---- compose/config/serialize.py | 4 +- tests/acceptance/cli_test.py | 54 +++++++++++++++++-- .../volumes/external-volumes-v2-x.yml | 17 ++++++ ...al-volumes.yml => external-volumes-v2.yml} | 2 +- .../volumes/external-volumes-v3-4.yml | 17 ++++++ .../volumes/external-volumes-v3-x.yml | 16 ++++++ 7 files changed, 114 insertions(+), 15 deletions(-) create mode 100644 tests/fixtures/volumes/external-volumes-v2-x.yml rename tests/fixtures/volumes/{external-volumes.yml => external-volumes-v2.yml} (92%) create mode 100644 tests/fixtures/volumes/external-volumes-v3-4.yml create mode 100644 tests/fixtures/volumes/external-volumes-v3-x.yml diff --git a/compose/config/config.py b/compose/config/config.py index 68b2be3a61e..7bb57076e10 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -15,6 +15,9 @@ from . import types from .. import const from ..const import COMPOSEFILE_V1 as V1 +from ..const import COMPOSEFILE_V2_1 as V2_1 +from ..const import COMPOSEFILE_V3_0 as V3_0 +from ..const import COMPOSEFILE_V3_4 as V3_4 from ..utils import build_string_dict from ..utils import parse_bytes from ..utils import parse_nanoseconds_int @@ -405,7 +408,7 @@ def load_mapping(config_files, get_func, entity_type, working_dir=None): external = config.get('external') if external: name_field = 'name' if entity_type == 'Volume' else 'external_name' - validate_external(entity_type, name, config) + validate_external(entity_type, name, config, config_file.version) if isinstance(external, dict): config[name_field] = external.get('name') elif not config.get('name'): @@ -425,14 +428,12 @@ def load_mapping(config_files, get_func, entity_type, working_dir=None): 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 validate_external(entity_type, name, config, version): + if (version < V2_1 or (version >= V3_0 and version < V3_4)) and len(config.keys()) > 1: + raise ConfigurationError( + "{} {} declared as external but specifies additional attributes " + "({}).".format( + entity_type, name, ', '.join(k for k in config if k != 'external'))) def load_services(config_details, config_file): diff --git a/compose/config/serialize.py b/compose/config/serialize.py index 606dd761409..2b8c73f14c1 100644 --- a/compose/config/serialize.py +++ b/compose/config/serialize.py @@ -9,7 +9,7 @@ from compose.const import COMPOSEFILE_V2_1 as V2_1 from compose.const import COMPOSEFILE_V3_0 as V3_0 from compose.const import COMPOSEFILE_V3_2 as V3_2 -from compose.const import COMPOSEFILE_V3_2 as V3_4 +from compose.const import COMPOSEFILE_V3_4 as V3_4 def serialize_config_type(dumper, data): @@ -67,7 +67,7 @@ def denormalize_config(config, image_digests=None): del conf['external_name'] if 'name' in conf: - if config.version < V2_1 or (config.version > V3_0 and config.version < V3_4): + if config.version < V2_1 or (config.version >= V3_0 and config.version < V3_4): del conf['name'] elif 'external' in conf: conf['external'] = True diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index b598d99d540..43cc89e3683 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -285,15 +285,63 @@ def test_config_external_network(self): } } - def test_config_external_volume(self): + def test_config_external_volume_v2(self): self.base_dir = 'tests/fixtures/volumes' - result = self.dispatch(['-f', 'external-volumes.yml', 'config']) + result = self.dispatch(['-f', 'external-volumes-v2.yml', 'config']) json_result = yaml.load(result.stdout) assert 'volumes' in json_result assert json_result['volumes'] == { 'foo': { 'external': True, - 'name': 'foo', + }, + 'bar': { + 'external': { + 'name': 'some_bar', + }, + } + } + + def test_config_external_volume_v2_x(self): + self.base_dir = 'tests/fixtures/volumes' + result = self.dispatch(['-f', 'external-volumes-v2-x.yml', 'config']) + json_result = yaml.load(result.stdout) + assert 'volumes' in json_result + assert json_result['volumes'] == { + 'foo': { + 'external': True, + 'name': 'some_foo', + }, + 'bar': { + 'external': True, + 'name': 'some_bar', + } + } + + def test_config_external_volume_v3_x(self): + self.base_dir = 'tests/fixtures/volumes' + result = self.dispatch(['-f', 'external-volumes-v3-x.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_external_volume_v3_4(self): + self.base_dir = 'tests/fixtures/volumes' + result = self.dispatch(['-f', 'external-volumes-v3-4.yml', 'config']) + json_result = yaml.load(result.stdout) + assert 'volumes' in json_result + assert json_result['volumes'] == { + 'foo': { + 'external': True, + 'name': 'some_foo', }, 'bar': { 'external': True, diff --git a/tests/fixtures/volumes/external-volumes-v2-x.yml b/tests/fixtures/volumes/external-volumes-v2-x.yml new file mode 100644 index 00000000000..3b736c5f481 --- /dev/null +++ b/tests/fixtures/volumes/external-volumes-v2-x.yml @@ -0,0 +1,17 @@ +version: "2.1" + +services: + web: + image: busybox + command: top + volumes: + - foo:/var/lib/ + - bar:/etc/ + +volumes: + foo: + external: true + name: some_foo + bar: + external: + name: some_bar diff --git a/tests/fixtures/volumes/external-volumes.yml b/tests/fixtures/volumes/external-volumes-v2.yml similarity index 92% rename from tests/fixtures/volumes/external-volumes.yml rename to tests/fixtures/volumes/external-volumes-v2.yml index 05c6c4844fe..4025b53b19f 100644 --- a/tests/fixtures/volumes/external-volumes.yml +++ b/tests/fixtures/volumes/external-volumes-v2.yml @@ -1,4 +1,4 @@ -version: "2.1" +version: "2" services: web: diff --git a/tests/fixtures/volumes/external-volumes-v3-4.yml b/tests/fixtures/volumes/external-volumes-v3-4.yml new file mode 100644 index 00000000000..76c8421dc54 --- /dev/null +++ b/tests/fixtures/volumes/external-volumes-v3-4.yml @@ -0,0 +1,17 @@ +version: "3.4" + +services: + web: + image: busybox + command: top + volumes: + - foo:/var/lib/ + - bar:/etc/ + +volumes: + foo: + external: true + name: some_foo + bar: + external: + name: some_bar diff --git a/tests/fixtures/volumes/external-volumes-v3-x.yml b/tests/fixtures/volumes/external-volumes-v3-x.yml new file mode 100644 index 00000000000..903fee64728 --- /dev/null +++ b/tests/fixtures/volumes/external-volumes-v3-x.yml @@ -0,0 +1,16 @@ +version: "3.0" + +services: + web: + image: busybox + command: top + volumes: + - foo:/var/lib/ + - bar:/etc/ + +volumes: + foo: + external: true + bar: + external: + name: some_bar From d8194cf6f0c5fd5a08f1a0daa6b4022a5582d008 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 16 Oct 2017 12:41:29 -0700 Subject: [PATCH 3078/4072] Add specific handling for pywintypes.error Signed-off-by: Joffrey F --- compose/cli/errors.py | 20 ++++++++++++++++++++ tests/unit/cli/errors_test.py | 21 +++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/compose/cli/errors.py b/compose/cli/errors.py index 23e065c99b5..1506aa66078 100644 --- a/compose/cli/errors.py +++ b/compose/cli/errors.py @@ -57,6 +57,26 @@ def handle_connection_errors(client): except (ReadTimeout, socket.timeout) as e: log_timeout_error(client.timeout) raise ConnectionError() + except Exception as e: + if is_windows(): + import pywintypes + if isinstance(e, pywintypes.error): + log_windows_pipe_error(e) + raise ConnectionError() + raise + + +def log_windows_pipe_error(exc): + if exc.winerror == 232: # https://github.com/docker/compose/issues/5005 + log.error( + "The current Compose file version is not compatible with your engine version. " + "Please upgrade your Compose file to a more recent version, or set " + "a COMPOSE_API_VERSION in your environment." + ) + else: + log.error( + "Windows named pipe error: {} (code: {})".format(exc.strerror, exc.winerror) + ) def log_timeout_error(timeout): diff --git a/tests/unit/cli/errors_test.py b/tests/unit/cli/errors_test.py index 7406a88803f..68326d1c753 100644 --- a/tests/unit/cli/errors_test.py +++ b/tests/unit/cli/errors_test.py @@ -7,6 +7,7 @@ from compose.cli import errors from compose.cli.errors import handle_connection_errors +from compose.const import IS_WINDOWS_PLATFORM from tests import mock @@ -65,3 +66,23 @@ def test_api_error_version_other_unicode_explanation(self, mock_logging): raise APIError(None, None, msg) mock_logging.error.assert_called_once_with(msg) + + @pytest.mark.skipif(not IS_WINDOWS_PLATFORM, reason='Needs pywin32') + def test_windows_pipe_error_no_data(self, mock_logging): + import pywintypes + with pytest.raises(errors.ConnectionError): + with handle_connection_errors(mock.Mock(api_version='1.22')): + raise pywintypes.error(232, 'WriteFile', 'The pipe is being closed.') + + _, args, _ = mock_logging.error.mock_calls[0] + assert "The current Compose file version is not compatible with your engine version." in args[0] + + @pytest.mark.skipif(not IS_WINDOWS_PLATFORM, reason='Needs pywin32') + def test_windows_pipe_error_misc(self, mock_logging): + import pywintypes + with pytest.raises(errors.ConnectionError): + with handle_connection_errors(mock.Mock(api_version='1.22')): + raise pywintypes.error(231, 'WriteFile', 'The pipe is busy.') + + _, args, _ = mock_logging.error.mock_calls[0] + assert "Windows named pipe error: The pipe is busy. (code: 231)" == args[0] From 395dce9d2c40632242cead5bffe3b7d55e2fb2a2 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 16 Oct 2017 13:54:30 -0700 Subject: [PATCH 3079/4072] Add check_duplicate=True when creating network 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 0f42eb20a11..2e0a7e6ecdb 100644 --- a/compose/network.py +++ b/compose/network.py @@ -79,6 +79,7 @@ def ensure(self): enable_ipv6=self.enable_ipv6, labels=self._labels, attachable=version_gte(self.client._version, '1.24') or None, + check_duplicate=True, ) def remove(self): From 13c5049dbccf63182bf5d963bf25c817b25b8a66 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 17 Oct 2017 13:36:06 -0700 Subject: [PATCH 3080/4072] flake8 Signed-off-by: Joffrey F --- tests/acceptance/cli_test.py | 4 ---- tests/integration/testcases.py | 2 -- 2 files changed, 6 deletions(-) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 43cc89e3683..8ba43b00f05 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -493,10 +493,6 @@ def test_pull_with_ignore_pull_failures(self): 'image library/nonexisting-image:latest not found' in result.stderr or 'pull access denied for nonexisting-image' in result.stderr) - def test_pull_with_quiet(self): - assert self.dispatch(['pull', '--quiet']).stderr == '' - assert self.dispatch(['pull', '--quiet']).stdout == '' - def test_pull_with_parallel_failure(self): result = self.dispatch([ '-f', 'ignore-pull-failures.yml', 'pull', '--parallel'], diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index 8435f97ddcf..b72fb53a81f 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -75,7 +75,6 @@ def v2_1_only(): return min_version_skip(V2_1) - def v2_2_only(): return min_version_skip(V2_2) @@ -84,7 +83,6 @@ def v2_3_only(): return min_version_skip(V2_3) - def v3_only(): return min_version_skip(V3_0) From 2a0dd1401fb832c377a70527d760f34754523f2a Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 16 Oct 2017 16:50:51 -0700 Subject: [PATCH 3081/4072] Bump 1.17.0-rc1 Signed-off-by: Joffrey F --- CHANGELOG.md | 61 +++++++++++++++++++++++++++++++++++++++++++-- compose/__init__.py | 2 +- script/run/run.sh | 2 +- 3 files changed, 61 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5583768559c..cff19d8793c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,64 @@ Change log ========== +1.17.0 (2017-11-03) +------------------- + +### New features + +#### Compose file version 3.4 + +- Introduced version 3.4 of the `docker-compose.yml` specification. + This version requires to be used with Docker Engine 17.06.0 or above. + +- Added support for `cache_from`, `network` and `target` options in build + configurations + +- Added support for the `order` parameter in the `update_config` section + +- Added support for setting a custom name in volume definitions using + the `name` parameter + +#### Compose file version 2.3 + +- Added support for `shm_size` option in build configuration + +#### Compose file version 2.x + +- Added support for extension fields (`x-*`). Also available for v3.4 files + +#### All formats + +- Added new `--no-start` to the `up` command, allowing users to create all + resources (networks, volumes, containers) without starting services. + The `create` command is deprecated in favor of this new option + +### Bugfixes + +- Fixed a bug where `extra_hosts` values would be overridden by extension + files instead of merging together + +- Fixed a bug where the validation for v3.2 files would prevent using the + `consistency` field in service volume definitions + +- Fixed a bug that would cause a crash when configuration fields expecting + unique items would contain duplicates + +- Fixed a bug where mount overrides with a different mode would create a + duplicate entry instead of overriding the original entry + +- Fixed a bug where build labels declared as a list wouldn't be properly + parsed + +- Fixed a bug where the output of `docker-compose config` would be invalid + for some versions if the file contained custom-named external volumes + +- Improved error handling when issuing a build command on Windows using an + unsupported file version + +- Fixed an issue where networks with identical names would sometimes be + created when running `up` commands concurrently. + 1.16.1 (2017-09-01) ------------------- @@ -8,7 +66,6 @@ Change log - Fixed bug that prevented using `extra_hosts` in several configuration files. - 1.16.0 (2017-08-31) ------------------- @@ -19,7 +76,7 @@ Change log - Introduced version 2.3 of the `docker-compose.yml` specification. This version requires to be used with Docker Engine 17.06.0 or above. -- Added support for the `target` parameter in network configurations +- Added support for the `target` parameter in build configurations - Added support for the `start_period` parameter in healthcheck configurations diff --git a/compose/__init__.py b/compose/__init__.py index 2e41ca89650..86542ec44f4 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.16.1' +__version__ = '1.17.0-rc1' diff --git a/script/run/run.sh b/script/run/run.sh index f1754d05a43..498226288c1 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.16.1" +VERSION="1.17.0-rc1" IMAGE="docker/compose:$VERSION" From 803d5352e6d5d37ad2187516347ed31bacffc58d Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 18 Oct 2017 16:34:54 -0700 Subject: [PATCH 3082/4072] Add missing test constraint Signed-off-by: Joffrey F --- tests/acceptance/cli_test.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index bc10de3e00a..c9420e011fd 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -719,12 +719,13 @@ def test_run_one_off_with_multiple_volumes(self): def test_run_one_off_with_volume_merge(self): self.base_dir = 'tests/fixtures/simple-composefile-volume-ready' volume_path = os.path.abspath(os.path.join(os.getcwd(), self.base_dir, 'files')) - create_host_file(self.client, os.path.join(volume_path, 'example.txt')) + node = create_host_file(self.client, os.path.join(volume_path, 'example.txt')) self.dispatch([ '-f', 'docker-compose.merge.yml', 'run', '-v', '{}:/data'.format(volume_path), + '-e', 'constraint:node=={}'.format(node if node is not None else '*'), 'simple', 'test', '-f', '/data/example.txt' ], returncode=0) From 9b06bdc34a4ff07814d29b2789ac5cf6523afb42 Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Thu, 19 Oct 2017 09:18:40 +0200 Subject: [PATCH 3083/4072] Add bash completion for `up --no-start` 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 9de156403fd..1fdb2770540 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -518,7 +518,7 @@ _docker_compose_up() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "--abort-on-container-exit --build -d --exit-code-from --force-recreate --help --no-build --no-color --no-deps --no-recreate --remove-orphans --scale --timeout -t" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--abort-on-container-exit --build -d --exit-code-from --force-recreate --help --no-build --no-color --no-deps --no-recreate --no-start --remove-orphans --scale --timeout -t" -- "$cur" ) ) ;; *) __docker_compose_services_all From 9c5347bd23b3bacc6a583a035a51c7092ee2dbb4 Mon Sep 17 00:00:00 2001 From: Guillermo Arribas Date: Tue, 17 Oct 2017 11:54:06 -0300 Subject: [PATCH 3084/4072] Placing dots in hostname no longer populates domainname if api >= 1.23 (fixes #4128) Signed-off-by: Guillermo Arribas --- compose/service.py | 8 +++++--- tests/unit/cli_test.py | 3 +++ tests/unit/service_test.py | 22 ++++++++++++++++------ 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/compose/service.py b/compose/service.py index 1a18c66549d..d5be740dfac 100644 --- a/compose/service.py +++ b/compose/service.py @@ -14,6 +14,7 @@ from docker.errors import ImageNotFound from docker.errors import NotFound from docker.types import LogConfig +from docker.utils import version_lt from docker.utils.ports import build_port_bindings from docker.utils.ports import split_port from docker.utils.utils import convert_tmpfs_mounts @@ -747,9 +748,10 @@ def _get_container_create_options( # If a qualified hostname was given, split it into an # unqualified hostname and a domainname unless domainname - # was also given explicitly. This matches the behavior of - # the official Docker CLI in that scenario. - if ('hostname' in container_options and + # was also given explicitly. This matches behavior + # until Docker Engine 1.11.0 - Docker API 1.23. + if (version_lt(self.client.api_version, '1.23') and + 'hostname' in container_options and 'domainname' not in container_options and '.' in container_options['hostname']): parts = container_options['hostname'].partition('.') diff --git a/tests/unit/cli_test.py b/tests/unit/cli_test.py index f9ce240a37d..1a324f50a4b 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -10,6 +10,7 @@ import docker import py import pytest +from docker.constants import DEFAULT_DOCKER_API_VERSION from .. import mock from .. import unittest @@ -98,6 +99,7 @@ def test_command_help_nonexistent(self): @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.APIClient) + mock_client.api_version = DEFAULT_DOCKER_API_VERSION project = Project.from_config( name='composetest', client=mock_client, @@ -130,6 +132,7 @@ def test_run_interactive_passes_logs_false(self, mock_pseudo_terminal, mock_run_ def test_run_service_with_restart_always(self): mock_client = mock.create_autospec(docker.APIClient) + mock_client.api_version = DEFAULT_DOCKER_API_VERSION project = Project.from_config( name='composetest', diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 7d61807ba00..c979295ad3b 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -3,6 +3,7 @@ import docker import pytest +from docker.constants import DEFAULT_DOCKER_API_VERSION from docker.errors import APIError from .. import mock @@ -40,6 +41,7 @@ class ServiceTest(unittest.TestCase): def setUp(self): self.mock_client = mock.create_autospec(docker.APIClient) + self.mock_client.api_version = DEFAULT_DOCKER_API_VERSION def test_containers(self): service = Service('db', self.mock_client, 'myproject', image='foo') @@ -145,12 +147,6 @@ def test_get_volumes_from_service_no_container(self): self.assertEqual(service._get_volumes_from(), [container_id + ':rw']) from_service.create_container.assert_called_once_with() - def test_split_domainname_none(self): - service = Service('foo', image='foo', hostname='name', client=self.mock_client) - opts = service._get_container_create_options({'image': 'foo'}, 1) - self.assertEqual(opts['hostname'], 'name', 'hostname') - self.assertFalse('domainname' in opts, 'domainname') - def test_memory_swap_limit(self): self.mock_client.create_host_config.return_value = {} @@ -232,7 +228,18 @@ def test_log_opt(self): {'Type': 'syslog', 'Config': {'syslog-address': 'tcp://192.168.0.42:123'}} ) + def test_split_domainname_none(self): + service = Service( + 'foo', + image='foo', + hostname='name.domain.tld', + client=self.mock_client) + opts = service._get_container_create_options({'image': 'foo'}, 1) + self.assertEqual(opts['hostname'], 'name.domain.tld', 'hostname') + self.assertFalse('domainname' in opts, 'domainname') + def test_split_domainname_fqdn(self): + self.mock_client.api_version = '1.22' service = Service( 'foo', hostname='name.domain.tld', @@ -243,6 +250,7 @@ def test_split_domainname_fqdn(self): self.assertEqual(opts['domainname'], 'domain.tld', 'domainname') def test_split_domainname_both(self): + self.mock_client.api_version = '1.22' service = Service( 'foo', hostname='name', @@ -254,6 +262,7 @@ def test_split_domainname_both(self): self.assertEqual(opts['domainname'], 'domain.tld', 'domainname') def test_split_domainname_weird(self): + self.mock_client.api_version = '1.22' service = Service( 'foo', hostname='name.sub', @@ -857,6 +866,7 @@ class ServiceVolumesTest(unittest.TestCase): def setUp(self): self.mock_client = mock.create_autospec(docker.APIClient) + self.mock_client.api_version = DEFAULT_DOCKER_API_VERSION def test_build_volume_binding(self): binding = build_volume_binding(VolumeSpec.parse('/outside:/inside', True)) From eebc63c216692bd681e2200b6ad30d5aa71fa9d2 Mon Sep 17 00:00:00 2001 From: Guillermo Arribas Date: Thu, 19 Oct 2017 22:19:05 -0300 Subject: [PATCH 3085/4072] Allow empty default values in variable interpolation (fixes #5185) Signed-off-by: Guillermo Arribas --- compose/config/interpolation.py | 2 +- .../docker-compose.yml | 13 +++++++++++ tests/unit/config/config_test.py | 22 +++++++++++++++++++ 3 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/environment-interpolation-with-defaults/docker-compose.yml diff --git a/compose/config/interpolation.py b/compose/config/interpolation.py index b13ac591aad..df9c988e7d3 100644 --- a/compose/config/interpolation.py +++ b/compose/config/interpolation.py @@ -71,7 +71,7 @@ def recursive_interpolate(obj, interpolator): class TemplateWithDefaults(Template): - idpattern = r'[_a-z][_a-z0-9]*(?::?-[^}]+)?' + idpattern = r'[_a-z][_a-z0-9]*(?::?-[^}]*)?' # Modified from python2.7/string.py def substitute(self, mapping): diff --git a/tests/fixtures/environment-interpolation-with-defaults/docker-compose.yml b/tests/fixtures/environment-interpolation-with-defaults/docker-compose.yml new file mode 100644 index 00000000000..42e7cbb6a5b --- /dev/null +++ b/tests/fixtures/environment-interpolation-with-defaults/docker-compose.yml @@ -0,0 +1,13 @@ +version: "2.1" + +services: + web: + # set value with default, default must be ignored + image: ${IMAGE:-alpine} + + # unset value with default value + ports: + - "${HOST_PORT:-80}:8000" + + # unset value with empty default + hostname: "host-${UNSET_VALUE:-}" diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 8f2266ed8d5..7a7cfacdd61 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -2875,6 +2875,28 @@ def test_config_file_with_environment_variable(self): } ]) + @mock.patch.dict(os.environ) + def test_config_file_with_environment_variable_with_defaults(self): + project_dir = 'tests/fixtures/environment-interpolation-with-defaults' + os.environ.update( + IMAGE="busybox", + ) + + service_dicts = config.load( + config.find( + project_dir, None, Environment.from_env_file(project_dir) + ) + ).services + + self.assertEqual(service_dicts, [ + { + 'name': 'web', + 'image': 'busybox', + 'ports': types.ServicePort.parse('80:8000'), + 'hostname': 'host-', + } + ]) + @mock.patch.dict(os.environ) def test_unset_variable_produces_warning(self): os.environ.pop('FOO', None) From 2e81e6ceb2a286dbff2fc9ccdbf683a8ca8a499c Mon Sep 17 00:00:00 2001 From: Guillermo Arribas Date: Mon, 23 Oct 2017 13:22:36 -0300 Subject: [PATCH 3086/4072] flake8 error on master branch (fixes #5298) Signed-off-by: Guillermo Arribas --- compose/bundle.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/bundle.py b/compose/bundle.py index 505ce91fed0..937a3708a99 100644 --- a/compose/bundle.py +++ b/compose/bundle.py @@ -121,7 +121,7 @@ def get_image_digest(service, allow_push=False): def push_image(service): try: digest = service.push() - except: + except Exception: log.error( "Failed to push image for service '{s.name}'. Please use an " "image tag that can be pushed to a Docker " From a07dee9207497d54f1f00489c24daba057a679b0 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 19 Oct 2017 17:55:32 -0700 Subject: [PATCH 3087/4072] Add type converter to interpolation module Signed-off-by: Joffrey F --- compose/config/config.py | 4 +- compose/config/interpolation.py | 95 +++++++++++- tests/unit/config/interpolation_test.py | 198 +++++++++++++++++++++++- 3 files changed, 287 insertions(+), 10 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 7bb57076e10..31c6f7277ea 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -519,13 +519,13 @@ def process_config_file(config_file, environment, service_name=None): processed_config['secrets'] = interpolate_config_section( config_file, config_file.get_secrets(), - 'secrets', + 'secret', environment) if config_file.version >= const.COMPOSEFILE_V3_3: processed_config['configs'] = interpolate_config_section( config_file, config_file.get_configs(), - 'configs', + 'config', environment ) else: diff --git a/compose/config/interpolation.py b/compose/config/interpolation.py index df9c988e7d3..9d7e428c9bd 100644 --- a/compose/config/interpolation.py +++ b/compose/config/interpolation.py @@ -2,6 +2,7 @@ from __future__ import unicode_literals import logging +import re from string import Template import six @@ -44,9 +45,13 @@ def process_item(name, config_dict): ) +def get_config_path(config_key, section, name): + return '{}.{}.{}'.format(section, name, config_key) + + def interpolate_value(name, config_key, value, section, interpolator): try: - return recursive_interpolate(value, interpolator) + return recursive_interpolate(value, interpolator, get_config_path(config_key, section, name)) except InvalidInterpolation as e: raise ConfigurationError( 'Invalid interpolation format for "{config_key}" option ' @@ -57,16 +62,19 @@ def interpolate_value(name, config_key, value, section, interpolator): string=e.string)) -def recursive_interpolate(obj, interpolator): +def recursive_interpolate(obj, interpolator, config_path): + def append(config_path, key): + return '{}.{}'.format(config_path, key) + if isinstance(obj, six.string_types): - return interpolator.interpolate(obj) + return converter.convert(config_path, interpolator.interpolate(obj)) if isinstance(obj, dict): return dict( - (key, recursive_interpolate(val, interpolator)) + (key, recursive_interpolate(val, interpolator, append(config_path, key))) for (key, val) in obj.items() ) if isinstance(obj, list): - return [recursive_interpolate(val, interpolator) for val in obj] + return [recursive_interpolate(val, interpolator, config_path) for val in obj] return obj @@ -100,3 +108,80 @@ def convert(mo): class InvalidInterpolation(Exception): def __init__(self, string): self.string = string + + +PATH_JOKER = '[^.]+' + + +def re_path(*args): + return re.compile('^{}$'.format('.'.join(args))) + + +def re_path_basic(section, name): + return re_path(section, PATH_JOKER, name) + + +def service_path(*args): + return re_path('service', PATH_JOKER, *args) + + +def to_boolean(s): + s = s.lower() + if s in ['y', 'yes', 'true', 'on']: + return True + elif s in ['n', 'no', 'false', 'off']: + return False + raise ValueError('"{}" is not a valid boolean value'.format(s)) + + +def to_int(s): + # We must be able to handle octal representation for `mode` values notably + if six.PY3 and re.match('^0[0-9]+$', s.strip()): + s = '0o' + s[1:] + return int(s, base=0) + + +class ConversionMap(object): + map = { + service_path('blkio_config', 'weight'): to_int, + service_path('blkio_config', 'weight_device', 'weight'): to_int, + service_path('cpus'): float, + service_path('cpu_count'): to_int, + service_path('configs', 'mode'): to_int, + service_path('secrets', 'mode'): to_int, + service_path('healthcheck', 'retries'): to_int, + service_path('healthcheck', 'disable'): to_boolean, + service_path('deploy', 'replicas'): to_int, + service_path('deploy', 'update_config', 'parallelism'): to_int, + service_path('deploy', 'update_config', 'max_failure_ratio'): float, + service_path('deploy', 'restart_policy', 'max_attempts'): to_int, + service_path('mem_swappiness'): to_int, + service_path('oom_score_adj'): to_int, + service_path('ports', 'target'): to_int, + service_path('ports', 'published'): to_int, + service_path('scale'): to_int, + service_path('ulimits', PATH_JOKER): to_int, + service_path('ulimits', PATH_JOKER, 'soft'): to_int, + service_path('ulimits', PATH_JOKER, 'hard'): to_int, + service_path('privileged'): to_boolean, + service_path('read_only'): to_boolean, + service_path('stdin_open'): to_boolean, + service_path('tty'): to_boolean, + service_path('volumes', 'read_only'): to_boolean, + service_path('volumes', 'volume', 'nocopy'): to_boolean, + re_path_basic('network', 'attachable'): to_boolean, + re_path_basic('network', 'external'): to_boolean, + re_path_basic('network', 'internal'): to_boolean, + re_path_basic('volume', 'external'): to_boolean, + re_path_basic('secret', 'external'): to_boolean, + re_path_basic('config', 'external'): to_boolean, + } + + def convert(self, path, value): + for rexp in self.map.keys(): + if rexp.match(path): + return self.map[rexp](value) + return value + + +converter = ConversionMap() diff --git a/tests/unit/config/interpolation_test.py b/tests/unit/config/interpolation_test.py index 018a5621a4c..516f5c9e968 100644 --- a/tests/unit/config/interpolation_test.py +++ b/tests/unit/config/interpolation_test.py @@ -9,12 +9,22 @@ from compose.config.interpolation import InvalidInterpolation from compose.config.interpolation import TemplateWithDefaults from compose.const import COMPOSEFILE_V2_0 as V2_0 -from compose.const import COMPOSEFILE_V3_1 as V3_1 +from compose.const import COMPOSEFILE_V2_3 as V2_3 +from compose.const import COMPOSEFILE_V3_4 as V3_4 @pytest.fixture def mock_env(): - return Environment({'USER': 'jenny', 'FOO': 'bar'}) + return Environment({ + 'USER': 'jenny', + 'FOO': 'bar', + 'TRUE': 'True', + 'FALSE': 'OFF', + 'POSINT': '50', + 'NEGINT': '-200', + 'FLOAT': '0.145', + 'MODE': '0600', + }) @pytest.fixture @@ -102,7 +112,189 @@ def test_interpolate_environment_variables_in_secrets(mock_env): }, 'other': {}, } - value = interpolate_environment_variables(V3_1, secrets, 'volume', mock_env) + value = interpolate_environment_variables(V3_4, secrets, 'secret', mock_env) + assert value == expected + + +def test_interpolate_environment_services_convert_types_v2(mock_env): + entry = { + 'service1': { + 'blkio_config': { + 'weight': '${POSINT}', + 'weight_device': [{'file': '/dev/sda1', 'weight': '${POSINT}'}] + }, + 'cpus': '${FLOAT}', + 'cpu_count': '$POSINT', + 'healthcheck': { + 'retries': '${POSINT:-3}', + 'disable': '${FALSE}', + 'command': 'true' + }, + 'mem_swappiness': '${DEFAULT:-127}', + 'oom_score_adj': '${NEGINT}', + 'scale': '${POSINT}', + 'ulimits': { + 'nproc': '${POSINT}', + 'nofile': { + 'soft': '${POSINT}', + 'hard': '${DEFAULT:-40000}' + }, + }, + 'privileged': '${TRUE}', + 'read_only': '${DEFAULT:-no}', + 'tty': '${DEFAULT:-N}', + 'stdin_open': '${DEFAULT-on}', + } + } + + expected = { + 'service1': { + 'blkio_config': { + 'weight': 50, + 'weight_device': [{'file': '/dev/sda1', 'weight': 50}] + }, + 'cpus': 0.145, + 'cpu_count': 50, + 'healthcheck': { + 'retries': 50, + 'disable': False, + 'command': 'true' + }, + 'mem_swappiness': 127, + 'oom_score_adj': -200, + 'scale': 50, + 'ulimits': { + 'nproc': 50, + 'nofile': { + 'soft': 50, + 'hard': 40000 + }, + }, + 'privileged': True, + 'read_only': False, + 'tty': False, + 'stdin_open': True, + } + } + + value = interpolate_environment_variables(V2_3, entry, 'service', mock_env) + assert value == expected + + +def test_interpolate_environment_services_convert_types_v3(mock_env): + entry = { + 'service1': { + 'healthcheck': { + 'retries': '${POSINT:-3}', + 'disable': '${FALSE}', + 'command': 'true' + }, + 'ulimits': { + 'nproc': '${POSINT}', + 'nofile': { + 'soft': '${POSINT}', + 'hard': '${DEFAULT:-40000}' + }, + }, + 'privileged': '${TRUE}', + 'read_only': '${DEFAULT:-no}', + 'tty': '${DEFAULT:-N}', + 'stdin_open': '${DEFAULT-on}', + 'deploy': { + 'update_config': { + 'parallelism': '${DEFAULT:-2}', + 'max_failure_ratio': '${FLOAT}', + }, + 'restart_policy': { + 'max_attempts': '$POSINT', + }, + 'replicas': '${DEFAULT-3}' + }, + 'ports': [{'target': '${POSINT}', 'published': '${DEFAULT:-5000}'}], + 'configs': [{'mode': '${MODE}', 'source': 'config1'}], + 'secrets': [{'mode': '${MODE}', 'source': 'secret1'}], + } + } + + expected = { + 'service1': { + 'healthcheck': { + 'retries': 50, + 'disable': False, + 'command': 'true' + }, + 'ulimits': { + 'nproc': 50, + 'nofile': { + 'soft': 50, + 'hard': 40000 + }, + }, + 'privileged': True, + 'read_only': False, + 'tty': False, + 'stdin_open': True, + 'deploy': { + 'update_config': { + 'parallelism': 2, + 'max_failure_ratio': 0.145, + }, + 'restart_policy': { + 'max_attempts': 50, + }, + 'replicas': 3 + }, + 'ports': [{'target': 50, 'published': 5000}], + 'configs': [{'mode': 0o600, 'source': 'config1'}], + 'secrets': [{'mode': 0o600, 'source': 'secret1'}], + } + } + + value = interpolate_environment_variables(V3_4, entry, 'service', mock_env) + assert value == expected + + +def test_interpolate_environment_network_convert_types(mock_env): + entry = { + 'network1': { + 'external': '${FALSE}', + 'attachable': '${TRUE}', + 'internal': '${DEFAULT:-false}' + } + } + + expected = { + 'network1': { + 'external': False, + 'attachable': True, + 'internal': False, + } + } + + value = interpolate_environment_variables(V3_4, entry, 'network', mock_env) + assert value == expected + + +def test_interpolate_environment_external_resource_convert_types(mock_env): + entry = { + 'resource1': { + 'external': '${TRUE}', + } + } + + expected = { + 'resource1': { + 'external': True, + } + } + + value = interpolate_environment_variables(V3_4, entry, 'network', mock_env) + assert value == expected + value = interpolate_environment_variables(V3_4, entry, 'volume', mock_env) + assert value == expected + value = interpolate_environment_variables(V3_4, entry, 'secret', mock_env) + assert value == expected + value = interpolate_environment_variables(V3_4, entry, 'config', mock_env) assert value == expected From 3ada75821b7b2a76d2e92948ac4fe823a024e538 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 23 Oct 2017 13:25:48 -0700 Subject: [PATCH 3088/4072] Add flake8 to dev requirements Signed-off-by: Joffrey F --- requirements-dev.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements-dev.txt b/requirements-dev.txt index 73b80783501..e06cad45c8c 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,4 +1,5 @@ coverage==3.7.1 +flake8==3.5.0 mock>=1.0.1 pytest==2.7.2 pytest-cov==2.1.0 From d6546e342b3ad1c5d5c53c02cb887264c3797a9f Mon Sep 17 00:00:00 2001 From: Reut Sharabani Date: Mon, 23 Oct 2017 23:21:16 +0300 Subject: [PATCH 3089/4072] Better installation instruction in release notes Changed sample download script to use the built in `-o` optoin in `curl` instead of redicrecting stdout's output. This allows users to prepend `sudo` to the snippet to make it work in common use cases where root permissions are needed to create the output file. From `curl`: -o, --output Write output to instead of stdout. Signed-off-by: Reut Sharabani --- project/RELEASE-PROCESS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project/RELEASE-PROCESS.md b/project/RELEASE-PROCESS.md index 5b30545f459..d4afb87b9c2 100644 --- a/project/RELEASE-PROCESS.md +++ b/project/RELEASE-PROCESS.md @@ -89,7 +89,7 @@ When prompted build the non-linux binaries and test them. Alternatively, you can use the usual commands to install or upgrade Compose: ``` - curl -L https://github.com/docker/compose/releases/download/1.16.0/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose + curl -L https://github.com/docker/compose/releases/download/1.16.0/docker-compose-`uname -s`-`uname -m` -o /usr/local/bin/docker-compose chmod +x /usr/local/bin/docker-compose ``` From aa1fb6749542733dc36f101b84b4dd38498fa4a3 Mon Sep 17 00:00:00 2001 From: Guillermo Arribas Date: Mon, 23 Oct 2017 12:48:44 -0300 Subject: [PATCH 3090/4072] Wrong format in the healthcheck test does not issue a warning (fixes #4424) Signed-off-by: Guillermo Arribas --- compose/config/config.py | 35 ++++------ compose/config/validation.py | 24 +++++++ tests/unit/config/config_test.py | 110 ++++++++++++++++++++++--------- 3 files changed, 116 insertions(+), 53 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 31c6f7277ea..b657335730d 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -47,6 +47,7 @@ from .validation import validate_cpu from .validation import validate_depends_on from .validation import validate_extends_file_path +from .validation import validate_healthcheck from .validation import validate_links from .validation import validate_network_mode from .validation import validate_pid_mode @@ -686,6 +687,7 @@ def validate_service(service_config, service_names, config_file): validate_pid_mode(service_config, service_names) validate_depends_on(service_config, service_names) validate_links(service_config, service_names) + validate_healthcheck(service_config) if not service_dict.get('image') and has_uppercase(service_name): raise ConfigurationError( @@ -728,7 +730,7 @@ def process_service(service_config): service_dict[field] = to_list(service_dict[field]) service_dict = process_blkio_config(process_ports( - process_healthcheck(service_dict, service_config.name) + process_healthcheck(service_dict) )) return service_dict @@ -781,33 +783,20 @@ def process_blkio_config(service_dict): return service_dict -def process_healthcheck(service_dict, service_name): +def process_healthcheck(service_dict): if 'healthcheck' not in service_dict: return service_dict - hc = {} - raw = service_dict['healthcheck'] - - 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 'disable' in service_dict['healthcheck']: + del service_dict['healthcheck']['disable'] + service_dict['healthcheck']['test'] = ['NONE'] for field in ['interval', 'timeout', 'start_period']: - if field in raw: - if not isinstance(raw[field], six.integer_types): - hc[field] = parse_nanoseconds_int(raw[field]) - else: # Conversion has been done previously - hc[field] = raw[field] - if 'retries' in raw: - hc['retries'] = raw['retries'] - - service_dict['healthcheck'] = hc + if field in service_dict['healthcheck']: + if not isinstance(service_dict['healthcheck'][field], six.integer_types): + service_dict['healthcheck'][field] = parse_nanoseconds_int( + service_dict['healthcheck'][field]) + return service_dict diff --git a/compose/config/validation.py b/compose/config/validation.py index 940775a2097..8247cf1500a 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -465,3 +465,27 @@ def handle_errors(errors, format_error_func, filename): "The Compose file{file_msg} is invalid because:\n{error_msg}".format( file_msg=" '{}'".format(filename) if filename else "", error_msg=error_msg)) + + +def validate_healthcheck(service_config): + healthcheck = service_config.config.get('healthcheck', {}) + + if 'test' in healthcheck and isinstance(healthcheck['test'], list): + if len(healthcheck['test']) == 0: + raise ConfigurationError( + 'Service "{}" defines an invalid healthcheck: ' + '"test" is an empty list' + .format(service_config.name)) + + # when disable is true config.py::process_healthcheck adds "test: ['NONE']" to service_config + elif healthcheck['test'][0] == 'NONE' and len(healthcheck) > 1: + raise ConfigurationError( + 'Service "{}" defines an invalid healthcheck: ' + '"disable: true" cannot be combined with other options' + .format(service_config.name)) + + elif healthcheck['test'][0] not in ('NONE', 'CMD', 'CMD-SHELL'): + raise ConfigurationError( + 'Service "{}" defines an invalid healthcheck: ' + 'when "test" is a list the first item must be either NONE, CMD or CMD-SHELL' + .format(service_config.name)) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 7a7cfacdd61..3eaa971669a 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -34,7 +34,6 @@ from compose.const import COMPOSEFILE_V3_2 as V3_2 from compose.const import COMPOSEFILE_V3_3 as V3_3 from compose.const import IS_WINDOWS_PLATFORM -from compose.utils import nanoseconds_from_time_seconds from tests import mock from tests import unittest @@ -4191,52 +4190,103 @@ def test_invalid_url_in_build_path(self): class HealthcheckTest(unittest.TestCase): def test_healthcheck(self): - service_dict = make_service_dict( - 'test', - {'healthcheck': { - 'test': ['CMD', 'true'], - 'interval': '1s', - 'timeout': '1m', - 'retries': 3, - 'start_period': '10s' - }}, - '.', + config_dict = config.load( + build_config_details({ + 'version': '2.3', + 'services': { + 'test': { + 'image': 'busybox', + 'healthcheck': { + 'test': ['CMD', 'true'], + 'interval': '1s', + 'timeout': '1m', + 'retries': 3, + 'start_period': '10s', + } + } + } + + }) ) - assert service_dict['healthcheck'] == { + serialized_config = yaml.load(serialize_config(config_dict)) + serialized_service = serialized_config['services']['test'] + + assert serialized_service['healthcheck'] == { 'test': ['CMD', 'true'], - 'interval': nanoseconds_from_time_seconds(1), - 'timeout': nanoseconds_from_time_seconds(60), + 'interval': '1s', + 'timeout': '1m', 'retries': 3, - 'start_period': nanoseconds_from_time_seconds(10) + 'start_period': '10s' } def test_disable(self): - service_dict = make_service_dict( - 'test', - {'healthcheck': { - 'disable': True, - }}, - '.', + config_dict = config.load( + build_config_details({ + 'version': '2.3', + 'services': { + 'test': { + 'image': 'busybox', + 'healthcheck': { + 'disable': True, + } + } + } + + }) ) - assert service_dict['healthcheck'] == { + serialized_config = yaml.load(serialize_config(config_dict)) + serialized_service = serialized_config['services']['test'] + + assert serialized_service['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', - }}, - '.', + config.load( + build_config_details({ + 'version': '2.3', + 'services': { + 'invalid-healthcheck': { + 'image': 'busybox', + 'healthcheck': { + 'disable': True, + 'interval': '1s', + } + } + } + + }) + ) + + assert 'invalid-healthcheck' in excinfo.exconly() + assert '"disable: true" cannot be combined with other options' in excinfo.exconly() + + def test_healthcheck_with_invalid_test(self): + with pytest.raises(ConfigurationError) as excinfo: + config.load( + build_config_details({ + 'version': '2.3', + 'services': { + 'invalid-healthcheck': { + 'image': 'busybox', + 'healthcheck': { + 'test': ['true'], + 'interval': '1s', + 'timeout': '1m', + 'retries': 3, + 'start_period': '10s', + } + } + } + + }) ) assert 'invalid-healthcheck' in excinfo.exconly() - assert 'disable' in excinfo.exconly() + assert 'the first item must be either NONE, CMD or CMD-SHELL' in excinfo.exconly() class GetDefaultConfigFilesTestCase(unittest.TestCase): From 3cfa0bd162a5df608b66532223db78aafbdada4f Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 23 Oct 2017 14:49:36 -0700 Subject: [PATCH 3091/4072] Improve process_healthcheck readability Signed-off-by: Joffrey F --- compose/config/config.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index b657335730d..9af40289706 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -787,15 +787,16 @@ def process_healthcheck(service_dict): if 'healthcheck' not in service_dict: return service_dict - if 'disable' in service_dict['healthcheck']: - del service_dict['healthcheck']['disable'] - service_dict['healthcheck']['test'] = ['NONE'] + hc = service_dict['healthcheck'] + + if 'disable' in hc: + del hc['disable'] + hc['test'] = ['NONE'] for field in ['interval', 'timeout', 'start_period']: - if field in service_dict['healthcheck']: - if not isinstance(service_dict['healthcheck'][field], six.integer_types): - service_dict['healthcheck'][field] = parse_nanoseconds_int( - service_dict['healthcheck'][field]) + if field not in hc or isinstance(hc[field], six.integer_types): + continue + hc[field] = parse_nanoseconds_int(hc[field]) return service_dict From 8c0b03a2f570d27e7e1d9a67c26d5c8963997f8c Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 23 Oct 2017 17:18:45 -0700 Subject: [PATCH 3092/4072] Add support for BOM-signed env files Signed-off-by: Joffrey F --- compose/config/environment.py | 2 +- tests/unit/config/environment_test.py | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/compose/config/environment.py b/compose/config/environment.py index 4ba228c8a1f..0087b612807 100644 --- a/compose/config/environment.py +++ b/compose/config/environment.py @@ -32,7 +32,7 @@ def env_vars_from_file(filename): elif not os.path.isfile(filename): raise ConfigurationError("%s is not a file." % (filename)) env = {} - with contextlib.closing(codecs.open(filename, 'r', 'utf-8')) as fileobj: + with contextlib.closing(codecs.open(filename, 'r', 'utf-8-sig')) as fileobj: for line in fileobj: line = line.strip() if line and not line.startswith('#'): diff --git a/tests/unit/config/environment_test.py b/tests/unit/config/environment_test.py index 20446d2bf2d..854aee5a358 100644 --- a/tests/unit/config/environment_test.py +++ b/tests/unit/config/environment_test.py @@ -3,6 +3,11 @@ from __future__ import print_function from __future__ import unicode_literals +import codecs + +import pytest + +from compose.config.environment import env_vars_from_file from compose.config.environment import Environment from tests import unittest @@ -38,3 +43,12 @@ def test_get_boolean(self): assert env.get_boolean('BAZ') is False assert env.get_boolean('FOOBAR') is True assert env.get_boolean('UNDEFINED') is False + + def test_env_vars_from_file_bom(self): + tmpdir = pytest.ensuretemp('env_file') + self.addCleanup(tmpdir.remove) + with codecs.open('{}/bom.env'.format(str(tmpdir)), 'w', encoding='utf-8') as f: + f.write('\ufeffPARK_BOM=박봄\n') + assert env_vars_from_file(str(tmpdir.join('bom.env'))) == { + 'PARK_BOM': '박봄' + } From b30cb77a7bc061bf46758428b3488ed7d3fe0ada Mon Sep 17 00:00:00 2001 From: Guillermo Arribas Date: Thu, 19 Oct 2017 22:07:30 -0300 Subject: [PATCH 3093/4072] docker-compose exec doesn't have -e option (fixes #4551) Signed-off-by: Guillermo Arribas --- compose/cli/main.py | 59 ++++++++++++------- tests/acceptance/cli_test.py | 26 ++++++++ .../environment-exec/docker-compose.yml | 10 ++++ 3 files changed, 74 insertions(+), 21 deletions(-) create mode 100644 tests/fixtures/environment-exec/docker-compose.yml diff --git a/compose/cli/main.py b/compose/cli/main.py index face38e6d33..c3e30919d4b 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -14,6 +14,8 @@ from inspect import getdoc from operator import attrgetter +import docker + from . import errors from . import signals from .. import __version__ @@ -402,7 +404,7 @@ def exec_command(self, options): """ Execute a command in a running container - Usage: exec [options] SERVICE COMMAND [ARGS...] + Usage: exec [options] [-e KEY=VAL...] SERVICE COMMAND [ARGS...] Options: -d Detached mode: Run command in the background. @@ -412,11 +414,16 @@ def exec_command(self, options): allocates a TTY. --index=index index of the container if there are multiple instances of a service [default: 1] + -e, --env KEY=VAL Set environment variables (can be used multiple times, + not supported in API < 1.25) """ index = int(options.get('--index')) service = self.project.get_service(options['SERVICE']) detach = options['-d'] + if options['--env'] and docker.utils.version_lt(self.project.client.api_version, '1.25'): + raise UserError("Setting environment for exec is not supported in API < 1.25'") + try: container = service.get_container(number=index) except ValueError as e: @@ -425,26 +432,7 @@ def exec_command(self, options): tty = not options["-T"] if IS_WINDOWS_PLATFORM and not detach: - args = ["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(call_docker(args)) + sys.exit(call_docker(build_exec_command(options, container.id, command))) create_exec_options = { "privileged": options["--privileged"], @@ -453,6 +441,9 @@ def exec_command(self, options): "stdin": tty, } + if docker.utils.version_gte(self.project.client.api_version, '1.25'): + create_exec_options["environment"] = options["--env"] + exec_id = container.create_exec(command, **create_exec_options) if detach: @@ -1295,3 +1286,29 @@ def parse_scale_args(options): ) res[service_name] = num return res + + +def build_exec_command(options, container_id, command): + args = ["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"]] + + if options["--env"]: + for env_variable in options["--env"]: + args += ["--env", env_variable] + + args += [container_id] + args += command + return args diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index c9420e011fd..6c18a175cc9 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -33,6 +33,7 @@ from tests.integration.testcases import pull_busybox from tests.integration.testcases import SWARM_SKIP_RM_VOLUMES from tests.integration.testcases import v2_1_only +from tests.integration.testcases import v2_2_only from tests.integration.testcases import v2_only from tests.integration.testcases import v3_only @@ -1369,6 +1370,31 @@ def test_exec_custom_user(self): self.assertEqual(stdout, "operator\n") self.assertEqual(stderr, "") + @v2_2_only() + def test_exec_service_with_environment_overridden(self): + name = 'service' + self.base_dir = 'tests/fixtures/environment-exec' + self.dispatch(['up', '-d']) + self.assertEqual(len(self.project.containers()), 1) + + stdout, stderr = self.dispatch([ + 'exec', + '-T', + '-e', 'foo=notbar', + '--env', 'alpha=beta', + name, + 'env', + ]) + + # env overridden + assert 'foo=notbar' in stdout + # keep environment from yaml + assert 'hello=world' in stdout + # added option from command line + assert 'alpha=beta' in stdout + + self.assertEqual(stderr, '') + 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/environment-exec/docker-compose.yml b/tests/fixtures/environment-exec/docker-compose.yml new file mode 100644 index 00000000000..813606eb8b4 --- /dev/null +++ b/tests/fixtures/environment-exec/docker-compose.yml @@ -0,0 +1,10 @@ +version: "2.2" + +services: + service: + image: busybox:latest + command: top + + environment: + foo: bar + hello: world From 80503da4763982fe1595f72a40032bacf59970b2 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 25 Oct 2017 15:07:00 -0700 Subject: [PATCH 3094/4072] Add support for oom_kill_disable in service config Signed-off-by: Joffrey F --- compose/config/config.py | 1 + compose/config/config_schema_v2.1.json | 1 + compose/config/config_schema_v2.2.json | 1 + compose/config/config_schema_v2.3.json | 1 + compose/config/interpolation.py | 1 + compose/service.py | 2 ++ tests/integration/service_test.py | 9 +++++++-- 7 files changed, 14 insertions(+), 2 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 9af40289706..df98dcb490c 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -91,6 +91,7 @@ 'mem_swappiness', 'net', 'oom_score_adj', + 'oom_kill_disable', 'pid', 'ports', 'privileged', diff --git a/compose/config/config_schema_v2.1.json b/compose/config/config_schema_v2.1.json index 24e6ba02cea..6b74f0ed699 100644 --- a/compose/config/config_schema_v2.1.json +++ b/compose/config/config_schema_v2.1.json @@ -229,6 +229,7 @@ } ] }, + "oom_kill_disable": {"type": "boolean"}, "oom_score_adj": {"type": "integer", "minimum": -1000, "maximum": 1000}, "group_add": { "type": "array", diff --git a/compose/config/config_schema_v2.2.json b/compose/config/config_schema_v2.2.json index 86fc5df95d1..21343b8932c 100644 --- a/compose/config/config_schema_v2.2.json +++ b/compose/config/config_schema_v2.2.json @@ -235,6 +235,7 @@ } ] }, + "oom_kill_disable": {"type": "boolean"}, "oom_score_adj": {"type": "integer", "minimum": -1000, "maximum": 1000}, "group_add": { "type": "array", diff --git a/compose/config/config_schema_v2.3.json b/compose/config/config_schema_v2.3.json index ceaf44954eb..0e709e9d93b 100644 --- a/compose/config/config_schema_v2.3.json +++ b/compose/config/config_schema_v2.3.json @@ -237,6 +237,7 @@ } ] }, + "oom_kill_disable": {"type": "boolean"}, "oom_score_adj": {"type": "integer", "minimum": -1000, "maximum": 1000}, "group_add": { "type": "array", diff --git a/compose/config/interpolation.py b/compose/config/interpolation.py index 9d7e428c9bd..45a5f9fc232 100644 --- a/compose/config/interpolation.py +++ b/compose/config/interpolation.py @@ -156,6 +156,7 @@ class ConversionMap(object): service_path('deploy', 'update_config', 'max_failure_ratio'): float, service_path('deploy', 'restart_policy', 'max_attempts'): to_int, service_path('mem_swappiness'): to_int, + service_path('oom_kill_disable'): to_boolean, service_path('oom_score_adj'): to_int, service_path('ports', 'target'): to_int, service_path('ports', 'published'): to_int, diff --git a/compose/service.py b/compose/service.py index d5be740dfac..6a5fd8fc188 100644 --- a/compose/service.py +++ b/compose/service.py @@ -77,6 +77,7 @@ 'mem_reservation', 'memswap_limit', 'mem_swappiness', + 'oom_kill_disable', 'oom_score_adj', 'pid', 'pids_limit', @@ -859,6 +860,7 @@ def _get_container_host_config(self, override_options, one_off=False): sysctls=options.get('sysctls'), pids_limit=options.get('pids_limit'), tmpfs=options.get('tmpfs'), + oom_kill_disable=options.get('oom_kill_disable'), oom_score_adj=options.get('oom_score_adj'), mem_swappiness=options.get('mem_swappiness'), group_add=options.get('group_add'), diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 84b54fe419d..1f5d22f6125 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -239,8 +239,7 @@ def test_create_container_with_security_opt(self): service.start_container(container) self.assertEqual(set(container.get('HostConfig.SecurityOpt')), set(security_opt)) - # @pytest.mark.xfail(True, reason='Not supported on most drivers') - @pytest.mark.skipif(True, reason='https://github.com/moby/moby/issues/34270') + @pytest.mark.xfail(True, reason='Not supported on most drivers') def test_create_container_with_storage_opt(self): storage_opt = {'size': '1G'} service = self.create_service('db', storage_opt=storage_opt) @@ -248,6 +247,12 @@ def test_create_container_with_storage_opt(self): service.start_container(container) self.assertEqual(container.get('HostConfig.StorageOpt'), storage_opt) + def test_create_container_with_oom_kill_disable(self): + self.require_api_version('1.20') + service = self.create_service('db', oom_kill_disable=True) + container = service.create_container() + assert container.get('HostConfig.OomKillDisable') is True + def test_create_container_with_mac_address(self): service = self.create_service('db', mac_address='02:42:ac:11:65:43') container = service.create_container() From da32c44bce15a83b1d504db3b6e6fb2ed8f4d584 Mon Sep 17 00:00:00 2001 From: Guillermo Arribas Date: Wed, 1 Nov 2017 12:15:00 -0300 Subject: [PATCH 3095/4072] Terminate containers on SIGHUP (fixes #4909) Signed-off-by: Guillermo Arribas --- compose/cli/main.py | 5 +++-- compose/cli/signals.py | 14 ++++++++++++++ tests/acceptance/cli_test.py | 13 +++++++++++++ 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index c3e30919d4b..e89a0fe13c9 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -1178,6 +1178,7 @@ def remove_container(force=False): project.client.remove_container(container.id, force=True, v=True) signals.set_signal_handler_to_shutdown() + signals.set_signal_handler_to_hang_up() try: try: if IS_WINDOWS_PLATFORM: @@ -1195,10 +1196,10 @@ def remove_container(force=False): service.start_container(container) pty.start(sockets) exit_code = container.wait() - except signals.ShutdownException: + except (signals.ShutdownException, signals.HangUpException): project.client.stop(container.id) exit_code = 1 - except signals.ShutdownException: + except (signals.ShutdownException, signals.HangUpException): project.client.kill(container.id) remove_container(force=True) sys.exit(2) diff --git a/compose/cli/signals.py b/compose/cli/signals.py index 9b360c44e91..44def2ece65 100644 --- a/compose/cli/signals.py +++ b/compose/cli/signals.py @@ -10,6 +10,10 @@ class ShutdownException(Exception): pass +class HangUpException(Exception): + pass + + def shutdown(signal, frame): raise ShutdownException() @@ -23,6 +27,16 @@ def set_signal_handler_to_shutdown(): set_signal_handler(shutdown) +def hang_up(signal, frame): + raise HangUpException() + + +def set_signal_handler_to_hang_up(): + # on Windows a ValueError will be raised if trying to set signal handler for SIGHUP + if not IS_WINDOWS_PLATFORM: + signal.signal(signal.SIGHUP, hang_up) + + def ignore_sigpipe(): # Restore default behavior for SIGPIPE instead of raising # an exception when encountered. diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 6c18a175cc9..65b96733e0c 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1795,6 +1795,19 @@ def test_run_handles_sigterm(self): 'simplecomposefile_simple_run_1', 'exited')) + def test_run_handles_sighup(self): + proc = start_process(self.base_dir, ['run', '-T', 'simple', 'top']) + wait_on_condition(ContainerStateCondition( + self.project.client, + 'simplecomposefile_simple_run_1', + 'running')) + + os.kill(proc.pid, signal.SIGHUP) + wait_on_condition(ContainerStateCondition( + self.project.client, + 'simplecomposefile_simple_run_1', + 'exited')) + @mock.patch.dict(os.environ) def test_run_unicode_env_values_from_system(self): value = 'ą, ć, ę, ł, ń, ó, ś, ź, ż' From 2df3d6f75aee58532cd6ffcd4f8300ba46758800 Mon Sep 17 00:00:00 2001 From: Andy Neff Date: Thu, 26 Oct 2017 11:42:57 -0400 Subject: [PATCH 3096/4072] Have stop_grace_period also set StopTimeout on create Signed-off-by: Andy Neff --- compose/service.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/compose/service.py b/compose/service.py index 6a5fd8fc188..1ecfd8bae14 100644 --- a/compose/service.py +++ b/compose/service.py @@ -15,6 +15,7 @@ from docker.errors import NotFound from docker.types import LogConfig from docker.utils import version_lt +from docker.utils import version_gte from docker.utils.ports import build_port_bindings from docker.utils.ports import split_port from docker.utils.utils import convert_tmpfs_mounts @@ -759,6 +760,11 @@ def _get_container_create_options( container_options['hostname'] = parts[0] container_options['domainname'] = parts[2] + if (version_gte(self.client.api_version, '1.25') and + 'stop_grace_period' in self.options): + container_options['stop_timeout'] = parse_seconds_float( + self.options.pop('stop_grace_period')) + if 'ports' in container_options or 'expose' in self.options: container_options['ports'] = build_container_ports( formatted_ports(container_options.get('ports', [])), From 0e4bd32a65617f310bb81d6a82edc547e02a57ca Mon Sep 17 00:00:00 2001 From: Andy Neff Date: Fri, 27 Oct 2017 17:44:17 -0400 Subject: [PATCH 3097/4072] Added unit test and used stop_timeout Signed-off-by: Andy Neff --- compose/service.py | 5 ++--- tests/unit/service_test.py | 11 +++++++++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/compose/service.py b/compose/service.py index 1ecfd8bae14..453f982f042 100644 --- a/compose/service.py +++ b/compose/service.py @@ -14,8 +14,8 @@ from docker.errors import ImageNotFound from docker.errors import NotFound from docker.types import LogConfig -from docker.utils import version_lt from docker.utils import version_gte +from docker.utils import version_lt from docker.utils.ports import build_port_bindings from docker.utils.ports import split_port from docker.utils.utils import convert_tmpfs_mounts @@ -762,8 +762,7 @@ def _get_container_create_options( if (version_gte(self.client.api_version, '1.25') and 'stop_grace_period' in self.options): - container_options['stop_timeout'] = parse_seconds_float( - self.options.pop('stop_grace_period')) + container_options['stop_timeout'] = self.stop_timeout(None) if 'ports' in container_options or 'expose' in self.options: container_options['ports'] = build_container_ports( diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index c979295ad3b..35f80d11a1e 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -228,6 +228,17 @@ def test_log_opt(self): {'Type': 'syslog', 'Config': {'syslog-address': 'tcp://192.168.0.42:123'}} ) + def test_stop_grace_period(self): + self.mock_client.api_version = '1.25' + self.mock_client.create_host_config.return_value = {} + service = Service( + 'foo', + image='foo', + client=self.mock_client, + stop_grace_period="1m35s") + opts = service._get_container_create_options({'image': 'foo'}, 1) + self.assertEqual(opts['stop_timeout'], 95) + def test_split_domainname_none(self): service = Service( 'foo', From 779773b6644b8598a1143d5f370a2fe24eeefac4 Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Thu, 19 Oct 2017 09:18:40 +0200 Subject: [PATCH 3098/4072] Add bash completion for `up --no-start` 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 9de156403fd..1fdb2770540 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -518,7 +518,7 @@ _docker_compose_up() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "--abort-on-container-exit --build -d --exit-code-from --force-recreate --help --no-build --no-color --no-deps --no-recreate --remove-orphans --scale --timeout -t" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--abort-on-container-exit --build -d --exit-code-from --force-recreate --help --no-build --no-color --no-deps --no-recreate --no-start --remove-orphans --scale --timeout -t" -- "$cur" ) ) ;; *) __docker_compose_services_all From 0847f8e84be690ad478a425565ab4e6cc9f8371a Mon Sep 17 00:00:00 2001 From: Guillermo Arribas Date: Mon, 23 Oct 2017 13:22:36 -0300 Subject: [PATCH 3099/4072] flake8 error on master branch (fixes #5298) Signed-off-by: Guillermo Arribas --- compose/bundle.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/bundle.py b/compose/bundle.py index 505ce91fed0..937a3708a99 100644 --- a/compose/bundle.py +++ b/compose/bundle.py @@ -121,7 +121,7 @@ def get_image_digest(service, allow_push=False): def push_image(service): try: digest = service.push() - except: + except Exception: log.error( "Failed to push image for service '{s.name}'. Please use an " "image tag that can be pushed to a Docker " From 6078736604e7ab77e16b20ec5c3928b939120a7b Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 23 Oct 2017 13:25:48 -0700 Subject: [PATCH 3100/4072] Add flake8 to dev requirements Signed-off-by: Joffrey F --- requirements-dev.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements-dev.txt b/requirements-dev.txt index 73b80783501..e06cad45c8c 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,4 +1,5 @@ coverage==3.7.1 +flake8==3.5.0 mock>=1.0.1 pytest==2.7.2 pytest-cov==2.1.0 From 9ae6015ce27b5cee86fed6e4b9d6f4272d9d6c23 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 1 Nov 2017 14:26:29 -0700 Subject: [PATCH 3101/4072] Miscellaneous test fixes Signed-off-by: Joffrey F --- tests/acceptance/cli_test.py | 8 +++++++- tests/integration/service_test.py | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 6c18a175cc9..8468dfbde51 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -853,7 +853,13 @@ def test_up_no_start(self): volumes = self.project.volumes.volumes assert 'data' in volumes volume = volumes['data'] - assert volume.exists() + + # The code below is a Swarm-compatible equivalent to volume.exists() + remote_volumes = [ + v for v in self.client.volumes().get('Volumes', []) + if v['Name'].split('/')[-1] == volume.full_name + ] + assert len(remote_volumes) > 0 @v2_only() def test_up_no_ansi(self): diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 1f5d22f6125..deced274240 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -793,7 +793,7 @@ def test_build_with_network(self): net_container = self.client.create_container( 'busybox', 'top', host_config=self.client.create_host_config( - extra_hosts={'google.local': '8.8.8.8'} + extra_hosts={'google.local': '127.0.0.1'} ), name='composetest_build_network' ) From 9f80ec548e79e1fbfa7adab27a0e50b3fdbb6ba9 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 1 Nov 2017 14:26:29 -0700 Subject: [PATCH 3102/4072] Miscellaneous test fixes Signed-off-by: Joffrey F --- tests/acceptance/cli_test.py | 8 +++++++- tests/integration/service_test.py | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 8ba43b00f05..bba2238e7bc 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -851,7 +851,13 @@ def test_up_no_start(self): volumes = self.project.volumes.volumes assert 'data' in volumes volume = volumes['data'] - assert volume.exists() + + # The code below is a Swarm-compatible equivalent to volume.exists() + remote_volumes = [ + v for v in self.client.volumes().get('Volumes', []) + if v['Name'].split('/')[-1] == volume.full_name + ] + assert len(remote_volumes) > 0 @v2_only() def test_up_no_ansi(self): diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 84b54fe419d..3ddf991b304 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -788,7 +788,7 @@ def test_build_with_network(self): net_container = self.client.create_container( 'busybox', 'top', host_config=self.client.create_host_config( - extra_hosts={'google.local': '8.8.8.8'} + extra_hosts={'google.local': '127.0.0.1'} ), name='composetest_build_network' ) From ac53b73e7958b825f7235a661c208f4f6f6e90f7 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 1 Nov 2017 13:34:50 -0700 Subject: [PATCH 3103/4072] Bump 1.17.0 Signed-off-by: Joffrey F --- CHANGELOG.md | 2 +- compose/__init__.py | 2 +- script/run/run.sh | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cff19d8793c..f531783e8cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ Change log ========== -1.17.0 (2017-11-03) +1.17.0 (2017-11-02) ------------------- ### New features diff --git a/compose/__init__.py b/compose/__init__.py index 86542ec44f4..7b0c7d1e400 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.17.0-rc1' +__version__ = '1.17.0' diff --git a/script/run/run.sh b/script/run/run.sh index 498226288c1..38ce878730c 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.17.0-rc1" +VERSION="1.17.0" IMAGE="docker/compose:$VERSION" From 819be19f7cec88f2baecbd6a8cfb2303ff64e019 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 1 Nov 2017 14:26:29 -0700 Subject: [PATCH 3104/4072] Miscellaneous test fixes Signed-off-by: Joffrey F --- tests/acceptance/cli_test.py | 8 +++++++- tests/integration/service_test.py | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 6c18a175cc9..8468dfbde51 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -853,7 +853,13 @@ def test_up_no_start(self): volumes = self.project.volumes.volumes assert 'data' in volumes volume = volumes['data'] - assert volume.exists() + + # The code below is a Swarm-compatible equivalent to volume.exists() + remote_volumes = [ + v for v in self.client.volumes().get('Volumes', []) + if v['Name'].split('/')[-1] == volume.full_name + ] + assert len(remote_volumes) > 0 @v2_only() def test_up_no_ansi(self): diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 1f5d22f6125..deced274240 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -793,7 +793,7 @@ def test_build_with_network(self): net_container = self.client.create_container( 'busybox', 'top', host_config=self.client.create_host_config( - extra_hosts={'google.local': '8.8.8.8'} + extra_hosts={'google.local': '127.0.0.1'} ), name='composetest_build_network' ) From 2482d57e9a3f1468cd20755e6d368b1e6fe2a749 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 3 Nov 2017 12:26:31 -0700 Subject: [PATCH 3105/4072] Add shasum computation to download-binaries script Signed-off-by: Joffrey F --- script/release/download-binaries | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/script/release/download-binaries b/script/release/download-binaries index 5d01f5f75e2..bef5430f4b3 100755 --- a/script/release/download-binaries +++ b/script/release/download-binaries @@ -30,3 +30,8 @@ mkdir $DESTINATION wget -O $DESTINATION/docker-compose-Darwin-x86_64 $BASE_BINTRAY_URL/docker-compose-Darwin-x86_64 wget -O $DESTINATION/docker-compose-Linux-x86_64 $BASE_BINTRAY_URL/docker-compose-Linux-x86_64 wget -O $DESTINATION/docker-compose-Windows-x86_64.exe $APPVEYOR_URL + +echo -e "\n\nCopy the following lines into the integrity check table in the release notes:\n\n" +cd $DESTINATION +ls | xargs sha256sum | sed 's/ / | /g' | sed -r 's/([^ |]+)/`\1`/g' +cd - From 5e032b7e8f940491cc472ab788baa4c3d822a4f7 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 16 Oct 2017 16:50:51 -0700 Subject: [PATCH 3106/4072] Bump 1.17.0-rc1 Signed-off-by: Joffrey F --- CHANGELOG.md | 67 ++++++++++++++++++++++++++++++++++++++++++++- compose/__init__.py | 2 +- script/run/run.sh | 2 +- 3 files changed, 68 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a92bf10a53..cff19d8793c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,71 @@ Change log ========== +1.17.0 (2017-11-03) +------------------- + +### New features + +#### Compose file version 3.4 + +- Introduced version 3.4 of the `docker-compose.yml` specification. + This version requires to be used with Docker Engine 17.06.0 or above. + +- Added support for `cache_from`, `network` and `target` options in build + configurations + +- Added support for the `order` parameter in the `update_config` section + +- Added support for setting a custom name in volume definitions using + the `name` parameter + +#### Compose file version 2.3 + +- Added support for `shm_size` option in build configuration + +#### Compose file version 2.x + +- Added support for extension fields (`x-*`). Also available for v3.4 files + +#### All formats + +- Added new `--no-start` to the `up` command, allowing users to create all + resources (networks, volumes, containers) without starting services. + The `create` command is deprecated in favor of this new option + +### Bugfixes + +- Fixed a bug where `extra_hosts` values would be overridden by extension + files instead of merging together + +- Fixed a bug where the validation for v3.2 files would prevent using the + `consistency` field in service volume definitions + +- Fixed a bug that would cause a crash when configuration fields expecting + unique items would contain duplicates + +- Fixed a bug where mount overrides with a different mode would create a + duplicate entry instead of overriding the original entry + +- Fixed a bug where build labels declared as a list wouldn't be properly + parsed + +- Fixed a bug where the output of `docker-compose config` would be invalid + for some versions if the file contained custom-named external volumes + +- Improved error handling when issuing a build command on Windows using an + unsupported file version + +- Fixed an issue where networks with identical names would sometimes be + created when running `up` commands concurrently. + +1.16.1 (2017-09-01) +------------------- + +### Bugfixes + +- Fixed bug that prevented using `extra_hosts` in several configuration files. + 1.16.0 (2017-08-31) ------------------- @@ -11,7 +76,7 @@ Change log - Introduced version 2.3 of the `docker-compose.yml` specification. This version requires to be used with Docker Engine 17.06.0 or above. -- Added support for the `target` parameter in network configurations +- Added support for the `target` parameter in build configurations - Added support for the `start_period` parameter in healthcheck configurations diff --git a/compose/__init__.py b/compose/__init__.py index 0b9282521c1..86542ec44f4 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.17.0dev' +__version__ = '1.17.0-rc1' diff --git a/script/run/run.sh b/script/run/run.sh index bf9a26cb8fc..498226288c1 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.16.0" +VERSION="1.17.0-rc1" IMAGE="docker/compose:$VERSION" From d94cfff78daa2c5acf6790c0cbcae71fe349b2f1 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 1 Nov 2017 13:34:50 -0700 Subject: [PATCH 3107/4072] Bump 1.17.0 Signed-off-by: Joffrey F --- CHANGELOG.md | 2 +- compose/__init__.py | 2 +- script/run/run.sh | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cff19d8793c..f531783e8cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ Change log ========== -1.17.0 (2017-11-03) +1.17.0 (2017-11-02) ------------------- ### New features diff --git a/compose/__init__.py b/compose/__init__.py index 86542ec44f4..7b0c7d1e400 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.17.0-rc1' +__version__ = '1.17.0' diff --git a/script/run/run.sh b/script/run/run.sh index 498226288c1..38ce878730c 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.17.0-rc1" +VERSION="1.17.0" IMAGE="docker/compose:$VERSION" From 9b03cdfdf80689401d520324d40ee7142aaa2219 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 3 Nov 2017 12:36:53 -0700 Subject: [PATCH 3108/4072] 1.18.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 7b0c7d1e400..7b954eb4f67 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.17.0' +__version__ = '1.18.0dev' From 4563d8c050af7d29bff248e875f00aaf2d5a985d Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 3 Nov 2017 13:51:49 -0700 Subject: [PATCH 3109/4072] Fix service label parsing Signed-off-by: Joffrey F --- compose/config/config.py | 23 ++++++++----- tests/unit/config/config_test.py | 55 +++++++++++++++++++++----------- 2 files changed, 52 insertions(+), 26 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index df98dcb490c..adfb53d8f7a 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -709,14 +709,7 @@ def process_service(service_config): ] if 'build' in service_dict: - if isinstance(service_dict['build'], six.string_types): - service_dict['build'] = resolve_build_path(working_dir, service_dict['build']) - elif isinstance(service_dict['build'], dict): - if 'context' in service_dict['build']: - path = service_dict['build']['context'] - service_dict['build']['context'] = resolve_build_path(working_dir, path) - if 'labels' in service_dict['build']: - service_dict['build']['labels'] = parse_labels(service_dict['build']['labels']) + process_build_section(service_dict, working_dir) if 'volumes' in service_dict and service_dict.get('volume_driver') is None: service_dict['volumes'] = resolve_volume_paths(working_dir, service_dict) @@ -724,6 +717,9 @@ def process_service(service_config): if 'sysctls' in service_dict: service_dict['sysctls'] = build_string_dict(parse_sysctls(service_dict['sysctls'])) + if 'labels' in service_dict: + service_dict['labels'] = parse_labels(service_dict['labels']) + service_dict = process_depends_on(service_dict) for field in ['dns', 'dns_search', 'tmpfs']: @@ -737,6 +733,17 @@ def process_service(service_config): return service_dict +def process_build_section(service_dict, working_dir): + if isinstance(service_dict['build'], six.string_types): + service_dict['build'] = resolve_build_path(working_dir, service_dict['build']) + elif isinstance(service_dict['build'], dict): + if 'context' in service_dict['build']: + path = service_dict['build']['context'] + service_dict['build']['context'] = resolve_build_path(working_dir, path) + if 'labels' in service_dict['build']: + service_dict['build']['labels'] = parse_labels(service_dict['build']['labels']) + + def process_ports(service_dict): if 'ports' not in service_dict: return service_dict diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 3eaa971669a..a758154c04b 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -406,6 +406,32 @@ def test_load_config_link_local_ips_network(self): } } + def test_load_config_service_labels(self): + base_file = config.ConfigFile( + 'base.yaml', + { + 'version': '2.1', + 'services': { + 'web': { + 'image': 'example/web', + 'labels': ['label_key=label_val'] + }, + 'db': { + 'image': 'example/db', + 'labels': { + 'label_key': 'label_val' + } + } + }, + } + ) + details = config.ConfigDetails('.', [base_file]) + service_dicts = config.load(details).services + for service in service_dicts: + assert service['labels'] == { + 'label_key': 'label_val' + } + def test_load_config_volume_and_network_labels(self): base_file = config.ConfigFile( 'base.yaml', @@ -434,30 +460,23 @@ def test_load_config_volume_and_network_labels(self): ) details = config.ConfigDetails('.', [base_file]) - network_dict = config.load(details).networks - volume_dict = config.load(details).volumes + loaded_config = config.load(details) - self.assertEqual( - network_dict, - { - 'with_label': { - 'labels': { - 'label_key': 'label_val' - } + assert loaded_config.networks == { + 'with_label': { + 'labels': { + 'label_key': 'label_val' } } - ) + } - self.assertEqual( - volume_dict, - { - 'with_label': { - 'labels': { - 'label_key': 'label_val' - } + assert loaded_config.volumes == { + 'with_label': { + 'labels': { + 'label_key': 'label_val' } } - ) + } def test_load_config_invalid_service_names(self): for invalid_name in ['?not?allowed', ' ', '', '!', '/', '\xe2']: From 03fefaca393799ac50e2a9f8bbcfdd26d86682cf Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 3 Nov 2017 13:51:49 -0700 Subject: [PATCH 3110/4072] Fix service label parsing Signed-off-by: Joffrey F --- compose/config/config.py | 23 ++++++++----- tests/unit/config/config_test.py | 55 +++++++++++++++++++++----------- 2 files changed, 52 insertions(+), 26 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 7bb57076e10..d5aaf9538c1 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -706,14 +706,7 @@ def process_service(service_config): ] if 'build' in service_dict: - if isinstance(service_dict['build'], six.string_types): - service_dict['build'] = resolve_build_path(working_dir, service_dict['build']) - elif isinstance(service_dict['build'], dict): - if 'context' in service_dict['build']: - path = service_dict['build']['context'] - service_dict['build']['context'] = resolve_build_path(working_dir, path) - if 'labels' in service_dict['build']: - service_dict['build']['labels'] = parse_labels(service_dict['build']['labels']) + process_build_section(service_dict, working_dir) if 'volumes' in service_dict and service_dict.get('volume_driver') is None: service_dict['volumes'] = resolve_volume_paths(working_dir, service_dict) @@ -721,6 +714,9 @@ def process_service(service_config): if 'sysctls' in service_dict: service_dict['sysctls'] = build_string_dict(parse_sysctls(service_dict['sysctls'])) + if 'labels' in service_dict: + service_dict['labels'] = parse_labels(service_dict['labels']) + service_dict = process_depends_on(service_dict) for field in ['dns', 'dns_search', 'tmpfs']: @@ -734,6 +730,17 @@ def process_service(service_config): return service_dict +def process_build_section(service_dict, working_dir): + if isinstance(service_dict['build'], six.string_types): + service_dict['build'] = resolve_build_path(working_dir, service_dict['build']) + elif isinstance(service_dict['build'], dict): + if 'context' in service_dict['build']: + path = service_dict['build']['context'] + service_dict['build']['context'] = resolve_build_path(working_dir, path) + if 'labels' in service_dict['build']: + service_dict['build']['labels'] = parse_labels(service_dict['build']['labels']) + + def process_ports(service_dict): if 'ports' not in service_dict: return service_dict diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 8f2266ed8d5..8e3d4e2ee31 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -407,6 +407,32 @@ def test_load_config_link_local_ips_network(self): } } + def test_load_config_service_labels(self): + base_file = config.ConfigFile( + 'base.yaml', + { + 'version': '2.1', + 'services': { + 'web': { + 'image': 'example/web', + 'labels': ['label_key=label_val'] + }, + 'db': { + 'image': 'example/db', + 'labels': { + 'label_key': 'label_val' + } + } + }, + } + ) + details = config.ConfigDetails('.', [base_file]) + service_dicts = config.load(details).services + for service in service_dicts: + assert service['labels'] == { + 'label_key': 'label_val' + } + def test_load_config_volume_and_network_labels(self): base_file = config.ConfigFile( 'base.yaml', @@ -435,30 +461,23 @@ def test_load_config_volume_and_network_labels(self): ) details = config.ConfigDetails('.', [base_file]) - network_dict = config.load(details).networks - volume_dict = config.load(details).volumes + loaded_config = config.load(details) - self.assertEqual( - network_dict, - { - 'with_label': { - 'labels': { - 'label_key': 'label_val' - } + assert loaded_config.networks == { + 'with_label': { + 'labels': { + 'label_key': 'label_val' } } - ) + } - self.assertEqual( - volume_dict, - { - 'with_label': { - 'labels': { - 'label_key': 'label_val' - } + assert loaded_config.volumes == { + 'with_label': { + 'labels': { + 'label_key': 'label_val' } } - ) + } def test_load_config_invalid_service_names(self): for invalid_name in ['?not?allowed', ' ', '', '!', '/', '\xe2']: From 6d101fb0686a6e380657aaf974bc3efd1471535e Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 7 Nov 2017 17:10:22 -0800 Subject: [PATCH 3111/4072] Bump 1.17.1 Signed-off-by: Joffrey F --- CHANGELOG.md | 8 ++++++++ compose/__init__.py | 2 +- script/run/run.sh | 2 +- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f531783e8cc..d0be7ea76a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,14 @@ Change log ========== +1.17.1 (2017-11-08) +------------------ + +### Bugfixes + +- Fixed a bug that would prevent creating new containers when using + container labels in the list format as part of the service's definition. + 1.17.0 (2017-11-02) ------------------- diff --git a/compose/__init__.py b/compose/__init__.py index 7b0c7d1e400..20392ec990d 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.17.0' +__version__ = '1.17.1' diff --git a/script/run/run.sh b/script/run/run.sh index 38ce878730c..58483196d62 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.17.0" +VERSION="1.17.1" IMAGE="docker/compose:$VERSION" From b0480b4d04e97b4f067e15cf2eb10f01f9fa898c Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 7 Nov 2017 17:29:23 -0800 Subject: [PATCH 3112/4072] Bump SDK version to latest 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 beeaa28517f..0207b193805 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ cached-property==1.3.0 certifi==2017.4.17 chardet==3.0.4 colorama==0.3.9; sys_platform == 'win32' -docker==2.5.1 +docker==2.6.0 docker-pycreds==0.2.1 dockerpty==0.4.1 docopt==0.6.2 @@ -15,7 +15,7 @@ jsonschema==2.6.0 pypiwin32==219; sys_platform == 'win32' PySocks==1.6.7 PyYAML==3.12 -requests==2.11.1 +requests==2.18.4 six==1.10.0 texttable==0.9.1 urllib3==1.21.1 diff --git a/setup.py b/setup.py index 192a0f6afdf..08d708e9595 100644 --- a/setup.py +++ b/setup.py @@ -33,10 +33,10 @@ def find_version(*file_paths): 'cached-property >= 1.2.0, < 2', 'docopt >= 0.6.1, < 0.7', 'PyYAML >= 3.10, < 4', - 'requests >= 2.6.1, != 2.11.0, < 2.12', + 'requests >= 2.6.1, != 2.11.0, != 2.12.2, != 2.18.0, < 2.19', 'texttable >= 0.9.0, < 0.10', 'websocket-client >= 0.32.0, < 1.0', - 'docker >= 2.5.1, < 3.0', + 'docker >= 2.6.0, < 3.0', 'dockerpty >= 0.4.1, < 0.5', 'six >= 1.3.0, < 2', 'jsonschema >= 2.5.1, < 3', From 3de82c0049922d47f9688c920ee7d285c61afaec Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 8 Nov 2017 16:48:41 -0800 Subject: [PATCH 3113/4072] Include SDK attach bugfix 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 0207b193805..8d86b7d3a23 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ cached-property==1.3.0 certifi==2017.4.17 chardet==3.0.4 colorama==0.3.9; sys_platform == 'win32' -docker==2.6.0 +docker==2.6.1 docker-pycreds==0.2.1 dockerpty==0.4.1 docopt==0.6.2 diff --git a/setup.py b/setup.py index 08d708e9595..bc760c3efe6 100644 --- a/setup.py +++ b/setup.py @@ -36,7 +36,7 @@ def find_version(*file_paths): 'requests >= 2.6.1, != 2.11.0, != 2.12.2, != 2.18.0, < 2.19', 'texttable >= 0.9.0, < 0.10', 'websocket-client >= 0.32.0, < 1.0', - 'docker >= 2.6.0, < 3.0', + 'docker >= 2.6.1, < 3.0', 'dockerpty >= 0.4.1, < 0.5', 'six >= 1.3.0, < 2', 'jsonschema >= 2.5.1, < 3', From 305fae85d1abbe5f04247dbd45ef81163ea20119 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 9 Nov 2017 14:25:14 -0800 Subject: [PATCH 3114/4072] Remove redundant log message Signed-off-by: Joffrey F --- compose/service.py | 1 - 1 file changed, 1 deletion(-) diff --git a/compose/service.py b/compose/service.py index 245d5f7c7b6..366bb374604 100644 --- a/compose/service.py +++ b/compose/service.py @@ -514,7 +514,6 @@ def recreate_container( volumes can be copied to the new container, before the original container is removed. """ - log.info("Recreating %s" % container.name) container.stop(timeout=self.stop_timeout(timeout)) container.rename_to_tmp_name() From c5408f3a4010c0760d25255ce989ed7cb9fe89dc Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 25 Oct 2017 14:51:11 -0700 Subject: [PATCH 3115/4072] Add support for extra_hosts in build config Signed-off-by: Joffrey F --- compose/config/config.py | 1 + compose/config/config_schema_v2.3.json | 3 ++- compose/service.py | 1 + tests/integration/service_test.py | 23 +++++++++++++++++++++++ tests/unit/service_test.py | 4 +++- tox.ini | 1 - 6 files changed, 30 insertions(+), 3 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index adfb53d8f7a..4c3f93ddb25 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -1023,6 +1023,7 @@ def to_dict(service): md.merge_mapping('args', parse_build_arguments) md.merge_field('cache_from', merge_unique_items_lists, default=[]) md.merge_mapping('labels', parse_labels) + md.merge_mapping('extra_hosts', parse_extra_hosts) return dict(md) diff --git a/compose/config/config_schema_v2.3.json b/compose/config/config_schema_v2.3.json index 0e709e9d93b..6f923871bfd 100644 --- a/compose/config/config_schema_v2.3.json +++ b/compose/config/config_schema_v2.3.json @@ -92,7 +92,8 @@ "cache_from": {"$ref": "#/definitions/list_of_strings"}, "network": {"type": "string"}, "target": {"type": "string"}, - "shm_size": {"type": ["integer", "string"]} + "shm_size": {"type": ["integer", "string"]}, + "extra_hosts": {"$ref": "#/definitions/list_or_dict"} }, "additionalProperties": false } diff --git a/compose/service.py b/compose/service.py index 453f982f042..d6a3ff0b35e 100644 --- a/compose/service.py +++ b/compose/service.py @@ -930,6 +930,7 @@ def build(self, no_cache=False, pull=False, force_rm=False, build_args_override= network_mode=build_opts.get('network', None), target=build_opts.get('target', None), shmsize=parse_bytes(build_opts.get('shm_size')) if build_opts.get('shm_size') else None, + extra_hosts=build_opts.get('extra_hosts', None), ) try: diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index deced274240..00bacebf5bc 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -833,6 +833,29 @@ def test_build_with_target(self): assert service.image() assert service.image()['Config']['Labels']['com.docker.compose.test.target'] == 'one' + @v2_3_only() + def test_build_with_extra_hosts(self): + self.require_api_version('1.27') + base_dir = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, base_dir) + + with open(os.path.join(base_dir, 'Dockerfile'), 'w') as f: + f.write('\n'.join([ + 'FROM busybox', + 'RUN ping -c1 foobar', + 'RUN ping -c1 baz', + ])) + + service = self.create_service('build_extra_hosts', build={ + 'context': text_type(base_dir), + 'extra_hosts': { + 'foobar': '127.0.0.1', + 'baz': '127.0.0.1' + } + }) + service.build() + assert service.image() + def test_start_container_stays_unprivileged(self): service = self.create_service('web') container = create_and_start_container(service).inspect() diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 35f80d11a1e..8a109036781 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -498,6 +498,7 @@ def test_create_container(self): network_mode=None, target=None, shmsize=None, + extra_hosts=None, ) def test_ensure_image_exists_no_build(self): @@ -538,7 +539,8 @@ def test_ensure_image_exists_force_build(self): cache_from=None, network_mode=None, target=None, - shmsize=None + shmsize=None, + extra_hosts=None, ) def test_build_does_not_pull(self): diff --git a/tox.ini b/tox.ini index e4f31ec8554..749be3faaea 100644 --- a/tox.ini +++ b/tox.ini @@ -18,7 +18,6 @@ deps = -rrequirements-dev.txt commands = py.test -v \ - --full-trace \ --cov=compose \ --cov-report html \ --cov-report term \ From 34464d5eee87a11a22800da3e39f88f5f36ff24b Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 16 Oct 2017 16:56:46 -0700 Subject: [PATCH 3116/4072] Bump colorama (use unreleased fix) 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 8d86b7d3a23..889f87a5a78 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,13 +2,13 @@ backports.ssl-match-hostname==3.5.0.1; python_version < '3' cached-property==1.3.0 certifi==2017.4.17 chardet==3.0.4 -colorama==0.3.9; sys_platform == 'win32' docker==2.6.1 docker-pycreds==0.2.1 dockerpty==0.4.1 docopt==0.6.2 enum34==1.1.6; python_version < '3.4' functools32==3.2.3.post2; python_version < '3.2' +git+git://github.com/tartley/colorama.git@bd378c725b45eba0b8e5cc091c3ca76a954c92ff; sys_platform == 'win32' idna==2.5 ipaddress==1.0.18 jsonschema==2.6.0 diff --git a/setup.py b/setup.py index bc760c3efe6..d0353404007 100644 --- a/setup.py +++ b/setup.py @@ -55,7 +55,7 @@ def find_version(*file_paths): ':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'], - ':sys_platform == "win32"': ['colorama >= 0.3.7, < 0.4'], + ':sys_platform == "win32"': ['colorama >= 0.3.9, < 0.4'], 'socks': ['PySocks >= 1.5.6, != 1.5.7, < 2'], } From 5691b8241dbf11e893d7d2b295b2dfb4ac7c6b1b Mon Sep 17 00:00:00 2001 From: Drew Romanyk Date: Thu, 9 Nov 2017 17:53:27 -0600 Subject: [PATCH 3117/4072] Implement subnet config validation (fixes #4552) Signed-off-by: Drew Romanyk --- compose/config/config_schema_v3.5.json | 2 +- compose/config/validation.py | 30 +++++++++- tests/unit/config/config_test.py | 82 ++++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 2 deletions(-) diff --git a/compose/config/config_schema_v3.5.json b/compose/config/config_schema_v3.5.json index fa95d6a2457..6ccecbfd411 100644 --- a/compose/config/config_schema_v3.5.json +++ b/compose/config/config_schema_v3.5.json @@ -418,7 +418,7 @@ "items": { "type": "object", "properties": { - "subnet": {"type": "string"} + "subnet": {"type": "string", "format": "subnet_ip_address"} }, "additionalProperties": false } diff --git a/compose/config/validation.py b/compose/config/validation.py index 8247cf1500a..a8061a5a411 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -5,6 +5,7 @@ import logging import os import re +import socket import sys import six @@ -43,6 +44,9 @@ VALID_NAME_CHARS = '[a-zA-Z0-9\._\-]' VALID_EXPOSE_FORMAT = r'^\d+(\-\d+)?(\/[a-zA-Z]+)?$' +VALID_IPV4_FORMAT = r'^(\d{1,3}.){3}\d{1,3}$' +VALID_IPV4_CIDR_FORMAT = r'^(\d|[1-2]\d|3[0-2])$' +VALID_IPV6_CIDR_FORMAT = r'^(\d|[1-9]\d|1[0-1]\d|12[0-8])$' @FormatChecker.cls_checks(format="ports", raises=ValidationError) @@ -64,6 +68,30 @@ def format_expose(instance): return True +@FormatChecker.cls_checks("subnet_ip_address", raises=ValidationError) +def format_subnet_ip_address(instance): + if isinstance(instance, six.string_types): + if '/' not in instance: + raise ValidationError("should be of the format 'IP_ADDRESS/CIDR'") + + ip_address, cidr = instance.split('/') + + if re.match(VALID_IPV4_FORMAT, ip_address): + if not (re.match(VALID_IPV4_CIDR_FORMAT, cidr) and + all(0 <= int(component) <= 255 for component in ip_address.split("."))): + raise ValidationError("should be of the format 'IP_ADDRESS/CIDR'") + elif re.match(VALID_IPV6_CIDR_FORMAT, cidr) and hasattr(socket, "inet_pton"): + try: + if not (socket.inet_pton(socket.AF_INET6, ip_address)): + raise ValidationError("should be of the format 'IP_ADDRESS/CIDR'") + except socket.error as e: + raise ValidationError(six.text_type(e)) + else: + raise ValidationError("should be of the format 'IP_ADDRESS/CIDR'") + + return True + + def match_named_volumes(service_dict, project_volumes): service_volumes = service_dict.get('volumes', []) for volume_spec in service_volumes: @@ -391,7 +419,7 @@ def process_config_schema_errors(error): def validate_against_config_schema(config_file): schema = load_jsonschema(config_file) - format_checker = FormatChecker(["ports", "expose"]) + format_checker = FormatChecker(["ports", "expose", "subnet_ip_address"]) 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 a758154c04b..819d8f5bec4 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -2846,6 +2846,88 @@ def check_config(self, cfg): ) +class SubnetTest(unittest.TestCase): + INVALID_SUBNET_TYPES = [ + None, + False, + 10, + ] + + INVALID_SUBNET_MAPPINGS = [ + "", + "192.168.0.1/sdfsdfs", + "192.168.0.1/", + "192.168.0.1/33", + "192.168.0.1/01", + "192.168.0.1", + "fe80:0000:0000:0000:0204:61ff:fe9d:f156/sdfsdfs", + "fe80:0000:0000:0000:0204:61ff:fe9d:f156/", + "fe80:0000:0000:0000:0204:61ff:fe9d:f156/129", + "fe80:0000:0000:0000:0204:61ff:fe9d:f156/01", + "fe80:0000:0000:0000:0204:61ff:fe9d:f156", + ] + + ILLEGAL_SUBNET_MAPPINGS = [ + "ge80:0000:0000:0000:0204:61ff:fe9d:f156/128" + ] + + VALID_SUBNET_MAPPINGS = [ + "192.168.0.1/0", + "192.168.0.1/32", + "fe80:0000:0000:0000:0204:61ff:fe9d:f156/0", + "fe80:0000:0000:0000:0204:61ff:fe9d:f156/128", + ] + + def test_config_invalid_subnet_type_validation(self): + for invalid_subnet in self.INVALID_SUBNET_TYPES: + with pytest.raises(ConfigurationError) as exc: + self.check_config(invalid_subnet) + + assert "contains an invalid type" in exc.value.msg + + def test_config_invalid_subnet_format_validation(self): + for invalid_subnet in self.INVALID_SUBNET_MAPPINGS: + with pytest.raises(ConfigurationError) as exc: + self.check_config(invalid_subnet) + + assert "should be of the format 'IP_ADDRESS/CIDR'" in exc.value.msg + + def test_config_illegal_subnet_type_validation(self): + for invalid_subnet in self.ILLEGAL_SUBNET_MAPPINGS: + with pytest.raises(ConfigurationError) as exc: + self.check_config(invalid_subnet) + + assert "illegal IP address string" in exc.value.msg + + def test_config_valid_subnet_format_validation(self): + for valid_subnet in self.VALID_SUBNET_MAPPINGS: + self.check_config(valid_subnet) + + def check_config(self, subnet): + config.load( + build_config_details({ + 'version': '3.5', + 'services': { + 'web': { + 'image': 'busybox' + } + }, + 'networks': { + 'default': { + 'ipam': { + 'config': [ + { + 'subnet': subnet + } + ], + 'driver': 'default' + } + } + } + }) + ) + + class InterpolationTest(unittest.TestCase): @mock.patch.dict(os.environ) From 68c636d728be2b9a2a2d0e7a464e2d5be8cfe651 Mon Sep 17 00:00:00 2001 From: Drew Romanyk Date: Thu, 9 Nov 2017 22:57:47 -0600 Subject: [PATCH 3118/4072] Fix subnet config test for windows Signed-off-by: Drew Romanyk --- compose/config/validation.py | 10 ++++++---- tests/unit/config/config_test.py | 7 +++++-- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/compose/config/validation.py b/compose/config/validation.py index a8061a5a411..c2256804b6d 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -72,22 +72,24 @@ def format_expose(instance): def format_subnet_ip_address(instance): if isinstance(instance, six.string_types): if '/' not in instance: - raise ValidationError("should be of the format 'IP_ADDRESS/CIDR'") + raise ValidationError("'{0}' 75 should be of the format 'IP_ADDRESS/CIDR'".format(instance)) ip_address, cidr = instance.split('/') if re.match(VALID_IPV4_FORMAT, ip_address): if not (re.match(VALID_IPV4_CIDR_FORMAT, cidr) and all(0 <= int(component) <= 255 for component in ip_address.split("."))): - raise ValidationError("should be of the format 'IP_ADDRESS/CIDR'") + raise ValidationError( + "'{0}' 83 should be of the format 'IP_ADDRESS/CIDR'".format(instance)) elif re.match(VALID_IPV6_CIDR_FORMAT, cidr) and hasattr(socket, "inet_pton"): try: if not (socket.inet_pton(socket.AF_INET6, ip_address)): - raise ValidationError("should be of the format 'IP_ADDRESS/CIDR'") + raise ValidationError( + "'{0}' 88 should be of the format 'IP_ADDRESS/CIDR'".format(instance)) except socket.error as e: raise ValidationError(six.text_type(e)) else: - raise ValidationError("should be of the format 'IP_ADDRESS/CIDR'") + raise ValidationError("'{0}' 92 should be of the format 'IP_ADDRESS/CIDR'".format(instance)) return True diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 819d8f5bec4..51323cd32d0 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -2896,8 +2896,11 @@ def test_config_illegal_subnet_type_validation(self): for invalid_subnet in self.ILLEGAL_SUBNET_MAPPINGS: with pytest.raises(ConfigurationError) as exc: self.check_config(invalid_subnet) - - assert "illegal IP address string" in exc.value.msg + if IS_WINDOWS_PLATFORM: + assert "An invalid argument was supplied" in exc.value.msg or \ + "illegal IP address string" in exc.value.msg + else: + assert "illegal IP address string" in exc.value.msg def test_config_valid_subnet_format_validation(self): for valid_subnet in self.VALID_SUBNET_MAPPINGS: From 6c8184d0d02cda9e67aaedad2f37ae754729f416 Mon Sep 17 00:00:00 2001 From: Drew Romanyk Date: Fri, 10 Nov 2017 18:04:11 -0600 Subject: [PATCH 3119/4072] Add format to other v3 configs & remove unix dependency Signed-off-by: Drew Romanyk --- compose/config/config_schema_v3.0.json | 2 +- compose/config/config_schema_v3.1.json | 2 +- compose/config/config_schema_v3.2.json | 2 +- compose/config/config_schema_v3.3.json | 2 +- compose/config/config_schema_v3.4.json | 2 +- compose/config/validation.py | 52 +++++++++++++++++--------- tests/unit/config/config_test.py | 30 ++++++++------- 7 files changed, 55 insertions(+), 37 deletions(-) diff --git a/compose/config/config_schema_v3.0.json b/compose/config/config_schema_v3.0.json index f39344cfbe7..fa601bed28b 100644 --- a/compose/config/config_schema_v3.0.json +++ b/compose/config/config_schema_v3.0.json @@ -294,7 +294,7 @@ "items": { "type": "object", "properties": { - "subnet": {"type": "string"} + "subnet": {"type": "string", "format": "subnet_ip_address"} }, "additionalProperties": false } diff --git a/compose/config/config_schema_v3.1.json b/compose/config/config_schema_v3.1.json index 719c0fa7acc..41da89650aa 100644 --- a/compose/config/config_schema_v3.1.json +++ b/compose/config/config_schema_v3.1.json @@ -323,7 +323,7 @@ "items": { "type": "object", "properties": { - "subnet": {"type": "string"} + "subnet": {"type": "string", "format": "subnet_ip_address"} }, "additionalProperties": false } diff --git a/compose/config/config_schema_v3.2.json b/compose/config/config_schema_v3.2.json index 8d850d5d2e9..e4f8fe6d8d9 100644 --- a/compose/config/config_schema_v3.2.json +++ b/compose/config/config_schema_v3.2.json @@ -368,7 +368,7 @@ "items": { "type": "object", "properties": { - "subnet": {"type": "string"} + "subnet": {"type": "string", "format": "subnet_ip_address"} }, "additionalProperties": false } diff --git a/compose/config/config_schema_v3.3.json b/compose/config/config_schema_v3.3.json index f1eb9a66103..96dc1d7d03e 100644 --- a/compose/config/config_schema_v3.3.json +++ b/compose/config/config_schema_v3.3.json @@ -412,7 +412,7 @@ "items": { "type": "object", "properties": { - "subnet": {"type": "string"} + "subnet": {"type": "string", "format": "subnet_ip_address"} }, "additionalProperties": false } diff --git a/compose/config/config_schema_v3.4.json b/compose/config/config_schema_v3.4.json index dae7d7d2345..8089c7e6d78 100644 --- a/compose/config/config_schema_v3.4.json +++ b/compose/config/config_schema_v3.4.json @@ -420,7 +420,7 @@ "items": { "type": "object", "properties": { - "subnet": {"type": "string"} + "subnet": {"type": "string", "format": "subnet_ip_address"} }, "additionalProperties": false } diff --git a/compose/config/validation.py b/compose/config/validation.py index c2256804b6d..f97069935d3 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -5,7 +5,6 @@ import logging import os import re -import socket import sys import six @@ -44,9 +43,32 @@ VALID_NAME_CHARS = '[a-zA-Z0-9\._\-]' VALID_EXPOSE_FORMAT = r'^\d+(\-\d+)?(\/[a-zA-Z]+)?$' -VALID_IPV4_FORMAT = r'^(\d{1,3}.){3}\d{1,3}$' -VALID_IPV4_CIDR_FORMAT = r'^(\d|[1-2]\d|3[0-2])$' -VALID_IPV6_CIDR_FORMAT = r'^(\d|[1-9]\d|1[0-1]\d|12[0-8])$' + +VALID_IPV4_SEG = r'(\d{1,2}|1\d{2}|2[0-4]\d|25[0-5])' +VALID_REGEX_IPV4_CIDR = r'^(\d|[1-2]\d|3[0-2])$' +VALID_IPV4_ADDR = "({IPV4_SEG}\.){{3}}{IPV4_SEG}".format(IPV4_SEG=VALID_IPV4_SEG) +VALID_REGEX_IPV4_ADDR = "^{IPV4_ADDR}$".format(IPV4_ADDR=VALID_IPV4_ADDR) + +VALID_IPV6_SEG = r'[0-9a-fA-F]{1,4}' +VALID_REGEX_IPV6_CIDR = r'^(\d|[1-9]\d|1[0-1]\d|12[0-8])$' +VALID_REGEX_IPV6_ADDR = "".join(""" +^ +( + (({IPV6_SEG}:){{7}}{IPV6_SEG})| + (({IPV6_SEG}:){{1,7}}:)| + (({IPV6_SEG}:){{1,6}}(:{IPV6_SEG}){{1,1}})| + (({IPV6_SEG}:){{1,5}}(:{IPV6_SEG}){{1,2}})| + (({IPV6_SEG}:){{1,4}}(:{IPV6_SEG}){{1,3}})| + (({IPV6_SEG}:){{1,3}}(:{IPV6_SEG}){{1,4}})| + (({IPV6_SEG}:){{1,2}}(:{IPV6_SEG}){{1,5}})| + (({IPV6_SEG}:){{1,1}}(:{IPV6_SEG}){{1,6}})| + (:((:{IPV6_SEG}){{1,7}}|:))| + (fe80:(:{IPV6_SEG}){{0,4}}%[0-9a-zA-Z]{{1,}})| + (::(ffff(:0{{1,4}}){{0,1}}:){{0,1}}{IPV4_ADDR})| + (({IPV6_SEG}:){{1,4}}:{IPV4_ADDR}) +) +$ +""".format(IPV6_SEG=VALID_IPV6_SEG, IPV4_ADDR=VALID_IPV4_ADDR).split()) @FormatChecker.cls_checks(format="ports", raises=ValidationError) @@ -72,24 +94,18 @@ def format_expose(instance): def format_subnet_ip_address(instance): if isinstance(instance, six.string_types): if '/' not in instance: - raise ValidationError("'{0}' 75 should be of the format 'IP_ADDRESS/CIDR'".format(instance)) + raise ValidationError("should be of the format 'IP_ADDRESS/CIDR'") ip_address, cidr = instance.split('/') - if re.match(VALID_IPV4_FORMAT, ip_address): - if not (re.match(VALID_IPV4_CIDR_FORMAT, cidr) and - all(0 <= int(component) <= 255 for component in ip_address.split("."))): - raise ValidationError( - "'{0}' 83 should be of the format 'IP_ADDRESS/CIDR'".format(instance)) - elif re.match(VALID_IPV6_CIDR_FORMAT, cidr) and hasattr(socket, "inet_pton"): - try: - if not (socket.inet_pton(socket.AF_INET6, ip_address)): - raise ValidationError( - "'{0}' 88 should be of the format 'IP_ADDRESS/CIDR'".format(instance)) - except socket.error as e: - raise ValidationError(six.text_type(e)) + if re.match(VALID_REGEX_IPV4_ADDR, ip_address): + if not re.match(VALID_REGEX_IPV4_CIDR, cidr): + raise ValidationError("should be of the format 'IP_ADDRESS/CIDR'") + elif re.match(VALID_REGEX_IPV6_ADDR, ip_address): + if not re.match(VALID_REGEX_IPV6_CIDR, cidr): + raise ValidationError("should be of the format 'IP_ADDRESS/CIDR'") else: - raise ValidationError("'{0}' 92 should be of the format 'IP_ADDRESS/CIDR'".format(instance)) + raise ValidationError("should be of the format 'IP_ADDRESS/CIDR'") return True diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 51323cd32d0..1cf783c777c 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -2865,10 +2865,7 @@ class SubnetTest(unittest.TestCase): "fe80:0000:0000:0000:0204:61ff:fe9d:f156/129", "fe80:0000:0000:0000:0204:61ff:fe9d:f156/01", "fe80:0000:0000:0000:0204:61ff:fe9d:f156", - ] - - ILLEGAL_SUBNET_MAPPINGS = [ - "ge80:0000:0000:0000:0204:61ff:fe9d:f156/128" + "ge80:0000:0000:0000:0204:61ff:fe9d:f156/128", ] VALID_SUBNET_MAPPINGS = [ @@ -2876,6 +2873,21 @@ class SubnetTest(unittest.TestCase): "192.168.0.1/32", "fe80:0000:0000:0000:0204:61ff:fe9d:f156/0", "fe80:0000:0000:0000:0204:61ff:fe9d:f156/128", + "1:2:3:4:5:6:7:8/0", + "1::/0", + "1:2:3:4:5:6:7::/0", + "1::8/0", + "1:2:3:4:5:6::8/0", + "::/0", + "::8/0", + "::2:3:4:5:6:7:8/0", + "fe80::7:8%eth0/0", + "fe80::7:8%1/0", + "::255.255.255.255/0", + "::ffff:255.255.255.255/0", + "::ffff:0:255.255.255.255/0", + "2001:db8:3:4::192.0.2.33/0", + "64:ff9b::192.0.2.33/0", ] def test_config_invalid_subnet_type_validation(self): @@ -2892,16 +2904,6 @@ def test_config_invalid_subnet_format_validation(self): assert "should be of the format 'IP_ADDRESS/CIDR'" in exc.value.msg - def test_config_illegal_subnet_type_validation(self): - for invalid_subnet in self.ILLEGAL_SUBNET_MAPPINGS: - with pytest.raises(ConfigurationError) as exc: - self.check_config(invalid_subnet) - if IS_WINDOWS_PLATFORM: - assert "An invalid argument was supplied" in exc.value.msg or \ - "illegal IP address string" in exc.value.msg - else: - assert "illegal IP address string" in exc.value.msg - def test_config_valid_subnet_format_validation(self): for valid_subnet in self.VALID_SUBNET_MAPPINGS: self.check_config(valid_subnet) From badd4d764a1ada326604f7bea3d806cb4eb3558e Mon Sep 17 00:00:00 2001 From: Drew Romanyk Date: Mon, 13 Nov 2017 21:53:14 -0600 Subject: [PATCH 3120/4072] Refactor subnet cidr validator & add new test Signed-off-by: Drew Romanyk --- compose/config/validation.py | 23 ++++++----------------- tests/unit/config/config_test.py | 3 ++- 2 files changed, 8 insertions(+), 18 deletions(-) diff --git a/compose/config/validation.py b/compose/config/validation.py index f97069935d3..0fdcb37e7dc 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -45,13 +45,11 @@ VALID_EXPOSE_FORMAT = r'^\d+(\-\d+)?(\/[a-zA-Z]+)?$' VALID_IPV4_SEG = r'(\d{1,2}|1\d{2}|2[0-4]\d|25[0-5])' -VALID_REGEX_IPV4_CIDR = r'^(\d|[1-2]\d|3[0-2])$' VALID_IPV4_ADDR = "({IPV4_SEG}\.){{3}}{IPV4_SEG}".format(IPV4_SEG=VALID_IPV4_SEG) -VALID_REGEX_IPV4_ADDR = "^{IPV4_ADDR}$".format(IPV4_ADDR=VALID_IPV4_ADDR) +VALID_REGEX_IPV4_CIDR = "^{IPV4_ADDR}/(\d|[1-2]\d|3[0-2])$".format(IPV4_ADDR=VALID_IPV4_ADDR) VALID_IPV6_SEG = r'[0-9a-fA-F]{1,4}' -VALID_REGEX_IPV6_CIDR = r'^(\d|[1-9]\d|1[0-1]\d|12[0-8])$' -VALID_REGEX_IPV6_ADDR = "".join(""" +VALID_REGEX_IPV6_CIDR = "".join(""" ^ ( (({IPV6_SEG}:){{7}}{IPV6_SEG})| @@ -67,6 +65,7 @@ (::(ffff(:0{{1,4}}){{0,1}}:){{0,1}}{IPV4_ADDR})| (({IPV6_SEG}:){{1,4}}:{IPV4_ADDR}) ) +/(\d|[1-9]\d|1[0-1]\d|12[0-8]) $ """.format(IPV6_SEG=VALID_IPV6_SEG, IPV4_ADDR=VALID_IPV4_ADDR).split()) @@ -93,19 +92,9 @@ def format_expose(instance): @FormatChecker.cls_checks("subnet_ip_address", raises=ValidationError) def format_subnet_ip_address(instance): if isinstance(instance, six.string_types): - if '/' not in instance: - raise ValidationError("should be of the format 'IP_ADDRESS/CIDR'") - - ip_address, cidr = instance.split('/') - - if re.match(VALID_REGEX_IPV4_ADDR, ip_address): - if not re.match(VALID_REGEX_IPV4_CIDR, cidr): - raise ValidationError("should be of the format 'IP_ADDRESS/CIDR'") - elif re.match(VALID_REGEX_IPV6_ADDR, ip_address): - if not re.match(VALID_REGEX_IPV6_CIDR, cidr): - raise ValidationError("should be of the format 'IP_ADDRESS/CIDR'") - else: - raise ValidationError("should be of the format 'IP_ADDRESS/CIDR'") + if not re.match(VALID_REGEX_IPV4_CIDR, instance) and \ + not re.match(VALID_REGEX_IPV6_CIDR, instance): + raise ValidationError("should use the CIDR format") return True diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 1cf783c777c..32ccf1cecd8 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -2866,6 +2866,7 @@ class SubnetTest(unittest.TestCase): "fe80:0000:0000:0000:0204:61ff:fe9d:f156/01", "fe80:0000:0000:0000:0204:61ff:fe9d:f156", "ge80:0000:0000:0000:0204:61ff:fe9d:f156/128", + "192.168.0.1/31/31", ] VALID_SUBNET_MAPPINGS = [ @@ -2902,7 +2903,7 @@ def test_config_invalid_subnet_format_validation(self): with pytest.raises(ConfigurationError) as exc: self.check_config(invalid_subnet) - assert "should be of the format 'IP_ADDRESS/CIDR'" in exc.value.msg + assert "should use the CIDR format" in exc.value.msg def test_config_valid_subnet_format_validation(self): for valid_subnet in self.VALID_SUBNET_MAPPINGS: From 21e312e402e44ff1b2431b8fdef3c3ae0212c6a6 Mon Sep 17 00:00:00 2001 From: Svyatoslav Ilinskiy Date: Fri, 17 Nov 2017 17:43:12 -0600 Subject: [PATCH 3121/4072] Implement --filter flag for docker-compose config --services. Fix #1498 Signed-off-by: Svyatoslav Ilinskiy --- compose/cli/main.py | 61 ++++++++++++++++++- tests/acceptance/cli_test.py | 26 ++++++++ .../config-services-filter/docker-compose.yml | 6 ++ 3 files changed, 91 insertions(+), 2 deletions(-) create mode 100644 tests/fixtures/config-services-filter/docker-compose.yml diff --git a/compose/cli/main.py b/compose/cli/main.py index c3e30919d4b..3e412142004 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -287,7 +287,7 @@ def config(self, config_options, options): """ Validate and view the Compose file. - Usage: config [options] + Usage: config [options] [-f KEY=VAL...] Options: --resolve-image-digests Pin image tags to digests. @@ -295,6 +295,7 @@ def config(self, config_options, options): anything. --services Print the service names, one per line. --volumes Print the volume names, one per line. + -f, --filter KEY=VAL Filter containers by a property (can be used multiple times) """ @@ -309,7 +310,15 @@ def config(self, config_options, options): return if options['--services']: - print('\n'.join(service['name'] for service in compose_config.services)) + filters = build_filters(options.get('--filter')) + if filters: + if not self.project: + self.project = project_from_options('.', config_options) + services = filter_services(filters, self.project.services, self.project) + else: + services = [service['name'] for service in compose_config.services] + + print('\n'.join(services)) return if options['--volumes']: @@ -1312,3 +1321,51 @@ def build_exec_command(options, container_id, command): args += [container_id] args += command return args + + +def has_container_with_state(containers, state): + for container in containers: + states = { + 'running': container.is_running, + 'stopped': not container.is_running, + 'paused': container.is_paused, + } + if state not in states: + raise UserError("Invalid state: %s" % state) + if states[state]: + return True + return False + + +def filter_services(filters, services, project): + def should_include(service): + for f in filters: + if f == 'status': + containers = project.containers([service.name], stopped=True) + for status in filters[f]: + if not has_container_with_state(containers, status): + return False + elif f == 'option': + for option in filters[f]: + if option == 'image' or option == 'build': + if option not in service.options: + return False + else: + raise UserError("Invalid option: %s" % option) + else: + raise UserError("Invalid filter: %s" % f) + return True + + return [s.name for s in services if should_include(s)] + + +def build_filters(args): + filters = {} + for arg in args: + if '=' not in arg: + raise UserError("Arguments to --filter should be in form KEY=VAL") + key, val = arg.split('=', 1) + if key not in filters: + filters[key] = [] + filters[key].append(val) + return filters diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 8468dfbde51..a4c19afc5ab 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -440,6 +440,32 @@ def test_config_v3(self): }, } + def test_config_services_filter_option(self): + self.base_dir = 'tests/fixtures/config-services-filter' + image = self.dispatch(['config', '--services', '--filter', 'option=image']) + build = self.dispatch(['config', '--services', '--filter', 'option=build']) + + self.assertIn('with_build', build.stdout) + self.assertNotIn('with_build', image.stdout) + self.assertIn('with_image', image.stdout) + self.assertNotIn('with_image', build.stdout) + + def test_config_services_filter_status(self): + self.base_dir = 'tests/fixtures/config-services-filter' + self.dispatch(['up', '-d']) + self.dispatch(['pause', 'with_image']) + paused = self.dispatch(['config', '--services', '--filter', 'status=paused']) + stopped = self.dispatch(['config', '--services', '--filter', 'status=stopped']) + running = self.dispatch(['config', '--services', '--filter', 'status=running', + '--filter', 'option=build']) + + self.assertNotIn('with_build', stopped.stdout) + self.assertNotIn('with_image', stopped.stdout) + self.assertNotIn('with_build', paused.stdout) + self.assertIn('with_image', paused.stdout) + self.assertIn('with_build', running.stdout) + self.assertNotIn('with_image', running.stdout) + def test_ps(self): self.project.get_service('simple').create_container() result = self.dispatch(['ps']) diff --git a/tests/fixtures/config-services-filter/docker-compose.yml b/tests/fixtures/config-services-filter/docker-compose.yml new file mode 100644 index 00000000000..3d86093731a --- /dev/null +++ b/tests/fixtures/config-services-filter/docker-compose.yml @@ -0,0 +1,6 @@ +with_image: + image: busybox:latest + command: top +with_build: + build: ../build-ctx/ + command: top From 1414c1f1fa92db256c83083cb2107740957dff05 Mon Sep 17 00:00:00 2001 From: Svyatoslav Ilinskiy Date: Fri, 17 Nov 2017 17:43:51 -0600 Subject: [PATCH 3122/4072] Use docker-compose config --services in bash completion Signed-off-by: Svyatoslav Ilinskiy --- contrib/completion/bash/docker-compose | 32 +++++++------------------- 1 file changed, 8 insertions(+), 24 deletions(-) diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index 1fdb2770540..5885e686bd3 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -64,48 +64,32 @@ __docker_compose_services_all() { COMPREPLY=( $(compgen -W "$(___docker_compose_all_services_in_compose_file)" -- "$cur") ) } -# All services that have an entry with the given key in their compose_file section -___docker_compose_services_with_key() { - # 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 __docker_compose_services_from_build() { - COMPREPLY=( $(compgen -W "$(___docker_compose_services_with_key build)" -- "$cur") ) + COMPREPLY=( $(compgen -W "$(__docker_compose_q config --services --filter "option=build")" -- "$cur") ) } # All services that are defined by an image __docker_compose_services_from_image() { - COMPREPLY=( $(compgen -W "$(___docker_compose_services_with_key image)" -- "$cur") ) -} - -# The services for which containers have been created, optionally filtered -# by a boolean expression passed in as argument. -__docker_compose_services_with() { - local containers names - 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") ) + COMPREPLY=( $(compgen -W "$(__docker_compose_q config --services --filter "option=image")" -- "$cur") ) } # The services for which at least one paused container exists __docker_compose_services_paused() { - __docker_compose_services_with '.State.Paused' + names=$(__docker_compose_q config --services --filter "status=paused") + COMPREPLY=( $(compgen -W "$names" -- "$cur") ) } # The services for which at least one running container exists __docker_compose_services_running() { - __docker_compose_services_with '.State.Running' + names=$(__docker_compose_q config --services --filter "status=running") + COMPREPLY=( $(compgen -W "$names" -- "$cur") ) } # The services for which at least one stopped container exists __docker_compose_services_stopped() { - __docker_compose_services_with 'not .State.Running' + names=$(__docker_compose_q config --services --filter "status=stopped") + COMPREPLY=( $(compgen -W "$names" -- "$cur") ) } From 5924c6366e7351299c3541279508a32fae3bbdfe Mon Sep 17 00:00:00 2001 From: Svyatoslav Ilinskiy Date: Sat, 18 Nov 2017 17:46:55 -0600 Subject: [PATCH 3123/4072] Fix minor issue in docker-compose config documentation. Signed-off-by: Svyatoslav Ilinskiy --- 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 3e412142004..5cec853db62 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -295,7 +295,7 @@ def config(self, config_options, options): anything. --services Print the service names, one per line. --volumes Print the volume names, one per line. - -f, --filter KEY=VAL Filter containers by a property (can be used multiple times) + -f, --filter KEY=VAL Filter services by a property (can be used multiple times) """ From 3b81e49c66cd20668478d54f291616b7db62f2b7 Mon Sep 17 00:00:00 2001 From: Madeline Stager Date: Wed, 22 Nov 2017 16:21:47 -0600 Subject: [PATCH 3124/4072] implement --timeout flag for docker-compose down Fix #3370 Signed-off-by: Madeline Stager --- compose/cli/main.py | 5 ++++- compose/project.py | 4 ++-- tests/acceptance/cli_test.py | 21 +++++++++++++++++++++ 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index c3e30919d4b..f866d5809fa 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -371,9 +371,12 @@ def down(self, options): attached to containers. --remove-orphans Remove containers for services not defined in the Compose file + -t, --timeout TIMEOUT Specify a shutdown timeout in seconds. + (default: 10) """ image_type = image_type_from_opt('--rmi', options['--rmi']) - self.project.down(image_type, options['--volumes'], options['--remove-orphans']) + timeout = timeout_from_opts(options) + self.project.down(image_type, options['--volumes'], options['--remove-orphans'], timeout=timeout) def events(self, options): """ diff --git a/compose/project.py b/compose/project.py index f6bd30a8869..9cc726e42ec 100644 --- a/compose/project.py +++ b/compose/project.py @@ -330,8 +330,8 @@ def remove_stopped(self, service_names=None, one_off=OneOffFilter.exclude, **opt service_names, stopped=True, one_off=one_off ), options) - def down(self, remove_image_type, include_volumes, remove_orphans=False): - self.stop(one_off=OneOffFilter.include) + def down(self, remove_image_type, include_volumes, remove_orphans=False, timeout=None): + self.stop(one_off=OneOffFilter.include, timeout=timeout) 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 8468dfbde51..91fdb9c2f5d 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -776,6 +776,27 @@ def test_down(self): assert 'Removing network v2full_default' in result.stderr assert 'Removing network v2full_front' in result.stderr + def test_down_timeout(self): + self.dispatch(['up', '-d'], None) + service = self.project.get_service('simple') + self.assertEqual(len(service.containers()), 1) + self.assertTrue(service.containers()[0].is_running) + "" + + self.dispatch(['down', '-t', '1'], None) + + self.assertEqual(len(service.containers(stopped=True)), 0) + + def test_down_signal(self): + self.base_dir = 'tests/fixtures/stop-signal-composefile' + self.dispatch(['up', '-d'], None) + service = self.project.get_service('simple') + self.assertEqual(len(service.containers()), 1) + self.assertTrue(service.containers()[0].is_running) + + self.dispatch(['down', '-t', '1'], None) + self.assertEqual(len(service.containers(stopped=True)), 0) + def test_up_detached(self): self.dispatch(['up', '-d']) service = self.project.get_service('simple') From 0840a7f0443f0679fa2fb2621bc06889ad532ab9 Mon Sep 17 00:00:00 2001 From: Madeline Stager Date: Wed, 22 Nov 2017 17:32:51 -0600 Subject: [PATCH 3125/4072] Fixed example in instructions for running tests. Fix #5394 Signed-off-by: Madeline Stager --- CONTRIBUTING.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 16bccf98b72..a031e2d6838 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -64,8 +64,8 @@ you can specify a test directory, file, module, class or method: $ 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 + $ script/test/default tests/unit/config/config_test.py::ConfigTest + $ script/test/default tests/unit/config/config_test.py::ConfigTest::test_load ## Finding things to work on From c36a2fb1ad9bc026da01bab9d87132f32687e06b Mon Sep 17 00:00:00 2001 From: Samantha Miller Date: Sun, 12 Nov 2017 11:33:34 -0600 Subject: [PATCH 3126/4072] Added a label option to 'docker-compose run' and test. Signed-off-by: Samantha Miller --- compose/cli/main.py | 9 ++++++++- compose/config/__init__.py | 2 ++ compose/config/config.py | 6 ++++++ compose/service.py | 5 +++++ contrib/completion/bash/docker-compose | 4 ++-- tests/acceptance/cli_test.py | 11 +++++++++++ tests/fixtures/run-labels/docker-compose.yml | 7 +++++++ tests/unit/cli_test.py | 4 ++++ 8 files changed, 45 insertions(+), 3 deletions(-) create mode 100644 tests/fixtures/run-labels/docker-compose.yml diff --git a/compose/cli/main.py b/compose/cli/main.py index c3e30919d4b..32dbf69b2f5 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -24,6 +24,7 @@ from ..bundle import serialize_bundle from ..config import ConfigurationError from ..config import parse_environment +from ..config import parse_labels from ..config import resolve_build_args from ..config.environment import Environment from ..config.serialize import serialize_config @@ -720,7 +721,9 @@ def run(self, options): running. If you do not want to start linked services, use `docker-compose run --no-deps SERVICE COMMAND [ARGS...]`. - Usage: run [options] [-v VOLUME...] [-p PORT...] [-e KEY=VAL...] SERVICE [COMMAND] [ARGS...] + Usage: + run [options] [-v VOLUME...] [-p PORT...] [-e KEY=VAL...] [-l KEY=VALUE...] + SERVICE [COMMAND] [ARGS...] Options: -d Detached mode: Run container in the background, print @@ -728,6 +731,7 @@ def run(self, options): --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) + -l, --label KEY=VAL Add or override a label (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. @@ -1122,6 +1126,9 @@ def build_container_options(options, detach, command): parse_environment(options['-e']) ) + if options['--label']: + container_options['labels'] = parse_labels(options['--label']) + if options['--entrypoint']: container_options['entrypoint'] = options.get('--entrypoint') diff --git a/compose/config/__init__.py b/compose/config/__init__.py index b629edf66f3..e1032f3dea0 100644 --- a/compose/config/__init__.py +++ b/compose/config/__init__.py @@ -8,5 +8,7 @@ from .config import find from .config import load from .config import merge_environment +from .config import merge_labels from .config import parse_environment +from .config import parse_labels from .config import resolve_build_args diff --git a/compose/config/config.py b/compose/config/config.py index adfb53d8f7a..c2d1089122e 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -1075,6 +1075,12 @@ def merge_environment(base, override): return env +def merge_labels(base, override): + labels = parse_labels(base) + labels.update(parse_labels(override)) + return labels + + def split_kv(kvpair): if '=' in kvpair: return kvpair.split('=', 1) diff --git a/compose/service.py b/compose/service.py index 453f982f042..2e9587b53b7 100644 --- a/compose/service.py +++ b/compose/service.py @@ -25,6 +25,7 @@ from . import progress_stream from .config import DOCKER_CONFIG_KEYS from .config import merge_environment +from .config import merge_labels from .config.errors import DependencyError from .config.types import ServicePort from .config.types import VolumeSpec @@ -778,6 +779,10 @@ def _get_container_create_options( self.options.get('environment'), override_options.get('environment')) + container_options['labels'] = merge_labels( + self.options.get('labels'), + override_options.get('labels')) + binds, affinity = merge_volume_bindings( container_options.get('volumes') or [], self.options.get('tmpfs') or [], diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index 1fdb2770540..af0368177e2 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -403,14 +403,14 @@ _docker_compose_run() { __docker_compose_nospace return ;; - --entrypoint|--name|--user|-u|--volume|-v|--workdir|-w) + --entrypoint|--label|-l|--name|--user|-u|--volume|-v|--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 --volume -v --workdir -w" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "-d --entrypoint -e --help --label -l --name --no-deps --publish -p --rm --service-ports -T --user -u --volume -v --workdir -w" -- "$cur" ) ) ;; *) __docker_compose_services_all diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 8468dfbde51..5987137f2d2 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1830,6 +1830,17 @@ def test_run_env_values_from_system(self): assert 'FOO=bar' in environment assert 'BAR=baz' not in environment + def test_run_label_flag(self): + self.base_dir = 'tests/fixtures/run-labels' + name = 'service' + self.dispatch(['run', '-l', 'default', '--label', 'foo=baz', name, '/bin/true']) + service = self.project.get_service(name) + container, = service.containers(stopped=True, one_off=OneOffFilter.only) + labels = container.labels + assert labels['default'] == '' + assert labels['foo'] == 'baz' + assert labels['hello'] == 'world' + def test_rm(self): service = self.project.get_service('simple') service.create_container() diff --git a/tests/fixtures/run-labels/docker-compose.yml b/tests/fixtures/run-labels/docker-compose.yml new file mode 100644 index 00000000000..e8cd5006556 --- /dev/null +++ b/tests/fixtures/run-labels/docker-compose.yml @@ -0,0 +1,7 @@ +service: + image: busybox:latest + command: top + + labels: + foo: bar + hello: world diff --git a/tests/unit/cli_test.py b/tests/unit/cli_test.py index 1a324f50a4b..c6aa75b26e0 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -114,6 +114,7 @@ def test_run_interactive_passes_logs_false(self, mock_pseudo_terminal, mock_run_ 'SERVICE': 'service', 'COMMAND': None, '-e': [], + '--label': [], '--user': None, '--no-deps': None, '-d': False, @@ -150,6 +151,7 @@ def test_run_service_with_restart_always(self): 'SERVICE': 'service', 'COMMAND': None, '-e': [], + '--label': [], '--user': None, '--no-deps': None, '-d': True, @@ -173,6 +175,7 @@ def test_run_service_with_restart_always(self): 'SERVICE': 'service', 'COMMAND': None, '-e': [], + '--label': [], '--user': None, '--no-deps': None, '-d': True, @@ -205,6 +208,7 @@ def test_command_manual_and_service_ports_together(self): 'SERVICE': 'service', 'COMMAND': None, '-e': [], + '--label': [], '--user': None, '--no-deps': None, '-d': True, From 415fa6c59bdab6a79e7a01429bcb3898cdfab92b Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 29 Nov 2017 12:21:56 -0800 Subject: [PATCH 3127/4072] Use mounts for secrets instead of volumes Signed-off-by: Joffrey F --- compose/config/types.py | 42 ++++++++++++++++++++++++++++++++++++++ compose/service.py | 27 ++++++++++++++++++++---- tests/unit/service_test.py | 12 +++++------ 3 files changed, 71 insertions(+), 10 deletions(-) diff --git a/compose/config/types.py b/compose/config/types.py index c410343b886..548f2c1cda1 100644 --- a/compose/config/types.py +++ b/compose/config/types.py @@ -133,6 +133,48 @@ def normalize_path_for_engine(path): return path.replace('\\', '/') +class MountSpec(object): + options_map = { + 'volume': { + 'nocopy': 'no_copy' + }, + 'bind': { + 'propagation': 'propagation' + } + } + _fields = ['type', 'source', 'target', 'read_only', 'consistency'] + + def __init__(self, type, source=None, target=None, read_only=None, consistency=None, **kwargs): + self.type = type + self.source = source + self.target = target + self.read_only = read_only + self.consistency = consistency + self.options = None + if self.type in kwargs: + self.options = kwargs[self.type] + + def as_volume_spec(self): + mode = 'ro' if self.read_only else 'rw' + return VolumeSpec(external=self.source, internal=self.target, mode=mode) + + def legacy_repr(self): + return self.as_volume_spec().repr() + + def repr(self): + res = {} + for field in self._fields: + if getattr(self, field, None): + res[field] = getattr(self, field) + if self.options: + res[self.type] = self.options + return res + + @property + def is_named_volume(self): + return self.type == 'volume' and self.source + + class VolumeSpec(namedtuple('_VolumeSpec', 'external internal mode')): @classmethod diff --git a/compose/service.py b/compose/service.py index b696fd66466..07db3ac5f43 100644 --- a/compose/service.py +++ b/compose/service.py @@ -14,6 +14,7 @@ from docker.errors import ImageNotFound from docker.errors import NotFound from docker.types import LogConfig +from docker.types import Mount from docker.utils import version_gte from docker.utils import version_lt from docker.utils.ports import build_port_bindings @@ -27,6 +28,7 @@ from .config import merge_environment from .config import merge_labels from .config.errors import DependencyError +from .config.types import MountSpec from .config.types import ServicePort from .config.types import VolumeSpec from .const import DEFAULT_TIMEOUT @@ -795,9 +797,13 @@ def _get_container_create_options( 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) + if version_lt(self.client.api_version, '1.30'): + override_options['binds'].extend(v.legacy_repr() for v in secret_volumes) + container_options['volumes'].update( + (v.target, {}) for v in secret_volumes + ) + else: + override_options['mounts'] = [build_mount(v) for v in secret_volumes] container_options['image'] = self.image_name @@ -891,6 +897,7 @@ def _get_container_host_config(self, override_options, one_off=False): device_read_iops=blkio_config.get('device_read_iops'), device_write_bps=blkio_config.get('device_write_bps'), device_write_iops=blkio_config.get('device_write_iops'), + mounts=options.get('mounts'), ) def get_secret_volumes(self): @@ -901,7 +908,7 @@ def build_spec(secret): elif not os.path.isabs(target): target = '{}/{}'.format(const.SECRETS_PATH, target) - return VolumeSpec(secret['file'], target, 'ro') + return MountSpec('bind', secret['file'], target, read_only=True) return [build_spec(secret) for secret in self.secrets] @@ -1346,6 +1353,18 @@ def build_volume_from(volume_from_spec): return "{}:{}".format(volume_from_spec.source.id, volume_from_spec.mode) +def build_mount(mount_spec): + kwargs = {} + if mount_spec.options: + for option, sdk_name in mount_spec.options_map[mount_spec.type].items(): + if option in mount_spec.options: + kwargs[sdk_name] = mount_spec.options[option] + + return Mount( + type=mount_spec.type, target=mount_spec.target, source=mount_spec.source, + read_only=mount_spec.read_only, consistency=mount_spec.consistency, **kwargs + ) + # Labels diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 8e8f602033c..87c86a7317e 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -1133,8 +1133,8 @@ def test_get_secret_volumes(self): ) volumes = service.get_secret_volumes() - assert volumes[0].external == secret1['file'] - assert volumes[0].internal == '{}/{}'.format(SECRETS_PATH, secret1['secret'].target) + assert volumes[0].source == secret1['file'] + assert volumes[0].target == '{}/{}'.format(SECRETS_PATH, secret1['secret'].target) def test_get_secret_volumes_abspath(self): secret1 = { @@ -1149,8 +1149,8 @@ def test_get_secret_volumes_abspath(self): ) volumes = service.get_secret_volumes() - assert volumes[0].external == secret1['file'] - assert volumes[0].internal == secret1['secret'].target + assert volumes[0].source == secret1['file'] + assert volumes[0].target == secret1['secret'].target def test_get_secret_volumes_no_target(self): secret1 = { @@ -1165,5 +1165,5 @@ def test_get_secret_volumes_no_target(self): ) volumes = service.get_secret_volumes() - assert volumes[0].external == secret1['file'] - assert volumes[0].internal == '{}/{}'.format(SECRETS_PATH, secret1['secret'].source) + assert volumes[0].source == secret1['file'] + assert volumes[0].target == '{}/{}'.format(SECRETS_PATH, secret1['secret'].source) From bee6046043d09540da7e33f8575413cb4836fb7c Mon Sep 17 00:00:00 2001 From: Drew Romanyk Date: Thu, 30 Nov 2017 10:21:27 -0600 Subject: [PATCH 3128/4072] Add config validation for service volumes, fixes #5352 Signed-off-by: Drew Romanyk --- compose/config/config_schema_v3.2.json | 1 + compose/config/config_schema_v3.3.json | 1 + compose/config/config_schema_v3.4.json | 1 + compose/config/config_schema_v3.5.json | 1 + tests/unit/config/config_test.py | 27 ++++++++++++++++++++++++++ 5 files changed, 31 insertions(+) diff --git a/compose/config/config_schema_v3.2.json b/compose/config/config_schema_v3.2.json index 8d850d5d2e9..fbca3bcbde0 100644 --- a/compose/config/config_schema_v3.2.json +++ b/compose/config/config_schema_v3.2.json @@ -244,6 +244,7 @@ { "type": "object", "required": ["type"], + "additionalProperties": false, "properties": { "type": {"type": "string"}, "source": {"type": "string"}, diff --git a/compose/config/config_schema_v3.3.json b/compose/config/config_schema_v3.3.json index f1eb9a66103..e9cdadab454 100644 --- a/compose/config/config_schema_v3.3.json +++ b/compose/config/config_schema_v3.3.json @@ -278,6 +278,7 @@ { "type": "object", "required": ["type"], + "additionalProperties": false, "properties": { "type": {"type": "string"}, "source": {"type": "string"}, diff --git a/compose/config/config_schema_v3.4.json b/compose/config/config_schema_v3.4.json index dae7d7d2345..dbce9186862 100644 --- a/compose/config/config_schema_v3.4.json +++ b/compose/config/config_schema_v3.4.json @@ -282,6 +282,7 @@ { "type": "object", "required": ["type"], + "additionalProperties": false, "properties": { "type": {"type": "string"}, "source": {"type": "string"}, diff --git a/compose/config/config_schema_v3.5.json b/compose/config/config_schema_v3.5.json index fa95d6a2457..1f05395dacb 100644 --- a/compose/config/config_schema_v3.5.json +++ b/compose/config/config_schema_v3.5.json @@ -281,6 +281,7 @@ { "type": "object", "required": ["type"], + "additionalProperties": false, "properties": { "type": {"type": "string"}, "source": {"type": "string"}, diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index a758154c04b..2d6e88a0978 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -2631,6 +2631,33 @@ def test_load_configs_multi_file(self): ] assert service_sort(service_dicts) == service_sort(expected) + def test_service_volume_invalid_config(self): + config_details = build_config_details( + { + 'version': '3.2', + 'services': { + 'web': { + 'build': { + 'context': '.', + 'args': None, + }, + 'volumes': [ + { + "type": "volume", + "source": "/data", + "garbage": { + "and": "error" + } + } + ] + }, + }, + } + ) + with pytest.raises(ConfigurationError) as exc: + config.load(config_details) + assert "services.web.volumes contains unsupported option: 'garbage'" in exc.exconly() + class NetworkModeTest(unittest.TestCase): From 60f818e5483c2697a8e4b806354f026fb49ecb1b Mon Sep 17 00:00:00 2001 From: Drew Romanyk Date: Thu, 30 Nov 2017 10:59:25 -0600 Subject: [PATCH 3129/4072] Add ipam default driver, fixes #5248 Signed-off-by: Drew Romanyk --- compose/network.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/network.py b/compose/network.py index 2e0a7e6ecdb..ee5939c1593 100644 --- a/compose/network.py +++ b/compose/network.py @@ -116,7 +116,7 @@ def create_ipam_config_from_dict(ipam_dict): return None return IPAMConfig( - driver=ipam_dict.get('driver'), + driver=ipam_dict.get('driver') or 'default', pool_configs=[ IPAMPool( subnet=config.get('subnet'), From a1ac289943639754106d5b6bdb1efbc5e503f7f3 Mon Sep 17 00:00:00 2001 From: Drew Romanyk Date: Thu, 30 Nov 2017 11:57:42 -0600 Subject: [PATCH 3130/4072] Add label config validation, fixes #4904 Signed-off-by: Drew Romanyk --- compose/config/config_schema_v1.json | 17 ++++++++++++- compose/config/config_schema_v2.0.json | 17 ++++++++++++- compose/config/config_schema_v2.1.json | 23 ++++++++++++++--- compose/config/config_schema_v2.2.json | 23 ++++++++++++++--- compose/config/config_schema_v2.3.json | 23 ++++++++++++++--- compose/config/config_schema_v3.0.json | 23 ++++++++++++++--- compose/config/config_schema_v3.1.json | 25 +++++++++++++++---- compose/config/config_schema_v3.2.json | 25 +++++++++++++++---- compose/config/config_schema_v3.3.json | 29 ++++++++++++++++------ compose/config/config_schema_v3.4.json | 29 ++++++++++++++++------ compose/config/config_schema_v3.5.json | 29 ++++++++++++++++------ tests/unit/config/config_test.py | 34 ++++++++++++++++++++++++++ 12 files changed, 248 insertions(+), 49 deletions(-) diff --git a/compose/config/config_schema_v1.json b/compose/config/config_schema_v1.json index 94354cda712..2771f9958fb 100644 --- a/compose/config/config_schema_v1.json +++ b/compose/config/config_schema_v1.json @@ -78,7 +78,7 @@ "hostname": {"type": "string"}, "image": {"type": "string"}, "ipc": {"type": "string"}, - "labels": {"$ref": "#/definitions/list_or_dict"}, + "labels": {"$ref": "#/definitions/labels"}, "links": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, "log_driver": {"type": "string"}, "log_opt": {"type": "object"}, @@ -166,6 +166,21 @@ ] }, + "labels": { + "oneOf": [ + { + "type": "object", + "patternProperties": { + ".+": { + "type": "string" + } + }, + "additionalProperties": false + }, + {"type": "array", "items": {"type": "string"}, "uniqueItems": true} + ] + }, + "constraints": { "service": { "id": "#/definitions/constraints/service", diff --git a/compose/config/config_schema_v2.0.json b/compose/config/config_schema_v2.0.json index 2ad62ac5221..5a659eaff11 100644 --- a/compose/config/config_schema_v2.0.json +++ b/compose/config/config_schema_v2.0.json @@ -158,7 +158,7 @@ "hostname": {"type": "string"}, "image": {"type": "string"}, "ipc": {"type": "string"}, - "labels": {"$ref": "#/definitions/list_or_dict"}, + "labels": {"$ref": "#/definitions/labels"}, "links": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, "logging": { @@ -354,6 +354,21 @@ ] }, + "labels": { + "oneOf": [ + { + "type": "object", + "patternProperties": { + ".+": { + "type": "string" + } + }, + "additionalProperties": false + }, + {"type": "array", "items": {"type": "string"}, "uniqueItems": true} + ] + }, + "blkio_limit": { "type": "object", "properties": { diff --git a/compose/config/config_schema_v2.1.json b/compose/config/config_schema_v2.1.json index 6b74f0ed699..3f3d8765436 100644 --- a/compose/config/config_schema_v2.1.json +++ b/compose/config/config_schema_v2.1.json @@ -88,7 +88,7 @@ "context": {"type": "string"}, "dockerfile": {"type": "string"}, "args": {"$ref": "#/definitions/list_or_dict"}, - "labels": {"$ref": "#/definitions/list_or_dict"} + "labels": {"$ref": "#/definitions/labels"} }, "additionalProperties": false } @@ -183,7 +183,7 @@ "image": {"type": "string"}, "ipc": {"type": "string"}, "isolation": {"type": "string"}, - "labels": {"$ref": "#/definitions/list_or_dict"}, + "labels": {"$ref": "#/definitions/labels"}, "links": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, "logging": { @@ -350,7 +350,7 @@ }, "internal": {"type": "boolean"}, "enable_ipv6": {"type": "boolean"}, - "labels": {"$ref": "#/definitions/list_or_dict"} + "labels": {"$ref": "#/definitions/labels"} }, "additionalProperties": false }, @@ -373,7 +373,7 @@ }, "additionalProperties": false }, - "labels": {"$ref": "#/definitions/list_or_dict"}, + "labels": {"$ref": "#/definitions/labels"}, "name": {"type": "string"} }, "additionalProperties": false @@ -407,6 +407,21 @@ ] }, + "labels": { + "oneOf": [ + { + "type": "object", + "patternProperties": { + ".+": { + "type": "string" + } + }, + "additionalProperties": false + }, + {"type": "array", "items": {"type": "string"}, "uniqueItems": true} + ] + }, + "blkio_limit": { "type": "object", "properties": { diff --git a/compose/config/config_schema_v2.2.json b/compose/config/config_schema_v2.2.json index 21343b8932c..294820c747b 100644 --- a/compose/config/config_schema_v2.2.json +++ b/compose/config/config_schema_v2.2.json @@ -88,7 +88,7 @@ "context": {"type": "string"}, "dockerfile": {"type": "string"}, "args": {"$ref": "#/definitions/list_or_dict"}, - "labels": {"$ref": "#/definitions/list_or_dict"}, + "labels": {"$ref": "#/definitions/labels"}, "cache_from": {"$ref": "#/definitions/list_of_strings"}, "network": {"type": "string"} }, @@ -189,7 +189,7 @@ "init": {"type": ["boolean", "string"]}, "ipc": {"type": "string"}, "isolation": {"type": "string"}, - "labels": {"$ref": "#/definitions/list_or_dict"}, + "labels": {"$ref": "#/definitions/labels"}, "links": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, "logging": { @@ -357,7 +357,7 @@ }, "internal": {"type": "boolean"}, "enable_ipv6": {"type": "boolean"}, - "labels": {"$ref": "#/definitions/list_or_dict"} + "labels": {"$ref": "#/definitions/labels"} }, "additionalProperties": false }, @@ -380,7 +380,7 @@ }, "additionalProperties": false }, - "labels": {"$ref": "#/definitions/list_or_dict"}, + "labels": {"$ref": "#/definitions/labels"}, "name": {"type": "string"} }, "additionalProperties": false @@ -414,6 +414,21 @@ ] }, + "labels": { + "oneOf": [ + { + "type": "object", + "patternProperties": { + ".+": { + "type": "string" + } + }, + "additionalProperties": false + }, + {"type": "array", "items": {"type": "string"}, "uniqueItems": true} + ] + }, + "blkio_limit": { "type": "object", "properties": { diff --git a/compose/config/config_schema_v2.3.json b/compose/config/config_schema_v2.3.json index 6f923871bfd..5c580afbf40 100644 --- a/compose/config/config_schema_v2.3.json +++ b/compose/config/config_schema_v2.3.json @@ -88,7 +88,7 @@ "context": {"type": "string"}, "dockerfile": {"type": "string"}, "args": {"$ref": "#/definitions/list_or_dict"}, - "labels": {"$ref": "#/definitions/list_or_dict"}, + "labels": {"$ref": "#/definitions/labels"}, "cache_from": {"$ref": "#/definitions/list_of_strings"}, "network": {"type": "string"}, "target": {"type": "string"}, @@ -192,7 +192,7 @@ "init": {"type": ["boolean", "string"]}, "ipc": {"type": "string"}, "isolation": {"type": "string"}, - "labels": {"$ref": "#/definitions/list_or_dict"}, + "labels": {"$ref": "#/definitions/labels"}, "links": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, "logging": { @@ -361,7 +361,7 @@ }, "internal": {"type": "boolean"}, "enable_ipv6": {"type": "boolean"}, - "labels": {"$ref": "#/definitions/list_or_dict"} + "labels": {"$ref": "#/definitions/labels"} }, "additionalProperties": false }, @@ -384,7 +384,7 @@ }, "additionalProperties": false }, - "labels": {"$ref": "#/definitions/list_or_dict"}, + "labels": {"$ref": "#/definitions/labels"}, "name": {"type": "string"} }, "additionalProperties": false @@ -418,6 +418,21 @@ ] }, + "labels": { + "oneOf": [ + { + "type": "object", + "patternProperties": { + ".+": { + "type": "string" + } + }, + "additionalProperties": false + }, + {"type": "array", "items": {"type": "string"}, "uniqueItems": true} + ] + }, + "blkio_limit": { "type": "object", "properties": { diff --git a/compose/config/config_schema_v3.0.json b/compose/config/config_schema_v3.0.json index f39344cfbe7..afdb23ae26b 100644 --- a/compose/config/config_schema_v3.0.json +++ b/compose/config/config_schema_v3.0.json @@ -105,7 +105,7 @@ "hostname": {"type": "string"}, "image": {"type": "string"}, "ipc": {"type": "string"}, - "labels": {"$ref": "#/definitions/list_or_dict"}, + "labels": {"$ref": "#/definitions/labels"}, "links": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, "logging": { @@ -223,7 +223,7 @@ "properties": { "mode": {"type": "string"}, "replicas": {"type": "integer"}, - "labels": {"$ref": "#/definitions/list_or_dict"}, + "labels": {"$ref": "#/definitions/labels"}, "update_config": { "type": "object", "properties": { @@ -310,7 +310,7 @@ "additionalProperties": false }, "internal": {"type": "boolean"}, - "labels": {"$ref": "#/definitions/list_or_dict"} + "labels": {"$ref": "#/definitions/labels"} }, "additionalProperties": false }, @@ -333,7 +333,7 @@ }, "additionalProperties": false }, - "labels": {"$ref": "#/definitions/list_or_dict"} + "labels": {"$ref": "#/definitions/labels"} }, "additionalProperties": false }, @@ -366,6 +366,21 @@ ] }, + "labels": { + "oneOf": [ + { + "type": "object", + "patternProperties": { + ".+": { + "type": "string" + } + }, + "additionalProperties": false + }, + {"type": "array", "items": {"type": "string"}, "uniqueItems": true} + ] + }, + "constraints": { "service": { "id": "#/definitions/constraints/service", diff --git a/compose/config/config_schema_v3.1.json b/compose/config/config_schema_v3.1.json index 719c0fa7acc..4689bed7101 100644 --- a/compose/config/config_schema_v3.1.json +++ b/compose/config/config_schema_v3.1.json @@ -116,7 +116,7 @@ "hostname": {"type": "string"}, "image": {"type": "string"}, "ipc": {"type": "string"}, - "labels": {"$ref": "#/definitions/list_or_dict"}, + "labels": {"$ref": "#/definitions/labels"}, "links": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, "logging": { @@ -252,7 +252,7 @@ "properties": { "mode": {"type": "string"}, "replicas": {"type": "integer"}, - "labels": {"$ref": "#/definitions/list_or_dict"}, + "labels": {"$ref": "#/definitions/labels"}, "update_config": { "type": "object", "properties": { @@ -339,7 +339,7 @@ "additionalProperties": false }, "internal": {"type": "boolean"}, - "labels": {"$ref": "#/definitions/list_or_dict"} + "labels": {"$ref": "#/definitions/labels"} }, "additionalProperties": false }, @@ -362,7 +362,7 @@ }, "additionalProperties": false }, - "labels": {"$ref": "#/definitions/list_or_dict"} + "labels": {"$ref": "#/definitions/labels"} }, "additionalProperties": false }, @@ -378,7 +378,7 @@ "name": {"type": "string"} } }, - "labels": {"$ref": "#/definitions/list_or_dict"} + "labels": {"$ref": "#/definitions/labels"} }, "additionalProperties": false }, @@ -411,6 +411,21 @@ ] }, + "labels": { + "oneOf": [ + { + "type": "object", + "patternProperties": { + ".+": { + "type": "string" + } + }, + "additionalProperties": false + }, + {"type": "array", "items": {"type": "string"}, "uniqueItems": true} + ] + }, + "constraints": { "service": { "id": "#/definitions/constraints/service", diff --git a/compose/config/config_schema_v3.2.json b/compose/config/config_schema_v3.2.json index 8d850d5d2e9..784a0233e84 100644 --- a/compose/config/config_schema_v3.2.json +++ b/compose/config/config_schema_v3.2.json @@ -117,7 +117,7 @@ "hostname": {"type": "string"}, "image": {"type": "string"}, "ipc": {"type": "string"}, - "labels": {"$ref": "#/definitions/list_or_dict"}, + "labels": {"$ref": "#/definitions/labels"}, "links": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, "logging": { @@ -297,7 +297,7 @@ "mode": {"type": "string"}, "endpoint_mode": {"type": "string"}, "replicas": {"type": "integer"}, - "labels": {"$ref": "#/definitions/list_or_dict"}, + "labels": {"$ref": "#/definitions/labels"}, "update_config": { "type": "object", "properties": { @@ -385,7 +385,7 @@ }, "internal": {"type": "boolean"}, "attachable": {"type": "boolean"}, - "labels": {"$ref": "#/definitions/list_or_dict"} + "labels": {"$ref": "#/definitions/labels"} }, "additionalProperties": false }, @@ -408,7 +408,7 @@ }, "additionalProperties": false }, - "labels": {"$ref": "#/definitions/list_or_dict"} + "labels": {"$ref": "#/definitions/labels"} }, "additionalProperties": false }, @@ -424,7 +424,7 @@ "name": {"type": "string"} } }, - "labels": {"$ref": "#/definitions/list_or_dict"} + "labels": {"$ref": "#/definitions/labels"} }, "additionalProperties": false }, @@ -457,6 +457,21 @@ ] }, + "labels": { + "oneOf": [ + { + "type": "object", + "patternProperties": { + ".+": { + "type": "string" + } + }, + "additionalProperties": false + }, + {"type": "array", "items": {"type": "string"}, "uniqueItems": true} + ] + }, + "constraints": { "service": { "id": "#/definitions/constraints/service", diff --git a/compose/config/config_schema_v3.3.json b/compose/config/config_schema_v3.3.json index f1eb9a66103..d8b509e89b2 100644 --- a/compose/config/config_schema_v3.3.json +++ b/compose/config/config_schema_v3.3.json @@ -83,7 +83,7 @@ "context": {"type": "string"}, "dockerfile": {"type": "string"}, "args": {"$ref": "#/definitions/list_or_dict"}, - "labels": {"$ref": "#/definitions/list_or_dict"}, + "labels": {"$ref": "#/definitions/labels"}, "cache_from": {"$ref": "#/definitions/list_of_strings"} }, "additionalProperties": false @@ -151,7 +151,7 @@ "hostname": {"type": "string"}, "image": {"type": "string"}, "ipc": {"type": "string"}, - "labels": {"$ref": "#/definitions/list_or_dict"}, + "labels": {"$ref": "#/definitions/labels"}, "links": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, "logging": { @@ -331,7 +331,7 @@ "mode": {"type": "string"}, "endpoint_mode": {"type": "string"}, "replicas": {"type": "integer"}, - "labels": {"$ref": "#/definitions/list_or_dict"}, + "labels": {"$ref": "#/definitions/labels"}, "update_config": { "type": "object", "properties": { @@ -429,7 +429,7 @@ }, "internal": {"type": "boolean"}, "attachable": {"type": "boolean"}, - "labels": {"$ref": "#/definitions/list_or_dict"} + "labels": {"$ref": "#/definitions/labels"} }, "additionalProperties": false }, @@ -452,7 +452,7 @@ }, "additionalProperties": false }, - "labels": {"$ref": "#/definitions/list_or_dict"} + "labels": {"$ref": "#/definitions/labels"} }, "additionalProperties": false }, @@ -468,7 +468,7 @@ "name": {"type": "string"} } }, - "labels": {"$ref": "#/definitions/list_or_dict"} + "labels": {"$ref": "#/definitions/labels"} }, "additionalProperties": false }, @@ -484,7 +484,7 @@ "name": {"type": "string"} } }, - "labels": {"$ref": "#/definitions/list_or_dict"} + "labels": {"$ref": "#/definitions/labels"} }, "additionalProperties": false }, @@ -517,6 +517,21 @@ ] }, + "labels": { + "oneOf": [ + { + "type": "object", + "patternProperties": { + ".+": { + "type": "string" + } + }, + "additionalProperties": false + }, + {"type": "array", "items": {"type": "string"}, "uniqueItems": true} + ] + }, + "constraints": { "service": { "id": "#/definitions/constraints/service", diff --git a/compose/config/config_schema_v3.4.json b/compose/config/config_schema_v3.4.json index dae7d7d2345..bf1f413d9fc 100644 --- a/compose/config/config_schema_v3.4.json +++ b/compose/config/config_schema_v3.4.json @@ -85,7 +85,7 @@ "context": {"type": "string"}, "dockerfile": {"type": "string"}, "args": {"$ref": "#/definitions/list_or_dict"}, - "labels": {"$ref": "#/definitions/list_or_dict"}, + "labels": {"$ref": "#/definitions/labels"}, "cache_from": {"$ref": "#/definitions/list_of_strings"}, "network": {"type": "string"}, "target": {"type": "string"} @@ -155,7 +155,7 @@ "hostname": {"type": "string"}, "image": {"type": "string"}, "ipc": {"type": "string"}, - "labels": {"$ref": "#/definitions/list_or_dict"}, + "labels": {"$ref": "#/definitions/labels"}, "links": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, "logging": { @@ -336,7 +336,7 @@ "mode": {"type": "string"}, "endpoint_mode": {"type": "string"}, "replicas": {"type": "integer"}, - "labels": {"$ref": "#/definitions/list_or_dict"}, + "labels": {"$ref": "#/definitions/labels"}, "update_config": { "type": "object", "properties": { @@ -437,7 +437,7 @@ }, "internal": {"type": "boolean"}, "attachable": {"type": "boolean"}, - "labels": {"$ref": "#/definitions/list_or_dict"} + "labels": {"$ref": "#/definitions/labels"} }, "additionalProperties": false }, @@ -461,7 +461,7 @@ }, "additionalProperties": false }, - "labels": {"$ref": "#/definitions/list_or_dict"} + "labels": {"$ref": "#/definitions/labels"} }, "additionalProperties": false }, @@ -477,7 +477,7 @@ "name": {"type": "string"} } }, - "labels": {"$ref": "#/definitions/list_or_dict"} + "labels": {"$ref": "#/definitions/labels"} }, "additionalProperties": false }, @@ -493,7 +493,7 @@ "name": {"type": "string"} } }, - "labels": {"$ref": "#/definitions/list_or_dict"} + "labels": {"$ref": "#/definitions/labels"} }, "additionalProperties": false }, @@ -526,6 +526,21 @@ ] }, + "labels": { + "oneOf": [ + { + "type": "object", + "patternProperties": { + ".+": { + "type": "string" + } + }, + "additionalProperties": false + }, + {"type": "array", "items": {"type": "string"}, "uniqueItems": true} + ] + }, + "constraints": { "service": { "id": "#/definitions/constraints/service", diff --git a/compose/config/config_schema_v3.5.json b/compose/config/config_schema_v3.5.json index fa95d6a2457..75af7724efe 100644 --- a/compose/config/config_schema_v3.5.json +++ b/compose/config/config_schema_v3.5.json @@ -83,7 +83,7 @@ "context": {"type": "string"}, "dockerfile": {"type": "string"}, "args": {"$ref": "#/definitions/list_or_dict"}, - "labels": {"$ref": "#/definitions/list_or_dict"}, + "labels": {"$ref": "#/definitions/labels"}, "cache_from": {"$ref": "#/definitions/list_of_strings"}, "network": {"type": "string"}, "target": {"type": "string"}, @@ -154,7 +154,7 @@ "hostname": {"type": "string"}, "image": {"type": "string"}, "ipc": {"type": "string"}, - "labels": {"$ref": "#/definitions/list_or_dict"}, + "labels": {"$ref": "#/definitions/labels"}, "links": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, "logging": { @@ -334,7 +334,7 @@ "mode": {"type": "string"}, "endpoint_mode": {"type": "string"}, "replicas": {"type": "integer"}, - "labels": {"$ref": "#/definitions/list_or_dict"}, + "labels": {"$ref": "#/definitions/labels"}, "update_config": { "type": "object", "properties": { @@ -435,7 +435,7 @@ }, "internal": {"type": "boolean"}, "attachable": {"type": "boolean"}, - "labels": {"$ref": "#/definitions/list_or_dict"} + "labels": {"$ref": "#/definitions/labels"} }, "additionalProperties": false }, @@ -459,7 +459,7 @@ }, "additionalProperties": false }, - "labels": {"$ref": "#/definitions/list_or_dict"} + "labels": {"$ref": "#/definitions/labels"} }, "additionalProperties": false }, @@ -475,7 +475,7 @@ "name": {"type": "string"} } }, - "labels": {"$ref": "#/definitions/list_or_dict"} + "labels": {"$ref": "#/definitions/labels"} }, "additionalProperties": false }, @@ -491,7 +491,7 @@ "name": {"type": "string"} } }, - "labels": {"$ref": "#/definitions/list_or_dict"} + "labels": {"$ref": "#/definitions/labels"} }, "additionalProperties": false }, @@ -524,6 +524,21 @@ ] }, + "labels": { + "oneOf": [ + { + "type": "object", + "patternProperties": { + ".+": { + "type": "string" + } + }, + "additionalProperties": false + }, + {"type": "array", "items": {"type": "string"}, "uniqueItems": true} + ] + }, + "constraints": { "service": { "id": "#/definitions/constraints/service", diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index a758154c04b..a61e0afcf9a 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -2631,6 +2631,40 @@ def test_load_configs_multi_file(self): ] assert service_sort(service_dicts) == service_sort(expected) + def test_config_invalid_service_label_validation(self): + config_details = build_config_details( + { + 'version': '3.5', + 'services': { + 'web': { + 'image': 'busybox', + 'labels': { + "key": 12345 + } + }, + }, + } + ) + with pytest.raises(ConfigurationError) as exc: + config.load(config_details) + assert "which is an invalid type, it should be a string" in exc.exconly() + + def test_config_valid_service_label_validation(self): + config_details = build_config_details( + { + 'version': '3.5', + 'services': { + 'web': { + 'image': 'busybox', + 'labels': { + "key": "string" + } + }, + }, + } + ) + config.load(config_details) + class NetworkModeTest(unittest.TestCase): From 7fdb90d0aac99c9027e99564b1b498190fc12827 Mon Sep 17 00:00:00 2001 From: Fumiaki MATSUSHIMA Date: Sun, 3 Dec 2017 01:07:17 +0900 Subject: [PATCH 3131/4072] Specify osx_image to fix CI Signed-off-by: Fumiaki Matsushima --- .travis.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.travis.yml b/.travis.yml index fbf2696466d..8fef7ed1bdf 100644 --- a/.travis.yml +++ b/.travis.yml @@ -8,6 +8,7 @@ matrix: services: - docker - os: osx + osx_image: xcode7.3 language: generic install: ./script/travis/install From be0b9026317eab47dc0c9a67a2d77105c7330c2a Mon Sep 17 00:00:00 2001 From: Svyatoslav Ilinskiy Date: Sat, 2 Dec 2017 18:36:31 -0600 Subject: [PATCH 3132/4072] Add ability to list and filter services in `ps` Also, rename --filter "option=..." to --filter "key=..." Signed-off-by: Svyatoslav Ilinskiy --- compose/cli/main.py | 90 +++++++++---------- contrib/completion/bash/docker-compose | 10 +-- tests/acceptance/cli_test.py | 55 ++++++------ .../docker-compose.yml | 0 4 files changed, 79 insertions(+), 76 deletions(-) rename tests/fixtures/{config-services-filter => ps-services-filter}/docker-compose.yml (100%) diff --git a/compose/cli/main.py b/compose/cli/main.py index 5cec853db62..37168f03388 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -287,7 +287,7 @@ def config(self, config_options, options): """ Validate and view the Compose file. - Usage: config [options] [-f KEY=VAL...] + Usage: config [options] Options: --resolve-image-digests Pin image tags to digests. @@ -295,7 +295,6 @@ def config(self, config_options, options): anything. --services Print the service names, one per line. --volumes Print the volume names, one per line. - -f, --filter KEY=VAL Filter services by a property (can be used multiple times) """ @@ -310,15 +309,7 @@ def config(self, config_options, options): return if options['--services']: - filters = build_filters(options.get('--filter')) - if filters: - if not self.project: - self.project = project_from_options('.', config_options) - services = filter_services(filters, self.project.services, self.project) - else: - services = [service['name'] for service in compose_config.services] - - print('\n'.join(services)) + print('\n'.join(service['name'] for service in compose_config.services)) return if options['--volumes']: @@ -608,38 +599,47 @@ def ps(self, options): """ List containers. - Usage: ps [options] [SERVICE...] + Usage: ps [options] [--filter KEY=VAL...] [SERVICE...] Options: - -q Only display IDs + -q Only display IDs + --services Display services + --filter KEY=VAL Filter services by a property (can be used multiple times) """ - containers = sorted( - self.project.containers(service_names=options['SERVICE'], stopped=True) + - self.project.containers(service_names=options['SERVICE'], one_off=OneOffFilter.only), - key=attrgetter('name')) - - if options['-q']: - for container in containers: - print(container.id) + if options['--services']: + filters = build_filters(options.get('--filter')) + services = self.project.services + if filters: + services = filter_services(filters, services, self.project) + print('\n'.join(service.name for service in services)) else: - headers = [ - 'Name', - 'Command', - 'State', - 'Ports', - ] - rows = [] - for container in containers: - command = container.human_readable_command - if len(command) > 30: - command = '%s ...' % command[:26] - rows.append([ - container.name, - command, - container.human_readable_state, - container.human_readable_ports, - ]) - print(Formatter().table(headers, rows)) + containers = sorted( + self.project.containers(service_names=options['SERVICE'], stopped=True) + + self.project.containers(service_names=options['SERVICE'], one_off=OneOffFilter.only), + key=attrgetter('name')) + + if options['-q']: + for container in containers: + print(container.id) + else: + headers = [ + 'Name', + 'Command', + 'State', + 'Ports', + ] + rows = [] + for container in containers: + command = container.human_readable_command + if len(command) > 30: + command = '%s ...' % command[:26] + rows.append([ + container.name, + command, + container.human_readable_state, + container.human_readable_ports, + ]) + print(Formatter().table(headers, rows)) def pull(self, options): """ @@ -1345,18 +1345,18 @@ def should_include(service): for status in filters[f]: if not has_container_with_state(containers, status): return False - elif f == 'option': - for option in filters[f]: - if option == 'image' or option == 'build': - if option not in service.options: + elif f == 'key': + for key in filters[f]: + if key == 'image' or key == 'build': + if key not in service.options: return False else: - raise UserError("Invalid option: %s" % option) + raise UserError("Invalid option: %s" % key) else: raise UserError("Invalid filter: %s" % f) return True - return [s.name for s in services if should_include(s)] + return filter(should_include, services) def build_filters(args): diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index 5885e686bd3..248ff928573 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -66,29 +66,29 @@ __docker_compose_services_all() { # All services that are defined by a Dockerfile reference __docker_compose_services_from_build() { - COMPREPLY=( $(compgen -W "$(__docker_compose_q config --services --filter "option=build")" -- "$cur") ) + COMPREPLY=( $(compgen -W "$(__docker_compose_q ps --services --filter "key=build")" -- "$cur") ) } # All services that are defined by an image __docker_compose_services_from_image() { - COMPREPLY=( $(compgen -W "$(__docker_compose_q config --services --filter "option=image")" -- "$cur") ) + COMPREPLY=( $(compgen -W "$(__docker_compose_q ps --services --filter "key=image")" -- "$cur") ) } # The services for which at least one paused container exists __docker_compose_services_paused() { - names=$(__docker_compose_q config --services --filter "status=paused") + names=$(__docker_compose_q ps --services --filter "status=paused") COMPREPLY=( $(compgen -W "$names" -- "$cur") ) } # The services for which at least one running container exists __docker_compose_services_running() { - names=$(__docker_compose_q config --services --filter "status=running") + names=$(__docker_compose_q ps --services --filter "status=running") COMPREPLY=( $(compgen -W "$names" -- "$cur") ) } # The services for which at least one stopped container exists __docker_compose_services_stopped() { - names=$(__docker_compose_q config --services --filter "status=stopped") + names=$(__docker_compose_q ps --services --filter "status=stopped") COMPREPLY=( $(compgen -W "$names" -- "$cur") ) } diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index a4c19afc5ab..d15d5c5fe53 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -440,32 +440,6 @@ def test_config_v3(self): }, } - def test_config_services_filter_option(self): - self.base_dir = 'tests/fixtures/config-services-filter' - image = self.dispatch(['config', '--services', '--filter', 'option=image']) - build = self.dispatch(['config', '--services', '--filter', 'option=build']) - - self.assertIn('with_build', build.stdout) - self.assertNotIn('with_build', image.stdout) - self.assertIn('with_image', image.stdout) - self.assertNotIn('with_image', build.stdout) - - def test_config_services_filter_status(self): - self.base_dir = 'tests/fixtures/config-services-filter' - self.dispatch(['up', '-d']) - self.dispatch(['pause', 'with_image']) - paused = self.dispatch(['config', '--services', '--filter', 'status=paused']) - stopped = self.dispatch(['config', '--services', '--filter', 'status=stopped']) - running = self.dispatch(['config', '--services', '--filter', 'status=running', - '--filter', 'option=build']) - - self.assertNotIn('with_build', stopped.stdout) - self.assertNotIn('with_image', stopped.stdout) - self.assertNotIn('with_build', paused.stdout) - self.assertIn('with_image', paused.stdout) - self.assertIn('with_build', running.stdout) - self.assertNotIn('with_image', running.stdout) - def test_ps(self): self.project.get_service('simple').create_container() result = self.dispatch(['ps']) @@ -493,6 +467,35 @@ def test_ps_alternate_composefile(self): self.assertNotIn('multiplecomposefiles_another_1', result.stdout) self.assertIn('multiplecomposefiles_yetanother_1', result.stdout) + def test_ps_services_filter_option(self): + self.base_dir = 'tests/fixtures/ps-services-filter' + image = self.dispatch(['ps', '--services', '--filter', 'key=image']) + build = self.dispatch(['ps', '--services', '--filter', 'key=build']) + all_services = self.dispatch(['ps', '--services']) + + self.assertIn('with_build', all_services.stdout) + self.assertIn('with_image', all_services.stdout) + self.assertIn('with_build', build.stdout) + self.assertNotIn('with_build', image.stdout) + self.assertIn('with_image', image.stdout) + self.assertNotIn('with_image', build.stdout) + + def test_ps_services_filter_status(self): + self.base_dir = 'tests/fixtures/ps-services-filter' + self.dispatch(['up', '-d']) + self.dispatch(['pause', 'with_image']) + paused = self.dispatch(['ps', '--services', '--filter', 'status=paused']) + stopped = self.dispatch(['ps', '--services', '--filter', 'status=stopped']) + running = self.dispatch(['ps', '--services', '--filter', 'status=running', + '--filter', 'key=build']) + + self.assertNotIn('with_build', stopped.stdout) + self.assertNotIn('with_image', stopped.stdout) + self.assertNotIn('with_build', paused.stdout) + self.assertIn('with_image', paused.stdout) + self.assertIn('with_build', running.stdout) + self.assertNotIn('with_image', running.stdout) + def test_pull(self): result = self.dispatch(['pull']) assert sorted(result.stderr.split('\n'))[1:] == [ diff --git a/tests/fixtures/config-services-filter/docker-compose.yml b/tests/fixtures/ps-services-filter/docker-compose.yml similarity index 100% rename from tests/fixtures/config-services-filter/docker-compose.yml rename to tests/fixtures/ps-services-filter/docker-compose.yml From a6cdd6272600a29d013250ddcd1dff667a674fe7 Mon Sep 17 00:00:00 2001 From: Samantha Miller Date: Fri, 24 Nov 2017 22:53:48 -0600 Subject: [PATCH 3133/4072] Adds support for a memory flag to docker-compose build. Signed-off-by: Samantha Miller --- compose/cli/main.py | 2 ++ compose/project.py | 5 +++-- compose/service.py | 5 ++++- contrib/completion/bash/docker-compose | 2 +- contrib/completion/zsh/_docker-compose | 1 + tests/acceptance/cli_test.py | 6 ++++++ tests/fixtures/build-memory/Dockerfile | 4 ++++ tests/fixtures/build-memory/docker-compose.yml | 6 ++++++ tests/unit/service_test.py | 2 ++ 9 files changed, 29 insertions(+), 4 deletions(-) create mode 100644 tests/fixtures/build-memory/Dockerfile create mode 100644 tests/fixtures/build-memory/docker-compose.yml diff --git a/compose/cli/main.py b/compose/cli/main.py index c3e30919d4b..bc63d971870 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -232,6 +232,7 @@ def build(self, 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. + -m, --memory MEM Sets memory limit for the bulid container. --build-arg key=val Set build-time variables for one service. """ service_names = options['SERVICE'] @@ -248,6 +249,7 @@ def build(self, options): no_cache=bool(options.get('--no-cache', False)), pull=bool(options.get('--pull', False)), force_rm=bool(options.get('--force-rm', False)), + memory=options.get('--memory'), build_args=build_args) def bundle(self, config_options, options): diff --git a/compose/project.py b/compose/project.py index f6bd30a8869..a618e0f2fbd 100644 --- a/compose/project.py +++ b/compose/project.py @@ -357,10 +357,11 @@ def restart(self, service_names=None, **options): ) return containers - def build(self, service_names=None, no_cache=False, pull=False, force_rm=False, build_args=None): + def build(self, service_names=None, no_cache=False, pull=False, force_rm=False, memory=None, + build_args=None): for service in self.get_services(service_names): if service.can_be_built(): - service.build(no_cache, pull, force_rm, build_args) + service.build(no_cache, pull, force_rm, memory, build_args) else: log.info('%s uses an image, skipping' % service.name) diff --git a/compose/service.py b/compose/service.py index 0b6561d9959..e19b8fbb163 100644 --- a/compose/service.py +++ b/compose/service.py @@ -900,7 +900,7 @@ def build_spec(secret): return [build_spec(secret) for secret in self.secrets] - def build(self, no_cache=False, pull=False, force_rm=False, build_args_override=None): + def build(self, no_cache=False, pull=False, force_rm=False, memory=None, build_args_override=None): log.info('Building %s' % self.name) build_opts = self.options.get('build', {}) @@ -931,6 +931,9 @@ def build(self, no_cache=False, pull=False, force_rm=False, build_args_override= target=build_opts.get('target', None), shmsize=parse_bytes(build_opts.get('shm_size')) if build_opts.get('shm_size') else None, extra_hosts=build_opts.get('extra_hosts', None), + container_limits={ + 'memory': parse_bytes(memory) if memory else None + }, ) try: diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index 1fdb2770540..d9cbbbbf38b 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -120,7 +120,7 @@ _docker_compose_build() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "--build-arg --force-rm --help --no-cache --pull" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--build-arg --force-rm --help --memory --no-cache --pull" -- "$cur" ) ) ;; *) __docker_compose_services_from_build diff --git a/contrib/completion/zsh/_docker-compose b/contrib/completion/zsh/_docker-compose index f53f963347e..c0a54cced1b 100644 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -196,6 +196,7 @@ __docker-compose_subcommand() { $opts_help \ "*--build-arg=[Set build-time variables for one service.]:=: " \ '--force-rm[Always remove intermediate containers.]' \ + '--memory[Memory limit for the build container.]' \ '--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 diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 8468dfbde51..01938064411 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -584,6 +584,12 @@ def test_build_shm_size_build_option(self): result = self.dispatch(['build', '--no-cache'], None) assert 'shm_size: 96' in result.stdout + def test_build_memory_build_option(self): + pull_busybox(self.client) + self.base_dir = 'tests/fixtures/build-memory' + result = self.dispatch(['build', '--no-cache', '--memory', '96m', 'service'], None) + assert 'memory: 100663296' in result.stdout # 96 * 1024 * 1024 + def test_bundle_with_digests(self): self.base_dir = 'tests/fixtures/bundle-with-digests/' tmpdir = pytest.ensuretemp('cli_test_bundle') diff --git a/tests/fixtures/build-memory/Dockerfile b/tests/fixtures/build-memory/Dockerfile new file mode 100644 index 00000000000..b27349b9659 --- /dev/null +++ b/tests/fixtures/build-memory/Dockerfile @@ -0,0 +1,4 @@ +FROM busybox + +# Report the memory (through the size of the group memory) +RUN echo "memory:" $(cat /sys/fs/cgroup/memory/memory.limit_in_bytes) diff --git a/tests/fixtures/build-memory/docker-compose.yml b/tests/fixtures/build-memory/docker-compose.yml new file mode 100644 index 00000000000..f98355851b1 --- /dev/null +++ b/tests/fixtures/build-memory/docker-compose.yml @@ -0,0 +1,6 @@ +version: '3.5' + +services: + service: + build: + context: . diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 8e8f602033c..3a0eab0ec8a 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -499,6 +499,7 @@ def test_create_container(self): target=None, shmsize=None, extra_hosts=None, + container_limits={'memory': None}, ) def test_ensure_image_exists_no_build(self): @@ -541,6 +542,7 @@ def test_ensure_image_exists_force_build(self): target=None, shmsize=None, extra_hosts=None, + container_limits={'memory': None}, ) def test_build_does_not_pull(self): From e4e92dd3d84f21a638adbe977074344468481309 Mon Sep 17 00:00:00 2001 From: Yafeng Shan Date: Mon, 4 Dec 2017 11:25:04 +0800 Subject: [PATCH 3134/4072] add support for `runtime` option in service definitions close #5360 Signed-off-by: Yafeng Shan --- compose/config/config.py | 1 + compose/config/config_schema_v2.3.json | 1 + compose/service.py | 2 + tests/integration/project_test.py | 63 ++++++++++++++++++++++++++ tests/integration/testcases.py | 20 ++++++++ tests/unit/config/config_test.py | 19 ++++++++ 6 files changed, 106 insertions(+) diff --git a/compose/config/config.py b/compose/config/config.py index 864bc7e90ae..e784be176d5 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -97,6 +97,7 @@ 'privileged', 'read_only', 'restart', + 'runtime', 'secrets', 'security_opt', 'shm_size', diff --git a/compose/config/config_schema_v2.3.json b/compose/config/config_schema_v2.3.json index 6f923871bfd..cedc2dae6c2 100644 --- a/compose/config/config_schema_v2.3.json +++ b/compose/config/config_schema_v2.3.json @@ -261,6 +261,7 @@ "privileged": {"type": "boolean"}, "read_only": {"type": "boolean"}, "restart": {"type": "string"}, + "runtime": {"type": "string"}, "scale": {"type": "integer"}, "security_opt": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, "shm_size": {"type": ["number", "string"]}, diff --git a/compose/service.py b/compose/service.py index 07db3ac5f43..3e492267f73 100644 --- a/compose/service.py +++ b/compose/service.py @@ -87,6 +87,7 @@ 'pids_limit', 'privileged', 'restart', + 'runtime', 'security_opt', 'shm_size', 'storage_opt', @@ -858,6 +859,7 @@ def _get_container_host_config(self, override_options, one_off=False): dns_opt=options.get('dns_opt'), dns_search=options.get('dns_search'), restart_policy=options.get('restart'), + runtime=options.get('runtime'), cap_add=options.get('cap_add'), cap_drop=options.get('cap_drop'), mem_limit=options.get('mem_limit'), diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 953dd52beb8..e966f8d88f7 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -22,6 +22,7 @@ from compose.const import COMPOSEFILE_V2_0 as V2_0 from compose.const import COMPOSEFILE_V2_1 as V2_1 from compose.const import COMPOSEFILE_V2_2 as V2_2 +from compose.const import COMPOSEFILE_V2_3 as V2_3 from compose.const import COMPOSEFILE_V3_1 as V3_1 from compose.const import LABEL_PROJECT from compose.const import LABEL_SERVICE @@ -31,10 +32,12 @@ from compose.project import Project from compose.project import ProjectError from compose.service import ConvergenceStrategy +from tests.integration.testcases import if_runtime_available from tests.integration.testcases import is_cluster from tests.integration.testcases import no_cluster from tests.integration.testcases import v2_1_only from tests.integration.testcases import v2_2_only +from tests.integration.testcases import v2_3_only from tests.integration.testcases import v2_only from tests.integration.testcases import v3_only @@ -971,6 +974,66 @@ def test_up_with_invalid_isolation(self): with self.assertRaises(ProjectError): project.up() + @v2_3_only() + def test_up_with_runtime(self): + self.require_api_version('1.30') + config_data = build_config( + version=V2_3, + services=[{ + 'name': 'web', + 'image': 'busybox:latest', + 'runtime': 'runc' + }], + ) + project = Project.from_config( + client=self.client, + name='composetest', + config_data=config_data + ) + project.up(detached=True) + service_container = project.get_service('web').containers(stopped=True)[0] + assert service_container.inspect()['HostConfig']['Runtime'] == 'runc' + + @v2_3_only() + def test_up_with_invalid_runtime(self): + self.require_api_version('1.30') + config_data = build_config( + version=V2_3, + services=[{ + 'name': 'web', + 'image': 'busybox:latest', + 'runtime': 'foobar' + }], + ) + project = Project.from_config( + client=self.client, + name='composetest', + config_data=config_data + ) + with self.assertRaises(ProjectError): + project.up() + + @v2_3_only() + @if_runtime_available('nvidia') + def test_up_with_nvidia_runtime(self): + self.require_api_version('1.30') + config_data = build_config( + version=V2_3, + services=[{ + 'name': 'web', + 'image': 'busybox:latest', + 'runtime': 'nvidia' + }], + ) + project = Project.from_config( + client=self.client, + name='composetest', + config_data=config_data + ) + project.up(detached=True) + service_container = project.get_service('web').containers(stopped=True)[0] + assert service_container.inspect()['HostConfig']['Runtime'] == 'nvidia' + @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 b72fb53a81f..84a97b133f1 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -5,6 +5,7 @@ import os import pytest +import six from docker.errors import APIError from docker.utils import version_lt @@ -155,6 +156,25 @@ def get_volume_data(self, volume_name): return self.client.inspect_volume(volumes[0]['Name']) +def if_runtime_available(runtime): + if runtime == 'nvidia': + command = 'nvidia-container-runtime' + if six.PY3: + import shutil + return pytest.mark.skipif( + shutil.which(command) is None, + reason="Nvida runtime not exists" + ) + return pytest.mark.skipif( + any( + os.access(os.path.join(path, command), os.X_OK) + for path in os.environ["PATH"].split(os.pathsep) + ) is False, + reason="Nvida runtime not exists" + ) + return pytest.skip("Runtime %s not exists", runtime) + + def is_cluster(client): if SWARM_ASSUME_MULTINODE: return True diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 00ba6c2c650..fc28f8ef360 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1678,6 +1678,25 @@ def test_isolation_option(self): } ] + def test_runtime_option(self): + actual = config.load(build_config_details({ + 'version': str(V2_3), + 'services': { + 'web': { + 'image': 'nvidia/cuda', + 'runtime': 'nvidia' + } + } + })) + + assert actual.services == [ + { + 'name': 'web', + 'image': 'nvidia/cuda', + 'runtime': 'nvidia', + } + ] + def test_merge_service_dicts_from_files_with_extends_in_base(self): base = { 'volumes': ['.:/app'], From ceec0595bd093b26769c1823b19477086b3f956b Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 1 Dec 2017 15:23:32 -0800 Subject: [PATCH 3135/4072] Allow port publish ranges Signed-off-by: Joffrey F --- compose/config/types.py | 18 +++++++++++++----- tests/unit/config/types_test.py | 26 ++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/compose/config/types.py b/compose/config/types.py index 548f2c1cda1..d3b3cfc5392 100644 --- a/compose/config/types.py +++ b/compose/config/types.py @@ -319,11 +319,19 @@ def __new__(cls, target, published, *args, **kwargs): except ValueError: raise ConfigurationError('Invalid target port: {}'.format(target)) - try: - if published: - published = int(published) - except ValueError: - raise ConfigurationError('Invalid published port: {}'.format(published)) + if published: + if isinstance(published, six.string_types) and '-' in published: # "x-y:z" format + a, b = published.split('-', 1) + try: + int(a) + int(b) + except ValueError: + raise ConfigurationError('Invalid published port: {}'.format(published)) + else: + try: + published = int(published) + except ValueError: + raise ConfigurationError('Invalid published port: {}'.format(published)) return super(ServicePort, cls).__new__( cls, target, published, *args, **kwargs diff --git a/tests/unit/config/types_test.py b/tests/unit/config/types_test.py index 3a43f727bd9..e7cc67b0422 100644 --- a/tests/unit/config/types_test.py +++ b/tests/unit/config/types_test.py @@ -100,11 +100,37 @@ def test_parse_port_range(self): 'published': 25001 } in reprs + def test_parse_port_publish_range(self): + ports = ServicePort.parse('4440-4450:4000') + assert len(ports) == 1 + reprs = [p.repr() for p in ports] + assert { + 'target': 4000, + 'published': '4440-4450' + } in reprs + def test_parse_invalid_port(self): port_def = '4000p' with pytest.raises(ConfigurationError): ServicePort.parse(port_def) + def test_parse_invalid_publish_range(self): + port_def = '-4000:4000' + with pytest.raises(ConfigurationError): + ServicePort.parse(port_def) + + port_def = 'asdf:4000' + with pytest.raises(ConfigurationError): + ServicePort.parse(port_def) + + port_def = '1234-12f:4000' + with pytest.raises(ConfigurationError): + ServicePort.parse(port_def) + + port_def = '1234-1235-1239:4000' + with pytest.raises(ConfigurationError): + ServicePort.parse(port_def) + class TestVolumeSpec(object): From 16cc8437de1b1a48d02ad078c0b42e8999dccf21 Mon Sep 17 00:00:00 2001 From: Madeline Stager Date: Mon, 4 Dec 2017 20:01:00 -0600 Subject: [PATCH 3136/4072] Raise error if up used with both -d and --timeout Fix #5434 Signed-off-by: Madeline Stager --- compose/cli/main.py | 10 +++++++--- tests/acceptance/cli_test.py | 15 +++------------ 2 files changed, 10 insertions(+), 15 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index c3e30919d4b..2205d19da5d 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -889,8 +889,8 @@ def up(self, options): Options: -d Detached mode: Run containers in the background, - print new container names. - Incompatible with --abort-on-container-exit. + print new container names. Incompatible with + --abort-on-container-exit and --timeout. --no-color Produce monochrome output. --no-deps Don't start linked services. --force-recreate Recreate containers even if their configuration @@ -904,7 +904,8 @@ def up(self, options): --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 + when attached or when containers are already. + Incompatible with -d. running. (default: 10) --remove-orphans Remove containers for services not defined in the Compose file @@ -925,6 +926,9 @@ def up(self, options): if detached and (cascade_stop or exit_value_from): raise UserError("--abort-on-container-exit and -d cannot be combined.") + if detached and timeout: + raise UserError("-d and --timeout cannot be combined.") + if no_start: for excluded in ['-d', '--abort-on-container-exit', '--exit-code-from']: if options.get(excluded): diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 8468dfbde51..ab5719c2027 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1280,18 +1280,9 @@ def test_up_with_force_recreate_and_no_recreate(self): ['up', '-d', '--force-recreate', '--no-recreate'], returncode=1) - def test_up_with_timeout(self): - self.dispatch(['up', '-d', '-t', '1']) - service = self.project.get_service('simple') - another = self.project.get_service('another') - self.assertEqual(len(service.containers()), 1) - self.assertEqual(len(another.containers()), 1) - - # Ensure containers don't have stdin and stdout connected in -d mode - config = service.containers()[0].inspect()['Config'] - self.assertFalse(config['AttachStderr']) - self.assertFalse(config['AttachStdout']) - self.assertFalse(config['AttachStdin']) + def test_up_with_timeout_detached(self): + result = self.dispatch(['up', '-d', '-t', '1'], returncode=1) + assert "-d and --timeout cannot be combined." in result.stderr def test_up_handles_sigint(self): proc = start_process(self.base_dir, ['up', '-t', '2']) From 79b20eb53f3c580b5202000708e9c39fae8f2c92 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 4 Dec 2017 22:47:33 -0800 Subject: [PATCH 3137/4072] Add support for mount syntax Signed-off-by: Joffrey F --- compose/config/config.py | 40 +++++++------ compose/config/config_schema_v2.3.json | 34 ++++++++++- compose/config/serialize.py | 6 ++ compose/config/types.py | 13 ++++ compose/service.py | 63 ++++++++++++++------ compose/utils.py | 2 +- compose/volume.py | 9 ++- tests/acceptance/cli_test.py | 22 ++++--- tests/helpers.py | 13 ++-- tests/integration/project_test.py | 21 +++++++ tests/integration/service_test.py | 82 ++++++++++++++++++++++++++ tests/integration/testcases.py | 6 +- tests/unit/config/config_test.py | 53 ++++++++++++++++- tests/unit/service_test.py | 4 +- 14 files changed, 309 insertions(+), 59 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 864bc7e90ae..9b41305360e 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -35,6 +35,7 @@ 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 MountSpec from .types import parse_extra_hosts from .types import parse_restart_spec from .types import ServiceLink @@ -809,6 +810,20 @@ def process_healthcheck(service_dict): return service_dict +def finalize_service_volumes(service_dict, environment): + if 'volumes' in service_dict: + finalized_volumes = [] + normalize = environment.get_boolean('COMPOSE_CONVERT_WINDOWS_PATHS') + for v in service_dict['volumes']: + if isinstance(v, dict): + finalized_volumes.append(MountSpec.parse(v, normalize)) + else: + finalized_volumes.append(VolumeSpec.parse(v, normalize)) + service_dict['volumes'] = finalized_volumes + + return service_dict + + def finalize_service(service_config, service_names, version, environment): service_dict = dict(service_config.config) @@ -822,12 +837,7 @@ def finalize_service(service_config, service_names, version, environment): for vf in service_dict['volumes_from'] ] - if 'volumes' in service_dict: - service_dict['volumes'] = [ - VolumeSpec.parse( - v, environment.get_boolean('COMPOSE_CONVERT_WINDOWS_PATHS') - ) for v in service_dict['volumes'] - ] + service_dict = finalize_service_volumes(service_dict, environment) if 'net' in service_dict: network_mode = service_dict.pop('net') @@ -1143,19 +1153,13 @@ def resolve_volume_paths(working_dir, service_dict): def resolve_volume_path(working_dir, volume): - mount_params = None if isinstance(volume, dict): - container_path = volume.get('target') - host_path = volume.get('source') - mode = None - if host_path: - if volume.get('read_only'): - mode = 'ro' - if volume.get('volume', {}).get('nocopy'): - mode = 'nocopy' - mount_params = (host_path, mode) - else: - container_path, mount_params = split_path_mapping(volume) + if volume.get('source', '').startswith('.') and volume['type'] == 'mount': + volume['source'] = expand_path(working_dir, volume['source']) + return volume + + mount_params = None + container_path, mount_params = split_path_mapping(volume) if mount_params is not None: host_path, mode = mount_params diff --git a/compose/config/config_schema_v2.3.json b/compose/config/config_schema_v2.3.json index 6f923871bfd..d50df3e81d4 100644 --- a/compose/config/config_schema_v2.3.json +++ b/compose/config/config_schema_v2.3.json @@ -293,7 +293,39 @@ }, "user": {"type": "string"}, "userns_mode": {"type": "string"}, - "volumes": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "volumes": { + "type": "array", + "items": { + "oneOf": [ + {"type": "string"}, + { + "type": "object", + "required": ["type"], + "additionalProperties": false, + "properties": { + "type": {"type": "string"}, + "source": {"type": "string"}, + "target": {"type": "string"}, + "read_only": {"type": "boolean"}, + "consistency": {"type": "string"}, + "bind": { + "type": "object", + "properties": { + "propagation": {"type": "string"} + } + }, + "volume": { + "type": "object", + "properties": { + "nocopy": {"type": "boolean"} + } + } + } + } + ], + "uniqueItems": true + } + }, "volume_driver": {"type": "string"}, "volumes_from": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, "working_dir": {"type": "string"} diff --git a/compose/config/serialize.py b/compose/config/serialize.py index 44878a47c61..4982f8e34c0 100644 --- a/compose/config/serialize.py +++ b/compose/config/serialize.py @@ -7,6 +7,7 @@ from compose.config import types from compose.const import COMPOSEFILE_V1 as V1 from compose.const import COMPOSEFILE_V2_1 as V2_1 +from compose.const import COMPOSEFILE_V2_3 as V2_3 from compose.const import COMPOSEFILE_V3_0 as V3_0 from compose.const import COMPOSEFILE_V3_2 as V3_2 from compose.const import COMPOSEFILE_V3_4 as V3_4 @@ -34,6 +35,7 @@ def serialize_string(dumper, data): return representer(data) +yaml.SafeDumper.add_representer(types.MountSpec, serialize_dict_type) yaml.SafeDumper.add_representer(types.VolumeFromSpec, serialize_config_type) yaml.SafeDumper.add_representer(types.VolumeSpec, serialize_config_type) yaml.SafeDumper.add_representer(types.ServiceSecret, serialize_dict_type) @@ -140,5 +142,9 @@ def denormalize_service_dict(service_dict, version, image_digest=None): p.legacy_repr() if isinstance(p, types.ServicePort) else p for p in service_dict['ports'] ] + if 'volumes' in service_dict and (version < V2_3 or (version > V3_0 and version < V3_2)): + service_dict['volumes'] = [ + v.legacy_repr() if isinstance(v, types.MountSpec) else v for v in service_dict['volumes'] + ] return service_dict diff --git a/compose/config/types.py b/compose/config/types.py index d3b3cfc5392..c134bd7ca32 100644 --- a/compose/config/types.py +++ b/compose/config/types.py @@ -144,6 +144,15 @@ class MountSpec(object): } _fields = ['type', 'source', 'target', 'read_only', 'consistency'] + @classmethod + def parse(cls, mount_dict, normalize=False): + if mount_dict.get('source'): + mount_dict['source'] = os.path.normpath(mount_dict['source']) + if normalize: + mount_dict['source'] = normalize_path_for_engine(mount_dict['source']) + + return cls(**mount_dict) + def __init__(self, type, source=None, target=None, read_only=None, consistency=None, **kwargs): self.type = type self.source = source @@ -174,6 +183,10 @@ def repr(self): def is_named_volume(self): return self.type == 'volume' and self.source + @property + def external(self): + return self.source + class VolumeSpec(namedtuple('_VolumeSpec', 'external internal mode')): diff --git a/compose/service.py b/compose/service.py index bfc2e594068..f51f0e5af72 100644 --- a/compose/service.py +++ b/compose/service.py @@ -785,15 +785,23 @@ def _get_container_create_options( self.options.get('labels'), override_options.get('labels')) + container_volumes = [] + container_mounts = [] + if 'volumes' in container_options: + container_volumes = [ + v for v in container_options.get('volumes') if isinstance(v, VolumeSpec) + ] + container_mounts = [v for v in container_options.get('volumes') if isinstance(v, MountSpec)] + binds, affinity = merge_volume_bindings( - container_options.get('volumes') or [], - self.options.get('tmpfs') or [], - previous_container) + container_volumes, self.options.get('tmpfs') or [], previous_container, + container_mounts + ) override_options['binds'] = binds container_options['environment'].update(affinity) - container_options['volumes'] = dict( - (v.internal, {}) for v in container_options.get('volumes') or {}) + container_options['volumes'] = dict((v.internal, {}) for v in container_volumes or {}) + override_options['mounts'] = [build_mount(v) for v in container_mounts] or None secret_volumes = self.get_secret_volumes() if secret_volumes: @@ -803,7 +811,8 @@ def _get_container_create_options( (v.target, {}) for v in secret_volumes ) else: - override_options['mounts'] = [build_mount(v) for v in secret_volumes] + override_options['mounts'] = override_options.get('mounts') or [] + override_options['mounts'].extend([build_mount(v) for v in secret_volumes]) container_options['image'] = self.image_name @@ -1245,32 +1254,40 @@ def parse_repository_tag(repo_path): # Volumes -def merge_volume_bindings(volumes, tmpfs, previous_container): - """Return a list of volume bindings for a container. Container data volumes - are replaced by those from the previous container. +def merge_volume_bindings(volumes, tmpfs, previous_container, mounts): + """ + Return a list of volume bindings for a container. Container data volumes + are replaced by those from the previous container. + Anonymous mounts are updated in place. """ affinity = {} volume_bindings = dict( build_volume_binding(volume) for volume in volumes - if volume.external) + if volume.external + ) if previous_container: - old_volumes = get_container_data_volumes(previous_container, volumes, tmpfs) + old_volumes, old_mounts = get_container_data_volumes( + previous_container, volumes, tmpfs, mounts + ) warn_on_masked_volume(volumes, old_volumes, previous_container.service) volume_bindings.update( - build_volume_binding(volume) for volume in old_volumes) + build_volume_binding(volume) for volume in old_volumes + ) - if old_volumes: + if old_volumes or old_mounts: affinity = {'affinity:container': '=' + previous_container.id} return list(volume_bindings.values()), affinity -def get_container_data_volumes(container, volumes_option, tmpfs_option): - """Find the container data volumes that are in `volumes_option`, and return - a mapping of volume bindings for those volumes. +def get_container_data_volumes(container, volumes_option, tmpfs_option, mounts_option): + """ + Find the container data volumes that are in `volumes_option`, and return + a mapping of volume bindings for those volumes. + Anonymous volume mounts are updated in place instead. """ volumes = [] volumes_option = volumes_option or [] @@ -1309,7 +1326,19 @@ def get_container_data_volumes(container, volumes_option, tmpfs_option): volume = volume._replace(external=mount['Name']) volumes.append(volume) - return volumes + updated_mounts = False + for mount in mounts_option: + if mount.type != 'volume': + continue + + ctnr_mount = container_mounts.get(mount.target) + if not ctnr_mount.get('Name'): + continue + + mount.source = ctnr_mount['Name'] + updated_mounts = True + + return volumes, updated_mounts def warn_on_masked_volume(volumes_option, container_volumes, service): diff --git a/compose/utils.py b/compose/utils.py index 197ae6eb29a..00b01df2e37 100644 --- a/compose/utils.py +++ b/compose/utils.py @@ -101,7 +101,7 @@ def json_stream(stream): def json_hash(obj): - dump = json.dumps(obj, sort_keys=True, separators=(',', ':')) + dump = json.dumps(obj, sort_keys=True, separators=(',', ':'), default=lambda x: x.repr()) h = hashlib.sha256() h.update(dump.encode('utf8')) return h.hexdigest() diff --git a/compose/volume.py b/compose/volume.py index da8ba25cab6..0b148620fbe 100644 --- a/compose/volume.py +++ b/compose/volume.py @@ -7,6 +7,7 @@ from docker.utils import version_lt from .config import ConfigurationError +from .config.types import VolumeSpec from .const import LABEL_PROJECT from .const import LABEL_VOLUME @@ -145,5 +146,9 @@ 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) + if isinstance(volume_spec, VolumeSpec): + volume = self.volumes[volume_spec.external] + return volume_spec._replace(external=volume.full_name) + else: + volume_spec.source = self.volumes[volume_spec.source].full_name + return volume_spec diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 8ec3f9cdd90..2ce5bf83ed8 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -428,13 +428,21 @@ def test_config_v3(self): 'timeout': '1s', 'retries': 5, }, - 'volumes': [ - '/host/path:/container/path:ro', - 'foobar:/container/volumepath:rw', - '/anonymous', - 'foobar:/container/volumepath2:nocopy' - ], - + 'volumes': [{ + 'read_only': True, + 'source': '/host/path', + 'target': '/container/path', + 'type': 'bind' + }, { + 'source': 'foobar', 'target': '/container/volumepath', 'type': 'volume' + }, { + 'target': '/anonymous', 'type': 'volume' + }, { + 'source': 'foobar', + 'target': '/container/volumepath2', + 'type': 'volume', + 'volume': {'nocopy': True} + }], 'stop_grace_period': '20s', }, }, diff --git a/tests/helpers.py b/tests/helpers.py index a93de993f13..f151f9cde40 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -19,12 +19,8 @@ def build_config_details(contents, working_dir='working_dir', filename='filename ) -def create_host_file(client, filename): +def create_custom_host_file(client, filename, content): 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)], @@ -48,3 +44,10 @@ def create_host_file(client, filename): return container_info['Node']['Name'] finally: client.remove_container(container, force=True) + + +def create_host_file(client, filename): + with open(filename, 'r') as fh: + content = fh.read() + + return create_custom_host_file(client, filename, content) diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 953dd52beb8..6686d96cce5 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -35,6 +35,7 @@ from tests.integration.testcases import no_cluster from tests.integration.testcases import v2_1_only from tests.integration.testcases import v2_2_only +from tests.integration.testcases import v2_3_only from tests.integration.testcases import v2_only from tests.integration.testcases import v3_only @@ -436,6 +437,26 @@ def test_recreate_preserves_volumes(self): self.assertNotEqual(db_container.id, old_db_id) self.assertEqual(db_container.get('Volumes./etc'), db_volume_path) + @v2_3_only() + def test_recreate_preserves_mounts(self): + web = self.create_service('web') + db = self.create_service('db', volumes=[types.MountSpec(type='volume', target='/etc')]) + project = Project('composetest', [web, db], self.client) + project.start() + assert len(project.containers()) == 0 + + project.up(['db']) + assert len(project.containers()) == 1 + old_db_id = project.containers()[0].id + db_volume_path = project.containers()[0].get_mount('/etc')['Source'] + + project.up(strategy=ConvergenceStrategy.always) + assert len(project.containers()) == 2 + + db_container = [c for c in project.containers() if 'db' in c.name][0] + assert db_container.id != old_db_id + assert db_container.get_mount('/etc')['Source'] == db_volume_path + def test_project_up_with_no_recreate_running(self): web = self.create_service('web') db = self.create_service('db', volumes=[VolumeSpec.parse('/var/db')]) diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 00bacebf5bc..b9005b8e104 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -19,6 +19,7 @@ from .testcases import SWARM_SKIP_CONTAINERS_ALL from .testcases import SWARM_SKIP_CPU_SHARES from compose import __version__ +from compose.config.types import MountSpec from compose.config.types import VolumeFromSpec from compose.config.types import VolumeSpec from compose.const import IS_WINDOWS_PLATFORM @@ -37,6 +38,7 @@ from compose.service import PidMode from compose.service import Service from compose.utils import parse_nanoseconds_int +from tests.helpers import create_custom_host_file from tests.integration.testcases import is_cluster from tests.integration.testcases import no_cluster from tests.integration.testcases import v2_1_only @@ -276,6 +278,54 @@ def test_create_container_with_specified_volume(self): self.assertTrue(path.basename(actual_host_path) == path.basename(host_path), msg=("Last component differs: %s, %s" % (actual_host_path, host_path))) + @v2_3_only() + def test_create_container_with_host_mount(self): + host_path = '/tmp/host-path' + container_path = '/container-path' + + create_custom_host_file(self.client, path.join(host_path, 'a.txt'), 'test') + + service = self.create_service( + 'db', + volumes=[ + MountSpec(type='bind', source=host_path, target=container_path, read_only=True) + ] + ) + container = service.create_container() + service.start_container(container) + mount = container.get_mount(container_path) + assert mount + assert path.basename(mount['Source']) == path.basename(host_path) + assert mount['RW'] is False + + @v2_3_only() + def test_create_container_with_tmpfs_mount(self): + container_path = '/container-tmpfs' + service = self.create_service( + 'db', + volumes=[MountSpec(type='tmpfs', target=container_path)] + ) + container = service.create_container() + service.start_container(container) + mount = container.get_mount(container_path) + assert mount + assert mount['Type'] == 'tmpfs' + + @v2_3_only() + def test_create_container_with_volume_mount(self): + container_path = '/container-volume' + volume_name = 'composetest_abcde' + self.client.create_volume(volume_name) + service = self.create_service( + 'db', + volumes=[MountSpec(type='volume', source=volume_name, target=container_path)] + ) + container = service.create_container() + service.start_container(container) + mount = container.get_mount(container_path) + assert mount + assert mount['Name'] == volume_name + def test_create_container_with_healthcheck_config(self): one_second = parse_nanoseconds_int('1s') healthcheck = { @@ -439,6 +489,38 @@ def test_execute_convergence_plan_recreate_twice(self): orig_container = new_container + @v2_3_only() + def test_execute_convergence_plan_recreate_twice_with_mount(self): + service = self.create_service( + 'db', + volumes=[MountSpec(target='/etc', type='volume')], + 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 + if not is_cluster(self.client): + assert ('affinity:container==%s' % orig_container.id in + new_container.get('Config.Env')) + else: + # In Swarm, the env marker is consumed and the container should be deployed + # on the same node. + assert orig_container.get('Node.Name') == new_container.get('Node.Name') + + orig_container = new_container + def test_execute_convergence_plan_when_containers_are_stopped(self): service = self.create_service( 'db', diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index b72fb53a81f..9427f3d0dad 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -20,7 +20,7 @@ from compose.const import COMPOSEFILE_V2_3 as V2_3 from compose.const import COMPOSEFILE_V3_0 as V3_0 from compose.const import COMPOSEFILE_V3_2 as V3_2 -from compose.const import COMPOSEFILE_V3_3 as V3_3 +from compose.const import COMPOSEFILE_V3_5 as V3_5 from compose.const import LABEL_PROJECT from compose.progress_stream import stream_output from compose.service import Service @@ -47,7 +47,7 @@ def format_link(link): def engine_max_version(): if 'DOCKER_VERSION' not in os.environ: - return V3_3 + return V3_5 version = os.environ['DOCKER_VERSION'].partition('-')[0] if version_lt(version, '1.10'): return V1 @@ -57,7 +57,7 @@ def engine_max_version(): return V2_1 if version_lt(version, '17.06'): return V3_2 - return V3_3 + return V3_5 def min_version_skip(version): diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 00ba6c2c650..d519deb9043 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1137,9 +1137,12 @@ def test_load_with_multiple_files_v3_2(self): details = config.ConfigDetails('.', [base_file, override_file]) service_dicts = config.load(details).services svc_volumes = map(lambda v: v.repr(), service_dicts[0]['volumes']) - assert sorted(svc_volumes) == sorted( - ['/anonymous', '/c:/b:rw', 'vol:/x:ro'] - ) + for vol in svc_volumes: + assert vol in [ + '/anonymous', + '/c:/b:rw', + {'source': 'vol', 'target': '/x', 'type': 'volume', 'read_only': True} + ] @mock.patch.dict(os.environ) def test_volume_mode_override(self): @@ -1223,6 +1226,50 @@ def test_undeclared_volume_v1(self): assert volume.external == 'data0028' assert volume.is_named_volume + def test_volumes_long_syntax(self): + base_file = config.ConfigFile( + 'base.yaml', { + 'version': '2.3', + 'services': { + 'web': { + 'image': 'busybox:latest', + 'volumes': [ + { + 'target': '/anonymous', 'type': 'volume' + }, { + 'source': '/abc', 'target': '/xyz', 'type': 'bind' + }, { + 'source': '\\\\.\\pipe\\abcd', 'target': '/named_pipe', 'type': 'npipe' + }, { + 'type': 'tmpfs', 'target': '/tmpfs' + } + ] + }, + }, + }, + ) + details = config.ConfigDetails('.', [base_file]) + config_data = config.load(details) + volumes = config_data.services[0].get('volumes') + anon_volume = [v for v in volumes if v.target == '/anonymous'][0] + tmpfs_mount = [v for v in volumes if v.type == 'tmpfs'][0] + host_mount = [v for v in volumes if v.type == 'bind'][0] + npipe_mount = [v for v in volumes if v.type == 'npipe'][0] + + assert anon_volume.type == 'volume' + assert not anon_volume.is_named_volume + + assert tmpfs_mount.target == '/tmpfs' + assert not tmpfs_mount.is_named_volume + + assert host_mount.source == os.path.normpath('/abc') + assert host_mount.target == '/xyz' + assert not host_mount.is_named_volume + + assert npipe_mount.source == '\\\\.\\pipe\\abcd' + assert npipe_mount.target == '/named_pipe' + assert not npipe_mount.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/service_test.py b/tests/unit/service_test.py index 16670cff5fc..24ed60e94e8 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -939,7 +939,7 @@ def test_get_container_data_volumes(self): VolumeSpec.parse('imagedata:/mnt/image/data:rw'), ] - volumes = get_container_data_volumes(container, options, ['/dev/tmpfs']) + volumes, _ = get_container_data_volumes(container, options, ['/dev/tmpfs'], []) assert sorted(volumes) == sorted(expected) def test_merge_volume_bindings(self): @@ -975,7 +975,7 @@ def test_merge_volume_bindings(self): 'existingvolume:/existing/volume:rw', ] - binds, affinity = merge_volume_bindings(options, ['/dev/tmpfs'], previous_container) + binds, affinity = merge_volume_bindings(options, ['/dev/tmpfs'], previous_container, []) assert sorted(binds) == sorted(expected) assert affinity == {'affinity:container': '=cdefab'} From 8155ddc7add99edd1c9a366f44c65ba4d62589a3 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 6 Dec 2017 16:48:14 -0800 Subject: [PATCH 3138/4072] Add support for custom names for networks, secrets, configs Finalize v3.5 schema Signed-off-by: Joffrey F --- compose/config/config.py | 5 +- compose/config/config_schema_v2.1.json | 3 +- compose/config/config_schema_v2.2.json | 3 +- compose/config/config_schema_v2.3.json | 3 +- compose/config/config_schema_v3.5.json | 58 ++++++++++++++----- compose/config/serialize.py | 11 +++- compose/config/types.py | 5 +- compose/network.py | 23 ++++---- compose/project.py | 2 +- docker-compose.spec | 5 ++ tests/acceptance/cli_test.py | 16 +++++ .../networks/external-networks-v3-5.yml | 17 ++++++ tests/integration/project_test.py | 37 ++++++++++++ tests/unit/config/config_test.py | 54 +++++++++++++---- 14 files changed, 198 insertions(+), 44 deletions(-) create mode 100644 tests/fixtures/networks/external-networks-v3-5.yml diff --git a/compose/config/config.py b/compose/config/config.py index 9b41305360e..98719d6bab9 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -410,12 +410,11 @@ def load_mapping(config_files, get_func, entity_type, working_dir=None): external = config.get('external') if external: - name_field = 'name' if entity_type == 'Volume' else 'external_name' validate_external(entity_type, name, config, config_file.version) if isinstance(external, dict): - config[name_field] = external.get('name') + config['name'] = external.get('name') elif not config.get('name'): - config[name_field] = name + config['name'] = name if 'driver_opts' in config: config['driver_opts'] = build_string_dict( diff --git a/compose/config/config_schema_v2.1.json b/compose/config/config_schema_v2.1.json index 6b74f0ed699..15b78e5db79 100644 --- a/compose/config/config_schema_v2.1.json +++ b/compose/config/config_schema_v2.1.json @@ -350,7 +350,8 @@ }, "internal": {"type": "boolean"}, "enable_ipv6": {"type": "boolean"}, - "labels": {"$ref": "#/definitions/list_or_dict"} + "labels": {"$ref": "#/definitions/list_or_dict"}, + "name": {"type": "string"} }, "additionalProperties": false }, diff --git a/compose/config/config_schema_v2.2.json b/compose/config/config_schema_v2.2.json index 21343b8932c..7a3eed0a9f7 100644 --- a/compose/config/config_schema_v2.2.json +++ b/compose/config/config_schema_v2.2.json @@ -357,7 +357,8 @@ }, "internal": {"type": "boolean"}, "enable_ipv6": {"type": "boolean"}, - "labels": {"$ref": "#/definitions/list_or_dict"} + "labels": {"$ref": "#/definitions/list_or_dict"}, + "name": {"type": "string"} }, "additionalProperties": false }, diff --git a/compose/config/config_schema_v2.3.json b/compose/config/config_schema_v2.3.json index d50df3e81d4..7c0e5480721 100644 --- a/compose/config/config_schema_v2.3.json +++ b/compose/config/config_schema_v2.3.json @@ -393,7 +393,8 @@ }, "internal": {"type": "boolean"}, "enable_ipv6": {"type": "boolean"}, - "labels": {"$ref": "#/definitions/list_or_dict"} + "labels": {"$ref": "#/definitions/list_or_dict"}, + "name": {"type": "string"} }, "additionalProperties": false }, diff --git a/compose/config/config_schema_v3.5.json b/compose/config/config_schema_v3.5.json index d94b3feb090..565da019375 100644 --- a/compose/config/config_schema_v3.5.json +++ b/compose/config/config_schema_v3.5.json @@ -64,6 +64,7 @@ } }, + "patternProperties": {"^x-": {}}, "additionalProperties": false, "definitions": { @@ -154,6 +155,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}, @@ -281,7 +283,6 @@ { "type": "object", "required": ["type"], - "additionalProperties": false, "properties": { "type": {"type": "string"}, "source": {"type": "string"}, @@ -300,7 +301,8 @@ "nocopy": {"type": "boolean"} } } - } + }, + "additionalProperties": false } ], "uniqueItems": true @@ -317,7 +319,7 @@ "additionalProperties": false, "properties": { "disable": {"type": "boolean"}, - "interval": {"type": "string"}, + "interval": {"type": "string", "format": "duration"}, "retries": {"type": "number"}, "test": { "oneOf": [ @@ -325,7 +327,8 @@ {"type": "array", "items": {"type": "string"}} ] }, - "timeout": {"type": "string"} + "timeout": {"type": "string", "format": "duration"}, + "start_period": {"type": "string", "format": "duration"} } }, "deployment": { @@ -353,8 +356,23 @@ "resources": { "type": "object", "properties": { - "limits": {"$ref": "#/definitions/resource"}, - "reservations": {"$ref": "#/definitions/resource"} + "limits": { + "type": "object", + "properties": { + "cpus": {"type": "string"}, + "memory": {"type": "string"} + }, + "additionalProperties": false + }, + "reservations": { + "type": "object", + "properties": { + "cpus": {"type": "string"}, + "memory": {"type": "string"}, + "generic_resources": {"$ref": "#/definitions/generic_resources"} + }, + "additionalProperties": false + } }, "additionalProperties": false }, @@ -389,20 +407,30 @@ "additionalProperties": false }, - "resource": { - "id": "#/definitions/resource", - "type": "object", - "properties": { - "cpus": {"type": "string"}, - "memory": {"type": "string"} - }, - "additionalProperties": false + "generic_resources": { + "id": "#/definitions/generic_resources", + "type": "array", + "items": { + "type": "object", + "properties": { + "discrete_resource_spec": { + "type": "object", + "properties": { + "kind": {"type": "string"}, + "value": {"type": "number"} + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } }, "network": { "id": "#/definitions/network", "type": ["object", "null"], "properties": { + "name": {"type": "string"}, "driver": {"type": "string"}, "driver_opts": { "type": "object", @@ -469,6 +497,7 @@ "id": "#/definitions/secret", "type": "object", "properties": { + "name": {"type": "string"}, "file": {"type": "string"}, "external": { "type": ["boolean", "object"], @@ -485,6 +514,7 @@ "id": "#/definitions/config", "type": "object", "properties": { + "name": {"type": "string"}, "file": {"type": "string"}, "external": { "type": ["boolean", "object"], diff --git a/compose/config/serialize.py b/compose/config/serialize.py index 4982f8e34c0..3ab43fc5935 100644 --- a/compose/config/serialize.py +++ b/compose/config/serialize.py @@ -11,6 +11,7 @@ from compose.const import COMPOSEFILE_V3_0 as V3_0 from compose.const import COMPOSEFILE_V3_2 as V3_2 from compose.const import COMPOSEFILE_V3_4 as V3_4 +from compose.const import COMPOSEFILE_V3_5 as V3_5 def serialize_config_type(dumper, data): @@ -58,6 +59,7 @@ def denormalize_config(config, image_digests=None): service_dict.pop('name'): service_dict for service_dict in denormalized_services } + for key in ('networks', 'volumes', 'secrets', 'configs'): config_dict = getattr(config, key) if not config_dict: @@ -68,7 +70,8 @@ def denormalize_config(config, image_digests=None): del conf['external_name'] if 'name' in conf: - if config.version < V2_1 or (config.version >= V3_0 and config.version < V3_4): + if config.version < V2_1 or ( + config.version >= V3_0 and config.version < v3_introduced_name_key(key)): del conf['name'] elif 'external' in conf: conf['external'] = True @@ -76,6 +79,12 @@ def denormalize_config(config, image_digests=None): return result +def v3_introduced_name_key(key): + if key == 'volumes': + return V3_4 + return V3_5 + + def serialize_config(config, image_digests=None): return yaml.safe_dump( denormalize_config(config, image_digests), diff --git a/compose/config/types.py b/compose/config/types.py index c134bd7ca32..daf25f7007c 100644 --- a/compose/config/types.py +++ b/compose/config/types.py @@ -293,17 +293,18 @@ def merge_field(self): return self.alias -class ServiceConfigBase(namedtuple('_ServiceConfigBase', 'source target uid gid mode')): +class ServiceConfigBase(namedtuple('_ServiceConfigBase', 'source target uid gid mode name')): @classmethod def parse(cls, spec): if isinstance(spec, six.string_types): - return cls(spec, None, None, None, None) + return cls(spec, None, None, None, None, None) return cls( spec.get('source'), spec.get('target'), spec.get('uid'), spec.get('gid'), spec.get('mode'), + spec.get('name') ) @property diff --git a/compose/network.py b/compose/network.py index ee5939c1593..95e2bf60e5f 100644 --- a/compose/network.py +++ b/compose/network.py @@ -25,21 +25,22 @@ class Network(object): def __init__(self, client, project, name, driver=None, driver_opts=None, - ipam=None, external_name=None, internal=False, enable_ipv6=False, - labels=None): + ipam=None, external=False, internal=False, enable_ipv6=False, + labels=None, custom_name=False): 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 + self.external = external self.internal = internal self.enable_ipv6 = enable_ipv6 self.labels = labels + self.custom_name = custom_name def ensure(self): - if self.external_name: + if self.external: try: self.inspect() log.debug( @@ -51,7 +52,7 @@ def ensure(self): 'Network {name} declared as external, but could' ' not be found. Please create the network manually' ' using `{command} {name}` and try again.'.format( - name=self.external_name, + name=self.full_name, command='docker network create' ) ) @@ -83,7 +84,7 @@ def ensure(self): ) def remove(self): - if self.external_name: + if self.external: log.info("Network %s is external, skipping", self.full_name) return @@ -95,8 +96,8 @@ def inspect(self): @property def full_name(self): - if self.external_name: - return self.external_name + if self.custom_name: + return self.name return '{0}_{1}'.format(self.project, self.name) @property @@ -203,14 +204,16 @@ def build_networks(name, config_data, client): network_config = config_data.networks or {} networks = { network_name: Network( - client=client, project=name, name=network_name, + client=client, project=name, + name=data.get('name', network_name), driver=data.get('driver'), driver_opts=data.get('driver_opts'), ipam=data.get('ipam'), - external_name=data.get('external_name'), + external=bool(data.get('external', False)), internal=data.get('internal'), enable_ipv6=data.get('enable_ipv6'), labels=data.get('labels'), + custom_name=data.get('name') is not None, ) for network_name, data in network_config.items() } diff --git a/compose/project.py b/compose/project.py index 4115763863f..11ee4a0b73c 100644 --- a/compose/project.py +++ b/compose/project.py @@ -648,7 +648,7 @@ def get_secrets(service, service_secrets, secret_defs): "Service \"{service}\" uses an undefined secret \"{secret}\" " .format(service=service, secret=secret.source)) - if secret_def.get('external_name'): + if secret_def.get('external'): 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)) diff --git a/docker-compose.spec b/docker-compose.spec index 9c46421f0a5..83d7389f378 100644 --- a/docker-compose.spec +++ b/docker-compose.spec @@ -67,6 +67,11 @@ exe = EXE(pyz, 'compose/config/config_schema_v3.4.json', 'DATA' ), + ( + 'compose/config/config_schema_v3.5.json', + 'compose/config/config_schema_v3.5.json', + 'DATA' + ), ( 'compose/GITSHA', 'compose/GITSHA', diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index ce0e75055e5..502907fe72e 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -350,6 +350,22 @@ def test_config_external_volume_v3_4(self): } } + def test_config_external_network_v3_5(self): + self.base_dir = 'tests/fixtures/networks' + result = self.dispatch(['-f', 'external-networks-v3-5.yml', 'config']) + json_result = yaml.load(result.stdout) + assert 'networks' in json_result + assert json_result['networks'] == { + 'foo': { + 'external': True, + 'name': 'some_foo', + }, + 'bar': { + 'external': True, + 'name': 'some_bar', + }, + } + def test_config_v1(self): self.base_dir = 'tests/fixtures/v1-config' result = self.dispatch(['config']) diff --git a/tests/fixtures/networks/external-networks-v3-5.yml b/tests/fixtures/networks/external-networks-v3-5.yml new file mode 100644 index 00000000000..9ac7b14b5f7 --- /dev/null +++ b/tests/fixtures/networks/external-networks-v3-5.yml @@ -0,0 +1,17 @@ +version: "3.5" + +services: + web: + image: busybox + command: top + networks: + - foo + - bar + +networks: + foo: + external: true + name: some_foo + bar: + external: + name: some_bar diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 6686d96cce5..82e0adab369 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -953,6 +953,43 @@ def test_up_with_network_link_local_ips(self): assert 'LinkLocalIPs' in ipam_config assert ipam_config['LinkLocalIPs'] == ['169.254.8.8'] + @v2_1_only() + def test_up_with_custom_name_resources(self): + config_data = build_config( + version=V2_2, + services=[{ + 'name': 'web', + 'volumes': [VolumeSpec.parse('foo:/container-path')], + 'networks': {'foo': {}}, + 'image': 'busybox:latest' + }], + networks={ + 'foo': { + 'name': 'zztop', + 'labels': {'com.docker.compose.test_value': 'sharpdressedman'} + } + }, + volumes={ + 'foo': { + 'name': 'acdc', + 'labels': {'com.docker.compose.test_value': 'thefuror'} + } + } + ) + + project = Project.from_config( + client=self.client, + name='composetest', + config_data=config_data + ) + + project.up(detached=True) + network = [n for n in self.client.networks() if n['Name'] == 'zztop'][0] + volume = [v for v in self.client.volumes()['Volumes'] if v['Name'] == 'acdc'][0] + + assert network['Labels']['com.docker.compose.test_value'] == 'sharpdressedman' + assert volume['Labels']['com.docker.compose.test_value'] == 'thefuror' + @v2_1_only() def test_up_with_isolation(self): self.require_api_version('1.24') diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index d519deb9043..7029fcb088e 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -432,6 +432,40 @@ def test_load_config_service_labels(self): 'label_key': 'label_val' } + def test_load_config_custom_resource_names(self): + base_file = config.ConfigFile( + 'base.yaml', { + 'version': '3.5', + 'volumes': { + 'abc': { + 'name': 'xyz' + } + }, + 'networks': { + 'abc': { + 'name': 'xyz' + } + }, + 'secrets': { + 'abc': { + 'name': 'xyz' + } + }, + 'configs': { + 'abc': { + 'name': 'xyz' + } + } + } + ) + details = config.ConfigDetails('.', [base_file]) + loaded_config = config.load(details) + + assert loaded_config.networks['abc'] == {'name': 'xyz'} + assert loaded_config.volumes['abc'] == {'name': 'xyz'} + assert loaded_config.secrets['abc']['name'] == 'xyz' + assert loaded_config.configs['abc']['name'] == 'xyz' + def test_load_config_volume_and_network_labels(self): base_file = config.ConfigFile( 'base.yaml', @@ -2539,8 +2573,8 @@ def test_load_secrets(self): 'name': 'web', 'image': 'example/web', 'secrets': [ - types.ServiceSecret('one', None, None, None, None), - types.ServiceSecret('source', 'target', '100', '200', 0o777), + types.ServiceSecret('one', None, None, None, None, None), + types.ServiceSecret('source', 'target', '100', '200', 0o777, None), ], }, ] @@ -2586,8 +2620,8 @@ def test_load_secrets_multi_file(self): 'name': 'web', 'image': 'example/web', 'secrets': [ - types.ServiceSecret('one', None, None, None, None), - types.ServiceSecret('source', 'target', '100', '200', 0o777), + types.ServiceSecret('one', None, None, None, None, None), + types.ServiceSecret('source', 'target', '100', '200', 0o777, None), ], }, ] @@ -2624,8 +2658,8 @@ def test_load_configs(self): 'name': 'web', 'image': 'example/web', 'configs': [ - types.ServiceConfig('one', None, None, None, None), - types.ServiceConfig('source', 'target', '100', '200', 0o777), + types.ServiceConfig('one', None, None, None, None, None), + types.ServiceConfig('source', 'target', '100', '200', 0o777, None), ], }, ] @@ -2671,8 +2705,8 @@ def test_load_configs_multi_file(self): 'name': 'web', 'image': 'example/web', 'configs': [ - types.ServiceConfig('one', None, None, None, None), - types.ServiceConfig('source', 'target', '100', '200', 0o777), + types.ServiceConfig('one', None, None, None, None, None), + types.ServiceConfig('source', 'target', '100', '200', 0o777, None), ], }, ] @@ -3131,7 +3165,7 @@ def test_interpolation_secrets_section(self): assert config_dict.secrets == { 'secretdata': { 'external': {'name': 'baz.bar'}, - 'external_name': 'baz.bar' + 'name': 'baz.bar' } } @@ -3149,7 +3183,7 @@ def test_interpolation_configs_section(self): assert config_dict.configs == { 'configdata': { 'external': {'name': 'baz.bar'}, - 'external_name': 'baz.bar' + 'name': 'baz.bar' } } From 700d6aca545fb32eda9cbdb6448f2f37dd66f9e8 Mon Sep 17 00:00:00 2001 From: Alexey Rokhin Date: Wed, 17 May 2017 15:21:35 +0300 Subject: [PATCH 3139/4072] Fix testcases.py formatting Signed-off-by: Alexey Rokhin --- tests/integration/testcases.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index b72fb53a81f..8435f97ddcf 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -75,6 +75,7 @@ def v2_1_only(): return min_version_skip(V2_1) + def v2_2_only(): return min_version_skip(V2_2) @@ -83,6 +84,7 @@ def v2_3_only(): return min_version_skip(V2_3) + def v3_only(): return min_version_skip(V3_0) From 9b91f3431b6190e03cd512cc49d49f996f075e2a Mon Sep 17 00:00:00 2001 From: Alexey Rokhin Date: Wed, 17 May 2017 16:18:28 +0300 Subject: [PATCH 3140/4072] skip cpu_percent test for Linux Signed-off-by: Alexey Rokhin --- 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 3ddf991b304..2583e39e828 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -28,6 +28,7 @@ from compose.const import LABEL_PROJECT from compose.const import LABEL_SERVICE from compose.const import LABEL_VERSION +from compose.const import IS_WINDOWS_PLATFORM from compose.container import Container from compose.errors import OperationFailedError from compose.project import OneOffFilter From 3d57f702f18f29c80ae90391b43b94c1c19402e7 Mon Sep 17 00:00:00 2001 From: Alexey Rokhin Date: Wed, 17 May 2017 16:42:43 +0300 Subject: [PATCH 3141/4072] service_test.py reorder imports Signed-off-by: Alexey Rokhin --- 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 2583e39e828..3ddf991b304 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -28,7 +28,6 @@ from compose.const import LABEL_PROJECT from compose.const import LABEL_SERVICE from compose.const import LABEL_VERSION -from compose.const import IS_WINDOWS_PLATFORM from compose.container import Container from compose.errors import OperationFailedError from compose.project import OneOffFilter From 5844dbb38e9e7cc0835cabbf000c3937b80c5c04 Mon Sep 17 00:00:00 2001 From: Joel Barciauskas Date: Wed, 12 Apr 2017 17:45:09 -0400 Subject: [PATCH 3142/4072] Add --quiet parameter to docker-compose pull, using existing silent flag Signed-off-by: Joel Barciauskas --- compose/project.py | 2 +- tests/acceptance/cli_test.py | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/compose/project.py b/compose/project.py index c8b57edd209..fa536f02ccf 100644 --- a/compose/project.py +++ b/compose/project.py @@ -498,7 +498,7 @@ def pull(self, service_names=None, ignore_pull_failures=False, parallel_pull=Fal if parallel_pull: def pull_service(service): - service.pull(ignore_pull_failures, True) + service.pull(ignore_pull_failures, True, silent=silent) _, errors = parallel.parallel_execute( services, diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index bba2238e7bc..746973a2a16 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -511,6 +511,10 @@ def test_pull_with_quiet(self): assert self.dispatch(['pull', '--quiet']).stderr == '' assert self.dispatch(['pull', '--quiet']).stdout == '' + def test_pull_with_quiet(self): + assert self.dispatch(['pull', '--quiet']).stderr == '' + assert self.dispatch(['pull', '--quiet']).stdout == '' + def test_build_plain(self): self.base_dir = 'tests/fixtures/simple-dockerfile' self.dispatch(['build', 'simple']) From 4286315bc907178efb0ce9d53bb28414ebbcd477 Mon Sep 17 00:00:00 2001 From: NikitaVlaznev Date: Mon, 19 Jun 2017 17:05:19 +0300 Subject: [PATCH 3143/4072] Fix double silent argument value Fix for "TypeError: pull() got multiple values for keyword argument 'silent'." This change https://github.com/docker/compose/commit/e9b6cc23fcf01d4768c7e082b7bc91b43ff84e7e caused additional value to be passed for the 'silent' argument, that was already passed there: https://github.com/docker/compose/commit/f85da99ef3273794e855afda8678174419d3bf4f Signed-off-by: Nikita Vlaznev --- compose/project.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/project.py b/compose/project.py index fa536f02ccf..9e0a7b02fae 100644 --- a/compose/project.py +++ b/compose/project.py @@ -498,7 +498,7 @@ def pull(self, service_names=None, ignore_pull_failures=False, parallel_pull=Fal if parallel_pull: def pull_service(service): - service.pull(ignore_pull_failures, True, silent=silent) + service.pull(ignore_pull_failures, silent=silent) _, errors = parallel.parallel_execute( services, From d1289554d505793d9ffc327df81990c475d482bf Mon Sep 17 00:00:00 2001 From: Evan Shaw Date: Sat, 1 Jul 2017 13:40:02 +1200 Subject: [PATCH 3144/4072] Always silence pull output with --parallel This is how things were prior to the addition of the --quiet flag. Making it not silent produces output that's weird and difficult to read. Signed-off-by: Evan Shaw --- compose/project.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/project.py b/compose/project.py index 9e0a7b02fae..c8b57edd209 100644 --- a/compose/project.py +++ b/compose/project.py @@ -498,7 +498,7 @@ def pull(self, service_names=None, ignore_pull_failures=False, parallel_pull=Fal if parallel_pull: def pull_service(service): - service.pull(ignore_pull_failures, silent=silent) + service.pull(ignore_pull_failures, True) _, errors = parallel.parallel_execute( services, From 2daf3628e9dda4b58e5c38cd5c2590654ba93329 Mon Sep 17 00:00:00 2001 From: aronahl Date: Wed, 9 Aug 2017 19:44:12 -0400 Subject: [PATCH 3145/4072] Fix exit code 0 upon parallel pull failure. Signed-off-by: Aaron Nall --- tests/acceptance/cli_test.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 746973a2a16..22756bd3da5 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -515,6 +515,20 @@ def test_pull_with_quiet(self): assert self.dispatch(['pull', '--quiet']).stderr == '' assert self.dispatch(['pull', '--quiet']).stdout == '' + def test_pull_with_parallel_failure(self): + result = self.dispatch([ + '-f', 'ignore-pull-failures.yml', 'pull', '--parallel'], + returncode=1 + ) + + self.assertRegexpMatches(result.stderr, re.compile('^Pulling simple', re.MULTILINE)) + self.assertRegexpMatches(result.stderr, re.compile('^Pulling another', re.MULTILINE)) + self.assertRegexpMatches(result.stderr, + re.compile('^ERROR: for another .*does not exist.*', re.MULTILINE)) + self.assertRegexpMatches(result.stderr, + re.compile('''^(ERROR: )?(b')?.* nonexisting-image''', + re.MULTILINE)) + def test_build_plain(self): self.base_dir = 'tests/fixtures/simple-dockerfile' self.dispatch(['build', 'simple']) From c8b2dd2fb1346e8efcdcdb6434d6ec860c9581f9 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 25 Aug 2017 18:09:06 -0700 Subject: [PATCH 3146/4072] Add support for extension fields in v2.x and v3.4 Signed-off-by: Joffrey F --- compose/config/config_schema_v3.5.json | 1 + 1 file changed, 1 insertion(+) diff --git a/compose/config/config_schema_v3.5.json b/compose/config/config_schema_v3.5.json index fa95d6a2457..5400cd99f28 100644 --- a/compose/config/config_schema_v3.5.json +++ b/compose/config/config_schema_v3.5.json @@ -64,6 +64,7 @@ } }, + "patternProperties": {"^x-": {}}, "additionalProperties": false, "definitions": { From e1db4f6e191df1abe5c6d4f6ecd85e9dd3d6fbe8 Mon Sep 17 00:00:00 2001 From: Guillermo Arribas Date: Fri, 6 Oct 2017 19:12:59 -0300 Subject: [PATCH 3147/4072] Build labels option: array form produces unmarshal error (fixes #5183) Signed-off-by: Guillermo Arribas --- compose/service.py | 3 ++- tests/integration/service_test.py | 19 ++++++++++++++++++- tests/unit/service_test.py | 4 ++-- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/compose/service.py b/compose/service.py index 1a18c66549d..e2f72aa5ac3 100644 --- a/compose/service.py +++ b/compose/service.py @@ -23,6 +23,7 @@ from . import progress_stream from .config import DOCKER_CONFIG_KEYS from .config import merge_environment +from .config.config import parse_labels from .config.errors import DependencyError from .config.types import ServicePort from .config.types import VolumeSpec @@ -916,7 +917,7 @@ def build(self, no_cache=False, pull=False, force_rm=False, build_args_override= nocache=no_cache, dockerfile=build_opts.get('dockerfile', None), cache_from=build_opts.get('cache_from', None), - labels=build_opts.get('labels', None), + labels=parse_labels(build_opts.get('labels', None)), buildargs=build_args, network_mode=build_opts.get('network', None), target=build_opts.get('target', None), diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 3ddf991b304..6cf8ddaa96d 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -761,7 +761,7 @@ def test_build_with_build_args_override(self): assert service.image() assert "build_version=2" in service.image()['ContainerConfig']['Cmd'] - def test_build_with_build_labels(self): + def test_build_with_build_labels_dict(self): base_dir = tempfile.mkdtemp() self.addCleanup(shutil.rmtree, base_dir) @@ -778,6 +778,23 @@ def test_build_with_build_labels(self): assert service.image() assert service.image()['Config']['Labels']['com.docker.compose.test'] == 'true' + def test_build_with_build_labels_list(self): + base_dir = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, base_dir) + + with open(os.path.join(base_dir, 'Dockerfile'), 'w') as f: + f.write('FROM busybox\n') + + service = self.create_service('buildlabels', build={ + 'context': text_type(base_dir), + 'labels': ['com.docker.compose.test=true'] + }) + service.build() + self.addCleanup(self.client.remove_image, service.image_name) + + assert service.image() + assert service.image()['Config']['Labels']['com.docker.compose.test'] == 'true' + @no_cluster('Container networks not on Swarm') def test_build_with_network(self): base_dir = tempfile.mkdtemp() diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 7d61807ba00..5c5c2bf677e 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -473,7 +473,7 @@ def test_create_container(self): nocache=False, rm=True, buildargs={}, - labels=None, + labels={}, cache_from=None, network_mode=None, target=None, @@ -514,7 +514,7 @@ def test_ensure_image_exists_force_build(self): nocache=False, rm=True, buildargs={}, - labels=None, + labels={}, cache_from=None, network_mode=None, target=None, From 6dfd4693548520b3ca4d1fb284984d9781433fc5 Mon Sep 17 00:00:00 2001 From: Guillermo Arribas Date: Tue, 10 Oct 2017 11:55:14 -0300 Subject: [PATCH 3148/4072] Progress markers are not shown correctly for docker-compose up (fixes #4801) Signed-off-by: Guillermo Arribas --- compose/parallel.py | 23 ++++++++++++++++------- compose/project.py | 25 ++++++++++++++++++++++++- compose/service.py | 23 ++++++++++++----------- tests/unit/service_test.py | 2 +- 4 files changed, 53 insertions(+), 20 deletions(-) diff --git a/compose/parallel.py b/compose/parallel.py index d455711ddc9..f271561ff05 100644 --- a/compose/parallel.py +++ b/compose/parallel.py @@ -26,7 +26,7 @@ STOP = object() -def parallel_execute(objects, func, get_name, msg, get_deps=None, limit=None): +def parallel_execute(objects, func, get_name, msg, get_deps=None, limit=None, parent_objects=None): """Runs func on objects in parallel while ensuring that func is ran on object only after it is ran on all its dependencies. @@ -37,9 +37,19 @@ def parallel_execute(objects, func, get_name, msg, get_deps=None, limit=None): stream = get_output_stream(sys.stderr) writer = ParallelStreamWriter(stream, msg) - for obj in objects: + + if parent_objects: + display_objects = list(parent_objects) + else: + display_objects = objects + + for obj in display_objects: writer.add_object(get_name(obj)) - writer.write_initial() + + # write data in a second loop to consider all objects for width alignment + # and avoid duplicates when parent_objects exists + for obj in objects: + writer.write_initial(get_name(obj)) events = parallel_execute_iter(objects, func, get_deps, limit) @@ -237,12 +247,11 @@ def add_object(self, obj_index): self.lines.append(obj_index) self.width = max(self.width, len(obj_index)) - def write_initial(self): + def write_initial(self, obj_index): if self.msg is None: return - for line in self.lines: - self.stream.write("{} {:<{width}} ... \r\n".format(self.msg, line, - width=self.width)) + self.stream.write("{} {:<{width}} ... \r\n".format( + self.msg, self.lines[self.lines.index(obj_index)], width=self.width)) self.stream.flush() def _write_ansi(self, obj_index, status): diff --git a/compose/project.py b/compose/project.py index c8b57edd209..f6bd30a8869 100644 --- a/compose/project.py +++ b/compose/project.py @@ -29,6 +29,7 @@ from .service import NetworkMode from .service import PidMode from .service import Service +from .service import ServiceName from .service import ServiceNetworkMode from .service import ServicePidMode from .utils import microseconds_from_time_nano @@ -190,6 +191,25 @@ def get_services_without_duplicate(self, service_names=None, include_deps=False) service.remove_duplicate_containers() return services + def get_scaled_services(self, services, scale_override): + """ + Returns a list of this project's services as scaled ServiceName objects. + + services: a list of Service objects + scale_override: a dict with the scale to apply to each service (k: service_name, v: scale) + """ + service_names = [] + for service in services: + if service.name in scale_override: + scale = scale_override[service.name] + else: + scale = service.scale_num + + for i in range(1, scale + 1): + service_names.append(ServiceName(self.name, service.name, i)) + + return service_names + def get_links(self, service_dict): links = [] if 'links' in service_dict: @@ -430,15 +450,18 @@ def up(self, for svc in services: svc.ensure_image_exists(do_build=do_build) plans = self._get_convergence_plans(services, strategy) + scaled_services = self.get_scaled_services(services, scale_override) def do(service): + return service.execute_convergence_plan( plans[service.name], timeout=timeout, detached=detached, scale_override=scale_override.get(service.name), rescale=rescale, - start=start + start=start, + project_services=scaled_services ) def get_deps(service): diff --git a/compose/service.py b/compose/service.py index e2f72aa5ac3..22a7ca53af5 100644 --- a/compose/service.py +++ b/compose/service.py @@ -379,11 +379,11 @@ def _containers_have_diverged(self, containers): return has_diverged - def _execute_convergence_create(self, scale, detached, start): + def _execute_convergence_create(self, scale, detached, start, project_services=None): i = self._next_container_number() def create_and_start(service, n): - container = service.create_container(number=n) + container = service.create_container(number=n, quiet=True) if not detached: container.attach_log_stream() if start: @@ -391,10 +391,11 @@ def create_and_start(service, n): return container containers, errors = parallel_execute( - range(i, i + scale), - lambda n: create_and_start(self, n), - lambda n: self.get_container_name(n), + [ServiceName(self.project, self.name, index) for index in range(i, i + scale)], + lambda service_name: create_and_start(self, service_name.number), + lambda service_name: self.get_container_name(service_name.service, service_name.number), "Creating", + parent_objects=project_services ) for error in errors.values(): raise OperationFailedError(error) @@ -433,7 +434,7 @@ def _execute_convergence_start(self, containers, scale, timeout, detached, start if start: _, errors = parallel_execute( containers, - lambda c: self.start_container_if_stopped(c, attach_logs=not detached), + lambda c: self.start_container_if_stopped(c, attach_logs=not detached, quiet=True), lambda c: c.name, "Starting", ) @@ -460,7 +461,7 @@ def stop_and_remove(container): ) def execute_convergence_plan(self, plan, timeout=None, detached=False, - start=True, scale_override=None, rescale=True): + start=True, scale_override=None, rescale=True, project_services=None): (action, containers) = plan scale = scale_override if scale_override is not None else self.scale_num containers = sorted(containers, key=attrgetter('number')) @@ -469,7 +470,7 @@ def execute_convergence_plan(self, plan, timeout=None, detached=False, if action == 'create': return self._execute_convergence_create( - scale, detached, start + scale, detached, start, project_services ) # The create action needs always needs an initial scale, but otherwise, @@ -742,7 +743,7 @@ def _get_container_create_options( container_options.update(override_options) if not container_options.get('name'): - container_options['name'] = self.get_container_name(number, one_off) + container_options['name'] = self.get_container_name(self.name, number, one_off) container_options.setdefault('detach', True) @@ -961,12 +962,12 @@ def labels(self, one_off=False): def custom_container_name(self): return self.options.get('container_name') - def get_container_name(self, number, one_off=False): + def get_container_name(self, service_name, number, one_off=False): if self.custom_container_name and not one_off: return self.custom_container_name container_name = build_container_name( - self.project, self.name, number, one_off, + self.project, service_name, number, one_off, ) ext_links_origins = [l.split(':')[0] for l in self.options.get('external_links', [])] if container_name in ext_links_origins: diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 5c5c2bf677e..50b09c87ffd 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -179,7 +179,7 @@ def test_self_reference_external_link(self): external_links=['default_foo_1'] ) with self.assertRaises(DependencyError): - service.get_container_name(1) + service.get_container_name('foo', 1) def test_mem_reservation(self): self.mock_client.create_host_config.return_value = {} From 8a08eb668876e73d5f18983fb591cce626ca4b27 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 16 Oct 2017 11:43:06 -0700 Subject: [PATCH 3149/4072] Move build labels parsing to config module Signed-off-by: Joffrey F --- compose/service.py | 3 +-- tests/integration/service_test.py | 19 +------------------ tests/unit/service_test.py | 4 ++-- 3 files changed, 4 insertions(+), 22 deletions(-) diff --git a/compose/service.py b/compose/service.py index 22a7ca53af5..48d428cb827 100644 --- a/compose/service.py +++ b/compose/service.py @@ -23,7 +23,6 @@ from . import progress_stream from .config import DOCKER_CONFIG_KEYS from .config import merge_environment -from .config.config import parse_labels from .config.errors import DependencyError from .config.types import ServicePort from .config.types import VolumeSpec @@ -918,7 +917,7 @@ def build(self, no_cache=False, pull=False, force_rm=False, build_args_override= nocache=no_cache, dockerfile=build_opts.get('dockerfile', None), cache_from=build_opts.get('cache_from', None), - labels=parse_labels(build_opts.get('labels', None)), + labels=build_opts.get('labels', None), buildargs=build_args, network_mode=build_opts.get('network', None), target=build_opts.get('target', None), diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 6cf8ddaa96d..3ddf991b304 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -761,7 +761,7 @@ def test_build_with_build_args_override(self): assert service.image() assert "build_version=2" in service.image()['ContainerConfig']['Cmd'] - def test_build_with_build_labels_dict(self): + def test_build_with_build_labels(self): base_dir = tempfile.mkdtemp() self.addCleanup(shutil.rmtree, base_dir) @@ -778,23 +778,6 @@ def test_build_with_build_labels_dict(self): assert service.image() assert service.image()['Config']['Labels']['com.docker.compose.test'] == 'true' - def test_build_with_build_labels_list(self): - base_dir = tempfile.mkdtemp() - self.addCleanup(shutil.rmtree, base_dir) - - with open(os.path.join(base_dir, 'Dockerfile'), 'w') as f: - f.write('FROM busybox\n') - - service = self.create_service('buildlabels', build={ - 'context': text_type(base_dir), - 'labels': ['com.docker.compose.test=true'] - }) - service.build() - self.addCleanup(self.client.remove_image, service.image_name) - - assert service.image() - assert service.image()['Config']['Labels']['com.docker.compose.test'] == 'true' - @no_cluster('Container networks not on Swarm') def test_build_with_network(self): base_dir = tempfile.mkdtemp() diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 50b09c87ffd..0bf0280de2e 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -473,7 +473,7 @@ def test_create_container(self): nocache=False, rm=True, buildargs={}, - labels={}, + labels=None, cache_from=None, network_mode=None, target=None, @@ -514,7 +514,7 @@ def test_ensure_image_exists_force_build(self): nocache=False, rm=True, buildargs={}, - labels={}, + labels=None, cache_from=None, network_mode=None, target=None, From dfa7380f3781f182be236f2ba932afc7b19e6acf Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 18 Oct 2017 16:34:54 -0700 Subject: [PATCH 3150/4072] Add missing test constraint Signed-off-by: Joffrey F --- tests/acceptance/cli_test.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 22756bd3da5..5398f0bb23d 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -737,12 +737,13 @@ def test_run_one_off_with_multiple_volumes(self): def test_run_one_off_with_volume_merge(self): self.base_dir = 'tests/fixtures/simple-composefile-volume-ready' volume_path = os.path.abspath(os.path.join(os.getcwd(), self.base_dir, 'files')) - create_host_file(self.client, os.path.join(volume_path, 'example.txt')) + node = create_host_file(self.client, os.path.join(volume_path, 'example.txt')) self.dispatch([ '-f', 'docker-compose.merge.yml', 'run', '-v', '{}:/data'.format(volume_path), + '-e', 'constraint:node=={}'.format(node if node is not None else '*'), 'simple', 'test', '-f', '/data/example.txt' ], returncode=0) From ee6a293ae022877f8113e1fb517cb64b587f0a75 Mon Sep 17 00:00:00 2001 From: Guillermo Arribas Date: Tue, 17 Oct 2017 11:54:06 -0300 Subject: [PATCH 3151/4072] Placing dots in hostname no longer populates domainname if api >= 1.23 (fixes #4128) Signed-off-by: Guillermo Arribas --- compose/service.py | 8 +++++--- tests/unit/cli_test.py | 3 +++ tests/unit/service_test.py | 22 ++++++++++++++++------ 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/compose/service.py b/compose/service.py index 48d428cb827..923c3d944b5 100644 --- a/compose/service.py +++ b/compose/service.py @@ -14,6 +14,7 @@ from docker.errors import ImageNotFound from docker.errors import NotFound from docker.types import LogConfig +from docker.utils import version_lt from docker.utils.ports import build_port_bindings from docker.utils.ports import split_port from docker.utils.utils import convert_tmpfs_mounts @@ -748,9 +749,10 @@ def _get_container_create_options( # If a qualified hostname was given, split it into an # unqualified hostname and a domainname unless domainname - # was also given explicitly. This matches the behavior of - # the official Docker CLI in that scenario. - if ('hostname' in container_options and + # was also given explicitly. This matches behavior + # until Docker Engine 1.11.0 - Docker API 1.23. + if (version_lt(self.client.api_version, '1.23') and + 'hostname' in container_options and 'domainname' not in container_options and '.' in container_options['hostname']): parts = container_options['hostname'].partition('.') diff --git a/tests/unit/cli_test.py b/tests/unit/cli_test.py index f9ce240a37d..1a324f50a4b 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -10,6 +10,7 @@ import docker import py import pytest +from docker.constants import DEFAULT_DOCKER_API_VERSION from .. import mock from .. import unittest @@ -98,6 +99,7 @@ def test_command_help_nonexistent(self): @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.APIClient) + mock_client.api_version = DEFAULT_DOCKER_API_VERSION project = Project.from_config( name='composetest', client=mock_client, @@ -130,6 +132,7 @@ def test_run_interactive_passes_logs_false(self, mock_pseudo_terminal, mock_run_ def test_run_service_with_restart_always(self): mock_client = mock.create_autospec(docker.APIClient) + mock_client.api_version = DEFAULT_DOCKER_API_VERSION project = Project.from_config( name='composetest', diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 0bf0280de2e..02b4f622360 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -3,6 +3,7 @@ import docker import pytest +from docker.constants import DEFAULT_DOCKER_API_VERSION from docker.errors import APIError from .. import mock @@ -40,6 +41,7 @@ class ServiceTest(unittest.TestCase): def setUp(self): self.mock_client = mock.create_autospec(docker.APIClient) + self.mock_client.api_version = DEFAULT_DOCKER_API_VERSION def test_containers(self): service = Service('db', self.mock_client, 'myproject', image='foo') @@ -145,12 +147,6 @@ def test_get_volumes_from_service_no_container(self): self.assertEqual(service._get_volumes_from(), [container_id + ':rw']) from_service.create_container.assert_called_once_with() - def test_split_domainname_none(self): - service = Service('foo', image='foo', hostname='name', client=self.mock_client) - opts = service._get_container_create_options({'image': 'foo'}, 1) - self.assertEqual(opts['hostname'], 'name', 'hostname') - self.assertFalse('domainname' in opts, 'domainname') - def test_memory_swap_limit(self): self.mock_client.create_host_config.return_value = {} @@ -232,7 +228,18 @@ def test_log_opt(self): {'Type': 'syslog', 'Config': {'syslog-address': 'tcp://192.168.0.42:123'}} ) + def test_split_domainname_none(self): + service = Service( + 'foo', + image='foo', + hostname='name.domain.tld', + client=self.mock_client) + opts = service._get_container_create_options({'image': 'foo'}, 1) + self.assertEqual(opts['hostname'], 'name.domain.tld', 'hostname') + self.assertFalse('domainname' in opts, 'domainname') + def test_split_domainname_fqdn(self): + self.mock_client.api_version = '1.22' service = Service( 'foo', hostname='name.domain.tld', @@ -243,6 +250,7 @@ def test_split_domainname_fqdn(self): self.assertEqual(opts['domainname'], 'domain.tld', 'domainname') def test_split_domainname_both(self): + self.mock_client.api_version = '1.22' service = Service( 'foo', hostname='name', @@ -254,6 +262,7 @@ def test_split_domainname_both(self): self.assertEqual(opts['domainname'], 'domain.tld', 'domainname') def test_split_domainname_weird(self): + self.mock_client.api_version = '1.22' service = Service( 'foo', hostname='name.sub', @@ -857,6 +866,7 @@ class ServiceVolumesTest(unittest.TestCase): def setUp(self): self.mock_client = mock.create_autospec(docker.APIClient) + self.mock_client.api_version = DEFAULT_DOCKER_API_VERSION def test_build_volume_binding(self): binding = build_volume_binding(VolumeSpec.parse('/outside:/inside', True)) From 8cd46cd54de66300453b81881087b54e213472ea Mon Sep 17 00:00:00 2001 From: Guillermo Arribas Date: Thu, 19 Oct 2017 22:19:05 -0300 Subject: [PATCH 3152/4072] Allow empty default values in variable interpolation (fixes #5185) Signed-off-by: Guillermo Arribas --- compose/config/interpolation.py | 2 +- .../docker-compose.yml | 13 +++++++++++ tests/unit/config/config_test.py | 22 +++++++++++++++++++ 3 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/environment-interpolation-with-defaults/docker-compose.yml diff --git a/compose/config/interpolation.py b/compose/config/interpolation.py index b13ac591aad..df9c988e7d3 100644 --- a/compose/config/interpolation.py +++ b/compose/config/interpolation.py @@ -71,7 +71,7 @@ def recursive_interpolate(obj, interpolator): class TemplateWithDefaults(Template): - idpattern = r'[_a-z][_a-z0-9]*(?::?-[^}]+)?' + idpattern = r'[_a-z][_a-z0-9]*(?::?-[^}]*)?' # Modified from python2.7/string.py def substitute(self, mapping): diff --git a/tests/fixtures/environment-interpolation-with-defaults/docker-compose.yml b/tests/fixtures/environment-interpolation-with-defaults/docker-compose.yml new file mode 100644 index 00000000000..42e7cbb6a5b --- /dev/null +++ b/tests/fixtures/environment-interpolation-with-defaults/docker-compose.yml @@ -0,0 +1,13 @@ +version: "2.1" + +services: + web: + # set value with default, default must be ignored + image: ${IMAGE:-alpine} + + # unset value with default value + ports: + - "${HOST_PORT:-80}:8000" + + # unset value with empty default + hostname: "host-${UNSET_VALUE:-}" diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 8e3d4e2ee31..1c01e52dfce 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -2894,6 +2894,28 @@ def test_config_file_with_environment_variable(self): } ]) + @mock.patch.dict(os.environ) + def test_config_file_with_environment_variable_with_defaults(self): + project_dir = 'tests/fixtures/environment-interpolation-with-defaults' + os.environ.update( + IMAGE="busybox", + ) + + service_dicts = config.load( + config.find( + project_dir, None, Environment.from_env_file(project_dir) + ) + ).services + + self.assertEqual(service_dicts, [ + { + 'name': 'web', + 'image': 'busybox', + 'ports': types.ServicePort.parse('80:8000'), + 'hostname': 'host-', + } + ]) + @mock.patch.dict(os.environ) def test_unset_variable_produces_warning(self): os.environ.pop('FOO', None) From eb51f0fae8d28bccd0bb339472a8c1755d602c4f Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 19 Oct 2017 17:55:32 -0700 Subject: [PATCH 3153/4072] Add type converter to interpolation module Signed-off-by: Joffrey F --- compose/config/config.py | 4 +- compose/config/interpolation.py | 95 +++++++++++- tests/unit/config/interpolation_test.py | 198 +++++++++++++++++++++++- 3 files changed, 287 insertions(+), 10 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index d5aaf9538c1..a9f82a29d34 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -519,13 +519,13 @@ def process_config_file(config_file, environment, service_name=None): processed_config['secrets'] = interpolate_config_section( config_file, config_file.get_secrets(), - 'secrets', + 'secret', environment) if config_file.version >= const.COMPOSEFILE_V3_3: processed_config['configs'] = interpolate_config_section( config_file, config_file.get_configs(), - 'configs', + 'config', environment ) else: diff --git a/compose/config/interpolation.py b/compose/config/interpolation.py index df9c988e7d3..9d7e428c9bd 100644 --- a/compose/config/interpolation.py +++ b/compose/config/interpolation.py @@ -2,6 +2,7 @@ from __future__ import unicode_literals import logging +import re from string import Template import six @@ -44,9 +45,13 @@ def process_item(name, config_dict): ) +def get_config_path(config_key, section, name): + return '{}.{}.{}'.format(section, name, config_key) + + def interpolate_value(name, config_key, value, section, interpolator): try: - return recursive_interpolate(value, interpolator) + return recursive_interpolate(value, interpolator, get_config_path(config_key, section, name)) except InvalidInterpolation as e: raise ConfigurationError( 'Invalid interpolation format for "{config_key}" option ' @@ -57,16 +62,19 @@ def interpolate_value(name, config_key, value, section, interpolator): string=e.string)) -def recursive_interpolate(obj, interpolator): +def recursive_interpolate(obj, interpolator, config_path): + def append(config_path, key): + return '{}.{}'.format(config_path, key) + if isinstance(obj, six.string_types): - return interpolator.interpolate(obj) + return converter.convert(config_path, interpolator.interpolate(obj)) if isinstance(obj, dict): return dict( - (key, recursive_interpolate(val, interpolator)) + (key, recursive_interpolate(val, interpolator, append(config_path, key))) for (key, val) in obj.items() ) if isinstance(obj, list): - return [recursive_interpolate(val, interpolator) for val in obj] + return [recursive_interpolate(val, interpolator, config_path) for val in obj] return obj @@ -100,3 +108,80 @@ def convert(mo): class InvalidInterpolation(Exception): def __init__(self, string): self.string = string + + +PATH_JOKER = '[^.]+' + + +def re_path(*args): + return re.compile('^{}$'.format('.'.join(args))) + + +def re_path_basic(section, name): + return re_path(section, PATH_JOKER, name) + + +def service_path(*args): + return re_path('service', PATH_JOKER, *args) + + +def to_boolean(s): + s = s.lower() + if s in ['y', 'yes', 'true', 'on']: + return True + elif s in ['n', 'no', 'false', 'off']: + return False + raise ValueError('"{}" is not a valid boolean value'.format(s)) + + +def to_int(s): + # We must be able to handle octal representation for `mode` values notably + if six.PY3 and re.match('^0[0-9]+$', s.strip()): + s = '0o' + s[1:] + return int(s, base=0) + + +class ConversionMap(object): + map = { + service_path('blkio_config', 'weight'): to_int, + service_path('blkio_config', 'weight_device', 'weight'): to_int, + service_path('cpus'): float, + service_path('cpu_count'): to_int, + service_path('configs', 'mode'): to_int, + service_path('secrets', 'mode'): to_int, + service_path('healthcheck', 'retries'): to_int, + service_path('healthcheck', 'disable'): to_boolean, + service_path('deploy', 'replicas'): to_int, + service_path('deploy', 'update_config', 'parallelism'): to_int, + service_path('deploy', 'update_config', 'max_failure_ratio'): float, + service_path('deploy', 'restart_policy', 'max_attempts'): to_int, + service_path('mem_swappiness'): to_int, + service_path('oom_score_adj'): to_int, + service_path('ports', 'target'): to_int, + service_path('ports', 'published'): to_int, + service_path('scale'): to_int, + service_path('ulimits', PATH_JOKER): to_int, + service_path('ulimits', PATH_JOKER, 'soft'): to_int, + service_path('ulimits', PATH_JOKER, 'hard'): to_int, + service_path('privileged'): to_boolean, + service_path('read_only'): to_boolean, + service_path('stdin_open'): to_boolean, + service_path('tty'): to_boolean, + service_path('volumes', 'read_only'): to_boolean, + service_path('volumes', 'volume', 'nocopy'): to_boolean, + re_path_basic('network', 'attachable'): to_boolean, + re_path_basic('network', 'external'): to_boolean, + re_path_basic('network', 'internal'): to_boolean, + re_path_basic('volume', 'external'): to_boolean, + re_path_basic('secret', 'external'): to_boolean, + re_path_basic('config', 'external'): to_boolean, + } + + def convert(self, path, value): + for rexp in self.map.keys(): + if rexp.match(path): + return self.map[rexp](value) + return value + + +converter = ConversionMap() diff --git a/tests/unit/config/interpolation_test.py b/tests/unit/config/interpolation_test.py index 018a5621a4c..516f5c9e968 100644 --- a/tests/unit/config/interpolation_test.py +++ b/tests/unit/config/interpolation_test.py @@ -9,12 +9,22 @@ from compose.config.interpolation import InvalidInterpolation from compose.config.interpolation import TemplateWithDefaults from compose.const import COMPOSEFILE_V2_0 as V2_0 -from compose.const import COMPOSEFILE_V3_1 as V3_1 +from compose.const import COMPOSEFILE_V2_3 as V2_3 +from compose.const import COMPOSEFILE_V3_4 as V3_4 @pytest.fixture def mock_env(): - return Environment({'USER': 'jenny', 'FOO': 'bar'}) + return Environment({ + 'USER': 'jenny', + 'FOO': 'bar', + 'TRUE': 'True', + 'FALSE': 'OFF', + 'POSINT': '50', + 'NEGINT': '-200', + 'FLOAT': '0.145', + 'MODE': '0600', + }) @pytest.fixture @@ -102,7 +112,189 @@ def test_interpolate_environment_variables_in_secrets(mock_env): }, 'other': {}, } - value = interpolate_environment_variables(V3_1, secrets, 'volume', mock_env) + value = interpolate_environment_variables(V3_4, secrets, 'secret', mock_env) + assert value == expected + + +def test_interpolate_environment_services_convert_types_v2(mock_env): + entry = { + 'service1': { + 'blkio_config': { + 'weight': '${POSINT}', + 'weight_device': [{'file': '/dev/sda1', 'weight': '${POSINT}'}] + }, + 'cpus': '${FLOAT}', + 'cpu_count': '$POSINT', + 'healthcheck': { + 'retries': '${POSINT:-3}', + 'disable': '${FALSE}', + 'command': 'true' + }, + 'mem_swappiness': '${DEFAULT:-127}', + 'oom_score_adj': '${NEGINT}', + 'scale': '${POSINT}', + 'ulimits': { + 'nproc': '${POSINT}', + 'nofile': { + 'soft': '${POSINT}', + 'hard': '${DEFAULT:-40000}' + }, + }, + 'privileged': '${TRUE}', + 'read_only': '${DEFAULT:-no}', + 'tty': '${DEFAULT:-N}', + 'stdin_open': '${DEFAULT-on}', + } + } + + expected = { + 'service1': { + 'blkio_config': { + 'weight': 50, + 'weight_device': [{'file': '/dev/sda1', 'weight': 50}] + }, + 'cpus': 0.145, + 'cpu_count': 50, + 'healthcheck': { + 'retries': 50, + 'disable': False, + 'command': 'true' + }, + 'mem_swappiness': 127, + 'oom_score_adj': -200, + 'scale': 50, + 'ulimits': { + 'nproc': 50, + 'nofile': { + 'soft': 50, + 'hard': 40000 + }, + }, + 'privileged': True, + 'read_only': False, + 'tty': False, + 'stdin_open': True, + } + } + + value = interpolate_environment_variables(V2_3, entry, 'service', mock_env) + assert value == expected + + +def test_interpolate_environment_services_convert_types_v3(mock_env): + entry = { + 'service1': { + 'healthcheck': { + 'retries': '${POSINT:-3}', + 'disable': '${FALSE}', + 'command': 'true' + }, + 'ulimits': { + 'nproc': '${POSINT}', + 'nofile': { + 'soft': '${POSINT}', + 'hard': '${DEFAULT:-40000}' + }, + }, + 'privileged': '${TRUE}', + 'read_only': '${DEFAULT:-no}', + 'tty': '${DEFAULT:-N}', + 'stdin_open': '${DEFAULT-on}', + 'deploy': { + 'update_config': { + 'parallelism': '${DEFAULT:-2}', + 'max_failure_ratio': '${FLOAT}', + }, + 'restart_policy': { + 'max_attempts': '$POSINT', + }, + 'replicas': '${DEFAULT-3}' + }, + 'ports': [{'target': '${POSINT}', 'published': '${DEFAULT:-5000}'}], + 'configs': [{'mode': '${MODE}', 'source': 'config1'}], + 'secrets': [{'mode': '${MODE}', 'source': 'secret1'}], + } + } + + expected = { + 'service1': { + 'healthcheck': { + 'retries': 50, + 'disable': False, + 'command': 'true' + }, + 'ulimits': { + 'nproc': 50, + 'nofile': { + 'soft': 50, + 'hard': 40000 + }, + }, + 'privileged': True, + 'read_only': False, + 'tty': False, + 'stdin_open': True, + 'deploy': { + 'update_config': { + 'parallelism': 2, + 'max_failure_ratio': 0.145, + }, + 'restart_policy': { + 'max_attempts': 50, + }, + 'replicas': 3 + }, + 'ports': [{'target': 50, 'published': 5000}], + 'configs': [{'mode': 0o600, 'source': 'config1'}], + 'secrets': [{'mode': 0o600, 'source': 'secret1'}], + } + } + + value = interpolate_environment_variables(V3_4, entry, 'service', mock_env) + assert value == expected + + +def test_interpolate_environment_network_convert_types(mock_env): + entry = { + 'network1': { + 'external': '${FALSE}', + 'attachable': '${TRUE}', + 'internal': '${DEFAULT:-false}' + } + } + + expected = { + 'network1': { + 'external': False, + 'attachable': True, + 'internal': False, + } + } + + value = interpolate_environment_variables(V3_4, entry, 'network', mock_env) + assert value == expected + + +def test_interpolate_environment_external_resource_convert_types(mock_env): + entry = { + 'resource1': { + 'external': '${TRUE}', + } + } + + expected = { + 'resource1': { + 'external': True, + } + } + + value = interpolate_environment_variables(V3_4, entry, 'network', mock_env) + assert value == expected + value = interpolate_environment_variables(V3_4, entry, 'volume', mock_env) + assert value == expected + value = interpolate_environment_variables(V3_4, entry, 'secret', mock_env) + assert value == expected + value = interpolate_environment_variables(V3_4, entry, 'config', mock_env) assert value == expected From 7dfb856244fb5bb5690c33db12cb2ad2e072f058 Mon Sep 17 00:00:00 2001 From: Reut Sharabani Date: Mon, 23 Oct 2017 23:21:16 +0300 Subject: [PATCH 3154/4072] Better installation instruction in release notes Changed sample download script to use the built in `-o` optoin in `curl` instead of redicrecting stdout's output. This allows users to prepend `sudo` to the snippet to make it work in common use cases where root permissions are needed to create the output file. From `curl`: -o, --output Write output to instead of stdout. Signed-off-by: Reut Sharabani --- project/RELEASE-PROCESS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project/RELEASE-PROCESS.md b/project/RELEASE-PROCESS.md index 5b30545f459..d4afb87b9c2 100644 --- a/project/RELEASE-PROCESS.md +++ b/project/RELEASE-PROCESS.md @@ -89,7 +89,7 @@ When prompted build the non-linux binaries and test them. Alternatively, you can use the usual commands to install or upgrade Compose: ``` - curl -L https://github.com/docker/compose/releases/download/1.16.0/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose + curl -L https://github.com/docker/compose/releases/download/1.16.0/docker-compose-`uname -s`-`uname -m` -o /usr/local/bin/docker-compose chmod +x /usr/local/bin/docker-compose ``` From e022f32ee99ffa67d6f224d3ac77151c8371774d Mon Sep 17 00:00:00 2001 From: Guillermo Arribas Date: Mon, 23 Oct 2017 12:48:44 -0300 Subject: [PATCH 3155/4072] Wrong format in the healthcheck test does not issue a warning (fixes #4424) Signed-off-by: Guillermo Arribas --- compose/config/config.py | 35 ++++------ compose/config/validation.py | 24 +++++++ tests/unit/config/config_test.py | 110 ++++++++++++++++++++++--------- 3 files changed, 116 insertions(+), 53 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index a9f82a29d34..8a2b2a776a1 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -47,6 +47,7 @@ from .validation import validate_cpu from .validation import validate_depends_on from .validation import validate_extends_file_path +from .validation import validate_healthcheck from .validation import validate_links from .validation import validate_network_mode from .validation import validate_pid_mode @@ -686,6 +687,7 @@ def validate_service(service_config, service_names, config_file): validate_pid_mode(service_config, service_names) validate_depends_on(service_config, service_names) validate_links(service_config, service_names) + validate_healthcheck(service_config) if not service_dict.get('image') and has_uppercase(service_name): raise ConfigurationError( @@ -724,7 +726,7 @@ def process_service(service_config): service_dict[field] = to_list(service_dict[field]) service_dict = process_blkio_config(process_ports( - process_healthcheck(service_dict, service_config.name) + process_healthcheck(service_dict) )) return service_dict @@ -788,33 +790,20 @@ def process_blkio_config(service_dict): return service_dict -def process_healthcheck(service_dict, service_name): +def process_healthcheck(service_dict): if 'healthcheck' not in service_dict: return service_dict - hc = {} - raw = service_dict['healthcheck'] - - 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 'disable' in service_dict['healthcheck']: + del service_dict['healthcheck']['disable'] + service_dict['healthcheck']['test'] = ['NONE'] for field in ['interval', 'timeout', 'start_period']: - if field in raw: - if not isinstance(raw[field], six.integer_types): - hc[field] = parse_nanoseconds_int(raw[field]) - else: # Conversion has been done previously - hc[field] = raw[field] - if 'retries' in raw: - hc['retries'] = raw['retries'] - - service_dict['healthcheck'] = hc + if field in service_dict['healthcheck']: + if not isinstance(service_dict['healthcheck'][field], six.integer_types): + service_dict['healthcheck'][field] = parse_nanoseconds_int( + service_dict['healthcheck'][field]) + return service_dict diff --git a/compose/config/validation.py b/compose/config/validation.py index 940775a2097..8247cf1500a 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -465,3 +465,27 @@ def handle_errors(errors, format_error_func, filename): "The Compose file{file_msg} is invalid because:\n{error_msg}".format( file_msg=" '{}'".format(filename) if filename else "", error_msg=error_msg)) + + +def validate_healthcheck(service_config): + healthcheck = service_config.config.get('healthcheck', {}) + + if 'test' in healthcheck and isinstance(healthcheck['test'], list): + if len(healthcheck['test']) == 0: + raise ConfigurationError( + 'Service "{}" defines an invalid healthcheck: ' + '"test" is an empty list' + .format(service_config.name)) + + # when disable is true config.py::process_healthcheck adds "test: ['NONE']" to service_config + elif healthcheck['test'][0] == 'NONE' and len(healthcheck) > 1: + raise ConfigurationError( + 'Service "{}" defines an invalid healthcheck: ' + '"disable: true" cannot be combined with other options' + .format(service_config.name)) + + elif healthcheck['test'][0] not in ('NONE', 'CMD', 'CMD-SHELL'): + raise ConfigurationError( + 'Service "{}" defines an invalid healthcheck: ' + 'when "test" is a list the first item must be either NONE, CMD or CMD-SHELL' + .format(service_config.name)) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 1c01e52dfce..a758154c04b 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -34,7 +34,6 @@ from compose.const import COMPOSEFILE_V3_2 as V3_2 from compose.const import COMPOSEFILE_V3_3 as V3_3 from compose.const import IS_WINDOWS_PLATFORM -from compose.utils import nanoseconds_from_time_seconds from tests import mock from tests import unittest @@ -4210,52 +4209,103 @@ def test_invalid_url_in_build_path(self): class HealthcheckTest(unittest.TestCase): def test_healthcheck(self): - service_dict = make_service_dict( - 'test', - {'healthcheck': { - 'test': ['CMD', 'true'], - 'interval': '1s', - 'timeout': '1m', - 'retries': 3, - 'start_period': '10s' - }}, - '.', + config_dict = config.load( + build_config_details({ + 'version': '2.3', + 'services': { + 'test': { + 'image': 'busybox', + 'healthcheck': { + 'test': ['CMD', 'true'], + 'interval': '1s', + 'timeout': '1m', + 'retries': 3, + 'start_period': '10s', + } + } + } + + }) ) - assert service_dict['healthcheck'] == { + serialized_config = yaml.load(serialize_config(config_dict)) + serialized_service = serialized_config['services']['test'] + + assert serialized_service['healthcheck'] == { 'test': ['CMD', 'true'], - 'interval': nanoseconds_from_time_seconds(1), - 'timeout': nanoseconds_from_time_seconds(60), + 'interval': '1s', + 'timeout': '1m', 'retries': 3, - 'start_period': nanoseconds_from_time_seconds(10) + 'start_period': '10s' } def test_disable(self): - service_dict = make_service_dict( - 'test', - {'healthcheck': { - 'disable': True, - }}, - '.', + config_dict = config.load( + build_config_details({ + 'version': '2.3', + 'services': { + 'test': { + 'image': 'busybox', + 'healthcheck': { + 'disable': True, + } + } + } + + }) ) - assert service_dict['healthcheck'] == { + serialized_config = yaml.load(serialize_config(config_dict)) + serialized_service = serialized_config['services']['test'] + + assert serialized_service['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', - }}, - '.', + config.load( + build_config_details({ + 'version': '2.3', + 'services': { + 'invalid-healthcheck': { + 'image': 'busybox', + 'healthcheck': { + 'disable': True, + 'interval': '1s', + } + } + } + + }) + ) + + assert 'invalid-healthcheck' in excinfo.exconly() + assert '"disable: true" cannot be combined with other options' in excinfo.exconly() + + def test_healthcheck_with_invalid_test(self): + with pytest.raises(ConfigurationError) as excinfo: + config.load( + build_config_details({ + 'version': '2.3', + 'services': { + 'invalid-healthcheck': { + 'image': 'busybox', + 'healthcheck': { + 'test': ['true'], + 'interval': '1s', + 'timeout': '1m', + 'retries': 3, + 'start_period': '10s', + } + } + } + + }) ) assert 'invalid-healthcheck' in excinfo.exconly() - assert 'disable' in excinfo.exconly() + assert 'the first item must be either NONE, CMD or CMD-SHELL' in excinfo.exconly() class GetDefaultConfigFilesTestCase(unittest.TestCase): From 947e98be387a2c534710a46f49679b5499733581 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 23 Oct 2017 14:49:36 -0700 Subject: [PATCH 3156/4072] Improve process_healthcheck readability Signed-off-by: Joffrey F --- compose/config/config.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 8a2b2a776a1..af4b69ce75e 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -794,15 +794,16 @@ def process_healthcheck(service_dict): if 'healthcheck' not in service_dict: return service_dict - if 'disable' in service_dict['healthcheck']: - del service_dict['healthcheck']['disable'] - service_dict['healthcheck']['test'] = ['NONE'] + hc = service_dict['healthcheck'] + + if 'disable' in hc: + del hc['disable'] + hc['test'] = ['NONE'] for field in ['interval', 'timeout', 'start_period']: - if field in service_dict['healthcheck']: - if not isinstance(service_dict['healthcheck'][field], six.integer_types): - service_dict['healthcheck'][field] = parse_nanoseconds_int( - service_dict['healthcheck'][field]) + if field not in hc or isinstance(hc[field], six.integer_types): + continue + hc[field] = parse_nanoseconds_int(hc[field]) return service_dict From 558df8fe2f3903c06b58e354a1a749ab09c5ebea Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 23 Oct 2017 17:18:45 -0700 Subject: [PATCH 3157/4072] Add support for BOM-signed env files Signed-off-by: Joffrey F --- compose/config/environment.py | 2 +- tests/unit/config/environment_test.py | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/compose/config/environment.py b/compose/config/environment.py index 4ba228c8a1f..0087b612807 100644 --- a/compose/config/environment.py +++ b/compose/config/environment.py @@ -32,7 +32,7 @@ def env_vars_from_file(filename): elif not os.path.isfile(filename): raise ConfigurationError("%s is not a file." % (filename)) env = {} - with contextlib.closing(codecs.open(filename, 'r', 'utf-8')) as fileobj: + with contextlib.closing(codecs.open(filename, 'r', 'utf-8-sig')) as fileobj: for line in fileobj: line = line.strip() if line and not line.startswith('#'): diff --git a/tests/unit/config/environment_test.py b/tests/unit/config/environment_test.py index 20446d2bf2d..854aee5a358 100644 --- a/tests/unit/config/environment_test.py +++ b/tests/unit/config/environment_test.py @@ -3,6 +3,11 @@ from __future__ import print_function from __future__ import unicode_literals +import codecs + +import pytest + +from compose.config.environment import env_vars_from_file from compose.config.environment import Environment from tests import unittest @@ -38,3 +43,12 @@ def test_get_boolean(self): assert env.get_boolean('BAZ') is False assert env.get_boolean('FOOBAR') is True assert env.get_boolean('UNDEFINED') is False + + def test_env_vars_from_file_bom(self): + tmpdir = pytest.ensuretemp('env_file') + self.addCleanup(tmpdir.remove) + with codecs.open('{}/bom.env'.format(str(tmpdir)), 'w', encoding='utf-8') as f: + f.write('\ufeffPARK_BOM=박봄\n') + assert env_vars_from_file(str(tmpdir.join('bom.env'))) == { + 'PARK_BOM': '박봄' + } From a1a6fb485b40cc2f4fff19fc7f5067fcf0292bfa Mon Sep 17 00:00:00 2001 From: Guillermo Arribas Date: Thu, 19 Oct 2017 22:07:30 -0300 Subject: [PATCH 3158/4072] docker-compose exec doesn't have -e option (fixes #4551) Signed-off-by: Guillermo Arribas --- compose/cli/main.py | 59 ++++++++++++------- tests/acceptance/cli_test.py | 26 ++++++++ .../environment-exec/docker-compose.yml | 10 ++++ 3 files changed, 74 insertions(+), 21 deletions(-) create mode 100644 tests/fixtures/environment-exec/docker-compose.yml diff --git a/compose/cli/main.py b/compose/cli/main.py index face38e6d33..c3e30919d4b 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -14,6 +14,8 @@ from inspect import getdoc from operator import attrgetter +import docker + from . import errors from . import signals from .. import __version__ @@ -402,7 +404,7 @@ def exec_command(self, options): """ Execute a command in a running container - Usage: exec [options] SERVICE COMMAND [ARGS...] + Usage: exec [options] [-e KEY=VAL...] SERVICE COMMAND [ARGS...] Options: -d Detached mode: Run command in the background. @@ -412,11 +414,16 @@ def exec_command(self, options): allocates a TTY. --index=index index of the container if there are multiple instances of a service [default: 1] + -e, --env KEY=VAL Set environment variables (can be used multiple times, + not supported in API < 1.25) """ index = int(options.get('--index')) service = self.project.get_service(options['SERVICE']) detach = options['-d'] + if options['--env'] and docker.utils.version_lt(self.project.client.api_version, '1.25'): + raise UserError("Setting environment for exec is not supported in API < 1.25'") + try: container = service.get_container(number=index) except ValueError as e: @@ -425,26 +432,7 @@ def exec_command(self, options): tty = not options["-T"] if IS_WINDOWS_PLATFORM and not detach: - args = ["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(call_docker(args)) + sys.exit(call_docker(build_exec_command(options, container.id, command))) create_exec_options = { "privileged": options["--privileged"], @@ -453,6 +441,9 @@ def exec_command(self, options): "stdin": tty, } + if docker.utils.version_gte(self.project.client.api_version, '1.25'): + create_exec_options["environment"] = options["--env"] + exec_id = container.create_exec(command, **create_exec_options) if detach: @@ -1295,3 +1286,29 @@ def parse_scale_args(options): ) res[service_name] = num return res + + +def build_exec_command(options, container_id, command): + args = ["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"]] + + if options["--env"]: + for env_variable in options["--env"]: + args += ["--env", env_variable] + + args += [container_id] + args += command + return args diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 5398f0bb23d..0fcf866ffb4 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -33,6 +33,7 @@ from tests.integration.testcases import pull_busybox from tests.integration.testcases import SWARM_SKIP_RM_VOLUMES from tests.integration.testcases import v2_1_only +from tests.integration.testcases import v2_2_only from tests.integration.testcases import v2_only from tests.integration.testcases import v3_only @@ -1393,6 +1394,31 @@ def test_exec_custom_user(self): self.assertEqual(stdout, "operator\n") self.assertEqual(stderr, "") + @v2_2_only() + def test_exec_service_with_environment_overridden(self): + name = 'service' + self.base_dir = 'tests/fixtures/environment-exec' + self.dispatch(['up', '-d']) + self.assertEqual(len(self.project.containers()), 1) + + stdout, stderr = self.dispatch([ + 'exec', + '-T', + '-e', 'foo=notbar', + '--env', 'alpha=beta', + name, + 'env', + ]) + + # env overridden + assert 'foo=notbar' in stdout + # keep environment from yaml + assert 'hello=world' in stdout + # added option from command line + assert 'alpha=beta' in stdout + + self.assertEqual(stderr, '') + 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/environment-exec/docker-compose.yml b/tests/fixtures/environment-exec/docker-compose.yml new file mode 100644 index 00000000000..813606eb8b4 --- /dev/null +++ b/tests/fixtures/environment-exec/docker-compose.yml @@ -0,0 +1,10 @@ +version: "2.2" + +services: + service: + image: busybox:latest + command: top + + environment: + foo: bar + hello: world From f89a55e4881b096e8cfcbf84fc9ae900eef89798 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 25 Oct 2017 15:07:00 -0700 Subject: [PATCH 3159/4072] Add support for oom_kill_disable in service config Signed-off-by: Joffrey F --- compose/config/config.py | 1 + compose/config/config_schema_v2.1.json | 1 + compose/config/config_schema_v2.2.json | 1 + compose/config/config_schema_v2.3.json | 1 + compose/config/interpolation.py | 1 + compose/service.py | 2 ++ tests/integration/service_test.py | 9 +++++++-- 7 files changed, 14 insertions(+), 2 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index af4b69ce75e..adfb53d8f7a 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -91,6 +91,7 @@ 'mem_swappiness', 'net', 'oom_score_adj', + 'oom_kill_disable', 'pid', 'ports', 'privileged', diff --git a/compose/config/config_schema_v2.1.json b/compose/config/config_schema_v2.1.json index 24e6ba02cea..6b74f0ed699 100644 --- a/compose/config/config_schema_v2.1.json +++ b/compose/config/config_schema_v2.1.json @@ -229,6 +229,7 @@ } ] }, + "oom_kill_disable": {"type": "boolean"}, "oom_score_adj": {"type": "integer", "minimum": -1000, "maximum": 1000}, "group_add": { "type": "array", diff --git a/compose/config/config_schema_v2.2.json b/compose/config/config_schema_v2.2.json index 86fc5df95d1..21343b8932c 100644 --- a/compose/config/config_schema_v2.2.json +++ b/compose/config/config_schema_v2.2.json @@ -235,6 +235,7 @@ } ] }, + "oom_kill_disable": {"type": "boolean"}, "oom_score_adj": {"type": "integer", "minimum": -1000, "maximum": 1000}, "group_add": { "type": "array", diff --git a/compose/config/config_schema_v2.3.json b/compose/config/config_schema_v2.3.json index ceaf44954eb..0e709e9d93b 100644 --- a/compose/config/config_schema_v2.3.json +++ b/compose/config/config_schema_v2.3.json @@ -237,6 +237,7 @@ } ] }, + "oom_kill_disable": {"type": "boolean"}, "oom_score_adj": {"type": "integer", "minimum": -1000, "maximum": 1000}, "group_add": { "type": "array", diff --git a/compose/config/interpolation.py b/compose/config/interpolation.py index 9d7e428c9bd..45a5f9fc232 100644 --- a/compose/config/interpolation.py +++ b/compose/config/interpolation.py @@ -156,6 +156,7 @@ class ConversionMap(object): service_path('deploy', 'update_config', 'max_failure_ratio'): float, service_path('deploy', 'restart_policy', 'max_attempts'): to_int, service_path('mem_swappiness'): to_int, + service_path('oom_kill_disable'): to_boolean, service_path('oom_score_adj'): to_int, service_path('ports', 'target'): to_int, service_path('ports', 'published'): to_int, diff --git a/compose/service.py b/compose/service.py index 923c3d944b5..8839c6cfd3c 100644 --- a/compose/service.py +++ b/compose/service.py @@ -77,6 +77,7 @@ 'mem_reservation', 'memswap_limit', 'mem_swappiness', + 'oom_kill_disable', 'oom_score_adj', 'pid', 'pids_limit', @@ -860,6 +861,7 @@ def _get_container_host_config(self, override_options, one_off=False): sysctls=options.get('sysctls'), pids_limit=options.get('pids_limit'), tmpfs=options.get('tmpfs'), + oom_kill_disable=options.get('oom_kill_disable'), oom_score_adj=options.get('oom_score_adj'), mem_swappiness=options.get('mem_swappiness'), group_add=options.get('group_add'), diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 3ddf991b304..deced274240 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -239,8 +239,7 @@ def test_create_container_with_security_opt(self): service.start_container(container) self.assertEqual(set(container.get('HostConfig.SecurityOpt')), set(security_opt)) - # @pytest.mark.xfail(True, reason='Not supported on most drivers') - @pytest.mark.skipif(True, reason='https://github.com/moby/moby/issues/34270') + @pytest.mark.xfail(True, reason='Not supported on most drivers') def test_create_container_with_storage_opt(self): storage_opt = {'size': '1G'} service = self.create_service('db', storage_opt=storage_opt) @@ -248,6 +247,12 @@ def test_create_container_with_storage_opt(self): service.start_container(container) self.assertEqual(container.get('HostConfig.StorageOpt'), storage_opt) + def test_create_container_with_oom_kill_disable(self): + self.require_api_version('1.20') + service = self.create_service('db', oom_kill_disable=True) + container = service.create_container() + assert container.get('HostConfig.OomKillDisable') is True + def test_create_container_with_mac_address(self): service = self.create_service('db', mac_address='02:42:ac:11:65:43') container = service.create_container() From 574ac9f124f4d5048feb21c0131fdb13138e31c0 Mon Sep 17 00:00:00 2001 From: Andy Neff Date: Thu, 26 Oct 2017 11:42:57 -0400 Subject: [PATCH 3160/4072] Have stop_grace_period also set StopTimeout on create Signed-off-by: Andy Neff --- compose/service.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/compose/service.py b/compose/service.py index 8839c6cfd3c..14027a1cc79 100644 --- a/compose/service.py +++ b/compose/service.py @@ -15,6 +15,7 @@ from docker.errors import NotFound from docker.types import LogConfig from docker.utils import version_lt +from docker.utils import version_gte from docker.utils.ports import build_port_bindings from docker.utils.ports import split_port from docker.utils.utils import convert_tmpfs_mounts @@ -760,6 +761,11 @@ def _get_container_create_options( container_options['hostname'] = parts[0] container_options['domainname'] = parts[2] + if (version_gte(self.client.api_version, '1.25') and + 'stop_grace_period' in self.options): + container_options['stop_timeout'] = parse_seconds_float( + self.options.pop('stop_grace_period')) + if 'ports' in container_options or 'expose' in self.options: container_options['ports'] = build_container_ports( formatted_ports(container_options.get('ports', [])), From 41d7d6e45ba56fb02e250ac70cab26110541dd42 Mon Sep 17 00:00:00 2001 From: Andy Neff Date: Fri, 27 Oct 2017 17:44:17 -0400 Subject: [PATCH 3161/4072] Added unit test and used stop_timeout Signed-off-by: Andy Neff --- compose/service.py | 5 ++--- tests/unit/service_test.py | 11 +++++++++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/compose/service.py b/compose/service.py index 14027a1cc79..245d5f7c7b6 100644 --- a/compose/service.py +++ b/compose/service.py @@ -14,8 +14,8 @@ from docker.errors import ImageNotFound from docker.errors import NotFound from docker.types import LogConfig -from docker.utils import version_lt from docker.utils import version_gte +from docker.utils import version_lt from docker.utils.ports import build_port_bindings from docker.utils.ports import split_port from docker.utils.utils import convert_tmpfs_mounts @@ -763,8 +763,7 @@ def _get_container_create_options( if (version_gte(self.client.api_version, '1.25') and 'stop_grace_period' in self.options): - container_options['stop_timeout'] = parse_seconds_float( - self.options.pop('stop_grace_period')) + container_options['stop_timeout'] = self.stop_timeout(None) if 'ports' in container_options or 'expose' in self.options: container_options['ports'] = build_container_ports( diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 02b4f622360..4c879cae7f2 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -228,6 +228,17 @@ def test_log_opt(self): {'Type': 'syslog', 'Config': {'syslog-address': 'tcp://192.168.0.42:123'}} ) + def test_stop_grace_period(self): + self.mock_client.api_version = '1.25' + self.mock_client.create_host_config.return_value = {} + service = Service( + 'foo', + image='foo', + client=self.mock_client, + stop_grace_period="1m35s") + opts = service._get_container_create_options({'image': 'foo'}, 1) + self.assertEqual(opts['stop_timeout'], 95) + def test_split_domainname_none(self): service = Service( 'foo', From 0f978642380c593f46448c4fcd91c23649bf3451 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 3 Nov 2017 12:26:31 -0700 Subject: [PATCH 3162/4072] Add shasum computation to download-binaries script Signed-off-by: Joffrey F --- script/release/download-binaries | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/script/release/download-binaries b/script/release/download-binaries index 5d01f5f75e2..bef5430f4b3 100755 --- a/script/release/download-binaries +++ b/script/release/download-binaries @@ -30,3 +30,8 @@ mkdir $DESTINATION wget -O $DESTINATION/docker-compose-Darwin-x86_64 $BASE_BINTRAY_URL/docker-compose-Darwin-x86_64 wget -O $DESTINATION/docker-compose-Linux-x86_64 $BASE_BINTRAY_URL/docker-compose-Linux-x86_64 wget -O $DESTINATION/docker-compose-Windows-x86_64.exe $APPVEYOR_URL + +echo -e "\n\nCopy the following lines into the integrity check table in the release notes:\n\n" +cd $DESTINATION +ls | xargs sha256sum | sed 's/ / | /g' | sed -r 's/([^ |]+)/`\1`/g' +cd - From 985010b88707b6b13ec7694d6eb06ca6f6e9d3dc Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 3 Nov 2017 12:36:53 -0700 Subject: [PATCH 3163/4072] 1.18.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 20392ec990d..7b954eb4f67 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.17.1' +__version__ = '1.18.0dev' From 183110e0b07453a1826b70f18da0b174d43ef237 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 7 Nov 2017 17:29:23 -0800 Subject: [PATCH 3164/4072] Bump SDK version to latest 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 beeaa28517f..0207b193805 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ cached-property==1.3.0 certifi==2017.4.17 chardet==3.0.4 colorama==0.3.9; sys_platform == 'win32' -docker==2.5.1 +docker==2.6.0 docker-pycreds==0.2.1 dockerpty==0.4.1 docopt==0.6.2 @@ -15,7 +15,7 @@ jsonschema==2.6.0 pypiwin32==219; sys_platform == 'win32' PySocks==1.6.7 PyYAML==3.12 -requests==2.11.1 +requests==2.18.4 six==1.10.0 texttable==0.9.1 urllib3==1.21.1 diff --git a/setup.py b/setup.py index 192a0f6afdf..08d708e9595 100644 --- a/setup.py +++ b/setup.py @@ -33,10 +33,10 @@ def find_version(*file_paths): 'cached-property >= 1.2.0, < 2', 'docopt >= 0.6.1, < 0.7', 'PyYAML >= 3.10, < 4', - 'requests >= 2.6.1, != 2.11.0, < 2.12', + 'requests >= 2.6.1, != 2.11.0, != 2.12.2, != 2.18.0, < 2.19', 'texttable >= 0.9.0, < 0.10', 'websocket-client >= 0.32.0, < 1.0', - 'docker >= 2.5.1, < 3.0', + 'docker >= 2.6.0, < 3.0', 'dockerpty >= 0.4.1, < 0.5', 'six >= 1.3.0, < 2', 'jsonschema >= 2.5.1, < 3', From 8ecd15e5680b18491f7eb90403baafa6733c0501 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 8 Nov 2017 16:48:41 -0800 Subject: [PATCH 3165/4072] Include SDK attach bugfix 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 0207b193805..8d86b7d3a23 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ cached-property==1.3.0 certifi==2017.4.17 chardet==3.0.4 colorama==0.3.9; sys_platform == 'win32' -docker==2.6.0 +docker==2.6.1 docker-pycreds==0.2.1 dockerpty==0.4.1 docopt==0.6.2 diff --git a/setup.py b/setup.py index 08d708e9595..bc760c3efe6 100644 --- a/setup.py +++ b/setup.py @@ -36,7 +36,7 @@ def find_version(*file_paths): 'requests >= 2.6.1, != 2.11.0, != 2.12.2, != 2.18.0, < 2.19', 'texttable >= 0.9.0, < 0.10', 'websocket-client >= 0.32.0, < 1.0', - 'docker >= 2.6.0, < 3.0', + 'docker >= 2.6.1, < 3.0', 'dockerpty >= 0.4.1, < 0.5', 'six >= 1.3.0, < 2', 'jsonschema >= 2.5.1, < 3', From b2c13e15343f9a44106eb5a85ba0c17c1de4c19e Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 9 Nov 2017 14:25:14 -0800 Subject: [PATCH 3166/4072] Remove redundant log message Signed-off-by: Joffrey F --- compose/service.py | 1 - 1 file changed, 1 deletion(-) diff --git a/compose/service.py b/compose/service.py index 245d5f7c7b6..366bb374604 100644 --- a/compose/service.py +++ b/compose/service.py @@ -514,7 +514,6 @@ def recreate_container( volumes can be copied to the new container, before the original container is removed. """ - log.info("Recreating %s" % container.name) container.stop(timeout=self.stop_timeout(timeout)) container.rename_to_tmp_name() From 67dfcd6951add2460973fe4180459e4076a0f41f Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 25 Oct 2017 14:51:11 -0700 Subject: [PATCH 3167/4072] Add support for extra_hosts in build config Signed-off-by: Joffrey F --- compose/config/config.py | 1 + compose/config/config_schema_v2.3.json | 3 ++- compose/service.py | 1 + tests/integration/service_test.py | 23 +++++++++++++++++++++++ tests/unit/service_test.py | 4 +++- tox.ini | 1 - 6 files changed, 30 insertions(+), 3 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index adfb53d8f7a..4c3f93ddb25 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -1023,6 +1023,7 @@ def to_dict(service): md.merge_mapping('args', parse_build_arguments) md.merge_field('cache_from', merge_unique_items_lists, default=[]) md.merge_mapping('labels', parse_labels) + md.merge_mapping('extra_hosts', parse_extra_hosts) return dict(md) diff --git a/compose/config/config_schema_v2.3.json b/compose/config/config_schema_v2.3.json index 0e709e9d93b..6f923871bfd 100644 --- a/compose/config/config_schema_v2.3.json +++ b/compose/config/config_schema_v2.3.json @@ -92,7 +92,8 @@ "cache_from": {"$ref": "#/definitions/list_of_strings"}, "network": {"type": "string"}, "target": {"type": "string"}, - "shm_size": {"type": ["integer", "string"]} + "shm_size": {"type": ["integer", "string"]}, + "extra_hosts": {"$ref": "#/definitions/list_or_dict"} }, "additionalProperties": false } diff --git a/compose/service.py b/compose/service.py index 366bb374604..0b6561d9959 100644 --- a/compose/service.py +++ b/compose/service.py @@ -930,6 +930,7 @@ def build(self, no_cache=False, pull=False, force_rm=False, build_args_override= network_mode=build_opts.get('network', None), target=build_opts.get('target', None), shmsize=parse_bytes(build_opts.get('shm_size')) if build_opts.get('shm_size') else None, + extra_hosts=build_opts.get('extra_hosts', None), ) try: diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index deced274240..00bacebf5bc 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -833,6 +833,29 @@ def test_build_with_target(self): assert service.image() assert service.image()['Config']['Labels']['com.docker.compose.test.target'] == 'one' + @v2_3_only() + def test_build_with_extra_hosts(self): + self.require_api_version('1.27') + base_dir = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, base_dir) + + with open(os.path.join(base_dir, 'Dockerfile'), 'w') as f: + f.write('\n'.join([ + 'FROM busybox', + 'RUN ping -c1 foobar', + 'RUN ping -c1 baz', + ])) + + service = self.create_service('build_extra_hosts', build={ + 'context': text_type(base_dir), + 'extra_hosts': { + 'foobar': '127.0.0.1', + 'baz': '127.0.0.1' + } + }) + service.build() + assert service.image() + def test_start_container_stays_unprivileged(self): service = self.create_service('web') container = create_and_start_container(service).inspect() diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 4c879cae7f2..8e8f602033c 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -498,6 +498,7 @@ def test_create_container(self): network_mode=None, target=None, shmsize=None, + extra_hosts=None, ) def test_ensure_image_exists_no_build(self): @@ -538,7 +539,8 @@ def test_ensure_image_exists_force_build(self): cache_from=None, network_mode=None, target=None, - shmsize=None + shmsize=None, + extra_hosts=None, ) def test_build_does_not_pull(self): diff --git a/tox.ini b/tox.ini index e4f31ec8554..749be3faaea 100644 --- a/tox.ini +++ b/tox.ini @@ -18,7 +18,6 @@ deps = -rrequirements-dev.txt commands = py.test -v \ - --full-trace \ --cov=compose \ --cov-report html \ --cov-report term \ From fb43b8b6b7a0411f15124f50752e5343b2080d00 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 16 Oct 2017 16:56:46 -0700 Subject: [PATCH 3168/4072] Bump colorama (use unreleased fix) 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 8d86b7d3a23..889f87a5a78 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,13 +2,13 @@ backports.ssl-match-hostname==3.5.0.1; python_version < '3' cached-property==1.3.0 certifi==2017.4.17 chardet==3.0.4 -colorama==0.3.9; sys_platform == 'win32' docker==2.6.1 docker-pycreds==0.2.1 dockerpty==0.4.1 docopt==0.6.2 enum34==1.1.6; python_version < '3.4' functools32==3.2.3.post2; python_version < '3.2' +git+git://github.com/tartley/colorama.git@bd378c725b45eba0b8e5cc091c3ca76a954c92ff; sys_platform == 'win32' idna==2.5 ipaddress==1.0.18 jsonschema==2.6.0 diff --git a/setup.py b/setup.py index bc760c3efe6..d0353404007 100644 --- a/setup.py +++ b/setup.py @@ -55,7 +55,7 @@ def find_version(*file_paths): ':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'], - ':sys_platform == "win32"': ['colorama >= 0.3.7, < 0.4'], + ':sys_platform == "win32"': ['colorama >= 0.3.9, < 0.4'], 'socks': ['PySocks >= 1.5.6, != 1.5.7, < 2'], } From cf782a3dbbe82ccabce8cddfd89ae6b00d6b50ac Mon Sep 17 00:00:00 2001 From: Drew Romanyk Date: Thu, 9 Nov 2017 17:53:27 -0600 Subject: [PATCH 3169/4072] Implement subnet config validation (fixes #4552) Signed-off-by: Drew Romanyk --- compose/config/config_schema_v3.5.json | 2 +- compose/config/validation.py | 30 +++++++++- tests/unit/config/config_test.py | 82 ++++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 2 deletions(-) diff --git a/compose/config/config_schema_v3.5.json b/compose/config/config_schema_v3.5.json index 5400cd99f28..c3ac559eed3 100644 --- a/compose/config/config_schema_v3.5.json +++ b/compose/config/config_schema_v3.5.json @@ -419,7 +419,7 @@ "items": { "type": "object", "properties": { - "subnet": {"type": "string"} + "subnet": {"type": "string", "format": "subnet_ip_address"} }, "additionalProperties": false } diff --git a/compose/config/validation.py b/compose/config/validation.py index 8247cf1500a..a8061a5a411 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -5,6 +5,7 @@ import logging import os import re +import socket import sys import six @@ -43,6 +44,9 @@ VALID_NAME_CHARS = '[a-zA-Z0-9\._\-]' VALID_EXPOSE_FORMAT = r'^\d+(\-\d+)?(\/[a-zA-Z]+)?$' +VALID_IPV4_FORMAT = r'^(\d{1,3}.){3}\d{1,3}$' +VALID_IPV4_CIDR_FORMAT = r'^(\d|[1-2]\d|3[0-2])$' +VALID_IPV6_CIDR_FORMAT = r'^(\d|[1-9]\d|1[0-1]\d|12[0-8])$' @FormatChecker.cls_checks(format="ports", raises=ValidationError) @@ -64,6 +68,30 @@ def format_expose(instance): return True +@FormatChecker.cls_checks("subnet_ip_address", raises=ValidationError) +def format_subnet_ip_address(instance): + if isinstance(instance, six.string_types): + if '/' not in instance: + raise ValidationError("should be of the format 'IP_ADDRESS/CIDR'") + + ip_address, cidr = instance.split('/') + + if re.match(VALID_IPV4_FORMAT, ip_address): + if not (re.match(VALID_IPV4_CIDR_FORMAT, cidr) and + all(0 <= int(component) <= 255 for component in ip_address.split("."))): + raise ValidationError("should be of the format 'IP_ADDRESS/CIDR'") + elif re.match(VALID_IPV6_CIDR_FORMAT, cidr) and hasattr(socket, "inet_pton"): + try: + if not (socket.inet_pton(socket.AF_INET6, ip_address)): + raise ValidationError("should be of the format 'IP_ADDRESS/CIDR'") + except socket.error as e: + raise ValidationError(six.text_type(e)) + else: + raise ValidationError("should be of the format 'IP_ADDRESS/CIDR'") + + return True + + def match_named_volumes(service_dict, project_volumes): service_volumes = service_dict.get('volumes', []) for volume_spec in service_volumes: @@ -391,7 +419,7 @@ def process_config_schema_errors(error): def validate_against_config_schema(config_file): schema = load_jsonschema(config_file) - format_checker = FormatChecker(["ports", "expose"]) + format_checker = FormatChecker(["ports", "expose", "subnet_ip_address"]) 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 a758154c04b..819d8f5bec4 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -2846,6 +2846,88 @@ def check_config(self, cfg): ) +class SubnetTest(unittest.TestCase): + INVALID_SUBNET_TYPES = [ + None, + False, + 10, + ] + + INVALID_SUBNET_MAPPINGS = [ + "", + "192.168.0.1/sdfsdfs", + "192.168.0.1/", + "192.168.0.1/33", + "192.168.0.1/01", + "192.168.0.1", + "fe80:0000:0000:0000:0204:61ff:fe9d:f156/sdfsdfs", + "fe80:0000:0000:0000:0204:61ff:fe9d:f156/", + "fe80:0000:0000:0000:0204:61ff:fe9d:f156/129", + "fe80:0000:0000:0000:0204:61ff:fe9d:f156/01", + "fe80:0000:0000:0000:0204:61ff:fe9d:f156", + ] + + ILLEGAL_SUBNET_MAPPINGS = [ + "ge80:0000:0000:0000:0204:61ff:fe9d:f156/128" + ] + + VALID_SUBNET_MAPPINGS = [ + "192.168.0.1/0", + "192.168.0.1/32", + "fe80:0000:0000:0000:0204:61ff:fe9d:f156/0", + "fe80:0000:0000:0000:0204:61ff:fe9d:f156/128", + ] + + def test_config_invalid_subnet_type_validation(self): + for invalid_subnet in self.INVALID_SUBNET_TYPES: + with pytest.raises(ConfigurationError) as exc: + self.check_config(invalid_subnet) + + assert "contains an invalid type" in exc.value.msg + + def test_config_invalid_subnet_format_validation(self): + for invalid_subnet in self.INVALID_SUBNET_MAPPINGS: + with pytest.raises(ConfigurationError) as exc: + self.check_config(invalid_subnet) + + assert "should be of the format 'IP_ADDRESS/CIDR'" in exc.value.msg + + def test_config_illegal_subnet_type_validation(self): + for invalid_subnet in self.ILLEGAL_SUBNET_MAPPINGS: + with pytest.raises(ConfigurationError) as exc: + self.check_config(invalid_subnet) + + assert "illegal IP address string" in exc.value.msg + + def test_config_valid_subnet_format_validation(self): + for valid_subnet in self.VALID_SUBNET_MAPPINGS: + self.check_config(valid_subnet) + + def check_config(self, subnet): + config.load( + build_config_details({ + 'version': '3.5', + 'services': { + 'web': { + 'image': 'busybox' + } + }, + 'networks': { + 'default': { + 'ipam': { + 'config': [ + { + 'subnet': subnet + } + ], + 'driver': 'default' + } + } + } + }) + ) + + class InterpolationTest(unittest.TestCase): @mock.patch.dict(os.environ) From fa61a91cb5056767ba4b72faf99c295a4372e25b Mon Sep 17 00:00:00 2001 From: Drew Romanyk Date: Thu, 9 Nov 2017 22:57:47 -0600 Subject: [PATCH 3170/4072] Fix subnet config test for windows Signed-off-by: Drew Romanyk --- compose/config/validation.py | 10 ++++++---- tests/unit/config/config_test.py | 7 +++++-- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/compose/config/validation.py b/compose/config/validation.py index a8061a5a411..c2256804b6d 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -72,22 +72,24 @@ def format_expose(instance): def format_subnet_ip_address(instance): if isinstance(instance, six.string_types): if '/' not in instance: - raise ValidationError("should be of the format 'IP_ADDRESS/CIDR'") + raise ValidationError("'{0}' 75 should be of the format 'IP_ADDRESS/CIDR'".format(instance)) ip_address, cidr = instance.split('/') if re.match(VALID_IPV4_FORMAT, ip_address): if not (re.match(VALID_IPV4_CIDR_FORMAT, cidr) and all(0 <= int(component) <= 255 for component in ip_address.split("."))): - raise ValidationError("should be of the format 'IP_ADDRESS/CIDR'") + raise ValidationError( + "'{0}' 83 should be of the format 'IP_ADDRESS/CIDR'".format(instance)) elif re.match(VALID_IPV6_CIDR_FORMAT, cidr) and hasattr(socket, "inet_pton"): try: if not (socket.inet_pton(socket.AF_INET6, ip_address)): - raise ValidationError("should be of the format 'IP_ADDRESS/CIDR'") + raise ValidationError( + "'{0}' 88 should be of the format 'IP_ADDRESS/CIDR'".format(instance)) except socket.error as e: raise ValidationError(six.text_type(e)) else: - raise ValidationError("should be of the format 'IP_ADDRESS/CIDR'") + raise ValidationError("'{0}' 92 should be of the format 'IP_ADDRESS/CIDR'".format(instance)) return True diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 819d8f5bec4..51323cd32d0 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -2896,8 +2896,11 @@ def test_config_illegal_subnet_type_validation(self): for invalid_subnet in self.ILLEGAL_SUBNET_MAPPINGS: with pytest.raises(ConfigurationError) as exc: self.check_config(invalid_subnet) - - assert "illegal IP address string" in exc.value.msg + if IS_WINDOWS_PLATFORM: + assert "An invalid argument was supplied" in exc.value.msg or \ + "illegal IP address string" in exc.value.msg + else: + assert "illegal IP address string" in exc.value.msg def test_config_valid_subnet_format_validation(self): for valid_subnet in self.VALID_SUBNET_MAPPINGS: From df0f7e17d3dc5e806d89865eed060db78a8a0b97 Mon Sep 17 00:00:00 2001 From: Drew Romanyk Date: Fri, 10 Nov 2017 18:04:11 -0600 Subject: [PATCH 3171/4072] Add format to other v3 configs & remove unix dependency Signed-off-by: Drew Romanyk --- compose/config/config_schema_v3.0.json | 2 +- compose/config/config_schema_v3.1.json | 2 +- compose/config/config_schema_v3.2.json | 2 +- compose/config/config_schema_v3.3.json | 2 +- compose/config/config_schema_v3.4.json | 2 +- compose/config/validation.py | 52 +++++++++++++++++--------- tests/unit/config/config_test.py | 30 ++++++++------- 7 files changed, 55 insertions(+), 37 deletions(-) diff --git a/compose/config/config_schema_v3.0.json b/compose/config/config_schema_v3.0.json index f39344cfbe7..fa601bed28b 100644 --- a/compose/config/config_schema_v3.0.json +++ b/compose/config/config_schema_v3.0.json @@ -294,7 +294,7 @@ "items": { "type": "object", "properties": { - "subnet": {"type": "string"} + "subnet": {"type": "string", "format": "subnet_ip_address"} }, "additionalProperties": false } diff --git a/compose/config/config_schema_v3.1.json b/compose/config/config_schema_v3.1.json index 719c0fa7acc..41da89650aa 100644 --- a/compose/config/config_schema_v3.1.json +++ b/compose/config/config_schema_v3.1.json @@ -323,7 +323,7 @@ "items": { "type": "object", "properties": { - "subnet": {"type": "string"} + "subnet": {"type": "string", "format": "subnet_ip_address"} }, "additionalProperties": false } diff --git a/compose/config/config_schema_v3.2.json b/compose/config/config_schema_v3.2.json index 2ca8e92dbe0..a74e2c66b0d 100644 --- a/compose/config/config_schema_v3.2.json +++ b/compose/config/config_schema_v3.2.json @@ -369,7 +369,7 @@ "items": { "type": "object", "properties": { - "subnet": {"type": "string"} + "subnet": {"type": "string", "format": "subnet_ip_address"} }, "additionalProperties": false } diff --git a/compose/config/config_schema_v3.3.json b/compose/config/config_schema_v3.3.json index f1eb9a66103..96dc1d7d03e 100644 --- a/compose/config/config_schema_v3.3.json +++ b/compose/config/config_schema_v3.3.json @@ -412,7 +412,7 @@ "items": { "type": "object", "properties": { - "subnet": {"type": "string"} + "subnet": {"type": "string", "format": "subnet_ip_address"} }, "additionalProperties": false } diff --git a/compose/config/config_schema_v3.4.json b/compose/config/config_schema_v3.4.json index dae7d7d2345..8089c7e6d78 100644 --- a/compose/config/config_schema_v3.4.json +++ b/compose/config/config_schema_v3.4.json @@ -420,7 +420,7 @@ "items": { "type": "object", "properties": { - "subnet": {"type": "string"} + "subnet": {"type": "string", "format": "subnet_ip_address"} }, "additionalProperties": false } diff --git a/compose/config/validation.py b/compose/config/validation.py index c2256804b6d..f97069935d3 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -5,7 +5,6 @@ import logging import os import re -import socket import sys import six @@ -44,9 +43,32 @@ VALID_NAME_CHARS = '[a-zA-Z0-9\._\-]' VALID_EXPOSE_FORMAT = r'^\d+(\-\d+)?(\/[a-zA-Z]+)?$' -VALID_IPV4_FORMAT = r'^(\d{1,3}.){3}\d{1,3}$' -VALID_IPV4_CIDR_FORMAT = r'^(\d|[1-2]\d|3[0-2])$' -VALID_IPV6_CIDR_FORMAT = r'^(\d|[1-9]\d|1[0-1]\d|12[0-8])$' + +VALID_IPV4_SEG = r'(\d{1,2}|1\d{2}|2[0-4]\d|25[0-5])' +VALID_REGEX_IPV4_CIDR = r'^(\d|[1-2]\d|3[0-2])$' +VALID_IPV4_ADDR = "({IPV4_SEG}\.){{3}}{IPV4_SEG}".format(IPV4_SEG=VALID_IPV4_SEG) +VALID_REGEX_IPV4_ADDR = "^{IPV4_ADDR}$".format(IPV4_ADDR=VALID_IPV4_ADDR) + +VALID_IPV6_SEG = r'[0-9a-fA-F]{1,4}' +VALID_REGEX_IPV6_CIDR = r'^(\d|[1-9]\d|1[0-1]\d|12[0-8])$' +VALID_REGEX_IPV6_ADDR = "".join(""" +^ +( + (({IPV6_SEG}:){{7}}{IPV6_SEG})| + (({IPV6_SEG}:){{1,7}}:)| + (({IPV6_SEG}:){{1,6}}(:{IPV6_SEG}){{1,1}})| + (({IPV6_SEG}:){{1,5}}(:{IPV6_SEG}){{1,2}})| + (({IPV6_SEG}:){{1,4}}(:{IPV6_SEG}){{1,3}})| + (({IPV6_SEG}:){{1,3}}(:{IPV6_SEG}){{1,4}})| + (({IPV6_SEG}:){{1,2}}(:{IPV6_SEG}){{1,5}})| + (({IPV6_SEG}:){{1,1}}(:{IPV6_SEG}){{1,6}})| + (:((:{IPV6_SEG}){{1,7}}|:))| + (fe80:(:{IPV6_SEG}){{0,4}}%[0-9a-zA-Z]{{1,}})| + (::(ffff(:0{{1,4}}){{0,1}}:){{0,1}}{IPV4_ADDR})| + (({IPV6_SEG}:){{1,4}}:{IPV4_ADDR}) +) +$ +""".format(IPV6_SEG=VALID_IPV6_SEG, IPV4_ADDR=VALID_IPV4_ADDR).split()) @FormatChecker.cls_checks(format="ports", raises=ValidationError) @@ -72,24 +94,18 @@ def format_expose(instance): def format_subnet_ip_address(instance): if isinstance(instance, six.string_types): if '/' not in instance: - raise ValidationError("'{0}' 75 should be of the format 'IP_ADDRESS/CIDR'".format(instance)) + raise ValidationError("should be of the format 'IP_ADDRESS/CIDR'") ip_address, cidr = instance.split('/') - if re.match(VALID_IPV4_FORMAT, ip_address): - if not (re.match(VALID_IPV4_CIDR_FORMAT, cidr) and - all(0 <= int(component) <= 255 for component in ip_address.split("."))): - raise ValidationError( - "'{0}' 83 should be of the format 'IP_ADDRESS/CIDR'".format(instance)) - elif re.match(VALID_IPV6_CIDR_FORMAT, cidr) and hasattr(socket, "inet_pton"): - try: - if not (socket.inet_pton(socket.AF_INET6, ip_address)): - raise ValidationError( - "'{0}' 88 should be of the format 'IP_ADDRESS/CIDR'".format(instance)) - except socket.error as e: - raise ValidationError(six.text_type(e)) + if re.match(VALID_REGEX_IPV4_ADDR, ip_address): + if not re.match(VALID_REGEX_IPV4_CIDR, cidr): + raise ValidationError("should be of the format 'IP_ADDRESS/CIDR'") + elif re.match(VALID_REGEX_IPV6_ADDR, ip_address): + if not re.match(VALID_REGEX_IPV6_CIDR, cidr): + raise ValidationError("should be of the format 'IP_ADDRESS/CIDR'") else: - raise ValidationError("'{0}' 92 should be of the format 'IP_ADDRESS/CIDR'".format(instance)) + raise ValidationError("should be of the format 'IP_ADDRESS/CIDR'") return True diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 51323cd32d0..1cf783c777c 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -2865,10 +2865,7 @@ class SubnetTest(unittest.TestCase): "fe80:0000:0000:0000:0204:61ff:fe9d:f156/129", "fe80:0000:0000:0000:0204:61ff:fe9d:f156/01", "fe80:0000:0000:0000:0204:61ff:fe9d:f156", - ] - - ILLEGAL_SUBNET_MAPPINGS = [ - "ge80:0000:0000:0000:0204:61ff:fe9d:f156/128" + "ge80:0000:0000:0000:0204:61ff:fe9d:f156/128", ] VALID_SUBNET_MAPPINGS = [ @@ -2876,6 +2873,21 @@ class SubnetTest(unittest.TestCase): "192.168.0.1/32", "fe80:0000:0000:0000:0204:61ff:fe9d:f156/0", "fe80:0000:0000:0000:0204:61ff:fe9d:f156/128", + "1:2:3:4:5:6:7:8/0", + "1::/0", + "1:2:3:4:5:6:7::/0", + "1::8/0", + "1:2:3:4:5:6::8/0", + "::/0", + "::8/0", + "::2:3:4:5:6:7:8/0", + "fe80::7:8%eth0/0", + "fe80::7:8%1/0", + "::255.255.255.255/0", + "::ffff:255.255.255.255/0", + "::ffff:0:255.255.255.255/0", + "2001:db8:3:4::192.0.2.33/0", + "64:ff9b::192.0.2.33/0", ] def test_config_invalid_subnet_type_validation(self): @@ -2892,16 +2904,6 @@ def test_config_invalid_subnet_format_validation(self): assert "should be of the format 'IP_ADDRESS/CIDR'" in exc.value.msg - def test_config_illegal_subnet_type_validation(self): - for invalid_subnet in self.ILLEGAL_SUBNET_MAPPINGS: - with pytest.raises(ConfigurationError) as exc: - self.check_config(invalid_subnet) - if IS_WINDOWS_PLATFORM: - assert "An invalid argument was supplied" in exc.value.msg or \ - "illegal IP address string" in exc.value.msg - else: - assert "illegal IP address string" in exc.value.msg - def test_config_valid_subnet_format_validation(self): for valid_subnet in self.VALID_SUBNET_MAPPINGS: self.check_config(valid_subnet) From 76e9076cb714242212a428fe7cfd84159425b5bc Mon Sep 17 00:00:00 2001 From: Drew Romanyk Date: Mon, 13 Nov 2017 21:53:14 -0600 Subject: [PATCH 3172/4072] Refactor subnet cidr validator & add new test Signed-off-by: Drew Romanyk --- compose/config/validation.py | 23 ++++++----------------- tests/unit/config/config_test.py | 3 ++- 2 files changed, 8 insertions(+), 18 deletions(-) diff --git a/compose/config/validation.py b/compose/config/validation.py index f97069935d3..0fdcb37e7dc 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -45,13 +45,11 @@ VALID_EXPOSE_FORMAT = r'^\d+(\-\d+)?(\/[a-zA-Z]+)?$' VALID_IPV4_SEG = r'(\d{1,2}|1\d{2}|2[0-4]\d|25[0-5])' -VALID_REGEX_IPV4_CIDR = r'^(\d|[1-2]\d|3[0-2])$' VALID_IPV4_ADDR = "({IPV4_SEG}\.){{3}}{IPV4_SEG}".format(IPV4_SEG=VALID_IPV4_SEG) -VALID_REGEX_IPV4_ADDR = "^{IPV4_ADDR}$".format(IPV4_ADDR=VALID_IPV4_ADDR) +VALID_REGEX_IPV4_CIDR = "^{IPV4_ADDR}/(\d|[1-2]\d|3[0-2])$".format(IPV4_ADDR=VALID_IPV4_ADDR) VALID_IPV6_SEG = r'[0-9a-fA-F]{1,4}' -VALID_REGEX_IPV6_CIDR = r'^(\d|[1-9]\d|1[0-1]\d|12[0-8])$' -VALID_REGEX_IPV6_ADDR = "".join(""" +VALID_REGEX_IPV6_CIDR = "".join(""" ^ ( (({IPV6_SEG}:){{7}}{IPV6_SEG})| @@ -67,6 +65,7 @@ (::(ffff(:0{{1,4}}){{0,1}}:){{0,1}}{IPV4_ADDR})| (({IPV6_SEG}:){{1,4}}:{IPV4_ADDR}) ) +/(\d|[1-9]\d|1[0-1]\d|12[0-8]) $ """.format(IPV6_SEG=VALID_IPV6_SEG, IPV4_ADDR=VALID_IPV4_ADDR).split()) @@ -93,19 +92,9 @@ def format_expose(instance): @FormatChecker.cls_checks("subnet_ip_address", raises=ValidationError) def format_subnet_ip_address(instance): if isinstance(instance, six.string_types): - if '/' not in instance: - raise ValidationError("should be of the format 'IP_ADDRESS/CIDR'") - - ip_address, cidr = instance.split('/') - - if re.match(VALID_REGEX_IPV4_ADDR, ip_address): - if not re.match(VALID_REGEX_IPV4_CIDR, cidr): - raise ValidationError("should be of the format 'IP_ADDRESS/CIDR'") - elif re.match(VALID_REGEX_IPV6_ADDR, ip_address): - if not re.match(VALID_REGEX_IPV6_CIDR, cidr): - raise ValidationError("should be of the format 'IP_ADDRESS/CIDR'") - else: - raise ValidationError("should be of the format 'IP_ADDRESS/CIDR'") + if not re.match(VALID_REGEX_IPV4_CIDR, instance) and \ + not re.match(VALID_REGEX_IPV6_CIDR, instance): + raise ValidationError("should use the CIDR format") return True diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 1cf783c777c..32ccf1cecd8 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -2866,6 +2866,7 @@ class SubnetTest(unittest.TestCase): "fe80:0000:0000:0000:0204:61ff:fe9d:f156/01", "fe80:0000:0000:0000:0204:61ff:fe9d:f156", "ge80:0000:0000:0000:0204:61ff:fe9d:f156/128", + "192.168.0.1/31/31", ] VALID_SUBNET_MAPPINGS = [ @@ -2902,7 +2903,7 @@ def test_config_invalid_subnet_format_validation(self): with pytest.raises(ConfigurationError) as exc: self.check_config(invalid_subnet) - assert "should be of the format 'IP_ADDRESS/CIDR'" in exc.value.msg + assert "should use the CIDR format" in exc.value.msg def test_config_valid_subnet_format_validation(self): for valid_subnet in self.VALID_SUBNET_MAPPINGS: From 6b0138d70f430b6ace9cc57287066ee9ef7a4942 Mon Sep 17 00:00:00 2001 From: Madeline Stager Date: Wed, 22 Nov 2017 16:21:47 -0600 Subject: [PATCH 3173/4072] implement --timeout flag for docker-compose down Fix #3370 Signed-off-by: Madeline Stager --- compose/cli/main.py | 5 ++++- compose/project.py | 4 ++-- tests/acceptance/cli_test.py | 21 +++++++++++++++++++++ 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index c3e30919d4b..f866d5809fa 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -371,9 +371,12 @@ def down(self, options): attached to containers. --remove-orphans Remove containers for services not defined in the Compose file + -t, --timeout TIMEOUT Specify a shutdown timeout in seconds. + (default: 10) """ image_type = image_type_from_opt('--rmi', options['--rmi']) - self.project.down(image_type, options['--volumes'], options['--remove-orphans']) + timeout = timeout_from_opts(options) + self.project.down(image_type, options['--volumes'], options['--remove-orphans'], timeout=timeout) def events(self, options): """ diff --git a/compose/project.py b/compose/project.py index f6bd30a8869..9cc726e42ec 100644 --- a/compose/project.py +++ b/compose/project.py @@ -330,8 +330,8 @@ def remove_stopped(self, service_names=None, one_off=OneOffFilter.exclude, **opt service_names, stopped=True, one_off=one_off ), options) - def down(self, remove_image_type, include_volumes, remove_orphans=False): - self.stop(one_off=OneOffFilter.include) + def down(self, remove_image_type, include_volumes, remove_orphans=False, timeout=None): + self.stop(one_off=OneOffFilter.include, timeout=timeout) 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 0fcf866ffb4..9d4ae325590 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -794,6 +794,27 @@ def test_down(self): assert 'Removing network v2full_default' in result.stderr assert 'Removing network v2full_front' in result.stderr + def test_down_timeout(self): + self.dispatch(['up', '-d'], None) + service = self.project.get_service('simple') + self.assertEqual(len(service.containers()), 1) + self.assertTrue(service.containers()[0].is_running) + "" + + self.dispatch(['down', '-t', '1'], None) + + self.assertEqual(len(service.containers(stopped=True)), 0) + + def test_down_signal(self): + self.base_dir = 'tests/fixtures/stop-signal-composefile' + self.dispatch(['up', '-d'], None) + service = self.project.get_service('simple') + self.assertEqual(len(service.containers()), 1) + self.assertTrue(service.containers()[0].is_running) + + self.dispatch(['down', '-t', '1'], None) + self.assertEqual(len(service.containers(stopped=True)), 0) + def test_up_detached(self): self.dispatch(['up', '-d']) service = self.project.get_service('simple') From a99dd9f2dc51b1f25145c7638933e66817dcd4b3 Mon Sep 17 00:00:00 2001 From: Madeline Stager Date: Wed, 22 Nov 2017 17:32:51 -0600 Subject: [PATCH 3174/4072] Fixed example in instructions for running tests. Fix #5394 Signed-off-by: Madeline Stager --- CONTRIBUTING.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 16bccf98b72..a031e2d6838 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -64,8 +64,8 @@ you can specify a test directory, file, module, class or method: $ 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 + $ script/test/default tests/unit/config/config_test.py::ConfigTest + $ script/test/default tests/unit/config/config_test.py::ConfigTest::test_load ## Finding things to work on From 7835a0755091fd5886cc33276d2a6457151ac981 Mon Sep 17 00:00:00 2001 From: Samantha Miller Date: Sun, 12 Nov 2017 11:33:34 -0600 Subject: [PATCH 3175/4072] Added a label option to 'docker-compose run' and test. Signed-off-by: Samantha Miller --- compose/cli/main.py | 9 ++++++++- compose/config/__init__.py | 2 ++ compose/config/config.py | 6 ++++++ compose/service.py | 5 +++++ contrib/completion/bash/docker-compose | 4 ++-- tests/acceptance/cli_test.py | 11 +++++++++++ tests/fixtures/run-labels/docker-compose.yml | 7 +++++++ tests/unit/cli_test.py | 4 ++++ 8 files changed, 45 insertions(+), 3 deletions(-) create mode 100644 tests/fixtures/run-labels/docker-compose.yml diff --git a/compose/cli/main.py b/compose/cli/main.py index f866d5809fa..79f66309630 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -24,6 +24,7 @@ from ..bundle import serialize_bundle from ..config import ConfigurationError from ..config import parse_environment +from ..config import parse_labels from ..config import resolve_build_args from ..config.environment import Environment from ..config.serialize import serialize_config @@ -723,7 +724,9 @@ def run(self, options): running. If you do not want to start linked services, use `docker-compose run --no-deps SERVICE COMMAND [ARGS...]`. - Usage: run [options] [-v VOLUME...] [-p PORT...] [-e KEY=VAL...] SERVICE [COMMAND] [ARGS...] + Usage: + run [options] [-v VOLUME...] [-p PORT...] [-e KEY=VAL...] [-l KEY=VALUE...] + SERVICE [COMMAND] [ARGS...] Options: -d Detached mode: Run container in the background, print @@ -731,6 +734,7 @@ def run(self, options): --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) + -l, --label KEY=VAL Add or override a label (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. @@ -1125,6 +1129,9 @@ def build_container_options(options, detach, command): parse_environment(options['-e']) ) + if options['--label']: + container_options['labels'] = parse_labels(options['--label']) + if options['--entrypoint']: container_options['entrypoint'] = options.get('--entrypoint') diff --git a/compose/config/__init__.py b/compose/config/__init__.py index b629edf66f3..e1032f3dea0 100644 --- a/compose/config/__init__.py +++ b/compose/config/__init__.py @@ -8,5 +8,7 @@ from .config import find from .config import load from .config import merge_environment +from .config import merge_labels from .config import parse_environment +from .config import parse_labels from .config import resolve_build_args diff --git a/compose/config/config.py b/compose/config/config.py index 4c3f93ddb25..864bc7e90ae 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -1076,6 +1076,12 @@ def merge_environment(base, override): return env +def merge_labels(base, override): + labels = parse_labels(base) + labels.update(parse_labels(override)) + return labels + + def split_kv(kvpair): if '=' in kvpair: return kvpair.split('=', 1) diff --git a/compose/service.py b/compose/service.py index 0b6561d9959..b696fd66466 100644 --- a/compose/service.py +++ b/compose/service.py @@ -25,6 +25,7 @@ from . import progress_stream from .config import DOCKER_CONFIG_KEYS from .config import merge_environment +from .config import merge_labels from .config.errors import DependencyError from .config.types import ServicePort from .config.types import VolumeSpec @@ -778,6 +779,10 @@ def _get_container_create_options( self.options.get('environment'), override_options.get('environment')) + container_options['labels'] = merge_labels( + self.options.get('labels'), + override_options.get('labels')) + binds, affinity = merge_volume_bindings( container_options.get('volumes') or [], self.options.get('tmpfs') or [], diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index 1fdb2770540..af0368177e2 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -403,14 +403,14 @@ _docker_compose_run() { __docker_compose_nospace return ;; - --entrypoint|--name|--user|-u|--volume|-v|--workdir|-w) + --entrypoint|--label|-l|--name|--user|-u|--volume|-v|--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 --volume -v --workdir -w" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "-d --entrypoint -e --help --label -l --name --no-deps --publish -p --rm --service-ports -T --user -u --volume -v --workdir -w" -- "$cur" ) ) ;; *) __docker_compose_services_all diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 9d4ae325590..0ea5f5a6f61 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1869,6 +1869,17 @@ def test_run_env_values_from_system(self): assert 'FOO=bar' in environment assert 'BAR=baz' not in environment + def test_run_label_flag(self): + self.base_dir = 'tests/fixtures/run-labels' + name = 'service' + self.dispatch(['run', '-l', 'default', '--label', 'foo=baz', name, '/bin/true']) + service = self.project.get_service(name) + container, = service.containers(stopped=True, one_off=OneOffFilter.only) + labels = container.labels + assert labels['default'] == '' + assert labels['foo'] == 'baz' + assert labels['hello'] == 'world' + def test_rm(self): service = self.project.get_service('simple') service.create_container() diff --git a/tests/fixtures/run-labels/docker-compose.yml b/tests/fixtures/run-labels/docker-compose.yml new file mode 100644 index 00000000000..e8cd5006556 --- /dev/null +++ b/tests/fixtures/run-labels/docker-compose.yml @@ -0,0 +1,7 @@ +service: + image: busybox:latest + command: top + + labels: + foo: bar + hello: world diff --git a/tests/unit/cli_test.py b/tests/unit/cli_test.py index 1a324f50a4b..c6aa75b26e0 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -114,6 +114,7 @@ def test_run_interactive_passes_logs_false(self, mock_pseudo_terminal, mock_run_ 'SERVICE': 'service', 'COMMAND': None, '-e': [], + '--label': [], '--user': None, '--no-deps': None, '-d': False, @@ -150,6 +151,7 @@ def test_run_service_with_restart_always(self): 'SERVICE': 'service', 'COMMAND': None, '-e': [], + '--label': [], '--user': None, '--no-deps': None, '-d': True, @@ -173,6 +175,7 @@ def test_run_service_with_restart_always(self): 'SERVICE': 'service', 'COMMAND': None, '-e': [], + '--label': [], '--user': None, '--no-deps': None, '-d': True, @@ -205,6 +208,7 @@ def test_command_manual_and_service_ports_together(self): 'SERVICE': 'service', 'COMMAND': None, '-e': [], + '--label': [], '--user': None, '--no-deps': None, '-d': True, From 3ce2f03d70d9d5688d6a76e6ad7993f994292ad1 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 29 Nov 2017 12:21:56 -0800 Subject: [PATCH 3176/4072] Use mounts for secrets instead of volumes Signed-off-by: Joffrey F --- compose/config/types.py | 42 ++++++++++++++++++++++++++++++++++++++ compose/service.py | 27 ++++++++++++++++++++---- tests/unit/service_test.py | 12 +++++------ 3 files changed, 71 insertions(+), 10 deletions(-) diff --git a/compose/config/types.py b/compose/config/types.py index c410343b886..548f2c1cda1 100644 --- a/compose/config/types.py +++ b/compose/config/types.py @@ -133,6 +133,48 @@ def normalize_path_for_engine(path): return path.replace('\\', '/') +class MountSpec(object): + options_map = { + 'volume': { + 'nocopy': 'no_copy' + }, + 'bind': { + 'propagation': 'propagation' + } + } + _fields = ['type', 'source', 'target', 'read_only', 'consistency'] + + def __init__(self, type, source=None, target=None, read_only=None, consistency=None, **kwargs): + self.type = type + self.source = source + self.target = target + self.read_only = read_only + self.consistency = consistency + self.options = None + if self.type in kwargs: + self.options = kwargs[self.type] + + def as_volume_spec(self): + mode = 'ro' if self.read_only else 'rw' + return VolumeSpec(external=self.source, internal=self.target, mode=mode) + + def legacy_repr(self): + return self.as_volume_spec().repr() + + def repr(self): + res = {} + for field in self._fields: + if getattr(self, field, None): + res[field] = getattr(self, field) + if self.options: + res[self.type] = self.options + return res + + @property + def is_named_volume(self): + return self.type == 'volume' and self.source + + class VolumeSpec(namedtuple('_VolumeSpec', 'external internal mode')): @classmethod diff --git a/compose/service.py b/compose/service.py index b696fd66466..07db3ac5f43 100644 --- a/compose/service.py +++ b/compose/service.py @@ -14,6 +14,7 @@ from docker.errors import ImageNotFound from docker.errors import NotFound from docker.types import LogConfig +from docker.types import Mount from docker.utils import version_gte from docker.utils import version_lt from docker.utils.ports import build_port_bindings @@ -27,6 +28,7 @@ from .config import merge_environment from .config import merge_labels from .config.errors import DependencyError +from .config.types import MountSpec from .config.types import ServicePort from .config.types import VolumeSpec from .const import DEFAULT_TIMEOUT @@ -795,9 +797,13 @@ def _get_container_create_options( 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) + if version_lt(self.client.api_version, '1.30'): + override_options['binds'].extend(v.legacy_repr() for v in secret_volumes) + container_options['volumes'].update( + (v.target, {}) for v in secret_volumes + ) + else: + override_options['mounts'] = [build_mount(v) for v in secret_volumes] container_options['image'] = self.image_name @@ -891,6 +897,7 @@ def _get_container_host_config(self, override_options, one_off=False): device_read_iops=blkio_config.get('device_read_iops'), device_write_bps=blkio_config.get('device_write_bps'), device_write_iops=blkio_config.get('device_write_iops'), + mounts=options.get('mounts'), ) def get_secret_volumes(self): @@ -901,7 +908,7 @@ def build_spec(secret): elif not os.path.isabs(target): target = '{}/{}'.format(const.SECRETS_PATH, target) - return VolumeSpec(secret['file'], target, 'ro') + return MountSpec('bind', secret['file'], target, read_only=True) return [build_spec(secret) for secret in self.secrets] @@ -1346,6 +1353,18 @@ def build_volume_from(volume_from_spec): return "{}:{}".format(volume_from_spec.source.id, volume_from_spec.mode) +def build_mount(mount_spec): + kwargs = {} + if mount_spec.options: + for option, sdk_name in mount_spec.options_map[mount_spec.type].items(): + if option in mount_spec.options: + kwargs[sdk_name] = mount_spec.options[option] + + return Mount( + type=mount_spec.type, target=mount_spec.target, source=mount_spec.source, + read_only=mount_spec.read_only, consistency=mount_spec.consistency, **kwargs + ) + # Labels diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 8e8f602033c..87c86a7317e 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -1133,8 +1133,8 @@ def test_get_secret_volumes(self): ) volumes = service.get_secret_volumes() - assert volumes[0].external == secret1['file'] - assert volumes[0].internal == '{}/{}'.format(SECRETS_PATH, secret1['secret'].target) + assert volumes[0].source == secret1['file'] + assert volumes[0].target == '{}/{}'.format(SECRETS_PATH, secret1['secret'].target) def test_get_secret_volumes_abspath(self): secret1 = { @@ -1149,8 +1149,8 @@ def test_get_secret_volumes_abspath(self): ) volumes = service.get_secret_volumes() - assert volumes[0].external == secret1['file'] - assert volumes[0].internal == secret1['secret'].target + assert volumes[0].source == secret1['file'] + assert volumes[0].target == secret1['secret'].target def test_get_secret_volumes_no_target(self): secret1 = { @@ -1165,5 +1165,5 @@ def test_get_secret_volumes_no_target(self): ) volumes = service.get_secret_volumes() - assert volumes[0].external == secret1['file'] - assert volumes[0].internal == '{}/{}'.format(SECRETS_PATH, secret1['secret'].source) + assert volumes[0].source == secret1['file'] + assert volumes[0].target == '{}/{}'.format(SECRETS_PATH, secret1['secret'].source) From dba2abd523dc81d30a6e19c3817a93086ed8e301 Mon Sep 17 00:00:00 2001 From: Drew Romanyk Date: Thu, 30 Nov 2017 10:21:27 -0600 Subject: [PATCH 3177/4072] Add config validation for service volumes, fixes #5352 Signed-off-by: Drew Romanyk --- compose/config/config_schema_v3.2.json | 1 + compose/config/config_schema_v3.3.json | 1 + compose/config/config_schema_v3.4.json | 1 + compose/config/config_schema_v3.5.json | 1 + tests/unit/config/config_test.py | 27 ++++++++++++++++++++++++++ 5 files changed, 31 insertions(+) diff --git a/compose/config/config_schema_v3.2.json b/compose/config/config_schema_v3.2.json index a74e2c66b0d..0baf6a1a96a 100644 --- a/compose/config/config_schema_v3.2.json +++ b/compose/config/config_schema_v3.2.json @@ -245,6 +245,7 @@ { "type": "object", "required": ["type"], + "additionalProperties": false, "properties": { "type": {"type": "string"}, "source": {"type": "string"}, diff --git a/compose/config/config_schema_v3.3.json b/compose/config/config_schema_v3.3.json index 96dc1d7d03e..efc0fdbd74c 100644 --- a/compose/config/config_schema_v3.3.json +++ b/compose/config/config_schema_v3.3.json @@ -278,6 +278,7 @@ { "type": "object", "required": ["type"], + "additionalProperties": false, "properties": { "type": {"type": "string"}, "source": {"type": "string"}, diff --git a/compose/config/config_schema_v3.4.json b/compose/config/config_schema_v3.4.json index 8089c7e6d78..576ecfd8424 100644 --- a/compose/config/config_schema_v3.4.json +++ b/compose/config/config_schema_v3.4.json @@ -282,6 +282,7 @@ { "type": "object", "required": ["type"], + "additionalProperties": false, "properties": { "type": {"type": "string"}, "source": {"type": "string"}, diff --git a/compose/config/config_schema_v3.5.json b/compose/config/config_schema_v3.5.json index c3ac559eed3..1e65b208793 100644 --- a/compose/config/config_schema_v3.5.json +++ b/compose/config/config_schema_v3.5.json @@ -282,6 +282,7 @@ { "type": "object", "required": ["type"], + "additionalProperties": false, "properties": { "type": {"type": "string"}, "source": {"type": "string"}, diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 32ccf1cecd8..00ba6c2c650 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -2631,6 +2631,33 @@ def test_load_configs_multi_file(self): ] assert service_sort(service_dicts) == service_sort(expected) + def test_service_volume_invalid_config(self): + config_details = build_config_details( + { + 'version': '3.2', + 'services': { + 'web': { + 'build': { + 'context': '.', + 'args': None, + }, + 'volumes': [ + { + "type": "volume", + "source": "/data", + "garbage": { + "and": "error" + } + } + ] + }, + }, + } + ) + with pytest.raises(ConfigurationError) as exc: + config.load(config_details) + assert "services.web.volumes contains unsupported option: 'garbage'" in exc.exconly() + class NetworkModeTest(unittest.TestCase): From 4099c97758fa4333bfd3b70a82581d4a15a403d7 Mon Sep 17 00:00:00 2001 From: Drew Romanyk Date: Thu, 30 Nov 2017 10:59:25 -0600 Subject: [PATCH 3178/4072] Add ipam default driver, fixes #5248 Signed-off-by: Drew Romanyk --- compose/network.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/network.py b/compose/network.py index 2e0a7e6ecdb..ee5939c1593 100644 --- a/compose/network.py +++ b/compose/network.py @@ -116,7 +116,7 @@ def create_ipam_config_from_dict(ipam_dict): return None return IPAMConfig( - driver=ipam_dict.get('driver'), + driver=ipam_dict.get('driver') or 'default', pool_configs=[ IPAMPool( subnet=config.get('subnet'), From 7765eed9db5d87c8676e2f8d2fd1dd69ea27e4cb Mon Sep 17 00:00:00 2001 From: Fumiaki MATSUSHIMA Date: Sun, 3 Dec 2017 01:07:17 +0900 Subject: [PATCH 3179/4072] Specify osx_image to fix CI Signed-off-by: Fumiaki Matsushima --- .travis.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.travis.yml b/.travis.yml index fbf2696466d..8fef7ed1bdf 100644 --- a/.travis.yml +++ b/.travis.yml @@ -8,6 +8,7 @@ matrix: services: - docker - os: osx + osx_image: xcode7.3 language: generic install: ./script/travis/install From 20a393d4f95adc5cca092b41138ff41e32627dc9 Mon Sep 17 00:00:00 2001 From: Samantha Miller Date: Fri, 24 Nov 2017 22:53:48 -0600 Subject: [PATCH 3180/4072] Adds support for a memory flag to docker-compose build. Signed-off-by: Samantha Miller --- compose/cli/main.py | 2 ++ compose/project.py | 5 +++-- compose/service.py | 5 ++++- contrib/completion/bash/docker-compose | 2 +- contrib/completion/zsh/_docker-compose | 1 + tests/acceptance/cli_test.py | 6 ++++++ tests/fixtures/build-memory/Dockerfile | 4 ++++ tests/fixtures/build-memory/docker-compose.yml | 6 ++++++ tests/unit/service_test.py | 2 ++ 9 files changed, 29 insertions(+), 4 deletions(-) create mode 100644 tests/fixtures/build-memory/Dockerfile create mode 100644 tests/fixtures/build-memory/docker-compose.yml diff --git a/compose/cli/main.py b/compose/cli/main.py index 79f66309630..f842f05c8b0 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -233,6 +233,7 @@ def build(self, 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. + -m, --memory MEM Sets memory limit for the bulid container. --build-arg key=val Set build-time variables for one service. """ service_names = options['SERVICE'] @@ -249,6 +250,7 @@ def build(self, options): no_cache=bool(options.get('--no-cache', False)), pull=bool(options.get('--pull', False)), force_rm=bool(options.get('--force-rm', False)), + memory=options.get('--memory'), build_args=build_args) def bundle(self, config_options, options): diff --git a/compose/project.py b/compose/project.py index 9cc726e42ec..4115763863f 100644 --- a/compose/project.py +++ b/compose/project.py @@ -357,10 +357,11 @@ def restart(self, service_names=None, **options): ) return containers - def build(self, service_names=None, no_cache=False, pull=False, force_rm=False, build_args=None): + def build(self, service_names=None, no_cache=False, pull=False, force_rm=False, memory=None, + build_args=None): for service in self.get_services(service_names): if service.can_be_built(): - service.build(no_cache, pull, force_rm, build_args) + service.build(no_cache, pull, force_rm, memory, build_args) else: log.info('%s uses an image, skipping' % service.name) diff --git a/compose/service.py b/compose/service.py index 07db3ac5f43..bfc2e594068 100644 --- a/compose/service.py +++ b/compose/service.py @@ -912,7 +912,7 @@ def build_spec(secret): return [build_spec(secret) for secret in self.secrets] - def build(self, no_cache=False, pull=False, force_rm=False, build_args_override=None): + def build(self, no_cache=False, pull=False, force_rm=False, memory=None, build_args_override=None): log.info('Building %s' % self.name) build_opts = self.options.get('build', {}) @@ -943,6 +943,9 @@ def build(self, no_cache=False, pull=False, force_rm=False, build_args_override= target=build_opts.get('target', None), shmsize=parse_bytes(build_opts.get('shm_size')) if build_opts.get('shm_size') else None, extra_hosts=build_opts.get('extra_hosts', None), + container_limits={ + 'memory': parse_bytes(memory) if memory else None + }, ) try: diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index af0368177e2..87161d0ac93 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -120,7 +120,7 @@ _docker_compose_build() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "--build-arg --force-rm --help --no-cache --pull" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--build-arg --force-rm --help --memory --no-cache --pull" -- "$cur" ) ) ;; *) __docker_compose_services_from_build diff --git a/contrib/completion/zsh/_docker-compose b/contrib/completion/zsh/_docker-compose index f53f963347e..c0a54cced1b 100644 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -196,6 +196,7 @@ __docker-compose_subcommand() { $opts_help \ "*--build-arg=[Set build-time variables for one service.]:=: " \ '--force-rm[Always remove intermediate containers.]' \ + '--memory[Memory limit for the build container.]' \ '--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 diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 0ea5f5a6f61..21e71675192 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -602,6 +602,12 @@ def test_build_shm_size_build_option(self): result = self.dispatch(['build', '--no-cache'], None) assert 'shm_size: 96' in result.stdout + def test_build_memory_build_option(self): + pull_busybox(self.client) + self.base_dir = 'tests/fixtures/build-memory' + result = self.dispatch(['build', '--no-cache', '--memory', '96m', 'service'], None) + assert 'memory: 100663296' in result.stdout # 96 * 1024 * 1024 + def test_bundle_with_digests(self): self.base_dir = 'tests/fixtures/bundle-with-digests/' tmpdir = pytest.ensuretemp('cli_test_bundle') diff --git a/tests/fixtures/build-memory/Dockerfile b/tests/fixtures/build-memory/Dockerfile new file mode 100644 index 00000000000..b27349b9659 --- /dev/null +++ b/tests/fixtures/build-memory/Dockerfile @@ -0,0 +1,4 @@ +FROM busybox + +# Report the memory (through the size of the group memory) +RUN echo "memory:" $(cat /sys/fs/cgroup/memory/memory.limit_in_bytes) diff --git a/tests/fixtures/build-memory/docker-compose.yml b/tests/fixtures/build-memory/docker-compose.yml new file mode 100644 index 00000000000..f98355851b1 --- /dev/null +++ b/tests/fixtures/build-memory/docker-compose.yml @@ -0,0 +1,6 @@ +version: '3.5' + +services: + service: + build: + context: . diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 87c86a7317e..16670cff5fc 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -499,6 +499,7 @@ def test_create_container(self): target=None, shmsize=None, extra_hosts=None, + container_limits={'memory': None}, ) def test_ensure_image_exists_no_build(self): @@ -541,6 +542,7 @@ def test_ensure_image_exists_force_build(self): target=None, shmsize=None, extra_hosts=None, + container_limits={'memory': None}, ) def test_build_does_not_pull(self): From 34ea11fcb72dcf4d314f62c457d2cfab1f81ee6c Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 1 Dec 2017 15:23:32 -0800 Subject: [PATCH 3181/4072] Allow port publish ranges Signed-off-by: Joffrey F --- compose/config/types.py | 18 +++++++++++++----- tests/unit/config/types_test.py | 26 ++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/compose/config/types.py b/compose/config/types.py index 548f2c1cda1..d3b3cfc5392 100644 --- a/compose/config/types.py +++ b/compose/config/types.py @@ -319,11 +319,19 @@ def __new__(cls, target, published, *args, **kwargs): except ValueError: raise ConfigurationError('Invalid target port: {}'.format(target)) - try: - if published: - published = int(published) - except ValueError: - raise ConfigurationError('Invalid published port: {}'.format(published)) + if published: + if isinstance(published, six.string_types) and '-' in published: # "x-y:z" format + a, b = published.split('-', 1) + try: + int(a) + int(b) + except ValueError: + raise ConfigurationError('Invalid published port: {}'.format(published)) + else: + try: + published = int(published) + except ValueError: + raise ConfigurationError('Invalid published port: {}'.format(published)) return super(ServicePort, cls).__new__( cls, target, published, *args, **kwargs diff --git a/tests/unit/config/types_test.py b/tests/unit/config/types_test.py index 3a43f727bd9..e7cc67b0422 100644 --- a/tests/unit/config/types_test.py +++ b/tests/unit/config/types_test.py @@ -100,11 +100,37 @@ def test_parse_port_range(self): 'published': 25001 } in reprs + def test_parse_port_publish_range(self): + ports = ServicePort.parse('4440-4450:4000') + assert len(ports) == 1 + reprs = [p.repr() for p in ports] + assert { + 'target': 4000, + 'published': '4440-4450' + } in reprs + def test_parse_invalid_port(self): port_def = '4000p' with pytest.raises(ConfigurationError): ServicePort.parse(port_def) + def test_parse_invalid_publish_range(self): + port_def = '-4000:4000' + with pytest.raises(ConfigurationError): + ServicePort.parse(port_def) + + port_def = 'asdf:4000' + with pytest.raises(ConfigurationError): + ServicePort.parse(port_def) + + port_def = '1234-12f:4000' + with pytest.raises(ConfigurationError): + ServicePort.parse(port_def) + + port_def = '1234-1235-1239:4000' + with pytest.raises(ConfigurationError): + ServicePort.parse(port_def) + class TestVolumeSpec(object): From 58f2f10d49a8b46888173236277658af39971f36 Mon Sep 17 00:00:00 2001 From: Madeline Stager Date: Mon, 4 Dec 2017 20:01:00 -0600 Subject: [PATCH 3182/4072] Raise error if up used with both -d and --timeout Fix #5434 Signed-off-by: Madeline Stager --- compose/cli/main.py | 10 +++++++--- tests/acceptance/cli_test.py | 15 +++------------ 2 files changed, 10 insertions(+), 15 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index f842f05c8b0..222f7d0135c 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -898,8 +898,8 @@ def up(self, options): Options: -d Detached mode: Run containers in the background, - print new container names. - Incompatible with --abort-on-container-exit. + print new container names. Incompatible with + --abort-on-container-exit and --timeout. --no-color Produce monochrome output. --no-deps Don't start linked services. --force-recreate Recreate containers even if their configuration @@ -913,7 +913,8 @@ def up(self, options): --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 + when attached or when containers are already. + Incompatible with -d. running. (default: 10) --remove-orphans Remove containers for services not defined in the Compose file @@ -934,6 +935,9 @@ def up(self, options): if detached and (cascade_stop or exit_value_from): raise UserError("--abort-on-container-exit and -d cannot be combined.") + if detached and timeout: + raise UserError("-d and --timeout cannot be combined.") + if no_start: for excluded in ['-d', '--abort-on-container-exit', '--exit-code-from']: if options.get(excluded): diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 21e71675192..251e39db6f7 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1325,18 +1325,9 @@ def test_up_with_force_recreate_and_no_recreate(self): ['up', '-d', '--force-recreate', '--no-recreate'], returncode=1) - def test_up_with_timeout(self): - self.dispatch(['up', '-d', '-t', '1']) - service = self.project.get_service('simple') - another = self.project.get_service('another') - self.assertEqual(len(service.containers()), 1) - self.assertEqual(len(another.containers()), 1) - - # Ensure containers don't have stdin and stdout connected in -d mode - config = service.containers()[0].inspect()['Config'] - self.assertFalse(config['AttachStderr']) - self.assertFalse(config['AttachStdout']) - self.assertFalse(config['AttachStdin']) + def test_up_with_timeout_detached(self): + result = self.dispatch(['up', '-d', '-t', '1'], returncode=1) + assert "-d and --timeout cannot be combined." in result.stderr def test_up_handles_sigint(self): proc = start_process(self.base_dir, ['up', '-t', '2']) From 084818ce2b31e121268228e9b696ed0bab43bad2 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 4 Dec 2017 22:47:33 -0800 Subject: [PATCH 3183/4072] Add support for mount syntax Signed-off-by: Joffrey F --- compose/config/config.py | 40 +++++++------ compose/config/config_schema_v2.3.json | 34 ++++++++++- compose/config/serialize.py | 6 ++ compose/config/types.py | 13 ++++ compose/service.py | 63 ++++++++++++++------ compose/utils.py | 2 +- compose/volume.py | 9 ++- tests/acceptance/cli_test.py | 22 ++++--- tests/helpers.py | 13 ++-- tests/integration/project_test.py | 21 +++++++ tests/integration/service_test.py | 82 ++++++++++++++++++++++++++ tests/integration/testcases.py | 6 +- tests/unit/config/config_test.py | 53 ++++++++++++++++- tests/unit/service_test.py | 4 +- 14 files changed, 309 insertions(+), 59 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 864bc7e90ae..9b41305360e 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -35,6 +35,7 @@ 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 MountSpec from .types import parse_extra_hosts from .types import parse_restart_spec from .types import ServiceLink @@ -809,6 +810,20 @@ def process_healthcheck(service_dict): return service_dict +def finalize_service_volumes(service_dict, environment): + if 'volumes' in service_dict: + finalized_volumes = [] + normalize = environment.get_boolean('COMPOSE_CONVERT_WINDOWS_PATHS') + for v in service_dict['volumes']: + if isinstance(v, dict): + finalized_volumes.append(MountSpec.parse(v, normalize)) + else: + finalized_volumes.append(VolumeSpec.parse(v, normalize)) + service_dict['volumes'] = finalized_volumes + + return service_dict + + def finalize_service(service_config, service_names, version, environment): service_dict = dict(service_config.config) @@ -822,12 +837,7 @@ def finalize_service(service_config, service_names, version, environment): for vf in service_dict['volumes_from'] ] - if 'volumes' in service_dict: - service_dict['volumes'] = [ - VolumeSpec.parse( - v, environment.get_boolean('COMPOSE_CONVERT_WINDOWS_PATHS') - ) for v in service_dict['volumes'] - ] + service_dict = finalize_service_volumes(service_dict, environment) if 'net' in service_dict: network_mode = service_dict.pop('net') @@ -1143,19 +1153,13 @@ def resolve_volume_paths(working_dir, service_dict): def resolve_volume_path(working_dir, volume): - mount_params = None if isinstance(volume, dict): - container_path = volume.get('target') - host_path = volume.get('source') - mode = None - if host_path: - if volume.get('read_only'): - mode = 'ro' - if volume.get('volume', {}).get('nocopy'): - mode = 'nocopy' - mount_params = (host_path, mode) - else: - container_path, mount_params = split_path_mapping(volume) + if volume.get('source', '').startswith('.') and volume['type'] == 'mount': + volume['source'] = expand_path(working_dir, volume['source']) + return volume + + mount_params = None + container_path, mount_params = split_path_mapping(volume) if mount_params is not None: host_path, mode = mount_params diff --git a/compose/config/config_schema_v2.3.json b/compose/config/config_schema_v2.3.json index 6f923871bfd..d50df3e81d4 100644 --- a/compose/config/config_schema_v2.3.json +++ b/compose/config/config_schema_v2.3.json @@ -293,7 +293,39 @@ }, "user": {"type": "string"}, "userns_mode": {"type": "string"}, - "volumes": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "volumes": { + "type": "array", + "items": { + "oneOf": [ + {"type": "string"}, + { + "type": "object", + "required": ["type"], + "additionalProperties": false, + "properties": { + "type": {"type": "string"}, + "source": {"type": "string"}, + "target": {"type": "string"}, + "read_only": {"type": "boolean"}, + "consistency": {"type": "string"}, + "bind": { + "type": "object", + "properties": { + "propagation": {"type": "string"} + } + }, + "volume": { + "type": "object", + "properties": { + "nocopy": {"type": "boolean"} + } + } + } + } + ], + "uniqueItems": true + } + }, "volume_driver": {"type": "string"}, "volumes_from": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, "working_dir": {"type": "string"} diff --git a/compose/config/serialize.py b/compose/config/serialize.py index 2b8c73f14c1..5e80e70e01a 100644 --- a/compose/config/serialize.py +++ b/compose/config/serialize.py @@ -7,6 +7,7 @@ from compose.config import types from compose.const import COMPOSEFILE_V1 as V1 from compose.const import COMPOSEFILE_V2_1 as V2_1 +from compose.const import COMPOSEFILE_V2_3 as V2_3 from compose.const import COMPOSEFILE_V3_0 as V3_0 from compose.const import COMPOSEFILE_V3_2 as V3_2 from compose.const import COMPOSEFILE_V3_4 as V3_4 @@ -34,6 +35,7 @@ def serialize_string(dumper, data): return representer(data) +yaml.SafeDumper.add_representer(types.MountSpec, serialize_dict_type) yaml.SafeDumper.add_representer(types.VolumeFromSpec, serialize_config_type) yaml.SafeDumper.add_representer(types.VolumeSpec, serialize_config_type) yaml.SafeDumper.add_representer(types.ServiceSecret, serialize_dict_type) @@ -141,5 +143,9 @@ def denormalize_service_dict(service_dict, version, image_digest=None): p.legacy_repr() if isinstance(p, types.ServicePort) else p for p in service_dict['ports'] ] + if 'volumes' in service_dict and (version < V2_3 or (version > V3_0 and version < V3_2)): + service_dict['volumes'] = [ + v.legacy_repr() if isinstance(v, types.MountSpec) else v for v in service_dict['volumes'] + ] return service_dict diff --git a/compose/config/types.py b/compose/config/types.py index d3b3cfc5392..c134bd7ca32 100644 --- a/compose/config/types.py +++ b/compose/config/types.py @@ -144,6 +144,15 @@ class MountSpec(object): } _fields = ['type', 'source', 'target', 'read_only', 'consistency'] + @classmethod + def parse(cls, mount_dict, normalize=False): + if mount_dict.get('source'): + mount_dict['source'] = os.path.normpath(mount_dict['source']) + if normalize: + mount_dict['source'] = normalize_path_for_engine(mount_dict['source']) + + return cls(**mount_dict) + def __init__(self, type, source=None, target=None, read_only=None, consistency=None, **kwargs): self.type = type self.source = source @@ -174,6 +183,10 @@ def repr(self): def is_named_volume(self): return self.type == 'volume' and self.source + @property + def external(self): + return self.source + class VolumeSpec(namedtuple('_VolumeSpec', 'external internal mode')): diff --git a/compose/service.py b/compose/service.py index bfc2e594068..f51f0e5af72 100644 --- a/compose/service.py +++ b/compose/service.py @@ -785,15 +785,23 @@ def _get_container_create_options( self.options.get('labels'), override_options.get('labels')) + container_volumes = [] + container_mounts = [] + if 'volumes' in container_options: + container_volumes = [ + v for v in container_options.get('volumes') if isinstance(v, VolumeSpec) + ] + container_mounts = [v for v in container_options.get('volumes') if isinstance(v, MountSpec)] + binds, affinity = merge_volume_bindings( - container_options.get('volumes') or [], - self.options.get('tmpfs') or [], - previous_container) + container_volumes, self.options.get('tmpfs') or [], previous_container, + container_mounts + ) override_options['binds'] = binds container_options['environment'].update(affinity) - container_options['volumes'] = dict( - (v.internal, {}) for v in container_options.get('volumes') or {}) + container_options['volumes'] = dict((v.internal, {}) for v in container_volumes or {}) + override_options['mounts'] = [build_mount(v) for v in container_mounts] or None secret_volumes = self.get_secret_volumes() if secret_volumes: @@ -803,7 +811,8 @@ def _get_container_create_options( (v.target, {}) for v in secret_volumes ) else: - override_options['mounts'] = [build_mount(v) for v in secret_volumes] + override_options['mounts'] = override_options.get('mounts') or [] + override_options['mounts'].extend([build_mount(v) for v in secret_volumes]) container_options['image'] = self.image_name @@ -1245,32 +1254,40 @@ def parse_repository_tag(repo_path): # Volumes -def merge_volume_bindings(volumes, tmpfs, previous_container): - """Return a list of volume bindings for a container. Container data volumes - are replaced by those from the previous container. +def merge_volume_bindings(volumes, tmpfs, previous_container, mounts): + """ + Return a list of volume bindings for a container. Container data volumes + are replaced by those from the previous container. + Anonymous mounts are updated in place. """ affinity = {} volume_bindings = dict( build_volume_binding(volume) for volume in volumes - if volume.external) + if volume.external + ) if previous_container: - old_volumes = get_container_data_volumes(previous_container, volumes, tmpfs) + old_volumes, old_mounts = get_container_data_volumes( + previous_container, volumes, tmpfs, mounts + ) warn_on_masked_volume(volumes, old_volumes, previous_container.service) volume_bindings.update( - build_volume_binding(volume) for volume in old_volumes) + build_volume_binding(volume) for volume in old_volumes + ) - if old_volumes: + if old_volumes or old_mounts: affinity = {'affinity:container': '=' + previous_container.id} return list(volume_bindings.values()), affinity -def get_container_data_volumes(container, volumes_option, tmpfs_option): - """Find the container data volumes that are in `volumes_option`, and return - a mapping of volume bindings for those volumes. +def get_container_data_volumes(container, volumes_option, tmpfs_option, mounts_option): + """ + Find the container data volumes that are in `volumes_option`, and return + a mapping of volume bindings for those volumes. + Anonymous volume mounts are updated in place instead. """ volumes = [] volumes_option = volumes_option or [] @@ -1309,7 +1326,19 @@ def get_container_data_volumes(container, volumes_option, tmpfs_option): volume = volume._replace(external=mount['Name']) volumes.append(volume) - return volumes + updated_mounts = False + for mount in mounts_option: + if mount.type != 'volume': + continue + + ctnr_mount = container_mounts.get(mount.target) + if not ctnr_mount.get('Name'): + continue + + mount.source = ctnr_mount['Name'] + updated_mounts = True + + return volumes, updated_mounts def warn_on_masked_volume(volumes_option, container_volumes, service): diff --git a/compose/utils.py b/compose/utils.py index 197ae6eb29a..00b01df2e37 100644 --- a/compose/utils.py +++ b/compose/utils.py @@ -101,7 +101,7 @@ def json_stream(stream): def json_hash(obj): - dump = json.dumps(obj, sort_keys=True, separators=(',', ':')) + dump = json.dumps(obj, sort_keys=True, separators=(',', ':'), default=lambda x: x.repr()) h = hashlib.sha256() h.update(dump.encode('utf8')) return h.hexdigest() diff --git a/compose/volume.py b/compose/volume.py index da8ba25cab6..0b148620fbe 100644 --- a/compose/volume.py +++ b/compose/volume.py @@ -7,6 +7,7 @@ from docker.utils import version_lt from .config import ConfigurationError +from .config.types import VolumeSpec from .const import LABEL_PROJECT from .const import LABEL_VOLUME @@ -145,5 +146,9 @@ 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) + if isinstance(volume_spec, VolumeSpec): + volume = self.volumes[volume_spec.external] + return volume_spec._replace(external=volume.full_name) + else: + volume_spec.source = self.volumes[volume_spec.source].full_name + return volume_spec diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 251e39db6f7..91e75abad54 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -428,13 +428,21 @@ def test_config_v3(self): 'timeout': '1s', 'retries': 5, }, - 'volumes': [ - '/host/path:/container/path:ro', - 'foobar:/container/volumepath:rw', - '/anonymous', - 'foobar:/container/volumepath2:nocopy' - ], - + 'volumes': [{ + 'read_only': True, + 'source': '/host/path', + 'target': '/container/path', + 'type': 'bind' + }, { + 'source': 'foobar', 'target': '/container/volumepath', 'type': 'volume' + }, { + 'target': '/anonymous', 'type': 'volume' + }, { + 'source': 'foobar', + 'target': '/container/volumepath2', + 'type': 'volume', + 'volume': {'nocopy': True} + }], 'stop_grace_period': '20s', }, }, diff --git a/tests/helpers.py b/tests/helpers.py index a93de993f13..f151f9cde40 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -19,12 +19,8 @@ def build_config_details(contents, working_dir='working_dir', filename='filename ) -def create_host_file(client, filename): +def create_custom_host_file(client, filename, content): 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)], @@ -48,3 +44,10 @@ def create_host_file(client, filename): return container_info['Node']['Name'] finally: client.remove_container(container, force=True) + + +def create_host_file(client, filename): + with open(filename, 'r') as fh: + content = fh.read() + + return create_custom_host_file(client, filename, content) diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 953dd52beb8..6686d96cce5 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -35,6 +35,7 @@ from tests.integration.testcases import no_cluster from tests.integration.testcases import v2_1_only from tests.integration.testcases import v2_2_only +from tests.integration.testcases import v2_3_only from tests.integration.testcases import v2_only from tests.integration.testcases import v3_only @@ -436,6 +437,26 @@ def test_recreate_preserves_volumes(self): self.assertNotEqual(db_container.id, old_db_id) self.assertEqual(db_container.get('Volumes./etc'), db_volume_path) + @v2_3_only() + def test_recreate_preserves_mounts(self): + web = self.create_service('web') + db = self.create_service('db', volumes=[types.MountSpec(type='volume', target='/etc')]) + project = Project('composetest', [web, db], self.client) + project.start() + assert len(project.containers()) == 0 + + project.up(['db']) + assert len(project.containers()) == 1 + old_db_id = project.containers()[0].id + db_volume_path = project.containers()[0].get_mount('/etc')['Source'] + + project.up(strategy=ConvergenceStrategy.always) + assert len(project.containers()) == 2 + + db_container = [c for c in project.containers() if 'db' in c.name][0] + assert db_container.id != old_db_id + assert db_container.get_mount('/etc')['Source'] == db_volume_path + def test_project_up_with_no_recreate_running(self): web = self.create_service('web') db = self.create_service('db', volumes=[VolumeSpec.parse('/var/db')]) diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 00bacebf5bc..b9005b8e104 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -19,6 +19,7 @@ from .testcases import SWARM_SKIP_CONTAINERS_ALL from .testcases import SWARM_SKIP_CPU_SHARES from compose import __version__ +from compose.config.types import MountSpec from compose.config.types import VolumeFromSpec from compose.config.types import VolumeSpec from compose.const import IS_WINDOWS_PLATFORM @@ -37,6 +38,7 @@ from compose.service import PidMode from compose.service import Service from compose.utils import parse_nanoseconds_int +from tests.helpers import create_custom_host_file from tests.integration.testcases import is_cluster from tests.integration.testcases import no_cluster from tests.integration.testcases import v2_1_only @@ -276,6 +278,54 @@ def test_create_container_with_specified_volume(self): self.assertTrue(path.basename(actual_host_path) == path.basename(host_path), msg=("Last component differs: %s, %s" % (actual_host_path, host_path))) + @v2_3_only() + def test_create_container_with_host_mount(self): + host_path = '/tmp/host-path' + container_path = '/container-path' + + create_custom_host_file(self.client, path.join(host_path, 'a.txt'), 'test') + + service = self.create_service( + 'db', + volumes=[ + MountSpec(type='bind', source=host_path, target=container_path, read_only=True) + ] + ) + container = service.create_container() + service.start_container(container) + mount = container.get_mount(container_path) + assert mount + assert path.basename(mount['Source']) == path.basename(host_path) + assert mount['RW'] is False + + @v2_3_only() + def test_create_container_with_tmpfs_mount(self): + container_path = '/container-tmpfs' + service = self.create_service( + 'db', + volumes=[MountSpec(type='tmpfs', target=container_path)] + ) + container = service.create_container() + service.start_container(container) + mount = container.get_mount(container_path) + assert mount + assert mount['Type'] == 'tmpfs' + + @v2_3_only() + def test_create_container_with_volume_mount(self): + container_path = '/container-volume' + volume_name = 'composetest_abcde' + self.client.create_volume(volume_name) + service = self.create_service( + 'db', + volumes=[MountSpec(type='volume', source=volume_name, target=container_path)] + ) + container = service.create_container() + service.start_container(container) + mount = container.get_mount(container_path) + assert mount + assert mount['Name'] == volume_name + def test_create_container_with_healthcheck_config(self): one_second = parse_nanoseconds_int('1s') healthcheck = { @@ -439,6 +489,38 @@ def test_execute_convergence_plan_recreate_twice(self): orig_container = new_container + @v2_3_only() + def test_execute_convergence_plan_recreate_twice_with_mount(self): + service = self.create_service( + 'db', + volumes=[MountSpec(target='/etc', type='volume')], + 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 + if not is_cluster(self.client): + assert ('affinity:container==%s' % orig_container.id in + new_container.get('Config.Env')) + else: + # In Swarm, the env marker is consumed and the container should be deployed + # on the same node. + assert orig_container.get('Node.Name') == new_container.get('Node.Name') + + orig_container = new_container + def test_execute_convergence_plan_when_containers_are_stopped(self): service = self.create_service( 'db', diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index 8435f97ddcf..5505df1b428 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -20,7 +20,7 @@ from compose.const import COMPOSEFILE_V2_3 as V2_3 from compose.const import COMPOSEFILE_V3_0 as V3_0 from compose.const import COMPOSEFILE_V3_2 as V3_2 -from compose.const import COMPOSEFILE_V3_3 as V3_3 +from compose.const import COMPOSEFILE_V3_5 as V3_5 from compose.const import LABEL_PROJECT from compose.progress_stream import stream_output from compose.service import Service @@ -47,7 +47,7 @@ def format_link(link): def engine_max_version(): if 'DOCKER_VERSION' not in os.environ: - return V3_3 + return V3_5 version = os.environ['DOCKER_VERSION'].partition('-')[0] if version_lt(version, '1.10'): return V1 @@ -57,7 +57,7 @@ def engine_max_version(): return V2_1 if version_lt(version, '17.06'): return V3_2 - return V3_3 + return V3_5 def min_version_skip(version): diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 00ba6c2c650..d519deb9043 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1137,9 +1137,12 @@ def test_load_with_multiple_files_v3_2(self): details = config.ConfigDetails('.', [base_file, override_file]) service_dicts = config.load(details).services svc_volumes = map(lambda v: v.repr(), service_dicts[0]['volumes']) - assert sorted(svc_volumes) == sorted( - ['/anonymous', '/c:/b:rw', 'vol:/x:ro'] - ) + for vol in svc_volumes: + assert vol in [ + '/anonymous', + '/c:/b:rw', + {'source': 'vol', 'target': '/x', 'type': 'volume', 'read_only': True} + ] @mock.patch.dict(os.environ) def test_volume_mode_override(self): @@ -1223,6 +1226,50 @@ def test_undeclared_volume_v1(self): assert volume.external == 'data0028' assert volume.is_named_volume + def test_volumes_long_syntax(self): + base_file = config.ConfigFile( + 'base.yaml', { + 'version': '2.3', + 'services': { + 'web': { + 'image': 'busybox:latest', + 'volumes': [ + { + 'target': '/anonymous', 'type': 'volume' + }, { + 'source': '/abc', 'target': '/xyz', 'type': 'bind' + }, { + 'source': '\\\\.\\pipe\\abcd', 'target': '/named_pipe', 'type': 'npipe' + }, { + 'type': 'tmpfs', 'target': '/tmpfs' + } + ] + }, + }, + }, + ) + details = config.ConfigDetails('.', [base_file]) + config_data = config.load(details) + volumes = config_data.services[0].get('volumes') + anon_volume = [v for v in volumes if v.target == '/anonymous'][0] + tmpfs_mount = [v for v in volumes if v.type == 'tmpfs'][0] + host_mount = [v for v in volumes if v.type == 'bind'][0] + npipe_mount = [v for v in volumes if v.type == 'npipe'][0] + + assert anon_volume.type == 'volume' + assert not anon_volume.is_named_volume + + assert tmpfs_mount.target == '/tmpfs' + assert not tmpfs_mount.is_named_volume + + assert host_mount.source == os.path.normpath('/abc') + assert host_mount.target == '/xyz' + assert not host_mount.is_named_volume + + assert npipe_mount.source == '\\\\.\\pipe\\abcd' + assert npipe_mount.target == '/named_pipe' + assert not npipe_mount.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/service_test.py b/tests/unit/service_test.py index 16670cff5fc..24ed60e94e8 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -939,7 +939,7 @@ def test_get_container_data_volumes(self): VolumeSpec.parse('imagedata:/mnt/image/data:rw'), ] - volumes = get_container_data_volumes(container, options, ['/dev/tmpfs']) + volumes, _ = get_container_data_volumes(container, options, ['/dev/tmpfs'], []) assert sorted(volumes) == sorted(expected) def test_merge_volume_bindings(self): @@ -975,7 +975,7 @@ def test_merge_volume_bindings(self): 'existingvolume:/existing/volume:rw', ] - binds, affinity = merge_volume_bindings(options, ['/dev/tmpfs'], previous_container) + binds, affinity = merge_volume_bindings(options, ['/dev/tmpfs'], previous_container, []) assert sorted(binds) == sorted(expected) assert affinity == {'affinity:container': '=cdefab'} From 99e9e32d7ebc2da54c4f1634560fbf344ce5b68d Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 6 Dec 2017 16:48:14 -0800 Subject: [PATCH 3184/4072] Add support for custom names for networks, secrets, configs Finalize v3.5 schema Signed-off-by: Joffrey F --- compose/config/config.py | 5 +- compose/config/config_schema_v2.1.json | 3 +- compose/config/config_schema_v2.2.json | 3 +- compose/config/config_schema_v2.3.json | 3 +- compose/config/config_schema_v3.5.json | 57 ++++++++++++++----- compose/config/serialize.py | 10 +++- compose/config/types.py | 5 +- compose/network.py | 23 ++++---- compose/project.py | 2 +- docker-compose.spec | 5 ++ tests/acceptance/cli_test.py | 16 ++++++ .../networks/external-networks-v3-5.yml | 17 ++++++ tests/integration/project_test.py | 37 ++++++++++++ tests/unit/config/config_test.py | 54 ++++++++++++++---- 14 files changed, 196 insertions(+), 44 deletions(-) create mode 100644 tests/fixtures/networks/external-networks-v3-5.yml diff --git a/compose/config/config.py b/compose/config/config.py index 9b41305360e..98719d6bab9 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -410,12 +410,11 @@ def load_mapping(config_files, get_func, entity_type, working_dir=None): external = config.get('external') if external: - name_field = 'name' if entity_type == 'Volume' else 'external_name' validate_external(entity_type, name, config, config_file.version) if isinstance(external, dict): - config[name_field] = external.get('name') + config['name'] = external.get('name') elif not config.get('name'): - config[name_field] = name + config['name'] = name if 'driver_opts' in config: config['driver_opts'] = build_string_dict( diff --git a/compose/config/config_schema_v2.1.json b/compose/config/config_schema_v2.1.json index 6b74f0ed699..15b78e5db79 100644 --- a/compose/config/config_schema_v2.1.json +++ b/compose/config/config_schema_v2.1.json @@ -350,7 +350,8 @@ }, "internal": {"type": "boolean"}, "enable_ipv6": {"type": "boolean"}, - "labels": {"$ref": "#/definitions/list_or_dict"} + "labels": {"$ref": "#/definitions/list_or_dict"}, + "name": {"type": "string"} }, "additionalProperties": false }, diff --git a/compose/config/config_schema_v2.2.json b/compose/config/config_schema_v2.2.json index 21343b8932c..7a3eed0a9f7 100644 --- a/compose/config/config_schema_v2.2.json +++ b/compose/config/config_schema_v2.2.json @@ -357,7 +357,8 @@ }, "internal": {"type": "boolean"}, "enable_ipv6": {"type": "boolean"}, - "labels": {"$ref": "#/definitions/list_or_dict"} + "labels": {"$ref": "#/definitions/list_or_dict"}, + "name": {"type": "string"} }, "additionalProperties": false }, diff --git a/compose/config/config_schema_v2.3.json b/compose/config/config_schema_v2.3.json index d50df3e81d4..7c0e5480721 100644 --- a/compose/config/config_schema_v2.3.json +++ b/compose/config/config_schema_v2.3.json @@ -393,7 +393,8 @@ }, "internal": {"type": "boolean"}, "enable_ipv6": {"type": "boolean"}, - "labels": {"$ref": "#/definitions/list_or_dict"} + "labels": {"$ref": "#/definitions/list_or_dict"}, + "name": {"type": "string"} }, "additionalProperties": false }, diff --git a/compose/config/config_schema_v3.5.json b/compose/config/config_schema_v3.5.json index 1e65b208793..565da019375 100644 --- a/compose/config/config_schema_v3.5.json +++ b/compose/config/config_schema_v3.5.json @@ -155,6 +155,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}, @@ -282,7 +283,6 @@ { "type": "object", "required": ["type"], - "additionalProperties": false, "properties": { "type": {"type": "string"}, "source": {"type": "string"}, @@ -301,7 +301,8 @@ "nocopy": {"type": "boolean"} } } - } + }, + "additionalProperties": false } ], "uniqueItems": true @@ -318,7 +319,7 @@ "additionalProperties": false, "properties": { "disable": {"type": "boolean"}, - "interval": {"type": "string"}, + "interval": {"type": "string", "format": "duration"}, "retries": {"type": "number"}, "test": { "oneOf": [ @@ -326,7 +327,8 @@ {"type": "array", "items": {"type": "string"}} ] }, - "timeout": {"type": "string"} + "timeout": {"type": "string", "format": "duration"}, + "start_period": {"type": "string", "format": "duration"} } }, "deployment": { @@ -354,8 +356,23 @@ "resources": { "type": "object", "properties": { - "limits": {"$ref": "#/definitions/resource"}, - "reservations": {"$ref": "#/definitions/resource"} + "limits": { + "type": "object", + "properties": { + "cpus": {"type": "string"}, + "memory": {"type": "string"} + }, + "additionalProperties": false + }, + "reservations": { + "type": "object", + "properties": { + "cpus": {"type": "string"}, + "memory": {"type": "string"}, + "generic_resources": {"$ref": "#/definitions/generic_resources"} + }, + "additionalProperties": false + } }, "additionalProperties": false }, @@ -390,20 +407,30 @@ "additionalProperties": false }, - "resource": { - "id": "#/definitions/resource", - "type": "object", - "properties": { - "cpus": {"type": "string"}, - "memory": {"type": "string"} - }, - "additionalProperties": false + "generic_resources": { + "id": "#/definitions/generic_resources", + "type": "array", + "items": { + "type": "object", + "properties": { + "discrete_resource_spec": { + "type": "object", + "properties": { + "kind": {"type": "string"}, + "value": {"type": "number"} + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } }, "network": { "id": "#/definitions/network", "type": ["object", "null"], "properties": { + "name": {"type": "string"}, "driver": {"type": "string"}, "driver_opts": { "type": "object", @@ -470,6 +497,7 @@ "id": "#/definitions/secret", "type": "object", "properties": { + "name": {"type": "string"}, "file": {"type": "string"}, "external": { "type": ["boolean", "object"], @@ -486,6 +514,7 @@ "id": "#/definitions/config", "type": "object", "properties": { + "name": {"type": "string"}, "file": {"type": "string"}, "external": { "type": ["boolean", "object"], diff --git a/compose/config/serialize.py b/compose/config/serialize.py index 5e80e70e01a..3ab43fc5935 100644 --- a/compose/config/serialize.py +++ b/compose/config/serialize.py @@ -11,6 +11,7 @@ from compose.const import COMPOSEFILE_V3_0 as V3_0 from compose.const import COMPOSEFILE_V3_2 as V3_2 from compose.const import COMPOSEFILE_V3_4 as V3_4 +from compose.const import COMPOSEFILE_V3_5 as V3_5 def serialize_config_type(dumper, data): @@ -69,7 +70,8 @@ def denormalize_config(config, image_digests=None): del conf['external_name'] if 'name' in conf: - if config.version < V2_1 or (config.version >= V3_0 and config.version < V3_4): + if config.version < V2_1 or ( + config.version >= V3_0 and config.version < v3_introduced_name_key(key)): del conf['name'] elif 'external' in conf: conf['external'] = True @@ -77,6 +79,12 @@ def denormalize_config(config, image_digests=None): return result +def v3_introduced_name_key(key): + if key == 'volumes': + return V3_4 + return V3_5 + + def serialize_config(config, image_digests=None): return yaml.safe_dump( denormalize_config(config, image_digests), diff --git a/compose/config/types.py b/compose/config/types.py index c134bd7ca32..daf25f7007c 100644 --- a/compose/config/types.py +++ b/compose/config/types.py @@ -293,17 +293,18 @@ def merge_field(self): return self.alias -class ServiceConfigBase(namedtuple('_ServiceConfigBase', 'source target uid gid mode')): +class ServiceConfigBase(namedtuple('_ServiceConfigBase', 'source target uid gid mode name')): @classmethod def parse(cls, spec): if isinstance(spec, six.string_types): - return cls(spec, None, None, None, None) + return cls(spec, None, None, None, None, None) return cls( spec.get('source'), spec.get('target'), spec.get('uid'), spec.get('gid'), spec.get('mode'), + spec.get('name') ) @property diff --git a/compose/network.py b/compose/network.py index ee5939c1593..95e2bf60e5f 100644 --- a/compose/network.py +++ b/compose/network.py @@ -25,21 +25,22 @@ class Network(object): def __init__(self, client, project, name, driver=None, driver_opts=None, - ipam=None, external_name=None, internal=False, enable_ipv6=False, - labels=None): + ipam=None, external=False, internal=False, enable_ipv6=False, + labels=None, custom_name=False): 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 + self.external = external self.internal = internal self.enable_ipv6 = enable_ipv6 self.labels = labels + self.custom_name = custom_name def ensure(self): - if self.external_name: + if self.external: try: self.inspect() log.debug( @@ -51,7 +52,7 @@ def ensure(self): 'Network {name} declared as external, but could' ' not be found. Please create the network manually' ' using `{command} {name}` and try again.'.format( - name=self.external_name, + name=self.full_name, command='docker network create' ) ) @@ -83,7 +84,7 @@ def ensure(self): ) def remove(self): - if self.external_name: + if self.external: log.info("Network %s is external, skipping", self.full_name) return @@ -95,8 +96,8 @@ def inspect(self): @property def full_name(self): - if self.external_name: - return self.external_name + if self.custom_name: + return self.name return '{0}_{1}'.format(self.project, self.name) @property @@ -203,14 +204,16 @@ def build_networks(name, config_data, client): network_config = config_data.networks or {} networks = { network_name: Network( - client=client, project=name, name=network_name, + client=client, project=name, + name=data.get('name', network_name), driver=data.get('driver'), driver_opts=data.get('driver_opts'), ipam=data.get('ipam'), - external_name=data.get('external_name'), + external=bool(data.get('external', False)), internal=data.get('internal'), enable_ipv6=data.get('enable_ipv6'), labels=data.get('labels'), + custom_name=data.get('name') is not None, ) for network_name, data in network_config.items() } diff --git a/compose/project.py b/compose/project.py index 4115763863f..11ee4a0b73c 100644 --- a/compose/project.py +++ b/compose/project.py @@ -648,7 +648,7 @@ def get_secrets(service, service_secrets, secret_defs): "Service \"{service}\" uses an undefined secret \"{secret}\" " .format(service=service, secret=secret.source)) - if secret_def.get('external_name'): + if secret_def.get('external'): 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)) diff --git a/docker-compose.spec b/docker-compose.spec index 9c46421f0a5..83d7389f378 100644 --- a/docker-compose.spec +++ b/docker-compose.spec @@ -67,6 +67,11 @@ exe = EXE(pyz, 'compose/config/config_schema_v3.4.json', 'DATA' ), + ( + 'compose/config/config_schema_v3.5.json', + 'compose/config/config_schema_v3.5.json', + 'DATA' + ), ( 'compose/GITSHA', 'compose/GITSHA', diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 91e75abad54..3225eb49b81 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -350,6 +350,22 @@ def test_config_external_volume_v3_4(self): } } + def test_config_external_network_v3_5(self): + self.base_dir = 'tests/fixtures/networks' + result = self.dispatch(['-f', 'external-networks-v3-5.yml', 'config']) + json_result = yaml.load(result.stdout) + assert 'networks' in json_result + assert json_result['networks'] == { + 'foo': { + 'external': True, + 'name': 'some_foo', + }, + 'bar': { + 'external': True, + 'name': 'some_bar', + }, + } + def test_config_v1(self): self.base_dir = 'tests/fixtures/v1-config' result = self.dispatch(['config']) diff --git a/tests/fixtures/networks/external-networks-v3-5.yml b/tests/fixtures/networks/external-networks-v3-5.yml new file mode 100644 index 00000000000..9ac7b14b5f7 --- /dev/null +++ b/tests/fixtures/networks/external-networks-v3-5.yml @@ -0,0 +1,17 @@ +version: "3.5" + +services: + web: + image: busybox + command: top + networks: + - foo + - bar + +networks: + foo: + external: true + name: some_foo + bar: + external: + name: some_bar diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 6686d96cce5..82e0adab369 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -953,6 +953,43 @@ def test_up_with_network_link_local_ips(self): assert 'LinkLocalIPs' in ipam_config assert ipam_config['LinkLocalIPs'] == ['169.254.8.8'] + @v2_1_only() + def test_up_with_custom_name_resources(self): + config_data = build_config( + version=V2_2, + services=[{ + 'name': 'web', + 'volumes': [VolumeSpec.parse('foo:/container-path')], + 'networks': {'foo': {}}, + 'image': 'busybox:latest' + }], + networks={ + 'foo': { + 'name': 'zztop', + 'labels': {'com.docker.compose.test_value': 'sharpdressedman'} + } + }, + volumes={ + 'foo': { + 'name': 'acdc', + 'labels': {'com.docker.compose.test_value': 'thefuror'} + } + } + ) + + project = Project.from_config( + client=self.client, + name='composetest', + config_data=config_data + ) + + project.up(detached=True) + network = [n for n in self.client.networks() if n['Name'] == 'zztop'][0] + volume = [v for v in self.client.volumes()['Volumes'] if v['Name'] == 'acdc'][0] + + assert network['Labels']['com.docker.compose.test_value'] == 'sharpdressedman' + assert volume['Labels']['com.docker.compose.test_value'] == 'thefuror' + @v2_1_only() def test_up_with_isolation(self): self.require_api_version('1.24') diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index d519deb9043..7029fcb088e 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -432,6 +432,40 @@ def test_load_config_service_labels(self): 'label_key': 'label_val' } + def test_load_config_custom_resource_names(self): + base_file = config.ConfigFile( + 'base.yaml', { + 'version': '3.5', + 'volumes': { + 'abc': { + 'name': 'xyz' + } + }, + 'networks': { + 'abc': { + 'name': 'xyz' + } + }, + 'secrets': { + 'abc': { + 'name': 'xyz' + } + }, + 'configs': { + 'abc': { + 'name': 'xyz' + } + } + } + ) + details = config.ConfigDetails('.', [base_file]) + loaded_config = config.load(details) + + assert loaded_config.networks['abc'] == {'name': 'xyz'} + assert loaded_config.volumes['abc'] == {'name': 'xyz'} + assert loaded_config.secrets['abc']['name'] == 'xyz' + assert loaded_config.configs['abc']['name'] == 'xyz' + def test_load_config_volume_and_network_labels(self): base_file = config.ConfigFile( 'base.yaml', @@ -2539,8 +2573,8 @@ def test_load_secrets(self): 'name': 'web', 'image': 'example/web', 'secrets': [ - types.ServiceSecret('one', None, None, None, None), - types.ServiceSecret('source', 'target', '100', '200', 0o777), + types.ServiceSecret('one', None, None, None, None, None), + types.ServiceSecret('source', 'target', '100', '200', 0o777, None), ], }, ] @@ -2586,8 +2620,8 @@ def test_load_secrets_multi_file(self): 'name': 'web', 'image': 'example/web', 'secrets': [ - types.ServiceSecret('one', None, None, None, None), - types.ServiceSecret('source', 'target', '100', '200', 0o777), + types.ServiceSecret('one', None, None, None, None, None), + types.ServiceSecret('source', 'target', '100', '200', 0o777, None), ], }, ] @@ -2624,8 +2658,8 @@ def test_load_configs(self): 'name': 'web', 'image': 'example/web', 'configs': [ - types.ServiceConfig('one', None, None, None, None), - types.ServiceConfig('source', 'target', '100', '200', 0o777), + types.ServiceConfig('one', None, None, None, None, None), + types.ServiceConfig('source', 'target', '100', '200', 0o777, None), ], }, ] @@ -2671,8 +2705,8 @@ def test_load_configs_multi_file(self): 'name': 'web', 'image': 'example/web', 'configs': [ - types.ServiceConfig('one', None, None, None, None), - types.ServiceConfig('source', 'target', '100', '200', 0o777), + types.ServiceConfig('one', None, None, None, None, None), + types.ServiceConfig('source', 'target', '100', '200', 0o777, None), ], }, ] @@ -3131,7 +3165,7 @@ def test_interpolation_secrets_section(self): assert config_dict.secrets == { 'secretdata': { 'external': {'name': 'baz.bar'}, - 'external_name': 'baz.bar' + 'name': 'baz.bar' } } @@ -3149,7 +3183,7 @@ def test_interpolation_configs_section(self): assert config_dict.configs == { 'configdata': { 'external': {'name': 'baz.bar'}, - 'external_name': 'baz.bar' + 'name': 'baz.bar' } } From 29c02ef598d1888fb389fa959ed2317afb40cc4f Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 6 Dec 2017 17:39:38 -0800 Subject: [PATCH 3185/4072] Fix bad rebase Signed-off-by: Joffrey F --- tests/acceptance/cli_test.py | 18 ------------------ tests/integration/testcases.py | 2 -- 2 files changed, 20 deletions(-) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 3225eb49b81..c4905f9096b 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -536,24 +536,6 @@ def test_pull_with_quiet(self): assert self.dispatch(['pull', '--quiet']).stderr == '' assert self.dispatch(['pull', '--quiet']).stdout == '' - def test_pull_with_quiet(self): - assert self.dispatch(['pull', '--quiet']).stderr == '' - assert self.dispatch(['pull', '--quiet']).stdout == '' - - def test_pull_with_parallel_failure(self): - result = self.dispatch([ - '-f', 'ignore-pull-failures.yml', 'pull', '--parallel'], - returncode=1 - ) - - self.assertRegexpMatches(result.stderr, re.compile('^Pulling simple', re.MULTILINE)) - self.assertRegexpMatches(result.stderr, re.compile('^Pulling another', re.MULTILINE)) - self.assertRegexpMatches(result.stderr, - re.compile('^ERROR: for another .*does not exist.*', re.MULTILINE)) - self.assertRegexpMatches(result.stderr, - re.compile('''^(ERROR: )?(b')?.* nonexisting-image''', - re.MULTILINE)) - def test_build_plain(self): self.base_dir = 'tests/fixtures/simple-dockerfile' self.dispatch(['build', 'simple']) diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index 5505df1b428..9427f3d0dad 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -75,7 +75,6 @@ def v2_1_only(): return min_version_skip(V2_1) - def v2_2_only(): return min_version_skip(V2_2) @@ -84,7 +83,6 @@ def v2_3_only(): return min_version_skip(V2_3) - def v3_only(): return min_version_skip(V3_0) From e96dfbac2a7982b8703abd3774ab661516096931 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 6 Dec 2017 17:25:37 -0800 Subject: [PATCH 3186/4072] Bump 1.18.0-rc1 Signed-off-by: Joffrey F --- CHANGELOG.md | 80 +++++++++++++++++++++++++++++++++++++++++++++ compose/__init__.py | 2 +- script/run/run.sh | 2 +- 3 files changed, 82 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d0be7ea76a8..ba91a505bc8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,86 @@ Change log ========== +1.18.0 (2017-12-15) +------------------- + +### New features + +#### Compose file version 3.5 + +- Introduced version 3.5 of the `docker-compose.yml` specification. + This version requires to be used with Docker Engine 17.06.0 or above + +- Added support for the `shm_size` parameter in build configurations + +- Added support for the `isolation` parameter in service definitions + +- Added support for custom names for network, secret and config definitions + +#### Compose file version 2.3 + +- Added support for `extra_hosts` in build configuration + +- Added support for the + [long syntax](https://docs.docker.com/compose/compose-file/#long-syntax-3) + for volume entries, as previously introduced in the 3.2 format. + Note that using this syntax will create + [mounts](https://docs.docker.com/engine/admin/volumes/bind-mounts/) + instead of volumes. + +#### Compose file version 2.1 and up + +- Added support for the `oom_kill_disable` parameter in service definitions + (2.x only) + +- Added support for custom names for network, secret and config definitions + (2.x only) + + +#### All formats + +- Values interpolated from the environment will now be converted to the + proper type when used in non-string fields. + +- Added support for `--labels` in `docker-compose run` + +- Added support for `--timeout` in `docker-compose down` + +- Added support for `--memory` in `docker-compose build` + +- Setting `stop_grace_period` in service definitions now also sets the + container's `stop_timeout` + +### Bugfixes + +- Fixed an issue where Compose was still handling service hostname according + to legacy engine behavior, causing hostnames containing dots to be cut up + +- Fixed a bug where the `X-Y:Z` syntax for ports was considered invalid + by Compose + +- Fixed an issue with CLI logging causing duplicate messages and inelegant + output to occur + +- Fixed a bug where the valid `${VAR:-}` syntax would cause Compose to + error out + +- Fixed a bug where `env_file` entries using an UTF-8 BOM were being read + incorrectly + +- Fixed a bug where missing secret files would generate an empty directory + in their place + +- Added validation for the `test` field in healthchecks + +- Added validation for the `subnet` field in IPAM configurations + +- Added validation for `volumes` properties when using the long syntax in + service definitions + +- The CLI now explicit prevents using `-d` and `--timeout` together + in `docker-compose up` + 1.17.1 (2017-11-08) ------------------ diff --git a/compose/__init__.py b/compose/__init__.py index 7b954eb4f67..2b363f3bea2 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.18.0dev' +__version__ = '1.18.0-rc1' diff --git a/script/run/run.sh b/script/run/run.sh index 58483196d62..441c0d80613 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.17.1" +VERSION="1.18.0-rc1" IMAGE="docker/compose:$VERSION" From bb42537bcf83387bb6c0a44f9c58452195cc903a Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Thu, 7 Dec 2017 17:49:39 +0100 Subject: [PATCH 3187/4072] Add bash completion for `down --timeout` Signed-off-by: Harald Albers --- contrib/completion/bash/docker-compose | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index 87161d0ac93..f3e14b02f22 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -194,11 +194,14 @@ _docker_compose_down() { COMPREPLY=( $( compgen -W "all local" -- "$cur" ) ) return ;; + --timeout|-t) + return + ;; esac case "$cur" in -*) - COMPREPLY=( $( compgen -W "--help --rmi --volumes -v --remove-orphans" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--help --rmi --timeout -t --volumes -v --remove-orphans" -- "$cur" ) ) ;; esac } From 38ae7ae037fc0d626cf847c6781ebba67de9cfff Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 7 Dec 2017 12:04:26 -0800 Subject: [PATCH 3188/4072] Update script to generate sha256 files Signed-off-by: Joffrey F --- script/release/download-binaries | 2 ++ 1 file changed, 2 insertions(+) diff --git a/script/release/download-binaries b/script/release/download-binaries index bef5430f4b3..0b187f6c26c 100755 --- a/script/release/download-binaries +++ b/script/release/download-binaries @@ -33,5 +33,7 @@ wget -O $DESTINATION/docker-compose-Windows-x86_64.exe $APPVEYOR_URL echo -e "\n\nCopy the following lines into the integrity check table in the release notes:\n\n" cd $DESTINATION +rm -rf *.sha256 ls | xargs sha256sum | sed 's/ / | /g' | sed -r 's/([^ |]+)/`\1`/g' +ls | xargs -I@ bash -c "sha256sum @ | cut -d' ' -f1 > @.sha256" cd - From 5c0a06475ccbce0aa8a36a5287b9401625255107 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 7 Dec 2017 12:44:17 -0800 Subject: [PATCH 3189/4072] Expand mount source when type == bind Signed-off-by: Joffrey F --- compose/config/config.py | 2 +- tests/unit/config/config_test.py | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/compose/config/config.py b/compose/config/config.py index 98719d6bab9..51391fc7b0d 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -1153,7 +1153,7 @@ def resolve_volume_paths(working_dir, service_dict): def resolve_volume_path(working_dir, volume): if isinstance(volume, dict): - if volume.get('source', '').startswith('.') and volume['type'] == 'mount': + if volume.get('source', '').startswith('.') and volume['type'] == 'bind': volume['source'] = expand_path(working_dir, volume['source']) return volume diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 7029fcb088e..122ab2ef918 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1304,6 +1304,29 @@ def test_volumes_long_syntax(self): assert npipe_mount.target == '/named_pipe' assert not npipe_mount.is_named_volume + def test_load_bind_mount_relative_path(self): + expected_source = 'C:\\tmp\\web' if IS_WINDOWS_PLATFORM else '/tmp/web' + base_file = config.ConfigFile( + 'base.yaml', { + 'version': '3.4', + 'services': { + 'web': { + 'image': 'busybox:latest', + 'volumes': [ + {'type': 'bind', 'source': './web', 'target': '/web'}, + ], + }, + }, + }, + ) + + details = config.ConfigDetails('/tmp', [base_file]) + config_data = config.load(details) + mount = config_data.services[0].get('volumes')[0] + assert mount.target == '/web' + assert mount.type == 'bind' + assert mount.source == expected_source + def test_config_valid_service_names(self): for valid_name in ['_', '-', '.__.', '_what-up.', 'what_.up----', 'whatup']: services = config.load( From d60f94b1bf4d508c5b3de5dcda53dab1c930a28d Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 7 Dec 2017 15:46:58 -0800 Subject: [PATCH 3190/4072] Recover from possible unicode errors in get_conn_error_message Signed-off-by: Joffrey F --- compose/cli/errors.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/compose/cli/errors.py b/compose/cli/errors.py index 1506aa66078..eefa4ebe483 100644 --- a/compose/cli/errors.py +++ b/compose/cli/errors.py @@ -106,7 +106,8 @@ def log_api_error(e, client_version): 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)) + "version {version} or greater.".format(version=version) + ) def exit_with_error(msg): @@ -115,12 +116,17 @@ def exit_with_error(msg): def get_conn_error_message(url): - 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 find_executable('docker-machine') is not None: - return conn_error_docker_machine + try: + 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 find_executable('docker-machine') is not None: + return conn_error_docker_machine + except UnicodeDecodeError: + # https://github.com/docker/compose/issues/5442 + # Ignore the error and print the generic message instead. + pass return conn_error_generic.format(url=url) From d0644bdff8ca78e73dee17279ce0eccd12fe41a6 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 8 Dec 2017 14:16:14 -0800 Subject: [PATCH 3191/4072] Handle non-ascii characters in npipe error handler Signed-off-by: Joffrey F --- compose/cli/errors.py | 10 +++++----- compose/cli/utils.py | 13 +++++++++++++ tests/unit/cli/errors_test.py | 10 ++++++++++ 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/compose/cli/errors.py b/compose/cli/errors.py index eefa4ebe483..82768970b30 100644 --- a/compose/cli/errors.py +++ b/compose/cli/errors.py @@ -7,7 +7,6 @@ from distutils.spawn import find_executable from textwrap import dedent -import six from docker.errors import APIError from requests.exceptions import ConnectionError as RequestsConnectionError from requests.exceptions import ReadTimeout @@ -15,6 +14,7 @@ from requests.packages.urllib3.exceptions import ReadTimeoutError from ..const import API_VERSION_TO_ENGINE_VERSION +from .utils import binarystr_to_unicode from .utils import is_docker_for_mac_installed from .utils import is_mac from .utils import is_ubuntu @@ -75,7 +75,9 @@ def log_windows_pipe_error(exc): ) else: log.error( - "Windows named pipe error: {} (code: {})".format(exc.strerror, exc.winerror) + "Windows named pipe error: {} (code: {})".format( + binarystr_to_unicode(exc.strerror), exc.winerror + ) ) @@ -89,9 +91,7 @@ def log_timeout_error(timeout): def log_api_error(e, client_version): - explanation = e.explanation - if isinstance(explanation, six.binary_type): - explanation = explanation.decode('utf-8') + explanation = binarystr_to_unicode(e.explanation) if 'client is newer than server' not in explanation: log.error(explanation) diff --git a/compose/cli/utils.py b/compose/cli/utils.py index 4d4fc4c1814..a171d6678e6 100644 --- a/compose/cli/utils.py +++ b/compose/cli/utils.py @@ -10,6 +10,7 @@ import sys import docker +import six import compose from ..const import IS_WINDOWS_PLATFORM @@ -148,3 +149,15 @@ def human_readable_file_size(size): size / float(1 << (order * 10)), suffixes[order] ) + + +def binarystr_to_unicode(s): + if not isinstance(s, six.binary_type): + return s + + if IS_WINDOWS_PLATFORM: + try: + return s.decode('windows-1250') + except UnicodeDecodeError: + pass + return s.decode('utf-8', 'replace') diff --git a/tests/unit/cli/errors_test.py b/tests/unit/cli/errors_test.py index 68326d1c753..7b53ed2b15f 100644 --- a/tests/unit/cli/errors_test.py +++ b/tests/unit/cli/errors_test.py @@ -86,3 +86,13 @@ def test_windows_pipe_error_misc(self, mock_logging): _, args, _ = mock_logging.error.mock_calls[0] assert "Windows named pipe error: The pipe is busy. (code: 231)" == args[0] + + @pytest.mark.skipif(not IS_WINDOWS_PLATFORM, reason='Needs pywin32') + def test_windows_pipe_error_encoding_issue(self, mock_logging): + import pywintypes + with pytest.raises(errors.ConnectionError): + with handle_connection_errors(mock.Mock(api_version='1.22')): + raise pywintypes.error(9999, 'WriteFile', 'I use weird characters \xe9') + + _, args, _ = mock_logging.error.mock_calls[0] + assert 'Windows named pipe error: I use weird characters \xe9 (code: 9999)' == args[0] From 2e232ee97cb0274060b59336f294c5abcd489db7 Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Thu, 7 Dec 2017 17:55:38 +0100 Subject: [PATCH 3192/4072] Fix wrong option name in changelog Signed-off-by: Harald Albers --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba91a505bc8..74d4f93f685 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,7 +42,7 @@ Change log - Values interpolated from the environment will now be converted to the proper type when used in non-string fields. -- Added support for `--labels` in `docker-compose run` +- Added support for `--label` in `docker-compose run` - Added support for `--timeout` in `docker-compose down` From ad40a9e65463f81c5b112d996a891c676a8588d4 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 7 Dec 2017 12:44:17 -0800 Subject: [PATCH 3193/4072] Expand mount source when type == bind Signed-off-by: Joffrey F --- compose/config/config.py | 2 +- tests/unit/config/config_test.py | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/compose/config/config.py b/compose/config/config.py index 98719d6bab9..51391fc7b0d 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -1153,7 +1153,7 @@ def resolve_volume_paths(working_dir, service_dict): def resolve_volume_path(working_dir, volume): if isinstance(volume, dict): - if volume.get('source', '').startswith('.') and volume['type'] == 'mount': + if volume.get('source', '').startswith('.') and volume['type'] == 'bind': volume['source'] = expand_path(working_dir, volume['source']) return volume diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 7029fcb088e..122ab2ef918 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1304,6 +1304,29 @@ def test_volumes_long_syntax(self): assert npipe_mount.target == '/named_pipe' assert not npipe_mount.is_named_volume + def test_load_bind_mount_relative_path(self): + expected_source = 'C:\\tmp\\web' if IS_WINDOWS_PLATFORM else '/tmp/web' + base_file = config.ConfigFile( + 'base.yaml', { + 'version': '3.4', + 'services': { + 'web': { + 'image': 'busybox:latest', + 'volumes': [ + {'type': 'bind', 'source': './web', 'target': '/web'}, + ], + }, + }, + }, + ) + + details = config.ConfigDetails('/tmp', [base_file]) + config_data = config.load(details) + mount = config_data.services[0].get('volumes')[0] + assert mount.target == '/web' + assert mount.type == 'bind' + assert mount.source == expected_source + def test_config_valid_service_names(self): for valid_name in ['_', '-', '.__.', '_what-up.', 'what_.up----', 'whatup']: services = config.load( From f79f06ca4a4901ad2bf4bdb057e1682973723623 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 7 Dec 2017 15:46:58 -0800 Subject: [PATCH 3194/4072] Recover from possible unicode errors in get_conn_error_message Signed-off-by: Joffrey F --- compose/cli/errors.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/compose/cli/errors.py b/compose/cli/errors.py index 1506aa66078..eefa4ebe483 100644 --- a/compose/cli/errors.py +++ b/compose/cli/errors.py @@ -106,7 +106,8 @@ def log_api_error(e, client_version): 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)) + "version {version} or greater.".format(version=version) + ) def exit_with_error(msg): @@ -115,12 +116,17 @@ def exit_with_error(msg): def get_conn_error_message(url): - 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 find_executable('docker-machine') is not None: - return conn_error_docker_machine + try: + 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 find_executable('docker-machine') is not None: + return conn_error_docker_machine + except UnicodeDecodeError: + # https://github.com/docker/compose/issues/5442 + # Ignore the error and print the generic message instead. + pass return conn_error_generic.format(url=url) From 45d2eb40039da06279683e113393d8e286189e3d Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 8 Dec 2017 14:16:14 -0800 Subject: [PATCH 3195/4072] Handle non-ascii characters in npipe error handler Signed-off-by: Joffrey F --- compose/cli/errors.py | 10 +++++----- compose/cli/utils.py | 13 +++++++++++++ tests/unit/cli/errors_test.py | 10 ++++++++++ 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/compose/cli/errors.py b/compose/cli/errors.py index eefa4ebe483..82768970b30 100644 --- a/compose/cli/errors.py +++ b/compose/cli/errors.py @@ -7,7 +7,6 @@ from distutils.spawn import find_executable from textwrap import dedent -import six from docker.errors import APIError from requests.exceptions import ConnectionError as RequestsConnectionError from requests.exceptions import ReadTimeout @@ -15,6 +14,7 @@ from requests.packages.urllib3.exceptions import ReadTimeoutError from ..const import API_VERSION_TO_ENGINE_VERSION +from .utils import binarystr_to_unicode from .utils import is_docker_for_mac_installed from .utils import is_mac from .utils import is_ubuntu @@ -75,7 +75,9 @@ def log_windows_pipe_error(exc): ) else: log.error( - "Windows named pipe error: {} (code: {})".format(exc.strerror, exc.winerror) + "Windows named pipe error: {} (code: {})".format( + binarystr_to_unicode(exc.strerror), exc.winerror + ) ) @@ -89,9 +91,7 @@ def log_timeout_error(timeout): def log_api_error(e, client_version): - explanation = e.explanation - if isinstance(explanation, six.binary_type): - explanation = explanation.decode('utf-8') + explanation = binarystr_to_unicode(e.explanation) if 'client is newer than server' not in explanation: log.error(explanation) diff --git a/compose/cli/utils.py b/compose/cli/utils.py index 4d4fc4c1814..a171d6678e6 100644 --- a/compose/cli/utils.py +++ b/compose/cli/utils.py @@ -10,6 +10,7 @@ import sys import docker +import six import compose from ..const import IS_WINDOWS_PLATFORM @@ -148,3 +149,15 @@ def human_readable_file_size(size): size / float(1 << (order * 10)), suffixes[order] ) + + +def binarystr_to_unicode(s): + if not isinstance(s, six.binary_type): + return s + + if IS_WINDOWS_PLATFORM: + try: + return s.decode('windows-1250') + except UnicodeDecodeError: + pass + return s.decode('utf-8', 'replace') diff --git a/tests/unit/cli/errors_test.py b/tests/unit/cli/errors_test.py index 68326d1c753..7b53ed2b15f 100644 --- a/tests/unit/cli/errors_test.py +++ b/tests/unit/cli/errors_test.py @@ -86,3 +86,13 @@ def test_windows_pipe_error_misc(self, mock_logging): _, args, _ = mock_logging.error.mock_calls[0] assert "Windows named pipe error: The pipe is busy. (code: 231)" == args[0] + + @pytest.mark.skipif(not IS_WINDOWS_PLATFORM, reason='Needs pywin32') + def test_windows_pipe_error_encoding_issue(self, mock_logging): + import pywintypes + with pytest.raises(errors.ConnectionError): + with handle_connection_errors(mock.Mock(api_version='1.22')): + raise pywintypes.error(9999, 'WriteFile', 'I use weird characters \xe9') + + _, args, _ = mock_logging.error.mock_calls[0] + assert 'Windows named pipe error: I use weird characters \xe9 (code: 9999)' == args[0] From 189468b07f295c8df8997e770701be31159ef54e Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 8 Dec 2017 14:29:43 -0800 Subject: [PATCH 3196/4072] Bump 1.18.0-rc2 Signed-off-by: Joffrey F --- CHANGELOG.md | 2 ++ compose/__init__.py | 2 +- script/run/run.sh | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 74d4f93f685..ac2050512a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -71,6 +71,8 @@ Change log - Fixed a bug where missing secret files would generate an empty directory in their place +- Fixed character encoding issues in the CLI's error handlers + - Added validation for the `test` field in healthchecks - Added validation for the `subnet` field in IPAM configurations diff --git a/compose/__init__.py b/compose/__init__.py index 2b363f3bea2..231670a5c41 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.18.0-rc1' +__version__ = '1.18.0-rc2' diff --git a/script/run/run.sh b/script/run/run.sh index 441c0d80613..4be14b722cc 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.18.0-rc1" +VERSION="1.18.0-rc2" IMAGE="docker/compose:$VERSION" From 48166a79c7dd484e082c09594c4abe44cf75da20 Mon Sep 17 00:00:00 2001 From: Shea Rozmiarek Date: Fri, 8 Dec 2017 00:34:22 -0600 Subject: [PATCH 3197/4072] Add COMPOSE_PARALLEL_LIMIT to restrict global number of parallel operations Signed-off-by: Shea Rozmiarek --- compose/const.py | 1 + compose/parallel.py | 16 +++++++++++++++- tests/unit/parallel_test.py | 27 +++++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/compose/const.py b/compose/const.py index 2ac08b89a70..6e5902cadf5 100644 --- a/compose/const.py +++ b/compose/const.py @@ -18,6 +18,7 @@ LABEL_VOLUME = 'com.docker.compose.volume' LABEL_CONFIG_HASH = 'com.docker.compose.config-hash' NANOCPUS_SCALE = 1000000000 +PARALLEL_LIMIT = 64 SECRETS_PATH = '/run/secrets' diff --git a/compose/parallel.py b/compose/parallel.py index f271561ff05..4f881c8f1ab 100644 --- a/compose/parallel.py +++ b/compose/parallel.py @@ -15,6 +15,8 @@ from compose.cli.colors import green from compose.cli.colors import red from compose.cli.signals import ShutdownException +from compose.config.environment import Environment +from compose.const import PARALLEL_LIMIT from compose.errors import HealthCheckFailed from compose.errors import NoHealthCheckConfigured from compose.errors import OperationFailedError @@ -26,6 +28,18 @@ STOP = object() +def get_configured_limit(): + limit = Environment.from_command_line({'COMPOSE_PARALLEL_LIMIT': None})['COMPOSE_PARALLEL_LIMIT'] + if limit: + limit = int(limit) + else: + limit = PARALLEL_LIMIT + return limit + + +global_limiter = Semaphore(get_configured_limit()) + + def parallel_execute(objects, func, get_name, msg, get_deps=None, limit=None, parent_objects=None): """Runs func on objects in parallel while ensuring that func is ran on object only after it is ran on all its dependencies. @@ -173,7 +187,7 @@ def producer(obj, func, results, limiter): 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. """ - with limiter: + with limiter, global_limiter: try: result = func(obj) results.put((obj, result, None)) diff --git a/tests/unit/parallel_test.py b/tests/unit/parallel_test.py index 3a60f01a69c..7aabed17b97 100644 --- a/tests/unit/parallel_test.py +++ b/tests/unit/parallel_test.py @@ -1,11 +1,13 @@ from __future__ import absolute_import from __future__ import unicode_literals +import os from threading import Lock import six from docker.errors import APIError +from compose.parallel import get_configured_limit from compose.parallel import parallel_execute from compose.parallel import parallel_execute_iter from compose.parallel import ParallelStreamWriter @@ -67,6 +69,31 @@ def f(obj): assert errors == {} +def test_parallel_execute_with_global_limit(): + os.environ['COMPOSE_PARALLEL_LIMIT'] = '1' + tasks = 20 + lock = Lock() + + assert get_configured_limit() == 1 + + def f(obj): + locked = lock.acquire(False) + # we should always get the lock because we're the only thread running + assert locked + lock.release() + return None + + results, errors = parallel_execute( + objects=list(range(tasks)), + func=f, + get_name=six.text_type, + msg="Testing", + ) + + assert results == tasks * [None] + assert errors == {} + + def test_parallel_execute_with_deps(): log = [] From fffcc05a8e2c8115991d456992b928b804199157 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 11 Dec 2017 11:03:19 -0800 Subject: [PATCH 3198/4072] Add stop_grace_period to ALLOWED_KEYS Signed-off-by: Joffrey F --- compose/config/config.py | 1 + tests/unit/config/config_test.py | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/compose/config/config.py b/compose/config/config.py index 51391fc7b0d..95c12d1cc6f 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -126,6 +126,7 @@ 'network_mode', 'init', 'scale', + 'stop_grace_period', ] DOCKER_VALID_URL_PREFIXES = ( diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 122ab2ef918..e16e4bfa11a 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1150,7 +1150,8 @@ def test_load_with_multiple_files_v3_2(self): 'volumes': [ {'source': '/a', 'target': '/b', 'type': 'bind'}, {'source': 'vol', 'target': '/x', 'type': 'volume', 'read_only': True} - ] + ], + 'stop_grace_period': '30s', } }, 'volumes': {'vol': {}} @@ -1177,6 +1178,7 @@ def test_load_with_multiple_files_v3_2(self): '/c:/b:rw', {'source': 'vol', 'target': '/x', 'type': 'volume', 'read_only': True} ] + assert service_dicts[0]['stop_grace_period'] == '30s' @mock.patch.dict(os.environ) def test_volume_mode_override(self): From c2a5dd4c89312aea63195f4f8620346de83d4e4b Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 11 Dec 2017 11:03:46 -0800 Subject: [PATCH 3199/4072] Re-align docstring Signed-off-by: Joffrey F --- compose/cli/main.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 222f7d0135c..46c6b965220 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -365,17 +365,17 @@ def down(self, options): 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 - -t, --timeout TIMEOUT Specify a shutdown timeout in seconds. - (default: 10) + --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 + -t, --timeout TIMEOUT Specify a shutdown timeout in seconds. + (default: 10) """ image_type = image_type_from_opt('--rmi', options['--rmi']) timeout = timeout_from_opts(options) From c8ba50ff1ee5a4c0019096512bda9aaaf6e53f06 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 12 Dec 2017 14:44:15 -0800 Subject: [PATCH 3200/4072] Avoid CLI crash if image has no tags Signed-off-by: Joffrey F --- compose/cli/main.py | 5 +++- tests/acceptance/cli_test.py | 26 +++++++++++++++---- tests/fixtures/tagless-image/Dockerfile | 2 ++ .../fixtures/tagless-image/docker-compose.yml | 5 ++++ 4 files changed, 32 insertions(+), 6 deletions(-) create mode 100644 tests/fixtures/tagless-image/Dockerfile create mode 100644 tests/fixtures/tagless-image/docker-compose.yml diff --git a/compose/cli/main.py b/compose/cli/main.py index 46c6b965220..308ac5bb252 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -511,7 +511,10 @@ def images(self, options): rows = [] for container in containers: image_config = container.image_config - repo_tags = image_config['RepoTags'][0].rsplit(':', 1) + repo_tags = ( + image_config['RepoTags'][0].rsplit(':', 1) if image_config['RepoTags'] + else ('', '') + ) image_id = image_config['Id'].split(':')[1][:12] size = human_readable_file_size(image_config['Size']) rows.append([ diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 502907fe72e..74c4e0159d5 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -2447,14 +2447,30 @@ def test_images_default_composefile(self): assert 'multiplecomposefiles_another_1' in result.stdout assert 'multiplecomposefiles_simple_1' in result.stdout + @mock.patch.dict(os.environ) + def test_images_tagless_image(self): + self.base_dir = 'tests/fixtures/tagless-image' + stream = self.client.build(self.base_dir, decode=True) + img_id = None + for data in stream: + if 'aux' in data: + img_id = data['aux']['ID'] + break + if 'stream' in data and 'Successfully built' in data['stream']: + img_id = self.client.inspect_image(data['stream'].split(' ')[2].strip())['Id'] + + assert img_id + + os.environ['IMAGE_ID'] = img_id + self.project.get_service('foo').create_container() + result = self.dispatch(['images']) + assert '' in result.stdout + assert 'taglessimage_foo_1' in result.stdout + def test_up_with_override_yaml(self): self.base_dir = 'tests/fixtures/override-yaml-files' self._project = get_project(self.base_dir, []) - self.dispatch( - [ - 'up', '-d', - ], - None) + self.dispatch(['up', '-d'], None) containers = self.project.containers() self.assertEqual(len(containers), 2) diff --git a/tests/fixtures/tagless-image/Dockerfile b/tests/fixtures/tagless-image/Dockerfile new file mode 100644 index 00000000000..56741055233 --- /dev/null +++ b/tests/fixtures/tagless-image/Dockerfile @@ -0,0 +1,2 @@ +FROM busybox:latest +RUN touch /blah diff --git a/tests/fixtures/tagless-image/docker-compose.yml b/tests/fixtures/tagless-image/docker-compose.yml new file mode 100644 index 00000000000..c4baf2ba153 --- /dev/null +++ b/tests/fixtures/tagless-image/docker-compose.yml @@ -0,0 +1,5 @@ +version: '2.3' +services: + foo: + image: ${IMAGE_ID} + command: top From 7d628ad1ab86b19d248f600ea5b5c958c92e6b58 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 11 Dec 2017 11:03:19 -0800 Subject: [PATCH 3201/4072] Add stop_grace_period to ALLOWED_KEYS Signed-off-by: Joffrey F --- compose/config/config.py | 1 + tests/unit/config/config_test.py | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/compose/config/config.py b/compose/config/config.py index 51391fc7b0d..95c12d1cc6f 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -126,6 +126,7 @@ 'network_mode', 'init', 'scale', + 'stop_grace_period', ] DOCKER_VALID_URL_PREFIXES = ( diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 122ab2ef918..e16e4bfa11a 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1150,7 +1150,8 @@ def test_load_with_multiple_files_v3_2(self): 'volumes': [ {'source': '/a', 'target': '/b', 'type': 'bind'}, {'source': 'vol', 'target': '/x', 'type': 'volume', 'read_only': True} - ] + ], + 'stop_grace_period': '30s', } }, 'volumes': {'vol': {}} @@ -1177,6 +1178,7 @@ def test_load_with_multiple_files_v3_2(self): '/c:/b:rw', {'source': 'vol', 'target': '/x', 'type': 'volume', 'read_only': True} ] + assert service_dicts[0]['stop_grace_period'] == '30s' @mock.patch.dict(os.environ) def test_volume_mode_override(self): From 7614becbfeda0457228bd381eee611c140134303 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 11 Dec 2017 11:03:46 -0800 Subject: [PATCH 3202/4072] Re-align docstring Signed-off-by: Joffrey F --- compose/cli/main.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 222f7d0135c..46c6b965220 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -365,17 +365,17 @@ def down(self, options): 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 - -t, --timeout TIMEOUT Specify a shutdown timeout in seconds. - (default: 10) + --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 + -t, --timeout TIMEOUT Specify a shutdown timeout in seconds. + (default: 10) """ image_type = image_type_from_opt('--rmi', options['--rmi']) timeout = timeout_from_opts(options) From d5167d53290bfab93c42fb3e1884cbff238df303 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 12 Dec 2017 14:44:15 -0800 Subject: [PATCH 3203/4072] Avoid CLI crash if image has no tags Signed-off-by: Joffrey F --- compose/cli/main.py | 5 +++- tests/acceptance/cli_test.py | 26 +++++++++++++++---- tests/fixtures/tagless-image/Dockerfile | 2 ++ .../fixtures/tagless-image/docker-compose.yml | 5 ++++ 4 files changed, 32 insertions(+), 6 deletions(-) create mode 100644 tests/fixtures/tagless-image/Dockerfile create mode 100644 tests/fixtures/tagless-image/docker-compose.yml diff --git a/compose/cli/main.py b/compose/cli/main.py index 46c6b965220..308ac5bb252 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -511,7 +511,10 @@ def images(self, options): rows = [] for container in containers: image_config = container.image_config - repo_tags = image_config['RepoTags'][0].rsplit(':', 1) + repo_tags = ( + image_config['RepoTags'][0].rsplit(':', 1) if image_config['RepoTags'] + else ('', '') + ) image_id = image_config['Id'].split(':')[1][:12] size = human_readable_file_size(image_config['Size']) rows.append([ diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index c4905f9096b..e0541f99fbf 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -2447,14 +2447,30 @@ def test_images_default_composefile(self): assert 'multiplecomposefiles_another_1' in result.stdout assert 'multiplecomposefiles_simple_1' in result.stdout + @mock.patch.dict(os.environ) + def test_images_tagless_image(self): + self.base_dir = 'tests/fixtures/tagless-image' + stream = self.client.build(self.base_dir, decode=True) + img_id = None + for data in stream: + if 'aux' in data: + img_id = data['aux']['ID'] + break + if 'stream' in data and 'Successfully built' in data['stream']: + img_id = self.client.inspect_image(data['stream'].split(' ')[2].strip())['Id'] + + assert img_id + + os.environ['IMAGE_ID'] = img_id + self.project.get_service('foo').create_container() + result = self.dispatch(['images']) + assert '' in result.stdout + assert 'taglessimage_foo_1' in result.stdout + def test_up_with_override_yaml(self): self.base_dir = 'tests/fixtures/override-yaml-files' self._project = get_project(self.base_dir, []) - self.dispatch( - [ - 'up', '-d', - ], - None) + self.dispatch(['up', '-d'], None) containers = self.project.containers() self.assertEqual(len(containers), 2) diff --git a/tests/fixtures/tagless-image/Dockerfile b/tests/fixtures/tagless-image/Dockerfile new file mode 100644 index 00000000000..56741055233 --- /dev/null +++ b/tests/fixtures/tagless-image/Dockerfile @@ -0,0 +1,2 @@ +FROM busybox:latest +RUN touch /blah diff --git a/tests/fixtures/tagless-image/docker-compose.yml b/tests/fixtures/tagless-image/docker-compose.yml new file mode 100644 index 00000000000..c4baf2ba153 --- /dev/null +++ b/tests/fixtures/tagless-image/docker-compose.yml @@ -0,0 +1,5 @@ +version: '2.3' +services: + foo: + image: ${IMAGE_ID} + command: top From dc6b464751b8d2cabc1c4f60403c68f0d4f4e369 Mon Sep 17 00:00:00 2001 From: Ashlie Martinez Date: Tue, 12 Dec 2017 11:15:01 -0600 Subject: [PATCH 3204/4072] Do not bleed env values outside of test. Signed-off-by: Ashlie Martinez --- tests/unit/parallel_test.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/unit/parallel_test.py b/tests/unit/parallel_test.py index 7aabed17b97..529395aa350 100644 --- a/tests/unit/parallel_test.py +++ b/tests/unit/parallel_test.py @@ -7,6 +7,7 @@ import six from docker.errors import APIError +from .. import mock from compose.parallel import get_configured_limit from compose.parallel import parallel_execute from compose.parallel import parallel_execute_iter @@ -69,6 +70,7 @@ def f(obj): assert errors == {} +@mock.patch.dict(os.environ) def test_parallel_execute_with_global_limit(): os.environ['COMPOSE_PARALLEL_LIMIT'] = '1' tasks = 20 From acf76c15a21903764e583ac31b844f09d05fecc6 Mon Sep 17 00:00:00 2001 From: Ashlie Martinez Date: Sat, 16 Dec 2017 18:11:55 -0600 Subject: [PATCH 3205/4072] Allow parallel limit to be set in env file. Signed-off-by: Ashlie Martinez --- compose/cli/command.py | 7 ++++++- compose/parallel.py | 20 ++++++++++---------- compose/project.py | 9 ++++++--- tests/unit/parallel_test.py | 9 ++------- 4 files changed, 24 insertions(+), 21 deletions(-) diff --git a/compose/cli/command.py b/compose/cli/command.py index e1ae690c0eb..6bfc2301937 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -99,8 +99,13 @@ def get_project(project_dir, config_path=None, project_name=None, verbose=False, host=host, environment=environment ) + global_parallel_limit = environment.get('COMPOSE_PARALLEL_LIMIT') + if global_parallel_limit: + global_parallel_limit = int(global_parallel_limit) + with errors.handle_connection_errors(client): - return Project.from_config(project_name, config_data, client) + return Project.from_config(project_name, config_data, client, + global_parallel_limit=global_parallel_limit) def get_project_name(working_dir, project_name=None, environment=None): diff --git a/compose/parallel.py b/compose/parallel.py index 4f881c8f1ab..382ce025131 100644 --- a/compose/parallel.py +++ b/compose/parallel.py @@ -15,7 +15,6 @@ from compose.cli.colors import green from compose.cli.colors import red from compose.cli.signals import ShutdownException -from compose.config.environment import Environment from compose.const import PARALLEL_LIMIT from compose.errors import HealthCheckFailed from compose.errors import NoHealthCheckConfigured @@ -28,16 +27,17 @@ STOP = object() -def get_configured_limit(): - limit = Environment.from_command_line({'COMPOSE_PARALLEL_LIMIT': None})['COMPOSE_PARALLEL_LIMIT'] - if limit: - limit = int(limit) - else: - limit = PARALLEL_LIMIT - return limit +class GlobalLimit(object): + """Simple class to hold a global semaphore limiter for a project. This class + should be treated as a singleton that is instantiated when the project is. + """ + global_limiter = Semaphore(PARALLEL_LIMIT) -global_limiter = Semaphore(get_configured_limit()) + @classmethod + def set_global_limit(cls, value=None): + if value is not None: + cls.global_limiter = Semaphore(value) def parallel_execute(objects, func, get_name, msg, get_deps=None, limit=None, parent_objects=None): @@ -187,7 +187,7 @@ def producer(obj, func, results, limiter): 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. """ - with limiter, global_limiter: + with limiter, GlobalLimit.global_limiter: try: result = func(obj) results.put((obj, result, None)) diff --git a/compose/project.py b/compose/project.py index 11ee4a0b73c..c5bdbb16ce9 100644 --- a/compose/project.py +++ b/compose/project.py @@ -61,13 +61,15 @@ class Project(object): """ A collection of services. """ - def __init__(self, name, services, client, networks=None, volumes=None, config_version=None): + def __init__(self, name, services, client, networks=None, volumes=None, config_version=None, + parallel_limit=None): self.name = name self.services = services self.client = client self.volumes = volumes or ProjectVolumes({}) self.networks = networks or ProjectNetworks({}, False) self.config_version = config_version + parallel.GlobalLimit.set_global_limit(value=parallel_limit) def labels(self, one_off=OneOffFilter.exclude): labels = ['{0}={1}'.format(LABEL_PROJECT, self.name)] @@ -76,7 +78,7 @@ def labels(self, one_off=OneOffFilter.exclude): return labels @classmethod - def from_config(cls, name, config_data, client): + def from_config(cls, name, config_data, client, global_parallel_limit=None): """ Construct a Project from a config.Config object. """ @@ -87,7 +89,8 @@ def from_config(cls, name, config_data, client): networks, use_networking) volumes = ProjectVolumes.from_config(name, config_data, client) - project = cls(name, [], client, project_networks, volumes, config_data.version) + project = cls(name, [], client, project_networks, volumes, config_data.version, + parallel_limit=global_parallel_limit) for service_dict in config_data.services: service_dict = dict(service_dict) diff --git a/tests/unit/parallel_test.py b/tests/unit/parallel_test.py index 529395aa350..8ac6b339abc 100644 --- a/tests/unit/parallel_test.py +++ b/tests/unit/parallel_test.py @@ -1,14 +1,12 @@ from __future__ import absolute_import from __future__ import unicode_literals -import os from threading import Lock import six from docker.errors import APIError -from .. import mock -from compose.parallel import get_configured_limit +from compose.parallel import GlobalLimit from compose.parallel import parallel_execute from compose.parallel import parallel_execute_iter from compose.parallel import ParallelStreamWriter @@ -70,14 +68,11 @@ def f(obj): assert errors == {} -@mock.patch.dict(os.environ) def test_parallel_execute_with_global_limit(): - os.environ['COMPOSE_PARALLEL_LIMIT'] = '1' + GlobalLimit.set_global_limit(1) tasks = 20 lock = Lock() - assert get_configured_limit() == 1 - def f(obj): locked = lock.acquire(False) # we should always get the lock because we're the only thread running From 002505084710d96bc7c16a7c984ed02e10e475c8 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 18 Dec 2017 12:35:59 -0800 Subject: [PATCH 3206/4072] Convert mounts to legacy volumes if API version < 1.30 Signed-off-by: Joffrey F --- compose/service.py | 59 ++++++++++++++++++------------- tests/integration/service_test.py | 18 ++++++++++ 2 files changed, 53 insertions(+), 24 deletions(-) diff --git a/compose/service.py b/compose/service.py index f51f0e5af72..fbab9281f51 100644 --- a/compose/service.py +++ b/compose/service.py @@ -785,6 +785,35 @@ def _get_container_create_options( self.options.get('labels'), override_options.get('labels')) + container_options, override_options = self._build_container_volume_options( + previous_container, container_options, override_options + ) + + container_options['image'] = self.image_name + + container_options['labels'] = build_container_labels( + container_options.get('labels', {}), + self.labels(one_off=one_off), + number, + self.config_hash if add_config_hash else None) + + # Delete options which are only used in HostConfig + for key in HOST_CONFIG_KEYS: + container_options.pop(key, None) + + container_options['host_config'] = self._get_container_host_config( + 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 + + def _build_container_volume_options(self, previous_container, container_options, override_options): container_volumes = [] container_mounts = [] if 'volumes' in container_options: @@ -801,7 +830,11 @@ def _get_container_create_options( container_options['environment'].update(affinity) container_options['volumes'] = dict((v.internal, {}) for v in container_volumes or {}) - override_options['mounts'] = [build_mount(v) for v in container_mounts] or None + if version_gte(self.client.api_version, '1.30'): + override_options['mounts'] = [build_mount(v) for v in container_mounts] or None + else: + override_options['binds'].extend(m.legacy_repr() for m in container_mounts) + container_options['volumes'].update((m.target, {}) for m in container_mounts) secret_volumes = self.get_secret_volumes() if secret_volumes: @@ -814,29 +847,7 @@ def _get_container_create_options( override_options['mounts'] = override_options.get('mounts') or [] override_options['mounts'].extend([build_mount(v) for v in secret_volumes]) - container_options['image'] = self.image_name - - container_options['labels'] = build_container_labels( - container_options.get('labels', {}), - self.labels(one_off=one_off), - number, - self.config_hash if add_config_hash else None) - - # Delete options which are only used in HostConfig - for key in HOST_CONFIG_KEYS: - container_options.pop(key, None) - - container_options['host_config'] = self._get_container_host_config( - 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 + return container_options, override_options def _get_container_host_config(self, override_options, one_off=False): options = dict(self.options, **override_options) diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index b9005b8e104..c1681a8de9a 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -13,6 +13,7 @@ from six import text_type from .. import mock +from .testcases import docker_client from .testcases import DockerClientTestCase from .testcases import get_links from .testcases import pull_busybox @@ -326,6 +327,23 @@ def test_create_container_with_volume_mount(self): assert mount assert mount['Name'] == volume_name + @v3_only() + def test_create_container_with_legacy_mount(self): + # Ensure mounts are converted to volumes if API version < 1.30 + # Needed to support long syntax in the 3.2 format + client = docker_client({}, version='1.25') + container_path = '/container-volume' + volume_name = 'composetest_abcde' + self.client.create_volume(volume_name) + service = Service('db', client=client, volumes=[ + MountSpec(type='volume', source=volume_name, target=container_path) + ], image='busybox:latest', command=['top'], project='composetest') + container = service.create_container() + service.start_container(container) + mount = container.get_mount(container_path) + assert mount + assert mount['Name'] == volume_name + def test_create_container_with_healthcheck_config(self): one_second = parse_nanoseconds_int('1s') healthcheck = { From 5a7ba590fb4ab3311e9caf2b00d04ccf60d5ae18 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 18 Dec 2017 12:35:59 -0800 Subject: [PATCH 3207/4072] Convert mounts to legacy volumes if API version < 1.30 Signed-off-by: Joffrey F --- compose/service.py | 59 ++++++++++++++++++------------- tests/integration/service_test.py | 18 ++++++++++ 2 files changed, 53 insertions(+), 24 deletions(-) diff --git a/compose/service.py b/compose/service.py index f51f0e5af72..fbab9281f51 100644 --- a/compose/service.py +++ b/compose/service.py @@ -785,6 +785,35 @@ def _get_container_create_options( self.options.get('labels'), override_options.get('labels')) + container_options, override_options = self._build_container_volume_options( + previous_container, container_options, override_options + ) + + container_options['image'] = self.image_name + + container_options['labels'] = build_container_labels( + container_options.get('labels', {}), + self.labels(one_off=one_off), + number, + self.config_hash if add_config_hash else None) + + # Delete options which are only used in HostConfig + for key in HOST_CONFIG_KEYS: + container_options.pop(key, None) + + container_options['host_config'] = self._get_container_host_config( + 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 + + def _build_container_volume_options(self, previous_container, container_options, override_options): container_volumes = [] container_mounts = [] if 'volumes' in container_options: @@ -801,7 +830,11 @@ def _get_container_create_options( container_options['environment'].update(affinity) container_options['volumes'] = dict((v.internal, {}) for v in container_volumes or {}) - override_options['mounts'] = [build_mount(v) for v in container_mounts] or None + if version_gte(self.client.api_version, '1.30'): + override_options['mounts'] = [build_mount(v) for v in container_mounts] or None + else: + override_options['binds'].extend(m.legacy_repr() for m in container_mounts) + container_options['volumes'].update((m.target, {}) for m in container_mounts) secret_volumes = self.get_secret_volumes() if secret_volumes: @@ -814,29 +847,7 @@ def _get_container_create_options( override_options['mounts'] = override_options.get('mounts') or [] override_options['mounts'].extend([build_mount(v) for v in secret_volumes]) - container_options['image'] = self.image_name - - container_options['labels'] = build_container_labels( - container_options.get('labels', {}), - self.labels(one_off=one_off), - number, - self.config_hash if add_config_hash else None) - - # Delete options which are only used in HostConfig - for key in HOST_CONFIG_KEYS: - container_options.pop(key, None) - - container_options['host_config'] = self._get_container_host_config( - 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 + return container_options, override_options def _get_container_host_config(self, override_options, one_off=False): options = dict(self.options, **override_options) diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index b9005b8e104..c1681a8de9a 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -13,6 +13,7 @@ from six import text_type from .. import mock +from .testcases import docker_client from .testcases import DockerClientTestCase from .testcases import get_links from .testcases import pull_busybox @@ -326,6 +327,23 @@ def test_create_container_with_volume_mount(self): assert mount assert mount['Name'] == volume_name + @v3_only() + def test_create_container_with_legacy_mount(self): + # Ensure mounts are converted to volumes if API version < 1.30 + # Needed to support long syntax in the 3.2 format + client = docker_client({}, version='1.25') + container_path = '/container-volume' + volume_name = 'composetest_abcde' + self.client.create_volume(volume_name) + service = Service('db', client=client, volumes=[ + MountSpec(type='volume', source=volume_name, target=container_path) + ], image='busybox:latest', command=['top'], project='composetest') + container = service.create_container() + service.start_container(container) + mount = container.get_mount(container_path) + assert mount + assert mount['Name'] == volume_name + def test_create_container_with_healthcheck_config(self): one_second = parse_nanoseconds_int('1s') healthcheck = { From 8dd22a962a4295ada9c8a45a5c58d02f8f66333f Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 13 Dec 2017 13:40:42 -0800 Subject: [PATCH 3208/4072] Bump 1.18.0 Signed-off-by: Joffrey F --- CHANGELOG.md | 19 ++++++++++--------- compose/__init__.py | 2 +- script/run/run.sh | 2 +- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ac2050512a0..c0b3b5653af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ Change log #### Compose file version 3.5 - Introduced version 3.5 of the `docker-compose.yml` specification. - This version requires to be used with Docker Engine 17.06.0 or above + This version requires Docker Engine 17.06.0 or above - Added support for the `shm_size` parameter in build configurations @@ -21,20 +21,15 @@ Change log - Added support for `extra_hosts` in build configuration -- Added support for the - [long syntax](https://docs.docker.com/compose/compose-file/#long-syntax-3) - for volume entries, as previously introduced in the 3.2 format. - Note that using this syntax will create - [mounts](https://docs.docker.com/engine/admin/volumes/bind-mounts/) - instead of volumes. +- Added support for the [long syntax](https://docs.docker.com/compose/compose-file/#long-syntax-3) for volume entries, as previously introduced in the 3.2 format. + Note that using this syntax will create [mounts](https://docs.docker.com/engine/admin/volumes/bind-mounts/) instead of volumes. #### Compose file version 2.1 and up - Added support for the `oom_kill_disable` parameter in service definitions (2.x only) -- Added support for custom names for network, secret and config definitions - (2.x only) +- Added support for custom names for network definitions (2.x only) #### All formats @@ -62,6 +57,12 @@ Change log - Fixed an issue with CLI logging causing duplicate messages and inelegant output to occur +- Fixed an issue that caused `stop_grace_period` to be ignored when using + multiple Compose files + +- Fixed a bug that caused `docker-compose images` to crash when using + untagged images + - Fixed a bug where the valid `${VAR:-}` syntax would cause Compose to error out diff --git a/compose/__init__.py b/compose/__init__.py index 231670a5c41..a15ad45f377 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.18.0-rc2' +__version__ = '1.18.0' diff --git a/script/run/run.sh b/script/run/run.sh index 4be14b722cc..abb4ff4feea 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.18.0-rc2" +VERSION="1.18.0" IMAGE="docker/compose:$VERSION" From 17195d33e6bc6f010bbe15c60a4713c3b37cc799 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 18 Dec 2017 15:59:23 -0800 Subject: [PATCH 3209/4072] 1.19.0-dev 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 7b954eb4f67..60a987ca61e 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.18.0dev' +__version__ = '1.19.0dev' From ebf1a658a6f48fd96849ed518609a6dd379350d8 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 19 Dec 2017 16:37:50 -0800 Subject: [PATCH 3210/4072] Bump docker SDK 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 889f87a5a78..bc483b4b725 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ backports.ssl-match-hostname==3.5.0.1; python_version < '3' cached-property==1.3.0 certifi==2017.4.17 chardet==3.0.4 -docker==2.6.1 +docker==2.7.0 docker-pycreds==0.2.1 dockerpty==0.4.1 docopt==0.6.2 diff --git a/setup.py b/setup.py index d0353404007..a75e0cb7f3d 100644 --- a/setup.py +++ b/setup.py @@ -36,7 +36,7 @@ def find_version(*file_paths): 'requests >= 2.6.1, != 2.11.0, != 2.12.2, != 2.18.0, < 2.19', 'texttable >= 0.9.0, < 0.10', 'websocket-client >= 0.32.0, < 1.0', - 'docker >= 2.6.1, < 3.0', + 'docker >= 2.7.0, < 3.0', 'dockerpty >= 0.4.1, < 0.5', 'six >= 1.3.0, < 2', 'jsonschema >= 2.5.1, < 3', From 2d064614acd63f93eb8253a215aae21e8f01ceee Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 3 Jan 2018 14:02:05 -0800 Subject: [PATCH 3211/4072] Gracefully handle errors and provide helpful error message in type conversion Signed-off-by: Joffrey F --- compose/config/interpolation.py | 25 +++++++++++++++++++++---- tests/unit/config/interpolation_test.py | 25 +++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/compose/config/interpolation.py b/compose/config/interpolation.py index 45a5f9fc232..68d3be682e6 100644 --- a/compose/config/interpolation.py +++ b/compose/config/interpolation.py @@ -138,14 +138,24 @@ def to_int(s): # We must be able to handle octal representation for `mode` values notably if six.PY3 and re.match('^0[0-9]+$', s.strip()): s = '0o' + s[1:] - return int(s, base=0) + try: + return int(s, base=0) + except ValueError: + raise ValueError('"{}" is not a valid integer'.format(s)) + + +def to_float(s): + try: + return float(s) + except ValueError: + raise ValueError('"{}" is not a valid float'.format(s)) class ConversionMap(object): map = { service_path('blkio_config', 'weight'): to_int, service_path('blkio_config', 'weight_device', 'weight'): to_int, - service_path('cpus'): float, + service_path('cpus'): to_float, service_path('cpu_count'): to_int, service_path('configs', 'mode'): to_int, service_path('secrets', 'mode'): to_int, @@ -153,7 +163,7 @@ class ConversionMap(object): service_path('healthcheck', 'disable'): to_boolean, service_path('deploy', 'replicas'): to_int, service_path('deploy', 'update_config', 'parallelism'): to_int, - service_path('deploy', 'update_config', 'max_failure_ratio'): float, + service_path('deploy', 'update_config', 'max_failure_ratio'): to_float, service_path('deploy', 'restart_policy', 'max_attempts'): to_int, service_path('mem_swappiness'): to_int, service_path('oom_kill_disable'): to_boolean, @@ -181,7 +191,14 @@ class ConversionMap(object): def convert(self, path, value): for rexp in self.map.keys(): if rexp.match(path): - return self.map[rexp](value) + try: + return self.map[rexp](value) + except ValueError as e: + raise ConfigurationError( + 'Error while attempting to convert {} to appropriate type: {}'.format( + path, e + ) + ) return value diff --git a/tests/unit/config/interpolation_test.py b/tests/unit/config/interpolation_test.py index 516f5c9e968..702ea682d39 100644 --- a/tests/unit/config/interpolation_test.py +++ b/tests/unit/config/interpolation_test.py @@ -4,6 +4,7 @@ import pytest from compose.config.environment import Environment +from compose.config.errors import ConfigurationError from compose.config.interpolation import interpolate_environment_variables from compose.config.interpolation import Interpolator from compose.config.interpolation import InvalidInterpolation @@ -254,6 +255,30 @@ def test_interpolate_environment_services_convert_types_v3(mock_env): assert value == expected +def test_interpolate_environment_services_convert_types_invalid(mock_env): + entry = {'service1': {'privileged': '${POSINT}'}} + + with pytest.raises(ConfigurationError) as exc: + interpolate_environment_variables(V2_3, entry, 'service', mock_env) + + assert 'Error while attempting to convert service.service1.privileged to '\ + 'appropriate type: "50" is not a valid boolean value' in exc.exconly() + + entry = {'service1': {'cpus': '${TRUE}'}} + with pytest.raises(ConfigurationError) as exc: + interpolate_environment_variables(V2_3, entry, 'service', mock_env) + + assert 'Error while attempting to convert service.service1.cpus to '\ + 'appropriate type: "True" is not a valid float' in exc.exconly() + + entry = {'service1': {'ulimits': {'nproc': '${FLOAT}'}}} + with pytest.raises(ConfigurationError) as exc: + interpolate_environment_variables(V2_3, entry, 'service', mock_env) + + assert 'Error while attempting to convert service.service1.ulimits.nproc to '\ + 'appropriate type: "0.145" is not a valid integer' in exc.exconly() + + def test_interpolate_environment_network_convert_types(mock_env): entry = { 'network1': { From 8d3c7d4bce9ddab14065e09f8660cb2f997f5aaa Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 4 Jan 2018 12:16:46 -0800 Subject: [PATCH 3212/4072] Clean up limit setting code and add reasonable input guards Signed-off-by: Joffrey F --- compose/cli/command.py | 26 ++++- compose/parallel.py | 7 +- compose/project.py | 9 +- tests/unit/parallel_test.py | 216 ++++++++++++++++++------------------ 4 files changed, 135 insertions(+), 123 deletions(-) diff --git a/compose/cli/command.py b/compose/cli/command.py index 6bfc2301937..7cea91a2da2 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -10,6 +10,7 @@ from . import errors from . import verbose_proxy from .. import config +from .. import parallel from ..config.environment import Environment from ..const import API_VERSIONS from ..project import Project @@ -23,6 +24,8 @@ def project_from_options(project_dir, options): environment = Environment.from_env_file(project_dir) + set_parallel_limit(environment) + host = options.get('--host') if host is not None: host = host.lstrip('=') @@ -38,6 +41,22 @@ def project_from_options(project_dir, options): ) +def set_parallel_limit(environment): + parallel_limit = environment.get('COMPOSE_PARALLEL_LIMIT') + if parallel_limit: + try: + parallel_limit = int(parallel_limit) + except ValueError: + raise errors.UserError( + 'COMPOSE_PARALLEL_LIMIT must be an integer (found: "{}")'.format( + environment.get('COMPOSE_PARALLEL_LIMIT') + ) + ) + if parallel_limit <= 1: + raise errors.UserError('COMPOSE_PARALLEL_LIMIT can not be less than 2') + parallel.GlobalLimit.set_global_limit(parallel_limit) + + def get_config_from_options(base_dir, options): environment = Environment.from_env_file(base_dir) config_path = get_config_path_from_options( @@ -99,13 +118,8 @@ def get_project(project_dir, config_path=None, project_name=None, verbose=False, host=host, environment=environment ) - global_parallel_limit = environment.get('COMPOSE_PARALLEL_LIMIT') - if global_parallel_limit: - global_parallel_limit = int(global_parallel_limit) - with errors.handle_connection_errors(client): - return Project.from_config(project_name, config_data, client, - global_parallel_limit=global_parallel_limit) + return Project.from_config(project_name, config_data, client) def get_project_name(working_dir, project_name=None, environment=None): diff --git a/compose/parallel.py b/compose/parallel.py index 382ce025131..3c0098c0583 100644 --- a/compose/parallel.py +++ b/compose/parallel.py @@ -35,9 +35,10 @@ class GlobalLimit(object): global_limiter = Semaphore(PARALLEL_LIMIT) @classmethod - def set_global_limit(cls, value=None): - if value is not None: - cls.global_limiter = Semaphore(value) + def set_global_limit(cls, value): + if value is None: + value = PARALLEL_LIMIT + cls.global_limiter = Semaphore(value) def parallel_execute(objects, func, get_name, msg, get_deps=None, limit=None, parent_objects=None): diff --git a/compose/project.py b/compose/project.py index c5bdbb16ce9..11ee4a0b73c 100644 --- a/compose/project.py +++ b/compose/project.py @@ -61,15 +61,13 @@ class Project(object): """ A collection of services. """ - def __init__(self, name, services, client, networks=None, volumes=None, config_version=None, - parallel_limit=None): + def __init__(self, name, services, client, networks=None, volumes=None, config_version=None): self.name = name self.services = services self.client = client self.volumes = volumes or ProjectVolumes({}) self.networks = networks or ProjectNetworks({}, False) self.config_version = config_version - parallel.GlobalLimit.set_global_limit(value=parallel_limit) def labels(self, one_off=OneOffFilter.exclude): labels = ['{0}={1}'.format(LABEL_PROJECT, self.name)] @@ -78,7 +76,7 @@ def labels(self, one_off=OneOffFilter.exclude): return labels @classmethod - def from_config(cls, name, config_data, client, global_parallel_limit=None): + def from_config(cls, name, config_data, client): """ Construct a Project from a config.Config object. """ @@ -89,8 +87,7 @@ def from_config(cls, name, config_data, client, global_parallel_limit=None): networks, use_networking) volumes = ProjectVolumes.from_config(name, config_data, client) - project = cls(name, [], client, project_networks, volumes, config_data.version, - parallel_limit=global_parallel_limit) + project = cls(name, [], client, project_networks, volumes, config_data.version) for service_dict in config_data.services: service_dict = dict(service_dict) diff --git a/tests/unit/parallel_test.py b/tests/unit/parallel_test.py index 8ac6b339abc..4ebc24d8cb6 100644 --- a/tests/unit/parallel_test.py +++ b/tests/unit/parallel_test.py @@ -1,6 +1,7 @@ from __future__ import absolute_import from __future__ import unicode_literals +import unittest from threading import Lock import six @@ -32,114 +33,113 @@ def get_deps(obj): return [(dep, None) for dep in deps[obj]] -def test_parallel_execute(): - results, errors = 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] - assert errors == {} - - -def test_parallel_execute_with_limit(): - limit = 1 - tasks = 20 - lock = Lock() - - def f(obj): - locked = lock.acquire(False) - # we should always get the lock because we're the only thread running - assert locked - lock.release() - return None - - results, errors = parallel_execute( - objects=list(range(tasks)), - func=f, - get_name=six.text_type, - msg="Testing", - limit=limit, - ) - - assert results == tasks * [None] - assert errors == {} - - -def test_parallel_execute_with_global_limit(): - GlobalLimit.set_global_limit(1) - tasks = 20 - lock = Lock() - - def f(obj): - locked = lock.acquire(False) - # we should always get the lock because we're the only thread running - assert locked - lock.release() - return None - - results, errors = parallel_execute( - objects=list(range(tasks)), - func=f, - get_name=six.text_type, - msg="Testing", - ) - - assert results == tasks * [None] - assert errors == {} - - -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=get_deps, - ) - - 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=get_deps, - ) - - assert log == [cache] - - events = [ - (obj, result, type(exception)) - for obj, result, exception - in parallel_execute_iter(objects, process, get_deps, None) - ] - - 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 +class ParallelTest(unittest.TestCase): + + def test_parallel_execute(self): + results, errors = 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] + assert errors == {} + + def test_parallel_execute_with_limit(self): + limit = 1 + tasks = 20 + lock = Lock() + + def f(obj): + locked = lock.acquire(False) + # we should always get the lock because we're the only thread running + assert locked + lock.release() + return None + + results, errors = parallel_execute( + objects=list(range(tasks)), + func=f, + get_name=six.text_type, + msg="Testing", + limit=limit, + ) + + assert results == tasks * [None] + assert errors == {} + + def test_parallel_execute_with_global_limit(self): + GlobalLimit.set_global_limit(1) + self.addCleanup(GlobalLimit.set_global_limit, None) + tasks = 20 + lock = Lock() + + def f(obj): + locked = lock.acquire(False) + # we should always get the lock because we're the only thread running + assert locked + lock.release() + return None + + results, errors = parallel_execute( + objects=list(range(tasks)), + func=f, + get_name=six.text_type, + msg="Testing", + ) + + assert results == tasks * [None] + assert errors == {} + + def test_parallel_execute_with_deps(self): + log = [] + + def process(x): + log.append(x) + + parallel_execute( + objects=objects, + func=process, + get_name=lambda obj: obj, + msg="Processing", + get_deps=get_deps, + ) + + 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(self): + 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=get_deps, + ) + + assert log == [cache] + + events = [ + (obj, result, type(exception)) + for obj, result, exception + in parallel_execute_iter(objects, process, get_deps, None) + ] + + 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 def test_parallel_execute_alignment(capsys): From 74f616f208b79e3275b97329094bfce025a14b18 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 4 Jan 2018 12:45:31 -0800 Subject: [PATCH 3213/4072] Fix if_runtime_available decorator Signed-off-by: Joffrey F --- tests/integration/project_test.py | 11 ++++++----- tests/integration/testcases.py | 26 +++++++++----------------- 2 files changed, 15 insertions(+), 22 deletions(-) diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 6c69590341a..554998e1c21 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -834,11 +834,11 @@ def test_up_with_network_static_addresses(self): service_container = project.get_service('web').containers()[0] - 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' + ipam_config = (service_container.inspect().get('NetworkSettings', {}). + get('Networks', {}).get('composetest_static_test', {}). + get('IPAMConfig', {})) + assert ipam_config.get('IPv4Address') == '172.16.100.100' + assert ipam_config.get('IPv6Address') == 'fe80::1001:102' @v2_1_only() def test_up_with_enable_ipv6(self): @@ -1032,6 +1032,7 @@ def test_up_with_invalid_isolation(self): project.up() @v2_3_only() + @if_runtime_available('runc') def test_up_with_runtime(self): self.require_api_version('1.30') config_data = build_config( diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index 1a3be6cfccf..4440d771e81 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -5,7 +5,6 @@ import os import pytest -import six from docker.errors import APIError from docker.utils import version_lt @@ -157,22 +156,15 @@ def get_volume_data(self, volume_name): def if_runtime_available(runtime): - if runtime == 'nvidia': - command = 'nvidia-container-runtime' - if six.PY3: - import shutil - return pytest.mark.skipif( - shutil.which(command) is None, - reason="Nvida runtime not exists" - ) - return pytest.mark.skipif( - any( - os.access(os.path.join(path, command), os.X_OK) - for path in os.environ["PATH"].split(os.pathsep) - ) is False, - reason="Nvida runtime not exists" - ) - return pytest.skip("Runtime %s not exists", runtime) + def decorator(f): + @functools.wraps(f) + def wrapper(self, *args, **kwargs): + if runtime not in self.client.info().get('Runtimes', {}): + return pytest.skip("This daemon does not support the '{}'' runtime".format(runtime)) + return f(self, *args, **kwargs) + return wrapper + + return decorator def is_cluster(client): From b13ec16e119e8a3e0f3d3d6ed6b8ca230dfa6719 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 4 Jan 2018 13:01:54 -0800 Subject: [PATCH 3214/4072] Replace unittest-style asserts with pytest asserts Signed-off-by: Joffrey F --- tests/acceptance/cli_test.py | 363 +++++++++++++------------- tests/integration/project_test.py | 217 ++++++++-------- tests/integration/resilience_test.py | 12 +- tests/integration/service_test.py | 364 ++++++++++++--------------- tests/integration/state_test.py | 83 +++--- tests/unit/cli/docker_client_test.py | 2 +- tests/unit/cli/verbose_proxy_test.py | 8 +- tests/unit/cli_test.py | 29 +-- tests/unit/config/config_test.py | 352 ++++++++++++-------------- tests/unit/container_test.py | 74 +++--- tests/unit/progress_stream_test.py | 12 +- tests/unit/project_test.py | 42 ++-- tests/unit/service_test.py | 179 ++++++------- tests/unit/split_buffer_test.py | 4 +- 14 files changed, 796 insertions(+), 945 deletions(-) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 74c4e0159d5..c064716503e 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -474,9 +474,9 @@ def test_ps_default_composefile(self): self.dispatch(['up', '-d']) result = self.dispatch(['ps']) - self.assertIn('multiplecomposefiles_simple_1', result.stdout) - self.assertIn('multiplecomposefiles_another_1', result.stdout) - self.assertNotIn('multiplecomposefiles_yetanother_1', result.stdout) + assert 'multiplecomposefiles_simple_1' in result.stdout + assert 'multiplecomposefiles_another_1' in result.stdout + assert 'multiplecomposefiles_yetanother_1' not in result.stdout def test_ps_alternate_composefile(self): config_path = os.path.abspath( @@ -487,9 +487,9 @@ def test_ps_alternate_composefile(self): self.dispatch(['-f', 'compose2.yml', 'up', '-d']) result = self.dispatch(['-f', 'compose2.yml', 'ps']) - self.assertNotIn('multiplecomposefiles_simple_1', result.stdout) - self.assertNotIn('multiplecomposefiles_another_1', result.stdout) - self.assertIn('multiplecomposefiles_yetanother_1', result.stdout) + assert 'multiplecomposefiles_simple_1' not in result.stdout + assert 'multiplecomposefiles_another_1' not in result.stdout + assert 'multiplecomposefiles_yetanother_1' in result.stdout def test_pull(self): result = self.dispatch(['pull']) @@ -528,13 +528,16 @@ def test_pull_with_parallel_failure(self): returncode=1 ) - self.assertRegexpMatches(result.stderr, re.compile('^Pulling simple', re.MULTILINE)) - self.assertRegexpMatches(result.stderr, re.compile('^Pulling another', re.MULTILINE)) - self.assertRegexpMatches(result.stderr, - re.compile('^ERROR: for another .*does not exist.*', re.MULTILINE)) - self.assertRegexpMatches(result.stderr, - re.compile('''^(ERROR: )?(b')?.* nonexisting-image''', - re.MULTILINE)) + assert re.search(re.compile('^Pulling simple', re.MULTILINE), result.stderr) + assert re.search(re.compile('^Pulling another', re.MULTILINE), result.stderr) + assert re.search( + re.compile('^ERROR: for another .*does not exist.*', re.MULTILINE), + result.stderr + ) + assert re.search( + re.compile('''^(ERROR: )?(b')?.* nonexisting-image''', re.MULTILINE), + result.stderr + ) def test_build_plain(self): self.base_dir = 'tests/fixtures/simple-dockerfile' @@ -573,7 +576,6 @@ def test_build_no_cache_pull(self): assert BUILD_CACHE_TEXT not in result.stdout assert BUILD_PULL_TEXT in result.stdout - @pytest.mark.xfail(reason='17.10.0 RC bug remove after GA https://github.com/moby/moby/issues/35116') def test_build_failed(self): self.base_dir = 'tests/fixtures/simple-failing-dockerfile' self.dispatch(['build', 'simple'], returncode=1) @@ -587,7 +589,6 @@ def test_build_failed(self): ] assert len(containers) == 1 - @pytest.mark.xfail(reason='17.10.0 RC bug remove after GA https://github.com/moby/moby/issues/35116') def test_build_failed_forcerm(self): self.base_dir = 'tests/fixtures/simple-failing-dockerfile' self.dispatch(['build', '--force-rm', 'simple'], returncode=1) @@ -809,36 +810,36 @@ def test_down(self): def test_down_timeout(self): self.dispatch(['up', '-d'], None) service = self.project.get_service('simple') - self.assertEqual(len(service.containers()), 1) - self.assertTrue(service.containers()[0].is_running) + assert len(service.containers()) == 1 + assert service.containers()[0].is_running "" self.dispatch(['down', '-t', '1'], None) - self.assertEqual(len(service.containers(stopped=True)), 0) + assert len(service.containers(stopped=True)) == 0 def test_down_signal(self): self.base_dir = 'tests/fixtures/stop-signal-composefile' self.dispatch(['up', '-d'], None) service = self.project.get_service('simple') - self.assertEqual(len(service.containers()), 1) - self.assertTrue(service.containers()[0].is_running) + assert len(service.containers()) == 1 + assert service.containers()[0].is_running self.dispatch(['down', '-t', '1'], None) - self.assertEqual(len(service.containers(stopped=True)), 0) + assert len(service.containers(stopped=True)) == 0 def test_up_detached(self): self.dispatch(['up', '-d']) service = self.project.get_service('simple') another = self.project.get_service('another') - self.assertEqual(len(service.containers()), 1) - self.assertEqual(len(another.containers()), 1) + assert len(service.containers()) == 1 + assert len(another.containers()) == 1 # Ensure containers don't have stdin and stdout connected in -d mode container, = service.containers() - self.assertFalse(container.get('Config.AttachStderr')) - self.assertFalse(container.get('Config.AttachStdout')) - self.assertFalse(container.get('Config.AttachStdin')) + assert not container.get('Config.AttachStderr') + assert not container.get('Config.AttachStdout') + assert not container.get('Config.AttachStdin') def test_up_attached(self): self.base_dir = 'tests/fixtures/echo-services' @@ -858,7 +859,7 @@ def test_up(self): network_name = self.project.networks.networks['default'].full_name networks = self.client.networks(names=[network_name]) - self.assertEqual(len(networks), 1) + assert len(networks) == 1 assert networks[0]['Driver'] == 'bridge' if not is_cluster(self.client) else 'overlay' assert 'com.docker.network.bridge.enable_icc' not in networks[0]['Options'] @@ -866,17 +867,17 @@ def test_up(self): for service in services: containers = service.containers() - self.assertEqual(len(containers), 1) + assert len(containers) == 1 container = containers[0] - self.assertIn(container.id, network['Containers']) + assert container.id in network['Containers'] networks = container.get('NetworkSettings.Networks') - self.assertEqual(list(networks), [network['Name']]) + assert list(networks) == [network['Name']] - self.assertEqual( - sorted(networks[network['Name']]['Aliases']), - sorted([service.name, container.short_id])) + assert sorted(networks[network['Name']]['Aliases']) == sorted( + [service.name, container.short_id] + ) for service in services: assert self.lookup(container, service.name) @@ -1213,13 +1214,13 @@ def test_up_with_links_v1(self): console = self.project.get_service('console') # console was not started - self.assertEqual(len(web.containers()), 1) - self.assertEqual(len(db.containers()), 1) - self.assertEqual(len(console.containers()), 0) + assert len(web.containers()) == 1 + assert len(db.containers()) == 1 + assert len(console.containers()) == 0 # web has links web_container = web.containers()[0] - self.assertTrue(web_container.get('HostConfig.Links')) + assert web_container.get('HostConfig.Links') def test_up_with_net_is_invalid(self): self.base_dir = 'tests/fixtures/net-container' @@ -1241,8 +1242,9 @@ def test_up_with_net_v1(self): foo = self.project.get_service('foo') foo_container = foo.containers()[0] - assert foo_container.get('HostConfig.NetworkMode') == \ - 'container:{}'.format(bar_container.id) + assert foo_container.get('HostConfig.NetworkMode') == 'container:{}'.format( + bar_container.id + ) @v3_only() def test_up_with_healthcheck(self): @@ -1294,37 +1296,37 @@ def test_up_with_no_deps(self): web = self.project.get_service('web') db = self.project.get_service('db') console = self.project.get_service('console') - self.assertEqual(len(web.containers()), 1) - self.assertEqual(len(db.containers()), 0) - self.assertEqual(len(console.containers()), 0) + assert len(web.containers()) == 1 + assert len(db.containers()) == 0 + assert len(console.containers()) == 0 def test_up_with_force_recreate(self): self.dispatch(['up', '-d'], None) service = self.project.get_service('simple') - self.assertEqual(len(service.containers()), 1) + assert len(service.containers()) == 1 old_ids = [c.id for c in service.containers()] self.dispatch(['up', '-d', '--force-recreate'], None) - self.assertEqual(len(service.containers()), 1) + assert len(service.containers()) == 1 new_ids = [c.id for c in service.containers()] - self.assertNotEqual(old_ids, new_ids) + assert old_ids != new_ids def test_up_with_no_recreate(self): self.dispatch(['up', '-d'], None) service = self.project.get_service('simple') - self.assertEqual(len(service.containers()), 1) + assert len(service.containers()) == 1 old_ids = [c.id for c in service.containers()] self.dispatch(['up', '-d', '--no-recreate'], None) - self.assertEqual(len(service.containers()), 1) + assert len(service.containers()) == 1 new_ids = [c.id for c in service.containers()] - self.assertEqual(old_ids, new_ids) + assert old_ids == new_ids def test_up_with_force_recreate_and_no_recreate(self): self.dispatch( @@ -1365,14 +1367,14 @@ def test_up_handles_abort_on_container_exit(self): proc = start_process(self.base_dir, ['up', '--abort-on-container-exit']) wait_on_condition(ContainerCountCondition(self.project, 0)) proc.wait() - self.assertEqual(proc.returncode, 0) + assert proc.returncode == 0 def test_up_handles_abort_on_container_exit_code(self): self.base_dir = 'tests/fixtures/abort-on-container-exit-1' proc = start_process(self.base_dir, ['up', '--abort-on-container-exit']) wait_on_condition(ContainerCountCondition(self.project, 0)) proc.wait() - self.assertEqual(proc.returncode, 1) + assert proc.returncode == 1 @v2_only() @no_cluster('Container PID mode does not work across clusters') @@ -1403,27 +1405,27 @@ def test_up_with_pid_mode(self): 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) + assert len(self.project.containers()) == 1 stdout, stderr = self.dispatch(['exec', '-T', 'console', 'ls', '-1d', '/']) - self.assertEqual(stderr, "") - self.assertEqual(stdout, "/\n") + assert stderr == "" + assert stdout == "/\n" 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) + assert len(self.project.containers()) == 1 stdout, stderr = self.dispatch(['exec', '-T', '--user=operator', 'console', 'whoami']) - self.assertEqual(stdout, "operator\n") - self.assertEqual(stderr, "") + assert stdout == "operator\n" + assert stderr == "" @v2_2_only() def test_exec_service_with_environment_overridden(self): name = 'service' self.base_dir = 'tests/fixtures/environment-exec' self.dispatch(['up', '-d']) - self.assertEqual(len(self.project.containers()), 1) + assert len(self.project.containers()) == 1 stdout, stderr = self.dispatch([ 'exec', @@ -1441,27 +1443,27 @@ def test_exec_service_with_environment_overridden(self): # added option from command line assert 'alpha=beta' in stdout - self.assertEqual(stderr, '') + assert stderr == '' def test_run_service_without_links(self): self.base_dir = 'tests/fixtures/links-composefile' self.dispatch(['run', 'console', '/bin/true']) - self.assertEqual(len(self.project.containers()), 0) + assert len(self.project.containers()) == 0 # Ensure stdin/out was open container = self.project.containers(stopped=True, one_off=OneOffFilter.only)[0] config = container.inspect()['Config'] - self.assertTrue(config['AttachStderr']) - self.assertTrue(config['AttachStdout']) - self.assertTrue(config['AttachStdin']) + assert config['AttachStderr'] + assert config['AttachStdout'] + assert config['AttachStdin'] def test_run_service_with_links(self): self.base_dir = 'tests/fixtures/links-composefile' 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) + assert len(db.containers()) == 1 + assert len(console.containers()) == 0 @v2_only() def test_run_service_with_dependencies(self): @@ -1469,8 +1471,8 @@ def test_run_service_with_dependencies(self): 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) + assert len(db.containers()) == 1 + assert len(console.containers()) == 0 def test_run_service_with_scaled_dependencies(self): self.base_dir = 'tests/fixtures/v2-dependencies' @@ -1487,22 +1489,22 @@ def test_run_with_no_deps(self): self.base_dir = 'tests/fixtures/links-composefile' self.dispatch(['run', '--no-deps', 'web', '/bin/true']) db = self.project.get_service('db') - self.assertEqual(len(db.containers()), 0) + assert len(db.containers()) == 0 def test_run_does_not_recreate_linked_containers(self): self.base_dir = 'tests/fixtures/links-composefile' self.dispatch(['up', '-d', 'db']) db = self.project.get_service('db') - self.assertEqual(len(db.containers()), 1) + assert len(db.containers()) == 1 old_ids = [c.id for c in db.containers()] self.dispatch(['run', 'web', '/bin/true'], None) - self.assertEqual(len(db.containers()), 1) + assert len(db.containers()) == 1 new_ids = [c.id for c in db.containers()] - self.assertEqual(old_ids, new_ids) + assert old_ids == new_ids def test_run_without_command(self): self.base_dir = 'tests/fixtures/commands-composefile' @@ -1511,18 +1513,12 @@ def test_run_without_command(self): self.dispatch(['run', 'implicit']) service = self.project.get_service('implicit') 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"'], - ) + assert [c.human_readable_command for c in containers] == [u'/bin/sh -c echo "success"'] self.dispatch(['run', 'explicit']) service = self.project.get_service('explicit') containers = service.containers(stopped=True, one_off=OneOffFilter.only) - self.assertEqual( - [c.human_readable_command for c in containers], - [u'/bin/true'], - ) + assert [c.human_readable_command for c in containers] == [u'/bin/true'] @pytest.mark.skipif(SWARM_SKIP_RM_VOLUMES, reason='Swarm DELETE /containers/ bug') def test_run_rm(self): @@ -1534,7 +1530,7 @@ def test_run_rm(self): 'running')) service = self.project.get_service('test') containers = service.containers(one_off=OneOffFilter.only) - self.assertEqual(len(containers), 1) + assert len(containers) == 1 mounts = containers[0].get('Mounts') for mount in mounts: if mount['Destination'] == '/container-path': @@ -1543,7 +1539,7 @@ def test_run_rm(self): os.kill(proc.pid, signal.SIGINT) wait_on_process(proc, 1) - self.assertEqual(len(service.containers(stopped=True, one_off=OneOffFilter.only)), 0) + assert len(service.containers(stopped=True, one_off=OneOffFilter.only)) == 0 volumes = self.client.volumes()['Volumes'] assert volumes is not None @@ -1611,7 +1607,7 @@ def test_run_service_with_user_overridden(self): self.dispatch(['run', '--user={user}'.format(user=user), name], returncode=1) service = self.project.get_service(name) container = service.containers(stopped=True, one_off=OneOffFilter.only)[0] - self.assertEqual(user, container.get('Config.User')) + assert user == container.get('Config.User') def test_run_service_with_user_overridden_short_form(self): self.base_dir = 'tests/fixtures/user-composefile' @@ -1620,7 +1616,7 @@ def test_run_service_with_user_overridden_short_form(self): self.dispatch(['run', '-u', user, name], returncode=1) service = self.project.get_service(name) container = service.containers(stopped=True, one_off=OneOffFilter.only)[0] - self.assertEqual(user, container.get('Config.User')) + assert user == container.get('Config.User') def test_run_service_with_environment_overridden(self): name = 'service' @@ -1635,13 +1631,13 @@ def test_run_service_with_environment_overridden(self): service = self.project.get_service(name) container = service.containers(stopped=True, one_off=OneOffFilter.only)[0] # env overridden - self.assertEqual('notbar', container.environment['foo']) + assert 'notbar' == container.environment['foo'] # keep environment from yaml - self.assertEqual('world', container.environment['hello']) + assert 'world' == container.environment['hello'] # added option from command line - self.assertEqual('beta', container.environment['alpha']) + assert 'beta' == container.environment['alpha'] # make sure a value with a = don't crash out - self.assertEqual('moto=bobo', container.environment['allo']) + assert 'moto=bobo' == container.environment['allo'] def test_run_service_without_map_ports(self): # create one off container @@ -1657,8 +1653,8 @@ def test_run_service_without_map_ports(self): container.stop() # check the ports - self.assertEqual(port_random, None) - self.assertEqual(port_assigned, None) + assert port_random is None + assert port_assigned is None def test_run_service_with_map_ports(self): # create one off container @@ -1716,8 +1712,8 @@ def test_run_service_with_explicitly_mapped_ip_ports(self): container.stop() # check the ports - self.assertEqual(port_short, "127.0.0.1:30000") - self.assertEqual(port_full, "127.0.0.1:30001") + assert port_short == "127.0.0.1:30000" + assert port_full == "127.0.0.1:30001" def test_run_with_expose_ports(self): # create one off container @@ -1726,7 +1722,7 @@ def test_run_with_expose_ports(self): container = self.project.get_service('simple').containers(one_off=OneOffFilter.only)[0] ports = container.ports - self.assertEqual(len(ports), 9) + assert len(ports) == 9 # exposed ports are not mapped to host ports assert ports['3000/tcp'] is None assert ports['3001/tcp'] is None @@ -1748,7 +1744,7 @@ def test_run_with_custom_name(self): service = self.project.get_service('service') container, = service.containers(stopped=True, one_off=OneOffFilter.only) - self.assertEqual(container.name, name) + assert container.name == name def test_run_service_with_workdir_overridden(self): self.base_dir = 'tests/fixtures/run-workdir' @@ -1757,7 +1753,7 @@ def test_run_service_with_workdir_overridden(self): 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')) + assert workdir == container.get('Config.WorkingDir') def test_run_service_with_workdir_overridden_short_form(self): self.base_dir = 'tests/fixtures/run-workdir' @@ -1766,7 +1762,7 @@ def test_run_service_with_workdir_overridden_short_form(self): 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')) + assert workdir == container.get('Config.WorkingDir') @v2_only() def test_run_interactive_connects_to_network(self): @@ -1887,19 +1883,19 @@ def test_rm(self): service = self.project.get_service('simple') service.create_container() kill_service(service) - self.assertEqual(len(service.containers(stopped=True)), 1) + assert len(service.containers(stopped=True)) == 1 self.dispatch(['rm', '--force'], None) - self.assertEqual(len(service.containers(stopped=True)), 0) + assert len(service.containers(stopped=True)) == 0 service = self.project.get_service('simple') service.create_container() kill_service(service) - self.assertEqual(len(service.containers(stopped=True)), 1) + assert len(service.containers(stopped=True)) == 1 self.dispatch(['rm', '-f'], None) - self.assertEqual(len(service.containers(stopped=True)), 0) + assert len(service.containers(stopped=True)) == 0 service = self.project.get_service('simple') service.create_container() self.dispatch(['rm', '-fs'], None) - self.assertEqual(len(service.containers(stopped=True)), 0) + assert len(service.containers(stopped=True)) == 0 def test_rm_stop(self): self.dispatch(['up', '-d'], None) @@ -1923,43 +1919,43 @@ def test_rm_all(self): 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=OneOffFilter.only)), 1) + assert len(service.containers(stopped=True)) == 1 + assert 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)), 0) + assert len(service.containers(stopped=True)) == 0 + assert 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=OneOffFilter.only)), 1) + assert len(service.containers(stopped=True)) == 1 + assert 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=OneOffFilter.only)), 0) + assert len(service.containers(stopped=True)) == 0 + assert len(service.containers(stopped=True, one_off=OneOffFilter.only)) == 0 def test_stop(self): self.dispatch(['up', '-d'], None) service = self.project.get_service('simple') - self.assertEqual(len(service.containers()), 1) - self.assertTrue(service.containers()[0].is_running) + assert len(service.containers()) == 1 + assert service.containers()[0].is_running self.dispatch(['stop', '-t', '1'], None) - self.assertEqual(len(service.containers(stopped=True)), 1) - self.assertFalse(service.containers(stopped=True)[0].is_running) + assert len(service.containers(stopped=True)) == 1 + assert not service.containers(stopped=True)[0].is_running def test_stop_signal(self): self.base_dir = 'tests/fixtures/stop-signal-composefile' self.dispatch(['up', '-d'], None) service = self.project.get_service('simple') - self.assertEqual(len(service.containers()), 1) - self.assertTrue(service.containers()[0].is_running) + assert len(service.containers()) == 1 + assert service.containers()[0].is_running self.dispatch(['stop', '-t', '1'], None) - self.assertEqual(len(service.containers(stopped=True)), 1) - self.assertFalse(service.containers(stopped=True)[0].is_running) - self.assertEqual(service.containers(stopped=True)[0].exit_code, 0) + assert len(service.containers(stopped=True)) == 1 + assert not service.containers(stopped=True)[0].is_running + assert service.containers(stopped=True)[0].exit_code == 0 def test_start_no_containers(self): result = self.dispatch(['start'], returncode=1) @@ -1971,39 +1967,39 @@ def test_up_logging(self): self.dispatch(['up', '-d']) simple = self.project.get_service('simple').containers()[0] log_config = simple.get('HostConfig.LogConfig') - self.assertTrue(log_config) - self.assertEqual(log_config.get('Type'), 'none') + assert log_config + assert log_config.get('Type') == 'none' another = self.project.get_service('another').containers()[0] log_config = another.get('HostConfig.LogConfig') - self.assertTrue(log_config) - self.assertEqual(log_config.get('Type'), 'json-file') - self.assertEqual(log_config.get('Config')['max-size'], '10m') + assert log_config + assert log_config.get('Type') == 'json-file' + assert log_config.get('Config')['max-size'] == '10m' def test_up_logging_legacy(self): self.base_dir = 'tests/fixtures/logging-composefile-legacy' self.dispatch(['up', '-d']) simple = self.project.get_service('simple').containers()[0] log_config = simple.get('HostConfig.LogConfig') - self.assertTrue(log_config) - self.assertEqual(log_config.get('Type'), 'none') + assert log_config + assert log_config.get('Type') == 'none' another = self.project.get_service('another').containers()[0] log_config = another.get('HostConfig.LogConfig') - self.assertTrue(log_config) - self.assertEqual(log_config.get('Type'), 'json-file') - self.assertEqual(log_config.get('Config')['max-size'], '10m') + assert log_config + assert log_config.get('Type') == 'json-file' + assert log_config.get('Config')['max-size'] == '10m' def test_pause_unpause(self): self.dispatch(['up', '-d'], None) service = self.project.get_service('simple') - self.assertFalse(service.containers()[0].is_paused) + assert not service.containers()[0].is_paused self.dispatch(['pause'], None) - self.assertTrue(service.containers()[0].is_paused) + assert service.containers()[0].is_paused self.dispatch(['unpause'], None) - self.assertFalse(service.containers()[0].is_paused) + assert not service.containers()[0].is_paused def test_pause_no_containers(self): result = self.dispatch(['pause'], returncode=1) @@ -2077,7 +2073,7 @@ def test_logs_timestamps(self): 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})') + assert re.search('(\d{4})-(\d{2})-(\d{2})T(\d{2})\:(\d{2})\:(\d{2})', result.stdout) def test_logs_tail(self): self.base_dir = 'tests/fixtures/logs-tail-composefile' @@ -2092,36 +2088,36 @@ def test_logs_tail(self): def test_kill(self): self.dispatch(['up', '-d'], None) service = self.project.get_service('simple') - self.assertEqual(len(service.containers()), 1) - self.assertTrue(service.containers()[0].is_running) + assert len(service.containers()) == 1 + assert service.containers()[0].is_running self.dispatch(['kill'], None) - self.assertEqual(len(service.containers(stopped=True)), 1) - self.assertFalse(service.containers(stopped=True)[0].is_running) + assert len(service.containers(stopped=True)) == 1 + assert not service.containers(stopped=True)[0].is_running def test_kill_signal_sigstop(self): self.dispatch(['up', '-d'], None) service = self.project.get_service('simple') - self.assertEqual(len(service.containers()), 1) - self.assertTrue(service.containers()[0].is_running) + assert len(service.containers()) == 1 + assert service.containers()[0].is_running self.dispatch(['kill', '-s', 'SIGSTOP'], None) - self.assertEqual(len(service.containers()), 1) + assert len(service.containers()) == 1 # The container is still running. It has only been paused - self.assertTrue(service.containers()[0].is_running) + assert service.containers()[0].is_running def test_kill_stopped_service(self): self.dispatch(['up', '-d'], None) service = self.project.get_service('simple') self.dispatch(['kill', '-s', 'SIGSTOP'], None) - self.assertTrue(service.containers()[0].is_running) + assert service.containers()[0].is_running self.dispatch(['kill', '-s', 'SIGKILL'], None) - self.assertEqual(len(service.containers(stopped=True)), 1) - self.assertFalse(service.containers(stopped=True)[0].is_running) + assert len(service.containers(stopped=True)) == 1 + assert not service.containers(stopped=True)[0].is_running def test_restart(self): service = self.project.get_service('simple') @@ -2130,23 +2126,17 @@ def test_restart(self): started_at = container.dictionary['State']['StartedAt'] self.dispatch(['restart', '-t', '1'], None) container.inspect() - self.assertNotEqual( - container.dictionary['State']['FinishedAt'], - '0001-01-01T00:00:00Z', - ) - self.assertNotEqual( - container.dictionary['State']['StartedAt'], - started_at, - ) + assert container.dictionary['State']['FinishedAt'] != '0001-01-01T00:00:00Z' + assert container.dictionary['State']['StartedAt'] != started_at def test_restart_stopped_container(self): service = self.project.get_service('simple') container = service.create_container() container.start() container.kill() - self.assertEqual(len(service.containers(stopped=True)), 1) + assert len(service.containers(stopped=True)) == 1 self.dispatch(['restart', '-t', '1'], None) - self.assertEqual(len(service.containers(stopped=False)), 1) + assert len(service.containers(stopped=False)) == 1 def test_restart_no_containers(self): result = self.dispatch(['restart'], returncode=1) @@ -2156,23 +2146,23 @@ def test_scale(self): project = self.project self.dispatch(['scale', 'simple=1']) - self.assertEqual(len(project.get_service('simple').containers()), 1) + assert len(project.get_service('simple').containers()) == 1 self.dispatch(['scale', 'simple=3', 'another=2']) - self.assertEqual(len(project.get_service('simple').containers()), 3) - self.assertEqual(len(project.get_service('another').containers()), 2) + assert len(project.get_service('simple').containers()) == 3 + assert len(project.get_service('another').containers()) == 2 self.dispatch(['scale', 'simple=1', 'another=1']) - self.assertEqual(len(project.get_service('simple').containers()), 1) - self.assertEqual(len(project.get_service('another').containers()), 1) + assert len(project.get_service('simple').containers()) == 1 + assert len(project.get_service('another').containers()) == 1 self.dispatch(['scale', 'simple=1', 'another=1']) - self.assertEqual(len(project.get_service('simple').containers()), 1) - self.assertEqual(len(project.get_service('another').containers()), 1) + assert len(project.get_service('simple').containers()) == 1 + assert len(project.get_service('another').containers()) == 1 self.dispatch(['scale', 'simple=0', 'another=0']) - self.assertEqual(len(project.get_service('simple').containers()), 0) - self.assertEqual(len(project.get_service('another').containers()), 0) + assert len(project.get_service('simple').containers()) == 0 + assert len(project.get_service('another').containers()) == 0 def test_scale_v2_2(self): self.base_dir = 'tests/fixtures/scale' @@ -2267,10 +2257,10 @@ def get_port(number, index=None): result = self.dispatch(['port', '--index=' + str(index), 'simple', str(number)]) return result.stdout.rstrip() - self.assertEqual(get_port(3000), containers[0].get_local_port(3000)) - self.assertEqual(get_port(3000, index=1), containers[0].get_local_port(3000)) - self.assertEqual(get_port(3000, index=2), containers[1].get_local_port(3000)) - self.assertEqual(get_port(3002), "") + assert get_port(3000) == containers[0].get_local_port(3000) + assert get_port(3000, index=1) == containers[0].get_local_port(3000) + assert get_port(3000, index=2) == containers[1].get_local_port(3000) + assert get_port(3002) == "" def test_events_json(self): events_proc = start_process(self.base_dir, ['events', '--json']) @@ -2321,8 +2311,8 @@ def test_env_file_relative_to_compose_file(self): self._project = get_project(self.base_dir, [config_path]) containers = self.project.containers(stopped=True) - self.assertEqual(len(containers), 1) - self.assertIn("FOO=1", containers[0].get('Config.Env')) + assert len(containers) == 1 + assert "FOO=1" in containers[0].get('Config.Env') @mock.patch.dict(os.environ) def test_home_and_env_var_in_volume_path(self): @@ -2342,11 +2332,11 @@ def test_up_with_default_override_file(self): self.dispatch(['up', '-d'], None) containers = self.project.containers() - self.assertEqual(len(containers), 2) + assert len(containers) == 2 web, db = containers - self.assertEqual(web.human_readable_command, 'top') - self.assertEqual(db.human_readable_command, 'top') + assert web.human_readable_command == 'top' + assert db.human_readable_command == 'top' def test_up_with_multiple_files(self): self.base_dir = 'tests/fixtures/override-files' @@ -2366,21 +2356,18 @@ def test_up_with_multiple_files(self): None) containers = self.project.containers() - self.assertEqual(len(containers), 3) + assert len(containers) == 3 web, other, db = containers - self.assertEqual(web.human_readable_command, 'top') - self.assertEqual(db.human_readable_command, 'top') - self.assertEqual(other.human_readable_command, 'top') + assert web.human_readable_command == 'top' + assert db.human_readable_command == 'top' + assert other.human_readable_command == 'top' def test_up_with_extends(self): self.base_dir = 'tests/fixtures/extends' self.dispatch(['up', '-d'], None) - self.assertEqual( - set([s.name for s in self.project.services]), - set(['mydb', 'myweb']), - ) + assert set([s.name for s in self.project.services]) == set(['mydb', 'myweb']) # Sort by name so we get [db, web] containers = sorted( @@ -2388,19 +2375,17 @@ def test_up_with_extends(self): key=lambda c: c.name, ) - self.assertEqual(len(containers), 2) + assert len(containers) == 2 web = containers[1] - self.assertEqual( - set(get_links(web)), - set(['db', 'mydb_1', 'extends_mydb_1'])) + assert set(get_links(web)) == set(['db', 'mydb_1', 'extends_mydb_1']) expected_env = set([ "FOO=1", "BAR=2", "BAZ=2", ]) - self.assertTrue(expected_env <= set(web.get('Config.Env'))) + assert expected_env <= set(web.get('Config.Env')) def test_top_services_not_running(self): self.base_dir = 'tests/fixtures/top' @@ -2412,9 +2397,9 @@ def test_top_services_running(self): 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) + assert 'top_service_a' in result.stdout + assert 'top_service_b' in result.stdout + assert 'top_not_a_service' not in result.stdout def test_top_processes_running(self): self.base_dir = 'tests/fixtures/top' @@ -2473,14 +2458,14 @@ def test_up_with_override_yaml(self): self.dispatch(['up', '-d'], None) containers = self.project.containers() - self.assertEqual(len(containers), 2) + assert len(containers) == 2 web, db = containers - self.assertEqual(web.human_readable_command, 'sleep 100') - self.assertEqual(db.human_readable_command, 'top') + assert web.human_readable_command == 'sleep 100' + assert db.human_readable_command == 'top' def test_up_with_duplicate_override_yaml_files(self): self.base_dir = 'tests/fixtures/duplicate-override-yaml-files' - with self.assertRaises(DuplicateOverrideFileFound): + with pytest.raises(DuplicateOverrideFileFound): get_project(self.base_dir, []) self.base_dir = None diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 554998e1c21..3180c1b9b1e 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -63,7 +63,7 @@ def test_containers(self): project.up() containers = project.containers() - self.assertEqual(len(containers), 2) + assert len(containers) == 2 @pytest.mark.skipif(SWARM_SKIP_CONTAINERS_ALL, reason='Swarm /containers/json bug') def test_containers_stopped(self): @@ -87,9 +87,7 @@ def test_containers_with_service_names(self): project.up() containers = project.containers(['web']) - self.assertEqual( - [c.name for c in containers], - ['composetest_web_1']) + assert [c.name for c in containers] == ['composetest_web_1'] def test_containers_with_extra_service(self): web = self.create_service('web') @@ -101,10 +99,7 @@ def test_containers_with_extra_service(self): self.create_service('extra').create_container() project = Project('composetest', [web, db], self.client) - self.assertEqual( - set(project.containers(stopped=True)), - set([web_1, db_1]), - ) + assert set(project.containers(stopped=True)) == set([web_1, db_1]) def test_volumes_from_service(self): project = Project.from_config( @@ -123,7 +118,7 @@ def test_volumes_from_service(self): ) db = project.get_service('db') data = project.get_service('data') - self.assertEqual(db.volumes_from, [VolumeFromSpec(data, 'rw', 'service')]) + assert db.volumes_from == [VolumeFromSpec(data, 'rw', 'service')] def test_volumes_from_container(self): data_container = Container.create( @@ -145,7 +140,7 @@ def test_volumes_from_container(self): client=self.client, ) db = project.get_service('db') - self.assertEqual(db._get_volumes_from(), [data_container.id + ':rw']) + assert db._get_volumes_from() == [data_container.id + ':rw'] @v2_only() @no_cluster('container networks not supported in Swarm') @@ -173,7 +168,7 @@ def test_network_mode_from_service(self): web = project.get_service('web') net = project.get_service('net') - self.assertEqual(web.network_mode.mode, 'container:' + net.containers()[0].id) + assert web.network_mode.mode == 'container:' + net.containers()[0].id @v2_only() @no_cluster('container networks not supported in Swarm') @@ -212,7 +207,7 @@ def get_project(): project.up() web = project.get_service('web') - self.assertEqual(web.network_mode.mode, 'container:' + net_container.id) + assert web.network_mode.mode == 'container:' + net_container.id @no_cluster('container networks not supported in Swarm') def test_net_from_service_v1(self): @@ -236,7 +231,7 @@ def test_net_from_service_v1(self): web = project.get_service('web') net = project.get_service('net') - self.assertEqual(web.network_mode.mode, 'container:' + net.containers()[0].id) + assert web.network_mode.mode == 'container:' + net.containers()[0].id @no_cluster('container networks not supported in Swarm') def test_net_from_container_v1(self): @@ -271,7 +266,7 @@ def get_project(): project.up() web = project.get_service('web') - self.assertEqual(web.network_mode.mode, 'container:' + net_container.id) + assert web.network_mode.mode == 'container:' + net_container.id def test_start_pause_unpause_stop_kill_remove(self): web = self.create_service('web') @@ -280,53 +275,51 @@ def test_start_pause_unpause_stop_kill_remove(self): project.start() - self.assertEqual(len(web.containers()), 0) - self.assertEqual(len(db.containers()), 0) + assert len(web.containers()) == 0 + assert len(db.containers()) == 0 web_container_1 = web.create_container() web_container_2 = web.create_container() db_container = db.create_container() project.start(service_names=['web']) - self.assertEqual( - set(c.name for c in project.containers() if c.is_running), - set([web_container_1.name, web_container_2.name])) + assert set(c.name for c in project.containers() if c.is_running) == set( + [web_container_1.name, web_container_2.name] + ) project.start() - self.assertEqual( - set(c.name for c in project.containers() if c.is_running), - set([web_container_1.name, web_container_2.name, db_container.name])) + assert set(c.name for c in project.containers() if c.is_running) == 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])) + assert 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])) + assert 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) + assert len([c.name for c in project.containers() if c.is_paused]) == 2 project.unpause() - self.assertEqual(len([c.name for c in project.containers() if c.is_paused]), 0) + assert len([c.name for c in project.containers() if c.is_paused]) == 0 project.stop(service_names=['web'], timeout=1) - self.assertEqual( - set(c.name for c in project.containers() if c.is_running), set([db_container.name]) - ) + assert set(c.name for c in project.containers() if c.is_running) == set([db_container.name]) project.kill(service_names=['db']) - self.assertEqual(len([c for c in project.containers() if c.is_running]), 0) - self.assertEqual(len(project.containers(stopped=True)), 3) + assert len([c for c in project.containers() if c.is_running]) == 0 + assert len(project.containers(stopped=True)) == 3 project.remove_stopped(service_names=['web']) - self.assertEqual(len(project.containers(stopped=True)), 1) + assert len(project.containers(stopped=True)) == 1 project.remove_stopped() - self.assertEqual(len(project.containers(stopped=True)), 0) + assert len(project.containers(stopped=True)) == 0 def test_create(self): web = self.create_service('web') @@ -401,43 +394,43 @@ def test_project_up(self): db = self.create_service('db', volumes=[VolumeSpec.parse('/var/db')]) project = Project('composetest', [web, db], self.client) project.start() - self.assertEqual(len(project.containers()), 0) + assert len(project.containers()) == 0 project.up(['db']) - self.assertEqual(len(project.containers()), 1) - self.assertEqual(len(db.containers()), 1) - self.assertEqual(len(web.containers()), 0) + assert len(project.containers()) == 1 + assert len(db.containers()) == 1 + assert len(web.containers()) == 0 def test_project_up_starts_uncreated_services(self): db = self.create_service('db') web = self.create_service('web', links=[(db, 'db')]) project = Project('composetest', [db, web], self.client) project.up(['db']) - self.assertEqual(len(project.containers()), 1) + assert len(project.containers()) == 1 project.up() - self.assertEqual(len(project.containers()), 2) - self.assertEqual(len(db.containers()), 1) - self.assertEqual(len(web.containers()), 1) + assert len(project.containers()) == 2 + assert len(db.containers()) == 1 + assert len(web.containers()) == 1 def test_recreate_preserves_volumes(self): web = self.create_service('web') db = self.create_service('db', volumes=[VolumeSpec.parse('/etc')]) project = Project('composetest', [web, db], self.client) project.start() - self.assertEqual(len(project.containers()), 0) + assert len(project.containers()) == 0 project.up(['db']) - self.assertEqual(len(project.containers()), 1) + assert len(project.containers()) == 1 old_db_id = project.containers()[0].id db_volume_path = project.containers()[0].get('Volumes./etc') project.up(strategy=ConvergenceStrategy.always) - self.assertEqual(len(project.containers()), 2) + assert len(project.containers()) == 2 db_container = [c for c in project.containers() if 'db' in c.name][0] - self.assertNotEqual(db_container.id, old_db_id) - self.assertEqual(db_container.get('Volumes./etc'), db_volume_path) + assert db_container.id != old_db_id + assert db_container.get('Volumes./etc') == db_volume_path @v2_3_only() def test_recreate_preserves_mounts(self): @@ -464,36 +457,34 @@ def test_project_up_with_no_recreate_running(self): db = self.create_service('db', volumes=[VolumeSpec.parse('/var/db')]) project = Project('composetest', [web, db], self.client) project.start() - self.assertEqual(len(project.containers()), 0) + assert len(project.containers()) == 0 project.up(['db']) - self.assertEqual(len(project.containers()), 1) + assert len(project.containers()) == 1 old_db_id = project.containers()[0].id container, = project.containers() db_volume_path = container.get_mount('/var/db')['Source'] project.up(strategy=ConvergenceStrategy.never) - self.assertEqual(len(project.containers()), 2) + assert len(project.containers()) == 2 db_container = [c for c in project.containers() if 'db' in c.name][0] - self.assertEqual(db_container.id, old_db_id) - self.assertEqual( - db_container.get_mount('/var/db')['Source'], - db_volume_path) + assert db_container.id == old_db_id + assert db_container.get_mount('/var/db')['Source'] == db_volume_path def test_project_up_with_no_recreate_stopped(self): web = self.create_service('web') db = self.create_service('db', volumes=[VolumeSpec.parse('/var/db')]) project = Project('composetest', [web, db], self.client) project.start() - self.assertEqual(len(project.containers()), 0) + assert len(project.containers()) == 0 project.up(['db']) project.kill() old_containers = project.containers(stopped=True) - self.assertEqual(len(old_containers), 1) + assert len(old_containers) == 1 old_container, = old_containers old_db_id = old_container.id db_volume_path = old_container.get_mount('/var/db')['Source'] @@ -501,26 +492,24 @@ def test_project_up_with_no_recreate_stopped(self): project.up(strategy=ConvergenceStrategy.never) new_containers = project.containers(stopped=True) - self.assertEqual(len(new_containers), 2) - self.assertEqual([c.is_running for c in new_containers], [True, True]) + assert len(new_containers) == 2 + assert [c.is_running for c in new_containers] == [True, True] db_container = [c for c in new_containers if 'db' in c.name][0] - self.assertEqual(db_container.id, old_db_id) - self.assertEqual( - db_container.get_mount('/var/db')['Source'], - db_volume_path) + assert db_container.id == old_db_id + assert db_container.get_mount('/var/db')['Source'] == db_volume_path def test_project_up_without_all_services(self): console = self.create_service('console') db = self.create_service('db') project = Project('composetest', [console, db], self.client) project.start() - self.assertEqual(len(project.containers()), 0) + assert len(project.containers()) == 0 project.up() - self.assertEqual(len(project.containers()), 2) - self.assertEqual(len(db.containers()), 1) - self.assertEqual(len(console.containers()), 1) + assert len(project.containers()) == 2 + assert len(db.containers()) == 1 + assert len(console.containers()) == 1 def test_project_up_starts_links(self): console = self.create_service('console') @@ -529,13 +518,13 @@ def test_project_up_starts_links(self): project = Project('composetest', [web, db, console], self.client) project.start() - self.assertEqual(len(project.containers()), 0) + assert len(project.containers()) == 0 project.up(['web']) - self.assertEqual(len(project.containers()), 2) - self.assertEqual(len(web.containers()), 1) - self.assertEqual(len(db.containers()), 1) - self.assertEqual(len(console.containers()), 0) + assert len(project.containers()) == 2 + assert len(web.containers()) == 1 + assert len(db.containers()) == 1 + assert len(console.containers()) == 0 def test_project_up_starts_depends(self): project = Project.from_config( @@ -563,14 +552,14 @@ def test_project_up_starts_depends(self): client=self.client, ) project.start() - self.assertEqual(len(project.containers()), 0) + assert len(project.containers()) == 0 project.up(['web']) - self.assertEqual(len(project.containers()), 3) - self.assertEqual(len(project.get_service('web').containers()), 1) - self.assertEqual(len(project.get_service('db').containers()), 1) - self.assertEqual(len(project.get_service('data').containers()), 1) - self.assertEqual(len(project.get_service('console').containers()), 0) + assert len(project.containers()) == 3 + assert len(project.get_service('web').containers()) == 1 + assert len(project.get_service('db').containers()) == 1 + assert len(project.get_service('data').containers()) == 1 + assert len(project.get_service('console').containers()) == 0 def test_project_up_with_no_deps(self): project = Project.from_config( @@ -598,15 +587,15 @@ def test_project_up_with_no_deps(self): client=self.client, ) project.start() - self.assertEqual(len(project.containers()), 0) + assert len(project.containers()) == 0 project.up(['db'], start_deps=False) - self.assertEqual(len(project.containers(stopped=True)), 2) - self.assertEqual(len(project.get_service('web').containers()), 0) - self.assertEqual(len(project.get_service('db').containers()), 1) - self.assertEqual(len(project.get_service('data').containers(stopped=True)), 1) + assert len(project.containers(stopped=True)) == 2 + assert len(project.get_service('web').containers()) == 0 + assert len(project.get_service('db').containers()) == 1 + assert len(project.get_service('data').containers(stopped=True)) == 1 assert not project.get_service('data').containers(stopped=True)[0].is_running - self.assertEqual(len(project.get_service('console').containers()), 0) + assert len(project.get_service('console').containers()) == 0 def test_project_up_recreate_with_tmpfs_volume(self): # https://github.com/docker/compose/issues/4751 @@ -634,22 +623,22 @@ def test_unscale_after_restart(self): service = project.get_service('web') service.scale(1) - self.assertEqual(len(service.containers()), 1) + assert len(service.containers()) == 1 service.scale(3) - self.assertEqual(len(service.containers()), 3) + assert len(service.containers()) == 3 project.up() service = project.get_service('web') - self.assertEqual(len(service.containers()), 1) + assert len(service.containers()) == 1 service.scale(1) - self.assertEqual(len(service.containers()), 1) + assert len(service.containers()) == 1 project.up(scale_override={'web': 3}) service = project.get_service('web') - self.assertEqual(len(service.containers()), 3) + assert len(service.containers()) == 3 # does scale=0 ,makes any sense? after recreating at least 1 container is running service.scale(0) project.up() service = project.get_service('web') - self.assertEqual(len(service.containers()), 1) + assert len(service.containers()) == 1 @v2_only() def test_project_up_networks(self): @@ -917,7 +906,7 @@ def test_up_with_network_static_addresses_missing_subnet(self): config_data=config_data, ) - with self.assertRaises(ProjectError): + with pytest.raises(ProjectError): project.up() @v2_1_only() @@ -1028,7 +1017,7 @@ def test_up_with_invalid_isolation(self): name='composetest', config_data=config_data ) - with self.assertRaises(ProjectError): + with pytest.raises(ProjectError): project.up() @v2_3_only() @@ -1068,7 +1057,7 @@ def test_up_with_invalid_runtime(self): name='composetest', config_data=config_data ) - with self.assertRaises(ProjectError): + with pytest.raises(ProjectError): project.up() @v2_3_only() @@ -1172,11 +1161,11 @@ def test_project_up_volumes(self): config_data=config_data, client=self.client ) project.up() - self.assertEqual(len(project.containers()), 1) + assert len(project.containers()) == 1 volume_data = self.get_volume_data(full_vol_name) assert volume_data['Name'].split('/')[-1] == full_vol_name - self.assertEqual(volume_data['Driver'], 'local') + assert volume_data['Driver'] == 'local' @v2_1_only() def test_project_up_with_volume_labels(self): @@ -1265,12 +1254,12 @@ def test_project_up_logging_with_multiple_files(self): ) project.up() containers = project.containers() - self.assertEqual(len(containers), 2) + assert len(containers) == 2 another = project.get_service('another').containers()[0] log_config = another.get('HostConfig.LogConfig') - self.assertTrue(log_config) - self.assertEqual(log_config.get('Type'), 'none') + assert log_config + assert log_config.get('Type') == 'none' @v2_only() def test_project_up_port_mappings_with_multiple_files(self): @@ -1306,7 +1295,7 @@ def test_project_up_port_mappings_with_multiple_files(self): ) project.up() containers = project.containers() - self.assertEqual(len(containers), 1) + assert len(containers) == 1 @v2_2_only() def test_project_up_config_scale(self): @@ -1382,7 +1371,7 @@ def test_project_up_implicit_volume_driver(self): volume_data = self.get_volume_data(full_vol_name) assert volume_data['Name'].split('/')[-1] == full_vol_name - self.assertEqual(volume_data['Driver'], 'local') + assert volume_data['Driver'] == 'local' @v3_only() def test_project_up_with_secrets(self): @@ -1439,7 +1428,7 @@ def test_initialize_volumes_invalid_volume_driver(self): name='composetest', config_data=config_data, client=self.client ) - with self.assertRaises(APIError if is_cluster(self.client) else config.ConfigurationError): + with pytest.raises(APIError if is_cluster(self.client) else config.ConfigurationError): project.volumes.initialize() @v2_only() @@ -1465,7 +1454,7 @@ def test_initialize_volumes_updated_driver(self): volume_data = self.get_volume_data(full_vol_name) assert volume_data['Name'].split('/')[-1] == full_vol_name - self.assertEqual(volume_data['Driver'], 'local') + assert volume_data['Driver'] == 'local' config_data = config_data._replace( volumes={vol_name: {'driver': 'smb'}} @@ -1475,11 +1464,11 @@ def test_initialize_volumes_updated_driver(self): config_data=config_data, client=self.client ) - with self.assertRaises(config.ConfigurationError) as e: + with pytest.raises(config.ConfigurationError) as e: project.volumes.initialize() assert 'Configuration for volume {0} specifies driver smb'.format( vol_name - ) in str(e.exception) + ) in str(e.value) @v2_only() def test_initialize_volumes_updated_blank_driver(self): @@ -1503,7 +1492,7 @@ def test_initialize_volumes_updated_blank_driver(self): volume_data = self.get_volume_data(full_vol_name) assert volume_data['Name'].split('/')[-1] == full_vol_name - self.assertEqual(volume_data['Driver'], 'local') + assert volume_data['Driver'] == 'local' config_data = config_data._replace( volumes={vol_name: {}} @@ -1516,7 +1505,7 @@ def test_initialize_volumes_updated_blank_driver(self): project.volumes.initialize() volume_data = self.get_volume_data(full_vol_name) assert volume_data['Name'].split('/')[-1] == full_vol_name - self.assertEqual(volume_data['Driver'], 'local') + assert volume_data['Driver'] == 'local' @v2_only() @no_cluster('inspect volume by name defect on Swarm Classic') @@ -1542,7 +1531,7 @@ def test_initialize_volumes_external_volumes(self): ) project.volumes.initialize() - with self.assertRaises(NotFound): + with pytest.raises(NotFound): self.client.inspect_volume(full_vol_name) @v2_only() @@ -1564,11 +1553,11 @@ def test_initialize_volumes_inexistent_external_volume(self): name='composetest', config_data=config_data, client=self.client ) - with self.assertRaises(config.ConfigurationError) as e: + with pytest.raises(config.ConfigurationError) as e: project.volumes.initialize() assert 'Volume {0} declared as external'.format( vol_name - ) in str(e.exception) + ) in str(e.value) @v2_only() def test_project_up_named_volumes_in_binds(self): @@ -1597,10 +1586,10 @@ def test_project_up_named_volumes_in_binds(self): name='composetest', config_data=config_data, client=self.client ) service = project.services[0] - self.assertEqual(service.name, 'simple') + assert service.name == 'simple' volumes = service.options.get('volumes') - self.assertEqual(len(volumes), 1) - self.assertEqual(volumes[0].external, full_vol_name) + assert len(volumes) == 1 + assert volumes[0].external == full_vol_name project.up() engine_volumes = self.client.volumes()['Volumes'] container = service.get_container() diff --git a/tests/integration/resilience_test.py b/tests/integration/resilience_test.py index 2a2d1b56eab..3de16e977b8 100644 --- a/tests/integration/resilience_test.py +++ b/tests/integration/resilience_test.py @@ -1,6 +1,8 @@ from __future__ import absolute_import from __future__ import unicode_literals +import pytest + from .. import mock from .testcases import DockerClientTestCase from compose.config.types import VolumeSpec @@ -28,25 +30,25 @@ def tearDown(self): def test_successful_recreate(self): self.project.up(strategy=ConvergenceStrategy.always) container = self.db.containers()[0] - self.assertEqual(container.get_mount('/var/db')['Source'], self.host_path) + assert container.get_mount('/var/db')['Source'] == self.host_path def test_create_failure(self): with mock.patch('compose.service.Service.create_container', crash): - with self.assertRaises(Crash): + with pytest.raises(Crash): self.project.up(strategy=ConvergenceStrategy.always) self.project.up() container = self.db.containers()[0] - self.assertEqual(container.get_mount('/var/db')['Source'], self.host_path) + assert container.get_mount('/var/db')['Source'] == self.host_path def test_start_failure(self): with mock.patch('compose.service.Service.start_container', crash): - with self.assertRaises(Crash): + with pytest.raises(Crash): self.project.up(strategy=ConvergenceStrategy.always) self.project.up() container = self.db.containers()[0] - self.assertEqual(container.get_mount('/var/db')['Source'], self.host_path) + assert container.get_mount('/var/db')['Source'] == self.host_path class Crash(Exception): diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index c1681a8de9a..7238aa69fec 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -2,6 +2,7 @@ from __future__ import unicode_literals import os +import re import shutil import tempfile from distutils.spawn import find_executable @@ -62,41 +63,41 @@ def test_containers(self): create_and_start_container(foo) - self.assertEqual(len(foo.containers()), 1) - self.assertEqual(foo.containers()[0].name, 'composetest_foo_1') - self.assertEqual(len(bar.containers()), 0) + assert len(foo.containers()) == 1 + assert foo.containers()[0].name == 'composetest_foo_1' + assert len(bar.containers()) == 0 create_and_start_container(bar) create_and_start_container(bar) - self.assertEqual(len(foo.containers()), 1) - self.assertEqual(len(bar.containers()), 2) + assert len(foo.containers()) == 1 + assert len(bar.containers()) == 2 names = [c.name for c in bar.containers()] - self.assertIn('composetest_bar_1', names) - self.assertIn('composetest_bar_2', names) + assert 'composetest_bar_1' in names + assert 'composetest_bar_2' in names def test_containers_one_off(self): db = self.create_service('db') container = db.create_container(one_off=True) - self.assertEqual(db.containers(stopped=True), []) - self.assertEqual(db.containers(one_off=OneOffFilter.only, stopped=True), [container]) + assert db.containers(stopped=True) == [] + assert db.containers(one_off=OneOffFilter.only, stopped=True) == [container] def test_project_is_added_to_container_name(self): service = self.create_service('web') create_and_start_container(service) - self.assertEqual(service.containers()[0].name, 'composetest_web_1') + assert service.containers()[0].name == 'composetest_web_1' def test_create_container_with_one_off(self): db = self.create_service('db') container = db.create_container(one_off=True) - self.assertEqual(container.name, 'composetest_db_run_1') + assert container.name == 'composetest_db_run_1' def test_create_container_with_one_off_when_existing_container_is_running(self): db = self.create_service('db') db.start() container = db.create_container(one_off=True) - self.assertEqual(container.name, 'composetest_db_run_1') + assert container.name == 'composetest_db_run_1' def test_create_container_with_unspecified_volume(self): service = self.create_service('db', volumes=[VolumeSpec.parse('/var/db')]) @@ -108,20 +109,20 @@ def test_create_container_with_volume_driver(self): service = self.create_service('db', volume_driver='foodriver') container = service.create_container() service.start_container(container) - self.assertEqual('foodriver', container.get('HostConfig.VolumeDriver')) + assert 'foodriver' == container.get('HostConfig.VolumeDriver') @pytest.mark.skipif(SWARM_SKIP_CPU_SHARES, reason='Swarm --cpu-shares bug') def test_create_container_with_cpu_shares(self): service = self.create_service('db', cpu_shares=73) container = service.create_container() service.start_container(container) - self.assertEqual(container.get('HostConfig.CpuShares'), 73) + assert container.get('HostConfig.CpuShares') == 73 def test_create_container_with_cpu_quota(self): service = self.create_service('db', cpu_quota=40000) container = service.create_container() container.start() - self.assertEqual(container.get('HostConfig.CpuQuota'), 40000) + assert container.get('HostConfig.CpuQuota') == 40000 @v2_2_only() def test_create_container_with_cpu_count(self): @@ -129,7 +130,7 @@ def test_create_container_with_cpu_count(self): service = self.create_service('db', cpu_count=2) container = service.create_container() service.start_container(container) - self.assertEqual(container.get('HostConfig.CpuCount'), 2) + assert container.get('HostConfig.CpuCount') == 2 @v2_2_only() @pytest.mark.skipif(not IS_WINDOWS_PLATFORM, reason='cpu_percent is not supported for Linux') @@ -138,7 +139,7 @@ def test_create_container_with_cpu_percent(self): service = self.create_service('db', cpu_percent=12) container = service.create_container() service.start_container(container) - self.assertEqual(container.get('HostConfig.CpuPercent'), 12) + assert container.get('HostConfig.CpuPercent') == 12 @v2_2_only() def test_create_container_with_cpus(self): @@ -146,14 +147,14 @@ def test_create_container_with_cpus(self): service = self.create_service('db', cpus=1) container = service.create_container() service.start_container(container) - self.assertEqual(container.get('HostConfig.NanoCpus'), 1000000000) + assert container.get('HostConfig.NanoCpus') == 1000000000 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) - self.assertEqual(container.get('HostConfig.ShmSize'), 67108864) + assert container.get('HostConfig.ShmSize') == 67108864 def test_create_container_with_init_bool(self): self.require_api_version('1.25') @@ -184,7 +185,7 @@ def test_create_container_with_extra_hosts_list(self): service = self.create_service('db', extra_hosts=extra_hosts) container = service.create_container() service.start_container(container) - self.assertEqual(set(container.get('HostConfig.ExtraHosts')), set(extra_hosts)) + assert set(container.get('HostConfig.ExtraHosts')) == set(extra_hosts) def test_create_container_with_extra_hosts_dicts(self): extra_hosts = {'somehost': '162.242.195.82', 'otherhost': '50.31.209.229'} @@ -192,13 +193,13 @@ def test_create_container_with_extra_hosts_dicts(self): service = self.create_service('db', extra_hosts=extra_hosts) container = service.create_container() service.start_container(container) - self.assertEqual(set(container.get('HostConfig.ExtraHosts')), set(extra_hosts_list)) + assert set(container.get('HostConfig.ExtraHosts')) == set(extra_hosts_list) def test_create_container_with_cpu_set(self): service = self.create_service('db', cpuset='0') container = service.create_container() service.start_container(container) - self.assertEqual(container.get('HostConfig.CpusetCpus'), '0') + assert container.get('HostConfig.CpusetCpus') == '0' def test_create_container_with_read_only_root_fs(self): read_only = True @@ -240,7 +241,7 @@ def test_create_container_with_security_opt(self): service = self.create_service('db', security_opt=security_opt) container = service.create_container() service.start_container(container) - self.assertEqual(set(container.get('HostConfig.SecurityOpt')), set(security_opt)) + assert set(container.get('HostConfig.SecurityOpt')) == set(security_opt) @pytest.mark.xfail(True, reason='Not supported on most drivers') def test_create_container_with_storage_opt(self): @@ -248,7 +249,7 @@ def test_create_container_with_storage_opt(self): service = self.create_service('db', storage_opt=storage_opt) container = service.create_container() service.start_container(container) - self.assertEqual(container.get('HostConfig.StorageOpt'), storage_opt) + assert container.get('HostConfig.StorageOpt') == storage_opt def test_create_container_with_oom_kill_disable(self): self.require_api_version('1.20') @@ -260,7 +261,7 @@ def test_create_container_with_mac_address(self): service = self.create_service('db', mac_address='02:42:ac:11:65:43') container = service.create_container() service.start_container(container) - self.assertEqual(container.inspect()['Config']['MacAddress'], '02:42:ac:11:65:43') + assert container.inspect()['Config']['MacAddress'] == '02:42:ac:11:65:43' def test_create_container_with_specified_volume(self): host_path = '/tmp/host-path' @@ -276,8 +277,9 @@ def test_create_container_with_specified_volume(self): # Match the last component ("host-path"), because boot2docker symlinks /tmp actual_host_path = container.get_mount(container_path)['Source'] - self.assertTrue(path.basename(actual_host_path) == path.basename(host_path), - msg=("Last component differs: %s, %s" % (actual_host_path, host_path))) + assert path.basename(actual_host_path) == path.basename(host_path), ( + "Last component differs: %s, %s" % (actual_host_path, host_path) + ) @v2_3_only() def test_create_container_with_host_mount(self): @@ -371,7 +373,7 @@ def test_recreate_preserves_volume_with_trailing_slash(self): volume_path = old_container.get_mount('/data')['Source'] new_container = service.recreate_container(old_container) - self.assertEqual(new_container.get_mount('/data')['Source'], volume_path) + assert new_container.get_mount('/data')['Source'] == volume_path def test_duplicate_volume_trailing_slash(self): """ @@ -393,20 +395,14 @@ def test_duplicate_volume_trailing_slash(self): service = self.create_service('db', image=image, volumes=volumes) old_container = create_and_start_container(service) - self.assertEqual( - old_container.get('Config.Volumes'), - {container_path: {}}, - ) + assert old_container.get('Config.Volumes') == {container_path: {}} service = self.create_service('db', image=image, volumes=volumes) new_container = service.recreate_container(old_container) - self.assertEqual( - new_container.get('Config.Volumes'), - {container_path: {}}, - ) + assert new_container.get('Config.Volumes') == {container_path: {}} - self.assertEqual(service.containers(stopped=False), [new_container]) + assert service.containers(stopped=False) == [new_container] def test_create_container_with_volumes_from(self): volume_service = self.create_service('data') @@ -429,10 +425,8 @@ def test_create_container_with_volumes_from(self): ) host_container = host_service.create_container() host_service.start_container(host_container) - self.assertIn(volume_container_1.id + ':rw', - host_container.get('HostConfig.VolumesFrom')) - self.assertIn(volume_container_2.id + ':rw', - host_container.get('HostConfig.VolumesFrom')) + assert volume_container_1.id + ':rw' in host_container.get('HostConfig.VolumesFrom') + assert volume_container_2.id + ':rw' in host_container.get('HostConfig.VolumesFrom') def test_execute_convergence_plan_recreate(self): service = self.create_service( @@ -443,10 +437,10 @@ def test_execute_convergence_plan_recreate(self): command=['-d', '1'] ) old_container = service.create_container() - self.assertEqual(old_container.get('Config.Entrypoint'), ['top']) - self.assertEqual(old_container.get('Config.Cmd'), ['-d', '1']) - self.assertIn('FOO=1', old_container.get('Config.Env')) - self.assertEqual(old_container.name, 'composetest_db_1') + assert old_container.get('Config.Entrypoint') == ['top'] + assert old_container.get('Config.Cmd') == ['-d', '1'] + assert 'FOO=1' in old_container.get('Config.Env') + assert old_container.name == 'composetest_db_1' service.start_container(old_container) old_container.inspect() # reload volume data volume_path = old_container.get_mount('/etc')['Source'] @@ -457,11 +451,11 @@ def test_execute_convergence_plan_recreate(self): new_container, = service.execute_convergence_plan( ConvergencePlan('recreate', [old_container])) - self.assertEqual(new_container.get('Config.Entrypoint'), ['top']) - self.assertEqual(new_container.get('Config.Cmd'), ['-d', '1']) - self.assertIn('FOO=2', new_container.get('Config.Env')) - self.assertEqual(new_container.name, 'composetest_db_1') - self.assertEqual(new_container.get_mount('/etc')['Source'], volume_path) + assert new_container.get('Config.Entrypoint') == ['top'] + assert new_container.get('Config.Cmd') == ['-d', '1'] + assert 'FOO=2' in new_container.get('Config.Env') + assert new_container.name == 'composetest_db_1' + assert new_container.get_mount('/etc')['Source'] == volume_path if not is_cluster(self.client): assert ( 'affinity:container==%s' % old_container.id in @@ -472,11 +466,10 @@ def test_execute_convergence_plan_recreate(self): # on the same node. assert old_container.get('Node.Name') == new_container.get('Node.Name') - self.assertEqual(len(self.client.containers(all=True)), num_containers_before) - self.assertNotEqual(old_container.id, new_container.id) - self.assertRaises(APIError, - self.client.inspect_container, - old_container.id) + assert len(self.client.containers(all=True)) == num_containers_before + assert old_container.id != new_container.id + with pytest.raises(APIError): + self.client.inspect_container(old_container.id) def test_execute_convergence_plan_recreate_twice(self): service = self.create_service( @@ -550,17 +543,17 @@ def test_execute_convergence_plan_when_containers_are_stopped(self): service.create_container() containers = service.containers(stopped=True) - self.assertEqual(len(containers), 1) + assert len(containers) == 1 container, = containers - self.assertFalse(container.is_running) + assert not container.is_running service.execute_convergence_plan(ConvergencePlan('start', [container])) containers = service.containers() - self.assertEqual(len(containers), 1) + assert len(containers) == 1 container.inspect() - self.assertEqual(container, containers[0]) - self.assertTrue(container.is_running) + assert container == containers[0] + assert container.is_running def test_execute_convergence_plan_with_image_declared_volume(self): service = Service( @@ -571,19 +564,14 @@ def test_execute_convergence_plan_with_image_declared_volume(self): ) old_container = create_and_start_container(service) - self.assertEqual( - [mount['Destination'] for mount in old_container.get('Mounts')], ['/data'] - ) + assert [mount['Destination'] for mount in old_container.get('Mounts')] == ['/data'] volume_path = old_container.get_mount('/data')['Source'] new_container, = service.execute_convergence_plan( ConvergencePlan('recreate', [old_container])) - self.assertEqual( - [mount['Destination'] for mount in new_container.get('Mounts')], - ['/data'] - ) - self.assertEqual(new_container.get_mount('/data')['Source'], volume_path) + assert [mount['Destination'] for mount in new_container.get('Mounts')] == ['/data'] + assert new_container.get_mount('/data')['Source'] == volume_path def test_execute_convergence_plan_when_image_volume_masks_config(self): service = self.create_service( @@ -592,10 +580,7 @@ def test_execute_convergence_plan_when_image_volume_masks_config(self): ) old_container = create_and_start_container(service) - self.assertEqual( - [mount['Destination'] for mount in old_container.get('Mounts')], - ['/data'] - ) + assert [mount['Destination'] for mount in old_container.get('Mounts')] == ['/data'] volume_path = old_container.get_mount('/data')['Source'] service.options['volumes'] = [VolumeSpec.parse('/tmp:/data')] @@ -606,15 +591,10 @@ def test_execute_convergence_plan_when_image_volume_masks_config(self): mock_log.warn.assert_called_once_with(mock.ANY) _, args, kwargs = mock_log.warn.mock_calls[0] - self.assertIn( - "Service \"db\" is using volume \"/data\" from the previous container", - args[0]) + assert "Service \"db\" is using volume \"/data\" from the previous container" in args[0] - self.assertEqual( - [mount['Destination'] for mount in new_container.get('Mounts')], - ['/data'] - ) - self.assertEqual(new_container.get_mount('/data')['Source'], volume_path) + assert [mount['Destination'] for mount in new_container.get('Mounts')] == ['/data'] + assert new_container.get_mount('/data')['Source'] == volume_path def test_execute_convergence_plan_when_host_volume_is_removed(self): host_path = '/tmp/host-path' @@ -667,12 +647,12 @@ def test_execute_convergence_plan_without_start(self): def test_start_container_passes_through_options(self): db = self.create_service('db') create_and_start_container(db, environment={'FOO': 'BAR'}) - self.assertEqual(db.containers()[0].environment['FOO'], 'BAR') + assert db.containers()[0].environment['FOO'] == 'BAR' def test_start_container_inherits_options_from_constructor(self): db = self.create_service('db', environment={'FOO': 'BAR'}) create_and_start_container(db) - self.assertEqual(db.containers()[0].environment['FOO'], 'BAR') + assert db.containers()[0].environment['FOO'] == 'BAR' @no_cluster('No legacy links support in Swarm') def test_start_container_creates_links(self): @@ -683,13 +663,11 @@ def test_start_container_creates_links(self): create_and_start_container(db) create_and_start_container(web) - self.assertEqual( - set(get_links(web.containers()[0])), - set([ - 'composetest_db_1', 'db_1', - 'composetest_db_2', 'db_2', - 'db']) - ) + assert set(get_links(web.containers()[0])) == set([ + 'composetest_db_1', 'db_1', + 'composetest_db_2', 'db_2', + 'db' + ]) @no_cluster('No legacy links support in Swarm') def test_start_container_creates_links_with_names(self): @@ -700,13 +678,11 @@ def test_start_container_creates_links_with_names(self): create_and_start_container(db) create_and_start_container(web) - self.assertEqual( - set(get_links(web.containers()[0])), - set([ - 'composetest_db_1', 'db_1', - 'composetest_db_2', 'db_2', - 'custom_link_name']) - ) + assert set(get_links(web.containers()[0])) == set([ + 'composetest_db_1', 'db_1', + 'composetest_db_2', 'db_2', + 'custom_link_name' + ]) @no_cluster('No legacy links support in Swarm') def test_start_container_with_external_links(self): @@ -719,13 +695,11 @@ def test_start_container_with_external_links(self): create_and_start_container(db) create_and_start_container(web) - self.assertEqual( - set(get_links(web.containers()[0])), - set([ - 'composetest_db_1', - 'composetest_db_2', - 'db_3']), - ) + assert set(get_links(web.containers()[0])) == set([ + 'composetest_db_1', + 'composetest_db_2', + 'db_3' + ]) @no_cluster('No legacy links support in Swarm') def test_start_normal_container_does_not_create_links_to_its_own_service(self): @@ -735,7 +709,7 @@ def test_start_normal_container_does_not_create_links_to_its_own_service(self): create_and_start_container(db) c = create_and_start_container(db) - self.assertEqual(set(get_links(c)), set([])) + assert set(get_links(c)) == set([]) @no_cluster('No legacy links support in Swarm') def test_start_one_off_container_creates_links_to_its_own_service(self): @@ -746,13 +720,11 @@ def test_start_one_off_container_creates_links_to_its_own_service(self): c = create_and_start_container(db, one_off=OneOffFilter.only) - self.assertEqual( - set(get_links(c)), - set([ - 'composetest_db_1', 'db_1', - 'composetest_db_2', 'db_2', - 'db']) - ) + assert set(get_links(c)) == set([ + 'composetest_db_1', 'db_1', + 'composetest_db_2', 'db_2', + 'db' + ]) def test_start_container_builds_images(self): service = Service( @@ -763,7 +735,7 @@ def test_start_container_builds_images(self): ) container = create_and_start_container(service) container.wait() - self.assertIn(b'success', container.logs()) + assert b'success' in container.logs() assert len(self.client.images(name='composetest_test')) >= 1 def test_start_container_uses_tagged_image_if_it_exists(self): @@ -776,13 +748,13 @@ def test_start_container_uses_tagged_image_if_it_exists(self): ) container = create_and_start_container(service) container.wait() - self.assertIn(b'success', container.logs()) + assert b'success' in container.logs() def test_start_container_creates_ports(self): service = self.create_service('web', ports=[8000]) container = create_and_start_container(service).inspect() - self.assertEqual(list(container['NetworkSettings']['Ports'].keys()), ['8000/tcp']) - self.assertNotEqual(container['NetworkSettings']['Ports']['8000/tcp'][0]['HostPort'], '8000') + assert list(container['NetworkSettings']['Ports'].keys()) == ['8000/tcp'] + assert container['NetworkSettings']['Ports']['8000/tcp'][0]['HostPort'] != '8000' def test_build(self): base_dir = tempfile.mkdtemp() @@ -959,34 +931,34 @@ def test_build_with_extra_hosts(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) + assert container['HostConfig']['Privileged'] is False 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) + assert container['HostConfig']['Privileged'] is True def test_expose_does_not_publish_ports(self): service = self.create_service('web', expose=["8000"]) container = create_and_start_container(service).inspect() - self.assertEqual(container['NetworkSettings']['Ports'], {'8000/tcp': None}) + assert container['NetworkSettings']['Ports'] == {'8000/tcp': None} def test_start_container_creates_port_with_explicit_protocol(self): service = self.create_service('web', ports=['8000/udp']) container = create_and_start_container(service).inspect() - self.assertEqual(list(container['NetworkSettings']['Ports'].keys()), ['8000/udp']) + assert list(container['NetworkSettings']['Ports'].keys()) == ['8000/udp'] def test_start_container_creates_fixed_external_ports(self): service = self.create_service('web', ports=['8000:8000']) container = create_and_start_container(service).inspect() - self.assertIn('8000/tcp', container['NetworkSettings']['Ports']) - self.assertEqual(container['NetworkSettings']['Ports']['8000/tcp'][0]['HostPort'], '8000') + assert '8000/tcp' in container['NetworkSettings']['Ports'] + assert container['NetworkSettings']['Ports']['8000/tcp'][0]['HostPort'] == '8000' def test_start_container_creates_fixed_external_ports_when_it_is_different_to_internal_port(self): service = self.create_service('web', ports=['8001:8000']) container = create_and_start_container(service).inspect() - self.assertIn('8000/tcp', container['NetworkSettings']['Ports']) - self.assertEqual(container['NetworkSettings']['Ports']['8000/tcp'][0]['HostPort'], '8001') + assert '8000/tcp' in container['NetworkSettings']['Ports'] + assert container['NetworkSettings']['Ports']['8000/tcp'][0]['HostPort'] == '8001' def test_port_with_explicit_interface(self): service = self.create_service('web', ports=[ @@ -1026,21 +998,21 @@ def test_create_with_image_id(self): def test_scale(self): service = self.create_service('web') service.scale(1) - self.assertEqual(len(service.containers()), 1) + assert len(service.containers()) == 1 # Ensure containers don't have stdout or stdin connected container = service.containers()[0] config = container.inspect()['Config'] - self.assertFalse(config['AttachStderr']) - self.assertFalse(config['AttachStdout']) - self.assertFalse(config['AttachStdin']) + assert not config['AttachStderr'] + assert not config['AttachStdout'] + assert not config['AttachStdin'] service.scale(3) - self.assertEqual(len(service.containers()), 3) + assert len(service.containers()) == 3 service.scale(1) - self.assertEqual(len(service.containers()), 1) + assert len(service.containers()) == 1 service.scale(0) - self.assertEqual(len(service.containers()), 0) + assert len(service.containers()) == 0 @pytest.mark.skipif( SWARM_SKIP_CONTAINERS_ALL, @@ -1061,12 +1033,12 @@ def test_scale_with_stopped_containers(self): with mock.patch('sys.stderr', new_callable=StringIO) as mock_stderr: service.scale(2) for container in service.containers(): - self.assertTrue(container.is_running) - self.assertTrue(container.number in valid_numbers) + assert container.is_running + assert container.number in valid_numbers captured_output = mock_stderr.getvalue() - self.assertNotIn('Creating', captured_output) - self.assertIn('Starting', captured_output) + assert 'Creating' not in captured_output + assert 'Starting' in captured_output def test_scale_with_stopped_containers_and_needing_creation(self): """ @@ -1079,18 +1051,18 @@ def test_scale_with_stopped_containers_and_needing_creation(self): service.create_container(number=next_number, quiet=True) for container in service.containers(): - self.assertFalse(container.is_running) + assert not container.is_running with mock.patch('sys.stderr', new_callable=StringIO) as mock_stderr: service.scale(2) - self.assertEqual(len(service.containers()), 2) + assert len(service.containers()) == 2 for container in service.containers(): - self.assertTrue(container.is_running) + assert container.is_running captured_output = mock_stderr.getvalue() - self.assertIn('Creating', captured_output) - self.assertIn('Starting', captured_output) + assert 'Creating' in captured_output + assert 'Starting' in captured_output def test_scale_with_api_error(self): """Test that when scaling if the API returns an error, that error is handled @@ -1129,11 +1101,11 @@ def test_scale_with_unexpected_exception(self): 'compose.container.Container.create', side_effect=ValueError("BOOM") ): - with self.assertRaises(ValueError): + with pytest.raises(ValueError): service.scale(3) - self.assertEqual(len(service.containers()), 1) - self.assertTrue(service.containers()[0].is_running) + assert len(service.containers()) == 1 + assert service.containers()[0].is_running @mock.patch('compose.service.log') def test_scale_with_desired_number_already_achieved(self, mock_log): @@ -1164,28 +1136,23 @@ def test_scale_with_custom_container_name_outputs_warning(self, mock_log): results in warning output. """ service = self.create_service('app', container_name='custom-container') - self.assertEqual(service.custom_container_name, 'custom-container') + assert service.custom_container_name == 'custom-container' with pytest.raises(OperationFailedError): service.scale(3) captured_output = mock_log.warn.call_args[0][0] - self.assertEqual(len(service.containers()), 1) - self.assertIn( - "Remove the custom name to scale the service.", - captured_output - ) + assert len(service.containers()) == 1 + assert "Remove the custom name to scale the service." in captured_output def test_scale_sets_ports(self): service = self.create_service('web', ports=['8000']) service.scale(2) containers = service.containers() - self.assertEqual(len(containers), 2) + assert len(containers) == 2 for container in containers: - self.assertEqual( - list(container.get('HostConfig.PortBindings')), - ['8000/tcp']) + assert list(container.get('HostConfig.PortBindings')) == ['8000/tcp'] def test_scale_with_immediate_exit(self): service = self.create_service('web', image='busybox', command='true') @@ -1195,54 +1162,54 @@ def test_scale_with_immediate_exit(self): def test_network_mode_none(self): service = self.create_service('web', network_mode=NetworkMode('none')) container = create_and_start_container(service) - self.assertEqual(container.get('HostConfig.NetworkMode'), 'none') + assert container.get('HostConfig.NetworkMode') == 'none' def test_network_mode_bridged(self): service = self.create_service('web', network_mode=NetworkMode('bridge')) container = create_and_start_container(service) - self.assertEqual(container.get('HostConfig.NetworkMode'), 'bridge') + assert container.get('HostConfig.NetworkMode') == 'bridge' def test_network_mode_host(self): service = self.create_service('web', network_mode=NetworkMode('host')) container = create_and_start_container(service) - self.assertEqual(container.get('HostConfig.NetworkMode'), 'host') + assert container.get('HostConfig.NetworkMode') == 'host' def test_pid_mode_none_defined(self): service = self.create_service('web', pid_mode=None) container = create_and_start_container(service) - self.assertEqual(container.get('HostConfig.PidMode'), '') + assert container.get('HostConfig.PidMode') == '' def test_pid_mode_host(self): service = self.create_service('web', pid_mode=PidMode('host')) container = create_and_start_container(service) - self.assertEqual(container.get('HostConfig.PidMode'), 'host') + assert 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'), '') + assert 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') + assert container.get('HostConfig.UsernsMode') == 'host' def test_dns_no_value(self): service = self.create_service('web') container = create_and_start_container(service) - self.assertIsNone(container.get('HostConfig.Dns')) + assert container.get('HostConfig.Dns') is None def test_dns_list(self): service = self.create_service('web', dns=['8.8.8.8', '9.9.9.9']) container = create_and_start_container(service) - self.assertEqual(container.get('HostConfig.Dns'), ['8.8.8.8', '9.9.9.9']) + assert 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) + assert container.get('HostConfig.MemorySwappiness') == 11 def test_mem_reservation(self): service = self.create_service('web', mem_reservation='20m') @@ -1252,12 +1219,12 @@ def test_mem_reservation(self): def test_restart_always_value(self): service = self.create_service('web', restart={'Name': 'always'}) container = create_and_start_container(service) - self.assertEqual(container.get('HostConfig.RestartPolicy.Name'), 'always') + assert 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) + assert container.get('HostConfig.OomScoreAdj') == 500 def test_group_add_value(self): service = self.create_service('web', group_add=["root", "1"]) @@ -1281,34 +1248,34 @@ def test_restart_on_failure_value(self): 'MaximumRetryCount': 5 }) container = create_and_start_container(service) - self.assertEqual(container.get('HostConfig.RestartPolicy.Name'), 'on-failure') - self.assertEqual(container.get('HostConfig.RestartPolicy.MaximumRetryCount'), 5) + assert container.get('HostConfig.RestartPolicy.Name') == 'on-failure' + assert container.get('HostConfig.RestartPolicy.MaximumRetryCount') == 5 def test_cap_add_list(self): service = self.create_service('web', cap_add=['SYS_ADMIN', 'NET_ADMIN']) container = create_and_start_container(service) - self.assertEqual(container.get('HostConfig.CapAdd'), ['SYS_ADMIN', 'NET_ADMIN']) + assert container.get('HostConfig.CapAdd') == ['SYS_ADMIN', 'NET_ADMIN'] def test_cap_drop_list(self): service = self.create_service('web', cap_drop=['SYS_ADMIN', 'NET_ADMIN']) container = create_and_start_container(service) - self.assertEqual(container.get('HostConfig.CapDrop'), ['SYS_ADMIN', 'NET_ADMIN']) + assert container.get('HostConfig.CapDrop') == ['SYS_ADMIN', 'NET_ADMIN'] def test_dns_search(self): service = self.create_service('web', dns_search=['dc1.example.com', 'dc2.example.com']) container = create_and_start_container(service) - self.assertEqual(container.get('HostConfig.DnsSearch'), ['dc1.example.com', 'dc2.example.com']) + assert 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) - self.assertEqual(container.get('HostConfig.Tmpfs'), {'/run': ''}) + assert 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() - self.assertEqual(container.get('Config.WorkingDir'), '/working/dir/sample') + assert container.get('Config.WorkingDir') == '/working/dir/sample' def test_split_env(self): service = self.create_service( @@ -1316,7 +1283,7 @@ def test_split_env(self): 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) + assert env[k] == v def test_env_from_file_combined_with_env(self): service = self.create_service( @@ -1331,7 +1298,7 @@ def test_env_from_file_combined_with_env(self): 'FOO': 'baz', 'DOO': 'dah' }.items(): - self.assertEqual(env[k], v) + assert env[k] == v @v3_only() def test_build_with_cachefrom(self): @@ -1370,14 +1337,14 @@ def test_resolve_env(self): 'ENV_DEF': 'E3', 'NO_DEF': None }.items(): - self.assertEqual(env[k], v) + assert env[k] == v def test_with_high_enough_api_version_we_get_default_network_mode(self): # TODO: remove this test once minimum docker version is 1.8.x with mock.patch.object(self.client, '_version', '1.20'): service = self.create_service('web') service_config = service._get_container_host_config({}) - self.assertEqual(service_config['NetworkMode'], 'default') + assert service_config['NetworkMode'] == 'default' def test_labels(self): labels_dict = { @@ -1398,52 +1365,53 @@ def test_labels(self): service = self.create_service('web', labels=labels_dict) labels = create_and_start_container(service).labels.items() for pair in expected.items(): - self.assertIn(pair, labels) + assert pair in labels def test_empty_labels(self): labels_dict = {'foo': '', 'bar': ''} service = self.create_service('web', labels=labels_dict) labels = create_and_start_container(service).labels.items() for name in labels_dict: - self.assertIn((name, ''), labels) + assert (name, '') in labels def test_stop_signal(self): stop_signal = 'SIGINT' service = self.create_service('web', stop_signal=stop_signal) container = create_and_start_container(service) - self.assertEqual(container.stop_signal, stop_signal) + assert container.stop_signal == stop_signal 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') + assert service.custom_container_name == 'my-web-container' container = create_and_start_container(service) - self.assertEqual(container.name, 'my-web-container') + assert container.name == 'my-web-container' one_off_container = service.create_container(one_off=True) - self.assertNotEqual(one_off_container.name, 'my-web-container') + assert one_off_container.name != 'my-web-container' @pytest.mark.skipif(True, reason="Broken on 1.11.0 - 17.03.0") 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" - with self.assertRaisesRegexp(APIError, expected_error_msg): + with pytest.raises(APIError) as excinfo: create_and_start_container(service) + assert re.search(expected_error_msg, excinfo.value) def test_log_drive_empty_default_jsonfile(self): service = self.create_service('web') log_config = create_and_start_container(service).log_config - self.assertEqual('json-file', log_config['Type']) - self.assertFalse(log_config['Config']) + assert 'json-file' == log_config['Type'] + assert not log_config['Config'] def test_log_drive_none(self): service = self.create_service('web', logging={'driver': 'none'}) log_config = create_and_start_container(service).log_config - self.assertEqual('none', log_config['Type']) - self.assertFalse(log_config['Config']) + assert 'none' == log_config['Type'] + assert not log_config['Config'] def test_devices(self): service = self.create_service('web', devices=["/dev/random:/dev/mapped-random"]) @@ -1455,8 +1423,8 @@ def test_devices(self): 'PathInContainer': '/dev/mapped-random' } - self.assertEqual(1, len(device_config)) - self.assertDictEqual(device_dict, device_config[0]) + assert 1 == len(device_config) + assert device_dict == device_config[0] def test_duplicate_containers(self): service = self.create_service('web') @@ -1464,14 +1432,14 @@ def test_duplicate_containers(self): options = service._get_container_create_options({}, 1) original = Container.create(service.client, **options) - self.assertEqual(set(service.containers(stopped=True)), set([original])) - self.assertEqual(set(service.duplicate_containers()), set()) + assert set(service.containers(stopped=True)) == set([original]) + assert set(service.duplicate_containers()) == set() options['name'] = 'temporary_container_name' duplicate = Container.create(service.client, **options) - self.assertEqual(set(service.containers(stopped=True)), set([original, duplicate])) - self.assertEqual(set(service.duplicate_containers()), set([duplicate])) + assert set(service.containers(stopped=True)) == set([original, duplicate]) + assert set(service.duplicate_containers()) == set([duplicate]) def converge(service, strategy=ConvergenceStrategy.changed): @@ -1485,24 +1453,24 @@ class ConfigHashTest(DockerClientTestCase): def test_no_config_hash_when_one_off(self): web = self.create_service('web') container = web.create_container(one_off=True) - self.assertNotIn(LABEL_CONFIG_HASH, container.labels) + assert LABEL_CONFIG_HASH not in container.labels def test_no_config_hash_when_overriding_options(self): web = self.create_service('web') container = web.create_container(environment={'FOO': '1'}) - self.assertNotIn(LABEL_CONFIG_HASH, container.labels) + assert LABEL_CONFIG_HASH not in container.labels def test_config_hash_with_custom_labels(self): web = self.create_service('web', labels={'foo': '1'}) container = converge(web)[0] - self.assertIn(LABEL_CONFIG_HASH, container.labels) - self.assertIn('foo', container.labels) + assert LABEL_CONFIG_HASH in container.labels + assert 'foo' in container.labels def test_config_hash_sticks_around(self): web = self.create_service('web', command=["top"]) container = converge(web)[0] - self.assertIn(LABEL_CONFIG_HASH, container.labels) + assert LABEL_CONFIG_HASH in container.labels web = self.create_service('web', command=["top", "-d", "1"]) container = converge(web)[0] - self.assertIn(LABEL_CONFIG_HASH, container.labels) + assert LABEL_CONFIG_HASH in container.labels diff --git a/tests/integration/state_test.py b/tests/integration/state_test.py index 047dc704695..0b174f69fbf 100644 --- a/tests/integration/state_test.py +++ b/tests/integration/state_test.py @@ -46,12 +46,12 @@ def setUp(self): def test_no_change(self): old_containers = self.run_up(self.cfg) - self.assertEqual(len(old_containers), 2) + assert len(old_containers) == 2 new_containers = self.run_up(self.cfg) - self.assertEqual(len(new_containers), 2) + assert len(new_containers) == 2 - self.assertEqual(old_containers, new_containers) + assert old_containers == new_containers def test_partial_change(self): old_containers = self.run_up(self.cfg) @@ -61,34 +61,34 @@ def test_partial_change(self): self.cfg['web']['command'] = '/bin/true' new_containers = self.run_up(self.cfg) - self.assertEqual(len(new_containers), 2) + assert len(new_containers) == 2 preserved = list(old_containers & new_containers) - self.assertEqual(preserved, [old_db]) + assert preserved == [old_db] removed = list(old_containers - new_containers) - self.assertEqual(removed, [old_web]) + assert removed == [old_web] created = list(new_containers - old_containers) - self.assertEqual(len(created), 1) - self.assertEqual(created[0].name_without_project, 'web_1') - self.assertEqual(created[0].get('Config.Cmd'), ['/bin/true']) + assert len(created) == 1 + assert created[0].name_without_project == 'web_1' + assert created[0].get('Config.Cmd') == ['/bin/true'] def test_all_change(self): old_containers = self.run_up(self.cfg) - self.assertEqual(len(old_containers), 2) + assert len(old_containers) == 2 self.cfg['web']['command'] = '/bin/true' self.cfg['db']['command'] = '/bin/true' new_containers = self.run_up(self.cfg) - self.assertEqual(len(new_containers), 2) + assert len(new_containers) == 2 unchanged = old_containers & new_containers - self.assertEqual(len(unchanged), 0) + assert len(unchanged) == 0 new = new_containers - old_containers - self.assertEqual(len(new), 2) + assert len(new) == 2 class ProjectWithDependenciesTest(ProjectTestCase): @@ -114,10 +114,7 @@ def setUp(self): def test_up(self): containers = self.run_up(self.cfg) - self.assertEqual( - set(c.name_without_project for c in containers), - set(['db_1', 'web_1', 'nginx_1']), - ) + assert set(c.name_without_project for c in containers) == set(['db_1', 'web_1', 'nginx_1']) def test_change_leaf(self): old_containers = self.run_up(self.cfg) @@ -125,10 +122,7 @@ def test_change_leaf(self): self.cfg['nginx']['environment'] = {'NEW_VAR': '1'} new_containers = self.run_up(self.cfg) - self.assertEqual( - set(c.name_without_project for c in new_containers - old_containers), - set(['nginx_1']), - ) + assert set(c.name_without_project for c in new_containers - old_containers) == set(['nginx_1']) def test_change_middle(self): old_containers = self.run_up(self.cfg) @@ -136,9 +130,8 @@ def test_change_middle(self): self.cfg['web']['environment'] = {'NEW_VAR': '1'} new_containers = self.run_up(self.cfg) - self.assertEqual( - set(c.name_without_project for c in new_containers - old_containers), - set(['web_1', 'nginx_1']), + assert set(c.name_without_project for c in new_containers - old_containers) == set( + ['web_1', 'nginx_1'] ) def test_change_root(self): @@ -147,9 +140,8 @@ def test_change_root(self): self.cfg['db']['environment'] = {'NEW_VAR': '1'} new_containers = self.run_up(self.cfg) - self.assertEqual( - set(c.name_without_project for c in new_containers - old_containers), - set(['db_1', 'web_1', 'nginx_1']), + assert set(c.name_without_project for c in new_containers - old_containers) == set( + ['db_1', 'web_1', 'nginx_1'] ) def test_change_root_no_recreate(self): @@ -160,7 +152,7 @@ def test_change_root_no_recreate(self): self.cfg, strategy=ConvergenceStrategy.never) - self.assertEqual(new_containers - old_containers, set()) + assert new_containers - old_containers == set() def test_service_removed_while_down(self): next_cfg = { @@ -172,26 +164,26 @@ def test_service_removed_while_down(self): } containers = self.run_up(self.cfg) - self.assertEqual(len(containers), 3) + assert len(containers) == 3 project = self.make_project(self.cfg) project.stop(timeout=1) containers = self.run_up(next_cfg) - self.assertEqual(len(containers), 2) + assert len(containers) == 2 def test_service_recreated_when_dependency_created(self): containers = self.run_up(self.cfg, service_names=['web'], start_deps=False) - self.assertEqual(len(containers), 1) + assert len(containers) == 1 containers = self.run_up(self.cfg) - self.assertEqual(len(containers), 3) + assert len(containers) == 3 web, = [c for c in containers if c.service == 'web'] nginx, = [c for c in containers if c.service == 'nginx'] - self.assertEqual(set(get_links(web)), {'composetest_db_1', 'db', 'db_1'}) - self.assertEqual(set(get_links(nginx)), {'composetest_web_1', 'web', 'web_1'}) + assert set(get_links(web)) == {'composetest_db_1', 'db', 'db_1'} + assert set(get_links(nginx)) == {'composetest_web_1', 'web', 'web_1'} class ServiceStateTest(DockerClientTestCase): @@ -199,7 +191,7 @@ class ServiceStateTest(DockerClientTestCase): def test_trigger_create(self): web = self.create_service('web') - self.assertEqual(('create', []), web.convergence_plan()) + assert ('create', []) == web.convergence_plan() def test_trigger_noop(self): web = self.create_service('web') @@ -207,7 +199,7 @@ def test_trigger_noop(self): web.start() web = self.create_service('web') - self.assertEqual(('noop', [container]), web.convergence_plan()) + assert ('noop', [container]) == web.convergence_plan() def test_trigger_start(self): options = dict(command=["top"]) @@ -219,26 +211,23 @@ def test_trigger_start(self): containers[0].stop() containers[0].inspect() - self.assertEqual([c.is_running for c in containers], [False, True]) + assert [c.is_running for c in containers] == [False, True] - self.assertEqual( - ('start', containers[0:1]), - web.convergence_plan(), - ) + assert ('start', containers[0:1]) == web.convergence_plan() def test_trigger_recreate_with_config_change(self): web = self.create_service('web', command=["top"]) container = web.create_container() web = self.create_service('web', command=["top", "-d", "1"]) - self.assertEqual(('recreate', [container]), web.convergence_plan()) + assert ('recreate', [container]) == web.convergence_plan() def test_trigger_recreate_with_nonexistent_image_tag(self): web = self.create_service('web', image="busybox:latest") container = web.create_container() web = self.create_service('web', image="nonexistent-image") - self.assertEqual(('recreate', [container]), web.convergence_plan()) + assert ('recreate', [container]) == web.convergence_plan() def test_trigger_recreate_with_image_change(self): repo = 'composetest_myimage' @@ -270,7 +259,7 @@ def safe_remove_image(image): self.client.remove_container(c) web = self.create_service('web', image=image) - self.assertEqual(('recreate', [container]), web.convergence_plan()) + assert ('recreate', [container]) == web.convergence_plan() @no_cluster('Can not guarantee the build will be run on the same node the service is deployed') def test_trigger_recreate_with_build(self): @@ -288,7 +277,7 @@ def test_trigger_recreate_with_build(self): web.build() web = self.create_service('web', build={'context': str(context)}) - self.assertEqual(('recreate', [container]), web.convergence_plan()) + assert ('recreate', [container]) == web.convergence_plan() def test_image_changed_to_build(self): context = py.test.ensuretemp('test_image_changed_to_build') @@ -303,6 +292,6 @@ def test_image_changed_to_build(self): web = self.create_service('web', build={'context': str(context)}) plan = web.convergence_plan() - self.assertEqual(('recreate', [container]), plan) + assert ('recreate', [container]) == plan containers = web.execute_convergence_plan(plan) - self.assertEqual(len(containers), 1) + assert len(containers) == 1 diff --git a/tests/unit/cli/docker_client_test.py b/tests/unit/cli/docker_client_test.py index 482ad985026..c4cd275f330 100644 --- a/tests/unit/cli/docker_client_test.py +++ b/tests/unit/cli/docker_client_test.py @@ -60,7 +60,7 @@ def test_user_agent(self): platform.system(), platform.release() ) - self.assertEqual(client.headers['User-Agent'], expected) + assert client.headers['User-Agent'] == expected class TLSConfigTestCase(unittest.TestCase): diff --git a/tests/unit/cli/verbose_proxy_test.py b/tests/unit/cli/verbose_proxy_test.py index f77568dc08f..f111f8cdbf2 100644 --- a/tests/unit/cli/verbose_proxy_test.py +++ b/tests/unit/cli/verbose_proxy_test.py @@ -16,18 +16,18 @@ def test_format_call(self): ("arg1", True), {'key': 'value'}) - self.assertEqual(expected, actual) + assert expected == actual def test_format_return_sequence(self): expected = "(list with 10 items)" actual = verbose_proxy.format_return(list(range(10)), 2) - self.assertEqual(expected, actual) + assert expected == actual def test_format_return(self): expected = repr({'Id': 'ok'}) actual = verbose_proxy.format_return({'Id': 'ok'}, 2) - self.assertEqual(expected, actual) + assert expected == actual def test_format_return_no_result(self): actual = verbose_proxy.format_return(None, 2) - self.assertEqual(None, actual) + assert actual is None diff --git a/tests/unit/cli_test.py b/tests/unit/cli_test.py index c6aa75b26e0..19f6c9782c2 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -30,36 +30,36 @@ def test_default_project_name(self): test_dir = py._path.local.LocalPath('tests/fixtures/simple-composefile') with test_dir.as_cwd(): project_name = get_project_name('.') - self.assertEqual('simplecomposefile', project_name) + assert 'simplecomposefile' == project_name def test_project_name_with_explicit_base_dir(self): base_dir = 'tests/fixtures/simple-composefile' project_name = get_project_name(base_dir) - self.assertEqual('simplecomposefile', project_name) + assert 'simplecomposefile' == project_name def test_project_name_with_explicit_uppercase_base_dir(self): base_dir = 'tests/fixtures/UpperCaseDir' project_name = get_project_name(base_dir) - self.assertEqual('uppercasedir', project_name) + assert 'uppercasedir' == project_name def test_project_name_with_explicit_project_name(self): name = 'explicit-project-name' project_name = get_project_name(None, project_name=name) - self.assertEqual('explicitprojectname', project_name) + assert 'explicitprojectname' == project_name @mock.patch.dict(os.environ) def test_project_name_from_environment_new_var(self): name = 'namefromenv' os.environ['COMPOSE_PROJECT_NAME'] = name project_name = get_project_name(None) - self.assertEqual(project_name, name) + assert 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.assertEqual('simplecomposefile', project_name) + assert 'simplecomposefile' == project_name @mock.patch.dict(os.environ) def test_project_name_with_environment_file(self): @@ -80,9 +80,9 @@ def test_project_name_with_environment_file(self): def test_get_project(self): base_dir = 'tests/fixtures/longer-filename-composefile' project = get_project(base_dir) - self.assertEqual(project.name, 'longerfilenamecomposefile') - self.assertTrue(project.client) - self.assertTrue(project.services) + assert project.name == 'longerfilenamecomposefile' + assert project.client + assert project.services def test_command_help(self): with mock.patch('sys.stdout', new=StringIO()) as fake_stdout: @@ -165,10 +165,7 @@ def test_run_service_with_restart_always(self): '--workdir': None, }) - self.assertEqual( - mock_client.create_host_config.call_args[1]['restart_policy']['Name'], - 'always' - ) + assert mock_client.create_host_config.call_args[1]['restart_policy']['Name'] == 'always' command = TopLevelCommand(project) command.run({ @@ -189,9 +186,7 @@ def test_run_service_with_restart_always(self): '--workdir': None, }) - self.assertFalse( - mock_client.create_host_config.call_args[1].get('restart_policy') - ) + assert not mock_client.create_host_config.call_args[1].get('restart_policy') def test_command_manual_and_service_ports_together(self): project = Project.from_config( @@ -203,7 +198,7 @@ def test_command_manual_and_service_ports_together(self): ) command = TopLevelCommand(project) - with self.assertRaises(UserError): + with pytest.raises(UserError): command.run({ 'SERVICE': 'service', 'COMMAND': None, diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 22f98ec5552..03626ad3637 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -77,20 +77,17 @@ def test_load(self): ) ).services - self.assertEqual( - service_sort(service_dicts), - service_sort([ - { - 'name': 'bar', - 'image': 'busybox', - 'environment': {'FOO': '1'}, - }, - { - 'name': 'foo', - 'image': 'busybox', - } - ]) - ) + assert service_sort(service_dicts) == service_sort([ + { + 'name': 'bar', + 'image': 'busybox', + 'environment': {'FOO': '1'}, + }, + { + 'name': 'foo', + 'image': 'busybox', + } + ]) def test_load_v2(self): config_data = config.load( @@ -129,27 +126,24 @@ def test_load_v2(self): service_dicts = config_data.services volume_dict = config_data.volumes networks_dict = config_data.networks - self.assertEqual( - service_sort(service_dicts), - service_sort([ - { - 'name': 'bar', - 'image': 'busybox', - 'environment': {'FOO': '1'}, - }, - { - 'name': 'foo', - 'image': 'busybox', - } - ]) - ) - self.assertEqual(volume_dict, { + assert service_sort(service_dicts) == service_sort([ + { + 'name': 'bar', + 'image': 'busybox', + 'environment': {'FOO': '1'}, + }, + { + 'name': 'foo', + 'image': 'busybox', + } + ]) + assert volume_dict == { 'hello': { 'driver': 'default', 'driver_opts': {'beep': 'boop'} } - }) - self.assertEqual(networks_dict, { + } + assert networks_dict == { 'default': { 'driver': 'bridge', 'driver_opts': {'beep': 'boop'} @@ -166,7 +160,7 @@ def test_load_v2(self): 'driver': 'bridge', 'internal': True } - }) + } def test_valid_versions(self): for version in ['2', '2.0']: @@ -335,18 +329,15 @@ def test_load_service_with_name_version(self): in mock_logging.warn.call_args[0][0] service_dicts = config_data.services - self.assertEqual( - service_sort(service_dicts), - service_sort([ - { - 'name': 'version', - 'image': 'busybox', - } - ]) - ) + assert service_sort(service_dicts) == service_sort([ + { + 'name': 'version', + 'image': 'busybox', + } + ]) def test_load_throws_error_when_not_dict(self): - with self.assertRaises(ConfigurationError): + with pytest.raises(ConfigurationError): config.load( build_config_details( {'web': 'busybox:latest'}, @@ -356,7 +347,7 @@ def test_load_throws_error_when_not_dict(self): ) def test_load_throws_error_when_not_dict_v2(self): - with self.assertRaises(ConfigurationError): + with pytest.raises(ConfigurationError): config.load( build_config_details( {'version': '2', 'services': {'web': 'busybox:latest'}}, @@ -366,7 +357,7 @@ def test_load_throws_error_when_not_dict_v2(self): ) def test_load_throws_error_with_invalid_network_fields(self): - with self.assertRaises(ConfigurationError): + with pytest.raises(ConfigurationError): config.load( build_config_details({ 'version': '2', @@ -766,7 +757,7 @@ def test_load_with_multiple_files_and_extends_in_override_file(self): 'labels': {'label': 'one'}, }, ] - self.assertEqual(service_sort(service_dicts), service_sort(expected)) + assert service_sort(service_dicts) == service_sort(expected) def test_load_mixed_extends_resolution(self): main_file = config.ConfigFile( @@ -862,12 +853,12 @@ def test_config_build_configuration(self): 'filename.yml' ) ).services - self.assertTrue('context' in service[0]['build']) - self.assertEqual(service[0]['build']['dockerfile'], 'Dockerfile-alt') + assert 'context' in service[0]['build'] + assert service[0]['build']['dockerfile'] == 'Dockerfile-alt' def test_config_build_configuration_v2(self): # service.dockerfile is invalid in v2 - with self.assertRaises(ConfigurationError): + with pytest.raises(ConfigurationError): config.load( build_config_details( { @@ -894,7 +885,7 @@ def test_config_build_configuration_v2(self): } }, 'tests/fixtures/extends', 'filename.yml') ).services[0] - self.assertTrue('context' in service['build']) + assert 'context' in service['build'] service = config.load( build_config_details( @@ -913,8 +904,8 @@ def test_config_build_configuration_v2(self): 'filename.yml' ) ).services - self.assertTrue('context' in service[0]['build']) - self.assertEqual(service[0]['build']['dockerfile'], 'Dockerfile-alt') + assert 'context' in service[0]['build'] + assert service[0]['build']['dockerfile'] == 'Dockerfile-alt' def test_load_with_buildargs(self): service = config.load( @@ -1226,7 +1217,7 @@ def test_undeclared_volume_v2(self): } ) details = config.ConfigDetails('.', [base_file]) - with self.assertRaises(ConfigurationError): + with pytest.raises(ConfigurationError): config.load(details) base_file = config.ConfigFile( @@ -1575,7 +1566,7 @@ def test_valid_config_which_allows_two_type_definitions(self): 'filename.yml' ) ).services - self.assertEqual(service[0]['expose'], expose) + assert service[0]['expose'] == expose def test_valid_config_oneof_string_or_list(self): entrypoint_values = [["sh"], "sh"] @@ -1590,7 +1581,7 @@ def test_valid_config_oneof_string_or_list(self): 'filename.yml' ) ).services - self.assertEqual(service[0]['entrypoint'], entrypoint) + assert service[0]['entrypoint'] == entrypoint def test_logs_warning_for_boolean_in_environment(self): config_details = build_config_details({ @@ -1616,7 +1607,7 @@ def test_config_valid_environment_dict_key_contains_dashes(self): 'filename.yml' ) ).services - self.assertEqual(services[0]['environment']['SPRING_JPA_HIBERNATE_DDL-AUTO'], 'none') + assert services[0]['environment']['SPRING_JPA_HIBERNATE_DDL-AUTO'] == 'none' def test_load_yaml_with_yaml_error(self): tmpdir = py.test.ensuretemp('invalid_yaml_test') @@ -2208,7 +2199,7 @@ def test_empty_environment_key_allowed(self): None, ) ).services[0] - self.assertEqual(service_dict['environment']['POSTGRES_PASSWORD'], '') + assert service_dict['environment']['POSTGRES_PASSWORD'] == '' def test_merge_pid(self): # Regression: https://github.com/docker/compose/issues/4184 @@ -2565,7 +2556,7 @@ def test_depends_on_unknown_service_errors(self): assert "Service 'one' depends on service 'three'" in exc.exconly() def test_linked_service_is_undefined(self): - with self.assertRaises(ConfigurationError): + with pytest.raises(ConfigurationError): config.load( build_config_details({ 'version': '2', @@ -3097,7 +3088,7 @@ def test_config_file_with_environment_file(self): ) ).services - self.assertEqual(service_dicts[0], { + assert service_dicts[0] == { 'name': 'web', 'image': 'alpine:latest', 'ports': [ @@ -3105,7 +3096,7 @@ def test_config_file_with_environment_file(self): types.ServicePort.parse('9999')[0] ], 'command': 'true' - }) + } @mock.patch.dict(os.environ) def test_config_file_with_environment_variable(self): @@ -3122,7 +3113,7 @@ def test_config_file_with_environment_variable(self): ) ).services - self.assertEqual(service_dicts, [ + assert service_dicts == [ { 'name': 'web', 'image': 'busybox', @@ -3131,7 +3122,7 @@ def test_config_file_with_environment_variable(self): 'hostname': 'host-', 'command': '${ESCAPED}', } - ]) + ] @mock.patch.dict(os.environ) def test_config_file_with_environment_variable_with_defaults(self): @@ -3146,14 +3137,14 @@ def test_config_file_with_environment_variable_with_defaults(self): ) ).services - self.assertEqual(service_dicts, [ + assert service_dicts == [ { 'name': 'web', 'image': 'busybox', 'ports': types.ServicePort.parse('80:8000'), 'hostname': 'host-', } - ]) + ] @mock.patch.dict(os.environ) def test_unset_variable_produces_warning(self): @@ -3174,14 +3165,14 @@ def test_unset_variable_produces_warning(self): with mock.patch('compose.config.environment.log') as log: config.load(config_details) - self.assertEqual(2, log.warn.call_count) + assert 2 == log.warn.call_count warnings = sorted(args[0][0] for args in log.warn.call_args_list) - self.assertIn('BAR', warnings[0]) - self.assertIn('FOO', warnings[1]) + assert 'BAR' in warnings[0] + assert 'FOO' in warnings[1] @mock.patch.dict(os.environ) def test_invalid_interpolation(self): - with self.assertRaises(config.ConfigurationError) as cm: + with pytest.raises(config.ConfigurationError) as cm: config.load( build_config_details( {'web': {'image': '${'}}, @@ -3190,10 +3181,10 @@ def test_invalid_interpolation(self): ) ) - self.assertIn('Invalid', cm.exception.msg) - self.assertIn('for "image" option', cm.exception.msg) - self.assertIn('in service "web"', cm.exception.msg) - self.assertIn('"${"', cm.exception.msg) + assert 'Invalid' in cm.value.msg + assert 'for "image" option' in cm.value.msg + assert 'in service "web"' in cm.value.msg + assert '"${"' in cm.value.msg @mock.patch.dict(os.environ) def test_interpolation_secrets_section(self): @@ -3236,7 +3227,7 @@ class VolumeConfigTest(unittest.TestCase): def test_no_binding(self): d = make_service_dict('foo', {'build': '.', 'volumes': ['/data']}, working_dir='.') - self.assertEqual(d['volumes'], ['/data']) + assert d['volumes'] == ['/data'] @mock.patch.dict(os.environ) def test_volume_binding_with_environment_variable(self): @@ -3249,26 +3240,26 @@ def test_volume_binding_with_environment_variable(self): None, ) ).services[0] - self.assertEqual(d['volumes'], [VolumeSpec.parse('/host/path:/container/path')]) + assert d['volumes'] == [VolumeSpec.parse('/host/path:/container/path')] @pytest.mark.skipif(IS_WINDOWS_PLATFORM, reason='posix paths') @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='.') - self.assertEqual(d['volumes'], ['/home/user:/container/path']) + assert d['volumes'] == ['/home/user:/container/path'] def test_name_does_not_expand(self): d = make_service_dict('foo', {'build': '.', 'volumes': ['mydatavolume:/data']}, working_dir='.') - self.assertEqual(d['volumes'], ['mydatavolume:/data']) + assert d['volumes'] == ['mydatavolume:/data'] def test_absolute_posix_path_does_not_expand(self): d = make_service_dict('foo', {'build': '.', 'volumes': ['/var/lib/data:/data']}, working_dir='.') - self.assertEqual(d['volumes'], ['/var/lib/data:/data']) + assert d['volumes'] == ['/var/lib/data:/data'] def test_absolute_windows_path_does_not_expand(self): d = make_service_dict('foo', {'build': '.', 'volumes': ['c:\\data:/data']}, working_dir='.') - self.assertEqual(d['volumes'], ['c:\\data:/data']) + assert d['volumes'] == ['c:\\data:/data'] @pytest.mark.skipif(IS_WINDOWS_PLATFORM, reason='posix paths') def test_relative_path_does_expand_posix(self): @@ -3276,19 +3267,19 @@ def test_relative_path_does_expand_posix(self): 'foo', {'build': '.', 'volumes': ['./data:/data']}, working_dir='/home/me/myproject') - self.assertEqual(d['volumes'], ['/home/me/myproject/data:/data']) + assert d['volumes'] == ['/home/me/myproject/data:/data'] d = make_service_dict( 'foo', {'build': '.', 'volumes': ['.:/data']}, working_dir='/home/me/myproject') - self.assertEqual(d['volumes'], ['/home/me/myproject:/data']) + assert d['volumes'] == ['/home/me/myproject:/data'] d = make_service_dict( 'foo', {'build': '.', 'volumes': ['../otherproject:/data']}, working_dir='/home/me/myproject') - self.assertEqual(d['volumes'], ['/home/me/otherproject:/data']) + assert d['volumes'] == ['/home/me/otherproject:/data'] @pytest.mark.skipif(not IS_WINDOWS_PLATFORM, reason='windows paths') def test_relative_path_does_expand_windows(self): @@ -3296,19 +3287,19 @@ def test_relative_path_does_expand_windows(self): 'foo', {'build': '.', 'volumes': ['./data:/data']}, working_dir='c:\\Users\\me\\myproject') - self.assertEqual(d['volumes'], ['c:\\Users\\me\\myproject\\data:/data']) + assert d['volumes'] == ['c:\\Users\\me\\myproject\\data:/data'] d = make_service_dict( 'foo', {'build': '.', 'volumes': ['.:/data']}, working_dir='c:\\Users\\me\\myproject') - self.assertEqual(d['volumes'], ['c:\\Users\\me\\myproject:/data']) + assert d['volumes'] == ['c:\\Users\\me\\myproject:/data'] d = make_service_dict( 'foo', {'build': '.', 'volumes': ['../otherproject:/data']}, working_dir='c:\\Users\\me\\myproject') - self.assertEqual(d['volumes'], ['c:\\Users\\me\\otherproject:/data']) + assert d['volumes'] == ['c:\\Users\\me\\otherproject:/data'] @mock.patch.dict(os.environ) def test_home_directory_with_driver_does_not_expand(self): @@ -3318,12 +3309,12 @@ def test_home_directory_with_driver_does_not_expand(self): 'volumes': ['~:/data'], 'volume_driver': 'foodriver', }, working_dir='.') - self.assertEqual(d['volumes'], ['~:/data']) + assert d['volumes'] == ['~:/data'] def test_volume_path_with_non_ascii_directory(self): volume = u'/Füü/data:/data' container_path = config.resolve_volume_path(".", volume) - self.assertEqual(container_path, volume) + assert container_path == volume class MergePathMappingTest(object): @@ -3380,37 +3371,23 @@ class MergeDevicesTest(unittest.TestCase, MergePathMappingTest): class BuildOrImageMergeTest(unittest.TestCase): def test_merge_build_or_image_no_override(self): - self.assertEqual( - config.merge_service_dicts({'build': '.'}, {}, V1), - {'build': '.'}, - ) + assert config.merge_service_dicts({'build': '.'}, {}, V1) == {'build': '.'} - self.assertEqual( - config.merge_service_dicts({'image': 'redis'}, {}, V1), - {'image': 'redis'}, - ) + assert config.merge_service_dicts({'image': 'redis'}, {}, V1) == {'image': 'redis'} def test_merge_build_or_image_override_with_same(self): - self.assertEqual( - config.merge_service_dicts({'build': '.'}, {'build': './web'}, V1), - {'build': './web'}, - ) + assert config.merge_service_dicts({'build': '.'}, {'build': './web'}, V1) == {'build': './web'} - self.assertEqual( - config.merge_service_dicts({'image': 'redis'}, {'image': 'postgres'}, V1), - {'image': 'postgres'}, - ) + assert config.merge_service_dicts({'image': 'redis'}, {'image': 'postgres'}, V1) == { + 'image': 'postgres' + } def test_merge_build_or_image_override_with_other(self): - self.assertEqual( - config.merge_service_dicts({'build': '.'}, {'image': 'redis'}, V1), - {'image': 'redis'}, - ) + assert config.merge_service_dicts({'build': '.'}, {'image': 'redis'}, V1) == { + 'image': 'redis' + } - self.assertEqual( - config.merge_service_dicts({'image': 'redis'}, {'build': '.'}, V1), - {'build': '.'} - ) + assert config.merge_service_dicts({'image': 'redis'}, {'build': '.'}, V1) == {'build': '.'} class MergeListsTest(object): @@ -3660,7 +3637,7 @@ def test_validation_with_correct_memswap_values(self): 'common.yml' ) ).services - self.assertEqual(service_dict[0]['memswap_limit'], 2000000) + assert service_dict[0]['memswap_limit'] == 2000000 def test_memswap_can_be_a_string(self): service_dict = config.load( @@ -3670,7 +3647,7 @@ def test_memswap_can_be_a_string(self): 'common.yml' ) ).services - self.assertEqual(service_dict[0]['memswap_limit'], "512M") + assert service_dict[0]['memswap_limit'] == "512M" class EnvTest(unittest.TestCase): @@ -3681,10 +3658,9 @@ def test_parse_environment_as_list(self): 'CONTAINS_EQUALS=F=2', 'TRAILING_EQUALS=', ] - self.assertEqual( - config.parse_environment(environment), - {'NORMAL': 'F1', 'CONTAINS_EQUALS': 'F=2', 'TRAILING_EQUALS': ''}, - ) + assert config.parse_environment(environment) == { + 'NORMAL': 'F1', 'CONTAINS_EQUALS': 'F=2', 'TRAILING_EQUALS': '' + } def test_parse_environment_as_dict(self): environment = { @@ -3692,14 +3668,14 @@ def test_parse_environment_as_dict(self): 'CONTAINS_EQUALS': 'F=2', 'TRAILING_EQUALS': None, } - self.assertEqual(config.parse_environment(environment), environment) + assert config.parse_environment(environment) == environment def test_parse_environment_invalid(self): - with self.assertRaises(ConfigurationError): + with pytest.raises(ConfigurationError): config.parse_environment('a=b') def test_parse_environment_empty(self): - self.assertEqual(config.parse_environment(None), {}) + assert config.parse_environment(None) == {} @mock.patch.dict(os.environ) def test_resolve_environment(self): @@ -3716,27 +3692,20 @@ def test_resolve_environment(self): 'NO_DEF': None }, } - self.assertEqual( - resolve_environment( - service_dict, Environment.from_env_file(None) - ), - {'FILE_DEF': 'F1', 'FILE_DEF_EMPTY': '', 'ENV_DEF': 'E3', 'NO_DEF': None}, - ) + assert resolve_environment( + service_dict, Environment.from_env_file(None) + ) == {'FILE_DEF': 'F1', 'FILE_DEF_EMPTY': '', 'ENV_DEF': 'E3', 'NO_DEF': None} def test_resolve_environment_from_env_file(self): - self.assertEqual( - resolve_environment({'env_file': ['tests/fixtures/env/one.env']}), - {'ONE': '2', 'TWO': '1', 'THREE': '3', 'FOO': 'bar'}, - ) + assert resolve_environment({'env_file': ['tests/fixtures/env/one.env']}) == { + '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'}, - ) + assert 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 = { @@ -3745,10 +3714,9 @@ def test_resolve_environment_with_multiple_env_files(self): 'tests/fixtures/env/two.env' ] } - self.assertEqual( - resolve_environment(service_dict), - {'ONE': '2', 'TWO': '1', 'THREE': '3', 'FOO': 'baz', 'DOO': 'dah'}, - ) + assert resolve_environment(service_dict) == { + 'ONE': '2', 'TWO': '1', 'THREE': '3', 'FOO': 'baz', 'DOO': 'dah' + } def test_resolve_environment_nonexistent_file(self): with pytest.raises(ConfigurationError) as exc: @@ -3764,18 +3732,15 @@ 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']}, - Environment.from_env_file(None) - ), - { - 'FILE_DEF': u'bär', - 'FILE_DEF_EMPTY': '', - 'ENV_DEF': 'E3', - 'NO_DEF': None - }, - ) + assert resolve_environment( + {'env_file': ['tests/fixtures/env/resolve.env']}, + Environment.from_env_file(None) + ) == { + 'FILE_DEF': u'bär', + 'FILE_DEF_EMPTY': '', + 'ENV_DEF': 'E3', + 'NO_DEF': None + } @mock.patch.dict(os.environ) def test_resolve_build_args(self): @@ -3790,10 +3755,9 @@ def test_resolve_build_args(self): 'no_env': None } } - self.assertEqual( - resolve_build_args(build['args'], Environment.from_env_file(build['context'])), - {'arg1': 'value1', 'empty_arg': '', 'env_arg': 'value2', 'no_env': None}, - ) + assert resolve_build_args(build['args'], Environment.from_env_file(build['context'])) == { + 'arg1': 'value1', 'empty_arg': '', 'env_arg': 'value2', 'no_env': None + } @pytest.mark.xfail(IS_WINDOWS_PLATFORM, reason='paths use slash') @mock.patch.dict(os.environ) @@ -3807,9 +3771,7 @@ def test_resolve_path(self): "tests/fixtures/env", ) ).services[0] - self.assertEqual( - set(service_dict['volumes']), - set([VolumeSpec.parse('/tmp:/host/tmp')])) + assert set(service_dict['volumes']) == set([VolumeSpec.parse('/tmp:/host/tmp')]) service_dict = config.load( build_config_details( @@ -3817,9 +3779,7 @@ def test_resolve_path(self): "tests/fixtures/env", ) ).services[0] - self.assertEqual( - set(service_dict['volumes']), - set([VolumeSpec.parse('/opt/tmp:/opt/host/tmp')])) + assert set(service_dict['volumes']) == set([VolumeSpec.parse('/opt/tmp:/opt/host/tmp')]) def load_from_filename(filename, override_dir=None): @@ -3833,7 +3793,7 @@ class ExtendsTest(unittest.TestCase): def test_extends(self): service_dicts = load_from_filename('tests/fixtures/extends/docker-compose.yml') - self.assertEqual(service_sort(service_dicts), service_sort([ + assert service_sort(service_dicts) == service_sort([ { 'name': 'mydb', 'image': 'busybox', @@ -3851,12 +3811,12 @@ def test_extends(self): "BAZ": "2", }, } - ])) + ]) def test_merging_env_labels_ulimits(self): service_dicts = load_from_filename('tests/fixtures/extends/common-env-labels-ulimits.yml') - self.assertEqual(service_sort(service_dicts), service_sort([ + assert service_sort(service_dicts) == service_sort([ { 'name': 'web', 'image': 'busybox', @@ -3870,12 +3830,12 @@ def test_merging_env_labels_ulimits(self): 'labels': {'label': 'one'}, 'ulimits': {'nproc': 65535, 'memlock': {'soft': 1024, 'hard': 2048}} } - ])) + ]) def test_nested(self): service_dicts = load_from_filename('tests/fixtures/extends/nested.yml') - self.assertEqual(service_dicts, [ + assert service_dicts == [ { 'name': 'myweb', 'image': 'busybox', @@ -3886,14 +3846,14 @@ def test_nested(self): "BAR": "2", }, }, - ]) + ] def test_self_referencing_file(self): """ We specify a 'file' key that is the filename we're already in. """ service_dicts = load_from_filename('tests/fixtures/extends/specify-file-as-self.yml') - self.assertEqual(service_sort(service_dicts), service_sort([ + assert service_sort(service_dicts) == service_sort([ { 'environment': { @@ -3914,7 +3874,7 @@ def test_self_referencing_file(self): 'image': 'busybox', 'name': 'web' } - ])) + ]) def test_circular(self): with pytest.raises(config.CircularReference) as exc: @@ -3929,7 +3889,7 @@ def test_circular(self): ('circle-2.yml', 'other'), ('circle-1.yml', 'web'), ] - self.assertEqual(path, expected) + assert path == expected def test_extends_validation_empty_dictionary(self): with pytest.raises(ConfigurationError) as excinfo: @@ -4021,9 +3981,9 @@ def test_extends_validation_valid_config(self): ) ).services - self.assertEqual(len(service), 1) - self.assertIsInstance(service[0], dict) - self.assertEqual(service[0]['command'], "/bin/true") + assert len(service) == 1 + assert isinstance(service[0], dict) + assert service[0]['command'] == "/bin/true" def test_extended_service_with_invalid_config(self): with pytest.raises(ConfigurationError) as exc: @@ -4035,7 +3995,7 @@ def test_extended_service_with_invalid_config(self): def test_extended_service_with_valid_config(self): service = load_from_filename('tests/fixtures/extends/service-with-valid-composite-extends.yml') - self.assertEqual(service[0]['command'], "top") + assert service[0]['command'] == "top" def test_extends_file_defaults_to_self(self): """ @@ -4043,7 +4003,7 @@ def test_extends_file_defaults_to_self(self): config is valid and correctly extends from itself. """ service_dicts = load_from_filename('tests/fixtures/extends/no-file-specified.yml') - self.assertEqual(service_sort(service_dicts), service_sort([ + assert service_sort(service_dicts) == service_sort([ { 'name': 'myweb', 'image': 'busybox', @@ -4059,7 +4019,7 @@ def test_extends_file_defaults_to_self(self): "BAZ": "3", } } - ])) + ]) def test_invalid_links_in_extended_service(self): with pytest.raises(ConfigurationError) as excinfo: @@ -4110,12 +4070,12 @@ def test_volume_path(self): 'rw') ] - self.assertEqual(set(dicts[0]['volumes']), set(paths)) + assert set(dicts[0]['volumes']) == set(paths) def test_parent_build_path_dne(self): child = load_from_filename('tests/fixtures/extends/nonexistent-path-child.yml') - self.assertEqual(child, [ + assert child == [ { 'name': 'dnechild', 'image': 'busybox', @@ -4125,7 +4085,7 @@ def test_parent_build_path_dne(self): "BAR": "2", }, }, - ]) + ] def test_load_throws_error_when_base_service_does_not_exist(self): with pytest.raises(ConfigurationError) as excinfo: @@ -4136,11 +4096,11 @@ def test_load_throws_error_when_base_service_does_not_exist(self): 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'}) + assert dicts[0]['environment'] == {'FOO': '1'} def test_extended_service_with_verbose_and_shorthand_way(self): services = load_from_filename('tests/fixtures/extends/verbose-and-shorthand.yml') - self.assertEqual(service_sort(services), service_sort([ + assert service_sort(services) == service_sort([ { 'name': 'base', 'image': 'busybox', @@ -4156,7 +4116,7 @@ def test_extended_service_with_verbose_and_shorthand_way(self): 'image': 'busybox', 'environment': {'BAR': '1', 'FOO': '2'}, }, - ])) + ]) @mock.patch.dict(os.environ) def test_extends_with_environment_and_env_files(self): @@ -4267,7 +4227,7 @@ def test_extends_with_defined_version_passes(self): """) service = load_from_filename(str(tmpdir.join('docker-compose.yml'))) - self.assertEqual(service[0]['command'], "top") + assert service[0]['command'] == "top" def test_extends_with_depends_on(self): tmpdir = py.test.ensuretemp('test_extends_with_depends_on') @@ -4331,12 +4291,12 @@ class ExpandPathTest(unittest.TestCase): def test_expand_path_normal(self): result = config.expand_path(self.working_dir, 'myfile') - self.assertEqual(result, self.working_dir + '/' + 'myfile') + assert result == self.working_dir + '/' + 'myfile' def test_expand_path_absolute(self): abs_path = '/home/user/otherdir/somefile' result = config.expand_path(self.working_dir, abs_path) - self.assertEqual(result, abs_path) + assert result == abs_path def test_expand_path_with_tilde(self): test_path = '~/otherdir/somefile' @@ -4344,7 +4304,7 @@ def test_expand_path_with_tilde(self): os.environ['HOME'] = user_path = '/home/user/' result = config.expand_path(self.working_dir, test_path) - self.assertEqual(result, user_path + 'otherdir/somefile') + assert result == user_path + 'otherdir/somefile' class VolumePathTest(unittest.TestCase): @@ -4380,7 +4340,7 @@ def setUp(self): self.abs_context_path = os.path.join(os.getcwd(), 'tests/fixtures/build-ctx') def test_nonexistent_path(self): - with self.assertRaises(ConfigurationError): + with pytest.raises(ConfigurationError): config.load( build_config_details( { @@ -4398,7 +4358,7 @@ def test_relative_path(self): {'build': relative_build_path}, working_dir='tests/fixtures/build-path' ) - self.assertEqual(service_dict['build'], self.abs_context_path) + assert service_dict['build'] == self.abs_context_path def test_absolute_path(self): service_dict = make_service_dict( @@ -4406,17 +4366,17 @@ def test_absolute_path(self): {'build': self.abs_context_path}, working_dir='tests/fixtures/build-path' ) - self.assertEqual(service_dict['build'], self.abs_context_path) + assert service_dict['build'] == self.abs_context_path def test_from_file(self): service_dict = load_from_filename('tests/fixtures/build-path/docker-compose.yml') - self.assertEqual(service_dict, [{'name': 'foo', 'build': {'context': self.abs_context_path}}]) + assert service_dict == [{'name': 'foo', 'build': {'context': self.abs_context_path}}] def test_from_file_override_dir(self): override_dir = os.path.join(os.getcwd(), 'tests/fixtures/') service_dict = load_from_filename( 'tests/fixtures/build-path-override-dir/docker-compose.yml', override_dir=override_dir) - self.assertEquals(service_dict, [{'name': 'foo', 'build': {'context': self.abs_context_path}}]) + assert service_dict == [{'name': 'foo', 'build': {'context': self.abs_context_path}}] def test_valid_url_in_build_path(self): valid_urls = [ @@ -4557,10 +4517,8 @@ class GetDefaultConfigFilesTestCase(unittest.TestCase): def test_get_config_path_default_file_in_basedir(self): for index, filename in enumerate(self.files): - self.assertEqual( - filename, - get_config_filename_for_files(self.files[index:])) - with self.assertRaises(config.ComposeFileNotFound): + assert filename == get_config_filename_for_files(self.files[index:]) + with pytest.raises(config.ComposeFileNotFound): get_config_filename_for_files([]) def test_get_config_path_default_file_in_parent_dir(self): @@ -4570,8 +4528,8 @@ def get_config_in_subdir(files): return get_config_filename_for_files(files, subdir=True) for index, filename in enumerate(self.files): - self.assertEqual(filename, get_config_in_subdir(self.files[index:])) - with self.assertRaises(config.ComposeFileNotFound): + assert filename == get_config_in_subdir(self.files[index:]) + with pytest.raises(config.ComposeFileNotFound): get_config_in_subdir([]) diff --git a/tests/unit/container_test.py b/tests/unit/container_test.py index 04f43016291..0fcf23fa6cf 100644 --- a/tests/unit/container_test.py +++ b/tests/unit/container_test.py @@ -39,13 +39,11 @@ def test_from_ps(self): container = Container.from_ps(None, self.container_dict, has_been_inspected=True) - self.assertEqual( - container.dictionary, - { - "Id": self.container_id, - "Image": "busybox:latest", - "Name": "/composetest_db_1", - }) + assert container.dictionary == { + "Id": self.container_id, + "Image": "busybox:latest", + "Name": "/composetest_db_1", + } def test_from_ps_prefixed(self): self.container_dict['Names'] = [ @@ -56,11 +54,11 @@ def test_from_ps_prefixed(self): None, self.container_dict, has_been_inspected=True) - self.assertEqual(container.dictionary, { + assert container.dictionary == { "Id": self.container_id, "Image": "busybox:latest", "Name": "/composetest_db_1", - }) + } def test_environment(self): container = Container(None, { @@ -72,30 +70,30 @@ def test_environment(self): ] } }, has_been_inspected=True) - self.assertEqual(container.environment, { + assert container.environment == { 'FOO': 'BAR', 'BAZ': 'DOGE', - }) + } def test_number(self): container = Container(None, self.container_dict, has_been_inspected=True) - self.assertEqual(container.number, 7) + assert container.number == 7 def test_name(self): container = Container.from_ps(None, self.container_dict, has_been_inspected=True) - self.assertEqual(container.name, "composetest_db_1") + assert container.name == "composetest_db_1" def test_name_without_project(self): self.container_dict['Name'] = "/composetest_web_7" container = Container(None, self.container_dict, has_been_inspected=True) - self.assertEqual(container.name_without_project, "web_7") + assert container.name_without_project == "web_7" def test_name_without_project_custom_container_name(self): self.container_dict['Name'] = "/custom_name_of_container" container = Container(None, self.container_dict, has_been_inspected=True) - self.assertEqual(container.name_without_project, "custom_name_of_container") + assert container.name_without_project == "custom_name_of_container" def test_inspect_if_not_inspected(self): mock_client = mock.create_autospec(docker.APIClient) @@ -103,16 +101,15 @@ def test_inspect_if_not_inspected(self): container.inspect_if_not_inspected() mock_client.inspect_container.assert_called_once_with("the_id") - self.assertEqual(container.dictionary, - mock_client.inspect_container.return_value) - self.assertTrue(container.has_been_inspected) + assert container.dictionary == mock_client.inspect_container.return_value + assert container.has_been_inspected container.inspect_if_not_inspected() - self.assertEqual(mock_client.inspect_container.call_count, 1) + assert mock_client.inspect_container.call_count == 1 def test_human_readable_ports_none(self): container = Container(None, self.container_dict, has_been_inspected=True) - self.assertEqual(container.human_readable_ports, '') + assert container.human_readable_ports == '' def test_human_readable_ports_public_and_private(self): self.container_dict['NetworkSettings']['Ports'].update({ @@ -122,7 +119,7 @@ def test_human_readable_ports_public_and_private(self): container = Container(None, self.container_dict, has_been_inspected=True) expected = "45453/tcp, 0.0.0.0:49197->45454/tcp" - self.assertEqual(container.human_readable_ports, expected) + assert container.human_readable_ports == expected def test_get_local_port(self): self.container_dict['NetworkSettings']['Ports'].update({ @@ -130,9 +127,7 @@ def test_get_local_port(self): }) container = Container(None, self.container_dict, has_been_inspected=True) - self.assertEqual( - container.get_local_port(45454, protocol='tcp'), - '0.0.0.0:49197') + assert container.get_local_port(45454, protocol='tcp') == '0.0.0.0:49197' def test_get(self): container = Container(None, { @@ -142,9 +137,9 @@ def test_get(self): }, }, has_been_inspected=True) - self.assertEqual(container.get('Status'), "Up 8 seconds") - self.assertEqual(container.get('HostConfig.VolumesFrom'), ["volume_id"]) - self.assertEqual(container.get('Foo.Bar.DoesNotExist'), None) + assert container.get('Status') == "Up 8 seconds" + assert container.get('HostConfig.VolumesFrom') == ["volume_id"] + assert container.get('Foo.Bar.DoesNotExist') is None def test_short_id(self): container = Container(None, self.container_dict, has_been_inspected=True) @@ -182,17 +177,14 @@ def test_has_api_logs(self): 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': [ - '/swarm-host-1/myproject_db_1', - '/swarm-host-1/myproject_web_1/db' - ] - }), - 'myproject_db_1' - ) + assert get_container_name({}) is None + assert get_container_name({'Name': 'myproject_db_1'}) == 'myproject_db_1' + assert get_container_name( + {'Names': ['/myproject_db_1', '/myproject_web_1/db']} + ) == 'myproject_db_1' + assert get_container_name({ + 'Names': [ + '/swarm-host-1/myproject_db_1', + '/swarm-host-1/myproject_web_1/db' + ] + }) == 'myproject_db_1' diff --git a/tests/unit/progress_stream_test.py b/tests/unit/progress_stream_test.py index c0cb906dd16..22a6e081ba3 100644 --- a/tests/unit/progress_stream_test.py +++ b/tests/unit/progress_stream_test.py @@ -15,7 +15,7 @@ def test_stream_output(self): b'"progress": "..."}', ] events = progress_stream.stream_output(output, StringIO()) - self.assertEqual(len(events), 1) + assert len(events) == 1 def test_stream_output_div_zero(self): output = [ @@ -24,7 +24,7 @@ def test_stream_output_div_zero(self): b'"progress": "..."}', ] events = progress_stream.stream_output(output, StringIO()) - self.assertEqual(len(events), 1) + assert len(events) == 1 def test_stream_output_null_total(self): output = [ @@ -33,7 +33,7 @@ def test_stream_output_null_total(self): b'"progress": "..."}', ] events = progress_stream.stream_output(output, StringIO()) - self.assertEqual(len(events), 1) + assert len(events) == 1 def test_stream_output_progress_event_tty(self): events = [ @@ -46,7 +46,7 @@ def isatty(self): output = TTYStringIO() events = progress_stream.stream_output(events, output) - self.assertTrue(len(output.getvalue()) > 0) + assert len(output.getvalue()) > 0 def test_stream_output_progress_event_no_tty(self): events = [ @@ -55,7 +55,7 @@ def test_stream_output_progress_event_no_tty(self): output = StringIO() events = progress_stream.stream_output(events, output) - self.assertEqual(len(output.getvalue()), 0) + assert len(output.getvalue()) == 0 def test_stream_output_no_progress_event_no_tty(self): events = [ @@ -64,7 +64,7 @@ def test_stream_output_no_progress_event_no_tty(self): output = StringIO() events = progress_stream.stream_output(events, output) - self.assertTrue(len(output.getvalue()) > 0) + assert len(output.getvalue()) > 0 def test_get_digest_from_push(): diff --git a/tests/unit/project_test.py b/tests/unit/project_test.py index e5f1a175f26..1313fbe35ea 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -46,12 +46,12 @@ def test_from_config_v1(self): config_data=config, client=None, ) - self.assertEqual(len(project.services), 2) - self.assertEqual(project.get_service('web').name, 'web') - 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.networks.use_networking) + assert len(project.services) == 2 + assert project.get_service('web').name == 'web' + assert project.get_service('web').options['image'] == 'busybox:latest' + assert project.get_service('db').name == 'db' + assert project.get_service('db').options['image'] == 'busybox:latest' + assert not project.networks.use_networking def test_from_config_v2(self): config = Config( @@ -72,8 +72,8 @@ def test_from_config_v2(self): configs=None, ) project = Project.from_config('composetest', config, None) - self.assertEqual(len(project.services), 2) - self.assertTrue(project.networks.use_networking) + assert len(project.services) == 2 + assert project.networks.use_networking def test_get_service(self): web = Service( @@ -83,7 +83,7 @@ def test_get_service(self): image="busybox:latest", ) project = Project('test', [web], None) - self.assertEqual(project.get_service('web'), web) + assert project.get_service('web') == web def test_get_services_returns_all_services_without_args(self): web = Service( @@ -97,7 +97,7 @@ def test_get_services_returns_all_services_without_args(self): image='foo', ) project = Project('test', [web, console], None) - self.assertEqual(project.get_services(), [web, console]) + assert project.get_services() == [web, console] def test_get_services_returns_listed_services_with_args(self): web = Service( @@ -111,7 +111,7 @@ def test_get_services_returns_listed_services_with_args(self): image='foo', ) project = Project('test', [web, console], None) - self.assertEqual(project.get_services(['console']), [console]) + assert project.get_services(['console']) == [console] def test_get_services_with_include_links(self): db = Service( @@ -137,10 +137,7 @@ def test_get_services_with_include_links(self): links=[(web, 'web')] ) project = Project('test', [web, db, cache, console], None) - self.assertEqual( - project.get_services(['console'], include_deps=True), - [db, web, console] - ) + assert project.get_services(['console'], include_deps=True) == [db, web, console] def test_get_services_removes_duplicates_following_links(self): db = Service( @@ -155,10 +152,7 @@ def test_get_services_removes_duplicates_following_links(self): links=[(db, 'database')] ) project = Project('test', [web, db], None) - self.assertEqual( - project.get_services(['web', 'db'], include_deps=True), - [db, web] - ) + assert project.get_services(['web', 'db'], include_deps=True) == [db, web] def test_use_volumes_from_container(self): container_id = 'aabbccddee' @@ -377,8 +371,8 @@ def test_net_unset(self): ), ) service = project.get_service('test') - self.assertEqual(service.network_mode.id, None) - self.assertNotIn('NetworkMode', service._get_container_host_config({})) + assert service.network_mode.id is None + assert 'NetworkMode' not in service._get_container_host_config({}) def test_use_net_from_container(self): container_id = 'aabbccddee' @@ -403,7 +397,7 @@ def test_use_net_from_container(self): ), ) service = project.get_service('test') - self.assertEqual(service.network_mode.mode, 'container:' + container_id) + assert service.network_mode.mode == 'container:' + container_id def test_use_net_from_service(self): container_name = 'test_aaa_1' @@ -439,7 +433,7 @@ def test_use_net_from_service(self): ) service = project.get_service('test') - self.assertEqual(service.network_mode.mode, 'container:' + container_name) + assert service.network_mode.mode == 'container:' + container_name def test_uses_default_network_true(self): project = Project.from_config( @@ -513,7 +507,7 @@ def test_container_without_name(self): configs=None, ), ) - self.assertEqual([c.id for c in project.containers()], ['1']) + assert [c.id for c in project.containers()] == ['1'] def test_down_with_no_resources(self): project = Project.from_config( diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 24ed60e94e8..92d7f08d5a3 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -46,14 +46,14 @@ def setUp(self): def test_containers(self): service = Service('db', self.mock_client, 'myproject', image='foo') self.mock_client.containers.return_value = [] - self.assertEqual(list(service.containers()), []) + assert list(service.containers()) == [] def test_containers_with_containers(self): self.mock_client.containers.return_value = [ dict(Name=str(i), Image='foo', Id=i) for i in range(3) ] service = Service('db', self.mock_client, 'myproject', image='foo') - self.assertEqual([c.id for c in service.containers()], list(range(3))) + assert [c.id for c in service.containers()] == list(range(3)) expected_labels = [ '{0}=myproject'.format(LABEL_PROJECT), @@ -73,9 +73,9 @@ def test_container_without_name(self): ] service = Service('db', self.mock_client, 'myproject', image='foo') - self.assertEqual([c.id for c in service.containers()], ['1']) - self.assertEqual(service._next_container_number(), 2) - self.assertEqual(service.get_container(1).id, '1') + assert [c.id for c in service.containers()] == ['1'] + assert service._next_container_number() == 2 + assert service.get_container(1).id == '1' def test_get_volumes_from_container(self): container_id = 'aabbccddee' @@ -88,7 +88,7 @@ def test_get_volumes_from_container(self): 'rw', 'container')]) - self.assertEqual(service._get_volumes_from(), [container_id + ':rw']) + assert service._get_volumes_from() == [container_id + ':rw'] def test_get_volumes_from_container_read_only(self): container_id = 'aabbccddee' @@ -101,7 +101,7 @@ def test_get_volumes_from_container_read_only(self): 'ro', 'container')]) - self.assertEqual(service._get_volumes_from(), [container_id + ':ro']) + assert service._get_volumes_from() == [container_id + ':ro'] def test_get_volumes_from_service_container_exists(self): container_ids = ['aabbccddee', '12345'] @@ -115,7 +115,7 @@ def test_get_volumes_from_service_container_exists(self): volumes_from=[VolumeFromSpec(from_service, 'rw', 'service')], image='foo') - self.assertEqual(service._get_volumes_from(), [container_ids[0] + ":rw"]) + assert service._get_volumes_from() == [container_ids[0] + ":rw"] def test_get_volumes_from_service_container_exists_with_flags(self): for mode in ['ro', 'rw', 'z', 'rw,z', 'z,rw']: @@ -130,7 +130,7 @@ def test_get_volumes_from_service_container_exists_with_flags(self): volumes_from=[VolumeFromSpec(from_service, mode, 'service')], image='foo') - self.assertEqual(service._get_volumes_from(), [container_ids[0]]) + assert service._get_volumes_from() == [container_ids[0]] def test_get_volumes_from_service_no_container(self): container_id = 'abababab' @@ -144,7 +144,7 @@ def test_get_volumes_from_service_no_container(self): image='foo', volumes_from=[VolumeFromSpec(from_service, 'rw', 'service')]) - self.assertEqual(service._get_volumes_from(), [container_id + ':rw']) + assert service._get_volumes_from() == [container_id + ':rw'] from_service.create_container.assert_called_once_with() def test_memory_swap_limit(self): @@ -159,22 +159,16 @@ def test_memory_swap_limit(self): memswap_limit=2000000000) service._get_container_create_options({'some': 'overrides'}, 1) - self.assertTrue(self.mock_client.create_host_config.called) - self.assertEqual( - self.mock_client.create_host_config.call_args[1]['mem_limit'], - 1000000000 - ) - self.assertEqual( - self.mock_client.create_host_config.call_args[1]['memswap_limit'], - 2000000000 - ) + assert self.mock_client.create_host_config.called + assert self.mock_client.create_host_config.call_args[1]['mem_limit'] == 1000000000 + assert self.mock_client.create_host_config.call_args[1]['memswap_limit'] == 2000000000 def test_self_reference_external_link(self): service = Service( name='foo', external_links=['default_foo_1'] ) - with self.assertRaises(DependencyError): + with pytest.raises(DependencyError): service.get_container_name('foo', 1) def test_mem_reservation(self): @@ -202,11 +196,8 @@ def test_cgroup_parent(self): cgroup_parent='test') service._get_container_create_options({'some': 'overrides'}, 1) - self.assertTrue(self.mock_client.create_host_config.called) - self.assertEqual( - self.mock_client.create_host_config.call_args[1]['cgroup_parent'], - 'test' - ) + assert self.mock_client.create_host_config.called + assert self.mock_client.create_host_config.call_args[1]['cgroup_parent'] == 'test' def test_log_opt(self): self.mock_client.create_host_config.return_value = {} @@ -222,11 +213,10 @@ def test_log_opt(self): logging=logging) service._get_container_create_options({'some': 'overrides'}, 1) - self.assertTrue(self.mock_client.create_host_config.called) - self.assertEqual( - self.mock_client.create_host_config.call_args[1]['log_config'], - {'Type': 'syslog', 'Config': {'syslog-address': 'tcp://192.168.0.42:123'}} - ) + assert self.mock_client.create_host_config.called + assert self.mock_client.create_host_config.call_args[1]['log_config'] == { + 'Type': 'syslog', 'Config': {'syslog-address': 'tcp://192.168.0.42:123'} + } def test_stop_grace_period(self): self.mock_client.api_version = '1.25' @@ -237,7 +227,7 @@ def test_stop_grace_period(self): client=self.mock_client, stop_grace_period="1m35s") opts = service._get_container_create_options({'image': 'foo'}, 1) - self.assertEqual(opts['stop_timeout'], 95) + assert opts['stop_timeout'] == 95 def test_split_domainname_none(self): service = Service( @@ -246,8 +236,8 @@ def test_split_domainname_none(self): hostname='name.domain.tld', client=self.mock_client) opts = service._get_container_create_options({'image': 'foo'}, 1) - self.assertEqual(opts['hostname'], 'name.domain.tld', 'hostname') - self.assertFalse('domainname' in opts, 'domainname') + assert opts['hostname'] == 'name.domain.tld', 'hostname' + assert not ('domainname' in opts), 'domainname' def test_split_domainname_fqdn(self): self.mock_client.api_version = '1.22' @@ -257,8 +247,8 @@ def test_split_domainname_fqdn(self): image='foo', client=self.mock_client) opts = service._get_container_create_options({'image': 'foo'}, 1) - self.assertEqual(opts['hostname'], 'name', 'hostname') - self.assertEqual(opts['domainname'], 'domain.tld', 'domainname') + assert opts['hostname'] == 'name', 'hostname' + assert opts['domainname'] == 'domain.tld', 'domainname' def test_split_domainname_both(self): self.mock_client.api_version = '1.22' @@ -269,8 +259,8 @@ def test_split_domainname_both(self): domainname='domain.tld', client=self.mock_client) opts = service._get_container_create_options({'image': 'foo'}, 1) - self.assertEqual(opts['hostname'], 'name', 'hostname') - self.assertEqual(opts['domainname'], 'domain.tld', 'domainname') + assert opts['hostname'] == 'name', 'hostname' + assert opts['domainname'] == 'domain.tld', 'domainname' def test_split_domainname_weird(self): self.mock_client.api_version = '1.22' @@ -281,8 +271,8 @@ def test_split_domainname_weird(self): image='foo', client=self.mock_client) opts = service._get_container_create_options({'image': 'foo'}, 1) - self.assertEqual(opts['hostname'], 'name.sub', 'hostname') - self.assertEqual(opts['domainname'], 'domain.tld', 'domainname') + assert opts['hostname'] == 'name.sub', 'hostname' + assert opts['domainname'] == 'domain.tld', 'domainname' def test_no_default_hostname_when_not_using_networking(self): service = Service( @@ -292,7 +282,7 @@ def test_no_default_hostname_when_not_using_networking(self): client=self.mock_client, ) opts = service._get_container_create_options({'image': 'foo'}, 1) - self.assertIsNone(opts.get('hostname')) + assert opts.get('hostname') is None def test_get_container_create_options_with_name_option(self): service = Service( @@ -305,7 +295,7 @@ def test_get_container_create_options_with_name_option(self): {'name': name}, 1, one_off=OneOffFilter.only) - self.assertEqual(opts['name'], name) + assert opts['name'] == name def test_get_container_create_options_does_not_mutate_options(self): labels = {'thing': 'real'} @@ -328,12 +318,11 @@ def test_get_container_create_options_does_not_mutate_options(self): 1, previous_container=prev_container) - self.assertEqual(service.options['labels'], labels) - self.assertEqual(service.options['environment'], environment) + assert service.options['labels'] == labels + assert service.options['environment'] == environment - self.assertEqual( - opts['labels'][LABEL_CONFIG_HASH], - '2524a06fcb3d781aa2c981fc40bcfa08013bb318e4273bfa388df22023e6f2aa') + assert opts['labels'][LABEL_CONFIG_HASH] == \ + '2524a06fcb3d781aa2c981fc40bcfa08013bb318e4273bfa388df22023e6f2aa' assert opts['environment'] == ['also=real'] def test_get_container_create_options_sets_affinity_with_binds(self): @@ -385,7 +374,8 @@ def test_get_container_not_found(self): self.mock_client.containers.return_value = [] service = Service('foo', client=self.mock_client, image='foo') - self.assertRaises(ValueError, service.get_container) + with pytest.raises(ValueError): + service.get_container() @mock.patch('compose.service.Container', autospec=True) def test_get_container(self, mock_container_class): @@ -394,7 +384,7 @@ def test_get_container(self, mock_container_class): service = Service('foo', image='foo', client=self.mock_client) container = service.get_container(number=2) - self.assertEqual(container, mock_container_class.from_ps.return_value) + assert container == mock_container_class.from_ps.return_value mock_container_class.from_ps.assert_called_once_with( self.mock_client, container_dict) @@ -449,23 +439,17 @@ def test_recreate_container_with_timeout(self, _): mock_container.stop.assert_called_once_with(timeout=1) def test_parse_repository_tag(self): - self.assertEqual(parse_repository_tag("root"), ("root", "", ":")) - self.assertEqual(parse_repository_tag("root:tag"), ("root", "tag", ":")) - 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", "@")) + assert parse_repository_tag("root") == ("root", "", ":") + assert parse_repository_tag("root:tag") == ("root", "tag", ":") + assert parse_repository_tag("user/repo") == ("user/repo", "", ":") + assert parse_repository_tag("user/repo:tag") == ("user/repo", "tag", ":") + assert parse_repository_tag("url:5000/repo") == ("url:5000/repo", "", ":") + assert parse_repository_tag("url:5000/repo:tag") == ("url:5000/repo", "tag", ":") + assert parse_repository_tag("root@sha256:digest") == ("root", "sha256:digest", "@") + assert parse_repository_tag("user/repo@sha256:digest") == ("user/repo", "sha256:digest", "@") + assert parse_repository_tag("url:5000/repo@sha256:digest") == ( + "url:5000/repo", "sha256:digest", "@" + ) def test_create_container(self): service = Service('foo', client=self.mock_client, build={'context': '.'}) @@ -553,8 +537,8 @@ def test_build_does_not_pull(self): service = Service('foo', client=self.mock_client, build={'context': '.'}) service.build() - self.assertEqual(self.mock_client.build.call_count, 1) - self.assertFalse(self.mock_client.build.call_args[1]['pull']) + assert self.mock_client.build.call_count == 1 + assert not self.mock_client.build.call_args[1]['pull'] def test_build_with_override_build_args(self): self.mock_client.build.return_value = [ @@ -653,63 +637,63 @@ def test_specifies_host_port_with_no_ports(self): service = Service( 'foo', image='foo') - self.assertEqual(service.specifies_host_port(), False) + assert not service.specifies_host_port() def test_specifies_host_port_with_container_port(self): service = Service( 'foo', image='foo', ports=["2000"]) - self.assertEqual(service.specifies_host_port(), False) + assert not service.specifies_host_port() def test_specifies_host_port_with_host_port(self): service = Service( 'foo', image='foo', ports=["1000:2000"]) - self.assertEqual(service.specifies_host_port(), True) + assert service.specifies_host_port() def test_specifies_host_port_with_host_ip_no_port(self): service = Service( 'foo', image='foo', ports=["127.0.0.1::2000"]) - self.assertEqual(service.specifies_host_port(), False) + assert not service.specifies_host_port() def test_specifies_host_port_with_host_ip_and_port(self): service = Service( 'foo', image='foo', ports=["127.0.0.1:1000:2000"]) - self.assertEqual(service.specifies_host_port(), True) + assert service.specifies_host_port() def test_specifies_host_port_with_container_port_range(self): service = Service( 'foo', image='foo', ports=["2000-3000"]) - self.assertEqual(service.specifies_host_port(), False) + assert not service.specifies_host_port() def test_specifies_host_port_with_host_port_range(self): service = Service( 'foo', image='foo', ports=["1000-2000:2000-3000"]) - self.assertEqual(service.specifies_host_port(), True) + assert service.specifies_host_port() def test_specifies_host_port_with_host_ip_no_port_range(self): service = Service( 'foo', image='foo', ports=["127.0.0.1::2000-3000"]) - self.assertEqual(service.specifies_host_port(), False) + assert not service.specifies_host_port() def test_specifies_host_port_with_host_ip_and_port_range(self): service = Service( 'foo', image='foo', ports=["127.0.0.1:1000-2000:2000-3000"]) - self.assertEqual(service.specifies_host_port(), True) + assert service.specifies_host_port() def test_image_name_from_config(self): image_name = 'example/web:latest' @@ -730,10 +714,10 @@ def test_only_log_warning_when_host_ports_clash(self, mock_log): ports=["8080:80"]) service.scale(0) - self.assertFalse(mock_log.warn.called) + assert not mock_log.warn.called service.scale(1) - self.assertFalse(mock_log.warn.called) + assert not mock_log.warn.called service.scale(2) mock_log.warn.assert_called_once_with( @@ -815,16 +799,16 @@ class NetTestCase(unittest.TestCase): 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) + assert network_mode.id == 'host' + assert network_mode.mode == 'host' + assert network_mode.service_name is None def test_network_mode_container(self): container_id = 'abcd' 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) + assert network_mode.id == container_id + assert network_mode.mode == 'container:' + container_id + assert network_mode.service_name is None def test_network_mode_service(self): container_id = 'bbbb' @@ -837,9 +821,9 @@ def test_network_mode_service(self): service = Service(name=service_name, client=mock_client) network_mode = ServiceNetworkMode(service) - self.assertEqual(network_mode.id, service_name) - self.assertEqual(network_mode.mode, 'container:' + container_id) - self.assertEqual(network_mode.service_name, service_name) + assert network_mode.id == service_name + assert network_mode.mode == 'container:' + container_id + assert network_mode.service_name == service_name def test_network_mode_service_no_containers(self): service_name = 'web' @@ -849,9 +833,9 @@ def test_network_mode_service_no_containers(self): service = Service(name=service_name, client=mock_client) network_mode = ServiceNetworkMode(service) - self.assertEqual(network_mode.id, service_name) - self.assertEqual(network_mode.mode, None) - self.assertEqual(network_mode.service_name, service_name) + assert network_mode.id == service_name + assert network_mode.mode is None + assert network_mode.service_name == service_name class ServicePortsTest(unittest.TestCase): @@ -1002,13 +986,10 @@ def test_mount_same_host_path_to_two_volumes(self): number=1, ) - self.assertEqual( - set(self.mock_client.create_host_config.call_args[1]['binds']), - set([ - '/host/path:/data1:rw', - '/host/path:/data2:rw', - ]), - ) + assert set(self.mock_client.create_host_config.call_args[1]['binds']) == set([ + '/host/path:/data1:rw', + '/host/path:/data2:rw', + ]) def test_get_container_create_options_with_different_host_path_in_container_json(self): service = Service( @@ -1113,9 +1094,7 @@ def test_create_with_special_volume_mode(self): ).create_container() assert self.mock_client.create_container.call_count == 1 - self.assertEqual( - self.mock_client.create_host_config.call_args[1]['binds'], - [volume]) + assert self.mock_client.create_host_config.call_args[1]['binds'] == [volume] class ServiceSecretTest(unittest.TestCase): diff --git a/tests/unit/split_buffer_test.py b/tests/unit/split_buffer_test.py index c41ea27d40f..dedd4ee36bb 100644 --- a/tests/unit/split_buffer_test.py +++ b/tests/unit/split_buffer_test.py @@ -50,5 +50,5 @@ def assert_produces(self, reader, expectations): split = split_buffer(reader()) for (actual, expected) in zip(split, expectations): - self.assertEqual(type(actual), type(expected)) - self.assertEqual(actual, expected) + assert type(actual) == type(expected) + assert actual == expected From 963b004749099e904ce706c80d53c3a89f831602 Mon Sep 17 00:00:00 2001 From: John Harris Date: Thu, 4 Jan 2018 10:14:21 -0800 Subject: [PATCH 3215/4072] Add COMPOSE_IGNORE_ORPHANS Signed-off-by: John Harris --- compose/cli/main.py | 20 +++++++++++++++++++- compose/project.py | 15 ++++++++++++--- tests/acceptance/cli_test.py | 6 ++++++ tests/integration/project_test.py | 25 +++++++++++++++++++++++++ 4 files changed, 62 insertions(+), 4 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 308ac5bb252..81f1e93cdba 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -377,9 +377,20 @@ def down(self, options): -t, --timeout TIMEOUT Specify a shutdown timeout in seconds. (default: 10) """ + environment = Environment.from_env_file(self.project_dir) + ignore_orphans = environment.get_boolean('COMPOSE_IGNORE_ORPHANS') + + if ignore_orphans and options['--remove-orphans']: + raise UserError("COMPOSE_IGNORE_ORPHANS and --remove-orphans cannot be combined.") + image_type = image_type_from_opt('--rmi', options['--rmi']) timeout = timeout_from_opts(options) - self.project.down(image_type, options['--volumes'], options['--remove-orphans'], timeout=timeout) + self.project.down( + image_type, + options['--volumes'], + options['--remove-orphans'], + timeout=timeout, + ignore_orphans=ignore_orphans) def events(self, options): """ @@ -941,6 +952,12 @@ def up(self, options): if detached and timeout: raise UserError("-d and --timeout cannot be combined.") + environment = Environment.from_env_file(self.project_dir) + ignore_orphans = environment.get_boolean('COMPOSE_IGNORE_ORPHANS') + + if ignore_orphans and remove_orphans: + raise UserError("COMPOSE_IGNORE_ORPHANS and --remove-orphans cannot be combined.") + if no_start: for excluded in ['-d', '--abort-on-container-exit', '--exit-code-from']: if options.get(excluded): @@ -955,6 +972,7 @@ def up(self, options): timeout=timeout, detached=detached, remove_orphans=remove_orphans, + ignore_orphans=ignore_orphans, scale_override=parse_scale_args(options['--scale']), start=not no_start ) diff --git a/compose/project.py b/compose/project.py index 11ee4a0b73c..6683a3cba34 100644 --- a/compose/project.py +++ b/compose/project.py @@ -330,9 +330,16 @@ def remove_stopped(self, service_names=None, one_off=OneOffFilter.exclude, **opt service_names, stopped=True, one_off=one_off ), options) - def down(self, remove_image_type, include_volumes, remove_orphans=False, timeout=None): + def down( + self, + remove_image_type, + include_volumes, + remove_orphans=False, + timeout=None, + ignore_orphans=False): self.stop(one_off=OneOffFilter.include, timeout=timeout) - self.find_orphan_containers(remove_orphans) + if not ignore_orphans: + self.find_orphan_containers(remove_orphans) self.remove_stopped(v=include_volumes, one_off=OneOffFilter.include) self.networks.remove() @@ -432,6 +439,7 @@ def up(self, timeout=None, detached=False, remove_orphans=False, + ignore_orphans=False, scale_override=None, rescale=True, start=True): @@ -439,7 +447,8 @@ def up(self, warn_for_swarm_mode(self.client) self.initialize() - self.find_orphan_containers(remove_orphans) + if not ignore_orphans: + self.find_orphan_containers(remove_orphans) if scale_override is None: scale_override = {} diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index c064716503e..f801704d747 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1337,6 +1337,12 @@ def test_up_with_timeout_detached(self): result = self.dispatch(['up', '-d', '-t', '1'], returncode=1) assert "-d and --timeout cannot be combined." in result.stderr + @mock.patch.dict(os.environ) + def test_up_with_ignore_remove_orphans(self): + os.environ["COMPOSE_IGNORE_ORPHANS"] = "True" + result = self.dispatch(['up', '-d', '--remove-orphans'], returncode=1) + assert "COMPOSE_IGNORE_ORPHANS and --remove-orphans cannot be combined." in result.stderr + def test_up_handles_sigint(self): proc = start_process(self.base_dir, ['up', '-t', '2']) wait_on_condition(ContainerCountCondition(self.project, 2)) diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 3180c1b9b1e..a9ca3be6119 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -1633,6 +1633,31 @@ def test_project_up_orphans(self): if ctnr.labels.get(LABEL_SERVICE) == 'service1' ]) == 0 + def test_project_up_ignore_orphans(self): + config_dict = { + 'service1': { + 'image': 'busybox:latest', + 'command': 'top', + } + } + + config_data = load_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 = load_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(ignore_orphans=True) + + mock_log.warning.assert_not_called() + @v2_1_only() def test_project_up_healthy_dependency(self): config_dict = { From ba0b3d421c2794c44129a1b2f1b5180ea3d1f238 Mon Sep 17 00:00:00 2001 From: Drew Romanyk Date: Tue, 14 Nov 2017 16:24:58 -0600 Subject: [PATCH 3216/4072] Fix depends on restart behavior, fixes #3397 Signed-off-by: Drew Romanyk --- compose/cli/main.py | 5 ++++- compose/project.py | 16 ++++++++++++---- tests/integration/state_test.py | 26 ++++++++++++++++++++------ 3 files changed, 36 insertions(+), 11 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 81f1e93cdba..14471cf345d 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -918,6 +918,7 @@ def up(self, options): --no-deps Don't start linked services. --force-recreate Recreate containers even if their configuration and image haven't changed. + --always-recreate-deps Recreate dependant containers. Incompatible with --no-recreate. --no-recreate If containers already exist, don't recreate them. Incompatible with --force-recreate. @@ -938,6 +939,7 @@ def up(self, options): setting in the Compose file if present. """ start_deps = not options['--no-deps'] + always_recreate_deps = options['--always-recreate-deps'] exit_value_from = exitval_from_opts(options, self.project) cascade_stop = options['--abort-on-container-exit'] service_names = options['SERVICE'] @@ -974,7 +976,8 @@ def up(self, options): remove_orphans=remove_orphans, ignore_orphans=ignore_orphans, scale_override=parse_scale_args(options['--scale']), - start=not no_start + start=not no_start, + always_recreate_deps=always_recreate_deps ) if detached or no_start: diff --git a/compose/project.py b/compose/project.py index 6683a3cba34..f7a55d966a0 100644 --- a/compose/project.py +++ b/compose/project.py @@ -442,7 +442,8 @@ def up(self, ignore_orphans=False, scale_override=None, rescale=True, - start=True): + start=True, + always_recreate_deps=False): warn_for_swarm_mode(self.client) @@ -459,7 +460,8 @@ def up(self, for svc in services: svc.ensure_image_exists(do_build=do_build) - plans = self._get_convergence_plans(services, strategy) + plans = self._get_convergence_plans( + services, strategy, always_recreate_deps=always_recreate_deps) scaled_services = self.get_scaled_services(services, scale_override) def do(service): @@ -503,7 +505,7 @@ def initialize(self): self.networks.initialize() self.volumes.initialize() - def _get_convergence_plans(self, services, strategy): + def _get_convergence_plans(self, services, strategy, always_recreate_deps=False): plans = {} for service in services: @@ -518,7 +520,13 @@ def _get_convergence_plans(self, services, strategy): log.debug('%s has upstream changes (%s)', service.name, ", ".join(updated_dependencies)) - plan = service.convergence_plan(ConvergenceStrategy.always) + containers_stopped = any((not c.is_paused and not c.is_restarting and not c.is_running + for c in service.containers(stopped=True))) + has_links = any(c.get('HostConfig.Links') for c in service.containers()) + if always_recreate_deps or containers_stopped or not has_links: + plan = service.convergence_plan(ConvergenceStrategy.always) + else: + plan = service.convergence_plan(strategy) else: plan = service.convergence_plan(strategy) diff --git a/tests/integration/state_test.py b/tests/integration/state_test.py index 0b174f69fbf..5992a02a464 100644 --- a/tests/integration/state_test.py +++ b/tests/integration/state_test.py @@ -130,9 +130,16 @@ def test_change_middle(self): self.cfg['web']['environment'] = {'NEW_VAR': '1'} new_containers = self.run_up(self.cfg) - assert set(c.name_without_project for c in new_containers - old_containers) == set( - ['web_1', 'nginx_1'] - ) + assert set(c.name_without_project for c in new_containers - old_containers) == set(['web_1']) + + def test_change_middle_always_recreate_deps(self): + old_containers = self.run_up(self.cfg, always_recreate_deps=True) + + self.cfg['web']['environment'] = {'NEW_VAR': '1'} + new_containers = self.run_up(self.cfg, always_recreate_deps=True) + + assert set(c.name_without_project + for c in new_containers - old_containers) == {'web_1', 'nginx_1'} def test_change_root(self): old_containers = self.run_up(self.cfg) @@ -140,9 +147,16 @@ def test_change_root(self): self.cfg['db']['environment'] = {'NEW_VAR': '1'} new_containers = self.run_up(self.cfg) - assert set(c.name_without_project for c in new_containers - old_containers) == set( - ['db_1', 'web_1', 'nginx_1'] - ) + assert set(c.name_without_project for c in new_containers - old_containers) == set(['db_1']) + + def test_change_root_always_recreate_deps(self): + old_containers = self.run_up(self.cfg, always_recreate_deps=True) + + self.cfg['db']['environment'] = {'NEW_VAR': '1'} + new_containers = self.run_up(self.cfg, always_recreate_deps=True) + + assert set(c.name_without_project + for c in new_containers - old_containers) == {'db_1', 'web_1', 'nginx_1'} def test_change_root_no_recreate(self): old_containers = self.run_up(self.cfg) From 5db3cd60d4e5bd4c320cc23ba7cda6f619522d62 Mon Sep 17 00:00:00 2001 From: Drew Romanyk Date: Wed, 29 Nov 2017 21:06:45 -0600 Subject: [PATCH 3217/4072] Reword & Optimize getting stopped containers & update tests Signed-off-by: Drew Romanyk --- 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 14471cf345d..338566c72aa 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -918,7 +918,7 @@ def up(self, options): --no-deps Don't start linked services. --force-recreate Recreate containers even if their configuration and image haven't changed. - --always-recreate-deps Recreate dependant containers. + --always-recreate-deps Recreate dependent containers. Incompatible with --no-recreate. --no-recreate If containers already exist, don't recreate them. Incompatible with --force-recreate. diff --git a/compose/project.py b/compose/project.py index f7a55d966a0..b4dbb817a67 100644 --- a/compose/project.py +++ b/compose/project.py @@ -520,8 +520,8 @@ def _get_convergence_plans(self, services, strategy, always_recreate_deps=False) log.debug('%s has upstream changes (%s)', service.name, ", ".join(updated_dependencies)) - containers_stopped = any((not c.is_paused and not c.is_restarting and not c.is_running - for c in service.containers(stopped=True))) + containers_stopped = len( + service.containers(stopped=True, filters={'status': ['created', 'exited']})) > 0 has_links = any(c.get('HostConfig.Links') for c in service.containers()) if always_recreate_deps or containers_stopped or not has_links: plan = service.convergence_plan(ConvergenceStrategy.always) From c63ad67e9c20ae09ceaea95f0d552973450efcb7 Mon Sep 17 00:00:00 2001 From: Drew Romanyk Date: Thu, 30 Nov 2017 22:00:10 -0600 Subject: [PATCH 3218/4072] Convert to use any for finding stopped containers Signed-off-by: Drew Romanyk --- compose/project.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/compose/project.py b/compose/project.py index b4dbb817a67..a507eab9636 100644 --- a/compose/project.py +++ b/compose/project.py @@ -520,8 +520,8 @@ def _get_convergence_plans(self, services, strategy, always_recreate_deps=False) log.debug('%s has upstream changes (%s)', service.name, ", ".join(updated_dependencies)) - containers_stopped = len( - service.containers(stopped=True, filters={'status': ['created', 'exited']})) > 0 + containers_stopped = any( + service.containers(stopped=True, filters={'status': ['created', 'exited']})) has_links = any(c.get('HostConfig.Links') for c in service.containers()) if always_recreate_deps or containers_stopped or not has_links: plan = service.convergence_plan(ConvergenceStrategy.always) From e400c05de09bc1a1cdb287a83ac56a05d77ab044 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 3 Jan 2018 18:30:26 -0800 Subject: [PATCH 3219/4072] Support ${VAR:?err} syntax for mandatory variables Signed-off-by: Joffrey F --- compose/config/interpolation.py | 63 +++++++++++++++++++++---- tests/unit/cli/formatter_test.py | 1 - tests/unit/config/interpolation_test.py | 40 +++++++++++++++- 3 files changed, 94 insertions(+), 10 deletions(-) diff --git a/compose/config/interpolation.py b/compose/config/interpolation.py index 68d3be682e6..0f263038305 100644 --- a/compose/config/interpolation.py +++ b/compose/config/interpolation.py @@ -60,6 +60,15 @@ def interpolate_value(name, config_key, value, section, interpolator): name=name, section=section, string=e.string)) + except UnsetRequiredSubstitution as e: + raise ConfigurationError( + 'Missing mandatory value for "{config_key}" option in {section} "{name}": {err}'.format( + config_key=config_key, + name=name, + section=section, + err=e.err + ) + ) def recursive_interpolate(obj, interpolator, config_path): @@ -79,21 +88,54 @@ def append(config_path, key): class TemplateWithDefaults(Template): - idpattern = r'[_a-z][_a-z0-9]*(?::?-[^}]*)?' + pattern = r""" + %(delim)s(?: + (?P%(delim)s) | + (?P%(id)s) | + {(?P%(bid)s)} | + (?P) + ) + """ % { + 'delim': re.escape('$'), + 'id': r'[_a-z][_a-z0-9]*', + 'bid': r'[_a-z][_a-z0-9]*(?:(?P:?[-?])[^}]*)?', + } + + @staticmethod + def process_braced_group(braced, sep, mapping): + if ':-' == sep: + var, _, default = braced.partition(':-') + return mapping.get(var) or default + elif '-' == sep: + var, _, default = braced.partition('-') + return mapping.get(var, default) + + elif ':?' == sep: + var, _, err = braced.partition(':?') + result = mapping.get(var) + if not result: + raise UnsetRequiredSubstitution(err) + return result + elif '?' == sep: + var, _, err = braced.partition('?') + if var in mapping: + return mapping.get(var) + raise UnsetRequiredSubstitution(err) # 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') + braced = mo.group('braced') + if braced is not None: + sep = mo.group('sep') + result = self.process_braced_group(braced, sep, mapping) + if result: + return result + if 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: @@ -110,6 +152,11 @@ def __init__(self, string): self.string = string +class UnsetRequiredSubstitution(Exception): + def __init__(self, custom_err_msg): + self.err = custom_err_msg + + PATH_JOKER = '[^.]+' diff --git a/tests/unit/cli/formatter_test.py b/tests/unit/cli/formatter_test.py index 4aa025e693b..e685725112f 100644 --- a/tests/unit/cli/formatter_test.py +++ b/tests/unit/cli/formatter_test.py @@ -37,7 +37,6 @@ def test_format_info(self): 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): diff --git a/tests/unit/config/interpolation_test.py b/tests/unit/config/interpolation_test.py index 702ea682d39..dfeba96d0a9 100644 --- a/tests/unit/config/interpolation_test.py +++ b/tests/unit/config/interpolation_test.py @@ -9,6 +9,7 @@ from compose.config.interpolation import Interpolator from compose.config.interpolation import InvalidInterpolation from compose.config.interpolation import TemplateWithDefaults +from compose.config.interpolation import UnsetRequiredSubstitution from compose.const import COMPOSEFILE_V2_0 as V2_0 from compose.const import COMPOSEFILE_V2_3 as V2_3 from compose.const import COMPOSEFILE_V3_4 as V3_4 @@ -357,9 +358,46 @@ 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): assert defaults_interpolator("ok ${BAR:-def}") == "ok def" assert defaults_interpolator("ok ${BAR-def}") == "ok " + + +def test_interpolate_mandatory_values(defaults_interpolator): + assert defaults_interpolator("ok ${FOO:?bar}") == "ok first" + assert defaults_interpolator("ok ${FOO?bar}") == "ok first" + assert defaults_interpolator("ok ${BAR?bar}") == "ok " + + with pytest.raises(UnsetRequiredSubstitution) as e: + defaults_interpolator("not ok ${BAR:?high bar}") + assert e.value.err == 'high bar' + + with pytest.raises(UnsetRequiredSubstitution) as e: + defaults_interpolator("not ok ${BAZ?dropped the bazz}") + assert e.value.err == 'dropped the bazz' + + +def test_interpolate_mandatory_no_err_msg(defaults_interpolator): + with pytest.raises(UnsetRequiredSubstitution) as e: + defaults_interpolator("not ok ${BAZ?}") + + assert e.value.err == '' + + +def test_interpolate_mixed_separators(defaults_interpolator): + assert defaults_interpolator("ok ${BAR:-/non:-alphanumeric}") == "ok /non:-alphanumeric" + assert defaults_interpolator("ok ${BAR:-:?wwegegr??:?}") == "ok :?wwegegr??:?" + assert defaults_interpolator("ok ${BAR-:-hello}") == 'ok ' + + with pytest.raises(UnsetRequiredSubstitution) as e: + defaults_interpolator("not ok ${BAR:?xazz:-redf}") + assert e.value.err == 'xazz:-redf' + + assert defaults_interpolator("ok ${BAR?...:?bar}") == "ok " + + +def test_unbraced_separators(defaults_interpolator): + assert defaults_interpolator("ok $FOO:-bar") == "ok first:-bar" + assert defaults_interpolator("ok $BAZ?error") == "ok ?error" From 253bed497db517807c6b69c4e68827038f727f6d Mon Sep 17 00:00:00 2001 From: Svyatoslav Ilinskiy Date: Sun, 7 Jan 2018 13:56:05 -0600 Subject: [PATCH 3220/4072] Allow only one `--filter` argument and simplify code Signed-off-by: Svyatoslav Ilinskiy --- compose/cli/main.py | 116 ++++++++++++++++++----------------- tests/acceptance/cli_test.py | 29 +++++---- 2 files changed, 73 insertions(+), 72 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 37168f03388..3b6e25ccb90 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -599,47 +599,51 @@ def ps(self, options): """ List containers. - Usage: ps [options] [--filter KEY=VAL...] [SERVICE...] + Usage: ps [options] [--filter KEY=VAL] [SERVICE...] Options: -q Only display IDs --services Display services - --filter KEY=VAL Filter services by a property (can be used multiple times) + --filter KEY=VAL Filter services by a property """ + if options['-q'] and options['--services']: + raise UserError('-q and --services cannot be combined') + if options['--services']: - filters = build_filters(options.get('--filter')) + filt = build_filter(options.get('--filter')) services = self.project.services - if filters: - services = filter_services(filters, services, self.project) + if filt: + services = filter_services(filt, services, self.project) print('\n'.join(service.name for service in services)) + return + + containers = sorted( + self.project.containers(service_names=options['SERVICE'], stopped=True) + + self.project.containers(service_names=options['SERVICE'], one_off=OneOffFilter.only), + key=attrgetter('name')) + + if options['-q']: + for container in containers: + print(container.id) else: - containers = sorted( - self.project.containers(service_names=options['SERVICE'], stopped=True) + - self.project.containers(service_names=options['SERVICE'], one_off=OneOffFilter.only), - key=attrgetter('name')) - - if options['-q']: - for container in containers: - print(container.id) - else: - headers = [ - 'Name', - 'Command', - 'State', - 'Ports', - ] - rows = [] - for container in containers: - command = container.human_readable_command - if len(command) > 30: - command = '%s ...' % command[:26] - rows.append([ - container.name, - command, - container.human_readable_state, - container.human_readable_ports, - ]) - print(Formatter().table(headers, rows)) + headers = [ + 'Name', + 'Command', + 'State', + 'Ports', + ] + rows = [] + for container in containers: + command = container.human_readable_command + if len(command) > 30: + command = '%s ...' % command[:26] + rows.append([ + container.name, + command, + container.human_readable_state, + container.human_readable_ports, + ]) + print(Formatter().table(headers, rows)) def pull(self, options): """ @@ -1324,34 +1328,34 @@ def build_exec_command(options, container_id, command): def has_container_with_state(containers, state): + states = { + 'running': lambda c: c.is_running, + 'stopped': lambda c: not c.is_running, + 'paused': lambda c: c.is_paused, + 'restarting': lambda c: c.is_restarting, + } for container in containers: - states = { - 'running': container.is_running, - 'stopped': not container.is_running, - 'paused': container.is_paused, - } if state not in states: raise UserError("Invalid state: %s" % state) - if states[state]: + if states[state](container): return True - return False -def filter_services(filters, services, project): +def filter_services(filt, services, project): def should_include(service): - for f in filters: + for f in filt: if f == 'status': + state = filt[f] containers = project.containers([service.name], stopped=True) - for status in filters[f]: - if not has_container_with_state(containers, status): - return False + if not has_container_with_state(containers, state): + return False elif f == 'key': - for key in filters[f]: - if key == 'image' or key == 'build': - if key not in service.options: - return False - else: - raise UserError("Invalid option: %s" % key) + key = filt[f] + if key == 'image' or key == 'build': + if key not in service.options: + return False + else: + raise UserError("Invalid value for key filter: %s" % key) else: raise UserError("Invalid filter: %s" % f) return True @@ -1359,13 +1363,11 @@ def should_include(service): return filter(should_include, services) -def build_filters(args): - filters = {} - for arg in args: +def build_filter(arg): + filt = {} + if arg is not None: if '=' not in arg: raise UserError("Arguments to --filter should be in form KEY=VAL") key, val = arg.split('=', 1) - if key not in filters: - filters[key] = [] - filters[key].append(val) - return filters + filt[key] = val + return filt diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index d15d5c5fe53..c6240419fec 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -473,12 +473,12 @@ def test_ps_services_filter_option(self): build = self.dispatch(['ps', '--services', '--filter', 'key=build']) all_services = self.dispatch(['ps', '--services']) - self.assertIn('with_build', all_services.stdout) - self.assertIn('with_image', all_services.stdout) - self.assertIn('with_build', build.stdout) - self.assertNotIn('with_build', image.stdout) - self.assertIn('with_image', image.stdout) - self.assertNotIn('with_image', build.stdout) + assert 'with_build' in all_services.stdout + assert 'with_image' in all_services.stdout + assert 'with_build' in build.stdout + assert 'with_build' not in image.stdout + assert 'with_image' in image.stdout + assert 'with_image' not in build.stdout def test_ps_services_filter_status(self): self.base_dir = 'tests/fixtures/ps-services-filter' @@ -486,15 +486,14 @@ def test_ps_services_filter_status(self): self.dispatch(['pause', 'with_image']) paused = self.dispatch(['ps', '--services', '--filter', 'status=paused']) stopped = self.dispatch(['ps', '--services', '--filter', 'status=stopped']) - running = self.dispatch(['ps', '--services', '--filter', 'status=running', - '--filter', 'key=build']) - - self.assertNotIn('with_build', stopped.stdout) - self.assertNotIn('with_image', stopped.stdout) - self.assertNotIn('with_build', paused.stdout) - self.assertIn('with_image', paused.stdout) - self.assertIn('with_build', running.stdout) - self.assertNotIn('with_image', running.stdout) + running = self.dispatch(['ps', '--services', '--filter', 'status=running']) + + assert 'with_build' not in stopped.stdout + assert 'with_image' not in stopped.stdout + assert 'with_build' not in paused.stdout + assert 'with_image' in paused.stdout + assert 'with_build' in running.stdout + assert 'with_image' in running.stdout def test_pull(self): result = self.dispatch(['pull']) From a1f0c3ed7cb590789aa848a466f85f78f912282d Mon Sep 17 00:00:00 2001 From: Svyatoslav Ilinskiy Date: Tue, 9 Jan 2018 10:57:06 -0600 Subject: [PATCH 3221/4072] Rename `ps --filter` option from `key` to `source`. Signed-off-by: Svyatoslav Ilinskiy --- compose/cli/main.py | 10 +++++----- contrib/completion/bash/docker-compose | 4 ++-- tests/acceptance/cli_test.py | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 3b6e25ccb90..224e6263af0 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -1349,13 +1349,13 @@ def should_include(service): containers = project.containers([service.name], stopped=True) if not has_container_with_state(containers, state): return False - elif f == 'key': - key = filt[f] - if key == 'image' or key == 'build': - if key not in service.options: + elif f == 'source': + source = filt[f] + if source == 'image' or source == 'build': + if source not in service.options: return False else: - raise UserError("Invalid value for key filter: %s" % key) + raise UserError("Invalid value for source filter: %s" % source) else: raise UserError("Invalid filter: %s" % f) return True diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index 248ff928573..bbc08e583a8 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -66,12 +66,12 @@ __docker_compose_services_all() { # All services that are defined by a Dockerfile reference __docker_compose_services_from_build() { - COMPREPLY=( $(compgen -W "$(__docker_compose_q ps --services --filter "key=build")" -- "$cur") ) + COMPREPLY=( $(compgen -W "$(__docker_compose_q ps --services --filter "source=build")" -- "$cur") ) } # All services that are defined by an image __docker_compose_services_from_image() { - COMPREPLY=( $(compgen -W "$(__docker_compose_q ps --services --filter "key=image")" -- "$cur") ) + COMPREPLY=( $(compgen -W "$(__docker_compose_q ps --services --filter "source=image")" -- "$cur") ) } # The services for which at least one paused container exists diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index c6240419fec..7622380a10e 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -469,8 +469,8 @@ def test_ps_alternate_composefile(self): def test_ps_services_filter_option(self): self.base_dir = 'tests/fixtures/ps-services-filter' - image = self.dispatch(['ps', '--services', '--filter', 'key=image']) - build = self.dispatch(['ps', '--services', '--filter', 'key=build']) + image = self.dispatch(['ps', '--services', '--filter', 'source=image']) + build = self.dispatch(['ps', '--services', '--filter', 'source=build']) all_services = self.dispatch(['ps', '--services']) assert 'with_build' in all_services.stdout From 39d535c1f5c47af9d753dc400478e052a3d45a46 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 9 Jan 2018 16:32:09 -0800 Subject: [PATCH 3222/4072] Fix unicode errors in interpolation / serialization Signed-off-by: Joffrey F --- compose/config/interpolation.py | 2 ++ compose/config/serialize.py | 6 +++++- tests/unit/config/config_test.py | 17 +++++++++++++++++ tests/unit/config/interpolation_test.py | 13 +++++++++++++ 4 files changed, 37 insertions(+), 1 deletion(-) diff --git a/compose/config/interpolation.py b/compose/config/interpolation.py index 0f263038305..5a5f349dbac 100644 --- a/compose/config/interpolation.py +++ b/compose/config/interpolation.py @@ -137,6 +137,8 @@ def convert(mo): if named is not None: val = mapping[named] + if isinstance(val, six.binary_type): + val = val.decode('utf-8') return '%s' % (val,) if mo.group('escaped') is not None: return self.delimiter diff --git a/compose/config/serialize.py b/compose/config/serialize.py index 3ab43fc5935..7fb9360a2e4 100644 --- a/compose/config/serialize.py +++ b/compose/config/serialize.py @@ -27,6 +27,9 @@ def serialize_string(dumper, data): """ Ensure boolean-like strings are quoted in the output and escape $ characters """ representer = dumper.represent_str if six.PY3 else dumper.represent_unicode + if isinstance(data, six.binary_type): + data = data.decode('utf-8') + data = data.replace('$', '$$') if data.lower() in ('y', 'n', 'yes', 'no', 'on', 'off', 'true', 'false'): @@ -90,7 +93,8 @@ def serialize_config(config, image_digests=None): denormalize_config(config, image_digests), default_flow_style=False, indent=2, - width=80 + width=80, + allow_unicode=True ) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 03626ad3637..cfbd4cf33b6 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -4750,3 +4750,20 @@ def test_serialize_escape_dollar_sign(self): assert serialized_service['environment']['CURRENCY'] == '$$' assert serialized_service['command'] == 'echo $$FOO' assert serialized_service['entrypoint'][0] == '$$SHELL' + + def test_serialize_unicode_values(self): + cfg = { + 'version': '2.3', + 'services': { + 'web': { + 'image': 'busybox', + 'command': 'echo 十六夜 咲夜' + } + } + } + + config_dict = config.load(build_config_details(cfg)) + + serialized_config = yaml.load(serialize_config(config_dict)) + serialized_service = serialized_config['services']['web'] + assert serialized_service['command'] == 'echo 十六夜 咲夜' diff --git a/tests/unit/config/interpolation_test.py b/tests/unit/config/interpolation_test.py index dfeba96d0a9..62f4ac774f1 100644 --- a/tests/unit/config/interpolation_test.py +++ b/tests/unit/config/interpolation_test.py @@ -1,3 +1,5 @@ +# encoding: utf-8 + from __future__ import absolute_import from __future__ import unicode_literals @@ -401,3 +403,14 @@ def test_interpolate_mixed_separators(defaults_interpolator): def test_unbraced_separators(defaults_interpolator): assert defaults_interpolator("ok $FOO:-bar") == "ok first:-bar" assert defaults_interpolator("ok $BAZ?error") == "ok ?error" + + +def test_interpolate_unicode_values(): + variable_mapping = { + 'FOO': '十六夜 咲夜'.encode('utf-8'), + 'BAR': '十六夜 咲夜' + } + interpol = Interpolator(TemplateWithDefaults, variable_mapping).interpolate + + interpol("$FOO") == '十六夜 咲夜' + interpol("${BAR}") == '十六夜 咲夜' From 397aa20dfcc7de7e140f82e0dc178dce9dcfe6f0 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 10 Jan 2018 12:16:59 -0800 Subject: [PATCH 3223/4072] Support legacy tmpfs mounts Signed-off-by: Joffrey F --- compose/config/types.py | 4 ++++ compose/service.py | 10 ++++++++-- tests/integration/service_test.py | 15 +++++++++++++++ 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/compose/config/types.py b/compose/config/types.py index daf25f7007c..5e1087858aa 100644 --- a/compose/config/types.py +++ b/compose/config/types.py @@ -183,6 +183,10 @@ def repr(self): def is_named_volume(self): return self.type == 'volume' and self.source + @property + def is_tmpfs(self): + return self.type == 'tmpfs' + @property def external(self): return self.source diff --git a/compose/service.py b/compose/service.py index cc08ec274ce..420eb3f0993 100644 --- a/compose/service.py +++ b/compose/service.py @@ -834,8 +834,14 @@ def _build_container_volume_options(self, previous_container, container_options, if version_gte(self.client.api_version, '1.30'): override_options['mounts'] = [build_mount(v) for v in container_mounts] or None else: - override_options['binds'].extend(m.legacy_repr() for m in container_mounts) - container_options['volumes'].update((m.target, {}) for m in container_mounts) + # Workaround for 3.2 format + self.options['tmpfs'] = self.options.get('tmpfs') or [] + for m in container_mounts: + if m.is_tmpfs: + self.options['tmpfs'].append(m.target) + else: + override_options['binds'].append(m.legacy_repr()) + container_options['volumes'][m.target] = {} secret_volumes = self.get_secret_volumes() if secret_volumes: diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 7238aa69fec..01be480051a 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -346,6 +346,21 @@ def test_create_container_with_legacy_mount(self): assert mount assert mount['Name'] == volume_name + @v3_only() + def test_create_container_with_legacy_tmpfs_mount(self): + # Ensure tmpfs mounts are converted to tmpfs entries if API version < 1.30 + # Needed to support long syntax in the 3.2 format + client = docker_client({}, version='1.25') + container_path = '/container-tmpfs' + service = Service('db', client=client, volumes=[ + MountSpec(type='tmpfs', target=container_path) + ], image='busybox:latest', command=['top'], project='composetest') + container = service.create_container() + service.start_container(container) + mount = container.get_mount(container_path) + assert mount is None + assert container_path in container.get('HostConfig.Tmpfs') + def test_create_container_with_healthcheck_config(self): one_second = parse_nanoseconds_int('1s') healthcheck = { From dfba255e52d97c21183b7eefc91c914341a79c21 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 10 Jan 2018 17:06:22 -0800 Subject: [PATCH 3224/4072] Remove outdated roadmap document Signed-off-by: Joffrey F --- ROADMAP.md | 32 -------------------------------- 1 file changed, 32 deletions(-) delete mode 100644 ROADMAP.md diff --git a/ROADMAP.md b/ROADMAP.md deleted file mode 100644 index c2184e56a2c..00000000000 --- a/ROADMAP.md +++ /dev/null @@ -1,32 +0,0 @@ -# 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 - -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, volume mount paths, or volume drivers. ([#1377](https://github.com/docker/compose/issues/1377)) -- Compose should recommend a technique for zero-downtime deploys. ([#1786](https://github.com/docker/compose/issues/1786)) -- It should be possible to continuously attempt to keep an application in the correct state, instead of just performing `up` a single time. - -## Integration with Swarm - -Compose should integrate really well with Swarm so you can take an application you've developed on your laptop and run it on a Swarm cluster. - -The current state of integration is documented in [SWARM.md](SWARM.md). - -## Applications spanning multiple teams - -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). From 2fbec60c9c94e7f20c221a5f0ceaf49dbe612c3f Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 10 Jan 2018 16:50:59 -0800 Subject: [PATCH 3225/4072] Add environment variable to force windows parsing style of volume paths Signed-off-by: Joffrey F --- compose/config/config.py | 5 +++-- compose/config/types.py | 19 ++++++++++++------- tests/unit/config/interpolation_test.py | 1 - 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 47ec177e34e..2a6e8437baf 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -815,11 +815,12 @@ def finalize_service_volumes(service_dict, environment): if 'volumes' in service_dict: finalized_volumes = [] normalize = environment.get_boolean('COMPOSE_CONVERT_WINDOWS_PATHS') + win_host = environment.get_boolean('COMPOSE_FORCE_WINDOWS_HOST') for v in service_dict['volumes']: if isinstance(v, dict): - finalized_volumes.append(MountSpec.parse(v, normalize)) + finalized_volumes.append(MountSpec.parse(v, normalize, win_host)) else: - finalized_volumes.append(VolumeSpec.parse(v, normalize)) + finalized_volumes.append(VolumeSpec.parse(v, normalize, win_host)) service_dict['volumes'] = finalized_volumes return service_dict diff --git a/compose/config/types.py b/compose/config/types.py index daf25f7007c..8e0a705d88a 100644 --- a/compose/config/types.py +++ b/compose/config/types.py @@ -4,6 +4,7 @@ from __future__ import absolute_import from __future__ import unicode_literals +import ntpath import os import re from collections import namedtuple @@ -145,9 +146,10 @@ class MountSpec(object): _fields = ['type', 'source', 'target', 'read_only', 'consistency'] @classmethod - def parse(cls, mount_dict, normalize=False): + def parse(cls, mount_dict, normalize=False, win_host=False): + normpath = ntpath.normpath if win_host else os.path.normpath if mount_dict.get('source'): - mount_dict['source'] = os.path.normpath(mount_dict['source']) + mount_dict['source'] = normpath(mount_dict['source']) if normalize: mount_dict['source'] = normalize_path_for_engine(mount_dict['source']) @@ -189,6 +191,7 @@ def external(self): class VolumeSpec(namedtuple('_VolumeSpec', 'external internal mode')): + win32 = False @classmethod def _parse_unix(cls, volume_config): @@ -232,7 +235,7 @@ def separate_next_section(volume_config): else: external = parts[0] parts = separate_next_section(parts[1]) - external = os.path.normpath(external) + external = ntpath.normpath(external) internal = parts[0] if len(parts) > 1: if ':' in parts[1]: @@ -245,14 +248,16 @@ def separate_next_section(volume_config): if normalize: external = normalize_path_for_engine(external) if external else None - return cls(external, internal, mode) + result = cls(external, internal, mode) + result.win32 = True + return result @classmethod - def parse(cls, volume_config, normalize=False): + def parse(cls, volume_config, normalize=False, win_host=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: + if IS_WINDOWS_PLATFORM or win_host: return cls._parse_win32(volume_config, normalize) else: return cls._parse_unix(volume_config) @@ -265,7 +270,7 @@ def repr(self): @property def is_named_volume(self): res = self.external and not self.external.startswith(('.', '/', '~')) - if not IS_WINDOWS_PLATFORM: + if not self.win32: return res return ( diff --git a/tests/unit/config/interpolation_test.py b/tests/unit/config/interpolation_test.py index 62f4ac774f1..d68e4e559b2 100644 --- a/tests/unit/config/interpolation_test.py +++ b/tests/unit/config/interpolation_test.py @@ -1,5 +1,4 @@ # encoding: utf-8 - from __future__ import absolute_import from __future__ import unicode_literals From e174c3fd1c10222183afc5822e47aeca4e1c466c Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 11 Jan 2018 12:24:38 -0800 Subject: [PATCH 3226/4072] Revert -d/--timeout exclusion Signed-off-by: Joffrey F --- compose/cli/main.py | 28 +++++++++++-------------- tests/acceptance/cli_test.py | 9 +++++--- tests/unit/config/interpolation_test.py | 1 - 3 files changed, 18 insertions(+), 20 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index b393d43e080..78df6b89a83 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -926,30 +926,29 @@ def up(self, options): Options: -d Detached mode: Run containers in the background, print new container names. Incompatible with - --abort-on-container-exit and --timeout. + --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. --always-recreate-deps Recreate dependent containers. Incompatible with --no-recreate. - --no-recreate If containers already exist, don't recreate them. - Incompatible with --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. --no-start Don't start the services after creating them. --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. - Incompatible with -d. - running. (default: 10) + --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 - --exit-code-from SERVICE Return the exit code of the selected service container. - Implies --abort-on-container-exit. - --scale SERVICE=NUM Scale SERVICE to NUM instances. Overrides the `scale` - setting in the Compose file if present. + --exit-code-from SERVICE Return the exit code of the selected service + container. Implies --abort-on-container-exit. + --scale SERVICE=NUM Scale SERVICE to NUM instances. Overrides the + `scale` setting in the Compose file if present. """ start_deps = not options['--no-deps'] always_recreate_deps = options['--always-recreate-deps'] @@ -964,9 +963,6 @@ def up(self, options): if detached and (cascade_stop or exit_value_from): raise UserError("--abort-on-container-exit and -d cannot be combined.") - if detached and timeout: - raise UserError("-d and --timeout cannot be combined.") - environment = Environment.from_env_file(self.project_dir) ignore_orphans = environment.get_boolean('COMPOSE_IGNORE_ORPHANS') diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index cb583b3433c..fa051fecb39 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1361,9 +1361,12 @@ def test_up_with_force_recreate_and_no_recreate(self): ['up', '-d', '--force-recreate', '--no-recreate'], returncode=1) - def test_up_with_timeout_detached(self): - result = self.dispatch(['up', '-d', '-t', '1'], returncode=1) - assert "-d and --timeout cannot be combined." in result.stderr + def test_up_with_timeout(self): + self.dispatch(['up', '-d', '-t', '1']) + service = self.project.get_service('simple') + another = self.project.get_service('another') + assert len(service.containers()) == 1 + assert len(another.containers()) == 1 @mock.patch.dict(os.environ) def test_up_with_ignore_remove_orphans(self): diff --git a/tests/unit/config/interpolation_test.py b/tests/unit/config/interpolation_test.py index 62f4ac774f1..d68e4e559b2 100644 --- a/tests/unit/config/interpolation_test.py +++ b/tests/unit/config/interpolation_test.py @@ -1,5 +1,4 @@ # encoding: utf-8 - from __future__ import absolute_import from __future__ import unicode_literals From f50e1a8c2d67e7aea7561c3122124eed019dd9d4 Mon Sep 17 00:00:00 2001 From: Zal Daroga Date: Mon, 11 Dec 2017 16:43:30 -0600 Subject: [PATCH 3227/4072] Added prioritization of networks Signed-off-by: Zal Daroga --- compose/config/config_schema_v2.0.json | 3 ++- compose/config/config_schema_v2.1.json | 3 ++- compose/config/config_schema_v2.2.json | 3 ++- compose/config/config_schema_v2.3.json | 3 ++- compose/config/config_schema_v3.0.json | 3 ++- compose/config/config_schema_v3.1.json | 3 ++- compose/config/config_schema_v3.2.json | 3 ++- compose/config/config_schema_v3.3.json | 3 ++- compose/config/config_schema_v3.4.json | 3 ++- compose/config/config_schema_v3.5.json | 3 ++- compose/network.py | 4 +++- compose/service.py | 8 +++++++- tests/acceptance/cli_test.py | 12 ++++++++++++ tests/fixtures/networks/ordered-networks.yml | 13 +++++++++++++ 14 files changed, 55 insertions(+), 12 deletions(-) create mode 100644 tests/fixtures/networks/ordered-networks.yml diff --git a/compose/config/config_schema_v2.0.json b/compose/config/config_schema_v2.0.json index 2ad62ac5221..7f4e1059236 100644 --- a/compose/config/config_schema_v2.0.json +++ b/compose/config/config_schema_v2.0.json @@ -299,7 +299,8 @@ }, "additionalProperties": false }, - "internal": {"type": "boolean"} + "internal": {"type": "boolean"}, + "priority": {"type": "number"} }, "additionalProperties": false }, diff --git a/compose/config/config_schema_v2.1.json b/compose/config/config_schema_v2.1.json index 15b78e5db79..14fc89a1df2 100644 --- a/compose/config/config_schema_v2.1.json +++ b/compose/config/config_schema_v2.1.json @@ -351,7 +351,8 @@ "internal": {"type": "boolean"}, "enable_ipv6": {"type": "boolean"}, "labels": {"$ref": "#/definitions/list_or_dict"}, - "name": {"type": "string"} + "name": {"type": "string"}, + "priority": {"type": "number"} }, "additionalProperties": false }, diff --git a/compose/config/config_schema_v2.2.json b/compose/config/config_schema_v2.2.json index 7a3eed0a9f7..3fbc4aeda34 100644 --- a/compose/config/config_schema_v2.2.json +++ b/compose/config/config_schema_v2.2.json @@ -358,7 +358,8 @@ "internal": {"type": "boolean"}, "enable_ipv6": {"type": "boolean"}, "labels": {"$ref": "#/definitions/list_or_dict"}, - "name": {"type": "string"} + "name": {"type": "string"}, + "priority": {"type": "number"} }, "additionalProperties": false }, diff --git a/compose/config/config_schema_v2.3.json b/compose/config/config_schema_v2.3.json index 3a6397a7e56..ab8eeafe917 100644 --- a/compose/config/config_schema_v2.3.json +++ b/compose/config/config_schema_v2.3.json @@ -395,7 +395,8 @@ "internal": {"type": "boolean"}, "enable_ipv6": {"type": "boolean"}, "labels": {"$ref": "#/definitions/list_or_dict"}, - "name": {"type": "string"} + "name": {"type": "string"}, + "priority": {"type": "number"} }, "additionalProperties": false }, diff --git a/compose/config/config_schema_v3.0.json b/compose/config/config_schema_v3.0.json index fa601bed28b..2b030812346 100644 --- a/compose/config/config_schema_v3.0.json +++ b/compose/config/config_schema_v3.0.json @@ -310,7 +310,8 @@ "additionalProperties": false }, "internal": {"type": "boolean"}, - "labels": {"$ref": "#/definitions/list_or_dict"} + "labels": {"$ref": "#/definitions/list_or_dict"}, + "priority": {"type": "number"} }, "additionalProperties": false }, diff --git a/compose/config/config_schema_v3.1.json b/compose/config/config_schema_v3.1.json index 41da89650aa..1fc5c6ead85 100644 --- a/compose/config/config_schema_v3.1.json +++ b/compose/config/config_schema_v3.1.json @@ -339,7 +339,8 @@ "additionalProperties": false }, "internal": {"type": "boolean"}, - "labels": {"$ref": "#/definitions/list_or_dict"} + "labels": {"$ref": "#/definitions/list_or_dict"}, + "priority": {"type": "number"} }, "additionalProperties": false }, diff --git a/compose/config/config_schema_v3.2.json b/compose/config/config_schema_v3.2.json index 0baf6a1a96a..c2d1ec7c8a4 100644 --- a/compose/config/config_schema_v3.2.json +++ b/compose/config/config_schema_v3.2.json @@ -387,7 +387,8 @@ }, "internal": {"type": "boolean"}, "attachable": {"type": "boolean"}, - "labels": {"$ref": "#/definitions/list_or_dict"} + "labels": {"$ref": "#/definitions/list_or_dict"}, + "priority": {"type": "number"} }, "additionalProperties": false }, diff --git a/compose/config/config_schema_v3.3.json b/compose/config/config_schema_v3.3.json index efc0fdbd74c..cdc8cbf7747 100644 --- a/compose/config/config_schema_v3.3.json +++ b/compose/config/config_schema_v3.3.json @@ -430,7 +430,8 @@ }, "internal": {"type": "boolean"}, "attachable": {"type": "boolean"}, - "labels": {"$ref": "#/definitions/list_or_dict"} + "labels": {"$ref": "#/definitions/list_or_dict"}, + "priority": {"type": "number"} }, "additionalProperties": false }, diff --git a/compose/config/config_schema_v3.4.json b/compose/config/config_schema_v3.4.json index 576ecfd8424..0d41fd148a2 100644 --- a/compose/config/config_schema_v3.4.json +++ b/compose/config/config_schema_v3.4.json @@ -438,7 +438,8 @@ }, "internal": {"type": "boolean"}, "attachable": {"type": "boolean"}, - "labels": {"$ref": "#/definitions/list_or_dict"} + "labels": {"$ref": "#/definitions/list_or_dict"}, + "priority": {"type": "number"} }, "additionalProperties": false }, diff --git a/compose/config/config_schema_v3.5.json b/compose/config/config_schema_v3.5.json index 565da019375..c09dc7ea605 100644 --- a/compose/config/config_schema_v3.5.json +++ b/compose/config/config_schema_v3.5.json @@ -464,7 +464,8 @@ }, "internal": {"type": "boolean"}, "attachable": {"type": "boolean"}, - "labels": {"$ref": "#/definitions/list_or_dict"} + "labels": {"$ref": "#/definitions/list_or_dict"}, + "priority": {"type": "number"} }, "additionalProperties": false }, diff --git a/compose/network.py b/compose/network.py index 95e2bf60e5f..3bc28a99cb8 100644 --- a/compose/network.py +++ b/compose/network.py @@ -26,7 +26,7 @@ class Network(object): def __init__(self, client, project, name, driver=None, driver_opts=None, ipam=None, external=False, internal=False, enable_ipv6=False, - labels=None, custom_name=False): + labels=None, custom_name=False, priority=0): self.client = client self.project = project self.name = name @@ -38,6 +38,7 @@ def __init__(self, client, project, name, driver=None, driver_opts=None, self.enable_ipv6 = enable_ipv6 self.labels = labels self.custom_name = custom_name + self.priority = priority def ensure(self): if self.external: @@ -214,6 +215,7 @@ def build_networks(name, config_data, client): enable_ipv6=data.get('enable_ipv6'), labels=data.get('labels'), custom_name=data.get('name') is not None, + priority=data.get('priority'), ) for network_name, data in network_config.items() } diff --git a/compose/service.py b/compose/service.py index 420eb3f0993..f6a9d12a860 100644 --- a/compose/service.py +++ b/compose/service.py @@ -6,6 +6,7 @@ import re import sys from collections import namedtuple +from collections import OrderedDict from operator import attrgetter import enum @@ -557,10 +558,15 @@ def start_container(self, container): raise OperationFailedError("Cannot start service %s: %s" % (self.name, ex.explanation)) return container + def prioritized_networks(self): + prioritized_networks = OrderedDict( + sorted(self.networks.items(), key=lambda t: t[1].get('priority', 0), reverse=True)) + return prioritized_networks + def connect_container_to_networks(self, container): connected_networks = container.get('NetworkSettings.Networks') - for network, netdefs in self.networks.items(): + for network, netdefs in self.prioritized_networks().items(): if network in connected_networks: if short_id_alias_exists(container, network): continue diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index fa051fecb39..87599f351b6 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1274,6 +1274,18 @@ def test_up_with_net_v1(self): bar_container.id ) + def test_up_ordered_networks(self): + self.base_dir = 'tests/fixtures/networks' + + self.dispatch(['-f', 'ordered-networks.yml', 'up', '-d']) + + containers = self.project.get_service('web').containers() + + for container in containers: + networks = container.get('NetworkSettings.Networks') + assert networks.keys()[0] == "networks_bar" + assert networks.keys()[1] == "networks_foo" + @v3_only() def test_up_with_healthcheck(self): def wait_on_health_status(container, status): diff --git a/tests/fixtures/networks/ordered-networks.yml b/tests/fixtures/networks/ordered-networks.yml new file mode 100644 index 00000000000..598148f6df0 --- /dev/null +++ b/tests/fixtures/networks/ordered-networks.yml @@ -0,0 +1,13 @@ +version: "2" + +services: + web: + image: busybox + command: top + networks: ["foo", "bar"] + +networks: + foo: + priority: 1 + bar: + priority: 2 From 55ee95b6d038c135b9a36f322ceed0669f19a33b Mon Sep 17 00:00:00 2001 From: Zal Daroga Date: Tue, 12 Dec 2017 14:22:29 -0600 Subject: [PATCH 3228/4072] Fixed network priorities. Resolves #5042 Signed-off-by: Zal Daroga --- compose/network.py | 1 + compose/service.py | 1 + tests/acceptance/cli_test.py | 12 +++--------- tests/fixtures/networks/ordered-networks.yml | 2 +- 4 files changed, 6 insertions(+), 10 deletions(-) diff --git a/compose/network.py b/compose/network.py index 3bc28a99cb8..ba388cc2cbb 100644 --- a/compose/network.py +++ b/compose/network.py @@ -282,6 +282,7 @@ def get_networks(service_dict, network_definitions): for name, netdef in get_network_defs_for_service(service_dict).items(): network = network_definitions.get(name) if network: + netdef['priority'] = network.priority networks[network.full_name] = netdef else: raise ConfigurationError( diff --git a/compose/service.py b/compose/service.py index f6a9d12a860..131e8b85870 100644 --- a/compose/service.py +++ b/compose/service.py @@ -575,6 +575,7 @@ def connect_container_to_networks(self, container): container.id, network) + print('Connecting to {}'.format(network)) self.client.connect_container_to_network( container.id, network, aliases=self._get_aliases(netdefs, container), diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 87599f351b6..47913e212c5 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1276,15 +1276,9 @@ def test_up_with_net_v1(self): def test_up_ordered_networks(self): self.base_dir = 'tests/fixtures/networks' + result = self.dispatch(['-f', 'ordered-networks.yml', 'up', '-d']) - self.dispatch(['-f', 'ordered-networks.yml', 'up', '-d']) - - containers = self.project.get_service('web').containers() - - for container in containers: - networks = container.get('NetworkSettings.Networks') - assert networks.keys()[0] == "networks_bar" - assert networks.keys()[1] == "networks_foo" + assert 'Connecting to networks_bar\nConnecting to networks_foo' in result.stdout @v3_only() def test_up_with_healthcheck(self): @@ -1899,7 +1893,7 @@ def test_run_unicode_env_values_from_system(self): result = self.dispatch(['run', 'simple']) if six.PY2: # Can't retrieve output on Py3. See issue #3670 - assert value == result.stdout.strip() + assert value in result.stdout.strip() container = self.project.containers(one_off=OneOffFilter.only, stopped=True)[0] environment = container.get('Config.Env') diff --git a/tests/fixtures/networks/ordered-networks.yml b/tests/fixtures/networks/ordered-networks.yml index 598148f6df0..af243286985 100644 --- a/tests/fixtures/networks/ordered-networks.yml +++ b/tests/fixtures/networks/ordered-networks.yml @@ -1,4 +1,4 @@ -version: "2" +version: "2.3" services: web: From 4839baf877c87cb652acfa72f35a61550c789494 Mon Sep 17 00:00:00 2001 From: Zal Daroga Date: Tue, 12 Dec 2017 16:17:29 -0600 Subject: [PATCH 3229/4072] Fixed python34 comparison issue Signed-off-by: Zal Daroga --- compose/service.py | 2 +- tests/acceptance/cli_test.py | 3 ++- tests/fixtures/networks/ordered-networks.yml | 8 +++++--- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/compose/service.py b/compose/service.py index 131e8b85870..0b20f00b9d9 100644 --- a/compose/service.py +++ b/compose/service.py @@ -560,7 +560,7 @@ def start_container(self, container): def prioritized_networks(self): prioritized_networks = OrderedDict( - sorted(self.networks.items(), key=lambda t: t[1].get('priority', 0), reverse=True)) + sorted(self.networks.items(), key=lambda t: t[1].get('priority', 0) or 0, reverse=True)) return prioritized_networks def connect_container_to_networks(self, container): diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 47913e212c5..26d336b65df 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1278,7 +1278,8 @@ def test_up_ordered_networks(self): self.base_dir = 'tests/fixtures/networks' result = self.dispatch(['-f', 'ordered-networks.yml', 'up', '-d']) - assert 'Connecting to networks_bar\nConnecting to networks_foo' in result.stdout + assert 'Connecting to networks_buzz\nConnecting to networks_foo' \ + '\nConnecting to networks_bar' in result.stdout @v3_only() def test_up_with_healthcheck(self): diff --git a/tests/fixtures/networks/ordered-networks.yml b/tests/fixtures/networks/ordered-networks.yml index af243286985..afb02930f00 100644 --- a/tests/fixtures/networks/ordered-networks.yml +++ b/tests/fixtures/networks/ordered-networks.yml @@ -4,10 +4,12 @@ services: web: image: busybox command: top - networks: ["foo", "bar"] + networks: ["foo", "bar", "buzz"] networks: foo: - priority: 1 + priority: 2 bar: - priority: 2 + priority: 1 + buzz: + priority: 3 From d63500a19155580f64f53fcaa4e16c92de07afa1 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 11 Jan 2018 18:48:56 -0800 Subject: [PATCH 3230/4072] Move priority option to service network reference Add ordering to networks.get_networks Fix priority test 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_v2.2.json | 6 +- compose/config/config_schema_v2.3.json | 6 +- compose/config/config_schema_v3.0.json | 3 +- compose/config/config_schema_v3.1.json | 3 +- compose/config/config_schema_v3.2.json | 3 +- compose/config/config_schema_v3.3.json | 3 +- compose/config/config_schema_v3.4.json | 3 +- compose/config/config_schema_v3.5.json | 3 +- compose/network.py | 11 ++-- compose/service.py | 19 +++--- tests/acceptance/cli_test.py | 7 --- tests/fixtures/networks/ordered-networks.yml | 15 ----- tests/integration/project_test.py | 65 ++++++++++++++++++++ 15 files changed, 99 insertions(+), 60 deletions(-) delete mode 100644 tests/fixtures/networks/ordered-networks.yml diff --git a/compose/config/config_schema_v2.0.json b/compose/config/config_schema_v2.0.json index 7f4e1059236..8ab451a62b3 100644 --- a/compose/config/config_schema_v2.0.json +++ b/compose/config/config_schema_v2.0.json @@ -191,7 +191,8 @@ "properties": { "aliases": {"$ref": "#/definitions/list_of_strings"}, "ipv4_address": {"type": "string"}, - "ipv6_address": {"type": "string"} + "ipv6_address": {"type": "string"}, + "priority": {"type": "number"} }, "additionalProperties": false }, @@ -299,8 +300,7 @@ }, "additionalProperties": false }, - "internal": {"type": "boolean"}, - "priority": {"type": "number"} + "internal": {"type": "boolean"} }, "additionalProperties": false }, diff --git a/compose/config/config_schema_v2.1.json b/compose/config/config_schema_v2.1.json index 14fc89a1df2..f2ee2ce402f 100644 --- a/compose/config/config_schema_v2.1.json +++ b/compose/config/config_schema_v2.1.json @@ -217,7 +217,8 @@ "aliases": {"$ref": "#/definitions/list_of_strings"}, "ipv4_address": {"type": "string"}, "ipv6_address": {"type": "string"}, - "link_local_ips": {"$ref": "#/definitions/list_of_strings"} + "link_local_ips": {"$ref": "#/definitions/list_of_strings"}, + "priority": {"type": "number"} }, "additionalProperties": false }, @@ -351,8 +352,7 @@ "internal": {"type": "boolean"}, "enable_ipv6": {"type": "boolean"}, "labels": {"$ref": "#/definitions/list_or_dict"}, - "name": {"type": "string"}, - "priority": {"type": "number"} + "name": {"type": "string"} }, "additionalProperties": false }, diff --git a/compose/config/config_schema_v2.2.json b/compose/config/config_schema_v2.2.json index 3fbc4aeda34..b25aa71e216 100644 --- a/compose/config/config_schema_v2.2.json +++ b/compose/config/config_schema_v2.2.json @@ -223,7 +223,8 @@ "aliases": {"$ref": "#/definitions/list_of_strings"}, "ipv4_address": {"type": "string"}, "ipv6_address": {"type": "string"}, - "link_local_ips": {"$ref": "#/definitions/list_of_strings"} + "link_local_ips": {"$ref": "#/definitions/list_of_strings"}, + "priority": {"type": "number"} }, "additionalProperties": false }, @@ -358,8 +359,7 @@ "internal": {"type": "boolean"}, "enable_ipv6": {"type": "boolean"}, "labels": {"$ref": "#/definitions/list_or_dict"}, - "name": {"type": "string"}, - "priority": {"type": "number"} + "name": {"type": "string"} }, "additionalProperties": false }, diff --git a/compose/config/config_schema_v2.3.json b/compose/config/config_schema_v2.3.json index ab8eeafe917..30f00dfe4f3 100644 --- a/compose/config/config_schema_v2.3.json +++ b/compose/config/config_schema_v2.3.json @@ -226,7 +226,8 @@ "aliases": {"$ref": "#/definitions/list_of_strings"}, "ipv4_address": {"type": "string"}, "ipv6_address": {"type": "string"}, - "link_local_ips": {"$ref": "#/definitions/list_of_strings"} + "link_local_ips": {"$ref": "#/definitions/list_of_strings"}, + "priority": {"type": "number"} }, "additionalProperties": false }, @@ -395,8 +396,7 @@ "internal": {"type": "boolean"}, "enable_ipv6": {"type": "boolean"}, "labels": {"$ref": "#/definitions/list_or_dict"}, - "name": {"type": "string"}, - "priority": {"type": "number"} + "name": {"type": "string"} }, "additionalProperties": false }, diff --git a/compose/config/config_schema_v3.0.json b/compose/config/config_schema_v3.0.json index 2b030812346..fa601bed28b 100644 --- a/compose/config/config_schema_v3.0.json +++ b/compose/config/config_schema_v3.0.json @@ -310,8 +310,7 @@ "additionalProperties": false }, "internal": {"type": "boolean"}, - "labels": {"$ref": "#/definitions/list_or_dict"}, - "priority": {"type": "number"} + "labels": {"$ref": "#/definitions/list_or_dict"} }, "additionalProperties": false }, diff --git a/compose/config/config_schema_v3.1.json b/compose/config/config_schema_v3.1.json index 1fc5c6ead85..41da89650aa 100644 --- a/compose/config/config_schema_v3.1.json +++ b/compose/config/config_schema_v3.1.json @@ -339,8 +339,7 @@ "additionalProperties": false }, "internal": {"type": "boolean"}, - "labels": {"$ref": "#/definitions/list_or_dict"}, - "priority": {"type": "number"} + "labels": {"$ref": "#/definitions/list_or_dict"} }, "additionalProperties": false }, diff --git a/compose/config/config_schema_v3.2.json b/compose/config/config_schema_v3.2.json index c2d1ec7c8a4..0baf6a1a96a 100644 --- a/compose/config/config_schema_v3.2.json +++ b/compose/config/config_schema_v3.2.json @@ -387,8 +387,7 @@ }, "internal": {"type": "boolean"}, "attachable": {"type": "boolean"}, - "labels": {"$ref": "#/definitions/list_or_dict"}, - "priority": {"type": "number"} + "labels": {"$ref": "#/definitions/list_or_dict"} }, "additionalProperties": false }, diff --git a/compose/config/config_schema_v3.3.json b/compose/config/config_schema_v3.3.json index cdc8cbf7747..efc0fdbd74c 100644 --- a/compose/config/config_schema_v3.3.json +++ b/compose/config/config_schema_v3.3.json @@ -430,8 +430,7 @@ }, "internal": {"type": "boolean"}, "attachable": {"type": "boolean"}, - "labels": {"$ref": "#/definitions/list_or_dict"}, - "priority": {"type": "number"} + "labels": {"$ref": "#/definitions/list_or_dict"} }, "additionalProperties": false }, diff --git a/compose/config/config_schema_v3.4.json b/compose/config/config_schema_v3.4.json index 0d41fd148a2..576ecfd8424 100644 --- a/compose/config/config_schema_v3.4.json +++ b/compose/config/config_schema_v3.4.json @@ -438,8 +438,7 @@ }, "internal": {"type": "boolean"}, "attachable": {"type": "boolean"}, - "labels": {"$ref": "#/definitions/list_or_dict"}, - "priority": {"type": "number"} + "labels": {"$ref": "#/definitions/list_or_dict"} }, "additionalProperties": false }, diff --git a/compose/config/config_schema_v3.5.json b/compose/config/config_schema_v3.5.json index c09dc7ea605..565da019375 100644 --- a/compose/config/config_schema_v3.5.json +++ b/compose/config/config_schema_v3.5.json @@ -464,8 +464,7 @@ }, "internal": {"type": "boolean"}, "attachable": {"type": "boolean"}, - "labels": {"$ref": "#/definitions/list_or_dict"}, - "priority": {"type": "number"} + "labels": {"$ref": "#/definitions/list_or_dict"} }, "additionalProperties": false }, diff --git a/compose/network.py b/compose/network.py index ba388cc2cbb..027e7d5a53c 100644 --- a/compose/network.py +++ b/compose/network.py @@ -2,6 +2,7 @@ from __future__ import unicode_literals import logging +from collections import OrderedDict from docker.errors import NotFound from docker.types import IPAMConfig @@ -26,7 +27,7 @@ class Network(object): def __init__(self, client, project, name, driver=None, driver_opts=None, ipam=None, external=False, internal=False, enable_ipv6=False, - labels=None, custom_name=False, priority=0): + labels=None, custom_name=False): self.client = client self.project = project self.name = name @@ -38,7 +39,6 @@ def __init__(self, client, project, name, driver=None, driver_opts=None, self.enable_ipv6 = enable_ipv6 self.labels = labels self.custom_name = custom_name - self.priority = priority def ensure(self): if self.external: @@ -215,7 +215,6 @@ def build_networks(name, config_data, client): enable_ipv6=data.get('enable_ipv6'), labels=data.get('labels'), custom_name=data.get('name') is not None, - priority=data.get('priority'), ) for network_name, data in network_config.items() } @@ -282,11 +281,13 @@ def get_networks(service_dict, network_definitions): for name, netdef in get_network_defs_for_service(service_dict).items(): network = network_definitions.get(name) if network: - netdef['priority'] = network.priority networks[network.full_name] = netdef else: raise ConfigurationError( 'Service "{}" uses an undefined network "{}"' .format(service_dict['name'], name)) - return networks + return OrderedDict(sorted( + networks.items(), + key=lambda t: t[1].get('priority') or 0, reverse=True + )) diff --git a/compose/service.py b/compose/service.py index 0b20f00b9d9..8a2faba9516 100644 --- a/compose/service.py +++ b/compose/service.py @@ -558,24 +558,25 @@ def start_container(self, container): raise OperationFailedError("Cannot start service %s: %s" % (self.name, ex.explanation)) return container + @property def prioritized_networks(self): - prioritized_networks = OrderedDict( - sorted(self.networks.items(), key=lambda t: t[1].get('priority', 0) or 0, reverse=True)) - return prioritized_networks + return OrderedDict( + sorted( + self.networks.items(), + key=lambda t: t[1].get('priority') or 0, reverse=True + ) + ) def connect_container_to_networks(self, container): connected_networks = container.get('NetworkSettings.Networks') - for network, netdefs in self.prioritized_networks().items(): + for network, netdefs in self.prioritized_networks.items(): if network in connected_networks: if short_id_alias_exists(container, network): continue + self.client.disconnect_container_from_network(container.id, network) - self.client.disconnect_container_from_network( - container.id, - network) - - print('Connecting to {}'.format(network)) + log.debug('Connecting to {}'.format(network)) self.client.connect_container_to_network( container.id, network, aliases=self._get_aliases(netdefs, container), diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 26d336b65df..ade7d10a9d3 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1274,13 +1274,6 @@ def test_up_with_net_v1(self): bar_container.id ) - def test_up_ordered_networks(self): - self.base_dir = 'tests/fixtures/networks' - result = self.dispatch(['-f', 'ordered-networks.yml', 'up', '-d']) - - assert 'Connecting to networks_buzz\nConnecting to networks_foo' \ - '\nConnecting to networks_bar' in result.stdout - @v3_only() def test_up_with_healthcheck(self): def wait_on_health_status(container, status): diff --git a/tests/fixtures/networks/ordered-networks.yml b/tests/fixtures/networks/ordered-networks.yml deleted file mode 100644 index afb02930f00..00000000000 --- a/tests/fixtures/networks/ordered-networks.yml +++ /dev/null @@ -1,15 +0,0 @@ -version: "2.3" - -services: - web: - image: busybox - command: top - networks: ["foo", "bar", "buzz"] - -networks: - foo: - priority: 2 - bar: - priority: 1 - buzz: - priority: 3 diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index a9ca3be6119..b0e55f2d06f 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -829,6 +829,71 @@ def test_up_with_network_static_addresses(self): assert ipam_config.get('IPv4Address') == '172.16.100.100' assert ipam_config.get('IPv6Address') == 'fe80::1001:102' + @v2_3_only() + def test_up_with_network_priorities(self): + mac_address = '74:6f:75:68:6f:75' + + def get_config_data(p1, p2, p3): + return build_config( + version=V2_3, + services=[{ + 'name': 'web', + 'image': 'busybox:latest', + 'networks': { + 'n1': { + 'priority': p1, + }, + 'n2': { + 'priority': p2, + }, + 'n3': { + 'priority': p3, + } + }, + 'command': 'top', + 'mac_address': mac_address + }], + networks={ + 'n1': {}, + 'n2': {}, + 'n3': {} + } + ) + + config1 = get_config_data(1000, 1, 1) + config2 = get_config_data(2, 3, 1) + config3 = get_config_data(5, 40, 100) + + project = Project.from_config( + client=self.client, + name='composetest', + config_data=config1 + ) + project.up(detached=True) + service_container = project.get_service('web').containers()[0] + net_config = service_container.inspect()['NetworkSettings']['Networks']['composetest_n1'] + assert net_config['MacAddress'] == mac_address + + project = Project.from_config( + client=self.client, + name='composetest', + config_data=config2 + ) + project.up(detached=True) + service_container = project.get_service('web').containers()[0] + net_config = service_container.inspect()['NetworkSettings']['Networks']['composetest_n2'] + assert net_config['MacAddress'] == mac_address + + project = Project.from_config( + client=self.client, + name='composetest', + config_data=config3 + ) + project.up(detached=True) + service_container = project.get_service('web').containers()[0] + net_config = service_container.inspect()['NetworkSettings']['Networks']['composetest_n3'] + assert net_config['MacAddress'] == mac_address + @v2_1_only() def test_up_with_enable_ipv6(self): self.require_api_version('1.23') From a7ed1dcbf6bb0fc1112fdafc4d1741b4baef63ce Mon Sep 17 00:00:00 2001 From: Thomas Date: Sat, 13 Jan 2018 06:48:16 +1100 Subject: [PATCH 3231/4072] Fix unicode error - python2 If the name of a container doesn't exist and its name isn't utf-8 encoded you receive a stacktrace Signed-off-by: Thomas --- compose/project.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/project.py b/compose/project.py index a507eab9636..1f5ae574d95 100644 --- a/compose/project.py +++ b/compose/project.py @@ -703,7 +703,7 @@ def warn_for_swarm_mode(client): class NoSuchService(Exception): def __init__(self, name): - self.name = name + self.name = name.decode('utf8') self.msg = "No such service: %s" % self.name def __str__(self): From 97da2cc6bb91e81cd4d620884d0f0a8006ed9b8f Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 12 Jan 2018 13:57:40 -0800 Subject: [PATCH 3232/4072] Add test for NoSuchService with unicode characters Signed-off-by: Joffrey F --- compose/project.py | 5 ++++- tests/unit/project_test.py | 6 ++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/compose/project.py b/compose/project.py index 1f5ae574d95..8e1c6a14d71 100644 --- a/compose/project.py +++ b/compose/project.py @@ -7,6 +7,7 @@ from functools import reduce import enum +import six from docker.errors import APIError from . import parallel @@ -703,7 +704,9 @@ def warn_for_swarm_mode(client): class NoSuchService(Exception): def __init__(self, name): - self.name = name.decode('utf8') + if isinstance(name, six.binary_type): + name = name.decode('utf-8') + self.name = name self.msg = "No such service: %s" % self.name def __str__(self): diff --git a/tests/unit/project_test.py b/tests/unit/project_test.py index 1313fbe35ea..397287ad926 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -1,3 +1,4 @@ +# encoding: utf-8 from __future__ import absolute_import from __future__ import unicode_literals @@ -14,6 +15,7 @@ from compose.const import COMPOSEFILE_V2_0 as V2_0 from compose.const import LABEL_SERVICE from compose.container import Container +from compose.project import NoSuchService from compose.project import Project from compose.service import ImageType from compose.service import Service @@ -562,3 +564,7 @@ def test_no_warning_with_no_swarm_info(self): with mock.patch('compose.project.log') as fake_log: project.up() assert fake_log.warn.call_count == 0 + + def test_no_such_service_unicode(self): + assert NoSuchService('十六夜 咲夜'.encode('utf-8')).msg == 'No such service: 十六夜 咲夜' + assert NoSuchService('十六夜 咲夜').msg == 'No such service: 十六夜 咲夜' From ab173472b4da679018935017cdf4abd7b760d4a1 Mon Sep 17 00:00:00 2001 From: Tom Van Rompaey Date: Mon, 15 Jan 2018 17:15:19 +0800 Subject: [PATCH 3233/4072] Fix small typo: bulid -> build Signed-off-by: Tom Van Rompaey --- 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 78df6b89a83..7b8fbcdaf2d 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -233,7 +233,7 @@ def build(self, 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. - -m, --memory MEM Sets memory limit for the bulid container. + -m, --memory MEM Sets memory limit for the build container. --build-arg key=val Set build-time variables for one service. """ service_names = options['SERVICE'] From 6f48f5db4c944140fc681bf99c1c4ec280c35318 Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Tue, 16 Jan 2018 11:09:53 +0100 Subject: [PATCH 3234/4072] Add bash completion for `ps --services --filter` Signed-off-by: Harald Albers --- contrib/completion/bash/docker-compose | 47 +++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index 2c7194fcb07..017b0192f1a 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -48,6 +48,31 @@ __docker_compose_has_option() { return 1 } +# Returns `key` if we are currently completing the value of a map option (`key=value`) +# which matches the extglob passed in as an argument. +# This function is needed for key-specific completions. +__docker_compose_map_key_of_current_option() { + local glob="$1" + + local key glob_pos + if [ "$cur" = "=" ] ; then # key= case + key="$prev" + glob_pos=$((cword - 2)) + elif [[ $cur == *=* ]] ; then # key=value case (OSX) + key=${cur%=*} + glob_pos=$((cword - 1)) + elif [ "$prev" = "=" ] ; then + key=${words[$cword - 2]} # key=value case + glob_pos=$((cword - 3)) + else + return + fi + + [ "${words[$glob_pos]}" = "=" ] && ((glob_pos--)) # --option=key=value syntax + + [[ ${words[$glob_pos]} == @($glob) ]] && echo "$key" +} + # suppress trailing whitespace __docker_compose_nospace() { # compopt is not available in ancient bash versions @@ -314,9 +339,29 @@ _docker_compose_port() { _docker_compose_ps() { + local key=$(__docker_compose_map_key_of_current_option '--filter') + case "$key" in + source) + COMPREPLY=( $( compgen -W "build image" -- "${cur##*=}" ) ) + return + ;; + status) + COMPREPLY=( $( compgen -W "paused restarting running stopped" -- "${cur##*=}" ) ) + return + ;; + esac + + case "$prev" in + --filter) + COMPREPLY=( $( compgen -W "source status" -S "=" -- "$cur" ) ) + __docker_compose_nospace + return; + ;; + esac + case "$cur" in -*) - COMPREPLY=( $( compgen -W "--help -q" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--help -q --services --filter" -- "$cur" ) ) ;; *) __docker_compose_services_all From 748357f6654516e1eee032e2917bf665b9fa01eb Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Tue, 16 Jan 2018 14:19:42 +0100 Subject: [PATCH 3235/4072] Remove duplicate `--filter` from help message for `ps` The `--filter` option appears twice in the help message. Its presence in the usage line suggests that it is valid for both ps and service mode, which is wrong. Signed-off-by: Harald Albers --- 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 78df6b89a83..41b11258040 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -619,7 +619,7 @@ def ps(self, options): """ List containers. - Usage: ps [options] [--filter KEY=VAL] [SERVICE...] + Usage: ps [options] [SERVICE...] Options: -q Only display IDs From 7f30a88bd6c88ef40ba8ef6ff974b5c124e3ac0d Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 17 Jan 2018 18:44:26 -0800 Subject: [PATCH 3236/4072] Add type conversion (number, bool) -> float for label values Signed-off-by: Joffrey F --- compose/config/interpolation.py | 26 ++++++++++++- tests/unit/config/config_test.py | 51 +++++++++++++++++++++---- tests/unit/config/interpolation_test.py | 2 +- 3 files changed, 69 insertions(+), 10 deletions(-) diff --git a/compose/config/interpolation.py b/compose/config/interpolation.py index 5a5f349dbac..9d52d2edb90 100644 --- a/compose/config/interpolation.py +++ b/compose/config/interpolation.py @@ -84,7 +84,7 @@ def append(config_path, key): ) if isinstance(obj, list): return [recursive_interpolate(val, interpolator, config_path) for val in obj] - return obj + return converter.convert(config_path, obj) class TemplateWithDefaults(Template): @@ -160,10 +160,11 @@ def __init__(self, custom_err_msg): PATH_JOKER = '[^.]+' +FULL_JOKER = '.+' def re_path(*args): - return re.compile('^{}$'.format('.'.join(args))) + return re.compile('^{}$'.format('\.'.join(args))) def re_path_basic(section, name): @@ -175,6 +176,8 @@ def service_path(*args): def to_boolean(s): + if not isinstance(s, six.string_types): + return s s = s.lower() if s in ['y', 'yes', 'true', 'on']: return True @@ -184,6 +187,9 @@ def to_boolean(s): def to_int(s): + if not isinstance(s, six.string_types): + return s + # We must be able to handle octal representation for `mode` values notably if six.PY3 and re.match('^0[0-9]+$', s.strip()): s = '0o' + s[1:] @@ -194,27 +200,39 @@ def to_int(s): def to_float(s): + if not isinstance(s, six.string_types): + return s + try: return float(s) except ValueError: raise ValueError('"{}" is not a valid float'.format(s)) +def to_str(o): + if isinstance(o, (bool, float, int)): + return '{}'.format(o) + return o + + class ConversionMap(object): map = { service_path('blkio_config', 'weight'): to_int, service_path('blkio_config', 'weight_device', 'weight'): to_int, + service_path('build', 'labels', FULL_JOKER): to_str, service_path('cpus'): to_float, service_path('cpu_count'): to_int, service_path('configs', 'mode'): to_int, service_path('secrets', 'mode'): to_int, service_path('healthcheck', 'retries'): to_int, service_path('healthcheck', 'disable'): to_boolean, + service_path('deploy', 'labels', PATH_JOKER): to_str, service_path('deploy', 'replicas'): to_int, service_path('deploy', 'update_config', 'parallelism'): to_int, service_path('deploy', 'update_config', 'max_failure_ratio'): to_float, service_path('deploy', 'restart_policy', 'max_attempts'): to_int, service_path('mem_swappiness'): to_int, + service_path('labels', FULL_JOKER): to_str, service_path('oom_kill_disable'): to_boolean, service_path('oom_score_adj'): to_int, service_path('ports', 'target'): to_int, @@ -232,9 +250,13 @@ class ConversionMap(object): re_path_basic('network', 'attachable'): to_boolean, re_path_basic('network', 'external'): to_boolean, re_path_basic('network', 'internal'): to_boolean, + re_path('network', PATH_JOKER, 'labels', FULL_JOKER): to_str, re_path_basic('volume', 'external'): to_boolean, + re_path('volume', PATH_JOKER, 'labels', FULL_JOKER): to_str, re_path_basic('secret', 'external'): to_boolean, + re_path('secret', PATH_JOKER, 'labels', FULL_JOKER): to_str, re_path_basic('config', 'external'): to_boolean, + re_path('config', PATH_JOKER, 'labels', FULL_JOKER): to_str, } def convert(self, path, value): diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 04e495f96e1..7cb74c00a5f 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -563,7 +563,7 @@ def test_load_with_empty_build_args(self): 'services': { 'web': { 'build': { - 'context': '.', + 'context': os.getcwd(), 'args': None, }, }, @@ -959,7 +959,7 @@ def test_load_build_labels_dict(self): ).services[0] assert 'labels' in service['build'] assert 'label1' in service['build']['labels'] - assert service['build']['labels']['label1'] == 42 + assert service['build']['labels']['label1'] == '42' assert service['build']['labels']['label2'] == 'foobar' def test_load_build_labels_list(self): @@ -2747,24 +2747,61 @@ def test_load_configs_multi_file(self): ] assert service_sort(service_dicts) == service_sort(expected) - def test_config_invalid_service_label_validation(self): + def test_config_convertible_label_types(self): config_details = build_config_details( { 'version': '3.5', 'services': { 'web': { - 'image': 'busybox', + 'build': { + 'labels': {'testbuild': True}, + 'context': os.getcwd() + }, 'labels': { "key": 12345 } }, }, + 'networks': { + 'foo': { + 'labels': {'network.ips.max': 1023} + } + }, + 'volumes': { + 'foo': { + 'labels': {'volume.is_readonly': False} + } + }, + 'secrets': { + 'foo': { + 'labels': {'secret.data.expires': 1546282120} + } + }, + 'configs': { + 'foo': { + 'labels': {'config.data.correction.value': -0.1412} + } + } } ) - with pytest.raises(ConfigurationError) as exc: - config.load(config_details) + loaded_config = config.load(config_details) + + assert loaded_config.services[0]['build']['labels'] == {'testbuild': 'True'} + assert loaded_config.services[0]['labels'] == {'key': '12345'} + assert loaded_config.networks['foo']['labels']['network.ips.max'] == '1023' + assert loaded_config.volumes['foo']['labels']['volume.is_readonly'] == 'False' + assert loaded_config.secrets['foo']['labels']['secret.data.expires'] == '1546282120' + assert loaded_config.configs['foo']['labels']['config.data.correction.value'] == '-0.1412' - assert "which is an invalid type, it should be a string" in exc.exconly() + def test_config_invalid_label_types(self): + config_details = build_config_details({ + 'version': '2.3', + 'volumes': { + 'foo': {'labels': [1, 2, 3]} + } + }) + with pytest.raises(ConfigurationError): + config.load(config_details) def test_service_volume_invalid_config(self): config_details = build_config_details( diff --git a/tests/unit/config/interpolation_test.py b/tests/unit/config/interpolation_test.py index d68e4e559b2..fe5ef2490ea 100644 --- a/tests/unit/config/interpolation_test.py +++ b/tests/unit/config/interpolation_test.py @@ -109,7 +109,7 @@ def test_interpolate_environment_variables_in_secrets(mock_env): 'secretservice': { 'file': 'bar', 'labels': { - 'max': 2, + 'max': '2', 'user': 'jenny' } }, From dcaef15b345f160e9e37cc77d0f4166f51c8d95a Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 16 Jan 2018 18:41:11 -0800 Subject: [PATCH 3237/4072] Use CLI for interactive exec on all platforms by default Add environment setting to enable old behavior (UNIX only) Signed-off-by: Joffrey F --- compose/cli/main.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index aef011f28e6..c69f49c7f28 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -434,6 +434,8 @@ def exec_command(self, options): -e, --env KEY=VAL Set environment variables (can be used multiple times, not supported in API < 1.25) """ + environment = Environment.from_env_file(self.project_dir) + use_cli = not environment.get_boolean('COMPOSE_EXEC_NO_CLI') index = int(options.get('--index')) service = self.project.get_service(options['SERVICE']) detach = options['-d'] @@ -448,14 +450,14 @@ def exec_command(self, options): command = [options['COMMAND']] + options['ARGS'] tty = not options["-T"] - if IS_WINDOWS_PLATFORM and not detach: + if IS_WINDOWS_PLATFORM or use_cli and not detach: sys.exit(call_docker(build_exec_command(options, container.id, command))) create_exec_options = { "privileged": options["--privileged"], "user": options["--user"], "tty": tty, - "stdin": tty, + "stdin": True, } if docker.utils.version_gte(self.project.client.api_version, '1.25'): From f4d7d32280cab3feffc20c09015bc81b7b819849 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 17 Jan 2018 11:24:59 -0800 Subject: [PATCH 3238/4072] Update CLI version in test-running Dockerfiles Signed-off-by: Joffrey F --- Dockerfile | 12 +++++++----- Dockerfile.armhf | 8 +++++--- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/Dockerfile b/Dockerfile index 154d515108e..c5ae9e739f5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,11 +17,13 @@ RUN set -ex; \ ; \ rm -rf /var/lib/apt/lists/* -RUN curl https://get.docker.com/builds/Linux/x86_64/docker-1.8.3 \ - -o /usr/local/bin/docker && \ - SHA256=f024bc65c45a3778cf07213d26016075e8172de8f6e4b5702bedde06c241650f; \ - echo "${SHA256} /usr/local/bin/docker" | sha256sum -c - && \ - chmod +x /usr/local/bin/docker +RUN curl -fsSL -o dockerbins.tgz "https://download.docker.com/linux/static/stable/x86_64/docker-17.12.0-ce.tgz" && \ + SHA256=692e1c72937f6214b1038def84463018d8e320c8eaf8530546c84c2f8f9c767d; \ + echo "${SHA256} dockerbins.tgz" | sha256sum -c - && \ + tar xvf dockerbins.tgz docker/docker --strip-components 1 && \ + mv docker /usr/local/bin/docker && \ + chmod +x /usr/local/bin/docker && \ + rm dockerbins.tgz # Build Python 2.7.13 from source RUN set -ex; \ diff --git a/Dockerfile.armhf b/Dockerfile.armhf index 9fd697155d9..b7be8cd3644 100644 --- a/Dockerfile.armhf +++ b/Dockerfile.armhf @@ -17,9 +17,11 @@ RUN set -ex; \ ; \ rm -rf /var/lib/apt/lists/* -RUN curl https://get.docker.com/builds/Linux/armel/docker-1.8.3 \ - -o /usr/local/bin/docker && \ - chmod +x /usr/local/bin/docker +RUN curl -fsSL -o dockerbins.tgz "https://download.docker.com/linux/static/stable/armhf/docker-17.12.0-ce.tgz" && \ + tar xvf dockerbins.tgz docker/docker --strip-components 1 && \ + mv docker /usr/local/bin/docker && \ + chmod +x /usr/local/bin/docker && \ + rm dockerbins.tgz # Build Python 2.7.13 from source RUN set -ex; \ From a5d8c68d429ae66f57e67938c5e30f8b8dc5c4df Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 18 Jan 2018 12:38:33 -0800 Subject: [PATCH 3239/4072] Also use docker CLI for interactive one-off runs. COMPOSE_EXEC_NO_CLI -> COMPOSE_INTERACTIVE_NO_CLI Signed-off-by: Joffrey F --- compose/cli/main.py | 11 +++++++---- tests/unit/cli_test.py | 2 ++ 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index c69f49c7f28..4de7c5caa3f 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -435,7 +435,7 @@ def exec_command(self, options): not supported in API < 1.25) """ environment = Environment.from_env_file(self.project_dir) - use_cli = not environment.get_boolean('COMPOSE_EXEC_NO_CLI') + use_cli = not environment.get_boolean('COMPOSE_INTERACTIVE_NO_CLI') index = int(options.get('--index')) service = self.project.get_service(options['SERVICE']) detach = options['-d'] @@ -794,7 +794,7 @@ def run(self, options): command = service.options.get('command') container_options = build_container_options(options, detach, command) - run_one_off_container(container_options, self.project, service, options) + run_one_off_container(container_options, self.project, service, options, self.project_dir) def scale(self, options): """ @@ -1201,7 +1201,7 @@ def build_container_options(options, detach, command): return container_options -def run_one_off_container(container_options, project, service, options): +def run_one_off_container(container_options, project, service, options, project_dir='.'): if not options['--no-deps']: deps = service.get_dependency_names() if deps: @@ -1228,10 +1228,13 @@ def remove_container(force=False): if options['--rm']: project.client.remove_container(container.id, force=True, v=True) + environment = Environment.from_env_file(project_dir) + use_cli = not environment.get_boolean('COMPOSE_INTERACTIVE_NO_CLI') + signals.set_signal_handler_to_shutdown() try: try: - if IS_WINDOWS_PLATFORM: + if IS_WINDOWS_PLATFORM or use_cli: service.connect_container_to_networks(container) exit_code = call_docker(["start", "--attach", "--interactive", container.id]) else: diff --git a/tests/unit/cli_test.py b/tests/unit/cli_test.py index 19f6c9782c2..d078614e600 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -97,7 +97,9 @@ def test_command_help_nonexistent(self): @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) + @mock.patch.dict(os.environ) def test_run_interactive_passes_logs_false(self, mock_pseudo_terminal, mock_run_operation): + os.environ['COMPOSE_INTERACTIVE_NO_CLI'] = 'true' mock_client = mock.create_autospec(docker.APIClient) mock_client.api_version = DEFAULT_DOCKER_API_VERSION project = Project.from_config( From 7591639c728744ac836b5e9dfe62b0963a78341e Mon Sep 17 00:00:00 2001 From: Ian Glen Neal Date: Sun, 10 Dec 2017 23:50:24 +0000 Subject: [PATCH 3240/4072] Fix #5465 by catching a no-image exception in get_container_data_volumes. The ImageNotFound exception is now bubbled up to the client, which prompts the user on the desired course of action (rebuild or abort). Added a case to the unit test to check that an empty existing image doesn't result in an exception. Closed old pull request #5466 because I did a rebase and it showed over 300 commits to merge, which I thought was messy. Signed-off-by: Ian Glen Neal --- compose/cli/main.py | 58 +++++++++++++++++++++++++++----------- compose/parallel.py | 12 +++++--- compose/project.py | 2 ++ compose/service.py | 55 ++++++++++++++++++++++++++---------- tests/unit/service_test.py | 16 +++++++++-- 5 files changed, 106 insertions(+), 37 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 4de7c5caa3f..bc10c5c4ff6 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -972,24 +972,50 @@ def up(self, options): raise UserError("COMPOSE_IGNORE_ORPHANS and --remove-orphans cannot be combined.") if no_start: - for excluded in ['-d', '--abort-on-container-exit', '--exit-code-from']: - if options.get(excluded): - raise UserError('--no-start and {} cannot be combined.'.format(excluded)) + opts = ['-d', '--abort-on-container-exit', '--exit-code-from'] + for excluded in [x for x in opts if options.get(x)]: + raise UserError('--no-start and {} cannot be combined.'.format(excluded)) 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), - do_build=build_action_from_opts(options), - timeout=timeout, - detached=detached, - remove_orphans=remove_orphans, - ignore_orphans=ignore_orphans, - scale_override=parse_scale_args(options['--scale']), - start=not no_start, - always_recreate_deps=always_recreate_deps - ) + try: + to_attach = self.project.up( + service_names=service_names, + start_deps=start_deps, + strategy=convergence_strategy_from_opts(options), + do_build=build_action_from_opts(options), + timeout=timeout, + detached=detached, + remove_orphans=remove_orphans, + ignore_orphans=ignore_orphans, + scale_override=parse_scale_args(options['--scale']), + start=not no_start, + always_recreate_deps=always_recreate_deps, + rebuild=False + ) + except docker.errors.ImageNotFound as e: + log.error(("Image not found. If you continue, there is a " + "risk of data loss. Consider backing up your data " + "before continuing.\n\n" + "Full error message: {}\n" + ).format(e.explanation)) + res = yesno("Continue by rebuilding the image(s)? [yN]", False) + if res is None or not res: + raise e + + to_attach = self.project.up( + service_names=service_names, + start_deps=start_deps, + strategy=convergence_strategy_from_opts(options), + do_build=build_action_from_opts(options), + timeout=timeout, + detached=detached, + remove_orphans=remove_orphans, + ignore_orphans=ignore_orphans, + scale_override=parse_scale_args(options['--scale']), + start=not no_start, + always_recreate_deps=always_recreate_deps, + rebuild=True + ) if detached or no_start: return diff --git a/compose/parallel.py b/compose/parallel.py index 3c0098c0583..341ca2f5e03 100644 --- a/compose/parallel.py +++ b/compose/parallel.py @@ -8,6 +8,7 @@ from threading import Thread from docker.errors import APIError +from docker.errors import ImageNotFound from six.moves import _thread as thread from six.moves.queue import Empty from six.moves.queue import Queue @@ -53,10 +54,7 @@ def parallel_execute(objects, func, get_name, msg, get_deps=None, limit=None, pa writer = ParallelStreamWriter(stream, msg) - if parent_objects: - display_objects = list(parent_objects) - else: - display_objects = objects + display_objects = list(parent_objects) if parent_objects else objects for obj in display_objects: writer.add_object(get_name(obj)) @@ -76,6 +74,12 @@ def parallel_execute(objects, func, get_name, msg, get_deps=None, limit=None, pa if exception is None: writer.write(get_name(obj), 'done', green) results.append(result) + elif isinstance(exception, ImageNotFound): + # This is to bubble up ImageNotFound exceptions to the client so we + # can prompt the user if they want to rebuild. + errors[get_name(obj)] = exception.explanation + writer.write(get_name(obj), 'error', red) + error_to_reraise = exception elif isinstance(exception, APIError): errors[get_name(obj)] = exception.explanation writer.write(get_name(obj), 'error', red) diff --git a/compose/project.py b/compose/project.py index 8e1c6a14d71..f69aaa7d733 100644 --- a/compose/project.py +++ b/compose/project.py @@ -442,6 +442,7 @@ def up(self, remove_orphans=False, ignore_orphans=False, scale_override=None, + rebuild=False, rescale=True, start=True, always_recreate_deps=False): @@ -472,6 +473,7 @@ def do(service): timeout=timeout, detached=detached, scale_override=scale_override.get(service.name), + rebuild=rebuild, rescale=rescale, start=start, project_services=scaled_services diff --git a/compose/service.py b/compose/service.py index 8a2faba9516..37bd2ca2f5a 100644 --- a/compose/service.py +++ b/compose/service.py @@ -280,6 +280,7 @@ def create_container(self, previous_container=None, number=None, quiet=False, + rebuild=False, **override_options): """ Create a container for this service. If the image doesn't exist, attempt to pull @@ -293,6 +294,7 @@ def create_container(self, override_options, number or self._next_container_number(one_off=one_off), one_off=one_off, + rebuild=rebuild, previous_container=previous_container, ) @@ -409,7 +411,7 @@ def create_and_start(service, n): return containers - def _execute_convergence_recreate(self, containers, scale, timeout, detached, start): + def _execute_convergence_recreate(self, containers, scale, timeout, detached, start, rebuild): if scale is not None and len(containers) > scale: self._downscale(containers[scale:], timeout) containers = containers[:scale] @@ -417,7 +419,7 @@ def _execute_convergence_recreate(self, containers, scale, timeout, detached, st def recreate(container): return self.recreate_container( container, timeout=timeout, attach_logs=not detached, - start_new_container=start + start_new_container=start, rebuild=rebuild ) containers, errors = parallel_execute( containers, @@ -468,7 +470,8 @@ def stop_and_remove(container): ) def execute_convergence_plan(self, plan, timeout=None, detached=False, - start=True, scale_override=None, rescale=True, project_services=None): + start=True, scale_override=None, rebuild=False, + rescale=True, project_services=None): (action, containers) = plan scale = scale_override if scale_override is not None else self.scale_num containers = sorted(containers, key=attrgetter('number')) @@ -487,7 +490,7 @@ def execute_convergence_plan(self, plan, timeout=None, detached=False, if action == 'recreate': return self._execute_convergence_recreate( - containers, scale, timeout, detached, start + containers, scale, timeout, detached, start, rebuild ) if action == 'start': @@ -512,6 +515,7 @@ def recreate_container( container, timeout=None, attach_logs=False, + rebuild=False, start_new_container=True): """Recreate a container. @@ -526,6 +530,7 @@ def recreate_container( previous_container=container, number=container.labels.get(LABEL_CONTAINER_NUMBER), quiet=True, + rebuild=rebuild ) if attach_logs: new_container.attach_log_stream() @@ -746,6 +751,7 @@ def _get_container_create_options( override_options, number, one_off=False, + rebuild=False, previous_container=None): add_config_hash = (not one_off and not override_options) @@ -795,7 +801,7 @@ def _get_container_create_options( override_options.get('labels')) container_options, override_options = self._build_container_volume_options( - previous_container, container_options, override_options + previous_container, container_options, override_options, rebuild ) container_options['image'] = self.image_name @@ -822,7 +828,8 @@ def _get_container_create_options( container_options['environment']) return container_options - def _build_container_volume_options(self, previous_container, container_options, override_options): + def _build_container_volume_options(self, previous_container, container_options, + override_options, rebuild): container_volumes = [] container_mounts = [] if 'volumes' in container_options: @@ -833,7 +840,7 @@ def _build_container_volume_options(self, previous_container, container_options, binds, affinity = merge_volume_bindings( container_volumes, self.options.get('tmpfs') or [], previous_container, - container_mounts + container_mounts, rebuild ) override_options['binds'] = binds container_options['environment'].update(affinity) @@ -1281,7 +1288,7 @@ def parse_repository_tag(repo_path): # Volumes -def merge_volume_bindings(volumes, tmpfs, previous_container, mounts): +def merge_volume_bindings(volumes, tmpfs, previous_container, mounts, rebuild): """ Return a list of volume bindings for a container. Container data volumes are replaced by those from the previous container. @@ -1297,7 +1304,7 @@ def merge_volume_bindings(volumes, tmpfs, previous_container, mounts): if previous_container: old_volumes, old_mounts = get_container_data_volumes( - previous_container, volumes, tmpfs, mounts + previous_container, volumes, tmpfs, mounts, rebuild ) warn_on_masked_volume(volumes, old_volumes, previous_container.service) volume_bindings.update( @@ -1310,12 +1317,34 @@ def merge_volume_bindings(volumes, tmpfs, previous_container, mounts): return list(volume_bindings.values()), affinity -def get_container_data_volumes(container, volumes_option, tmpfs_option, mounts_option): +def try_get_image_volumes(container, rebuild): + """ + Try to get the volumes from the existing container. If the image does + not exist, prompt the user to either continue (rebuild the image from + scratch) or raise an exception. + """ + + try: + image_volumes = [ + VolumeSpec.parse(volume) + for volume in + container.image_config['ContainerConfig'].get('Volumes') or {} + ] + return image_volumes + except ImageNotFound: + if rebuild: + # This will force Compose to rebuild the images. + return [] + raise + + +def get_container_data_volumes(container, volumes_option, tmpfs_option, mounts_option, rebuild): """ Find the container data volumes that are in `volumes_option`, and return a mapping of volume bindings for those volumes. Anonymous volume mounts are updated in place instead. """ + volumes = [] volumes_option = volumes_option or [] @@ -1324,11 +1353,7 @@ def get_container_data_volumes(container, volumes_option, tmpfs_option, mounts_o for mount in container.get('Mounts') or {} ) - image_volumes = [ - VolumeSpec.parse(volume) - for volume in - container.image_config['ContainerConfig'].get('Volumes') or {} - ] + image_volumes = try_get_image_volumes(container, rebuild) for volume in set(volumes_option + image_volumes): # No need to preserve host volumes diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 92d7f08d5a3..44f14e58b6e 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -923,7 +923,19 @@ def test_get_container_data_volumes(self): VolumeSpec.parse('imagedata:/mnt/image/data:rw'), ] - volumes, _ = get_container_data_volumes(container, options, ['/dev/tmpfs'], []) + volumes, _ = get_container_data_volumes(container, options, ['/dev/tmpfs'], [], False) + assert sorted(volumes) == sorted(expected) + + # Issue 5465, check for non-existant image. + + container = Container(self.mock_client, { + 'Image': None, + 'Mounts': [] + }, has_been_inspected=True) + + expected = [] + + volumes, _ = get_container_data_volumes(container, options, ['/dev/tmpfs'], [], False) assert sorted(volumes) == sorted(expected) def test_merge_volume_bindings(self): @@ -959,7 +971,7 @@ def test_merge_volume_bindings(self): 'existingvolume:/existing/volume:rw', ] - binds, affinity = merge_volume_bindings(options, ['/dev/tmpfs'], previous_container, []) + binds, affinity = merge_volume_bindings(options, ['/dev/tmpfs'], previous_container, [], False) assert sorted(binds) == sorted(expected) assert affinity == {'affinity:container': '=cdefab'} From 2d986dff79f6d8a63f66ad46e9c231e0d7910165 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 11 Jan 2018 16:37:09 -0800 Subject: [PATCH 3241/4072] Remove rebuild parameter ; set do_build instead Reduce repetition in main.py Signed-off-by: Joffrey F --- compose/cli/main.py | 25 ++++++---------------- compose/project.py | 2 -- compose/service.py | 44 +++++++++++++------------------------- tests/unit/service_test.py | 14 +++++++++--- 4 files changed, 33 insertions(+), 52 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index bc10c5c4ff6..14f7a745cc6 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -977,12 +977,12 @@ def up(self, options): raise UserError('--no-start and {} cannot be combined.'.format(excluded)) with up_shutdown_context(self.project, service_names, timeout, detached): - try: - to_attach = self.project.up( + def up(rebuild): + return self.project.up( service_names=service_names, start_deps=start_deps, strategy=convergence_strategy_from_opts(options), - do_build=build_action_from_opts(options), + do_build=build_action_from_opts(options) if not rebuild else BuildAction.force, timeout=timeout, detached=detached, remove_orphans=remove_orphans, @@ -990,8 +990,10 @@ def up(self, options): scale_override=parse_scale_args(options['--scale']), start=not no_start, always_recreate_deps=always_recreate_deps, - rebuild=False ) + + try: + to_attach = up(False) except docker.errors.ImageNotFound as e: log.error(("Image not found. If you continue, there is a " "risk of data loss. Consider backing up your data " @@ -1002,20 +1004,7 @@ def up(self, options): if res is None or not res: raise e - to_attach = self.project.up( - service_names=service_names, - start_deps=start_deps, - strategy=convergence_strategy_from_opts(options), - do_build=build_action_from_opts(options), - timeout=timeout, - detached=detached, - remove_orphans=remove_orphans, - ignore_orphans=ignore_orphans, - scale_override=parse_scale_args(options['--scale']), - start=not no_start, - always_recreate_deps=always_recreate_deps, - rebuild=True - ) + to_attach = up(True) if detached or no_start: return diff --git a/compose/project.py b/compose/project.py index f69aaa7d733..8e1c6a14d71 100644 --- a/compose/project.py +++ b/compose/project.py @@ -442,7 +442,6 @@ def up(self, remove_orphans=False, ignore_orphans=False, scale_override=None, - rebuild=False, rescale=True, start=True, always_recreate_deps=False): @@ -473,7 +472,6 @@ def do(service): timeout=timeout, detached=detached, scale_override=scale_override.get(service.name), - rebuild=rebuild, rescale=rescale, start=start, project_services=scaled_services diff --git a/compose/service.py b/compose/service.py index 37bd2ca2f5a..195257c3944 100644 --- a/compose/service.py +++ b/compose/service.py @@ -280,7 +280,6 @@ def create_container(self, previous_container=None, number=None, quiet=False, - rebuild=False, **override_options): """ Create a container for this service. If the image doesn't exist, attempt to pull @@ -294,7 +293,6 @@ def create_container(self, override_options, number or self._next_container_number(one_off=one_off), one_off=one_off, - rebuild=rebuild, previous_container=previous_container, ) @@ -411,7 +409,7 @@ def create_and_start(service, n): return containers - def _execute_convergence_recreate(self, containers, scale, timeout, detached, start, rebuild): + def _execute_convergence_recreate(self, containers, scale, timeout, detached, start): if scale is not None and len(containers) > scale: self._downscale(containers[scale:], timeout) containers = containers[:scale] @@ -419,7 +417,7 @@ def _execute_convergence_recreate(self, containers, scale, timeout, detached, st def recreate(container): return self.recreate_container( container, timeout=timeout, attach_logs=not detached, - start_new_container=start, rebuild=rebuild + start_new_container=start ) containers, errors = parallel_execute( containers, @@ -470,7 +468,7 @@ def stop_and_remove(container): ) def execute_convergence_plan(self, plan, timeout=None, detached=False, - start=True, scale_override=None, rebuild=False, + start=True, scale_override=None, rescale=True, project_services=None): (action, containers) = plan scale = scale_override if scale_override is not None else self.scale_num @@ -490,7 +488,7 @@ def execute_convergence_plan(self, plan, timeout=None, detached=False, if action == 'recreate': return self._execute_convergence_recreate( - containers, scale, timeout, detached, start, rebuild + containers, scale, timeout, detached, start ) if action == 'start': @@ -510,13 +508,7 @@ def execute_convergence_plan(self, plan, timeout=None, detached=False, raise Exception("Invalid action: {}".format(action)) - def recreate_container( - self, - container, - timeout=None, - attach_logs=False, - rebuild=False, - start_new_container=True): + def recreate_container(self, container, timeout=None, attach_logs=False, start_new_container=True): """Recreate a container. The original container is renamed to a temporary name so that data @@ -530,7 +522,6 @@ def recreate_container( previous_container=container, number=container.labels.get(LABEL_CONTAINER_NUMBER), quiet=True, - rebuild=rebuild ) if attach_logs: new_container.attach_log_stream() @@ -751,7 +742,6 @@ def _get_container_create_options( override_options, number, one_off=False, - rebuild=False, previous_container=None): add_config_hash = (not one_off and not override_options) @@ -801,7 +791,7 @@ def _get_container_create_options( override_options.get('labels')) container_options, override_options = self._build_container_volume_options( - previous_container, container_options, override_options, rebuild + previous_container, container_options, override_options ) container_options['image'] = self.image_name @@ -828,8 +818,7 @@ def _get_container_create_options( container_options['environment']) return container_options - def _build_container_volume_options(self, previous_container, container_options, - override_options, rebuild): + def _build_container_volume_options(self, previous_container, container_options, override_options): container_volumes = [] container_mounts = [] if 'volumes' in container_options: @@ -840,7 +829,7 @@ def _build_container_volume_options(self, previous_container, container_options, binds, affinity = merge_volume_bindings( container_volumes, self.options.get('tmpfs') or [], previous_container, - container_mounts, rebuild + container_mounts ) override_options['binds'] = binds container_options['environment'].update(affinity) @@ -1288,7 +1277,7 @@ def parse_repository_tag(repo_path): # Volumes -def merge_volume_bindings(volumes, tmpfs, previous_container, mounts, rebuild): +def merge_volume_bindings(volumes, tmpfs, previous_container, mounts): """ Return a list of volume bindings for a container. Container data volumes are replaced by those from the previous container. @@ -1304,7 +1293,7 @@ def merge_volume_bindings(volumes, tmpfs, previous_container, mounts, rebuild): if previous_container: old_volumes, old_mounts = get_container_data_volumes( - previous_container, volumes, tmpfs, mounts, rebuild + previous_container, volumes, tmpfs, mounts ) warn_on_masked_volume(volumes, old_volumes, previous_container.service) volume_bindings.update( @@ -1317,11 +1306,11 @@ def merge_volume_bindings(volumes, tmpfs, previous_container, mounts, rebuild): return list(volume_bindings.values()), affinity -def try_get_image_volumes(container, rebuild): +def try_get_image_volumes(container): """ Try to get the volumes from the existing container. If the image does - not exist, prompt the user to either continue (rebuild the image from - scratch) or raise an exception. + not exist, raise an exception that will be caught at the CLI level to + prompt user for a rebuild. """ try: @@ -1332,13 +1321,10 @@ def try_get_image_volumes(container, rebuild): ] return image_volumes except ImageNotFound: - if rebuild: - # This will force Compose to rebuild the images. - return [] raise -def get_container_data_volumes(container, volumes_option, tmpfs_option, mounts_option, rebuild): +def get_container_data_volumes(container, volumes_option, tmpfs_option, mounts_option): """ Find the container data volumes that are in `volumes_option`, and return a mapping of volume bindings for those volumes. @@ -1353,7 +1339,7 @@ def get_container_data_volumes(container, volumes_option, tmpfs_option, mounts_o for mount in container.get('Mounts') or {} ) - image_volumes = try_get_image_volumes(container, rebuild) + image_volumes = try_get_image_volumes(container) for volume in set(volumes_option + image_volumes): # No need to preserve host volumes diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 44f14e58b6e..ef250a4d7c0 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -923,10 +923,18 @@ def test_get_container_data_volumes(self): VolumeSpec.parse('imagedata:/mnt/image/data:rw'), ] - volumes, _ = get_container_data_volumes(container, options, ['/dev/tmpfs'], [], False) + volumes, _ = get_container_data_volumes(container, options, ['/dev/tmpfs'], []) assert sorted(volumes) == sorted(expected) + def test_get_container_data_volumes_image_is_none(self): # Issue 5465, check for non-existant image. + options = [VolumeSpec.parse(v) for v in [ + '/host/volume:/host/volume:ro', + '/new/volume', + '/existing/volume', + 'named:/named/vol', + '/dev/tmpfs' + ]] container = Container(self.mock_client, { 'Image': None, @@ -935,7 +943,7 @@ def test_get_container_data_volumes(self): expected = [] - volumes, _ = get_container_data_volumes(container, options, ['/dev/tmpfs'], [], False) + volumes, _ = get_container_data_volumes(container, options, ['/dev/tmpfs'], []) assert sorted(volumes) == sorted(expected) def test_merge_volume_bindings(self): @@ -971,7 +979,7 @@ def test_merge_volume_bindings(self): 'existingvolume:/existing/volume:rw', ] - binds, affinity = merge_volume_bindings(options, ['/dev/tmpfs'], previous_container, [], False) + binds, affinity = merge_volume_bindings(options, ['/dev/tmpfs'], previous_container, []) assert sorted(binds) == sorted(expected) assert affinity == {'affinity:container': '=cdefab'} From 4042121f6e8e302206189bd110c46791ce72bc28 Mon Sep 17 00:00:00 2001 From: Ian Glen Neal Date: Fri, 12 Jan 2018 06:29:16 +0000 Subject: [PATCH 3242/4072] The updated unit test simulates the error in the bug. What's happening is that the container has an image sha (represented by 'shaDOES_NOT_EXIST') which causes an error when client.inspect_image is called on it, because the image has actually been removed. Signed-off-by: Ian Glen Neal --- compose/cli/main.py | 7 +++---- compose/service.py | 24 +++++------------------- tests/unit/service_test.py | 12 ++++++++++-- 3 files changed, 18 insertions(+), 25 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 14f7a745cc6..ef7ea0c9de7 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -971,10 +971,9 @@ def up(self, options): if ignore_orphans and remove_orphans: raise UserError("COMPOSE_IGNORE_ORPHANS and --remove-orphans cannot be combined.") - if no_start: - opts = ['-d', '--abort-on-container-exit', '--exit-code-from'] - for excluded in [x for x in opts if options.get(x)]: - raise UserError('--no-start and {} cannot be combined.'.format(excluded)) + opts = ['-d', '--abort-on-container-exit', '--exit-code-from'] + for excluded in [x for x in opts if options.get(x) and no_start]: + raise UserError('--no-start and {} cannot be combined.'.format(excluded)) with up_shutdown_context(self.project, service_names, timeout, detached): def up(rebuild): diff --git a/compose/service.py b/compose/service.py index 195257c3944..97455f6c2f0 100644 --- a/compose/service.py +++ b/compose/service.py @@ -1306,24 +1306,6 @@ def merge_volume_bindings(volumes, tmpfs, previous_container, mounts): return list(volume_bindings.values()), affinity -def try_get_image_volumes(container): - """ - Try to get the volumes from the existing container. If the image does - not exist, raise an exception that will be caught at the CLI level to - prompt user for a rebuild. - """ - - try: - image_volumes = [ - VolumeSpec.parse(volume) - for volume in - container.image_config['ContainerConfig'].get('Volumes') or {} - ] - return image_volumes - except ImageNotFound: - raise - - def get_container_data_volumes(container, volumes_option, tmpfs_option, mounts_option): """ Find the container data volumes that are in `volumes_option`, and return @@ -1339,7 +1321,11 @@ def get_container_data_volumes(container, volumes_option, tmpfs_option, mounts_o for mount in container.get('Mounts') or {} ) - image_volumes = try_get_image_volumes(container) + image_volumes = [ + VolumeSpec.parse(volume) + for volume in + container.image_config['ContainerConfig'].get('Volumes') or {} + ] for volume in set(volumes_option + image_volumes): # No need to preserve host volumes diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index ef250a4d7c0..bdeaebdd764 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -5,6 +5,7 @@ import pytest from docker.constants import DEFAULT_DOCKER_API_VERSION from docker.errors import APIError +from docker.errors import ImageNotFound from .. import mock from .. import unittest @@ -926,7 +927,7 @@ def test_get_container_data_volumes(self): volumes, _ = get_container_data_volumes(container, options, ['/dev/tmpfs'], []) assert sorted(volumes) == sorted(expected) - def test_get_container_data_volumes_image_is_none(self): + def test_get_container_data_volumes_image_does_not_exist(self): # Issue 5465, check for non-existant image. options = [VolumeSpec.parse(v) for v in [ '/host/volume:/host/volume:ro', @@ -936,8 +937,15 @@ def test_get_container_data_volumes_image_is_none(self): '/dev/tmpfs' ]] + def inspect_fn(image): + if image == 'shaDOES_NOT_EXIST': + raise ImageNotFound("inspect_fn: {}".format(image)) + return {'ContainerConfig': None} + + self.mock_client.inspect_image = inspect_fn + container = Container(self.mock_client, { - 'Image': None, + 'Image': 'shaDOES_NOT_EXIST', 'Mounts': [] }, has_been_inspected=True) From 9ba9016cbc66bf2473fa62b1e831ae2c70408509 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 17 Jan 2018 17:08:13 -0800 Subject: [PATCH 3243/4072] Improve ImageNotFound handling in convergence - Improved CLI prompt - Attempt volume preservation with new image config - Fix container.rename_to_tmp_name() - Integration test replicates behavior ; remove obsolete unit test Signed-off-by: Joffrey F --- compose/cli/main.py | 33 ++++++++++++++++++++++++------- compose/container.py | 32 +++++++++++++++++++++++------- compose/project.py | 24 ++++------------------ compose/service.py | 9 ++++++++- tests/integration/service_test.py | 30 ++++++++++++++++++++++++++++ tests/unit/cli/main_test.py | 10 ++++++++++ tests/unit/project_test.py | 8 -------- tests/unit/service_test.py | 28 -------------------------- 8 files changed, 103 insertions(+), 71 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index ef7ea0c9de7..de4fbd4306c 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -976,12 +976,14 @@ def up(self, options): raise UserError('--no-start and {} cannot be combined.'.format(excluded)) with up_shutdown_context(self.project, service_names, timeout, detached): + warn_for_swarm_mode(self.project.client) + def up(rebuild): return self.project.up( service_names=service_names, start_deps=start_deps, strategy=convergence_strategy_from_opts(options), - do_build=build_action_from_opts(options) if not rebuild else BuildAction.force, + do_build=build_action_from_opts(options), timeout=timeout, detached=detached, remove_orphans=remove_orphans, @@ -989,17 +991,18 @@ def up(rebuild): scale_override=parse_scale_args(options['--scale']), start=not no_start, always_recreate_deps=always_recreate_deps, + reset_container_image=rebuild, ) try: to_attach = up(False) except docker.errors.ImageNotFound as e: - log.error(("Image not found. If you continue, there is a " - "risk of data loss. Consider backing up your data " - "before continuing.\n\n" - "Full error message: {}\n" - ).format(e.explanation)) - res = yesno("Continue by rebuilding the image(s)? [yN]", False) + log.error( + "The image for the service you're trying to recreate has been removed. " + "If you continue, volume data could be lost. Consider backing up your data " + "before continuing.\n".format(e.explanation) + ) + res = yesno("Continue with the new image? [yN]", False) if res is None or not res: raise e @@ -1426,3 +1429,19 @@ def build_filter(arg): key, val = arg.split('=', 1) filt[key] = val return filt + + +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. " + "All containers will be scheduled on the current node.\n\n" + "To deploy your application across the swarm, " + "use `docker stack deploy`.\n" + ) diff --git a/compose/container.py b/compose/container.py index 4bc7f54f9de..4ab99ffa8aa 100644 --- a/compose/container.py +++ b/compose/container.py @@ -4,6 +4,7 @@ from functools import reduce import six +from docker.errors import ImageNotFound from .const import LABEL_CONTAINER_NUMBER from .const import LABEL_PROJECT @@ -66,15 +67,17 @@ def short_id(self): def name(self): return self.dictionary['Name'][1:] + @property + def project(self): + return self.labels.get(LABEL_PROJECT) + @property def service(self): return self.labels.get(LABEL_SERVICE) @property def name_without_project(self): - project = self.labels.get(LABEL_PROJECT) - - if self.name.startswith('{0}_{1}'.format(project, self.service)): + if self.name.startswith('{0}_{1}'.format(self.project, self.service)): return '{0}_{1}'.format(self.service, self.number) else: return self.name @@ -230,10 +233,10 @@ def rename_to_tmp_name(self): """Rename the container to a hopefully unique temporary container name by prepending the short id. """ - self.client.rename( - self.id, - '%s_%s' % (self.short_id, self.name) - ) + if not self.name.startswith(self.short_id): + self.client.rename( + self.id, '{0}_{1}'.format(self.short_id, self.name) + ) def inspect_if_not_inspected(self): if not self.has_been_inspected: @@ -250,6 +253,21 @@ def inspect(self): self.has_been_inspected = True return self.dictionary + def image_exists(self): + try: + self.client.inspect_image(self.image) + except ImageNotFound: + return False + + return True + + def reset_image(self, img_id): + """ If this container's image has been removed, temporarily replace the old image ID + with `img_id`. + """ + if not self.image_exists(): + self.dictionary['Image'] = img_id + def attach(self, *args, **kwargs): return self.client.attach(self.id, *args, **kwargs) diff --git a/compose/project.py b/compose/project.py index 8e1c6a14d71..6af4cd94a67 100644 --- a/compose/project.py +++ b/compose/project.py @@ -444,9 +444,8 @@ def up(self, scale_override=None, rescale=True, start=True, - always_recreate_deps=False): - - warn_for_swarm_mode(self.client) + always_recreate_deps=False, + reset_container_image=False): self.initialize() if not ignore_orphans: @@ -474,7 +473,8 @@ def do(service): scale_override=scale_override.get(service.name), rescale=rescale, start=start, - project_services=scaled_services + project_services=scaled_services, + reset_container_image=reset_container_image ) def get_deps(service): @@ -686,22 +686,6 @@ def get_secrets(service, service_secrets, secret_defs): return secrets -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. " - "All containers will be scheduled on the current node.\n\n" - "To deploy your application across the swarm, " - "use `docker stack deploy`.\n" - ) - - class NoSuchService(Exception): def __init__(self, name): if isinstance(name, six.binary_type): diff --git a/compose/service.py b/compose/service.py index 97455f6c2f0..4e236351184 100644 --- a/compose/service.py +++ b/compose/service.py @@ -469,7 +469,8 @@ def stop_and_remove(container): def execute_convergence_plan(self, plan, timeout=None, detached=False, start=True, scale_override=None, - rescale=True, project_services=None): + rescale=True, project_services=None, + reset_container_image=False): (action, containers) = plan scale = scale_override if scale_override is not None else self.scale_num containers = sorted(containers, key=attrgetter('number')) @@ -487,6 +488,12 @@ def execute_convergence_plan(self, plan, timeout=None, detached=False, scale = None if action == 'recreate': + if reset_container_image: + # Updating the image ID on the container object lets us recover old volumes if + # the new image uses them as well + img_id = self.image()['Id'] + for c in containers: + c.reset_image(img_id) return self._execute_convergence_recreate( containers, scale, timeout, detached, start ) diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 01be480051a..a6efc24a99d 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -10,6 +10,7 @@ import pytest from docker.errors import APIError +from docker.errors import ImageNotFound from six import StringIO from six import text_type @@ -659,6 +660,35 @@ def test_execute_convergence_plan_without_start(self): assert len(service_containers) == 1 assert not service_containers[0].is_running + def test_execute_convergence_plan_image_with_volume_is_removed(self): + service = self.create_service( + 'db', build={'context': 'tests/fixtures/dockerfile-with-volume'} + ) + + old_container = create_and_start_container(service) + assert ( + [mount['Destination'] for mount in old_container.get('Mounts')] == + ['/data'] + ) + volume_path = old_container.get_mount('/data')['Source'] + + old_container.stop() + self.client.remove_image(service.image(), force=True) + + service.ensure_image_exists() + with pytest.raises(ImageNotFound): + service.execute_convergence_plan( + ConvergencePlan('recreate', [old_container]) + ) + old_container.inspect() # retrieve new name from server + + new_container, = service.execute_convergence_plan( + ConvergencePlan('recreate', [old_container]), + reset_container_image=True + ) + assert [mount['Destination'] for mount in new_container.get('Mounts')] == ['/data'] + assert new_container.get_mount('/data')['Source'] == volume_path + def test_start_container_passes_through_options(self): db = self.create_service('db') create_and_start_container(db, environment={'FOO': 'BAR'}) diff --git a/tests/unit/cli/main_test.py b/tests/unit/cli/main_test.py index dc527880086..b1546d6f367 100644 --- a/tests/unit/cli/main_test.py +++ b/tests/unit/cli/main_test.py @@ -3,6 +3,7 @@ import logging +import docker import pytest from compose import container @@ -11,6 +12,7 @@ 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.cli.main import warn_for_swarm_mode from compose.service import ConvergenceStrategy from tests import mock @@ -54,6 +56,14 @@ def test_filter_containers_to_service_names_all(self): actual = filter_containers_to_service_names(containers, service_names) assert actual == containers + def test_warning_in_swarm_mode(self): + mock_client = mock.create_autospec(docker.APIClient) + mock_client.info.return_value = {'Swarm': {'LocalNodeState': 'active'}} + + with mock.patch('compose.cli.main.log') as fake_log: + warn_for_swarm_mode(mock_client) + assert fake_log.warn.call_count == 1 + class TestSetupConsoleHandlerTestCase(object): diff --git a/tests/unit/project_test.py b/tests/unit/project_test.py index 397287ad926..a0291d9f9f9 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -533,14 +533,6 @@ def test_down_with_no_resources(self): 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) diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index bdeaebdd764..92d7f08d5a3 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -5,7 +5,6 @@ import pytest from docker.constants import DEFAULT_DOCKER_API_VERSION from docker.errors import APIError -from docker.errors import ImageNotFound from .. import mock from .. import unittest @@ -927,33 +926,6 @@ def test_get_container_data_volumes(self): volumes, _ = get_container_data_volumes(container, options, ['/dev/tmpfs'], []) assert sorted(volumes) == sorted(expected) - def test_get_container_data_volumes_image_does_not_exist(self): - # Issue 5465, check for non-existant image. - options = [VolumeSpec.parse(v) for v in [ - '/host/volume:/host/volume:ro', - '/new/volume', - '/existing/volume', - 'named:/named/vol', - '/dev/tmpfs' - ]] - - def inspect_fn(image): - if image == 'shaDOES_NOT_EXIST': - raise ImageNotFound("inspect_fn: {}".format(image)) - return {'ContainerConfig': None} - - self.mock_client.inspect_image = inspect_fn - - container = Container(self.mock_client, { - 'Image': 'shaDOES_NOT_EXIST', - 'Mounts': [] - }, has_been_inspected=True) - - expected = [] - - volumes, _ = get_container_data_volumes(container, options, ['/dev/tmpfs'], []) - assert sorted(volumes) == sorted(expected) - def test_merge_volume_bindings(self): options = [ VolumeSpec.parse(v, True) for v in [ From 593a675d2b2f4a8c7e1e837f5619693d6c5137a5 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 19 Jan 2018 14:08:04 -0800 Subject: [PATCH 3244/4072] Support mixed use of TLS flags and TLS environment variables Signed-off-by: Joffrey F --- compose/cli/command.py | 2 +- compose/cli/docker_client.py | 13 +++++++++++-- tests/fixtures/tls/{key.key => key.pem} | 0 tests/unit/cli/docker_client_test.py | 26 ++++++++++++++++++++++--- 4 files changed, 35 insertions(+), 6 deletions(-) rename tests/fixtures/tls/{key.key => key.pem} (100%) diff --git a/compose/cli/command.py b/compose/cli/command.py index 7cea91a2da2..6977195a0de 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -35,7 +35,7 @@ def project_from_options(project_dir, options): project_name=options.get('--project-name'), verbose=options.get('--verbose'), host=host, - tls_config=tls_config_from_options(options), + tls_config=tls_config_from_options(options, environment), environment=environment, override_dir=options.get('--project-directory'), ) diff --git a/compose/cli/docker_client.py b/compose/cli/docker_client.py index 44c7ad91d8f..a581ae67285 100644 --- a/compose/cli/docker_client.py +++ b/compose/cli/docker_client.py @@ -2,6 +2,7 @@ from __future__ import unicode_literals import logging +import os.path import ssl from docker import APIClient @@ -35,14 +36,22 @@ def get_tls_version(environment): def tls_config_from_options(options, environment=None): + environment = environment or {} + cert_path = environment.get('DOCKER_CERT_PATH') or None + tls = options.get('--tls', False) ca_cert = unquote_path(options.get('--tlscacert')) cert = unquote_path(options.get('--tlscert')) key = unquote_path(options.get('--tlskey')) - verify = options.get('--tlsverify') + verify = options.get('--tlsverify', environment.get('DOCKER_TLS_VERIFY')) skip_hostname_check = options.get('--skip-hostname-check', False) + if cert_path is not None and not any((ca_cert, cert, key)): + # FIXME: Modify TLSConfig to take a cert_path argument and do this internally + cert = os.path.join(cert_path, 'cert.pem') + key = os.path.join(cert_path, 'key.pem') + ca_cert = os.path.join(cert_path, 'ca.pem') - tls_version = get_tls_version(environment or {}) + tls_version = get_tls_version(environment) advanced_opts = any([ca_cert, cert, key, verify, tls_version]) diff --git a/tests/fixtures/tls/key.key b/tests/fixtures/tls/key.pem similarity index 100% rename from tests/fixtures/tls/key.key rename to tests/fixtures/tls/key.pem diff --git a/tests/unit/cli/docker_client_test.py b/tests/unit/cli/docker_client_test.py index c4cd275f330..62a537ba571 100644 --- a/tests/unit/cli/docker_client_test.py +++ b/tests/unit/cli/docker_client_test.py @@ -64,9 +64,9 @@ def test_user_agent(self): class TLSConfigTestCase(unittest.TestCase): - ca_cert = 'tests/fixtures/tls/ca.pem' - client_cert = 'tests/fixtures/tls/cert.pem' - key = 'tests/fixtures/tls/key.key' + ca_cert = os.path.join('tests/fixtures/tls/', 'ca.pem') + client_cert = os.path.join('tests/fixtures/tls/', 'cert.pem') + key = os.path.join('tests/fixtures/tls/', 'key.pem') def test_simple_tls(self): options = {'--tls': True} @@ -168,6 +168,26 @@ def test_tls_simple_with_tls_version(self): assert isinstance(result, docker.tls.TLSConfig) assert result.ssl_version == ssl.PROTOCOL_TLSv1 + def test_tls_mixed_environment_and_flags(self): + options = {'--tls': True, '--tlsverify': False} + environment = {'DOCKER_CERT_PATH': 'tests/fixtures/tls/'} + result = tls_config_from_options(options, environment) + 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 False + + def test_tls_flags_override_environment(self): + environment = {'DOCKER_TLS_VERIFY': True} + options = {'--tls': True, '--tlsverify': False} + assert tls_config_from_options(options, environment) is True + + environment['COMPOSE_TLS_VERSION'] = 'TLSv1' + result = tls_config_from_options(options, environment) + assert isinstance(result, docker.tls.TLSConfig) + assert result.ssl_version == ssl.PROTOCOL_TLSv1 + assert result.verify is False + class TestGetTlsVersion(object): def test_get_tls_version_default(self): From b968d34227881dea65a94d5cda8e5ed9dff2aebf Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 22 Jan 2018 14:37:27 -0800 Subject: [PATCH 3245/4072] Advanced merge for deploy dict in v3 files Signed-off-by: Joffrey F --- compose/config/config.py | 45 ++++++++++++++-- compose/config/types.py | 29 ++++++++++ tests/unit/config/config_test.py | 92 ++++++++++++++++++++++++++------ 3 files changed, 147 insertions(+), 19 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 2a6e8437baf..960c3c678bf 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -19,6 +19,7 @@ from ..const import COMPOSEFILE_V3_0 as V3_0 from ..const import COMPOSEFILE_V3_4 as V3_4 from ..utils import build_string_dict +from ..utils import json_hash from ..utils import parse_bytes from ..utils import parse_nanoseconds_int from ..utils import splitdrive @@ -922,10 +923,14 @@ def merge_field(self, field, merge_func, default=None): self.base.get(field, default), self.override.get(field, default)) - def merge_mapping(self, field, parse_func): + def merge_mapping(self, field, parse_func=None): if not self.needs_merge(field): return + if parse_func is None: + def parse_func(m): + return m or {} + self[field] = parse_func(self.base.get(field)) self[field].update(parse_func(self.override.get(field))) @@ -957,7 +962,6 @@ def merge_service_dicts(base, override, version): md.merge_sequence('links', ServiceLink.parse) md.merge_sequence('secrets', types.ServiceSecret.parse) md.merge_sequence('configs', types.ServiceConfig.parse) - md.merge_mapping('deploy', parse_deploy) md.merge_mapping('extra_hosts', parse_extra_hosts) for field in ['volumes', 'devices']: @@ -976,6 +980,7 @@ def merge_service_dicts(base, override, version): merge_ports(md, base, override) md.merge_field('blkio_config', merge_blkio_config, default={}) md.merge_field('healthcheck', merge_healthchecks, default={}) + md.merge_field('deploy', merge_deploy, default={}) for field in set(ALLOWED_KEYS) - set(md): md.merge_scalar(field) @@ -1039,6 +1044,41 @@ def to_dict(service): return dict(md) +def merge_deploy(base, override): + md = MergeDict(base or {}, override or {}) + md.merge_scalar('mode') + md.merge_scalar('endpoint_mode') + md.merge_scalar('replicas') + md.merge_mapping('labels', parse_labels) + md.merge_mapping('update_config') + md.merge_mapping('restart_policy') + if md.needs_merge('resources'): + resources_md = MergeDict(md.base.get('resources') or {}, md.override.get('resources') or {}) + resources_md.merge_mapping('limits') + resources_md.merge_field('reservations', merge_reservations, default={}) + md['resources'] = dict(resources_md) + if md.needs_merge('placement'): + placement_md = MergeDict(md.base.get('placement') or {}, md.override.get('placement') or {}) + placement_md.merge_field('constraints', merge_unique_items_lists, default=[]) + placement_md.merge_field('preferences', merge_unique_objects_lists, default=[]) + md['placement'] = dict(placement_md) + + return dict(md) + + +def merge_reservations(base, override): + md = MergeDict(base, override) + md.merge_scalar('cpus') + md.merge_scalar('memory') + md.merge_sequence('generic_resources', types.GenericResource.parse) + return dict(md) + + +def merge_unique_objects_lists(base, override): + result = dict((json_hash(i), i) for i in base + override) + return [i[1] for i in sorted([(k, v) for k, v in result.items()], key=lambda x: x[0])] + + def merge_blkio_config(base, override): md = MergeDict(base, override) md.merge_scalar('weight') @@ -1125,7 +1165,6 @@ def parse_dict_or_list(split_func, type_name, arguments): parse_depends_on = functools.partial( parse_dict_or_list, lambda k: (k, {'condition': 'service_started'}), 'depends_on' ) -parse_deploy = functools.partial(parse_dict_or_list, split_kv, 'deploy') def parse_flat_dict(d): diff --git a/compose/config/types.py b/compose/config/types.py index 72e68d34550..b896b883f7b 100644 --- a/compose/config/types.py +++ b/compose/config/types.py @@ -413,6 +413,35 @@ def legacy_repr(self): return normalize_port_dict(self.repr()) +class GenericResource(namedtuple('_GenericResource', 'kind value')): + @classmethod + def parse(cls, dct): + if 'discrete_resource_spec' not in dct: + raise ConfigurationError( + 'generic_resource entry must include a discrete_resource_spec key' + ) + if 'kind' not in dct['discrete_resource_spec']: + raise ConfigurationError( + 'generic_resource entry must include a discrete_resource_spec.kind subkey' + ) + return cls( + dct['discrete_resource_spec']['kind'], + dct['discrete_resource_spec'].get('value') + ) + + def repr(self): + return { + 'discrete_resource_spec': { + 'kind': self.kind, + 'value': self.value, + } + } + + @property + def merge_field(self): + return self.kind + + def normalize_port_dict(port): return '{external_ip}{has_ext_ip}{published}{is_pub}{target}/{protocol}'.format( published=port.get('published', ''), diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 7cb74c00a5f..a3308072689 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -33,6 +33,7 @@ from compose.const import COMPOSEFILE_V3_1 as V3_1 from compose.const import COMPOSEFILE_V3_2 as V3_2 from compose.const import COMPOSEFILE_V3_3 as V3_3 +from compose.const import COMPOSEFILE_V3_5 as V3_5 from compose.const import IS_WINDOWS_PLATFORM from tests import mock from tests import unittest @@ -2300,37 +2301,96 @@ def test_merge_deploy(self): def test_merge_deploy_override(self): base = { - 'image': 'busybox', 'deploy': { - 'mode': 'global', - 'restart_policy': { - 'condition': 'on-failure' - }, + 'endpoint_mode': 'vip', + 'labels': ['com.docker.compose.a=1', 'com.docker.compose.b=2'], + 'mode': 'replicated', 'placement': { 'constraints': [ - 'node.role == manager' + 'node.role == manager', 'engine.labels.aws == true' + ], + 'preferences': [ + {'spread': 'node.labels.zone'}, {'spread': 'x.d.z'} ] - } - } + }, + 'replicas': 3, + 'resources': { + 'limits': {'cpus': '0.50', 'memory': '50m'}, + 'reservations': { + 'cpus': '0.1', + 'generic_resources': [ + {'discrete_resource_spec': {'kind': 'abc', 'value': 123}} + ], + 'memory': '15m' + } + }, + 'restart_policy': {'condition': 'any', 'delay': '10s'}, + 'update_config': {'delay': '10s', 'max_failure_ratio': 0.3} + }, + 'image': 'hello-world' } override = { 'deploy': { - 'mode': 'replicated', - 'restart_policy': { - 'condition': 'any' - } + 'labels': { + 'com.docker.compose.b': '21', 'com.docker.compose.c': '3' + }, + 'placement': { + 'constraints': ['node.role == worker', 'engine.labels.dev == true'], + 'preferences': [{'spread': 'node.labels.zone'}, {'spread': 'x.d.s'}] + }, + 'resources': { + 'limits': {'memory': '200m'}, + 'reservations': { + 'cpus': '0.78', + 'generic_resources': [ + {'discrete_resource_spec': {'kind': 'abc', 'value': 134}}, + {'discrete_resource_spec': {'kind': 'xyz', 'value': 0.1}} + ] + } + }, + 'restart_policy': {'condition': 'on-failure', 'max_attempts': 42}, + 'update_config': {'max_failure_ratio': 0.712, 'parallelism': 4} } } - actual = config.merge_service_dicts(base, override, V3_0) + actual = config.merge_service_dicts(base, override, V3_5) assert actual['deploy'] == { 'mode': 'replicated', - 'restart_policy': { - 'condition': 'any' + 'endpoint_mode': 'vip', + 'labels': { + 'com.docker.compose.a': '1', + 'com.docker.compose.b': '21', + 'com.docker.compose.c': '3' }, 'placement': { 'constraints': [ - 'node.role == manager' + 'engine.labels.aws == true', 'engine.labels.dev == true', + 'node.role == manager', 'node.role == worker' + ], + 'preferences': [ + {'spread': 'node.labels.zone'}, {'spread': 'x.d.s'}, {'spread': 'x.d.z'} ] + }, + 'replicas': 3, + 'resources': { + 'limits': {'cpus': '0.50', 'memory': '200m'}, + 'reservations': { + 'cpus': '0.78', + 'memory': '15m', + 'generic_resources': [ + {'discrete_resource_spec': {'kind': 'abc', 'value': 134}}, + {'discrete_resource_spec': {'kind': 'xyz', 'value': 0.1}}, + ] + } + }, + 'restart_policy': { + 'condition': 'on-failure', + 'delay': '10s', + 'max_attempts': 42, + }, + 'update_config': { + 'max_failure_ratio': 0.712, + 'delay': '10s', + 'parallelism': 4 } } From b07091ac5f5ed8657909a30d26fff680fb765e73 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 22 Jan 2018 15:59:52 -0800 Subject: [PATCH 3246/4072] Add up flag `--renew-anon-volumes` (shorthand -V) to avoid reusing the previous container's data Signed-off-by: Joffrey F --- compose/cli/main.py | 15 ++++-- compose/project.py | 6 ++- compose/service.py | 15 +++--- tests/integration/service_test.py | 77 +++++++++++++++++++++++++++++++ 4 files changed, 101 insertions(+), 12 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index de4fbd4306c..380257dbfe5 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -936,7 +936,7 @@ def up(self, options): --always-recreate-deps Recreate dependent containers. Incompatible with --no-recreate. --no-recreate If containers already exist, don't recreate - them. Incompatible with --force-recreate. + them. Incompatible with --force-recreate and -V. --no-build Don't build an image, even if it's missing. --no-start Don't start the services after creating them. --build Build images before starting containers. @@ -945,8 +945,10 @@ def up(self, 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 + -V, --renew-anon-volumes Recreate anonymous volumes instead of retrieving + data from the previous containers. + --remove-orphans Remove containers for services not defined + in the Compose file. --exit-code-from SERVICE Return the exit code of the selected service container. Implies --abort-on-container-exit. --scale SERVICE=NUM Scale SERVICE to NUM instances. Overrides the @@ -992,6 +994,7 @@ def up(rebuild): start=not no_start, always_recreate_deps=always_recreate_deps, reset_container_image=rebuild, + renew_anonymous_volumes=options.get('--renew-anon-volumes') ) try: @@ -1083,10 +1086,14 @@ def compute_exit_code(exit_value_from, attached_containers, cascade_starter, all def convergence_strategy_from_opts(options): no_recreate = options['--no-recreate'] force_recreate = options['--force-recreate'] + renew_anonymous_volumes = options.get('--renew-anon-volumes') if force_recreate and no_recreate: raise UserError("--force-recreate and --no-recreate cannot be combined.") - if force_recreate: + if no_recreate and renew_anonymous_volumes: + raise UserError('--no-recreate and --renew-anon-volumes cannot be combined.') + + if force_recreate or renew_anonymous_volumes: return ConvergenceStrategy.always if no_recreate: diff --git a/compose/project.py b/compose/project.py index 6af4cd94a67..1880f39ad0e 100644 --- a/compose/project.py +++ b/compose/project.py @@ -445,7 +445,8 @@ def up(self, rescale=True, start=True, always_recreate_deps=False, - reset_container_image=False): + reset_container_image=False, + renew_anonymous_volumes=False): self.initialize() if not ignore_orphans: @@ -474,7 +475,8 @@ def do(service): rescale=rescale, start=start, project_services=scaled_services, - reset_container_image=reset_container_image + reset_container_image=reset_container_image, + renew_anonymous_volumes=renew_anonymous_volumes, ) def get_deps(service): diff --git a/compose/service.py b/compose/service.py index 4e236351184..0e147194f7c 100644 --- a/compose/service.py +++ b/compose/service.py @@ -409,7 +409,8 @@ def create_and_start(service, n): return containers - def _execute_convergence_recreate(self, containers, scale, timeout, detached, start): + def _execute_convergence_recreate(self, containers, scale, timeout, detached, start, + renew_anonymous_volumes): if scale is not None and len(containers) > scale: self._downscale(containers[scale:], timeout) containers = containers[:scale] @@ -417,7 +418,7 @@ def _execute_convergence_recreate(self, containers, scale, timeout, detached, st def recreate(container): return self.recreate_container( container, timeout=timeout, attach_logs=not detached, - start_new_container=start + start_new_container=start, renew_anonymous_volumes=renew_anonymous_volumes ) containers, errors = parallel_execute( containers, @@ -470,7 +471,7 @@ def stop_and_remove(container): def execute_convergence_plan(self, plan, timeout=None, detached=False, start=True, scale_override=None, rescale=True, project_services=None, - reset_container_image=False): + reset_container_image=False, renew_anonymous_volumes=False): (action, containers) = plan scale = scale_override if scale_override is not None else self.scale_num containers = sorted(containers, key=attrgetter('number')) @@ -495,7 +496,8 @@ def execute_convergence_plan(self, plan, timeout=None, detached=False, for c in containers: c.reset_image(img_id) return self._execute_convergence_recreate( - containers, scale, timeout, detached, start + containers, scale, timeout, detached, start, + renew_anonymous_volumes, ) if action == 'start': @@ -515,7 +517,8 @@ def execute_convergence_plan(self, plan, timeout=None, detached=False, raise Exception("Invalid action: {}".format(action)) - def recreate_container(self, container, timeout=None, attach_logs=False, start_new_container=True): + def recreate_container(self, container, timeout=None, attach_logs=False, start_new_container=True, + renew_anonymous_volumes=False): """Recreate a container. The original container is renamed to a temporary name so that data @@ -526,7 +529,7 @@ def recreate_container(self, container, timeout=None, attach_logs=False, start_n container.stop(timeout=self.stop_timeout(timeout)) container.rename_to_tmp_name() new_container = self.create_container( - previous_container=container, + previous_container=container if not renew_anonymous_volumes else None, number=container.labels.get(LABEL_CONTAINER_NUMBER), quiet=True, ) diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index a6efc24a99d..e00ae433d32 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -589,6 +589,25 @@ def test_execute_convergence_plan_with_image_declared_volume(self): assert [mount['Destination'] for mount in new_container.get('Mounts')] == ['/data'] assert new_container.get_mount('/data')['Source'] == volume_path + def test_execute_convergence_plan_with_image_declared_volume_renew(self): + service = Service( + project='composetest', + name='db', + client=self.client, + build={'context': 'tests/fixtures/dockerfile-with-volume'}, + ) + + old_container = create_and_start_container(service) + assert [mount['Destination'] for mount in old_container.get('Mounts')] == ['/data'] + volume_path = old_container.get_mount('/data')['Source'] + + new_container, = service.execute_convergence_plan( + ConvergencePlan('recreate', [old_container]), renew_anonymous_volumes=True + ) + + assert [mount['Destination'] for mount in new_container.get('Mounts')] == ['/data'] + assert new_container.get_mount('/data')['Source'] != volume_path + def test_execute_convergence_plan_when_image_volume_masks_config(self): service = self.create_service( 'db', @@ -637,6 +656,64 @@ def test_execute_convergence_plan_when_host_volume_is_removed(self): ) assert new_container.get_mount('/data')['Source'] != host_path + def test_execute_convergence_plan_anonymous_volume_renew(self): + service = self.create_service( + 'db', + image='busybox', + volumes=[VolumeSpec(None, '/data', 'rw')]) + + old_container = create_and_start_container(service) + assert ( + [mount['Destination'] for mount in old_container.get('Mounts')] == + ['/data'] + ) + volume_path = old_container.get_mount('/data')['Source'] + + new_container, = service.execute_convergence_plan( + ConvergencePlan('recreate', [old_container]), + renew_anonymous_volumes=True + ) + + assert ( + [mount['Destination'] for mount in new_container.get('Mounts')] == + ['/data'] + ) + assert new_container.get_mount('/data')['Source'] != volume_path + + def test_execute_convergence_plan_anonymous_volume_recreate_then_renew(self): + service = self.create_service( + 'db', + image='busybox', + volumes=[VolumeSpec(None, '/data', 'rw')]) + + old_container = create_and_start_container(service) + assert ( + [mount['Destination'] for mount in old_container.get('Mounts')] == + ['/data'] + ) + volume_path = old_container.get_mount('/data')['Source'] + + mid_container, = service.execute_convergence_plan( + ConvergencePlan('recreate', [old_container]), + ) + + assert ( + [mount['Destination'] for mount in mid_container.get('Mounts')] == + ['/data'] + ) + assert mid_container.get_mount('/data')['Source'] == volume_path + + new_container, = service.execute_convergence_plan( + ConvergencePlan('recreate', [mid_container]), + renew_anonymous_volumes=True + ) + + assert ( + [mount['Destination'] for mount in new_container.get('Mounts')] == + ['/data'] + ) + assert new_container.get_mount('/data')['Source'] != volume_path + def test_execute_convergence_plan_without_start(self): service = self.create_service( 'db', From 8b5d32373e55dbf9d041c6b9c379900c94b9a6b9 Mon Sep 17 00:00:00 2001 From: Carl George Date: Mon, 22 Jan 2018 23:10:50 -0600 Subject: [PATCH 3247/4072] Use mock compatibility import `tests/__init__.py` already has a try/except statement to use mock from the standard library when possible. Take advantage of it like other tests already do. Signed-off-by: Carl George --- tests/unit/bundle_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/bundle_test.py b/tests/unit/bundle_test.py index 8477952022c..88f75405a7b 100644 --- a/tests/unit/bundle_test.py +++ b/tests/unit/bundle_test.py @@ -2,9 +2,9 @@ from __future__ import unicode_literals import docker -import mock import pytest +from .. import mock from compose import bundle from compose import service from compose.cli.errors import UserError From c5154d6b2be15b3b12b393db9ff03ae77b32947b Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 23 Jan 2018 15:25:25 -0800 Subject: [PATCH 3248/4072] Don't break during recreate when a mount target is updated Signed-off-by: Joffrey F --- compose/service.py | 3 +-- tests/integration/service_test.py | 22 ++++++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/compose/service.py b/compose/service.py index 0e147194f7c..b1f7d707b34 100644 --- a/compose/service.py +++ b/compose/service.py @@ -1322,7 +1322,6 @@ def get_container_data_volumes(container, volumes_option, tmpfs_option, mounts_o a mapping of volume bindings for those volumes. Anonymous volume mounts are updated in place instead. """ - volumes = [] volumes_option = volumes_option or [] @@ -1366,7 +1365,7 @@ def get_container_data_volumes(container, volumes_option, tmpfs_option, mounts_o continue ctnr_mount = container_mounts.get(mount.target) - if not ctnr_mount.get('Name'): + if not ctnr_mount or not ctnr_mount.get('Name'): continue mount.source = ctnr_mount['Name'] diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index e00ae433d32..c12724c85ec 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -487,6 +487,28 @@ def test_execute_convergence_plan_recreate(self): with pytest.raises(APIError): self.client.inspect_container(old_container.id) + def test_execute_convergence_plan_recreate_change_mount_target(self): + service = self.create_service( + 'db', + volumes=[MountSpec(target='/app1', type='volume')], + entrypoint=['top'], command=['-d', '1'] + ) + old_container = create_and_start_container(service) + assert ( + [mount['Destination'] for mount in old_container.get('Mounts')] == + ['/app1'] + ) + service.options['volumes'] = [MountSpec(target='/app2', type='volume')] + + new_container, = service.execute_convergence_plan( + ConvergencePlan('recreate', [old_container]) + ) + + assert ( + [mount['Destination'] for mount in new_container.get('Mounts')] == + ['/app2'] + ) + def test_execute_convergence_plan_recreate_twice(self): service = self.create_service( 'db', From b27c2453956209225f2e89dd9f5da8d0ca9086e2 Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Wed, 24 Jan 2018 23:07:27 +0100 Subject: [PATCH 3249/4072] Add bash completion for `up {--always-recreate-deps,--renew-anon-volumes}` 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 017b0192f1a..faa537cb35d 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -550,7 +550,7 @@ _docker_compose_up() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "--abort-on-container-exit --build -d --exit-code-from --force-recreate --help --no-build --no-color --no-deps --no-recreate --no-start --remove-orphans --scale --timeout -t" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--abort-on-container-exit --always-recreate-deps --build -d --exit-code-from --force-recreate --help --no-build --no-color --no-deps --no-recreate --no-start --renew-anon-volumes -V --remove-orphans --scale --timeout -t" -- "$cur" ) ) ;; *) __docker_compose_services_all From e09ea2f0f0dc450f1d91cc5b8c1c8283a15220d9 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 24 Jan 2018 11:49:56 -0800 Subject: [PATCH 3250/4072] Add circleCI build/test config for Mac OSX Signed-off-by: Joffrey F --- .circleci/config.yml | 62 ++++++++++++++++++++++++++ .travis.yml | 30 ------------- script/circle/bintray-deploy.sh | 27 +++++++++++ script/setup/osx | 34 -------------- script/travis/bintray.json.tmpl | 29 ------------ script/travis/build-binary | 13 ------ script/travis/ci | 10 ----- script/travis/install | 10 ----- script/travis/render-bintray-config.py | 13 ------ 9 files changed, 89 insertions(+), 139 deletions(-) create mode 100644 .circleci/config.yml delete mode 100644 .travis.yml create mode 100755 script/circle/bintray-deploy.sh delete mode 100644 script/travis/bintray.json.tmpl delete mode 100755 script/travis/build-binary delete mode 100755 script/travis/ci delete mode 100755 script/travis/install delete mode 100755 script/travis/render-bintray-config.py diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 00000000000..11239e25f67 --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,62 @@ +version: 2 +jobs: + test: + macos: + xcode: "8.3.3" + steps: + - checkout +# - run: +# name: install python3 +# command: brew install python3 + - run: + name: install tox + command: sudo pip install --upgrade tox==2.1.1 + - run: + name: unit tests + command: tox -e py27 -- tests/unit + + build-osx-binary: + macos: + xcode: "8.3.3" + steps: + - checkout + - run: + name: upgrade python tools + command: sudo pip install --upgrade pip virtualenv + - run: + name: setup script + command: ./script/setup/osx + - run: + name: build script + command: ./script/build/osx + - store_artifacts: + path: dist/docker-compose-Darwin-x86_64 + destination: docker-compose-Darwin-x86_64 + - deploy: + name: Deploy binary to bintray + command: | + OS_NAME=Darwin PKG_NAME=osx ./script/circle/bintray-deploy.sh + + + build-linux-binary: + machine: + enabled: true + steps: + - checkout + - run: + name: build Linux binary + command: ./script/build/linux + - store_artifacts: + path: dist/docker-compose-Linux-x86_64 + destination: docker-compose-Linux-x86_64 + - deploy: + name: Deploy binary to bintray + command: | + OS_NAME=Linux PKG_NAME=linux ./script/circle/bintray-deploy.sh +workflows: + version: 2 + all: + jobs: + - test + - build-linux-binary + - build-osx-binary diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 8fef7ed1bdf..00000000000 --- a/.travis.yml +++ /dev/null @@ -1,30 +0,0 @@ -sudo: required - -language: python - -matrix: - include: - - os: linux - services: - - docker - - os: osx - osx_image: xcode7.3 - language: generic - -install: ./script/travis/install - -script: - - ./script/travis/ci - - ./script/travis/build-binary - -before_deploy: - - "./script/travis/render-bintray-config.py < ./script/travis/bintray.json.tmpl > ./bintray.json" - -deploy: - provider: bintray - user: docker-compose-roleuser - key: '$BINTRAY_API_KEY' - file: ./bintray.json - skip_cleanup: true - on: - all_branches: true diff --git a/script/circle/bintray-deploy.sh b/script/circle/bintray-deploy.sh new file mode 100755 index 00000000000..d508da36563 --- /dev/null +++ b/script/circle/bintray-deploy.sh @@ -0,0 +1,27 @@ +#!/bin/bash + +curl -f -u$BINTRAY_USERNAME:$BINTRAY_API_KEY -X GET \ + https://api.bintray.com/repos/docker-compose/${CIRCLE_BRANCH} + +if test $? -ne 0; then + echo "Bintray repository ${CIRCLE_BRANCH} does not exist ; abandoning upload attempt" + exit 0 +fi + +curl -u$BINTRAY_USERNAME:$BINTRAY_API_KEY -X POST \ + -d "{\ + \"name\": \"${PKG_NAME}\", \"desc\": \"auto\", \"licenses\": [\"Apache-2.0\"], \ + \"vcs_url\": \"${CIRCLE_REPOSITORY_URL}\" \ + }" -H "Content-Type: application/json" \ + https://api.bintray.com/packages/docker-compose/${CIRCLE_BRANCH} + +curl -u$BINTRAY_USERNAME:$BINTRAY_API_KEY -X POST -d "{\ + \"name\": \"$CIRCLE_BRANCH\", \ + \"desc\": \"Automated build of the ${CIRCLE_BRANCH} branch.\", \ + }" -H "Content-Type: application/json" \ + https://api.bintray.com/packages/docker-compose/${CIRCLE_BRANCH}/${PKG_NAME}/versions + +curl -f -T dist/docker-compose-${OS_NAME}-x86_64 -u$BINTRAY_USERNAME:$BINTRAY_API_KEY \ + -H "X-Bintray-Package: ${PKG_NAME}" -H "X-Bintray-Version: $CIRCLE_BRANCH" \ + -H "X-Bintray-Override: 1" -H "X-Bintray-Publish: 1" -X PUT \ + https://api.bintray.com/content/docker-compose/${CIRCLE_BRANCH}/docker-compose-${OS_NAME}-x86_64 || exit 1 diff --git a/script/setup/osx b/script/setup/osx index e0c2bd0a24e..407524cba16 100755 --- a/script/setup/osx +++ b/script/setup/osx @@ -10,40 +10,6 @@ openssl_version() { python -c "import ssl; print ssl.OPENSSL_VERSION" } -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.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" - -if !(which brew); then - ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)" -fi - -brew update > /dev/null - -if !(python_version | grep "$desired_python_version"); then - if brew list | grep python; then - brew unlink python - fi - - brew install "$python_formula" - brew switch python "$desired_python_brew_version" -fi - -if !(openssl_version | grep "$desired_openssl_version"); then - if brew list | grep openssl; then - brew unlink openssl - fi - - brew install "$openssl_formula" - brew switch openssl "$desired_openssl_brew_version" -fi - echo "*** Using $(python_version)" echo "*** Using $(openssl_version)" diff --git a/script/travis/bintray.json.tmpl b/script/travis/bintray.json.tmpl deleted file mode 100644 index f9728558a61..00000000000 --- a/script/travis/bintray.json.tmpl +++ /dev/null @@ -1,29 +0,0 @@ -{ - "package": { - "name": "${TRAVIS_OS_NAME}", - "repo": "${TRAVIS_BRANCH}", - "subject": "docker-compose", - "desc": "Automated build of master branch from travis ci.", - "website_url": "https://github.com/docker/compose", - "issue_tracker_url": "https://github.com/docker/compose/issues", - "vcs_url": "https://github.com/docker/compose.git", - "licenses": ["Apache-2.0"] - }, - - "version": { - "name": "${TRAVIS_BRANCH}", - "desc": "Automated build of the ${TRAVIS_BRANCH} branch.", - "released": "${DATE}", - "vcs_tag": "master" - }, - - "files": [ - { - "includePattern": "dist/(.*)", - "excludePattern": ".*\.tar.gz", - "uploadPattern": "$1", - "matrixParams": { "override": 1 } - } - ], - "publish": true -} diff --git a/script/travis/build-binary b/script/travis/build-binary deleted file mode 100755 index 7707a1eee82..00000000000 --- a/script/travis/build-binary +++ /dev/null @@ -1,13 +0,0 @@ -#!/bin/bash - -set -ex - -if [[ "$TRAVIS_OS_NAME" == "linux" ]]; then - script/build/linux - # TODO: requires auth to push, so disable for now - # script/build/image master - # docker push docker/compose:master -else - script/setup/osx - script/build/osx -fi diff --git a/script/travis/ci b/script/travis/ci deleted file mode 100755 index cd4fcc6d1bf..00000000000 --- a/script/travis/ci +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/bash - -set -e - -if [[ "$TRAVIS_OS_NAME" == "linux" ]]; then - tox -e py27,py34 -- tests/unit -else - # TODO: we could also install py34 and test against it - tox -e py27 -- tests/unit -fi diff --git a/script/travis/install b/script/travis/install deleted file mode 100755 index d4b34786cf3..00000000000 --- a/script/travis/install +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/bash - -set -ex - -if [[ "$TRAVIS_OS_NAME" == "linux" ]]; then - pip install tox==2.1.1 -else - sudo pip install --upgrade pip tox==2.1.1 virtualenv - pip --version -fi diff --git a/script/travis/render-bintray-config.py b/script/travis/render-bintray-config.py deleted file mode 100755 index b5364a0b6c5..00000000000 --- a/script/travis/render-bintray-config.py +++ /dev/null @@ -1,13 +0,0 @@ -#!/usr/bin/env python -from __future__ import absolute_import -from __future__ import print_function -from __future__ import unicode_literals - -import datetime -import os.path -import sys - -os.environ['DATE'] = str(datetime.date.today()) - -for line in sys.stdin: - print(os.path.expandvars(line), end='') From d149ccd3125604fd7aebba307c1b97a1b1070090 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 23 Jan 2018 11:02:16 -0800 Subject: [PATCH 3251/4072] Bump 1.19.0-rc1 Signed-off-by: Joffrey F --- CHANGELOG.md | 87 +++++++++++++++++++++++++++++++++++++++++++++ compose/__init__.py | 2 +- script/run/run.sh | 2 +- 3 files changed, 89 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c0b3b5653af..b6217fdcd66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,93 @@ Change log ========== +1.19.0 (2018-01-31) +------------------- + +### Breaking changes + +- On UNIX platforms, interactive `run` and `exec` commands now require + the `docker` CLI to be installed on the client by default. To revert + to the previous behavior, users may set the `COMPOSE_INTERACTIVE_NO_CLI` + environment variable. + +### New features + +#### Compose file version 3.x + +- The output of the `config` command should now merge `deploy` options from + several Compose files in a more accurate manner + +#### Compose file version 2.3 + +- Added support for the `runtime` option in service definitions + +#### Compose file version 2.1 and up + +- Added support for the `${VAR:?err}` and `${VAR?err}` variable interpolation + syntax to indicate mandatory variables + +#### Compose file version 2.x + +- Added `priority` key to service network mappings, allowing the user to + define in which order the specified service will connect to each network + +#### All formats + +- Added `--renew-anon-volumes` (shorthand `-V`) to the `up` command, + preventing Compose from recovering volume data from previous containers for + anonymous volumes + +- Added limit for number of simulatenous parallel operations, which should + prevent accidental resource exhaustion of the server. Default is 64 and + can be configured using the `COMPOSE_PARALLEL_LIMIT` environment variable + +- Added `--always-recreate-deps` flag to the `up` command to force recreating + dependent services along with the dependency owner + +- Added `COMPOSE_IGNORE_ORPHANS` environment variable to forgo orphan + container detection and suppress warnings + +- Added `COMPOSE_FORCE_WINDOWS_HOST` environment variable to force Compose + to parse volume definitions as if the Docker host was a Windows system, + even if Compose itself is currently running on UNIX + +- Bash completion should now be able to better differentiate between running, + stopped and paused services + +### Bugfixes + +- Fixed a bug that would cause the `build` command to report a connection + error when the build context contained unreadable files or FIFO objects. + These file types will now be handled appropriately + +- Fixed various issues around interactive `run`/`exec` sessions. + +- Fixed a bug where setting TLS options with environment and CLI flags + simultaneously would result in part of the configuration being ignored + +- Fixed a bug where the `-d` and `--timeout` flags in `up` were erroneously + marked as incompatible + +- Fixed a bug where the recreation of a service would break if the image + associated with the previous container had been removed + +- Fixed a bug where `tmpfs` volumes declared using the extended syntax in + Compose files using version 3.2 would be erroneously created as anonymous + volumes instead + +- Fixed a bug where type conversion errors would print a stacktrace instead + of exiting gracefully + +- Fixed some errors related to unicode handling + +- Dependent services no longer get recreated along with the dependency owner + if their configuration hasn't changed + +- Added better validation of `labels` fields in Compose files. Label values + containing scalar types (number, boolean) now get automatically converted + to strings + 1.18.0 (2017-12-15) ------------------- diff --git a/compose/__init__.py b/compose/__init__.py index 60a987ca61e..7e19bd4629a 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.19.0dev' +__version__ = '1.19.0-rc1' diff --git a/script/run/run.sh b/script/run/run.sh index abb4ff4feea..8cf59fc3304 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.18.0" +VERSION="1.19.0-rc1" IMAGE="docker/compose:$VERSION" From 5d1554c9dfd1096ecbb6ede5f92148e605d5989b Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 25 Jan 2018 17:50:20 -0800 Subject: [PATCH 3252/4072] Trigger remote build for OSX 10.11 Signed-off-by: Joffrey F --- .circleci/config.yml | 38 ++++++++++++++++++++++++++++++++++---- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 11239e25f67..748cbdd0d64 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -32,10 +32,10 @@ jobs: - store_artifacts: path: dist/docker-compose-Darwin-x86_64 destination: docker-compose-Darwin-x86_64 - - deploy: - name: Deploy binary to bintray - command: | - OS_NAME=Darwin PKG_NAME=osx ./script/circle/bintray-deploy.sh + # - deploy: + # name: Deploy binary to bintray + # command: | + # OS_NAME=Darwin PKG_NAME=osx ./script/circle/bintray-deploy.sh build-linux-binary: @@ -53,6 +53,30 @@ jobs: name: Deploy binary to bintray command: | OS_NAME=Linux PKG_NAME=linux ./script/circle/bintray-deploy.sh + + trigger-osx-binary-deploy: + # We use a separate repo to build OSX binaries meant for distribution + # with support for OSSX 10.11 (xcode 7). This job triggers a build on + # that repo. + docker: + - image: alpine:3.6 + + steps: + - run: + name: install curl + command: apk update && apk add curl + + - run: + name: API trigger + command: | + curl -X POST -H "Content-Type: application/json" \ + -u ${OSX_RELEASE_TOKEN} -d "{\ + \"build_parameters\": {\ + \"COMPOSE_BRANCH\": \"${CIRCLE_BRANCH}\"\ + }\ + }" https://circleci.com/api/v1.1/project/github/docker/compose-osx-release + + workflows: version: 2 all: @@ -60,3 +84,9 @@ workflows: - test - build-linux-binary - build-osx-binary + - trigger-osx-binary-deploy: + filters: + branches: + only: + - master + - /bump-.*/ From b9939c97e576c1a24e1bbd907e8807ed6eed76e9 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 25 Jan 2018 18:10:56 -0800 Subject: [PATCH 3253/4072] Don't leak circle API auth info Signed-off-by: Joffrey F --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 748cbdd0d64..4be1557b053 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -74,7 +74,7 @@ jobs: \"build_parameters\": {\ \"COMPOSE_BRANCH\": \"${CIRCLE_BRANCH}\"\ }\ - }" https://circleci.com/api/v1.1/project/github/docker/compose-osx-release + }" https://circleci.com/api/v1.1/project/github/docker/compose-osx-release > /dev/null workflows: From 520fad331fa498862ebb3baf928820cb37b10aa6 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 25 Jan 2018 18:13:49 -0800 Subject: [PATCH 3254/4072] Circle token Signed-off-by: Joffrey F --- .circleci/config.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 4be1557b053..4ac6d413560 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -69,12 +69,12 @@ jobs: - run: name: API trigger command: | - curl -X POST -H "Content-Type: application/json" \ - -u ${OSX_RELEASE_TOKEN} -d "{\ + curl -X POST -H "Content-Type: application/json" -d "{\ \"build_parameters\": {\ \"COMPOSE_BRANCH\": \"${CIRCLE_BRANCH}\"\ }\ - }" https://circleci.com/api/v1.1/project/github/docker/compose-osx-release > /dev/null + }" https://circleci.com/api/v1.1/project/github/docker/compose-osx-release?circle-token=${OSX_RELEASE_TOKEN} \ + > /dev/null workflows: From 0f41e07a5c004f975ca3c67d9c4d901301eb147b Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Wed, 24 Jan 2018 23:07:27 +0100 Subject: [PATCH 3255/4072] Add bash completion for `up {--always-recreate-deps,--renew-anon-volumes}` 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 017b0192f1a..faa537cb35d 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -550,7 +550,7 @@ _docker_compose_up() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "--abort-on-container-exit --build -d --exit-code-from --force-recreate --help --no-build --no-color --no-deps --no-recreate --no-start --remove-orphans --scale --timeout -t" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--abort-on-container-exit --always-recreate-deps --build -d --exit-code-from --force-recreate --help --no-build --no-color --no-deps --no-recreate --no-start --renew-anon-volumes -V --remove-orphans --scale --timeout -t" -- "$cur" ) ) ;; *) __docker_compose_services_all From 70ad55cd885afa2ac8bfba1e5b26799d6219d9e8 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 25 Jan 2018 17:50:20 -0800 Subject: [PATCH 3256/4072] Trigger remote build for OSX 10.11 Signed-off-by: Joffrey F --- .circleci/config.yml | 38 ++++++++++++++++++++++++++++++++++---- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 11239e25f67..748cbdd0d64 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -32,10 +32,10 @@ jobs: - store_artifacts: path: dist/docker-compose-Darwin-x86_64 destination: docker-compose-Darwin-x86_64 - - deploy: - name: Deploy binary to bintray - command: | - OS_NAME=Darwin PKG_NAME=osx ./script/circle/bintray-deploy.sh + # - deploy: + # name: Deploy binary to bintray + # command: | + # OS_NAME=Darwin PKG_NAME=osx ./script/circle/bintray-deploy.sh build-linux-binary: @@ -53,6 +53,30 @@ jobs: name: Deploy binary to bintray command: | OS_NAME=Linux PKG_NAME=linux ./script/circle/bintray-deploy.sh + + trigger-osx-binary-deploy: + # We use a separate repo to build OSX binaries meant for distribution + # with support for OSSX 10.11 (xcode 7). This job triggers a build on + # that repo. + docker: + - image: alpine:3.6 + + steps: + - run: + name: install curl + command: apk update && apk add curl + + - run: + name: API trigger + command: | + curl -X POST -H "Content-Type: application/json" \ + -u ${OSX_RELEASE_TOKEN} -d "{\ + \"build_parameters\": {\ + \"COMPOSE_BRANCH\": \"${CIRCLE_BRANCH}\"\ + }\ + }" https://circleci.com/api/v1.1/project/github/docker/compose-osx-release + + workflows: version: 2 all: @@ -60,3 +84,9 @@ workflows: - test - build-linux-binary - build-osx-binary + - trigger-osx-binary-deploy: + filters: + branches: + only: + - master + - /bump-.*/ From 14485b306cdbe7b90d732ca1f411d7079f9b3d50 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 25 Jan 2018 18:10:56 -0800 Subject: [PATCH 3257/4072] Don't leak circle API auth info Signed-off-by: Joffrey F --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 748cbdd0d64..4be1557b053 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -74,7 +74,7 @@ jobs: \"build_parameters\": {\ \"COMPOSE_BRANCH\": \"${CIRCLE_BRANCH}\"\ }\ - }" https://circleci.com/api/v1.1/project/github/docker/compose-osx-release + }" https://circleci.com/api/v1.1/project/github/docker/compose-osx-release > /dev/null workflows: From cc2f0237473e0ab898a5febef91a69f616971449 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 25 Jan 2018 18:13:49 -0800 Subject: [PATCH 3258/4072] Circle token Signed-off-by: Joffrey F --- .circleci/config.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 4be1557b053..4ac6d413560 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -69,12 +69,12 @@ jobs: - run: name: API trigger command: | - curl -X POST -H "Content-Type: application/json" \ - -u ${OSX_RELEASE_TOKEN} -d "{\ + curl -X POST -H "Content-Type: application/json" -d "{\ \"build_parameters\": {\ \"COMPOSE_BRANCH\": \"${CIRCLE_BRANCH}\"\ }\ - }" https://circleci.com/api/v1.1/project/github/docker/compose-osx-release > /dev/null + }" https://circleci.com/api/v1.1/project/github/docker/compose-osx-release?circle-token=${OSX_RELEASE_TOKEN} \ + > /dev/null workflows: From a186c3142b80d99c62995f6f36e64867ed883701 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 25 Jan 2018 18:24:39 -0800 Subject: [PATCH 3259/4072] Bump 1.19.0-rc2 Signed-off-by: Joffrey F --- CHANGELOG.md | 3 +++ compose/__init__.py | 2 +- script/run/run.sh | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6217fdcd66..3c3595d9772 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,6 +72,9 @@ Change log - Fixed a bug where the recreation of a service would break if the image associated with the previous container had been removed +- Fixed a bug where updating a mount's target would break Compose when + trying to recreate the associated service + - Fixed a bug where `tmpfs` volumes declared using the extended syntax in Compose files using version 3.2 would be erroneously created as anonymous volumes instead diff --git a/compose/__init__.py b/compose/__init__.py index 7e19bd4629a..bc5cff57585 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.19.0-rc1' +__version__ = '1.19.0-rc2' diff --git a/script/run/run.sh b/script/run/run.sh index 8cf59fc3304..e45067b3cb6 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.19.0-rc1" +VERSION="1.19.0-rc2" IMAGE="docker/compose:$VERSION" From 6cfbb7ed8a4a9ac022144465c9544f92ab6357b5 Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Tue, 30 Jan 2018 00:57:07 +0100 Subject: [PATCH 3260/4072] Add missing `-q|--quiet` options to increase consistency Signed-off-by: Harald Albers --- compose/cli/main.py | 14 +++++++------- contrib/completion/bash/docker-compose | 6 +++--- tests/acceptance/cli_test.py | 4 ++-- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 380257dbfe5..6f8cc47c808 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -503,14 +503,14 @@ def images(self, options): Usage: images [options] [SERVICE...] Options: - -q Only display IDs + -q, --quiet Only display IDs """ containers = sorted( self.project.containers(service_names=options['SERVICE'], stopped=True) + self.project.containers(service_names=options['SERVICE'], one_off=OneOffFilter.only), key=attrgetter('name')) - if options['-q']: + if options['--quiet']: for image in set(c.image for c in containers): print(image.split(':')[1]) else: @@ -624,12 +624,12 @@ def ps(self, options): Usage: ps [options] [SERVICE...] Options: - -q Only display IDs + -q, --quiet Only display IDs --services Display services --filter KEY=VAL Filter services by a property """ - if options['-q'] and options['--services']: - raise UserError('-q and --services cannot be combined') + if options['--quiet'] and options['--services']: + raise UserError('--quiet and --services cannot be combined') if options['--services']: filt = build_filter(options.get('--filter')) @@ -644,7 +644,7 @@ def ps(self, options): self.project.containers(service_names=options['SERVICE'], one_off=OneOffFilter.only), key=attrgetter('name')) - if options['-q']: + if options['--quiet']: for container in containers: print(container.id) else: @@ -676,7 +676,7 @@ def pull(self, options): Options: --ignore-pull-failures Pull what it can and ignores images with pull failures. --parallel Pull multiple images in parallel. - --quiet Pull without printing progress information + -q, --quiet Pull without printing progress information """ self.project.pull( service_names=options['SERVICE'], diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index faa537cb35d..98e1d6c0d14 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -259,7 +259,7 @@ _docker_compose_help() { _docker_compose_images() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "--help -q" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--help --quiet -q" -- "$cur" ) ) ;; *) __docker_compose_services_all @@ -361,7 +361,7 @@ _docker_compose_ps() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "--help -q --services --filter" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--help --quiet -q --services --filter" -- "$cur" ) ) ;; *) __docker_compose_services_all @@ -373,7 +373,7 @@ _docker_compose_ps() { _docker_compose_pull() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "--help --ignore-pull-failures --parallel --quiet" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--help --ignore-pull-failures --parallel --quiet -q" -- "$cur" ) ) ;; *) __docker_compose_services_from_image diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index ade7d10a9d3..8fb6dffec4a 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -207,13 +207,13 @@ def test_config_quiet_with_error(self): self.base_dir = None result = self.dispatch([ '-f', 'tests/fixtures/invalid-composefile/invalid.yml', - 'config', '-q' + 'config', '--quiet' ], returncode=1) assert "'notaservice' must be a mapping" in result.stderr def test_config_quiet(self): self.base_dir = 'tests/fixtures/v2-full' - assert self.dispatch(['config', '-q']).stdout == '' + assert self.dispatch(['config', '--quiet']).stdout == '' def test_config_default(self): self.base_dir = 'tests/fixtures/v2-full' From d8d484e0e19db5326afeb4cdf56864eceb81566c Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 31 Jan 2018 18:54:58 -0800 Subject: [PATCH 3261/4072] Bump python SDK to 3.0.0 Signed-off-by: Joffrey F --- compose/container.py | 2 +- compose/service.py | 1 - requirements.txt | 2 +- setup.py | 2 +- tests/helpers.py | 2 +- tests/unit/service_test.py | 2 -- 6 files changed, 4 insertions(+), 7 deletions(-) diff --git a/compose/container.py b/compose/container.py index 4ab99ffa8aa..9323b119249 100644 --- a/compose/container.py +++ b/compose/container.py @@ -243,7 +243,7 @@ def inspect_if_not_inspected(self): self.inspect() def wait(self): - return self.client.wait(self.id) + return self.client.wait(self.id).get('StatusCode', 127) def logs(self, *args, **kwargs): return self.client.logs(self.id, *args, **kwargs) diff --git a/compose/service.py b/compose/service.py index b1f7d707b34..b3d91113589 100644 --- a/compose/service.py +++ b/compose/service.py @@ -972,7 +972,6 @@ def build(self, no_cache=False, pull=False, force_rm=False, memory=None, build_a build_output = self.client.build( path=path, tag=self.image_name, - stream=True, rm=True, forcerm=force_rm, pull=pull, diff --git a/requirements.txt b/requirements.txt index bc483b4b725..100e7211791 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ backports.ssl-match-hostname==3.5.0.1; python_version < '3' cached-property==1.3.0 certifi==2017.4.17 chardet==3.0.4 -docker==2.7.0 +docker==3.0.0 docker-pycreds==0.2.1 dockerpty==0.4.1 docopt==0.6.2 diff --git a/setup.py b/setup.py index a75e0cb7f3d..a85bcdf7246 100644 --- a/setup.py +++ b/setup.py @@ -36,7 +36,7 @@ def find_version(*file_paths): 'requests >= 2.6.1, != 2.11.0, != 2.12.2, != 2.18.0, < 2.19', 'texttable >= 0.9.0, < 0.10', 'websocket-client >= 0.32.0, < 1.0', - 'docker >= 2.7.0, < 3.0', + 'docker >= 3.0.0, < 4.0', 'dockerpty >= 0.4.1, < 0.5', 'six >= 1.3.0, < 2', 'jsonschema >= 2.5.1, < 3', diff --git a/tests/helpers.py b/tests/helpers.py index f151f9cde40..dd129981194 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -32,7 +32,7 @@ def create_custom_host_file(client, filename, content): ) try: client.start(container) - exitcode = client.wait(container) + exitcode = client.wait(container)['StatusCode'] if exitcode != 0: output = client.logs(container) diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 92d7f08d5a3..21bac8b83c1 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -470,7 +470,6 @@ def test_create_container(self): self.mock_client.build.assert_called_once_with( tag='default_foo', dockerfile=None, - stream=True, path='.', pull=False, forcerm=False, @@ -513,7 +512,6 @@ def test_ensure_image_exists_force_build(self): self.mock_client.build.assert_called_once_with( tag='default_foo', dockerfile=None, - stream=True, path='.', pull=False, forcerm=False, From 4d4e066fbc605b75996ea135139f13f3b1853712 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 1 Feb 2018 12:16:32 -0800 Subject: [PATCH 3262/4072] Fix DOCKER_TLS_VERIFY bug Signed-off-by: Joffrey F --- compose/cli/docker_client.py | 9 +++++-- tests/unit/cli/docker_client_test.py | 40 +++++++++++++++++++++++----- 2 files changed, 41 insertions(+), 8 deletions(-) diff --git a/compose/cli/docker_client.py b/compose/cli/docker_client.py index a581ae67285..818fe63addd 100644 --- a/compose/cli/docker_client.py +++ b/compose/cli/docker_client.py @@ -10,6 +10,7 @@ from docker.tls import TLSConfig from docker.utils import kwargs_from_env +from ..config.environment import Environment from ..const import HTTP_TIMEOUT from .errors import UserError from .utils import generate_user_agent @@ -36,14 +37,18 @@ def get_tls_version(environment): def tls_config_from_options(options, environment=None): - environment = environment or {} + environment = environment or Environment() cert_path = environment.get('DOCKER_CERT_PATH') or None tls = options.get('--tls', False) ca_cert = unquote_path(options.get('--tlscacert')) cert = unquote_path(options.get('--tlscert')) key = unquote_path(options.get('--tlskey')) - verify = options.get('--tlsverify', environment.get('DOCKER_TLS_VERIFY')) + # verify is a special case - with docopt `--tlsverify` = False means it + # wasn't used, so we set it if either the environment or the flag is True + # see https://github.com/docker/compose/issues/5632 + verify = options.get('--tlsverify') or environment.get_boolean('DOCKER_TLS_VERIFY') + skip_hostname_check = options.get('--skip-hostname-check', False) if cert_path is not None and not any((ca_cert, cert, key)): # FIXME: Modify TLSConfig to take a cert_path argument and do this internally diff --git a/tests/unit/cli/docker_client_test.py b/tests/unit/cli/docker_client_test.py index 62a537ba571..d8ce31fba87 100644 --- a/tests/unit/cli/docker_client_test.py +++ b/tests/unit/cli/docker_client_test.py @@ -13,6 +13,7 @@ from compose.cli.docker_client import docker_client from compose.cli.docker_client import get_tls_version from compose.cli.docker_client import tls_config_from_options +from compose.config.environment import Environment from tests import mock from tests import unittest @@ -163,14 +164,14 @@ def test_tls_client_and_ca_quoted_paths(self): def test_tls_simple_with_tls_version(self): tls_version = 'TLSv1' options = {'--tls': True} - environment = {'COMPOSE_TLS_VERSION': tls_version} + environment = Environment({'COMPOSE_TLS_VERSION': tls_version}) result = tls_config_from_options(options, environment) assert isinstance(result, docker.tls.TLSConfig) assert result.ssl_version == ssl.PROTOCOL_TLSv1 def test_tls_mixed_environment_and_flags(self): options = {'--tls': True, '--tlsverify': False} - environment = {'DOCKER_CERT_PATH': 'tests/fixtures/tls/'} + environment = Environment({'DOCKER_CERT_PATH': 'tests/fixtures/tls/'}) result = tls_config_from_options(options, environment) assert isinstance(result, docker.tls.TLSConfig) assert result.cert == (self.client_cert, self.key) @@ -178,15 +179,42 @@ def test_tls_mixed_environment_and_flags(self): assert result.verify is False def test_tls_flags_override_environment(self): - environment = {'DOCKER_TLS_VERIFY': True} + environment = Environment({ + 'DOCKER_CERT_PATH': '/completely/wrong/path', + 'DOCKER_TLS_VERIFY': 'false' + }) + 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, environment) + 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 + + def test_tls_verify_flag_no_override(self): + environment = Environment({ + 'DOCKER_TLS_VERIFY': 'true', + 'COMPOSE_TLS_VERSION': 'TLSv1' + }) options = {'--tls': True, '--tlsverify': False} - assert tls_config_from_options(options, environment) is True - environment['COMPOSE_TLS_VERSION'] = 'TLSv1' result = tls_config_from_options(options, environment) assert isinstance(result, docker.tls.TLSConfig) assert result.ssl_version == ssl.PROTOCOL_TLSv1 - assert result.verify is False + # verify is a special case - since `--tlsverify` = False means it + # wasn't used, we set it if either the environment or the flag is True + # see https://github.com/docker/compose/issues/5632 + assert result.verify is True + + def test_tls_verify_env_falsy_value(self): + environment = Environment({'DOCKER_TLS_VERIFY': '0'}) + options = {'--tls': True} + assert tls_config_from_options(options, environment) is True class TestGetTlsVersion(object): From ea0dc8a4082c9be6cbf25e36620ea21795cd6df4 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 1 Feb 2018 12:16:32 -0800 Subject: [PATCH 3263/4072] Fix DOCKER_TLS_VERIFY bug Signed-off-by: Joffrey F --- compose/cli/docker_client.py | 9 +++++-- tests/unit/cli/docker_client_test.py | 40 +++++++++++++++++++++++----- 2 files changed, 41 insertions(+), 8 deletions(-) diff --git a/compose/cli/docker_client.py b/compose/cli/docker_client.py index a581ae67285..818fe63addd 100644 --- a/compose/cli/docker_client.py +++ b/compose/cli/docker_client.py @@ -10,6 +10,7 @@ from docker.tls import TLSConfig from docker.utils import kwargs_from_env +from ..config.environment import Environment from ..const import HTTP_TIMEOUT from .errors import UserError from .utils import generate_user_agent @@ -36,14 +37,18 @@ def get_tls_version(environment): def tls_config_from_options(options, environment=None): - environment = environment or {} + environment = environment or Environment() cert_path = environment.get('DOCKER_CERT_PATH') or None tls = options.get('--tls', False) ca_cert = unquote_path(options.get('--tlscacert')) cert = unquote_path(options.get('--tlscert')) key = unquote_path(options.get('--tlskey')) - verify = options.get('--tlsverify', environment.get('DOCKER_TLS_VERIFY')) + # verify is a special case - with docopt `--tlsverify` = False means it + # wasn't used, so we set it if either the environment or the flag is True + # see https://github.com/docker/compose/issues/5632 + verify = options.get('--tlsverify') or environment.get_boolean('DOCKER_TLS_VERIFY') + skip_hostname_check = options.get('--skip-hostname-check', False) if cert_path is not None and not any((ca_cert, cert, key)): # FIXME: Modify TLSConfig to take a cert_path argument and do this internally diff --git a/tests/unit/cli/docker_client_test.py b/tests/unit/cli/docker_client_test.py index 62a537ba571..d8ce31fba87 100644 --- a/tests/unit/cli/docker_client_test.py +++ b/tests/unit/cli/docker_client_test.py @@ -13,6 +13,7 @@ from compose.cli.docker_client import docker_client from compose.cli.docker_client import get_tls_version from compose.cli.docker_client import tls_config_from_options +from compose.config.environment import Environment from tests import mock from tests import unittest @@ -163,14 +164,14 @@ def test_tls_client_and_ca_quoted_paths(self): def test_tls_simple_with_tls_version(self): tls_version = 'TLSv1' options = {'--tls': True} - environment = {'COMPOSE_TLS_VERSION': tls_version} + environment = Environment({'COMPOSE_TLS_VERSION': tls_version}) result = tls_config_from_options(options, environment) assert isinstance(result, docker.tls.TLSConfig) assert result.ssl_version == ssl.PROTOCOL_TLSv1 def test_tls_mixed_environment_and_flags(self): options = {'--tls': True, '--tlsverify': False} - environment = {'DOCKER_CERT_PATH': 'tests/fixtures/tls/'} + environment = Environment({'DOCKER_CERT_PATH': 'tests/fixtures/tls/'}) result = tls_config_from_options(options, environment) assert isinstance(result, docker.tls.TLSConfig) assert result.cert == (self.client_cert, self.key) @@ -178,15 +179,42 @@ def test_tls_mixed_environment_and_flags(self): assert result.verify is False def test_tls_flags_override_environment(self): - environment = {'DOCKER_TLS_VERIFY': True} + environment = Environment({ + 'DOCKER_CERT_PATH': '/completely/wrong/path', + 'DOCKER_TLS_VERIFY': 'false' + }) + 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, environment) + 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 + + def test_tls_verify_flag_no_override(self): + environment = Environment({ + 'DOCKER_TLS_VERIFY': 'true', + 'COMPOSE_TLS_VERSION': 'TLSv1' + }) options = {'--tls': True, '--tlsverify': False} - assert tls_config_from_options(options, environment) is True - environment['COMPOSE_TLS_VERSION'] = 'TLSv1' result = tls_config_from_options(options, environment) assert isinstance(result, docker.tls.TLSConfig) assert result.ssl_version == ssl.PROTOCOL_TLSv1 - assert result.verify is False + # verify is a special case - since `--tlsverify` = False means it + # wasn't used, we set it if either the environment or the flag is True + # see https://github.com/docker/compose/issues/5632 + assert result.verify is True + + def test_tls_verify_env_falsy_value(self): + environment = Environment({'DOCKER_TLS_VERIFY': '0'}) + options = {'--tls': True} + assert tls_config_from_options(options, environment) is True class TestGetTlsVersion(object): From cdd02822f53a13d91d590578b8617f217a076393 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 1 Feb 2018 14:22:10 -0800 Subject: [PATCH 3264/4072] Bump 1.19.0-rc3 Signed-off-by: Joffrey F --- CHANGELOG.md | 3 +++ compose/__init__.py | 2 +- script/run/run.sh | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c3595d9772..ea970a10048 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,9 @@ Change log - Fixed a bug where setting TLS options with environment and CLI flags simultaneously would result in part of the configuration being ignored +- Fixed a bug where the DOCKER_TLS_VERIFY environment variable was being + ignored by Compose + - Fixed a bug where the `-d` and `--timeout` flags in `up` were erroneously marked as incompatible diff --git a/compose/__init__.py b/compose/__init__.py index bc5cff57585..e5e83434fcb 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.19.0-rc2' +__version__ = '1.19.0-rc3' diff --git a/script/run/run.sh b/script/run/run.sh index e45067b3cb6..7355d9181f7 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.19.0-rc2" +VERSION="1.19.0-rc3" IMAGE="docker/compose:$VERSION" From a0f78539b64ee81eb5e43feac85c4ed4b449e102 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 18 Jan 2018 17:19:22 -0800 Subject: [PATCH 3265/4072] Test and build on 3.6 (replaces 3.4) ; dist 3.6-compiled binaries Signed-off-by: Joffrey F --- .circleci/config.yml | 8 ++++---- Dockerfile | 16 ++++++++-------- Dockerfile.armhf | 8 ++++---- Jenkinsfile | 2 +- appveyor.yml | 6 +++--- requirements-build.txt | 2 +- requirements-dev.txt | 6 +++--- requirements.txt | 3 ++- script/build/linux-entrypoint | 2 +- script/build/osx | 2 +- script/build/windows.ps1 | 13 +++++++++---- script/circle/bintray-deploy.sh | 2 ++ script/clean | 1 + script/setup/osx | 27 ++++++++++++++++++++++++++- script/test/all | 2 +- setup.py | 1 + tests/unit/cli/docker_client_test.py | 5 ++++- tox.ini | 5 ++++- 18 files changed, 76 insertions(+), 35 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 4ac6d413560..7661c647061 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -5,15 +5,15 @@ jobs: xcode: "8.3.3" steps: - checkout -# - run: -# name: install python3 -# command: brew install python3 + - run: + name: install python3 + command: brew update > /dev/null && brew install python3 - run: name: install tox command: sudo pip install --upgrade tox==2.1.1 - run: name: unit tests - command: tox -e py27 -- tests/unit + command: tox -e py27,py36 -- tests/unit build-osx-binary: macos: diff --git a/Dockerfile b/Dockerfile index c5ae9e739f5..6e36fddb40e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -39,19 +39,19 @@ RUN set -ex; \ rm -rf /Python-2.7.13; \ rm Python-2.7.13.tgz -# Build python 3.4 from source +# Build python 3.6 from source RUN set -ex; \ - curl -LO https://www.python.org/ftp/python/3.4.6/Python-3.4.6.tgz && \ - SHA256=fe59daced99549d1d452727c050ae486169e9716a890cffb0d468b376d916b48; \ - echo "${SHA256} Python-3.4.6.tgz" | sha256sum -c - && \ - tar -xzf Python-3.4.6.tgz; \ - cd Python-3.4.6; \ + curl -LO https://www.python.org/ftp/python/3.6.4/Python-3.6.4.tgz && \ + SHA256=9de6494314ea199e3633211696735f65; \ + echo "${SHA256} Python-3.6.4.tgz" | md5sum -c - && \ + tar -xzf Python-3.6.4.tgz; \ + cd Python-3.6.4; \ ./configure --enable-shared; \ make; \ make install; \ cd ..; \ - rm -rf /Python-3.4.6; \ - rm Python-3.4.6.tgz + rm -rf /Python-3.6.4; \ + rm Python-3.6.4.tgz # Make libpython findable ENV LD_LIBRARY_PATH /usr/local/lib diff --git a/Dockerfile.armhf b/Dockerfile.armhf index b7be8cd3644..ce4ab7c13dd 100644 --- a/Dockerfile.armhf +++ b/Dockerfile.armhf @@ -33,15 +33,15 @@ RUN set -ex; \ cd ..; \ rm -rf /Python-2.7.13 -# Build python 3.4 from source +# Build python 3.6 from source RUN set -ex; \ - curl -L https://www.python.org/ftp/python/3.4.6/Python-3.4.6.tgz | tar -xz; \ - cd Python-3.4.6; \ + curl -L https://www.python.org/ftp/python/3.6.4/Python-3.6.4.tgz | tar -xz; \ + cd Python-3.6.4; \ ./configure --enable-shared; \ make; \ make install; \ cd ..; \ - rm -rf /Python-3.4.6 + rm -rf /Python-3.6.4 # Make libpython findable ENV LD_LIBRARY_PATH /usr/local/lib diff --git a/Jenkinsfile b/Jenkinsfile index 51136b1f78e..eb86ea32619 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -60,5 +60,5 @@ buildImage() parallel( failFast: true, all_py27: runTests(pythonVersions: "py27", dockerVersions: "all"), - all_py34: runTests(pythonVersions: "py34", dockerVersions: "all"), + all_py36: runTests(pythonVersions: "py36", dockerVersions: "all"), ) diff --git a/appveyor.yml b/appveyor.yml index e4f39544a0a..f027a11800d 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -2,15 +2,15 @@ version: '{branch}-{build}' install: - - "SET PATH=C:\\Python27-x64;C:\\Python27-x64\\Scripts;%PATH%" + - "SET PATH=C:\\Python36-x64;C:\\Python36-x64\\Scripts;%PATH%" - "python --version" - - "pip install tox==2.1.1 virtualenv==13.1.2" + - "pip install tox==2.9.1 virtualenv==15.1.0" # Build the binary after tests build: false test_script: - - "tox -e py27,py34 -- tests/unit" + - "tox -e py27,py36 -- tests/unit" - ps: ".\\script\\build\\windows.ps1" artifacts: diff --git a/requirements-build.txt b/requirements-build.txt index 27f610ca940..e5a77e7942e 100644 --- a/requirements-build.txt +++ b/requirements-build.txt @@ -1 +1 @@ -pyinstaller==3.2.1 +pyinstaller==3.3.1 diff --git a/requirements-dev.txt b/requirements-dev.txt index e06cad45c8c..32c5c23a1b2 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,5 +1,5 @@ -coverage==3.7.1 +coverage==4.4.2 flake8==3.5.0 mock>=1.0.1 -pytest==2.7.2 -pytest-cov==2.1.0 +pytest==2.9.2 +pytest-cov==2.5.1 diff --git a/requirements.txt b/requirements.txt index 100e7211791..0aad2ea28f0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,8 @@ git+git://github.com/tartley/colorama.git@bd378c725b45eba0b8e5cc091c3ca76a954c92 idna==2.5 ipaddress==1.0.18 jsonschema==2.6.0 -pypiwin32==219; sys_platform == 'win32' +pypiwin32==219; sys_platform == 'win32' and python_version < '3.6' +pypiwin32==220; sys_platform == 'win32' and python_version >= '3.6' PySocks==1.6.7 PyYAML==3.12 requests==2.18.4 diff --git a/script/build/linux-entrypoint b/script/build/linux-entrypoint index bf515060a02..0e3c7ec1e95 100755 --- a/script/build/linux-entrypoint +++ b/script/build/linux-entrypoint @@ -3,7 +3,7 @@ set -ex TARGET=dist/docker-compose-$(uname -s)-$(uname -m) -VENV=/code/.tox/py27 +VENV=/code/.tox/py36 mkdir -p `pwd`/dist chmod 777 `pwd`/dist diff --git a/script/build/osx b/script/build/osx index 3de3457621c..0c4b062bb47 100755 --- a/script/build/osx +++ b/script/build/osx @@ -5,7 +5,7 @@ PATH="/usr/local/bin:$PATH" rm -rf venv -virtualenv -p /usr/local/bin/python venv +virtualenv -p /usr/local/bin/python3 venv venv/bin/pip install -r requirements.txt venv/bin/pip install -r requirements-build.txt venv/bin/pip install --no-deps . diff --git a/script/build/windows.ps1 b/script/build/windows.ps1 index db643274c3a..98a74815801 100644 --- a/script/build/windows.ps1 +++ b/script/build/windows.ps1 @@ -6,17 +6,17 @@ # # http://git-scm.com/download/win # -# 2. Install Python 2.7.10: +# 2. Install Python 3.6.4: # # https://www.python.org/downloads/ # -# 3. Append ";C:\Python27;C:\Python27\Scripts" to the "Path" environment variable: +# 3. Append ";C:\Python36;C:\Python36\Scripts" to the "Path" environment variable: # # https://www.microsoft.com/resources/documentation/windows/xp/all/proddocs/en-us/sysdm_advancd_environmnt_addchange_variable.mspx?mfr=true # # 4. In Powershell, run the following commands: # -# $ pip install virtualenv +# $ pip install 'virtualenv>=15.1.0' # $ Set-ExecutionPolicy -Scope CurrentUser RemoteSigned # # 5. Clone the repository: @@ -45,7 +45,12 @@ virtualenv .\venv $ErrorActionPreference = "Continue" # Install dependencies -.\venv\Scripts\pip install pypiwin32==219 +# Fix for https://github.com/pypa/pip/issues/3964 +# Remove-Item -Recurse -Force .\venv\Lib\site-packages\pip +# .\venv\Scripts\easy_install pip==9.0.1 +# .\venv\Scripts\pip install --upgrade pip setuptools +# End fix +.\venv\Scripts\pip install pypiwin32==220 .\venv\Scripts\pip install -r requirements.txt .\venv\Scripts\pip install --no-deps . .\venv\Scripts\pip install --allow-external pyinstaller -r requirements-build.txt diff --git a/script/circle/bintray-deploy.sh b/script/circle/bintray-deploy.sh index d508da36563..8c8871aa68e 100755 --- a/script/circle/bintray-deploy.sh +++ b/script/circle/bintray-deploy.sh @@ -1,5 +1,7 @@ #!/bin/bash +set -x + curl -f -u$BINTRAY_USERNAME:$BINTRAY_API_KEY -X GET \ https://api.bintray.com/repos/docker-compose/${CIRCLE_BRANCH} diff --git a/script/clean b/script/clean index fb7ba3be2da..2e1994df391 100755 --- a/script/clean +++ b/script/clean @@ -2,6 +2,7 @@ set -e find . -type f -name '*.pyc' -delete +rm -rf .coverage-binfiles find . -name .coverage.* -delete find . -name __pycache__ -delete rm -rf docs/_site build dist docker-compose.egg-info diff --git a/script/setup/osx b/script/setup/osx index 407524cba16..972e79efb44 100755 --- a/script/setup/osx +++ b/script/setup/osx @@ -6,11 +6,36 @@ python_version() { python -V 2>&1 } +python3_version() { + python3 -V 2>&1 +} + openssl_version() { python -c "import ssl; print ssl.OPENSSL_VERSION" } -echo "*** Using $(python_version)" +desired_python3_version="3.6.4" +desired_python3_brew_version="3.6.4_2" +python3_formula="https://raw.githubusercontent.com/Homebrew/homebrew-core/b4e69a9a592232fa5a82741f6acecffc2f1d198d/Formula/python3.rb" + +PATH="/usr/local/bin:$PATH" + +if !(which brew); then + ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)" +fi + +brew update > /dev/null + +if !(python3_version | grep "$desired_python3_version"); then + if brew list | grep python3; then + brew unlink python3 + fi + + brew install "$python3_formula" + brew switch python3 "$desired_python3_brew_version" +fi + +echo "*** Using $(python3_version) ; $(python_version)" echo "*** Using $(openssl_version)" if !(which virtualenv); then diff --git a/script/test/all b/script/test/all index 1200c496e27..e48f73bba76 100755 --- a/script/test/all +++ b/script/test/all @@ -24,7 +24,7 @@ fi BUILD_NUMBER=${BUILD_NUMBER-$USER} -PY_TEST_VERSIONS=${PY_TEST_VERSIONS:-py27,py34} +PY_TEST_VERSIONS=${PY_TEST_VERSIONS:-py27,py36} for version in $DOCKER_VERSIONS; do >&2 echo "Running tests against Docker $version" diff --git a/setup.py b/setup.py index a85bcdf7246..fbf34e4653f 100644 --- a/setup.py +++ b/setup.py @@ -99,5 +99,6 @@ def find_version(*file_paths): 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3.6', ], ) diff --git a/tests/unit/cli/docker_client_test.py b/tests/unit/cli/docker_client_test.py index d8ce31fba87..5bb4564ef06 100644 --- a/tests/unit/cli/docker_client_test.py +++ b/tests/unit/cli/docker_client_test.py @@ -22,7 +22,10 @@ class DockerClientTestCase(unittest.TestCase): def test_docker_client_no_home(self): with mock.patch.dict(os.environ): - del os.environ['HOME'] + try: + del os.environ['HOME'] + except KeyError: + pass docker_client(os.environ) @mock.patch.dict(os.environ) diff --git a/tox.ini b/tox.ini index 749be3faaea..33347df20ee 100644 --- a/tox.ini +++ b/tox.ini @@ -1,8 +1,9 @@ [tox] -envlist = py27,py34,pre-commit +envlist = py27,py36,pre-commit [testenv] usedevelop=True +whitelist_externals=mkdir passenv = LD_LIBRARY_PATH DOCKER_HOST @@ -17,6 +18,7 @@ deps = -rrequirements.txt -rrequirements-dev.txt commands = + mkdir -p .coverage-binfiles py.test -v \ --cov=compose \ --cov-report html \ @@ -35,6 +37,7 @@ commands = # Coverage configuration [run] branch = True +data_file = .coverage-binfiles/.coverage [report] show_missing = true From 9dde4fff0e2ad2180e256b44bac3287bdd9ae213 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 2 Feb 2018 11:57:15 -0800 Subject: [PATCH 3266/4072] Add support for 3.6 schema and tmpfs mount size Signed-off-by: Joffrey F --- compose/config/config_schema_v2.3.json | 6 + compose/config/config_schema_v3.6.json | 582 ++++++++++++++++++++++++ compose/config/interpolation.py | 9 + compose/config/types.py | 6 + compose/const.py | 3 + docker-compose.spec | 5 + tests/integration/service_test.py | 17 + tests/unit/config/interpolation_test.py | 7 + 8 files changed, 635 insertions(+) create mode 100644 compose/config/config_schema_v3.6.json diff --git a/compose/config/config_schema_v2.3.json b/compose/config/config_schema_v2.3.json index 42e2afdad71..2d28df77a50 100644 --- a/compose/config/config_schema_v2.3.json +++ b/compose/config/config_schema_v2.3.json @@ -321,6 +321,12 @@ "properties": { "nocopy": {"type": "boolean"} } + }, + "tmpfs": { + "type": "object", + "properties": { + "size": {"type": ["integer", "string"]} + } } } } diff --git a/compose/config/config_schema_v3.6.json b/compose/config/config_schema_v3.6.json new file mode 100644 index 00000000000..8e718780bf4 --- /dev/null +++ b/compose/config/config_schema_v3.6.json @@ -0,0 +1,582 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "id": "config_schema_v3.5.json", + "type": "object", + "required": ["version"], + + "properties": { + "version": { + "type": "string" + }, + + "services": { + "id": "#/properties/services", + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "$ref": "#/definitions/service" + } + }, + "additionalProperties": false + }, + + "networks": { + "id": "#/properties/networks", + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "$ref": "#/definitions/network" + } + } + }, + + "volumes": { + "id": "#/properties/volumes", + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "$ref": "#/definitions/volume" + } + }, + "additionalProperties": false + }, + + "secrets": { + "id": "#/properties/secrets", + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "$ref": "#/definitions/secret" + } + }, + "additionalProperties": false + }, + + "configs": { + "id": "#/properties/configs", + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "$ref": "#/definitions/config" + } + }, + "additionalProperties": false + } + }, + + "patternProperties": {"^x-": {}}, + "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"}, + "labels": {"$ref": "#/definitions/list_or_dict"}, + "cache_from": {"$ref": "#/definitions/list_of_strings"}, + "network": {"type": "string"}, + "target": {"type": "string"}, + "shm_size": {"type": ["integer", "string"]} + }, + "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"}} + ] + }, + "configs": { + "type": "array", + "items": { + "oneOf": [ + {"type": "string"}, + { + "type": "object", + "properties": { + "source": {"type": "string"}, + "target": {"type": "string"}, + "uid": {"type": "string"}, + "gid": {"type": "string"}, + "mode": {"type": "number"} + } + } + ] + } + }, + "container_name": {"type": "string"}, + "credential_spec": {"type": "object", "properties": { + "file": {"type": "string"}, + "registry": {"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"}, + "isolation": {"type": "string"}, + "labels": {"$ref": "#/definitions/list_or_dict"}, + "links": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + + "logging": { + "type": "object", + + "properties": { + "driver": {"type": "string"}, + "options": { + "type": "object", + "patternProperties": { + "^.+$": {"type": ["string", "number", "null"]} + } + } + }, + "additionalProperties": false + }, + + "mac_address": {"type": "string"}, + "network_mode": {"type": "string"}, + + "networks": { + "oneOf": [ + {"$ref": "#/definitions/list_of_strings"}, + { + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "oneOf": [ + { + "type": "object", + "properties": { + "aliases": {"$ref": "#/definitions/list_of_strings"}, + "ipv4_address": {"type": "string"}, + "ipv6_address": {"type": "string"} + }, + "additionalProperties": false + }, + {"type": "null"} + ] + } + }, + "additionalProperties": false + } + ] + }, + "pid": {"type": ["string", "null"]}, + + "ports": { + "type": "array", + "items": { + "oneOf": [ + {"type": "number", "format": "ports"}, + {"type": "string", "format": "ports"}, + { + "type": "object", + "properties": { + "mode": {"type": "string"}, + "target": {"type": "integer"}, + "published": {"type": "integer"}, + "protocol": {"type": "string"} + }, + "additionalProperties": false + } + ] + }, + "uniqueItems": true + }, + + "privileged": {"type": "boolean"}, + "read_only": {"type": "boolean"}, + "restart": {"type": "string"}, + "security_opt": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "shm_size": {"type": ["number", "string"]}, + "secrets": { + "type": "array", + "items": { + "oneOf": [ + {"type": "string"}, + { + "type": "object", + "properties": { + "source": {"type": "string"}, + "target": {"type": "string"}, + "uid": {"type": "string"}, + "gid": {"type": "string"}, + "mode": {"type": "number"} + } + } + ] + } + }, + "sysctls": {"$ref": "#/definitions/list_or_dict"}, + "stdin_open": {"type": "boolean"}, + "stop_grace_period": {"type": "string", "format": "duration"}, + "stop_signal": {"type": "string"}, + "tmpfs": {"$ref": "#/definitions/string_or_list"}, + "tty": {"type": "boolean"}, + "ulimits": { + "type": "object", + "patternProperties": { + "^[a-z]+$": { + "oneOf": [ + {"type": "integer"}, + { + "type":"object", + "properties": { + "hard": {"type": "integer"}, + "soft": {"type": "integer"} + }, + "required": ["soft", "hard"], + "additionalProperties": false + } + ] + } + } + }, + "user": {"type": "string"}, + "userns_mode": {"type": "string"}, + "volumes": { + "type": "array", + "items": { + "oneOf": [ + {"type": "string"}, + { + "type": "object", + "required": ["type"], + "properties": { + "type": {"type": "string"}, + "source": {"type": "string"}, + "target": {"type": "string"}, + "read_only": {"type": "boolean"}, + "consistency": {"type": "string"}, + "bind": { + "type": "object", + "properties": { + "propagation": {"type": "string"} + } + }, + "volume": { + "type": "object", + "properties": { + "nocopy": {"type": "boolean"} + } + }, + "tmpfs": { + "type": "object", + "properties": { + "size": { + "type": "integer", + "minimum": 0 + } + } + } + }, + "additionalProperties": false + } + ], + "uniqueItems": true + } + }, + "working_dir": {"type": "string"} + }, + "additionalProperties": false + }, + + "healthcheck": { + "id": "#/definitions/healthcheck", + "type": "object", + "additionalProperties": false, + "properties": { + "disable": {"type": "boolean"}, + "interval": {"type": "string", "format": "duration"}, + "retries": {"type": "number"}, + "test": { + "oneOf": [ + {"type": "string"}, + {"type": "array", "items": {"type": "string"}} + ] + }, + "timeout": {"type": "string", "format": "duration"}, + "start_period": {"type": "string", "format": "duration"} + } + }, + "deployment": { + "id": "#/definitions/deployment", + "type": ["object", "null"], + "properties": { + "mode": {"type": "string"}, + "endpoint_mode": {"type": "string"}, + "replicas": {"type": "integer"}, + "labels": {"$ref": "#/definitions/list_or_dict"}, + "update_config": { + "type": "object", + "properties": { + "parallelism": {"type": "integer"}, + "delay": {"type": "string", "format": "duration"}, + "failure_action": {"type": "string"}, + "monitor": {"type": "string", "format": "duration"}, + "max_failure_ratio": {"type": "number"}, + "order": {"type": "string", "enum": [ + "start-first", "stop-first" + ]} + }, + "additionalProperties": false + }, + "resources": { + "type": "object", + "properties": { + "limits": { + "type": "object", + "properties": { + "cpus": {"type": "string"}, + "memory": {"type": "string"} + }, + "additionalProperties": false + }, + "reservations": { + "type": "object", + "properties": { + "cpus": {"type": "string"}, + "memory": {"type": "string"}, + "generic_resources": {"$ref": "#/definitions/generic_resources"} + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "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"}}, + "preferences": { + "type": "array", + "items": { + "type": "object", + "properties": { + "spread": {"type": "string"} + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + + "generic_resources": { + "id": "#/definitions/generic_resources", + "type": "array", + "items": { + "type": "object", + "properties": { + "discrete_resource_spec": { + "type": "object", + "properties": { + "kind": {"type": "string"}, + "value": {"type": "number"} + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + }, + + "network": { + "id": "#/definitions/network", + "type": ["object", "null"], + "properties": { + "name": {"type": "string"}, + "driver": {"type": "string"}, + "driver_opts": { + "type": "object", + "patternProperties": { + "^.+$": {"type": ["string", "number"]} + } + }, + "ipam": { + "type": "object", + "properties": { + "driver": {"type": "string"}, + "config": { + "type": "array", + "items": { + "type": "object", + "properties": { + "subnet": {"type": "string"} + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + }, + "external": { + "type": ["boolean", "object"], + "properties": { + "name": {"type": "string"} + }, + "additionalProperties": false + }, + "internal": {"type": "boolean"}, + "attachable": {"type": "boolean"}, + "labels": {"$ref": "#/definitions/list_or_dict"} + }, + "additionalProperties": false + }, + + "volume": { + "id": "#/definitions/volume", + "type": ["object", "null"], + "properties": { + "name": {"type": "string"}, + "driver": {"type": "string"}, + "driver_opts": { + "type": "object", + "patternProperties": { + "^.+$": {"type": ["string", "number"]} + } + }, + "external": { + "type": ["boolean", "object"], + "properties": { + "name": {"type": "string"} + }, + "additionalProperties": false + }, + "labels": {"$ref": "#/definitions/list_or_dict"} + }, + "additionalProperties": false + }, + + "secret": { + "id": "#/definitions/secret", + "type": "object", + "properties": { + "name": {"type": "string"}, + "file": {"type": "string"}, + "external": { + "type": ["boolean", "object"], + "properties": { + "name": {"type": "string"} + } + }, + "labels": {"$ref": "#/definitions/list_or_dict"} + }, + "additionalProperties": false + }, + + "config": { + "id": "#/definitions/config", + "type": "object", + "properties": { + "name": {"type": "string"}, + "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"] + } + } + } + } + } +} diff --git a/compose/config/interpolation.py b/compose/config/interpolation.py index 9d52d2edb90..b1143d66c77 100644 --- a/compose/config/interpolation.py +++ b/compose/config/interpolation.py @@ -9,6 +9,7 @@ from .errors import ConfigurationError from compose.const import COMPOSEFILE_V2_0 as V2_0 +from compose.utils import parse_bytes log = logging.getLogger(__name__) @@ -215,6 +216,13 @@ def to_str(o): return o +def bytes_to_int(s): + v = parse_bytes(s) + if v is None: + raise ValueError('"{}" is not a valid byte value'.format(s)) + return v + + class ConversionMap(object): map = { service_path('blkio_config', 'weight'): to_int, @@ -247,6 +255,7 @@ class ConversionMap(object): service_path('tty'): to_boolean, service_path('volumes', 'read_only'): to_boolean, service_path('volumes', 'volume', 'nocopy'): to_boolean, + service_path('volumes', 'tmpfs', 'size'): bytes_to_int, re_path_basic('network', 'attachable'): to_boolean, re_path_basic('network', 'external'): to_boolean, re_path_basic('network', 'internal'): to_boolean, diff --git a/compose/config/types.py b/compose/config/types.py index b896b883f7b..d84491d0a12 100644 --- a/compose/config/types.py +++ b/compose/config/types.py @@ -141,6 +141,9 @@ class MountSpec(object): }, 'bind': { 'propagation': 'propagation' + }, + 'tmpfs': { + 'size': 'tmpfs_size' } } _fields = ['type', 'source', 'target', 'read_only', 'consistency'] @@ -149,6 +152,9 @@ class MountSpec(object): def parse(cls, mount_dict, normalize=False, win_host=False): normpath = ntpath.normpath if win_host else os.path.normpath if mount_dict.get('source'): + if mount_dict['type'] == 'tmpfs': + raise ConfigurationError('tmpfs mounts can not specify a source') + mount_dict['source'] = normpath(mount_dict['source']) if normalize: mount_dict['source'] = normalize_path_for_engine(mount_dict['source']) diff --git a/compose/const.py b/compose/const.py index 6e5902cadf5..495539fb054 100644 --- a/compose/const.py +++ b/compose/const.py @@ -34,6 +34,7 @@ COMPOSEFILE_V3_3 = ComposeVersion('3.3') COMPOSEFILE_V3_4 = ComposeVersion('3.4') COMPOSEFILE_V3_5 = ComposeVersion('3.5') +COMPOSEFILE_V3_6 = ComposeVersion('3.6') API_VERSIONS = { COMPOSEFILE_V1: '1.21', @@ -47,6 +48,7 @@ COMPOSEFILE_V3_3: '1.30', COMPOSEFILE_V3_4: '1.30', COMPOSEFILE_V3_5: '1.30', + COMPOSEFILE_V3_6: '1.36', } API_VERSION_TO_ENGINE_VERSION = { @@ -61,4 +63,5 @@ API_VERSIONS[COMPOSEFILE_V3_3]: '17.06.0', API_VERSIONS[COMPOSEFILE_V3_4]: '17.06.0', API_VERSIONS[COMPOSEFILE_V3_5]: '17.06.0', + API_VERSIONS[COMPOSEFILE_V3_6]: '18.02.0', } diff --git a/docker-compose.spec b/docker-compose.spec index 83d7389f378..b2b4f5f18b2 100644 --- a/docker-compose.spec +++ b/docker-compose.spec @@ -72,6 +72,11 @@ exe = EXE(pyz, 'compose/config/config_schema_v3.5.json', 'DATA' ), + ( + 'compose/config/config_schema_v3.6.json', + 'compose/config/config_schema_v3.6.json', + 'DATA' + ), ( 'compose/GITSHA', 'compose/GITSHA', diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index c12724c85ec..0bc902aea25 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -315,6 +315,23 @@ def test_create_container_with_tmpfs_mount(self): assert mount assert mount['Type'] == 'tmpfs' + @v2_3_only() + def test_create_container_with_tmpfs_mount_tmpfs_size(self): + container_path = '/container-tmpfs' + service = self.create_service( + 'db', + volumes=[MountSpec(type='tmpfs', target=container_path, tmpfs={'size': 5368709})] + ) + container = service.create_container() + service.start_container(container) + mount = container.get_mount(container_path) + assert mount + print(container.dictionary) + assert mount['Type'] == 'tmpfs' + assert container.get('HostConfig.Mounts')[0]['TmpfsOptions'] == { + 'SizeBytes': 5368709 + } + @v2_3_only() def test_create_container_with_volume_mount(self): container_path = '/container-volume' diff --git a/tests/unit/config/interpolation_test.py b/tests/unit/config/interpolation_test.py index fe5ef2490ea..2ba698fbf4a 100644 --- a/tests/unit/config/interpolation_test.py +++ b/tests/unit/config/interpolation_test.py @@ -27,6 +27,7 @@ def mock_env(): 'NEGINT': '-200', 'FLOAT': '0.145', 'MODE': '0600', + 'BYTES': '512m', }) @@ -147,6 +148,9 @@ def test_interpolate_environment_services_convert_types_v2(mock_env): 'read_only': '${DEFAULT:-no}', 'tty': '${DEFAULT:-N}', 'stdin_open': '${DEFAULT-on}', + 'volumes': [ + {'type': 'tmpfs', 'target': '/target', 'tmpfs': {'size': '$BYTES'}} + ] } } @@ -177,6 +181,9 @@ def test_interpolate_environment_services_convert_types_v2(mock_env): 'read_only': False, 'tty': False, 'stdin_open': True, + 'volumes': [ + {'type': 'tmpfs', 'target': '/target', 'tmpfs': {'size': 536870912}} + ] } } From dce62c81d5c3c801f39065fccd45223f11c0d21c Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 2 Feb 2018 15:18:55 -0800 Subject: [PATCH 3267/4072] Remove obsolete code that slows down test execution Signed-off-by: Joffrey F --- Dockerfile | 55 +++-------------------------------------- compose/cli/__init__.py | 49 ------------------------------------ 2 files changed, 4 insertions(+), 100 deletions(-) diff --git a/Dockerfile b/Dockerfile index 6e36fddb40e..9df78a8262a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,21 +1,12 @@ -FROM debian:wheezy +FROM python:3.6 RUN set -ex; \ apt-get update -qq; \ apt-get install -y \ locales \ - gcc \ - make \ - zlib1g \ - zlib1g-dev \ - libssl-dev \ - git \ - ca-certificates \ curl \ - libsqlite3-dev \ - libbz2-dev \ - ; \ - rm -rf /var/lib/apt/lists/* + python-dev \ + git RUN curl -fsSL -o dockerbins.tgz "https://download.docker.com/linux/static/stable/x86_64/docker-17.12.0-ce.tgz" && \ SHA256=692e1c72937f6214b1038def84463018d8e320c8eaf8530546c84c2f8f9c767d; \ @@ -25,44 +16,6 @@ RUN curl -fsSL -o dockerbins.tgz "https://download.docker.com/linux/static/stabl chmod +x /usr/local/bin/docker && \ rm dockerbins.tgz -# Build Python 2.7.13 from source -RUN set -ex; \ - curl -LO https://www.python.org/ftp/python/2.7.13/Python-2.7.13.tgz && \ - SHA256=a4f05a0720ce0fd92626f0278b6b433eee9a6173ddf2bced7957dfb599a5ece1; \ - echo "${SHA256} Python-2.7.13.tgz" | sha256sum -c - && \ - tar -xzf Python-2.7.13.tgz; \ - cd Python-2.7.13; \ - ./configure --enable-shared; \ - make; \ - make install; \ - cd ..; \ - rm -rf /Python-2.7.13; \ - rm Python-2.7.13.tgz - -# Build python 3.6 from source -RUN set -ex; \ - curl -LO https://www.python.org/ftp/python/3.6.4/Python-3.6.4.tgz && \ - SHA256=9de6494314ea199e3633211696735f65; \ - echo "${SHA256} Python-3.6.4.tgz" | md5sum -c - && \ - tar -xzf Python-3.6.4.tgz; \ - cd Python-3.6.4; \ - ./configure --enable-shared; \ - make; \ - make install; \ - cd ..; \ - rm -rf /Python-3.6.4; \ - rm Python-3.6.4.tgz - -# Make libpython findable -ENV LD_LIBRARY_PATH /usr/local/lib - -# Install pip -RUN set -ex; \ - curl -LO https://bootstrap.pypa.io/get-pip.py && \ - SHA256=19dae841a150c86e2a09d475b5eb0602861f2a5b7761ec268049a662dbd2bd0c; \ - echo "${SHA256} get-pip.py" | sha256sum -c - && \ - python get-pip.py - # Python3 requires a valid locale RUN echo "en_US.UTF-8 UTF-8" > /etc/locale.gen && locale-gen ENV LANG en_US.UTF-8 @@ -83,4 +36,4 @@ RUN tox --notest ADD . /code/ RUN chown -R user /code/ -ENTRYPOINT ["/code/.tox/py27/bin/docker-compose"] +ENTRYPOINT ["/code/.tox/py36/bin/docker-compose"] diff --git a/compose/cli/__init__.py b/compose/cli/__init__.py index 2574a311f24..e69de29bb2d 100644 --- a/compose/cli/__init__.py +++ b/compose/cli/__init__.py @@ -1,49 +0,0 @@ -from __future__ import absolute_import -from __future__ import print_function -from __future__ import unicode_literals - -import os -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 - env = os.environ.copy() - env[str('PIP_DISABLE_PIP_VERSION_CHECK')] = str('1') - - s_cmd = subprocess.Popen( - # DO NOT replace this call with a `sys.executable` call. It breaks the binary - # distribution (with the binary calling itself recursively over and over). - ['pip', 'freeze'], stderr=subprocess.PIPE, stdout=subprocess.PIPE, - env=env - ) - 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 yellow - print( - yellow('WARNING:'), - "Dependency conflict: an older version of the 'docker-py' package " - "may be polluting the namespace. " - "If you're experiencing crashes, run the following command to remedy the issue:\n" - "pip uninstall docker-py; pip uninstall docker; pip install docker", - file=sys.stderr - ) - -except OSError: - # pip command is not available, which indicates it's probably the binary - # distribution of Compose which is not affected - pass -except UnicodeDecodeError: - # ref: https://github.com/docker/compose/issues/4663 - # This could be caused by a number of things, but it seems to be a - # python 2 + MacOS interaction. It's not ideal to ignore this, but at least - # it doesn't make the program unusable. - pass From 632abe94c010455a588127efa99bc8f3f10bea2d Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 2 Feb 2018 16:24:17 -0800 Subject: [PATCH 3268/4072] Parallelize Docker versions Signed-off-by: Joffrey F --- Jenkinsfile | 35 +++++++++++++++++++++++++++-------- script/test/ci | 2 +- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index eb86ea32619..44cd7c3c2a6 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -18,12 +18,26 @@ def buildImage = { -> } } +def get_versions = { int number -> + def docker_versions + wrappedNode(label: "ubuntu && !zfs") { + def result = sh(script: """docker run --rm \\ + --entrypoint=/code/.tox/py27/bin/python \\ + ${image.id} \\ + /code/script/test/versions.py -n ${number} docker/docker-ce recent + """, returnStdout: true + ) + docker_versions = result.split() + } + return docker_versions +} + 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')`") + throw new Exception("Need Python versions to test. e.g.: `runTests(pythonVersions: 'py27,py36')`") } if (!dockerVersions) { throw new Exception("Need Docker versions to test. e.g.: `runTests(dockerVersions: 'all')`") @@ -46,7 +60,7 @@ def runTests = { Map settings -> -e "DOCKER_VERSIONS=${dockerVersions}" \\ -e "BUILD_NUMBER=\$BUILD_TAG" \\ -e "PY_TEST_VERSIONS=${pythonVersions}" \\ - --entrypoint="script/ci" \\ + --entrypoint="script/test/ci" \\ ${image.id} \\ --verbose """ @@ -56,9 +70,14 @@ def runTests = { Map settings -> } buildImage() -// TODO: break this out into meaningful "DOCKER_VERSIONS" values instead of all -parallel( - failFast: true, - all_py27: runTests(pythonVersions: "py27", dockerVersions: "all"), - all_py36: runTests(pythonVersions: "py36", dockerVersions: "all"), -) + +def testMatrix = [failFast: true] +def docker_versions = get_versions(2) + +for (int i = 0 ;i < docker_versions.length ; i++) { + def dockerVersion = docker_versions[i] + testMatrix["${dockerVersion}_py27"] = runTests([dockerVersions: dockerVersion, pythonVersions: "py27"]) + testMatrix["${dockerVersion}_py36"] = runTests([dockerVersions: dockerVersion, pythonVersions: "py36"]) +} + +parallel(testMatrix) diff --git a/script/test/ci b/script/test/ci index c5927b2c9a3..8d3aa56cb80 100755 --- a/script/test/ci +++ b/script/test/ci @@ -14,7 +14,7 @@ set -ex docker version -export DOCKER_VERSIONS=all +export DOCKER_VERSIONS=${DOCKER_VERSIONS:-all} STORAGE_DRIVER=${STORAGE_DRIVER:-overlay} export DOCKER_DAEMON_ARGS="--storage-driver=$STORAGE_DRIVER" From 95005e6c03565acc33758719987a11192d9255a7 Mon Sep 17 00:00:00 2001 From: Daniel Schildt Date: Sat, 3 Feb 2018 17:22:16 +0200 Subject: [PATCH 3269/4072] Improve spelling in the README.md Improve spelling of the brand names: - GitHub instead of Github Signed-off-by: Daniel Schildt --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e3ca8f833e0..ea07f6a7d27 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ Installation and documentation - Full documentation is available on [Docker's website](https://docs.docker.com/compose/). - If you have any questions, you can talk in real-time with other developers in the #docker-compose IRC channel on Freenode. [Click here to join using IRCCloud.](https://www.irccloud.com/invite?hostname=irc.freenode.net&channel=%23docker-compose) -- Code repository for Compose is on [Github](https://github.com/docker/compose) +- Code repository for Compose is on [GitHub](https://github.com/docker/compose) - If you find any problems please fill out an [issue](https://github.com/docker/compose/issues/new) Contributing From 649604d88d94c73acb0860ab53ed1b55a6bd61b1 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 5 Feb 2018 14:49:13 -0800 Subject: [PATCH 3270/4072] Bump Docker Python 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 0aad2ea28f0..6b1df191638 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ backports.ssl-match-hostname==3.5.0.1; python_version < '3' cached-property==1.3.0 certifi==2017.4.17 chardet==3.0.4 -docker==3.0.0 +docker==3.0.1 docker-pycreds==0.2.1 dockerpty==0.4.1 docopt==0.6.2 diff --git a/setup.py b/setup.py index fbf34e4653f..264d647f7bb 100644 --- a/setup.py +++ b/setup.py @@ -36,7 +36,7 @@ def find_version(*file_paths): 'requests >= 2.6.1, != 2.11.0, != 2.12.2, != 2.18.0, < 2.19', 'texttable >= 0.9.0, < 0.10', 'websocket-client >= 0.32.0, < 1.0', - 'docker >= 3.0.0, < 4.0', + 'docker >= 3.0.1, < 4.0', 'dockerpty >= 0.4.1, < 0.5', 'six >= 1.3.0, < 2', 'jsonschema >= 2.5.1, < 3', From 8c297f267e5f986485961a4d9b9fc24a46d4c4a8 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 5 Feb 2018 17:31:17 -0800 Subject: [PATCH 3271/4072] Implement compatibility mode, translating deploy keys to equivalent v2 config if available Enabled using `--compatibility` CLI flag Signed-off-by: Joffrey F --- compose/cli/command.py | 9 ++- compose/cli/main.py | 2 + compose/config/config.py | 94 ++++++++++++++++++++--- tests/fixtures/v3-full/docker-compose.yml | 15 ++-- 4 files changed, 101 insertions(+), 19 deletions(-) diff --git a/compose/cli/command.py b/compose/cli/command.py index 6977195a0de..9fd941bb6a5 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -38,6 +38,7 @@ def project_from_options(project_dir, options): tls_config=tls_config_from_options(options, environment), environment=environment, override_dir=options.get('--project-directory'), + compatibility=options.get('--compatibility'), ) @@ -63,7 +64,8 @@ def get_config_from_options(base_dir, options): base_dir, options, environment ) return config.load( - config.find(base_dir, config_path, environment) + config.find(base_dir, config_path, environment), + options.get('--compatibility') ) @@ -100,14 +102,15 @@ def get_client(environment, verbose=False, version=None, tls_config=None, host=N def get_project(project_dir, config_path=None, project_name=None, verbose=False, - host=None, tls_config=None, environment=None, override_dir=None): + host=None, tls_config=None, environment=None, override_dir=None, + compatibility=False): if not environment: environment = Environment.from_env_file(project_dir) config_details = config.find(project_dir, config_path, environment, override_dir) project_name = get_project_name( config_details.working_dir, project_name, environment ) - config_data = config.load(config_details) + config_data = config.load(config_details, compatibility) api_version = environment.get( 'COMPOSE_API_VERSION', diff --git a/compose/cli/main.py b/compose/cli/main.py index 380257dbfe5..9c76a561b3a 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -185,6 +185,8 @@ class TopLevelCommand(object): is an IP address) --project-directory PATH Specify an alternate working directory (default: the path of the Compose file) + --compatibility If set, Compose will attempt to convert deploy keys in v3 + files to their non-Swarm equivalent Commands: build Build or rebuild services diff --git a/compose/config/config.py b/compose/config/config.py index 960c3c678bf..58420d15732 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -16,6 +16,7 @@ from .. import const from ..const import COMPOSEFILE_V1 as V1 from ..const import COMPOSEFILE_V2_1 as V2_1 +from ..const import COMPOSEFILE_V2_3 as V2_3 from ..const import COMPOSEFILE_V3_0 as V3_0 from ..const import COMPOSEFILE_V3_4 as V3_4 from ..utils import build_string_dict @@ -341,7 +342,7 @@ def find_candidates_in_parent_dirs(filenames, path): return (candidates, path) -def check_swarm_only_config(service_dicts): +def check_swarm_only_config(service_dicts, compatibility=False): warning_template = ( "Some services ({services}) use the '{key}' key, which will be ignored. " "Compose does not support '{key}' configuration - use " @@ -357,13 +358,13 @@ def check_swarm_only_key(service_dicts, key): key=key ) ) - - check_swarm_only_key(service_dicts, 'deploy') + if not compatibility: + check_swarm_only_key(service_dicts, 'deploy') check_swarm_only_key(service_dicts, 'credential_spec') check_swarm_only_key(service_dicts, 'configs') -def load(config_details): +def load(config_details, compatibility=False): """Load the configuration from a working directory and a list of configuration files. Files are loaded in order, and merged on top of each other to create the final configuration. @@ -391,15 +392,17 @@ def load(config_details): configs = load_mapping( config_details.config_files, 'get_configs', 'Config', config_details.working_dir ) - service_dicts = load_services(config_details, main_file) + service_dicts = load_services(config_details, main_file, compatibility) if main_file.version != V1: for service_dict in service_dicts: match_named_volumes(service_dict, volumes) - check_swarm_only_config(service_dicts) + check_swarm_only_config(service_dicts, compatibility) + + version = V2_3 if compatibility and main_file.version >= V3_0 else main_file.version - return Config(main_file.version, service_dicts, volumes, networks, secrets, configs) + return Config(version, service_dicts, volumes, networks, secrets, configs) def load_mapping(config_files, get_func, entity_type, working_dir=None): @@ -441,7 +444,7 @@ def validate_external(entity_type, name, config, version): entity_type, name, ', '.join(k for k in config if k != 'external'))) -def load_services(config_details, config_file): +def load_services(config_details, config_file, compatibility=False): def build_service(service_name, service_dict, service_names): service_config = ServiceConfig.with_abs_paths( config_details.working_dir, @@ -459,7 +462,9 @@ def build_service(service_name, service_dict, service_names): service_config, service_names, config_file.version, - config_details.environment) + config_details.environment, + compatibility + ) return service_dict def build_services(service_config): @@ -827,7 +832,7 @@ def finalize_service_volumes(service_dict, environment): return service_dict -def finalize_service(service_config, service_names, version, environment): +def finalize_service(service_config, service_names, version, environment, compatibility): service_dict = dict(service_config.config) if 'environment' in service_dict or 'env_file' in service_dict: @@ -868,10 +873,79 @@ def finalize_service(service_config, service_names, version, environment): normalize_build(service_dict, service_config.working_dir, environment) + if compatibility: + service_dict, ignored_keys = translate_deploy_keys_to_container_config( + service_dict + ) + if ignored_keys: + log.warn( + 'The following deploy sub-keys are not supported in compatibility mode and have' + ' been ignored: {}'.format(', '.join(ignored_keys)) + ) + service_dict['name'] = service_config.name return normalize_v1_service_format(service_dict) +def translate_resource_keys_to_container_config(resources_dict, service_dict): + if 'limits' in resources_dict: + service_dict['mem_limit'] = resources_dict['limits'].get('memory') + if 'cpus' in resources_dict['limits']: + service_dict['cpus'] = float(resources_dict['limits']['cpus']) + if 'reservations' in resources_dict: + service_dict['mem_reservation'] = resources_dict['reservations'].get('memory') + if 'cpus' in resources_dict['reservations']: + return ['resources.reservations.cpus'] + + +def convert_restart_policy(name): + try: + return { + 'any': 'always', + 'none': 'no', + 'on-failure': 'on-failure' + }[name] + except KeyError: + raise ConfigurationError('Invalid restart policy "{}"'.format(name)) + + +def translate_deploy_keys_to_container_config(service_dict): + if 'deploy' not in service_dict: + return service_dict, [] + + deploy_dict = service_dict['deploy'] + ignored_keys = [ + k for k in ['endpoint_mode', 'labels', 'update_config', 'placement'] + if k in deploy_dict + ] + + if 'replicas' in deploy_dict and deploy_dict.get('mode') == 'replicated': + service_dict['scale'] = deploy_dict['replicas'] + + if 'restart_policy' in deploy_dict: + service_dict['restart'] = { + 'Name': convert_restart_policy(deploy_dict['restart_policy'].get('condition', 'any')), + 'MaximumRetryCount': deploy_dict['restart_policy'].get('max_attempts', 0) + } + for k in deploy_dict['restart_policy'].keys(): + if k != 'condition' and k != 'max_attempts': + ignored_keys.append('restart_policy.{}'.format(k)) + + ignored_keys.extend( + translate_resource_keys_to_container_config( + deploy_dict.get('resources', {}), service_dict + ) + ) + + del service_dict['deploy'] + if 'credential_spec' in service_dict: + del service_dict['credential_spec'] + if 'configs' in service_dict: + del service_dict['configs'] + + return service_dict, ignored_keys + + def normalize_v1_service_format(service_dict): if 'log_driver' in service_dict or 'log_opt' in service_dict: if 'logging' not in service_dict: diff --git a/tests/fixtures/v3-full/docker-compose.yml b/tests/fixtures/v3-full/docker-compose.yml index 2bc0e248d13..3a7ac25c905 100644 --- a/tests/fixtures/v3-full/docker-compose.yml +++ b/tests/fixtures/v3-full/docker-compose.yml @@ -1,8 +1,7 @@ -version: "3.2" +version: "3.5" services: web: image: busybox - deploy: mode: replicated replicas: 6 @@ -15,18 +14,22 @@ services: max_failure_ratio: 0.3 resources: limits: - cpus: '0.001' + cpus: '0.05' memory: 50M reservations: - cpus: '0.0001' + cpus: '0.01' memory: 20M restart_policy: - condition: on_failure + condition: on-failure delay: 5s max_attempts: 3 window: 120s placement: - constraints: [node=foo] + constraints: + - node.hostname==foo + - node.role != manager + preferences: + - spread: node.labels.datacenter healthcheck: test: cat /etc/passwd From df6e300081667688bbee9e457558ad9e5e4145df Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 6 Feb 2018 14:50:52 -0800 Subject: [PATCH 3272/4072] Update dockerfile.run to compile the latest (2.27) version of glibc Signed-off-by: Joffrey F --- Dockerfile.run | 36 ++++++++++++++++++++++++++++-------- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/Dockerfile.run b/Dockerfile.run index 5d246e9e6cf..a09e57a091a 100644 --- a/Dockerfile.run +++ b/Dockerfile.run @@ -1,13 +1,33 @@ -FROM alpine:3.4 +FROM sgerrand/glibc-builder as glibc +RUN apt-get install -yq bison -ENV GLIBC 2.23-r3 +ENV PKGDIR /pkgdata -RUN apk update && apk add --no-cache openssl ca-certificates && \ - wget -q -O /etc/apk/keys/sgerrand.rsa.pub https://raw.githubusercontent.com/sgerrand/alpine-pkg-glibc/master/sgerrand.rsa.pub && \ - wget https://github.com/sgerrand/alpine-pkg-glibc/releases/download/$GLIBC/glibc-$GLIBC.apk && \ - apk add --no-cache glibc-$GLIBC.apk && rm glibc-$GLIBC.apk && \ - ln -s /lib/libz.so.1 /usr/glibc-compat/lib/ && \ - ln -s /lib/libc.musl-x86_64.so.1 /usr/glibc-compat/lib +RUN mkdir -p /usr/glibc-compat/etc && touch /usr/glibc-compat/etc/ld.so.conf +RUN /builder 2.27 /usr/glibc-compat || true +RUN mkdir -p $PKGDIR +RUN tar -xf /glibc-bin-2.27.tar.gz -C $PKGDIR +RUN rm "$PKGDIR"/usr/glibc-compat/etc/rpc && \ + rm -rf "$PKGDIR"/usr/glibc-compat/bin && \ + rm -rf "$PKGDIR"/usr/glibc-compat/sbin && \ + rm -rf "$PKGDIR"/usr/glibc-compat/lib/gconv && \ + rm -rf "$PKGDIR"/usr/glibc-compat/lib/getconf && \ + rm -rf "$PKGDIR"/usr/glibc-compat/lib/audit && \ + rm -rf "$PKGDIR"/usr/glibc-compat/share && \ + rm -rf "$PKGDIR"/usr/glibc-compat/var + + +FROM alpine:3.6 + +RUN apk update && apk add --no-cache openssl ca-certificates +COPY --from=glibc /pkgdata/ / + +RUN mkdir -p /lib /lib64 /usr/glibc-compat/lib/locale /etc && \ + ln -s /lib/libz.so.1 /usr/glibc-compat/lib/ && \ + ln -s /lib/libc.musl-x86_64.so.1 /usr/glibc-compat/lib && \ + ln -s /usr/glibc-compat/lib/ld-linux-x86-64.so.2 /lib/ld-linux-x86-64.so.2 && \ + ln -s /usr/glibc-compat/lib/ld-linux-x86-64.so.2 /lib64/ld-linux-x86-64.so.2 && \ + ln -s /usr/glibc-compat/etc/ld.so.cache /etc/ld.so.cache COPY dist/docker-compose-Linux-x86_64 /usr/local/bin/docker-compose From 3a0ed8cbbae36a197697e0876fb932ae10604d6a Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 6 Feb 2018 16:58:15 -0800 Subject: [PATCH 3273/4072] Modified options dict leads to a mismatched config hash for API < 1.30 Signed-off-by: Joffrey F --- compose/service.py | 4 ++-- tests/unit/service_test.py | 20 ++++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/compose/service.py b/compose/service.py index b3d91113589..f79a4696255 100644 --- a/compose/service.py +++ b/compose/service.py @@ -849,10 +849,10 @@ def _build_container_volume_options(self, previous_container, container_options, override_options['mounts'] = [build_mount(v) for v in container_mounts] or None else: # Workaround for 3.2 format - self.options['tmpfs'] = self.options.get('tmpfs') or [] + override_options['tmpfs'] = self.options.get('tmpfs') or [] for m in container_mounts: if m.is_tmpfs: - self.options['tmpfs'].append(m.target) + override_options['tmpfs'].append(m.target) else: override_options['binds'].append(m.legacy_repr()) container_options['volumes'][m.target] = {} diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 21bac8b83c1..002ae0c0db2 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -13,6 +13,7 @@ from compose.config.types import ServiceSecret from compose.config.types import VolumeFromSpec from compose.config.types import VolumeSpec +from compose.const import API_VERSIONS from compose.const import LABEL_CONFIG_HASH from compose.const import LABEL_ONE_OFF from compose.const import LABEL_PROJECT @@ -599,6 +600,25 @@ def test_config_dict_with_network_mode_from_container(self): } assert config_dict == expected + def test_config_hash_matches_label(self): + self.mock_client.inspect_image.return_value = {'Id': 'abcd'} + service = Service( + 'foo', + image='example.com/foo', + client=self.mock_client, + network_mode=NetworkMode('bridge'), + networks={'bridge': {}}, + links=[(Service('one', client=self.mock_client), 'one')], + volumes_from=[VolumeFromSpec(Service('two', client=self.mock_client), 'rw', 'service')] + ) + config_hash = service.config_hash + + for api_version in set(API_VERSIONS.values()): + self.mock_client.api_version = api_version + assert service._get_container_create_options({}, 1)['labels'][LABEL_CONFIG_HASH] == ( + config_hash + ) + def test_remove_image_none(self): web = Service('web', image='example', client=self.mock_client) assert not web.remove_image(ImageType.none) From b0992579267da90c50843150949a3102180a0b54 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 6 Feb 2018 14:50:52 -0800 Subject: [PATCH 3274/4072] Update dockerfile.run to compile the latest (2.27) version of glibc Signed-off-by: Joffrey F --- Dockerfile.run | 36 ++++++++++++++++++++++++++++-------- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/Dockerfile.run b/Dockerfile.run index 5d246e9e6cf..a09e57a091a 100644 --- a/Dockerfile.run +++ b/Dockerfile.run @@ -1,13 +1,33 @@ -FROM alpine:3.4 +FROM sgerrand/glibc-builder as glibc +RUN apt-get install -yq bison -ENV GLIBC 2.23-r3 +ENV PKGDIR /pkgdata -RUN apk update && apk add --no-cache openssl ca-certificates && \ - wget -q -O /etc/apk/keys/sgerrand.rsa.pub https://raw.githubusercontent.com/sgerrand/alpine-pkg-glibc/master/sgerrand.rsa.pub && \ - wget https://github.com/sgerrand/alpine-pkg-glibc/releases/download/$GLIBC/glibc-$GLIBC.apk && \ - apk add --no-cache glibc-$GLIBC.apk && rm glibc-$GLIBC.apk && \ - ln -s /lib/libz.so.1 /usr/glibc-compat/lib/ && \ - ln -s /lib/libc.musl-x86_64.so.1 /usr/glibc-compat/lib +RUN mkdir -p /usr/glibc-compat/etc && touch /usr/glibc-compat/etc/ld.so.conf +RUN /builder 2.27 /usr/glibc-compat || true +RUN mkdir -p $PKGDIR +RUN tar -xf /glibc-bin-2.27.tar.gz -C $PKGDIR +RUN rm "$PKGDIR"/usr/glibc-compat/etc/rpc && \ + rm -rf "$PKGDIR"/usr/glibc-compat/bin && \ + rm -rf "$PKGDIR"/usr/glibc-compat/sbin && \ + rm -rf "$PKGDIR"/usr/glibc-compat/lib/gconv && \ + rm -rf "$PKGDIR"/usr/glibc-compat/lib/getconf && \ + rm -rf "$PKGDIR"/usr/glibc-compat/lib/audit && \ + rm -rf "$PKGDIR"/usr/glibc-compat/share && \ + rm -rf "$PKGDIR"/usr/glibc-compat/var + + +FROM alpine:3.6 + +RUN apk update && apk add --no-cache openssl ca-certificates +COPY --from=glibc /pkgdata/ / + +RUN mkdir -p /lib /lib64 /usr/glibc-compat/lib/locale /etc && \ + ln -s /lib/libz.so.1 /usr/glibc-compat/lib/ && \ + ln -s /lib/libc.musl-x86_64.so.1 /usr/glibc-compat/lib && \ + ln -s /usr/glibc-compat/lib/ld-linux-x86-64.so.2 /lib/ld-linux-x86-64.so.2 && \ + ln -s /usr/glibc-compat/lib/ld-linux-x86-64.so.2 /lib64/ld-linux-x86-64.so.2 && \ + ln -s /usr/glibc-compat/etc/ld.so.cache /etc/ld.so.cache COPY dist/docker-compose-Linux-x86_64 /usr/local/bin/docker-compose From ea64baa8fe35dc5cab27fb518ed4f6001222ce83 Mon Sep 17 00:00:00 2001 From: Daniel Schildt Date: Sat, 3 Feb 2018 17:22:16 +0200 Subject: [PATCH 3275/4072] Improve spelling in the README.md Improve spelling of the brand names: - GitHub instead of Github Signed-off-by: Daniel Schildt --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e3ca8f833e0..ea07f6a7d27 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ Installation and documentation - Full documentation is available on [Docker's website](https://docs.docker.com/compose/). - If you have any questions, you can talk in real-time with other developers in the #docker-compose IRC channel on Freenode. [Click here to join using IRCCloud.](https://www.irccloud.com/invite?hostname=irc.freenode.net&channel=%23docker-compose) -- Code repository for Compose is on [Github](https://github.com/docker/compose) +- Code repository for Compose is on [GitHub](https://github.com/docker/compose) - If you find any problems please fill out an [issue](https://github.com/docker/compose/issues/new) Contributing From 84f7bef1b7efeb6fe9fe59e8532b3fa534e27ac9 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 6 Feb 2018 16:58:15 -0800 Subject: [PATCH 3276/4072] Modified options dict leads to a mismatched config hash for API < 1.30 Signed-off-by: Joffrey F --- compose/service.py | 4 ++-- tests/unit/service_test.py | 20 ++++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/compose/service.py b/compose/service.py index b1f7d707b34..6830c4d7b2e 100644 --- a/compose/service.py +++ b/compose/service.py @@ -849,10 +849,10 @@ def _build_container_volume_options(self, previous_container, container_options, override_options['mounts'] = [build_mount(v) for v in container_mounts] or None else: # Workaround for 3.2 format - self.options['tmpfs'] = self.options.get('tmpfs') or [] + override_options['tmpfs'] = self.options.get('tmpfs') or [] for m in container_mounts: if m.is_tmpfs: - self.options['tmpfs'].append(m.target) + override_options['tmpfs'].append(m.target) else: override_options['binds'].append(m.legacy_repr()) container_options['volumes'][m.target] = {} diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 92d7f08d5a3..d0c0b0900e3 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -13,6 +13,7 @@ from compose.config.types import ServiceSecret from compose.config.types import VolumeFromSpec from compose.config.types import VolumeSpec +from compose.const import API_VERSIONS from compose.const import LABEL_CONFIG_HASH from compose.const import LABEL_ONE_OFF from compose.const import LABEL_PROJECT @@ -601,6 +602,25 @@ def test_config_dict_with_network_mode_from_container(self): } assert config_dict == expected + def test_config_hash_matches_label(self): + self.mock_client.inspect_image.return_value = {'Id': 'abcd'} + service = Service( + 'foo', + image='example.com/foo', + client=self.mock_client, + network_mode=NetworkMode('bridge'), + networks={'bridge': {}}, + links=[(Service('one', client=self.mock_client), 'one')], + volumes_from=[VolumeFromSpec(Service('two', client=self.mock_client), 'rw', 'service')] + ) + config_hash = service.config_hash + + for api_version in set(API_VERSIONS.values()): + self.mock_client.api_version = api_version + assert service._get_container_create_options({}, 1)['labels'][LABEL_CONFIG_HASH] == ( + config_hash + ) + def test_remove_image_none(self): web = Service('web', image='example', client=self.mock_client) assert not web.remove_image(ImageType.none) From 9e633ef35af238fd4d375d7bbfc2450316b21387 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 7 Feb 2018 10:53:31 -0800 Subject: [PATCH 3277/4072] Bump 1.19.0 Signed-off-by: Joffrey F --- CHANGELOG.md | 2 +- compose/__init__.py | 2 +- script/run/run.sh | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea970a10048..4287de49373 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ Change log ========== -1.19.0 (2018-01-31) +1.19.0 (2018-02-07) ------------------- ### Breaking changes diff --git a/compose/__init__.py b/compose/__init__.py index e5e83434fcb..6688ef4f10e 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.19.0-rc3' +__version__ = '1.19.0' diff --git a/script/run/run.sh b/script/run/run.sh index 7355d9181f7..ae55ff75980 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.19.0-rc3" +VERSION="1.19.0" IMAGE="docker/compose:$VERSION" From 8ea89efdd86da53c0d1b6ac20b1fc4a5e7c9d3cf Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 7 Feb 2018 12:20:45 -0800 Subject: [PATCH 3278/4072] 1.20.0-dev Signed-off-by: Joffrey F --- CHANGELOG.md | 2 +- compose/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea970a10048..4287de49373 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ Change log ========== -1.19.0 (2018-01-31) +1.19.0 (2018-02-07) ------------------- ### Breaking changes diff --git a/compose/__init__.py b/compose/__init__.py index e5e83434fcb..f0ad67347bf 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.19.0-rc3' +__version__ = '1.20.0dev' From 75572f38609b57d637d79c67660a9c0899527f34 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 7 Feb 2018 14:03:31 -0800 Subject: [PATCH 3279/4072] Immediately kill / force-rm one-off container when receiving SIGHUP Signed-off-by: Joffrey F --- 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 9359ec70316..4319d0adabf 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -1274,7 +1274,7 @@ def remove_container(force=False): service.start_container(container) pty.start(sockets) exit_code = container.wait() - except (signals.ShutdownException, signals.HangUpException): + except (signals.ShutdownException): project.client.stop(container.id) exit_code = 1 except (signals.ShutdownException, signals.HangUpException): From cd87d88882f6b10d259f5618e98e5110eea2ba88 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 8 Feb 2018 13:59:27 -0800 Subject: [PATCH 3280/4072] Add `docker` CLI to the `docker/compose` image Signed-off-by: Joffrey F --- Dockerfile.run | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/Dockerfile.run b/Dockerfile.run index a09e57a091a..6c532fc1411 100644 --- a/Dockerfile.run +++ b/Dockerfile.run @@ -29,6 +29,16 @@ RUN mkdir -p /lib /lib64 /usr/glibc-compat/lib/locale /etc && \ ln -s /usr/glibc-compat/lib/ld-linux-x86-64.so.2 /lib64/ld-linux-x86-64.so.2 && \ ln -s /usr/glibc-compat/etc/ld.so.cache /etc/ld.so.cache +RUN apk add --no-cache curl && \ + curl -fsSL -o dockerbins.tgz "https://download.docker.com/linux/static/stable/x86_64/docker-17.12.0-ce.tgz" && \ + SHA256=692e1c72937f6214b1038def84463018d8e320c8eaf8530546c84c2f8f9c767d; \ + echo "${SHA256} dockerbins.tgz" | sha256sum -c - && \ + tar xvf dockerbins.tgz docker/docker --strip-components 1 && \ + mv docker /usr/local/bin/docker && \ + chmod +x /usr/local/bin/docker && \ + rm dockerbins.tgz && \ + apk del curl + COPY dist/docker-compose-Linux-x86_64 /usr/local/bin/docker-compose ENTRYPOINT ["docker-compose"] From bb8c2e1f456b1b846dbb8d87c1cb6c83a4ae55b8 Mon Sep 17 00:00:00 2001 From: Brian de Alwis Date: Fri, 9 Feb 2018 11:22:36 -0500 Subject: [PATCH 3281/4072] Fix bash completion on systems where extglob is not set Signed-off-by: Brian de Alwis --- contrib/completion/bash/docker-compose | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index 98e1d6c0d14..fc51f604e43 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -16,6 +16,8 @@ # below to your .bashrc after bash completion features are loaded # . ~/.docker-compose-completion.sh +__docker_compose_previous_extglob_setting=$(shopt -p extglob) +shopt -s extglob __docker_compose_q() { docker-compose 2>/dev/null "${top_level_options[@]}" "$@" @@ -658,4 +660,7 @@ _docker_compose() { return 0 } +eval "$__docker_compose_previous_extglob_setting" +unset __docker_compose_previous_extglob_setting + complete -F _docker_compose docker-compose docker-compose.exe From 515ea20f25de739d496f2931fe9fd1899301781b Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 12 Feb 2018 11:54:08 -0800 Subject: [PATCH 3282/4072] Fix Dockerfile.run indentation Signed-off-by: Joffrey F --- Dockerfile.run | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/Dockerfile.run b/Dockerfile.run index 6c532fc1411..b3f9a01f6c9 100644 --- a/Dockerfile.run +++ b/Dockerfile.run @@ -8,13 +8,13 @@ RUN /builder 2.27 /usr/glibc-compat || true RUN mkdir -p $PKGDIR RUN tar -xf /glibc-bin-2.27.tar.gz -C $PKGDIR RUN rm "$PKGDIR"/usr/glibc-compat/etc/rpc && \ - rm -rf "$PKGDIR"/usr/glibc-compat/bin && \ - rm -rf "$PKGDIR"/usr/glibc-compat/sbin && \ - rm -rf "$PKGDIR"/usr/glibc-compat/lib/gconv && \ - rm -rf "$PKGDIR"/usr/glibc-compat/lib/getconf && \ - rm -rf "$PKGDIR"/usr/glibc-compat/lib/audit && \ - rm -rf "$PKGDIR"/usr/glibc-compat/share && \ - rm -rf "$PKGDIR"/usr/glibc-compat/var + rm -rf "$PKGDIR"/usr/glibc-compat/bin && \ + rm -rf "$PKGDIR"/usr/glibc-compat/sbin && \ + rm -rf "$PKGDIR"/usr/glibc-compat/lib/gconv && \ + rm -rf "$PKGDIR"/usr/glibc-compat/lib/getconf && \ + rm -rf "$PKGDIR"/usr/glibc-compat/lib/audit && \ + rm -rf "$PKGDIR"/usr/glibc-compat/share && \ + rm -rf "$PKGDIR"/usr/glibc-compat/var FROM alpine:3.6 @@ -23,11 +23,11 @@ RUN apk update && apk add --no-cache openssl ca-certificates COPY --from=glibc /pkgdata/ / RUN mkdir -p /lib /lib64 /usr/glibc-compat/lib/locale /etc && \ - ln -s /lib/libz.so.1 /usr/glibc-compat/lib/ && \ + ln -s /lib/libz.so.1 /usr/glibc-compat/lib/ && \ ln -s /lib/libc.musl-x86_64.so.1 /usr/glibc-compat/lib && \ - ln -s /usr/glibc-compat/lib/ld-linux-x86-64.so.2 /lib/ld-linux-x86-64.so.2 && \ - ln -s /usr/glibc-compat/lib/ld-linux-x86-64.so.2 /lib64/ld-linux-x86-64.so.2 && \ - ln -s /usr/glibc-compat/etc/ld.so.cache /etc/ld.so.cache + ln -s /usr/glibc-compat/lib/ld-linux-x86-64.so.2 /lib/ld-linux-x86-64.so.2 && \ + ln -s /usr/glibc-compat/lib/ld-linux-x86-64.so.2 /lib64/ld-linux-x86-64.so.2 && \ + ln -s /usr/glibc-compat/etc/ld.so.cache /etc/ld.so.cache RUN apk add --no-cache curl && \ curl -fsSL -o dockerbins.tgz "https://download.docker.com/linux/static/stable/x86_64/docker-17.12.0-ce.tgz" && \ From 981df93f121ee4a8342f26d5b6441728427c1e5d Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 12 Feb 2018 12:05:45 -0800 Subject: [PATCH 3283/4072] Keep CONTRIBUTING.md information up to date Signed-off-by: Joffrey F --- CONTRIBUTING.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a031e2d6838..5bf7cb1318c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -43,7 +43,11 @@ To run the style checks at any time run `tox -e pre-commit`. ## Submitting a pull request -See Docker's [basic contribution workflow](https://docs.docker.com/opensource/workflow/make-a-contribution/#the-basic-contribution-workflow) for a guide on how to submit a pull request for code or documentation. +See Docker's [basic contribution workflow](https://docs.docker.com/v17.06/opensource/code/#code-contribution-workflow) for a guide on how to submit a pull request for code. + +## Documentation changes + +Issues and pull requests to update the documentation should be submitted to the [docs repo](https://github.com/docker/docker.github.io). You can learn more about contributing to the documentation [here](https://docs.docker.com/opensource/#how-to-contribute-to-the-docs). ## Running the test suite @@ -69,6 +73,4 @@ you can specify a test directory, file, module, class or method: ## Finding things to work on -We use a [ZenHub board](https://www.zenhub.io/) to keep track of specific things we are working on and planning to work on. If you're looking for things to work on, stuff in the backlog is a great place to start. - -For more information about our project planning, take a look at our [GitHub wiki](https://github.com/docker/compose/wiki). +[Issues marked with the `exp/beginner` label](https://github.com/docker/compose/issues?q=is%3Aopen+is%3Aissue+label%3Aexp%2Fbeginner) are a good starting point for people looking to make their first contribution to the project. From bb16d9e951afbd62a9f22d02b2eb613c1ac3ef4c Mon Sep 17 00:00:00 2001 From: Sorawis Nilparuk Date: Sat, 3 Feb 2018 13:14:27 -0800 Subject: [PATCH 3284/4072] Add health string generator to match `docker ps` output --- compose/container.py | 14 +++++++- tests/unit/container_test.py | 67 ++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) diff --git a/compose/container.py b/compose/container.py index 9323b119249..0c2ca990218 100644 --- a/compose/container.py +++ b/compose/container.py @@ -129,7 +129,7 @@ def human_readable_state(self): if self.is_restarting: return 'Restarting' if self.is_running: - return 'Ghost' if self.get('State.Ghost') else 'Up' + return 'Ghost' if self.get('State.Ghost') else self.human_readable_health_status else: return 'Exit %s' % self.get('State.ExitCode') @@ -172,6 +172,18 @@ def has_api_logs(self): log_type = self.log_driver return not log_type or log_type in ('json-file', 'journald') + @property + def human_readable_health_status(self): + """ Generate UP status string with up time and health + """ + status_string = 'Up' + container_status = self.get('State.Health.Status') + if container_status == 'starting': + status_string += ' (health: starting)' + elif container_status is not None: + status_string += ' (%s)' % container_status + return status_string + def attach_log_stream(self): """A log stream can only be attached if the container uses a json-file log driver. diff --git a/tests/unit/container_test.py b/tests/unit/container_test.py index 0fcf23fa6cf..d64263c1fd6 100644 --- a/tests/unit/container_test.py +++ b/tests/unit/container_test.py @@ -129,6 +129,73 @@ def test_get_local_port(self): assert container.get_local_port(45454, protocol='tcp') == '0.0.0.0:49197' + def test_human_readable_states_no_health(self): + container = Container(None, { + "State": { + "Status": "running", + "Running": True, + "Paused": False, + "Restarting": False, + "OOMKilled": False, + "Dead": False, + "Pid": 7623, + "ExitCode": 0, + "Error": "", + "StartedAt": "2018-01-29T00:34:25.2052414Z", + "FinishedAt": "0001-01-01T00:00:00Z" + }, + }, has_been_inspected=True) + expected = "Up" + assert container.human_readable_state == expected + + def test_human_readable_states_starting(self): + container = Container(None, { + "State": { + "Status": "running", + "Running": True, + "Paused": False, + "Restarting": False, + "OOMKilled": False, + "Dead": False, + "Pid": 11744, + "ExitCode": 0, + "Error": "", + "StartedAt": "2018-02-03T07:56:20.3591233Z", + "FinishedAt": "2018-01-31T08:56:11.0505228Z", + "Health": { + "Status": "starting", + "FailingStreak": 0, + "Log": [] + } + } + }, has_been_inspected=True) + expected = "Up (health: starting)" + assert container.human_readable_state == expected + + def test_human_readable_states_healthy(self): + container = Container(None, { + "State": { + "Status": "running", + "Running": True, + "Paused": False, + "Restarting": False, + "OOMKilled": False, + "Dead": False, + "Pid": 5674, + "ExitCode": 0, + "Error": "", + "StartedAt": "2018-02-03T08:32:05.3281831Z", + "FinishedAt": "2018-02-03T08:11:35.7872706Z", + "Health": { + "Status": "healthy", + "FailingStreak": 0, + "Log": [] + } + } + }, has_been_inspected=True) + expected = "Up (healthy)" + assert container.human_readable_state == expected + def test_get(self): container = Container(None, { "Status": "Up 8 seconds", From 64b466c0bc56a871aee6c34e527789d4a5a658ed Mon Sep 17 00:00:00 2001 From: kcboschert Date: Wed, 27 Jan 2016 05:02:33 +0000 Subject: [PATCH 3285/4072] Add optional argument to pull dependencies on docker-compose pull. Signed-off-by: Kevin Boschert --- compose/cli/main.py | 2 ++ compose/project.py | 5 +++-- tests/acceptance/cli_test.py | 15 +++++++++++++++ 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 4319d0adabf..edc07d4268b 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -677,12 +677,14 @@ def pull(self, options): --ignore-pull-failures Pull what it can and ignores images with pull failures. --parallel Pull multiple images in parallel. -q, --quiet Pull without printing progress information + --include-deps Also pull services declared as dependencies """ self.project.pull( service_names=options['SERVICE'], ignore_pull_failures=options.get('--ignore-pull-failures'), parallel_pull=options.get('--parallel'), silent=options.get('--quiet'), + include_deps=options.get('--include-deps'), ) def push(self, options): diff --git a/compose/project.py b/compose/project.py index 1880f39ad0e..5c72444937c 100644 --- a/compose/project.py +++ b/compose/project.py @@ -537,8 +537,9 @@ def _get_convergence_plans(self, services, strategy, always_recreate_deps=False) return plans - def pull(self, service_names=None, ignore_pull_failures=False, parallel_pull=False, silent=False): - services = self.get_services(service_names, include_deps=False) + def pull(self, service_names=None, ignore_pull_failures=False, parallel_pull=False, silent=False, + include_deps=False): + services = self.get_services(service_names, include_deps) if parallel_pull: def pull_service(service): diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 9dd03b72f58..134485c4053 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -567,6 +567,21 @@ def test_pull_with_parallel_failure(self): result.stderr ) + def test_pull_with_no_deps(self): + self.base_dir = 'tests/fixtures/links-composefile' + result = self.dispatch(['pull', 'web']) + assert sorted(result.stderr.split('\n'))[1:] == [ + 'Pulling web (busybox:latest)...', + ] + + def test_pull_with_include_deps(self): + self.base_dir = 'tests/fixtures/links-composefile' + result = self.dispatch(['pull', '--include-deps', 'web']) + assert sorted(result.stderr.split('\n'))[1:] == [ + 'Pulling db (busybox:latest)...', + 'Pulling web (busybox:latest)...', + ] + def test_build_plain(self): self.base_dir = 'tests/fixtures/simple-dockerfile' self.dispatch(['build', 'simple']) From 51076b5e1280aa316b4a3eaf7ab636357ed467e1 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 16 Feb 2018 16:15:55 -0800 Subject: [PATCH 3286/4072] Tests for compatibility mode Signed-off-by: Joffrey F --- compose/cli/main.py | 15 ++-- compose/config/config.py | 3 +- compose/config/config_schema_v3.6.json | 2 +- tests/acceptance/cli_test.py | 34 +++++++-- .../compatibility-mode/docker-compose.yml | 22 ++++++ tests/unit/config/config_test.py | 76 +++++++++++++++++++ 6 files changed, 138 insertions(+), 14 deletions(-) create mode 100644 tests/fixtures/compatibility-mode/docker-compose.yml diff --git a/compose/cli/main.py b/compose/cli/main.py index 9c76a561b3a..56abf4b4885 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -168,8 +168,10 @@ 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) + -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 --no-ansi Do not print ANSI control characters -v, --version Print version and exit @@ -180,13 +182,12 @@ 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) + --skip-hostname-check Don't check the daemon's hostname against the + name specified in the client certificate --project-directory PATH Specify an alternate working directory (default: the path of the Compose file) - --compatibility If set, Compose will attempt to convert deploy keys in v3 - files to their non-Swarm equivalent + --compatibility If set, Compose will attempt to convert deploy + keys in v3 files to their non-Swarm equivalent Commands: build Build or rebuild services diff --git a/compose/config/config.py b/compose/config/config.py index 58420d15732..b7764dd3bfe 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -896,6 +896,7 @@ def translate_resource_keys_to_container_config(resources_dict, service_dict): service_dict['mem_reservation'] = resources_dict['reservations'].get('memory') if 'cpus' in resources_dict['reservations']: return ['resources.reservations.cpus'] + return [] def convert_restart_policy(name): @@ -919,7 +920,7 @@ def translate_deploy_keys_to_container_config(service_dict): if k in deploy_dict ] - if 'replicas' in deploy_dict and deploy_dict.get('mode') == 'replicated': + if 'replicas' in deploy_dict and deploy_dict.get('mode', 'replicated') == 'replicated': service_dict['scale'] = deploy_dict['replicas'] if 'restart_policy' in deploy_dict: diff --git a/compose/config/config_schema_v3.6.json b/compose/config/config_schema_v3.6.json index 8e718780bf4..95a552b346c 100644 --- a/compose/config/config_schema_v3.6.json +++ b/compose/config/config_schema_v3.6.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-04/schema#", - "id": "config_schema_v3.5.json", + "id": "config_schema_v3.6.json", "type": "object", "required": ["version"], diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index ade7d10a9d3..67d953483e2 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -395,7 +395,7 @@ def test_config_v3(self): result = self.dispatch(['config']) assert yaml.load(result.stdout) == { - 'version': '3.2', + 'version': '3.5', 'volumes': { 'foobar': { 'labels': { @@ -419,22 +419,25 @@ def test_config_v3(self): }, 'resources': { 'limits': { - 'cpus': '0.001', + 'cpus': '0.05', 'memory': '50M', }, 'reservations': { - 'cpus': '0.0001', + 'cpus': '0.01', 'memory': '20M', }, }, 'restart_policy': { - 'condition': 'on_failure', + 'condition': 'on-failure', 'delay': '5s', 'max_attempts': 3, 'window': '120s', }, 'placement': { - 'constraints': ['node=foo'], + 'constraints': [ + 'node.hostname==foo', 'node.role != manager' + ], + 'preferences': [{'spread': 'node.labels.datacenter'}] }, }, @@ -464,6 +467,27 @@ def test_config_v3(self): }, } + def test_config_compatibility_mode(self): + self.base_dir = 'tests/fixtures/compatibility-mode' + result = self.dispatch(['--compatibility', 'config']) + + assert yaml.load(result.stdout) == { + 'version': '2.3', + 'volumes': {'foo': {'driver': 'default'}}, + 'services': { + 'foo': { + 'command': '/bin/true', + 'image': 'alpine:3.7', + 'scale': 3, + 'restart': 'always:7', + 'mem_limit': '300M', + 'mem_reservation': '100M', + 'cpus': 0.7, + 'volumes': ['foo:/bar:rw'] + } + } + } + def test_ps(self): self.project.get_service('simple').create_container() result = self.dispatch(['ps']) diff --git a/tests/fixtures/compatibility-mode/docker-compose.yml b/tests/fixtures/compatibility-mode/docker-compose.yml new file mode 100644 index 00000000000..aac6fd4cb94 --- /dev/null +++ b/tests/fixtures/compatibility-mode/docker-compose.yml @@ -0,0 +1,22 @@ +version: '3.5' +services: + foo: + image: alpine:3.7 + command: /bin/true + deploy: + replicas: 3 + restart_policy: + condition: any + max_attempts: 7 + resources: + limits: + memory: 300M + cpus: '0.7' + reservations: + memory: 100M + volumes: + - foo:/bar + +volumes: + foo: + driver: default diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index a3308072689..d72fae2f5c2 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -3303,6 +3303,82 @@ def test_unset_variable_produces_warning(self): assert 'BAR' in warnings[0] assert 'FOO' in warnings[1] + def test_compatibility_mode_warnings(self): + config_details = build_config_details({ + 'version': '3.5', + 'services': { + 'web': { + 'deploy': { + 'labels': ['abc=def'], + 'endpoint_mode': 'dnsrr', + 'update_config': {'max_failure_ratio': 0.4}, + 'placement': {'constraints': ['node.id==deadbeef']}, + 'resources': { + 'reservations': {'cpus': '0.2'} + }, + 'restart_policy': { + 'delay': '2s', + 'window': '12s' + } + }, + 'image': 'busybox' + } + } + }) + + with mock.patch('compose.config.config.log') as log: + config.load(config_details, compatibility=True) + + assert log.warn.call_count == 1 + warn_message = log.warn.call_args[0][0] + assert warn_message.startswith( + 'The following deploy sub-keys are not supported in compatibility mode' + ) + assert 'labels' in warn_message + assert 'endpoint_mode' in warn_message + assert 'update_config' in warn_message + assert 'placement' in warn_message + assert 'resources.reservations.cpus' in warn_message + assert 'restart_policy.delay' in warn_message + assert 'restart_policy.window' in warn_message + + def test_compatibility_mode_load(self): + config_details = build_config_details({ + 'version': '3.5', + 'services': { + 'foo': { + 'image': 'alpine:3.7', + 'deploy': { + 'replicas': 3, + 'restart_policy': { + 'condition': 'any', + 'max_attempts': 7, + }, + 'resources': { + 'limits': {'memory': '300M', 'cpus': '0.7'}, + 'reservations': {'memory': '100M'}, + }, + }, + }, + }, + }) + + with mock.patch('compose.config.config.log') as log: + cfg = config.load(config_details, compatibility=True) + + assert log.warn.call_count == 0 + + service_dict = cfg.services[0] + assert service_dict == { + 'image': 'alpine:3.7', + 'scale': 3, + 'restart': {'MaximumRetryCount': 7, 'Name': 'always'}, + 'mem_limit': '300M', + 'mem_reservation': '100M', + 'cpus': 0.7, + 'name': 'foo' + } + @mock.patch.dict(os.environ) def test_invalid_interpolation(self): with pytest.raises(config.ConfigurationError) as cm: From cd7ccad81ee527582992bbc225d5f485cb5e12bb Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 21 Feb 2018 13:24:25 -0800 Subject: [PATCH 3287/4072] Retrieve certs from default path if not provided explicitly Signed-off-by: Joffrey F --- compose/cli/docker_client.py | 11 +++++++++++ tests/unit/cli/docker_client_test.py | 21 +++++++++++++++++---- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/compose/cli/docker_client.py b/compose/cli/docker_client.py index 818fe63addd..cc8993d7fb3 100644 --- a/compose/cli/docker_client.py +++ b/compose/cli/docker_client.py @@ -9,6 +9,7 @@ from docker.errors import TLSParameterError from docker.tls import TLSConfig from docker.utils import kwargs_from_env +from docker.utils.config import home_dir from ..config.environment import Environment from ..const import HTTP_TIMEOUT @@ -19,6 +20,10 @@ log = logging.getLogger(__name__) +def default_cert_path(): + return os.path.join(home_dir(), '.docker') + + def get_tls_version(environment): compose_tls_version = environment.get('COMPOSE_TLS_VERSION', None) if not compose_tls_version: @@ -56,6 +61,12 @@ def tls_config_from_options(options, environment=None): key = os.path.join(cert_path, 'key.pem') ca_cert = os.path.join(cert_path, 'ca.pem') + if verify and not any((ca_cert, cert, key)): + # Default location for cert files is ~/.docker + ca_cert = os.path.join(default_cert_path(), 'ca.pem') + cert = os.path.join(default_cert_path(), 'cert.pem') + key = os.path.join(default_cert_path(), 'key.pem') + tls_version = get_tls_version(environment) advanced_opts = any([ca_cert, cert, key, verify, tls_version]) diff --git a/tests/unit/cli/docker_client_test.py b/tests/unit/cli/docker_client_test.py index 5bb4564ef06..be91ea31d8f 100644 --- a/tests/unit/cli/docker_client_test.py +++ b/tests/unit/cli/docker_client_test.py @@ -68,9 +68,10 @@ def test_user_agent(self): class TLSConfigTestCase(unittest.TestCase): - ca_cert = os.path.join('tests/fixtures/tls/', 'ca.pem') - client_cert = os.path.join('tests/fixtures/tls/', 'cert.pem') - key = os.path.join('tests/fixtures/tls/', 'key.pem') + cert_path = 'tests/fixtures/tls/' + ca_cert = os.path.join(cert_path, 'ca.pem') + client_cert = os.path.join(cert_path, 'cert.pem') + key = os.path.join(cert_path, 'key.pem') def test_simple_tls(self): options = {'--tls': True} @@ -202,7 +203,8 @@ def test_tls_flags_override_environment(self): def test_tls_verify_flag_no_override(self): environment = Environment({ 'DOCKER_TLS_VERIFY': 'true', - 'COMPOSE_TLS_VERSION': 'TLSv1' + 'COMPOSE_TLS_VERSION': 'TLSv1', + 'DOCKER_CERT_PATH': self.cert_path }) options = {'--tls': True, '--tlsverify': False} @@ -219,6 +221,17 @@ def test_tls_verify_env_falsy_value(self): options = {'--tls': True} assert tls_config_from_options(options, environment) is True + def test_tls_verify_default_cert_path(self): + environment = Environment({'DOCKER_TLS_VERIFY': '1'}) + options = {'--tls': True} + with mock.patch('compose.cli.docker_client.default_cert_path') as dcp: + dcp.return_value = 'tests/fixtures/tls/' + result = tls_config_from_options(options, environment) + assert isinstance(result, docker.tls.TLSConfig) + assert result.verify is True + assert result.ca_cert == self.ca_cert + assert result.cert == (self.client_cert, self.key) + class TestGetTlsVersion(object): def test_get_tls_version_default(self): From ac209a2485296d8b54ad794cd8a307d4b1374a51 Mon Sep 17 00:00:00 2001 From: Ramkumar Ramachandra Date: Fri, 22 Sep 2017 12:01:22 -0700 Subject: [PATCH 3288/4072] [cli] Lift artificial limitation on --build-arg Currently, `docker-compose --build-arg` requires that a service be specified as part of the command-line invocation. So, $ docker-compose build --build-arg nocache=`git rev-parse @` foom works. However, when using out-of-band scripts to automate the build process of several Docker containers (in a CI system, for instance), it becomes difficult to specify exactly which service requires the build-arg. Docker has supported Dockerfiles that ignore build-args for a long time, so there is no problem is specifying spurious build-args to builds that don't consume it. The limitation on `docker-compose build` today is artificial, and there are no other commands that require specifying a service. Allow `--build-arg` to also match all services so this is possible: $ docker-compose build --build-arg nocache=`git rev-parse @` Please refer to #3790 for discussion on the original feature. Signed-off-by: Ramkumar Ramachandra --- compose/cli/main.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index edc07d4268b..164769bbf95 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -234,19 +234,15 @@ def build(self, options): --no-cache Do not use cache when building the image. --pull Always attempt to pull a newer version of the image. -m, --memory MEM Sets memory limit for the build container. - --build-arg key=val Set build-time variables for one service. + --build-arg key=val Set build-time variables for services. """ - service_names = options['SERVICE'] build_args = options.get('--build-arg', None) if build_args: environment = Environment.from_env_file(self.project_dir) build_args = resolve_build_args(build_args, environment) - if not service_names and build_args: - raise UserError("Need service name for --build-arg option") - self.project.build( - service_names=service_names, + service_names=options['SERVICE'], no_cache=bool(options.get('--no-cache', False)), pull=bool(options.get('--pull', False)), force_rm=bool(options.get('--force-rm', False)), @@ -1030,7 +1026,8 @@ def up(rebuild): if cascade_stop: print("Aborting on container exit...") - all_containers = self.project.containers(service_names=options['SERVICE'], stopped=True) + all_containers = self.project.containers( + service_names=options['SERVICE'], stopped=True) exit_code = compute_exit_code( exit_value_from, attached_containers, cascade_starter, all_containers ) From 8e268afc936321753789a28817eb25f0ad619e85 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 21 Feb 2018 15:56:02 -0800 Subject: [PATCH 3289/4072] Add version guard for multi-service buildarg Add buildarg tests Signed-off-by: Joffrey F --- compose/cli/main.py | 9 +++++-- tests/acceptance/cli_test.py | 27 ++++++++++++++++++++ tests/fixtures/build-args/Dockerfile | 4 +++ tests/fixtures/build-args/docker-compose.yml | 7 +++++ 4 files changed, 45 insertions(+), 2 deletions(-) create mode 100644 tests/fixtures/build-args/Dockerfile create mode 100644 tests/fixtures/build-args/docker-compose.yml diff --git a/compose/cli/main.py b/compose/cli/main.py index 164769bbf95..fd7f92a81f7 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -236,8 +236,14 @@ def build(self, options): -m, --memory MEM Sets memory limit for the build container. --build-arg key=val Set build-time variables for services. """ + service_names = options['SERVICE'] build_args = options.get('--build-arg', None) if build_args: + if not service_names and docker.utils.version_lt(self.project.client.api_version, '1.25'): + raise UserError( + '--build-arg is only supported when services are specified for API version < 1.25.' + ' Please use a Compose file version > 2.2 or specify which services to build.' + ) environment = Environment.from_env_file(self.project_dir) build_args = resolve_build_args(build_args, environment) @@ -1026,8 +1032,7 @@ def up(rebuild): if cascade_stop: print("Aborting on container exit...") - all_containers = self.project.containers( - service_names=options['SERVICE'], stopped=True) + all_containers = self.project.containers(service_names=options['SERVICE'], stopped=True) exit_code = compute_exit_code( exit_value_from, attached_containers, cascade_starter, all_containers ) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 134485c4053..125501b0df4 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -658,6 +658,33 @@ def test_build_memory_build_option(self): result = self.dispatch(['build', '--no-cache', '--memory', '96m', 'service'], None) assert 'memory: 100663296' in result.stdout # 96 * 1024 * 1024 + def test_build_with_buildarg_from_compose_file(self): + pull_busybox(self.client) + self.base_dir = 'tests/fixtures/build-args' + result = self.dispatch(['build'], None) + assert 'Favorite Touhou Character: mariya.kirisame' in result.stdout + + def test_build_with_buildarg_cli_override(self): + pull_busybox(self.client) + self.base_dir = 'tests/fixtures/build-args' + result = self.dispatch(['build', '--build-arg', 'favorite_th_character=sakuya.izayoi'], None) + assert 'Favorite Touhou Character: sakuya.izayoi' in result.stdout + + @mock.patch.dict(os.environ) + def test_build_with_buildarg_old_api_version(self): + pull_busybox(self.client) + self.base_dir = 'tests/fixtures/build-args' + os.environ['COMPOSE_API_VERSION'] = '1.24' + result = self.dispatch( + ['build', '--build-arg', 'favorite_th_character=reimu.hakurei'], None, returncode=1 + ) + assert '--build-arg is only supported when services are specified' in result.stderr + + result = self.dispatch( + ['build', '--build-arg', 'favorite_th_character=hong.meiling', 'web'], None + ) + assert 'Favorite Touhou Character: hong.meiling' in result.stdout + def test_bundle_with_digests(self): self.base_dir = 'tests/fixtures/bundle-with-digests/' tmpdir = pytest.ensuretemp('cli_test_bundle') diff --git a/tests/fixtures/build-args/Dockerfile b/tests/fixtures/build-args/Dockerfile new file mode 100644 index 00000000000..93ebcb9cd6f --- /dev/null +++ b/tests/fixtures/build-args/Dockerfile @@ -0,0 +1,4 @@ +FROM busybox:latest +LABEL com.docker.compose.test_image=true +ARG favorite_th_character +RUN echo "Favorite Touhou Character: ${favorite_th_character}" diff --git a/tests/fixtures/build-args/docker-compose.yml b/tests/fixtures/build-args/docker-compose.yml new file mode 100644 index 00000000000..ed60a337b86 --- /dev/null +++ b/tests/fixtures/build-args/docker-compose.yml @@ -0,0 +1,7 @@ +version: '2.2' +services: + web: + build: + context: . + args: + - favorite_th_character=mariya.kirisame From 18338606143971b5ba64250cb0eca49d1a516c1d Mon Sep 17 00:00:00 2001 From: Gustavo Pantuza Coelho Pinto Date: Tue, 24 Oct 2017 19:24:06 -0200 Subject: [PATCH 3290/4072] CLI: Add --detach option for up, run and exec commands This contribution allows the usage of --detach option when running: docker-compose run --detach docker-compose exec --detach docker-compose up --detach The behavior is the same as the -d option. Signed-off-by: Gustavo Pantuza Coelho Pinto --- compose/cli/main.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index edc07d4268b..7b6ca98c452 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -424,7 +424,7 @@ def exec_command(self, options): Usage: exec [options] [-e KEY=VAL...] SERVICE COMMAND [ARGS...] Options: - -d Detached mode: Run command in the background. + -d, --detach Detached mode: Run command in the background. --privileged Give extended privileges to the process. -u, --user USER Run the command as this user. -T Disable pseudo-tty allocation. By default `docker-compose exec` @@ -438,7 +438,7 @@ def exec_command(self, options): use_cli = not environment.get_boolean('COMPOSE_INTERACTIVE_NO_CLI') index = int(options.get('--index')) service = self.project.get_service(options['SERVICE']) - detach = options['-d'] + detach = options.get('-d') or options.get('--detach') if options['--env'] and docker.utils.version_lt(self.project.client.api_version, '1.25'): raise UserError("Setting environment for exec is not supported in API < 1.25'") @@ -762,7 +762,7 @@ def run(self, options): SERVICE [COMMAND] [ARGS...] Options: - -d Detached mode: Run container in the background, print + -d --detach 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. @@ -780,7 +780,7 @@ def run(self, options): -w, --workdir="" Working directory inside the container """ service = self.project.get_service(options['SERVICE']) - detach = options['-d'] + detach = options.get('-d') or options.get('--detach') if options['--publish'] and options['--service-ports']: raise UserError( @@ -928,7 +928,7 @@ def up(self, options): Usage: up [options] [--scale SERVICE=NUM...] [SERVICE...] Options: - -d Detached mode: Run containers in the background, + -d, --detach Detached mode: Run containers in the background, print new container names. Incompatible with --abort-on-container-exit. --no-color Produce monochrome output. @@ -963,7 +963,7 @@ def up(self, options): service_names = options['SERVICE'] timeout = timeout_from_opts(options) remove_orphans = options['--remove-orphans'] - detached = options.get('-d') + detached = options.get('-d') or options.get('--detach') no_start = options.get('--no-start') if detached and (cascade_stop or exit_value_from): @@ -975,7 +975,7 @@ def up(self, options): if ignore_orphans and remove_orphans: raise UserError("COMPOSE_IGNORE_ORPHANS and --remove-orphans cannot be combined.") - opts = ['-d', '--abort-on-container-exit', '--exit-code-from'] + opts = ['--detach', '--abort-on-container-exit', '--exit-code-from'] for excluded in [x for x in opts if options.get(x) and no_start]: raise UserError('--no-start and {} cannot be combined.'.format(excluded)) @@ -1245,7 +1245,7 @@ def run_one_off_container(container_options, project, service, options, project_ one_off=True, **container_options) - if options['-d']: + if options.get('-d') or options.get('--detach'): service.start_container(container) print(container.name) return From c6fe564ed579d8150efa2ec5613734de09d8dc45 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 21 Feb 2018 16:42:49 -0800 Subject: [PATCH 3291/4072] Add --detach tests Signed-off-by: Joffrey F --- compose/cli/main.py | 12 ++++++------ tests/acceptance/cli_test.py | 22 ++++++++++++++++++++++ tests/unit/cli_test.py | 8 ++++---- 3 files changed, 32 insertions(+), 10 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 7b6ca98c452..46078648938 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -438,7 +438,7 @@ def exec_command(self, options): use_cli = not environment.get_boolean('COMPOSE_INTERACTIVE_NO_CLI') index = int(options.get('--index')) service = self.project.get_service(options['SERVICE']) - detach = options.get('-d') or options.get('--detach') + detach = options.get('--detach') if options['--env'] and docker.utils.version_lt(self.project.client.api_version, '1.25'): raise UserError("Setting environment for exec is not supported in API < 1.25'") @@ -762,7 +762,7 @@ def run(self, options): SERVICE [COMMAND] [ARGS...] Options: - -d --detach Detached mode: Run container in the background, print + -d, --detach 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. @@ -780,7 +780,7 @@ def run(self, options): -w, --workdir="" Working directory inside the container """ service = self.project.get_service(options['SERVICE']) - detach = options.get('-d') or options.get('--detach') + detach = options.get('--detach') if options['--publish'] and options['--service-ports']: raise UserError( @@ -963,7 +963,7 @@ def up(self, options): service_names = options['SERVICE'] timeout = timeout_from_opts(options) remove_orphans = options['--remove-orphans'] - detached = options.get('-d') or options.get('--detach') + detached = options.get('--detach') no_start = options.get('--no-start') if detached and (cascade_stop or exit_value_from): @@ -1245,7 +1245,7 @@ def run_one_off_container(container_options, project, service, options, project_ one_off=True, **container_options) - if options.get('-d') or options.get('--detach'): + if options.get('--detach'): service.start_container(container) print(container.name) return @@ -1372,7 +1372,7 @@ def parse_scale_args(options): def build_exec_command(options, container_id, command): args = ["exec"] - if options["-d"]: + if options["--detach"]: args += ["--detach"] else: args += ["--interactive"] diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 134485c4053..63122e7437b 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -884,6 +884,19 @@ def test_up_detached(self): assert not container.get('Config.AttachStdout') assert not container.get('Config.AttachStdin') + def test_up_detached_long_form(self): + self.dispatch(['up', '--detach']) + service = self.project.get_service('simple') + another = self.project.get_service('another') + assert len(service.containers()) == 1 + assert len(another.containers()) == 1 + + # Ensure containers don't have stdin and stdout connected in -d mode + container, = service.containers() + assert not container.get('Config.AttachStderr') + assert not container.get('Config.AttachStdout') + assert not container.get('Config.AttachStdin') + def test_up_attached(self): self.base_dir = 'tests/fixtures/echo-services' result = self.dispatch(['up', '--no-color']) @@ -1463,6 +1476,15 @@ def test_exec_without_tty(self): assert stderr == "" assert stdout == "/\n" + def test_exec_detach_long_form(self): + self.base_dir = 'tests/fixtures/links-composefile' + self.dispatch(['up', '--detach', 'console']) + assert len(self.project.containers()) == 1 + + stdout, stderr = self.dispatch(['exec', '-T', 'console', 'ls', '-1d', '/']) + assert stderr == "" + assert stdout == "/\n" + def test_exec_custom_user(self): self.base_dir = 'tests/fixtures/links-composefile' self.dispatch(['up', '-d', 'console']) diff --git a/tests/unit/cli_test.py b/tests/unit/cli_test.py index d078614e600..6399bef8980 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -119,7 +119,7 @@ def test_run_interactive_passes_logs_false(self, mock_pseudo_terminal, mock_run_ '--label': [], '--user': None, '--no-deps': None, - '-d': False, + '--detach': False, '-T': None, '--entrypoint': None, '--service-ports': None, @@ -156,7 +156,7 @@ def test_run_service_with_restart_always(self): '--label': [], '--user': None, '--no-deps': None, - '-d': True, + '--detach': True, '-T': None, '--entrypoint': None, '--service-ports': None, @@ -177,7 +177,7 @@ def test_run_service_with_restart_always(self): '--label': [], '--user': None, '--no-deps': None, - '-d': True, + '--detach': True, '-T': None, '--entrypoint': None, '--service-ports': None, @@ -208,7 +208,7 @@ def test_command_manual_and_service_ports_together(self): '--label': [], '--user': None, '--no-deps': None, - '-d': True, + '--detach': True, '-T': None, '--entrypoint': None, '--service-ports': True, From c16820eca093f2bbf573c31f7390220565aa5937 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 21 Feb 2018 16:45:29 -0800 Subject: [PATCH 3292/4072] Update bash completion Signed-off-by: Joffrey F --- contrib/completion/bash/docker-compose | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index fc51f604e43..29853b0893d 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -245,7 +245,7 @@ _docker_compose_exec() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "-d --help --index --privileged -T --user -u" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "-d --detach --help --index --privileged -T --user -u" -- "$cur" ) ) ;; *) __docker_compose_services_running @@ -444,7 +444,7 @@ _docker_compose_run() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "-d --entrypoint -e --help --label -l --name --no-deps --publish -p --rm --service-ports -T --user -u --volume -v --workdir -w" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "-d --detach --entrypoint -e --help --label -l --name --no-deps --publish -p --rm --service-ports -T --user -u --volume -v --workdir -w" -- "$cur" ) ) ;; *) __docker_compose_services_all @@ -552,7 +552,7 @@ _docker_compose_up() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "--abort-on-container-exit --always-recreate-deps --build -d --exit-code-from --force-recreate --help --no-build --no-color --no-deps --no-recreate --no-start --renew-anon-volumes -V --remove-orphans --scale --timeout -t" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--abort-on-container-exit --always-recreate-deps --build -d --detach --exit-code-from --force-recreate --help --no-build --no-color --no-deps --no-recreate --no-start --renew-anon-volumes -V --remove-orphans --scale --timeout -t" -- "$cur" ) ) ;; *) __docker_compose_services_all From ad683b2d8d83f6c4df9500fef243c0b5d2bfe3cb Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 22 Feb 2018 15:18:18 -0800 Subject: [PATCH 3293/4072] Bump 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 6b1df191638..da05e421296 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ backports.ssl-match-hostname==3.5.0.1; python_version < '3' cached-property==1.3.0 certifi==2017.4.17 chardet==3.0.4 -docker==3.0.1 +docker==3.1.0 docker-pycreds==0.2.1 dockerpty==0.4.1 docopt==0.6.2 diff --git a/setup.py b/setup.py index 264d647f7bb..d1788df0ab3 100644 --- a/setup.py +++ b/setup.py @@ -36,7 +36,7 @@ def find_version(*file_paths): 'requests >= 2.6.1, != 2.11.0, != 2.12.2, != 2.18.0, < 2.19', 'texttable >= 0.9.0, < 0.10', 'websocket-client >= 0.32.0, < 1.0', - 'docker >= 3.0.1, < 4.0', + 'docker >= 3.1.0, < 4.0', 'dockerpty >= 0.4.1, < 0.5', 'six >= 1.3.0, < 2', 'jsonschema >= 2.5.1, < 3', From d4106679a6883addea940dcc8bbf424b90eed023 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 22 Feb 2018 16:18:44 -0800 Subject: [PATCH 3294/4072] Use configfile-provided proxy values to populate buildargs and env values Signed-off-by: Joffrey F --- compose/cli/docker_client.py | 5 ++++- compose/service.py | 36 ++++++++++++++++++++++++++++++++++-- tests/unit/cli_test.py | 2 ++ tests/unit/project_test.py | 1 + tests/unit/service_test.py | 31 ++++++++++++++++++++----------- 5 files changed, 61 insertions(+), 14 deletions(-) diff --git a/compose/cli/docker_client.py b/compose/cli/docker_client.py index cc8993d7fb3..a4609780f3a 100644 --- a/compose/cli/docker_client.py +++ b/compose/cli/docker_client.py @@ -117,4 +117,7 @@ def docker_client(environment, version=None, tls_config=None, host=None, kwargs['user_agent'] = generate_user_agent() - return APIClient(**kwargs) + client = APIClient(**kwargs) + client._original_base_url = kwargs.get('base_url') + + return client diff --git a/compose/service.py b/compose/service.py index f79a4696255..50f9fd71b92 100644 --- a/compose/service.py +++ b/compose/service.py @@ -793,8 +793,12 @@ def _get_container_create_options( )) container_options['environment'] = merge_environment( - self.options.get('environment'), - override_options.get('environment')) + self._parse_proxy_config(), + merge_environment( + self.options.get('environment'), + override_options.get('environment') + ) + ) container_options['labels'] = merge_labels( self.options.get('labels'), @@ -963,6 +967,9 @@ def build(self, no_cache=False, pull=False, force_rm=False, memory=None, build_a if build_args_override: build_args.update(build_args_override) + for k, v in self._parse_proxy_config().items(): + build_args.setdefault(k, v) + # python2 os.stat() doesn't support unicode on some UNIX, so we # encode it to a bytestring to be safe path = build_opts.get('context') @@ -1142,6 +1149,31 @@ def is_healthy(self): raise HealthCheckFailed(ctnr.short_id) return result + def _parse_proxy_config(self): + client = self.client + if 'proxies' not in client._general_configs: + return {} + docker_host = getattr(client, '_original_base_url', client.base_url) + proxy_config = client._general_configs['proxies'].get( + docker_host, client._general_configs['proxies'].get('default') + ) or {} + + permitted = { + 'ftpProxy': 'FTP_PROXY', + 'httpProxy': 'HTTP_PROXY', + 'httpsProxy': 'HTTPS_PROXY', + 'noProxy': 'NO_PROXY', + } + + result = {} + + for k, v in proxy_config.items(): + if k not in permitted: + continue + result[permitted[k]] = result[permitted[k].lower()] = v + + return result + def short_id_alias_exists(container, network): aliases = container.get( diff --git a/tests/unit/cli_test.py b/tests/unit/cli_test.py index 6399bef8980..cef53740dd0 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -102,6 +102,7 @@ def test_run_interactive_passes_logs_false(self, mock_pseudo_terminal, mock_run_ os.environ['COMPOSE_INTERACTIVE_NO_CLI'] = 'true' mock_client = mock.create_autospec(docker.APIClient) mock_client.api_version = DEFAULT_DOCKER_API_VERSION + mock_client._general_configs = {} project = Project.from_config( name='composetest', client=mock_client, @@ -136,6 +137,7 @@ def test_run_interactive_passes_logs_false(self, mock_pseudo_terminal, mock_run_ def test_run_service_with_restart_always(self): mock_client = mock.create_autospec(docker.APIClient) mock_client.api_version = DEFAULT_DOCKER_API_VERSION + mock_client._general_configs = {} project = Project.from_config( name='composetest', diff --git a/tests/unit/project_test.py b/tests/unit/project_test.py index a0291d9f9f9..b4994a99e02 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -24,6 +24,7 @@ class ProjectTest(unittest.TestCase): def setUp(self): self.mock_client = mock.create_autospec(docker.APIClient) + self.mock_client._general_configs = {} def test_from_config_v1(self): config = Config( diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 002ae0c0db2..884598b60c9 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -43,6 +43,7 @@ class ServiceTest(unittest.TestCase): def setUp(self): self.mock_client = mock.create_autospec(docker.APIClient) self.mock_client.api_version = DEFAULT_DOCKER_API_VERSION + self.mock_client._general_configs = {} def test_containers(self): service = Service('db', self.mock_client, 'myproject', image='foo') @@ -743,13 +744,16 @@ def test_only_log_warning_when_host_ports_clash(self, mock_log): 'for this service are created on a single host, the port will clash.'.format(name)) -class TestServiceNetwork(object): +class TestServiceNetwork(unittest.TestCase): + def setUp(self): + self.mock_client = mock.create_autospec(docker.APIClient) + self.mock_client.api_version = DEFAULT_DOCKER_API_VERSION + self.mock_client._general_configs = {} def test_connect_container_to_networks_short_aliase_exists(self): - mock_client = mock.create_autospec(docker.APIClient) service = Service( 'db', - mock_client, + self.mock_client, 'myproject', image='foo', networks={'project_default': {}}) @@ -768,8 +772,8 @@ def test_connect_container_to_networks_short_aliase_exists(self): 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 + assert not self.mock_client.disconnect_container_from_network.call_count + assert not self.mock_client.connect_container_to_network.call_count def sort_by_name(dictionary_list): @@ -814,6 +818,10 @@ def test_build_ulimits_with_integers_and_dicts(self): class NetTestCase(unittest.TestCase): + def setUp(self): + self.mock_client = mock.create_autospec(docker.APIClient) + self.mock_client.api_version = DEFAULT_DOCKER_API_VERSION + self.mock_client._general_configs = {} def test_network_mode(self): network_mode = NetworkMode('host') @@ -831,12 +839,11 @@ def test_network_mode_container(self): def test_network_mode_service(self): container_id = 'bbbb' service_name = 'web' - mock_client = mock.create_autospec(docker.APIClient) - mock_client.containers.return_value = [ + self.mock_client.containers.return_value = [ {'Id': container_id, 'Name': container_id, 'Image': 'abcd'}, ] - service = Service(name=service_name, client=mock_client) + service = Service(name=service_name, client=self.mock_client) network_mode = ServiceNetworkMode(service) assert network_mode.id == service_name @@ -845,10 +852,9 @@ def test_network_mode_service(self): def test_network_mode_service_no_containers(self): service_name = 'web' - mock_client = mock.create_autospec(docker.APIClient) - mock_client.containers.return_value = [] + self.mock_client.containers.return_value = [] - service = Service(name=service_name, client=mock_client) + service = Service(name=service_name, client=self.mock_client) network_mode = ServiceNetworkMode(service) assert network_mode.id == service_name @@ -884,6 +890,7 @@ class ServiceVolumesTest(unittest.TestCase): def setUp(self): self.mock_client = mock.create_autospec(docker.APIClient) self.mock_client.api_version = DEFAULT_DOCKER_API_VERSION + self.mock_client._general_configs = {} def test_build_volume_binding(self): binding = build_volume_binding(VolumeSpec.parse('/outside:/inside', True)) @@ -1118,6 +1125,8 @@ def test_create_with_special_volume_mode(self): class ServiceSecretTest(unittest.TestCase): def setUp(self): self.mock_client = mock.create_autospec(docker.APIClient) + self.mock_client.api_version = DEFAULT_DOCKER_API_VERSION + self.mock_client._general_configs = {} def test_get_secret_volumes(self): secret1 = { From 5eefa81f9e23e98d02e2e5df8e74378c4713a9ec Mon Sep 17 00:00:00 2001 From: Matthieu Nottale Date: Thu, 22 Feb 2018 11:30:51 +0100 Subject: [PATCH 3295/4072] Add '--quiet' option to 'up' to pull silently. Signed-off-by: Matthieu Nottale --- compose/cli/main.py | 4 +++- compose/project.py | 6 ++++-- compose/service.py | 4 ++-- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 39178fb3c55..b0ffd2f6cb9 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -934,6 +934,7 @@ def up(self, options): print new container names. Incompatible with --abort-on-container-exit. --no-color Produce monochrome output. + --quiet-pull Pull without printing progress information --no-deps Don't start linked services. --force-recreate Recreate containers even if their configuration and image haven't changed. @@ -998,7 +999,8 @@ def up(rebuild): start=not no_start, always_recreate_deps=always_recreate_deps, reset_container_image=rebuild, - renew_anonymous_volumes=options.get('--renew-anon-volumes') + renew_anonymous_volumes=options.get('--renew-anon-volumes'), + silent=options.get('--quiet-pull'), ) try: diff --git a/compose/project.py b/compose/project.py index 5c72444937c..2cbc4aeea36 100644 --- a/compose/project.py +++ b/compose/project.py @@ -446,7 +446,9 @@ def up(self, start=True, always_recreate_deps=False, reset_container_image=False, - renew_anonymous_volumes=False): + renew_anonymous_volumes=False, + silent=False, + ): self.initialize() if not ignore_orphans: @@ -460,7 +462,7 @@ def up(self, include_deps=start_deps) for svc in services: - svc.ensure_image_exists(do_build=do_build) + svc.ensure_image_exists(do_build=do_build, silent=silent) plans = self._get_convergence_plans( services, strategy, always_recreate_deps=always_recreate_deps) scaled_services = self.get_scaled_services(services, scale_override) diff --git a/compose/service.py b/compose/service.py index f79a4696255..3918a19e83a 100644 --- a/compose/service.py +++ b/compose/service.py @@ -305,7 +305,7 @@ def create_container(self, raise OperationFailedError("Cannot create container for service %s: %s" % (self.name, ex.explanation)) - def ensure_image_exists(self, do_build=BuildAction.none): + def ensure_image_exists(self, do_build=BuildAction.none, silent=False): if self.can_be_built() and do_build == BuildAction.force: self.build() return @@ -317,7 +317,7 @@ def ensure_image_exists(self, do_build=BuildAction.none): pass if not self.can_be_built(): - self.pull() + self.pull(silent=silent) return if do_build == BuildAction.skip: From 59b08c7d1db4b1105481ab74bbf788b7d7f24fd2 Mon Sep 17 00:00:00 2001 From: Matthieu Nottale Date: Thu, 22 Feb 2018 13:47:51 +0100 Subject: [PATCH 3296/4072] New --log-level option. Signed-off-by: Matthieu Nottale --- compose/cli/main.py | 27 +++++++++++++++++++++++---- tests/acceptance/cli_test.py | 14 ++++++++++++++ 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 39178fb3c55..71aa58c5d1e 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -100,7 +100,10 @@ def dispatch(): {'options_first': True, 'version': get_version_info('compose')}) options, handler, command_options = dispatcher.parse(sys.argv[1:]) - setup_console_handler(console_handler, options.get('--verbose'), options.get('--no-ansi')) + setup_console_handler(console_handler, + options.get('--verbose'), + options.get('--no-ansi'), + options.get("--log-level")) setup_parallel_logger(options.get('--no-ansi')) if options.get('--no-ansi'): command_options['--no-color'] = True @@ -139,7 +142,7 @@ def setup_parallel_logger(noansi): compose.parallel.ParallelStreamWriter.set_noansi() -def setup_console_handler(handler, verbose, noansi=False): +def setup_console_handler(handler, verbose, noansi=False, level=None): if handler.stream.isatty() and noansi is False: format_class = ConsoleWarningFormatter else: @@ -147,10 +150,25 @@ def setup_console_handler(handler, verbose, noansi=False): if verbose: handler.setFormatter(format_class('%(name)s.%(funcName)s: %(message)s')) - handler.setLevel(logging.DEBUG) + loglevel = logging.DEBUG else: handler.setFormatter(format_class()) - handler.setLevel(logging.INFO) + loglevel = logging.INFO + + if level is not None: + levels = { + 'DEBUG': logging.DEBUG, + 'INFO': logging.INFO, + 'WARNING': logging.WARNING, + 'ERROR': logging.ERROR, + 'CRITICAL': logging.CRITICAL, + } + loglevel = levels.get(level.upper()) + if loglevel is None: + raise UserError('Invalid value for --log-level. Expected one of ' + + 'DEBUG, INFO, WARNING, ERROR, CRITICAL.') + + handler.setLevel(loglevel) # stolen from docopt master @@ -171,6 +189,7 @@ class TopLevelCommand(object): -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 + --log-level LEVEL Set log level (DEBUG, INFO, WARNING, ERROR, CRITICAL) --no-ansi Do not print ANSI control characters -v, --version Print version and exit -H, --host HOST Daemon socket to connect to diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 66857307ba0..7968615775e 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -619,6 +619,20 @@ def test_build_no_cache_pull(self): assert BUILD_CACHE_TEXT not in result.stdout assert BUILD_PULL_TEXT in result.stdout + def test_build_log_level(self): + self.base_dir = 'tests/fixtures/simple-dockerfile' + result = self.dispatch(['--log-level', 'warning', 'build', 'simple']) + assert result.stderr == '' + result = self.dispatch(['--log-level', 'debug', 'build', 'simple']) + assert 'Building simple' in result.stderr + assert 'Using configuration file' in result.stderr + self.base_dir = 'tests/fixtures/simple-failing-dockerfile' + result = self.dispatch(['--log-level', 'critical', 'build', 'simple'], returncode=1) + assert result.stderr == '' + result = self.dispatch(['--log-level', 'debug', 'build', 'simple'], returncode=1) + assert 'Building simple' in result.stderr + assert 'non-zero code' in result.stderr + def test_build_failed(self): self.base_dir = 'tests/fixtures/simple-failing-dockerfile' self.dispatch(['build', 'simple'], returncode=1) From 2fed3998814b8a4b35d5d61e2c637810577ba1ba Mon Sep 17 00:00:00 2001 From: Matthieu Nottale Date: Fri, 23 Feb 2018 16:33:01 +0100 Subject: [PATCH 3297/4072] Add '--workdir' option to 'exec'. Signed-off-by: Matthieu Nottale --- compose/cli/main.py | 12 +++++++++++- tests/acceptance/cli_test.py | 10 ++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 39178fb3c55..be3a52f2008 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -435,6 +435,7 @@ def exec_command(self, options): instances of a service [default: 1] -e, --env KEY=VAL Set environment variables (can be used multiple times, not supported in API < 1.25) + -w, --workdir DIR Path to workdir directory for this command. """ environment = Environment.from_env_file(self.project_dir) use_cli = not environment.get_boolean('COMPOSE_INTERACTIVE_NO_CLI') @@ -443,7 +444,12 @@ def exec_command(self, options): detach = options.get('--detach') if options['--env'] and docker.utils.version_lt(self.project.client.api_version, '1.25'): - raise UserError("Setting environment for exec is not supported in API < 1.25'") + raise UserError("Setting environment for exec is not supported in API < 1.25 (%s)" + % self.project.client.api_version) + + if options['--workdir'] and docker.utils.version_lt(self.project.client.api_version, '1.35'): + raise UserError("Setting workdir for exec is not supported in API < 1.35 (%s)" + % self.project.client.api_version) try: container = service.get_container(number=index) @@ -460,6 +466,7 @@ def exec_command(self, options): "user": options["--user"], "tty": tty, "stdin": True, + "workdir": options["--workdir"], } if docker.utils.version_gte(self.project.client.api_version, '1.25'): @@ -1392,6 +1399,9 @@ def build_exec_command(options, container_id, command): for env_variable in options["--env"]: args += ["--env", env_variable] + if options["--workdir"]: + args += ["--workdir", options["--workdir"]] + args += [container_id] args += command return args diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 66857307ba0..e1fbaea56de 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1521,6 +1521,16 @@ def test_exec_custom_user(self): assert stdout == "operator\n" assert stderr == "" + @v3_only() + def test_exec_workdir(self): + self.base_dir = 'tests/fixtures/links-composefile' + os.environ['COMPOSE_API_VERSION'] = '1.35' + self.dispatch(['up', '-d', 'console']) + assert len(self.project.containers()) == 1 + + stdout, stderr = self.dispatch(['exec', '-T', '--workdir', '/etc', 'console', 'ls']) + assert 'passwd' in stdout + @v2_2_only() def test_exec_service_with_environment_overridden(self): name = 'service' From 5e4700b176bf36eaece1cfb6bb5c75a03d069d3f Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 23 Feb 2018 14:32:43 -0800 Subject: [PATCH 3298/4072] Add proxy_config tests Signed-off-by: Joffrey F --- tests/unit/service_test.py | 143 +++++++++++++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 884598b60c9..c315dcc4db8 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -25,6 +25,7 @@ from compose.service import build_volume_binding from compose.service import BuildAction from compose.service import ContainerNetworkMode +from compose.service import format_environment from compose.service import formatted_ports from compose.service import get_container_data_volumes from compose.service import ImageType @@ -743,6 +744,148 @@ def test_only_log_warning_when_host_ports_clash(self, mock_log): '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 test_parse_proxy_config(self): + default_proxy_config = { + 'httpProxy': 'http://proxy.mycorp.com:3128', + 'httpsProxy': 'https://user:password@proxy.mycorp.com:3129', + 'ftpProxy': 'http://ftpproxy.mycorp.com:21', + 'noProxy': '*.intra.mycorp.com', + } + + self.mock_client.base_url = 'http+docker://localunixsocket' + self.mock_client._general_configs = { + 'proxies': { + 'default': default_proxy_config, + } + } + + service = Service('foo', client=self.mock_client) + + assert service._parse_proxy_config() == { + 'HTTP_PROXY': default_proxy_config['httpProxy'], + 'http_proxy': default_proxy_config['httpProxy'], + 'HTTPS_PROXY': default_proxy_config['httpsProxy'], + 'https_proxy': default_proxy_config['httpsProxy'], + 'FTP_PROXY': default_proxy_config['ftpProxy'], + 'ftp_proxy': default_proxy_config['ftpProxy'], + 'NO_PROXY': default_proxy_config['noProxy'], + 'no_proxy': default_proxy_config['noProxy'], + } + + def test_parse_proxy_config_per_host(self): + default_proxy_config = { + 'httpProxy': 'http://proxy.mycorp.com:3128', + 'httpsProxy': 'https://user:password@proxy.mycorp.com:3129', + 'ftpProxy': 'http://ftpproxy.mycorp.com:21', + 'noProxy': '*.intra.mycorp.com', + } + host_specific_proxy_config = { + 'httpProxy': 'http://proxy.example.com:3128', + 'httpsProxy': 'https://user:password@proxy.example.com:3129', + 'ftpProxy': 'http://ftpproxy.example.com:21', + 'noProxy': '*.intra.example.com' + } + + self.mock_client.base_url = 'http+docker://localunixsocket' + self.mock_client._general_configs = { + 'proxies': { + 'default': default_proxy_config, + 'tcp://example.docker.com:2376': host_specific_proxy_config, + } + } + + service = Service('foo', client=self.mock_client) + + assert service._parse_proxy_config() == { + 'HTTP_PROXY': default_proxy_config['httpProxy'], + 'http_proxy': default_proxy_config['httpProxy'], + 'HTTPS_PROXY': default_proxy_config['httpsProxy'], + 'https_proxy': default_proxy_config['httpsProxy'], + 'FTP_PROXY': default_proxy_config['ftpProxy'], + 'ftp_proxy': default_proxy_config['ftpProxy'], + 'NO_PROXY': default_proxy_config['noProxy'], + 'no_proxy': default_proxy_config['noProxy'], + } + + self.mock_client._original_base_url = 'tcp://example.docker.com:2376' + + assert service._parse_proxy_config() == { + 'HTTP_PROXY': host_specific_proxy_config['httpProxy'], + 'http_proxy': host_specific_proxy_config['httpProxy'], + 'HTTPS_PROXY': host_specific_proxy_config['httpsProxy'], + 'https_proxy': host_specific_proxy_config['httpsProxy'], + 'FTP_PROXY': host_specific_proxy_config['ftpProxy'], + 'ftp_proxy': host_specific_proxy_config['ftpProxy'], + 'NO_PROXY': host_specific_proxy_config['noProxy'], + 'no_proxy': host_specific_proxy_config['noProxy'], + } + + def test_build_service_with_proxy_config(self): + default_proxy_config = { + 'httpProxy': 'http://proxy.mycorp.com:3128', + 'httpsProxy': 'https://user:password@proxy.example.com:3129', + } + buildargs = { + 'HTTPS_PROXY': 'https://rdcf.th08.jp:8911', + 'https_proxy': 'https://rdcf.th08.jp:8911', + } + self.mock_client._general_configs = { + 'proxies': { + 'default': default_proxy_config, + } + } + self.mock_client.base_url = 'http+docker://localunixsocket' + self.mock_client.build.return_value = [ + b'{"stream": "Successfully built 12345"}', + ] + + service = Service('foo', client=self.mock_client, build={'context': '.', 'args': buildargs}) + service.build() + + assert self.mock_client.build.call_count == 1 + assert self.mock_client.build.call_args[1]['buildargs'] == { + 'HTTP_PROXY': default_proxy_config['httpProxy'], + 'http_proxy': default_proxy_config['httpProxy'], + 'HTTPS_PROXY': buildargs['HTTPS_PROXY'], + 'https_proxy': buildargs['HTTPS_PROXY'], + } + + def test_get_create_options_with_proxy_config(self): + default_proxy_config = { + 'httpProxy': 'http://proxy.mycorp.com:3128', + 'httpsProxy': 'https://user:password@proxy.mycorp.com:3129', + 'ftpProxy': 'http://ftpproxy.mycorp.com:21', + } + self.mock_client._general_configs = { + 'proxies': { + 'default': default_proxy_config, + } + } + self.mock_client.base_url = 'http+docker://localunixsocket' + + override_options = { + 'environment': { + 'FTP_PROXY': 'ftp://xdge.exo.au:21', + 'ftp_proxy': 'ftp://xdge.exo.au:21', + } + } + environment = { + 'HTTPS_PROXY': 'https://rdcf.th08.jp:8911', + 'https_proxy': 'https://rdcf.th08.jp:8911', + } + + service = Service('foo', client=self.mock_client, environment=environment) + + create_opts = service._get_container_create_options(override_options, 1) + assert set(create_opts['environment']) == set(format_environment({ + 'HTTP_PROXY': default_proxy_config['httpProxy'], + 'http_proxy': default_proxy_config['httpProxy'], + 'HTTPS_PROXY': environment['HTTPS_PROXY'], + 'https_proxy': environment['HTTPS_PROXY'], + 'FTP_PROXY': override_options['environment']['FTP_PROXY'], + 'ftp_proxy': override_options['environment']['FTP_PROXY'], + })) + class TestServiceNetwork(unittest.TestCase): def setUp(self): From a6c31b80fefc68e64c3ad57abb6f64541460453d Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 21 Feb 2018 18:32:45 -0800 Subject: [PATCH 3299/4072] Add support for seccomp files Signed-off-by: Joffrey F --- compose/cli/docker_client.py | 2 +- compose/cli/utils.py | 8 ------- compose/config/config.py | 15 +++++++++++-- compose/config/serialize.py | 1 + compose/config/types.py | 29 +++++++++++++++++++++++++ compose/service.py | 6 +++++- compose/utils.py | 8 +++++++ tests/integration/project_test.py | 36 ++++++++++++++++++++++++++++++- tests/integration/service_test.py | 5 +++-- tests/unit/cli/utils_test.py | 2 +- 10 files changed, 96 insertions(+), 16 deletions(-) diff --git a/compose/cli/docker_client.py b/compose/cli/docker_client.py index cc8993d7fb3..73a7b7e1065 100644 --- a/compose/cli/docker_client.py +++ b/compose/cli/docker_client.py @@ -13,9 +13,9 @@ from ..config.environment import Environment from ..const import HTTP_TIMEOUT +from ..utils import unquote_path from .errors import UserError from .utils import generate_user_agent -from .utils import unquote_path log = logging.getLogger(__name__) diff --git a/compose/cli/utils.py b/compose/cli/utils.py index a171d6678e6..4cc055cc998 100644 --- a/compose/cli/utils.py +++ b/compose/cli/utils.py @@ -131,14 +131,6 @@ def generate_user_agent(): return " ".join(parts) -def unquote_path(s): - if not s: - return s - if s[0] == '"' and s[-1] == '"': - return s[1:-1] - return s - - def human_readable_file_size(size): suffixes = ['B', 'kB', 'MB', 'GB', 'TB', 'PB', 'EB', ] order = int(math.log(size, 2) / 10) if size else 0 diff --git a/compose/config/config.py b/compose/config/config.py index b7764dd3bfe..38e88741710 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -40,6 +40,7 @@ from .types import MountSpec from .types import parse_extra_hosts from .types import parse_restart_spec +from .types import SecurityOpt from .types import ServiceLink from .types import ServicePort from .types import VolumeFromSpec @@ -734,9 +735,9 @@ def process_service(service_config): if field in service_dict: service_dict[field] = to_list(service_dict[field]) - service_dict = process_blkio_config(process_ports( + service_dict = process_security_opt(process_blkio_config(process_ports( process_healthcheck(service_dict) - )) + ))) return service_dict @@ -1376,6 +1377,16 @@ def split_path_mapping(volume_path): return (volume_path, None) +def process_security_opt(service_dict): + security_opts = service_dict.get('security_opt', []) + result = [] + for value in security_opts: + result.append(SecurityOpt.parse(value)) + if result: + service_dict['security_opt'] = result + return service_dict + + def join_path_mapping(pair): (container, host) = pair if isinstance(host, dict): diff --git a/compose/config/serialize.py b/compose/config/serialize.py index 7fb9360a2e4..7de7f41e882 100644 --- a/compose/config/serialize.py +++ b/compose/config/serialize.py @@ -42,6 +42,7 @@ def serialize_string(dumper, data): yaml.SafeDumper.add_representer(types.MountSpec, serialize_dict_type) yaml.SafeDumper.add_representer(types.VolumeFromSpec, serialize_config_type) yaml.SafeDumper.add_representer(types.VolumeSpec, serialize_config_type) +yaml.SafeDumper.add_representer(types.SecurityOpt, serialize_config_type) yaml.SafeDumper.add_representer(types.ServiceSecret, serialize_dict_type) yaml.SafeDumper.add_representer(types.ServiceConfig, serialize_dict_type) yaml.SafeDumper.add_representer(types.ServicePort, serialize_dict_type) diff --git a/compose/config/types.py b/compose/config/types.py index d84491d0a12..47e7222a3f2 100644 --- a/compose/config/types.py +++ b/compose/config/types.py @@ -4,6 +4,7 @@ from __future__ import absolute_import from __future__ import unicode_literals +import json import ntpath import os import re @@ -13,6 +14,7 @@ from docker.utils.ports import build_port_bindings from ..const import COMPOSEFILE_V1 as V1 +from ..utils import unquote_path from .errors import ConfigurationError from compose.const import IS_WINDOWS_PLATFORM from compose.utils import splitdrive @@ -457,3 +459,30 @@ def normalize_port_dict(port): external_ip=port.get('external_ip', ''), has_ext_ip=(':' if port.get('external_ip') else ''), ) + + +class SecurityOpt(namedtuple('_SecurityOpt', 'value src_file')): + @classmethod + def parse(cls, value): + # based on https://github.com/docker/cli/blob/9de1b162f/cli/command/container/opts.go#L673-L697 + con = value.split('=', 2) + if len(con) == 1 and con[0] != 'no-new-privileges': + if ':' not in value: + raise ConfigurationError('Invalid security_opt: {}'.format(value)) + con = value.split(':', 2) + + if con[0] == 'seccomp' and con[1] != 'unconfined': + try: + with open(unquote_path(con[1]), 'r') as f: + seccomp_data = json.load(f) + except (IOError, ValueError) as e: + raise ConfigurationError('Error reading seccomp profile: {}'.format(e)) + return cls( + 'seccomp={}'.format(json.dumps(seccomp_data)), con[1] + ) + return cls(value, None) + + def repr(self): + if self.src_file is not None: + return 'seccomp:{}'.format(self.src_file) + return self.value diff --git a/compose/service.py b/compose/service.py index 3918a19e83a..37368d64dbd 100644 --- a/compose/service.py +++ b/compose/service.py @@ -881,6 +881,10 @@ def _get_container_host_config(self, override_options, one_off=False): init_path = options.get('init') options['init'] = True + security_opt = [ + o.value for o in options.get('security_opt') + ] if options.get('security_opt') else None + nano_cpus = None if 'cpus' in options: nano_cpus = int(options.get('cpus') * NANOCPUS_SCALE) @@ -910,7 +914,7 @@ def _get_container_host_config(self, override_options, one_off=False): extra_hosts=options.get('extra_hosts'), read_only=options.get('read_only'), pid_mode=self.pid_mode.mode, - security_opt=options.get('security_opt'), + security_opt=security_opt, ipc_mode=options.get('ipc'), cgroup_parent=options.get('cgroup_parent'), cpu_quota=options.get('cpu_quota'), diff --git a/compose/utils.py b/compose/utils.py index 00b01df2e37..956673b4b7f 100644 --- a/compose/utils.py +++ b/compose/utils.py @@ -143,3 +143,11 @@ def parse_bytes(n): return sdk_parse_bytes(n) except DockerException: return None + + +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/integration/project_test.py b/tests/integration/project_test.py index b0e55f2d06f..0acb8028458 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -1,8 +1,10 @@ from __future__ import absolute_import from __future__ import unicode_literals -import os.path +import json +import os import random +import tempfile import py import pytest @@ -1834,3 +1836,35 @@ def test_project_up_no_healthcheck_dependency(self): assert 'svc1' in svc2.get_dependency_names() with pytest.raises(NoHealthCheckConfigured): svc1.is_healthy() + + def test_project_up_seccomp_profile(self): + seccomp_data = { + 'defaultAction': 'SCMP_ACT_ALLOW', + 'syscalls': [] + } + fd, profile_path = tempfile.mkstemp('_seccomp.json') + self.addCleanup(os.remove, profile_path) + with os.fdopen(fd, 'w') as f: + json.dump(seccomp_data, f) + + config_dict = { + 'version': '2.3', + 'services': { + 'svc1': { + 'image': 'busybox:latest', + 'command': 'top', + 'security_opt': ['seccomp:"{}"'.format(profile_path)] + } + } + } + + config_data = load_config(config_dict) + project = Project.from_config(name='composetest', config_data=config_data, client=self.client) + project.up() + containers = project.containers() + assert len(containers) == 1 + + remote_secopts = containers[0].get('HostConfig.SecurityOpt') + assert len(remote_secopts) == 1 + assert remote_secopts[0].startswith('seccomp=') + assert json.loads(remote_secopts[0].lstrip('seccomp=')) == seccomp_data diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 0bc902aea25..d1a704199ac 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -23,6 +23,7 @@ from .testcases import SWARM_SKIP_CPU_SHARES from compose import __version__ from compose.config.types import MountSpec +from compose.config.types import SecurityOpt from compose.config.types import VolumeFromSpec from compose.config.types import VolumeSpec from compose.const import IS_WINDOWS_PLATFORM @@ -238,11 +239,11 @@ def test_create_container_with_blkio_config(self): }] def test_create_container_with_security_opt(self): - security_opt = ['label:disable'] + security_opt = [SecurityOpt.parse('label:disable')] service = self.create_service('db', security_opt=security_opt) container = service.create_container() service.start_container(container) - assert set(container.get('HostConfig.SecurityOpt')) == set(security_opt) + assert set(container.get('HostConfig.SecurityOpt')) == set([o.repr() for o in security_opt]) @pytest.mark.xfail(True, reason='Not supported on most drivers') def test_create_container_with_storage_opt(self): diff --git a/tests/unit/cli/utils_test.py b/tests/unit/cli/utils_test.py index 066fb359544..26524ff3769 100644 --- a/tests/unit/cli/utils_test.py +++ b/tests/unit/cli/utils_test.py @@ -3,7 +3,7 @@ import unittest -from compose.cli.utils import unquote_path +from compose.utils import unquote_path class UnquotePathTest(unittest.TestCase): From a35335a75c66bf9cd8c85d02d880afc674f0da5c Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 26 Feb 2018 14:43:44 -0800 Subject: [PATCH 3300/4072] Add support for device_cgroup_rules in v2.3 files Signed-off-by: Joffrey F --- compose/config/config.py | 3 ++- compose/config/config_schema_v2.3.json | 15 ++++++++------- compose/service.py | 2 ++ tests/integration/service_test.py | 5 +++++ tests/unit/config/config_test.py | 15 +++++++++++++++ 5 files changed, 32 insertions(+), 8 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 38e88741710..0b5c7df0a74 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -72,6 +72,7 @@ 'cpus', 'cpuset', 'detach', + 'device_cgroup_rules', 'devices', 'dns', 'dns_search', @@ -1045,7 +1046,7 @@ def merge_service_dicts(base, override, version): for field in [ 'cap_add', 'cap_drop', 'expose', 'external_links', - 'security_opt', 'volumes_from', + 'security_opt', 'volumes_from', 'device_cgroup_rules', ]: md.merge_field(field, merge_unique_items_lists, default=[]) diff --git a/compose/config/config_schema_v2.3.json b/compose/config/config_schema_v2.3.json index 2d28df77a50..33840dba502 100644 --- a/compose/config/config_schema_v2.3.json +++ b/compose/config/config_schema_v2.3.json @@ -99,8 +99,8 @@ } ] }, - "cap_add": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, - "cap_drop": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "cap_add": {"$ref": "#/definitions/list_of_strings"}, + "cap_drop": {"$ref": "#/definitions/list_of_strings"}, "cgroup_parent": {"type": "string"}, "command": { "oneOf": [ @@ -137,7 +137,8 @@ } ] }, - "devices": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "device_cgroup_rules": {"$ref": "#/definitions/list_of_strings"}, + "devices": {"$ref": "#/definitions/list_of_strings"}, "dns_opt": { "type": "array", "items": { @@ -184,7 +185,7 @@ ] }, - "external_links": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "external_links": {"$ref": "#/definitions/list_of_strings"}, "extra_hosts": {"$ref": "#/definitions/list_or_dict"}, "healthcheck": {"$ref": "#/definitions/healthcheck"}, "hostname": {"type": "string"}, @@ -193,7 +194,7 @@ "ipc": {"type": "string"}, "isolation": {"type": "string"}, "labels": {"$ref": "#/definitions/labels"}, - "links": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "links": {"$ref": "#/definitions/list_of_strings"}, "logging": { "type": "object", @@ -264,7 +265,7 @@ "restart": {"type": "string"}, "runtime": {"type": "string"}, "scale": {"type": "integer"}, - "security_opt": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "security_opt": {"$ref": "#/definitions/list_of_strings"}, "shm_size": {"type": ["number", "string"]}, "sysctls": {"$ref": "#/definitions/list_or_dict"}, "pids_limit": {"type": ["number", "string"]}, @@ -335,7 +336,7 @@ } }, "volume_driver": {"type": "string"}, - "volumes_from": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "volumes_from": {"$ref": "#/definitions/list_of_strings"}, "working_dir": {"type": "string"} }, diff --git a/compose/service.py b/compose/service.py index 37368d64dbd..4a1cde88d47 100644 --- a/compose/service.py +++ b/compose/service.py @@ -66,6 +66,7 @@ 'cpu_shares', 'cpus', 'cpuset', + 'device_cgroup_rules', 'devices', 'dns', 'dns_search', @@ -944,6 +945,7 @@ def _get_container_host_config(self, override_options, one_off=False): device_write_bps=blkio_config.get('device_write_bps'), device_write_iops=blkio_config.get('device_write_iops'), mounts=options.get('mounts'), + device_cgroup_rules=options.get('device_cgroup_rules'), ) def get_secret_volumes(self): diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index d1a704199ac..2b6b7711edc 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -265,6 +265,11 @@ def test_create_container_with_mac_address(self): service.start_container(container) assert container.inspect()['Config']['MacAddress'] == '02:42:ac:11:65:43' + def test_create_container_with_device_cgroup_rules(self): + service = self.create_service('db', device_cgroup_rules=['c 7:128 rwm']) + container = service.create_container() + assert container.get('HostConfig.DeviceCgroupRules') == ['c 7:128 rwm'] + def test_create_container_with_specified_volume(self): host_path = '/tmp/host-path' container_path = '/container-path' diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index d72fae2f5c2..e032982214f 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -2558,6 +2558,21 @@ def test_merge_healthcheck_override_enables(self): actual = config.merge_service_dicts(base, override, V2_3) assert actual['healthcheck'] == override['healthcheck'] + def test_merge_device_cgroup_rules(self): + base = { + 'image': 'bar', + 'device_cgroup_rules': ['c 7:128 rwm', 'x 3:244 rw'] + } + + override = { + 'device_cgroup_rules': ['c 7:128 rwm', 'f 0:128 n'] + } + + actual = config.merge_service_dicts(base, override, V2_3) + assert sorted(actual['device_cgroup_rules']) == sorted( + ['c 7:128 rwm', 'x 3:244 rw', 'f 0:128 n'] + ) + def test_external_volume_config(self): config_details = build_config_details({ 'version': '2', From 7ce5766f6a02f013b64f3611af36deccf5109500 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 27 Feb 2018 11:14:28 -0800 Subject: [PATCH 3301/4072] Re-use TLS and host options when shelling out to docker CLI Signed-off-by: Joffrey F --- compose/cli/main.py | 62 ++++++++++++++++++++++++++++--------- tests/unit/cli/main_test.py | 42 +++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 15 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 624b007a8dc..972dffe2070 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -122,7 +122,7 @@ def perform_command(options, handler, command_options): return project = project_from_options('.', options) - command = TopLevelCommand(project) + command = TopLevelCommand(project, options=options) with errors.handle_connection_errors(project.client): handler(command, command_options) @@ -157,16 +157,17 @@ def setup_console_handler(handler, verbose, noansi=False, level=None): if level is not None: levels = { - 'DEBUG': logging.DEBUG, - 'INFO': logging.INFO, - 'WARNING': logging.WARNING, - 'ERROR': logging.ERROR, - 'CRITICAL': logging.CRITICAL, + 'DEBUG': logging.DEBUG, + 'INFO': logging.INFO, + 'WARNING': logging.WARNING, + 'ERROR': logging.ERROR, + 'CRITICAL': logging.CRITICAL, } loglevel = levels.get(level.upper()) if loglevel is None: - raise UserError('Invalid value for --log-level. Expected one of ' - + 'DEBUG, INFO, WARNING, ERROR, CRITICAL.') + raise UserError( + 'Invalid value for --log-level. Expected one of DEBUG, INFO, WARNING, ERROR, CRITICAL.' + ) handler.setLevel(loglevel) @@ -237,9 +238,10 @@ class TopLevelCommand(object): version Show the Docker-Compose version information """ - def __init__(self, project, project_dir='.'): + def __init__(self, project, project_dir='.', options=None): self.project = project self.project_dir = '.' + self.toplevel_options = options or {} def build(self, options): """ @@ -475,7 +477,10 @@ def exec_command(self, options): tty = not options["-T"] if IS_WINDOWS_PLATFORM or use_cli and not detach: - sys.exit(call_docker(build_exec_command(options, container.id, command))) + sys.exit(call_docker( + build_exec_command(options, container.id, command), + self.toplevel_options) + ) create_exec_options = { "privileged": options["--privileged"], @@ -820,7 +825,10 @@ def run(self, options): command = service.options.get('command') container_options = build_container_options(options, detach, command) - run_one_off_container(container_options, self.project, service, options, self.project_dir) + run_one_off_container( + container_options, self.project, service, options, + self.toplevel_options, self.project_dir + ) def scale(self, options): """ @@ -1253,7 +1261,8 @@ def build_container_options(options, detach, command): return container_options -def run_one_off_container(container_options, project, service, options, project_dir='.'): +def run_one_off_container(container_options, project, service, options, toplevel_options, + project_dir='.'): if not options['--no-deps']: deps = service.get_dependency_names() if deps: @@ -1289,7 +1298,10 @@ def remove_container(force=False): try: if IS_WINDOWS_PLATFORM or use_cli: service.connect_container_to_networks(container) - exit_code = call_docker(["start", "--attach", "--interactive", container.id]) + exit_code = call_docker( + ["start", "--attach", "--interactive", container.id], + toplevel_options + ) else: operation = RunOperation( project.client, @@ -1368,12 +1380,32 @@ def exit_if(condition, message, exit_code): raise SystemExit(exit_code) -def call_docker(args): +def call_docker(args, dockeropts): 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 + tls = dockeropts.get('--tls', False) + ca_cert = dockeropts.get('--tlscacert') + cert = dockeropts.get('--tlscert') + key = dockeropts.get('--tlskey') + verify = dockeropts.get('--tlsverify') + host = dockeropts.get('--host') + tls_options = [] + if tls: + tls_options.append('--tls') + if ca_cert: + tls_options.extend(['--tlscacert', ca_cert]) + if cert: + tls_options.extend(['--tlscert', cert]) + if key: + tls_options.extend(['--tlskey', key]) + if verify: + tls_options.append('--tlsverify') + if host: + tls_options.extend(['--host', host]) + + args = [executable_path] + tls_options + args log.debug(" ".join(map(pipes.quote, args))) return subprocess.call(args) diff --git a/tests/unit/cli/main_test.py b/tests/unit/cli/main_test.py index b1546d6f367..b46a3ee229e 100644 --- a/tests/unit/cli/main_test.py +++ b/tests/unit/cli/main_test.py @@ -9,6 +9,7 @@ from compose import container from compose.cli.errors import UserError from compose.cli.formatter import ConsoleWarningFormatter +from compose.cli.main import call_docker 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 @@ -112,3 +113,44 @@ def test_changed(self): convergence_strategy_from_opts(options) == ConvergenceStrategy.changed ) + + +def mock_find_executable(exe): + return exe + + +@mock.patch('compose.cli.main.find_executable', mock_find_executable) +class TestCallDocker(object): + def test_simple_no_options(self): + with mock.patch('subprocess.call') as fake_call: + call_docker(['ps'], {}) + + assert fake_call.call_args[0][0] == ['docker', 'ps'] + + def test_simple_tls_option(self): + with mock.patch('subprocess.call') as fake_call: + call_docker(['ps'], {'--tls': True}) + + assert fake_call.call_args[0][0] == ['docker', '--tls', 'ps'] + + def test_advanced_tls_options(self): + with mock.patch('subprocess.call') as fake_call: + call_docker(['ps'], { + '--tls': True, + '--tlscacert': './ca.pem', + '--tlscert': './cert.pem', + '--tlskey': './key.pem', + }) + + assert fake_call.call_args[0][0] == [ + 'docker', '--tls', '--tlscacert', './ca.pem', '--tlscert', + './cert.pem', '--tlskey', './key.pem', 'ps' + ] + + def test_with_host_option(self): + with mock.patch('subprocess.call') as fake_call: + call_docker(['ps'], {'--host': 'tcp://mydocker.net:2333'}) + + assert fake_call.call_args[0][0] == [ + 'docker', '--host', 'tcp://mydocker.net:2333', 'ps' + ] From a7d1fada5216fee774619cfa07e3e644e464460a Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 27 Feb 2018 11:24:40 -0800 Subject: [PATCH 3302/4072] Unify toplevel command handlers Signed-off-by: Joffrey F --- compose/cli/main.py | 83 ++++++++++++++++++++++----------------------- 1 file changed, 41 insertions(+), 42 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 972dffe2070..aff69c2d328 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -116,9 +116,9 @@ def perform_command(options, handler, command_options): handler(command_options) return - if options['COMMAND'] in ('config', 'bundle'): - command = TopLevelCommand(None) - handler(command, options, command_options) + if options['COMMAND'] == 'config': + command = TopLevelCommand(None, options=options) + handler(command, command_options) return project = project_from_options('.', options) @@ -279,7 +279,7 @@ def build(self, options): memory=options.get('--memory'), build_args=build_args) - def bundle(self, config_options, options): + def bundle(self, options): """ Generate a Distributed Application Bundle (DAB) from the Compose file. @@ -298,8 +298,7 @@ def bundle(self, config_options, options): -o, --output PATH Path to write the bundle file to. Defaults to ".dab". """ - self.project = project_from_options('.', config_options) - compose_config = get_config_from_options(self.project_dir, config_options) + compose_config = get_config_from_options(self.project_dir, self.toplevel_options) output = options["--output"] if not output: @@ -312,7 +311,7 @@ def bundle(self, config_options, options): log.info("Wrote bundle to {}".format(output)) - def config(self, config_options, options): + def config(self, options): """ Validate and view the Compose file. @@ -327,12 +326,13 @@ def config(self, config_options, options): """ - compose_config = get_config_from_options(self.project_dir, config_options) + compose_config = get_config_from_options(self.project_dir, self.toplevel_options) image_digests = None if options['--resolve-image-digests']: - self.project = project_from_options('.', config_options) - image_digests = image_digests_for_project(self.project) + self.project = project_from_options('.', self.toplevel_options) + with errors.handle_connection_errors(self.project.client): + image_digests = image_digests_for_project(self.project) if options['--quiet']: return @@ -1144,42 +1144,41 @@ def timeout_from_opts(options): def image_digests_for_project(project, allow_push=False): - with errors.handle_connection_errors(project.client): - try: - return get_image_digests( - project, - allow_push=allow_push - ) - except MissingDigests as e: - def list_images(images): - return "\n".join(" {}".format(name) for name in sorted(images)) + try: + return get_image_digests( + project, + allow_push=allow_push + ) + except MissingDigests as e: + def list_images(images): + return "\n".join(" {}".format(name) for name in sorted(images)) - paras = ["Some images are missing digests."] + paras = ["Some images are missing digests."] - if e.needs_push: - command_hint = ( - "Use `docker-compose push {}` to push them. " - .format(" ".join(sorted(e.needs_push))) - ) - paras += [ - "The following images can be pushed:", - list_images(e.needs_push), - command_hint, - ] - - if e.needs_pull: - command_hint = ( - "Use `docker-compose pull {}` to pull them. " - .format(" ".join(sorted(e.needs_pull))) - ) + if e.needs_push: + command_hint = ( + "Use `docker-compose push {}` to push them. " + .format(" ".join(sorted(e.needs_push))) + ) + paras += [ + "The following images can be pushed:", + list_images(e.needs_push), + command_hint, + ] - paras += [ - "The following images need to be pulled:", - list_images(e.needs_pull), - command_hint, - ] + if e.needs_pull: + command_hint = ( + "Use `docker-compose pull {}` to pull them. " + .format(" ".join(sorted(e.needs_pull))) + ) + + paras += [ + "The following images need to be pulled:", + list_images(e.needs_pull), + command_hint, + ] - raise UserError("\n\n".join(paras)) + raise UserError("\n\n".join(paras)) def exitval_from_opts(options, project): From 59c8ed77e4e8a54c5a7a7d645a73feed43ef140d Mon Sep 17 00:00:00 2001 From: Ganesh Satpute Date: Sun, 28 Jan 2018 16:45:38 +0530 Subject: [PATCH 3303/4072] Allow unset of entrypoint (resolves #5582) When an empty string is passed to the 'entrypoint' parameter, for example `docker-compose run --entrypoint='' ...` OR `docker-compose run --entrypoint '' ...` It allows the default entrypoint to be overriden by empty value. Signed-off-by: Ganesh Satpute --- compose/cli/main.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index aff69c2d328..056d8c46f1a 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -1232,8 +1232,7 @@ def build_container_options(options, detach, command): if options['--label']: container_options['labels'] = parse_labels(options['--label']) - if options['--entrypoint']: - container_options['entrypoint'] = options.get('--entrypoint') + _build_container_entrypoint_options(container_options, options) if options['--rm']: container_options['restart'] = None @@ -1260,6 +1259,16 @@ def build_container_options(options, detach, command): return container_options +def _build_container_entrypoint_options(container_options, options): + if options['--entrypoint']: + if options['--entrypoint'].strip() == '': + # Set an empty entry point. Refer https://github.com/moby/moby/pull/23718 + log.info("Overriding the entrypoint") + container_options['entrypoint'] = [""] + else: + container_options['entrypoint'] = options.get('--entrypoint') + + def run_one_off_container(container_options, project, service, options, toplevel_options, project_dir='.'): if not options['--no-deps']: From 5b6e02d13ac929efdf99c145f351429040493940 Mon Sep 17 00:00:00 2001 From: Thomas Scholtes Date: Wed, 10 May 2017 10:36:05 +0200 Subject: [PATCH 3304/4072] 'run' command can use network aliases for service It is now possible for the 'run' command to use the network aliases defined for the service. Fixes #3492 Signed-off-by: Thomas Scholtes --- compose/cli/main.py | 10 +++++++--- compose/service.py | 26 ++++++++++++-------------- tests/unit/cli_test.py | 4 ++++ 3 files changed, 23 insertions(+), 17 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index aff69c2d328..c79e4f7e4b5 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -803,6 +803,8 @@ def run(self, 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. + --use-aliases Use the service's network aliases in the network(s) the + container connects to. -v, --volume=[] Bind mount a volume (default []) -T Disable pseudo-tty allocation. By default `docker-compose run` allocates a TTY. @@ -1279,8 +1281,10 @@ def run_one_off_container(container_options, project, service, options, toplevel one_off=True, **container_options) + use_network_aliases = options['--use-aliases'] + if options.get('--detach'): - service.start_container(container) + service.start_container(container, use_network_aliases) print(container.name) return @@ -1296,7 +1300,7 @@ def remove_container(force=False): try: try: if IS_WINDOWS_PLATFORM or use_cli: - service.connect_container_to_networks(container) + service.connect_container_to_networks(container, use_network_aliases) exit_code = call_docker( ["start", "--attach", "--interactive", container.id], toplevel_options @@ -1310,7 +1314,7 @@ def remove_container(force=False): ) pty = PseudoTerminal(project.client, operation) sockets = pty.sockets() - service.start_container(container) + service.start_container(container, use_network_aliases) pty.start(sockets) exit_code = container.wait() except (signals.ShutdownException): diff --git a/compose/service.py b/compose/service.py index 85aa74f263c..cd1f7758759 100644 --- a/compose/service.py +++ b/compose/service.py @@ -557,8 +557,8 @@ def start_container_if_stopped(self, container, attach_logs=False, quiet=False): container.attach_log_stream() return self.start_container(container) - def start_container(self, container): - self.connect_container_to_networks(container) + def start_container(self, container, use_network_aliases=True): + self.connect_container_to_networks(container, use_network_aliases) try: container.start() except APIError as ex: @@ -574,7 +574,7 @@ def prioritized_networks(self): ) ) - def connect_container_to_networks(self, container): + def connect_container_to_networks(self, container, use_network_aliases=True): connected_networks = container.get('NetworkSettings.Networks') for network, netdefs in self.prioritized_networks.items(): @@ -583,10 +583,15 @@ def connect_container_to_networks(self, container): continue self.client.disconnect_container_from_network(container.id, network) - log.debug('Connecting to {}'.format(network)) + self.client.disconnect_container_from_network( + container.id, + network) + + aliases = self._get_aliases(netdefs) if use_network_aliases else [] + self.client.connect_container_to_network( container.id, network, - aliases=self._get_aliases(netdefs, container), + aliases=aliases, ipv4_address=netdefs.get('ipv4_address', None), ipv6_address=netdefs.get('ipv6_address', None), links=self._get_links(False), @@ -691,15 +696,8 @@ def _next_container_number(self, one_off=False): numbers = [c.number for c in containers] return 1 if not numbers else max(numbers) + 1 - def _get_aliases(self, network, container=None): - if container and container.labels.get(LABEL_ONE_OFF) == "True": - return [] - - return list( - {self.name} | - ({container.short_id} if container else set()) | - set(network.get('aliases', ())) - ) + def _get_aliases(self, network): + return list({self.name} | set(network.get('aliases', ()))) def build_default_networking_config(self): if not self.networks: diff --git a/tests/unit/cli_test.py b/tests/unit/cli_test.py index cef53740dd0..47eaabf9d8c 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -124,6 +124,7 @@ def test_run_interactive_passes_logs_false(self, mock_pseudo_terminal, mock_run_ '-T': None, '--entrypoint': None, '--service-ports': None, + '--use-aliases': None, '--publish': [], '--volume': [], '--rm': None, @@ -162,6 +163,7 @@ def test_run_service_with_restart_always(self): '-T': None, '--entrypoint': None, '--service-ports': None, + '--use-aliases': None, '--publish': [], '--volume': [], '--rm': None, @@ -183,6 +185,7 @@ def test_run_service_with_restart_always(self): '-T': None, '--entrypoint': None, '--service-ports': None, + '--use-aliases': None, '--publish': [], '--volume': [], '--rm': True, @@ -214,6 +217,7 @@ def test_command_manual_and_service_ports_together(self): '-T': None, '--entrypoint': None, '--service-ports': True, + '--use-aliases': None, '--publish': ['80:80'], '--rm': None, '--name': None, From e78c0bf533280ee526e1b5cc52cc7e7db139fb36 Mon Sep 17 00:00:00 2001 From: Jim Dalton Date: Wed, 10 May 2017 13:28:40 +0200 Subject: [PATCH 3305/4072] Add acceptance test for use-aliases feature Signed-off-by: Jim Dalton --- tests/acceptance/cli_test.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 42b487aabdc..404cd8c48ba 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1903,6 +1903,28 @@ def test_run_service_with_workdir_overridden_short_form(self): container = service.containers(stopped=True, one_off=True)[0] assert workdir == container.get('Config.WorkingDir') + @v2_only() + def test_run_service_with_use_aliases(self): + filename = 'network-aliases.yml' + self.base_dir = 'tests/fixtures/networks' + self.dispatch(['-f', filename, 'run', '-d', '--use-aliases', 'web', 'top']) + + back_name = '{}_back'.format(self.project.name) + front_name = '{}_front'.format(self.project.name) + + web_container = self.project.get_service('web').containers(one_off=OneOffFilter.only)[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_run_interactive_connects_to_network(self): self.base_dir = 'tests/fixtures/networks' From 1096a903be0b5897af8995cec4cccac9d20d880c Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 27 Feb 2018 12:57:58 -0800 Subject: [PATCH 3306/4072] unset entrypoint test Signed-off-by: Joffrey F --- compose/cli/main.py | 15 ++++----------- tests/acceptance/cli_test.py | 12 ++++++++++++ 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 056d8c46f1a..62de35fec81 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -1232,7 +1232,10 @@ def build_container_options(options, detach, command): if options['--label']: container_options['labels'] = parse_labels(options['--label']) - _build_container_entrypoint_options(container_options, options) + if options.get('--entrypoint') is not None: + container_options['entrypoint'] = ( + [""] if options['--entrypoint'] == '' else options['--entrypoint'] + ) if options['--rm']: container_options['restart'] = None @@ -1259,16 +1262,6 @@ def build_container_options(options, detach, command): return container_options -def _build_container_entrypoint_options(container_options, options): - if options['--entrypoint']: - if options['--entrypoint'].strip() == '': - # Set an empty entry point. Refer https://github.com/moby/moby/pull/23718 - log.info("Overriding the entrypoint") - container_options['entrypoint'] = [""] - else: - container_options['entrypoint'] = options.get('--entrypoint') - - def run_one_off_container(container_options, project, service, options, toplevel_options, project_dir='.'): if not options['--no-deps']: diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 42b487aabdc..a8d93bfe8f2 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1697,6 +1697,18 @@ def test_run_service_with_dockerfile_entrypoint(self): assert container.get('Config.Entrypoint') == ['printf'] assert container.get('Config.Cmd') == ['default', 'args'] + def test_run_service_with_unset_entrypoint(self): + self.base_dir = 'tests/fixtures/entrypoint-dockerfile' + self.dispatch(['run', '--entrypoint=""', 'test', 'true']) + container = self.project.containers(stopped=True, one_off=OneOffFilter.only)[0] + assert container.get('Config.Entrypoint') is None + assert container.get('Config.Cmd') == ['true'] + + self.dispatch(['run', '--entrypoint', '""', 'test', 'true']) + container = self.project.containers(stopped=True, one_off=OneOffFilter.only)[0] + assert container.get('Config.Entrypoint') is None + assert container.get('Config.Cmd') == ['true'] + def test_run_service_with_dockerfile_entrypoint_overridden(self): self.base_dir = 'tests/fixtures/entrypoint-dockerfile' self.dispatch(['run', '--entrypoint', 'echo', 'test']) From 07199fac374c427dcd949507e282abd2082d7564 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 27 Feb 2018 13:17:28 -0800 Subject: [PATCH 3307/4072] Restore container ID alias Signed-off-by: Joffrey F --- compose/service.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/compose/service.py b/compose/service.py index cd1f7758759..b9f9af2cda7 100644 --- a/compose/service.py +++ b/compose/service.py @@ -583,11 +583,7 @@ def connect_container_to_networks(self, container, use_network_aliases=True): continue self.client.disconnect_container_from_network(container.id, network) - self.client.disconnect_container_from_network( - container.id, - network) - - aliases = self._get_aliases(netdefs) if use_network_aliases else [] + aliases = self._get_aliases(netdefs, container) if use_network_aliases else [] self.client.connect_container_to_network( container.id, network, @@ -696,8 +692,12 @@ def _next_container_number(self, one_off=False): numbers = [c.number for c in containers] return 1 if not numbers else max(numbers) + 1 - def _get_aliases(self, network): - return list({self.name} | set(network.get('aliases', ()))) + def _get_aliases(self, network, container=None): + 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: From 86428af5bcb96975e4379074c54c1e6c088603d5 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 27 Feb 2018 14:44:21 -0800 Subject: [PATCH 3308/4072] Bump 1.20.0-rc1 Signed-off-by: Joffrey F --- CHANGELOG.md | 77 +++++++++++++++++++++++++++++++++++++++++++++ compose/__init__.py | 2 +- script/run/run.sh | 2 +- 3 files changed, 79 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4287de49373..e662d40a697 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,83 @@ Change log ========== +1.20.0 (2018-03-07) +------------------- + +### New features + +#### Compose file version 3.6 + +- Introduced version 3.6 of the `docker-compose.yml` specification. + This version requires to be used with Docker Engine 18.02.0 or above. + +- Added support for the `tmpfs.size` property in volume mappings + +#### Compose file version 3.2 and up + +- The `--build-arg` option can now be used without specifying a service + in `docker-compose build` + +#### Compose file version 2.3 + +- Added support for `device_cgroup_rules` in service definitions + +- Added support for the `tmpfs.size` property in long-form volume mappings + +- The `--build-arg` option can now be used without specifying a service + in `docker-compose build` + +#### All formats + +- Added a `--log-level` option to the top-level `docker-compose` command. + Accepted values are `debug`, `info`, `warning`, `error`, `critical`. + Default log level is `info` + +- `docker-compose run` now allows users to unset the container's entrypoint + +- Proxy configuration found in the `~/.docker/config.json` file now populates + environment and build args for containers created by Compose + +- Added a `--use-aliases` flag to `docker-compose run`, indicating that + network aliases declared in the service's config should be used for the + running container + +- `docker-compose run` now kills and removes the running container upon + receiving `SIGHUP` + +- `docker-compose ps` now shows the containers' health status if available + +- Added the long-form `--detach` option to the `exec`, `run` and `up` + commands + +### Bugfixes + +- Fixed `.dockerignore` handling, notably with regard to absolute paths + and last-line precedence rules + +- Fixed a bug introduced in 1.19.0 which caused the default certificate path + to not be honored by Compose + +- Fixed a bug where Compose would incorrectly check whether a symlink's + destination was accessible when part of a build context + +- Fixed a bug where `.dockerignore` files containing lines of whitespace + caused Compose to error out on Windows + +- Fixed a bug where `--tls*` and `--host` options wouldn't be properly honored + for interactive `run` and `exec` commands + +- A `seccomp:` entry in the `security_opt` config now correctly + sends the contents of the file to the engine + +- Improved support for non-unicode locales + +- Fixed a crash occurring on Windows when the user's home directory name + contained non-ASCII characters + +- Fixed a bug occurring during builds caused by files with a negative `mtime` + values in the build context + 1.19.0 (2018-02-07) ------------------- diff --git a/compose/__init__.py b/compose/__init__.py index f0ad67347bf..2090f10c804 100644 --- a/compose/__init__.py +++ b/compose/__init__.py @@ -1,4 +1,4 @@ from __future__ import absolute_import from __future__ import unicode_literals -__version__ = '1.20.0dev' +__version__ = '1.20.0-rc1' diff --git a/script/run/run.sh b/script/run/run.sh index ae55ff75980..2adcc98f870 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.19.0" +VERSION="1.20.0-rc1" IMAGE="docker/compose:$VERSION" From 7049bea1bb932e96309ef71f6fe21a1b4f22a805 Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Thu, 1 Mar 2018 15:32:41 +0100 Subject: [PATCH 3309/4072] Add support for options added in 1.20.0 to bash completion New options: - `docker-compose --log-level` - `docker-compose pull --include-deps` - `docker-compose run --use-aliases` Signed-off-by: Harald Albers --- contrib/completion/bash/docker-compose | 30 +++++++++++++++++--------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index 29853b0893d..90c9ce5fcfb 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -179,18 +179,22 @@ _docker_compose_docker_compose() { _filedir "y?(a)ml" return ;; + --log-level) + COMPREPLY=( $( compgen -W "debug info warning error critical" -- "$cur" ) ) + return + ;; --project-directory) _filedir -d return ;; - $(__docker_compose_to_extglob "$top_level_options_with_args") ) + $(__docker_compose_to_extglob "$daemon_options_with_args") ) return ;; esac case "$cur" in -*) - COMPREPLY=( $( compgen -W "$top_level_boolean_options $top_level_options_with_args --help -h --no-ansi --verbose --version -v" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "$daemon_boolean_options $daemon_options_with_args $top_level_options_with_args --help -h --no-ansi --verbose --version -v" -- "$cur" ) ) ;; *) COMPREPLY=( $( compgen -W "${commands[*]}" -- "$cur" ) ) @@ -375,7 +379,7 @@ _docker_compose_ps() { _docker_compose_pull() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "--help --ignore-pull-failures --parallel --quiet -q" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--help --ignore-pull-failures --include-deps --parallel --quiet -q" -- "$cur" ) ) ;; *) __docker_compose_services_from_image @@ -444,7 +448,7 @@ _docker_compose_run() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "-d --detach --entrypoint -e --help --label -l --name --no-deps --publish -p --rm --service-ports -T --user -u --volume -v --workdir -w" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--detach -d --entrypoint -e --help --label -l --name --no-deps --publish -p --rm --service-ports -T --use-aliases --user -u --volume -v --workdir -w" -- "$cur" ) ) ;; *) __docker_compose_services_all @@ -605,14 +609,12 @@ _docker_compose() { # Options for the docker daemon that have to be passed to secondary calls to # docker-compose executed by this script. - # Other global otions that are not relevant for secondary calls are defined in - # `_docker_compose_docker_compose`. - local top_level_boolean_options=" + local daemon_boolean_options=" --skip-hostname-check --tls --tlsverify " - local top_level_options_with_args=" + local daemon_options_with_args=" --file -f --host -H --project-directory @@ -622,6 +624,11 @@ _docker_compose() { --tlskey " + # These options are require special treatment when searching the command. + local top_level_options_with_args=" + --log-level + " + COMPREPLY=() local cur prev words cword _get_comp_words_by_ref -n : cur prev words cword @@ -634,15 +641,18 @@ _docker_compose() { while [ $counter -lt $cword ]; do case "${words[$counter]}" in - $(__docker_compose_to_extglob "$top_level_boolean_options") ) + $(__docker_compose_to_extglob "$daemon_boolean_options") ) local opt=${words[counter]} top_level_options+=($opt) ;; - $(__docker_compose_to_extglob "$top_level_options_with_args") ) + $(__docker_compose_to_extglob "$daemon_options_with_args") ) local opt=${words[counter]} local arg=${words[++counter]} top_level_options+=($opt $arg) ;; + $(__docker_compose_to_extglob "$top_level_options_with_args") ) + (( counter++ )) + ;; -*) ;; *) From 17610e8d19216610dfc8111671ab84535cbcc2d3 Mon Sep 17 00:00:00 2001 From: Matthieu Nottale Date: Thu, 1 Mar 2018 14:09:11 +0100 Subject: [PATCH 3310/4072] Fix a race condition in ParallelStreamWriter. Signed-off-by: Matthieu Nottale --- compose/parallel.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/compose/parallel.py b/compose/parallel.py index 341ca2f5e03..dd83c70cd76 100644 --- a/compose/parallel.py +++ b/compose/parallel.py @@ -4,6 +4,7 @@ import logging import operator import sys +from threading import Lock from threading import Semaphore from threading import Thread @@ -251,6 +252,7 @@ class ParallelStreamWriter(object): """ noansi = False + lock = Lock() @classmethod def set_noansi(cls, value=True): @@ -274,6 +276,7 @@ def write_initial(self, obj_index): self.stream.flush() def _write_ansi(self, obj_index, status): + self.lock.acquire() position = self.lines.index(obj_index) diff = len(self.lines) - position # move up @@ -285,6 +288,7 @@ def _write_ansi(self, obj_index, status): # move back down self.stream.write("%c[%dB" % (27, diff)) self.stream.flush() + self.lock.release() def _write_noansi(self, obj_index, status): self.stream.write("{} {:<{width}} ... {}\r\n".format(self.msg, obj_index, From efd3d1db064689093ac5bc4a730820f59faa21d7 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 2 Mar 2018 15:45:37 -0800 Subject: [PATCH 3311/4072] Install both versions of python Signed-off-by: Joffrey F --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 7661c647061..6ee2a60e50d 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -7,7 +7,7 @@ jobs: - checkout - run: name: install python3 - command: brew update > /dev/null && brew install python3 + command: brew update > /dev/null && brew upgrade python - run: name: install tox command: sudo pip install --upgrade tox==2.1.1 From d9e023f79f5e9cc5d5e4934255f14f09ae327661 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 5 Mar 2018 15:38:42 -0800 Subject: [PATCH 3312/4072] SDK version 3.1.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 da05e421296..33462d4961c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ backports.ssl-match-hostname==3.5.0.1; python_version < '3' cached-property==1.3.0 certifi==2017.4.17 chardet==3.0.4 -docker==3.1.0 +docker==3.1.1 docker-pycreds==0.2.1 dockerpty==0.4.1 docopt==0.6.2 diff --git a/setup.py b/setup.py index d1788df0ab3..cf8f6dc1318 100644 --- a/setup.py +++ b/setup.py @@ -36,7 +36,7 @@ def find_version(*file_paths): 'requests >= 2.6.1, != 2.11.0, != 2.12.2, != 2.18.0, < 2.19', 'texttable >= 0.9.0, < 0.10', 'websocket-client >= 0.32.0, < 1.0', - 'docker >= 3.1.0, < 4.0', + 'docker >= 3.1.1, < 4.0', 'dockerpty >= 0.4.1, < 0.5', 'six >= 1.3.0, < 2', 'jsonschema >= 2.5.1, < 3', From 31dcfcff2ad9124d028e642e5dd61530714b15c7 Mon Sep 17 00:00:00 2001 From: Matthieu Nottale Date: Mon, 5 Mar 2018 14:28:46 +0100 Subject: [PATCH 3313/4072] Revamp ParallelStreamWriter to fix display issues. Signed-off-by: Matthieu Nottale --- compose/parallel.py | 106 ++++++++++++++++-------------- compose/service.py | 3 +- tests/integration/service_test.py | 3 + tests/unit/parallel_test.py | 3 + tests/unit/service_test.py | 2 + 5 files changed, 66 insertions(+), 51 deletions(-) diff --git a/compose/parallel.py b/compose/parallel.py index dd83c70cd76..5d4791f9756 100644 --- a/compose/parallel.py +++ b/compose/parallel.py @@ -43,55 +43,60 @@ def set_global_limit(cls, value): cls.global_limiter = Semaphore(value) -def parallel_execute(objects, func, get_name, msg, get_deps=None, limit=None, parent_objects=None): - """Runs func on objects in parallel while ensuring that func is - ran on object only after it is ran on all its dependencies. - - get_deps called on object must return a collection with its dependencies. - get_name called on object must return its name. +def parallel_execute_watch(events, writer, errors, results, msg, get_name): + """ Watch events from a parallel execution, update status and fill errors and results. + Returns exception to re-raise. """ - objects = list(objects) - stream = get_output_stream(sys.stderr) - - writer = ParallelStreamWriter(stream, msg) - - display_objects = list(parent_objects) if parent_objects else objects - - for obj in display_objects: - writer.add_object(get_name(obj)) - - # write data in a second loop to consider all objects for width alignment - # and avoid duplicates when parent_objects exists - for obj in objects: - writer.write_initial(get_name(obj)) - - events = parallel_execute_iter(objects, func, get_deps, limit) - - errors = {} - results = [] error_to_reraise = None - for obj, result, exception in events: if exception is None: - writer.write(get_name(obj), 'done', green) + writer.write(msg, get_name(obj), 'done', green) results.append(result) elif isinstance(exception, ImageNotFound): # This is to bubble up ImageNotFound exceptions to the client so we # can prompt the user if they want to rebuild. errors[get_name(obj)] = exception.explanation - writer.write(get_name(obj), 'error', red) + writer.write(msg, get_name(obj), 'error', red) error_to_reraise = exception elif isinstance(exception, APIError): errors[get_name(obj)] = exception.explanation - writer.write(get_name(obj), 'error', red) + writer.write(msg, get_name(obj), 'error', red) elif isinstance(exception, (OperationFailedError, HealthCheckFailed, NoHealthCheckConfigured)): errors[get_name(obj)] = exception.msg - writer.write(get_name(obj), 'error', red) + writer.write(msg, get_name(obj), 'error', red) elif isinstance(exception, UpstreamError): - writer.write(get_name(obj), 'error', red) + writer.write(msg, get_name(obj), 'error', red) else: errors[get_name(obj)] = exception error_to_reraise = exception + return error_to_reraise + + +def parallel_execute(objects, func, get_name, msg, get_deps=None, limit=None): + """Runs func on objects in parallel while ensuring that func is + ran on object only after it is ran on all its dependencies. + + get_deps called on object must return a collection with its dependencies. + get_name called on object must return its name. + """ + objects = list(objects) + stream = get_output_stream(sys.stderr) + + if ParallelStreamWriter.instance: + writer = ParallelStreamWriter.instance + else: + writer = ParallelStreamWriter(stream) + + for obj in objects: + writer.add_object(msg, get_name(obj)) + for obj in objects: + writer.write_initial(msg, get_name(obj)) + + events = parallel_execute_iter(objects, func, get_deps, limit) + + errors = {} + results = [] + error_to_reraise = parallel_execute_watch(events, writer, errors, results, msg, get_name) for obj_name, error in errors.items(): stream.write("\nERROR: for {} {}\n".format(obj_name, error)) @@ -253,55 +258,58 @@ class ParallelStreamWriter(object): noansi = False lock = Lock() + instance = None @classmethod def set_noansi(cls, value=True): cls.noansi = value - def __init__(self, stream, msg): + def __init__(self, stream): self.stream = stream - self.msg = msg self.lines = [] self.width = 0 + ParallelStreamWriter.instance = self - def add_object(self, obj_index): - self.lines.append(obj_index) - self.width = max(self.width, len(obj_index)) + def add_object(self, msg, obj_index): + if msg is None: + return + self.lines.append(msg + obj_index) + self.width = max(self.width, len(msg + ' ' + obj_index)) - def write_initial(self, obj_index): - if self.msg is None: + def write_initial(self, msg, obj_index): + if msg is None: return - self.stream.write("{} {:<{width}} ... \r\n".format( - self.msg, self.lines[self.lines.index(obj_index)], width=self.width)) + self.stream.write("{:<{width}} ... \r\n".format( + msg + ' ' + obj_index, width=self.width)) self.stream.flush() - def _write_ansi(self, obj_index, status): + def _write_ansi(self, msg, obj_index, status): self.lock.acquire() - position = self.lines.index(obj_index) + position = self.lines.index(msg + obj_index) diff = len(self.lines) - position # move up self.stream.write("%c[%dA" % (27, diff)) # erase self.stream.write("%c[2K\r" % 27) - self.stream.write("{} {:<{width}} ... {}\r".format(self.msg, obj_index, + self.stream.write("{:<{width}} ... {}\r".format(msg + ' ' + obj_index, status, width=self.width)) # move back down self.stream.write("%c[%dB" % (27, diff)) self.stream.flush() self.lock.release() - def _write_noansi(self, obj_index, status): - self.stream.write("{} {:<{width}} ... {}\r\n".format(self.msg, obj_index, + def _write_noansi(self, msg, obj_index, status): + self.stream.write("{:<{width}} ... {}\r\n".format(msg + ' ' + obj_index, status, width=self.width)) self.stream.flush() - def write(self, obj_index, status, color_func): - if self.msg is None: + def write(self, msg, obj_index, status, color_func): + if msg is None: return if self.noansi: - self._write_noansi(obj_index, status) + self._write_noansi(msg, obj_index, status) else: - self._write_ansi(obj_index, color_func(status)) + self._write_ansi(msg, obj_index, color_func(status)) def parallel_operation(containers, operation, options, message): diff --git a/compose/service.py b/compose/service.py index b9f9af2cda7..65208047837 100644 --- a/compose/service.py +++ b/compose/service.py @@ -402,8 +402,7 @@ def create_and_start(service, n): [ServiceName(self.project, self.name, index) for index in range(i, i + scale)], lambda service_name: create_and_start(self, service_name.number), lambda service_name: self.get_container_name(service_name.service, service_name.number), - "Creating", - parent_objects=project_services + "Creating" ) for error in errors.values(): raise OperationFailedError(error) diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 2b6b7711edc..6e86a02d476 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -35,6 +35,7 @@ from compose.const import LABEL_VERSION from compose.container import Container from compose.errors import OperationFailedError +from compose.parallel import ParallelStreamWriter from compose.project import OneOffFilter from compose.service import ConvergencePlan from compose.service import ConvergenceStrategy @@ -1197,6 +1198,7 @@ def test_scale_with_stopped_containers(self): service.create_container(number=next_number) service.create_container(number=next_number + 1) + ParallelStreamWriter.instance = None with mock.patch('sys.stderr', new_callable=StringIO) as mock_stderr: service.scale(2) for container in service.containers(): @@ -1220,6 +1222,7 @@ def test_scale_with_stopped_containers_and_needing_creation(self): for container in service.containers(): assert not container.is_running + ParallelStreamWriter.instance = None with mock.patch('sys.stderr', new_callable=StringIO) as mock_stderr: service.scale(2) diff --git a/tests/unit/parallel_test.py b/tests/unit/parallel_test.py index 4ebc24d8cb6..0735bfccb48 100644 --- a/tests/unit/parallel_test.py +++ b/tests/unit/parallel_test.py @@ -143,6 +143,7 @@ def process(x): def test_parallel_execute_alignment(capsys): + ParallelStreamWriter.instance = None results, errors = parallel_execute( objects=["short", "a very long name"], func=lambda x: x, @@ -158,6 +159,7 @@ def test_parallel_execute_alignment(capsys): def test_parallel_execute_ansi(capsys): + ParallelStreamWriter.instance = None ParallelStreamWriter.set_noansi(value=False) results, errors = parallel_execute( objects=["something", "something more"], @@ -173,6 +175,7 @@ def test_parallel_execute_ansi(capsys): def test_parallel_execute_noansi(capsys): + ParallelStreamWriter.instance = None ParallelStreamWriter.set_noansi() results, errors = parallel_execute( objects=["something", "something more"], diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index c315dcc4db8..9128b95503c 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -20,6 +20,7 @@ from compose.const import LABEL_SERVICE from compose.const import SECRETS_PATH from compose.container import Container +from compose.parallel import ParallelStreamWriter from compose.project import OneOffFilter from compose.service import build_ulimits from compose.service import build_volume_binding @@ -727,6 +728,7 @@ def test_image_name_default(self): @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'} + ParallelStreamWriter.instance = None name = 'foo' service = Service( name, From b9f9643d24bd358677e39c2d198560b5ccb2b022 Mon Sep 17 00:00:00 2001 From: Matthieu Nottale Date: Mon, 5 Mar 2018 11:15:15 +0100 Subject: [PATCH 3314/4072] Add support for 'cpu_period' for compose v2.1-v2.3. Signed-off-by: Matthieu Nottale --- compose/config/config.py | 1 + compose/config/config_schema_v2.1.json | 1 + compose/config/config_schema_v2.2.json | 1 + compose/config/config_schema_v2.3.json | 1 + compose/config/interpolation.py | 9 +++++++++ compose/service.py | 2 ++ tests/integration/service_test.py | 3 ++- 7 files changed, 17 insertions(+), 1 deletion(-) diff --git a/compose/config/config.py b/compose/config/config.py index 0b5c7df0a74..3b8490a0b82 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -67,6 +67,7 @@ 'command', 'cpu_count', 'cpu_percent', + 'cpu_period', 'cpu_quota', 'cpu_shares', 'cpus', diff --git a/compose/config/config_schema_v2.1.json b/compose/config/config_schema_v2.1.json index 984c46c5354..87a730dd85a 100644 --- a/compose/config/config_schema_v2.1.json +++ b/compose/config/config_schema_v2.1.json @@ -106,6 +106,7 @@ "container_name": {"type": "string"}, "cpu_shares": {"type": ["number", "string"]}, "cpu_quota": {"type": ["number", "string"]}, + "cpu_period": {"type": ["number", "string"]}, "cpuset": {"type": "string"}, "depends_on": { "oneOf": [ diff --git a/compose/config/config_schema_v2.2.json b/compose/config/config_schema_v2.2.json index 8e3927e1da1..e15223fc3ce 100644 --- a/compose/config/config_schema_v2.2.json +++ b/compose/config/config_schema_v2.2.json @@ -110,6 +110,7 @@ "cpu_percent": {"type": "integer", "minimum": 0, "maximum": 100}, "cpu_shares": {"type": ["number", "string"]}, "cpu_quota": {"type": ["number", "string"]}, + "cpu_period": {"type": ["number", "string"]}, "cpus": {"type": "number", "minimum": 0}, "cpuset": {"type": "string"}, "depends_on": { diff --git a/compose/config/config_schema_v2.3.json b/compose/config/config_schema_v2.3.json index 33840dba502..c2e860be239 100644 --- a/compose/config/config_schema_v2.3.json +++ b/compose/config/config_schema_v2.3.json @@ -113,6 +113,7 @@ "cpu_percent": {"type": "integer", "minimum": 0, "maximum": 100}, "cpu_shares": {"type": ["number", "string"]}, "cpu_quota": {"type": ["number", "string"]}, + "cpu_period": {"type": ["number", "string"]}, "cpus": {"type": "number", "minimum": 0}, "cpuset": {"type": "string"}, "depends_on": { diff --git a/compose/config/interpolation.py b/compose/config/interpolation.py index b1143d66c77..7e50f989223 100644 --- a/compose/config/interpolation.py +++ b/compose/config/interpolation.py @@ -10,6 +10,7 @@ from .errors import ConfigurationError from compose.const import COMPOSEFILE_V2_0 as V2_0 from compose.utils import parse_bytes +from compose.utils import parse_nanoseconds_int log = logging.getLogger(__name__) @@ -223,6 +224,12 @@ def bytes_to_int(s): return v +def to_microseconds(v): + if not isinstance(v, six.string_types): + return v + return int(parse_nanoseconds_int(v) / 1000) + + class ConversionMap(object): map = { service_path('blkio_config', 'weight'): to_int, @@ -230,6 +237,8 @@ class ConversionMap(object): service_path('build', 'labels', FULL_JOKER): to_str, service_path('cpus'): to_float, service_path('cpu_count'): to_int, + service_path('cpu_quota'): to_microseconds, + service_path('cpu_period'): to_microseconds, service_path('configs', 'mode'): to_int, service_path('secrets', 'mode'): to_int, service_path('healthcheck', 'retries'): to_int, diff --git a/compose/service.py b/compose/service.py index b9f9af2cda7..501664c7ca4 100644 --- a/compose/service.py +++ b/compose/service.py @@ -62,6 +62,7 @@ 'cgroup_parent', 'cpu_count', 'cpu_percent', + 'cpu_period', 'cpu_quota', 'cpu_shares', 'cpus', @@ -948,6 +949,7 @@ def _get_container_host_config(self, override_options, one_off=False): device_write_iops=blkio_config.get('device_write_iops'), mounts=options.get('mounts'), device_cgroup_rules=options.get('device_cgroup_rules'), + cpu_period=options.get('cpu_period'), ) def get_secret_volumes(self): diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 2b6b7711edc..6752364b24e 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -121,10 +121,11 @@ def test_create_container_with_cpu_shares(self): assert container.get('HostConfig.CpuShares') == 73 def test_create_container_with_cpu_quota(self): - service = self.create_service('db', cpu_quota=40000) + service = self.create_service('db', cpu_quota=40000, cpu_period=150000) container = service.create_container() container.start() assert container.get('HostConfig.CpuQuota') == 40000 + assert container.get('HostConfig.CpuPeriod') == 150000 @v2_2_only() def test_create_container_with_cpu_count(self): From c2052d0370263407ed78cfa6f44ef24b09a67945 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 7 Mar 2018 11:39:24 -0800 Subject: [PATCH 3315/4072] Add blacklist to versions.py CI script Signed-off-by: Joffrey F --- script/test/versions.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/script/test/versions.py b/script/test/versions.py index 46872ed9a6d..f699f2681fc 100755 --- a/script/test/versions.py +++ b/script/test/versions.py @@ -73,6 +73,11 @@ def __str__(self): return '.'.join(map(str, self[:3])) + edition + rc +BLACKLIST = [ # List of versions known to be broken and should not be used + Version.parse('18.03.0-ce-rc2'), +] + + def group_versions(versions): """Group versions by `major.minor` releases. @@ -117,7 +122,9 @@ def get_default(versions): def get_versions(tags): for tag in tags: try: - yield Version.parse(tag['name']) + v = Version.parse(tag['name']) + if v not in BLACKLIST: + yield v except ValueError: print("Skipping invalid tag: {name}".format(**tag), file=sys.stderr) From 4181d231313632a5b84e603dbf0c8aa37774c98a Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 6 Mar 2018 14:00:03 -0800 Subject: [PATCH 3316/4072] Add new maintainers and move inactive maintainers to alumni Signed-off-by: Joffrey F --- MAINTAINERS | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/MAINTAINERS b/MAINTAINERS index 89f5b4124c9..ff840887413 100644 --- a/MAINTAINERS +++ b/MAINTAINERS @@ -11,11 +11,25 @@ [Org] [Org."Core maintainers"] people = [ + "dnephin", + "mefyl", + "mnottale", + "shin-", + ] + [Org.Alumni] + people = [ + # Aanand Prasad is one of the two creators of the fig project + # which later went on to become docker-compose, and a longtime + # maintainer responsible for several keystone features "aanand", + # Ben Firshman is also one of the fig creators and contributed + # heavily to the project's design and UX as well as the + # day-to-day maintenance "bfirsh", - "dnephin", + # Mazz Mosley made significant contributions to the project + # in 2015 with solid bugfixes and improved error handling + # among them "mnowster", - "shin-", ] [people] @@ -41,6 +55,16 @@ Email = "dnephin@gmail.com" GitHub = "dnephin" + [people.mefyl] + Name = "Quentin Hocquet" + Email = "quentin.hocquet@docker.com" + GitHub = "mefyl" + + [people.mnottale] + Name = "Matthieu Nottale" + Email = "matthieu.nottale@docker.com" + GitHub = "mnottale" + [people.mnowster] Name = "Mazz Mosley" Email = "mazz@houseofmnowster.com" From 08f71a8d3d73f32d890b7485d0c3449ffb9e97f8 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 8 Mar 2018 15:44:42 -0800 Subject: [PATCH 3317/4072] Update Dockerfile.run to produce smaller image Signed-off-by: Joffrey F --- Dockerfile.run | 40 +++++++++------------------------------- 1 file changed, 9 insertions(+), 31 deletions(-) diff --git a/Dockerfile.run b/Dockerfile.run index b3f9a01f6c9..04acf01c304 100644 --- a/Dockerfile.run +++ b/Dockerfile.run @@ -1,42 +1,20 @@ -FROM sgerrand/glibc-builder as glibc -RUN apt-get install -yq bison - -ENV PKGDIR /pkgdata - -RUN mkdir -p /usr/glibc-compat/etc && touch /usr/glibc-compat/etc/ld.so.conf -RUN /builder 2.27 /usr/glibc-compat || true -RUN mkdir -p $PKGDIR -RUN tar -xf /glibc-bin-2.27.tar.gz -C $PKGDIR -RUN rm "$PKGDIR"/usr/glibc-compat/etc/rpc && \ - rm -rf "$PKGDIR"/usr/glibc-compat/bin && \ - rm -rf "$PKGDIR"/usr/glibc-compat/sbin && \ - rm -rf "$PKGDIR"/usr/glibc-compat/lib/gconv && \ - rm -rf "$PKGDIR"/usr/glibc-compat/lib/getconf && \ - rm -rf "$PKGDIR"/usr/glibc-compat/lib/audit && \ - rm -rf "$PKGDIR"/usr/glibc-compat/share && \ - rm -rf "$PKGDIR"/usr/glibc-compat/var - - FROM alpine:3.6 -RUN apk update && apk add --no-cache openssl ca-certificates -COPY --from=glibc /pkgdata/ / +ENV GLIBC 2.27-r0 +ENV DOCKERBINS_SHA 1270dce1bd7e1838d62ae21d2505d87f16efc1d9074645571daaefdfd0c14054 -RUN mkdir -p /lib /lib64 /usr/glibc-compat/lib/locale /etc && \ +RUN apk update && apk add --no-cache openssl ca-certificates curl && \ + curl -fsSL -o /etc/apk/keys/sgerrand.rsa.pub https://raw.githubusercontent.com/sgerrand/alpine-pkg-glibc/master/sgerrand.rsa.pub && \ + curl -fsSL -o glibc-$GLIBC.apk https://github.com/sgerrand/alpine-pkg-glibc/releases/download/$GLIBC/glibc-$GLIBC.apk && \ + apk add --no-cache glibc-$GLIBC.apk && \ ln -s /lib/libz.so.1 /usr/glibc-compat/lib/ && \ ln -s /lib/libc.musl-x86_64.so.1 /usr/glibc-compat/lib && \ - ln -s /usr/glibc-compat/lib/ld-linux-x86-64.so.2 /lib/ld-linux-x86-64.so.2 && \ - ln -s /usr/glibc-compat/lib/ld-linux-x86-64.so.2 /lib64/ld-linux-x86-64.so.2 && \ - ln -s /usr/glibc-compat/etc/ld.so.cache /etc/ld.so.cache - -RUN apk add --no-cache curl && \ - curl -fsSL -o dockerbins.tgz "https://download.docker.com/linux/static/stable/x86_64/docker-17.12.0-ce.tgz" && \ - SHA256=692e1c72937f6214b1038def84463018d8e320c8eaf8530546c84c2f8f9c767d; \ - echo "${SHA256} dockerbins.tgz" | sha256sum -c - && \ + curl -fsSL -o dockerbins.tgz "https://download.docker.com/linux/static/stable/x86_64/docker-17.12.1-ce.tgz" && \ + echo "${DOCKERBINS_SHA} dockerbins.tgz" | sha256sum -c - && \ tar xvf dockerbins.tgz docker/docker --strip-components 1 && \ mv docker /usr/local/bin/docker && \ chmod +x /usr/local/bin/docker && \ - rm dockerbins.tgz && \ + rm dockerbins.tgz /etc/apk/keys/sgerrand.rsa.pub glibc-$GLIBC.apk && \ apk del curl COPY dist/docker-compose-Linux-x86_64 /usr/local/bin/docker-compose From 7e3bbef4369d0bcb9ed1e64a13ddbbb2961874a4 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 8 Mar 2018 18:03:54 -0800 Subject: [PATCH 3318/4072] Preserve security_opt values in extends Signed-off-by: Joffrey F --- compose/config/config.py | 3 ++- compose/config/types.py | 6 ++++++ tests/unit/config/config_test.py | 23 +++++++++++++++++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/compose/config/config.py b/compose/config/config.py index 0b5c7df0a74..508ceafbfcd 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -1039,6 +1039,7 @@ def merge_service_dicts(base, override, version): md.merge_sequence('links', ServiceLink.parse) md.merge_sequence('secrets', types.ServiceSecret.parse) md.merge_sequence('configs', types.ServiceConfig.parse) + md.merge_sequence('security_opt', types.SecurityOpt.parse) md.merge_mapping('extra_hosts', parse_extra_hosts) for field in ['volumes', 'devices']: @@ -1046,7 +1047,7 @@ def merge_service_dicts(base, override, version): for field in [ 'cap_add', 'cap_drop', 'expose', 'external_links', - 'security_opt', 'volumes_from', 'device_cgroup_rules', + 'volumes_from', 'device_cgroup_rules', ]: md.merge_field(field, merge_unique_items_lists, default=[]) diff --git a/compose/config/types.py b/compose/config/types.py index 47e7222a3f2..ff9875218ef 100644 --- a/compose/config/types.py +++ b/compose/config/types.py @@ -464,6 +464,8 @@ def normalize_port_dict(port): class SecurityOpt(namedtuple('_SecurityOpt', 'value src_file')): @classmethod def parse(cls, value): + if not isinstance(value, six.string_types): + return value # based on https://github.com/docker/cli/blob/9de1b162f/cli/command/container/opts.go#L673-L697 con = value.split('=', 2) if len(con) == 1 and con[0] != 'no-new-privileges': @@ -486,3 +488,7 @@ def repr(self): if self.src_file is not None: return 'seccomp:{}'.format(self.src_file) return self.value + + @property + def merge_field(self): + return self.value diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index e032982214f..7a9bb944dc6 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -4508,6 +4508,29 @@ def test_extends_with_ports(self): for svc in services: assert svc['ports'] == [types.ServicePort('80', None, None, None, None)] + def test_extends_with_security_opt(self): + tmpdir = py.test.ensuretemp('test_extends_with_ports') + self.addCleanup(tmpdir.remove) + tmpdir.join('docker-compose.yml').write(""" + version: '2' + + services: + a: + image: nginx + security_opt: + - apparmor:unconfined + - seccomp:unconfined + + b: + extends: + service: a + """) + services = load_from_filename(str(tmpdir.join('docker-compose.yml'))) + assert len(services) == 2 + for svc in services: + assert types.SecurityOpt.parse('apparmor:unconfined') in svc['security_opt'] + assert types.SecurityOpt.parse('seccomp:unconfined') in svc['security_opt'] + @pytest.mark.xfail(IS_WINDOWS_PLATFORM, reason='paths use slash') class ExpandPathTest(unittest.TestCase): From d36f222c7e922b9c9acd3275fdca20cd1b694afe Mon Sep 17 00:00:00 2001 From: Matthieu Nottale Date: Thu, 1 Mar 2018 14:09:11 +0100 Subject: [PATCH 3319/4072] Fix a race condition in ParallelStreamWriter. Signed-off-by: Matthieu Nottale --- compose/parallel.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/compose/parallel.py b/compose/parallel.py index 341ca2f5e03..dd83c70cd76 100644 --- a/compose/parallel.py +++ b/compose/parallel.py @@ -4,6 +4,7 @@ import logging import operator import sys +from threading import Lock from threading import Semaphore from threading import Thread @@ -251,6 +252,7 @@ class ParallelStreamWriter(object): """ noansi = False + lock = Lock() @classmethod def set_noansi(cls, value=True): @@ -274,6 +276,7 @@ def write_initial(self, obj_index): self.stream.flush() def _write_ansi(self, obj_index, status): + self.lock.acquire() position = self.lines.index(obj_index) diff = len(self.lines) - position # move up @@ -285,6 +288,7 @@ def _write_ansi(self, obj_index, status): # move back down self.stream.write("%c[%dB" % (27, diff)) self.stream.flush() + self.lock.release() def _write_noansi(self, obj_index, status): self.stream.write("{} {:<{width}} ... {}\r\n".format(self.msg, obj_index, From 9e42b5006332b98a5a2ad361dc202ad1660878af Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Thu, 1 Mar 2018 15:32:41 +0100 Subject: [PATCH 3320/4072] Add support for options added in 1.20.0 to bash completion New options: - `docker-compose --log-level` - `docker-compose pull --include-deps` - `docker-compose run --use-aliases` Signed-off-by: Harald Albers --- contrib/completion/bash/docker-compose | 30 +++++++++++++++++--------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index 29853b0893d..90c9ce5fcfb 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -179,18 +179,22 @@ _docker_compose_docker_compose() { _filedir "y?(a)ml" return ;; + --log-level) + COMPREPLY=( $( compgen -W "debug info warning error critical" -- "$cur" ) ) + return + ;; --project-directory) _filedir -d return ;; - $(__docker_compose_to_extglob "$top_level_options_with_args") ) + $(__docker_compose_to_extglob "$daemon_options_with_args") ) return ;; esac case "$cur" in -*) - COMPREPLY=( $( compgen -W "$top_level_boolean_options $top_level_options_with_args --help -h --no-ansi --verbose --version -v" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "$daemon_boolean_options $daemon_options_with_args $top_level_options_with_args --help -h --no-ansi --verbose --version -v" -- "$cur" ) ) ;; *) COMPREPLY=( $( compgen -W "${commands[*]}" -- "$cur" ) ) @@ -375,7 +379,7 @@ _docker_compose_ps() { _docker_compose_pull() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "--help --ignore-pull-failures --parallel --quiet -q" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--help --ignore-pull-failures --include-deps --parallel --quiet -q" -- "$cur" ) ) ;; *) __docker_compose_services_from_image @@ -444,7 +448,7 @@ _docker_compose_run() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "-d --detach --entrypoint -e --help --label -l --name --no-deps --publish -p --rm --service-ports -T --user -u --volume -v --workdir -w" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--detach -d --entrypoint -e --help --label -l --name --no-deps --publish -p --rm --service-ports -T --use-aliases --user -u --volume -v --workdir -w" -- "$cur" ) ) ;; *) __docker_compose_services_all @@ -605,14 +609,12 @@ _docker_compose() { # Options for the docker daemon that have to be passed to secondary calls to # docker-compose executed by this script. - # Other global otions that are not relevant for secondary calls are defined in - # `_docker_compose_docker_compose`. - local top_level_boolean_options=" + local daemon_boolean_options=" --skip-hostname-check --tls --tlsverify " - local top_level_options_with_args=" + local daemon_options_with_args=" --file -f --host -H --project-directory @@ -622,6 +624,11 @@ _docker_compose() { --tlskey " + # These options are require special treatment when searching the command. + local top_level_options_with_args=" + --log-level + " + COMPREPLY=() local cur prev words cword _get_comp_words_by_ref -n : cur prev words cword @@ -634,15 +641,18 @@ _docker_compose() { while [ $counter -lt $cword ]; do case "${words[$counter]}" in - $(__docker_compose_to_extglob "$top_level_boolean_options") ) + $(__docker_compose_to_extglob "$daemon_boolean_options") ) local opt=${words[counter]} top_level_options+=($opt) ;; - $(__docker_compose_to_extglob "$top_level_options_with_args") ) + $(__docker_compose_to_extglob "$daemon_options_with_args") ) local opt=${words[counter]} local arg=${words[++counter]} top_level_options+=($opt $arg) ;; + $(__docker_compose_to_extglob "$top_level_options_with_args") ) + (( counter++ )) + ;; -*) ;; *) From 4d1dad24ae2fa88a6429b7a6277da7eeff408e13 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 5 Mar 2018 15:38:42 -0800 Subject: [PATCH 3321/4072] SDK version 3.1.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 da05e421296..33462d4961c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ backports.ssl-match-hostname==3.5.0.1; python_version < '3' cached-property==1.3.0 certifi==2017.4.17 chardet==3.0.4 -docker==3.1.0 +docker==3.1.1 docker-pycreds==0.2.1 dockerpty==0.4.1 docopt==0.6.2 diff --git a/setup.py b/setup.py index d1788df0ab3..cf8f6dc1318 100644 --- a/setup.py +++ b/setup.py @@ -36,7 +36,7 @@ def find_version(*file_paths): 'requests >= 2.6.1, != 2.11.0, != 2.12.2, != 2.18.0, < 2.19', 'texttable >= 0.9.0, < 0.10', 'websocket-client >= 0.32.0, < 1.0', - 'docker >= 3.1.0, < 4.0', + 'docker >= 3.1.1, < 4.0', 'dockerpty >= 0.4.1, < 0.5', 'six >= 1.3.0, < 2', 'jsonschema >= 2.5.1, < 3', From d2b5d59dd8a9e83642799557b73d92ecbd4045e0 Mon Sep 17 00:00:00 2001 From: Matthieu Nottale Date: Mon, 5 Mar 2018 14:28:46 +0100 Subject: [PATCH 3322/4072] Revamp ParallelStreamWriter to fix display issues. Signed-off-by: Matthieu Nottale --- compose/parallel.py | 106 ++++++++++++++++-------------- compose/service.py | 3 +- tests/integration/service_test.py | 3 + tests/unit/parallel_test.py | 3 + tests/unit/service_test.py | 2 + 5 files changed, 66 insertions(+), 51 deletions(-) diff --git a/compose/parallel.py b/compose/parallel.py index dd83c70cd76..5d4791f9756 100644 --- a/compose/parallel.py +++ b/compose/parallel.py @@ -43,55 +43,60 @@ def set_global_limit(cls, value): cls.global_limiter = Semaphore(value) -def parallel_execute(objects, func, get_name, msg, get_deps=None, limit=None, parent_objects=None): - """Runs func on objects in parallel while ensuring that func is - ran on object only after it is ran on all its dependencies. - - get_deps called on object must return a collection with its dependencies. - get_name called on object must return its name. +def parallel_execute_watch(events, writer, errors, results, msg, get_name): + """ Watch events from a parallel execution, update status and fill errors and results. + Returns exception to re-raise. """ - objects = list(objects) - stream = get_output_stream(sys.stderr) - - writer = ParallelStreamWriter(stream, msg) - - display_objects = list(parent_objects) if parent_objects else objects - - for obj in display_objects: - writer.add_object(get_name(obj)) - - # write data in a second loop to consider all objects for width alignment - # and avoid duplicates when parent_objects exists - for obj in objects: - writer.write_initial(get_name(obj)) - - events = parallel_execute_iter(objects, func, get_deps, limit) - - errors = {} - results = [] error_to_reraise = None - for obj, result, exception in events: if exception is None: - writer.write(get_name(obj), 'done', green) + writer.write(msg, get_name(obj), 'done', green) results.append(result) elif isinstance(exception, ImageNotFound): # This is to bubble up ImageNotFound exceptions to the client so we # can prompt the user if they want to rebuild. errors[get_name(obj)] = exception.explanation - writer.write(get_name(obj), 'error', red) + writer.write(msg, get_name(obj), 'error', red) error_to_reraise = exception elif isinstance(exception, APIError): errors[get_name(obj)] = exception.explanation - writer.write(get_name(obj), 'error', red) + writer.write(msg, get_name(obj), 'error', red) elif isinstance(exception, (OperationFailedError, HealthCheckFailed, NoHealthCheckConfigured)): errors[get_name(obj)] = exception.msg - writer.write(get_name(obj), 'error', red) + writer.write(msg, get_name(obj), 'error', red) elif isinstance(exception, UpstreamError): - writer.write(get_name(obj), 'error', red) + writer.write(msg, get_name(obj), 'error', red) else: errors[get_name(obj)] = exception error_to_reraise = exception + return error_to_reraise + + +def parallel_execute(objects, func, get_name, msg, get_deps=None, limit=None): + """Runs func on objects in parallel while ensuring that func is + ran on object only after it is ran on all its dependencies. + + get_deps called on object must return a collection with its dependencies. + get_name called on object must return its name. + """ + objects = list(objects) + stream = get_output_stream(sys.stderr) + + if ParallelStreamWriter.instance: + writer = ParallelStreamWriter.instance + else: + writer = ParallelStreamWriter(stream) + + for obj in objects: + writer.add_object(msg, get_name(obj)) + for obj in objects: + writer.write_initial(msg, get_name(obj)) + + events = parallel_execute_iter(objects, func, get_deps, limit) + + errors = {} + results = [] + error_to_reraise = parallel_execute_watch(events, writer, errors, results, msg, get_name) for obj_name, error in errors.items(): stream.write("\nERROR: for {} {}\n".format(obj_name, error)) @@ -253,55 +258,58 @@ class ParallelStreamWriter(object): noansi = False lock = Lock() + instance = None @classmethod def set_noansi(cls, value=True): cls.noansi = value - def __init__(self, stream, msg): + def __init__(self, stream): self.stream = stream - self.msg = msg self.lines = [] self.width = 0 + ParallelStreamWriter.instance = self - def add_object(self, obj_index): - self.lines.append(obj_index) - self.width = max(self.width, len(obj_index)) + def add_object(self, msg, obj_index): + if msg is None: + return + self.lines.append(msg + obj_index) + self.width = max(self.width, len(msg + ' ' + obj_index)) - def write_initial(self, obj_index): - if self.msg is None: + def write_initial(self, msg, obj_index): + if msg is None: return - self.stream.write("{} {:<{width}} ... \r\n".format( - self.msg, self.lines[self.lines.index(obj_index)], width=self.width)) + self.stream.write("{:<{width}} ... \r\n".format( + msg + ' ' + obj_index, width=self.width)) self.stream.flush() - def _write_ansi(self, obj_index, status): + def _write_ansi(self, msg, obj_index, status): self.lock.acquire() - position = self.lines.index(obj_index) + position = self.lines.index(msg + obj_index) diff = len(self.lines) - position # move up self.stream.write("%c[%dA" % (27, diff)) # erase self.stream.write("%c[2K\r" % 27) - self.stream.write("{} {:<{width}} ... {}\r".format(self.msg, obj_index, + self.stream.write("{:<{width}} ... {}\r".format(msg + ' ' + obj_index, status, width=self.width)) # move back down self.stream.write("%c[%dB" % (27, diff)) self.stream.flush() self.lock.release() - def _write_noansi(self, obj_index, status): - self.stream.write("{} {:<{width}} ... {}\r\n".format(self.msg, obj_index, + def _write_noansi(self, msg, obj_index, status): + self.stream.write("{:<{width}} ... {}\r\n".format(msg + ' ' + obj_index, status, width=self.width)) self.stream.flush() - def write(self, obj_index, status, color_func): - if self.msg is None: + def write(self, msg, obj_index, status, color_func): + if msg is None: return if self.noansi: - self._write_noansi(obj_index, status) + self._write_noansi(msg, obj_index, status) else: - self._write_ansi(obj_index, color_func(status)) + self._write_ansi(msg, obj_index, color_func(status)) def parallel_operation(containers, operation, options, message): diff --git a/compose/service.py b/compose/service.py index b9f9af2cda7..65208047837 100644 --- a/compose/service.py +++ b/compose/service.py @@ -402,8 +402,7 @@ def create_and_start(service, n): [ServiceName(self.project, self.name, index) for index in range(i, i + scale)], lambda service_name: create_and_start(self, service_name.number), lambda service_name: self.get_container_name(service_name.service, service_name.number), - "Creating", - parent_objects=project_services + "Creating" ) for error in errors.values(): raise OperationFailedError(error) diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 2b6b7711edc..6e86a02d476 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -35,6 +35,7 @@ from compose.const import LABEL_VERSION from compose.container import Container from compose.errors import OperationFailedError +from compose.parallel import ParallelStreamWriter from compose.project import OneOffFilter from compose.service import ConvergencePlan from compose.service import ConvergenceStrategy @@ -1197,6 +1198,7 @@ def test_scale_with_stopped_containers(self): service.create_container(number=next_number) service.create_container(number=next_number + 1) + ParallelStreamWriter.instance = None with mock.patch('sys.stderr', new_callable=StringIO) as mock_stderr: service.scale(2) for container in service.containers(): @@ -1220,6 +1222,7 @@ def test_scale_with_stopped_containers_and_needing_creation(self): for container in service.containers(): assert not container.is_running + ParallelStreamWriter.instance = None with mock.patch('sys.stderr', new_callable=StringIO) as mock_stderr: service.scale(2) diff --git a/tests/unit/parallel_test.py b/tests/unit/parallel_test.py index 4ebc24d8cb6..0735bfccb48 100644 --- a/tests/unit/parallel_test.py +++ b/tests/unit/parallel_test.py @@ -143,6 +143,7 @@ def process(x): def test_parallel_execute_alignment(capsys): + ParallelStreamWriter.instance = None results, errors = parallel_execute( objects=["short", "a very long name"], func=lambda x: x, @@ -158,6 +159,7 @@ def test_parallel_execute_alignment(capsys): def test_parallel_execute_ansi(capsys): + ParallelStreamWriter.instance = None ParallelStreamWriter.set_noansi(value=False) results, errors = parallel_execute( objects=["something", "something more"], @@ -173,6 +175,7 @@ def test_parallel_execute_ansi(capsys): def test_parallel_execute_noansi(capsys): + ParallelStreamWriter.instance = None ParallelStreamWriter.set_noansi() results, errors = parallel_execute( objects=["something", "something more"], diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index c315dcc4db8..9128b95503c 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -20,6 +20,7 @@ from compose.const import LABEL_SERVICE from compose.const import SECRETS_PATH from compose.container import Container +from compose.parallel import ParallelStreamWriter from compose.project import OneOffFilter from compose.service import build_ulimits from compose.service import build_volume_binding @@ -727,6 +728,7 @@ def test_image_name_default(self): @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'} + ParallelStreamWriter.instance = None name = 'foo' service = Service( name, From ca012640c1c476cc1d4bfd44176cc0f24f6e87aa Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 8 Mar 2018 18:03:54 -0800 Subject: [PATCH 3323/4072] Preserve security_opt values in extends Signed-off-by: Joffrey F --- compose/config/config.py | 3 ++- compose/config/types.py | 6 ++++++ tests/unit/config/config_test.py | 23 +++++++++++++++++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/compose/config/config.py b/compose/config/config.py index 0b5c7df0a74..508ceafbfcd 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -1039,6 +1039,7 @@ def merge_service_dicts(base, override, version): md.merge_sequence('links', ServiceLink.parse) md.merge_sequence('secrets', types.ServiceSecret.parse) md.merge_sequence('configs', types.ServiceConfig.parse) + md.merge_sequence('security_opt', types.SecurityOpt.parse) md.merge_mapping('extra_hosts', parse_extra_hosts) for field in ['volumes', 'devices']: @@ -1046,7 +1047,7 @@ def merge_service_dicts(base, override, version): for field in [ 'cap_add', 'cap_drop', 'expose', 'external_links', - 'security_opt', 'volumes_from', 'device_cgroup_rules', + 'volumes_from', 'device_cgroup_rules', ]: md.merge_field(field, merge_unique_items_lists, default=[]) diff --git a/compose/config/types.py b/compose/config/types.py index 47e7222a3f2..ff9875218ef 100644 --- a/compose/config/types.py +++ b/compose/config/types.py @@ -464,6 +464,8 @@ def normalize_port_dict(port): class SecurityOpt(namedtuple('_SecurityOpt', 'value src_file')): @classmethod def parse(cls, value): + if not isinstance(value, six.string_types): + return value # based on https://github.com/docker/cli/blob/9de1b162f/cli/command/container/opts.go#L673-L697 con = value.split('=', 2) if len(con) == 1 and con[0] != 'no-new-privileges': @@ -486,3 +488,7 @@ def repr(self): if self.src_file is not None: return 'seccomp:{}'.format(self.src_file) return self.value + + @property + def merge_field(self): + return self.value diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index e032982214f..7a9bb944dc6 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -4508,6 +4508,29 @@ def test_extends_with_ports(self): for svc in services: assert svc['ports'] == [types.ServicePort('80', None, None, None, None)] + def test_extends_with_security_opt(self): + tmpdir = py.test.ensuretemp('test_extends_with_ports') + self.addCleanup(tmpdir.remove) + tmpdir.join('docker-compose.yml').write(""" + version: '2' + + services: + a: + image: nginx + security_opt: + - apparmor:unconfined + - seccomp:unconfined + + b: + extends: + service: a + """) + services = load_from_filename(str(tmpdir.join('docker-compose.yml'))) + assert len(services) == 2 + for svc in services: + assert types.SecurityOpt.parse('apparmor:unconfined') in svc['security_opt'] + assert types.SecurityOpt.parse('seccomp:unconfined') in svc['security_opt'] + @pytest.mark.xfail(IS_WINDOWS_PLATFORM, reason='paths use slash') class ExpandPathTest(unittest.TestCase): From 742979371d55aa4f2ada056c0447f3e0c1485090 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 2 Mar 2018 15:45:37 -0800 Subject: [PATCH 3324/4072] Install both versions of python Signed-off-by: Joffrey F --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 7661c647061..6ee2a60e50d 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -7,7 +7,7 @@ jobs: - checkout - run: name: install python3 - command: brew update > /dev/null && brew install python3 + command: brew update > /dev/null && brew upgrade python - run: name: install tox command: sudo pip install --upgrade tox==2.1.1 From 768e28ee303d2ef6a6102fd1394b527fcdf10f2a Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 8 Mar 2018 15:44:42 -0800 Subject: [PATCH 3325/4072] Update Dockerfile.run to produce smaller image Signed-off-by: Joffrey F --- Dockerfile.run | 40 +++++++++------------------------------- 1 file changed, 9 insertions(+), 31 deletions(-) diff --git a/Dockerfile.run b/Dockerfile.run index b3f9a01f6c9..04acf01c304 100644 --- a/Dockerfile.run +++ b/Dockerfile.run @@ -1,42 +1,20 @@ -FROM sgerrand/glibc-builder as glibc -RUN apt-get install -yq bison - -ENV PKGDIR /pkgdata - -RUN mkdir -p /usr/glibc-compat/etc && touch /usr/glibc-compat/etc/ld.so.conf -RUN /builder 2.27 /usr/glibc-compat || true -RUN mkdir -p $PKGDIR -RUN tar -xf /glibc-bin-2.27.tar.gz -C $PKGDIR -RUN rm "$PKGDIR"/usr/glibc-compat/etc/rpc && \ - rm -rf "$PKGDIR"/usr/glibc-compat/bin && \ - rm -rf "$PKGDIR"/usr/glibc-compat/sbin && \ - rm -rf "$PKGDIR"/usr/glibc-compat/lib/gconv && \ - rm -rf "$PKGDIR"/usr/glibc-compat/lib/getconf && \ - rm -rf "$PKGDIR"/usr/glibc-compat/lib/audit && \ - rm -rf "$PKGDIR"/usr/glibc-compat/share && \ - rm -rf "$PKGDIR"/usr/glibc-compat/var - - FROM alpine:3.6 -RUN apk update && apk add --no-cache openssl ca-certificates -COPY --from=glibc /pkgdata/ / +ENV GLIBC 2.27-r0 +ENV DOCKERBINS_SHA 1270dce1bd7e1838d62ae21d2505d87f16efc1d9074645571daaefdfd0c14054 -RUN mkdir -p /lib /lib64 /usr/glibc-compat/lib/locale /etc && \ +RUN apk update && apk add --no-cache openssl ca-certificates curl && \ + curl -fsSL -o /etc/apk/keys/sgerrand.rsa.pub https://raw.githubusercontent.com/sgerrand/alpine-pkg-glibc/master/sgerrand.rsa.pub && \ + curl -fsSL -o glibc-$GLIBC.apk https://github.com/sgerrand/alpine-pkg-glibc/releases/download/$GLIBC/glibc-$GLIBC.apk && \ + apk add --no-cache glibc-$GLIBC.apk && \ ln -s /lib/libz.so.1 /usr/glibc-compat/lib/ && \ ln -s /lib/libc.musl-x86_64.so.1 /usr/glibc-compat/lib && \ - ln -s /usr/glibc-compat/lib/ld-linux-x86-64.so.2 /lib/ld-linux-x86-64.so.2 && \ - ln -s /usr/glibc-compat/lib/ld-linux-x86-64.so.2 /lib64/ld-linux-x86-64.so.2 && \ - ln -s /usr/glibc-compat/etc/ld.so.cache /etc/ld.so.cache - -RUN apk add --no-cache curl && \ - curl -fsSL -o dockerbins.tgz "https://download.docker.com/linux/static/stable/x86_64/docker-17.12.0-ce.tgz" && \ - SHA256=692e1c72937f6214b1038def84463018d8e320c8eaf8530546c84c2f8f9c767d; \ - echo "${SHA256} dockerbins.tgz" | sha256sum -c - && \ + curl -fsSL -o dockerbins.tgz "https://download.docker.com/linux/static/stable/x86_64/docker-17.12.1-ce.tgz" && \ + echo "${DOCKERBINS_SHA} dockerbins.tgz" | sha256sum -c - && \ tar xvf dockerbins.tgz docker/docker --strip-components 1 && \ mv docker /usr/local/bin/docker && \ chmod +x /usr/local/bin/docker && \ - rm dockerbins.tgz && \ + rm dockerbins.tgz /etc/apk/keys/sgerrand.rsa.pub glibc-$GLIBC.apk && \ apk del curl COPY dist/docker-compose-Linux-x86_64 /usr/local/bin/docker-compose From 8c4af54257daae922010f75dac3f259fb0f3905d Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 8 Mar 2018 18:29:48 -0800 Subject: [PATCH 3326/4072] Bump 1.20.0-rc2 Signed-off-by: Joffrey F --- CHANGELOG.md | 10 +++++++++- compose/__init__.py | 2 +- script/run/run.sh | 2 +- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e662d40a697..c113572ca13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,10 +38,12 @@ Change log - Proxy configuration found in the `~/.docker/config.json` file now populates environment and build args for containers created by Compose -- Added a `--use-aliases` flag to `docker-compose run`, indicating that +- Added the `--use-aliases` flag to `docker-compose run`, indicating that network aliases declared in the service's config should be used for the running container +- Added the `--include-deps` flag to `docker-compose pull` + - `docker-compose run` now kills and removes the running container upon receiving `SIGHUP` @@ -55,6 +57,9 @@ Change log - Fixed `.dockerignore` handling, notably with regard to absolute paths and last-line precedence rules +- Fixed an issue where Compose would make costly DNS lookups when connecting + to the Engine when using Docker For Mac + - Fixed a bug introduced in 1.19.0 which caused the default certificate path to not be honored by Compose @@ -70,6 +75,9 @@ Change log - A `seccomp:` entry in the `security_opt` config now correctly sends the contents of the file to the engine +- ANSI output for `up` and `down` operations should no longer affect the wrong + lines + - Improved support for non-unicode locales - Fixed a crash occurring on Windows when the user's home directory name diff --git a/compose/__init__.py b/compose/__init__.py index 2090f10c804..d2e3f696bf2 100644 --- a/compose/__init__.py +++ b/compose/__init__.py @@ -1,4 +1,4 @@ from __future__ import absolute_import from __future__ import unicode_literals -__version__ = '1.20.0-rc1' +__version__ = '1.20.0-rc2' diff --git a/script/run/run.sh b/script/run/run.sh index 2adcc98f870..dff9d982acc 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.20.0-rc1" +VERSION="1.20.0-rc2" IMAGE="docker/compose:$VERSION" From 0112c740ad6764df47d46b7bd3d1f93bafab32d2 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 12 Mar 2018 16:46:59 -0700 Subject: [PATCH 3327/4072] Check volume config against remote and error out if diverged Signed-off-by: Joffrey F --- .circleci/config.yml | 4 +-- compose/volume.py | 54 +++++++++++++++++++++++-------- tests/integration/project_test.py | 47 +++++++++++++++++++++++++++ 3 files changed, 90 insertions(+), 15 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 6ee2a60e50d..d422fdcc56f 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -6,8 +6,8 @@ jobs: steps: - checkout - run: - name: install python3 - command: brew update > /dev/null && brew upgrade python + name: setup script + command: ./script/setup/osx - run: name: install tox command: sudo pip install --upgrade tox==2.1.1 diff --git a/compose/volume.py b/compose/volume.py index 0b148620fbe..6bf184045c4 100644 --- a/compose/volume.py +++ b/compose/volume.py @@ -124,19 +124,7 @@ def initialize(self): ) volume.create() else: - driver = volume.inspect()['Driver'] - if volume.driver is not None and driver != volume.driver: - raise ConfigurationError( - 'Configuration for volume {0} specifies driver ' - '{1}, but a volume with the same name uses a ' - 'different driver ({3}). If you wish to use the ' - 'new configuration, please remove the existing ' - 'volume "{2}" first:\n' - '$ docker volume rm {2}'.format( - volume.name, volume.driver, volume.full_name, - volume.inspect()['Driver'] - ) - ) + check_remote_volume_config(volume.inspect(), volume) except NotFound: raise ConfigurationError( 'Volume %s specifies nonexistent driver %s' % (volume.name, volume.driver) @@ -152,3 +140,43 @@ def namespace_spec(self, volume_spec): else: volume_spec.source = self.volumes[volume_spec.source].full_name return volume_spec + + +class VolumeConfigChangedError(ConfigurationError): + def __init__(self, local, property_name, local_value, remote_value): + super(VolumeConfigChangedError, self).__init__( + 'Configuration for volume {vol_name} specifies {property_name} ' + '{local_value}, but a volume with the same name uses a different ' + '{property_name} ({remote_value}). If you wish to use the new ' + 'configuration, please remove the existing volume "{full_name}" ' + 'first:\n$ docker volume rm {full_name}'.format( + vol_name=local.name, property_name=property_name, + local_value=local_value, remote_value=remote_value, + full_name=local.full_name + ) + ) + + +def check_remote_volume_config(remote, local): + if local.driver and remote.get('Driver') != local.driver: + raise VolumeConfigChangedError(local, 'driver', local.driver, remote.get('Driver')) + local_opts = local.driver_opts or {} + remote_opts = remote.get('Options') or {} + for k in set.union(set(remote_opts.keys()), set(local_opts.keys())): + if k.startswith('com.docker.'): # These options are set internally + continue + if remote_opts.get(k) != local_opts.get(k): + raise VolumeConfigChangedError( + local, '"{}" driver_opt'.format(k), local_opts.get(k), remote_opts.get(k), + ) + + local_labels = local.labels or {} + remote_labels = remote.get('Labels') or {} + for k in set.union(set(remote_labels.keys()), set(local_labels.keys())): + if k.startswith('com.docker.'): # We are only interested in user-specified labels + continue + if remote_labels.get(k) != local_labels.get(k): + log.warn( + 'Volume {}: label "{}" has changed. It may need to be' + ' recreated.'.format(local.name, k) + ) diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 0acb8028458..3960d12e589 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -4,6 +4,7 @@ import json import os import random +import shutil import tempfile import py @@ -1537,6 +1538,52 @@ def test_initialize_volumes_updated_driver(self): vol_name ) in str(e.value) + @v2_only() + @no_cluster('inspect volume by name defect on Swarm Classic') + def test_initialize_volumes_updated_driver_opts(self): + vol_name = '{0:x}'.format(random.getrandbits(32)) + full_vol_name = 'composetest_{0}'.format(vol_name) + tmpdir = tempfile.mkdtemp(prefix='compose_test_') + self.addCleanup(shutil.rmtree, tmpdir) + driver_opts = {'o': 'bind', 'device': tmpdir, 'type': 'none'} + + config_data = build_config( + version=V2_0, + services=[{ + 'name': 'web', + 'image': 'busybox:latest', + 'command': 'top' + }], + volumes={ + vol_name: { + 'driver': 'local', + 'driver_opts': driver_opts + } + }, + ) + project = Project.from_config( + name='composetest', + config_data=config_data, client=self.client + ) + project.volumes.initialize() + + volume_data = self.get_volume_data(full_vol_name) + assert volume_data['Name'].split('/')[-1] == full_vol_name + assert volume_data['Driver'] == 'local' + assert volume_data['Options'] == driver_opts + + driver_opts['device'] = '/opt/data/localdata' + project = Project.from_config( + name='composetest', + config_data=config_data, + client=self.client + ) + with pytest.raises(config.ConfigurationError) as e: + project.volumes.initialize() + assert 'Configuration for volume {0} specifies "device" driver_opt {1}'.format( + vol_name, driver_opts['device'] + ) in str(e.value) + @v2_only() def test_initialize_volumes_updated_blank_driver(self): vol_name = '{0:x}'.format(random.getrandbits(32)) From a43ec0aa2e762853756b9a1d9e78f279f1f508ac Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Tue, 13 Mar 2018 14:41:20 -0400 Subject: [PATCH 3328/4072] Remove myself as maintainer Signed-off-by: Daniel Nephin --- MAINTAINERS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MAINTAINERS b/MAINTAINERS index ff840887413..d552aa3ae26 100644 --- a/MAINTAINERS +++ b/MAINTAINERS @@ -11,7 +11,6 @@ [Org] [Org."Core maintainers"] people = [ - "dnephin", "mefyl", "mnottale", "shin-", @@ -30,6 +29,7 @@ # in 2015 with solid bugfixes and improved error handling # among them "mnowster", + "dnephin", ] [people] From e7f0ab04a1eb75d7e0573e5aeb5d55f55cea9477 Mon Sep 17 00:00:00 2001 From: Wes Higbee Date: Wed, 14 Mar 2018 21:08:55 -0400 Subject: [PATCH 3329/4072] Fix docker-compose zsh running service name completion This applies to commands that operate on running services. For example: top, stop, restart, etc. Configuring a custom psFormat in ~/.docker/config.json can break zsh running service name completion that depends upon default `docker ps` output. This breaks when you don't include the output needed by completion. The fix specifies the explicit format needed for completion and is based on a previous fix in the docker CLI completion: https://github.com/docker/cli/commit/8b38343e46e46b589bf556651c10dc3dbee3f88b Signed-off-by: Wes Higbee --- 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 c0a54cced1b..aba367706ad 100644 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -88,7 +88,7 @@ __docker-compose_get_services() { shift [[ $kind =~ (stopped|all) ]] && args=($args -a) - lines=(${(f)"$(_call_program commands docker $docker_options ps $args)"}) + lines=(${(f)"$(_call_program commands docker $docker_options ps --format 'table' $args)"}) services=(${(f)"$(_call_program commands docker-compose 2>/dev/null $compose_options ps -q)"}) # Parse header line to find columns From 5fe3aff1c310d6b8684404b19a8246736cfe8f1a Mon Sep 17 00:00:00 2001 From: Matthieu Nottale Date: Thu, 15 Mar 2018 15:41:36 +0100 Subject: [PATCH 3330/4072] Allow dash and underscore in project name. Signed-off-by: Matthieu Nottale --- compose/cli/command.py | 2 +- tests/acceptance/cli_test.py | 66 ++++++++++++++++++------------------ tests/unit/cli_test.py | 10 +++--- 3 files changed, 39 insertions(+), 39 deletions(-) diff --git a/compose/cli/command.py b/compose/cli/command.py index 9fd941bb6a5..022bc85769e 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -127,7 +127,7 @@ def get_project(project_dir, config_path=None, project_name=None, verbose=False, def get_project_name(working_dir, project_name=None, environment=None): def normalize_name(name): - return re.sub(r'[^a-z0-9]', '', name.lower()) + return re.sub(r'[^-_a-z0-9]', '', name.lower()) if not environment: environment = Environment.from_env_file(working_dir) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 45e9a54e574..b8d6c997840 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -491,16 +491,16 @@ def test_config_compatibility_mode(self): def test_ps(self): self.project.get_service('simple').create_container() result = self.dispatch(['ps']) - assert 'simplecomposefile_simple_1' in result.stdout + assert 'simple-composefile_simple_1' in result.stdout def test_ps_default_composefile(self): self.base_dir = 'tests/fixtures/multiple-composefiles' self.dispatch(['up', '-d']) result = self.dispatch(['ps']) - assert 'multiplecomposefiles_simple_1' in result.stdout - assert 'multiplecomposefiles_another_1' in result.stdout - assert 'multiplecomposefiles_yetanother_1' not in result.stdout + assert 'multiple-composefiles_simple_1' in result.stdout + assert 'multiple-composefiles_another_1' in result.stdout + assert 'multiple-composefiles_yetanother_1' not in result.stdout def test_ps_alternate_composefile(self): config_path = os.path.abspath( @@ -511,9 +511,9 @@ def test_ps_alternate_composefile(self): self.dispatch(['-f', 'compose2.yml', 'up', '-d']) result = self.dispatch(['-f', 'compose2.yml', 'ps']) - assert 'multiplecomposefiles_simple_1' not in result.stdout - assert 'multiplecomposefiles_another_1' not in result.stdout - assert 'multiplecomposefiles_yetanother_1' in result.stdout + assert 'multiple-composefiles_simple_1' not in result.stdout + assert 'multiple-composefiles_another_1' not in result.stdout + assert 'multiple-composefiles_yetanother_1' in result.stdout def test_ps_services_filter_option(self): self.base_dir = 'tests/fixtures/ps-services-filter' @@ -902,18 +902,18 @@ def test_down(self): assert len(self.project.containers(one_off=OneOffFilter.only, stopped=True)) == 2 result = self.dispatch(['down', '--rmi=local', '--volumes']) - assert 'Stopping v2full_web_1' in result.stderr - assert 'Stopping v2full_other_1' in result.stderr - assert 'Stopping v2full_web_run_2' in result.stderr - assert 'Removing v2full_web_1' in result.stderr - assert 'Removing v2full_other_1' in result.stderr - assert 'Removing v2full_web_run_1' in result.stderr - assert 'Removing v2full_web_run_2' in result.stderr - assert 'Removing volume v2full_data' in result.stderr - assert 'Removing image v2full_web' in result.stderr + assert 'Stopping v2-full_web_1' in result.stderr + assert 'Stopping v2-full_other_1' in result.stderr + assert 'Stopping v2-full_web_run_2' in result.stderr + assert 'Removing v2-full_web_1' in result.stderr + assert 'Removing v2-full_other_1' in result.stderr + assert 'Removing v2-full_web_run_1' in result.stderr + assert 'Removing v2-full_web_run_2' in result.stderr + assert 'Removing volume v2-full_data' in result.stderr + assert 'Removing image v2-full_web' in result.stderr assert 'Removing image busybox' not in result.stderr - assert 'Removing network v2full_default' in result.stderr - assert 'Removing network v2full_front' in result.stderr + assert 'Removing network v2-full_default' in result.stderr + assert 'Removing network v2-full_front' in result.stderr def test_down_timeout(self): self.dispatch(['up', '-d'], None) @@ -2000,39 +2000,39 @@ def test_run_handles_sigint(self): proc = start_process(self.base_dir, ['run', '-T', 'simple', 'top']) wait_on_condition(ContainerStateCondition( self.project.client, - 'simplecomposefile_simple_run_1', + 'simple-composefile_simple_run_1', 'running')) os.kill(proc.pid, signal.SIGINT) wait_on_condition(ContainerStateCondition( self.project.client, - 'simplecomposefile_simple_run_1', + 'simple-composefile_simple_run_1', 'exited')) def test_run_handles_sigterm(self): proc = start_process(self.base_dir, ['run', '-T', 'simple', 'top']) wait_on_condition(ContainerStateCondition( self.project.client, - 'simplecomposefile_simple_run_1', + 'simple-composefile_simple_run_1', 'running')) os.kill(proc.pid, signal.SIGTERM) wait_on_condition(ContainerStateCondition( self.project.client, - 'simplecomposefile_simple_run_1', + 'simple-composefile_simple_run_1', 'exited')) def test_run_handles_sighup(self): proc = start_process(self.base_dir, ['run', '-T', 'simple', 'top']) wait_on_condition(ContainerStateCondition( self.project.client, - 'simplecomposefile_simple_run_1', + 'simple-composefile_simple_run_1', 'running')) os.kill(proc.pid, signal.SIGHUP) wait_on_condition(ContainerStateCondition( self.project.client, - 'simplecomposefile_simple_run_1', + 'simple-composefile_simple_run_1', 'exited')) @mock.patch.dict(os.environ) @@ -2234,7 +2234,7 @@ def test_logs_follow_logs_from_new_containers(self): self.dispatch(['up', '-d', 'another']) wait_on_condition(ContainerStateCondition( self.project.client, - 'logscomposefile_another_1', + 'logs-composefile_another_1', 'exited')) self.dispatch(['kill', 'simple']) @@ -2243,8 +2243,8 @@ def test_logs_follow_logs_from_new_containers(self): assert 'hello' in result.stdout assert 'test' in result.stdout - assert 'logscomposefile_another_1 exited with code 0' in result.stdout - assert 'logscomposefile_simple_1 exited with code 137' in result.stdout + assert 'logs-composefile_another_1 exited with code 0' in result.stdout + assert 'logs-composefile_simple_1 exited with code 137' in result.stdout def test_logs_default(self): self.base_dir = 'tests/fixtures/logs-composefile' @@ -2491,7 +2491,7 @@ def has_timestamp(string): container, = self.project.containers() expected_template = ' container {} {}' - expected_meta_info = ['image=busybox:latest', 'name=simplecomposefile_simple_1'] + expected_meta_info = ['image=busybox:latest', 'name=simple-composefile_simple_1'] assert expected_template.format('create', container.id) in lines[0] assert expected_template.format('start', container.id) in lines[1] @@ -2611,13 +2611,13 @@ def test_forward_exitval(self): result = wait_on_process(proc, returncode=1) - assert 'exitcodefrom_another_1 exited with code 1' in result.stdout + assert 'exit-code-from_another_1 exited with code 1' in result.stdout def test_images(self): self.project.get_service('simple').create_container() result = self.dispatch(['images']) assert 'busybox' in result.stdout - assert 'simplecomposefile_simple_1' in result.stdout + assert 'simple-composefile_simple_1' in result.stdout def test_images_default_composefile(self): self.base_dir = 'tests/fixtures/multiple-composefiles' @@ -2625,8 +2625,8 @@ def test_images_default_composefile(self): result = self.dispatch(['images']) assert 'busybox' in result.stdout - assert 'multiplecomposefiles_another_1' in result.stdout - assert 'multiplecomposefiles_simple_1' in result.stdout + assert 'multiple-composefiles_another_1' in result.stdout + assert 'multiple-composefiles_simple_1' in result.stdout @mock.patch.dict(os.environ) def test_images_tagless_image(self): @@ -2646,7 +2646,7 @@ def test_images_tagless_image(self): self.project.get_service('foo').create_container() result = self.dispatch(['images']) assert '' in result.stdout - assert 'taglessimage_foo_1' in result.stdout + assert 'tagless-image_foo_1' in result.stdout def test_up_with_override_yaml(self): self.base_dir = 'tests/fixtures/override-yaml-files' diff --git a/tests/unit/cli_test.py b/tests/unit/cli_test.py index 47eaabf9d8c..7c8a1423c7c 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -30,12 +30,12 @@ def test_default_project_name(self): test_dir = py._path.local.LocalPath('tests/fixtures/simple-composefile') with test_dir.as_cwd(): project_name = get_project_name('.') - assert 'simplecomposefile' == project_name + assert 'simple-composefile' == project_name def test_project_name_with_explicit_base_dir(self): base_dir = 'tests/fixtures/simple-composefile' project_name = get_project_name(base_dir) - assert 'simplecomposefile' == project_name + assert 'simple-composefile' == project_name def test_project_name_with_explicit_uppercase_base_dir(self): base_dir = 'tests/fixtures/UpperCaseDir' @@ -45,7 +45,7 @@ def test_project_name_with_explicit_uppercase_base_dir(self): def test_project_name_with_explicit_project_name(self): name = 'explicit-project-name' project_name = get_project_name(None, project_name=name) - assert 'explicitprojectname' == project_name + assert 'explicit-project-name' == project_name @mock.patch.dict(os.environ) def test_project_name_from_environment_new_var(self): @@ -59,7 +59,7 @@ def test_project_name_with_empty_environment_var(self): with mock.patch.dict(os.environ): os.environ['COMPOSE_PROJECT_NAME'] = '' project_name = get_project_name(base_dir) - assert 'simplecomposefile' == project_name + assert 'simple-composefile' == project_name @mock.patch.dict(os.environ) def test_project_name_with_environment_file(self): @@ -80,7 +80,7 @@ def test_project_name_with_environment_file(self): def test_get_project(self): base_dir = 'tests/fixtures/longer-filename-composefile' project = get_project(base_dir) - assert project.name == 'longerfilenamecomposefile' + assert project.name == 'longer-filename-composefile' assert project.client assert project.services From c7b76b1d128a3c4d4bb5deb50633062698b3df3e Mon Sep 17 00:00:00 2001 From: Matthieu Nottale Date: Thu, 15 Mar 2018 15:40:13 +0100 Subject: [PATCH 3331/4072] pull: Honor --quiet in parallel mode. Signed-off-by: Matthieu Nottale --- compose/project.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/project.py b/compose/project.py index 2cbc4aeea36..48095e9387f 100644 --- a/compose/project.py +++ b/compose/project.py @@ -551,7 +551,7 @@ def pull_service(service): services, pull_service, operator.attrgetter('name'), - 'Pulling', + not silent and 'Pulling' or None, limit=5, ) if len(errors): From 83364078300340cabaad95d5d0e37954b52e3c30 Mon Sep 17 00:00:00 2001 From: Matthieu Nottale Date: Thu, 15 Mar 2018 14:10:20 +0100 Subject: [PATCH 3332/4072] pull: Deprecate '--parallel' and enable it by default. Signed-off-by: Matthieu Nottale --- compose/cli/main.py | 7 +++++-- tests/acceptance/cli_test.py | 16 +++++++--------- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 59f111184ac..9e49e297430 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -711,14 +711,17 @@ def pull(self, options): Options: --ignore-pull-failures Pull what it can and ignores images with pull failures. - --parallel Pull multiple images in parallel. + --parallel Deprecated, pull multiple images in parallel (enabled by default). + --no-parallel Disable parallel pulling. -q, --quiet Pull without printing progress information --include-deps Also pull services declared as dependencies """ + if options.get('--parallel'): + log.warn('--parallel option is deprecated and will be removed in future versions.') self.project.pull( service_names=options['SERVICE'], ignore_pull_failures=options.get('--ignore-pull-failures'), - parallel_pull=options.get('--parallel'), + parallel_pull=not options.get('--no-parallel'), silent=options.get('--quiet'), include_deps=options.get('--include-deps'), ) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 45e9a54e574..0366df51e29 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -545,13 +545,11 @@ def test_ps_services_filter_status(self): def test_pull(self): result = self.dispatch(['pull']) - assert sorted(result.stderr.split('\n'))[1:] == [ - 'Pulling another (busybox:latest)...', - 'Pulling simple (busybox:latest)...', - ] + assert 'Pulling simple' in result.stderr + assert 'Pulling another' in result.stderr def test_pull_with_digest(self): - result = self.dispatch(['-f', 'digest.yml', 'pull']) + result = self.dispatch(['-f', 'digest.yml', 'pull', '--no-parallel']) assert 'Pulling simple (busybox:latest)...' in result.stderr assert ('Pulling digest (busybox@' @@ -561,7 +559,7 @@ def test_pull_with_digest(self): def test_pull_with_ignore_pull_failures(self): result = self.dispatch([ '-f', 'ignore-pull-failures.yml', - 'pull', '--ignore-pull-failures'] + 'pull', '--ignore-pull-failures', '--no-parallel'] ) assert 'Pulling simple (busybox:latest)...' in result.stderr @@ -576,7 +574,7 @@ def test_pull_with_quiet(self): def test_pull_with_parallel_failure(self): result = self.dispatch([ - '-f', 'ignore-pull-failures.yml', 'pull', '--parallel'], + '-f', 'ignore-pull-failures.yml', 'pull'], returncode=1 ) @@ -593,14 +591,14 @@ def test_pull_with_parallel_failure(self): def test_pull_with_no_deps(self): self.base_dir = 'tests/fixtures/links-composefile' - result = self.dispatch(['pull', 'web']) + result = self.dispatch(['pull', '--no-parallel', 'web']) assert sorted(result.stderr.split('\n'))[1:] == [ 'Pulling web (busybox:latest)...', ] def test_pull_with_include_deps(self): self.base_dir = 'tests/fixtures/links-composefile' - result = self.dispatch(['pull', '--include-deps', 'web']) + result = self.dispatch(['pull', '--no-parallel', '--include-deps', 'web']) assert sorted(result.stderr.split('\n'))[1:] == [ 'Pulling db (busybox:latest)...', 'Pulling web (busybox:latest)...', From 06593110dc04d5f14ff4c56b8fbb1921b0aaf8cf Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 15 Mar 2018 12:20:00 -0700 Subject: [PATCH 3333/4072] Fix indentation + HOF comment Signed-off-by: Joffrey F --- MAINTAINERS | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/MAINTAINERS b/MAINTAINERS index d552aa3ae26..7aedd46e971 100644 --- a/MAINTAINERS +++ b/MAINTAINERS @@ -29,7 +29,11 @@ # in 2015 with solid bugfixes and improved error handling # among them "mnowster", - "dnephin", + # Daniel Nephin is one of the longest-running maitainers on + # the Compose project, and has contributed several major features + # including muti-file support, variable interpolation, secrets + # emulation and many more + "dnephin", ] [people] From 5184f4f78dd083cf2afe4569de3ec0c90743a0b1 Mon Sep 17 00:00:00 2001 From: Dakota Hawkins Date: Thu, 15 Mar 2018 17:33:15 -0400 Subject: [PATCH 3334/4072] Add update-docker-compose.ps1 to contrib/update/ Updates Windows' installed version of docker-compose to the latest release. Fixes #5790 Signed-off-by: Dakota Hawkins --- contrib/update/update-docker-compose.ps1 | 116 +++++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 contrib/update/update-docker-compose.ps1 diff --git a/contrib/update/update-docker-compose.ps1 b/contrib/update/update-docker-compose.ps1 new file mode 100644 index 00000000000..bb033b46467 --- /dev/null +++ b/contrib/update/update-docker-compose.ps1 @@ -0,0 +1,116 @@ +# Self-elevate the script if required +# http://www.expta.com/2017/03/how-to-self-elevate-powershell-script.html +If (-Not ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] 'Administrator')) { + If ([int](Get-CimInstance -Class Win32_OperatingSystem | Select-Object -ExpandProperty BuildNumber) -ge 6000) { + $CommandLine = "-File `"" + $MyInvocation.MyCommand.Path + "`" " + $MyInvocation.UnboundArguments + Start-Process -FilePath PowerShell.exe -Verb Runas -ArgumentList $CommandLine + Exit + } +} + +$SectionSeparator = "--------------------------------------------------" + +# Update docker-compose if required +Function UpdateDockerCompose() { + Write-Host "Updating docker-compose if required..." + Write-Host $SectionSeparator + + # Find the installed docker-compose.exe location + Try { + $DockerComposePath = Get-Command docker-compose.exe -ErrorAction Stop | ` + Select-Object -First 1 -ExpandProperty Definition + } + Catch { + Write-Host "Error: Could not find path to docker-compose.exe" ` + -ForegroundColor Red + Return $false + } + + # Prefer/enable TLS 1.2 + # https://stackoverflow.com/a/48030563/153079 + [Net.ServicePointManager]::SecurityProtocol = "tls12, tls11, tls" + + # Query for the latest release version + Try { + $URI = "https://api.github.com/repos/docker/compose/releases/latest" + $LatestComposeVersion = [System.Version](Invoke-RestMethod -Method Get -Uri $URI).tag_name + } + Catch { + Write-Host "Error: Query for the latest docker-compose release version failed" ` + -ForegroundColor Red + Return $false + } + + # Check the installed version and compare with latest release + $UpdateDockerCompose = $false + Try { + $InstalledComposeVersion = ` + [System.Version]((docker-compose.exe version --short) | Out-String) + + If ($InstalledComposeVersion -eq $LatestComposeVersion) { + Write-Host ("Installed docker-compose version ({0}) same as latest ({1})." ` + -f $InstalledComposeVersion.ToString(), $LatestComposeVersion.ToString()) + } + ElseIf ($InstalledComposeVersion -lt $LatestComposeVersion) { + Write-Host ("Installed docker-compose version ({0}) older than latest ({1})." ` + -f $InstalledComposeVersion.ToString(), $LatestComposeVersion.ToString()) + $UpdateDockerCompose = $true + } + Else { + Write-Host ("Installed docker-compose version ({0}) newer than latest ({1})." ` + -f $InstalledComposeVersion.ToString(), $LatestComposeVersion.ToString()) ` + -ForegroundColor Yellow + } + } + Catch { + Write-Host ` + "Warning: Couldn't get docker-compose version, assuming an update is required..." ` + -ForegroundColor Yellow + $UpdateDockerCompose = $true + } + + If (-Not $UpdateDockerCompose) { + # Nothing to do! + Return $false + } + + # Download the latest version of docker-compose.exe + Try { + $RemoteFileName = "docker-compose-Windows-x86_64.exe" + $URI = ("https://github.com/docker/compose/releases/download/{0}/{1}" ` + -f $LatestComposeVersion.ToString(), $RemoteFileName) + Invoke-WebRequest -UseBasicParsing -Uri $URI ` + -OutFile $DockerComposePath + Return $true + } + Catch { + Write-Host ("Error: Failed to download the latest version of docker-compose`n{0}" ` + -f $_.Exception.Message) -ForegroundColor Red + Return $false + } + + Return $false +} + +If (UpdateDockerCompose) { + Write-Host "Updated to latest-version of docker-compose, running update again to verify.`n" + If (UpdateDockerCompose) { + Write-Host "Error: Should not have updated twice." -ForegroundColor Red + } +} + +# Assuming elevation popped up a new powershell window, pause so the user can see what happened +# https://stackoverflow.com/a/22362868/153079 +Function Pause ($Message = "Press any key to continue . . . ") { + If ((Test-Path variable:psISE) -and $psISE) { + $Shell = New-Object -ComObject "WScript.Shell" + $Shell.Popup("Click OK to continue.", 0, "Script Paused", 0) + } + Else { + Write-Host "`n$SectionSeparator" + Write-Host -NoNewline $Message + [void][System.Console]::ReadKey($true) + Write-Host + } +} +Pause From b1ade31c67bf57945d867237273d4cc1caae6850 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 15 Mar 2018 16:22:28 -0700 Subject: [PATCH 3335/4072] Manage encoding errors in progress_stream Signed-off-by: Joffrey F --- compose/progress_stream.py | 32 +++++++++++++++++++----------- tests/unit/progress_stream_test.py | 31 +++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 12 deletions(-) diff --git a/compose/progress_stream.py b/compose/progress_stream.py index 5314f89fdb3..5e709770a50 100644 --- a/compose/progress_stream.py +++ b/compose/progress_stream.py @@ -8,6 +8,14 @@ class StreamOutputError(Exception): pass +def write_to_stream(s, stream): + try: + stream.write(s) + except UnicodeEncodeError: + encoding = getattr(stream, 'encoding', 'ascii') + stream.write(s.encode(encoding, errors='replace').decode(encoding)) + + def stream_output(output, stream): is_terminal = hasattr(stream, 'isatty') and stream.isatty() stream = utils.get_output_stream(stream) @@ -34,18 +42,18 @@ def stream_output(output, stream): if image_id not in lines: lines[image_id] = len(lines) - stream.write("\n") + write_to_stream("\n", stream) diff = len(lines) - lines[image_id] # move cursor up `diff` rows - stream.write("%c[%dA" % (27, diff)) + write_to_stream("%c[%dA" % (27, diff), stream) print_output_event(event, stream, is_terminal) if 'id' in event: # move cursor back down - stream.write("%c[%dB" % (27, diff)) + write_to_stream("%c[%dB" % (27, diff), stream) stream.flush() @@ -60,36 +68,36 @@ def print_output_event(event, stream, is_terminal): if is_terminal and 'stream' not in event: # erase current line - stream.write("%c[2K\r" % 27) + write_to_stream("%c[2K\r" % 27, stream) terminator = "\r" elif 'progressDetail' in event: return if 'time' in event: - stream.write("[%s] " % event['time']) + write_to_stream("[%s] " % event['time'], stream) if 'id' in event: - stream.write("%s: " % event['id']) + write_to_stream("%s: " % event['id'], stream) if 'from' in event: - stream.write("(from %s) " % event['from']) + write_to_stream("(from %s) " % event['from'], stream) status = event.get('status', '') if 'progress' in event: - stream.write("%s %s%s" % (status, event['progress'], terminator)) + write_to_stream("%s %s%s" % (status, event['progress'], terminator), stream) elif 'progressDetail' in event: detail = event['progressDetail'] total = detail.get('total') if 'current' in detail and total: percentage = float(detail['current']) / float(total) * 100 - stream.write('%s (%.1f%%)%s' % (status, percentage, terminator)) + write_to_stream('%s (%.1f%%)%s' % (status, percentage, terminator), stream) else: - stream.write('%s%s' % (status, terminator)) + write_to_stream('%s%s' % (status, terminator), stream) elif 'stream' in event: - stream.write("%s%s" % (event['stream'], terminator)) + write_to_stream("%s%s" % (event['stream'], terminator), stream) else: - stream.write("%s%s\n" % (status, terminator)) + write_to_stream("%s%s\n" % (status, terminator), stream) def get_digest_from_pull(events): diff --git a/tests/unit/progress_stream_test.py b/tests/unit/progress_stream_test.py index 22a6e081ba3..f4a0ab06348 100644 --- a/tests/unit/progress_stream_test.py +++ b/tests/unit/progress_stream_test.py @@ -1,6 +1,13 @@ +# ~*~ encoding: utf-8 ~*~ from __future__ import absolute_import from __future__ import unicode_literals +import io +import os +import random +import shutil +import tempfile + from six import StringIO from compose import progress_stream @@ -66,6 +73,30 @@ def test_stream_output_no_progress_event_no_tty(self): events = progress_stream.stream_output(events, output) assert len(output.getvalue()) > 0 + def test_mismatched_encoding_stream_write(self): + tmpdir = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, tmpdir, True) + + def mktempfile(encoding): + fname = os.path.join(tmpdir, hex(random.getrandbits(128))[2:-1]) + return io.open(fname, mode='w+', encoding=encoding) + + text = '就吃饭' + with mktempfile(encoding='utf-8') as tf: + progress_stream.write_to_stream(text, tf) + tf.seek(0) + assert tf.read() == text + + with mktempfile(encoding='utf-32') as tf: + progress_stream.write_to_stream(text, tf) + tf.seek(0) + assert tf.read() == text + + with mktempfile(encoding='ascii') as tf: + progress_stream.write_to_stream(text, tf) + tf.seek(0) + assert tf.read() == '???' + def test_get_digest_from_push(): digest = "sha256:abcd" From 16ea49ac8cb71771f52cafc327e864285b6d39ef Mon Sep 17 00:00:00 2001 From: Matthieu Nottale Date: Fri, 16 Mar 2018 15:56:30 +0100 Subject: [PATCH 3336/4072] Add support for cpu_rt_period and cpu_rt_runtime. Signed-off-by: Matthieu Nottale --- compose/config/config.py | 2 ++ compose/config/config_schema_v2.2.json | 2 ++ compose/config/config_schema_v2.3.json | 2 ++ compose/config/interpolation.py | 2 ++ compose/service.py | 4 ++++ tests/integration/service_test.py | 8 ++++++++ 6 files changed, 20 insertions(+) diff --git a/compose/config/config.py b/compose/config/config.py index 59f9d35baa6..7bc220e5f60 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -69,6 +69,8 @@ 'cpu_percent', 'cpu_period', 'cpu_quota', + 'cpu_rt_period', + 'cpu_rt_runtime', 'cpu_shares', 'cpus', 'cpuset', diff --git a/compose/config/config_schema_v2.2.json b/compose/config/config_schema_v2.2.json index e15223fc3ce..dc7076745e2 100644 --- a/compose/config/config_schema_v2.2.json +++ b/compose/config/config_schema_v2.2.json @@ -111,6 +111,8 @@ "cpu_shares": {"type": ["number", "string"]}, "cpu_quota": {"type": ["number", "string"]}, "cpu_period": {"type": ["number", "string"]}, + "cpu_rt_period": {"type": ["number", "string"]}, + "cpu_rt_runtime": {"type": ["number", "string"]}, "cpus": {"type": "number", "minimum": 0}, "cpuset": {"type": "string"}, "depends_on": { diff --git a/compose/config/config_schema_v2.3.json b/compose/config/config_schema_v2.3.json index c2e860be239..bd7ce166d17 100644 --- a/compose/config/config_schema_v2.3.json +++ b/compose/config/config_schema_v2.3.json @@ -114,6 +114,8 @@ "cpu_shares": {"type": ["number", "string"]}, "cpu_quota": {"type": ["number", "string"]}, "cpu_period": {"type": ["number", "string"]}, + "cpu_rt_period": {"type": ["number", "string"]}, + "cpu_rt_runtime": {"type": ["number", "string"]}, "cpus": {"type": "number", "minimum": 0}, "cpuset": {"type": "string"}, "depends_on": { diff --git a/compose/config/interpolation.py b/compose/config/interpolation.py index 7e50f989223..59a567bb2d6 100644 --- a/compose/config/interpolation.py +++ b/compose/config/interpolation.py @@ -239,6 +239,8 @@ class ConversionMap(object): service_path('cpu_count'): to_int, service_path('cpu_quota'): to_microseconds, service_path('cpu_period'): to_microseconds, + service_path('cpu_rt_period'): to_microseconds, + service_path('cpu_rt_runtime'): to_microseconds, service_path('configs', 'mode'): to_int, service_path('secrets', 'mode'): to_int, service_path('healthcheck', 'retries'): to_int, diff --git a/compose/service.py b/compose/service.py index bcd21d02f2f..a4e5d9b82d1 100644 --- a/compose/service.py +++ b/compose/service.py @@ -64,6 +64,8 @@ 'cpu_percent', 'cpu_period', 'cpu_quota', + 'cpu_rt_period', + 'cpu_rt_runtime', 'cpu_shares', 'cpus', 'cpuset', @@ -949,6 +951,8 @@ def _get_container_host_config(self, override_options, one_off=False): mounts=options.get('mounts'), device_cgroup_rules=options.get('device_cgroup_rules'), cpu_period=options.get('cpu_period'), + cpu_rt_period=options.get('cpu_rt_period'), + cpu_rt_runtime=options.get('cpu_rt_runtime'), ) def get_secret_volumes(self): diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index befc0204ab7..7c1be24b98e 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -128,6 +128,14 @@ def test_create_container_with_cpu_quota(self): assert container.get('HostConfig.CpuQuota') == 40000 assert container.get('HostConfig.CpuPeriod') == 150000 + @pytest.mark.xfail(raises=OperationFailedError, reason='not supported by kernel') + def test_create_container_with_cpu_rt(self): + service = self.create_service('db', cpu_rt_runtime=40000, cpu_rt_period=150000) + container = service.create_container() + container.start() + assert container.get('HostConfig.CpuRealtimeRuntime') == 40000 + assert container.get('HostConfig.CpuRealtimePeriod') == 150000 + @v2_2_only() def test_create_container_with_cpu_count(self): self.require_api_version('1.25') From 25c048fd0ada78796d13f6010a93b25efee02bde Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 20 Mar 2018 12:15:51 +0100 Subject: [PATCH 3337/4072] Bump Docker SDK -> 3.1.3 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 33462d4961c..743272d4e51 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ backports.ssl-match-hostname==3.5.0.1; python_version < '3' cached-property==1.3.0 certifi==2017.4.17 chardet==3.0.4 -docker==3.1.1 +docker==3.1.3 docker-pycreds==0.2.1 dockerpty==0.4.1 docopt==0.6.2 diff --git a/setup.py b/setup.py index cf8f6dc1318..e24736df02f 100644 --- a/setup.py +++ b/setup.py @@ -36,7 +36,7 @@ def find_version(*file_paths): 'requests >= 2.6.1, != 2.11.0, != 2.12.2, != 2.18.0, < 2.19', 'texttable >= 0.9.0, < 0.10', 'websocket-client >= 0.32.0, < 1.0', - 'docker >= 3.1.1, < 4.0', + 'docker >= 3.1.3, < 4.0', 'dockerpty >= 0.4.1, < 0.5', 'six >= 1.3.0, < 2', 'jsonschema >= 2.5.1, < 3', From fd94fab264ee56509730dce184a30e159d4cc074 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 15 Mar 2018 16:22:28 -0700 Subject: [PATCH 3338/4072] Manage encoding errors in progress_stream Signed-off-by: Joffrey F --- compose/progress_stream.py | 32 +++++++++++++++++++----------- tests/unit/progress_stream_test.py | 31 +++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 12 deletions(-) diff --git a/compose/progress_stream.py b/compose/progress_stream.py index 5314f89fdb3..5e709770a50 100644 --- a/compose/progress_stream.py +++ b/compose/progress_stream.py @@ -8,6 +8,14 @@ class StreamOutputError(Exception): pass +def write_to_stream(s, stream): + try: + stream.write(s) + except UnicodeEncodeError: + encoding = getattr(stream, 'encoding', 'ascii') + stream.write(s.encode(encoding, errors='replace').decode(encoding)) + + def stream_output(output, stream): is_terminal = hasattr(stream, 'isatty') and stream.isatty() stream = utils.get_output_stream(stream) @@ -34,18 +42,18 @@ def stream_output(output, stream): if image_id not in lines: lines[image_id] = len(lines) - stream.write("\n") + write_to_stream("\n", stream) diff = len(lines) - lines[image_id] # move cursor up `diff` rows - stream.write("%c[%dA" % (27, diff)) + write_to_stream("%c[%dA" % (27, diff), stream) print_output_event(event, stream, is_terminal) if 'id' in event: # move cursor back down - stream.write("%c[%dB" % (27, diff)) + write_to_stream("%c[%dB" % (27, diff), stream) stream.flush() @@ -60,36 +68,36 @@ def print_output_event(event, stream, is_terminal): if is_terminal and 'stream' not in event: # erase current line - stream.write("%c[2K\r" % 27) + write_to_stream("%c[2K\r" % 27, stream) terminator = "\r" elif 'progressDetail' in event: return if 'time' in event: - stream.write("[%s] " % event['time']) + write_to_stream("[%s] " % event['time'], stream) if 'id' in event: - stream.write("%s: " % event['id']) + write_to_stream("%s: " % event['id'], stream) if 'from' in event: - stream.write("(from %s) " % event['from']) + write_to_stream("(from %s) " % event['from'], stream) status = event.get('status', '') if 'progress' in event: - stream.write("%s %s%s" % (status, event['progress'], terminator)) + write_to_stream("%s %s%s" % (status, event['progress'], terminator), stream) elif 'progressDetail' in event: detail = event['progressDetail'] total = detail.get('total') if 'current' in detail and total: percentage = float(detail['current']) / float(total) * 100 - stream.write('%s (%.1f%%)%s' % (status, percentage, terminator)) + write_to_stream('%s (%.1f%%)%s' % (status, percentage, terminator), stream) else: - stream.write('%s%s' % (status, terminator)) + write_to_stream('%s%s' % (status, terminator), stream) elif 'stream' in event: - stream.write("%s%s" % (event['stream'], terminator)) + write_to_stream("%s%s" % (event['stream'], terminator), stream) else: - stream.write("%s%s\n" % (status, terminator)) + write_to_stream("%s%s\n" % (status, terminator), stream) def get_digest_from_pull(events): diff --git a/tests/unit/progress_stream_test.py b/tests/unit/progress_stream_test.py index 22a6e081ba3..f4a0ab06348 100644 --- a/tests/unit/progress_stream_test.py +++ b/tests/unit/progress_stream_test.py @@ -1,6 +1,13 @@ +# ~*~ encoding: utf-8 ~*~ from __future__ import absolute_import from __future__ import unicode_literals +import io +import os +import random +import shutil +import tempfile + from six import StringIO from compose import progress_stream @@ -66,6 +73,30 @@ def test_stream_output_no_progress_event_no_tty(self): events = progress_stream.stream_output(events, output) assert len(output.getvalue()) > 0 + def test_mismatched_encoding_stream_write(self): + tmpdir = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, tmpdir, True) + + def mktempfile(encoding): + fname = os.path.join(tmpdir, hex(random.getrandbits(128))[2:-1]) + return io.open(fname, mode='w+', encoding=encoding) + + text = '就吃饭' + with mktempfile(encoding='utf-8') as tf: + progress_stream.write_to_stream(text, tf) + tf.seek(0) + assert tf.read() == text + + with mktempfile(encoding='utf-32') as tf: + progress_stream.write_to_stream(text, tf) + tf.seek(0) + assert tf.read() == text + + with mktempfile(encoding='ascii') as tf: + progress_stream.write_to_stream(text, tf) + tf.seek(0) + assert tf.read() == '???' + def test_get_digest_from_push(): digest = "sha256:abcd" From 538e982c93ca6c2c281e2dea116790d8d6c57e49 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 20 Mar 2018 12:15:51 +0100 Subject: [PATCH 3339/4072] Bump Docker SDK -> 3.1.3 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 33462d4961c..743272d4e51 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ backports.ssl-match-hostname==3.5.0.1; python_version < '3' cached-property==1.3.0 certifi==2017.4.17 chardet==3.0.4 -docker==3.1.1 +docker==3.1.3 docker-pycreds==0.2.1 dockerpty==0.4.1 docopt==0.6.2 diff --git a/setup.py b/setup.py index cf8f6dc1318..e24736df02f 100644 --- a/setup.py +++ b/setup.py @@ -36,7 +36,7 @@ def find_version(*file_paths): 'requests >= 2.6.1, != 2.11.0, != 2.12.2, != 2.18.0, < 2.19', 'texttable >= 0.9.0, < 0.10', 'websocket-client >= 0.32.0, < 1.0', - 'docker >= 3.1.1, < 4.0', + 'docker >= 3.1.3, < 4.0', 'dockerpty >= 0.4.1, < 0.5', 'six >= 1.3.0, < 2', 'jsonschema >= 2.5.1, < 3', From 80ce4eafa722ee9fc312ad0d14d2eaa5d7f17a22 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 20 Mar 2018 15:10:39 +0100 Subject: [PATCH 3340/4072] pip3? Signed-off-by: Joffrey F --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 6ee2a60e50d..66a87d1d04d 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -10,7 +10,7 @@ jobs: command: brew update > /dev/null && brew upgrade python - run: name: install tox - command: sudo pip install --upgrade tox==2.1.1 + command: sudo pip3 install --upgrade tox==2.1.1 - run: name: unit tests command: tox -e py27,py36 -- tests/unit From ca8d3c6c18d314a7314e383b53472127e00a523f Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 20 Mar 2018 14:55:44 +0100 Subject: [PATCH 3341/4072] Bump 1.20.0 Signed-off-by: Joffrey F --- CHANGELOG.md | 2 ++ compose/__init__.py | 2 +- script/run/run.sh | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c113572ca13..ab0a2c354ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -86,6 +86,8 @@ Change log - Fixed a bug occurring during builds caused by files with a negative `mtime` values in the build context +- Fixed an encoding bug when streaming build progress + 1.19.0 (2018-02-07) ------------------- diff --git a/compose/__init__.py b/compose/__init__.py index d2e3f696bf2..1c5a409f194 100644 --- a/compose/__init__.py +++ b/compose/__init__.py @@ -1,4 +1,4 @@ from __future__ import absolute_import from __future__ import unicode_literals -__version__ = '1.20.0-rc2' +__version__ = '1.20.0' diff --git a/script/run/run.sh b/script/run/run.sh index dff9d982acc..a739edb354b 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.20.0-rc2" +VERSION="1.20.0" IMAGE="docker/compose:$VERSION" From 679d3555a2c8c5ad0b0f491f5340ba60be34b977 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 20 Mar 2018 16:10:08 +0100 Subject: [PATCH 3342/4072] 1.21.0-dev Signed-off-by: Joffrey F --- CHANGELOG.md | 2 +- compose/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ab0a2c354ba..25e04d1b274 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ Change log ========== -1.20.0 (2018-03-07) +1.20.0 (2018-03-20) ------------------- ### New features diff --git a/compose/__init__.py b/compose/__init__.py index 1c5a409f194..a5c1364a69b 100644 --- a/compose/__init__.py +++ b/compose/__init__.py @@ -1,4 +1,4 @@ from __future__ import absolute_import from __future__ import unicode_literals -__version__ = '1.20.0' +__version__ = '1.21.0dev' From db10ef2624aaac2d8f9bea6a22579755c4abcddc Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 21 Mar 2018 14:40:21 +0100 Subject: [PATCH 3343/4072] Bump Docker python SDK -> 3.1.4 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 743272d4e51..e5a26bba2c2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ backports.ssl-match-hostname==3.5.0.1; python_version < '3' cached-property==1.3.0 certifi==2017.4.17 chardet==3.0.4 -docker==3.1.3 +docker==3.1.4 docker-pycreds==0.2.1 dockerpty==0.4.1 docopt==0.6.2 diff --git a/setup.py b/setup.py index e24736df02f..96530726400 100644 --- a/setup.py +++ b/setup.py @@ -36,7 +36,7 @@ def find_version(*file_paths): 'requests >= 2.6.1, != 2.11.0, != 2.12.2, != 2.18.0, < 2.19', 'texttable >= 0.9.0, < 0.10', 'websocket-client >= 0.32.0, < 1.0', - 'docker >= 3.1.3, < 4.0', + 'docker >= 3.1.4, < 4.0', 'dockerpty >= 0.4.1, < 0.5', 'six >= 1.3.0, < 2', 'jsonschema >= 2.5.1, < 3', From e5ce2e6c075a18c0cbb292d16cfbb6ce2cd37fcb Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 21 Mar 2018 14:40:21 +0100 Subject: [PATCH 3344/4072] Bump Docker python SDK -> 3.1.4 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 743272d4e51..e5a26bba2c2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ backports.ssl-match-hostname==3.5.0.1; python_version < '3' cached-property==1.3.0 certifi==2017.4.17 chardet==3.0.4 -docker==3.1.3 +docker==3.1.4 docker-pycreds==0.2.1 dockerpty==0.4.1 docopt==0.6.2 diff --git a/setup.py b/setup.py index e24736df02f..96530726400 100644 --- a/setup.py +++ b/setup.py @@ -36,7 +36,7 @@ def find_version(*file_paths): 'requests >= 2.6.1, != 2.11.0, != 2.12.2, != 2.18.0, < 2.19', 'texttable >= 0.9.0, < 0.10', 'websocket-client >= 0.32.0, < 1.0', - 'docker >= 3.1.3, < 4.0', + 'docker >= 3.1.4, < 4.0', 'dockerpty >= 0.4.1, < 0.5', 'six >= 1.3.0, < 2', 'jsonschema >= 2.5.1, < 3', From 5d8c71b2c630f9eeeed7c755bb8dad4b24f8cdb7 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 21 Mar 2018 14:47:30 +0100 Subject: [PATCH 3345/4072] Bump 1.20.1 Signed-off-by: Joffrey F --- CHANGELOG.md | 10 +++++++++- compose/__init__.py | 2 +- script/run/run.sh | 2 +- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ab0a2c354ba..3b18d0c89b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,15 @@ Change log ========== -1.20.0 (2018-03-07) +1.20.1 (2018-03-21) +------------------- + +### Bugfixes + +- Fixed an issue where `docker-compose build` would error out if the + build context contained directory symlinks + +1.20.0 (2018-03-20) ------------------- ### New features diff --git a/compose/__init__.py b/compose/__init__.py index 1c5a409f194..5706c78ead1 100644 --- a/compose/__init__.py +++ b/compose/__init__.py @@ -1,4 +1,4 @@ from __future__ import absolute_import from __future__ import unicode_literals -__version__ = '1.20.0' +__version__ = '1.20.1' diff --git a/script/run/run.sh b/script/run/run.sh index a739edb354b..0cc7c1463fb 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.20.0" +VERSION="1.20.1" IMAGE="docker/compose:$VERSION" From 166f355efb2054786892c2a033164d1c8ea7595e Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 23 Mar 2018 14:08:06 +0100 Subject: [PATCH 3346/4072] Add libgcc dependency for Compose in a container Signed-off-by: Joffrey F --- Dockerfile.run | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Dockerfile.run b/Dockerfile.run index 04acf01c304..c403ac23063 100644 --- a/Dockerfile.run +++ b/Dockerfile.run @@ -3,12 +3,13 @@ FROM alpine:3.6 ENV GLIBC 2.27-r0 ENV DOCKERBINS_SHA 1270dce1bd7e1838d62ae21d2505d87f16efc1d9074645571daaefdfd0c14054 -RUN apk update && apk add --no-cache openssl ca-certificates curl && \ +RUN apk update && apk add --no-cache openssl ca-certificates curl libgcc && \ curl -fsSL -o /etc/apk/keys/sgerrand.rsa.pub https://raw.githubusercontent.com/sgerrand/alpine-pkg-glibc/master/sgerrand.rsa.pub && \ curl -fsSL -o glibc-$GLIBC.apk https://github.com/sgerrand/alpine-pkg-glibc/releases/download/$GLIBC/glibc-$GLIBC.apk && \ apk add --no-cache glibc-$GLIBC.apk && \ ln -s /lib/libz.so.1 /usr/glibc-compat/lib/ && \ ln -s /lib/libc.musl-x86_64.so.1 /usr/glibc-compat/lib && \ + ln -s /usr/lib/libgcc_s.so.1 /usr/glibc-compat/lib && \ curl -fsSL -o dockerbins.tgz "https://download.docker.com/linux/static/stable/x86_64/docker-17.12.1-ce.tgz" && \ echo "${DOCKERBINS_SHA} dockerbins.tgz" | sha256sum -c - && \ tar xvf dockerbins.tgz docker/docker --strip-components 1 && \ From 255d16d7fa2b85260237b6ebd74b98da68110c03 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 23 Mar 2018 16:54:12 +0100 Subject: [PATCH 3347/4072] Add --compress option to build command Signed-off-by: Joffrey F --- compose/cli/main.py | 5 ++++- compose/project.py | 4 ++-- compose/service.py | 4 +++- tests/integration/service_test.py | 18 ++++++++++++++++++ tests/unit/service_test.py | 2 ++ 5 files changed, 29 insertions(+), 4 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 9e49e297430..3932e4eae7f 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -254,6 +254,7 @@ def build(self, options): Usage: build [options] [--build-arg key=val...] [SERVICE...] Options: + --compress Compress the build context using gzip. --force-rm Always remove intermediate containers. --no-cache Do not use cache when building the image. --pull Always attempt to pull a newer version of the image. @@ -277,7 +278,9 @@ def build(self, options): pull=bool(options.get('--pull', False)), force_rm=bool(options.get('--force-rm', False)), memory=options.get('--memory'), - build_args=build_args) + build_args=build_args, + gzip=options.get('--compress', False), + ) def bundle(self, options): """ diff --git a/compose/project.py b/compose/project.py index 48095e9387f..f66e415fe5d 100644 --- a/compose/project.py +++ b/compose/project.py @@ -366,10 +366,10 @@ def restart(self, service_names=None, **options): return containers def build(self, service_names=None, no_cache=False, pull=False, force_rm=False, memory=None, - build_args=None): + build_args=None, gzip=False): for service in self.get_services(service_names): if service.can_be_built(): - service.build(no_cache, pull, force_rm, memory, build_args) + service.build(no_cache, pull, force_rm, memory, build_args, gzip) else: log.info('%s uses an image, skipping' % service.name) diff --git a/compose/service.py b/compose/service.py index a4e5d9b82d1..cd9b4b56821 100644 --- a/compose/service.py +++ b/compose/service.py @@ -967,7 +967,8 @@ def build_spec(secret): return [build_spec(secret) for secret in self.secrets] - def build(self, no_cache=False, pull=False, force_rm=False, memory=None, build_args_override=None): + def build(self, no_cache=False, pull=False, force_rm=False, memory=None, build_args_override=None, + gzip=False): log.info('Building %s' % self.name) build_opts = self.options.get('build', {}) @@ -1003,6 +1004,7 @@ def build(self, no_cache=False, pull=False, force_rm=False, memory=None, build_a container_limits={ 'memory': parse_bytes(memory) if memory else None }, + gzip=gzip ) try: diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 7c1be24b98e..260867fe03b 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -1105,6 +1105,24 @@ def test_build_with_extra_hosts(self): service.build() assert service.image() + def test_build_with_gzip(self): + base_dir = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, base_dir) + with open(os.path.join(base_dir, 'Dockerfile'), 'w') as f: + f.write('\n'.join([ + 'FROM busybox', + 'COPY . /src', + 'RUN cat /src/hello.txt' + ])) + with open(os.path.join(base_dir, 'hello.txt'), 'w') as f: + f.write('hello world\n') + + service = self.create_service('build_gzip', build={ + 'context': text_type(base_dir), + }) + service.build(gzip=True) + assert service.image() + def test_start_container_stays_unprivileged(self): service = self.create_service('web') container = create_and_start_container(service).inspect() diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 9128b95503c..1f322ce4e4f 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -487,6 +487,7 @@ def test_create_container(self): shmsize=None, extra_hosts=None, container_limits={'memory': None}, + gzip=False, ) def test_ensure_image_exists_no_build(self): @@ -529,6 +530,7 @@ def test_ensure_image_exists_force_build(self): shmsize=None, extra_hosts=None, container_limits={'memory': None}, + gzip=False ) def test_build_does_not_pull(self): From 4e97e0fd41efea16fae1d870918095e8a86fa283 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 23 Mar 2018 17:59:22 +0100 Subject: [PATCH 3348/4072] Add issue / PR templates (fixes #5795) Signed-off-by: Joffrey F --- docs/issue_template.md | 50 ++++++++++++++++++++++++++++++++++++++++++ docs/pull_request.md | 13 +++++++++++ 2 files changed, 63 insertions(+) create mode 100644 docs/issue_template.md create mode 100644 docs/pull_request.md diff --git a/docs/issue_template.md b/docs/issue_template.md new file mode 100644 index 00000000000..774f27e207f --- /dev/null +++ b/docs/issue_template.md @@ -0,0 +1,50 @@ + + +## Description of the issue + +## Context information (for bug reports) + +``` +Output of "docker-compose version" +``` + +``` +Output of "docker version" +``` + +``` +Output of "docker-compose config" +``` + + +## Steps to reproduce the issue + +1. +2. +3. + +### Observed result + +### Expected result + +### Stacktrace / full error message + +``` +(if applicable) +``` + +## Additional information + +OS version / distribution, `docker-compose` install method, etc. diff --git a/docs/pull_request.md b/docs/pull_request.md new file mode 100644 index 00000000000..ffe751fb331 --- /dev/null +++ b/docs/pull_request.md @@ -0,0 +1,13 @@ + + + +Resolves # From 36e646685501ba66a2093c2cd85655d1d146341d Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 23 Mar 2018 18:01:55 +0100 Subject: [PATCH 3349/4072] pull_request_template.md Signed-off-by: Joffrey F --- docs/{pull_request.md => pull_request_template.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename docs/{pull_request.md => pull_request_template.md} (100%) diff --git a/docs/pull_request.md b/docs/pull_request_template.md similarity index 100% rename from docs/pull_request.md rename to docs/pull_request_template.md From 7d68d4bb442eec920864a560fdfa523eb1d0398f Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 23 Mar 2018 18:03:08 +0100 Subject: [PATCH 3350/4072] missing "is" Signed-off-by: Joffrey F --- docs/pull_request_template.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/pull_request_template.md b/docs/pull_request_template.md index ffe751fb331..15526af0556 100644 --- a/docs/pull_request_template.md +++ b/docs/pull_request_template.md @@ -7,7 +7,7 @@ do not comply and contributions with failing tests will not be reviewed! Resolves # From cbb9bff9243a8e2de24e74327caa21779a12e075 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 26 Mar 2018 14:25:31 -0700 Subject: [PATCH 3351/4072] Support -H=host notation for interactive run/execs Signed-off-by: Joffrey F --- compose/cli/main.py | 2 +- tests/acceptance/cli_test.py | 7 +++++++ tests/unit/cli/main_test.py | 8 ++++++++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 9e49e297430..c05ba498814 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -1418,7 +1418,7 @@ def call_docker(args, dockeropts): if verify: tls_options.append('--tlsverify') if host: - tls_options.extend(['--host', host]) + tls_options.extend(['--host', host.lstrip('=')]) args = [executable_path] + tls_options + args log.debug(" ".join(map(pipes.quote, args))) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 14c96b24ddd..075705804c6 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -177,6 +177,13 @@ def test_shorthand_host_opt(self): returncode=0 ) + def test_shorthand_host_opt_interactive(self): + self.dispatch( + ['-H={0}'.format(os.environ.get('DOCKER_HOST', 'unix://')), + 'run', 'another', 'ls'], + returncode=0 + ) + def test_host_not_reachable(self): result = self.dispatch(['-H=tcp://doesnotexist:8000', 'ps'], returncode=1) assert "Couldn't connect to Docker daemon" in result.stderr diff --git a/tests/unit/cli/main_test.py b/tests/unit/cli/main_test.py index b46a3ee229e..1a2dfbcf353 100644 --- a/tests/unit/cli/main_test.py +++ b/tests/unit/cli/main_test.py @@ -154,3 +154,11 @@ def test_with_host_option(self): assert fake_call.call_args[0][0] == [ 'docker', '--host', 'tcp://mydocker.net:2333', 'ps' ] + + def test_with_host_option_shorthand_equal(self): + with mock.patch('subprocess.call') as fake_call: + call_docker(['ps'], {'--host': '=tcp://mydocker.net:2333'}) + + assert fake_call.call_args[0][0] == [ + 'docker', '--host', 'tcp://mydocker.net:2333', 'ps' + ] From 394c8efe98fb13aaaca8c1f276642445889a73b2 Mon Sep 17 00:00:00 2001 From: Alberto Piai Date: Sun, 25 Mar 2018 22:58:59 +0200 Subject: [PATCH 3352/4072] fix race condition in Service.create_container() The Service.create_container() method fetches a list of the current containers in order to determine the next container number. In doing so, it makes several API calls: one to fetch the list of containers, then one per container in order to inspect it. In some situations it can happen that a container is removed after having been listed: in that case, the call to inspect will get a 404 and raise a NotFound. One situation in which this has been observed is when trying to concurrently create multiple one-off containers for the same service (using `docker-compose run` and a unique `--name`), as described in more detail in gh-5179. This patch adds a unit test that simulates the race between the calls to list and to inspect, and changes Service._next_container_number to skip removed containers instead of blowing up. Fixes gh-5179 Signed-off-by: Alberto Piai --- compose/service.py | 24 ++++++++++++++++++------ tests/unit/service_test.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/compose/service.py b/compose/service.py index a4e5d9b82d1..1fa35f45a92 100644 --- a/compose/service.py +++ b/compose/service.py @@ -685,15 +685,27 @@ def get_volumes_from_names(self): # TODO: this would benefit from github.com/docker/docker/pull/14699 # to remove the need to inspect every container def _next_container_number(self, one_off=False): - containers = filter(None, [ - Container.from_ps(self.client, container) - for container in self.client.containers( - all=True, - filters={'label': self.labels(one_off=one_off)}) - ]) + containers = self._fetch_containers( + all=True, + filters={'label': self.labels(one_off=one_off)} + ) numbers = [c.number for c in containers] return 1 if not numbers else max(numbers) + 1 + def _fetch_containers(self, **fetch_options): + # Account for containers that might have been removed since we fetched + # the list. + def soft_inspect(container): + try: + return Container.from_id(self.client, container['Id']) + except NotFound: + return None + + return filter(None, [ + soft_inspect(container) + for container in self.client.containers(**fetch_options) + ]) + def _get_aliases(self, network, container=None): return list( {self.name} | diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 9128b95503c..ca8cd2e6df5 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -5,6 +5,7 @@ import pytest from docker.constants import DEFAULT_DOCKER_API_VERSION from docker.errors import APIError +from docker.errors import NotFound from .. import mock from .. import unittest @@ -888,6 +889,38 @@ def test_get_create_options_with_proxy_config(self): 'ftp_proxy': override_options['environment']['FTP_PROXY'], })) + def test_create_when_removed_containers_are_listed(self): + # This is aimed at simulating a race between the API call to list the + # containers, and the ones to inspect each of the listed containers. + # It can happen that a container has been removed after we listed it. + + # containers() returns a container that is about to be removed + self.mock_client.containers.return_value = [ + {'Id': 'rm_cont_id', 'Name': 'rm_cont', 'Image': 'img_id'}, + ] + + # inspect_container() will raise a NotFound when trying to inspect + # rm_cont_id, which at this point has been removed + def inspect(name): + if name == 'rm_cont_id': + raise NotFound(message='Not Found') + + if name == 'new_cont_id': + return {'Id': 'new_cont_id'} + + raise NotImplementedError("incomplete mock") + + self.mock_client.inspect_container.side_effect = inspect + + self.mock_client.inspect_image.return_value = {'Id': 'imageid'} + + self.mock_client.create_container.return_value = {'Id': 'new_cont_id'} + + # We should nonetheless be able to create a new container + service = Service('foo', client=self.mock_client) + + assert service.create_container().id == 'new_cont_id' + class TestServiceNetwork(unittest.TestCase): def setUp(self): From 71d40c2a9b346bb5e19d443f6ab5553a80a7b692 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 27 Mar 2018 11:10:30 -0700 Subject: [PATCH 3353/4072] Avoid fallthrough with empty defaults Signed-off-by: Joffrey F --- compose/config/interpolation.py | 5 ++--- tests/unit/config/interpolation_test.py | 12 ++++++++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/compose/config/interpolation.py b/compose/config/interpolation.py index 59a567bb2d6..8845d73b5e5 100644 --- a/compose/config/interpolation.py +++ b/compose/config/interpolation.py @@ -133,9 +133,8 @@ def convert(mo): braced = mo.group('braced') if braced is not None: sep = mo.group('sep') - result = self.process_braced_group(braced, sep, mapping) - if result: - return result + if sep: + return self.process_braced_group(braced, sep, mapping) if named is not None: val = mapping[named] diff --git a/tests/unit/config/interpolation_test.py b/tests/unit/config/interpolation_test.py index 2ba698fbf4a..0d0e7d28dc6 100644 --- a/tests/unit/config/interpolation_test.py +++ b/tests/unit/config/interpolation_test.py @@ -420,3 +420,15 @@ def test_interpolate_unicode_values(): interpol("$FOO") == '十六夜 咲夜' interpol("${BAR}") == '十六夜 咲夜' + + +def test_interpolate_no_fallthrough(): + # Test regression on docker/compose#5829 + variable_mapping = { + 'TEST:-': 'hello', + 'TEST-': 'hello', + } + interpol = Interpolator(TemplateWithDefaults, variable_mapping).interpolate + + assert interpol('${TEST:-}') == '' + assert interpol('${TEST-}') == '' From 90c57f99e88c5caf4e4959b3740df7235a0e1220 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 27 Mar 2018 12:37:21 -0700 Subject: [PATCH 3354/4072] On load error, retry reading the file with UTF-8 encoding Signed-off-by: Joffrey F --- compose/config/config.py | 12 +++++++++--- tests/unit/config/config_test.py | 16 ++++++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 7bc220e5f60..147c1d31d2f 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -2,6 +2,7 @@ from __future__ import unicode_literals import functools +import io import logging import os import string @@ -1431,10 +1432,15 @@ def has_uppercase(name): return any(char in string.ascii_uppercase for char in name) -def load_yaml(filename): +def load_yaml(filename, encoding=None): try: - with open(filename, 'r') as fh: + with io.open(filename, 'r', encoding=encoding) as fh: return yaml.safe_load(fh) - except (IOError, yaml.YAMLError) as e: + except (IOError, yaml.YAMLError, UnicodeDecodeError) as e: + if encoding is None: + # Sometimes the user's locale sets an encoding that doesn't match + # the YAML files. Im such cases, retry once with the "default" + # UTF-8 encoding + return load_yaml(filename, encoding='utf-8') error_name = getattr(e, '__module__', '') + '.' + e.__class__.__name__ raise ConfigurationError(u"{}: {}".format(error_name, e)) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 7a9bb944dc6..0574b215c92 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -3,6 +3,7 @@ from __future__ import print_function from __future__ import unicode_literals +import codecs import os import shutil import tempfile @@ -1623,6 +1624,21 @@ def test_load_yaml_with_yaml_error(self): assert 'line 3, column 32' in exc.exconly() + def test_load_yaml_with_bom(self): + tmpdir = py.test.ensuretemp('bom_yaml') + self.addCleanup(tmpdir.remove) + bom_yaml = tmpdir.join('docker-compose.yml') + with codecs.open(str(bom_yaml), 'w', encoding='utf-8') as f: + f.write('''\ufeff + version: '2.3' + volumes: + park_bom: + ''') + assert config.load_yaml(str(bom_yaml)) == { + 'version': '2.3', + 'volumes': {'park_bom': None} + } + def test_validate_extra_hosts_invalid(self): with pytest.raises(ConfigurationError) as exc: config.load(build_config_details({ From 344003a2f9cf1068652e4e9a919f469503068010 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 28 Mar 2018 16:47:25 -0700 Subject: [PATCH 3355/4072] Ignore NotFound for external overlay networks Signed-off-by: Joffrey F --- compose/network.py | 5 +++++ tests/integration/network_test.py | 20 ++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/compose/network.py b/compose/network.py index 027e7d5a53c..1a080c40cf6 100644 --- a/compose/network.py +++ b/compose/network.py @@ -42,6 +42,11 @@ def __init__(self, client, project, name, driver=None, driver_opts=None, def ensure(self): if self.external: + if self.driver == 'overlay': + # Swarm nodes do not register overlay networks that were + # created on a different node unless they're in use. + # See docker/compose#4399 + return try: self.inspect() log.debug( diff --git a/tests/integration/network_test.py b/tests/integration/network_test.py index 2ff610fbf81..a2493fda1a2 100644 --- a/tests/integration/network_test.py +++ b/tests/integration/network_test.py @@ -1,7 +1,10 @@ from __future__ import absolute_import from __future__ import unicode_literals +import pytest + from .testcases import DockerClientTestCase +from compose.config.errors import ConfigurationError from compose.const import LABEL_NETWORK from compose.const import LABEL_PROJECT from compose.network import Network @@ -15,3 +18,20 @@ def test_network_default_labels(self): labels = net_data['Labels'] assert labels[LABEL_NETWORK] == net.name assert labels[LABEL_PROJECT] == net.project + + def test_network_external_default_ensure(self): + net = Network( + self.client, 'composetest', 'foonet', + external=True + ) + + with pytest.raises(ConfigurationError): + net.ensure() + + def test_network_external_overlay_ensure(self): + net = Network( + self.client, 'composetest', 'foonet', + driver='overlay', external=True + ) + + assert net.ensure() is None From e19fa1ad4d34075709884a38cdd2dd0d46f7b22b Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 29 Mar 2018 16:32:15 -0700 Subject: [PATCH 3356/4072] Bump docker SDK -> 3.2.0 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 e5a26bba2c2..8db120a6e01 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ backports.ssl-match-hostname==3.5.0.1; python_version < '3' cached-property==1.3.0 certifi==2017.4.17 chardet==3.0.4 -docker==3.1.4 +docker==3.2.0 docker-pycreds==0.2.1 dockerpty==0.4.1 docopt==0.6.2 diff --git a/setup.py b/setup.py index 96530726400..2ddd606b19c 100644 --- a/setup.py +++ b/setup.py @@ -36,7 +36,7 @@ def find_version(*file_paths): 'requests >= 2.6.1, != 2.11.0, != 2.12.2, != 2.18.0, < 2.19', 'texttable >= 0.9.0, < 0.10', 'websocket-client >= 0.32.0, < 1.0', - 'docker >= 3.1.4, < 4.0', + 'docker >= 3.2.0, < 4.0', 'dockerpty >= 0.4.1, < 0.5', 'six >= 1.3.0, < 2', 'jsonschema >= 2.5.1, < 3', From 2e100353d38a6fb5cf0ad37d0403305ab6c54f65 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 29 Mar 2018 16:24:56 -0700 Subject: [PATCH 3357/4072] Add support for build isolation parameter Signed-off-by: Joffrey F --- compose/config/config.py | 1 + compose/config/config_schema_v2.1.json | 3 ++- compose/config/config_schema_v2.2.json | 3 ++- compose/config/config_schema_v2.3.json | 3 ++- compose/service.py | 3 ++- tests/integration/service_test.py | 14 +++++++++++++ tests/unit/service_test.py | 27 ++++++++++++++++++++++++++ 7 files changed, 50 insertions(+), 4 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 147c1d31d2f..84d2a86a6dc 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -1119,6 +1119,7 @@ def to_dict(service): md.merge_scalar('network') md.merge_scalar('target') md.merge_scalar('shm_size') + md.merge_scalar('isolation') md.merge_mapping('args', parse_build_arguments) md.merge_field('cache_from', merge_unique_items_lists, default=[]) md.merge_mapping('labels', parse_labels) diff --git a/compose/config/config_schema_v2.1.json b/compose/config/config_schema_v2.1.json index 87a730dd85a..5ad5a20ea98 100644 --- a/compose/config/config_schema_v2.1.json +++ b/compose/config/config_schema_v2.1.json @@ -88,7 +88,8 @@ "context": {"type": "string"}, "dockerfile": {"type": "string"}, "args": {"$ref": "#/definitions/list_or_dict"}, - "labels": {"$ref": "#/definitions/labels"} + "labels": {"$ref": "#/definitions/labels"}, + "isolation": {"type": "string"} }, "additionalProperties": false } diff --git a/compose/config/config_schema_v2.2.json b/compose/config/config_schema_v2.2.json index dc7076745e2..26044b6510a 100644 --- a/compose/config/config_schema_v2.2.json +++ b/compose/config/config_schema_v2.2.json @@ -90,7 +90,8 @@ "args": {"$ref": "#/definitions/list_or_dict"}, "labels": {"$ref": "#/definitions/labels"}, "cache_from": {"$ref": "#/definitions/list_of_strings"}, - "network": {"type": "string"} + "network": {"type": "string"}, + "isolation": {"type": "string"} }, "additionalProperties": false } diff --git a/compose/config/config_schema_v2.3.json b/compose/config/config_schema_v2.3.json index bd7ce166d17..ac0778f2a0d 100644 --- a/compose/config/config_schema_v2.3.json +++ b/compose/config/config_schema_v2.3.json @@ -93,7 +93,8 @@ "network": {"type": "string"}, "target": {"type": "string"}, "shm_size": {"type": ["integer", "string"]}, - "extra_hosts": {"$ref": "#/definitions/list_or_dict"} + "extra_hosts": {"$ref": "#/definitions/list_or_dict"}, + "isolation": {"type": "string"} }, "additionalProperties": false } diff --git a/compose/service.py b/compose/service.py index 9b03a399134..9e5725e0af8 100644 --- a/compose/service.py +++ b/compose/service.py @@ -1016,7 +1016,8 @@ def build(self, no_cache=False, pull=False, force_rm=False, memory=None, build_a container_limits={ 'memory': parse_bytes(memory) if memory else None }, - gzip=gzip + gzip=gzip, + isolation=build_opts.get('isolation', self.options.get('isolation', None)), ) try: diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 260867fe03b..d8f4d094adf 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -1123,6 +1123,20 @@ def test_build_with_gzip(self): service.build(gzip=True) assert service.image() + @v2_1_only() + def test_build_with_isolation(self): + base_dir = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, base_dir) + with open(os.path.join(base_dir, 'Dockerfile'), 'w') as f: + f.write('FROM busybox\n') + + service = self.create_service('build_isolation', build={ + 'context': text_type(base_dir), + 'isolation': 'default', + }) + service.build() + assert service.image() + def test_start_container_stays_unprivileged(self): service = self.create_service('web') container = create_and_start_container(service).inspect() diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 5002954b58b..c4d53a2c007 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -562,6 +562,33 @@ def test_build_with_override_build_args(self): assert called_build_args['arg1'] == build_args['arg1'] assert called_build_args['arg2'] == 'arg2' + def test_build_with_isolation_from_service_config(self): + self.mock_client.build.return_value = [ + b'{"stream": "Successfully built 12345"}', + ] + + service = Service('foo', client=self.mock_client, build={'context': '.'}, isolation='hyperv') + service.build() + + assert self.mock_client.build.call_count == 1 + called_build_args = self.mock_client.build.call_args[1] + assert called_build_args['isolation'] == 'hyperv' + + def test_build_isolation_from_build_override_service_config(self): + self.mock_client.build.return_value = [ + b'{"stream": "Successfully built 12345"}', + ] + + service = Service( + 'foo', client=self.mock_client, build={'context': '.', 'isolation': 'default'}, + isolation='hyperv' + ) + service.build() + + assert self.mock_client.build.call_count == 1 + called_build_args = self.mock_client.build.call_args[1] + assert called_build_args['isolation'] == 'default' + def test_config_dict(self): self.mock_client.inspect_image.return_value = {'Id': 'abcd'} service = Service( From 070474acae92b037dda0966ea3e90f1281beca52 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 29 Mar 2018 17:21:47 -0700 Subject: [PATCH 3358/4072] Fix unit tests Signed-off-by: Joffrey F --- tests/unit/service_test.py | 40 ++++---------------------------------- 1 file changed, 4 insertions(+), 36 deletions(-) diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index c4d53a2c007..012bfd5e39b 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -472,24 +472,8 @@ def test_create_container(self): _, args, _ = mock_log.warn.mock_calls[0] assert 'was built because it did not already exist' in args[0] - self.mock_client.build.assert_called_once_with( - tag='default_foo', - dockerfile=None, - path='.', - pull=False, - forcerm=False, - nocache=False, - rm=True, - buildargs={}, - labels=None, - cache_from=None, - network_mode=None, - target=None, - shmsize=None, - extra_hosts=None, - container_limits={'memory': None}, - gzip=False, - ) + assert self.mock_client.build.call_count == 1 + self.mock_client.build.call_args[1]['tag'] == 'default_foo' def test_ensure_image_exists_no_build(self): service = Service('foo', client=self.mock_client, build={'context': '.'}) @@ -515,24 +499,8 @@ def test_ensure_image_exists_force_build(self): service.ensure_image_exists(do_build=BuildAction.force) assert not mock_log.warn.called - self.mock_client.build.assert_called_once_with( - tag='default_foo', - dockerfile=None, - path='.', - pull=False, - forcerm=False, - nocache=False, - rm=True, - buildargs={}, - labels=None, - cache_from=None, - network_mode=None, - target=None, - shmsize=None, - extra_hosts=None, - container_limits={'memory': None}, - gzip=False - ) + assert self.mock_client.build.call_count == 1 + self.mock_client.build.call_args[1]['tag'] == 'default_foo' def test_build_does_not_pull(self): self.mock_client.build.return_value = [ From 24a4e312dc5e12f3c71a76c272d6bbbc483ca289 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 30 Mar 2018 11:30:13 -0700 Subject: [PATCH 3359/4072] Bump SDK -> 3.2.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 8db120a6e01..7dce40246a6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ backports.ssl-match-hostname==3.5.0.1; python_version < '3' cached-property==1.3.0 certifi==2017.4.17 chardet==3.0.4 -docker==3.2.0 +docker==3.2.1 docker-pycreds==0.2.1 dockerpty==0.4.1 docopt==0.6.2 diff --git a/setup.py b/setup.py index 2ddd606b19c..a7a33363404 100644 --- a/setup.py +++ b/setup.py @@ -36,7 +36,7 @@ def find_version(*file_paths): 'requests >= 2.6.1, != 2.11.0, != 2.12.2, != 2.18.0, < 2.19', 'texttable >= 0.9.0, < 0.10', 'websocket-client >= 0.32.0, < 1.0', - 'docker >= 3.2.0, < 4.0', + 'docker >= 3.2.1, < 4.0', 'dockerpty >= 0.4.1, < 0.5', 'six >= 1.3.0, < 2', 'jsonschema >= 2.5.1, < 3', From 520f5d0fdece26862c622c33007775400a9b9000 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 8 Mar 2018 17:28:43 -0800 Subject: [PATCH 3360/4072] Add 2.4 file format with platform support. Also reads DOCKER_DEFAULT_PLATFORM env Signed-off-by: Joffrey F --- compose/cli/command.py | 4 +- compose/config/config.py | 3 +- compose/config/config_schema_v2.4.json | 509 +++++++++++++++++++++++++ compose/const.py | 3 + compose/project.py | 3 +- compose/service.py | 20 +- docker-compose.spec | 5 + tests/unit/project_test.py | 27 ++ tests/unit/service_test.py | 43 ++- 9 files changed, 609 insertions(+), 8 deletions(-) create mode 100644 compose/config/config_schema_v2.4.json diff --git a/compose/cli/command.py b/compose/cli/command.py index 022bc85769e..8a32a93a290 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -122,7 +122,9 @@ def get_project(project_dir, config_path=None, project_name=None, verbose=False, ) with errors.handle_connection_errors(client): - return Project.from_config(project_name, config_data, client) + return Project.from_config( + project_name, config_data, client, environment.get('DOCKER_DEFAULT_PLATFORM') + ) def get_project_name(working_dir, project_name=None, environment=None): diff --git a/compose/config/config.py b/compose/config/config.py index 84d2a86a6dc..9f8a50c62e4 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -129,11 +129,12 @@ 'container_name', 'credential_spec', 'dockerfile', + 'init', 'log_driver', 'log_opt', 'logging', 'network_mode', - 'init', + 'platform', 'scale', 'stop_grace_period', ] diff --git a/compose/config/config_schema_v2.4.json b/compose/config/config_schema_v2.4.json new file mode 100644 index 00000000000..7cf0c006edb --- /dev/null +++ b/compose/config/config_schema_v2.4.json @@ -0,0 +1,509 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "id": "config_schema_v2.4.json", + "type": "object", + + "properties": { + "version": { + "type": "string" + }, + + "services": { + "id": "#/properties/services", + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "$ref": "#/definitions/service" + } + }, + "additionalProperties": false + }, + + "networks": { + "id": "#/properties/networks", + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "$ref": "#/definitions/network" + } + } + }, + + "volumes": { + "id": "#/properties/volumes", + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "$ref": "#/definitions/volume" + } + }, + "additionalProperties": false + } + }, + + "patternProperties": {"^x-": {}}, + "additionalProperties": false, + + "definitions": { + + "service": { + "id": "#/definitions/service", + "type": "object", + + "properties": { + "blkio_config": { + "type": "object", + "properties": { + "device_read_bps": { + "type": "array", + "items": {"$ref": "#/definitions/blkio_limit"} + }, + "device_read_iops": { + "type": "array", + "items": {"$ref": "#/definitions/blkio_limit"} + }, + "device_write_bps": { + "type": "array", + "items": {"$ref": "#/definitions/blkio_limit"} + }, + "device_write_iops": { + "type": "array", + "items": {"$ref": "#/definitions/blkio_limit"} + }, + "weight": {"type": "integer"}, + "weight_device": { + "type": "array", + "items": {"$ref": "#/definitions/blkio_weight"} + } + }, + "additionalProperties": false + }, + + "build": { + "oneOf": [ + {"type": "string"}, + { + "type": "object", + "properties": { + "context": {"type": "string"}, + "dockerfile": {"type": "string"}, + "args": {"$ref": "#/definitions/list_or_dict"}, + "labels": {"$ref": "#/definitions/labels"}, + "cache_from": {"$ref": "#/definitions/list_of_strings"}, + "network": {"type": "string"}, + "target": {"type": "string"}, + "shm_size": {"type": ["integer", "string"]}, + "extra_hosts": {"$ref": "#/definitions/list_or_dict"} + }, + "additionalProperties": false + } + ] + }, + "cap_add": {"$ref": "#/definitions/list_of_strings"}, + "cap_drop": {"$ref": "#/definitions/list_of_strings"}, + "cgroup_parent": {"type": "string"}, + "command": { + "oneOf": [ + {"type": "string"}, + {"type": "array", "items": {"type": "string"}} + ] + }, + "container_name": {"type": "string"}, + "cpu_count": {"type": "integer", "minimum": 0}, + "cpu_percent": {"type": "integer", "minimum": 0, "maximum": 100}, + "cpu_shares": {"type": ["number", "string"]}, + "cpu_quota": {"type": ["number", "string"]}, + "cpus": {"type": "number", "minimum": 0}, + "cpuset": {"type": "string"}, + "depends_on": { + "oneOf": [ + {"$ref": "#/definitions/list_of_strings"}, + { + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "type": "object", + "additionalProperties": false, + "properties": { + "condition": { + "type": "string", + "enum": ["service_started", "service_healthy"] + } + }, + "required": ["condition"] + } + } + } + ] + }, + "device_cgroup_rules": {"$ref": "#/definitions/list_of_strings"}, + "devices": {"$ref": "#/definitions/list_of_strings"}, + "dns_opt": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + }, + "dns": {"$ref": "#/definitions/string_or_list"}, + "dns_search": {"$ref": "#/definitions/string_or_list"}, + "domainname": {"type": "string"}, + "entrypoint": { + "oneOf": [ + {"type": "string"}, + {"type": "array", "items": {"type": "string"}} + ] + }, + "env_file": {"$ref": "#/definitions/string_or_list"}, + "environment": {"$ref": "#/definitions/list_or_dict"}, + + "expose": { + "type": "array", + "items": { + "type": ["string", "number"], + "format": "expose" + }, + "uniqueItems": true + }, + + "extends": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + + "properties": { + "service": {"type": "string"}, + "file": {"type": "string"} + }, + "required": ["service"], + "additionalProperties": false + } + ] + }, + + "external_links": {"$ref": "#/definitions/list_of_strings"}, + "extra_hosts": {"$ref": "#/definitions/list_or_dict"}, + "group_add": { + "type": "array", + "items": { + "type": ["string", "number"] + }, + "uniqueItems": true + }, + "healthcheck": {"$ref": "#/definitions/healthcheck"}, + "hostname": {"type": "string"}, + "image": {"type": "string"}, + "init": {"type": ["boolean", "string"]}, + "ipc": {"type": "string"}, + "isolation": {"type": "string"}, + "labels": {"$ref": "#/definitions/labels"}, + "links": {"$ref": "#/definitions/list_of_strings"}, + + "logging": { + "type": "object", + + "properties": { + "driver": {"type": "string"}, + "options": {"type": "object"} + }, + "additionalProperties": false + }, + + "mac_address": {"type": "string"}, + "mem_limit": {"type": ["number", "string"]}, + "mem_reservation": {"type": ["string", "integer"]}, + "mem_swappiness": {"type": "integer"}, + "memswap_limit": {"type": ["number", "string"]}, + "network_mode": {"type": "string"}, + "networks": { + "oneOf": [ + {"$ref": "#/definitions/list_of_strings"}, + { + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "oneOf": [ + { + "type": "object", + "properties": { + "aliases": {"$ref": "#/definitions/list_of_strings"}, + "ipv4_address": {"type": "string"}, + "ipv6_address": {"type": "string"}, + "link_local_ips": {"$ref": "#/definitions/list_of_strings"}, + "priority": {"type": "number"} + }, + "additionalProperties": false + }, + {"type": "null"} + ] + } + }, + "additionalProperties": false + } + ] + }, + "oom_kill_disable": {"type": "boolean"}, + "oom_score_adj": {"type": "integer", "minimum": -1000, "maximum": 1000}, + "pid": {"type": ["string", "null"]}, + "platform": {"type": "string"}, + "ports": { + "type": "array", + "items": { + "type": ["string", "number"], + "format": "ports" + }, + "uniqueItems": true + }, + + "privileged": {"type": "boolean"}, + "read_only": {"type": "boolean"}, + "restart": {"type": "string"}, + "runtime": {"type": "string"}, + "scale": {"type": "integer"}, + "security_opt": {"$ref": "#/definitions/list_of_strings"}, + "shm_size": {"type": ["number", "string"]}, + "sysctls": {"$ref": "#/definitions/list_or_dict"}, + "pids_limit": {"type": ["number", "string"]}, + "stdin_open": {"type": "boolean"}, + "stop_grace_period": {"type": "string", "format": "duration"}, + "stop_signal": {"type": "string"}, + "storage_opt": {"type": "object"}, + "tmpfs": {"$ref": "#/definitions/string_or_list"}, + "tty": {"type": "boolean"}, + "ulimits": { + "type": "object", + "patternProperties": { + "^[a-z]+$": { + "oneOf": [ + {"type": "integer"}, + { + "type":"object", + "properties": { + "hard": {"type": "integer"}, + "soft": {"type": "integer"} + }, + "required": ["soft", "hard"], + "additionalProperties": false + } + ] + } + } + }, + "user": {"type": "string"}, + "userns_mode": {"type": "string"}, + "volumes": { + "type": "array", + "items": { + "oneOf": [ + {"type": "string"}, + { + "type": "object", + "required": ["type"], + "additionalProperties": false, + "properties": { + "type": {"type": "string"}, + "source": {"type": "string"}, + "target": {"type": "string"}, + "read_only": {"type": "boolean"}, + "consistency": {"type": "string"}, + "bind": { + "type": "object", + "properties": { + "propagation": {"type": "string"} + } + }, + "volume": { + "type": "object", + "properties": { + "nocopy": {"type": "boolean"} + } + }, + "tmpfs": { + "type": "object", + "properties": { + "size": {"type": ["integer", "string"]} + } + } + } + } + ], + "uniqueItems": true + } + }, + "volume_driver": {"type": "string"}, + "volumes_from": {"$ref": "#/definitions/list_of_strings"}, + "working_dir": {"type": "string"} + }, + + "dependencies": { + "memswap_limit": ["mem_limit"] + }, + "additionalProperties": false + }, + + "healthcheck": { + "id": "#/definitions/healthcheck", + "type": "object", + "additionalProperties": false, + "properties": { + "disable": {"type": "boolean"}, + "interval": {"type": "string"}, + "retries": {"type": "number"}, + "start_period": {"type": "string"}, + "test": { + "oneOf": [ + {"type": "string"}, + {"type": "array", "items": {"type": "string"}} + ] + }, + "timeout": {"type": "string"} + } + }, + + "network": { + "id": "#/definitions/network", + "type": "object", + "properties": { + "driver": {"type": "string"}, + "driver_opts": { + "type": "object", + "patternProperties": { + "^.+$": {"type": ["string", "number"]} + } + }, + "ipam": { + "type": "object", + "properties": { + "driver": {"type": "string"}, + "config": { + "type": "array" + }, + "options": { + "type": "object", + "patternProperties": { + "^.+$": {"type": "string"} + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "external": { + "type": ["boolean", "object"], + "properties": { + "name": {"type": "string"} + }, + "additionalProperties": false + }, + "internal": {"type": "boolean"}, + "enable_ipv6": {"type": "boolean"}, + "labels": {"$ref": "#/definitions/labels"}, + "name": {"type": "string"} + }, + "additionalProperties": false + }, + + "volume": { + "id": "#/definitions/volume", + "type": ["object", "null"], + "properties": { + "driver": {"type": "string"}, + "driver_opts": { + "type": "object", + "patternProperties": { + "^.+$": {"type": ["string", "number"]} + } + }, + "external": { + "type": ["boolean", "object"], + "properties": { + "name": {"type": "string"} + }, + "additionalProperties": false + }, + "labels": {"$ref": "#/definitions/labels"}, + "name": {"type": "string"} + }, + "additionalProperties": false + }, + + "string_or_list": { + "oneOf": [ + {"type": "string"}, + {"$ref": "#/definitions/list_of_strings"} + ] + }, + + "list_of_strings": { + "type": "array", + "items": {"type": "string"}, + "uniqueItems": true + }, + + "list_or_dict": { + "oneOf": [ + { + "type": "object", + "patternProperties": { + ".+": { + "type": ["string", "number", "null"] + } + }, + "additionalProperties": false + }, + {"type": "array", "items": {"type": "string"}, "uniqueItems": true} + ] + }, + + "labels": { + "oneOf": [ + { + "type": "object", + "patternProperties": { + ".+": { + "type": "string" + } + }, + "additionalProperties": false + }, + {"type": "array", "items": {"type": "string"}, "uniqueItems": true} + ] + }, + + "blkio_limit": { + "type": "object", + "properties": { + "path": {"type": "string"}, + "rate": {"type": ["integer", "string"]} + }, + "additionalProperties": false + }, + "blkio_weight": { + "type": "object", + "properties": { + "path": {"type": "string"}, + "weight": {"type": "integer"} + }, + "additionalProperties": false + }, + + "constraints": { + "service": { + "id": "#/definitions/constraints/service", + "anyOf": [ + {"required": ["build"]}, + {"required": ["image"]} + ], + "properties": { + "build": { + "required": ["context"] + } + } + } + } + } +} diff --git a/compose/const.py b/compose/const.py index 495539fb054..200a458a183 100644 --- a/compose/const.py +++ b/compose/const.py @@ -27,6 +27,7 @@ COMPOSEFILE_V2_1 = ComposeVersion('2.1') COMPOSEFILE_V2_2 = ComposeVersion('2.2') COMPOSEFILE_V2_3 = ComposeVersion('2.3') +COMPOSEFILE_V2_4 = ComposeVersion('2.4') COMPOSEFILE_V3_0 = ComposeVersion('3.0') COMPOSEFILE_V3_1 = ComposeVersion('3.1') @@ -42,6 +43,7 @@ COMPOSEFILE_V2_1: '1.24', COMPOSEFILE_V2_2: '1.25', COMPOSEFILE_V2_3: '1.30', + COMPOSEFILE_V2_4: '1.35', COMPOSEFILE_V3_0: '1.25', COMPOSEFILE_V3_1: '1.25', COMPOSEFILE_V3_2: '1.25', @@ -57,6 +59,7 @@ API_VERSIONS[COMPOSEFILE_V2_1]: '1.12.0', API_VERSIONS[COMPOSEFILE_V2_2]: '1.13.0', API_VERSIONS[COMPOSEFILE_V2_3]: '17.06.0', + API_VERSIONS[COMPOSEFILE_V2_4]: '17.12.0', API_VERSIONS[COMPOSEFILE_V3_0]: '1.13.0', API_VERSIONS[COMPOSEFILE_V3_1]: '1.13.0', API_VERSIONS[COMPOSEFILE_V3_2]: '1.13.0', diff --git a/compose/project.py b/compose/project.py index f66e415fe5d..afbec183fa3 100644 --- a/compose/project.py +++ b/compose/project.py @@ -77,7 +77,7 @@ def labels(self, one_off=OneOffFilter.exclude): return labels @classmethod - def from_config(cls, name, config_data, client): + def from_config(cls, name, config_data, client, default_platform=None): """ Construct a Project from a config.Config object. """ @@ -128,6 +128,7 @@ def from_config(cls, name, config_data, client): volumes_from=volumes_from, secrets=secrets, pid_mode=pid_mode, + platform=service_dict.pop('platform', default_platform), **service_dict) ) diff --git a/compose/service.py b/compose/service.py index 9e5725e0af8..bb9e26baa29 100644 --- a/compose/service.py +++ b/compose/service.py @@ -998,6 +998,12 @@ def build(self, no_cache=False, pull=False, force_rm=False, memory=None, build_a if not six.PY3 and not IS_WINDOWS_PLATFORM: path = path.encode('utf8') + platform = self.options.get('platform') + if platform and version_lt(self.client.api_version, '1.35'): + raise OperationFailedError( + 'Impossible to perform platform-targeted builds for API version < 1.35' + ) + build_output = self.client.build( path=path, tag=self.image_name, @@ -1018,6 +1024,7 @@ def build(self, no_cache=False, pull=False, force_rm=False, memory=None, build_a }, gzip=gzip, isolation=build_opts.get('isolation', self.options.get('isolation', None)), + platform=platform, ) try: @@ -1119,11 +1126,20 @@ def pull(self, ignore_pull_failures=False, silent=False): return repo, tag, separator = parse_repository_tag(self.options['image']) - tag = tag or 'latest' + kwargs = { + 'tag': tag or 'latest', + 'stream': True, + 'platform': self.options.get('platform'), + } if not silent: log.info('Pulling %s (%s%s%s)...' % (self.name, repo, separator, tag)) + + if kwargs['platform'] and version_lt(self.client.api_version, '1.35'): + raise OperationFailedError( + 'Impossible to perform platform-targeted builds for API version < 1.35' + ) try: - output = self.client.pull(repo, tag=tag, stream=True) + output = self.client.pull(repo, **kwargs) if silent: with open(os.devnull, 'w') as devnull: return progress_stream.get_digest_from_pull( diff --git a/docker-compose.spec b/docker-compose.spec index b2b4f5f18b2..b8c3a41912f 100644 --- a/docker-compose.spec +++ b/docker-compose.spec @@ -42,6 +42,11 @@ exe = EXE(pyz, 'compose/config/config_schema_v2.3.json', 'DATA' ), + ( + 'compose/config/config_schema_v2.4.json', + 'compose/config/config_schema_v2.4.json', + 'DATA' + ), ( 'compose/config/config_schema_v3.0.json', 'compose/config/config_schema_v3.0.json', diff --git a/tests/unit/project_test.py b/tests/unit/project_test.py index b4994a99e02..eb620972355 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -13,6 +13,7 @@ from compose.config.types import VolumeFromSpec from compose.const import COMPOSEFILE_V1 as V1 from compose.const import COMPOSEFILE_V2_0 as V2_0 +from compose.const import COMPOSEFILE_V2_4 as V2_4 from compose.const import LABEL_SERVICE from compose.container import Container from compose.project import NoSuchService @@ -561,3 +562,29 @@ def test_no_warning_with_no_swarm_info(self): def test_no_such_service_unicode(self): assert NoSuchService('十六夜 咲夜'.encode('utf-8')).msg == 'No such service: 十六夜 咲夜' assert NoSuchService('十六夜 咲夜').msg == 'No such service: 十六夜 咲夜' + + def test_project_platform_value(self): + service_config = { + 'name': 'web', + 'image': 'busybox:latest', + } + config_data = Config( + version=V2_4, services=[service_config], networks={}, volumes={}, secrets=None, configs=None + ) + + project = Project.from_config(name='test', client=self.mock_client, config_data=config_data) + assert project.get_service('web').options.get('platform') is None + + project = Project.from_config( + name='test', client=self.mock_client, config_data=config_data, default_platform='windows' + ) + assert project.get_service('web').options.get('platform') == 'windows' + + service_config['platform'] = 'linux/s390x' + project = Project.from_config(name='test', client=self.mock_client, config_data=config_data) + assert project.get_service('web').options.get('platform') == 'linux/s390x' + + project = Project.from_config( + name='test', client=self.mock_client, config_data=config_data, default_platform='windows' + ) + assert project.get_service('web').options.get('platform') == 'linux/s390x' diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 012bfd5e39b..4ccc48653dd 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -21,6 +21,7 @@ from compose.const import LABEL_SERVICE from compose.const import SECRETS_PATH from compose.container import Container +from compose.errors import OperationFailedError from compose.parallel import ParallelStreamWriter from compose.project import OneOffFilter from compose.service import build_ulimits @@ -400,7 +401,8 @@ def test_pull_image(self, mock_log): self.mock_client.pull.assert_called_once_with( 'someimage', tag='sometag', - stream=True) + stream=True, + platform=None) mock_log.info.assert_called_once_with('Pulling foo (someimage:sometag)...') def test_pull_image_no_tag(self): @@ -409,7 +411,8 @@ def test_pull_image_no_tag(self): self.mock_client.pull.assert_called_once_with( 'ababab', tag='latest', - stream=True) + stream=True, + platform=None) @mock.patch('compose.service.log', autospec=True) def test_pull_image_digest(self, mock_log): @@ -418,9 +421,30 @@ def test_pull_image_digest(self, mock_log): self.mock_client.pull.assert_called_once_with( 'someimage', tag='sha256:1234', - stream=True) + stream=True, + platform=None) mock_log.info.assert_called_once_with('Pulling foo (someimage@sha256:1234)...') + @mock.patch('compose.service.log', autospec=True) + def test_pull_image_with_platform(self, mock_log): + self.mock_client.api_version = '1.35' + service = Service( + 'foo', client=self.mock_client, image='someimage:sometag', platform='windows/x86_64' + ) + service.pull() + assert self.mock_client.pull.call_count == 1 + call_args = self.mock_client.pull.call_args + assert call_args[1]['platform'] == 'windows/x86_64' + + @mock.patch('compose.service.log', autospec=True) + def test_pull_image_with_platform_unsupported_api(self, mock_log): + self.mock_client.api_version = '1.33' + service = Service( + 'foo', client=self.mock_client, image='someimage:sometag', platform='linux/arm' + ) + with pytest.raises(OperationFailedError): + service.pull() + @mock.patch('compose.service.Container', autospec=True) def test_recreate_container(self, _): mock_container = mock.create_autospec(Container) @@ -513,6 +537,19 @@ def test_build_does_not_pull(self): assert self.mock_client.build.call_count == 1 assert not self.mock_client.build.call_args[1]['pull'] + def test_build_does_with_platform(self): + self.mock_client.api_version = '1.35' + self.mock_client.build.return_value = [ + b'{"stream": "Successfully built 12345"}', + ] + + service = Service('foo', client=self.mock_client, build={'context': '.'}, platform='linux') + service.build() + + assert self.mock_client.build.call_count == 1 + call_args = self.mock_client.build.call_args + assert call_args[1]['platform'] == 'linux' + def test_build_with_override_build_args(self): self.mock_client.build.return_value = [ b'{"stream": "Successfully built 12345"}', From e6420bd011d4dbb5593981fbb9ff80dda50ff98a Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 30 Mar 2018 12:41:19 -0700 Subject: [PATCH 3361/4072] Update 2.4 schema Signed-off-by: Joffrey F --- compose/config/config_schema_v2.4.json | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/compose/config/config_schema_v2.4.json b/compose/config/config_schema_v2.4.json index 7cf0c006edb..731fa2f9b08 100644 --- a/compose/config/config_schema_v2.4.json +++ b/compose/config/config_schema_v2.4.json @@ -93,7 +93,8 @@ "network": {"type": "string"}, "target": {"type": "string"}, "shm_size": {"type": ["integer", "string"]}, - "extra_hosts": {"$ref": "#/definitions/list_or_dict"} + "extra_hosts": {"$ref": "#/definitions/list_or_dict"}, + "isolation": {"type": "string"} }, "additionalProperties": false } @@ -113,6 +114,9 @@ "cpu_percent": {"type": "integer", "minimum": 0, "maximum": 100}, "cpu_shares": {"type": ["number", "string"]}, "cpu_quota": {"type": ["number", "string"]}, + "cpu_period": {"type": ["number", "string"]}, + "cpu_rt_period": {"type": ["number", "string"]}, + "cpu_rt_runtime": {"type": ["number", "string"]}, "cpus": {"type": "number", "minimum": 0}, "cpuset": {"type": "string"}, "depends_on": { @@ -219,6 +223,7 @@ "mem_swappiness": {"type": "integer"}, "memswap_limit": {"type": ["number", "string"]}, "network_mode": {"type": "string"}, + "networks": { "oneOf": [ {"$ref": "#/definitions/list_of_strings"}, @@ -258,7 +263,6 @@ }, "uniqueItems": true }, - "privileged": {"type": "boolean"}, "read_only": {"type": "boolean"}, "restart": {"type": "string"}, From 7aa51a18ff00ebc1425899c7dba643ff033e05e3 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 30 Mar 2018 18:02:06 -0700 Subject: [PATCH 3362/4072] Fix port serialization with external IP Signed-off-by: Joffrey F --- compose/config/serialize.py | 5 +++-- tests/unit/config/config_test.py | 12 ++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/compose/config/serialize.py b/compose/config/serialize.py index 7de7f41e882..c0cf35c1bd8 100644 --- a/compose/config/serialize.py +++ b/compose/config/serialize.py @@ -151,9 +151,10 @@ def denormalize_service_dict(service_dict, version, image_digest=None): service_dict['healthcheck']['start_period'] = serialize_ns_time_value( service_dict['healthcheck']['start_period'] ) - if 'ports' in service_dict and version < V3_2: + + if 'ports' in service_dict: service_dict['ports'] = [ - p.legacy_repr() if isinstance(p, types.ServicePort) else p + p.legacy_repr() if p.external_ip or version < V3_2 else p for p in service_dict['ports'] ] if 'volumes' in service_dict and (version < V2_3 or (version > V3_0 and version < V3_2)): diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 0574b215c92..8a75648ac40 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -4943,6 +4943,18 @@ def test_serialize_ports(self): serialized_config = yaml.load(serialize_config(config_dict)) assert '8080:80/tcp' in serialized_config['services']['web']['ports'] + def test_serialize_ports_with_ext_ip(self): + config_dict = config.Config(version=V3_5, services=[ + { + 'ports': [types.ServicePort('80', '8080', None, None, '127.0.0.1')], + 'image': 'alpine', + 'name': 'web' + } + ], volumes={}, networks={}, secrets={}, configs={}) + + serialized_config = yaml.load(serialize_config(config_dict)) + assert '127.0.0.1:8080:80/tcp' in serialized_config['services']['web']['ports'] + def test_serialize_configs(self): service_dict = { 'image': 'example/web', From e69b9a21ca4fd0f36f111f9a6968a3d854e0f28c Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 30 Mar 2018 18:02:06 -0700 Subject: [PATCH 3363/4072] Fix port serialization with external IP Signed-off-by: Joffrey F --- compose/config/serialize.py | 5 +++-- tests/unit/config/config_test.py | 12 ++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/compose/config/serialize.py b/compose/config/serialize.py index 7de7f41e882..c0cf35c1bd8 100644 --- a/compose/config/serialize.py +++ b/compose/config/serialize.py @@ -151,9 +151,10 @@ def denormalize_service_dict(service_dict, version, image_digest=None): service_dict['healthcheck']['start_period'] = serialize_ns_time_value( service_dict['healthcheck']['start_period'] ) - if 'ports' in service_dict and version < V3_2: + + if 'ports' in service_dict: service_dict['ports'] = [ - p.legacy_repr() if isinstance(p, types.ServicePort) else p + p.legacy_repr() if p.external_ip or version < V3_2 else p for p in service_dict['ports'] ] if 'volumes' in service_dict and (version < V2_3 or (version > V3_0 and version < V3_2)): diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 0574b215c92..8a75648ac40 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -4943,6 +4943,18 @@ def test_serialize_ports(self): serialized_config = yaml.load(serialize_config(config_dict)) assert '8080:80/tcp' in serialized_config['services']['web']['ports'] + def test_serialize_ports_with_ext_ip(self): + config_dict = config.Config(version=V3_5, services=[ + { + 'ports': [types.ServicePort('80', '8080', None, None, '127.0.0.1')], + 'image': 'alpine', + 'name': 'web' + } + ], volumes={}, networks={}, secrets={}, configs={}) + + serialized_config = yaml.load(serialize_config(config_dict)) + assert '127.0.0.1:8080:80/tcp' in serialized_config['services']['web']['ports'] + def test_serialize_configs(self): service_dict = { 'image': 'example/web', From 1d329808cc5c8b6f97a7fd23503f0a48c432f302 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 30 Mar 2018 16:04:48 -0700 Subject: [PATCH 3364/4072] Bump 1.21.0-rc1 Signed-off-by: Joffrey F --- CHANGELOG.md | 74 ++++++++++++++++++++++++++++++++++++++++++++- compose/__init__.py | 2 +- script/run/run.sh | 2 +- 3 files changed, 75 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 25e04d1b274..a8e0b8df5eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,78 @@ Change log ========== +1.21.0 (2018-04-11) +------------------- + +### New features + +#### Compose file version 2.4 + +- Introduced version 2.4 of the `docker-compose.yml` specification. + This version requires Docker Engine 17.12.0 or above. + +- Added support for the `platform` parameter in service definitions. + If supplied, the parameter is also used when performing build for the + service. + +#### Compose file version 2.2 and up + +- Added support for the `cpu_rt_period` and `cpu_rt_runtime` parameters + in service definitions (2.x only). + +#### Compose file version 2.1 and up + +- Added support for the `cpu_period` parameter in service definitions + (2.x only). + +- Added support for the `isolation` parameter in service build configurations. + Additionally, the `isolation` parameter is used for builds as well if no + `build.isolation` parameter is defined. (2.x only) + +#### All formats + +- Added support for the `--workdir` flag in `docker-compose exec`. + +- Added support for the `--compress` flag in `docker-compose build`. + +- `docker-compose pull` is now performed in parallel by default. You can + opt out using the `--no-parallel` flag. The `--parallel` flag is now + deprecated and will be removed in a future version. + +- Dashes and underscores in project names are no longer stripped out. + +- `docker-compose build` now supports the use of Dockerfile from outside + the build context. + +### Bugfixes + +- Compose now checks that the volume's configuration matches the remote + volume, and errors out if a mismatch is detected. + +- Fixed a bug that caused Compose to raise unexpected errors when attempting + to create several one-off containers in parallel. + +- Fixed a bug with argument parsing when using `docker-machine config` to + generate TLS flags for `exec` and `run` commands. + +- Fixed a bug where variable substitution with an empty default value + (e.g. `${VAR:-}`) would print an incorrect warning. + +- Improved resilience when encoding of the Compose file doesn't match the + system's. Users are encouraged to use UTF-8 when possible. + +- Fixed a bug where external overlay networks in Swarm would be incorrectly + recognized as inexistent by Compose, interrupting otherwise valid + operations. + +1.20.1 (2018-03-21) +------------------- + +### Bugfixes + +- Fixed an issue where `docker-compose build` would error out if the + build context contained directory symlinks + 1.20.0 (2018-03-20) ------------------- @@ -9,7 +81,7 @@ Change log #### Compose file version 3.6 - Introduced version 3.6 of the `docker-compose.yml` specification. - This version requires to be used with Docker Engine 18.02.0 or above. + This version requires Docker Engine 18.02.0 or above. - Added support for the `tmpfs.size` property in volume mappings diff --git a/compose/__init__.py b/compose/__init__.py index a5c1364a69b..d7ed9198c2e 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.21.0dev' +__version__ = '1.21.0-rc1' diff --git a/script/run/run.sh b/script/run/run.sh index a739edb354b..fb4cb8b4b83 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.20.0" +VERSION="1.21.0-rc1" IMAGE="docker/compose:$VERSION" From 8356576a9a090462a5469c3ca34b47ca0fafb36e Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 6 Apr 2018 17:46:23 -0700 Subject: [PATCH 3365/4072] Make sure error messages are unicode strings before combining Signed-off-by: Joffrey F --- compose/parallel.py | 11 ++++++----- compose/project.py | 6 +++++- tests/unit/project_test.py | 29 +++++++++++++++++++++++++++++ 3 files changed, 40 insertions(+), 6 deletions(-) diff --git a/compose/parallel.py b/compose/parallel.py index 5d4791f9756..a2eb160e55e 100644 --- a/compose/parallel.py +++ b/compose/parallel.py @@ -279,9 +279,7 @@ def add_object(self, msg, obj_index): def write_initial(self, msg, obj_index): if msg is None: return - self.stream.write("{:<{width}} ... \r\n".format( - msg + ' ' + obj_index, width=self.width)) - self.stream.flush() + return self._write_noansi(msg, obj_index, '') def _write_ansi(self, msg, obj_index, status): self.lock.acquire() @@ -299,8 +297,11 @@ def _write_ansi(self, msg, obj_index, status): self.lock.release() def _write_noansi(self, msg, obj_index, status): - self.stream.write("{:<{width}} ... {}\r\n".format(msg + ' ' + obj_index, - status, width=self.width)) + self.stream.write( + "{:<{width}} ... {}\r\n".format( + msg + ' ' + obj_index, status, width=self.width + ) + ) self.stream.flush() def write(self, msg, obj_index, status, color_func): diff --git a/compose/project.py b/compose/project.py index afbec183fa3..924390b4e21 100644 --- a/compose/project.py +++ b/compose/project.py @@ -556,7 +556,11 @@ def pull_service(service): limit=5, ) if len(errors): - raise ProjectError(b"\n".join(errors.values())) + combined_errors = '\n'.join([ + e.decode('utf-8') if isinstance(e, six.binary_type) else e for e in errors.values() + ]) + raise ProjectError(combined_errors) + else: for service in services: service.pull(ignore_pull_failures, silent=silent) diff --git a/tests/unit/project_test.py b/tests/unit/project_test.py index eb620972355..83a0147588e 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -5,6 +5,7 @@ import datetime import docker +import pytest from docker.errors import NotFound from .. import mock @@ -16,8 +17,10 @@ from compose.const import COMPOSEFILE_V2_4 as V2_4 from compose.const import LABEL_SERVICE from compose.container import Container +from compose.errors import OperationFailedError from compose.project import NoSuchService from compose.project import Project +from compose.project import ProjectError from compose.service import ImageType from compose.service import Service @@ -588,3 +591,29 @@ def test_project_platform_value(self): name='test', client=self.mock_client, config_data=config_data, default_platform='windows' ) assert project.get_service('web').options.get('platform') == 'linux/s390x' + + @mock.patch('compose.parallel.ParallelStreamWriter._write_noansi') + def test_error_parallel_pull(self, mock_write): + project = Project.from_config( + name='test', + client=self.mock_client, + config_data=Config( + version=V2_0, + services=[{ + 'name': 'web', + 'image': 'busybox:latest', + }], + networks=None, + volumes=None, + secrets=None, + configs=None, + ), + ) + + self.mock_client.pull.side_effect = OperationFailedError('pull error') + with pytest.raises(ProjectError): + project.pull(parallel_pull=True) + + self.mock_client.pull.side_effect = OperationFailedError(b'pull error') + with pytest.raises(ProjectError): + project.pull(parallel_pull=True) From 614bfd6fb04888f5e376b7249bf791d7e39672e7 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 6 Apr 2018 17:46:23 -0700 Subject: [PATCH 3366/4072] Make sure error messages are unicode strings before combining Signed-off-by: Joffrey F --- compose/parallel.py | 11 ++++++----- compose/project.py | 6 +++++- tests/unit/project_test.py | 29 +++++++++++++++++++++++++++++ 3 files changed, 40 insertions(+), 6 deletions(-) diff --git a/compose/parallel.py b/compose/parallel.py index 5d4791f9756..a2eb160e55e 100644 --- a/compose/parallel.py +++ b/compose/parallel.py @@ -279,9 +279,7 @@ def add_object(self, msg, obj_index): def write_initial(self, msg, obj_index): if msg is None: return - self.stream.write("{:<{width}} ... \r\n".format( - msg + ' ' + obj_index, width=self.width)) - self.stream.flush() + return self._write_noansi(msg, obj_index, '') def _write_ansi(self, msg, obj_index, status): self.lock.acquire() @@ -299,8 +297,11 @@ def _write_ansi(self, msg, obj_index, status): self.lock.release() def _write_noansi(self, msg, obj_index, status): - self.stream.write("{:<{width}} ... {}\r\n".format(msg + ' ' + obj_index, - status, width=self.width)) + self.stream.write( + "{:<{width}} ... {}\r\n".format( + msg + ' ' + obj_index, status, width=self.width + ) + ) self.stream.flush() def write(self, msg, obj_index, status, color_func): diff --git a/compose/project.py b/compose/project.py index afbec183fa3..924390b4e21 100644 --- a/compose/project.py +++ b/compose/project.py @@ -556,7 +556,11 @@ def pull_service(service): limit=5, ) if len(errors): - raise ProjectError(b"\n".join(errors.values())) + combined_errors = '\n'.join([ + e.decode('utf-8') if isinstance(e, six.binary_type) else e for e in errors.values() + ]) + raise ProjectError(combined_errors) + else: for service in services: service.pull(ignore_pull_failures, silent=silent) diff --git a/tests/unit/project_test.py b/tests/unit/project_test.py index eb620972355..83a0147588e 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -5,6 +5,7 @@ import datetime import docker +import pytest from docker.errors import NotFound from .. import mock @@ -16,8 +17,10 @@ from compose.const import COMPOSEFILE_V2_4 as V2_4 from compose.const import LABEL_SERVICE from compose.container import Container +from compose.errors import OperationFailedError from compose.project import NoSuchService from compose.project import Project +from compose.project import ProjectError from compose.service import ImageType from compose.service import Service @@ -588,3 +591,29 @@ def test_project_platform_value(self): name='test', client=self.mock_client, config_data=config_data, default_platform='windows' ) assert project.get_service('web').options.get('platform') == 'linux/s390x' + + @mock.patch('compose.parallel.ParallelStreamWriter._write_noansi') + def test_error_parallel_pull(self, mock_write): + project = Project.from_config( + name='test', + client=self.mock_client, + config_data=Config( + version=V2_0, + services=[{ + 'name': 'web', + 'image': 'busybox:latest', + }], + networks=None, + volumes=None, + secrets=None, + configs=None, + ), + ) + + self.mock_client.pull.side_effect = OperationFailedError('pull error') + with pytest.raises(ProjectError): + project.pull(parallel_pull=True) + + self.mock_client.pull.side_effect = OperationFailedError(b'pull error') + with pytest.raises(ProjectError): + project.pull(parallel_pull=True) From 5920eb082ca60c5e66fdd856b28807b0c97a33dc Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 9 Apr 2018 13:54:15 -0700 Subject: [PATCH 3367/4072] Bump 1.21.0 Signed-off-by: Joffrey F --- CHANGELOG.md | 2 +- compose/__init__.py | 2 +- script/run/run.sh | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a8e0b8df5eb..3709e263d26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ Change log ========== -1.21.0 (2018-04-11) +1.21.0 (2018-04-10) ------------------- ### New features diff --git a/compose/__init__.py b/compose/__init__.py index d7ed9198c2e..693a1ab18f5 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.21.0-rc1' +__version__ = '1.21.0' diff --git a/script/run/run.sh b/script/run/run.sh index fb4cb8b4b83..1e4bd9853e3 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.21.0-rc1" +VERSION="1.21.0" IMAGE="docker/compose:$VERSION" From 20a9ae50b00d6aeb2d68735a56549976e356e6f5 Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Wed, 11 Apr 2018 12:47:10 +0200 Subject: [PATCH 3368/4072] Refactor bash completion for services Signed-off-by: Harald Albers --- contrib/completion/bash/docker-compose | 89 +++++++++++--------------- 1 file changed, 36 insertions(+), 53 deletions(-) diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index 90c9ce5fcfb..409796f63e5 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -81,41 +81,24 @@ __docker_compose_nospace() { type compopt &>/dev/null && compopt -o nospace } -# Extracts all service names from the compose file. -___docker_compose_all_services_in_compose_file() { - __docker_compose_q config --services -} - -# All services, even those without an existing container -__docker_compose_services_all() { - COMPREPLY=( $(compgen -W "$(___docker_compose_all_services_in_compose_file)" -- "$cur") ) -} -# All services that are defined by a Dockerfile reference -__docker_compose_services_from_build() { - COMPREPLY=( $(compgen -W "$(__docker_compose_q ps --services --filter "source=build")" -- "$cur") ) +# Outputs a list of all defined services, regardless of their running state. +# Arguments for `docker-compose ps` may be passed in order to filter the service list, +# e.g. `status=running`. +__docker_compose_services() { + __docker_compose_q ps --services "$@" } -# All services that are defined by an image -__docker_compose_services_from_image() { - COMPREPLY=( $(compgen -W "$(__docker_compose_q ps --services --filter "source=image")" -- "$cur") ) -} - -# The services for which at least one paused container exists -__docker_compose_services_paused() { - names=$(__docker_compose_q ps --services --filter "status=paused") - COMPREPLY=( $(compgen -W "$names" -- "$cur") ) +# Applies completion of services based on the current value of `$cur`. +# Arguments for `docker-compose ps` may be passed in order to filter the service list, +# see `__docker_compose_services`. +__docker_compose_complete_services() { + COMPREPLY=( $(compgen -W "$(__docker_compose_services "$@")" -- "$cur") ) } # The services for which at least one running container exists -__docker_compose_services_running() { - names=$(__docker_compose_q ps --services --filter "status=running") - COMPREPLY=( $(compgen -W "$names" -- "$cur") ) -} - -# The services for which at least one stopped container exists -__docker_compose_services_stopped() { - names=$(__docker_compose_q ps --services --filter "status=stopped") +__docker_compose_complete_running_services() { + local names=$(__docker_compose_complete_services --filter status=running) COMPREPLY=( $(compgen -W "$names" -- "$cur") ) } @@ -134,7 +117,7 @@ _docker_compose_build() { COMPREPLY=( $( compgen -W "--build-arg --force-rm --help --memory --no-cache --pull" -- "$cur" ) ) ;; *) - __docker_compose_services_from_build + __docker_compose_complete_services --filter source=build ;; esac } @@ -163,7 +146,7 @@ _docker_compose_create() { COMPREPLY=( $( compgen -W "--build --force-recreate --help --no-build --no-recreate" -- "$cur" ) ) ;; *) - __docker_compose_services_all + __docker_compose_complete_services ;; esac } @@ -234,7 +217,7 @@ _docker_compose_events() { COMPREPLY=( $( compgen -W "--help --json" -- "$cur" ) ) ;; *) - __docker_compose_services_all + __docker_compose_complete_services ;; esac } @@ -252,7 +235,7 @@ _docker_compose_exec() { COMPREPLY=( $( compgen -W "-d --detach --help --index --privileged -T --user -u" -- "$cur" ) ) ;; *) - __docker_compose_services_running + __docker_compose_complete_running_services ;; esac } @@ -268,7 +251,7 @@ _docker_compose_images() { COMPREPLY=( $( compgen -W "--help --quiet -q" -- "$cur" ) ) ;; *) - __docker_compose_services_all + __docker_compose_complete_services ;; esac } @@ -286,7 +269,7 @@ _docker_compose_kill() { COMPREPLY=( $( compgen -W "--help -s" -- "$cur" ) ) ;; *) - __docker_compose_services_running + __docker_compose_complete_running_services ;; esac } @@ -304,7 +287,7 @@ _docker_compose_logs() { COMPREPLY=( $( compgen -W "--follow -f --help --no-color --tail --timestamps -t" -- "$cur" ) ) ;; *) - __docker_compose_services_all + __docker_compose_complete_services ;; esac } @@ -316,7 +299,7 @@ _docker_compose_pause() { COMPREPLY=( $( compgen -W "--help" -- "$cur" ) ) ;; *) - __docker_compose_services_running + __docker_compose_complete_running_services ;; esac } @@ -338,7 +321,7 @@ _docker_compose_port() { COMPREPLY=( $( compgen -W "--help --index --protocol" -- "$cur" ) ) ;; *) - __docker_compose_services_all + __docker_compose_complete_services ;; esac } @@ -370,7 +353,7 @@ _docker_compose_ps() { COMPREPLY=( $( compgen -W "--help --quiet -q --services --filter" -- "$cur" ) ) ;; *) - __docker_compose_services_all + __docker_compose_complete_services ;; esac } @@ -382,7 +365,7 @@ _docker_compose_pull() { COMPREPLY=( $( compgen -W "--help --ignore-pull-failures --include-deps --parallel --quiet -q" -- "$cur" ) ) ;; *) - __docker_compose_services_from_image + __docker_compose_complete_services --filter source=image ;; esac } @@ -394,7 +377,7 @@ _docker_compose_push() { COMPREPLY=( $( compgen -W "--help --ignore-push-failures" -- "$cur" ) ) ;; *) - __docker_compose_services_all + __docker_compose_complete_services ;; esac } @@ -412,7 +395,7 @@ _docker_compose_restart() { COMPREPLY=( $( compgen -W "--help --timeout -t" -- "$cur" ) ) ;; *) - __docker_compose_services_running + __docker_compose_complete_running_services ;; esac } @@ -425,9 +408,9 @@ _docker_compose_rm() { ;; *) if __docker_compose_has_option "--stop|-s" ; then - __docker_compose_services_all + __docker_compose_complete_services else - __docker_compose_services_stopped + __docker_compose_complete_services --filter status=stopped fi ;; esac @@ -451,7 +434,7 @@ _docker_compose_run() { COMPREPLY=( $( compgen -W "--detach -d --entrypoint -e --help --label -l --name --no-deps --publish -p --rm --service-ports -T --use-aliases --user -u --volume -v --workdir -w" -- "$cur" ) ) ;; *) - __docker_compose_services_all + __docker_compose_complete_services ;; esac } @@ -473,7 +456,7 @@ _docker_compose_scale() { COMPREPLY=( $( compgen -W "--help --timeout -t" -- "$cur" ) ) ;; *) - COMPREPLY=( $(compgen -S "=" -W "$(___docker_compose_all_services_in_compose_file)" -- "$cur") ) + COMPREPLY=( $(compgen -S "=" -W "$(__docker_compose_services)" -- "$cur") ) __docker_compose_nospace ;; esac @@ -486,7 +469,7 @@ _docker_compose_start() { COMPREPLY=( $( compgen -W "--help" -- "$cur" ) ) ;; *) - __docker_compose_services_stopped + __docker_compose_complete_services --filter status=stopped ;; esac } @@ -504,7 +487,7 @@ _docker_compose_stop() { COMPREPLY=( $( compgen -W "--help --timeout -t" -- "$cur" ) ) ;; *) - __docker_compose_services_running + __docker_compose_complete_running_services ;; esac } @@ -516,7 +499,7 @@ _docker_compose_top() { COMPREPLY=( $( compgen -W "--help" -- "$cur" ) ) ;; *) - __docker_compose_services_running + __docker_compose_complete_running_services ;; esac } @@ -528,7 +511,7 @@ _docker_compose_unpause() { COMPREPLY=( $( compgen -W "--help" -- "$cur" ) ) ;; *) - __docker_compose_services_paused + __docker_compose_complete_services --filter status=paused ;; esac } @@ -541,11 +524,11 @@ _docker_compose_up() { return ;; --exit-code-from) - __docker_compose_services_all + __docker_compose_complete_services return ;; --scale) - COMPREPLY=( $(compgen -S "=" -W "$(___docker_compose_all_services_in_compose_file)" -- "$cur") ) + COMPREPLY=( $(compgen -S "=" -W "$(__docker_compose_services)" -- "$cur") ) __docker_compose_nospace return ;; @@ -559,7 +542,7 @@ _docker_compose_up() { COMPREPLY=( $( compgen -W "--abort-on-container-exit --always-recreate-deps --build -d --detach --exit-code-from --force-recreate --help --no-build --no-color --no-deps --no-recreate --no-start --renew-anon-volumes -V --remove-orphans --scale --timeout -t" -- "$cur" ) ) ;; *) - __docker_compose_services_all + __docker_compose_complete_services ;; esac } From ca396bac6d7220fe61e4255078f85ba4d9d02cfd Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Thu, 12 Apr 2018 08:52:20 +0200 Subject: [PATCH 3369/4072] Add support for features added in 1.21.0 to bash completion - add support for `docker-compose exec --workdir|-w` - add support for `docker-compose build --compress` - add support for `docker-compose pull --no-parallel`, drop deprecated option `--parallel` Signed-off-by: Harald Albers --- contrib/completion/bash/docker-compose | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index 90c9ce5fcfb..0713486d306 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -131,7 +131,7 @@ _docker_compose_build() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "--build-arg --force-rm --help --memory --no-cache --pull" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--build-arg --compress --force-rm --help --memory --no-cache --pull" -- "$cur" ) ) ;; *) __docker_compose_services_from_build @@ -242,14 +242,14 @@ _docker_compose_events() { _docker_compose_exec() { case "$prev" in - --index|--user|-u) + --index|--user|-u|--workdir|-w) return ;; esac case "$cur" in -*) - COMPREPLY=( $( compgen -W "-d --detach --help --index --privileged -T --user -u" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "-d --detach --help --index --privileged -T --user -u --workdir -w" -- "$cur" ) ) ;; *) __docker_compose_services_running @@ -379,7 +379,7 @@ _docker_compose_ps() { _docker_compose_pull() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "--help --ignore-pull-failures --include-deps --parallel --quiet -q" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--help --ignore-pull-failures --include-deps --no-parallel --quiet -q" -- "$cur" ) ) ;; *) __docker_compose_services_from_image From 7078c8740a999c794d7eefa38b7077ee72f2012c Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 12 Apr 2018 11:31:44 -0700 Subject: [PATCH 3370/4072] Bump 1.22.0 dev 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 a5c1364a69b..5eb2efd03bf 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.21.0dev' +__version__ = '1.22.0dev' From fc923c3580f88f3743d9b0bd91cae7ae64e6a2e1 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 17 Apr 2018 12:13:51 -0700 Subject: [PATCH 3371/4072] Update .gitignore Signed-off-by: Joffrey F --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index ef04ca15fa9..11266c2e39f 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ compose/GITSHA *.swo *.swp .DS_Store +.cache From b1c831c54ae6808a9df2dac7890f5d7dbd9f8b9d Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 18 Apr 2018 16:01:52 -0700 Subject: [PATCH 3372/4072] Inital pass on comprehensive automated release script Signed-off-by: Joffrey F --- script/release/release.md.tmpl | 34 +++++ script/release/release.py | 197 +++++++++++++++++++++++++++ script/release/release/__init__.py | 0 script/release/release/bintray.py | 40 ++++++ script/release/release/const.py | 9 ++ script/release/release/downloader.py | 72 ++++++++++ script/release/release/repository.py | 161 ++++++++++++++++++++++ script/release/release/utils.py | 63 +++++++++ 8 files changed, 576 insertions(+) create mode 100644 script/release/release.md.tmpl create mode 100755 script/release/release.py create mode 100644 script/release/release/__init__.py create mode 100644 script/release/release/bintray.py create mode 100644 script/release/release/const.py create mode 100644 script/release/release/downloader.py create mode 100644 script/release/release/repository.py create mode 100644 script/release/release/utils.py diff --git a/script/release/release.md.tmpl b/script/release/release.md.tmpl new file mode 100644 index 00000000000..ee97ef104ea --- /dev/null +++ b/script/release/release.md.tmpl @@ -0,0 +1,34 @@ +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)**. + +Docker for Mac and Windows will automatically install the latest version of Docker Engine for you. + +Alternatively, you can use the usual commands to install or upgrade Compose: + +``` +curl -L https://github.com/docker/compose/releases/download/{{version}}/docker-compose-`uname -s`-`uname -m` -o /usr/local/bin/docker-compose +chmod +x /usr/local/bin/docker-compose +``` + +See the [install docs](https://docs.docker.com/compose/install/) for more install options and instructions. + +## Compose file format compatibility matrix + +| Compose file format | Docker Engine | +| --- | --- | +{% for engine, formats in compat_matrix.items() -%} +| {% for format in formats %}{{format}}{% if not loop.last %}, {% endif %}{% endfor %} | {{engine}}+ | +{% endfor -%} + +## Changes + +{{changelog}} + +Thanks to {% for name in contributors %}@{{name}}{% if not loop.last %}, {% endif %}{% endfor %} for contributing to this release! + +## Integrity check + +Binary name | SHA-256 sum +| --- | --- | +{% for filename, sha in integrity.items() -%} +| `{{filename}}` | `{{sha[1]}}` | +{% endfor -%} diff --git a/script/release/release.py b/script/release/release.py new file mode 100755 index 00000000000..f23146288d3 --- /dev/null +++ b/script/release/release.py @@ -0,0 +1,197 @@ +from __future__ import absolute_import +from __future__ import print_function +from __future__ import unicode_literals + +import argparse +import os +import sys +import time + +from jinja2 import Template +from release.bintray import BintrayAPI +from release.const import BINTRAY_ORG +from release.const import NAME +from release.const import REPO_ROOT +from release.downloader import BinaryDownloader +from release.repository import get_contributors +from release.repository import Repository +from release.repository import upload_assets +from release.utils import branch_name +from release.utils import compatibility_matrix +from release.utils import read_release_notes_from_changelog +from release.utils import ScriptError +from release.utils import update_init_py_version +from release.utils import update_run_sh_version + + +def create_initial_branch(repository, release, base, bintray_user): + release_branch = repository.create_release_branch(release, base) + print('Updating version info in __init__.py and run.sh') + update_run_sh_version(release) + update_init_py_version(release) + + input('Please add the release notes to the CHANGELOG.md file, then press Enter to continue.') + proceed = '' + while proceed.lower() != 'y': + print(repository.diff()) + proceed = input('Are these changes ok? y/N ') + + repository.create_bump_commit(release_branch, release) + repository.push_branch_to_remote(release_branch) + + bintray_api = BintrayAPI(os.environ['BINTRAY_TOKEN'], bintray_user) + print('Creating data repository {} on bintray'.format(release_branch.name)) + bintray_api.create_repository(BINTRAY_ORG, release_branch.name, 'generic') + + +def monitor_pr_status(pr_data): + print('Waiting for CI to complete...') + last_commit = pr_data.get_commits().reversed[0] + while True: + status = last_commit.get_combined_status() + if status.state == 'pending': + summary = { + 'pending': 0, + 'success': 0, + 'failure': 0, + } + for detail in status.statuses: + summary[detail.state] += 1 + print('{pending} pending, {success} successes, {failure} failures'.format(**summary)) + if status.total_count == 0: + # Mostly for testing purposes against repos with no CI setup + return True + time.sleep(30) + elif status.state == 'success': + print('{} successes: all clear!'.format(status.total_count)) + return True + else: + raise ScriptError('CI failure detected') + + +def create_release_draft(repository, version, pr_data, files): + print('Creating Github release draft') + with open(os.path.join(os.path.dirname(__file__), 'release.md.tmpl'), 'r') as f: + template = Template(f.read()) + print('Rendering release notes based on template') + release_notes = template.render( + version=version, + compat_matrix=compatibility_matrix(), + integrity=files, + contributors=get_contributors(pr_data), + changelog=read_release_notes_from_changelog(), + ) + gh_release = repository.create_release( + version, release_notes, draft=True, prerelease='-rc' in version, + target_commitish='release' + ) + print('Release draft initialized') + return gh_release + + +def resume(args): + raise NotImplementedError() + try: + repository = Repository(REPO_ROOT, args.repo or NAME) + br_name = branch_name(args.release) + if not repository.branch_exists(br_name): + raise ScriptError('No local branch exists for this release.') + # release_branch = repository.checkout_branch(br_name) + except ScriptError as e: + print(e) + return 1 + return 0 + + +def cancel(args): + try: + repository = Repository(REPO_ROOT, args.repo or NAME) + repository.close_release_pr(args.release) + repository.remove_release(args.release) + repository.remove_bump_branch(args.release) + # TODO: uncomment after testing is complete + # bintray_api = BintrayAPI(os.environ['BINTRAY_TOKEN'], args.bintray_user) + # print('Removing Bintray data repository for {}'.format(args.release)) + # bintray_api.delete_repository(BINTRAY_ORG, branch_name(args.release)) + except ScriptError as e: + print(e) + return 1 + print('Release cancellation complete.') + return 0 + + +def start(args): + try: + repository = Repository(REPO_ROOT, args.repo or NAME) + create_initial_branch(repository, args.release, args.base, args.bintray_user) + pr_data = repository.create_release_pull_request(args.release) + monitor_pr_status(pr_data) + downloader = BinaryDownloader(args.destination) + files = downloader.download_all(args.release) + gh_release = create_release_draft(repository, args.release, pr_data, files) + upload_assets(gh_release, files) + except ScriptError as e: + print(e) + return 1 + + return 0 + + +def main(): + if 'GITHUB_TOKEN' not in os.environ: + print('GITHUB_TOKEN environment variable must be set') + return 1 + + if 'BINTRAY_TOKEN' not in os.environ: + print('BINTRAY_TOKEN environment variable must be set') + return 1 + + parser = argparse.ArgumentParser( + description='Orchestrate a new release of docker/compose. This tool assumes that you have' + 'obtained a Github API token and Bintray API key and set the GITHUB_TOKEN and' + 'BINTRAY_TOKEN environment variables accordingly.', + epilog='''Example uses: + * Start a new feature release (includes all changes currently in master) + release.py -b user start 1.23.0 + * Start a new patch release + release.py -b user --patch 1.21.0 start 1.21.1 + * Cancel / rollback an existing release draft + release.py -b user cancel 1.23.0 + * Restart a previously aborted patch release + release.py -b user -p 1.21.0 resume 1.21.1 + ''', formatter_class=argparse.RawTextHelpFormatter) + parser.add_argument( + 'action', choices=['start', 'resume', 'cancel'], + help='The action to be performed for this release' + ) + parser.add_argument('release', help='Release number, e.g. 1.9.0-rc1, 2.1.1') + parser.add_argument( + '--patch', '-p', dest='base', + help='Which version is being patched by this release' + ) + parser.add_argument( + '--repo', '-r', dest='repo', + help='Start a release for the given repo (default: {})'.format(NAME) + ) + parser.add_argument( + '-b', dest='bintray_user', required=True, metavar='USER', + help='Username associated with the Bintray API key' + ) + parser.add_argument( + '--destination', '-o', metavar='DIR', default='binaries', + help='Directory where release binaries will be downloaded relative to the project root' + ) + args = parser.parse_args() + + if args.action == 'start': + return start(args) + elif args.action == 'resume': + return resume(args) + elif args.action == 'cancel': + return cancel(args) + print('Unexpected action "{}"'.format(args.action), file=sys.stderr) + return 1 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/script/release/release/__init__.py b/script/release/release/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/script/release/release/bintray.py b/script/release/release/bintray.py new file mode 100644 index 00000000000..d99d372c686 --- /dev/null +++ b/script/release/release/bintray.py @@ -0,0 +1,40 @@ +from __future__ import absolute_import +from __future__ import unicode_literals + +import json + +import requests + +from .const import NAME + + +class BintrayAPI(requests.Session): + def __init__(self, api_key, user, *args, **kwargs): + super(BintrayAPI, self).__init__(*args, **kwargs) + self.auth = (user, api_key) + self.base_url = 'https://api.bintray.com/' + + def create_repository(self, subject, repo_name, repo_type='generic'): + url = '{base}/repos/{subject}/{repo_name}'.format( + base=self.base_url, subject=subject, repo_name=repo_name, + ) + data = { + 'name': repo_name, + 'type': repo_type, + 'private': False, + 'desc': 'Automated release for {}: {}'.format(NAME, repo_name), + 'labels': ['docker-compose', 'docker', 'release-bot'], + } + return self.post_json(url, data) + + def delete_repository(self, subject, repo_name): + url = '{base}/repos/{subject}/{repo_name}'.format( + base=self.base_url, subject=subject, repo_name=repo_name, + ) + return self.delete(url) + + def post_json(self, url, data, **kwargs): + if 'headers' not in kwargs: + kwargs['headers'] = {} + kwargs['headers']['Content-Type'] = 'application/json' + return self.post(url, data=json.dumps(data), **kwargs) diff --git a/script/release/release/const.py b/script/release/release/const.py new file mode 100644 index 00000000000..34f338a8980 --- /dev/null +++ b/script/release/release/const.py @@ -0,0 +1,9 @@ +from __future__ import absolute_import +from __future__ import unicode_literals + +import os + + +REPO_ROOT = os.path.join(os.path.dirname(__file__), '..', '..', '..') +NAME = 'shin-/compose' +BINTRAY_ORG = 'shin-compose' diff --git a/script/release/release/downloader.py b/script/release/release/downloader.py new file mode 100644 index 00000000000..cd43bc993bf --- /dev/null +++ b/script/release/release/downloader.py @@ -0,0 +1,72 @@ +from __future__ import absolute_import +from __future__ import print_function +from __future__ import unicode_literals + +import hashlib +import os + +import requests + +from .const import BINTRAY_ORG +from .const import NAME +from .const import REPO_ROOT +from .utils import branch_name + + +class BinaryDownloader(requests.Session): + base_bintray_url = 'https://dl.bintray.com/{}'.format(BINTRAY_ORG) + base_appveyor_url = 'https://ci.appveyor.com/api/projects/{}/artifacts/'.format(NAME) + + def __init__(self, destination, *args, **kwargs): + super(BinaryDownloader, self).__init__(*args, **kwargs) + self.destination = destination + os.makedirs(self.destination, exist_ok=True) + + def download_from_bintray(self, repo_name, filename): + print('Downloading {} from bintray'.format(filename)) + url = '{base}/{repo_name}/{filename}'.format( + base=self.base_bintray_url, repo_name=repo_name, filename=filename + ) + full_dest = os.path.join(REPO_ROOT, self.destination, filename) + return self._download(url, full_dest) + + def download_from_appveyor(self, branch_name, filename): + print('Downloading {} from appveyor'.format(filename)) + url = '{base}/dist%2F{filename}?branch={branch_name}'.format( + base=self.base_appveyor_url, filename=filename, branch_name=branch_name + ) + full_dest = os.path.join(REPO_ROOT, self.destination, filename) + return self.download(url, full_dest) + + def _download(self, url, full_dest): + m = hashlib.sha256() + with open(full_dest, 'wb') as f: + r = self.get(url, stream=True) + for chunk in r.iter_content(chunk_size=1024 * 600, decode_unicode=False): + print('.', end='', flush=True) + m.update(chunk) + f.write(chunk) + + print(' download complete') + hex_digest = m.hexdigest() + with open(full_dest + '.sha256', 'w') as f: + f.write('{} {}\n'.format(hex_digest, os.path.basename(full_dest))) + return full_dest, hex_digest + + def download_all(self, version): + files = { + 'docker-compose-Darwin-x86_64': None, + 'docker-compose-Linux-x86_64': None, + # 'docker-compose-Windows-x86_64.exe': None, + } + + for filename in files.keys(): + if 'Windows' in filename: + files[filename] = self.download_from_appveyor( + branch_name(version), filename + ) + else: + files[filename] = self.download_from_bintray( + branch_name(version), filename + ) + return files diff --git a/script/release/release/repository.py b/script/release/release/repository.py new file mode 100644 index 00000000000..77c697a9961 --- /dev/null +++ b/script/release/release/repository.py @@ -0,0 +1,161 @@ +from __future__ import absolute_import +from __future__ import unicode_literals + +import os + +from git import GitCommandError +from git import Repo +from github import Github + +from .const import NAME +from .const import REPO_ROOT +from .utils import branch_name +from .utils import read_release_notes_from_changelog +from .utils import ScriptError + + +class Repository(object): + def __init__(self, root=None, gh_name=None): + if root is None: + root = REPO_ROOT + if gh_name is None: + gh_name = NAME + self.git_repo = Repo(root) + self.gh_client = Github(os.environ['GITHUB_TOKEN']) + self.gh_repo = self.gh_client.get_repo(gh_name) + + def create_release_branch(self, version, base=None): + print('Creating release branch {} based on {}...'.format(version, base or 'master')) + remote = self.find_remote(self.gh_repo.full_name) + remote.fetch() + if self.branch_exists(branch_name(version)): + raise ScriptError( + "Branch {} already exists locally. " + "Please remove it before running the release script.".format(branch_name(version)) + ) + if base is not None: + base = self.git_repo.tag('refs/tags/{}'.format(base)) + else: + base = 'refs/remotes/{}/master'.format(remote.name) + release_branch = self.git_repo.create_head(branch_name(version), commit=base) + release_branch.checkout() + self.git_repo.git.merge('--strategy=ours', '--no-edit', '{}/release'.format(remote.name)) + with release_branch.config_writer() as cfg: + cfg.set_value('release', version) + return release_branch + + def find_remote(self, remote_name=None): + if not remote_name: + remote_name = self.gh_repo.full_name + for remote in self.git_repo.remotes: + for url in remote.urls: + if remote_name in url: + return remote + return None + + def create_bump_commit(self, bump_branch, version): + print('Creating bump commit...') + bump_branch.checkout() + self.git_repo.git.commit('-a', '-s', '-m "Bump {}"'.format(version), '--no-verify') + + def diff(self): + return self.git_repo.git.diff() + + def checkout_branch(self, name): + return self.git_repo.branches[name].checkout() + + def push_branch_to_remote(self, branch, remote_name=None): + print('Pushing branch {} to remote...'.format(branch.name)) + remote = self.find_remote(remote_name) + remote.push(refspec=branch) + + def branch_exists(self, name): + return name in [h.name for h in self.git_repo.heads] + + def create_release_pull_request(self, version): + return self.gh_repo.create_pull( + title='Bump {}'.format(version), + body='Automated release for docker-compose {}\n\n{}'.format( + version, read_release_notes_from_changelog() + ), + base='release', + head=branch_name(version), + ) + + def create_release(self, version, release_notes, **kwargs): + return self.gh_repo.create_git_release( + tag=version, name=version, message=release_notes, **kwargs + ) + + def remove_release(self, version): + print('Removing release draft for {}'.format(version)) + releases = self.gh_repo.get_releases() + for release in releases: + if release.tag_name == version and release.title == version: + if not release.draft: + print( + 'The release at {} is no longer a draft. If you TRULY intend ' + 'to remove it, please do so manually.' + ) + continue + release.delete_release() + + def remove_bump_branch(self, version, remote_name=None): + name = branch_name(version) + if not self.branch_exists(name): + return False + print('Removing local branch "{}"'.format(name)) + if self.git_repo.active_branch.name == name: + print('Active branch is about to be deleted. Checking out to master...') + try: + self.checkout_branch('master') + except GitCommandError: + raise ScriptError( + 'Unable to checkout master. Try stashing local changes before proceeding.' + ) + self.git_repo.branches[name].delete(self.git_repo, name, force=True) + print('Removing remote branch "{}"'.format(name)) + remote = self.find_remote(remote_name) + try: + remote.push(name, delete=True) + except GitCommandError as e: + if 'remote ref does not exist' in str(e): + return False + raise ScriptError( + 'Error trying to remove remote branch: {}'.format(e) + ) + return True + + def close_release_pr(self, version): + print('Retrieving and closing release PR for {}'.format(version)) + name = branch_name(version) + open_prs = self.gh_repo.get_pulls(state='open') + count = 0 + for pr in open_prs: + if pr.head.ref == name: + print('Found matching PR #{}'.format(pr.number)) + pr.edit(state='closed') + count += 1 + if count == 0: + print('No open PR for this release branch.') + return count + + +def get_contributors(pr_data): + commits = pr_data.get_commits() + authors = {} + for commit in commits: + author = commit.author.login + authors[author] = authors.get(author, 0) + 1 + return [x[0] for x in sorted(list(authors.items()), key=lambda x: x[1])] + + +def upload_assets(gh_release, files): + print('Uploading binaries and hash sums') + for filename, filedata in files.items(): + print('Uploading {}...'.format(filename)) + gh_release.upload_asset(filedata[0], content_type='application/octet-stream') + gh_release.upload_asset('{}.sha256'.format(filedata[0]), content_type='text/plain') + gh_release.upload_asset( + os.path.join(REPO_ROOT, 'script', 'run', 'run.sh'), content_type='text/plain' + ) diff --git a/script/release/release/utils.py b/script/release/release/utils.py new file mode 100644 index 00000000000..b0e1f6a8487 --- /dev/null +++ b/script/release/release/utils.py @@ -0,0 +1,63 @@ +from __future__ import absolute_import +from __future__ import unicode_literals + +import os +import re + +from .const import REPO_ROOT +from compose import const as compose_const + +section_header_re = re.compile(r'^[0-9]+\.[0-9]+\.[0-9]+ \([0-9]{4}-[01][0-9]-[0-3][0-9]\)$') + + +class ScriptError(Exception): + pass + + +def branch_name(version): + return 'bump-{}'.format(version) + + +def read_release_notes_from_changelog(): + with open(os.path.join(REPO_ROOT, 'CHANGELOG.md'), 'r') as f: + lines = f.readlines() + i = 0 + while i < len(lines): + if section_header_re.match(lines[i]): + break + i += 1 + + j = i + 1 + while j < len(lines): + if section_header_re.match(lines[j]): + break + j += 1 + + return ''.join(lines[i + 2:j - 1]) + + +def update_init_py_version(version): + path = os.path.join(REPO_ROOT, 'compose', '__init__.py') + with open(path, 'r') as f: + contents = f.read() + contents = re.sub(r"__version__ = '[0-9a-z.-]+'", "__version__ = '{}'".format(version), contents) + with open(path, 'w') as f: + f.write(contents) + + +def update_run_sh_version(version): + path = os.path.join(REPO_ROOT, 'script', 'run', 'run.sh') + with open(path, 'r') as f: + contents = f.read() + contents = re.sub(r'VERSION="[0-9a-z.-]+"', 'VERSION="{}"'.format(version), contents) + with open(path, 'w') as f: + f.write(contents) + + +def compatibility_matrix(): + result = {} + for engine_version in compose_const.API_VERSION_TO_ENGINE_VERSION.values(): + result[engine_version] = [] + for fmt, api_version in compose_const.API_VERSIONS.items(): + result[compose_const.API_VERSION_TO_ENGINE_VERSION[api_version]].append(fmt.vstring) + return result From ae6dd8a93c22ddda0a193ae1935b4b1df0300f08 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 18 Apr 2018 16:58:24 -0700 Subject: [PATCH 3373/4072] Implement resuming a release Signed-off-by: Joffrey F --- script/release/release.py | 52 ++++++++++++++++++++++++---- script/release/release/repository.py | 21 ++++++++++- 2 files changed, 65 insertions(+), 8 deletions(-) diff --git a/script/release/release.py b/script/release/release.py index f23146288d3..aa6c6198be0 100755 --- a/script/release/release.py +++ b/script/release/release.py @@ -26,6 +26,12 @@ def create_initial_branch(repository, release, base, bintray_user): release_branch = repository.create_release_branch(release, base) + return create_bump_commit(repository, release_branch, bintray_user) + + +def create_bump_commit(repository, release_branch, bintray_user): + with release_branch.config_reader() as cfg: + release = cfg.get('release') print('Updating version info in __init__.py and run.sh') update_run_sh_version(release) update_init_py_version(release) @@ -36,7 +42,8 @@ def create_initial_branch(repository, release, base, bintray_user): print(repository.diff()) proceed = input('Are these changes ok? y/N ') - repository.create_bump_commit(release_branch, release) + if repository.diff(): + repository.create_bump_commit(release_branch, release) repository.push_branch_to_remote(release_branch) bintray_api = BintrayAPI(os.environ['BINTRAY_TOKEN'], bintray_user) @@ -89,17 +96,48 @@ def create_release_draft(repository, version, pr_data, files): return gh_release +def print_final_instructions(gh_release): + print(""" +You're almost done! The following steps should be executed after you've +verified that everything is in order and are ready to make the release public: +1. +2. +3.""") + + def resume(args): - raise NotImplementedError() try: repository = Repository(REPO_ROOT, args.repo or NAME) br_name = branch_name(args.release) if not repository.branch_exists(br_name): raise ScriptError('No local branch exists for this release.') - # release_branch = repository.checkout_branch(br_name) + release_branch = repository.checkout_branch(br_name) + create_bump_commit(repository, release_branch, args.bintray_user) + pr_data = repository.find_release_pr(args.release) + if not pr_data: + pr_data = repository.create_release_pull_request(args.release) + monitor_pr_status(pr_data) + downloader = BinaryDownloader(args.destination) + files = downloader.download_all(args.release) + gh_release = repository.find_release(args.release) + if not gh_release: + gh_release = create_release_draft(repository, args.release, pr_data, files) + elif not gh_release.draft: + print('WARNING!! Found non-draft (public) release for this version!') + proceed = input( + 'Are you sure you wish to proceed? Modifying an already ' + 'released version is dangerous! y/N' + ) + if proceed.lower() != 'y': + raise ScriptError('Aborting release') + for asset in gh_release.get_assets(): + asset.delete_asset() + upload_assets(gh_release, files) except ScriptError as e: print(e) return 1 + + print_final_instructions(gh_release) return 0 @@ -134,6 +172,7 @@ def start(args): print(e) return 1 + print_final_instructions(gh_release) return 0 @@ -147,8 +186,8 @@ def main(): return 1 parser = argparse.ArgumentParser( - description='Orchestrate a new release of docker/compose. This tool assumes that you have' - 'obtained a Github API token and Bintray API key and set the GITHUB_TOKEN and' + description='Orchestrate a new release of docker/compose. This tool assumes that you have ' + 'obtained a Github API token and Bintray API key and set the GITHUB_TOKEN and ' 'BINTRAY_TOKEN environment variables accordingly.', epilog='''Example uses: * Start a new feature release (includes all changes currently in master) @@ -158,8 +197,7 @@ def main(): * Cancel / rollback an existing release draft release.py -b user cancel 1.23.0 * Restart a previously aborted patch release - release.py -b user -p 1.21.0 resume 1.21.1 - ''', formatter_class=argparse.RawTextHelpFormatter) + release.py -b user -p 1.21.0 resume 1.21.1''', formatter_class=argparse.RawTextHelpFormatter) parser.add_argument( 'action', choices=['start', 'resume', 'cancel'], help='The action to be performed for this release' diff --git a/script/release/release/repository.py b/script/release/release/repository.py index 77c697a9961..18c2dbf2c39 100644 --- a/script/release/release/repository.py +++ b/script/release/release/repository.py @@ -67,7 +67,7 @@ def checkout_branch(self, name): def push_branch_to_remote(self, branch, remote_name=None): print('Pushing branch {} to remote...'.format(branch.name)) remote = self.find_remote(remote_name) - remote.push(refspec=branch) + remote.push(refspec=branch, force=True) def branch_exists(self, name): return name in [h.name for h in self.git_repo.heads] @@ -87,6 +87,14 @@ def create_release(self, version, release_notes, **kwargs): tag=version, name=version, message=release_notes, **kwargs ) + def find_release(self, version): + print('Retrieving release draft for {}'.format(version)) + releases = self.gh_repo.get_releases() + for release in releases: + if release.tag_name == version and release.title == version: + return release + return None + def remove_release(self, version): print('Removing release draft for {}'.format(version)) releases = self.gh_repo.get_releases() @@ -126,6 +134,17 @@ def remove_bump_branch(self, version, remote_name=None): ) return True + def find_release_pr(self, version): + print('Retrieving release PR for {}'.format(version)) + name = branch_name(version) + open_prs = self.gh_repo.get_pulls(state='open') + for pr in open_prs: + if pr.head.ref == name: + print('Found matching PR #{}'.format(pr.number)) + return pr + print('No open PR for this release branch.') + return None + def close_release_pr(self, version): print('Retrieving and closing release PR for {}'.format(version)) name = branch_name(version) From 6a710405141a9be99da1d3ca4facca8535669d8e Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 18 Apr 2018 17:02:15 -0700 Subject: [PATCH 3374/4072] Temp test Signed-off-by: Joffrey F --- script/release/release/repository.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/release/release/repository.py b/script/release/release/repository.py index 18c2dbf2c39..c84c9e1b538 100644 --- a/script/release/release/repository.py +++ b/script/release/release/repository.py @@ -36,7 +36,7 @@ def create_release_branch(self, version, base=None): if base is not None: base = self.git_repo.tag('refs/tags/{}'.format(base)) else: - base = 'refs/remotes/{}/master'.format(remote.name) + base = 'refs/remotes/{}/automated-releases'.format(remote.name) release_branch = self.git_repo.create_head(branch_name(version), commit=base) release_branch.checkout() self.git_repo.git.merge('--strategy=ours', '--no-edit', '{}/release'.format(remote.name)) From 599456378bb8af3da4d48e9d4a73c35fb2194adf Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 18 Apr 2018 17:07:41 -0700 Subject: [PATCH 3375/4072] Added logging for asset removal Signed-off-by: Joffrey F --- script/release/release.py | 4 ++-- script/release/release/repository.py | 7 +++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/script/release/release.py b/script/release/release.py index aa6c6198be0..b72fb254611 100755 --- a/script/release/release.py +++ b/script/release/release.py @@ -13,6 +13,7 @@ from release.const import NAME from release.const import REPO_ROOT from release.downloader import BinaryDownloader +from release.repository import delete_assets from release.repository import get_contributors from release.repository import Repository from release.repository import upload_assets @@ -130,8 +131,7 @@ def resume(args): ) if proceed.lower() != 'y': raise ScriptError('Aborting release') - for asset in gh_release.get_assets(): - asset.delete_asset() + delete_assets(gh_release) upload_assets(gh_release, files) except ScriptError as e: print(e) diff --git a/script/release/release/repository.py b/script/release/release/repository.py index c84c9e1b538..d7034f8bda0 100644 --- a/script/release/release/repository.py +++ b/script/release/release/repository.py @@ -178,3 +178,10 @@ def upload_assets(gh_release, files): gh_release.upload_asset( os.path.join(REPO_ROOT, 'script', 'run', 'run.sh'), content_type='text/plain' ) + + +def delete_assets(gh_release): + print('Removing previously uploaded assets') + for asset in gh_release.get_assets(): + print('Deleting asset {}'.format(asset.name)) + asset.delete_asset() From e9f6abf8f4c6de2295b28df25dd01a46b60a0741 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 18 Apr 2018 18:33:20 -0700 Subject: [PATCH 3376/4072] Add images build step and finalize placeholder Signed-off-by: Joffrey F --- script/release/release.py | 116 ++++++++++++++++++++++----- script/release/release/repository.py | 5 ++ 2 files changed, 101 insertions(+), 20 deletions(-) diff --git a/script/release/release.py b/script/release/release.py index b72fb254611..848ab9751de 100755 --- a/script/release/release.py +++ b/script/release/release.py @@ -4,9 +4,11 @@ import argparse import os +import shutil import sys import time +import docker from jinja2 import Template from release.bintray import BintrayAPI from release.const import BINTRAY_ORG @@ -77,6 +79,15 @@ def monitor_pr_status(pr_data): raise ScriptError('CI failure detected') +def check_pr_mergeable(pr_data): + if not pr_data.mergeable: + print( + 'WARNING!! PR #{} can not currently be merged. You will need to ' + 'resolve the conflicts manually before finalizing the release.'.format(pr_data.number) + ) + return pr_data.mergeable + + def create_release_draft(repository, version, pr_data, files): print('Creating Github release draft') with open(os.path.join(os.path.dirname(__file__), 'release.md.tmpl'), 'r') as f: @@ -97,13 +108,51 @@ def create_release_draft(repository, version, pr_data, files): return gh_release -def print_final_instructions(gh_release): - print(""" -You're almost done! The following steps should be executed after you've -verified that everything is in order and are ready to make the release public: -1. -2. -3.""") +def build_images(repository, files, version): + print("Building release images...") + repository.write_git_sha() + docker_client = docker.APIClient(**docker.utils.kwargs_from_env()) + distdir = os.path.join(REPO_ROOT, 'dist') + os.makedirs(distdir, exist_ok=True) + shutil.copy(files['docker-compose-Linux-x86_64'][0], distdir) + print('Building docker/compose image') + logstream = docker_client.build( + REPO_ROOT, tag='docker/compose:{}'.format(version), dockerfile='Dockerfile.run', + decode=True + ) + for chunk in logstream: + if 'error' in chunk: + raise ScriptError('Build error: {}'.format(chunk['error'])) + if 'stream' in chunk: + print(chunk['stream'], end='') + + print('Building test image (for UCP e2e)') + logstream = docker_client.build( + REPO_ROOT, tag='docker-compose-tests:tmp', decode=True + ) + for chunk in logstream: + if 'error' in chunk: + raise ScriptError('Build error: {}'.format(chunk['error'])) + if 'stream' in chunk: + print(chunk['stream'], end='') + + container = docker_client.create_container( + 'docker-compose-tests:tmp', entrypoint='tox' + ) + docker_client.commit(container, 'docker/compose-tests:latest') + docker_client.tag('docker/compose-tests:latest', 'docker/compose-tests:{}'.format(version)) + docker_client.remove_container(container, force=True) + docker_client.remove_image('docker-compose-tests:tmp', force=True) + + +def print_final_instructions(args): + print( + "You're almost done! Please verify that everything is in order and " + "you are ready to make the release public, then run the following " + "command:\n{exe} -b {user} finalize {version}".format( + exe=sys.argv[0], user=args.bintray_user, version=args.release + ) + ) def resume(args): @@ -117,6 +166,7 @@ def resume(args): pr_data = repository.find_release_pr(args.release) if not pr_data: pr_data = repository.create_release_pull_request(args.release) + check_pr_mergeable(pr_data) monitor_pr_status(pr_data) downloader = BinaryDownloader(args.destination) files = downloader.download_all(args.release) @@ -133,11 +183,12 @@ def resume(args): raise ScriptError('Aborting release') delete_assets(gh_release) upload_assets(gh_release, files) + build_images(repository, files, args.release) except ScriptError as e: print(e) return 1 - print_final_instructions(gh_release) + print_final_instructions(args) return 0 @@ -163,19 +214,50 @@ def start(args): repository = Repository(REPO_ROOT, args.repo or NAME) create_initial_branch(repository, args.release, args.base, args.bintray_user) pr_data = repository.create_release_pull_request(args.release) + check_pr_mergeable(pr_data) monitor_pr_status(pr_data) downloader = BinaryDownloader(args.destination) files = downloader.download_all(args.release) gh_release = create_release_draft(repository, args.release, pr_data, files) upload_assets(gh_release, files) + build_images(repository, files, args.release) + except ScriptError as e: + print(e) + return 1 + + print_final_instructions(args) + return 0 + + +def finalize(args): + try: + raise NotImplementedError() except ScriptError as e: print(e) return 1 - print_final_instructions(gh_release) return 0 +ACTIONS = [ + 'start', + 'cancel', + 'resume', + 'finalize', +] + +EPILOG = '''Example uses: + * Start a new feature release (includes all changes currently in master) + release.py -b user start 1.23.0 + * Start a new patch release + release.py -b user --patch 1.21.0 start 1.21.1 + * Cancel / rollback an existing release draft + release.py -b user cancel 1.23.0 + * Restart a previously aborted patch release + release.py -b user -p 1.21.0 resume 1.21.1 +''' + + def main(): if 'GITHUB_TOKEN' not in os.environ: print('GITHUB_TOKEN environment variable must be set') @@ -189,18 +271,9 @@ def main(): description='Orchestrate a new release of docker/compose. This tool assumes that you have ' 'obtained a Github API token and Bintray API key and set the GITHUB_TOKEN and ' 'BINTRAY_TOKEN environment variables accordingly.', - epilog='''Example uses: - * Start a new feature release (includes all changes currently in master) - release.py -b user start 1.23.0 - * Start a new patch release - release.py -b user --patch 1.21.0 start 1.21.1 - * Cancel / rollback an existing release draft - release.py -b user cancel 1.23.0 - * Restart a previously aborted patch release - release.py -b user -p 1.21.0 resume 1.21.1''', formatter_class=argparse.RawTextHelpFormatter) + epilog=EPILOG, formatter_class=argparse.RawTextHelpFormatter) parser.add_argument( - 'action', choices=['start', 'resume', 'cancel'], - help='The action to be performed for this release' + 'action', choices=ACTIONS, help='The action to be performed for this release' ) parser.add_argument('release', help='Release number, e.g. 1.9.0-rc1, 2.1.1') parser.add_argument( @@ -227,6 +300,9 @@ def main(): return resume(args) elif args.action == 'cancel': return cancel(args) + elif args.action == 'finalize': + return finalize(args) + print('Unexpected action "{}"'.format(args.action), file=sys.stderr) return 1 diff --git a/script/release/release/repository.py b/script/release/release/repository.py index d7034f8bda0..dc4c6c46632 100644 --- a/script/release/release/repository.py +++ b/script/release/release/repository.py @@ -159,6 +159,10 @@ def close_release_pr(self, version): print('No open PR for this release branch.') return count + def write_git_sha(self): + with open(os.path.join(REPO_ROOT, 'compose', 'GITSHA'), 'w') as f: + f.write(self.git_repo.head.commit.hexsha[:7]) + def get_contributors(pr_data): commits = pr_data.get_commits() @@ -175,6 +179,7 @@ def upload_assets(gh_release, files): print('Uploading {}...'.format(filename)) gh_release.upload_asset(filedata[0], content_type='application/octet-stream') gh_release.upload_asset('{}.sha256'.format(filedata[0]), content_type='text/plain') + print('Uploading run.sh...') gh_release.upload_asset( os.path.join(REPO_ROOT, 'script', 'run', 'run.sh'), content_type='text/plain' ) From a120759c9dfea7e77db209004967582965df8a7d Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 19 Apr 2018 14:47:04 -0700 Subject: [PATCH 3377/4072] Add finalize step Signed-off-by: Joffrey F --- script/release/release.py | 77 ++++++++++++-------------- script/release/release/images.py | 82 ++++++++++++++++++++++++++++ script/release/release/repository.py | 8 +++ 3 files changed, 125 insertions(+), 42 deletions(-) create mode 100644 script/release/release/images.py diff --git a/script/release/release.py b/script/release/release.py index 848ab9751de..92a8c1c0c61 100755 --- a/script/release/release.py +++ b/script/release/release.py @@ -4,17 +4,18 @@ import argparse import os -import shutil import sys import time +from distutils.core import run_setup -import docker +import pypandoc from jinja2 import Template from release.bintray import BintrayAPI from release.const import BINTRAY_ORG from release.const import NAME from release.const import REPO_ROOT from release.downloader import BinaryDownloader +from release.images import ImageManager from release.repository import delete_assets from release.repository import get_contributors from release.repository import Repository @@ -108,43 +109,6 @@ def create_release_draft(repository, version, pr_data, files): return gh_release -def build_images(repository, files, version): - print("Building release images...") - repository.write_git_sha() - docker_client = docker.APIClient(**docker.utils.kwargs_from_env()) - distdir = os.path.join(REPO_ROOT, 'dist') - os.makedirs(distdir, exist_ok=True) - shutil.copy(files['docker-compose-Linux-x86_64'][0], distdir) - print('Building docker/compose image') - logstream = docker_client.build( - REPO_ROOT, tag='docker/compose:{}'.format(version), dockerfile='Dockerfile.run', - decode=True - ) - for chunk in logstream: - if 'error' in chunk: - raise ScriptError('Build error: {}'.format(chunk['error'])) - if 'stream' in chunk: - print(chunk['stream'], end='') - - print('Building test image (for UCP e2e)') - logstream = docker_client.build( - REPO_ROOT, tag='docker-compose-tests:tmp', decode=True - ) - for chunk in logstream: - if 'error' in chunk: - raise ScriptError('Build error: {}'.format(chunk['error'])) - if 'stream' in chunk: - print(chunk['stream'], end='') - - container = docker_client.create_container( - 'docker-compose-tests:tmp', entrypoint='tox' - ) - docker_client.commit(container, 'docker/compose-tests:latest') - docker_client.tag('docker/compose-tests:latest', 'docker/compose-tests:{}'.format(version)) - docker_client.remove_container(container, force=True) - docker_client.remove_image('docker-compose-tests:tmp', force=True) - - def print_final_instructions(args): print( "You're almost done! Please verify that everything is in order and " @@ -183,7 +147,8 @@ def resume(args): raise ScriptError('Aborting release') delete_assets(gh_release) upload_assets(gh_release, files) - build_images(repository, files, args.release) + img_manager = ImageManager(args.release) + img_manager.build_images(repository, files, args.release) except ScriptError as e: print(e) return 1 @@ -220,7 +185,8 @@ def start(args): files = downloader.download_all(args.release) gh_release = create_release_draft(repository, args.release, pr_data, files) upload_assets(gh_release, files) - build_images(repository, files, args.release) + img_manager = ImageManager(args.release) + img_manager.build_images(repository, files) except ScriptError as e: print(e) return 1 @@ -231,7 +197,34 @@ def start(args): def finalize(args): try: - raise NotImplementedError() + repository = Repository(REPO_ROOT, args.repo or NAME) + img_manager = ImageManager(args.release) + pr_data = repository.find_release_pr(args.release) + if not pr_data: + raise ScriptError('No PR found for {}'.format(args.release)) + if not check_pr_mergeable(pr_data): + raise ScriptError('Can not finalize release with an unmergeable PR') + if not img_manager.check_images(args.release): + raise ScriptError('Missing release image') + br_name = branch_name(args.release) + if not repository.branch_exists(br_name): + raise ScriptError('No local branch exists for this release.') + gh_release = repository.find_release(args.release) + if not gh_release: + raise ScriptError('No Github release draft for this version') + + pypandoc.convert_file( + os.path.join(REPO_ROOT, 'README.md'), 'rst', outputfile=os.path.join(REPO_ROOT, 'README.rst') + ) + run_setup(os.path.join(REPO_ROOT, 'setup.py'), script_args=['sdist', 'bdist_wheel']) + + merge_status = pr_data.merge() + if not merge_status.merged: + raise ScriptError('Unable to merge PR #{}: {}'.format(pr_data.number, merge_status.message)) + print('Uploading to PyPi') + run_setup(os.path.join(REPO_ROOT, 'setup.py'), script_args=['upload']) + img_manager.push_images(args.release) + repository.publish_release(gh_release) except ScriptError as e: print(e) return 1 diff --git a/script/release/release/images.py b/script/release/release/images.py new file mode 100644 index 00000000000..0c7bb20454e --- /dev/null +++ b/script/release/release/images.py @@ -0,0 +1,82 @@ +from __future__ import absolute_import +from __future__ import print_function +from __future__ import unicode_literals + +import os +import shutil + +import docker + +from .const import REPO_ROOT +from .utils import ScriptError + + +class ImageManager(object): + def __init__(self, version): + self.docker_client = docker.APIClient(**docker.utils.kwargs_from_env()) + self.version = version + + def build_images(self, repository, files): + print("Building release images...") + repository.write_git_sha() + docker_client = docker.APIClient(**docker.utils.kwargs_from_env()) + distdir = os.path.join(REPO_ROOT, 'dist') + os.makedirs(distdir, exist_ok=True) + shutil.copy(files['docker-compose-Linux-x86_64'][0], distdir) + print('Building docker/compose image') + logstream = docker_client.build( + REPO_ROOT, tag='docker/compose:{}'.format(self.version), dockerfile='Dockerfile.run', + decode=True + ) + for chunk in logstream: + if 'error' in chunk: + raise ScriptError('Build error: {}'.format(chunk['error'])) + if 'stream' in chunk: + print(chunk['stream'], end='') + + print('Building test image (for UCP e2e)') + logstream = docker_client.build( + REPO_ROOT, tag='docker-compose-tests:tmp', decode=True + ) + for chunk in logstream: + if 'error' in chunk: + raise ScriptError('Build error: {}'.format(chunk['error'])) + if 'stream' in chunk: + print(chunk['stream'], end='') + + container = docker_client.create_container( + 'docker-compose-tests:tmp', entrypoint='tox' + ) + docker_client.commit(container, 'docker/compose-tests:latest') + docker_client.tag('docker/compose-tests:latest', 'docker/compose-tests:{}'.format(self.version)) + docker_client.remove_container(container, force=True) + docker_client.remove_image('docker-compose-tests:tmp', force=True) + + @property + def image_names(self): + return [ + 'docker/compose-tests:latest', + 'docker/compose-tests:{}'.format(self.version), + 'docker/compose:{}'.format(self.version) + ] + + def check_images(self, version): + docker_client = docker.APIClient(**docker.utils.kwargs_from_env()) + + for name in self.image_names: + try: + docker_client.inspect_image(name) + except docker.errors.ImageNotFound: + print('Expected image {} was not found'.format(name)) + return False + return True + + def push_images(self): + docker_client = docker.APIClient(**docker.utils.kwargs_from_env()) + + for name in self.image_names: + print('Pushing {} to Docker Hub'.format(name)) + logstream = docker_client.push(name, stream=True, decode=True) + for chunk in logstream: + if 'status' in chunk: + print(chunk['status']) diff --git a/script/release/release/repository.py b/script/release/release/repository.py index dc4c6c46632..122eada8a33 100644 --- a/script/release/release/repository.py +++ b/script/release/release/repository.py @@ -95,6 +95,14 @@ def find_release(self, version): return release return None + def publish_release(self, release): + release.update_release( + name=release.title, + message=release.body, + draft=False, + prerelease=release.prerelease + ) + def remove_release(self, version): print('Removing release draft for {}'.format(version)) releases = self.gh_repo.get_releases() From c49eca41a082c675fef81453ef8cf1ba62c962fd Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 19 Apr 2018 14:54:17 -0700 Subject: [PATCH 3378/4072] Avoid accidental prod push Signed-off-by: Joffrey F --- script/release/release.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/script/release/release.py b/script/release/release.py index 92a8c1c0c61..3c154012f2d 100755 --- a/script/release/release.py +++ b/script/release/release.py @@ -213,6 +213,8 @@ def finalize(args): if not gh_release: raise ScriptError('No Github release draft for this version') + repository.checkout_branch(br_name) + pypandoc.convert_file( os.path.join(REPO_ROOT, 'README.md'), 'rst', outputfile=os.path.join(REPO_ROOT, 'README.rst') ) @@ -222,8 +224,9 @@ def finalize(args): if not merge_status.merged: raise ScriptError('Unable to merge PR #{}: {}'.format(pr_data.number, merge_status.message)) print('Uploading to PyPi') - run_setup(os.path.join(REPO_ROOT, 'setup.py'), script_args=['upload']) - img_manager.push_images(args.release) + # TODO: this will do real stuff. Uncomment when done testing + # run_setup(os.path.join(REPO_ROOT, 'setup.py'), script_args=['upload']) + # img_manager.push_images(args.release) repository.publish_release(gh_release) except ScriptError as e: print(e) From e7086091be083e793e920707ee7d87a5ccaacd85 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 19 Apr 2018 15:22:55 -0700 Subject: [PATCH 3379/4072] Early check for non-draft release in resume Signed-off-by: Joffrey F --- script/release/release.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/script/release/release.py b/script/release/release.py index 3c154012f2d..06dfdad9a03 100755 --- a/script/release/release.py +++ b/script/release/release.py @@ -125,6 +125,15 @@ def resume(args): br_name = branch_name(args.release) if not repository.branch_exists(br_name): raise ScriptError('No local branch exists for this release.') + gh_release = repository.find_release(args.release) + if gh_release and not gh_release.draft: + print('WARNING!! Found non-draft (public) release for this version!') + proceed = input( + 'Are you sure you wish to proceed? Modifying an already ' + 'released version is dangerous! y/N ' + ) + if proceed.lower() != 'y': + raise ScriptError('Aborting release') release_branch = repository.checkout_branch(br_name) create_bump_commit(repository, release_branch, args.bintray_user) pr_data = repository.find_release_pr(args.release) @@ -134,17 +143,8 @@ def resume(args): monitor_pr_status(pr_data) downloader = BinaryDownloader(args.destination) files = downloader.download_all(args.release) - gh_release = repository.find_release(args.release) if not gh_release: gh_release = create_release_draft(repository, args.release, pr_data, files) - elif not gh_release.draft: - print('WARNING!! Found non-draft (public) release for this version!') - proceed = input( - 'Are you sure you wish to proceed? Modifying an already ' - 'released version is dangerous! y/N' - ) - if proceed.lower() != 'y': - raise ScriptError('Aborting release') delete_assets(gh_release) upload_assets(gh_release, files) img_manager = ImageManager(args.release) From 85115707645f7e134023a9635d5926fde7ccef8e Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 19 Apr 2018 15:24:41 -0700 Subject: [PATCH 3380/4072] Default base is master Signed-off-by: Joffrey F --- script/release/release/repository.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/release/release/repository.py b/script/release/release/repository.py index 122eada8a33..e0c581bd587 100644 --- a/script/release/release/repository.py +++ b/script/release/release/repository.py @@ -36,7 +36,7 @@ def create_release_branch(self, version, base=None): if base is not None: base = self.git_repo.tag('refs/tags/{}'.format(base)) else: - base = 'refs/remotes/{}/automated-releases'.format(remote.name) + base = 'refs/remotes/{}/master'.format(remote.name) release_branch = self.git_repo.create_head(branch_name(version), commit=base) release_branch.checkout() self.git_repo.git.merge('--strategy=ours', '--no-edit', '{}/release'.format(remote.name)) From b06bc3cdea4d45a6e3fdbe0d7e44d5bbacadf8be Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 20 Apr 2018 13:06:41 -0700 Subject: [PATCH 3381/4072] Add support for PR cherry picks Signed-off-by: Joffrey F --- script/release/release.py | 5 +++++ script/release/release/repository.py | 24 +++++++++++++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/script/release/release.py b/script/release/release.py index 06dfdad9a03..a38b2aa7d37 100755 --- a/script/release/release.py +++ b/script/release/release.py @@ -30,6 +30,11 @@ def create_initial_branch(repository, release, base, bintray_user): release_branch = repository.create_release_branch(release, base) + if base: + print('Detected patch version.') + cherries = input('Indicate PR#s to cherry-pick then press Enter:\n') + repository.cherry_pick_prs(release_branch, cherries.split()) + return create_bump_commit(repository, release_branch, bintray_user) diff --git a/script/release/release/repository.py b/script/release/release/repository.py index e0c581bd587..4fcb2712ad1 100644 --- a/script/release/release/repository.py +++ b/script/release/release/repository.py @@ -2,7 +2,9 @@ from __future__ import unicode_literals import os +import tempfile +import requests from git import GitCommandError from git import Repo from github import Github @@ -111,7 +113,7 @@ def remove_release(self, version): if not release.draft: print( 'The release at {} is no longer a draft. If you TRULY intend ' - 'to remove it, please do so manually.' + 'to remove it, please do so manually.'.format(release.url) ) continue release.delete_release() @@ -171,6 +173,26 @@ def write_git_sha(self): with open(os.path.join(REPO_ROOT, 'compose', 'GITSHA'), 'w') as f: f.write(self.git_repo.head.commit.hexsha[:7]) + def cherry_pick_prs(self, release_branch, ids): + if not ids: + return + release_branch.checkout() + for i in ids: + try: + i = int(i) + except ValueError as e: + raise ScriptError('Invalid PR id: {}'.format(e)) + print('Retrieving PR#{}'.format(i)) + pr = self.gh_repo.get_pull(i) + patch_data = requests.get(pr.patch_url).text + self.apply_patch(patch_data) + + def apply_patch(self, patch_data): + with tempfile.NamedTemporaryFile(mode='w', prefix='_compose_cherry', encoding='utf-8') as f: + f.write(patch_data) + f.flush() + self.git_repo.git.am('--3way', f.name) + def get_contributors(pr_data): commits = pr_data.get_commits() From 2b5ad06e003a714c13a5f3f86c5796d191e19bf2 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 20 Apr 2018 13:34:18 -0700 Subject: [PATCH 3382/4072] Cleanup Signed-off-by: Joffrey F --- script/release/release.py | 61 +++++++++++++++++----------- script/release/release/const.py | 4 +- script/release/release/repository.py | 11 +++-- script/release/release/utils.py | 22 ++++++++++ 4 files changed, 68 insertions(+), 30 deletions(-) diff --git a/script/release/release.py b/script/release/release.py index a38b2aa7d37..c1908f94a2c 100755 --- a/script/release/release.py +++ b/script/release/release.py @@ -26,19 +26,20 @@ from release.utils import ScriptError from release.utils import update_init_py_version from release.utils import update_run_sh_version +from release.utils import yesno -def create_initial_branch(repository, release, base, bintray_user): - release_branch = repository.create_release_branch(release, base) - if base: +def create_initial_branch(repository, args): + release_branch = repository.create_release_branch(args.release, args.base) + if args.base and args.cherries: print('Detected patch version.') - cherries = input('Indicate PR#s to cherry-pick then press Enter:\n') + cherries = input('Indicate (space-separated) PR numbers to cherry-pick then press Enter:\n') repository.cherry_pick_prs(release_branch, cherries.split()) - return create_bump_commit(repository, release_branch, bintray_user) + return create_bump_commit(repository, release_branch, args.bintray_user, args.bintray_org) -def create_bump_commit(repository, release_branch, bintray_user): +def create_bump_commit(repository, release_branch, bintray_user, bintray_org): with release_branch.config_reader() as cfg: release = cfg.get('release') print('Updating version info in __init__.py and run.sh') @@ -46,10 +47,10 @@ def create_bump_commit(repository, release_branch, bintray_user): update_init_py_version(release) input('Please add the release notes to the CHANGELOG.md file, then press Enter to continue.') - proceed = '' - while proceed.lower() != 'y': + proceed = None + while not proceed: print(repository.diff()) - proceed = input('Are these changes ok? y/N ') + proceed = yesno('Are these changes ok? y/N ', default=False) if repository.diff(): repository.create_bump_commit(release_branch, release) @@ -57,7 +58,7 @@ def create_bump_commit(repository, release_branch, bintray_user): bintray_api = BintrayAPI(os.environ['BINTRAY_TOKEN'], bintray_user) print('Creating data repository {} on bintray'.format(release_branch.name)) - bintray_api.create_repository(BINTRAY_ORG, release_branch.name, 'generic') + bintray_api.create_repository(bintray_org, release_branch.name, 'generic') def monitor_pr_status(pr_data): @@ -126,21 +127,26 @@ def print_final_instructions(args): def resume(args): try: - repository = Repository(REPO_ROOT, args.repo or NAME) + repository = Repository(REPO_ROOT, args.repo) br_name = branch_name(args.release) if not repository.branch_exists(br_name): raise ScriptError('No local branch exists for this release.') gh_release = repository.find_release(args.release) if gh_release and not gh_release.draft: print('WARNING!! Found non-draft (public) release for this version!') - proceed = input( + proceed = yesno( 'Are you sure you wish to proceed? Modifying an already ' - 'released version is dangerous! y/N ' + 'released version is dangerous! y/N ', default=False ) - if proceed.lower() != 'y': + if proceed.lower() is not True: raise ScriptError('Aborting release') + release_branch = repository.checkout_branch(br_name) - create_bump_commit(repository, release_branch, args.bintray_user) + if args.cherries: + cherries = input('Indicate (space-separated) PR numbers to cherry-pick then press Enter:\n') + repository.cherry_pick_prs(release_branch, cherries.split()) + + create_bump_commit(repository, release_branch, args.bintray_user, args.bintray_org) pr_data = repository.find_release_pr(args.release) if not pr_data: pr_data = repository.create_release_pull_request(args.release) @@ -164,14 +170,13 @@ def resume(args): def cancel(args): try: - repository = Repository(REPO_ROOT, args.repo or NAME) + repository = Repository(REPO_ROOT, args.repo) repository.close_release_pr(args.release) repository.remove_release(args.release) repository.remove_bump_branch(args.release) - # TODO: uncomment after testing is complete - # bintray_api = BintrayAPI(os.environ['BINTRAY_TOKEN'], args.bintray_user) - # print('Removing Bintray data repository for {}'.format(args.release)) - # bintray_api.delete_repository(BINTRAY_ORG, branch_name(args.release)) + bintray_api = BintrayAPI(os.environ['BINTRAY_TOKEN'], args.bintray_user) + print('Removing Bintray data repository for {}'.format(args.release)) + bintray_api.delete_repository(args.bintray_org, branch_name(args.release)) except ScriptError as e: print(e) return 1 @@ -181,8 +186,8 @@ def cancel(args): def start(args): try: - repository = Repository(REPO_ROOT, args.repo or NAME) - create_initial_branch(repository, args.release, args.base, args.bintray_user) + repository = Repository(REPO_ROOT, args.repo) + create_initial_branch(repository, args) pr_data = repository.create_release_pull_request(args.release) check_pr_mergeable(pr_data) monitor_pr_status(pr_data) @@ -202,7 +207,7 @@ def start(args): def finalize(args): try: - repository = Repository(REPO_ROOT, args.repo or NAME) + repository = Repository(REPO_ROOT, args.repo) img_manager = ImageManager(args.release) pr_data = repository.find_release_pr(args.release) if not pr_data: @@ -282,17 +287,25 @@ def main(): help='Which version is being patched by this release' ) parser.add_argument( - '--repo', '-r', dest='repo', + '--repo', '-r', dest='repo', default=NAME, help='Start a release for the given repo (default: {})'.format(NAME) ) parser.add_argument( '-b', dest='bintray_user', required=True, metavar='USER', help='Username associated with the Bintray API key' ) + parser.add_argument( + '--bintray-org', dest='bintray_org', metavar='ORG', default=BINTRAY_ORG, + help='Organization name on bintray where the data repository will be created.' + ) parser.add_argument( '--destination', '-o', metavar='DIR', default='binaries', help='Directory where release binaries will be downloaded relative to the project root' ) + parser.add_argument( + '--no-cherries', '-C', dest='cherries', action='store_false', + help='If set, the program will not prompt the user for PR numbers to cherry-pick' + ) args = parser.parse_args() if args.action == 'start': diff --git a/script/release/release/const.py b/script/release/release/const.py index 34f338a8980..5a72bde411b 100644 --- a/script/release/release/const.py +++ b/script/release/release/const.py @@ -5,5 +5,5 @@ REPO_ROOT = os.path.join(os.path.dirname(__file__), '..', '..', '..') -NAME = 'shin-/compose' -BINTRAY_ORG = 'shin-compose' +NAME = 'docker/compose' +BINTRAY_ORG = 'docker-compose' diff --git a/script/release/release/repository.py b/script/release/release/repository.py index 4fcb2712ad1..d4d1c720111 100644 --- a/script/release/release/repository.py +++ b/script/release/release/repository.py @@ -29,17 +29,20 @@ def __init__(self, root=None, gh_name=None): def create_release_branch(self, version, base=None): print('Creating release branch {} based on {}...'.format(version, base or 'master')) remote = self.find_remote(self.gh_repo.full_name) + br_name = branch_name(version) remote.fetch() - if self.branch_exists(branch_name(version)): + if self.branch_exists(br_name): raise ScriptError( - "Branch {} already exists locally. " - "Please remove it before running the release script.".format(branch_name(version)) + "Branch {} already exists locally. Please remove it before " + "running the release script, or use `resume` instead.".format( + br_name + ) ) if base is not None: base = self.git_repo.tag('refs/tags/{}'.format(base)) else: base = 'refs/remotes/{}/master'.format(remote.name) - release_branch = self.git_repo.create_head(branch_name(version), commit=base) + release_branch = self.git_repo.create_head(br_name, commit=base) release_branch.checkout() self.git_repo.git.merge('--strategy=ours', '--no-edit', '{}/release'.format(remote.name)) with release_branch.config_writer() as cfg: diff --git a/script/release/release/utils.py b/script/release/release/utils.py index b0e1f6a8487..977a0a71227 100644 --- a/script/release/release/utils.py +++ b/script/release/release/utils.py @@ -61,3 +61,25 @@ def compatibility_matrix(): for fmt, api_version in compose_const.API_VERSIONS.items(): result[compose_const.API_VERSION_TO_ENGINE_VERSION[api_version]].append(fmt.vstring) return result + + +def yesno(prompt, default=None): + """ + Prompt the user for a yes or no. + + Can optionally specify a default value, which will only be + used if they enter a blank line. + + Unrecognised input (anything other than "y", "n", "yes", + "no" or "") will return None. + """ + answer = input(prompt).strip().lower() + + if answer == "y" or answer == "yes": + return True + elif answer == "n" or answer == "no": + return False + elif answer == "": + return default + else: + return None From 6b83a651f663873ec4d43fd3e8f171f35eedcf42 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 20 Apr 2018 14:23:58 -0700 Subject: [PATCH 3383/4072] Improve monitor function Signed-off-by: Joffrey F --- script/release/release.py | 13 +++++++++---- script/release/release/downloader.py | 4 ++-- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/script/release/release.py b/script/release/release.py index c1908f94a2c..338e73af241 100755 --- a/script/release/release.py +++ b/script/release/release.py @@ -66,24 +66,29 @@ def monitor_pr_status(pr_data): last_commit = pr_data.get_commits().reversed[0] while True: status = last_commit.get_combined_status() - if status.state == 'pending': + if status.state == 'pending' or status.state == 'failure': summary = { 'pending': 0, 'success': 0, 'failure': 0, } for detail in status.statuses: + if detail.context == 'dco-signed': + # dco-signed check breaks on merge remote-tracking ; ignore it + continue summary[detail.state] += 1 print('{pending} pending, {success} successes, {failure} failures'.format(**summary)) if status.total_count == 0: # Mostly for testing purposes against repos with no CI setup return True + elif summary['pending'] == 0 and summary['failure'] == 0: + return True + elif summary['failure'] > 0: + raise ScriptError('CI failures detected!') time.sleep(30) elif status.state == 'success': print('{} successes: all clear!'.format(status.total_count)) return True - else: - raise ScriptError('CI failure detected') def check_pr_mergeable(pr_data): @@ -159,7 +164,7 @@ def resume(args): delete_assets(gh_release) upload_assets(gh_release, files) img_manager = ImageManager(args.release) - img_manager.build_images(repository, files, args.release) + img_manager.build_images(repository, files) except ScriptError as e: print(e) return 1 diff --git a/script/release/release/downloader.py b/script/release/release/downloader.py index cd43bc993bf..d92ae78b527 100644 --- a/script/release/release/downloader.py +++ b/script/release/release/downloader.py @@ -36,7 +36,7 @@ def download_from_appveyor(self, branch_name, filename): base=self.base_appveyor_url, filename=filename, branch_name=branch_name ) full_dest = os.path.join(REPO_ROOT, self.destination, filename) - return self.download(url, full_dest) + return self._download(url, full_dest) def _download(self, url, full_dest): m = hashlib.sha256() @@ -57,7 +57,7 @@ def download_all(self, version): files = { 'docker-compose-Darwin-x86_64': None, 'docker-compose-Linux-x86_64': None, - # 'docker-compose-Windows-x86_64.exe': None, + 'docker-compose-Windows-x86_64.exe': None, } for filename in files.keys(): From a752208621ef77b5ea34ffefef4230027f95ad7a Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 20 Apr 2018 15:29:37 -0700 Subject: [PATCH 3384/4072] Fix appveyor build Signed-off-by: Joffrey F --- script/build/windows.ps1 | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/script/build/windows.ps1 b/script/build/windows.ps1 index 98a74815801..1de9bbfa4a2 100644 --- a/script/build/windows.ps1 +++ b/script/build/windows.ps1 @@ -44,16 +44,10 @@ virtualenv .\venv # pip and pyinstaller generate lots of warnings, so we need to ignore them $ErrorActionPreference = "Continue" -# Install dependencies -# Fix for https://github.com/pypa/pip/issues/3964 -# Remove-Item -Recurse -Force .\venv\Lib\site-packages\pip -# .\venv\Scripts\easy_install pip==9.0.1 -# .\venv\Scripts\pip install --upgrade pip setuptools -# End fix .\venv\Scripts\pip install pypiwin32==220 .\venv\Scripts\pip install -r requirements.txt .\venv\Scripts\pip install --no-deps . -.\venv\Scripts\pip install --allow-external pyinstaller -r requirements-build.txt +.\venv\Scripts\pip install -r requirements-build.txt git rev-parse --short HEAD | out-file -encoding ASCII compose\GITSHA From eba67910f3b59f121e0832e4edb01c64b05a10b9 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 20 Apr 2018 16:21:13 -0700 Subject: [PATCH 3385/4072] Containerize release tool Signed-off-by: Joffrey F --- script/release/Dockerfile | 14 ++++++++++++++ script/release/release.sh | 25 +++++++++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 script/release/Dockerfile create mode 100755 script/release/release.sh diff --git a/script/release/Dockerfile b/script/release/Dockerfile new file mode 100644 index 00000000000..0d4ec27e1da --- /dev/null +++ b/script/release/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.6 +RUN mkdir -p /src && pip install -U Jinja2==2.10 \ + PyGithub==1.39 \ + pypandoc==1.4 \ + GitPython==2.1.9 \ + requests==2.18.4 && \ + apt-get update && apt-get install -y pandoc + +VOLUME /src/script/release +WORKDIR /src +COPY . /src +RUN python setup.py develop +ENTRYPOINT ["python", "script/release/release.py"] +CMD ["--help"] diff --git a/script/release/release.sh b/script/release/release.sh new file mode 100755 index 00000000000..2310429aaa2 --- /dev/null +++ b/script/release/release.sh @@ -0,0 +1,25 @@ +#!/bin/sh + +docker image inspect compose/release-tool > /dev/null +if test $? -ne 0; then + docker build -t compose/release-tool -f $(pwd)/script/release/Dockerfile $(pwd) +fi + +if test -z $GITHUB_TOKEN; then + echo "GITHUB_TOKEN environment variable must be set" + exit 1 +fi + +if test -z $BINTRAY_TOKEN; then + echo "BINTRAY_TOKEN environment variable must be set" + exit 1 +fi + +docker run -e GITHUB_TOKEN=$GITHUB_TOKEN -e BINTRAY_TOKEN=$BINTRAY_TOKEN -it \ + --mount type=bind,source=$(pwd),target=/src \ + --mount type=bind,source=$(pwd)/.git,target=/src/.git \ + --mount type=bind,source=$HOME/.docker,target=/root/.docker \ + --mount type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock \ + --mount type=bind,source=$HOME/.ssh,target=/root/.ssh \ + -v $HOME/.pypirc:/root/.pypirc \ + compose/release-tool $* From 62fc24eb279edaab628609bf14d910cb34d58dc6 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 20 Apr 2018 17:15:45 -0700 Subject: [PATCH 3386/4072] Uncomment deploy steps Signed-off-by: Joffrey F --- script/release/release.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/script/release/release.py b/script/release/release.py index 338e73af241..add8fb2d353 100755 --- a/script/release/release.py +++ b/script/release/release.py @@ -239,9 +239,8 @@ def finalize(args): if not merge_status.merged: raise ScriptError('Unable to merge PR #{}: {}'.format(pr_data.number, merge_status.message)) print('Uploading to PyPi') - # TODO: this will do real stuff. Uncomment when done testing - # run_setup(os.path.join(REPO_ROOT, 'setup.py'), script_args=['upload']) - # img_manager.push_images(args.release) + run_setup(os.path.join(REPO_ROOT, 'setup.py'), script_args=['upload']) + img_manager.push_images(args.release) repository.publish_release(gh_release) except ScriptError as e: print(e) From 7536c331e0cc63be834bee03007db544cf5b656d Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 23 Apr 2018 14:37:34 -0700 Subject: [PATCH 3387/4072] Document new release process Signed-off-by: Joffrey F --- project/RELEASE-PROCESS.md | 149 +----------------------------- script/release/README.md | 184 +++++++++++++++++++++++++++++++++++++ 2 files changed, 185 insertions(+), 148 deletions(-) mode change 100644 => 120000 project/RELEASE-PROCESS.md create mode 100644 script/release/README.md diff --git a/project/RELEASE-PROCESS.md b/project/RELEASE-PROCESS.md deleted file mode 100644 index d4afb87b9c2..00000000000 --- a/project/RELEASE-PROCESS.md +++ /dev/null @@ -1,148 +0,0 @@ -Building a Compose release -========================== - -## Prerequisites - -The release scripts require the following tools installed on the host: - -* https://hub.github.com/ -* https://stedolan.github.io/jq/ -* http://pandoc.org/ - -## To get started with a new release - -Create a branch, update version, and add release notes by running `make-branch` - - ./script/release/make-branch $VERSION [$BASE_VERSION] - -`$BASE_VERSION` will default to master. Use the last version tag for a bug fix -release. - -As part of this script you'll be asked to: - -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.0-rc1`. - -2. Write release notes in `CHANGELOG.md`. - - 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. - - 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 - -1. Check out the bump branch and run the cherry pick script - - git checkout bump-$VERSION - ./script/release/cherry-pick-pr $PR_NUMBER - -2. When you are done cherry-picking branches move the bump version commit to HEAD - - ./script/release/rebase-bump-commit - git push --force $USERNAME bump-$VERSION - - -## To release a version (whether RC or stable) - -Check out the bump branch and run the `build-binaries` script - - git checkout bump-$VERSION - ./script/release/build-binaries - -When prompted build the non-linux binaries and test them. - -1. Download the different platform binaries by running the following script: - - `./script/release/download-binaries $VERSION` - - The binaries for Linux, OSX and Windows will be downloaded in the `binaries-$VERSION` folder. - -3. Draft a release from the tag on GitHub (the `build-binaries` script will open the window for - you) - - 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: - - 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)**. - - Docker for Mac and Windows will automatically install the latest version of Docker Engine for you. - - Alternatively, you can use the usual commands to install or upgrade Compose: - - ``` - curl -L https://github.com/docker/compose/releases/download/1.16.0/docker-compose-`uname -s`-`uname -m` -o /usr/local/bin/docker-compose - chmod +x /usr/local/bin/docker-compose - ``` - - See the [install docs](https://docs.docker.com/compose/install/) for more install options and instructions. - - ## Compose file format compatibility matrix - - | Compose file format | Docker Engine | - | --- | --- | - | 3.3 | 17.06.0+ | - | 3.0 – 3.2 | 1.13.0+ | - | 2.3| 17.06.0+ | - | 2.2 | 1.13.0+ | - | 2.1 | 1.12.0+ | - | 2.0 | 1.10.0+ | - | 1.0 | 1.9.1+ | - - ## Changes - - ...release notes go here... - -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`. - -7. If everything looks good, it's time to push the release. - - - ./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. - -10. Announce the release on the appropriate Slack channel(s). - -## If it’s a stable release (not an RC) - -1. Close the release’s milestone. - -## If it’s a minor release (1.x.0), rather than a patch release (1.x.y) - -1. Open a PR against `master` to: - - - update `CHANGELOG.md` to bring it in line with `release` - - bump the version in `compose/__init__.py` to the *next* minor version number with `dev` appended. For example, if you just released `1.4.0`, update it to `1.5.0dev`. - -2. Get the PR merged. - -## Finally - -1. Celebrate, however you’d like. diff --git a/project/RELEASE-PROCESS.md b/project/RELEASE-PROCESS.md new file mode 120000 index 00000000000..c8457671ac7 --- /dev/null +++ b/project/RELEASE-PROCESS.md @@ -0,0 +1 @@ +../script/release/README.md \ No newline at end of file diff --git a/script/release/README.md b/script/release/README.md new file mode 100644 index 00000000000..c5136c764f1 --- /dev/null +++ b/script/release/README.md @@ -0,0 +1,184 @@ +# Release HOWTO + +This file describes the process of making a public release of `docker-compose`. +Please read it carefully before proceeding! + +## Prerequisites + +The following things are required to bring a release to a successful conclusion + +### Local Docker engine (Linux Containers) + +The release script runs inside a container and builds images that will be part +of the release. + +### Docker Hub account + +You should be logged into a Docker Hub account that allows pushing to the +following repositories: + +- docker/compose +- docker/compose-tests + +### A Github account and Github API token + +Your Github account needs to have write access on the `docker/compose` repo. +To generate a Github token, head over to the +[Personal access tokens](https://github.com/settings/tokens) page in your +Github settings and select "Generate new token". Your token should include +(at minimum) the following scopes: + +- `repo:status` +- `public_repo` + +This API token should be exposed to the release script through the +`GITHUB_TOKEN` environment variable. + +### A Bintray account and Bintray API key + +Your Bintray account will need to be an admin member of the +[docker-compose organization](https://github.com/settings/tokens). +Additionally, you should generate a personal API key. To do so, click your +username in the top-right hand corner and select "Edit profile" ; on the new +page, select "API key" in the left-side menu. + +This API key should be exposed to the release script through the +`BINTRAY_TOKEN` environment variable. + +### A PyPi account + +Said account needs to be a member of the maintainers group for the +[`docker-compose` project](https://pypi.org/project/docker-compose/). + +Moreover, the `~/.pypirc` file should exist on your host and contain the +relevant pypi credentials. + +## Start a feature release + +A feature release is a release that includes all changes present in the +`master` branch when initiated. It's typically versioned `X.Y.0-rc1`, where +Y is the minor version of the previous release incremented by one. A series +of one or more Release Candidates (RCs) should be made available to the public +to find and squash potential bugs. + +From the root of the Compose repository, run the following command: +``` +./script/release/release.sh -b start X.Y.0-rc1 +``` + +After a short initialization period, the script will invite you to edit the +`CHANGELOG.md` file. Do so by being careful to respect the same format as +previous releases. Once done, the script will display a `diff` of the staged +changes for the bump commit. Once you validate these, a bump commit will be +created on the newly created release branch and pushed remotely. + +The release tool then waits for the CI to conclude before proceeding. +If failures are reported, the release will be aborted until these are fixed. +Please refer to the "Resume a draft release" section below for more details. + +Once all resources have been prepared, the release script will exit with a +message resembling this one: + +``` +You're almost done! Please verify that everything is in order and you are ready +to make the release public, then run the following command: +./script/release/release.sh -b user finalize X.Y.0-rc1 +``` + +Once you are ready to finalize the release (making binaries and other versioned +assets public), proceed to the "Finalize a release" section of this guide. + +## Start a patch release + +A patch release is a release that builds off a previous release with discrete +additions. This can be an RC release after RC1 (`X.Y.0-rcZ`, `Z > 1`), a GA release +based off the final RC (`X.Y.0`), or a bugfix release based off a previous +GA release (`X.Y.Z`, `Z > 0`). + +From the root of the Compose repository, run the following command: +``` +./script/release/release.sh -b start --patch=BASE_VERSION RELEASE_VERSION +``` + +The process of starting a patch release is identical to starting a feature +release except for one difference ; at the beginning, the script will ask for +PR numbers you wish to cherry-pick into the release. These numbers should +correspond to existing PRs on the docker/compose repository. Multiple numbers +should be separated by whitespace. + +Once you are ready to finalize the release (making binaries and other versioned +assets public), proceed to the "Finalize a release" section of this guide. + +## Finalize a release + +Once you're ready to make your release public, you may execute the following +command from the root of the Compose repository: +``` +./script/release/release.sh -b finalize RELEAE_VERSION +``` + +Note that this command will create and publish versioned assets to the public. +As a result, it can not be reverted. The command will perform some basic +sanity checks before doing so, but it is your responsibility to ensure +everything is in order before pushing the button. + +After the command exits, you should make sure: + +- The `docker/compose:VERSION` image is available on Docker Hub and functional +- The `pip install -U docker-compose==VERSION` command correctly installs the + specified version +- The install command on the Github release page installs the new release + +## Resume a draft release + +"Resuming" a release lets you address the following situations occurring before +a release is made final: + +- Cherry-pick additional PRs to include in the release +- Resume a release that was aborted because of CI failures after they've been + addressed +- Rebuild / redownload assets after manual changes have been made to the + release branch +- etc. + +From the root of the Compose repository, run the following command: +``` +./script/release/release.sh -b resume RELEASE_VERSION +``` + +The release tool will attempt to determine what steps it's already been through +for the specified release and pick up where it left off. Some steps are +executed again no matter what as it's assumed they'll produce different +results, like building images or downloading binaries. + +## Cancel a draft release + +If issues snuck into your release branch, it is sometimes easier to start from +scratch. Before a release has been finalized, it is possible to cancel it using +the following command: +``` +./script/release/release.sh -b cancel RELEASE_VERSION +``` + +This will remove the release branch with this release (locally and remotely), +close the associated PR, remove the release page draft on Github and delete +the Bintray repository for it, allowing you to start fresh. + +## Manual operations + +Some common, release-related operations are not covered by this tool and should +be handled manually by the operator: + +- After any release: + - Announce new release on Slack +- After a GA release: + - Close the release milestone + - Merge back `CHANGELOG.md` changes from the `release` branch into `master` + - Bump the version in `compose/__init__.py` to the *next* minor version + number with `dev` appended. For example, if you just released `1.4.0`, + update it to `1.5.0dev` + +## Advanced options + +You can consult the full list of options for the release tool by executing +`./script/release/release.sh --help`. From 0578a58471d6dbca16fc6ed218f5affe76ac813e Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 23 Apr 2018 15:01:30 -0700 Subject: [PATCH 3388/4072] Remove obsolete release scripts Signed-off-by: Joffrey F --- script/release/build-binaries | 40 --------------- script/release/contributors | 30 ----------- script/release/download-binaries | 39 --------------- script/release/make-branch | 86 -------------------------------- 4 files changed, 195 deletions(-) delete mode 100755 script/release/build-binaries delete mode 100755 script/release/contributors delete mode 100755 script/release/download-binaries delete mode 100755 script/release/make-branch diff --git a/script/release/build-binaries b/script/release/build-binaries deleted file mode 100755 index a39b186d97b..00000000000 --- a/script/release/build-binaries +++ /dev/null @@ -1,40 +0,0 @@ -#!/bin/bash -# -# Build the release binaries -# - -. "$(dirname "${BASH_SOURCE[0]}")/utils.sh" - -function usage() { - >&2 cat << EOM -Build binaries for the release. - -This script requires that 'git config branch.${BRANCH}.release' is set to the -release version for the release branch. - -EOM - exit 1 -} - -BRANCH="$(git rev-parse --abbrev-ref HEAD)" -VERSION="$(git config "branch.${BRANCH}.release")" || usage -REPO=docker/compose - -# Build the binaries -script/clean -script/build/linux - -echo "Building the container distribution" -script/build/image $VERSION - -echo "Building the compose-tests image" -script/build/test-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/contributors b/script/release/contributors deleted file mode 100755 index 4657dd8051d..00000000000 --- a/script/release/contributors +++ /dev/null @@ -1,30 +0,0 @@ -#!/bin/bash -set -e - - -function usage() { - >&2 cat << EOM -Print the list of github contributors for the release - -Usage: - - $0 -EOM - exit 1 -} - -[[ -n "$1" ]] || usage -PREV_RELEASE=$1 -BRANCH="$(git rev-parse --abbrev-ref HEAD)" -URL="https://api.github.com/repos/docker/compose/compare" - -contribs=$(curl -sf "$URL/$PREV_RELEASE...$BRANCH" | \ - jq -r '.commits[].author.login' | \ - sort | \ - uniq -c | \ - sort -nr) - -echo "Contributions by user: " -echo "$contribs" -echo -echo "$contribs" | awk '{print "@"$2","}' | xargs diff --git a/script/release/download-binaries b/script/release/download-binaries deleted file mode 100755 index 0b187f6c26c..00000000000 --- a/script/release/download-binaries +++ /dev/null @@ -1,39 +0,0 @@ -#!/bin/bash - -function usage() { - >&2 cat << EOM -Download Linux, Mac OS and Windows binaries from remote endpoints - -Usage: - - $0 - -Options: - - version version string for the release (ex: 1.6.0) - -EOM - exit 1 -} - - -[ -n "$1" ] || usage -VERSION=$1 -BASE_BINTRAY_URL=https://dl.bintray.com/docker-compose/bump-$VERSION/ -DESTINATION=binaries-$VERSION -APPVEYOR_URL=https://ci.appveyor.com/api/projects/docker/compose/\ -artifacts/dist%2Fdocker-compose-Windows-x86_64.exe?branch=bump-$VERSION - -mkdir $DESTINATION - - -wget -O $DESTINATION/docker-compose-Darwin-x86_64 $BASE_BINTRAY_URL/docker-compose-Darwin-x86_64 -wget -O $DESTINATION/docker-compose-Linux-x86_64 $BASE_BINTRAY_URL/docker-compose-Linux-x86_64 -wget -O $DESTINATION/docker-compose-Windows-x86_64.exe $APPVEYOR_URL - -echo -e "\n\nCopy the following lines into the integrity check table in the release notes:\n\n" -cd $DESTINATION -rm -rf *.sha256 -ls | xargs sha256sum | sed 's/ / | /g' | sed -r 's/([^ |]+)/`\1`/g' -ls | xargs -I@ bash -c "sha256sum @ | cut -d' ' -f1 > @.sha256" -cd - diff --git a/script/release/make-branch b/script/release/make-branch deleted file mode 100755 index b8a0cd31ee7..00000000000 --- a/script/release/make-branch +++ /dev/null @@ -1,86 +0,0 @@ -#!/bin/bash -# -# Prepare a new release branch -# - -. "$(dirname "${BASH_SOURCE[0]}")/utils.sh" - -function usage() { - >&2 cat << EOM -Create a new release branch 'release-' - -Usage: - - $0 [] - -Options: - - version version string for the release (ex: 1.6.0) - base_version branch or tag to start from. Defaults to master. For - bug-fix releases use the previous stage release tag. - -EOM - exit 1 -} - - -[ -n "$1" ] || usage -VERSION=$1 -BRANCH=bump-$VERSION -REPO=docker/compose -GITHUB_REPO=git@github.com:$REPO - -if [ -z "$2" ]; then - BASE_VERSION="master" -else - BASE_VERSION=$2 -fi - - -DEFAULT_REMOTE=release -REMOTE="$(find_remote "$GITHUB_REPO")" -# If we don't have a docker remote add one -if [ -z "$REMOTE" ]; then - echo "Creating $DEFAULT_REMOTE remote" - git remote add ${DEFAULT_REMOTE} ${GITHUB_REPO} -fi - -# handle the difference between a branch and a tag -if [ -z "$(git name-rev --tags $BASE_VERSION | grep tags)" ]; then - BASE_VERSION=$REMOTE/$BASE_VERSION -fi - -echo "Creating a release branch $VERSION from $BASE_VERSION" -read -n1 -r -p "Continue? (ctrl+c to cancel)" -git fetch $REMOTE -p -git checkout -b $BRANCH $BASE_VERSION - -echo "Merging remote release branch into new release branch" -git merge --strategy=ours --no-edit $REMOTE/release - -# Store the release version for this branch in git, so that other release -# scripts can use it -git config "branch.${BRANCH}.release" $VERSION - - -editor=${EDITOR:-vim} - -echo "Update versions in compose/__init__.py, script/run/run.sh" -$editor compose/__init__.py -$editor script/run/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 - - -git diff -echo "Verify changes before commit. Exit the shell to commit changes" -$SHELL || true -git commit -a -m "Bump $VERSION" --signoff --no-verify - - -echo "Push branch to docker remote" -git push $REMOTE -browser https://github.com/$REPO/compare/docker:release...$BRANCH?expand=1 From 4dece7fcb20f4cae3a77291d20d1c83e34cbc4c3 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 23 Apr 2018 16:41:10 -0700 Subject: [PATCH 3389/4072] Retrieve objects using legacy (< 1.21) project names Signed-off-by: Joffrey F --- compose/network.py | 66 ++++++++++++++++++++++++++++++++++------------ compose/service.py | 36 +++++++++++++++++++++---- compose/volume.py | 43 +++++++++++++++++++++++++----- 3 files changed, 117 insertions(+), 28 deletions(-) diff --git a/compose/network.py b/compose/network.py index 1a080c40cf6..e0fdb3e2798 100644 --- a/compose/network.py +++ b/compose/network.py @@ -2,6 +2,7 @@ from __future__ import unicode_literals import logging +import re from collections import OrderedDict from docker.errors import NotFound @@ -10,9 +11,11 @@ from docker.utils import version_gte from docker.utils import version_lt +from . import __version__ from .config import ConfigurationError from .const import LABEL_NETWORK from .const import LABEL_PROJECT +from .const import LABEL_VERSION log = logging.getLogger(__name__) @@ -39,6 +42,7 @@ def __init__(self, client, project, name, driver=None, driver_opts=None, self.enable_ipv6 = enable_ipv6 self.labels = labels self.custom_name = custom_name + self.legacy = False def ensure(self): if self.external: @@ -68,6 +72,14 @@ def ensure(self): data = self.inspect() check_remote_network_config(data, self) except NotFound: + try: + data = self.inspect(legacy=True) + self.legacy = True + check_remote_network_config(data, self) + return + except NotFound: + pass + driver_name = 'the default driver' if self.driver: driver_name = 'driver "{}"'.format(self.driver) @@ -94,18 +106,37 @@ def remove(self): log.info("Network %s is external, skipping", self.full_name) return - log.info("Removing network {}".format(self.full_name)) - self.client.remove_network(self.full_name) + log.info("Removing network {}".format(self.true_name)) + try: + self.client.remove_network(self.full_name) + except NotFound: + self.client.remove_network(self.legacy_full_name) - def inspect(self): + def inspect(self, legacy=False): + if legacy: + return self.client.inspect_network(self.legacy_full_name) return self.client.inspect_network(self.full_name) + @property + def legacy_full_name(self): + if self.custom_name: + return self.name + return '{0}_{1}'.format( + re.sub(r'[_-]', '', self.project), self.name + ) + @property def full_name(self): if self.custom_name: return self.name return '{0}_{1}'.format(self.project, self.name) + @property + def true_name(self): + if self.legacy: + return self.legacy_full_name + return self.full_name + @property def _labels(self): if version_lt(self.client._version, '1.23'): @@ -114,6 +145,7 @@ def _labels(self): labels.update({ LABEL_PROJECT: self.project, LABEL_NETWORK: self.name, + LABEL_VERSION: __version__, }) return labels @@ -150,49 +182,49 @@ def check_remote_ipam_config(remote, local): remote_ipam = remote.get('IPAM') ipam_dict = create_ipam_config_from_dict(local.ipam) if local.ipam.get('driver') and local.ipam.get('driver') != remote_ipam.get('Driver'): - raise NetworkConfigChangedError(local.full_name, 'IPAM driver') + raise NetworkConfigChangedError(local.true_name, 'IPAM driver') if len(ipam_dict['Config']) != 0: if len(ipam_dict['Config']) != len(remote_ipam['Config']): - raise NetworkConfigChangedError(local.full_name, 'IPAM configs') + raise NetworkConfigChangedError(local.true_name, 'IPAM configs') remote_configs = sorted(remote_ipam['Config'], key='Subnet') local_configs = sorted(ipam_dict['Config'], key='Subnet') while local_configs: lc = local_configs.pop() rc = remote_configs.pop() if lc.get('Subnet') != rc.get('Subnet'): - raise NetworkConfigChangedError(local.full_name, 'IPAM config subnet') + raise NetworkConfigChangedError(local.true_name, 'IPAM config subnet') if lc.get('Gateway') is not None and lc.get('Gateway') != rc.get('Gateway'): - raise NetworkConfigChangedError(local.full_name, 'IPAM config gateway') + raise NetworkConfigChangedError(local.true_name, 'IPAM config gateway') if lc.get('IPRange') != rc.get('IPRange'): - raise NetworkConfigChangedError(local.full_name, 'IPAM config ip_range') + raise NetworkConfigChangedError(local.true_name, 'IPAM config ip_range') if sorted(lc.get('AuxiliaryAddresses')) != sorted(rc.get('AuxiliaryAddresses')): - raise NetworkConfigChangedError(local.full_name, 'IPAM config aux_addresses') + raise NetworkConfigChangedError(local.true_name, 'IPAM config aux_addresses') remote_opts = remote_ipam.get('Options') or {} local_opts = local.ipam.get('options') or {} for k in set.union(set(remote_opts.keys()), set(local_opts.keys())): if remote_opts.get(k) != local_opts.get(k): - raise NetworkConfigChangedError(local.full_name, 'IPAM option "{}"'.format(k)) + raise NetworkConfigChangedError(local.true_name, 'IPAM option "{}"'.format(k)) def check_remote_network_config(remote, local): if local.driver and remote.get('Driver') != local.driver: - raise NetworkConfigChangedError(local.full_name, 'driver') + raise NetworkConfigChangedError(local.true_name, 'driver') local_opts = local.driver_opts or {} remote_opts = remote.get('Options') or {} for k in set.union(set(remote_opts.keys()), set(local_opts.keys())): if k in OPTS_EXCEPTIONS: continue if remote_opts.get(k) != local_opts.get(k): - raise NetworkConfigChangedError(local.full_name, 'option "{}"'.format(k)) + raise NetworkConfigChangedError(local.true_name, 'option "{}"'.format(k)) if local.ipam is not None: check_remote_ipam_config(remote, local) if local.internal is not None and local.internal != remote.get('Internal', False): - raise NetworkConfigChangedError(local.full_name, 'internal') + raise NetworkConfigChangedError(local.true_name, 'internal') if local.enable_ipv6 is not None and local.enable_ipv6 != remote.get('EnableIPv6', False): - raise NetworkConfigChangedError(local.full_name, 'enable_ipv6') + raise NetworkConfigChangedError(local.true_name, 'enable_ipv6') local_labels = local.labels or {} remote_labels = remote.get('Labels', {}) @@ -202,7 +234,7 @@ def check_remote_network_config(remote, local): if remote_labels.get(k) != local_labels.get(k): log.warn( 'Network {}: label "{}" has changed. It may need to be' - ' recreated.'.format(local.full_name, k) + ' recreated.'.format(local.true_name, k) ) @@ -257,7 +289,7 @@ def remove(self): try: network.remove() except NotFound: - log.warn("Network %s not found.", network.full_name) + log.warn("Network %s not found.", network.true_name) def initialize(self): if not self.use_networking: @@ -286,7 +318,7 @@ def get_networks(service_dict, network_definitions): for name, netdef in get_network_defs_for_service(service_dict).items(): network = network_definitions.get(name) if network: - networks[network.full_name] = netdef + networks[network.true_name] = netdef else: raise ConfigurationError( 'Service "{}" uses an undefined network "{}"' diff --git a/compose/service.py b/compose/service.py index bb9e26baa29..0a866161cfa 100644 --- a/compose/service.py +++ b/compose/service.py @@ -51,6 +51,7 @@ from .utils import json_hash from .utils import parse_bytes from .utils import parse_seconds_float +from .version import ComposeVersion log = logging.getLogger(__name__) @@ -192,11 +193,25 @@ def __repr__(self): def containers(self, stopped=False, one_off=False, filters={}): filters.update({'label': self.labels(one_off=one_off)}) - return list(filter(None, [ + result = list(filter(None, [ Container.from_ps(self.client, container) for container in self.client.containers( all=stopped, - filters=filters)])) + filters=filters)]) + ) + if result: + return result + + filters.update({'label': self.labels(one_off=one_off, legacy=True)}) + return list( + filter( + self.has_legacy_proj_name, filter(None, [ + Container.from_ps(self.client, container) + for container in self.client.containers( + all=stopped, + filters=filters)]) + ) + ) def get_container(self, number=1): """Return a :class:`compose.container.Container` for this service. The @@ -380,6 +395,10 @@ def _containers_have_diverged(self, containers): has_diverged = False for c in containers: + if self.has_legacy_proj_name(c): + log.debug('%s has diverged: Legacy project name' % c.name) + has_diverged = True + continue container_config_hash = c.labels.get(LABEL_CONFIG_HASH, None) if container_config_hash != config_hash: log.debug( @@ -1053,11 +1072,12 @@ def build(self, no_cache=False, pull=False, force_rm=False, memory=None, build_a def can_be_built(self): return 'build' in self.options - def labels(self, one_off=False): + def labels(self, one_off=False, legacy=False): + proj_name = self.project if not legacy else re.sub(r'[_-]', '', self.project) return [ - '{0}={1}'.format(LABEL_PROJECT, self.project), + '{0}={1}'.format(LABEL_PROJECT, proj_name), '{0}={1}'.format(LABEL_SERVICE, self.name), - '{0}={1}'.format(LABEL_ONE_OFF, "True" if one_off else "False") + '{0}={1}'.format(LABEL_ONE_OFF, "True" if one_off else "False"), ] @property @@ -1214,6 +1234,12 @@ def _parse_proxy_config(self): return result + def has_legacy_proj_name(self, ctnr): + return ( + ComposeVersion(ctnr.labels.get(LABEL_VERSION)) < ComposeVersion('1.21.0') and + ctnr.project != self.project + ) + def short_id_alias_exists(container, network): aliases = container.get( diff --git a/compose/volume.py b/compose/volume.py index 6bf184045c4..6cad1e0ded3 100644 --- a/compose/volume.py +++ b/compose/volume.py @@ -2,15 +2,19 @@ from __future__ import unicode_literals import logging +import re from docker.errors import NotFound from docker.utils import version_lt +from . import __version__ from .config import ConfigurationError from .config.types import VolumeSpec from .const import LABEL_PROJECT +from .const import LABEL_VERSION from .const import LABEL_VOLUME + log = logging.getLogger(__name__) @@ -25,6 +29,7 @@ def __init__(self, client, project, name, driver=None, driver_opts=None, self.external = external self.labels = labels self.custom_name = custom_name + self.legacy = False def create(self): return self.client.create_volume( @@ -36,15 +41,26 @@ def remove(self): log.info("Volume %s is external, skipping", self.full_name) return log.info("Removing volume %s", self.full_name) - return self.client.remove_volume(self.full_name) + try: + return self.client.remove_volume(self.full_name) + except NotFound: + self.client.remove_volume(self.legacy_full_name) - def inspect(self): + def inspect(self, legacy=False): + if legacy: + return self.client.inspect_volume(self.legacy_full_name) return self.client.inspect_volume(self.full_name) def exists(self): try: self.inspect() except NotFound: + try: + self.inspect(legacy=True) + self.legacy = True + return True + except NotFound: + pass return False return True @@ -54,6 +70,20 @@ def full_name(self): return self.name return '{0}_{1}'.format(self.project, self.name) + @property + def legacy_full_name(self): + if self.custom_name: + return self.name + return '{0}_{1}'.format( + re.sub(r'[_-]', '', self.project), self.name + ) + + @property + def true_name(self): + if self.legacy: + return self.legacy_full_name + return self.full_name + @property def _labels(self): if version_lt(self.client._version, '1.23'): @@ -62,6 +92,7 @@ def _labels(self): labels.update({ LABEL_PROJECT: self.project, LABEL_VOLUME: self.name, + LABEL_VERSION: __version__, }) return labels @@ -94,7 +125,7 @@ def remove(self): try: volume.remove() except NotFound: - log.warn("Volume %s not found.", volume.full_name) + log.warn("Volume %s not found.", volume.true_name) def initialize(self): try: @@ -136,9 +167,9 @@ def namespace_spec(self, volume_spec): if isinstance(volume_spec, VolumeSpec): volume = self.volumes[volume_spec.external] - return volume_spec._replace(external=volume.full_name) + return volume_spec._replace(external=volume.true_name) else: - volume_spec.source = self.volumes[volume_spec.source].full_name + volume_spec.source = self.volumes[volume_spec.source].true_name return volume_spec @@ -152,7 +183,7 @@ def __init__(self, local, property_name, local_value, remote_value): 'first:\n$ docker volume rm {full_name}'.format( vol_name=local.name, property_name=property_name, local_value=local_value, remote_value=remote_value, - full_name=local.full_name + full_name=local.true_name ) ) From 299ce6ad009fa9f56564c51a4cd86eafa6e7ccee Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 23 Apr 2018 18:16:58 -0700 Subject: [PATCH 3390/4072] Incorrect key name for IPAM options check Signed-off-by: Joffrey F --- compose/network.py | 2 +- tests/unit/network_test.py | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/compose/network.py b/compose/network.py index 1a080c40cf6..7803db9799e 100644 --- a/compose/network.py +++ b/compose/network.py @@ -169,7 +169,7 @@ def check_remote_ipam_config(remote, local): raise NetworkConfigChangedError(local.full_name, 'IPAM config aux_addresses') remote_opts = remote_ipam.get('Options') or {} - local_opts = local.ipam.get('options') or {} + local_opts = local.ipam.get('Options') or {} for k in set.union(set(remote_opts.keys()), set(local_opts.keys())): if remote_opts.get(k) != local_opts.get(k): raise NetworkConfigChangedError(local.full_name, 'IPAM option "{}"'.format(k)) diff --git a/tests/unit/network_test.py b/tests/unit/network_test.py index b27339af89c..0e03fc10e43 100644 --- a/tests/unit/network_test.py +++ b/tests/unit/network_test.py @@ -23,7 +23,10 @@ def test_check_remote_network_config_success(self): 'aux_addresses': ['11.0.0.1', '24.25.26.27'], 'ip_range': '156.0.0.1-254' } - ] + ], + 'options': { + 'iface': 'eth0', + } } labels = { 'com.project.tests.istest': 'true', @@ -57,6 +60,9 @@ def test_check_remote_network_config_success(self): 'Subnet': '172.0.0.1/16', 'Gateway': '172.0.0.1' }], + 'Options': { + 'iface': 'eth0', + }, }, 'Labels': remote_labels }, From fa6d837b49a607d3fd480f50d2e1adec3dabd9e2 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 23 Apr 2018 19:08:55 -0700 Subject: [PATCH 3391/4072] Clearly define IPAM config schema for validation Signed-off-by: Joffrey F --- compose/config/config_schema_v2.0.json | 21 ++++++++++++++++++++- compose/config/config_schema_v2.1.json | 21 ++++++++++++++++++++- compose/config/config_schema_v2.2.json | 21 ++++++++++++++++++++- compose/config/config_schema_v2.3.json | 21 ++++++++++++++++++++- compose/config/config_schema_v2.4.json | 21 ++++++++++++++++++++- tests/unit/config/config_test.py | 22 ++++++++++++++++++++++ 6 files changed, 122 insertions(+), 5 deletions(-) diff --git a/compose/config/config_schema_v2.0.json b/compose/config/config_schema_v2.0.json index eddf787eab4..793cef1d6db 100644 --- a/compose/config/config_schema_v2.0.json +++ b/compose/config/config_schema_v2.0.json @@ -281,7 +281,8 @@ "properties": { "driver": {"type": "string"}, "config": { - "type": "array" + "type": "array", + "items": {"$ref": "#/definitions/ipam_config"} }, "options": { "type": "object", @@ -305,6 +306,24 @@ "additionalProperties": false }, + "ipam_config": { + "id": "#/definitions/ipam_config", + "type": "object", + "properties": { + "subnet": {"type": "string"}, + "iprange": {"type": "string"}, + "gateway": {"type": "string"}, + "aux_addresses": { + "type": "object", + "patternProperties": { + "^.+$": {"type": "string"} + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "volume": { "id": "#/definitions/volume", "type": ["object", "null"], diff --git a/compose/config/config_schema_v2.1.json b/compose/config/config_schema_v2.1.json index 5ad5a20ea98..5ea763544fe 100644 --- a/compose/config/config_schema_v2.1.json +++ b/compose/config/config_schema_v2.1.json @@ -332,7 +332,8 @@ "properties": { "driver": {"type": "string"}, "config": { - "type": "array" + "type": "array", + "items": {"$ref": "#/definitions/ipam_config"} }, "options": { "type": "object", @@ -359,6 +360,24 @@ "additionalProperties": false }, + "ipam_config": { + "id": "#/definitions/ipam_config", + "type": "object", + "properties": { + "subnet": {"type": "string"}, + "iprange": {"type": "string"}, + "gateway": {"type": "string"}, + "aux_addresses": { + "type": "object", + "patternProperties": { + "^.+$": {"type": "string"} + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "volume": { "id": "#/definitions/volume", "type": ["object", "null"], diff --git a/compose/config/config_schema_v2.2.json b/compose/config/config_schema_v2.2.json index 26044b6510a..a19d4c94514 100644 --- a/compose/config/config_schema_v2.2.json +++ b/compose/config/config_schema_v2.2.json @@ -341,7 +341,8 @@ "properties": { "driver": {"type": "string"}, "config": { - "type": "array" + "type": "array", + "items": {"$ref": "#/definitions/ipam_config"} }, "options": { "type": "object", @@ -368,6 +369,24 @@ "additionalProperties": false }, + "ipam_config": { + "id": "#/definitions/ipam_config", + "type": "object", + "properties": { + "subnet": {"type": "string"}, + "iprange": {"type": "string"}, + "gateway": {"type": "string"}, + "aux_addresses": { + "type": "object", + "patternProperties": { + "^.+$": {"type": "string"} + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "volume": { "id": "#/definitions/volume", "type": ["object", "null"], diff --git a/compose/config/config_schema_v2.3.json b/compose/config/config_schema_v2.3.json index ac0778f2a0d..78b716a7a7a 100644 --- a/compose/config/config_schema_v2.3.json +++ b/compose/config/config_schema_v2.3.json @@ -385,7 +385,8 @@ "properties": { "driver": {"type": "string"}, "config": { - "type": "array" + "type": "array", + "items": {"$ref": "#/definitions/ipam_config"} }, "options": { "type": "object", @@ -412,6 +413,24 @@ "additionalProperties": false }, + "ipam_config": { + "id": "#/definitions/ipam_config", + "type": "object", + "properties": { + "subnet": {"type": "string"}, + "iprange": {"type": "string"}, + "gateway": {"type": "string"}, + "aux_addresses": { + "type": "object", + "patternProperties": { + "^.+$": {"type": "string"} + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "volume": { "id": "#/definitions/volume", "type": ["object", "null"], diff --git a/compose/config/config_schema_v2.4.json b/compose/config/config_schema_v2.4.json index 731fa2f9b08..a5796d5b12a 100644 --- a/compose/config/config_schema_v2.4.json +++ b/compose/config/config_schema_v2.4.json @@ -384,7 +384,8 @@ "properties": { "driver": {"type": "string"}, "config": { - "type": "array" + "type": "array", + "items": {"$ref": "#/definitions/ipam_config"} }, "options": { "type": "object", @@ -411,6 +412,24 @@ "additionalProperties": false }, + "ipam_config": { + "id": "#/definitions/ipam_config", + "type": "object", + "properties": { + "subnet": {"type": "string"}, + "iprange": {"type": "string"}, + "gateway": {"type": "string"}, + "aux_addresses": { + "type": "object", + "patternProperties": { + "^.+$": {"type": "string"} + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "volume": { "id": "#/definitions/volume", "type": ["object", "null"], diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 8a75648ac40..4562a99ca41 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1322,6 +1322,28 @@ def test_load_bind_mount_relative_path(self): assert mount.type == 'bind' assert mount.source == expected_source + def test_config_invalid_ipam_config(self): + with pytest.raises(ConfigurationError) as excinfo: + config.load( + build_config_details( + { + 'version': str(V2_1), + 'networks': { + 'foo': { + 'driver': 'default', + 'ipam': { + 'driver': 'default', + 'config': ['172.18.0.0/16'], + } + } + } + }, + filename='filename.yml', + ) + ) + assert ('networks.foo.ipam.config contains an invalid type,' + ' it should be an object') in excinfo.exconly() + def test_config_valid_service_names(self): for valid_name in ['_', '-', '.__.', '_what-up.', 'what_.up----', 'whatup']: services = config.load( From c1657dc46aab2edafbd175b27756e367839129d5 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 24 Apr 2018 15:48:02 -0700 Subject: [PATCH 3392/4072] Improve legacy network and volume detection Signed-off-by: Joffrey F --- compose/network.py | 26 ++++++++++++++------------ compose/volume.py | 25 +++++++++++++++---------- 2 files changed, 29 insertions(+), 22 deletions(-) diff --git a/compose/network.py b/compose/network.py index e0fdb3e2798..b00e2ae9902 100644 --- a/compose/network.py +++ b/compose/network.py @@ -42,7 +42,7 @@ def __init__(self, client, project, name, driver=None, driver_opts=None, self.enable_ipv6 = enable_ipv6 self.labels = labels self.custom_name = custom_name - self.legacy = False + self.legacy = None def ensure(self): if self.external: @@ -68,25 +68,17 @@ def ensure(self): ) return + self._set_legacy_flag() try: - data = self.inspect() + data = self.inspect(legacy=self.legacy) check_remote_network_config(data, self) except NotFound: - try: - data = self.inspect(legacy=True) - self.legacy = True - check_remote_network_config(data, self) - return - except NotFound: - pass - driver_name = 'the default driver' if self.driver: driver_name = 'driver "{}"'.format(self.driver) log.info( - 'Creating network "{}" with {}' - .format(self.full_name, driver_name) + 'Creating network "{}" with {}'.format(self.full_name, driver_name) ) self.client.create_network( @@ -133,6 +125,7 @@ def full_name(self): @property def true_name(self): + self._set_legacy_flag() if self.legacy: return self.legacy_full_name return self.full_name @@ -149,6 +142,15 @@ def _labels(self): }) return labels + def _set_legacy_flag(self): + if self.legacy is not None: + return + try: + data = self.inspect(legacy=True) + self.legacy = data is not None + except NotFound: + self.legacy = False + def create_ipam_config_from_dict(ipam_dict): if not ipam_dict: diff --git a/compose/volume.py b/compose/volume.py index 6cad1e0ded3..56ff601cd2b 100644 --- a/compose/volume.py +++ b/compose/volume.py @@ -29,7 +29,7 @@ def __init__(self, client, project, name, driver=None, driver_opts=None, self.external = external self.labels = labels self.custom_name = custom_name - self.legacy = False + self.legacy = None def create(self): return self.client.create_volume( @@ -46,21 +46,16 @@ def remove(self): except NotFound: self.client.remove_volume(self.legacy_full_name) - def inspect(self, legacy=False): + def inspect(self, legacy=None): if legacy: return self.client.inspect_volume(self.legacy_full_name) return self.client.inspect_volume(self.full_name) def exists(self): + self._set_legacy_flag() try: - self.inspect() + self.inspect(legacy=self.legacy) except NotFound: - try: - self.inspect(legacy=True) - self.legacy = True - return True - except NotFound: - pass return False return True @@ -80,6 +75,7 @@ def legacy_full_name(self): @property def true_name(self): + self._set_legacy_flag() if self.legacy: return self.legacy_full_name return self.full_name @@ -96,6 +92,15 @@ def _labels(self): }) return labels + def _set_legacy_flag(self): + if self.legacy is not None: + return + try: + data = self.inspect(legacy=True) + self.legacy = data is not None + except NotFound: + self.legacy = False + class ProjectVolumes(object): @@ -155,7 +160,7 @@ def initialize(self): ) volume.create() else: - check_remote_volume_config(volume.inspect(), volume) + check_remote_volume_config(volume.inspect(legacy=volume.legacy), volume) except NotFound: raise ConfigurationError( 'Volume %s specifies nonexistent driver %s' % (volume.name, volume.driver) From 3b2ce82fa1c2131a542d3af7768c6b4c56094a18 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 24 Apr 2018 16:10:59 -0700 Subject: [PATCH 3393/4072] Use true_name for remove operation Signed-off-by: Joffrey F --- compose/network.py | 7 ++----- compose/volume.py | 9 +++------ tests/unit/network_test.py | 3 +++ tests/unit/project_test.py | 2 ++ 4 files changed, 10 insertions(+), 11 deletions(-) diff --git a/compose/network.py b/compose/network.py index b00e2ae9902..4ac3a0ea432 100644 --- a/compose/network.py +++ b/compose/network.py @@ -95,14 +95,11 @@ def ensure(self): def remove(self): if self.external: - log.info("Network %s is external, skipping", self.full_name) + log.info("Network %s is external, skipping", self.true_name) return log.info("Removing network {}".format(self.true_name)) - try: - self.client.remove_network(self.full_name) - except NotFound: - self.client.remove_network(self.legacy_full_name) + self.client.remove_network(self.true_name) def inspect(self, legacy=False): if legacy: diff --git a/compose/volume.py b/compose/volume.py index 56ff601cd2b..7618417ffa4 100644 --- a/compose/volume.py +++ b/compose/volume.py @@ -38,13 +38,10 @@ def create(self): def remove(self): if self.external: - log.info("Volume %s is external, skipping", self.full_name) + log.info("Volume %s is external, skipping", self.true_name) return - log.info("Removing volume %s", self.full_name) - try: - return self.client.remove_volume(self.full_name) - except NotFound: - self.client.remove_volume(self.legacy_full_name) + log.info("Removing volume %s", self.true_name) + return self.client.remove_volume(self.true_name) def inspect(self, legacy=None): if legacy: diff --git a/tests/unit/network_test.py b/tests/unit/network_test.py index b27339af89c..42615666225 100644 --- a/tests/unit/network_test.py +++ b/tests/unit/network_test.py @@ -78,6 +78,7 @@ def test_check_remote_network_config_whitelist(self): {'Driver': 'overlay', 'Options': remote_options}, net ) + @mock.patch('compose.network.Network.true_name', lambda n: n.full_name) def test_check_remote_network_config_driver_mismatch(self): net = Network(None, 'compose_test', 'net1', 'overlay') with pytest.raises(NetworkConfigChangedError) as e: @@ -87,6 +88,7 @@ def test_check_remote_network_config_driver_mismatch(self): assert 'driver has changed' in str(e.value) + @mock.patch('compose.network.Network.true_name', lambda n: n.full_name) def test_check_remote_network_config_options_mismatch(self): net = Network(None, 'compose_test', 'net1', 'overlay') with pytest.raises(NetworkConfigChangedError) as e: @@ -140,6 +142,7 @@ def test_check_remote_network_config_null_remote_ipam_options(self): net ) + @mock.patch('compose.network.Network.true_name', lambda n: n.full_name) def test_check_remote_network_labels_mismatch(self): net = Network(None, 'compose_test', 'net1', 'overlay', labels={ 'com.project.touhou.character': 'sakuya.izayoi' diff --git a/tests/unit/project_test.py b/tests/unit/project_test.py index 83a0147588e..1b6b6651fea 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -60,6 +60,7 @@ def test_from_config_v1(self): assert project.get_service('db').options['image'] == 'busybox:latest' assert not project.networks.use_networking + @mock.patch('compose.network.Network.true_name', lambda n: n.full_name) def test_from_config_v2(self): config = Config( version=V2_0, @@ -217,6 +218,7 @@ def test_use_volumes_from_service_no_container(self): ) assert project.get_service('test')._get_volumes_from() == [container_name + ":rw"] + @mock.patch('compose.network.Network.true_name', lambda n: n.full_name) def test_use_volumes_from_service_container(self): container_ids = ['aabbccddee', '12345'] From 6e09e37114b59196661a08d94cd4b36ea3f1f25f Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 25 Apr 2018 18:08:34 -0700 Subject: [PATCH 3394/4072] Bump SDK version to latest Signed-off-by: Joffrey F --- requirements.txt | 4 ++-- setup.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/requirements.txt b/requirements.txt index 7dce40246a6..93a0cce3527 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,8 +2,8 @@ backports.ssl-match-hostname==3.5.0.1; python_version < '3' cached-property==1.3.0 certifi==2017.4.17 chardet==3.0.4 -docker==3.2.1 -docker-pycreds==0.2.1 +docker==3.3.0 +docker-pycreds==0.2.3 dockerpty==0.4.1 docopt==0.6.2 enum34==1.1.6; python_version < '3.4' diff --git a/setup.py b/setup.py index a7a33363404..422ba5466ee 100644 --- a/setup.py +++ b/setup.py @@ -36,7 +36,7 @@ def find_version(*file_paths): 'requests >= 2.6.1, != 2.11.0, != 2.12.2, != 2.18.0, < 2.19', 'texttable >= 0.9.0, < 0.10', 'websocket-client >= 0.32.0, < 1.0', - 'docker >= 3.2.1, < 4.0', + 'docker >= 3.3.0, < 4.0', 'dockerpty >= 0.4.1, < 0.5', 'six >= 1.3.0, < 2', 'jsonschema >= 2.5.1, < 3', From aecc0de28fdcbeb3a4df3ef876ef7f58cc998396 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 26 Apr 2018 15:20:45 -0700 Subject: [PATCH 3395/4072] Prevent duplicate binds in generated container config Signed-off-by: Joffrey F --- compose/service.py | 7 ++++--- tests/unit/service_test.py | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/compose/service.py b/compose/service.py index 0a866161cfa..ae9e0bb0879 100644 --- a/compose/service.py +++ b/compose/service.py @@ -877,7 +877,6 @@ def _build_container_volume_options(self, previous_container, container_options, container_volumes, self.options.get('tmpfs') or [], previous_container, container_mounts ) - override_options['binds'] = binds container_options['environment'].update(affinity) container_options['volumes'] = dict((v.internal, {}) for v in container_volumes or {}) @@ -890,13 +889,13 @@ def _build_container_volume_options(self, previous_container, container_options, if m.is_tmpfs: override_options['tmpfs'].append(m.target) else: - override_options['binds'].append(m.legacy_repr()) + binds.append(m.legacy_repr()) container_options['volumes'][m.target] = {} secret_volumes = self.get_secret_volumes() if secret_volumes: if version_lt(self.client.api_version, '1.30'): - override_options['binds'].extend(v.legacy_repr() for v in secret_volumes) + binds.extend(v.legacy_repr() for v in secret_volumes) container_options['volumes'].update( (v.target, {}) for v in secret_volumes ) @@ -904,6 +903,8 @@ def _build_container_volume_options(self, previous_container, container_options, override_options['mounts'] = override_options.get('mounts') or [] override_options['mounts'].extend([build_mount(v) for v in secret_volumes]) + # Remove possible duplicates (see e.g. https://github.com/docker/compose/issues/5885) + override_options['binds'] = list(set(binds)) return container_options, override_options def _get_container_host_config(self, override_options, one_off=False): diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 4ccc48653dd..d50db904491 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -10,6 +10,7 @@ from .. import mock from .. import unittest from compose.config.errors import DependencyError +from compose.config.types import MountSpec from compose.config.types import ServicePort from compose.config.types import ServiceSecret from compose.config.types import VolumeFromSpec @@ -955,6 +956,24 @@ def inspect(name): assert service.create_container().id == 'new_cont_id' + def test_build_volume_options_duplicate_binds(self): + self.mock_client.api_version = '1.29' # Trigger 3.2 format workaround + service = Service('foo', client=self.mock_client) + ctnr_opts, override_opts = service._build_container_volume_options( + previous_container=None, + container_options={ + 'volumes': [ + MountSpec.parse({'source': 'vol', 'target': '/data', 'type': 'volume'}), + VolumeSpec.parse('vol:/data:rw'), + ], + 'environment': {}, + }, + override_options={}, + ) + assert 'binds' in override_opts + assert len(override_opts['binds']) == 1 + assert override_opts['binds'][0] == 'vol:/data:rw' + class TestServiceNetwork(unittest.TestCase): def setUp(self): From 1315b51e447f2b15ee9273c0973f08f3c5f9e149 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 23 Apr 2018 18:16:58 -0700 Subject: [PATCH 3396/4072] Incorrect key name for IPAM options check Signed-off-by: Joffrey F --- compose/network.py | 2 +- tests/unit/network_test.py | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/compose/network.py b/compose/network.py index 1a080c40cf6..7803db9799e 100644 --- a/compose/network.py +++ b/compose/network.py @@ -169,7 +169,7 @@ def check_remote_ipam_config(remote, local): raise NetworkConfigChangedError(local.full_name, 'IPAM config aux_addresses') remote_opts = remote_ipam.get('Options') or {} - local_opts = local.ipam.get('options') or {} + local_opts = local.ipam.get('Options') or {} for k in set.union(set(remote_opts.keys()), set(local_opts.keys())): if remote_opts.get(k) != local_opts.get(k): raise NetworkConfigChangedError(local.full_name, 'IPAM option "{}"'.format(k)) diff --git a/tests/unit/network_test.py b/tests/unit/network_test.py index b27339af89c..0e03fc10e43 100644 --- a/tests/unit/network_test.py +++ b/tests/unit/network_test.py @@ -23,7 +23,10 @@ def test_check_remote_network_config_success(self): 'aux_addresses': ['11.0.0.1', '24.25.26.27'], 'ip_range': '156.0.0.1-254' } - ] + ], + 'options': { + 'iface': 'eth0', + } } labels = { 'com.project.tests.istest': 'true', @@ -57,6 +60,9 @@ def test_check_remote_network_config_success(self): 'Subnet': '172.0.0.1/16', 'Gateway': '172.0.0.1' }], + 'Options': { + 'iface': 'eth0', + }, }, 'Labels': remote_labels }, From 385b65032db5106124ac3f62a04efc9ef843968f Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 23 Apr 2018 16:41:10 -0700 Subject: [PATCH 3397/4072] Retrieve objects using legacy (< 1.21) project names Signed-off-by: Joffrey F --- compose/network.py | 66 ++++++++++++++++++++++++++++++++++------------ compose/service.py | 36 +++++++++++++++++++++---- compose/volume.py | 43 +++++++++++++++++++++++++----- 3 files changed, 117 insertions(+), 28 deletions(-) diff --git a/compose/network.py b/compose/network.py index 7803db9799e..98b8f7b1db7 100644 --- a/compose/network.py +++ b/compose/network.py @@ -2,6 +2,7 @@ from __future__ import unicode_literals import logging +import re from collections import OrderedDict from docker.errors import NotFound @@ -10,9 +11,11 @@ from docker.utils import version_gte from docker.utils import version_lt +from . import __version__ from .config import ConfigurationError from .const import LABEL_NETWORK from .const import LABEL_PROJECT +from .const import LABEL_VERSION log = logging.getLogger(__name__) @@ -39,6 +42,7 @@ def __init__(self, client, project, name, driver=None, driver_opts=None, self.enable_ipv6 = enable_ipv6 self.labels = labels self.custom_name = custom_name + self.legacy = False def ensure(self): if self.external: @@ -68,6 +72,14 @@ def ensure(self): data = self.inspect() check_remote_network_config(data, self) except NotFound: + try: + data = self.inspect(legacy=True) + self.legacy = True + check_remote_network_config(data, self) + return + except NotFound: + pass + driver_name = 'the default driver' if self.driver: driver_name = 'driver "{}"'.format(self.driver) @@ -94,18 +106,37 @@ def remove(self): log.info("Network %s is external, skipping", self.full_name) return - log.info("Removing network {}".format(self.full_name)) - self.client.remove_network(self.full_name) + log.info("Removing network {}".format(self.true_name)) + try: + self.client.remove_network(self.full_name) + except NotFound: + self.client.remove_network(self.legacy_full_name) - def inspect(self): + def inspect(self, legacy=False): + if legacy: + return self.client.inspect_network(self.legacy_full_name) return self.client.inspect_network(self.full_name) + @property + def legacy_full_name(self): + if self.custom_name: + return self.name + return '{0}_{1}'.format( + re.sub(r'[_-]', '', self.project), self.name + ) + @property def full_name(self): if self.custom_name: return self.name return '{0}_{1}'.format(self.project, self.name) + @property + def true_name(self): + if self.legacy: + return self.legacy_full_name + return self.full_name + @property def _labels(self): if version_lt(self.client._version, '1.23'): @@ -114,6 +145,7 @@ def _labels(self): labels.update({ LABEL_PROJECT: self.project, LABEL_NETWORK: self.name, + LABEL_VERSION: __version__, }) return labels @@ -150,49 +182,49 @@ def check_remote_ipam_config(remote, local): remote_ipam = remote.get('IPAM') ipam_dict = create_ipam_config_from_dict(local.ipam) if local.ipam.get('driver') and local.ipam.get('driver') != remote_ipam.get('Driver'): - raise NetworkConfigChangedError(local.full_name, 'IPAM driver') + raise NetworkConfigChangedError(local.true_name, 'IPAM driver') if len(ipam_dict['Config']) != 0: if len(ipam_dict['Config']) != len(remote_ipam['Config']): - raise NetworkConfigChangedError(local.full_name, 'IPAM configs') + raise NetworkConfigChangedError(local.true_name, 'IPAM configs') remote_configs = sorted(remote_ipam['Config'], key='Subnet') local_configs = sorted(ipam_dict['Config'], key='Subnet') while local_configs: lc = local_configs.pop() rc = remote_configs.pop() if lc.get('Subnet') != rc.get('Subnet'): - raise NetworkConfigChangedError(local.full_name, 'IPAM config subnet') + raise NetworkConfigChangedError(local.true_name, 'IPAM config subnet') if lc.get('Gateway') is not None and lc.get('Gateway') != rc.get('Gateway'): - raise NetworkConfigChangedError(local.full_name, 'IPAM config gateway') + raise NetworkConfigChangedError(local.true_name, 'IPAM config gateway') if lc.get('IPRange') != rc.get('IPRange'): - raise NetworkConfigChangedError(local.full_name, 'IPAM config ip_range') + raise NetworkConfigChangedError(local.true_name, 'IPAM config ip_range') if sorted(lc.get('AuxiliaryAddresses')) != sorted(rc.get('AuxiliaryAddresses')): - raise NetworkConfigChangedError(local.full_name, 'IPAM config aux_addresses') + raise NetworkConfigChangedError(local.true_name, 'IPAM config aux_addresses') remote_opts = remote_ipam.get('Options') or {} local_opts = local.ipam.get('Options') or {} for k in set.union(set(remote_opts.keys()), set(local_opts.keys())): if remote_opts.get(k) != local_opts.get(k): - raise NetworkConfigChangedError(local.full_name, 'IPAM option "{}"'.format(k)) + raise NetworkConfigChangedError(local.true_name, 'IPAM option "{}"'.format(k)) def check_remote_network_config(remote, local): if local.driver and remote.get('Driver') != local.driver: - raise NetworkConfigChangedError(local.full_name, 'driver') + raise NetworkConfigChangedError(local.true_name, 'driver') local_opts = local.driver_opts or {} remote_opts = remote.get('Options') or {} for k in set.union(set(remote_opts.keys()), set(local_opts.keys())): if k in OPTS_EXCEPTIONS: continue if remote_opts.get(k) != local_opts.get(k): - raise NetworkConfigChangedError(local.full_name, 'option "{}"'.format(k)) + raise NetworkConfigChangedError(local.true_name, 'option "{}"'.format(k)) if local.ipam is not None: check_remote_ipam_config(remote, local) if local.internal is not None and local.internal != remote.get('Internal', False): - raise NetworkConfigChangedError(local.full_name, 'internal') + raise NetworkConfigChangedError(local.true_name, 'internal') if local.enable_ipv6 is not None and local.enable_ipv6 != remote.get('EnableIPv6', False): - raise NetworkConfigChangedError(local.full_name, 'enable_ipv6') + raise NetworkConfigChangedError(local.true_name, 'enable_ipv6') local_labels = local.labels or {} remote_labels = remote.get('Labels', {}) @@ -202,7 +234,7 @@ def check_remote_network_config(remote, local): if remote_labels.get(k) != local_labels.get(k): log.warn( 'Network {}: label "{}" has changed. It may need to be' - ' recreated.'.format(local.full_name, k) + ' recreated.'.format(local.true_name, k) ) @@ -257,7 +289,7 @@ def remove(self): try: network.remove() except NotFound: - log.warn("Network %s not found.", network.full_name) + log.warn("Network %s not found.", network.true_name) def initialize(self): if not self.use_networking: @@ -286,7 +318,7 @@ def get_networks(service_dict, network_definitions): for name, netdef in get_network_defs_for_service(service_dict).items(): network = network_definitions.get(name) if network: - networks[network.full_name] = netdef + networks[network.true_name] = netdef else: raise ConfigurationError( 'Service "{}" uses an undefined network "{}"' diff --git a/compose/service.py b/compose/service.py index bb9e26baa29..0a866161cfa 100644 --- a/compose/service.py +++ b/compose/service.py @@ -51,6 +51,7 @@ from .utils import json_hash from .utils import parse_bytes from .utils import parse_seconds_float +from .version import ComposeVersion log = logging.getLogger(__name__) @@ -192,11 +193,25 @@ def __repr__(self): def containers(self, stopped=False, one_off=False, filters={}): filters.update({'label': self.labels(one_off=one_off)}) - return list(filter(None, [ + result = list(filter(None, [ Container.from_ps(self.client, container) for container in self.client.containers( all=stopped, - filters=filters)])) + filters=filters)]) + ) + if result: + return result + + filters.update({'label': self.labels(one_off=one_off, legacy=True)}) + return list( + filter( + self.has_legacy_proj_name, filter(None, [ + Container.from_ps(self.client, container) + for container in self.client.containers( + all=stopped, + filters=filters)]) + ) + ) def get_container(self, number=1): """Return a :class:`compose.container.Container` for this service. The @@ -380,6 +395,10 @@ def _containers_have_diverged(self, containers): has_diverged = False for c in containers: + if self.has_legacy_proj_name(c): + log.debug('%s has diverged: Legacy project name' % c.name) + has_diverged = True + continue container_config_hash = c.labels.get(LABEL_CONFIG_HASH, None) if container_config_hash != config_hash: log.debug( @@ -1053,11 +1072,12 @@ def build(self, no_cache=False, pull=False, force_rm=False, memory=None, build_a def can_be_built(self): return 'build' in self.options - def labels(self, one_off=False): + def labels(self, one_off=False, legacy=False): + proj_name = self.project if not legacy else re.sub(r'[_-]', '', self.project) return [ - '{0}={1}'.format(LABEL_PROJECT, self.project), + '{0}={1}'.format(LABEL_PROJECT, proj_name), '{0}={1}'.format(LABEL_SERVICE, self.name), - '{0}={1}'.format(LABEL_ONE_OFF, "True" if one_off else "False") + '{0}={1}'.format(LABEL_ONE_OFF, "True" if one_off else "False"), ] @property @@ -1214,6 +1234,12 @@ def _parse_proxy_config(self): return result + def has_legacy_proj_name(self, ctnr): + return ( + ComposeVersion(ctnr.labels.get(LABEL_VERSION)) < ComposeVersion('1.21.0') and + ctnr.project != self.project + ) + def short_id_alias_exists(container, network): aliases = container.get( diff --git a/compose/volume.py b/compose/volume.py index 6bf184045c4..6cad1e0ded3 100644 --- a/compose/volume.py +++ b/compose/volume.py @@ -2,15 +2,19 @@ from __future__ import unicode_literals import logging +import re from docker.errors import NotFound from docker.utils import version_lt +from . import __version__ from .config import ConfigurationError from .config.types import VolumeSpec from .const import LABEL_PROJECT +from .const import LABEL_VERSION from .const import LABEL_VOLUME + log = logging.getLogger(__name__) @@ -25,6 +29,7 @@ def __init__(self, client, project, name, driver=None, driver_opts=None, self.external = external self.labels = labels self.custom_name = custom_name + self.legacy = False def create(self): return self.client.create_volume( @@ -36,15 +41,26 @@ def remove(self): log.info("Volume %s is external, skipping", self.full_name) return log.info("Removing volume %s", self.full_name) - return self.client.remove_volume(self.full_name) + try: + return self.client.remove_volume(self.full_name) + except NotFound: + self.client.remove_volume(self.legacy_full_name) - def inspect(self): + def inspect(self, legacy=False): + if legacy: + return self.client.inspect_volume(self.legacy_full_name) return self.client.inspect_volume(self.full_name) def exists(self): try: self.inspect() except NotFound: + try: + self.inspect(legacy=True) + self.legacy = True + return True + except NotFound: + pass return False return True @@ -54,6 +70,20 @@ def full_name(self): return self.name return '{0}_{1}'.format(self.project, self.name) + @property + def legacy_full_name(self): + if self.custom_name: + return self.name + return '{0}_{1}'.format( + re.sub(r'[_-]', '', self.project), self.name + ) + + @property + def true_name(self): + if self.legacy: + return self.legacy_full_name + return self.full_name + @property def _labels(self): if version_lt(self.client._version, '1.23'): @@ -62,6 +92,7 @@ def _labels(self): labels.update({ LABEL_PROJECT: self.project, LABEL_VOLUME: self.name, + LABEL_VERSION: __version__, }) return labels @@ -94,7 +125,7 @@ def remove(self): try: volume.remove() except NotFound: - log.warn("Volume %s not found.", volume.full_name) + log.warn("Volume %s not found.", volume.true_name) def initialize(self): try: @@ -136,9 +167,9 @@ def namespace_spec(self, volume_spec): if isinstance(volume_spec, VolumeSpec): volume = self.volumes[volume_spec.external] - return volume_spec._replace(external=volume.full_name) + return volume_spec._replace(external=volume.true_name) else: - volume_spec.source = self.volumes[volume_spec.source].full_name + volume_spec.source = self.volumes[volume_spec.source].true_name return volume_spec @@ -152,7 +183,7 @@ def __init__(self, local, property_name, local_value, remote_value): 'first:\n$ docker volume rm {full_name}'.format( vol_name=local.name, property_name=property_name, local_value=local_value, remote_value=remote_value, - full_name=local.full_name + full_name=local.true_name ) ) From d9a6d30f6dccb28cf5a3d5a83e01c8701e172fd7 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 24 Apr 2018 15:48:02 -0700 Subject: [PATCH 3398/4072] Improve legacy network and volume detection Signed-off-by: Joffrey F --- compose/network.py | 26 ++++++++++++++------------ compose/volume.py | 25 +++++++++++++++---------- 2 files changed, 29 insertions(+), 22 deletions(-) diff --git a/compose/network.py b/compose/network.py index 98b8f7b1db7..5e9d929d976 100644 --- a/compose/network.py +++ b/compose/network.py @@ -42,7 +42,7 @@ def __init__(self, client, project, name, driver=None, driver_opts=None, self.enable_ipv6 = enable_ipv6 self.labels = labels self.custom_name = custom_name - self.legacy = False + self.legacy = None def ensure(self): if self.external: @@ -68,25 +68,17 @@ def ensure(self): ) return + self._set_legacy_flag() try: - data = self.inspect() + data = self.inspect(legacy=self.legacy) check_remote_network_config(data, self) except NotFound: - try: - data = self.inspect(legacy=True) - self.legacy = True - check_remote_network_config(data, self) - return - except NotFound: - pass - driver_name = 'the default driver' if self.driver: driver_name = 'driver "{}"'.format(self.driver) log.info( - 'Creating network "{}" with {}' - .format(self.full_name, driver_name) + 'Creating network "{}" with {}'.format(self.full_name, driver_name) ) self.client.create_network( @@ -133,6 +125,7 @@ def full_name(self): @property def true_name(self): + self._set_legacy_flag() if self.legacy: return self.legacy_full_name return self.full_name @@ -149,6 +142,15 @@ def _labels(self): }) return labels + def _set_legacy_flag(self): + if self.legacy is not None: + return + try: + data = self.inspect(legacy=True) + self.legacy = data is not None + except NotFound: + self.legacy = False + def create_ipam_config_from_dict(ipam_dict): if not ipam_dict: diff --git a/compose/volume.py b/compose/volume.py index 6cad1e0ded3..56ff601cd2b 100644 --- a/compose/volume.py +++ b/compose/volume.py @@ -29,7 +29,7 @@ def __init__(self, client, project, name, driver=None, driver_opts=None, self.external = external self.labels = labels self.custom_name = custom_name - self.legacy = False + self.legacy = None def create(self): return self.client.create_volume( @@ -46,21 +46,16 @@ def remove(self): except NotFound: self.client.remove_volume(self.legacy_full_name) - def inspect(self, legacy=False): + def inspect(self, legacy=None): if legacy: return self.client.inspect_volume(self.legacy_full_name) return self.client.inspect_volume(self.full_name) def exists(self): + self._set_legacy_flag() try: - self.inspect() + self.inspect(legacy=self.legacy) except NotFound: - try: - self.inspect(legacy=True) - self.legacy = True - return True - except NotFound: - pass return False return True @@ -80,6 +75,7 @@ def legacy_full_name(self): @property def true_name(self): + self._set_legacy_flag() if self.legacy: return self.legacy_full_name return self.full_name @@ -96,6 +92,15 @@ def _labels(self): }) return labels + def _set_legacy_flag(self): + if self.legacy is not None: + return + try: + data = self.inspect(legacy=True) + self.legacy = data is not None + except NotFound: + self.legacy = False + class ProjectVolumes(object): @@ -155,7 +160,7 @@ def initialize(self): ) volume.create() else: - check_remote_volume_config(volume.inspect(), volume) + check_remote_volume_config(volume.inspect(legacy=volume.legacy), volume) except NotFound: raise ConfigurationError( 'Volume %s specifies nonexistent driver %s' % (volume.name, volume.driver) From 7341dba5696e47e8b7c67b6b041ba91a9e0376ea Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 24 Apr 2018 16:10:59 -0700 Subject: [PATCH 3399/4072] Use true_name for remove operation Signed-off-by: Joffrey F --- compose/network.py | 7 ++----- compose/volume.py | 9 +++------ tests/unit/network_test.py | 3 +++ tests/unit/project_test.py | 2 ++ 4 files changed, 10 insertions(+), 11 deletions(-) diff --git a/compose/network.py b/compose/network.py index 5e9d929d976..9751f20375c 100644 --- a/compose/network.py +++ b/compose/network.py @@ -95,14 +95,11 @@ def ensure(self): def remove(self): if self.external: - log.info("Network %s is external, skipping", self.full_name) + log.info("Network %s is external, skipping", self.true_name) return log.info("Removing network {}".format(self.true_name)) - try: - self.client.remove_network(self.full_name) - except NotFound: - self.client.remove_network(self.legacy_full_name) + self.client.remove_network(self.true_name) def inspect(self, legacy=False): if legacy: diff --git a/compose/volume.py b/compose/volume.py index 56ff601cd2b..7618417ffa4 100644 --- a/compose/volume.py +++ b/compose/volume.py @@ -38,13 +38,10 @@ def create(self): def remove(self): if self.external: - log.info("Volume %s is external, skipping", self.full_name) + log.info("Volume %s is external, skipping", self.true_name) return - log.info("Removing volume %s", self.full_name) - try: - return self.client.remove_volume(self.full_name) - except NotFound: - self.client.remove_volume(self.legacy_full_name) + log.info("Removing volume %s", self.true_name) + return self.client.remove_volume(self.true_name) def inspect(self, legacy=None): if legacy: diff --git a/tests/unit/network_test.py b/tests/unit/network_test.py index 0e03fc10e43..d7ffa289422 100644 --- a/tests/unit/network_test.py +++ b/tests/unit/network_test.py @@ -84,6 +84,7 @@ def test_check_remote_network_config_whitelist(self): {'Driver': 'overlay', 'Options': remote_options}, net ) + @mock.patch('compose.network.Network.true_name', lambda n: n.full_name) def test_check_remote_network_config_driver_mismatch(self): net = Network(None, 'compose_test', 'net1', 'overlay') with pytest.raises(NetworkConfigChangedError) as e: @@ -93,6 +94,7 @@ def test_check_remote_network_config_driver_mismatch(self): assert 'driver has changed' in str(e.value) + @mock.patch('compose.network.Network.true_name', lambda n: n.full_name) def test_check_remote_network_config_options_mismatch(self): net = Network(None, 'compose_test', 'net1', 'overlay') with pytest.raises(NetworkConfigChangedError) as e: @@ -146,6 +148,7 @@ def test_check_remote_network_config_null_remote_ipam_options(self): net ) + @mock.patch('compose.network.Network.true_name', lambda n: n.full_name) def test_check_remote_network_labels_mismatch(self): net = Network(None, 'compose_test', 'net1', 'overlay', labels={ 'com.project.touhou.character': 'sakuya.izayoi' diff --git a/tests/unit/project_test.py b/tests/unit/project_test.py index 83a0147588e..1b6b6651fea 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -60,6 +60,7 @@ def test_from_config_v1(self): assert project.get_service('db').options['image'] == 'busybox:latest' assert not project.networks.use_networking + @mock.patch('compose.network.Network.true_name', lambda n: n.full_name) def test_from_config_v2(self): config = Config( version=V2_0, @@ -217,6 +218,7 @@ def test_use_volumes_from_service_no_container(self): ) assert project.get_service('test')._get_volumes_from() == [container_name + ":rw"] + @mock.patch('compose.network.Network.true_name', lambda n: n.full_name) def test_use_volumes_from_service_container(self): container_ids = ['aabbccddee', '12345'] From 398b13d345dbd013eb92234a90134bc3b4572a43 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 23 Apr 2018 19:08:55 -0700 Subject: [PATCH 3400/4072] Clearly define IPAM config schema for validation Signed-off-by: Joffrey F --- compose/config/config_schema_v2.0.json | 21 ++++++++++++++++++++- compose/config/config_schema_v2.1.json | 21 ++++++++++++++++++++- compose/config/config_schema_v2.2.json | 21 ++++++++++++++++++++- compose/config/config_schema_v2.3.json | 21 ++++++++++++++++++++- compose/config/config_schema_v2.4.json | 21 ++++++++++++++++++++- tests/unit/config/config_test.py | 22 ++++++++++++++++++++++ 6 files changed, 122 insertions(+), 5 deletions(-) diff --git a/compose/config/config_schema_v2.0.json b/compose/config/config_schema_v2.0.json index eddf787eab4..793cef1d6db 100644 --- a/compose/config/config_schema_v2.0.json +++ b/compose/config/config_schema_v2.0.json @@ -281,7 +281,8 @@ "properties": { "driver": {"type": "string"}, "config": { - "type": "array" + "type": "array", + "items": {"$ref": "#/definitions/ipam_config"} }, "options": { "type": "object", @@ -305,6 +306,24 @@ "additionalProperties": false }, + "ipam_config": { + "id": "#/definitions/ipam_config", + "type": "object", + "properties": { + "subnet": {"type": "string"}, + "iprange": {"type": "string"}, + "gateway": {"type": "string"}, + "aux_addresses": { + "type": "object", + "patternProperties": { + "^.+$": {"type": "string"} + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "volume": { "id": "#/definitions/volume", "type": ["object", "null"], diff --git a/compose/config/config_schema_v2.1.json b/compose/config/config_schema_v2.1.json index 5ad5a20ea98..5ea763544fe 100644 --- a/compose/config/config_schema_v2.1.json +++ b/compose/config/config_schema_v2.1.json @@ -332,7 +332,8 @@ "properties": { "driver": {"type": "string"}, "config": { - "type": "array" + "type": "array", + "items": {"$ref": "#/definitions/ipam_config"} }, "options": { "type": "object", @@ -359,6 +360,24 @@ "additionalProperties": false }, + "ipam_config": { + "id": "#/definitions/ipam_config", + "type": "object", + "properties": { + "subnet": {"type": "string"}, + "iprange": {"type": "string"}, + "gateway": {"type": "string"}, + "aux_addresses": { + "type": "object", + "patternProperties": { + "^.+$": {"type": "string"} + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "volume": { "id": "#/definitions/volume", "type": ["object", "null"], diff --git a/compose/config/config_schema_v2.2.json b/compose/config/config_schema_v2.2.json index 26044b6510a..a19d4c94514 100644 --- a/compose/config/config_schema_v2.2.json +++ b/compose/config/config_schema_v2.2.json @@ -341,7 +341,8 @@ "properties": { "driver": {"type": "string"}, "config": { - "type": "array" + "type": "array", + "items": {"$ref": "#/definitions/ipam_config"} }, "options": { "type": "object", @@ -368,6 +369,24 @@ "additionalProperties": false }, + "ipam_config": { + "id": "#/definitions/ipam_config", + "type": "object", + "properties": { + "subnet": {"type": "string"}, + "iprange": {"type": "string"}, + "gateway": {"type": "string"}, + "aux_addresses": { + "type": "object", + "patternProperties": { + "^.+$": {"type": "string"} + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "volume": { "id": "#/definitions/volume", "type": ["object", "null"], diff --git a/compose/config/config_schema_v2.3.json b/compose/config/config_schema_v2.3.json index ac0778f2a0d..78b716a7a7a 100644 --- a/compose/config/config_schema_v2.3.json +++ b/compose/config/config_schema_v2.3.json @@ -385,7 +385,8 @@ "properties": { "driver": {"type": "string"}, "config": { - "type": "array" + "type": "array", + "items": {"$ref": "#/definitions/ipam_config"} }, "options": { "type": "object", @@ -412,6 +413,24 @@ "additionalProperties": false }, + "ipam_config": { + "id": "#/definitions/ipam_config", + "type": "object", + "properties": { + "subnet": {"type": "string"}, + "iprange": {"type": "string"}, + "gateway": {"type": "string"}, + "aux_addresses": { + "type": "object", + "patternProperties": { + "^.+$": {"type": "string"} + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "volume": { "id": "#/definitions/volume", "type": ["object", "null"], diff --git a/compose/config/config_schema_v2.4.json b/compose/config/config_schema_v2.4.json index 731fa2f9b08..a5796d5b12a 100644 --- a/compose/config/config_schema_v2.4.json +++ b/compose/config/config_schema_v2.4.json @@ -384,7 +384,8 @@ "properties": { "driver": {"type": "string"}, "config": { - "type": "array" + "type": "array", + "items": {"$ref": "#/definitions/ipam_config"} }, "options": { "type": "object", @@ -411,6 +412,24 @@ "additionalProperties": false }, + "ipam_config": { + "id": "#/definitions/ipam_config", + "type": "object", + "properties": { + "subnet": {"type": "string"}, + "iprange": {"type": "string"}, + "gateway": {"type": "string"}, + "aux_addresses": { + "type": "object", + "patternProperties": { + "^.+$": {"type": "string"} + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "volume": { "id": "#/definitions/volume", "type": ["object", "null"], diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 8a75648ac40..4562a99ca41 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1322,6 +1322,28 @@ def test_load_bind_mount_relative_path(self): assert mount.type == 'bind' assert mount.source == expected_source + def test_config_invalid_ipam_config(self): + with pytest.raises(ConfigurationError) as excinfo: + config.load( + build_config_details( + { + 'version': str(V2_1), + 'networks': { + 'foo': { + 'driver': 'default', + 'ipam': { + 'driver': 'default', + 'config': ['172.18.0.0/16'], + } + } + } + }, + filename='filename.yml', + ) + ) + assert ('networks.foo.ipam.config contains an invalid type,' + ' it should be an object') in excinfo.exconly() + def test_config_valid_service_names(self): for valid_name in ['_', '-', '.__.', '_what-up.', 'what_.up----', 'whatup']: services = config.load( From 41417aa379a9ff3167d3dec7f276d654ee500877 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 25 Apr 2018 18:08:34 -0700 Subject: [PATCH 3401/4072] Bump SDK version to latest Signed-off-by: Joffrey F --- requirements.txt | 4 ++-- setup.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/requirements.txt b/requirements.txt index 7dce40246a6..93a0cce3527 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,8 +2,8 @@ backports.ssl-match-hostname==3.5.0.1; python_version < '3' cached-property==1.3.0 certifi==2017.4.17 chardet==3.0.4 -docker==3.2.1 -docker-pycreds==0.2.1 +docker==3.3.0 +docker-pycreds==0.2.3 dockerpty==0.4.1 docopt==0.6.2 enum34==1.1.6; python_version < '3.4' diff --git a/setup.py b/setup.py index a7a33363404..422ba5466ee 100644 --- a/setup.py +++ b/setup.py @@ -36,7 +36,7 @@ def find_version(*file_paths): 'requests >= 2.6.1, != 2.11.0, != 2.12.2, != 2.18.0, < 2.19', 'texttable >= 0.9.0, < 0.10', 'websocket-client >= 0.32.0, < 1.0', - 'docker >= 3.2.1, < 4.0', + 'docker >= 3.3.0, < 4.0', 'dockerpty >= 0.4.1, < 0.5', 'six >= 1.3.0, < 2', 'jsonschema >= 2.5.1, < 3', From b1a1c6a2345f05e3905e908e00abc81c8374a4db Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 26 Apr 2018 15:20:45 -0700 Subject: [PATCH 3402/4072] Prevent duplicate binds in generated container config Signed-off-by: Joffrey F --- compose/service.py | 7 ++++--- tests/unit/service_test.py | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/compose/service.py b/compose/service.py index 0a866161cfa..ae9e0bb0879 100644 --- a/compose/service.py +++ b/compose/service.py @@ -877,7 +877,6 @@ def _build_container_volume_options(self, previous_container, container_options, container_volumes, self.options.get('tmpfs') or [], previous_container, container_mounts ) - override_options['binds'] = binds container_options['environment'].update(affinity) container_options['volumes'] = dict((v.internal, {}) for v in container_volumes or {}) @@ -890,13 +889,13 @@ def _build_container_volume_options(self, previous_container, container_options, if m.is_tmpfs: override_options['tmpfs'].append(m.target) else: - override_options['binds'].append(m.legacy_repr()) + binds.append(m.legacy_repr()) container_options['volumes'][m.target] = {} secret_volumes = self.get_secret_volumes() if secret_volumes: if version_lt(self.client.api_version, '1.30'): - override_options['binds'].extend(v.legacy_repr() for v in secret_volumes) + binds.extend(v.legacy_repr() for v in secret_volumes) container_options['volumes'].update( (v.target, {}) for v in secret_volumes ) @@ -904,6 +903,8 @@ def _build_container_volume_options(self, previous_container, container_options, override_options['mounts'] = override_options.get('mounts') or [] override_options['mounts'].extend([build_mount(v) for v in secret_volumes]) + # Remove possible duplicates (see e.g. https://github.com/docker/compose/issues/5885) + override_options['binds'] = list(set(binds)) return container_options, override_options def _get_container_host_config(self, override_options, one_off=False): diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 4ccc48653dd..d50db904491 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -10,6 +10,7 @@ from .. import mock from .. import unittest from compose.config.errors import DependencyError +from compose.config.types import MountSpec from compose.config.types import ServicePort from compose.config.types import ServiceSecret from compose.config.types import VolumeFromSpec @@ -955,6 +956,24 @@ def inspect(name): assert service.create_container().id == 'new_cont_id' + def test_build_volume_options_duplicate_binds(self): + self.mock_client.api_version = '1.29' # Trigger 3.2 format workaround + service = Service('foo', client=self.mock_client) + ctnr_opts, override_opts = service._build_container_volume_options( + previous_container=None, + container_options={ + 'volumes': [ + MountSpec.parse({'source': 'vol', 'target': '/data', 'type': 'volume'}), + VolumeSpec.parse('vol:/data:rw'), + ], + 'environment': {}, + }, + override_options={}, + ) + assert 'binds' in override_opts + assert len(override_opts['binds']) == 1 + assert override_opts['binds'][0] == 'vol:/data:rw' + class TestServiceNetwork(unittest.TestCase): def setUp(self): From 192a6655694620a8eaafed721c02e433b2a5e8fa Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 27 Apr 2018 19:03:45 +0000 Subject: [PATCH 3403/4072] "Bump 1.21.1" Signed-off-by: Joffrey F --- CHANGELOG.md | 26 ++++++++++++++++++++++++++ compose/__init__.py | 2 +- script/run/run.sh | 2 +- 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3709e263d26..18742324f58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,32 @@ Change log ========== +1.21.1 (2018-04-27) +------------------- + +### Bugfixes + +- In 1.21.0, we introduced a change to how project names are sanitized for + internal use in resource names. This caused issues when manipulating an + existing, deployed application whose name had changed as a result. + This release properly detects resources using "legacy" naming conventions. + +- Fixed an issue where specifying an in-context Dockerfile using an absolute + path would fail despite being valid. + +- Fixed a bug where IPAM option changes were incorrectly detected, preventing + redeployments. + +- Validation of v2 files now properly checks the structure of IPAM configs. + +- Improved support for credentials stores on Windows to include binaries using + extensions other than `.exe`. The list of valid extensions is determined by + the contents of the `PATHEXT` environment variable. + +- Fixed a bug where Compose would generate invalid binds containing duplicate + elements with some v3.2 files, triggering errors at the Engine level during + deployment. + 1.21.0 (2018-04-10) ------------------- diff --git a/compose/__init__.py b/compose/__init__.py index 693a1ab18f5..6baeabc14d2 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.21.0' +__version__ = '1.21.1' diff --git a/script/run/run.sh b/script/run/run.sh index 1e4bd9853e3..45e74febd9f 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.21.0" +VERSION="1.21.1" IMAGE="docker/compose:$VERSION" From 4691515420b2e62912b3ededef252677ec10a184 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 18 Apr 2018 16:01:52 -0700 Subject: [PATCH 3404/4072] Inital pass on comprehensive automated release script Signed-off-by: Joffrey F --- script/release/release.md.tmpl | 34 +++++ script/release/release.py | 197 +++++++++++++++++++++++++++ script/release/release/__init__.py | 0 script/release/release/bintray.py | 40 ++++++ script/release/release/const.py | 9 ++ script/release/release/downloader.py | 72 ++++++++++ script/release/release/repository.py | 161 ++++++++++++++++++++++ script/release/release/utils.py | 63 +++++++++ 8 files changed, 576 insertions(+) create mode 100644 script/release/release.md.tmpl create mode 100755 script/release/release.py create mode 100644 script/release/release/__init__.py create mode 100644 script/release/release/bintray.py create mode 100644 script/release/release/const.py create mode 100644 script/release/release/downloader.py create mode 100644 script/release/release/repository.py create mode 100644 script/release/release/utils.py diff --git a/script/release/release.md.tmpl b/script/release/release.md.tmpl new file mode 100644 index 00000000000..ee97ef104ea --- /dev/null +++ b/script/release/release.md.tmpl @@ -0,0 +1,34 @@ +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)**. + +Docker for Mac and Windows will automatically install the latest version of Docker Engine for you. + +Alternatively, you can use the usual commands to install or upgrade Compose: + +``` +curl -L https://github.com/docker/compose/releases/download/{{version}}/docker-compose-`uname -s`-`uname -m` -o /usr/local/bin/docker-compose +chmod +x /usr/local/bin/docker-compose +``` + +See the [install docs](https://docs.docker.com/compose/install/) for more install options and instructions. + +## Compose file format compatibility matrix + +| Compose file format | Docker Engine | +| --- | --- | +{% for engine, formats in compat_matrix.items() -%} +| {% for format in formats %}{{format}}{% if not loop.last %}, {% endif %}{% endfor %} | {{engine}}+ | +{% endfor -%} + +## Changes + +{{changelog}} + +Thanks to {% for name in contributors %}@{{name}}{% if not loop.last %}, {% endif %}{% endfor %} for contributing to this release! + +## Integrity check + +Binary name | SHA-256 sum +| --- | --- | +{% for filename, sha in integrity.items() -%} +| `{{filename}}` | `{{sha[1]}}` | +{% endfor -%} diff --git a/script/release/release.py b/script/release/release.py new file mode 100755 index 00000000000..f23146288d3 --- /dev/null +++ b/script/release/release.py @@ -0,0 +1,197 @@ +from __future__ import absolute_import +from __future__ import print_function +from __future__ import unicode_literals + +import argparse +import os +import sys +import time + +from jinja2 import Template +from release.bintray import BintrayAPI +from release.const import BINTRAY_ORG +from release.const import NAME +from release.const import REPO_ROOT +from release.downloader import BinaryDownloader +from release.repository import get_contributors +from release.repository import Repository +from release.repository import upload_assets +from release.utils import branch_name +from release.utils import compatibility_matrix +from release.utils import read_release_notes_from_changelog +from release.utils import ScriptError +from release.utils import update_init_py_version +from release.utils import update_run_sh_version + + +def create_initial_branch(repository, release, base, bintray_user): + release_branch = repository.create_release_branch(release, base) + print('Updating version info in __init__.py and run.sh') + update_run_sh_version(release) + update_init_py_version(release) + + input('Please add the release notes to the CHANGELOG.md file, then press Enter to continue.') + proceed = '' + while proceed.lower() != 'y': + print(repository.diff()) + proceed = input('Are these changes ok? y/N ') + + repository.create_bump_commit(release_branch, release) + repository.push_branch_to_remote(release_branch) + + bintray_api = BintrayAPI(os.environ['BINTRAY_TOKEN'], bintray_user) + print('Creating data repository {} on bintray'.format(release_branch.name)) + bintray_api.create_repository(BINTRAY_ORG, release_branch.name, 'generic') + + +def monitor_pr_status(pr_data): + print('Waiting for CI to complete...') + last_commit = pr_data.get_commits().reversed[0] + while True: + status = last_commit.get_combined_status() + if status.state == 'pending': + summary = { + 'pending': 0, + 'success': 0, + 'failure': 0, + } + for detail in status.statuses: + summary[detail.state] += 1 + print('{pending} pending, {success} successes, {failure} failures'.format(**summary)) + if status.total_count == 0: + # Mostly for testing purposes against repos with no CI setup + return True + time.sleep(30) + elif status.state == 'success': + print('{} successes: all clear!'.format(status.total_count)) + return True + else: + raise ScriptError('CI failure detected') + + +def create_release_draft(repository, version, pr_data, files): + print('Creating Github release draft') + with open(os.path.join(os.path.dirname(__file__), 'release.md.tmpl'), 'r') as f: + template = Template(f.read()) + print('Rendering release notes based on template') + release_notes = template.render( + version=version, + compat_matrix=compatibility_matrix(), + integrity=files, + contributors=get_contributors(pr_data), + changelog=read_release_notes_from_changelog(), + ) + gh_release = repository.create_release( + version, release_notes, draft=True, prerelease='-rc' in version, + target_commitish='release' + ) + print('Release draft initialized') + return gh_release + + +def resume(args): + raise NotImplementedError() + try: + repository = Repository(REPO_ROOT, args.repo or NAME) + br_name = branch_name(args.release) + if not repository.branch_exists(br_name): + raise ScriptError('No local branch exists for this release.') + # release_branch = repository.checkout_branch(br_name) + except ScriptError as e: + print(e) + return 1 + return 0 + + +def cancel(args): + try: + repository = Repository(REPO_ROOT, args.repo or NAME) + repository.close_release_pr(args.release) + repository.remove_release(args.release) + repository.remove_bump_branch(args.release) + # TODO: uncomment after testing is complete + # bintray_api = BintrayAPI(os.environ['BINTRAY_TOKEN'], args.bintray_user) + # print('Removing Bintray data repository for {}'.format(args.release)) + # bintray_api.delete_repository(BINTRAY_ORG, branch_name(args.release)) + except ScriptError as e: + print(e) + return 1 + print('Release cancellation complete.') + return 0 + + +def start(args): + try: + repository = Repository(REPO_ROOT, args.repo or NAME) + create_initial_branch(repository, args.release, args.base, args.bintray_user) + pr_data = repository.create_release_pull_request(args.release) + monitor_pr_status(pr_data) + downloader = BinaryDownloader(args.destination) + files = downloader.download_all(args.release) + gh_release = create_release_draft(repository, args.release, pr_data, files) + upload_assets(gh_release, files) + except ScriptError as e: + print(e) + return 1 + + return 0 + + +def main(): + if 'GITHUB_TOKEN' not in os.environ: + print('GITHUB_TOKEN environment variable must be set') + return 1 + + if 'BINTRAY_TOKEN' not in os.environ: + print('BINTRAY_TOKEN environment variable must be set') + return 1 + + parser = argparse.ArgumentParser( + description='Orchestrate a new release of docker/compose. This tool assumes that you have' + 'obtained a Github API token and Bintray API key and set the GITHUB_TOKEN and' + 'BINTRAY_TOKEN environment variables accordingly.', + epilog='''Example uses: + * Start a new feature release (includes all changes currently in master) + release.py -b user start 1.23.0 + * Start a new patch release + release.py -b user --patch 1.21.0 start 1.21.1 + * Cancel / rollback an existing release draft + release.py -b user cancel 1.23.0 + * Restart a previously aborted patch release + release.py -b user -p 1.21.0 resume 1.21.1 + ''', formatter_class=argparse.RawTextHelpFormatter) + parser.add_argument( + 'action', choices=['start', 'resume', 'cancel'], + help='The action to be performed for this release' + ) + parser.add_argument('release', help='Release number, e.g. 1.9.0-rc1, 2.1.1') + parser.add_argument( + '--patch', '-p', dest='base', + help='Which version is being patched by this release' + ) + parser.add_argument( + '--repo', '-r', dest='repo', + help='Start a release for the given repo (default: {})'.format(NAME) + ) + parser.add_argument( + '-b', dest='bintray_user', required=True, metavar='USER', + help='Username associated with the Bintray API key' + ) + parser.add_argument( + '--destination', '-o', metavar='DIR', default='binaries', + help='Directory where release binaries will be downloaded relative to the project root' + ) + args = parser.parse_args() + + if args.action == 'start': + return start(args) + elif args.action == 'resume': + return resume(args) + elif args.action == 'cancel': + return cancel(args) + print('Unexpected action "{}"'.format(args.action), file=sys.stderr) + return 1 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/script/release/release/__init__.py b/script/release/release/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/script/release/release/bintray.py b/script/release/release/bintray.py new file mode 100644 index 00000000000..d99d372c686 --- /dev/null +++ b/script/release/release/bintray.py @@ -0,0 +1,40 @@ +from __future__ import absolute_import +from __future__ import unicode_literals + +import json + +import requests + +from .const import NAME + + +class BintrayAPI(requests.Session): + def __init__(self, api_key, user, *args, **kwargs): + super(BintrayAPI, self).__init__(*args, **kwargs) + self.auth = (user, api_key) + self.base_url = 'https://api.bintray.com/' + + def create_repository(self, subject, repo_name, repo_type='generic'): + url = '{base}/repos/{subject}/{repo_name}'.format( + base=self.base_url, subject=subject, repo_name=repo_name, + ) + data = { + 'name': repo_name, + 'type': repo_type, + 'private': False, + 'desc': 'Automated release for {}: {}'.format(NAME, repo_name), + 'labels': ['docker-compose', 'docker', 'release-bot'], + } + return self.post_json(url, data) + + def delete_repository(self, subject, repo_name): + url = '{base}/repos/{subject}/{repo_name}'.format( + base=self.base_url, subject=subject, repo_name=repo_name, + ) + return self.delete(url) + + def post_json(self, url, data, **kwargs): + if 'headers' not in kwargs: + kwargs['headers'] = {} + kwargs['headers']['Content-Type'] = 'application/json' + return self.post(url, data=json.dumps(data), **kwargs) diff --git a/script/release/release/const.py b/script/release/release/const.py new file mode 100644 index 00000000000..34f338a8980 --- /dev/null +++ b/script/release/release/const.py @@ -0,0 +1,9 @@ +from __future__ import absolute_import +from __future__ import unicode_literals + +import os + + +REPO_ROOT = os.path.join(os.path.dirname(__file__), '..', '..', '..') +NAME = 'shin-/compose' +BINTRAY_ORG = 'shin-compose' diff --git a/script/release/release/downloader.py b/script/release/release/downloader.py new file mode 100644 index 00000000000..cd43bc993bf --- /dev/null +++ b/script/release/release/downloader.py @@ -0,0 +1,72 @@ +from __future__ import absolute_import +from __future__ import print_function +from __future__ import unicode_literals + +import hashlib +import os + +import requests + +from .const import BINTRAY_ORG +from .const import NAME +from .const import REPO_ROOT +from .utils import branch_name + + +class BinaryDownloader(requests.Session): + base_bintray_url = 'https://dl.bintray.com/{}'.format(BINTRAY_ORG) + base_appveyor_url = 'https://ci.appveyor.com/api/projects/{}/artifacts/'.format(NAME) + + def __init__(self, destination, *args, **kwargs): + super(BinaryDownloader, self).__init__(*args, **kwargs) + self.destination = destination + os.makedirs(self.destination, exist_ok=True) + + def download_from_bintray(self, repo_name, filename): + print('Downloading {} from bintray'.format(filename)) + url = '{base}/{repo_name}/{filename}'.format( + base=self.base_bintray_url, repo_name=repo_name, filename=filename + ) + full_dest = os.path.join(REPO_ROOT, self.destination, filename) + return self._download(url, full_dest) + + def download_from_appveyor(self, branch_name, filename): + print('Downloading {} from appveyor'.format(filename)) + url = '{base}/dist%2F{filename}?branch={branch_name}'.format( + base=self.base_appveyor_url, filename=filename, branch_name=branch_name + ) + full_dest = os.path.join(REPO_ROOT, self.destination, filename) + return self.download(url, full_dest) + + def _download(self, url, full_dest): + m = hashlib.sha256() + with open(full_dest, 'wb') as f: + r = self.get(url, stream=True) + for chunk in r.iter_content(chunk_size=1024 * 600, decode_unicode=False): + print('.', end='', flush=True) + m.update(chunk) + f.write(chunk) + + print(' download complete') + hex_digest = m.hexdigest() + with open(full_dest + '.sha256', 'w') as f: + f.write('{} {}\n'.format(hex_digest, os.path.basename(full_dest))) + return full_dest, hex_digest + + def download_all(self, version): + files = { + 'docker-compose-Darwin-x86_64': None, + 'docker-compose-Linux-x86_64': None, + # 'docker-compose-Windows-x86_64.exe': None, + } + + for filename in files.keys(): + if 'Windows' in filename: + files[filename] = self.download_from_appveyor( + branch_name(version), filename + ) + else: + files[filename] = self.download_from_bintray( + branch_name(version), filename + ) + return files diff --git a/script/release/release/repository.py b/script/release/release/repository.py new file mode 100644 index 00000000000..77c697a9961 --- /dev/null +++ b/script/release/release/repository.py @@ -0,0 +1,161 @@ +from __future__ import absolute_import +from __future__ import unicode_literals + +import os + +from git import GitCommandError +from git import Repo +from github import Github + +from .const import NAME +from .const import REPO_ROOT +from .utils import branch_name +from .utils import read_release_notes_from_changelog +from .utils import ScriptError + + +class Repository(object): + def __init__(self, root=None, gh_name=None): + if root is None: + root = REPO_ROOT + if gh_name is None: + gh_name = NAME + self.git_repo = Repo(root) + self.gh_client = Github(os.environ['GITHUB_TOKEN']) + self.gh_repo = self.gh_client.get_repo(gh_name) + + def create_release_branch(self, version, base=None): + print('Creating release branch {} based on {}...'.format(version, base or 'master')) + remote = self.find_remote(self.gh_repo.full_name) + remote.fetch() + if self.branch_exists(branch_name(version)): + raise ScriptError( + "Branch {} already exists locally. " + "Please remove it before running the release script.".format(branch_name(version)) + ) + if base is not None: + base = self.git_repo.tag('refs/tags/{}'.format(base)) + else: + base = 'refs/remotes/{}/master'.format(remote.name) + release_branch = self.git_repo.create_head(branch_name(version), commit=base) + release_branch.checkout() + self.git_repo.git.merge('--strategy=ours', '--no-edit', '{}/release'.format(remote.name)) + with release_branch.config_writer() as cfg: + cfg.set_value('release', version) + return release_branch + + def find_remote(self, remote_name=None): + if not remote_name: + remote_name = self.gh_repo.full_name + for remote in self.git_repo.remotes: + for url in remote.urls: + if remote_name in url: + return remote + return None + + def create_bump_commit(self, bump_branch, version): + print('Creating bump commit...') + bump_branch.checkout() + self.git_repo.git.commit('-a', '-s', '-m "Bump {}"'.format(version), '--no-verify') + + def diff(self): + return self.git_repo.git.diff() + + def checkout_branch(self, name): + return self.git_repo.branches[name].checkout() + + def push_branch_to_remote(self, branch, remote_name=None): + print('Pushing branch {} to remote...'.format(branch.name)) + remote = self.find_remote(remote_name) + remote.push(refspec=branch) + + def branch_exists(self, name): + return name in [h.name for h in self.git_repo.heads] + + def create_release_pull_request(self, version): + return self.gh_repo.create_pull( + title='Bump {}'.format(version), + body='Automated release for docker-compose {}\n\n{}'.format( + version, read_release_notes_from_changelog() + ), + base='release', + head=branch_name(version), + ) + + def create_release(self, version, release_notes, **kwargs): + return self.gh_repo.create_git_release( + tag=version, name=version, message=release_notes, **kwargs + ) + + def remove_release(self, version): + print('Removing release draft for {}'.format(version)) + releases = self.gh_repo.get_releases() + for release in releases: + if release.tag_name == version and release.title == version: + if not release.draft: + print( + 'The release at {} is no longer a draft. If you TRULY intend ' + 'to remove it, please do so manually.' + ) + continue + release.delete_release() + + def remove_bump_branch(self, version, remote_name=None): + name = branch_name(version) + if not self.branch_exists(name): + return False + print('Removing local branch "{}"'.format(name)) + if self.git_repo.active_branch.name == name: + print('Active branch is about to be deleted. Checking out to master...') + try: + self.checkout_branch('master') + except GitCommandError: + raise ScriptError( + 'Unable to checkout master. Try stashing local changes before proceeding.' + ) + self.git_repo.branches[name].delete(self.git_repo, name, force=True) + print('Removing remote branch "{}"'.format(name)) + remote = self.find_remote(remote_name) + try: + remote.push(name, delete=True) + except GitCommandError as e: + if 'remote ref does not exist' in str(e): + return False + raise ScriptError( + 'Error trying to remove remote branch: {}'.format(e) + ) + return True + + def close_release_pr(self, version): + print('Retrieving and closing release PR for {}'.format(version)) + name = branch_name(version) + open_prs = self.gh_repo.get_pulls(state='open') + count = 0 + for pr in open_prs: + if pr.head.ref == name: + print('Found matching PR #{}'.format(pr.number)) + pr.edit(state='closed') + count += 1 + if count == 0: + print('No open PR for this release branch.') + return count + + +def get_contributors(pr_data): + commits = pr_data.get_commits() + authors = {} + for commit in commits: + author = commit.author.login + authors[author] = authors.get(author, 0) + 1 + return [x[0] for x in sorted(list(authors.items()), key=lambda x: x[1])] + + +def upload_assets(gh_release, files): + print('Uploading binaries and hash sums') + for filename, filedata in files.items(): + print('Uploading {}...'.format(filename)) + gh_release.upload_asset(filedata[0], content_type='application/octet-stream') + gh_release.upload_asset('{}.sha256'.format(filedata[0]), content_type='text/plain') + gh_release.upload_asset( + os.path.join(REPO_ROOT, 'script', 'run', 'run.sh'), content_type='text/plain' + ) diff --git a/script/release/release/utils.py b/script/release/release/utils.py new file mode 100644 index 00000000000..b0e1f6a8487 --- /dev/null +++ b/script/release/release/utils.py @@ -0,0 +1,63 @@ +from __future__ import absolute_import +from __future__ import unicode_literals + +import os +import re + +from .const import REPO_ROOT +from compose import const as compose_const + +section_header_re = re.compile(r'^[0-9]+\.[0-9]+\.[0-9]+ \([0-9]{4}-[01][0-9]-[0-3][0-9]\)$') + + +class ScriptError(Exception): + pass + + +def branch_name(version): + return 'bump-{}'.format(version) + + +def read_release_notes_from_changelog(): + with open(os.path.join(REPO_ROOT, 'CHANGELOG.md'), 'r') as f: + lines = f.readlines() + i = 0 + while i < len(lines): + if section_header_re.match(lines[i]): + break + i += 1 + + j = i + 1 + while j < len(lines): + if section_header_re.match(lines[j]): + break + j += 1 + + return ''.join(lines[i + 2:j - 1]) + + +def update_init_py_version(version): + path = os.path.join(REPO_ROOT, 'compose', '__init__.py') + with open(path, 'r') as f: + contents = f.read() + contents = re.sub(r"__version__ = '[0-9a-z.-]+'", "__version__ = '{}'".format(version), contents) + with open(path, 'w') as f: + f.write(contents) + + +def update_run_sh_version(version): + path = os.path.join(REPO_ROOT, 'script', 'run', 'run.sh') + with open(path, 'r') as f: + contents = f.read() + contents = re.sub(r'VERSION="[0-9a-z.-]+"', 'VERSION="{}"'.format(version), contents) + with open(path, 'w') as f: + f.write(contents) + + +def compatibility_matrix(): + result = {} + for engine_version in compose_const.API_VERSION_TO_ENGINE_VERSION.values(): + result[engine_version] = [] + for fmt, api_version in compose_const.API_VERSIONS.items(): + result[compose_const.API_VERSION_TO_ENGINE_VERSION[api_version]].append(fmt.vstring) + return result From e4c5b2a248e239ad6c1720ce2df63a6132c690a8 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 18 Apr 2018 16:58:24 -0700 Subject: [PATCH 3405/4072] Implement resuming a release Signed-off-by: Joffrey F --- script/release/release.py | 52 ++++++++++++++++++++++++---- script/release/release/repository.py | 21 ++++++++++- 2 files changed, 65 insertions(+), 8 deletions(-) diff --git a/script/release/release.py b/script/release/release.py index f23146288d3..aa6c6198be0 100755 --- a/script/release/release.py +++ b/script/release/release.py @@ -26,6 +26,12 @@ def create_initial_branch(repository, release, base, bintray_user): release_branch = repository.create_release_branch(release, base) + return create_bump_commit(repository, release_branch, bintray_user) + + +def create_bump_commit(repository, release_branch, bintray_user): + with release_branch.config_reader() as cfg: + release = cfg.get('release') print('Updating version info in __init__.py and run.sh') update_run_sh_version(release) update_init_py_version(release) @@ -36,7 +42,8 @@ def create_initial_branch(repository, release, base, bintray_user): print(repository.diff()) proceed = input('Are these changes ok? y/N ') - repository.create_bump_commit(release_branch, release) + if repository.diff(): + repository.create_bump_commit(release_branch, release) repository.push_branch_to_remote(release_branch) bintray_api = BintrayAPI(os.environ['BINTRAY_TOKEN'], bintray_user) @@ -89,17 +96,48 @@ def create_release_draft(repository, version, pr_data, files): return gh_release +def print_final_instructions(gh_release): + print(""" +You're almost done! The following steps should be executed after you've +verified that everything is in order and are ready to make the release public: +1. +2. +3.""") + + def resume(args): - raise NotImplementedError() try: repository = Repository(REPO_ROOT, args.repo or NAME) br_name = branch_name(args.release) if not repository.branch_exists(br_name): raise ScriptError('No local branch exists for this release.') - # release_branch = repository.checkout_branch(br_name) + release_branch = repository.checkout_branch(br_name) + create_bump_commit(repository, release_branch, args.bintray_user) + pr_data = repository.find_release_pr(args.release) + if not pr_data: + pr_data = repository.create_release_pull_request(args.release) + monitor_pr_status(pr_data) + downloader = BinaryDownloader(args.destination) + files = downloader.download_all(args.release) + gh_release = repository.find_release(args.release) + if not gh_release: + gh_release = create_release_draft(repository, args.release, pr_data, files) + elif not gh_release.draft: + print('WARNING!! Found non-draft (public) release for this version!') + proceed = input( + 'Are you sure you wish to proceed? Modifying an already ' + 'released version is dangerous! y/N' + ) + if proceed.lower() != 'y': + raise ScriptError('Aborting release') + for asset in gh_release.get_assets(): + asset.delete_asset() + upload_assets(gh_release, files) except ScriptError as e: print(e) return 1 + + print_final_instructions(gh_release) return 0 @@ -134,6 +172,7 @@ def start(args): print(e) return 1 + print_final_instructions(gh_release) return 0 @@ -147,8 +186,8 @@ def main(): return 1 parser = argparse.ArgumentParser( - description='Orchestrate a new release of docker/compose. This tool assumes that you have' - 'obtained a Github API token and Bintray API key and set the GITHUB_TOKEN and' + description='Orchestrate a new release of docker/compose. This tool assumes that you have ' + 'obtained a Github API token and Bintray API key and set the GITHUB_TOKEN and ' 'BINTRAY_TOKEN environment variables accordingly.', epilog='''Example uses: * Start a new feature release (includes all changes currently in master) @@ -158,8 +197,7 @@ def main(): * Cancel / rollback an existing release draft release.py -b user cancel 1.23.0 * Restart a previously aborted patch release - release.py -b user -p 1.21.0 resume 1.21.1 - ''', formatter_class=argparse.RawTextHelpFormatter) + release.py -b user -p 1.21.0 resume 1.21.1''', formatter_class=argparse.RawTextHelpFormatter) parser.add_argument( 'action', choices=['start', 'resume', 'cancel'], help='The action to be performed for this release' diff --git a/script/release/release/repository.py b/script/release/release/repository.py index 77c697a9961..18c2dbf2c39 100644 --- a/script/release/release/repository.py +++ b/script/release/release/repository.py @@ -67,7 +67,7 @@ def checkout_branch(self, name): def push_branch_to_remote(self, branch, remote_name=None): print('Pushing branch {} to remote...'.format(branch.name)) remote = self.find_remote(remote_name) - remote.push(refspec=branch) + remote.push(refspec=branch, force=True) def branch_exists(self, name): return name in [h.name for h in self.git_repo.heads] @@ -87,6 +87,14 @@ def create_release(self, version, release_notes, **kwargs): tag=version, name=version, message=release_notes, **kwargs ) + def find_release(self, version): + print('Retrieving release draft for {}'.format(version)) + releases = self.gh_repo.get_releases() + for release in releases: + if release.tag_name == version and release.title == version: + return release + return None + def remove_release(self, version): print('Removing release draft for {}'.format(version)) releases = self.gh_repo.get_releases() @@ -126,6 +134,17 @@ def remove_bump_branch(self, version, remote_name=None): ) return True + def find_release_pr(self, version): + print('Retrieving release PR for {}'.format(version)) + name = branch_name(version) + open_prs = self.gh_repo.get_pulls(state='open') + for pr in open_prs: + if pr.head.ref == name: + print('Found matching PR #{}'.format(pr.number)) + return pr + print('No open PR for this release branch.') + return None + def close_release_pr(self, version): print('Retrieving and closing release PR for {}'.format(version)) name = branch_name(version) From 0f4dbba0ec2a19593da198fe06ee5947078b2951 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 18 Apr 2018 17:02:15 -0700 Subject: [PATCH 3406/4072] Temp test Signed-off-by: Joffrey F --- script/release/release/repository.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/release/release/repository.py b/script/release/release/repository.py index 18c2dbf2c39..c84c9e1b538 100644 --- a/script/release/release/repository.py +++ b/script/release/release/repository.py @@ -36,7 +36,7 @@ def create_release_branch(self, version, base=None): if base is not None: base = self.git_repo.tag('refs/tags/{}'.format(base)) else: - base = 'refs/remotes/{}/master'.format(remote.name) + base = 'refs/remotes/{}/automated-releases'.format(remote.name) release_branch = self.git_repo.create_head(branch_name(version), commit=base) release_branch.checkout() self.git_repo.git.merge('--strategy=ours', '--no-edit', '{}/release'.format(remote.name)) From f083ef3d17a3eb936bef3266a7b371fd626fc5be Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 18 Apr 2018 17:07:41 -0700 Subject: [PATCH 3407/4072] Added logging for asset removal Signed-off-by: Joffrey F --- script/release/release.py | 4 ++-- script/release/release/repository.py | 7 +++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/script/release/release.py b/script/release/release.py index aa6c6198be0..b72fb254611 100755 --- a/script/release/release.py +++ b/script/release/release.py @@ -13,6 +13,7 @@ from release.const import NAME from release.const import REPO_ROOT from release.downloader import BinaryDownloader +from release.repository import delete_assets from release.repository import get_contributors from release.repository import Repository from release.repository import upload_assets @@ -130,8 +131,7 @@ def resume(args): ) if proceed.lower() != 'y': raise ScriptError('Aborting release') - for asset in gh_release.get_assets(): - asset.delete_asset() + delete_assets(gh_release) upload_assets(gh_release, files) except ScriptError as e: print(e) diff --git a/script/release/release/repository.py b/script/release/release/repository.py index c84c9e1b538..d7034f8bda0 100644 --- a/script/release/release/repository.py +++ b/script/release/release/repository.py @@ -178,3 +178,10 @@ def upload_assets(gh_release, files): gh_release.upload_asset( os.path.join(REPO_ROOT, 'script', 'run', 'run.sh'), content_type='text/plain' ) + + +def delete_assets(gh_release): + print('Removing previously uploaded assets') + for asset in gh_release.get_assets(): + print('Deleting asset {}'.format(asset.name)) + asset.delete_asset() From fbbac04fb795b756d870a4607fdb5b57bd65d139 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 18 Apr 2018 18:33:20 -0700 Subject: [PATCH 3408/4072] Add images build step and finalize placeholder Signed-off-by: Joffrey F --- script/release/release.py | 116 ++++++++++++++++++++++----- script/release/release/repository.py | 5 ++ 2 files changed, 101 insertions(+), 20 deletions(-) diff --git a/script/release/release.py b/script/release/release.py index b72fb254611..848ab9751de 100755 --- a/script/release/release.py +++ b/script/release/release.py @@ -4,9 +4,11 @@ import argparse import os +import shutil import sys import time +import docker from jinja2 import Template from release.bintray import BintrayAPI from release.const import BINTRAY_ORG @@ -77,6 +79,15 @@ def monitor_pr_status(pr_data): raise ScriptError('CI failure detected') +def check_pr_mergeable(pr_data): + if not pr_data.mergeable: + print( + 'WARNING!! PR #{} can not currently be merged. You will need to ' + 'resolve the conflicts manually before finalizing the release.'.format(pr_data.number) + ) + return pr_data.mergeable + + def create_release_draft(repository, version, pr_data, files): print('Creating Github release draft') with open(os.path.join(os.path.dirname(__file__), 'release.md.tmpl'), 'r') as f: @@ -97,13 +108,51 @@ def create_release_draft(repository, version, pr_data, files): return gh_release -def print_final_instructions(gh_release): - print(""" -You're almost done! The following steps should be executed after you've -verified that everything is in order and are ready to make the release public: -1. -2. -3.""") +def build_images(repository, files, version): + print("Building release images...") + repository.write_git_sha() + docker_client = docker.APIClient(**docker.utils.kwargs_from_env()) + distdir = os.path.join(REPO_ROOT, 'dist') + os.makedirs(distdir, exist_ok=True) + shutil.copy(files['docker-compose-Linux-x86_64'][0], distdir) + print('Building docker/compose image') + logstream = docker_client.build( + REPO_ROOT, tag='docker/compose:{}'.format(version), dockerfile='Dockerfile.run', + decode=True + ) + for chunk in logstream: + if 'error' in chunk: + raise ScriptError('Build error: {}'.format(chunk['error'])) + if 'stream' in chunk: + print(chunk['stream'], end='') + + print('Building test image (for UCP e2e)') + logstream = docker_client.build( + REPO_ROOT, tag='docker-compose-tests:tmp', decode=True + ) + for chunk in logstream: + if 'error' in chunk: + raise ScriptError('Build error: {}'.format(chunk['error'])) + if 'stream' in chunk: + print(chunk['stream'], end='') + + container = docker_client.create_container( + 'docker-compose-tests:tmp', entrypoint='tox' + ) + docker_client.commit(container, 'docker/compose-tests:latest') + docker_client.tag('docker/compose-tests:latest', 'docker/compose-tests:{}'.format(version)) + docker_client.remove_container(container, force=True) + docker_client.remove_image('docker-compose-tests:tmp', force=True) + + +def print_final_instructions(args): + print( + "You're almost done! Please verify that everything is in order and " + "you are ready to make the release public, then run the following " + "command:\n{exe} -b {user} finalize {version}".format( + exe=sys.argv[0], user=args.bintray_user, version=args.release + ) + ) def resume(args): @@ -117,6 +166,7 @@ def resume(args): pr_data = repository.find_release_pr(args.release) if not pr_data: pr_data = repository.create_release_pull_request(args.release) + check_pr_mergeable(pr_data) monitor_pr_status(pr_data) downloader = BinaryDownloader(args.destination) files = downloader.download_all(args.release) @@ -133,11 +183,12 @@ def resume(args): raise ScriptError('Aborting release') delete_assets(gh_release) upload_assets(gh_release, files) + build_images(repository, files, args.release) except ScriptError as e: print(e) return 1 - print_final_instructions(gh_release) + print_final_instructions(args) return 0 @@ -163,19 +214,50 @@ def start(args): repository = Repository(REPO_ROOT, args.repo or NAME) create_initial_branch(repository, args.release, args.base, args.bintray_user) pr_data = repository.create_release_pull_request(args.release) + check_pr_mergeable(pr_data) monitor_pr_status(pr_data) downloader = BinaryDownloader(args.destination) files = downloader.download_all(args.release) gh_release = create_release_draft(repository, args.release, pr_data, files) upload_assets(gh_release, files) + build_images(repository, files, args.release) + except ScriptError as e: + print(e) + return 1 + + print_final_instructions(args) + return 0 + + +def finalize(args): + try: + raise NotImplementedError() except ScriptError as e: print(e) return 1 - print_final_instructions(gh_release) return 0 +ACTIONS = [ + 'start', + 'cancel', + 'resume', + 'finalize', +] + +EPILOG = '''Example uses: + * Start a new feature release (includes all changes currently in master) + release.py -b user start 1.23.0 + * Start a new patch release + release.py -b user --patch 1.21.0 start 1.21.1 + * Cancel / rollback an existing release draft + release.py -b user cancel 1.23.0 + * Restart a previously aborted patch release + release.py -b user -p 1.21.0 resume 1.21.1 +''' + + def main(): if 'GITHUB_TOKEN' not in os.environ: print('GITHUB_TOKEN environment variable must be set') @@ -189,18 +271,9 @@ def main(): description='Orchestrate a new release of docker/compose. This tool assumes that you have ' 'obtained a Github API token and Bintray API key and set the GITHUB_TOKEN and ' 'BINTRAY_TOKEN environment variables accordingly.', - epilog='''Example uses: - * Start a new feature release (includes all changes currently in master) - release.py -b user start 1.23.0 - * Start a new patch release - release.py -b user --patch 1.21.0 start 1.21.1 - * Cancel / rollback an existing release draft - release.py -b user cancel 1.23.0 - * Restart a previously aborted patch release - release.py -b user -p 1.21.0 resume 1.21.1''', formatter_class=argparse.RawTextHelpFormatter) + epilog=EPILOG, formatter_class=argparse.RawTextHelpFormatter) parser.add_argument( - 'action', choices=['start', 'resume', 'cancel'], - help='The action to be performed for this release' + 'action', choices=ACTIONS, help='The action to be performed for this release' ) parser.add_argument('release', help='Release number, e.g. 1.9.0-rc1, 2.1.1') parser.add_argument( @@ -227,6 +300,9 @@ def main(): return resume(args) elif args.action == 'cancel': return cancel(args) + elif args.action == 'finalize': + return finalize(args) + print('Unexpected action "{}"'.format(args.action), file=sys.stderr) return 1 diff --git a/script/release/release/repository.py b/script/release/release/repository.py index d7034f8bda0..dc4c6c46632 100644 --- a/script/release/release/repository.py +++ b/script/release/release/repository.py @@ -159,6 +159,10 @@ def close_release_pr(self, version): print('No open PR for this release branch.') return count + def write_git_sha(self): + with open(os.path.join(REPO_ROOT, 'compose', 'GITSHA'), 'w') as f: + f.write(self.git_repo.head.commit.hexsha[:7]) + def get_contributors(pr_data): commits = pr_data.get_commits() @@ -175,6 +179,7 @@ def upload_assets(gh_release, files): print('Uploading {}...'.format(filename)) gh_release.upload_asset(filedata[0], content_type='application/octet-stream') gh_release.upload_asset('{}.sha256'.format(filedata[0]), content_type='text/plain') + print('Uploading run.sh...') gh_release.upload_asset( os.path.join(REPO_ROOT, 'script', 'run', 'run.sh'), content_type='text/plain' ) From 05afd5a2dbc5028287233ea680b74fb74c09b196 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 19 Apr 2018 14:47:04 -0700 Subject: [PATCH 3409/4072] Add finalize step Signed-off-by: Joffrey F --- script/release/release.py | 77 ++++++++++++-------------- script/release/release/images.py | 82 ++++++++++++++++++++++++++++ script/release/release/repository.py | 8 +++ 3 files changed, 125 insertions(+), 42 deletions(-) create mode 100644 script/release/release/images.py diff --git a/script/release/release.py b/script/release/release.py index 848ab9751de..92a8c1c0c61 100755 --- a/script/release/release.py +++ b/script/release/release.py @@ -4,17 +4,18 @@ import argparse import os -import shutil import sys import time +from distutils.core import run_setup -import docker +import pypandoc from jinja2 import Template from release.bintray import BintrayAPI from release.const import BINTRAY_ORG from release.const import NAME from release.const import REPO_ROOT from release.downloader import BinaryDownloader +from release.images import ImageManager from release.repository import delete_assets from release.repository import get_contributors from release.repository import Repository @@ -108,43 +109,6 @@ def create_release_draft(repository, version, pr_data, files): return gh_release -def build_images(repository, files, version): - print("Building release images...") - repository.write_git_sha() - docker_client = docker.APIClient(**docker.utils.kwargs_from_env()) - distdir = os.path.join(REPO_ROOT, 'dist') - os.makedirs(distdir, exist_ok=True) - shutil.copy(files['docker-compose-Linux-x86_64'][0], distdir) - print('Building docker/compose image') - logstream = docker_client.build( - REPO_ROOT, tag='docker/compose:{}'.format(version), dockerfile='Dockerfile.run', - decode=True - ) - for chunk in logstream: - if 'error' in chunk: - raise ScriptError('Build error: {}'.format(chunk['error'])) - if 'stream' in chunk: - print(chunk['stream'], end='') - - print('Building test image (for UCP e2e)') - logstream = docker_client.build( - REPO_ROOT, tag='docker-compose-tests:tmp', decode=True - ) - for chunk in logstream: - if 'error' in chunk: - raise ScriptError('Build error: {}'.format(chunk['error'])) - if 'stream' in chunk: - print(chunk['stream'], end='') - - container = docker_client.create_container( - 'docker-compose-tests:tmp', entrypoint='tox' - ) - docker_client.commit(container, 'docker/compose-tests:latest') - docker_client.tag('docker/compose-tests:latest', 'docker/compose-tests:{}'.format(version)) - docker_client.remove_container(container, force=True) - docker_client.remove_image('docker-compose-tests:tmp', force=True) - - def print_final_instructions(args): print( "You're almost done! Please verify that everything is in order and " @@ -183,7 +147,8 @@ def resume(args): raise ScriptError('Aborting release') delete_assets(gh_release) upload_assets(gh_release, files) - build_images(repository, files, args.release) + img_manager = ImageManager(args.release) + img_manager.build_images(repository, files, args.release) except ScriptError as e: print(e) return 1 @@ -220,7 +185,8 @@ def start(args): files = downloader.download_all(args.release) gh_release = create_release_draft(repository, args.release, pr_data, files) upload_assets(gh_release, files) - build_images(repository, files, args.release) + img_manager = ImageManager(args.release) + img_manager.build_images(repository, files) except ScriptError as e: print(e) return 1 @@ -231,7 +197,34 @@ def start(args): def finalize(args): try: - raise NotImplementedError() + repository = Repository(REPO_ROOT, args.repo or NAME) + img_manager = ImageManager(args.release) + pr_data = repository.find_release_pr(args.release) + if not pr_data: + raise ScriptError('No PR found for {}'.format(args.release)) + if not check_pr_mergeable(pr_data): + raise ScriptError('Can not finalize release with an unmergeable PR') + if not img_manager.check_images(args.release): + raise ScriptError('Missing release image') + br_name = branch_name(args.release) + if not repository.branch_exists(br_name): + raise ScriptError('No local branch exists for this release.') + gh_release = repository.find_release(args.release) + if not gh_release: + raise ScriptError('No Github release draft for this version') + + pypandoc.convert_file( + os.path.join(REPO_ROOT, 'README.md'), 'rst', outputfile=os.path.join(REPO_ROOT, 'README.rst') + ) + run_setup(os.path.join(REPO_ROOT, 'setup.py'), script_args=['sdist', 'bdist_wheel']) + + merge_status = pr_data.merge() + if not merge_status.merged: + raise ScriptError('Unable to merge PR #{}: {}'.format(pr_data.number, merge_status.message)) + print('Uploading to PyPi') + run_setup(os.path.join(REPO_ROOT, 'setup.py'), script_args=['upload']) + img_manager.push_images(args.release) + repository.publish_release(gh_release) except ScriptError as e: print(e) return 1 diff --git a/script/release/release/images.py b/script/release/release/images.py new file mode 100644 index 00000000000..0c7bb20454e --- /dev/null +++ b/script/release/release/images.py @@ -0,0 +1,82 @@ +from __future__ import absolute_import +from __future__ import print_function +from __future__ import unicode_literals + +import os +import shutil + +import docker + +from .const import REPO_ROOT +from .utils import ScriptError + + +class ImageManager(object): + def __init__(self, version): + self.docker_client = docker.APIClient(**docker.utils.kwargs_from_env()) + self.version = version + + def build_images(self, repository, files): + print("Building release images...") + repository.write_git_sha() + docker_client = docker.APIClient(**docker.utils.kwargs_from_env()) + distdir = os.path.join(REPO_ROOT, 'dist') + os.makedirs(distdir, exist_ok=True) + shutil.copy(files['docker-compose-Linux-x86_64'][0], distdir) + print('Building docker/compose image') + logstream = docker_client.build( + REPO_ROOT, tag='docker/compose:{}'.format(self.version), dockerfile='Dockerfile.run', + decode=True + ) + for chunk in logstream: + if 'error' in chunk: + raise ScriptError('Build error: {}'.format(chunk['error'])) + if 'stream' in chunk: + print(chunk['stream'], end='') + + print('Building test image (for UCP e2e)') + logstream = docker_client.build( + REPO_ROOT, tag='docker-compose-tests:tmp', decode=True + ) + for chunk in logstream: + if 'error' in chunk: + raise ScriptError('Build error: {}'.format(chunk['error'])) + if 'stream' in chunk: + print(chunk['stream'], end='') + + container = docker_client.create_container( + 'docker-compose-tests:tmp', entrypoint='tox' + ) + docker_client.commit(container, 'docker/compose-tests:latest') + docker_client.tag('docker/compose-tests:latest', 'docker/compose-tests:{}'.format(self.version)) + docker_client.remove_container(container, force=True) + docker_client.remove_image('docker-compose-tests:tmp', force=True) + + @property + def image_names(self): + return [ + 'docker/compose-tests:latest', + 'docker/compose-tests:{}'.format(self.version), + 'docker/compose:{}'.format(self.version) + ] + + def check_images(self, version): + docker_client = docker.APIClient(**docker.utils.kwargs_from_env()) + + for name in self.image_names: + try: + docker_client.inspect_image(name) + except docker.errors.ImageNotFound: + print('Expected image {} was not found'.format(name)) + return False + return True + + def push_images(self): + docker_client = docker.APIClient(**docker.utils.kwargs_from_env()) + + for name in self.image_names: + print('Pushing {} to Docker Hub'.format(name)) + logstream = docker_client.push(name, stream=True, decode=True) + for chunk in logstream: + if 'status' in chunk: + print(chunk['status']) diff --git a/script/release/release/repository.py b/script/release/release/repository.py index dc4c6c46632..122eada8a33 100644 --- a/script/release/release/repository.py +++ b/script/release/release/repository.py @@ -95,6 +95,14 @@ def find_release(self, version): return release return None + def publish_release(self, release): + release.update_release( + name=release.title, + message=release.body, + draft=False, + prerelease=release.prerelease + ) + def remove_release(self, version): print('Removing release draft for {}'.format(version)) releases = self.gh_repo.get_releases() From f248dbe28062682cdc05535e8af1f2605672d47c Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 19 Apr 2018 14:54:17 -0700 Subject: [PATCH 3410/4072] Avoid accidental prod push Signed-off-by: Joffrey F --- script/release/release.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/script/release/release.py b/script/release/release.py index 92a8c1c0c61..3c154012f2d 100755 --- a/script/release/release.py +++ b/script/release/release.py @@ -213,6 +213,8 @@ def finalize(args): if not gh_release: raise ScriptError('No Github release draft for this version') + repository.checkout_branch(br_name) + pypandoc.convert_file( os.path.join(REPO_ROOT, 'README.md'), 'rst', outputfile=os.path.join(REPO_ROOT, 'README.rst') ) @@ -222,8 +224,9 @@ def finalize(args): if not merge_status.merged: raise ScriptError('Unable to merge PR #{}: {}'.format(pr_data.number, merge_status.message)) print('Uploading to PyPi') - run_setup(os.path.join(REPO_ROOT, 'setup.py'), script_args=['upload']) - img_manager.push_images(args.release) + # TODO: this will do real stuff. Uncomment when done testing + # run_setup(os.path.join(REPO_ROOT, 'setup.py'), script_args=['upload']) + # img_manager.push_images(args.release) repository.publish_release(gh_release) except ScriptError as e: print(e) From 0621739a86f0eca07811f43915750dcd849b52af Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 19 Apr 2018 15:22:55 -0700 Subject: [PATCH 3411/4072] Early check for non-draft release in resume Signed-off-by: Joffrey F --- script/release/release.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/script/release/release.py b/script/release/release.py index 3c154012f2d..06dfdad9a03 100755 --- a/script/release/release.py +++ b/script/release/release.py @@ -125,6 +125,15 @@ def resume(args): br_name = branch_name(args.release) if not repository.branch_exists(br_name): raise ScriptError('No local branch exists for this release.') + gh_release = repository.find_release(args.release) + if gh_release and not gh_release.draft: + print('WARNING!! Found non-draft (public) release for this version!') + proceed = input( + 'Are you sure you wish to proceed? Modifying an already ' + 'released version is dangerous! y/N ' + ) + if proceed.lower() != 'y': + raise ScriptError('Aborting release') release_branch = repository.checkout_branch(br_name) create_bump_commit(repository, release_branch, args.bintray_user) pr_data = repository.find_release_pr(args.release) @@ -134,17 +143,8 @@ def resume(args): monitor_pr_status(pr_data) downloader = BinaryDownloader(args.destination) files = downloader.download_all(args.release) - gh_release = repository.find_release(args.release) if not gh_release: gh_release = create_release_draft(repository, args.release, pr_data, files) - elif not gh_release.draft: - print('WARNING!! Found non-draft (public) release for this version!') - proceed = input( - 'Are you sure you wish to proceed? Modifying an already ' - 'released version is dangerous! y/N' - ) - if proceed.lower() != 'y': - raise ScriptError('Aborting release') delete_assets(gh_release) upload_assets(gh_release, files) img_manager = ImageManager(args.release) From 4fab78d7e0d81ad00c66ad625566639ff4cef851 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 19 Apr 2018 15:24:41 -0700 Subject: [PATCH 3412/4072] Default base is master Signed-off-by: Joffrey F --- script/release/release/repository.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/release/release/repository.py b/script/release/release/repository.py index 122eada8a33..e0c581bd587 100644 --- a/script/release/release/repository.py +++ b/script/release/release/repository.py @@ -36,7 +36,7 @@ def create_release_branch(self, version, base=None): if base is not None: base = self.git_repo.tag('refs/tags/{}'.format(base)) else: - base = 'refs/remotes/{}/automated-releases'.format(remote.name) + base = 'refs/remotes/{}/master'.format(remote.name) release_branch = self.git_repo.create_head(branch_name(version), commit=base) release_branch.checkout() self.git_repo.git.merge('--strategy=ours', '--no-edit', '{}/release'.format(remote.name)) From b68811fd7f768b9187fd70584bd43c216c01895a Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 20 Apr 2018 13:06:41 -0700 Subject: [PATCH 3413/4072] Add support for PR cherry picks Signed-off-by: Joffrey F --- script/release/release.py | 5 +++++ script/release/release/repository.py | 24 +++++++++++++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/script/release/release.py b/script/release/release.py index 06dfdad9a03..a38b2aa7d37 100755 --- a/script/release/release.py +++ b/script/release/release.py @@ -30,6 +30,11 @@ def create_initial_branch(repository, release, base, bintray_user): release_branch = repository.create_release_branch(release, base) + if base: + print('Detected patch version.') + cherries = input('Indicate PR#s to cherry-pick then press Enter:\n') + repository.cherry_pick_prs(release_branch, cherries.split()) + return create_bump_commit(repository, release_branch, bintray_user) diff --git a/script/release/release/repository.py b/script/release/release/repository.py index e0c581bd587..4fcb2712ad1 100644 --- a/script/release/release/repository.py +++ b/script/release/release/repository.py @@ -2,7 +2,9 @@ from __future__ import unicode_literals import os +import tempfile +import requests from git import GitCommandError from git import Repo from github import Github @@ -111,7 +113,7 @@ def remove_release(self, version): if not release.draft: print( 'The release at {} is no longer a draft. If you TRULY intend ' - 'to remove it, please do so manually.' + 'to remove it, please do so manually.'.format(release.url) ) continue release.delete_release() @@ -171,6 +173,26 @@ def write_git_sha(self): with open(os.path.join(REPO_ROOT, 'compose', 'GITSHA'), 'w') as f: f.write(self.git_repo.head.commit.hexsha[:7]) + def cherry_pick_prs(self, release_branch, ids): + if not ids: + return + release_branch.checkout() + for i in ids: + try: + i = int(i) + except ValueError as e: + raise ScriptError('Invalid PR id: {}'.format(e)) + print('Retrieving PR#{}'.format(i)) + pr = self.gh_repo.get_pull(i) + patch_data = requests.get(pr.patch_url).text + self.apply_patch(patch_data) + + def apply_patch(self, patch_data): + with tempfile.NamedTemporaryFile(mode='w', prefix='_compose_cherry', encoding='utf-8') as f: + f.write(patch_data) + f.flush() + self.git_repo.git.am('--3way', f.name) + def get_contributors(pr_data): commits = pr_data.get_commits() From 87b8eaa27cf56c367e25a88e0459e5163bf7f838 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 20 Apr 2018 13:34:18 -0700 Subject: [PATCH 3414/4072] Cleanup Signed-off-by: Joffrey F --- script/release/release.py | 61 +++++++++++++++++----------- script/release/release/const.py | 4 +- script/release/release/repository.py | 11 +++-- script/release/release/utils.py | 22 ++++++++++ 4 files changed, 68 insertions(+), 30 deletions(-) diff --git a/script/release/release.py b/script/release/release.py index a38b2aa7d37..c1908f94a2c 100755 --- a/script/release/release.py +++ b/script/release/release.py @@ -26,19 +26,20 @@ from release.utils import ScriptError from release.utils import update_init_py_version from release.utils import update_run_sh_version +from release.utils import yesno -def create_initial_branch(repository, release, base, bintray_user): - release_branch = repository.create_release_branch(release, base) - if base: +def create_initial_branch(repository, args): + release_branch = repository.create_release_branch(args.release, args.base) + if args.base and args.cherries: print('Detected patch version.') - cherries = input('Indicate PR#s to cherry-pick then press Enter:\n') + cherries = input('Indicate (space-separated) PR numbers to cherry-pick then press Enter:\n') repository.cherry_pick_prs(release_branch, cherries.split()) - return create_bump_commit(repository, release_branch, bintray_user) + return create_bump_commit(repository, release_branch, args.bintray_user, args.bintray_org) -def create_bump_commit(repository, release_branch, bintray_user): +def create_bump_commit(repository, release_branch, bintray_user, bintray_org): with release_branch.config_reader() as cfg: release = cfg.get('release') print('Updating version info in __init__.py and run.sh') @@ -46,10 +47,10 @@ def create_bump_commit(repository, release_branch, bintray_user): update_init_py_version(release) input('Please add the release notes to the CHANGELOG.md file, then press Enter to continue.') - proceed = '' - while proceed.lower() != 'y': + proceed = None + while not proceed: print(repository.diff()) - proceed = input('Are these changes ok? y/N ') + proceed = yesno('Are these changes ok? y/N ', default=False) if repository.diff(): repository.create_bump_commit(release_branch, release) @@ -57,7 +58,7 @@ def create_bump_commit(repository, release_branch, bintray_user): bintray_api = BintrayAPI(os.environ['BINTRAY_TOKEN'], bintray_user) print('Creating data repository {} on bintray'.format(release_branch.name)) - bintray_api.create_repository(BINTRAY_ORG, release_branch.name, 'generic') + bintray_api.create_repository(bintray_org, release_branch.name, 'generic') def monitor_pr_status(pr_data): @@ -126,21 +127,26 @@ def print_final_instructions(args): def resume(args): try: - repository = Repository(REPO_ROOT, args.repo or NAME) + repository = Repository(REPO_ROOT, args.repo) br_name = branch_name(args.release) if not repository.branch_exists(br_name): raise ScriptError('No local branch exists for this release.') gh_release = repository.find_release(args.release) if gh_release and not gh_release.draft: print('WARNING!! Found non-draft (public) release for this version!') - proceed = input( + proceed = yesno( 'Are you sure you wish to proceed? Modifying an already ' - 'released version is dangerous! y/N ' + 'released version is dangerous! y/N ', default=False ) - if proceed.lower() != 'y': + if proceed.lower() is not True: raise ScriptError('Aborting release') + release_branch = repository.checkout_branch(br_name) - create_bump_commit(repository, release_branch, args.bintray_user) + if args.cherries: + cherries = input('Indicate (space-separated) PR numbers to cherry-pick then press Enter:\n') + repository.cherry_pick_prs(release_branch, cherries.split()) + + create_bump_commit(repository, release_branch, args.bintray_user, args.bintray_org) pr_data = repository.find_release_pr(args.release) if not pr_data: pr_data = repository.create_release_pull_request(args.release) @@ -164,14 +170,13 @@ def resume(args): def cancel(args): try: - repository = Repository(REPO_ROOT, args.repo or NAME) + repository = Repository(REPO_ROOT, args.repo) repository.close_release_pr(args.release) repository.remove_release(args.release) repository.remove_bump_branch(args.release) - # TODO: uncomment after testing is complete - # bintray_api = BintrayAPI(os.environ['BINTRAY_TOKEN'], args.bintray_user) - # print('Removing Bintray data repository for {}'.format(args.release)) - # bintray_api.delete_repository(BINTRAY_ORG, branch_name(args.release)) + bintray_api = BintrayAPI(os.environ['BINTRAY_TOKEN'], args.bintray_user) + print('Removing Bintray data repository for {}'.format(args.release)) + bintray_api.delete_repository(args.bintray_org, branch_name(args.release)) except ScriptError as e: print(e) return 1 @@ -181,8 +186,8 @@ def cancel(args): def start(args): try: - repository = Repository(REPO_ROOT, args.repo or NAME) - create_initial_branch(repository, args.release, args.base, args.bintray_user) + repository = Repository(REPO_ROOT, args.repo) + create_initial_branch(repository, args) pr_data = repository.create_release_pull_request(args.release) check_pr_mergeable(pr_data) monitor_pr_status(pr_data) @@ -202,7 +207,7 @@ def start(args): def finalize(args): try: - repository = Repository(REPO_ROOT, args.repo or NAME) + repository = Repository(REPO_ROOT, args.repo) img_manager = ImageManager(args.release) pr_data = repository.find_release_pr(args.release) if not pr_data: @@ -282,17 +287,25 @@ def main(): help='Which version is being patched by this release' ) parser.add_argument( - '--repo', '-r', dest='repo', + '--repo', '-r', dest='repo', default=NAME, help='Start a release for the given repo (default: {})'.format(NAME) ) parser.add_argument( '-b', dest='bintray_user', required=True, metavar='USER', help='Username associated with the Bintray API key' ) + parser.add_argument( + '--bintray-org', dest='bintray_org', metavar='ORG', default=BINTRAY_ORG, + help='Organization name on bintray where the data repository will be created.' + ) parser.add_argument( '--destination', '-o', metavar='DIR', default='binaries', help='Directory where release binaries will be downloaded relative to the project root' ) + parser.add_argument( + '--no-cherries', '-C', dest='cherries', action='store_false', + help='If set, the program will not prompt the user for PR numbers to cherry-pick' + ) args = parser.parse_args() if args.action == 'start': diff --git a/script/release/release/const.py b/script/release/release/const.py index 34f338a8980..5a72bde411b 100644 --- a/script/release/release/const.py +++ b/script/release/release/const.py @@ -5,5 +5,5 @@ REPO_ROOT = os.path.join(os.path.dirname(__file__), '..', '..', '..') -NAME = 'shin-/compose' -BINTRAY_ORG = 'shin-compose' +NAME = 'docker/compose' +BINTRAY_ORG = 'docker-compose' diff --git a/script/release/release/repository.py b/script/release/release/repository.py index 4fcb2712ad1..d4d1c720111 100644 --- a/script/release/release/repository.py +++ b/script/release/release/repository.py @@ -29,17 +29,20 @@ def __init__(self, root=None, gh_name=None): def create_release_branch(self, version, base=None): print('Creating release branch {} based on {}...'.format(version, base or 'master')) remote = self.find_remote(self.gh_repo.full_name) + br_name = branch_name(version) remote.fetch() - if self.branch_exists(branch_name(version)): + if self.branch_exists(br_name): raise ScriptError( - "Branch {} already exists locally. " - "Please remove it before running the release script.".format(branch_name(version)) + "Branch {} already exists locally. Please remove it before " + "running the release script, or use `resume` instead.".format( + br_name + ) ) if base is not None: base = self.git_repo.tag('refs/tags/{}'.format(base)) else: base = 'refs/remotes/{}/master'.format(remote.name) - release_branch = self.git_repo.create_head(branch_name(version), commit=base) + release_branch = self.git_repo.create_head(br_name, commit=base) release_branch.checkout() self.git_repo.git.merge('--strategy=ours', '--no-edit', '{}/release'.format(remote.name)) with release_branch.config_writer() as cfg: diff --git a/script/release/release/utils.py b/script/release/release/utils.py index b0e1f6a8487..977a0a71227 100644 --- a/script/release/release/utils.py +++ b/script/release/release/utils.py @@ -61,3 +61,25 @@ def compatibility_matrix(): for fmt, api_version in compose_const.API_VERSIONS.items(): result[compose_const.API_VERSION_TO_ENGINE_VERSION[api_version]].append(fmt.vstring) return result + + +def yesno(prompt, default=None): + """ + Prompt the user for a yes or no. + + Can optionally specify a default value, which will only be + used if they enter a blank line. + + Unrecognised input (anything other than "y", "n", "yes", + "no" or "") will return None. + """ + answer = input(prompt).strip().lower() + + if answer == "y" or answer == "yes": + return True + elif answer == "n" or answer == "no": + return False + elif answer == "": + return default + else: + return None From 28f7f79fea91e8af27bd8fd3a454ad1d0199e3c7 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 20 Apr 2018 14:23:58 -0700 Subject: [PATCH 3415/4072] Improve monitor function Signed-off-by: Joffrey F --- script/release/release.py | 13 +++++++++---- script/release/release/downloader.py | 4 ++-- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/script/release/release.py b/script/release/release.py index c1908f94a2c..338e73af241 100755 --- a/script/release/release.py +++ b/script/release/release.py @@ -66,24 +66,29 @@ def monitor_pr_status(pr_data): last_commit = pr_data.get_commits().reversed[0] while True: status = last_commit.get_combined_status() - if status.state == 'pending': + if status.state == 'pending' or status.state == 'failure': summary = { 'pending': 0, 'success': 0, 'failure': 0, } for detail in status.statuses: + if detail.context == 'dco-signed': + # dco-signed check breaks on merge remote-tracking ; ignore it + continue summary[detail.state] += 1 print('{pending} pending, {success} successes, {failure} failures'.format(**summary)) if status.total_count == 0: # Mostly for testing purposes against repos with no CI setup return True + elif summary['pending'] == 0 and summary['failure'] == 0: + return True + elif summary['failure'] > 0: + raise ScriptError('CI failures detected!') time.sleep(30) elif status.state == 'success': print('{} successes: all clear!'.format(status.total_count)) return True - else: - raise ScriptError('CI failure detected') def check_pr_mergeable(pr_data): @@ -159,7 +164,7 @@ def resume(args): delete_assets(gh_release) upload_assets(gh_release, files) img_manager = ImageManager(args.release) - img_manager.build_images(repository, files, args.release) + img_manager.build_images(repository, files) except ScriptError as e: print(e) return 1 diff --git a/script/release/release/downloader.py b/script/release/release/downloader.py index cd43bc993bf..d92ae78b527 100644 --- a/script/release/release/downloader.py +++ b/script/release/release/downloader.py @@ -36,7 +36,7 @@ def download_from_appveyor(self, branch_name, filename): base=self.base_appveyor_url, filename=filename, branch_name=branch_name ) full_dest = os.path.join(REPO_ROOT, self.destination, filename) - return self.download(url, full_dest) + return self._download(url, full_dest) def _download(self, url, full_dest): m = hashlib.sha256() @@ -57,7 +57,7 @@ def download_all(self, version): files = { 'docker-compose-Darwin-x86_64': None, 'docker-compose-Linux-x86_64': None, - # 'docker-compose-Windows-x86_64.exe': None, + 'docker-compose-Windows-x86_64.exe': None, } for filename in files.keys(): From 4faf7c19b6f10941645a577637ecf9ba2c7f82f9 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 20 Apr 2018 16:21:13 -0700 Subject: [PATCH 3416/4072] Containerize release tool Signed-off-by: Joffrey F --- script/release/Dockerfile | 14 ++++++++++++++ script/release/release.sh | 25 +++++++++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 script/release/Dockerfile create mode 100755 script/release/release.sh diff --git a/script/release/Dockerfile b/script/release/Dockerfile new file mode 100644 index 00000000000..0d4ec27e1da --- /dev/null +++ b/script/release/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.6 +RUN mkdir -p /src && pip install -U Jinja2==2.10 \ + PyGithub==1.39 \ + pypandoc==1.4 \ + GitPython==2.1.9 \ + requests==2.18.4 && \ + apt-get update && apt-get install -y pandoc + +VOLUME /src/script/release +WORKDIR /src +COPY . /src +RUN python setup.py develop +ENTRYPOINT ["python", "script/release/release.py"] +CMD ["--help"] diff --git a/script/release/release.sh b/script/release/release.sh new file mode 100755 index 00000000000..2310429aaa2 --- /dev/null +++ b/script/release/release.sh @@ -0,0 +1,25 @@ +#!/bin/sh + +docker image inspect compose/release-tool > /dev/null +if test $? -ne 0; then + docker build -t compose/release-tool -f $(pwd)/script/release/Dockerfile $(pwd) +fi + +if test -z $GITHUB_TOKEN; then + echo "GITHUB_TOKEN environment variable must be set" + exit 1 +fi + +if test -z $BINTRAY_TOKEN; then + echo "BINTRAY_TOKEN environment variable must be set" + exit 1 +fi + +docker run -e GITHUB_TOKEN=$GITHUB_TOKEN -e BINTRAY_TOKEN=$BINTRAY_TOKEN -it \ + --mount type=bind,source=$(pwd),target=/src \ + --mount type=bind,source=$(pwd)/.git,target=/src/.git \ + --mount type=bind,source=$HOME/.docker,target=/root/.docker \ + --mount type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock \ + --mount type=bind,source=$HOME/.ssh,target=/root/.ssh \ + -v $HOME/.pypirc:/root/.pypirc \ + compose/release-tool $* From a50c056d7cc932967626160f9a832878955053b4 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 20 Apr 2018 17:15:45 -0700 Subject: [PATCH 3417/4072] Uncomment deploy steps Signed-off-by: Joffrey F --- script/release/release.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/script/release/release.py b/script/release/release.py index 338e73af241..add8fb2d353 100755 --- a/script/release/release.py +++ b/script/release/release.py @@ -239,9 +239,8 @@ def finalize(args): if not merge_status.merged: raise ScriptError('Unable to merge PR #{}: {}'.format(pr_data.number, merge_status.message)) print('Uploading to PyPi') - # TODO: this will do real stuff. Uncomment when done testing - # run_setup(os.path.join(REPO_ROOT, 'setup.py'), script_args=['upload']) - # img_manager.push_images(args.release) + run_setup(os.path.join(REPO_ROOT, 'setup.py'), script_args=['upload']) + img_manager.push_images(args.release) repository.publish_release(gh_release) except ScriptError as e: print(e) From 7503a2eddd886b7871b47c18fdddd587a2836122 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 23 Apr 2018 14:37:34 -0700 Subject: [PATCH 3418/4072] Document new release process Signed-off-by: Joffrey F --- project/RELEASE-PROCESS.md | 149 +----------------------------- script/release/README.md | 184 +++++++++++++++++++++++++++++++++++++ 2 files changed, 185 insertions(+), 148 deletions(-) mode change 100644 => 120000 project/RELEASE-PROCESS.md create mode 100644 script/release/README.md diff --git a/project/RELEASE-PROCESS.md b/project/RELEASE-PROCESS.md deleted file mode 100644 index d4afb87b9c2..00000000000 --- a/project/RELEASE-PROCESS.md +++ /dev/null @@ -1,148 +0,0 @@ -Building a Compose release -========================== - -## Prerequisites - -The release scripts require the following tools installed on the host: - -* https://hub.github.com/ -* https://stedolan.github.io/jq/ -* http://pandoc.org/ - -## To get started with a new release - -Create a branch, update version, and add release notes by running `make-branch` - - ./script/release/make-branch $VERSION [$BASE_VERSION] - -`$BASE_VERSION` will default to master. Use the last version tag for a bug fix -release. - -As part of this script you'll be asked to: - -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.0-rc1`. - -2. Write release notes in `CHANGELOG.md`. - - 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. - - 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 - -1. Check out the bump branch and run the cherry pick script - - git checkout bump-$VERSION - ./script/release/cherry-pick-pr $PR_NUMBER - -2. When you are done cherry-picking branches move the bump version commit to HEAD - - ./script/release/rebase-bump-commit - git push --force $USERNAME bump-$VERSION - - -## To release a version (whether RC or stable) - -Check out the bump branch and run the `build-binaries` script - - git checkout bump-$VERSION - ./script/release/build-binaries - -When prompted build the non-linux binaries and test them. - -1. Download the different platform binaries by running the following script: - - `./script/release/download-binaries $VERSION` - - The binaries for Linux, OSX and Windows will be downloaded in the `binaries-$VERSION` folder. - -3. Draft a release from the tag on GitHub (the `build-binaries` script will open the window for - you) - - 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: - - 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)**. - - Docker for Mac and Windows will automatically install the latest version of Docker Engine for you. - - Alternatively, you can use the usual commands to install or upgrade Compose: - - ``` - curl -L https://github.com/docker/compose/releases/download/1.16.0/docker-compose-`uname -s`-`uname -m` -o /usr/local/bin/docker-compose - chmod +x /usr/local/bin/docker-compose - ``` - - See the [install docs](https://docs.docker.com/compose/install/) for more install options and instructions. - - ## Compose file format compatibility matrix - - | Compose file format | Docker Engine | - | --- | --- | - | 3.3 | 17.06.0+ | - | 3.0 – 3.2 | 1.13.0+ | - | 2.3| 17.06.0+ | - | 2.2 | 1.13.0+ | - | 2.1 | 1.12.0+ | - | 2.0 | 1.10.0+ | - | 1.0 | 1.9.1+ | - - ## Changes - - ...release notes go here... - -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`. - -7. If everything looks good, it's time to push the release. - - - ./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. - -10. Announce the release on the appropriate Slack channel(s). - -## If it’s a stable release (not an RC) - -1. Close the release’s milestone. - -## If it’s a minor release (1.x.0), rather than a patch release (1.x.y) - -1. Open a PR against `master` to: - - - update `CHANGELOG.md` to bring it in line with `release` - - bump the version in `compose/__init__.py` to the *next* minor version number with `dev` appended. For example, if you just released `1.4.0`, update it to `1.5.0dev`. - -2. Get the PR merged. - -## Finally - -1. Celebrate, however you’d like. diff --git a/project/RELEASE-PROCESS.md b/project/RELEASE-PROCESS.md new file mode 120000 index 00000000000..c8457671ac7 --- /dev/null +++ b/project/RELEASE-PROCESS.md @@ -0,0 +1 @@ +../script/release/README.md \ No newline at end of file diff --git a/script/release/README.md b/script/release/README.md new file mode 100644 index 00000000000..c5136c764f1 --- /dev/null +++ b/script/release/README.md @@ -0,0 +1,184 @@ +# Release HOWTO + +This file describes the process of making a public release of `docker-compose`. +Please read it carefully before proceeding! + +## Prerequisites + +The following things are required to bring a release to a successful conclusion + +### Local Docker engine (Linux Containers) + +The release script runs inside a container and builds images that will be part +of the release. + +### Docker Hub account + +You should be logged into a Docker Hub account that allows pushing to the +following repositories: + +- docker/compose +- docker/compose-tests + +### A Github account and Github API token + +Your Github account needs to have write access on the `docker/compose` repo. +To generate a Github token, head over to the +[Personal access tokens](https://github.com/settings/tokens) page in your +Github settings and select "Generate new token". Your token should include +(at minimum) the following scopes: + +- `repo:status` +- `public_repo` + +This API token should be exposed to the release script through the +`GITHUB_TOKEN` environment variable. + +### A Bintray account and Bintray API key + +Your Bintray account will need to be an admin member of the +[docker-compose organization](https://github.com/settings/tokens). +Additionally, you should generate a personal API key. To do so, click your +username in the top-right hand corner and select "Edit profile" ; on the new +page, select "API key" in the left-side menu. + +This API key should be exposed to the release script through the +`BINTRAY_TOKEN` environment variable. + +### A PyPi account + +Said account needs to be a member of the maintainers group for the +[`docker-compose` project](https://pypi.org/project/docker-compose/). + +Moreover, the `~/.pypirc` file should exist on your host and contain the +relevant pypi credentials. + +## Start a feature release + +A feature release is a release that includes all changes present in the +`master` branch when initiated. It's typically versioned `X.Y.0-rc1`, where +Y is the minor version of the previous release incremented by one. A series +of one or more Release Candidates (RCs) should be made available to the public +to find and squash potential bugs. + +From the root of the Compose repository, run the following command: +``` +./script/release/release.sh -b start X.Y.0-rc1 +``` + +After a short initialization period, the script will invite you to edit the +`CHANGELOG.md` file. Do so by being careful to respect the same format as +previous releases. Once done, the script will display a `diff` of the staged +changes for the bump commit. Once you validate these, a bump commit will be +created on the newly created release branch and pushed remotely. + +The release tool then waits for the CI to conclude before proceeding. +If failures are reported, the release will be aborted until these are fixed. +Please refer to the "Resume a draft release" section below for more details. + +Once all resources have been prepared, the release script will exit with a +message resembling this one: + +``` +You're almost done! Please verify that everything is in order and you are ready +to make the release public, then run the following command: +./script/release/release.sh -b user finalize X.Y.0-rc1 +``` + +Once you are ready to finalize the release (making binaries and other versioned +assets public), proceed to the "Finalize a release" section of this guide. + +## Start a patch release + +A patch release is a release that builds off a previous release with discrete +additions. This can be an RC release after RC1 (`X.Y.0-rcZ`, `Z > 1`), a GA release +based off the final RC (`X.Y.0`), or a bugfix release based off a previous +GA release (`X.Y.Z`, `Z > 0`). + +From the root of the Compose repository, run the following command: +``` +./script/release/release.sh -b start --patch=BASE_VERSION RELEASE_VERSION +``` + +The process of starting a patch release is identical to starting a feature +release except for one difference ; at the beginning, the script will ask for +PR numbers you wish to cherry-pick into the release. These numbers should +correspond to existing PRs on the docker/compose repository. Multiple numbers +should be separated by whitespace. + +Once you are ready to finalize the release (making binaries and other versioned +assets public), proceed to the "Finalize a release" section of this guide. + +## Finalize a release + +Once you're ready to make your release public, you may execute the following +command from the root of the Compose repository: +``` +./script/release/release.sh -b finalize RELEAE_VERSION +``` + +Note that this command will create and publish versioned assets to the public. +As a result, it can not be reverted. The command will perform some basic +sanity checks before doing so, but it is your responsibility to ensure +everything is in order before pushing the button. + +After the command exits, you should make sure: + +- The `docker/compose:VERSION` image is available on Docker Hub and functional +- The `pip install -U docker-compose==VERSION` command correctly installs the + specified version +- The install command on the Github release page installs the new release + +## Resume a draft release + +"Resuming" a release lets you address the following situations occurring before +a release is made final: + +- Cherry-pick additional PRs to include in the release +- Resume a release that was aborted because of CI failures after they've been + addressed +- Rebuild / redownload assets after manual changes have been made to the + release branch +- etc. + +From the root of the Compose repository, run the following command: +``` +./script/release/release.sh -b resume RELEASE_VERSION +``` + +The release tool will attempt to determine what steps it's already been through +for the specified release and pick up where it left off. Some steps are +executed again no matter what as it's assumed they'll produce different +results, like building images or downloading binaries. + +## Cancel a draft release + +If issues snuck into your release branch, it is sometimes easier to start from +scratch. Before a release has been finalized, it is possible to cancel it using +the following command: +``` +./script/release/release.sh -b cancel RELEASE_VERSION +``` + +This will remove the release branch with this release (locally and remotely), +close the associated PR, remove the release page draft on Github and delete +the Bintray repository for it, allowing you to start fresh. + +## Manual operations + +Some common, release-related operations are not covered by this tool and should +be handled manually by the operator: + +- After any release: + - Announce new release on Slack +- After a GA release: + - Close the release milestone + - Merge back `CHANGELOG.md` changes from the `release` branch into `master` + - Bump the version in `compose/__init__.py` to the *next* minor version + number with `dev` appended. For example, if you just released `1.4.0`, + update it to `1.5.0dev` + +## Advanced options + +You can consult the full list of options for the release tool by executing +`./script/release/release.sh --help`. From fe20526d05ab83ccc60b7ce35026073537b1edb0 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 23 Apr 2018 15:01:30 -0700 Subject: [PATCH 3419/4072] Remove obsolete release scripts Signed-off-by: Joffrey F --- script/release/build-binaries | 40 --------------- script/release/contributors | 30 ----------- script/release/download-binaries | 39 --------------- script/release/make-branch | 86 -------------------------------- 4 files changed, 195 deletions(-) delete mode 100755 script/release/build-binaries delete mode 100755 script/release/contributors delete mode 100755 script/release/download-binaries delete mode 100755 script/release/make-branch diff --git a/script/release/build-binaries b/script/release/build-binaries deleted file mode 100755 index a39b186d97b..00000000000 --- a/script/release/build-binaries +++ /dev/null @@ -1,40 +0,0 @@ -#!/bin/bash -# -# Build the release binaries -# - -. "$(dirname "${BASH_SOURCE[0]}")/utils.sh" - -function usage() { - >&2 cat << EOM -Build binaries for the release. - -This script requires that 'git config branch.${BRANCH}.release' is set to the -release version for the release branch. - -EOM - exit 1 -} - -BRANCH="$(git rev-parse --abbrev-ref HEAD)" -VERSION="$(git config "branch.${BRANCH}.release")" || usage -REPO=docker/compose - -# Build the binaries -script/clean -script/build/linux - -echo "Building the container distribution" -script/build/image $VERSION - -echo "Building the compose-tests image" -script/build/test-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/contributors b/script/release/contributors deleted file mode 100755 index 4657dd8051d..00000000000 --- a/script/release/contributors +++ /dev/null @@ -1,30 +0,0 @@ -#!/bin/bash -set -e - - -function usage() { - >&2 cat << EOM -Print the list of github contributors for the release - -Usage: - - $0 -EOM - exit 1 -} - -[[ -n "$1" ]] || usage -PREV_RELEASE=$1 -BRANCH="$(git rev-parse --abbrev-ref HEAD)" -URL="https://api.github.com/repos/docker/compose/compare" - -contribs=$(curl -sf "$URL/$PREV_RELEASE...$BRANCH" | \ - jq -r '.commits[].author.login' | \ - sort | \ - uniq -c | \ - sort -nr) - -echo "Contributions by user: " -echo "$contribs" -echo -echo "$contribs" | awk '{print "@"$2","}' | xargs diff --git a/script/release/download-binaries b/script/release/download-binaries deleted file mode 100755 index 0b187f6c26c..00000000000 --- a/script/release/download-binaries +++ /dev/null @@ -1,39 +0,0 @@ -#!/bin/bash - -function usage() { - >&2 cat << EOM -Download Linux, Mac OS and Windows binaries from remote endpoints - -Usage: - - $0 - -Options: - - version version string for the release (ex: 1.6.0) - -EOM - exit 1 -} - - -[ -n "$1" ] || usage -VERSION=$1 -BASE_BINTRAY_URL=https://dl.bintray.com/docker-compose/bump-$VERSION/ -DESTINATION=binaries-$VERSION -APPVEYOR_URL=https://ci.appveyor.com/api/projects/docker/compose/\ -artifacts/dist%2Fdocker-compose-Windows-x86_64.exe?branch=bump-$VERSION - -mkdir $DESTINATION - - -wget -O $DESTINATION/docker-compose-Darwin-x86_64 $BASE_BINTRAY_URL/docker-compose-Darwin-x86_64 -wget -O $DESTINATION/docker-compose-Linux-x86_64 $BASE_BINTRAY_URL/docker-compose-Linux-x86_64 -wget -O $DESTINATION/docker-compose-Windows-x86_64.exe $APPVEYOR_URL - -echo -e "\n\nCopy the following lines into the integrity check table in the release notes:\n\n" -cd $DESTINATION -rm -rf *.sha256 -ls | xargs sha256sum | sed 's/ / | /g' | sed -r 's/([^ |]+)/`\1`/g' -ls | xargs -I@ bash -c "sha256sum @ | cut -d' ' -f1 > @.sha256" -cd - diff --git a/script/release/make-branch b/script/release/make-branch deleted file mode 100755 index b8a0cd31ee7..00000000000 --- a/script/release/make-branch +++ /dev/null @@ -1,86 +0,0 @@ -#!/bin/bash -# -# Prepare a new release branch -# - -. "$(dirname "${BASH_SOURCE[0]}")/utils.sh" - -function usage() { - >&2 cat << EOM -Create a new release branch 'release-' - -Usage: - - $0 [] - -Options: - - version version string for the release (ex: 1.6.0) - base_version branch or tag to start from. Defaults to master. For - bug-fix releases use the previous stage release tag. - -EOM - exit 1 -} - - -[ -n "$1" ] || usage -VERSION=$1 -BRANCH=bump-$VERSION -REPO=docker/compose -GITHUB_REPO=git@github.com:$REPO - -if [ -z "$2" ]; then - BASE_VERSION="master" -else - BASE_VERSION=$2 -fi - - -DEFAULT_REMOTE=release -REMOTE="$(find_remote "$GITHUB_REPO")" -# If we don't have a docker remote add one -if [ -z "$REMOTE" ]; then - echo "Creating $DEFAULT_REMOTE remote" - git remote add ${DEFAULT_REMOTE} ${GITHUB_REPO} -fi - -# handle the difference between a branch and a tag -if [ -z "$(git name-rev --tags $BASE_VERSION | grep tags)" ]; then - BASE_VERSION=$REMOTE/$BASE_VERSION -fi - -echo "Creating a release branch $VERSION from $BASE_VERSION" -read -n1 -r -p "Continue? (ctrl+c to cancel)" -git fetch $REMOTE -p -git checkout -b $BRANCH $BASE_VERSION - -echo "Merging remote release branch into new release branch" -git merge --strategy=ours --no-edit $REMOTE/release - -# Store the release version for this branch in git, so that other release -# scripts can use it -git config "branch.${BRANCH}.release" $VERSION - - -editor=${EDITOR:-vim} - -echo "Update versions in compose/__init__.py, script/run/run.sh" -$editor compose/__init__.py -$editor script/run/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 - - -git diff -echo "Verify changes before commit. Exit the shell to commit changes" -$SHELL || true -git commit -a -m "Bump $VERSION" --signoff --no-verify - - -echo "Push branch to docker remote" -git push $REMOTE -browser https://github.com/$REPO/compare/docker:release...$BRANCH?expand=1 From 7db13582f1ae4274fd4b559c9707a43c4cab3883 Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Thu, 12 Apr 2018 08:52:20 +0200 Subject: [PATCH 3420/4072] Add support for features added in 1.21.0 to bash completion - add support for `docker-compose exec --workdir|-w` - add support for `docker-compose build --compress` - add support for `docker-compose pull --no-parallel`, drop deprecated option `--parallel` Signed-off-by: Harald Albers --- contrib/completion/bash/docker-compose | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index 90c9ce5fcfb..0713486d306 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -131,7 +131,7 @@ _docker_compose_build() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "--build-arg --force-rm --help --memory --no-cache --pull" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--build-arg --compress --force-rm --help --memory --no-cache --pull" -- "$cur" ) ) ;; *) __docker_compose_services_from_build @@ -242,14 +242,14 @@ _docker_compose_events() { _docker_compose_exec() { case "$prev" in - --index|--user|-u) + --index|--user|-u|--workdir|-w) return ;; esac case "$cur" in -*) - COMPREPLY=( $( compgen -W "-d --detach --help --index --privileged -T --user -u" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "-d --detach --help --index --privileged -T --user -u --workdir -w" -- "$cur" ) ) ;; *) __docker_compose_services_running @@ -379,7 +379,7 @@ _docker_compose_ps() { _docker_compose_pull() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "--help --ignore-pull-failures --include-deps --parallel --quiet -q" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--help --ignore-pull-failures --include-deps --no-parallel --quiet -q" -- "$cur" ) ) ;; *) __docker_compose_services_from_image From 5a3f1a3cca0ba256ae2c6ee248b3b7a5e5e428bb Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Wed, 11 Apr 2018 12:47:10 +0200 Subject: [PATCH 3421/4072] Refactor bash completion for services Signed-off-by: Harald Albers --- contrib/completion/bash/docker-compose | 89 +++++++++++--------------- 1 file changed, 36 insertions(+), 53 deletions(-) diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index 0713486d306..7aa69a4632b 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -81,41 +81,24 @@ __docker_compose_nospace() { type compopt &>/dev/null && compopt -o nospace } -# Extracts all service names from the compose file. -___docker_compose_all_services_in_compose_file() { - __docker_compose_q config --services -} - -# All services, even those without an existing container -__docker_compose_services_all() { - COMPREPLY=( $(compgen -W "$(___docker_compose_all_services_in_compose_file)" -- "$cur") ) -} -# All services that are defined by a Dockerfile reference -__docker_compose_services_from_build() { - COMPREPLY=( $(compgen -W "$(__docker_compose_q ps --services --filter "source=build")" -- "$cur") ) +# Outputs a list of all defined services, regardless of their running state. +# Arguments for `docker-compose ps` may be passed in order to filter the service list, +# e.g. `status=running`. +__docker_compose_services() { + __docker_compose_q ps --services "$@" } -# All services that are defined by an image -__docker_compose_services_from_image() { - COMPREPLY=( $(compgen -W "$(__docker_compose_q ps --services --filter "source=image")" -- "$cur") ) -} - -# The services for which at least one paused container exists -__docker_compose_services_paused() { - names=$(__docker_compose_q ps --services --filter "status=paused") - COMPREPLY=( $(compgen -W "$names" -- "$cur") ) +# Applies completion of services based on the current value of `$cur`. +# Arguments for `docker-compose ps` may be passed in order to filter the service list, +# see `__docker_compose_services`. +__docker_compose_complete_services() { + COMPREPLY=( $(compgen -W "$(__docker_compose_services "$@")" -- "$cur") ) } # The services for which at least one running container exists -__docker_compose_services_running() { - names=$(__docker_compose_q ps --services --filter "status=running") - COMPREPLY=( $(compgen -W "$names" -- "$cur") ) -} - -# The services for which at least one stopped container exists -__docker_compose_services_stopped() { - names=$(__docker_compose_q ps --services --filter "status=stopped") +__docker_compose_complete_running_services() { + local names=$(__docker_compose_complete_services --filter status=running) COMPREPLY=( $(compgen -W "$names" -- "$cur") ) } @@ -134,7 +117,7 @@ _docker_compose_build() { COMPREPLY=( $( compgen -W "--build-arg --compress --force-rm --help --memory --no-cache --pull" -- "$cur" ) ) ;; *) - __docker_compose_services_from_build + __docker_compose_complete_services --filter source=build ;; esac } @@ -163,7 +146,7 @@ _docker_compose_create() { COMPREPLY=( $( compgen -W "--build --force-recreate --help --no-build --no-recreate" -- "$cur" ) ) ;; *) - __docker_compose_services_all + __docker_compose_complete_services ;; esac } @@ -234,7 +217,7 @@ _docker_compose_events() { COMPREPLY=( $( compgen -W "--help --json" -- "$cur" ) ) ;; *) - __docker_compose_services_all + __docker_compose_complete_services ;; esac } @@ -252,7 +235,7 @@ _docker_compose_exec() { COMPREPLY=( $( compgen -W "-d --detach --help --index --privileged -T --user -u --workdir -w" -- "$cur" ) ) ;; *) - __docker_compose_services_running + __docker_compose_complete_running_services ;; esac } @@ -268,7 +251,7 @@ _docker_compose_images() { COMPREPLY=( $( compgen -W "--help --quiet -q" -- "$cur" ) ) ;; *) - __docker_compose_services_all + __docker_compose_complete_services ;; esac } @@ -286,7 +269,7 @@ _docker_compose_kill() { COMPREPLY=( $( compgen -W "--help -s" -- "$cur" ) ) ;; *) - __docker_compose_services_running + __docker_compose_complete_running_services ;; esac } @@ -304,7 +287,7 @@ _docker_compose_logs() { COMPREPLY=( $( compgen -W "--follow -f --help --no-color --tail --timestamps -t" -- "$cur" ) ) ;; *) - __docker_compose_services_all + __docker_compose_complete_services ;; esac } @@ -316,7 +299,7 @@ _docker_compose_pause() { COMPREPLY=( $( compgen -W "--help" -- "$cur" ) ) ;; *) - __docker_compose_services_running + __docker_compose_complete_running_services ;; esac } @@ -338,7 +321,7 @@ _docker_compose_port() { COMPREPLY=( $( compgen -W "--help --index --protocol" -- "$cur" ) ) ;; *) - __docker_compose_services_all + __docker_compose_complete_services ;; esac } @@ -370,7 +353,7 @@ _docker_compose_ps() { COMPREPLY=( $( compgen -W "--help --quiet -q --services --filter" -- "$cur" ) ) ;; *) - __docker_compose_services_all + __docker_compose_complete_services ;; esac } @@ -382,7 +365,7 @@ _docker_compose_pull() { COMPREPLY=( $( compgen -W "--help --ignore-pull-failures --include-deps --no-parallel --quiet -q" -- "$cur" ) ) ;; *) - __docker_compose_services_from_image + __docker_compose_complete_services --filter source=image ;; esac } @@ -394,7 +377,7 @@ _docker_compose_push() { COMPREPLY=( $( compgen -W "--help --ignore-push-failures" -- "$cur" ) ) ;; *) - __docker_compose_services_all + __docker_compose_complete_services ;; esac } @@ -412,7 +395,7 @@ _docker_compose_restart() { COMPREPLY=( $( compgen -W "--help --timeout -t" -- "$cur" ) ) ;; *) - __docker_compose_services_running + __docker_compose_complete_running_services ;; esac } @@ -425,9 +408,9 @@ _docker_compose_rm() { ;; *) if __docker_compose_has_option "--stop|-s" ; then - __docker_compose_services_all + __docker_compose_complete_services else - __docker_compose_services_stopped + __docker_compose_complete_services --filter status=stopped fi ;; esac @@ -451,7 +434,7 @@ _docker_compose_run() { COMPREPLY=( $( compgen -W "--detach -d --entrypoint -e --help --label -l --name --no-deps --publish -p --rm --service-ports -T --use-aliases --user -u --volume -v --workdir -w" -- "$cur" ) ) ;; *) - __docker_compose_services_all + __docker_compose_complete_services ;; esac } @@ -473,7 +456,7 @@ _docker_compose_scale() { COMPREPLY=( $( compgen -W "--help --timeout -t" -- "$cur" ) ) ;; *) - COMPREPLY=( $(compgen -S "=" -W "$(___docker_compose_all_services_in_compose_file)" -- "$cur") ) + COMPREPLY=( $(compgen -S "=" -W "$(__docker_compose_services)" -- "$cur") ) __docker_compose_nospace ;; esac @@ -486,7 +469,7 @@ _docker_compose_start() { COMPREPLY=( $( compgen -W "--help" -- "$cur" ) ) ;; *) - __docker_compose_services_stopped + __docker_compose_complete_services --filter status=stopped ;; esac } @@ -504,7 +487,7 @@ _docker_compose_stop() { COMPREPLY=( $( compgen -W "--help --timeout -t" -- "$cur" ) ) ;; *) - __docker_compose_services_running + __docker_compose_complete_running_services ;; esac } @@ -516,7 +499,7 @@ _docker_compose_top() { COMPREPLY=( $( compgen -W "--help" -- "$cur" ) ) ;; *) - __docker_compose_services_running + __docker_compose_complete_running_services ;; esac } @@ -528,7 +511,7 @@ _docker_compose_unpause() { COMPREPLY=( $( compgen -W "--help" -- "$cur" ) ) ;; *) - __docker_compose_services_paused + __docker_compose_complete_services --filter status=paused ;; esac } @@ -541,11 +524,11 @@ _docker_compose_up() { return ;; --exit-code-from) - __docker_compose_services_all + __docker_compose_complete_services return ;; --scale) - COMPREPLY=( $(compgen -S "=" -W "$(___docker_compose_all_services_in_compose_file)" -- "$cur") ) + COMPREPLY=( $(compgen -S "=" -W "$(__docker_compose_services)" -- "$cur") ) __docker_compose_nospace return ;; @@ -559,7 +542,7 @@ _docker_compose_up() { COMPREPLY=( $( compgen -W "--abort-on-container-exit --always-recreate-deps --build -d --detach --exit-code-from --force-recreate --help --no-build --no-color --no-deps --no-recreate --no-start --renew-anon-volumes -V --remove-orphans --scale --timeout -t" -- "$cur" ) ) ;; *) - __docker_compose_services_all + __docker_compose_complete_services ;; esac } From d469113b3742e0761f70e0f5b073f7061d8c854e Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 27 Apr 2018 12:24:43 -0700 Subject: [PATCH 3422/4072] Improve release automation Signed-off-by: Joffrey F --- script/release/release.py | 17 +++++++++++------ script/release/release.sh | 1 + 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/script/release/release.py b/script/release/release.py index add8fb2d353..4357e36b97f 100755 --- a/script/release/release.py +++ b/script/release/release.py @@ -78,10 +78,9 @@ def monitor_pr_status(pr_data): continue summary[detail.state] += 1 print('{pending} pending, {success} successes, {failure} failures'.format(**summary)) - if status.total_count == 0: - # Mostly for testing purposes against repos with no CI setup - return True - elif summary['pending'] == 0 and summary['failure'] == 0: + if summary['pending'] == 0 and summary['failure'] == 0 and summary['success'] > 0: + # This check assumes at least 1 non-DCO CI check to avoid race conditions. + # If testing on a repo without CI, use --skip-ci-check to avoid looping eternally return True elif summary['failure'] > 0: raise ScriptError('CI failures detected!') @@ -156,7 +155,8 @@ def resume(args): if not pr_data: pr_data = repository.create_release_pull_request(args.release) check_pr_mergeable(pr_data) - monitor_pr_status(pr_data) + if not args.skip_ci: + monitor_pr_status(pr_data) downloader = BinaryDownloader(args.destination) files = downloader.download_all(args.release) if not gh_release: @@ -195,7 +195,8 @@ def start(args): create_initial_branch(repository, args) pr_data = repository.create_release_pull_request(args.release) check_pr_mergeable(pr_data) - monitor_pr_status(pr_data) + if not args.skip_ci: + monitor_pr_status(pr_data) downloader = BinaryDownloader(args.destination) files = downloader.download_all(args.release) gh_release = create_release_draft(repository, args.release, pr_data, files) @@ -310,6 +311,10 @@ def main(): '--no-cherries', '-C', dest='cherries', action='store_false', help='If set, the program will not prompt the user for PR numbers to cherry-pick' ) + parser.add_argument( + '--skip-ci-checks', dest='skip_ci', action='store_true', + help='If set, the program will not wait for CI jobs to complete' + ) args = parser.parse_args() if args.action == 'start': diff --git a/script/release/release.sh b/script/release/release.sh index 2310429aaa2..f592365d33c 100755 --- a/script/release/release.sh +++ b/script/release/release.sh @@ -19,6 +19,7 @@ docker run -e GITHUB_TOKEN=$GITHUB_TOKEN -e BINTRAY_TOKEN=$BINTRAY_TOKEN -it \ --mount type=bind,source=$(pwd),target=/src \ --mount type=bind,source=$(pwd)/.git,target=/src/.git \ --mount type=bind,source=$HOME/.docker,target=/root/.docker \ + --mount type=bind,source=$HOME/.gitconfig,target=/root/.gitconfig --mount type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock \ --mount type=bind,source=$HOME/.ssh,target=/root/.ssh \ -v $HOME/.pypirc:/root/.pypirc \ From 90c89e34f19364023f9ff06a32c059c3c86efaaa Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 27 Apr 2018 14:35:13 -0700 Subject: [PATCH 3423/4072] Finalize fixes Signed-off-by: Joffrey F --- script/release/Dockerfile | 3 ++- script/release/release.py | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/script/release/Dockerfile b/script/release/Dockerfile index 0d4ec27e1da..e5af676a50a 100644 --- a/script/release/Dockerfile +++ b/script/release/Dockerfile @@ -3,7 +3,8 @@ RUN mkdir -p /src && pip install -U Jinja2==2.10 \ PyGithub==1.39 \ pypandoc==1.4 \ GitPython==2.1.9 \ - requests==2.18.4 && \ + requests==2.18.4 \ + twine==1.11.0 && \ apt-get update && apt-get install -y pandoc VOLUME /src/script/release diff --git a/script/release/release.py b/script/release/release.py index 4357e36b97f..d0545a7e647 100755 --- a/script/release/release.py +++ b/script/release/release.py @@ -27,6 +27,7 @@ from release.utils import update_init_py_version from release.utils import update_run_sh_version from release.utils import yesno +from twine.commands.upload import main as twine_upload def create_initial_branch(repository, args): @@ -240,8 +241,8 @@ def finalize(args): if not merge_status.merged: raise ScriptError('Unable to merge PR #{}: {}'.format(pr_data.number, merge_status.message)) print('Uploading to PyPi') - run_setup(os.path.join(REPO_ROOT, 'setup.py'), script_args=['upload']) - img_manager.push_images(args.release) + twine_upload(['dist/*']) + img_manager.push_images() repository.publish_release(gh_release) except ScriptError as e: print(e) From bc0344155016d713587db564a39800899f6be04c Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 27 Apr 2018 18:36:48 -0700 Subject: [PATCH 3424/4072] Automatically detect pickable PRs for patch releases Signed-off-by: Joffrey F --- script/release/release.py | 9 +++++++++ script/release/release/repository.py | 18 ++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/script/release/release.py b/script/release/release.py index d0545a7e647..e9a52c4aa59 100755 --- a/script/release/release.py +++ b/script/release/release.py @@ -34,6 +34,15 @@ def create_initial_branch(repository, args): release_branch = repository.create_release_branch(args.release, args.base) if args.base and args.cherries: print('Detected patch version.') + auto_prs = repository.get_prs_in_milestone(args.release) + if auto_prs: + print( + 'Found the following PRs in this release\'s milestone: {}'.format(', '.join(auto_prs)) + ) + proceed = yesno('Automatically cherry-pick detected PRs? Y/n', default=True) + if proceed: + repository.cherry_pick_prs(release_branch, auto_prs) + cherries = input('Indicate (space-separated) PR numbers to cherry-pick then press Enter:\n') repository.cherry_pick_prs(release_branch, cherries.split()) diff --git a/script/release/release/repository.py b/script/release/release/repository.py index d4d1c720111..9a5d432c084 100644 --- a/script/release/release/repository.py +++ b/script/release/release/repository.py @@ -196,6 +196,24 @@ def apply_patch(self, patch_data): f.flush() self.git_repo.git.am('--3way', f.name) + def get_prs_in_milestone(self, version): + milestones = self.gh_repo.get_milestones(state='open') + milestone = None + for ms in milestones: + if ms.title == version: + milestone = ms + break + if not milestone: + print('Didn\'t find a milestone matching "{}"'.format(version)) + return None + + issues = self.gh_repo.get_issues(milestone=milestone, state='all') + prs = [] + for issue in issues: + if issue.pull_request is not None: + prs.append(issue.number) + return sorted(prs) + def get_contributors(pr_data): commits = pr_data.get_commits() From 5eb3f4b32f04c4471b2d537a8bb52480d6eae1c0 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 27 Apr 2018 18:43:05 -0700 Subject: [PATCH 3425/4072] Typo fix Signed-off-by: Joffrey F --- script/release/release.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/release/release.sh b/script/release/release.sh index f592365d33c..affbce37b90 100755 --- a/script/release/release.sh +++ b/script/release/release.sh @@ -19,7 +19,7 @@ docker run -e GITHUB_TOKEN=$GITHUB_TOKEN -e BINTRAY_TOKEN=$BINTRAY_TOKEN -it \ --mount type=bind,source=$(pwd),target=/src \ --mount type=bind,source=$(pwd)/.git,target=/src/.git \ --mount type=bind,source=$HOME/.docker,target=/root/.docker \ - --mount type=bind,source=$HOME/.gitconfig,target=/root/.gitconfig + --mount type=bind,source=$HOME/.gitconfig,target=/root/.gitconfig \ --mount type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock \ --mount type=bind,source=$HOME/.ssh,target=/root/.ssh \ -v $HOME/.pypirc:/root/.pypirc \ From e6aedb1ce0837918b53d20d3501b740b47a4b920 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 27 Apr 2018 18:48:30 -0700 Subject: [PATCH 3426/4072] Partial revert bc034415501 Signed-off-by: Joffrey F --- script/release/release.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/script/release/release.py b/script/release/release.py index e9a52c4aa59..d0545a7e647 100755 --- a/script/release/release.py +++ b/script/release/release.py @@ -34,15 +34,6 @@ def create_initial_branch(repository, args): release_branch = repository.create_release_branch(args.release, args.base) if args.base and args.cherries: print('Detected patch version.') - auto_prs = repository.get_prs_in_milestone(args.release) - if auto_prs: - print( - 'Found the following PRs in this release\'s milestone: {}'.format(', '.join(auto_prs)) - ) - proceed = yesno('Automatically cherry-pick detected PRs? Y/n', default=True) - if proceed: - repository.cherry_pick_prs(release_branch, auto_prs) - cherries = input('Indicate (space-separated) PR numbers to cherry-pick then press Enter:\n') repository.cherry_pick_prs(release_branch, cherries.split()) From 05638ab5ead4819b6af5dfb1f40b3ea37c8a0e12 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Sat, 28 Apr 2018 13:42:24 -0700 Subject: [PATCH 3427/4072] Esnure docker-compose binary is executable (fixes #5917) Signed-off-by: Joffrey F --- script/release/release/images.py | 1 + 1 file changed, 1 insertion(+) diff --git a/script/release/release/images.py b/script/release/release/images.py index 0c7bb20454e..d238d4d7fb6 100644 --- a/script/release/release/images.py +++ b/script/release/release/images.py @@ -23,6 +23,7 @@ def build_images(self, repository, files): distdir = os.path.join(REPO_ROOT, 'dist') os.makedirs(distdir, exist_ok=True) shutil.copy(files['docker-compose-Linux-x86_64'][0], distdir) + os.chmod(os.path.join(distdir, 'docker-compose-Linux-x86_64'), 0o755) print('Building docker/compose image') logstream = docker_client.build( REPO_ROOT, tag='docker/compose:{}'.format(self.version), dockerfile='Dockerfile.run', From 5aafa54667f95660e4863d4a0a40f8f20353191b Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 1 May 2018 17:11:14 -0700 Subject: [PATCH 3428/4072] iprange -> ip_range Signed-off-by: Joffrey F --- compose/config/config_schema_v2.0.json | 2 +- compose/config/config_schema_v2.1.json | 2 +- compose/config/config_schema_v2.2.json | 2 +- compose/config/config_schema_v2.3.json | 2 +- compose/config/config_schema_v2.4.json | 2 +- tests/unit/config/config_test.py | 32 ++++++++++++++++++++++++++ 6 files changed, 37 insertions(+), 5 deletions(-) diff --git a/compose/config/config_schema_v2.0.json b/compose/config/config_schema_v2.0.json index 793cef1d6db..419f2e28c9d 100644 --- a/compose/config/config_schema_v2.0.json +++ b/compose/config/config_schema_v2.0.json @@ -311,7 +311,7 @@ "type": "object", "properties": { "subnet": {"type": "string"}, - "iprange": {"type": "string"}, + "ip_range": {"type": "string"}, "gateway": {"type": "string"}, "aux_addresses": { "type": "object", diff --git a/compose/config/config_schema_v2.1.json b/compose/config/config_schema_v2.1.json index 5ea763544fe..3cb1ee21316 100644 --- a/compose/config/config_schema_v2.1.json +++ b/compose/config/config_schema_v2.1.json @@ -365,7 +365,7 @@ "type": "object", "properties": { "subnet": {"type": "string"}, - "iprange": {"type": "string"}, + "ip_range": {"type": "string"}, "gateway": {"type": "string"}, "aux_addresses": { "type": "object", diff --git a/compose/config/config_schema_v2.2.json b/compose/config/config_schema_v2.2.json index a19d4c94514..8e1f288badf 100644 --- a/compose/config/config_schema_v2.2.json +++ b/compose/config/config_schema_v2.2.json @@ -374,7 +374,7 @@ "type": "object", "properties": { "subnet": {"type": "string"}, - "iprange": {"type": "string"}, + "ip_range": {"type": "string"}, "gateway": {"type": "string"}, "aux_addresses": { "type": "object", diff --git a/compose/config/config_schema_v2.3.json b/compose/config/config_schema_v2.3.json index 78b716a7a7a..659dbcd1a49 100644 --- a/compose/config/config_schema_v2.3.json +++ b/compose/config/config_schema_v2.3.json @@ -418,7 +418,7 @@ "type": "object", "properties": { "subnet": {"type": "string"}, - "iprange": {"type": "string"}, + "ip_range": {"type": "string"}, "gateway": {"type": "string"}, "aux_addresses": { "type": "object", diff --git a/compose/config/config_schema_v2.4.json b/compose/config/config_schema_v2.4.json index a5796d5b12a..47e11875577 100644 --- a/compose/config/config_schema_v2.4.json +++ b/compose/config/config_schema_v2.4.json @@ -417,7 +417,7 @@ "type": "object", "properties": { "subnet": {"type": "string"}, - "iprange": {"type": "string"}, + "ip_range": {"type": "string"}, "gateway": {"type": "string"}, "aux_addresses": { "type": "object", diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 4562a99ca41..085a2d01092 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1344,6 +1344,38 @@ def test_config_invalid_ipam_config(self): assert ('networks.foo.ipam.config contains an invalid type,' ' it should be an object') in excinfo.exconly() + def test_config_valid_ipam_config(self): + ipam_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', + }, + } + networks = config.load( + build_config_details( + { + 'version': str(V2_1), + 'networks': { + 'foo': { + 'driver': 'default', + 'ipam': { + 'driver': 'default', + 'config': [ipam_config], + } + } + } + }, + filename='filename.yml', + ) + ).networks + + assert 'foo' in networks + assert networks['foo']['ipam']['config'] == [ipam_config] + def test_config_valid_service_names(self): for valid_name in ['_', '-', '.__.', '_what-up.', 'what_.up----', 'whatup']: services = config.load( From 507376549cc8c999109bd25b60ddf1e70429277a Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 27 Apr 2018 12:24:43 -0700 Subject: [PATCH 3429/4072] Improve release automation Signed-off-by: Joffrey F --- script/release/release.py | 17 +++++++++++------ script/release/release.sh | 1 + 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/script/release/release.py b/script/release/release.py index add8fb2d353..4357e36b97f 100755 --- a/script/release/release.py +++ b/script/release/release.py @@ -78,10 +78,9 @@ def monitor_pr_status(pr_data): continue summary[detail.state] += 1 print('{pending} pending, {success} successes, {failure} failures'.format(**summary)) - if status.total_count == 0: - # Mostly for testing purposes against repos with no CI setup - return True - elif summary['pending'] == 0 and summary['failure'] == 0: + if summary['pending'] == 0 and summary['failure'] == 0 and summary['success'] > 0: + # This check assumes at least 1 non-DCO CI check to avoid race conditions. + # If testing on a repo without CI, use --skip-ci-check to avoid looping eternally return True elif summary['failure'] > 0: raise ScriptError('CI failures detected!') @@ -156,7 +155,8 @@ def resume(args): if not pr_data: pr_data = repository.create_release_pull_request(args.release) check_pr_mergeable(pr_data) - monitor_pr_status(pr_data) + if not args.skip_ci: + monitor_pr_status(pr_data) downloader = BinaryDownloader(args.destination) files = downloader.download_all(args.release) if not gh_release: @@ -195,7 +195,8 @@ def start(args): create_initial_branch(repository, args) pr_data = repository.create_release_pull_request(args.release) check_pr_mergeable(pr_data) - monitor_pr_status(pr_data) + if not args.skip_ci: + monitor_pr_status(pr_data) downloader = BinaryDownloader(args.destination) files = downloader.download_all(args.release) gh_release = create_release_draft(repository, args.release, pr_data, files) @@ -310,6 +311,10 @@ def main(): '--no-cherries', '-C', dest='cherries', action='store_false', help='If set, the program will not prompt the user for PR numbers to cherry-pick' ) + parser.add_argument( + '--skip-ci-checks', dest='skip_ci', action='store_true', + help='If set, the program will not wait for CI jobs to complete' + ) args = parser.parse_args() if args.action == 'start': diff --git a/script/release/release.sh b/script/release/release.sh index 2310429aaa2..f592365d33c 100755 --- a/script/release/release.sh +++ b/script/release/release.sh @@ -19,6 +19,7 @@ docker run -e GITHUB_TOKEN=$GITHUB_TOKEN -e BINTRAY_TOKEN=$BINTRAY_TOKEN -it \ --mount type=bind,source=$(pwd),target=/src \ --mount type=bind,source=$(pwd)/.git,target=/src/.git \ --mount type=bind,source=$HOME/.docker,target=/root/.docker \ + --mount type=bind,source=$HOME/.gitconfig,target=/root/.gitconfig --mount type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock \ --mount type=bind,source=$HOME/.ssh,target=/root/.ssh \ -v $HOME/.pypirc:/root/.pypirc \ From 7b4603dc22823fefb2cf3143d7017d261649c4a9 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 27 Apr 2018 14:35:13 -0700 Subject: [PATCH 3430/4072] Finalize fixes Signed-off-by: Joffrey F --- script/release/Dockerfile | 3 ++- script/release/release.py | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/script/release/Dockerfile b/script/release/Dockerfile index 0d4ec27e1da..e5af676a50a 100644 --- a/script/release/Dockerfile +++ b/script/release/Dockerfile @@ -3,7 +3,8 @@ RUN mkdir -p /src && pip install -U Jinja2==2.10 \ PyGithub==1.39 \ pypandoc==1.4 \ GitPython==2.1.9 \ - requests==2.18.4 && \ + requests==2.18.4 \ + twine==1.11.0 && \ apt-get update && apt-get install -y pandoc VOLUME /src/script/release diff --git a/script/release/release.py b/script/release/release.py index 4357e36b97f..d0545a7e647 100755 --- a/script/release/release.py +++ b/script/release/release.py @@ -27,6 +27,7 @@ from release.utils import update_init_py_version from release.utils import update_run_sh_version from release.utils import yesno +from twine.commands.upload import main as twine_upload def create_initial_branch(repository, args): @@ -240,8 +241,8 @@ def finalize(args): if not merge_status.merged: raise ScriptError('Unable to merge PR #{}: {}'.format(pr_data.number, merge_status.message)) print('Uploading to PyPi') - run_setup(os.path.join(REPO_ROOT, 'setup.py'), script_args=['upload']) - img_manager.push_images(args.release) + twine_upload(['dist/*']) + img_manager.push_images() repository.publish_release(gh_release) except ScriptError as e: print(e) From d3ca20074d5eebaec9f9417bbb1f5655731bb113 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 27 Apr 2018 18:36:48 -0700 Subject: [PATCH 3431/4072] Automatically detect pickable PRs for patch releases Signed-off-by: Joffrey F --- script/release/release.py | 9 +++++++++ script/release/release/repository.py | 18 ++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/script/release/release.py b/script/release/release.py index d0545a7e647..e9a52c4aa59 100755 --- a/script/release/release.py +++ b/script/release/release.py @@ -34,6 +34,15 @@ def create_initial_branch(repository, args): release_branch = repository.create_release_branch(args.release, args.base) if args.base and args.cherries: print('Detected patch version.') + auto_prs = repository.get_prs_in_milestone(args.release) + if auto_prs: + print( + 'Found the following PRs in this release\'s milestone: {}'.format(', '.join(auto_prs)) + ) + proceed = yesno('Automatically cherry-pick detected PRs? Y/n', default=True) + if proceed: + repository.cherry_pick_prs(release_branch, auto_prs) + cherries = input('Indicate (space-separated) PR numbers to cherry-pick then press Enter:\n') repository.cherry_pick_prs(release_branch, cherries.split()) diff --git a/script/release/release/repository.py b/script/release/release/repository.py index d4d1c720111..9a5d432c084 100644 --- a/script/release/release/repository.py +++ b/script/release/release/repository.py @@ -196,6 +196,24 @@ def apply_patch(self, patch_data): f.flush() self.git_repo.git.am('--3way', f.name) + def get_prs_in_milestone(self, version): + milestones = self.gh_repo.get_milestones(state='open') + milestone = None + for ms in milestones: + if ms.title == version: + milestone = ms + break + if not milestone: + print('Didn\'t find a milestone matching "{}"'.format(version)) + return None + + issues = self.gh_repo.get_issues(milestone=milestone, state='all') + prs = [] + for issue in issues: + if issue.pull_request is not None: + prs.append(issue.number) + return sorted(prs) + def get_contributors(pr_data): commits = pr_data.get_commits() From d8b4b945859bebea6e5f30abc9b298d0a5381fbd Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 27 Apr 2018 18:43:05 -0700 Subject: [PATCH 3432/4072] Typo fix Signed-off-by: Joffrey F --- script/release/release.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/release/release.sh b/script/release/release.sh index f592365d33c..affbce37b90 100755 --- a/script/release/release.sh +++ b/script/release/release.sh @@ -19,7 +19,7 @@ docker run -e GITHUB_TOKEN=$GITHUB_TOKEN -e BINTRAY_TOKEN=$BINTRAY_TOKEN -it \ --mount type=bind,source=$(pwd),target=/src \ --mount type=bind,source=$(pwd)/.git,target=/src/.git \ --mount type=bind,source=$HOME/.docker,target=/root/.docker \ - --mount type=bind,source=$HOME/.gitconfig,target=/root/.gitconfig + --mount type=bind,source=$HOME/.gitconfig,target=/root/.gitconfig \ --mount type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock \ --mount type=bind,source=$HOME/.ssh,target=/root/.ssh \ -v $HOME/.pypirc:/root/.pypirc \ From f05f1699c4325153e98813d2d740a0727113cf2c Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 27 Apr 2018 18:48:30 -0700 Subject: [PATCH 3433/4072] Partial revert bc034415501 Signed-off-by: Joffrey F --- script/release/release.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/script/release/release.py b/script/release/release.py index e9a52c4aa59..d0545a7e647 100755 --- a/script/release/release.py +++ b/script/release/release.py @@ -34,15 +34,6 @@ def create_initial_branch(repository, args): release_branch = repository.create_release_branch(args.release, args.base) if args.base and args.cherries: print('Detected patch version.') - auto_prs = repository.get_prs_in_milestone(args.release) - if auto_prs: - print( - 'Found the following PRs in this release\'s milestone: {}'.format(', '.join(auto_prs)) - ) - proceed = yesno('Automatically cherry-pick detected PRs? Y/n', default=True) - if proceed: - repository.cherry_pick_prs(release_branch, auto_prs) - cherries = input('Indicate (space-separated) PR numbers to cherry-pick then press Enter:\n') repository.cherry_pick_prs(release_branch, cherries.split()) From 064471e640e2fbf5594844cd1845dfaf3a3cc611 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 1 May 2018 17:11:14 -0700 Subject: [PATCH 3434/4072] iprange -> ip_range Signed-off-by: Joffrey F --- compose/config/config_schema_v2.0.json | 2 +- compose/config/config_schema_v2.1.json | 2 +- compose/config/config_schema_v2.2.json | 2 +- compose/config/config_schema_v2.3.json | 2 +- compose/config/config_schema_v2.4.json | 2 +- tests/unit/config/config_test.py | 32 ++++++++++++++++++++++++++ 6 files changed, 37 insertions(+), 5 deletions(-) diff --git a/compose/config/config_schema_v2.0.json b/compose/config/config_schema_v2.0.json index 793cef1d6db..419f2e28c9d 100644 --- a/compose/config/config_schema_v2.0.json +++ b/compose/config/config_schema_v2.0.json @@ -311,7 +311,7 @@ "type": "object", "properties": { "subnet": {"type": "string"}, - "iprange": {"type": "string"}, + "ip_range": {"type": "string"}, "gateway": {"type": "string"}, "aux_addresses": { "type": "object", diff --git a/compose/config/config_schema_v2.1.json b/compose/config/config_schema_v2.1.json index 5ea763544fe..3cb1ee21316 100644 --- a/compose/config/config_schema_v2.1.json +++ b/compose/config/config_schema_v2.1.json @@ -365,7 +365,7 @@ "type": "object", "properties": { "subnet": {"type": "string"}, - "iprange": {"type": "string"}, + "ip_range": {"type": "string"}, "gateway": {"type": "string"}, "aux_addresses": { "type": "object", diff --git a/compose/config/config_schema_v2.2.json b/compose/config/config_schema_v2.2.json index a19d4c94514..8e1f288badf 100644 --- a/compose/config/config_schema_v2.2.json +++ b/compose/config/config_schema_v2.2.json @@ -374,7 +374,7 @@ "type": "object", "properties": { "subnet": {"type": "string"}, - "iprange": {"type": "string"}, + "ip_range": {"type": "string"}, "gateway": {"type": "string"}, "aux_addresses": { "type": "object", diff --git a/compose/config/config_schema_v2.3.json b/compose/config/config_schema_v2.3.json index 78b716a7a7a..659dbcd1a49 100644 --- a/compose/config/config_schema_v2.3.json +++ b/compose/config/config_schema_v2.3.json @@ -418,7 +418,7 @@ "type": "object", "properties": { "subnet": {"type": "string"}, - "iprange": {"type": "string"}, + "ip_range": {"type": "string"}, "gateway": {"type": "string"}, "aux_addresses": { "type": "object", diff --git a/compose/config/config_schema_v2.4.json b/compose/config/config_schema_v2.4.json index a5796d5b12a..47e11875577 100644 --- a/compose/config/config_schema_v2.4.json +++ b/compose/config/config_schema_v2.4.json @@ -417,7 +417,7 @@ "type": "object", "properties": { "subnet": {"type": "string"}, - "iprange": {"type": "string"}, + "ip_range": {"type": "string"}, "gateway": {"type": "string"}, "aux_addresses": { "type": "object", diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 4562a99ca41..085a2d01092 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1344,6 +1344,38 @@ def test_config_invalid_ipam_config(self): assert ('networks.foo.ipam.config contains an invalid type,' ' it should be an object') in excinfo.exconly() + def test_config_valid_ipam_config(self): + ipam_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', + }, + } + networks = config.load( + build_config_details( + { + 'version': str(V2_1), + 'networks': { + 'foo': { + 'driver': 'default', + 'ipam': { + 'driver': 'default', + 'config': [ipam_config], + } + } + } + }, + filename='filename.yml', + ) + ).networks + + assert 'foo' in networks + assert networks['foo']['ipam']['config'] == [ipam_config] + def test_config_valid_service_names(self): for valid_name in ['_', '-', '.__.', '_what-up.', 'what_.up----', 'whatup']: services = config.load( From 7db742d3f2be826af002a4a20aded8fe7cf19374 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Sat, 28 Apr 2018 13:42:24 -0700 Subject: [PATCH 3435/4072] Esnure docker-compose binary is executable (fixes #5917) Signed-off-by: Joffrey F --- script/release/release/images.py | 1 + 1 file changed, 1 insertion(+) diff --git a/script/release/release/images.py b/script/release/release/images.py index 0c7bb20454e..d238d4d7fb6 100644 --- a/script/release/release/images.py +++ b/script/release/release/images.py @@ -23,6 +23,7 @@ def build_images(self, repository, files): distdir = os.path.join(REPO_ROOT, 'dist') os.makedirs(distdir, exist_ok=True) shutil.copy(files['docker-compose-Linux-x86_64'][0], distdir) + os.chmod(os.path.join(distdir, 'docker-compose-Linux-x86_64'), 0o755) print('Building docker/compose image') logstream = docker_client.build( REPO_ROOT, tag='docker/compose:{}'.format(self.version), dockerfile='Dockerfile.run', From f336694912ad603022063b8f5f142d7d7c259c76 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 2 May 2018 00:30:30 +0000 Subject: [PATCH 3436/4072] "Bump 1.21.2" Signed-off-by: Joffrey F --- CHANGELOG.md | 8 ++++++++ compose/__init__.py | 2 +- script/run/run.sh | 2 +- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 18742324f58..b92117d1627 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,14 @@ Change log ========== +1.21.2 (2018-05-03) +------------------- + +### Bugfixes + +- Fixed a bug where the ip_range attirbute in IPAM configs was prevented + from passing validation + 1.21.1 (2018-04-27) ------------------- diff --git a/compose/__init__.py b/compose/__init__.py index 6baeabc14d2..34974e80418 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.21.1' +__version__ = '1.21.2' diff --git a/script/run/run.sh b/script/run/run.sh index 45e74febd9f..60e06996b71 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.21.1" +VERSION="1.21.2" IMAGE="docker/compose:$VERSION" From a133471152a3449920ee9f80e8b89e7e7328444c Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 20 Apr 2018 15:29:37 -0700 Subject: [PATCH 3437/4072] Fix appveyor build Signed-off-by: Joffrey F --- script/build/windows.ps1 | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/script/build/windows.ps1 b/script/build/windows.ps1 index 98a74815801..1de9bbfa4a2 100644 --- a/script/build/windows.ps1 +++ b/script/build/windows.ps1 @@ -44,16 +44,10 @@ virtualenv .\venv # pip and pyinstaller generate lots of warnings, so we need to ignore them $ErrorActionPreference = "Continue" -# Install dependencies -# Fix for https://github.com/pypa/pip/issues/3964 -# Remove-Item -Recurse -Force .\venv\Lib\site-packages\pip -# .\venv\Scripts\easy_install pip==9.0.1 -# .\venv\Scripts\pip install --upgrade pip setuptools -# End fix .\venv\Scripts\pip install pypiwin32==220 .\venv\Scripts\pip install -r requirements.txt .\venv\Scripts\pip install --no-deps . -.\venv\Scripts\pip install --allow-external pyinstaller -r requirements-build.txt +.\venv\Scripts\pip install -r requirements-build.txt git rev-parse --short HEAD | out-file -encoding ASCII compose\GITSHA From c3bb9588651969c3b6161aa7530d8d4becd2753f Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 4 May 2018 14:06:03 -0700 Subject: [PATCH 3438/4072] Ignore default platform if API version doesn't support platform param Signed-off-by: Joffrey F --- compose/project.py | 3 +- compose/service.py | 18 ++++++++---- tests/unit/project_test.py | 9 +++--- tests/unit/service_test.py | 57 +++++++++++++++++++++++++++++++++++++- 4 files changed, 76 insertions(+), 11 deletions(-) diff --git a/compose/project.py b/compose/project.py index 924390b4e21..c27794fc35c 100644 --- a/compose/project.py +++ b/compose/project.py @@ -128,7 +128,8 @@ def from_config(cls, name, config_data, client, default_platform=None): volumes_from=volumes_from, secrets=secrets, pid_mode=pid_mode, - platform=service_dict.pop('platform', default_platform), + platform=service_dict.pop('platform', None), + default_platform=default_platform, **service_dict) ) diff --git a/compose/service.py b/compose/service.py index ae9e0bb0879..4ff56eea74a 100644 --- a/compose/service.py +++ b/compose/service.py @@ -172,6 +172,7 @@ def __init__( secrets=None, scale=None, pid_mode=None, + default_platform=None, **options ): self.name = name @@ -185,6 +186,7 @@ def __init__( self.networks = networks or {} self.secrets = secrets or [] self.scale_num = scale or 1 + self.default_platform = default_platform self.options = options def __repr__(self): @@ -358,6 +360,13 @@ def image(self): def image_name(self): return self.options.get('image', '{s.project}_{s.name}'.format(s=self)) + @property + def platform(self): + platform = self.options.get('platform') + if not platform and version_gte(self.client.api_version, '1.35'): + platform = self.default_platform + return platform + def convergence_plan(self, strategy=ConvergenceStrategy.changed): containers = self.containers(stopped=True) @@ -1018,8 +1027,7 @@ def build(self, no_cache=False, pull=False, force_rm=False, memory=None, build_a if not six.PY3 and not IS_WINDOWS_PLATFORM: path = path.encode('utf8') - platform = self.options.get('platform') - if platform and version_lt(self.client.api_version, '1.35'): + if self.platform and version_lt(self.client.api_version, '1.35'): raise OperationFailedError( 'Impossible to perform platform-targeted builds for API version < 1.35' ) @@ -1044,7 +1052,7 @@ def build(self, no_cache=False, pull=False, force_rm=False, memory=None, build_a }, gzip=gzip, isolation=build_opts.get('isolation', self.options.get('isolation', None)), - platform=platform, + platform=self.platform, ) try: @@ -1150,14 +1158,14 @@ def pull(self, ignore_pull_failures=False, silent=False): kwargs = { 'tag': tag or 'latest', 'stream': True, - 'platform': self.options.get('platform'), + 'platform': self.platform, } if not silent: log.info('Pulling %s (%s%s%s)...' % (self.name, repo, separator, tag)) if kwargs['platform'] and version_lt(self.client.api_version, '1.35'): raise OperationFailedError( - 'Impossible to perform platform-targeted builds for API version < 1.35' + 'Impossible to perform platform-targeted pulls for API version < 1.35' ) try: output = self.client.pull(repo, **kwargs) diff --git a/tests/unit/project_test.py b/tests/unit/project_test.py index 1b6b6651fea..1cc841814d5 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -29,6 +29,7 @@ class ProjectTest(unittest.TestCase): def setUp(self): self.mock_client = mock.create_autospec(docker.APIClient) self.mock_client._general_configs = {} + self.mock_client.api_version = docker.constants.DEFAULT_DOCKER_API_VERSION def test_from_config_v1(self): config = Config( @@ -578,21 +579,21 @@ def test_project_platform_value(self): ) project = Project.from_config(name='test', client=self.mock_client, config_data=config_data) - assert project.get_service('web').options.get('platform') is None + assert project.get_service('web').platform is None project = Project.from_config( name='test', client=self.mock_client, config_data=config_data, default_platform='windows' ) - assert project.get_service('web').options.get('platform') == 'windows' + assert project.get_service('web').platform == 'windows' service_config['platform'] = 'linux/s390x' project = Project.from_config(name='test', client=self.mock_client, config_data=config_data) - assert project.get_service('web').options.get('platform') == 'linux/s390x' + assert project.get_service('web').platform == 'linux/s390x' project = Project.from_config( name='test', client=self.mock_client, config_data=config_data, default_platform='windows' ) - assert project.get_service('web').options.get('platform') == 'linux/s390x' + assert project.get_service('web').platform == 'linux/s390x' @mock.patch('compose.parallel.ParallelStreamWriter._write_noansi') def test_error_parallel_pull(self, mock_write): diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index d50db904491..f5a35d81495 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -446,6 +446,20 @@ def test_pull_image_with_platform_unsupported_api(self, mock_log): with pytest.raises(OperationFailedError): service.pull() + def test_pull_image_with_default_platform(self): + self.mock_client.api_version = '1.35' + + service = Service( + 'foo', client=self.mock_client, image='someimage:sometag', + default_platform='linux' + ) + assert service.platform == 'linux' + service.pull() + + assert self.mock_client.pull.call_count == 1 + call_args = self.mock_client.pull.call_args + assert call_args[1]['platform'] == 'linux' + @mock.patch('compose.service.Container', autospec=True) def test_recreate_container(self, _): mock_container = mock.create_autospec(Container) @@ -538,7 +552,7 @@ def test_build_does_not_pull(self): assert self.mock_client.build.call_count == 1 assert not self.mock_client.build.call_args[1]['pull'] - def test_build_does_with_platform(self): + def test_build_with_platform(self): self.mock_client.api_version = '1.35' self.mock_client.build.return_value = [ b'{"stream": "Successfully built 12345"}', @@ -551,6 +565,47 @@ def test_build_does_with_platform(self): call_args = self.mock_client.build.call_args assert call_args[1]['platform'] == 'linux' + def test_build_with_default_platform(self): + self.mock_client.api_version = '1.35' + self.mock_client.build.return_value = [ + b'{"stream": "Successfully built 12345"}', + ] + + service = Service( + 'foo', client=self.mock_client, build={'context': '.'}, + default_platform='linux' + ) + assert service.platform == 'linux' + service.build() + + assert self.mock_client.build.call_count == 1 + call_args = self.mock_client.build.call_args + assert call_args[1]['platform'] == 'linux' + + def test_service_platform_precedence(self): + self.mock_client.api_version = '1.35' + + service = Service( + 'foo', client=self.mock_client, platform='linux/arm', + default_platform='osx' + ) + assert service.platform == 'linux/arm' + + def test_service_ignore_default_platform_with_unsupported_api(self): + self.mock_client.api_version = '1.32' + self.mock_client.build.return_value = [ + b'{"stream": "Successfully built 12345"}', + ] + + service = Service( + 'foo', client=self.mock_client, default_platform='windows', build={'context': '.'} + ) + assert service.platform is None + service.build() + assert self.mock_client.build.call_count == 1 + call_args = self.mock_client.build.call_args + assert call_args[1]['platform'] is None + def test_build_with_override_build_args(self): self.mock_client.build.return_value = [ b'{"stream": "Successfully built 12345"}', From f368b4846f84afc2fa9fc0701408fd3b6eaed132 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 4 May 2018 15:05:28 -0700 Subject: [PATCH 3439/4072] Ignore attachable property on networks in compatibility mode Signed-off-by: Joffrey F --- compose/config/config.py | 9 +++++---- compose/config/serialize.py | 4 ++++ tests/acceptance/cli_test.py | 6 ++++-- tests/fixtures/compatibility-mode/docker-compose.yml | 6 ++++++ 4 files changed, 19 insertions(+), 6 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 9f8a50c62e4..aab78be5fb9 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -918,6 +918,11 @@ def convert_restart_policy(name): def translate_deploy_keys_to_container_config(service_dict): + if 'credential_spec' in service_dict: + del service_dict['credential_spec'] + if 'configs' in service_dict: + del service_dict['configs'] + if 'deploy' not in service_dict: return service_dict, [] @@ -946,10 +951,6 @@ def translate_deploy_keys_to_container_config(service_dict): ) del service_dict['deploy'] - if 'credential_spec' in service_dict: - del service_dict['credential_spec'] - if 'configs' in service_dict: - del service_dict['configs'] return service_dict, ignored_keys diff --git a/compose/config/serialize.py b/compose/config/serialize.py index c0cf35c1bd8..ccddbf5322a 100644 --- a/compose/config/serialize.py +++ b/compose/config/serialize.py @@ -80,6 +80,10 @@ def denormalize_config(config, image_digests=None): elif 'external' in conf: conf['external'] = True + if 'attachable' in conf and config.version < V3_2: + # For compatibility mode, this option is invalid in v2 + del conf['attachable'] + return result diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 075705804c6..43e8fa8223c 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -481,6 +481,7 @@ def test_config_compatibility_mode(self): assert yaml.load(result.stdout) == { 'version': '2.3', 'volumes': {'foo': {'driver': 'default'}}, + 'networks': {'bar': {}}, 'services': { 'foo': { 'command': '/bin/true', @@ -490,9 +491,10 @@ def test_config_compatibility_mode(self): 'mem_limit': '300M', 'mem_reservation': '100M', 'cpus': 0.7, - 'volumes': ['foo:/bar:rw'] + 'volumes': ['foo:/bar:rw'], + 'networks': {'bar': None}, } - } + }, } def test_ps(self): diff --git a/tests/fixtures/compatibility-mode/docker-compose.yml b/tests/fixtures/compatibility-mode/docker-compose.yml index aac6fd4cb94..8187b110c83 100644 --- a/tests/fixtures/compatibility-mode/docker-compose.yml +++ b/tests/fixtures/compatibility-mode/docker-compose.yml @@ -16,7 +16,13 @@ services: memory: 100M volumes: - foo:/bar + networks: + - bar volumes: foo: driver: default + +networks: + bar: + attachable: true From d5ebc734829f86054388fced14665c348e1d1ef5 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 4 May 2018 16:15:52 -0700 Subject: [PATCH 3440/4072] Don't attempt to create resources with name starting with illegal characters Signed-off-by: Joffrey F --- compose/service.py | 2 +- compose/volume.py | 2 +- tests/integration/project_test.py | 62 +++++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 2 deletions(-) diff --git a/compose/service.py b/compose/service.py index 4ff56eea74a..932ed8b3498 100644 --- a/compose/service.py +++ b/compose/service.py @@ -1355,7 +1355,7 @@ def mode(self): def build_container_name(project, service, number, one_off=False): - bits = [project, service] + bits = [project.lstrip('-_'), service] if one_off: bits.append('run') return '_'.join(bits + [str(number)]) diff --git a/compose/volume.py b/compose/volume.py index 7618417ffa4..60c1e0fe8f2 100644 --- a/compose/volume.py +++ b/compose/volume.py @@ -60,7 +60,7 @@ def exists(self): def full_name(self): if self.custom_name: return self.name - return '{0}_{1}'.format(self.project, self.name) + return '{0}_{1}'.format(self.project.lstrip('-_'), self.name) @property def legacy_full_name(self): diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 3960d12e589..8813e84ce5f 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -1915,3 +1915,65 @@ def test_project_up_seccomp_profile(self): assert len(remote_secopts) == 1 assert remote_secopts[0].startswith('seccomp=') assert json.loads(remote_secopts[0].lstrip('seccomp=')) == seccomp_data + + @no_cluster('inspect volume by name defect on Swarm Classic') + def test_project_up_name_starts_with_illegal_char(self): + config_dict = { + 'version': '2.3', + 'services': { + 'svc1': { + 'image': 'busybox:latest', + 'command': 'ls', + 'volumes': ['foo:/foo:rw'], + 'networks': ['bar'], + }, + }, + 'volumes': { + 'foo': {}, + }, + 'networks': { + 'bar': {}, + } + } + config_data = load_config(config_dict) + project = Project.from_config( + name='_underscoretest', config_data=config_data, client=self.client + ) + project.up() + self.addCleanup(project.down, None, True) + + containers = project.containers(stopped=True) + assert len(containers) == 1 + assert containers[0].name == 'underscoretest_svc1_1' + assert containers[0].project == '_underscoretest' + + full_vol_name = 'underscoretest_foo' + vol_data = self.get_volume_data(full_vol_name) + assert vol_data + assert vol_data['Labels'][LABEL_PROJECT] == '_underscoretest' + + full_net_name = '_underscoretest_bar' + net_data = self.client.inspect_network(full_net_name) + assert net_data + assert net_data['Labels'][LABEL_PROJECT] == '_underscoretest' + + project2 = Project.from_config( + name='-dashtest', config_data=config_data, client=self.client + ) + project2.up() + self.addCleanup(project2.down, None, True) + + containers = project2.containers(stopped=True) + assert len(containers) == 1 + assert containers[0].name == 'dashtest_svc1_1' + assert containers[0].project == '-dashtest' + + full_vol_name = 'dashtest_foo' + vol_data = self.get_volume_data(full_vol_name) + assert vol_data + assert vol_data['Labels'][LABEL_PROJECT] == '-dashtest' + + full_net_name = '-dashtest_bar' + net_data = self.client.inspect_network(full_net_name) + assert net_data + assert net_data['Labels'][LABEL_PROJECT] == '-dashtest' From 7846f6e2a06ac0e0acfda41b9f0e869b411c7225 Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Thu, 17 May 2018 15:57:07 +0200 Subject: [PATCH 3441/4072] Fix bash completion for running services 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 7aa69a4632b..b90af45d189 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -98,7 +98,7 @@ __docker_compose_complete_services() { # The services for which at least one running container exists __docker_compose_complete_running_services() { - local names=$(__docker_compose_complete_services --filter status=running) + local names=$(__docker_compose_services --filter status=running) COMPREPLY=( $(compgen -W "$names" -- "$cur") ) } From e245fb04cfb2703950e458cf3a81ac6e50db1886 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 23 May 2018 16:28:41 -0700 Subject: [PATCH 3442/4072] Allow all Compose commands to retrieve and handle legacy-name containers Signed-off-by: Joffrey F --- compose/container.py | 8 ++++++++ compose/project.py | 19 ++++++++++++++++--- compose/service.py | 40 ++++++++++++++++++++++------------------ 3 files changed, 46 insertions(+), 21 deletions(-) diff --git a/compose/container.py b/compose/container.py index 0c2ca990218..8dac8cacd99 100644 --- a/compose/container.py +++ b/compose/container.py @@ -9,6 +9,8 @@ from .const import LABEL_CONTAINER_NUMBER from .const import LABEL_PROJECT from .const import LABEL_SERVICE +from .const import LABEL_VERSION +from .version import ComposeVersion class Container(object): @@ -283,6 +285,12 @@ def reset_image(self, img_id): def attach(self, *args, **kwargs): return self.client.attach(self.id, *args, **kwargs) + def has_legacy_proj_name(self, project_name): + return ( + ComposeVersion(self.labels.get(LABEL_VERSION)) < ComposeVersion('1.21.0') and + self.project != project_name + ) + def __repr__(self): return '' % (self.name, self.id[:6]) diff --git a/compose/project.py b/compose/project.py index c27794fc35c..005b7e24092 100644 --- a/compose/project.py +++ b/compose/project.py @@ -4,6 +4,7 @@ import datetime import logging import operator +import re from functools import reduce import enum @@ -70,8 +71,11 @@ def __init__(self, name, services, client, networks=None, volumes=None, config_v self.networks = networks or ProjectNetworks({}, False) self.config_version = config_version - def labels(self, one_off=OneOffFilter.exclude): - labels = ['{0}={1}'.format(LABEL_PROJECT, self.name)] + def labels(self, one_off=OneOffFilter.exclude, legacy=False): + name = self.name + if legacy: + name = re.sub(r'[_-]', '', name) + labels = ['{0}={1}'.format(LABEL_PROJECT, name)] OneOffFilter.update_labels(one_off, labels) return labels @@ -571,12 +575,21 @@ def push(self, service_names=None, ignore_push_failures=False): service.push(ignore_push_failures) def _labeled_containers(self, stopped=False, one_off=OneOffFilter.exclude): - return list(filter(None, [ + ctnrs = 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)})]) ) + if ctnrs: + return ctnrs + + return list(filter(lambda c: c.has_legacy_proj_name(self.name), filter(None, [ + Container.from_ps(self.client, container) + for container in self.client.containers( + all=stopped, + filters={'label': self.labels(one_off=one_off, legacy=True)})]) + )) def containers(self, service_names=None, stopped=False, one_off=OneOffFilter.exclude): if service_names: diff --git a/compose/service.py b/compose/service.py index 932ed8b3498..48cbc1702f2 100644 --- a/compose/service.py +++ b/compose/service.py @@ -1,6 +1,7 @@ from __future__ import absolute_import from __future__ import unicode_literals +import itertools import logging import os import re @@ -51,7 +52,6 @@ from .utils import json_hash from .utils import parse_bytes from .utils import parse_seconds_float -from .version import ComposeVersion log = logging.getLogger(__name__) @@ -192,8 +192,8 @@ def __init__( 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)}) + def containers(self, stopped=False, one_off=False, filters={}, labels=None): + filters.update({'label': self.labels(one_off=one_off) + (labels or [])}) result = list(filter(None, [ Container.from_ps(self.client, container) @@ -204,10 +204,10 @@ def containers(self, stopped=False, one_off=False, filters={}): if result: return result - filters.update({'label': self.labels(one_off=one_off, legacy=True)}) + filters.update({'label': self.labels(one_off=one_off, legacy=True) + (labels or [])}) return list( filter( - self.has_legacy_proj_name, filter(None, [ + lambda c: c.has_legacy_proj_name(self.project), filter(None, [ Container.from_ps(self.client, container) for container in self.client.containers( all=stopped, @@ -219,9 +219,9 @@ def get_container(self, number=1): """Return a :class:`compose.container.Container` for this service. The container must be active, and match `number`. """ - labels = self.labels() + ['{0}={1}'.format(LABEL_CONTAINER_NUMBER, number)] - for container in self.client.containers(filters={'label': labels}): - return Container.from_ps(self.client, container) + + for container in self.containers(labels=['{0}={1}'.format(LABEL_CONTAINER_NUMBER, number)]): + return container raise ValueError("No container found for %s_%s" % (self.name, number)) @@ -258,6 +258,11 @@ def scale(self, desired_num, timeout=None): running_containers = self.containers(stopped=False) num_running = len(running_containers) + for c in running_containers: + if not c.has_legacy_proj_name(self.project): + continue + log.info('Recreating container with legacy name %s' % c.name) + self.recreate_container(c, timeout, start_new_container=False) if desired_num == num_running: # do nothing as we already have the desired number @@ -404,7 +409,7 @@ def _containers_have_diverged(self, containers): has_diverged = False for c in containers: - if self.has_legacy_proj_name(c): + if c.has_legacy_proj_name(self.project): log.debug('%s has diverged: Legacy project name' % c.name) has_diverged = True continue @@ -713,9 +718,14 @@ def get_volumes_from_names(self): # TODO: this would benefit from github.com/docker/docker/pull/14699 # to remove the need to inspect every container def _next_container_number(self, one_off=False): - containers = self._fetch_containers( - all=True, - filters={'label': self.labels(one_off=one_off)} + containers = itertools.chain( + self._fetch_containers( + all=True, + filters={'label': self.labels(one_off=one_off)} + ), self._fetch_containers( + all=True, + filters={'label': self.labels(one_off=one_off, legacy=True)} + ) ) numbers = [c.number for c in containers] return 1 if not numbers else max(numbers) + 1 @@ -1243,12 +1253,6 @@ def _parse_proxy_config(self): return result - def has_legacy_proj_name(self, ctnr): - return ( - ComposeVersion(ctnr.labels.get(LABEL_VERSION)) < ComposeVersion('1.21.0') and - ctnr.project != self.project - ) - def short_id_alias_exists(container, network): aliases = container.get( From 025fb7f86075042c3fb9d3201aebb71c0a1a5003 Mon Sep 17 00:00:00 2001 From: Vincent Demeester Date: Tue, 29 May 2018 11:58:54 +0200 Subject: [PATCH 3443/4072] Add composefile v3.7 Signed-off-by: Vincent Demeester --- compose/config/config_schema_v3.7.json | 582 +++++++++++++++++++++++++ compose/const.py | 3 + docker-compose.spec | 5 + 3 files changed, 590 insertions(+) create mode 100644 compose/config/config_schema_v3.7.json diff --git a/compose/config/config_schema_v3.7.json b/compose/config/config_schema_v3.7.json new file mode 100644 index 00000000000..f85efe34f36 --- /dev/null +++ b/compose/config/config_schema_v3.7.json @@ -0,0 +1,582 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "id": "config_schema_v3.7.json", + "type": "object", + "required": ["version"], + + "properties": { + "version": { + "type": "string" + }, + + "services": { + "id": "#/properties/services", + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "$ref": "#/definitions/service" + } + }, + "additionalProperties": false + }, + + "networks": { + "id": "#/properties/networks", + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "$ref": "#/definitions/network" + } + } + }, + + "volumes": { + "id": "#/properties/volumes", + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "$ref": "#/definitions/volume" + } + }, + "additionalProperties": false + }, + + "secrets": { + "id": "#/properties/secrets", + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "$ref": "#/definitions/secret" + } + }, + "additionalProperties": false + }, + + "configs": { + "id": "#/properties/configs", + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "$ref": "#/definitions/config" + } + }, + "additionalProperties": false + } + }, + + "patternProperties": {"^x-": {}}, + "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"}, + "labels": {"$ref": "#/definitions/list_or_dict"}, + "cache_from": {"$ref": "#/definitions/list_of_strings"}, + "network": {"type": "string"}, + "target": {"type": "string"}, + "shm_size": {"type": ["integer", "string"]} + }, + "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"}} + ] + }, + "configs": { + "type": "array", + "items": { + "oneOf": [ + {"type": "string"}, + { + "type": "object", + "properties": { + "source": {"type": "string"}, + "target": {"type": "string"}, + "uid": {"type": "string"}, + "gid": {"type": "string"}, + "mode": {"type": "number"} + } + } + ] + } + }, + "container_name": {"type": "string"}, + "credential_spec": {"type": "object", "properties": { + "file": {"type": "string"}, + "registry": {"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"}, + "isolation": {"type": "string"}, + "labels": {"$ref": "#/definitions/list_or_dict"}, + "links": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + + "logging": { + "type": "object", + + "properties": { + "driver": {"type": "string"}, + "options": { + "type": "object", + "patternProperties": { + "^.+$": {"type": ["string", "number", "null"]} + } + } + }, + "additionalProperties": false + }, + + "mac_address": {"type": "string"}, + "network_mode": {"type": "string"}, + + "networks": { + "oneOf": [ + {"$ref": "#/definitions/list_of_strings"}, + { + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "oneOf": [ + { + "type": "object", + "properties": { + "aliases": {"$ref": "#/definitions/list_of_strings"}, + "ipv4_address": {"type": "string"}, + "ipv6_address": {"type": "string"} + }, + "additionalProperties": false + }, + {"type": "null"} + ] + } + }, + "additionalProperties": false + } + ] + }, + "pid": {"type": ["string", "null"]}, + + "ports": { + "type": "array", + "items": { + "oneOf": [ + {"type": "number", "format": "ports"}, + {"type": "string", "format": "ports"}, + { + "type": "object", + "properties": { + "mode": {"type": "string"}, + "target": {"type": "integer"}, + "published": {"type": "integer"}, + "protocol": {"type": "string"} + }, + "additionalProperties": false + } + ] + }, + "uniqueItems": true + }, + + "privileged": {"type": "boolean"}, + "read_only": {"type": "boolean"}, + "restart": {"type": "string"}, + "security_opt": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "shm_size": {"type": ["number", "string"]}, + "secrets": { + "type": "array", + "items": { + "oneOf": [ + {"type": "string"}, + { + "type": "object", + "properties": { + "source": {"type": "string"}, + "target": {"type": "string"}, + "uid": {"type": "string"}, + "gid": {"type": "string"}, + "mode": {"type": "number"} + } + } + ] + } + }, + "sysctls": {"$ref": "#/definitions/list_or_dict"}, + "stdin_open": {"type": "boolean"}, + "stop_grace_period": {"type": "string", "format": "duration"}, + "stop_signal": {"type": "string"}, + "tmpfs": {"$ref": "#/definitions/string_or_list"}, + "tty": {"type": "boolean"}, + "ulimits": { + "type": "object", + "patternProperties": { + "^[a-z]+$": { + "oneOf": [ + {"type": "integer"}, + { + "type":"object", + "properties": { + "hard": {"type": "integer"}, + "soft": {"type": "integer"} + }, + "required": ["soft", "hard"], + "additionalProperties": false + } + ] + } + } + }, + "user": {"type": "string"}, + "userns_mode": {"type": "string"}, + "volumes": { + "type": "array", + "items": { + "oneOf": [ + {"type": "string"}, + { + "type": "object", + "required": ["type"], + "properties": { + "type": {"type": "string"}, + "source": {"type": "string"}, + "target": {"type": "string"}, + "read_only": {"type": "boolean"}, + "consistency": {"type": "string"}, + "bind": { + "type": "object", + "properties": { + "propagation": {"type": "string"} + } + }, + "volume": { + "type": "object", + "properties": { + "nocopy": {"type": "boolean"} + } + }, + "tmpfs": { + "type": "object", + "properties": { + "size": { + "type": "integer", + "minimum": 0 + } + } + } + }, + "additionalProperties": false + } + ], + "uniqueItems": true + } + }, + "working_dir": {"type": "string"} + }, + "additionalProperties": false + }, + + "healthcheck": { + "id": "#/definitions/healthcheck", + "type": "object", + "additionalProperties": false, + "properties": { + "disable": {"type": "boolean"}, + "interval": {"type": "string", "format": "duration"}, + "retries": {"type": "number"}, + "test": { + "oneOf": [ + {"type": "string"}, + {"type": "array", "items": {"type": "string"}} + ] + }, + "timeout": {"type": "string", "format": "duration"}, + "start_period": {"type": "string", "format": "duration"} + } + }, + "deployment": { + "id": "#/definitions/deployment", + "type": ["object", "null"], + "properties": { + "mode": {"type": "string"}, + "endpoint_mode": {"type": "string"}, + "replicas": {"type": "integer"}, + "labels": {"$ref": "#/definitions/list_or_dict"}, + "update_config": { + "type": "object", + "properties": { + "parallelism": {"type": "integer"}, + "delay": {"type": "string", "format": "duration"}, + "failure_action": {"type": "string"}, + "monitor": {"type": "string", "format": "duration"}, + "max_failure_ratio": {"type": "number"}, + "order": {"type": "string", "enum": [ + "start-first", "stop-first" + ]} + }, + "additionalProperties": false + }, + "resources": { + "type": "object", + "properties": { + "limits": { + "type": "object", + "properties": { + "cpus": {"type": "string"}, + "memory": {"type": "string"} + }, + "additionalProperties": false + }, + "reservations": { + "type": "object", + "properties": { + "cpus": {"type": "string"}, + "memory": {"type": "string"}, + "generic_resources": {"$ref": "#/definitions/generic_resources"} + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "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"}}, + "preferences": { + "type": "array", + "items": { + "type": "object", + "properties": { + "spread": {"type": "string"} + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + + "generic_resources": { + "id": "#/definitions/generic_resources", + "type": "array", + "items": { + "type": "object", + "properties": { + "discrete_resource_spec": { + "type": "object", + "properties": { + "kind": {"type": "string"}, + "value": {"type": "number"} + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + }, + + "network": { + "id": "#/definitions/network", + "type": ["object", "null"], + "properties": { + "name": {"type": "string"}, + "driver": {"type": "string"}, + "driver_opts": { + "type": "object", + "patternProperties": { + "^.+$": {"type": ["string", "number"]} + } + }, + "ipam": { + "type": "object", + "properties": { + "driver": {"type": "string"}, + "config": { + "type": "array", + "items": { + "type": "object", + "properties": { + "subnet": {"type": "string"} + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + }, + "external": { + "type": ["boolean", "object"], + "properties": { + "name": {"type": "string"} + }, + "additionalProperties": false + }, + "internal": {"type": "boolean"}, + "attachable": {"type": "boolean"}, + "labels": {"$ref": "#/definitions/list_or_dict"} + }, + "additionalProperties": false + }, + + "volume": { + "id": "#/definitions/volume", + "type": ["object", "null"], + "properties": { + "name": {"type": "string"}, + "driver": {"type": "string"}, + "driver_opts": { + "type": "object", + "patternProperties": { + "^.+$": {"type": ["string", "number"]} + } + }, + "external": { + "type": ["boolean", "object"], + "properties": { + "name": {"type": "string"} + }, + "additionalProperties": false + }, + "labels": {"$ref": "#/definitions/list_or_dict"} + }, + "additionalProperties": false + }, + + "secret": { + "id": "#/definitions/secret", + "type": "object", + "properties": { + "name": {"type": "string"}, + "file": {"type": "string"}, + "external": { + "type": ["boolean", "object"], + "properties": { + "name": {"type": "string"} + } + }, + "labels": {"$ref": "#/definitions/list_or_dict"} + }, + "additionalProperties": false + }, + + "config": { + "id": "#/definitions/config", + "type": "object", + "properties": { + "name": {"type": "string"}, + "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"] + } + } + } + } + } +} diff --git a/compose/const.py b/compose/const.py index 200a458a183..f5632de749b 100644 --- a/compose/const.py +++ b/compose/const.py @@ -36,6 +36,7 @@ COMPOSEFILE_V3_4 = ComposeVersion('3.4') COMPOSEFILE_V3_5 = ComposeVersion('3.5') COMPOSEFILE_V3_6 = ComposeVersion('3.6') +COMPOSEFILE_V3_7 = ComposeVersion('3.7') API_VERSIONS = { COMPOSEFILE_V1: '1.21', @@ -51,6 +52,7 @@ COMPOSEFILE_V3_4: '1.30', COMPOSEFILE_V3_5: '1.30', COMPOSEFILE_V3_6: '1.36', + COMPOSEFILE_V3_7: '1.36', } API_VERSION_TO_ENGINE_VERSION = { @@ -67,4 +69,5 @@ API_VERSIONS[COMPOSEFILE_V3_4]: '17.06.0', API_VERSIONS[COMPOSEFILE_V3_5]: '17.06.0', API_VERSIONS[COMPOSEFILE_V3_6]: '18.02.0', + API_VERSIONS[COMPOSEFILE_V3_7]: '18.02.0', } diff --git a/docker-compose.spec b/docker-compose.spec index b8c3a41912f..70db5c29e16 100644 --- a/docker-compose.spec +++ b/docker-compose.spec @@ -82,6 +82,11 @@ exe = EXE(pyz, 'compose/config/config_schema_v3.6.json', 'DATA' ), + ( + 'compose/config/config_schema_v3.7.json', + 'compose/config/config_schema_v3.7.json', + 'DATA' + ), ( 'compose/GITSHA', 'compose/GITSHA', From 70574efd5bb882efd22027eba5853255c9026892 Mon Sep 17 00:00:00 2001 From: Vincent Demeester Date: Wed, 30 May 2018 13:36:59 +0200 Subject: [PATCH 3444/4072] Support for rollback config in compose 3.7 Ignoring it on docker-compose Signed-off-by: Vincent Demeester --- compose/config/config.py | 3 ++- compose/config/config_schema_v3.7.json | 14 ++++++++++++++ compose/config/interpolation.py | 2 ++ compose/const.py | 2 +- 4 files changed, 19 insertions(+), 2 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index aab78be5fb9..5c614fadc06 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -928,7 +928,7 @@ def translate_deploy_keys_to_container_config(service_dict): deploy_dict = service_dict['deploy'] ignored_keys = [ - k for k in ['endpoint_mode', 'labels', 'update_config', 'placement'] + k for k in ['endpoint_mode', 'labels', 'update_config', 'rollback_config', 'placement'] if k in deploy_dict ] @@ -1136,6 +1136,7 @@ def merge_deploy(base, override): md.merge_scalar('replicas') md.merge_mapping('labels', parse_labels) md.merge_mapping('update_config') + md.merge_mapping('rollback_config') md.merge_mapping('restart_policy') if md.needs_merge('resources'): resources_md = MergeDict(md.base.get('resources') or {}, md.override.get('resources') or {}) diff --git a/compose/config/config_schema_v3.7.json b/compose/config/config_schema_v3.7.json index f85efe34f36..4c3d24dcd27 100644 --- a/compose/config/config_schema_v3.7.json +++ b/compose/config/config_schema_v3.7.json @@ -348,6 +348,20 @@ "endpoint_mode": {"type": "string"}, "replicas": {"type": "integer"}, "labels": {"$ref": "#/definitions/list_or_dict"}, + "rollback_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"}, + "order": {"type": "string", "enum": [ + "start-first", "stop-first" + ]} + }, + "additionalProperties": false + }, "update_config": { "type": "object", "properties": { diff --git a/compose/config/interpolation.py b/compose/config/interpolation.py index 8845d73b5e5..4f56dff5973 100644 --- a/compose/config/interpolation.py +++ b/compose/config/interpolation.py @@ -248,6 +248,8 @@ class ConversionMap(object): service_path('deploy', 'replicas'): to_int, service_path('deploy', 'update_config', 'parallelism'): to_int, service_path('deploy', 'update_config', 'max_failure_ratio'): to_float, + service_path('deploy', 'rollback_config', 'parallelism'): to_int, + service_path('deploy', 'rollback_config', 'max_failure_ratio'): to_float, service_path('deploy', 'restart_policy', 'max_attempts'): to_int, service_path('mem_swappiness'): to_int, service_path('labels', FULL_JOKER): to_str, diff --git a/compose/const.py b/compose/const.py index f5632de749b..374a0971187 100644 --- a/compose/const.py +++ b/compose/const.py @@ -69,5 +69,5 @@ API_VERSIONS[COMPOSEFILE_V3_4]: '17.06.0', API_VERSIONS[COMPOSEFILE_V3_5]: '17.06.0', API_VERSIONS[COMPOSEFILE_V3_6]: '18.02.0', - API_VERSIONS[COMPOSEFILE_V3_7]: '18.02.0', + API_VERSIONS[COMPOSEFILE_V3_7]: '18.06.0', } From 7a19b7548ff44955108a6a3bd4be9527babd1622 Mon Sep 17 00:00:00 2001 From: Vincent Demeester Date: Thu, 31 May 2018 14:12:10 +0200 Subject: [PATCH 3445/4072] Allow `x-*` extension on 3rd level objects As for top-level key, any 3rd-level key which starts with `x-` will be ignored by compose. This allows for users to: * include additional metadata in their compose files * create YAML anchor objects that can be re-used in other parts of the config This matches a similar feature in the swagger spec definition: https://swagger.io/specification/#specificationExtensions This means a composefile like the following is valid ``` verison: "3.7" services: foo: image: foo/bar x-foo: bar network: bar: x-bar: baz ``` It concerns services, volumes, networks, configs and secrets. Signed-off-by: Vincent Demeester --- compose/config/config_schema_v2.4.json | 3 +++ compose/config/config_schema_v3.7.json | 5 +++++ 2 files changed, 8 insertions(+) diff --git a/compose/config/config_schema_v2.4.json b/compose/config/config_schema_v2.4.json index 47e11875577..4e641788718 100644 --- a/compose/config/config_schema_v2.4.json +++ b/compose/config/config_schema_v2.4.json @@ -346,6 +346,7 @@ "dependencies": { "memswap_limit": ["mem_limit"] }, + "patternProperties": {"^x-": {}}, "additionalProperties": false }, @@ -409,6 +410,7 @@ "labels": {"$ref": "#/definitions/labels"}, "name": {"type": "string"} }, + "patternProperties": {"^x-": {}}, "additionalProperties": false }, @@ -451,6 +453,7 @@ "labels": {"$ref": "#/definitions/labels"}, "name": {"type": "string"} }, + "patternProperties": {"^x-": {}}, "additionalProperties": false }, diff --git a/compose/config/config_schema_v3.7.json b/compose/config/config_schema_v3.7.json index 4c3d24dcd27..05566cf8195 100644 --- a/compose/config/config_schema_v3.7.json +++ b/compose/config/config_schema_v3.7.json @@ -319,6 +319,7 @@ }, "working_dir": {"type": "string"} }, + "patternProperties": {"^x-": {}}, "additionalProperties": false }, @@ -489,6 +490,7 @@ "attachable": {"type": "boolean"}, "labels": {"$ref": "#/definitions/list_or_dict"} }, + "patternProperties": {"^x-": {}}, "additionalProperties": false }, @@ -513,6 +515,7 @@ }, "labels": {"$ref": "#/definitions/list_or_dict"} }, + "patternProperties": {"^x-": {}}, "additionalProperties": false }, @@ -530,6 +533,7 @@ }, "labels": {"$ref": "#/definitions/list_or_dict"} }, + "patternProperties": {"^x-": {}}, "additionalProperties": false }, @@ -547,6 +551,7 @@ }, "labels": {"$ref": "#/definitions/list_or_dict"} }, + "patternProperties": {"^x-": {}}, "additionalProperties": false }, From c584ad67fce14ab9ee221945ccc10b52b5ab0aeb Mon Sep 17 00:00:00 2001 From: Vincent Demeester Date: Mon, 18 Jun 2018 10:52:57 +0200 Subject: [PATCH 3446/4072] Add `init` support in 3.7 schema > Run an init inside the container that forwards signals and reaps > processes This is already supported in 2.4 schema Signed-off-by: Vincent Demeester --- compose/config/config_schema_v3.7.json | 1 + 1 file changed, 1 insertion(+) diff --git a/compose/config/config_schema_v3.7.json b/compose/config/config_schema_v3.7.json index 05566cf8195..cd7882f5b24 100644 --- a/compose/config/config_schema_v3.7.json +++ b/compose/config/config_schema_v3.7.json @@ -154,6 +154,7 @@ "healthcheck": {"$ref": "#/definitions/healthcheck"}, "hostname": {"type": "string"}, "image": {"type": "string"}, + "init": {"type": "boolean"}, "ipc": {"type": "string"}, "isolation": {"type": "string"}, "labels": {"$ref": "#/definitions/list_or_dict"}, From a728ff6a599fb4aaefba066fd71fb276598e2cd7 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 18 Jun 2018 15:31:25 -0700 Subject: [PATCH 3447/4072] Bump Python SDK -> 3.4.0 Signed-off-by: Joffrey F --- requirements.txt | 4 ++-- setup.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/requirements.txt b/requirements.txt index 93a0cce3527..05b38526ade 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,8 +2,8 @@ backports.ssl-match-hostname==3.5.0.1; python_version < '3' cached-property==1.3.0 certifi==2017.4.17 chardet==3.0.4 -docker==3.3.0 -docker-pycreds==0.2.3 +docker==3.4.0 +docker-pycreds==0.3.0 dockerpty==0.4.1 docopt==0.6.2 enum34==1.1.6; python_version < '3.4' diff --git a/setup.py b/setup.py index 422ba5466ee..fc024078e90 100644 --- a/setup.py +++ b/setup.py @@ -36,7 +36,7 @@ def find_version(*file_paths): 'requests >= 2.6.1, != 2.11.0, != 2.12.2, != 2.18.0, < 2.19', 'texttable >= 0.9.0, < 0.10', 'websocket-client >= 0.32.0, < 1.0', - 'docker >= 3.3.0, < 4.0', + 'docker >= 3.4.0, < 4.0', 'dockerpty >= 0.4.1, < 0.5', 'six >= 1.3.0, < 2', 'jsonschema >= 2.5.1, < 3', From c187d3c39fece75144d4e7c0b04994dca0098190 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 8 Jun 2018 16:32:55 -0700 Subject: [PATCH 3448/4072] Use original LD_LIBRARY_PATH when shelling out to credential stores Signed-off-by: Joffrey F --- compose/cli/docker_client.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/compose/cli/docker_client.py b/compose/cli/docker_client.py index 939e95bf0e1..a01704fd271 100644 --- a/compose/cli/docker_client.py +++ b/compose/cli/docker_client.py @@ -117,6 +117,13 @@ def docker_client(environment, version=None, tls_config=None, host=None, kwargs['user_agent'] = generate_user_agent() + # Workaround for + # https://pyinstaller.readthedocs.io/en/v3.3.1/runtime-information.html#ld-library-path-libpath-considerations + if 'LD_LIBRARY_PATH_ORIG' in environment: + kwargs['credstore_env'] = { + 'LD_LIBRARY_PATH': environment.get('LD_LIBRARY_PATH_ORIG'), + } + client = APIClient(**kwargs) client._original_base_url = kwargs.get('base_url') From 80322cfa5b00162c22f8abe6442daf3d0f57f230 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 12 Jun 2018 15:01:53 -0700 Subject: [PATCH 3449/4072] Better support for UTF8+bom Compose files Signed-off-by: Joffrey F --- compose/config/config.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 5c614fadc06..7abab254681 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -1436,15 +1436,15 @@ def has_uppercase(name): return any(char in string.ascii_uppercase for char in name) -def load_yaml(filename, encoding=None): +def load_yaml(filename, encoding=None, binary=True): try: - with io.open(filename, 'r', encoding=encoding) as fh: + with io.open(filename, 'rb' if binary else 'r', encoding=encoding) as fh: return yaml.safe_load(fh) except (IOError, yaml.YAMLError, UnicodeDecodeError) as e: if encoding is None: # Sometimes the user's locale sets an encoding that doesn't match # the YAML files. Im such cases, retry once with the "default" # UTF-8 encoding - return load_yaml(filename, encoding='utf-8') + return load_yaml(filename, encoding='utf-8-sig', binary=False) error_name = getattr(e, '__module__', '') + '.' + e.__class__.__name__ raise ConfigurationError(u"{}: {}".format(error_name, e)) From 709ba0975da55e1367a1bb29f5316db98ea7e0c6 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 21 Jun 2018 11:40:32 -0700 Subject: [PATCH 3450/4072] Release script fixes Signed-off-by: Joffrey F --- script/release/release.sh | 4 ++-- script/release/release/bintray.py | 4 +++- script/release/release/images.py | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/script/release/release.sh b/script/release/release.sh index affbce37b90..2011826571a 100755 --- a/script/release/release.sh +++ b/script/release/release.sh @@ -15,12 +15,12 @@ if test -z $BINTRAY_TOKEN; then exit 1 fi -docker run -e GITHUB_TOKEN=$GITHUB_TOKEN -e BINTRAY_TOKEN=$BINTRAY_TOKEN -it \ +docker run -e GITHUB_TOKEN=$GITHUB_TOKEN -e BINTRAY_TOKEN=$BINTRAY_TOKEN -e SSH_AUTH_SOCK=$SSH_AUTH_SOCK -it \ --mount type=bind,source=$(pwd),target=/src \ - --mount type=bind,source=$(pwd)/.git,target=/src/.git \ --mount type=bind,source=$HOME/.docker,target=/root/.docker \ --mount type=bind,source=$HOME/.gitconfig,target=/root/.gitconfig \ --mount type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock \ --mount type=bind,source=$HOME/.ssh,target=/root/.ssh \ + --mount type=bind,source=/tmp,target=/tmp \ -v $HOME/.pypirc:/root/.pypirc \ compose/release-tool $* diff --git a/script/release/release/bintray.py b/script/release/release/bintray.py index d99d372c686..554611a4012 100644 --- a/script/release/release/bintray.py +++ b/script/release/release/bintray.py @@ -25,7 +25,9 @@ def create_repository(self, subject, repo_name, repo_type='generic'): 'desc': 'Automated release for {}: {}'.format(NAME, repo_name), 'labels': ['docker-compose', 'docker', 'release-bot'], } - return self.post_json(url, data) + result = self.post_json(url, data) + result.raise_for_status() + return result def delete_repository(self, subject, repo_name): url = '{base}/repos/{subject}/{repo_name}'.format( diff --git a/script/release/release/images.py b/script/release/release/images.py index d238d4d7fb6..24672f2ba31 100644 --- a/script/release/release/images.py +++ b/script/release/release/images.py @@ -48,7 +48,7 @@ def build_images(self, repository, files): container = docker_client.create_container( 'docker-compose-tests:tmp', entrypoint='tox' ) - docker_client.commit(container, 'docker/compose-tests:latest') + docker_client.commit(container, 'docker/compose-tests', 'latest') docker_client.tag('docker/compose-tests:latest', 'docker/compose-tests:{}'.format(self.version)) docker_client.remove_container(container, force=True) docker_client.remove_image('docker-compose-tests:tmp', force=True) From 47584a37c9f0a4c69e3e5dbea68e57a8563e9a40 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 21 Jun 2018 11:49:35 -0700 Subject: [PATCH 3451/4072] Fix bintray API client Signed-off-by: Joffrey F --- script/release/release/bintray.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/script/release/release/bintray.py b/script/release/release/bintray.py index 554611a4012..b291a34ac0f 100644 --- a/script/release/release/bintray.py +++ b/script/release/release/bintray.py @@ -15,7 +15,7 @@ def __init__(self, api_key, user, *args, **kwargs): self.base_url = 'https://api.bintray.com/' def create_repository(self, subject, repo_name, repo_type='generic'): - url = '{base}/repos/{subject}/{repo_name}'.format( + url = '{base}repos/{subject}/{repo_name}'.format( base=self.base_url, subject=subject, repo_name=repo_name, ) data = { @@ -30,7 +30,7 @@ def create_repository(self, subject, repo_name, repo_type='generic'): return result def delete_repository(self, subject, repo_name): - url = '{base}/repos/{subject}/{repo_name}'.format( + url = '{base}repos/{subject}/{repo_name}'.format( base=self.base_url, subject=subject, repo_name=repo_name, ) return self.delete(url) From e8af19daa32b5b5ccbad966df425b57cd3926299 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 21 Jun 2018 13:27:42 -0700 Subject: [PATCH 3452/4072] Fix release script Signed-off-by: Joffrey F --- script/release/release.py | 7 +++++-- script/release/release/bintray.py | 10 ++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/script/release/release.py b/script/release/release.py index d0545a7e647..476adc4c3db 100755 --- a/script/release/release.py +++ b/script/release/release.py @@ -58,8 +58,11 @@ def create_bump_commit(repository, release_branch, bintray_user, bintray_org): repository.push_branch_to_remote(release_branch) bintray_api = BintrayAPI(os.environ['BINTRAY_TOKEN'], bintray_user) - print('Creating data repository {} on bintray'.format(release_branch.name)) - bintray_api.create_repository(bintray_org, release_branch.name, 'generic') + if not bintray_api.repository_exists(bintray_org, release_branch.name): + print('Creating data repository {} on bintray'.format(release_branch.name)) + bintray_api.create_repository(bintray_org, release_branch.name, 'generic') + else: + print('Bintray repository {} already exists. Skipping'.format(release_branch.name)) def monitor_pr_status(pr_data): diff --git a/script/release/release/bintray.py b/script/release/release/bintray.py index 554611a4012..d9986875d50 100644 --- a/script/release/release/bintray.py +++ b/script/release/release/bintray.py @@ -29,6 +29,16 @@ def create_repository(self, subject, repo_name, repo_type='generic'): result.raise_for_status() return result + def repository_exists(self, subject, repo_name): + url = '{base}/repos/{subject}/{repo_name}'.format( + base=self.base_url, subject=subject, repo_name=repo_name, + ) + result = self.get(url) + if result.status_code == 404: + return False + result.raise_for_status() + return True + def delete_repository(self, subject, repo_name): url = '{base}/repos/{subject}/{repo_name}'.format( base=self.base_url, subject=subject, repo_name=repo_name, From 1fb50395850295adf405243e7f093107fc55301a Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 21 Jun 2018 18:48:04 +0000 Subject: [PATCH 3453/4072] Bump 1.22.0-rc1 Signed-off-by: Joffrey F --- CHANGELOG.md | 57 +++++++++++++++++++++++++++++++++++++++++++++ compose/__init__.py | 2 +- script/run/run.sh | 2 +- 3 files changed, 59 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 18742324f58..b5a22aad262 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,63 @@ Change log ========== +1.22.0 (2018-06-30) +------------------- + +### Features + +#### Compose format version 3.7 + +- Introduced version 3.7 of the `docker-compose.yml` specification. + This version requires Docker Engine 18.06.0 or above. + +- Added support for `rollback_config` in the deploy configuration + +- Added support for the `init` parameter in service configurations + +- Added support for extension fields in service, network, volume, secret, + and config configurations + +#### Compose format version 2.4 + +- Added support for extension fields in service, network, + and volume configurations + +### Bugfixes + +- Fixed a bug that prevented deployment with some Compose files when + `DOCKER_DEFAULT_PLATFORM` was set + +- Compose will no longer try to create containers or volumes with + invalid starting characters + +- Fixed several bugs that prevented Compose commands from working properly + with containers created with an older version of Compose + +- Fixed an issue with the output of `docker-compose config` with the + `--compatibility-mode` flag enabled when the source file contains + attachable networks + +- Fixed a bug that prevented the `gcloud` credential store from working + properly when used with the Compose binary on UNIX + +- Fixed a bug that caused connection errors when trying to operate + over a non-HTTPS TCP connection on Windows + +- Fixed a bug that caused builds to fail on Windows if the Dockerfile + was located in a subdirectory of the build context + +- Fixed an issue that prevented proper parsing of UTF-8 BOM encoded + Compose files on Windows + +1.21.2 (2018-05-03) +------------------- + +### Bugfixes + +- Fixed a bug where the ip_range attirbute in IPAM configs was prevented + from passing validation + 1.21.1 (2018-04-27) ------------------- diff --git a/compose/__init__.py b/compose/__init__.py index 5eb2efd03bf..eb7195176cf 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.22.0dev' +__version__ = '1.22.0-rc1' diff --git a/script/run/run.sh b/script/run/run.sh index 45e74febd9f..86679bbecba 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.21.1" +VERSION="1.22.0-rc1" IMAGE="docker/compose:$VERSION" From 73663e46b9761e3d90917bfdf49b6f340c4bc4ed Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 21 Jun 2018 13:47:44 -0700 Subject: [PATCH 3454/4072] 3.7 --> API v1.38 Signed-off-by: Joffrey F --- compose/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/const.py b/compose/const.py index 374a0971187..ffb68db01d2 100644 --- a/compose/const.py +++ b/compose/const.py @@ -52,7 +52,7 @@ COMPOSEFILE_V3_4: '1.30', COMPOSEFILE_V3_5: '1.30', COMPOSEFILE_V3_6: '1.36', - COMPOSEFILE_V3_7: '1.36', + COMPOSEFILE_V3_7: '1.38', } API_VERSION_TO_ENGINE_VERSION = { From a82986943b0e873b3b97bac2b99e2a46a8bf968e Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 21 Jun 2018 13:27:42 -0700 Subject: [PATCH 3455/4072] Fix release script Signed-off-by: Joffrey F --- script/release/release.py | 7 +++++-- script/release/release/bintray.py | 10 ++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/script/release/release.py b/script/release/release.py index d0545a7e647..476adc4c3db 100755 --- a/script/release/release.py +++ b/script/release/release.py @@ -58,8 +58,11 @@ def create_bump_commit(repository, release_branch, bintray_user, bintray_org): repository.push_branch_to_remote(release_branch) bintray_api = BintrayAPI(os.environ['BINTRAY_TOKEN'], bintray_user) - print('Creating data repository {} on bintray'.format(release_branch.name)) - bintray_api.create_repository(bintray_org, release_branch.name, 'generic') + if not bintray_api.repository_exists(bintray_org, release_branch.name): + print('Creating data repository {} on bintray'.format(release_branch.name)) + bintray_api.create_repository(bintray_org, release_branch.name, 'generic') + else: + print('Bintray repository {} already exists. Skipping'.format(release_branch.name)) def monitor_pr_status(pr_data): diff --git a/script/release/release/bintray.py b/script/release/release/bintray.py index b291a34ac0f..16bb4d51456 100644 --- a/script/release/release/bintray.py +++ b/script/release/release/bintray.py @@ -29,6 +29,16 @@ def create_repository(self, subject, repo_name, repo_type='generic'): result.raise_for_status() return result + def repository_exists(self, subject, repo_name): + url = '{base}/repos/{subject}/{repo_name}'.format( + base=self.base_url, subject=subject, repo_name=repo_name, + ) + result = self.get(url) + if result.status_code == 404: + return False + result.raise_for_status() + return True + def delete_repository(self, subject, repo_name): url = '{base}repos/{subject}/{repo_name}'.format( base=self.base_url, subject=subject, repo_name=repo_name, From e7de1bc3c9c9f7d65b1bf7f58136154618c90db1 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 21 Jun 2018 13:47:44 -0700 Subject: [PATCH 3456/4072] 3.7 --> API v1.38 Signed-off-by: Joffrey F --- compose/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/const.py b/compose/const.py index 374a0971187..ffb68db01d2 100644 --- a/compose/const.py +++ b/compose/const.py @@ -52,7 +52,7 @@ COMPOSEFILE_V3_4: '1.30', COMPOSEFILE_V3_5: '1.30', COMPOSEFILE_V3_6: '1.36', - COMPOSEFILE_V3_7: '1.36', + COMPOSEFILE_V3_7: '1.38', } API_VERSION_TO_ENGINE_VERSION = { From b00db08aa9e54f57c56b8090142dec6aed314e27 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 22 Jun 2018 15:56:53 -0700 Subject: [PATCH 3457/4072] Prevent attempts to create image names starting with - or _ Signed-off-by: Joffrey F --- compose/service.py | 4 +++- tests/integration/service_test.py | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/compose/service.py b/compose/service.py index 48cbc1702f2..e77780fd8c0 100644 --- a/compose/service.py +++ b/compose/service.py @@ -363,7 +363,9 @@ def image(self): @property def image_name(self): - return self.options.get('image', '{s.project}_{s.name}'.format(s=self)) + return self.options.get('image', '{project}_{s.name}'.format( + s=self, project=self.project.lstrip('_-') + )) @property def platform(self): diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index d8f4d094adf..88123152cab 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -1137,6 +1137,21 @@ def test_build_with_isolation(self): service.build() assert service.image() + def test_build_with_illegal_leading_chars(self): + base_dir = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, base_dir) + with open(os.path.join(base_dir, 'Dockerfile'), 'w') as f: + f.write('FROM busybox\nRUN echo "Embodiment of Scarlet Devil"\n') + service = Service( + 'build_leading_slug', client=self.client, + project='___-composetest', build={ + 'context': text_type(base_dir) + } + ) + assert service.image_name == 'composetest_build_leading_slug' + service.build() + assert service.image() + def test_start_container_stays_unprivileged(self): service = self.create_service('web') container = create_and_start_container(service).inspect() From e8713d7cef948736351840b1ff1d494278bb4a74 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 29 Jun 2018 13:05:20 -0700 Subject: [PATCH 3458/4072] Docker SDK -> 3.4.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 05b38526ade..a3d6e02d689 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ backports.ssl-match-hostname==3.5.0.1; python_version < '3' cached-property==1.3.0 certifi==2017.4.17 chardet==3.0.4 -docker==3.4.0 +docker==3.4.1 docker-pycreds==0.3.0 dockerpty==0.4.1 docopt==0.6.2 diff --git a/setup.py b/setup.py index fc024078e90..e0a26b0ec51 100644 --- a/setup.py +++ b/setup.py @@ -36,7 +36,7 @@ def find_version(*file_paths): 'requests >= 2.6.1, != 2.11.0, != 2.12.2, != 2.18.0, < 2.19', 'texttable >= 0.9.0, < 0.10', 'websocket-client >= 0.32.0, < 1.0', - 'docker >= 3.4.0, < 4.0', + 'docker >= 3.4.1, < 4.0', 'dockerpty >= 0.4.1, < 0.5', 'six >= 1.3.0, < 2', 'jsonschema >= 2.5.1, < 3', From 969525c1903fc12c37b89fad80c2860a9a5e4d61 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 29 Jun 2018 13:05:20 -0700 Subject: [PATCH 3459/4072] Docker SDK -> 3.4.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 05b38526ade..a3d6e02d689 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ backports.ssl-match-hostname==3.5.0.1; python_version < '3' cached-property==1.3.0 certifi==2017.4.17 chardet==3.0.4 -docker==3.4.0 +docker==3.4.1 docker-pycreds==0.3.0 dockerpty==0.4.1 docopt==0.6.2 diff --git a/setup.py b/setup.py index fc024078e90..e0a26b0ec51 100644 --- a/setup.py +++ b/setup.py @@ -36,7 +36,7 @@ def find_version(*file_paths): 'requests >= 2.6.1, != 2.11.0, != 2.12.2, != 2.18.0, < 2.19', 'texttable >= 0.9.0, < 0.10', 'websocket-client >= 0.32.0, < 1.0', - 'docker >= 3.4.0, < 4.0', + 'docker >= 3.4.1, < 4.0', 'dockerpty >= 0.4.1, < 0.5', 'six >= 1.3.0, < 2', 'jsonschema >= 2.5.1, < 3', From 15718810c07bc8251682809d7ab1501fc50d2c08 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 22 Jun 2018 15:56:53 -0700 Subject: [PATCH 3460/4072] Prevent attempts to create image names starting with - or _ Signed-off-by: Joffrey F --- compose/service.py | 4 +++- tests/integration/service_test.py | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/compose/service.py b/compose/service.py index 48cbc1702f2..e77780fd8c0 100644 --- a/compose/service.py +++ b/compose/service.py @@ -363,7 +363,9 @@ def image(self): @property def image_name(self): - return self.options.get('image', '{s.project}_{s.name}'.format(s=self)) + return self.options.get('image', '{project}_{s.name}'.format( + s=self, project=self.project.lstrip('_-') + )) @property def platform(self): diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index d8f4d094adf..88123152cab 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -1137,6 +1137,21 @@ def test_build_with_isolation(self): service.build() assert service.image() + def test_build_with_illegal_leading_chars(self): + base_dir = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, base_dir) + with open(os.path.join(base_dir, 'Dockerfile'), 'w') as f: + f.write('FROM busybox\nRUN echo "Embodiment of Scarlet Devil"\n') + service = Service( + 'build_leading_slug', client=self.client, + project='___-composetest', build={ + 'context': text_type(base_dir) + } + ) + assert service.image_name == 'composetest_build_leading_slug' + service.build() + assert service.image() + def test_start_container_stays_unprivileged(self): service = self.create_service('web') container = create_and_start_container(service).inspect() From 6817b533a89da4925ac91e6e2c2a96306dc81042 Mon Sep 17 00:00:00 2001 From: Matthieu Nottale Date: Thu, 5 Jul 2018 15:10:31 +0000 Subject: [PATCH 3461/4072] "Bump 1.22.0-rc2" Signed-off-by: Matthieu Nottale --- CHANGELOG.md | 5 +++++ compose/__init__.py | 2 +- script/release/release.py | 7 ++----- script/release/release.sh | 1 + script/release/release/bintray.py | 14 +------------- script/run/run.sh | 2 +- 6 files changed, 11 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b5a22aad262..45cba051657 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,11 @@ Change log - Fixed an issue that prevented proper parsing of UTF-8 BOM encoded Compose files on Windows +- Fixed an issue with handling of the double-wildcard (`**`) pattern in `.dockerignore` files when using `docker-compose build` + +- Fixed a bug that caused auth values in legacy `.dockercfg` files to be ignored +- `docker-compose build` will no longer attempt to create image names starting with an invalid character + 1.21.2 (2018-05-03) ------------------- diff --git a/compose/__init__.py b/compose/__init__.py index eb7195176cf..a76ca8177ae 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.22.0-rc1' +__version__ = '1.22.0-rc2' diff --git a/script/release/release.py b/script/release/release.py index 476adc4c3db..d0545a7e647 100755 --- a/script/release/release.py +++ b/script/release/release.py @@ -58,11 +58,8 @@ def create_bump_commit(repository, release_branch, bintray_user, bintray_org): repository.push_branch_to_remote(release_branch) bintray_api = BintrayAPI(os.environ['BINTRAY_TOKEN'], bintray_user) - if not bintray_api.repository_exists(bintray_org, release_branch.name): - print('Creating data repository {} on bintray'.format(release_branch.name)) - bintray_api.create_repository(bintray_org, release_branch.name, 'generic') - else: - print('Bintray repository {} already exists. Skipping'.format(release_branch.name)) + print('Creating data repository {} on bintray'.format(release_branch.name)) + bintray_api.create_repository(bintray_org, release_branch.name, 'generic') def monitor_pr_status(pr_data): diff --git a/script/release/release.sh b/script/release/release.sh index 2011826571a..eddc315b7f0 100755 --- a/script/release/release.sh +++ b/script/release/release.sh @@ -17,6 +17,7 @@ fi docker run -e GITHUB_TOKEN=$GITHUB_TOKEN -e BINTRAY_TOKEN=$BINTRAY_TOKEN -e SSH_AUTH_SOCK=$SSH_AUTH_SOCK -it \ --mount type=bind,source=$(pwd),target=/src \ + --mount type=bind,source=$(pwd)/.git,target=/src/.git \ --mount type=bind,source=$HOME/.docker,target=/root/.docker \ --mount type=bind,source=$HOME/.gitconfig,target=/root/.gitconfig \ --mount type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock \ diff --git a/script/release/release/bintray.py b/script/release/release/bintray.py index d9986875d50..d99d372c686 100644 --- a/script/release/release/bintray.py +++ b/script/release/release/bintray.py @@ -25,19 +25,7 @@ def create_repository(self, subject, repo_name, repo_type='generic'): 'desc': 'Automated release for {}: {}'.format(NAME, repo_name), 'labels': ['docker-compose', 'docker', 'release-bot'], } - result = self.post_json(url, data) - result.raise_for_status() - return result - - def repository_exists(self, subject, repo_name): - url = '{base}/repos/{subject}/{repo_name}'.format( - base=self.base_url, subject=subject, repo_name=repo_name, - ) - result = self.get(url) - if result.status_code == 404: - return False - result.raise_for_status() - return True + return self.post_json(url, data) def delete_repository(self, subject, repo_name): url = '{base}/repos/{subject}/{repo_name}'.format( diff --git a/script/run/run.sh b/script/run/run.sh index 86679bbecba..7f4acb7652e 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.22.0-rc1" +VERSION="1.22.0-rc2" IMAGE="docker/compose:$VERSION" From 28085ebee2bc1fc995cbe8614a1316357dcb8ce1 Mon Sep 17 00:00:00 2001 From: Nicholas Higgins Date: Mon, 9 Jul 2018 08:38:43 +1000 Subject: [PATCH 3462/4072] Attach logger to containers after crashing. Fixes #6060 Signed-off-by: Nicholas Higgins --- compose/cli/log_printer.py | 10 ++++++++++ tests/acceptance/cli_test.py | 16 ++++++++++++++++ .../logs-restart-composefile/docker-compose.yml | 7 +++++++ 3 files changed, 33 insertions(+) create mode 100644 tests/fixtures/logs-restart-composefile/docker-compose.yml diff --git a/compose/cli/log_printer.py b/compose/cli/log_printer.py index 60bba8da6f1..bd6723ef24e 100644 --- a/compose/cli/log_printer.py +++ b/compose/cli/log_printer.py @@ -210,10 +210,15 @@ def start_producer_thread(thread_args): def watch_events(thread_map, event_stream, presenters, thread_args): + crashed_containers = set() for event in event_stream: if event['action'] == 'stop': thread_map.pop(event['id'], None) + if event['action'] == 'die': + thread_map.pop(event['id'], None) + crashed_containers.add(event['id']) + if event['action'] != 'start': continue @@ -223,6 +228,11 @@ def watch_events(thread_map, event_stream, presenters, thread_args): # Container was stopped and started, we need a new thread thread_map.pop(event['id'], None) + # Container crashed so we should reattach to it + if event['id'] in crashed_containers: + event['container'].attach_log_stream() + crashed_containers.remove(event['id']) + thread_map[event['id']] = build_thread( event['container'], next(presenters), diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 43e8fa8223c..6da01ab1b97 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -2253,6 +2253,22 @@ def test_logs_follow_logs_from_new_containers(self): assert 'logs-composefile_another_1 exited with code 0' in result.stdout assert 'logs-composefile_simple_1 exited with code 137' in result.stdout + def test_logs_follow_logs_from_restarted_containers(self): + self.base_dir = 'tests/fixtures/logs-restart-composefile' + proc = start_process(self.base_dir, ['up']) + + wait_on_condition(ContainerStateCondition( + self.project.client, + 'logs-restart-composefile_another_1', + 'exited')) + + self.dispatch(['kill', 'simple']) + + result = wait_on_process(proc) + + assert result.stdout.count('logs-restart-composefile_another_1 exited with code 1') == 3 + assert result.stdout.count('world') == 3 + def test_logs_default(self): self.base_dir = 'tests/fixtures/logs-composefile' self.dispatch(['up', '-d']) diff --git a/tests/fixtures/logs-restart-composefile/docker-compose.yml b/tests/fixtures/logs-restart-composefile/docker-compose.yml new file mode 100644 index 00000000000..c662a1e719d --- /dev/null +++ b/tests/fixtures/logs-restart-composefile/docker-compose.yml @@ -0,0 +1,7 @@ +simple: + image: busybox:latest + command: sh -c "echo hello && tail -f /dev/null" +another: + image: busybox:latest + command: sh -c "sleep 0.5 && echo world && /bin/false" + restart: "on-failure:2" From 9c2ffe6384e56f26dcdb7014def873a8235a979e Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 9 Jul 2018 15:28:32 -0400 Subject: [PATCH 3463/4072] Avoid overriding external = False in serializer Signed-off-by: Joffrey F --- compose/config/serialize.py | 2 +- tests/unit/config/config_test.py | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/compose/config/serialize.py b/compose/config/serialize.py index ccddbf5322a..8cb8a280807 100644 --- a/compose/config/serialize.py +++ b/compose/config/serialize.py @@ -78,7 +78,7 @@ def denormalize_config(config, image_digests=None): config.version >= V3_0 and config.version < v3_introduced_name_key(key)): del conf['name'] elif 'external' in conf: - conf['external'] = True + conf['external'] = bool(conf['external']) if 'attachable' in conf and config.version < V3_2: # For compatibility mode, this option is invalid in v2 diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 085a2d01092..08b92a57312 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -5096,3 +5096,19 @@ def test_serialize_unicode_values(self): serialized_config = yaml.load(serialize_config(config_dict)) serialized_service = serialized_config['services']['web'] assert serialized_service['command'] == 'echo 十六夜 咲夜' + + def test_serialize_external_false(self): + cfg = { + 'version': '3.4', + 'volumes': { + 'test': { + 'name': 'test-false', + 'external': False + } + } + } + + config_dict = config.load(build_config_details(cfg)) + serialized_config = yaml.load(serialize_config(config_dict)) + serialized_volume = serialized_config['volumes']['test'] + assert serialized_volume['external'] is False From e9aaece40d31b8cacc1f80e5506a6f65b2bbbb0b Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 9 Jul 2018 15:46:56 -0400 Subject: [PATCH 3464/4072] s/release.py/release.sh/ Signed-off-by: Joffrey F --- script/release/release.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/script/release/release.py b/script/release/release.py index 476adc4c3db..9be06b21141 100755 --- a/script/release/release.py +++ b/script/release/release.py @@ -128,7 +128,7 @@ def print_final_instructions(args): "You're almost done! Please verify that everything is in order and " "you are ready to make the release public, then run the following " "command:\n{exe} -b {user} finalize {version}".format( - exe=sys.argv[0], user=args.bintray_user, version=args.release + exe='./script/release/release.sh', user=args.bintray_user, version=args.release ) ) @@ -263,13 +263,13 @@ def finalize(args): EPILOG = '''Example uses: * Start a new feature release (includes all changes currently in master) - release.py -b user start 1.23.0 + release.sh -b user start 1.23.0 * Start a new patch release - release.py -b user --patch 1.21.0 start 1.21.1 + release.sh -b user --patch 1.21.0 start 1.21.1 * Cancel / rollback an existing release draft - release.py -b user cancel 1.23.0 + release.sh -b user cancel 1.23.0 * Restart a previously aborted patch release - release.py -b user -p 1.21.0 resume 1.21.1 + release.sh -b user -p 1.21.0 resume 1.21.1 ''' From 8a7ee5a7d5844c60068f3436f2f327e61690551c Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 9 Jul 2018 16:19:17 -0400 Subject: [PATCH 3465/4072] Add distclean to remove old build files Signed-off-by: Joffrey F --- script/release/release.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/script/release/release.py b/script/release/release.py index 9be06b21141..b47c38875df 100755 --- a/script/release/release.py +++ b/script/release/release.py @@ -4,6 +4,7 @@ import argparse import os +import shutil import sys import time from distutils.core import run_setup @@ -133,8 +134,37 @@ def print_final_instructions(args): ) +def distclean(): + print('Running distclean...') + dirs = [ + os.path.join(REPO_ROOT, 'build'), os.path.join(REPO_ROOT, 'dist'), + os.path.join(REPO_ROOT, 'docker-compose.egg-info') + ] + files = [] + for base, dirnames, fnames in os.walk(REPO_ROOT): + for fname in fnames: + path = os.path.normpath(os.path.join(base, fname)) + if fname.endswith('.pyc'): + files.append(path) + elif fname.startswith('.coverage.'): + files.append(path) + for dirname in dirnames: + path = os.path.normpath(os.path.join(base, dirname)) + if dirname == '__pycache__': + dirs.append(path) + elif dirname == '.coverage-binfiles': + dirs.append(path) + + for file in files: + os.unlink(file) + + for folder in dirs: + shutil.rmtree(folder, ignore_errors=True) + + def resume(args): try: + distclean() repository = Repository(REPO_ROOT, args.repo) br_name = branch_name(args.release) if not repository.branch_exists(br_name): @@ -186,6 +216,7 @@ def cancel(args): bintray_api = BintrayAPI(os.environ['BINTRAY_TOKEN'], args.bintray_user) print('Removing Bintray data repository for {}'.format(args.release)) bintray_api.delete_repository(args.bintray_org, branch_name(args.release)) + distclean() except ScriptError as e: print(e) return 1 @@ -194,6 +225,7 @@ def cancel(args): def start(args): + distclean() try: repository = Repository(REPO_ROOT, args.repo) create_initial_branch(repository, args) @@ -216,6 +248,7 @@ def start(args): def finalize(args): + distclean() try: repository = Repository(REPO_ROOT, args.repo) img_manager = ImageManager(args.release) From 0b5f68098c9a9c5e24f1fbc02d08207f33f4c02f Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 9 Jul 2018 16:25:06 -0400 Subject: [PATCH 3466/4072] Avoid unrelated file uploads with twine Signed-off-by: Joffrey F --- script/release/release.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/script/release/release.py b/script/release/release.py index b47c38875df..736b8849133 100755 --- a/script/release/release.py +++ b/script/release/release.py @@ -277,7 +277,10 @@ def finalize(args): if not merge_status.merged: raise ScriptError('Unable to merge PR #{}: {}'.format(pr_data.number, merge_status.message)) print('Uploading to PyPi') - twine_upload(['dist/*']) + twine_upload([ + 'dist/docker_compose-{}*.whl'.format(args.release), + 'dist/docker-compose-{}*.tar.gz'.format(args.release) + ]) img_manager.push_images() repository.publish_release(gh_release) except ScriptError as e: From d7f5220292a9cf79cd7b214b9fe4d88a5ad8236f Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 9 Jul 2018 16:51:01 -0400 Subject: [PATCH 3467/4072] Improve finalize robustness and allow resume using special --finalize-resume flag Signed-off-by: Joffrey F --- script/release/release.py | 37 ++++++++++++++++++++++++++------ script/release/release/images.py | 4 ++++ 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/script/release/release.py b/script/release/release.py index 736b8849133..c6dc146a76e 100755 --- a/script/release/release.py +++ b/script/release/release.py @@ -28,6 +28,7 @@ from release.utils import update_init_py_version from release.utils import update_run_sh_version from release.utils import yesno +from requests.exceptions import HTTPError from twine.commands.upload import main as twine_upload @@ -162,6 +163,24 @@ def distclean(): shutil.rmtree(folder, ignore_errors=True) +def pypi_upload(args): + print('Uploading to PyPi') + try: + twine_upload([ + 'dist/docker_compose-{}*.whl'.format(args.release), + 'dist/docker-compose-{}*.tar.gz'.format(args.release) + ]) + except HTTPError as e: + if e.response.status_code == 400 and 'File already exists' in e.message: + if not args.finalize_resume: + raise ScriptError( + 'Package already uploaded on PyPi.' + ) + print('Skipping PyPi upload - package already uploaded') + else: + raise ScriptError('Unexpected HTTP error uploading package to PyPi: {}'.format(e)) + + def resume(args): try: distclean() @@ -274,13 +293,13 @@ def finalize(args): run_setup(os.path.join(REPO_ROOT, 'setup.py'), script_args=['sdist', 'bdist_wheel']) merge_status = pr_data.merge() - if not merge_status.merged: - raise ScriptError('Unable to merge PR #{}: {}'.format(pr_data.number, merge_status.message)) - print('Uploading to PyPi') - twine_upload([ - 'dist/docker_compose-{}*.whl'.format(args.release), - 'dist/docker-compose-{}*.tar.gz'.format(args.release) - ]) + if not merge_status.merged and not args.finalize_resume: + raise ScriptError( + 'Unable to merge PR #{}: {}'.format(pr_data.number, merge_status.message) + ) + + pypi_upload(args) + img_manager.push_images() repository.publish_release(gh_release) except ScriptError as e: @@ -355,6 +374,10 @@ def main(): '--skip-ci-checks', dest='skip_ci', action='store_true', help='If set, the program will not wait for CI jobs to complete' ) + parser.add_argument( + '--finalize-resume', dest='finalize_resume', action='store_true', + help='If set, finalize will continue through steps that have already been completed.' + ) args = parser.parse_args() if args.action == 'start': diff --git a/script/release/release/images.py b/script/release/release/images.py index 24672f2ba31..b8f7ed3d6f1 100644 --- a/script/release/release/images.py +++ b/script/release/release/images.py @@ -81,3 +81,7 @@ def push_images(self): for chunk in logstream: if 'status' in chunk: print(chunk['status']) + if 'error' in chunk: + raise ScriptError( + 'Error pushing {name}: {err}'.format(name=name, err=chunk['error']) + ) From e6d18b188143bb8581cf935888cafd7cca59a463 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 10 Jul 2018 15:28:55 -0400 Subject: [PATCH 3468/4072] Fix --exit-code-from to reflect exit code after termination by Compose Signed-off-by: Joffrey F --- compose/cli/main.py | 53 +++++++++++++++++++----------------- tests/acceptance/cli_test.py | 9 ++++++ 2 files changed, 37 insertions(+), 25 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index a972058366f..fa64014128b 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -1085,6 +1085,9 @@ def up(rebuild): ) self.project.stop(service_names=service_names, timeout=timeout) + if exit_value_from: + exit_code = compute_service_exit_code(exit_value_from, attached_containers) + sys.exit(exit_code) @classmethod @@ -1103,33 +1106,33 @@ def version(cls, options): print(get_version_info('full')) +def compute_service_exit_code(exit_value_from, attached_containers): + candidates = list(filter( + lambda c: c.service == exit_value_from, + attached_containers)) + if not candidates: + log.error( + 'No containers matching the spec "{0}" ' + 'were run.'.format(exit_value_from) + ) + return 2 + if len(candidates) > 1: + exit_values = filter( + lambda e: e != 0, + [c.inspect()['State']['ExitCode'] for c in candidates] + ) + + return exit_values[0] + return candidates[0].inspect()['State']['ExitCode'] + + def compute_exit_code(exit_value_from, attached_containers, cascade_starter, all_containers): exit_code = 0 - if exit_value_from: - candidates = list(filter( - lambda c: c.service == exit_value_from, - attached_containers)) - if not candidates: - log.error( - 'No containers matching the spec "{0}" ' - 'were run.'.format(exit_value_from) - ) - exit_code = 2 - elif len(candidates) > 1: - exit_values = filter( - lambda e: e != 0, - [c.inspect()['State']['ExitCode'] for c in candidates] - ) - - exit_code = exit_values[0] - else: - exit_code = candidates[0].inspect()['State']['ExitCode'] - else: - for e in all_containers: - if (not e.is_running and cascade_starter == e.name): - if not e.exit_code == 0: - exit_code = e.exit_code - break + for e in all_containers: + if (not e.is_running and cascade_starter == e.name): + if not e.exit_code == 0: + exit_code = e.exit_code + break return exit_code diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 43e8fa8223c..471b1831a59 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -2620,6 +2620,15 @@ def test_forward_exitval(self): assert 'exit-code-from_another_1 exited with code 1' in result.stdout + def test_exit_code_from_signal_stop(self): + self.base_dir = 'tests/fixtures/exit-code-from' + proc = start_process( + self.base_dir, + ['up', '--abort-on-container-exit', '--exit-code-from', 'simple'] + ) + result = wait_on_process(proc, returncode=137) # SIGKILL + assert 'exit-code-from_another_1 exited with code 1' in result.stdout + def test_images(self): self.project.get_service('simple').create_container() result = self.dispatch(['images']) From cb1b88c4f857c3a0a4a4f1f07881f35c8dd41d9a Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 9 Jul 2018 15:46:56 -0400 Subject: [PATCH 3469/4072] s/release.py/release.sh/ Signed-off-by: Joffrey F --- script/release/release.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/script/release/release.py b/script/release/release.py index d0545a7e647..ab56c8c38d3 100755 --- a/script/release/release.py +++ b/script/release/release.py @@ -125,7 +125,7 @@ def print_final_instructions(args): "You're almost done! Please verify that everything is in order and " "you are ready to make the release public, then run the following " "command:\n{exe} -b {user} finalize {version}".format( - exe=sys.argv[0], user=args.bintray_user, version=args.release + exe='./script/release/release.sh', user=args.bintray_user, version=args.release ) ) @@ -260,13 +260,13 @@ def finalize(args): EPILOG = '''Example uses: * Start a new feature release (includes all changes currently in master) - release.py -b user start 1.23.0 + release.sh -b user start 1.23.0 * Start a new patch release - release.py -b user --patch 1.21.0 start 1.21.1 + release.sh -b user --patch 1.21.0 start 1.21.1 * Cancel / rollback an existing release draft - release.py -b user cancel 1.23.0 + release.sh -b user cancel 1.23.0 * Restart a previously aborted patch release - release.py -b user -p 1.21.0 resume 1.21.1 + release.sh -b user -p 1.21.0 resume 1.21.1 ''' From d9545a5909a8abba81f2cd4d11352075bde4326f Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 9 Jul 2018 16:19:17 -0400 Subject: [PATCH 3470/4072] Add distclean to remove old build files Signed-off-by: Joffrey F --- script/release/release.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/script/release/release.py b/script/release/release.py index ab56c8c38d3..54e792c4ca0 100755 --- a/script/release/release.py +++ b/script/release/release.py @@ -4,6 +4,7 @@ import argparse import os +import shutil import sys import time from distutils.core import run_setup @@ -130,8 +131,37 @@ def print_final_instructions(args): ) +def distclean(): + print('Running distclean...') + dirs = [ + os.path.join(REPO_ROOT, 'build'), os.path.join(REPO_ROOT, 'dist'), + os.path.join(REPO_ROOT, 'docker-compose.egg-info') + ] + files = [] + for base, dirnames, fnames in os.walk(REPO_ROOT): + for fname in fnames: + path = os.path.normpath(os.path.join(base, fname)) + if fname.endswith('.pyc'): + files.append(path) + elif fname.startswith('.coverage.'): + files.append(path) + for dirname in dirnames: + path = os.path.normpath(os.path.join(base, dirname)) + if dirname == '__pycache__': + dirs.append(path) + elif dirname == '.coverage-binfiles': + dirs.append(path) + + for file in files: + os.unlink(file) + + for folder in dirs: + shutil.rmtree(folder, ignore_errors=True) + + def resume(args): try: + distclean() repository = Repository(REPO_ROOT, args.repo) br_name = branch_name(args.release) if not repository.branch_exists(br_name): @@ -183,6 +213,7 @@ def cancel(args): bintray_api = BintrayAPI(os.environ['BINTRAY_TOKEN'], args.bintray_user) print('Removing Bintray data repository for {}'.format(args.release)) bintray_api.delete_repository(args.bintray_org, branch_name(args.release)) + distclean() except ScriptError as e: print(e) return 1 @@ -191,6 +222,7 @@ def cancel(args): def start(args): + distclean() try: repository = Repository(REPO_ROOT, args.repo) create_initial_branch(repository, args) @@ -213,6 +245,7 @@ def start(args): def finalize(args): + distclean() try: repository = Repository(REPO_ROOT, args.repo) img_manager = ImageManager(args.release) From 8c0411910d7f26d3c781a67fe14f51d5feb22a1c Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 9 Jul 2018 16:25:06 -0400 Subject: [PATCH 3471/4072] Avoid unrelated file uploads with twine Signed-off-by: Joffrey F --- script/release/release.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/script/release/release.py b/script/release/release.py index 54e792c4ca0..8ff5fb0c679 100755 --- a/script/release/release.py +++ b/script/release/release.py @@ -274,7 +274,10 @@ def finalize(args): if not merge_status.merged: raise ScriptError('Unable to merge PR #{}: {}'.format(pr_data.number, merge_status.message)) print('Uploading to PyPi') - twine_upload(['dist/*']) + twine_upload([ + 'dist/docker_compose-{}*.whl'.format(args.release), + 'dist/docker-compose-{}*.tar.gz'.format(args.release) + ]) img_manager.push_images() repository.publish_release(gh_release) except ScriptError as e: From cda827cbfc6628ff0bf0b3ffa2f657d439436379 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 9 Jul 2018 16:51:01 -0400 Subject: [PATCH 3472/4072] Improve finalize robustness and allow resume using special --finalize-resume flag Signed-off-by: Joffrey F --- script/release/release.py | 37 ++++++++++++++++++++++++++------ script/release/release/images.py | 4 ++++ 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/script/release/release.py b/script/release/release.py index 8ff5fb0c679..23b93a528c7 100755 --- a/script/release/release.py +++ b/script/release/release.py @@ -28,6 +28,7 @@ from release.utils import update_init_py_version from release.utils import update_run_sh_version from release.utils import yesno +from requests.exceptions import HTTPError from twine.commands.upload import main as twine_upload @@ -159,6 +160,24 @@ def distclean(): shutil.rmtree(folder, ignore_errors=True) +def pypi_upload(args): + print('Uploading to PyPi') + try: + twine_upload([ + 'dist/docker_compose-{}*.whl'.format(args.release), + 'dist/docker-compose-{}*.tar.gz'.format(args.release) + ]) + except HTTPError as e: + if e.response.status_code == 400 and 'File already exists' in e.message: + if not args.finalize_resume: + raise ScriptError( + 'Package already uploaded on PyPi.' + ) + print('Skipping PyPi upload - package already uploaded') + else: + raise ScriptError('Unexpected HTTP error uploading package to PyPi: {}'.format(e)) + + def resume(args): try: distclean() @@ -271,13 +290,13 @@ def finalize(args): run_setup(os.path.join(REPO_ROOT, 'setup.py'), script_args=['sdist', 'bdist_wheel']) merge_status = pr_data.merge() - if not merge_status.merged: - raise ScriptError('Unable to merge PR #{}: {}'.format(pr_data.number, merge_status.message)) - print('Uploading to PyPi') - twine_upload([ - 'dist/docker_compose-{}*.whl'.format(args.release), - 'dist/docker-compose-{}*.tar.gz'.format(args.release) - ]) + if not merge_status.merged and not args.finalize_resume: + raise ScriptError( + 'Unable to merge PR #{}: {}'.format(pr_data.number, merge_status.message) + ) + + pypi_upload(args) + img_manager.push_images() repository.publish_release(gh_release) except ScriptError as e: @@ -352,6 +371,10 @@ def main(): '--skip-ci-checks', dest='skip_ci', action='store_true', help='If set, the program will not wait for CI jobs to complete' ) + parser.add_argument( + '--finalize-resume', dest='finalize_resume', action='store_true', + help='If set, finalize will continue through steps that have already been completed.' + ) args = parser.parse_args() if args.action == 'start': diff --git a/script/release/release/images.py b/script/release/release/images.py index 24672f2ba31..b8f7ed3d6f1 100644 --- a/script/release/release/images.py +++ b/script/release/release/images.py @@ -81,3 +81,7 @@ def push_images(self): for chunk in logstream: if 'status' in chunk: print(chunk['status']) + if 'error' in chunk: + raise ScriptError( + 'Error pushing {name}: {err}'.format(name=name, err=chunk['error']) + ) From f46880fe9a459c69cb20ed80825d7f466e2fcf71 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 17 Jul 2018 22:48:24 +0000 Subject: [PATCH 3473/4072] "Bump 1.22.0" Signed-off-by: Joffrey F --- CHANGELOG.md | 2 +- compose/__init__.py | 2 +- script/run/run.sh | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 45cba051657..b791c1e08ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ Change log ========== -1.22.0 (2018-06-30) +1.22.0 (2018-07-17) ------------------- ### Features diff --git a/compose/__init__.py b/compose/__init__.py index a76ca8177ae..10ae3675faa 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.22.0-rc2' +__version__ = '1.22.0' diff --git a/script/run/run.sh b/script/run/run.sh index 7f4acb7652e..52ff9513f5f 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.22.0-rc2" +VERSION="1.22.0" IMAGE="docker/compose:$VERSION" From bb00352c34249f16f6c49c562461bb43dabcddfc Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 18 Jul 2018 11:09:25 -0700 Subject: [PATCH 3474/4072] Fix up_with_networks test Signed-off-by: Joffrey F --- tests/fixtures/networks/docker-compose.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/fixtures/networks/docker-compose.yml b/tests/fixtures/networks/docker-compose.yml index c11fa6821df..275376aef9b 100644 --- a/tests/fixtures/networks/docker-compose.yml +++ b/tests/fixtures/networks/docker-compose.yml @@ -2,17 +2,17 @@ version: "2" services: web: - image: busybox + image: alpine:3.7 command: top networks: ["front"] app: - image: busybox + image: alpine:3.7 command: top networks: ["front", "back"] links: - "db:database" db: - image: busybox + image: alpine:3.7 command: top networks: ["back"] From 6cb17b90ef60bce2b984c569e02fa89d277279b5 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 18 Jul 2018 11:11:34 -0700 Subject: [PATCH 3475/4072] 1.23.0dev Signed-off-by: Joffrey F --- compose/__init__.py | 2 +- script/release/release.sh | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/compose/__init__.py b/compose/__init__.py index 10ae3675faa..3433b63cc0d 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.22.0' +__version__ = '1.23.0dev' diff --git a/script/release/release.sh b/script/release/release.sh index eddc315b7f0..2011826571a 100755 --- a/script/release/release.sh +++ b/script/release/release.sh @@ -17,7 +17,6 @@ fi docker run -e GITHUB_TOKEN=$GITHUB_TOKEN -e BINTRAY_TOKEN=$BINTRAY_TOKEN -e SSH_AUTH_SOCK=$SSH_AUTH_SOCK -it \ --mount type=bind,source=$(pwd),target=/src \ - --mount type=bind,source=$(pwd)/.git,target=/src/.git \ --mount type=bind,source=$HOME/.docker,target=/root/.docker \ --mount type=bind,source=$HOME/.gitconfig,target=/root/.gitconfig \ --mount type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock \ From 88d88d1998ea94c465cd370c4e8e06b64d34e707 Mon Sep 17 00:00:00 2001 From: Ofek Lev Date: Wed, 18 Jul 2018 22:22:45 -0400 Subject: [PATCH 3476/4072] support newer minor version of requests Signed-off-by: Ofek Lev --- requirements.txt | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index a3d6e02d689..96a98417e4b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,7 +16,7 @@ pypiwin32==219; sys_platform == 'win32' and python_version < '3.6' pypiwin32==220; sys_platform == 'win32' and python_version >= '3.6' PySocks==1.6.7 PyYAML==3.12 -requests==2.18.4 +requests==2.19.1 six==1.10.0 texttable==0.9.1 urllib3==1.21.1 diff --git a/setup.py b/setup.py index e0a26b0ec51..213f666259b 100644 --- a/setup.py +++ b/setup.py @@ -33,7 +33,7 @@ def find_version(*file_paths): 'cached-property >= 1.2.0, < 2', 'docopt >= 0.6.1, < 0.7', 'PyYAML >= 3.10, < 4', - 'requests >= 2.6.1, != 2.11.0, != 2.12.2, != 2.18.0, < 2.19', + 'requests >= 2.6.1, != 2.11.0, != 2.12.2, != 2.18.0, < 2.20', 'texttable >= 0.9.0, < 0.10', 'websocket-client >= 0.32.0, < 1.0', 'docker >= 3.4.1, < 4.0', From 450efd557af180f2d5e8bd275a4c30afccc70ab2 Mon Sep 17 00:00:00 2001 From: Christopher Crone Date: Tue, 10 Jul 2018 13:52:57 +0200 Subject: [PATCH 3477/4072] macOS: Rework build scripts Allows us to build for older versions of macOS by downloading an older SDK and building OpenSSL and Python against it. Signed-off-by: Christopher Crone --- .circleci/config.yml | 43 +++---------- script/build/osx | 4 +- script/setup/osx | 117 +++++++++++++++++++++++++++--------- script/setup/osx_helpers.sh | 41 +++++++++++++ 4 files changed, 139 insertions(+), 66 deletions(-) create mode 100644 script/setup/osx_helpers.sh diff --git a/.circleci/config.yml b/.circleci/config.yml index d422fdcc56f..e3e798f516b 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -2,7 +2,7 @@ version: 2 jobs: test: macos: - xcode: "8.3.3" + xcode: "9.4.1" steps: - checkout - run: @@ -17,7 +17,7 @@ jobs: build-osx-binary: macos: - xcode: "8.3.3" + xcode: "9.4.1" steps: - checkout - run: @@ -25,18 +25,17 @@ jobs: command: sudo pip install --upgrade pip virtualenv - run: name: setup script - command: ./script/setup/osx + command: DEPLOYMENT_TARGET=10.11 ./script/setup/osx - run: name: build script command: ./script/build/osx - store_artifacts: path: dist/docker-compose-Darwin-x86_64 destination: docker-compose-Darwin-x86_64 - # - deploy: - # name: Deploy binary to bintray - # command: | - # OS_NAME=Darwin PKG_NAME=osx ./script/circle/bintray-deploy.sh - + - deploy: + name: Deploy binary to bintray + command: | + OS_NAME=Darwin PKG_NAME=osx ./script/circle/bintray-deploy.sh build-linux-binary: machine: @@ -54,28 +53,6 @@ jobs: command: | OS_NAME=Linux PKG_NAME=linux ./script/circle/bintray-deploy.sh - trigger-osx-binary-deploy: - # We use a separate repo to build OSX binaries meant for distribution - # with support for OSSX 10.11 (xcode 7). This job triggers a build on - # that repo. - docker: - - image: alpine:3.6 - - steps: - - run: - name: install curl - command: apk update && apk add curl - - - run: - name: API trigger - command: | - curl -X POST -H "Content-Type: application/json" -d "{\ - \"build_parameters\": {\ - \"COMPOSE_BRANCH\": \"${CIRCLE_BRANCH}\"\ - }\ - }" https://circleci.com/api/v1.1/project/github/docker/compose-osx-release?circle-token=${OSX_RELEASE_TOKEN} \ - > /dev/null - workflows: version: 2 @@ -84,9 +61,3 @@ workflows: - test - build-linux-binary - build-osx-binary - - trigger-osx-binary-deploy: - filters: - branches: - only: - - master - - /bump-.*/ diff --git a/script/build/osx b/script/build/osx index 0c4b062bb47..c62b2702433 100755 --- a/script/build/osx +++ b/script/build/osx @@ -1,11 +1,11 @@ #!/bin/bash set -ex -PATH="/usr/local/bin:$PATH" +TOOLCHAIN_PATH="$(realpath $(dirname $0)/../../build/toolchain)" rm -rf venv -virtualenv -p /usr/local/bin/python3 venv +virtualenv -p ${TOOLCHAIN_PATH}/bin/python3 venv venv/bin/pip install -r requirements.txt venv/bin/pip install -r requirements-build.txt venv/bin/pip install --no-deps . diff --git a/script/setup/osx b/script/setup/osx index 972e79efb44..08491b6e5cb 100755 --- a/script/setup/osx +++ b/script/setup/osx @@ -1,43 +1,104 @@ -#!/bin/bash +#!/usr/bin/env bash set -ex -python_version() { - python -V 2>&1 -} +. $(dirname $0)/osx_helpers.sh -python3_version() { - python3 -V 2>&1 -} - -openssl_version() { - python -c "import ssl; print ssl.OPENSSL_VERSION" -} +DEPLOYMENT_TARGET=${DEPLOYMENT_TARGET:-"$(macos_version)"} +SDK_FETCH= +if ! [ ${DEPLOYMENT_TARGET} == "$(macos_version)" ]; then + SDK_FETCH=1 + # SDK URL from https://github.com/docker/golang-cross/blob/master/osx-cross.sh + SDK_URL=https://s3.dockerproject.org/darwin/v2/MacOSX${DEPLOYMENT_TARGET}.sdk.tar.xz + SDK_SHA1=dd228a335194e3392f1904ce49aff1b1da26ca62 +fi -desired_python3_version="3.6.4" -desired_python3_brew_version="3.6.4_2" -python3_formula="https://raw.githubusercontent.com/Homebrew/homebrew-core/b4e69a9a592232fa5a82741f6acecffc2f1d198d/Formula/python3.rb" +OPENSSL_VERSION=1.1.0h +OPENSSL_URL=https://www.openssl.org/source/openssl-${OPENSSL_VERSION}.tar.gz +OPENSSL_SHA1=0fc39f6aa91b6e7f4d05018f7c5e991e1d2491fd -PATH="/usr/local/bin:$PATH" +PYTHON_VERSION=3.6.6 +PYTHON_URL=https://www.python.org/ftp/python/${PYTHON_VERSION}/Python-${PYTHON_VERSION}.tgz +PYTHON_SHA1=ae1fc9ddd29ad8c1d5f7b0d799ff0787efeb9652 -if !(which brew); then +# +# Install prerequisites. +# +if ! [ -x "$(command -v brew)" ]; then ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)" fi +if ! [ -x "$(command -v grealpath)" ]; then + brew update > /dev/null + brew install coreutils +fi +if ! [ -x "$(command -v python3)" ]; then + brew update > /dev/null + brew install python3 +fi +if ! [ -x "$(command -v virtualenv)" ]; then + pip install virtualenv +fi -brew update > /dev/null - -if !(python3_version | grep "$desired_python3_version"); then - if brew list | grep python3; then - brew unlink python3 - fi +# +# Create toolchain directory. +# +BUILD_PATH="$(grealpath $(dirname $0)/../../build)" +mkdir -p ${BUILD_PATH} +TOOLCHAIN_PATH="${BUILD_PATH}/toolchain" +mkdir -p ${TOOLCHAIN_PATH} - brew install "$python3_formula" - brew switch python3 "$desired_python3_brew_version" +# +# Set macOS SDK. +# +if [ ${SDK_FETCH} ]; then + SDK_PATH=${TOOLCHAIN_PATH}/MacOSX${DEPLOYMENT_TARGET}.sdk + fetch_tarball ${SDK_URL} ${SDK_PATH} ${SDK_SHA1} +else + SDK_PATH="$(xcode-select --print-path)/Platforms/MacOSX.platform/Developer/SDKs/MacOSX${DEPLOYMENT_TARGET}.sdk" fi -echo "*** Using $(python3_version) ; $(python_version)" -echo "*** Using $(openssl_version)" +# +# Build OpenSSL. +# +OPENSSL_SRC_PATH=${TOOLCHAIN_PATH}/openssl-${OPENSSL_VERSION} +if ! [ -f ${TOOLCHAIN_PATH}/bin/openssl ]; then + rm -rf ${OPENSSL_SRC_PATH} + fetch_tarball ${OPENSSL_URL} ${OPENSSL_SRC_PATH} ${OPENSSL_SHA1} + ( + cd ${OPENSSL_SRC_PATH} + export MACOSX_DEPLOYMENT_TARGET=${DEPLOYMENT_TARGET} + export SDKROOT=${SDK_PATH} + ./Configure darwin64-x86_64-cc --prefix=${TOOLCHAIN_PATH} + make install_sw install_dev + ) +fi -if !(which virtualenv); then - pip install virtualenv +# +# Build Python. +# +PYTHON_SRC_PATH=${TOOLCHAIN_PATH}/Python-${PYTHON_VERSION} +if ! [ -f ${TOOLCHAIN_PATH}/bin/python3 ]; then + rm -rf ${PYTHON_SRC_PATH} + fetch_tarball ${PYTHON_URL} ${PYTHON_SRC_PATH} ${PYTHON_SHA1} + ( + cd ${PYTHON_SRC_PATH} + ./configure --prefix=${TOOLCHAIN_PATH} \ + --enable-ipv6 --without-ensurepip --with-dtrace --without-gcc \ + --datarootdir=${TOOLCHAIN_PATH}/share \ + --datadir=${TOOLCHAIN_PATH}/share \ + --enable-framework=${TOOLCHAIN_PATH}/Frameworks \ + MACOSX_DEPLOYMENT_TARGET=${DEPLOYMENT_TARGET} \ + CFLAGS="-isysroot ${SDK_PATH} -I${TOOLCHAIN_PATH}/include" \ + CPPFLAGS="-I${SDK_PATH}/usr/include -I${TOOLCHAIN_PATH}include" \ + LDFLAGS="-isysroot ${SDK_PATH} -L ${TOOLCHAIN_PATH}/lib" + make -j 4 + make install PYTHONAPPSDIR=${TOOLCHAIN_PATH} + make frameworkinstallextras PYTHONAPPSDIR=${TOOLCHAIN_PATH}/share + ) fi + +echo "" +echo "*** Targeting macOS: ${DEPLOYMENT_TARGET}" +echo "*** Using SDK ${SDK_PATH}" +echo "*** Using $(python3_version ${TOOLCHAIN_PATH})" +echo "*** Using $(openssl_version ${TOOLCHAIN_PATH})" diff --git a/script/setup/osx_helpers.sh b/script/setup/osx_helpers.sh new file mode 100644 index 00000000000..d60a30b6dd9 --- /dev/null +++ b/script/setup/osx_helpers.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash + +# Check file's ($1) SHA1 ($2). +check_sha1() { + echo -n "$2 *$1" | shasum -c - +} + +# Download URL ($1) to path ($2). +download() { + curl -L $1 -o $2 +} + +# Extract tarball ($1) in folder ($2). +extract() { + tar xf $1 -C $2 +} + +# Download URL ($1), check SHA1 ($3), and extract utility ($2). +fetch_tarball() { + url=$1 + tarball=$2.tarball + sha1=$3 + download $url $tarball + check_sha1 $tarball $sha1 + extract $tarball $(dirname $tarball) +} + +# Version of Python at toolchain path ($1). +python3_version() { + $1/bin/python3 -V 2>&1 +} + +# Version of OpenSSL used by toolchain ($1) Python. +openssl_version() { + $1/bin/python3 -c "import ssl; print(ssl.OPENSSL_VERSION)" +} + +# System macOS version. +macos_version() { + sw_vers -productVersion | cut -f1,2 -d'.' +} From 7f9c042300e023f63ca76449b893ade64355c290 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arkadiusz=20Dzi=C4=99giel?= Date: Tue, 24 Jul 2018 12:21:37 +0200 Subject: [PATCH 3478/4072] Fixes pipe handling in container mode. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #4599, #4460 - adds a way to provide options from env in both cases (tty & non tty) - allocates TTY only if both stdin & stdout are TTYs - enables interactive mode if stdin is not TTY Signed-off-by: Arkadiusz Dzięgiel --- script/run/run.sh | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/script/run/run.sh b/script/run/run.sh index 52ff9513f5f..fe253875e23 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -47,10 +47,11 @@ if [ -n "$HOME" ]; then fi # Only allocate tty if we detect one -if [ -t 1 ]; then - DOCKER_RUN_OPTIONS="-t" -fi if [ -t 0 ]; then + if [ -t 1 ]; then + DOCKER_RUN_OPTIONS="$DOCKER_RUN_OPTIONS -t" + fi +else DOCKER_RUN_OPTIONS="$DOCKER_RUN_OPTIONS -i" fi From c956785cdc242f081043f3879047c428f0bd893a Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 20 Jul 2018 15:37:15 -0700 Subject: [PATCH 3479/4072] Add progress messages to parallel pull Signed-off-by: Joffrey F --- compose/parallel.py | 7 ++++++ compose/progress_stream.py | 5 +--- compose/project.py | 25 +++++++++++++++++-- compose/service.py | 39 ++++++++++++++++++------------ tests/integration/testcases.py | 4 ++- tests/unit/progress_stream_test.py | 12 ++++----- 6 files changed, 63 insertions(+), 29 deletions(-) diff --git a/compose/parallel.py b/compose/parallel.py index a2eb160e55e..34a498ca760 100644 --- a/compose/parallel.py +++ b/compose/parallel.py @@ -313,6 +313,13 @@ def write(self, msg, obj_index, status, color_func): self._write_ansi(msg, obj_index, color_func(status)) +def get_stream_writer(): + instance = ParallelStreamWriter.instance + if instance is None: + raise RuntimeError('ParallelStreamWriter has not yet been instantiated') + return instance + + def parallel_operation(containers, operation, options, message): parallel_execute( containers, diff --git a/compose/progress_stream.py b/compose/progress_stream.py index 5e709770a50..4cd311432ae 100644 --- a/compose/progress_stream.py +++ b/compose/progress_stream.py @@ -19,12 +19,11 @@ def write_to_stream(s, stream): def stream_output(output, stream): is_terminal = hasattr(stream, 'isatty') and stream.isatty() stream = utils.get_output_stream(stream) - all_events = [] lines = {} diff = 0 for event in utils.json_stream(output): - all_events.append(event) + yield event is_progress_event = 'progress' in event or 'progressDetail' in event if not is_progress_event: @@ -57,8 +56,6 @@ def stream_output(output, stream): stream.flush() - return all_events - def print_output_event(event, stream, is_terminal): if 'errorDetail' in event: diff --git a/compose/project.py b/compose/project.py index 005b7e24092..391bbd038ef 100644 --- a/compose/project.py +++ b/compose/project.py @@ -548,16 +548,37 @@ def _get_convergence_plans(self, services, strategy, always_recreate_deps=False) def pull(self, service_names=None, ignore_pull_failures=False, parallel_pull=False, silent=False, include_deps=False): services = self.get_services(service_names, include_deps) + msg = not silent and 'Pulling' or None if parallel_pull: def pull_service(service): - service.pull(ignore_pull_failures, True) + strm = service.pull(ignore_pull_failures, True, stream=True) + writer = parallel.get_stream_writer() + + def trunc(s): + if len(s) > 35: + return s[:33] + '...' + return s + + for event in strm: + if 'status' not in event: + continue + status = event['status'].lower() + if 'progressDetail' in event: + detail = event['progressDetail'] + if 'current' in detail and 'total' in detail: + percentage = float(detail['current']) / float(detail['total']) + status = '{} ({:.1%})'.format(status, percentage) + + writer.write( + msg, service.name, trunc(status), lambda s: s + ) _, errors = parallel.parallel_execute( services, pull_service, operator.attrgetter('name'), - not silent and 'Pulling' or None, + msg, limit=5, ) if len(errors): diff --git a/compose/service.py b/compose/service.py index e77780fd8c0..4b545ab027b 100644 --- a/compose/service.py +++ b/compose/service.py @@ -1068,7 +1068,7 @@ def build(self, no_cache=False, pull=False, force_rm=False, memory=None, build_a ) try: - all_events = stream_output(build_output, sys.stdout) + all_events = list(stream_output(build_output, sys.stdout)) except StreamOutputError as e: raise BuildError(self, six.text_type(e)) @@ -1162,7 +1162,23 @@ def has_host_port(binding): return any(has_host_port(binding) for binding in self.options.get('ports', [])) - def pull(self, ignore_pull_failures=False, silent=False): + def _do_pull(self, repo, pull_kwargs, silent, ignore_pull_failures): + try: + output = self.client.pull(repo, **pull_kwargs) + if silent: + with open(os.devnull, 'w') as devnull: + for event in stream_output(output, devnull): + yield event + else: + for event in stream_output(output, sys.stdout): + yield event + except (StreamOutputError, NotFound) as e: + if not ignore_pull_failures: + raise + else: + log.error(six.text_type(e)) + + def pull(self, ignore_pull_failures=False, silent=False, stream=False): if 'image' not in self.options: return @@ -1179,20 +1195,11 @@ def pull(self, ignore_pull_failures=False, silent=False): raise OperationFailedError( 'Impossible to perform platform-targeted pulls for API version < 1.35' ) - try: - output = self.client.pull(repo, **kwargs) - if silent: - with open(os.devnull, 'w') as devnull: - return progress_stream.get_digest_from_pull( - stream_output(output, devnull)) - else: - return progress_stream.get_digest_from_pull( - stream_output(output, sys.stdout)) - except (StreamOutputError, NotFound) as e: - if not ignore_pull_failures: - raise - else: - log.error(six.text_type(e)) + + event_stream = self._do_pull(repo, kwargs, silent, ignore_pull_failures) + if stream: + return event_stream + return progress_stream.get_digest_from_pull(event_stream) def push(self, ignore_push_failures=False): if 'image' not in self.options or 'build' not in self.options: diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index 4440d771e81..cfdf22f7e61 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -139,7 +139,9 @@ def create_service(self, name, **kwargs): def check_build(self, *args, **kwargs): kwargs.setdefault('rm', True) build_output = self.client.build(*args, **kwargs) - stream_output(build_output, open('/dev/null', 'w')) + with open(os.devnull, 'w') as devnull: + for event in stream_output(build_output, devnull): + pass def require_api_version(self, minimum): api_version = self.client.version()['ApiVersion'] diff --git a/tests/unit/progress_stream_test.py b/tests/unit/progress_stream_test.py index f4a0ab06348..d29227458cb 100644 --- a/tests/unit/progress_stream_test.py +++ b/tests/unit/progress_stream_test.py @@ -21,7 +21,7 @@ def test_stream_output(self): b'31019763, "start": 1413653874, "total": 62763875}, ' b'"progress": "..."}', ] - events = progress_stream.stream_output(output, StringIO()) + events = list(progress_stream.stream_output(output, StringIO())) assert len(events) == 1 def test_stream_output_div_zero(self): @@ -30,7 +30,7 @@ def test_stream_output_div_zero(self): b'0, "start": 1413653874, "total": 0}, ' b'"progress": "..."}', ] - events = progress_stream.stream_output(output, StringIO()) + events = list(progress_stream.stream_output(output, StringIO())) assert len(events) == 1 def test_stream_output_null_total(self): @@ -39,7 +39,7 @@ def test_stream_output_null_total(self): b'0, "start": 1413653874, "total": null}, ' b'"progress": "..."}', ] - events = progress_stream.stream_output(output, StringIO()) + events = list(progress_stream.stream_output(output, StringIO())) assert len(events) == 1 def test_stream_output_progress_event_tty(self): @@ -52,7 +52,7 @@ def isatty(self): return True output = TTYStringIO() - events = progress_stream.stream_output(events, output) + events = list(progress_stream.stream_output(events, output)) assert len(output.getvalue()) > 0 def test_stream_output_progress_event_no_tty(self): @@ -61,7 +61,7 @@ def test_stream_output_progress_event_no_tty(self): ] output = StringIO() - events = progress_stream.stream_output(events, output) + events = list(progress_stream.stream_output(events, output)) assert len(output.getvalue()) == 0 def test_stream_output_no_progress_event_no_tty(self): @@ -70,7 +70,7 @@ def test_stream_output_no_progress_event_no_tty(self): ] output = StringIO() - events = progress_stream.stream_output(events, output) + events = list(progress_stream.stream_output(events, output)) assert len(output.getvalue()) > 0 def test_mismatched_encoding_stream_write(self): From 89f2bfe4f392b1bb74ab85af01dc95d2d6b47202 Mon Sep 17 00:00:00 2001 From: Gil Raphaelli Date: Tue, 31 Jul 2018 11:54:32 -0400 Subject: [PATCH 3480/4072] add --parallel option to build Signed-off-by: Gil Raphaelli --- compose/cli/main.py | 2 ++ compose/project.py | 27 +++++++++++++++++-- tests/acceptance/cli_test.py | 7 +++++ .../build-multiple-composefile/a/Dockerfile | 4 +++ .../build-multiple-composefile/b/Dockerfile | 4 +++ .../docker-compose.yml | 8 ++++++ 6 files changed, 50 insertions(+), 2 deletions(-) create mode 100644 tests/fixtures/build-multiple-composefile/a/Dockerfile create mode 100644 tests/fixtures/build-multiple-composefile/b/Dockerfile create mode 100644 tests/fixtures/build-multiple-composefile/docker-compose.yml diff --git a/compose/cli/main.py b/compose/cli/main.py index fa64014128b..d224093cb51 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -260,6 +260,7 @@ def build(self, options): --pull Always attempt to pull a newer version of the image. -m, --memory MEM Sets memory limit for the build container. --build-arg key=val Set build-time variables for services. + --parallel Build images in parallel. """ service_names = options['SERVICE'] build_args = options.get('--build-arg', None) @@ -280,6 +281,7 @@ def build(self, options): memory=options.get('--memory'), build_args=build_args, gzip=options.get('--compress', False), + parallel_build=options.get('--parallel', False), ) def bundle(self, options): diff --git a/compose/project.py b/compose/project.py index 005b7e24092..a7ef327e6ea 100644 --- a/compose/project.py +++ b/compose/project.py @@ -372,13 +372,36 @@ def restart(self, service_names=None, **options): return containers def build(self, service_names=None, no_cache=False, pull=False, force_rm=False, memory=None, - build_args=None, gzip=False): + build_args=None, gzip=False, parallel_build=False): + + services = [] for service in self.get_services(service_names): if service.can_be_built(): - service.build(no_cache, pull, force_rm, memory, build_args, gzip) + services.append(service) else: log.info('%s uses an image, skipping' % service.name) + def build_service(service): + service.build(no_cache, pull, force_rm, memory, build_args, gzip) + + if parallel_build: + _, errors = parallel.parallel_execute( + services, + build_service, + operator.attrgetter('name'), + 'Building', + limit=5, + ) + if len(errors): + combined_errors = '\n'.join([ + e.decode('utf-8') if isinstance(e, six.binary_type) else e for e in errors.values() + ]) + raise ProjectError(combined_errors) + + else: + for service in services: + build_service(service) + def create( self, service_names=None, diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 6c6cf32f53a..2361a1fbfd0 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -773,6 +773,13 @@ def test_build_override_dir_invalid_path(self): assert 'does not exist, is not accessible, or is not a valid URL' in result.stderr + def test_build_parallel(self): + self.base_dir = 'tests/fixtures/build-multiple-composefile' + result = self.dispatch(['build', '--parallel']) + assert 'Successfully tagged build-multiple-composefile_a:latest' in result.stdout + assert 'Successfully tagged build-multiple-composefile_b:latest' in result.stdout + assert 'Successfully built' in result.stdout + def test_create(self): self.dispatch(['create']) service = self.project.get_service('simple') diff --git a/tests/fixtures/build-multiple-composefile/a/Dockerfile b/tests/fixtures/build-multiple-composefile/a/Dockerfile new file mode 100644 index 00000000000..2ba45ce55f0 --- /dev/null +++ b/tests/fixtures/build-multiple-composefile/a/Dockerfile @@ -0,0 +1,4 @@ + +FROM busybox:latest +RUN echo a +CMD top diff --git a/tests/fixtures/build-multiple-composefile/b/Dockerfile b/tests/fixtures/build-multiple-composefile/b/Dockerfile new file mode 100644 index 00000000000..e282e8bbfba --- /dev/null +++ b/tests/fixtures/build-multiple-composefile/b/Dockerfile @@ -0,0 +1,4 @@ + +FROM busybox:latest +RUN echo b +CMD top diff --git a/tests/fixtures/build-multiple-composefile/docker-compose.yml b/tests/fixtures/build-multiple-composefile/docker-compose.yml new file mode 100644 index 00000000000..efa70d7e060 --- /dev/null +++ b/tests/fixtures/build-multiple-composefile/docker-compose.yml @@ -0,0 +1,8 @@ + +version: "2" + +services: + a: + build: ./a + b: + build: ./b From 541fb6525991ae7110bf512a3313c0e13ee7a42d Mon Sep 17 00:00:00 2001 From: Fender William Date: Tue, 24 Apr 2018 07:41:55 +0200 Subject: [PATCH 3481/4072] Add --hash opt for config command Signed-off-by: Fender William --- .gitignore | 1 + compose/cli/main.py | 17 ++++++++++++++++- contrib/completion/bash/docker-compose | 2 +- contrib/completion/zsh/_docker-compose | 3 ++- 4 files changed, 20 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 11266c2e39f..18afd643da2 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ compose/GITSHA *.swp .DS_Store .cache +.idea diff --git a/compose/cli/main.py b/compose/cli/main.py index d224093cb51..231767424e5 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -328,7 +328,8 @@ def config(self, options): anything. --services Print the service names, one per line. --volumes Print the volume names, one per line. - + --hash="all" Print the service config hash, one per line. + Set "service1,service2" for a list of specified services. """ compose_config = get_config_from_options(self.project_dir, self.toplevel_options) @@ -350,6 +351,20 @@ def config(self, options): print('\n'.join(volume for volume in compose_config.volumes)) return + if options['--hash'] is not None: + self.project = project_from_options('.', self.toplevel_options) + if options['--hash'] == "all": + for service in self.project.services: + print('{} {}'.format(service.name, service.config_hash)) + else: + for service_name in options['--hash'].split(','): + try: + print('{} {}'.format(service_name, + self.project.get_service(service_name).config_hash)) + except NoSuchService as s: + print('{}'.format(s)) + return + print(serialize_config(compose_config, image_digests)) def create(self, options): diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index b90af45d189..f4c42362c2a 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -136,7 +136,7 @@ _docker_compose_bundle() { _docker_compose_config() { - COMPREPLY=( $( compgen -W "--help --quiet -q --resolve-image-digests --services --volumes" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--help --quiet -q --resolve-image-digests --services --volumes --hash" -- "$cur" ) ) } diff --git a/contrib/completion/zsh/_docker-compose b/contrib/completion/zsh/_docker-compose index aba367706ad..676aa117b0b 100644 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -213,7 +213,8 @@ __docker-compose_subcommand() { '(--quiet -q)'{--quiet,-q}"[Only validate the configuration, don't print anything.]" \ '--resolve-image-digests[Pin image tags to digests.]' \ '--services[Print the service names, one per line.]' \ - '--volumes[Print the volume names, one per line.]' && ret=0 + '--volumes[Print the volume names, one per line.]' \ + '--hash[Print the service config hash, one per line. Set "service1,service2" for a list of specified services.]' \ && ret=0 ;; (create) _arguments \ From 707e21183f5117f4f2b5005615a7c483958b5b3d Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 7 Aug 2018 16:45:34 -0700 Subject: [PATCH 3482/4072] Fix config hash consistency with unprioritized networks Signed-off-by: Joffrey F --- compose/network.py | 13 +++++++++---- tests/unit/service_test.py | 6 ++++-- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/compose/network.py b/compose/network.py index 9751f20375c..2491a598964 100644 --- a/compose/network.py +++ b/compose/network.py @@ -323,7 +323,12 @@ def get_networks(service_dict, network_definitions): 'Service "{}" uses an undefined network "{}"' .format(service_dict['name'], name)) - return OrderedDict(sorted( - networks.items(), - key=lambda t: t[1].get('priority') or 0, reverse=True - )) + if any([v.get('priority') for v in networks.values()]): + return OrderedDict(sorted( + networks.items(), + key=lambda t: t[1].get('priority') or 0, reverse=True + )) + else: + # Ensure Compose will pick a consistent primary network if no + # priority is set + return OrderedDict(sorted(networks.items(), key=lambda t: t[0])) diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index f5a35d81495..791019a4e3e 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -701,9 +701,11 @@ def test_config_hash_matches_label(self): image='example.com/foo', client=self.mock_client, network_mode=NetworkMode('bridge'), - networks={'bridge': {}}, + networks={'bridge': {}, 'net2': {}}, links=[(Service('one', client=self.mock_client), 'one')], - volumes_from=[VolumeFromSpec(Service('two', client=self.mock_client), 'rw', 'service')] + volumes_from=[VolumeFromSpec(Service('two', client=self.mock_client), 'rw', 'service')], + volumes=[VolumeSpec('/ext', '/int', 'ro')], + build={'context': 'some/random/path'}, ) config_hash = service.config_hash From 861031b9b7ed83866a73b48dc1c17119cd0a708e Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 7 Aug 2018 16:47:34 -0700 Subject: [PATCH 3483/4072] Reduce config --hash code complexity and add test Signed-off-by: Joffrey F --- compose/cli/main.py | 20 ++++++++------------ tests/acceptance/cli_test.py | 11 +++++++++++ 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 231767424e5..4c18d19f77f 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -328,8 +328,9 @@ def config(self, options): anything. --services Print the service names, one per line. --volumes Print the volume names, one per line. - --hash="all" Print the service config hash, one per line. - Set "service1,service2" for a list of specified services. + --hash="*" Print the service config hash, one per line. + Set "service1,service2" for a list of specified services + or use the wildcard symbol to display all services """ compose_config = get_config_from_options(self.project_dir, self.toplevel_options) @@ -352,17 +353,12 @@ def config(self, options): return if options['--hash'] is not None: + h = options['--hash'] self.project = project_from_options('.', self.toplevel_options) - if options['--hash'] == "all": - for service in self.project.services: - print('{} {}'.format(service.name, service.config_hash)) - else: - for service_name in options['--hash'].split(','): - try: - print('{} {}'.format(service_name, - self.project.get_service(service_name).config_hash)) - except NoSuchService as s: - print('{}'.format(s)) + services = [svc for svc in options['--hash'].split(',')] if h != '*' else None + + for service in self.project.get_services(services): + print('{} {}'.format(service.name, service.config_hash)) return print(serialize_config(compose_config, image_digests)) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 2361a1fbfd0..815b92c8d77 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -222,6 +222,17 @@ def test_config_quiet(self): self.base_dir = 'tests/fixtures/v2-full' assert self.dispatch(['config', '--quiet']).stdout == '' + def test_config_with_hash_option(self): + self.base_dir = 'tests/fixtures/v2-full' + self.project.build() + result = self.dispatch(['config', '--hash=*']) + for service in self.project.get_services(): + assert '{} {}\n'.format(service.name, service.config_hash) in result.stdout + + svc = self.project.get_service('other') + result = self.dispatch(['config', '--hash=other']) + assert result.stdout == '{} {}\n'.format(svc.name, svc.config_hash) + def test_config_default(self): self.base_dir = 'tests/fixtures/v2-full' result = self.dispatch(['config']) From ee878aee4cddad7d652f4908ff1b1ddd5474fbbd Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 7 Aug 2018 17:32:31 -0700 Subject: [PATCH 3484/4072] Handle missing (not built) service image in config --hash Signed-off-by: Joffrey F --- compose/cli/main.py | 6 +++--- compose/service.py | 8 +++++++- tests/acceptance/cli_test.py | 1 - 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 4c18d19f77f..07447d671cc 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -356,9 +356,9 @@ def config(self, options): h = options['--hash'] self.project = project_from_options('.', self.toplevel_options) services = [svc for svc in options['--hash'].split(',')] if h != '*' else None - - for service in self.project.get_services(services): - print('{} {}'.format(service.name, service.config_hash)) + with errors.handle_connection_errors(self.project.client): + for service in self.project.get_services(services): + print('{} {}'.format(service.name, service.config_hash)) return print(serialize_config(compose_config, image_digests)) diff --git a/compose/service.py b/compose/service.py index e77780fd8c0..a31f75a3d42 100644 --- a/compose/service.py +++ b/compose/service.py @@ -656,9 +656,15 @@ def config_hash(self): return json_hash(self.config_dict()) def config_dict(self): + def image_id(): + try: + return self.image()['Id'] + except NoSuchImageError: + return None + return { 'options': self.options, - 'image_id': self.image()['Id'], + 'image_id': image_id(), 'links': self.get_link_names(), 'net': self.network_mode.id, 'networks': self.networks, diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 815b92c8d77..f9d2821b07d 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -224,7 +224,6 @@ def test_config_quiet(self): def test_config_with_hash_option(self): self.base_dir = 'tests/fixtures/v2-full' - self.project.build() result = self.dispatch(['config', '--hash=*']) for service in self.project.get_services(): assert '{} {}\n'.format(service.name, service.config_hash) in result.stdout From 5ad50dc0b3f978e4911dd24279c4d99d3bc3c51e Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 9 Aug 2018 17:51:39 -0700 Subject: [PATCH 3485/4072] Bump Python SDK -> 3.5.0 Add support for Python 3.7 Signed-off-by: Joffrey F --- .circleci/config.yml | 2 +- Jenkinsfile | 3 ++- appveyor.yml | 2 +- requirements-dev.txt | 2 +- requirements.txt | 6 +++--- script/build/windows.ps1 | 2 +- setup.py | 3 ++- tox.ini | 2 +- 8 files changed, 12 insertions(+), 10 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index e3e798f516b..f4e90d6de71 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -13,7 +13,7 @@ jobs: command: sudo pip install --upgrade tox==2.1.1 - run: name: unit tests - command: tox -e py27,py36 -- tests/unit + command: tox -e py27,py36,py37 -- tests/unit build-osx-binary: macos: diff --git a/Jenkinsfile b/Jenkinsfile index 44cd7c3c2a6..04f5cfbda5c 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -74,10 +74,11 @@ buildImage() def testMatrix = [failFast: true] def docker_versions = get_versions(2) -for (int i = 0 ;i < docker_versions.length ; i++) { +for (int i = 0; i < docker_versions.length; i++) { def dockerVersion = docker_versions[i] testMatrix["${dockerVersion}_py27"] = runTests([dockerVersions: dockerVersion, pythonVersions: "py27"]) testMatrix["${dockerVersion}_py36"] = runTests([dockerVersions: dockerVersion, pythonVersions: "py36"]) + testMatrix["${dockerVersion}_py37"] = runTests([dockerVersions: dockerVersion, pythonVersions: "py37"]) } parallel(testMatrix) diff --git a/appveyor.yml b/appveyor.yml index f027a11800d..da80d01d959 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -10,7 +10,7 @@ install: build: false test_script: - - "tox -e py27,py36 -- tests/unit" + - "tox -e py27,py36,py37 -- tests/unit" - ps: ".\\script\\build\\windows.ps1" artifacts: diff --git a/requirements-dev.txt b/requirements-dev.txt index 32c5c23a1b2..4d74f6d157a 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,5 +1,5 @@ coverage==4.4.2 flake8==3.5.0 mock>=1.0.1 -pytest==2.9.2 +pytest==3.6.3 pytest-cov==2.5.1 diff --git a/requirements.txt b/requirements.txt index 96a98417e4b..41d21172e35 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ backports.ssl-match-hostname==3.5.0.1; python_version < '3' cached-property==1.3.0 certifi==2017.4.17 chardet==3.0.4 -docker==3.4.1 +docker==3.5.0 docker-pycreds==0.3.0 dockerpty==0.4.1 docopt==0.6.2 @@ -13,11 +13,11 @@ idna==2.5 ipaddress==1.0.18 jsonschema==2.6.0 pypiwin32==219; sys_platform == 'win32' and python_version < '3.6' -pypiwin32==220; sys_platform == 'win32' and python_version >= '3.6' +pypiwin32==223; sys_platform == 'win32' and python_version >= '3.6' PySocks==1.6.7 PyYAML==3.12 requests==2.19.1 six==1.10.0 texttable==0.9.1 -urllib3==1.21.1 +urllib3==1.21.1; python_version == '3.3' websocket-client==0.32.0 diff --git a/script/build/windows.ps1 b/script/build/windows.ps1 index 1de9bbfa4a2..41dc51e313c 100644 --- a/script/build/windows.ps1 +++ b/script/build/windows.ps1 @@ -44,7 +44,7 @@ virtualenv .\venv # pip and pyinstaller generate lots of warnings, so we need to ignore them $ErrorActionPreference = "Continue" -.\venv\Scripts\pip install pypiwin32==220 +.\venv\Scripts\pip install pypiwin32==223 .\venv\Scripts\pip install -r requirements.txt .\venv\Scripts\pip install --no-deps . .\venv\Scripts\pip install -r requirements-build.txt diff --git a/setup.py b/setup.py index 213f666259b..2819810c2ed 100644 --- a/setup.py +++ b/setup.py @@ -36,7 +36,7 @@ def find_version(*file_paths): 'requests >= 2.6.1, != 2.11.0, != 2.12.2, != 2.18.0, < 2.20', 'texttable >= 0.9.0, < 0.10', 'websocket-client >= 0.32.0, < 1.0', - 'docker >= 3.4.1, < 4.0', + 'docker >= 3.5.0, < 4.0', 'dockerpty >= 0.4.1, < 0.5', 'six >= 1.3.0, < 2', 'jsonschema >= 2.5.1, < 3', @@ -100,5 +100,6 @@ def find_version(*file_paths): 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3.4', 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', ], ) diff --git a/tox.ini b/tox.ini index 33347df20ee..08efd4e68e4 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py27,py36,pre-commit +envlist = py27,py36,py37,pre-commit [testenv] usedevelop=True From eb63e9f3c76a41e21ac27608b3cd0576dc19d49a Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 10 Aug 2018 17:02:56 -0700 Subject: [PATCH 3486/4072] Fix --project-directory handling to apply to .env files as well Signed-off-by: Joffrey F --- compose/cli/main.py | 7 +++-- tests/acceptance/cli_test.py | 30 +++++++++++++++++++ tests/fixtures/default-env-file/alt/.env | 4 +++ .../default-env-file/docker-compose.yml | 4 ++- 4 files changed, 42 insertions(+), 3 deletions(-) create mode 100644 tests/fixtures/default-env-file/alt/.env diff --git a/compose/cli/main.py b/compose/cli/main.py index 07447d671cc..c3e8f2789d1 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -238,11 +238,14 @@ class TopLevelCommand(object): version Show the Docker-Compose version information """ - def __init__(self, project, project_dir='.', options=None): + def __init__(self, project, options=None): self.project = project - self.project_dir = '.' self.toplevel_options = options or {} + @property + def project_dir(self): + return self.toplevel_options.get('--project-directory') or '.' + def build(self, options): """ Build or rebuild services. diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index f9d2821b07d..66e7d4c3898 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -303,6 +303,36 @@ def test_config_external_network(self): } } + def test_config_with_dot_env(self): + self.base_dir = 'tests/fixtures/default-env-file' + result = self.dispatch(['config']) + json_result = yaml.load(result.stdout) + assert json_result == { + 'services': { + 'web': { + 'command': 'true', + 'image': 'alpine:latest', + 'ports': ['5643/tcp', '9999/tcp'] + } + }, + 'version': '2.4' + } + + def test_config_with_dot_env_and_override_dir(self): + self.base_dir = 'tests/fixtures/default-env-file' + result = self.dispatch(['--project-directory', 'alt/', 'config']) + json_result = yaml.load(result.stdout) + assert json_result == { + 'services': { + 'web': { + 'command': 'echo uwu', + 'image': 'alpine:3.4', + 'ports': ['3341/tcp', '4449/tcp'] + } + }, + 'version': '2.4' + } + def test_config_external_volume_v2(self): self.base_dir = 'tests/fixtures/volumes' result = self.dispatch(['-f', 'external-volumes-v2.yml', 'config']) diff --git a/tests/fixtures/default-env-file/alt/.env b/tests/fixtures/default-env-file/alt/.env new file mode 100644 index 00000000000..163668d2220 --- /dev/null +++ b/tests/fixtures/default-env-file/alt/.env @@ -0,0 +1,4 @@ +IMAGE=alpine:3.4 +COMMAND=echo uwu +PORT1=3341 +PORT2=4449 diff --git a/tests/fixtures/default-env-file/docker-compose.yml b/tests/fixtures/default-env-file/docker-compose.yml index aa8e4409ebc..79363586185 100644 --- a/tests/fixtures/default-env-file/docker-compose.yml +++ b/tests/fixtures/default-env-file/docker-compose.yml @@ -1,4 +1,6 @@ -web: +version: '2.4' +services: + web: image: ${IMAGE} command: ${COMMAND} ports: From 3a93e8576247a5d6828cd98afd7816590bc9f072 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Rodr=C3=ADguez?= Date: Fri, 17 Aug 2018 14:08:41 -0300 Subject: [PATCH 3487/4072] Fix broken url MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit As per https://github.com/sgerrand/alpine-pkg-glibc#please-note. Signed-off-by: David Rodríguez --- Dockerfile.run | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile.run b/Dockerfile.run index c403ac23063..e9ba19fd401 100644 --- a/Dockerfile.run +++ b/Dockerfile.run @@ -4,7 +4,7 @@ ENV GLIBC 2.27-r0 ENV DOCKERBINS_SHA 1270dce1bd7e1838d62ae21d2505d87f16efc1d9074645571daaefdfd0c14054 RUN apk update && apk add --no-cache openssl ca-certificates curl libgcc && \ - curl -fsSL -o /etc/apk/keys/sgerrand.rsa.pub https://raw.githubusercontent.com/sgerrand/alpine-pkg-glibc/master/sgerrand.rsa.pub && \ + curl -fsSL -o /etc/apk/keys/sgerrand.rsa.pub https://alpine-pkgs.sgerrand.com/sgerrand.rsa.pub && \ curl -fsSL -o glibc-$GLIBC.apk https://github.com/sgerrand/alpine-pkg-glibc/releases/download/$GLIBC/glibc-$GLIBC.apk && \ apk add --no-cache glibc-$GLIBC.apk && \ ln -s /lib/libz.so.1 /usr/glibc-compat/lib/ && \ From a541d88d570617ea3ef2694798170386d03d7ad4 Mon Sep 17 00:00:00 2001 From: Josenivaldo Benito Jr Date: Wed, 5 Sep 2018 11:52:50 -0300 Subject: [PATCH 3488/4072] [armhf] Make Dockerfile.armhf compatible with main Dockerfile now uses python:3.6 image while Dockerfile.armhf uses debian. Python image is officially supported in ARM archtecture hence, the now both dockerfiles differs only on dockerbins.tgz file version. May we use environmental variables to select dockerbins.tgz? Signed-off-by: Josenivaldo Benito Jr --- Dockerfile.armhf | 46 ++++++---------------------------------------- 1 file changed, 6 insertions(+), 40 deletions(-) diff --git a/Dockerfile.armhf b/Dockerfile.armhf index ce4ab7c13dd..ee2ce8941eb 100644 --- a/Dockerfile.armhf +++ b/Dockerfile.armhf @@ -1,55 +1,21 @@ -FROM armhf/debian:wheezy +FROM python:3.6 RUN set -ex; \ apt-get update -qq; \ apt-get install -y \ locales \ - gcc \ - make \ - zlib1g \ - zlib1g-dev \ - libssl-dev \ - git \ - ca-certificates \ curl \ - libsqlite3-dev \ - libbz2-dev \ - ; \ - rm -rf /var/lib/apt/lists/* + python-dev \ + git RUN curl -fsSL -o dockerbins.tgz "https://download.docker.com/linux/static/stable/armhf/docker-17.12.0-ce.tgz" && \ + SHA256=f8de6378dad825b9fd5c3c2f949e791d22f918623c27a72c84fd6975a0e5d0a2; \ + echo "${SHA256} dockerbins.tgz" | sha256sum -c - && \ tar xvf dockerbins.tgz docker/docker --strip-components 1 && \ mv docker /usr/local/bin/docker && \ chmod +x /usr/local/bin/docker && \ rm dockerbins.tgz -# Build Python 2.7.13 from source -RUN set -ex; \ - curl -L https://www.python.org/ftp/python/2.7.13/Python-2.7.13.tgz | tar -xz; \ - cd Python-2.7.13; \ - ./configure --enable-shared; \ - make; \ - make install; \ - cd ..; \ - rm -rf /Python-2.7.13 - -# Build python 3.6 from source -RUN set -ex; \ - curl -L https://www.python.org/ftp/python/3.6.4/Python-3.6.4.tgz | tar -xz; \ - cd Python-3.6.4; \ - ./configure --enable-shared; \ - make; \ - make install; \ - cd ..; \ - rm -rf /Python-3.6.4 - -# Make libpython findable -ENV LD_LIBRARY_PATH /usr/local/lib - -# Install pip -RUN set -ex; \ - curl -L https://bootstrap.pypa.io/get-pip.py | python - # Python3 requires a valid locale RUN echo "en_US.UTF-8 UTF-8" > /etc/locale.gen && locale-gen ENV LANG en_US.UTF-8 @@ -70,4 +36,4 @@ RUN tox --notest ADD . /code/ RUN chown -R user /code/ -ENTRYPOINT ["/code/.tox/py27/bin/docker-compose"] +ENTRYPOINT ["/code/.tox/py36/bin/docker-compose"] From 5713215e847d4551b329234ff173a3e3299b28d5 Mon Sep 17 00:00:00 2001 From: ruicao Date: Fri, 7 Sep 2018 16:08:19 +0800 Subject: [PATCH 3489/4072] Typo fix: overriden -> overridden Signed-off-by: ruicao --- script/test/default | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/test/default b/script/test/default index aabb4e426f4..cbb6a67cb89 100755 --- a/script/test/default +++ b/script/test/default @@ -5,7 +5,7 @@ set -ex TAG="docker-compose:$(git rev-parse --short HEAD)" -# By default use the Dockerfile, but can be overriden to use an alternative file +# By default use the Dockerfile, but can be overridden to use an alternative file # e.g DOCKERFILE=Dockerfile.armhf script/test/default DOCKERFILE="${DOCKERFILE:-Dockerfile}" From b66782b412b794b0a8fbd276485f47ac924f80f6 Mon Sep 17 00:00:00 2001 From: Xiaoxi He Date: Fri, 7 Sep 2018 14:48:27 +0800 Subject: [PATCH 3490/4072] Fix typos in CHANGELOG Signed-off-by: Xiaoxi He --- CHANGELOG.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b791c1e08ae..d22c1645434 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,7 +60,7 @@ Change log ### Bugfixes -- Fixed a bug where the ip_range attirbute in IPAM configs was prevented +- Fixed a bug where the ip_range attribute in IPAM configs was prevented from passing validation 1.21.1 (2018-04-27) @@ -285,7 +285,7 @@ Change log preventing Compose from recovering volume data from previous containers for anonymous volumes -- Added limit for number of simulatenous parallel operations, which should +- Added limit for number of simultaneous parallel operations, which should prevent accidental resource exhaustion of the server. Default is 64 and can be configured using the `COMPOSE_PARALLEL_LIMIT` environment variable @@ -583,7 +583,7 @@ Change log ### Bugfixes - Volumes specified through the `--volume` flag of `docker-compose run` now - complement volumes declared in the service's defintion instead of replacing + complement volumes declared in the service's definition instead of replacing them - Fixed a bug where using multiple Compose files would unset the scale value From 373c83ccd7031993d2229258e81dc324914ac685 Mon Sep 17 00:00:00 2001 From: rongzhang Date: Fri, 7 Sep 2018 15:32:09 +0800 Subject: [PATCH 3491/4072] Fix some typo Signed-off-by: rongzhang --- 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 c3e8f2789d1..e0acf07114b 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -1109,7 +1109,7 @@ def up(rebuild): @classmethod def version(cls, options): """ - Show version informations + Show version information Usage: version [--short] From d491a81cecb191adf447f7874fec9b999cf6f663 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 31 Aug 2018 16:58:30 -0700 Subject: [PATCH 3492/4072] Skip testing TPs/betas for now Signed-off-by: Joffrey F --- script/test/versions.py | 38 +++++++++++++++++++++----------------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/script/test/versions.py b/script/test/versions.py index f699f2681fc..0dd27538f61 100755 --- a/script/test/versions.py +++ b/script/test/versions.py @@ -37,22 +37,22 @@ GITHUB_API = 'https://api.github.com/repos' -class Version(namedtuple('_Version', 'major minor patch rc edition')): +class Version(namedtuple('_Version', 'major minor patch stage edition')): @classmethod def parse(cls, version): edition = None version = version.lstrip('v') - version, _, rc = version.partition('-') - if rc: - if 'rc' not in rc: - edition = rc - rc = None - elif '-' in rc: - edition, rc = rc.split('-') + version, _, stage = version.partition('-') + if stage: + if not any(marker in stage for marker in ['rc', 'tp', 'beta']): + edition = stage + stage = None + elif '-' in stage: + edition, stage = stage.split('-') major, minor, patch = version.split('.', 3) - return cls(major, minor, patch, rc, edition) + return cls(major, minor, patch, stage, edition) @property def major_minor(self): @@ -64,13 +64,13 @@ def order(self): correctly with the default comparator. """ # rc releases should appear before official releases - rc = (0, self.rc) if self.rc else (1, ) - return (int(self.major), int(self.minor), int(self.patch)) + rc + stage = (0, self.stage) if self.stage else (1, ) + return (int(self.major), int(self.minor), int(self.patch)) + stage def __str__(self): - rc = '-{}'.format(self.rc) if self.rc else '' + stage = '-{}'.format(self.stage) if self.stage else '' edition = '-{}'.format(self.edition) if self.edition else '' - return '.'.join(map(str, self[:3])) + edition + rc + return '.'.join(map(str, self[:3])) + edition + stage BLACKLIST = [ # List of versions known to be broken and should not be used @@ -113,9 +113,9 @@ def get_latest_versions(versions, num=1): def get_default(versions): - """Return a :class:`Version` for the latest non-rc version.""" + """Return a :class:`Version` for the latest GA version.""" for version in versions: - if not version.rc: + if not version.stage: return version @@ -123,8 +123,12 @@ def get_versions(tags): for tag in tags: try: v = Version.parse(tag['name']) - if v not in BLACKLIST: - yield v + if v in BLACKLIST: + continue + # FIXME: Temporary. Remove once these versions are built on dockerswarm/dind + if v.stage and 'rc' not in v.stage: + continue + yield v except ValueError: print("Skipping invalid tag: {name}".format(**tag), file=sys.stderr) From 4e2de3c1ff4c30310e40f8fa4695cc0f518ce436 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 13 Aug 2018 19:29:29 -0700 Subject: [PATCH 3493/4072] Replace sequential container indexes with randomly generated IDs Signed-off-by: Joffrey F --- compose/cli/main.py | 14 +- compose/container.py | 9 +- compose/service.py | 38 ++--- compose/utils.py | 19 +++ tests/acceptance/cli_test.py | 138 ++++++++++-------- .../logs-tail-composefile/docker-compose.yml | 2 +- tests/integration/project_test.py | 11 +- tests/integration/service_test.py | 77 +++++----- tests/integration/state_test.py | 35 +++-- tests/unit/container_test.py | 6 +- tests/unit/service_test.py | 55 +++---- 11 files changed, 230 insertions(+), 174 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index e0acf07114b..2cee9e0336a 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -474,15 +474,16 @@ def exec_command(self, options): -u, --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=index "index" of the container if there are multiple + instances of a service. If missing, Compose will pick an + arbitrary container. -e, --env KEY=VAL Set environment variables (can be used multiple times, not supported in API < 1.25) -w, --workdir DIR Path to workdir directory for this command. """ environment = Environment.from_env_file(self.project_dir) use_cli = not environment.get_boolean('COMPOSE_INTERACTIVE_NO_CLI') - index = int(options.get('--index')) + index = options.get('--index') service = self.project.get_service(options['SERVICE']) detach = options.get('--detach') @@ -659,10 +660,11 @@ def port(self, options): Options: --protocol=proto tcp or udp [default: tcp] - --index=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. If missing, Compose will pick an + arbitrary container. """ - index = int(options.get('--index')) + index = options.get('--index') service = self.project.get_service(options['SERVICE']) try: container = service.get_container(number=index) diff --git a/compose/container.py b/compose/container.py index 8dac8cacd99..9b5bbba0472 100644 --- a/compose/container.py +++ b/compose/container.py @@ -10,6 +10,7 @@ from .const import LABEL_PROJECT from .const import LABEL_SERVICE from .const import LABEL_VERSION +from .utils import truncate_id from .version import ComposeVersion @@ -80,7 +81,7 @@ def service(self): @property def name_without_project(self): if self.name.startswith('{0}_{1}'.format(self.project, self.service)): - return '{0}_{1}'.format(self.service, self.number) + return '{0}_{1}'.format(self.service, self.short_number) else: return self.name @@ -90,7 +91,11 @@ def number(self): if not number: raise ValueError("Container {0} does not have a {1} label".format( self.short_id, LABEL_CONTAINER_NUMBER)) - return int(number) + return number + + @property + def short_number(self): + return truncate_id(self.number) @property def ports(self): diff --git a/compose/service.py b/compose/service.py index 33bb3fe34d7..5989217d78a 100644 --- a/compose/service.py +++ b/compose/service.py @@ -1,7 +1,6 @@ from __future__ import absolute_import from __future__ import unicode_literals -import itertools import logging import os import re @@ -49,9 +48,11 @@ from .parallel import parallel_execute from .progress_stream import stream_output from .progress_stream import StreamOutputError +from .utils import generate_random_id from .utils import json_hash from .utils import parse_bytes from .utils import parse_seconds_float +from .utils import truncate_id log = logging.getLogger(__name__) @@ -215,13 +216,17 @@ def containers(self, stopped=False, one_off=False, filters={}, labels=None): ) ) - def get_container(self, number=1): + def get_container(self, number=None): """Return a :class:`compose.container.Container` for this service. The container must be active, and match `number`. """ - - for container in self.containers(labels=['{0}={1}'.format(LABEL_CONTAINER_NUMBER, number)]): - return container + if number is not None and len(number) == 64: + for container in self.containers(labels=['{0}={1}'.format(LABEL_CONTAINER_NUMBER, number)]): + return container + else: + for container in self.containers(): + if number is None or container.number.startswith(number): + return container raise ValueError("No container found for %s_%s" % (self.name, number)) @@ -426,7 +431,6 @@ def _containers_have_diverged(self, containers): return has_diverged def _execute_convergence_create(self, scale, detached, start, project_services=None): - i = self._next_container_number() def create_and_start(service, n): container = service.create_container(number=n, quiet=True) @@ -437,7 +441,9 @@ def create_and_start(service, n): return container containers, errors = parallel_execute( - [ServiceName(self.project, self.name, index) for index in range(i, i + scale)], + [ServiceName(self.project, self.name, number) for number in [ + self._next_container_number() for _ in range(scale) + ]], lambda service_name: create_and_start(self, service_name.number), lambda service_name: self.get_container_name(service_name.service, service_name.number), "Creating" @@ -568,7 +574,7 @@ def recreate_container(self, container, timeout=None, attach_logs=False, start_n container.rename_to_tmp_name() new_container = self.create_container( previous_container=container if not renew_anonymous_volumes else None, - number=container.labels.get(LABEL_CONTAINER_NUMBER), + number=container.number, quiet=True, ) if attach_logs: @@ -723,20 +729,8 @@ def get_link_names(self): def get_volumes_from_names(self): return [s.source.name for s in self.volumes_from if isinstance(s.source, Service)] - # TODO: this would benefit from github.com/docker/docker/pull/14699 - # to remove the need to inspect every container def _next_container_number(self, one_off=False): - containers = itertools.chain( - self._fetch_containers( - all=True, - filters={'label': self.labels(one_off=one_off)} - ), self._fetch_containers( - all=True, - filters={'label': self.labels(one_off=one_off, legacy=True)} - ) - ) - numbers = [c.number for c in containers] - return 1 if not numbers else max(numbers) + 1 + return generate_random_id() def _fetch_containers(self, **fetch_options): # Account for containers that might have been removed since we fetched @@ -1377,7 +1371,7 @@ def build_container_name(project, service, number, one_off=False): bits = [project.lstrip('-_'), service] if one_off: bits.append('run') - return '_'.join(bits + [str(number)]) + return '_'.join(bits + [truncate_id(number)]) # Images diff --git a/compose/utils.py b/compose/utils.py index 956673b4b7f..8f0b3e54979 100644 --- a/compose/utils.py +++ b/compose/utils.py @@ -7,6 +7,7 @@ import json.decoder import logging import ntpath +import random import six from docker.errors import DockerException @@ -151,3 +152,21 @@ def unquote_path(s): if s[0] == '"' and s[-1] == '"': return s[1:-1] return s + + +def generate_random_id(): + while True: + val = hex(random.getrandbits(32 * 8))[2:-1] + try: + int(truncate_id(val)) + continue + except ValueError: + return val + + +def truncate_id(value): + if ':' in value: + value = value[value.index(':') + 1:] + if len(value) > 12: + return value[:12] + return value diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 66e7d4c3898..a41250d3d03 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -99,7 +99,14 @@ def __init__(self, client, name, status): def __call__(self): try: - container = self.client.inspect_container(self.name) + if self.name.endswith('*'): + ctnrs = self.client.containers(all=True, filters={'name': self.name[:-1]}) + if len(ctnrs) > 0: + container = self.client.inspect_container(ctnrs[0]['Id']) + else: + return False + else: + container = self.client.inspect_container(self.name) return container['State']['Status'] == self.status except errors.APIError: return False @@ -540,16 +547,16 @@ def test_config_compatibility_mode(self): def test_ps(self): self.project.get_service('simple').create_container() result = self.dispatch(['ps']) - assert 'simple-composefile_simple_1' in result.stdout + assert 'simple-composefile_simple_' in result.stdout def test_ps_default_composefile(self): self.base_dir = 'tests/fixtures/multiple-composefiles' self.dispatch(['up', '-d']) result = self.dispatch(['ps']) - assert 'multiple-composefiles_simple_1' in result.stdout - assert 'multiple-composefiles_another_1' in result.stdout - assert 'multiple-composefiles_yetanother_1' not in result.stdout + assert 'multiple-composefiles_simple_' in result.stdout + assert 'multiple-composefiles_another_' in result.stdout + assert 'multiple-composefiles_yetanother_' not in result.stdout def test_ps_alternate_composefile(self): config_path = os.path.abspath( @@ -560,9 +567,9 @@ def test_ps_alternate_composefile(self): self.dispatch(['-f', 'compose2.yml', 'up', '-d']) result = self.dispatch(['-f', 'compose2.yml', 'ps']) - assert 'multiple-composefiles_simple_1' not in result.stdout - assert 'multiple-composefiles_another_1' not in result.stdout - assert 'multiple-composefiles_yetanother_1' in result.stdout + assert 'multiple-composefiles_simple_' not in result.stdout + assert 'multiple-composefiles_another_' not in result.stdout + assert 'multiple-composefiles_yetanother_' in result.stdout def test_ps_services_filter_option(self): self.base_dir = 'tests/fixtures/ps-services-filter' @@ -956,13 +963,13 @@ def test_down(self): assert len(self.project.containers(one_off=OneOffFilter.only, stopped=True)) == 2 result = self.dispatch(['down', '--rmi=local', '--volumes']) - assert 'Stopping v2-full_web_1' in result.stderr - assert 'Stopping v2-full_other_1' in result.stderr - assert 'Stopping v2-full_web_run_2' in result.stderr - assert 'Removing v2-full_web_1' in result.stderr - assert 'Removing v2-full_other_1' in result.stderr - assert 'Removing v2-full_web_run_1' in result.stderr - assert 'Removing v2-full_web_run_2' in result.stderr + assert 'Stopping v2-full_web_' in result.stderr + assert 'Stopping v2-full_other_' in result.stderr + assert 'Stopping v2-full_web_run_' in result.stderr + assert 'Removing v2-full_web_' in result.stderr + assert 'Removing v2-full_other_' in result.stderr + assert 'Removing v2-full_web_run_' in result.stderr + assert 'Removing v2-full_web_run_' in result.stderr assert 'Removing volume v2-full_data' in result.stderr assert 'Removing image v2-full_web' in result.stderr assert 'Removing image busybox' not in result.stderr @@ -1019,11 +1026,13 @@ def test_up_detached_long_form(self): def test_up_attached(self): self.base_dir = 'tests/fixtures/echo-services' result = self.dispatch(['up', '--no-color']) + simple_num = self.project.get_service('simple').containers(stopped=True)[0].short_number + another_num = self.project.get_service('another').containers(stopped=True)[0].short_number - 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 + assert 'simple_{} | simple'.format(simple_num) in result.stdout + assert 'another_{} | another'.format(another_num) in result.stdout + assert 'simple_{} exited with code 0'.format(simple_num) in result.stdout + assert 'another_{} exited with code 0'.format(another_num) in result.stdout @v2_only() def test_up(self): @@ -1727,11 +1736,12 @@ def test_run_without_command(self): def test_run_rm(self): self.base_dir = 'tests/fixtures/volume' proc = start_process(self.base_dir, ['run', '--rm', 'test']) + service = self.project.get_service('test') wait_on_condition(ContainerStateCondition( self.project.client, - 'volume_test_run_1', - 'running')) - service = self.project.get_service('test') + 'volume_test_run_*', + 'running') + ) containers = service.containers(one_off=OneOffFilter.only) assert len(containers) == 1 mounts = containers[0].get('Mounts') @@ -2054,39 +2064,39 @@ def test_run_handles_sigint(self): proc = start_process(self.base_dir, ['run', '-T', 'simple', 'top']) wait_on_condition(ContainerStateCondition( self.project.client, - 'simple-composefile_simple_run_1', + 'simple-composefile_simple_run_*', 'running')) os.kill(proc.pid, signal.SIGINT) wait_on_condition(ContainerStateCondition( self.project.client, - 'simple-composefile_simple_run_1', + 'simple-composefile_simple_run_*', 'exited')) def test_run_handles_sigterm(self): proc = start_process(self.base_dir, ['run', '-T', 'simple', 'top']) wait_on_condition(ContainerStateCondition( self.project.client, - 'simple-composefile_simple_run_1', + 'simple-composefile_simple_run_*', 'running')) os.kill(proc.pid, signal.SIGTERM) wait_on_condition(ContainerStateCondition( self.project.client, - 'simple-composefile_simple_run_1', + 'simple-composefile_simple_run_*', 'exited')) def test_run_handles_sighup(self): proc = start_process(self.base_dir, ['run', '-T', 'simple', 'top']) wait_on_condition(ContainerStateCondition( self.project.client, - 'simple-composefile_simple_run_1', + 'simple-composefile_simple_run_*', 'running')) os.kill(proc.pid, signal.SIGHUP) wait_on_condition(ContainerStateCondition( self.project.client, - 'simple-composefile_simple_run_1', + 'simple-composefile_simple_run_*', 'exited')) @mock.patch.dict(os.environ) @@ -2286,34 +2296,44 @@ def test_logs_follow_logs_from_new_containers(self): proc = start_process(self.base_dir, ['logs', '-f']) self.dispatch(['up', '-d', 'another']) - wait_on_condition(ContainerStateCondition( - self.project.client, - 'logs-composefile_another_1', - 'exited')) + another_num = self.project.get_service('another').get_container().short_number + wait_on_condition( + ContainerStateCondition( + self.project.client, + 'logs-composefile_another_{}'.format(another_num), + 'exited' + ) + ) + simple_num = self.project.get_service('simple').get_container().short_number self.dispatch(['kill', 'simple']) result = wait_on_process(proc) assert 'hello' in result.stdout assert 'test' in result.stdout - assert 'logs-composefile_another_1 exited with code 0' in result.stdout - assert 'logs-composefile_simple_1 exited with code 137' in result.stdout + assert 'logs-composefile_another_{} exited with code 0'.format(another_num) in result.stdout + assert 'logs-composefile_simple_{} exited with code 137'.format(simple_num) in result.stdout def test_logs_follow_logs_from_restarted_containers(self): self.base_dir = 'tests/fixtures/logs-restart-composefile' proc = start_process(self.base_dir, ['up']) - wait_on_condition(ContainerStateCondition( - self.project.client, - 'logs-restart-composefile_another_1', - 'exited')) - + wait_on_condition( + ContainerStateCondition( + self.project.client, + 'logs-restart-composefile_another_*', + 'exited' + ) + ) self.dispatch(['kill', 'simple']) result = wait_on_process(proc) - assert result.stdout.count('logs-restart-composefile_another_1 exited with code 1') == 3 + assert len(re.findall( + r'logs-restart-composefile_another_[a-f0-9]{12} exited with code 1', + result.stdout + )) == 3 assert result.stdout.count('world') == 3 def test_logs_default(self): @@ -2346,10 +2366,10 @@ def test_logs_tail(self): self.dispatch(['up']) result = self.dispatch(['logs', '--tail', '2']) - assert 'c\n' in result.stdout - assert 'd\n' in result.stdout - assert 'a\n' not in result.stdout - assert 'b\n' not in result.stdout + assert 'y\n' in result.stdout + assert 'z\n' in result.stdout + assert 'w\n' not in result.stdout + assert 'x\n' not in result.stdout def test_kill(self): self.dispatch(['up', '-d'], None) @@ -2523,9 +2543,9 @@ def get_port(number, index=None): result = self.dispatch(['port', '--index=' + str(index), 'simple', str(number)]) return result.stdout.rstrip() - assert get_port(3000) == containers[0].get_local_port(3000) - assert get_port(3000, index=1) == containers[0].get_local_port(3000) - assert get_port(3000, index=2) == containers[1].get_local_port(3000) + assert get_port(3000) in (containers[0].get_local_port(3000), containers[1].get_local_port(3000)) + assert get_port(3000, index=containers[0].number) == containers[0].get_local_port(3000) + assert get_port(3000, index=containers[1].number) == containers[1].get_local_port(3000) assert get_port(3002) == "" def test_events_json(self): @@ -2561,7 +2581,7 @@ def has_timestamp(string): container, = self.project.containers() expected_template = ' container {} {}' - expected_meta_info = ['image=busybox:latest', 'name=simple-composefile_simple_1'] + expected_meta_info = ['image=busybox:latest', 'name=simple-composefile_simple_'] assert expected_template.format('create', container.id) in lines[0] assert expected_template.format('start', container.id) in lines[1] @@ -2643,8 +2663,11 @@ def test_up_with_extends(self): assert len(containers) == 2 web = containers[1] + db_num = containers[0].short_number - assert set(get_links(web)) == set(['db', 'mydb_1', 'extends_mydb_1']) + assert set(get_links(web)) == set( + ['db', 'mydb_{}'.format(db_num), 'extends_mydb_{}'.format(db_num)] + ) expected_env = set([ "FOO=1", @@ -2677,11 +2700,11 @@ def test_forward_exitval(self): self.base_dir = 'tests/fixtures/exit-code-from' proc = start_process( self.base_dir, - ['up', '--abort-on-container-exit', '--exit-code-from', 'another']) + ['up', '--abort-on-container-exit', '--exit-code-from', 'another'] + ) result = wait_on_process(proc, returncode=1) - - assert 'exit-code-from_another_1 exited with code 1' in result.stdout + assert re.findall(r'exit-code-from_another_[a-f0-9]{12} exited with code 1', result.stdout) def test_exit_code_from_signal_stop(self): self.base_dir = 'tests/fixtures/exit-code-from' @@ -2690,13 +2713,14 @@ def test_exit_code_from_signal_stop(self): ['up', '--abort-on-container-exit', '--exit-code-from', 'simple'] ) result = wait_on_process(proc, returncode=137) # SIGKILL - assert 'exit-code-from_another_1 exited with code 1' in result.stdout + num = self.project.get_service('another').containers(stopped=True)[0].short_number + assert 'exit-code-from_another_{} exited with code 1'.format(num) in result.stdout def test_images(self): self.project.get_service('simple').create_container() result = self.dispatch(['images']) assert 'busybox' in result.stdout - assert 'simple-composefile_simple_1' in result.stdout + assert 'simple-composefile_simple_' in result.stdout def test_images_default_composefile(self): self.base_dir = 'tests/fixtures/multiple-composefiles' @@ -2704,8 +2728,8 @@ def test_images_default_composefile(self): result = self.dispatch(['images']) assert 'busybox' in result.stdout - assert 'multiple-composefiles_another_1' in result.stdout - assert 'multiple-composefiles_simple_1' in result.stdout + assert 'multiple-composefiles_another_' in result.stdout + assert 'multiple-composefiles_simple_' in result.stdout @mock.patch.dict(os.environ) def test_images_tagless_image(self): @@ -2725,7 +2749,7 @@ def test_images_tagless_image(self): self.project.get_service('foo').create_container() result = self.dispatch(['images']) assert '' in result.stdout - assert 'tagless-image_foo_1' in result.stdout + assert 'tagless-image_foo_' in result.stdout def test_up_with_override_yaml(self): self.base_dir = 'tests/fixtures/override-yaml-files' diff --git a/tests/fixtures/logs-tail-composefile/docker-compose.yml b/tests/fixtures/logs-tail-composefile/docker-compose.yml index 80d8feaecdd..b70d0cc6344 100644 --- a/tests/fixtures/logs-tail-composefile/docker-compose.yml +++ b/tests/fixtures/logs-tail-composefile/docker-compose.yml @@ -1,3 +1,3 @@ simple: image: busybox:latest - command: sh -c "echo a && echo b && echo c && echo d" + command: sh -c "echo w && echo x && echo y && echo z" diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 8813e84ce5f..858a8dfd7d2 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -90,7 +90,8 @@ def test_containers_with_service_names(self): project.up() containers = project.containers(['web']) - assert [c.name for c in containers] == ['composetest_web_1'] + assert len(containers) == 1 + assert containers[0].name.startswith('composetest_web_') def test_containers_with_extra_service(self): web = self.create_service('web') @@ -464,14 +465,14 @@ def test_project_up_with_no_recreate_running(self): project.up(['db']) assert len(project.containers()) == 1 - old_db_id = project.containers()[0].id container, = project.containers() + old_db_id = container.id db_volume_path = container.get_mount('/var/db')['Source'] project.up(strategy=ConvergenceStrategy.never) assert len(project.containers()) == 2 - db_container = [c for c in project.containers() if 'db' in c.name][0] + db_container = [c for c in project.containers() if c.name == container.name][0] assert db_container.id == old_db_id assert db_container.get_mount('/var/db')['Source'] == db_volume_path @@ -1944,7 +1945,7 @@ def test_project_up_name_starts_with_illegal_char(self): containers = project.containers(stopped=True) assert len(containers) == 1 - assert containers[0].name == 'underscoretest_svc1_1' + assert containers[0].name.startswith('underscoretest_svc1_') assert containers[0].project == '_underscoretest' full_vol_name = 'underscoretest_foo' @@ -1965,7 +1966,7 @@ def test_project_up_name_starts_with_illegal_char(self): containers = project2.containers(stopped=True) assert len(containers) == 1 - assert containers[0].name == 'dashtest_svc1_1' + assert containers[0].name.startswith('dashtest_svc1_') assert containers[0].project == '-dashtest' full_vol_name = 'dashtest_foo' diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 88123152cab..d7422e2f6f5 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -67,7 +67,7 @@ def test_containers(self): create_and_start_container(foo) assert len(foo.containers()) == 1 - assert foo.containers()[0].name == 'composetest_foo_1' + assert foo.containers()[0].name.startswith('composetest_foo_') assert len(bar.containers()) == 0 create_and_start_container(bar) @@ -77,8 +77,8 @@ def test_containers(self): assert len(bar.containers()) == 2 names = [c.name for c in bar.containers()] - assert 'composetest_bar_1' in names - assert 'composetest_bar_2' in names + assert len(names) == 2 + assert all(name.startswith('composetest_bar_') for name in names) def test_containers_one_off(self): db = self.create_service('db') @@ -89,18 +89,18 @@ def test_containers_one_off(self): def test_project_is_added_to_container_name(self): service = self.create_service('web') create_and_start_container(service) - assert service.containers()[0].name == 'composetest_web_1' + assert service.containers()[0].name.startswith('composetest_web_') def test_create_container_with_one_off(self): db = self.create_service('db') container = db.create_container(one_off=True) - assert container.name == 'composetest_db_run_1' + assert container.name.startswith('composetest_db_run_') def test_create_container_with_one_off_when_existing_container_is_running(self): db = self.create_service('db') db.start() container = db.create_container(one_off=True) - assert container.name == 'composetest_db_run_1' + assert container.name.startswith('composetest_db_run_') def test_create_container_with_unspecified_volume(self): service = self.create_service('db', volumes=[VolumeSpec.parse('/var/db')]) @@ -489,7 +489,7 @@ def test_execute_convergence_plan_recreate(self): assert old_container.get('Config.Entrypoint') == ['top'] assert old_container.get('Config.Cmd') == ['-d', '1'] assert 'FOO=1' in old_container.get('Config.Env') - assert old_container.name == 'composetest_db_1' + assert old_container.name.startswith('composetest_db_') service.start_container(old_container) old_container.inspect() # reload volume data volume_path = old_container.get_mount('/etc')['Source'] @@ -503,7 +503,7 @@ def test_execute_convergence_plan_recreate(self): assert new_container.get('Config.Entrypoint') == ['top'] assert new_container.get('Config.Cmd') == ['-d', '1'] assert 'FOO=2' in new_container.get('Config.Env') - assert new_container.name == 'composetest_db_1' + assert new_container.name.startswith('composetest_db_') assert new_container.get_mount('/etc')['Source'] == volume_path if not is_cluster(self.client): assert ( @@ -836,13 +836,13 @@ def test_start_container_creates_links(self): db = self.create_service('db') web = self.create_service('web', links=[(db, None)]) - create_and_start_container(db) - create_and_start_container(db) + db1 = create_and_start_container(db) + db2 = create_and_start_container(db) create_and_start_container(web) assert set(get_links(web.containers()[0])) == set([ - 'composetest_db_1', 'db_1', - 'composetest_db_2', 'db_2', + db1.name, db1.name_without_project, + db2.name, db2.name_without_project, 'db' ]) @@ -851,30 +851,33 @@ def test_start_container_creates_links_with_names(self): db = self.create_service('db') web = self.create_service('web', links=[(db, 'custom_link_name')]) - create_and_start_container(db) - create_and_start_container(db) + db1 = create_and_start_container(db) + db2 = create_and_start_container(db) create_and_start_container(web) assert set(get_links(web.containers()[0])) == set([ - 'composetest_db_1', 'db_1', - 'composetest_db_2', 'db_2', + db1.name, db1.name_without_project, + db2.name, db2.name_without_project, 'custom_link_name' ]) @no_cluster('No legacy links support in Swarm') def test_start_container_with_external_links(self): db = self.create_service('db') - web = self.create_service('web', external_links=['composetest_db_1', - 'composetest_db_2', - 'composetest_db_3:db_3']) + db_ctnrs = [create_and_start_container(db) for _ in range(3)] + web = self.create_service( + 'web', external_links=[ + 'composetest_db_{}'.format(db_ctnrs[0].short_number), + 'composetest_db_{}'.format(db_ctnrs[1].short_number), + 'composetest_db_{}:db_3'.format(db_ctnrs[2].short_number) + ] + ) - for _ in range(3): - create_and_start_container(db) create_and_start_container(web) assert set(get_links(web.containers()[0])) == set([ - 'composetest_db_1', - 'composetest_db_2', + 'composetest_db_{}'.format(db_ctnrs[0].short_number), + 'composetest_db_{}'.format(db_ctnrs[1].short_number), 'db_3' ]) @@ -892,14 +895,14 @@ def test_start_normal_container_does_not_create_links_to_its_own_service(self): def test_start_one_off_container_creates_links_to_its_own_service(self): db = self.create_service('db') - create_and_start_container(db) - create_and_start_container(db) + db1 = create_and_start_container(db) + db2 = create_and_start_container(db) c = create_and_start_container(db, one_off=OneOffFilter.only) assert set(get_links(c)) == set([ - 'composetest_db_1', 'db_1', - 'composetest_db_2', 'db_2', + db1.name, db1.name_without_project, + db2.name, db2.name_without_project, 'db' ]) @@ -1249,10 +1252,9 @@ def test_scale_with_stopped_containers(self): test that those containers are restarted and not removed/recreated. """ service = self.create_service('web') - next_number = service._next_container_number() - valid_numbers = [next_number, next_number + 1] - service.create_container(number=next_number) - service.create_container(number=next_number + 1) + valid_numbers = [service._next_container_number(), service._next_container_number()] + service.create_container(number=valid_numbers[0]) + service.create_container(number=valid_numbers[1]) ParallelStreamWriter.instance = None with mock.patch('sys.stderr', new_callable=StringIO) as mock_stderr: @@ -1310,10 +1312,8 @@ def test_scale_with_api_error(self): assert len(service.containers()) == 1 assert service.containers()[0].is_running - assert ( - "ERROR: for composetest_web_2 Cannot create container for service" - " web: Boom" in mock_stderr.getvalue() - ) + assert "ERROR: for composetest_web_" in mock_stderr.getvalue() + assert "Cannot create container for service web: Boom" in 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 @@ -1580,7 +1580,6 @@ def test_labels(self): } compose_labels = { - LABEL_CONTAINER_NUMBER: '1', LABEL_ONE_OFF: 'False', LABEL_PROJECT: 'composetest', LABEL_SERVICE: 'web', @@ -1589,9 +1588,11 @@ def test_labels(self): expected = dict(labels_dict, **compose_labels) service = self.create_service('web', labels=labels_dict) - labels = create_and_start_container(service).labels.items() + ctnr = create_and_start_container(service) + labels = ctnr.labels.items() for pair in expected.items(): assert pair in labels + assert ctnr.labels[LABEL_CONTAINER_NUMBER] == ctnr.number def test_empty_labels(self): labels_dict = {'foo': '', 'bar': ''} @@ -1655,7 +1656,7 @@ def test_devices(self): def test_duplicate_containers(self): service = self.create_service('web') - options = service._get_container_create_options({}, 1) + options = service._get_container_create_options({}, service._next_container_number()) original = Container.create(service.client, **options) assert set(service.containers(stopped=True)) == set([original]) diff --git a/tests/integration/state_test.py b/tests/integration/state_test.py index 5992a02a464..7652c06c841 100644 --- a/tests/integration/state_test.py +++ b/tests/integration/state_test.py @@ -55,8 +55,8 @@ def test_no_change(self): def test_partial_change(self): old_containers = self.run_up(self.cfg) - old_db = [c for c in old_containers if c.name_without_project == 'db_1'][0] - old_web = [c for c in old_containers if c.name_without_project == 'web_1'][0] + old_db = [c for c in old_containers if c.name_without_project.startswith('db_')][0] + old_web = [c for c in old_containers if c.name_without_project.startswith('web_')][0] self.cfg['web']['command'] = '/bin/true' @@ -71,7 +71,7 @@ def test_partial_change(self): created = list(new_containers - old_containers) assert len(created) == 1 - assert created[0].name_without_project == 'web_1' + assert created[0].name_without_project == old_web.name_without_project assert created[0].get('Config.Cmd') == ['/bin/true'] def test_all_change(self): @@ -114,7 +114,7 @@ def setUp(self): def test_up(self): containers = self.run_up(self.cfg) - assert set(c.name_without_project for c in containers) == set(['db_1', 'web_1', 'nginx_1']) + assert set(c.service for c in containers) == set(['db', 'web', 'nginx']) def test_change_leaf(self): old_containers = self.run_up(self.cfg) @@ -122,7 +122,7 @@ def test_change_leaf(self): self.cfg['nginx']['environment'] = {'NEW_VAR': '1'} new_containers = self.run_up(self.cfg) - assert set(c.name_without_project for c in new_containers - old_containers) == set(['nginx_1']) + assert set(c.service for c in new_containers - old_containers) == set(['nginx']) def test_change_middle(self): old_containers = self.run_up(self.cfg) @@ -130,7 +130,7 @@ def test_change_middle(self): self.cfg['web']['environment'] = {'NEW_VAR': '1'} new_containers = self.run_up(self.cfg) - assert set(c.name_without_project for c in new_containers - old_containers) == set(['web_1']) + assert set(c.service for c in new_containers - old_containers) == set(['web']) def test_change_middle_always_recreate_deps(self): old_containers = self.run_up(self.cfg, always_recreate_deps=True) @@ -138,8 +138,7 @@ def test_change_middle_always_recreate_deps(self): self.cfg['web']['environment'] = {'NEW_VAR': '1'} new_containers = self.run_up(self.cfg, always_recreate_deps=True) - assert set(c.name_without_project - for c in new_containers - old_containers) == {'web_1', 'nginx_1'} + assert set(c.service for c in new_containers - old_containers) == {'web', 'nginx'} def test_change_root(self): old_containers = self.run_up(self.cfg) @@ -147,7 +146,7 @@ def test_change_root(self): self.cfg['db']['environment'] = {'NEW_VAR': '1'} new_containers = self.run_up(self.cfg) - assert set(c.name_without_project for c in new_containers - old_containers) == set(['db_1']) + assert set(c.service for c in new_containers - old_containers) == set(['db']) def test_change_root_always_recreate_deps(self): old_containers = self.run_up(self.cfg, always_recreate_deps=True) @@ -155,8 +154,9 @@ def test_change_root_always_recreate_deps(self): self.cfg['db']['environment'] = {'NEW_VAR': '1'} new_containers = self.run_up(self.cfg, always_recreate_deps=True) - assert set(c.name_without_project - for c in new_containers - old_containers) == {'db_1', 'web_1', 'nginx_1'} + assert set(c.service for c in new_containers - old_containers) == { + 'db', 'web', 'nginx' + } def test_change_root_no_recreate(self): old_containers = self.run_up(self.cfg) @@ -195,9 +195,18 @@ def test_service_recreated_when_dependency_created(self): web, = [c for c in containers if c.service == 'web'] nginx, = [c for c in containers if c.service == 'nginx'] + db, = [c for c in containers if c.service == 'db'] - assert set(get_links(web)) == {'composetest_db_1', 'db', 'db_1'} - assert set(get_links(nginx)) == {'composetest_web_1', 'web', 'web_1'} + assert set(get_links(web)) == { + 'composetest_db_{}'.format(db.short_number), + 'db', + 'db_{}'.format(db.short_number) + } + assert set(get_links(nginx)) == { + 'composetest_web_{}'.format(web.short_number), + 'web', + 'web_{}'.format(web.short_number) + } class ServiceStateTest(DockerClientTestCase): diff --git a/tests/unit/container_test.py b/tests/unit/container_test.py index d64263c1fd6..4f2f08302a5 100644 --- a/tests/unit/container_test.py +++ b/tests/unit/container_test.py @@ -30,7 +30,7 @@ def setUp(self): "Labels": { "com.docker.compose.project": "composetest", "com.docker.compose.service": "web", - "com.docker.compose.container-number": 7, + "com.docker.compose.container-number": "092cd63296fdc446ad432d3905dd1fcbe12a2ba6b52", }, } } @@ -77,7 +77,7 @@ def test_environment(self): def test_number(self): container = Container(None, self.container_dict, has_been_inspected=True) - assert container.number == 7 + assert container.number == "092cd63296fdc446ad432d3905dd1fcbe12a2ba6b52" def test_name(self): container = Container.from_ps(None, @@ -88,7 +88,7 @@ def test_name(self): def test_name_without_project(self): self.container_dict['Name'] = "/composetest_web_7" container = Container(None, self.container_dict, has_been_inspected=True) - assert container.name_without_project == "web_7" + assert container.name_without_project == "web_092cd63296fd" def test_name_without_project_custom_container_name(self): self.container_dict['Name'] = "/custom_name_of_container" diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 791019a4e3e..ac234e62407 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -41,6 +41,7 @@ from compose.service import Service from compose.service import ServiceNetworkMode from compose.service import warn_on_masked_volume +from compose.utils import generate_random_id as generate_id class ServiceTest(unittest.TestCase): @@ -81,8 +82,7 @@ def test_container_without_name(self): service = Service('db', self.mock_client, 'myproject', image='foo') assert [c.id for c in service.containers()] == ['1'] - assert service._next_container_number() == 2 - assert service.get_container(1).id == '1' + assert service.get_container().id == '1' def test_get_volumes_from_container(self): container_id = 'aabbccddee' @@ -164,7 +164,7 @@ def test_memory_swap_limit(self): client=self.mock_client, mem_limit=1000000000, memswap_limit=2000000000) - service._get_container_create_options({'some': 'overrides'}, 1) + service._get_container_create_options({'some': 'overrides'}, generate_id()) assert self.mock_client.create_host_config.called assert self.mock_client.create_host_config.call_args[1]['mem_limit'] == 1000000000 @@ -173,10 +173,10 @@ def test_memory_swap_limit(self): def test_self_reference_external_link(self): service = Service( name='foo', - external_links=['default_foo_1'] + external_links=['default_foo_bdfa3ed91e2c'] ) with pytest.raises(DependencyError): - service.get_container_name('foo', 1) + service.get_container_name('foo', 'bdfa3ed91e2c') def test_mem_reservation(self): self.mock_client.create_host_config.return_value = {} @@ -188,7 +188,7 @@ def test_mem_reservation(self): client=self.mock_client, mem_reservation='512m' ) - service._get_container_create_options({'some': 'overrides'}, 1) + service._get_container_create_options({'some': 'overrides'}, generate_id()) assert self.mock_client.create_host_config.called is True assert self.mock_client.create_host_config.call_args[1]['mem_reservation'] == '512m' @@ -201,7 +201,7 @@ def test_cgroup_parent(self): hostname='name', client=self.mock_client, cgroup_parent='test') - service._get_container_create_options({'some': 'overrides'}, 1) + service._get_container_create_options({'some': 'overrides'}, generate_id()) assert self.mock_client.create_host_config.called assert self.mock_client.create_host_config.call_args[1]['cgroup_parent'] == 'test' @@ -218,7 +218,7 @@ def test_log_opt(self): client=self.mock_client, log_driver='syslog', logging=logging) - service._get_container_create_options({'some': 'overrides'}, 1) + service._get_container_create_options({'some': 'overrides'}, generate_id()) assert self.mock_client.create_host_config.called assert self.mock_client.create_host_config.call_args[1]['log_config'] == { @@ -233,7 +233,7 @@ def test_stop_grace_period(self): image='foo', client=self.mock_client, stop_grace_period="1m35s") - opts = service._get_container_create_options({'image': 'foo'}, 1) + opts = service._get_container_create_options({'image': 'foo'}, generate_id()) assert opts['stop_timeout'] == 95 def test_split_domainname_none(self): @@ -242,7 +242,7 @@ def test_split_domainname_none(self): image='foo', hostname='name.domain.tld', client=self.mock_client) - opts = service._get_container_create_options({'image': 'foo'}, 1) + opts = service._get_container_create_options({'image': 'foo'}, generate_id()) assert opts['hostname'] == 'name.domain.tld', 'hostname' assert not ('domainname' in opts), 'domainname' @@ -253,7 +253,7 @@ def test_split_domainname_fqdn(self): hostname='name.domain.tld', image='foo', client=self.mock_client) - opts = service._get_container_create_options({'image': 'foo'}, 1) + opts = service._get_container_create_options({'image': 'foo'}, generate_id()) assert opts['hostname'] == 'name', 'hostname' assert opts['domainname'] == 'domain.tld', 'domainname' @@ -265,7 +265,7 @@ def test_split_domainname_both(self): image='foo', domainname='domain.tld', client=self.mock_client) - opts = service._get_container_create_options({'image': 'foo'}, 1) + opts = service._get_container_create_options({'image': 'foo'}, generate_id()) assert opts['hostname'] == 'name', 'hostname' assert opts['domainname'] == 'domain.tld', 'domainname' @@ -277,7 +277,7 @@ def test_split_domainname_weird(self): domainname='domain.tld', image='foo', client=self.mock_client) - opts = service._get_container_create_options({'image': 'foo'}, 1) + opts = service._get_container_create_options({'image': 'foo'}, generate_id()) assert opts['hostname'] == 'name.sub', 'hostname' assert opts['domainname'] == 'domain.tld', 'domainname' @@ -288,7 +288,7 @@ def test_no_default_hostname_when_not_using_networking(self): use_networking=False, client=self.mock_client, ) - opts = service._get_container_create_options({'image': 'foo'}, 1) + opts = service._get_container_create_options({'image': 'foo'}, generate_id()) assert opts.get('hostname') is None def test_get_container_create_options_with_name_option(self): @@ -321,9 +321,8 @@ def test_get_container_create_options_does_not_mutate_options(self): prev_container.get.return_value = None opts = service._get_container_create_options( - {}, - 1, - previous_container=prev_container) + {}, generate_id(), previous_container=prev_container + ) assert service.options['labels'] == labels assert service.options['environment'] == environment @@ -358,7 +357,7 @@ def container_get(key): opts = service._get_container_create_options( {}, - 1, + generate_id(), previous_container=prev_container) assert opts['environment'] == ['affinity:container==ababab'] @@ -373,7 +372,7 @@ def test_get_container_create_options_no_affinity_without_binds(self): opts = service._get_container_create_options( {}, - 1, + generate_id(), previous_container=prev_container) assert opts['environment'] == [] @@ -386,11 +385,11 @@ def test_get_container_not_found(self): @mock.patch('compose.service.Container', autospec=True) def test_get_container(self, mock_container_class): - container_dict = dict(Name='default_foo_2') + container_dict = dict(Name='default_foo_bdfa3ed91e2c') self.mock_client.containers.return_value = [container_dict] service = Service('foo', image='foo', client=self.mock_client) - container = service.get_container(number=2) + container = service.get_container(number="bdfa3ed91e2c") assert container == mock_container_class.from_ps.return_value mock_container_class.from_ps.assert_called_once_with( self.mock_client, container_dict) @@ -463,6 +462,7 @@ def test_pull_image_with_default_platform(self): @mock.patch('compose.service.Container', autospec=True) def test_recreate_container(self, _): mock_container = mock.create_autospec(Container) + mock_container.number = generate_id() service = Service('foo', client=self.mock_client, image='someimage') service.image = lambda: {'Id': 'abc123'} new_container = service.recreate_container(mock_container) @@ -476,6 +476,7 @@ def test_recreate_container(self, _): @mock.patch('compose.service.Container', autospec=True) def test_recreate_container_with_timeout(self, _): mock_container = mock.create_autospec(Container) + mock_container.number = generate_id() self.mock_client.inspect_image.return_value = {'Id': 'abc123'} service = Service('foo', client=self.mock_client, image='someimage') service.recreate_container(mock_container, timeout=1) @@ -711,9 +712,9 @@ def test_config_hash_matches_label(self): for api_version in set(API_VERSIONS.values()): self.mock_client.api_version = api_version - assert service._get_container_create_options({}, 1)['labels'][LABEL_CONFIG_HASH] == ( - config_hash - ) + assert service._get_container_create_options( + {}, generate_id() + )['labels'][LABEL_CONFIG_HASH] == config_hash def test_remove_image_none(self): web = Service('web', image='example', client=self.mock_client) @@ -971,7 +972,7 @@ def test_get_create_options_with_proxy_config(self): service = Service('foo', client=self.mock_client, environment=environment) - create_opts = service._get_container_create_options(override_options, 1) + create_opts = service._get_container_create_options(override_options, generate_id()) assert set(create_opts['environment']) == set(format_environment({ 'HTTP_PROXY': default_proxy_config['httpProxy'], 'http_proxy': default_proxy_config['httpProxy'], @@ -1296,7 +1297,7 @@ def test_mount_same_host_path_to_two_volumes(self): service._get_container_create_options( override_options={}, - number=1, + number=generate_id(), ) assert set(self.mock_client.create_host_config.call_args[1]['binds']) == set([ @@ -1339,7 +1340,7 @@ def test_get_container_create_options_with_different_host_path_in_container_json service._get_container_create_options( override_options={}, - number=1, + number=generate_id(), previous_container=Container(self.mock_client, {'Id': '123123123'}), ) From 5916639383d334145f0566502f80d4152528a158 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 31 Aug 2018 16:18:19 -0700 Subject: [PATCH 3494/4072] Preserve container numbers, add slug to prevent name collisions Signed-off-by: Joffrey F --- compose/cli/main.py | 14 ++--- compose/const.py | 1 + compose/container.py | 13 +++-- compose/project.py | 22 ------- compose/service.py | 96 ++++++++++++++++++------------- script/test/versions.py | 1 - tests/acceptance/cli_test.py | 70 +++++++++++----------- tests/integration/service_test.py | 14 +++-- tests/integration/state_test.py | 8 +-- tests/unit/container_test.py | 7 ++- tests/unit/service_test.py | 59 ++++++++++--------- 11 files changed, 157 insertions(+), 148 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 2cee9e0336a..e0acf07114b 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -474,16 +474,15 @@ def exec_command(self, options): -u, --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. If missing, Compose will pick an - arbitrary container. + --index=index index of the container if there are multiple + instances of a service [default: 1] -e, --env KEY=VAL Set environment variables (can be used multiple times, not supported in API < 1.25) -w, --workdir DIR Path to workdir directory for this command. """ environment = Environment.from_env_file(self.project_dir) use_cli = not environment.get_boolean('COMPOSE_INTERACTIVE_NO_CLI') - index = options.get('--index') + index = int(options.get('--index')) service = self.project.get_service(options['SERVICE']) detach = options.get('--detach') @@ -660,11 +659,10 @@ def port(self, options): Options: --protocol=proto tcp or udp [default: tcp] - --index=index "index" of the container if there are multiple - instances of a service. If missing, Compose will pick an - arbitrary container. + --index=index index of the container if there are multiple + instances of a service [default: 1] """ - index = options.get('--index') + index = int(options.get('--index')) service = self.project.get_service(options['SERVICE']) try: container = service.get_container(number=index) diff --git a/compose/const.py b/compose/const.py index ffb68db01d2..f4b9489e1f1 100644 --- a/compose/const.py +++ b/compose/const.py @@ -15,6 +15,7 @@ LABEL_SERVICE = 'com.docker.compose.service' LABEL_NETWORK = 'com.docker.compose.network' LABEL_VERSION = 'com.docker.compose.version' +LABEL_SLUG = 'com.docker.compose.slug' LABEL_VOLUME = 'com.docker.compose.volume' LABEL_CONFIG_HASH = 'com.docker.compose.config-hash' NANOCPUS_SCALE = 1000000000 diff --git a/compose/container.py b/compose/container.py index 9b5bbba0472..3ee45c8f3f3 100644 --- a/compose/container.py +++ b/compose/container.py @@ -9,6 +9,7 @@ from .const import LABEL_CONTAINER_NUMBER from .const import LABEL_PROJECT from .const import LABEL_SERVICE +from .const import LABEL_SLUG from .const import LABEL_VERSION from .utils import truncate_id from .version import ComposeVersion @@ -81,7 +82,7 @@ def service(self): @property def name_without_project(self): if self.name.startswith('{0}_{1}'.format(self.project, self.service)): - return '{0}_{1}'.format(self.service, self.short_number) + return '{0}_{1}{2}'.format(self.service, self.number, '_' + self.slug if self.slug else '') else: return self.name @@ -91,11 +92,15 @@ def number(self): if not number: raise ValueError("Container {0} does not have a {1} label".format( self.short_id, LABEL_CONTAINER_NUMBER)) - return number + return int(number) @property - def short_number(self): - return truncate_id(self.number) + def slug(self): + return truncate_id(self.full_slug) + + @property + def full_slug(self): + return self.labels.get(LABEL_SLUG) @property def ports(self): diff --git a/compose/project.py b/compose/project.py index 22ef8be4490..4340577c9aa 100644 --- a/compose/project.py +++ b/compose/project.py @@ -31,7 +31,6 @@ from .service import NetworkMode from .service import PidMode from .service import Service -from .service import ServiceName from .service import ServiceNetworkMode from .service import ServicePidMode from .utils import microseconds_from_time_nano @@ -198,25 +197,6 @@ def get_services_without_duplicate(self, service_names=None, include_deps=False) service.remove_duplicate_containers() return services - def get_scaled_services(self, services, scale_override): - """ - Returns a list of this project's services as scaled ServiceName objects. - - services: a list of Service objects - scale_override: a dict with the scale to apply to each service (k: service_name, v: scale) - """ - service_names = [] - for service in services: - if service.name in scale_override: - scale = scale_override[service.name] - else: - scale = service.scale_num - - for i in range(1, scale + 1): - service_names.append(ServiceName(self.name, service.name, i)) - - return service_names - def get_links(self, service_dict): links = [] if 'links' in service_dict: @@ -494,7 +474,6 @@ def up(self, svc.ensure_image_exists(do_build=do_build, silent=silent) plans = self._get_convergence_plans( services, strategy, always_recreate_deps=always_recreate_deps) - scaled_services = self.get_scaled_services(services, scale_override) def do(service): @@ -505,7 +484,6 @@ def do(service): scale_override=scale_override.get(service.name), rescale=rescale, start=start, - project_services=scaled_services, reset_container_image=reset_container_image, renew_anonymous_volumes=renew_anonymous_volumes, ) diff --git a/compose/service.py b/compose/service.py index 5989217d78a..199be8f1fa5 100644 --- a/compose/service.py +++ b/compose/service.py @@ -1,6 +1,7 @@ from __future__ import absolute_import from __future__ import unicode_literals +import itertools import logging import os import re @@ -39,6 +40,7 @@ from .const import LABEL_ONE_OFF from .const import LABEL_PROJECT from .const import LABEL_SERVICE +from .const import LABEL_SLUG from .const import LABEL_VERSION from .const import NANOCPUS_SCALE from .container import Container @@ -123,7 +125,7 @@ class NoSuchImageError(Exception): pass -ServiceName = namedtuple('ServiceName', 'project service number') +ServiceName = namedtuple('ServiceName', 'project service number slug') ConvergencePlan = namedtuple('ConvergencePlan', 'action containers') @@ -216,17 +218,12 @@ def containers(self, stopped=False, one_off=False, filters={}, labels=None): ) ) - def get_container(self, number=None): + def get_container(self, number=1): """Return a :class:`compose.container.Container` for this service. The container must be active, and match `number`. """ - if number is not None and len(number) == 64: - for container in self.containers(labels=['{0}={1}'.format(LABEL_CONTAINER_NUMBER, number)]): - return container - else: - for container in self.containers(): - if number is None or container.number.startswith(number): - return container + for container in self.containers(labels=['{0}={1}'.format(LABEL_CONTAINER_NUMBER, number)]): + return container raise ValueError("No container found for %s_%s" % (self.name, number)) @@ -430,28 +427,33 @@ def _containers_have_diverged(self, containers): return has_diverged - def _execute_convergence_create(self, scale, detached, start, project_services=None): + def _execute_convergence_create(self, scale, detached, start): - def create_and_start(service, n): - container = service.create_container(number=n, quiet=True) - if not detached: - container.attach_log_stream() - if start: - self.start_container(container) - return container + i = self._next_container_number() - containers, errors = parallel_execute( - [ServiceName(self.project, self.name, number) for number in [ - self._next_container_number() for _ in range(scale) - ]], - lambda service_name: create_and_start(self, service_name.number), - lambda service_name: self.get_container_name(service_name.service, service_name.number), - "Creating" - ) - for error in errors.values(): - raise OperationFailedError(error) + def create_and_start(service, n): + container = service.create_container(number=n, quiet=True) + if not detached: + container.attach_log_stream() + if start: + self.start_container(container) + return container - return containers + containers, errors = parallel_execute( + [ + ServiceName(self.project, self.name, index, generate_random_id()) + for index in range(i, i + scale) + ], + lambda service_name: create_and_start(self, service_name.number), + lambda service_name: self.get_container_name( + service_name.service, service_name.number, service_name.slug + ), + "Creating" + ) + for error in errors.values(): + raise OperationFailedError(error) + + return containers def _execute_convergence_recreate(self, containers, scale, timeout, detached, start, renew_anonymous_volumes): @@ -514,8 +516,8 @@ def stop_and_remove(container): def execute_convergence_plan(self, plan, timeout=None, detached=False, start=True, scale_override=None, - rescale=True, project_services=None, - reset_container_image=False, renew_anonymous_volumes=False): + rescale=True, reset_container_image=False, + renew_anonymous_volumes=False): (action, containers) = plan scale = scale_override if scale_override is not None else self.scale_num containers = sorted(containers, key=attrgetter('number')) @@ -524,7 +526,7 @@ def execute_convergence_plan(self, plan, timeout=None, detached=False, if action == 'create': return self._execute_convergence_create( - scale, detached, start, project_services + scale, detached, start ) # The create action needs always needs an initial scale, but otherwise, @@ -730,7 +732,17 @@ def get_volumes_from_names(self): return [s.source.name for s in self.volumes_from if isinstance(s.source, Service)] def _next_container_number(self, one_off=False): - return generate_random_id() + containers = itertools.chain( + self._fetch_containers( + all=True, + filters={'label': self.labels(one_off=one_off)} + ), self._fetch_containers( + all=True, + filters={'label': self.labels(one_off=one_off, legacy=True)} + ) + ) + numbers = [c.number for c in containers] + return 1 if not numbers else max(numbers) + 1 def _fetch_containers(self, **fetch_options): # Account for containers that might have been removed since we fetched @@ -807,6 +819,7 @@ def _get_container_create_options( one_off=False, previous_container=None): add_config_hash = (not one_off and not override_options) + slug = generate_random_id() if previous_container is None else previous_container.full_slug container_options = dict( (k, self.options[k]) @@ -815,7 +828,7 @@ def _get_container_create_options( container_options.update(override_options) if not container_options.get('name'): - container_options['name'] = self.get_container_name(self.name, number, one_off) + container_options['name'] = self.get_container_name(self.name, number, slug, one_off) container_options.setdefault('detach', True) @@ -867,7 +880,9 @@ def _get_container_create_options( container_options.get('labels', {}), self.labels(one_off=one_off), number, - self.config_hash if add_config_hash else None) + self.config_hash if add_config_hash else None, + slug + ) # Delete options which are only used in HostConfig for key in HOST_CONFIG_KEYS: @@ -1105,12 +1120,12 @@ def labels(self, one_off=False, legacy=False): def custom_container_name(self): return self.options.get('container_name') - def get_container_name(self, service_name, number, one_off=False): + def get_container_name(self, service_name, number, slug, one_off=False): if self.custom_container_name and not one_off: return self.custom_container_name container_name = build_container_name( - self.project, service_name, number, one_off, + self.project, service_name, number, slug, one_off, ) ext_links_origins = [l.split(':')[0] for l in self.options.get('external_links', [])] if container_name in ext_links_origins: @@ -1367,11 +1382,13 @@ def mode(self): # Names -def build_container_name(project, service, number, one_off=False): +def build_container_name(project, service, number, slug, one_off=False): bits = [project.lstrip('-_'), service] if one_off: bits.append('run') - return '_'.join(bits + [truncate_id(number)]) + return '_'.join( + bits + ([str(number), truncate_id(slug)] if slug else [str(number)]) + ) # Images @@ -1552,10 +1569,11 @@ def build_mount(mount_spec): # Labels -def build_container_labels(label_options, service_labels, number, config_hash): +def build_container_labels(label_options, service_labels, number, config_hash, slug): labels = dict(label_options or {}) labels.update(label.split('=', 1) for label in service_labels) labels[LABEL_CONTAINER_NUMBER] = str(number) + labels[LABEL_SLUG] = slug labels[LABEL_VERSION] = __version__ if config_hash: diff --git a/script/test/versions.py b/script/test/versions.py index 0dd27538f61..6d273a9e665 100755 --- a/script/test/versions.py +++ b/script/test/versions.py @@ -50,7 +50,6 @@ def parse(cls, version): stage = None elif '-' in stage: edition, stage = stage.split('-') - major, minor, patch = version.split('.', 3) return cls(major, minor, patch, stage, edition) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index a41250d3d03..015180bc779 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -547,16 +547,16 @@ def test_config_compatibility_mode(self): def test_ps(self): self.project.get_service('simple').create_container() result = self.dispatch(['ps']) - assert 'simple-composefile_simple_' in result.stdout + assert 'simple-composefile_simple_1' in result.stdout def test_ps_default_composefile(self): self.base_dir = 'tests/fixtures/multiple-composefiles' self.dispatch(['up', '-d']) result = self.dispatch(['ps']) - assert 'multiple-composefiles_simple_' in result.stdout - assert 'multiple-composefiles_another_' in result.stdout - assert 'multiple-composefiles_yetanother_' not in result.stdout + assert 'multiple-composefiles_simple_1' in result.stdout + assert 'multiple-composefiles_another_1' in result.stdout + assert 'multiple-composefiles_yetanother_1' not in result.stdout def test_ps_alternate_composefile(self): config_path = os.path.abspath( @@ -567,9 +567,9 @@ def test_ps_alternate_composefile(self): self.dispatch(['-f', 'compose2.yml', 'up', '-d']) result = self.dispatch(['-f', 'compose2.yml', 'ps']) - assert 'multiple-composefiles_simple_' not in result.stdout - assert 'multiple-composefiles_another_' not in result.stdout - assert 'multiple-composefiles_yetanother_' in result.stdout + assert 'multiple-composefiles_simple_1' not in result.stdout + assert 'multiple-composefiles_another_1' not in result.stdout + assert 'multiple-composefiles_yetanother_1' in result.stdout def test_ps_services_filter_option(self): self.base_dir = 'tests/fixtures/ps-services-filter' @@ -963,13 +963,13 @@ def test_down(self): assert len(self.project.containers(one_off=OneOffFilter.only, stopped=True)) == 2 result = self.dispatch(['down', '--rmi=local', '--volumes']) - assert 'Stopping v2-full_web_' in result.stderr - assert 'Stopping v2-full_other_' in result.stderr - assert 'Stopping v2-full_web_run_' in result.stderr - assert 'Removing v2-full_web_' in result.stderr - assert 'Removing v2-full_other_' in result.stderr - assert 'Removing v2-full_web_run_' in result.stderr - assert 'Removing v2-full_web_run_' in result.stderr + assert 'Stopping v2-full_web_1' in result.stderr + assert 'Stopping v2-full_other_1' in result.stderr + assert 'Stopping v2-full_web_run_2' in result.stderr + assert 'Removing v2-full_web_1' in result.stderr + assert 'Removing v2-full_other_1' in result.stderr + assert 'Removing v2-full_web_run_1' in result.stderr + assert 'Removing v2-full_web_run_2' in result.stderr assert 'Removing volume v2-full_data' in result.stderr assert 'Removing image v2-full_web' in result.stderr assert 'Removing image busybox' not in result.stderr @@ -1026,13 +1026,15 @@ def test_up_detached_long_form(self): def test_up_attached(self): self.base_dir = 'tests/fixtures/echo-services' result = self.dispatch(['up', '--no-color']) - simple_num = self.project.get_service('simple').containers(stopped=True)[0].short_number - another_num = self.project.get_service('another').containers(stopped=True)[0].short_number + simple_name = self.project.get_service('simple').containers(stopped=True)[0].name_without_project + another_name = self.project.get_service('another').containers( + stopped=True + )[0].name_without_project - assert 'simple_{} | simple'.format(simple_num) in result.stdout - assert 'another_{} | another'.format(another_num) in result.stdout - assert 'simple_{} exited with code 0'.format(simple_num) in result.stdout - assert 'another_{} exited with code 0'.format(another_num) in result.stdout + assert '{} | simple'.format(simple_name) in result.stdout + assert '{} | another'.format(another_name) in result.stdout + assert '{} exited with code 0'.format(simple_name) in result.stdout + assert '{} exited with code 0'.format(another_name) in result.stdout @v2_only() def test_up(self): @@ -2296,24 +2298,24 @@ def test_logs_follow_logs_from_new_containers(self): proc = start_process(self.base_dir, ['logs', '-f']) self.dispatch(['up', '-d', 'another']) - another_num = self.project.get_service('another').get_container().short_number + another_name = self.project.get_service('another').get_container().name_without_project wait_on_condition( ContainerStateCondition( self.project.client, - 'logs-composefile_another_{}'.format(another_num), + 'logs-composefile_another_*', 'exited' ) ) - simple_num = self.project.get_service('simple').get_container().short_number + simple_name = self.project.get_service('simple').get_container().name_without_project self.dispatch(['kill', 'simple']) result = wait_on_process(proc) assert 'hello' in result.stdout assert 'test' in result.stdout - assert 'logs-composefile_another_{} exited with code 0'.format(another_num) in result.stdout - assert 'logs-composefile_simple_{} exited with code 137'.format(simple_num) in result.stdout + assert '{} exited with code 0'.format(another_name) in result.stdout + assert '{} exited with code 137'.format(simple_name) in result.stdout def test_logs_follow_logs_from_restarted_containers(self): self.base_dir = 'tests/fixtures/logs-restart-composefile' @@ -2331,7 +2333,7 @@ def test_logs_follow_logs_from_restarted_containers(self): result = wait_on_process(proc) assert len(re.findall( - r'logs-restart-composefile_another_[a-f0-9]{12} exited with code 1', + r'logs-restart-composefile_another_1_[a-f0-9]{12} exited with code 1', result.stdout )) == 3 assert result.stdout.count('world') == 3 @@ -2663,10 +2665,10 @@ def test_up_with_extends(self): assert len(containers) == 2 web = containers[1] - db_num = containers[0].short_number + db_name = containers[0].name_without_project assert set(get_links(web)) == set( - ['db', 'mydb_{}'.format(db_num), 'extends_mydb_{}'.format(db_num)] + ['db', db_name, 'extends_{}'.format(db_name)] ) expected_env = set([ @@ -2704,7 +2706,7 @@ def test_forward_exitval(self): ) result = wait_on_process(proc, returncode=1) - assert re.findall(r'exit-code-from_another_[a-f0-9]{12} exited with code 1', result.stdout) + assert re.findall(r'exit-code-from_another_1_[a-f0-9]{12} exited with code 1', result.stdout) def test_exit_code_from_signal_stop(self): self.base_dir = 'tests/fixtures/exit-code-from' @@ -2713,8 +2715,8 @@ def test_exit_code_from_signal_stop(self): ['up', '--abort-on-container-exit', '--exit-code-from', 'simple'] ) result = wait_on_process(proc, returncode=137) # SIGKILL - num = self.project.get_service('another').containers(stopped=True)[0].short_number - assert 'exit-code-from_another_{} exited with code 1'.format(num) in result.stdout + name = self.project.get_service('another').containers(stopped=True)[0].name_without_project + assert '{} exited with code 1'.format(name) in result.stdout def test_images(self): self.project.get_service('simple').create_container() @@ -2728,8 +2730,8 @@ def test_images_default_composefile(self): result = self.dispatch(['images']) assert 'busybox' in result.stdout - assert 'multiple-composefiles_another_' in result.stdout - assert 'multiple-composefiles_simple_' in result.stdout + assert 'multiple-composefiles_another_1' in result.stdout + assert 'multiple-composefiles_simple_1' in result.stdout @mock.patch.dict(os.environ) def test_images_tagless_image(self): @@ -2749,7 +2751,7 @@ def test_images_tagless_image(self): self.project.get_service('foo').create_container() result = self.dispatch(['images']) assert '' in result.stdout - assert 'tagless-image_foo_' in result.stdout + assert 'tagless-image_foo_1' in result.stdout def test_up_with_override_yaml(self): self.base_dir = 'tests/fixtures/override-yaml-files' diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index d7422e2f6f5..db40409f806 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -32,6 +32,7 @@ from compose.const import LABEL_ONE_OFF from compose.const import LABEL_PROJECT from compose.const import LABEL_SERVICE +from compose.const import LABEL_SLUG from compose.const import LABEL_VERSION from compose.container import Container from compose.errors import OperationFailedError @@ -867,17 +868,17 @@ def test_start_container_with_external_links(self): db_ctnrs = [create_and_start_container(db) for _ in range(3)] web = self.create_service( 'web', external_links=[ - 'composetest_db_{}'.format(db_ctnrs[0].short_number), - 'composetest_db_{}'.format(db_ctnrs[1].short_number), - 'composetest_db_{}:db_3'.format(db_ctnrs[2].short_number) + db_ctnrs[0].name, + db_ctnrs[1].name, + '{}:db_3'.format(db_ctnrs[2].name) ] ) create_and_start_container(web) assert set(get_links(web.containers()[0])) == set([ - 'composetest_db_{}'.format(db_ctnrs[0].short_number), - 'composetest_db_{}'.format(db_ctnrs[1].short_number), + db_ctnrs[0].name, + db_ctnrs[1].name, 'db_3' ]) @@ -1584,6 +1585,7 @@ def test_labels(self): LABEL_PROJECT: 'composetest', LABEL_SERVICE: 'web', LABEL_VERSION: __version__, + LABEL_CONTAINER_NUMBER: '1' } expected = dict(labels_dict, **compose_labels) @@ -1592,7 +1594,7 @@ def test_labels(self): labels = ctnr.labels.items() for pair in expected.items(): assert pair in labels - assert ctnr.labels[LABEL_CONTAINER_NUMBER] == ctnr.number + assert ctnr.labels[LABEL_SLUG] == ctnr.full_slug def test_empty_labels(self): labels_dict = {'foo': '', 'bar': ''} diff --git a/tests/integration/state_test.py b/tests/integration/state_test.py index 7652c06c841..a41986f4659 100644 --- a/tests/integration/state_test.py +++ b/tests/integration/state_test.py @@ -198,14 +198,14 @@ def test_service_recreated_when_dependency_created(self): db, = [c for c in containers if c.service == 'db'] assert set(get_links(web)) == { - 'composetest_db_{}'.format(db.short_number), + 'composetest_db_{}_{}'.format(db.number, db.slug), 'db', - 'db_{}'.format(db.short_number) + 'db_{}_{}'.format(db.number, db.slug) } assert set(get_links(nginx)) == { - 'composetest_web_{}'.format(web.short_number), + 'composetest_web_{}_{}'.format(web.number, web.slug), 'web', - 'web_{}'.format(web.short_number) + 'web_{}_{}'.format(web.number, web.slug) } diff --git a/tests/unit/container_test.py b/tests/unit/container_test.py index 4f2f08302a5..64c9cc344de 100644 --- a/tests/unit/container_test.py +++ b/tests/unit/container_test.py @@ -30,7 +30,8 @@ def setUp(self): "Labels": { "com.docker.compose.project": "composetest", "com.docker.compose.service": "web", - "com.docker.compose.container-number": "092cd63296fdc446ad432d3905dd1fcbe12a2ba6b52", + "com.docker.compose.container-number": "7", + "com.docker.compose.slug": "092cd63296fdc446ad432d3905dd1fcbe12a2ba6b52" }, } } @@ -77,7 +78,7 @@ def test_environment(self): def test_number(self): container = Container(None, self.container_dict, has_been_inspected=True) - assert container.number == "092cd63296fdc446ad432d3905dd1fcbe12a2ba6b52" + assert container.number == 7 def test_name(self): container = Container.from_ps(None, @@ -88,7 +89,7 @@ def test_name(self): def test_name_without_project(self): self.container_dict['Name'] = "/composetest_web_7" container = Container(None, self.container_dict, has_been_inspected=True) - assert container.name_without_project == "web_092cd63296fd" + assert container.name_without_project == "web_7_092cd63296fd" def test_name_without_project_custom_container_name(self): self.container_dict['Name'] = "/custom_name_of_container" diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index ac234e62407..d5dbcbea6d0 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -41,7 +41,6 @@ from compose.service import Service from compose.service import ServiceNetworkMode from compose.service import warn_on_masked_volume -from compose.utils import generate_random_id as generate_id class ServiceTest(unittest.TestCase): @@ -82,7 +81,8 @@ def test_container_without_name(self): service = Service('db', self.mock_client, 'myproject', image='foo') assert [c.id for c in service.containers()] == ['1'] - assert service.get_container().id == '1' + assert service._next_container_number() == 2 + assert service.get_container(1).id == '1' def test_get_volumes_from_container(self): container_id = 'aabbccddee' @@ -164,7 +164,7 @@ def test_memory_swap_limit(self): client=self.mock_client, mem_limit=1000000000, memswap_limit=2000000000) - service._get_container_create_options({'some': 'overrides'}, generate_id()) + service._get_container_create_options({'some': 'overrides'}, 1) assert self.mock_client.create_host_config.called assert self.mock_client.create_host_config.call_args[1]['mem_limit'] == 1000000000 @@ -173,10 +173,10 @@ def test_memory_swap_limit(self): def test_self_reference_external_link(self): service = Service( name='foo', - external_links=['default_foo_bdfa3ed91e2c'] + external_links=['default_foo_1_bdfa3ed91e2c'] ) with pytest.raises(DependencyError): - service.get_container_name('foo', 'bdfa3ed91e2c') + service.get_container_name('foo', 1, 'bdfa3ed91e2c') def test_mem_reservation(self): self.mock_client.create_host_config.return_value = {} @@ -188,7 +188,7 @@ def test_mem_reservation(self): client=self.mock_client, mem_reservation='512m' ) - service._get_container_create_options({'some': 'overrides'}, generate_id()) + service._get_container_create_options({'some': 'overrides'}, 1) assert self.mock_client.create_host_config.called is True assert self.mock_client.create_host_config.call_args[1]['mem_reservation'] == '512m' @@ -201,7 +201,7 @@ def test_cgroup_parent(self): hostname='name', client=self.mock_client, cgroup_parent='test') - service._get_container_create_options({'some': 'overrides'}, generate_id()) + service._get_container_create_options({'some': 'overrides'}, 1) assert self.mock_client.create_host_config.called assert self.mock_client.create_host_config.call_args[1]['cgroup_parent'] == 'test' @@ -218,7 +218,7 @@ def test_log_opt(self): client=self.mock_client, log_driver='syslog', logging=logging) - service._get_container_create_options({'some': 'overrides'}, generate_id()) + service._get_container_create_options({'some': 'overrides'}, 1) assert self.mock_client.create_host_config.called assert self.mock_client.create_host_config.call_args[1]['log_config'] == { @@ -233,7 +233,7 @@ def test_stop_grace_period(self): image='foo', client=self.mock_client, stop_grace_period="1m35s") - opts = service._get_container_create_options({'image': 'foo'}, generate_id()) + opts = service._get_container_create_options({'image': 'foo'}, 1) assert opts['stop_timeout'] == 95 def test_split_domainname_none(self): @@ -242,7 +242,7 @@ def test_split_domainname_none(self): image='foo', hostname='name.domain.tld', client=self.mock_client) - opts = service._get_container_create_options({'image': 'foo'}, generate_id()) + opts = service._get_container_create_options({'image': 'foo'}, 1) assert opts['hostname'] == 'name.domain.tld', 'hostname' assert not ('domainname' in opts), 'domainname' @@ -253,7 +253,7 @@ def test_split_domainname_fqdn(self): hostname='name.domain.tld', image='foo', client=self.mock_client) - opts = service._get_container_create_options({'image': 'foo'}, generate_id()) + opts = service._get_container_create_options({'image': 'foo'}, 1) assert opts['hostname'] == 'name', 'hostname' assert opts['domainname'] == 'domain.tld', 'domainname' @@ -265,7 +265,7 @@ def test_split_domainname_both(self): image='foo', domainname='domain.tld', client=self.mock_client) - opts = service._get_container_create_options({'image': 'foo'}, generate_id()) + opts = service._get_container_create_options({'image': 'foo'}, 1) assert opts['hostname'] == 'name', 'hostname' assert opts['domainname'] == 'domain.tld', 'domainname' @@ -277,7 +277,7 @@ def test_split_domainname_weird(self): domainname='domain.tld', image='foo', client=self.mock_client) - opts = service._get_container_create_options({'image': 'foo'}, generate_id()) + opts = service._get_container_create_options({'image': 'foo'}, 1) assert opts['hostname'] == 'name.sub', 'hostname' assert opts['domainname'] == 'domain.tld', 'domainname' @@ -288,7 +288,7 @@ def test_no_default_hostname_when_not_using_networking(self): use_networking=False, client=self.mock_client, ) - opts = service._get_container_create_options({'image': 'foo'}, generate_id()) + opts = service._get_container_create_options({'image': 'foo'}, 1) assert opts.get('hostname') is None def test_get_container_create_options_with_name_option(self): @@ -317,11 +317,13 @@ def test_get_container_create_options_does_not_mutate_options(self): self.mock_client.inspect_image.return_value = {'Id': 'abcd'} prev_container = mock.Mock( id='ababab', - image_config={'ContainerConfig': {}}) + image_config={'ContainerConfig': {}} + ) + prev_container.full_slug = 'abcdefff1234' prev_container.get.return_value = None opts = service._get_container_create_options( - {}, generate_id(), previous_container=prev_container + {}, 1, previous_container=prev_container ) assert service.options['labels'] == labels @@ -354,11 +356,13 @@ def container_get(key): }.get(key, None) prev_container.get.side_effect = container_get + prev_container.full_slug = 'abcdefff1234' opts = service._get_container_create_options( {}, - generate_id(), - previous_container=prev_container) + 1, + previous_container=prev_container + ) assert opts['environment'] == ['affinity:container==ababab'] @@ -369,10 +373,11 @@ def test_get_container_create_options_no_affinity_without_binds(self): id='ababab', image_config={'ContainerConfig': {}}) prev_container.get.return_value = None + prev_container.full_slug = 'abcdefff1234' opts = service._get_container_create_options( {}, - generate_id(), + 1, previous_container=prev_container) assert opts['environment'] == [] @@ -385,11 +390,11 @@ def test_get_container_not_found(self): @mock.patch('compose.service.Container', autospec=True) def test_get_container(self, mock_container_class): - container_dict = dict(Name='default_foo_bdfa3ed91e2c') + container_dict = dict(Name='default_foo_2_bdfa3ed91e2c') self.mock_client.containers.return_value = [container_dict] service = Service('foo', image='foo', client=self.mock_client) - container = service.get_container(number="bdfa3ed91e2c") + container = service.get_container(number=2) assert container == mock_container_class.from_ps.return_value mock_container_class.from_ps.assert_called_once_with( self.mock_client, container_dict) @@ -462,7 +467,7 @@ def test_pull_image_with_default_platform(self): @mock.patch('compose.service.Container', autospec=True) def test_recreate_container(self, _): mock_container = mock.create_autospec(Container) - mock_container.number = generate_id() + mock_container.full_slug = 'abcdefff1234' service = Service('foo', client=self.mock_client, image='someimage') service.image = lambda: {'Id': 'abc123'} new_container = service.recreate_container(mock_container) @@ -476,7 +481,7 @@ def test_recreate_container(self, _): @mock.patch('compose.service.Container', autospec=True) def test_recreate_container_with_timeout(self, _): mock_container = mock.create_autospec(Container) - mock_container.number = generate_id() + mock_container.full_slug = 'abcdefff1234' self.mock_client.inspect_image.return_value = {'Id': 'abc123'} service = Service('foo', client=self.mock_client, image='someimage') service.recreate_container(mock_container, timeout=1) @@ -713,7 +718,7 @@ def test_config_hash_matches_label(self): for api_version in set(API_VERSIONS.values()): self.mock_client.api_version = api_version assert service._get_container_create_options( - {}, generate_id() + {}, 1 )['labels'][LABEL_CONFIG_HASH] == config_hash def test_remove_image_none(self): @@ -972,7 +977,7 @@ def test_get_create_options_with_proxy_config(self): service = Service('foo', client=self.mock_client, environment=environment) - create_opts = service._get_container_create_options(override_options, generate_id()) + create_opts = service._get_container_create_options(override_options, 1) assert set(create_opts['environment']) == set(format_environment({ 'HTTP_PROXY': default_proxy_config['httpProxy'], 'http_proxy': default_proxy_config['httpProxy'], @@ -1297,7 +1302,7 @@ def test_mount_same_host_path_to_two_volumes(self): service._get_container_create_options( override_options={}, - number=generate_id(), + number=1, ) assert set(self.mock_client.create_host_config.call_args[1]['binds']) == set([ @@ -1340,7 +1345,7 @@ def test_get_container_create_options_with_different_host_path_in_container_json service._get_container_create_options( override_options={}, - number=generate_id(), + number=1, previous_container=Container(self.mock_client, {'Id': '123123123'}), ) From 265d9dae4b92d590e8809d1c98d5d648e700114e Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 12 Sep 2018 16:17:30 -0700 Subject: [PATCH 3495/4072] Update zsh completion with new options, and ensure service names are properly retrieved Signed-off-by: Joffrey F --- contrib/completion/zsh/_docker-compose | 142 +++++++------------------ 1 file changed, 37 insertions(+), 105 deletions(-) mode change 100644 => 100755 contrib/completion/zsh/_docker-compose diff --git a/contrib/completion/zsh/_docker-compose b/contrib/completion/zsh/_docker-compose old mode 100644 new mode 100755 index 676aa117b0b..eb619983170 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -23,7 +23,7 @@ __docker-compose_all_services_in_compose_file() { local already_selected local -a services already_selected=$(echo $words | tr " " "|") - __docker-compose_q config --services \ + __docker-compose_q ps --services "$@" \ | grep -Ev "^(${already_selected})$" } @@ -31,125 +31,42 @@ __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() { - local already_selected - 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_q config \ - | sed -n -e '/^services:/,/^[^ ]/p' \ - | sed -n 's/^ //p' \ - | awk '/^[a-zA-Z0-9]/{printf "\n"};{printf $0;next;}' \ - | grep " \+$1:" \ - | cut -d: -f1 \ - | grep -Ev "^(${already_selected})$" -} - # All services that are defined by a Dockerfile reference __docker-compose_services_from_build() { [[ $PREFIX = -* ]] && return 1 - integer ret=1 - buildable=$(__docker-compose_services_with_key build) - _alternative "args:buildable services:($buildable)" && ret=0 - - return ret + __docker-compose_services_all --filter source=build } # All services that are defined by an image __docker-compose_services_from_image() { [[ $PREFIX = -* ]] && return 1 - integer ret=1 - pullable=$(__docker-compose_services_with_key image) - _alternative "args:pullable services:($pullable)" && ret=0 - - return ret -} - -__docker-compose_get_services() { - [[ $PREFIX = -* ]] && return 1 - integer ret=1 - local kind - declare -a running paused stopped lines args services - - docker_status=$(docker ps > /dev/null 2>&1) - if [ $? -ne 0 ]; then - _message "Error! Docker is not running." - return 1 - fi - - kind=$1 - shift - [[ $kind =~ (stopped|all) ]] && args=($args -a) - - lines=(${(f)"$(_call_program commands docker $docker_options ps --format 'table' $args)"}) - services=(${(f)"$(_call_program commands docker-compose 2>/dev/null $compose_options ps -q)"}) - - # Parse header line to find columns - local i=1 j=1 k header=${lines[1]} - declare -A begin end - while (( j < ${#header} - 1 )); do - i=$(( j + ${${header[$j,-1]}[(i)[^ ]]} - 1 )) - j=$(( i + ${${header[$i,-1]}[(i) ]} - 1 )) - k=$(( j + ${${header[$j,-1]}[(i)[^ ]]} - 2 )) - begin[${header[$i,$((j-1))]}]=$i - end[${header[$i,$((j-1))]}]=$k - done - lines=(${lines[2,-1]}) - - # Container ID - local line s name - local -a names - for line in $lines; do - if [[ ${services[@]} == *"${line[${begin[CONTAINER ID]},${end[CONTAINER ID]}]%% ##}"* ]]; then - names=(${(ps:,:)${${line[${begin[NAMES]},-1]}%% *}}) - for name in $names; do - s="${${name%_*}#*_}:${(l:15:: :::)${${line[${begin[CREATED]},${end[CREATED]}]/ ago/}%% ##}}" - s="$s, ${line[${begin[CONTAINER ID]},${end[CONTAINER ID]}]%% ##}" - s="$s, ${${${line[${begin[IMAGE]},${end[IMAGE]}]}/:/\\:}%% ##}" - if [[ ${line[${begin[STATUS]},${end[STATUS]}]} = Exit* ]]; then - stopped=($stopped $s) - else - if [[ ${line[${begin[STATUS]},${end[STATUS]}]} = *\(Paused\)* ]]; then - paused=($paused $s) - fi - running=($running $s) - fi - done - fi - done - - [[ $kind =~ (running|all) ]] && _describe -t services-running "running services" running "$@" && ret=0 - [[ $kind =~ (paused|all) ]] && _describe -t services-paused "paused services" paused "$@" && ret=0 - [[ $kind =~ (stopped|all) ]] && _describe -t services-stopped "stopped services" stopped "$@" && ret=0 - - return ret + __docker-compose_services_all --filter source=image } __docker-compose_pausedservices() { [[ $PREFIX = -* ]] && return 1 - __docker-compose_get_services paused "$@" + __docker-compose_services_all --filter status=paused } __docker-compose_stoppedservices() { [[ $PREFIX = -* ]] && return 1 - __docker-compose_get_services stopped "$@" + __docker-compose_services_all --filter status=stopped } __docker-compose_runningservices() { [[ $PREFIX = -* ]] && return 1 - __docker-compose_get_services running "$@" + __docker-compose_services_all --filter status=running } __docker-compose_services() { [[ $PREFIX = -* ]] && return 1 - __docker-compose_get_services all "$@" + __docker-compose_services_all } __docker-compose_caching_policy() { @@ -196,9 +113,10 @@ __docker-compose_subcommand() { $opts_help \ "*--build-arg=[Set build-time variables for one service.]:=: " \ '--force-rm[Always remove intermediate containers.]' \ - '--memory[Memory limit for the build container.]' \ + '(--memory -m)'{--memory,-m}'[Memory limit for the build container.]' \ '--no-cache[Do not use cache when building the image.]' \ '--pull[Always attempt to pull a newer version of the image.]' \ + '--compress[Compress the build context using gzip.]' \ '*:services:__docker-compose_services_from_build' && ret=0 ;; (bundle) @@ -223,11 +141,12 @@ __docker-compose_subcommand() { $opts_no_recreate \ $opts_no_build \ "(--no-build)--build[Build images before creating containers.]" \ - '*:services:__docker-compose_services_all' && ret=0 + '*:services:__docker-compose_services' && ret=0 ;; (down) _arguments \ $opts_help \ + $opts_timeout \ "--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.]" \ $opts_remove_orphans && ret=0 @@ -236,16 +155,18 @@ __docker-compose_subcommand() { _arguments \ $opts_help \ '--json[Output events as a stream of json objects]' \ - '*:services:__docker-compose_services_all' && ret=0 + '*:services:__docker-compose_services' && ret=0 ;; (exec) _arguments \ $opts_help \ '-d[Detached mode: Run command in the background.]' \ '--privileged[Give extended privileges to the process.]' \ - '(-u --user)'{-u,--user=}'[Run the command as this user.]:username:_users' \ + '(-u --user)'{-u,--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: ' \ + '*'{-e,--env}'[KEY=VAL Set an environment variable (can be used multiple times)]:environment variable KEY=VAL: ' \ + '(-w --workdir)'{-w,--workdir=}'[Working directory inside the container]:workdir: ' \ '(-):running services:__docker-compose_runningservices' \ '(-):command: _command_names -e' \ '*::arguments: _normal' && ret=0 @@ -253,12 +174,12 @@ __docker-compose_subcommand() { (help) _arguments ':subcommand:__docker-compose_commands' && ret=0 ;; - (images) - _arguments \ - $opts_help \ - '-q[Only display IDs]' \ - '*:services:__docker-compose_services_all' && ret=0 - ;; + (images) + _arguments \ + $opts_help \ + '-q[Only display IDs]' \ + '*:services:__docker-compose_services' && ret=0 + ;; (kill) _arguments \ $opts_help \ @@ -272,7 +193,7 @@ __docker-compose_subcommand() { $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 + '*:services:__docker-compose_services' && ret=0 ;; (pause) _arguments \ @@ -291,12 +212,16 @@ __docker-compose_subcommand() { _arguments \ $opts_help \ '-q[Only display IDs]' \ - '*:services:__docker-compose_services_all' && ret=0 + '--filter KEY=VAL[Filter services by a property]:=:' \ + '*:services:__docker-compose_services' && ret=0 ;; (pull) _arguments \ $opts_help \ '--ignore-pull-failures[Pull what it can and ignores images with pull failures.]' \ + '--no-parallel[Disable parallel pulling]' \ + '(-q --quiet)'{-q,--quiet}'[Pull without printing progress information]' \ + '--include-deps[Also pull services declared as dependencies]' \ '*:services:__docker-compose_services_from_image' && ret=0 ;; (push) @@ -318,6 +243,7 @@ __docker-compose_subcommand() { $opts_no_deps \ '-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: ' \ + '*'{-l,--label}'[KEY=VAL Add or override a label (can be used multiple times)]:label KEY=VAL: ' \ '--entrypoint[Overwrite the entrypoint of the image.]:entry point: ' \ '--name=[Assign a name to the container]:name: ' \ '(-p --publish)'{-p,--publish=}"[Publish a container's port(s) to the host]" \ @@ -327,6 +253,7 @@ __docker-compose_subcommand() { '(-u --user)'{-u,--user=}'[Run as specified username or uid]:username or uid:_users' \ '(-v --volume)*'{-v,--volume=}'[Bind mount a volume]:volume: ' \ '(-w --workdir)'{-w,--workdir=}'[Working directory inside the container]:workdir: ' \ + "--use-aliases[Use the services network aliases in the network(s) the container connects to]" \ '(-):services:__docker-compose_services' \ '(-):command: _command_names -e' \ '*::arguments: _normal' && ret=0 @@ -370,8 +297,10 @@ __docker-compose_subcommand() { "(--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: " \ + '--scale[SERVICE=NUM Scale SERVICE to NUM instances. Overrides the `scale` setting in the Compose file if present.]:service scale SERVICE=NUM: ' \ + '--exit-code-from=[Return the exit code of the selected service container. Implies --abort-on-container-exit]:service:__docker-compose_services' \ $opts_remove_orphans \ - '*:services:__docker-compose_services_all' && ret=0 + '*:services:__docker-compose_services' && ret=0 ;; (version) _arguments \ @@ -410,8 +339,11 @@ _docker-compose() { '(- :)'{-h,--help}'[Get help]' \ '*'{-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]' \ + "--compatibility[If set, Compose will attempt to convert deploy keys in v3 files to their non-Swarm equivalent]" \ '(- :)'{-v,--version}'[Print version and exit]' \ + '--verbose[Show more output]' \ + '--log-level=[Set log level]:level:(DEBUG INFO WARNING ERROR CRITICAL)' \ + '--no-ansi[Do not print ANSI control characters]' \ '(-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:' \ From a7c05f41f1f213daff93a253412aa30d2c3769c0 Mon Sep 17 00:00:00 2001 From: Maxwell Bloch Date: Wed, 12 Sep 2018 19:11:10 -0400 Subject: [PATCH 3496/4072] Handle userns security - Adds `--userns=host` when `userns-remap` is set Signed-off-by: Maxwell Bloch --- script/run/run.sh | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/script/run/run.sh b/script/run/run.sh index fe253875e23..6b004606c87 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -55,4 +55,9 @@ else DOCKER_RUN_OPTIONS="$DOCKER_RUN_OPTIONS -i" fi +# Handle userns security +if [ ! -z "$(docker info 2>/dev/null | grep userns)" ]; then + DOCKER_RUN_OPTIONS="$DOCKER_RUN_OPTIONS --userns=host" +fi + exec docker run --rm $DOCKER_RUN_OPTIONS $DOCKER_ADDR $COMPOSE_OPTIONS $VOLUMES -w "$(pwd)" $IMAGE "$@" From 9f9122cd9543931760ae6fe4546d7b2e0c8b82cc Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 19 Sep 2018 11:18:09 -0700 Subject: [PATCH 3497/4072] Don't convert slashes for UNIX paths on Windows hosts Signed-off-by: Joffrey F --- compose/config/types.py | 19 ++++++++++++++++--- tests/unit/config/config_test.py | 2 +- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/compose/config/types.py b/compose/config/types.py index ff9875218ef..838fb9f58cf 100644 --- a/compose/config/types.py +++ b/compose/config/types.py @@ -136,6 +136,20 @@ def normalize_path_for_engine(path): return path.replace('\\', '/') +def normpath(path, win_host=False): + """ Custom path normalizer that handles Compose-specific edge cases like + UNIX paths on Windows hosts and vice-versa. """ + + sysnorm = ntpath.normpath if win_host else os.path.normpath + # If a path looks like a UNIX absolute path on Windows, it probably is; + # we'll need to revert the backslashes to forward slashes after normalization + flip_slashes = path.startswith('/') and IS_WINDOWS_PLATFORM + path = sysnorm(path) + if flip_slashes: + path = path.replace('\\', '/') + return path + + class MountSpec(object): options_map = { 'volume': { @@ -152,12 +166,11 @@ class MountSpec(object): @classmethod def parse(cls, mount_dict, normalize=False, win_host=False): - normpath = ntpath.normpath if win_host else os.path.normpath if mount_dict.get('source'): if mount_dict['type'] == 'tmpfs': raise ConfigurationError('tmpfs mounts can not specify a source') - mount_dict['source'] = normpath(mount_dict['source']) + mount_dict['source'] = normpath(mount_dict['source'], win_host) if normalize: mount_dict['source'] = normalize_path_for_engine(mount_dict['source']) @@ -247,7 +260,7 @@ def separate_next_section(volume_config): else: external = parts[0] parts = separate_next_section(parts[1]) - external = ntpath.normpath(external) + external = normpath(external, True) internal = parts[0] if len(parts) > 1: if ':' in parts[1]: diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 08b92a57312..1d42c10d5f4 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1291,7 +1291,7 @@ def test_volumes_long_syntax(self): assert tmpfs_mount.target == '/tmpfs' assert not tmpfs_mount.is_named_volume - assert host_mount.source == os.path.normpath('/abc') + assert host_mount.source == '/abc' assert host_mount.target == '/xyz' assert not host_mount.is_named_volume From 96a49a02534d5bd58f8adcbd4148076d463afea9 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 19 Sep 2018 15:35:22 -0700 Subject: [PATCH 3498/4072] Force consistent behavior around long paths on Windows builds Signed-off-by: Joffrey F --- compose/const.py | 1 + compose/service.py | 20 ++++++++++++++------ 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/compose/const.py b/compose/const.py index f4b9489e1f1..0e66a297a0a 100644 --- a/compose/const.py +++ b/compose/const.py @@ -22,6 +22,7 @@ PARALLEL_LIMIT = 64 SECRETS_PATH = '/run/secrets' +WINDOWS_LONGPATH_PREFIX = '\\\\?\\' COMPOSEFILE_V1 = ComposeVersion('1') COMPOSEFILE_V2_0 = ComposeVersion('2.0') diff --git a/compose/service.py b/compose/service.py index 199be8f1fa5..aca24ce17d3 100644 --- a/compose/service.py +++ b/compose/service.py @@ -43,6 +43,7 @@ from .const import LABEL_SLUG from .const import LABEL_VERSION from .const import NANOCPUS_SCALE +from .const import WINDOWS_LONGPATH_PREFIX from .container import Container from .errors import HealthCheckFailed from .errors import NoHealthCheckConfigured @@ -1048,12 +1049,7 @@ def build(self, no_cache=False, pull=False, force_rm=False, memory=None, build_a for k, v in self._parse_proxy_config().items(): build_args.setdefault(k, v) - # python2 os.stat() doesn't support unicode on some UNIX, so we - # encode it to a bytestring to be safe - path = build_opts.get('context') - if not six.PY3 and not IS_WINDOWS_PLATFORM: - path = path.encode('utf8') - + path = rewrite_build_path(build_opts.get('context')) if self.platform and version_lt(self.client.api_version, '1.35'): raise OperationFailedError( 'Impossible to perform platform-targeted builds for API version < 1.35' @@ -1662,3 +1658,15 @@ def convert_blkio_config(blkio_config): arr.append(dict([(k.capitalize(), v) for k, v in item.items()])) result[field] = arr return result + + +def rewrite_build_path(path): + # 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') + + if IS_WINDOWS_PLATFORM and not path.startswith(WINDOWS_LONGPATH_PREFIX): + path = WINDOWS_LONGPATH_PREFIX + os.path.normpath(path) + + return path From a2ec572fdf45655d5805df0abb0a84e76e6f8bf3 Mon Sep 17 00:00:00 2001 From: Boris HUISGEN Date: Sun, 6 May 2018 12:26:37 +0200 Subject: [PATCH 3499/4072] Use same tag as service definition Signed-off-by: Boris HUISGEN --- compose/cli/main.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index e0acf07114b..69d6ca4dd90 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -579,10 +579,18 @@ def images(self, options): rows = [] for container in containers: image_config = container.image_config - repo_tags = ( - image_config['RepoTags'][0].rsplit(':', 1) if image_config['RepoTags'] - else ('', '') - ) + service = self.project.get_service(container.service) + if service.image_name in image_config['RepoTags']: + index = image_config['RepoTags'].index(service.image_name) + repo_tags = ( + image_config['RepoTags'][index].rsplit(':', 1) if image_config['RepoTags'] + else ('', '') + ) + else: + repo_tags = ( + image_config['RepoTags'][0].rsplit(':', 1) if image_config['RepoTags'] + else ('', '') + ) image_id = image_config['Id'].split(':')[1][:12] size = human_readable_file_size(image_config['Size']) rows.append([ From 1b668973a2f3c28370a77d8af51cd2dc082dda0a Mon Sep 17 00:00:00 2001 From: Boris HUISGEN Date: Thu, 5 Jul 2018 23:33:13 +0200 Subject: [PATCH 3500/4072] Add acceptance test Signed-off-by: Boris HUISGEN --- tests/acceptance/cli_test.py | 13 +++++++++++++ tests/fixtures/images-service-tag/dev/Dockerfile | 2 ++ .../images-service-tag/dev/docker-compose.yml | 6 ++++++ .../fixtures/images-service-tag/docker-compose.yml | 5 +++++ tests/fixtures/images-service-tag/prod/Dockerfile | 2 ++ .../images-service-tag/prod/docker-compose.yml | 6 ++++++ 6 files changed, 34 insertions(+) create mode 100644 tests/fixtures/images-service-tag/dev/Dockerfile create mode 100644 tests/fixtures/images-service-tag/dev/docker-compose.yml create mode 100644 tests/fixtures/images-service-tag/docker-compose.yml create mode 100644 tests/fixtures/images-service-tag/prod/Dockerfile create mode 100644 tests/fixtures/images-service-tag/prod/docker-compose.yml diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 015180bc779..ef7e88990ca 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -2770,3 +2770,16 @@ def test_up_with_duplicate_override_yaml_files(self): with pytest.raises(DuplicateOverrideFileFound): get_project(self.base_dir, []) self.base_dir = None + + def test_images_use_service_tag(self): + pull_busybox(self.client) + self.base_dir = 'tests/fixtures/images-service-tag/dev' + self.dispatch(['build']) + self.base_dir = 'tests/fixtures/images-service-tag/prod' + self.dispatch(['build']) + self.base_dir = 'tests/fixtures/images-service-tag' + self.dispatch(['up', '-d']) + result = self.dispatch(['images']) + self.dispatch(['down']) + + assert 'dev' in result.stdout diff --git a/tests/fixtures/images-service-tag/dev/Dockerfile b/tests/fixtures/images-service-tag/dev/Dockerfile new file mode 100644 index 00000000000..570e11ae363 --- /dev/null +++ b/tests/fixtures/images-service-tag/dev/Dockerfile @@ -0,0 +1,2 @@ +FROM busybox:latest +RUN touch /same-image diff --git a/tests/fixtures/images-service-tag/dev/docker-compose.yml b/tests/fixtures/images-service-tag/dev/docker-compose.yml new file mode 100644 index 00000000000..cb01f6f357a --- /dev/null +++ b/tests/fixtures/images-service-tag/dev/docker-compose.yml @@ -0,0 +1,6 @@ +version: "2.2" + +services: + test: + image: busybox:dev + build: . diff --git a/tests/fixtures/images-service-tag/docker-compose.yml b/tests/fixtures/images-service-tag/docker-compose.yml new file mode 100644 index 00000000000..824b9416b3b --- /dev/null +++ b/tests/fixtures/images-service-tag/docker-compose.yml @@ -0,0 +1,5 @@ +version: "2.2" + +services: + test: + image: busybox:dev diff --git a/tests/fixtures/images-service-tag/prod/Dockerfile b/tests/fixtures/images-service-tag/prod/Dockerfile new file mode 100644 index 00000000000..570e11ae363 --- /dev/null +++ b/tests/fixtures/images-service-tag/prod/Dockerfile @@ -0,0 +1,2 @@ +FROM busybox:latest +RUN touch /same-image diff --git a/tests/fixtures/images-service-tag/prod/docker-compose.yml b/tests/fixtures/images-service-tag/prod/docker-compose.yml new file mode 100644 index 00000000000..cb01f6f357a --- /dev/null +++ b/tests/fixtures/images-service-tag/prod/docker-compose.yml @@ -0,0 +1,6 @@ +version: "2.2" + +services: + test: + image: busybox:dev + build: . From 7d0fb7d3f33a81e39d08871d71f38cc68b51f39f Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 20 Sep 2018 15:34:14 -0700 Subject: [PATCH 3501/4072] Rewrite images command method to decrease complexity Also ensure we properly detect matching image names when tag is omitted Signed-off-by: Joffrey F --- compose/cli/main.py | 70 ++++++++++++++++++++++++--------------------- 1 file changed, 37 insertions(+), 33 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 69d6ca4dd90..f2e76c1ad54 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -568,39 +568,43 @@ def images(self, options): if options['--quiet']: for image in set(c.image for c in containers): print(image.split(':')[1]) - else: - headers = [ - 'Container', - 'Repository', - 'Tag', - 'Image Id', - 'Size' - ] - rows = [] - for container in containers: - image_config = container.image_config - service = self.project.get_service(container.service) - if service.image_name in image_config['RepoTags']: - index = image_config['RepoTags'].index(service.image_name) - repo_tags = ( - image_config['RepoTags'][index].rsplit(':', 1) if image_config['RepoTags'] - else ('', '') - ) - else: - repo_tags = ( - image_config['RepoTags'][0].rsplit(':', 1) if image_config['RepoTags'] - else ('', '') - ) - image_id = image_config['Id'].split(':')[1][:12] - size = human_readable_file_size(image_config['Size']) - rows.append([ - container.name, - repo_tags[0], - repo_tags[1], - image_id, - size - ]) - print(Formatter().table(headers, rows)) + return + + def add_default_tag(img_name): + if ':' not in img_name.split('/')[-1]: + return '{}:latest'.format(img_name) + return img_name + + headers = [ + 'Container', + 'Repository', + 'Tag', + 'Image Id', + 'Size' + ] + rows = [] + for container in containers: + image_config = container.image_config + service = self.project.get_service(container.service) + index = 0 + img_name = add_default_tag(service.image_name) + if img_name in image_config['RepoTags']: + index = image_config['RepoTags'].index(img_name) + repo_tags = ( + image_config['RepoTags'][index].rsplit(':', 1) if image_config['RepoTags'] + else ('', '') + ) + + image_id = image_config['Id'].split(':')[1][:12] + size = human_readable_file_size(image_config['Size']) + rows.append([ + container.name, + repo_tags[0], + repo_tags[1], + image_id, + size + ]) + print(Formatter().table(headers, rows)) def kill(self, options): """ From 834acca49712735ec9e64b39991d8d66989bf1d6 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 20 Sep 2018 15:37:42 -0700 Subject: [PATCH 3502/4072] Update acceptance test for image matching Signed-off-by: Joffrey F --- tests/acceptance/cli_test.py | 11 ++++------- tests/fixtures/images-service-tag/Dockerfile | 2 ++ tests/fixtures/images-service-tag/dev/Dockerfile | 2 -- .../images-service-tag/dev/docker-compose.yml | 6 ------ .../fixtures/images-service-tag/docker-compose.yml | 13 +++++++++---- tests/fixtures/images-service-tag/prod/Dockerfile | 2 -- .../images-service-tag/prod/docker-compose.yml | 6 ------ 7 files changed, 15 insertions(+), 27 deletions(-) create mode 100644 tests/fixtures/images-service-tag/Dockerfile delete mode 100644 tests/fixtures/images-service-tag/dev/Dockerfile delete mode 100644 tests/fixtures/images-service-tag/dev/docker-compose.yml delete mode 100644 tests/fixtures/images-service-tag/prod/Dockerfile delete mode 100644 tests/fixtures/images-service-tag/prod/docker-compose.yml diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index ef7e88990ca..3d063d8539b 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -2773,13 +2773,10 @@ def test_up_with_duplicate_override_yaml_files(self): def test_images_use_service_tag(self): pull_busybox(self.client) - self.base_dir = 'tests/fixtures/images-service-tag/dev' - self.dispatch(['build']) - self.base_dir = 'tests/fixtures/images-service-tag/prod' - self.dispatch(['build']) self.base_dir = 'tests/fixtures/images-service-tag' - self.dispatch(['up', '-d']) + self.dispatch(['up', '-d', '--build']) result = self.dispatch(['images']) - self.dispatch(['down']) - assert 'dev' in result.stdout + assert re.search(r'foo1.+test[ \t]+dev', result.stdout) is not None + assert re.search(r'foo2.+test[ \t]+prod', result.stdout) is not None + assert re.search(r'foo3.+_foo3[ \t]+latest', result.stdout) is not None diff --git a/tests/fixtures/images-service-tag/Dockerfile b/tests/fixtures/images-service-tag/Dockerfile new file mode 100644 index 00000000000..145e0202f05 --- /dev/null +++ b/tests/fixtures/images-service-tag/Dockerfile @@ -0,0 +1,2 @@ +FROM busybox:latest +RUN touch /foo diff --git a/tests/fixtures/images-service-tag/dev/Dockerfile b/tests/fixtures/images-service-tag/dev/Dockerfile deleted file mode 100644 index 570e11ae363..00000000000 --- a/tests/fixtures/images-service-tag/dev/Dockerfile +++ /dev/null @@ -1,2 +0,0 @@ -FROM busybox:latest -RUN touch /same-image diff --git a/tests/fixtures/images-service-tag/dev/docker-compose.yml b/tests/fixtures/images-service-tag/dev/docker-compose.yml deleted file mode 100644 index cb01f6f357a..00000000000 --- a/tests/fixtures/images-service-tag/dev/docker-compose.yml +++ /dev/null @@ -1,6 +0,0 @@ -version: "2.2" - -services: - test: - image: busybox:dev - build: . diff --git a/tests/fixtures/images-service-tag/docker-compose.yml b/tests/fixtures/images-service-tag/docker-compose.yml index 824b9416b3b..aff3cf285a9 100644 --- a/tests/fixtures/images-service-tag/docker-compose.yml +++ b/tests/fixtures/images-service-tag/docker-compose.yml @@ -1,5 +1,10 @@ -version: "2.2" - +version: "2.4" services: - test: - image: busybox:dev + foo1: + build: . + image: test:dev + foo2: + build: . + image: test:prod + foo3: + build: . diff --git a/tests/fixtures/images-service-tag/prod/Dockerfile b/tests/fixtures/images-service-tag/prod/Dockerfile deleted file mode 100644 index 570e11ae363..00000000000 --- a/tests/fixtures/images-service-tag/prod/Dockerfile +++ /dev/null @@ -1,2 +0,0 @@ -FROM busybox:latest -RUN touch /same-image diff --git a/tests/fixtures/images-service-tag/prod/docker-compose.yml b/tests/fixtures/images-service-tag/prod/docker-compose.yml deleted file mode 100644 index cb01f6f357a..00000000000 --- a/tests/fixtures/images-service-tag/prod/docker-compose.yml +++ /dev/null @@ -1,6 +0,0 @@ -version: "2.2" - -services: - test: - image: busybox:dev - build: . From 936e6971f91438394b7fe051ac5ab56797d1feaf Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 24 Sep 2018 23:46:38 +0000 Subject: [PATCH 3503/4072] "Bump 1.23.0-rc1" Signed-off-by: Joffrey F --- CHANGELOG.md | 62 +++++++++++++++++++++++++++++++++++++++++++++ compose/__init__.py | 2 +- script/run/run.sh | 2 +- 3 files changed, 64 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d22c1645434..e9226db234d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,68 @@ Change log ========== +1.23.0 (2018-10-10) +------------------- + +### Features + +### Important note + +The default naming scheme for containers created by Compose in this version +has changed from `__` to +`___`, where `` is a randomly-generated +hexadecimal string. Please make sure to update scripts relying on the old +naming scheme accordingly before upgrading. + +### All versions + +- Logs for containers restarting after a crash will now appear in the output + of the `up` and `logs` commands. + +- Added `--hash` option to the `docker-compose config` command, allowing users + to print a hash string for each service's configuration to facilitate rolling + updates. + +- Output for the `pull` command now reports status / progress even when pulling + multiple images in parallel. + +- For images with multiple names, Compose will now attempt to match the one + present in the service configuration in the output of the `images` command. + +### Bugfixes + +- Parallel `run` commands for the same service will no longer fail due to name + collisions. + +- Fixed an issue where paths longer than 260 characters on Windows clients would + cause `docker-compose build` to fail. + +- Fixed a bug where attempting to mount `/var/run/docker.sock` with + Docker Desktop for Windows would result in failure. + +- The `--project-directory` option is now used by Compose to determine where to + look for the `.env` file. + +- `docker-compose build` no longer fails when attempting to pull an image with + credentials provided by the gcloud credential helper. + +- Fixed the `--exit-code-from` option in `docker-compose up` to always report + the actual exit code even when the watched container isn't the cause of the + exit. + +- Fixed a bug that caused hash configuration with multiple networks to be + inconsistent, causing some services to be unnecessarily restarted. + +- Fixed a pipe handling issue when using the containerized version of Compose. + +- Fixed a bug causing `external: false` entries in the Compose file to be + printed as `external: true` in the output of `docker-compose config` + +### Miscellaneous + +- The `zsh` completion script has been updated with new options, and no + longer suggests container names where service names are expected. + 1.22.0 (2018-07-17) ------------------- diff --git a/compose/__init__.py b/compose/__init__.py index 3433b63cc0d..f0e3f3274e2 100644 --- a/compose/__init__.py +++ b/compose/__init__.py @@ -1,4 +1,4 @@ from __future__ import absolute_import from __future__ import unicode_literals -__version__ = '1.23.0dev' +__version__ = '1.23.0-rc1' diff --git a/script/run/run.sh b/script/run/run.sh index 6b004606c87..fa2248609a0 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.22.0" +VERSION="1.23.0-rc1" IMAGE="docker/compose:$VERSION" From ec4ea8d2f14a72a31b6d14d54b274159c39095a6 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 25 Sep 2018 00:46:52 +0000 Subject: [PATCH 3504/4072] "Bump 1.23.0-rc1" Signed-off-by: Joffrey F --- CHANGELOG.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9226db234d..3f212809011 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,6 @@ Change log 1.23.0 (2018-10-10) ------------------- -### Features - ### Important note The default naming scheme for containers created by Compose in this version @@ -14,7 +12,7 @@ has changed from `__` to hexadecimal string. Please make sure to update scripts relying on the old naming scheme accordingly before upgrading. -### All versions +### Features - Logs for containers restarting after a crash will now appear in the output of the `up` and `logs` commands. From 4b4c250638eb1649e3672b829c47f9e5e2ef6191 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 24 Sep 2018 20:05:40 -0700 Subject: [PATCH 3505/4072] Fix some release script issues Signed-off-by: Joffrey F --- script/release/release.py | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/script/release/release.py b/script/release/release.py index c6dc146a76e..749ea49d3f1 100755 --- a/script/release/release.py +++ b/script/release/release.py @@ -77,19 +77,24 @@ def monitor_pr_status(pr_data): 'pending': 0, 'success': 0, 'failure': 0, + 'error': 0, } for detail in status.statuses: if detail.context == 'dco-signed': # dco-signed check breaks on merge remote-tracking ; ignore it continue - summary[detail.state] += 1 - print('{pending} pending, {success} successes, {failure} failures'.format(**summary)) - if summary['pending'] == 0 and summary['failure'] == 0 and summary['success'] > 0: + if detail.state in summary: + summary[detail.state] += 1 + print( + '{pending} pending, {success} successes, {failure} failures, ' + '{error} errors'.format(**summary) + ) + if summary['failure'] > 0 or summary['error'] > 0: + raise ScriptError('CI failures detected!') + elif summary['pending'] == 0 and summary['success'] > 0: # This check assumes at least 1 non-DCO CI check to avoid race conditions. # If testing on a repo without CI, use --skip-ci-check to avoid looping eternally return True - elif summary['failure'] > 0: - raise ScriptError('CI failures detected!') time.sleep(30) elif status.state == 'success': print('{} successes: all clear!'.format(status.total_count)) @@ -97,12 +102,14 @@ def monitor_pr_status(pr_data): def check_pr_mergeable(pr_data): - if not pr_data.mergeable: + if pr_data.mergeable is False: + # mergeable can also be null, in which case the warning would be a false positive. print( 'WARNING!! PR #{} can not currently be merged. You will need to ' 'resolve the conflicts manually before finalizing the release.'.format(pr_data.number) ) - return pr_data.mergeable + + return pr_data.mergeable is True def create_release_draft(repository, version, pr_data, files): From cc2462e6f4e475bc18bd6cc3be01d9a2ef1cb1d9 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 25 Sep 2018 09:13:12 -0700 Subject: [PATCH 3506/4072] Don't rely on container names containing the db string to identify them Signed-off-by: Joffrey F --- tests/integration/project_test.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 858a8dfd7d2..63939676e78 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -432,7 +432,7 @@ def test_recreate_preserves_volumes(self): project.up(strategy=ConvergenceStrategy.always) assert len(project.containers()) == 2 - db_container = [c for c in project.containers() if 'db' in c.name][0] + db_container = [c for c in project.containers() if c.service == 'db'][0] assert db_container.id != old_db_id assert db_container.get('Volumes./etc') == db_volume_path @@ -452,7 +452,7 @@ def test_recreate_preserves_mounts(self): project.up(strategy=ConvergenceStrategy.always) assert len(project.containers()) == 2 - db_container = [c for c in project.containers() if 'db' in c.name][0] + db_container = [c for c in project.containers() if c.service == 'db'][0] assert db_container.id != old_db_id assert db_container.get_mount('/etc')['Source'] == db_volume_path @@ -499,7 +499,7 @@ def test_project_up_with_no_recreate_stopped(self): assert len(new_containers) == 2 assert [c.is_running for c in new_containers] == [True, True] - db_container = [c for c in new_containers if 'db' in c.name][0] + db_container = [c for c in new_containers if c.service == 'db'][0] assert db_container.id == old_db_id assert db_container.get_mount('/var/db')['Source'] == db_volume_path From 47d740b800addf285bcbf96a4ce35918724a4ba6 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 24 Sep 2018 20:05:40 -0700 Subject: [PATCH 3507/4072] Fix some release script issues Signed-off-by: Joffrey F --- script/release/release.py | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/script/release/release.py b/script/release/release.py index c6dc146a76e..749ea49d3f1 100755 --- a/script/release/release.py +++ b/script/release/release.py @@ -77,19 +77,24 @@ def monitor_pr_status(pr_data): 'pending': 0, 'success': 0, 'failure': 0, + 'error': 0, } for detail in status.statuses: if detail.context == 'dco-signed': # dco-signed check breaks on merge remote-tracking ; ignore it continue - summary[detail.state] += 1 - print('{pending} pending, {success} successes, {failure} failures'.format(**summary)) - if summary['pending'] == 0 and summary['failure'] == 0 and summary['success'] > 0: + if detail.state in summary: + summary[detail.state] += 1 + print( + '{pending} pending, {success} successes, {failure} failures, ' + '{error} errors'.format(**summary) + ) + if summary['failure'] > 0 or summary['error'] > 0: + raise ScriptError('CI failures detected!') + elif summary['pending'] == 0 and summary['success'] > 0: # This check assumes at least 1 non-DCO CI check to avoid race conditions. # If testing on a repo without CI, use --skip-ci-check to avoid looping eternally return True - elif summary['failure'] > 0: - raise ScriptError('CI failures detected!') time.sleep(30) elif status.state == 'success': print('{} successes: all clear!'.format(status.total_count)) @@ -97,12 +102,14 @@ def monitor_pr_status(pr_data): def check_pr_mergeable(pr_data): - if not pr_data.mergeable: + if pr_data.mergeable is False: + # mergeable can also be null, in which case the warning would be a false positive. print( 'WARNING!! PR #{} can not currently be merged. You will need to ' 'resolve the conflicts manually before finalizing the release.'.format(pr_data.number) ) - return pr_data.mergeable + + return pr_data.mergeable is True def create_release_draft(repository, version, pr_data, files): From c327a498b03dfcc4137d3e05c904ab620eb12b90 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 25 Sep 2018 09:13:12 -0700 Subject: [PATCH 3508/4072] Don't rely on container names containing the db string to identify them Signed-off-by: Joffrey F --- tests/integration/project_test.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 858a8dfd7d2..63939676e78 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -432,7 +432,7 @@ def test_recreate_preserves_volumes(self): project.up(strategy=ConvergenceStrategy.always) assert len(project.containers()) == 2 - db_container = [c for c in project.containers() if 'db' in c.name][0] + db_container = [c for c in project.containers() if c.service == 'db'][0] assert db_container.id != old_db_id assert db_container.get('Volumes./etc') == db_volume_path @@ -452,7 +452,7 @@ def test_recreate_preserves_mounts(self): project.up(strategy=ConvergenceStrategy.always) assert len(project.containers()) == 2 - db_container = [c for c in project.containers() if 'db' in c.name][0] + db_container = [c for c in project.containers() if c.service == 'db'][0] assert db_container.id != old_db_id assert db_container.get_mount('/etc')['Source'] == db_volume_path @@ -499,7 +499,7 @@ def test_project_up_with_no_recreate_stopped(self): assert len(new_containers) == 2 assert [c.is_running for c in new_containers] == [True, True] - db_container = [c for c in new_containers if 'db' in c.name][0] + db_container = [c for c in new_containers if c.service == 'db'][0] assert db_container.id == old_db_id assert db_container.get_mount('/var/db')['Source'] == db_volume_path From 879f7cb1edf8b15b393d7e65c00ee4852f0ffcb0 Mon Sep 17 00:00:00 2001 From: Antony MECHIN Date: Mon, 24 Sep 2018 15:55:55 +0200 Subject: [PATCH 3509/4072] tests.unit.config: Make make_service_dict working dir argument optional. Signed-off-by: Antony MECHIN --- tests/unit/config/config_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 1d42c10d5f4..c054c388e17 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -42,7 +42,7 @@ DEFAULT_VERSION = V2_0 -def make_service_dict(name, service_dict, working_dir, filename=None): +def make_service_dict(name, service_dict, working_dir='.', filename=None): """Test helper function to construct a ServiceExtendsResolver """ resolver = config.ServiceExtendsResolver( From bbcfce40290a42de4f9658e8463d605b1242edd2 Mon Sep 17 00:00:00 2001 From: Antony MECHIN Date: Mon, 24 Sep 2018 15:59:02 +0200 Subject: [PATCH 3510/4072] tests.unit.config: Make sure volume order is preserved. Signed-off-by: Antony MECHIN --- tests/unit/config/config_test.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index c054c388e17..52c89a9e0c6 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -8,6 +8,7 @@ import shutil import tempfile from operator import itemgetter +from random import shuffle import py import pytest @@ -3536,6 +3537,13 @@ def test_volume_binding_with_environment_variable(self): ).services[0] assert d['volumes'] == [VolumeSpec.parse('/host/path:/container/path')] + @pytest.mark.skipif(IS_WINDOWS_PLATFORM, reason='posix paths') + def test_volumes_order_is_preserved(self): + volumes = ['/{0}:/{0}'.format(i) for i in range(0, 6)] + shuffle(volumes) + cfg = make_service_dict('foo', {'build': '.', 'volumes': volumes}) + assert cfg['volumes'] == volumes + @pytest.mark.skipif(IS_WINDOWS_PLATFORM, reason='posix paths') @mock.patch.dict(os.environ) def test_volume_binding_with_home(self): From de1958c5ff74b811b2766501eababee765d56677 Mon Sep 17 00:00:00 2001 From: Antony MECHIN Date: Mon, 24 Sep 2018 18:08:17 +0200 Subject: [PATCH 3511/4072] utils: Add unique_everseen (from itertools recipies). Signed-off-by: Antony MECHIN --- compose/utils.py | 9 +++++++++ tests/unit/utils_test.py | 6 ++++++ 2 files changed, 15 insertions(+) diff --git a/compose/utils.py b/compose/utils.py index 8f0b3e54979..b9b6ab9bdb5 100644 --- a/compose/utils.py +++ b/compose/utils.py @@ -170,3 +170,12 @@ def truncate_id(value): if len(value) > 12: return value[:12] return value + + +def unique_everseen(iterable, key=lambda x: x): + "List unique elements, preserving order. Remember all elements ever seen." + seen = set() + for element in iterable: + if key(element) not in seen: + seen.add(element) + yield element diff --git a/tests/unit/utils_test.py b/tests/unit/utils_test.py index 84becb97554..186b6b14e2c 100644 --- a/tests/unit/utils_test.py +++ b/tests/unit/utils_test.py @@ -68,3 +68,9 @@ def test_parse_bytes(self): assert utils.parse_bytes(123) == 123 assert utils.parse_bytes('foobar') is None assert utils.parse_bytes('123') == 123 + + +class TestMoreItertools(object): + def test_unique_everseen(self): + assert list(utils.unique_everseen([2, 1, 2, 1])) == [2, 1] + assert list(utils.unique_everseen([2, 1, 2, 1], hash)) == [2, 1] From 39b051885052c556e1c1fe7e6c8de2147d906742 Mon Sep 17 00:00:00 2001 From: Antony MECHIN Date: Mon, 24 Sep 2018 16:57:49 +0200 Subject: [PATCH 3512/4072] tests.unity.service: Make sure volumes order is preserved. Signed-off-by: Antony MECHIN --- compose/service.py | 6 ++++-- tests/unit/service_test.py | 17 +++++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/compose/service.py b/compose/service.py index aca24ce17d3..8df061b9e15 100644 --- a/compose/service.py +++ b/compose/service.py @@ -56,6 +56,7 @@ from .utils import parse_bytes from .utils import parse_seconds_float from .utils import truncate_id +from .utils import unique_everseen log = logging.getLogger(__name__) @@ -940,8 +941,9 @@ def _build_container_volume_options(self, previous_container, container_options, override_options['mounts'] = override_options.get('mounts') or [] override_options['mounts'].extend([build_mount(v) for v in secret_volumes]) - # Remove possible duplicates (see e.g. https://github.com/docker/compose/issues/5885) - override_options['binds'] = list(set(binds)) + # Remove possible duplicates (see e.g. https://github.com/docker/compose/issues/5885). + # unique_everseen preserves order. (see https://github.com/docker/compose/issues/6091). + override_options['binds'] = list(unique_everseen(binds)) return container_options, override_options def _get_container_host_config(self, override_options, one_off=False): diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index d5dbcbea6d0..af1cd1beae1 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -1037,6 +1037,23 @@ def test_build_volume_options_duplicate_binds(self): assert len(override_opts['binds']) == 1 assert override_opts['binds'][0] == 'vol:/data:rw' + def test_volumes_order_is_preserved(self): + service = Service('foo', client=self.mock_client) + volumes = [ + VolumeSpec.parse(cfg) for cfg in [ + '/v{0}:/v{0}:rw'.format(i) for i in range(6) + ] + ] + ctnr_opts, override_opts = service._build_container_volume_options( + previous_container=None, + container_options={ + 'volumes': volumes, + 'environment': {}, + }, + override_options={}, + ) + assert override_opts['binds'] == [vol.repr() for vol in volumes] + class TestServiceNetwork(unittest.TestCase): def setUp(self): From bf46a6cc600828c3aab55ed51a267bfb0fc0c35f Mon Sep 17 00:00:00 2001 From: Antony MECHIN Date: Wed, 26 Sep 2018 15:15:59 +0200 Subject: [PATCH 3513/4072] service: Use OrderedDict to preserve volumes order on versions prior 3.6. Signed-off-by: Antony MECHIN --- compose/service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/service.py b/compose/service.py index 8df061b9e15..3327c77f889 100644 --- a/compose/service.py +++ b/compose/service.py @@ -1429,7 +1429,7 @@ def merge_volume_bindings(volumes, tmpfs, previous_container, mounts): """ affinity = {} - volume_bindings = dict( + volume_bindings = OrderedDict( build_volume_binding(volume) for volume in volumes if volume.external From 772a3071922d3ed6055eb0083026c82ee0d7f195 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 26 Sep 2018 13:44:42 -0700 Subject: [PATCH 3514/4072] Avoid cred helpers errors in release script Signed-off-by: Joffrey F --- script/release/README.md | 6 ++++++ script/release/release.sh | 14 ++++++++++++-- script/release/release/images.py | 8 ++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/script/release/README.md b/script/release/README.md index c5136c764f1..65883f5d365 100644 --- a/script/release/README.md +++ b/script/release/README.md @@ -20,6 +20,12 @@ following repositories: - docker/compose - docker/compose-tests +### A local Python environment + +While most of the release script is running inside a Docker container, +fetching local Docker credentials depends on the `docker` Python package +being available locally. + ### A Github account and Github API token Your Github account needs to have write access on the `docker/compose` repo. diff --git a/script/release/release.sh b/script/release/release.sh index 2011826571a..ee75b13a632 100755 --- a/script/release/release.sh +++ b/script/release/release.sh @@ -15,9 +15,19 @@ if test -z $BINTRAY_TOKEN; then exit 1 fi -docker run -e GITHUB_TOKEN=$GITHUB_TOKEN -e BINTRAY_TOKEN=$BINTRAY_TOKEN -e SSH_AUTH_SOCK=$SSH_AUTH_SOCK -it \ +if test -z $(python -c "import docker; print(docker.version)" 2>/dev/null); then + echo "This script requires the 'docker' Python package to be installed locally" + exit 1 +fi + +hub_credentials=$(python -c "from docker import auth; cfg = auth.load_config(); print(auth.encode_header(auth.resolve_authconfig(cfg, 'docker.io')).decode('ascii'))") + +docker run -it \ + -e GITHUB_TOKEN=$GITHUB_TOKEN \ + -e BINTRAY_TOKEN=$BINTRAY_TOKEN \ + -e SSH_AUTH_SOCK=$SSH_AUTH_SOCK \ + -e HUB_CREDENTIALS=$hub_credentials \ --mount type=bind,source=$(pwd),target=/src \ - --mount type=bind,source=$HOME/.docker,target=/root/.docker \ --mount type=bind,source=$HOME/.gitconfig,target=/root/.gitconfig \ --mount type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock \ --mount type=bind,source=$HOME/.ssh,target=/root/.ssh \ diff --git a/script/release/release/images.py b/script/release/release/images.py index b8f7ed3d6f1..e247f596dcc 100644 --- a/script/release/release/images.py +++ b/script/release/release/images.py @@ -2,6 +2,8 @@ from __future__ import print_function from __future__ import unicode_literals +import base64 +import json import os import shutil @@ -15,6 +17,12 @@ class ImageManager(object): def __init__(self, version): self.docker_client = docker.APIClient(**docker.utils.kwargs_from_env()) self.version = version + if 'HUB_CREDENTIALS' in os.environ: + print('HUB_CREDENTIALS found in environment, issuing login') + credentials = json.loads(base64.urlsafe_b64decode(os.environ['HUB_CREDENTIALS'])) + self.docker_client.login( + username=credentials['Username'], password=credentials['Password'] + ) def build_images(self, repository, files): print("Building release images...") From 320e4819d873a857630f41064f6bb6f76ee0ff31 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 26 Sep 2018 13:44:42 -0700 Subject: [PATCH 3515/4072] Avoid cred helpers errors in release script Signed-off-by: Joffrey F --- script/release/README.md | 6 ++++++ script/release/release.sh | 14 ++++++++++++-- script/release/release/images.py | 8 ++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/script/release/README.md b/script/release/README.md index c5136c764f1..65883f5d365 100644 --- a/script/release/README.md +++ b/script/release/README.md @@ -20,6 +20,12 @@ following repositories: - docker/compose - docker/compose-tests +### A local Python environment + +While most of the release script is running inside a Docker container, +fetching local Docker credentials depends on the `docker` Python package +being available locally. + ### A Github account and Github API token Your Github account needs to have write access on the `docker/compose` repo. diff --git a/script/release/release.sh b/script/release/release.sh index 2011826571a..ee75b13a632 100755 --- a/script/release/release.sh +++ b/script/release/release.sh @@ -15,9 +15,19 @@ if test -z $BINTRAY_TOKEN; then exit 1 fi -docker run -e GITHUB_TOKEN=$GITHUB_TOKEN -e BINTRAY_TOKEN=$BINTRAY_TOKEN -e SSH_AUTH_SOCK=$SSH_AUTH_SOCK -it \ +if test -z $(python -c "import docker; print(docker.version)" 2>/dev/null); then + echo "This script requires the 'docker' Python package to be installed locally" + exit 1 +fi + +hub_credentials=$(python -c "from docker import auth; cfg = auth.load_config(); print(auth.encode_header(auth.resolve_authconfig(cfg, 'docker.io')).decode('ascii'))") + +docker run -it \ + -e GITHUB_TOKEN=$GITHUB_TOKEN \ + -e BINTRAY_TOKEN=$BINTRAY_TOKEN \ + -e SSH_AUTH_SOCK=$SSH_AUTH_SOCK \ + -e HUB_CREDENTIALS=$hub_credentials \ --mount type=bind,source=$(pwd),target=/src \ - --mount type=bind,source=$HOME/.docker,target=/root/.docker \ --mount type=bind,source=$HOME/.gitconfig,target=/root/.gitconfig \ --mount type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock \ --mount type=bind,source=$HOME/.ssh,target=/root/.ssh \ diff --git a/script/release/release/images.py b/script/release/release/images.py index b8f7ed3d6f1..e247f596dcc 100644 --- a/script/release/release/images.py +++ b/script/release/release/images.py @@ -2,6 +2,8 @@ from __future__ import print_function from __future__ import unicode_literals +import base64 +import json import os import shutil @@ -15,6 +17,12 @@ class ImageManager(object): def __init__(self, version): self.docker_client = docker.APIClient(**docker.utils.kwargs_from_env()) self.version = version + if 'HUB_CREDENTIALS' in os.environ: + print('HUB_CREDENTIALS found in environment, issuing login') + credentials = json.loads(base64.urlsafe_b64decode(os.environ['HUB_CREDENTIALS'])) + self.docker_client.login( + username=credentials['Username'], password=credentials['Password'] + ) def build_images(self, repository, files): print("Building release images...") From b29ffb49e9405135768a39d7c96ec667d8fa18d6 Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Thu, 27 Sep 2018 08:46:37 +0200 Subject: [PATCH 3516/4072] Fix bash completion for `config --hash` Signed-off-by: Harald Albers --- contrib/completion/bash/docker-compose | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index f4c42362c2a..395888d347e 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -136,7 +136,18 @@ _docker_compose_bundle() { _docker_compose_config() { - COMPREPLY=( $( compgen -W "--help --quiet -q --resolve-image-digests --services --volumes --hash" -- "$cur" ) ) + case "$prev" in + --hash) + if [[ $cur == \\* ]] ; then + COMPREPLY=( '\*' ) + else + COMPREPLY=( $(compgen -W "$(__docker_compose_services) \\\* " -- "$cur") ) + fi + return + ;; + esac + + COMPREPLY=( $( compgen -W "--hash --help --quiet -q --resolve-image-digests --services --volumes" -- "$cur" ) ) } From 5b9b519e8a7c6441dcd980405c3b105944ff5a1a Mon Sep 17 00:00:00 2001 From: Antony MECHIN Date: Thu, 27 Sep 2018 13:58:38 +0200 Subject: [PATCH 3517/4072] utils: Fix typo in unique_everseen. Signed-off-by: Antony MECHIN --- compose/utils.py | 5 +++-- tests/unit/utils_test.py | 6 ++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/compose/utils.py b/compose/utils.py index b9b6ab9bdb5..72e6ced17ae 100644 --- a/compose/utils.py +++ b/compose/utils.py @@ -176,6 +176,7 @@ def unique_everseen(iterable, key=lambda x: x): "List unique elements, preserving order. Remember all elements ever seen." seen = set() for element in iterable: - if key(element) not in seen: - seen.add(element) + unique_key = key(element) + if unique_key not in seen: + seen.add(unique_key) yield element diff --git a/tests/unit/utils_test.py b/tests/unit/utils_test.py index 186b6b14e2c..21b88d962c1 100644 --- a/tests/unit/utils_test.py +++ b/tests/unit/utils_test.py @@ -72,5 +72,7 @@ def test_parse_bytes(self): class TestMoreItertools(object): def test_unique_everseen(self): - assert list(utils.unique_everseen([2, 1, 2, 1])) == [2, 1] - assert list(utils.unique_everseen([2, 1, 2, 1], hash)) == [2, 1] + unique = utils.unique_everseen + assert list(unique([2, 1, 2, 1])) == [2, 1] + assert list(unique([2, 1, 2, 1], hash)) == [2, 1] + assert list(unique([2, 1, 2, 1], lambda x: 'key_%s' % x)) == [2, 1] From 15089886c2b261d75545bfc853d55804596fe13a Mon Sep 17 00:00:00 2001 From: Emil Hessman Date: Sat, 29 Sep 2018 18:30:52 +0200 Subject: [PATCH 3518/4072] Avoid modifying mutable default value Rationale: http://effbot.org/zone/default-values.htm Signed-off-by: Emil Hessman --- compose/service.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/compose/service.py b/compose/service.py index 3327c77f889..5ae2ac3af8f 100644 --- a/compose/service.py +++ b/compose/service.py @@ -197,7 +197,9 @@ def __init__( def __repr__(self): return ''.format(self.name) - def containers(self, stopped=False, one_off=False, filters={}, labels=None): + def containers(self, stopped=False, one_off=False, filters=None, labels=None): + if filters is None: + filters = {} filters.update({'label': self.labels(one_off=one_off) + (labels or [])}) result = list(filter(None, [ From 8493540a1c6b010ec743cc0c4297c366491b96ac Mon Sep 17 00:00:00 2001 From: Gabriel Machado Date: Sat, 29 Sep 2018 20:08:00 -0300 Subject: [PATCH 3519/4072] Reffer Docker for Mac and Windows as Docker Desktop Signed-off-by: Gabriel Machado --- script/release/release.md.tmpl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/script/release/release.md.tmpl b/script/release/release.md.tmpl index ee97ef104ea..4d0ebe926eb 100644 --- a/script/release/release.md.tmpl +++ b/script/release/release.md.tmpl @@ -1,6 +1,6 @@ -If you're a Mac or Windows user, the best way to install Compose and keep it up-to-date is **[Docker for Mac and Windows](https://www.docker.com/products/docker)**. +If you're a Mac or Windows user, the best way to install Compose and keep it up-to-date is **[Docker Desktop for Mac and Windows](https://www.docker.com/products/docker-desktop)**. -Docker for Mac and Windows will automatically install the latest version of Docker Engine for you. +Docker Desktop will automatically install the latest version of Docker Engine for you. Alternatively, you can use the usual commands to install or upgrade Compose: From abf67565f63a7dad1c0e51ceb55d203b9d18d069 Mon Sep 17 00:00:00 2001 From: Heath Milligan Date: Tue, 2 Oct 2018 14:25:33 -0400 Subject: [PATCH 3520/4072] Show more helpful error message when Docker is not running. Fixes #6175 Signed-off-by: Heath Milligan --- compose/cli/errors.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/compose/cli/errors.py b/compose/cli/errors.py index 82768970b30..b48ccf4d7d1 100644 --- a/compose/cli/errors.py +++ b/compose/cli/errors.py @@ -67,7 +67,9 @@ def handle_connection_errors(client): def log_windows_pipe_error(exc): - if exc.winerror == 232: # https://github.com/docker/compose/issues/5005 + if exc.winerror == 2: + log.error("Couldn't connect to Docker daemon. You might need to start Docker for Windows.") + elif exc.winerror == 232: # https://github.com/docker/compose/issues/5005 log.error( "The current Compose file version is not compatible with your engine version. " "Please upgrade your Compose file to a more recent version, or set " From 25e419c763128bbf3b9104acc1b322bab809ee68 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 4 Oct 2018 00:48:53 -0700 Subject: [PATCH 3521/4072] Fix twine upload for RC versions Signed-off-by: Joffrey F --- script/release/release.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/script/release/release.py b/script/release/release.py index 749ea49d3f1..9a5af3aa563 100755 --- a/script/release/release.py +++ b/script/release/release.py @@ -173,9 +173,10 @@ def distclean(): def pypi_upload(args): print('Uploading to PyPi') try: + rel = args.release.replace('-rc', 'rc') twine_upload([ - 'dist/docker_compose-{}*.whl'.format(args.release), - 'dist/docker-compose-{}*.tar.gz'.format(args.release) + 'dist/docker_compose-{}*.whl'.format(rel), + 'dist/docker-compose-{}*.tar.gz'.format(rel) ]) except HTTPError as e: if e.response.status_code == 400 and 'File already exists' in e.message: From cc595a65f04487b77d4779b9580b573b50ddcdb6 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 4 Oct 2018 01:09:48 -0700 Subject: [PATCH 3522/4072] Don't attempt iterating on None during parallel pull Signed-off-by: Joffrey F --- compose/project.py | 11 +++++------ compose/utils.py | 6 ++++++ tests/integration/project_test.py | 17 +++++++++++++++++ 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/compose/project.py b/compose/project.py index 4340577c9aa..92c352050af 100644 --- a/compose/project.py +++ b/compose/project.py @@ -34,6 +34,7 @@ from .service import ServiceNetworkMode from .service import ServicePidMode from .utils import microseconds_from_time_nano +from .utils import truncate_string from .volume import ProjectVolumes @@ -554,12 +555,10 @@ def pull(self, service_names=None, ignore_pull_failures=False, parallel_pull=Fal if parallel_pull: def pull_service(service): strm = service.pull(ignore_pull_failures, True, stream=True) - writer = parallel.get_stream_writer() + if strm is None: # Attempting to pull service with no `image` key is a no-op + return - def trunc(s): - if len(s) > 35: - return s[:33] + '...' - return s + writer = parallel.get_stream_writer() for event in strm: if 'status' not in event: @@ -572,7 +571,7 @@ def trunc(s): status = '{} ({:.1%})'.format(status, percentage) writer.write( - msg, service.name, trunc(status), lambda s: s + msg, service.name, truncate_string(status), lambda s: s ) _, errors = parallel.parallel_execute( diff --git a/compose/utils.py b/compose/utils.py index 72e6ced17ae..9f0441d08f2 100644 --- a/compose/utils.py +++ b/compose/utils.py @@ -180,3 +180,9 @@ def unique_everseen(iterable, key=lambda x: x): if unique_key not in seen: seen.add(unique_key) yield element + + +def truncate_string(s, max_chars=35): + if len(s) > max_chars: + return s[:max_chars - 2] + '...' + return s diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 63939676e78..57f3b70748e 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -105,6 +105,23 @@ def test_containers_with_extra_service(self): project = Project('composetest', [web, db], self.client) assert set(project.containers(stopped=True)) == set([web_1, db_1]) + def test_parallel_pull_with_no_image(self): + config_data = build_config( + version=V2_3, + services=[{ + 'name': 'web', + 'build': {'context': '.'}, + }], + ) + + project = Project.from_config( + name='composetest', + config_data=config_data, + client=self.client + ) + + project.pull(parallel_pull=True) + def test_volumes_from_service(self): project = Project.from_config( name='composetest', From b21a06cd6f657e94193581fcc9a58006254a4b41 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 4 Oct 2018 01:40:39 -0700 Subject: [PATCH 3523/4072] Re-enable testing of TP and beta releases Signed-off-by: Joffrey F --- script/test/versions.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/script/test/versions.py b/script/test/versions.py index 6d273a9e665..a06c49f20b0 100755 --- a/script/test/versions.py +++ b/script/test/versions.py @@ -36,6 +36,8 @@ GITHUB_API = 'https://api.github.com/repos' +STAGES = ['tp', 'beta', 'rc'] + class Version(namedtuple('_Version', 'major minor patch stage edition')): @@ -45,7 +47,7 @@ def parse(cls, version): version = version.lstrip('v') version, _, stage = version.partition('-') if stage: - if not any(marker in stage for marker in ['rc', 'tp', 'beta']): + if not any(marker in stage for marker in STAGES): edition = stage stage = None elif '-' in stage: @@ -62,8 +64,16 @@ def order(self): """Return a representation that allows this object to be sorted correctly with the default comparator. """ - # rc releases should appear before official releases - stage = (0, self.stage) if self.stage else (1, ) + # non-GA releases should appear before GA releases + # Order: tp -> beta -> rc -> GA + if self.stage: + for st in STAGES: + if st in self.stage: + stage = (STAGES.index(st), self.stage) + break + else: + stage = (len(STAGES),) + return (int(self.major), int(self.minor), int(self.patch)) + stage def __str__(self): @@ -124,9 +134,6 @@ def get_versions(tags): v = Version.parse(tag['name']) if v in BLACKLIST: continue - # FIXME: Temporary. Remove once these versions are built on dockerswarm/dind - if v.stage and 'rc' not in v.stage: - continue yield v except ValueError: print("Skipping invalid tag: {name}".format(**tag), file=sys.stderr) From 9d7202d12256d8672b0ddfa6840877e551323f08 Mon Sep 17 00:00:00 2001 From: Alexander Date: Fri, 5 Oct 2018 14:52:56 +0600 Subject: [PATCH 3524/4072] Squashed commit of the following: commit d3fbd3d630099dc0d34cb1a93b0a664f633a1c25 Author: zasca Date: Wed Oct 3 11:27:43 2018 +0600 Fix typo in function name, path separator updated commit bc3f03cd9a7702b3f2d96b18380d75e10f18def0 Author: zasca Date: Tue Oct 2 11:12:28 2018 +0600 Fix endswith arg in the test commit 602d2977b4e881850c99c7555bc284690a802815 Author: zasca Date: Mon Oct 1 12:24:17 2018 +0600 Update test commit 6cd7a4a2c411ddf9b8e7d91194c60fb2238db8d7 Author: zasca Date: Fri Sep 28 11:13:36 2018 +0600 Fix last test commit 0d37343433caceec18ea15babf924b5975b83c80 Author: zasca Date: Fri Sep 28 10:58:57 2018 +0600 Unit test added commit fc086e544677dd33bad798c773cb92600aaefc51 Author: zasca Date: Thu Sep 27 20:28:03 2018 +0600 Improved expanding source paths of volumes defined with long syntax when paths starts with '~' Signed-off-by: Alexander --- compose/config/config.py | 2 +- tests/unit/config/config_test.py | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/compose/config/config.py b/compose/config/config.py index 7abab254681..fb2c742f47b 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -1281,7 +1281,7 @@ def resolve_volume_paths(working_dir, service_dict): def resolve_volume_path(working_dir, volume): if isinstance(volume, dict): - if volume.get('source', '').startswith('.') and volume['type'] == 'bind': + if volume.get('source', '').startswith(('.', '~')) and volume['type'] == 'bind': volume['source'] = expand_path(working_dir, volume['source']) return volume diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 1d42c10d5f4..8f98a7515a5 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1322,6 +1322,29 @@ def test_load_bind_mount_relative_path(self): assert mount.type == 'bind' assert mount.source == expected_source + def test_load_bind_mount_relative_path_with_tilde(self): + base_file = config.ConfigFile( + 'base.yaml', { + 'version': '3.4', + 'services': { + 'web': { + 'image': 'busybox:latest', + 'volumes': [ + {'type': 'bind', 'source': '~/web', 'target': '/web'}, + ], + }, + }, + }, + ) + + details = config.ConfigDetails('.', [base_file]) + config_data = config.load(details) + mount = config_data.services[0].get('volumes')[0] + assert mount.target == '/web' + assert mount.type == 'bind' + assert (not mount.source.startswith('~') + and mount.source.endswith('{}web'.format(os.path.sep))) + def test_config_invalid_ipam_config(self): with pytest.raises(ConfigurationError) as excinfo: config.load( From 6a35663781779f298c2032fa620c08c2b890ef36 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 5 Oct 2018 08:21:39 -0700 Subject: [PATCH 3525/4072] Decontainerize release script Credentials management inside containers is a mess. Let's work on the host instead. Signed-off-by: Joffrey F --- script/release/Dockerfile | 15 --------------- script/release/README.md | 21 ++++++++++++++------ script/release/release.sh | 37 ++++++++---------------------------- script/release/setup-venv.sh | 30 +++++++++++++++++++++++++++++ 4 files changed, 53 insertions(+), 50 deletions(-) delete mode 100644 script/release/Dockerfile create mode 100755 script/release/setup-venv.sh diff --git a/script/release/Dockerfile b/script/release/Dockerfile deleted file mode 100644 index e5af676a50a..00000000000 --- a/script/release/Dockerfile +++ /dev/null @@ -1,15 +0,0 @@ -FROM python:3.6 -RUN mkdir -p /src && pip install -U Jinja2==2.10 \ - PyGithub==1.39 \ - pypandoc==1.4 \ - GitPython==2.1.9 \ - requests==2.18.4 \ - twine==1.11.0 && \ - apt-get update && apt-get install -y pandoc - -VOLUME /src/script/release -WORKDIR /src -COPY . /src -RUN python setup.py develop -ENTRYPOINT ["python", "script/release/release.py"] -CMD ["--help"] diff --git a/script/release/README.md b/script/release/README.md index 65883f5d365..f7f911e5378 100644 --- a/script/release/README.md +++ b/script/release/README.md @@ -9,8 +9,7 @@ The following things are required to bring a release to a successful conclusion ### Local Docker engine (Linux Containers) -The release script runs inside a container and builds images that will be part -of the release. +The release script builds images that will be part of the release. ### Docker Hub account @@ -20,11 +19,9 @@ following repositories: - docker/compose - docker/compose-tests -### A local Python environment +### Python -While most of the release script is running inside a Docker container, -fetching local Docker credentials depends on the `docker` Python package -being available locally. +The release script is written in Python and requires Python 3.3 at minimum. ### A Github account and Github API token @@ -59,6 +56,18 @@ Said account needs to be a member of the maintainers group for the Moreover, the `~/.pypirc` file should exist on your host and contain the relevant pypi credentials. +The following is a sample `.pypirc` provided as a guideline: + +``` +[distutils] +index-servers = + pypi + +[pypi] +username = user +password = pass +``` + ## Start a feature release A feature release is a release that includes all changes present in the diff --git a/script/release/release.sh b/script/release/release.sh index ee75b13a632..7947316e0b6 100755 --- a/script/release/release.sh +++ b/script/release/release.sh @@ -1,36 +1,15 @@ #!/bin/sh -docker image inspect compose/release-tool > /dev/null -if test $? -ne 0; then - docker build -t compose/release-tool -f $(pwd)/script/release/Dockerfile $(pwd) +if test -d ./.release-venv; then + true +else + ./script/release/setup-venv.sh fi -if test -z $GITHUB_TOKEN; then - echo "GITHUB_TOKEN environment variable must be set" - exit 1 -fi - -if test -z $BINTRAY_TOKEN; then - echo "BINTRAY_TOKEN environment variable must be set" - exit 1 -fi +args=$* -if test -z $(python -c "import docker; print(docker.version)" 2>/dev/null); then - echo "This script requires the 'docker' Python package to be installed locally" - exit 1 +if test -z $args; then + args="--help" fi -hub_credentials=$(python -c "from docker import auth; cfg = auth.load_config(); print(auth.encode_header(auth.resolve_authconfig(cfg, 'docker.io')).decode('ascii'))") - -docker run -it \ - -e GITHUB_TOKEN=$GITHUB_TOKEN \ - -e BINTRAY_TOKEN=$BINTRAY_TOKEN \ - -e SSH_AUTH_SOCK=$SSH_AUTH_SOCK \ - -e HUB_CREDENTIALS=$hub_credentials \ - --mount type=bind,source=$(pwd),target=/src \ - --mount type=bind,source=$HOME/.gitconfig,target=/root/.gitconfig \ - --mount type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock \ - --mount type=bind,source=$HOME/.ssh,target=/root/.ssh \ - --mount type=bind,source=/tmp,target=/tmp \ - -v $HOME/.pypirc:/root/.pypirc \ - compose/release-tool $* +./.release-venv/bin/python ./script/release/release.py $args diff --git a/script/release/setup-venv.sh b/script/release/setup-venv.sh new file mode 100755 index 00000000000..d3d3f9a42ac --- /dev/null +++ b/script/release/setup-venv.sh @@ -0,0 +1,30 @@ +#!/bin/bash + +if test -z $PYTHONBIN; then + PYTHONBIN=$(which python3) + if test -z $PYTHONBIN; then + PYTHONBIN=$(which python) + fi +fi + +VERSION=$($PYTHONBIN -c "import sys; print('{}.{}'.format(*sys.version_info[0:2]))") +if test $(echo $VERSION | cut -d. -f1) -lt 3; then + echo "Python 3.3 or above is required" +fi + +if test $(echo $VERSION | cut -d. -f2) -lt 3; then + echo "Python 3.3 or above is required" +fi + +$PYTHONBIN -m venv ./.release-venv + +VENVBINS=./.release-venv/bin + +$VENVBINS/pip install -U Jinja2==2.10 \ + PyGithub==1.39 \ + pypandoc==1.4 \ + GitPython==2.1.9 \ + requests==2.18.4 \ + twine==1.11.0 + +$VENVBINS/python setup.py develop From 62aeb767d393bacc047b5a46bcab0eae524f53dc Mon Sep 17 00:00:00 2001 From: Antony MECHIN Date: Mon, 24 Sep 2018 15:55:55 +0200 Subject: [PATCH 3526/4072] tests.unit.config: Make make_service_dict working dir argument optional. Signed-off-by: Antony MECHIN --- tests/unit/config/config_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 1d42c10d5f4..c054c388e17 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -42,7 +42,7 @@ DEFAULT_VERSION = V2_0 -def make_service_dict(name, service_dict, working_dir, filename=None): +def make_service_dict(name, service_dict, working_dir='.', filename=None): """Test helper function to construct a ServiceExtendsResolver """ resolver = config.ServiceExtendsResolver( From bb87a3d040b515f32ae349986c131697aa09ca29 Mon Sep 17 00:00:00 2001 From: Antony MECHIN Date: Mon, 24 Sep 2018 15:59:02 +0200 Subject: [PATCH 3527/4072] tests.unit.config: Make sure volume order is preserved. Signed-off-by: Antony MECHIN --- tests/unit/config/config_test.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index c054c388e17..52c89a9e0c6 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -8,6 +8,7 @@ import shutil import tempfile from operator import itemgetter +from random import shuffle import py import pytest @@ -3536,6 +3537,13 @@ def test_volume_binding_with_environment_variable(self): ).services[0] assert d['volumes'] == [VolumeSpec.parse('/host/path:/container/path')] + @pytest.mark.skipif(IS_WINDOWS_PLATFORM, reason='posix paths') + def test_volumes_order_is_preserved(self): + volumes = ['/{0}:/{0}'.format(i) for i in range(0, 6)] + shuffle(volumes) + cfg = make_service_dict('foo', {'build': '.', 'volumes': volumes}) + assert cfg['volumes'] == volumes + @pytest.mark.skipif(IS_WINDOWS_PLATFORM, reason='posix paths') @mock.patch.dict(os.environ) def test_volume_binding_with_home(self): From 18c2d08011fbf7f97d574f10f84ef68717d68ad8 Mon Sep 17 00:00:00 2001 From: Antony MECHIN Date: Mon, 24 Sep 2018 18:08:17 +0200 Subject: [PATCH 3528/4072] utils: Add unique_everseen (from itertools recipies). Signed-off-by: Antony MECHIN --- compose/utils.py | 9 +++++++++ tests/unit/utils_test.py | 6 ++++++ 2 files changed, 15 insertions(+) diff --git a/compose/utils.py b/compose/utils.py index 8f0b3e54979..b9b6ab9bdb5 100644 --- a/compose/utils.py +++ b/compose/utils.py @@ -170,3 +170,12 @@ def truncate_id(value): if len(value) > 12: return value[:12] return value + + +def unique_everseen(iterable, key=lambda x: x): + "List unique elements, preserving order. Remember all elements ever seen." + seen = set() + for element in iterable: + if key(element) not in seen: + seen.add(element) + yield element diff --git a/tests/unit/utils_test.py b/tests/unit/utils_test.py index 84becb97554..186b6b14e2c 100644 --- a/tests/unit/utils_test.py +++ b/tests/unit/utils_test.py @@ -68,3 +68,9 @@ def test_parse_bytes(self): assert utils.parse_bytes(123) == 123 assert utils.parse_bytes('foobar') is None assert utils.parse_bytes('123') == 123 + + +class TestMoreItertools(object): + def test_unique_everseen(self): + assert list(utils.unique_everseen([2, 1, 2, 1])) == [2, 1] + assert list(utils.unique_everseen([2, 1, 2, 1], hash)) == [2, 1] From d5c314b382ae966e3483fd58f5bca4b1fe7dadfb Mon Sep 17 00:00:00 2001 From: Antony MECHIN Date: Mon, 24 Sep 2018 16:57:49 +0200 Subject: [PATCH 3529/4072] tests.unity.service: Make sure volumes order is preserved. Signed-off-by: Antony MECHIN --- compose/service.py | 6 ++++-- tests/unit/service_test.py | 17 +++++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/compose/service.py b/compose/service.py index aca24ce17d3..8df061b9e15 100644 --- a/compose/service.py +++ b/compose/service.py @@ -56,6 +56,7 @@ from .utils import parse_bytes from .utils import parse_seconds_float from .utils import truncate_id +from .utils import unique_everseen log = logging.getLogger(__name__) @@ -940,8 +941,9 @@ def _build_container_volume_options(self, previous_container, container_options, override_options['mounts'] = override_options.get('mounts') or [] override_options['mounts'].extend([build_mount(v) for v in secret_volumes]) - # Remove possible duplicates (see e.g. https://github.com/docker/compose/issues/5885) - override_options['binds'] = list(set(binds)) + # Remove possible duplicates (see e.g. https://github.com/docker/compose/issues/5885). + # unique_everseen preserves order. (see https://github.com/docker/compose/issues/6091). + override_options['binds'] = list(unique_everseen(binds)) return container_options, override_options def _get_container_host_config(self, override_options, one_off=False): diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index d5dbcbea6d0..af1cd1beae1 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -1037,6 +1037,23 @@ def test_build_volume_options_duplicate_binds(self): assert len(override_opts['binds']) == 1 assert override_opts['binds'][0] == 'vol:/data:rw' + def test_volumes_order_is_preserved(self): + service = Service('foo', client=self.mock_client) + volumes = [ + VolumeSpec.parse(cfg) for cfg in [ + '/v{0}:/v{0}:rw'.format(i) for i in range(6) + ] + ] + ctnr_opts, override_opts = service._build_container_volume_options( + previous_container=None, + container_options={ + 'volumes': volumes, + 'environment': {}, + }, + override_options={}, + ) + assert override_opts['binds'] == [vol.repr() for vol in volumes] + class TestServiceNetwork(unittest.TestCase): def setUp(self): From b64184e388380a6498c3cd9ab15b5da3059b4421 Mon Sep 17 00:00:00 2001 From: Antony MECHIN Date: Wed, 26 Sep 2018 15:15:59 +0200 Subject: [PATCH 3530/4072] service: Use OrderedDict to preserve volumes order on versions prior 3.6. Signed-off-by: Antony MECHIN --- compose/service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/service.py b/compose/service.py index 8df061b9e15..3327c77f889 100644 --- a/compose/service.py +++ b/compose/service.py @@ -1429,7 +1429,7 @@ def merge_volume_bindings(volumes, tmpfs, previous_container, mounts): """ affinity = {} - volume_bindings = dict( + volume_bindings = OrderedDict( build_volume_binding(volume) for volume in volumes if volume.external From eb86881af17ac4255acd50745a83e10899591da5 Mon Sep 17 00:00:00 2001 From: Antony MECHIN Date: Thu, 27 Sep 2018 13:58:38 +0200 Subject: [PATCH 3531/4072] utils: Fix typo in unique_everseen. Signed-off-by: Antony MECHIN --- compose/utils.py | 5 +++-- tests/unit/utils_test.py | 6 ++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/compose/utils.py b/compose/utils.py index b9b6ab9bdb5..72e6ced17ae 100644 --- a/compose/utils.py +++ b/compose/utils.py @@ -176,6 +176,7 @@ def unique_everseen(iterable, key=lambda x: x): "List unique elements, preserving order. Remember all elements ever seen." seen = set() for element in iterable: - if key(element) not in seen: - seen.add(element) + unique_key = key(element) + if unique_key not in seen: + seen.add(unique_key) yield element diff --git a/tests/unit/utils_test.py b/tests/unit/utils_test.py index 186b6b14e2c..21b88d962c1 100644 --- a/tests/unit/utils_test.py +++ b/tests/unit/utils_test.py @@ -72,5 +72,7 @@ def test_parse_bytes(self): class TestMoreItertools(object): def test_unique_everseen(self): - assert list(utils.unique_everseen([2, 1, 2, 1])) == [2, 1] - assert list(utils.unique_everseen([2, 1, 2, 1], hash)) == [2, 1] + unique = utils.unique_everseen + assert list(unique([2, 1, 2, 1])) == [2, 1] + assert list(unique([2, 1, 2, 1], hash)) == [2, 1] + assert list(unique([2, 1, 2, 1], lambda x: 'key_%s' % x)) == [2, 1] From 30c91388f31712b47196dd5bfe6352655df12089 Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Thu, 27 Sep 2018 08:46:37 +0200 Subject: [PATCH 3532/4072] Fix bash completion for `config --hash` Signed-off-by: Harald Albers --- contrib/completion/bash/docker-compose | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index f4c42362c2a..395888d347e 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -136,7 +136,18 @@ _docker_compose_bundle() { _docker_compose_config() { - COMPREPLY=( $( compgen -W "--help --quiet -q --resolve-image-digests --services --volumes --hash" -- "$cur" ) ) + case "$prev" in + --hash) + if [[ $cur == \\* ]] ; then + COMPREPLY=( '\*' ) + else + COMPREPLY=( $(compgen -W "$(__docker_compose_services) \\\* " -- "$cur") ) + fi + return + ;; + esac + + COMPREPLY=( $( compgen -W "--hash --help --quiet -q --resolve-image-digests --services --volumes" -- "$cur" ) ) } From 970f8317c51a53431307dd799c63880de5d2151c Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 4 Oct 2018 00:48:53 -0700 Subject: [PATCH 3533/4072] Fix twine upload for RC versions Signed-off-by: Joffrey F --- script/release/release.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/script/release/release.py b/script/release/release.py index 749ea49d3f1..9a5af3aa563 100755 --- a/script/release/release.py +++ b/script/release/release.py @@ -173,9 +173,10 @@ def distclean(): def pypi_upload(args): print('Uploading to PyPi') try: + rel = args.release.replace('-rc', 'rc') twine_upload([ - 'dist/docker_compose-{}*.whl'.format(args.release), - 'dist/docker-compose-{}*.tar.gz'.format(args.release) + 'dist/docker_compose-{}*.whl'.format(rel), + 'dist/docker-compose-{}*.tar.gz'.format(rel) ]) except HTTPError as e: if e.response.status_code == 400 and 'File already exists' in e.message: From 90625cf31b806ee53ecf4dab2cabf498c7541444 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 4 Oct 2018 01:09:48 -0700 Subject: [PATCH 3534/4072] Don't attempt iterating on None during parallel pull Signed-off-by: Joffrey F --- compose/project.py | 11 +++++------ compose/utils.py | 6 ++++++ tests/integration/project_test.py | 17 +++++++++++++++++ 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/compose/project.py b/compose/project.py index 4340577c9aa..92c352050af 100644 --- a/compose/project.py +++ b/compose/project.py @@ -34,6 +34,7 @@ from .service import ServiceNetworkMode from .service import ServicePidMode from .utils import microseconds_from_time_nano +from .utils import truncate_string from .volume import ProjectVolumes @@ -554,12 +555,10 @@ def pull(self, service_names=None, ignore_pull_failures=False, parallel_pull=Fal if parallel_pull: def pull_service(service): strm = service.pull(ignore_pull_failures, True, stream=True) - writer = parallel.get_stream_writer() + if strm is None: # Attempting to pull service with no `image` key is a no-op + return - def trunc(s): - if len(s) > 35: - return s[:33] + '...' - return s + writer = parallel.get_stream_writer() for event in strm: if 'status' not in event: @@ -572,7 +571,7 @@ def trunc(s): status = '{} ({:.1%})'.format(status, percentage) writer.write( - msg, service.name, trunc(status), lambda s: s + msg, service.name, truncate_string(status), lambda s: s ) _, errors = parallel.parallel_execute( diff --git a/compose/utils.py b/compose/utils.py index 72e6ced17ae..9f0441d08f2 100644 --- a/compose/utils.py +++ b/compose/utils.py @@ -180,3 +180,9 @@ def unique_everseen(iterable, key=lambda x: x): if unique_key not in seen: seen.add(unique_key) yield element + + +def truncate_string(s, max_chars=35): + if len(s) > max_chars: + return s[:max_chars - 2] + '...' + return s diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 63939676e78..57f3b70748e 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -105,6 +105,23 @@ def test_containers_with_extra_service(self): project = Project('composetest', [web, db], self.client) assert set(project.containers(stopped=True)) == set([web_1, db_1]) + def test_parallel_pull_with_no_image(self): + config_data = build_config( + version=V2_3, + services=[{ + 'name': 'web', + 'build': {'context': '.'}, + }], + ) + + project = Project.from_config( + name='composetest', + config_data=config_data, + client=self.client + ) + + project.pull(parallel_pull=True) + def test_volumes_from_service(self): project = Project.from_config( name='composetest', From 099c887b597f6883a1dea6086d24edc05c13dfa3 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 4 Oct 2018 01:40:39 -0700 Subject: [PATCH 3535/4072] Re-enable testing of TP and beta releases Signed-off-by: Joffrey F --- script/test/versions.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/script/test/versions.py b/script/test/versions.py index 6d273a9e665..a06c49f20b0 100755 --- a/script/test/versions.py +++ b/script/test/versions.py @@ -36,6 +36,8 @@ GITHUB_API = 'https://api.github.com/repos' +STAGES = ['tp', 'beta', 'rc'] + class Version(namedtuple('_Version', 'major minor patch stage edition')): @@ -45,7 +47,7 @@ def parse(cls, version): version = version.lstrip('v') version, _, stage = version.partition('-') if stage: - if not any(marker in stage for marker in ['rc', 'tp', 'beta']): + if not any(marker in stage for marker in STAGES): edition = stage stage = None elif '-' in stage: @@ -62,8 +64,16 @@ def order(self): """Return a representation that allows this object to be sorted correctly with the default comparator. """ - # rc releases should appear before official releases - stage = (0, self.stage) if self.stage else (1, ) + # non-GA releases should appear before GA releases + # Order: tp -> beta -> rc -> GA + if self.stage: + for st in STAGES: + if st in self.stage: + stage = (STAGES.index(st), self.stage) + break + else: + stage = (len(STAGES),) + return (int(self.major), int(self.minor), int(self.patch)) + stage def __str__(self): @@ -124,9 +134,6 @@ def get_versions(tags): v = Version.parse(tag['name']) if v in BLACKLIST: continue - # FIXME: Temporary. Remove once these versions are built on dockerswarm/dind - if v.stage and 'rc' not in v.stage: - continue yield v except ValueError: print("Skipping invalid tag: {name}".format(**tag), file=sys.stderr) From 350a555e0402c20c06c15a9bf6977ce0b3a2407c Mon Sep 17 00:00:00 2001 From: Silvin Lubecki Date: Mon, 8 Oct 2018 17:10:25 +0200 Subject: [PATCH 3536/4072] "Bump 1.23.0-rc2" Signed-off-by: Silvin Lubecki --- CHANGELOG.md | 6 ++++++ compose/__init__.py | 2 +- script/run/run.sh | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f212809011..a37f1664cbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,12 @@ naming scheme accordingly before upgrading. - Fixed a bug causing `external: false` entries in the Compose file to be printed as `external: true` in the output of `docker-compose config` +- Fixed a bug where issuing a `docker-compose pull` command on services + without a defined image key would cause Compose to crash + +- Volumes and binds are now mounted in the order they're declared in the + service definition + ### Miscellaneous - The `zsh` completion script has been updated with new options, and no diff --git a/compose/__init__.py b/compose/__init__.py index f0e3f3274e2..1f35b9a3585 100644 --- a/compose/__init__.py +++ b/compose/__init__.py @@ -1,4 +1,4 @@ from __future__ import absolute_import from __future__ import unicode_literals -__version__ = '1.23.0-rc1' +__version__ = '1.23.0-rc2' diff --git a/script/run/run.sh b/script/run/run.sh index fa2248609a0..f02135f4202 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.23.0-rc1" +VERSION="1.23.0-rc2" IMAGE="docker/compose:$VERSION" From 21a51bcd600408768e8c15efd71808d5d7da8df5 Mon Sep 17 00:00:00 2001 From: Andrew Rabert Date: Fri, 5 Oct 2018 14:01:35 -0400 Subject: [PATCH 3537/4072] Use Docker binary from official Docker image Signed-off-by: Andrew Rabert --- Dockerfile.run | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/Dockerfile.run b/Dockerfile.run index e9ba19fd401..bf87fc3352f 100644 --- a/Dockerfile.run +++ b/Dockerfile.run @@ -1,7 +1,7 @@ +FROM docker:17.12.1 as docker FROM alpine:3.6 ENV GLIBC 2.27-r0 -ENV DOCKERBINS_SHA 1270dce1bd7e1838d62ae21d2505d87f16efc1d9074645571daaefdfd0c14054 RUN apk update && apk add --no-cache openssl ca-certificates curl libgcc && \ curl -fsSL -o /etc/apk/keys/sgerrand.rsa.pub https://alpine-pkgs.sgerrand.com/sgerrand.rsa.pub && \ @@ -10,14 +10,10 @@ RUN apk update && apk add --no-cache openssl ca-certificates curl libgcc && \ ln -s /lib/libz.so.1 /usr/glibc-compat/lib/ && \ ln -s /lib/libc.musl-x86_64.so.1 /usr/glibc-compat/lib && \ ln -s /usr/lib/libgcc_s.so.1 /usr/glibc-compat/lib && \ - curl -fsSL -o dockerbins.tgz "https://download.docker.com/linux/static/stable/x86_64/docker-17.12.1-ce.tgz" && \ - echo "${DOCKERBINS_SHA} dockerbins.tgz" | sha256sum -c - && \ - tar xvf dockerbins.tgz docker/docker --strip-components 1 && \ - mv docker /usr/local/bin/docker && \ - chmod +x /usr/local/bin/docker && \ - rm dockerbins.tgz /etc/apk/keys/sgerrand.rsa.pub glibc-$GLIBC.apk && \ + rm /etc/apk/keys/sgerrand.rsa.pub glibc-$GLIBC.apk && \ apk del curl +COPY --from=docker /usr/local/bin/docker /usr/local/bin/docker COPY dist/docker-compose-Linux-x86_64 /usr/local/bin/docker-compose ENTRYPOINT ["docker-compose"] From c7c5b5e8c436a6f1f7a3ca576d3c22931383572a Mon Sep 17 00:00:00 2001 From: Ofek Lev Date: Wed, 10 Oct 2018 22:04:33 -0400 Subject: [PATCH 3538/4072] Upgrade Windows-specific dependency colorama Signed-off-by: Ofek Lev --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 2819810c2ed..a0093d8dd34 100644 --- a/setup.py +++ b/setup.py @@ -55,7 +55,7 @@ def find_version(*file_paths): ':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'], - ':sys_platform == "win32"': ['colorama >= 0.3.9, < 0.4'], + ':sys_platform == "win32"': ['colorama >= 0.4, < 0.5'], 'socks': ['PySocks >= 1.5.6, != 1.5.7, < 2'], } From be324d57a20be9280e8bdbce3eed12f7b9feecfa Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 12 Oct 2018 06:13:55 -0700 Subject: [PATCH 3539/4072] Add pypirc check Signed-off-by: Joffrey F --- script/release/release.py | 24 +++---------------- script/release/release/pypi.py | 44 ++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 21 deletions(-) create mode 100644 script/release/release/pypi.py diff --git a/script/release/release.py b/script/release/release.py index 9a5af3aa563..15c74c77593 100755 --- a/script/release/release.py +++ b/script/release/release.py @@ -17,6 +17,8 @@ from release.const import REPO_ROOT from release.downloader import BinaryDownloader from release.images import ImageManager +from release.pypi import check_pypirc +from release.pypi import pypi_upload from release.repository import delete_assets from release.repository import get_contributors from release.repository import Repository @@ -28,8 +30,6 @@ from release.utils import update_init_py_version from release.utils import update_run_sh_version from release.utils import yesno -from requests.exceptions import HTTPError -from twine.commands.upload import main as twine_upload def create_initial_branch(repository, args): @@ -170,25 +170,6 @@ def distclean(): shutil.rmtree(folder, ignore_errors=True) -def pypi_upload(args): - print('Uploading to PyPi') - try: - rel = args.release.replace('-rc', 'rc') - twine_upload([ - 'dist/docker_compose-{}*.whl'.format(rel), - 'dist/docker-compose-{}*.tar.gz'.format(rel) - ]) - except HTTPError as e: - if e.response.status_code == 400 and 'File already exists' in e.message: - if not args.finalize_resume: - raise ScriptError( - 'Package already uploaded on PyPi.' - ) - print('Skipping PyPi upload - package already uploaded') - else: - raise ScriptError('Unexpected HTTP error uploading package to PyPi: {}'.format(e)) - - def resume(args): try: distclean() @@ -277,6 +258,7 @@ def start(args): def finalize(args): distclean() try: + check_pypirc() repository = Repository(REPO_ROOT, args.repo) img_manager = ImageManager(args.release) pr_data = repository.find_release_pr(args.release) diff --git a/script/release/release/pypi.py b/script/release/release/pypi.py new file mode 100644 index 00000000000..a40e17544cb --- /dev/null +++ b/script/release/release/pypi.py @@ -0,0 +1,44 @@ +from __future__ import absolute_import +from __future__ import unicode_literals + +from configparser import Error +from requests.exceptions import HTTPError +from twine.commands.upload import main as twine_upload +from twine.utils import get_config + +from .utils import ScriptError + + +def pypi_upload(args): + print('Uploading to PyPi') + try: + rel = args.release.replace('-rc', 'rc') + twine_upload([ + 'dist/docker_compose-{}*.whl'.format(rel), + 'dist/docker-compose-{}*.tar.gz'.format(rel) + ]) + except HTTPError as e: + if e.response.status_code == 400 and 'File already exists' in e.message: + if not args.finalize_resume: + raise ScriptError( + 'Package already uploaded on PyPi.' + ) + print('Skipping PyPi upload - package already uploaded') + else: + raise ScriptError('Unexpected HTTP error uploading package to PyPi: {}'.format(e)) + + +def check_pypirc(): + try: + config = get_config() + except Error as e: + raise ScriptError('Failed to parse .pypirc file: {}'.format(e)) + + if config is None: + raise ScriptError('Failed to parse .pypirc file') + + if 'pypi' not in config: + raise ScriptError('Missing [pypi] section in .pypirc file') + + if not (config['pypi'].get('username') and config['pypi'].get('password')): + raise ScriptError('Missing login/password pair for pypi repo') From 297bee897b251a791fc3612225bf1e93505fe957 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 12 Oct 2018 06:14:35 -0700 Subject: [PATCH 3540/4072] Fix arg checks in release.sh Signed-off-by: Joffrey F --- script/release/release.sh | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/script/release/release.sh b/script/release/release.sh index 7947316e0b6..c10f8aba533 100755 --- a/script/release/release.sh +++ b/script/release/release.sh @@ -6,10 +6,8 @@ else ./script/release/setup-venv.sh fi -args=$* - -if test -z $args; then +if test -z "$*"; then args="--help" fi -./.release-venv/bin/python ./script/release/release.py $args +./.release-venv/bin/python ./script/release/release.py "$@" From bd67b90869051db4f7fd5c222b538ee07c58ef56 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 12 Oct 2018 06:39:56 -0700 Subject: [PATCH 3541/4072] Fix ImageManager inconsistencies Signed-off-by: Joffrey F --- script/release/release.py | 2 +- script/release/release/images.py | 27 ++++++++++++--------------- 2 files changed, 13 insertions(+), 16 deletions(-) diff --git a/script/release/release.py b/script/release/release.py index 15c74c77593..6574bfdddaf 100755 --- a/script/release/release.py +++ b/script/release/release.py @@ -266,7 +266,7 @@ def finalize(args): raise ScriptError('No PR found for {}'.format(args.release)) if not check_pr_mergeable(pr_data): raise ScriptError('Can not finalize release with an unmergeable PR') - if not img_manager.check_images(args.release): + if not img_manager.check_images(): raise ScriptError('Missing release image') br_name = branch_name(args.release) if not repository.branch_exists(br_name): diff --git a/script/release/release/images.py b/script/release/release/images.py index e247f596dcc..df6eeda4f71 100644 --- a/script/release/release/images.py +++ b/script/release/release/images.py @@ -27,13 +27,12 @@ def __init__(self, version): def build_images(self, repository, files): print("Building release images...") repository.write_git_sha() - docker_client = docker.APIClient(**docker.utils.kwargs_from_env()) distdir = os.path.join(REPO_ROOT, 'dist') os.makedirs(distdir, exist_ok=True) shutil.copy(files['docker-compose-Linux-x86_64'][0], distdir) os.chmod(os.path.join(distdir, 'docker-compose-Linux-x86_64'), 0o755) print('Building docker/compose image') - logstream = docker_client.build( + logstream = self.docker_client.build( REPO_ROOT, tag='docker/compose:{}'.format(self.version), dockerfile='Dockerfile.run', decode=True ) @@ -44,7 +43,7 @@ def build_images(self, repository, files): print(chunk['stream'], end='') print('Building test image (for UCP e2e)') - logstream = docker_client.build( + logstream = self.docker_client.build( REPO_ROOT, tag='docker-compose-tests:tmp', decode=True ) for chunk in logstream: @@ -53,13 +52,15 @@ def build_images(self, repository, files): if 'stream' in chunk: print(chunk['stream'], end='') - container = docker_client.create_container( + container = self.docker_client.create_container( 'docker-compose-tests:tmp', entrypoint='tox' ) - docker_client.commit(container, 'docker/compose-tests', 'latest') - docker_client.tag('docker/compose-tests:latest', 'docker/compose-tests:{}'.format(self.version)) - docker_client.remove_container(container, force=True) - docker_client.remove_image('docker-compose-tests:tmp', force=True) + self.docker_client.commit(container, 'docker/compose-tests', 'latest') + self.docker_client.tag( + 'docker/compose-tests:latest', 'docker/compose-tests:{}'.format(self.version) + ) + self.docker_client.remove_container(container, force=True) + self.docker_client.remove_image('docker-compose-tests:tmp', force=True) @property def image_names(self): @@ -69,23 +70,19 @@ def image_names(self): 'docker/compose:{}'.format(self.version) ] - def check_images(self, version): - docker_client = docker.APIClient(**docker.utils.kwargs_from_env()) - + def check_images(self): for name in self.image_names: try: - docker_client.inspect_image(name) + self.docker_client.inspect_image(name) except docker.errors.ImageNotFound: print('Expected image {} was not found'.format(name)) return False return True def push_images(self): - docker_client = docker.APIClient(**docker.utils.kwargs_from_env()) - for name in self.image_names: print('Pushing {} to Docker Hub'.format(name)) - logstream = docker_client.push(name, stream=True, decode=True) + logstream = self.docker_client.push(name, stream=True, decode=True) for chunk in logstream: if 'status' in chunk: print(chunk['status']) From 402060e41975cafee88982eab74b63301fa6acd2 Mon Sep 17 00:00:00 2001 From: Ofek Lev Date: Fri, 12 Oct 2018 11:35:27 -0400 Subject: [PATCH 3542/4072] Update requirements.txt Signed-off-by: Ofek Lev --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 41d21172e35..0ea046589d4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,13 +2,13 @@ backports.ssl-match-hostname==3.5.0.1; python_version < '3' cached-property==1.3.0 certifi==2017.4.17 chardet==3.0.4 +colorama==0.4.0; sys_platform == 'win32' docker==3.5.0 docker-pycreds==0.3.0 dockerpty==0.4.1 docopt==0.6.2 enum34==1.1.6; python_version < '3.4' functools32==3.2.3.post2; python_version < '3.2' -git+git://github.com/tartley/colorama.git@bd378c725b45eba0b8e5cc091c3ca76a954c92ff; sys_platform == 'win32' idna==2.5 ipaddress==1.0.18 jsonschema==2.6.0 From 3844ff2fde0a840e28fd05ea0031b19e040b7327 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 15 Oct 2018 19:14:58 -0700 Subject: [PATCH 3543/4072] Update versions in Dockerfiles Signed-off-by: Joffrey F --- Dockerfile | 9 ++------- Dockerfile.run | 6 +++--- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/Dockerfile b/Dockerfile index 9df78a8262a..aa3e1d87b04 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,4 @@ +FROM docker:18.06.1 as docker FROM python:3.6 RUN set -ex; \ @@ -8,13 +9,7 @@ RUN set -ex; \ python-dev \ git -RUN curl -fsSL -o dockerbins.tgz "https://download.docker.com/linux/static/stable/x86_64/docker-17.12.0-ce.tgz" && \ - SHA256=692e1c72937f6214b1038def84463018d8e320c8eaf8530546c84c2f8f9c767d; \ - echo "${SHA256} dockerbins.tgz" | sha256sum -c - && \ - tar xvf dockerbins.tgz docker/docker --strip-components 1 && \ - mv docker /usr/local/bin/docker && \ - chmod +x /usr/local/bin/docker && \ - rm dockerbins.tgz +COPY --from=docker /usr/local/bin/docker /usr/local/bin/docker # Python3 requires a valid locale RUN echo "en_US.UTF-8 UTF-8" > /etc/locale.gen && locale-gen diff --git a/Dockerfile.run b/Dockerfile.run index bf87fc3352f..ccc86ea967f 100644 --- a/Dockerfile.run +++ b/Dockerfile.run @@ -1,7 +1,7 @@ -FROM docker:17.12.1 as docker -FROM alpine:3.6 +FROM docker:18.06.1 as docker +FROM alpine:3.8 -ENV GLIBC 2.27-r0 +ENV GLIBC 2.28-r0 RUN apk update && apk add --no-cache openssl ca-certificates curl libgcc && \ curl -fsSL -o /etc/apk/keys/sgerrand.rsa.pub https://alpine-pkgs.sgerrand.com/sgerrand.rsa.pub && \ From 9df0a4f3a974a24512e6b7e13053075cff0ffb3d Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 15 Oct 2018 19:22:25 -0700 Subject: [PATCH 3544/4072] Remove obsolete curl dependency Signed-off-by: Joffrey F --- Dockerfile | 1 - 1 file changed, 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index aa3e1d87b04..a14be492ec5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,7 +5,6 @@ RUN set -ex; \ apt-get update -qq; \ apt-get install -y \ locales \ - curl \ python-dev \ git From 4cb92294a34524c4501d7c12046c9391f46b1f55 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 16 Oct 2018 13:57:01 -0700 Subject: [PATCH 3545/4072] Avoid creating duplicate mount points when recreating a service Signed-off-by: Joffrey F --- compose/service.py | 5 +++++ tests/integration/service_test.py | 16 ++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/compose/service.py b/compose/service.py index 5ae2ac3af8f..cc6d16a8215 100644 --- a/compose/service.py +++ b/compose/service.py @@ -1491,6 +1491,11 @@ def get_container_data_volumes(container, volumes_option, tmpfs_option, mounts_o if not mount.get('Name'): continue + # Volume (probably an image volume) is overridden by a mount in the service's config + # and would cause a duplicate mountpoint error + if volume.internal in [m.target for m in mounts_option]: + continue + # Copy existing volume from old container volume = volume._replace(external=mount['Name']) volumes.append(volume) diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index db40409f806..edc19528716 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -425,6 +425,22 @@ def test_recreate_preserves_volume_with_trailing_slash(self): new_container = service.recreate_container(old_container) assert new_container.get_mount('/data')['Source'] == volume_path + def test_recreate_volume_to_mount(self): + # https://github.com/docker/compose/issues/6280 + service = Service( + project='composetest', + name='db', + client=self.client, + build={'context': 'tests/fixtures/dockerfile-with-volume'}, + volumes=[MountSpec.parse({ + 'type': 'volume', + 'target': '/data', + })] + ) + old_container = create_and_start_container(service) + new_container = service.recreate_container(old_container) + assert new_container.get_mount('/data')['Source'] + def test_duplicate_volume_trailing_slash(self): """ When an image specifies a volume, and the Compose file specifies a host path From 62057d098f3e563313d84e928f48962e75cb3807 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 16 Oct 2018 17:21:57 -0700 Subject: [PATCH 3546/4072] Don't use dot as a path separator as it is a valid character in resource identifiers Signed-off-by: Joffrey F --- compose/config/interpolation.py | 10 ++++---- tests/unit/config/interpolation_test.py | 31 +++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/compose/config/interpolation.py b/compose/config/interpolation.py index 4f56dff5973..0f878be1444 100644 --- a/compose/config/interpolation.py +++ b/compose/config/interpolation.py @@ -48,7 +48,7 @@ def process_item(name, config_dict): def get_config_path(config_key, section, name): - return '{}.{}.{}'.format(section, name, config_key) + return '{}/{}/{}'.format(section, name, config_key) def interpolate_value(name, config_key, value, section, interpolator): @@ -75,7 +75,7 @@ def interpolate_value(name, config_key, value, section, interpolator): def recursive_interpolate(obj, interpolator, config_path): def append(config_path, key): - return '{}.{}'.format(config_path, key) + return '{}/{}'.format(config_path, key) if isinstance(obj, six.string_types): return converter.convert(config_path, interpolator.interpolate(obj)) @@ -160,12 +160,12 @@ def __init__(self, custom_err_msg): self.err = custom_err_msg -PATH_JOKER = '[^.]+' +PATH_JOKER = '[^/]+' FULL_JOKER = '.+' def re_path(*args): - return re.compile('^{}$'.format('\.'.join(args))) + return re.compile('^{}$'.format('/'.join(args))) def re_path_basic(section, name): @@ -288,7 +288,7 @@ def convert(self, path, value): except ValueError as e: raise ConfigurationError( 'Error while attempting to convert {} to appropriate type: {}'.format( - path, e + path.replace('/', '.'), e ) ) return value diff --git a/tests/unit/config/interpolation_test.py b/tests/unit/config/interpolation_test.py index 0d0e7d28dc6..91fc3e69db5 100644 --- a/tests/unit/config/interpolation_test.py +++ b/tests/unit/config/interpolation_test.py @@ -332,6 +332,37 @@ def test_interpolate_environment_external_resource_convert_types(mock_env): assert value == expected +def test_interpolate_service_name_uses_dot(mock_env): + entry = { + 'service.1': { + 'image': 'busybox', + 'ulimits': { + 'nproc': '${POSINT}', + 'nofile': { + 'soft': '${POSINT}', + 'hard': '${DEFAULT:-40000}' + }, + }, + } + } + + expected = { + 'service.1': { + 'image': 'busybox', + 'ulimits': { + 'nproc': 50, + 'nofile': { + 'soft': 50, + 'hard': 40000 + }, + }, + } + } + + value = interpolate_environment_variables(V3_4, entry, 'service', mock_env) + assert value == expected + + def test_escaped_interpolation(defaults_interpolator): assert defaults_interpolator('$${foo}') == '${foo}' From 5017b25f149603a02fdcae45611d753a698f6cfb Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 16 Oct 2018 18:12:34 -0700 Subject: [PATCH 3547/4072] Update issue templates Signed-off-by: Joffrey F --- .github/ISSUE_TEMPLATE/bug_report.md | 60 +++++++++++++++++++ .github/ISSUE_TEMPLATE/feature_request.md | 29 +++++++++ .../question-about-using-compose.md | 9 +++ 3 files changed, 98 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/ISSUE_TEMPLATE/question-about-using-compose.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000000..49d4691fb69 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,60 @@ +--- +name: Bug report +about: Report a bug encountered while using docker-compose + +--- + + + +## Description of the issue + +## Context information (for bug reports) + +**Output of `docker-compose version`** +``` +(paste here) +``` + +**Output of `docker version`** +``` +(paste here) +``` + +**Output of `docker-compose config`** +(Make sure to add the relevant `-f` and other flags) +``` +(paste here) +``` + + +## Steps to reproduce the issue + +1. +2. +3. + +### Observed result + +### Expected result + +### Stacktrace / full error message + +``` +(paste here) +``` + +## Additional information + +OS version / distribution, `docker-compose` install method, etc. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000000..d53c49a79da --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,29 @@ +--- +name: Feature request +about: Suggest an idea to improve Compose + +--- + + + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/ISSUE_TEMPLATE/question-about-using-compose.md b/.github/ISSUE_TEMPLATE/question-about-using-compose.md new file mode 100644 index 00000000000..11ef65ccfe8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/question-about-using-compose.md @@ -0,0 +1,9 @@ +--- +name: Question about using Compose +about: This is not the appropriate channel + +--- + +Please post on our forums: https://forums.docker.com for questions about using `docker-compose`. + +Posts that are not a bug report or a feature/enhancement request will not be addressed on this issue tracker. From 7712d19b32cb6f8302ee3f8c1a24e951b784dfd7 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 17 Oct 2018 12:10:08 -0700 Subject: [PATCH 3548/4072] Add workaround for Debian/Ubuntu venv setup failure Signed-off-by: Joffrey F --- script/release/release.sh | 4 ++-- script/release/setup-venv.sh | 25 +++++++++++++++++++++---- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/script/release/release.sh b/script/release/release.sh index c10f8aba533..5f853808bd5 100755 --- a/script/release/release.sh +++ b/script/release/release.sh @@ -1,6 +1,6 @@ #!/bin/sh -if test -d ./.release-venv; then +if test -d ${VENV_DIR:-./.release-venv}; then true else ./script/release/setup-venv.sh @@ -10,4 +10,4 @@ if test -z "$*"; then args="--help" fi -./.release-venv/bin/python ./script/release/release.py "$@" +${VENV_DIR:-./.release-venv}/bin/python ./script/release/release.py "$@" diff --git a/script/release/setup-venv.sh b/script/release/setup-venv.sh index d3d3f9a42ac..780fc800f4c 100755 --- a/script/release/setup-venv.sh +++ b/script/release/setup-venv.sh @@ -1,5 +1,11 @@ #!/bin/bash +debian_based() { test -f /etc/debian_version; } + +if test -z $VENV_DIR; then + VENV_DIR=./.release-venv +fi + if test -z $PYTHONBIN; then PYTHONBIN=$(which python3) if test -z $PYTHONBIN; then @@ -16,15 +22,26 @@ if test $(echo $VERSION | cut -d. -f2) -lt 3; then echo "Python 3.3 or above is required" fi -$PYTHONBIN -m venv ./.release-venv +# Debian / Ubuntu workaround: +# https://askubuntu.com/questions/879437/ensurepip-is-disabled-in-debian-ubuntu-for-the-system-python +if debian_based; then + VENV_FLAGS="$VENV_FLAGS --without-pip" +fi -VENVBINS=./.release-venv/bin +$PYTHONBIN -m venv $VENV_DIR $VENV_FLAGS + +VENV_PYTHONBIN=$VENV_DIR/bin/python + +if debian_based; then + curl https://bootstrap.pypa.io/get-pip.py -o $VENV_DIR/get-pip.py + $VENV_PYTHONBIN $VENV_DIR/get-pip.py +fi -$VENVBINS/pip install -U Jinja2==2.10 \ +$VENV_PYTHONBIN -m pip install -U Jinja2==2.10 \ PyGithub==1.39 \ pypandoc==1.4 \ GitPython==2.1.9 \ requests==2.18.4 \ twine==1.11.0 -$VENVBINS/python setup.py develop +$VENV_PYTHONBIN setup.py develop From 5cf25f519e290c46828f9696ce85019a036f829e Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 5 Oct 2018 08:21:39 -0700 Subject: [PATCH 3549/4072] Decontainerize release script Credentials management inside containers is a mess. Let's work on the host instead. Signed-off-by: Joffrey F --- script/release/Dockerfile | 15 --------------- script/release/README.md | 21 ++++++++++++++------ script/release/release.sh | 37 ++++++++---------------------------- script/release/setup-venv.sh | 30 +++++++++++++++++++++++++++++ 4 files changed, 53 insertions(+), 50 deletions(-) delete mode 100644 script/release/Dockerfile create mode 100755 script/release/setup-venv.sh diff --git a/script/release/Dockerfile b/script/release/Dockerfile deleted file mode 100644 index e5af676a50a..00000000000 --- a/script/release/Dockerfile +++ /dev/null @@ -1,15 +0,0 @@ -FROM python:3.6 -RUN mkdir -p /src && pip install -U Jinja2==2.10 \ - PyGithub==1.39 \ - pypandoc==1.4 \ - GitPython==2.1.9 \ - requests==2.18.4 \ - twine==1.11.0 && \ - apt-get update && apt-get install -y pandoc - -VOLUME /src/script/release -WORKDIR /src -COPY . /src -RUN python setup.py develop -ENTRYPOINT ["python", "script/release/release.py"] -CMD ["--help"] diff --git a/script/release/README.md b/script/release/README.md index 65883f5d365..f7f911e5378 100644 --- a/script/release/README.md +++ b/script/release/README.md @@ -9,8 +9,7 @@ The following things are required to bring a release to a successful conclusion ### Local Docker engine (Linux Containers) -The release script runs inside a container and builds images that will be part -of the release. +The release script builds images that will be part of the release. ### Docker Hub account @@ -20,11 +19,9 @@ following repositories: - docker/compose - docker/compose-tests -### A local Python environment +### Python -While most of the release script is running inside a Docker container, -fetching local Docker credentials depends on the `docker` Python package -being available locally. +The release script is written in Python and requires Python 3.3 at minimum. ### A Github account and Github API token @@ -59,6 +56,18 @@ Said account needs to be a member of the maintainers group for the Moreover, the `~/.pypirc` file should exist on your host and contain the relevant pypi credentials. +The following is a sample `.pypirc` provided as a guideline: + +``` +[distutils] +index-servers = + pypi + +[pypi] +username = user +password = pass +``` + ## Start a feature release A feature release is a release that includes all changes present in the diff --git a/script/release/release.sh b/script/release/release.sh index ee75b13a632..7947316e0b6 100755 --- a/script/release/release.sh +++ b/script/release/release.sh @@ -1,36 +1,15 @@ #!/bin/sh -docker image inspect compose/release-tool > /dev/null -if test $? -ne 0; then - docker build -t compose/release-tool -f $(pwd)/script/release/Dockerfile $(pwd) +if test -d ./.release-venv; then + true +else + ./script/release/setup-venv.sh fi -if test -z $GITHUB_TOKEN; then - echo "GITHUB_TOKEN environment variable must be set" - exit 1 -fi - -if test -z $BINTRAY_TOKEN; then - echo "BINTRAY_TOKEN environment variable must be set" - exit 1 -fi +args=$* -if test -z $(python -c "import docker; print(docker.version)" 2>/dev/null); then - echo "This script requires the 'docker' Python package to be installed locally" - exit 1 +if test -z $args; then + args="--help" fi -hub_credentials=$(python -c "from docker import auth; cfg = auth.load_config(); print(auth.encode_header(auth.resolve_authconfig(cfg, 'docker.io')).decode('ascii'))") - -docker run -it \ - -e GITHUB_TOKEN=$GITHUB_TOKEN \ - -e BINTRAY_TOKEN=$BINTRAY_TOKEN \ - -e SSH_AUTH_SOCK=$SSH_AUTH_SOCK \ - -e HUB_CREDENTIALS=$hub_credentials \ - --mount type=bind,source=$(pwd),target=/src \ - --mount type=bind,source=$HOME/.gitconfig,target=/root/.gitconfig \ - --mount type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock \ - --mount type=bind,source=$HOME/.ssh,target=/root/.ssh \ - --mount type=bind,source=/tmp,target=/tmp \ - -v $HOME/.pypirc:/root/.pypirc \ - compose/release-tool $* +./.release-venv/bin/python ./script/release/release.py $args diff --git a/script/release/setup-venv.sh b/script/release/setup-venv.sh new file mode 100755 index 00000000000..d3d3f9a42ac --- /dev/null +++ b/script/release/setup-venv.sh @@ -0,0 +1,30 @@ +#!/bin/bash + +if test -z $PYTHONBIN; then + PYTHONBIN=$(which python3) + if test -z $PYTHONBIN; then + PYTHONBIN=$(which python) + fi +fi + +VERSION=$($PYTHONBIN -c "import sys; print('{}.{}'.format(*sys.version_info[0:2]))") +if test $(echo $VERSION | cut -d. -f1) -lt 3; then + echo "Python 3.3 or above is required" +fi + +if test $(echo $VERSION | cut -d. -f2) -lt 3; then + echo "Python 3.3 or above is required" +fi + +$PYTHONBIN -m venv ./.release-venv + +VENVBINS=./.release-venv/bin + +$VENVBINS/pip install -U Jinja2==2.10 \ + PyGithub==1.39 \ + pypandoc==1.4 \ + GitPython==2.1.9 \ + requests==2.18.4 \ + twine==1.11.0 + +$VENVBINS/python setup.py develop From 9bccfa8dd0ae29df273824dd9697733555175fc1 Mon Sep 17 00:00:00 2001 From: Andrew Rabert Date: Fri, 5 Oct 2018 14:01:35 -0400 Subject: [PATCH 3550/4072] Use Docker binary from official Docker image Signed-off-by: Andrew Rabert --- Dockerfile.run | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/Dockerfile.run b/Dockerfile.run index e9ba19fd401..bf87fc3352f 100644 --- a/Dockerfile.run +++ b/Dockerfile.run @@ -1,7 +1,7 @@ +FROM docker:17.12.1 as docker FROM alpine:3.6 ENV GLIBC 2.27-r0 -ENV DOCKERBINS_SHA 1270dce1bd7e1838d62ae21d2505d87f16efc1d9074645571daaefdfd0c14054 RUN apk update && apk add --no-cache openssl ca-certificates curl libgcc && \ curl -fsSL -o /etc/apk/keys/sgerrand.rsa.pub https://alpine-pkgs.sgerrand.com/sgerrand.rsa.pub && \ @@ -10,14 +10,10 @@ RUN apk update && apk add --no-cache openssl ca-certificates curl libgcc && \ ln -s /lib/libz.so.1 /usr/glibc-compat/lib/ && \ ln -s /lib/libc.musl-x86_64.so.1 /usr/glibc-compat/lib && \ ln -s /usr/lib/libgcc_s.so.1 /usr/glibc-compat/lib && \ - curl -fsSL -o dockerbins.tgz "https://download.docker.com/linux/static/stable/x86_64/docker-17.12.1-ce.tgz" && \ - echo "${DOCKERBINS_SHA} dockerbins.tgz" | sha256sum -c - && \ - tar xvf dockerbins.tgz docker/docker --strip-components 1 && \ - mv docker /usr/local/bin/docker && \ - chmod +x /usr/local/bin/docker && \ - rm dockerbins.tgz /etc/apk/keys/sgerrand.rsa.pub glibc-$GLIBC.apk && \ + rm /etc/apk/keys/sgerrand.rsa.pub glibc-$GLIBC.apk && \ apk del curl +COPY --from=docker /usr/local/bin/docker /usr/local/bin/docker COPY dist/docker-compose-Linux-x86_64 /usr/local/bin/docker-compose ENTRYPOINT ["docker-compose"] From fe347321c952ceb429c5c09c4113f4e4b9676b95 Mon Sep 17 00:00:00 2001 From: Ofek Lev Date: Wed, 10 Oct 2018 22:04:33 -0400 Subject: [PATCH 3551/4072] Upgrade Windows-specific dependency colorama Signed-off-by: Ofek Lev --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 2819810c2ed..a0093d8dd34 100644 --- a/setup.py +++ b/setup.py @@ -55,7 +55,7 @@ def find_version(*file_paths): ':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'], - ':sys_platform == "win32"': ['colorama >= 0.3.9, < 0.4'], + ':sys_platform == "win32"': ['colorama >= 0.4, < 0.5'], 'socks': ['PySocks >= 1.5.6, != 1.5.7, < 2'], } From e722190d5011a31eb070435ca165160f4c996f65 Mon Sep 17 00:00:00 2001 From: Ofek Lev Date: Fri, 12 Oct 2018 11:35:27 -0400 Subject: [PATCH 3552/4072] Update requirements.txt Signed-off-by: Ofek Lev --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 41d21172e35..0ea046589d4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,13 +2,13 @@ backports.ssl-match-hostname==3.5.0.1; python_version < '3' cached-property==1.3.0 certifi==2017.4.17 chardet==3.0.4 +colorama==0.4.0; sys_platform == 'win32' docker==3.5.0 docker-pycreds==0.3.0 dockerpty==0.4.1 docopt==0.6.2 enum34==1.1.6; python_version < '3.4' functools32==3.2.3.post2; python_version < '3.2' -git+git://github.com/tartley/colorama.git@bd378c725b45eba0b8e5cc091c3ca76a954c92ff; sys_platform == 'win32' idna==2.5 ipaddress==1.0.18 jsonschema==2.6.0 From 51d44c7ebc551ece13c611b348486e967794cf34 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 12 Oct 2018 06:13:55 -0700 Subject: [PATCH 3553/4072] Add pypirc check Signed-off-by: Joffrey F --- script/release/release.py | 24 +++---------------- script/release/release/pypi.py | 44 ++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 21 deletions(-) create mode 100644 script/release/release/pypi.py diff --git a/script/release/release.py b/script/release/release.py index 9a5af3aa563..15c74c77593 100755 --- a/script/release/release.py +++ b/script/release/release.py @@ -17,6 +17,8 @@ from release.const import REPO_ROOT from release.downloader import BinaryDownloader from release.images import ImageManager +from release.pypi import check_pypirc +from release.pypi import pypi_upload from release.repository import delete_assets from release.repository import get_contributors from release.repository import Repository @@ -28,8 +30,6 @@ from release.utils import update_init_py_version from release.utils import update_run_sh_version from release.utils import yesno -from requests.exceptions import HTTPError -from twine.commands.upload import main as twine_upload def create_initial_branch(repository, args): @@ -170,25 +170,6 @@ def distclean(): shutil.rmtree(folder, ignore_errors=True) -def pypi_upload(args): - print('Uploading to PyPi') - try: - rel = args.release.replace('-rc', 'rc') - twine_upload([ - 'dist/docker_compose-{}*.whl'.format(rel), - 'dist/docker-compose-{}*.tar.gz'.format(rel) - ]) - except HTTPError as e: - if e.response.status_code == 400 and 'File already exists' in e.message: - if not args.finalize_resume: - raise ScriptError( - 'Package already uploaded on PyPi.' - ) - print('Skipping PyPi upload - package already uploaded') - else: - raise ScriptError('Unexpected HTTP error uploading package to PyPi: {}'.format(e)) - - def resume(args): try: distclean() @@ -277,6 +258,7 @@ def start(args): def finalize(args): distclean() try: + check_pypirc() repository = Repository(REPO_ROOT, args.repo) img_manager = ImageManager(args.release) pr_data = repository.find_release_pr(args.release) diff --git a/script/release/release/pypi.py b/script/release/release/pypi.py new file mode 100644 index 00000000000..a40e17544cb --- /dev/null +++ b/script/release/release/pypi.py @@ -0,0 +1,44 @@ +from __future__ import absolute_import +from __future__ import unicode_literals + +from configparser import Error +from requests.exceptions import HTTPError +from twine.commands.upload import main as twine_upload +from twine.utils import get_config + +from .utils import ScriptError + + +def pypi_upload(args): + print('Uploading to PyPi') + try: + rel = args.release.replace('-rc', 'rc') + twine_upload([ + 'dist/docker_compose-{}*.whl'.format(rel), + 'dist/docker-compose-{}*.tar.gz'.format(rel) + ]) + except HTTPError as e: + if e.response.status_code == 400 and 'File already exists' in e.message: + if not args.finalize_resume: + raise ScriptError( + 'Package already uploaded on PyPi.' + ) + print('Skipping PyPi upload - package already uploaded') + else: + raise ScriptError('Unexpected HTTP error uploading package to PyPi: {}'.format(e)) + + +def check_pypirc(): + try: + config = get_config() + except Error as e: + raise ScriptError('Failed to parse .pypirc file: {}'.format(e)) + + if config is None: + raise ScriptError('Failed to parse .pypirc file') + + if 'pypi' not in config: + raise ScriptError('Missing [pypi] section in .pypirc file') + + if not (config['pypi'].get('username') and config['pypi'].get('password')): + raise ScriptError('Missing login/password pair for pypi repo') From c9107cff39328475ccb3a3efa467a98e4dccba10 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 12 Oct 2018 06:14:35 -0700 Subject: [PATCH 3554/4072] Fix arg checks in release.sh Signed-off-by: Joffrey F --- script/release/release.sh | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/script/release/release.sh b/script/release/release.sh index 7947316e0b6..c10f8aba533 100755 --- a/script/release/release.sh +++ b/script/release/release.sh @@ -6,10 +6,8 @@ else ./script/release/setup-venv.sh fi -args=$* - -if test -z $args; then +if test -z "$*"; then args="--help" fi -./.release-venv/bin/python ./script/release/release.py $args +./.release-venv/bin/python ./script/release/release.py "$@" From da25be8f9944e3962a832396c78f32c0bd4c562a Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 12 Oct 2018 06:39:56 -0700 Subject: [PATCH 3555/4072] Fix ImageManager inconsistencies Signed-off-by: Joffrey F --- script/release/release.py | 2 +- script/release/release/images.py | 27 ++++++++++++--------------- 2 files changed, 13 insertions(+), 16 deletions(-) diff --git a/script/release/release.py b/script/release/release.py index 15c74c77593..6574bfdddaf 100755 --- a/script/release/release.py +++ b/script/release/release.py @@ -266,7 +266,7 @@ def finalize(args): raise ScriptError('No PR found for {}'.format(args.release)) if not check_pr_mergeable(pr_data): raise ScriptError('Can not finalize release with an unmergeable PR') - if not img_manager.check_images(args.release): + if not img_manager.check_images(): raise ScriptError('Missing release image') br_name = branch_name(args.release) if not repository.branch_exists(br_name): diff --git a/script/release/release/images.py b/script/release/release/images.py index e247f596dcc..df6eeda4f71 100644 --- a/script/release/release/images.py +++ b/script/release/release/images.py @@ -27,13 +27,12 @@ def __init__(self, version): def build_images(self, repository, files): print("Building release images...") repository.write_git_sha() - docker_client = docker.APIClient(**docker.utils.kwargs_from_env()) distdir = os.path.join(REPO_ROOT, 'dist') os.makedirs(distdir, exist_ok=True) shutil.copy(files['docker-compose-Linux-x86_64'][0], distdir) os.chmod(os.path.join(distdir, 'docker-compose-Linux-x86_64'), 0o755) print('Building docker/compose image') - logstream = docker_client.build( + logstream = self.docker_client.build( REPO_ROOT, tag='docker/compose:{}'.format(self.version), dockerfile='Dockerfile.run', decode=True ) @@ -44,7 +43,7 @@ def build_images(self, repository, files): print(chunk['stream'], end='') print('Building test image (for UCP e2e)') - logstream = docker_client.build( + logstream = self.docker_client.build( REPO_ROOT, tag='docker-compose-tests:tmp', decode=True ) for chunk in logstream: @@ -53,13 +52,15 @@ def build_images(self, repository, files): if 'stream' in chunk: print(chunk['stream'], end='') - container = docker_client.create_container( + container = self.docker_client.create_container( 'docker-compose-tests:tmp', entrypoint='tox' ) - docker_client.commit(container, 'docker/compose-tests', 'latest') - docker_client.tag('docker/compose-tests:latest', 'docker/compose-tests:{}'.format(self.version)) - docker_client.remove_container(container, force=True) - docker_client.remove_image('docker-compose-tests:tmp', force=True) + self.docker_client.commit(container, 'docker/compose-tests', 'latest') + self.docker_client.tag( + 'docker/compose-tests:latest', 'docker/compose-tests:{}'.format(self.version) + ) + self.docker_client.remove_container(container, force=True) + self.docker_client.remove_image('docker-compose-tests:tmp', force=True) @property def image_names(self): @@ -69,23 +70,19 @@ def image_names(self): 'docker/compose:{}'.format(self.version) ] - def check_images(self, version): - docker_client = docker.APIClient(**docker.utils.kwargs_from_env()) - + def check_images(self): for name in self.image_names: try: - docker_client.inspect_image(name) + self.docker_client.inspect_image(name) except docker.errors.ImageNotFound: print('Expected image {} was not found'.format(name)) return False return True def push_images(self): - docker_client = docker.APIClient(**docker.utils.kwargs_from_env()) - for name in self.image_names: print('Pushing {} to Docker Hub'.format(name)) - logstream = docker_client.push(name, stream=True, decode=True) + logstream = self.docker_client.push(name, stream=True, decode=True) for chunk in logstream: if 'status' in chunk: print(chunk['status']) From 23beeb353c08e2c53df0d7e7f97d0d70b05d8c25 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 15 Oct 2018 19:14:58 -0700 Subject: [PATCH 3556/4072] Update versions in Dockerfiles Signed-off-by: Joffrey F --- Dockerfile | 9 ++------- Dockerfile.run | 6 +++--- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/Dockerfile b/Dockerfile index 9df78a8262a..aa3e1d87b04 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,4 @@ +FROM docker:18.06.1 as docker FROM python:3.6 RUN set -ex; \ @@ -8,13 +9,7 @@ RUN set -ex; \ python-dev \ git -RUN curl -fsSL -o dockerbins.tgz "https://download.docker.com/linux/static/stable/x86_64/docker-17.12.0-ce.tgz" && \ - SHA256=692e1c72937f6214b1038def84463018d8e320c8eaf8530546c84c2f8f9c767d; \ - echo "${SHA256} dockerbins.tgz" | sha256sum -c - && \ - tar xvf dockerbins.tgz docker/docker --strip-components 1 && \ - mv docker /usr/local/bin/docker && \ - chmod +x /usr/local/bin/docker && \ - rm dockerbins.tgz +COPY --from=docker /usr/local/bin/docker /usr/local/bin/docker # Python3 requires a valid locale RUN echo "en_US.UTF-8 UTF-8" > /etc/locale.gen && locale-gen diff --git a/Dockerfile.run b/Dockerfile.run index bf87fc3352f..ccc86ea967f 100644 --- a/Dockerfile.run +++ b/Dockerfile.run @@ -1,7 +1,7 @@ -FROM docker:17.12.1 as docker -FROM alpine:3.6 +FROM docker:18.06.1 as docker +FROM alpine:3.8 -ENV GLIBC 2.27-r0 +ENV GLIBC 2.28-r0 RUN apk update && apk add --no-cache openssl ca-certificates curl libgcc && \ curl -fsSL -o /etc/apk/keys/sgerrand.rsa.pub https://alpine-pkgs.sgerrand.com/sgerrand.rsa.pub && \ From 12f7e0d2fbcd0876aaf3dc2aec8dfd70ebf8f921 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 15 Oct 2018 19:22:25 -0700 Subject: [PATCH 3557/4072] Remove obsolete curl dependency Signed-off-by: Joffrey F --- Dockerfile | 1 - 1 file changed, 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index aa3e1d87b04..a14be492ec5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,7 +5,6 @@ RUN set -ex; \ apt-get update -qq; \ apt-get install -y \ locales \ - curl \ python-dev \ git From 5e4098d2280b2b46e2d0ffd08aeaee9fc9b6aec9 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 16 Oct 2018 13:57:01 -0700 Subject: [PATCH 3558/4072] Avoid creating duplicate mount points when recreating a service Signed-off-by: Joffrey F --- compose/service.py | 5 +++++ tests/integration/service_test.py | 16 ++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/compose/service.py b/compose/service.py index 3327c77f889..73744801d0c 100644 --- a/compose/service.py +++ b/compose/service.py @@ -1489,6 +1489,11 @@ def get_container_data_volumes(container, volumes_option, tmpfs_option, mounts_o if not mount.get('Name'): continue + # Volume (probably an image volume) is overridden by a mount in the service's config + # and would cause a duplicate mountpoint error + if volume.internal in [m.target for m in mounts_option]: + continue + # Copy existing volume from old container volume = volume._replace(external=mount['Name']) volumes.append(volume) diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index db40409f806..edc19528716 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -425,6 +425,22 @@ def test_recreate_preserves_volume_with_trailing_slash(self): new_container = service.recreate_container(old_container) assert new_container.get_mount('/data')['Source'] == volume_path + def test_recreate_volume_to_mount(self): + # https://github.com/docker/compose/issues/6280 + service = Service( + project='composetest', + name='db', + client=self.client, + build={'context': 'tests/fixtures/dockerfile-with-volume'}, + volumes=[MountSpec.parse({ + 'type': 'volume', + 'target': '/data', + })] + ) + old_container = create_and_start_container(service) + new_container = service.recreate_container(old_container) + assert new_container.get_mount('/data')['Source'] + def test_duplicate_volume_trailing_slash(self): """ When an image specifies a volume, and the Compose file specifies a host path From 0fa1462b0f9439ff38feec8201871bba4f05c70c Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 16 Oct 2018 17:21:57 -0700 Subject: [PATCH 3559/4072] Don't use dot as a path separator as it is a valid character in resource identifiers Signed-off-by: Joffrey F --- compose/config/interpolation.py | 10 ++++---- tests/unit/config/interpolation_test.py | 31 +++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/compose/config/interpolation.py b/compose/config/interpolation.py index 4f56dff5973..0f878be1444 100644 --- a/compose/config/interpolation.py +++ b/compose/config/interpolation.py @@ -48,7 +48,7 @@ def process_item(name, config_dict): def get_config_path(config_key, section, name): - return '{}.{}.{}'.format(section, name, config_key) + return '{}/{}/{}'.format(section, name, config_key) def interpolate_value(name, config_key, value, section, interpolator): @@ -75,7 +75,7 @@ def interpolate_value(name, config_key, value, section, interpolator): def recursive_interpolate(obj, interpolator, config_path): def append(config_path, key): - return '{}.{}'.format(config_path, key) + return '{}/{}'.format(config_path, key) if isinstance(obj, six.string_types): return converter.convert(config_path, interpolator.interpolate(obj)) @@ -160,12 +160,12 @@ def __init__(self, custom_err_msg): self.err = custom_err_msg -PATH_JOKER = '[^.]+' +PATH_JOKER = '[^/]+' FULL_JOKER = '.+' def re_path(*args): - return re.compile('^{}$'.format('\.'.join(args))) + return re.compile('^{}$'.format('/'.join(args))) def re_path_basic(section, name): @@ -288,7 +288,7 @@ def convert(self, path, value): except ValueError as e: raise ConfigurationError( 'Error while attempting to convert {} to appropriate type: {}'.format( - path, e + path.replace('/', '.'), e ) ) return value diff --git a/tests/unit/config/interpolation_test.py b/tests/unit/config/interpolation_test.py index 0d0e7d28dc6..91fc3e69db5 100644 --- a/tests/unit/config/interpolation_test.py +++ b/tests/unit/config/interpolation_test.py @@ -332,6 +332,37 @@ def test_interpolate_environment_external_resource_convert_types(mock_env): assert value == expected +def test_interpolate_service_name_uses_dot(mock_env): + entry = { + 'service.1': { + 'image': 'busybox', + 'ulimits': { + 'nproc': '${POSINT}', + 'nofile': { + 'soft': '${POSINT}', + 'hard': '${DEFAULT:-40000}' + }, + }, + } + } + + expected = { + 'service.1': { + 'image': 'busybox', + 'ulimits': { + 'nproc': 50, + 'nofile': { + 'soft': 50, + 'hard': 40000 + }, + }, + } + } + + value = interpolate_environment_variables(V3_4, entry, 'service', mock_env) + assert value == expected + + def test_escaped_interpolation(defaults_interpolator): assert defaults_interpolator('$${foo}') == '${foo}' From 5ab3e47b42f004d2cd847051c72f5c10ab16485c Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 17 Oct 2018 12:10:08 -0700 Subject: [PATCH 3560/4072] Add workaround for Debian/Ubuntu venv setup failure Signed-off-by: Joffrey F --- script/release/release.sh | 4 ++-- script/release/setup-venv.sh | 25 +++++++++++++++++++++---- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/script/release/release.sh b/script/release/release.sh index c10f8aba533..5f853808bd5 100755 --- a/script/release/release.sh +++ b/script/release/release.sh @@ -1,6 +1,6 @@ #!/bin/sh -if test -d ./.release-venv; then +if test -d ${VENV_DIR:-./.release-venv}; then true else ./script/release/setup-venv.sh @@ -10,4 +10,4 @@ if test -z "$*"; then args="--help" fi -./.release-venv/bin/python ./script/release/release.py "$@" +${VENV_DIR:-./.release-venv}/bin/python ./script/release/release.py "$@" diff --git a/script/release/setup-venv.sh b/script/release/setup-venv.sh index d3d3f9a42ac..780fc800f4c 100755 --- a/script/release/setup-venv.sh +++ b/script/release/setup-venv.sh @@ -1,5 +1,11 @@ #!/bin/bash +debian_based() { test -f /etc/debian_version; } + +if test -z $VENV_DIR; then + VENV_DIR=./.release-venv +fi + if test -z $PYTHONBIN; then PYTHONBIN=$(which python3) if test -z $PYTHONBIN; then @@ -16,15 +22,26 @@ if test $(echo $VERSION | cut -d. -f2) -lt 3; then echo "Python 3.3 or above is required" fi -$PYTHONBIN -m venv ./.release-venv +# Debian / Ubuntu workaround: +# https://askubuntu.com/questions/879437/ensurepip-is-disabled-in-debian-ubuntu-for-the-system-python +if debian_based; then + VENV_FLAGS="$VENV_FLAGS --without-pip" +fi -VENVBINS=./.release-venv/bin +$PYTHONBIN -m venv $VENV_DIR $VENV_FLAGS + +VENV_PYTHONBIN=$VENV_DIR/bin/python + +if debian_based; then + curl https://bootstrap.pypa.io/get-pip.py -o $VENV_DIR/get-pip.py + $VENV_PYTHONBIN $VENV_DIR/get-pip.py +fi -$VENVBINS/pip install -U Jinja2==2.10 \ +$VENV_PYTHONBIN -m pip install -U Jinja2==2.10 \ PyGithub==1.39 \ pypandoc==1.4 \ GitPython==2.1.9 \ requests==2.18.4 \ twine==1.11.0 -$VENVBINS/python setup.py develop +$VENV_PYTHONBIN setup.py develop From 45189c134db4669cdd36cc6f041b513a052f831a Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 17 Oct 2018 12:16:34 -0700 Subject: [PATCH 3561/4072] "Bump 1.23.0-rc3" Signed-off-by: Joffrey F --- CHANGELOG.md | 7 +++++++ compose/__init__.py | 2 +- script/run/run.sh | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a37f1664cbc..27f6f3c52c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,9 +48,16 @@ naming scheme accordingly before upgrading. the actual exit code even when the watched container isn't the cause of the exit. +- Fixed an issue that would prevent recreating a service in some cases where + a volume would be mapped to the same mountpoint as a volume declared inside + the image's Dockerfile. + - Fixed a bug that caused hash configuration with multiple networks to be inconsistent, causing some services to be unnecessarily restarted. +- Fixed a bug that would cause failures with variable substitution for services + with a name containing one or more dot characters + - Fixed a pipe handling issue when using the containerized version of Compose. - Fixed a bug causing `external: false` entries in the Compose file to be diff --git a/compose/__init__.py b/compose/__init__.py index 1f35b9a3585..532f76888f2 100644 --- a/compose/__init__.py +++ b/compose/__init__.py @@ -1,4 +1,4 @@ from __future__ import absolute_import from __future__ import unicode_literals -__version__ = '1.23.0-rc2' +__version__ = '1.23.0-rc3' diff --git a/script/run/run.sh b/script/run/run.sh index f02135f4202..ba945d3ed94 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.23.0-rc2" +VERSION="1.23.0-rc3" IMAGE="docker/compose:$VERSION" From ca8ab06571933f867d2c4ec633ef4c2d6562533b Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 17 Oct 2018 13:39:11 -0700 Subject: [PATCH 3562/4072] Some additional exclusions in .gitignore / .dockerignore Signed-off-by: Joffrey F --- .dockerignore | 4 +++- .gitignore | 18 ++++++++++-------- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/.dockerignore b/.dockerignore index eccd86dda41..65ad588d9fd 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,11 +1,13 @@ *.egg-info .coverage .git +.github .tox build +binaries coverage-html docs/_site -venv +*venv .tox **/__pycache__ *.pyc diff --git a/.gitignore b/.gitignore index 18afd643da2..79888274847 100644 --- a/.gitignore +++ b/.gitignore @@ -1,16 +1,18 @@ *.egg-info *.pyc +*.swo +*.swp +.cache .coverage* +.DS_Store +.idea + /.tox +/binaries /build +/compose/GITSHA /coverage-html /dist /docs/_site -/venv -README.rst -compose/GITSHA -*.swo -*.swp -.DS_Store -.cache -.idea +/README.rst +/*venv From ea3d406eeda212363f03289770e6c8a7c654dac2 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 17 Oct 2018 13:39:11 -0700 Subject: [PATCH 3563/4072] Some additional exclusions in .gitignore / .dockerignore Signed-off-by: Joffrey F --- .dockerignore | 4 +++- .gitignore | 18 ++++++++++-------- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/.dockerignore b/.dockerignore index eccd86dda41..65ad588d9fd 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,11 +1,13 @@ *.egg-info .coverage .git +.github .tox build +binaries coverage-html docs/_site -venv +*venv .tox **/__pycache__ *.pyc diff --git a/.gitignore b/.gitignore index 18afd643da2..79888274847 100644 --- a/.gitignore +++ b/.gitignore @@ -1,16 +1,18 @@ *.egg-info *.pyc +*.swo +*.swp +.cache .coverage* +.DS_Store +.idea + /.tox +/binaries /build +/compose/GITSHA /coverage-html /dist /docs/_site -/venv -README.rst -compose/GITSHA -*.swo -*.swp -.DS_Store -.cache -.idea +/README.rst +/*venv From 98bb68e404f70cb164125f1f4f8464971d453617 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 24 Oct 2018 15:06:04 -0700 Subject: [PATCH 3564/4072] Fix new flake8 errors/warnings Signed-off-by: Joffrey F --- compose/cli/errors.py | 2 +- compose/config/types.py | 2 +- compose/config/validation.py | 8 ++++---- tests/acceptance/cli_test.py | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/compose/cli/errors.py b/compose/cli/errors.py index 82768970b30..8c89da6c5d2 100644 --- a/compose/cli/errors.py +++ b/compose/cli/errors.py @@ -54,7 +54,7 @@ def handle_connection_errors(client): except APIError as e: log_api_error(e, client.api_version) raise ConnectionError() - except (ReadTimeout, socket.timeout) as e: + except (ReadTimeout, socket.timeout): log_timeout_error(client.timeout) raise ConnectionError() except Exception as e: diff --git a/compose/config/types.py b/compose/config/types.py index 838fb9f58cf..ab8f34e3d7f 100644 --- a/compose/config/types.py +++ b/compose/config/types.py @@ -125,7 +125,7 @@ def parse_extra_hosts(extra_hosts_config): def normalize_path_for_engine(path): - """Windows paths, c:\my\path\shiny, need to be changed to be compatible with + """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/ """ drive, tail = splitdrive(path) diff --git a/compose/config/validation.py b/compose/config/validation.py index 0fdcb37e7dc..87c1f23451d 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -41,15 +41,15 @@ } -VALID_NAME_CHARS = '[a-zA-Z0-9\._\-]' +VALID_NAME_CHARS = r'[a-zA-Z0-9\._\-]' VALID_EXPOSE_FORMAT = r'^\d+(\-\d+)?(\/[a-zA-Z]+)?$' VALID_IPV4_SEG = r'(\d{1,2}|1\d{2}|2[0-4]\d|25[0-5])' -VALID_IPV4_ADDR = "({IPV4_SEG}\.){{3}}{IPV4_SEG}".format(IPV4_SEG=VALID_IPV4_SEG) -VALID_REGEX_IPV4_CIDR = "^{IPV4_ADDR}/(\d|[1-2]\d|3[0-2])$".format(IPV4_ADDR=VALID_IPV4_ADDR) +VALID_IPV4_ADDR = r"({IPV4_SEG}\.){{3}}{IPV4_SEG}".format(IPV4_SEG=VALID_IPV4_SEG) +VALID_REGEX_IPV4_CIDR = r"^{IPV4_ADDR}/(\d|[1-2]\d|3[0-2])$".format(IPV4_ADDR=VALID_IPV4_ADDR) VALID_IPV6_SEG = r'[0-9a-fA-F]{1,4}' -VALID_REGEX_IPV6_CIDR = "".join(""" +VALID_REGEX_IPV6_CIDR = "".join(r""" ^ ( (({IPV6_SEG}:){{7}}{IPV6_SEG})| diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 3d063d8539b..5b0a0e0fd72 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -2361,7 +2361,7 @@ def test_logs_timestamps(self): self.dispatch(['up', '-d']) result = self.dispatch(['logs', '-f', '-t']) - assert re.search('(\d{4})-(\d{2})-(\d{2})T(\d{2})\:(\d{2})\:(\d{2})', result.stdout) + assert re.search(r'(\d{4})-(\d{2})-(\d{2})T(\d{2})\:(\d{2})\:(\d{2})', result.stdout) def test_logs_tail(self): self.base_dir = 'tests/fixtures/logs-tail-composefile' From 4368b8ac0541bd3d42cf818b04f772f618362790 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 24 Oct 2018 16:08:56 -0700 Subject: [PATCH 3565/4072] Only use supported protocols when starting engine CLI subprocess Signed-off-by: Joffrey F --- compose/cli/main.py | 4 +++- tests/unit/cli/main_test.py | 8 ++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index f2e76c1ad54..46b547b00f8 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -1452,7 +1452,9 @@ def call_docker(args, dockeropts): if verify: tls_options.append('--tlsverify') if host: - tls_options.extend(['--host', host.lstrip('=')]) + tls_options.extend( + ['--host', re.sub(r'^https?://', 'tcp://', host.lstrip('='))] + ) args = [executable_path] + tls_options + args log.debug(" ".join(map(pipes.quote, args))) diff --git a/tests/unit/cli/main_test.py b/tests/unit/cli/main_test.py index 1a2dfbcf353..2e97f2c8794 100644 --- a/tests/unit/cli/main_test.py +++ b/tests/unit/cli/main_test.py @@ -155,6 +155,14 @@ def test_with_host_option(self): 'docker', '--host', 'tcp://mydocker.net:2333', 'ps' ] + def test_with_http_host(self): + with mock.patch('subprocess.call') as fake_call: + call_docker(['ps'], {'--host': 'http://mydocker.net:2333'}) + + assert fake_call.call_args[0][0] == [ + 'docker', '--host', 'tcp://mydocker.net:2333', 'ps', + ] + def test_with_host_option_shorthand_equal(self): with mock.patch('subprocess.call') as fake_call: call_docker(['ps'], {'--host': '=tcp://mydocker.net:2333'}) From e008db5c975cbaaf1a5e7ce2b59b5d37e754aa4a Mon Sep 17 00:00:00 2001 From: Ofek Lev Date: Wed, 17 Oct 2018 17:11:36 -0400 Subject: [PATCH 3566/4072] Allow requests 2.20.x Signed-off-by: Ofek Lev --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index a0093d8dd34..8260ebc6948 100644 --- a/setup.py +++ b/setup.py @@ -33,7 +33,7 @@ def find_version(*file_paths): 'cached-property >= 1.2.0, < 2', 'docopt >= 0.6.1, < 0.7', 'PyYAML >= 3.10, < 4', - 'requests >= 2.6.1, != 2.11.0, != 2.12.2, != 2.18.0, < 2.20', + 'requests >= 2.6.1, != 2.11.0, != 2.12.2, != 2.18.0, < 2.21', 'texttable >= 0.9.0, < 0.10', 'websocket-client >= 0.32.0, < 1.0', 'docker >= 3.5.0, < 4.0', From 8f9ead34d36835cc6eb45dc1a70e86cc02d01876 Mon Sep 17 00:00:00 2001 From: Ofek Lev Date: Wed, 17 Oct 2018 17:11:36 -0400 Subject: [PATCH 3567/4072] Allow requests 2.20.x Signed-off-by: Ofek Lev --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index a0093d8dd34..8260ebc6948 100644 --- a/setup.py +++ b/setup.py @@ -33,7 +33,7 @@ def find_version(*file_paths): 'cached-property >= 1.2.0, < 2', 'docopt >= 0.6.1, < 0.7', 'PyYAML >= 3.10, < 4', - 'requests >= 2.6.1, != 2.11.0, != 2.12.2, != 2.18.0, < 2.20', + 'requests >= 2.6.1, != 2.11.0, != 2.12.2, != 2.18.0, < 2.21', 'texttable >= 0.9.0, < 0.10', 'websocket-client >= 0.32.0, < 1.0', 'docker >= 3.5.0, < 4.0', From 1c002b584475d20f1dddc347fcf246bf8537e247 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 24 Oct 2018 15:06:04 -0700 Subject: [PATCH 3568/4072] Fix new flake8 errors/warnings Signed-off-by: Joffrey F --- compose/cli/errors.py | 2 +- compose/config/types.py | 2 +- compose/config/validation.py | 8 ++++---- tests/acceptance/cli_test.py | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/compose/cli/errors.py b/compose/cli/errors.py index 82768970b30..8c89da6c5d2 100644 --- a/compose/cli/errors.py +++ b/compose/cli/errors.py @@ -54,7 +54,7 @@ def handle_connection_errors(client): except APIError as e: log_api_error(e, client.api_version) raise ConnectionError() - except (ReadTimeout, socket.timeout) as e: + except (ReadTimeout, socket.timeout): log_timeout_error(client.timeout) raise ConnectionError() except Exception as e: diff --git a/compose/config/types.py b/compose/config/types.py index 838fb9f58cf..ab8f34e3d7f 100644 --- a/compose/config/types.py +++ b/compose/config/types.py @@ -125,7 +125,7 @@ def parse_extra_hosts(extra_hosts_config): def normalize_path_for_engine(path): - """Windows paths, c:\my\path\shiny, need to be changed to be compatible with + """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/ """ drive, tail = splitdrive(path) diff --git a/compose/config/validation.py b/compose/config/validation.py index 0fdcb37e7dc..87c1f23451d 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -41,15 +41,15 @@ } -VALID_NAME_CHARS = '[a-zA-Z0-9\._\-]' +VALID_NAME_CHARS = r'[a-zA-Z0-9\._\-]' VALID_EXPOSE_FORMAT = r'^\d+(\-\d+)?(\/[a-zA-Z]+)?$' VALID_IPV4_SEG = r'(\d{1,2}|1\d{2}|2[0-4]\d|25[0-5])' -VALID_IPV4_ADDR = "({IPV4_SEG}\.){{3}}{IPV4_SEG}".format(IPV4_SEG=VALID_IPV4_SEG) -VALID_REGEX_IPV4_CIDR = "^{IPV4_ADDR}/(\d|[1-2]\d|3[0-2])$".format(IPV4_ADDR=VALID_IPV4_ADDR) +VALID_IPV4_ADDR = r"({IPV4_SEG}\.){{3}}{IPV4_SEG}".format(IPV4_SEG=VALID_IPV4_SEG) +VALID_REGEX_IPV4_CIDR = r"^{IPV4_ADDR}/(\d|[1-2]\d|3[0-2])$".format(IPV4_ADDR=VALID_IPV4_ADDR) VALID_IPV6_SEG = r'[0-9a-fA-F]{1,4}' -VALID_REGEX_IPV6_CIDR = "".join(""" +VALID_REGEX_IPV6_CIDR = "".join(r""" ^ ( (({IPV6_SEG}:){{7}}{IPV6_SEG})| diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 3d063d8539b..5b0a0e0fd72 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -2361,7 +2361,7 @@ def test_logs_timestamps(self): self.dispatch(['up', '-d']) result = self.dispatch(['logs', '-f', '-t']) - assert re.search('(\d{4})-(\d{2})-(\d{2})T(\d{2})\:(\d{2})\:(\d{2})', result.stdout) + assert re.search(r'(\d{4})-(\d{2})-(\d{2})T(\d{2})\:(\d{2})\:(\d{2})', result.stdout) def test_logs_tail(self): self.base_dir = 'tests/fixtures/logs-tail-composefile' From 3104597e7da64fb3efb2c559a77881d58975b2ec Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 29 Oct 2018 11:43:45 -0700 Subject: [PATCH 3569/4072] "Bump 1.23.0" Signed-off-by: Joffrey F --- CHANGELOG.md | 3 +++ compose/__init__.py | 2 +- script/run/run.sh | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 27f6f3c52c6..0f646709878 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,9 @@ naming scheme accordingly before upgrading. to print a hash string for each service's configuration to facilitate rolling updates. +- Added `--parallel` flag to the `docker-compose build` command, allowing + Compose to build up to 5 images simultaneously. + - Output for the `pull` command now reports status / progress even when pulling multiple images in parallel. diff --git a/compose/__init__.py b/compose/__init__.py index 532f76888f2..b9088474f67 100644 --- a/compose/__init__.py +++ b/compose/__init__.py @@ -1,4 +1,4 @@ from __future__ import absolute_import from __future__ import unicode_literals -__version__ = '1.23.0-rc3' +__version__ = '1.23.0' diff --git a/script/run/run.sh b/script/run/run.sh index ba945d3ed94..d82b54f0f6b 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.23.0-rc3" +VERSION="1.23.0" IMAGE="docker/compose:$VERSION" From 140431d3b96f51233d299df131d06d42ed3bbfba Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 29 Oct 2018 12:22:22 -0700 Subject: [PATCH 3570/4072] "Bump 1.23.0" Signed-off-by: Joffrey F --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f646709878..26fd3f8837b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ Change log ========== -1.23.0 (2018-10-10) +1.23.0 (2018-10-30) ------------------- ### Important note From fd83791d55dd3074fb9c784517198af0d9c43cee Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 29 Oct 2018 14:38:50 -0700 Subject: [PATCH 3571/4072] Bump requests version in requirements.txt Signed-off-by: Joffrey F --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 0ea046589d4..024b671cc99 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,7 +16,7 @@ pypiwin32==219; sys_platform == 'win32' and python_version < '3.6' pypiwin32==223; sys_platform == 'win32' and python_version >= '3.6' PySocks==1.6.7 PyYAML==3.12 -requests==2.19.1 +requests==2.20.0 six==1.10.0 texttable==0.9.1 urllib3==1.21.1; python_version == '3.3' From c8524dc1aa54fac0ef27877a07bf4dbc672ba4c8 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 29 Oct 2018 14:38:50 -0700 Subject: [PATCH 3572/4072] Bump requests version in requirements.txt Signed-off-by: Joffrey F --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 0ea046589d4..024b671cc99 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,7 +16,7 @@ pypiwin32==219; sys_platform == 'win32' and python_version < '3.6' pypiwin32==223; sys_platform == 'win32' and python_version >= '3.6' PySocks==1.6.7 PyYAML==3.12 -requests==2.19.1 +requests==2.20.0 six==1.10.0 texttable==0.9.1 urllib3==1.21.1; python_version == '3.3' From 147a8e9ab86f83f8bd167157a966b250c5dbafdc Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 30 Oct 2018 14:29:07 -0700 Subject: [PATCH 3573/4072] Bump next 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 b9088474f67..aeca7923f79 100644 --- a/compose/__init__.py +++ b/compose/__init__.py @@ -1,4 +1,4 @@ from __future__ import absolute_import from __future__ import unicode_literals -__version__ = '1.23.0' +__version__ = '1.24.0-dev' From 7925f8cfa805d20487b95dd141208e92ed4111b6 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 30 Oct 2018 14:30:11 -0700 Subject: [PATCH 3574/4072] Fix 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 aeca7923f79..652e1fad976 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.24.0-dev' +__version__ = '1.24.0dev' From 03bdd67eb54780f76753f9f98737b8e5fcf90257 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 31 Oct 2018 13:55:20 -0700 Subject: [PATCH 3575/4072] Don't attempt to truncate a None value in Container.slug Signed-off-by: Joffrey F --- compose/container.py | 2 ++ tests/unit/container_test.py | 9 ++++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/compose/container.py b/compose/container.py index 3ee45c8f3f3..026306866a7 100644 --- a/compose/container.py +++ b/compose/container.py @@ -96,6 +96,8 @@ def number(self): @property def slug(self): + if not self.full_slug: + return None return truncate_id(self.full_slug) @property diff --git a/tests/unit/container_test.py b/tests/unit/container_test.py index 64c9cc344de..66c6c15788f 100644 --- a/tests/unit/container_test.py +++ b/tests/unit/container_test.py @@ -5,6 +5,7 @@ from .. import mock from .. import unittest +from compose.const import LABEL_SLUG from compose.container import Container from compose.container import get_container_name @@ -87,7 +88,7 @@ def test_name(self): assert container.name == "composetest_db_1" def test_name_without_project(self): - self.container_dict['Name'] = "/composetest_web_7" + self.container_dict['Name'] = "/composetest_web_7_092cd63296fd" container = Container(None, self.container_dict, has_been_inspected=True) assert container.name_without_project == "web_7_092cd63296fd" @@ -96,6 +97,12 @@ def test_name_without_project_custom_container_name(self): container = Container(None, self.container_dict, has_been_inspected=True) assert container.name_without_project == "custom_name_of_container" + def test_name_without_project_noslug(self): + self.container_dict['Name'] = "/composetest_web_7" + del self.container_dict['Config']['Labels'][LABEL_SLUG] + container = Container(None, self.container_dict, has_been_inspected=True) + assert container.name_without_project == 'web_7' + def test_inspect_if_not_inspected(self): mock_client = mock.create_autospec(docker.APIClient) container = Container(mock_client, dict(Id="the_id")) From 8f4d56a6489ef0fa685e6b57e2b3eb286cd9050f Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 31 Oct 2018 14:24:35 -0700 Subject: [PATCH 3576/4072] Impose consistent behavior across command for --project-directory flag Signed-off-by: Joffrey F --- compose/cli/command.py | 10 ++++++---- compose/cli/main.py | 4 ++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/compose/cli/command.py b/compose/cli/command.py index 8a32a93a290..339a65c53c4 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -23,7 +23,8 @@ def project_from_options(project_dir, options): - environment = Environment.from_env_file(project_dir) + override_dir = options.get('--project-directory') + environment = Environment.from_env_file(override_dir or project_dir) set_parallel_limit(environment) host = options.get('--host') @@ -37,7 +38,7 @@ def project_from_options(project_dir, options): host=host, tls_config=tls_config_from_options(options, environment), environment=environment, - override_dir=options.get('--project-directory'), + override_dir=override_dir, compatibility=options.get('--compatibility'), ) @@ -59,12 +60,13 @@ def set_parallel_limit(environment): def get_config_from_options(base_dir, options): - environment = Environment.from_env_file(base_dir) + override_dir = options.get('--project-directory') + environment = Environment.from_env_file(override_dir or base_dir) config_path = get_config_path_from_options( base_dir, options, environment ) return config.load( - config.find(base_dir, config_path, environment), + config.find(base_dir, config_path, environment, override_dir), options.get('--compatibility') ) diff --git a/compose/cli/main.py b/compose/cli/main.py index 46b547b00f8..afe813ee515 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -306,7 +306,7 @@ def bundle(self, options): -o, --output PATH Path to write the bundle file to. Defaults to ".dab". """ - compose_config = get_config_from_options(self.project_dir, self.toplevel_options) + compose_config = get_config_from_options('.', self.toplevel_options) output = options["--output"] if not output: @@ -336,7 +336,7 @@ def config(self, options): or use the wildcard symbol to display all services """ - compose_config = get_config_from_options(self.project_dir, self.toplevel_options) + compose_config = get_config_from_options('.', self.toplevel_options) image_digests = None if options['--resolve-image-digests']: From 187f48e33888941367776e82e934aa9ee2db56f1 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 31 Oct 2018 13:55:20 -0700 Subject: [PATCH 3577/4072] Don't attempt to truncate a None value in Container.slug Signed-off-by: Joffrey F --- compose/container.py | 2 ++ tests/unit/container_test.py | 9 ++++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/compose/container.py b/compose/container.py index 3ee45c8f3f3..026306866a7 100644 --- a/compose/container.py +++ b/compose/container.py @@ -96,6 +96,8 @@ def number(self): @property def slug(self): + if not self.full_slug: + return None return truncate_id(self.full_slug) @property diff --git a/tests/unit/container_test.py b/tests/unit/container_test.py index 64c9cc344de..66c6c15788f 100644 --- a/tests/unit/container_test.py +++ b/tests/unit/container_test.py @@ -5,6 +5,7 @@ from .. import mock from .. import unittest +from compose.const import LABEL_SLUG from compose.container import Container from compose.container import get_container_name @@ -87,7 +88,7 @@ def test_name(self): assert container.name == "composetest_db_1" def test_name_without_project(self): - self.container_dict['Name'] = "/composetest_web_7" + self.container_dict['Name'] = "/composetest_web_7_092cd63296fd" container = Container(None, self.container_dict, has_been_inspected=True) assert container.name_without_project == "web_7_092cd63296fd" @@ -96,6 +97,12 @@ def test_name_without_project_custom_container_name(self): container = Container(None, self.container_dict, has_been_inspected=True) assert container.name_without_project == "custom_name_of_container" + def test_name_without_project_noslug(self): + self.container_dict['Name'] = "/composetest_web_7" + del self.container_dict['Config']['Labels'][LABEL_SLUG] + container = Container(None, self.container_dict, has_been_inspected=True) + assert container.name_without_project == 'web_7' + def test_inspect_if_not_inspected(self): mock_client = mock.create_autospec(docker.APIClient) container = Container(mock_client, dict(Id="the_id")) From 176a4efaf2dc718e43afeaf01c69679b07654d42 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 31 Oct 2018 14:24:35 -0700 Subject: [PATCH 3578/4072] Impose consistent behavior across command for --project-directory flag Signed-off-by: Joffrey F --- compose/cli/command.py | 10 ++++++---- compose/cli/main.py | 4 ++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/compose/cli/command.py b/compose/cli/command.py index 8a32a93a290..339a65c53c4 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -23,7 +23,8 @@ def project_from_options(project_dir, options): - environment = Environment.from_env_file(project_dir) + override_dir = options.get('--project-directory') + environment = Environment.from_env_file(override_dir or project_dir) set_parallel_limit(environment) host = options.get('--host') @@ -37,7 +38,7 @@ def project_from_options(project_dir, options): host=host, tls_config=tls_config_from_options(options, environment), environment=environment, - override_dir=options.get('--project-directory'), + override_dir=override_dir, compatibility=options.get('--compatibility'), ) @@ -59,12 +60,13 @@ def set_parallel_limit(environment): def get_config_from_options(base_dir, options): - environment = Environment.from_env_file(base_dir) + override_dir = options.get('--project-directory') + environment = Environment.from_env_file(override_dir or base_dir) config_path = get_config_path_from_options( base_dir, options, environment ) return config.load( - config.find(base_dir, config_path, environment), + config.find(base_dir, config_path, environment, override_dir), options.get('--compatibility') ) diff --git a/compose/cli/main.py b/compose/cli/main.py index f2e76c1ad54..610308f20f1 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -306,7 +306,7 @@ def bundle(self, options): -o, --output PATH Path to write the bundle file to. Defaults to ".dab". """ - compose_config = get_config_from_options(self.project_dir, self.toplevel_options) + compose_config = get_config_from_options('.', self.toplevel_options) output = options["--output"] if not output: @@ -336,7 +336,7 @@ def config(self, options): or use the wildcard symbol to display all services """ - compose_config = get_config_from_options(self.project_dir, self.toplevel_options) + compose_config = get_config_from_options('.', self.toplevel_options) image_digests = None if options['--resolve-image-digests']: From b02f1306842d603774a19bf925c5719425f82b5b Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 31 Oct 2018 14:49:00 -0700 Subject: [PATCH 3579/4072] "Bump 1.23.1" Signed-off-by: Joffrey F --- CHANGELOG.md | 11 +++++++++++ compose/__init__.py | 2 +- script/run/run.sh | 2 +- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 26fd3f8837b..66af5ecd491 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,17 @@ Change log ========== +1.23.1 (2018-11-01) +------------------- + +### Bugfixes + +- Fixed a bug where working with containers created with a previous (< 1.23.0) + version of Compose would cause unexpected crashes + +- Fixed an issue where the behavior of the `--project-directory` flag would + vary depending on which subcommand was being used. + 1.23.0 (2018-10-30) ------------------- diff --git a/compose/__init__.py b/compose/__init__.py index b9088474f67..7431dd0c07a 100644 --- a/compose/__init__.py +++ b/compose/__init__.py @@ -1,4 +1,4 @@ from __future__ import absolute_import from __future__ import unicode_literals -__version__ = '1.23.0' +__version__ = '1.23.1' diff --git a/script/run/run.sh b/script/run/run.sh index d82b54f0f6b..4ce09e99245 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.23.0" +VERSION="1.23.1" IMAGE="docker/compose:$VERSION" From db819bf0b212eb6edb751119c84dcc1aa3b44f94 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 2 Nov 2018 11:35:34 -0700 Subject: [PATCH 3580/4072] Fix config merging for isolation and storage_opt keys Signed-off-by: Joffrey F --- compose/config/config.py | 2 ++ compose/service.py | 1 + tests/unit/config/config_test.py | 46 ++++++++++++++++++++++++++++++-- 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index fb2c742f47b..0298b4e2d3a 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -91,6 +91,7 @@ 'healthcheck', 'image', 'ipc', + 'isolation', 'labels', 'links', 'mac_address', @@ -1042,6 +1043,7 @@ def merge_service_dicts(base, override, version): md.merge_mapping('networks', parse_networks) md.merge_mapping('sysctls', parse_sysctls) md.merge_mapping('depends_on', parse_depends_on) + md.merge_mapping('storage_opt', parse_flat_dict) md.merge_sequence('links', ServiceLink.parse) md.merge_sequence('secrets', types.ServiceSecret.parse) md.merge_sequence('configs', types.ServiceConfig.parse) diff --git a/compose/service.py b/compose/service.py index cc6d16a8215..7be2d8feb3e 100644 --- a/compose/service.py +++ b/compose/service.py @@ -85,6 +85,7 @@ 'group_add', 'init', 'ipc', + 'isolation', 'read_only', 'log_driver', 'log_opt', diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index bcff21c9216..203dfbec81b 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1343,8 +1343,11 @@ def test_load_bind_mount_relative_path_with_tilde(self): mount = config_data.services[0].get('volumes')[0] assert mount.target == '/web' assert mount.type == 'bind' - assert (not mount.source.startswith('~') - and mount.source.endswith('{}web'.format(os.path.sep))) + assert ( + not mount.source.startswith('~') and mount.source.endswith( + '{}web'.format(os.path.sep) + ) + ) def test_config_invalid_ipam_config(self): with pytest.raises(ConfigurationError) as excinfo: @@ -2667,6 +2670,45 @@ def test_merge_device_cgroup_rules(self): ['c 7:128 rwm', 'x 3:244 rw', 'f 0:128 n'] ) + def test_merge_isolation(self): + base = { + 'image': 'bar', + 'isolation': 'default', + } + + override = { + 'isolation': 'hyperv', + } + + actual = config.merge_service_dicts(base, override, V2_3) + assert actual == { + 'image': 'bar', + 'isolation': 'hyperv', + } + + def test_merge_storage_opt(self): + base = { + 'image': 'bar', + 'storage_opt': { + 'size': '1G', + 'readonly': 'false', + } + } + + override = { + 'storage_opt': { + 'size': '2G', + 'encryption': 'aes', + } + } + + actual = config.merge_service_dicts(base, override, V2_3) + assert actual['storage_opt'] == { + 'size': '2G', + 'readonly': 'false', + 'encryption': 'aes', + } + def test_external_volume_config(self): config_details = build_config_details({ 'version': '2', From 5b0292245586ee114342a2b1e48e99ba9c541e39 Mon Sep 17 00:00:00 2001 From: Alex Puschinsky Date: Sat, 3 Nov 2018 18:09:28 +0200 Subject: [PATCH 3581/4072] Fix ZSH autocomplete for multiple -f flags Signed-off-by: Alex Puschinsky --- contrib/completion/zsh/_docker-compose | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/contrib/completion/zsh/_docker-compose b/contrib/completion/zsh/_docker-compose index eb619983170..e55c9196475 100755 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -354,7 +354,7 @@ _docker-compose() { '(-): :->command' \ '(-)*:: :->option-or-argument' && ret=0 - local -a relevant_compose_flags relevant_docker_flags compose_options docker_options + local -a relevant_compose_flags relevant_compose_repeatable_flags relevant_docker_flags compose_options docker_options relevant_compose_flags=( "--file" "-f" @@ -368,6 +368,10 @@ _docker-compose() { "--skip-hostname-check" ) + relevant_compose_repeatable_flags=( + "--file" "-f" + ) + relevant_docker_flags=( "--host" "-H" "--tls" @@ -385,9 +389,18 @@ _docker-compose() { fi fi if [[ -n "${relevant_compose_flags[(r)$k]}" ]]; then - compose_options+=$k - if [[ -n "$opt_args[$k]" ]]; then - compose_options+=$opt_args[$k] + if [[ -n "${relevant_compose_repeatable_flags[(r)$k]}" ]]; then + values=("${(@s/:/)opt_args[$k]}") + for value in $values + do + compose_options+=$k + compose_options+=$value + done + else + compose_options+=$k + if [[ -n "$opt_args[$k]" ]]; then + compose_options+=$opt_args[$k] + fi fi fi done From d5eb209be04a733fbe118a27b888c2eff3f40c57 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 5 Nov 2018 13:45:15 -0800 Subject: [PATCH 3582/4072] Fix parse_key_from_error_msg to not error out on non-string keys Signed-off-by: Joffrey F --- compose/config/validation.py | 5 ++++- tests/unit/config/config_test.py | 13 +++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/compose/config/validation.py b/compose/config/validation.py index 87c1f23451d..0395695514d 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -330,7 +330,10 @@ def handle_generic_error(error, path): def parse_key_from_error_msg(error): - return error.message.split("'")[1] + try: + return error.message.split("'")[1] + except IndexError: + return error.message.split('(')[1].split(' ')[0].strip("'") def path_string(path): diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 203dfbec81b..787d8ff4aac 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -613,6 +613,19 @@ def test_config_integer_service_name_raise_validation_error_v2(self): excinfo.exconly() ) + def test_config_integer_service_property_raise_validation_error(self): + with pytest.raises(ConfigurationError) as excinfo: + config.load( + build_config_details({ + 'version': '2.1', + 'services': {'foobar': {'image': 'busybox', 1234: 'hah'}} + }, 'working_dir', 'filename.yml') + ) + + assert ( + "Unsupported config option for services.foobar: '1234'" in excinfo.exconly() + ) + def test_config_invalid_service_name_raise_validation_error(self): with pytest.raises(ConfigurationError) as excinfo: config.load( From ba1e0311a7e9c02ff5a2751a62062aedba151314 Mon Sep 17 00:00:00 2001 From: Collins Abitekaniza Date: Tue, 6 Nov 2018 14:39:53 +0300 Subject: [PATCH 3583/4072] add option to list all processes Signed-off-by: Collins Abitekaniza --- compose/cli/main.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index afe813ee515..e96aac03161 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -694,6 +694,7 @@ def ps(self, options): -q, --quiet Only display IDs --services Display services --filter KEY=VAL Filter services by a property + -a, --all Shows all stopped containers """ if options['--quiet'] and options['--services']: raise UserError('--quiet and --services cannot be combined') @@ -706,10 +707,14 @@ def ps(self, options): print('\n'.join(service.name for service in services)) return - containers = sorted( - self.project.containers(service_names=options['SERVICE'], stopped=True) + - self.project.containers(service_names=options['SERVICE'], one_off=OneOffFilter.only), - key=attrgetter('name')) + if options['--all']: + containers = sorted(self.project.containers(service_names=options['SERVICE'], + one_off=OneOffFilter.include, stopped=True)) + else: + containers = sorted( + self.project.containers(service_names=options['SERVICE'], stopped=True) + + self.project.containers(service_names=options['SERVICE'], one_off=OneOffFilter.only), + key=attrgetter('name')) if options['--quiet']: for container in containers: From 05efe52ccd1c1d64a87e906b389ef31d948067e9 Mon Sep 17 00:00:00 2001 From: Collins Abitekaniza Date: Tue, 6 Nov 2018 14:49:56 +0300 Subject: [PATCH 3584/4072] test --all flag Signed-off-by: Collins Abitekaniza --- compose/cli/main.py | 2 +- tests/acceptance/cli_test.py | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index e96aac03161..f64af894860 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -694,7 +694,7 @@ def ps(self, options): -q, --quiet Only display IDs --services Display services --filter KEY=VAL Filter services by a property - -a, --all Shows all stopped containers + -a, --all Show all stopped containers """ if options['--quiet'] and options['--services']: raise UserError('--quiet and --services cannot be combined') diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 5b0a0e0fd72..f42268f2cf1 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -599,6 +599,14 @@ def test_ps_services_filter_status(self): assert 'with_build' in running.stdout assert 'with_image' in running.stdout + def test_ps_all(self): + self.project.get_service('simple').create_container(one_off='blahblah') + result = self.dispatch(['ps']) + assert 'simple-composefile_simple_run_1' not in result.stdout + + result2 = self.dispatch(['ps', '--all']) + assert 'simple-composefile_simple_run_1' in result2.stdout + def test_pull(self): result = self.dispatch(['pull']) assert 'Pulling simple' in result.stderr From e0e06a4b5627cdccf2408f2a2e91125c4d0ea8b8 Mon Sep 17 00:00:00 2001 From: Collins Abitekaniza Date: Thu, 15 Nov 2018 15:24:50 +0300 Subject: [PATCH 3585/4072] add detail to description for --all flag Signed-off-by: Collins Abitekaniza --- 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 f64af894860..e9c7dbb435b 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -694,7 +694,7 @@ def ps(self, options): -q, --quiet Only display IDs --services Display services --filter KEY=VAL Filter services by a property - -a, --all Show all stopped containers + -a, --all Show all stopped containers (including those created by the run command) """ if options['--quiet'] and options['--services']: raise UserError('--quiet and --services cannot be combined') From 1affc55b17cb68c748ef6f5192705b0a0d536ea0 Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Thu, 22 Nov 2018 15:58:41 +0100 Subject: [PATCH 3586/4072] Adopts 'unknown' as build revision in case git cannot retrieve it. Signed-off-by: Ulysses Souza --- script/build/write-git-sha | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/script/build/write-git-sha b/script/build/write-git-sha index d16743c6f10..be87f505806 100755 --- a/script/build/write-git-sha +++ b/script/build/write-git-sha @@ -2,6 +2,11 @@ # # Write the current commit sha to the file GITSHA. This file is included in # packaging so that `docker-compose version` can include the git sha. -# -set -e -git rev-parse --short HEAD > compose/GITSHA +# sets to 'unknown' and echoes a message if the command is not successful + +DOCKER_COMPOSE_GITSHA="$(git rev-parse --short HEAD)" +if [[ "${?}" != "0" ]]; then + echo "Couldn't get revision of the git repository. Setting to 'unknown' instead" + DOCKER_COMPOSE_GITSHA="unknown" +fi +echo "${DOCKER_COMPOSE_GITSHA}" > compose/GITSHA From 6559af7660fd157a21d0abf4d0e1708201a5c5de Mon Sep 17 00:00:00 2001 From: Sebastian Pipping Date: Mon, 19 Nov 2018 15:01:32 +0100 Subject: [PATCH 3587/4072] Fix one-off commands for "restart: unless-stopped" (fixes #6302) Signed-off-by: Sebastian Pipping --- compose/cli/main.py | 4 ++-- tests/unit/cli_test.py | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index e9c7dbb435b..0dc39d660d3 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -1288,8 +1288,8 @@ def build_container_options(options, detach, command): [""] if options['--entrypoint'] == '' else options['--entrypoint'] ) - if options['--rm']: - container_options['restart'] = None + # Ensure that run command remains one-off (issue #6302) + container_options['restart'] = None if options['--user']: container_options['user'] = options.get('--user') diff --git a/tests/unit/cli_test.py b/tests/unit/cli_test.py index 7c8a1423c7c..a7522f939b9 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -171,7 +171,10 @@ def test_run_service_with_restart_always(self): '--workdir': None, }) - assert mock_client.create_host_config.call_args[1]['restart_policy']['Name'] == 'always' + # NOTE: The "run" command is supposed to be a one-off tool; therefore restart policy "no" + # (the default) is enforced despite explicit wish for "always" in the project + # configuration file + assert not mock_client.create_host_config.call_args[1].get('restart_policy') command = TopLevelCommand(project) command.run({ From e7f82d298919639980f72d8a85acd20da042940d Mon Sep 17 00:00:00 2001 From: Sebastian Pipping Date: Mon, 26 Nov 2018 23:21:26 +0100 Subject: [PATCH 3588/4072] Rename build_container_options to build_one_off_container_options .. to better reflect that its scope is limited to one-off execution (i.e. the "run" command) Signed-off-by: Sebastian Pipping --- compose/cli/main.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 0dc39d660d3..950e5055dcc 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -872,7 +872,7 @@ def run(self, options): else: command = service.options.get('command') - container_options = build_container_options(options, detach, command) + container_options = build_one_off_container_options(options, detach, command) run_one_off_container( container_options, self.project, service, options, self.toplevel_options, self.project_dir @@ -1267,7 +1267,7 @@ def build_action_from_opts(options): return BuildAction.none -def build_container_options(options, detach, command): +def build_one_off_container_options(options, detach, command): container_options = { 'command': command, 'tty': not (detach or options['-T'] or not sys.stdin.isatty()), From ccc777831c658c52ce9b32cd3e99833cf82571bf Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 26 Nov 2018 15:26:27 -0800 Subject: [PATCH 3589/4072] Don't add long path prefix to build context URLs Signed-off-by: Joffrey F --- compose/config/__init__.py | 1 + compose/service.py | 3 ++- tests/unit/service_test.py | 27 +++++++++++++++++++++++++++ 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/compose/config/__init__.py b/compose/config/__init__.py index e1032f3dea0..2b40666f149 100644 --- a/compose/config/__init__.py +++ b/compose/config/__init__.py @@ -6,6 +6,7 @@ from .config import ConfigurationError from .config import DOCKER_CONFIG_KEYS from .config import find +from .config import is_url from .config import load from .config import merge_environment from .config import merge_labels diff --git a/compose/service.py b/compose/service.py index 7be2d8feb3e..240ca9cd378 100644 --- a/compose/service.py +++ b/compose/service.py @@ -27,6 +27,7 @@ from . import const from . import progress_stream from .config import DOCKER_CONFIG_KEYS +from .config import is_url from .config import merge_environment from .config import merge_labels from .config.errors import DependencyError @@ -1676,7 +1677,7 @@ def rewrite_build_path(path): if not six.PY3 and not IS_WINDOWS_PLATFORM: path = path.encode('utf8') - if IS_WINDOWS_PLATFORM and not path.startswith(WINDOWS_LONGPATH_PREFIX): + if IS_WINDOWS_PLATFORM and not is_url(path) and not path.startswith(WINDOWS_LONGPATH_PREFIX): path = WINDOWS_LONGPATH_PREFIX + os.path.normpath(path) return path diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index af1cd1beae1..6c9ea151125 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -21,6 +21,7 @@ from compose.const import LABEL_PROJECT from compose.const import LABEL_SERVICE from compose.const import SECRETS_PATH +from compose.const import WINDOWS_LONGPATH_PREFIX from compose.container import Container from compose.errors import OperationFailedError from compose.parallel import ParallelStreamWriter @@ -38,6 +39,7 @@ from compose.service import NetworkMode from compose.service import NoSuchImageError from compose.service import parse_repository_tag +from compose.service import rewrite_build_path from compose.service import Service from compose.service import ServiceNetworkMode from compose.service import warn_on_masked_volume @@ -1486,3 +1488,28 @@ def test_get_secret_volumes_no_target(self): assert volumes[0].source == secret1['file'] assert volumes[0].target == '{}/{}'.format(SECRETS_PATH, secret1['secret'].source) + + +class RewriteBuildPathTest(unittest.TestCase): + @mock.patch('compose.service.IS_WINDOWS_PLATFORM', True) + def test_rewrite_url_no_prefix(self): + urls = [ + 'http://test.com', + 'https://test.com', + 'git://test.com', + 'github.com/test/test', + 'git@test.com', + ] + for u in urls: + assert rewrite_build_path(u) == u + + @mock.patch('compose.service.IS_WINDOWS_PLATFORM', True) + def test_rewrite_windows_path(self): + assert rewrite_build_path('C:\\context') == WINDOWS_LONGPATH_PREFIX + 'C:\\context' + assert rewrite_build_path( + rewrite_build_path('C:\\context') + ) == rewrite_build_path('C:\\context') + + @mock.patch('compose.service.IS_WINDOWS_PLATFORM', False) + def test_rewrite_unix_path(self): + assert rewrite_build_path('/context') == '/context' From 6ea20e43f68ad6783df1206b45956368c01ca65d Mon Sep 17 00:00:00 2001 From: Sebastian Pipping Date: Tue, 27 Nov 2018 00:24:30 +0100 Subject: [PATCH 3590/4072] README.md: Drop reference to IRC channel Signed-off-by: Sebastian Pipping --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index ea07f6a7d27..a1e391a0778 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,6 @@ Installation and documentation ------------------------------ - Full documentation is available on [Docker's website](https://docs.docker.com/compose/). -- If you have any questions, you can talk in real-time with other developers in the #docker-compose IRC channel on Freenode. [Click here to join using IRCCloud.](https://www.irccloud.com/invite?hostname=irc.freenode.net&channel=%23docker-compose) - Code repository for Compose is on [GitHub](https://github.com/docker/compose) - If you find any problems please fill out an [issue](https://github.com/docker/compose/issues/new) From 6421ae5ea3eee7a88a88feb5eb89b885e43c9679 Mon Sep 17 00:00:00 2001 From: Sebastian Pipping Date: Sun, 18 Nov 2018 18:05:04 +0100 Subject: [PATCH 3591/4072] README.md: Add a few missing full stops One full stop is moved out of a link and a "Thank you!" is added as well. Signed-off-by: Sebastian Pipping --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index a1e391a0778..2750b890f43 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ A `docker-compose.yml` looks like this: image: redis For more information about the Compose file, see the -[Compose file reference](https://github.com/docker/docker.github.io/blob/master/compose/compose-file/compose-versioning.md) +[Compose file reference](https://github.com/docker/docker.github.io/blob/master/compose/compose-file/compose-versioning.md). Compose has commands for managing the whole lifecycle of your application: @@ -48,8 +48,8 @@ Installation and documentation ------------------------------ - Full documentation is available on [Docker's website](https://docs.docker.com/compose/). -- Code repository for Compose is on [GitHub](https://github.com/docker/compose) -- If you find any problems please fill out an [issue](https://github.com/docker/compose/issues/new) +- Code repository for Compose is on [GitHub](https://github.com/docker/compose). +- If you find any problems please fill out an [issue](https://github.com/docker/compose/issues/new). Thank you! Contributing ------------ From 10864ba68733482dbdd1311b0bcfd25c72414382 Mon Sep 17 00:00:00 2001 From: Sebastian Pipping Date: Tue, 27 Nov 2018 00:16:23 +0100 Subject: [PATCH 3592/4072] README.md: Update bug report link Signed-off-by: Sebastian Pipping --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2750b890f43..dd4003048fb 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ Installation and documentation - Full documentation is available on [Docker's website](https://docs.docker.com/compose/). - Code repository for Compose is on [GitHub](https://github.com/docker/compose). -- If you find any problems please fill out an [issue](https://github.com/docker/compose/issues/new). Thank you! +- If you find any problems please fill out an [issue](https://github.com/docker/compose/issues/new/choose). Thank you! Contributing ------------ From 61bb1ea4849a4d7d2b98a6689d5e1813b06639bd Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 27 Nov 2018 17:09:36 -0800 Subject: [PATCH 3593/4072] Don't append slugs to containers created by "up" This change reverts the new naming convention introduced in 1.23 for service containers. One-off containers will now use a slug instead of a sequential number as they do not present addressability concerns and benefit from being capable of running in parallel. Signed-off-by: Joffrey F --- compose/container.py | 11 +++++++++- compose/service.py | 36 +++++++++++++++++-------------- tests/acceptance/cli_test.py | 23 ++++++++++---------- tests/integration/service_test.py | 9 +++----- tests/integration/state_test.py | 8 +++---- tests/unit/container_test.py | 17 +++++++++------ tests/unit/service_test.py | 4 ++-- 7 files changed, 60 insertions(+), 48 deletions(-) diff --git a/compose/container.py b/compose/container.py index 026306866a7..8a2fb240e0d 100644 --- a/compose/container.py +++ b/compose/container.py @@ -7,6 +7,7 @@ from docker.errors import ImageNotFound from .const import LABEL_CONTAINER_NUMBER +from .const import LABEL_ONE_OFF from .const import LABEL_PROJECT from .const import LABEL_SERVICE from .const import LABEL_SLUG @@ -82,12 +83,16 @@ def service(self): @property def name_without_project(self): if self.name.startswith('{0}_{1}'.format(self.project, self.service)): - return '{0}_{1}{2}'.format(self.service, self.number, '_' + self.slug if self.slug else '') + return '{0}_{1}'.format(self.service, self.number if self.number is not None else self.slug) else: return self.name @property def number(self): + if self.one_off: + # One-off containers are no longer assigned numbers and use slugs instead. + return None + number = self.labels.get(LABEL_CONTAINER_NUMBER) if not number: raise ValueError("Container {0} does not have a {1} label".format( @@ -104,6 +109,10 @@ def slug(self): def full_slug(self): return self.labels.get(LABEL_SLUG) + @property + def one_off(self): + return self.labels.get(LABEL_ONE_OFF) == 'True' + @property def ports(self): self.inspect_if_not_inspected() diff --git a/compose/service.py b/compose/service.py index 240ca9cd378..17d631e8a73 100644 --- a/compose/service.py +++ b/compose/service.py @@ -738,16 +738,18 @@ def get_volumes_from_names(self): return [s.source.name for s in self.volumes_from if isinstance(s.source, Service)] def _next_container_number(self, one_off=False): + if one_off: + return None containers = itertools.chain( self._fetch_containers( all=True, - filters={'label': self.labels(one_off=one_off)} + filters={'label': self.labels(one_off=False)} ), self._fetch_containers( all=True, - filters={'label': self.labels(one_off=one_off, legacy=True)} + filters={'label': self.labels(one_off=False, legacy=True)} ) ) - numbers = [c.number for c in containers] + numbers = [c.number for c in containers if c.number is not None] return 1 if not numbers else max(numbers) + 1 def _fetch_containers(self, **fetch_options): @@ -825,7 +827,7 @@ def _get_container_create_options( one_off=False, previous_container=None): add_config_hash = (not one_off and not override_options) - slug = generate_random_id() if previous_container is None else previous_container.full_slug + slug = generate_random_id() if one_off else None container_options = dict( (k, self.options[k]) @@ -834,7 +836,7 @@ def _get_container_create_options( container_options.update(override_options) if not container_options.get('name'): - container_options['name'] = self.get_container_name(self.name, number, slug, one_off) + container_options['name'] = self.get_container_name(self.name, number, slug) container_options.setdefault('detach', True) @@ -1122,12 +1124,12 @@ def labels(self, one_off=False, legacy=False): def custom_container_name(self): return self.options.get('container_name') - def get_container_name(self, service_name, number, slug, one_off=False): - if self.custom_container_name and not one_off: + def get_container_name(self, service_name, number, slug=None): + if self.custom_container_name and slug is None: return self.custom_container_name container_name = build_container_name( - self.project, service_name, number, slug, one_off, + self.project, service_name, number, slug, ) ext_links_origins = [l.split(':')[0] for l in self.options.get('external_links', [])] if container_name in ext_links_origins: @@ -1384,13 +1386,13 @@ def mode(self): # Names -def build_container_name(project, service, number, slug, one_off=False): +def build_container_name(project, service, number, slug=None): bits = [project.lstrip('-_'), service] - if one_off: - bits.append('run') - return '_'.join( - bits + ([str(number), truncate_id(slug)] if slug else [str(number)]) - ) + if slug: + bits.extend(['run', truncate_id(slug)]) + else: + bits.append(str(number)) + return '_'.join(bits) # Images @@ -1579,8 +1581,10 @@ def build_mount(mount_spec): def build_container_labels(label_options, service_labels, number, config_hash, slug): labels = dict(label_options or {}) labels.update(label.split('=', 1) for label in service_labels) - labels[LABEL_CONTAINER_NUMBER] = str(number) - labels[LABEL_SLUG] = slug + if number is not None: + labels[LABEL_CONTAINER_NUMBER] = str(number) + if slug is not None: + labels[LABEL_SLUG] = slug labels[LABEL_VERSION] = __version__ if config_hash: diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index f42268f2cf1..d49c16073b3 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -602,10 +602,10 @@ def test_ps_services_filter_status(self): def test_ps_all(self): self.project.get_service('simple').create_container(one_off='blahblah') result = self.dispatch(['ps']) - assert 'simple-composefile_simple_run_1' not in result.stdout + assert 'simple-composefile_simple_run_' not in result.stdout result2 = self.dispatch(['ps', '--all']) - assert 'simple-composefile_simple_run_1' in result2.stdout + assert 'simple-composefile_simple_run_' in result2.stdout def test_pull(self): result = self.dispatch(['pull']) @@ -973,11 +973,11 @@ def test_down(self): result = self.dispatch(['down', '--rmi=local', '--volumes']) assert 'Stopping v2-full_web_1' in result.stderr assert 'Stopping v2-full_other_1' in result.stderr - assert 'Stopping v2-full_web_run_2' in result.stderr + assert 'Stopping v2-full_web_run_' in result.stderr assert 'Removing v2-full_web_1' in result.stderr assert 'Removing v2-full_other_1' in result.stderr - assert 'Removing v2-full_web_run_1' in result.stderr - assert 'Removing v2-full_web_run_2' in result.stderr + assert 'Removing v2-full_web_run_' in result.stderr + assert 'Removing v2-full_web_run_' in result.stderr assert 'Removing volume v2-full_data' in result.stderr assert 'Removing image v2-full_web' in result.stderr assert 'Removing image busybox' not in result.stderr @@ -1039,8 +1039,8 @@ def test_up_attached(self): stopped=True )[0].name_without_project - assert '{} | simple'.format(simple_name) in result.stdout - assert '{} | another'.format(another_name) in result.stdout + assert '{} | simple'.format(simple_name) in result.stdout + assert '{} | another'.format(another_name) in result.stdout assert '{} exited with code 0'.format(simple_name) in result.stdout assert '{} exited with code 0'.format(another_name) in result.stdout @@ -2340,10 +2340,9 @@ def test_logs_follow_logs_from_restarted_containers(self): result = wait_on_process(proc) - assert len(re.findall( - r'logs-restart-composefile_another_1_[a-f0-9]{12} exited with code 1', - result.stdout - )) == 3 + assert result.stdout.count( + r'logs-restart-composefile_another_1 exited with code 1' + ) == 3 assert result.stdout.count('world') == 3 def test_logs_default(self): @@ -2714,7 +2713,7 @@ def test_forward_exitval(self): ) result = wait_on_process(proc, returncode=1) - assert re.findall(r'exit-code-from_another_1_[a-f0-9]{12} exited with code 1', result.stdout) + assert 'exit-code-from_another_1 exited with code 1' in result.stdout def test_exit_code_from_signal_stop(self): self.base_dir = 'tests/fixtures/exit-code-from' diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index edc19528716..000f6838c7c 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -32,7 +32,6 @@ from compose.const import LABEL_ONE_OFF from compose.const import LABEL_PROJECT from compose.const import LABEL_SERVICE -from compose.const import LABEL_SLUG from compose.const import LABEL_VERSION from compose.container import Container from compose.errors import OperationFailedError @@ -1269,16 +1268,15 @@ def test_scale_with_stopped_containers(self): test that those containers are restarted and not removed/recreated. """ service = self.create_service('web') - valid_numbers = [service._next_container_number(), service._next_container_number()] - service.create_container(number=valid_numbers[0]) - service.create_container(number=valid_numbers[1]) + service.create_container(number=1) + service.create_container(number=2) ParallelStreamWriter.instance = None with mock.patch('sys.stderr', new_callable=StringIO) as mock_stderr: service.scale(2) for container in service.containers(): assert container.is_running - assert container.number in valid_numbers + assert container.number in [1, 2] captured_output = mock_stderr.getvalue() assert 'Creating' not in captured_output @@ -1610,7 +1608,6 @@ def test_labels(self): labels = ctnr.labels.items() for pair in expected.items(): assert pair in labels - assert ctnr.labels[LABEL_SLUG] == ctnr.full_slug def test_empty_labels(self): labels_dict = {'foo': '', 'bar': ''} diff --git a/tests/integration/state_test.py b/tests/integration/state_test.py index a41986f4659..b7d38a4ba86 100644 --- a/tests/integration/state_test.py +++ b/tests/integration/state_test.py @@ -198,14 +198,14 @@ def test_service_recreated_when_dependency_created(self): db, = [c for c in containers if c.service == 'db'] assert set(get_links(web)) == { - 'composetest_db_{}_{}'.format(db.number, db.slug), + 'composetest_db_1', 'db', - 'db_{}_{}'.format(db.number, db.slug) + 'db_1', } assert set(get_links(nginx)) == { - 'composetest_web_{}_{}'.format(web.number, web.slug), + 'composetest_web_1', 'web', - 'web_{}_{}'.format(web.number, web.slug) + 'web_1', } diff --git a/tests/unit/container_test.py b/tests/unit/container_test.py index 66c6c15788f..fde17847a13 100644 --- a/tests/unit/container_test.py +++ b/tests/unit/container_test.py @@ -5,6 +5,7 @@ from .. import mock from .. import unittest +from compose.const import LABEL_ONE_OFF from compose.const import LABEL_SLUG from compose.container import Container from compose.container import get_container_name @@ -32,7 +33,6 @@ def setUp(self): "com.docker.compose.project": "composetest", "com.docker.compose.service": "web", "com.docker.compose.container-number": "7", - "com.docker.compose.slug": "092cd63296fdc446ad432d3905dd1fcbe12a2ba6b52" }, } } @@ -88,20 +88,23 @@ def test_name(self): assert container.name == "composetest_db_1" def test_name_without_project(self): - self.container_dict['Name'] = "/composetest_web_7_092cd63296fd" + self.container_dict['Name'] = "/composetest_web_7" container = Container(None, self.container_dict, has_been_inspected=True) - assert container.name_without_project == "web_7_092cd63296fd" + assert container.name_without_project == "web_7" def test_name_without_project_custom_container_name(self): self.container_dict['Name'] = "/custom_name_of_container" container = Container(None, self.container_dict, has_been_inspected=True) assert container.name_without_project == "custom_name_of_container" - def test_name_without_project_noslug(self): - self.container_dict['Name'] = "/composetest_web_7" - del self.container_dict['Config']['Labels'][LABEL_SLUG] + def test_name_without_project_one_off(self): + self.container_dict['Name'] = "/composetest_web_092cd63296f" + self.container_dict['Config']['Labels'][LABEL_SLUG] = ( + "092cd63296fdc446ad432d3905dd1fcbe12a2ba6b52" + ) + self.container_dict['Config']['Labels'][LABEL_ONE_OFF] = 'True' container = Container(None, self.container_dict, has_been_inspected=True) - assert container.name_without_project == 'web_7' + assert container.name_without_project == 'web_092cd63296fd' def test_inspect_if_not_inspected(self): mock_client = mock.create_autospec(docker.APIClient) diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 6c9ea151125..99adea34b43 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -175,10 +175,10 @@ def test_memory_swap_limit(self): def test_self_reference_external_link(self): service = Service( name='foo', - external_links=['default_foo_1_bdfa3ed91e2c'] + external_links=['default_foo_1'] ) with pytest.raises(DependencyError): - service.get_container_name('foo', 1, 'bdfa3ed91e2c') + service.get_container_name('foo', 1) def test_mem_reservation(self): self.mock_client.create_host_config.return_value = {} From dbe3a6e9a938cc10e9543e17285cc0045c24b965 Mon Sep 17 00:00:00 2001 From: Collins Abitekaniza Date: Fri, 16 Nov 2018 18:33:54 +0300 Subject: [PATCH 3594/4072] stdout failed for failing services Signed-off-by: Collins Abitekaniza --- compose/parallel.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/compose/parallel.py b/compose/parallel.py index 34a498ca760..32ee602f4a3 100644 --- a/compose/parallel.py +++ b/compose/parallel.py @@ -50,7 +50,11 @@ def parallel_execute_watch(events, writer, errors, results, msg, get_name): error_to_reraise = None for obj, result, exception in events: if exception is None: - writer.write(msg, get_name(obj), 'done', green) + if callable(getattr(obj, 'containers', None)) and not obj.containers(): + # If service has no containers started + writer.write(msg, get_name(obj), 'failed', red) + else: + writer.write(msg, get_name(obj), 'done', green) results.append(result) elif isinstance(exception, ImageNotFound): # This is to bubble up ImageNotFound exceptions to the client so we From b8b6199958eaa77c7364b07431d1f6b2e8c1e220 Mon Sep 17 00:00:00 2001 From: Collins Abitekaniza Date: Fri, 16 Nov 2018 18:34:18 +0300 Subject: [PATCH 3595/4072] refactor cli tests Signed-off-by: Collins Abitekaniza --- 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 f42268f2cf1..d808da9c5b3 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -2229,6 +2229,7 @@ def test_stop_signal(self): def test_start_no_containers(self): result = self.dispatch(['start'], returncode=1) + assert 'failed' in result.stderr assert 'No containers to start' in result.stderr @v2_only() From d1bf27e73a59a6d66434dfdd7231965a3a101880 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 28 Nov 2018 11:53:26 -0800 Subject: [PATCH 3596/4072] Bump 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 024b671cc99..45ed9049d24 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ cached-property==1.3.0 certifi==2017.4.17 chardet==3.0.4 colorama==0.4.0; sys_platform == 'win32' -docker==3.5.0 +docker==3.6.0 docker-pycreds==0.3.0 dockerpty==0.4.1 docopt==0.6.2 diff --git a/setup.py b/setup.py index 8260ebc6948..22dafdb223c 100644 --- a/setup.py +++ b/setup.py @@ -36,7 +36,7 @@ def find_version(*file_paths): 'requests >= 2.6.1, != 2.11.0, != 2.12.2, != 2.18.0, < 2.21', 'texttable >= 0.9.0, < 0.10', 'websocket-client >= 0.32.0, < 1.0', - 'docker >= 3.5.0, < 4.0', + 'docker >= 3.6.0, < 4.0', 'dockerpty >= 0.4.1, < 0.5', 'six >= 1.3.0, < 2', 'jsonschema >= 2.5.1, < 3', From d9e05f262fd98880b3809fe3b45c998b325ad6cf Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Thu, 22 Nov 2018 11:34:34 +0100 Subject: [PATCH 3597/4072] Avoids pushing the same image more than once. Signed-off-by: Ulysses Souza --- compose/project.py | 10 +++++++++- tests/integration/project_test.py | 18 ++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/compose/project.py b/compose/project.py index 92c352050af..8fab09e54b8 100644 --- a/compose/project.py +++ b/compose/project.py @@ -29,6 +29,7 @@ from .service import ContainerPidMode from .service import ConvergenceStrategy from .service import NetworkMode +from .service import parse_repository_tag from .service import PidMode from .service import Service from .service import ServiceNetworkMode @@ -592,8 +593,15 @@ def pull_service(service): service.pull(ignore_pull_failures, silent=silent) def push(self, service_names=None, ignore_push_failures=False): + unique_images = set() for service in self.get_services(service_names, include_deps=False): - service.push(ignore_push_failures) + # Considering and as the same + repo, tag, sep = parse_repository_tag(service.image_name) + service_image_name = sep.join((repo, tag)) if tag else sep.join((repo, 'latest')) + + if service_image_name not in unique_images: + service.push(ignore_push_failures) + unique_images.add(service_image_name) def _labeled_containers(self, stopped=False, one_off=OneOffFilter.exclude): ctnrs = list(filter(None, [ diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 57f3b70748e..203b7bb3dcf 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -1995,3 +1995,21 @@ def test_project_up_name_starts_with_illegal_char(self): net_data = self.client.inspect_network(full_net_name) assert net_data assert net_data['Labels'][LABEL_PROJECT] == '-dashtest' + + def test_avoid_multiple_push(self): + service_config_latest = {'image': 'busybox:latest', 'build': '.'} + service_config_default = {'image': 'busybox', 'build': '.'} + service_config_sha = { + 'image': 'busybox@sha256:38a203e1986cf79639cfb9b2e1d6e773de84002feea2d4eb006b52004ee8502d', + 'build': '.' + } + svc1 = self.create_service('busy1', **service_config_latest) + svc1_1 = self.create_service('busy11', **service_config_latest) + svc2 = self.create_service('busy2', **service_config_default) + svc2_1 = self.create_service('busy21', **service_config_default) + svc3 = self.create_service('busy3', **service_config_sha) + svc3_1 = self.create_service('busy31', **service_config_sha) + project = Project('composetest', [svc1, svc1_1, svc2, svc2_1, svc3, svc3_1], self.client) + with mock.patch('compose.service.Service.push') as fake_push: + project.push(ignore_push_failures=True) + assert fake_push.call_count == 2 From a7894ddfea6298ee1d4e07fe703f0edfe1996842 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 28 Nov 2018 14:19:21 -0800 Subject: [PATCH 3598/4072] Fix incorrect pre-create container name in up logs Signed-off-by: Joffrey F --- compose/service.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/compose/service.py b/compose/service.py index 17d631e8a73..f6dfa7c72bb 100644 --- a/compose/service.py +++ b/compose/service.py @@ -129,7 +129,7 @@ class NoSuchImageError(Exception): pass -ServiceName = namedtuple('ServiceName', 'project service number slug') +ServiceName = namedtuple('ServiceName', 'project service number') ConvergencePlan = namedtuple('ConvergencePlan', 'action containers') @@ -447,13 +447,11 @@ def create_and_start(service, n): containers, errors = parallel_execute( [ - ServiceName(self.project, self.name, index, generate_random_id()) + ServiceName(self.project, self.name, index) for index in range(i, i + scale) ], lambda service_name: create_and_start(self, service_name.number), - lambda service_name: self.get_container_name( - service_name.service, service_name.number, service_name.slug - ), + lambda service_name: self.get_container_name(service_name.service, service_name.number), "Creating" ) for error in errors.values(): From 8a0090c18c69b4815afde6dbf5150aee871d14c5 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 24 Oct 2018 16:08:56 -0700 Subject: [PATCH 3599/4072] Only use supported protocols when starting engine CLI subprocess Signed-off-by: Joffrey F --- compose/cli/main.py | 4 +++- tests/unit/cli/main_test.py | 8 ++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 610308f20f1..afe813ee515 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -1452,7 +1452,9 @@ def call_docker(args, dockeropts): if verify: tls_options.append('--tlsverify') if host: - tls_options.extend(['--host', host.lstrip('=')]) + tls_options.extend( + ['--host', re.sub(r'^https?://', 'tcp://', host.lstrip('='))] + ) args = [executable_path] + tls_options + args log.debug(" ".join(map(pipes.quote, args))) diff --git a/tests/unit/cli/main_test.py b/tests/unit/cli/main_test.py index 1a2dfbcf353..2e97f2c8794 100644 --- a/tests/unit/cli/main_test.py +++ b/tests/unit/cli/main_test.py @@ -155,6 +155,14 @@ def test_with_host_option(self): 'docker', '--host', 'tcp://mydocker.net:2333', 'ps' ] + def test_with_http_host(self): + with mock.patch('subprocess.call') as fake_call: + call_docker(['ps'], {'--host': 'http://mydocker.net:2333'}) + + assert fake_call.call_args[0][0] == [ + 'docker', '--host', 'tcp://mydocker.net:2333', 'ps', + ] + def test_with_host_option_shorthand_equal(self): with mock.patch('subprocess.call') as fake_call: call_docker(['ps'], {'--host': '=tcp://mydocker.net:2333'}) From 4682e766a3a9350ad5db2287fcb0c84c812aab88 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 2 Nov 2018 11:35:34 -0700 Subject: [PATCH 3600/4072] Fix config merging for isolation and storage_opt keys Signed-off-by: Joffrey F --- compose/config/config.py | 2 ++ compose/service.py | 1 + tests/unit/config/config_test.py | 39 ++++++++++++++++++++++++++++++++ 3 files changed, 42 insertions(+) diff --git a/compose/config/config.py b/compose/config/config.py index 7abab254681..714397eb33c 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -91,6 +91,7 @@ 'healthcheck', 'image', 'ipc', + 'isolation', 'labels', 'links', 'mac_address', @@ -1042,6 +1043,7 @@ def merge_service_dicts(base, override, version): md.merge_mapping('networks', parse_networks) md.merge_mapping('sysctls', parse_sysctls) md.merge_mapping('depends_on', parse_depends_on) + md.merge_mapping('storage_opt', parse_flat_dict) md.merge_sequence('links', ServiceLink.parse) md.merge_sequence('secrets', types.ServiceSecret.parse) md.merge_sequence('configs', types.ServiceConfig.parse) diff --git a/compose/service.py b/compose/service.py index 73744801d0c..6a0a9dac038 100644 --- a/compose/service.py +++ b/compose/service.py @@ -85,6 +85,7 @@ 'group_add', 'init', 'ipc', + 'isolation', 'read_only', 'log_driver', 'log_opt', diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 52c89a9e0c6..3d7235d58dd 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -2644,6 +2644,45 @@ def test_merge_device_cgroup_rules(self): ['c 7:128 rwm', 'x 3:244 rw', 'f 0:128 n'] ) + def test_merge_isolation(self): + base = { + 'image': 'bar', + 'isolation': 'default', + } + + override = { + 'isolation': 'hyperv', + } + + actual = config.merge_service_dicts(base, override, V2_3) + assert actual == { + 'image': 'bar', + 'isolation': 'hyperv', + } + + def test_merge_storage_opt(self): + base = { + 'image': 'bar', + 'storage_opt': { + 'size': '1G', + 'readonly': 'false', + } + } + + override = { + 'storage_opt': { + 'size': '2G', + 'encryption': 'aes', + } + } + + actual = config.merge_service_dicts(base, override, V2_3) + assert actual['storage_opt'] == { + 'size': '2G', + 'readonly': 'false', + 'encryption': 'aes', + } + def test_external_volume_config(self): config_details = build_config_details({ 'version': '2', From dce70a55668f8c36d6543341d16c1c5f9545b07b Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 5 Nov 2018 13:45:15 -0800 Subject: [PATCH 3601/4072] Fix parse_key_from_error_msg to not error out on non-string keys Signed-off-by: Joffrey F --- compose/config/validation.py | 5 ++++- tests/unit/config/config_test.py | 13 +++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/compose/config/validation.py b/compose/config/validation.py index 87c1f23451d..0395695514d 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -330,7 +330,10 @@ def handle_generic_error(error, path): def parse_key_from_error_msg(error): - return error.message.split("'")[1] + try: + return error.message.split("'")[1] + except IndexError: + return error.message.split('(')[1].split(' ')[0].strip("'") def path_string(path): diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 3d7235d58dd..a6219b2009e 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -613,6 +613,19 @@ def test_config_integer_service_name_raise_validation_error_v2(self): excinfo.exconly() ) + def test_config_integer_service_property_raise_validation_error(self): + with pytest.raises(ConfigurationError) as excinfo: + config.load( + build_config_details({ + 'version': '2.1', + 'services': {'foobar': {'image': 'busybox', 1234: 'hah'}} + }, 'working_dir', 'filename.yml') + ) + + assert ( + "Unsupported config option for services.foobar: '1234'" in excinfo.exconly() + ) + def test_config_invalid_service_name_raise_validation_error(self): with pytest.raises(ConfigurationError) as excinfo: config.load( From 07e2717beea575489893aa31c67aadf327f977f5 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 26 Nov 2018 15:26:27 -0800 Subject: [PATCH 3602/4072] Don't add long path prefix to build context URLs Signed-off-by: Joffrey F --- compose/config/__init__.py | 1 + compose/service.py | 3 ++- tests/unit/service_test.py | 27 +++++++++++++++++++++++++++ 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/compose/config/__init__.py b/compose/config/__init__.py index e1032f3dea0..2b40666f149 100644 --- a/compose/config/__init__.py +++ b/compose/config/__init__.py @@ -6,6 +6,7 @@ from .config import ConfigurationError from .config import DOCKER_CONFIG_KEYS from .config import find +from .config import is_url from .config import load from .config import merge_environment from .config import merge_labels diff --git a/compose/service.py b/compose/service.py index 6a0a9dac038..d2f49caebec 100644 --- a/compose/service.py +++ b/compose/service.py @@ -27,6 +27,7 @@ from . import const from . import progress_stream from .config import DOCKER_CONFIG_KEYS +from .config import is_url from .config import merge_environment from .config import merge_labels from .config.errors import DependencyError @@ -1674,7 +1675,7 @@ def rewrite_build_path(path): if not six.PY3 and not IS_WINDOWS_PLATFORM: path = path.encode('utf8') - if IS_WINDOWS_PLATFORM and not path.startswith(WINDOWS_LONGPATH_PREFIX): + if IS_WINDOWS_PLATFORM and not is_url(path) and not path.startswith(WINDOWS_LONGPATH_PREFIX): path = WINDOWS_LONGPATH_PREFIX + os.path.normpath(path) return path diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index af1cd1beae1..6c9ea151125 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -21,6 +21,7 @@ from compose.const import LABEL_PROJECT from compose.const import LABEL_SERVICE from compose.const import SECRETS_PATH +from compose.const import WINDOWS_LONGPATH_PREFIX from compose.container import Container from compose.errors import OperationFailedError from compose.parallel import ParallelStreamWriter @@ -38,6 +39,7 @@ from compose.service import NetworkMode from compose.service import NoSuchImageError from compose.service import parse_repository_tag +from compose.service import rewrite_build_path from compose.service import Service from compose.service import ServiceNetworkMode from compose.service import warn_on_masked_volume @@ -1486,3 +1488,28 @@ def test_get_secret_volumes_no_target(self): assert volumes[0].source == secret1['file'] assert volumes[0].target == '{}/{}'.format(SECRETS_PATH, secret1['secret'].source) + + +class RewriteBuildPathTest(unittest.TestCase): + @mock.patch('compose.service.IS_WINDOWS_PLATFORM', True) + def test_rewrite_url_no_prefix(self): + urls = [ + 'http://test.com', + 'https://test.com', + 'git://test.com', + 'github.com/test/test', + 'git@test.com', + ] + for u in urls: + assert rewrite_build_path(u) == u + + @mock.patch('compose.service.IS_WINDOWS_PLATFORM', True) + def test_rewrite_windows_path(self): + assert rewrite_build_path('C:\\context') == WINDOWS_LONGPATH_PREFIX + 'C:\\context' + assert rewrite_build_path( + rewrite_build_path('C:\\context') + ) == rewrite_build_path('C:\\context') + + @mock.patch('compose.service.IS_WINDOWS_PLATFORM', False) + def test_rewrite_unix_path(self): + assert rewrite_build_path('/context') == '/context' From 66ed9b492ecefc7bdf57aa22ecac2c551a6fb67e Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 27 Nov 2018 17:09:36 -0800 Subject: [PATCH 3603/4072] Don't append slugs to containers created by "up" This change reverts the new naming convention introduced in 1.23 for service containers. One-off containers will now use a slug instead of a sequential number as they do not present addressability concerns and benefit from being capable of running in parallel. Signed-off-by: Joffrey F --- compose/container.py | 11 +++++++++- compose/service.py | 36 +++++++++++++++++-------------- tests/acceptance/cli_test.py | 19 ++++++++-------- tests/integration/service_test.py | 9 +++----- tests/integration/state_test.py | 8 +++---- tests/unit/container_test.py | 17 +++++++++------ tests/unit/service_test.py | 4 ++-- 7 files changed, 58 insertions(+), 46 deletions(-) diff --git a/compose/container.py b/compose/container.py index 026306866a7..8a2fb240e0d 100644 --- a/compose/container.py +++ b/compose/container.py @@ -7,6 +7,7 @@ from docker.errors import ImageNotFound from .const import LABEL_CONTAINER_NUMBER +from .const import LABEL_ONE_OFF from .const import LABEL_PROJECT from .const import LABEL_SERVICE from .const import LABEL_SLUG @@ -82,12 +83,16 @@ def service(self): @property def name_without_project(self): if self.name.startswith('{0}_{1}'.format(self.project, self.service)): - return '{0}_{1}{2}'.format(self.service, self.number, '_' + self.slug if self.slug else '') + return '{0}_{1}'.format(self.service, self.number if self.number is not None else self.slug) else: return self.name @property def number(self): + if self.one_off: + # One-off containers are no longer assigned numbers and use slugs instead. + return None + number = self.labels.get(LABEL_CONTAINER_NUMBER) if not number: raise ValueError("Container {0} does not have a {1} label".format( @@ -104,6 +109,10 @@ def slug(self): def full_slug(self): return self.labels.get(LABEL_SLUG) + @property + def one_off(self): + return self.labels.get(LABEL_ONE_OFF) == 'True' + @property def ports(self): self.inspect_if_not_inspected() diff --git a/compose/service.py b/compose/service.py index d2f49caebec..4dafff0f4b6 100644 --- a/compose/service.py +++ b/compose/service.py @@ -736,16 +736,18 @@ def get_volumes_from_names(self): return [s.source.name for s in self.volumes_from if isinstance(s.source, Service)] def _next_container_number(self, one_off=False): + if one_off: + return None containers = itertools.chain( self._fetch_containers( all=True, - filters={'label': self.labels(one_off=one_off)} + filters={'label': self.labels(one_off=False)} ), self._fetch_containers( all=True, - filters={'label': self.labels(one_off=one_off, legacy=True)} + filters={'label': self.labels(one_off=False, legacy=True)} ) ) - numbers = [c.number for c in containers] + numbers = [c.number for c in containers if c.number is not None] return 1 if not numbers else max(numbers) + 1 def _fetch_containers(self, **fetch_options): @@ -823,7 +825,7 @@ def _get_container_create_options( one_off=False, previous_container=None): add_config_hash = (not one_off and not override_options) - slug = generate_random_id() if previous_container is None else previous_container.full_slug + slug = generate_random_id() if one_off else None container_options = dict( (k, self.options[k]) @@ -832,7 +834,7 @@ def _get_container_create_options( container_options.update(override_options) if not container_options.get('name'): - container_options['name'] = self.get_container_name(self.name, number, slug, one_off) + container_options['name'] = self.get_container_name(self.name, number, slug) container_options.setdefault('detach', True) @@ -1120,12 +1122,12 @@ def labels(self, one_off=False, legacy=False): def custom_container_name(self): return self.options.get('container_name') - def get_container_name(self, service_name, number, slug, one_off=False): - if self.custom_container_name and not one_off: + def get_container_name(self, service_name, number, slug=None): + if self.custom_container_name and slug is None: return self.custom_container_name container_name = build_container_name( - self.project, service_name, number, slug, one_off, + self.project, service_name, number, slug, ) ext_links_origins = [l.split(':')[0] for l in self.options.get('external_links', [])] if container_name in ext_links_origins: @@ -1382,13 +1384,13 @@ def mode(self): # Names -def build_container_name(project, service, number, slug, one_off=False): +def build_container_name(project, service, number, slug=None): bits = [project.lstrip('-_'), service] - if one_off: - bits.append('run') - return '_'.join( - bits + ([str(number), truncate_id(slug)] if slug else [str(number)]) - ) + if slug: + bits.extend(['run', truncate_id(slug)]) + else: + bits.append(str(number)) + return '_'.join(bits) # Images @@ -1577,8 +1579,10 @@ def build_mount(mount_spec): def build_container_labels(label_options, service_labels, number, config_hash, slug): labels = dict(label_options or {}) labels.update(label.split('=', 1) for label in service_labels) - labels[LABEL_CONTAINER_NUMBER] = str(number) - labels[LABEL_SLUG] = slug + if number is not None: + labels[LABEL_CONTAINER_NUMBER] = str(number) + if slug is not None: + labels[LABEL_SLUG] = slug labels[LABEL_VERSION] = __version__ if config_hash: diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 5b0a0e0fd72..ae01a88efac 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -965,11 +965,11 @@ def test_down(self): result = self.dispatch(['down', '--rmi=local', '--volumes']) assert 'Stopping v2-full_web_1' in result.stderr assert 'Stopping v2-full_other_1' in result.stderr - assert 'Stopping v2-full_web_run_2' in result.stderr + assert 'Stopping v2-full_web_run_' in result.stderr assert 'Removing v2-full_web_1' in result.stderr assert 'Removing v2-full_other_1' in result.stderr - assert 'Removing v2-full_web_run_1' in result.stderr - assert 'Removing v2-full_web_run_2' in result.stderr + assert 'Removing v2-full_web_run_' in result.stderr + assert 'Removing v2-full_web_run_' in result.stderr assert 'Removing volume v2-full_data' in result.stderr assert 'Removing image v2-full_web' in result.stderr assert 'Removing image busybox' not in result.stderr @@ -1031,8 +1031,8 @@ def test_up_attached(self): stopped=True )[0].name_without_project - assert '{} | simple'.format(simple_name) in result.stdout - assert '{} | another'.format(another_name) in result.stdout + assert '{} | simple'.format(simple_name) in result.stdout + assert '{} | another'.format(another_name) in result.stdout assert '{} exited with code 0'.format(simple_name) in result.stdout assert '{} exited with code 0'.format(another_name) in result.stdout @@ -2332,10 +2332,9 @@ def test_logs_follow_logs_from_restarted_containers(self): result = wait_on_process(proc) - assert len(re.findall( - r'logs-restart-composefile_another_1_[a-f0-9]{12} exited with code 1', - result.stdout - )) == 3 + assert result.stdout.count( + r'logs-restart-composefile_another_1 exited with code 1' + ) == 3 assert result.stdout.count('world') == 3 def test_logs_default(self): @@ -2706,7 +2705,7 @@ def test_forward_exitval(self): ) result = wait_on_process(proc, returncode=1) - assert re.findall(r'exit-code-from_another_1_[a-f0-9]{12} exited with code 1', result.stdout) + assert 'exit-code-from_another_1 exited with code 1' in result.stdout def test_exit_code_from_signal_stop(self): self.base_dir = 'tests/fixtures/exit-code-from' diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index edc19528716..000f6838c7c 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -32,7 +32,6 @@ from compose.const import LABEL_ONE_OFF from compose.const import LABEL_PROJECT from compose.const import LABEL_SERVICE -from compose.const import LABEL_SLUG from compose.const import LABEL_VERSION from compose.container import Container from compose.errors import OperationFailedError @@ -1269,16 +1268,15 @@ def test_scale_with_stopped_containers(self): test that those containers are restarted and not removed/recreated. """ service = self.create_service('web') - valid_numbers = [service._next_container_number(), service._next_container_number()] - service.create_container(number=valid_numbers[0]) - service.create_container(number=valid_numbers[1]) + service.create_container(number=1) + service.create_container(number=2) ParallelStreamWriter.instance = None with mock.patch('sys.stderr', new_callable=StringIO) as mock_stderr: service.scale(2) for container in service.containers(): assert container.is_running - assert container.number in valid_numbers + assert container.number in [1, 2] captured_output = mock_stderr.getvalue() assert 'Creating' not in captured_output @@ -1610,7 +1608,6 @@ def test_labels(self): labels = ctnr.labels.items() for pair in expected.items(): assert pair in labels - assert ctnr.labels[LABEL_SLUG] == ctnr.full_slug def test_empty_labels(self): labels_dict = {'foo': '', 'bar': ''} diff --git a/tests/integration/state_test.py b/tests/integration/state_test.py index a41986f4659..b7d38a4ba86 100644 --- a/tests/integration/state_test.py +++ b/tests/integration/state_test.py @@ -198,14 +198,14 @@ def test_service_recreated_when_dependency_created(self): db, = [c for c in containers if c.service == 'db'] assert set(get_links(web)) == { - 'composetest_db_{}_{}'.format(db.number, db.slug), + 'composetest_db_1', 'db', - 'db_{}_{}'.format(db.number, db.slug) + 'db_1', } assert set(get_links(nginx)) == { - 'composetest_web_{}_{}'.format(web.number, web.slug), + 'composetest_web_1', 'web', - 'web_{}_{}'.format(web.number, web.slug) + 'web_1', } diff --git a/tests/unit/container_test.py b/tests/unit/container_test.py index 66c6c15788f..fde17847a13 100644 --- a/tests/unit/container_test.py +++ b/tests/unit/container_test.py @@ -5,6 +5,7 @@ from .. import mock from .. import unittest +from compose.const import LABEL_ONE_OFF from compose.const import LABEL_SLUG from compose.container import Container from compose.container import get_container_name @@ -32,7 +33,6 @@ def setUp(self): "com.docker.compose.project": "composetest", "com.docker.compose.service": "web", "com.docker.compose.container-number": "7", - "com.docker.compose.slug": "092cd63296fdc446ad432d3905dd1fcbe12a2ba6b52" }, } } @@ -88,20 +88,23 @@ def test_name(self): assert container.name == "composetest_db_1" def test_name_without_project(self): - self.container_dict['Name'] = "/composetest_web_7_092cd63296fd" + self.container_dict['Name'] = "/composetest_web_7" container = Container(None, self.container_dict, has_been_inspected=True) - assert container.name_without_project == "web_7_092cd63296fd" + assert container.name_without_project == "web_7" def test_name_without_project_custom_container_name(self): self.container_dict['Name'] = "/custom_name_of_container" container = Container(None, self.container_dict, has_been_inspected=True) assert container.name_without_project == "custom_name_of_container" - def test_name_without_project_noslug(self): - self.container_dict['Name'] = "/composetest_web_7" - del self.container_dict['Config']['Labels'][LABEL_SLUG] + def test_name_without_project_one_off(self): + self.container_dict['Name'] = "/composetest_web_092cd63296f" + self.container_dict['Config']['Labels'][LABEL_SLUG] = ( + "092cd63296fdc446ad432d3905dd1fcbe12a2ba6b52" + ) + self.container_dict['Config']['Labels'][LABEL_ONE_OFF] = 'True' container = Container(None, self.container_dict, has_been_inspected=True) - assert container.name_without_project == 'web_7' + assert container.name_without_project == 'web_092cd63296fd' def test_inspect_if_not_inspected(self): mock_client = mock.create_autospec(docker.APIClient) diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 6c9ea151125..99adea34b43 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -175,10 +175,10 @@ def test_memory_swap_limit(self): def test_self_reference_external_link(self): service = Service( name='foo', - external_links=['default_foo_1_bdfa3ed91e2c'] + external_links=['default_foo_1'] ) with pytest.raises(DependencyError): - service.get_container_name('foo', 1, 'bdfa3ed91e2c') + service.get_container_name('foo', 1) def test_mem_reservation(self): self.mock_client.create_host_config.return_value = {} From bffb6094dad503ea42710723aecc7836ca91efb8 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 28 Nov 2018 11:53:26 -0800 Subject: [PATCH 3604/4072] Bump 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 024b671cc99..45ed9049d24 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ cached-property==1.3.0 certifi==2017.4.17 chardet==3.0.4 colorama==0.4.0; sys_platform == 'win32' -docker==3.5.0 +docker==3.6.0 docker-pycreds==0.3.0 dockerpty==0.4.1 docopt==0.6.2 diff --git a/setup.py b/setup.py index 8260ebc6948..22dafdb223c 100644 --- a/setup.py +++ b/setup.py @@ -36,7 +36,7 @@ def find_version(*file_paths): 'requests >= 2.6.1, != 2.11.0, != 2.12.2, != 2.18.0, < 2.21', 'texttable >= 0.9.0, < 0.10', 'websocket-client >= 0.32.0, < 1.0', - 'docker >= 3.5.0, < 4.0', + 'docker >= 3.6.0, < 4.0', 'dockerpty >= 0.4.1, < 0.5', 'six >= 1.3.0, < 2', 'jsonschema >= 2.5.1, < 3', From f266e3459d7b3f3407e4dfa9d287d743186e3f64 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 28 Nov 2018 14:19:21 -0800 Subject: [PATCH 3605/4072] Fix incorrect pre-create container name in up logs Signed-off-by: Joffrey F --- compose/service.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/compose/service.py b/compose/service.py index 4dafff0f4b6..12fb0232564 100644 --- a/compose/service.py +++ b/compose/service.py @@ -129,7 +129,7 @@ class NoSuchImageError(Exception): pass -ServiceName = namedtuple('ServiceName', 'project service number slug') +ServiceName = namedtuple('ServiceName', 'project service number') ConvergencePlan = namedtuple('ConvergencePlan', 'action containers') @@ -445,13 +445,11 @@ def create_and_start(service, n): containers, errors = parallel_execute( [ - ServiceName(self.project, self.name, index, generate_random_id()) + ServiceName(self.project, self.name, index) for index in range(i, i + scale) ], lambda service_name: create_and_start(self, service_name.number), - lambda service_name: self.get_container_name( - service_name.service, service_name.number, service_name.slug - ), + lambda service_name: self.get_container_name(service_name.service, service_name.number), "Creating" ) for error in errors.values(): From 1110ad0108c45bd91ec494d5b8bbf74dc5c407de Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 28 Nov 2018 14:26:26 -0800 Subject: [PATCH 3606/4072] "Bump 1.23.2" Signed-off-by: Joffrey F --- CHANGELOG.md | 24 ++++++++++++++++++++++++ compose/__init__.py | 2 +- script/run/run.sh | 2 +- 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 66af5ecd491..4a7a2ffe97c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,30 @@ Change log ========== +1.23.2 (2018-11-28) +------------------- + +### Bugfixes + +- Reverted a 1.23.0 change that appended random strings to container names + created by `docker-compose up`, causing addressability issues. + Note: Containers created by `docker-compose run` will continue to use + randomly generated names to avoid collisions during parallel runs. + +- Fixed an issue where some `dockerfile` paths would fail unexpectedly when + attempting to build on Windows. + +- Fixed a bug where build context URLs would fail to build on Windows. + +- Fixed a bug that caused `run` and `exec` commands to fail for some otherwise + accepted values of the `--host` parameter. + +- Fixed an issue where overrides for the `storage_opt` and `isolation` keys in + service definitions weren't properly applied. + +- Fixed a bug where some invalid Compose files would raise an uncaught + exception during validation. + 1.23.1 (2018-11-01) ------------------- diff --git a/compose/__init__.py b/compose/__init__.py index 7431dd0c07a..1cb5be0dae6 100644 --- a/compose/__init__.py +++ b/compose/__init__.py @@ -1,4 +1,4 @@ from __future__ import absolute_import from __future__ import unicode_literals -__version__ = '1.23.1' +__version__ = '1.23.2' diff --git a/script/run/run.sh b/script/run/run.sh index 4ce09e99245..d3069ff7801 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.23.1" +VERSION="1.23.2" IMAGE="docker/compose:$VERSION" From d563a6640539ad6395d69561c719d886c0d1861c Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Fri, 30 Nov 2018 23:30:55 +0100 Subject: [PATCH 3607/4072] Update `reorder_python_imports` version to fix Unicode problems Signed-off-by: Ulysses Souza --- .pre-commit-config.yaml | 2 +- compose/utils.py | 1 - tests/acceptance/cli_test.py | 1 - 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b7bcc8466c5..e447294eb7a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,7 +14,7 @@ - id: requirements-txt-fixer - id: trailing-whitespace - repo: git://github.com/asottile/reorder_python_imports - sha: v0.3.5 + sha: v1.3.4 hooks: - id: reorder-python-imports language_version: 'python2.7' diff --git a/compose/utils.py b/compose/utils.py index 9f0441d08f2..a1e5e6435d8 100644 --- a/compose/utils.py +++ b/compose/utils.py @@ -3,7 +3,6 @@ import codecs import hashlib -import json import json.decoder import logging import ntpath diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index d49c16073b3..b429e356720 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -4,7 +4,6 @@ import datetime import json -import os import os.path import re import signal From 7b82b2e8c721010b73f664e9d4657746a1fcd92b Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 30 Nov 2018 16:24:38 -0800 Subject: [PATCH 3608/4072] Add SSH-enabled docker SDK to requirements Signed-off-by: Joffrey F --- requirements.txt | 1 + setup.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 45ed9049d24..fbb285b6b41 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,6 +12,7 @@ functools32==3.2.3.post2; python_version < '3.2' idna==2.5 ipaddress==1.0.18 jsonschema==2.6.0 +paramiko==2.4.2 pypiwin32==219; sys_platform == 'win32' and python_version < '3.6' pypiwin32==223; sys_platform == 'win32' and python_version >= '3.6' PySocks==1.6.7 diff --git a/setup.py b/setup.py index 22dafdb223c..9efc642c429 100644 --- a/setup.py +++ b/setup.py @@ -36,7 +36,7 @@ def find_version(*file_paths): 'requests >= 2.6.1, != 2.11.0, != 2.12.2, != 2.18.0, < 2.21', 'texttable >= 0.9.0, < 0.10', 'websocket-client >= 0.32.0, < 1.0', - 'docker >= 3.6.0, < 4.0', + 'docker[ssh] >= 3.6.0, < 4.0', 'dockerpty >= 0.4.1, < 0.5', 'six >= 1.3.0, < 2', 'jsonschema >= 2.5.1, < 3', From fc3df83d39b63d3a67db7650e858d92803cb1033 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 30 Nov 2018 17:59:55 -0800 Subject: [PATCH 3609/4072] Update setup.py for modern pypi /setuptools Remove pandoc dependencies Signed-off-by: Joffrey F --- MANIFEST.in | 3 +-- script/release/push-release | 8 -------- script/release/release.py | 4 ---- script/release/setup-venv.sh | 2 +- setup.py | 17 ++++++++++++----- 5 files changed, 14 insertions(+), 20 deletions(-) diff --git a/MANIFEST.in b/MANIFEST.in index 8c6f932bab5..fca685eaae0 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -4,8 +4,7 @@ include requirements.txt include requirements-dev.txt include tox.ini include *.md -exclude README.md -include README.rst +include README.md include compose/config/*.json include compose/GITSHA recursive-include contrib/completion * diff --git a/script/release/push-release b/script/release/push-release index 0578aaff82f..f28c1d4fea8 100755 --- a/script/release/push-release +++ b/script/release/push-release @@ -26,12 +26,6 @@ if [ -z "$(command -v jq 2> /dev/null)" ]; then fi -if [ -z "$(command -v pandoc 2> /dev/null)" ]; then - >&2 echo "$0 requires http://pandoc.org/" - >&2 echo "Please install it and make sure it is available on your \$PATH." - exit 2 -fi - API=https://api.github.com/repos REPO=docker/compose GITHUB_REPO=git@github.com:$REPO @@ -59,8 +53,6 @@ docker push docker/compose-tests:latest docker push docker/compose-tests:$VERSION echo "Uploading package to PyPI" -pandoc -f markdown -t rst README.md -o README.rst -sed -i -e 's/logo.png?raw=true/https:\/\/github.com\/docker\/compose\/raw\/master\/logo.png?raw=true/' README.rst ./script/build/write-git-sha python setup.py sdist bdist_wheel if [ "$(command -v twine 2> /dev/null)" ]; then diff --git a/script/release/release.py b/script/release/release.py index 6574bfdddaf..63bf863dfc9 100755 --- a/script/release/release.py +++ b/script/release/release.py @@ -9,7 +9,6 @@ import time from distutils.core import run_setup -import pypandoc from jinja2 import Template from release.bintray import BintrayAPI from release.const import BINTRAY_ORG @@ -277,9 +276,6 @@ def finalize(args): repository.checkout_branch(br_name) - pypandoc.convert_file( - os.path.join(REPO_ROOT, 'README.md'), 'rst', outputfile=os.path.join(REPO_ROOT, 'README.rst') - ) run_setup(os.path.join(REPO_ROOT, 'setup.py'), script_args=['sdist', 'bdist_wheel']) merge_status = pr_data.merge() diff --git a/script/release/setup-venv.sh b/script/release/setup-venv.sh index 780fc800f4c..ab419be0cdd 100755 --- a/script/release/setup-venv.sh +++ b/script/release/setup-venv.sh @@ -39,9 +39,9 @@ fi $VENV_PYTHONBIN -m pip install -U Jinja2==2.10 \ PyGithub==1.39 \ - pypandoc==1.4 \ GitPython==2.1.9 \ requests==2.18.4 \ + setuptools==40.6.2 \ twine==1.11.0 $VENV_PYTHONBIN setup.py develop diff --git a/setup.py b/setup.py index 9efc642c429..4c49bab7b80 100644 --- a/setup.py +++ b/setup.py @@ -77,19 +77,26 @@ def find_version(*file_paths): name='docker-compose', version=find_version("compose", "__init__.py"), description='Multi-container orchestration for Docker', + long_description=read('README.md'), + long_description_content_type='text/markdown', url='https://www.docker.com/', + project_urls={ + 'Documentation': 'https://docs.docker.com/compose/overview', + 'Changelog': 'https://github.com/docker/compose/blob/release/CHANGELOG.md', + 'Source': 'https://github.com/docker/compose', + 'Tracker': 'https://github.com/docker/compose/issues', + }, author='Docker, Inc.', license='Apache License 2.0', packages=find_packages(exclude=['tests.*', 'tests']), include_package_data=True, - test_suite='nose.collector', install_requires=install_requires, extras_require=extras_require, tests_require=tests_require, - entry_points=""" - [console_scripts] - docker-compose=compose.cli.main:main - """, + python_requires='>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*', + entry_points={ + 'console_scripts': ['docker-compose=compose.cli.main:main'], + }, classifiers=[ 'Development Status :: 5 - Production/Stable', 'Environment :: Console', From d3933cd34a88e811b9612d728569fc9be6d560d4 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 4 Dec 2018 17:13:40 -0800 Subject: [PATCH 3610/4072] Move multi-push test to unit tests Signed-off-by: Joffrey F --- tests/integration/project_test.py | 18 ------------------ tests/unit/project_test.py | 20 ++++++++++++++++++++ 2 files changed, 20 insertions(+), 18 deletions(-) diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 203b7bb3dcf..57f3b70748e 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -1995,21 +1995,3 @@ def test_project_up_name_starts_with_illegal_char(self): net_data = self.client.inspect_network(full_net_name) assert net_data assert net_data['Labels'][LABEL_PROJECT] == '-dashtest' - - def test_avoid_multiple_push(self): - service_config_latest = {'image': 'busybox:latest', 'build': '.'} - service_config_default = {'image': 'busybox', 'build': '.'} - service_config_sha = { - 'image': 'busybox@sha256:38a203e1986cf79639cfb9b2e1d6e773de84002feea2d4eb006b52004ee8502d', - 'build': '.' - } - svc1 = self.create_service('busy1', **service_config_latest) - svc1_1 = self.create_service('busy11', **service_config_latest) - svc2 = self.create_service('busy2', **service_config_default) - svc2_1 = self.create_service('busy21', **service_config_default) - svc3 = self.create_service('busy3', **service_config_sha) - svc3_1 = self.create_service('busy31', **service_config_sha) - project = Project('composetest', [svc1, svc1_1, svc2, svc2_1, svc3, svc3_1], self.client) - with mock.patch('compose.service.Service.push') as fake_push: - project.push(ignore_push_failures=True) - assert fake_push.call_count == 2 diff --git a/tests/unit/project_test.py b/tests/unit/project_test.py index 1cc841814d5..f17bc571e5e 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -620,3 +620,23 @@ def test_error_parallel_pull(self, mock_write): self.mock_client.pull.side_effect = OperationFailedError(b'pull error') with pytest.raises(ProjectError): project.pull(parallel_pull=True) + + def test_avoid_multiple_push(self): + service_config_latest = {'image': 'busybox:latest', 'build': '.'} + service_config_default = {'image': 'busybox', 'build': '.'} + service_config_sha = { + 'image': 'busybox@sha256:38a203e1986cf79639cfb9b2e1d6e773de84002feea2d4eb006b52004ee8502d', + 'build': '.' + } + svc1 = Service('busy1', **service_config_latest) + svc1_1 = Service('busy11', **service_config_latest) + svc2 = Service('busy2', **service_config_default) + svc2_1 = Service('busy21', **service_config_default) + svc3 = Service('busy3', **service_config_sha) + svc3_1 = Service('busy31', **service_config_sha) + project = Project( + 'composetest', [svc1, svc1_1, svc2, svc2_1, svc3, svc3_1], self.mock_client + ) + with mock.patch('compose.service.Service.push') as fake_push: + project.push() + assert fake_push.call_count == 2 From afc161a0b1ce74cdcbc9be84d1dca58b10f0daf1 Mon Sep 17 00:00:00 2001 From: Hiroshi Ioka Date: Fri, 7 Dec 2018 17:12:40 +0900 Subject: [PATCH 3611/4072] reject environment variable that contains white spaces Signed-off-by: Hiroshi Ioka --- compose/config/environment.py | 29 ++++++++++++++++++++------- compose/config/errors.py | 4 ++++ tests/unit/config/environment_test.py | 10 +++++++++ 3 files changed, 36 insertions(+), 7 deletions(-) diff --git a/compose/config/environment.py b/compose/config/environment.py index 0087b612807..675ab10ebe0 100644 --- a/compose/config/environment.py +++ b/compose/config/environment.py @@ -5,22 +5,33 @@ import contextlib import logging import os +import string import six from ..const import IS_WINDOWS_PLATFORM from .errors import ConfigurationError +from .errors import EnvFileNotFound log = logging.getLogger(__name__) +whitespace = set(string.whitespace) + def split_env(env): if isinstance(env, six.binary_type): env = env.decode('utf-8', 'replace') + key = value = None if '=' in env: - return env.split('=', 1) + key, value = env.split('=', 1) else: - return env, None + key = env + for k in key: + if k in whitespace: + raise ConfigurationError( + "environment variable name '%s' may not contains white spaces." % key + ) + return key, value def env_vars_from_file(filename): @@ -28,16 +39,19 @@ def env_vars_from_file(filename): Read in a line delimited file of environment variables. """ if not os.path.exists(filename): - raise ConfigurationError("Couldn't find env file: %s" % filename) + raise EnvFileNotFound("Couldn't find env file: %s" % filename) elif not os.path.isfile(filename): - raise ConfigurationError("%s is not a file." % (filename)) + raise EnvFileNotFound("%s is not a file." % (filename)) env = {} with contextlib.closing(codecs.open(filename, 'r', 'utf-8-sig')) as fileobj: for line in fileobj: line = line.strip() if line and not line.startswith('#'): - k, v = split_env(line) - env[k] = v + try: + k, v = split_env(line) + env[k] = v + except ConfigurationError as e: + raise ConfigurationError('In file {}: {}'.format(filename, e.msg)) return env @@ -55,9 +69,10 @@ def _initialize(): env_file_path = os.path.join(base_dir, '.env') try: return cls(env_vars_from_file(env_file_path)) - except ConfigurationError: + except EnvFileNotFound: pass return result + instance = _initialize() instance.update(os.environ) return instance diff --git a/compose/config/errors.py b/compose/config/errors.py index f5c038088d3..9b2078f2c6d 100644 --- a/compose/config/errors.py +++ b/compose/config/errors.py @@ -19,6 +19,10 @@ def __str__(self): return self.msg +class EnvFileNotFound(ConfigurationError): + pass + + class DependencyError(ConfigurationError): pass diff --git a/tests/unit/config/environment_test.py b/tests/unit/config/environment_test.py index 854aee5a358..88eb0d6e11a 100644 --- a/tests/unit/config/environment_test.py +++ b/tests/unit/config/environment_test.py @@ -9,6 +9,7 @@ from compose.config.environment import env_vars_from_file from compose.config.environment import Environment +from compose.config.errors import ConfigurationError from tests import unittest @@ -52,3 +53,12 @@ def test_env_vars_from_file_bom(self): assert env_vars_from_file(str(tmpdir.join('bom.env'))) == { 'PARK_BOM': '박봄' } + + def test_env_vars_from_file_whitespace(self): + tmpdir = pytest.ensuretemp('env_file') + self.addCleanup(tmpdir.remove) + with codecs.open('{}/whitespace.env'.format(str(tmpdir)), 'w', encoding='utf-8') as f: + f.write('WHITESPACE =yes\n') + with pytest.raises(ConfigurationError) as exc: + env_vars_from_file(str(tmpdir.join('whitespace.env'))) + assert 'environment variable' in exc.exconly() From a2bcf526652164a8e1d440052a56a9e5f190f18e Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Sat, 1 Dec 2018 03:15:05 +0100 Subject: [PATCH 3612/4072] Fix merge on networks section Signed-off-by: Ulysses Souza --- compose/config/config.py | 18 ++++- tests/unit/config/config_test.py | 112 +++++++++++++++++++++++++++++-- 2 files changed, 125 insertions(+), 5 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 0298b4e2d3a..f8c9773b30d 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -1040,7 +1040,6 @@ def merge_service_dicts(base, override, version): md.merge_mapping('environment', parse_environment) md.merge_mapping('labels', parse_labels) md.merge_mapping('ulimits', parse_flat_dict) - md.merge_mapping('networks', parse_networks) md.merge_mapping('sysctls', parse_sysctls) md.merge_mapping('depends_on', parse_depends_on) md.merge_mapping('storage_opt', parse_flat_dict) @@ -1050,6 +1049,7 @@ def merge_service_dicts(base, override, version): md.merge_sequence('security_opt', types.SecurityOpt.parse) md.merge_mapping('extra_hosts', parse_extra_hosts) + md.merge_field('networks', merge_networks, default={}) for field in ['volumes', 'devices']: md.merge_field(field, merge_path_mappings) @@ -1154,6 +1154,22 @@ def merge_deploy(base, override): return dict(md) +def merge_networks(base, override): + merged_networks = {} + all_network_names = set(base) | set(override) + base = {k: {} for k in base} if isinstance(base, list) else base + override = {k: {} for k in override} if isinstance(override, list) else override + for network_name in all_network_names: + md = MergeDict(base.get(network_name, {}), override.get(network_name, {})) + md.merge_field('aliases', merge_unique_items_lists, []) + md.merge_field('link_local_ips', merge_unique_items_lists, []) + md.merge_scalar('priority') + md.merge_scalar('ipv4_address') + md.merge_scalar('ipv6_address') + merged_networks[network_name] = dict(md) + return merged_networks + + def merge_reservations(base, override): md = MergeDict(base, override) md.merge_scalar('cpus') diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 787d8ff4aac..39ff374d411 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1085,8 +1085,43 @@ def test_load_with_multiple_files_mismatched_networks_format(self): details = config.ConfigDetails('.', [base_file, override_file]) web_service = config.load(details).services[0] assert web_service['networks'] == { - 'foobar': {'aliases': ['foo', 'bar']}, - 'baz': None + 'foobar': {'aliases': ['bar', 'foo']}, + 'baz': {} + } + + def test_load_with_multiple_files_mismatched_networks_format_inverse_order(self): + base_file = config.ConfigFile( + 'override.yaml', + { + 'version': '2', + 'services': { + 'web': { + 'networks': ['baz'] + } + } + } + ) + override_file = config.ConfigFile( + 'base.yaml', + { + 'version': '2', + 'services': { + 'web': { + 'image': 'example/web', + 'networks': { + 'foobar': {'aliases': ['foo', 'bar']} + } + } + }, + 'networks': {'foobar': {}, 'baz': {}} + } + ) + + details = config.ConfigDetails('.', [base_file, override_file]) + web_service = config.load(details).services[0] + assert web_service['networks'] == { + 'foobar': {'aliases': ['bar', 'foo']}, + 'baz': {} } def test_load_with_multiple_files_v2(self): @@ -3843,8 +3878,77 @@ def test_no_base(self): class MergeNetworksTest(unittest.TestCase, MergeListsTest): config_name = 'networks' - base_config = ['frontend', 'backend'] - override_config = ['monitoring'] + base_config = {'default': {'aliases': ['foo.bar', 'foo.baz']}} + override_config = {'default': {'ipv4_address': '123.234.123.234'}} + + def test_no_network_overrides(self): + service_dict = config.merge_service_dicts( + {self.config_name: self.base_config}, + {self.config_name: self.override_config}, + DEFAULT_VERSION) + assert service_dict[self.config_name] == { + 'default': { + 'aliases': ['foo.bar', 'foo.baz'], + 'ipv4_address': '123.234.123.234' + } + } + + def test_all_properties(self): + service_dict = config.merge_service_dicts( + {self.config_name: { + 'default': { + 'aliases': ['foo.bar', 'foo.baz'], + 'link_local_ips': ['192.168.1.10', '192.168.1.11'], + 'ipv4_address': '111.111.111.111', + 'ipv6_address': 'FE80:CD00:0000:0CDE:1257:0000:211E:729C-first' + } + }}, + {self.config_name: { + 'default': { + 'aliases': ['foo.baz', 'foo.baz2'], + 'link_local_ips': ['192.168.1.11', '192.168.1.12'], + 'ipv4_address': '123.234.123.234', + 'ipv6_address': 'FE80:CD00:0000:0CDE:1257:0000:211E:729C-second' + } + }}, + DEFAULT_VERSION) + + assert service_dict[self.config_name] == { + 'default': { + 'aliases': ['foo.bar', 'foo.baz', 'foo.baz2'], + 'link_local_ips': ['192.168.1.10', '192.168.1.11', '192.168.1.12'], + 'ipv4_address': '123.234.123.234', + 'ipv6_address': 'FE80:CD00:0000:0CDE:1257:0000:211E:729C-second' + } + } + + def test_no_network_name_overrides(self): + service_dict = config.merge_service_dicts( + { + self.config_name: { + 'default': { + 'aliases': ['foo.bar', 'foo.baz'], + 'ipv4_address': '123.234.123.234' + } + } + }, + { + self.config_name: { + 'another_network': { + 'ipv4_address': '123.234.123.234' + } + } + }, + DEFAULT_VERSION) + assert service_dict[self.config_name] == { + 'default': { + 'aliases': ['foo.bar', 'foo.baz'], + 'ipv4_address': '123.234.123.234' + }, + 'another_network': { + 'ipv4_address': '123.234.123.234' + } + } class MergeStringsOrListsTest(unittest.TestCase): From 8b293d486e6276555bf94ded4e8559503502dff3 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 10 Dec 2018 17:52:52 -0800 Subject: [PATCH 3613/4072] Use improved API fields for project events when possible Signed-off-by: Joffrey F --- compose/cli/log_printer.py | 3 +- compose/const.py | 1 - compose/project.py | 70 ++++++++++++--- tests/unit/project_test.py | 172 ++++++++++++++++++++++++++++++++++++- 4 files changed, 233 insertions(+), 13 deletions(-) diff --git a/compose/cli/log_printer.py b/compose/cli/log_printer.py index bd6723ef24e..8aa93a84404 100644 --- a/compose/cli/log_printer.py +++ b/compose/cli/log_printer.py @@ -236,7 +236,8 @@ def watch_events(thread_map, event_stream, presenters, thread_args): thread_map[event['id']] = build_thread( event['container'], next(presenters), - *thread_args) + *thread_args + ) def consume_queue(queue, cascade_stop): diff --git a/compose/const.py b/compose/const.py index 0e66a297a0a..46d81ae7191 100644 --- a/compose/const.py +++ b/compose/const.py @@ -7,7 +7,6 @@ DEFAULT_TIMEOUT = 10 HTTP_TIMEOUT = 60 -IMAGE_EVENTS = ['delete', 'import', 'load', 'pull', 'push', 'save', 'tag', 'untag'] IS_WINDOWS_PLATFORM = (sys.platform == "win32") LABEL_CONTAINER_NUMBER = 'com.docker.compose.container-number' LABEL_ONE_OFF = 'com.docker.compose.oneoff' diff --git a/compose/project.py b/compose/project.py index 8fab09e54b8..5c4ce6e17ca 100644 --- a/compose/project.py +++ b/compose/project.py @@ -10,13 +10,13 @@ import enum import six from docker.errors import APIError +from docker.utils import version_lt from . import parallel from .config import ConfigurationError from .config.config import V1 from .config.sort_services import get_container_name_from_network_mode from .config.sort_services import get_service_name_from_network_mode -from .const import IMAGE_EVENTS from .const import LABEL_ONE_OFF from .const import LABEL_PROJECT from .const import LABEL_SERVICE @@ -402,11 +402,13 @@ def create( detached=True, start=False) - def events(self, service_names=None): + def _legacy_event_processor(self, service_names): + # Only for v1 files or when Compose is forced to use an older API version def build_container_event(event, container): time = datetime.datetime.fromtimestamp(event['time']) time = time.replace( - microsecond=microseconds_from_time_nano(event['timeNano'])) + microsecond=microseconds_from_time_nano(event['timeNano']) + ) return { 'time': time, 'type': 'container', @@ -425,17 +427,15 @@ def build_container_event(event, container): filters={'label': self.labels()}, decode=True ): - # The first part of this condition is a guard against some events - # broadcasted by swarm that don't have a status field. + # This is a guard against some events broadcasted by swarm that + # don't have a status field. # See https://github.com/docker/compose/issues/3316 - if 'status' not in event or event['status'] in IMAGE_EVENTS: - # We don't receive any image events because labels aren't applied - # to images + if 'status' not in event: continue - # TODO: get labels from the API v1.22 , see github issue 2618 try: - # this can fail if the container has been removed + # this can fail if the container has been removed or if the event + # refers to an image container = Container.from_id(self.client, event['id']) except APIError: continue @@ -443,6 +443,56 @@ def build_container_event(event, container): continue yield build_container_event(event, container) + def events(self, service_names=None): + if version_lt(self.client.api_version, '1.22'): + # New, better event API was introduced in 1.22. + return self._legacy_event_processor(service_names) + + def build_container_event(event): + container_attrs = event['Actor']['Attributes'] + time = datetime.datetime.fromtimestamp(event['time']) + time = time.replace( + microsecond=microseconds_from_time_nano(event['timeNano']) + ) + + container = None + try: + container = Container.from_id(self.client, event['id']) + except APIError: + # Container may have been removed (e.g. if this is a destroy event) + pass + + return { + 'time': time, + 'type': 'container', + 'action': event['status'], + 'id': event['Actor']['ID'], + 'service': container_attrs.get(LABEL_SERVICE), + 'attributes': dict([ + (k, v) for k, v in container_attrs.items() + if not k.startswith('com.docker.compose.') + ]), + 'container': container, + } + + def yield_loop(service_names): + for event in self.client.events( + filters={'label': self.labels()}, + decode=True + ): + # TODO: support other event types + if event.get('Type') != 'container': + continue + + try: + if event['Actor']['Attributes'][LABEL_SERVICE] not in service_names: + continue + except KeyError: + continue + yield build_container_event(event) + + return yield_loop(set(service_names) if service_names else self.service_names) + def up(self, service_names=None, start_deps=True, diff --git a/tests/unit/project_test.py b/tests/unit/project_test.py index f17bc571e5e..4aea91a0d5a 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -254,9 +254,10 @@ def test_use_volumes_from_service_container(self): [container_ids[0] + ':rw'] ) - def test_events(self): + def test_events_legacy(self): services = [Service(name='web'), Service(name='db')] project = Project('test', services, self.mock_client) + self.mock_client.api_version = '1.21' self.mock_client.events.return_value = iter([ { 'status': 'create', @@ -362,6 +363,175 @@ def get_container(cid): }, ] + def test_events(self): + services = [Service(name='web'), Service(name='db')] + project = Project('test', services, self.mock_client) + self.mock_client.api_version = '1.35' + self.mock_client.events.return_value = iter([ + { + 'status': 'create', + 'from': 'example/image', + 'Type': 'container', + 'Actor': { + 'ID': 'abcde', + 'Attributes': { + 'com.docker.compose.project': 'test', + 'com.docker.compose.service': 'web', + 'image': 'example/image', + 'name': 'test_web_1', + } + }, + 'id': 'abcde', + 'time': 1420092061, + 'timeNano': 14200920610000002000, + }, + { + 'status': 'attach', + 'from': 'example/image', + 'Type': 'container', + 'Actor': { + 'ID': 'abcde', + 'Attributes': { + 'com.docker.compose.project': 'test', + 'com.docker.compose.service': 'web', + 'image': 'example/image', + 'name': 'test_web_1', + } + }, + 'id': 'abcde', + 'time': 1420092061, + 'timeNano': 14200920610000003000, + }, + { + 'status': 'create', + 'from': 'example/other', + 'Type': 'container', + 'Actor': { + 'ID': 'bdbdbd', + 'Attributes': { + 'image': 'example/other', + 'name': 'shrewd_einstein', + } + }, + 'id': 'bdbdbd', + 'time': 1420092061, + 'timeNano': 14200920610000005000, + }, + { + 'status': 'create', + 'from': 'example/db', + 'Type': 'container', + 'Actor': { + 'ID': 'ababa', + 'Attributes': { + 'com.docker.compose.project': 'test', + 'com.docker.compose.service': 'db', + 'image': 'example/db', + 'name': 'test_db_1', + } + }, + 'id': 'ababa', + 'time': 1420092061, + 'timeNano': 14200920610000004000, + }, + { + 'status': 'destroy', + 'from': 'example/db', + 'Type': 'container', + 'Actor': { + 'ID': 'eeeee', + 'Attributes': { + 'com.docker.compose.project': 'test', + 'com.docker.compose.service': 'db', + 'image': 'example/db', + 'name': 'test_db_1', + } + }, + 'id': 'eeeee', + 'time': 1420092061, + 'timeNano': 14200920610000004000, + }, + ]) + + def dt_with_microseconds(dt, us): + return datetime.datetime.fromtimestamp(dt).replace(microsecond=us) + + def get_container(cid): + if cid == 'eeeee': + raise NotFound(None, None, "oops") + if cid == 'abcde': + name = 'web' + labels = {LABEL_SERVICE: name} + elif cid == 'ababa': + name = 'db' + labels = {LABEL_SERVICE: name} + else: + labels = {} + name = '' + return { + 'Id': cid, + 'Config': {'Labels': labels}, + 'Name': '/project_%s_1' % name, + } + + self.mock_client.inspect_container.side_effect = get_container + + events = project.events() + + events_list = list(events) + # Assert the return value is a generator + assert not list(events) + assert events_list == [ + { + 'type': 'container', + 'service': 'web', + 'action': 'create', + 'id': 'abcde', + 'attributes': { + 'name': 'test_web_1', + 'image': 'example/image', + }, + 'time': dt_with_microseconds(1420092061, 2), + 'container': Container(None, get_container('abcde')), + }, + { + 'type': 'container', + 'service': 'web', + 'action': 'attach', + 'id': 'abcde', + 'attributes': { + 'name': 'test_web_1', + 'image': 'example/image', + }, + 'time': dt_with_microseconds(1420092061, 3), + 'container': Container(None, get_container('abcde')), + }, + { + 'type': 'container', + 'service': 'db', + 'action': 'create', + 'id': 'ababa', + 'attributes': { + 'name': 'test_db_1', + 'image': 'example/db', + }, + 'time': dt_with_microseconds(1420092061, 4), + 'container': Container(None, get_container('ababa')), + }, + { + 'type': 'container', + 'service': 'db', + 'action': 'destroy', + 'id': 'eeeee', + 'attributes': { + 'name': 'test_db_1', + 'image': 'example/db', + }, + 'time': dt_with_microseconds(1420092061, 4), + 'container': None, + }, + ] + def test_net_unset(self): project = Project.from_config( name='test', From 0323920957f42f134e0690f30915a881d7f12986 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 14 Dec 2018 14:36:40 -0800 Subject: [PATCH 3614/4072] Style and language fixes Signed-off-by: Joffrey F --- compose/config/environment.py | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/compose/config/environment.py b/compose/config/environment.py index 675ab10ebe0..bd52758f237 100644 --- a/compose/config/environment.py +++ b/compose/config/environment.py @@ -5,7 +5,7 @@ import contextlib import logging import os -import string +import re import six @@ -15,8 +15,6 @@ log = logging.getLogger(__name__) -whitespace = set(string.whitespace) - def split_env(env): if isinstance(env, six.binary_type): @@ -26,11 +24,10 @@ def split_env(env): key, value = env.split('=', 1) else: key = env - for k in key: - if k in whitespace: - raise ConfigurationError( - "environment variable name '%s' may not contains white spaces." % key - ) + if re.search(r'\s', key): + raise ConfigurationError( + "environment variable name '{}' may not contains whitespace.".format(key) + ) return key, value @@ -39,9 +36,9 @@ def env_vars_from_file(filename): Read in a line delimited file of environment variables. """ if not os.path.exists(filename): - raise EnvFileNotFound("Couldn't find env file: %s" % filename) + raise EnvFileNotFound("Couldn't find env file: {}".format(filename)) elif not os.path.isfile(filename): - raise EnvFileNotFound("%s is not a file." % (filename)) + raise EnvFileNotFound("{} is not a file.".format(filename)) env = {} with contextlib.closing(codecs.open(filename, 'r', 'utf-8-sig')) as fileobj: for line in fileobj: From fee5261014ff1f0a867e2f871a82cc3ade78e3cd Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 19 Dec 2018 00:24:33 -0800 Subject: [PATCH 3615/4072] Always connect Compose container to stdin Signed-off-by: Joffrey F --- script/run/run.sh | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/script/run/run.sh b/script/run/run.sh index d3069ff7801..cc36e475185 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -47,14 +47,14 @@ if [ -n "$HOME" ]; then fi # Only allocate tty if we detect one -if [ -t 0 ]; then - if [ -t 1 ]; then +if [ -t 0 -a -t 1 ]; then DOCKER_RUN_OPTIONS="$DOCKER_RUN_OPTIONS -t" - fi -else - DOCKER_RUN_OPTIONS="$DOCKER_RUN_OPTIONS -i" fi +# Always set -i to support piped and terminal input in run/exec +DOCKER_RUN_OPTIONS="$DOCKER_RUN_OPTIONS -i" + + # Handle userns security if [ ! -z "$(docker info 2>/dev/null | grep userns)" ]; then DOCKER_RUN_OPTIONS="$DOCKER_RUN_OPTIONS --userns=host" From f4ed9b2ef59513fe09392e8386b8eafc19b5e578 Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Thu, 22 Nov 2018 18:26:25 +0100 Subject: [PATCH 3616/4072] Detects the execution on an`exec` command and sets the environment to `silent` mode. Signed-off-by: Ulysses Souza --- compose/cli/command.py | 16 +++++++++ compose/config/environment.py | 3 +- requirements-dev.txt | 1 + tests/integration/environment_test.py | 52 +++++++++++++++++++++++++++ 4 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 tests/integration/environment_test.py diff --git a/compose/cli/command.py b/compose/cli/command.py index 339a65c53c4..c1f6c29257b 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -21,10 +21,26 @@ log = logging.getLogger(__name__) +SILENT_COMMANDS = set(( + 'events', + 'exec', + 'kill', + 'logs', + 'pause', + 'ps', + 'restart', + 'rm', + 'start', + 'stop', + 'top', + 'unpause', +)) + def project_from_options(project_dir, options): override_dir = options.get('--project-directory') environment = Environment.from_env_file(override_dir or project_dir) + environment.silent = options.get('COMMAND', None) in SILENT_COMMANDS set_parallel_limit(environment) host = options.get('--host') diff --git a/compose/config/environment.py b/compose/config/environment.py index bd52758f237..62c40a4bc06 100644 --- a/compose/config/environment.py +++ b/compose/config/environment.py @@ -56,6 +56,7 @@ class Environment(dict): def __init__(self, *args, **kwargs): super(Environment, self).__init__(*args, **kwargs) self.missing_keys = [] + self.silent = False @classmethod def from_env_file(cls, base_dir): @@ -95,7 +96,7 @@ def __getitem__(self, key): return super(Environment, self).__getitem__(key.upper()) except KeyError: pass - if key not in self.missing_keys: + if not self.silent and key not in self.missing_keys: log.warn( "The {} variable is not set. Defaulting to a blank string." .format(key) diff --git a/requirements-dev.txt b/requirements-dev.txt index 4d74f6d157a..d49ce49c468 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,4 +1,5 @@ coverage==4.4.2 +ddt==1.2.0 flake8==3.5.0 mock>=1.0.1 pytest==3.6.3 diff --git a/tests/integration/environment_test.py b/tests/integration/environment_test.py new file mode 100644 index 00000000000..07619eec1e1 --- /dev/null +++ b/tests/integration/environment_test.py @@ -0,0 +1,52 @@ +from __future__ import absolute_import +from __future__ import unicode_literals + +import tempfile + +from ddt import data +from ddt import ddt + +from .. import mock +from compose.cli.command import project_from_options +from tests.integration.testcases import DockerClientTestCase + + +@ddt +class EnvironmentTest(DockerClientTestCase): + @classmethod + def setUpClass(cls): + super(EnvironmentTest, cls).setUpClass() + cls.compose_file = tempfile.NamedTemporaryFile(mode='w+b') + cls.compose_file.write(bytes("""version: '3.2' +services: + svc: + image: busybox:latest + environment: + TEST_VARIABLE: ${TEST_VARIABLE}""", encoding='utf-8')) + cls.compose_file.flush() + + @classmethod + def tearDownClass(cls): + super(EnvironmentTest, cls).tearDownClass() + cls.compose_file.close() + + @data('events', + 'exec', + 'kill', + 'logs', + 'pause', + 'ps', + 'restart', + 'rm', + 'start', + 'stop', + 'top', + 'unpause') + def _test_no_warning_on_missing_host_environment_var_on_silent_commands(self, cmd): + options = {'COMMAND': cmd, '--file': [EnvironmentTest.compose_file.name]} + with mock.patch('compose.config.environment.log') as fake_log: + # Note that the warning silencing and the env variables check is + # done in `project_from_options` + # So no need to have a proper options map, the `COMMAND` key is enough + project_from_options('.', options) + assert fake_log.warn.call_count == 0 From 01eb4b625045100f7aeeafd69ef4e305cfe605ef Mon Sep 17 00:00:00 2001 From: Andriy Maletsky Date: Fri, 26 Jan 2018 13:26:31 +0200 Subject: [PATCH 3617/4072] Lower severity to "warning" if `down` tries to remove nonexisting image Signed-off-by: Andriy Maletsky --- compose/service.py | 3 +++ tests/unit/service_test.py | 8 ++++++++ 2 files changed, 11 insertions(+) diff --git a/compose/service.py b/compose/service.py index f6dfa7c72bb..3c5e356a39d 100644 --- a/compose/service.py +++ b/compose/service.py @@ -1148,6 +1148,9 @@ def remove_image(self, image_type): try: self.client.remove_image(self.image_name) return True + except ImageNotFound: + log.warning("Image %s not found.", self.image_name) + return False except APIError as e: log.error("Failed to remove image for service %s: %s", self.name, e) return False diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 99adea34b43..8b3352fcbd9 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -5,6 +5,7 @@ import pytest from docker.constants import DEFAULT_DOCKER_API_VERSION from docker.errors import APIError +from docker.errors import ImageNotFound from docker.errors import NotFound from .. import mock @@ -755,6 +756,13 @@ def test_remove_image_with_error(self): mock_log.error.assert_called_once_with( "Failed to remove image for service %s: %s", web.name, error) + def test_remove_non_existing_image(self): + self.mock_client.remove_image.side_effect = ImageNotFound('image not found') + web = Service('web', image='example', client=self.mock_client) + with mock.patch('compose.service.log', autospec=True) as mock_log: + assert not web.remove_image(ImageType.all) + mock_log.warning.assert_called_once_with("Image %s not found.", web.image_name) + def test_specifies_host_port_with_no_ports(self): service = Service( 'foo', From d980d170a6a58df573c0b78211fd35a9d33286a2 Mon Sep 17 00:00:00 2001 From: Collins Abitekaniza Date: Sat, 8 Dec 2018 13:48:05 +0300 Subject: [PATCH 3618/4072] error on duplicate mount points Signed-off-by: Collins Abitekaniza --- compose/config/config.py | 12 ++++++++++++ compose/service.py | 1 + 2 files changed, 13 insertions(+) diff --git a/compose/config/config.py b/compose/config/config.py index 0298b4e2d3a..59c76680bce 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -8,6 +8,7 @@ import string import sys from collections import namedtuple +from operator import itemgetter, attrgetter import six import yaml @@ -835,6 +836,17 @@ def finalize_service_volumes(service_dict, environment): finalized_volumes.append(MountSpec.parse(v, normalize, win_host)) else: finalized_volumes.append(VolumeSpec.parse(v, normalize, win_host)) + + duplicate_mounts = [] + mounts = [v.as_volume_spec() if isinstance(v, MountSpec) else v for v in finalized_volumes] + for mount in mounts: + if list(map(attrgetter('internal'), mounts)).count(mount.internal) > 1: + duplicate_mounts.append(mount.repr()) + + if duplicate_mounts: + raise ConfigurationError("Duplicate mount points: volumes [%s]" % ( + ', '.join(duplicate_mounts))) + service_dict['volumes'] = finalized_volumes return service_dict diff --git a/compose/service.py b/compose/service.py index f6dfa7c72bb..964ab019343 100644 --- a/compose/service.py +++ b/compose/service.py @@ -9,6 +9,7 @@ from collections import namedtuple from collections import OrderedDict from operator import attrgetter +from operator import itemgetter import enum import six From 47ff8d710c62a2c58e359ac41a90d7b6c411de90 Mon Sep 17 00:00:00 2001 From: Collins Abitekaniza Date: Sun, 9 Dec 2018 00:43:06 +0300 Subject: [PATCH 3619/4072] test create from config with duplicate mount points Signed-off-by: Collins Abitekaniza --- compose/config/config.py | 6 +++--- compose/service.py | 1 - tests/unit/config/config_test.py | 35 ++++++++++++++++++++++++++++++++ 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 59c76680bce..d9ee158d524 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -8,7 +8,7 @@ import string import sys from collections import namedtuple -from operator import itemgetter, attrgetter +from operator import attrgetter import six import yaml @@ -844,9 +844,9 @@ def finalize_service_volumes(service_dict, environment): duplicate_mounts.append(mount.repr()) if duplicate_mounts: - raise ConfigurationError("Duplicate mount points: volumes [%s]" % ( + raise ConfigurationError("Duplicate mount points: [%s]" % ( ', '.join(duplicate_mounts))) - + service_dict['volumes'] = finalized_volumes return service_dict diff --git a/compose/service.py b/compose/service.py index 964ab019343..f6dfa7c72bb 100644 --- a/compose/service.py +++ b/compose/service.py @@ -9,7 +9,6 @@ from collections import namedtuple from collections import OrderedDict from operator import attrgetter -from operator import itemgetter import enum import six diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 787d8ff4aac..f95c46d8909 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -3071,6 +3071,41 @@ def test_config_valid_service_label_validation(self): ) config.load(config_details) + def test_config_duplicate_mount_points(self): + config1 = build_config_details( + { + 'version': '3.5', + 'services': { + 'web': { + 'image': 'busybox', + 'volumes': ['/tmp/foo:/tmp/foo', '/tmp/foo:/tmp/foo:rw'] + } + } + } + ) + + config2 = build_config_details( + { + 'version': '3.5', + 'services': { + 'web': { + 'image': 'busybox', + 'volumes': ['/x:/y', '/z:/y'] + } + } + } + ) + + with self.assertRaises(ConfigurationError) as e: + config.load(config1) + self.assertEquals(str(e.exception), 'Duplicate mount points: [%s]' % ( + ', '.join(['/tmp/foo:/tmp/foo:rw']*2))) + + with self.assertRaises(ConfigurationError) as e: + config.load(config2) + self.assertEquals(str(e.exception), 'Duplicate mount points: [%s]' % ( + ', '.join(['/x:/y:rw', '/z:/y:rw']))) + class NetworkModeTest(unittest.TestCase): From 8419a670aed3364c39b86a0608782aaeae3ce5df Mon Sep 17 00:00:00 2001 From: Quentin Brunet Date: Tue, 8 Jan 2019 14:04:54 +0100 Subject: [PATCH 3620/4072] Upgrade pyyaml to 4.2b1 Signed-off-by: Quentin Brunet --- requirements.txt | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index fbb285b6b41..8883d55b719 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,7 +16,7 @@ paramiko==2.4.2 pypiwin32==219; sys_platform == 'win32' and python_version < '3.6' pypiwin32==223; sys_platform == 'win32' and python_version >= '3.6' PySocks==1.6.7 -PyYAML==3.12 +PyYAML==4.2b1 requests==2.20.0 six==1.10.0 texttable==0.9.1 diff --git a/setup.py b/setup.py index 4c49bab7b80..8b5f9d99b6a 100644 --- a/setup.py +++ b/setup.py @@ -32,7 +32,7 @@ def find_version(*file_paths): install_requires = [ 'cached-property >= 1.2.0, < 2', 'docopt >= 0.6.1, < 0.7', - 'PyYAML >= 3.10, < 4', + 'PyYAML >= 3.10, < 4.3', 'requests >= 2.6.1, != 2.11.0, != 2.12.2, != 2.18.0, < 2.21', 'texttable >= 0.9.0, < 0.10', 'websocket-client >= 0.32.0, < 1.0', From 56fbd22825794b488894b9b589f7d17d2337d1d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20M=C3=BCller?= Date: Wed, 9 Jan 2019 23:14:12 +0100 Subject: [PATCH 3621/4072] fix race condition after pulling image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Stephan Müller --- compose/progress_stream.py | 8 +++---- tests/unit/progress_stream_test.py | 38 ++++++++++++++++-------------- 2 files changed, 24 insertions(+), 22 deletions(-) diff --git a/compose/progress_stream.py b/compose/progress_stream.py index 4cd311432ae..c4281cb4cf4 100644 --- a/compose/progress_stream.py +++ b/compose/progress_stream.py @@ -98,14 +98,14 @@ def print_output_event(event, stream, is_terminal): def get_digest_from_pull(events): + digest = None for event in events: status = event.get('status') if not status or 'Digest' not in status: continue - - _, digest = status.split(':', 1) - return digest.strip() - return None + else: + digest = status.split(':', 1)[1].strip() + return digest def get_digest_from_push(events): diff --git a/tests/unit/progress_stream_test.py b/tests/unit/progress_stream_test.py index d29227458cb..6fdb7d92788 100644 --- a/tests/unit/progress_stream_test.py +++ b/tests/unit/progress_stream_test.py @@ -97,22 +97,24 @@ def mktempfile(encoding): tf.seek(0) assert tf.read() == '???' + def test_get_digest_from_push(self): + digest = "sha256:abcd" + events = [ + {"status": "..."}, + {"status": "..."}, + {"progressDetail": {}, "aux": {"Digest": digest}}, + ] + assert progress_stream.get_digest_from_push(events) == digest + + def test_get_digest_from_pull(self): + events = list() + assert progress_stream.get_digest_from_pull(events) is None -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 + digest = "sha256:abcd" + events = [ + {"status": "..."}, + {"status": "..."}, + {"status": "Digest: %s" % digest}, + {"status": "..."}, + ] + assert progress_stream.get_digest_from_pull(events) == digest From ab0a0d69d98af05afb8edf9293088dc29cbe764a Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 10 Jan 2019 13:50:28 -0800 Subject: [PATCH 3622/4072] Bump SDK version -> 3.7.0 Signed-off-by: Joffrey F --- requirements.txt | 4 ++-- setup.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/requirements.txt b/requirements.txt index fbb285b6b41..cf569ed2345 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,8 +3,8 @@ cached-property==1.3.0 certifi==2017.4.17 chardet==3.0.4 colorama==0.4.0; sys_platform == 'win32' -docker==3.6.0 -docker-pycreds==0.3.0 +docker==3.7.0 +docker-pycreds==0.4.0 dockerpty==0.4.1 docopt==0.6.2 enum34==1.1.6; python_version < '3.4' diff --git a/setup.py b/setup.py index 4c49bab7b80..1ef0da5c586 100644 --- a/setup.py +++ b/setup.py @@ -36,7 +36,7 @@ def find_version(*file_paths): 'requests >= 2.6.1, != 2.11.0, != 2.12.2, != 2.18.0, < 2.21', 'texttable >= 0.9.0, < 0.10', 'websocket-client >= 0.32.0, < 1.0', - 'docker[ssh] >= 3.6.0, < 4.0', + 'docker[ssh] >= 3.7.0, < 4.0', 'dockerpty >= 0.4.1, < 0.5', 'six >= 1.3.0, < 2', 'jsonschema >= 2.5.1, < 3', From bab8b3985e02a71373f5d6f753485ac7a08c3092 Mon Sep 17 00:00:00 2001 From: Collins Abitekaniza Date: Thu, 10 Jan 2019 13:48:42 +0300 Subject: [PATCH 3623/4072] check for started containers only on service_start Signed-off-by: Collins Abitekaniza --- compose/parallel.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/compose/parallel.py b/compose/parallel.py index 32ee602f4a3..dbf4845b8d6 100644 --- a/compose/parallel.py +++ b/compose/parallel.py @@ -43,14 +43,15 @@ def set_global_limit(cls, value): cls.global_limiter = Semaphore(value) -def parallel_execute_watch(events, writer, errors, results, msg, get_name): +def parallel_execute_watch(events, writer, errors, results, msg, get_name, func_name): """ Watch events from a parallel execution, update status and fill errors and results. Returns exception to re-raise. """ error_to_reraise = None for obj, result, exception in events: if exception is None: - if callable(getattr(obj, 'containers', None)) and not obj.containers(): + if func_name == 'start_service' and ( + callable(getattr(obj, 'containers', None)) and not obj.containers()): # If service has no containers started writer.write(msg, get_name(obj), 'failed', red) else: @@ -100,7 +101,8 @@ def parallel_execute(objects, func, get_name, msg, get_deps=None, limit=None): errors = {} results = [] - error_to_reraise = parallel_execute_watch(events, writer, errors, results, msg, get_name) + error_to_reraise = parallel_execute_watch( + events, writer, errors, results, msg, get_name, func.__name__) for obj_name, error in errors.items(): stream.write("\nERROR: for {} {}\n".format(obj_name, error)) From 325637d9d5939a82418a25360934b525dd601c20 Mon Sep 17 00:00:00 2001 From: Collins Abitekaniza Date: Thu, 10 Jan 2019 14:49:30 +0300 Subject: [PATCH 3624/4072] test image pull done Signed-off-by: Collins Abitekaniza --- compose/parallel.py | 2 +- tests/acceptance/cli_test.py | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/compose/parallel.py b/compose/parallel.py index dbf4845b8d6..d6af51694ee 100644 --- a/compose/parallel.py +++ b/compose/parallel.py @@ -102,7 +102,7 @@ def parallel_execute(objects, func, get_name, msg, get_deps=None, limit=None): errors = {} results = [] error_to_reraise = parallel_execute_watch( - events, writer, errors, results, msg, get_name, func.__name__) + events, writer, errors, results, msg, get_name, getattr(func, '__name__', None)) for obj_name, error in errors.items(): stream.write("\nERROR: for {} {}\n".format(obj_name, error)) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 1dc9616a572..9334a29fbed 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -611,6 +611,11 @@ def test_pull(self): assert 'Pulling simple' in result.stderr assert 'Pulling another' in result.stderr + def test_pull_done(self): + result = self.dispatch(['pull']) + assert 'Pulling simple' in result.stderr + assert 'done' in result.stderr + def test_pull_with_digest(self): result = self.dispatch(['-f', 'digest.yml', 'pull', '--no-parallel']) From 2ed171cae94ef5ac9d2eeeb683b217e380e93b81 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 10 Jan 2019 15:47:44 -0800 Subject: [PATCH 3625/4072] Bring zero container check up in the call stack Signed-off-by: Joffrey F --- compose/parallel.py | 13 +++++++------ compose/project.py | 1 + tests/acceptance/cli_test.py | 5 +---- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/compose/parallel.py b/compose/parallel.py index d6af51694ee..e242a318ae7 100644 --- a/compose/parallel.py +++ b/compose/parallel.py @@ -43,16 +43,14 @@ def set_global_limit(cls, value): cls.global_limiter = Semaphore(value) -def parallel_execute_watch(events, writer, errors, results, msg, get_name, func_name): +def parallel_execute_watch(events, writer, errors, results, msg, get_name, fail_check): """ Watch events from a parallel execution, update status and fill errors and results. Returns exception to re-raise. """ error_to_reraise = None for obj, result, exception in events: if exception is None: - if func_name == 'start_service' and ( - callable(getattr(obj, 'containers', None)) and not obj.containers()): - # If service has no containers started + if fail_check is not None and fail_check(obj): writer.write(msg, get_name(obj), 'failed', red) else: writer.write(msg, get_name(obj), 'done', green) @@ -77,12 +75,14 @@ def parallel_execute_watch(events, writer, errors, results, msg, get_name, func_ return error_to_reraise -def parallel_execute(objects, func, get_name, msg, get_deps=None, limit=None): +def parallel_execute(objects, func, get_name, msg, get_deps=None, limit=None, fail_check=None): """Runs func on objects in parallel while ensuring that func is ran on object only after it is ran on all its dependencies. get_deps called on object must return a collection with its dependencies. get_name called on object must return its name. + fail_check is an additional failure check for cases that should display as a failure + in the CLI logs, but don't raise an exception (such as attempting to start 0 containers) """ objects = list(objects) stream = get_output_stream(sys.stderr) @@ -102,7 +102,8 @@ def parallel_execute(objects, func, get_name, msg, get_deps=None, limit=None): errors = {} results = [] error_to_reraise = parallel_execute_watch( - events, writer, errors, results, msg, get_name, getattr(func, '__name__', None)) + events, writer, errors, results, msg, get_name, fail_check + ) for obj_name, error in errors.items(): stream.write("\nERROR: for {} {}\n".format(obj_name, error)) diff --git a/compose/project.py b/compose/project.py index 5c4ce6e17ca..a7f2aa05720 100644 --- a/compose/project.py +++ b/compose/project.py @@ -280,6 +280,7 @@ def get_deps(service): operator.attrgetter('name'), 'Starting', get_deps, + fail_check=lambda obj: not obj.containers(), ) return containers diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 9334a29fbed..5142f96eb34 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -610,11 +610,8 @@ def test_pull(self): result = self.dispatch(['pull']) assert 'Pulling simple' in result.stderr assert 'Pulling another' in result.stderr - - def test_pull_done(self): - result = self.dispatch(['pull']) - assert 'Pulling simple' in result.stderr assert 'done' in result.stderr + assert 'failed' not in result.stderr def test_pull_with_digest(self): result = self.dispatch(['-f', 'digest.yml', 'pull', '--no-parallel']) From 60f8ce09f9b512536ce731a693446f6279ecd939 Mon Sep 17 00:00:00 2001 From: Djordje Lukic Date: Fri, 11 Jan 2019 17:43:47 +0100 Subject: [PATCH 3626/4072] "Bump 1.24.0-rc1" Signed-off-by: Djordje Lukic --- CHANGELOG.md | 36 ++++++++++++++++++++++++++++++++++++ compose/__init__.py | 2 +- script/run/run.sh | 2 +- 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a7a2ffe97c..e294a209adb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,42 @@ Change log ========== +1.24.0 (2019-01-25) +------------------- + +### Features + +- Added support for connecting to the Docker Engine using the `ssh` protocol. + +- Added a `--all` flag to `docker-compose ps` to include stopped one-off containers + in the command's output. + +### Bugfixes + +- Fixed a bug where some valid credential helpers weren't properly handled by Compose + when attempting to pull images from private registries. + +- Fixed an issue where the output of `docker-compose start` before containers were created + was misleading + +- To match the Docker CLI behavior and to avoid confusing issues, Compose will no longer + accept whitespace in variable names sourced from environment files. + +- Compose will now report a configuration error if a service attempts to declare + duplicate mount points in the volumes section. + +- Fixed an issue with the containerized version of Compose that prevented users from + writing to stdin during interactive sessions started by `run` or `exec`. + +- One-off containers started by `run` no longer adopt the restart policy of the service, + and are instead set to never restart. + +- Fixed an issue that caused some container events to not appear in the output of + the `docker-compose events` command. + +- Missing images will no longer stop the execution of `docker-compose down` commands + (a warning will be displayed instead). + 1.23.2 (2018-11-28) ------------------- diff --git a/compose/__init__.py b/compose/__init__.py index 652e1fad976..bc5e6b116f0 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.24.0dev' +__version__ = '1.24.0-rc1' diff --git a/script/run/run.sh b/script/run/run.sh index cc36e475185..df3f2298f29 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.23.2" +VERSION="1.24.0-rc1" IMAGE="docker/compose:$VERSION" From 200795173168aeb5aeccaa9184d972a7a28ad600 Mon Sep 17 00:00:00 2001 From: Djordje Lukic Date: Fri, 11 Jan 2019 17:57:05 +0100 Subject: [PATCH 3627/4072] "Bump 1.24.0-rc1" Signed-off-by: Djordje Lukic --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e294a209adb..c5eb1bb4d3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,7 @@ Change log - Fixed an issue where the output of `docker-compose start` before containers were created was misleading - + - To match the Docker CLI behavior and to avoid confusing issues, Compose will no longer accept whitespace in variable names sourced from environment files. @@ -31,7 +31,7 @@ Change log - One-off containers started by `run` no longer adopt the restart policy of the service, and are instead set to never restart. -- Fixed an issue that caused some container events to not appear in the output of +- Fixed an issue that caused some container events to not appear in the output of the `docker-compose events` command. - Missing images will no longer stop the execution of `docker-compose down` commands From 0f3d4ddaa7b8262f964f2c42d502e5a65d28cc64 Mon Sep 17 00:00:00 2001 From: Djordje Lukic Date: Fri, 11 Jan 2019 18:28:30 +0100 Subject: [PATCH 3628/4072] "Bump 1.24.0-rc1" Signed-off-by: Djordje Lukic --- script/release/release/repository.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/script/release/release/repository.py b/script/release/release/repository.py index 9a5d432c084..bb8f4fbeb7d 100644 --- a/script/release/release/repository.py +++ b/script/release/release/repository.py @@ -219,6 +219,8 @@ def get_contributors(pr_data): commits = pr_data.get_commits() authors = {} for commit in commits: + if not commit.author: + continue author = commit.author.login authors[author] = authors.get(author, 0) + 1 return [x[0] for x in sorted(list(authors.items()), key=lambda x: x[1])] From 693343500499d6052dff6e7db2291a1bb6ecb611 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 14 Jan 2019 15:22:12 -0800 Subject: [PATCH 3629/4072] Update maintainers file Signed-off-by: Joffrey F --- MAINTAINERS | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/MAINTAINERS b/MAINTAINERS index 7aedd46e971..5d4bd6a6359 100644 --- a/MAINTAINERS +++ b/MAINTAINERS @@ -11,9 +11,8 @@ [Org] [Org."Core maintainers"] people = [ - "mefyl", - "mnottale", - "shin-", + "rumpl", + "ulyssessouza", ] [Org.Alumni] people = [ @@ -34,6 +33,10 @@ # including muti-file support, variable interpolation, secrets # emulation and many more "dnephin", + + "shin-", + "mefyl", + "mnottale", ] [people] @@ -74,7 +77,17 @@ Email = "mazz@houseofmnowster.com" GitHub = "mnowster" - [People.shin-] + [people.rumpl] + Name = "Djordje Lukic" + Email = "djordje.lukic@docker.com" + GitHub = "rumpl" + + [people.shin-] Name = "Joffrey F" - Email = "joffrey@docker.com" + Email = "f.joffrey@gmail.com" GitHub = "shin-" + + [people.ulyssessouza] + Name = "Ulysses Domiciano Souza" + Email = "ulysses.souza@docker.com" + GitHub = "ulyssessouza" From 14a1a0c020e70b99bc5b54f2098be52c66c45fb6 Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Tue, 15 Jan 2019 09:01:49 +0100 Subject: [PATCH 3630/4072] Add bash completion for `ps --all|-a` 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 395888d347e..5938ff9e2cf 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -361,7 +361,7 @@ _docker_compose_ps() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "--help --quiet -q --services --filter" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--all -a --filter --help --quiet -q --services" -- "$cur" ) ) ;; *) __docker_compose_complete_services From 0c20fc5d914367816387dbf149077835117daa14 Mon Sep 17 00:00:00 2001 From: Djordje Lukic Date: Fri, 11 Jan 2019 10:04:57 +0100 Subject: [PATCH 3631/4072] Resolve digests without pulling image If there is no image locally `docker-compose --resolve-image-digests` will try and get the digest from the repository. Fixes https://github.com/docker/compose/issues/5818 Signed-off-by: Djordje Lukic --- compose/bundle.py | 41 +++++++++++++++++++++++++++------------ compose/service.py | 6 ++++++ tests/unit/bundle_test.py | 11 +++++++++++ 3 files changed, 46 insertions(+), 12 deletions(-) diff --git a/compose/bundle.py b/compose/bundle.py index 937a3708a99..efc455b7234 100644 --- a/compose/bundle.py +++ b/compose/bundle.py @@ -95,19 +95,10 @@ def get_image_digest(service, allow_push=False): 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)) + digest = get_digest(service) - if image['RepoDigests']: - # TODO: pick a digest based on the image tag if there are multiple - # digests - return image['RepoDigests'][0] + if digest: + return digest if 'build' not in service.options: raise NeedsPull(service.image_name, service.name) @@ -118,6 +109,32 @@ def get_image_digest(service, allow_push=False): return push_image(service) +def get_digest(service): + digest = None + try: + image = service.image() + # TODO: pick a digest based on the image tag if there are multiple + # digests + if image['RepoDigests']: + digest = image['RepoDigests'][0] + except NoSuchImageError: + try: + # Fetch the image digest from the registry + distribution = service.get_image_registry_data() + + if distribution['Descriptor']['digest']: + digest = '{image_name}@{digest}'.format( + image_name=service.image_name, + digest=distribution['Descriptor']['digest'] + ) + except NoSuchImageError: + raise UserError( + "Digest not found for service '{service}'. " + "Repository does not exist or may require 'docker login'" + .format(service=service.name)) + return digest + + def push_image(service): try: digest = service.push() diff --git a/compose/service.py b/compose/service.py index 3c5e356a39d..59f05f8dce6 100644 --- a/compose/service.py +++ b/compose/service.py @@ -363,6 +363,12 @@ def ensure_image_exists(self, do_build=BuildAction.none, silent=False): "rebuild this image you must use `docker-compose build` or " "`docker-compose up --build`.".format(self.name)) + def get_image_registry_data(self): + try: + return self.client.inspect_distribution(self.image_name) + except APIError: + raise NoSuchImageError("Image '{}' not found".format(self.image_name)) + def image(self): try: return self.client.inspect_image(self.image_name) diff --git a/tests/unit/bundle_test.py b/tests/unit/bundle_test.py index 88f75405a7b..065cdd00b49 100644 --- a/tests/unit/bundle_test.py +++ b/tests/unit/bundle_test.py @@ -10,6 +10,7 @@ from compose.cli.errors import UserError from compose.config.config import Config from compose.const import COMPOSEFILE_V2_0 as V2_0 +from compose.service import NoSuchImageError @pytest.fixture @@ -35,6 +36,16 @@ def test_get_image_digest_image_uses_digest(mock_service): assert not mock_service.image.called +def test_get_image_digest_from_repository(mock_service): + mock_service.options['image'] = 'abcd' + mock_service.image_name = 'abcd' + mock_service.image.side_effect = NoSuchImageError(None) + mock_service.get_image_registry_data.return_value = {'Descriptor': {'digest': 'digest'}} + + digest = bundle.get_image_digest(mock_service) + assert digest == 'abcd@digest' + + def test_get_image_digest_no_image(mock_service): with pytest.raises(UserError) as exc: bundle.get_image_digest(service.Service(name='theservice')) From ae0f3c74a0a986f0b5f1c0b546d6fb88e617aa76 Mon Sep 17 00:00:00 2001 From: Djordje Lukic Date: Wed, 16 Jan 2019 13:47:23 +0100 Subject: [PATCH 3632/4072] Support for credential_spec Signed-off-by: Djordje Lukic --- compose/cli/main.py | 4 ++-- compose/config/config.py | 23 ++++++++++++++++++++++- compose/config/validation.py | 12 ++++++++++++ contrib/completion/zsh/_docker-compose | 2 +- tests/unit/config/config_test.py | 6 +++++- 5 files changed, 42 insertions(+), 5 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 950e5055dcc..789601792ea 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -206,8 +206,8 @@ class TopLevelCommand(object): name specified in the client certificate --project-directory PATH Specify an alternate working directory (default: the path of the Compose file) - --compatibility If set, Compose will attempt to convert deploy - keys in v3 files to their non-Swarm equivalent + --compatibility If set, Compose will attempt to convert keys + in v3 files to their non-Swarm equivalent Commands: build Build or rebuild services diff --git a/compose/config/config.py b/compose/config/config.py index 3df211f7387..e2ed29a479c 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -51,6 +51,7 @@ from .validation import validate_against_config_schema from .validation import validate_config_section from .validation import validate_cpu +from .validation import validate_credential_spec from .validation import validate_depends_on from .validation import validate_extends_file_path from .validation import validate_healthcheck @@ -369,7 +370,6 @@ def check_swarm_only_key(service_dicts, key): ) if not compatibility: check_swarm_only_key(service_dicts, 'deploy') - check_swarm_only_key(service_dicts, 'credential_spec') check_swarm_only_key(service_dicts, 'configs') @@ -706,6 +706,7 @@ def validate_service(service_config, service_names, config_file): validate_depends_on(service_config, service_names) validate_links(service_config, service_names) validate_healthcheck(service_config) + validate_credential_spec(service_config) if not service_dict.get('image') and has_uppercase(service_name): raise ConfigurationError( @@ -894,6 +895,7 @@ def finalize_service(service_config, service_names, version, environment, compat normalize_build(service_dict, service_config.working_dir, environment) if compatibility: + service_dict = translate_credential_spec_to_security_opt(service_dict) service_dict, ignored_keys = translate_deploy_keys_to_container_config( service_dict ) @@ -930,6 +932,25 @@ def convert_restart_policy(name): raise ConfigurationError('Invalid restart policy "{}"'.format(name)) +def convert_credential_spec_to_security_opt(credential_spec): + if 'file' in credential_spec: + return 'file://{file}'.format(file=credential_spec['file']) + return 'registry://{registry}'.format(registry=credential_spec['registry']) + + +def translate_credential_spec_to_security_opt(service_dict): + result = [] + + if 'credential_spec' in service_dict: + spec = convert_credential_spec_to_security_opt(service_dict['credential_spec']) + result.append('credentialspec={spec}'.format(spec=spec)) + + if result: + service_dict['security_opt'] = result + + return service_dict + + def translate_deploy_keys_to_container_config(service_dict): if 'credential_spec' in service_dict: del service_dict['credential_spec'] diff --git a/compose/config/validation.py b/compose/config/validation.py index 0395695514d..1cceb71f0a4 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -240,6 +240,18 @@ def validate_depends_on(service_config, service_names): ) +def validate_credential_spec(service_config): + credential_spec = service_config.config.get('credential_spec') + if not credential_spec: + return + + if 'registry' not in credential_spec and 'file' not in credential_spec: + raise ConfigurationError( + "Service '{s.name}' is missing 'credential_spec.file' or " + "credential_spec.registry'".format(s=service_config) + ) + + 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: diff --git a/contrib/completion/zsh/_docker-compose b/contrib/completion/zsh/_docker-compose index e55c9196475..5fd418a698a 100755 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -339,7 +339,7 @@ _docker-compose() { '(- :)'{-h,--help}'[Get help]' \ '*'{-f,--file}"[${file_description}]:file:_files -g '*.yml'" \ '(-p --project-name)'{-p,--project-name}'[Specify an alternate project name (default: directory name)]:project name:' \ - "--compatibility[If set, Compose will attempt to convert deploy keys in v3 files to their non-Swarm equivalent]" \ + "--compatibility[If set, Compose will attempt to convert keys in v3 files to their non-Swarm equivalent]" \ '(- :)'{-v,--version}'[Print version and exit]' \ '--verbose[Show more output]' \ '--log-level=[Set log level]:level:(DEBUG INFO WARNING ERROR CRITICAL)' \ diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 8baf8e4ee58..c2e6b7b0761 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -3593,6 +3593,9 @@ def test_compatibility_mode_load(self): 'reservations': {'memory': '100M'}, }, }, + 'credential_spec': { + 'file': 'spec.json' + }, }, }, }) @@ -3610,7 +3613,8 @@ def test_compatibility_mode_load(self): 'mem_limit': '300M', 'mem_reservation': '100M', 'cpus': 0.7, - 'name': 'foo' + 'name': 'foo', + 'security_opt': ['credentialspec=file://spec.json'], } @mock.patch.dict(os.environ) From 698ea33b1568b81dd7fe5234d26f616e66b6fa27 Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Mon, 21 Jan 2019 19:13:45 +0100 Subject: [PATCH 3633/4072] Add `--parallel` to `docker build`'s options in `bash` and `zsh` completion Signed-off-by: Ulysses Souza --- 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 5938ff9e2cf..2add0c9cd75 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -114,7 +114,7 @@ _docker_compose_build() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "--build-arg --compress --force-rm --help --memory --no-cache --pull" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--build-arg --compress --force-rm --help --memory --no-cache --pull --parallel" -- "$cur" ) ) ;; *) __docker_compose_complete_services --filter source=build diff --git a/contrib/completion/zsh/_docker-compose b/contrib/completion/zsh/_docker-compose index 5fd418a698a..d25256c14c3 100755 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -117,6 +117,7 @@ __docker-compose_subcommand() { '--no-cache[Do not use cache when building the image.]' \ '--pull[Always attempt to pull a newer version of the image.]' \ '--compress[Compress the build context using gzip.]' \ + '--parallel[Build images in parallel.]' \ '*:services:__docker-compose_services_from_build' && ret=0 ;; (bundle) From c27132afad5cb39a84bf3cb349ba993ea8c738a6 Mon Sep 17 00:00:00 2001 From: Collins Abitekaniza Date: Thu, 15 Nov 2018 15:19:08 +0300 Subject: [PATCH 3634/4072] remove stopped containers on --remove-orphans Signed-off-by: Collins Abitekaniza kill orphan containers, catch APIError Exception Signed-off-by: Collins Abitekaniza test remove orphans with --no-start Signed-off-by: Collins Abitekaniza --- compose/project.py | 7 +++++-- tests/acceptance/cli_test.py | 17 +++++++++++++++++ tests/fixtures/v2-simple/one-container.yml | 5 +++++ 3 files changed, 27 insertions(+), 2 deletions(-) create mode 100644 tests/fixtures/v2-simple/one-container.yml diff --git a/compose/project.py b/compose/project.py index 92c352050af..78b5a356994 100644 --- a/compose/project.py +++ b/compose/project.py @@ -627,7 +627,7 @@ def matches_service_names(container): def find_orphan_containers(self, remove_orphans): def _find(): - containers = self._labeled_containers() + containers = set(self._labeled_containers() + self._labeled_containers(stopped=True)) for ctnr in containers: service_name = ctnr.labels.get(LABEL_SERVICE) if service_name not in self.service_names: @@ -638,7 +638,10 @@ def _find(): if remove_orphans: for ctnr in orphans: log.info('Removing orphan container "{0}"'.format(ctnr.name)) - ctnr.kill() + try: + ctnr.kill() + except APIError: + pass ctnr.remove(force=True) else: log.warning( diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 5b0a0e0fd72..1844b757a14 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -12,6 +12,7 @@ import time from collections import Counter from collections import namedtuple +from functools import reduce from operator import attrgetter import pytest @@ -1099,6 +1100,22 @@ def test_up_no_start(self): ] assert len(remote_volumes) > 0 + @v2_only() + def test_up_no_start_remove_orphans(self): + self.base_dir = 'tests/fixtures/v2-simple' + self.dispatch(['up', '--no-start'], None) + + services = self.project.get_services() + + stopped = reduce((lambda prev, next: prev.containers( + stopped=True) + next.containers(stopped=True)), services) + assert len(stopped) == 2 + + self.dispatch(['-f', 'one-container.yml', 'up', '--no-start', '--remove-orphans'], None) + stopped2 = reduce((lambda prev, next: prev.containers( + stopped=True) + next.containers(stopped=True)), services) + assert len(stopped2) == 1 + @v2_only() def test_up_no_ansi(self): self.base_dir = 'tests/fixtures/v2-simple' diff --git a/tests/fixtures/v2-simple/one-container.yml b/tests/fixtures/v2-simple/one-container.yml new file mode 100644 index 00000000000..22cd9863cb4 --- /dev/null +++ b/tests/fixtures/v2-simple/one-container.yml @@ -0,0 +1,5 @@ +version: "2" +services: + simple: + image: busybox:latest + command: top From 8ad4c08109f4ec45985b10fa9cf0482d9361c886 Mon Sep 17 00:00:00 2001 From: Christopher Crone Date: Tue, 5 Feb 2019 10:40:03 +0100 Subject: [PATCH 3635/4072] macOS: Bump Python and OpenSSL Signed-off-by: Christopher Crone --- script/setup/osx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/script/setup/osx b/script/setup/osx index 08491b6e5cb..1b546816df3 100755 --- a/script/setup/osx +++ b/script/setup/osx @@ -13,13 +13,13 @@ if ! [ ${DEPLOYMENT_TARGET} == "$(macos_version)" ]; then SDK_SHA1=dd228a335194e3392f1904ce49aff1b1da26ca62 fi -OPENSSL_VERSION=1.1.0h +OPENSSL_VERSION=1.1.0j OPENSSL_URL=https://www.openssl.org/source/openssl-${OPENSSL_VERSION}.tar.gz -OPENSSL_SHA1=0fc39f6aa91b6e7f4d05018f7c5e991e1d2491fd +OPENSSL_SHA1=dcad1efbacd9a4ed67d4514470af12bbe2a1d60a -PYTHON_VERSION=3.6.6 +PYTHON_VERSION=3.6.8 PYTHON_URL=https://www.python.org/ftp/python/${PYTHON_VERSION}/Python-${PYTHON_VERSION}.tgz -PYTHON_SHA1=ae1fc9ddd29ad8c1d5f7b0d799ff0787efeb9652 +PYTHON_SHA1=09fcc4edaef0915b4dedbfb462f1cd15f82d3a6f # # Install prerequisites. From b572b3299995c54189c0ec66ed39b6fc288cb98f Mon Sep 17 00:00:00 2001 From: Christopher Crone Date: Tue, 5 Feb 2019 10:50:25 +0100 Subject: [PATCH 3636/4072] requirements-dev: Fix version of mock to 2.0.0 Signed-off-by: Christopher Crone --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index d49ce49c468..b19fc2e0129 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,6 +1,6 @@ coverage==4.4.2 ddt==1.2.0 flake8==3.5.0 -mock>=1.0.1 +mock==2.0.0 pytest==3.6.3 pytest-cov==2.5.1 From f1f0894c1bee49585166d0dc6045ca9742eeb83b Mon Sep 17 00:00:00 2001 From: Christopher Crone Date: Tue, 5 Feb 2019 10:50:55 +0100 Subject: [PATCH 3637/4072] script.build.linux: Do not tail image build logs Signed-off-by: Christopher Crone --- script/build/linux | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/build/linux b/script/build/linux index 1a4cd4d9bf5..056940ad021 100755 --- a/script/build/linux +++ b/script/build/linux @@ -5,7 +5,7 @@ set -ex ./script/clean TAG="docker-compose" -docker build -t "$TAG" . | tail -n 200 +docker build -t "$TAG" . docker run \ --rm --entrypoint="script/build/linux-entrypoint" \ -v $(pwd)/dist:/code/dist \ From f472fd545be79860a3c9e03b6e63fbacb86977d1 Mon Sep 17 00:00:00 2001 From: Christopher Crone Date: Tue, 5 Feb 2019 10:51:26 +0100 Subject: [PATCH 3638/4072] Dockerfile: Force version of virtualenv to 16.2.0 Signed-off-by: Christopher Crone --- Dockerfile | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Dockerfile b/Dockerfile index a14be492ec5..c5e7c815aae 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,6 +17,8 @@ ENV LANG en_US.UTF-8 RUN useradd -d /home/user -m -s /bin/bash user WORKDIR /code/ +# FIXME(chris-crone): virtualenv 16.3.0 breaks build, force 16.2.0 until fixed +RUN pip install virtualenv==16.2.0 RUN pip install tox==2.1.1 ADD requirements.txt /code/ @@ -25,6 +27,7 @@ ADD .pre-commit-config.yaml /code/ ADD setup.py /code/ ADD tox.ini /code/ ADD compose /code/compose/ +ADD README.md /code/ RUN tox --notest ADD . /code/ From c8a621b637a7574b621e919b849d56fa66c3d996 Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Wed, 30 Jan 2019 14:28:00 +0100 Subject: [PATCH 3639/4072] Fix Flake8 lint This removes extra indentation and replace the use of `is` by `==` when comparing strings Signed-off-by: Ulysses Souza --- compose/service.py | 78 +++++++++++++++--------------- tests/unit/cli/log_printer_test.py | 2 +- 2 files changed, 40 insertions(+), 40 deletions(-) diff --git a/compose/service.py b/compose/service.py index 59f05f8dce6..401efa7eb0d 100644 --- a/compose/service.py +++ b/compose/service.py @@ -291,7 +291,7 @@ def scale(self, desired_num, timeout=None): c for c in stopped_containers if self._containers_have_diverged([c]) ] for c in divergent_containers: - c.remove() + c.remove() all_containers = list(set(all_containers) - set(divergent_containers)) @@ -467,50 +467,50 @@ def create_and_start(service, n): def _execute_convergence_recreate(self, containers, scale, timeout, detached, start, renew_anonymous_volumes): - if scale is not None and len(containers) > scale: - self._downscale(containers[scale:], timeout) - containers = containers[:scale] - - def recreate(container): - return self.recreate_container( - container, timeout=timeout, attach_logs=not detached, - start_new_container=start, renew_anonymous_volumes=renew_anonymous_volumes - ) - containers, errors = parallel_execute( - containers, - recreate, - lambda c: c.name, - "Recreating", + if scale is not None and len(containers) > scale: + self._downscale(containers[scale:], timeout) + containers = containers[:scale] + + def recreate(container): + return self.recreate_container( + container, timeout=timeout, attach_logs=not detached, + start_new_container=start, renew_anonymous_volumes=renew_anonymous_volumes ) - for error in errors.values(): - raise OperationFailedError(error) + containers, errors = parallel_execute( + containers, + recreate, + lambda c: c.name, + "Recreating", + ) + for error in errors.values(): + raise OperationFailedError(error) - if scale is not None and len(containers) < scale: - containers.extend(self._execute_convergence_create( - scale - len(containers), detached, start - )) - return containers + if scale is not None and len(containers) < scale: + containers.extend(self._execute_convergence_create( + scale - len(containers), detached, start + )) + return containers def _execute_convergence_start(self, containers, scale, timeout, detached, start): - if scale is not None and len(containers) > scale: - self._downscale(containers[scale:], timeout) - containers = containers[:scale] - if start: - _, errors = parallel_execute( - containers, - lambda c: self.start_container_if_stopped(c, attach_logs=not detached, quiet=True), - lambda c: c.name, - "Starting", - ) + if scale is not None and len(containers) > scale: + self._downscale(containers[scale:], timeout) + containers = containers[:scale] + if start: + _, errors = parallel_execute( + containers, + lambda c: self.start_container_if_stopped(c, attach_logs=not detached, quiet=True), + lambda c: c.name, + "Starting", + ) - for error in errors.values(): - raise OperationFailedError(error) + for error in errors.values(): + raise OperationFailedError(error) - if scale is not None and len(containers) < scale: - containers.extend(self._execute_convergence_create( - scale - len(containers), detached, start - )) - return containers + if scale is not None and len(containers) < scale: + containers.extend(self._execute_convergence_create( + scale - len(containers), detached, start + )) + return containers def _downscale(self, containers, timeout=None): def stop_and_remove(container): diff --git a/tests/unit/cli/log_printer_test.py b/tests/unit/cli/log_printer_test.py index d0c4b56be22..6db24e46486 100644 --- a/tests/unit/cli/log_printer_test.py +++ b/tests/unit/cli/log_printer_test.py @@ -193,7 +193,7 @@ def test_item_is_stop_with_cascade_stop(self): queue.put(item) generator = consume_queue(queue, True) - assert next(generator) is 'foobar-1' + assert next(generator) == 'foobar-1' def test_item_is_none_when_timeout_is_hit(self): queue = Queue() From d9ffec400224256cd06dfc6dd71ceb909285aa2d Mon Sep 17 00:00:00 2001 From: Christopher Crone Date: Tue, 5 Feb 2019 12:13:19 +0100 Subject: [PATCH 3640/4072] circleci: Fix virtualenv version to 16.2.0 Signed-off-by: Christopher Crone --- .circleci/config.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index f4e90d6de71..08f8c42c39f 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -10,7 +10,7 @@ jobs: command: ./script/setup/osx - run: name: install tox - command: sudo pip install --upgrade tox==2.1.1 + command: sudo pip install --upgrade tox==2.1.1 virtualenv==16.2.0 - run: name: unit tests command: tox -e py27,py36,py37 -- tests/unit @@ -22,7 +22,7 @@ jobs: - checkout - run: name: upgrade python tools - command: sudo pip install --upgrade pip virtualenv + command: sudo pip install --upgrade pip virtualenv==16.2.0 - run: name: setup script command: DEPLOYMENT_TARGET=10.11 ./script/setup/osx From 436a343a185ef10efc88dcf2eebda102e260324b Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Mon, 11 Feb 2019 13:50:41 +0100 Subject: [PATCH 3641/4072] Fix bash completion for `build --memory` - the option requires an argument - adds missing short form `-m` Signed-off-by: Harald Albers --- contrib/completion/bash/docker-compose | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index 2add0c9cd75..b0ff484fc94 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -110,11 +110,14 @@ _docker_compose_build() { __docker_compose_nospace return ;; + --memory|-m) + return + ;; esac case "$cur" in -*) - COMPREPLY=( $( compgen -W "--build-arg --compress --force-rm --help --memory --no-cache --pull --parallel" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--build-arg --compress --force-rm --help --memory -m --no-cache --pull --parallel" -- "$cur" ) ) ;; *) __docker_compose_complete_services --filter source=build From fbbf78d3da278f2844c769461bfdbec823532608 Mon Sep 17 00:00:00 2001 From: Christopher Crone Date: Tue, 5 Feb 2019 12:47:51 +0100 Subject: [PATCH 3642/4072] macOS: Bump OpenSSL to 1.1.1a Signed-off-by: Christopher Crone --- script/setup/osx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/script/setup/osx b/script/setup/osx index 1b546816df3..b0c34e5bf24 100755 --- a/script/setup/osx +++ b/script/setup/osx @@ -13,9 +13,9 @@ if ! [ ${DEPLOYMENT_TARGET} == "$(macos_version)" ]; then SDK_SHA1=dd228a335194e3392f1904ce49aff1b1da26ca62 fi -OPENSSL_VERSION=1.1.0j +OPENSSL_VERSION=1.1.1a OPENSSL_URL=https://www.openssl.org/source/openssl-${OPENSSL_VERSION}.tar.gz -OPENSSL_SHA1=dcad1efbacd9a4ed67d4514470af12bbe2a1d60a +OPENSSL_SHA1=8fae27b4f34445a5500c9dc50ae66b4d6472ce29 PYTHON_VERSION=3.6.8 PYTHON_URL=https://www.python.org/ftp/python/${PYTHON_VERSION}/Python-${PYTHON_VERSION}.tgz From a35aef4953954759e8970fd5163d96d998048939 Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Wed, 20 Feb 2019 10:36:59 +0100 Subject: [PATCH 3643/4072] Add --no-rm to command build - When present, build does not remove intermediate containers after a successful build. Signed-off-by: Ulysses Souza --- compose/cli/main.py | 2 ++ compose/project.py | 4 ++-- compose/service.py | 4 ++-- contrib/completion/bash/docker-compose | 2 +- tests/acceptance/cli_test.py | 17 +++++++++++++++++ 5 files changed, 24 insertions(+), 5 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 789601792ea..08976347ec2 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -260,6 +260,7 @@ def build(self, options): --compress Compress the build context using gzip. --force-rm Always remove intermediate containers. --no-cache Do not use cache when building the image. + --no-rm Do not remove intermediate containers after a successful build. --pull Always attempt to pull a newer version of the image. -m, --memory MEM Sets memory limit for the build container. --build-arg key=val Set build-time variables for services. @@ -282,6 +283,7 @@ def build(self, options): pull=bool(options.get('--pull', False)), force_rm=bool(options.get('--force-rm', False)), memory=options.get('--memory'), + rm=not bool(options.get('--no-rm', False)), build_args=build_args, gzip=options.get('--compress', False), parallel_build=options.get('--parallel', False), diff --git a/compose/project.py b/compose/project.py index a7f2aa05720..a2307fa0552 100644 --- a/compose/project.py +++ b/compose/project.py @@ -355,7 +355,7 @@ def restart(self, service_names=None, **options): return containers def build(self, service_names=None, no_cache=False, pull=False, force_rm=False, memory=None, - build_args=None, gzip=False, parallel_build=False): + build_args=None, gzip=False, parallel_build=False, rm=True): services = [] for service in self.get_services(service_names): @@ -365,7 +365,7 @@ def build(self, service_names=None, no_cache=False, pull=False, force_rm=False, log.info('%s uses an image, skipping' % service.name) def build_service(service): - service.build(no_cache, pull, force_rm, memory, build_args, gzip) + service.build(no_cache, pull, force_rm, memory, build_args, gzip, rm) if parallel_build: _, errors = parallel.parallel_execute( diff --git a/compose/service.py b/compose/service.py index 401efa7eb0d..6483f4f313a 100644 --- a/compose/service.py +++ b/compose/service.py @@ -1049,7 +1049,7 @@ def build_spec(secret): return [build_spec(secret) for secret in self.secrets] def build(self, no_cache=False, pull=False, force_rm=False, memory=None, build_args_override=None, - gzip=False): + gzip=False, rm=True): log.info('Building %s' % self.name) build_opts = self.options.get('build', {}) @@ -1070,7 +1070,7 @@ def build(self, no_cache=False, pull=False, force_rm=False, memory=None, build_a build_output = self.client.build( path=path, tag=self.image_name, - rm=True, + rm=rm, forcerm=force_rm, pull=pull, nocache=no_cache, diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index b0ff484fc94..e330e576b57 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -117,7 +117,7 @@ _docker_compose_build() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "--build-arg --compress --force-rm --help --memory -m --no-cache --pull --parallel" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--build-arg --compress --force-rm --help --memory -m --no-cache --no-rm --pull --parallel" -- "$cur" ) ) ;; *) __docker_compose_complete_services --filter source=build diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 5142f96eb34..8a6415b4088 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -747,6 +747,23 @@ def test_build_failed_forcerm(self): ] assert not containers + def test_build_rm(self): + containers = [ + Container.from_ps(self.project.client, c) + for c in self.project.client.containers(all=True) + ] + + assert not containers + + self.base_dir = 'tests/fixtures/simple-dockerfile' + self.dispatch(['build', '--no-rm', 'simple'], returncode=0) + + containers = [ + Container.from_ps(self.project.client, c) + for c in self.project.client.containers(all=True) + ] + assert containers + def test_build_shm_size_build_option(self): pull_busybox(self.client) self.base_dir = 'tests/fixtures/build-shm-size' From a734371e7fc27f07bbd5f3b16d7f19d49053716d Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Thu, 14 Feb 2019 12:08:00 +0100 Subject: [PATCH 3644/4072] Bump python version from 3.6.8 to 3.7.2 Signed-off-by: Ulysses Souza --- .circleci/config.yml | 2 +- Dockerfile | 4 ++-- Dockerfile.armhf | 4 ++-- Jenkinsfile | 3 +-- appveyor.yml | 4 ++-- requirements-build.txt | 2 +- script/build/linux-entrypoint | 2 +- script/build/windows.ps1 | 4 ++-- script/setup/osx | 4 ++-- script/test/all | 2 +- tox.ini | 2 +- 11 files changed, 16 insertions(+), 17 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 08f8c42c39f..906b1c0dc21 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -13,7 +13,7 @@ jobs: command: sudo pip install --upgrade tox==2.1.1 virtualenv==16.2.0 - run: name: unit tests - command: tox -e py27,py36,py37 -- tests/unit + command: tox -e py27,py37 -- tests/unit build-osx-binary: macos: diff --git a/Dockerfile b/Dockerfile index c5e7c815aae..d3edfcbeb37 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ FROM docker:18.06.1 as docker -FROM python:3.6 +FROM python:3.7.2-stretch RUN set -ex; \ apt-get update -qq; \ @@ -33,4 +33,4 @@ RUN tox --notest ADD . /code/ RUN chown -R user /code/ -ENTRYPOINT ["/code/.tox/py36/bin/docker-compose"] +ENTRYPOINT ["/code/.tox/py37/bin/docker-compose"] diff --git a/Dockerfile.armhf b/Dockerfile.armhf index ee2ce8941eb..9c02dbc76c5 100644 --- a/Dockerfile.armhf +++ b/Dockerfile.armhf @@ -1,4 +1,4 @@ -FROM python:3.6 +FROM python:3.7.2-stretch RUN set -ex; \ apt-get update -qq; \ @@ -36,4 +36,4 @@ RUN tox --notest ADD . /code/ RUN chown -R user /code/ -ENTRYPOINT ["/code/.tox/py36/bin/docker-compose"] +ENTRYPOINT ["/code/.tox/py37/bin/docker-compose"] diff --git a/Jenkinsfile b/Jenkinsfile index 04f5cfbda5c..a19e82273e4 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -37,7 +37,7 @@ def runTests = { Map settings -> def pythonVersions = settings.get("pythonVersions", null) if (!pythonVersions) { - throw new Exception("Need Python versions to test. e.g.: `runTests(pythonVersions: 'py27,py36')`") + throw new Exception("Need Python versions to test. e.g.: `runTests(pythonVersions: 'py27,py37')`") } if (!dockerVersions) { throw new Exception("Need Docker versions to test. e.g.: `runTests(dockerVersions: 'all')`") @@ -77,7 +77,6 @@ def docker_versions = get_versions(2) for (int i = 0; i < docker_versions.length; i++) { def dockerVersion = docker_versions[i] testMatrix["${dockerVersion}_py27"] = runTests([dockerVersions: dockerVersion, pythonVersions: "py27"]) - testMatrix["${dockerVersion}_py36"] = runTests([dockerVersions: dockerVersion, pythonVersions: "py36"]) testMatrix["${dockerVersion}_py37"] = runTests([dockerVersions: dockerVersion, pythonVersions: "py37"]) } diff --git a/appveyor.yml b/appveyor.yml index da80d01d959..98a128a8a18 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -2,7 +2,7 @@ version: '{branch}-{build}' install: - - "SET PATH=C:\\Python36-x64;C:\\Python36-x64\\Scripts;%PATH%" + - "SET PATH=C:\\Python37-x64;C:\\Python37-x64\\Scripts;%PATH%" - "python --version" - "pip install tox==2.9.1 virtualenv==15.1.0" @@ -10,7 +10,7 @@ install: build: false test_script: - - "tox -e py27,py36,py37 -- tests/unit" + - "tox -e py27,py37 -- tests/unit" - ps: ".\\script\\build\\windows.ps1" artifacts: diff --git a/requirements-build.txt b/requirements-build.txt index e5a77e7942e..9161fadf941 100644 --- a/requirements-build.txt +++ b/requirements-build.txt @@ -1 +1 @@ -pyinstaller==3.3.1 +pyinstaller==3.4 diff --git a/script/build/linux-entrypoint b/script/build/linux-entrypoint index 0e3c7ec1e95..34c16ac695f 100755 --- a/script/build/linux-entrypoint +++ b/script/build/linux-entrypoint @@ -3,7 +3,7 @@ set -ex TARGET=dist/docker-compose-$(uname -s)-$(uname -m) -VENV=/code/.tox/py36 +VENV=/code/.tox/py37 mkdir -p `pwd`/dist chmod 777 `pwd`/dist diff --git a/script/build/windows.ps1 b/script/build/windows.ps1 index 41dc51e313c..14c75762b2e 100644 --- a/script/build/windows.ps1 +++ b/script/build/windows.ps1 @@ -6,11 +6,11 @@ # # http://git-scm.com/download/win # -# 2. Install Python 3.6.4: +# 2. Install Python 3.7.2: # # https://www.python.org/downloads/ # -# 3. Append ";C:\Python36;C:\Python36\Scripts" to the "Path" environment variable: +# 3. Append ";C:\Python37;C:\Python37\Scripts" to the "Path" environment variable: # # https://www.microsoft.com/resources/documentation/windows/xp/all/proddocs/en-us/sysdm_advancd_environmnt_addchange_variable.mspx?mfr=true # diff --git a/script/setup/osx b/script/setup/osx index b0c34e5bf24..ff460d3929b 100755 --- a/script/setup/osx +++ b/script/setup/osx @@ -17,9 +17,9 @@ OPENSSL_VERSION=1.1.1a OPENSSL_URL=https://www.openssl.org/source/openssl-${OPENSSL_VERSION}.tar.gz OPENSSL_SHA1=8fae27b4f34445a5500c9dc50ae66b4d6472ce29 -PYTHON_VERSION=3.6.8 +PYTHON_VERSION=3.7.2 PYTHON_URL=https://www.python.org/ftp/python/${PYTHON_VERSION}/Python-${PYTHON_VERSION}.tgz -PYTHON_SHA1=09fcc4edaef0915b4dedbfb462f1cd15f82d3a6f +PYTHON_SHA1=0cd8e52d8ed1d0be12ac8e87a623a15df3a3b418 # # Install prerequisites. diff --git a/script/test/all b/script/test/all index e48f73bba76..5c911bba4bc 100755 --- a/script/test/all +++ b/script/test/all @@ -24,7 +24,7 @@ fi BUILD_NUMBER=${BUILD_NUMBER-$USER} -PY_TEST_VERSIONS=${PY_TEST_VERSIONS:-py27,py36} +PY_TEST_VERSIONS=${PY_TEST_VERSIONS:-py27,py37} for version in $DOCKER_VERSIONS; do >&2 echo "Running tests against Docker $version" diff --git a/tox.ini b/tox.ini index 08efd4e68e4..57e57bc635e 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py27,py36,py37,pre-commit +envlist = py27,py37,pre-commit [testenv] usedevelop=True From bb0bd3b26b91a8fdbcaf242265a29b9fffd9f904 Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Thu, 14 Feb 2019 12:20:15 +0100 Subject: [PATCH 3645/4072] Harmonize tox and virtualenv versions - Set all tox versions to 2.9.1 - Set all virtualenv version to 16.2.0 Signed-off-by: Ulysses Souza --- Dockerfile | 2 +- appveyor.yml | 2 +- script/build/windows.ps1 | 2 +- script/setup/osx | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index d3edfcbeb37..b887bcc4e15 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,7 +19,7 @@ WORKDIR /code/ # FIXME(chris-crone): virtualenv 16.3.0 breaks build, force 16.2.0 until fixed RUN pip install virtualenv==16.2.0 -RUN pip install tox==2.1.1 +RUN pip install tox==2.9.1 ADD requirements.txt /code/ ADD requirements-dev.txt /code/ diff --git a/appveyor.yml b/appveyor.yml index 98a128a8a18..04a40e9c2c4 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -4,7 +4,7 @@ version: '{branch}-{build}' install: - "SET PATH=C:\\Python37-x64;C:\\Python37-x64\\Scripts;%PATH%" - "python --version" - - "pip install tox==2.9.1 virtualenv==15.1.0" + - "pip install tox==2.9.1 virtualenv==16.2.0" # Build the binary after tests build: false diff --git a/script/build/windows.ps1 b/script/build/windows.ps1 index 14c75762b2e..4c7a8bed56c 100644 --- a/script/build/windows.ps1 +++ b/script/build/windows.ps1 @@ -16,7 +16,7 @@ # # 4. In Powershell, run the following commands: # -# $ pip install 'virtualenv>=15.1.0' +# $ pip install 'virtualenv==16.2.0' # $ Set-ExecutionPolicy -Scope CurrentUser RemoteSigned # # 5. Clone the repository: diff --git a/script/setup/osx b/script/setup/osx index ff460d3929b..d8613e86425 100755 --- a/script/setup/osx +++ b/script/setup/osx @@ -36,7 +36,7 @@ if ! [ -x "$(command -v python3)" ]; then brew install python3 fi if ! [ -x "$(command -v virtualenv)" ]; then - pip install virtualenv + pip install virtualenv==16.2.0 fi # From dbc229dc3790976055d17a9f14a6364f7c949002 Mon Sep 17 00:00:00 2001 From: Christopher Crone Date: Thu, 21 Feb 2019 13:57:19 +0100 Subject: [PATCH 3646/4072] Fix macOS build for Python 3.7 - Specify --with-openssl directory for Python build - Better checks for downloaded SDK, OpenSSL, and Python - Fix missing slash for Python build CPPFLAGS Signed-off-by: Christopher Crone --- script/setup/osx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/script/setup/osx b/script/setup/osx index d8613e86425..2a1277822a7 100755 --- a/script/setup/osx +++ b/script/setup/osx @@ -50,7 +50,7 @@ mkdir -p ${TOOLCHAIN_PATH} # # Set macOS SDK. # -if [ ${SDK_FETCH} ]; then +if [[ ${SDK_FETCH} && ! -f ${TOOLCHAIN_PATH}/MacOSX${DEPLOYMENT_TARGET}.sdk/SDKSettings.plist ]]; then SDK_PATH=${TOOLCHAIN_PATH}/MacOSX${DEPLOYMENT_TARGET}.sdk fetch_tarball ${SDK_URL} ${SDK_PATH} ${SDK_SHA1} else @@ -61,7 +61,7 @@ fi # Build OpenSSL. # OPENSSL_SRC_PATH=${TOOLCHAIN_PATH}/openssl-${OPENSSL_VERSION} -if ! [ -f ${TOOLCHAIN_PATH}/bin/openssl ]; then +if ! [[ $(${TOOLCHAIN_PATH}/bin/openssl version) == *"${OPENSSL_VERSION}"* ]]; then rm -rf ${OPENSSL_SRC_PATH} fetch_tarball ${OPENSSL_URL} ${OPENSSL_SRC_PATH} ${OPENSSL_SHA1} ( @@ -77,7 +77,7 @@ fi # Build Python. # PYTHON_SRC_PATH=${TOOLCHAIN_PATH}/Python-${PYTHON_VERSION} -if ! [ -f ${TOOLCHAIN_PATH}/bin/python3 ]; then +if ! [[ $(${TOOLCHAIN_PATH}/bin/python3 --version) == *"${PYTHON_VERSION}"* ]]; then rm -rf ${PYTHON_SRC_PATH} fetch_tarball ${PYTHON_URL} ${PYTHON_SRC_PATH} ${PYTHON_SHA1} ( @@ -87,9 +87,10 @@ if ! [ -f ${TOOLCHAIN_PATH}/bin/python3 ]; then --datarootdir=${TOOLCHAIN_PATH}/share \ --datadir=${TOOLCHAIN_PATH}/share \ --enable-framework=${TOOLCHAIN_PATH}/Frameworks \ + --with-openssl=${TOOLCHAIN_PATH} \ MACOSX_DEPLOYMENT_TARGET=${DEPLOYMENT_TARGET} \ CFLAGS="-isysroot ${SDK_PATH} -I${TOOLCHAIN_PATH}/include" \ - CPPFLAGS="-I${SDK_PATH}/usr/include -I${TOOLCHAIN_PATH}include" \ + CPPFLAGS="-I${SDK_PATH}/usr/include -I${TOOLCHAIN_PATH}/include" \ LDFLAGS="-isysroot ${SDK_PATH} -L ${TOOLCHAIN_PATH}/lib" make -j 4 make install PYTHONAPPSDIR=${TOOLCHAIN_PATH} From 133df6310851888067c42e1b938cf51de46ebc03 Mon Sep 17 00:00:00 2001 From: Christopher Crone Date: Thu, 21 Feb 2019 16:21:03 +0100 Subject: [PATCH 3647/4072] Add built Python smoke test to macOS setup script Prior to this smoke test, the macOS setup step wouldn't fail if the Python that it built wasn't functional. This will make debugging Python build issues easier in the future. Signed-off-by: Christopher Crone --- script/setup/osx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/script/setup/osx b/script/setup/osx index 2a1277822a7..1fb91edca5e 100755 --- a/script/setup/osx +++ b/script/setup/osx @@ -98,6 +98,11 @@ if ! [[ $(${TOOLCHAIN_PATH}/bin/python3 --version) == *"${PYTHON_VERSION}"* ]]; ) fi +# +# Smoke test built Python. +# +openssl_version ${TOOLCHAIN_PATH} + echo "" echo "*** Targeting macOS: ${DEPLOYMENT_TARGET}" echo "*** Using SDK ${SDK_PATH}" From 572032fc0b2c35f12787a170a891da6c59b4bc90 Mon Sep 17 00:00:00 2001 From: tuttieee Date: Wed, 6 Feb 2019 20:39:19 +0900 Subject: [PATCH 3648/4072] Fix Project#build_container_operation_with_timeout_func not to mutate a 'option' dict over multiple containers Signed-off-by: Yuichiro Tsuchiya --- compose/project.py | 7 ++++--- tests/unit/project_test.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/compose/project.py b/compose/project.py index a2307fa0552..bdeff19e668 100644 --- a/compose/project.py +++ b/compose/project.py @@ -725,10 +725,11 @@ def _inject_deps(self, acc, service): def build_container_operation_with_timeout_func(self, operation, options): def container_operation_with_timeout(container): - if options.get('timeout') is None: + _options = options.copy() + if _options.get('timeout') is None: service = self.get_service(container.service) - options['timeout'] = service.stop_timeout(None) - return getattr(container, operation)(**options) + _options['timeout'] = service.stop_timeout(None) + return getattr(container, operation)(**_options) return container_operation_with_timeout diff --git a/tests/unit/project_test.py b/tests/unit/project_test.py index 4aea91a0d5a..89b080d20e7 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -15,6 +15,8 @@ from compose.const import COMPOSEFILE_V1 as V1 from compose.const import COMPOSEFILE_V2_0 as V2_0 from compose.const import COMPOSEFILE_V2_4 as V2_4 +from compose.const import COMPOSEFILE_V3_7 as V3_7 +from compose.const import DEFAULT_TIMEOUT from compose.const import LABEL_SERVICE from compose.container import Container from compose.errors import OperationFailedError @@ -765,6 +767,34 @@ def test_project_platform_value(self): ) assert project.get_service('web').platform == 'linux/s390x' + def test_build_container_operation_with_timeout_func_does_not_mutate_options_with_timeout(self): + config_data = Config( + version=V3_7, + services=[ + {'name': 'web', 'image': 'busybox:latest'}, + {'name': 'db', 'image': 'busybox:latest', 'stop_grace_period': '1s'}, + ], + networks={}, volumes={}, secrets=None, configs=None, + ) + + project = Project.from_config(name='test', client=self.mock_client, config_data=config_data) + + stop_op = project.build_container_operation_with_timeout_func('stop', options={}) + + web_container = mock.create_autospec(Container, service='web') + db_container = mock.create_autospec(Container, service='db') + + # `stop_grace_period` is not set to 'web' service, + # then it is stopped with the default timeout. + stop_op(web_container) + web_container.stop.assert_called_once_with(timeout=DEFAULT_TIMEOUT) + + # `stop_grace_period` is set to 'db' service, + # then it is stopped with the specified timeout and + # the value is not overridden by the previous function call. + stop_op(db_container) + db_container.stop.assert_called_once_with(timeout=1) + @mock.patch('compose.parallel.ParallelStreamWriter._write_noansi') def test_error_parallel_pull(self, mock_write): project = Project.from_config( From b09d8802edb498a91a73bcd63603774c9b027b47 Mon Sep 17 00:00:00 2001 From: slowr Date: Fri, 22 Feb 2019 18:40:28 -0800 Subject: [PATCH 3649/4072] Added additional argument (--env-file) for docker-compose to import environment variables from a given PATH. Signed-off-by: Dimitrios Mavrommatis --- compose/cli/command.py | 6 ++++-- compose/cli/main.py | 19 ++++++++++++++----- compose/config/environment.py | 7 +++++-- tests/acceptance/cli_test.py | 15 +++++++++++++++ tests/fixtures/default-env-file/.env2 | 4 ++++ tests/unit/config/config_test.py | 19 +++++++++++++++++++ 6 files changed, 61 insertions(+), 9 deletions(-) create mode 100644 tests/fixtures/default-env-file/.env2 diff --git a/compose/cli/command.py b/compose/cli/command.py index c1f6c29257b..f9e698e170b 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -39,7 +39,8 @@ def project_from_options(project_dir, options): override_dir = options.get('--project-directory') - environment = Environment.from_env_file(override_dir or project_dir) + environment_file = options.get('--env-file') + environment = Environment.from_env_file(override_dir or project_dir, environment_file) environment.silent = options.get('COMMAND', None) in SILENT_COMMANDS set_parallel_limit(environment) @@ -77,7 +78,8 @@ def set_parallel_limit(environment): def get_config_from_options(base_dir, options): override_dir = options.get('--project-directory') - environment = Environment.from_env_file(override_dir or base_dir) + environment_file = options.get('--env-file') + environment = Environment.from_env_file(override_dir or base_dir, environment_file) config_path = get_config_path_from_options( base_dir, options, environment ) diff --git a/compose/cli/main.py b/compose/cli/main.py index 08976347ec2..d8793c85bc8 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -208,6 +208,7 @@ class TopLevelCommand(object): (default: the path of the Compose file) --compatibility If set, Compose will attempt to convert keys in v3 files to their non-Swarm equivalent + --env-file PATH Specify an alternate environment file Commands: build Build or rebuild services @@ -274,7 +275,8 @@ def build(self, options): '--build-arg is only supported when services are specified for API version < 1.25.' ' Please use a Compose file version > 2.2 or specify which services to build.' ) - environment = Environment.from_env_file(self.project_dir) + environment_file = options.get('--env-file') + environment = Environment.from_env_file(self.project_dir, environment_file) build_args = resolve_build_args(build_args, environment) self.project.build( @@ -423,8 +425,10 @@ def down(self, options): Compose file -t, --timeout TIMEOUT Specify a shutdown timeout in seconds. (default: 10) + --env-file PATH Specify an alternate environment file """ - environment = Environment.from_env_file(self.project_dir) + environment_file = options.get('--env-file') + environment = Environment.from_env_file(self.project_dir, environment_file) ignore_orphans = environment.get_boolean('COMPOSE_IGNORE_ORPHANS') if ignore_orphans and options['--remove-orphans']: @@ -481,8 +485,10 @@ def exec_command(self, options): -e, --env KEY=VAL Set environment variables (can be used multiple times, not supported in API < 1.25) -w, --workdir DIR Path to workdir directory for this command. + --env-file PATH Specify an alternate environment file """ - environment = Environment.from_env_file(self.project_dir) + environment_file = options.get('--env-file') + environment = Environment.from_env_file(self.project_dir, environment_file) use_cli = not environment.get_boolean('COMPOSE_INTERACTIVE_NO_CLI') index = int(options.get('--index')) service = self.project.get_service(options['SERVICE']) @@ -1038,6 +1044,7 @@ def up(self, options): container. Implies --abort-on-container-exit. --scale SERVICE=NUM Scale SERVICE to NUM instances. Overrides the `scale` setting in the Compose file if present. + --env-file PATH Specify an alternate environment file """ start_deps = not options['--no-deps'] always_recreate_deps = options['--always-recreate-deps'] @@ -1052,7 +1059,8 @@ def up(self, options): if detached and (cascade_stop or exit_value_from): raise UserError("--abort-on-container-exit and -d cannot be combined.") - environment = Environment.from_env_file(self.project_dir) + environment_file = options.get('--env-file') + environment = Environment.from_env_file(self.project_dir, environment_file) ignore_orphans = environment.get_boolean('COMPOSE_IGNORE_ORPHANS') if ignore_orphans and remove_orphans: @@ -1345,7 +1353,8 @@ def remove_container(force=False): if options['--rm']: project.client.remove_container(container.id, force=True, v=True) - environment = Environment.from_env_file(project_dir) + environment_file = options.get('--env-file') + environment = Environment.from_env_file(project_dir, environment_file) use_cli = not environment.get_boolean('COMPOSE_INTERACTIVE_NO_CLI') signals.set_signal_handler_to_shutdown() diff --git a/compose/config/environment.py b/compose/config/environment.py index 62c40a4bc06..e2db343d70a 100644 --- a/compose/config/environment.py +++ b/compose/config/environment.py @@ -59,12 +59,15 @@ def __init__(self, *args, **kwargs): self.silent = False @classmethod - def from_env_file(cls, base_dir): + def from_env_file(cls, base_dir, env_file=None): def _initialize(): result = cls() if base_dir is None: return result - env_file_path = os.path.join(base_dir, '.env') + if env_file: + env_file_path = os.path.join(base_dir, env_file) + else: + env_file_path = os.path.join(base_dir, '.env') try: return cls(env_vars_from_file(env_file_path)) except EnvFileNotFound: diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 8a6415b4088..6a9a392a555 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -324,6 +324,21 @@ def test_config_with_dot_env(self): 'version': '2.4' } + def test_config_with_env_file(self): + self.base_dir = 'tests/fixtures/default-env-file' + result = self.dispatch(['--env-file', '.env2', 'config']) + json_result = yaml.load(result.stdout) + assert json_result == { + 'services': { + 'web': { + 'command': 'false', + 'image': 'alpine:latest', + 'ports': ['5644/tcp', '9998/tcp'] + } + }, + 'version': '2.4' + } + def test_config_with_dot_env_and_override_dir(self): self.base_dir = 'tests/fixtures/default-env-file' result = self.dispatch(['--project-directory', 'alt/', 'config']) diff --git a/tests/fixtures/default-env-file/.env2 b/tests/fixtures/default-env-file/.env2 new file mode 100644 index 00000000000..d754523fc50 --- /dev/null +++ b/tests/fixtures/default-env-file/.env2 @@ -0,0 +1,4 @@ +IMAGE=alpine:latest +COMMAND=false +PORT1=5644 +PORT2=9998 diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index c2e6b7b0761..888c0ae5805 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -3465,6 +3465,25 @@ def test_config_file_with_environment_file(self): 'command': 'true' } + @mock.patch.dict(os.environ) + def test_config_file_with_options_environment_file(self): + project_dir = 'tests/fixtures/default-env-file' + service_dicts = config.load( + config.find( + project_dir, None, Environment.from_env_file(project_dir, '.env2') + ) + ).services + + assert service_dicts[0] == { + 'name': 'web', + 'image': 'alpine:latest', + 'ports': [ + types.ServicePort.parse('5644')[0], + types.ServicePort.parse('9998')[0] + ], + 'command': 'false' + } + @mock.patch.dict(os.environ) def test_config_file_with_environment_variable(self): project_dir = 'tests/fixtures/environment-interpolation' From 1f97a572fe35f2ff2e557b2f6ca371c67f7c2a82 Mon Sep 17 00:00:00 2001 From: Akshit Grover Date: Sat, 2 Mar 2019 13:07:23 +0530 Subject: [PATCH 3650/4072] Add --quiet build flag Signed-off-by: Akshit Grover --- compose/cli/main.py | 2 ++ compose/project.py | 7 +++---- compose/service.py | 10 ++++++---- contrib/completion/bash/docker-compose | 2 +- contrib/completion/zsh/_docker-compose | 1 + tests/acceptance/cli_test.py | 7 +++++++ 6 files changed, 20 insertions(+), 9 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index d8793c85bc8..2349568c92b 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -266,6 +266,7 @@ def build(self, options): -m, --memory MEM Sets memory limit for the build container. --build-arg key=val Set build-time variables for services. --parallel Build images in parallel. + -q, --quiet Don't print anything to STDOUT """ service_names = options['SERVICE'] build_args = options.get('--build-arg', None) @@ -289,6 +290,7 @@ def build(self, options): build_args=build_args, gzip=options.get('--compress', False), parallel_build=options.get('--parallel', False), + silent=options.get('--quiet', False) ) def bundle(self, options): diff --git a/compose/project.py b/compose/project.py index bdeff19e668..a74033729fd 100644 --- a/compose/project.py +++ b/compose/project.py @@ -355,18 +355,17 @@ def restart(self, service_names=None, **options): return containers def build(self, service_names=None, no_cache=False, pull=False, force_rm=False, memory=None, - build_args=None, gzip=False, parallel_build=False, rm=True): + build_args=None, gzip=False, parallel_build=False, rm=True, silent=False): services = [] for service in self.get_services(service_names): if service.can_be_built(): services.append(service) - else: + elif not silent: log.info('%s uses an image, skipping' % service.name) def build_service(service): - service.build(no_cache, pull, force_rm, memory, build_args, gzip, rm) - + service.build(no_cache, pull, force_rm, memory, build_args, gzip, rm, silent) if parallel_build: _, errors = parallel.parallel_execute( services, diff --git a/compose/service.py b/compose/service.py index 6483f4f313a..b5a6b392d77 100644 --- a/compose/service.py +++ b/compose/service.py @@ -59,7 +59,6 @@ from .utils import truncate_id from .utils import unique_everseen - log = logging.getLogger(__name__) @@ -1049,8 +1048,11 @@ def build_spec(secret): return [build_spec(secret) for secret in self.secrets] def build(self, no_cache=False, pull=False, force_rm=False, memory=None, build_args_override=None, - gzip=False, rm=True): - log.info('Building %s' % self.name) + gzip=False, rm=True, silent=False): + output_stream = open(os.devnull, 'w') + if not silent: + output_stream = sys.stdout + log.info('Building %s' % self.name) build_opts = self.options.get('build', {}) @@ -1091,7 +1093,7 @@ def build(self, no_cache=False, pull=False, force_rm=False, memory=None, build_a ) try: - all_events = list(stream_output(build_output, sys.stdout)) + all_events = list(stream_output(build_output, output_stream)) except StreamOutputError as e: raise BuildError(self, six.text_type(e)) diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index e330e576b57..941f25a3bec 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -117,7 +117,7 @@ _docker_compose_build() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "--build-arg --compress --force-rm --help --memory -m --no-cache --no-rm --pull --parallel" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--build-arg --compress --force-rm --help --memory -m --no-cache --no-rm --pull --parallel -q --quiet" -- "$cur" ) ) ;; *) __docker_compose_complete_services --filter source=build diff --git a/contrib/completion/zsh/_docker-compose b/contrib/completion/zsh/_docker-compose index d25256c14c3..808b068a314 100755 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -113,6 +113,7 @@ __docker-compose_subcommand() { $opts_help \ "*--build-arg=[Set build-time variables for one service.]:=: " \ '--force-rm[Always remove intermediate containers.]' \ + '(--quiet -q)'{--quiet,-q}'[Curb build output]' \ '(--memory -m)'{--memory,-m}'[Memory limit for the build container.]' \ '--no-cache[Do not use cache when building the image.]' \ '--pull[Always attempt to pull a newer version of the image.]' \ diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 6a9a392a555..dbd82211540 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -170,6 +170,13 @@ def test_help(self): # Prevent tearDown from trying to create a project self.base_dir = None + def test_quiet_build(self): + self.base_dir = 'tests/fixtures/build-args' + result = self.dispatch(['build'], None) + quietResult = self.dispatch(['build', '-q'], None) + assert result.stdout != "" + assert quietResult.stdout == "" + def test_help_nonexistent(self): self.base_dir = 'tests/fixtures/no-composefile' result = self.dispatch(['help', 'foobar'], returncode=1) From e34d3292273447e4c7ecca4cb85609034f7c7468 Mon Sep 17 00:00:00 2001 From: "Peter Nagy (NPE)" Date: Fri, 23 Nov 2018 11:30:49 +0100 Subject: [PATCH 3651/4072] adds --no-interpolate to docker-compose config Signed-off-by: Peter Nagy --- compose/cli/command.py | 12 ++++---- compose/cli/main.py | 10 ++++--- compose/config/config.py | 49 +++++++++++++++++++++++--------- compose/config/serialize.py | 20 +++++++++---- tests/unit/config/config_test.py | 41 ++++++++++++++++++++++++++ 5 files changed, 103 insertions(+), 29 deletions(-) diff --git a/compose/cli/command.py b/compose/cli/command.py index f9e698e170b..21ab9a39fa6 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -37,7 +37,7 @@ )) -def project_from_options(project_dir, options): +def project_from_options(project_dir, options, additional_options={}): override_dir = options.get('--project-directory') environment_file = options.get('--env-file') environment = Environment.from_env_file(override_dir or project_dir, environment_file) @@ -57,6 +57,7 @@ def project_from_options(project_dir, options): environment=environment, override_dir=override_dir, compatibility=options.get('--compatibility'), + interpolate=(not additional_options.get('--no-interpolate')) ) @@ -76,7 +77,7 @@ def set_parallel_limit(environment): parallel.GlobalLimit.set_global_limit(parallel_limit) -def get_config_from_options(base_dir, options): +def get_config_from_options(base_dir, options, additional_options={}): override_dir = options.get('--project-directory') environment_file = options.get('--env-file') environment = Environment.from_env_file(override_dir or base_dir, environment_file) @@ -85,7 +86,8 @@ def get_config_from_options(base_dir, options): ) return config.load( config.find(base_dir, config_path, environment, override_dir), - options.get('--compatibility') + options.get('--compatibility'), + not additional_options.get('--no-interpolate') ) @@ -123,14 +125,14 @@ def get_client(environment, verbose=False, version=None, tls_config=None, host=N def get_project(project_dir, config_path=None, project_name=None, verbose=False, host=None, tls_config=None, environment=None, override_dir=None, - compatibility=False): + compatibility=False, interpolate=True): if not environment: environment = Environment.from_env_file(project_dir) config_details = config.find(project_dir, config_path, environment, override_dir) project_name = get_project_name( config_details.working_dir, project_name, environment ) - config_data = config.load(config_details, compatibility) + config_data = config.load(config_details, compatibility, interpolate) api_version = environment.get( 'COMPOSE_API_VERSION', diff --git a/compose/cli/main.py b/compose/cli/main.py index d8793c85bc8..1f8c58ca4d7 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -331,6 +331,7 @@ def config(self, options): Options: --resolve-image-digests Pin image tags to digests. + --no-interpolate Don't interpolate environment variables -q, --quiet Only validate the configuration, don't print anything. --services Print the service names, one per line. @@ -340,11 +341,12 @@ def config(self, options): or use the wildcard symbol to display all services """ - compose_config = get_config_from_options('.', self.toplevel_options) + additional_options = {'--no-interpolate': options.get('--no-interpolate')} + compose_config = get_config_from_options('.', self.toplevel_options, additional_options) image_digests = None if options['--resolve-image-digests']: - self.project = project_from_options('.', self.toplevel_options) + self.project = project_from_options('.', self.toplevel_options, additional_options) with errors.handle_connection_errors(self.project.client): image_digests = image_digests_for_project(self.project) @@ -361,14 +363,14 @@ def config(self, options): if options['--hash'] is not None: h = options['--hash'] - self.project = project_from_options('.', self.toplevel_options) + self.project = project_from_options('.', self.toplevel_options, additional_options) services = [svc for svc in options['--hash'].split(',')] if h != '*' else None with errors.handle_connection_errors(self.project.client): for service in self.project.get_services(services): print('{} {}'.format(service.name, service.config_hash)) return - print(serialize_config(compose_config, image_digests)) + print(serialize_config(compose_config, image_digests, not options['--no-interpolate'])) def create(self, options): """ diff --git a/compose/config/config.py b/compose/config/config.py index e2ed29a479c..c91c5aeb25b 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -373,7 +373,7 @@ def check_swarm_only_key(service_dicts, key): check_swarm_only_key(service_dicts, 'configs') -def load(config_details, compatibility=False): +def load(config_details, compatibility=False, interpolate=True): """Load the configuration from a working directory and a list of configuration files. Files are loaded in order, and merged on top of each other to create the final configuration. @@ -383,7 +383,7 @@ def load(config_details, compatibility=False): validate_config_version(config_details.config_files) processed_files = [ - process_config_file(config_file, config_details.environment) + process_config_file(config_file, config_details.environment, interpolate=interpolate) for config_file in config_details.config_files ] config_details = config_details._replace(config_files=processed_files) @@ -505,7 +505,6 @@ def merge_services(base, override): 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, @@ -514,38 +513,60 @@ def interpolate_config_section(config_file, config, section, environment): ) -def process_config_file(config_file, environment, service_name=None): - services = interpolate_config_section( +def process_config_section(config_file, config, section, environment, interpolate): + validate_config_section(config_file.filename, config, section) + if interpolate: + return interpolate_environment_variables( + config_file.version, + config, + section, + environment + ) + else: + return config + + +def process_config_file(config_file, environment, service_name=None, interpolate=True): + services = process_config_section( config_file, config_file.get_service_dicts(), 'service', - environment) + environment, + interpolate, + ) if config_file.version > V1: processed_config = dict(config_file.config) processed_config['services'] = services - processed_config['volumes'] = interpolate_config_section( + processed_config['volumes'] = process_config_section( config_file, config_file.get_volumes(), 'volume', - environment) - processed_config['networks'] = interpolate_config_section( + environment, + interpolate, + ) + processed_config['networks'] = process_config_section( config_file, config_file.get_networks(), 'network', - environment) + environment, + interpolate, + ) if config_file.version >= const.COMPOSEFILE_V3_1: - processed_config['secrets'] = interpolate_config_section( + processed_config['secrets'] = process_config_section( config_file, config_file.get_secrets(), 'secret', - environment) + environment, + interpolate, + ) if config_file.version >= const.COMPOSEFILE_V3_3: - processed_config['configs'] = interpolate_config_section( + processed_config['configs'] = process_config_section( config_file, config_file.get_configs(), 'config', - environment + environment, + interpolate, ) else: processed_config = services diff --git a/compose/config/serialize.py b/compose/config/serialize.py index 8cb8a280807..5776ce957cb 100644 --- a/compose/config/serialize.py +++ b/compose/config/serialize.py @@ -24,14 +24,12 @@ def serialize_dict_type(dumper, data): def serialize_string(dumper, data): - """ Ensure boolean-like strings are quoted in the output and escape $ characters """ + """ Ensure boolean-like strings are quoted in the output """ representer = dumper.represent_str if six.PY3 else dumper.represent_unicode if isinstance(data, six.binary_type): data = data.decode('utf-8') - data = data.replace('$', '$$') - if data.lower() in ('y', 'n', 'yes', 'no', 'on', 'off', 'true', 'false'): # Empirically only y/n appears to be an issue, but this might change # depending on which PyYaml version is being used. Err on safe side. @@ -39,6 +37,12 @@ def serialize_string(dumper, data): return representer(data) +def serialize_string_escape_dollar(dumper, data): + """ Ensure boolean-like strings are quoted in the output and escape $ characters """ + data = data.replace('$', '$$') + return serialize_string(dumper, data) + + yaml.SafeDumper.add_representer(types.MountSpec, serialize_dict_type) yaml.SafeDumper.add_representer(types.VolumeFromSpec, serialize_config_type) yaml.SafeDumper.add_representer(types.VolumeSpec, serialize_config_type) @@ -46,8 +50,6 @@ def serialize_string(dumper, data): yaml.SafeDumper.add_representer(types.ServiceSecret, serialize_dict_type) yaml.SafeDumper.add_representer(types.ServiceConfig, serialize_dict_type) yaml.SafeDumper.add_representer(types.ServicePort, serialize_dict_type) -yaml.SafeDumper.add_representer(str, serialize_string) -yaml.SafeDumper.add_representer(six.text_type, serialize_string) def denormalize_config(config, image_digests=None): @@ -93,7 +95,13 @@ def v3_introduced_name_key(key): return V3_5 -def serialize_config(config, image_digests=None): +def serialize_config(config, image_digests=None, escape_dollar=True): + if escape_dollar: + yaml.SafeDumper.add_representer(str, serialize_string_escape_dollar) + yaml.SafeDumper.add_representer(six.text_type, serialize_string_escape_dollar) + else: + yaml.SafeDumper.add_representer(str, serialize_string) + yaml.SafeDumper.add_representer(six.text_type, serialize_string) return yaml.safe_dump( denormalize_config(config, image_digests), default_flow_style=False, diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 888c0ae5805..d9d8496af5a 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -613,6 +613,25 @@ def test_config_integer_service_name_raise_validation_error_v2(self): excinfo.exconly() ) + def test_config_integer_service_name_raise_validation_error_v2_when_no_interpolate(self): + with pytest.raises(ConfigurationError) as excinfo: + config.load( + build_config_details( + { + 'version': '2', + 'services': {1: {'image': 'busybox'}} + }, + 'working_dir', + 'filename.yml' + ), + interpolate=False + ) + + 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_property_raise_validation_error(self): with pytest.raises(ConfigurationError) as excinfo: config.load( @@ -5328,6 +5347,28 @@ def test_serialize_escape_dollar_sign(self): assert serialized_service['command'] == 'echo $$FOO' assert serialized_service['entrypoint'][0] == '$$SHELL' + def test_serialize_escape_dont_interpolate(self): + cfg = { + 'version': '2.2', + 'services': { + 'web': { + 'image': 'busybox', + 'command': 'echo $FOO', + 'environment': { + 'CURRENCY': '$' + }, + 'entrypoint': ['$SHELL', '-c'], + } + } + } + config_dict = config.load(build_config_details(cfg), interpolate=False) + + serialized_config = yaml.load(serialize_config(config_dict, escape_dollar=False)) + serialized_service = serialized_config['services']['web'] + assert serialized_service['environment']['CURRENCY'] == '$' + assert serialized_service['command'] == 'echo $FOO' + assert serialized_service['entrypoint'][0] == '$SHELL' + def test_serialize_unicode_values(self): cfg = { 'version': '2.3', From 42c965935f93aff8961517cd7c1803afcb621a2c Mon Sep 17 00:00:00 2001 From: Jonathan Cremin Date: Tue, 5 Mar 2019 11:28:15 +0000 Subject: [PATCH 3652/4072] Fix scale attribute to accept 0 as a value Signed-off-by: Jonathan Cremin --- compose/service.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/compose/service.py b/compose/service.py index 6483f4f313a..2754572fe5e 100644 --- a/compose/service.py +++ b/compose/service.py @@ -177,7 +177,7 @@ def __init__( network_mode=None, networks=None, secrets=None, - scale=None, + scale=1, pid_mode=None, default_platform=None, **options @@ -192,7 +192,7 @@ def __init__( self.pid_mode = pid_mode or PidMode(None) self.networks = networks or {} self.secrets = secrets or [] - self.scale_num = scale or 1 + self.scale_num = scale self.default_platform = default_platform self.options = options From 0b039202ac3c78c12857e464350225b8c10e43ef Mon Sep 17 00:00:00 2001 From: Ian Campbell Date: Tue, 5 Mar 2019 11:00:05 +0000 Subject: [PATCH 3653/4072] docs/README.md: update since `vnext-compose` branch is no longer used. All PRs should be made to `master` now. Also: - Template seems to exist now[0] so remove the "coming soon". - The labels used seem different now, but labelling seems more like a docs maintainer thing than a contributor thing, so just drop that paragraph. [0] https://raw.githubusercontent.com/docker/docker.github.io/master/.github/PULL_REQUEST_TEMPLATE.md Signed-off-by: Ian Campbell --- docs/README.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/docs/README.md b/docs/README.md index 50c91d2074f..accc7c23e42 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,11 +6,9 @@ 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). +Please submit pull requests for unreleased features/changes on the `master` branch (https://github.com/docker/docker.github.io/tree/master), please prefix the PR title with `[WIP]` to indicate that it relates to an unreleased change. -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` +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. As always, the docs remain open-source and we appreciate your feedback and pull requests! From 087bef4f95da6007a3a17ce1f796ee521050be7b Mon Sep 17 00:00:00 2001 From: Jonathan Cremin Date: Wed, 6 Mar 2019 12:57:14 +0000 Subject: [PATCH 3654/4072] Add tests for compose file 'scale: 0' Signed-off-by: Jonathan Cremin --- tests/acceptance/cli_test.py | 14 +++++++++++--- tests/fixtures/scale/docker-compose.yml | 8 ++++++-- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 6a9a392a555..4fbc535d036 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -2505,10 +2505,12 @@ def test_up_scale_scale_up(self): self.dispatch(['up', '-d']) assert len(project.get_service('web').containers()) == 2 assert len(project.get_service('db').containers()) == 1 + assert len(project.get_service('worker').containers()) == 0 - self.dispatch(['up', '-d', '--scale', 'web=3']) + self.dispatch(['up', '-d', '--scale', 'web=3', '--scale', 'worker=1']) assert len(project.get_service('web').containers()) == 3 assert len(project.get_service('db').containers()) == 1 + assert len(project.get_service('worker').containers()) == 1 def test_up_scale_scale_down(self): self.base_dir = 'tests/fixtures/scale' @@ -2517,22 +2519,26 @@ def test_up_scale_scale_down(self): self.dispatch(['up', '-d']) assert len(project.get_service('web').containers()) == 2 assert len(project.get_service('db').containers()) == 1 + assert len(project.get_service('worker').containers()) == 0 self.dispatch(['up', '-d', '--scale', 'web=1']) assert len(project.get_service('web').containers()) == 1 assert len(project.get_service('db').containers()) == 1 + assert len(project.get_service('worker').containers()) == 0 def test_up_scale_reset(self): self.base_dir = 'tests/fixtures/scale' project = self.project - self.dispatch(['up', '-d', '--scale', 'web=3', '--scale', 'db=3']) + self.dispatch(['up', '-d', '--scale', 'web=3', '--scale', 'db=3', '--scale', 'worker=3']) assert len(project.get_service('web').containers()) == 3 assert len(project.get_service('db').containers()) == 3 + assert len(project.get_service('worker').containers()) == 3 self.dispatch(['up', '-d']) assert len(project.get_service('web').containers()) == 2 assert len(project.get_service('db').containers()) == 1 + assert len(project.get_service('worker').containers()) == 0 def test_up_scale_to_zero(self): self.base_dir = 'tests/fixtures/scale' @@ -2541,10 +2547,12 @@ def test_up_scale_to_zero(self): self.dispatch(['up', '-d']) assert len(project.get_service('web').containers()) == 2 assert len(project.get_service('db').containers()) == 1 + assert len(project.get_service('worker').containers()) == 0 - self.dispatch(['up', '-d', '--scale', 'web=0', '--scale', 'db=0']) + self.dispatch(['up', '-d', '--scale', 'web=0', '--scale', 'db=0', '--scale', 'worker=0']) assert len(project.get_service('web').containers()) == 0 assert len(project.get_service('db').containers()) == 0 + assert len(project.get_service('worker').containers()) == 0 def test_port(self): self.base_dir = 'tests/fixtures/ports-composefile' diff --git a/tests/fixtures/scale/docker-compose.yml b/tests/fixtures/scale/docker-compose.yml index a0d3b771f16..53ae1342d3c 100644 --- a/tests/fixtures/scale/docker-compose.yml +++ b/tests/fixtures/scale/docker-compose.yml @@ -5,5 +5,9 @@ services: command: top scale: 2 db: - image: busybox - command: top + image: busybox + command: top + worker: + image: busybox + command: top + scale: 0 From 3f1d41a97eb8c73f390009a99cb802899daaed90 Mon Sep 17 00:00:00 2001 From: Michael Irwin Date: Tue, 5 Mar 2019 10:17:09 -0500 Subject: [PATCH 3655/4072] Fix merging of compose files when network has None config Signed-off-by: Michael Irwin Resolves #6525 --- compose/config/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/config/config.py b/compose/config/config.py index c91c5aeb25b..c110e2cfae0 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -1214,7 +1214,7 @@ def merge_networks(base, override): base = {k: {} for k in base} if isinstance(base, list) else base override = {k: {} for k in override} if isinstance(override, list) else override for network_name in all_network_names: - md = MergeDict(base.get(network_name, {}), override.get(network_name, {})) + md = MergeDict(base.get(network_name) or {}, override.get(network_name) or {}) md.merge_field('aliases', merge_unique_items_lists, []) md.merge_field('link_local_ips', merge_unique_items_lists, []) md.merge_scalar('priority') From d8e390eb9fd843d2d232ba08bae02381e1236e54 Mon Sep 17 00:00:00 2001 From: Michael Irwin Date: Tue, 5 Mar 2019 22:18:22 -0500 Subject: [PATCH 3656/4072] Added test case to verify fix for #6525 Signed-off-by: Michael Irwin --- tests/unit/config/config_test.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index d9d8496af5a..e27427ca5c2 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -3970,6 +3970,24 @@ def test_no_network_overrides(self): } } + def test_network_has_none_value(self): + service_dict = config.merge_service_dicts( + {self.config_name: { + 'default': None + }}, + {self.config_name: { + 'default': { + 'aliases': [] + } + }}, + DEFAULT_VERSION) + + assert service_dict[self.config_name] == { + 'default': { + 'aliases': [] + } + } + def test_all_properties(self): service_dict = config.merge_service_dicts( {self.config_name: { From 76d0406fab3ee17bcd71afd98e05a5e6f2731ff4 Mon Sep 17 00:00:00 2001 From: Henke Adolfsson Date: Sat, 2 Mar 2019 15:02:47 +0100 Subject: [PATCH 3657/4072] Add test and implementation for secret added after container has been created The issue is that if a secret is added to the compose file, then it will not notice that containers have diverged since last run, because secrets are not part of the config_hash, which determines if the configuration of a service is the same or not. Signed-off-by: Henke Adolfsson --- compose/service.py | 1 + tests/integration/project_test.py | 42 +++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/compose/service.py b/compose/service.py index 0aaf3cf3759..e989d4877c9 100644 --- a/compose/service.py +++ b/compose/service.py @@ -685,6 +685,7 @@ def image_id(): 'links': self.get_link_names(), 'net': self.network_mode.id, 'networks': self.networks, + 'secrets': self.secrets, 'volumes_from': [ (v.source.name, v.mode) for v in self.volumes_from if isinstance(v.source, Service) diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 57f3b70748e..c6949cdc960 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -1496,6 +1496,48 @@ def test_project_up_with_secrets(self): output = container.logs() assert output == b"This is the secret\n" + @v3_only() + def test_project_up_with_added_secrets(self): + node = create_host_file(self.client, os.path.abspath('tests/fixtures/secrets/default')) + + 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'}), + # ], + 'environment': ['constraint:node=={}'.format(node if node is not None else '*')] + }], + 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() + project.services[0].secrets = [ + types.ServiceSecret.parse({'source': 'super', 'target': 'special'}) + ] + project.up() + project.stop() + + containers = project.containers(stopped=True) + assert len(containers) == 1 + container, = containers + + output = container.logs() + assert output == b"This is the secret\n" + @v2_only() def test_initialize_volumes_invalid_volume_driver(self): vol_name = '{0:x}'.format(random.getrandbits(32)) From aa79fb2473da535f758c8c4562857844e4ed1710 Mon Sep 17 00:00:00 2001 From: Henke Adolfsson Date: Tue, 5 Mar 2019 10:32:05 +0100 Subject: [PATCH 3658/4072] Ensure test passes Signed-off-by: Henke Adolfsson --- tests/integration/project_test.py | 54 +++++++++++++++++++------------ 1 file changed, 34 insertions(+), 20 deletions(-) diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index c6949cdc960..f915640e31d 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 copy import json import os import random @@ -1500,34 +1501,47 @@ def test_project_up_with_secrets(self): def test_project_up_with_added_secrets(self): node = create_host_file(self.client, os.path.abspath('tests/fixtures/secrets/default')) - 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'}), - # ], - 'environment': ['constraint:node=={}'.format(node if node is not None else '*')] - }], - secrets={ + config_input1 = { + 'version': V3_1, + 'services': [ + { + 'name': 'web', + 'image': 'busybox:latest', + 'command': 'cat /run/secrets/special', + 'environment': ['constraint:node=={}'.format(node if node is not None else '')] + } + + ], + 'secrets': { 'super': { - 'file': os.path.abspath('tests/fixtures/secrets/default'), - }, - }, - ) + 'file': os.path.abspath('tests/fixtures/secrets/default') + } + } + } + config_input2 = copy.deepcopy(config_input1) + # Add the secret + config_input2['services'][0]['secrets'] = [ + types.ServiceSecret.parse({'source': 'super', 'target': 'special'}) + ] + config_data1 = build_config(**config_input1) + config_data2 = build_config(**config_input2) + + # First up with non-secret project = Project.from_config( client=self.client, name='composetest', - config_data=config_data, + config_data=config_data1, ) project.up() project.stop() - project.services[0].secrets = [ - types.ServiceSecret.parse({'source': 'super', 'target': 'special'}) - ] + + # Then up with secret + project = Project.from_config( + client=self.client, + name='composetest', + config_data=config_data2, + ) project.up() project.stop() From 87935893fcdf9d5ea5bfa661836fa95c98743705 Mon Sep 17 00:00:00 2001 From: Henke Adolfsson Date: Tue, 5 Mar 2019 15:50:05 +0100 Subject: [PATCH 3659/4072] Update data for unit tests Signed-off-by: Henke Adolfsson --- tests/unit/service_test.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 8b3352fcbd9..3d7c4987a6f 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -333,7 +333,7 @@ def test_get_container_create_options_does_not_mutate_options(self): assert service.options['environment'] == environment assert opts['labels'][LABEL_CONFIG_HASH] == \ - '2524a06fcb3d781aa2c981fc40bcfa08013bb318e4273bfa388df22023e6f2aa' + '689149e6041a85f6fb4945a2146a497ed43c8a5cbd8991753d875b165f1b4de4' assert opts['environment'] == ['also=real'] def test_get_container_create_options_sets_affinity_with_binds(self): @@ -676,6 +676,7 @@ def test_config_dict(self): 'options': {'image': 'example.com/foo'}, 'links': [('one', 'one')], 'net': 'other', + 'secrets': [], 'networks': {'default': None}, 'volumes_from': [('two', 'rw')], } @@ -698,6 +699,7 @@ def test_config_dict_with_network_mode_from_container(self): 'options': {'image': 'example.com/foo'}, 'links': [], 'networks': {}, + 'secrets': [], 'net': 'aaabbb', 'volumes_from': [], } From 853215acf619cadda4f5b892a2fb8402de4ba28f Mon Sep 17 00:00:00 2001 From: Henke Adolfsson Date: Tue, 5 Mar 2019 17:53:13 +0100 Subject: [PATCH 3660/4072] Remove project.stop() in test Signed-off-by: Henke Adolfsson --- tests/integration/project_test.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index f915640e31d..fe6ace90e37 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -1534,7 +1534,6 @@ def test_project_up_with_added_secrets(self): config_data=config_data1, ) project.up() - project.stop() # Then up with secret project = Project.from_config( From 0863785e96903a8610f25171d67cf063c6f6cfea Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Tue, 12 Mar 2019 10:35:09 -0400 Subject: [PATCH 3661/4072] Enable bootloader_ignore_signals in pyinstaller Fixes #3347 Signed-off-by: Ben Firshman --- docker-compose.spec | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docker-compose.spec b/docker-compose.spec index 70db5c29e16..5ca1e4c24de 100644 --- a/docker-compose.spec +++ b/docker-compose.spec @@ -98,4 +98,5 @@ exe = EXE(pyz, debug=False, strip=None, upx=True, - console=True) + console=True, + bootloader_ignore_signals=True) From dc712bfa23aac08f6503f616095e6576b06b3c20 Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Wed, 20 Mar 2019 18:03:41 +0100 Subject: [PATCH 3662/4072] Bump docker-py version to 3.7.1 This docker-py version includes ssh fixes Signed-off-by: Ulysses Souza --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 9333ec60d79..2487f92c048 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ cached-property==1.3.0 certifi==2017.4.17 chardet==3.0.4 colorama==0.4.0; sys_platform == 'win32' -docker==3.7.0 +docker==3.7.1 docker-pycreds==0.4.0 dockerpty==0.4.1 docopt==0.6.2 From 1e4fde8aa7258c3e4c33cbdaf1e71381a5e43c27 Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Wed, 20 Mar 2019 18:03:41 +0100 Subject: [PATCH 3663/4072] Bump docker-py version to 3.7.1 This docker-py version includes ssh fixes Signed-off-by: Ulysses Souza --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 9333ec60d79..2487f92c048 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ cached-property==1.3.0 certifi==2017.4.17 chardet==3.0.4 colorama==0.4.0; sys_platform == 'win32' -docker==3.7.0 +docker==3.7.1 docker-pycreds==0.4.0 dockerpty==0.4.1 docopt==0.6.2 From cd1fcd3ea52a0453ae26253bbe48e3f24ed722e2 Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Fri, 22 Mar 2019 10:32:59 +0100 Subject: [PATCH 3664/4072] Use os.system() instead of run_setup() Use `os.system()` instead of `run_setup()` because the last is not taking any effect Signed-off-by: Ulysses Souza --- script/release/release.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/script/release/release.py b/script/release/release.py index 63bf863dfc9..9db1a49d9f0 100755 --- a/script/release/release.py +++ b/script/release/release.py @@ -7,7 +7,6 @@ import shutil import sys import time -from distutils.core import run_setup from jinja2 import Template from release.bintray import BintrayAPI @@ -276,7 +275,8 @@ def finalize(args): repository.checkout_branch(br_name) - run_setup(os.path.join(REPO_ROOT, 'setup.py'), script_args=['sdist', 'bdist_wheel']) + os.system('python {setup_script} sdist bdist_wheel'.format( + setup_script=os.path.join(REPO_ROOT, 'setup.py'))) merge_status = pr_data.merge() if not merge_status.merged and not args.finalize_resume: From 15f8c30a51e7e614a22f66244b06fd2f736ac356 Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Fri, 22 Mar 2019 10:58:32 +0100 Subject: [PATCH 3665/4072] Fix typo on finalize Signed-off-by: Ulysses Souza --- script/release/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/release/README.md b/script/release/README.md index f7f911e5378..a0ce5d2a8c0 100644 --- a/script/release/README.md +++ b/script/release/README.md @@ -129,7 +129,7 @@ assets public), proceed to the "Finalize a release" section of this guide. Once you're ready to make your release public, you may execute the following command from the root of the Compose repository: ``` -./script/release/release.sh -b finalize RELEAE_VERSION +./script/release/release.sh -b finalize RELEASE_VERSION ``` Note that this command will create and publish versioned assets to the public. From 2948c396a6abc853ffcb16051f3593a70e43625d Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Fri, 22 Mar 2019 11:00:23 +0100 Subject: [PATCH 3666/4072] Fix bintray docker-compose link Signed-off-by: Ulysses Souza --- script/release/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/release/README.md b/script/release/README.md index a0ce5d2a8c0..0c6f12cbeea 100644 --- a/script/release/README.md +++ b/script/release/README.md @@ -40,7 +40,7 @@ This API token should be exposed to the release script through the ### A Bintray account and Bintray API key Your Bintray account will need to be an admin member of the -[docker-compose organization](https://github.com/settings/tokens). +[docker-compose organization](https://bintray.com/docker-compose). Additionally, you should generate a personal API key. To do so, click your username in the top-right hand corner and select "Edit profile" ; on the new page, select "API key" in the left-side menu. From 3934617e379e5c75ba5fc6aa7e518c2108165167 Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Tue, 15 Jan 2019 09:01:49 +0100 Subject: [PATCH 3667/4072] Add bash completion for `ps --all|-a` 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 395888d347e..5938ff9e2cf 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -361,7 +361,7 @@ _docker_compose_ps() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "--help --quiet -q --services --filter" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--all -a --filter --help --quiet -q --services" -- "$cur" ) ) ;; *) __docker_compose_complete_services From 82a89aef1c15a5419097fde5039c837f2af8d9ae Mon Sep 17 00:00:00 2001 From: Djordje Lukic Date: Wed, 16 Jan 2019 13:47:23 +0100 Subject: [PATCH 3668/4072] Support for credential_spec Signed-off-by: Djordje Lukic --- compose/cli/main.py | 4 ++-- compose/config/config.py | 23 ++++++++++++++++++++++- compose/config/validation.py | 12 ++++++++++++ contrib/completion/zsh/_docker-compose | 2 +- tests/unit/config/config_test.py | 6 +++++- 5 files changed, 42 insertions(+), 5 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 950e5055dcc..789601792ea 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -206,8 +206,8 @@ class TopLevelCommand(object): name specified in the client certificate --project-directory PATH Specify an alternate working directory (default: the path of the Compose file) - --compatibility If set, Compose will attempt to convert deploy - keys in v3 files to their non-Swarm equivalent + --compatibility If set, Compose will attempt to convert keys + in v3 files to their non-Swarm equivalent Commands: build Build or rebuild services diff --git a/compose/config/config.py b/compose/config/config.py index 3df211f7387..e2ed29a479c 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -51,6 +51,7 @@ from .validation import validate_against_config_schema from .validation import validate_config_section from .validation import validate_cpu +from .validation import validate_credential_spec from .validation import validate_depends_on from .validation import validate_extends_file_path from .validation import validate_healthcheck @@ -369,7 +370,6 @@ def check_swarm_only_key(service_dicts, key): ) if not compatibility: check_swarm_only_key(service_dicts, 'deploy') - check_swarm_only_key(service_dicts, 'credential_spec') check_swarm_only_key(service_dicts, 'configs') @@ -706,6 +706,7 @@ def validate_service(service_config, service_names, config_file): validate_depends_on(service_config, service_names) validate_links(service_config, service_names) validate_healthcheck(service_config) + validate_credential_spec(service_config) if not service_dict.get('image') and has_uppercase(service_name): raise ConfigurationError( @@ -894,6 +895,7 @@ def finalize_service(service_config, service_names, version, environment, compat normalize_build(service_dict, service_config.working_dir, environment) if compatibility: + service_dict = translate_credential_spec_to_security_opt(service_dict) service_dict, ignored_keys = translate_deploy_keys_to_container_config( service_dict ) @@ -930,6 +932,25 @@ def convert_restart_policy(name): raise ConfigurationError('Invalid restart policy "{}"'.format(name)) +def convert_credential_spec_to_security_opt(credential_spec): + if 'file' in credential_spec: + return 'file://{file}'.format(file=credential_spec['file']) + return 'registry://{registry}'.format(registry=credential_spec['registry']) + + +def translate_credential_spec_to_security_opt(service_dict): + result = [] + + if 'credential_spec' in service_dict: + spec = convert_credential_spec_to_security_opt(service_dict['credential_spec']) + result.append('credentialspec={spec}'.format(spec=spec)) + + if result: + service_dict['security_opt'] = result + + return service_dict + + def translate_deploy_keys_to_container_config(service_dict): if 'credential_spec' in service_dict: del service_dict['credential_spec'] diff --git a/compose/config/validation.py b/compose/config/validation.py index 0395695514d..1cceb71f0a4 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -240,6 +240,18 @@ def validate_depends_on(service_config, service_names): ) +def validate_credential_spec(service_config): + credential_spec = service_config.config.get('credential_spec') + if not credential_spec: + return + + if 'registry' not in credential_spec and 'file' not in credential_spec: + raise ConfigurationError( + "Service '{s.name}' is missing 'credential_spec.file' or " + "credential_spec.registry'".format(s=service_config) + ) + + 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: diff --git a/contrib/completion/zsh/_docker-compose b/contrib/completion/zsh/_docker-compose index e55c9196475..5fd418a698a 100755 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -339,7 +339,7 @@ _docker-compose() { '(- :)'{-h,--help}'[Get help]' \ '*'{-f,--file}"[${file_description}]:file:_files -g '*.yml'" \ '(-p --project-name)'{-p,--project-name}'[Specify an alternate project name (default: directory name)]:project name:' \ - "--compatibility[If set, Compose will attempt to convert deploy keys in v3 files to their non-Swarm equivalent]" \ + "--compatibility[If set, Compose will attempt to convert keys in v3 files to their non-Swarm equivalent]" \ '(- :)'{-v,--version}'[Print version and exit]' \ '--verbose[Show more output]' \ '--log-level=[Set log level]:level:(DEBUG INFO WARNING ERROR CRITICAL)' \ diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 8baf8e4ee58..c2e6b7b0761 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -3593,6 +3593,9 @@ def test_compatibility_mode_load(self): 'reservations': {'memory': '100M'}, }, }, + 'credential_spec': { + 'file': 'spec.json' + }, }, }, }) @@ -3610,7 +3613,8 @@ def test_compatibility_mode_load(self): 'mem_limit': '300M', 'mem_reservation': '100M', 'cpus': 0.7, - 'name': 'foo' + 'name': 'foo', + 'security_opt': ['credentialspec=file://spec.json'], } @mock.patch.dict(os.environ) From 1f9b20d97bfb5af7cf64544c0e5d1613cc57ebdc Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Mon, 21 Jan 2019 19:13:45 +0100 Subject: [PATCH 3669/4072] Add `--parallel` to `docker build`'s options in `bash` and `zsh` completion Signed-off-by: Ulysses Souza --- 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 5938ff9e2cf..2add0c9cd75 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -114,7 +114,7 @@ _docker_compose_build() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "--build-arg --compress --force-rm --help --memory --no-cache --pull" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--build-arg --compress --force-rm --help --memory --no-cache --pull --parallel" -- "$cur" ) ) ;; *) __docker_compose_complete_services --filter source=build diff --git a/contrib/completion/zsh/_docker-compose b/contrib/completion/zsh/_docker-compose index 5fd418a698a..d25256c14c3 100755 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -117,6 +117,7 @@ __docker-compose_subcommand() { '--no-cache[Do not use cache when building the image.]' \ '--pull[Always attempt to pull a newer version of the image.]' \ '--compress[Compress the build context using gzip.]' \ + '--parallel[Build images in parallel.]' \ '*:services:__docker-compose_services_from_build' && ret=0 ;; (bundle) From 4585db124a9a51e3f58f2714c40e097c4eb568a9 Mon Sep 17 00:00:00 2001 From: Christopher Crone Date: Tue, 5 Feb 2019 10:40:03 +0100 Subject: [PATCH 3670/4072] macOS: Bump Python and OpenSSL Signed-off-by: Christopher Crone --- script/setup/osx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/script/setup/osx b/script/setup/osx index 08491b6e5cb..1b546816df3 100755 --- a/script/setup/osx +++ b/script/setup/osx @@ -13,13 +13,13 @@ if ! [ ${DEPLOYMENT_TARGET} == "$(macos_version)" ]; then SDK_SHA1=dd228a335194e3392f1904ce49aff1b1da26ca62 fi -OPENSSL_VERSION=1.1.0h +OPENSSL_VERSION=1.1.0j OPENSSL_URL=https://www.openssl.org/source/openssl-${OPENSSL_VERSION}.tar.gz -OPENSSL_SHA1=0fc39f6aa91b6e7f4d05018f7c5e991e1d2491fd +OPENSSL_SHA1=dcad1efbacd9a4ed67d4514470af12bbe2a1d60a -PYTHON_VERSION=3.6.6 +PYTHON_VERSION=3.6.8 PYTHON_URL=https://www.python.org/ftp/python/${PYTHON_VERSION}/Python-${PYTHON_VERSION}.tgz -PYTHON_SHA1=ae1fc9ddd29ad8c1d5f7b0d799ff0787efeb9652 +PYTHON_SHA1=09fcc4edaef0915b4dedbfb462f1cd15f82d3a6f # # Install prerequisites. From 28310b3ba4fe77d53b684dc6930f1e18f01e1564 Mon Sep 17 00:00:00 2001 From: Christopher Crone Date: Tue, 5 Feb 2019 10:50:25 +0100 Subject: [PATCH 3671/4072] requirements-dev: Fix version of mock to 2.0.0 Signed-off-by: Christopher Crone --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 4d74f6d157a..bfb941152f8 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,5 +1,5 @@ coverage==4.4.2 flake8==3.5.0 -mock>=1.0.1 +mock==2.0.0 pytest==3.6.3 pytest-cov==2.5.1 From 3fc5c6f563dea6526947a5b6d23126b612eaeb19 Mon Sep 17 00:00:00 2001 From: Christopher Crone Date: Tue, 5 Feb 2019 10:50:55 +0100 Subject: [PATCH 3672/4072] script.build.linux: Do not tail image build logs Signed-off-by: Christopher Crone --- script/build/linux | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/build/linux b/script/build/linux index 1a4cd4d9bf5..056940ad021 100755 --- a/script/build/linux +++ b/script/build/linux @@ -5,7 +5,7 @@ set -ex ./script/clean TAG="docker-compose" -docker build -t "$TAG" . | tail -n 200 +docker build -t "$TAG" . docker run \ --rm --entrypoint="script/build/linux-entrypoint" \ -v $(pwd)/dist:/code/dist \ From e0412a24884351e093764b91cc2689c682bc1562 Mon Sep 17 00:00:00 2001 From: Christopher Crone Date: Tue, 5 Feb 2019 10:51:26 +0100 Subject: [PATCH 3673/4072] Dockerfile: Force version of virtualenv to 16.2.0 Signed-off-by: Christopher Crone --- Dockerfile | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Dockerfile b/Dockerfile index a14be492ec5..c5e7c815aae 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,6 +17,8 @@ ENV LANG en_US.UTF-8 RUN useradd -d /home/user -m -s /bin/bash user WORKDIR /code/ +# FIXME(chris-crone): virtualenv 16.3.0 breaks build, force 16.2.0 until fixed +RUN pip install virtualenv==16.2.0 RUN pip install tox==2.1.1 ADD requirements.txt /code/ @@ -25,6 +27,7 @@ ADD .pre-commit-config.yaml /code/ ADD setup.py /code/ ADD tox.ini /code/ ADD compose /code/compose/ +ADD README.md /code/ RUN tox --notest ADD . /code/ From 0dec6b5ff1fb078817aba1533896f61b5dcfae4d Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Wed, 30 Jan 2019 14:28:00 +0100 Subject: [PATCH 3674/4072] Fix Flake8 lint This removes extra indentation and replace the use of `is` by `==` when comparing strings Signed-off-by: Ulysses Souza --- compose/service.py | 78 +++++++++++++++--------------- tests/unit/cli/log_printer_test.py | 2 +- 2 files changed, 40 insertions(+), 40 deletions(-) diff --git a/compose/service.py b/compose/service.py index 3c5e356a39d..8c6702f1ede 100644 --- a/compose/service.py +++ b/compose/service.py @@ -291,7 +291,7 @@ def scale(self, desired_num, timeout=None): c for c in stopped_containers if self._containers_have_diverged([c]) ] for c in divergent_containers: - c.remove() + c.remove() all_containers = list(set(all_containers) - set(divergent_containers)) @@ -461,50 +461,50 @@ def create_and_start(service, n): def _execute_convergence_recreate(self, containers, scale, timeout, detached, start, renew_anonymous_volumes): - if scale is not None and len(containers) > scale: - self._downscale(containers[scale:], timeout) - containers = containers[:scale] - - def recreate(container): - return self.recreate_container( - container, timeout=timeout, attach_logs=not detached, - start_new_container=start, renew_anonymous_volumes=renew_anonymous_volumes - ) - containers, errors = parallel_execute( - containers, - recreate, - lambda c: c.name, - "Recreating", + if scale is not None and len(containers) > scale: + self._downscale(containers[scale:], timeout) + containers = containers[:scale] + + def recreate(container): + return self.recreate_container( + container, timeout=timeout, attach_logs=not detached, + start_new_container=start, renew_anonymous_volumes=renew_anonymous_volumes ) - for error in errors.values(): - raise OperationFailedError(error) + containers, errors = parallel_execute( + containers, + recreate, + lambda c: c.name, + "Recreating", + ) + for error in errors.values(): + raise OperationFailedError(error) - if scale is not None and len(containers) < scale: - containers.extend(self._execute_convergence_create( - scale - len(containers), detached, start - )) - return containers + if scale is not None and len(containers) < scale: + containers.extend(self._execute_convergence_create( + scale - len(containers), detached, start + )) + return containers def _execute_convergence_start(self, containers, scale, timeout, detached, start): - if scale is not None and len(containers) > scale: - self._downscale(containers[scale:], timeout) - containers = containers[:scale] - if start: - _, errors = parallel_execute( - containers, - lambda c: self.start_container_if_stopped(c, attach_logs=not detached, quiet=True), - lambda c: c.name, - "Starting", - ) + if scale is not None and len(containers) > scale: + self._downscale(containers[scale:], timeout) + containers = containers[:scale] + if start: + _, errors = parallel_execute( + containers, + lambda c: self.start_container_if_stopped(c, attach_logs=not detached, quiet=True), + lambda c: c.name, + "Starting", + ) - for error in errors.values(): - raise OperationFailedError(error) + for error in errors.values(): + raise OperationFailedError(error) - if scale is not None and len(containers) < scale: - containers.extend(self._execute_convergence_create( - scale - len(containers), detached, start - )) - return containers + if scale is not None and len(containers) < scale: + containers.extend(self._execute_convergence_create( + scale - len(containers), detached, start + )) + return containers def _downscale(self, containers, timeout=None): def stop_and_remove(container): diff --git a/tests/unit/cli/log_printer_test.py b/tests/unit/cli/log_printer_test.py index d0c4b56be22..6db24e46486 100644 --- a/tests/unit/cli/log_printer_test.py +++ b/tests/unit/cli/log_printer_test.py @@ -193,7 +193,7 @@ def test_item_is_stop_with_cascade_stop(self): queue.put(item) generator = consume_queue(queue, True) - assert next(generator) is 'foobar-1' + assert next(generator) == 'foobar-1' def test_item_is_none_when_timeout_is_hit(self): queue = Queue() From 0fdb9783cd3358a480ed508b5da6744a2d0dedb4 Mon Sep 17 00:00:00 2001 From: Christopher Crone Date: Tue, 5 Feb 2019 12:13:19 +0100 Subject: [PATCH 3675/4072] circleci: Fix virtualenv version to 16.2.0 Signed-off-by: Christopher Crone --- .circleci/config.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index f4e90d6de71..08f8c42c39f 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -10,7 +10,7 @@ jobs: command: ./script/setup/osx - run: name: install tox - command: sudo pip install --upgrade tox==2.1.1 + command: sudo pip install --upgrade tox==2.1.1 virtualenv==16.2.0 - run: name: unit tests command: tox -e py27,py36,py37 -- tests/unit @@ -22,7 +22,7 @@ jobs: - checkout - run: name: upgrade python tools - command: sudo pip install --upgrade pip virtualenv + command: sudo pip install --upgrade pip virtualenv==16.2.0 - run: name: setup script command: DEPLOYMENT_TARGET=10.11 ./script/setup/osx From 3fae0119ca2d1a05c490fdbf286370e92ce4a349 Mon Sep 17 00:00:00 2001 From: Michael Irwin Date: Tue, 5 Mar 2019 10:17:09 -0500 Subject: [PATCH 3676/4072] Fix merging of compose files when network has None config Signed-off-by: Michael Irwin Resolves #6525 --- compose/config/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/config/config.py b/compose/config/config.py index e2ed29a479c..f3142d80a9f 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -1193,7 +1193,7 @@ def merge_networks(base, override): base = {k: {} for k in base} if isinstance(base, list) else base override = {k: {} for k in override} if isinstance(override, list) else override for network_name in all_network_names: - md = MergeDict(base.get(network_name, {}), override.get(network_name, {})) + md = MergeDict(base.get(network_name) or {}, override.get(network_name) or {}) md.merge_field('aliases', merge_unique_items_lists, []) md.merge_field('link_local_ips', merge_unique_items_lists, []) md.merge_scalar('priority') From 360753ecc17e7af637b031c7d1785b343e326ea6 Mon Sep 17 00:00:00 2001 From: Michael Irwin Date: Tue, 5 Mar 2019 22:18:22 -0500 Subject: [PATCH 3677/4072] Added test case to verify fix for #6525 Signed-off-by: Michael Irwin --- tests/unit/config/config_test.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index c2e6b7b0761..50d8e13a8be 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -3932,6 +3932,24 @@ def test_no_network_overrides(self): } } + def test_network_has_none_value(self): + service_dict = config.merge_service_dicts( + {self.config_name: { + 'default': None + }}, + {self.config_name: { + 'default': { + 'aliases': [] + } + }}, + DEFAULT_VERSION) + + assert service_dict[self.config_name] == { + 'default': { + 'aliases': [] + } + } + def test_all_properties(self): service_dict = config.merge_service_dicts( {self.config_name: { From 81b30c438014a7213734641777b0ec6c2ca60e2b Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Tue, 12 Mar 2019 10:35:09 -0400 Subject: [PATCH 3678/4072] Enable bootloader_ignore_signals in pyinstaller Fixes #3347 Signed-off-by: Ben Firshman --- docker-compose.spec | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docker-compose.spec b/docker-compose.spec index 70db5c29e16..5ca1e4c24de 100644 --- a/docker-compose.spec +++ b/docker-compose.spec @@ -98,4 +98,5 @@ exe = EXE(pyz, debug=False, strip=None, upx=True, - console=True) + console=True, + bootloader_ignore_signals=True) From 295dd9abda9c0e96d989dba7d69b13a62b7f3511 Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Wed, 20 Mar 2019 18:03:41 +0100 Subject: [PATCH 3679/4072] Bump docker-py version to 3.7.1 This docker-py version includes ssh fixes Signed-off-by: Ulysses Souza --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 9333ec60d79..2487f92c048 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ cached-property==1.3.0 certifi==2017.4.17 chardet==3.0.4 colorama==0.4.0; sys_platform == 'win32' -docker==3.7.0 +docker==3.7.1 docker-pycreds==0.4.0 dockerpty==0.4.1 docopt==0.6.2 From 0e05ac6d2c7a1ee9fe77760f288cd85ab117074c Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Fri, 22 Mar 2019 10:32:59 +0100 Subject: [PATCH 3680/4072] Use os.system() instead of run_setup() Use `os.system()` instead of `run_setup()` because the last is not taking any effect Signed-off-by: Ulysses Souza --- script/release/release.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/script/release/release.py b/script/release/release.py index 63bf863dfc9..9db1a49d9f0 100755 --- a/script/release/release.py +++ b/script/release/release.py @@ -7,7 +7,6 @@ import shutil import sys import time -from distutils.core import run_setup from jinja2 import Template from release.bintray import BintrayAPI @@ -276,7 +275,8 @@ def finalize(args): repository.checkout_branch(br_name) - run_setup(os.path.join(REPO_ROOT, 'setup.py'), script_args=['sdist', 'bdist_wheel']) + os.system('python {setup_script} sdist bdist_wheel'.format( + setup_script=os.path.join(REPO_ROOT, 'setup.py'))) merge_status = pr_data.merge() if not merge_status.merged and not args.finalize_resume: From 662761dbba74b52c8788854f00bfef6ca130bce9 Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Fri, 22 Mar 2019 10:58:32 +0100 Subject: [PATCH 3681/4072] Fix typo on finalize Signed-off-by: Ulysses Souza --- script/release/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/release/README.md b/script/release/README.md index f7f911e5378..a0ce5d2a8c0 100644 --- a/script/release/README.md +++ b/script/release/README.md @@ -129,7 +129,7 @@ assets public), proceed to the "Finalize a release" section of this guide. Once you're ready to make your release public, you may execute the following command from the root of the Compose repository: ``` -./script/release/release.sh -b finalize RELEAE_VERSION +./script/release/release.sh -b finalize RELEASE_VERSION ``` Note that this command will create and publish versioned assets to the public. From c54341758a29032712e51c45c11076d330a1de39 Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Fri, 22 Mar 2019 11:00:23 +0100 Subject: [PATCH 3682/4072] Fix bintray docker-compose link Signed-off-by: Ulysses Souza --- script/release/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/release/README.md b/script/release/README.md index a0ce5d2a8c0..0c6f12cbeea 100644 --- a/script/release/README.md +++ b/script/release/README.md @@ -40,7 +40,7 @@ This API token should be exposed to the release script through the ### A Bintray account and Bintray API key Your Bintray account will need to be an admin member of the -[docker-compose organization](https://github.com/settings/tokens). +[docker-compose organization](https://bintray.com/docker-compose). Additionally, you should generate a personal API key. To do so, click your username in the top-right hand corner and select "Edit profile" ; on the new page, select "API key" in the left-side menu. From 428942498b90567b76541851ed30cec382af75cf Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Fri, 22 Mar 2019 15:15:52 +0100 Subject: [PATCH 3683/4072] "Bump 1.24.0-rc3" Signed-off-by: Ulysses Souza --- CHANGELOG.md | 18 +++++++++++++++++- compose/__init__.py | 2 +- script/run/run.sh | 2 +- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c5eb1bb4d3e..440104fa62e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ Change log ========== -1.24.0 (2019-01-25) +1.24.0 (2019-03-22) ------------------- ### Features @@ -11,6 +11,12 @@ Change log - Added a `--all` flag to `docker-compose ps` to include stopped one-off containers in the command's output. +- Add bash completion for `ps --all|-a` + +- Support for credential_spec + +- Add `--parallel` to `docker build`'s options in `bash` and `zsh` completion + ### Bugfixes - Fixed a bug where some valid credential helpers weren't properly handled by Compose @@ -37,6 +43,16 @@ Change log - Missing images will no longer stop the execution of `docker-compose down` commands (a warning will be displayed instead). +- Force `virtualenv` version for macOS CI + +- Fix merging of compose files when network has `None` config + +- Fix `CTRL+C` issues by enabling `bootloader_ignore_signals` in `pyinstaller` + +- Bump `docker-py` version to `3.7.1` to fix SSH issues + +- Fix release script and some typos on release documentation + 1.23.2 (2018-11-28) ------------------- diff --git a/compose/__init__.py b/compose/__init__.py index bc5e6b116f0..5fd7d326b7a 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.24.0-rc1' +__version__ = '1.24.0-rc3' diff --git a/script/run/run.sh b/script/run/run.sh index df3f2298f29..edc2e0c0e96 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.24.0-rc1" +VERSION="1.24.0-rc3" IMAGE="docker/compose:$VERSION" From 154d7c17228cf9196ea4ee6b5e13a5268cc69407 Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Fri, 22 Mar 2019 17:10:41 +0100 Subject: [PATCH 3684/4072] Fix script for release file already present case This avoids a: "AttributeError: 'HTTPError' object has no attribute 'message'" Signed-off-by: Ulysses Souza --- script/release/release/pypi.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/release/release/pypi.py b/script/release/release/pypi.py index a40e17544cb..dc0b0cb97b3 100644 --- a/script/release/release/pypi.py +++ b/script/release/release/pypi.py @@ -18,7 +18,7 @@ def pypi_upload(args): 'dist/docker-compose-{}*.tar.gz'.format(rel) ]) except HTTPError as e: - if e.response.status_code == 400 and 'File already exists' in e.message: + if e.response.status_code == 400 and 'File already exists' in str(e): if not args.finalize_resume: raise ScriptError( 'Package already uploaded on PyPi.' From c6dd7da15eb3d85a1f7634e8ded9fe42c9035669 Mon Sep 17 00:00:00 2001 From: Collins Abitekaniza Date: Sun, 3 Feb 2019 04:29:20 +0300 Subject: [PATCH 3685/4072] only pull images that can't build Signed-off-by: Collins Abitekaniza --- compose/project.py | 7 +++++-- tests/acceptance/cli_test.py | 7 +++++++ tests/fixtures/simple-composefile/pull-with-build.yml | 11 +++++++++++ 3 files changed, 23 insertions(+), 2 deletions(-) create mode 100644 tests/fixtures/simple-composefile/pull-with-build.yml diff --git a/compose/project.py b/compose/project.py index a7f2aa05720..c6a80250999 100644 --- a/compose/project.py +++ b/compose/project.py @@ -602,6 +602,9 @@ def _get_convergence_plans(self, services, strategy, always_recreate_deps=False) def pull(self, service_names=None, ignore_pull_failures=False, parallel_pull=False, silent=False, include_deps=False): services = self.get_services(service_names, include_deps) + images_to_build = {service.image_name for service in services if service.can_be_built()} + services_to_pull = [service for service in services if service.image_name not in images_to_build] + msg = not silent and 'Pulling' or None if parallel_pull: @@ -627,7 +630,7 @@ def pull_service(service): ) _, errors = parallel.parallel_execute( - services, + services_to_pull, pull_service, operator.attrgetter('name'), msg, @@ -640,7 +643,7 @@ def pull_service(service): raise ProjectError(combined_errors) else: - for service in services: + for service in services_to_pull: service.pull(ignore_pull_failures, silent=silent) def push(self, service_names=None, ignore_push_failures=False): diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 5142f96eb34..8a7c0febc87 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -633,6 +633,13 @@ def test_pull_with_ignore_pull_failures(self): 'image library/nonexisting-image:latest not found' in result.stderr or 'pull access denied for nonexisting-image' in result.stderr) + def test_pull_with_build(self): + result = self.dispatch(['-f', 'pull-with-build.yml', 'pull']) + + assert 'Pulling simple' not in result.stderr + assert 'Pulling from_simple' not in result.stderr + assert 'Pulling another ...' in result.stderr + def test_pull_with_quiet(self): assert self.dispatch(['pull', '--quiet']).stderr == '' assert self.dispatch(['pull', '--quiet']).stdout == '' diff --git a/tests/fixtures/simple-composefile/pull-with-build.yml b/tests/fixtures/simple-composefile/pull-with-build.yml new file mode 100644 index 00000000000..261dc44dfd0 --- /dev/null +++ b/tests/fixtures/simple-composefile/pull-with-build.yml @@ -0,0 +1,11 @@ +version: "3" +services: + build_simple: + image: simple + build: . + command: top + from_simple: + image: simple + another: + image: busybox:latest + command: top From 8a339946fa77871e23919af0be322a4aba1f8747 Mon Sep 17 00:00:00 2001 From: joeweoj Date: Tue, 19 Mar 2019 11:16:51 +0000 Subject: [PATCH 3686/4072] Fixed depends_on recreation behaviour for issue #6589 Previously any containers which did *not* have any links were always recreated. In order to fix depends_on and preserve expected links recreation behaviour, we now only use the ConvergenceStrategy.always recreation strategy for a service if any of the the following conditions are true: * --always-recreate-deps flag provided * service container is stopped * service defines links but the container does not have any * container has links but the service definition does not Signed-off-by: joeweoj --- compose/project.py | 6 +- tests/integration/state_test.py | 139 ++++++++++++++++++++++++++++++++ 2 files changed, 143 insertions(+), 2 deletions(-) diff --git a/compose/project.py b/compose/project.py index a74033729fd..e3b14d62754 100644 --- a/compose/project.py +++ b/compose/project.py @@ -586,8 +586,10 @@ def _get_convergence_plans(self, services, strategy, always_recreate_deps=False) ", ".join(updated_dependencies)) containers_stopped = any( service.containers(stopped=True, filters={'status': ['created', 'exited']})) - has_links = any(c.get('HostConfig.Links') for c in service.containers()) - if always_recreate_deps or containers_stopped or not has_links: + service_has_links = any(service.get_link_names()) + container_has_links = any(c.get('HostConfig.Links') for c in service.containers()) + should_recreate_for_links = service_has_links ^ container_has_links + if always_recreate_deps or containers_stopped or should_recreate_for_links: plan = service.convergence_plan(ConvergenceStrategy.always) else: plan = service.convergence_plan(strategy) diff --git a/tests/integration/state_test.py b/tests/integration/state_test.py index b7d38a4ba86..0d69217cf13 100644 --- a/tests/integration/state_test.py +++ b/tests/integration/state_test.py @@ -5,6 +5,8 @@ from __future__ import absolute_import from __future__ import unicode_literals +import copy + import py from docker.errors import ImageNotFound @@ -209,6 +211,143 @@ def test_service_recreated_when_dependency_created(self): } +class ProjectWithDependsOnDependenciesTest(ProjectTestCase): + def setUp(self): + super(ProjectWithDependsOnDependenciesTest, self).setUp() + + self.cfg = { + 'version': '2', + 'services': { + 'db': { + 'image': 'busybox:latest', + 'command': 'tail -f /dev/null', + }, + 'web': { + 'image': 'busybox:latest', + 'command': 'tail -f /dev/null', + 'depends_on': ['db'], + }, + 'nginx': { + 'image': 'busybox:latest', + 'command': 'tail -f /dev/null', + 'depends_on': ['web'], + }, + } + } + + def test_up(self): + local_cfg = copy.deepcopy(self.cfg) + containers = self.run_up(local_cfg) + assert set(c.service for c in containers) == set(['db', 'web', 'nginx']) + + def test_change_leaf(self): + local_cfg = copy.deepcopy(self.cfg) + old_containers = self.run_up(local_cfg) + + local_cfg['services']['nginx']['environment'] = {'NEW_VAR': '1'} + new_containers = self.run_up(local_cfg) + + assert set(c.service for c in new_containers - old_containers) == set(['nginx']) + + def test_change_middle(self): + local_cfg = copy.deepcopy(self.cfg) + old_containers = self.run_up(local_cfg) + + local_cfg['services']['web']['environment'] = {'NEW_VAR': '1'} + new_containers = self.run_up(local_cfg) + + assert set(c.service for c in new_containers - old_containers) == set(['web']) + + def test_change_middle_always_recreate_deps(self): + local_cfg = copy.deepcopy(self.cfg) + old_containers = self.run_up(local_cfg, always_recreate_deps=True) + + local_cfg['services']['web']['environment'] = {'NEW_VAR': '1'} + new_containers = self.run_up(local_cfg, always_recreate_deps=True) + + assert set(c.service for c in new_containers - old_containers) == set(['web', 'nginx']) + + def test_change_root(self): + local_cfg = copy.deepcopy(self.cfg) + old_containers = self.run_up(local_cfg) + + local_cfg['services']['db']['environment'] = {'NEW_VAR': '1'} + new_containers = self.run_up(local_cfg) + + assert set(c.service for c in new_containers - old_containers) == set(['db']) + + def test_change_root_always_recreate_deps(self): + local_cfg = copy.deepcopy(self.cfg) + old_containers = self.run_up(local_cfg, always_recreate_deps=True) + + local_cfg['services']['db']['environment'] = {'NEW_VAR': '1'} + new_containers = self.run_up(local_cfg, always_recreate_deps=True) + + assert set(c.service for c in new_containers - old_containers) == set(['db', 'web', 'nginx']) + + def test_change_root_no_recreate(self): + local_cfg = copy.deepcopy(self.cfg) + old_containers = self.run_up(local_cfg) + + local_cfg['services']['db']['environment'] = {'NEW_VAR': '1'} + new_containers = self.run_up( + local_cfg, + strategy=ConvergenceStrategy.never) + + assert new_containers - old_containers == set() + + def test_service_removed_while_down(self): + local_cfg = copy.deepcopy(self.cfg) + next_cfg = copy.deepcopy(self.cfg) + del next_cfg['services']['db'] + del next_cfg['services']['web']['depends_on'] + + containers = self.run_up(local_cfg) + assert set(c.service for c in containers) == set(['db', 'web', 'nginx']) + + project = self.make_project(local_cfg) + project.stop(timeout=1) + + next_containers = self.run_up(next_cfg) + assert set(c.service for c in next_containers) == set(['web', 'nginx']) + + def test_service_removed_while_up(self): + local_cfg = copy.deepcopy(self.cfg) + containers = self.run_up(local_cfg) + assert set(c.service for c in containers) == set(['db', 'web', 'nginx']) + + del local_cfg['services']['db'] + del local_cfg['services']['web']['depends_on'] + + containers = self.run_up(local_cfg) + assert set(c.service for c in containers) == set(['web', 'nginx']) + + def test_dependency_removed(self): + local_cfg = copy.deepcopy(self.cfg) + next_cfg = copy.deepcopy(self.cfg) + del next_cfg['services']['nginx']['depends_on'] + + containers = self.run_up(local_cfg, service_names=['nginx']) + assert set(c.service for c in containers) == set(['db', 'web', 'nginx']) + + project = self.make_project(local_cfg) + project.stop(timeout=1) + + next_containers = self.run_up(next_cfg, service_names=['nginx']) + assert set(c.service for c in next_containers if c.is_running) == set(['nginx']) + + def test_dependency_added(self): + local_cfg = copy.deepcopy(self.cfg) + + del local_cfg['services']['nginx']['depends_on'] + containers = self.run_up(local_cfg, service_names=['nginx']) + assert set(c.service for c in containers) == set(['nginx']) + + local_cfg['services']['nginx']['depends_on'] = ['db'] + containers = self.run_up(local_cfg, service_names=['nginx']) + assert set(c.service for c in containers) == set(['nginx', 'db']) + + class ServiceStateTest(DockerClientTestCase): """Test cases for Service.convergence_plan.""" From ac148bc1ca4d3e1f367fac36dbdf7485e093280f Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Thu, 28 Mar 2019 17:45:03 +0100 Subject: [PATCH 3687/4072] Bump docker-py 3.7.2 Signed-off-by: Ulysses Souza --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 2487f92c048..5e8ec2ed719 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ cached-property==1.3.0 certifi==2017.4.17 chardet==3.0.4 colorama==0.4.0; sys_platform == 'win32' -docker==3.7.1 +docker==3.7.2 docker-pycreds==0.4.0 dockerpty==0.4.1 docopt==0.6.2 From 917c2701f25f15b0d39a4dd8f93254b75aa058dd Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Fri, 22 Mar 2019 17:10:41 +0100 Subject: [PATCH 3688/4072] Fix script for release file already present case This avoids a: "AttributeError: 'HTTPError' object has no attribute 'message'" Signed-off-by: Ulysses Souza --- script/release/release/pypi.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/release/release/pypi.py b/script/release/release/pypi.py index a40e17544cb..dc0b0cb97b3 100644 --- a/script/release/release/pypi.py +++ b/script/release/release/pypi.py @@ -18,7 +18,7 @@ def pypi_upload(args): 'dist/docker-compose-{}*.tar.gz'.format(rel) ]) except HTTPError as e: - if e.response.status_code == 400 and 'File already exists' in e.message: + if e.response.status_code == 400 and 'File already exists' in str(e): if not args.finalize_resume: raise ScriptError( 'Package already uploaded on PyPi.' From eb2fdf81b468cd5f88360df23850341510b342e4 Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Thu, 28 Mar 2019 17:45:03 +0100 Subject: [PATCH 3689/4072] Bump docker-py 3.7.2 Signed-off-by: Ulysses Souza --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 2487f92c048..5e8ec2ed719 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ cached-property==1.3.0 certifi==2017.4.17 chardet==3.0.4 colorama==0.4.0; sys_platform == 'win32' -docker==3.7.1 +docker==3.7.2 docker-pycreds==0.4.0 dockerpty==0.4.1 docopt==0.6.2 From 0aa590649c2cabdbf9527ae90438395eaf1e6b0b Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Thu, 28 Mar 2019 18:34:02 +0100 Subject: [PATCH 3690/4072] "Bump 1.24.0" Signed-off-by: Ulysses Souza --- CHANGELOG.md | 2 +- compose/__init__.py | 2 +- script/run/run.sh | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 440104fa62e..80f98be923b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,7 +49,7 @@ Change log - Fix `CTRL+C` issues by enabling `bootloader_ignore_signals` in `pyinstaller` -- Bump `docker-py` version to `3.7.1` to fix SSH issues +- Bump `docker-py` version to `3.7.2` to fix SSH and proxy config issues - Fix release script and some typos on release documentation diff --git a/compose/__init__.py b/compose/__init__.py index 5fd7d326b7a..b66d2eb5877 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.24.0-rc3' +__version__ = '1.24.0' diff --git a/script/run/run.sh b/script/run/run.sh index edc2e0c0e96..a8690cad1fe 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.24.0-rc3" +VERSION="1.24.0" IMAGE="docker/compose:$VERSION" From ef10c1803fa7857a955585a62f77449e608d07dc Mon Sep 17 00:00:00 2001 From: Djordje Lukic Date: Fri, 11 Jan 2019 17:43:47 +0100 Subject: [PATCH 3691/4072] "Bump 1.24.0" Signed-off-by: Ulysses Souza --- CHANGELOG.md | 52 +++++++++++++++++++++++++++++++++++++++++++++ compose/__init__.py | 2 +- script/run/run.sh | 2 +- 3 files changed, 54 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a7a2ffe97c..7a3ad8bfd5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,58 @@ Change log ========== +1.24.0 (2019-03-28) +------------------- + +### Features + +- Added support for connecting to the Docker Engine using the `ssh` protocol. + +- Added a `--all` flag to `docker-compose ps` to include stopped one-off containers + in the command's output. + +- Add bash completion for `ps --all|-a` + +- Support for credential_spec + +- Add `--parallel` to `docker build`'s options in `bash` and `zsh` completion + +### Bugfixes + +- Fixed a bug where some valid credential helpers weren't properly handled by Compose + when attempting to pull images from private registries. + +- Fixed an issue where the output of `docker-compose start` before containers were created + was misleading + +- To match the Docker CLI behavior and to avoid confusing issues, Compose will no longer + accept whitespace in variable names sourced from environment files. + +- Compose will now report a configuration error if a service attempts to declare + duplicate mount points in the volumes section. + +- Fixed an issue with the containerized version of Compose that prevented users from + writing to stdin during interactive sessions started by `run` or `exec`. + +- One-off containers started by `run` no longer adopt the restart policy of the service, + and are instead set to never restart. + +- Fixed an issue that caused some container events to not appear in the output of + the `docker-compose events` command. + +- Missing images will no longer stop the execution of `docker-compose down` commands + (a warning will be displayed instead). + +- Force `virtualenv` version for macOS CI + +- Fix merging of compose files when network has `None` config + +- Fix `CTRL+C` issues by enabling `bootloader_ignore_signals` in `pyinstaller` + +- Bump `docker-py` version to `3.7.2` to fix SSH and proxy config issues + +- Fix release script and some typos on release documentation + 1.23.2 (2018-11-28) ------------------- diff --git a/compose/__init__.py b/compose/__init__.py index 652e1fad976..0042896b658 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.24.0dev' +__version__ = '1.25.0dev' diff --git a/script/run/run.sh b/script/run/run.sh index cc36e475185..a8690cad1fe 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.23.2" +VERSION="1.24.0" IMAGE="docker/compose:$VERSION" From 9e3d9f66811f0d21dfca65874a2937e8cf210089 Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Thu, 4 Apr 2019 10:08:04 +0200 Subject: [PATCH 3692/4072] Update release process on updating docs Signed-off-by: Ulysses Souza --- script/release/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/script/release/README.md b/script/release/README.md index 0c6f12cbeea..97168d3769a 100644 --- a/script/release/README.md +++ b/script/release/README.md @@ -192,6 +192,8 @@ be handled manually by the operator: - Bump the version in `compose/__init__.py` to the *next* minor version number with `dev` appended. For example, if you just released `1.4.0`, update it to `1.5.0dev` + - Update compose_version in [github.com/docker/docker.github.io/blob/master/_config.yml](https://github.com/docker/docker.github.io/blob/master/_config.yml) and [github.com/docker/docker.github.io/blob/master/_config_authoring.yml](https://github.com/docker/docker.github.io/blob/master/_config_authoring.yml) + - Update the release note in [github.com/docker/docker.github.io](https://github.com/docker/docker.github.io/blob/master/release-notes/docker-compose.md) ## Advanced options From c217bab7f6123de80dbd55c99c2254666d766fb3 Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Wed, 10 Apr 2019 21:05:02 +0200 Subject: [PATCH 3693/4072] Refactor Dockerfiles for generating musl binaries - Refactor Dockerfile to be used for tests and distribution on docker hub on debian and alpine to use for final usage and also tests - Adapt test scripts to the new Dockerfiles' structure - Adapt Jenkinsfile to add alpine to the test matrix Signed-off-by: Ulysses Souza --- Dockerfile | 85 ++++++++++++++++++++++++----------- Dockerfile.run | 19 -------- Jenkinsfile | 46 ++++++++++--------- docker-compose-entrypoint.sh | 20 +++++++++ pyinstaller/ldd | 13 ++++++ script/build/linux | 19 +++++--- script/build/linux-entrypoint | 37 +++++++++++---- script/build/test-image | 15 ++++--- script/test/ci | 3 -- script/test/default | 9 ++-- 10 files changed, 173 insertions(+), 93 deletions(-) delete mode 100644 Dockerfile.run create mode 100755 docker-compose-entrypoint.sh create mode 100755 pyinstaller/ldd diff --git a/Dockerfile b/Dockerfile index b887bcc4e15..1a3c501aefa 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,36 +1,71 @@ -FROM docker:18.06.1 as docker -FROM python:3.7.2-stretch +ARG DOCKER_VERSION=18.09.5 +ARG PYTHON_VERSION=3.7.3 +ARG BUILD_ALPINE_VERSION=3.9 +ARG BUILD_DEBIAN_VERSION=slim-stretch +ARG RUNTIME_ALPINE_VERSION=3.9.3 +ARG RUNTIME_DEBIAN_VERSION=stretch-20190326-slim -RUN set -ex; \ - apt-get update -qq; \ - apt-get install -y \ - locales \ - python-dev \ - git +ARG BUILD_PLATFORM=alpine -COPY --from=docker /usr/local/bin/docker /usr/local/bin/docker +FROM docker:${DOCKER_VERSION} AS docker-cli -# Python3 requires a valid locale -RUN echo "en_US.UTF-8 UTF-8" > /etc/locale.gen && locale-gen -ENV LANG en_US.UTF-8 +FROM python:${PYTHON_VERSION}-alpine${BUILD_ALPINE_VERSION} AS build-alpine +RUN apk add --no-cache \ + bash \ + build-base \ + ca-certificates \ + curl \ + gcc \ + git \ + libc-dev \ + libffi-dev \ + libgcc \ + make \ + musl-dev \ + openssl \ + openssl-dev \ + python2 \ + python2-dev \ + zlib-dev +ENV BUILD_BOOTLOADER=1 -RUN useradd -d /home/user -m -s /bin/bash user -WORKDIR /code/ +FROM python:${PYTHON_VERSION}-${BUILD_DEBIAN_VERSION} AS build-debian +RUN apt-get update && apt-get install -y \ + curl \ + gcc \ + git \ + libc-dev \ + libgcc-6-dev \ + make \ + openssl \ + python2.7-dev +FROM build-${BUILD_PLATFORM} AS build +COPY docker-compose-entrypoint.sh /usr/local/bin/ +ENTRYPOINT ["sh", "/usr/local/bin/docker-compose-entrypoint.sh"] +COPY --from=docker-cli /usr/local/bin/docker /usr/local/bin/docker +WORKDIR /code/ # FIXME(chris-crone): virtualenv 16.3.0 breaks build, force 16.2.0 until fixed RUN pip install virtualenv==16.2.0 RUN pip install tox==2.9.1 -ADD requirements.txt /code/ -ADD requirements-dev.txt /code/ -ADD .pre-commit-config.yaml /code/ -ADD setup.py /code/ -ADD tox.ini /code/ -ADD compose /code/compose/ -ADD README.md /code/ +COPY requirements.txt . +COPY requirements-dev.txt . +COPY .pre-commit-config.yaml . +COPY tox.ini . +COPY setup.py . +COPY README.md . +COPY compose compose/ RUN tox --notest +COPY . . +ARG GIT_COMMIT=unknown +ENV DOCKER_COMPOSE_GITSHA=$GIT_COMMIT +RUN script/build/linux-entrypoint -ADD . /code/ -RUN chown -R user /code/ - -ENTRYPOINT ["/code/.tox/py37/bin/docker-compose"] +FROM alpine:${RUNTIME_ALPINE_VERSION} AS runtime-alpine +FROM debian:${RUNTIME_DEBIAN_VERSION} AS runtime-debian +FROM runtime-${BUILD_PLATFORM} AS runtime +COPY docker-compose-entrypoint.sh /usr/local/bin/ +ENTRYPOINT ["sh", "/usr/local/bin/docker-compose-entrypoint.sh"] +COPY --from=docker-cli /usr/local/bin/docker /usr/local/bin/docker +COPY --from=build /usr/local/bin/docker-compose /usr/local/bin/docker-compose diff --git a/Dockerfile.run b/Dockerfile.run deleted file mode 100644 index ccc86ea967f..00000000000 --- a/Dockerfile.run +++ /dev/null @@ -1,19 +0,0 @@ -FROM docker:18.06.1 as docker -FROM alpine:3.8 - -ENV GLIBC 2.28-r0 - -RUN apk update && apk add --no-cache openssl ca-certificates curl libgcc && \ - curl -fsSL -o /etc/apk/keys/sgerrand.rsa.pub https://alpine-pkgs.sgerrand.com/sgerrand.rsa.pub && \ - curl -fsSL -o glibc-$GLIBC.apk https://github.com/sgerrand/alpine-pkg-glibc/releases/download/$GLIBC/glibc-$GLIBC.apk && \ - apk add --no-cache glibc-$GLIBC.apk && \ - ln -s /lib/libz.so.1 /usr/glibc-compat/lib/ && \ - ln -s /lib/libc.musl-x86_64.so.1 /usr/glibc-compat/lib && \ - ln -s /usr/lib/libgcc_s.so.1 /usr/glibc-compat/lib && \ - rm /etc/apk/keys/sgerrand.rsa.pub glibc-$GLIBC.apk && \ - apk del curl - -COPY --from=docker /usr/local/bin/docker /usr/local/bin/docker -COPY dist/docker-compose-Linux-x86_64 /usr/local/bin/docker-compose - -ENTRYPOINT ["docker-compose"] diff --git a/Jenkinsfile b/Jenkinsfile index a19e82273e4..51fecf99f39 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -1,29 +1,32 @@ #!groovy -def image - -def buildImage = { -> +def buildImage = { String baseImage -> + def image wrappedNode(label: "ubuntu && !zfs", cleanWorkspace: true) { - stage("build image") { + stage("build image for \"${baseImage}\"") { checkout(scm) - def imageName = "dockerbuildbot/compose:${gitCommit()}" + def imageName = "dockerbuildbot/compose:${baseImage}-${gitCommit()}" image = docker.image(imageName) try { image.pull() } catch (Exception exc) { - image = docker.build(imageName, ".") - image.push() + sh "docker build -t ${imageName} --target build --build-arg BUILD_PLATFORM=${baseImage} ." + sh "docker push ${imageName}" + echo "${imageName}" + return imageName } } } + echo "image.id: ${image.id}" + return image.id } -def get_versions = { int number -> +def get_versions = { String imageId, int number -> def docker_versions wrappedNode(label: "ubuntu && !zfs") { def result = sh(script: """docker run --rm \\ --entrypoint=/code/.tox/py27/bin/python \\ - ${image.id} \\ + ${imageId} \\ /code/script/test/versions.py -n ${number} docker/docker-ce recent """, returnStdout: true ) @@ -35,6 +38,8 @@ def get_versions = { int number -> def runTests = { Map settings -> def dockerVersions = settings.get("dockerVersions", null) def pythonVersions = settings.get("pythonVersions", null) + def baseImage = settings.get("baseImage", null) + def imageName = settings.get("image", null) if (!pythonVersions) { throw new Exception("Need Python versions to test. e.g.: `runTests(pythonVersions: 'py27,py37')`") @@ -45,7 +50,7 @@ def runTests = { Map settings -> { -> wrappedNode(label: "ubuntu && !zfs", cleanWorkspace: true) { - stage("test python=${pythonVersions} / docker=${dockerVersions}") { + stage("test python=${pythonVersions} / docker=${dockerVersions} / baseImage=${baseImage}") { 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}" @@ -55,13 +60,13 @@ def runTests = { Map settings -> --privileged \\ --volume="\$(pwd)/.git:/code/.git" \\ --volume="/var/run/docker.sock:/var/run/docker.sock" \\ - -e "TAG=${image.id}" \\ + -e "TAG=${imageName}" \\ -e "STORAGE_DRIVER=${storageDriver}" \\ -e "DOCKER_VERSIONS=${dockerVersions}" \\ -e "BUILD_NUMBER=\$BUILD_TAG" \\ -e "PY_TEST_VERSIONS=${pythonVersions}" \\ --entrypoint="script/test/ci" \\ - ${image.id} \\ + ${imageName} \\ --verbose """ } @@ -69,15 +74,16 @@ def runTests = { Map settings -> } } -buildImage() - def testMatrix = [failFast: true] -def docker_versions = get_versions(2) - -for (int i = 0; i < docker_versions.length; i++) { - def dockerVersion = docker_versions[i] - testMatrix["${dockerVersion}_py27"] = runTests([dockerVersions: dockerVersion, pythonVersions: "py27"]) - testMatrix["${dockerVersion}_py37"] = runTests([dockerVersions: dockerVersion, pythonVersions: "py37"]) +def baseImages = ['alpine', 'debian'] +def pythonVersions = ['py27', 'py37'] +baseImages.each { baseImage -> + def imageName = buildImage(baseImage) + get_versions(imageName, 2).each { dockerVersion -> + pythonVersions.each { pyVersion -> + testMatrix["${baseImage}_${dockerVersion}_${pyVersion}"] = runTests([baseImage: baseImage, image: imageName, dockerVersions: dockerVersion, pythonVersions: pyVersion]) + } + } } parallel(testMatrix) diff --git a/docker-compose-entrypoint.sh b/docker-compose-entrypoint.sh new file mode 100755 index 00000000000..84436fa0778 --- /dev/null +++ b/docker-compose-entrypoint.sh @@ -0,0 +1,20 @@ +#!/bin/sh +set -e + +# first arg is `-f` or `--some-option` +if [ "${1#-}" != "$1" ]; then + set -- docker-compose "$@" +fi + +# if our command is a valid Docker subcommand, let's invoke it through Docker instead +# (this allows for "docker run docker ps", etc) +if docker-compose help "$1" > /dev/null 2>&1; then + set -- docker-compose "$@" +fi + +# if we have "--link some-docker:docker" and not DOCKER_HOST, let's set DOCKER_HOST automatically +if [ -z "$DOCKER_HOST" -a "$DOCKER_PORT_2375_TCP" ]; then + export DOCKER_HOST='tcp://docker:2375' +fi + +exec "$@" diff --git a/pyinstaller/ldd b/pyinstaller/ldd new file mode 100755 index 00000000000..3f10ad2757e --- /dev/null +++ b/pyinstaller/ldd @@ -0,0 +1,13 @@ +#!/bin/sh + +# From http://wiki.musl-libc.org/wiki/FAQ#Q:_where_is_ldd_.3F +# +# Musl's dynlinker comes with ldd functionality built in. just create a +# symlink from ld-musl-$ARCH.so to /bin/ldd. If the dynlinker was started +# as "ldd", it will detect that and print the appropriate DSO information. +# +# Instead, this string replaced "ldd" with the package so that pyinstaller +# can find the actual lib. +exec /usr/bin/ldd "$@" | \ + sed -r 's/([^[:space:]]+) => ldd/\1 => \/lib\/\1/g' | \ + sed -r 's/ldd \(.*\)//g' diff --git a/script/build/linux b/script/build/linux index 056940ad021..8de7218db5e 100755 --- a/script/build/linux +++ b/script/build/linux @@ -4,10 +4,15 @@ set -ex ./script/clean -TAG="docker-compose" -docker build -t "$TAG" . -docker run \ - --rm --entrypoint="script/build/linux-entrypoint" \ - -v $(pwd)/dist:/code/dist \ - -v $(pwd)/.git:/code/.git \ - "$TAG" +TMP_CONTAINER="tmpcontainer" +TAG="docker/compose:tmp-glibc-linux-binary" +DOCKER_COMPOSE_GITSHA=$(script/build/write-git-sha) + +docker build -t "${TAG}" . \ + --build-arg BUILD_PLATFORM=debian \ + --build-arg GIT_COMMIT=${DOCKER_COMPOSE_GITSHA} +docker create --name ${TMP_CONTAINER} ${TAG} +mkdir -p dist +docker cp ${TMP_CONTAINER}:/usr/local/bin/docker-compose dist/docker-compose-Linux-x86_64 +docker container rm -f ${TMP_CONTAINER} +docker image rm -f ${TAG} diff --git a/script/build/linux-entrypoint b/script/build/linux-entrypoint index 34c16ac695f..1556bbf20e4 100755 --- a/script/build/linux-entrypoint +++ b/script/build/linux-entrypoint @@ -2,14 +2,35 @@ set -ex -TARGET=dist/docker-compose-$(uname -s)-$(uname -m) -VENV=/code/.tox/py37 +CODE_PATH=/code +VENV=${CODE_PATH}/.tox/py37 -mkdir -p `pwd`/dist -chmod 777 `pwd`/dist +cd ${CODE_PATH} +mkdir -p dist +chmod 777 dist -$VENV/bin/pip install -q -r requirements-build.txt +${VENV}/bin/pip3 install -q -r requirements-build.txt + +# TODO(ulyssessouza) To check if really needed ./script/build/write-git-sha -su -c "$VENV/bin/pyinstaller docker-compose.spec" user -mv dist/docker-compose $TARGET -$TARGET version + +export PATH="${CODE_PATH}/pyinstaller:${PATH}" + +if [ ! -z "${BUILD_BOOTLOADER}" ]; then + # Build bootloader for alpine + git clone --single-branch --branch master https://github.com/pyinstaller/pyinstaller.git /tmp/pyinstaller + cd /tmp/pyinstaller/bootloader + git checkout v3.4 + ${VENV}/bin/python3 ./waf configure --no-lsb all + ${VENV}/bin/pip3 install .. + cd ${CODE_PATH} + rm -Rf /tmp/pyinstaller +else + echo "NOT compiling bootloader!!!" +fi + +${VENV}/bin/pyinstaller --exclude-module pycrypto --exclude-module PyInstaller docker-compose.spec +ls -la dist/ +ldd dist/docker-compose +mv dist/docker-compose /usr/local/bin +docker-compose version diff --git a/script/build/test-image b/script/build/test-image index a2eb62cdf78..9d880c27fcf 100755 --- a/script/build/test-image +++ b/script/build/test-image @@ -7,11 +7,12 @@ if [ -z "$1" ]; then exit 1 fi -TAG=$1 +TAG="$1" +IMAGE="docker/compose-tests" -docker build -t docker-compose-tests:tmp . -ctnr_id=$(docker create --entrypoint=tox docker-compose-tests:tmp) -docker commit $ctnr_id docker/compose-tests:latest -docker tag docker/compose-tests:latest docker/compose-tests:$TAG -docker rm -f $ctnr_id -docker rmi -f docker-compose-tests:tmp +DOCKER_COMPOSE_GITSHA=$(script/build/write-git-sha) +docker build -t "${IMAGE}:${TAG}" . \ + --target build \ + --build-arg BUILD_PLATFORM=debian \ + --build-arg GIT_COMMIT=${DOCKER_COMPOSE_GITSHA} +docker tag ${IMAGE}:${TAG} ${IMAGE}:latest diff --git a/script/test/ci b/script/test/ci index 8d3aa56cb80..bbcedac47c5 100755 --- a/script/test/ci +++ b/script/test/ci @@ -20,6 +20,3 @@ 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 index cbb6a67cb89..d24b41b0d4e 100755 --- a/script/test/default +++ b/script/test/default @@ -3,17 +3,18 @@ set -ex -TAG="docker-compose:$(git rev-parse --short HEAD)" +TAG="docker-compose:alpine-$(git rev-parse --short HEAD)" -# By default use the Dockerfile, but can be overridden to use an alternative file +# By default use the Dockerfile.alpine, but can be overridden to use an alternative file # e.g DOCKERFILE=Dockerfile.armhf script/test/default -DOCKERFILE="${DOCKERFILE:-Dockerfile}" +DOCKERFILE="${DOCKERFILE:-Dockerfile.alpine}" +DOCKER_BUILD_TARGET="${DOCKER_BUILD_TARGET:-build}" rm -rf coverage-html # Create the host directory so it's owned by $USER mkdir -p coverage-html -docker build -f ${DOCKERFILE} -t "$TAG" . +docker build -f ${DOCKERFILE} -t "${TAG}" --target "${DOCKER_BUILD_TARGET}" . GIT_VOLUME="--volume=$(pwd)/.git:/code/.git" . script/test/all From 2b24eb693c62ce8fd5f274f0fcb2837132a6a0b8 Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Fri, 12 Apr 2019 18:46:06 +0200 Subject: [PATCH 3694/4072] Refactor release and build scripts - Make use of the same Dockerfile when producing an image for testing and for deploying to DockerHub Signed-off-by: Ulysses Souza --- Jenkinsfile | 8 ++- script/build/image | 11 +-- script/build/linux | 15 ++-- script/build/linux-entrypoint | 19 ++--- script/build/osx | 5 +- script/build/test-image | 8 +-- script/build/write-git-sha | 2 +- script/release/release.py | 13 +++- script/release/release/images.py | 101 +++++++++++++++++++-------- script/release/release/repository.py | 1 + script/run/run.sh | 2 +- script/test/all | 3 +- script/test/default | 6 +- 13 files changed, 126 insertions(+), 68 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index 51fecf99f39..4de276ada90 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -10,7 +10,13 @@ def buildImage = { String baseImage -> try { image.pull() } catch (Exception exc) { - sh "docker build -t ${imageName} --target build --build-arg BUILD_PLATFORM=${baseImage} ." + sh """GIT_COMMIT=\$(script/build/write-git-sha) && \\ + docker build -t ${imageName} \\ + --target build \\ + --build-arg BUILD_PLATFORM="${baseImage}" \\ + --build-arg GIT_COMMIT="${GIT_COMMIT}" \\ + .\\ + """ sh "docker push ${imageName}" echo "${imageName}" return imageName diff --git a/script/build/image b/script/build/image index a3198c99f19..fb3f856ee24 100755 --- a/script/build/image +++ b/script/build/image @@ -7,11 +7,14 @@ if [ -z "$1" ]; then exit 1 fi -TAG=$1 +TAG="$1" VERSION="$(python setup.py --version)" -./script/build/write-git-sha +DOCKER_COMPOSE_GITSHA="$(script/build/write-git-sha)" +echo "${DOCKER_COMPOSE_GITSHA}" > compose/GITSHA python setup.py sdist bdist_wheel -./script/build/linux -docker build -t docker/compose:$TAG -f Dockerfile.run . + +docker build \ + --build-arg GIT_COMMIT="${DOCKER_COMPOSE_GITSHA}" \ + -t "${TAG}" . diff --git a/script/build/linux b/script/build/linux index 8de7218db5e..28065da0844 100755 --- a/script/build/linux +++ b/script/build/linux @@ -4,15 +4,14 @@ set -ex ./script/clean -TMP_CONTAINER="tmpcontainer" -TAG="docker/compose:tmp-glibc-linux-binary" -DOCKER_COMPOSE_GITSHA=$(script/build/write-git-sha) +DOCKER_COMPOSE_GITSHA="$(script/build/write-git-sha)" +TAG="docker/compose:tmp-glibc-linux-binary-${DOCKER_COMPOSE_GITSHA}" docker build -t "${TAG}" . \ --build-arg BUILD_PLATFORM=debian \ - --build-arg GIT_COMMIT=${DOCKER_COMPOSE_GITSHA} -docker create --name ${TMP_CONTAINER} ${TAG} + --build-arg GIT_COMMIT="${DOCKER_COMPOSE_GITSHA}" +TMP_CONTAINER=$(docker create "${TAG}") mkdir -p dist -docker cp ${TMP_CONTAINER}:/usr/local/bin/docker-compose dist/docker-compose-Linux-x86_64 -docker container rm -f ${TMP_CONTAINER} -docker image rm -f ${TAG} +docker cp "${TMP_CONTAINER}":/usr/local/bin/docker-compose dist/docker-compose-Linux-x86_64 +docker container rm -f "${TMP_CONTAINER}" +docker image rm -f "${TAG}" diff --git a/script/build/linux-entrypoint b/script/build/linux-entrypoint index 1556bbf20e4..1c5438d8ea6 100755 --- a/script/build/linux-entrypoint +++ b/script/build/linux-entrypoint @@ -3,16 +3,19 @@ set -ex CODE_PATH=/code -VENV=${CODE_PATH}/.tox/py37 +VENV="${CODE_PATH}"/.tox/py37 -cd ${CODE_PATH} +cd "${CODE_PATH}" mkdir -p dist chmod 777 dist -${VENV}/bin/pip3 install -q -r requirements-build.txt +"${VENV}"/bin/pip3 install -q -r requirements-build.txt # TODO(ulyssessouza) To check if really needed -./script/build/write-git-sha +if [ -z "${DOCKER_COMPOSE_GITSHA}" ]; then + DOCKER_COMPOSE_GITSHA="$(script/build/write-git-sha)" +fi +echo "${DOCKER_COMPOSE_GITSHA}" > compose/GITSHA export PATH="${CODE_PATH}/pyinstaller:${PATH}" @@ -21,15 +24,15 @@ if [ ! -z "${BUILD_BOOTLOADER}" ]; then git clone --single-branch --branch master https://github.com/pyinstaller/pyinstaller.git /tmp/pyinstaller cd /tmp/pyinstaller/bootloader git checkout v3.4 - ${VENV}/bin/python3 ./waf configure --no-lsb all - ${VENV}/bin/pip3 install .. - cd ${CODE_PATH} + "${VENV}"/bin/python3 ./waf configure --no-lsb all + "${VENV}"/bin/pip3 install .. + cd "${CODE_PATH}" rm -Rf /tmp/pyinstaller else echo "NOT compiling bootloader!!!" fi -${VENV}/bin/pyinstaller --exclude-module pycrypto --exclude-module PyInstaller docker-compose.spec +"${VENV}"/bin/pyinstaller --exclude-module pycrypto --exclude-module PyInstaller docker-compose.spec ls -la dist/ ldd dist/docker-compose mv dist/docker-compose /usr/local/bin diff --git a/script/build/osx b/script/build/osx index c62b2702433..529914586bc 100755 --- a/script/build/osx +++ b/script/build/osx @@ -5,11 +5,12 @@ TOOLCHAIN_PATH="$(realpath $(dirname $0)/../../build/toolchain)" rm -rf venv -virtualenv -p ${TOOLCHAIN_PATH}/bin/python3 venv +virtualenv -p "${TOOLCHAIN_PATH}"/bin/python3 venv venv/bin/pip install -r requirements.txt venv/bin/pip install -r requirements-build.txt venv/bin/pip install --no-deps . -./script/build/write-git-sha +DOCKER_COMPOSE_GITSHA="$(script/build/write-git-sha)" +echo "${DOCKER_COMPOSE_GITSHA}" > compose/GITSHA 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/build/test-image b/script/build/test-image index 9d880c27fcf..4964a5f9da4 100755 --- a/script/build/test-image +++ b/script/build/test-image @@ -10,9 +10,9 @@ fi TAG="$1" IMAGE="docker/compose-tests" -DOCKER_COMPOSE_GITSHA=$(script/build/write-git-sha) +DOCKER_COMPOSE_GITSHA="$(script/build/write-git-sha)" docker build -t "${IMAGE}:${TAG}" . \ --target build \ - --build-arg BUILD_PLATFORM=debian \ - --build-arg GIT_COMMIT=${DOCKER_COMPOSE_GITSHA} -docker tag ${IMAGE}:${TAG} ${IMAGE}:latest + --build-arg BUILD_PLATFORM="debian" \ + --build-arg GIT_COMMIT="${DOCKER_COMPOSE_GITSHA}" +docker tag "${IMAGE}":"${TAG}" "${IMAGE}":latest diff --git a/script/build/write-git-sha b/script/build/write-git-sha index be87f505806..cac4b6fd3b1 100755 --- a/script/build/write-git-sha +++ b/script/build/write-git-sha @@ -9,4 +9,4 @@ if [[ "${?}" != "0" ]]; then echo "Couldn't get revision of the git repository. Setting to 'unknown' instead" DOCKER_COMPOSE_GITSHA="unknown" fi -echo "${DOCKER_COMPOSE_GITSHA}" > compose/GITSHA +echo "${DOCKER_COMPOSE_GITSHA}" diff --git a/script/release/release.py b/script/release/release.py index 9db1a49d9f0..9fdd92dae1b 100755 --- a/script/release/release.py +++ b/script/release/release.py @@ -204,7 +204,7 @@ def resume(args): delete_assets(gh_release) upload_assets(gh_release, files) img_manager = ImageManager(args.release) - img_manager.build_images(repository, files) + img_manager.build_images(repository) except ScriptError as e: print(e) return 1 @@ -244,7 +244,7 @@ def start(args): gh_release = create_release_draft(repository, args.release, pr_data, files) upload_assets(gh_release, files) img_manager = ImageManager(args.release) - img_manager.build_images(repository, files) + img_manager.build_images(repository) except ScriptError as e: print(e) return 1 @@ -258,7 +258,8 @@ def finalize(args): try: check_pypirc() repository = Repository(REPO_ROOT, args.repo) - img_manager = ImageManager(args.release) + tag_as_latest = _check_if_tag_latest(args.release) + img_manager = ImageManager(args.release, tag_as_latest) pr_data = repository.find_release_pr(args.release) if not pr_data: raise ScriptError('No PR found for {}'.format(args.release)) @@ -314,6 +315,12 @@ def finalize(args): ''' +# Checks if this version respects the GA version format ('x.y.z') and not an RC +def _check_if_tag_latest(version): + ga_version = all(n.isdigit() for n in version.split('.')) and version.count('.') == 2 + return ga_version and yesno('Should this release be tagged as \"latest\"? Y/n', default=True) + + def main(): if 'GITHUB_TOKEN' not in os.environ: print('GITHUB_TOKEN environment variable must be set') diff --git a/script/release/release/images.py b/script/release/release/images.py index df6eeda4f71..796a4d82549 100644 --- a/script/release/release/images.py +++ b/script/release/release/images.py @@ -5,18 +5,29 @@ import base64 import json import os -import shutil import docker +from enum import Enum +from .const import NAME from .const import REPO_ROOT from .utils import ScriptError +class Platform(Enum): + ALPINE = 'alpine' + DEBIAN = 'debian' + + def __str__(self): + return self.value + + class ImageManager(object): - def __init__(self, version): + def __init__(self, version, latest=False): + self.built_tags = [] self.docker_client = docker.APIClient(**docker.utils.kwargs_from_env()) self.version = version + self.latest = latest if 'HUB_CREDENTIALS' in os.environ: print('HUB_CREDENTIALS found in environment, issuing login') credentials = json.loads(base64.urlsafe_b64decode(os.environ['HUB_CREDENTIALS'])) @@ -24,16 +35,31 @@ def __init__(self, version): username=credentials['Username'], password=credentials['Password'] ) - def build_images(self, repository, files): - print("Building release images...") - repository.write_git_sha() - distdir = os.path.join(REPO_ROOT, 'dist') - os.makedirs(distdir, exist_ok=True) - shutil.copy(files['docker-compose-Linux-x86_64'][0], distdir) - os.chmod(os.path.join(distdir, 'docker-compose-Linux-x86_64'), 0o755) - print('Building docker/compose image') + def _tag(self, image, existing_tag, new_tag): + existing_repo_tag = '{image}:{tag}'.format(image=image, tag=existing_tag) + new_repo_tag = '{image}:{tag}'.format(image=image, tag=new_tag) + self.docker_client.tag(existing_repo_tag, new_repo_tag) + self.built_tags.append(new_repo_tag) + + def build_runtime_image(self, repository, platform): + git_sha = repository.write_git_sha() + compose_image_base_name = NAME + print('Building {image} image ({platform} based)'.format( + image=compose_image_base_name, + platform=platform + )) + full_version = '{version}-{platform}'.format(version=self.version, platform=platform) + build_tag = '{image_base_image}:{full_version}'.format( + image_base_image=compose_image_base_name, + full_version=full_version + ) logstream = self.docker_client.build( - REPO_ROOT, tag='docker/compose:{}'.format(self.version), dockerfile='Dockerfile.run', + REPO_ROOT, + tag=build_tag, + buildargs={ + 'BUILD_PLATFORM': platform.value, + 'GIT_COMMIT': git_sha, + }, decode=True ) for chunk in logstream: @@ -42,9 +68,32 @@ def build_images(self, repository, files): if 'stream' in chunk: print(chunk['stream'], end='') - print('Building test image (for UCP e2e)') + self.built_tags.append(build_tag) + if platform == Platform.ALPINE: + self._tag(compose_image_base_name, full_version, self.version) + if self.latest: + self._tag(compose_image_base_name, full_version, platform) + if platform == Platform.ALPINE: + self._tag(compose_image_base_name, full_version, 'latest') + + # Used for producing a test image for UCP + def build_ucp_test_image(self, repository): + print('Building test image (debian based for UCP e2e)') + git_sha = repository.write_git_sha() + compose_tests_image_base_name = NAME + '-tests' + ucp_test_image_tag = '{image}:{tag}'.format( + image=compose_tests_image_base_name, + tag=self.version + ) logstream = self.docker_client.build( - REPO_ROOT, tag='docker-compose-tests:tmp', decode=True + REPO_ROOT, + tag=ucp_test_image_tag, + target='build', + buildargs={ + 'BUILD_PLATFORM': Platform.DEBIAN.value, + 'GIT_COMMIT': git_sha, + }, + decode=True ) for chunk in logstream: if 'error' in chunk: @@ -52,26 +101,16 @@ def build_images(self, repository, files): if 'stream' in chunk: print(chunk['stream'], end='') - container = self.docker_client.create_container( - 'docker-compose-tests:tmp', entrypoint='tox' - ) - self.docker_client.commit(container, 'docker/compose-tests', 'latest') - self.docker_client.tag( - 'docker/compose-tests:latest', 'docker/compose-tests:{}'.format(self.version) - ) - self.docker_client.remove_container(container, force=True) - self.docker_client.remove_image('docker-compose-tests:tmp', force=True) + self.built_tags.append(ucp_test_image_tag) + self._tag(compose_tests_image_base_name, self.version, 'latest') - @property - def image_names(self): - return [ - 'docker/compose-tests:latest', - 'docker/compose-tests:{}'.format(self.version), - 'docker/compose:{}'.format(self.version) - ] + def build_images(self, repository): + self.build_runtime_image(repository, Platform.ALPINE) + self.build_runtime_image(repository, Platform.DEBIAN) + self.build_ucp_test_image(repository) def check_images(self): - for name in self.image_names: + for name in self.built_tags: try: self.docker_client.inspect_image(name) except docker.errors.ImageNotFound: @@ -80,7 +119,7 @@ def check_images(self): return True def push_images(self): - for name in self.image_names: + for name in self.built_tags: print('Pushing {} to Docker Hub'.format(name)) logstream = self.docker_client.push(name, stream=True, decode=True) for chunk in logstream: diff --git a/script/release/release/repository.py b/script/release/release/repository.py index 9a5d432c084..0dc724f8074 100644 --- a/script/release/release/repository.py +++ b/script/release/release/repository.py @@ -175,6 +175,7 @@ def close_release_pr(self, version): def write_git_sha(self): with open(os.path.join(REPO_ROOT, 'compose', 'GITSHA'), 'w') as f: f.write(self.git_repo.head.commit.hexsha[:7]) + return self.git_repo.head.commit.hexsha[:7] def cherry_pick_prs(self, release_branch, ids): if not ids: diff --git a/script/run/run.sh b/script/run/run.sh index a8690cad1fe..f3456720fa5 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -48,7 +48,7 @@ fi # Only allocate tty if we detect one if [ -t 0 -a -t 1 ]; then - DOCKER_RUN_OPTIONS="$DOCKER_RUN_OPTIONS -t" + DOCKER_RUN_OPTIONS="$DOCKER_RUN_OPTIONS -t" fi # Always set -i to support piped and terminal input in run/exec diff --git a/script/test/all b/script/test/all index 5c911bba4bc..f929a57eecc 100755 --- a/script/test/all +++ b/script/test/all @@ -8,8 +8,7 @@ set -e docker run --rm \ --tty \ ${GIT_VOLUME} \ - --entrypoint="tox" \ - "$TAG" -e pre-commit + "$TAG" tox -e pre-commit get_versions="docker run --rm --entrypoint=/code/.tox/py27/bin/python diff --git a/script/test/default b/script/test/default index d24b41b0d4e..4d973d1d087 100755 --- a/script/test/default +++ b/script/test/default @@ -5,16 +5,16 @@ set -ex TAG="docker-compose:alpine-$(git rev-parse --short HEAD)" -# By default use the Dockerfile.alpine, but can be overridden to use an alternative file +# By default use the Dockerfile, but can be overridden to use an alternative file # e.g DOCKERFILE=Dockerfile.armhf script/test/default -DOCKERFILE="${DOCKERFILE:-Dockerfile.alpine}" +DOCKERFILE="${DOCKERFILE:-Dockerfile}" DOCKER_BUILD_TARGET="${DOCKER_BUILD_TARGET:-build}" rm -rf coverage-html # Create the host directory so it's owned by $USER mkdir -p coverage-html -docker build -f ${DOCKERFILE} -t "${TAG}" --target "${DOCKER_BUILD_TARGET}" . +docker build -f "${DOCKERFILE}" -t "${TAG}" --target "${DOCKER_BUILD_TARGET}" . GIT_VOLUME="--volume=$(pwd)/.git:/code/.git" . script/test/all From e047169315d3ca7fa62de8a4bba436a058aa3e94 Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Mon, 15 Apr 2019 14:14:38 +0200 Subject: [PATCH 3695/4072] Workaround race conditions on tests Signed-off-by: Ulysses Souza --- tests/acceptance/cli_test.py | 2 ++ tests/fixtures/logs-composefile/docker-compose.yml | 4 ++-- tests/fixtures/logs-restart-composefile/docker-compose.yml | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index c15a99e0907..8c8286f5d26 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -2347,6 +2347,7 @@ def test_logs_follow(self): assert 'another' in result.stdout assert 'exited with code 0' in result.stdout + @pytest.mark.skip(reason="race condition between up and logs") def test_logs_follow_logs_from_new_containers(self): self.base_dir = 'tests/fixtures/logs-composefile' self.dispatch(['up', '-d', 'simple']) @@ -2393,6 +2394,7 @@ def test_logs_follow_logs_from_restarted_containers(self): ) == 3 assert result.stdout.count('world') == 3 + @pytest.mark.skip(reason="race condition between up and logs") def test_logs_default(self): self.base_dir = 'tests/fixtures/logs-composefile' self.dispatch(['up', '-d']) diff --git a/tests/fixtures/logs-composefile/docker-compose.yml b/tests/fixtures/logs-composefile/docker-compose.yml index b719c91e079..ea18f162dd0 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 && tail -f /dev/null" + command: sh -c "sleep 1 && echo hello && tail -f /dev/null" another: image: busybox:latest - command: sh -c "echo test" + command: sh -c "sleep 1 && echo test" diff --git a/tests/fixtures/logs-restart-composefile/docker-compose.yml b/tests/fixtures/logs-restart-composefile/docker-compose.yml index c662a1e719d..6be8b9079cb 100644 --- a/tests/fixtures/logs-restart-composefile/docker-compose.yml +++ b/tests/fixtures/logs-restart-composefile/docker-compose.yml @@ -3,5 +3,5 @@ simple: command: sh -c "echo hello && tail -f /dev/null" another: image: busybox:latest - command: sh -c "sleep 0.5 && echo world && /bin/false" + command: sh -c "sleep 2 && echo world && /bin/false" restart: "on-failure:2" From f2dc9230843020a70d5bf99166ada325f4e1cb79 Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Fri, 19 Apr 2019 15:53:02 +0200 Subject: [PATCH 3696/4072] Avoid race condition on test Signed-off-by: Ulysses Souza --- 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 8c8286f5d26..7f45fc199bf 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -2374,6 +2374,7 @@ def test_logs_follow_logs_from_new_containers(self): assert '{} exited with code 0'.format(another_name) in result.stdout assert '{} exited with code 137'.format(simple_name) in result.stdout + @pytest.mark.skip(reason="race condition between up and logs") def test_logs_follow_logs_from_restarted_containers(self): self.base_dir = 'tests/fixtures/logs-restart-composefile' proc = start_process(self.base_dir, ['up']) From 482bca9519af4947e8d61396d37483ee0be62108 Mon Sep 17 00:00:00 2001 From: Joakim Roubert Date: Tue, 23 Apr 2019 09:52:35 +0200 Subject: [PATCH 3697/4072] Purge Dockerfile.armhf which is no longer needed Current Dockerfile builds fine for armhf and thus the outdated Dockerfile.armhf is unnecessary. Change-Id: Idafdb9fbddedd622c2c0aaddb1d5331d81cfe57d Signed-off-by: Joakim Roubert --- Dockerfile.armhf | 39 --------------------------------------- script/test/default | 2 +- 2 files changed, 1 insertion(+), 40 deletions(-) delete mode 100644 Dockerfile.armhf diff --git a/Dockerfile.armhf b/Dockerfile.armhf deleted file mode 100644 index 9c02dbc76c5..00000000000 --- a/Dockerfile.armhf +++ /dev/null @@ -1,39 +0,0 @@ -FROM python:3.7.2-stretch - -RUN set -ex; \ - apt-get update -qq; \ - apt-get install -y \ - locales \ - curl \ - python-dev \ - git - -RUN curl -fsSL -o dockerbins.tgz "https://download.docker.com/linux/static/stable/armhf/docker-17.12.0-ce.tgz" && \ - SHA256=f8de6378dad825b9fd5c3c2f949e791d22f918623c27a72c84fd6975a0e5d0a2; \ - echo "${SHA256} dockerbins.tgz" | sha256sum -c - && \ - tar xvf dockerbins.tgz docker/docker --strip-components 1 && \ - mv docker /usr/local/bin/docker && \ - chmod +x /usr/local/bin/docker && \ - rm dockerbins.tgz - -# Python3 requires a valid locale -RUN echo "en_US.UTF-8 UTF-8" > /etc/locale.gen && locale-gen -ENV LANG en_US.UTF-8 - -RUN useradd -d /home/user -m -s /bin/bash user -WORKDIR /code/ - -RUN pip install tox==2.1.1 - -ADD requirements.txt /code/ -ADD requirements-dev.txt /code/ -ADD .pre-commit-config.yaml /code/ -ADD setup.py /code/ -ADD tox.ini /code/ -ADD compose /code/compose/ -RUN tox --notest - -ADD . /code/ -RUN chown -R user /code/ - -ENTRYPOINT ["/code/.tox/py37/bin/docker-compose"] diff --git a/script/test/default b/script/test/default index 4d973d1d087..4f307f2e927 100755 --- a/script/test/default +++ b/script/test/default @@ -6,7 +6,7 @@ set -ex TAG="docker-compose:alpine-$(git rev-parse --short HEAD)" # By default use the Dockerfile, but can be overridden to use an alternative file -# e.g DOCKERFILE=Dockerfile.armhf script/test/default +# e.g DOCKERFILE=Dockerfile.s390x script/test/default DOCKERFILE="${DOCKERFILE:-Dockerfile}" DOCKER_BUILD_TARGET="${DOCKER_BUILD_TARGET:-build}" From 3a47000e71c98a0916d735215e12efd778f7b737 Mon Sep 17 00:00:00 2001 From: ulyssessouza Date: Sun, 12 May 2019 13:33:38 +0200 Subject: [PATCH 3698/4072] Bump urllib3 Signed-off-by: ulyssessouza --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 5e8ec2ed719..f2bfe6e1a67 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,5 +20,5 @@ PyYAML==4.2b1 requests==2.20.0 six==1.10.0 texttable==0.9.1 -urllib3==1.21.1; python_version == '3.3' +urllib3==1.24.2; python_version == '3.3' websocket-client==0.32.0 From c2783d6f886db0f14c40dc07ea52f685dbe763d7 Mon Sep 17 00:00:00 2001 From: noname Date: Thu, 18 Apr 2019 00:15:29 -0700 Subject: [PATCH 3699/4072] fix #6579 cli ps --all Signed-off-by: Seedf --- 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 78f5967539e..443435b10ee 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -721,7 +721,8 @@ def ps(self, options): if options['--all']: containers = sorted(self.project.containers(service_names=options['SERVICE'], - one_off=OneOffFilter.include, stopped=True)) + one_off=OneOffFilter.include, stopped=True), + key=attrgetter('name')) else: containers = sorted( self.project.containers(service_names=options['SERVICE'], stopped=True) + From 8a9575bd0d6ca19f67f89c2881d950b40a93a83a Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Tue, 14 May 2019 16:53:52 +0200 Subject: [PATCH 3700/4072] Remove remaining containers on test_build_run Signed-off-by: Ulysses Souza --- tests/acceptance/cli_test.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 7f45fc199bf..49b1d0021d4 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -793,6 +793,9 @@ def test_build_rm(self): ] assert containers + for c in self.project.client.containers(all=True): + self.addCleanup(self.project.client.remove_container, c, force=True) + def test_build_shm_size_build_option(self): pull_busybox(self.client) self.base_dir = 'tests/fixtures/build-shm-size' From 99e67d0c061fa3d9b9793391f3b7c8bdf8e841fc Mon Sep 17 00:00:00 2001 From: Inconnu08 Date: Wed, 15 May 2019 23:45:40 +0600 Subject: [PATCH 3701/4072] fix warning method is deprecated with tests Signed-off-by: Taufiq Rahman --- compose/bundle.py | 8 +++---- compose/cli/docker_client.py | 2 +- compose/cli/main.py | 12 +++++----- compose/config/config.py | 14 +++++------ compose/config/environment.py | 2 +- compose/network.py | 6 ++--- compose/project.py | 8 +++---- compose/service.py | 24 +++++++++---------- compose/volume.py | 4 ++-- .../migrate-compose-file-v1-to-v2.py | 6 ++--- tests/integration/service_test.py | 6 ++--- tests/unit/bundle_test.py | 8 +++---- tests/unit/cli/docker_client_test.py | 2 +- tests/unit/cli/main_test.py | 2 +- tests/unit/config/config_test.py | 12 +++++----- tests/unit/network_test.py | 4 ++-- tests/unit/service_test.py | 20 ++++++++-------- 17 files changed, 70 insertions(+), 70 deletions(-) diff --git a/compose/bundle.py b/compose/bundle.py index efc455b7234..77cb37aa97b 100644 --- a/compose/bundle.py +++ b/compose/bundle.py @@ -164,10 +164,10 @@ def push_image(service): def to_bundle(config, image_digests): if config.networks: - log.warn("Unsupported top level key 'networks' - ignoring") + log.warning("Unsupported top level key 'networks' - ignoring") if config.volumes: - log.warn("Unsupported top level key 'volumes' - ignoring") + log.warning("Unsupported top level key 'volumes' - ignoring") config = denormalize_config(config) @@ -192,7 +192,7 @@ def convert_service_to_bundle(name, service_dict, image_digest): continue if key not in SUPPORTED_KEYS: - log.warn("Unsupported key '{}' in services.{} - ignoring".format(key, name)) + log.warning("Unsupported key '{}' in services.{} - ignoring".format(key, name)) continue if key == 'environment': @@ -239,7 +239,7 @@ def make_service_networks(name, service_dict): for network_name, network_def in get_network_defs_for_service(service_dict).items(): for key in network_def.keys(): - log.warn( + log.warning( "Unsupported key '{}' in services.{}.networks.{} - ignoring" .format(key, name, network_name)) diff --git a/compose/cli/docker_client.py b/compose/cli/docker_client.py index a01704fd271..a57a69b501b 100644 --- a/compose/cli/docker_client.py +++ b/compose/cli/docker_client.py @@ -31,7 +31,7 @@ def get_tls_version(environment): tls_attr_name = "PROTOCOL_{}".format(compose_tls_version) if not hasattr(ssl, tls_attr_name): - log.warn( + log.warning( 'The "{}" protocol is unavailable. You may need to update your ' 'version of Python or OpenSSL. Falling back to TLSv1 (default).' .format(compose_tls_version) diff --git a/compose/cli/main.py b/compose/cli/main.py index 78f5967539e..d697dc3b193 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -391,7 +391,7 @@ def create(self, options): """ service_names = options['SERVICE'] - log.warn( + log.warning( 'The create command is deprecated. ' 'Use the up command with the --no-start flag instead.' ) @@ -765,7 +765,7 @@ def pull(self, options): --include-deps Also pull services declared as dependencies """ if options.get('--parallel'): - log.warn('--parallel option is deprecated and will be removed in future versions.') + log.warning('--parallel option is deprecated and will be removed in future versions.') self.project.pull( service_names=options['SERVICE'], ignore_pull_failures=options.get('--ignore-pull-failures'), @@ -806,7 +806,7 @@ def rm(self, options): -a, --all Deprecated - no effect. """ if options.get('--all'): - log.warn( + log.warning( '--all flag is obsolete. This is now the default behavior ' 'of `docker-compose rm`' ) @@ -916,7 +916,7 @@ def scale(self, options): 'Use the up command with the --scale flag instead.' ) else: - log.warn( + log.warning( 'The scale command is deprecated. ' 'Use the up command with the --scale flag instead.' ) @@ -1250,7 +1250,7 @@ def exitval_from_opts(options, project): exit_value_from = options.get('--exit-code-from') if exit_value_from: if not options.get('--abort-on-container-exit'): - log.warn('using --exit-code-from implies --abort-on-container-exit') + log.warning('using --exit-code-from implies --abort-on-container-exit') options['--abort-on-container-exit'] = True if exit_value_from not in [s.name for s in project.get_services()]: log.error('No service named "%s" was found in your compose file.', @@ -1580,7 +1580,7 @@ def warn_for_swarm_mode(client): # UCP does multi-node scheduling with traditional Compose files. return - log.warn( + log.warning( "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" diff --git a/compose/config/config.py b/compose/config/config.py index c110e2cfae0..5202d00255b 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -198,9 +198,9 @@ def version(self): version = self.config['version'] if isinstance(version, dict): - 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)) + log.warning('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): @@ -318,8 +318,8 @@ def get_default_config_files(base_dir): winner = candidates[0] if len(candidates) > 1: - log.warn("Found multiple config files with supported names: %s", ", ".join(candidates)) - log.warn("Using %s\n", winner) + log.warning("Found multiple config files with supported names: %s", ", ".join(candidates)) + log.warning("Using %s\n", winner) return [os.path.join(path, winner)] + get_default_override_file(path) @@ -362,7 +362,7 @@ def check_swarm_only_config(service_dicts, compatibility=False): def check_swarm_only_key(service_dicts, key): services = [s for s in service_dicts if s.get(key)] if services: - log.warn( + log.warning( warning_template.format( services=", ".join(sorted(s['name'] for s in services)), key=key @@ -921,7 +921,7 @@ def finalize_service(service_config, service_names, version, environment, compat service_dict ) if ignored_keys: - log.warn( + log.warning( 'The following deploy sub-keys are not supported in compatibility mode and have' ' been ignored: {}'.format(', '.join(ignored_keys)) ) diff --git a/compose/config/environment.py b/compose/config/environment.py index e2db343d70a..e72c8823129 100644 --- a/compose/config/environment.py +++ b/compose/config/environment.py @@ -100,7 +100,7 @@ def __getitem__(self, key): except KeyError: pass if not self.silent and key not in self.missing_keys: - log.warn( + log.warning( "The {} variable is not set. Defaulting to a blank string." .format(key) ) diff --git a/compose/network.py b/compose/network.py index 2491a598964..e0d711ff701 100644 --- a/compose/network.py +++ b/compose/network.py @@ -231,7 +231,7 @@ def check_remote_network_config(remote, local): if k.startswith('com.docker.'): # We are only interested in user-specified labels continue if remote_labels.get(k) != local_labels.get(k): - log.warn( + log.warning( 'Network {}: label "{}" has changed. It may need to be' ' recreated.'.format(local.true_name, k) ) @@ -276,7 +276,7 @@ def from_services(cls, services, networks, use_networking): } unused = set(networks) - set(service_networks) - {'default'} if unused: - log.warn( + log.warning( "Some networks were defined but are not used by any service: " "{}".format(", ".join(unused))) return cls(service_networks, use_networking) @@ -288,7 +288,7 @@ def remove(self): try: network.remove() except NotFound: - log.warn("Network %s not found.", network.true_name) + log.warning("Network %s not found.", network.true_name) def initialize(self): if not self.use_networking: diff --git a/compose/project.py b/compose/project.py index 6965c6251e6..996c4cbaca1 100644 --- a/compose/project.py +++ b/compose/project.py @@ -776,13 +776,13 @@ def get_secrets(service, service_secrets, secret_defs): .format(service=service, secret=secret.source)) if secret_def.get('external'): - 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)) + log.warning("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 if secret.uid or secret.gid or secret.mode: - log.warn( + log.warning( "Service \"{service}\" uses secret \"{secret}\" with uid, " "gid, or mode. These fields are not supported by this " "implementation of the Compose file".format( diff --git a/compose/service.py b/compose/service.py index e989d4877c9..af9b10baf82 100644 --- a/compose/service.py +++ b/compose/service.py @@ -240,15 +240,15 @@ def start(self, **options): def show_scale_warnings(self, desired_num): 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)) + log.warning('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)) 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) + log.warning('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) def scale(self, desired_num, timeout=None): """ @@ -357,7 +357,7 @@ def ensure_image_exists(self, do_build=BuildAction.none, silent=False): raise NeedsBuildError(self) self.build() - log.warn( + log.warning( "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)) @@ -1325,7 +1325,7 @@ def mode(self): if containers: return 'container:' + containers[0].id - log.warn( + log.warning( "Service %s is trying to use reuse the PID namespace " "of another service that is not running." % (self.service_name) ) @@ -1388,8 +1388,8 @@ def mode(self): if containers: return 'container:' + containers[0].id - log.warn("Service %s is trying to use reuse the network stack " - "of another service that is not running." % (self.id)) + log.warning("Service %s is trying to use reuse the network stack " + "of another service that is not running." % (self.id)) return None @@ -1540,7 +1540,7 @@ def warn_on_masked_volume(volumes_option, container_volumes, service): volume.internal in container_volumes and container_volumes.get(volume.internal) != volume.external ): - log.warn(( + log.warning(( "Service \"{service}\" is using volume \"{volume}\" from the " "previous container. Host mapping \"{host_path}\" has no effect. " "Remove the existing containers (with `docker-compose rm {service}`) " diff --git a/compose/volume.py b/compose/volume.py index 60c1e0fe8f2..b02fc5d8030 100644 --- a/compose/volume.py +++ b/compose/volume.py @@ -127,7 +127,7 @@ def remove(self): try: volume.remove() except NotFound: - log.warn("Volume %s not found.", volume.true_name) + log.warning("Volume %s not found.", volume.true_name) def initialize(self): try: @@ -209,7 +209,7 @@ def check_remote_volume_config(remote, local): if k.startswith('com.docker.'): # We are only interested in user-specified labels continue if remote_labels.get(k) != local_labels.get(k): - log.warn( + log.warning( 'Volume {}: label "{}" has changed. It may need to be' ' recreated.'.format(local.name, k) ) diff --git a/contrib/migration/migrate-compose-file-v1-to-v2.py b/contrib/migration/migrate-compose-file-v1-to-v2.py index c1785b0daee..274b499b9d4 100755 --- a/contrib/migration/migrate-compose-file-v1-to-v2.py +++ b/contrib/migration/migrate-compose-file-v1-to-v2.py @@ -44,7 +44,7 @@ def warn_for_links(name, service): links = service.get('links') if links: example_service = links[0].partition(':')[0] - log.warn( + log.warning( "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 " @@ -57,7 +57,7 @@ def warn_for_links(name, service): def warn_for_external_links(name, service): external_links = service.get('external_links') if external_links: - log.warn( + log.warning( "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 " @@ -107,7 +107,7 @@ def rewrite_volumes_from(service, service_names): def create_volumes_section(data): named_volumes = get_named_volumes(data['services']) if named_volumes: - log.warn( + log.warning( "Named volumes ({names}) must be explicitly declared. Creating a " "'volumes' section with declarations.\n\n" "For backwards-compatibility, they've been declared as external. " diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 000f6838c7c..b49ae710634 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -695,8 +695,8 @@ def test_execute_convergence_plan_when_image_volume_masks_config(self): new_container, = service.execute_convergence_plan( ConvergencePlan('recreate', [old_container])) - mock_log.warn.assert_called_once_with(mock.ANY) - _, args, kwargs = mock_log.warn.mock_calls[0] + mock_log.warning.assert_called_once_with(mock.ANY) + _, args, kwargs = mock_log.warning.mock_calls[0] assert "Service \"db\" is using volume \"/data\" from the previous container" in args[0] assert [mount['Destination'] for mount in new_container.get('Mounts')] == ['/data'] @@ -1382,7 +1382,7 @@ def test_scale_with_custom_container_name_outputs_warning(self, mock_log): with pytest.raises(OperationFailedError): service.scale(3) - captured_output = mock_log.warn.call_args[0][0] + captured_output = mock_log.warning.call_args[0][0] assert len(service.containers()) == 1 assert "Remove the custom name to scale the service." in captured_output diff --git a/tests/unit/bundle_test.py b/tests/unit/bundle_test.py index 065cdd00b49..8faebb7f1fc 100644 --- a/tests/unit/bundle_test.py +++ b/tests/unit/bundle_test.py @@ -94,7 +94,7 @@ def test_to_bundle(): configs={} ) - with mock.patch('compose.bundle.log.warn', autospec=True) as mock_log: + with mock.patch('compose.bundle.log.warning', autospec=True) as mock_log: output = bundle.to_bundle(config, image_digests) assert mock_log.mock_calls == [ @@ -128,7 +128,7 @@ def test_convert_service_to_bundle(): 'privileged': True, } - with mock.patch('compose.bundle.log.warn', autospec=True) as mock_log: + with mock.patch('compose.bundle.log.warning', autospec=True) as mock_log: config = bundle.convert_service_to_bundle(name, service_dict, image_digest) mock_log.assert_called_once_with( @@ -177,7 +177,7 @@ def test_make_service_networks_default(): name = 'theservice' service_dict = {} - with mock.patch('compose.bundle.log.warn', autospec=True) as mock_log: + with mock.patch('compose.bundle.log.warning', autospec=True) as mock_log: networks = bundle.make_service_networks(name, service_dict) assert not mock_log.called @@ -195,7 +195,7 @@ def test_make_service_networks(): }, } - with mock.patch('compose.bundle.log.warn', autospec=True) as mock_log: + with mock.patch('compose.bundle.log.warning', autospec=True) as mock_log: networks = bundle.make_service_networks(name, service_dict) mock_log.assert_called_once_with( diff --git a/tests/unit/cli/docker_client_test.py b/tests/unit/cli/docker_client_test.py index be91ea31d8f..772c136eefd 100644 --- a/tests/unit/cli/docker_client_test.py +++ b/tests/unit/cli/docker_client_test.py @@ -247,5 +247,5 @@ def test_get_tls_version_unavailable(self): environment = {'COMPOSE_TLS_VERSION': 'TLSv5_5'} with mock.patch('compose.cli.docker_client.log') as mock_log: tls_version = get_tls_version(environment) - mock_log.warn.assert_called_once_with(mock.ANY) + mock_log.warning.assert_called_once_with(mock.ANY) assert tls_version is None diff --git a/tests/unit/cli/main_test.py b/tests/unit/cli/main_test.py index 2e97f2c8794..eb6a99d72c0 100644 --- a/tests/unit/cli/main_test.py +++ b/tests/unit/cli/main_test.py @@ -63,7 +63,7 @@ def test_warning_in_swarm_mode(self): with mock.patch('compose.cli.main.log') as fake_log: warn_for_swarm_mode(mock_client) - assert fake_log.warn.call_count == 1 + assert fake_log.warning.call_count == 1 class TestSetupConsoleHandlerTestCase(object): diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index e27427ca5c2..163c9cd1606 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -329,7 +329,7 @@ def test_load_service_with_name_version(self): ) assert 'Unexpected type for "version" key in "filename.yml"' \ - in mock_logging.warn.call_args[0][0] + in mock_logging.warning.call_args[0][0] service_dicts = config_data.services assert service_sort(service_dicts) == service_sort([ @@ -3570,8 +3570,8 @@ def test_unset_variable_produces_warning(self): with mock.patch('compose.config.environment.log') as log: config.load(config_details) - assert 2 == log.warn.call_count - warnings = sorted(args[0][0] for args in log.warn.call_args_list) + assert 2 == log.warning.call_count + warnings = sorted(args[0][0] for args in log.warning.call_args_list) assert 'BAR' in warnings[0] assert 'FOO' in warnings[1] @@ -3601,8 +3601,8 @@ def test_compatibility_mode_warnings(self): with mock.patch('compose.config.config.log') as log: config.load(config_details, compatibility=True) - assert log.warn.call_count == 1 - warn_message = log.warn.call_args[0][0] + assert log.warning.call_count == 1 + warn_message = log.warning.call_args[0][0] assert warn_message.startswith( 'The following deploy sub-keys are not supported in compatibility mode' ) @@ -3641,7 +3641,7 @@ def test_compatibility_mode_load(self): with mock.patch('compose.config.config.log') as log: cfg = config.load(config_details, compatibility=True) - assert log.warn.call_count == 0 + assert log.warning.call_count == 0 service_dict = cfg.services[0] assert service_dict == { diff --git a/tests/unit/network_test.py b/tests/unit/network_test.py index d7ffa289422..82cfb3be28b 100644 --- a/tests/unit/network_test.py +++ b/tests/unit/network_test.py @@ -165,6 +165,6 @@ def test_check_remote_network_labels_mismatch(self): with mock.patch('compose.network.log') as mock_log: check_remote_network_config(remote, net) - mock_log.warn.assert_called_once_with(mock.ANY) - _, args, kwargs = mock_log.warn.mock_calls[0] + mock_log.warning.assert_called_once_with(mock.ANY) + _, args, kwargs = mock_log.warning.mock_calls[0] assert 'label "com.project.touhou.character" has changed' in args[0] diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 3d7c4987a6f..8c381f15dc8 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -516,8 +516,8 @@ def test_create_container(self): with mock.patch('compose.service.log', autospec=True) as mock_log: service.create_container() - assert mock_log.warn.called - _, args, _ = mock_log.warn.mock_calls[0] + assert mock_log.warning.called + _, args, _ = mock_log.warning.mock_calls[0] assert 'was built because it did not already exist' in args[0] assert self.mock_client.build.call_count == 1 @@ -546,7 +546,7 @@ def test_ensure_image_exists_force_build(self): 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 + assert not mock_log.warning.called assert self.mock_client.build.call_count == 1 self.mock_client.build.call_args[1]['tag'] == 'default_foo' @@ -847,13 +847,13 @@ def test_only_log_warning_when_host_ports_clash(self, mock_log): ports=["8080:80"]) service.scale(0) - assert not mock_log.warn.called + assert not mock_log.warning.called service.scale(1) - assert not mock_log.warn.called + assert not mock_log.warning.called service.scale(2) - mock_log.warn.assert_called_once_with( + mock_log.warning.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)) @@ -1391,7 +1391,7 @@ def test_warn_on_masked_volume_no_warning_when_no_container_volumes(self): with mock.patch('compose.service.log', autospec=True) as mock_log: warn_on_masked_volume(volumes_option, container_volumes, service) - assert not mock_log.warn.called + assert not mock_log.warning.called def test_warn_on_masked_volume_when_masked(self): volumes_option = [VolumeSpec('/home/user', '/path', 'rw')] @@ -1404,7 +1404,7 @@ def test_warn_on_masked_volume_when_masked(self): with mock.patch('compose.service.log', autospec=True) as mock_log: warn_on_masked_volume(volumes_option, container_volumes, service) - mock_log.warn.assert_called_once_with(mock.ANY) + mock_log.warning.assert_called_once_with(mock.ANY) def test_warn_on_masked_no_warning_with_same_path(self): volumes_option = [VolumeSpec('/home/user', '/path', 'rw')] @@ -1414,7 +1414,7 @@ def test_warn_on_masked_no_warning_with_same_path(self): with mock.patch('compose.service.log', autospec=True) as mock_log: warn_on_masked_volume(volumes_option, container_volumes, service) - assert not mock_log.warn.called + assert not mock_log.warning.called def test_warn_on_masked_no_warning_with_container_only_option(self): volumes_option = [VolumeSpec(None, '/path', 'rw')] @@ -1426,7 +1426,7 @@ def test_warn_on_masked_no_warning_with_container_only_option(self): with mock.patch('compose.service.log', autospec=True) as mock_log: warn_on_masked_volume(volumes_option, container_volumes, service) - assert not mock_log.warn.called + assert not mock_log.warning.called def test_create_with_special_volume_mode(self): self.mock_client.inspect_image.return_value = {'Id': 'imageid'} From 51ee6093df9947b4e77ff679d9d587b1d0164fa2 Mon Sep 17 00:00:00 2001 From: Nao YONASHIRO Date: Mon, 1 Apr 2019 19:09:10 +0900 Subject: [PATCH 3702/4072] feat: drop empty tag on cache_from Signed-off-by: Nao YONASHIRO --- compose/service.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/compose/service.py b/compose/service.py index e989d4877c9..6bb75576729 100644 --- a/compose/service.py +++ b/compose/service.py @@ -1078,7 +1078,7 @@ def build(self, no_cache=False, pull=False, force_rm=False, memory=None, build_a pull=pull, nocache=no_cache, dockerfile=build_opts.get('dockerfile', None), - cache_from=build_opts.get('cache_from', None), + cache_from=self.get_cache_from(build_opts), labels=build_opts.get('labels', None), buildargs=build_args, network_mode=build_opts.get('network', None), @@ -1116,6 +1116,12 @@ def build(self, no_cache=False, pull=False, force_rm=False, memory=None, build_a return image_id + def get_cache_from(self, build_opts): + cache_from = build_opts.get('cache_from', None) + if cache_from is not None: + cache_from = [tag for tag in cache_from if tag] + return cache_from + def can_be_built(self): return 'build' in self.options From a857be3f7ed3882bc291df928621d5e61d0d7081 Mon Sep 17 00:00:00 2001 From: Sergey Fursov Date: Mon, 20 May 2019 16:54:49 +0300 Subject: [PATCH 3703/4072] support requests up to 2.22.x version Signed-off-by: Sergey Fursov --- requirements.txt | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index f2bfe6e1a67..aac374aaca8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,7 +17,7 @@ pypiwin32==219; sys_platform == 'win32' and python_version < '3.6' pypiwin32==223; sys_platform == 'win32' and python_version >= '3.6' PySocks==1.6.7 PyYAML==4.2b1 -requests==2.20.0 +requests==2.22.0 six==1.10.0 texttable==0.9.1 urllib3==1.24.2; python_version == '3.3' diff --git a/setup.py b/setup.py index 8371cc756ee..e0ab41bd0ca 100644 --- a/setup.py +++ b/setup.py @@ -33,7 +33,7 @@ def find_version(*file_paths): 'cached-property >= 1.2.0, < 2', 'docopt >= 0.6.1, < 0.7', 'PyYAML >= 3.10, < 4.3', - 'requests >= 2.6.1, != 2.11.0, != 2.12.2, != 2.18.0, < 2.21', + 'requests >= 2.6.1, != 2.11.0, != 2.12.2, != 2.18.0, < 2.23', 'texttable >= 0.9.0, < 0.10', 'websocket-client >= 0.32.0, < 1.0', 'docker[ssh] >= 3.7.0, < 4.0', From e4b4babc24a49b5875cbdbe64c6c9c0c3e128e86 Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Wed, 22 May 2019 11:55:37 +0200 Subject: [PATCH 3704/4072] Bump docker-py 4.0.1 Signed-off-by: Ulysses Souza --- requirements.txt | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index aac374aaca8..ff23516e151 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ cached-property==1.3.0 certifi==2017.4.17 chardet==3.0.4 colorama==0.4.0; sys_platform == 'win32' -docker==3.7.2 +docker==4.0.1 docker-pycreds==0.4.0 dockerpty==0.4.1 docopt==0.6.2 diff --git a/setup.py b/setup.py index e0ab41bd0ca..c6d07a86ae6 100644 --- a/setup.py +++ b/setup.py @@ -36,7 +36,7 @@ def find_version(*file_paths): 'requests >= 2.6.1, != 2.11.0, != 2.12.2, != 2.18.0, < 2.23', 'texttable >= 0.9.0, < 0.10', 'websocket-client >= 0.32.0, < 1.0', - 'docker[ssh] >= 3.7.0, < 4.0', + 'docker[ssh] >= 3.7.0, < 4.0.2', 'dockerpty >= 0.4.1, < 0.5', 'six >= 1.3.0, < 2', 'jsonschema >= 2.5.1, < 3', From c15e8af7f86932f8a064a076b606fd2028b8c7be Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Wed, 22 May 2019 19:00:16 +0200 Subject: [PATCH 3705/4072] Fix release script for null user Signed-off-by: Ulysses Souza --- script/release/release/repository.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/script/release/release/repository.py b/script/release/release/repository.py index 0dc724f8074..a0281eaa3d8 100644 --- a/script/release/release/repository.py +++ b/script/release/release/repository.py @@ -220,6 +220,8 @@ def get_contributors(pr_data): commits = pr_data.get_commits() authors = {} for commit in commits: + if not commit or not commit.author or not commit.author.login: + continue author = commit.author.login authors[author] = authors.get(author, 0) + 1 return [x[0] for x in sorted(list(authors.items()), key=lambda x: x[1])] From 9d2508cf585261f0f7167df1dbfb363940ebe5c6 Mon Sep 17 00:00:00 2001 From: Ian Campbell Date: Thu, 23 May 2019 15:59:21 +0100 Subject: [PATCH 3706/4072] Pass environment when calling through to docker cli. This ensures that settings from any `.env` file (such as `DOCKER_HOST`) are passed on to the cli. Unit tests are adjusted for the new parameter and a new case is added to ensure it is propagated as expected. Fixes: 6661 Signed-off-by: Ian Campbell --- compose/cli/main.py | 9 ++++----- tests/unit/cli/main_test.py | 21 +++++++++++++++------ 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index e2c04bd2494..78990111bfa 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -516,7 +516,7 @@ def exec_command(self, options): if IS_WINDOWS_PLATFORM or use_cli and not detach: sys.exit(call_docker( build_exec_command(options, container.id, command), - self.toplevel_options) + self.toplevel_options, environment) ) create_exec_options = { @@ -1361,7 +1361,6 @@ def remove_container(force=False): environment_file = options.get('--env-file') environment = Environment.from_env_file(project_dir, environment_file) use_cli = not environment.get_boolean('COMPOSE_INTERACTIVE_NO_CLI') - signals.set_signal_handler_to_shutdown() signals.set_signal_handler_to_hang_up() try: @@ -1370,7 +1369,7 @@ def remove_container(force=False): service.connect_container_to_networks(container, use_network_aliases) exit_code = call_docker( ["start", "--attach", "--interactive", container.id], - toplevel_options + toplevel_options, environment ) else: operation = RunOperation( @@ -1450,7 +1449,7 @@ def exit_if(condition, message, exit_code): raise SystemExit(exit_code) -def call_docker(args, dockeropts): +def call_docker(args, dockeropts, environment): executable_path = find_executable('docker') if not executable_path: raise UserError(errors.docker_not_found_msg("Couldn't find `docker` binary.")) @@ -1480,7 +1479,7 @@ def call_docker(args, dockeropts): args = [executable_path] + tls_options + args log.debug(" ".join(map(pipes.quote, args))) - return subprocess.call(args) + return subprocess.call(args, env=environment) def parse_scale_args(options): diff --git a/tests/unit/cli/main_test.py b/tests/unit/cli/main_test.py index eb6a99d72c0..7fed6bccc29 100644 --- a/tests/unit/cli/main_test.py +++ b/tests/unit/cli/main_test.py @@ -123,13 +123,13 @@ def mock_find_executable(exe): class TestCallDocker(object): def test_simple_no_options(self): with mock.patch('subprocess.call') as fake_call: - call_docker(['ps'], {}) + call_docker(['ps'], {}, {}) assert fake_call.call_args[0][0] == ['docker', 'ps'] def test_simple_tls_option(self): with mock.patch('subprocess.call') as fake_call: - call_docker(['ps'], {'--tls': True}) + call_docker(['ps'], {'--tls': True}, {}) assert fake_call.call_args[0][0] == ['docker', '--tls', 'ps'] @@ -140,7 +140,7 @@ def test_advanced_tls_options(self): '--tlscacert': './ca.pem', '--tlscert': './cert.pem', '--tlskey': './key.pem', - }) + }, {}) assert fake_call.call_args[0][0] == [ 'docker', '--tls', '--tlscacert', './ca.pem', '--tlscert', @@ -149,7 +149,7 @@ def test_advanced_tls_options(self): def test_with_host_option(self): with mock.patch('subprocess.call') as fake_call: - call_docker(['ps'], {'--host': 'tcp://mydocker.net:2333'}) + call_docker(['ps'], {'--host': 'tcp://mydocker.net:2333'}, {}) assert fake_call.call_args[0][0] == [ 'docker', '--host', 'tcp://mydocker.net:2333', 'ps' @@ -157,7 +157,7 @@ def test_with_host_option(self): def test_with_http_host(self): with mock.patch('subprocess.call') as fake_call: - call_docker(['ps'], {'--host': 'http://mydocker.net:2333'}) + call_docker(['ps'], {'--host': 'http://mydocker.net:2333'}, {}) assert fake_call.call_args[0][0] == [ 'docker', '--host', 'tcp://mydocker.net:2333', 'ps', @@ -165,8 +165,17 @@ def test_with_http_host(self): def test_with_host_option_shorthand_equal(self): with mock.patch('subprocess.call') as fake_call: - call_docker(['ps'], {'--host': '=tcp://mydocker.net:2333'}) + call_docker(['ps'], {'--host': '=tcp://mydocker.net:2333'}, {}) assert fake_call.call_args[0][0] == [ 'docker', '--host', 'tcp://mydocker.net:2333', 'ps' ] + + def test_with_env(self): + with mock.patch('subprocess.call') as fake_call: + call_docker(['ps'], {}, {'DOCKER_HOST': 'tcp://mydocker.net:2333'}) + + assert fake_call.call_args[0][0] == [ + 'docker', 'ps' + ] + assert fake_call.call_args[1]['env'] == {'DOCKER_HOST': 'tcp://mydocker.net:2333'} From 2d2b0bd9a8764cd40877c95bb758badda9413de7 Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Thu, 23 May 2019 21:38:20 +0200 Subject: [PATCH 3707/4072] Fix 'finalize' command on release script Signed-off-by: Ulysses Souza --- script/release/release.py | 9 ++--- script/release/release/const.py | 1 + script/release/release/images.py | 61 +++++++++++++++++++++++--------- 3 files changed, 47 insertions(+), 24 deletions(-) diff --git a/script/release/release.py b/script/release/release.py index 9fdd92dae1b..861461f670f 100755 --- a/script/release/release.py +++ b/script/release/release.py @@ -17,6 +17,7 @@ from release.images import ImageManager from release.pypi import check_pypirc from release.pypi import pypi_upload +from release.images import is_tag_latest from release.repository import delete_assets from release.repository import get_contributors from release.repository import Repository @@ -258,7 +259,7 @@ def finalize(args): try: check_pypirc() repository = Repository(REPO_ROOT, args.repo) - tag_as_latest = _check_if_tag_latest(args.release) + tag_as_latest = is_tag_latest(args.release) img_manager = ImageManager(args.release, tag_as_latest) pr_data = repository.find_release_pr(args.release) if not pr_data: @@ -315,12 +316,6 @@ def finalize(args): ''' -# Checks if this version respects the GA version format ('x.y.z') and not an RC -def _check_if_tag_latest(version): - ga_version = all(n.isdigit() for n in version.split('.')) and version.count('.') == 2 - return ga_version and yesno('Should this release be tagged as \"latest\"? Y/n', default=True) - - def main(): if 'GITHUB_TOKEN' not in os.environ: print('GITHUB_TOKEN environment variable must be set') diff --git a/script/release/release/const.py b/script/release/release/const.py index 5a72bde411b..52458ea14b9 100644 --- a/script/release/release/const.py +++ b/script/release/release/const.py @@ -6,4 +6,5 @@ REPO_ROOT = os.path.join(os.path.dirname(__file__), '..', '..', '..') NAME = 'docker/compose' +COMPOSE_TESTS_IMAGE_BASE_NAME = NAME + '-tests' BINTRAY_ORG = 'docker-compose' diff --git a/script/release/release/images.py b/script/release/release/images.py index 796a4d82549..d8752b5d263 100644 --- a/script/release/release/images.py +++ b/script/release/release/images.py @@ -9,9 +9,12 @@ import docker from enum import Enum +from script.release.release.const import COMPOSE_TESTS_IMAGE_BASE_NAME + from .const import NAME from .const import REPO_ROOT from .utils import ScriptError +from .utils import yesno class Platform(Enum): @@ -22,9 +25,14 @@ def __str__(self): return self.value +# Checks if this version respects the GA version format ('x.y.z') and not an RC +def is_tag_latest(version): + ga_version = all(n.isdigit() for n in version.split('.')) and version.count('.') == 2 + return ga_version and yesno('Should this release be tagged as \"latest\"? [Y/n]: ', default=True) + + class ImageManager(object): def __init__(self, version, latest=False): - self.built_tags = [] self.docker_client = docker.APIClient(**docker.utils.kwargs_from_env()) self.version = version self.latest = latest @@ -39,7 +47,15 @@ def _tag(self, image, existing_tag, new_tag): existing_repo_tag = '{image}:{tag}'.format(image=image, tag=existing_tag) new_repo_tag = '{image}:{tag}'.format(image=image, tag=new_tag) self.docker_client.tag(existing_repo_tag, new_repo_tag) - self.built_tags.append(new_repo_tag) + + def get_full_version(self, platform=None): + return self.version + '-' + platform.__str__() if platform else self.version + + def get_runtime_image_tag(self, tag): + return '{image_base_image}:{tag}'.format( + image_base_image=NAME, + tag=self.get_full_version(tag) + ) def build_runtime_image(self, repository, platform): git_sha = repository.write_git_sha() @@ -48,11 +64,8 @@ def build_runtime_image(self, repository, platform): image=compose_image_base_name, platform=platform )) - full_version = '{version}-{platform}'.format(version=self.version, platform=platform) - build_tag = '{image_base_image}:{full_version}'.format( - image_base_image=compose_image_base_name, - full_version=full_version - ) + full_version = self.get_full_version(self, platform) + build_tag = self.get_runtime_image_tag(platform) logstream = self.docker_client.build( REPO_ROOT, tag=build_tag, @@ -68,7 +81,6 @@ def build_runtime_image(self, repository, platform): if 'stream' in chunk: print(chunk['stream'], end='') - self.built_tags.append(build_tag) if platform == Platform.ALPINE: self._tag(compose_image_base_name, full_version, self.version) if self.latest: @@ -76,15 +88,17 @@ def build_runtime_image(self, repository, platform): if platform == Platform.ALPINE: self._tag(compose_image_base_name, full_version, 'latest') + def get_ucp_test_image_tag(self, tag=None): + return '{image}:{tag}'.format( + image=COMPOSE_TESTS_IMAGE_BASE_NAME, + tag=tag or self.version + ) + # Used for producing a test image for UCP def build_ucp_test_image(self, repository): print('Building test image (debian based for UCP e2e)') git_sha = repository.write_git_sha() - compose_tests_image_base_name = NAME + '-tests' - ucp_test_image_tag = '{image}:{tag}'.format( - image=compose_tests_image_base_name, - tag=self.version - ) + ucp_test_image_tag = self.get_ucp_test_image_tag() logstream = self.docker_client.build( REPO_ROOT, tag=ucp_test_image_tag, @@ -101,8 +115,7 @@ def build_ucp_test_image(self, repository): if 'stream' in chunk: print(chunk['stream'], end='') - self.built_tags.append(ucp_test_image_tag) - self._tag(compose_tests_image_base_name, self.version, 'latest') + self._tag(COMPOSE_TESTS_IMAGE_BASE_NAME, self.version, 'latest') def build_images(self, repository): self.build_runtime_image(repository, Platform.ALPINE) @@ -110,7 +123,7 @@ def build_images(self, repository): self.build_ucp_test_image(repository) def check_images(self): - for name in self.built_tags: + for name in self.get_images_to_push(): try: self.docker_client.inspect_image(name) except docker.errors.ImageNotFound: @@ -118,8 +131,22 @@ def check_images(self): return False return True + def get_images_to_push(self): + tags_to_push = { + "{}:{}".format(NAME, self.version), + self.get_runtime_image_tag(Platform.ALPINE), + self.get_runtime_image_tag(Platform.DEBIAN), + self.get_ucp_test_image_tag(), + self.get_ucp_test_image_tag('latest'), + } + if is_tag_latest(self.version): + tags_to_push.add("{}:latest".format(NAME)) + return tags_to_push + def push_images(self): - for name in self.built_tags: + tags_to_push = self.get_images_to_push() + print('Build tags to push {}'.format(tags_to_push)) + for name in tags_to_push: print('Pushing {} to Docker Hub'.format(name)) logstream = self.docker_client.push(name, stream=True, decode=True) for chunk in logstream: From a2516c48d99496816d47ae6c2d518499c7b4f8ed Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Thu, 23 May 2019 22:04:02 +0200 Subject: [PATCH 3708/4072] Fix imports ordering Signed-off-by: Ulysses Souza --- script/release/release.py | 2 +- script/release/release/images.py | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/script/release/release.py b/script/release/release.py index 861461f670f..a9c05eb7874 100755 --- a/script/release/release.py +++ b/script/release/release.py @@ -15,9 +15,9 @@ from release.const import REPO_ROOT from release.downloader import BinaryDownloader from release.images import ImageManager +from release.images import is_tag_latest from release.pypi import check_pypirc from release.pypi import pypi_upload -from release.images import is_tag_latest from release.repository import delete_assets from release.repository import get_contributors from release.repository import Repository diff --git a/script/release/release/images.py b/script/release/release/images.py index d8752b5d263..0ce7a0c2d12 100644 --- a/script/release/release/images.py +++ b/script/release/release/images.py @@ -9,12 +9,11 @@ import docker from enum import Enum -from script.release.release.const import COMPOSE_TESTS_IMAGE_BASE_NAME - from .const import NAME from .const import REPO_ROOT from .utils import ScriptError from .utils import yesno +from script.release.release.const import COMPOSE_TESTS_IMAGE_BASE_NAME class Platform(Enum): From fb4d5aa7e6019df20075139468d924a18fc05fcb Mon Sep 17 00:00:00 2001 From: Brett Randall Date: Sun, 17 Mar 2019 14:38:24 +1100 Subject: [PATCH 3709/4072] Include required but missing VAR name and assignment in interpolation error message. Error message format is now e.g.: ERROR: Missing mandatory value for "environment" option interpolating ['MYENV=${MYVAR:?}'] in service "myservice": Fixed #6587. Signed-off-by: Brett Randall --- compose/config/interpolation.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/compose/config/interpolation.py b/compose/config/interpolation.py index 0f878be1444..18be8562cbb 100644 --- a/compose/config/interpolation.py +++ b/compose/config/interpolation.py @@ -64,12 +64,12 @@ def interpolate_value(name, config_key, value, section, interpolator): string=e.string)) except UnsetRequiredSubstitution as e: raise ConfigurationError( - 'Missing mandatory value for "{config_key}" option in {section} "{name}": {err}'.format( - config_key=config_key, - name=name, - section=section, - err=e.err - ) + 'Missing mandatory value for "{config_key}" option interpolating {value} ' + 'in {section} "{name}": {err}'.format(config_key=config_key, + value=value, + name=name, + section=section, + err=e.err) ) From 1f55b533c492eb40f189a1e91480a5c05420bb13 Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Thu, 23 May 2019 23:35:57 +0200 Subject: [PATCH 3710/4072] Fix release script get_full_version() Signed-off-by: Ulysses Souza --- script/release/release/images.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/release/release/images.py b/script/release/release/images.py index 0ce7a0c2d12..17d572df33c 100644 --- a/script/release/release/images.py +++ b/script/release/release/images.py @@ -63,7 +63,7 @@ def build_runtime_image(self, repository, platform): image=compose_image_base_name, platform=platform )) - full_version = self.get_full_version(self, platform) + full_version = self.get_full_version(platform) build_tag = self.get_runtime_image_tag(platform) logstream = self.docker_client.build( REPO_ROOT, From 8552e8e2833f377103843bbc31df282cce299a77 Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Thu, 23 May 2019 23:39:09 +0200 Subject: [PATCH 3711/4072] "Bump 1.25.0-rc1" Signed-off-by: Ulysses Souza --- CHANGELOG.md | 69 +++++++++++++++++++++++++++++++++++++++++++++ compose/__init__.py | 2 +- script/run/run.sh | 2 +- 3 files changed, 71 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a3ad8bfd5f..0a512c353e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,75 @@ Change log ========== +1.25.0 (2019-05-22) +------------------- + +### Features + +- Add tag `docker-compose:latest` + +- Add `docker-compose:-alpine` image/tag + +- Add `docker-compose:-debian` image/tag + +- Bumped `docker-py` 4.0.1 + +- Supports `requests` up to 2.22.0 version + +- Drops empty tag on `build:cache_from` + +- `Dockerfile` now generates `libmusl` binaries for alpine + +- Only pull images that can't be built + +- Attribute `scale` can now accept `0` as a value + +- Added `--quiet` build flag + +- Added `--no-interpolate` to `docker-compose config` + +- Bump OpenSSL for macOS build (`1.1.0j` to `1.1.1a`) + +- Added `--no-rm` to `build` command + +- Added support for `credential_spec` + +- Resolve digests without pulling image + +- Upgrade `pyyaml` to `4.2b1` + +- Lowered severity to `warning` if `down` tries to remove nonexisting image + +- Use improved API fields for project events when possible + +- Update `setup.py` for modern `pypi/setuptools` and remove `pandoc` dependencies + +- Removed `Dockerfile.armhf` which is no longer needed + +### Bugfixes + +- Fixed `--remove-orphans` when used with `up --no-start` + +- Fixed `docker-compose ps --all` + +- Fixed `depends_on` dependency recreation behavior + +- Fixed bash completion for `build --memory` + +- Fixed misleading warning concerning env vars when performing an `exec` command + +- Fixed failure check in parallel_execute_watch + +- Fixed race condition after pulling image + +- Fixed error on duplicate mount points. + +- Fixed merge on networks section + +- Always connect Compose container to `stdin` + +- Fixed the presentation of failed services on 'docker-compose start' when containers are not available + 1.24.0 (2019-03-28) ------------------- diff --git a/compose/__init__.py b/compose/__init__.py index 0042896b658..55060583efb 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.25.0dev' +__version__ = '1.25.0-rc1' diff --git a/script/run/run.sh b/script/run/run.sh index f3456720fa5..58caf361255 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.24.0" +VERSION="1.25.0-rc1" IMAGE="docker/compose:$VERSION" From d68113f5c0590128bed842222f957761c3b40009 Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Fri, 24 May 2019 21:59:14 +0200 Subject: [PATCH 3712/4072] Add bash completion for `config --no-interpolate` 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 941f25a3bec..e9168b1b16a 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -150,7 +150,7 @@ _docker_compose_config() { ;; esac - COMPREPLY=( $( compgen -W "--hash --help --quiet -q --resolve-image-digests --services --volumes" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--hash --help --no-interpolate --quiet -q --resolve-image-digests --services --volumes" -- "$cur" ) ) } From b29b6a1538a1554a5b805938b930a8c70aae452f Mon Sep 17 00:00:00 2001 From: Inconnu08 Date: Fri, 31 May 2019 20:29:09 +0600 Subject: [PATCH 3713/4072] replace sets with set literal syntax for efficiency Signed-off-by: Taufiq Rahman --- compose/cli/command.py | 15 +-------------- tests/acceptance/cli_test.py | 18 ++++++------------ 2 files changed, 7 insertions(+), 26 deletions(-) diff --git a/compose/cli/command.py b/compose/cli/command.py index 21ab9a39fa6..a756630fd21 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -21,20 +21,7 @@ log = logging.getLogger(__name__) -SILENT_COMMANDS = set(( - 'events', - 'exec', - 'kill', - 'logs', - 'pause', - 'ps', - 'restart', - 'rm', - 'start', - 'stop', - 'top', - 'unpause', -)) +SILENT_COMMANDS = {'events', 'exec', 'kill', 'logs', 'pause', 'ps', 'restart', 'rm', 'start', 'stop', 'top', 'unpause'} def project_from_options(project_dir, options, additional_options={}): diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 8e66c48e2da..b9005df8b55 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1446,7 +1446,7 @@ def test_up_with_volume_labels(self): if v['Name'].split('/')[-1].startswith('{}_'.format(self.project.name)) ] - assert set([v['Name'].split('/')[-1] for v in volumes]) == set([volume_with_label]) + assert set([v['Name'].split('/')[-1] for v in volumes]) == {volume_with_label} assert 'label_key' in volumes[0]['Labels'] assert volumes[0]['Labels']['label_key'] == 'label_val' @@ -2111,7 +2111,7 @@ def test_run_interactive_connects_to_network(self): 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'] or []) - set([container.short_id]) + aliases = set(config['Aliases'] or []) - {container.short_id} assert not aliases @v2_only() @@ -2131,7 +2131,7 @@ def test_run_detached_connects_to_network(self): 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'] or []) - set([container.short_id]) + aliases = set(config['Aliases'] or []) - {container.short_id} assert not aliases assert self.lookup(container, 'app') @@ -2741,7 +2741,7 @@ def test_up_with_extends(self): self.base_dir = 'tests/fixtures/extends' self.dispatch(['up', '-d'], None) - assert set([s.name for s in self.project.services]) == set(['mydb', 'myweb']) + assert set([s.name for s in self.project.services]) == {'mydb', 'myweb'} # Sort by name so we get [db, web] containers = sorted( @@ -2753,15 +2753,9 @@ def test_up_with_extends(self): web = containers[1] db_name = containers[0].name_without_project - assert set(get_links(web)) == set( - ['db', db_name, 'extends_{}'.format(db_name)] - ) + assert set(get_links(web)) == {'db', db_name, 'extends_{}'.format(db_name)} - expected_env = set([ - "FOO=1", - "BAR=2", - "BAZ=2", - ]) + expected_env = {"FOO=1", "BAR=2", "BAZ=2"} assert expected_env <= set(web.get('Config.Env')) def test_top_services_not_running(self): From c37fb783feaedf1bf9338c1a3c3fbeef9fb9f373 Mon Sep 17 00:00:00 2001 From: Inconnu08 Date: Sat, 1 Jun 2019 01:31:35 +0600 Subject: [PATCH 3714/4072] replace sets with set literal syntax for efficiency Signed-off-by: Taufiq Rahman --- tests/integration/project_test.py | 18 +++++++----------- tests/unit/config/config_test.py | 24 ++++++++++++------------ tests/unit/service_test.py | 6 ++---- 3 files changed, 21 insertions(+), 27 deletions(-) diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index fe6ace90e37..b76b63f3c31 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -104,7 +104,7 @@ def test_containers_with_extra_service(self): self.create_service('extra').create_container() project = Project('composetest', [web, db], self.client) - assert set(project.containers(stopped=True)) == set([web_1, db_1]) + assert set(project.containers(stopped=True)) == {web_1, db_1} def test_parallel_pull_with_no_image(self): config_data = build_config( @@ -305,9 +305,7 @@ def test_start_pause_unpause_stop_kill_remove(self): db_container = db.create_container() project.start(service_names=['web']) - assert set(c.name for c in project.containers() if c.is_running) == set( - [web_container_1.name, web_container_2.name] - ) + assert set(c.name for c in project.containers() if c.is_running) == {web_container_1.name, web_container_2.name} project.start() assert set(c.name for c in project.containers() if c.is_running) == set( @@ -315,14 +313,12 @@ def test_start_pause_unpause_stop_kill_remove(self): ) project.pause(service_names=['web']) - assert set([c.name for c in project.containers() if c.is_paused]) == set( - [web_container_1.name, web_container_2.name] - ) + assert set([c.name for c in project.containers() if c.is_paused]) == {web_container_1.name, + web_container_2.name} project.pause() - assert set([c.name for c in project.containers() if c.is_paused]) == set( - [web_container_1.name, web_container_2.name, db_container.name] - ) + assert set([c.name for c in project.containers() if c.is_paused]) == {web_container_1.name, + web_container_2.name, db_container.name} project.unpause(service_names=['db']) assert len([c.name for c in project.containers() if c.is_paused]) == 2 @@ -331,7 +327,7 @@ def test_start_pause_unpause_stop_kill_remove(self): assert len([c.name for c in project.containers() if c.is_paused]) == 0 project.stop(service_names=['web'], timeout=1) - assert set(c.name for c in project.containers() if c.is_running) == set([db_container.name]) + assert set(c.name for c in project.containers() if c.is_running) == {db_container.name} project.kill(service_names=['db']) assert len([c for c in project.containers() if c.is_running]) == 0 diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 163c9cd1606..2f27a5b2090 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -3821,35 +3821,35 @@ def test_no_override(self): {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]) == {'/foo:/code', '/data'} def test_no_base(self): service_dict = config.merge_service_dicts( {}, {self.config_name: ['/bar:/code']}, DEFAULT_VERSION) - assert set(service_dict[self.config_name]) == set(['/bar:/code']) + assert set(service_dict[self.config_name]) == {'/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']}, DEFAULT_VERSION) - assert set(service_dict[self.config_name]) == set(['/bar:/code', '/data']) + assert set(service_dict[self.config_name]) == {'/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']}, DEFAULT_VERSION) - assert set(service_dict[self.config_name]) == set(['/bar:/code', '/quux:/data']) + assert set(service_dict[self.config_name]) == {'/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']}, DEFAULT_VERSION) - assert set(service_dict[self.config_name]) == set(['/bar:/code', '/data']) + assert set(service_dict[self.config_name]) == {'/bar:/code', '/data'} class MergeVolumesTest(unittest.TestCase, MergePathMappingTest): @@ -4053,28 +4053,28 @@ def test_no_override(self): {'dns': '8.8.8.8'}, {}, DEFAULT_VERSION) - assert set(service_dict['dns']) == set(['8.8.8.8']) + assert set(service_dict['dns']) == {'8.8.8.8'} def test_no_base(self): service_dict = config.merge_service_dicts( {}, {'dns': '8.8.8.8'}, DEFAULT_VERSION) - assert set(service_dict['dns']) == set(['8.8.8.8']) + assert set(service_dict['dns']) == {'8.8.8.8'} def test_add_string(self): service_dict = config.merge_service_dicts( {'dns': ['8.8.8.8']}, {'dns': '9.9.9.9'}, DEFAULT_VERSION) - assert set(service_dict['dns']) == set(['8.8.8.8', '9.9.9.9']) + assert set(service_dict['dns']) == {'8.8.8.8', '9.9.9.9'} def test_add_list(self): service_dict = config.merge_service_dicts( {'dns': '8.8.8.8'}, {'dns': ['9.9.9.9']}, DEFAULT_VERSION) - assert set(service_dict['dns']) == set(['8.8.8.8', '9.9.9.9']) + assert set(service_dict['dns']) == {'8.8.8.8', '9.9.9.9'} class MergeLabelsTest(unittest.TestCase): @@ -4146,7 +4146,7 @@ def test_full(self): assert result['context'] == override['context'] assert result['dockerfile'] == override['dockerfile'] assert result['args'] == {'x': '12', 'y': '2'} - assert set(result['cache_from']) == set(['ubuntu', 'debian']) + assert set(result['cache_from']) == {'ubuntu', 'debian'} assert result['labels'] == override['labels'] def test_empty_override(self): @@ -4350,7 +4350,7 @@ def test_resolve_path(self): "tests/fixtures/env", ) ).services[0] - assert set(service_dict['volumes']) == set([VolumeSpec.parse('/tmp:/host/tmp')]) + assert set(service_dict['volumes']) == {VolumeSpec.parse('/tmp:/host/tmp')} service_dict = config.load( build_config_details( @@ -4358,7 +4358,7 @@ def test_resolve_path(self): "tests/fixtures/env", ) ).services[0] - assert set(service_dict['volumes']) == set([VolumeSpec.parse('/opt/tmp:/opt/host/tmp')]) + assert set(service_dict['volumes']) == {VolumeSpec.parse('/opt/tmp:/opt/host/tmp')} def load_from_filename(filename, override_dir=None): diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 8c381f15dc8..0cff08ab603 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -1334,10 +1334,8 @@ def test_mount_same_host_path_to_two_volumes(self): number=1, ) - assert set(self.mock_client.create_host_config.call_args[1]['binds']) == set([ - '/host/path:/data1:rw', - '/host/path:/data2:rw', - ]) + assert set(self.mock_client.create_host_config.call_args[1]['binds']) == {'/host/path:/data1:rw', + '/host/path:/data2:rw'} def test_get_container_create_options_with_different_host_path_in_container_json(self): service = Service( From 57055e0e66a033de493b27d55df10ef36eae429b Mon Sep 17 00:00:00 2001 From: Inconnu08 Date: Sun, 2 Jun 2019 20:21:21 +0600 Subject: [PATCH 3715/4072] Replace sets with set literal syntax for efficiency Signed-off-by: Taufiq Rahman --- compose/cli/command.py | 15 ++++++++++++++- tests/integration/project_test.py | 16 ++++++++-------- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/compose/cli/command.py b/compose/cli/command.py index a756630fd21..2f38fe5af4c 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -21,7 +21,20 @@ log = logging.getLogger(__name__) -SILENT_COMMANDS = {'events', 'exec', 'kill', 'logs', 'pause', 'ps', 'restart', 'rm', 'start', 'stop', 'top', 'unpause'} +SILENT_COMMANDS = { + 'events', + 'exec', + 'kill', + 'logs', + 'pause', + 'ps', + 'restart', + 'rm', + 'start', + 'stop', + 'top', + 'unpause', +} def project_from_options(project_dir, options, additional_options={}): diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index b76b63f3c31..1d9495ff880 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -305,20 +305,20 @@ def test_start_pause_unpause_stop_kill_remove(self): db_container = db.create_container() project.start(service_names=['web']) - assert set(c.name for c in project.containers() if c.is_running) == {web_container_1.name, web_container_2.name} + assert set(c.name for c in project.containers() if c.is_running) == { + web_container_1.name, web_container_2.name} project.start() - assert set(c.name for c in project.containers() if c.is_running) == set( - [web_container_1.name, web_container_2.name, db_container.name] - ) + assert set(c.name for c in project.containers() if c.is_running) == { + web_container_1.name, web_container_2.name, db_container.name} project.pause(service_names=['web']) - assert set([c.name for c in project.containers() if c.is_paused]) == {web_container_1.name, - web_container_2.name} + assert set([c.name for c in project.containers() if c.is_paused]) == { + web_container_1.name, web_container_2.name} project.pause() - assert set([c.name for c in project.containers() if c.is_paused]) == {web_container_1.name, - web_container_2.name, db_container.name} + assert set([c.name for c in project.containers() if c.is_paused]) == { + web_container_1.name, web_container_2.name, db_container.name} project.unpause(service_names=['db']) assert len([c.name for c in project.containers() if c.is_paused]) == 2 From 8c387c6013c509eff7a2bf5cb284782b82a9ae36 Mon Sep 17 00:00:00 2001 From: Dave Tucker Date: Fri, 14 Jun 2019 14:58:17 +0100 Subject: [PATCH 3716/4072] Add .fossa.yml file This commit adds a .fossa.yml file used by fossa.io It allows for fossa to scan the dependencies and figure out which oss licenses are in use. This can be added to CI at some point in the near future. Signed-off-by: Dave Tucker --- .fossa.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .fossa.yml diff --git a/.fossa.yml b/.fossa.yml new file mode 100644 index 00000000000..b50761ef14a --- /dev/null +++ b/.fossa.yml @@ -0,0 +1,14 @@ +# Generated by FOSSA CLI (https://github.com/fossas/fossa-cli) +# Visit https://fossa.io to learn more + +version: 2 +cli: + server: https://app.fossa.io + fetcher: custom + project: git@github.com:docker/compose +analyze: + modules: + - name: . + type: pip + target: . + path: . From 5e7521909d59ca69f77b60dc4173c9517facc2b9 Mon Sep 17 00:00:00 2001 From: Dave Tucker Date: Fri, 14 Jun 2019 14:58:17 +0100 Subject: [PATCH 3717/4072] Add .fossa.yml file This commit adds a .fossa.yml file used by fossa.io It allows for fossa to scan the dependencies and figure out which oss licenses are in use. This can be added to CI at some point in the near future. Signed-off-by: Dave Tucker --- .fossa.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .fossa.yml diff --git a/.fossa.yml b/.fossa.yml new file mode 100644 index 00000000000..b50761ef14a --- /dev/null +++ b/.fossa.yml @@ -0,0 +1,14 @@ +# Generated by FOSSA CLI (https://github.com/fossas/fossa-cli) +# Visit https://fossa.io to learn more + +version: 2 +cli: + server: https://app.fossa.io + fetcher: custom + project: git@github.com:docker/compose +analyze: + modules: + - name: . + type: pip + target: . + path: . From a78e3b0c7c6f9d562f2bac5246e3fb2f2f34c21d Mon Sep 17 00:00:00 2001 From: Djordje Lukic Date: Thu, 20 Jun 2019 14:12:28 +0200 Subject: [PATCH 3718/4072] Bump docker-py Signed-off-by: Djordje Lukic --- requirements.txt | 4 ++-- tests/acceptance/cli_test.py | 10 +++++----- tests/fixtures/environment-exec/docker-compose.yml | 2 +- tests/fixtures/links-composefile/docker-compose.yml | 6 +++--- tests/fixtures/simple-composefile/docker-compose.yml | 2 +- tests/fixtures/simple-dockerfile/Dockerfile | 2 +- tests/fixtures/v2-simple/docker-compose.yml | 4 ++-- 7 files changed, 15 insertions(+), 15 deletions(-) diff --git a/requirements.txt b/requirements.txt index 5e8ec2ed719..6007ee3fff7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ cached-property==1.3.0 certifi==2017.4.17 chardet==3.0.4 colorama==0.4.0; sys_platform == 'win32' -docker==3.7.2 +docker==3.7.3 docker-pycreds==0.4.0 dockerpty==0.4.1 docopt==0.6.2 @@ -21,4 +21,4 @@ requests==2.20.0 six==1.10.0 texttable==0.9.1 urllib3==1.21.1; python_version == '3.3' -websocket-client==0.32.0 +websocket-client==0.56.0 diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 5142f96eb34..9ed257369d8 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -40,7 +40,7 @@ BUILD_CACHE_TEXT = 'Using cache' -BUILD_PULL_TEXT = 'Status: Image is up to date for busybox:latest' +BUILD_PULL_TEXT = 'Status: Image is up to date for busybox:1.27.2' def start_process(base_dir, options): @@ -658,15 +658,15 @@ def test_pull_with_no_deps(self): self.base_dir = 'tests/fixtures/links-composefile' result = self.dispatch(['pull', '--no-parallel', 'web']) assert sorted(result.stderr.split('\n'))[1:] == [ - 'Pulling web (busybox:latest)...', + 'Pulling web (busybox:1.27.2)...', ] def test_pull_with_include_deps(self): self.base_dir = 'tests/fixtures/links-composefile' result = self.dispatch(['pull', '--no-parallel', '--include-deps', 'web']) assert sorted(result.stderr.split('\n'))[1:] == [ - 'Pulling db (busybox:latest)...', - 'Pulling web (busybox:latest)...', + 'Pulling db (busybox:1.27.2)...', + 'Pulling web (busybox:1.27.2)...', ] def test_build_plain(self): @@ -2592,7 +2592,7 @@ def has_timestamp(string): container, = self.project.containers() expected_template = ' container {} {}' - expected_meta_info = ['image=busybox:latest', 'name=simple-composefile_simple_'] + expected_meta_info = ['image=busybox:1.27.2', 'name=simple-composefile_simple_'] assert expected_template.format('create', container.id) in lines[0] assert expected_template.format('start', container.id) in lines[1] diff --git a/tests/fixtures/environment-exec/docker-compose.yml b/tests/fixtures/environment-exec/docker-compose.yml index 813606eb8b4..e284ba8cb36 100644 --- a/tests/fixtures/environment-exec/docker-compose.yml +++ b/tests/fixtures/environment-exec/docker-compose.yml @@ -2,7 +2,7 @@ version: "2.2" services: service: - image: busybox:latest + image: busybox:1.27.2 command: top environment: diff --git a/tests/fixtures/links-composefile/docker-compose.yml b/tests/fixtures/links-composefile/docker-compose.yml index 930fd4c7adf..0a2f3d9ef77 100644 --- a/tests/fixtures/links-composefile/docker-compose.yml +++ b/tests/fixtures/links-composefile/docker-compose.yml @@ -1,11 +1,11 @@ db: - image: busybox:latest + image: busybox:1.27.2 command: top web: - image: busybox:latest + image: busybox:1.27.2 command: top links: - db:db console: - image: busybox:latest + image: busybox:1.27.2 command: top diff --git a/tests/fixtures/simple-composefile/docker-compose.yml b/tests/fixtures/simple-composefile/docker-compose.yml index b25beaf4b75..e86d3fc80ea 100644 --- a/tests/fixtures/simple-composefile/docker-compose.yml +++ b/tests/fixtures/simple-composefile/docker-compose.yml @@ -1,5 +1,5 @@ simple: - image: busybox:latest + image: busybox:1.27.2 command: top another: image: busybox:latest diff --git a/tests/fixtures/simple-dockerfile/Dockerfile b/tests/fixtures/simple-dockerfile/Dockerfile index dd864b8387c..098ff3eb195 100644 --- a/tests/fixtures/simple-dockerfile/Dockerfile +++ b/tests/fixtures/simple-dockerfile/Dockerfile @@ -1,3 +1,3 @@ -FROM busybox:latest +FROM busybox:1.27.2 LABEL com.docker.compose.test_image=true CMD echo "success" diff --git a/tests/fixtures/v2-simple/docker-compose.yml b/tests/fixtures/v2-simple/docker-compose.yml index c99ae02fc76..ac754eeea0b 100644 --- a/tests/fixtures/v2-simple/docker-compose.yml +++ b/tests/fixtures/v2-simple/docker-compose.yml @@ -1,8 +1,8 @@ version: "2" services: simple: - image: busybox:latest + image: busybox:1.27.2 command: top another: - image: busybox:latest + image: busybox:1.27.2 command: top From 4667896b69e46991f5e93183fa70f1233f469414 Mon Sep 17 00:00:00 2001 From: Djordje Lukic Date: Mon, 24 Jun 2019 11:20:44 +0200 Subject: [PATCH 3719/4072] "Bump 1.24.1" Signed-off-by: Djordje Lukic --- CHANGELOG.md | 7 +++++++ compose/__init__.py | 2 +- script/run/run.sh | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 80f98be923b..8f777c6c5a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,13 @@ Change log ========== +1.24.1 (2019-06-24) +------------------- + +### Bugfixes + +- Fixed acceptance tests + 1.24.0 (2019-03-22) ------------------- diff --git a/compose/__init__.py b/compose/__init__.py index b66d2eb5877..6a40e150f26 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.24.0' +__version__ = '1.24.1' diff --git a/script/run/run.sh b/script/run/run.sh index a8690cad1fe..4881adc388e 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.24.0" +VERSION="1.24.1" IMAGE="docker/compose:$VERSION" From cacc9752a39418775282e931f018133e0fc41b1a Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Tue, 2 Jul 2019 13:42:41 +0200 Subject: [PATCH 3720/4072] Pin busybox image version in tests Signed-off-by: Ulysses Souza --- tests/acceptance/cli_test.py | 10 +++++----- tests/fixtures/environment-exec/docker-compose.yml | 2 +- tests/fixtures/links-composefile/docker-compose.yml | 6 +++--- tests/fixtures/simple-composefile/docker-compose.yml | 2 +- tests/fixtures/simple-dockerfile/Dockerfile | 2 +- tests/fixtures/v2-simple/docker-compose.yml | 4 ++-- 6 files changed, 13 insertions(+), 13 deletions(-) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 8e66c48e2da..fcb034a7c90 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -41,7 +41,7 @@ BUILD_CACHE_TEXT = 'Using cache' -BUILD_PULL_TEXT = 'Status: Image is up to date for busybox:latest' +BUILD_PULL_TEXT = 'Status: Image is up to date for busybox:1.27.2' def start_process(base_dir, options): @@ -688,15 +688,15 @@ def test_pull_with_no_deps(self): self.base_dir = 'tests/fixtures/links-composefile' result = self.dispatch(['pull', '--no-parallel', 'web']) assert sorted(result.stderr.split('\n'))[1:] == [ - 'Pulling web (busybox:latest)...', + 'Pulling web (busybox:1.27.2)...', ] def test_pull_with_include_deps(self): self.base_dir = 'tests/fixtures/links-composefile' result = self.dispatch(['pull', '--no-parallel', '--include-deps', 'web']) assert sorted(result.stderr.split('\n'))[1:] == [ - 'Pulling db (busybox:latest)...', - 'Pulling web (busybox:latest)...', + 'Pulling db (busybox:1.27.2)...', + 'Pulling web (busybox:1.27.2)...', ] def test_build_plain(self): @@ -2669,7 +2669,7 @@ def has_timestamp(string): container, = self.project.containers() expected_template = ' container {} {}' - expected_meta_info = ['image=busybox:latest', 'name=simple-composefile_simple_'] + expected_meta_info = ['image=busybox:1.27.2', 'name=simple-composefile_simple_'] assert expected_template.format('create', container.id) in lines[0] assert expected_template.format('start', container.id) in lines[1] diff --git a/tests/fixtures/environment-exec/docker-compose.yml b/tests/fixtures/environment-exec/docker-compose.yml index 813606eb8b4..e284ba8cb36 100644 --- a/tests/fixtures/environment-exec/docker-compose.yml +++ b/tests/fixtures/environment-exec/docker-compose.yml @@ -2,7 +2,7 @@ version: "2.2" services: service: - image: busybox:latest + image: busybox:1.27.2 command: top environment: diff --git a/tests/fixtures/links-composefile/docker-compose.yml b/tests/fixtures/links-composefile/docker-compose.yml index 930fd4c7adf..0a2f3d9ef77 100644 --- a/tests/fixtures/links-composefile/docker-compose.yml +++ b/tests/fixtures/links-composefile/docker-compose.yml @@ -1,11 +1,11 @@ db: - image: busybox:latest + image: busybox:1.27.2 command: top web: - image: busybox:latest + image: busybox:1.27.2 command: top links: - db:db console: - image: busybox:latest + image: busybox:1.27.2 command: top diff --git a/tests/fixtures/simple-composefile/docker-compose.yml b/tests/fixtures/simple-composefile/docker-compose.yml index b25beaf4b75..e86d3fc80ea 100644 --- a/tests/fixtures/simple-composefile/docker-compose.yml +++ b/tests/fixtures/simple-composefile/docker-compose.yml @@ -1,5 +1,5 @@ simple: - image: busybox:latest + image: busybox:1.27.2 command: top another: image: busybox:latest diff --git a/tests/fixtures/simple-dockerfile/Dockerfile b/tests/fixtures/simple-dockerfile/Dockerfile index dd864b8387c..098ff3eb195 100644 --- a/tests/fixtures/simple-dockerfile/Dockerfile +++ b/tests/fixtures/simple-dockerfile/Dockerfile @@ -1,3 +1,3 @@ -FROM busybox:latest +FROM busybox:1.27.2 LABEL com.docker.compose.test_image=true CMD echo "success" diff --git a/tests/fixtures/v2-simple/docker-compose.yml b/tests/fixtures/v2-simple/docker-compose.yml index c99ae02fc76..ac754eeea0b 100644 --- a/tests/fixtures/v2-simple/docker-compose.yml +++ b/tests/fixtures/v2-simple/docker-compose.yml @@ -1,8 +1,8 @@ version: "2" services: simple: - image: busybox:latest + image: busybox:1.27.2 command: top another: - image: busybox:latest + image: busybox:1.27.2 command: top From ce5451c5b4a3b449ce703168d2a568b0a4d25ee6 Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Tue, 2 Jul 2019 15:49:07 +0200 Subject: [PATCH 3721/4072] Strip up generic versions and bump requests Replaces generic limitations with a next major value Bump the minimal `requests` to 2.20.0 Signed-off-by: Ulysses Souza --- setup.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/setup.py b/setup.py index c6d07a86ae6..614478010cc 100644 --- a/setup.py +++ b/setup.py @@ -31,31 +31,31 @@ def find_version(*file_paths): install_requires = [ 'cached-property >= 1.2.0, < 2', - 'docopt >= 0.6.1, < 0.7', - 'PyYAML >= 3.10, < 4.3', - 'requests >= 2.6.1, != 2.11.0, != 2.12.2, != 2.18.0, < 2.23', - 'texttable >= 0.9.0, < 0.10', - 'websocket-client >= 0.32.0, < 1.0', - 'docker[ssh] >= 3.7.0, < 4.0.2', - 'dockerpty >= 0.4.1, < 0.5', + 'docopt >= 0.6.1, < 1', + 'PyYAML >= 3.10, < 5', + 'requests >= 2.20.0, < 3', + 'texttable >= 0.9.0, < 1', + 'websocket-client >= 0.32.0, < 1', + 'docker[ssh] >= 3.7.0, < 5', + 'dockerpty >= 0.4.1, < 1', 'six >= 1.3.0, < 2', 'jsonschema >= 2.5.1, < 3', ] tests_require = [ - 'pytest', + 'pytest < 6', ] if sys.version_info[:2] < (3, 4): - tests_require.append('mock >= 1.0.1') + tests_require.append('mock >= 1.0.1, < 2') extras_require = { ':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'], - ':sys_platform == "win32"': ['colorama >= 0.4, < 0.5'], + ':python_version < "3.5"': ['backports.ssl_match_hostname >= 3.5, < 4'], + ':python_version < "3.3"': ['ipaddress >= 1.0.16, < 2'], + ':sys_platform == "win32"': ['colorama >= 0.4, < 1'], 'socks': ['PySocks >= 1.5.6, != 1.5.7, < 2'], } From 57a2bb0c50a0d43ae472c8ba5580868620591ad0 Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Mon, 8 Jul 2019 13:47:19 +0200 Subject: [PATCH 3722/4072] Bump mock from 2.0.0 to 3.0.5 Signed-off-by: Ulysses Souza --- requirements-dev.txt | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index b19fc2e0129..27b71a26873 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,6 +1,6 @@ coverage==4.4.2 ddt==1.2.0 flake8==3.5.0 -mock==2.0.0 +mock==3.0.5 pytest==3.6.3 pytest-cov==2.5.1 diff --git a/setup.py b/setup.py index 614478010cc..4ceb6e1f88a 100644 --- a/setup.py +++ b/setup.py @@ -49,7 +49,7 @@ def find_version(*file_paths): if sys.version_info[:2] < (3, 4): - tests_require.append('mock >= 1.0.1, < 2') + tests_require.append('mock >= 1.0.1, < 4') extras_require = { ':python_version < "3.4"': ['enum34 >= 1.0.4, < 2'], From 0bfa1c34f054d86674434770d4d6340e02508e52 Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Mon, 8 Jul 2019 14:52:30 +0200 Subject: [PATCH 3723/4072] Bump texttable from 0.9.1 to 1.6.2 Signed-off-by: Ulysses Souza --- requirements.txt | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index ff23516e151..e5b6883e9ad 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,6 +19,6 @@ PySocks==1.6.7 PyYAML==4.2b1 requests==2.22.0 six==1.10.0 -texttable==0.9.1 +texttable==1.6.2 urllib3==1.24.2; python_version == '3.3' websocket-client==0.32.0 diff --git a/setup.py b/setup.py index 614478010cc..c9e4729d867 100644 --- a/setup.py +++ b/setup.py @@ -34,7 +34,7 @@ def find_version(*file_paths): 'docopt >= 0.6.1, < 1', 'PyYAML >= 3.10, < 5', 'requests >= 2.20.0, < 3', - 'texttable >= 0.9.0, < 1', + 'texttable >= 0.9.0, < 2', 'websocket-client >= 0.32.0, < 1', 'docker[ssh] >= 3.7.0, < 5', 'dockerpty >= 0.4.1, < 1', From 3d80c8e86dec5724ef50154e4448eb018b6b035b Mon Sep 17 00:00:00 2001 From: Christopher Crone Date: Mon, 8 Jul 2019 15:04:34 +0200 Subject: [PATCH 3724/4072] Bump macOS build dependencies * OpenSSL 1.1.1a to 1.1.1c * Python 3.7.2 to 3.7.3 Signed-off-by: Christopher Crone --- script/setup/osx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/script/setup/osx b/script/setup/osx index 1fb91edca5e..ea707994450 100755 --- a/script/setup/osx +++ b/script/setup/osx @@ -13,13 +13,13 @@ if ! [ ${DEPLOYMENT_TARGET} == "$(macos_version)" ]; then SDK_SHA1=dd228a335194e3392f1904ce49aff1b1da26ca62 fi -OPENSSL_VERSION=1.1.1a +OPENSSL_VERSION=1.1.1c OPENSSL_URL=https://www.openssl.org/source/openssl-${OPENSSL_VERSION}.tar.gz -OPENSSL_SHA1=8fae27b4f34445a5500c9dc50ae66b4d6472ce29 +OPENSSL_SHA1=71b830a077276cbeccc994369538617a21bee808 -PYTHON_VERSION=3.7.2 +PYTHON_VERSION=3.7.3 PYTHON_URL=https://www.python.org/ftp/python/${PYTHON_VERSION}/Python-${PYTHON_VERSION}.tgz -PYTHON_SHA1=0cd8e52d8ed1d0be12ac8e87a623a15df3a3b418 +PYTHON_SHA1=77fd17e15ef06a7f6c5c0eab2909dc4bd81678d6 # # Install prerequisites. From b0e7d801a309c38430d55aa1047eeac33ef54501 Mon Sep 17 00:00:00 2001 From: Christopher Crone Date: Wed, 10 Jul 2019 17:02:27 +0200 Subject: [PATCH 3725/4072] Bump macOS build dependency * Python 3.7.3 to 3.7.4 Signed-off-by: Christopher Crone --- script/setup/osx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/script/setup/osx b/script/setup/osx index ea707994450..69280f8a298 100755 --- a/script/setup/osx +++ b/script/setup/osx @@ -17,9 +17,9 @@ OPENSSL_VERSION=1.1.1c OPENSSL_URL=https://www.openssl.org/source/openssl-${OPENSSL_VERSION}.tar.gz OPENSSL_SHA1=71b830a077276cbeccc994369538617a21bee808 -PYTHON_VERSION=3.7.3 +PYTHON_VERSION=3.7.4 PYTHON_URL=https://www.python.org/ftp/python/${PYTHON_VERSION}/Python-${PYTHON_VERSION}.tgz -PYTHON_SHA1=77fd17e15ef06a7f6c5c0eab2909dc4bd81678d6 +PYTHON_SHA1=fb1d764be8a9dcd40f2f152a610a0ab04e0d0ed3 # # Install prerequisites. From 993bada521a325910032399f51e8773aa6a90eaf Mon Sep 17 00:00:00 2001 From: Christopher Crone Date: Wed, 10 Jul 2019 17:29:05 +0200 Subject: [PATCH 3726/4072] Bump Linux build dependencies * Python 3.7.2 to 3.7.4 * Docker 18.09.5 to 18.09.7 * Alpine 3.9.3 to 3.10.0 * Debian stretch-20190326 to stretch-20190708 Signed-off-by: Christopher Crone --- Dockerfile | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index 1a3c501aefa..ed9d74e5ea8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,9 @@ -ARG DOCKER_VERSION=18.09.5 -ARG PYTHON_VERSION=3.7.3 -ARG BUILD_ALPINE_VERSION=3.9 +ARG DOCKER_VERSION=18.09.7 +ARG PYTHON_VERSION=3.7.4 +ARG BUILD_ALPINE_VERSION=3.10 ARG BUILD_DEBIAN_VERSION=slim-stretch -ARG RUNTIME_ALPINE_VERSION=3.9.3 -ARG RUNTIME_DEBIAN_VERSION=stretch-20190326-slim +ARG RUNTIME_ALPINE_VERSION=3.10.0 +ARG RUNTIME_DEBIAN_VERSION=stretch-20190708-slim ARG BUILD_PLATFORM=alpine From f9099c91ae9e615eb5c7150f4f20520d966967a4 Mon Sep 17 00:00:00 2001 From: Goryudyuma Date: Mon, 3 Jun 2019 21:57:07 +0900 Subject: [PATCH 3727/4072] fix: The correct number is displayed Signed-off-by: Kei Matsumoto --- 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 4cc055cc998..bd06beef84f 100644 --- a/compose/cli/utils.py +++ b/compose/cli/utils.py @@ -137,7 +137,7 @@ def human_readable_file_size(size): if order >= len(suffixes): order = len(suffixes) - 1 - return '{0:.3g} {1}'.format( + return '{0:.4g} {1}'.format( size / float(1 << (order * 10)), suffixes[order] ) From 75d41edb94d5dda7b5b7c476c1ba248d6e838755 Mon Sep 17 00:00:00 2001 From: Kei Matsumoto Date: Wed, 10 Jul 2019 12:10:19 +0900 Subject: [PATCH 3728/4072] fix: Add test Signed-off-by: Kei Matsumoto --- tests/unit/cli/utils_test.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/unit/cli/utils_test.py b/tests/unit/cli/utils_test.py index 26524ff3769..25912f77a13 100644 --- a/tests/unit/cli/utils_test.py +++ b/tests/unit/cli/utils_test.py @@ -4,6 +4,7 @@ import unittest from compose.utils import unquote_path +from compose.cli.utils import human_readable_file_size class UnquotePathTest(unittest.TestCase): @@ -21,3 +22,14 @@ def test_nested_quotes(self): assert unquote_path('""hello""') == '"hello"' assert unquote_path('"hel"lo"') == 'hel"lo' assert unquote_path('"hello""') == 'hello"' + + +class HumanReadableFileSizeTest(unittest.TestCase): + def test_100b(self): + assert human_readable_file_size(100) == '100 B' + + def test_1kb(self): + assert human_readable_file_size(1024) == '1 kB' + + def test_1023b(self): + assert human_readable_file_size(1023) == '1023 B' From 59491c7d775b8a2f86532aa37fd4cc72b2b9459d Mon Sep 17 00:00:00 2001 From: Goryudyuma Date: Sun, 14 Jul 2019 03:24:18 +0900 Subject: [PATCH 3729/4072] add: test for units Signed-off-by: Kei Matsumoto --- tests/unit/cli/utils_test.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/unit/cli/utils_test.py b/tests/unit/cli/utils_test.py index 25912f77a13..b340fb94711 100644 --- a/tests/unit/cli/utils_test.py +++ b/tests/unit/cli/utils_test.py @@ -3,8 +3,8 @@ import unittest -from compose.utils import unquote_path from compose.cli.utils import human_readable_file_size +from compose.utils import unquote_path class UnquotePathTest(unittest.TestCase): @@ -33,3 +33,12 @@ def test_1kb(self): def test_1023b(self): assert human_readable_file_size(1023) == '1023 B' + + def test_units(self): + assert human_readable_file_size((2 ** 10) ** 0) == '1 B' + assert human_readable_file_size((2 ** 10) ** 1) == '1 kB' + assert human_readable_file_size((2 ** 10) ** 2) == '1 MB' + assert human_readable_file_size((2 ** 10) ** 3) == '1 GB' + assert human_readable_file_size((2 ** 10) ** 4) == '1 TB' + assert human_readable_file_size((2 ** 10) ** 5) == '1 PB' + assert human_readable_file_size((2 ** 10) ** 6) == '1 EB' From cd098e0cad4a0155b37179a77c6759ec33be0d61 Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Mon, 15 Jul 2019 18:56:04 +0200 Subject: [PATCH 3730/4072] Pin test images on a non rolling tag Mainly busybox:latest to the current latest which is 1.31.0-uclibc Signed-off-by: Ulysses Souza --- tests/acceptance/cli_test.py | 8 +- .../fixtures/UpperCaseDir/docker-compose.yml | 4 +- .../docker-compose.yml | 4 +- .../docker-compose.yml | 4 +- tests/fixtures/build-args/Dockerfile | 2 +- tests/fixtures/build-ctx/Dockerfile | 2 +- tests/fixtures/build-memory/Dockerfile | 2 +- .../build-multiple-composefile/a/Dockerfile | 2 +- .../build-multiple-composefile/b/Dockerfile | 2 +- .../dockerfile-with-volume/Dockerfile | 2 +- .../docker-compose.yml | 4 +- .../fixtures/echo-services/docker-compose.yml | 4 +- .../fixtures/entrypoint-dockerfile/Dockerfile | 2 +- .../docker-compose.yml | 2 +- .../exit-code-from/docker-compose.yml | 4 +- .../expose-composefile/docker-compose.yml | 2 +- tests/fixtures/images-service-tag/Dockerfile | 2 +- .../docker-compose.yml | 4 +- .../logging-composefile/docker-compose.yml | 4 +- .../logs-composefile/docker-compose.yml | 4 +- .../docker-compose.yml | 4 +- .../logs-tail-composefile/docker-compose.yml | 2 +- .../docker-compose.yaml | 2 +- .../multiple-composefiles/compose2.yml | 2 +- .../multiple-composefiles/docker-compose.yml | 4 +- .../networks/default-network-config.yml | 4 +- tests/fixtures/networks/external-default.yml | 4 +- .../no-links-composefile/docker-compose.yml | 6 +- .../override-files/docker-compose.yml | 4 +- tests/fixtures/override-files/extra.yml | 2 +- .../override-yaml-files/docker-compose.yml | 4 +- .../docker-compose.yml | 2 +- .../ports-composefile/docker-compose.yml | 2 +- .../ports-composefile/expanded-notation.yml | 2 +- .../ps-services-filter/docker-compose.yml | 2 +- tests/fixtures/run-labels/docker-compose.yml | 2 +- tests/fixtures/run-workdir/docker-compose.yml | 2 +- .../docker-compose.merge.yml | 2 +- .../docker-compose.yml | 2 +- tests/fixtures/simple-composefile/digest.yml | 2 +- .../simple-composefile/docker-compose.yml | 2 +- .../ignore-pull-failures.yml | 2 +- .../simple-composefile/pull-with-build.yml | 2 +- .../simple-failing-dockerfile/Dockerfile | 2 +- .../sleeps-composefile/docker-compose.yml | 4 +- .../docker-compose.yml | 2 +- tests/fixtures/tagless-image/Dockerfile | 2 +- tests/fixtures/top/docker-compose.yml | 4 +- .../unicode-environment/docker-compose.yml | 2 +- .../user-composefile/docker-compose.yml | 2 +- .../v2-dependencies/docker-compose.yml | 6 +- tests/fixtures/v2-full/Dockerfile | 2 +- tests/fixtures/v2-full/docker-compose.yml | 2 +- tests/fixtures/v2-simple/links-invalid.yml | 4 +- tests/fixtures/v2-simple/one-container.yml | 2 +- tests/helpers.py | 6 +- tests/integration/environment_test.py | 2 +- tests/integration/project_test.py | 129 +++++++++--------- tests/integration/service_test.py | 10 +- tests/integration/state_test.py | 21 +-- tests/integration/testcases.py | 5 +- tests/unit/config/config_test.py | 29 ++-- tests/unit/container_test.py | 7 +- tests/unit/project_test.py | 61 +++++---- tests/unit/service_test.py | 2 +- 65 files changed, 222 insertions(+), 210 deletions(-) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 16789346815..3fd857f26cb 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -20,6 +20,7 @@ from docker import errors from .. import mock +from ..helpers import BUSYBOX_IMAGE_WITH_TAG from ..helpers import create_host_file from compose.cli.command import get_project from compose.config.errors import DuplicateOverrideFileFound @@ -266,7 +267,7 @@ def test_config_default(self): 'volumes_from': ['service:other:rw'], }, 'other': { - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'command': 'top', 'volumes': ['/data'], }, @@ -639,7 +640,7 @@ def test_pull(self): def test_pull_with_digest(self): result = self.dispatch(['-f', 'digest.yml', 'pull', '--no-parallel']) - assert 'Pulling simple (busybox:latest)...' in result.stderr + assert 'Pulling simple ({})...'.format(BUSYBOX_IMAGE_WITH_TAG) in result.stderr assert ('Pulling digest (busybox@' 'sha256:38a203e1986cf79639cfb9b2e1d6e773de84002feea2d4eb006b520' '04ee8502d)...') in result.stderr @@ -650,7 +651,7 @@ def test_pull_with_ignore_pull_failures(self): 'pull', '--ignore-pull-failures', '--no-parallel'] ) - assert 'Pulling simple (busybox:latest)...' in result.stderr + assert 'Pulling simple ({})...'.format(BUSYBOX_IMAGE_WITH_TAG) in result.stderr assert 'Pulling another (nonexisting-image:latest)...' in result.stderr assert ('repository nonexisting-image not found' in result.stderr or 'image library/nonexisting-image:latest not found' in result.stderr or @@ -777,6 +778,7 @@ def test_build_failed_forcerm(self): ] assert not containers + @pytest.mark.xfail(True, reason='Flaky on local') def test_build_rm(self): containers = [ Container.from_ps(self.project.client, c) diff --git a/tests/fixtures/UpperCaseDir/docker-compose.yml b/tests/fixtures/UpperCaseDir/docker-compose.yml index b25beaf4b75..09cc9519fed 100644 --- a/tests/fixtures/UpperCaseDir/docker-compose.yml +++ b/tests/fixtures/UpperCaseDir/docker-compose.yml @@ -1,6 +1,6 @@ simple: - image: busybox:latest + image: busybox:1.31.0-uclibc command: top another: - image: busybox:latest + image: busybox:1.31.0-uclibc command: top diff --git a/tests/fixtures/abort-on-container-exit-0/docker-compose.yml b/tests/fixtures/abort-on-container-exit-0/docker-compose.yml index ce41697bcd9..77307ef29cd 100644 --- a/tests/fixtures/abort-on-container-exit-0/docker-compose.yml +++ b/tests/fixtures/abort-on-container-exit-0/docker-compose.yml @@ -1,6 +1,6 @@ simple: - image: busybox:latest + image: busybox:1.31.0-uclibc command: top another: - image: busybox:latest + image: busybox:1.31.0-uclibc command: ls . diff --git a/tests/fixtures/abort-on-container-exit-1/docker-compose.yml b/tests/fixtures/abort-on-container-exit-1/docker-compose.yml index 7ec9b7e117f..23290964e51 100644 --- a/tests/fixtures/abort-on-container-exit-1/docker-compose.yml +++ b/tests/fixtures/abort-on-container-exit-1/docker-compose.yml @@ -1,6 +1,6 @@ simple: - image: busybox:latest + image: busybox:1.31.0-uclibc command: top another: - image: busybox:latest + image: busybox:1.31.0-uclibc command: ls /thecakeisalie diff --git a/tests/fixtures/build-args/Dockerfile b/tests/fixtures/build-args/Dockerfile index 93ebcb9cd6f..d1534068ae6 100644 --- a/tests/fixtures/build-args/Dockerfile +++ b/tests/fixtures/build-args/Dockerfile @@ -1,4 +1,4 @@ -FROM busybox:latest +FROM busybox:1.31.0-uclibc LABEL com.docker.compose.test_image=true ARG favorite_th_character RUN echo "Favorite Touhou Character: ${favorite_th_character}" diff --git a/tests/fixtures/build-ctx/Dockerfile b/tests/fixtures/build-ctx/Dockerfile index dd864b8387c..4acac9c7ac4 100644 --- a/tests/fixtures/build-ctx/Dockerfile +++ b/tests/fixtures/build-ctx/Dockerfile @@ -1,3 +1,3 @@ -FROM busybox:latest +FROM busybox:1.31.0-uclibc LABEL com.docker.compose.test_image=true CMD echo "success" diff --git a/tests/fixtures/build-memory/Dockerfile b/tests/fixtures/build-memory/Dockerfile index b27349b9659..076b84d771e 100644 --- a/tests/fixtures/build-memory/Dockerfile +++ b/tests/fixtures/build-memory/Dockerfile @@ -1,4 +1,4 @@ -FROM busybox +FROM busybox:1.31.0-uclibc # Report the memory (through the size of the group memory) RUN echo "memory:" $(cat /sys/fs/cgroup/memory/memory.limit_in_bytes) diff --git a/tests/fixtures/build-multiple-composefile/a/Dockerfile b/tests/fixtures/build-multiple-composefile/a/Dockerfile index 2ba45ce55f0..52ed15ec671 100644 --- a/tests/fixtures/build-multiple-composefile/a/Dockerfile +++ b/tests/fixtures/build-multiple-composefile/a/Dockerfile @@ -1,4 +1,4 @@ -FROM busybox:latest +FROM busybox:1.31.0-uclibc RUN echo a CMD top diff --git a/tests/fixtures/build-multiple-composefile/b/Dockerfile b/tests/fixtures/build-multiple-composefile/b/Dockerfile index e282e8bbfba..932d851d98a 100644 --- a/tests/fixtures/build-multiple-composefile/b/Dockerfile +++ b/tests/fixtures/build-multiple-composefile/b/Dockerfile @@ -1,4 +1,4 @@ -FROM busybox:latest +FROM busybox:1.31.0-uclibc RUN echo b CMD top diff --git a/tests/fixtures/dockerfile-with-volume/Dockerfile b/tests/fixtures/dockerfile-with-volume/Dockerfile index 0d376ec4846..f38e1d579ec 100644 --- a/tests/fixtures/dockerfile-with-volume/Dockerfile +++ b/tests/fixtures/dockerfile-with-volume/Dockerfile @@ -1,4 +1,4 @@ -FROM busybox:latest +FROM busybox:1.31.0-uclibc LABEL com.docker.compose.test_image=true VOLUME /data CMD top diff --git a/tests/fixtures/duplicate-override-yaml-files/docker-compose.yml b/tests/fixtures/duplicate-override-yaml-files/docker-compose.yml index 5f2909d69e7..6880435b384 100644 --- a/tests/fixtures/duplicate-override-yaml-files/docker-compose.yml +++ b/tests/fixtures/duplicate-override-yaml-files/docker-compose.yml @@ -1,10 +1,10 @@ web: - image: busybox:latest + image: busybox:1.31.0-uclibc command: "sleep 100" links: - db db: - image: busybox:latest + image: busybox:1.31.0-uclibc command: "sleep 200" diff --git a/tests/fixtures/echo-services/docker-compose.yml b/tests/fixtures/echo-services/docker-compose.yml index 8014f3d9167..75fc45d95bb 100644 --- a/tests/fixtures/echo-services/docker-compose.yml +++ b/tests/fixtures/echo-services/docker-compose.yml @@ -1,6 +1,6 @@ simple: - image: busybox:latest + image: busybox:1.31.0-uclibc command: echo simple another: - image: busybox:latest + image: busybox:1.31.0-uclibc command: echo another diff --git a/tests/fixtures/entrypoint-dockerfile/Dockerfile b/tests/fixtures/entrypoint-dockerfile/Dockerfile index 49f4416c8cc..30ec50bac09 100644 --- a/tests/fixtures/entrypoint-dockerfile/Dockerfile +++ b/tests/fixtures/entrypoint-dockerfile/Dockerfile @@ -1,4 +1,4 @@ -FROM busybox:latest +FROM busybox:1.31.0-uclibc LABEL com.docker.compose.test_image=true ENTRYPOINT ["printf"] CMD ["default", "args"] diff --git a/tests/fixtures/environment-composefile/docker-compose.yml b/tests/fixtures/environment-composefile/docker-compose.yml index 9d99fee088c..5650c7c8e7d 100644 --- a/tests/fixtures/environment-composefile/docker-compose.yml +++ b/tests/fixtures/environment-composefile/docker-compose.yml @@ -1,5 +1,5 @@ service: - image: busybox:latest + image: busybox:1.31.0-uclibc command: top environment: diff --git a/tests/fixtures/exit-code-from/docker-compose.yml b/tests/fixtures/exit-code-from/docker-compose.yml index 687e78b97c6..c38bd549b5b 100644 --- a/tests/fixtures/exit-code-from/docker-compose.yml +++ b/tests/fixtures/exit-code-from/docker-compose.yml @@ -1,6 +1,6 @@ simple: - image: busybox:latest + image: busybox:1.31.0-uclibc command: sh -c "echo hello && tail -f /dev/null" another: - image: busybox:latest + image: busybox:1.31.0-uclibc command: /bin/false diff --git a/tests/fixtures/expose-composefile/docker-compose.yml b/tests/fixtures/expose-composefile/docker-compose.yml index d14a468dec8..c2a3dc42490 100644 --- a/tests/fixtures/expose-composefile/docker-compose.yml +++ b/tests/fixtures/expose-composefile/docker-compose.yml @@ -1,6 +1,6 @@ simple: - image: busybox:latest + image: busybox:1.31.0-uclibc command: top expose: - '3000' diff --git a/tests/fixtures/images-service-tag/Dockerfile b/tests/fixtures/images-service-tag/Dockerfile index 145e0202f05..1e1a1b2e0e9 100644 --- a/tests/fixtures/images-service-tag/Dockerfile +++ b/tests/fixtures/images-service-tag/Dockerfile @@ -1,2 +1,2 @@ -FROM busybox:latest +FROM busybox:1.31.0-uclibc RUN touch /foo diff --git a/tests/fixtures/logging-composefile-legacy/docker-compose.yml b/tests/fixtures/logging-composefile-legacy/docker-compose.yml index ee99410790d..efac1d6a353 100644 --- a/tests/fixtures/logging-composefile-legacy/docker-compose.yml +++ b/tests/fixtures/logging-composefile-legacy/docker-compose.yml @@ -1,9 +1,9 @@ simple: - image: busybox:latest + image: busybox:1.31.0-uclibc command: top log_driver: "none" another: - image: busybox:latest + image: busybox:1.31.0-uclibc command: top log_driver: "json-file" log_opt: diff --git a/tests/fixtures/logging-composefile/docker-compose.yml b/tests/fixtures/logging-composefile/docker-compose.yml index 466d13e5b8f..ac231b89862 100644 --- a/tests/fixtures/logging-composefile/docker-compose.yml +++ b/tests/fixtures/logging-composefile/docker-compose.yml @@ -1,12 +1,12 @@ version: "2" services: simple: - image: busybox:latest + image: busybox:1.31.0-uclibc command: top logging: driver: "none" another: - image: busybox:latest + image: busybox:1.31.0-uclibc command: top logging: driver: "json-file" diff --git a/tests/fixtures/logs-composefile/docker-compose.yml b/tests/fixtures/logs-composefile/docker-compose.yml index ea18f162dd0..3ffaa98491f 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 + image: busybox:1.31.0-uclibc command: sh -c "sleep 1 && echo hello && tail -f /dev/null" another: - image: busybox:latest + image: busybox:1.31.0-uclibc command: sh -c "sleep 1 && echo test" diff --git a/tests/fixtures/logs-restart-composefile/docker-compose.yml b/tests/fixtures/logs-restart-composefile/docker-compose.yml index 6be8b9079cb..2179d54de4d 100644 --- a/tests/fixtures/logs-restart-composefile/docker-compose.yml +++ b/tests/fixtures/logs-restart-composefile/docker-compose.yml @@ -1,7 +1,7 @@ simple: - image: busybox:latest + image: busybox:1.31.0-uclibc command: sh -c "echo hello && tail -f /dev/null" another: - image: busybox:latest + image: busybox:1.31.0-uclibc command: sh -c "sleep 2 && echo world && /bin/false" restart: "on-failure:2" diff --git a/tests/fixtures/logs-tail-composefile/docker-compose.yml b/tests/fixtures/logs-tail-composefile/docker-compose.yml index b70d0cc6344..18dad986ec7 100644 --- a/tests/fixtures/logs-tail-composefile/docker-compose.yml +++ b/tests/fixtures/logs-tail-composefile/docker-compose.yml @@ -1,3 +1,3 @@ simple: - image: busybox:latest + image: busybox:1.31.0-uclibc command: sh -c "echo w && echo x && echo y && echo z" diff --git a/tests/fixtures/longer-filename-composefile/docker-compose.yaml b/tests/fixtures/longer-filename-composefile/docker-compose.yaml index a4eba2d05da..5dadce44a63 100644 --- a/tests/fixtures/longer-filename-composefile/docker-compose.yaml +++ b/tests/fixtures/longer-filename-composefile/docker-compose.yaml @@ -1,3 +1,3 @@ definedinyamlnotyml: - image: busybox:latest + image: busybox:1.31.0-uclibc command: top diff --git a/tests/fixtures/multiple-composefiles/compose2.yml b/tests/fixtures/multiple-composefiles/compose2.yml index 56803380407..530d92df685 100644 --- a/tests/fixtures/multiple-composefiles/compose2.yml +++ b/tests/fixtures/multiple-composefiles/compose2.yml @@ -1,3 +1,3 @@ yetanother: - image: busybox:latest + image: busybox:1.31.0-uclibc command: top diff --git a/tests/fixtures/multiple-composefiles/docker-compose.yml b/tests/fixtures/multiple-composefiles/docker-compose.yml index b25beaf4b75..09cc9519fed 100644 --- a/tests/fixtures/multiple-composefiles/docker-compose.yml +++ b/tests/fixtures/multiple-composefiles/docker-compose.yml @@ -1,6 +1,6 @@ simple: - image: busybox:latest + image: busybox:1.31.0-uclibc command: top another: - image: busybox:latest + image: busybox:1.31.0-uclibc command: top diff --git a/tests/fixtures/networks/default-network-config.yml b/tests/fixtures/networks/default-network-config.yml index 4bd0989b741..556ca9805d6 100644 --- a/tests/fixtures/networks/default-network-config.yml +++ b/tests/fixtures/networks/default-network-config.yml @@ -1,10 +1,10 @@ version: "2" services: simple: - image: busybox:latest + image: busybox:1.31.0-uclibc command: top another: - image: busybox:latest + image: busybox:1.31.0-uclibc command: top networks: default: diff --git a/tests/fixtures/networks/external-default.yml b/tests/fixtures/networks/external-default.yml index 5c9426b8464..42a39565675 100644 --- a/tests/fixtures/networks/external-default.yml +++ b/tests/fixtures/networks/external-default.yml @@ -1,10 +1,10 @@ version: "2" services: simple: - image: busybox:latest + image: busybox:1.31.0-uclibc command: top another: - image: busybox:latest + image: busybox:1.31.0-uclibc command: top networks: default: diff --git a/tests/fixtures/no-links-composefile/docker-compose.yml b/tests/fixtures/no-links-composefile/docker-compose.yml index 75a6a085cdb..54936f30617 100644 --- a/tests/fixtures/no-links-composefile/docker-compose.yml +++ b/tests/fixtures/no-links-composefile/docker-compose.yml @@ -1,9 +1,9 @@ db: - image: busybox:latest + image: busybox:1.31.0-uclibc command: top web: - image: busybox:latest + image: busybox:1.31.0-uclibc command: top console: - image: busybox:latest + image: busybox:1.31.0-uclibc command: top diff --git a/tests/fixtures/override-files/docker-compose.yml b/tests/fixtures/override-files/docker-compose.yml index 6c3d4e17230..0119ec73817 100644 --- a/tests/fixtures/override-files/docker-compose.yml +++ b/tests/fixtures/override-files/docker-compose.yml @@ -1,10 +1,10 @@ version: '2.2' services: web: - image: busybox:latest + image: busybox:1.31.0-uclibc command: "sleep 200" depends_on: - db db: - image: busybox:latest + image: busybox:1.31.0-uclibc command: "sleep 200" diff --git a/tests/fixtures/override-files/extra.yml b/tests/fixtures/override-files/extra.yml index 492c379526e..d03c5096d55 100644 --- a/tests/fixtures/override-files/extra.yml +++ b/tests/fixtures/override-files/extra.yml @@ -6,5 +6,5 @@ services: - other other: - image: busybox:latest + image: busybox:1.31.0-uclibc command: "top" diff --git a/tests/fixtures/override-yaml-files/docker-compose.yml b/tests/fixtures/override-yaml-files/docker-compose.yml index 5f2909d69e7..6880435b384 100644 --- a/tests/fixtures/override-yaml-files/docker-compose.yml +++ b/tests/fixtures/override-yaml-files/docker-compose.yml @@ -1,10 +1,10 @@ web: - image: busybox:latest + image: busybox:1.31.0-uclibc command: "sleep 100" links: - db db: - image: busybox:latest + image: busybox:1.31.0-uclibc command: "sleep 200" diff --git a/tests/fixtures/ports-composefile-scale/docker-compose.yml b/tests/fixtures/ports-composefile-scale/docker-compose.yml index 1a2bb485bc7..bdd39cef3e5 100644 --- a/tests/fixtures/ports-composefile-scale/docker-compose.yml +++ b/tests/fixtures/ports-composefile-scale/docker-compose.yml @@ -1,6 +1,6 @@ simple: - image: busybox:latest + image: busybox:1.31.0-uclibc command: /bin/sleep 300 ports: - '3000' diff --git a/tests/fixtures/ports-composefile/docker-compose.yml b/tests/fixtures/ports-composefile/docker-compose.yml index c213068defb..f4987027846 100644 --- a/tests/fixtures/ports-composefile/docker-compose.yml +++ b/tests/fixtures/ports-composefile/docker-compose.yml @@ -1,6 +1,6 @@ simple: - image: busybox:latest + image: busybox:1.31.0-uclibc command: top ports: - '3000' diff --git a/tests/fixtures/ports-composefile/expanded-notation.yml b/tests/fixtures/ports-composefile/expanded-notation.yml index 09a7a2bf998..6510e4281f0 100644 --- a/tests/fixtures/ports-composefile/expanded-notation.yml +++ b/tests/fixtures/ports-composefile/expanded-notation.yml @@ -1,7 +1,7 @@ version: '3.2' services: simple: - image: busybox:latest + image: busybox:1.31.0-uclibc command: top ports: - target: 3000 diff --git a/tests/fixtures/ps-services-filter/docker-compose.yml b/tests/fixtures/ps-services-filter/docker-compose.yml index 3d86093731a..180f515aaef 100644 --- a/tests/fixtures/ps-services-filter/docker-compose.yml +++ b/tests/fixtures/ps-services-filter/docker-compose.yml @@ -1,5 +1,5 @@ with_image: - image: busybox:latest + image: busybox:1.31.0-uclibc command: top with_build: build: ../build-ctx/ diff --git a/tests/fixtures/run-labels/docker-compose.yml b/tests/fixtures/run-labels/docker-compose.yml index e8cd5006556..e3b237fd51a 100644 --- a/tests/fixtures/run-labels/docker-compose.yml +++ b/tests/fixtures/run-labels/docker-compose.yml @@ -1,5 +1,5 @@ service: - image: busybox:latest + image: busybox:1.31.0-uclibc command: top labels: diff --git a/tests/fixtures/run-workdir/docker-compose.yml b/tests/fixtures/run-workdir/docker-compose.yml index dc3ea86a0fd..9d092a55fcb 100644 --- a/tests/fixtures/run-workdir/docker-compose.yml +++ b/tests/fixtures/run-workdir/docker-compose.yml @@ -1,4 +1,4 @@ service: - image: busybox:latest + image: busybox:1.31.0-uclibc working_dir: /etc command: /bin/true diff --git a/tests/fixtures/simple-composefile-volume-ready/docker-compose.merge.yml b/tests/fixtures/simple-composefile-volume-ready/docker-compose.merge.yml index fe717151677..45b626d02a1 100644 --- a/tests/fixtures/simple-composefile-volume-ready/docker-compose.merge.yml +++ b/tests/fixtures/simple-composefile-volume-ready/docker-compose.merge.yml @@ -1,7 +1,7 @@ version: '2.2' services: simple: - image: busybox:latest + image: busybox:1.31.0-uclibc volumes: - datastore:/data1 diff --git a/tests/fixtures/simple-composefile-volume-ready/docker-compose.yml b/tests/fixtures/simple-composefile-volume-ready/docker-compose.yml index 98a7d23b723..088d71c9990 100644 --- a/tests/fixtures/simple-composefile-volume-ready/docker-compose.yml +++ b/tests/fixtures/simple-composefile-volume-ready/docker-compose.yml @@ -1,2 +1,2 @@ simple: - image: busybox:latest + image: busybox:1.31.0-uclibc diff --git a/tests/fixtures/simple-composefile/digest.yml b/tests/fixtures/simple-composefile/digest.yml index 08f1d993e95..79f043baa02 100644 --- a/tests/fixtures/simple-composefile/digest.yml +++ b/tests/fixtures/simple-composefile/digest.yml @@ -1,5 +1,5 @@ simple: - image: busybox:latest + image: busybox:1.31.0-uclibc command: top digest: image: busybox@sha256:38a203e1986cf79639cfb9b2e1d6e773de84002feea2d4eb006b52004ee8502d diff --git a/tests/fixtures/simple-composefile/docker-compose.yml b/tests/fixtures/simple-composefile/docker-compose.yml index e86d3fc80ea..b66a065275b 100644 --- a/tests/fixtures/simple-composefile/docker-compose.yml +++ b/tests/fixtures/simple-composefile/docker-compose.yml @@ -2,5 +2,5 @@ simple: image: busybox:1.27.2 command: top another: - image: busybox:latest + image: busybox:1.31.0-uclibc command: top diff --git a/tests/fixtures/simple-composefile/ignore-pull-failures.yml b/tests/fixtures/simple-composefile/ignore-pull-failures.yml index a28f7922330..7e7d560dac4 100644 --- a/tests/fixtures/simple-composefile/ignore-pull-failures.yml +++ b/tests/fixtures/simple-composefile/ignore-pull-failures.yml @@ -1,5 +1,5 @@ simple: - image: busybox:latest + image: busybox:1.31.0-uclibc command: top another: image: nonexisting-image:latest diff --git a/tests/fixtures/simple-composefile/pull-with-build.yml b/tests/fixtures/simple-composefile/pull-with-build.yml index 261dc44dfd0..3bff35c515a 100644 --- a/tests/fixtures/simple-composefile/pull-with-build.yml +++ b/tests/fixtures/simple-composefile/pull-with-build.yml @@ -7,5 +7,5 @@ services: from_simple: image: simple another: - image: busybox:latest + image: busybox:1.31.0-uclibc command: top diff --git a/tests/fixtures/simple-failing-dockerfile/Dockerfile b/tests/fixtures/simple-failing-dockerfile/Dockerfile index c2d06b1672c..205021a23f5 100644 --- a/tests/fixtures/simple-failing-dockerfile/Dockerfile +++ b/tests/fixtures/simple-failing-dockerfile/Dockerfile @@ -1,4 +1,4 @@ -FROM busybox:latest +FROM busybox:1.31.0-uclibc LABEL com.docker.compose.test_image=true LABEL com.docker.compose.test_failing_image=true # With the following label the container wil be cleaned up automatically diff --git a/tests/fixtures/sleeps-composefile/docker-compose.yml b/tests/fixtures/sleeps-composefile/docker-compose.yml index 7c8d84f8d41..26feb502a58 100644 --- a/tests/fixtures/sleeps-composefile/docker-compose.yml +++ b/tests/fixtures/sleeps-composefile/docker-compose.yml @@ -3,8 +3,8 @@ version: "2" services: simple: - image: busybox:latest + image: busybox:1.31.0-uclibc command: sleep 200 another: - image: busybox:latest + image: busybox:1.31.0-uclibc command: sleep 200 diff --git a/tests/fixtures/stop-signal-composefile/docker-compose.yml b/tests/fixtures/stop-signal-composefile/docker-compose.yml index 04f58aa98aa..9f99b0c75df 100644 --- a/tests/fixtures/stop-signal-composefile/docker-compose.yml +++ b/tests/fixtures/stop-signal-composefile/docker-compose.yml @@ -1,5 +1,5 @@ simple: - image: busybox:latest + image: busybox:1.31.0-uclibc command: - sh - '-c' diff --git a/tests/fixtures/tagless-image/Dockerfile b/tests/fixtures/tagless-image/Dockerfile index 56741055233..92305555136 100644 --- a/tests/fixtures/tagless-image/Dockerfile +++ b/tests/fixtures/tagless-image/Dockerfile @@ -1,2 +1,2 @@ -FROM busybox:latest +FROM busybox:1.31.0-uclibc RUN touch /blah diff --git a/tests/fixtures/top/docker-compose.yml b/tests/fixtures/top/docker-compose.yml index d632a836e9c..36a3917d756 100644 --- a/tests/fixtures/top/docker-compose.yml +++ b/tests/fixtures/top/docker-compose.yml @@ -1,6 +1,6 @@ service_a: - image: busybox:latest + image: busybox:1.31.0-uclibc command: top service_b: - image: busybox:latest + image: busybox:1.31.0-uclibc command: top diff --git a/tests/fixtures/unicode-environment/docker-compose.yml b/tests/fixtures/unicode-environment/docker-compose.yml index a41af4f0741..307678cd056 100644 --- a/tests/fixtures/unicode-environment/docker-compose.yml +++ b/tests/fixtures/unicode-environment/docker-compose.yml @@ -1,7 +1,7 @@ version: '2' services: simple: - image: busybox:latest + image: busybox:1.31.0-uclibc command: sh -c 'echo $$FOO' environment: FOO: ${BAR} diff --git a/tests/fixtures/user-composefile/docker-compose.yml b/tests/fixtures/user-composefile/docker-compose.yml index 3eb7d397760..11283d9d991 100644 --- a/tests/fixtures/user-composefile/docker-compose.yml +++ b/tests/fixtures/user-composefile/docker-compose.yml @@ -1,4 +1,4 @@ service: - image: busybox:latest + image: busybox:1.31.0-uclibc user: notauser command: id diff --git a/tests/fixtures/v2-dependencies/docker-compose.yml b/tests/fixtures/v2-dependencies/docker-compose.yml index 2e14b94bbd8..45ec8501ea4 100644 --- a/tests/fixtures/v2-dependencies/docker-compose.yml +++ b/tests/fixtures/v2-dependencies/docker-compose.yml @@ -1,13 +1,13 @@ version: "2.0" services: db: - image: busybox:latest + image: busybox:1.31.0-uclibc command: top web: - image: busybox:latest + image: busybox:1.31.0-uclibc command: top depends_on: - db console: - image: busybox:latest + image: busybox:1.31.0-uclibc command: top diff --git a/tests/fixtures/v2-full/Dockerfile b/tests/fixtures/v2-full/Dockerfile index 51ed0d90726..6fa7a726c23 100644 --- a/tests/fixtures/v2-full/Dockerfile +++ b/tests/fixtures/v2-full/Dockerfile @@ -1,4 +1,4 @@ -FROM busybox:latest +FROM busybox:1.31.0-uclibc RUN echo something CMD top diff --git a/tests/fixtures/v2-full/docker-compose.yml b/tests/fixtures/v2-full/docker-compose.yml index a973dd0cf7f..20c14f0f7b9 100644 --- a/tests/fixtures/v2-full/docker-compose.yml +++ b/tests/fixtures/v2-full/docker-compose.yml @@ -18,7 +18,7 @@ services: - other other: - image: busybox:latest + image: busybox:1.31.0-uclibc command: top volumes: - /data diff --git a/tests/fixtures/v2-simple/links-invalid.yml b/tests/fixtures/v2-simple/links-invalid.yml index 481aa404583..a88eb1d5241 100644 --- a/tests/fixtures/v2-simple/links-invalid.yml +++ b/tests/fixtures/v2-simple/links-invalid.yml @@ -1,10 +1,10 @@ version: "2" services: simple: - image: busybox:latest + image: busybox:1.31.0-uclibc command: top links: - another another: - image: busybox:latest + image: busybox:1.31.0-uclibc command: top diff --git a/tests/fixtures/v2-simple/one-container.yml b/tests/fixtures/v2-simple/one-container.yml index 22cd9863cb4..2d5c2ca668f 100644 --- a/tests/fixtures/v2-simple/one-container.yml +++ b/tests/fixtures/v2-simple/one-container.yml @@ -1,5 +1,5 @@ version: "2" services: simple: - image: busybox:latest + image: busybox:1.31.0-uclibc command: top diff --git a/tests/helpers.py b/tests/helpers.py index dd129981194..327715ee2e8 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -7,6 +7,10 @@ from compose.config.config import ConfigFile from compose.config.config import load +BUSYBOX_IMAGE_NAME = 'busybox' +BUSYBOX_DEFAULT_TAG = '1.31.0-uclibc' +BUSYBOX_IMAGE_WITH_TAG = '{}:{}'.format(BUSYBOX_IMAGE_NAME, BUSYBOX_DEFAULT_TAG) + def build_config(contents, **kwargs): return load(build_config_details(contents, **kwargs)) @@ -22,7 +26,7 @@ def build_config_details(contents, working_dir='working_dir', filename='filename def create_custom_host_file(client, filename, content): dirname = os.path.dirname(filename) container = client.create_container( - 'busybox:latest', + BUSYBOX_IMAGE_WITH_TAG, ['sh', '-c', 'echo -n "{}" > {}'.format(content, filename)], volumes={dirname: {}}, host_config=client.create_host_config( diff --git a/tests/integration/environment_test.py b/tests/integration/environment_test.py index 07619eec1e1..ac31d2e1f68 100644 --- a/tests/integration/environment_test.py +++ b/tests/integration/environment_test.py @@ -20,7 +20,7 @@ def setUpClass(cls): cls.compose_file.write(bytes("""version: '3.2' services: svc: - image: busybox:latest + image: busybox:1.31.0-uclibc environment: TEST_VARIABLE: ${TEST_VARIABLE}""", encoding='utf-8')) cls.compose_file.flush() diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 1d9495ff880..4c88f3d6b8a 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -15,6 +15,7 @@ from .. import mock from ..helpers import build_config as load_config +from ..helpers import BUSYBOX_IMAGE_WITH_TAG from ..helpers import create_host_file from .testcases import DockerClientTestCase from .testcases import SWARM_SKIP_CONTAINERS_ALL @@ -128,11 +129,11 @@ def test_volumes_from_service(self): name='composetest', config_data=load_config({ 'data': { - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'volumes': ['/var/data'], }, 'db': { - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'volumes_from': ['data'], }, }), @@ -145,7 +146,7 @@ def test_volumes_from_service(self): def test_volumes_from_container(self): data_container = Container.create( self.client, - image='busybox:latest', + image=BUSYBOX_IMAGE_WITH_TAG, volumes=['/var/data'], name='composetest_data_container', labels={LABEL_PROJECT: 'composetest'}, @@ -155,7 +156,7 @@ def test_volumes_from_container(self): name='composetest', config_data=load_config({ 'db': { - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'volumes_from': ['composetest_data_container'], }, }), @@ -174,11 +175,11 @@ def test_network_mode_from_service(self): 'version': str(V2_0), 'services': { 'net': { - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'command': ["top"] }, 'web': { - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'network_mode': 'service:net', 'command': ["top"] }, @@ -202,7 +203,7 @@ def get_project(): 'version': str(V2_0), 'services': { 'web': { - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'network_mode': 'container:composetest_net_container' }, }, @@ -217,7 +218,7 @@ def get_project(): net_container = Container.create( self.client, - image='busybox:latest', + image=BUSYBOX_IMAGE_WITH_TAG, name='composetest_net_container', command='top', labels={LABEL_PROJECT: 'composetest'}, @@ -237,11 +238,11 @@ def test_net_from_service_v1(self): name='composetest', config_data=load_config({ 'net': { - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'command': ["top"] }, 'web': { - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'net': 'container:net', 'command': ["top"] }, @@ -262,7 +263,7 @@ def get_project(): name='composetest', config_data=load_config({ 'web': { - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'net': 'container:composetest_net_container' }, }), @@ -276,7 +277,7 @@ def get_project(): net_container = Container.create( self.client, - image='busybox:latest', + image=BUSYBOX_IMAGE_WITH_TAG, name='composetest_net_container', command='top', labels={LABEL_PROJECT: 'composetest'}, @@ -549,20 +550,20 @@ def test_project_up_starts_depends(self): name='composetest', config_data=load_config({ 'console': { - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'command': ["top"], }, 'data': { - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'command': ["top"] }, 'db': { - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'command': ["top"], 'volumes_from': ['data'], }, 'web': { - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'command': ["top"], 'links': ['db'], }, @@ -584,20 +585,20 @@ def test_project_up_with_no_deps(self): name='composetest', config_data=load_config({ 'console': { - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'command': ["top"], }, 'data': { - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'command': ["top"] }, 'db': { - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'command': ["top"], 'volumes_from': ['data'], }, 'web': { - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'command': ["top"], 'links': ['db'], }, @@ -623,7 +624,7 @@ def test_project_up_recreate_with_tmpfs_volume(self): 'version': '2.1', 'services': { 'foo': { - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'tmpfs': ['/dev/shm'], 'volumes': ['/dev/shm'] } @@ -664,7 +665,7 @@ def test_project_up_networks(self): version=V2_0, services=[{ 'name': 'web', - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'command': 'top', 'networks': { 'foo': None, @@ -709,7 +710,7 @@ def test_up_with_ipam_config(self): version=V2_0, services=[{ 'name': 'web', - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'networks': {'front': None}, }], networks={ @@ -769,7 +770,7 @@ def test_up_with_ipam_options(self): version=V2_0, services=[{ 'name': 'web', - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'networks': {'front': None}, }], networks={ @@ -804,7 +805,7 @@ def test_up_with_network_static_addresses(self): version=V2_1, services=[{ 'name': 'web', - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'command': 'top', 'networks': { 'static_test': { @@ -856,7 +857,7 @@ def get_config_data(p1, p2, p3): version=V2_3, services=[{ 'name': 'web', - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'networks': { 'n1': { 'priority': p1, @@ -919,7 +920,7 @@ def test_up_with_enable_ipv6(self): version=V2_1, services=[{ 'name': 'web', - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'command': 'top', 'networks': { 'static_test': { @@ -962,7 +963,7 @@ def test_up_with_network_static_addresses_missing_subnet(self): version=V2_0, services=[{ 'name': 'web', - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'networks': { 'static_test': { 'ipv4_address': '172.16.100.100', @@ -998,7 +999,7 @@ def test_up_with_network_link_local_ips(self): version=V2_1, services=[{ 'name': 'web', - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'networks': { 'linklocaltest': { 'link_local_ips': ['169.254.8.8'] @@ -1035,7 +1036,7 @@ def test_up_with_custom_name_resources(self): 'name': 'web', 'volumes': [VolumeSpec.parse('foo:/container-path')], 'networks': {'foo': {}}, - 'image': 'busybox:latest' + 'image': BUSYBOX_IMAGE_WITH_TAG }], networks={ 'foo': { @@ -1071,7 +1072,7 @@ def test_up_with_isolation(self): version=V2_1, services=[{ 'name': 'web', - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'isolation': 'default' }], ) @@ -1091,7 +1092,7 @@ def test_up_with_invalid_isolation(self): version=V2_1, services=[{ 'name': 'web', - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'isolation': 'foobar' }], ) @@ -1111,7 +1112,7 @@ def test_up_with_runtime(self): version=V2_3, services=[{ 'name': 'web', - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'runtime': 'runc' }], ) @@ -1131,7 +1132,7 @@ def test_up_with_invalid_runtime(self): version=V2_3, services=[{ 'name': 'web', - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'runtime': 'foobar' }], ) @@ -1151,7 +1152,7 @@ def test_up_with_nvidia_runtime(self): version=V2_3, services=[{ 'name': 'web', - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'runtime': 'nvidia' }], ) @@ -1171,7 +1172,7 @@ def test_project_up_with_network_internal(self): version=V2_0, services=[{ 'name': 'web', - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'networks': {'internal': None}, }], networks={ @@ -1200,7 +1201,7 @@ def test_project_up_with_network_label(self): version=V2_1, services=[{ 'name': 'web', - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'networks': {network_name: None} }], networks={ @@ -1233,7 +1234,7 @@ def test_project_up_volumes(self): version=V2_0, services=[{ 'name': 'web', - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'command': 'top' }], volumes={vol_name: {'driver': 'local'}}, @@ -1260,7 +1261,7 @@ def test_project_up_with_volume_labels(self): version=V2_1, services=[{ 'name': 'web', - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'volumes': [VolumeSpec.parse('{}:/data'.format(volume_name))] }], volumes={ @@ -1299,9 +1300,9 @@ def test_project_up_logging_with_multiple_files(self): { 'version': str(V2_0), 'services': { - 'simple': {'image': 'busybox:latest', 'command': 'top'}, + 'simple': {'image': BUSYBOX_IMAGE_WITH_TAG, 'command': 'top'}, 'another': { - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'command': 'top', 'logging': { 'driver': "json-file", @@ -1352,7 +1353,7 @@ def test_project_up_port_mappings_with_multiple_files(self): 'version': str(V2_0), 'services': { 'simple': { - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'command': 'top', 'ports': ['1234:1234'] }, @@ -1386,7 +1387,7 @@ def test_project_up_config_scale(self): version=V2_2, services=[{ 'name': 'web', - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'command': 'top', 'scale': 3 }] @@ -1416,7 +1417,7 @@ def test_initialize_volumes(self): version=V2_0, services=[{ 'name': 'web', - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'command': 'top' }], volumes={vol_name: {}}, @@ -1440,7 +1441,7 @@ def test_project_up_implicit_volume_driver(self): version=V2_0, services=[{ 'name': 'web', - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'command': 'top' }], volumes={vol_name: {}}, @@ -1464,7 +1465,7 @@ def test_project_up_with_secrets(self): version=V3_1, services=[{ 'name': 'web', - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'command': 'cat /run/secrets/special', 'secrets': [ types.ServiceSecret.parse({'source': 'super', 'target': 'special'}), @@ -1502,7 +1503,7 @@ def test_project_up_with_added_secrets(self): 'services': [ { 'name': 'web', - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'command': 'cat /run/secrets/special', 'environment': ['constraint:node=={}'.format(node if node is not None else '')] } @@ -1555,7 +1556,7 @@ def test_initialize_volumes_invalid_volume_driver(self): version=V2_0, services=[{ 'name': 'web', - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'command': 'top' }], volumes={vol_name: {'driver': 'foobar'}}, @@ -1578,7 +1579,7 @@ def test_initialize_volumes_updated_driver(self): version=V2_0, services=[{ 'name': 'web', - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'command': 'top' }], volumes={vol_name: {'driver': 'local'}}, @@ -1620,7 +1621,7 @@ def test_initialize_volumes_updated_driver_opts(self): version=V2_0, services=[{ 'name': 'web', - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'command': 'top' }], volumes={ @@ -1662,7 +1663,7 @@ def test_initialize_volumes_updated_blank_driver(self): version=V2_0, services=[{ 'name': 'web', - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'command': 'top' }], volumes={vol_name: {'driver': 'local'}}, @@ -1701,7 +1702,7 @@ def test_initialize_volumes_external_volumes(self): version=V2_0, services=[{ 'name': 'web', - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'command': 'top' }], volumes={ @@ -1725,7 +1726,7 @@ def test_initialize_volumes_inexistent_external_volume(self): version=V2_0, services=[{ 'name': 'web', - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'command': 'top' }], volumes={ @@ -1753,7 +1754,7 @@ def test_project_up_named_volumes_in_binds(self): 'version': str(V2_0), 'services': { 'simple': { - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'command': 'top', 'volumes': ['{0}:/data'.format(vol_name)] }, @@ -1782,7 +1783,7 @@ def test_project_up_named_volumes_in_binds(self): def test_project_up_orphans(self): config_dict = { 'service1': { - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'command': 'top', } } @@ -1819,7 +1820,7 @@ def test_project_up_orphans(self): def test_project_up_ignore_orphans(self): config_dict = { 'service1': { - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'command': 'top', } } @@ -1847,7 +1848,7 @@ def test_project_up_healthy_dependency(self): 'version': '2.1', 'services': { 'svc1': { - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'command': 'top', 'healthcheck': { 'test': 'exit 0', @@ -1857,7 +1858,7 @@ def test_project_up_healthy_dependency(self): }, }, 'svc2': { - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'command': 'top', 'depends_on': { 'svc1': {'condition': 'service_healthy'}, @@ -1884,7 +1885,7 @@ def test_project_up_unhealthy_dependency(self): 'version': '2.1', 'services': { 'svc1': { - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'command': 'top', 'healthcheck': { 'test': 'exit 1', @@ -1894,7 +1895,7 @@ def test_project_up_unhealthy_dependency(self): }, }, 'svc2': { - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'command': 'top', 'depends_on': { 'svc1': {'condition': 'service_healthy'}, @@ -1923,14 +1924,14 @@ def test_project_up_no_healthcheck_dependency(self): 'version': '2.1', 'services': { 'svc1': { - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'command': 'top', 'healthcheck': { 'disable': True }, }, 'svc2': { - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'command': 'top', 'depends_on': { 'svc1': {'condition': 'service_healthy'}, @@ -1967,7 +1968,7 @@ def test_project_up_seccomp_profile(self): 'version': '2.3', 'services': { 'svc1': { - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'command': 'top', 'security_opt': ['seccomp:"{}"'.format(profile_path)] } @@ -1991,7 +1992,7 @@ def test_project_up_name_starts_with_illegal_char(self): 'version': '2.3', 'services': { 'svc1': { - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'command': 'ls', 'volumes': ['foo:/foo:rw'], 'networks': ['bar'], diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index b49ae710634..9750f581cff 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -15,6 +15,7 @@ from six import text_type from .. import mock +from ..helpers import BUSYBOX_IMAGE_WITH_TAG from .testcases import docker_client from .testcases import DockerClientTestCase from .testcases import get_links @@ -373,7 +374,7 @@ def test_create_container_with_legacy_mount(self): self.client.create_volume(volume_name) service = Service('db', client=client, volumes=[ MountSpec(type='volume', source=volume_name, target=container_path) - ], image='busybox:latest', command=['top'], project='composetest') + ], image=BUSYBOX_IMAGE_WITH_TAG, command=['top'], project='composetest') container = service.create_container() service.start_container(container) mount = container.get_mount(container_path) @@ -388,7 +389,7 @@ def test_create_container_with_legacy_tmpfs_mount(self): container_path = '/container-tmpfs' service = Service('db', client=client, volumes=[ MountSpec(type='tmpfs', target=container_path) - ], image='busybox:latest', command=['top'], project='composetest') + ], image=BUSYBOX_IMAGE_WITH_TAG, command=['top'], project='composetest') container = service.create_container() service.start_container(container) mount = container.get_mount(container_path) @@ -474,7 +475,7 @@ def test_create_container_with_volumes_from(self): volume_container_1 = volume_service.create_container() volume_container_2 = Container.create( self.client, - image='busybox:latest', + image=BUSYBOX_IMAGE_WITH_TAG, command=["top"], labels={LABEL_PROJECT: 'composetest'}, host_config={}, @@ -1232,9 +1233,8 @@ def test_port_with_explicit_interface(self): # }) def test_create_with_image_id(self): - # Get image id for the current busybox:latest pull_busybox(self.client) - image_id = self.client.inspect_image('busybox:latest')['Id'][:12] + image_id = self.client.inspect_image(BUSYBOX_IMAGE_WITH_TAG)['Id'][:12] service = self.create_service('foo', image=image_id) service.create_container() diff --git a/tests/integration/state_test.py b/tests/integration/state_test.py index 0d69217cf13..714945ee52f 100644 --- a/tests/integration/state_test.py +++ b/tests/integration/state_test.py @@ -10,6 +10,7 @@ import py from docker.errors import ImageNotFound +from ..helpers import BUSYBOX_IMAGE_WITH_TAG from .testcases import DockerClientTestCase from .testcases import get_links from .testcases import no_cluster @@ -42,8 +43,8 @@ def setUp(self): super(BasicProjectTest, self).setUp() self.cfg = { - 'db': {'image': 'busybox:latest', 'command': 'top'}, - 'web': {'image': 'busybox:latest', 'command': 'top'}, + 'db': {'image': BUSYBOX_IMAGE_WITH_TAG, 'command': 'top'}, + 'web': {'image': BUSYBOX_IMAGE_WITH_TAG, 'command': 'top'}, } def test_no_change(self): @@ -99,16 +100,16 @@ def setUp(self): self.cfg = { 'db': { - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'command': 'tail -f /dev/null', }, 'web': { - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'command': 'tail -f /dev/null', 'links': ['db'], }, 'nginx': { - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'command': 'tail -f /dev/null', 'links': ['web'], }, @@ -173,7 +174,7 @@ def test_change_root_no_recreate(self): def test_service_removed_while_down(self): next_cfg = { 'web': { - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'command': 'tail -f /dev/null', }, 'nginx': self.cfg['nginx'], @@ -219,16 +220,16 @@ def setUp(self): 'version': '2', 'services': { 'db': { - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'command': 'tail -f /dev/null', }, 'web': { - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'command': 'tail -f /dev/null', 'depends_on': ['db'], }, 'nginx': { - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'command': 'tail -f /dev/null', 'depends_on': ['web'], }, @@ -385,7 +386,7 @@ def test_trigger_recreate_with_config_change(self): assert ('recreate', [container]) == web.convergence_plan() def test_trigger_recreate_with_nonexistent_image_tag(self): - web = self.create_service('web', image="busybox:latest") + web = self.create_service('web', image=BUSYBOX_IMAGE_WITH_TAG) container = web.create_container() web = self.create_service('web', image="nonexistent-image") diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index cfdf22f7e61..fe70d1f7246 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -9,6 +9,7 @@ from docker.utils import version_lt from .. import unittest +from ..helpers import BUSYBOX_IMAGE_WITH_TAG from compose.cli.docker_client import docker_client from compose.config.config import resolve_environment from compose.config.environment import Environment @@ -32,7 +33,7 @@ def pull_busybox(client): - client.pull('busybox:latest', stream=False) + client.pull(BUSYBOX_IMAGE_WITH_TAG, stream=False) def get_links(container): @@ -123,7 +124,7 @@ def tearDown(self): def create_service(self, name, **kwargs): if 'image' not in kwargs and 'build' not in kwargs: - kwargs['image'] = 'busybox:latest' + kwargs['image'] = BUSYBOX_IMAGE_WITH_TAG if 'command' not in kwargs: kwargs['command'] = ["top"] diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 2f27a5b2090..b583422f54f 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -15,6 +15,7 @@ import yaml from ...helpers import build_config_details +from ...helpers import BUSYBOX_IMAGE_WITH_TAG from compose.config import config from compose.config import types from compose.config.config import resolve_build_args @@ -343,7 +344,7 @@ def test_load_throws_error_when_not_dict(self): with pytest.raises(ConfigurationError): config.load( build_config_details( - {'web': 'busybox:latest'}, + {'web': BUSYBOX_IMAGE_WITH_TAG}, 'working_dir', 'filename.yml' ) @@ -353,7 +354,7 @@ def test_load_throws_error_when_not_dict_v2(self): with pytest.raises(ConfigurationError): config.load( build_config_details( - {'version': '2', 'services': {'web': 'busybox:latest'}}, + {'version': '2', 'services': {'web': BUSYBOX_IMAGE_WITH_TAG}}, 'working_dir', 'filename.yml' ) @@ -364,7 +365,7 @@ def test_load_throws_error_with_invalid_network_fields(self): config.load( build_config_details({ 'version': '2', - 'services': {'web': 'busybox:latest'}, + 'services': {'web': BUSYBOX_IMAGE_WITH_TAG}, 'networks': { 'invalid': {'foo', 'bar'} } @@ -847,15 +848,15 @@ def test_load_with_multiple_files_and_invalid_override(self): def test_load_sorts_in_dependency_order(self): config_details = build_config_details({ 'web': { - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'links': ['db'], }, 'db': { - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'volumes_from': ['volume:ro'] }, 'volume': { - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'volumes': ['/tmp'], } }) @@ -1280,7 +1281,7 @@ def test_undeclared_volume_v2(self): 'version': '2', 'services': { 'web': { - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'volumes': ['data0028:/data:ro'], }, }, @@ -1296,7 +1297,7 @@ def test_undeclared_volume_v2(self): 'version': '2', 'services': { 'web': { - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'volumes': ['./data0028:/data:ro'], }, }, @@ -1312,7 +1313,7 @@ def test_undeclared_volume_v1(self): 'base.yaml', { 'web': { - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'volumes': ['data0028:/data:ro'], }, } @@ -1329,7 +1330,7 @@ def test_volumes_long_syntax(self): 'version': '2.3', 'services': { 'web': { - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'volumes': [ { 'target': '/anonymous', 'type': 'volume' @@ -1374,7 +1375,7 @@ def test_load_bind_mount_relative_path(self): 'version': '3.4', 'services': { 'web': { - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'volumes': [ {'type': 'bind', 'source': './web', 'target': '/web'}, ], @@ -1396,7 +1397,7 @@ def test_load_bind_mount_relative_path_with_tilde(self): 'version': '3.4', 'services': { 'web': { - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'volumes': [ {'type': 'bind', 'source': '~/web', 'target': '/web'}, ], @@ -2293,7 +2294,7 @@ def test_merge_logging_v2_no_override(self): def test_merge_mixed_ports(self): base = { - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'command': 'top', 'ports': [ { @@ -2310,7 +2311,7 @@ def test_merge_mixed_ports(self): actual = config.merge_service_dicts(base, override, V3_1) assert actual == { - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'command': 'top', 'ports': [types.ServicePort('1245', '1245', 'udp', None, None)] } diff --git a/tests/unit/container_test.py b/tests/unit/container_test.py index fde17847a13..626b466d4b3 100644 --- a/tests/unit/container_test.py +++ b/tests/unit/container_test.py @@ -5,6 +5,7 @@ from .. import mock from .. import unittest +from ..helpers import BUSYBOX_IMAGE_WITH_TAG from compose.const import LABEL_ONE_OFF from compose.const import LABEL_SLUG from compose.container import Container @@ -17,7 +18,7 @@ def setUp(self): self.container_id = "abcabcabcbabc12345" self.container_dict = { "Id": self.container_id, - "Image": "busybox:latest", + "Image": BUSYBOX_IMAGE_WITH_TAG, "Command": "top", "Created": 1387384730, "Status": "Up 8 seconds", @@ -43,7 +44,7 @@ def test_from_ps(self): has_been_inspected=True) assert container.dictionary == { "Id": self.container_id, - "Image": "busybox:latest", + "Image": BUSYBOX_IMAGE_WITH_TAG, "Name": "/composetest_db_1", } @@ -58,7 +59,7 @@ def test_from_ps_prefixed(self): has_been_inspected=True) assert container.dictionary == { "Id": self.container_id, - "Image": "busybox:latest", + "Image": BUSYBOX_IMAGE_WITH_TAG, "Name": "/composetest_db_1", } diff --git a/tests/unit/project_test.py b/tests/unit/project_test.py index 89b080d20e7..93a9aa292dd 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -10,6 +10,7 @@ from .. import mock from .. import unittest +from ..helpers import BUSYBOX_IMAGE_WITH_TAG from compose.config.config import Config from compose.config.types import VolumeFromSpec from compose.const import COMPOSEFILE_V1 as V1 @@ -39,11 +40,11 @@ def test_from_config_v1(self): services=[ { 'name': 'web', - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, }, { 'name': 'db', - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, }, ], networks=None, @@ -58,9 +59,9 @@ def test_from_config_v1(self): ) assert len(project.services) == 2 assert project.get_service('web').name == 'web' - assert project.get_service('web').options['image'] == 'busybox:latest' + assert project.get_service('web').options['image'] == BUSYBOX_IMAGE_WITH_TAG assert project.get_service('db').name == 'db' - assert project.get_service('db').options['image'] == 'busybox:latest' + assert project.get_service('db').options['image'] == BUSYBOX_IMAGE_WITH_TAG assert not project.networks.use_networking @mock.patch('compose.network.Network.true_name', lambda n: n.full_name) @@ -70,11 +71,11 @@ def test_from_config_v2(self): services=[ { 'name': 'web', - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, }, { 'name': 'db', - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, }, ], networks=None, @@ -91,7 +92,7 @@ def test_get_service(self): project='composetest', name='web', client=None, - image="busybox:latest", + image=BUSYBOX_IMAGE_WITH_TAG, ) project = Project('test', [web], None) assert project.get_service('web') == web @@ -176,7 +177,7 @@ def test_use_volumes_from_container(self): version=V2_0, services=[{ 'name': 'test', - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'volumes_from': [VolumeFromSpec('aaa', 'rw', 'container')] }], networks=None, @@ -194,7 +195,7 @@ def test_use_volumes_from_service_no_container(self): "Name": container_name, "Names": [container_name], "Id": container_name, - "Image": 'busybox:latest' + "Image": BUSYBOX_IMAGE_WITH_TAG } ] project = Project.from_config( @@ -205,11 +206,11 @@ def test_use_volumes_from_service_no_container(self): services=[ { 'name': 'vol', - 'image': 'busybox:latest' + 'image': BUSYBOX_IMAGE_WITH_TAG }, { 'name': 'test', - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'volumes_from': [VolumeFromSpec('vol', 'rw', 'service')] } ], @@ -233,11 +234,11 @@ def test_use_volumes_from_service_container(self): services=[ { 'name': 'vol', - 'image': 'busybox:latest' + 'image': BUSYBOX_IMAGE_WITH_TAG }, { 'name': 'test', - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'volumes_from': [VolumeFromSpec('vol', 'rw', 'service')] } ], @@ -543,7 +544,7 @@ def test_net_unset(self): services=[ { 'name': 'test', - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, } ], networks=None, @@ -568,7 +569,7 @@ def test_use_net_from_container(self): services=[ { 'name': 'test', - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'network_mode': 'container:aaa' }, ], @@ -588,7 +589,7 @@ def test_use_net_from_service(self): "Name": container_name, "Names": [container_name], "Id": container_name, - "Image": 'busybox:latest' + "Image": BUSYBOX_IMAGE_WITH_TAG } ] project = Project.from_config( @@ -599,11 +600,11 @@ def test_use_net_from_service(self): services=[ { 'name': 'aaa', - 'image': 'busybox:latest' + 'image': BUSYBOX_IMAGE_WITH_TAG }, { 'name': 'test', - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'network_mode': 'service:aaa' }, ], @@ -626,7 +627,7 @@ def test_uses_default_network_true(self): services=[ { 'name': 'foo', - 'image': 'busybox:latest' + 'image': BUSYBOX_IMAGE_WITH_TAG }, ], networks=None, @@ -647,7 +648,7 @@ def test_uses_default_network_false(self): services=[ { 'name': 'foo', - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, 'networks': {'custom': None} }, ], @@ -662,9 +663,9 @@ def test_uses_default_network_false(self): def test_container_without_name(self): self.mock_client.containers.return_value = [ - {'Image': 'busybox:latest', 'Id': '1', 'Name': '1'}, - {'Image': 'busybox:latest', 'Id': '2', 'Name': None}, - {'Image': 'busybox:latest', 'Id': '3'}, + {'Image': BUSYBOX_IMAGE_WITH_TAG, 'Id': '1', 'Name': '1'}, + {'Image': BUSYBOX_IMAGE_WITH_TAG, 'Id': '2', 'Name': None}, + {'Image': BUSYBOX_IMAGE_WITH_TAG, 'Id': '3'}, ] self.mock_client.inspect_container.return_value = { 'Id': '1', @@ -681,7 +682,7 @@ def test_container_without_name(self): version=V2_0, services=[{ 'name': 'web', - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, }], networks=None, volumes=None, @@ -699,7 +700,7 @@ def test_down_with_no_resources(self): version=V2_0, services=[{ 'name': 'web', - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, }], networks={'default': {}}, volumes={'data': {}}, @@ -711,7 +712,7 @@ def test_down_with_no_resources(self): 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") + self.mock_client.remove_image.assert_called_once_with(BUSYBOX_IMAGE_WITH_TAG) def test_no_warning_on_stop(self): self.mock_client.info.return_value = {'Swarm': {'LocalNodeState': 'active'}} @@ -744,7 +745,7 @@ def test_no_such_service_unicode(self): def test_project_platform_value(self): service_config = { 'name': 'web', - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, } config_data = Config( version=V2_4, services=[service_config], networks={}, volumes={}, secrets=None, configs=None @@ -771,8 +772,8 @@ def test_build_container_operation_with_timeout_func_does_not_mutate_options_wit config_data = Config( version=V3_7, services=[ - {'name': 'web', 'image': 'busybox:latest'}, - {'name': 'db', 'image': 'busybox:latest', 'stop_grace_period': '1s'}, + {'name': 'web', 'image': BUSYBOX_IMAGE_WITH_TAG}, + {'name': 'db', 'image': BUSYBOX_IMAGE_WITH_TAG, 'stop_grace_period': '1s'}, ], networks={}, volumes={}, secrets=None, configs=None, ) @@ -804,7 +805,7 @@ def test_error_parallel_pull(self, mock_write): version=V2_0, services=[{ 'name': 'web', - 'image': 'busybox:latest', + 'image': BUSYBOX_IMAGE_WITH_TAG, }], networks=None, volumes=None, diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 0cff08ab603..a6a633db859 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -828,7 +828,7 @@ def test_specifies_host_port_with_host_ip_and_port_range(self): assert service.specifies_host_port() def test_image_name_from_config(self): - image_name = 'example/web:latest' + image_name = 'example/web:mytag' service = Service('foo', image=image_name) assert service.image_name == image_name From c641ea08ae550200a76a944f85e1e1d4b2409ecc Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Mon, 22 Jul 2019 17:27:10 +0200 Subject: [PATCH 3731/4072] Fix stdin_open when running docker-compose run This fix makes sure that stdin_open specified in the service is considering when shelling out to the CLI Signed-off-by: Ulysses Souza --- compose/cli/main.py | 16 ++++++++-- tests/unit/cli/main_test.py | 60 +++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 78990111bfa..6391bd3ea2f 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -885,6 +885,8 @@ def run(self, options): else: command = service.options.get('command') + options['stdin_open'] = service.options.get('stdin_open', True) + container_options = build_one_off_container_options(options, detach, command) run_one_off_container( container_options, self.project, service, options, @@ -1286,7 +1288,7 @@ def build_one_off_container_options(options, detach, command): container_options = { 'command': command, 'tty': not (detach or options['-T'] or not sys.stdin.isatty()), - 'stdin_open': not detach, + 'stdin_open': options.get('stdin_open'), 'detach': detach, } @@ -1368,7 +1370,7 @@ def remove_container(force=False): if IS_WINDOWS_PLATFORM or use_cli: service.connect_container_to_networks(container, use_network_aliases) exit_code = call_docker( - ["start", "--attach", "--interactive", container.id], + get_docker_start_call(container_options, container.id), toplevel_options, environment ) else: @@ -1395,6 +1397,16 @@ def remove_container(force=False): sys.exit(exit_code) +def get_docker_start_call(container_options, container_id): + docker_call = ["start"] + if not container_options.get('detach'): + docker_call.append("--attach") + if container_options.get('stdin_open'): + docker_call.append("--interactive") + docker_call.append(container_id) + return docker_call + + def log_printer_from_project( project, containers, diff --git a/tests/unit/cli/main_test.py b/tests/unit/cli/main_test.py index 7fed6bccc29..aadb9d459db 100644 --- a/tests/unit/cli/main_test.py +++ b/tests/unit/cli/main_test.py @@ -9,9 +9,11 @@ from compose import container from compose.cli.errors import UserError from compose.cli.formatter import ConsoleWarningFormatter +from compose.cli.main import build_one_off_container_options from compose.cli.main import call_docker from compose.cli.main import convergence_strategy_from_opts from compose.cli.main import filter_containers_to_service_names +from compose.cli.main import get_docker_start_call from compose.cli.main import setup_console_handler from compose.cli.main import warn_for_swarm_mode from compose.service import ConvergenceStrategy @@ -65,6 +67,64 @@ def test_warning_in_swarm_mode(self): warn_for_swarm_mode(mock_client) assert fake_log.warning.call_count == 1 + def test_build_one_off_container_options(self): + command = 'build myservice' + detach = False + options = { + '-e': ['MYVAR=MYVALUE'], + '-T': True, + '--label': ['MYLABEL'], + '--entrypoint': 'bash', + '--user': 'MYUSER', + '--service-ports': [], + '--publish': '', + '--name': 'MYNAME', + '--workdir': '.', + '--volume': [], + 'stdin_open': False, + } + + expected_container_options = { + 'command': command, + 'tty': False, + 'stdin_open': False, + 'detach': detach, + 'entrypoint': 'bash', + 'environment': {'MYVAR': 'MYVALUE'}, + 'labels': {'MYLABEL': ''}, + 'name': 'MYNAME', + 'ports': [], + 'restart': None, + 'user': 'MYUSER', + 'working_dir': '.', + } + + container_options = build_one_off_container_options(options, detach, command) + assert container_options == expected_container_options + + def test_get_docker_start_call(self): + container_id = 'my_container_id' + + mock_container_options = {'detach': False, 'stdin_open': True} + expected_docker_start_call = ['start', '--attach', '--interactive', container_id] + docker_start_call = get_docker_start_call(mock_container_options, container_id) + assert expected_docker_start_call == docker_start_call + + mock_container_options = {'detach': False, 'stdin_open': False} + expected_docker_start_call = ['start', '--attach', container_id] + docker_start_call = get_docker_start_call(mock_container_options, container_id) + assert expected_docker_start_call == docker_start_call + + mock_container_options = {'detach': True, 'stdin_open': True} + expected_docker_start_call = ['start', '--interactive', container_id] + docker_start_call = get_docker_start_call(mock_container_options, container_id) + assert expected_docker_start_call == docker_start_call + + mock_container_options = {'detach': True, 'stdin_open': False} + expected_docker_start_call = ['start', container_id] + docker_start_call = get_docker_start_call(mock_container_options, container_id) + assert expected_docker_start_call == docker_start_call + class TestSetupConsoleHandlerTestCase(object): From 99464d9c2bfc9052ca341dc0e57c0ab2760cd8a2 Mon Sep 17 00:00:00 2001 From: Klaas Hoekema Date: Sun, 16 Jun 2019 22:57:04 -0400 Subject: [PATCH 3732/4072] Handle environment file override within TopLevelCommand Several (but not all) of the subcommands are accepting and processing the `--env-file` option, but only because they need to look for a specific value in the environment. The work of applying the override makes more sense as the domain of TopLevelCommand, and moving it there and removing the option from the subcommands makes things simpler. Signed-off-by: Klaas Hoekema --- compose/cli/main.py | 26 ++++++++++---------------- 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 6391bd3ea2f..e11554ac095 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -247,6 +247,11 @@ def __init__(self, project, options=None): def project_dir(self): return self.toplevel_options.get('--project-directory') or '.' + @property + def environment(self): + environment_file = self.toplevel_options.get('--env-file') + return Environment.from_env_file(self.project_dir, environment_file) + def build(self, options): """ Build or rebuild services. @@ -276,9 +281,7 @@ def build(self, options): '--build-arg is only supported when services are specified for API version < 1.25.' ' Please use a Compose file version > 2.2 or specify which services to build.' ) - environment_file = options.get('--env-file') - environment = Environment.from_env_file(self.project_dir, environment_file) - build_args = resolve_build_args(build_args, environment) + build_args = resolve_build_args(build_args, self.environment) self.project.build( service_names=options['SERVICE'], @@ -429,11 +432,8 @@ def down(self, options): Compose file -t, --timeout TIMEOUT Specify a shutdown timeout in seconds. (default: 10) - --env-file PATH Specify an alternate environment file """ - environment_file = options.get('--env-file') - environment = Environment.from_env_file(self.project_dir, environment_file) - ignore_orphans = environment.get_boolean('COMPOSE_IGNORE_ORPHANS') + ignore_orphans = self.environment.get_boolean('COMPOSE_IGNORE_ORPHANS') if ignore_orphans and options['--remove-orphans']: raise UserError("COMPOSE_IGNORE_ORPHANS and --remove-orphans cannot be combined.") @@ -489,11 +489,8 @@ def exec_command(self, options): -e, --env KEY=VAL Set environment variables (can be used multiple times, not supported in API < 1.25) -w, --workdir DIR Path to workdir directory for this command. - --env-file PATH Specify an alternate environment file """ - environment_file = options.get('--env-file') - environment = Environment.from_env_file(self.project_dir, environment_file) - use_cli = not environment.get_boolean('COMPOSE_INTERACTIVE_NO_CLI') + use_cli = not self.environment.get_boolean('COMPOSE_INTERACTIVE_NO_CLI') index = int(options.get('--index')) service = self.project.get_service(options['SERVICE']) detach = options.get('--detach') @@ -1051,7 +1048,6 @@ def up(self, options): container. Implies --abort-on-container-exit. --scale SERVICE=NUM Scale SERVICE to NUM instances. Overrides the `scale` setting in the Compose file if present. - --env-file PATH Specify an alternate environment file """ start_deps = not options['--no-deps'] always_recreate_deps = options['--always-recreate-deps'] @@ -1066,9 +1062,7 @@ def up(self, options): if detached and (cascade_stop or exit_value_from): raise UserError("--abort-on-container-exit and -d cannot be combined.") - environment_file = options.get('--env-file') - environment = Environment.from_env_file(self.project_dir, environment_file) - ignore_orphans = environment.get_boolean('COMPOSE_IGNORE_ORPHANS') + ignore_orphans = self.environment.get_boolean('COMPOSE_IGNORE_ORPHANS') if ignore_orphans and remove_orphans: raise UserError("COMPOSE_IGNORE_ORPHANS and --remove-orphans cannot be combined.") @@ -1360,7 +1354,7 @@ def remove_container(force=False): if options['--rm']: project.client.remove_container(container.id, force=True, v=True) - environment_file = options.get('--env-file') + environment_file = toplevel_options.get('--env-file') environment = Environment.from_env_file(project_dir, environment_file) use_cli = not environment.get_boolean('COMPOSE_INTERACTIVE_NO_CLI') signals.set_signal_handler_to_shutdown() From 35eb40424c45209df3c23a33ae9831413abf7f26 Mon Sep 17 00:00:00 2001 From: Klaas Hoekema Date: Thu, 11 Jul 2019 22:28:18 -0400 Subject: [PATCH 3733/4072] Call TopLevelCommand's environment 'toplevel_environment' To help prevent confusion between the different meanings and sources of "environment", rename the method that loads the environment from the .env or --env-file (i.e. the one that applies at a project level) to 'toplevel_environment'. Signed-off-by: Klaas Hoekema --- compose/cli/main.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index e11554ac095..eb6e7d82082 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -248,7 +248,7 @@ def project_dir(self): return self.toplevel_options.get('--project-directory') or '.' @property - def environment(self): + def toplevel_environment(self): environment_file = self.toplevel_options.get('--env-file') return Environment.from_env_file(self.project_dir, environment_file) @@ -281,7 +281,7 @@ def build(self, options): '--build-arg is only supported when services are specified for API version < 1.25.' ' Please use a Compose file version > 2.2 or specify which services to build.' ) - build_args = resolve_build_args(build_args, self.environment) + build_args = resolve_build_args(build_args, self.toplevel_environment) self.project.build( service_names=options['SERVICE'], @@ -433,7 +433,7 @@ def down(self, options): -t, --timeout TIMEOUT Specify a shutdown timeout in seconds. (default: 10) """ - ignore_orphans = self.environment.get_boolean('COMPOSE_IGNORE_ORPHANS') + ignore_orphans = self.toplevel_environment.get_boolean('COMPOSE_IGNORE_ORPHANS') if ignore_orphans and options['--remove-orphans']: raise UserError("COMPOSE_IGNORE_ORPHANS and --remove-orphans cannot be combined.") @@ -490,7 +490,7 @@ def exec_command(self, options): not supported in API < 1.25) -w, --workdir DIR Path to workdir directory for this command. """ - use_cli = not self.environment.get_boolean('COMPOSE_INTERACTIVE_NO_CLI') + use_cli = not self.toplevel_environment.get_boolean('COMPOSE_INTERACTIVE_NO_CLI') index = int(options.get('--index')) service = self.project.get_service(options['SERVICE']) detach = options.get('--detach') @@ -513,7 +513,7 @@ def exec_command(self, options): if IS_WINDOWS_PLATFORM or use_cli and not detach: sys.exit(call_docker( build_exec_command(options, container.id, command), - self.toplevel_options, environment) + self.toplevel_options, self.toplevel_environment) ) create_exec_options = { @@ -1062,7 +1062,7 @@ def up(self, options): if detached and (cascade_stop or exit_value_from): raise UserError("--abort-on-container-exit and -d cannot be combined.") - ignore_orphans = self.environment.get_boolean('COMPOSE_IGNORE_ORPHANS') + ignore_orphans = self.toplevel_environment.get_boolean('COMPOSE_IGNORE_ORPHANS') if ignore_orphans and remove_orphans: raise UserError("COMPOSE_IGNORE_ORPHANS and --remove-orphans cannot be combined.") @@ -1355,8 +1355,9 @@ def remove_container(force=False): project.client.remove_container(container.id, force=True, v=True) environment_file = toplevel_options.get('--env-file') - environment = Environment.from_env_file(project_dir, environment_file) - use_cli = not environment.get_boolean('COMPOSE_INTERACTIVE_NO_CLI') + toplevel_environment = Environment.from_env_file(project_dir, environment_file) + use_cli = not toplevel_environment.get_boolean('COMPOSE_INTERACTIVE_NO_CLI') + signals.set_signal_handler_to_shutdown() signals.set_signal_handler_to_hang_up() try: @@ -1365,7 +1366,7 @@ def remove_container(force=False): service.connect_container_to_networks(container, use_network_aliases) exit_code = call_docker( get_docker_start_call(container_options, container.id), - toplevel_options, environment + toplevel_options, toplevel_environment ) else: operation = RunOperation( From 088a798e7a546755537cb9082b440f17d64d6bdf Mon Sep 17 00:00:00 2001 From: Klaas Hoekema Date: Thu, 11 Jul 2019 23:27:11 -0400 Subject: [PATCH 3734/4072] Fix typo in 'split_env' error message Signed-off-by: Klaas Hoekema --- compose/config/environment.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/config/environment.py b/compose/config/environment.py index e72c8823129..696356f3246 100644 --- a/compose/config/environment.py +++ b/compose/config/environment.py @@ -26,7 +26,7 @@ def split_env(env): key = env if re.search(r'\s', key): raise ConfigurationError( - "environment variable name '{}' may not contains whitespace.".format(key) + "environment variable name '{}' may not contain whitespace.".format(key) ) return key, value From 69c0683bfe8f4bbb90d07f0db6516e51942f97d6 Mon Sep 17 00:00:00 2001 From: Klaas Hoekema Date: Fri, 12 Jul 2019 12:59:31 -0400 Subject: [PATCH 3735/4072] Pass toplevel_environment to run_one_off_container Instead of passing `project_dir` from `TopLevelCommand.run` to `run_one_off_container` then using it there to load the toplevel environment (duplicating the logic that `TopLevelCommand.toplevel_environment` encapsulates), pass the Environment object. Signed-off-by: Klaas Hoekema --- compose/cli/main.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index eb6e7d82082..477b57b52ef 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -887,7 +887,7 @@ def run(self, options): container_options = build_one_off_container_options(options, detach, command) run_one_off_container( container_options, self.project, service, options, - self.toplevel_options, self.project_dir + self.toplevel_options, self.toplevel_environment ) def scale(self, options): @@ -1325,7 +1325,7 @@ def build_one_off_container_options(options, detach, command): def run_one_off_container(container_options, project, service, options, toplevel_options, - project_dir='.'): + toplevel_environment): if not options['--no-deps']: deps = service.get_dependency_names() if deps: @@ -1354,8 +1354,6 @@ def remove_container(force=False): if options['--rm']: project.client.remove_container(container.id, force=True, v=True) - environment_file = toplevel_options.get('--env-file') - toplevel_environment = Environment.from_env_file(project_dir, environment_file) use_cli = not toplevel_environment.get_boolean('COMPOSE_INTERACTIVE_NO_CLI') signals.set_signal_handler_to_shutdown() From 413e5db7b35684a543e1c14f89aad379167f9b06 Mon Sep 17 00:00:00 2001 From: Klaas Hoekema Date: Mon, 22 Jul 2019 13:59:49 -0400 Subject: [PATCH 3736/4072] Add shell completions for --env-file option Adds completions for the --env-file toplevel option to the bash, fish, and zsh completions files. Signed-off-by: Klaas Hoekema --- contrib/completion/bash/docker-compose | 5 +++++ contrib/completion/fish/docker-compose.fish | 1 + contrib/completion/zsh/_docker-compose | 2 ++ 3 files changed, 8 insertions(+) diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index e9168b1b16a..6dc47799db5 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -184,6 +184,10 @@ _docker_compose_docker_compose() { _filedir -d return ;; + --env-file) + _filedir + return + ;; $(__docker_compose_to_extglob "$daemon_options_with_args") ) return ;; @@ -612,6 +616,7 @@ _docker_compose() { --tlsverify " local daemon_options_with_args=" + --env-file --file -f --host -H --project-directory diff --git a/contrib/completion/fish/docker-compose.fish b/contrib/completion/fish/docker-compose.fish index 69ecc505685..0566e16ae88 100644 --- a/contrib/completion/fish/docker-compose.fish +++ b/contrib/completion/fish/docker-compose.fish @@ -12,6 +12,7 @@ end complete -c docker-compose -s f -l file -r -d 'Specify an alternate compose file' complete -c docker-compose -s p -l project-name -x -d 'Specify an alternate project name' +complete -c docker-compose -l env-file -r -d 'Specify an alternate environment file (default: .env)' complete -c docker-compose -l verbose -d 'Show more output' complete -c docker-compose -s H -l host -x -d 'Daemon socket to connect to' complete -c docker-compose -l tls -d 'Use TLS; implied by --tlsverify' diff --git a/contrib/completion/zsh/_docker-compose b/contrib/completion/zsh/_docker-compose index 808b068a314..faf4059888f 100755 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -341,6 +341,7 @@ _docker-compose() { '(- :)'{-h,--help}'[Get help]' \ '*'{-f,--file}"[${file_description}]:file:_files -g '*.yml'" \ '(-p --project-name)'{-p,--project-name}'[Specify an alternate project name (default: directory name)]:project name:' \ + '--env-file[Specify an alternate environment file (default: .env)]:env-file:_files' \ "--compatibility[If set, Compose will attempt to convert keys in v3 files to their non-Swarm equivalent]" \ '(- :)'{-v,--version}'[Print version and exit]' \ '--verbose[Show more output]' \ @@ -359,6 +360,7 @@ _docker-compose() { local -a relevant_compose_flags relevant_compose_repeatable_flags relevant_docker_flags compose_options docker_options relevant_compose_flags=( + "--env-file" "--file" "-f" "--host" "-H" "--project-name" "-p" From 66856e884c0e7337046b0f0fa359ca17a2a96b53 Mon Sep 17 00:00:00 2001 From: Antonio Gutierrez Date: Tue, 2 Apr 2019 15:04:26 +0200 Subject: [PATCH 3737/4072] requirements: update jsonschema dependency Fixes: https://github.com/docker/compose/issues/6347 Signed-off-by: Antonio Gutierrez --- requirements-build.txt | 2 +- requirements.txt | 4 ++-- script/build/linux-entrypoint | 7 ++++--- setup.py | 2 +- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/requirements-build.txt b/requirements-build.txt index 9161fadf941..2a1cd7d6b32 100644 --- a/requirements-build.txt +++ b/requirements-build.txt @@ -1 +1 @@ -pyinstaller==3.4 +pyinstaller==3.5 diff --git a/requirements.txt b/requirements.txt index e5b6883e9ad..726b43b0319 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,14 +11,14 @@ enum34==1.1.6; python_version < '3.4' functools32==3.2.3.post2; python_version < '3.2' idna==2.5 ipaddress==1.0.18 -jsonschema==2.6.0 +jsonschema==3.0.1 paramiko==2.4.2 pypiwin32==219; sys_platform == 'win32' and python_version < '3.6' pypiwin32==223; sys_platform == 'win32' and python_version >= '3.6' PySocks==1.6.7 PyYAML==4.2b1 requests==2.22.0 -six==1.10.0 +six==1.12.0 texttable==1.6.2 urllib3==1.24.2; python_version == '3.3' websocket-client==0.32.0 diff --git a/script/build/linux-entrypoint b/script/build/linux-entrypoint index 1c5438d8ea6..d607dd5c2aa 100755 --- a/script/build/linux-entrypoint +++ b/script/build/linux-entrypoint @@ -20,10 +20,11 @@ echo "${DOCKER_COMPOSE_GITSHA}" > compose/GITSHA export PATH="${CODE_PATH}/pyinstaller:${PATH}" if [ ! -z "${BUILD_BOOTLOADER}" ]; then - # Build bootloader for alpine - git clone --single-branch --branch master https://github.com/pyinstaller/pyinstaller.git /tmp/pyinstaller + # Build bootloader for alpine; develop is the main branch + git clone --single-branch --branch develop https://github.com/pyinstaller/pyinstaller.git /tmp/pyinstaller cd /tmp/pyinstaller/bootloader - git checkout v3.4 + # Checkout commit corresponding to version in requirements-build + git checkout v3.5 "${VENV}"/bin/python3 ./waf configure --no-lsb all "${VENV}"/bin/pip3 install .. cd "${CODE_PATH}" diff --git a/setup.py b/setup.py index a4020df4653..4b47c8bf10f 100644 --- a/setup.py +++ b/setup.py @@ -39,7 +39,7 @@ def find_version(*file_paths): 'docker[ssh] >= 3.7.0, < 5', 'dockerpty >= 0.4.1, < 1', 'six >= 1.3.0, < 2', - 'jsonschema >= 2.5.1, < 3', + 'jsonschema >= 2.5.1, < 4', ] From b03889ac2a09e8ac6145b90015f0e4328d9928c4 Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Wed, 31 Jul 2019 01:58:16 +0200 Subject: [PATCH 3738/4072] Add integration tests regarding environment This covers what was included in #6800 Signed-off-by: Ulysses Souza --- tests/acceptance/cli_test.py | 10 +++++++--- tests/fixtures/env-file-override/.env.conf | 2 ++ tests/fixtures/env-file-override/.env.override | 1 + .../env-file-override/docker-compose.yml | 6 ++++++ tests/integration/environment_test.py | 18 ++++++++++++++++++ 5 files changed, 34 insertions(+), 3 deletions(-) create mode 100644 tests/fixtures/env-file-override/.env.conf create mode 100644 tests/fixtures/env-file-override/.env.override create mode 100644 tests/fixtures/env-file-override/docker-compose.yml diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 3fd857f26cb..77b46c2790f 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -64,6 +64,12 @@ def wait_on_process(proc, returncode=0): return ProcessResult(stdout.decode('utf-8'), stderr.decode('utf-8')) +def dispatch(base_dir, options, project_options=None, returncode=0): + project_options = project_options or [] + proc = start_process(base_dir, project_options + options) + return wait_on_process(proc, returncode=returncode) + + def wait_on_condition(condition, delay=0.1, timeout=40): start_time = time.time() while not condition(): @@ -151,9 +157,7 @@ def project(self): return self._project def dispatch(self, options, project_options=None, returncode=0): - project_options = project_options or [] - proc = start_process(self.base_dir, project_options + options) - return wait_on_process(proc, returncode=returncode) + return dispatch(self.base_dir, options, project_options, returncode) def execute(self, container, cmd): # Remove once Hijack and CloseNotifier sign a peace treaty diff --git a/tests/fixtures/env-file-override/.env.conf b/tests/fixtures/env-file-override/.env.conf new file mode 100644 index 00000000000..90b8b495a4b --- /dev/null +++ b/tests/fixtures/env-file-override/.env.conf @@ -0,0 +1,2 @@ +WHEREAMI +DEFAULT_CONF_LOADED=true diff --git a/tests/fixtures/env-file-override/.env.override b/tests/fixtures/env-file-override/.env.override new file mode 100644 index 00000000000..398fa51b321 --- /dev/null +++ b/tests/fixtures/env-file-override/.env.override @@ -0,0 +1 @@ +WHEREAMI=override diff --git a/tests/fixtures/env-file-override/docker-compose.yml b/tests/fixtures/env-file-override/docker-compose.yml new file mode 100644 index 00000000000..fdae6d826c7 --- /dev/null +++ b/tests/fixtures/env-file-override/docker-compose.yml @@ -0,0 +1,6 @@ +version: '3.7' +services: + test: + image: busybox + env_file: .env.conf + entrypoint: env diff --git a/tests/integration/environment_test.py b/tests/integration/environment_test.py index ac31d2e1f68..671e65318a6 100644 --- a/tests/integration/environment_test.py +++ b/tests/integration/environment_test.py @@ -7,7 +7,10 @@ from ddt import ddt from .. import mock +from ..acceptance.cli_test import dispatch +from compose.cli.command import get_project from compose.cli.command import project_from_options +from compose.config.environment import Environment from tests.integration.testcases import DockerClientTestCase @@ -50,3 +53,18 @@ def _test_no_warning_on_missing_host_environment_var_on_silent_commands(self, cm # So no need to have a proper options map, the `COMMAND` key is enough project_from_options('.', options) assert fake_log.warn.call_count == 0 + + +class EnvironmentOverrideFileTest(DockerClientTestCase): + def test_env_file_override(self): + base_dir = 'tests/fixtures/env-file-override' + dispatch(base_dir, ['--env-file', '.env.override', 'up']) + project = get_project(project_dir=base_dir, + config_path=['docker-compose.yml'], + environment=Environment.from_env_file(base_dir, '.env.override'), + override_dir=base_dir) + containers = project.containers(stopped=True) + assert len(containers) == 1 + assert "WHEREAMI=override" in containers[0].get('Config.Env') + assert "DEFAULT_CONF_LOADED=true" in containers[0].get('Config.Env') + dispatch(base_dir, ['--env-file', '.env.override', 'down'], None) From 661ac20e5d0a926fbca015133eda0574a9e4a1bd Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Tue, 6 Aug 2019 15:28:46 +0200 Subject: [PATCH 3739/4072] "Bump 1.25.0-rc2" Signed-off-by: Ulysses Souza --- CHANGELOG.md | 71 +++++++++++++++++++++++++++++++++++++++++++++ compose/__init__.py | 2 +- script/run/run.sh | 2 +- 3 files changed, 73 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a3ad8bfd5f..89f02881221 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,77 @@ Change log ========== +1.25.0-rc2 (2019-08-06) +------------------- + +### Features + +- Add tag `docker-compose:latest` + +- Add `docker-compose:-alpine` image/tag + +- Add `docker-compose:-debian` image/tag + +- Bumped `docker-py` 4.0.1 + +- Supports `requests` up to 2.22.0 version + +- Drops empty tag on `build:cache_from` + +- `Dockerfile` now generates `libmusl` binaries for alpine + +- Only pull images that can't be built + +- Attribute `scale` can now accept `0` as a value + +- Added `--quiet` build flag + +- Added `--no-interpolate` to `docker-compose config` + +- Bump OpenSSL for macOS build (`1.1.0j` to `1.1.1a`) + +- Added `--no-rm` to `build` command + +- Added support for `credential_spec` + +- Resolve digests without pulling image + +- Upgrade `pyyaml` to `4.2b1` + +- Lowered severity to `warning` if `down` tries to remove nonexisting image + +- Use improved API fields for project events when possible + +- Update `setup.py` for modern `pypi/setuptools` and remove `pandoc` dependencies + +- Removed `Dockerfile.armhf` which is no longer needed + +### Bugfixes + +- Fixed stdin_open + +- Fixed `--remove-orphans` when used with `up --no-start` + +- Fixed `docker-compose ps --all` + +- Fixed `depends_on` dependency recreation behavior + +- Fixed bash completion for `build --memory` + +- Fixed misleading warning concerning env vars when performing an `exec` command + +- Fixed failure check in parallel_execute_watch + +- Fixed race condition after pulling image + +- Fixed error on duplicate mount points. + +- Fixed merge on networks section + +- Always connect Compose container to `stdin` + +- Fixed the presentation of failed services on 'docker-compose start' when containers are not available + 1.24.0 (2019-03-28) ------------------- diff --git a/compose/__init__.py b/compose/__init__.py index 0042896b658..df0fd3fbd82 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.25.0dev' +__version__ = '1.25.0-rc2' diff --git a/script/run/run.sh b/script/run/run.sh index f3456720fa5..8756ae34a44 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.24.0" +VERSION="1.25.0-rc2" IMAGE="docker/compose:$VERSION" From 60dcf87cc08bef7d45f1b376fe54374c3c1f99a1 Mon Sep 17 00:00:00 2001 From: aiordache Date: Mon, 19 Aug 2019 17:30:28 +0200 Subject: [PATCH 3740/4072] update alpine version to 3.10.1 Signed-off-by: aiordache --- Dockerfile | 2 +- Dockerfile.s390x | 2 +- tests/acceptance/cli_test.py | 4 ++-- tests/fixtures/compatibility-mode/docker-compose.yml | 2 +- tests/fixtures/default-env-file/alt/.env | 2 +- tests/fixtures/networks/docker-compose.yml | 6 +++--- tests/unit/config/config_test.py | 4 ++-- 7 files changed, 11 insertions(+), 11 deletions(-) diff --git a/Dockerfile b/Dockerfile index ed9d74e5ea8..a45b1dd6410 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ ARG DOCKER_VERSION=18.09.7 ARG PYTHON_VERSION=3.7.4 ARG BUILD_ALPINE_VERSION=3.10 ARG BUILD_DEBIAN_VERSION=slim-stretch -ARG RUNTIME_ALPINE_VERSION=3.10.0 +ARG RUNTIME_ALPINE_VERSION=3.10.1 ARG RUNTIME_DEBIAN_VERSION=stretch-20190708-slim ARG BUILD_PLATFORM=alpine diff --git a/Dockerfile.s390x b/Dockerfile.s390x index 3b19bb390b9..9bae72d6734 100644 --- a/Dockerfile.s390x +++ b/Dockerfile.s390x @@ -1,4 +1,4 @@ -FROM s390x/alpine:3.6 +FROM s390x/alpine:3.10.1 ARG COMPOSE_VERSION=1.16.1 diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 77b46c2790f..14dbb7d6cdb 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -360,7 +360,7 @@ def test_config_with_dot_env_and_override_dir(self): 'services': { 'web': { 'command': 'echo uwu', - 'image': 'alpine:3.4', + 'image': 'alpine:3.10.1', 'ports': ['3341/tcp', '4449/tcp'] } }, @@ -559,7 +559,7 @@ def test_config_compatibility_mode(self): 'services': { 'foo': { 'command': '/bin/true', - 'image': 'alpine:3.7', + 'image': 'alpine:3.10.1', 'scale': 3, 'restart': 'always:7', 'mem_limit': '300M', diff --git a/tests/fixtures/compatibility-mode/docker-compose.yml b/tests/fixtures/compatibility-mode/docker-compose.yml index 8187b110c83..4b63fadfb9e 100644 --- a/tests/fixtures/compatibility-mode/docker-compose.yml +++ b/tests/fixtures/compatibility-mode/docker-compose.yml @@ -1,7 +1,7 @@ version: '3.5' services: foo: - image: alpine:3.7 + image: alpine:3.10.1 command: /bin/true deploy: replicas: 3 diff --git a/tests/fixtures/default-env-file/alt/.env b/tests/fixtures/default-env-file/alt/.env index 163668d2220..981c7207b19 100644 --- a/tests/fixtures/default-env-file/alt/.env +++ b/tests/fixtures/default-env-file/alt/.env @@ -1,4 +1,4 @@ -IMAGE=alpine:3.4 +IMAGE=alpine:3.10.1 COMMAND=echo uwu PORT1=3341 PORT2=4449 diff --git a/tests/fixtures/networks/docker-compose.yml b/tests/fixtures/networks/docker-compose.yml index 275376aef9b..b911c752bd6 100644 --- a/tests/fixtures/networks/docker-compose.yml +++ b/tests/fixtures/networks/docker-compose.yml @@ -2,17 +2,17 @@ version: "2" services: web: - image: alpine:3.7 + image: alpine:3.10.1 command: top networks: ["front"] app: - image: alpine:3.7 + image: alpine:3.10.1 command: top networks: ["front", "back"] links: - "db:database" db: - image: alpine:3.7 + image: alpine:3.10.1 command: top networks: ["back"] diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index b583422f54f..5ad1a23341c 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -3620,7 +3620,7 @@ def test_compatibility_mode_load(self): 'version': '3.5', 'services': { 'foo': { - 'image': 'alpine:3.7', + 'image': 'alpine:3.10.1', 'deploy': { 'replicas': 3, 'restart_policy': { @@ -3646,7 +3646,7 @@ def test_compatibility_mode_load(self): service_dict = cfg.services[0] assert service_dict == { - 'image': 'alpine:3.7', + 'image': 'alpine:3.10.1', 'scale': 3, 'restart': {'MaximumRetryCount': 7, 'Name': 'always'}, 'mem_limit': '300M', From 672ced8742f517b7d7e17c50edd50f3748575a05 Mon Sep 17 00:00:00 2001 From: Samuel Searles-Bryant Date: Thu, 22 Aug 2019 11:42:48 +0100 Subject: [PATCH 3741/4072] Change Formatter.table method to staticmethod Make this a staticmethod so it's easier to use without needing to init a Formatter object first. Signed-off-by: Samuel Searles-Bryant --- compose/cli/formatter.py | 6 ++++-- compose/cli/main.py | 6 +++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/compose/cli/formatter.py b/compose/cli/formatter.py index 6c0a3695a4f..13794b89ad8 100644 --- a/compose/cli/formatter.py +++ b/compose/cli/formatter.py @@ -18,9 +18,11 @@ def get_tty_width(): return int(width) -class Formatter(object): +class Formatter: """Format tabular data for printing.""" - def table(self, headers, rows): + + @staticmethod + def table(headers, rows): table = texttable.Texttable(max_width=get_tty_width()) table.set_cols_dtype(['t' for h in headers]) table.add_rows([headers] + rows) diff --git a/compose/cli/main.py b/compose/cli/main.py index 477b57b52ef..86da056eac3 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -613,7 +613,7 @@ def add_default_tag(img_name): image_id, size ]) - print(Formatter().table(headers, rows)) + print(Formatter.table(headers, rows)) def kill(self, options): """ @@ -747,7 +747,7 @@ def ps(self, options): container.human_readable_state, container.human_readable_ports, ]) - print(Formatter().table(headers, rows)) + print(Formatter.table(headers, rows)) def pull(self, options): """ @@ -987,7 +987,7 @@ def top(self, options): rows.append(process) print(container.name) - print(Formatter().table(headers, rows)) + print(Formatter.table(headers, rows)) def unpause(self, options): """ From cacbcccc0c68bfcd33f4707bd388b1441523c521 Mon Sep 17 00:00:00 2001 From: Nao YONASHIRO Date: Fri, 15 Mar 2019 20:22:14 +0900 Subject: [PATCH 3742/4072] Add support to CLI build This includes can be enabled by setting the env var `COMPOSE_NATIVE_BUILDER=1`. Signed-off-by: Nao YONASHIRO Signed-off-by: Ulysses Souza --- compose/cli/main.py | 14 +++-- compose/project.py | 13 ++++- compose/service.py | 135 ++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 150 insertions(+), 12 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 477b57b52ef..a9f9da1c4ca 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -263,14 +263,14 @@ def build(self, options): Usage: build [options] [--build-arg key=val...] [SERVICE...] Options: + --build-arg key=val Set build-time variables for services. --compress Compress the build context using gzip. --force-rm Always remove intermediate containers. + -m, --memory MEM Sets memory limit for the build container. --no-cache Do not use cache when building the image. --no-rm Do not remove intermediate containers after a successful build. - --pull Always attempt to pull a newer version of the image. - -m, --memory MEM Sets memory limit for the build container. - --build-arg key=val Set build-time variables for services. --parallel Build images in parallel. + --pull Always attempt to pull a newer version of the image. -q, --quiet Don't print anything to STDOUT """ service_names = options['SERVICE'] @@ -283,6 +283,8 @@ def build(self, options): ) build_args = resolve_build_args(build_args, self.toplevel_environment) + native_builder = self.toplevel_environment.get_boolean('COMPOSE_NATIVE_BUILDER') + self.project.build( service_names=options['SERVICE'], no_cache=bool(options.get('--no-cache', False)), @@ -293,7 +295,8 @@ def build(self, options): build_args=build_args, gzip=options.get('--compress', False), parallel_build=options.get('--parallel', False), - silent=options.get('--quiet', False) + silent=options.get('--quiet', False), + cli=native_builder, ) def bundle(self, options): @@ -1071,6 +1074,8 @@ def up(self, options): for excluded in [x for x in opts if options.get(x) and no_start]: raise UserError('--no-start and {} cannot be combined.'.format(excluded)) + native_builder = self.toplevel_environment.get_boolean('COMPOSE_NATIVE_BUILDER') + with up_shutdown_context(self.project, service_names, timeout, detached): warn_for_swarm_mode(self.project.client) @@ -1090,6 +1095,7 @@ def up(rebuild): reset_container_image=rebuild, renew_anonymous_volumes=options.get('--renew-anon-volumes'), silent=options.get('--quiet-pull'), + cli=native_builder, ) try: diff --git a/compose/project.py b/compose/project.py index a608ffd71f3..b4f55ac9539 100644 --- a/compose/project.py +++ b/compose/project.py @@ -355,7 +355,7 @@ def restart(self, service_names=None, **options): return containers def build(self, service_names=None, no_cache=False, pull=False, force_rm=False, memory=None, - build_args=None, gzip=False, parallel_build=False, rm=True, silent=False): + build_args=None, gzip=False, parallel_build=False, rm=True, silent=False, cli=False): services = [] for service in self.get_services(service_names): @@ -364,8 +364,11 @@ def build(self, service_names=None, no_cache=False, pull=False, force_rm=False, elif not silent: log.info('%s uses an image, skipping' % service.name) + if cli: + log.warning("Native build is an experimental feature and could change at any time") + def build_service(service): - service.build(no_cache, pull, force_rm, memory, build_args, gzip, rm, silent) + service.build(no_cache, pull, force_rm, memory, build_args, gzip, rm, silent, cli) if parallel_build: _, errors = parallel.parallel_execute( services, @@ -509,8 +512,12 @@ def up(self, reset_container_image=False, renew_anonymous_volumes=False, silent=False, + cli=False, ): + if cli: + log.warning("Native build is an experimental feature and could change at any time") + self.initialize() if not ignore_orphans: self.find_orphan_containers(remove_orphans) @@ -523,7 +530,7 @@ def up(self, include_deps=start_deps) for svc in services: - svc.ensure_image_exists(do_build=do_build, silent=silent) + svc.ensure_image_exists(do_build=do_build, silent=silent, cli=cli) plans = self._get_convergence_plans( services, strategy, always_recreate_deps=always_recreate_deps) diff --git a/compose/service.py b/compose/service.py index 0db35438df7..9457bfd2b48 100644 --- a/compose/service.py +++ b/compose/service.py @@ -2,10 +2,13 @@ from __future__ import unicode_literals import itertools +import json import logging import os import re +import subprocess import sys +import tempfile from collections import namedtuple from collections import OrderedDict from operator import attrgetter @@ -338,9 +341,9 @@ def create_container(self, raise OperationFailedError("Cannot create container for service %s: %s" % (self.name, ex.explanation)) - def ensure_image_exists(self, do_build=BuildAction.none, silent=False): + def ensure_image_exists(self, do_build=BuildAction.none, silent=False, cli=False): if self.can_be_built() and do_build == BuildAction.force: - self.build() + self.build(cli=cli) return try: @@ -356,7 +359,7 @@ def ensure_image_exists(self, do_build=BuildAction.none, silent=False): if do_build == BuildAction.skip: raise NeedsBuildError(self) - self.build() + self.build(cli=cli) log.warning( "Image for service {} was built because it did not already exist. To " "rebuild this image you must use `docker-compose build` or " @@ -1049,7 +1052,7 @@ def build_spec(secret): return [build_spec(secret) for secret in self.secrets] def build(self, no_cache=False, pull=False, force_rm=False, memory=None, build_args_override=None, - gzip=False, rm=True, silent=False): + gzip=False, rm=True, silent=False, cli=False): output_stream = open(os.devnull, 'w') if not silent: output_stream = sys.stdout @@ -1070,7 +1073,8 @@ def build(self, no_cache=False, pull=False, force_rm=False, memory=None, build_a 'Impossible to perform platform-targeted builds for API version < 1.35' ) - build_output = self.client.build( + build_image = self.client.build if not cli else cli_build + build_output = build_image( path=path, tag=self.image_name, rm=rm, @@ -1701,3 +1705,124 @@ def rewrite_build_path(path): path = WINDOWS_LONGPATH_PREFIX + os.path.normpath(path) return path + + +def cli_build(path, tag=None, quiet=False, fileobj=None, + nocache=False, rm=False, timeout=None, + custom_context=False, encoding=None, pull=False, + forcerm=False, dockerfile=None, container_limits=None, + decode=False, buildargs=None, gzip=False, shmsize=None, + labels=None, cache_from=None, target=None, network_mode=None, + squash=None, extra_hosts=None, platform=None, isolation=None, + use_config_proxy=True): + """ + Args: + path (str): Path to the directory containing the Dockerfile + buildargs (dict): A dictionary of build arguments + cache_from (:py:class:`list`): A list of images used for build + cache resolution + container_limits (dict): A dictionary of limits applied to each + container created by the build process. Valid keys: + - memory (int): set memory limit for build + - memswap (int): Total memory (memory + swap), -1 to disable + swap + - cpushares (int): CPU shares (relative weight) + - cpusetcpus (str): CPUs in which to allow execution, e.g., + ``"0-3"``, ``"0,1"`` + custom_context (bool): Optional if using ``fileobj`` + decode (bool): If set to ``True``, the returned stream will be + decoded into dicts on the fly. Default ``False`` + dockerfile (str): path within the build context to the Dockerfile + encoding (str): The encoding for a stream. Set to ``gzip`` for + compressing + extra_hosts (dict): Extra hosts to add to /etc/hosts in building + containers, as a mapping of hostname to IP address. + fileobj: A file object to use as the Dockerfile. (Or a file-like + object) + forcerm (bool): Always remove intermediate containers, even after + unsuccessful builds + isolation (str): Isolation technology used during build. + Default: `None`. + labels (dict): A dictionary of labels to set on the image + network_mode (str): networking mode for the run commands during + build + nocache (bool): Don't use the cache when set to ``True`` + platform (str): Platform in the format ``os[/arch[/variant]]`` + pull (bool): Downloads any updates to the FROM image in Dockerfiles + quiet (bool): Whether to return the status + rm (bool): Remove intermediate containers. The ``docker build`` + command now defaults to ``--rm=true``, but we have kept the old + default of `False` to preserve backward compatibility + shmsize (int): Size of `/dev/shm` in bytes. The size must be + greater than 0. If omitted the system uses 64MB + squash (bool): Squash the resulting images layers into a + single layer. + tag (str): A tag to add to the final image + target (str): Name of the build-stage to build in a multi-stage + Dockerfile + timeout (int): HTTP timeout + use_config_proxy (bool): If ``True``, and if the docker client + configuration file (``~/.docker/config.json`` by default) + contains a proxy configuration, the corresponding environment + variables will be set in the container being built. + Returns: + A generator for the build output. + """ + if dockerfile: + dockerfile = os.path.join(path, dockerfile) + iidfile = tempfile.mktemp() + + command_builder = _CommandBuilder() + command_builder.add_params("--build-arg", buildargs) + command_builder.add_arg("--file", dockerfile) + command_builder.add_flag("--force-rm", forcerm) + command_builder.add_arg("--memory", container_limits.get("memory")) + command_builder.add_flag("--no-cache", nocache) + command_builder.add_flag("--pull", pull) + command_builder.add_arg("--iidfile", iidfile) + args = command_builder.build([path]) + + magic_word = "Successfully built " + appear = False + with subprocess.Popen(args, stdout=subprocess.PIPE, universal_newlines=True) as p: + while True: + line = p.stdout.readline() + if not line: + break + if line.startswith(magic_word): + appear = True + yield json.dumps({"stream": line}) + + with open(iidfile) as f: + line = f.readline() + image_id = line.split(":")[1].strip() + os.remove(iidfile) + + if not appear: + yield json.dumps({"stream": "{}{}\n".format(magic_word, image_id)}) + + +class _CommandBuilder(object): + def __init__(self): + self._args = ["docker", "build"] + + def add_arg(self, name, value): + if value: + self._args.extend([name, str(value)]) + + def add_flag(self, name, flag): + if flag: + self._args.extend([name]) + + def add_params(self, name, params): + if params: + for key, val in params.items(): + self._args.extend([name, "{}={}".format(key, val)]) + + def add_list(self, name, values): + if values: + for val in values: + self._args.extend([name, val]) + + def build(self, args): + return self._args + args From 862a13b8f3f9eceadef80d47a671247d7ba63669 Mon Sep 17 00:00:00 2001 From: Nao YONASHIRO Date: Sun, 25 Aug 2019 21:04:44 +0900 Subject: [PATCH 3743/4072] fix: add build flags Signed-off-by: Nao YONASHIRO --- compose/service.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/compose/service.py b/compose/service.py index 9457bfd2b48..4688d6c1d57 100644 --- a/compose/service.py +++ b/compose/service.py @@ -1774,11 +1774,14 @@ def cli_build(path, tag=None, quiet=False, fileobj=None, command_builder = _CommandBuilder() command_builder.add_params("--build-arg", buildargs) + command_builder.add_list("--cache-from", cache_from) command_builder.add_arg("--file", dockerfile) command_builder.add_flag("--force-rm", forcerm) command_builder.add_arg("--memory", container_limits.get("memory")) command_builder.add_flag("--no-cache", nocache) command_builder.add_flag("--pull", pull) + command_builder.add_arg("--tag", tag) + command_builder.add_arg("--target", target) command_builder.add_arg("--iidfile", iidfile) args = command_builder.build([path]) From 81e223d499f5cfd4935f322b2006d4c41067bca0 Mon Sep 17 00:00:00 2001 From: Nao YONASHIRO Date: Wed, 28 Aug 2019 05:34:47 +0900 Subject: [PATCH 3744/4072] feat: add --progress flag Signed-off-by: Nao YONASHIRO --- compose/cli/main.py | 4 + compose/project.py | 5 +- compose/service.py | 189 +++++++++++++++++++++++--------------------- 3 files changed, 104 insertions(+), 94 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index a9f9da1c4ca..0850f73b0c6 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -270,6 +270,9 @@ def build(self, options): --no-cache Do not use cache when building the image. --no-rm Do not remove intermediate containers after a successful build. --parallel Build images in parallel. + --progress string Set type of progress output (auto, plain, tty). + EXPERIMENTAL flag for native builder. + To enable, run with COMPOSE_NATIVE_BUILDER=1) --pull Always attempt to pull a newer version of the image. -q, --quiet Don't print anything to STDOUT """ @@ -297,6 +300,7 @@ def build(self, options): parallel_build=options.get('--parallel', False), silent=options.get('--quiet', False), cli=native_builder, + progress=options.get('--progress'), ) def bundle(self, options): diff --git a/compose/project.py b/compose/project.py index b4f55ac9539..d41bcc8afc5 100644 --- a/compose/project.py +++ b/compose/project.py @@ -355,7 +355,8 @@ def restart(self, service_names=None, **options): return containers def build(self, service_names=None, no_cache=False, pull=False, force_rm=False, memory=None, - build_args=None, gzip=False, parallel_build=False, rm=True, silent=False, cli=False): + build_args=None, gzip=False, parallel_build=False, rm=True, silent=False, cli=False, + progress=None): services = [] for service in self.get_services(service_names): @@ -368,7 +369,7 @@ def build(self, service_names=None, no_cache=False, pull=False, force_rm=False, log.warning("Native build is an experimental feature and could change at any time") def build_service(service): - service.build(no_cache, pull, force_rm, memory, build_args, gzip, rm, silent, cli) + service.build(no_cache, pull, force_rm, memory, build_args, gzip, rm, silent, cli, progress) if parallel_build: _, errors = parallel.parallel_execute( services, diff --git a/compose/service.py b/compose/service.py index 4688d6c1d57..73d38287c25 100644 --- a/compose/service.py +++ b/compose/service.py @@ -1052,7 +1052,7 @@ def build_spec(secret): return [build_spec(secret) for secret in self.secrets] def build(self, no_cache=False, pull=False, force_rm=False, memory=None, build_args_override=None, - gzip=False, rm=True, silent=False, cli=False): + gzip=False, rm=True, silent=False, cli=False, progress=None): output_stream = open(os.devnull, 'w') if not silent: output_stream = sys.stdout @@ -1073,8 +1073,8 @@ def build(self, no_cache=False, pull=False, force_rm=False, memory=None, build_a 'Impossible to perform platform-targeted builds for API version < 1.35' ) - build_image = self.client.build if not cli else cli_build - build_output = build_image( + builder = self.client if not cli else _CLIBuilder(progress) + build_output = builder.build( path=path, tag=self.image_name, rm=rm, @@ -1707,7 +1707,11 @@ def rewrite_build_path(path): return path -def cli_build(path, tag=None, quiet=False, fileobj=None, +class _CLIBuilder(object): + def __init__(self, progress): + self._progress = progress + + def build(self, path, tag=None, quiet=False, fileobj=None, nocache=False, rm=False, timeout=None, custom_context=False, encoding=None, pull=False, forcerm=False, dockerfile=None, container_limits=None, @@ -1715,94 +1719,95 @@ def cli_build(path, tag=None, quiet=False, fileobj=None, labels=None, cache_from=None, target=None, network_mode=None, squash=None, extra_hosts=None, platform=None, isolation=None, use_config_proxy=True): - """ - Args: - path (str): Path to the directory containing the Dockerfile - buildargs (dict): A dictionary of build arguments - cache_from (:py:class:`list`): A list of images used for build - cache resolution - container_limits (dict): A dictionary of limits applied to each - container created by the build process. Valid keys: - - memory (int): set memory limit for build - - memswap (int): Total memory (memory + swap), -1 to disable - swap - - cpushares (int): CPU shares (relative weight) - - cpusetcpus (str): CPUs in which to allow execution, e.g., - ``"0-3"``, ``"0,1"`` - custom_context (bool): Optional if using ``fileobj`` - decode (bool): If set to ``True``, the returned stream will be - decoded into dicts on the fly. Default ``False`` - dockerfile (str): path within the build context to the Dockerfile - encoding (str): The encoding for a stream. Set to ``gzip`` for - compressing - extra_hosts (dict): Extra hosts to add to /etc/hosts in building - containers, as a mapping of hostname to IP address. - fileobj: A file object to use as the Dockerfile. (Or a file-like - object) - forcerm (bool): Always remove intermediate containers, even after - unsuccessful builds - isolation (str): Isolation technology used during build. - Default: `None`. - labels (dict): A dictionary of labels to set on the image - network_mode (str): networking mode for the run commands during - build - nocache (bool): Don't use the cache when set to ``True`` - platform (str): Platform in the format ``os[/arch[/variant]]`` - pull (bool): Downloads any updates to the FROM image in Dockerfiles - quiet (bool): Whether to return the status - rm (bool): Remove intermediate containers. The ``docker build`` - command now defaults to ``--rm=true``, but we have kept the old - default of `False` to preserve backward compatibility - shmsize (int): Size of `/dev/shm` in bytes. The size must be - greater than 0. If omitted the system uses 64MB - squash (bool): Squash the resulting images layers into a - single layer. - tag (str): A tag to add to the final image - target (str): Name of the build-stage to build in a multi-stage - Dockerfile - timeout (int): HTTP timeout - use_config_proxy (bool): If ``True``, and if the docker client - configuration file (``~/.docker/config.json`` by default) - contains a proxy configuration, the corresponding environment - variables will be set in the container being built. - Returns: - A generator for the build output. - """ - if dockerfile: - dockerfile = os.path.join(path, dockerfile) - iidfile = tempfile.mktemp() - - command_builder = _CommandBuilder() - command_builder.add_params("--build-arg", buildargs) - command_builder.add_list("--cache-from", cache_from) - command_builder.add_arg("--file", dockerfile) - command_builder.add_flag("--force-rm", forcerm) - command_builder.add_arg("--memory", container_limits.get("memory")) - command_builder.add_flag("--no-cache", nocache) - command_builder.add_flag("--pull", pull) - command_builder.add_arg("--tag", tag) - command_builder.add_arg("--target", target) - command_builder.add_arg("--iidfile", iidfile) - args = command_builder.build([path]) - - magic_word = "Successfully built " - appear = False - with subprocess.Popen(args, stdout=subprocess.PIPE, universal_newlines=True) as p: - while True: - line = p.stdout.readline() - if not line: - break - if line.startswith(magic_word): - appear = True - yield json.dumps({"stream": line}) - - with open(iidfile) as f: - line = f.readline() - image_id = line.split(":")[1].strip() - os.remove(iidfile) - - if not appear: - yield json.dumps({"stream": "{}{}\n".format(magic_word, image_id)}) + """ + Args: + path (str): Path to the directory containing the Dockerfile + buildargs (dict): A dictionary of build arguments + cache_from (:py:class:`list`): A list of images used for build + cache resolution + container_limits (dict): A dictionary of limits applied to each + container created by the build process. Valid keys: + - memory (int): set memory limit for build + - memswap (int): Total memory (memory + swap), -1 to disable + swap + - cpushares (int): CPU shares (relative weight) + - cpusetcpus (str): CPUs in which to allow execution, e.g., + ``"0-3"``, ``"0,1"`` + custom_context (bool): Optional if using ``fileobj`` + decode (bool): If set to ``True``, the returned stream will be + decoded into dicts on the fly. Default ``False`` + dockerfile (str): path within the build context to the Dockerfile + encoding (str): The encoding for a stream. Set to ``gzip`` for + compressing + extra_hosts (dict): Extra hosts to add to /etc/hosts in building + containers, as a mapping of hostname to IP address. + fileobj: A file object to use as the Dockerfile. (Or a file-like + object) + forcerm (bool): Always remove intermediate containers, even after + unsuccessful builds + isolation (str): Isolation technology used during build. + Default: `None`. + labels (dict): A dictionary of labels to set on the image + network_mode (str): networking mode for the run commands during + build + nocache (bool): Don't use the cache when set to ``True`` + platform (str): Platform in the format ``os[/arch[/variant]]`` + pull (bool): Downloads any updates to the FROM image in Dockerfiles + quiet (bool): Whether to return the status + rm (bool): Remove intermediate containers. The ``docker build`` + command now defaults to ``--rm=true``, but we have kept the old + default of `False` to preserve backward compatibility + shmsize (int): Size of `/dev/shm` in bytes. The size must be + greater than 0. If omitted the system uses 64MB + squash (bool): Squash the resulting images layers into a + single layer. + tag (str): A tag to add to the final image + target (str): Name of the build-stage to build in a multi-stage + Dockerfile + timeout (int): HTTP timeout + use_config_proxy (bool): If ``True``, and if the docker client + configuration file (``~/.docker/config.json`` by default) + contains a proxy configuration, the corresponding environment + variables will be set in the container being built. + Returns: + A generator for the build output. + """ + if dockerfile: + dockerfile = os.path.join(path, dockerfile) + iidfile = tempfile.mktemp() + + command_builder = _CommandBuilder() + command_builder.add_params("--build-arg", buildargs) + command_builder.add_list("--cache-from", cache_from) + command_builder.add_arg("--file", dockerfile) + command_builder.add_flag("--force-rm", forcerm) + command_builder.add_arg("--memory", container_limits.get("memory")) + command_builder.add_flag("--no-cache", nocache) + command_builder.add_flag("--progress", self._progress) + command_builder.add_flag("--pull", pull) + command_builder.add_arg("--tag", tag) + command_builder.add_arg("--target", target) + command_builder.add_arg("--iidfile", iidfile) + args = command_builder.build([path]) + + magic_word = "Successfully built " + appear = False + with subprocess.Popen(args, stdout=subprocess.PIPE, universal_newlines=True) as p: + while True: + line = p.stdout.readline() + if not line: + break + if line.startswith(magic_word): + appear = True + yield json.dumps({"stream": line}) + + with open(iidfile) as f: + line = f.readline() + image_id = line.split(":")[1].strip() + os.remove(iidfile) + + if not appear: + yield json.dumps({"stream": "{}{}\n".format(magic_word, image_id)}) class _CommandBuilder(object): From 15e8edca3c6853a7c48cffce9eee00486a8a84c0 Mon Sep 17 00:00:00 2001 From: Nao YONASHIRO Date: Wed, 28 Aug 2019 05:53:46 +0900 Subject: [PATCH 3745/4072] feat: add a warning if someone uses the --compress or --parallel flag Signed-off-by: Nao YONASHIRO --- compose/project.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/compose/project.py b/compose/project.py index d41bcc8afc5..bbaa1094e95 100644 --- a/compose/project.py +++ b/compose/project.py @@ -367,6 +367,10 @@ def build(self, service_names=None, no_cache=False, pull=False, force_rm=False, if cli: log.warning("Native build is an experimental feature and could change at any time") + if parallel_build: + log.warning("unavailable --parallel on COMPOSE_NATIVE_BUILDER=1") + if gzip: + log.warning("unavailable --compress on COMPOSE_NATIVE_BUILDER=1") def build_service(service): service.build(no_cache, pull, force_rm, memory, build_args, gzip, rm, silent, cli, progress) From ee8ca5d6f8d929f52d4ddecef897ff31009f8aea Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Wed, 28 Aug 2019 14:08:32 +0200 Subject: [PATCH 3746/4072] Rephrase warnings when building with the cli Signed-off-by: Ulysses Souza --- compose/project.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/compose/project.py b/compose/project.py index bbaa1094e95..c84fa77b156 100644 --- a/compose/project.py +++ b/compose/project.py @@ -368,9 +368,11 @@ def build(self, service_names=None, no_cache=False, pull=False, force_rm=False, if cli: log.warning("Native build is an experimental feature and could change at any time") if parallel_build: - log.warning("unavailable --parallel on COMPOSE_NATIVE_BUILDER=1") + log.warning("Flag '--parallel' is ignored when building with " + "COMPOSE_NATIVE_BUILDER=1") if gzip: - log.warning("unavailable --compress on COMPOSE_NATIVE_BUILDER=1") + log.warning("Flag '--compress' is ignored when building with " + "COMPOSE_NATIVE_BUILDER=1") def build_service(service): service.build(no_cache, pull, force_rm, memory, build_args, gzip, rm, silent, cli, progress) From bbdb3cab881692c0785b1bd61217db9822db76f1 Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Wed, 28 Aug 2019 15:14:24 +0200 Subject: [PATCH 3747/4072] Add integration tests to native builder Signed-off-by: Ulysses Souza --- tests/integration/service_test.py | 39 +++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 9750f581cff..738391c053d 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -38,6 +38,8 @@ from compose.errors import OperationFailedError from compose.parallel import ParallelStreamWriter from compose.project import OneOffFilter +from compose.project import Project +from compose.service import BuildAction from compose.service import ConvergencePlan from compose.service import ConvergenceStrategy from compose.service import NetworkMode @@ -966,6 +968,43 @@ def test_build(self): assert self.client.inspect_image('composetest_web') + def test_build_cli(self): + base_dir = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, base_dir) + + with open(os.path.join(base_dir, 'Dockerfile'), 'w') as f: + f.write("FROM busybox\n") + + service = self.create_service('web', + build={'context': base_dir}, + environment={ + 'COMPOSE_NATIVE_BUILDER': '1', + 'DOCKER_BUILDKIT': '1', + }) + service.build(cli=True) + self.addCleanup(self.client.remove_image, service.image_name) + assert self.client.inspect_image('composetest_web') + + def test_up_build_cli(self): + base_dir = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, base_dir) + + with open(os.path.join(base_dir, 'Dockerfile'), 'w') as f: + f.write("FROM busybox\n") + + web = self.create_service('web', + build={'context': base_dir}, + environment={ + 'COMPOSE_NATIVE_BUILDER': '1', + 'DOCKER_BUILDKIT': '1', + }) + project = Project('composetest', [web], self.client) + project.up(do_build=BuildAction.force) + + containers = project.containers(['web']) + assert len(containers) == 1 + assert containers[0].name.startswith('composetest_web_') + def test_build_non_ascii_filename(self): base_dir = tempfile.mkdtemp() self.addCleanup(shutil.rmtree, base_dir) From 719a1b05818cc0dcb4515dc3a704e468061b2455 Mon Sep 17 00:00:00 2001 From: Nao YONASHIRO Date: Thu, 29 Aug 2019 19:56:31 +0900 Subject: [PATCH 3748/4072] fix: use subprocess32 for python2 Signed-off-by: Nao YONASHIRO --- compose/service.py | 6 +++++- setup.py | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/compose/service.py b/compose/service.py index 73d38287c25..987ba64f1cd 100644 --- a/compose/service.py +++ b/compose/service.py @@ -6,7 +6,6 @@ import logging import os import re -import subprocess import sys import tempfile from collections import namedtuple @@ -62,6 +61,11 @@ from .utils import truncate_id from .utils import unique_everseen +if six.PY2: + import subprocess32 as subprocess +else: + import subprocess + log = logging.getLogger(__name__) diff --git a/setup.py b/setup.py index 4b47c8bf10f..c3e214c22d0 100644 --- a/setup.py +++ b/setup.py @@ -52,6 +52,7 @@ def find_version(*file_paths): tests_require.append('mock >= 1.0.1, < 4') extras_require = { + ':python_version < "3.2"': ['subprocess32 >= 3.5.4, < 4'], ':python_version < "3.4"': ['enum34 >= 1.0.4, < 2'], ':python_version < "3.5"': ['backports.ssl_match_hostname >= 3.5, < 4'], ':python_version < "3.3"': ['ipaddress >= 1.0.16, < 2'], From 9d7ad3bac17a174a85f6d9f4e20d04b2e65b8c66 Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Thu, 29 Aug 2019 16:29:09 +0200 Subject: [PATCH 3749/4072] Add comment on native build and fix typo Signed-off-by: Ulysses Souza --- compose/cli/main.py | 2 +- compose/service.py | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 0850f73b0c6..244ac64f0a6 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -266,7 +266,7 @@ def build(self, options): --build-arg key=val Set build-time variables for services. --compress Compress the build context using gzip. --force-rm Always remove intermediate containers. - -m, --memory MEM Sets memory limit for the build container. + -m, --memory MEM Set memory limit for the build container. --no-cache Do not use cache when building the image. --no-rm Do not remove intermediate containers after a successful build. --parallel Build images in parallel. diff --git a/compose/service.py b/compose/service.py index 987ba64f1cd..55d2e9cd161 100644 --- a/compose/service.py +++ b/compose/service.py @@ -1810,6 +1810,10 @@ def build(self, path, tag=None, quiet=False, fileobj=None, image_id = line.split(":")[1].strip() os.remove(iidfile) + # In case of `DOCKER_BUILDKIT=1` + # there is no success message already present in the output. + # Since that's the way `Service::build` gets the `image_id` + # it has to be added `manually` if not appear: yield json.dumps({"stream": "{}{}\n".format(magic_word, image_id)}) From 0c6fce271e949955693ed86461d8a0e6adcd18d4 Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Thu, 29 Aug 2019 17:45:21 +0200 Subject: [PATCH 3750/4072] Bump runtime debian From `stretch-20190708-slim` to `stretch-20190812-slim` Signed-off-by: Ulysses Souza --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index a45b1dd6410..a5398413c5b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ ARG PYTHON_VERSION=3.7.4 ARG BUILD_ALPINE_VERSION=3.10 ARG BUILD_DEBIAN_VERSION=slim-stretch ARG RUNTIME_ALPINE_VERSION=3.10.1 -ARG RUNTIME_DEBIAN_VERSION=stretch-20190708-slim +ARG RUNTIME_DEBIAN_VERSION=stretch-20190812-slim ARG BUILD_PLATFORM=alpine From 5add9192ac52a5c72ecc1495aa68cbfeb5a8e863 Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Fri, 30 Aug 2019 12:11:09 +0200 Subject: [PATCH 3751/4072] Rename envvar switch to COMPOSE_DOCKER_CLI_BUILD From `COMPOSE_NATIVE_BUILDER` to `COMPOSE_DOCKER_CLI_BUILD` Signed-off-by: Ulysses Souza --- compose/cli/main.py | 6 +++--- compose/project.py | 4 ++-- tests/integration/service_test.py | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 244ac64f0a6..b94f41eecaa 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -272,7 +272,7 @@ def build(self, options): --parallel Build images in parallel. --progress string Set type of progress output (auto, plain, tty). EXPERIMENTAL flag for native builder. - To enable, run with COMPOSE_NATIVE_BUILDER=1) + To enable, run with COMPOSE_DOCKER_CLI_BUILD=1) --pull Always attempt to pull a newer version of the image. -q, --quiet Don't print anything to STDOUT """ @@ -286,7 +286,7 @@ def build(self, options): ) build_args = resolve_build_args(build_args, self.toplevel_environment) - native_builder = self.toplevel_environment.get_boolean('COMPOSE_NATIVE_BUILDER') + native_builder = self.toplevel_environment.get_boolean('COMPOSE_DOCKER_CLI_BUILD') self.project.build( service_names=options['SERVICE'], @@ -1078,7 +1078,7 @@ def up(self, options): for excluded in [x for x in opts if options.get(x) and no_start]: raise UserError('--no-start and {} cannot be combined.'.format(excluded)) - native_builder = self.toplevel_environment.get_boolean('COMPOSE_NATIVE_BUILDER') + native_builder = self.toplevel_environment.get_boolean('COMPOSE_DOCKER_CLI_BUILD') with up_shutdown_context(self.project, service_names, timeout, detached): warn_for_swarm_mode(self.project.client) diff --git a/compose/project.py b/compose/project.py index c84fa77b156..69e273c4be1 100644 --- a/compose/project.py +++ b/compose/project.py @@ -369,10 +369,10 @@ def build(self, service_names=None, no_cache=False, pull=False, force_rm=False, log.warning("Native build is an experimental feature and could change at any time") if parallel_build: log.warning("Flag '--parallel' is ignored when building with " - "COMPOSE_NATIVE_BUILDER=1") + "COMPOSE_DOCKER_CLI_BUILD=1") if gzip: log.warning("Flag '--compress' is ignored when building with " - "COMPOSE_NATIVE_BUILDER=1") + "COMPOSE_DOCKER_CLI_BUILD=1") def build_service(service): service.build(no_cache, pull, force_rm, memory, build_args, gzip, rm, silent, cli, progress) diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 738391c053d..c50aab08bb2 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -978,7 +978,7 @@ def test_build_cli(self): service = self.create_service('web', build={'context': base_dir}, environment={ - 'COMPOSE_NATIVE_BUILDER': '1', + 'COMPOSE_DOCKER_CLI_BUILD': '1', 'DOCKER_BUILDKIT': '1', }) service.build(cli=True) @@ -995,7 +995,7 @@ def test_up_build_cli(self): web = self.create_service('web', build={'context': base_dir}, environment={ - 'COMPOSE_NATIVE_BUILDER': '1', + 'COMPOSE_DOCKER_CLI_BUILD': '1', 'DOCKER_BUILDKIT': '1', }) project = Project('composetest', [web], self.client) From 47d170b06a0f04ca77c4083a151d48a714508a52 Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Wed, 4 Sep 2019 17:52:31 +0200 Subject: [PATCH 3752/4072] Fix race condition on watch_events Avoid to attach to restarting containers and ignore race conditions when trying to attach to already dead containers Signed-off-by: Ulysses Souza --- compose/cli/log_printer.py | 8 +++++++- tests/unit/cli/log_printer_test.py | 11 +++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/compose/cli/log_printer.py b/compose/cli/log_printer.py index 8aa93a84404..6940a74c878 100644 --- a/compose/cli/log_printer.py +++ b/compose/cli/log_printer.py @@ -230,7 +230,13 @@ def watch_events(thread_map, event_stream, presenters, thread_args): # Container crashed so we should reattach to it if event['id'] in crashed_containers: - event['container'].attach_log_stream() + container = event['container'] + if not container.is_restarting: + try: + container.attach_log_stream() + except APIError: + # Just ignore errors when reattaching to already crashed containers + pass crashed_containers.remove(event['id']) thread_map[event['id']] = build_thread( diff --git a/tests/unit/cli/log_printer_test.py b/tests/unit/cli/log_printer_test.py index 6db24e46486..5e387241d67 100644 --- a/tests/unit/cli/log_printer_test.py +++ b/tests/unit/cli/log_printer_test.py @@ -152,6 +152,17 @@ def test_start_event(self, thread_map, mock_presenters): *thread_args) assert container_id in thread_map + def test_container_attach_event(self, thread_map, mock_presenters): + container_id = 'abcd' + mock_container = mock.Mock(is_restarting=False) + mock_container.attach_log_stream.side_effect = APIError("race condition") + event_die = {'action': 'die', 'id': container_id} + event_start = {'action': 'start', 'id': container_id, 'container': mock_container} + event_stream = [event_die, event_start] + thread_args = 'foo', 'bar' + watch_events(thread_map, event_stream, mock_presenters, thread_args) + assert mock_container.attach_log_stream.called + def test_other_event(self, thread_map, mock_presenters): container_id = 'abcd' event_stream = [{'action': 'create', 'id': container_id}] From a5fbf91b72f1f32fe61b2d7b3e0cd5d53fbf2aac Mon Sep 17 00:00:00 2001 From: Danil Kister Date: Wed, 21 Aug 2019 18:51:44 +0200 Subject: [PATCH 3753/4072] Prevent KeyError when remote network labels are None. Signed-off-by: Danil Kister --- compose/network.py | 2 +- tests/unit/network_test.py | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/compose/network.py b/compose/network.py index e0d711ff701..84531ecc7b8 100644 --- a/compose/network.py +++ b/compose/network.py @@ -226,7 +226,7 @@ def check_remote_network_config(remote, local): raise NetworkConfigChangedError(local.true_name, 'enable_ipv6') local_labels = local.labels or {} - remote_labels = remote.get('Labels', {}) + remote_labels = remote.get('Labels') or {} for k in set.union(set(remote_labels.keys()), set(local_labels.keys())): if k.startswith('com.docker.'): # We are only interested in user-specified labels continue diff --git a/tests/unit/network_test.py b/tests/unit/network_test.py index 82cfb3be28b..b829de196be 100644 --- a/tests/unit/network_test.py +++ b/tests/unit/network_test.py @@ -168,3 +168,8 @@ def test_check_remote_network_labels_mismatch(self): mock_log.warning.assert_called_once_with(mock.ANY) _, args, kwargs = mock_log.warning.mock_calls[0] assert 'label "com.project.touhou.character" has changed' in args[0] + + def test_remote_config_labels_none(self): + remote = {'Labels': None} + local = Network(None, 'test_project', 'test_network') + check_remote_network_config(remote, local) From b9092cacdb51bb4077aee5a18e0bb750833ca4ab Mon Sep 17 00:00:00 2001 From: Marian Gappa Date: Fri, 10 Aug 2018 00:06:47 +0200 Subject: [PATCH 3754/4072] Fix missing secret error message Add a warning message when the secret file doesn't exist Fixes #5920 Signed-off-by: Marian Gappa --- compose/project.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/compose/project.py b/compose/project.py index 69e273c4be1..b2305e67fe0 100644 --- a/compose/project.py +++ b/compose/project.py @@ -6,6 +6,7 @@ import operator import re from functools import reduce +from os import path import enum import six @@ -807,7 +808,15 @@ def get_secrets(service, service_secrets, secret_defs): ) ) - secrets.append({'secret': secret, 'file': secret_def.get('file')}) + secret_file = secret_def.get('file') + if not path.isfile(str(secret_file)): + log.warn( + "Service \"{service}\" uses an undefined secret file \"{secret_file}\", " + "the following folder is created \"{secret_file}\"".format( + service=service, secret_file=secret_file + ) + ) + secrets.append({'secret': secret, 'file': secret_file}) return secrets From 70ead597d271bf87ec0b564f3901ae5809abdfbc Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Mon, 9 Sep 2019 10:03:43 +0200 Subject: [PATCH 3755/4072] Add tests to 'get_secret' warnings Signed-off-by: Ulysses Souza --- compose/project.py | 2 +- tests/unit/project_test.py | 84 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/compose/project.py b/compose/project.py index b2305e67fe0..478e50b55ed 100644 --- a/compose/project.py +++ b/compose/project.py @@ -810,7 +810,7 @@ def get_secrets(service, service_secrets, secret_defs): secret_file = secret_def.get('file') if not path.isfile(str(secret_file)): - log.warn( + log.warning( "Service \"{service}\" uses an undefined secret file \"{secret_file}\", " "the following folder is created \"{secret_file}\"".format( service=service, secret_file=secret_file diff --git a/tests/unit/project_test.py b/tests/unit/project_test.py index 93a9aa292dd..e1453e7f625 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -3,6 +3,8 @@ from __future__ import unicode_literals import datetime +import os +import tempfile import docker import pytest @@ -11,6 +13,7 @@ from .. import mock from .. import unittest from ..helpers import BUSYBOX_IMAGE_WITH_TAG +from compose.config import ConfigurationError from compose.config.config import Config from compose.config.types import VolumeFromSpec from compose.const import COMPOSEFILE_V1 as V1 @@ -21,6 +24,7 @@ from compose.const import LABEL_SERVICE from compose.container import Container from compose.errors import OperationFailedError +from compose.project import get_secrets from compose.project import NoSuchService from compose.project import Project from compose.project import ProjectError @@ -841,3 +845,83 @@ def test_avoid_multiple_push(self): with mock.patch('compose.service.Service.push') as fake_push: project.push() assert fake_push.call_count == 2 + + def test_get_secrets_no_secret_def(self): + service = 'foo' + secret_source = 'bar' + + secret_defs = mock.Mock() + secret_defs.get.return_value = None + secret = mock.Mock(source=secret_source) + + with self.assertRaises(ConfigurationError): + get_secrets(service, [secret], secret_defs) + + def test_get_secrets_external_warning(self): + service = 'foo' + secret_source = 'bar' + + secret_def = mock.Mock() + secret_def.get.return_value = True + + secret_defs = mock.Mock() + secret_defs.get.side_effect = secret_def + secret = mock.Mock(source=secret_source) + + with mock.patch('compose.project.log') as mock_log: + get_secrets(service, [secret], secret_defs) + + mock_log.warning.assert_called_with("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)) + + def test_get_secrets_uid_gid_mode_warning(self): + service = 'foo' + secret_source = 'bar' + + _, filename_path = tempfile.mkstemp() + self.addCleanup(os.remove, filename_path) + + def mock_get(key): + return {'external': False, 'file': filename_path}[key] + + secret_def = mock.MagicMock() + secret_def.get = mock.MagicMock(side_effect=mock_get) + + secret_defs = mock.Mock() + secret_defs.get.return_value = secret_def + + secret = mock.Mock(uid=True, gid=True, mode=True, source=secret_source) + + with mock.patch('compose.project.log') as mock_log: + get_secrets(service, [secret], secret_defs) + + mock_log.warning.assert_called_with("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)) + + def test_get_secrets_secret_file_warning(self): + service = 'foo' + secret_source = 'bar' + not_a_path = 'NOT_A_PATH' + + def mock_get(key): + return {'external': False, 'file': not_a_path}[key] + + secret_def = mock.MagicMock() + secret_def.get = mock.MagicMock(side_effect=mock_get) + + secret_defs = mock.Mock() + secret_defs.get.return_value = secret_def + + secret = mock.Mock(uid=False, gid=False, mode=False, source=secret_source) + + with mock.patch('compose.project.log') as mock_log: + get_secrets(service, [secret], secret_defs) + + mock_log.warning.assert_called_with("Service \"{service}\" uses an undefined secret file " + "\"{secret_file}\", the following folder is created " + "\"{secret_file}\"" + .format(service=service, secret_file=not_a_path)) From 98d7cc8d0c8b884720579ad9a9dc944517fc042d Mon Sep 17 00:00:00 2001 From: Zuhayr Elahi Date: Wed, 28 Aug 2019 11:51:22 -0700 Subject: [PATCH 3756/4072] ADDED a stage for executing License Scans Signed-off-by: Zuhayr Elahi --- script/Jenkinsfile.fossa | 20 ++++++++++++++++++++ script/fossa.mk | 16 ++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 script/Jenkinsfile.fossa create mode 100644 script/fossa.mk diff --git a/script/Jenkinsfile.fossa b/script/Jenkinsfile.fossa new file mode 100644 index 00000000000..480e98efad4 --- /dev/null +++ b/script/Jenkinsfile.fossa @@ -0,0 +1,20 @@ +pipeline { + agent any + stages { + stage("License Scan") { + agent { + label 'ubuntu-1604-aufs-edge' + } + + steps { + withCredentials([ + string(credentialsId: 'fossa-api-key', variable: 'FOSSA_API_KEY') + ]) { + checkout scm + sh "FOSSA_API_KEY='${FOSSA_API_KEY}' BRANCH_NAME='${env.BRANCH_NAME}' make -f script/fossa.mk fossa-analyze" + sh "FOSSA_API_KEY='${FOSSA_API_KEY}' make -f script/fossa.mk fossa-test" + } + } + } + } +} diff --git a/script/fossa.mk b/script/fossa.mk new file mode 100644 index 00000000000..8d7af49d82c --- /dev/null +++ b/script/fossa.mk @@ -0,0 +1,16 @@ +# Variables for Fossa +BUILD_ANALYZER?=docker/fossa-analyzer +FOSSA_OPTS?=--option all-tags:true --option allow-unresolved:true + +fossa-analyze: + docker run --rm -e FOSSA_API_KEY=$(FOSSA_API_KEY) \ + -v $(CURDIR)/$*:/go/src/github.com/docker/compose \ + -w /go/src/github.com/docker/compose \ + $(BUILD_ANALYZER) analyze ${FOSSA_OPTS} --branch ${BRANCH_NAME} + + # This command is used to run the fossa test command +fossa-test: + docker run -i -e FOSSA_API_KEY=$(FOSSA_API_KEY) \ + -v $(CURDIR)/$*:/go/src/github.com/docker/compose \ + -w /go/src/github.com/docker/compose \ + $(BUILD_ANALYZER) test From 475f8199f773936e2504c0977b970c2f9f432140 Mon Sep 17 00:00:00 2001 From: Dimitar Dimitrov Date: Tue, 24 Sep 2019 13:31:30 +0300 Subject: [PATCH 3757/4072] Fixing features broken link Signed-off-by: Dimitar Dimitrov --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index dd4003048fb..c9b87daba75 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ 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/docker.github.io/blob/master/compose/overview.md#features). +see [the list of features](https://github.com/docker/docker.github.io/blob/master/compose/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 From 32ac6edb862ba6d4f3bb8cb4869d8b2f0b27ca88 Mon Sep 17 00:00:00 2001 From: Lukas Hettwer Date: Tue, 24 Sep 2019 16:02:12 +0200 Subject: [PATCH 3758/4072] Fix --progress arg when run docker-compose build --progress is no longer processed as flag but as argument with value. Signed-off-by: Lukas Hettwer Resolve: [#6913] --- compose/service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/service.py b/compose/service.py index 55d2e9cd161..50d2c4b5251 100644 --- a/compose/service.py +++ b/compose/service.py @@ -1787,7 +1787,7 @@ def build(self, path, tag=None, quiet=False, fileobj=None, command_builder.add_flag("--force-rm", forcerm) command_builder.add_arg("--memory", container_limits.get("memory")) command_builder.add_flag("--no-cache", nocache) - command_builder.add_flag("--progress", self._progress) + command_builder.add_arg("--progress", self._progress) command_builder.add_flag("--pull", pull) command_builder.add_arg("--tag", tag) command_builder.add_arg("--target", target) From eca358e2f0583d6e85aedc58b45aaebfd38e55f0 Mon Sep 17 00:00:00 2001 From: ulyssessouza Date: Fri, 27 Sep 2019 09:10:49 +0200 Subject: [PATCH 3759/4072] Fix secret missing warning Signed-off-by: ulyssessouza --- compose/project.py | 2 +- tests/unit/project_test.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/compose/project.py b/compose/project.py index 478e50b55ed..092c1e123a0 100644 --- a/compose/project.py +++ b/compose/project.py @@ -812,7 +812,7 @@ def get_secrets(service, service_secrets, secret_defs): if not path.isfile(str(secret_file)): log.warning( "Service \"{service}\" uses an undefined secret file \"{secret_file}\", " - "the following folder is created \"{secret_file}\"".format( + "the following file should be created \"{secret_file}\"".format( service=service, secret_file=secret_file ) ) diff --git a/tests/unit/project_test.py b/tests/unit/project_test.py index e1453e7f625..de16febf54e 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -922,6 +922,6 @@ def mock_get(key): get_secrets(service, [secret], secret_defs) mock_log.warning.assert_called_with("Service \"{service}\" uses an undefined secret file " - "\"{secret_file}\", the following folder is created " + "\"{secret_file}\", the following file should be created " "\"{secret_file}\"" .format(service=service, secret_file=not_a_path)) From 70f8e38b1dbc8d34e1af4274b7e3bc756f521f99 Mon Sep 17 00:00:00 2001 From: Guillaume LOURS Date: Tue, 8 Oct 2019 11:05:30 +0200 Subject: [PATCH 3760/4072] Add automatic labeling of bug, feature & question issues Signed-off-by: Guillaume Lours --- .github/ISSUE_TEMPLATE/bug_report.md | 3 +++ .github/ISSUE_TEMPLATE/feature_request.md | 3 +++ .github/ISSUE_TEMPLATE/question-about-using-compose.md | 3 +++ 3 files changed, 9 insertions(+) diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 49d4691fb69..2f3012f6135 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,6 +1,9 @@ --- name: Bug report about: Report a bug encountered while using docker-compose +title: '' +labels: kind/bug +assignees: '' --- diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index d53c49a79da..603d34c38ab 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -1,6 +1,9 @@ --- name: Feature request about: Suggest an idea to improve Compose +title: '' +labels: kind/feature +assignees: '' --- diff --git a/.github/ISSUE_TEMPLATE/question-about-using-compose.md b/.github/ISSUE_TEMPLATE/question-about-using-compose.md index 11ef65ccfe8..ccb4e9b33bb 100644 --- a/.github/ISSUE_TEMPLATE/question-about-using-compose.md +++ b/.github/ISSUE_TEMPLATE/question-about-using-compose.md @@ -1,6 +1,9 @@ --- name: Question about using Compose about: This is not the appropriate channel +title: '' +labels: kind/question +assignees: '' --- From 74f892de955759cbcead836780126850be488a36 Mon Sep 17 00:00:00 2001 From: Aleksandr Mezin Date: Fri, 21 Dec 2018 15:07:39 +0600 Subject: [PATCH 3761/4072] Add test to verify same file 'extends' optimization Signed-off-by: Aleksandr Mezin --- tests/unit/config/config_test.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 5ad1a23341c..0d3f49b99a5 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -18,6 +18,7 @@ from ...helpers import BUSYBOX_IMAGE_WITH_TAG from compose.config import config from compose.config import types +from compose.config.config import ConfigFile from compose.config.config import resolve_build_args from compose.config.config import resolve_environment from compose.config.environment import Environment @@ -4887,6 +4888,11 @@ def test_extends_with_security_opt(self): assert types.SecurityOpt.parse('apparmor:unconfined') in svc['security_opt'] assert types.SecurityOpt.parse('seccomp:unconfined') in svc['security_opt'] + @mock.patch.object(ConfigFile, 'from_filename', wraps=ConfigFile.from_filename) + def test_extends_same_file_optimization(self, from_filename_mock): + load_from_filename('tests/fixtures/extends/no-file-specified.yml') + from_filename_mock.assert_called_once() + @pytest.mark.xfail(IS_WINDOWS_PLATFORM, reason='paths use slash') class ExpandPathTest(unittest.TestCase): From c24b7b6464b302477728b0882d2fb6bf647f2e79 Mon Sep 17 00:00:00 2001 From: Aleksandr Mezin Date: Fri, 21 Dec 2018 15:03:44 +0600 Subject: [PATCH 3762/4072] Fix same file 'extends' optimization Signed-off-by: Aleksandr Mezin --- compose/config/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/config/config.py b/compose/config/config.py index 5202d00255b..f64dc04a0f5 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -615,7 +615,7 @@ def validate_and_construct_extends(self): config_path = self.get_extended_config_path(extends) service_name = extends['service'] - if config_path == self.config_file.filename: + if config_path == os.path.abspath(self.config_file.filename): try: service_config = self.config_file.get_service(service_name) except KeyError: From 79f29dda2370b7eec9942405d45541f1f569d0f9 Mon Sep 17 00:00:00 2001 From: Stefan Scherer Date: Sat, 21 Sep 2019 17:40:58 +0200 Subject: [PATCH 3763/4072] Add dependencies for ARM build Signed-off-by: Stefan Scherer --- Dockerfile | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index a5398413c5b..64de7789087 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,15 +30,18 @@ RUN apk add --no-cache \ ENV BUILD_BOOTLOADER=1 FROM python:${PYTHON_VERSION}-${BUILD_DEBIAN_VERSION} AS build-debian -RUN apt-get update && apt-get install -y \ +RUN apt-get update && apt-get install --no-install-recommends -y \ curl \ gcc \ git \ libc-dev \ + libffi-dev \ libgcc-6-dev \ + libssl-dev \ make \ openssl \ - python2.7-dev + python2.7-dev \ + zlib1g-dev FROM build-${BUILD_PLATFORM} AS build COPY docker-compose-entrypoint.sh /usr/local/bin/ From ce52f597a0f0a509912b91befe59991906391028 Mon Sep 17 00:00:00 2001 From: Stefan Scherer Date: Mon, 23 Sep 2019 10:36:47 +0200 Subject: [PATCH 3764/4072] Enhance build script for different CPU architectures Signed-off-by: Stefan Scherer --- script/build/linux | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/script/build/linux b/script/build/linux index 28065da0844..ca5620b8527 100755 --- a/script/build/linux +++ b/script/build/linux @@ -12,6 +12,7 @@ docker build -t "${TAG}" . \ --build-arg GIT_COMMIT="${DOCKER_COMPOSE_GITSHA}" TMP_CONTAINER=$(docker create "${TAG}") mkdir -p dist -docker cp "${TMP_CONTAINER}":/usr/local/bin/docker-compose dist/docker-compose-Linux-x86_64 +ARCH=$(uname -m) +docker cp "${TMP_CONTAINER}":/usr/local/bin/docker-compose "dist/docker-compose-Linux-${ARCH}" docker container rm -f "${TMP_CONTAINER}" docker image rm -f "${TAG}" From 37be2ad9cd54e61de07a43c14ca768b2d07da851 Mon Sep 17 00:00:00 2001 From: Jean-Christophe Sirot Date: Wed, 9 Oct 2019 10:51:17 +0200 Subject: [PATCH 3765/4072] Remove set -x to make this script less verbose Signed-off-by: Jean-Christophe Sirot --- script/circle/bintray-deploy.sh | 2 -- 1 file changed, 2 deletions(-) diff --git a/script/circle/bintray-deploy.sh b/script/circle/bintray-deploy.sh index 8c8871aa68e..d508da36563 100755 --- a/script/circle/bintray-deploy.sh +++ b/script/circle/bintray-deploy.sh @@ -1,7 +1,5 @@ #!/bin/bash -set -x - curl -f -u$BINTRAY_USERNAME:$BINTRAY_API_KEY -X GET \ https://api.bintray.com/repos/docker-compose/${CIRCLE_BRANCH} From 9375c15bad6931be9cfadf198b2932f0079dc03c Mon Sep 17 00:00:00 2001 From: Guillaume Lours Date: Wed, 9 Oct 2019 16:16:57 +0200 Subject: [PATCH 3766/4072] Add config file for @probot/stale Signed-off-by: Guillaume Lours --- .github/stale.yml | 58 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 .github/stale.yml diff --git a/.github/stale.yml b/.github/stale.yml new file mode 100644 index 00000000000..10660d75957 --- /dev/null +++ b/.github/stale.yml @@ -0,0 +1,58 @@ +# Configuration for probot-stale - https://github.com/probot/stale + +# Number of days of inactivity before an Issue or Pull Request becomes stale +daysUntilStale: 180 + +# Number of days of inactivity before an Issue or Pull Request with the stale label is closed. +# Set to false to disable. If disabled, issues still need to be closed manually, but will remain marked as stale. +daysUntilClose: 7 + +# Only issues or pull requests with all of these labels are check if stale. Defaults to `[]` (disabled) +onlyLabels: [] + +# Issues or Pull Requests with these labels will never be considered stale. Set to `[]` to disable +exemptLabels: [] + +# Set to true to ignore issues in a project (defaults to false) +exemptProjects: false + +# Set to true to ignore issues in a milestone (defaults to false) +exemptMilestones: false + +# Set to true to ignore issues with an assignee (defaults to false) +exemptAssignees: true + +# Label to use when marking as stale +staleLabel: stale + +# Comment to post when marking as stale. Set to `false` to disable +markComment: > + This issue has been automatically marked as stale because it has not had + recent activity. It will be closed if no further activity occurs. Thank you + for your contributions. + +# Comment to post when removing the stale label. +unmarkComment: > + This issue has been automatically marked as not stale anymore due to the recent activity. + +# Comment to post when closing a stale Issue or Pull Request. +closeComment: > + This issue has been automatically closed because it had not recent activity during the stale period. + +# Limit the number of actions per hour, from 1-30. Default is 30 +limitPerRun: 30 + +# Limit to only `issues` or `pulls` + only: issues + +# Optionally, specify configuration settings that are specific to just 'issues' or 'pulls': +# pulls: +# daysUntilStale: 30 +# markComment: > +# This pull request has been automatically marked as stale because it has not had +# recent activity. It will be closed if no further activity occurs. Thank you +# for your contributions. + +# issues: +# exemptLabels: +# - confirmed From 79bf9ed652470e34bbca7fee390cf7306a710cf7 Mon Sep 17 00:00:00 2001 From: Guillaume Lours Date: Wed, 9 Oct 2019 21:10:18 +0200 Subject: [PATCH 3767/4072] correct invalid yaml indentation Signed-off-by: Guillaume Lours --- .github/stale.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/stale.yml b/.github/stale.yml index 10660d75957..0e42a89cbee 100644 --- a/.github/stale.yml +++ b/.github/stale.yml @@ -43,7 +43,7 @@ closeComment: > limitPerRun: 30 # Limit to only `issues` or `pulls` - only: issues +only: issues # Optionally, specify configuration settings that are specific to just 'issues' or 'pulls': # pulls: From cdae06a89ced31765391a7ab2aa70b8bb75214f9 Mon Sep 17 00:00:00 2001 From: Guillaume Lours Date: Wed, 9 Oct 2019 21:51:34 +0200 Subject: [PATCH 3768/4072] exclude issue flagged with kind/feature from stale process Signed-off-by: Guillaume Lours --- .github/stale.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/stale.yml b/.github/stale.yml index 0e42a89cbee..6de76aef987 100644 --- a/.github/stale.yml +++ b/.github/stale.yml @@ -11,7 +11,8 @@ daysUntilClose: 7 onlyLabels: [] # Issues or Pull Requests with these labels will never be considered stale. Set to `[]` to disable -exemptLabels: [] +exemptLabels: + - kind/feature # Set to true to ignore issues in a project (defaults to false) exemptProjects: false From 3135a0a8392e4a70d98bde16e58fd28d17904e1d Mon Sep 17 00:00:00 2001 From: Zuhayr Elahi Date: Tue, 8 Oct 2019 16:27:56 -0700 Subject: [PATCH 3769/4072] Added log message to check compose file Signed-off-by: Zuhayr Elahi --- compose/service.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/compose/service.py b/compose/service.py index 50d2c4b5251..f036826f6f1 100644 --- a/compose/service.py +++ b/compose/service.py @@ -623,6 +623,10 @@ def start_container(self, container, use_network_aliases=True): try: container.start() except APIError as ex: + if "driver failed programming external connectivity" in ex.explanation: + log.warn( + "Port is already in use, check the docker-compose file to see if the same" + + " port was allocated to multiple services") raise OperationFailedError("Cannot start service %s: %s" % (self.name, ex.explanation)) return container From 8835056ce46dd680ecb5d6015e183b17c3815772 Mon Sep 17 00:00:00 2001 From: Zuhayr Elahi Date: Wed, 9 Oct 2019 12:02:18 -0700 Subject: [PATCH 3770/4072] UPDATED log message Signed-off-by: Zuhayr Elahi --- compose/service.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/compose/service.py b/compose/service.py index f036826f6f1..638cd71e5bb 100644 --- a/compose/service.py +++ b/compose/service.py @@ -624,9 +624,7 @@ def start_container(self, container, use_network_aliases=True): container.start() except APIError as ex: if "driver failed programming external connectivity" in ex.explanation: - log.warn( - "Port is already in use, check the docker-compose file to see if the same" + - " port was allocated to multiple services") + log.warn("Host is already in use by another container") raise OperationFailedError("Cannot start service %s: %s" % (self.name, ex.explanation)) return container From 8973a940e6e28c3b23648dc60528cb690766fdc3 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Thu, 10 Oct 2019 08:12:11 +0200 Subject: [PATCH 3771/4072] Bump paramiko to 2.6.0 close #6953 Signed-off-by: Nicolas De Loof --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 726b43b0319..ed1c8769369 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ functools32==3.2.3.post2; python_version < '3.2' idna==2.5 ipaddress==1.0.18 jsonschema==3.0.1 -paramiko==2.4.2 +paramiko==2.6.0 pypiwin32==219; sys_platform == 'win32' and python_version < '3.6' pypiwin32==223; sys_platform == 'win32' and python_version >= '3.6' PySocks==1.6.7 From 1678a4fbe45d9e0d2a7eb0b055787f6bf25bd2a1 Mon Sep 17 00:00:00 2001 From: Guillaume Rose Date: Mon, 14 Oct 2019 21:17:15 +0200 Subject: [PATCH 3772/4072] Run CI on amd64 Signed-off-by: Guillaume Rose --- Jenkinsfile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index 4de276ada90..1d7c348e3b0 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -2,7 +2,7 @@ def buildImage = { String baseImage -> def image - wrappedNode(label: "ubuntu && !zfs", cleanWorkspace: true) { + wrappedNode(label: "ubuntu && amd64 && !zfs", cleanWorkspace: true) { stage("build image for \"${baseImage}\"") { checkout(scm) def imageName = "dockerbuildbot/compose:${baseImage}-${gitCommit()}" @@ -29,7 +29,7 @@ def buildImage = { String baseImage -> def get_versions = { String imageId, int number -> def docker_versions - wrappedNode(label: "ubuntu && !zfs") { + wrappedNode(label: "ubuntu && amd64 && !zfs") { def result = sh(script: """docker run --rm \\ --entrypoint=/code/.tox/py27/bin/python \\ ${imageId} \\ @@ -55,7 +55,7 @@ def runTests = { Map settings -> } { -> - wrappedNode(label: "ubuntu && !zfs", cleanWorkspace: true) { + wrappedNode(label: "ubuntu && amd64 && !zfs", cleanWorkspace: true) { stage("test python=${pythonVersions} / docker=${dockerVersions} / baseImage=${baseImage}") { checkout(scm) def storageDriver = sh(script: 'docker info | awk -F \': \' \'$1 == "Storage Driver" { print $2; exit }\'', returnStdout: true).trim() From dbe4d7323eb8d38c98cec2810469bcd65266edb0 Mon Sep 17 00:00:00 2001 From: Guillaume Rose Date: Mon, 14 Oct 2019 14:55:41 +0200 Subject: [PATCH 3773/4072] Add working dir, config files and env file in service labels Signed-off-by: Guillaume Rose --- compose/cli/command.py | 30 ++++++++++++++++++--- compose/const.py | 3 +++ compose/project.py | 3 ++- compose/service.py | 59 ++++++++++++++++++++++-------------------- 4 files changed, 63 insertions(+), 32 deletions(-) diff --git a/compose/cli/command.py b/compose/cli/command.py index 2f38fe5af4c..c3a10a04340 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -13,6 +13,9 @@ from .. import parallel from ..config.environment import Environment from ..const import API_VERSIONS +from ..const import LABEL_CONFIG_FILES +from ..const import LABEL_ENVIRONMENT_FILE +from ..const import LABEL_WORKING_DIR from ..project import Project from .docker_client import docker_client from .docker_client import get_tls_version @@ -57,7 +60,8 @@ def project_from_options(project_dir, options, additional_options={}): environment=environment, override_dir=override_dir, compatibility=options.get('--compatibility'), - interpolate=(not additional_options.get('--no-interpolate')) + interpolate=(not additional_options.get('--no-interpolate')), + environment_file=environment_file ) @@ -125,7 +129,7 @@ def get_client(environment, verbose=False, version=None, tls_config=None, host=N def get_project(project_dir, config_path=None, project_name=None, verbose=False, host=None, tls_config=None, environment=None, override_dir=None, - compatibility=False, interpolate=True): + compatibility=False, interpolate=True, environment_file=None): if not environment: environment = Environment.from_env_file(project_dir) config_details = config.find(project_dir, config_path, environment, override_dir) @@ -145,10 +149,30 @@ def get_project(project_dir, config_path=None, project_name=None, verbose=False, with errors.handle_connection_errors(client): return Project.from_config( - project_name, config_data, client, environment.get('DOCKER_DEFAULT_PLATFORM') + project_name, + config_data, + client, + environment.get('DOCKER_DEFAULT_PLATFORM'), + execution_context_labels(config_details, environment_file), ) +def execution_context_labels(config_details, environment_file): + extra_labels = [ + '{0}={1}'.format(LABEL_WORKING_DIR, os.path.abspath(config_details.working_dir)), + '{0}={1}'.format(LABEL_CONFIG_FILES, config_files_label(config_details)), + ] + if environment_file is not None: + extra_labels.append('{0}={1}'.format(LABEL_ENVIRONMENT_FILE, + os.path.normpath(environment_file))) + return extra_labels + + +def config_files_label(config_details): + return ",".join( + map(str, (os.path.normpath(c.filename) for c in config_details.config_files))) + + def get_project_name(working_dir, project_name=None, environment=None): def normalize_name(name): return re.sub(r'[^-_a-z0-9]', '', name.lower()) diff --git a/compose/const.py b/compose/const.py index 46d81ae7191..ab0389ce01e 100644 --- a/compose/const.py +++ b/compose/const.py @@ -11,6 +11,9 @@ LABEL_CONTAINER_NUMBER = 'com.docker.compose.container-number' LABEL_ONE_OFF = 'com.docker.compose.oneoff' LABEL_PROJECT = 'com.docker.compose.project' +LABEL_WORKING_DIR = 'com.docker.compose.project.working_dir' +LABEL_CONFIG_FILES = 'com.docker.compose.project.config_files' +LABEL_ENVIRONMENT_FILE = 'com.docker.compose.project.environment_file' LABEL_SERVICE = 'com.docker.compose.service' LABEL_NETWORK = 'com.docker.compose.network' LABEL_VERSION = 'com.docker.compose.version' diff --git a/compose/project.py b/compose/project.py index 092c1e123a0..094ce4d7ad5 100644 --- a/compose/project.py +++ b/compose/project.py @@ -83,7 +83,7 @@ def labels(self, one_off=OneOffFilter.exclude, legacy=False): return labels @classmethod - def from_config(cls, name, config_data, client, default_platform=None): + def from_config(cls, name, config_data, client, default_platform=None, extra_labels=[]): """ Construct a Project from a config.Config object. """ @@ -136,6 +136,7 @@ def from_config(cls, name, config_data, client, default_platform=None): pid_mode=pid_mode, platform=service_dict.pop('platform', None), default_platform=default_platform, + extra_labels=extra_labels, **service_dict) ) diff --git a/compose/service.py b/compose/service.py index 638cd71e5bb..ae4e7665c68 100644 --- a/compose/service.py +++ b/compose/service.py @@ -68,7 +68,6 @@ log = logging.getLogger(__name__) - HOST_CONFIG_KEYS = [ 'cap_add', 'cap_drop', @@ -137,7 +136,6 @@ class NoSuchImageError(Exception): ServiceName = namedtuple('ServiceName', 'project service number') - ConvergencePlan = namedtuple('ConvergencePlan', 'action containers') @@ -173,20 +171,21 @@ class BuildAction(enum.Enum): class Service(object): def __init__( - self, - name, - client=None, - project='default', - use_networking=False, - links=None, - volumes_from=None, - network_mode=None, - networks=None, - secrets=None, - scale=1, - pid_mode=None, - default_platform=None, - **options + self, + name, + client=None, + project='default', + use_networking=False, + links=None, + volumes_from=None, + network_mode=None, + networks=None, + secrets=None, + scale=1, + pid_mode=None, + default_platform=None, + extra_labels=[], + **options ): self.name = name self.client = client @@ -201,6 +200,7 @@ def __init__( self.scale_num = scale self.default_platform = default_platform self.options = options + self.extra_labels = extra_labels def __repr__(self): return ''.format(self.name) @@ -215,7 +215,7 @@ def containers(self, stopped=False, one_off=False, filters=None, labels=None): for container in self.client.containers( all=stopped, filters=filters)]) - ) + ) if result: return result @@ -404,8 +404,8 @@ def convergence_plan(self, strategy=ConvergenceStrategy.changed): return ConvergencePlan('start', containers) if ( - strategy is ConvergenceStrategy.always or - self._containers_have_diverged(containers) + strategy is ConvergenceStrategy.always or + self._containers_have_diverged(containers) ): return ConvergencePlan('recreate', containers) @@ -482,6 +482,7 @@ def recreate(container): container, timeout=timeout, attach_logs=not detached, start_new_container=start, renew_anonymous_volumes=renew_anonymous_volumes ) + containers, errors = parallel_execute( containers, recreate, @@ -705,11 +706,11 @@ def get_dependency_names(self): net_name = self.network_mode.service_name pid_namespace = self.pid_mode.service_name return ( - self.get_linked_service_names() + - self.get_volumes_from_names() + - ([net_name] if net_name else []) + - ([pid_namespace] if pid_namespace else []) + - list(self.options.get('depends_on', {}).keys()) + self.get_linked_service_names() + + self.get_volumes_from_names() + + ([net_name] if net_name else []) + + ([pid_namespace] if pid_namespace else []) + + list(self.options.get('depends_on', {}).keys()) ) def get_dependency_configs(self): @@ -899,7 +900,7 @@ def _get_container_create_options( container_options['labels'] = build_container_labels( container_options.get('labels', {}), - self.labels(one_off=one_off), + self.labels(one_off=one_off) + self.extra_labels, number, self.config_hash if add_config_hash else None, slug @@ -1552,9 +1553,9 @@ def warn_on_masked_volume(volumes_option, container_volumes, service): for volume in volumes_option: if ( - volume.external and - volume.internal in container_volumes and - container_volumes.get(volume.internal) != volume.external + volume.external and + volume.internal in container_volumes and + container_volumes.get(volume.internal) != volume.external ): log.warning(( "Service \"{service}\" is using volume \"{volume}\" from the " @@ -1601,6 +1602,7 @@ def build_mount(mount_spec): read_only=mount_spec.read_only, consistency=mount_spec.consistency, **kwargs ) + # Labels @@ -1655,6 +1657,7 @@ def format_env(key, value): 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()] From 452880af7cd3c59f3bbe9a7714a0176f52f2d12d Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Fri, 11 Oct 2019 17:27:08 +0200 Subject: [PATCH 3774/4072] Use python Posix support to get tty size stty is not portable outside *nix Note: shutil.get_terminal_size require python 3.3 Signed-off-by: Nicolas De Loof --- compose/cli/formatter.py | 15 ++++++++++----- requirements.txt | 1 + setup.py | 3 ++- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/compose/cli/formatter.py b/compose/cli/formatter.py index 13794b89ad8..c1f43ed7a2f 100644 --- a/compose/cli/formatter.py +++ b/compose/cli/formatter.py @@ -2,20 +2,25 @@ from __future__ import unicode_literals import logging -import os +import shutil import six import texttable from compose.cli import colors +if hasattr(shutil, "get_terminal_size"): + from shutil import get_terminal_size +else: + from backports.shutil_get_terminal_size import get_terminal_size + def get_tty_width(): - tty_size = os.popen('stty size 2> /dev/null', 'r').read().split() - if len(tty_size) != 2: + try: + width, _ = get_terminal_size() + return int(width) + except OSError: return 0 - _, width = tty_size - return int(width) class Formatter: diff --git a/requirements.txt b/requirements.txt index ed1c8769369..f2e66b34d2f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ +backports.shutil_get_terminal_size==1.0.0 backports.ssl-match-hostname==3.5.0.1; python_version < '3' cached-property==1.3.0 certifi==2017.4.17 diff --git a/setup.py b/setup.py index c3e214c22d0..23ae08a12d0 100644 --- a/setup.py +++ b/setup.py @@ -55,7 +55,8 @@ def find_version(*file_paths): ':python_version < "3.2"': ['subprocess32 >= 3.5.4, < 4'], ':python_version < "3.4"': ['enum34 >= 1.0.4, < 2'], ':python_version < "3.5"': ['backports.ssl_match_hostname >= 3.5, < 4'], - ':python_version < "3.3"': ['ipaddress >= 1.0.16, < 2'], + ':python_version < "3.3"': ['backports.shutil_get_terminal_size == 1.0.0', + 'ipaddress >= 1.0.16, < 2'], ':sys_platform == "win32"': ['colorama >= 0.4, < 1'], 'socks': ['PySocks >= 1.5.6, != 1.5.7, < 2'], } From 1ca10f90fb280016a14158ff702524d6bb4ae2fd Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Tue, 15 Oct 2019 19:45:54 +0200 Subject: [PATCH 3775/4072] Fix acceptance tests tty is now (correclty) reported to have 80 columns, which split service ID in two lines Signed-off-by: Nicolas De Loof --- tests/acceptance/cli_test.py | 6 +++--- tests/fixtures/images-service-tag/docker-compose.yml | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 14dbb7d6cdb..a03d56567c2 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -2816,8 +2816,8 @@ def test_images_default_composefile(self): result = self.dispatch(['images']) assert 'busybox' in result.stdout - assert 'multiple-composefiles_another_1' in result.stdout - assert 'multiple-composefiles_simple_1' in result.stdout + assert '_another_1' in result.stdout + assert '_simple_1' in result.stdout @mock.patch.dict(os.environ) def test_images_tagless_image(self): @@ -2865,4 +2865,4 @@ def test_images_use_service_tag(self): assert re.search(r'foo1.+test[ \t]+dev', result.stdout) is not None assert re.search(r'foo2.+test[ \t]+prod', result.stdout) is not None - assert re.search(r'foo3.+_foo3[ \t]+latest', result.stdout) is not None + assert re.search(r'foo3.+test[ \t]+latest', result.stdout) is not None diff --git a/tests/fixtures/images-service-tag/docker-compose.yml b/tests/fixtures/images-service-tag/docker-compose.yml index aff3cf285a9..a46b32bf578 100644 --- a/tests/fixtures/images-service-tag/docker-compose.yml +++ b/tests/fixtures/images-service-tag/docker-compose.yml @@ -8,3 +8,4 @@ services: image: test:prod foo3: build: . + image: test:latest From 17bbbba7d67f3756ea8bae580f962490d59abab8 Mon Sep 17 00:00:00 2001 From: okor Date: Thu, 17 Oct 2019 12:50:01 -0600 Subject: [PATCH 3776/4072] update docker-py Signed-off-by: Jason Ormand --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index f2e66b34d2f..1627cca9ef8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ cached-property==1.3.0 certifi==2017.4.17 chardet==3.0.4 colorama==0.4.0; sys_platform == 'win32' -docker==4.0.1 +docker==4.1.0 docker-pycreds==0.4.0 dockerpty==0.4.1 docopt==0.6.2 From 386bdda2468056b755af186bc80386e565a9785d Mon Sep 17 00:00:00 2001 From: Guillaume Lours Date: Fri, 18 Oct 2019 11:49:30 +0200 Subject: [PATCH 3777/4072] Format image size as decimal to be align with Docker CLI Signed-off-by: Guillaume Lours --- compose/cli/utils.py | 4 ++-- tests/unit/cli/utils_test.py | 22 +++++++++++++--------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/compose/cli/utils.py b/compose/cli/utils.py index bd06beef84f..931487a6cdc 100644 --- a/compose/cli/utils.py +++ b/compose/cli/utils.py @@ -133,12 +133,12 @@ def generate_user_agent(): def human_readable_file_size(size): suffixes = ['B', 'kB', 'MB', 'GB', 'TB', 'PB', 'EB', ] - order = int(math.log(size, 2) / 10) if size else 0 + order = int(math.log(size, 1000)) if size else 0 if order >= len(suffixes): order = len(suffixes) - 1 return '{0:.4g} {1}'.format( - size / float(1 << (order * 10)), + size / pow(10, order * 3), suffixes[order] ) diff --git a/tests/unit/cli/utils_test.py b/tests/unit/cli/utils_test.py index b340fb94711..7a762890370 100644 --- a/tests/unit/cli/utils_test.py +++ b/tests/unit/cli/utils_test.py @@ -29,16 +29,20 @@ def test_100b(self): assert human_readable_file_size(100) == '100 B' def test_1kb(self): - assert human_readable_file_size(1024) == '1 kB' + assert human_readable_file_size(1000) == '1 kB' + assert human_readable_file_size(1024) == '1.024 kB' def test_1023b(self): - assert human_readable_file_size(1023) == '1023 B' + assert human_readable_file_size(1023) == '1.023 kB' + + def test_999b(self): + assert human_readable_file_size(999) == '999 B' def test_units(self): - assert human_readable_file_size((2 ** 10) ** 0) == '1 B' - assert human_readable_file_size((2 ** 10) ** 1) == '1 kB' - assert human_readable_file_size((2 ** 10) ** 2) == '1 MB' - assert human_readable_file_size((2 ** 10) ** 3) == '1 GB' - assert human_readable_file_size((2 ** 10) ** 4) == '1 TB' - assert human_readable_file_size((2 ** 10) ** 5) == '1 PB' - assert human_readable_file_size((2 ** 10) ** 6) == '1 EB' + assert human_readable_file_size((10 ** 3) ** 0) == '1 B' + assert human_readable_file_size((10 ** 3) ** 1) == '1 kB' + assert human_readable_file_size((10 ** 3) ** 2) == '1 MB' + assert human_readable_file_size((10 ** 3) ** 3) == '1 GB' + assert human_readable_file_size((10 ** 3) ** 4) == '1 TB' + assert human_readable_file_size((10 ** 3) ** 5) == '1 PB' + assert human_readable_file_size((10 ** 3) ** 6) == '1 EB' From 2e7493a8899d75a991fa2386d3eedcfbca690580 Mon Sep 17 00:00:00 2001 From: Guillaume Lours Date: Fri, 18 Oct 2019 17:54:18 +0200 Subject: [PATCH 3778/4072] Set no-colors to true if CLICOLOR env variable is set to 0 Signed-off-by: Guillaume Lours --- compose/cli/main.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 9e01b539647..fde4fd0359b 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -6,6 +6,7 @@ import functools import json import logging +import os import pipes import re import subprocess @@ -102,9 +103,9 @@ def dispatch(): options, handler, command_options = dispatcher.parse(sys.argv[1:]) setup_console_handler(console_handler, options.get('--verbose'), - options.get('--no-ansi'), + set_no_color_if_clicolor(options.get('--no-ansi')), options.get("--log-level")) - setup_parallel_logger(options.get('--no-ansi')) + setup_parallel_logger(set_no_color_if_clicolor(options.get('--no-ansi'))) if options.get('--no-ansi'): command_options['--no-color'] = True return functools.partial(perform_command, options, handler, command_options) @@ -666,7 +667,7 @@ def logs(self, options): log_printer_from_project( self.project, containers, - options['--no-color'], + set_no_color_if_clicolor(options['--no-color']), log_args, event_stream=self.project.events(service_names=options['SERVICE'])).run() @@ -1124,7 +1125,7 @@ def up(rebuild): log_printer = log_printer_from_project( self.project, attached_containers, - options['--no-color'], + set_no_color_if_clicolor(options['--no-color']), {'follow': True}, cascade_stop, event_stream=self.project.events(service_names=service_names)) @@ -1602,3 +1603,7 @@ def warn_for_swarm_mode(client): "To deploy your application across the swarm, " "use `docker stack deploy`.\n" ) + + +def set_no_color_if_clicolor(no_color_flag): + return no_color_flag or os.environ.get('CLICOLOR') == "0" From c7e82489f4bf8ebef4afc22848d121b39aa2e769 Mon Sep 17 00:00:00 2001 From: Djordje Lukic Date: Mon, 28 Oct 2019 11:33:59 +0100 Subject: [PATCH 3779/4072] "Bump 1.25.0-rc3" Signed-off-by: Djordje Lukic --- CHANGELOG.md | 83 +++++++++++++++++++++++++++++++++++++++++++++ compose/__init__.py | 2 +- script/run/run.sh | 2 +- 3 files changed, 85 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a3ad8bfd5f..210aecd4d46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,89 @@ Change log ========== +1.25.0-rc3 (2019-10-28) +------------------- + +### Features + +- Add BuildKit support, use `DOCKER_BUILDKIT=1` and `COMPOSE_NATIVE_BUILDER=1` + +- Bump paramiko to 2.6.0 + +- Add working dir, config files and env file in service labels + +- Add tag `docker-compose:latest` + +- Add `docker-compose:-alpine` image/tag + +- Add `docker-compose:-debian` image/tag + +- Bumped `docker-py` 4.1.0 + +- Supports `requests` up to 2.22.0 version + +- Drops empty tag on `build:cache_from` + +- `Dockerfile` now generates `libmusl` binaries for alpine + +- Only pull images that can't be built + +- Attribute `scale` can now accept `0` as a value + +- Added `--quiet` build flag + +- Added `--no-interpolate` to `docker-compose config` + +- Bump OpenSSL for macOS build (`1.1.0j` to `1.1.1c`) + +- Added `--no-rm` to `build` command + +- Added support for `credential_spec` + +- Resolve digests without pulling image + +- Upgrade `pyyaml` to `4.2b1` + +- Lowered severity to `warning` if `down` tries to remove nonexisting image + +- Use improved API fields for project events when possible + +- Update `setup.py` for modern `pypi/setuptools` and remove `pandoc` dependencies + +- Removed `Dockerfile.armhf` which is no longer needed + +### Bugfixes + +- Fix same file 'extends' optimization + +- Use python POSIX support to get tty size + +- Format image size as decimal to be align with Docker CLI + +- Fixed stdin_open + +- Fixed `--remove-orphans` when used with `up --no-start` + +- Fixed `docker-compose ps --all` + +- Fixed `depends_on` dependency recreation behavior + +- Fixed bash completion for `build --memory` + +- Fixed misleading warning concerning env vars when performing an `exec` command + +- Fixed failure check in parallel_execute_watch + +- Fixed race condition after pulling image + +- Fixed error on duplicate mount points. + +- Fixed merge on networks section + +- Always connect Compose container to `stdin` + +- Fixed the presentation of failed services on 'docker-compose start' when containers are not available + 1.24.0 (2019-03-28) ------------------- diff --git a/compose/__init__.py b/compose/__init__.py index 0042896b658..552f91b2d34 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.25.0dev' +__version__ = '1.25.0-rc3' diff --git a/script/run/run.sh b/script/run/run.sh index f3456720fa5..f7d6eb35f86 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.24.0" +VERSION="1.25.0-rc3" IMAGE="docker/compose:$VERSION" From f8142a899c0dd8ea4f2ff227a0dd868a91f6e11d Mon Sep 17 00:00:00 2001 From: Djordje Lukic Date: Mon, 28 Oct 2019 15:33:32 +0100 Subject: [PATCH 3780/4072] Cleanup all open files If the fd is not closed the cleanup will fail on windows. Signed-off-by: Djordje Lukic --- tests/unit/project_test.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/unit/project_test.py b/tests/unit/project_test.py index de16febf54e..6391fac863d 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -880,7 +880,8 @@ def test_get_secrets_uid_gid_mode_warning(self): service = 'foo' secret_source = 'bar' - _, filename_path = tempfile.mkstemp() + fd, filename_path = tempfile.mkstemp() + os.close(fd) self.addCleanup(os.remove, filename_path) def mock_get(key): From 8f3c9c58c5096790b3c72d52e8886574a7c90bdd Mon Sep 17 00:00:00 2001 From: Djordje Lukic Date: Mon, 28 Oct 2019 16:18:12 +0100 Subject: [PATCH 3781/4072] "Bump 1.25.0-rc4" Signed-off-by: Djordje Lukic --- CHANGELOG.md | 83 +++++++++++++++++++++++++++++++++++++++++++++ compose/__init__.py | 2 +- script/run/run.sh | 2 +- 3 files changed, 85 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a3ad8bfd5f..92ab0f90f20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,89 @@ Change log ========== +1.25.0-rc4 (2019-10-28) +------------------- + +### Features + +- Add BuildKit support, use `DOCKER_BUILDKIT=1` and `COMPOSE_NATIVE_BUILDER=1` + +- Bump paramiko to 2.6.0 + +- Add working dir, config files and env file in service labels + +- Add tag `docker-compose:latest` + +- Add `docker-compose:-alpine` image/tag + +- Add `docker-compose:-debian` image/tag + +- Bumped `docker-py` 4.1.0 + +- Supports `requests` up to 2.22.0 version + +- Drops empty tag on `build:cache_from` + +- `Dockerfile` now generates `libmusl` binaries for alpine + +- Only pull images that can't be built + +- Attribute `scale` can now accept `0` as a value + +- Added `--quiet` build flag + +- Added `--no-interpolate` to `docker-compose config` + +- Bump OpenSSL for macOS build (`1.1.0j` to `1.1.1c`) + +- Added `--no-rm` to `build` command + +- Added support for `credential_spec` + +- Resolve digests without pulling image + +- Upgrade `pyyaml` to `4.2b1` + +- Lowered severity to `warning` if `down` tries to remove nonexisting image + +- Use improved API fields for project events when possible + +- Update `setup.py` for modern `pypi/setuptools` and remove `pandoc` dependencies + +- Removed `Dockerfile.armhf` which is no longer needed + +### Bugfixes + +- Fix same file 'extends' optimization + +- Use python POSIX support to get tty size + +- Format image size as decimal to be align with Docker CLI + +- Fixed stdin_open + +- Fixed `--remove-orphans` when used with `up --no-start` + +- Fixed `docker-compose ps --all` + +- Fixed `depends_on` dependency recreation behavior + +- Fixed bash completion for `build --memory` + +- Fixed misleading warning concerning env vars when performing an `exec` command + +- Fixed failure check in parallel_execute_watch + +- Fixed race condition after pulling image + +- Fixed error on duplicate mount points. + +- Fixed merge on networks section + +- Always connect Compose container to `stdin` + +- Fixed the presentation of failed services on 'docker-compose start' when containers are not available + 1.24.0 (2019-03-28) ------------------- diff --git a/compose/__init__.py b/compose/__init__.py index 0042896b658..4bd8d724ccf 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.25.0dev' +__version__ = '1.25.0-rc4' diff --git a/script/run/run.sh b/script/run/run.sh index f3456720fa5..cb2fb8ca99f 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.24.0" +VERSION="1.25.0-rc4" IMAGE="docker/compose:$VERSION" From a3a23bf94934d5d01a997ba14e34d794d157e3ec Mon Sep 17 00:00:00 2001 From: Sebastien Mamessier Date: Fri, 25 Oct 2019 13:55:45 +0200 Subject: [PATCH 3782/4072] Fixed error when using startswith on non-ascii string Signed-off-by: Sebastien Mamessier --- compose/service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/service.py b/compose/service.py index ae4e7665c68..f487db7d17c 100644 --- a/compose/service.py +++ b/compose/service.py @@ -1806,7 +1806,7 @@ def build(self, path, tag=None, quiet=False, fileobj=None, line = p.stdout.readline() if not line: break - if line.startswith(magic_word): + if line.startswith(str(magic_word)): appear = True yield json.dumps({"stream": line}) From 802fa202286fb9bf0fab18b0f558b13009367fc5 Mon Sep 17 00:00:00 2001 From: Anthony Lai Date: Sun, 3 Nov 2019 22:54:44 +0000 Subject: [PATCH 3783/4072] Make container service color deterministic, remove red from chosen colors Signed-off-by: Anthony Lai --- compose/cli/colors.py | 4 ++-- compose/cli/log_printer.py | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/compose/cli/colors.py b/compose/cli/colors.py index cb30e361598..ea45198e07d 100644 --- a/compose/cli/colors.py +++ b/compose/cli/colors.py @@ -41,9 +41,9 @@ def make_color_fn(code): def rainbow(): - cs = ['cyan', 'yellow', 'green', 'magenta', 'red', 'blue', + cs = ['cyan', 'yellow', 'green', 'magenta', 'blue', 'intense_cyan', 'intense_yellow', 'intense_green', - 'intense_magenta', 'intense_red', 'intense_blue'] + 'intense_magenta', 'intense_blue'] for c in cs: yield globals()[c] diff --git a/compose/cli/log_printer.py b/compose/cli/log_printer.py index 6940a74c878..a4b70a67205 100644 --- a/compose/cli/log_printer.py +++ b/compose/cli/log_printer.py @@ -134,7 +134,10 @@ def build_thread(container, presenter, queue, log_args): def build_thread_map(initial_containers, presenters, thread_args): return { container.id: build_thread(container, next(presenters), *thread_args) - for container in initial_containers + # Container order is unspecified, so they are sorted by name in order to make + # container:presenter (log color) assignment deterministic when given a list of containers + # with the same names. + for container in sorted(initial_containers, key=lambda c: c.name) } From b9a4581d606e33f085c136a4906732222f58a1a2 Mon Sep 17 00:00:00 2001 From: Anton Lundin Date: Wed, 6 Nov 2019 12:48:44 +0100 Subject: [PATCH 3784/4072] Pass in HOME env-var in container mode. To get ~/-paths to work as expected in contaier mode, env-var HOME must be the same outside the container as inside the docker-compose container, otherwise HOME inside the container points to /root which might not be what the user expects. Signed-off-by: Anton Lundin --- 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 f3456720fa5..21b8373290d 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -43,7 +43,7 @@ if [ -n "$compose_dir" ]; then VOLUMES="$VOLUMES -v $compose_dir:$compose_dir" fi if [ -n "$HOME" ]; then - VOLUMES="$VOLUMES -v $HOME:$HOME -v $HOME:/root" # mount $HOME in /root to share docker.config + VOLUMES="$VOLUMES -v $HOME:$HOME -e HOME" # Pass in HOME to share docker.config and allow ~/-relative paths to work. fi # Only allocate tty if we detect one From e546533cfe21ab380c0ec4cefbd1e9d0cd3ec4e6 Mon Sep 17 00:00:00 2001 From: Zuhayr Elahi Date: Wed, 6 Nov 2019 17:10:48 -0800 Subject: [PATCH 3785/4072] Fixed broken README link for common use cases Signed-off-by: Zuhayr Elahi --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c9b87daba75..fd643f174bb 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ see [the list of features](https://github.com/docker/docker.github.io/blob/maste 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/docker.github.io/blob/master/compose/overview.md#common-use-cases). +[Common Use Cases](https://github.com/docker/docker.github.io/blob/master/compose/index.md#common-use-cases). Using Compose is basically a three-step process. From aeddfd41d6bd4d44577689eaef6756f2a28ce605 Mon Sep 17 00:00:00 2001 From: Anton Lundin Date: Wed, 6 Nov 2019 13:50:48 +0100 Subject: [PATCH 3786/4072] Resolve shellcheck warnings in container-script This fixes a couple of small potential issues pointed out by shellcheck. Signed-off-by: Anton Lundin --- script/run/run.sh | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/script/run/run.sh b/script/run/run.sh index f3456720fa5..dc28eeb7ec8 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -36,7 +36,7 @@ if [ "$(pwd)" != '/' ]; then fi if [ -n "$COMPOSE_FILE" ]; then COMPOSE_OPTIONS="$COMPOSE_OPTIONS -e COMPOSE_FILE=$COMPOSE_FILE" - compose_dir=$(realpath $(dirname $COMPOSE_FILE)) + compose_dir=$(realpath "$(dirname "$COMPOSE_FILE")") fi # TODO: also check --file argument if [ -n "$compose_dir" ]; then @@ -47,7 +47,7 @@ if [ -n "$HOME" ]; then fi # Only allocate tty if we detect one -if [ -t 0 -a -t 1 ]; then +if [ -t 0 ] && [ -t 1 ]; then DOCKER_RUN_OPTIONS="$DOCKER_RUN_OPTIONS -t" fi @@ -56,8 +56,9 @@ DOCKER_RUN_OPTIONS="$DOCKER_RUN_OPTIONS -i" # Handle userns security -if [ ! -z "$(docker info 2>/dev/null | grep userns)" ]; then +if docker info 2>/dev/null | grep -q userns; then DOCKER_RUN_OPTIONS="$DOCKER_RUN_OPTIONS --userns=host" fi +# shellcheck disable=SC2086 exec docker run --rm $DOCKER_RUN_OPTIONS $DOCKER_ADDR $COMPOSE_OPTIONS $VOLUMES -w "$(pwd)" $IMAGE "$@" From 2919bebea46574f95e566f57dfd6746c6e1661b9 Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Mon, 18 Nov 2019 15:43:50 +0100 Subject: [PATCH 3787/4072] Fix non ascii chars error. Python2 only Signed-off-by: Ulysses Souza --- compose/service.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/compose/service.py b/compose/service.py index f487db7d17c..d329be97992 100644 --- a/compose/service.py +++ b/compose/service.py @@ -1806,7 +1806,10 @@ def build(self, path, tag=None, quiet=False, fileobj=None, line = p.stdout.readline() if not line: break - if line.startswith(str(magic_word)): + # Fix non ascii chars on Python2. To remove when #6890 is complete. + if six.PY2: + magic_word = str(magic_word) + if line.startswith(magic_word): appear = True yield json.dumps({"stream": line}) From a0592ce58504615c8c773ff6270066f18e7de60f Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Mon, 18 Nov 2019 19:14:13 +0100 Subject: [PATCH 3788/4072] "Bump 1.25.0" Signed-off-by: Ulysses Souza --- CHANGELOG.md | 104 ++++++++++++++++++++++++++++++++++++++++++++ compose/__init__.py | 2 +- script/run/run.sh | 2 +- 3 files changed, 106 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a3ad8bfd5f..4436ed74d01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,110 @@ Change log ========== +1.25.0 (2019-11-18) +------------------- + +### Features + +- Set no-colors to true if CLICOLOR env variable is set to 0 + +- Add working dir, config files and env file in service labels + +- Add dependencies for ARM build + +- Add BuildKit support, use `DOCKER_BUILDKIT=1` and `COMPOSE_DOCKER_CLI_BUILD=1` + +- Bump paramiko to 2.6.0 + +- Add working dir, config files and env file in service labels + +- Add tag `docker-compose:latest` + +- Add `docker-compose:-alpine` image/tag + +- Add `docker-compose:-debian` image/tag + +- Bumped `docker-py` 4.1.0 + +- Supports `requests` up to 2.22.0 version + +- Drops empty tag on `build:cache_from` + +- `Dockerfile` now generates `libmusl` binaries for alpine + +- Only pull images that can't be built + +- Attribute `scale` can now accept `0` as a value + +- Added `--quiet` build flag + +- Added `--no-interpolate` to `docker-compose config` + +- Bump OpenSSL for macOS build (`1.1.0j` to `1.1.1c`) + +- Added `--no-rm` to `build` command + +- Added support for `credential_spec` + +- Resolve digests without pulling image + +- Upgrade `pyyaml` to `4.2b1` + +- Lowered severity to `warning` if `down` tries to remove nonexisting image + +- Use improved API fields for project events when possible + +- Update `setup.py` for modern `pypi/setuptools` and remove `pandoc` dependencies + +- Removed `Dockerfile.armhf` which is no longer needed + +### Bugfixes + +- Make container service color deterministic, remove red from chosen colors + +- Fix non ascii chars error. Python2 only + +- Format image size as decimal to be align with Docker CLI + +- Use Python Posix support to get tty size + +- Fix same file 'extends' optimization + +- Use python POSIX support to get tty size + +- Format image size as decimal to be align with Docker CLI + +- Fixed stdin_open + +- Fixed `--remove-orphans` when used with `up --no-start` + +- Fixed `docker-compose ps --all` + +- Fixed `depends_on` dependency recreation behavior + +- Fixed bash completion for `build --memory` + +- Fixed misleading warning concerning env vars when performing an `exec` command + +- Fixed failure check in parallel_execute_watch + +- Fixed race condition after pulling image + +- Fixed error on duplicate mount points. + +- Fixed merge on networks section + +- Always connect Compose container to `stdin` + +- Fixed the presentation of failed services on 'docker-compose start' when containers are not available + +1.24.1 (2019-06-24) +------------------- + +### Bugfixes + +- Fixed acceptance tests + 1.24.0 (2019-03-28) ------------------- diff --git a/compose/__init__.py b/compose/__init__.py index 0042896b658..d35e818c75d 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.25.0dev' +__version__ = '1.25.0' diff --git a/script/run/run.sh b/script/run/run.sh index f3456720fa5..ffeec59a2e4 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.24.0" +VERSION="1.25.0" IMAGE="docker/compose:$VERSION" From d072601197effc2271b599a9aafe3a9cc5366729 Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Mon, 18 Nov 2019 19:29:40 +0100 Subject: [PATCH 3789/4072] "Bump 1.25.0" Signed-off-by: Ulysses Souza --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4436ed74d01..ca9cc342438 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ Change log ### Features -- Set no-colors to true if CLICOLOR env variable is set to 0 +- Set no-colors to true if CLICOLOR env variable is set to 0 - Add working dir, config files and env file in service labels From 0a186604be76457def87f9b571ca53eb37c0ffa7 Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Mon, 18 Nov 2019 19:35:34 +0100 Subject: [PATCH 3790/4072] "Bump 1.25.0" Signed-off-by: Ulysses Souza --- CHANGELOG.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca9cc342438..d1a6fae3290 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,9 +64,9 @@ Change log - Fix non ascii chars error. Python2 only -- Format image size as decimal to be align with Docker CLI +- Format image size as decimal to be align with Docker CLI -- Use Python Posix support to get tty size +- Use Python Posix support to get tty size - Fix same file 'extends' optimization @@ -90,7 +90,7 @@ Change log - Fixed race condition after pulling image -- Fixed error on duplicate mount points. +- Fixed error on duplicate mount points - Fixed merge on networks section From b42d4197ce3865c31e27ed134ea1fb081b839c0c Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Mon, 18 Nov 2019 22:46:12 +0100 Subject: [PATCH 3791/4072] Add latest tag question on resume and start to tag on build Signed-off-by: Ulysses Souza --- script/release/release.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/script/release/release.py b/script/release/release.py index a9c05eb7874..82bc9a0a677 100755 --- a/script/release/release.py +++ b/script/release/release.py @@ -204,7 +204,8 @@ def resume(args): gh_release = create_release_draft(repository, args.release, pr_data, files) delete_assets(gh_release) upload_assets(gh_release, files) - img_manager = ImageManager(args.release) + tag_as_latest = is_tag_latest(args.release) + img_manager = ImageManager(args.release, tag_as_latest) img_manager.build_images(repository) except ScriptError as e: print(e) @@ -244,7 +245,8 @@ def start(args): files = downloader.download_all(args.release) gh_release = create_release_draft(repository, args.release, pr_data, files) upload_assets(gh_release, files) - img_manager = ImageManager(args.release) + tag_as_latest = is_tag_latest(args.release) + img_manager = ImageManager(args.release, tag_as_latest) img_manager.build_images(repository) except ScriptError as e: print(e) From 962421d0197db71db037a9c82a0d9bc356547d05 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Wed, 20 Nov 2019 08:56:55 +0100 Subject: [PATCH 3792/4072] config_detail.filename is None when passed by stdin Fix #7032 Signed-off-by: Nicolas De Loof --- compose/cli/command.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/compose/cli/command.py b/compose/cli/command.py index c3a10a04340..bdab67ccf59 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -170,7 +170,11 @@ def execution_context_labels(config_details, environment_file): def config_files_label(config_details): return ",".join( - map(str, (os.path.normpath(c.filename) for c in config_details.config_files))) + map(str, (config_file_path(c.filename) for c in config_details.config_files))) + + +def config_file_path(config_file): + return os.path.normpath(config_file) if config_file else 'stdin' def get_project_name(working_dir, project_name=None, environment=None): From fe2b69254797309c28ef95f15991e971dc3d7511 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Wed, 20 Nov 2019 13:32:07 +0100 Subject: [PATCH 3793/4072] testcase for compose file read from stdin Signed-off-by: Nicolas De Loof --- tests/acceptance/cli_test.py | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index a03d56567c2..b1790fdcffd 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -48,6 +48,7 @@ def start_process(base_dir, options): proc = subprocess.Popen( ['docker-compose'] + options, + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=base_dir) @@ -55,8 +56,8 @@ def start_process(base_dir, options): return proc -def wait_on_process(proc, returncode=0): - stdout, stderr = proc.communicate() +def wait_on_process(proc, returncode=0, stdin=None): + stdout, stderr = proc.communicate(input=stdin) if proc.returncode != returncode: print("Stderr: {}".format(stderr)) print("Stdout: {}".format(stdout)) @@ -64,10 +65,10 @@ def wait_on_process(proc, returncode=0): return ProcessResult(stdout.decode('utf-8'), stderr.decode('utf-8')) -def dispatch(base_dir, options, project_options=None, returncode=0): +def dispatch(base_dir, options, project_options=None, returncode=0, stdin=None): project_options = project_options or [] proc = start_process(base_dir, project_options + options) - return wait_on_process(proc, returncode=returncode) + return wait_on_process(proc, returncode=returncode, stdin=stdin) def wait_on_condition(condition, delay=0.1, timeout=40): @@ -156,8 +157,8 @@ def project(self): self._project = get_project(self.base_dir, override_dir=self.override_dir) return self._project - def dispatch(self, options, project_options=None, returncode=0): - return dispatch(self.base_dir, options, project_options, returncode) + def dispatch(self, options, project_options=None, returncode=0, stdin=None): + return dispatch(self.base_dir, options, project_options, returncode, stdin) def execute(self, container, cmd): # Remove once Hijack and CloseNotifier sign a peace treaty @@ -241,6 +242,17 @@ def test_config_quiet(self): self.base_dir = 'tests/fixtures/v2-full' assert self.dispatch(['config', '--quiet']).stdout == '' + def test_config_stdin(self): + config = b"""version: "3.7" +services: + web: + image: nginx + other: + image: alpine +""" + result = self.dispatch(['-f', '-', 'config', '--services'], stdin=config) + assert set(result.stdout.rstrip().split('\n')) == {'web', 'other'} + def test_config_with_hash_option(self): self.base_dir = 'tests/fixtures/v2-full' result = self.dispatch(['config', '--hash=*']) From e13a7213f13f4e7993f33736e75e64473153719e Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Fri, 8 Nov 2019 15:05:40 +0100 Subject: [PATCH 3794/4072] Build OSX binary as a directory OSX Catalina otherwise do scan the temporary executable files created by the single-file packaging. Signed-off-by: Nicolas De Loof --- .circleci/config.yml | 4 +- docker-compose_darwin.spec | 108 +++++++++++++++++++++++++++ script/build/osx | 6 ++ script/release/release/downloader.py | 1 + 4 files changed, 117 insertions(+), 2 deletions(-) create mode 100644 docker-compose_darwin.spec diff --git a/.circleci/config.yml b/.circleci/config.yml index 906b1c0dc21..9b95b7e9e63 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -30,8 +30,8 @@ jobs: name: build script command: ./script/build/osx - store_artifacts: - path: dist/docker-compose-Darwin-x86_64 - destination: docker-compose-Darwin-x86_64 + path: dist/docker-compose-Darwin-x86_64.tgz + destination: docker-compose-Darwin-x86_64.tgz - deploy: name: Deploy binary to bintray command: | diff --git a/docker-compose_darwin.spec b/docker-compose_darwin.spec new file mode 100644 index 00000000000..344c070d501 --- /dev/null +++ b/docker-compose_darwin.spec @@ -0,0 +1,108 @@ +# -*- mode: python ; coding: utf-8 -*- + +block_cipher = None + +a = Analysis(['bin/docker-compose'], + pathex=['.'], + hiddenimports=[], + hookspath=[], + runtime_hooks=[], + cipher=block_cipher) + +pyz = PYZ(a.pure, a.zipped_data, + cipher=block_cipher) + +exe = EXE(pyz, + a.scripts, + exclude_binaries=True, + name='docker-compose', + debug=False, + strip=False, + upx=True, + console=True, + bootloader_ignore_signals=True) +coll = COLLECT(exe, + a.binaries, + a.zipfiles, + a.datas, + [ + ( + 'compose/config/config_schema_v1.json', + 'compose/config/config_schema_v1.json', + 'DATA' + ), + ( + 'compose/config/config_schema_v2.0.json', + 'compose/config/config_schema_v2.0.json', + 'DATA' + ), + ( + 'compose/config/config_schema_v2.1.json', + 'compose/config/config_schema_v2.1.json', + 'DATA' + ), + ( + 'compose/config/config_schema_v2.2.json', + 'compose/config/config_schema_v2.2.json', + 'DATA' + ), + ( + 'compose/config/config_schema_v2.3.json', + 'compose/config/config_schema_v2.3.json', + 'DATA' + ), + ( + 'compose/config/config_schema_v2.4.json', + 'compose/config/config_schema_v2.4.json', + 'DATA' + ), + ( + 'compose/config/config_schema_v3.0.json', + 'compose/config/config_schema_v3.0.json', + 'DATA' + ), + ( + 'compose/config/config_schema_v3.1.json', + 'compose/config/config_schema_v3.1.json', + 'DATA' + ), + ( + 'compose/config/config_schema_v3.2.json', + 'compose/config/config_schema_v3.2.json', + 'DATA' + ), + ( + 'compose/config/config_schema_v3.3.json', + 'compose/config/config_schema_v3.3.json', + 'DATA' + ), + ( + 'compose/config/config_schema_v3.4.json', + 'compose/config/config_schema_v3.4.json', + 'DATA' + ), + ( + 'compose/config/config_schema_v3.5.json', + 'compose/config/config_schema_v3.5.json', + 'DATA' + ), + ( + 'compose/config/config_schema_v3.6.json', + 'compose/config/config_schema_v3.6.json', + 'DATA' + ), + ( + 'compose/config/config_schema_v3.7.json', + 'compose/config/config_schema_v3.7.json', + 'DATA' + ), + ( + 'compose/GITSHA', + 'compose/GITSHA', + 'DATA' + ) + ], + strip=False, + upx=True, + upx_exclude=[], + name='docker-compose-Darwin-x86_64') diff --git a/script/build/osx b/script/build/osx index 529914586bc..bd501ee55b5 100755 --- a/script/build/osx +++ b/script/build/osx @@ -11,6 +11,12 @@ venv/bin/pip install -r requirements-build.txt venv/bin/pip install --no-deps . DOCKER_COMPOSE_GITSHA="$(script/build/write-git-sha)" echo "${DOCKER_COMPOSE_GITSHA}" > compose/GITSHA + venv/bin/pyinstaller docker-compose.spec mv dist/docker-compose dist/docker-compose-Darwin-x86_64 dist/docker-compose-Darwin-x86_64 version + +# Also build as a folder, required on osx Catalina +venv/bin/pyinstaller docker-compose_darwin.spec +dist/docker-compose-Darwin-x86_64/docker-compose version +cd dist/docker-compose-Darwin-x86_64/ && tar zcvf ../docker-compose-Darwin-x86_64.tgz . diff --git a/script/release/release/downloader.py b/script/release/release/downloader.py index d92ae78b527..0e9b8013020 100644 --- a/script/release/release/downloader.py +++ b/script/release/release/downloader.py @@ -55,6 +55,7 @@ def _download(self, url, full_dest): def download_all(self, version): files = { + 'docker-compose-Darwin-x86_64.tgz': None, 'docker-compose-Darwin-x86_64': None, 'docker-compose-Linux-x86_64': None, 'docker-compose-Windows-x86_64.exe': None, From fa9e8bd6412f01597288795df850a0320419acc5 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Thu, 21 Nov 2019 08:31:48 +0100 Subject: [PATCH 3795/4072] Skip label if some config file was passed by stdin com.docker.compose.project.config_files must contain valid file paths Signed-off-by: Nicolas De Loof --- compose/cli/command.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/compose/cli/command.py b/compose/cli/command.py index bdab67ccf59..1fa8a17a233 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -159,22 +159,28 @@ def get_project(project_dir, config_path=None, project_name=None, verbose=False, def execution_context_labels(config_details, environment_file): extra_labels = [ - '{0}={1}'.format(LABEL_WORKING_DIR, os.path.abspath(config_details.working_dir)), - '{0}={1}'.format(LABEL_CONFIG_FILES, config_files_label(config_details)), + '{0}={1}'.format(LABEL_WORKING_DIR, os.path.abspath(config_details.working_dir)) ] + + if not use_config_from_stdin(config_details): + extra_labels.append('{0}={1}'.format(LABEL_CONFIG_FILES, config_files_label(config_details))) + if environment_file is not None: extra_labels.append('{0}={1}'.format(LABEL_ENVIRONMENT_FILE, os.path.normpath(environment_file))) return extra_labels -def config_files_label(config_details): - return ",".join( - map(str, (config_file_path(c.filename) for c in config_details.config_files))) +def use_config_from_stdin(config_details): + for c in config_details.config_files: + if not c.filename: + return True + return False -def config_file_path(config_file): - return os.path.normpath(config_file) if config_file else 'stdin' +def config_files_label(config_details): + return ",".join( + map(str, (os.path.normpath(c.filename) for c in config_details.config_files))) def get_project_name(working_dir, project_name=None, environment=None): From f4cf7a939ef656f25bf4e68cd2bb95388c8b57ed Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Thu, 21 Nov 2019 12:00:30 +0100 Subject: [PATCH 3796/4072] Announce drop py2 Signed-off-by: Ulysses Souza --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index fd643f174bb..f1ae7e1ed2f 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ Docker Compose ============== ![Docker Compose](logo.png?raw=true "Docker Compose Logo") +## :exclamation: The docker-compose project announces that as Python 2 reaches it's EOL, versions 1.25.x will be the last to support it. For more information, please refer to this [issue](https://github.com/docker/compose/issues/6890). + 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 e6ec77047bf50abc34d5f08781bb36f75d424b59 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Thu, 21 Nov 2019 11:09:49 +0100 Subject: [PATCH 3797/4072] Revert "only pull images that can't build" This reverts commit c6dd7da15eb3d85a1f7634e8ded9fe42c9035669. Signed-off-by: Nicolas De Loof --- compose/project.py | 7 ++----- tests/acceptance/cli_test.py | 7 ------- 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/compose/project.py b/compose/project.py index 094ce4d7ad5..d7dcb6bd670 100644 --- a/compose/project.py +++ b/compose/project.py @@ -619,9 +619,6 @@ def _get_convergence_plans(self, services, strategy, always_recreate_deps=False) def pull(self, service_names=None, ignore_pull_failures=False, parallel_pull=False, silent=False, include_deps=False): services = self.get_services(service_names, include_deps) - images_to_build = {service.image_name for service in services if service.can_be_built()} - services_to_pull = [service for service in services if service.image_name not in images_to_build] - msg = not silent and 'Pulling' or None if parallel_pull: @@ -647,7 +644,7 @@ def pull_service(service): ) _, errors = parallel.parallel_execute( - services_to_pull, + services, pull_service, operator.attrgetter('name'), msg, @@ -660,7 +657,7 @@ def pull_service(service): raise ProjectError(combined_errors) else: - for service in services_to_pull: + for service in services: service.pull(ignore_pull_failures, silent=silent) def push(self, service_names=None, ignore_push_failures=False): diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index a03d56567c2..25f426d5ba3 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -661,13 +661,6 @@ def test_pull_with_ignore_pull_failures(self): 'image library/nonexisting-image:latest not found' in result.stderr or 'pull access denied for nonexisting-image' in result.stderr) - def test_pull_with_build(self): - result = self.dispatch(['-f', 'pull-with-build.yml', 'pull']) - - assert 'Pulling simple' not in result.stderr - assert 'Pulling from_simple' not in result.stderr - assert 'Pulling another ...' in result.stderr - def test_pull_with_quiet(self): assert self.dispatch(['pull', '--quiet']).stderr == '' assert self.dispatch(['pull', '--quiet']).stdout == '' From 657bdef6ff1d31dfca63e10e66a4394fd9d350ae Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Fri, 22 Nov 2019 09:24:33 +0100 Subject: [PATCH 3798/4072] Suggested command is invalid Signed-off-by: Nicolas De Loof --- compose/cli/main.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index fde4fd0359b..838d9128f2e 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -1230,7 +1230,7 @@ def list_images(images): if e.needs_push: command_hint = ( - "Use `docker-compose push {}` to push them. " + "Use `docker push {}` to push them. " .format(" ".join(sorted(e.needs_push))) ) paras += [ @@ -1241,7 +1241,7 @@ def list_images(images): if e.needs_pull: command_hint = ( - "Use `docker-compose pull {}` to pull them. " + "Use `docker pull {}` to pull them. " .format(" ".join(sorted(e.needs_pull))) ) From d837d27ad7dd4ed8b00d45106af00f251352d634 Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Fri, 22 Nov 2019 14:12:39 +0100 Subject: [PATCH 3799/4072] Update dev version Signed-off-by: Ulysses Souza --- compose/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/__init__.py b/compose/__init__.py index d35e818c75d..69c4e0e49d8 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.25.0' +__version__ = '1.26.0dev' From a7e8356651c6caac6097c1f9b4ec02fd4bc14830 Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Fri, 22 Nov 2019 15:13:22 +0100 Subject: [PATCH 3800/4072] Update maintainers and add CODEOWNERS for github Signed-off-by: Ulysses Souza --- .github/CODEOWNERS | 6 ++++++ MAINTAINERS | 6 ++++++ 2 files changed, 12 insertions(+) create mode 100644 .github/CODEOWNERS diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000000..13fb9bac0ea --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,6 @@ +# GitHub code owners +# See https://help.github.com/articles/about-codeowners/ +# +# KEEP THIS FILE SORTED. Order is important. Last match takes precedence. + +* @ndeloof @rumpl @ulyssessouza diff --git a/MAINTAINERS b/MAINTAINERS index 5d4bd6a6359..27bde5bb38e 100644 --- a/MAINTAINERS +++ b/MAINTAINERS @@ -11,6 +11,7 @@ [Org] [Org."Core maintainers"] people = [ + "ndeloof", "rumpl", "ulyssessouza", ] @@ -77,6 +78,11 @@ Email = "mazz@houseofmnowster.com" GitHub = "mnowster" + [people.ndeloof] + Name = "Nicolas De Loof" + Email = "nicolas.deloof@gmail.com" + GitHub = "ndeloof" + [people.rumpl] Name = "Djordje Lukic" Email = "djordje.lukic@docker.com" From c8cfc590cc8252a3130c40f5257c32c9a3cd7a82 Mon Sep 17 00:00:00 2001 From: Anton Lundin Date: Fri, 22 Nov 2019 16:24:09 +0100 Subject: [PATCH 3801/4072] Better userns detection in container-script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This uses formatting to have docker info just emit the information we're interested in. Based-on-code-by: Sebastiaan van Stijn Signed-off-by: Anton Lundin Co-Authored-By: Sebastiaan van Stijn --- 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 dc28eeb7ec8..5bed51cac30 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -56,7 +56,7 @@ DOCKER_RUN_OPTIONS="$DOCKER_RUN_OPTIONS -i" # Handle userns security -if docker info 2>/dev/null | grep -q userns; then +if docker info --format '{{json .SecurityOptions}}' 2>/dev/null | grep -q 'name=userns'; then DOCKER_RUN_OPTIONS="$DOCKER_RUN_OPTIONS --userns=host" fi From 101ee1cd62ec0f4018a9c1ac46b7de8d71f98215 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 25 Nov 2019 13:27:05 +0000 Subject: [PATCH 3802/4072] Bump idna from 2.5 to 2.8 Bumps [idna](https://github.com/kjd/idna) from 2.5 to 2.8. - [Release notes](https://github.com/kjd/idna/releases) - [Changelog](https://github.com/kjd/idna/blob/master/HISTORY.rst) - [Commits](https://github.com/kjd/idna/compare/v2.5...v2.8) Signed-off-by: dependabot-preview[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 1627cca9ef8..b08f6221bd6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,7 +10,7 @@ dockerpty==0.4.1 docopt==0.6.2 enum34==1.1.6; python_version < '3.4' functools32==3.2.3.post2; python_version < '3.2' -idna==2.5 +idna==2.8 ipaddress==1.0.18 jsonschema==3.0.1 paramiko==2.6.0 From 55c5c8e8ac35d8c069ec8dd2ebeb919e39efca41 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Mon, 25 Nov 2019 11:09:42 +0100 Subject: [PATCH 3803/4072] Report image we can't pull and must be built Signed-off-by: Nicolas De Loof --- compose/progress_stream.py | 10 +++ compose/project.py | 81 ++++++++++++------- tests/acceptance/cli_test.py | 8 ++ .../can-build-pull-failures.yml | 6 ++ 4 files changed, 77 insertions(+), 28 deletions(-) create mode 100644 tests/fixtures/simple-composefile/can-build-pull-failures.yml diff --git a/compose/progress_stream.py b/compose/progress_stream.py index c4281cb4cf4..522ddf75d19 100644 --- a/compose/progress_stream.py +++ b/compose/progress_stream.py @@ -114,3 +114,13 @@ def get_digest_from_push(events): if digest: return digest return None + + +def read_status(event): + status = event['status'].lower() + if 'progressDetail' in event: + detail = event['progressDetail'] + if 'current' in detail and 'total' in detail: + percentage = float(detail['current']) / float(detail['total']) + status = '{} ({:.1%})'.format(status, percentage) + return status diff --git a/compose/project.py b/compose/project.py index d7dcb6bd670..d7405defdac 100644 --- a/compose/project.py +++ b/compose/project.py @@ -11,6 +11,8 @@ import enum import six from docker.errors import APIError +from docker.errors import ImageNotFound +from docker.errors import NotFound from docker.utils import version_lt from . import parallel @@ -25,6 +27,7 @@ from .network import build_networks from .network import get_networks from .network import ProjectNetworks +from .progress_stream import read_status from .service import BuildAction from .service import ContainerNetworkMode from .service import ContainerPidMode @@ -619,46 +622,68 @@ def _get_convergence_plans(self, services, strategy, always_recreate_deps=False) def pull(self, service_names=None, ignore_pull_failures=False, parallel_pull=False, silent=False, include_deps=False): services = self.get_services(service_names, include_deps) - msg = not silent and 'Pulling' or None if parallel_pull: - def pull_service(service): - strm = service.pull(ignore_pull_failures, True, stream=True) - if strm is None: # Attempting to pull service with no `image` key is a no-op - return + self.parallel_pull(services, silent=silent) - writer = parallel.get_stream_writer() + else: + must_build = [] + for service in services: + try: + service.pull(ignore_pull_failures, silent=silent) + except (ImageNotFound, NotFound): + if service.can_be_built(): + must_build.append(service.name) + else: + raise + + if len(must_build): + log.warning('Some service image(s) must be built from source by running:\n' + ' docker-compose build {}' + .format(' '.join(must_build))) + + def parallel_pull(self, services, ignore_pull_failures=False, silent=False): + msg = 'Pulling' if not silent else None + must_build = [] + + def pull_service(service): + strm = service.pull(ignore_pull_failures, True, stream=True) + if strm is None: # Attempting to pull service with no `image` key is a no-op + return + + try: + writer = parallel.get_stream_writer() for event in strm: if 'status' not in event: continue - status = event['status'].lower() - if 'progressDetail' in event: - detail = event['progressDetail'] - if 'current' in detail and 'total' in detail: - percentage = float(detail['current']) / float(detail['total']) - status = '{} ({:.1%})'.format(status, percentage) - + status = read_status(event) writer.write( msg, service.name, truncate_string(status), lambda s: s ) + except (ImageNotFound, NotFound): + if service.can_be_built(): + must_build.append(service.name) + else: + raise - _, errors = parallel.parallel_execute( - services, - pull_service, - operator.attrgetter('name'), - msg, - limit=5, - ) - if len(errors): - combined_errors = '\n'.join([ - e.decode('utf-8') if isinstance(e, six.binary_type) else e for e in errors.values() - ]) - raise ProjectError(combined_errors) + _, errors = parallel.parallel_execute( + services, + pull_service, + operator.attrgetter('name'), + msg, + limit=5, + ) - else: - for service in services: - service.pull(ignore_pull_failures, silent=silent) + if len(must_build): + log.warning('Some service image(s) must be built from source by running:\n' + ' docker-compose build {}' + .format(' '.join(must_build))) + if len(errors): + combined_errors = '\n'.join([ + e.decode('utf-8') if isinstance(e, six.binary_type) else e for e in errors.values() + ]) + raise ProjectError(combined_errors) def push(self, service_names=None, ignore_push_failures=False): unique_images = set() diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 41e51a304fb..b729e7d76d4 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -694,6 +694,14 @@ def test_pull_with_parallel_failure(self): result.stderr ) + def test_pull_can_build(self): + result = self.dispatch([ + '-f', 'can-build-pull-failures.yml', 'pull'], + returncode=0 + ) + assert 'Some service image(s) must be built from source' in result.stderr + assert 'docker-compose build can_build' in result.stderr + def test_pull_with_no_deps(self): self.base_dir = 'tests/fixtures/links-composefile' result = self.dispatch(['pull', '--no-parallel', 'web']) diff --git a/tests/fixtures/simple-composefile/can-build-pull-failures.yml b/tests/fixtures/simple-composefile/can-build-pull-failures.yml new file mode 100644 index 00000000000..1ffe8e0fbd8 --- /dev/null +++ b/tests/fixtures/simple-composefile/can-build-pull-failures.yml @@ -0,0 +1,6 @@ +version: '3' +services: + can_build: + image: nonexisting-image-but-can-build:latest + build: . + command: top From d6b5d324e2b08587c3e4b99ac456276bc4d8d5f7 Mon Sep 17 00:00:00 2001 From: Christopher Crone Date: Thu, 28 Nov 2019 10:50:21 +0100 Subject: [PATCH 3804/4072] Use Python3 for macOS build environment With the deprecation of Python 2 coming soon, explicitly use Python 3. Signed-off-by: Christopher Crone --- script/setup/osx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/setup/osx b/script/setup/osx index 69280f8a298..08420fb2362 100755 --- a/script/setup/osx +++ b/script/setup/osx @@ -36,7 +36,7 @@ if ! [ -x "$(command -v python3)" ]; then brew install python3 fi if ! [ -x "$(command -v virtualenv)" ]; then - pip install virtualenv==16.2.0 + pip3 install virtualenv==16.2.0 fi # From fedc8f71adef0eb33f6697fb16c3c4f023f83617 Mon Sep 17 00:00:00 2001 From: Christopher Crone Date: Fri, 29 Nov 2019 18:27:35 +0100 Subject: [PATCH 3805/4072] Build single binary and folder format for macOS Previously we were overwriting the single binary with the folder format. Signed-off-by: Christopher Crone --- script/build/osx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/script/build/osx b/script/build/osx index bd501ee55b5..66868756b69 100755 --- a/script/build/osx +++ b/script/build/osx @@ -12,11 +12,13 @@ venv/bin/pip install --no-deps . DOCKER_COMPOSE_GITSHA="$(script/build/write-git-sha)" echo "${DOCKER_COMPOSE_GITSHA}" > compose/GITSHA +# Build as a folder for macOS Catalina. +venv/bin/pyinstaller docker-compose_darwin.spec +dist/docker-compose-Darwin-x86_64/docker-compose version +(cd dist/docker-compose-Darwin-x86_64/ && tar zcvf ../docker-compose-Darwin-x86_64.tgz .) +rm -rf dist/docker-compose-Darwin-x86_64 + +# Build static binary for legacy. venv/bin/pyinstaller docker-compose.spec mv dist/docker-compose dist/docker-compose-Darwin-x86_64 dist/docker-compose-Darwin-x86_64 version - -# Also build as a folder, required on osx Catalina -venv/bin/pyinstaller docker-compose_darwin.spec -dist/docker-compose-Darwin-x86_64/docker-compose version -cd dist/docker-compose-Darwin-x86_64/ && tar zcvf ../docker-compose-Darwin-x86_64.tgz . From 882034388245b2a852a90ac4d1ffaa5daa37f751 Mon Sep 17 00:00:00 2001 From: Christopher Crone Date: Fri, 29 Nov 2019 18:28:17 +0100 Subject: [PATCH 3806/4072] Stash all macOS build artifacts Signed-off-by: Christopher Crone --- .circleci/config.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.circleci/config.yml b/.circleci/config.yml index 9b95b7e9e63..36dd8d57e0e 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -29,6 +29,9 @@ jobs: - run: name: build script command: ./script/build/osx + - store_artifacts: + path: dist/docker-compose-Darwin-x86_64 + destination: docker-compose-Darwin-x86_64 - store_artifacts: path: dist/docker-compose-Darwin-x86_64.tgz destination: docker-compose-Darwin-x86_64.tgz From b7a675b1c047dcf53da0076e074271e2af8e4d00 Mon Sep 17 00:00:00 2001 From: Christopher Crone Date: Fri, 29 Nov 2019 18:28:47 +0100 Subject: [PATCH 3807/4072] Upload macOS folder format to bintray Signed-off-by: Christopher Crone --- script/circle/bintray-deploy.sh | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/script/circle/bintray-deploy.sh b/script/circle/bintray-deploy.sh index d508da36563..a7cce726e60 100755 --- a/script/circle/bintray-deploy.sh +++ b/script/circle/bintray-deploy.sh @@ -25,3 +25,11 @@ curl -f -T dist/docker-compose-${OS_NAME}-x86_64 -u$BINTRAY_USERNAME:$BINTRAY_AP -H "X-Bintray-Package: ${PKG_NAME}" -H "X-Bintray-Version: $CIRCLE_BRANCH" \ -H "X-Bintray-Override: 1" -H "X-Bintray-Publish: 1" -X PUT \ https://api.bintray.com/content/docker-compose/${CIRCLE_BRANCH}/docker-compose-${OS_NAME}-x86_64 || exit 1 + +# Upload folder format of docker-compose for macOS in addition to binary. +if [ "${OS_NAME}" == "Darwin" ]; then + curl -f -T dist/docker-compose-${OS_NAME}-x86_64.tgz -u$BINTRAY_USERNAME:$BINTRAY_API_KEY \ + -H "X-Bintray-Package: ${PKG_NAME}" -H "X-Bintray-Version: $CIRCLE_BRANCH" \ + -H "X-Bintray-Override: 1" -H "X-Bintray-Publish: 1" -X PUT \ + https://api.bintray.com/content/docker-compose/${CIRCLE_BRANCH}/docker-compose-${OS_NAME}-x86_64.tgz || exit 1 +fi From d92e9beec1b405df52ff745d8128c72b5bc35833 Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Fri, 29 Nov 2019 19:21:16 +0100 Subject: [PATCH 3808/4072] "Bump 1.25.1-rc1" Signed-off-by: Ulysses Souza --- CHANGELOG.md | 13 +++++++++++++ compose/__init__.py | 2 +- script/run/run.sh | 2 +- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d1a6fae3290..ea0ddf8022e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,19 @@ Change log ========== +1.25.1-rc1 (2019-11-29) +----------------------- + +### Bugfixes + +- Discard label `com.docker.compose.filepaths` having `None` as value. Typically, when coming from stdin + +- Add OSX binary as a directory to solve slow start up time caused by MacOS Catalina binary scan + +- Pass in HOME env-var in container mode (running with `script/run/run.sh`) + +- Revert behavior of "only pull images that we can't build" and replace by a warning informing the image we can't pull and must be built + 1.25.0 (2019-11-18) ------------------- diff --git a/compose/__init__.py b/compose/__init__.py index 69c4e0e49d8..cda82097820 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.26.0dev' +__version__ = '1.25.1-rc1' diff --git a/script/run/run.sh b/script/run/run.sh index 77f245c1337..744766a06cd 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.25.0" +VERSION="1.25.1-rc1" IMAGE="docker/compose:$VERSION" From d32b9f95cae761a6cbc878d894ce40e901b73e6a Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 25 Nov 2019 12:48:05 +0000 Subject: [PATCH 3809/4072] Bump pytest-cov from 2.5.1 to 2.8.1 Bumps [pytest-cov](https://github.com/pytest-dev/pytest-cov) from 2.5.1 to 2.8.1. - [Release notes](https://github.com/pytest-dev/pytest-cov/releases) - [Changelog](https://github.com/pytest-dev/pytest-cov/blob/master/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest-cov/compare/v2.5.1...v2.8.1) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 27b71a26873..0ed26cd1b9c 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -3,4 +3,4 @@ ddt==1.2.0 flake8==3.5.0 mock==3.0.5 pytest==3.6.3 -pytest-cov==2.5.1 +pytest-cov==2.8.1 From 780a425c6079feba7a9a6ad2dc832e33eee2f47e Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 25 Nov 2019 13:28:21 +0000 Subject: [PATCH 3810/4072] Bump flake8 from 3.5.0 to 3.7.9 Bumps [flake8](https://gitlab.com/pycqa/flake8) from 3.5.0 to 3.7.9. - [Release notes](https://gitlab.com/pycqa/flake8/tags) - [Commits](https://gitlab.com/pycqa/flake8/compare/3.5.0...3.7.9) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 0ed26cd1b9c..f0250387279 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,6 +1,6 @@ coverage==4.4.2 ddt==1.2.0 -flake8==3.5.0 +flake8==3.7.9 mock==3.0.5 pytest==3.6.3 pytest-cov==2.8.1 From e9220f45df07c1f884d5d496507778d2cd4a1687 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 25 Nov 2019 12:47:18 +0000 Subject: [PATCH 3811/4072] Bump coverage from 4.4.2 to 4.5.4 Bumps [coverage](https://github.com/nedbat/coveragepy) from 4.4.2 to 4.5.4. - [Release notes](https://github.com/nedbat/coveragepy/releases) - [Changelog](https://github.com/nedbat/coveragepy/blob/master/CHANGES.rst) - [Commits](https://github.com/nedbat/coveragepy/compare/coverage-4.4.2...coverage-4.5.4) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index f0250387279..00a2c64472b 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,4 +1,4 @@ -coverage==4.4.2 +coverage==4.5.4 ddt==1.2.0 flake8==3.7.9 mock==3.0.5 From e6e9263260f92249ed4f71d9bf62cefc0d186e3d Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Wed, 4 Dec 2019 07:10:30 +0000 Subject: [PATCH 3812/4072] Bump ddt from 1.2.0 to 1.2.2 Bumps [ddt](https://github.com/datadriventests/ddt) from 1.2.0 to 1.2.2. - [Release notes](https://github.com/datadriventests/ddt/releases) - [Commits](https://github.com/datadriventests/ddt/compare/1.2.0...1.2.2) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 00a2c64472b..e40cbc43ca6 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,5 +1,5 @@ coverage==4.5.4 -ddt==1.2.0 +ddt==1.2.2 flake8==3.7.9 mock==3.0.5 pytest==3.6.3 From 025002260bb9ac45353d97bc8fd2f493147ffe37 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 9 Dec 2019 13:31:00 +0000 Subject: [PATCH 3813/4072] Bump colorama from 0.4.0 to 0.4.3 Bumps [colorama](https://github.com/tartley/colorama) from 0.4.0 to 0.4.3. - [Release notes](https://github.com/tartley/colorama/releases) - [Changelog](https://github.com/tartley/colorama/blob/master/CHANGELOG.rst) - [Commits](https://github.com/tartley/colorama/compare/0.4.0...0.4.3) Signed-off-by: dependabot-preview[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 1627cca9ef8..05fd188064b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ backports.ssl-match-hostname==3.5.0.1; python_version < '3' cached-property==1.3.0 certifi==2017.4.17 chardet==3.0.4 -colorama==0.4.0; sys_platform == 'win32' +colorama==0.4.3; sys_platform == 'win32' docker==4.1.0 docker-pycreds==0.4.0 dockerpty==0.4.1 From cba8ad474ca0b3b2af20c792cf3cb4d331524bde Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Mon, 6 Jan 2020 15:53:32 +0100 Subject: [PATCH 3814/4072] Fix by adding an assert to make the comparison effective Signed-off-by: Ulysses Souza --- tests/unit/service_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index a6a633db859..99342c9764e 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -521,7 +521,7 @@ def test_create_container(self): assert 'was built because it did not already exist' in args[0] assert self.mock_client.build.call_count == 1 - self.mock_client.build.call_args[1]['tag'] == 'default_foo' + assert self.mock_client.build.call_args[1]['tag'] == 'default_foo' def test_ensure_image_exists_no_build(self): service = Service('foo', client=self.mock_client, build={'context': '.'}) From 37eb7a509b41e5f39cfb6506ebfdef0a59f02765 Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Mon, 6 Jan 2020 16:00:34 +0100 Subject: [PATCH 3815/4072] Decode APIError explanation to unicode before usage Signed-off-by: Ulysses Souza --- 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 d329be97992..024e7fbde9c 100644 --- a/compose/service.py +++ b/compose/service.py @@ -60,6 +60,7 @@ from .utils import parse_seconds_float from .utils import truncate_id from .utils import unique_everseen +from compose.cli.utils import binarystr_to_unicode if six.PY2: import subprocess32 as subprocess @@ -343,7 +344,7 @@ def create_container(self, return Container.create(self.client, **container_options) except APIError as ex: raise OperationFailedError("Cannot create container for service %s: %s" % - (self.name, ex.explanation)) + (self.name, binarystr_to_unicode(ex.explanation))) def ensure_image_exists(self, do_build=BuildAction.none, silent=False, cli=False): if self.can_be_built() and do_build == BuildAction.force: @@ -624,9 +625,10 @@ def start_container(self, container, use_network_aliases=True): try: container.start() except APIError as ex: - if "driver failed programming external connectivity" in ex.explanation: + expl = binarystr_to_unicode(ex.explanation) + if "driver failed programming external connectivity" in expl: log.warn("Host is already in use by another container") - raise OperationFailedError("Cannot start service %s: %s" % (self.name, ex.explanation)) + raise OperationFailedError("Cannot start service %s: %s" % (self.name, expl)) return container @property diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 99342c9764e..592e22f759f 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -523,6 +523,36 @@ def test_create_container(self): assert self.mock_client.build.call_count == 1 assert self.mock_client.build.call_args[1]['tag'] == 'default_foo' + def test_create_container_binary_string_error(self): + service = Service('foo', client=self.mock_client, build={'context': '.'}) + service.image = lambda: {'Id': 'abc123'} + + self.mock_client.create_container.side_effect = APIError(None, + None, + b"Test binary string explanation") + with pytest.raises(OperationFailedError) as ex: + service.create_container() + + assert ex.value.msg == "Cannot create container for service foo: Test binary string explanation" + + def test_start_binary_string_error(self): + service = Service('foo', client=self.mock_client) + container = Container(self.mock_client, {'Id': 'abc123'}) + + self.mock_client.start.side_effect = APIError(None, + None, + b"Test binary string explanation with " + b"driver failed programming external " + b"connectivity") + with mock.patch('compose.service.log', autospec=True) as mock_log: + with pytest.raises(OperationFailedError) as ex: + service.start_container(container) + + assert ex.value.msg == "Cannot start service foo: " \ + "Test binary string explanation " \ + "with driver failed programming external connectivity" + mock_log.warn.assert_called_once_with("Host is already in use by another container") + 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'} From a82fef0722142ee37458db2682f89868acbe0d32 Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Mon, 6 Jan 2020 17:42:50 +0100 Subject: [PATCH 3816/4072] "Bump 1.25.1" Signed-off-by: Ulysses Souza --- CHANGELOG.md | 26 ++++++++++++++++++++++++++ compose/__init__.py | 2 +- script/run/run.sh | 2 +- 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d1a6fae3290..e8cd889f079 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,32 @@ Change log ========== +1.25.1 (2020-01-06) +------------------- + +### Features + +- Bump `pytest-cov` 2.8.1 + +- Bump `flake8` 3.7.9 + +- Bump `coverage` 4.5.4 + +### Bugfixes + +- Decode APIError explanation to unicode before usage on start and create of a container + +- Reports when images that cannot be pulled and must be built + +- Discard label `com.docker.compose.filepaths` having None as value. Typically, when coming from stdin + +- Added OSX binary as a directory to solve slow start up time caused by MacOS Catalina binary scan + +- Passed in HOME env-var in container mode (running with `script/run/run.sh`) + +- Reverted behavior of "only pull images that we can't build" and replace by a warning informing the image we can't pull and must be built + + 1.25.0 (2019-11-18) ------------------- diff --git a/compose/__init__.py b/compose/__init__.py index 69c4e0e49d8..8112b4e163e 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.26.0dev' +__version__ = '1.25.1' diff --git a/script/run/run.sh b/script/run/run.sh index 77f245c1337..7df5fe979d9 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.25.0" +VERSION="1.25.1" IMAGE="docker/compose:$VERSION" From 67cce913a6ab960b7ddc476fa9a16adb39a69862 Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Tue, 7 Jan 2020 16:44:42 +0100 Subject: [PATCH 3817/4072] Set dev version to 1.26.0dev after releasing 1.25.1 Signed-off-by: Ulysses Souza --- compose/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/__init__.py b/compose/__init__.py index 8112b4e163e..69c4e0e49d8 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.25.1' +__version__ = '1.26.0dev' From 7f49bbb998546d6850c2ea185157aed567db5deb Mon Sep 17 00:00:00 2001 From: ulyssessouza Date: Thu, 12 Dec 2019 01:03:19 +0100 Subject: [PATCH 3818/4072] Validate version format on formats 2+ Signed-off-by: ulyssessouza --- compose/config/config.py | 7 +++++++ tests/unit/config/config_test.py | 23 +++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/compose/config/config.py b/compose/config/config.py index f64dc04a0f5..84933e9c9de 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -5,6 +5,7 @@ import io import logging import os +import re import string import sys from collections import namedtuple @@ -214,6 +215,12 @@ def version(self): .format(self.filename, VERSION_EXPLANATION) ) + version_pattern = re.compile(r"^[2-9]+(\.\d+)?$") + if not version_pattern.match(version): + raise ConfigurationError( + 'Version "{}" in "{}" is invalid.' + .format(version, self.filename)) + if version == '2': return const.COMPOSEFILE_V2_0 diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 0d3f49b99a5..0f744e22afe 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -13,6 +13,8 @@ import py import pytest import yaml +from ddt import data +from ddt import ddt from ...helpers import build_config_details from ...helpers import BUSYBOX_IMAGE_WITH_TAG @@ -68,6 +70,7 @@ def secret_sort(secrets): return sorted(secrets, key=itemgetter('source')) +@ddt class ConfigTest(unittest.TestCase): def test_load(self): @@ -1885,6 +1888,26 @@ def test_swappiness_option(self): } ] + @data( + '2 ', + '3.', + '3.0.0', + '3.0.a', + '3.a', + '3a') + def test_invalid_version_formats(self, version): + content = { + 'version': version, + 'services': { + 'web': { + 'image': 'alpine', + } + } + } + with pytest.raises(ConfigurationError) as exc: + config.load(build_config_details(content)) + assert 'Version "{}" in "filename.yml" is invalid.'.format(version) in exc.exconly() + def test_group_add_option(self): actual = config.load(build_config_details({ 'version': '2', From 3df4ba1544e90f7dc7f01b018e92d018295b73c9 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Mon, 6 Jan 2020 17:25:25 +0100 Subject: [PATCH 3819/4072] Assume infinite terminal width when not running in a terminal Close https://github.com/docker/compose/issues/7119 Signed-off-by: Nicolas De Loof --- compose/cli/formatter.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/compose/cli/formatter.py b/compose/cli/formatter.py index c1f43ed7a2f..9651fb4da44 100644 --- a/compose/cli/formatter.py +++ b/compose/cli/formatter.py @@ -17,7 +17,12 @@ def get_tty_width(): try: - width, _ = get_terminal_size() + # get_terminal_size can't determine the size if compose is piped + # to another command. But in such case it doesn't make sense to + # try format the output by terminal size as this output is consumed + # by another command. So let's pretend we have a huge terminal so + # output is single-lined + width, _ = get_terminal_size(fallback=(999, 0)) return int(width) except OSError: return 0 From dd889b990b723edf08c21b7442d02f603cf8eec1 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Tue, 19 Nov 2019 17:26:34 +0100 Subject: [PATCH 3820/4072] Prepare drop of python 2.x support see https://github.com/docker/compose/issues/6890 Signed-off-by: Nicolas De Loof --- Jenkinsfile | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index 1d7c348e3b0..db9f601d7ce 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -48,7 +48,7 @@ def runTests = { Map settings -> def imageName = settings.get("image", null) if (!pythonVersions) { - throw new Exception("Need Python versions to test. e.g.: `runTests(pythonVersions: 'py27,py37')`") + throw new Exception("Need Python versions to test. e.g.: `runTests(pythonVersions: 'py37')`") } if (!dockerVersions) { throw new Exception("Need Docker versions to test. e.g.: `runTests(dockerVersions: 'all')`") @@ -82,13 +82,10 @@ def runTests = { Map settings -> def testMatrix = [failFast: true] def baseImages = ['alpine', 'debian'] -def pythonVersions = ['py27', 'py37'] baseImages.each { baseImage -> def imageName = buildImage(baseImage) get_versions(imageName, 2).each { dockerVersion -> - pythonVersions.each { pyVersion -> - testMatrix["${baseImage}_${dockerVersion}_${pyVersion}"] = runTests([baseImage: baseImage, image: imageName, dockerVersions: dockerVersion, pythonVersions: pyVersion]) - } + testMatrix["${baseImage}_${dockerVersion}"] = runTests([baseImage: baseImage, image: imageName, dockerVersions: dockerVersion, pythonVersions: 'py37']) } } From c5c287db5c31c2107bfd5778205e54fe9bf30b84 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Tue, 3 Dec 2019 11:44:30 +0100 Subject: [PATCH 3821/4072] We don't use FOSSA anymore Signed-off-by: Nicolas De Loof --- script/Jenkinsfile.fossa | 20 -------------------- script/fossa.mk | 16 ---------------- 2 files changed, 36 deletions(-) delete mode 100644 script/Jenkinsfile.fossa delete mode 100644 script/fossa.mk diff --git a/script/Jenkinsfile.fossa b/script/Jenkinsfile.fossa deleted file mode 100644 index 480e98efad4..00000000000 --- a/script/Jenkinsfile.fossa +++ /dev/null @@ -1,20 +0,0 @@ -pipeline { - agent any - stages { - stage("License Scan") { - agent { - label 'ubuntu-1604-aufs-edge' - } - - steps { - withCredentials([ - string(credentialsId: 'fossa-api-key', variable: 'FOSSA_API_KEY') - ]) { - checkout scm - sh "FOSSA_API_KEY='${FOSSA_API_KEY}' BRANCH_NAME='${env.BRANCH_NAME}' make -f script/fossa.mk fossa-analyze" - sh "FOSSA_API_KEY='${FOSSA_API_KEY}' make -f script/fossa.mk fossa-test" - } - } - } - } -} diff --git a/script/fossa.mk b/script/fossa.mk deleted file mode 100644 index 8d7af49d82c..00000000000 --- a/script/fossa.mk +++ /dev/null @@ -1,16 +0,0 @@ -# Variables for Fossa -BUILD_ANALYZER?=docker/fossa-analyzer -FOSSA_OPTS?=--option all-tags:true --option allow-unresolved:true - -fossa-analyze: - docker run --rm -e FOSSA_API_KEY=$(FOSSA_API_KEY) \ - -v $(CURDIR)/$*:/go/src/github.com/docker/compose \ - -w /go/src/github.com/docker/compose \ - $(BUILD_ANALYZER) analyze ${FOSSA_OPTS} --branch ${BRANCH_NAME} - - # This command is used to run the fossa test command -fossa-test: - docker run -i -e FOSSA_API_KEY=$(FOSSA_API_KEY) \ - -v $(CURDIR)/$*:/go/src/github.com/docker/compose \ - -w /go/src/github.com/docker/compose \ - $(BUILD_ANALYZER) test From 912d90832c88ab401115db59a5df9ace4c1bd3d2 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Tue, 3 Dec 2019 13:45:05 +0100 Subject: [PATCH 3822/4072] Use a simple script to get docker-ce releases Signed-off-by: Nicolas De Loof --- .dockerignore | 1 + Jenkinsfile | 27 ++++++++++++--------------- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/.dockerignore b/.dockerignore index 65ad588d9fd..c1323a91826 100644 --- a/.dockerignore +++ b/.dockerignore @@ -11,3 +11,4 @@ docs/_site .tox **/__pycache__ *.pyc +Jenkinsfile diff --git a/Jenkinsfile b/Jenkinsfile index db9f601d7ce..95a8bb522f9 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -1,20 +1,19 @@ #!groovy -def buildImage = { String baseImage -> +def buildImage(baseImage) { def image wrappedNode(label: "ubuntu && amd64 && !zfs", cleanWorkspace: true) { stage("build image for \"${baseImage}\"") { - checkout(scm) - def imageName = "dockerbuildbot/compose:${baseImage}-${gitCommit()}" + def scmvar = checkout(scm) + def imageName = "dockerbuildbot/compose:${baseImage}-${scmvar.GIT_COMMIT}" image = docker.image(imageName) try { image.pull() } catch (Exception exc) { - sh """GIT_COMMIT=\$(script/build/write-git-sha) && \\ - docker build -t ${imageName} \\ + sh """docker build -t ${imageName} \\ --target build \\ --build-arg BUILD_PLATFORM="${baseImage}" \\ - --build-arg GIT_COMMIT="${GIT_COMMIT}" \\ + --build-arg GIT_COMMIT="${scmvar.GIT_COMMIT}" \\ .\\ """ sh "docker push ${imageName}" @@ -27,16 +26,14 @@ def buildImage = { String baseImage -> return image.id } -def get_versions = { String imageId, int number -> +def get_versions(imageId, number) { def docker_versions wrappedNode(label: "ubuntu && amd64 && !zfs") { - def result = sh(script: """docker run --rm \\ - --entrypoint=/code/.tox/py27/bin/python \\ - ${imageId} \\ - /code/script/test/versions.py -n ${number} docker/docker-ce recent - """, returnStdout: true - ) - docker_versions = result.split() + docker_versions = sh(script:""" + curl https://api.github.com/repos/docker/docker-ce/releases \ + | jq -r -c '.[] | select (.prerelease == false ) | .tag_name | ltrimstr("v")' > /tmp/versions.txt + for v in \$(cut -f1 -d"." /tmp/versions.txt | uniq | head -${number}); do grep -m 1 "\$v" /tmp/versions.txt ; done + """, returnStdout: true) } return docker_versions } @@ -84,7 +81,7 @@ def testMatrix = [failFast: true] def baseImages = ['alpine', 'debian'] baseImages.each { baseImage -> def imageName = buildImage(baseImage) - get_versions(imageName, 2).each { dockerVersion -> + get_versions(imageName, 2).eachLine { dockerVersion -> testMatrix["${baseImage}_${dockerVersion}"] = runTests([baseImage: baseImage, image: imageName, dockerVersions: dockerVersion, pythonVersions: 'py37']) } } From 644c55c4f7d0630755f45de1a80561f98267af17 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Tue, 3 Dec 2019 16:47:52 +0100 Subject: [PATCH 3823/4072] Use declarative syntax when possible Signed-off-by: Nicolas De Loof --- Jenkinsfile | 88 ++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 63 insertions(+), 25 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index 95a8bb522f9..5b62363cccd 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -1,32 +1,70 @@ #!groovy +pipeline { + agent none + + options { + skipDefaultCheckout(true) + buildDiscarder(logRotator(daysToKeepStr: '30')) + timeout(time: 2, unit: 'HOURS') + timestamps() + } + + environment { + TAG = tag() + BUILD_TAG = tag() + } + + stages { + stage('Build test images') { + parallel { + stage('alpine') { + agent { + label 'ubuntu && amd64 && !zfs' + } + steps { + buildImage('alpine') + } + } + stage('debian') { + agent { + label 'ubuntu && amd64 && !zfs' + } + steps { + buildImage('debian') + } + } + } + } + } +} + + def buildImage(baseImage) { - def image - wrappedNode(label: "ubuntu && amd64 && !zfs", cleanWorkspace: true) { - stage("build image for \"${baseImage}\"") { - def scmvar = checkout(scm) - def imageName = "dockerbuildbot/compose:${baseImage}-${scmvar.GIT_COMMIT}" - image = docker.image(imageName) - try { - image.pull() - } catch (Exception exc) { - sh """docker build -t ${imageName} \\ - --target build \\ - --build-arg BUILD_PLATFORM="${baseImage}" \\ - --build-arg GIT_COMMIT="${scmvar.GIT_COMMIT}" \\ - .\\ - """ - sh "docker push ${imageName}" - echo "${imageName}" - return imageName - } + def scmvar = checkout(scm) + def imageName = "dockerbuildbot/compose:${baseImage}-${scmvar.GIT_COMMIT}" + image = docker.image(imageName) + + withDockerRegistry(credentialsId:'dockerbuildbot-index.docker.io') { + try { + image.pull() + } catch (Exception exc) { + ansiColor('xterm') { + sh """docker build -t ${imageName} \\ + --target build \\ + --build-arg BUILD_PLATFORM="${baseImage}" \\ + --build-arg GIT_COMMIT="${scmvar.GIT_COMMIT}" \\ + .\\ + """ + sh "docker push ${imageName}" + } + echo "${imageName}" + return imageName + } } - } - echo "image.id: ${image.id}" - return image.id } -def get_versions(imageId, number) { +def get_versions(number) { def docker_versions wrappedNode(label: "ubuntu && amd64 && !zfs") { docker_versions = sh(script:""" @@ -42,7 +80,6 @@ def runTests = { Map settings -> def dockerVersions = settings.get("dockerVersions", null) def pythonVersions = settings.get("pythonVersions", null) def baseImage = settings.get("baseImage", null) - def imageName = settings.get("image", null) if (!pythonVersions) { throw new Exception("Need Python versions to test. e.g.: `runTests(pythonVersions: 'py37')`") @@ -54,7 +91,8 @@ def runTests = { Map settings -> { -> wrappedNode(label: "ubuntu && amd64 && !zfs", cleanWorkspace: true) { stage("test python=${pythonVersions} / docker=${dockerVersions} / baseImage=${baseImage}") { - checkout(scm) + def scmvar = checkout(scm) + def imageName = "dockerbuildbot/compose:${baseImage}-${scmvar.GIT_COMMIT}" 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 \\ From 2955f48468574f783207baeff9754655310af719 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Wed, 4 Dec 2019 08:43:28 +0100 Subject: [PATCH 3824/4072] Get docker versions using a plain command line Signed-off-by: Nicolas De Loof --- Jenkinsfile | 65 +++++++++++++++++++++++++---------------------------- 1 file changed, 30 insertions(+), 35 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index 5b62363cccd..694f12f471d 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -1,5 +1,9 @@ #!groovy +def dockerVersions +def baseImages = ['alpine', 'debian'] +def pythonVersions = ['py27', 'py37'] + pipeline { agent none @@ -17,6 +21,7 @@ pipeline { stages { stage('Build test images') { + // TODO use declarative 1.5.0 `matrix` once available on CI parallel { stage('alpine') { agent { @@ -36,6 +41,20 @@ pipeline { } } } + stage('Get Docker versions') { + agent { + label 'ubuntu' + } + steps { + script { + dockerVersions = sh(script:""" + curl https://api.github.com/repos/docker/docker-ce/releases \ + | jq -r -c '.[] | select (.prerelease == false ) | .tag_name | ltrimstr("v")' > /tmp/versions.txt + for v in \$(cut -f1 -d"." /tmp/versions.txt | uniq | head -2); do grep -m 1 "\$v" /tmp/versions.txt ; done + """, returnStdout: true) + } + } + } } } @@ -64,33 +83,9 @@ def buildImage(baseImage) { } } -def get_versions(number) { - def docker_versions - wrappedNode(label: "ubuntu && amd64 && !zfs") { - docker_versions = sh(script:""" - curl https://api.github.com/repos/docker/docker-ce/releases \ - | jq -r -c '.[] | select (.prerelease == false ) | .tag_name | ltrimstr("v")' > /tmp/versions.txt - for v in \$(cut -f1 -d"." /tmp/versions.txt | uniq | head -${number}); do grep -m 1 "\$v" /tmp/versions.txt ; done - """, returnStdout: true) - } - return docker_versions -} - -def runTests = { Map settings -> - def dockerVersions = settings.get("dockerVersions", null) - def pythonVersions = settings.get("pythonVersions", null) - def baseImage = settings.get("baseImage", null) - - if (!pythonVersions) { - throw new Exception("Need Python versions to test. e.g.: `runTests(pythonVersions: 'py37')`") - } - if (!dockerVersions) { - throw new Exception("Need Docker versions to test. e.g.: `runTests(dockerVersions: 'all')`") - } - - { -> +def runTests(dockerVersion, pythonVersion, baseImage) { wrappedNode(label: "ubuntu && amd64 && !zfs", cleanWorkspace: true) { - stage("test python=${pythonVersions} / docker=${dockerVersions} / baseImage=${baseImage}") { + stage("test python=${pythonVersion} / docker=${dockerVersion} / baseImage=${baseImage}") { def scmvar = checkout(scm) def imageName = "dockerbuildbot/compose:${baseImage}-${scmvar.GIT_COMMIT}" def storageDriver = sh(script: 'docker info | awk -F \': \' \'$1 == "Storage Driver" { print $2; exit }\'', returnStdout: true).trim() @@ -103,25 +98,25 @@ def runTests = { Map settings -> --volume="/var/run/docker.sock:/var/run/docker.sock" \\ -e "TAG=${imageName}" \\ -e "STORAGE_DRIVER=${storageDriver}" \\ - -e "DOCKER_VERSIONS=${dockerVersions}" \\ + -e "DOCKER_VERSIONS=${dockerVersion}" \\ -e "BUILD_NUMBER=\$BUILD_TAG" \\ - -e "PY_TEST_VERSIONS=${pythonVersions}" \\ + -e "PY_TEST_VERSIONS=${pythonVersion}" \\ --entrypoint="script/test/ci" \\ ${imageName} \\ --verbose """ - } + } } - } } def testMatrix = [failFast: true] -def baseImages = ['alpine', 'debian'] + baseImages.each { baseImage -> - def imageName = buildImage(baseImage) - get_versions(imageName, 2).eachLine { dockerVersion -> - testMatrix["${baseImage}_${dockerVersion}"] = runTests([baseImage: baseImage, image: imageName, dockerVersions: dockerVersion, pythonVersions: 'py37']) + dockerVersions.eachLine { dockerVersion -> + pythonVersions.each { pythonVersion -> + testMatrix["${baseImage}_${dockerVersion}_${pythonVersion}"] = runTests(dockerVersion, pythonVersion, baseImage) + } } } -parallel(testMatrix) +parallel testMatrix From 9478725a70fd80622395518753d944e0ed594b7c Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Wed, 4 Dec 2019 09:40:33 +0100 Subject: [PATCH 3825/4072] Fix tested docker releases in Pipeline This allows Engine team to trigger a compose build by pushing a PR changing the `dockerVersions` variable to test Release Candidates Signed-off-by: Nicolas De Loof --- Jenkinsfile | 34 ++++++++++++---------------------- 1 file changed, 12 insertions(+), 22 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index 694f12f471d..cae52e14714 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -1,6 +1,6 @@ #!groovy -def dockerVersions +def dockerVersions = ['19.03.5', '18.09.9'] def baseImages = ['alpine', 'debian'] def pythonVersions = ['py27', 'py37'] @@ -41,17 +41,19 @@ pipeline { } } } - stage('Get Docker versions') { - agent { - label 'ubuntu' - } + stage('Test') { steps { script { - dockerVersions = sh(script:""" - curl https://api.github.com/repos/docker/docker-ce/releases \ - | jq -r -c '.[] | select (.prerelease == false ) | .tag_name | ltrimstr("v")' > /tmp/versions.txt - for v in \$(cut -f1 -d"." /tmp/versions.txt | uniq | head -2); do grep -m 1 "\$v" /tmp/versions.txt ; done - """, returnStdout: true) + def testMatrix = [:] + baseImages.each { baseImage -> + dockerVersions.each { dockerVersion -> + pythonVersions.each { pythonVersion -> + testMatrix["${baseImage}_${dockerVersion}_${pythonVersion}"] = runTests(dockerVersion, pythonVersion, baseImage) + } + } + } + + parallel testMatrix } } } @@ -108,15 +110,3 @@ def runTests(dockerVersion, pythonVersion, baseImage) { } } } - -def testMatrix = [failFast: true] - -baseImages.each { baseImage -> - dockerVersions.eachLine { dockerVersion -> - pythonVersions.each { pythonVersion -> - testMatrix["${baseImage}_${dockerVersion}_${pythonVersion}"] = runTests(dockerVersion, pythonVersion, baseImage) - } - } -} - -parallel testMatrix From 8859ab0d66a1996af3be11ddd2b289996f7cd70b Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Wed, 4 Dec 2019 10:10:37 +0100 Subject: [PATCH 3826/4072] Use gotemplate formater to extract specific data Signed-off-by: Nicolas De Loof --- Jenkinsfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index cae52e14714..2cf2a0e2816 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -90,7 +90,7 @@ def runTests(dockerVersion, pythonVersion, baseImage) { stage("test python=${pythonVersion} / docker=${dockerVersion} / baseImage=${baseImage}") { def scmvar = checkout(scm) def imageName = "dockerbuildbot/compose:${baseImage}-${scmvar.GIT_COMMIT}" - def storageDriver = sh(script: 'docker info | awk -F \': \' \'$1 == "Storage Driver" { print $2; exit }\'', returnStdout: true).trim() + def storageDriver = sh(script: "docker info -f \'{{.Driver}}\'", returnStdout: true).trim() echo "Using local system's storage driver: ${storageDriver}" sh """docker run \\ -t \\ @@ -101,7 +101,7 @@ def runTests(dockerVersion, pythonVersion, baseImage) { -e "TAG=${imageName}" \\ -e "STORAGE_DRIVER=${storageDriver}" \\ -e "DOCKER_VERSIONS=${dockerVersion}" \\ - -e "BUILD_NUMBER=\$BUILD_TAG" \\ + -e "BUILD_NUMBER=${env.BUILD_NUMBER}" \\ -e "PY_TEST_VERSIONS=${pythonVersion}" \\ --entrypoint="script/test/ci" \\ ${imageName} \\ From 6b0acc9ecbcf7b769612b775ce895a4f0b53277b Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Wed, 4 Dec 2019 10:33:54 +0100 Subject: [PATCH 3827/4072] tests don't run in parallel Signed-off-by: Nicolas De Loof --- Jenkinsfile | 49 +++++++++++++++++++++++++++---------------------- 1 file changed, 27 insertions(+), 22 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index 2cf2a0e2816..87ff15b7f18 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -43,6 +43,7 @@ pipeline { } stage('Test') { steps { + // TODO use declarative 1.5.0 `matrix` once available on CI script { def testMatrix = [:] baseImages.each { baseImage -> @@ -86,27 +87,31 @@ def buildImage(baseImage) { } def runTests(dockerVersion, pythonVersion, baseImage) { - wrappedNode(label: "ubuntu && amd64 && !zfs", cleanWorkspace: true) { - stage("test python=${pythonVersion} / docker=${dockerVersion} / baseImage=${baseImage}") { - def scmvar = checkout(scm) - def imageName = "dockerbuildbot/compose:${baseImage}-${scmvar.GIT_COMMIT}" - def storageDriver = sh(script: "docker info -f \'{{.Driver}}\'", 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=${imageName}" \\ - -e "STORAGE_DRIVER=${storageDriver}" \\ - -e "DOCKER_VERSIONS=${dockerVersion}" \\ - -e "BUILD_NUMBER=${env.BUILD_NUMBER}" \\ - -e "PY_TEST_VERSIONS=${pythonVersion}" \\ - --entrypoint="script/test/ci" \\ - ${imageName} \\ - --verbose - """ - } + return { + stage("python=${pythonVersion} docker=${dockerVersion} ${baseImage}") { + node("ubuntu && amd64 && !zfs") { + def scmvar = checkout(scm) + def imageName = "dockerbuildbot/compose:${baseImage}-${scmvar.GIT_COMMIT}" + def storageDriver = sh(script: "docker info -f \'{{.Driver}}\'", returnStdout: true).trim() + echo "Using local system's storage driver: ${storageDriver}" + withDockerRegistry(credentialsId:'dockerbuildbot-index.docker.io') { + sh """docker run \\ + -t \\ + --rm \\ + --privileged \\ + --volume="\$(pwd)/.git:/code/.git" \\ + --volume="/var/run/docker.sock:/var/run/docker.sock" \\ + -e "TAG=${imageName}" \\ + -e "STORAGE_DRIVER=${storageDriver}" \\ + -e "DOCKER_VERSIONS=${dockerVersion}" \\ + -e "BUILD_NUMBER=${env.BUILD_NUMBER}" \\ + -e "PY_TEST_VERSIONS=${pythonVersion}" \\ + --entrypoint="script/test/ci" \\ + ${imageName} \\ + --verbose + """ + } + } + } } } From 7be66baaa74fe9e32ab303c91a177fa0db16e7dd Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Wed, 4 Dec 2019 13:54:05 +0100 Subject: [PATCH 3828/4072] TAG and BUILD_TAG are obsolete Signed-off-by: Nicolas De Loof --- Jenkinsfile | 5 ----- 1 file changed, 5 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index 87ff15b7f18..35998bf96d4 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -14,11 +14,6 @@ pipeline { timestamps() } - environment { - TAG = tag() - BUILD_TAG = tag() - } - stages { stage('Build test images') { // TODO use declarative 1.5.0 `matrix` once available on CI From da5567715479612264d58384fd9449ae0effffae Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Thu, 5 Dec 2019 12:11:06 +0100 Subject: [PATCH 3829/4072] Release pipeline Signed-off-by: Nicolas De Loof --- Jenkinsfile.release | 267 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 267 insertions(+) create mode 100644 Jenkinsfile.release diff --git a/Jenkinsfile.release b/Jenkinsfile.release new file mode 100644 index 00000000000..d089a8c9876 --- /dev/null +++ b/Jenkinsfile.release @@ -0,0 +1,267 @@ +#!groovy + +def dockerVersions = ['19.03.5', '18.09.9'] +def baseImages = ['alpine', 'debian'] +def pythonVersions = ['py27', 'py37'] + +pipeline { + agent none + + options { + skipDefaultCheckout(true) + buildDiscarder(logRotator(daysToKeepStr: '30')) + timeout(time: 2, unit: 'HOURS') + timestamps() + } + + stages { + stage('Build test images') { + // TODO use declarative 1.5.0 `matrix` once available on CI + parallel { + stage('alpine') { + agent { + label 'linux' + } + steps { + buildImage('alpine') + } + } + stage('debian') { + agent { + label 'linux' + } + steps { + buildImage('debian') + } + } + } + } + stage('Test') { + steps { + // TODO use declarative 1.5.0 `matrix` once available on CI + script { + def testMatrix = [:] + baseImages.each { baseImage -> + dockerVersions.each { dockerVersion -> + pythonVersions.each { pythonVersion -> + testMatrix["${baseImage}_${dockerVersion}_${pythonVersion}"] = runTests(dockerVersion, pythonVersion, baseImage) + } + } + } + + parallel testMatrix + } + } + } + stage('Package') { + parallel { + stage('macosx binary') { + agent { + label 'mac-python' + } + steps { + checkout scm + sh 'script/setup/osx-ci' + sh 'tox -e py27,py37 -- tests/unit' + sh './script/build/osx' + checksum("dist/docker-compose-Darwin-x86_64") + checksum("dist/docker-compose-Darwin-x86_64.tgz") + archiveArtifacts artifacts: 'dist/*', fingerprint: true + dir("dist") { + stash name: "bin-darwin" + } + } + } + stage('linux binary') { + agent { + label 'linux' + } + steps { + checkout scm + sh ' ./script/build/linux' + checksum("dist/docker-compose-Linux-x86_64") + archiveArtifacts artifacts: 'dist/*', fingerprint: true + dir("dist") { + stash name: "bin-linux" + } + } + } + stage('windows binary') { + agent { + label 'windows-python' + } + environment { + PATH = "$PATH;C:\\Python37;C:\\Python37\\Scripts" + } + steps { + checkout scm + bat 'tox.exe -e py27,py37 -- tests/unit' + powershell '.\\script\\build\\windows.ps1' + checksum("dist/docker-compose-Windows-x86_64.exe") + archiveArtifacts artifacts: 'dist/*', fingerprint: true + dir("dist") { + stash name: "bin-win" + } + } + } + stage('alpine image') { + agent { + label 'linux' + } + steps { + buildRuntimeImage('alpine') + } + } + stage('debian image') { + agent { + label 'linux' + } + steps { + buildRuntimeImage('debian') + } + } + } + } + stage('Release') { + when { + buildingTag() + } + parallel { + stage('Pushing images') { + agent { + label 'linux' + } + steps { + pushRuntimeImage('alpine') + pushRuntimeImage('debian') + } + } + stage('Creating Github Release') { + agent { + label 'linux' + } + steps { + checkout scm + sh 'mkdir -p dist' + dir("dist") { + unstash "bin-darwin" + unstash "bin-linux" + unstash "bin-win" + githubRelease("docker/compose") + } + } + } + stage('Publishing Python packages') { + agent { + label 'linux' + } + steps { + checkout scm + withCredentials([[$class: "FileBinding", credentialsId: 'pypirc-docker-dsg-cibot', variable: 'PYPIRC']]) { + sh './script/release/python-package' + } + archiveArtifacts artifacts: 'dist/*', fingerprint: true + } + } + stage('Publishing binaries to Bintray') { + agent { + label 'linux' + } + steps { + checkout scm + dir("dist") { + unstash "bin-darwin" + unstash "bin-linux" + unstash "bin-win" + } + withCredentials([usernamePassword(credentialsId: 'bintray-docker-dsg-cibot', usernameVariable: 'BINTRAY_USER', passwordVariable: 'BINTRAY_TOKEN')]) { + sh './script/release/push-binaries' + } + } + } + } + } + } +} + + +def buildImage(baseImage) { + def scmvar = checkout(scm) + def imageName = "dockerbuildbot/compose:${baseImage}-${scmvar.GIT_COMMIT}" + image = docker.image(imageName) + + withDockerRegistry(credentialsId:'dockerbuildbot-index.docker.io') { + try { + image.pull() + } catch (Exception exc) { + ansiColor('xterm') { + sh """docker build -t ${imageName} \\ + --target build \\ + --build-arg BUILD_PLATFORM="${baseImage}" \\ + --build-arg GIT_COMMIT="${scmvar.GIT_COMMIT}" \\ + .\\ + """ + sh "docker push ${imageName}" + } + echo "${imageName}" + return imageName + } + } +} + +def runTests(dockerVersion, pythonVersion, baseImage) { + return { + stage("python=${pythonVersion} docker=${dockerVersion} ${baseImage}") { + node("linux") { + def scmvar = checkout(scm) + def imageName = "dockerbuildbot/compose:${baseImage}-${scmvar.GIT_COMMIT}" + def storageDriver = sh(script: "docker info -f \'{{.Driver}}\'", returnStdout: true).trim() + echo "Using local system's storage driver: ${storageDriver}" + withDockerRegistry(credentialsId:'dockerbuildbot-index.docker.io') { + sh """docker run \\ + -t \\ + --rm \\ + --privileged \\ + --volume="\$(pwd)/.git:/code/.git" \\ + --volume="/var/run/docker.sock:/var/run/docker.sock" \\ + -e "TAG=${imageName}" \\ + -e "STORAGE_DRIVER=${storageDriver}" \\ + -e "DOCKER_VERSIONS=${dockerVersion}" \\ + -e "BUILD_NUMBER=${env.BUILD_NUMBER}" \\ + -e "PY_TEST_VERSIONS=${pythonVersion}" \\ + --entrypoint="script/test/ci" \\ + ${imageName} \\ + --verbose + """ + } + } + } + } +} + +def buildRuntimeImage(baseImage) { + scmvar = checkout scm + def imageName = "docker/compose:${baseImage}-${env.BRANCH_NAME}" + ansiColor('xterm') { + sh """docker build -t ${imageName} \\ + --build-arg BUILD_PLATFORM="${baseImage}" \\ + --build-arg GIT_COMMIT="${scmvar.GIT_COMMIT.take(7)}" + """ + } + sh "mkdir -p dist" + sh "docker save ${imageName} -o dist/docker-compose-${baseImage}.tar" + stash name: "compose-${baseImage}", includes: "dist/docker-compose-${baseImage}.tar" +} + +def pushRuntimeImage(baseImage) { + unstash "compose-${baseImage}" + sh 'echo -n "${DOCKERHUB_CREDS_PSW}" | docker login --username "${DOCKERHUB_CREDS_USR}" --password-stdin' + sh "docker load -i dist/docker-compose-${baseImage}.tar" + withDockerRegistry(credentialsId: 'dockerbuildbot-hub.docker.com') { + sh "docker push docker/compose:${baseImage}-${env.TAG_NAME}" + if (baseImage == "alpine" && env.TAG_NAME != null) { + sh "docker tag docker/compose:alpine-${env.TAG_NAME} docker/compose:${env.TAG_NAME}" + sh "docker push docker/compose:${env.TAG_NAME}" + } + } +} From bdb11849b15848244050238f3c8dd3ce4362ab90 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Thu, 5 Dec 2019 12:45:57 +0100 Subject: [PATCH 3830/4072] Use .Jenkinsfile extension for IDE support Signed-off-by: Nicolas De Loof --- Jenkinsfile.release => Release.Jenkinsfile | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename Jenkinsfile.release => Release.Jenkinsfile (100%) diff --git a/Jenkinsfile.release b/Release.Jenkinsfile similarity index 100% rename from Jenkinsfile.release rename to Release.Jenkinsfile From 417d72ea3d9e0080dc0180c6a7c0da0f3e4a9840 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Thu, 5 Dec 2019 14:23:41 +0100 Subject: [PATCH 3831/4072] Compute checksum Signed-off-by: Nicolas De Loof --- Release.Jenkinsfile | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/Release.Jenkinsfile b/Release.Jenkinsfile index d089a8c9876..8958ada61de 100644 --- a/Release.Jenkinsfile +++ b/Release.Jenkinsfile @@ -61,11 +61,13 @@ pipeline { } steps { checkout scm - sh 'script/setup/osx-ci' + sh './script/setup/osx' sh 'tox -e py27,py37 -- tests/unit' sh './script/build/osx' - checksum("dist/docker-compose-Darwin-x86_64") - checksum("dist/docker-compose-Darwin-x86_64.tgz") + dir ('dist') { + sh 'openssl sha256 -r -out docker-compose-Darwin-x86_64.sha256 docker-compose-Darwin-x86_64' + sh 'openssl sha256 -r -out docker-compose-Darwin-x86_64.tgz.sha256 docker-compose-Darwin-x86_64.tgz' + } archiveArtifacts artifacts: 'dist/*', fingerprint: true dir("dist") { stash name: "bin-darwin" @@ -79,7 +81,9 @@ pipeline { steps { checkout scm sh ' ./script/build/linux' - checksum("dist/docker-compose-Linux-x86_64") + dir ('dist') { + sh 'openssl sha256 -r -out docker-compose-Linux-x86_64.sha256 docker-compose-Linux-x86_64' + } archiveArtifacts artifacts: 'dist/*', fingerprint: true dir("dist") { stash name: "bin-linux" @@ -97,7 +101,9 @@ pipeline { checkout scm bat 'tox.exe -e py27,py37 -- tests/unit' powershell '.\\script\\build\\windows.ps1' - checksum("dist/docker-compose-Windows-x86_64.exe") + dir ('dist') { + sh 'openssl sha256 -r -out docker-compose-Windows-x86_64.exe.sha256 docker-compose-Windows-x86_64.exe' + } archiveArtifacts artifacts: 'dist/*', fingerprint: true dir("dist") { stash name: "bin-win" @@ -245,7 +251,8 @@ def buildRuntimeImage(baseImage) { ansiColor('xterm') { sh """docker build -t ${imageName} \\ --build-arg BUILD_PLATFORM="${baseImage}" \\ - --build-arg GIT_COMMIT="${scmvar.GIT_COMMIT.take(7)}" + --build-arg GIT_COMMIT="${scmvar.GIT_COMMIT.take(7)}" \\ + ." """ } sh "mkdir -p dist" From 9c6db546e8a52970e7ee9e4f5d3971a91c67fd33 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Fri, 6 Dec 2019 09:47:30 +0100 Subject: [PATCH 3832/4072] Remove Circle-CI and AppVeyor config files Signed-off-by: Nicolas De Loof --- .circleci/config.yml | 66 -------------------------------------------- Release.Jenkinsfile | 2 +- appveyor.yml | 24 ---------------- 3 files changed, 1 insertion(+), 91 deletions(-) delete mode 100644 .circleci/config.yml delete mode 100644 appveyor.yml diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index 36dd8d57e0e..00000000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,66 +0,0 @@ -version: 2 -jobs: - test: - macos: - xcode: "9.4.1" - steps: - - checkout - - run: - name: setup script - command: ./script/setup/osx - - run: - name: install tox - command: sudo pip install --upgrade tox==2.1.1 virtualenv==16.2.0 - - run: - name: unit tests - command: tox -e py27,py37 -- tests/unit - - build-osx-binary: - macos: - xcode: "9.4.1" - steps: - - checkout - - run: - name: upgrade python tools - command: sudo pip install --upgrade pip virtualenv==16.2.0 - - run: - name: setup script - command: DEPLOYMENT_TARGET=10.11 ./script/setup/osx - - run: - name: build script - command: ./script/build/osx - - store_artifacts: - path: dist/docker-compose-Darwin-x86_64 - destination: docker-compose-Darwin-x86_64 - - store_artifacts: - path: dist/docker-compose-Darwin-x86_64.tgz - destination: docker-compose-Darwin-x86_64.tgz - - deploy: - name: Deploy binary to bintray - command: | - OS_NAME=Darwin PKG_NAME=osx ./script/circle/bintray-deploy.sh - - build-linux-binary: - machine: - enabled: true - steps: - - checkout - - run: - name: build Linux binary - command: ./script/build/linux - - store_artifacts: - path: dist/docker-compose-Linux-x86_64 - destination: docker-compose-Linux-x86_64 - - deploy: - name: Deploy binary to bintray - command: | - OS_NAME=Linux PKG_NAME=linux ./script/circle/bintray-deploy.sh - - -workflows: - version: 2 - all: - jobs: - - test - - build-linux-binary - - build-osx-binary diff --git a/Release.Jenkinsfile b/Release.Jenkinsfile index 8958ada61de..76a990e0d2a 100644 --- a/Release.Jenkinsfile +++ b/Release.Jenkinsfile @@ -252,7 +252,7 @@ def buildRuntimeImage(baseImage) { sh """docker build -t ${imageName} \\ --build-arg BUILD_PLATFORM="${baseImage}" \\ --build-arg GIT_COMMIT="${scmvar.GIT_COMMIT.take(7)}" \\ - ." + . """ } sh "mkdir -p dist" diff --git a/appveyor.yml b/appveyor.yml deleted file mode 100644 index 04a40e9c2c4..00000000000 --- a/appveyor.yml +++ /dev/null @@ -1,24 +0,0 @@ - -version: '{branch}-{build}' - -install: - - "SET PATH=C:\\Python37-x64;C:\\Python37-x64\\Scripts;%PATH%" - - "python --version" - - "pip install tox==2.9.1 virtualenv==16.2.0" - -# Build the binary after tests -build: false - -test_script: - - "tox -e py27,py37 -- tests/unit" - - ps: ".\\script\\build\\windows.ps1" - -artifacts: - - path: .\dist\docker-compose-Windows-x86_64.exe - name: "Compose Windows binary" - -deploy: - - provider: Environment - name: master-builds - on: - branch: master From 1af385227722bfd32cb0456bf6edc3f708928504 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Fri, 6 Dec 2019 18:00:33 +0100 Subject: [PATCH 3833/4072] Generate changelog Signed-off-by: Nicolas De Loof --- Release.Jenkinsfile | 41 +++++++++++++++++++++++++++- script/release/generate_changelog.sh | 39 ++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) create mode 100755 script/release/generate_changelog.sh diff --git a/Release.Jenkinsfile b/Release.Jenkinsfile index 76a990e0d2a..aa205c6c857 100644 --- a/Release.Jenkinsfile +++ b/Release.Jenkinsfile @@ -53,6 +53,19 @@ pipeline { } } } + stage('Generate Changelog') { + agent { + label 'linux' + } + steps { + checkout scm + withCredentials([string(credentialsId: 'github-compose-release-test-token', variable: 'GITHUB_TOKEN')]) { + sh "./script/release/generate_changelog.sh" + } + archiveArtifacts artifacts: 'CHANGELOG.md' + stash( name: "changelog", includes: 'CHANGELOG.md' ) + } + } stage('Package') { parallel { stage('macosx binary') { @@ -153,7 +166,7 @@ pipeline { unstash "bin-darwin" unstash "bin-linux" unstash "bin-win" - githubRelease("docker/compose") + githubRelease() } } } @@ -272,3 +285,29 @@ def pushRuntimeImage(baseImage) { } } } + +def githubRelease() { + withCredentials([string(credentialsId: 'github-compose-release-test-token', variable: 'GITHUB_TOKEN')]) { + def prerelease = !( env.TAG_NAME ==~ /v[0-9\.]+/ ) + def data = """{ + \"tag_name\": \"${env.TAG_NAME}\", + \"name\": \"${env.TAG_NAME}\", + \"draft\": true, + \"prerelease\": ${prerelease}, + \"body\" : \"${changelog}\" + """ + echo $data + + def url = "https://api.github.com/repos/docker/compose/releases" + def upload_url = sh(returnStdout: true, script: """ + curl -sSf -H 'Authorization: token ${GITHUB_TOKEN}' -H 'Accept: application/json' -H 'Content-type: application/json' -X POST -d '$data' $url") \\ + | jq '.upload_url | .[:rindex("{")]' + """) + sh(""" + for f in * ; do + curl -sf -H 'Authorization: token ${GITHUB_TOKEN}' -H 'Accept: application/json' -H 'Content-type: application/octet-stream' \\ + -X POST --data-binary @\$f ${upload_url}?name=\$f; + done + """) + } +} diff --git a/script/release/generate_changelog.sh b/script/release/generate_changelog.sh new file mode 100755 index 00000000000..783e744007c --- /dev/null +++ b/script/release/generate_changelog.sh @@ -0,0 +1,39 @@ +#!/bin/bash + +set -e +set -x + +## Usage : +## changelog PREVIOUS_TAG..HEAD + +# configure refs so we get pull-requests metadata +git config --add remote.origin.fetch +refs/pull/*/head:refs/remotes/origin/pull/* +git fetch origin + +RANGE=${1:-"$(git describe --tags --abbrev=0)..HEAD"} +echo "Generate changelog for range ${RANGE}" +echo + +pullrequests() { + for commit in $(git log ${RANGE} --format='format:%H'); do + # Get the oldest remotes/origin/pull/* branch to include this commit, i.e. the one to introduce it + git branch -a --sort=committerdate --contains $commit --list 'origin/pull/*' | head -1 | cut -d'/' -f4 + done +} + +changes=$(pullrequests | uniq) + +echo "pull requests merged within range:" +echo $changes + +echo '#Features' > CHANGELOG.md +for pr in $changes; do + curl -fs -H "Authorization: token ${GITHUB_TOKEN}" https://api.github.com/repos/docker/compose/pulls/${pr} \ + | jq -r ' select( .labels[].name | contains("kind/feature") ) | "* "+.title' >> CHANGELOG.md +done + +echo '#Bugs' >> CHANGELOG.md +for pr in $changes; do + curl -fs -H "Authorization: token ${GITHUB_TOKEN}" https://api.github.com/repos/docker/compose/pulls/${pr} \ + | jq -r ' select( .labels[].name | contains("kind/bug") ) | "* "+.title' >> CHANGELOG.md +done From 0e826efee5b4b14f28a19666073b22b5837e622e Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Sat, 7 Dec 2019 14:01:07 +0100 Subject: [PATCH 3834/4072] attempt to fix windows build Signed-off-by: Nicolas De Loof --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index 1627cca9ef8..fccd7c4cede 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,6 +20,7 @@ PySocks==1.6.7 PyYAML==4.2b1 requests==2.22.0 six==1.12.0 +subprocess32==3.5.4; python_version < '3.2' texttable==1.6.2 urllib3==1.24.2; python_version == '3.3' websocket-client==0.32.0 From d6c13b69c32173ac264a6ace6454058ab9132fe9 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Mon, 9 Dec 2019 08:22:04 +0100 Subject: [PATCH 3835/4072] compute sha256sum windows nodes don't have openssl installed:'( Signed-off-by: Nicolas De Loof --- Release.Jenkinsfile | 34 ++++++++++++++-------------------- 1 file changed, 14 insertions(+), 20 deletions(-) diff --git a/Release.Jenkinsfile b/Release.Jenkinsfile index aa205c6c857..def79907244 100644 --- a/Release.Jenkinsfile +++ b/Release.Jenkinsfile @@ -78,8 +78,8 @@ pipeline { sh 'tox -e py27,py37 -- tests/unit' sh './script/build/osx' dir ('dist') { - sh 'openssl sha256 -r -out docker-compose-Darwin-x86_64.sha256 docker-compose-Darwin-x86_64' - sh 'openssl sha256 -r -out docker-compose-Darwin-x86_64.tgz.sha256 docker-compose-Darwin-x86_64.tgz' + checksum('docker-compose-Darwin-x86_64') + checksum('docker-compose-Darwin-x86_64.tgz') } archiveArtifacts artifacts: 'dist/*', fingerprint: true dir("dist") { @@ -95,7 +95,7 @@ pipeline { checkout scm sh ' ./script/build/linux' dir ('dist') { - sh 'openssl sha256 -r -out docker-compose-Linux-x86_64.sha256 docker-compose-Linux-x86_64' + checksum('docker-compose-Linux-x86_64') } archiveArtifacts artifacts: 'dist/*', fingerprint: true dir("dist") { @@ -115,7 +115,7 @@ pipeline { bat 'tox.exe -e py27,py37 -- tests/unit' powershell '.\\script\\build\\windows.ps1' dir ('dist') { - sh 'openssl sha256 -r -out docker-compose-Windows-x86_64.exe.sha256 docker-compose-Windows-x86_64.exe' + checksum('docker-compose-Windows-x86_64.exe') } archiveArtifacts artifacts: 'dist/*', fingerprint: true dir("dist") { @@ -166,6 +166,7 @@ pipeline { unstash "bin-darwin" unstash "bin-linux" unstash "bin-win" + unstash "changelog" githubRelease() } } @@ -182,22 +183,6 @@ pipeline { archiveArtifacts artifacts: 'dist/*', fingerprint: true } } - stage('Publishing binaries to Bintray') { - agent { - label 'linux' - } - steps { - checkout scm - dir("dist") { - unstash "bin-darwin" - unstash "bin-linux" - unstash "bin-win" - } - withCredentials([usernamePassword(credentialsId: 'bintray-docker-dsg-cibot', usernameVariable: 'BINTRAY_USER', passwordVariable: 'BINTRAY_TOKEN')]) { - sh './script/release/push-binaries' - } - } - } } } } @@ -289,6 +274,7 @@ def pushRuntimeImage(baseImage) { def githubRelease() { withCredentials([string(credentialsId: 'github-compose-release-test-token', variable: 'GITHUB_TOKEN')]) { def prerelease = !( env.TAG_NAME ==~ /v[0-9\.]+/ ) + changelog = readFile "CHANGELOG.md" def data = """{ \"tag_name\": \"${env.TAG_NAME}\", \"name\": \"${env.TAG_NAME}\", @@ -311,3 +297,11 @@ def githubRelease() { """) } } + +def checksum(filepath) { + if (isUnix()) { + sh "openssl sha256 -r -out ${filepath}.sha256 ${filepath}" + } else { + powershell "(Get-FileHash -Path ${filepath} -Algorithm SHA256 | % hash) + ' *${filepath}' > ${filepath}.sha256" + } +} From 31396786baf768acad4ec121976c31b2d6775885 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Mon, 9 Dec 2019 16:41:05 +0100 Subject: [PATCH 3836/4072] publish package on PyPI Signed-off-by: Nicolas De Loof --- Release.Jenkinsfile | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/Release.Jenkinsfile b/Release.Jenkinsfile index def79907244..c4d5ad1a515 100644 --- a/Release.Jenkinsfile +++ b/Release.Jenkinsfile @@ -178,9 +178,17 @@ pipeline { steps { checkout scm withCredentials([[$class: "FileBinding", credentialsId: 'pypirc-docker-dsg-cibot', variable: 'PYPIRC']]) { - sh './script/release/python-package' + sh """ + virtualenv venv-publish + source venv-publish/bin/activate + python setup.py sdist bdist_wheel + pip install twine + twine upload --config-file ${PYPIRC} ./dist/docker-compose-${env.TAG_NAME}.tar.gz ./dist/docker_compose-${env.TAG_NAME}-py2.py3-none-any.whl + """ } - archiveArtifacts artifacts: 'dist/*', fingerprint: true + } + post { + sh 'deactivate; rm -rf venv-publish' } } } From 75c45c27dfc2429cf985855a7bd1e0cd2f195480 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Wed, 8 Jan 2020 09:17:04 +0000 Subject: [PATCH 3837/4072] Bump pysocks from 1.6.7 to 1.7.1 Bumps [pysocks](https://github.com/Anorov/PySocks) from 1.6.7 to 1.7.1. - [Release notes](https://github.com/Anorov/PySocks/releases) - [Changelog](https://github.com/Anorov/PySocks/blob/master/CHANGELOG.md) - [Commits](https://github.com/Anorov/PySocks/commits) Signed-off-by: dependabot-preview[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index fccd7c4cede..161404e525f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,7 +16,7 @@ jsonschema==3.0.1 paramiko==2.6.0 pypiwin32==219; sys_platform == 'win32' and python_version < '3.6' pypiwin32==223; sys_platform == 'win32' and python_version >= '3.6' -PySocks==1.6.7 +PySocks==1.7.1 PyYAML==4.2b1 requests==2.22.0 six==1.12.0 From f2f6b3035099d7f42a799f11f1032df822594503 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Wed, 8 Jan 2020 09:17:12 +0000 Subject: [PATCH 3838/4072] Bump websocket-client from 0.32.0 to 0.57.0 Bumps [websocket-client](https://github.com/websocket-client/websocket-client) from 0.32.0 to 0.57.0. - [Release notes](https://github.com/websocket-client/websocket-client/releases) - [Changelog](https://github.com/websocket-client/websocket-client/blob/master/ChangeLog) - [Commits](https://github.com/websocket-client/websocket-client/compare/v0.32.0...v0.57.0) Signed-off-by: dependabot-preview[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index fccd7c4cede..0d3bdc98fdc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -23,4 +23,4 @@ six==1.12.0 subprocess32==3.5.4; python_version < '3.2' texttable==1.6.2 urllib3==1.24.2; python_version == '3.3' -websocket-client==0.32.0 +websocket-client==0.57.0 From 81e3566ebd71ea06497c2f2882d503c852516469 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Wed, 8 Jan 2020 14:42:12 +0000 Subject: [PATCH 3839/4072] Bump urllib3 from 1.24.2 to 1.25.7 Bumps [urllib3](https://github.com/urllib3/urllib3) from 1.24.2 to 1.25.7. - [Release notes](https://github.com/urllib3/urllib3/releases) - [Changelog](https://github.com/urllib3/urllib3/blob/master/CHANGES.rst) - [Commits](https://github.com/urllib3/urllib3/compare/1.24.2...1.25.7) Signed-off-by: dependabot-preview[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 0d3bdc98fdc..cffa88958b2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,5 +22,5 @@ requests==2.22.0 six==1.12.0 subprocess32==3.5.4; python_version < '3.2' texttable==1.6.2 -urllib3==1.24.2; python_version == '3.3' +urllib3==1.25.7; python_version == '3.3' websocket-client==0.57.0 From 4ace98acbe4fe98fa9190b445a02c371f3498675 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Wed, 8 Jan 2020 15:01:58 +0000 Subject: [PATCH 3840/4072] Bump jsonschema from 3.0.1 to 3.2.0 Bumps [jsonschema](https://github.com/Julian/jsonschema) from 3.0.1 to 3.2.0. - [Release notes](https://github.com/Julian/jsonschema/releases) - [Changelog](https://github.com/Julian/jsonschema/blob/master/CHANGELOG.rst) - [Commits](https://github.com/Julian/jsonschema/compare/v3.0.1...v3.2.0) Signed-off-by: dependabot-preview[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 128b18cf619..9260d29a003 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ enum34==1.1.6; python_version < '3.4' functools32==3.2.3.post2; python_version < '3.2' idna==2.8 ipaddress==1.0.18 -jsonschema==3.0.1 +jsonschema==3.2.0 paramiko==2.6.0 pypiwin32==219; sys_platform == 'win32' and python_version < '3.6' pypiwin32==223; sys_platform == 'win32' and python_version >= '3.6' From 33eeef41abc6c463fa494db6766395a80f37d9d9 Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Fri, 6 Dec 2019 16:49:45 +0100 Subject: [PATCH 3841/4072] Remove "bundle" subcommand and support for DAB files Deploying stacks using the "Docker Application Bundle" (`.dab`) file format was introduced as an experimental feature in Docker 1.13 / 17.03, but superseded by support for Docker Compose files in the CLI. With no development being done on this feature, and no active use of the file format, support for the DAB file format and the top-level `docker deploy` command (hidden by default in 19.03), will be removed from the CLI, in favour of `docker stack deploy` using compose files. This patch removes the `docker-compose bundle` subcommand from Docker Compose, which was used to convert compose files into DAB files (and given the above, will no longer be needed). Signed-off-by: Sebastiaan van Stijn --- compose/bundle.py | 275 ------------------ compose/cli/main.py | 49 +--- compose/project.py | 89 +++++- contrib/completion/bash/docker-compose | 13 - contrib/completion/zsh/_docker-compose | 6 - tests/acceptance/cli_test.py | 26 -- .../bundle-with-digests/docker-compose.yml | 9 - tests/unit/bundle_test.py | 233 --------------- 8 files changed, 95 insertions(+), 605 deletions(-) delete mode 100644 compose/bundle.py delete mode 100644 tests/fixtures/bundle-with-digests/docker-compose.yml delete mode 100644 tests/unit/bundle_test.py diff --git a/compose/bundle.py b/compose/bundle.py deleted file mode 100644 index 77cb37aa97b..00000000000 --- a/compose/bundle.py +++ /dev/null @@ -1,275 +0,0 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - -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 - - -log = logging.getLogger(__name__) - - -SERVICE_KEYS = { - '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' - - -class NeedsPush(Exception): - def __init__(self, image_name): - self.image_name = image_name - - -class NeedsPull(Exception): - def __init__(self, image_name, service_name): - self.image_name = image_name - self.service_name = service_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): - return json.dumps(to_bundle(config, image_digests), indent=2, sort_keys=True) - - -def get_image_digests(project, allow_push=False): - digests = {} - needs_push = set() - needs_pull = set() - - for service in project.services: - try: - digests[service.name] = get_image_digest( - service, - allow_push=allow_push, - ) - except NeedsPush as e: - needs_push.add(e.image_name) - except NeedsPull as e: - needs_pull.add(e.service_name) - - if needs_push or needs_pull: - raise MissingDigests(needs_push, needs_pull) - - return digests - - -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 " - "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']) - # Compose file already uses a digest, no lookup required - if separator == '@': - return service.options['image'] - - digest = get_digest(service) - - if digest: - return digest - - if 'build' not in service.options: - raise NeedsPull(service.image_name, service.name) - - if not allow_push: - raise NeedsPush(service.image_name) - - return push_image(service) - - -def get_digest(service): - digest = None - try: - image = service.image() - # TODO: pick a digest based on the image tag if there are multiple - # digests - if image['RepoDigests']: - digest = image['RepoDigests'][0] - except NoSuchImageError: - try: - # Fetch the image digest from the registry - distribution = service.get_image_registry_data() - - if distribution['Descriptor']['digest']: - digest = '{image_name}@{digest}'.format( - image_name=service.image_name, - digest=distribution['Descriptor']['digest'] - ) - except NoSuchImageError: - raise UserError( - "Digest not found for service '{service}'. " - "Repository does not exist or may require 'docker login'" - .format(service=service.name)) - return digest - - -def push_image(service): - try: - digest = service.push() - except Exception: - 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) - - repo, _, _ = parse_repository_tag(service.options['image']) - identifier = '{repo}@{digest}'.format(repo=repo, digest=digest) - - # 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 - # 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.warning("Unsupported top level key 'networks' - ignoring") - - if config.volumes: - log.warning("Unsupported top level key 'volumes' - ignoring") - - 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_digest): - container_config = {'Image': image_digest} - - for key, value in service_dict.items(): - if key in IGNORED_KEYS: - continue - - if key not in SUPPORTED_KEYS: - log.warning("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 - }) - 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) - if ports: - container_config['Ports'] = ports - - 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 = [] - - for network_name, network_def in get_network_defs_for_service(service_dict).items(): - for key in network_def.keys(): - log.warning( - "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/main.py b/compose/cli/main.py index fde4fd0359b..200d4eeaca9 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -15,14 +15,12 @@ from inspect import getdoc from operator import attrgetter -import docker +import docker.errors +import docker.utils 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 from ..config import parse_labels @@ -34,6 +32,8 @@ from ..const import IS_WINDOWS_PLATFORM from ..errors import StreamParseError from ..progress_stream import StreamOutputError +from ..project import get_image_digests +from ..project import MissingDigests from ..project import NoSuchService from ..project import OneOffFilter from ..project import ProjectError @@ -213,7 +213,6 @@ 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 @@ -304,38 +303,6 @@ def build(self, options): progress=options.get('--progress'), ) - def bundle(self, options): - """ - 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. - - 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". - """ - compose_config = get_config_from_options('.', self.toplevel_options) - - output = options["--output"] - if not output: - output = "{}.dab".format(self.project.name) - - image_digests = image_digests_for_project(self.project, options['--push-images']) - - with open(output, 'w') as f: - f.write(serialize_bundle(compose_config, image_digests)) - - log.info("Wrote bundle to {}".format(output)) - def config(self, options): """ Validate and view the Compose file. @@ -1216,12 +1183,10 @@ def timeout_from_opts(options): return None if timeout is None else int(timeout) -def image_digests_for_project(project, allow_push=False): +def image_digests_for_project(project): try: - return get_image_digests( - project, - allow_push=allow_push - ) + return get_image_digests(project) + except MissingDigests as e: def list_images(images): return "\n".join(" {}".format(name) for name in sorted(images)) diff --git a/compose/project.py b/compose/project.py index d7405defdac..a7770ddc9db 100644 --- a/compose/project.py +++ b/compose/project.py @@ -16,6 +16,7 @@ from docker.utils import version_lt from . import parallel +from .cli.errors import UserError from .config import ConfigurationError from .config.config import V1 from .config.sort_services import get_container_name_from_network_mode @@ -33,6 +34,7 @@ from .service import ContainerPidMode from .service import ConvergenceStrategy from .service import NetworkMode +from .service import NoSuchImageError from .service import parse_repository_tag from .service import PidMode from .service import Service @@ -42,7 +44,6 @@ from .utils import truncate_string from .volume import ProjectVolumes - log = logging.getLogger(__name__) @@ -381,6 +382,7 @@ def build(self, service_names=None, no_cache=False, pull=False, force_rm=False, def build_service(service): service.build(no_cache, pull, force_rm, memory, build_args, gzip, rm, silent, cli, progress) + if parallel_build: _, errors = parallel.parallel_execute( services, @@ -844,6 +846,91 @@ def get_secrets(service, service_secrets, secret_defs): return secrets +def get_image_digests(project): + digests = {} + needs_push = set() + needs_pull = set() + + for service in project.services: + try: + digests[service.name] = get_image_digest(service) + except NeedsPush as e: + needs_push.add(e.image_name) + except NeedsPull as e: + needs_pull.add(e.service_name) + + if needs_push or needs_pull: + raise MissingDigests(needs_push, needs_pull) + + return digests + + +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. Specify an image repo " + "and tag with the 'image' option.".format(s=service)) + + _, _, separator = parse_repository_tag(service.options['image']) + # Compose file already uses a digest, no lookup required + if separator == '@': + return service.options['image'] + + digest = get_digest(service) + + if digest: + return digest + + if 'build' not in service.options: + raise NeedsPull(service.image_name, service.name) + + raise NeedsPush(service.image_name) + + +def get_digest(service): + digest = None + try: + image = service.image() + # TODO: pick a digest based on the image tag if there are multiple + # digests + if image['RepoDigests']: + digest = image['RepoDigests'][0] + except NoSuchImageError: + try: + # Fetch the image digest from the registry + distribution = service.get_image_registry_data() + + if distribution['Descriptor']['digest']: + digest = '{image_name}@{digest}'.format( + image_name=service.image_name, + digest=distribution['Descriptor']['digest'] + ) + except NoSuchImageError: + raise UserError( + "Digest not found for service '{service}'. " + "Repository does not exist or may require 'docker login'" + .format(service=service.name)) + return digest + + +class MissingDigests(Exception): + def __init__(self, needs_push, needs_pull): + self.needs_push = needs_push + self.needs_pull = needs_pull + + +class NeedsPush(Exception): + def __init__(self, image_name): + self.image_name = image_name + + +class NeedsPull(Exception): + def __init__(self, image_name, service_name): + self.image_name = image_name + self.service_name = service_name + + class NoSuchService(Exception): def __init__(self, name): if isinstance(name, six.binary_type): diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index 6dc47799db5..23c48b7f4a2 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -126,18 +126,6 @@ _docker_compose_build() { } -_docker_compose_bundle() { - case "$prev" in - --output|-o) - _filedir - return - ;; - esac - - COMPREPLY=( $( compgen -W "--push-images --help --output -o" -- "$cur" ) ) -} - - _docker_compose_config() { case "$prev" in --hash) @@ -581,7 +569,6 @@ _docker_compose() { local commands=( build - bundle config create down diff --git a/contrib/completion/zsh/_docker-compose b/contrib/completion/zsh/_docker-compose index faf4059888f..277bf0d3c22 100755 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -121,12 +121,6 @@ __docker-compose_subcommand() { '--parallel[Build images in parallel.]' \ '*:services:__docker-compose_services_from_build' && ret=0 ;; - (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) _arguments \ $opts_help \ diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index b729e7d76d4..ffa05574449 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -855,32 +855,6 @@ def test_build_with_buildarg_old_api_version(self): ) assert 'Favorite Touhou Character: hong.meiling' in result.stdout - def test_bundle_with_digests(self): - self.base_dir = 'tests/fixtures/bundle-with-digests/' - tmpdir = pytest.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_build_override_dir(self): self.base_dir = 'tests/fixtures/build-path-override-dir' self.override_dir = os.path.abspath('tests/fixtures') diff --git a/tests/fixtures/bundle-with-digests/docker-compose.yml b/tests/fixtures/bundle-with-digests/docker-compose.yml deleted file mode 100644 index b701351209e..00000000000 --- a/tests/fixtures/bundle-with-digests/docker-compose.yml +++ /dev/null @@ -1,9 +0,0 @@ - -version: '2.0' - -services: - web: - image: dockercloud/hello-world@sha256:fe79a2cfbd17eefc344fb8419420808df95a1e22d93b7f621a7399fd1e9dca1d - - redis: - image: redis@sha256:a84cb8f53a70e19f61ff2e1d5e73fb7ae62d374b2b7392de1e7d77be26ef8f7b diff --git a/tests/unit/bundle_test.py b/tests/unit/bundle_test.py deleted file mode 100644 index 8faebb7f1fc..00000000000 --- a/tests/unit/bundle_test.py +++ /dev/null @@ -1,233 +0,0 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - -import docker -import pytest - -from .. import mock -from compose import bundle -from compose import service -from compose.cli.errors import UserError -from compose.config.config import Config -from compose.const import COMPOSEFILE_V2_0 as V2_0 -from compose.service import NoSuchImageError - - -@pytest.fixture -def mock_service(): - return mock.create_autospec( - service.Service, - client=mock.create_autospec(docker.APIClient), - 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_from_repository(mock_service): - mock_service.options['image'] = 'abcd' - mock_service.image_name = 'abcd' - mock_service.image.side_effect = NoSuchImageError(None) - mock_service.get_image_registry_data.return_value = {'Descriptor': {'digest': 'digest'}} - - digest = bundle.get_image_digest(mock_service) - assert digest == 'abcd@digest' - - -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_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.push_image(mock_service) - assert digest == image_id + '@' + expected - - mock_service.push.assert_called_once_with() - 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(): - image_digests = {'a': 'aaaa', 'b': 'bbbb'} - services = [ - {'name': 'a', 'build': '.', }, - {'name': 'b', 'build': './b'}, - ] - config = Config( - version=V2_0, - services=services, - volumes={'special': {}}, - networks={'extra': {}}, - secrets={}, - configs={} - ) - - with mock.patch('compose.bundle.log.warning', 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.warning', 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.warning', 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.warning', 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} From fb14f41ddb1479d9990e147c9d18bf047928164d Mon Sep 17 00:00:00 2001 From: Lumir Balhar Date: Tue, 27 Aug 2019 12:24:05 +0200 Subject: [PATCH 3842/4072] Move to the latest pytest versions for Python 2 and 3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Lumír Balhar --- requirements-dev.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index e40cbc43ca6..496cefe1584 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -2,5 +2,6 @@ coverage==4.5.4 ddt==1.2.2 flake8==3.7.9 mock==3.0.5 -pytest==3.6.3 +pytest==5.1.1; python_version >= '3.5' +pytest==4.6.5; python_version < '3.5' pytest-cov==2.8.1 From 60458c8ae7c3d99a4dd408bc7fbced3b4a8cd7de Mon Sep 17 00:00:00 2001 From: Lumir Balhar Date: Tue, 27 Aug 2019 12:25:20 +0200 Subject: [PATCH 3843/4072] Implement custom context manager for changing CWD MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Lumír Balhar --- tests/helpers.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/helpers.py b/tests/helpers.py index 327715ee2e8..1365c5bcfb3 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -1,6 +1,7 @@ from __future__ import absolute_import from __future__ import unicode_literals +import contextlib import os from compose.config.config import ConfigDetails @@ -55,3 +56,17 @@ def create_host_file(client, filename): content = fh.read() return create_custom_host_file(client, filename, content) + + +@contextlib.contextmanager +def cd(path): + """ + A context manager which changes the working directory to the given + path, and then changes it back to its previous value on exit. + """ + prev_cwd = os.getcwd() + os.chdir(path) + try: + yield + finally: + os.chdir(prev_cwd) From 73cc89c15ffb808047b1562e35a4c61c86c9a572 Mon Sep 17 00:00:00 2001 From: Lumir Balhar Date: Tue, 27 Aug 2019 12:26:01 +0200 Subject: [PATCH 3844/4072] Use stdlib modules instead of deprecated pytest fixtures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Lumír Balhar --- tests/integration/project_test.py | 8 +- tests/integration/state_test.py | 29 ++- tests/unit/config/config_test.py | 323 ++++++++++++++------------ tests/unit/config/environment_test.py | 15 +- 4 files changed, 199 insertions(+), 176 deletions(-) diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 4c88f3d6b8a..cb620a8c972 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -8,7 +8,6 @@ import shutil import tempfile -import py import pytest from docker.errors import APIError from docker.errors import NotFound @@ -16,6 +15,7 @@ from .. import mock from ..helpers import build_config as load_config from ..helpers import BUSYBOX_IMAGE_WITH_TAG +from ..helpers import cd from ..helpers import create_host_file from .testcases import DockerClientTestCase from .testcases import SWARM_SKIP_CONTAINERS_ALL @@ -1329,9 +1329,9 @@ def test_project_up_logging_with_multiple_files(self): }) details = config.ConfigDetails('.', [base_file, override_file]) - tmpdir = py.test.ensuretemp('logging_test') - self.addCleanup(tmpdir.remove) - with tmpdir.as_cwd(): + tmpdir = tempfile.mkdtemp('logging_test') + self.addCleanup(shutil.rmtree, tmpdir) + with cd(tmpdir): config_data = config.load(details) project = Project.from_config( name='composetest', config_data=config_data, client=self.client diff --git a/tests/integration/state_test.py b/tests/integration/state_test.py index 714945ee52f..492de7b8ab1 100644 --- a/tests/integration/state_test.py +++ b/tests/integration/state_test.py @@ -6,8 +6,10 @@ from __future__ import unicode_literals import copy +import os +import shutil +import tempfile -import py from docker.errors import ImageNotFound from ..helpers import BUSYBOX_IMAGE_WITH_TAG @@ -426,29 +428,32 @@ def safe_remove_image(image): @no_cluster('Can not guarantee the build will be run on the same node the service is deployed') def test_trigger_recreate_with_build(self): - context = py.test.ensuretemp('test_trigger_recreate_with_build') - self.addCleanup(context.remove) + context = tempfile.mkdtemp('test_trigger_recreate_with_build') + self.addCleanup(shutil.rmtree, context) base_image = "FROM busybox\nLABEL com.docker.compose.test_image=true\n" - dockerfile = context.join('Dockerfile') - dockerfile.write(base_image) + dockerfile = os.path.join(context, 'Dockerfile') + with open(dockerfile, mode="w") as dockerfile_fh: + dockerfile_fh.write(base_image) web = self.create_service('web', build={'context': str(context)}) container = web.create_container() - dockerfile.write(base_image + 'CMD echo hello world\n') + with open(dockerfile, mode="w") as dockerfile_fh: + dockerfile_fh.write(base_image + 'CMD echo hello world\n') web.build() web = self.create_service('web', build={'context': str(context)}) assert ('recreate', [container]) == web.convergence_plan() def test_image_changed_to_build(self): - context = py.test.ensuretemp('test_image_changed_to_build') - self.addCleanup(context.remove) - context.join('Dockerfile').write(""" - FROM busybox - LABEL com.docker.compose.test_image=true - """) + context = tempfile.mkdtemp('test_image_changed_to_build') + self.addCleanup(shutil.rmtree, context) + with open(os.path.join(context, 'Dockerfile'), mode="w") as dockerfile: + dockerfile.write(""" + FROM busybox + LABEL com.docker.compose.test_image=true + """) web = self.create_service('web', image='busybox') container = web.create_container() diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 0f744e22afe..f1398e84fa4 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -10,7 +10,6 @@ from operator import itemgetter from random import shuffle -import py import pytest import yaml from ddt import data @@ -18,6 +17,7 @@ from ...helpers import build_config_details from ...helpers import BUSYBOX_IMAGE_WITH_TAG +from ...helpers import cd from compose.config import config from compose.config import types from compose.config.config import ConfigFile @@ -780,13 +780,14 @@ def test_load_with_multiple_files_and_extends_in_override_file(self): }) details = config.ConfigDetails('.', [base_file, override_file]) - tmpdir = py.test.ensuretemp('config_test') - self.addCleanup(tmpdir.remove) - tmpdir.join('common.yml').write(""" - base: - labels: ['label=one'] - """) - with tmpdir.as_cwd(): + tmpdir = tempfile.mkdtemp('config_test') + self.addCleanup(shutil.rmtree, tmpdir) + with open(os.path.join(tmpdir, 'common.yml'), mode="w") as common_fh: + common_fh.write(""" + base: + labels: ['label=one'] + """) + with cd(tmpdir): service_dicts = config.load(details).services expected = [ @@ -815,19 +816,20 @@ def test_load_mixed_extends_resolution(self): } ) - tmpdir = pytest.ensuretemp('config_test') - self.addCleanup(tmpdir.remove) - tmpdir.join('base.yml').write(""" - version: '2.2' - services: - base: - image: base - web: - extends: base - """) + tmpdir = tempfile.mkdtemp('config_test') + self.addCleanup(shutil.rmtree, tmpdir) + with open(os.path.join(tmpdir, 'base.yml'), mode="w") as base_fh: + base_fh.write(""" + version: '2.2' + services: + base: + image: base + web: + extends: base + """) details = config.ConfigDetails('.', [main_file]) - with tmpdir.as_cwd(): + with cd(tmpdir): service_dicts = config.load(details).services assert service_dicts[0] == { 'name': 'prodweb', @@ -1765,22 +1767,23 @@ def test_config_valid_environment_dict_key_contains_dashes(self): assert services[0]['environment']['SPRING_JPA_HIBERNATE_DDL-AUTO'] == 'none' def test_load_yaml_with_yaml_error(self): - tmpdir = py.test.ensuretemp('invalid_yaml_test') - self.addCleanup(tmpdir.remove) - invalid_yaml_file = tmpdir.join('docker-compose.yml') - invalid_yaml_file.write(""" - web: - this is bogus: ok: what - """) + tmpdir = tempfile.mkdtemp('invalid_yaml_test') + self.addCleanup(shutil.rmtree, tmpdir) + invalid_yaml_file = os.path.join(tmpdir, 'docker-compose.yml') + with open(invalid_yaml_file, mode="w") as invalid_yaml_file_fh: + invalid_yaml_file_fh.write(""" + web: + this is bogus: ok: what + """) with pytest.raises(ConfigurationError) as exc: config.load_yaml(str(invalid_yaml_file)) - assert 'line 3, column 32' in exc.exconly() + assert 'line 3, column 36' in exc.exconly() def test_load_yaml_with_bom(self): - tmpdir = py.test.ensuretemp('bom_yaml') - self.addCleanup(tmpdir.remove) - bom_yaml = tmpdir.join('docker-compose.yml') + tmpdir = tempfile.mkdtemp('bom_yaml') + self.addCleanup(shutil.rmtree, tmpdir) + bom_yaml = os.path.join(tmpdir, 'docker-compose.yml') with codecs.open(str(bom_yaml), 'w', encoding='utf-8') as f: f.write('''\ufeff version: '2.3' @@ -4724,43 +4727,48 @@ def test_extended_service_with_verbose_and_shorthand_way(self): @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) - commondir = tmpdir.mkdir('common') - commondir.join('base.yml').write(""" - app: - image: 'example/app' - env_file: - - 'envs' - environment: - - SECRET - - TEST_ONE=common - - TEST_TWO=common - """) - tmpdir.join('docker-compose.yml').write(""" - ext: - extends: - file: common/base.yml - service: app - env_file: - - 'envs' - environment: - - THING - - TEST_ONE=top - """) - commondir.join('envs').write(""" - COMMON_ENV_FILE - TEST_ONE=common-env-file - TEST_TWO=common-env-file - TEST_THREE=common-env-file - TEST_FOUR=common-env-file - """) - tmpdir.join('envs').write(""" - TOP_ENV_FILE - TEST_ONE=top-env-file - TEST_TWO=top-env-file - TEST_THREE=top-env-file - """) + tmpdir = tempfile.mkdtemp('test_extends_with_environment') + self.addCleanup(shutil.rmtree, tmpdir) + commondir = os.path.join(tmpdir, 'common') + os.mkdir(commondir) + with open(os.path.join(commondir, 'base.yml'), mode="w") as base_fh: + base_fh.write(""" + app: + image: 'example/app' + env_file: + - 'envs' + environment: + - SECRET + - TEST_ONE=common + - TEST_TWO=common + """) + with open(os.path.join(tmpdir, 'docker-compose.yml'), mode="w") as docker_compose_fh: + docker_compose_fh.write(""" + ext: + extends: + file: common/base.yml + service: app + env_file: + - 'envs' + environment: + - THING + - TEST_ONE=top + """) + with open(os.path.join(commondir, 'envs'), mode="w") as envs_fh: + envs_fh.write(""" + COMMON_ENV_FILE + TEST_ONE=common-env-file + TEST_TWO=common-env-file + TEST_THREE=common-env-file + TEST_FOUR=common-env-file + """) + with open(os.path.join(tmpdir, 'envs'), mode="w") as envs_fh: + envs_fh.write(""" + TOP_ENV_FILE + TEST_ONE=top-env-file + TEST_TWO=top-env-file + TEST_THREE=top-env-file + """) expected = [ { @@ -4783,72 +4791,77 @@ def test_extends_with_environment_and_env_files(self): 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'))) + config = load_from_filename(str(os.path.join(tmpdir, 'docker-compose.yml'))) 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'] - """) + tmpdir = tempfile.mkdtemp('test_extends_with_mixed_version') + self.addCleanup(shutil.rmtree, tmpdir) + with open(os.path.join(tmpdir, 'docker-compose.yml'), mode="w") as docker_compose_fh: + docker_compose_fh.write(""" + version: "2" + services: + web: + extends: + file: base.yml + service: base + image: busybox + """) + with open(os.path.join(tmpdir, 'base.yml'), mode="w") as base_fh: + base_fh.write(""" + base: + volumes: ['/foo'] + ports: ['3000:3000'] + """) with pytest.raises(ConfigurationError) as exc: - load_from_filename(str(tmpdir.join('docker-compose.yml'))) + load_from_filename(str(os.path.join(tmpdir, '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'))) + tmpdir = tempfile.mkdtemp('test_extends_with_defined_version') + self.addCleanup(shutil.rmtree, tmpdir) + with open(os.path.join(tmpdir, 'docker-compose.yml'), mode="w") as docker_compose_fh: + docker_compose_fh.write(""" + version: "2" + services: + web: + extends: + file: base.yml + service: base + image: busybox + """) + with open(os.path.join(tmpdir, 'base.yml'), mode="w") as base_fh: + base_fh.write(""" + version: "2" + services: + base: + volumes: ['/foo'] + ports: ['3000:3000'] + command: top + """) + + service = load_from_filename(str(os.path.join(tmpdir, 'docker-compose.yml'))) assert service[0]['command'] == "top" def test_extends_with_depends_on(self): - tmpdir = py.test.ensuretemp('test_extends_with_depends_on') - 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'))) + tmpdir = tempfile.mkdtemp('test_extends_with_depends_on') + self.addCleanup(shutil.rmtree, tmpdir) + with open(os.path.join(tmpdir, 'docker-compose.yml'), mode="w") as docker_compose_fh: + docker_compose_fh.write(""" + version: "2" + services: + base: + image: example + web: + extends: base + image: busybox + depends_on: ['other'] + other: + image: example + """) + services = load_from_filename(str(os.path.join(tmpdir, 'docker-compose.yml'))) assert service_sort(services)[2]['depends_on'] == { 'other': {'condition': 'service_started'} } @@ -4867,45 +4880,47 @@ def test_extends_with_healthcheck(self): }] def test_extends_with_ports(self): - tmpdir = py.test.ensuretemp('test_extends_with_ports') - self.addCleanup(tmpdir.remove) - tmpdir.join('docker-compose.yml').write(""" - version: '2' - - services: - a: - image: nginx - ports: - - 80 - - b: - extends: - service: a - """) - services = load_from_filename(str(tmpdir.join('docker-compose.yml'))) + tmpdir = tempfile.mkdtemp('test_extends_with_ports') + self.addCleanup(shutil.rmtree, tmpdir) + with open(os.path.join(tmpdir, 'docker-compose.yml'), mode="w") as docker_compose_fh: + docker_compose_fh.write(""" + version: '2' + + services: + a: + image: nginx + ports: + - 80 + + b: + extends: + service: a + """) + services = load_from_filename(str(os.path.join(tmpdir, 'docker-compose.yml'))) assert len(services) == 2 for svc in services: assert svc['ports'] == [types.ServicePort('80', None, None, None, None)] def test_extends_with_security_opt(self): - tmpdir = py.test.ensuretemp('test_extends_with_ports') - self.addCleanup(tmpdir.remove) - tmpdir.join('docker-compose.yml').write(""" - version: '2' - - services: - a: - image: nginx - security_opt: - - apparmor:unconfined - - seccomp:unconfined - - b: - extends: - service: a - """) - services = load_from_filename(str(tmpdir.join('docker-compose.yml'))) + tmpdir = tempfile.mkdtemp('test_extends_with_ports') + self.addCleanup(shutil.rmtree, tmpdir) + with open(os.path.join(tmpdir, 'docker-compose.yml'), mode="w") as docker_compose_fh: + docker_compose_fh.write(""" + version: '2' + + services: + a: + image: nginx + security_opt: + - apparmor:unconfined + - seccomp:unconfined + + b: + extends: + service: a + """) + services = load_from_filename(str(os.path.join(tmpdir, 'docker-compose.yml'))) assert len(services) == 2 for svc in services: assert types.SecurityOpt.parse('apparmor:unconfined') in svc['security_opt'] diff --git a/tests/unit/config/environment_test.py b/tests/unit/config/environment_test.py index 88eb0d6e11a..186702db127 100644 --- a/tests/unit/config/environment_test.py +++ b/tests/unit/config/environment_test.py @@ -4,6 +4,9 @@ from __future__ import unicode_literals import codecs +import os +import shutil +import tempfile import pytest @@ -46,19 +49,19 @@ def test_get_boolean(self): assert env.get_boolean('UNDEFINED') is False def test_env_vars_from_file_bom(self): - tmpdir = pytest.ensuretemp('env_file') - self.addCleanup(tmpdir.remove) + tmpdir = tempfile.mkdtemp('env_file') + self.addCleanup(shutil.rmtree, tmpdir) with codecs.open('{}/bom.env'.format(str(tmpdir)), 'w', encoding='utf-8') as f: f.write('\ufeffPARK_BOM=박봄\n') - assert env_vars_from_file(str(tmpdir.join('bom.env'))) == { + assert env_vars_from_file(str(os.path.join(tmpdir, 'bom.env'))) == { 'PARK_BOM': '박봄' } def test_env_vars_from_file_whitespace(self): - tmpdir = pytest.ensuretemp('env_file') - self.addCleanup(tmpdir.remove) + tmpdir = tempfile.mkdtemp('env_file') + self.addCleanup(shutil.rmtree, tmpdir) with codecs.open('{}/whitespace.env'.format(str(tmpdir)), 'w', encoding='utf-8') as f: f.write('WHITESPACE =yes\n') with pytest.raises(ConfigurationError) as exc: - env_vars_from_file(str(tmpdir.join('whitespace.env'))) + env_vars_from_file(str(os.path.join(tmpdir, 'whitespace.env'))) assert 'environment variable' in exc.exconly() From c818bfc62c0574009175d832c1a8a2857bf1b1bf Mon Sep 17 00:00:00 2001 From: Sergey Fursov Date: Sun, 31 Mar 2019 12:45:50 +0700 Subject: [PATCH 3845/4072] support PyYAML up to 5.x version Signed-off-by: Sergey Fursov --- requirements.txt | 2 +- setup.py | 2 +- tests/acceptance/cli_test.py | 28 ++++++++++++++-------------- tests/unit/config/config_test.py | 20 ++++++++++---------- 4 files changed, 26 insertions(+), 26 deletions(-) diff --git a/requirements.txt b/requirements.txt index 128b18cf619..0cef32af5fc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,7 +17,7 @@ paramiko==2.6.0 pypiwin32==219; sys_platform == 'win32' and python_version < '3.6' pypiwin32==223; sys_platform == 'win32' and python_version >= '3.6' PySocks==1.7.1 -PyYAML==4.2b1 +PyYAML==5.3 requests==2.22.0 six==1.12.0 subprocess32==3.5.4; python_version < '3.2' diff --git a/setup.py b/setup.py index 23ae08a12d0..110441dca62 100644 --- a/setup.py +++ b/setup.py @@ -32,7 +32,7 @@ def find_version(*file_paths): install_requires = [ 'cached-property >= 1.2.0, < 2', 'docopt >= 0.6.1, < 1', - 'PyYAML >= 3.10, < 5', + 'PyYAML >= 3.10, < 6', 'requests >= 2.20.0, < 3', 'texttable >= 0.9.0, < 2', 'websocket-client >= 0.32.0, < 1', diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index ffa05574449..d1e96fdc818 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -269,7 +269,7 @@ def test_config_default(self): # assert there are no python objects encoded in the output assert '!!' not in result.stdout - output = yaml.load(result.stdout) + output = yaml.safe_load(result.stdout) expected = { 'version': '2.0', 'volumes': {'data': {'driver': 'local'}}, @@ -294,7 +294,7 @@ def test_config_default(self): def test_config_restart(self): self.base_dir = 'tests/fixtures/restart' result = self.dispatch(['config']) - assert yaml.load(result.stdout) == { + assert yaml.safe_load(result.stdout) == { 'version': '2.0', 'services': { 'never': { @@ -323,7 +323,7 @@ def test_config_restart(self): 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) + json_result = yaml.safe_load(result.stdout) assert 'networks' in json_result assert json_result['networks'] == { 'networks_foo': { @@ -337,7 +337,7 @@ def test_config_external_network(self): def test_config_with_dot_env(self): self.base_dir = 'tests/fixtures/default-env-file' result = self.dispatch(['config']) - json_result = yaml.load(result.stdout) + json_result = yaml.safe_load(result.stdout) assert json_result == { 'services': { 'web': { @@ -352,7 +352,7 @@ def test_config_with_dot_env(self): def test_config_with_env_file(self): self.base_dir = 'tests/fixtures/default-env-file' result = self.dispatch(['--env-file', '.env2', 'config']) - json_result = yaml.load(result.stdout) + json_result = yaml.safe_load(result.stdout) assert json_result == { 'services': { 'web': { @@ -367,7 +367,7 @@ def test_config_with_env_file(self): def test_config_with_dot_env_and_override_dir(self): self.base_dir = 'tests/fixtures/default-env-file' result = self.dispatch(['--project-directory', 'alt/', 'config']) - json_result = yaml.load(result.stdout) + json_result = yaml.safe_load(result.stdout) assert json_result == { 'services': { 'web': { @@ -382,7 +382,7 @@ def test_config_with_dot_env_and_override_dir(self): def test_config_external_volume_v2(self): self.base_dir = 'tests/fixtures/volumes' result = self.dispatch(['-f', 'external-volumes-v2.yml', 'config']) - json_result = yaml.load(result.stdout) + json_result = yaml.safe_load(result.stdout) assert 'volumes' in json_result assert json_result['volumes'] == { 'foo': { @@ -398,7 +398,7 @@ def test_config_external_volume_v2(self): def test_config_external_volume_v2_x(self): self.base_dir = 'tests/fixtures/volumes' result = self.dispatch(['-f', 'external-volumes-v2-x.yml', 'config']) - json_result = yaml.load(result.stdout) + json_result = yaml.safe_load(result.stdout) assert 'volumes' in json_result assert json_result['volumes'] == { 'foo': { @@ -414,7 +414,7 @@ def test_config_external_volume_v2_x(self): def test_config_external_volume_v3_x(self): self.base_dir = 'tests/fixtures/volumes' result = self.dispatch(['-f', 'external-volumes-v3-x.yml', 'config']) - json_result = yaml.load(result.stdout) + json_result = yaml.safe_load(result.stdout) assert 'volumes' in json_result assert json_result['volumes'] == { 'foo': { @@ -430,7 +430,7 @@ def test_config_external_volume_v3_x(self): def test_config_external_volume_v3_4(self): self.base_dir = 'tests/fixtures/volumes' result = self.dispatch(['-f', 'external-volumes-v3-4.yml', 'config']) - json_result = yaml.load(result.stdout) + json_result = yaml.safe_load(result.stdout) assert 'volumes' in json_result assert json_result['volumes'] == { 'foo': { @@ -446,7 +446,7 @@ def test_config_external_volume_v3_4(self): def test_config_external_network_v3_5(self): self.base_dir = 'tests/fixtures/networks' result = self.dispatch(['-f', 'external-networks-v3-5.yml', 'config']) - json_result = yaml.load(result.stdout) + json_result = yaml.safe_load(result.stdout) assert 'networks' in json_result assert json_result['networks'] == { 'foo': { @@ -462,7 +462,7 @@ def test_config_external_network_v3_5(self): def test_config_v1(self): self.base_dir = 'tests/fixtures/v1-config' result = self.dispatch(['config']) - assert yaml.load(result.stdout) == { + assert yaml.safe_load(result.stdout) == { 'version': '2.1', 'services': { 'net': { @@ -487,7 +487,7 @@ def test_config_v3(self): self.base_dir = 'tests/fixtures/v3-full' result = self.dispatch(['config']) - assert yaml.load(result.stdout) == { + assert yaml.safe_load(result.stdout) == { 'version': '3.5', 'volumes': { 'foobar': { @@ -564,7 +564,7 @@ def test_config_compatibility_mode(self): self.base_dir = 'tests/fixtures/compatibility-mode' result = self.dispatch(['--compatibility', 'config']) - assert yaml.load(result.stdout) == { + assert yaml.safe_load(result.stdout) == { 'version': '2.3', 'volumes': {'foo': {'driver': 'default'}}, 'networks': {'bar': {}}, diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 0f744e22afe..fc76a2b9cf2 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -5060,7 +5060,7 @@ def test_healthcheck(self): }) ) - serialized_config = yaml.load(serialize_config(config_dict)) + serialized_config = yaml.safe_load(serialize_config(config_dict)) serialized_service = serialized_config['services']['test'] assert serialized_service['healthcheck'] == { @@ -5087,7 +5087,7 @@ def test_disable(self): }) ) - serialized_config = yaml.load(serialize_config(config_dict)) + serialized_config = yaml.safe_load(serialize_config(config_dict)) serialized_service = serialized_config['services']['test'] assert serialized_service['healthcheck'] == { @@ -5294,7 +5294,7 @@ def test_serialize_secrets(self): 'secrets': secrets_dict })) - serialized_config = yaml.load(serialize_config(config_dict)) + serialized_config = yaml.safe_load(serialize_config(config_dict)) serialized_service = serialized_config['services']['web'] assert secret_sort(serialized_service['secrets']) == secret_sort(service_dict['secrets']) assert 'secrets' in serialized_config @@ -5309,7 +5309,7 @@ def test_serialize_ports(self): } ], volumes={}, networks={}, secrets={}, configs={}) - serialized_config = yaml.load(serialize_config(config_dict)) + serialized_config = yaml.safe_load(serialize_config(config_dict)) assert '8080:80/tcp' in serialized_config['services']['web']['ports'] def test_serialize_ports_with_ext_ip(self): @@ -5321,7 +5321,7 @@ def test_serialize_ports_with_ext_ip(self): } ], volumes={}, networks={}, secrets={}, configs={}) - serialized_config = yaml.load(serialize_config(config_dict)) + serialized_config = yaml.safe_load(serialize_config(config_dict)) assert '127.0.0.1:8080:80/tcp' in serialized_config['services']['web']['ports'] def test_serialize_configs(self): @@ -5349,7 +5349,7 @@ def test_serialize_configs(self): 'configs': configs_dict })) - serialized_config = yaml.load(serialize_config(config_dict)) + serialized_config = yaml.safe_load(serialize_config(config_dict)) serialized_service = serialized_config['services']['web'] assert secret_sort(serialized_service['configs']) == secret_sort(service_dict['configs']) assert 'configs' in serialized_config @@ -5389,7 +5389,7 @@ def test_serialize_escape_dollar_sign(self): } config_dict = config.load(build_config_details(cfg)) - serialized_config = yaml.load(serialize_config(config_dict)) + serialized_config = yaml.safe_load(serialize_config(config_dict)) serialized_service = serialized_config['services']['web'] assert serialized_service['environment']['CURRENCY'] == '$$' assert serialized_service['command'] == 'echo $$FOO' @@ -5411,7 +5411,7 @@ def test_serialize_escape_dont_interpolate(self): } config_dict = config.load(build_config_details(cfg), interpolate=False) - serialized_config = yaml.load(serialize_config(config_dict, escape_dollar=False)) + serialized_config = yaml.safe_load(serialize_config(config_dict, escape_dollar=False)) serialized_service = serialized_config['services']['web'] assert serialized_service['environment']['CURRENCY'] == '$' assert serialized_service['command'] == 'echo $FOO' @@ -5430,7 +5430,7 @@ def test_serialize_unicode_values(self): config_dict = config.load(build_config_details(cfg)) - serialized_config = yaml.load(serialize_config(config_dict)) + serialized_config = yaml.safe_load(serialize_config(config_dict)) serialized_service = serialized_config['services']['web'] assert serialized_service['command'] == 'echo 十六夜 咲夜' @@ -5446,6 +5446,6 @@ def test_serialize_external_false(self): } config_dict = config.load(build_config_details(cfg)) - serialized_config = yaml.load(serialize_config(config_dict)) + serialized_config = yaml.safe_load(serialize_config(config_dict)) serialized_volume = serialized_config['volumes']['test'] assert serialized_volume['external'] is False From 7ca5973a7169fc3674f422efc0d175186f26995b Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Wed, 8 Jan 2020 12:27:47 +0100 Subject: [PATCH 3846/4072] run release on tag by Jenkinsfile Signed-off-by: Nicolas De Loof --- Release.Jenkinsfile | 76 +++--- script/release/README.md | 202 +------------- script/release/generate_changelog.sh | 19 +- script/release/push-release | 74 ----- script/release/rebase-bump-commit | 38 --- script/release/release.py | 387 --------------------------- script/release/release.sh | 13 - script/release/release/__init__.py | 0 script/release/release/bintray.py | 50 ---- script/release/release/const.py | 10 - script/release/release/downloader.py | 73 ----- script/release/release/images.py | 157 ----------- script/release/release/pypi.py | 44 --- script/release/release/repository.py | 246 ----------------- script/release/release/utils.py | 85 ------ script/release/setup-venv.sh | 47 ---- 16 files changed, 47 insertions(+), 1474 deletions(-) delete mode 100755 script/release/push-release delete mode 100755 script/release/rebase-bump-commit delete mode 100755 script/release/release.py delete mode 100755 script/release/release.sh delete mode 100644 script/release/release/__init__.py delete mode 100644 script/release/release/bintray.py delete mode 100644 script/release/release/const.py delete mode 100644 script/release/release/downloader.py delete mode 100644 script/release/release/images.py delete mode 100644 script/release/release/pypi.py delete mode 100644 script/release/release/repository.py delete mode 100644 script/release/release/utils.py delete mode 100755 script/release/setup-venv.sh diff --git a/Release.Jenkinsfile b/Release.Jenkinsfile index c4d5ad1a515..96aa01530fa 100644 --- a/Release.Jenkinsfile +++ b/Release.Jenkinsfile @@ -2,7 +2,7 @@ def dockerVersions = ['19.03.5', '18.09.9'] def baseImages = ['alpine', 'debian'] -def pythonVersions = ['py27', 'py37'] +def pythonVersions = ['py37'] pipeline { agent none @@ -75,7 +75,7 @@ pipeline { steps { checkout scm sh './script/setup/osx' - sh 'tox -e py27,py37 -- tests/unit' + sh 'tox -e py37 -- tests/unit' sh './script/build/osx' dir ('dist') { checksum('docker-compose-Darwin-x86_64') @@ -112,7 +112,7 @@ pipeline { } steps { checkout scm - bat 'tox.exe -e py27,py37 -- tests/unit' + bat 'tox.exe -e py37 -- tests/unit' powershell '.\\script\\build\\windows.ps1' dir ('dist') { checksum('docker-compose-Windows-x86_64.exe') @@ -159,6 +159,9 @@ pipeline { agent { label 'linux' } + environment { + GITHUB_TOKEN = credentials('github-release-token') + } steps { checkout scm sh 'mkdir -p dist' @@ -167,7 +170,20 @@ pipeline { unstash "bin-linux" unstash "bin-win" unstash "changelog" - githubRelease() + sh(""" + curl -SfL https://github.com/github/hub/releases/download/v2.13.0/hub-linux-amd64-2.13.0.tgz | tar xzv --wildcards 'hub-*/bin/hub' --strip=2 + ./hub release create --draft --prerelease=${env.TAG_NAME !=~ /v[0-9\.]+/} \\ + -a docker-compose-Darwin-x86_64 \\ + -a docker-compose-Darwin-x86_64.sha256 \\ + -a docker-compose-Darwin-x86_64.tgz \\ + -a docker-compose-Darwin-x86_64.tgz.sha256 \\ + -a docker-compose-Linux-x86_64 \\ + -a docker-compose-Linux-x86_64.sha256 \\ + -a docker-compose-Windows-x86_64.exe \\ + -a docker-compose-Windows-x86_64.exe.sha256 \\ + -a ../script/run/run.sh \\ + -F CHANGELOG.md \${TAG_NAME} + """) } } } @@ -175,20 +191,18 @@ pipeline { agent { label 'linux' } + environment { + PYPIRC = credentials('pypirc-docker-dsg-cibot') + } steps { checkout scm - withCredentials([[$class: "FileBinding", credentialsId: 'pypirc-docker-dsg-cibot', variable: 'PYPIRC']]) { - sh """ - virtualenv venv-publish - source venv-publish/bin/activate - python setup.py sdist bdist_wheel - pip install twine - twine upload --config-file ${PYPIRC} ./dist/docker-compose-${env.TAG_NAME}.tar.gz ./dist/docker_compose-${env.TAG_NAME}-py2.py3-none-any.whl - """ - } - } - post { - sh 'deactivate; rm -rf venv-publish' + sh """ + rm -rf build/ dist/ + pip install wheel + python setup.py sdist bdist_wheel + pip install twine + ~/.local/bin/twine upload --config-file ${PYPIRC} ./dist/docker-compose-*.tar.gz ./dist/docker_compose-*-py2.py3-none-any.whl + """ } } } @@ -268,9 +282,8 @@ def buildRuntimeImage(baseImage) { def pushRuntimeImage(baseImage) { unstash "compose-${baseImage}" - sh 'echo -n "${DOCKERHUB_CREDS_PSW}" | docker login --username "${DOCKERHUB_CREDS_USR}" --password-stdin' sh "docker load -i dist/docker-compose-${baseImage}.tar" - withDockerRegistry(credentialsId: 'dockerbuildbot-hub.docker.com') { + withDockerRegistry(credentialsId: 'dockerhub-dockerdsgcibot') { sh "docker push docker/compose:${baseImage}-${env.TAG_NAME}" if (baseImage == "alpine" && env.TAG_NAME != null) { sh "docker tag docker/compose:alpine-${env.TAG_NAME} docker/compose:${env.TAG_NAME}" @@ -279,33 +292,6 @@ def pushRuntimeImage(baseImage) { } } -def githubRelease() { - withCredentials([string(credentialsId: 'github-compose-release-test-token', variable: 'GITHUB_TOKEN')]) { - def prerelease = !( env.TAG_NAME ==~ /v[0-9\.]+/ ) - changelog = readFile "CHANGELOG.md" - def data = """{ - \"tag_name\": \"${env.TAG_NAME}\", - \"name\": \"${env.TAG_NAME}\", - \"draft\": true, - \"prerelease\": ${prerelease}, - \"body\" : \"${changelog}\" - """ - echo $data - - def url = "https://api.github.com/repos/docker/compose/releases" - def upload_url = sh(returnStdout: true, script: """ - curl -sSf -H 'Authorization: token ${GITHUB_TOKEN}' -H 'Accept: application/json' -H 'Content-type: application/json' -X POST -d '$data' $url") \\ - | jq '.upload_url | .[:rindex("{")]' - """) - sh(""" - for f in * ; do - curl -sf -H 'Authorization: token ${GITHUB_TOKEN}' -H 'Accept: application/json' -H 'Content-type: application/octet-stream' \\ - -X POST --data-binary @\$f ${upload_url}?name=\$f; - done - """) - } -} - def checksum(filepath) { if (isUnix()) { sh "openssl sha256 -r -out ${filepath}.sha256 ${filepath}" diff --git a/script/release/README.md b/script/release/README.md index 97168d3769a..d53f67606a8 100644 --- a/script/release/README.md +++ b/script/release/README.md @@ -1,201 +1,9 @@ # Release HOWTO -This file describes the process of making a public release of `docker-compose`. -Please read it carefully before proceeding! +The release process is fully automated by `Release.Jenkinsfile`. -## Prerequisites +## Usage -The following things are required to bring a release to a successful conclusion - -### Local Docker engine (Linux Containers) - -The release script builds images that will be part of the release. - -### Docker Hub account - -You should be logged into a Docker Hub account that allows pushing to the -following repositories: - -- docker/compose -- docker/compose-tests - -### Python - -The release script is written in Python and requires Python 3.3 at minimum. - -### A Github account and Github API token - -Your Github account needs to have write access on the `docker/compose` repo. -To generate a Github token, head over to the -[Personal access tokens](https://github.com/settings/tokens) page in your -Github settings and select "Generate new token". Your token should include -(at minimum) the following scopes: - -- `repo:status` -- `public_repo` - -This API token should be exposed to the release script through the -`GITHUB_TOKEN` environment variable. - -### A Bintray account and Bintray API key - -Your Bintray account will need to be an admin member of the -[docker-compose organization](https://bintray.com/docker-compose). -Additionally, you should generate a personal API key. To do so, click your -username in the top-right hand corner and select "Edit profile" ; on the new -page, select "API key" in the left-side menu. - -This API key should be exposed to the release script through the -`BINTRAY_TOKEN` environment variable. - -### A PyPi account - -Said account needs to be a member of the maintainers group for the -[`docker-compose` project](https://pypi.org/project/docker-compose/). - -Moreover, the `~/.pypirc` file should exist on your host and contain the -relevant pypi credentials. - -The following is a sample `.pypirc` provided as a guideline: - -``` -[distutils] -index-servers = - pypi - -[pypi] -username = user -password = pass -``` - -## Start a feature release - -A feature release is a release that includes all changes present in the -`master` branch when initiated. It's typically versioned `X.Y.0-rc1`, where -Y is the minor version of the previous release incremented by one. A series -of one or more Release Candidates (RCs) should be made available to the public -to find and squash potential bugs. - -From the root of the Compose repository, run the following command: -``` -./script/release/release.sh -b start X.Y.0-rc1 -``` - -After a short initialization period, the script will invite you to edit the -`CHANGELOG.md` file. Do so by being careful to respect the same format as -previous releases. Once done, the script will display a `diff` of the staged -changes for the bump commit. Once you validate these, a bump commit will be -created on the newly created release branch and pushed remotely. - -The release tool then waits for the CI to conclude before proceeding. -If failures are reported, the release will be aborted until these are fixed. -Please refer to the "Resume a draft release" section below for more details. - -Once all resources have been prepared, the release script will exit with a -message resembling this one: - -``` -You're almost done! Please verify that everything is in order and you are ready -to make the release public, then run the following command: -./script/release/release.sh -b user finalize X.Y.0-rc1 -``` - -Once you are ready to finalize the release (making binaries and other versioned -assets public), proceed to the "Finalize a release" section of this guide. - -## Start a patch release - -A patch release is a release that builds off a previous release with discrete -additions. This can be an RC release after RC1 (`X.Y.0-rcZ`, `Z > 1`), a GA release -based off the final RC (`X.Y.0`), or a bugfix release based off a previous -GA release (`X.Y.Z`, `Z > 0`). - -From the root of the Compose repository, run the following command: -``` -./script/release/release.sh -b start --patch=BASE_VERSION RELEASE_VERSION -``` - -The process of starting a patch release is identical to starting a feature -release except for one difference ; at the beginning, the script will ask for -PR numbers you wish to cherry-pick into the release. These numbers should -correspond to existing PRs on the docker/compose repository. Multiple numbers -should be separated by whitespace. - -Once you are ready to finalize the release (making binaries and other versioned -assets public), proceed to the "Finalize a release" section of this guide. - -## Finalize a release - -Once you're ready to make your release public, you may execute the following -command from the root of the Compose repository: -``` -./script/release/release.sh -b finalize RELEASE_VERSION -``` - -Note that this command will create and publish versioned assets to the public. -As a result, it can not be reverted. The command will perform some basic -sanity checks before doing so, but it is your responsibility to ensure -everything is in order before pushing the button. - -After the command exits, you should make sure: - -- The `docker/compose:VERSION` image is available on Docker Hub and functional -- The `pip install -U docker-compose==VERSION` command correctly installs the - specified version -- The install command on the Github release page installs the new release - -## Resume a draft release - -"Resuming" a release lets you address the following situations occurring before -a release is made final: - -- Cherry-pick additional PRs to include in the release -- Resume a release that was aborted because of CI failures after they've been - addressed -- Rebuild / redownload assets after manual changes have been made to the - release branch -- etc. - -From the root of the Compose repository, run the following command: -``` -./script/release/release.sh -b resume RELEASE_VERSION -``` - -The release tool will attempt to determine what steps it's already been through -for the specified release and pick up where it left off. Some steps are -executed again no matter what as it's assumed they'll produce different -results, like building images or downloading binaries. - -## Cancel a draft release - -If issues snuck into your release branch, it is sometimes easier to start from -scratch. Before a release has been finalized, it is possible to cancel it using -the following command: -``` -./script/release/release.sh -b cancel RELEASE_VERSION -``` - -This will remove the release branch with this release (locally and remotely), -close the associated PR, remove the release page draft on Github and delete -the Bintray repository for it, allowing you to start fresh. - -## Manual operations - -Some common, release-related operations are not covered by this tool and should -be handled manually by the operator: - -- After any release: - - Announce new release on Slack -- After a GA release: - - Close the release milestone - - Merge back `CHANGELOG.md` changes from the `release` branch into `master` - - Bump the version in `compose/__init__.py` to the *next* minor version - number with `dev` appended. For example, if you just released `1.4.0`, - update it to `1.5.0dev` - - Update compose_version in [github.com/docker/docker.github.io/blob/master/_config.yml](https://github.com/docker/docker.github.io/blob/master/_config.yml) and [github.com/docker/docker.github.io/blob/master/_config_authoring.yml](https://github.com/docker/docker.github.io/blob/master/_config_authoring.yml) - - Update the release note in [github.com/docker/docker.github.io](https://github.com/docker/docker.github.io/blob/master/release-notes/docker-compose.md) - -## Advanced options - -You can consult the full list of options for the release tool by executing -`./script/release/release.sh --help`. +1. edit `compose/__init__.py` to set release version number +1. commit and tag as `v{major}.{minor}.{patch}` +1. edit `compose/__init__.py` again to set next development version number diff --git a/script/release/generate_changelog.sh b/script/release/generate_changelog.sh index 783e744007c..8c3b3da8d15 100755 --- a/script/release/generate_changelog.sh +++ b/script/release/generate_changelog.sh @@ -26,14 +26,17 @@ changes=$(pullrequests | uniq) echo "pull requests merged within range:" echo $changes -echo '#Features' > CHANGELOG.md +echo '#Features' > FEATURES.md +echo '#Bugs' > BUGS.md for pr in $changes; do - curl -fs -H "Authorization: token ${GITHUB_TOKEN}" https://api.github.com/repos/docker/compose/pulls/${pr} \ - | jq -r ' select( .labels[].name | contains("kind/feature") ) | "* "+.title' >> CHANGELOG.md -done + curl -fs -H "Authorization: token ${GITHUB_TOKEN}" https://api.github.com/repos/docker/compose/pulls/${pr} -o PR.json -echo '#Bugs' >> CHANGELOG.md -for pr in $changes; do - curl -fs -H "Authorization: token ${GITHUB_TOKEN}" https://api.github.com/repos/docker/compose/pulls/${pr} \ - | jq -r ' select( .labels[].name | contains("kind/bug") ) | "* "+.title' >> CHANGELOG.md + cat PR.json | jq -r ' select( .labels[].name | contains("kind/feature") ) | "- "+.title' >> FEATURES.md + cat PR.json | jq -r ' select( .labels[].name | contains("kind/bug") ) | "- "+.title' >> BUGS.md done + +echo ${TAG_NAME} > CHANGELOG.md +echo >> CHANGELOG.md +cat FEATURES.md >> CHANGELOG.md +echo >> CHANGELOG.md +cat BUGS.md >> CHANGELOG.md diff --git a/script/release/push-release b/script/release/push-release deleted file mode 100755 index f28c1d4fea8..00000000000 --- a/script/release/push-release +++ /dev/null @@ -1,74 +0,0 @@ -#!/bin/bash -# -# Create the official release -# - -. "$(dirname "${BASH_SOURCE[0]}")/utils.sh" - -function usage() { - >&2 cat << EOM -Publish a release by building all artifacts and pushing them. - -This script requires that 'git config branch.${BRANCH}.release' is set to the -release version for the release branch. - -EOM - exit 1 -} - -BRANCH="$(git rev-parse --abbrev-ref HEAD)" -VERSION="$(git config "branch.${BRANCH}.release")" || usage - -if [ -z "$(command -v jq 2> /dev/null)" ]; then - >&2 echo "$0 requires https://stedolan.github.io/jq/" - >&2 echo "Please install it and make sure it is available on your \$PATH." - exit 2 -fi - - -API=https://api.github.com/repos -REPO=docker/compose -GITHUB_REPO=git@github.com:$REPO - -# Check the build status is green -sha=$(git rev-parse HEAD) -url=$API/$REPO/statuses/$sha -build_status=$(curl -s $url | jq -r '.[0].state') -if [ -n "$SKIP_BUILD_CHECK" ]; then - echo "Skipping build status check..." -elif [[ "$build_status" != "success" ]]; then - >&2 echo "Build status is $build_status, but it should be success." - exit -1 -fi - -echo "Tagging the release as $VERSION" -git tag $VERSION -git push $GITHUB_REPO $VERSION - -echo "Uploading the docker image" -docker push docker/compose:$VERSION - -echo "Uploading the compose-tests image" -docker push docker/compose-tests:latest -docker push docker/compose-tests:$VERSION - -echo "Uploading package to PyPI" -./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 -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 -docker-compose version -deactivate -rm -rf venv-test - -echo "Now publish the github release, and test the downloads." -echo "Email maintainers@dockerproject.org and engineering@docker.com about the new release." diff --git a/script/release/rebase-bump-commit b/script/release/rebase-bump-commit deleted file mode 100755 index 3c2ae72b12a..00000000000 --- a/script/release/rebase-bump-commit +++ /dev/null @@ -1,38 +0,0 @@ -#!/bin/bash -# -# Move the "bump to " commit to the HEAD of the branch -# - -. "$(dirname "${BASH_SOURCE[0]}")/utils.sh" - -function usage() { - >&2 cat << EOM -Move the "bump to " commit to the HEAD of the branch - -This script requires that 'git config branch.${BRANCH}.release' is set to the -release version for the release branch. - -EOM - exit 1 -} - - -BRANCH="$(git rev-parse --abbrev-ref HEAD)" -VERSION="$(git config "branch.${BRANCH}.release")" || usage - - -COMMIT_MSG="Bump $VERSION" -sha="$(git log --grep "$COMMIT_MSG\$" --format="%H")" -if [ -z "$sha" ]; then - >&2 echo "No commit with message \"$COMMIT_MSG\"" - exit 2 -fi -if [[ "$sha" == "$(git rev-parse HEAD)" ]]; then - >&2 echo "Bump commit already at HEAD" - exit 0 -fi - -commits=$(git log --format="%H" "$sha..HEAD" | wc -l | xargs echo) - -git rebase --onto $sha~1 HEAD~$commits $BRANCH -git cherry-pick $sha diff --git a/script/release/release.py b/script/release/release.py deleted file mode 100755 index 82bc9a0a677..00000000000 --- a/script/release/release.py +++ /dev/null @@ -1,387 +0,0 @@ -from __future__ import absolute_import -from __future__ import print_function -from __future__ import unicode_literals - -import argparse -import os -import shutil -import sys -import time - -from jinja2 import Template -from release.bintray import BintrayAPI -from release.const import BINTRAY_ORG -from release.const import NAME -from release.const import REPO_ROOT -from release.downloader import BinaryDownloader -from release.images import ImageManager -from release.images import is_tag_latest -from release.pypi import check_pypirc -from release.pypi import pypi_upload -from release.repository import delete_assets -from release.repository import get_contributors -from release.repository import Repository -from release.repository import upload_assets -from release.utils import branch_name -from release.utils import compatibility_matrix -from release.utils import read_release_notes_from_changelog -from release.utils import ScriptError -from release.utils import update_init_py_version -from release.utils import update_run_sh_version -from release.utils import yesno - - -def create_initial_branch(repository, args): - release_branch = repository.create_release_branch(args.release, args.base) - if args.base and args.cherries: - print('Detected patch version.') - cherries = input('Indicate (space-separated) PR numbers to cherry-pick then press Enter:\n') - repository.cherry_pick_prs(release_branch, cherries.split()) - - return create_bump_commit(repository, release_branch, args.bintray_user, args.bintray_org) - - -def create_bump_commit(repository, release_branch, bintray_user, bintray_org): - with release_branch.config_reader() as cfg: - release = cfg.get('release') - print('Updating version info in __init__.py and run.sh') - update_run_sh_version(release) - update_init_py_version(release) - - input('Please add the release notes to the CHANGELOG.md file, then press Enter to continue.') - proceed = None - while not proceed: - print(repository.diff()) - proceed = yesno('Are these changes ok? y/N ', default=False) - - if repository.diff(): - repository.create_bump_commit(release_branch, release) - repository.push_branch_to_remote(release_branch) - - bintray_api = BintrayAPI(os.environ['BINTRAY_TOKEN'], bintray_user) - if not bintray_api.repository_exists(bintray_org, release_branch.name): - print('Creating data repository {} on bintray'.format(release_branch.name)) - bintray_api.create_repository(bintray_org, release_branch.name, 'generic') - else: - print('Bintray repository {} already exists. Skipping'.format(release_branch.name)) - - -def monitor_pr_status(pr_data): - print('Waiting for CI to complete...') - last_commit = pr_data.get_commits().reversed[0] - while True: - status = last_commit.get_combined_status() - if status.state == 'pending' or status.state == 'failure': - summary = { - 'pending': 0, - 'success': 0, - 'failure': 0, - 'error': 0, - } - for detail in status.statuses: - if detail.context == 'dco-signed': - # dco-signed check breaks on merge remote-tracking ; ignore it - continue - if detail.state in summary: - summary[detail.state] += 1 - print( - '{pending} pending, {success} successes, {failure} failures, ' - '{error} errors'.format(**summary) - ) - if summary['failure'] > 0 or summary['error'] > 0: - raise ScriptError('CI failures detected!') - elif summary['pending'] == 0 and summary['success'] > 0: - # This check assumes at least 1 non-DCO CI check to avoid race conditions. - # If testing on a repo without CI, use --skip-ci-check to avoid looping eternally - return True - time.sleep(30) - elif status.state == 'success': - print('{} successes: all clear!'.format(status.total_count)) - return True - - -def check_pr_mergeable(pr_data): - if pr_data.mergeable is False: - # mergeable can also be null, in which case the warning would be a false positive. - print( - 'WARNING!! PR #{} can not currently be merged. You will need to ' - 'resolve the conflicts manually before finalizing the release.'.format(pr_data.number) - ) - - return pr_data.mergeable is True - - -def create_release_draft(repository, version, pr_data, files): - print('Creating Github release draft') - with open(os.path.join(os.path.dirname(__file__), 'release.md.tmpl'), 'r') as f: - template = Template(f.read()) - print('Rendering release notes based on template') - release_notes = template.render( - version=version, - compat_matrix=compatibility_matrix(), - integrity=files, - contributors=get_contributors(pr_data), - changelog=read_release_notes_from_changelog(), - ) - gh_release = repository.create_release( - version, release_notes, draft=True, prerelease='-rc' in version, - target_commitish='release' - ) - print('Release draft initialized') - return gh_release - - -def print_final_instructions(args): - print( - "You're almost done! Please verify that everything is in order and " - "you are ready to make the release public, then run the following " - "command:\n{exe} -b {user} finalize {version}".format( - exe='./script/release/release.sh', user=args.bintray_user, version=args.release - ) - ) - - -def distclean(): - print('Running distclean...') - dirs = [ - os.path.join(REPO_ROOT, 'build'), os.path.join(REPO_ROOT, 'dist'), - os.path.join(REPO_ROOT, 'docker-compose.egg-info') - ] - files = [] - for base, dirnames, fnames in os.walk(REPO_ROOT): - for fname in fnames: - path = os.path.normpath(os.path.join(base, fname)) - if fname.endswith('.pyc'): - files.append(path) - elif fname.startswith('.coverage.'): - files.append(path) - for dirname in dirnames: - path = os.path.normpath(os.path.join(base, dirname)) - if dirname == '__pycache__': - dirs.append(path) - elif dirname == '.coverage-binfiles': - dirs.append(path) - - for file in files: - os.unlink(file) - - for folder in dirs: - shutil.rmtree(folder, ignore_errors=True) - - -def resume(args): - try: - distclean() - repository = Repository(REPO_ROOT, args.repo) - br_name = branch_name(args.release) - if not repository.branch_exists(br_name): - raise ScriptError('No local branch exists for this release.') - gh_release = repository.find_release(args.release) - if gh_release and not gh_release.draft: - print('WARNING!! Found non-draft (public) release for this version!') - proceed = yesno( - 'Are you sure you wish to proceed? Modifying an already ' - 'released version is dangerous! y/N ', default=False - ) - if proceed.lower() is not True: - raise ScriptError('Aborting release') - - release_branch = repository.checkout_branch(br_name) - if args.cherries: - cherries = input('Indicate (space-separated) PR numbers to cherry-pick then press Enter:\n') - repository.cherry_pick_prs(release_branch, cherries.split()) - - create_bump_commit(repository, release_branch, args.bintray_user, args.bintray_org) - pr_data = repository.find_release_pr(args.release) - if not pr_data: - pr_data = repository.create_release_pull_request(args.release) - check_pr_mergeable(pr_data) - if not args.skip_ci: - monitor_pr_status(pr_data) - downloader = BinaryDownloader(args.destination) - files = downloader.download_all(args.release) - if not gh_release: - gh_release = create_release_draft(repository, args.release, pr_data, files) - delete_assets(gh_release) - upload_assets(gh_release, files) - tag_as_latest = is_tag_latest(args.release) - img_manager = ImageManager(args.release, tag_as_latest) - img_manager.build_images(repository) - except ScriptError as e: - print(e) - return 1 - - print_final_instructions(args) - return 0 - - -def cancel(args): - try: - repository = Repository(REPO_ROOT, args.repo) - repository.close_release_pr(args.release) - repository.remove_release(args.release) - repository.remove_bump_branch(args.release) - bintray_api = BintrayAPI(os.environ['BINTRAY_TOKEN'], args.bintray_user) - print('Removing Bintray data repository for {}'.format(args.release)) - bintray_api.delete_repository(args.bintray_org, branch_name(args.release)) - distclean() - except ScriptError as e: - print(e) - return 1 - print('Release cancellation complete.') - return 0 - - -def start(args): - distclean() - try: - repository = Repository(REPO_ROOT, args.repo) - create_initial_branch(repository, args) - pr_data = repository.create_release_pull_request(args.release) - check_pr_mergeable(pr_data) - if not args.skip_ci: - monitor_pr_status(pr_data) - downloader = BinaryDownloader(args.destination) - files = downloader.download_all(args.release) - gh_release = create_release_draft(repository, args.release, pr_data, files) - upload_assets(gh_release, files) - tag_as_latest = is_tag_latest(args.release) - img_manager = ImageManager(args.release, tag_as_latest) - img_manager.build_images(repository) - except ScriptError as e: - print(e) - return 1 - - print_final_instructions(args) - return 0 - - -def finalize(args): - distclean() - try: - check_pypirc() - repository = Repository(REPO_ROOT, args.repo) - tag_as_latest = is_tag_latest(args.release) - img_manager = ImageManager(args.release, tag_as_latest) - pr_data = repository.find_release_pr(args.release) - if not pr_data: - raise ScriptError('No PR found for {}'.format(args.release)) - if not check_pr_mergeable(pr_data): - raise ScriptError('Can not finalize release with an unmergeable PR') - if not img_manager.check_images(): - raise ScriptError('Missing release image') - br_name = branch_name(args.release) - if not repository.branch_exists(br_name): - raise ScriptError('No local branch exists for this release.') - gh_release = repository.find_release(args.release) - if not gh_release: - raise ScriptError('No Github release draft for this version') - - repository.checkout_branch(br_name) - - os.system('python {setup_script} sdist bdist_wheel'.format( - setup_script=os.path.join(REPO_ROOT, 'setup.py'))) - - merge_status = pr_data.merge() - if not merge_status.merged and not args.finalize_resume: - raise ScriptError( - 'Unable to merge PR #{}: {}'.format(pr_data.number, merge_status.message) - ) - - pypi_upload(args) - - img_manager.push_images() - repository.publish_release(gh_release) - except ScriptError as e: - print(e) - return 1 - - return 0 - - -ACTIONS = [ - 'start', - 'cancel', - 'resume', - 'finalize', -] - -EPILOG = '''Example uses: - * Start a new feature release (includes all changes currently in master) - release.sh -b user start 1.23.0 - * Start a new patch release - release.sh -b user --patch 1.21.0 start 1.21.1 - * Cancel / rollback an existing release draft - release.sh -b user cancel 1.23.0 - * Restart a previously aborted patch release - release.sh -b user -p 1.21.0 resume 1.21.1 -''' - - -def main(): - if 'GITHUB_TOKEN' not in os.environ: - print('GITHUB_TOKEN environment variable must be set') - return 1 - - if 'BINTRAY_TOKEN' not in os.environ: - print('BINTRAY_TOKEN environment variable must be set') - return 1 - - parser = argparse.ArgumentParser( - description='Orchestrate a new release of docker/compose. This tool assumes that you have ' - 'obtained a Github API token and Bintray API key and set the GITHUB_TOKEN and ' - 'BINTRAY_TOKEN environment variables accordingly.', - epilog=EPILOG, formatter_class=argparse.RawTextHelpFormatter) - parser.add_argument( - 'action', choices=ACTIONS, help='The action to be performed for this release' - ) - parser.add_argument('release', help='Release number, e.g. 1.9.0-rc1, 2.1.1') - parser.add_argument( - '--patch', '-p', dest='base', - help='Which version is being patched by this release' - ) - parser.add_argument( - '--repo', '-r', dest='repo', default=NAME, - help='Start a release for the given repo (default: {})'.format(NAME) - ) - parser.add_argument( - '-b', dest='bintray_user', required=True, metavar='USER', - help='Username associated with the Bintray API key' - ) - parser.add_argument( - '--bintray-org', dest='bintray_org', metavar='ORG', default=BINTRAY_ORG, - help='Organization name on bintray where the data repository will be created.' - ) - parser.add_argument( - '--destination', '-o', metavar='DIR', default='binaries', - help='Directory where release binaries will be downloaded relative to the project root' - ) - parser.add_argument( - '--no-cherries', '-C', dest='cherries', action='store_false', - help='If set, the program will not prompt the user for PR numbers to cherry-pick' - ) - parser.add_argument( - '--skip-ci-checks', dest='skip_ci', action='store_true', - help='If set, the program will not wait for CI jobs to complete' - ) - parser.add_argument( - '--finalize-resume', dest='finalize_resume', action='store_true', - help='If set, finalize will continue through steps that have already been completed.' - ) - args = parser.parse_args() - - if args.action == 'start': - return start(args) - elif args.action == 'resume': - return resume(args) - elif args.action == 'cancel': - return cancel(args) - elif args.action == 'finalize': - return finalize(args) - - print('Unexpected action "{}"'.format(args.action), file=sys.stderr) - return 1 - - -if __name__ == '__main__': - sys.exit(main()) diff --git a/script/release/release.sh b/script/release/release.sh deleted file mode 100755 index 5f853808bd5..00000000000 --- a/script/release/release.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/bin/sh - -if test -d ${VENV_DIR:-./.release-venv}; then - true -else - ./script/release/setup-venv.sh -fi - -if test -z "$*"; then - args="--help" -fi - -${VENV_DIR:-./.release-venv}/bin/python ./script/release/release.py "$@" diff --git a/script/release/release/__init__.py b/script/release/release/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/script/release/release/bintray.py b/script/release/release/bintray.py deleted file mode 100644 index fb4008ad034..00000000000 --- a/script/release/release/bintray.py +++ /dev/null @@ -1,50 +0,0 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - -import json - -import requests - -from .const import NAME - - -class BintrayAPI(requests.Session): - def __init__(self, api_key, user, *args, **kwargs): - super(BintrayAPI, self).__init__(*args, **kwargs) - self.auth = (user, api_key) - self.base_url = 'https://api.bintray.com/' - - def create_repository(self, subject, repo_name, repo_type='generic'): - url = '{base}repos/{subject}/{repo_name}'.format( - base=self.base_url, subject=subject, repo_name=repo_name, - ) - data = { - 'name': repo_name, - 'type': repo_type, - 'private': False, - 'desc': 'Automated release for {}: {}'.format(NAME, repo_name), - 'labels': ['docker-compose', 'docker', 'release-bot'], - } - return self.post_json(url, data) - - def repository_exists(self, subject, repo_name): - url = '{base}/repos/{subject}/{repo_name}'.format( - base=self.base_url, subject=subject, repo_name=repo_name, - ) - result = self.get(url) - if result.status_code == 404: - return False - result.raise_for_status() - return True - - def delete_repository(self, subject, repo_name): - url = '{base}repos/{subject}/{repo_name}'.format( - base=self.base_url, subject=subject, repo_name=repo_name, - ) - return self.delete(url) - - def post_json(self, url, data, **kwargs): - if 'headers' not in kwargs: - kwargs['headers'] = {} - kwargs['headers']['Content-Type'] = 'application/json' - return self.post(url, data=json.dumps(data), **kwargs) diff --git a/script/release/release/const.py b/script/release/release/const.py deleted file mode 100644 index 52458ea14b9..00000000000 --- a/script/release/release/const.py +++ /dev/null @@ -1,10 +0,0 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - -import os - - -REPO_ROOT = os.path.join(os.path.dirname(__file__), '..', '..', '..') -NAME = 'docker/compose' -COMPOSE_TESTS_IMAGE_BASE_NAME = NAME + '-tests' -BINTRAY_ORG = 'docker-compose' diff --git a/script/release/release/downloader.py b/script/release/release/downloader.py deleted file mode 100644 index 0e9b8013020..00000000000 --- a/script/release/release/downloader.py +++ /dev/null @@ -1,73 +0,0 @@ -from __future__ import absolute_import -from __future__ import print_function -from __future__ import unicode_literals - -import hashlib -import os - -import requests - -from .const import BINTRAY_ORG -from .const import NAME -from .const import REPO_ROOT -from .utils import branch_name - - -class BinaryDownloader(requests.Session): - base_bintray_url = 'https://dl.bintray.com/{}'.format(BINTRAY_ORG) - base_appveyor_url = 'https://ci.appveyor.com/api/projects/{}/artifacts/'.format(NAME) - - def __init__(self, destination, *args, **kwargs): - super(BinaryDownloader, self).__init__(*args, **kwargs) - self.destination = destination - os.makedirs(self.destination, exist_ok=True) - - def download_from_bintray(self, repo_name, filename): - print('Downloading {} from bintray'.format(filename)) - url = '{base}/{repo_name}/{filename}'.format( - base=self.base_bintray_url, repo_name=repo_name, filename=filename - ) - full_dest = os.path.join(REPO_ROOT, self.destination, filename) - return self._download(url, full_dest) - - def download_from_appveyor(self, branch_name, filename): - print('Downloading {} from appveyor'.format(filename)) - url = '{base}/dist%2F{filename}?branch={branch_name}'.format( - base=self.base_appveyor_url, filename=filename, branch_name=branch_name - ) - full_dest = os.path.join(REPO_ROOT, self.destination, filename) - return self._download(url, full_dest) - - def _download(self, url, full_dest): - m = hashlib.sha256() - with open(full_dest, 'wb') as f: - r = self.get(url, stream=True) - for chunk in r.iter_content(chunk_size=1024 * 600, decode_unicode=False): - print('.', end='', flush=True) - m.update(chunk) - f.write(chunk) - - print(' download complete') - hex_digest = m.hexdigest() - with open(full_dest + '.sha256', 'w') as f: - f.write('{} {}\n'.format(hex_digest, os.path.basename(full_dest))) - return full_dest, hex_digest - - def download_all(self, version): - files = { - 'docker-compose-Darwin-x86_64.tgz': None, - 'docker-compose-Darwin-x86_64': None, - 'docker-compose-Linux-x86_64': None, - 'docker-compose-Windows-x86_64.exe': None, - } - - for filename in files.keys(): - if 'Windows' in filename: - files[filename] = self.download_from_appveyor( - branch_name(version), filename - ) - else: - files[filename] = self.download_from_bintray( - branch_name(version), filename - ) - return files diff --git a/script/release/release/images.py b/script/release/release/images.py deleted file mode 100644 index 17d572df33c..00000000000 --- a/script/release/release/images.py +++ /dev/null @@ -1,157 +0,0 @@ -from __future__ import absolute_import -from __future__ import print_function -from __future__ import unicode_literals - -import base64 -import json -import os - -import docker -from enum import Enum - -from .const import NAME -from .const import REPO_ROOT -from .utils import ScriptError -from .utils import yesno -from script.release.release.const import COMPOSE_TESTS_IMAGE_BASE_NAME - - -class Platform(Enum): - ALPINE = 'alpine' - DEBIAN = 'debian' - - def __str__(self): - return self.value - - -# Checks if this version respects the GA version format ('x.y.z') and not an RC -def is_tag_latest(version): - ga_version = all(n.isdigit() for n in version.split('.')) and version.count('.') == 2 - return ga_version and yesno('Should this release be tagged as \"latest\"? [Y/n]: ', default=True) - - -class ImageManager(object): - def __init__(self, version, latest=False): - self.docker_client = docker.APIClient(**docker.utils.kwargs_from_env()) - self.version = version - self.latest = latest - if 'HUB_CREDENTIALS' in os.environ: - print('HUB_CREDENTIALS found in environment, issuing login') - credentials = json.loads(base64.urlsafe_b64decode(os.environ['HUB_CREDENTIALS'])) - self.docker_client.login( - username=credentials['Username'], password=credentials['Password'] - ) - - def _tag(self, image, existing_tag, new_tag): - existing_repo_tag = '{image}:{tag}'.format(image=image, tag=existing_tag) - new_repo_tag = '{image}:{tag}'.format(image=image, tag=new_tag) - self.docker_client.tag(existing_repo_tag, new_repo_tag) - - def get_full_version(self, platform=None): - return self.version + '-' + platform.__str__() if platform else self.version - - def get_runtime_image_tag(self, tag): - return '{image_base_image}:{tag}'.format( - image_base_image=NAME, - tag=self.get_full_version(tag) - ) - - def build_runtime_image(self, repository, platform): - git_sha = repository.write_git_sha() - compose_image_base_name = NAME - print('Building {image} image ({platform} based)'.format( - image=compose_image_base_name, - platform=platform - )) - full_version = self.get_full_version(platform) - build_tag = self.get_runtime_image_tag(platform) - logstream = self.docker_client.build( - REPO_ROOT, - tag=build_tag, - buildargs={ - 'BUILD_PLATFORM': platform.value, - 'GIT_COMMIT': git_sha, - }, - decode=True - ) - for chunk in logstream: - if 'error' in chunk: - raise ScriptError('Build error: {}'.format(chunk['error'])) - if 'stream' in chunk: - print(chunk['stream'], end='') - - if platform == Platform.ALPINE: - self._tag(compose_image_base_name, full_version, self.version) - if self.latest: - self._tag(compose_image_base_name, full_version, platform) - if platform == Platform.ALPINE: - self._tag(compose_image_base_name, full_version, 'latest') - - def get_ucp_test_image_tag(self, tag=None): - return '{image}:{tag}'.format( - image=COMPOSE_TESTS_IMAGE_BASE_NAME, - tag=tag or self.version - ) - - # Used for producing a test image for UCP - def build_ucp_test_image(self, repository): - print('Building test image (debian based for UCP e2e)') - git_sha = repository.write_git_sha() - ucp_test_image_tag = self.get_ucp_test_image_tag() - logstream = self.docker_client.build( - REPO_ROOT, - tag=ucp_test_image_tag, - target='build', - buildargs={ - 'BUILD_PLATFORM': Platform.DEBIAN.value, - 'GIT_COMMIT': git_sha, - }, - decode=True - ) - for chunk in logstream: - if 'error' in chunk: - raise ScriptError('Build error: {}'.format(chunk['error'])) - if 'stream' in chunk: - print(chunk['stream'], end='') - - self._tag(COMPOSE_TESTS_IMAGE_BASE_NAME, self.version, 'latest') - - def build_images(self, repository): - self.build_runtime_image(repository, Platform.ALPINE) - self.build_runtime_image(repository, Platform.DEBIAN) - self.build_ucp_test_image(repository) - - def check_images(self): - for name in self.get_images_to_push(): - try: - self.docker_client.inspect_image(name) - except docker.errors.ImageNotFound: - print('Expected image {} was not found'.format(name)) - return False - return True - - def get_images_to_push(self): - tags_to_push = { - "{}:{}".format(NAME, self.version), - self.get_runtime_image_tag(Platform.ALPINE), - self.get_runtime_image_tag(Platform.DEBIAN), - self.get_ucp_test_image_tag(), - self.get_ucp_test_image_tag('latest'), - } - if is_tag_latest(self.version): - tags_to_push.add("{}:latest".format(NAME)) - return tags_to_push - - def push_images(self): - tags_to_push = self.get_images_to_push() - print('Build tags to push {}'.format(tags_to_push)) - for name in tags_to_push: - print('Pushing {} to Docker Hub'.format(name)) - logstream = self.docker_client.push(name, stream=True, decode=True) - for chunk in logstream: - if 'status' in chunk: - print(chunk['status']) - if 'error' in chunk: - raise ScriptError( - 'Error pushing {name}: {err}'.format(name=name, err=chunk['error']) - ) diff --git a/script/release/release/pypi.py b/script/release/release/pypi.py deleted file mode 100644 index dc0b0cb97b3..00000000000 --- a/script/release/release/pypi.py +++ /dev/null @@ -1,44 +0,0 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - -from configparser import Error -from requests.exceptions import HTTPError -from twine.commands.upload import main as twine_upload -from twine.utils import get_config - -from .utils import ScriptError - - -def pypi_upload(args): - print('Uploading to PyPi') - try: - rel = args.release.replace('-rc', 'rc') - twine_upload([ - 'dist/docker_compose-{}*.whl'.format(rel), - 'dist/docker-compose-{}*.tar.gz'.format(rel) - ]) - except HTTPError as e: - if e.response.status_code == 400 and 'File already exists' in str(e): - if not args.finalize_resume: - raise ScriptError( - 'Package already uploaded on PyPi.' - ) - print('Skipping PyPi upload - package already uploaded') - else: - raise ScriptError('Unexpected HTTP error uploading package to PyPi: {}'.format(e)) - - -def check_pypirc(): - try: - config = get_config() - except Error as e: - raise ScriptError('Failed to parse .pypirc file: {}'.format(e)) - - if config is None: - raise ScriptError('Failed to parse .pypirc file') - - if 'pypi' not in config: - raise ScriptError('Missing [pypi] section in .pypirc file') - - if not (config['pypi'].get('username') and config['pypi'].get('password')): - raise ScriptError('Missing login/password pair for pypi repo') diff --git a/script/release/release/repository.py b/script/release/release/repository.py deleted file mode 100644 index a0281eaa3d8..00000000000 --- a/script/release/release/repository.py +++ /dev/null @@ -1,246 +0,0 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - -import os -import tempfile - -import requests -from git import GitCommandError -from git import Repo -from github import Github - -from .const import NAME -from .const import REPO_ROOT -from .utils import branch_name -from .utils import read_release_notes_from_changelog -from .utils import ScriptError - - -class Repository(object): - def __init__(self, root=None, gh_name=None): - if root is None: - root = REPO_ROOT - if gh_name is None: - gh_name = NAME - self.git_repo = Repo(root) - self.gh_client = Github(os.environ['GITHUB_TOKEN']) - self.gh_repo = self.gh_client.get_repo(gh_name) - - def create_release_branch(self, version, base=None): - print('Creating release branch {} based on {}...'.format(version, base or 'master')) - remote = self.find_remote(self.gh_repo.full_name) - br_name = branch_name(version) - remote.fetch() - if self.branch_exists(br_name): - raise ScriptError( - "Branch {} already exists locally. Please remove it before " - "running the release script, or use `resume` instead.".format( - br_name - ) - ) - if base is not None: - base = self.git_repo.tag('refs/tags/{}'.format(base)) - else: - base = 'refs/remotes/{}/master'.format(remote.name) - release_branch = self.git_repo.create_head(br_name, commit=base) - release_branch.checkout() - self.git_repo.git.merge('--strategy=ours', '--no-edit', '{}/release'.format(remote.name)) - with release_branch.config_writer() as cfg: - cfg.set_value('release', version) - return release_branch - - def find_remote(self, remote_name=None): - if not remote_name: - remote_name = self.gh_repo.full_name - for remote in self.git_repo.remotes: - for url in remote.urls: - if remote_name in url: - return remote - return None - - def create_bump_commit(self, bump_branch, version): - print('Creating bump commit...') - bump_branch.checkout() - self.git_repo.git.commit('-a', '-s', '-m "Bump {}"'.format(version), '--no-verify') - - def diff(self): - return self.git_repo.git.diff() - - def checkout_branch(self, name): - return self.git_repo.branches[name].checkout() - - def push_branch_to_remote(self, branch, remote_name=None): - print('Pushing branch {} to remote...'.format(branch.name)) - remote = self.find_remote(remote_name) - remote.push(refspec=branch, force=True) - - def branch_exists(self, name): - return name in [h.name for h in self.git_repo.heads] - - def create_release_pull_request(self, version): - return self.gh_repo.create_pull( - title='Bump {}'.format(version), - body='Automated release for docker-compose {}\n\n{}'.format( - version, read_release_notes_from_changelog() - ), - base='release', - head=branch_name(version), - ) - - def create_release(self, version, release_notes, **kwargs): - return self.gh_repo.create_git_release( - tag=version, name=version, message=release_notes, **kwargs - ) - - def find_release(self, version): - print('Retrieving release draft for {}'.format(version)) - releases = self.gh_repo.get_releases() - for release in releases: - if release.tag_name == version and release.title == version: - return release - return None - - def publish_release(self, release): - release.update_release( - name=release.title, - message=release.body, - draft=False, - prerelease=release.prerelease - ) - - def remove_release(self, version): - print('Removing release draft for {}'.format(version)) - releases = self.gh_repo.get_releases() - for release in releases: - if release.tag_name == version and release.title == version: - if not release.draft: - print( - 'The release at {} is no longer a draft. If you TRULY intend ' - 'to remove it, please do so manually.'.format(release.url) - ) - continue - release.delete_release() - - def remove_bump_branch(self, version, remote_name=None): - name = branch_name(version) - if not self.branch_exists(name): - return False - print('Removing local branch "{}"'.format(name)) - if self.git_repo.active_branch.name == name: - print('Active branch is about to be deleted. Checking out to master...') - try: - self.checkout_branch('master') - except GitCommandError: - raise ScriptError( - 'Unable to checkout master. Try stashing local changes before proceeding.' - ) - self.git_repo.branches[name].delete(self.git_repo, name, force=True) - print('Removing remote branch "{}"'.format(name)) - remote = self.find_remote(remote_name) - try: - remote.push(name, delete=True) - except GitCommandError as e: - if 'remote ref does not exist' in str(e): - return False - raise ScriptError( - 'Error trying to remove remote branch: {}'.format(e) - ) - return True - - def find_release_pr(self, version): - print('Retrieving release PR for {}'.format(version)) - name = branch_name(version) - open_prs = self.gh_repo.get_pulls(state='open') - for pr in open_prs: - if pr.head.ref == name: - print('Found matching PR #{}'.format(pr.number)) - return pr - print('No open PR for this release branch.') - return None - - def close_release_pr(self, version): - print('Retrieving and closing release PR for {}'.format(version)) - name = branch_name(version) - open_prs = self.gh_repo.get_pulls(state='open') - count = 0 - for pr in open_prs: - if pr.head.ref == name: - print('Found matching PR #{}'.format(pr.number)) - pr.edit(state='closed') - count += 1 - if count == 0: - print('No open PR for this release branch.') - return count - - def write_git_sha(self): - with open(os.path.join(REPO_ROOT, 'compose', 'GITSHA'), 'w') as f: - f.write(self.git_repo.head.commit.hexsha[:7]) - return self.git_repo.head.commit.hexsha[:7] - - def cherry_pick_prs(self, release_branch, ids): - if not ids: - return - release_branch.checkout() - for i in ids: - try: - i = int(i) - except ValueError as e: - raise ScriptError('Invalid PR id: {}'.format(e)) - print('Retrieving PR#{}'.format(i)) - pr = self.gh_repo.get_pull(i) - patch_data = requests.get(pr.patch_url).text - self.apply_patch(patch_data) - - def apply_patch(self, patch_data): - with tempfile.NamedTemporaryFile(mode='w', prefix='_compose_cherry', encoding='utf-8') as f: - f.write(patch_data) - f.flush() - self.git_repo.git.am('--3way', f.name) - - def get_prs_in_milestone(self, version): - milestones = self.gh_repo.get_milestones(state='open') - milestone = None - for ms in milestones: - if ms.title == version: - milestone = ms - break - if not milestone: - print('Didn\'t find a milestone matching "{}"'.format(version)) - return None - - issues = self.gh_repo.get_issues(milestone=milestone, state='all') - prs = [] - for issue in issues: - if issue.pull_request is not None: - prs.append(issue.number) - return sorted(prs) - - -def get_contributors(pr_data): - commits = pr_data.get_commits() - authors = {} - for commit in commits: - if not commit or not commit.author or not commit.author.login: - continue - author = commit.author.login - authors[author] = authors.get(author, 0) + 1 - return [x[0] for x in sorted(list(authors.items()), key=lambda x: x[1])] - - -def upload_assets(gh_release, files): - print('Uploading binaries and hash sums') - for filename, filedata in files.items(): - print('Uploading {}...'.format(filename)) - gh_release.upload_asset(filedata[0], content_type='application/octet-stream') - gh_release.upload_asset('{}.sha256'.format(filedata[0]), content_type='text/plain') - print('Uploading run.sh...') - gh_release.upload_asset( - os.path.join(REPO_ROOT, 'script', 'run', 'run.sh'), content_type='text/plain' - ) - - -def delete_assets(gh_release): - print('Removing previously uploaded assets') - for asset in gh_release.get_assets(): - print('Deleting asset {}'.format(asset.name)) - asset.delete_asset() diff --git a/script/release/release/utils.py b/script/release/release/utils.py deleted file mode 100644 index 977a0a71227..00000000000 --- a/script/release/release/utils.py +++ /dev/null @@ -1,85 +0,0 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - -import os -import re - -from .const import REPO_ROOT -from compose import const as compose_const - -section_header_re = re.compile(r'^[0-9]+\.[0-9]+\.[0-9]+ \([0-9]{4}-[01][0-9]-[0-3][0-9]\)$') - - -class ScriptError(Exception): - pass - - -def branch_name(version): - return 'bump-{}'.format(version) - - -def read_release_notes_from_changelog(): - with open(os.path.join(REPO_ROOT, 'CHANGELOG.md'), 'r') as f: - lines = f.readlines() - i = 0 - while i < len(lines): - if section_header_re.match(lines[i]): - break - i += 1 - - j = i + 1 - while j < len(lines): - if section_header_re.match(lines[j]): - break - j += 1 - - return ''.join(lines[i + 2:j - 1]) - - -def update_init_py_version(version): - path = os.path.join(REPO_ROOT, 'compose', '__init__.py') - with open(path, 'r') as f: - contents = f.read() - contents = re.sub(r"__version__ = '[0-9a-z.-]+'", "__version__ = '{}'".format(version), contents) - with open(path, 'w') as f: - f.write(contents) - - -def update_run_sh_version(version): - path = os.path.join(REPO_ROOT, 'script', 'run', 'run.sh') - with open(path, 'r') as f: - contents = f.read() - contents = re.sub(r'VERSION="[0-9a-z.-]+"', 'VERSION="{}"'.format(version), contents) - with open(path, 'w') as f: - f.write(contents) - - -def compatibility_matrix(): - result = {} - for engine_version in compose_const.API_VERSION_TO_ENGINE_VERSION.values(): - result[engine_version] = [] - for fmt, api_version in compose_const.API_VERSIONS.items(): - result[compose_const.API_VERSION_TO_ENGINE_VERSION[api_version]].append(fmt.vstring) - return result - - -def yesno(prompt, default=None): - """ - Prompt the user for a yes or no. - - Can optionally specify a default value, which will only be - used if they enter a blank line. - - Unrecognised input (anything other than "y", "n", "yes", - "no" or "") will return None. - """ - answer = input(prompt).strip().lower() - - if answer == "y" or answer == "yes": - return True - elif answer == "n" or answer == "no": - return False - elif answer == "": - return default - else: - return None diff --git a/script/release/setup-venv.sh b/script/release/setup-venv.sh deleted file mode 100755 index ab419be0cdd..00000000000 --- a/script/release/setup-venv.sh +++ /dev/null @@ -1,47 +0,0 @@ -#!/bin/bash - -debian_based() { test -f /etc/debian_version; } - -if test -z $VENV_DIR; then - VENV_DIR=./.release-venv -fi - -if test -z $PYTHONBIN; then - PYTHONBIN=$(which python3) - if test -z $PYTHONBIN; then - PYTHONBIN=$(which python) - fi -fi - -VERSION=$($PYTHONBIN -c "import sys; print('{}.{}'.format(*sys.version_info[0:2]))") -if test $(echo $VERSION | cut -d. -f1) -lt 3; then - echo "Python 3.3 or above is required" -fi - -if test $(echo $VERSION | cut -d. -f2) -lt 3; then - echo "Python 3.3 or above is required" -fi - -# Debian / Ubuntu workaround: -# https://askubuntu.com/questions/879437/ensurepip-is-disabled-in-debian-ubuntu-for-the-system-python -if debian_based; then - VENV_FLAGS="$VENV_FLAGS --without-pip" -fi - -$PYTHONBIN -m venv $VENV_DIR $VENV_FLAGS - -VENV_PYTHONBIN=$VENV_DIR/bin/python - -if debian_based; then - curl https://bootstrap.pypa.io/get-pip.py -o $VENV_DIR/get-pip.py - $VENV_PYTHONBIN $VENV_DIR/get-pip.py -fi - -$VENV_PYTHONBIN -m pip install -U Jinja2==2.10 \ - PyGithub==1.39 \ - GitPython==2.1.9 \ - requests==2.18.4 \ - setuptools==40.6.2 \ - twine==1.11.0 - -$VENV_PYTHONBIN setup.py develop From b2e9b83d469d338634a3f46ce7cf35a65b765d79 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Thu, 9 Jan 2020 16:15:34 +0100 Subject: [PATCH 3847/4072] update public CI so we run tests on same combinations of python+docker Signed-off-by: Nicolas De Loof --- Jenkinsfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index 35998bf96d4..5dd1101c2c7 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -1,8 +1,8 @@ #!groovy -def dockerVersions = ['19.03.5', '18.09.9'] +def dockerVersions = ['19.03.5'] def baseImages = ['alpine', 'debian'] -def pythonVersions = ['py27', 'py37'] +def pythonVersions = ['py37'] pipeline { agent none From a436fb953c4843f44dd9305ae188eb7d714fa49e Mon Sep 17 00:00:00 2001 From: Lumir Balhar Date: Fri, 10 Jan 2020 08:39:54 +0100 Subject: [PATCH 3848/4072] Remove indentation from test YAML Signed-off-by: Lumir Balhar --- tests/unit/config/config_test.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index f1398e84fa4..03d8688800b 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1772,13 +1772,13 @@ def test_load_yaml_with_yaml_error(self): invalid_yaml_file = os.path.join(tmpdir, 'docker-compose.yml') with open(invalid_yaml_file, mode="w") as invalid_yaml_file_fh: invalid_yaml_file_fh.write(""" - web: - this is bogus: ok: what +web: + this is bogus: ok: what """) with pytest.raises(ConfigurationError) as exc: config.load_yaml(str(invalid_yaml_file)) - assert 'line 3, column 36' in exc.exconly() + assert 'line 3, column 22' in exc.exconly() def test_load_yaml_with_bom(self): tmpdir = tempfile.mkdtemp('bom_yaml') From 3ea84fd9bcd185c30fe97655b6281ed781b11ce9 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Fri, 10 Jan 2020 16:10:59 +0000 Subject: [PATCH 3849/4072] Bump pytest from 3.6.3 to 5.3.2 Bumps [pytest](https://github.com/pytest-dev/pytest) from 3.6.3 to 5.3.2. - [Release notes](https://github.com/pytest-dev/pytest/releases) - [Changelog](https://github.com/pytest-dev/pytest/blob/master/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest/compare/3.6.3...5.3.2) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 496cefe1584..a641868f47c 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -2,6 +2,6 @@ coverage==4.5.4 ddt==1.2.2 flake8==3.7.9 mock==3.0.5 -pytest==5.1.1; python_version >= '3.5' +pytest==5.3.2; python_version >= '3.5' pytest==4.6.5; python_version < '3.5' pytest-cov==2.8.1 From 707a340304be39fd6885c8d26df3151eb28f14e9 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 13 Jan 2020 13:32:31 +0000 Subject: [PATCH 3850/4072] Bump certifi from 2017.4.17 to 2019.11.28 Bumps [certifi](https://github.com/certifi/python-certifi) from 2017.4.17 to 2019.11.28. - [Release notes](https://github.com/certifi/python-certifi/releases) - [Commits](https://github.com/certifi/python-certifi/compare/2017.04.17...2019.11.28) Signed-off-by: dependabot-preview[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 50a2e6fc097..3f960ef6cba 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ backports.shutil_get_terminal_size==1.0.0 backports.ssl-match-hostname==3.5.0.1; python_version < '3' cached-property==1.3.0 -certifi==2017.4.17 +certifi==2019.11.28 chardet==3.0.4 colorama==0.4.3; sys_platform == 'win32' docker==4.1.0 From 2cdd2f626b6a2131afdd1f8c542a17c350948b33 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 13 Jan 2020 13:33:13 +0000 Subject: [PATCH 3851/4072] Bump coverage from 4.5.4 to 5.0.3 Bumps [coverage](https://github.com/nedbat/coveragepy) from 4.5.4 to 5.0.3. - [Release notes](https://github.com/nedbat/coveragepy/releases) - [Changelog](https://github.com/nedbat/coveragepy/blob/master/CHANGES.rst) - [Commits](https://github.com/nedbat/coveragepy/compare/coverage-4.5.4...coverage-5.0.3) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index a641868f47c..6f670a380aa 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,4 +1,4 @@ -coverage==4.5.4 +coverage==5.0.3 ddt==1.2.2 flake8==3.7.9 mock==3.0.5 From 661afb400330db289cf9002b19c183d3ec562fd2 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 13 Jan 2020 13:34:32 +0000 Subject: [PATCH 3852/4072] Bump paramiko from 2.6.0 to 2.7.1 Bumps [paramiko](https://github.com/paramiko/paramiko) from 2.6.0 to 2.7.1. - [Release notes](https://github.com/paramiko/paramiko/releases) - [Changelog](https://github.com/paramiko/paramiko/blob/master/NEWS) - [Commits](https://github.com/paramiko/paramiko/compare/2.6.0...2.7.1) Signed-off-by: dependabot-preview[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 50a2e6fc097..281c03ba33c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,7 +13,7 @@ functools32==3.2.3.post2; python_version < '3.2' idna==2.8 ipaddress==1.0.18 jsonschema==3.2.0 -paramiko==2.6.0 +paramiko==2.7.1 pypiwin32==219; sys_platform == 'win32' and python_version < '3.6' pypiwin32==223; sys_platform == 'win32' and python_version >= '3.6' PySocks==1.7.1 From 093cc2c089ab77978ca993eee09c2f53111730b1 Mon Sep 17 00:00:00 2001 From: Kevin Roy Date: Mon, 18 Nov 2019 15:30:35 +0100 Subject: [PATCH 3853/4072] Allow setting compatibility options from environment Signed-off-by: Kevin Roy --- compose/cli/command.py | 14 ++++++++-- tests/acceptance/cli_test.py | 53 ++++++++++++++++++++++++------------ 2 files changed, 47 insertions(+), 20 deletions(-) diff --git a/compose/cli/command.py b/compose/cli/command.py index 1fa8a17a233..cf77237ac0f 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -59,7 +59,7 @@ def project_from_options(project_dir, options, additional_options={}): tls_config=tls_config_from_options(options, environment), environment=environment, override_dir=override_dir, - compatibility=options.get('--compatibility'), + compatibility=compatibility_from_options(project_dir, options, environment), interpolate=(not additional_options.get('--no-interpolate')), environment_file=environment_file ) @@ -90,7 +90,7 @@ def get_config_from_options(base_dir, options, additional_options={}): ) return config.load( config.find(base_dir, config_path, environment, override_dir), - options.get('--compatibility'), + compatibility_from_options(config_path, options, environment), not additional_options.get('--no-interpolate') ) @@ -198,3 +198,13 @@ def normalize_name(name): return normalize_name(project) return 'default' + + +def compatibility_from_options(working_dir, options=None, environment=None): + """Get compose v3 compatibility from --compatibility option + or from COMPOSE_COMPATIBILITY environment variable.""" + + compatibility_option = options.get('--compatibility') + compatibility_environment = environment.get_boolean('COMPOSE_COMPATIBILITY') + + return compatibility_option or compatibility_environment diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index d1e96fdc818..3a207c83a4e 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -43,6 +43,24 @@ BUILD_CACHE_TEXT = 'Using cache' BUILD_PULL_TEXT = 'Status: Image is up to date for busybox:1.27.2' +COMPOSE_COMPATIBILITY_DICT = { + 'version': '2.3', + 'volumes': {'foo': {'driver': 'default'}}, + 'networks': {'bar': {}}, + 'services': { + 'foo': { + 'command': '/bin/true', + 'image': 'alpine:3.10.1', + 'scale': 3, + 'restart': 'always:7', + 'mem_limit': '300M', + 'mem_reservation': '100M', + 'cpus': 0.7, + 'volumes': ['foo:/bar:rw'], + 'networks': {'bar': None}, + } + }, +} def start_process(base_dir, options): @@ -564,24 +582,23 @@ def test_config_compatibility_mode(self): self.base_dir = 'tests/fixtures/compatibility-mode' result = self.dispatch(['--compatibility', 'config']) - assert yaml.safe_load(result.stdout) == { - 'version': '2.3', - 'volumes': {'foo': {'driver': 'default'}}, - 'networks': {'bar': {}}, - 'services': { - 'foo': { - 'command': '/bin/true', - 'image': 'alpine:3.10.1', - 'scale': 3, - 'restart': 'always:7', - 'mem_limit': '300M', - 'mem_reservation': '100M', - 'cpus': 0.7, - 'volumes': ['foo:/bar:rw'], - 'networks': {'bar': None}, - } - }, - } + assert yaml.load(result.stdout) == COMPOSE_COMPATIBILITY_DICT + + @mock.patch.dict(os.environ) + def test_config_compatibility_mode_from_env(self): + self.base_dir = 'tests/fixtures/compatibility-mode' + os.environ['COMPOSE_COMPATIBILITY'] = 'true' + result = self.dispatch(['config']) + + assert yaml.load(result.stdout) == COMPOSE_COMPATIBILITY_DICT + + @mock.patch.dict(os.environ) + def test_config_compatibility_mode_from_env_and_option_precedence(self): + self.base_dir = 'tests/fixtures/compatibility-mode' + os.environ['COMPOSE_COMPATIBILITY'] = 'false' + result = self.dispatch(['--compatibility', 'config']) + + assert yaml.load(result.stdout) == COMPOSE_COMPATIBILITY_DICT def test_ps(self): self.project.get_service('simple').create_container() From 120a7b1b067cf5c4c68afeadecd5541eafeaed68 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Tue, 14 Jan 2020 17:23:07 +0000 Subject: [PATCH 3854/4072] Bump ipaddress from 1.0.18 to 1.0.23 Bumps [ipaddress](https://github.com/phihag/ipaddress) from 1.0.18 to 1.0.23. - [Release notes](https://github.com/phihag/ipaddress/releases) - [Commits](https://github.com/phihag/ipaddress/compare/v1.0.18...v1.0.23) Signed-off-by: dependabot-preview[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 281c03ba33c..9149312428f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,7 +11,7 @@ docopt==0.6.2 enum34==1.1.6; python_version < '3.4' functools32==3.2.3.post2; python_version < '3.2' idna==2.8 -ipaddress==1.0.18 +ipaddress==1.0.23 jsonschema==3.2.0 paramiko==2.7.1 pypiwin32==219; sys_platform == 'win32' and python_version < '3.6' From dafece4ae56b0649cb349fcf6929c1b10f87c522 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Tue, 14 Jan 2020 17:26:05 +0000 Subject: [PATCH 3855/4072] Bump cached-property from 1.3.0 to 1.5.1 Bumps [cached-property](https://github.com/pydanny/cached-property) from 1.3.0 to 1.5.1. - [Release notes](https://github.com/pydanny/cached-property/releases) - [Changelog](https://github.com/pydanny/cached-property/blob/master/HISTORY.rst) - [Commits](https://github.com/pydanny/cached-property/compare/1.3.0...1.5.1) Signed-off-by: dependabot-preview[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 486307e77ac..eedda62b54a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ backports.shutil_get_terminal_size==1.0.0 backports.ssl-match-hostname==3.5.0.1; python_version < '3' -cached-property==1.3.0 +cached-property==1.5.1 certifi==2019.11.28 chardet==3.0.4 colorama==0.4.3; sys_platform == 'win32' From a92a8eb508456effd5388c8eb7c172ec54ca9936 Mon Sep 17 00:00:00 2001 From: Christopher Crone Date: Fri, 29 Nov 2019 09:58:03 +0100 Subject: [PATCH 3856/4072] Bump macOS dependencies - Python 3.7.5 - OpenSSL 1.1.1d Signed-off-by: Christopher Crone --- script/setup/osx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/script/setup/osx b/script/setup/osx index 08420fb2362..077b5b497a9 100755 --- a/script/setup/osx +++ b/script/setup/osx @@ -13,13 +13,13 @@ if ! [ ${DEPLOYMENT_TARGET} == "$(macos_version)" ]; then SDK_SHA1=dd228a335194e3392f1904ce49aff1b1da26ca62 fi -OPENSSL_VERSION=1.1.1c +OPENSSL_VERSION=1.1.1d OPENSSL_URL=https://www.openssl.org/source/openssl-${OPENSSL_VERSION}.tar.gz -OPENSSL_SHA1=71b830a077276cbeccc994369538617a21bee808 +OPENSSL_SHA1=056057782325134b76d1931c48f2c7e6595d7ef4 -PYTHON_VERSION=3.7.4 +PYTHON_VERSION=3.7.5 PYTHON_URL=https://www.python.org/ftp/python/${PYTHON_VERSION}/Python-${PYTHON_VERSION}.tgz -PYTHON_SHA1=fb1d764be8a9dcd40f2f152a610a0ab04e0d0ed3 +PYTHON_SHA1=8b0311d4cca19f0ea9181731189fa33c9f5aedf9 # # Install prerequisites. From a2cdffeeee4802882ac88ccc0159e6f2297fe52f Mon Sep 17 00:00:00 2001 From: Christopher Crone Date: Fri, 29 Nov 2019 11:57:18 +0100 Subject: [PATCH 3857/4072] Bump Linux dependencies - Alpine 3.10.3 - Debian Stretch 20191118 - Python 3.7.5 Signed-off-by: Christopher Crone --- Dockerfile | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index 64de7789087..ed270eb4ae2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,9 @@ -ARG DOCKER_VERSION=18.09.7 -ARG PYTHON_VERSION=3.7.4 +ARG DOCKER_VERSION=19.03.5 +ARG PYTHON_VERSION=3.7.5 ARG BUILD_ALPINE_VERSION=3.10 ARG BUILD_DEBIAN_VERSION=slim-stretch -ARG RUNTIME_ALPINE_VERSION=3.10.1 -ARG RUNTIME_DEBIAN_VERSION=stretch-20190812-slim +ARG RUNTIME_ALPINE_VERSION=3.10.3 +ARG RUNTIME_DEBIAN_VERSION=stretch-20191118-slim ARG BUILD_PLATFORM=alpine From 53d00f76777d111c192b0674eb89f2073da73cf0 Mon Sep 17 00:00:00 2001 From: yukihira1992 Date: Tue, 14 Jan 2020 20:13:10 +0900 Subject: [PATCH 3858/4072] Refactored mutable default values. Signed-off-by: yukihira1992 --- compose/cli/command.py | 6 ++++-- compose/project.py | 3 ++- compose/service.py | 4 ++-- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/compose/cli/command.py b/compose/cli/command.py index cf77237ac0f..2fabbe18a62 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -40,7 +40,8 @@ } -def project_from_options(project_dir, options, additional_options={}): +def project_from_options(project_dir, options, additional_options=None): + additional_options = additional_options or {} override_dir = options.get('--project-directory') environment_file = options.get('--env-file') environment = Environment.from_env_file(override_dir or project_dir, environment_file) @@ -81,7 +82,8 @@ def set_parallel_limit(environment): parallel.GlobalLimit.set_global_limit(parallel_limit) -def get_config_from_options(base_dir, options, additional_options={}): +def get_config_from_options(base_dir, options, additional_options=None): + additional_options = additional_options or {} override_dir = options.get('--project-directory') environment_file = options.get('--env-file') environment = Environment.from_env_file(override_dir or base_dir, environment_file) diff --git a/compose/project.py b/compose/project.py index a7770ddc9db..696c8b04023 100644 --- a/compose/project.py +++ b/compose/project.py @@ -87,10 +87,11 @@ def labels(self, one_off=OneOffFilter.exclude, legacy=False): return labels @classmethod - def from_config(cls, name, config_data, client, default_platform=None, extra_labels=[]): + def from_config(cls, name, config_data, client, default_platform=None, extra_labels=None): """ Construct a Project from a config.Config object. """ + extra_labels = extra_labels or [] use_networking = (config_data.version and config_data.version != V1) networks = build_networks(name, config_data, client) project_networks = ProjectNetworks.from_services( diff --git a/compose/service.py b/compose/service.py index 024e7fbde9c..ebe237b8cfc 100644 --- a/compose/service.py +++ b/compose/service.py @@ -185,7 +185,7 @@ def __init__( scale=1, pid_mode=None, default_platform=None, - extra_labels=[], + extra_labels=None, **options ): self.name = name @@ -201,7 +201,7 @@ def __init__( self.scale_num = scale self.default_platform = default_platform self.options = options - self.extra_labels = extra_labels + self.extra_labels = extra_labels or [] def __repr__(self): return ''.format(self.name) From 387f5e4c96ea841a8855d4e887b6f2a0e54c5cc9 Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Thu, 16 Jan 2020 13:46:47 +0100 Subject: [PATCH 3859/4072] Bump pyinstaller to 3.6 Signed-off-by: Ulysses Souza --- requirements-build.txt | 2 +- script/build/linux-entrypoint | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements-build.txt b/requirements-build.txt index 2a1cd7d6b32..9126f8af9d8 100644 --- a/requirements-build.txt +++ b/requirements-build.txt @@ -1 +1 @@ -pyinstaller==3.5 +pyinstaller==3.6 diff --git a/script/build/linux-entrypoint b/script/build/linux-entrypoint index d607dd5c2aa..d75b9927d09 100755 --- a/script/build/linux-entrypoint +++ b/script/build/linux-entrypoint @@ -24,7 +24,7 @@ if [ ! -z "${BUILD_BOOTLOADER}" ]; then git clone --single-branch --branch develop https://github.com/pyinstaller/pyinstaller.git /tmp/pyinstaller cd /tmp/pyinstaller/bootloader # Checkout commit corresponding to version in requirements-build - git checkout v3.5 + git checkout v3.6 "${VENV}"/bin/python3 ./waf configure --no-lsb all "${VENV}"/bin/pip3 install .. cd "${CODE_PATH}" From a6b602d086dee4b2d17b3cf731163a77f2d617db Mon Sep 17 00:00:00 2001 From: Ben Thorner Date: Tue, 8 Oct 2019 17:14:23 +0100 Subject: [PATCH 3860/4072] Support attaching to dependencies on up When using the 'up' command, only services listed as arguments are attached to, which can be very different to the 'no argument' case if a service has many and deep dependencies: - It's not clear when dependencies have failed to start. Have to run 'compose ps' separately to find out. - It's not clear when dependencies are erroring. Have to run 'compose logs' separately to find out. With a simple setup, it's possible to work around theses issue by using the 'up' command without arguments. But when there are lots of 'top-level' services, with common dependencies, in a single config, using 'up' without arguments isn't practical due to resource limits and the sheer volume of output from other services. This introduces a new '--attach-dependencies' flag to optionally attach dependent containers as part of the 'up' command. This makes their logs visible in the output, alongside the listed services. It also means we benefit from the '--abort-on-container-exit' behaviour when dependencies fail to start, giving more visibility of the failure. Signed-off-by: Ben Thorner --- compose/cli/main.py | 18 ++++++++++----- contrib/completion/bash/docker-compose | 2 +- contrib/completion/zsh/_docker-compose | 3 ++- tests/acceptance/cli_test.py | 20 +++++++++++++++++ .../docker-compose.yml | 10 +++++++++ .../docker-compose.yml | 10 +++++++++ tests/unit/cli/main_test.py | 22 ++++++++++++++----- 7 files changed, 72 insertions(+), 13 deletions(-) create mode 100644 tests/fixtures/abort-on-container-exit-dependencies/docker-compose.yml create mode 100644 tests/fixtures/echo-services-dependencies/docker-compose.yml diff --git a/compose/cli/main.py b/compose/cli/main.py index 200d4eeaca9..b6ad404dcde 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -1012,6 +1012,7 @@ def up(self, options): --build Build images before starting containers. --abort-on-container-exit Stops all containers if any container was stopped. Incompatible with -d. + --attach-dependencies Attach to dependent containers -t, --timeout TIMEOUT Use this timeout in seconds for container shutdown when attached or when containers are already running. (default: 10) @@ -1033,16 +1034,18 @@ def up(self, options): remove_orphans = options['--remove-orphans'] detached = options.get('--detach') no_start = options.get('--no-start') + attach_dependencies = options.get('--attach-dependencies') - if detached and (cascade_stop or exit_value_from): - raise UserError("--abort-on-container-exit and -d cannot be combined.") + if detached and (cascade_stop or exit_value_from or attach_dependencies): + raise UserError( + "-d cannot be combined with --abort-on-container-exit or --attach-dependencies.") ignore_orphans = self.toplevel_environment.get_boolean('COMPOSE_IGNORE_ORPHANS') if ignore_orphans and remove_orphans: raise UserError("COMPOSE_IGNORE_ORPHANS and --remove-orphans cannot be combined.") - opts = ['--detach', '--abort-on-container-exit', '--exit-code-from'] + opts = ['--detach', '--abort-on-container-exit', '--exit-code-from', '--attach-dependencies'] for excluded in [x for x in opts if options.get(x) and no_start]: raise UserError('--no-start and {} cannot be combined.'.format(excluded)) @@ -1087,7 +1090,10 @@ def up(rebuild): if detached or no_start: return - attached_containers = filter_containers_to_service_names(to_attach, service_names) + attached_containers = filter_attached_containers( + to_attach, + service_names, + attach_dependencies) log_printer = log_printer_from_project( self.project, @@ -1392,8 +1398,8 @@ def log_printer_from_project( log_args=log_args) -def filter_containers_to_service_names(containers, service_names): - if not service_names: +def filter_attached_containers(containers, service_names, attach_dependencies=False): + if attach_dependencies or not service_names: return containers return [ diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index 23c48b7f4a2..ad0ce44c177 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -545,7 +545,7 @@ _docker_compose_up() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "--abort-on-container-exit --always-recreate-deps --build -d --detach --exit-code-from --force-recreate --help --no-build --no-color --no-deps --no-recreate --no-start --renew-anon-volumes -V --remove-orphans --scale --timeout -t" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--abort-on-container-exit --always-recreate-deps --attach-dependencies --build -d --detach --exit-code-from --force-recreate --help --no-build --no-color --no-deps --no-recreate --no-start --renew-anon-volumes -V --remove-orphans --scale --timeout -t" -- "$cur" ) ) ;; *) __docker_compose_complete_services diff --git a/contrib/completion/zsh/_docker-compose b/contrib/completion/zsh/_docker-compose index 277bf0d3c22..de14149844a 100755 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -284,7 +284,7 @@ __docker-compose_subcommand() { (up) _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.]' \ + '(--abort-on-container-exit)-d[Detached mode: Run containers in the background, print new container names. Incompatible with --abort-on-container-exit and --attach-dependencies.]' \ $opts_no_color \ $opts_no_deps \ $opts_force_recreate \ @@ -292,6 +292,7 @@ __docker-compose_subcommand() { $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.]" \ + "(-d)--attach-dependencies[Attach to dependent containers. 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: " \ '--scale[SERVICE=NUM Scale SERVICE to NUM instances. Overrides the `scale` setting in the Compose file if present.]:service scale SERVICE=NUM: ' \ '--exit-code-from=[Return the exit code of the selected service container. Implies --abort-on-container-exit]:service:__docker-compose_services' \ diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 3a207c83a4e..7fa7fc548cd 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1571,6 +1571,26 @@ def test_up_with_no_deps(self): assert len(db.containers()) == 0 assert len(console.containers()) == 0 + def test_up_with_attach_dependencies(self): + self.base_dir = 'tests/fixtures/echo-services-dependencies' + result = self.dispatch(['up', '--attach-dependencies', '--no-color', 'simple'], None) + simple_name = self.project.get_service('simple').containers(stopped=True)[0].name_without_project + another_name = self.project.get_service('another').containers( + stopped=True + )[0].name_without_project + + assert '{} | simple'.format(simple_name) in result.stdout + assert '{} | another'.format(another_name) in result.stdout + + def test_up_handles_aborted_dependencies(self): + self.base_dir = 'tests/fixtures/abort-on-container-exit-dependencies' + proc = start_process( + self.base_dir, + ['up', 'simple', '--attach-dependencies', '--abort-on-container-exit']) + wait_on_condition(ContainerCountCondition(self.project, 0)) + proc.wait() + assert proc.returncode == 1 + def test_up_with_force_recreate(self): self.dispatch(['up', '-d'], None) service = self.project.get_service('simple') diff --git a/tests/fixtures/abort-on-container-exit-dependencies/docker-compose.yml b/tests/fixtures/abort-on-container-exit-dependencies/docker-compose.yml new file mode 100644 index 00000000000..cd10c851c1b --- /dev/null +++ b/tests/fixtures/abort-on-container-exit-dependencies/docker-compose.yml @@ -0,0 +1,10 @@ +version: "2.0" +services: + simple: + image: busybox:1.31.0-uclibc + command: top + depends_on: + - another + another: + image: busybox:1.31.0-uclibc + command: ls /thecakeisalie diff --git a/tests/fixtures/echo-services-dependencies/docker-compose.yml b/tests/fixtures/echo-services-dependencies/docker-compose.yml new file mode 100644 index 00000000000..5329e0033df --- /dev/null +++ b/tests/fixtures/echo-services-dependencies/docker-compose.yml @@ -0,0 +1,10 @@ +version: "2.0" +services: + simple: + image: busybox:1.31.0-uclibc + command: echo simple + depends_on: + - another + another: + image: busybox:1.31.0-uclibc + command: echo another diff --git a/tests/unit/cli/main_test.py b/tests/unit/cli/main_test.py index aadb9d459db..067c74f0b0e 100644 --- a/tests/unit/cli/main_test.py +++ b/tests/unit/cli/main_test.py @@ -12,7 +12,7 @@ from compose.cli.main import build_one_off_container_options from compose.cli.main import call_docker from compose.cli.main import convergence_strategy_from_opts -from compose.cli.main import filter_containers_to_service_names +from compose.cli.main import filter_attached_containers from compose.cli.main import get_docker_start_call from compose.cli.main import setup_console_handler from compose.cli.main import warn_for_swarm_mode @@ -37,7 +37,7 @@ def logging_handler(): class TestCLIMainTestCase(object): - def test_filter_containers_to_service_names(self): + def test_filter_attached_containers(self): containers = [ mock_container('web', 1), mock_container('web', 2), @@ -46,17 +46,29 @@ def test_filter_containers_to_service_names(self): mock_container('another', 1), ] service_names = ['web', 'db'] - actual = filter_containers_to_service_names(containers, service_names) + actual = filter_attached_containers(containers, service_names) assert actual == containers[:3] - def test_filter_containers_to_service_names_all(self): + def test_filter_attached_containers_with_dependencies(self): + containers = [ + mock_container('web', 1), + mock_container('web', 2), + mock_container('db', 1), + mock_container('other', 1), + mock_container('another', 1), + ] + service_names = ['web', 'db'] + actual = filter_attached_containers(containers, service_names, attach_dependencies=True) + assert actual == containers + + def test_filter_attached_containers_all(self): containers = [ mock_container('web', 1), mock_container('db', 1), mock_container('other', 1), ] service_names = [] - actual = filter_containers_to_service_names(containers, service_names) + actual = filter_attached_containers(containers, service_names) assert actual == containers def test_warning_in_swarm_mode(self): From 84dad1a0e6b4684579dd495c291216b73a9292f6 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Mon, 20 Jan 2020 16:18:02 +0100 Subject: [PATCH 3861/4072] Compute changelog by searching previous tag .. even from a tag Signed-off-by: Nicolas De Loof --- script/release/generate_changelog.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/release/generate_changelog.sh b/script/release/generate_changelog.sh index 783e744007c..2a45e87fa0b 100755 --- a/script/release/generate_changelog.sh +++ b/script/release/generate_changelog.sh @@ -10,7 +10,7 @@ set -x git config --add remote.origin.fetch +refs/pull/*/head:refs/remotes/origin/pull/* git fetch origin -RANGE=${1:-"$(git describe --tags --abbrev=0)..HEAD"} +RANGE=${1:-"$(git describe --tags --abbrev=0 HEAD^)..HEAD"} echo "Generate changelog for range ${RANGE}" echo From 1c28b5a5d006b75993541c22ad7f5651273c8d5f Mon Sep 17 00:00:00 2001 From: Sebastian Pipping Date: Mon, 20 Jan 2020 19:48:40 +0100 Subject: [PATCH 3862/4072] setup.py: Add missing test depencendy ddt Signed-off-by: Sebastian Pipping --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index 110441dca62..21ab2d01d83 100644 --- a/setup.py +++ b/setup.py @@ -44,6 +44,7 @@ def find_version(*file_paths): tests_require = [ + 'ddt >= 1.2.2, < 2', 'pytest < 6', ] From 9887121c2c0d21efca2d03ca810249f34ab4727a Mon Sep 17 00:00:00 2001 From: Sebastian Pipping Date: Mon, 20 Jan 2020 19:49:25 +0100 Subject: [PATCH 3863/4072] setup.py: Expose test dependencies as extra "tests" Signed-off-by: Sebastian Pipping --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index 21ab2d01d83..548e2bc9398 100644 --- a/setup.py +++ b/setup.py @@ -60,6 +60,7 @@ def find_version(*file_paths): 'ipaddress >= 1.0.16, < 2'], ':sys_platform == "win32"': ['colorama >= 0.4, < 1'], 'socks': ['PySocks >= 1.5.6, != 1.5.7, < 2'], + 'tests': tests_require, } From 8ad480546f5253182d83fdaab02fa622bd107002 Mon Sep 17 00:00:00 2001 From: Nicolas De loof Date: Wed, 22 Jan 2020 13:55:24 +0100 Subject: [PATCH 3864/4072] Tag as `x.y.z` without `v` prefix Signed-off-by: Nicolas De Loof Close https://github.com/docker/compose/issues/7168 --- script/release/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/release/README.md b/script/release/README.md index d53f67606a8..022d3541786 100644 --- a/script/release/README.md +++ b/script/release/README.md @@ -5,5 +5,5 @@ The release process is fully automated by `Release.Jenkinsfile`. ## Usage 1. edit `compose/__init__.py` to set release version number -1. commit and tag as `v{major}.{minor}.{patch}` +1. commit and tag as `{major}.{minor}.{patch}` 1. edit `compose/__init__.py` again to set next development version number From a9c79bd5b1d21437d73872c2d0c231a93fb1ee54 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Wed, 22 Jan 2020 17:34:43 +0100 Subject: [PATCH 3865/4072] Force sha256 file to be ASCII encoded Signed-off-by: Nicolas De Loof --- Release.Jenkinsfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Release.Jenkinsfile b/Release.Jenkinsfile index 96aa01530fa..7a46e3f64ff 100644 --- a/Release.Jenkinsfile +++ b/Release.Jenkinsfile @@ -296,6 +296,6 @@ def checksum(filepath) { if (isUnix()) { sh "openssl sha256 -r -out ${filepath}.sha256 ${filepath}" } else { - powershell "(Get-FileHash -Path ${filepath} -Algorithm SHA256 | % hash) + ' *${filepath}' > ${filepath}.sha256" + powershell "(Get-FileHash -Path ${filepath} -Algorithm SHA256 | % hash).ToLower() + ' *${filepath}' | Out-File -encoding ascii ${filepath}.sha256" } } From a259c48ae9755e292dab9ca11cf0307feab8007b Mon Sep 17 00:00:00 2001 From: Christopher Crone Date: Thu, 23 Jan 2020 11:29:38 +0100 Subject: [PATCH 3866/4072] Bump Linux and Python * Alpine 3.10 to 3.11 * Debian Stretch 20191118 to 20191224 * Python 3.7.5 to 3.7.6 Signed-off-by: Christopher Crone --- Dockerfile | 8 ++++---- script/setup/osx | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Dockerfile b/Dockerfile index ed270eb4ae2..918c6876b9d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,9 @@ ARG DOCKER_VERSION=19.03.5 -ARG PYTHON_VERSION=3.7.5 -ARG BUILD_ALPINE_VERSION=3.10 +ARG PYTHON_VERSION=3.7.6 +ARG BUILD_ALPINE_VERSION=3.11 ARG BUILD_DEBIAN_VERSION=slim-stretch -ARG RUNTIME_ALPINE_VERSION=3.10.3 -ARG RUNTIME_DEBIAN_VERSION=stretch-20191118-slim +ARG RUNTIME_ALPINE_VERSION=3.11.3 +ARG RUNTIME_DEBIAN_VERSION=stretch-20191224-slim ARG BUILD_PLATFORM=alpine diff --git a/script/setup/osx b/script/setup/osx index 077b5b497a9..00cca06cf8b 100755 --- a/script/setup/osx +++ b/script/setup/osx @@ -17,9 +17,9 @@ OPENSSL_VERSION=1.1.1d OPENSSL_URL=https://www.openssl.org/source/openssl-${OPENSSL_VERSION}.tar.gz OPENSSL_SHA1=056057782325134b76d1931c48f2c7e6595d7ef4 -PYTHON_VERSION=3.7.5 +PYTHON_VERSION=3.7.6 PYTHON_URL=https://www.python.org/ftp/python/${PYTHON_VERSION}/Python-${PYTHON_VERSION}.tgz -PYTHON_SHA1=8b0311d4cca19f0ea9181731189fa33c9f5aedf9 +PYTHON_SHA1=4642680fbf9a9a5382597dc0e9faa058fdfd94e2 # # Install prerequisites. From 8d5023e1caab1c8f190f16509fc097bb4d00249b Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Thu, 23 Jan 2020 10:39:31 +0100 Subject: [PATCH 3867/4072] Enforce Python37 in the creation of virtualenv Signed-off-by: Ulysses Souza --- script/build/windows.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/script/build/windows.ps1 b/script/build/windows.ps1 index 4c7a8bed56c..7ba5ebde29e 100644 --- a/script/build/windows.ps1 +++ b/script/build/windows.ps1 @@ -6,7 +6,7 @@ # # http://git-scm.com/download/win # -# 2. Install Python 3.7.2: +# 2. Install Python 3.7.x: # # https://www.python.org/downloads/ # @@ -39,7 +39,7 @@ if (Test-Path venv) { Get-ChildItem -Recurse -Include *.pyc | foreach ($_) { Remove-Item $_.FullName } # Create virtualenv -virtualenv .\venv +virtualenv -p C:\Python37\python.exe .\venv # pip and pyinstaller generate lots of warnings, so we need to ignore them $ErrorActionPreference = "Continue" From 6d2658ea65bc01655d0615bdb8d023e1982ca681 Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Thu, 16 Jan 2020 09:51:58 +0100 Subject: [PATCH 3868/4072] Add python-dotenv Signed-off-by: Ulysses Souza --- compose/config/environment.py | 16 +++--------- requirements.txt | 1 + tests/unit/config/environment_test.py | 37 +++++++++++++++------------ 3 files changed, 24 insertions(+), 30 deletions(-) diff --git a/compose/config/environment.py b/compose/config/environment.py index 696356f3246..6afbfc97244 100644 --- a/compose/config/environment.py +++ b/compose/config/environment.py @@ -1,12 +1,11 @@ from __future__ import absolute_import from __future__ import unicode_literals -import codecs -import contextlib import logging import os import re +import dotenv import six from ..const import IS_WINDOWS_PLATFORM @@ -39,17 +38,8 @@ def env_vars_from_file(filename): raise EnvFileNotFound("Couldn't find env file: {}".format(filename)) elif not os.path.isfile(filename): raise EnvFileNotFound("{} is not a file.".format(filename)) - env = {} - with contextlib.closing(codecs.open(filename, 'r', 'utf-8-sig')) as fileobj: - for line in fileobj: - line = line.strip() - if line and not line.startswith('#'): - try: - k, v = split_env(line) - env[k] = v - except ConfigurationError as e: - raise ConfigurationError('In file {}: {}'.format(filename, e.msg)) - return env + + return dotenv.dotenv_values(dotenv_path=filename, encoding='utf-8-sig') class Environment(dict): diff --git a/requirements.txt b/requirements.txt index 76556d652ed..ee57c26b5af 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,6 +17,7 @@ paramiko==2.7.1 pypiwin32==219; sys_platform == 'win32' and python_version < '3.6' pypiwin32==223; sys_platform == 'win32' and python_version >= '3.6' PySocks==1.7.1 +python-dotenv==0.10.5 PyYAML==5.3 requests==2.22.0 six==1.12.0 diff --git a/tests/unit/config/environment_test.py b/tests/unit/config/environment_test.py index 186702db127..7e394d248f5 100644 --- a/tests/unit/config/environment_test.py +++ b/tests/unit/config/environment_test.py @@ -8,15 +8,18 @@ import shutil import tempfile -import pytest +from ddt import data +from ddt import ddt +from ddt import unpack from compose.config.environment import env_vars_from_file from compose.config.environment import Environment -from compose.config.errors import ConfigurationError from tests import unittest +@ddt class EnvironmentTest(unittest.TestCase): + @classmethod def test_get_simple(self): env = Environment({ 'FOO': 'bar', @@ -28,12 +31,14 @@ def test_get_simple(self): assert env.get('BAR') == '1' assert env.get('BAZ') == '' + @classmethod def test_get_undefined(self): env = Environment({ 'FOO': 'bar' }) assert env.get('FOOBAR') is None + @classmethod def test_get_boolean(self): env = Environment({ 'FOO': '', @@ -48,20 +53,18 @@ def test_get_boolean(self): assert env.get_boolean('FOOBAR') is True assert env.get_boolean('UNDEFINED') is False - def test_env_vars_from_file_bom(self): + @data( + ('unicode exclude test', '\ufeffPARK_BOM=박봄\n', {'PARK_BOM': '박봄'}), + ('export prefixed test', 'export PREFIXED_VARS=yes\n', {"PREFIXED_VARS": "yes"}), + ('quoted vars test', "QUOTED_VARS='yes'\n", {"QUOTED_VARS": "yes"}), + ('double quoted vars test', 'DOUBLE_QUOTED_VARS="yes"\n', {"DOUBLE_QUOTED_VARS": "yes"}), + ('extra spaces test', 'SPACES_VARS = "yes"\n', {"SPACES_VARS": "yes"}), + ) + @unpack + def test_env_vars(self, test_name, content, expected): tmpdir = tempfile.mkdtemp('env_file') self.addCleanup(shutil.rmtree, tmpdir) - with codecs.open('{}/bom.env'.format(str(tmpdir)), 'w', encoding='utf-8') as f: - f.write('\ufeffPARK_BOM=박봄\n') - assert env_vars_from_file(str(os.path.join(tmpdir, 'bom.env'))) == { - 'PARK_BOM': '박봄' - } - - def test_env_vars_from_file_whitespace(self): - tmpdir = tempfile.mkdtemp('env_file') - self.addCleanup(shutil.rmtree, tmpdir) - with codecs.open('{}/whitespace.env'.format(str(tmpdir)), 'w', encoding='utf-8') as f: - f.write('WHITESPACE =yes\n') - with pytest.raises(ConfigurationError) as exc: - env_vars_from_file(str(os.path.join(tmpdir, 'whitespace.env'))) - assert 'environment variable' in exc.exconly() + file_abs_path = str(os.path.join(tmpdir, ".env")) + with codecs.open(file_abs_path, 'w', encoding='utf-8') as f: + f.write(content) + assert env_vars_from_file(file_abs_path) == expected, '"{}" Failed'.format(test_name) From 97b8bff14e4275d8f9b4deca758ab8c6bb4c1eda Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 27 Jan 2020 13:30:57 +0000 Subject: [PATCH 3869/4072] Bump pytest from 5.3.2 to 5.3.4 Bumps [pytest](https://github.com/pytest-dev/pytest) from 5.3.2 to 5.3.4. - [Release notes](https://github.com/pytest-dev/pytest/releases) - [Changelog](https://github.com/pytest-dev/pytest/blob/master/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest/compare/5.3.2...5.3.4) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 6f670a380aa..0fea3b6d3f5 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -2,6 +2,6 @@ coverage==5.0.3 ddt==1.2.2 flake8==3.7.9 mock==3.0.5 -pytest==5.3.2; python_version >= '3.5' +pytest==5.3.4; python_version >= '3.5' pytest==4.6.5; python_version < '3.5' pytest-cov==2.8.1 From f17e7268b0a7fd287bd8be3af61f9363222d2e07 Mon Sep 17 00:00:00 2001 From: Florian Apolloner Date: Thu, 3 Oct 2019 15:46:49 +0200 Subject: [PATCH 3870/4072] Properly escape values coming from env_files, fixes #6871 Signed-off-by: Florian Apolloner --- compose/config/config.py | 16 +++++++++------- compose/config/environment.py | 3 ++- tests/fixtures/env/three.env | 2 ++ tests/unit/config/config_test.py | 6 +++++- 4 files changed, 18 insertions(+), 9 deletions(-) create mode 100644 tests/fixtures/env/three.env diff --git a/compose/config/config.py b/compose/config/config.py index 84933e9c9de..3bd49a0f788 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -408,7 +408,7 @@ def load(config_details, compatibility=False, interpolate=True): configs = load_mapping( config_details.config_files, 'get_configs', 'Config', config_details.working_dir ) - service_dicts = load_services(config_details, main_file, compatibility) + service_dicts = load_services(config_details, main_file, compatibility, interpolate=interpolate) if main_file.version != V1: for service_dict in service_dicts: @@ -460,7 +460,7 @@ def validate_external(entity_type, name, config, version): entity_type, name, ', '.join(k for k in config if k != 'external'))) -def load_services(config_details, config_file, compatibility=False): +def load_services(config_details, config_file, compatibility=False, interpolate=True): def build_service(service_name, service_dict, service_names): service_config = ServiceConfig.with_abs_paths( config_details.working_dir, @@ -479,7 +479,8 @@ def build_service(service_name, service_dict, service_names): service_names, config_file.version, config_details.environment, - compatibility + compatibility, + interpolate ) return service_dict @@ -679,13 +680,13 @@ def get_extended_config_path(self, extends_options): return filename -def resolve_environment(service_dict, environment=None): +def resolve_environment(service_dict, environment=None, interpolate=True): """Unpack any environment variables from an env_file, if set. Interpolate environment values if set. """ env = {} for env_file in service_dict.get('env_file', []): - env.update(env_vars_from_file(env_file)) + env.update(env_vars_from_file(env_file, interpolate)) env.update(parse_environment(service_dict.get('environment'))) return dict(resolve_env_var(k, v, environment) for k, v in six.iteritems(env)) @@ -881,11 +882,12 @@ def finalize_service_volumes(service_dict, environment): return service_dict -def finalize_service(service_config, service_names, version, environment, compatibility): +def finalize_service(service_config, service_names, version, environment, compatibility, + interpolate=True): service_dict = dict(service_config.config) if 'environment' in service_dict or 'env_file' in service_dict: - service_dict['environment'] = resolve_environment(service_dict, environment) + service_dict['environment'] = resolve_environment(service_dict, environment, interpolate) service_dict.pop('env_file', None) if 'volumes_from' in service_dict: diff --git a/compose/config/environment.py b/compose/config/environment.py index 6afbfc97244..292029eb5fc 100644 --- a/compose/config/environment.py +++ b/compose/config/environment.py @@ -30,7 +30,7 @@ def split_env(env): return key, value -def env_vars_from_file(filename): +def env_vars_from_file(filename, interpolate=True): """ Read in a line delimited file of environment variables. """ @@ -39,6 +39,7 @@ def env_vars_from_file(filename): elif not os.path.isfile(filename): raise EnvFileNotFound("{} is not a file.".format(filename)) + # TODO: now we should do something with interpolate here, but what? return dotenv.dotenv_values(dotenv_path=filename, encoding='utf-8-sig') diff --git a/tests/fixtures/env/three.env b/tests/fixtures/env/three.env new file mode 100644 index 00000000000..c2da74f19ee --- /dev/null +++ b/tests/fixtures/env/three.env @@ -0,0 +1,2 @@ +FOO=NO $ENV VAR +DOO=NO ${ENV} VAR diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index dc346df9556..933f659f863 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -5420,15 +5420,19 @@ def test_serialize_escape_dont_interpolate(self): 'environment': { 'CURRENCY': '$' }, + 'env_file': ['tests/fixtures/env/three.env'], 'entrypoint': ['$SHELL', '-c'], } } } - config_dict = config.load(build_config_details(cfg), interpolate=False) + config_dict = config.load(build_config_details(cfg, working_dir='.'), interpolate=False) serialized_config = yaml.safe_load(serialize_config(config_dict, escape_dollar=False)) serialized_service = serialized_config['services']['web'] assert serialized_service['environment']['CURRENCY'] == '$' + # Values coming from env_files are not allowed to have variables + assert serialized_service['environment']['FOO'] == 'NO $$ENV VAR' + assert serialized_service['environment']['DOO'] == 'NO $${ENV} VAR' assert serialized_service['command'] == 'echo $FOO' assert serialized_service['entrypoint'][0] == '$SHELL' From edf27e486a1f556a74c915f9c1915c0b4466970e Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Wed, 29 Jan 2020 14:29:32 +0100 Subject: [PATCH 3871/4072] Pass the interpolation value to python-dotenv Signed-off-by: Ulysses Souza --- compose/config/environment.py | 6 ++++-- requirements.txt | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/compose/config/environment.py b/compose/config/environment.py index 292029eb5fc..d8810ebb61b 100644 --- a/compose/config/environment.py +++ b/compose/config/environment.py @@ -39,8 +39,10 @@ def env_vars_from_file(filename, interpolate=True): elif not os.path.isfile(filename): raise EnvFileNotFound("{} is not a file.".format(filename)) - # TODO: now we should do something with interpolate here, but what? - return dotenv.dotenv_values(dotenv_path=filename, encoding='utf-8-sig') + env = dotenv.dotenv_values(dotenv_path=filename, encoding='utf-8-sig', interpolate=interpolate) + for k, v in env.items(): + env[k] = v if interpolate else v.replace('$', '$$') + return env class Environment(dict): diff --git a/requirements.txt b/requirements.txt index ee57c26b5af..8b1eca57369 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,6 +10,7 @@ dockerpty==0.4.1 docopt==0.6.2 enum34==1.1.6; python_version < '3.4' functools32==3.2.3.post2; python_version < '3.2' +git+git://github.com/ulyssessouza/python-dotenv.git@no-interpolate#egg=python-dotenv idna==2.8 ipaddress==1.0.23 jsonschema==3.2.0 @@ -17,7 +18,7 @@ paramiko==2.7.1 pypiwin32==219; sys_platform == 'win32' and python_version < '3.6' pypiwin32==223; sys_platform == 'win32' and python_version >= '3.6' PySocks==1.7.1 -python-dotenv==0.10.5 +#python-dotenv==0.10.5 PyYAML==5.3 requests==2.22.0 six==1.12.0 From b62722a3c5762158c627f67cba5a4c4121ac0077 Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Wed, 29 Jan 2020 17:11:00 +0100 Subject: [PATCH 3872/4072] Force MacOS SDK version to "10.11" This is due to the fact that the new CI machines are on 10.14 Signed-off-by: Ulysses Souza --- Release.Jenkinsfile | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Release.Jenkinsfile b/Release.Jenkinsfile index 7a46e3f64ff..953e19e4d91 100644 --- a/Release.Jenkinsfile +++ b/Release.Jenkinsfile @@ -72,6 +72,9 @@ pipeline { agent { label 'mac-python' } + environment { + DEPLOYMENT_TARGET="10.11" + } steps { checkout scm sh './script/setup/osx' From 9f5f8b4757c449bde7c6ed733bf0f6f6bd2afe86 Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Thu, 30 Jan 2020 19:26:33 +0100 Subject: [PATCH 3873/4072] Remove `None` entries on execute command Signed-off-by: Ulysses Souza --- compose/cli/main.py | 7 ++++++- tests/acceptance/cli_test.py | 11 +++++++++++ tests/fixtures/exec-novalue-var/docker-compose.yml | 6 ++++++ 3 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/exec-novalue-var/docker-compose.yml diff --git a/compose/cli/main.py b/compose/cli/main.py index b6ad404dcde..f0fbe64379f 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -1466,7 +1466,12 @@ def call_docker(args, dockeropts, environment): args = [executable_path] + tls_options + args log.debug(" ".join(map(pipes.quote, args))) - return subprocess.call(args, env=environment) + filtered_env = {} + for k, v in environment.items(): + if v is not None: + filtered_env[k] = environment[k] + + return subprocess.call(args, env=filtered_env) def parse_scale_args(options): diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 7fa7fc548cd..43d5a3f5ccc 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1711,6 +1711,17 @@ def test_exec_without_tty(self): assert stderr == "" assert stdout == "/\n" + @mock.patch.dict(os.environ) + def test_exec_novalue_var_dotenv_file(self): + os.environ['MYVAR'] = 'SUCCESS' + self.base_dir = 'tests/fixtures/exec-novalue-var' + self.dispatch(['up', '-d']) + assert len(self.project.containers()) == 1 + + stdout, stderr = self.dispatch(['exec', '-T', 'nginx', 'env']) + assert 'CHECK_VAR=SUCCESS' in stdout + assert not stderr + def test_exec_detach_long_form(self): self.base_dir = 'tests/fixtures/links-composefile' self.dispatch(['up', '--detach', 'console']) diff --git a/tests/fixtures/exec-novalue-var/docker-compose.yml b/tests/fixtures/exec-novalue-var/docker-compose.yml new file mode 100644 index 00000000000..1f8502f957a --- /dev/null +++ b/tests/fixtures/exec-novalue-var/docker-compose.yml @@ -0,0 +1,6 @@ +version: '3' +services: + nginx: + image: nginx + environment: + - CHECK_VAR=${MYVAR} From b5c4f4fc0f42f89f30d496be4cef7ab2b15ed82c Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Mon, 3 Feb 2020 15:45:13 +0100 Subject: [PATCH 3874/4072] Add release validation and tagging script release.py Signed-off-by: Ulysses Souza --- requirements-dev.txt | 2 + script/release/README.md | 15 ++++- script/release/const.py | 7 +++ script/release/release.py | 126 ++++++++++++++++++++++++++++++++++++++ script/release/utils.py | 47 ++++++++++++++ 5 files changed, 194 insertions(+), 3 deletions(-) create mode 100644 script/release/const.py create mode 100755 script/release/release.py create mode 100644 script/release/utils.py diff --git a/requirements-dev.txt b/requirements-dev.txt index 0fea3b6d3f5..d723b3705df 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,6 +1,8 @@ +Click==7.0 coverage==5.0.3 ddt==1.2.2 flake8==3.7.9 +gitpython==2.1.14 mock==3.0.5 pytest==5.3.4; python_version >= '3.5' pytest==4.6.5; python_version < '3.5' diff --git a/script/release/README.md b/script/release/README.md index 022d3541786..8c961e3ad04 100644 --- a/script/release/README.md +++ b/script/release/README.md @@ -4,6 +4,15 @@ The release process is fully automated by `Release.Jenkinsfile`. ## Usage -1. edit `compose/__init__.py` to set release version number -1. commit and tag as `{major}.{minor}.{patch}` -1. edit `compose/__init__.py` again to set next development version number +1. In the appropriate branch, run `./scripts/release/release tag ` + +By appropriate, we mean for a version `1.26.0` or `1.26.0-rc1` you should run the script in the `1.26.x` branch. + +The script should check the above then ask for changelog modifications. + +After the executions, you should have a commit with the proper bumps for `docker-compose version` and `run.sh` + +2. Run `git push --tags upstream ` +This should trigger a new CI build on the new tag. When the CI finishes with the tests and builds a new draft release would be available on github's releases page. + +3. Check and confirm the release on github's release page. diff --git a/script/release/const.py b/script/release/const.py new file mode 100644 index 00000000000..77a32332a7f --- /dev/null +++ b/script/release/const.py @@ -0,0 +1,7 @@ +from __future__ import absolute_import +from __future__ import unicode_literals + +import os + + +REPO_ROOT = os.path.join(os.path.dirname(__file__), '..', '..') diff --git a/script/release/release.py b/script/release/release.py new file mode 100755 index 00000000000..f53d1f3c181 --- /dev/null +++ b/script/release/release.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +from __future__ import absolute_import +from __future__ import unicode_literals + +import re + +import click +from git import Repo +from utils import update_init_py_version +from utils import update_run_sh_version +from utils import yesno + +VALID_VERSION_PATTERN = re.compile(r"^\d+\.\d+\.\d+(-rc\d+)?$") + + +class Version(str): + def matching_groups(self): + match = VALID_VERSION_PATTERN.match(self) + if not match: + return False + + return match.groups() + + def is_ga_version(self): + groups = self.matching_groups() + if not groups: + return False + + rc_suffix = groups[1] + return not rc_suffix + + def validate(self): + return len(self.matching_groups()) > 0 + + def branch_name(self): + if not self.validate(): + return None + + rc_part = self.matching_groups()[0] + ver = self + if rc_part: + ver = ver[:-len(rc_part)] + + tokens = ver.split(".") + tokens[-1] = 'x' + + return ".".join(tokens) + + +def create_bump_commit(repository, version): + print('Creating bump commit...') + repository.commit('-a', '-s', '-m "Bump {}"'.format(version), '--no-verify') + + +def validate_environment(version, repository): + if not version.validate(): + print('Version "{}" has an invalid format. This should follow D+.D+.D+(-rcD+). ' + 'Like: 1.26.0 or 1.26.0-rc1'.format(version)) + return False + + expected_branch = version.branch_name() + if str(repository.active_branch) != expected_branch: + print('Cannot tag in this branch with version "{}". ' + 'Please checkout "{}" to tag'.format(version, version.branch_name())) + return False + return True + + +@click.group() +def cli(): + pass + + +@cli.command() +@click.argument('version') +def tag(version): + """ + Updates the version related files and tag + """ + repo = Repo(".") + version = Version(version) + if not validate_environment(version, repo): + return + + update_init_py_version(version) + update_run_sh_version(version) + + input('Please add the release notes to the CHANGELOG.md file, then press Enter to continue.') + proceed = False + while not proceed: + print(repo.git.diff()) + proceed = yesno('Are these changes ok? y/N ', default=False) + + if repo.git.diff(): + create_bump_commit(repo.git, version) + else: + print('No changes to commit. Exiting...') + return + + repo.create_tag(version) + + print('Please, check the changes. If everything is OK, you just need to push with:\n' + '$ git push --tags upstream {}'.format(version.branch_name())) + + +@cli.command() +@click.argument('version') +def push_latest(version): + """ + TODO Pushes the latest tag pointing to a certain GA version + """ + raise NotImplementedError + + +@cli.command() +@click.argument('version') +def ghtemplate(version): + """ + TODO Generates the github release page content + """ + version = Version(version) + raise NotImplementedError + + +if __name__ == '__main__': + cli() diff --git a/script/release/utils.py b/script/release/utils.py new file mode 100644 index 00000000000..4f57704872b --- /dev/null +++ b/script/release/utils.py @@ -0,0 +1,47 @@ +from __future__ import absolute_import +from __future__ import unicode_literals + +import os +import re + +from const import REPO_ROOT + + +def update_init_py_version(version): + path = os.path.join(REPO_ROOT, 'compose', '__init__.py') + with open(path, 'r') as f: + contents = f.read() + contents = re.sub(r"__version__ = '[0-9a-z.-]+'", "__version__ = '{}'".format(version), contents) + with open(path, 'w') as f: + f.write(contents) + + +def update_run_sh_version(version): + path = os.path.join(REPO_ROOT, 'script', 'run', 'run.sh') + with open(path, 'r') as f: + contents = f.read() + contents = re.sub(r'VERSION="[0-9a-z.-]+"', 'VERSION="{}"'.format(version), contents) + with open(path, 'w') as f: + f.write(contents) + + +def yesno(prompt, default=None): + """ + Prompt the user for a yes or no. + + Can optionally specify a default value, which will only be + used if they enter a blank line. + + Unrecognised input (anything other than "y", "n", "yes", + "no" or "") will return None. + """ + answer = input(prompt).strip().lower() + + if answer == "y" or answer == "yes": + return True + elif answer == "n" or answer == "no": + return False + elif answer == "": + return default + else: + return None From 2dfd85e30ea0318be956ae0a959ee9cc60ba83c4 Mon Sep 17 00:00:00 2001 From: Anca Iordache Date: Wed, 29 Jan 2020 11:41:07 +0100 Subject: [PATCH 3875/4072] Use docker context interface from docker-py Signed-off-by: Anca Iordache --- compose/cli/command.py | 49 ++++++++++++---------------- compose/cli/docker_client.py | 56 ++++++++++++++++++++++++++++---- compose/cli/main.py | 1 + requirements.txt | 2 +- tests/acceptance/context_test.py | 48 +++++++++++++++++++++++++++ 5 files changed, 120 insertions(+), 36 deletions(-) create mode 100644 tests/acceptance/context_test.py diff --git a/compose/cli/command.py b/compose/cli/command.py index 2fabbe18a62..7621134e8f7 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -8,7 +8,6 @@ import six from . import errors -from . import verbose_proxy from .. import config from .. import parallel from ..config.environment import Environment @@ -17,10 +16,10 @@ from ..const import LABEL_ENVIRONMENT_FILE from ..const import LABEL_WORKING_DIR from ..project import Project -from .docker_client import docker_client -from .docker_client import get_tls_version -from .docker_client import tls_config_from_options -from .utils import get_version_info +from .docker_client import get_client +from .docker_client import load_context +from .docker_client import make_context +from .errors import UserError log = logging.getLogger(__name__) @@ -48,16 +47,28 @@ def project_from_options(project_dir, options, additional_options=None): environment.silent = options.get('COMMAND', None) in SILENT_COMMANDS set_parallel_limit(environment) - host = options.get('--host') + # get the context for the run + context = None + context_name = options.get('--context', None) + if context_name: + context = load_context(context_name) + if not context: + raise UserError("Context '{}' not found".format(context_name)) + + host = options.get('--host', None) if host is not None: + if context: + raise UserError( + "-H, --host and -c, --context are mutually exclusive. Only one should be set.") host = host.lstrip('=') + context = make_context(host, options, environment) + 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=host, - tls_config=tls_config_from_options(options, environment), + context=context, environment=environment, override_dir=override_dir, compatibility=compatibility_from_options(project_dir, options, environment), @@ -112,25 +123,8 @@ def unicode_paths(paths): return None -def get_client(environment, verbose=False, version=None, tls_config=None, host=None, - tls_version=None): - - client = docker_client( - version=version, tls_config=tls_config, host=host, - environment=environment, tls_version=get_tls_version(environment) - ) - if verbose: - version_info = six.iteritems(client.version()) - log.info(get_version_info('full')) - log.info("Docker base_url: %s", client.base_url) - log.info("Docker version: %s", - ", ".join("%s=%s" % item for item in version_info)) - return verbose_proxy.VerboseProxy('docker', client) - return client - - def get_project(project_dir, config_path=None, project_name=None, verbose=False, - host=None, tls_config=None, environment=None, override_dir=None, + context=None, environment=None, override_dir=None, compatibility=False, interpolate=True, environment_file=None): if not environment: environment = Environment.from_env_file(project_dir) @@ -145,8 +139,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, environment=environment + verbose=verbose, version=api_version, context=context, environment=environment ) with errors.handle_connection_errors(client): diff --git a/compose/cli/docker_client.py b/compose/cli/docker_client.py index a57a69b501b..d4cdc96e8f5 100644 --- a/compose/cli/docker_client.py +++ b/compose/cli/docker_client.py @@ -5,17 +5,22 @@ import os.path import ssl +import six from docker import APIClient +from docker import Context +from docker import ContextAPI +from docker import TLSConfig from docker.errors import TLSParameterError -from docker.tls import TLSConfig from docker.utils import kwargs_from_env from docker.utils.config import home_dir +from . import verbose_proxy from ..config.environment import Environment from ..const import HTTP_TIMEOUT from ..utils import unquote_path from .errors import UserError from .utils import generate_user_agent +from .utils import get_version_info log = logging.getLogger(__name__) @@ -24,6 +29,33 @@ def default_cert_path(): return os.path.join(home_dir(), '.docker') +def make_context(host, options, environment): + tls = tls_config_from_options(options, environment) + ctx = Context("compose", host=host) + if tls: + ctx.set_endpoint("docker", host, tls, skip_tls_verify=not tls.verify) + return ctx + + +def load_context(name=None): + return ContextAPI.get_context(name) + + +def get_client(environment, verbose=False, version=None, context=None): + client = docker_client( + version=version, context=context, + environment=environment, tls_version=get_tls_version(environment) + ) + if verbose: + version_info = six.iteritems(client.version()) + log.info(get_version_info('full')) + log.info("Docker base_url: %s", client.base_url) + log.info("Docker version: %s", + ", ".join("%s=%s" % item for item in version_info)) + return verbose_proxy.VerboseProxy('docker', client) + return client + + def get_tls_version(environment): compose_tls_version = environment.get('COMPOSE_TLS_VERSION', None) if not compose_tls_version: @@ -87,8 +119,7 @@ def tls_config_from_options(options, environment=None): return None -def docker_client(environment, version=None, tls_config=None, host=None, - tls_version=None): +def docker_client(environment, version=None, context=None, tls_version=None): """ Returns a docker-py client configured using environment variables according to the same logic as the official Docker client. @@ -101,10 +132,21 @@ def docker_client(environment, version=None, tls_config=None, host=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_config: - kwargs['tls'] = tls_config + if not context: + # check env for DOCKER_HOST and certs path + host = kwargs.get("base_url", None) + tls = kwargs.get("tls", None) + verify = False if not tls else tls.verify + if host: + context = Context("compose", host=host) + else: + context = ContextAPI.get_current_context() + if tls: + context.set_endpoint("docker", host=host, tls_cfg=tls, skip_tls_verify=not verify) + + kwargs['base_url'] = context.Host + if context.TLSConfig: + kwargs['tls'] = context.TLSConfig if version: kwargs['version'] = version diff --git a/compose/cli/main.py b/compose/cli/main.py index 3f0f88384f3..e226a600869 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -192,6 +192,7 @@ class TopLevelCommand(object): (default: docker-compose.yml) -p, --project-name NAME Specify an alternate project name (default: directory name) + -c, --context NAME Specify a context name --verbose Show more output --log-level LEVEL Set log level (DEBUG, INFO, WARNING, ERROR, CRITICAL) --no-ansi Do not print ANSI control characters diff --git a/requirements.txt b/requirements.txt index ee57c26b5af..a3e8fc5d3d9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ cached-property==1.5.1 certifi==2019.11.28 chardet==3.0.4 colorama==0.4.3; sys_platform == 'win32' -docker==4.1.0 +docker==4.2.0 docker-pycreds==0.4.0 dockerpty==0.4.1 docopt==0.6.2 diff --git a/tests/acceptance/context_test.py b/tests/acceptance/context_test.py new file mode 100644 index 00000000000..1d79a22a042 --- /dev/null +++ b/tests/acceptance/context_test.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import +from __future__ import unicode_literals + +import os +import shutil +import unittest + +from docker import ContextAPI + +from tests.acceptance.cli_test import dispatch + + +class ContextTestCase(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.docker_dir = os.path.join(os.environ.get("HOME", "/tmp"), '.docker') + if not os.path.exists(cls.docker_dir): + os.makedirs(cls.docker_dir) + f = open(os.path.join(cls.docker_dir, "config.json"), "w") + f.write("{}") + f.close() + cls.docker_config = os.path.join(cls.docker_dir, "config.json") + os.environ['DOCKER_CONFIG'] = cls.docker_config + ContextAPI.create_context("testcontext", host="tcp://doesnotexist:8000") + + @classmethod + def tearDownClass(cls): + shutil.rmtree(cls.docker_dir, ignore_errors=True) + + def setUp(self): + self.base_dir = 'tests/fixtures/simple-composefile' + self.override_dir = None + + def dispatch(self, options, project_options=None, returncode=0, stdin=None): + return dispatch(self.base_dir, options, project_options, returncode, stdin) + + def test_help(self): + result = self.dispatch(['help'], returncode=0) + assert '-c, --context NAME' in result.stdout + + def test_fail_on_both_host_and_context_opt(self): + result = self.dispatch(['-H', 'unix://', '-c', 'default', 'up'], returncode=1) + assert '-H, --host and -c, --context are mutually exclusive' in result.stderr + + def test_fail_run_on_inexistent_context(self): + result = self.dispatch(['-c', 'testcontext', 'up', '-d'], returncode=1) + assert "Couldn't connect to Docker daemon" in result.stderr From 391e5a6bc29d72b792e23c35b8c929e91f481097 Mon Sep 17 00:00:00 2001 From: Anca Iordache Date: Wed, 5 Feb 2020 15:57:49 +0100 Subject: [PATCH 3876/4072] Add v3.8 schema support - service scale bounded by 'max_replicas_per_node' field Signed-off-by: Anca Iordache --- compose/config/config.py | 6 +- compose/config/config_schema_v3.7.json | 843 ++++++++++++++----- compose/config/config_schema_v3.8.json | 1040 ++++++++++++++++++++++++ compose/const.py | 3 + 4 files changed, 1674 insertions(+), 218 deletions(-) create mode 100644 compose/config/config_schema_v3.8.json diff --git a/compose/config/config.py b/compose/config/config.py index 84933e9c9de..b22931b299f 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -990,12 +990,14 @@ def translate_deploy_keys_to_container_config(service_dict): deploy_dict = service_dict['deploy'] ignored_keys = [ - k for k in ['endpoint_mode', 'labels', 'update_config', 'rollback_config', 'placement'] + k for k in ['endpoint_mode', 'labels', 'update_config', 'rollback_config'] if k in deploy_dict ] if 'replicas' in deploy_dict and deploy_dict.get('mode', 'replicated') == 'replicated': - service_dict['scale'] = deploy_dict['replicas'] + scale = deploy_dict.get('replicas', 1) + max_replicas = deploy_dict.get('placement', {}).get('max_replicas_per_node', scale) + service_dict['scale'] = min(scale, max_replicas) if 'restart_policy' in deploy_dict: service_dict['restart'] = { diff --git a/compose/config/config_schema_v3.7.json b/compose/config/config_schema_v3.7.json index cd7882f5b24..c5906af9f2b 100644 --- a/compose/config/config_schema_v3.7.json +++ b/compose/config/config_schema_v3.7.json @@ -2,13 +2,13 @@ "$schema": "http://json-schema.org/draft-04/schema#", "id": "config_schema_v3.7.json", "type": "object", - "required": ["version"], - + "required": [ + "version" + ], "properties": { "version": { "type": "string" }, - "services": { "id": "#/properties/services", "type": "object", @@ -19,7 +19,6 @@ }, "additionalProperties": false }, - "networks": { "id": "#/properties/networks", "type": "object", @@ -29,7 +28,6 @@ } } }, - "volumes": { "id": "#/properties/volumes", "type": "object", @@ -40,7 +38,6 @@ }, "additionalProperties": false }, - "secrets": { "id": "#/properties/secrets", "type": "object", @@ -51,7 +48,6 @@ }, "additionalProperties": false }, - "configs": { "id": "#/properties/configs", "type": "object", @@ -63,124 +59,251 @@ "additionalProperties": false } }, - - "patternProperties": {"^x-": {}}, + "patternProperties": { + "^x-": {} + }, "additionalProperties": false, - "definitions": { - "service": { "id": "#/definitions/service", "type": "object", - "properties": { - "deploy": {"$ref": "#/definitions/deployment"}, + "deploy": { + "$ref": "#/definitions/deployment" + }, "build": { "oneOf": [ - {"type": "string"}, + { + "type": "string" + }, { "type": "object", "properties": { - "context": {"type": "string"}, - "dockerfile": {"type": "string"}, - "args": {"$ref": "#/definitions/list_or_dict"}, - "labels": {"$ref": "#/definitions/list_or_dict"}, - "cache_from": {"$ref": "#/definitions/list_of_strings"}, - "network": {"type": "string"}, - "target": {"type": "string"}, - "shm_size": {"type": ["integer", "string"]} + "context": { + "type": "string" + }, + "dockerfile": { + "type": "string" + }, + "args": { + "$ref": "#/definitions/list_or_dict" + }, + "labels": { + "$ref": "#/definitions/list_or_dict" + }, + "cache_from": { + "$ref": "#/definitions/list_of_strings" + }, + "network": { + "type": "string" + }, + "target": { + "type": "string" + }, + "shm_size": { + "type": [ + "integer", + "string" + ] + } }, "additionalProperties": false } ] }, - "cap_add": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, - "cap_drop": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, - "cgroup_parent": {"type": "string"}, + "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"}} + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } ] }, "configs": { "type": "array", "items": { "oneOf": [ - {"type": "string"}, + { + "type": "string" + }, { "type": "object", "properties": { - "source": {"type": "string"}, - "target": {"type": "string"}, - "uid": {"type": "string"}, - "gid": {"type": "string"}, - "mode": {"type": "number"} + "source": { + "type": "string" + }, + "target": { + "type": "string" + }, + "uid": { + "type": "string" + }, + "gid": { + "type": "string" + }, + "mode": { + "type": "number" + } } } ] } }, - "container_name": {"type": "string"}, - "credential_spec": {"type": "object", "properties": { - "file": {"type": "string"}, - "registry": {"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"}, + "container_name": { + "type": "string" + }, + "credential_spec": { + "type": "object", + "properties": { + "file": { + "type": "string" + }, + "registry": { + "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"}} + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } ] }, - "env_file": {"$ref": "#/definitions/string_or_list"}, - "environment": {"$ref": "#/definitions/list_or_dict"}, - + "env_file": { + "$ref": "#/definitions/string_or_list" + }, + "environment": { + "$ref": "#/definitions/list_or_dict" + }, "expose": { "type": "array", "items": { - "type": ["string", "number"], + "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"}, - "init": {"type": "boolean"}, - "ipc": {"type": "string"}, - "isolation": {"type": "string"}, - "labels": {"$ref": "#/definitions/list_or_dict"}, - "links": {"type": "array", "items": {"type": "string"}, "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" + }, + "init": { + "type": "boolean" + }, + "ipc": { + "type": "string" + }, + "isolation": { + "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"]} - } - } + "type": "object", + "properties": { + "driver": { + "type": "string" }, - "additionalProperties": false + "options": { + "type": "object", + "patternProperties": { + "^.+$": { + "type": [ + "string", + "number", + "null" + ] + } + } + } + }, + "additionalProperties": false + }, + "mac_address": { + "type": "string" + }, + "network_mode": { + "type": "string" }, - - "mac_address": {"type": "string"}, - "network_mode": {"type": "string"}, - "networks": { "oneOf": [ - {"$ref": "#/definitions/list_of_strings"}, + { + "$ref": "#/definitions/list_of_strings" + }, { "type": "object", "patternProperties": { @@ -189,13 +312,21 @@ { "type": "object", "properties": { - "aliases": {"$ref": "#/definitions/list_of_strings"}, - "ipv4_address": {"type": "string"}, - "ipv6_address": {"type": "string"} + "aliases": { + "$ref": "#/definitions/list_of_strings" + }, + "ipv4_address": { + "type": "string" + }, + "ipv6_address": { + "type": "string" + } }, "additionalProperties": false }, - {"type": "null"} + { + "type": "null" + } ] } }, @@ -203,21 +334,39 @@ } ] }, - "pid": {"type": ["string", "null"]}, - + "pid": { + "type": [ + "string", + "null" + ] + }, "ports": { "type": "array", "items": { "oneOf": [ - {"type": "number", "format": "ports"}, - {"type": "string", "format": "ports"}, + { + "type": "number", + "format": "ports" + }, + { + "type": "string", + "format": "ports" + }, { "type": "object", "properties": { - "mode": {"type": "string"}, - "target": {"type": "integer"}, - "published": {"type": "integer"}, - "protocol": {"type": "string"} + "mode": { + "type": "string" + }, + "target": { + "type": "integer" + }, + "published": { + "type": "integer" + }, + "protocol": { + "type": "string" + } }, "additionalProperties": false } @@ -225,81 +374,153 @@ }, "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"]}, + "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": "string" + }, { "type": "object", "properties": { - "source": {"type": "string"}, - "target": {"type": "string"}, - "uid": {"type": "string"}, - "gid": {"type": "string"}, - "mode": {"type": "number"} + "source": { + "type": "string" + }, + "target": { + "type": "string" + }, + "uid": { + "type": "string" + }, + "gid": { + "type": "string" + }, + "mode": { + "type": "number" + } } } ] } }, - "sysctls": {"$ref": "#/definitions/list_or_dict"}, - "stdin_open": {"type": "boolean"}, - "stop_grace_period": {"type": "string", "format": "duration"}, - "stop_signal": {"type": "string"}, - "tmpfs": {"$ref": "#/definitions/string_or_list"}, - "tty": {"type": "boolean"}, + "sysctls": { + "$ref": "#/definitions/list_or_dict" + }, + "stdin_open": { + "type": "boolean" + }, + "stop_grace_period": { + "type": "string", + "format": "duration" + }, + "stop_signal": { + "type": "string" + }, + "tmpfs": { + "$ref": "#/definitions/string_or_list" + }, + "tty": { + "type": "boolean" + }, "ulimits": { "type": "object", "patternProperties": { "^[a-z]+$": { "oneOf": [ - {"type": "integer"}, { - "type":"object", + "type": "integer" + }, + { + "type": "object", "properties": { - "hard": {"type": "integer"}, - "soft": {"type": "integer"} + "hard": { + "type": "integer" + }, + "soft": { + "type": "integer" + } }, - "required": ["soft", "hard"], + "required": [ + "soft", + "hard" + ], "additionalProperties": false } ] } } }, - "user": {"type": "string"}, - "userns_mode": {"type": "string"}, + "user": { + "type": "string" + }, + "userns_mode": { + "type": "string" + }, "volumes": { "type": "array", "items": { "oneOf": [ - {"type": "string"}, + { + "type": "string" + }, { "type": "object", - "required": ["type"], + "required": [ + "type" + ], "properties": { - "type": {"type": "string"}, - "source": {"type": "string"}, - "target": {"type": "string"}, - "read_only": {"type": "boolean"}, - "consistency": {"type": "string"}, + "type": { + "type": "string" + }, + "source": { + "type": "string" + }, + "target": { + "type": "string" + }, + "read_only": { + "type": "boolean" + }, + "consistency": { + "type": "string" + }, "bind": { "type": "object", "properties": { - "propagation": {"type": "string"} + "propagation": { + "type": "string" + } } }, "volume": { "type": "object", "properties": { - "nocopy": {"type": "boolean"} + "nocopy": { + "type": "boolean" + } } }, "tmpfs": { @@ -318,63 +539,129 @@ "uniqueItems": true } }, - "working_dir": {"type": "string"} + "working_dir": { + "type": "string" + } + }, + "patternProperties": { + "^x-": {} }, - "patternProperties": {"^x-": {}}, "additionalProperties": false }, - "healthcheck": { "id": "#/definitions/healthcheck", "type": "object", "additionalProperties": false, "properties": { - "disable": {"type": "boolean"}, - "interval": {"type": "string", "format": "duration"}, - "retries": {"type": "number"}, + "disable": { + "type": "boolean" + }, + "interval": { + "type": "string", + "format": "duration" + }, + "retries": { + "type": "number" + }, "test": { "oneOf": [ - {"type": "string"}, - {"type": "array", "items": {"type": "string"}} + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } ] }, - "timeout": {"type": "string", "format": "duration"}, - "start_period": {"type": "string", "format": "duration"} + "timeout": { + "type": "string", + "format": "duration" + }, + "start_period": { + "type": "string", + "format": "duration" + } } }, "deployment": { "id": "#/definitions/deployment", - "type": ["object", "null"], + "type": [ + "object", + "null" + ], "properties": { - "mode": {"type": "string"}, - "endpoint_mode": {"type": "string"}, - "replicas": {"type": "integer"}, - "labels": {"$ref": "#/definitions/list_or_dict"}, + "mode": { + "type": "string" + }, + "endpoint_mode": { + "type": "string" + }, + "replicas": { + "type": "integer" + }, + "labels": { + "$ref": "#/definitions/list_or_dict" + }, "rollback_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"}, - "order": {"type": "string", "enum": [ - "start-first", "stop-first" - ]} + "parallelism": { + "type": "integer" + }, + "delay": { + "type": "string", + "format": "duration" + }, + "failure_action": { + "type": "string" + }, + "monitor": { + "type": "string", + "format": "duration" + }, + "max_failure_ratio": { + "type": "number" + }, + "order": { + "type": "string", + "enum": [ + "start-first", + "stop-first" + ] + } }, "additionalProperties": false }, "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"}, - "order": {"type": "string", "enum": [ - "start-first", "stop-first" - ]} + "parallelism": { + "type": "integer" + }, + "delay": { + "type": "string", + "format": "duration" + }, + "failure_action": { + "type": "string" + }, + "monitor": { + "type": "string", + "format": "duration" + }, + "max_failure_ratio": { + "type": "number" + }, + "order": { + "type": "string", + "enum": [ + "start-first", + "stop-first" + ] + } }, "additionalProperties": false }, @@ -384,17 +671,27 @@ "limits": { "type": "object", "properties": { - "cpus": {"type": "string"}, - "memory": {"type": "string"} + "cpus": { + "type": "string" + }, + "memory": { + "type": "string" + } }, "additionalProperties": false }, "reservations": { "type": "object", "properties": { - "cpus": {"type": "string"}, - "memory": {"type": "string"}, - "generic_resources": {"$ref": "#/definitions/generic_resources"} + "cpus": { + "type": "string" + }, + "memory": { + "type": "string" + }, + "generic_resources": { + "$ref": "#/definitions/generic_resources" + } }, "additionalProperties": false } @@ -404,23 +701,40 @@ "restart_policy": { "type": "object", "properties": { - "condition": {"type": "string"}, - "delay": {"type": "string", "format": "duration"}, - "max_attempts": {"type": "integer"}, - "window": {"type": "string", "format": "duration"} + "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"}}, + "constraints": { + "type": "array", + "items": { + "type": "string" + } + }, "preferences": { "type": "array", "items": { "type": "object", "properties": { - "spread": {"type": "string"} + "spread": { + "type": "string" + } }, "additionalProperties": false } @@ -431,7 +745,6 @@ }, "additionalProperties": false }, - "generic_resources": { "id": "#/definitions/generic_resources", "type": "array", @@ -441,8 +754,12 @@ "discrete_resource_spec": { "type": "object", "properties": { - "kind": {"type": "string"}, - "value": {"type": "number"} + "kind": { + "type": "string" + }, + "value": { + "type": "number" + } }, "additionalProperties": false } @@ -450,29 +767,44 @@ "additionalProperties": false } }, - "network": { "id": "#/definitions/network", - "type": ["object", "null"], + "type": [ + "object", + "null" + ], "properties": { - "name": {"type": "string"}, - "driver": {"type": "string"}, + "name": { + "type": "string" + }, + "driver": { + "type": "string" + }, "driver_opts": { "type": "object", "patternProperties": { - "^.+$": {"type": ["string", "number"]} + "^.+$": { + "type": [ + "string", + "number" + ] + } } }, "ipam": { "type": "object", "properties": { - "driver": {"type": "string"}, + "driver": { + "type": "string" + }, "config": { "type": "array", "items": { "type": "object", "properties": { - "subnet": {"type": "string"} + "subnet": { + "type": "string" + } }, "additionalProperties": false } @@ -481,119 +813,198 @@ "additionalProperties": false }, "external": { - "type": ["boolean", "object"], + "type": [ + "boolean", + "object" + ], "properties": { - "name": {"type": "string"} + "name": { + "type": "string" + } }, "additionalProperties": false }, - "internal": {"type": "boolean"}, - "attachable": {"type": "boolean"}, - "labels": {"$ref": "#/definitions/list_or_dict"} + "internal": { + "type": "boolean" + }, + "attachable": { + "type": "boolean" + }, + "labels": { + "$ref": "#/definitions/list_or_dict" + } + }, + "patternProperties": { + "^x-": {} }, - "patternProperties": {"^x-": {}}, "additionalProperties": false }, - "volume": { "id": "#/definitions/volume", - "type": ["object", "null"], + "type": [ + "object", + "null" + ], "properties": { - "name": {"type": "string"}, - "driver": {"type": "string"}, + "name": { + "type": "string" + }, + "driver": { + "type": "string" + }, "driver_opts": { "type": "object", "patternProperties": { - "^.+$": {"type": ["string", "number"]} + "^.+$": { + "type": [ + "string", + "number" + ] + } } }, "external": { - "type": ["boolean", "object"], + "type": [ + "boolean", + "object" + ], "properties": { - "name": {"type": "string"} + "name": { + "type": "string" + } }, "additionalProperties": false }, - "labels": {"$ref": "#/definitions/list_or_dict"} + "labels": { + "$ref": "#/definitions/list_or_dict" + } + }, + "patternProperties": { + "^x-": {} }, - "patternProperties": {"^x-": {}}, "additionalProperties": false }, - "secret": { "id": "#/definitions/secret", "type": "object", "properties": { - "name": {"type": "string"}, - "file": {"type": "string"}, + "name": { + "type": "string" + }, + "file": { + "type": "string" + }, "external": { - "type": ["boolean", "object"], + "type": [ + "boolean", + "object" + ], "properties": { - "name": {"type": "string"} + "name": { + "type": "string" + } } }, - "labels": {"$ref": "#/definitions/list_or_dict"} + "labels": { + "$ref": "#/definitions/list_or_dict" + } + }, + "patternProperties": { + "^x-": {} }, - "patternProperties": {"^x-": {}}, "additionalProperties": false }, - "config": { "id": "#/definitions/config", "type": "object", "properties": { - "name": {"type": "string"}, - "file": {"type": "string"}, + "name": { + "type": "string" + }, + "file": { + "type": "string" + }, "external": { - "type": ["boolean", "object"], + "type": [ + "boolean", + "object" + ], "properties": { - "name": {"type": "string"} + "name": { + "type": "string" + } } }, - "labels": {"$ref": "#/definitions/list_or_dict"} + "labels": { + "$ref": "#/definitions/list_or_dict" + } + }, + "patternProperties": { + "^x-": {} }, - "patternProperties": {"^x-": {}}, "additionalProperties": false }, - "string_or_list": { "oneOf": [ - {"type": "string"}, - {"$ref": "#/definitions/list_of_strings"} + { + "type": "string" + }, + { + "$ref": "#/definitions/list_of_strings" + } ] }, - "list_of_strings": { "type": "array", - "items": {"type": "string"}, + "items": { + "type": "string" + }, "uniqueItems": true }, - "list_or_dict": { "oneOf": [ { "type": "object", "patternProperties": { ".+": { - "type": ["string", "number", "null"] + "type": [ + "string", + "number", + "null" + ] } }, "additionalProperties": false }, - {"type": "array", "items": {"type": "string"}, "uniqueItems": true} + { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } ] }, - "constraints": { "service": { "id": "#/definitions/constraints/service", "anyOf": [ - {"required": ["build"]}, - {"required": ["image"]} + { + "required": [ + "build" + ] + }, + { + "required": [ + "image" + ] + } ], "properties": { "build": { - "required": ["context"] + "required": [ + "context" + ] } } } diff --git a/compose/config/config_schema_v3.8.json b/compose/config/config_schema_v3.8.json new file mode 100644 index 00000000000..e9bc20cee69 --- /dev/null +++ b/compose/config/config_schema_v3.8.json @@ -0,0 +1,1040 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "id": "config_schema_v3.8.json", + "type": "object", + "required": [ + "version" + ], + "properties": { + "version": { + "type": "string" + }, + "services": { + "id": "#/properties/services", + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "$ref": "#/definitions/service" + } + }, + "additionalProperties": false + }, + "networks": { + "id": "#/properties/networks", + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "$ref": "#/definitions/network" + } + } + }, + "volumes": { + "id": "#/properties/volumes", + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "$ref": "#/definitions/volume" + } + }, + "additionalProperties": false + }, + "secrets": { + "id": "#/properties/secrets", + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "$ref": "#/definitions/secret" + } + }, + "additionalProperties": false + }, + "configs": { + "id": "#/properties/configs", + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "$ref": "#/definitions/config" + } + }, + "additionalProperties": false + } + }, + "patternProperties": { + "^x-": {} + }, + "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" + }, + "labels": { + "$ref": "#/definitions/list_or_dict" + }, + "cache_from": { + "$ref": "#/definitions/list_of_strings" + }, + "network": { + "type": "string" + }, + "target": { + "type": "string" + }, + "shm_size": { + "type": [ + "integer", + "string" + ] + } + }, + "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" + } + } + ] + }, + "configs": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "source": { + "type": "string" + }, + "target": { + "type": "string" + }, + "uid": { + "type": "string" + }, + "gid": { + "type": "string" + }, + "mode": { + "type": "number" + } + } + } + ] + } + }, + "container_name": { + "type": "string" + }, + "credential_spec": { + "type": "object", + "properties": { + "config": { + "type": "string" + }, + "file": { + "type": "string" + }, + "registry": { + "type": "string" + } + }, + "additionalProperties": false + }, + "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" + }, + "init": { + "type": "boolean" + }, + "ipc": { + "type": "string" + }, + "isolation": { + "type": "string" + }, + "labels": { + "$ref": "#/definitions/list_or_dict" + }, + "links": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + }, + "logging": { + "type": "object", + "properties": { + "driver": { + "type": "string" + }, + "options": { + "type": "object", + "patternProperties": { + "^.+$": { + "type": [ + "string", + "number", + "null" + ] + } + } + } + }, + "additionalProperties": false + }, + "mac_address": { + "type": "string" + }, + "network_mode": { + "type": "string" + }, + "networks": { + "oneOf": [ + { + "$ref": "#/definitions/list_of_strings" + }, + { + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "oneOf": [ + { + "type": "object", + "properties": { + "aliases": { + "$ref": "#/definitions/list_of_strings" + }, + "ipv4_address": { + "type": "string" + }, + "ipv6_address": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + } + ] + }, + "pid": { + "type": [ + "string", + "null" + ] + }, + "ports": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "number", + "format": "ports" + }, + { + "type": "string", + "format": "ports" + }, + { + "type": "object", + "properties": { + "mode": { + "type": "string" + }, + "target": { + "type": "integer" + }, + "published": { + "type": "integer" + }, + "protocol": { + "type": "string" + } + }, + "additionalProperties": false + } + ] + }, + "uniqueItems": true + }, + "privileged": { + "type": "boolean" + }, + "read_only": { + "type": "boolean" + }, + "restart": { + "type": "string" + }, + "security_opt": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + }, + "shm_size": { + "type": [ + "number", + "string" + ] + }, + "secrets": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "source": { + "type": "string" + }, + "target": { + "type": "string" + }, + "uid": { + "type": "string" + }, + "gid": { + "type": "string" + }, + "mode": { + "type": "number" + } + } + } + ] + } + }, + "sysctls": { + "$ref": "#/definitions/list_or_dict" + }, + "stdin_open": { + "type": "boolean" + }, + "stop_grace_period": { + "type": "string", + "format": "duration" + }, + "stop_signal": { + "type": "string" + }, + "tmpfs": { + "$ref": "#/definitions/string_or_list" + }, + "tty": { + "type": "boolean" + }, + "ulimits": { + "type": "object", + "patternProperties": { + "^[a-z]+$": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "object", + "properties": { + "hard": { + "type": "integer" + }, + "soft": { + "type": "integer" + } + }, + "required": [ + "soft", + "hard" + ], + "additionalProperties": false + } + ] + } + } + }, + "user": { + "type": "string" + }, + "userns_mode": { + "type": "string" + }, + "volumes": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string" + }, + "source": { + "type": "string" + }, + "target": { + "type": "string" + }, + "read_only": { + "type": "boolean" + }, + "consistency": { + "type": "string" + }, + "bind": { + "type": "object", + "properties": { + "propagation": { + "type": "string" + } + } + }, + "volume": { + "type": "object", + "properties": { + "nocopy": { + "type": "boolean" + } + } + }, + "tmpfs": { + "type": "object", + "properties": { + "size": { + "type": "integer", + "minimum": 0 + } + } + } + }, + "additionalProperties": false + } + ], + "uniqueItems": true + } + }, + "working_dir": { + "type": "string" + } + }, + "patternProperties": { + "^x-": {} + }, + "additionalProperties": false + }, + "healthcheck": { + "id": "#/definitions/healthcheck", + "type": "object", + "additionalProperties": false, + "properties": { + "disable": { + "type": "boolean" + }, + "interval": { + "type": "string", + "format": "duration" + }, + "retries": { + "type": "number" + }, + "test": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "timeout": { + "type": "string", + "format": "duration" + }, + "start_period": { + "type": "string", + "format": "duration" + } + } + }, + "deployment": { + "id": "#/definitions/deployment", + "type": [ + "object", + "null" + ], + "properties": { + "mode": { + "type": "string" + }, + "endpoint_mode": { + "type": "string" + }, + "replicas": { + "type": "integer" + }, + "labels": { + "$ref": "#/definitions/list_or_dict" + }, + "rollback_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" + }, + "order": { + "type": "string", + "enum": [ + "start-first", + "stop-first" + ] + } + }, + "additionalProperties": false + }, + "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" + }, + "order": { + "type": "string", + "enum": [ + "start-first", + "stop-first" + ] + } + }, + "additionalProperties": false + }, + "resources": { + "type": "object", + "properties": { + "limits": { + "type": "object", + "properties": { + "cpus": { + "type": "string" + }, + "memory": { + "type": "string" + } + }, + "additionalProperties": false + }, + "reservations": { + "type": "object", + "properties": { + "cpus": { + "type": "string" + }, + "memory": { + "type": "string" + }, + "generic_resources": { + "$ref": "#/definitions/generic_resources" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "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" + } + }, + "preferences": { + "type": "array", + "items": { + "type": "object", + "properties": { + "spread": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "max_replicas_per_node": { + "type": "integer" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "generic_resources": { + "id": "#/definitions/generic_resources", + "type": "array", + "items": { + "type": "object", + "properties": { + "discrete_resource_spec": { + "type": "object", + "properties": { + "kind": { + "type": "string" + }, + "value": { + "type": "number" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + }, + "network": { + "id": "#/definitions/network", + "type": [ + "object", + "null" + ], + "properties": { + "name": { + "type": "string" + }, + "driver": { + "type": "string" + }, + "driver_opts": { + "type": "object", + "patternProperties": { + "^.+$": { + "type": [ + "string", + "number" + ] + } + } + }, + "ipam": { + "type": "object", + "properties": { + "driver": { + "type": "string" + }, + "config": { + "type": "array", + "items": { + "type": "object", + "properties": { + "subnet": { + "type": "string" + } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + }, + "external": { + "type": [ + "boolean", + "object" + ], + "properties": { + "name": { + "type": "string" + } + }, + "additionalProperties": false + }, + "internal": { + "type": "boolean" + }, + "attachable": { + "type": "boolean" + }, + "labels": { + "$ref": "#/definitions/list_or_dict" + } + }, + "patternProperties": { + "^x-": {} + }, + "additionalProperties": false + }, + "volume": { + "id": "#/definitions/volume", + "type": [ + "object", + "null" + ], + "properties": { + "name": { + "type": "string" + }, + "driver": { + "type": "string" + }, + "driver_opts": { + "type": "object", + "patternProperties": { + "^.+$": { + "type": [ + "string", + "number" + ] + } + } + }, + "external": { + "type": [ + "boolean", + "object" + ], + "properties": { + "name": { + "type": "string" + } + }, + "additionalProperties": false + }, + "labels": { + "$ref": "#/definitions/list_or_dict" + } + }, + "patternProperties": { + "^x-": {} + }, + "additionalProperties": false + }, + "secret": { + "id": "#/definitions/secret", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "file": { + "type": "string" + }, + "external": { + "type": [ + "boolean", + "object" + ], + "properties": { + "name": { + "type": "string" + } + } + }, + "labels": { + "$ref": "#/definitions/list_or_dict" + }, + "driver": { + "type": "string" + }, + "driver_opts": { + "type": "object", + "patternProperties": { + "^.+$": { + "type": [ + "string", + "number" + ] + } + } + }, + "template_driver": { + "type": "string" + } + }, + "patternProperties": { + "^x-": {} + }, + "additionalProperties": false + }, + "config": { + "id": "#/definitions/config", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "file": { + "type": "string" + }, + "external": { + "type": [ + "boolean", + "object" + ], + "properties": { + "name": { + "type": "string" + } + } + }, + "labels": { + "$ref": "#/definitions/list_or_dict" + }, + "template_driver": { + "type": "string" + } + }, + "patternProperties": { + "^x-": {} + }, + "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/const.py b/compose/const.py index ab0389ce01e..75774867ac8 100644 --- a/compose/const.py +++ b/compose/const.py @@ -41,6 +41,7 @@ COMPOSEFILE_V3_5 = ComposeVersion('3.5') COMPOSEFILE_V3_6 = ComposeVersion('3.6') COMPOSEFILE_V3_7 = ComposeVersion('3.7') +COMPOSEFILE_V3_8 = ComposeVersion('3.8') API_VERSIONS = { COMPOSEFILE_V1: '1.21', @@ -57,6 +58,7 @@ COMPOSEFILE_V3_5: '1.30', COMPOSEFILE_V3_6: '1.36', COMPOSEFILE_V3_7: '1.38', + COMPOSEFILE_V3_8: '1.38', } API_VERSION_TO_ENGINE_VERSION = { @@ -74,4 +76,5 @@ API_VERSIONS[COMPOSEFILE_V3_5]: '17.06.0', API_VERSIONS[COMPOSEFILE_V3_6]: '18.02.0', API_VERSIONS[COMPOSEFILE_V3_7]: '18.06.0', + API_VERSIONS[COMPOSEFILE_V3_8]: '19.03.0', } From 09c80ce49baa598c816ce4bdde603c53689bb5b3 Mon Sep 17 00:00:00 2001 From: Anca Iordache Date: Thu, 6 Feb 2020 17:38:47 +0100 Subject: [PATCH 3877/4072] test update - remove 'placement' from unsupported fields Signed-off-by: Anca Iordache --- tests/unit/config/config_test.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index dc346df9556..603eddaf244 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -3637,7 +3637,6 @@ def test_compatibility_mode_warnings(self): assert 'labels' in warn_message assert 'endpoint_mode' in warn_message assert 'update_config' in warn_message - assert 'placement' in warn_message assert 'resources.reservations.cpus' in warn_message assert 'restart_policy.delay' in warn_message assert 'restart_policy.window' in warn_message From d9b0fabd9b366165746a8bdb40cfa1f90a4d997b Mon Sep 17 00:00:00 2001 From: Anca Iordache Date: Thu, 6 Feb 2020 17:55:46 +0100 Subject: [PATCH 3878/4072] update api version for 3.8 Signed-off-by: Anca Iordache --- compose/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/const.py b/compose/const.py index 75774867ac8..89c6f2eb3e1 100644 --- a/compose/const.py +++ b/compose/const.py @@ -58,7 +58,7 @@ COMPOSEFILE_V3_5: '1.30', COMPOSEFILE_V3_6: '1.36', COMPOSEFILE_V3_7: '1.38', - COMPOSEFILE_V3_8: '1.38', + COMPOSEFILE_V3_8: '1.40', } API_VERSION_TO_ENGINE_VERSION = { From 02d8e9ee1445ce58ac355c7cda95204a66d54029 Mon Sep 17 00:00:00 2001 From: Anca Iordache Date: Fri, 7 Feb 2020 11:37:38 +0100 Subject: [PATCH 3879/4072] set min engine version needed for v38 schema support Signed-off-by: Anca Iordache --- compose/const.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/compose/const.py b/compose/const.py index 89c6f2eb3e1..3d9030520b0 100644 --- a/compose/const.py +++ b/compose/const.py @@ -43,6 +43,8 @@ COMPOSEFILE_V3_7 = ComposeVersion('3.7') COMPOSEFILE_V3_8 = ComposeVersion('3.8') +# minimum DOCKER ENGINE API version needed to support +# features for each compose schema version API_VERSIONS = { COMPOSEFILE_V1: '1.21', COMPOSEFILE_V2_0: '1.22', @@ -58,7 +60,7 @@ COMPOSEFILE_V3_5: '1.30', COMPOSEFILE_V3_6: '1.36', COMPOSEFILE_V3_7: '1.38', - COMPOSEFILE_V3_8: '1.40', + COMPOSEFILE_V3_8: '1.38', } API_VERSION_TO_ENGINE_VERSION = { @@ -76,5 +78,5 @@ API_VERSIONS[COMPOSEFILE_V3_5]: '17.06.0', API_VERSIONS[COMPOSEFILE_V3_6]: '18.02.0', API_VERSIONS[COMPOSEFILE_V3_7]: '18.06.0', - API_VERSIONS[COMPOSEFILE_V3_8]: '19.03.0', + API_VERSIONS[COMPOSEFILE_V3_8]: '18.06.0', } From 5fe0858450303f701175dbc879d68fc558adda2d Mon Sep 17 00:00:00 2001 From: Florian Apolloner Date: Thu, 6 Feb 2020 09:44:37 +0100 Subject: [PATCH 3880/4072] Updated requirements.txt back to the released python-dotenv 0.11.0. Signed-off-by: Florian Apolloner --- requirements.txt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 8b1eca57369..92f5d104d83 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,7 +10,6 @@ dockerpty==0.4.1 docopt==0.6.2 enum34==1.1.6; python_version < '3.4' functools32==3.2.3.post2; python_version < '3.2' -git+git://github.com/ulyssessouza/python-dotenv.git@no-interpolate#egg=python-dotenv idna==2.8 ipaddress==1.0.23 jsonschema==3.2.0 @@ -18,7 +17,7 @@ paramiko==2.7.1 pypiwin32==219; sys_platform == 'win32' and python_version < '3.6' pypiwin32==223; sys_platform == 'win32' and python_version >= '3.6' PySocks==1.7.1 -#python-dotenv==0.10.5 +python-dotenv==0.11.0 PyYAML==5.3 requests==2.22.0 six==1.12.0 From 79fe7ca997f01d16817e769a7a73b3b1b3135fdc Mon Sep 17 00:00:00 2001 From: Anca Iordache Date: Mon, 10 Feb 2020 11:52:16 +0100 Subject: [PATCH 3881/4072] add warning when max_replicas_per_node limits scale Signed-off-by: Anca Iordache --- compose/config/config.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/compose/config/config.py b/compose/config/config.py index b22931b299f..56761d18146 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -998,6 +998,9 @@ def translate_deploy_keys_to_container_config(service_dict): scale = deploy_dict.get('replicas', 1) max_replicas = deploy_dict.get('placement', {}).get('max_replicas_per_node', scale) service_dict['scale'] = min(scale, max_replicas) + if max_replicas < scale: + log.warning("Scale is limited to {} ('max_replicas_per_node' field).".format( + max_replicas)) if 'restart_policy' in deploy_dict: service_dict['restart'] = { From 01df88e1eb6f5649eb72c4c3c0463d7869ceaa4f Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 10 Feb 2020 22:15:09 +0000 Subject: [PATCH 3882/4072] Bump python-dotenv from 0.10.5 to 0.11.0 Bumps [python-dotenv](https://github.com/theskumar/python-dotenv) from 0.10.5 to 0.11.0. - [Release notes](https://github.com/theskumar/python-dotenv/releases) - [Changelog](https://github.com/theskumar/python-dotenv/blob/master/CHANGELOG.md) - [Commits](https://github.com/theskumar/python-dotenv/compare/v0.10.5...v0.11.0) Signed-off-by: dependabot-preview[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index a3e8fc5d3d9..b1bc694427f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,7 +17,7 @@ paramiko==2.7.1 pypiwin32==219; sys_platform == 'win32' and python_version < '3.6' pypiwin32==223; sys_platform == 'win32' and python_version >= '3.6' PySocks==1.7.1 -python-dotenv==0.10.5 +python-dotenv==0.11.0 PyYAML==5.3 requests==2.22.0 six==1.12.0 From 0f8e7a8874c8135b208218a7647f5119db3494bf Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 17 Feb 2020 22:16:41 +0000 Subject: [PATCH 3883/4072] Bump idna from 2.8 to 2.9 Bumps [idna](https://github.com/kjd/idna) from 2.8 to 2.9. - [Release notes](https://github.com/kjd/idna/releases) - [Changelog](https://github.com/kjd/idna/blob/master/HISTORY.rst) - [Commits](https://github.com/kjd/idna/compare/v2.8...v2.9) Signed-off-by: dependabot-preview[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index a3e8fc5d3d9..4e3d37b66ad 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,7 +10,7 @@ dockerpty==0.4.1 docopt==0.6.2 enum34==1.1.6; python_version < '3.4' functools32==3.2.3.post2; python_version < '3.2' -idna==2.8 +idna==2.9 ipaddress==1.0.23 jsonschema==3.2.0 paramiko==2.7.1 From cfefeaa6f750037b33a4fa4d18706b0dfad9d378 Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Thu, 27 Feb 2020 10:48:37 +0100 Subject: [PATCH 3884/4072] Resolve a compatibility issue Signed-off-by: Ulysses Souza --- requirements-dev.txt | 2 +- requirements.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index d723b3705df..7f17d7bba3f 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -2,7 +2,7 @@ Click==7.0 coverage==5.0.3 ddt==1.2.2 flake8==3.7.9 -gitpython==2.1.14 +gitpython==2.1.15 mock==3.0.5 pytest==5.3.4; python_version >= '3.5' pytest==4.6.5; python_version < '3.5' diff --git a/requirements.txt b/requirements.txt index 99048235172..b1bc694427f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,7 +10,7 @@ dockerpty==0.4.1 docopt==0.6.2 enum34==1.1.6; python_version < '3.4' functools32==3.2.3.post2; python_version < '3.2' -idna==2.9 +idna==2.8 ipaddress==1.0.23 jsonschema==3.2.0 paramiko==2.7.1 From 2769c33a7eae5941b840e6f16c2919c9dcfc4342 Mon Sep 17 00:00:00 2001 From: Stefan Scherer Date: Thu, 27 Feb 2020 17:16:38 +0100 Subject: [PATCH 3885/4072] Update Jenkins build status badge Signed-off-by: Stefan Scherer --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f1ae7e1ed2f..27c8dcb34c2 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ Installation and documentation Contributing ------------ -[![Build Status](https://jenkins.dockerproject.org/buildStatus/icon?job=docker/compose/master)](https://jenkins.dockerproject.org/job/docker/job/compose/job/master/) +[![Build Status](https://ci-next.docker.com/public/buildStatus/icon?job=compose/master)](https://ci-next.docker.com/public/job/compose/job/master/) Want to help build Compose? Check out our [contributing documentation](https://github.com/docker/compose/blob/master/CONTRIBUTING.md). From 98abe0764637c48518b9af3c3bfedfd08559db67 Mon Sep 17 00:00:00 2001 From: Anca Iordache Date: Thu, 5 Mar 2020 15:46:45 +0100 Subject: [PATCH 3886/4072] Fix v3.8 schema support for binaries Signed-off-by: Anca Iordache --- docker-compose.spec | 5 +++++ docker-compose_darwin.spec | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/docker-compose.spec b/docker-compose.spec index 5ca1e4c24de..4c8db3e51b9 100644 --- a/docker-compose.spec +++ b/docker-compose.spec @@ -87,6 +87,11 @@ exe = EXE(pyz, 'compose/config/config_schema_v3.7.json', 'DATA' ), + ( + 'compose/config/config_schema_v3.8.json', + 'compose/config/config_schema_v3.8.json', + 'DATA' + ), ( 'compose/GITSHA', 'compose/GITSHA', diff --git a/docker-compose_darwin.spec b/docker-compose_darwin.spec index 344c070d501..df7fcdd6f66 100644 --- a/docker-compose_darwin.spec +++ b/docker-compose_darwin.spec @@ -96,6 +96,11 @@ coll = COLLECT(exe, 'compose/config/config_schema_v3.7.json', 'DATA' ), + ( + 'compose/config/config_schema_v3.8.json', + 'compose/config/config_schema_v3.8.json', + 'DATA' + ), ( 'compose/GITSHA', 'compose/GITSHA', From 0ace76114b520360fe9bb31a1098be4a1805d07a Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Tue, 25 Feb 2020 17:09:14 +0100 Subject: [PATCH 3887/4072] Add conformity tests hack - That can be used with: ./script/test/acceptance Signed-off-by: Ulysses Souza --- script/test/acceptance | 3 + tests/acceptance/cli_test.py | 4 +- tests/conftest.py | 243 +++++++++++++++++++++++++++++++++++ 3 files changed, 249 insertions(+), 1 deletion(-) create mode 100755 script/test/acceptance create mode 100644 tests/conftest.py diff --git a/script/test/acceptance b/script/test/acceptance new file mode 100755 index 00000000000..92710a76a8d --- /dev/null +++ b/script/test/acceptance @@ -0,0 +1,3 @@ +#!/usr/bin/env bash + +pytest --conformity --binary ${1:-docker-compose} tests/acceptance/ diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 43d5a3f5ccc..9f5853143d4 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -38,6 +38,8 @@ from tests.integration.testcases import v2_only from tests.integration.testcases import v3_only +DOCKER_COMPOSE_EXECUTABLE = 'docker-compose' + ProcessResult = namedtuple('ProcessResult', 'stdout stderr') @@ -65,7 +67,7 @@ def start_process(base_dir, options): proc = subprocess.Popen( - ['docker-compose'] + options, + [DOCKER_COMPOSE_EXECUTABLE] + options, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000000..013368689e2 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,243 @@ +from __future__ import absolute_import +from __future__ import unicode_literals + +import pytest + +import tests.acceptance.cli_test + +# FIXME Skipping all the acceptance tests when in `--conformity` +non_conformity_tests = [ + "test_build_failed", + "test_build_failed_forcerm", + "test_build_log_level", + "test_build_memory_build_option", + "test_build_no_cache", + "test_build_no_cache_pull", + "test_build_override_dir", + "test_build_override_dir_invalid_path", + "test_build_parallel", + "test_build_plain", + "test_build_pull", + "test_build_rm", + "test_build_shm_size_build_option", + "test_build_with_buildarg_cli_override", + "test_build_with_buildarg_from_compose_file", + "test_build_with_buildarg_old_api_version", + "test_config_compatibility_mode", + "test_config_compatibility_mode_from_env", + "test_config_compatibility_mode_from_env_and_option_precedence", + "test_config_default", + "test_config_external_network", + "test_config_external_network_v3_5", + "test_config_external_volume_v2", + "test_config_external_volume_v2_x", + "test_config_external_volume_v3_4", + "test_config_external_volume_v3_x", + "test_config_list_services", + "test_config_list_volumes", + "test_config_quiet", + "test_config_quiet_with_error", + "test_config_restart", + "test_config_stdin", + "test_config_v1", + "test_config_v3", + "test_config_with_dot_env", + "test_config_with_dot_env_and_override_dir", + "test_config_with_env_file", + "test_config_with_hash_option", + "test_create", + "test_create_with_force_recreate", + "test_create_with_force_recreate_and_no_recreate", + "test_create_with_no_recreate", + "test_down", + "test_down_invalid_rmi_flag", + "test_down_signal", + "test_down_timeout", + "test_env_file_relative_to_compose_file", + "test_events_human_readable", + "test_events_json", + "test_exec_custom_user", + "test_exec_detach_long_form", + "test_exec_novalue_var_dotenv_file", + "test_exec_service_with_environment_overridden", + "test_exec_without_tty", + "test_exec_workdir", + "test_exit_code_from_signal_stop", + "test_expanded_port", + "test_forward_exitval", + "test_help", + "test_help_nonexistent", + "test_home_and_env_var_in_volume_path", + "test_host_not_reachable", + "test_host_not_reachable_volumes_from_container", + "test_host_not_reachable_volumes_from_container", + "test_images", + "test_images_default_composefile", + "test_images_tagless_image", + "test_images_use_service_tag", + "test_kill", + "test_kill_signal_sigstop", + "test_kill_stopped_service", + "test_logs_default", + "test_logs_follow", + "test_logs_follow_logs_from_new_containers", + "test_logs_follow_logs_from_restarted_containers", + "test_logs_invalid_service_name", + "test_logs_on_stopped_containers_exits", + "test_logs_tail", + "test_logs_timestamps", + "test_pause_no_containers", + "test_pause_unpause", + "test_port", + "test_port_with_scale", + "test_ps", + "test_ps_all", + "test_ps_alternate_composefile", + "test_ps_default_composefile", + "test_ps_services_filter_option", + "test_ps_services_filter_status", + "test_pull", + "test_pull_can_build", + "test_pull_with_digest", + "test_pull_with_ignore_pull_failures", + "test_pull_with_include_deps", + "test_pull_with_no_deps", + "test_pull_with_parallel_failure", + "test_pull_with_quiet", + "test_quiet_build", + "test_restart", + "test_restart_no_containers", + "test_restart_stopped_container", + "test_rm", + "test_rm_all", + "test_rm_stop", + "test_run_detached_connects_to_network", + "test_run_does_not_recreate_linked_containers", + "test_run_env_values_from_system", + "test_run_handles_sighup", + "test_run_handles_sigint", + "test_run_handles_sigterm", + "test_run_interactive_connects_to_network", + "test_run_label_flag", + "test_run_one_off_with_multiple_volumes", + "test_run_one_off_with_volume", + "test_run_one_off_with_volume_merge", + "test_run_rm", + "test_run_service_with_compose_file_entrypoint", + "test_run_service_with_compose_file_entrypoint_and_command_overridden", + "test_run_service_with_compose_file_entrypoint_and_empty_string_command", + "test_run_service_with_compose_file_entrypoint_overridden", + "test_run_service_with_dependencies", + "test_run_service_with_dockerfile_entrypoint", + "test_run_service_with_dockerfile_entrypoint_and_command_overridden", + "test_run_service_with_dockerfile_entrypoint_overridden", + "test_run_service_with_environment_overridden", + "test_run_service_with_explicitly_mapped_ip_ports", + "test_run_service_with_explicitly_mapped_ports", + "test_run_service_with_links", + "test_run_service_with_map_ports", + "test_run_service_with_scaled_dependencies", + "test_run_service_with_unset_entrypoint", + "test_run_service_with_use_aliases", + "test_run_service_with_user_overridden", + "test_run_service_with_user_overridden_short_form", + "test_run_service_with_workdir_overridden", + "test_run_service_with_workdir_overridden_short_form", + "test_run_service_without_links", + "test_run_service_without_map_ports", + "test_run_unicode_env_values_from_system", + "test_run_with_custom_name", + "test_run_with_expose_ports", + "test_run_with_no_deps", + "test_run_without_command", + "test_scale", + "test_scale_v2_2", + "test_shorthand_host_opt", + "test_shorthand_host_opt_interactive", + "test_start_no_containers", + "test_stop", + "test_stop_signal", + "test_top_processes_running", + "test_top_services_not_running", + "test_top_services_running", + "test_unpause_no_containers", + "test_up", + "test_up_attached", + "test_up_detached", + "test_up_detached_long_form", + "test_up_external_networks", + "test_up_handles_abort_on_container_exit", + "test_up_handles_abort_on_container_exit_code", + "test_up_handles_aborted_dependencies", + "test_up_handles_force_shutdown", + "test_up_handles_sigint", + "test_up_handles_sigterm", + "test_up_logging", + "test_up_logging_legacy", + "test_up_missing_network", + "test_up_no_ansi", + "test_up_no_services", + "test_up_no_start", + "test_up_no_start_remove_orphans", + "test_up_scale_reset", + "test_up_scale_scale_down", + "test_up_scale_scale_up", + "test_up_scale_to_zero", + "test_up_with_attach_dependencies", + "test_up_with_default_network_config", + "test_up_with_default_override_file", + "test_up_with_duplicate_override_yaml_files", + "test_up_with_extends", + "test_up_with_external_default_network", + "test_up_with_force_recreate", + "test_up_with_force_recreate_and_no_recreate", + "test_up_with_healthcheck", + "test_up_with_ignore_remove_orphans", + "test_up_with_links_v1", + "test_up_with_multiple_files", + "test_up_with_net_is_invalid", + "test_up_with_net_v1", + "test_up_with_network_aliases", + "test_up_with_network_internal", + "test_up_with_network_labels", + "test_up_with_network_mode", + "test_up_with_network_static_addresses", + "test_up_with_networks", + "test_up_with_no_deps", + "test_up_with_no_recreate", + "test_up_with_override_yaml", + "test_up_with_pid_mode", + "test_up_with_timeout", + "test_up_with_volume_labels", + "test_fail_on_both_host_and_context_opt", + "test_fail_run_on_inexistent_context", +] + + +def pytest_addoption(parser): + parser.addoption( + "--conformity", + action="store_true", + default=False, + help="Only runs tests that are not black listed as non conformity test. " + "The conformity tests check for compatibility with the Compose spec." + ) + parser.addoption( + "--binary", + default=tests.acceptance.cli_test.DOCKER_COMPOSE_EXECUTABLE, + help="Forces the execution of a binary in the PATH. Default is `docker-compose`." + ) + + +def pytest_collection_modifyitems(config, items): + if not config.getoption("--conformity"): + return + if config.getoption("--binary"): + tests.acceptance.cli_test.DOCKER_COMPOSE_EXECUTABLE = config.getoption("--binary") + + print("Binary -> {}".format(tests.acceptance.cli_test.DOCKER_COMPOSE_EXECUTABLE)) + skip_non_conformity = pytest.mark.skip(reason="skipping because that's not a conformity test") + for item in items: + if item.name in non_conformity_tests: + print("Skipping '{}' when running in compatibility mode".format(item.name)) + item.add_marker(skip_non_conformity) From 1b9855c1c2dbbee6f5708de15d0b268b569aeba1 Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Fri, 13 Mar 2020 11:57:44 +0100 Subject: [PATCH 3888/4072] Fix pip install by adding python-dotenv to setup.py Signed-off-by: Ulysses Souza --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index 548e2bc9398..bca578e1102 100644 --- a/setup.py +++ b/setup.py @@ -40,6 +40,7 @@ def find_version(*file_paths): 'dockerpty >= 0.4.1, < 1', 'six >= 1.3.0, < 2', 'jsonschema >= 2.5.1, < 4', + 'python-dotenv >= 0.10.5, < 1', ] From 1a688289b42c6927c3e05304c5259db1f85c69c4 Mon Sep 17 00:00:00 2001 From: luHub Date: Sat, 28 Mar 2020 17:50:23 +0100 Subject: [PATCH 3889/4072] add labels to CLIbuilder Signed-off-by: luHub --- compose/service.py | 1 + tests/integration/service_test.py | 17 +++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/compose/service.py b/compose/service.py index ebe237b8cfc..92b2b3329f7 100644 --- a/compose/service.py +++ b/compose/service.py @@ -1792,6 +1792,7 @@ def build(self, path, tag=None, quiet=False, fileobj=None, command_builder.add_list("--cache-from", cache_from) command_builder.add_arg("--file", dockerfile) command_builder.add_flag("--force-rm", forcerm) + command_builder.add_params("--label", labels) command_builder.add_arg("--memory", container_limits.get("memory")) command_builder.add_flag("--no-cache", nocache) command_builder.add_arg("--progress", self._progress) diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index c50aab08bb2..01e2e1d22c4 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -985,6 +985,23 @@ def test_build_cli(self): self.addCleanup(self.client.remove_image, service.image_name) assert self.client.inspect_image('composetest_web') + def test_build_cli_with_build_labels(self): + base_dir = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, base_dir) + + with open(os.path.join(base_dir, 'Dockerfile'), 'w') as f: + f.write("FROM busybox\n") + + service = self.create_service('web', + build={ + 'context': base_dir, + 'labels': {'com.docker.compose.test': 'true'}}, + ) + service.build(cli=True) + self.addCleanup(self.client.remove_image, service.image_name) + image = self.client.inspect_image('composetest_web') + assert image['Config']['Labels']['com.docker.compose.test'] + def test_up_build_cli(self): base_dir = tempfile.mkdtemp() self.addCleanup(shutil.rmtree, base_dir) From 23889a8f88f364a2777493e9295919c9d71b7edf Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Thu, 9 Apr 2020 14:22:37 +0200 Subject: [PATCH 3890/4072] Bump OPENSSL to 1.1.1f Signed-off-by: Ulysses Souza --- script/setup/osx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/script/setup/osx b/script/setup/osx index 00cca06cf8b..00ae929b9fe 100755 --- a/script/setup/osx +++ b/script/setup/osx @@ -13,9 +13,9 @@ if ! [ ${DEPLOYMENT_TARGET} == "$(macos_version)" ]; then SDK_SHA1=dd228a335194e3392f1904ce49aff1b1da26ca62 fi -OPENSSL_VERSION=1.1.1d +OPENSSL_VERSION=1.1.1f OPENSSL_URL=https://www.openssl.org/source/openssl-${OPENSSL_VERSION}.tar.gz -OPENSSL_SHA1=056057782325134b76d1931c48f2c7e6595d7ef4 +OPENSSL_SHA1=238e001ea1fbf19ede43e36209c37c1a636bb51f PYTHON_VERSION=3.7.6 PYTHON_URL=https://www.python.org/ftp/python/${PYTHON_VERSION}/Python-${PYTHON_VERSION}.tgz From 27d43ed041f49c4cd68ac28a7a9e2229e929bb67 Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Thu, 16 Apr 2020 10:09:21 +0200 Subject: [PATCH 3891/4072] Update changelog to 1.25.5 Signed-off-by: Ulysses Souza --- CHANGELOG.md | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e8cd889f079..78f51227242 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,68 @@ Change log ========== +1.25.5 (2020-02-04) +------------------- + +### Features + +- Bump OpenSSL from 1.1.1d to 1.1.1f + +- Add 3.8 compose version + +1.25.4 (2020-01-23) +------------------- + +### Bugfixes + +- Fix CI script to enforce the minimal MacOS version to 10.11 + +- Fix docker-compose exec for keys with no value + +1.25.3 (2020-01-23) +------------------- + +### Bugfixes + +- Fix CI script to enforce the compilation with Python3 + +- Fix binary's sha256 in the release page + +1.25.2 (2020-01-20) +------------------- + +### Features + +- Allow compatibility option with `COMPOSE_COMPATIBILITY` environment variable + +- Bump PyInstaller from 3.5 to 3.6 + +- Bump pysocks from 1.6.7 to 1.7.1 + +- Bump websocket-client from 0.32.0 to 0.57.0 + +- Bump urllib3 from 1.24.2 to 1.25.7 + +- Bump jsonschema from 3.0.1 to 3.2.0 + +- Bump PyYAML from 4.2b1 to 5.3 + +- Bump certifi from 2017.4.17 to 2019.11.28 + +- Bump coverage from 4.5.4 to 5.0.3 + +- Bump paramiko from 2.6.0 to 2.7.1 + +- Bump cached-property from 1.3.0 to 1.5.1 + +- Bump minor Linux and MacOSX dependencies + +### Bugfixes + +- Validate version format on formats 2+ + +- Assume infinite terminal width when not running in a terminal + 1.25.1 (2020-01-06) ------------------- From a62a1e1d6273d1b2021b072603ee9ffc86afc5c5 Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Thu, 16 Apr 2020 17:04:40 +0200 Subject: [PATCH 3892/4072] Add "distro" package This package implements the method 'platform.linux_distribution' removed in Python 3.8 Signed-off-by: Ulysses Souza --- compose/cli/utils.py | 3 ++- requirements.txt | 1 + setup.py | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/compose/cli/utils.py b/compose/cli/utils.py index 931487a6cdc..40bef165202 100644 --- a/compose/cli/utils.py +++ b/compose/cli/utils.py @@ -9,6 +9,7 @@ import subprocess import sys +import distro import docker import six @@ -73,7 +74,7 @@ def is_mac(): def is_ubuntu(): - return platform.system() == 'Linux' and platform.linux_distribution()[0] == 'Ubuntu' + return platform.system() == 'Linux' and distro.linux_distribution()[0] == 'Ubuntu' def is_windows(): diff --git a/requirements.txt b/requirements.txt index b1bc694427f..71b07540e81 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,6 +4,7 @@ cached-property==1.5.1 certifi==2019.11.28 chardet==3.0.4 colorama==0.4.3; sys_platform == 'win32' +distro==1.5.0 docker==4.2.0 docker-pycreds==0.4.0 dockerpty==0.4.1 diff --git a/setup.py b/setup.py index bca578e1102..cf273ed5481 100644 --- a/setup.py +++ b/setup.py @@ -36,6 +36,7 @@ def find_version(*file_paths): 'requests >= 2.20.0, < 3', 'texttable >= 0.9.0, < 2', 'websocket-client >= 0.32.0, < 1', + 'distro >= 1.5.0, < 2', 'docker[ssh] >= 3.7.0, < 5', 'dockerpty >= 0.4.1, < 1', 'six >= 1.3.0, < 2', From b7d6dc79410a8aaa2fec06f8461627df8da147d4 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2020 15:48:12 +0000 Subject: [PATCH 3893/4072] Bump certifi from 2019.11.28 to 2020.4.5.1 Bumps [certifi](https://github.com/certifi/python-certifi) from 2019.11.28 to 2020.4.5.1. - [Release notes](https://github.com/certifi/python-certifi/releases) - [Commits](https://github.com/certifi/python-certifi/commits) Signed-off-by: dependabot-preview[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 71b07540e81..933bcf8d43f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ backports.shutil_get_terminal_size==1.0.0 backports.ssl-match-hostname==3.5.0.1; python_version < '3' cached-property==1.5.1 -certifi==2019.11.28 +certifi==2020.4.5.1 chardet==3.0.4 colorama==0.4.3; sys_platform == 'win32' distro==1.5.0 From ce782b592fb20ef1d18e7e30d94680dbb4883ff8 Mon Sep 17 00:00:00 2001 From: Joe Hattori Date: Sun, 19 Apr 2020 01:52:19 +0900 Subject: [PATCH 3894/4072] Simplify code in compose/config/config.py Signed-off-by: Joe Hattori --- compose/config/config.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index a5f7e35aacb..444a40325ea 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -505,9 +505,7 @@ def merge_services(base, override): 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) + service_config = functools.reduce(merge_services, service_configs) return build_services(service_config) From d52b51e8eac6546b803e2ab3a3a9645417185657 Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Mon, 20 Apr 2020 18:31:52 +0200 Subject: [PATCH 3895/4072] Bump python-dotenv from 0.11.0 to 0.13.0 Signed-off-by: Ulysses Souza --- requirements.txt | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 933bcf8d43f..1f78d378bfe 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,7 +18,7 @@ paramiko==2.7.1 pypiwin32==219; sys_platform == 'win32' and python_version < '3.6' pypiwin32==223; sys_platform == 'win32' and python_version >= '3.6' PySocks==1.7.1 -python-dotenv==0.11.0 +python-dotenv==0.13.0 PyYAML==5.3 requests==2.22.0 six==1.12.0 diff --git a/setup.py b/setup.py index cf273ed5481..efc144b7fb9 100644 --- a/setup.py +++ b/setup.py @@ -41,7 +41,7 @@ def find_version(*file_paths): 'dockerpty >= 0.4.1, < 1', 'six >= 1.3.0, < 2', 'jsonschema >= 2.5.1, < 4', - 'python-dotenv >= 0.10.5, < 1', + 'python-dotenv >= 0.13.0, < 1', ] From d64f3f39122fce334dba8ec33daae7e889dae7cb Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Tue, 21 Apr 2020 11:04:10 +0200 Subject: [PATCH 3896/4072] Remove unused files Signed-off-by: Ulysses Souza --- .fossa.yml | 14 -------------- Dockerfile.s390x | 15 --------------- 2 files changed, 29 deletions(-) delete mode 100644 .fossa.yml delete mode 100644 Dockerfile.s390x diff --git a/.fossa.yml b/.fossa.yml deleted file mode 100644 index b50761ef14a..00000000000 --- a/.fossa.yml +++ /dev/null @@ -1,14 +0,0 @@ -# Generated by FOSSA CLI (https://github.com/fossas/fossa-cli) -# Visit https://fossa.io to learn more - -version: 2 -cli: - server: https://app.fossa.io - fetcher: custom - project: git@github.com:docker/compose -analyze: - modules: - - name: . - type: pip - target: . - path: . diff --git a/Dockerfile.s390x b/Dockerfile.s390x deleted file mode 100644 index 9bae72d6734..00000000000 --- a/Dockerfile.s390x +++ /dev/null @@ -1,15 +0,0 @@ -FROM s390x/alpine:3.10.1 - -ARG COMPOSE_VERSION=1.16.1 - -RUN apk add --update --no-cache \ - python \ - py-pip \ - && pip install --no-cache-dir docker-compose==$COMPOSE_VERSION \ - && rm -rf /var/cache/apk/* - -WORKDIR /data -VOLUME /data - - -ENTRYPOINT ["docker-compose"] From 836e2b7c4dd9c65b7e829104d116ee2050021343 Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Tue, 21 Apr 2020 10:45:40 +0200 Subject: [PATCH 3897/4072] General bumps Signed-off-by: Ulysses Souza --- Dockerfile | 8 ++++---- Jenkinsfile | 2 +- README.md | 2 +- Release.Jenkinsfile | 2 +- script/run/run.sh | 2 +- script/setup/osx | 8 ++++---- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/Dockerfile b/Dockerfile index 918c6876b9d..1f7608e24ae 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,9 @@ -ARG DOCKER_VERSION=19.03.5 -ARG PYTHON_VERSION=3.7.6 +ARG DOCKER_VERSION=19.03.8 +ARG PYTHON_VERSION=3.7.7 ARG BUILD_ALPINE_VERSION=3.11 ARG BUILD_DEBIAN_VERSION=slim-stretch -ARG RUNTIME_ALPINE_VERSION=3.11.3 -ARG RUNTIME_DEBIAN_VERSION=stretch-20191224-slim +ARG RUNTIME_ALPINE_VERSION=3.11.5 +ARG RUNTIME_DEBIAN_VERSION=stretch-20200414-slim ARG BUILD_PLATFORM=alpine diff --git a/Jenkinsfile b/Jenkinsfile index 5dd1101c2c7..127a26c7fa1 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -1,6 +1,6 @@ #!groovy -def dockerVersions = ['19.03.5'] +def dockerVersions = ['19.03.8'] def baseImages = ['alpine', 'debian'] def pythonVersions = ['py37'] diff --git a/README.md b/README.md index 27c8dcb34c2..18f7e1bc122 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Docker Compose ============== ![Docker Compose](logo.png?raw=true "Docker Compose Logo") -## :exclamation: The docker-compose project announces that as Python 2 reaches it's EOL, versions 1.25.x will be the last to support it. For more information, please refer to this [issue](https://github.com/docker/compose/issues/6890). +## :exclamation: The docker-compose project announces that as Python 2 has reached it's EOL, versions 1.26.x will be the last to support it. For more information, please refer to this [issue](https://github.com/docker/compose/issues/6890). 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. diff --git a/Release.Jenkinsfile b/Release.Jenkinsfile index 953e19e4d91..8aa741e561e 100644 --- a/Release.Jenkinsfile +++ b/Release.Jenkinsfile @@ -1,6 +1,6 @@ #!groovy -def dockerVersions = ['19.03.5', '18.09.9'] +def dockerVersions = ['19.03.8', '18.09.9'] def baseImages = ['alpine', 'debian'] def pythonVersions = ['py37'] diff --git a/script/run/run.sh b/script/run/run.sh index 7df5fe979d9..cd566f3349d 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.25.1" +VERSION="1.25.5" IMAGE="docker/compose:$VERSION" diff --git a/script/setup/osx b/script/setup/osx index 00ae929b9fe..678b7f22a47 100755 --- a/script/setup/osx +++ b/script/setup/osx @@ -13,13 +13,13 @@ if ! [ ${DEPLOYMENT_TARGET} == "$(macos_version)" ]; then SDK_SHA1=dd228a335194e3392f1904ce49aff1b1da26ca62 fi -OPENSSL_VERSION=1.1.1f +OPENSSL_VERSION=1.1.1g OPENSSL_URL=https://www.openssl.org/source/openssl-${OPENSSL_VERSION}.tar.gz -OPENSSL_SHA1=238e001ea1fbf19ede43e36209c37c1a636bb51f +OPENSSL_SHA1=b213a293f2127ec3e323fb3cfc0c9807664fd997 -PYTHON_VERSION=3.7.6 +PYTHON_VERSION=3.7.7 PYTHON_URL=https://www.python.org/ftp/python/${PYTHON_VERSION}/Python-${PYTHON_VERSION}.tgz -PYTHON_SHA1=4642680fbf9a9a5382597dc0e9faa058fdfd94e2 +PYTHON_SHA1=8e9968663a214aea29659ba9dfa959e8a7d82b39 # # Install prerequisites. From 9f6ac73c651805f11d6eca57da6c89fc4d7ded9a Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Wed, 29 Apr 2020 17:44:41 +0200 Subject: [PATCH 3898/4072] Add bash completion for `--context` Signed-off-by: Harald Albers --- contrib/completion/bash/docker-compose | 1 + 1 file changed, 1 insertion(+) diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index ad0ce44c177..04f1a78ae76 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -603,6 +603,7 @@ _docker_compose() { --tlsverify " local daemon_options_with_args=" + --context -c --env-file --file -f --host -H From 982069903d986cb38152e1da662148db8441e04c Mon Sep 17 00:00:00 2001 From: Mike Zak Date: Mon, 4 May 2020 09:28:42 +0300 Subject: [PATCH 3899/4072] Add 'local' to list of logging drivers that support `docker-compose logs` Signed-off-by: Mike Zak --- compose/container.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/container.py b/compose/container.py index 8a2fb240e0d..a5695c73c7e 100644 --- a/compose/container.py +++ b/compose/container.py @@ -193,7 +193,7 @@ def log_driver(self): @property def has_api_logs(self): log_type = self.log_driver - return not log_type or log_type in ('json-file', 'journald') + return not log_type or log_type in ('json-file', 'journald', 'local') @property def human_readable_health_status(self): From f873aeace3438312a67fda8dcb61cb4dd113f7d6 Mon Sep 17 00:00:00 2001 From: Mike Zak Date: Mon, 4 May 2020 11:07:50 +0300 Subject: [PATCH 3900/4072] Update comment about log drivers that support log stream Signed-off-by: Mike Zak --- compose/container.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/compose/container.py b/compose/container.py index a5695c73c7e..c49b23c7f11 100644 --- a/compose/container.py +++ b/compose/container.py @@ -208,8 +208,8 @@ def human_readable_health_status(self): return status_string def attach_log_stream(self): - """A log stream can only be attached if the container uses a json-file - log driver. + """A log stream can only be attached if the container uses a + json-file, journald or local log driver. """ if self.has_api_logs: self.log_stream = self.attach(stdout=True, stderr=True, stream=True) From 2883d8fa5d179fa21161910c31404879e1405260 Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Wed, 20 May 2020 10:45:39 +0200 Subject: [PATCH 3901/4072] Fix flake8 errors Signed-off-by: Ulysses Souza --- compose/cli/main.py | 2 +- compose/service.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index e226a600869..250d06c90cf 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -1080,7 +1080,7 @@ def up(rebuild): log.error( "The image for the service you're trying to recreate has been removed. " "If you continue, volume data could be lost. Consider backing up your data " - "before continuing.\n".format(e.explanation) + "before continuing.\n" ) res = yesno("Continue with the new image? [yN]", False) if res is None or not res: diff --git a/compose/service.py b/compose/service.py index 92b2b3329f7..95a87d210d2 100644 --- a/compose/service.py +++ b/compose/service.py @@ -1157,7 +1157,7 @@ def get_container_name(self, service_name, number, slug=None): container_name = build_container_name( self.project, service_name, number, slug, ) - ext_links_origins = [l.split(':')[0] for l in self.options.get('external_links', [])] + ext_links_origins = [link.split(':')[0] for link in self.options.get('external_links', [])] if container_name in ext_links_origins: raise DependencyError( 'Service {0} has a self-referential external link: {1}'.format( From 00b7c613e774aed6a2dc765d9801b4307c850bb4 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Wed, 20 May 2020 11:59:44 +0000 Subject: [PATCH 3902/4072] Bump ddt from 1.2.2 to 1.4.1 Bumps [ddt](https://github.com/datadriventests/ddt) from 1.2.2 to 1.4.1. - [Release notes](https://github.com/datadriventests/ddt/releases) - [Commits](https://github.com/datadriventests/ddt/compare/1.2.2...1.4.1) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 7f17d7bba3f..04a29310fb5 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,6 +1,6 @@ Click==7.0 coverage==5.0.3 -ddt==1.2.2 +ddt==1.4.1 flake8==3.7.9 gitpython==2.1.15 mock==3.0.5 From 9be0f7bbe65c50447bb125aa4c1b8d29db182f33 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Wed, 20 May 2020 12:01:08 +0000 Subject: [PATCH 3903/4072] Bump urllib3 from 1.25.7 to 1.25.9 Bumps [urllib3](https://github.com/urllib3/urllib3) from 1.25.7 to 1.25.9. - [Release notes](https://github.com/urllib3/urllib3/releases) - [Changelog](https://github.com/urllib3/urllib3/blob/master/CHANGES.rst) - [Commits](https://github.com/urllib3/urllib3/compare/1.25.7...1.25.9) Signed-off-by: dependabot-preview[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 1f78d378bfe..d3bf986c1e1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -24,5 +24,5 @@ requests==2.22.0 six==1.12.0 subprocess32==3.5.4; python_version < '3.2' texttable==1.6.2 -urllib3==1.25.7; python_version == '3.3' +urllib3==1.25.9; python_version == '3.3' websocket-client==0.57.0 From 675be344b853d711bc3bc9fa5b482ebeb4e9f57b Mon Sep 17 00:00:00 2001 From: mistergij Date: Mon, 18 May 2020 18:38:38 -0400 Subject: [PATCH 3904/4072] Should fix typo in README.md Signed-off-by: mistergij --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 18f7e1bc122..b867ee9232b 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Docker Compose ============== ![Docker Compose](logo.png?raw=true "Docker Compose Logo") -## :exclamation: The docker-compose project announces that as Python 2 has reached it's EOL, versions 1.26.x will be the last to support it. For more information, please refer to this [issue](https://github.com/docker/compose/issues/6890). +## :exclamation: The docker-compose project announces that as Python 2 has reached its EOL, versions 1.26.x will be the last to support it. For more information, please refer to this [issue](https://github.com/docker/compose/issues/6890). 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. From 5ed07556ba9f0043486a25788e22b82060df4ef9 Mon Sep 17 00:00:00 2001 From: alexrecuenco Date: Sat, 14 Mar 2020 18:03:34 +0700 Subject: [PATCH 3905/4072] Show diff on pre-commit failure This would provide a better CI experience since this is run within a docker image on `./scripts/tests/default` Signed-off-by: alexrecuenco --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 57e57bc635e..5f57cfb1182 100644 --- a/tox.ini +++ b/tox.ini @@ -32,7 +32,7 @@ deps = pre-commit commands = pre-commit install - pre-commit run --all-files + pre-commit run --all-files --show-diff-on-failure # Coverage configuration [run] From d4baba057bd20962e40cdd66a075dc7d01eefc3b Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Wed, 20 May 2020 12:37:54 +0000 Subject: [PATCH 3906/4072] Bump flake8 from 3.7.9 to 3.8.1 Bumps [flake8](https://gitlab.com/pycqa/flake8) from 3.7.9 to 3.8.1. - [Release notes](https://gitlab.com/pycqa/flake8/tags) - [Commits](https://gitlab.com/pycqa/flake8/compare/3.7.9...3.8.1) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 04a29310fb5..51639038577 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,7 +1,7 @@ Click==7.0 coverage==5.0.3 ddt==1.4.1 -flake8==3.7.9 +flake8==3.8.1 gitpython==2.1.15 mock==3.0.5 pytest==5.3.4; python_version >= '3.5' From e6a9445f59e74e29264c0cdba6a1da83dae5238a Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Wed, 20 May 2020 13:00:36 +0000 Subject: [PATCH 3907/4072] Bump click from 7.0 to 7.1.2 Bumps [click](https://github.com/pallets/click) from 7.0 to 7.1.2. - [Release notes](https://github.com/pallets/click/releases) - [Changelog](https://github.com/pallets/click/blob/7.1.2/CHANGES.rst) - [Commits](https://github.com/pallets/click/compare/7.0...7.1.2) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 51639038577..6b795f00bd1 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,4 +1,4 @@ -Click==7.0 +Click==7.1.2 coverage==5.0.3 ddt==1.4.1 flake8==3.8.1 From b967d2f06b755059080674322b75c24a64b45d82 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Wed, 20 May 2020 13:00:41 +0000 Subject: [PATCH 3908/4072] Bump pytest from 5.3.4 to 5.4.2 Bumps [pytest](https://github.com/pytest-dev/pytest) from 5.3.4 to 5.4.2. - [Release notes](https://github.com/pytest-dev/pytest/releases) - [Changelog](https://github.com/pytest-dev/pytest/blob/master/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest/compare/5.3.4...5.4.2) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 51639038577..3b2dfc6a672 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -4,6 +4,6 @@ ddt==1.4.1 flake8==3.8.1 gitpython==2.1.15 mock==3.0.5 -pytest==5.3.4; python_version >= '3.5' +pytest==5.4.2; python_version >= '3.5' pytest==4.6.5; python_version < '3.5' pytest-cov==2.8.1 From 99562d9df99c2d63e715c459e56ee681691d50ce Mon Sep 17 00:00:00 2001 From: Kevin Kirsche Date: Wed, 20 May 2020 18:30:57 -0400 Subject: [PATCH 3909/4072] Fix Typos ./MAINTAINERS:32: maitainers ==> maintainers ./tests/fixtures/simple-failing-dockerfile/Dockerfile:4: wil ==> will Signed-off-by: Kevin Kirsche --- MAINTAINERS | 2 +- tests/fixtures/simple-failing-dockerfile/Dockerfile | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/MAINTAINERS b/MAINTAINERS index 27bde5bb38e..273f724ec44 100644 --- a/MAINTAINERS +++ b/MAINTAINERS @@ -29,7 +29,7 @@ # in 2015 with solid bugfixes and improved error handling # among them "mnowster", - # Daniel Nephin is one of the longest-running maitainers on + # Daniel Nephin is one of the longest-running maintainers on # the Compose project, and has contributed several major features # including muti-file support, variable interpolation, secrets # emulation and many more diff --git a/tests/fixtures/simple-failing-dockerfile/Dockerfile b/tests/fixtures/simple-failing-dockerfile/Dockerfile index 205021a23f5..a3328b0d5ec 100644 --- a/tests/fixtures/simple-failing-dockerfile/Dockerfile +++ b/tests/fixtures/simple-failing-dockerfile/Dockerfile @@ -1,7 +1,7 @@ FROM busybox:1.31.0-uclibc LABEL com.docker.compose.test_image=true LABEL com.docker.compose.test_failing_image=true -# With the following label the container wil be cleaned up automatically +# With the following label the container will be cleaned up automatically # Must be kept in sync with LABEL_PROJECT from compose/const.py LABEL com.docker.compose.project=composetest RUN exit 1 From d80e08b3e98d377072a43a89b347deeb79d76446 Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Tue, 2 Jun 2020 23:56:34 +0200 Subject: [PATCH 3910/4072] Pin wcwidth==0.1.9 wcwidth version 0.2.0 until at least 0.2.3 results in: [745] Failed to execute script pyi_rth_pkgres Traceback (most recent call last): File "site-packages/PyInstaller/loader/rthooks/pyi_rth_pkgres.py", line 13, in File "/code/.tox/py37/lib/python3.7/site-packages/PyInstaller/loader/pyimod03_importers.py", line 623, in exec_module exec(bytecode, module.__dict__) File "site-packages/pkg_resources/__init__.py", line 86, in ModuleNotFoundError: No module named 'pkg_resources.py2_warn' Signed-off-by: Ulysses Souza --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index d3bf986c1e1..f02e15eac3b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -25,4 +25,5 @@ six==1.12.0 subprocess32==3.5.4; python_version < '3.2' texttable==1.6.2 urllib3==1.25.9; python_version == '3.3' +wcwidth==0.1.9 websocket-client==0.57.0 From 5254d7aca2fe4105722692e9998b01afd4659233 Mon Sep 17 00:00:00 2001 From: Christopher Crone Date: Wed, 3 Jun 2020 11:36:18 +0200 Subject: [PATCH 3911/4072] README: Remove Python 2 deprecation message Signed-off-by: Christopher Crone --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index b867ee9232b..c9ea28f710f 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,6 @@ Docker Compose ============== ![Docker Compose](logo.png?raw=true "Docker Compose Logo") -## :exclamation: The docker-compose project announces that as Python 2 has reached its EOL, versions 1.26.x will be the last to support it. For more information, please refer to this [issue](https://github.com/docker/compose/issues/6890). - 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 e8424d5ae0de01fae98fa7aaff3c79cbb1dfcf2b Mon Sep 17 00:00:00 2001 From: Bastian Venthur Date: Tue, 19 Nov 2019 14:47:57 +0100 Subject: [PATCH 3912/4072] Removed Python2 support Closes: #6890 Signed-off-by: Bastian Venthur --- .pre-commit-config.yaml | 7 +- Dockerfile | 3 - bin/docker-compose | 3 - compose/__init__.py | 2 - compose/__main__.py | 3 - compose/cli/colors.py | 3 - compose/cli/command.py | 3 - compose/cli/docker_client.py | 3 - compose/cli/docopt_command.py | 3 - compose/cli/errors.py | 3 - compose/cli/formatter.py | 3 - compose/cli/log_printer.py | 9 +- compose/cli/main.py | 4 - compose/cli/signals.py | 3 - compose/cli/utils.py | 4 - compose/cli/verbose_proxy.py | 3 - compose/config/__init__.py | 3 - compose/config/config.py | 3 - compose/config/environment.py | 5 +- compose/config/errors.py | 2 - compose/config/interpolation.py | 3 - compose/config/serialize.py | 3 - compose/config/sort_services.py | 3 - compose/config/types.py | 3 - compose/config/validation.py | 3 - compose/const.py | 3 - compose/container.py | 3 - compose/errors.py | 2 - compose/network.py | 3 - compose/parallel.py | 9 +- compose/progress_stream.py | 3 - compose/project.py | 5 +- compose/service.py | 5 +- compose/timeparse.py | 3 - compose/utils.py | 3 - compose/version.py | 3 - compose/volume.py | 3 - .../migrate-compose-file-v1-to-v2.py | 3 - requirements.txt | 1 - script/release/release.py | 126 ------------------ script/release/utils.py | 3 - script/test/all | 4 +- script/test/versions.py | 4 - setup.py | 6 - tests/__init__.py | 3 - tests/acceptance/cli_test.py | 3 - tests/helpers.py | 4 - tests/integration/environment_test.py | 3 - tests/integration/network_test.py | 3 - tests/integration/project_test.py | 3 - tests/integration/resilience_test.py | 3 - tests/integration/service_test.py | 5 +- tests/integration/state_test.py | 3 - tests/integration/testcases.py | 3 - tests/integration/volume_test.py | 3 - tests/unit/cli/command_test.py | 3 - tests/unit/cli/docker_client_test.py | 3 - tests/unit/cli/errors_test.py | 3 - tests/unit/cli/formatter_test.py | 3 - tests/unit/cli/log_printer_test.py | 5 +- tests/unit/cli/main_test.py | 3 - tests/unit/cli/utils_test.py | 3 - tests/unit/cli/verbose_proxy_test.py | 3 - tests/unit/cli_test.py | 3 - tests/unit/config/config_test.py | 4 - tests/unit/config/environment_test.py | 4 - tests/unit/config/interpolation_test.py | 3 - tests/unit/config/sort_services_test.py | 3 - tests/unit/config/types_test.py | 3 - tests/unit/container_test.py | 3 - tests/unit/network_test.py | 3 - tests/unit/parallel_test.py | 3 - tests/unit/progress_stream_test.py | 6 +- tests/unit/project_test.py | 3 - tests/unit/service_test.py | 3 - tests/unit/split_buffer_test.py | 3 - tests/unit/timeparse_test.py | 3 - tests/unit/utils_test.py | 3 - tests/unit/volume_test.py | 3 - tox.ini | 2 +- 80 files changed, 18 insertions(+), 378 deletions(-) delete mode 100755 script/release/release.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e447294eb7a..a2aeb014a00 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -17,9 +17,6 @@ sha: v1.3.4 hooks: - id: reorder-python-imports - language_version: 'python2.7' + language_version: 'python3.7' args: - - --add-import - - from __future__ import absolute_import - - --add-import - - from __future__ import unicode_literals + - --py3-plus diff --git a/Dockerfile b/Dockerfile index 1f7608e24ae..9439d0e4f0c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,8 +24,6 @@ RUN apk add --no-cache \ musl-dev \ openssl \ openssl-dev \ - python2 \ - python2-dev \ zlib-dev ENV BUILD_BOOTLOADER=1 @@ -40,7 +38,6 @@ RUN apt-get update && apt-get install --no-install-recommends -y \ libssl-dev \ make \ openssl \ - python2.7-dev \ zlib1g-dev FROM build-${BUILD_PLATFORM} AS build diff --git a/bin/docker-compose b/bin/docker-compose index aeb53870303..5976e1d4aa5 100755 --- a/bin/docker-compose +++ b/bin/docker-compose @@ -1,6 +1,3 @@ #!/usr/bin/env python -from __future__ import absolute_import -from __future__ import unicode_literals - from compose.cli.main import main main() diff --git a/compose/__init__.py b/compose/__init__.py index 69c4e0e49d8..32f0afd92ec 100644 --- a/compose/__init__.py +++ b/compose/__init__.py @@ -1,4 +1,2 @@ -from __future__ import absolute_import -from __future__ import unicode_literals __version__ = '1.26.0dev' diff --git a/compose/__main__.py b/compose/__main__.py index 27a7acbb8d1..199ba2ae9b4 100644 --- a/compose/__main__.py +++ b/compose/__main__.py @@ -1,6 +1,3 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - from compose.cli.main import main main() diff --git a/compose/cli/colors.py b/compose/cli/colors.py index ea45198e07d..c6a869bf59d 100644 --- a/compose/cli/colors.py +++ b/compose/cli/colors.py @@ -1,6 +1,3 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - from ..const import IS_WINDOWS_PLATFORM NAMES = [ diff --git a/compose/cli/command.py b/compose/cli/command.py index 7621134e8f7..6c88e58192c 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -1,6 +1,3 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - import logging import os import re diff --git a/compose/cli/docker_client.py b/compose/cli/docker_client.py index d4cdc96e8f5..3abeb5cdc3f 100644 --- a/compose/cli/docker_client.py +++ b/compose/cli/docker_client.py @@ -1,6 +1,3 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - import logging import os.path import ssl diff --git a/compose/cli/docopt_command.py b/compose/cli/docopt_command.py index 809a4b7455e..856c9234818 100644 --- a/compose/cli/docopt_command.py +++ b/compose/cli/docopt_command.py @@ -1,6 +1,3 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - from inspect import getdoc from docopt import docopt diff --git a/compose/cli/errors.py b/compose/cli/errors.py index 189b67faf67..d1a47f07833 100644 --- a/compose/cli/errors.py +++ b/compose/cli/errors.py @@ -1,6 +1,3 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - import contextlib import logging import socket diff --git a/compose/cli/formatter.py b/compose/cli/formatter.py index 9651fb4da44..4dabe77c29e 100644 --- a/compose/cli/formatter.py +++ b/compose/cli/formatter.py @@ -1,6 +1,3 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - import logging import shutil diff --git a/compose/cli/log_printer.py b/compose/cli/log_printer.py index a4b70a67205..fd193000f5c 100644 --- a/compose/cli/log_printer.py +++ b/compose/cli/log_printer.py @@ -1,15 +1,12 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - +import _thread as thread import sys from collections import namedtuple from itertools import cycle +from queue import Empty +from queue import Queue 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 . import colors from compose import utils diff --git a/compose/cli/main.py b/compose/cli/main.py index 250d06c90cf..714592a3236 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import print_function -from __future__ import unicode_literals - import contextlib import functools import json diff --git a/compose/cli/signals.py b/compose/cli/signals.py index 44def2ece65..0244e70189a 100644 --- a/compose/cli/signals.py +++ b/compose/cli/signals.py @@ -1,6 +1,3 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - import signal from ..const import IS_WINDOWS_PLATFORM diff --git a/compose/cli/utils.py b/compose/cli/utils.py index 40bef165202..65ba74dbba5 100644 --- a/compose/cli/utils.py +++ b/compose/cli/utils.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import unicode_literals - import math import os import platform diff --git a/compose/cli/verbose_proxy.py b/compose/cli/verbose_proxy.py index b1592eabe73..68dfabe521c 100644 --- a/compose/cli/verbose_proxy.py +++ b/compose/cli/verbose_proxy.py @@ -1,6 +1,3 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - import functools import logging import pprint diff --git a/compose/config/__init__.py b/compose/config/__init__.py index 2b40666f149..855b2401d94 100644 --- a/compose/config/__init__.py +++ b/compose/config/__init__.py @@ -1,7 +1,4 @@ # flake8: noqa -from __future__ import absolute_import -from __future__ import unicode_literals - from . import environment from .config import ConfigurationError from .config import DOCKER_CONFIG_KEYS diff --git a/compose/config/config.py b/compose/config/config.py index 444a40325ea..52863b13c77 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -1,6 +1,3 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - import functools import io import logging diff --git a/compose/config/environment.py b/compose/config/environment.py index d8810ebb61b..abcafe0ce09 100644 --- a/compose/config/environment.py +++ b/compose/config/environment.py @@ -1,6 +1,5 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - +import codecs +import contextlib import logging import os import re diff --git a/compose/config/errors.py b/compose/config/errors.py index 9b2078f2c6d..7db079e9c78 100644 --- a/compose/config/errors.py +++ b/compose/config/errors.py @@ -1,5 +1,3 @@ -from __future__ import absolute_import -from __future__ import unicode_literals VERSION_EXPLANATION = ( diff --git a/compose/config/interpolation.py b/compose/config/interpolation.py index 18be8562cbb..f202e911447 100644 --- a/compose/config/interpolation.py +++ b/compose/config/interpolation.py @@ -1,6 +1,3 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - import logging import re from string import Template diff --git a/compose/config/serialize.py b/compose/config/serialize.py index 5776ce957cb..79b5777cb46 100644 --- a/compose/config/serialize.py +++ b/compose/config/serialize.py @@ -1,6 +1,3 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - import six import yaml diff --git a/compose/config/sort_services.py b/compose/config/sort_services.py index 42f548a6dd1..a600139b2ac 100644 --- a/compose/config/sort_services.py +++ b/compose/config/sort_services.py @@ -1,6 +1,3 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - from compose.config.errors import DependencyError diff --git a/compose/config/types.py b/compose/config/types.py index ab8f34e3d7f..4a7a25b2bce 100644 --- a/compose/config/types.py +++ b/compose/config/types.py @@ -1,9 +1,6 @@ """ Types for objects parsed from the configuration. """ -from __future__ import absolute_import -from __future__ import unicode_literals - import json import ntpath import os diff --git a/compose/config/validation.py b/compose/config/validation.py index 1cceb71f0a4..f8513bdef58 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -1,6 +1,3 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - import json import logging import os diff --git a/compose/const.py b/compose/const.py index 3d9030520b0..d80e23c9ef8 100644 --- a/compose/const.py +++ b/compose/const.py @@ -1,6 +1,3 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - import sys from .version import ComposeVersion diff --git a/compose/container.py b/compose/container.py index 8a2fb240e0d..37de4e6f625 100644 --- a/compose/container.py +++ b/compose/container.py @@ -1,6 +1,3 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - from functools import reduce import six diff --git a/compose/errors.py b/compose/errors.py index 415b41e7f04..53065617360 100644 --- a/compose/errors.py +++ b/compose/errors.py @@ -1,5 +1,3 @@ -from __future__ import absolute_import -from __future__ import unicode_literals class OperationFailedError(Exception): diff --git a/compose/network.py b/compose/network.py index 84531ecc7b8..bc3ade16870 100644 --- a/compose/network.py +++ b/compose/network.py @@ -1,6 +1,3 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - import logging import re from collections import OrderedDict diff --git a/compose/parallel.py b/compose/parallel.py index e242a318ae7..6ef9ceedd35 100644 --- a/compose/parallel.py +++ b/compose/parallel.py @@ -1,18 +1,15 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - +import _thread as thread import logging import operator import sys +from queue import Empty +from queue import Queue from threading import Lock from threading import Semaphore from threading import Thread from docker.errors import APIError from docker.errors import ImageNotFound -from six.moves import _thread as thread -from six.moves.queue import Empty -from six.moves.queue import Queue from compose.cli.colors import green from compose.cli.colors import red diff --git a/compose/progress_stream.py b/compose/progress_stream.py index 522ddf75d19..0ae0dc3161f 100644 --- a/compose/progress_stream.py +++ b/compose/progress_stream.py @@ -1,6 +1,3 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - from compose import utils diff --git a/compose/project.py b/compose/project.py index 696c8b04023..90a6f5cd53c 100644 --- a/compose/project.py +++ b/compose/project.py @@ -1,14 +1,11 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - import datetime +import enum import logging import operator import re from functools import reduce from os import path -import enum import six from docker.errors import APIError from docker.errors import ImageNotFound diff --git a/compose/service.py b/compose/service.py index 95a87d210d2..18173cf7058 100644 --- a/compose/service.py +++ b/compose/service.py @@ -1,6 +1,4 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - +import enum import itertools import json import logging @@ -12,7 +10,6 @@ from collections import OrderedDict from operator import attrgetter -import enum import six from docker.errors import APIError from docker.errors import ImageNotFound diff --git a/compose/timeparse.py b/compose/timeparse.py index 16ef8a6dc91..bdd9f611f3a 100644 --- a/compose/timeparse.py +++ b/compose/timeparse.py @@ -31,9 +31,6 @@ # 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' diff --git a/compose/utils.py b/compose/utils.py index a1e5e6435d8..4ea163c42c3 100644 --- a/compose/utils.py +++ b/compose/utils.py @@ -1,6 +1,3 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - import codecs import hashlib import json.decoder diff --git a/compose/version.py b/compose/version.py index 0532e16c717..c039263acb9 100644 --- a/compose/version.py +++ b/compose/version.py @@ -1,6 +1,3 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - from distutils.version import LooseVersion diff --git a/compose/volume.py b/compose/volume.py index b02fc5d8030..d31417a550d 100644 --- a/compose/volume.py +++ b/compose/volume.py @@ -1,6 +1,3 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - import logging import re diff --git a/contrib/migration/migrate-compose-file-v1-to-v2.py b/contrib/migration/migrate-compose-file-v1-to-v2.py index 274b499b9d4..e217b7072c3 100755 --- a/contrib/migration/migrate-compose-file-v1-to-v2.py +++ b/contrib/migration/migrate-compose-file-v1-to-v2.py @@ -3,9 +3,6 @@ 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 diff --git a/requirements.txt b/requirements.txt index f02e15eac3b..4dea71c14fa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,4 @@ backports.shutil_get_terminal_size==1.0.0 -backports.ssl-match-hostname==3.5.0.1; python_version < '3' cached-property==1.5.1 certifi==2020.4.5.1 chardet==3.0.4 diff --git a/script/release/release.py b/script/release/release.py deleted file mode 100755 index f53d1f3c181..00000000000 --- a/script/release/release.py +++ /dev/null @@ -1,126 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import absolute_import -from __future__ import unicode_literals - -import re - -import click -from git import Repo -from utils import update_init_py_version -from utils import update_run_sh_version -from utils import yesno - -VALID_VERSION_PATTERN = re.compile(r"^\d+\.\d+\.\d+(-rc\d+)?$") - - -class Version(str): - def matching_groups(self): - match = VALID_VERSION_PATTERN.match(self) - if not match: - return False - - return match.groups() - - def is_ga_version(self): - groups = self.matching_groups() - if not groups: - return False - - rc_suffix = groups[1] - return not rc_suffix - - def validate(self): - return len(self.matching_groups()) > 0 - - def branch_name(self): - if not self.validate(): - return None - - rc_part = self.matching_groups()[0] - ver = self - if rc_part: - ver = ver[:-len(rc_part)] - - tokens = ver.split(".") - tokens[-1] = 'x' - - return ".".join(tokens) - - -def create_bump_commit(repository, version): - print('Creating bump commit...') - repository.commit('-a', '-s', '-m "Bump {}"'.format(version), '--no-verify') - - -def validate_environment(version, repository): - if not version.validate(): - print('Version "{}" has an invalid format. This should follow D+.D+.D+(-rcD+). ' - 'Like: 1.26.0 or 1.26.0-rc1'.format(version)) - return False - - expected_branch = version.branch_name() - if str(repository.active_branch) != expected_branch: - print('Cannot tag in this branch with version "{}". ' - 'Please checkout "{}" to tag'.format(version, version.branch_name())) - return False - return True - - -@click.group() -def cli(): - pass - - -@cli.command() -@click.argument('version') -def tag(version): - """ - Updates the version related files and tag - """ - repo = Repo(".") - version = Version(version) - if not validate_environment(version, repo): - return - - update_init_py_version(version) - update_run_sh_version(version) - - input('Please add the release notes to the CHANGELOG.md file, then press Enter to continue.') - proceed = False - while not proceed: - print(repo.git.diff()) - proceed = yesno('Are these changes ok? y/N ', default=False) - - if repo.git.diff(): - create_bump_commit(repo.git, version) - else: - print('No changes to commit. Exiting...') - return - - repo.create_tag(version) - - print('Please, check the changes. If everything is OK, you just need to push with:\n' - '$ git push --tags upstream {}'.format(version.branch_name())) - - -@cli.command() -@click.argument('version') -def push_latest(version): - """ - TODO Pushes the latest tag pointing to a certain GA version - """ - raise NotImplementedError - - -@cli.command() -@click.argument('version') -def ghtemplate(version): - """ - TODO Generates the github release page content - """ - version = Version(version) - raise NotImplementedError - - -if __name__ == '__main__': - cli() diff --git a/script/release/utils.py b/script/release/utils.py index 4f57704872b..25b39ca7497 100644 --- a/script/release/utils.py +++ b/script/release/utils.py @@ -1,6 +1,3 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - import os import re diff --git a/script/test/all b/script/test/all index f929a57eecc..57b76d5a988 100755 --- a/script/test/all +++ b/script/test/all @@ -11,7 +11,7 @@ docker run --rm \ "$TAG" tox -e pre-commit get_versions="docker run --rm - --entrypoint=/code/.tox/py27/bin/python + --entrypoint=/code/.tox/py37/bin/python $TAG /code/script/test/versions.py docker/docker-ce,moby/moby" @@ -23,7 +23,7 @@ fi BUILD_NUMBER=${BUILD_NUMBER-$USER} -PY_TEST_VERSIONS=${PY_TEST_VERSIONS:-py27,py37} +PY_TEST_VERSIONS=${PY_TEST_VERSIONS:-py37} for version in $DOCKER_VERSIONS; do >&2 echo "Running tests against Docker $version" diff --git a/script/test/versions.py b/script/test/versions.py index a06c49f20b0..1a28dc19ad3 100755 --- a/script/test/versions.py +++ b/script/test/versions.py @@ -21,10 +21,6 @@ `default` would return `1.7.1` and `recent -n 3` would return `1.8.0-rc2 1.7.1 1.6.2` """ -from __future__ import absolute_import -from __future__ import print_function -from __future__ import unicode_literals - import argparse import itertools import operator diff --git a/setup.py b/setup.py index efc144b7fb9..dad44d72e43 100644 --- a/setup.py +++ b/setup.py @@ -1,9 +1,5 @@ #!/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 os import re @@ -108,8 +104,6 @@ def find_version(*file_paths): 'Environment :: Console', 'Intended Audience :: Developers', 'License :: OSI Approved :: Apache Software License', - 'Programming Language :: Python :: 2', - 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3.4', 'Programming Language :: Python :: 3.6', diff --git a/tests/__init__.py b/tests/__init__.py index 1ac1b21cf74..d3cfb864913 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,6 +1,3 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - import sys if sys.version_info >= (2, 7): diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 9f5853143d4..868e1e227a6 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1,7 +1,4 @@ # -*- coding: utf-8 -*- -from __future__ import absolute_import -from __future__ import unicode_literals - import datetime import json import os.path diff --git a/tests/helpers.py b/tests/helpers.py index 1365c5bcfb3..2ecb045236f 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -1,7 +1,3 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - -import contextlib import os from compose.config.config import ConfigDetails diff --git a/tests/integration/environment_test.py b/tests/integration/environment_test.py index 671e65318a6..43df2c52b03 100644 --- a/tests/integration/environment_test.py +++ b/tests/integration/environment_test.py @@ -1,6 +1,3 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - import tempfile from ddt import data diff --git a/tests/integration/network_test.py b/tests/integration/network_test.py index a2493fda1a2..23c9e9a4bda 100644 --- a/tests/integration/network_test.py +++ b/tests/integration/network_test.py @@ -1,6 +1,3 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - import pytest from .testcases import DockerClientTestCase diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index cb620a8c972..19d27185c9b 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -1,6 +1,3 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - import copy import json import os diff --git a/tests/integration/resilience_test.py b/tests/integration/resilience_test.py index 3de16e977b8..81cb2382fe2 100644 --- a/tests/integration/resilience_test.py +++ b/tests/integration/resilience_test.py @@ -1,6 +1,3 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - import pytest from .. import mock diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 01e2e1d22c4..e83b8af07d2 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -1,17 +1,14 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - import os import re import shutil import tempfile from distutils.spawn import find_executable +from io import StringIO from os import path import pytest from docker.errors import APIError from docker.errors import ImageNotFound -from six import StringIO from six import text_type from .. import mock diff --git a/tests/integration/state_test.py b/tests/integration/state_test.py index 492de7b8ab1..611d0cc9408 100644 --- a/tests/integration/state_test.py +++ b/tests/integration/state_test.py @@ -2,9 +2,6 @@ Integration tests which cover state convergence (aka smart recreate) performed by `docker-compose up`. """ -from __future__ import absolute_import -from __future__ import unicode_literals - import copy import os import shutil diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index fe70d1f7246..f787923adde 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -1,6 +1,3 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - import functools import os diff --git a/tests/integration/volume_test.py b/tests/integration/volume_test.py index 2a521d4c5b1..1e02647fb7e 100644 --- a/tests/integration/volume_test.py +++ b/tests/integration/volume_test.py @@ -1,6 +1,3 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - import six from docker.errors import DockerException diff --git a/tests/unit/cli/command_test.py b/tests/unit/cli/command_test.py index 3a9844c4f7c..e2a89951702 100644 --- a/tests/unit/cli/command_test.py +++ b/tests/unit/cli/command_test.py @@ -1,7 +1,4 @@ # ~*~ encoding: utf-8 ~*~ -from __future__ import absolute_import -from __future__ import unicode_literals - import os import pytest diff --git a/tests/unit/cli/docker_client_test.py b/tests/unit/cli/docker_client_test.py index 772c136eefd..873c1ff3d82 100644 --- a/tests/unit/cli/docker_client_test.py +++ b/tests/unit/cli/docker_client_test.py @@ -1,6 +1,3 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - import os import platform import ssl diff --git a/tests/unit/cli/errors_test.py b/tests/unit/cli/errors_test.py index 7b53ed2b15f..cb5f59df19b 100644 --- a/tests/unit/cli/errors_test.py +++ b/tests/unit/cli/errors_test.py @@ -1,6 +1,3 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - import pytest from docker.errors import APIError from requests.exceptions import ConnectionError diff --git a/tests/unit/cli/formatter_test.py b/tests/unit/cli/formatter_test.py index e685725112f..07f5a8f50c5 100644 --- a/tests/unit/cli/formatter_test.py +++ b/tests/unit/cli/formatter_test.py @@ -1,6 +1,3 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - import logging from compose.cli import colors diff --git a/tests/unit/cli/log_printer_test.py b/tests/unit/cli/log_printer_test.py index 5e387241d67..da0abb99f00 100644 --- a/tests/unit/cli/log_printer_test.py +++ b/tests/unit/cli/log_printer_test.py @@ -1,13 +1,10 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - import itertools +from queue import Queue 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 from compose.cli.log_printer import build_log_presenters diff --git a/tests/unit/cli/main_test.py b/tests/unit/cli/main_test.py index 067c74f0b0e..ac3df2920a7 100644 --- a/tests/unit/cli/main_test.py +++ b/tests/unit/cli/main_test.py @@ -1,6 +1,3 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - import logging import docker diff --git a/tests/unit/cli/utils_test.py b/tests/unit/cli/utils_test.py index 7a762890370..d67c8ba8aff 100644 --- a/tests/unit/cli/utils_test.py +++ b/tests/unit/cli/utils_test.py @@ -1,6 +1,3 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - import unittest from compose.cli.utils import human_readable_file_size diff --git a/tests/unit/cli/verbose_proxy_test.py b/tests/unit/cli/verbose_proxy_test.py index f111f8cdbf2..1da1676eb3c 100644 --- a/tests/unit/cli/verbose_proxy_test.py +++ b/tests/unit/cli/verbose_proxy_test.py @@ -1,6 +1,3 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - import six from compose.cli import verbose_proxy diff --git a/tests/unit/cli_test.py b/tests/unit/cli_test.py index a7522f939b9..c6891bc3412 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -1,7 +1,4 @@ # encoding: utf-8 -from __future__ import absolute_import -from __future__ import unicode_literals - import os import shutil import tempfile diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 4f09c59a63b..1f9a168c0a9 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1,8 +1,4 @@ # encoding: utf-8 -from __future__ import absolute_import -from __future__ import print_function -from __future__ import unicode_literals - import codecs import os import shutil diff --git a/tests/unit/config/environment_test.py b/tests/unit/config/environment_test.py index 7e394d248f5..50738546899 100644 --- a/tests/unit/config/environment_test.py +++ b/tests/unit/config/environment_test.py @@ -1,8 +1,4 @@ # encoding: utf-8 -from __future__ import absolute_import -from __future__ import print_function -from __future__ import unicode_literals - import codecs import os import shutil diff --git a/tests/unit/config/interpolation_test.py b/tests/unit/config/interpolation_test.py index 91fc3e69db5..4efcf865a60 100644 --- a/tests/unit/config/interpolation_test.py +++ b/tests/unit/config/interpolation_test.py @@ -1,7 +1,4 @@ # encoding: utf-8 -from __future__ import absolute_import -from __future__ import unicode_literals - import pytest from compose.config.environment import Environment diff --git a/tests/unit/config/sort_services_test.py b/tests/unit/config/sort_services_test.py index c39ac022562..430fed6a620 100644 --- a/tests/unit/config/sort_services_test.py +++ b/tests/unit/config/sort_services_test.py @@ -1,6 +1,3 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - import pytest from compose.config.errors import DependencyError diff --git a/tests/unit/config/types_test.py b/tests/unit/config/types_test.py index e7cc67b0422..c0991b9dc14 100644 --- a/tests/unit/config/types_test.py +++ b/tests/unit/config/types_test.py @@ -1,6 +1,3 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - import pytest from compose.config.errors import ConfigurationError diff --git a/tests/unit/container_test.py b/tests/unit/container_test.py index 626b466d4b3..452475209c5 100644 --- a/tests/unit/container_test.py +++ b/tests/unit/container_test.py @@ -1,6 +1,3 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - import docker from .. import mock diff --git a/tests/unit/network_test.py b/tests/unit/network_test.py index b829de196be..ab7ad59cfbd 100644 --- a/tests/unit/network_test.py +++ b/tests/unit/network_test.py @@ -1,6 +1,3 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - import pytest from .. import mock diff --git a/tests/unit/parallel_test.py b/tests/unit/parallel_test.py index 0735bfccb48..e4c98b5d68a 100644 --- a/tests/unit/parallel_test.py +++ b/tests/unit/parallel_test.py @@ -1,6 +1,3 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - import unittest from threading import Lock diff --git a/tests/unit/progress_stream_test.py b/tests/unit/progress_stream_test.py index 6fdb7d92788..b885b239b92 100644 --- a/tests/unit/progress_stream_test.py +++ b/tests/unit/progress_stream_test.py @@ -1,14 +1,10 @@ # ~*~ encoding: utf-8 ~*~ -from __future__ import absolute_import -from __future__ import unicode_literals - import io import os import random import shutil import tempfile - -from six import StringIO +from io import StringIO from compose import progress_stream from tests import unittest diff --git a/tests/unit/project_test.py b/tests/unit/project_test.py index 6391fac863d..1ad49c1aae5 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -1,7 +1,4 @@ # encoding: utf-8 -from __future__ import absolute_import -from __future__ import unicode_literals - import datetime import os import tempfile diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 592e22f759f..d4e7f3c5d94 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -1,6 +1,3 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - import docker import pytest from docker.constants import DEFAULT_DOCKER_API_VERSION diff --git a/tests/unit/split_buffer_test.py b/tests/unit/split_buffer_test.py index dedd4ee36bb..f1974c83144 100644 --- a/tests/unit/split_buffer_test.py +++ b/tests/unit/split_buffer_test.py @@ -1,6 +1,3 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - from .. import unittest from compose.utils import split_buffer diff --git a/tests/unit/timeparse_test.py b/tests/unit/timeparse_test.py index 9915932c300..e56595f1893 100644 --- a/tests/unit/timeparse_test.py +++ b/tests/unit/timeparse_test.py @@ -1,6 +1,3 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - from compose import timeparse diff --git a/tests/unit/utils_test.py b/tests/unit/utils_test.py index 21b88d962c1..f1febc13e69 100644 --- a/tests/unit/utils_test.py +++ b/tests/unit/utils_test.py @@ -1,7 +1,4 @@ # encoding: utf-8 -from __future__ import absolute_import -from __future__ import unicode_literals - from compose import utils diff --git a/tests/unit/volume_test.py b/tests/unit/volume_test.py index 457d8558174..8b2f6cfeef8 100644 --- a/tests/unit/volume_test.py +++ b/tests/unit/volume_test.py @@ -1,6 +1,3 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - import docker import pytest diff --git a/tox.ini b/tox.ini index 5f57cfb1182..c7cde59ef73 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py27,py37,pre-commit +envlist = py37,pre-commit [testenv] usedevelop=True From 5529376d4c9ae47b1330f6f74bc6d73ddb791b8f Mon Sep 17 00:00:00 2001 From: Bastian Venthur Date: Tue, 19 Nov 2019 15:34:42 +0100 Subject: [PATCH 3913/4072] Removed six Signed-off-by: Bastian Venthur --- compose/cli/command.py | 4 +--- compose/cli/formatter.py | 3 +-- compose/cli/utils.py | 3 +-- compose/cli/verbose_proxy.py | 6 ++---- compose/config/config.py | 19 +++++++++--------- compose/config/environment.py | 5 +---- compose/config/interpolation.py | 16 +++++++-------- compose/config/serialize.py | 11 +++++----- compose/config/types.py | 9 ++++----- compose/config/validation.py | 15 +++++++------- compose/container.py | 3 +-- compose/project.py | 7 +++---- compose/service.py | 30 +++++++++------------------- compose/utils.py | 12 ++++------- requirements.txt | 2 -- setup.py | 1 - tests/acceptance/cli_test.py | 11 ++-------- tests/integration/service_test.py | 21 ++++++++++--------- tests/integration/volume_test.py | 3 +-- tests/unit/cli/command_test.py | 10 ---------- tests/unit/cli/log_printer_test.py | 4 ++-- tests/unit/cli/verbose_proxy_test.py | 4 +--- tests/unit/parallel_test.py | 13 ++++++------ 23 files changed, 77 insertions(+), 135 deletions(-) diff --git a/compose/cli/command.py b/compose/cli/command.py index 6c88e58192c..f18f7663995 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -2,8 +2,6 @@ import os import re -import six - from . import errors from .. import config from .. import parallel @@ -107,7 +105,7 @@ def get_config_from_options(base_dir, options, additional_options=None): def get_config_path_from_options(base_dir, options, environment): def unicode_paths(paths): - return [p.decode('utf-8') if isinstance(p, six.binary_type) else p for p in paths] + return [p.decode('utf-8') if isinstance(p, bytes) else p for p in paths] file_option = options.get('--file') if file_option: diff --git a/compose/cli/formatter.py b/compose/cli/formatter.py index 4dabe77c29e..a59f0742c6a 100644 --- a/compose/cli/formatter.py +++ b/compose/cli/formatter.py @@ -1,7 +1,6 @@ import logging import shutil -import six import texttable from compose.cli import colors @@ -54,7 +53,7 @@ def get_level_message(self, record): return '' def format(self, record): - if isinstance(record.msg, six.binary_type): + if isinstance(record.msg, bytes): record.msg = record.msg.decode('utf-8') message = super(ConsoleWarningFormatter, self).format(record) return '{0}{1}'.format(self.get_level_message(record), message) diff --git a/compose/cli/utils.py b/compose/cli/utils.py index 65ba74dbba5..b91ab6a13c2 100644 --- a/compose/cli/utils.py +++ b/compose/cli/utils.py @@ -7,7 +7,6 @@ import distro import docker -import six import compose from ..const import IS_WINDOWS_PLATFORM @@ -141,7 +140,7 @@ def human_readable_file_size(size): def binarystr_to_unicode(s): - if not isinstance(s, six.binary_type): + if not isinstance(s, bytes): return s if IS_WINDOWS_PLATFORM: diff --git a/compose/cli/verbose_proxy.py b/compose/cli/verbose_proxy.py index 68dfabe521c..1d2f28b5c37 100644 --- a/compose/cli/verbose_proxy.py +++ b/compose/cli/verbose_proxy.py @@ -3,12 +3,10 @@ import pprint from itertools import chain -import six - def format_call(args, kwargs): args = (repr(a) for a in args) - kwargs = ("{0!s}={1!r}".format(*item) for item in six.iteritems(kwargs)) + kwargs = ("{0!s}={1!r}".format(*item) for item in kwargs.items()) return "({0})".format(", ".join(chain(args, kwargs))) @@ -38,7 +36,7 @@ def __init__(self, obj_name, obj, log_name=None, max_lines=10): def __getattr__(self, name): attr = getattr(self.obj, name) - if not six.callable(attr): + if not callable(attr): return attr return functools.partial(self.proxy_callable, name) diff --git a/compose/config/config.py b/compose/config/config.py index 52863b13c77..61fad199f60 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -8,7 +8,6 @@ from collections import namedtuple from operator import attrgetter -import six import yaml from cached_property import cached_property @@ -201,7 +200,7 @@ def version(self): 'Compose file version 1.'.format(self.filename)) return V1 - if not isinstance(version, six.string_types): + if not isinstance(version, str): raise ConfigurationError( 'Version in "{}" is invalid - it should be a string.' .format(self.filename)) @@ -684,12 +683,12 @@ def resolve_environment(service_dict, environment=None, interpolate=True): env.update(env_vars_from_file(env_file, interpolate)) env.update(parse_environment(service_dict.get('environment'))) - return dict(resolve_env_var(k, v, environment) for k, v in six.iteritems(env)) + return dict(resolve_env_var(k, v, environment) for k, v in env.items()) def resolve_build_args(buildargs, environment): args = parse_build_arguments(buildargs) - return dict(resolve_env_var(k, v, environment) for k, v in six.iteritems(args)) + return dict(resolve_env_var(k, v, environment) for k, v in args.items()) def validate_extended_service_dict(service_dict, filename, service): @@ -776,7 +775,7 @@ def process_service(service_config): def process_build_section(service_dict, working_dir): - if isinstance(service_dict['build'], six.string_types): + if isinstance(service_dict['build'], str): service_dict['build'] = resolve_build_path(working_dir, service_dict['build']) elif isinstance(service_dict['build'], dict): if 'context' in service_dict['build']: @@ -844,7 +843,7 @@ def process_healthcheck(service_dict): hc['test'] = ['NONE'] for field in ['interval', 'timeout', 'start_period']: - if field not in hc or isinstance(hc[field], six.integer_types): + if field not in hc or isinstance(hc[field], int): continue hc[field] = parse_nanoseconds_int(hc[field]) @@ -1176,7 +1175,7 @@ def parse_sequence_func(seq): def merge_build(output, base, override): def to_dict(service): build_config = service.get('build', {}) - if isinstance(build_config, six.string_types): + if isinstance(build_config, str): return {'context': build_config} return build_config @@ -1386,7 +1385,7 @@ def normalize_build(service_dict, working_dir, environment): if 'build' in service_dict: build = {} # Shortcut where specifying a string is treated as the build context - if isinstance(service_dict['build'], six.string_types): + if isinstance(service_dict['build'], str): build['context'] = service_dict.pop('build') else: build.update(service_dict['build']) @@ -1412,7 +1411,7 @@ def validate_paths(service_dict): if 'build' in service_dict: build = service_dict.get('build', {}) - if isinstance(build, six.string_types): + if isinstance(build, str): build_path = build elif isinstance(build, dict) and 'context' in build: build_path = build['context'] @@ -1503,7 +1502,7 @@ def merge_list_or_string(base, override): def to_list(value): if value is None: return [] - elif isinstance(value, six.string_types): + elif isinstance(value, str): return [value] else: return value diff --git a/compose/config/environment.py b/compose/config/environment.py index abcafe0ce09..4526d0b3ef2 100644 --- a/compose/config/environment.py +++ b/compose/config/environment.py @@ -1,11 +1,8 @@ -import codecs -import contextlib import logging import os import re import dotenv -import six from ..const import IS_WINDOWS_PLATFORM from .errors import ConfigurationError @@ -15,7 +12,7 @@ def split_env(env): - if isinstance(env, six.binary_type): + if isinstance(env, bytes): env = env.decode('utf-8', 'replace') key = value = None if '=' in env: diff --git a/compose/config/interpolation.py b/compose/config/interpolation.py index f202e911447..9f0fc48472d 100644 --- a/compose/config/interpolation.py +++ b/compose/config/interpolation.py @@ -2,8 +2,6 @@ import re from string import Template -import six - from .errors import ConfigurationError from compose.const import COMPOSEFILE_V2_0 as V2_0 from compose.utils import parse_bytes @@ -74,7 +72,7 @@ def recursive_interpolate(obj, interpolator, config_path): def append(config_path, key): return '{}/{}'.format(config_path, key) - if isinstance(obj, six.string_types): + if isinstance(obj, str): return converter.convert(config_path, interpolator.interpolate(obj)) if isinstance(obj, dict): return dict( @@ -135,7 +133,7 @@ def convert(mo): if named is not None: val = mapping[named] - if isinstance(val, six.binary_type): + if isinstance(val, bytes): val = val.decode('utf-8') return '%s' % (val,) if mo.group('escaped') is not None: @@ -174,7 +172,7 @@ def service_path(*args): def to_boolean(s): - if not isinstance(s, six.string_types): + if not isinstance(s, str): return s s = s.lower() if s in ['y', 'yes', 'true', 'on']: @@ -185,11 +183,11 @@ def to_boolean(s): def to_int(s): - if not isinstance(s, six.string_types): + if not isinstance(s, str): return s # We must be able to handle octal representation for `mode` values notably - if six.PY3 and re.match('^0[0-9]+$', s.strip()): + if re.match('^0[0-9]+$', s.strip()): s = '0o' + s[1:] try: return int(s, base=0) @@ -198,7 +196,7 @@ def to_int(s): def to_float(s): - if not isinstance(s, six.string_types): + if not isinstance(s, str): return s try: @@ -221,7 +219,7 @@ def bytes_to_int(s): def to_microseconds(v): - if not isinstance(v, six.string_types): + if not isinstance(v, str): return v return int(parse_nanoseconds_int(v) / 1000) diff --git a/compose/config/serialize.py b/compose/config/serialize.py index 79b5777cb46..fe0d007a60f 100644 --- a/compose/config/serialize.py +++ b/compose/config/serialize.py @@ -1,4 +1,3 @@ -import six import yaml from compose.config import types @@ -12,7 +11,7 @@ def serialize_config_type(dumper, data): - representer = dumper.represent_str if six.PY3 else dumper.represent_unicode + representer = dumper.represent_str return representer(data.repr()) @@ -22,9 +21,9 @@ def serialize_dict_type(dumper, data): def serialize_string(dumper, data): """ Ensure boolean-like strings are quoted in the output """ - representer = dumper.represent_str if six.PY3 else dumper.represent_unicode + representer = dumper.represent_str - if isinstance(data, six.binary_type): + if isinstance(data, bytes): data = data.decode('utf-8') if data.lower() in ('y', 'n', 'yes', 'no', 'on', 'off', 'true', 'false'): @@ -95,10 +94,10 @@ def v3_introduced_name_key(key): def serialize_config(config, image_digests=None, escape_dollar=True): if escape_dollar: yaml.SafeDumper.add_representer(str, serialize_string_escape_dollar) - yaml.SafeDumper.add_representer(six.text_type, serialize_string_escape_dollar) + yaml.SafeDumper.add_representer(str, serialize_string_escape_dollar) else: yaml.SafeDumper.add_representer(str, serialize_string) - yaml.SafeDumper.add_representer(six.text_type, serialize_string) + yaml.SafeDumper.add_representer(str, serialize_string) return yaml.safe_dump( denormalize_config(config, image_digests), default_flow_style=False, diff --git a/compose/config/types.py b/compose/config/types.py index 4a7a25b2bce..0c654fa6f8d 100644 --- a/compose/config/types.py +++ b/compose/config/types.py @@ -7,7 +7,6 @@ import re from collections import namedtuple -import six from docker.utils.ports import build_port_bindings from ..const import COMPOSEFILE_V1 as V1 @@ -101,7 +100,7 @@ def serialize_restart_spec(restart_spec): return '' parts = [restart_spec['Name']] if restart_spec['MaximumRetryCount']: - parts.append(six.text_type(restart_spec['MaximumRetryCount'])) + parts.append(str(restart_spec['MaximumRetryCount'])) return ':'.join(parts) @@ -323,7 +322,7 @@ def merge_field(self): class ServiceConfigBase(namedtuple('_ServiceConfigBase', 'source target uid gid mode name')): @classmethod def parse(cls, spec): - if isinstance(spec, six.string_types): + if isinstance(spec, str): return cls(spec, None, None, None, None, None) return cls( spec.get('source'), @@ -361,7 +360,7 @@ def __new__(cls, target, published, *args, **kwargs): raise ConfigurationError('Invalid target port: {}'.format(target)) if published: - if isinstance(published, six.string_types) and '-' in published: # "x-y:z" format + if isinstance(published, str) and '-' in published: # "x-y:z" format a, b = published.split('-', 1) try: int(a) @@ -474,7 +473,7 @@ def normalize_port_dict(port): class SecurityOpt(namedtuple('_SecurityOpt', 'value src_file')): @classmethod def parse(cls, value): - if not isinstance(value, six.string_types): + if not isinstance(value, str): return value # based on https://github.com/docker/cli/blob/9de1b162f/cli/command/container/opts.go#L673-L697 con = value.split('=', 2) diff --git a/compose/config/validation.py b/compose/config/validation.py index f8513bdef58..3161b239533 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -4,7 +4,6 @@ import re import sys -import six from docker.utils.ports import split_port from jsonschema import Draft4Validator from jsonschema import FormatChecker @@ -72,13 +71,13 @@ def format_ports(instance): try: split_port(instance) except ValueError as e: - raise ValidationError(six.text_type(e)) + raise ValidationError(str(e)) return True @FormatChecker.cls_checks(format="expose", raises=ValidationError) def format_expose(instance): - if isinstance(instance, six.string_types): + if isinstance(instance, str): if not re.match(VALID_EXPOSE_FORMAT, instance): raise ValidationError( "should be of the format 'PORT[/PROTOCOL]'") @@ -88,7 +87,7 @@ def format_expose(instance): @FormatChecker.cls_checks("subnet_ip_address", raises=ValidationError) def format_subnet_ip_address(instance): - if isinstance(instance, six.string_types): + if isinstance(instance, str): if not re.match(VALID_REGEX_IPV4_CIDR, instance) and \ not re.match(VALID_REGEX_IPV6_CIDR, instance): raise ValidationError("should use the CIDR format") @@ -135,7 +134,7 @@ def validate_config_section(filename, config, section): type=anglicize_json_type(python_type_to_yaml_type(config)))) for key, value in config.items(): - if not isinstance(key, six.string_types): + if not isinstance(key, str): raise ConfigurationError( "In file '{filename}', the {section} name {name} must be a " "quoted string, i.e. '{name}'.".format( @@ -163,7 +162,7 @@ def validate_top_level_object(config_file): def validate_ulimits(service_config): ulimit_config = service_config.config.get('ulimits', {}) - for limit_name, soft_hard_values in six.iteritems(ulimit_config): + for limit_name, soft_hard_values in ulimit_config.items(): if isinstance(soft_hard_values, dict): if not soft_hard_values['soft'] <= soft_hard_values['hard']: raise ConfigurationError( @@ -326,7 +325,7 @@ def handle_generic_error(error, path): required_keys) elif error.cause: - error_msg = six.text_type(error.cause) + error_msg = str(error.cause) msg_format = "{path} is invalid: {msg}" elif error.path: @@ -346,7 +345,7 @@ def parse_key_from_error_msg(error): def path_string(path): - return ".".join(c for c in path if isinstance(c, six.string_types)) + return ".".join(c for c in path if isinstance(c, str)) def _parse_valid_types_from_validator(validator): diff --git a/compose/container.py b/compose/container.py index 37de4e6f625..18deabbda57 100644 --- a/compose/container.py +++ b/compose/container.py @@ -1,6 +1,5 @@ from functools import reduce -import six from docker.errors import ImageNotFound from .const import LABEL_CONTAINER_NUMBER @@ -127,7 +126,7 @@ def format_port(private, public): return ', '.join( ','.join(format_port(*item)) - for item in sorted(six.iteritems(self.ports)) + for item in sorted(self.ports.items()) ) @property diff --git a/compose/project.py b/compose/project.py index 90a6f5cd53c..4c8b197f287 100644 --- a/compose/project.py +++ b/compose/project.py @@ -6,7 +6,6 @@ from functools import reduce from os import path -import six from docker.errors import APIError from docker.errors import ImageNotFound from docker.errors import NotFound @@ -391,7 +390,7 @@ def build_service(service): ) if len(errors): combined_errors = '\n'.join([ - e.decode('utf-8') if isinstance(e, six.binary_type) else e for e in errors.values() + e.decode('utf-8') if isinstance(e, bytes) else e for e in errors.values() ]) raise ProjectError(combined_errors) @@ -681,7 +680,7 @@ def pull_service(service): .format(' '.join(must_build))) if len(errors): combined_errors = '\n'.join([ - e.decode('utf-8') if isinstance(e, six.binary_type) else e for e in errors.values() + e.decode('utf-8') if isinstance(e, bytes) else e for e in errors.values() ]) raise ProjectError(combined_errors) @@ -931,7 +930,7 @@ def __init__(self, image_name, service_name): class NoSuchService(Exception): def __init__(self, name): - if isinstance(name, six.binary_type): + if isinstance(name, bytes): name = name.decode('utf-8') self.name = name self.msg = "No such service: %s" % self.name diff --git a/compose/service.py b/compose/service.py index 18173cf7058..673b0b335e8 100644 --- a/compose/service.py +++ b/compose/service.py @@ -4,13 +4,13 @@ import logging import os import re +import subprocess import sys import tempfile from collections import namedtuple from collections import OrderedDict from operator import attrgetter -import six from docker.errors import APIError from docker.errors import ImageNotFound from docker.errors import NotFound @@ -59,10 +59,6 @@ from .utils import unique_everseen from compose.cli.utils import binarystr_to_unicode -if six.PY2: - import subprocess32 as subprocess -else: - import subprocess log = logging.getLogger(__name__) @@ -422,7 +418,7 @@ def _containers_have_diverged(self, containers): except NoSuchImageError as e: log.debug( 'Service %s has diverged: %s', - self.name, six.text_type(e), + self.name, str(e), ) return True @@ -972,7 +968,7 @@ def _get_container_host_config(self, override_options, one_off=False): blkio_config = convert_blkio_config(options.get('blkio_config', None)) log_config = get_log_config(logging_dict) init_path = None - if isinstance(options.get('init'), six.string_types): + if isinstance(options.get('init'), str): init_path = options.get('init') options['init'] = True @@ -1106,7 +1102,7 @@ def build(self, no_cache=False, pull=False, force_rm=False, memory=None, build_a try: all_events = list(stream_output(build_output, output_stream)) except StreamOutputError as e: - raise BuildError(self, six.text_type(e)) + raise BuildError(self, str(e)) # Ensure the HTTP connection is not reused for another # streaming command, as the Docker daemon can sometimes @@ -1221,7 +1217,7 @@ def _do_pull(self, repo, pull_kwargs, silent, ignore_pull_failures): if not ignore_pull_failures: raise else: - log.error(six.text_type(e)) + log.error(str(e)) def pull(self, ignore_pull_failures=False, silent=False, stream=False): if 'image' not in self.options: @@ -1262,7 +1258,7 @@ def push(self, ignore_push_failures=False): if not ignore_push_failures: raise else: - log.error(six.text_type(e)) + log.error(str(e)) def is_healthy(self): """ Check that all containers for this service report healthy. @@ -1628,8 +1624,8 @@ def build_ulimits(ulimit_config): if not ulimit_config: return None ulimits = [] - for limit_name, soft_hard_values in six.iteritems(ulimit_config): - if isinstance(soft_hard_values, six.integer_types): + for limit_name, soft_hard_values in ulimit_config.items(): + if isinstance(soft_hard_values, int): ulimits.append({'name': limit_name, 'soft': soft_hard_values, 'hard': soft_hard_values}) elif isinstance(soft_hard_values, dict): ulimit_dict = {'name': limit_name} @@ -1653,7 +1649,7 @@ def format_environment(environment): def format_env(key, value): if value is None: return key - if isinstance(value, six.binary_type): + if isinstance(value, bytes): value = value.decode('utf-8') return '{key}={value}'.format(key=key, value=value) @@ -1704,11 +1700,6 @@ def convert_blkio_config(blkio_config): def rewrite_build_path(path): - # 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') - if IS_WINDOWS_PLATFORM and not is_url(path) and not path.startswith(WINDOWS_LONGPATH_PREFIX): path = WINDOWS_LONGPATH_PREFIX + os.path.normpath(path) @@ -1806,9 +1797,6 @@ def build(self, path, tag=None, quiet=False, fileobj=None, line = p.stdout.readline() if not line: break - # Fix non ascii chars on Python2. To remove when #6890 is complete. - if six.PY2: - magic_word = str(magic_word) if line.startswith(magic_word): appear = True yield json.dumps({"stream": line}) diff --git a/compose/utils.py b/compose/utils.py index 4ea163c42c3..9170abc13b8 100644 --- a/compose/utils.py +++ b/compose/utils.py @@ -1,11 +1,9 @@ -import codecs import hashlib import json.decoder import logging import ntpath import random -import six from docker.errors import DockerException from docker.utils import parse_bytes as sdk_parse_bytes @@ -19,9 +17,7 @@ def get_output_stream(stream): - if six.PY3: - return stream - return codecs.getwriter('utf-8')(stream) + return stream def stream_as_text(stream): @@ -32,13 +28,13 @@ def stream_as_text(stream): of byte streams. """ for data in stream: - if not isinstance(data, six.text_type): + if not isinstance(data, str): data = data.decode('utf-8', 'replace') yield data def line_splitter(buffer, separator=u'\n'): - index = buffer.find(six.text_type(separator)) + index = buffer.find(str(separator)) if index == -1: return None return buffer[:index + 1], buffer[index + 1:] @@ -53,7 +49,7 @@ def split_buffer(stream, splitter=None, decoder=lambda a: a): of the input. """ splitter = splitter or line_splitter - buffered = six.text_type('') + buffered = str('') for data in stream_as_text(stream): buffered += data diff --git a/requirements.txt b/requirements.txt index 4dea71c14fa..32257fcede1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,8 +20,6 @@ PySocks==1.7.1 python-dotenv==0.13.0 PyYAML==5.3 requests==2.22.0 -six==1.12.0 -subprocess32==3.5.4; python_version < '3.2' texttable==1.6.2 urllib3==1.25.9; python_version == '3.3' wcwidth==0.1.9 diff --git a/setup.py b/setup.py index dad44d72e43..e32785225d6 100644 --- a/setup.py +++ b/setup.py @@ -35,7 +35,6 @@ def find_version(*file_paths): 'distro >= 1.5.0, < 2', 'docker[ssh] >= 3.7.0, < 5', 'dockerpty >= 0.4.1, < 1', - 'six >= 1.3.0, < 2', 'jsonschema >= 2.5.1, < 4', 'python-dotenv >= 0.13.0, < 1', ] diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 868e1e227a6..c84d3f8cb99 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -12,7 +12,6 @@ from operator import attrgetter import pytest -import six import yaml from docker import errors @@ -2219,15 +2218,9 @@ def test_run_handles_sighup(self): @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 + 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 in result.stdout.strip() + self.dispatch(['run', 'simple']) container = self.project.containers(one_off=OneOffFilter.only, stopped=True)[0] environment = container.get('Config.Env') diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index e83b8af07d2..9af5a2324a4 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -9,7 +9,6 @@ import pytest from docker.errors import APIError from docker.errors import ImageNotFound -from six import text_type from .. import mock from ..helpers import BUSYBOX_IMAGE_WITH_TAG @@ -1029,7 +1028,7 @@ def test_build_non_ascii_filename(self): with open(os.path.join(base_dir.encode('utf8'), b'foo\xE2bar'), 'w') as f: f.write("hello world\n") - service = self.create_service('web', build={'context': text_type(base_dir)}) + service = self.create_service('web', build={'context': str(base_dir)}) service.build() self.addCleanup(self.client.remove_image, service.image_name) assert self.client.inspect_image('composetest_web') @@ -1063,7 +1062,7 @@ def test_build_with_build_args(self): f.write("RUN echo ${build_version}\n") service = self.create_service('buildwithargs', - build={'context': text_type(base_dir), + build={'context': str(base_dir), 'args': {"build_version": "1"}}) service.build() self.addCleanup(self.client.remove_image, service.image_name) @@ -1080,7 +1079,7 @@ def test_build_with_build_args_override(self): f.write("RUN echo ${build_version}\n") service = self.create_service('buildwithargs', - build={'context': text_type(base_dir), + build={'context': str(base_dir), 'args': {"build_version": "1"}}) service.build(build_args_override={'build_version': '2'}) self.addCleanup(self.client.remove_image, service.image_name) @@ -1096,7 +1095,7 @@ def test_build_with_build_labels(self): f.write('FROM busybox\n') service = self.create_service('buildlabels', build={ - 'context': text_type(base_dir), + 'context': str(base_dir), 'labels': {'com.docker.compose.test': 'true'} }) service.build() @@ -1123,7 +1122,7 @@ def test_build_with_network(self): self.client.start(net_container) service = self.create_service('buildwithnet', build={ - 'context': text_type(base_dir), + 'context': str(base_dir), 'network': 'container:{}'.format(net_container['Id']) }) @@ -1147,7 +1146,7 @@ def test_build_with_target(self): f.write('LABEL com.docker.compose.test.target=two\n') service = self.create_service('buildtarget', build={ - 'context': text_type(base_dir), + 'context': str(base_dir), 'target': 'one' }) @@ -1169,7 +1168,7 @@ def test_build_with_extra_hosts(self): ])) service = self.create_service('build_extra_hosts', build={ - 'context': text_type(base_dir), + 'context': str(base_dir), 'extra_hosts': { 'foobar': '127.0.0.1', 'baz': '127.0.0.1' @@ -1191,7 +1190,7 @@ def test_build_with_gzip(self): f.write('hello world\n') service = self.create_service('build_gzip', build={ - 'context': text_type(base_dir), + 'context': str(base_dir), }) service.build(gzip=True) assert service.image() @@ -1204,7 +1203,7 @@ def test_build_with_isolation(self): f.write('FROM busybox\n') service = self.create_service('build_isolation', build={ - 'context': text_type(base_dir), + 'context': str(base_dir), 'isolation': 'default', }) service.build() @@ -1218,7 +1217,7 @@ def test_build_with_illegal_leading_chars(self): service = Service( 'build_leading_slug', client=self.client, project='___-composetest', build={ - 'context': text_type(base_dir) + 'context': str(base_dir) } ) assert service.image_name == 'composetest_build_leading_slug' diff --git a/tests/integration/volume_test.py b/tests/integration/volume_test.py index 1e02647fb7e..2ede7bf28d2 100644 --- a/tests/integration/volume_test.py +++ b/tests/integration/volume_test.py @@ -1,4 +1,3 @@ -import six from docker.errors import DockerException from .testcases import DockerClientTestCase @@ -24,7 +23,7 @@ def tearDown(self): def create_volume(self, name, driver=None, opts=None, external=None, custom_name=False): if external: custom_name = True - if isinstance(external, six.text_type): + if isinstance(external, str): name = external vol = Volume( diff --git a/tests/unit/cli/command_test.py b/tests/unit/cli/command_test.py index e2a89951702..20702d97580 100644 --- a/tests/unit/cli/command_test.py +++ b/tests/unit/cli/command_test.py @@ -2,7 +2,6 @@ import os import pytest -import six from compose.cli.command import get_config_path_from_options from compose.config.environment import Environment @@ -62,12 +61,3 @@ def test_unicode_path_from_options(self): assert get_config_path_from_options( '.', opts, environment ) == ['就吃饭/docker-compose.yml'] - - @pytest.mark.skipif(six.PY3, reason='Env values in Python 3 are already Unicode') - def test_unicode_path_from_env(self): - with mock.patch.dict(os.environ): - os.environ['COMPOSE_FILE'] = b'\xe5\xb0\xb1\xe5\x90\x83\xe9\xa5\xad/docker-compose.yml' - environment = Environment.from_env_file('.') - assert get_config_path_from_options( - '.', {}, environment - ) == ['就吃饭/docker-compose.yml'] diff --git a/tests/unit/cli/log_printer_test.py b/tests/unit/cli/log_printer_test.py index da0abb99f00..38dd56c715a 100644 --- a/tests/unit/cli/log_printer_test.py +++ b/tests/unit/cli/log_printer_test.py @@ -1,9 +1,9 @@ import itertools +from io import StringIO from queue import Queue import pytest import requests -import six from docker.errors import APIError from compose.cli.log_printer import build_log_generator @@ -19,7 +19,7 @@ @pytest.fixture def output_stream(): - output = six.StringIO() + output = StringIO() output.flush = mock.Mock() return output diff --git a/tests/unit/cli/verbose_proxy_test.py b/tests/unit/cli/verbose_proxy_test.py index 1da1676eb3c..0da662fd0c0 100644 --- a/tests/unit/cli/verbose_proxy_test.py +++ b/tests/unit/cli/verbose_proxy_test.py @@ -1,5 +1,3 @@ -import six - from compose.cli import verbose_proxy from tests import unittest @@ -7,7 +5,7 @@ class VerboseProxyTestCase(unittest.TestCase): def test_format_call(self): - prefix = '' if six.PY3 else 'u' + prefix = '' expected = "(%(p)s'arg1', True, key=%(p)s'value')" % dict(p=prefix) actual = verbose_proxy.format_call( ("arg1", True), diff --git a/tests/unit/parallel_test.py b/tests/unit/parallel_test.py index e4c98b5d68a..98412f9a259 100644 --- a/tests/unit/parallel_test.py +++ b/tests/unit/parallel_test.py @@ -1,7 +1,6 @@ import unittest from threading import Lock -import six from docker.errors import APIError from compose.parallel import GlobalLimit @@ -36,7 +35,7 @@ def test_parallel_execute(self): results, errors = parallel_execute( objects=[1, 2, 3, 4, 5], func=lambda x: x * 2, - get_name=six.text_type, + get_name=str, msg="Doubling", ) @@ -58,7 +57,7 @@ def f(obj): results, errors = parallel_execute( objects=list(range(tasks)), func=f, - get_name=six.text_type, + get_name=str, msg="Testing", limit=limit, ) @@ -82,7 +81,7 @@ def f(obj): results, errors = parallel_execute( objects=list(range(tasks)), func=f, - get_name=six.text_type, + get_name=str, msg="Testing", ) @@ -144,7 +143,7 @@ def test_parallel_execute_alignment(capsys): results, errors = parallel_execute( objects=["short", "a very long name"], func=lambda x: x, - get_name=six.text_type, + get_name=str, msg="Aligning", ) @@ -161,7 +160,7 @@ def test_parallel_execute_ansi(capsys): results, errors = parallel_execute( objects=["something", "something more"], func=lambda x: x, - get_name=six.text_type, + get_name=str, msg="Control characters", ) @@ -177,7 +176,7 @@ def test_parallel_execute_noansi(capsys): results, errors = parallel_execute( objects=["something", "something more"], func=lambda x: x, - get_name=six.text_type, + get_name=str, msg="Control characters", ) From c44746d92d696d64386145216b605733d2e12a37 Mon Sep 17 00:00:00 2001 From: Bastian Venthur Date: Wed, 8 Jan 2020 09:52:04 +0100 Subject: [PATCH 3914/4072] Removed now useless check for version_info >= 2.7 Signed-off-by: Bastian Venthur --- tests/__init__.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/tests/__init__.py b/tests/__init__.py index d3cfb864913..84347ec0f63 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,9 +1,4 @@ -import sys - -if sys.version_info >= (2, 7): - import unittest # NOQA -else: - import unittest2 as unittest # NOQA +import unittest # NOQA try: from unittest import mock From 0263d3ec3714131acaa17900c6d620a5a2064c7c Mon Sep 17 00:00:00 2001 From: Bastian Venthur Date: Wed, 8 Jan 2020 10:04:38 +0100 Subject: [PATCH 3915/4072] Removed now unused get_output_stream method Signed-off-by: Bastian Venthur --- compose/cli/log_printer.py | 3 +-- compose/parallel.py | 3 +-- compose/progress_stream.py | 2 +- compose/utils.py | 4 ---- 4 files changed, 3 insertions(+), 9 deletions(-) diff --git a/compose/cli/log_printer.py b/compose/cli/log_printer.py index fd193000f5c..100f3a8250d 100644 --- a/compose/cli/log_printer.py +++ b/compose/cli/log_printer.py @@ -9,7 +9,6 @@ from docker.errors import APIError from . import colors -from compose import utils from compose.cli.signals import ShutdownException from compose.utils import split_buffer @@ -64,7 +63,7 @@ def __init__(self, self.containers = containers self.presenters = presenters self.event_stream = event_stream - self.output = utils.get_output_stream(output) + self.output = output self.cascade_stop = cascade_stop self.log_args = log_args or {} diff --git a/compose/parallel.py b/compose/parallel.py index 6ef9ceedd35..15c3ad572ab 100644 --- a/compose/parallel.py +++ b/compose/parallel.py @@ -18,7 +18,6 @@ from compose.errors import HealthCheckFailed from compose.errors import NoHealthCheckConfigured from compose.errors import OperationFailedError -from compose.utils import get_output_stream log = logging.getLogger(__name__) @@ -82,7 +81,7 @@ def parallel_execute(objects, func, get_name, msg, get_deps=None, limit=None, fa in the CLI logs, but don't raise an exception (such as attempting to start 0 containers) """ objects = list(objects) - stream = get_output_stream(sys.stderr) + stream = sys.stderr if ParallelStreamWriter.instance: writer = ParallelStreamWriter.instance diff --git a/compose/progress_stream.py b/compose/progress_stream.py index 0ae0dc3161f..8792ff287cb 100644 --- a/compose/progress_stream.py +++ b/compose/progress_stream.py @@ -15,7 +15,7 @@ def write_to_stream(s, stream): def stream_output(output, stream): is_terminal = hasattr(stream, 'isatty') and stream.isatty() - stream = utils.get_output_stream(stream) + stream = stream lines = {} diff = 0 diff --git a/compose/utils.py b/compose/utils.py index 9170abc13b8..8b5ab38d9a1 100644 --- a/compose/utils.py +++ b/compose/utils.py @@ -16,10 +16,6 @@ log = logging.getLogger(__name__) -def get_output_stream(stream): - return stream - - def stream_as_text(stream): """Given a stream of bytes or text, if any of the items in the stream are bytes convert them to text. From 6351ad8a9590c6b504dfa271189b1571328f9c2c Mon Sep 17 00:00:00 2001 From: Bastian Venthur Date: Wed, 8 Jan 2020 10:46:28 +0100 Subject: [PATCH 3916/4072] Import unittest.mock directly. We don't need to support Python2 anymore. Signed-off-by: Bastian Venthur --- tests/__init__.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/tests/__init__.py b/tests/__init__.py index 84347ec0f63..9d732490098 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,6 +1,2 @@ import unittest # NOQA - -try: - from unittest import mock -except ImportError: - import mock # NOQA +from unittest import mock # NOQA From 55ca40693cd16d498ddeba1a54462436a1b206f0 Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Mon, 24 Feb 2020 11:42:32 +0100 Subject: [PATCH 3917/4072] "Bump 1.26.0-rc1" Signed-off-by: Ulysses Souza --- 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 69c4e0e49d8..6e5fcaae99b 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.26.0dev' +__version__ = '1.26.0-rc1' diff --git a/script/run/run.sh b/script/run/run.sh index cd566f3349d..80307f11a83 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.25.5" +VERSION="1.26.0-rc1" IMAGE="docker/compose:$VERSION" From 9b8b8cb856ea9127ca82db0de6363e02ce002585 Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Tue, 10 Mar 2020 16:25:59 +0100 Subject: [PATCH 3918/4072] "Bump 1.26.0-rc3" Signed-off-by: Ulysses Souza --- 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 6e5fcaae99b..6d98c8f5e5e 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.26.0-rc1' +__version__ = '1.26.0-rc3' diff --git a/script/run/run.sh b/script/run/run.sh index 80307f11a83..f6dbe11dc23 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.26.0-rc1" +VERSION="1.26.0-rc3" IMAGE="docker/compose:$VERSION" From cad60dbc0037f4bf4e209fad6f27633c5829b095 Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Wed, 29 Apr 2020 12:27:55 +0200 Subject: [PATCH 3919/4072] "Bump 1.26.0-rc4" Signed-off-by: Ulysses Souza --- CHANGELOG.md | 31 +++++++++++++++++++++++++++++++ compose/__init__.py | 2 +- script/run/run.sh | 2 +- 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78f51227242..9a264d4ec9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,37 @@ Change log ========== +1.26.0 (2020-04-29) +------------------- + +### Features + +- Add `docker context` support + +- Add missing test dependency `ddt` to `setup.py` + +- Add `--attach-dependencies` to command `up` for attaching to dependencies + +- Allow compatibility option with `COMPOSE_COMPATIBILITY` environment variable + +- Bump `Pytest` to 5.3.4 and add refactor compatibility with new version + +- Bump `OpenSSL` from 1.1.1f to 1.1.1g + +### Bugs + +- Properly escape values coming from env_files + +- Sync compose-schemas with upstream (docker/cli) + +- Remove `None` entries on exec command + +- Add `python-dotenv` to delegate `.env` file processing + +- Don't adjust output on terminal width when piped into another command + +- Show an error message when `version` attribute is malformed + 1.25.5 (2020-02-04) ------------------- diff --git a/compose/__init__.py b/compose/__init__.py index 6d98c8f5e5e..a279559fb12 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.26.0-rc3' +__version__ = '1.26.0-rc4' diff --git a/script/run/run.sh b/script/run/run.sh index f6dbe11dc23..2fd0b9d576c 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.26.0-rc3" +VERSION="1.26.0-rc4" IMAGE="docker/compose:$VERSION" From 3f0a7fe3ee91df5ab3068999ee56aa6cc10e920f Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Fri, 29 May 2020 10:11:45 +0200 Subject: [PATCH 3920/4072] Bump docker-py This should fix https problems when running on remote daemon Signed-off-by: Ulysses Souza --- compose/cli/docker_client.py | 4 ++-- requirements.txt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/compose/cli/docker_client.py b/compose/cli/docker_client.py index d4cdc96e8f5..4553eee86f2 100644 --- a/compose/cli/docker_client.py +++ b/compose/cli/docker_client.py @@ -31,7 +31,7 @@ def default_cert_path(): def make_context(host, options, environment): tls = tls_config_from_options(options, environment) - ctx = Context("compose", host=host) + ctx = Context("compose", host=host, tls=tls.verify if tls else False) if tls: ctx.set_endpoint("docker", host, tls, skip_tls_verify=not tls.verify) return ctx @@ -138,7 +138,7 @@ def docker_client(environment, version=None, context=None, tls_version=None): tls = kwargs.get("tls", None) verify = False if not tls else tls.verify if host: - context = Context("compose", host=host) + context = Context("compose", host=host, tls=verify) else: context = ContextAPI.get_current_context() if tls: diff --git a/requirements.txt b/requirements.txt index f02e15eac3b..e6473127217 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ certifi==2020.4.5.1 chardet==3.0.4 colorama==0.4.3; sys_platform == 'win32' distro==1.5.0 -docker==4.2.0 +docker==4.2.1 docker-pycreds==0.4.0 dockerpty==0.4.1 docopt==0.6.2 From f2bb6ff4730925af451333ad3405032fef0d0a6e Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Wed, 3 Jun 2020 10:32:47 +0200 Subject: [PATCH 3921/4072] "Bump 1.26.0-rc5" Signed-off-by: Ulysses Souza --- CHANGELOG.md | 6 +++++- compose/__init__.py | 2 +- script/run/run.sh | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a264d4ec9f..432f1745e1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ Change log ========== -1.26.0 (2020-04-29) +1.26.0 (2020-06-03) ------------------- ### Features @@ -18,6 +18,8 @@ Change log - Bump `OpenSSL` from 1.1.1f to 1.1.1g +- Bump `docker-py` from 4.2.0 to 4.2.1 + ### Bugs - Properly escape values coming from env_files @@ -32,6 +34,8 @@ Change log - Show an error message when `version` attribute is malformed +- Fix HTTPS connection when DOCKER_HOST is remote + 1.25.5 (2020-02-04) ------------------- diff --git a/compose/__init__.py b/compose/__init__.py index a279559fb12..96193675a75 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.26.0-rc4' +__version__ = '1.26.0-rc5' diff --git a/script/run/run.sh b/script/run/run.sh index 2fd0b9d576c..8e8b0d4bd50 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.26.0-rc4" +VERSION="1.26.0-rc5" IMAGE="docker/compose:$VERSION" From 3d2a82ca8326c79673c69beec20b94f1327bb64d Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Wed, 3 Jun 2020 16:10:04 +0200 Subject: [PATCH 3922/4072] "Bump 1.26.0" Signed-off-by: Ulysses Souza --- 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 96193675a75..4163aeb903b 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.26.0-rc5' +__version__ = '1.26.0' diff --git a/script/run/run.sh b/script/run/run.sh index 8e8b0d4bd50..65fb656add9 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.26.0-rc5" +VERSION="1.26.0" IMAGE="docker/compose:$VERSION" From a691cd89cc3719a351f5d822cdcd24a0400572f9 Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Wed, 3 Jun 2020 17:52:02 +0200 Subject: [PATCH 3923/4072] Set next version to a `dev` once again Signed-off-by: Ulysses Souza --- compose/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/__init__.py b/compose/__init__.py index 4163aeb903b..37b5db09cf7 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.26.0' +__version__ = '1.27.0dev' From 8ce743c8c1c42c12729ea74a0572187673455a04 Mon Sep 17 00:00:00 2001 From: Bastian Venthur Date: Wed, 3 Jun 2020 18:08:23 +0200 Subject: [PATCH 3924/4072] Fixed new things that came after the PR Signed-off-by: Bastian Venthur --- compose/cli/docker_client.py | 3 +-- script/release/const.py | 3 --- tests/acceptance/context_test.py | 3 --- tests/conftest.py | 3 --- 4 files changed, 1 insertion(+), 11 deletions(-) diff --git a/compose/cli/docker_client.py b/compose/cli/docker_client.py index 3abeb5cdc3f..493597b8071 100644 --- a/compose/cli/docker_client.py +++ b/compose/cli/docker_client.py @@ -2,7 +2,6 @@ import os.path import ssl -import six from docker import APIClient from docker import Context from docker import ContextAPI @@ -44,7 +43,7 @@ def get_client(environment, verbose=False, version=None, context=None): environment=environment, tls_version=get_tls_version(environment) ) if verbose: - version_info = six.iteritems(client.version()) + version_info = client.version().items() log.info(get_version_info('full')) log.info("Docker base_url: %s", client.base_url) log.info("Docker version: %s", diff --git a/script/release/const.py b/script/release/const.py index 77a32332a7f..8c90eebca6e 100644 --- a/script/release/const.py +++ b/script/release/const.py @@ -1,6 +1,3 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - import os diff --git a/tests/acceptance/context_test.py b/tests/acceptance/context_test.py index 1d79a22a042..e17e7171785 100644 --- a/tests/acceptance/context_test.py +++ b/tests/acceptance/context_test.py @@ -1,7 +1,4 @@ # -*- coding: utf-8 -*- -from __future__ import absolute_import -from __future__ import unicode_literals - import os import shutil import unittest diff --git a/tests/conftest.py b/tests/conftest.py index 013368689e2..fd31a974840 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,3 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - import pytest import tests.acceptance.cli_test From c5d2d3c30ea14b18af781408976f968602b691e4 Mon Sep 17 00:00:00 2001 From: Bastian Venthur Date: Wed, 3 Jun 2020 18:23:19 +0200 Subject: [PATCH 3925/4072] re-added accidentally removed contextlib import Signed-off-by: Bastian Venthur --- tests/helpers.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/helpers.py b/tests/helpers.py index 2ecb045236f..d178684853b 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -1,3 +1,4 @@ +import contextlib import os from compose.config.config import ConfigDetails From 8e01f361bd30e43156b88f80d97a7f715c9c333c Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Wed, 3 Jun 2020 18:27:56 +0200 Subject: [PATCH 3926/4072] Update release docs Signed-off-by: Ulysses Souza --- script/release/README.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/script/release/README.md b/script/release/README.md index 8c961e3ad04..b42e4fa1dfa 100644 --- a/script/release/README.md +++ b/script/release/README.md @@ -4,7 +4,7 @@ The release process is fully automated by `Release.Jenkinsfile`. ## Usage -1. In the appropriate branch, run `./scripts/release/release tag ` +1. In the appropriate branch, run `./script/release/release.py tag ` By appropriate, we mean for a version `1.26.0` or `1.26.0-rc1` you should run the script in the `1.26.x` branch. @@ -16,3 +16,8 @@ After the executions, you should have a commit with the proper bumps for `docker This should trigger a new CI build on the new tag. When the CI finishes with the tests and builds a new draft release would be available on github's releases page. 3. Check and confirm the release on github's release page. + +4. In case of a GA version, please update `docker-compose`s release notes and version on [github documentation repository](https://github.com/docker/docker.github.io): + - [Release Notes](https://github.com/docker/docker.github.io/blob/master/compose/release-notes.md) + - [Config version](https://github.com/docker/docker.github.io/blob/master/_config.yml) + - [Config authoring version](https://github.com/docker/docker.github.io/blob/master/_config_authoring.yml) From 9f734f7c53d5ec6d892ea51fdf1c626773a1fd89 Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Wed, 3 Jun 2020 18:39:22 +0200 Subject: [PATCH 3927/4072] Pin all indirect dependencies Signed-off-by: Ulysses Souza --- Dockerfile | 1 + MANIFEST.in | 1 + requirements-indirect.txt | 28 ++++++++++++++++++++++++++++ requirements.txt | 1 - script/build/osx | 1 + script/build/windows.ps1 | 1 + tox.ini | 1 + 7 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 requirements-indirect.txt diff --git a/Dockerfile b/Dockerfile index 1f7608e24ae..4ef76b03ef6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -52,6 +52,7 @@ WORKDIR /code/ RUN pip install virtualenv==16.2.0 RUN pip install tox==2.9.1 +COPY requirements-indirect.txt . COPY requirements.txt . COPY requirements-dev.txt . COPY .pre-commit-config.yaml . diff --git a/MANIFEST.in b/MANIFEST.in index fca685eaae0..313b4e00814 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,5 +1,6 @@ include Dockerfile include LICENSE +include requirements-indirect.txt include requirements.txt include requirements-dev.txt include tox.ini diff --git a/requirements-indirect.txt b/requirements-indirect.txt new file mode 100644 index 00000000000..d92b4e708e4 --- /dev/null +++ b/requirements-indirect.txt @@ -0,0 +1,28 @@ +altgraph==0.17 +appdirs==1.4.4 +attrs==19.3.0 +bcrypt==3.1.7 +cffi==1.14.0 +cryptography==2.9.2 +distlib==0.3.0 +entrypoints==0.3 +filelock==3.0.12 +gitdb2==2.0.6 +mccabe==0.6.1 +more-itertools==8.3.0; python_version >= '3.5' +more-itertools==5.0.0; python_version < '3.5' +packaging==20.4 +pluggy==0.13.1 +py==1.8.1 +pycodestyle==2.5.0 +pycparser==2.20 +pyflakes==2.1.1 +PyNaCl==1.3.0 +pyparsing==2.4.7 +pyrsistent==0.16.0 +smmap==3.0.4 +smmap2==3.0.1 +toml==0.10.1 +tox==2.9.1 +virtualenv==16.2.0 +wcwidth==0.1.9 diff --git a/requirements.txt b/requirements.txt index e6473127217..7cc8edbeaa1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -25,5 +25,4 @@ six==1.12.0 subprocess32==3.5.4; python_version < '3.2' texttable==1.6.2 urllib3==1.25.9; python_version == '3.3' -wcwidth==0.1.9 websocket-client==0.57.0 diff --git a/script/build/osx b/script/build/osx index 66868756b69..e2d17527b3a 100755 --- a/script/build/osx +++ b/script/build/osx @@ -6,6 +6,7 @@ TOOLCHAIN_PATH="$(realpath $(dirname $0)/../../build/toolchain)" rm -rf venv virtualenv -p "${TOOLCHAIN_PATH}"/bin/python3 venv +venv/bin/pip install -r requirements-indirect.txt venv/bin/pip install -r requirements.txt venv/bin/pip install -r requirements-build.txt venv/bin/pip install --no-deps . diff --git a/script/build/windows.ps1 b/script/build/windows.ps1 index 7ba5ebde29e..472b31ca1ed 100644 --- a/script/build/windows.ps1 +++ b/script/build/windows.ps1 @@ -45,6 +45,7 @@ virtualenv -p C:\Python37\python.exe .\venv $ErrorActionPreference = "Continue" .\venv\Scripts\pip install pypiwin32==223 +.\venv\Scripts\pip install -r requirements-indirect.txt .\venv\Scripts\pip install -r requirements.txt .\venv\Scripts\pip install --no-deps . .\venv\Scripts\pip install -r requirements-build.txt diff --git a/tox.ini b/tox.ini index 5f57cfb1182..111d7e618bc 100644 --- a/tox.ini +++ b/tox.ini @@ -15,6 +15,7 @@ passenv = setenv = HOME=/tmp deps = + -rrequirements-indirect.txt -rrequirements.txt -rrequirements-dev.txt commands = From 7b84b66b0b128b19dd9a9697b54e31727e9ae5dd Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Mon, 8 Jun 2020 16:41:59 +0200 Subject: [PATCH 3928/4072] Expect failure of `test_create_container_with_blkio_config` On Linux kernel >= 5.3.x at least the daemon prints 2 warnings: "Your kernel does not support cgroup blkio weight" "Your kernel does not support cgroup blkio weight_device" Signed-off-by: Ulysses Souza --- tests/integration/service_test.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 01e2e1d22c4..765a2ced120 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -223,6 +223,9 @@ def test_create_container_with_read_only_root_fs(self): service.start_container(container) assert container.get('HostConfig.ReadonlyRootfs') == read_only + @pytest.mark.xfail(True, reason='Getting "Your kernel does not support ' + 'cgroup blkio weight and weight_device" on daemon start ' + 'on Linux kernel 5.3.x') def test_create_container_with_blkio_config(self): blkio_config = { 'weight': 300, From 312344f0882ce75dbec49cfb4d834881ef0d6bb5 Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Thu, 4 Jun 2020 17:00:15 +0200 Subject: [PATCH 3929/4072] Enforce docker>=4.2.1 on pip install Signed-off-by: Ulysses Souza --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index efc144b7fb9..ac55ce44fcc 100644 --- a/setup.py +++ b/setup.py @@ -37,7 +37,7 @@ def find_version(*file_paths): 'texttable >= 0.9.0, < 2', 'websocket-client >= 0.32.0, < 1', 'distro >= 1.5.0, < 2', - 'docker[ssh] >= 3.7.0, < 5', + 'docker[ssh] >= 4.2.1, < 5', 'dockerpty >= 0.4.1, < 1', 'six >= 1.3.0, < 2', 'jsonschema >= 2.5.1, < 4', From 2657601a88a94170dbf6bde5bc0bfda863761460 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 8 Jun 2020 21:24:27 +0000 Subject: [PATCH 3930/4072] Bump flake8 from 3.8.1 to 3.8.3 Bumps [flake8](https://gitlab.com/pycqa/flake8) from 3.8.1 to 3.8.3. - [Release notes](https://gitlab.com/pycqa/flake8/tags) - [Commits](https://gitlab.com/pycqa/flake8/compare/3.8.1...3.8.3) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 26a13a3cf2c..5eb76c27942 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,7 +1,7 @@ Click==7.1.2 coverage==5.0.3 ddt==1.4.1 -flake8==3.8.1 +flake8==3.8.3 gitpython==2.1.15 mock==3.0.5 pytest==5.4.2; python_version >= '3.5' From 59d03b1d5e7ae010d2aae52cbe519160c2cde016 Mon Sep 17 00:00:00 2001 From: Sebastian Pipping Date: Thu, 4 Jun 2020 21:40:30 +0200 Subject: [PATCH 3931/4072] tests: Stop using deprecated assertEquals (fixes #7501) Signed-off-by: Sebastian Pipping --- tests/unit/config/config_test.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 4f09c59a63b..7513d4b66de 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -3180,12 +3180,12 @@ def test_config_duplicate_mount_points(self): with self.assertRaises(ConfigurationError) as e: config.load(config1) - self.assertEquals(str(e.exception), 'Duplicate mount points: [%s]' % ( + self.assertEqual(str(e.exception), 'Duplicate mount points: [%s]' % ( ', '.join(['/tmp/foo:/tmp/foo:rw']*2))) with self.assertRaises(ConfigurationError) as e: config.load(config2) - self.assertEquals(str(e.exception), 'Duplicate mount points: [%s]' % ( + self.assertEqual(str(e.exception), 'Duplicate mount points: [%s]' % ( ', '.join(['/x:/y:rw', '/z:/y:rw']))) From 717085c8c2e752ac0f308df41391aa6b2817439b Mon Sep 17 00:00:00 2001 From: Joshua Arulsamy Date: Wed, 17 Jun 2020 13:45:57 -0600 Subject: [PATCH 3932/4072] Add `--` as a seperator of flags and arguments. `--` is common practice to clearly say where the flags stop and and arguments start. Fixes #7540 Signed-off-by: Joshua Arulsamy --- compose/cli/main.py | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 714592a3236..a052c110a7d 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -180,7 +180,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: @@ -257,7 +257,7 @@ def build(self, options): e.g. `composetest_db`. If you change a service's `Dockerfile` or the contents of its build directory, you can run `docker-compose build` to rebuild it. - Usage: build [options] [--build-arg key=val...] [SERVICE...] + Usage: build [options] [--build-arg key=val...] [--] [SERVICE...] Options: --build-arg key=val Set build-time variables for services. @@ -423,7 +423,7 @@ def events(self, options): """ Receive real time events from containers. - Usage: events [options] [SERVICE...] + Usage: events [options] [--] [SERVICE...] Options: --json Output events as a stream of json objects @@ -448,7 +448,7 @@ def exec_command(self, options): """ Execute a command in a running container - Usage: exec [options] [-e KEY=VAL...] SERVICE COMMAND [ARGS...] + Usage: exec [options] [-e KEY=VAL...] [--] SERVICE COMMAND [ARGS...] Options: -d, --detach Detached mode: Run command in the background. @@ -536,7 +536,7 @@ def help(cls, options): def images(self, options): """ List images used by the created containers. - Usage: images [options] [SERVICE...] + Usage: images [options] [--] [SERVICE...] Options: -q, --quiet Only display IDs @@ -591,7 +591,7 @@ def kill(self, options): """ Force stop service containers. - Usage: kill [options] [SERVICE...] + Usage: kill [options] [--] [SERVICE...] Options: -s SIGNAL SIGNAL to send to the container. @@ -605,7 +605,7 @@ def logs(self, options): """ View output from containers. - Usage: logs [options] [SERVICE...] + Usage: logs [options] [--] [SERVICE...] Options: --no-color Produce monochrome output. @@ -648,7 +648,7 @@ def port(self, options): """ Print the public port for a port binding. - Usage: port [options] SERVICE PRIVATE_PORT + Usage: port [options] [--] SERVICE PRIVATE_PORT Options: --protocol=proto tcp or udp [default: tcp] @@ -669,7 +669,7 @@ def ps(self, options): """ List containers. - Usage: ps [options] [SERVICE...] + Usage: ps [options] [--] [SERVICE...] Options: -q, --quiet Only display IDs @@ -725,7 +725,7 @@ def pull(self, options): """ Pulls images for services defined in a Compose file, but does not start the containers. - Usage: pull [options] [SERVICE...] + Usage: pull [options] [--] [SERVICE...] Options: --ignore-pull-failures Pull what it can and ignores images with pull failures. @@ -748,7 +748,7 @@ def push(self, options): """ Pushes images for services. - Usage: push [options] [SERVICE...] + Usage: push [options] [--] [SERVICE...] Options: --ignore-push-failures Push what it can and ignores images with push failures. @@ -767,7 +767,7 @@ def rm(self, options): Any data which is not in a volume will be lost. - Usage: rm [options] [SERVICE...] + Usage: rm [options] [--] [SERVICE...] Options: -f, --force Don't ask to confirm removal @@ -815,7 +815,7 @@ def run(self, options): `docker-compose run --no-deps SERVICE COMMAND [ARGS...]`. Usage: - run [options] [-v VOLUME...] [-p PORT...] [-e KEY=VAL...] [-l KEY=VALUE...] + run [options] [-v VOLUME...] [-p PORT...] [-e KEY=VAL...] [-l KEY=VALUE...] [--] SERVICE [COMMAND] [ARGS...] Options: @@ -911,7 +911,7 @@ def stop(self, options): They can be started again with `docker-compose start`. - Usage: stop [options] [SERVICE...] + Usage: stop [options] [--] [SERVICE...] Options: -t, --timeout TIMEOUT Specify a shutdown timeout in seconds. @@ -924,7 +924,7 @@ def restart(self, options): """ Restart running containers. - Usage: restart [options] [SERVICE...] + Usage: restart [options] [--] [SERVICE...] Options: -t, --timeout TIMEOUT Specify a shutdown timeout in seconds. @@ -989,7 +989,7 @@ def up(self, options): If you want to force Compose to stop and recreate all containers, use the `--force-recreate` flag. - Usage: up [options] [--scale SERVICE=NUM...] [SERVICE...] + Usage: up [options] [--scale SERVICE=NUM...] [--] [SERVICE...] Options: -d, --detach Detached mode: Run containers in the background, From d0f9fa84ade6cf2c8b0bc352e4f58b02348cb681 Mon Sep 17 00:00:00 2001 From: Joshua Arulsamy Date: Wed, 17 Jun 2020 23:14:39 -0600 Subject: [PATCH 3933/4072] Add tests for `--` seperator of flags and args. Signed-off-by: Joshua Arulsamy --- tests/acceptance/cli_test.py | 128 +++++++++++++++++- .../fixtures/flag-as-service-name/Dockerfile | 3 + .../flag-as-service-name/docker-compose.yml | 12 ++ 3 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/flag-as-service-name/Dockerfile create mode 100644 tests/fixtures/flag-as-service-name/docker-compose.yml diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index c84d3f8cb99..259550192cd 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -189,7 +189,7 @@ def lookup(self, container, hostname): def test_help(self): self.base_dir = 'tests/fixtures/no-composefile' result = self.dispatch(['help', 'up'], returncode=0) - assert 'Usage: up [options] [--scale SERVICE=NUM...] [SERVICE...]' in result.stdout + assert 'Usage: up [options] [--scale SERVICE=NUM...] [--] [SERVICE...]' in result.stdout # Prevent tearDown from trying to create a project self.base_dir = None @@ -2893,3 +2893,129 @@ def test_images_use_service_tag(self): assert re.search(r'foo1.+test[ \t]+dev', result.stdout) is not None assert re.search(r'foo2.+test[ \t]+prod', result.stdout) is not None assert re.search(r'foo3.+test[ \t]+latest', result.stdout) is not None + + def test_build_with_stop_process_flag(self): + self.base_dir = 'tests/fixtures/flag-as-service-name' + result = self.dispatch(['build', '--pull', '--', '--test-service']) + + assert BUILD_PULL_TEXT in result.stdout + + def test_events_with_stop_process_flag(self): + self.base_dir = 'tests/fixtures/flag-as-service-name' + events_proc = start_process(self.base_dir, ['events', '--json', '--', '--test-service']) + self.dispatch(['up', '-d', '--', '--test-service']) + wait_on_condition(ContainerCountCondition(self.project, 1)) + + 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 Counter(e['action'] for e in lines) == {'create': 1, 'start': 1} + + def test_exec_with_stop_process_flag(self): + self.base_dir = 'tests/fixtures/flag-as-service-name' + self.dispatch(['up', '-d', '--', '--test-service']) + assert len(self.project.containers()) == 1 + + stdout, stderr = self.dispatch(['exec', '-T', '--', '--test-service', 'ls', '-1d', '/']) + + assert stderr == "" + assert stdout == "/\n" + + def test_images_with_stop_process_flag(self): + self.base_dir = 'tests/fixtures/flag-as-service-name' + self.dispatch(['up', '-d', '--', '--test-service']) + result = self.dispatch(['images', '--', '--test-service']) + + assert "busybox" in result.stdout + + def test_kill_with_stop_process_flag(self): + self.base_dir = 'tests/fixtures/flag-as-service-name' + self.dispatch(['up', '-d', '--', '--test-service']) + service = self.project.get_service('--test-service') + + assert len(service.containers()) == 1 + assert service.containers()[0].is_running + + self.dispatch(['kill', '--', '--test-service']) + + assert len(service.containers(stopped=True)) == 1 + assert not service.containers(stopped=True)[0].is_running + + def test_logs_with_stop_process_flag(self): + self.base_dir = 'tests/fixtures/flag-as-service-name' + self.dispatch(['up', '-d', '--', '--log-service']) + result = self.dispatch(['logs', '--', '--log-service']) + + assert 'hello' in result.stdout + assert 'exited with' not in result.stdout + + def test_port_with_stop_process_flag(self): + self.base_dir = 'tests/fixtures/flag-as-service-name' + self.dispatch(['up', '-d', '--', '--test-service']) + result = self.dispatch(['port', '--', '--test-service', '80']) + + assert result.stdout.strip() == "0.0.0.0:8080" + + def test_ps_with_stop_process_flag(self): + self.base_dir = 'tests/fixtures/flag-as-service-name' + self.dispatch(['up', '-d', '--', '--test-service']) + + result = self.dispatch(['ps', '--', '--test-service']) + + assert 'flag-as-service-name_--test-service_1' in result.stdout + + def test_pull_with_stop_process_flag(self): + self.base_dir = 'tests/fixtures/flag-as-service-name' + result = self.dispatch(['pull', '--', '--test-service']) + + assert 'Pulling --test-service' in result.stderr + assert 'failed' not in result.stderr + + def test_rm_with_stop_process_flag(self): + self.base_dir = 'tests/fixtures/flag-as-service-name' + self.dispatch(['up', '--no-start', '--', '--test-service']) + service = self.project.get_service('--test-service') + assert len(service.containers(stopped=True)) == 1 + + self.dispatch(['rm', '--force', '--', '--test-service']) + assert len(service.containers(stopped=True)) == 0 + + def test_run_with_stop_process_flag(self): + self.base_dir = 'tests/fixtures/flag-as-service-name' + result = self.dispatch(['run', '--no-deps', '--', '--test-service', 'echo', '-hello']) + + assert 'hello' in result.stdout + assert len(self.project.containers()) == 0 + + def test_stop_with_stop_process_flag(self): + self.base_dir = 'tests/fixtures/flag-as-service-name' + self.dispatch(['up', '-d', '--', '--test-service']) + service = self.project.get_service('--test-service') + assert len(service.containers()) == 1 + assert service.containers()[0].is_running + + self.dispatch(['stop', '-t', '1', '--', '--test-service']) + + assert len(service.containers(stopped=True)) == 1 + assert not service.containers(stopped=True)[0].is_running + + def test_restart_with_stop_process_flag(self): + self.base_dir = 'tests/fixtures/flag-as-service-name' + self.dispatch(['up', '-d', '--', '--test-service']) + service = self.project.get_service('--test-service') + assert len(service.containers()) == 1 + assert service.containers()[0].is_running + + self.dispatch(['restart', '-t', '1', '--', '--test-service']) + + assert len(service.containers()) == 1 + assert service.containers()[0].is_running + + def test_up_with_stop_process_flag(self): + self.base_dir = 'tests/fixtures/flag-as-service-name' + self.dispatch(['up', '-d', '--', '--test-service', '--log-service']) + + service = self.project.get_service('--test-service') + another = self.project.get_service('--log-service') + assert len(service.containers()) == 1 + assert len(another.containers()) == 1 diff --git a/tests/fixtures/flag-as-service-name/Dockerfile b/tests/fixtures/flag-as-service-name/Dockerfile new file mode 100644 index 00000000000..098ff3eb195 --- /dev/null +++ b/tests/fixtures/flag-as-service-name/Dockerfile @@ -0,0 +1,3 @@ +FROM busybox:1.27.2 +LABEL com.docker.compose.test_image=true +CMD echo "success" diff --git a/tests/fixtures/flag-as-service-name/docker-compose.yml b/tests/fixtures/flag-as-service-name/docker-compose.yml new file mode 100644 index 00000000000..5b519a63efe --- /dev/null +++ b/tests/fixtures/flag-as-service-name/docker-compose.yml @@ -0,0 +1,12 @@ +version: "2" +services: + --test-service: + image: busybox:1.27.0.2 + build: . + command: top + ports: + - "8080:80" + + --log-service: + image: busybox:1.31.0-uclibc + command: sh -c "echo hello && tail -f /dev/null" From 62144b4f95dc960fa6d22fa324dc2ea2b42046fe Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Mon, 22 Jun 2020 15:18:47 +0200 Subject: [PATCH 3934/4072] Remove the empty file Signed-off-by: Ulysses Souza --- compose/state.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 compose/state.py diff --git a/compose/state.py b/compose/state.py deleted file mode 100644 index e69de29bb2d..00000000000 From de7f864008daa1a5cdb60b8b383a6aa243b3c77e Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 22 Jun 2020 21:25:37 +0000 Subject: [PATCH 3935/4072] Bump certifi from 2020.4.5.1 to 2020.6.20 Bumps [certifi](https://github.com/certifi/python-certifi) from 2020.4.5.1 to 2020.6.20. - [Release notes](https://github.com/certifi/python-certifi/releases) - [Commits](https://github.com/certifi/python-certifi/compare/2020.04.05.1...2020.06.20) Signed-off-by: dependabot-preview[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 82eb84d4987..6b38f51ac7b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ backports.shutil_get_terminal_size==1.0.0 cached-property==1.5.1 -certifi==2020.4.5.1 +certifi==2020.6.20 chardet==3.0.4 colorama==0.4.3; sys_platform == 'win32' distro==1.5.0 From 76657158008a8bb8b1ddb52a357fe86766b74ce1 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 15 Jun 2020 21:20:29 +0000 Subject: [PATCH 3936/4072] Bump pycodestyle from 2.5.0 to 2.6.0 Bumps [pycodestyle](https://github.com/PyCQA/pycodestyle) from 2.5.0 to 2.6.0. - [Release notes](https://github.com/PyCQA/pycodestyle/releases) - [Changelog](https://github.com/PyCQA/pycodestyle/blob/master/CHANGES.txt) - [Commits](https://github.com/PyCQA/pycodestyle/compare/2.5.0...2.6.0) Signed-off-by: dependabot-preview[bot] --- requirements-indirect.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-indirect.txt b/requirements-indirect.txt index d92b4e708e4..72b4a8f5f06 100644 --- a/requirements-indirect.txt +++ b/requirements-indirect.txt @@ -14,7 +14,7 @@ more-itertools==5.0.0; python_version < '3.5' packaging==20.4 pluggy==0.13.1 py==1.8.1 -pycodestyle==2.5.0 +pycodestyle==2.6.0 pycparser==2.20 pyflakes==2.1.1 PyNaCl==1.3.0 From 71de72d25915f2f22f4a581f14b70712ae68c73a Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 15 Jun 2020 21:19:46 +0000 Subject: [PATCH 3937/4072] Bump pyflakes from 2.1.1 to 2.2.0 Bumps [pyflakes](https://github.com/PyCQA/pyflakes) from 2.1.1 to 2.2.0. - [Release notes](https://github.com/PyCQA/pyflakes/releases) - [Changelog](https://github.com/PyCQA/pyflakes/blob/master/NEWS.rst) - [Commits](https://github.com/PyCQA/pyflakes/compare/2.1.1...2.2.0) Signed-off-by: dependabot-preview[bot] --- requirements-indirect.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-indirect.txt b/requirements-indirect.txt index 72b4a8f5f06..5ecf39b1b76 100644 --- a/requirements-indirect.txt +++ b/requirements-indirect.txt @@ -16,7 +16,7 @@ pluggy==0.13.1 py==1.8.1 pycodestyle==2.6.0 pycparser==2.20 -pyflakes==2.1.1 +pyflakes==2.2.0 PyNaCl==1.3.0 pyparsing==2.4.7 pyrsistent==0.16.0 From caee9fc136e83b2d43e807b80d55e89aa11c7c9c Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 22 Jun 2020 08:28:03 +0000 Subject: [PATCH 3938/4072] Bump pytest from 5.4.2 to 5.4.3 Bumps [pytest](https://github.com/pytest-dev/pytest) from 5.4.2 to 5.4.3. - [Release notes](https://github.com/pytest-dev/pytest/releases) - [Changelog](https://github.com/pytest-dev/pytest/blob/master/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest/compare/5.4.2...5.4.3) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 5eb76c27942..7f0ee67cded 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -4,6 +4,6 @@ ddt==1.4.1 flake8==3.8.3 gitpython==2.1.15 mock==3.0.5 -pytest==5.4.2; python_version >= '3.5' +pytest==5.4.3; python_version >= '3.5' pytest==4.6.5; python_version < '3.5' pytest-cov==2.8.1 From 23f05e67b085f086d8896560f7374e14832e46e4 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 22 Jun 2020 21:25:33 +0000 Subject: [PATCH 3939/4072] Bump gitdb2 from 2.0.6 to 4.0.2 Bumps [gitdb2](https://github.com/gitpython-developers/gitdb) from 2.0.6 to 4.0.2. - [Release notes](https://github.com/gitpython-developers/gitdb/releases) - [Commits](https://github.com/gitpython-developers/gitdb/compare/2.0.6...4.0.2) Signed-off-by: dependabot-preview[bot] --- requirements-indirect.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-indirect.txt b/requirements-indirect.txt index 5ecf39b1b76..13d1547cc24 100644 --- a/requirements-indirect.txt +++ b/requirements-indirect.txt @@ -7,7 +7,7 @@ cryptography==2.9.2 distlib==0.3.0 entrypoints==0.3 filelock==3.0.12 -gitdb2==2.0.6 +gitdb2==4.0.2 mccabe==0.6.1 more-itertools==8.3.0; python_version >= '3.5' more-itertools==5.0.0; python_version < '3.5' From b1250a541a455b54cf3f54251f9bf4ee25caaa6e Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 22 Jun 2020 22:34:02 +0000 Subject: [PATCH 3940/4072] Bump more-itertools from 8.3.0 to 8.4.0 Bumps [more-itertools](https://github.com/more-itertools/more-itertools) from 8.3.0 to 8.4.0. - [Release notes](https://github.com/more-itertools/more-itertools/releases) - [Commits](https://github.com/more-itertools/more-itertools/compare/v8.3.0...v8.4.0) Signed-off-by: dependabot-preview[bot] --- requirements-indirect.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-indirect.txt b/requirements-indirect.txt index 13d1547cc24..d0e9c92585c 100644 --- a/requirements-indirect.txt +++ b/requirements-indirect.txt @@ -9,7 +9,7 @@ entrypoints==0.3 filelock==3.0.12 gitdb2==4.0.2 mccabe==0.6.1 -more-itertools==8.3.0; python_version >= '3.5' +more-itertools==8.4.0; python_version >= '3.5' more-itertools==5.0.0; python_version < '3.5' packaging==20.4 pluggy==0.13.1 From 92501a107612d67bbbfa95257ac915512f974460 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 22 Jun 2020 22:34:21 +0000 Subject: [PATCH 3941/4072] Bump pytest-cov from 2.8.1 to 2.10.0 Bumps [pytest-cov](https://github.com/pytest-dev/pytest-cov) from 2.8.1 to 2.10.0. - [Release notes](https://github.com/pytest-dev/pytest-cov/releases) - [Changelog](https://github.com/pytest-dev/pytest-cov/blob/master/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest-cov/compare/v2.8.1...v2.10.0) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 7f0ee67cded..8fefffdd016 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -6,4 +6,4 @@ gitpython==2.1.15 mock==3.0.5 pytest==5.4.3; python_version >= '3.5' pytest==4.6.5; python_version < '3.5' -pytest-cov==2.8.1 +pytest-cov==2.10.0 From af2d336c95891565421e98110cffeb195ceb190b Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 22 Jun 2020 23:48:22 +0000 Subject: [PATCH 3942/4072] Bump py from 1.8.1 to 1.8.2 Bumps [py](https://github.com/pytest-dev/py) from 1.8.1 to 1.8.2. - [Release notes](https://github.com/pytest-dev/py/releases) - [Changelog](https://github.com/pytest-dev/py/blob/master/CHANGELOG) - [Commits](https://github.com/pytest-dev/py/compare/1.8.1...1.8.2) Signed-off-by: dependabot-preview[bot] --- requirements-indirect.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-indirect.txt b/requirements-indirect.txt index d0e9c92585c..472e9393a5e 100644 --- a/requirements-indirect.txt +++ b/requirements-indirect.txt @@ -13,7 +13,7 @@ more-itertools==8.4.0; python_version >= '3.5' more-itertools==5.0.0; python_version < '3.5' packaging==20.4 pluggy==0.13.1 -py==1.8.1 +py==1.8.2 pycodestyle==2.6.0 pycparser==2.20 pyflakes==2.2.0 From 500a32212526a3d34c6696e1fc58d35fab47d92a Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 22 Jun 2020 08:28:10 +0000 Subject: [PATCH 3943/4072] Bump coverage from 5.0.3 to 5.1 Bumps [coverage](https://github.com/nedbat/coveragepy) from 5.0.3 to 5.1. - [Release notes](https://github.com/nedbat/coveragepy/releases) - [Changelog](https://github.com/nedbat/coveragepy/blob/master/CHANGES.rst) - [Commits](https://github.com/nedbat/coveragepy/compare/coverage-5.0.3...coverage-5.1) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 8fefffdd016..b31226b6933 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,5 +1,5 @@ Click==7.1.2 -coverage==5.0.3 +coverage==5.1 ddt==1.4.1 flake8==3.8.3 gitpython==2.1.15 From d8c996a65ed76658eabcdae4c1b347347d78d8f4 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Wed, 10 Jun 2020 15:04:41 +0000 Subject: [PATCH 3944/4072] Bump pyyaml from 5.3 to 5.3.1 Bumps [pyyaml](https://github.com/yaml/pyyaml) from 5.3 to 5.3.1. - [Release notes](https://github.com/yaml/pyyaml/releases) - [Changelog](https://github.com/yaml/pyyaml/blob/master/CHANGES) - [Commits](https://github.com/yaml/pyyaml/compare/5.3...5.3.1) Signed-off-by: dependabot-preview[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 6b38f51ac7b..e22f3345713 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,7 +18,7 @@ pypiwin32==219; sys_platform == 'win32' and python_version < '3.6' pypiwin32==223; sys_platform == 'win32' and python_version >= '3.6' PySocks==1.7.1 python-dotenv==0.13.0 -PyYAML==5.3 +PyYAML==5.3.1 requests==2.22.0 texttable==1.6.2 urllib3==1.25.9; python_version == '3.3' From ef565ce7a769cf12bbd394f906e7c3050d8d9c84 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2020 21:24:11 +0000 Subject: [PATCH 3945/4072] Bump idna from 2.8 to 2.10 Bumps [idna](https://github.com/kjd/idna) from 2.8 to 2.10. - [Release notes](https://github.com/kjd/idna/releases) - [Changelog](https://github.com/kjd/idna/blob/master/HISTORY.rst) - [Commits](https://github.com/kjd/idna/compare/v2.8...v2.10) Signed-off-by: dependabot-preview[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index e22f3345713..3800ec231ce 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,7 +10,7 @@ dockerpty==0.4.1 docopt==0.6.2 enum34==1.1.6; python_version < '3.4' functools32==3.2.3.post2; python_version < '3.2' -idna==2.8 +idna==2.10 ipaddress==1.0.23 jsonschema==3.2.0 paramiko==2.7.1 From 9c376dbe2fb8c150ab5757601834069c22782d07 Mon Sep 17 00:00:00 2001 From: aiordache Date: Fri, 26 Jun 2020 16:29:07 +0200 Subject: [PATCH 3946/4072] check context in use targets a docker engine Signed-off-by: aiordache --- compose/cli/docker_client.py | 5 +++++ requirements.txt | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/compose/cli/docker_client.py b/compose/cli/docker_client.py index 9a930f7f8b9..b65262344e0 100644 --- a/compose/cli/docker_client.py +++ b/compose/cli/docker_client.py @@ -140,6 +140,11 @@ def docker_client(environment, version=None, context=None, tls_version=None): if tls: context.set_endpoint("docker", host=host, tls_cfg=tls, skip_tls_verify=not verify) + if not context.is_docker_host(): + raise UserError( + "The platform targeted with the current context is not supported.\n" + "Make sure the context in use targets a Docker Engine.\n") + kwargs['base_url'] = context.Host if context.TLSConfig: kwargs['tls'] = context.TLSConfig diff --git a/requirements.txt b/requirements.txt index 82eb84d4987..6c3667c0c22 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ certifi==2020.4.5.1 chardet==3.0.4 colorama==0.4.3; sys_platform == 'win32' distro==1.5.0 -docker==4.2.1 +docker==4.2.2 docker-pycreds==0.4.0 dockerpty==0.4.1 docopt==0.6.2 From a559d50db81c1edad9ff54efdd96fa9ec6814306 Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Thu, 2 Jul 2020 12:50:15 +0200 Subject: [PATCH 3947/4072] setyp.py: fix minimum docker-py requirement to 4.2.2 Signed-off-by: Sebastiaan van Stijn --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 79809b52432..a2e946b3342 100644 --- a/setup.py +++ b/setup.py @@ -33,7 +33,7 @@ def find_version(*file_paths): 'texttable >= 0.9.0, < 2', 'websocket-client >= 0.32.0, < 1', 'distro >= 1.5.0, < 2', - 'docker[ssh] >= 4.2.1, < 5', + 'docker[ssh] >= 4.2.2, < 5', 'dockerpty >= 0.4.1, < 1', 'jsonschema >= 2.5.1, < 4', 'python-dotenv >= 0.13.0, < 1', From 7728bf6c8bfe28b2b9a35d5b3f5634a099371602 Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Tue, 30 Jun 2020 19:28:59 +0200 Subject: [PATCH 3948/4072] [master] forward-port "Bump 1.26.1" Signed-off-by: Ulysses Souza (cherry picked from commit f216ddbf05c131058cb11323023f8b43cd381926) Signed-off-by: Sebastiaan van Stijn --- CHANGELOG.md | 13 +++++++++++++ script/run/run.sh | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 432f1745e1a..2e7a6bbf09a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,19 @@ Change log ========== +1.26.1 (2020-06-30) +------------------- + +### Features + +- Bump `docker-py` from 4.2.1 to 4.2.2 + +### Bugs + +- Enforce `docker-py` 4.2.1 as minimum version when installing with pip + +- Fix context load for non-docker endpoints + 1.26.0 (2020-06-03) ------------------- diff --git a/script/run/run.sh b/script/run/run.sh index 65fb656add9..f7be36b60e0 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -15,7 +15,7 @@ set -e -VERSION="1.26.0" +VERSION="1.26.1" IMAGE="docker/compose:$VERSION" From deaa3d68c325133abccf0a49632d6a57558bed87 Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Thu, 2 Jul 2020 15:09:52 +0200 Subject: [PATCH 3949/4072] Apply more specific filtering CI machines Signed-off-by: Ulysses Souza --- Release.Jenkinsfile | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/Release.Jenkinsfile b/Release.Jenkinsfile index 8aa741e561e..e7a2c7c13e7 100644 --- a/Release.Jenkinsfile +++ b/Release.Jenkinsfile @@ -20,7 +20,7 @@ pipeline { parallel { stage('alpine') { agent { - label 'linux' + label 'linux && docker && ubuntu-2004' } steps { buildImage('alpine') @@ -28,7 +28,7 @@ pipeline { } stage('debian') { agent { - label 'linux' + label 'linux && docker && ubuntu-2004' } steps { buildImage('debian') @@ -55,7 +55,7 @@ pipeline { } stage('Generate Changelog') { agent { - label 'linux' + label 'linux && docker && ubuntu-2004' } steps { checkout scm @@ -92,7 +92,7 @@ pipeline { } stage('linux binary') { agent { - label 'linux' + label 'linux && docker && ubuntu-2004' } steps { checkout scm @@ -128,7 +128,7 @@ pipeline { } stage('alpine image') { agent { - label 'linux' + label 'linux && docker && ubuntu-2004' } steps { buildRuntimeImage('alpine') @@ -136,7 +136,7 @@ pipeline { } stage('debian image') { agent { - label 'linux' + label 'linux && docker && ubuntu-2004' } steps { buildRuntimeImage('debian') @@ -151,7 +151,7 @@ pipeline { parallel { stage('Pushing images') { agent { - label 'linux' + label 'linux && docker && ubuntu-2004' } steps { pushRuntimeImage('alpine') @@ -160,7 +160,7 @@ pipeline { } stage('Creating Github Release') { agent { - label 'linux' + label 'linux && docker && ubuntu-2004' } environment { GITHUB_TOKEN = credentials('github-release-token') @@ -192,7 +192,7 @@ pipeline { } stage('Publishing Python packages') { agent { - label 'linux' + label 'linux && docker && ubuntu-2004' } environment { PYPIRC = credentials('pypirc-docker-dsg-cibot') From a383cae6154723091e469d2b12e42a97a13eea70 Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Thu, 2 Jul 2020 18:04:28 +0200 Subject: [PATCH 3950/4072] Enforce pip3 and python3 on Release Jenkins Signed-off-by: Ulysses Souza --- Release.Jenkinsfile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Release.Jenkinsfile b/Release.Jenkinsfile index e7a2c7c13e7..be3d935d935 100644 --- a/Release.Jenkinsfile +++ b/Release.Jenkinsfile @@ -201,9 +201,9 @@ pipeline { checkout scm sh """ rm -rf build/ dist/ - pip install wheel - python setup.py sdist bdist_wheel - pip install twine + pip3 install wheel + python3 setup.py sdist bdist_wheel + pip3 install twine ~/.local/bin/twine upload --config-file ${PYPIRC} ./dist/docker-compose-*.tar.gz ./dist/docker_compose-*-py2.py3-none-any.whl """ } From c17ef995b44e62d9ed8c1327292a3bff2e155396 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Wed, 1 Jul 2020 15:37:15 -0700 Subject: [PATCH 3951/4072] Improve version help message Signed-off-by: Ben Firshman --- compose/cli/main.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 714592a3236..0027a7e96ab 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -233,7 +233,7 @@ class TopLevelCommand(object): top Display the running processes unpause Unpause services up Create and start containers - version Show the Docker-Compose version information + version Show version information and quit """ def __init__(self, project, options=None): @@ -1118,7 +1118,7 @@ def up(rebuild): @classmethod def version(cls, options): """ - Show version information + Show version information and quit. Usage: version [--short] From 0ea51906cd918fd8a226d215aa307fff977b1645 Mon Sep 17 00:00:00 2001 From: "Santiago M. Mola" Date: Fri, 3 Jul 2020 12:04:47 +0200 Subject: [PATCH 3952/4072] pass COMPOSE_PROJECT_NAME env var in container mode Avoids the surprise of finding the project name set to the directory name when COMPOSE_PROJECT_NAME is being used. Signed-off-by: Santiago M. Mola --- script/run/run.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/script/run/run.sh b/script/run/run.sh index f7be36b60e0..a6de02f7339 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -38,6 +38,9 @@ if [ -n "$COMPOSE_FILE" ]; then COMPOSE_OPTIONS="$COMPOSE_OPTIONS -e COMPOSE_FILE=$COMPOSE_FILE" compose_dir=$(realpath "$(dirname "$COMPOSE_FILE")") fi +if [ -n "$COMPOSE_PROJECT_NAME" ]; then + COMPOSE_OPTIONS="-e COMPOSE_PROJECT_NAME $COMPOSE_OPTIONS" +fi # TODO: also check --file argument if [ -n "$compose_dir" ]; then VOLUMES="$VOLUMES -v $compose_dir:$compose_dir" From 789bfb0e8b2e61f15f423d371508b698c64b057f Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2020 21:25:45 +0000 Subject: [PATCH 3953/4072] Bump python-dotenv from 0.13.0 to 0.14.0 Bumps [python-dotenv](https://github.com/theskumar/python-dotenv) from 0.13.0 to 0.14.0. - [Release notes](https://github.com/theskumar/python-dotenv/releases) - [Changelog](https://github.com/theskumar/python-dotenv/blob/master/CHANGELOG.md) - [Commits](https://github.com/theskumar/python-dotenv/compare/v0.13.0...v0.14.0) Signed-off-by: dependabot-preview[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 013f62caf52..7743e314498 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,7 +17,7 @@ paramiko==2.7.1 pypiwin32==219; sys_platform == 'win32' and python_version < '3.6' pypiwin32==223; sys_platform == 'win32' and python_version >= '3.6' PySocks==1.7.1 -python-dotenv==0.13.0 +python-dotenv==0.14.0 PyYAML==5.3.1 requests==2.22.0 texttable==1.6.2 From 5fb1b139ca3836bad3eadc53ac3d803eb5637fc2 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2020 09:26:45 +0000 Subject: [PATCH 3954/4072] Bump requests from 2.22.0 to 2.24.0 Bumps [requests](https://github.com/psf/requests) from 2.22.0 to 2.24.0. - [Release notes](https://github.com/psf/requests/releases) - [Changelog](https://github.com/psf/requests/blob/master/HISTORY.md) - [Commits](https://github.com/psf/requests/compare/v2.22.0...v2.24.0) Signed-off-by: dependabot-preview[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 7743e314498..89cfd68a571 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,7 +19,7 @@ pypiwin32==223; sys_platform == 'win32' and python_version >= '3.6' PySocks==1.7.1 python-dotenv==0.14.0 PyYAML==5.3.1 -requests==2.22.0 +requests==2.24.0 texttable==1.6.2 urllib3==1.25.9; python_version == '3.3' websocket-client==0.57.0 From 64299dd467d83c8eedb7b3627241df2f11029a83 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2020 09:26:47 +0000 Subject: [PATCH 3955/4072] Bump py from 1.8.2 to 1.9.0 Bumps [py](https://github.com/pytest-dev/py) from 1.8.2 to 1.9.0. - [Release notes](https://github.com/pytest-dev/py/releases) - [Changelog](https://github.com/pytest-dev/py/blob/master/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/py/compare/1.8.2...1.9.0) Signed-off-by: dependabot-preview[bot] --- requirements-indirect.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-indirect.txt b/requirements-indirect.txt index 472e9393a5e..b73c7e0d95e 100644 --- a/requirements-indirect.txt +++ b/requirements-indirect.txt @@ -13,7 +13,7 @@ more-itertools==8.4.0; python_version >= '3.5' more-itertools==5.0.0; python_version < '3.5' packaging==20.4 pluggy==0.13.1 -py==1.8.2 +py==1.9.0 pycodestyle==2.6.0 pycparser==2.20 pyflakes==2.2.0 From b78c1ec193c5ee65fd59d00a42c4ca996251af57 Mon Sep 17 00:00:00 2001 From: aiordache Date: Fri, 26 Jun 2020 10:21:48 +0200 Subject: [PATCH 3956/4072] Merge 2.x and 3.x schemas Signed-off-by: aiordache --- compose/cli/main.py | 17 +- compose/config/config.py | 117 +- compose/config/config_schema_v2.0.json | 424 ------- compose/config/config_schema_v2.1.json | 480 -------- compose/config/config_schema_v2.2.json | 489 -------- compose/config/config_schema_v2.3.json | 533 --------- compose/config/config_schema_v2.4.json | 535 --------- compose/config/config_schema_v3.0.json | 399 ------- compose/config/config_schema_v3.1.json | 444 -------- compose/config/config_schema_v3.2.json | 492 -------- compose/config/config_schema_v3.3.json | 551 --------- compose/config/config_schema_v3.4.json | 560 --------- compose/config/config_schema_v3.5.json | 588 ---------- compose/config/config_schema_v3.6.json | 582 ---------- compose/config/config_schema_v3.7.json | 1013 ----------------- ...hema_v3.8.json => config_schema_v4.0.json} | 101 +- compose/config/interpolation.py | 4 +- compose/config/serialize.py | 31 +- compose/config/validation.py | 29 +- compose/const.py | 46 +- docker-compose.spec | 69 +- docker-compose_darwin.spec | 69 +- tests/acceptance/cli_test.py | 89 +- tests/integration/project_test.py | 89 +- tests/integration/testcases.py | 28 +- tests/unit/cli/errors_test.py | 8 +- tests/unit/config/config_test.py | 378 +++--- tests/unit/config/interpolation_test.py | 32 +- tests/unit/config/types_test.py | 14 +- tests/unit/project_test.py | 59 +- 30 files changed, 522 insertions(+), 7748 deletions(-) delete mode 100644 compose/config/config_schema_v2.0.json delete mode 100644 compose/config/config_schema_v2.1.json delete mode 100644 compose/config/config_schema_v2.2.json delete mode 100644 compose/config/config_schema_v2.3.json delete mode 100644 compose/config/config_schema_v2.4.json delete mode 100644 compose/config/config_schema_v3.0.json delete mode 100644 compose/config/config_schema_v3.1.json delete mode 100644 compose/config/config_schema_v3.2.json delete mode 100644 compose/config/config_schema_v3.3.json delete mode 100644 compose/config/config_schema_v3.4.json delete mode 100644 compose/config/config_schema_v3.5.json delete mode 100644 compose/config/config_schema_v3.6.json delete mode 100644 compose/config/config_schema_v3.7.json rename compose/config/{config_schema_v3.8.json => config_schema_v4.0.json} (89%) diff --git a/compose/cli/main.py b/compose/cli/main.py index 0027a7e96ab..079ea160a57 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -24,7 +24,6 @@ from ..config.environment import Environment from ..config.serialize import serialize_config from ..config.types import VolumeSpec -from ..const import COMPOSEFILE_V2_2 as V2_2 from ..const import IS_WINDOWS_PLATFORM from ..errors import StreamParseError from ..progress_stream import StreamOutputError @@ -205,7 +204,7 @@ class TopLevelCommand(object): --project-directory PATH Specify an alternate working directory (default: the path of the Compose file) --compatibility If set, Compose will attempt to convert keys - in v3 files to their non-Swarm equivalent + in v3 files to their non-Swarm equivalent (DEPRECATED) --env-file PATH Specify an alternate environment file Commands: @@ -882,16 +881,10 @@ def scale(self, options): """ timeout = timeout_from_opts(options) - if self.project.config_version == V2_2: - raise UserError( - 'The scale command is incompatible with the v2.2 format. ' - 'Use the up command with the --scale flag instead.' - ) - else: - log.warning( - 'The scale command is deprecated. ' - 'Use the up command with the --scale flag instead.' - ) + log.warning( + 'The scale command is deprecated. ' + 'Use the up command with the --scale flag instead.' + ) for service_name, num in parse_scale_args(options['SERVICE=NUM']).items(): self.project.get_service(service_name).scale(num, timeout=timeout) diff --git a/compose/config/config.py b/compose/config/config.py index 61fad199f60..1557e52d0bd 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -12,18 +12,13 @@ from cached_property import cached_property from . import types -from .. import const from ..const import COMPOSEFILE_V1 as V1 -from ..const import COMPOSEFILE_V2_1 as V2_1 -from ..const import COMPOSEFILE_V2_3 as V2_3 -from ..const import COMPOSEFILE_V3_0 as V3_0 -from ..const import COMPOSEFILE_V3_4 as V3_4 +from ..const import COMPOSEFILE_V4 as VERSION from ..utils import build_string_dict from ..utils import json_hash from ..utils import parse_bytes from ..utils import parse_nanoseconds_int from ..utils import splitdrive -from ..version import ComposeVersion from .environment import env_vars_from_file from .environment import Environment from .environment import split_env @@ -189,15 +184,28 @@ def from_filename(cls, filename): @cached_property def version(self): - if 'version' not in self.config: + version = self.config.get('version', None) + if not version: + # no version is specified in the config file + services = self.config.get('services', None) + networks = self.config.get('networks', None) + volumes = self.config.get('volumes', None) + if services or networks or volumes: + # validate V2/V3 structure + for section in ['services', 'networks', 'volumes']: + validate_config_section( + self.filename, self.config.get(section, {}), section) + return VERSION + + # validate V1 structure + validate_config_section( + self.filename, self.config, 'services') return V1 - version = self.config['version'] - if isinstance(version, dict): log.warning('Unexpected type for "version" key in "{}". Assuming ' '"version" is the name of a service, and defaulting to ' - 'Compose file version 1.'.format(self.filename)) + 'Compose file version {}.'.format(self.filename, V1)) return V1 if not isinstance(version, str): @@ -205,31 +213,32 @@ def version(self): 'Version in "{}" is invalid - it should be a string.' .format(self.filename)) - if version == '1': + if isinstance(version, str): + version_pattern = re.compile(r"^[1-4]+(\.\d+)?$") + if not version_pattern.match(version): + raise ConfigurationError( + 'Version "{}" in "{}" is invalid.' + .format(version, self.filename)) + + if version.startswith("1"): + version = V1 + else: + version = VERSION + + if version == V1: raise ConfigurationError( 'Version in "{}" is invalid. {}' .format(self.filename, VERSION_EXPLANATION) ) - - version_pattern = re.compile(r"^[2-9]+(\.\d+)?$") - if not version_pattern.match(version): - raise ConfigurationError( - 'Version "{}" in "{}" is invalid.' - .format(version, self.filename)) - - if version == '2': - return const.COMPOSEFILE_V2_0 - - if version == '3': - return const.COMPOSEFILE_V3_0 - - return ComposeVersion(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 == V1 else self.config.get('services', {}) + if self.version == V1: + return self.config + return self.config.get('services', {}) def get_volumes(self): return {} if self.version == V1 else self.config.get('volumes', {}) @@ -238,10 +247,10 @@ def get_networks(self): return {} if self.version == V1 else self.config.get('networks', {}) def get_secrets(self): - return {} if self.version < const.COMPOSEFILE_V3_1 else self.config.get('secrets', {}) + return {} if self.version == V1 else self.config.get('secrets', {}) def get_configs(self): - return {} if self.version < const.COMPOSEFILE_V3_3 else self.config.get('configs', {}) + return {} if self.version == V1 else self.config.get('configs', {}) class Config(namedtuple('_Config', 'version services volumes networks secrets configs')): @@ -299,6 +308,7 @@ def find(base_dir, filenames, environment, override_dir=None): def validate_config_version(config_files): main_file = config_files[0] validate_top_level_object(main_file) + for next_file in config_files[1:]: validate_top_level_object(next_file) @@ -371,8 +381,8 @@ def check_swarm_only_key(service_dicts, key): key=key ) ) - if not compatibility: - check_swarm_only_key(service_dicts, 'deploy') + + check_swarm_only_key(service_dicts, 'deploy') check_swarm_only_key(service_dicts, 'configs') @@ -383,6 +393,8 @@ def load(config_details, compatibility=False, interpolate=True): Return a fully interpolated, extended and validated configuration. """ + + # validate against latest version and if fails do it against v1 schema validate_config_version(config_details.config_files) processed_files = [ @@ -412,7 +424,7 @@ def load(config_details, compatibility=False, interpolate=True): check_swarm_only_config(service_dicts, compatibility) - version = V2_3 if compatibility and main_file.version >= V3_0 else main_file.version + version = main_file.version return Config(version, service_dicts, volumes, networks, secrets, configs) @@ -449,11 +461,12 @@ def load_mapping(config_files, get_func, entity_type, working_dir=None): def validate_external(entity_type, name, config, version): - if (version < V2_1 or (version >= V3_0 and version < V3_4)) and len(config.keys()) > 1: - raise ConfigurationError( - "{} {} declared as external but specifies additional attributes " - "({}).".format( - entity_type, name, ', '.join(k for k in config if k != 'external'))) + for k in config.keys(): + if k not in ['external', 'name']: + raise ConfigurationError( + "{} {} declared as external but specifies additional attributes " + "({}).".format( + entity_type, name, ', '.join(k for k in config if k != 'external'))) def load_services(config_details, config_file, compatibility=False, interpolate=True): @@ -554,27 +567,25 @@ def process_config_file(config_file, environment, service_name=None, interpolate environment, interpolate, ) - if config_file.version >= const.COMPOSEFILE_V3_1: - processed_config['secrets'] = process_config_section( - config_file, - config_file.get_secrets(), - 'secret', - environment, - interpolate, - ) - if config_file.version >= const.COMPOSEFILE_V3_3: - processed_config['configs'] = process_config_section( - config_file, - config_file.get_configs(), - 'config', - environment, - interpolate, - ) + processed_config['secrets'] = process_config_section( + config_file, + config_file.get_secrets(), + 'secret', + environment, + interpolate, + ) + processed_config['configs'] = process_config_section( + config_file, + config_file.get_configs(), + 'config', + environment, + interpolate, + ) else: processed_config = services config_file = config_file._replace(config=processed_config) - validate_against_config_schema(config_file) + validate_against_config_schema(config_file, config_file.version) if service_name and service_name not in services: raise ConfigurationError( diff --git a/compose/config/config_schema_v2.0.json b/compose/config/config_schema_v2.0.json deleted file mode 100644 index 419f2e28c9d..00000000000 --- a/compose/config/config_schema_v2.0.json +++ /dev/null @@ -1,424 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - "id": "config_schema_v2.0.json", - "type": "object", - - "properties": { - "version": { - "type": "string" - }, - - "services": { - "id": "#/properties/services", - "type": "object", - "patternProperties": { - "^[a-zA-Z0-9._-]+$": { - "$ref": "#/definitions/service" - } - }, - "additionalProperties": false - }, - - "networks": { - "id": "#/properties/networks", - "type": "object", - "patternProperties": { - "^[a-zA-Z0-9._-]+$": { - "$ref": "#/definitions/network" - } - } - }, - - "volumes": { - "id": "#/properties/volumes", - "type": "object", - "patternProperties": { - "^[a-zA-Z0-9._-]+$": { - "$ref": "#/definitions/volume" - } - }, - "additionalProperties": false - } - }, - - "patternProperties": {"^x-": {}}, - "additionalProperties": false, - - "definitions": { - - "service": { - "id": "#/definitions/service", - "type": "object", - - "properties": { - "blkio_config": { - "type": "object", - "properties": { - "device_read_bps": { - "type": "array", - "items": {"$ref": "#/definitions/blkio_limit"} - }, - "device_read_iops": { - "type": "array", - "items": {"$ref": "#/definitions/blkio_limit"} - }, - "device_write_bps": { - "type": "array", - "items": {"$ref": "#/definitions/blkio_limit"} - }, - "device_write_iops": { - "type": "array", - "items": {"$ref": "#/definitions/blkio_limit"} - }, - "weight": {"type": "integer"}, - "weight_device": { - "type": "array", - "items": {"$ref": "#/definitions/blkio_weight"} - } - }, - "additionalProperties": false - }, - "build": { - "oneOf": [ - {"type": "string"}, - { - "type": "object", - "properties": { - "context": {"type": "string"}, - "dockerfile": {"type": "string"}, - "args": {"$ref": "#/definitions/list_or_dict"} - }, - "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_opt": { - "type": "array", - "items": { - "type": "string" - }, - "uniqueItems": true - }, - "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/labels"}, - "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"]}, - "mem_reservation": {"type": ["string", "integer"]}, - "mem_swappiness": {"type": "integer"}, - "memswap_limit": {"type": ["number", "string"]}, - "network_mode": {"type": "string"}, - - "networks": { - "oneOf": [ - {"$ref": "#/definitions/list_of_strings"}, - { - "type": "object", - "patternProperties": { - "^[a-zA-Z0-9._-]+$": { - "oneOf": [ - { - "type": "object", - "properties": { - "aliases": {"$ref": "#/definitions/list_of_strings"}, - "ipv4_address": {"type": "string"}, - "ipv6_address": {"type": "string"}, - "priority": {"type": "number"} - }, - "additionalProperties": false - }, - {"type": "null"} - ] - } - }, - "additionalProperties": false - } - ] - }, - "oom_score_adj": {"type": "integer", "minimum": -1000, "maximum": 1000}, - "group_add": { - "type": "array", - "items": { - "type": ["string", "number"] - }, - "uniqueItems": true - }, - "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_grace_period": {"type": "string", "format": "duration"}, - "stop_signal": {"type": "string"}, - "tmpfs": {"$ref": "#/definitions/string_or_list"}, - "tty": {"type": "boolean"}, - "ulimits": { - "type": "object", - "patternProperties": { - "^[a-z]+$": { - "oneOf": [ - {"type": "integer"}, - { - "type":"object", - "properties": { - "hard": {"type": "integer"}, - "soft": {"type": "integer"} - }, - "required": ["soft", "hard"], - "additionalProperties": false - } - ] - } - } - }, - "user": {"type": "string"}, - "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", - "items": {"$ref": "#/definitions/ipam_config"} - }, - "options": { - "type": "object", - "patternProperties": { - "^.+$": {"type": "string"} - }, - "additionalProperties": false - } - }, - "additionalProperties": false - }, - "external": { - "type": ["boolean", "object"], - "properties": { - "name": {"type": "string"} - }, - "additionalProperties": false - }, - "internal": {"type": "boolean"} - }, - "additionalProperties": false - }, - - "ipam_config": { - "id": "#/definitions/ipam_config", - "type": "object", - "properties": { - "subnet": {"type": "string"}, - "ip_range": {"type": "string"}, - "gateway": {"type": "string"}, - "aux_addresses": { - "type": "object", - "patternProperties": { - "^.+$": {"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} - ] - }, - - "labels": { - "oneOf": [ - { - "type": "object", - "patternProperties": { - ".+": { - "type": "string" - } - }, - "additionalProperties": false - }, - {"type": "array", "items": {"type": "string"}, "uniqueItems": true} - ] - }, - - "blkio_limit": { - "type": "object", - "properties": { - "path": {"type": "string"}, - "rate": {"type": ["integer", "string"]} - }, - "additionalProperties": false - }, - "blkio_weight": { - "type": "object", - "properties": { - "path": {"type": "string"}, - "weight": {"type": "integer"} - }, - "additionalProperties": false - }, - - "constraints": { - "service": { - "id": "#/definitions/constraints/service", - "anyOf": [ - {"required": ["build"]}, - {"required": ["image"]} - ], - "properties": { - "build": { - "required": ["context"] - } - } - } - } - } -} diff --git a/compose/config/config_schema_v2.1.json b/compose/config/config_schema_v2.1.json deleted file mode 100644 index 3cb1ee21316..00000000000 --- a/compose/config/config_schema_v2.1.json +++ /dev/null @@ -1,480 +0,0 @@ -{ - "$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 - } - }, - - "patternProperties": {"^x-": {}}, - "additionalProperties": false, - - "definitions": { - - "service": { - "id": "#/definitions/service", - "type": "object", - - "properties": { - "blkio_config": { - "type": "object", - "properties": { - "device_read_bps": { - "type": "array", - "items": {"$ref": "#/definitions/blkio_limit"} - }, - "device_read_iops": { - "type": "array", - "items": {"$ref": "#/definitions/blkio_limit"} - }, - "device_write_bps": { - "type": "array", - "items": {"$ref": "#/definitions/blkio_limit"} - }, - "device_write_iops": { - "type": "array", - "items": {"$ref": "#/definitions/blkio_limit"} - }, - "weight": {"type": "integer"}, - "weight_device": { - "type": "array", - "items": {"$ref": "#/definitions/blkio_weight"} - } - }, - "additionalProperties": false - }, - - "build": { - "oneOf": [ - {"type": "string"}, - { - "type": "object", - "properties": { - "context": {"type": "string"}, - "dockerfile": {"type": "string"}, - "args": {"$ref": "#/definitions/list_or_dict"}, - "labels": {"$ref": "#/definitions/labels"}, - "isolation": {"type": "string"} - }, - "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"]}, - "cpu_period": {"type": ["number", "string"]}, - "cpuset": {"type": "string"}, - "depends_on": { - "oneOf": [ - {"$ref": "#/definitions/list_of_strings"}, - { - "type": "object", - "additionalProperties": false, - "patternProperties": { - "^[a-zA-Z0-9._-]+$": { - "type": "object", - "additionalProperties": false, - "properties": { - "condition": { - "type": "string", - "enum": ["service_started", "service_healthy"] - } - }, - "required": ["condition"] - } - } - } - ] - }, - "devices": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, - "dns_opt": { - "type": "array", - "items": { - "type": "string" - }, - "uniqueItems": true - }, - "dns": {"$ref": "#/definitions/string_or_list"}, - "dns_search": {"$ref": "#/definitions/string_or_list"}, - "domainname": {"type": "string"}, - "entrypoint": { - "oneOf": [ - {"type": "string"}, - {"type": "array", "items": {"type": "string"}} - ] - }, - "env_file": {"$ref": "#/definitions/string_or_list"}, - "environment": {"$ref": "#/definitions/list_or_dict"}, - - "expose": { - "type": "array", - "items": { - "type": ["string", "number"], - "format": "expose" - }, - "uniqueItems": true - }, - - "extends": { - "oneOf": [ - { - "type": "string" - }, - { - "type": "object", - - "properties": { - "service": {"type": "string"}, - "file": {"type": "string"} - }, - "required": ["service"], - "additionalProperties": false - } - ] - }, - - "external_links": {"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"}, - "isolation": {"type": "string"}, - "labels": {"$ref": "#/definitions/labels"}, - "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"]}, - "mem_reservation": {"type": ["string", "integer"]}, - "mem_swappiness": {"type": "integer"}, - "memswap_limit": {"type": ["number", "string"]}, - "network_mode": {"type": "string"}, - - "networks": { - "oneOf": [ - {"$ref": "#/definitions/list_of_strings"}, - { - "type": "object", - "patternProperties": { - "^[a-zA-Z0-9._-]+$": { - "oneOf": [ - { - "type": "object", - "properties": { - "aliases": {"$ref": "#/definitions/list_of_strings"}, - "ipv4_address": {"type": "string"}, - "ipv6_address": {"type": "string"}, - "link_local_ips": {"$ref": "#/definitions/list_of_strings"}, - "priority": {"type": "number"} - }, - "additionalProperties": false - }, - {"type": "null"} - ] - } - }, - "additionalProperties": false - } - ] - }, - "oom_kill_disable": {"type": "boolean"}, - "oom_score_adj": {"type": "integer", "minimum": -1000, "maximum": 1000}, - "group_add": { - "type": "array", - "items": { - "type": ["string", "number"] - }, - "uniqueItems": true - }, - "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"]}, - "sysctls": {"$ref": "#/definitions/list_or_dict"}, - "pids_limit": {"type": ["number", "string"]}, - "stdin_open": {"type": "boolean"}, - "stop_grace_period": {"type": "string", "format": "duration"}, - "stop_signal": {"type": "string"}, - "storage_opt": {"type": "object"}, - "tmpfs": {"$ref": "#/definitions/string_or_list"}, - "tty": {"type": "boolean"}, - "ulimits": { - "type": "object", - "patternProperties": { - "^[a-z]+$": { - "oneOf": [ - {"type": "integer"}, - { - "type":"object", - "properties": { - "hard": {"type": "integer"}, - "soft": {"type": "integer"} - }, - "required": ["soft", "hard"], - "additionalProperties": false - } - ] - } - } - }, - "user": {"type": "string"}, - "userns_mode": {"type": "string"}, - "volumes": {"type": "array", "items": {"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 - }, - - "healthcheck": { - "id": "#/definitions/healthcheck", - "type": "object", - "additionalProperties": false, - "properties": { - "disable": {"type": "boolean"}, - "interval": {"type": "string"}, - "retries": {"type": "number"}, - "test": { - "oneOf": [ - {"type": "string"}, - {"type": "array", "items": {"type": "string"}} - ] - }, - "timeout": {"type": "string"} - } - }, - - "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", - "items": {"$ref": "#/definitions/ipam_config"} - }, - "options": { - "type": "object", - "patternProperties": { - "^.+$": {"type": "string"} - }, - "additionalProperties": false - } - }, - "additionalProperties": false - }, - "external": { - "type": ["boolean", "object"], - "properties": { - "name": {"type": "string"} - }, - "additionalProperties": false - }, - "internal": {"type": "boolean"}, - "enable_ipv6": {"type": "boolean"}, - "labels": {"$ref": "#/definitions/labels"}, - "name": {"type": "string"} - }, - "additionalProperties": false - }, - - "ipam_config": { - "id": "#/definitions/ipam_config", - "type": "object", - "properties": { - "subnet": {"type": "string"}, - "ip_range": {"type": "string"}, - "gateway": {"type": "string"}, - "aux_addresses": { - "type": "object", - "patternProperties": { - "^.+$": {"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 - }, - "labels": {"$ref": "#/definitions/labels"}, - "name": {"type": "string"} - }, - "additionalProperties": false - }, - - "string_or_list": { - "oneOf": [ - {"type": "string"}, - {"$ref": "#/definitions/list_of_strings"} - ] - }, - - "list_of_strings": { - "type": "array", - "items": {"type": "string"}, - "uniqueItems": true - }, - - "list_or_dict": { - "oneOf": [ - { - "type": "object", - "patternProperties": { - ".+": { - "type": ["string", "number", "null"] - } - }, - "additionalProperties": false - }, - {"type": "array", "items": {"type": "string"}, "uniqueItems": true} - ] - }, - - "labels": { - "oneOf": [ - { - "type": "object", - "patternProperties": { - ".+": { - "type": "string" - } - }, - "additionalProperties": false - }, - {"type": "array", "items": {"type": "string"}, "uniqueItems": true} - ] - }, - - "blkio_limit": { - "type": "object", - "properties": { - "path": {"type": "string"}, - "rate": {"type": ["integer", "string"]} - }, - "additionalProperties": false - }, - "blkio_weight": { - "type": "object", - "properties": { - "path": {"type": "string"}, - "weight": {"type": "integer"} - }, - "additionalProperties": false - }, - - "constraints": { - "service": { - "id": "#/definitions/constraints/service", - "anyOf": [ - {"required": ["build"]}, - {"required": ["image"]} - ], - "properties": { - "build": { - "required": ["context"] - } - } - } - } - } -} diff --git a/compose/config/config_schema_v2.2.json b/compose/config/config_schema_v2.2.json deleted file mode 100644 index 8e1f288badf..00000000000 --- a/compose/config/config_schema_v2.2.json +++ /dev/null @@ -1,489 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - "id": "config_schema_v2.2.json", - "type": "object", - - "properties": { - "version": { - "type": "string" - }, - - "services": { - "id": "#/properties/services", - "type": "object", - "patternProperties": { - "^[a-zA-Z0-9._-]+$": { - "$ref": "#/definitions/service" - } - }, - "additionalProperties": false - }, - - "networks": { - "id": "#/properties/networks", - "type": "object", - "patternProperties": { - "^[a-zA-Z0-9._-]+$": { - "$ref": "#/definitions/network" - } - } - }, - - "volumes": { - "id": "#/properties/volumes", - "type": "object", - "patternProperties": { - "^[a-zA-Z0-9._-]+$": { - "$ref": "#/definitions/volume" - } - }, - "additionalProperties": false - } - }, - - "patternProperties": {"^x-": {}}, - "additionalProperties": false, - - "definitions": { - - "service": { - "id": "#/definitions/service", - "type": "object", - - "properties": { - "blkio_config": { - "type": "object", - "properties": { - "device_read_bps": { - "type": "array", - "items": {"$ref": "#/definitions/blkio_limit"} - }, - "device_read_iops": { - "type": "array", - "items": {"$ref": "#/definitions/blkio_limit"} - }, - "device_write_bps": { - "type": "array", - "items": {"$ref": "#/definitions/blkio_limit"} - }, - "device_write_iops": { - "type": "array", - "items": {"$ref": "#/definitions/blkio_limit"} - }, - "weight": {"type": "integer"}, - "weight_device": { - "type": "array", - "items": {"$ref": "#/definitions/blkio_weight"} - } - }, - "additionalProperties": false - }, - - "build": { - "oneOf": [ - {"type": "string"}, - { - "type": "object", - "properties": { - "context": {"type": "string"}, - "dockerfile": {"type": "string"}, - "args": {"$ref": "#/definitions/list_or_dict"}, - "labels": {"$ref": "#/definitions/labels"}, - "cache_from": {"$ref": "#/definitions/list_of_strings"}, - "network": {"type": "string"}, - "isolation": {"type": "string"} - }, - "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_count": {"type": "integer", "minimum": 0}, - "cpu_percent": {"type": "integer", "minimum": 0, "maximum": 100}, - "cpu_shares": {"type": ["number", "string"]}, - "cpu_quota": {"type": ["number", "string"]}, - "cpu_period": {"type": ["number", "string"]}, - "cpu_rt_period": {"type": ["number", "string"]}, - "cpu_rt_runtime": {"type": ["number", "string"]}, - "cpus": {"type": "number", "minimum": 0}, - "cpuset": {"type": "string"}, - "depends_on": { - "oneOf": [ - {"$ref": "#/definitions/list_of_strings"}, - { - "type": "object", - "additionalProperties": false, - "patternProperties": { - "^[a-zA-Z0-9._-]+$": { - "type": "object", - "additionalProperties": false, - "properties": { - "condition": { - "type": "string", - "enum": ["service_started", "service_healthy"] - } - }, - "required": ["condition"] - } - } - } - ] - }, - "devices": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, - "dns_opt": { - "type": "array", - "items": { - "type": "string" - }, - "uniqueItems": true - }, - "dns": {"$ref": "#/definitions/string_or_list"}, - "dns_search": {"$ref": "#/definitions/string_or_list"}, - "domainname": {"type": "string"}, - "entrypoint": { - "oneOf": [ - {"type": "string"}, - {"type": "array", "items": {"type": "string"}} - ] - }, - "env_file": {"$ref": "#/definitions/string_or_list"}, - "environment": {"$ref": "#/definitions/list_or_dict"}, - - "expose": { - "type": "array", - "items": { - "type": ["string", "number"], - "format": "expose" - }, - "uniqueItems": true - }, - - "extends": { - "oneOf": [ - { - "type": "string" - }, - { - "type": "object", - - "properties": { - "service": {"type": "string"}, - "file": {"type": "string"} - }, - "required": ["service"], - "additionalProperties": false - } - ] - }, - - "external_links": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, - "extra_hosts": {"$ref": "#/definitions/list_or_dict"}, - "healthcheck": {"$ref": "#/definitions/healthcheck"}, - "hostname": {"type": "string"}, - "image": {"type": "string"}, - "init": {"type": ["boolean", "string"]}, - "ipc": {"type": "string"}, - "isolation": {"type": "string"}, - "labels": {"$ref": "#/definitions/labels"}, - "links": {"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"]}, - "mem_reservation": {"type": ["string", "integer"]}, - "mem_swappiness": {"type": "integer"}, - "memswap_limit": {"type": ["number", "string"]}, - "network_mode": {"type": "string"}, - - "networks": { - "oneOf": [ - {"$ref": "#/definitions/list_of_strings"}, - { - "type": "object", - "patternProperties": { - "^[a-zA-Z0-9._-]+$": { - "oneOf": [ - { - "type": "object", - "properties": { - "aliases": {"$ref": "#/definitions/list_of_strings"}, - "ipv4_address": {"type": "string"}, - "ipv6_address": {"type": "string"}, - "link_local_ips": {"$ref": "#/definitions/list_of_strings"}, - "priority": {"type": "number"} - }, - "additionalProperties": false - }, - {"type": "null"} - ] - } - }, - "additionalProperties": false - } - ] - }, - "oom_kill_disable": {"type": "boolean"}, - "oom_score_adj": {"type": "integer", "minimum": -1000, "maximum": 1000}, - "group_add": { - "type": "array", - "items": { - "type": ["string", "number"] - }, - "uniqueItems": true - }, - "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"}, - "scale": {"type": "integer"}, - "security_opt": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, - "shm_size": {"type": ["number", "string"]}, - "sysctls": {"$ref": "#/definitions/list_or_dict"}, - "pids_limit": {"type": ["number", "string"]}, - "stdin_open": {"type": "boolean"}, - "stop_grace_period": {"type": "string", "format": "duration"}, - "stop_signal": {"type": "string"}, - "storage_opt": {"type": "object"}, - "tmpfs": {"$ref": "#/definitions/string_or_list"}, - "tty": {"type": "boolean"}, - "ulimits": { - "type": "object", - "patternProperties": { - "^[a-z]+$": { - "oneOf": [ - {"type": "integer"}, - { - "type":"object", - "properties": { - "hard": {"type": "integer"}, - "soft": {"type": "integer"} - }, - "required": ["soft", "hard"], - "additionalProperties": false - } - ] - } - } - }, - "user": {"type": "string"}, - "userns_mode": {"type": "string"}, - "volumes": {"type": "array", "items": {"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 - }, - - "healthcheck": { - "id": "#/definitions/healthcheck", - "type": "object", - "additionalProperties": false, - "properties": { - "disable": {"type": "boolean"}, - "interval": {"type": "string"}, - "retries": {"type": "number"}, - "test": { - "oneOf": [ - {"type": "string"}, - {"type": "array", "items": {"type": "string"}} - ] - }, - "timeout": {"type": "string"} - } - }, - - "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", - "items": {"$ref": "#/definitions/ipam_config"} - }, - "options": { - "type": "object", - "patternProperties": { - "^.+$": {"type": "string"} - }, - "additionalProperties": false - } - }, - "additionalProperties": false - }, - "external": { - "type": ["boolean", "object"], - "properties": { - "name": {"type": "string"} - }, - "additionalProperties": false - }, - "internal": {"type": "boolean"}, - "enable_ipv6": {"type": "boolean"}, - "labels": {"$ref": "#/definitions/labels"}, - "name": {"type": "string"} - }, - "additionalProperties": false - }, - - "ipam_config": { - "id": "#/definitions/ipam_config", - "type": "object", - "properties": { - "subnet": {"type": "string"}, - "ip_range": {"type": "string"}, - "gateway": {"type": "string"}, - "aux_addresses": { - "type": "object", - "patternProperties": { - "^.+$": {"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 - }, - "labels": {"$ref": "#/definitions/labels"}, - "name": {"type": "string"} - }, - "additionalProperties": false - }, - - "string_or_list": { - "oneOf": [ - {"type": "string"}, - {"$ref": "#/definitions/list_of_strings"} - ] - }, - - "list_of_strings": { - "type": "array", - "items": {"type": "string"}, - "uniqueItems": true - }, - - "list_or_dict": { - "oneOf": [ - { - "type": "object", - "patternProperties": { - ".+": { - "type": ["string", "number", "null"] - } - }, - "additionalProperties": false - }, - {"type": "array", "items": {"type": "string"}, "uniqueItems": true} - ] - }, - - "labels": { - "oneOf": [ - { - "type": "object", - "patternProperties": { - ".+": { - "type": "string" - } - }, - "additionalProperties": false - }, - {"type": "array", "items": {"type": "string"}, "uniqueItems": true} - ] - }, - - "blkio_limit": { - "type": "object", - "properties": { - "path": {"type": "string"}, - "rate": {"type": ["integer", "string"]} - }, - "additionalProperties": false - }, - "blkio_weight": { - "type": "object", - "properties": { - "path": {"type": "string"}, - "weight": {"type": "integer"} - }, - "additionalProperties": false - }, - - "constraints": { - "service": { - "id": "#/definitions/constraints/service", - "anyOf": [ - {"required": ["build"]}, - {"required": ["image"]} - ], - "properties": { - "build": { - "required": ["context"] - } - } - } - } - } -} diff --git a/compose/config/config_schema_v2.3.json b/compose/config/config_schema_v2.3.json deleted file mode 100644 index 659dbcd1a49..00000000000 --- a/compose/config/config_schema_v2.3.json +++ /dev/null @@ -1,533 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - "id": "config_schema_v2.3.json", - "type": "object", - - "properties": { - "version": { - "type": "string" - }, - - "services": { - "id": "#/properties/services", - "type": "object", - "patternProperties": { - "^[a-zA-Z0-9._-]+$": { - "$ref": "#/definitions/service" - } - }, - "additionalProperties": false - }, - - "networks": { - "id": "#/properties/networks", - "type": "object", - "patternProperties": { - "^[a-zA-Z0-9._-]+$": { - "$ref": "#/definitions/network" - } - } - }, - - "volumes": { - "id": "#/properties/volumes", - "type": "object", - "patternProperties": { - "^[a-zA-Z0-9._-]+$": { - "$ref": "#/definitions/volume" - } - }, - "additionalProperties": false - } - }, - - "patternProperties": {"^x-": {}}, - "additionalProperties": false, - - "definitions": { - - "service": { - "id": "#/definitions/service", - "type": "object", - - "properties": { - "blkio_config": { - "type": "object", - "properties": { - "device_read_bps": { - "type": "array", - "items": {"$ref": "#/definitions/blkio_limit"} - }, - "device_read_iops": { - "type": "array", - "items": {"$ref": "#/definitions/blkio_limit"} - }, - "device_write_bps": { - "type": "array", - "items": {"$ref": "#/definitions/blkio_limit"} - }, - "device_write_iops": { - "type": "array", - "items": {"$ref": "#/definitions/blkio_limit"} - }, - "weight": {"type": "integer"}, - "weight_device": { - "type": "array", - "items": {"$ref": "#/definitions/blkio_weight"} - } - }, - "additionalProperties": false - }, - - "build": { - "oneOf": [ - {"type": "string"}, - { - "type": "object", - "properties": { - "context": {"type": "string"}, - "dockerfile": {"type": "string"}, - "args": {"$ref": "#/definitions/list_or_dict"}, - "labels": {"$ref": "#/definitions/labels"}, - "cache_from": {"$ref": "#/definitions/list_of_strings"}, - "network": {"type": "string"}, - "target": {"type": "string"}, - "shm_size": {"type": ["integer", "string"]}, - "extra_hosts": {"$ref": "#/definitions/list_or_dict"}, - "isolation": {"type": "string"} - }, - "additionalProperties": false - } - ] - }, - "cap_add": {"$ref": "#/definitions/list_of_strings"}, - "cap_drop": {"$ref": "#/definitions/list_of_strings"}, - "cgroup_parent": {"type": "string"}, - "command": { - "oneOf": [ - {"type": "string"}, - {"type": "array", "items": {"type": "string"}} - ] - }, - "container_name": {"type": "string"}, - "cpu_count": {"type": "integer", "minimum": 0}, - "cpu_percent": {"type": "integer", "minimum": 0, "maximum": 100}, - "cpu_shares": {"type": ["number", "string"]}, - "cpu_quota": {"type": ["number", "string"]}, - "cpu_period": {"type": ["number", "string"]}, - "cpu_rt_period": {"type": ["number", "string"]}, - "cpu_rt_runtime": {"type": ["number", "string"]}, - "cpus": {"type": "number", "minimum": 0}, - "cpuset": {"type": "string"}, - "depends_on": { - "oneOf": [ - {"$ref": "#/definitions/list_of_strings"}, - { - "type": "object", - "additionalProperties": false, - "patternProperties": { - "^[a-zA-Z0-9._-]+$": { - "type": "object", - "additionalProperties": false, - "properties": { - "condition": { - "type": "string", - "enum": ["service_started", "service_healthy"] - } - }, - "required": ["condition"] - } - } - } - ] - }, - "device_cgroup_rules": {"$ref": "#/definitions/list_of_strings"}, - "devices": {"$ref": "#/definitions/list_of_strings"}, - "dns_opt": { - "type": "array", - "items": { - "type": "string" - }, - "uniqueItems": true - }, - "dns": {"$ref": "#/definitions/string_or_list"}, - "dns_search": {"$ref": "#/definitions/string_or_list"}, - "domainname": {"type": "string"}, - "entrypoint": { - "oneOf": [ - {"type": "string"}, - {"type": "array", "items": {"type": "string"}} - ] - }, - "env_file": {"$ref": "#/definitions/string_or_list"}, - "environment": {"$ref": "#/definitions/list_or_dict"}, - - "expose": { - "type": "array", - "items": { - "type": ["string", "number"], - "format": "expose" - }, - "uniqueItems": true - }, - - "extends": { - "oneOf": [ - { - "type": "string" - }, - { - "type": "object", - - "properties": { - "service": {"type": "string"}, - "file": {"type": "string"} - }, - "required": ["service"], - "additionalProperties": false - } - ] - }, - - "external_links": {"$ref": "#/definitions/list_of_strings"}, - "extra_hosts": {"$ref": "#/definitions/list_or_dict"}, - "healthcheck": {"$ref": "#/definitions/healthcheck"}, - "hostname": {"type": "string"}, - "image": {"type": "string"}, - "init": {"type": ["boolean", "string"]}, - "ipc": {"type": "string"}, - "isolation": {"type": "string"}, - "labels": {"$ref": "#/definitions/labels"}, - "links": {"$ref": "#/definitions/list_of_strings"}, - - "logging": { - "type": "object", - - "properties": { - "driver": {"type": "string"}, - "options": {"type": "object"} - }, - "additionalProperties": false - }, - - "mac_address": {"type": "string"}, - "mem_limit": {"type": ["number", "string"]}, - "mem_reservation": {"type": ["string", "integer"]}, - "mem_swappiness": {"type": "integer"}, - "memswap_limit": {"type": ["number", "string"]}, - "network_mode": {"type": "string"}, - - "networks": { - "oneOf": [ - {"$ref": "#/definitions/list_of_strings"}, - { - "type": "object", - "patternProperties": { - "^[a-zA-Z0-9._-]+$": { - "oneOf": [ - { - "type": "object", - "properties": { - "aliases": {"$ref": "#/definitions/list_of_strings"}, - "ipv4_address": {"type": "string"}, - "ipv6_address": {"type": "string"}, - "link_local_ips": {"$ref": "#/definitions/list_of_strings"}, - "priority": {"type": "number"} - }, - "additionalProperties": false - }, - {"type": "null"} - ] - } - }, - "additionalProperties": false - } - ] - }, - "oom_kill_disable": {"type": "boolean"}, - "oom_score_adj": {"type": "integer", "minimum": -1000, "maximum": 1000}, - "group_add": { - "type": "array", - "items": { - "type": ["string", "number"] - }, - "uniqueItems": true - }, - "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"}, - "runtime": {"type": "string"}, - "scale": {"type": "integer"}, - "security_opt": {"$ref": "#/definitions/list_of_strings"}, - "shm_size": {"type": ["number", "string"]}, - "sysctls": {"$ref": "#/definitions/list_or_dict"}, - "pids_limit": {"type": ["number", "string"]}, - "stdin_open": {"type": "boolean"}, - "stop_grace_period": {"type": "string", "format": "duration"}, - "stop_signal": {"type": "string"}, - "storage_opt": {"type": "object"}, - "tmpfs": {"$ref": "#/definitions/string_or_list"}, - "tty": {"type": "boolean"}, - "ulimits": { - "type": "object", - "patternProperties": { - "^[a-z]+$": { - "oneOf": [ - {"type": "integer"}, - { - "type":"object", - "properties": { - "hard": {"type": "integer"}, - "soft": {"type": "integer"} - }, - "required": ["soft", "hard"], - "additionalProperties": false - } - ] - } - } - }, - "user": {"type": "string"}, - "userns_mode": {"type": "string"}, - "volumes": { - "type": "array", - "items": { - "oneOf": [ - {"type": "string"}, - { - "type": "object", - "required": ["type"], - "additionalProperties": false, - "properties": { - "type": {"type": "string"}, - "source": {"type": "string"}, - "target": {"type": "string"}, - "read_only": {"type": "boolean"}, - "consistency": {"type": "string"}, - "bind": { - "type": "object", - "properties": { - "propagation": {"type": "string"} - } - }, - "volume": { - "type": "object", - "properties": { - "nocopy": {"type": "boolean"} - } - }, - "tmpfs": { - "type": "object", - "properties": { - "size": {"type": ["integer", "string"]} - } - } - } - } - ], - "uniqueItems": true - } - }, - "volume_driver": {"type": "string"}, - "volumes_from": {"$ref": "#/definitions/list_of_strings"}, - "working_dir": {"type": "string"} - }, - - "dependencies": { - "memswap_limit": ["mem_limit"] - }, - "additionalProperties": false - }, - - "healthcheck": { - "id": "#/definitions/healthcheck", - "type": "object", - "additionalProperties": false, - "properties": { - "disable": {"type": "boolean"}, - "interval": {"type": "string"}, - "retries": {"type": "number"}, - "start_period": {"type": "string"}, - "test": { - "oneOf": [ - {"type": "string"}, - {"type": "array", "items": {"type": "string"}} - ] - }, - "timeout": {"type": "string"} - } - }, - - "network": { - "id": "#/definitions/network", - "type": "object", - "properties": { - "driver": {"type": "string"}, - "driver_opts": { - "type": "object", - "patternProperties": { - "^.+$": {"type": ["string", "number"]} - } - }, - "ipam": { - "type": "object", - "properties": { - "driver": {"type": "string"}, - "config": { - "type": "array", - "items": {"$ref": "#/definitions/ipam_config"} - }, - "options": { - "type": "object", - "patternProperties": { - "^.+$": {"type": "string"} - }, - "additionalProperties": false - } - }, - "additionalProperties": false - }, - "external": { - "type": ["boolean", "object"], - "properties": { - "name": {"type": "string"} - }, - "additionalProperties": false - }, - "internal": {"type": "boolean"}, - "enable_ipv6": {"type": "boolean"}, - "labels": {"$ref": "#/definitions/labels"}, - "name": {"type": "string"} - }, - "additionalProperties": false - }, - - "ipam_config": { - "id": "#/definitions/ipam_config", - "type": "object", - "properties": { - "subnet": {"type": "string"}, - "ip_range": {"type": "string"}, - "gateway": {"type": "string"}, - "aux_addresses": { - "type": "object", - "patternProperties": { - "^.+$": {"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 - }, - "labels": {"$ref": "#/definitions/labels"}, - "name": {"type": "string"} - }, - "additionalProperties": false - }, - - "string_or_list": { - "oneOf": [ - {"type": "string"}, - {"$ref": "#/definitions/list_of_strings"} - ] - }, - - "list_of_strings": { - "type": "array", - "items": {"type": "string"}, - "uniqueItems": true - }, - - "list_or_dict": { - "oneOf": [ - { - "type": "object", - "patternProperties": { - ".+": { - "type": ["string", "number", "null"] - } - }, - "additionalProperties": false - }, - {"type": "array", "items": {"type": "string"}, "uniqueItems": true} - ] - }, - - "labels": { - "oneOf": [ - { - "type": "object", - "patternProperties": { - ".+": { - "type": "string" - } - }, - "additionalProperties": false - }, - {"type": "array", "items": {"type": "string"}, "uniqueItems": true} - ] - }, - - "blkio_limit": { - "type": "object", - "properties": { - "path": {"type": "string"}, - "rate": {"type": ["integer", "string"]} - }, - "additionalProperties": false - }, - "blkio_weight": { - "type": "object", - "properties": { - "path": {"type": "string"}, - "weight": {"type": "integer"} - }, - "additionalProperties": false - }, - - "constraints": { - "service": { - "id": "#/definitions/constraints/service", - "anyOf": [ - {"required": ["build"]}, - {"required": ["image"]} - ], - "properties": { - "build": { - "required": ["context"] - } - } - } - } - } -} diff --git a/compose/config/config_schema_v2.4.json b/compose/config/config_schema_v2.4.json deleted file mode 100644 index 4e641788718..00000000000 --- a/compose/config/config_schema_v2.4.json +++ /dev/null @@ -1,535 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - "id": "config_schema_v2.4.json", - "type": "object", - - "properties": { - "version": { - "type": "string" - }, - - "services": { - "id": "#/properties/services", - "type": "object", - "patternProperties": { - "^[a-zA-Z0-9._-]+$": { - "$ref": "#/definitions/service" - } - }, - "additionalProperties": false - }, - - "networks": { - "id": "#/properties/networks", - "type": "object", - "patternProperties": { - "^[a-zA-Z0-9._-]+$": { - "$ref": "#/definitions/network" - } - } - }, - - "volumes": { - "id": "#/properties/volumes", - "type": "object", - "patternProperties": { - "^[a-zA-Z0-9._-]+$": { - "$ref": "#/definitions/volume" - } - }, - "additionalProperties": false - } - }, - - "patternProperties": {"^x-": {}}, - "additionalProperties": false, - - "definitions": { - - "service": { - "id": "#/definitions/service", - "type": "object", - - "properties": { - "blkio_config": { - "type": "object", - "properties": { - "device_read_bps": { - "type": "array", - "items": {"$ref": "#/definitions/blkio_limit"} - }, - "device_read_iops": { - "type": "array", - "items": {"$ref": "#/definitions/blkio_limit"} - }, - "device_write_bps": { - "type": "array", - "items": {"$ref": "#/definitions/blkio_limit"} - }, - "device_write_iops": { - "type": "array", - "items": {"$ref": "#/definitions/blkio_limit"} - }, - "weight": {"type": "integer"}, - "weight_device": { - "type": "array", - "items": {"$ref": "#/definitions/blkio_weight"} - } - }, - "additionalProperties": false - }, - - "build": { - "oneOf": [ - {"type": "string"}, - { - "type": "object", - "properties": { - "context": {"type": "string"}, - "dockerfile": {"type": "string"}, - "args": {"$ref": "#/definitions/list_or_dict"}, - "labels": {"$ref": "#/definitions/labels"}, - "cache_from": {"$ref": "#/definitions/list_of_strings"}, - "network": {"type": "string"}, - "target": {"type": "string"}, - "shm_size": {"type": ["integer", "string"]}, - "extra_hosts": {"$ref": "#/definitions/list_or_dict"}, - "isolation": {"type": "string"} - }, - "additionalProperties": false - } - ] - }, - "cap_add": {"$ref": "#/definitions/list_of_strings"}, - "cap_drop": {"$ref": "#/definitions/list_of_strings"}, - "cgroup_parent": {"type": "string"}, - "command": { - "oneOf": [ - {"type": "string"}, - {"type": "array", "items": {"type": "string"}} - ] - }, - "container_name": {"type": "string"}, - "cpu_count": {"type": "integer", "minimum": 0}, - "cpu_percent": {"type": "integer", "minimum": 0, "maximum": 100}, - "cpu_shares": {"type": ["number", "string"]}, - "cpu_quota": {"type": ["number", "string"]}, - "cpu_period": {"type": ["number", "string"]}, - "cpu_rt_period": {"type": ["number", "string"]}, - "cpu_rt_runtime": {"type": ["number", "string"]}, - "cpus": {"type": "number", "minimum": 0}, - "cpuset": {"type": "string"}, - "depends_on": { - "oneOf": [ - {"$ref": "#/definitions/list_of_strings"}, - { - "type": "object", - "additionalProperties": false, - "patternProperties": { - "^[a-zA-Z0-9._-]+$": { - "type": "object", - "additionalProperties": false, - "properties": { - "condition": { - "type": "string", - "enum": ["service_started", "service_healthy"] - } - }, - "required": ["condition"] - } - } - } - ] - }, - "device_cgroup_rules": {"$ref": "#/definitions/list_of_strings"}, - "devices": {"$ref": "#/definitions/list_of_strings"}, - "dns_opt": { - "type": "array", - "items": { - "type": "string" - }, - "uniqueItems": true - }, - "dns": {"$ref": "#/definitions/string_or_list"}, - "dns_search": {"$ref": "#/definitions/string_or_list"}, - "domainname": {"type": "string"}, - "entrypoint": { - "oneOf": [ - {"type": "string"}, - {"type": "array", "items": {"type": "string"}} - ] - }, - "env_file": {"$ref": "#/definitions/string_or_list"}, - "environment": {"$ref": "#/definitions/list_or_dict"}, - - "expose": { - "type": "array", - "items": { - "type": ["string", "number"], - "format": "expose" - }, - "uniqueItems": true - }, - - "extends": { - "oneOf": [ - { - "type": "string" - }, - { - "type": "object", - - "properties": { - "service": {"type": "string"}, - "file": {"type": "string"} - }, - "required": ["service"], - "additionalProperties": false - } - ] - }, - - "external_links": {"$ref": "#/definitions/list_of_strings"}, - "extra_hosts": {"$ref": "#/definitions/list_or_dict"}, - "group_add": { - "type": "array", - "items": { - "type": ["string", "number"] - }, - "uniqueItems": true - }, - "healthcheck": {"$ref": "#/definitions/healthcheck"}, - "hostname": {"type": "string"}, - "image": {"type": "string"}, - "init": {"type": ["boolean", "string"]}, - "ipc": {"type": "string"}, - "isolation": {"type": "string"}, - "labels": {"$ref": "#/definitions/labels"}, - "links": {"$ref": "#/definitions/list_of_strings"}, - - "logging": { - "type": "object", - - "properties": { - "driver": {"type": "string"}, - "options": {"type": "object"} - }, - "additionalProperties": false - }, - - "mac_address": {"type": "string"}, - "mem_limit": {"type": ["number", "string"]}, - "mem_reservation": {"type": ["string", "integer"]}, - "mem_swappiness": {"type": "integer"}, - "memswap_limit": {"type": ["number", "string"]}, - "network_mode": {"type": "string"}, - - "networks": { - "oneOf": [ - {"$ref": "#/definitions/list_of_strings"}, - { - "type": "object", - "patternProperties": { - "^[a-zA-Z0-9._-]+$": { - "oneOf": [ - { - "type": "object", - "properties": { - "aliases": {"$ref": "#/definitions/list_of_strings"}, - "ipv4_address": {"type": "string"}, - "ipv6_address": {"type": "string"}, - "link_local_ips": {"$ref": "#/definitions/list_of_strings"}, - "priority": {"type": "number"} - }, - "additionalProperties": false - }, - {"type": "null"} - ] - } - }, - "additionalProperties": false - } - ] - }, - "oom_kill_disable": {"type": "boolean"}, - "oom_score_adj": {"type": "integer", "minimum": -1000, "maximum": 1000}, - "pid": {"type": ["string", "null"]}, - "platform": {"type": "string"}, - "ports": { - "type": "array", - "items": { - "type": ["string", "number"], - "format": "ports" - }, - "uniqueItems": true - }, - "privileged": {"type": "boolean"}, - "read_only": {"type": "boolean"}, - "restart": {"type": "string"}, - "runtime": {"type": "string"}, - "scale": {"type": "integer"}, - "security_opt": {"$ref": "#/definitions/list_of_strings"}, - "shm_size": {"type": ["number", "string"]}, - "sysctls": {"$ref": "#/definitions/list_or_dict"}, - "pids_limit": {"type": ["number", "string"]}, - "stdin_open": {"type": "boolean"}, - "stop_grace_period": {"type": "string", "format": "duration"}, - "stop_signal": {"type": "string"}, - "storage_opt": {"type": "object"}, - "tmpfs": {"$ref": "#/definitions/string_or_list"}, - "tty": {"type": "boolean"}, - "ulimits": { - "type": "object", - "patternProperties": { - "^[a-z]+$": { - "oneOf": [ - {"type": "integer"}, - { - "type":"object", - "properties": { - "hard": {"type": "integer"}, - "soft": {"type": "integer"} - }, - "required": ["soft", "hard"], - "additionalProperties": false - } - ] - } - } - }, - "user": {"type": "string"}, - "userns_mode": {"type": "string"}, - "volumes": { - "type": "array", - "items": { - "oneOf": [ - {"type": "string"}, - { - "type": "object", - "required": ["type"], - "additionalProperties": false, - "properties": { - "type": {"type": "string"}, - "source": {"type": "string"}, - "target": {"type": "string"}, - "read_only": {"type": "boolean"}, - "consistency": {"type": "string"}, - "bind": { - "type": "object", - "properties": { - "propagation": {"type": "string"} - } - }, - "volume": { - "type": "object", - "properties": { - "nocopy": {"type": "boolean"} - } - }, - "tmpfs": { - "type": "object", - "properties": { - "size": {"type": ["integer", "string"]} - } - } - } - } - ], - "uniqueItems": true - } - }, - "volume_driver": {"type": "string"}, - "volumes_from": {"$ref": "#/definitions/list_of_strings"}, - "working_dir": {"type": "string"} - }, - - "dependencies": { - "memswap_limit": ["mem_limit"] - }, - "patternProperties": {"^x-": {}}, - "additionalProperties": false - }, - - "healthcheck": { - "id": "#/definitions/healthcheck", - "type": "object", - "additionalProperties": false, - "properties": { - "disable": {"type": "boolean"}, - "interval": {"type": "string"}, - "retries": {"type": "number"}, - "start_period": {"type": "string"}, - "test": { - "oneOf": [ - {"type": "string"}, - {"type": "array", "items": {"type": "string"}} - ] - }, - "timeout": {"type": "string"} - } - }, - - "network": { - "id": "#/definitions/network", - "type": "object", - "properties": { - "driver": {"type": "string"}, - "driver_opts": { - "type": "object", - "patternProperties": { - "^.+$": {"type": ["string", "number"]} - } - }, - "ipam": { - "type": "object", - "properties": { - "driver": {"type": "string"}, - "config": { - "type": "array", - "items": {"$ref": "#/definitions/ipam_config"} - }, - "options": { - "type": "object", - "patternProperties": { - "^.+$": {"type": "string"} - }, - "additionalProperties": false - } - }, - "additionalProperties": false - }, - "external": { - "type": ["boolean", "object"], - "properties": { - "name": {"type": "string"} - }, - "additionalProperties": false - }, - "internal": {"type": "boolean"}, - "enable_ipv6": {"type": "boolean"}, - "labels": {"$ref": "#/definitions/labels"}, - "name": {"type": "string"} - }, - "patternProperties": {"^x-": {}}, - "additionalProperties": false - }, - - "ipam_config": { - "id": "#/definitions/ipam_config", - "type": "object", - "properties": { - "subnet": {"type": "string"}, - "ip_range": {"type": "string"}, - "gateway": {"type": "string"}, - "aux_addresses": { - "type": "object", - "patternProperties": { - "^.+$": {"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 - }, - "labels": {"$ref": "#/definitions/labels"}, - "name": {"type": "string"} - }, - "patternProperties": {"^x-": {}}, - "additionalProperties": false - }, - - "string_or_list": { - "oneOf": [ - {"type": "string"}, - {"$ref": "#/definitions/list_of_strings"} - ] - }, - - "list_of_strings": { - "type": "array", - "items": {"type": "string"}, - "uniqueItems": true - }, - - "list_or_dict": { - "oneOf": [ - { - "type": "object", - "patternProperties": { - ".+": { - "type": ["string", "number", "null"] - } - }, - "additionalProperties": false - }, - {"type": "array", "items": {"type": "string"}, "uniqueItems": true} - ] - }, - - "labels": { - "oneOf": [ - { - "type": "object", - "patternProperties": { - ".+": { - "type": "string" - } - }, - "additionalProperties": false - }, - {"type": "array", "items": {"type": "string"}, "uniqueItems": true} - ] - }, - - "blkio_limit": { - "type": "object", - "properties": { - "path": {"type": "string"}, - "rate": {"type": ["integer", "string"]} - }, - "additionalProperties": false - }, - "blkio_weight": { - "type": "object", - "properties": { - "path": {"type": "string"}, - "weight": {"type": "integer"} - }, - "additionalProperties": false - }, - - "constraints": { - "service": { - "id": "#/definitions/constraints/service", - "anyOf": [ - {"required": ["build"]}, - {"required": ["image"]} - ], - "properties": { - "build": { - "required": ["context"] - } - } - } - } - } -} diff --git a/compose/config/config_schema_v3.0.json b/compose/config/config_schema_v3.0.json deleted file mode 100644 index 10c3635215b..00000000000 --- a/compose/config/config_schema_v3.0.json +++ /dev/null @@ -1,399 +0,0 @@ -{ - "$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/labels"}, - "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"]}, - "sysctls": {"$ref": "#/definitions/list_or_dict"}, - "stdin_open": {"type": "boolean"}, - "stop_grace_period": {"type": "string", "format": "duration"}, - "stop_signal": {"type": "string"}, - "tmpfs": {"$ref": "#/definitions/string_or_list"}, - "tty": {"type": "boolean"}, - "ulimits": { - "type": "object", - "patternProperties": { - "^[a-z]+$": { - "oneOf": [ - {"type": "integer"}, - { - "type":"object", - "properties": { - "hard": {"type": "integer"}, - "soft": {"type": "integer"} - }, - "required": ["soft", "hard"], - "additionalProperties": false - } - ] - } - } - }, - "user": {"type": "string"}, - "userns_mode": {"type": "string"}, - "volumes": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, - "working_dir": {"type": "string"} - }, - "additionalProperties": false - }, - - "healthcheck": { - "id": "#/definitions/healthcheck", - "type": "object", - "additionalProperties": false, - "properties": { - "disable": {"type": "boolean"}, - "interval": {"type": "string"}, - "retries": {"type": "number"}, - "test": { - "oneOf": [ - {"type": "string"}, - {"type": "array", "items": {"type": "string"}} - ] - }, - "timeout": {"type": "string"} - } - }, - "deployment": { - "id": "#/definitions/deployment", - "type": ["object", "null"], - "properties": { - "mode": {"type": "string"}, - "replicas": {"type": "integer"}, - "labels": {"$ref": "#/definitions/labels"}, - "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"} - }, - "additionalProperties": false - }, - "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", "format": "subnet_ip_address"} - }, - "additionalProperties": false - } - } - }, - "additionalProperties": false - }, - "external": { - "type": ["boolean", "object"], - "properties": { - "name": {"type": "string"} - }, - "additionalProperties": false - }, - "internal": {"type": "boolean"}, - "labels": {"$ref": "#/definitions/labels"} - }, - "additionalProperties": false - }, - - "volume": { - "id": "#/definitions/volume", - "type": ["object", "null"], - "properties": { - "driver": {"type": "string"}, - "driver_opts": { - "type": "object", - "patternProperties": { - "^.+$": {"type": ["string", "number"]} - } - }, - "external": { - "type": ["boolean", "object"], - "properties": { - "name": {"type": "string"} - }, - "additionalProperties": false - }, - "labels": {"$ref": "#/definitions/labels"} - }, - "additionalProperties": false - }, - - "string_or_list": { - "oneOf": [ - {"type": "string"}, - {"$ref": "#/definitions/list_of_strings"} - ] - }, - - "list_of_strings": { - "type": "array", - "items": {"type": "string"}, - "uniqueItems": true - }, - - "list_or_dict": { - "oneOf": [ - { - "type": "object", - "patternProperties": { - ".+": { - "type": ["string", "number", "null"] - } - }, - "additionalProperties": false - }, - {"type": "array", "items": {"type": "string"}, "uniqueItems": true} - ] - }, - - "labels": { - "oneOf": [ - { - "type": "object", - "patternProperties": { - ".+": { - "type": "string" - } - }, - "additionalProperties": false - }, - {"type": "array", "items": {"type": "string"}, "uniqueItems": true} - ] - }, - - "constraints": { - "service": { - "id": "#/definitions/constraints/service", - "anyOf": [ - {"required": ["build"]}, - {"required": ["image"]} - ], - "properties": { - "build": { - "required": ["context"] - } - } - } - } - } -} diff --git a/compose/config/config_schema_v3.1.json b/compose/config/config_schema_v3.1.json deleted file mode 100644 index 8630ec3174c..00000000000 --- a/compose/config/config_schema_v3.1.json +++ /dev/null @@ -1,444 +0,0 @@ -{ - "$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/secret" - } - }, - "additionalProperties": false - } - }, - - "additionalProperties": false, - - "definitions": { - - "service": { - "id": "#/definitions/service", - "type": "object", - - "properties": { - "deploy": {"$ref": "#/definitions/deployment"}, - "build": { - "oneOf": [ - {"type": "string"}, - { - "type": "object", - "properties": { - "context": {"type": "string"}, - "dockerfile": {"type": "string"}, - "args": {"$ref": "#/definitions/list_or_dict"} - }, - "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/labels"}, - "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": "string"}, - "gid": {"type": "string"}, - "mode": {"type": "number"} - } - } - ] - } - }, - "sysctls": {"$ref": "#/definitions/list_or_dict"}, - "stdin_open": {"type": "boolean"}, - "stop_grace_period": {"type": "string", "format": "duration"}, - "stop_signal": {"type": "string"}, - "tmpfs": {"$ref": "#/definitions/string_or_list"}, - "tty": {"type": "boolean"}, - "ulimits": { - "type": "object", - "patternProperties": { - "^[a-z]+$": { - "oneOf": [ - {"type": "integer"}, - { - "type":"object", - "properties": { - "hard": {"type": "integer"}, - "soft": {"type": "integer"} - }, - "required": ["soft", "hard"], - "additionalProperties": false - } - ] - } - } - }, - "user": {"type": "string"}, - "userns_mode": {"type": "string"}, - "volumes": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, - "working_dir": {"type": "string"} - }, - "additionalProperties": false - }, - - "healthcheck": { - "id": "#/definitions/healthcheck", - "type": "object", - "additionalProperties": false, - "properties": { - "disable": {"type": "boolean"}, - "interval": {"type": "string"}, - "retries": {"type": "number"}, - "test": { - "oneOf": [ - {"type": "string"}, - {"type": "array", "items": {"type": "string"}} - ] - }, - "timeout": {"type": "string"} - } - }, - "deployment": { - "id": "#/definitions/deployment", - "type": ["object", "null"], - "properties": { - "mode": {"type": "string"}, - "replicas": {"type": "integer"}, - "labels": {"$ref": "#/definitions/labels"}, - "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"} - }, - "additionalProperties": false - }, - "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", "format": "subnet_ip_address"} - }, - "additionalProperties": false - } - } - }, - "additionalProperties": false - }, - "external": { - "type": ["boolean", "object"], - "properties": { - "name": {"type": "string"} - }, - "additionalProperties": false - }, - "internal": {"type": "boolean"}, - "labels": {"$ref": "#/definitions/labels"} - }, - "additionalProperties": false - }, - - "volume": { - "id": "#/definitions/volume", - "type": ["object", "null"], - "properties": { - "driver": {"type": "string"}, - "driver_opts": { - "type": "object", - "patternProperties": { - "^.+$": {"type": ["string", "number"]} - } - }, - "external": { - "type": ["boolean", "object"], - "properties": { - "name": {"type": "string"} - }, - "additionalProperties": false - }, - "labels": {"$ref": "#/definitions/labels"} - }, - "additionalProperties": false - }, - - "secret": { - "id": "#/definitions/secret", - "type": "object", - "properties": { - "file": {"type": "string"}, - "external": { - "type": ["boolean", "object"], - "properties": { - "name": {"type": "string"} - } - }, - "labels": {"$ref": "#/definitions/labels"} - }, - "additionalProperties": false - }, - - "string_or_list": { - "oneOf": [ - {"type": "string"}, - {"$ref": "#/definitions/list_of_strings"} - ] - }, - - "list_of_strings": { - "type": "array", - "items": {"type": "string"}, - "uniqueItems": true - }, - - "list_or_dict": { - "oneOf": [ - { - "type": "object", - "patternProperties": { - ".+": { - "type": ["string", "number", "null"] - } - }, - "additionalProperties": false - }, - {"type": "array", "items": {"type": "string"}, "uniqueItems": true} - ] - }, - - "labels": { - "oneOf": [ - { - "type": "object", - "patternProperties": { - ".+": { - "type": "string" - } - }, - "additionalProperties": false - }, - {"type": "array", "items": {"type": "string"}, "uniqueItems": true} - ] - }, - - "constraints": { - "service": { - "id": "#/definitions/constraints/service", - "anyOf": [ - {"required": ["build"]}, - {"required": ["image"]} - ], - "properties": { - "build": { - "required": ["context"] - } - } - } - } - } -} diff --git a/compose/config/config_schema_v3.2.json b/compose/config/config_schema_v3.2.json deleted file mode 100644 index 5eccdea72c0..00000000000 --- a/compose/config/config_schema_v3.2.json +++ /dev/null @@ -1,492 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - "id": "config_schema_v3.2.json", - "type": "object", - "required": ["version"], - - "properties": { - "version": { - "type": "string" - }, - - "services": { - "id": "#/properties/services", - "type": "object", - "patternProperties": { - "^[a-zA-Z0-9._-]+$": { - "$ref": "#/definitions/service" - } - }, - "additionalProperties": false - }, - - "networks": { - "id": "#/properties/networks", - "type": "object", - "patternProperties": { - "^[a-zA-Z0-9._-]+$": { - "$ref": "#/definitions/network" - } - } - }, - - "volumes": { - "id": "#/properties/volumes", - "type": "object", - "patternProperties": { - "^[a-zA-Z0-9._-]+$": { - "$ref": "#/definitions/volume" - } - }, - "additionalProperties": false - }, - - "secrets": { - "id": "#/properties/secrets", - "type": "object", - "patternProperties": { - "^[a-zA-Z0-9._-]+$": { - "$ref": "#/definitions/secret" - } - }, - "additionalProperties": false - } - }, - - "additionalProperties": false, - - "definitions": { - - "service": { - "id": "#/definitions/service", - "type": "object", - - "properties": { - "deploy": {"$ref": "#/definitions/deployment"}, - "build": { - "oneOf": [ - {"type": "string"}, - { - "type": "object", - "properties": { - "context": {"type": "string"}, - "dockerfile": {"type": "string"}, - "args": {"$ref": "#/definitions/list_or_dict"}, - "labels": {"$ref": "#/definitions/list_or_dict"}, - "cache_from": {"$ref": "#/definitions/list_of_strings"} - }, - "additionalProperties": false - } - ] - }, - "cap_add": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, - "cap_drop": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, - "cgroup_parent": {"type": "string"}, - "command": { - "oneOf": [ - {"type": "string"}, - {"type": "array", "items": {"type": "string"}} - ] - }, - "container_name": {"type": "string"}, - "depends_on": {"$ref": "#/definitions/list_of_strings"}, - "devices": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, - "dns": {"$ref": "#/definitions/string_or_list"}, - "dns_search": {"$ref": "#/definitions/string_or_list"}, - "domainname": {"type": "string"}, - "entrypoint": { - "oneOf": [ - {"type": "string"}, - {"type": "array", "items": {"type": "string"}} - ] - }, - "env_file": {"$ref": "#/definitions/string_or_list"}, - "environment": {"$ref": "#/definitions/list_or_dict"}, - - "expose": { - "type": "array", - "items": { - "type": ["string", "number"], - "format": "expose" - }, - "uniqueItems": true - }, - - "external_links": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, - "extra_hosts": {"$ref": "#/definitions/list_or_dict"}, - "healthcheck": {"$ref": "#/definitions/healthcheck"}, - "hostname": {"type": "string"}, - "image": {"type": "string"}, - "ipc": {"type": "string"}, - "labels": {"$ref": "#/definitions/labels"}, - "links": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, - - "logging": { - "type": "object", - - "properties": { - "driver": {"type": "string"}, - "options": { - "type": "object", - "patternProperties": { - "^.+$": {"type": ["string", "number", "null"]} - } - } - }, - "additionalProperties": false - }, - - "mac_address": {"type": "string"}, - "network_mode": {"type": "string"}, - - "networks": { - "oneOf": [ - {"$ref": "#/definitions/list_of_strings"}, - { - "type": "object", - "patternProperties": { - "^[a-zA-Z0-9._-]+$": { - "oneOf": [ - { - "type": "object", - "properties": { - "aliases": {"$ref": "#/definitions/list_of_strings"}, - "ipv4_address": {"type": "string"}, - "ipv6_address": {"type": "string"} - }, - "additionalProperties": false - }, - {"type": "null"} - ] - } - }, - "additionalProperties": false - } - ] - }, - "pid": {"type": ["string", "null"]}, - - "ports": { - "type": "array", - "items": { - "oneOf": [ - {"type": "number", "format": "ports"}, - {"type": "string", "format": "ports"}, - { - "type": "object", - "properties": { - "mode": {"type": "string"}, - "target": {"type": "integer"}, - "published": {"type": "integer"}, - "protocol": {"type": "string"} - }, - "additionalProperties": false - } - ] - }, - "uniqueItems": true - }, - - "privileged": {"type": "boolean"}, - "read_only": {"type": "boolean"}, - "restart": {"type": "string"}, - "security_opt": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, - "shm_size": {"type": ["number", "string"]}, - "secrets": { - "type": "array", - "items": { - "oneOf": [ - {"type": "string"}, - { - "type": "object", - "properties": { - "source": {"type": "string"}, - "target": {"type": "string"}, - "uid": {"type": "string"}, - "gid": {"type": "string"}, - "mode": {"type": "number"} - } - } - ] - } - }, - "sysctls": {"$ref": "#/definitions/list_or_dict"}, - "stdin_open": {"type": "boolean"}, - "stop_grace_period": {"type": "string", "format": "duration"}, - "stop_signal": {"type": "string"}, - "tmpfs": {"$ref": "#/definitions/string_or_list"}, - "tty": {"type": "boolean"}, - "ulimits": { - "type": "object", - "patternProperties": { - "^[a-z]+$": { - "oneOf": [ - {"type": "integer"}, - { - "type":"object", - "properties": { - "hard": {"type": "integer"}, - "soft": {"type": "integer"} - }, - "required": ["soft", "hard"], - "additionalProperties": false - } - ] - } - } - }, - "user": {"type": "string"}, - "userns_mode": {"type": "string"}, - "volumes": { - "type": "array", - "items": { - "oneOf": [ - {"type": "string"}, - { - "type": "object", - "required": ["type"], - "additionalProperties": false, - "properties": { - "type": {"type": "string"}, - "source": {"type": "string"}, - "target": {"type": "string"}, - "read_only": {"type": "boolean"}, - "consistency": {"type": "string"}, - "bind": { - "type": "object", - "properties": { - "propagation": {"type": "string"} - } - }, - "volume": { - "type": "object", - "properties": { - "nocopy": {"type": "boolean"} - } - } - } - } - ], - "uniqueItems": true - } - }, - "working_dir": {"type": "string"} - }, - "additionalProperties": false - }, - - "healthcheck": { - "id": "#/definitions/healthcheck", - "type": "object", - "additionalProperties": false, - "properties": { - "disable": {"type": "boolean"}, - "interval": {"type": "string"}, - "retries": {"type": "number"}, - "test": { - "oneOf": [ - {"type": "string"}, - {"type": "array", "items": {"type": "string"}} - ] - }, - "timeout": {"type": "string"} - } - }, - "deployment": { - "id": "#/definitions/deployment", - "type": ["object", "null"], - "properties": { - "mode": {"type": "string"}, - "endpoint_mode": {"type": "string"}, - "replicas": {"type": "integer"}, - "labels": {"$ref": "#/definitions/labels"}, - "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"} - }, - "additionalProperties": false - }, - "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", "format": "subnet_ip_address"} - }, - "additionalProperties": false - } - } - }, - "additionalProperties": false - }, - "external": { - "type": ["boolean", "object"], - "properties": { - "name": {"type": "string"} - }, - "additionalProperties": false - }, - "internal": {"type": "boolean"}, - "attachable": {"type": "boolean"}, - "labels": {"$ref": "#/definitions/labels"} - }, - "additionalProperties": false - }, - - "volume": { - "id": "#/definitions/volume", - "type": ["object", "null"], - "properties": { - "driver": {"type": "string"}, - "driver_opts": { - "type": "object", - "patternProperties": { - "^.+$": {"type": ["string", "number"]} - } - }, - "external": { - "type": ["boolean", "object"], - "properties": { - "name": {"type": "string"} - }, - "additionalProperties": false - }, - "labels": {"$ref": "#/definitions/labels"} - }, - "additionalProperties": false - }, - - "secret": { - "id": "#/definitions/secret", - "type": "object", - "properties": { - "file": {"type": "string"}, - "external": { - "type": ["boolean", "object"], - "properties": { - "name": {"type": "string"} - } - }, - "labels": {"$ref": "#/definitions/labels"} - }, - "additionalProperties": false - }, - - "string_or_list": { - "oneOf": [ - {"type": "string"}, - {"$ref": "#/definitions/list_of_strings"} - ] - }, - - "list_of_strings": { - "type": "array", - "items": {"type": "string"}, - "uniqueItems": true - }, - - "list_or_dict": { - "oneOf": [ - { - "type": "object", - "patternProperties": { - ".+": { - "type": ["string", "number", "null"] - } - }, - "additionalProperties": false - }, - {"type": "array", "items": {"type": "string"}, "uniqueItems": true} - ] - }, - - "labels": { - "oneOf": [ - { - "type": "object", - "patternProperties": { - ".+": { - "type": "string" - } - }, - "additionalProperties": false - }, - {"type": "array", "items": {"type": "string"}, "uniqueItems": true} - ] - }, - - "constraints": { - "service": { - "id": "#/definitions/constraints/service", - "anyOf": [ - {"required": ["build"]}, - {"required": ["image"]} - ], - "properties": { - "build": { - "required": ["context"] - } - } - } - } - } -} diff --git a/compose/config/config_schema_v3.3.json b/compose/config/config_schema_v3.3.json deleted file mode 100644 index f63842b9de2..00000000000 --- a/compose/config/config_schema_v3.3.json +++ /dev/null @@ -1,551 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - "id": "config_schema_v3.3.json", - "type": "object", - "required": ["version"], - - "properties": { - "version": { - "type": "string" - }, - - "services": { - "id": "#/properties/services", - "type": "object", - "patternProperties": { - "^[a-zA-Z0-9._-]+$": { - "$ref": "#/definitions/service" - } - }, - "additionalProperties": false - }, - - "networks": { - "id": "#/properties/networks", - "type": "object", - "patternProperties": { - "^[a-zA-Z0-9._-]+$": { - "$ref": "#/definitions/network" - } - } - }, - - "volumes": { - "id": "#/properties/volumes", - "type": "object", - "patternProperties": { - "^[a-zA-Z0-9._-]+$": { - "$ref": "#/definitions/volume" - } - }, - "additionalProperties": false - }, - - "secrets": { - "id": "#/properties/secrets", - "type": "object", - "patternProperties": { - "^[a-zA-Z0-9._-]+$": { - "$ref": "#/definitions/secret" - } - }, - "additionalProperties": false - }, - - "configs": { - "id": "#/properties/configs", - "type": "object", - "patternProperties": { - "^[a-zA-Z0-9._-]+$": { - "$ref": "#/definitions/config" - } - }, - "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"}, - "labels": {"$ref": "#/definitions/labels"}, - "cache_from": {"$ref": "#/definitions/list_of_strings"} - }, - "additionalProperties": false - } - ] - }, - "cap_add": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, - "cap_drop": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, - "cgroup_parent": {"type": "string"}, - "command": { - "oneOf": [ - {"type": "string"}, - {"type": "array", "items": {"type": "string"}} - ] - }, - "configs": { - "type": "array", - "items": { - "oneOf": [ - {"type": "string"}, - { - "type": "object", - "properties": { - "source": {"type": "string"}, - "target": {"type": "string"}, - "uid": {"type": "string"}, - "gid": {"type": "string"}, - "mode": {"type": "number"} - } - } - ] - } - }, - "container_name": {"type": "string"}, - "credential_spec": {"type": "object", "properties": { - "file": {"type": "string"}, - "registry": {"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/labels"}, - "links": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, - - "logging": { - "type": "object", - - "properties": { - "driver": {"type": "string"}, - "options": { - "type": "object", - "patternProperties": { - "^.+$": {"type": ["string", "number", "null"]} - } - } - }, - "additionalProperties": false - }, - - "mac_address": {"type": "string"}, - "network_mode": {"type": "string"}, - - "networks": { - "oneOf": [ - {"$ref": "#/definitions/list_of_strings"}, - { - "type": "object", - "patternProperties": { - "^[a-zA-Z0-9._-]+$": { - "oneOf": [ - { - "type": "object", - "properties": { - "aliases": {"$ref": "#/definitions/list_of_strings"}, - "ipv4_address": {"type": "string"}, - "ipv6_address": {"type": "string"} - }, - "additionalProperties": false - }, - {"type": "null"} - ] - } - }, - "additionalProperties": false - } - ] - }, - "pid": {"type": ["string", "null"]}, - - "ports": { - "type": "array", - "items": { - "oneOf": [ - {"type": "number", "format": "ports"}, - {"type": "string", "format": "ports"}, - { - "type": "object", - "properties": { - "mode": {"type": "string"}, - "target": {"type": "integer"}, - "published": {"type": "integer"}, - "protocol": {"type": "string"} - }, - "additionalProperties": false - } - ] - }, - "uniqueItems": true - }, - - "privileged": {"type": "boolean"}, - "read_only": {"type": "boolean"}, - "restart": {"type": "string"}, - "security_opt": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, - "shm_size": {"type": ["number", "string"]}, - "secrets": { - "type": "array", - "items": { - "oneOf": [ - {"type": "string"}, - { - "type": "object", - "properties": { - "source": {"type": "string"}, - "target": {"type": "string"}, - "uid": {"type": "string"}, - "gid": {"type": "string"}, - "mode": {"type": "number"} - } - } - ] - } - }, - "sysctls": {"$ref": "#/definitions/list_or_dict"}, - "stdin_open": {"type": "boolean"}, - "stop_grace_period": {"type": "string", "format": "duration"}, - "stop_signal": {"type": "string"}, - "tmpfs": {"$ref": "#/definitions/string_or_list"}, - "tty": {"type": "boolean"}, - "ulimits": { - "type": "object", - "patternProperties": { - "^[a-z]+$": { - "oneOf": [ - {"type": "integer"}, - { - "type":"object", - "properties": { - "hard": {"type": "integer"}, - "soft": {"type": "integer"} - }, - "required": ["soft", "hard"], - "additionalProperties": false - } - ] - } - } - }, - "user": {"type": "string"}, - "userns_mode": {"type": "string"}, - "volumes": { - "type": "array", - "items": { - "oneOf": [ - {"type": "string"}, - { - "type": "object", - "required": ["type"], - "additionalProperties": false, - "properties": { - "type": {"type": "string"}, - "source": {"type": "string"}, - "target": {"type": "string"}, - "read_only": {"type": "boolean"}, - "consistency": {"type": "string"}, - "bind": { - "type": "object", - "properties": { - "propagation": {"type": "string"} - } - }, - "volume": { - "type": "object", - "properties": { - "nocopy": {"type": "boolean"} - } - } - } - } - ], - "uniqueItems": true - } - }, - "working_dir": {"type": "string"} - }, - "additionalProperties": false - }, - - "healthcheck": { - "id": "#/definitions/healthcheck", - "type": "object", - "additionalProperties": false, - "properties": { - "disable": {"type": "boolean"}, - "interval": {"type": "string"}, - "retries": {"type": "number"}, - "test": { - "oneOf": [ - {"type": "string"}, - {"type": "array", "items": {"type": "string"}} - ] - }, - "timeout": {"type": "string"} - } - }, - "deployment": { - "id": "#/definitions/deployment", - "type": ["object", "null"], - "properties": { - "mode": {"type": "string"}, - "endpoint_mode": {"type": "string"}, - "replicas": {"type": "integer"}, - "labels": {"$ref": "#/definitions/labels"}, - "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"} - }, - "additionalProperties": false - }, - "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"}}, - "preferences": { - "type": "array", - "items": { - "type": "object", - "properties": { - "spread": {"type": "string"} - }, - "additionalProperties": false - } - } - }, - "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", "format": "subnet_ip_address"} - }, - "additionalProperties": false - } - } - }, - "additionalProperties": false - }, - "external": { - "type": ["boolean", "object"], - "properties": { - "name": {"type": "string"} - }, - "additionalProperties": false - }, - "internal": {"type": "boolean"}, - "attachable": {"type": "boolean"}, - "labels": {"$ref": "#/definitions/labels"} - }, - "additionalProperties": false - }, - - "volume": { - "id": "#/definitions/volume", - "type": ["object", "null"], - "properties": { - "driver": {"type": "string"}, - "driver_opts": { - "type": "object", - "patternProperties": { - "^.+$": {"type": ["string", "number"]} - } - }, - "external": { - "type": ["boolean", "object"], - "properties": { - "name": {"type": "string"} - }, - "additionalProperties": false - }, - "labels": {"$ref": "#/definitions/labels"} - }, - "additionalProperties": false - }, - - "secret": { - "id": "#/definitions/secret", - "type": "object", - "properties": { - "file": {"type": "string"}, - "external": { - "type": ["boolean", "object"], - "properties": { - "name": {"type": "string"} - } - }, - "labels": {"$ref": "#/definitions/labels"} - }, - "additionalProperties": false - }, - - "config": { - "id": "#/definitions/config", - "type": "object", - "properties": { - "file": {"type": "string"}, - "external": { - "type": ["boolean", "object"], - "properties": { - "name": {"type": "string"} - } - }, - "labels": {"$ref": "#/definitions/labels"} - }, - "additionalProperties": false - }, - - "string_or_list": { - "oneOf": [ - {"type": "string"}, - {"$ref": "#/definitions/list_of_strings"} - ] - }, - - "list_of_strings": { - "type": "array", - "items": {"type": "string"}, - "uniqueItems": true - }, - - "list_or_dict": { - "oneOf": [ - { - "type": "object", - "patternProperties": { - ".+": { - "type": ["string", "number", "null"] - } - }, - "additionalProperties": false - }, - {"type": "array", "items": {"type": "string"}, "uniqueItems": true} - ] - }, - - "labels": { - "oneOf": [ - { - "type": "object", - "patternProperties": { - ".+": { - "type": "string" - } - }, - "additionalProperties": false - }, - {"type": "array", "items": {"type": "string"}, "uniqueItems": true} - ] - }, - - "constraints": { - "service": { - "id": "#/definitions/constraints/service", - "anyOf": [ - {"required": ["build"]}, - {"required": ["image"]} - ], - "properties": { - "build": { - "required": ["context"] - } - } - } - } - } -} diff --git a/compose/config/config_schema_v3.4.json b/compose/config/config_schema_v3.4.json deleted file mode 100644 index 23e95544656..00000000000 --- a/compose/config/config_schema_v3.4.json +++ /dev/null @@ -1,560 +0,0 @@ - -{ - "$schema": "http://json-schema.org/draft-04/schema#", - "id": "config_schema_v3.4.json", - "type": "object", - "required": ["version"], - - "properties": { - "version": { - "type": "string" - }, - - "services": { - "id": "#/properties/services", - "type": "object", - "patternProperties": { - "^[a-zA-Z0-9._-]+$": { - "$ref": "#/definitions/service" - } - }, - "additionalProperties": false - }, - - "networks": { - "id": "#/properties/networks", - "type": "object", - "patternProperties": { - "^[a-zA-Z0-9._-]+$": { - "$ref": "#/definitions/network" - } - } - }, - - "volumes": { - "id": "#/properties/volumes", - "type": "object", - "patternProperties": { - "^[a-zA-Z0-9._-]+$": { - "$ref": "#/definitions/volume" - } - }, - "additionalProperties": false - }, - - "secrets": { - "id": "#/properties/secrets", - "type": "object", - "patternProperties": { - "^[a-zA-Z0-9._-]+$": { - "$ref": "#/definitions/secret" - } - }, - "additionalProperties": false - }, - - "configs": { - "id": "#/properties/configs", - "type": "object", - "patternProperties": { - "^[a-zA-Z0-9._-]+$": { - "$ref": "#/definitions/config" - } - }, - "additionalProperties": false - } - }, - - "patternProperties": {"^x-": {}}, - "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"}, - "labels": {"$ref": "#/definitions/labels"}, - "cache_from": {"$ref": "#/definitions/list_of_strings"}, - "network": {"type": "string"}, - "target": {"type": "string"} - }, - "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"}} - ] - }, - "configs": { - "type": "array", - "items": { - "oneOf": [ - {"type": "string"}, - { - "type": "object", - "properties": { - "source": {"type": "string"}, - "target": {"type": "string"}, - "uid": {"type": "string"}, - "gid": {"type": "string"}, - "mode": {"type": "number"} - } - } - ] - } - }, - "container_name": {"type": "string"}, - "credential_spec": {"type": "object", "properties": { - "file": {"type": "string"}, - "registry": {"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/labels"}, - "links": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, - - "logging": { - "type": "object", - - "properties": { - "driver": {"type": "string"}, - "options": { - "type": "object", - "patternProperties": { - "^.+$": {"type": ["string", "number", "null"]} - } - } - }, - "additionalProperties": false - }, - - "mac_address": {"type": "string"}, - "network_mode": {"type": "string"}, - - "networks": { - "oneOf": [ - {"$ref": "#/definitions/list_of_strings"}, - { - "type": "object", - "patternProperties": { - "^[a-zA-Z0-9._-]+$": { - "oneOf": [ - { - "type": "object", - "properties": { - "aliases": {"$ref": "#/definitions/list_of_strings"}, - "ipv4_address": {"type": "string"}, - "ipv6_address": {"type": "string"} - }, - "additionalProperties": false - }, - {"type": "null"} - ] - } - }, - "additionalProperties": false - } - ] - }, - "pid": {"type": ["string", "null"]}, - - "ports": { - "type": "array", - "items": { - "oneOf": [ - {"type": "number", "format": "ports"}, - {"type": "string", "format": "ports"}, - { - "type": "object", - "properties": { - "mode": {"type": "string"}, - "target": {"type": "integer"}, - "published": {"type": "integer"}, - "protocol": {"type": "string"} - }, - "additionalProperties": false - } - ] - }, - "uniqueItems": true - }, - - "privileged": {"type": "boolean"}, - "read_only": {"type": "boolean"}, - "restart": {"type": "string"}, - "security_opt": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, - "shm_size": {"type": ["number", "string"]}, - "secrets": { - "type": "array", - "items": { - "oneOf": [ - {"type": "string"}, - { - "type": "object", - "properties": { - "source": {"type": "string"}, - "target": {"type": "string"}, - "uid": {"type": "string"}, - "gid": {"type": "string"}, - "mode": {"type": "number"} - } - } - ] - } - }, - "sysctls": {"$ref": "#/definitions/list_or_dict"}, - "stdin_open": {"type": "boolean"}, - "stop_grace_period": {"type": "string", "format": "duration"}, - "stop_signal": {"type": "string"}, - "tmpfs": {"$ref": "#/definitions/string_or_list"}, - "tty": {"type": "boolean"}, - "ulimits": { - "type": "object", - "patternProperties": { - "^[a-z]+$": { - "oneOf": [ - {"type": "integer"}, - { - "type":"object", - "properties": { - "hard": {"type": "integer"}, - "soft": {"type": "integer"} - }, - "required": ["soft", "hard"], - "additionalProperties": false - } - ] - } - } - }, - "user": {"type": "string"}, - "userns_mode": {"type": "string"}, - "volumes": { - "type": "array", - "items": { - "oneOf": [ - {"type": "string"}, - { - "type": "object", - "required": ["type"], - "additionalProperties": false, - "properties": { - "type": {"type": "string"}, - "source": {"type": "string"}, - "target": {"type": "string"}, - "read_only": {"type": "boolean"}, - "consistency": {"type": "string"}, - "bind": { - "type": "object", - "properties": { - "propagation": {"type": "string"} - } - }, - "volume": { - "type": "object", - "properties": { - "nocopy": {"type": "boolean"} - } - } - } - } - ], - "uniqueItems": true - } - }, - "working_dir": {"type": "string"} - }, - "additionalProperties": false - }, - - "healthcheck": { - "id": "#/definitions/healthcheck", - "type": "object", - "additionalProperties": false, - "properties": { - "disable": {"type": "boolean"}, - "interval": {"type": "string", "format": "duration"}, - "retries": {"type": "number"}, - "test": { - "oneOf": [ - {"type": "string"}, - {"type": "array", "items": {"type": "string"}} - ] - }, - "timeout": {"type": "string", "format": "duration"}, - "start_period": {"type": "string", "format": "duration"} - } - }, - "deployment": { - "id": "#/definitions/deployment", - "type": ["object", "null"], - "properties": { - "mode": {"type": "string"}, - "endpoint_mode": {"type": "string"}, - "replicas": {"type": "integer"}, - "labels": {"$ref": "#/definitions/labels"}, - "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"}, - "order": {"type": "string", "enum": [ - "start-first", "stop-first" - ]} - }, - "additionalProperties": false - }, - "resources": { - "type": "object", - "properties": { - "limits": {"$ref": "#/definitions/resource"}, - "reservations": {"$ref": "#/definitions/resource"} - }, - "additionalProperties": false - }, - "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"}}, - "preferences": { - "type": "array", - "items": { - "type": "object", - "properties": { - "spread": {"type": "string"} - }, - "additionalProperties": false - } - } - }, - "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", "format": "subnet_ip_address"} - }, - "additionalProperties": false - } - } - }, - "additionalProperties": false - }, - "external": { - "type": ["boolean", "object"], - "properties": { - "name": {"type": "string"} - }, - "additionalProperties": false - }, - "internal": {"type": "boolean"}, - "attachable": {"type": "boolean"}, - "labels": {"$ref": "#/definitions/labels"} - }, - "additionalProperties": false - }, - - "volume": { - "id": "#/definitions/volume", - "type": ["object", "null"], - "properties": { - "name": {"type": "string"}, - "driver": {"type": "string"}, - "driver_opts": { - "type": "object", - "patternProperties": { - "^.+$": {"type": ["string", "number"]} - } - }, - "external": { - "type": ["boolean", "object"], - "properties": { - "name": {"type": "string"} - }, - "additionalProperties": false - }, - "labels": {"$ref": "#/definitions/labels"} - }, - "additionalProperties": false - }, - - "secret": { - "id": "#/definitions/secret", - "type": "object", - "properties": { - "file": {"type": "string"}, - "external": { - "type": ["boolean", "object"], - "properties": { - "name": {"type": "string"} - } - }, - "labels": {"$ref": "#/definitions/labels"} - }, - "additionalProperties": false - }, - - "config": { - "id": "#/definitions/config", - "type": "object", - "properties": { - "file": {"type": "string"}, - "external": { - "type": ["boolean", "object"], - "properties": { - "name": {"type": "string"} - } - }, - "labels": {"$ref": "#/definitions/labels"} - }, - "additionalProperties": false - }, - - "string_or_list": { - "oneOf": [ - {"type": "string"}, - {"$ref": "#/definitions/list_of_strings"} - ] - }, - - "list_of_strings": { - "type": "array", - "items": {"type": "string"}, - "uniqueItems": true - }, - - "list_or_dict": { - "oneOf": [ - { - "type": "object", - "patternProperties": { - ".+": { - "type": ["string", "number", "null"] - } - }, - "additionalProperties": false - }, - {"type": "array", "items": {"type": "string"}, "uniqueItems": true} - ] - }, - - "labels": { - "oneOf": [ - { - "type": "object", - "patternProperties": { - ".+": { - "type": "string" - } - }, - "additionalProperties": false - }, - {"type": "array", "items": {"type": "string"}, "uniqueItems": true} - ] - }, - - "constraints": { - "service": { - "id": "#/definitions/constraints/service", - "anyOf": [ - {"required": ["build"]}, - {"required": ["image"]} - ], - "properties": { - "build": { - "required": ["context"] - } - } - } - } - } -} diff --git a/compose/config/config_schema_v3.5.json b/compose/config/config_schema_v3.5.json deleted file mode 100644 index e3bdecbc1c5..00000000000 --- a/compose/config/config_schema_v3.5.json +++ /dev/null @@ -1,588 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - "id": "config_schema_v3.5.json", - "type": "object", - "required": ["version"], - - "properties": { - "version": { - "type": "string" - }, - - "services": { - "id": "#/properties/services", - "type": "object", - "patternProperties": { - "^[a-zA-Z0-9._-]+$": { - "$ref": "#/definitions/service" - } - }, - "additionalProperties": false - }, - - "networks": { - "id": "#/properties/networks", - "type": "object", - "patternProperties": { - "^[a-zA-Z0-9._-]+$": { - "$ref": "#/definitions/network" - } - } - }, - - "volumes": { - "id": "#/properties/volumes", - "type": "object", - "patternProperties": { - "^[a-zA-Z0-9._-]+$": { - "$ref": "#/definitions/volume" - } - }, - "additionalProperties": false - }, - - "secrets": { - "id": "#/properties/secrets", - "type": "object", - "patternProperties": { - "^[a-zA-Z0-9._-]+$": { - "$ref": "#/definitions/secret" - } - }, - "additionalProperties": false - }, - - "configs": { - "id": "#/properties/configs", - "type": "object", - "patternProperties": { - "^[a-zA-Z0-9._-]+$": { - "$ref": "#/definitions/config" - } - }, - "additionalProperties": false - } - }, - - "patternProperties": {"^x-": {}}, - "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"}, - "labels": {"$ref": "#/definitions/labels"}, - "cache_from": {"$ref": "#/definitions/list_of_strings"}, - "network": {"type": "string"}, - "target": {"type": "string"}, - "shm_size": {"type": ["integer", "string"]} - }, - "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"}} - ] - }, - "configs": { - "type": "array", - "items": { - "oneOf": [ - {"type": "string"}, - { - "type": "object", - "properties": { - "source": {"type": "string"}, - "target": {"type": "string"}, - "uid": {"type": "string"}, - "gid": {"type": "string"}, - "mode": {"type": "number"} - } - } - ] - } - }, - "container_name": {"type": "string"}, - "credential_spec": {"type": "object", "properties": { - "file": {"type": "string"}, - "registry": {"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"}, - "isolation": {"type": "string"}, - "labels": {"$ref": "#/definitions/labels"}, - "links": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, - - "logging": { - "type": "object", - - "properties": { - "driver": {"type": "string"}, - "options": { - "type": "object", - "patternProperties": { - "^.+$": {"type": ["string", "number", "null"]} - } - } - }, - "additionalProperties": false - }, - - "mac_address": {"type": "string"}, - "network_mode": {"type": "string"}, - - "networks": { - "oneOf": [ - {"$ref": "#/definitions/list_of_strings"}, - { - "type": "object", - "patternProperties": { - "^[a-zA-Z0-9._-]+$": { - "oneOf": [ - { - "type": "object", - "properties": { - "aliases": {"$ref": "#/definitions/list_of_strings"}, - "ipv4_address": {"type": "string"}, - "ipv6_address": {"type": "string"} - }, - "additionalProperties": false - }, - {"type": "null"} - ] - } - }, - "additionalProperties": false - } - ] - }, - "pid": {"type": ["string", "null"]}, - - "ports": { - "type": "array", - "items": { - "oneOf": [ - {"type": "number", "format": "ports"}, - {"type": "string", "format": "ports"}, - { - "type": "object", - "properties": { - "mode": {"type": "string"}, - "target": {"type": "integer"}, - "published": {"type": "integer"}, - "protocol": {"type": "string"} - }, - "additionalProperties": false - } - ] - }, - "uniqueItems": true - }, - - "privileged": {"type": "boolean"}, - "read_only": {"type": "boolean"}, - "restart": {"type": "string"}, - "security_opt": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, - "shm_size": {"type": ["number", "string"]}, - "secrets": { - "type": "array", - "items": { - "oneOf": [ - {"type": "string"}, - { - "type": "object", - "properties": { - "source": {"type": "string"}, - "target": {"type": "string"}, - "uid": {"type": "string"}, - "gid": {"type": "string"}, - "mode": {"type": "number"} - } - } - ] - } - }, - "sysctls": {"$ref": "#/definitions/list_or_dict"}, - "stdin_open": {"type": "boolean"}, - "stop_grace_period": {"type": "string", "format": "duration"}, - "stop_signal": {"type": "string"}, - "tmpfs": {"$ref": "#/definitions/string_or_list"}, - "tty": {"type": "boolean"}, - "ulimits": { - "type": "object", - "patternProperties": { - "^[a-z]+$": { - "oneOf": [ - {"type": "integer"}, - { - "type":"object", - "properties": { - "hard": {"type": "integer"}, - "soft": {"type": "integer"} - }, - "required": ["soft", "hard"], - "additionalProperties": false - } - ] - } - } - }, - "user": {"type": "string"}, - "userns_mode": {"type": "string"}, - "volumes": { - "type": "array", - "items": { - "oneOf": [ - {"type": "string"}, - { - "type": "object", - "required": ["type"], - "properties": { - "type": {"type": "string"}, - "source": {"type": "string"}, - "target": {"type": "string"}, - "read_only": {"type": "boolean"}, - "consistency": {"type": "string"}, - "bind": { - "type": "object", - "properties": { - "propagation": {"type": "string"} - } - }, - "volume": { - "type": "object", - "properties": { - "nocopy": {"type": "boolean"} - } - } - }, - "additionalProperties": false - } - ], - "uniqueItems": true - } - }, - "working_dir": {"type": "string"} - }, - "additionalProperties": false - }, - - "healthcheck": { - "id": "#/definitions/healthcheck", - "type": "object", - "additionalProperties": false, - "properties": { - "disable": {"type": "boolean"}, - "interval": {"type": "string", "format": "duration"}, - "retries": {"type": "number"}, - "test": { - "oneOf": [ - {"type": "string"}, - {"type": "array", "items": {"type": "string"}} - ] - }, - "timeout": {"type": "string", "format": "duration"}, - "start_period": {"type": "string", "format": "duration"} - } - }, - "deployment": { - "id": "#/definitions/deployment", - "type": ["object", "null"], - "properties": { - "mode": {"type": "string"}, - "endpoint_mode": {"type": "string"}, - "replicas": {"type": "integer"}, - "labels": {"$ref": "#/definitions/labels"}, - "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"}, - "order": {"type": "string", "enum": [ - "start-first", "stop-first" - ]} - }, - "additionalProperties": false - }, - "resources": { - "type": "object", - "properties": { - "limits": { - "type": "object", - "properties": { - "cpus": {"type": "string"}, - "memory": {"type": "string"} - }, - "additionalProperties": false - }, - "reservations": { - "type": "object", - "properties": { - "cpus": {"type": "string"}, - "memory": {"type": "string"}, - "generic_resources": {"$ref": "#/definitions/generic_resources"} - }, - "additionalProperties": false - } - }, - "additionalProperties": false - }, - "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"}}, - "preferences": { - "type": "array", - "items": { - "type": "object", - "properties": { - "spread": {"type": "string"} - }, - "additionalProperties": false - } - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false - }, - - "generic_resources": { - "id": "#/definitions/generic_resources", - "type": "array", - "items": { - "type": "object", - "properties": { - "discrete_resource_spec": { - "type": "object", - "properties": { - "kind": {"type": "string"}, - "value": {"type": "number"} - }, - "additionalProperties": false - } - }, - "additionalProperties": false - } - }, - - "network": { - "id": "#/definitions/network", - "type": ["object", "null"], - "properties": { - "name": {"type": "string"}, - "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", "format": "subnet_ip_address"} - }, - "additionalProperties": false - } - } - }, - "additionalProperties": false - }, - "external": { - "type": ["boolean", "object"], - "properties": { - "name": {"type": "string"} - }, - "additionalProperties": false - }, - "internal": {"type": "boolean"}, - "attachable": {"type": "boolean"}, - "labels": {"$ref": "#/definitions/labels"} - }, - "additionalProperties": false - }, - - "volume": { - "id": "#/definitions/volume", - "type": ["object", "null"], - "properties": { - "name": {"type": "string"}, - "driver": {"type": "string"}, - "driver_opts": { - "type": "object", - "patternProperties": { - "^.+$": {"type": ["string", "number"]} - } - }, - "external": { - "type": ["boolean", "object"], - "properties": { - "name": {"type": "string"} - }, - "additionalProperties": false - }, - "labels": {"$ref": "#/definitions/labels"} - }, - "additionalProperties": false - }, - - "secret": { - "id": "#/definitions/secret", - "type": "object", - "properties": { - "name": {"type": "string"}, - "file": {"type": "string"}, - "external": { - "type": ["boolean", "object"], - "properties": { - "name": {"type": "string"} - } - }, - "labels": {"$ref": "#/definitions/labels"} - }, - "additionalProperties": false - }, - - "config": { - "id": "#/definitions/config", - "type": "object", - "properties": { - "name": {"type": "string"}, - "file": {"type": "string"}, - "external": { - "type": ["boolean", "object"], - "properties": { - "name": {"type": "string"} - } - }, - "labels": {"$ref": "#/definitions/labels"} - }, - "additionalProperties": false - }, - - "string_or_list": { - "oneOf": [ - {"type": "string"}, - {"$ref": "#/definitions/list_of_strings"} - ] - }, - - "list_of_strings": { - "type": "array", - "items": {"type": "string"}, - "uniqueItems": true - }, - - "list_or_dict": { - "oneOf": [ - { - "type": "object", - "patternProperties": { - ".+": { - "type": ["string", "number", "null"] - } - }, - "additionalProperties": false - }, - {"type": "array", "items": {"type": "string"}, "uniqueItems": true} - ] - }, - - "labels": { - "oneOf": [ - { - "type": "object", - "patternProperties": { - ".+": { - "type": "string" - } - }, - "additionalProperties": false - }, - {"type": "array", "items": {"type": "string"}, "uniqueItems": true} - ] - }, - - "constraints": { - "service": { - "id": "#/definitions/constraints/service", - "anyOf": [ - {"required": ["build"]}, - {"required": ["image"]} - ], - "properties": { - "build": { - "required": ["context"] - } - } - } - } - } -} diff --git a/compose/config/config_schema_v3.6.json b/compose/config/config_schema_v3.6.json deleted file mode 100644 index 95a552b346c..00000000000 --- a/compose/config/config_schema_v3.6.json +++ /dev/null @@ -1,582 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - "id": "config_schema_v3.6.json", - "type": "object", - "required": ["version"], - - "properties": { - "version": { - "type": "string" - }, - - "services": { - "id": "#/properties/services", - "type": "object", - "patternProperties": { - "^[a-zA-Z0-9._-]+$": { - "$ref": "#/definitions/service" - } - }, - "additionalProperties": false - }, - - "networks": { - "id": "#/properties/networks", - "type": "object", - "patternProperties": { - "^[a-zA-Z0-9._-]+$": { - "$ref": "#/definitions/network" - } - } - }, - - "volumes": { - "id": "#/properties/volumes", - "type": "object", - "patternProperties": { - "^[a-zA-Z0-9._-]+$": { - "$ref": "#/definitions/volume" - } - }, - "additionalProperties": false - }, - - "secrets": { - "id": "#/properties/secrets", - "type": "object", - "patternProperties": { - "^[a-zA-Z0-9._-]+$": { - "$ref": "#/definitions/secret" - } - }, - "additionalProperties": false - }, - - "configs": { - "id": "#/properties/configs", - "type": "object", - "patternProperties": { - "^[a-zA-Z0-9._-]+$": { - "$ref": "#/definitions/config" - } - }, - "additionalProperties": false - } - }, - - "patternProperties": {"^x-": {}}, - "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"}, - "labels": {"$ref": "#/definitions/list_or_dict"}, - "cache_from": {"$ref": "#/definitions/list_of_strings"}, - "network": {"type": "string"}, - "target": {"type": "string"}, - "shm_size": {"type": ["integer", "string"]} - }, - "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"}} - ] - }, - "configs": { - "type": "array", - "items": { - "oneOf": [ - {"type": "string"}, - { - "type": "object", - "properties": { - "source": {"type": "string"}, - "target": {"type": "string"}, - "uid": {"type": "string"}, - "gid": {"type": "string"}, - "mode": {"type": "number"} - } - } - ] - } - }, - "container_name": {"type": "string"}, - "credential_spec": {"type": "object", "properties": { - "file": {"type": "string"}, - "registry": {"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"}, - "isolation": {"type": "string"}, - "labels": {"$ref": "#/definitions/list_or_dict"}, - "links": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, - - "logging": { - "type": "object", - - "properties": { - "driver": {"type": "string"}, - "options": { - "type": "object", - "patternProperties": { - "^.+$": {"type": ["string", "number", "null"]} - } - } - }, - "additionalProperties": false - }, - - "mac_address": {"type": "string"}, - "network_mode": {"type": "string"}, - - "networks": { - "oneOf": [ - {"$ref": "#/definitions/list_of_strings"}, - { - "type": "object", - "patternProperties": { - "^[a-zA-Z0-9._-]+$": { - "oneOf": [ - { - "type": "object", - "properties": { - "aliases": {"$ref": "#/definitions/list_of_strings"}, - "ipv4_address": {"type": "string"}, - "ipv6_address": {"type": "string"} - }, - "additionalProperties": false - }, - {"type": "null"} - ] - } - }, - "additionalProperties": false - } - ] - }, - "pid": {"type": ["string", "null"]}, - - "ports": { - "type": "array", - "items": { - "oneOf": [ - {"type": "number", "format": "ports"}, - {"type": "string", "format": "ports"}, - { - "type": "object", - "properties": { - "mode": {"type": "string"}, - "target": {"type": "integer"}, - "published": {"type": "integer"}, - "protocol": {"type": "string"} - }, - "additionalProperties": false - } - ] - }, - "uniqueItems": true - }, - - "privileged": {"type": "boolean"}, - "read_only": {"type": "boolean"}, - "restart": {"type": "string"}, - "security_opt": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, - "shm_size": {"type": ["number", "string"]}, - "secrets": { - "type": "array", - "items": { - "oneOf": [ - {"type": "string"}, - { - "type": "object", - "properties": { - "source": {"type": "string"}, - "target": {"type": "string"}, - "uid": {"type": "string"}, - "gid": {"type": "string"}, - "mode": {"type": "number"} - } - } - ] - } - }, - "sysctls": {"$ref": "#/definitions/list_or_dict"}, - "stdin_open": {"type": "boolean"}, - "stop_grace_period": {"type": "string", "format": "duration"}, - "stop_signal": {"type": "string"}, - "tmpfs": {"$ref": "#/definitions/string_or_list"}, - "tty": {"type": "boolean"}, - "ulimits": { - "type": "object", - "patternProperties": { - "^[a-z]+$": { - "oneOf": [ - {"type": "integer"}, - { - "type":"object", - "properties": { - "hard": {"type": "integer"}, - "soft": {"type": "integer"} - }, - "required": ["soft", "hard"], - "additionalProperties": false - } - ] - } - } - }, - "user": {"type": "string"}, - "userns_mode": {"type": "string"}, - "volumes": { - "type": "array", - "items": { - "oneOf": [ - {"type": "string"}, - { - "type": "object", - "required": ["type"], - "properties": { - "type": {"type": "string"}, - "source": {"type": "string"}, - "target": {"type": "string"}, - "read_only": {"type": "boolean"}, - "consistency": {"type": "string"}, - "bind": { - "type": "object", - "properties": { - "propagation": {"type": "string"} - } - }, - "volume": { - "type": "object", - "properties": { - "nocopy": {"type": "boolean"} - } - }, - "tmpfs": { - "type": "object", - "properties": { - "size": { - "type": "integer", - "minimum": 0 - } - } - } - }, - "additionalProperties": false - } - ], - "uniqueItems": true - } - }, - "working_dir": {"type": "string"} - }, - "additionalProperties": false - }, - - "healthcheck": { - "id": "#/definitions/healthcheck", - "type": "object", - "additionalProperties": false, - "properties": { - "disable": {"type": "boolean"}, - "interval": {"type": "string", "format": "duration"}, - "retries": {"type": "number"}, - "test": { - "oneOf": [ - {"type": "string"}, - {"type": "array", "items": {"type": "string"}} - ] - }, - "timeout": {"type": "string", "format": "duration"}, - "start_period": {"type": "string", "format": "duration"} - } - }, - "deployment": { - "id": "#/definitions/deployment", - "type": ["object", "null"], - "properties": { - "mode": {"type": "string"}, - "endpoint_mode": {"type": "string"}, - "replicas": {"type": "integer"}, - "labels": {"$ref": "#/definitions/list_or_dict"}, - "update_config": { - "type": "object", - "properties": { - "parallelism": {"type": "integer"}, - "delay": {"type": "string", "format": "duration"}, - "failure_action": {"type": "string"}, - "monitor": {"type": "string", "format": "duration"}, - "max_failure_ratio": {"type": "number"}, - "order": {"type": "string", "enum": [ - "start-first", "stop-first" - ]} - }, - "additionalProperties": false - }, - "resources": { - "type": "object", - "properties": { - "limits": { - "type": "object", - "properties": { - "cpus": {"type": "string"}, - "memory": {"type": "string"} - }, - "additionalProperties": false - }, - "reservations": { - "type": "object", - "properties": { - "cpus": {"type": "string"}, - "memory": {"type": "string"}, - "generic_resources": {"$ref": "#/definitions/generic_resources"} - }, - "additionalProperties": false - } - }, - "additionalProperties": false - }, - "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"}}, - "preferences": { - "type": "array", - "items": { - "type": "object", - "properties": { - "spread": {"type": "string"} - }, - "additionalProperties": false - } - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false - }, - - "generic_resources": { - "id": "#/definitions/generic_resources", - "type": "array", - "items": { - "type": "object", - "properties": { - "discrete_resource_spec": { - "type": "object", - "properties": { - "kind": {"type": "string"}, - "value": {"type": "number"} - }, - "additionalProperties": false - } - }, - "additionalProperties": false - } - }, - - "network": { - "id": "#/definitions/network", - "type": ["object", "null"], - "properties": { - "name": {"type": "string"}, - "driver": {"type": "string"}, - "driver_opts": { - "type": "object", - "patternProperties": { - "^.+$": {"type": ["string", "number"]} - } - }, - "ipam": { - "type": "object", - "properties": { - "driver": {"type": "string"}, - "config": { - "type": "array", - "items": { - "type": "object", - "properties": { - "subnet": {"type": "string"} - }, - "additionalProperties": false - } - } - }, - "additionalProperties": false - }, - "external": { - "type": ["boolean", "object"], - "properties": { - "name": {"type": "string"} - }, - "additionalProperties": false - }, - "internal": {"type": "boolean"}, - "attachable": {"type": "boolean"}, - "labels": {"$ref": "#/definitions/list_or_dict"} - }, - "additionalProperties": false - }, - - "volume": { - "id": "#/definitions/volume", - "type": ["object", "null"], - "properties": { - "name": {"type": "string"}, - "driver": {"type": "string"}, - "driver_opts": { - "type": "object", - "patternProperties": { - "^.+$": {"type": ["string", "number"]} - } - }, - "external": { - "type": ["boolean", "object"], - "properties": { - "name": {"type": "string"} - }, - "additionalProperties": false - }, - "labels": {"$ref": "#/definitions/list_or_dict"} - }, - "additionalProperties": false - }, - - "secret": { - "id": "#/definitions/secret", - "type": "object", - "properties": { - "name": {"type": "string"}, - "file": {"type": "string"}, - "external": { - "type": ["boolean", "object"], - "properties": { - "name": {"type": "string"} - } - }, - "labels": {"$ref": "#/definitions/list_or_dict"} - }, - "additionalProperties": false - }, - - "config": { - "id": "#/definitions/config", - "type": "object", - "properties": { - "name": {"type": "string"}, - "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"] - } - } - } - } - } -} diff --git a/compose/config/config_schema_v3.7.json b/compose/config/config_schema_v3.7.json deleted file mode 100644 index c5906af9f2b..00000000000 --- a/compose/config/config_schema_v3.7.json +++ /dev/null @@ -1,1013 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - "id": "config_schema_v3.7.json", - "type": "object", - "required": [ - "version" - ], - "properties": { - "version": { - "type": "string" - }, - "services": { - "id": "#/properties/services", - "type": "object", - "patternProperties": { - "^[a-zA-Z0-9._-]+$": { - "$ref": "#/definitions/service" - } - }, - "additionalProperties": false - }, - "networks": { - "id": "#/properties/networks", - "type": "object", - "patternProperties": { - "^[a-zA-Z0-9._-]+$": { - "$ref": "#/definitions/network" - } - } - }, - "volumes": { - "id": "#/properties/volumes", - "type": "object", - "patternProperties": { - "^[a-zA-Z0-9._-]+$": { - "$ref": "#/definitions/volume" - } - }, - "additionalProperties": false - }, - "secrets": { - "id": "#/properties/secrets", - "type": "object", - "patternProperties": { - "^[a-zA-Z0-9._-]+$": { - "$ref": "#/definitions/secret" - } - }, - "additionalProperties": false - }, - "configs": { - "id": "#/properties/configs", - "type": "object", - "patternProperties": { - "^[a-zA-Z0-9._-]+$": { - "$ref": "#/definitions/config" - } - }, - "additionalProperties": false - } - }, - "patternProperties": { - "^x-": {} - }, - "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" - }, - "labels": { - "$ref": "#/definitions/list_or_dict" - }, - "cache_from": { - "$ref": "#/definitions/list_of_strings" - }, - "network": { - "type": "string" - }, - "target": { - "type": "string" - }, - "shm_size": { - "type": [ - "integer", - "string" - ] - } - }, - "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" - } - } - ] - }, - "configs": { - "type": "array", - "items": { - "oneOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": { - "source": { - "type": "string" - }, - "target": { - "type": "string" - }, - "uid": { - "type": "string" - }, - "gid": { - "type": "string" - }, - "mode": { - "type": "number" - } - } - } - ] - } - }, - "container_name": { - "type": "string" - }, - "credential_spec": { - "type": "object", - "properties": { - "file": { - "type": "string" - }, - "registry": { - "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" - }, - "init": { - "type": "boolean" - }, - "ipc": { - "type": "string" - }, - "isolation": { - "type": "string" - }, - "labels": { - "$ref": "#/definitions/list_or_dict" - }, - "links": { - "type": "array", - "items": { - "type": "string" - }, - "uniqueItems": true - }, - "logging": { - "type": "object", - "properties": { - "driver": { - "type": "string" - }, - "options": { - "type": "object", - "patternProperties": { - "^.+$": { - "type": [ - "string", - "number", - "null" - ] - } - } - } - }, - "additionalProperties": false - }, - "mac_address": { - "type": "string" - }, - "network_mode": { - "type": "string" - }, - "networks": { - "oneOf": [ - { - "$ref": "#/definitions/list_of_strings" - }, - { - "type": "object", - "patternProperties": { - "^[a-zA-Z0-9._-]+$": { - "oneOf": [ - { - "type": "object", - "properties": { - "aliases": { - "$ref": "#/definitions/list_of_strings" - }, - "ipv4_address": { - "type": "string" - }, - "ipv6_address": { - "type": "string" - } - }, - "additionalProperties": false - }, - { - "type": "null" - } - ] - } - }, - "additionalProperties": false - } - ] - }, - "pid": { - "type": [ - "string", - "null" - ] - }, - "ports": { - "type": "array", - "items": { - "oneOf": [ - { - "type": "number", - "format": "ports" - }, - { - "type": "string", - "format": "ports" - }, - { - "type": "object", - "properties": { - "mode": { - "type": "string" - }, - "target": { - "type": "integer" - }, - "published": { - "type": "integer" - }, - "protocol": { - "type": "string" - } - }, - "additionalProperties": false - } - ] - }, - "uniqueItems": true - }, - "privileged": { - "type": "boolean" - }, - "read_only": { - "type": "boolean" - }, - "restart": { - "type": "string" - }, - "security_opt": { - "type": "array", - "items": { - "type": "string" - }, - "uniqueItems": true - }, - "shm_size": { - "type": [ - "number", - "string" - ] - }, - "secrets": { - "type": "array", - "items": { - "oneOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": { - "source": { - "type": "string" - }, - "target": { - "type": "string" - }, - "uid": { - "type": "string" - }, - "gid": { - "type": "string" - }, - "mode": { - "type": "number" - } - } - } - ] - } - }, - "sysctls": { - "$ref": "#/definitions/list_or_dict" - }, - "stdin_open": { - "type": "boolean" - }, - "stop_grace_period": { - "type": "string", - "format": "duration" - }, - "stop_signal": { - "type": "string" - }, - "tmpfs": { - "$ref": "#/definitions/string_or_list" - }, - "tty": { - "type": "boolean" - }, - "ulimits": { - "type": "object", - "patternProperties": { - "^[a-z]+$": { - "oneOf": [ - { - "type": "integer" - }, - { - "type": "object", - "properties": { - "hard": { - "type": "integer" - }, - "soft": { - "type": "integer" - } - }, - "required": [ - "soft", - "hard" - ], - "additionalProperties": false - } - ] - } - } - }, - "user": { - "type": "string" - }, - "userns_mode": { - "type": "string" - }, - "volumes": { - "type": "array", - "items": { - "oneOf": [ - { - "type": "string" - }, - { - "type": "object", - "required": [ - "type" - ], - "properties": { - "type": { - "type": "string" - }, - "source": { - "type": "string" - }, - "target": { - "type": "string" - }, - "read_only": { - "type": "boolean" - }, - "consistency": { - "type": "string" - }, - "bind": { - "type": "object", - "properties": { - "propagation": { - "type": "string" - } - } - }, - "volume": { - "type": "object", - "properties": { - "nocopy": { - "type": "boolean" - } - } - }, - "tmpfs": { - "type": "object", - "properties": { - "size": { - "type": "integer", - "minimum": 0 - } - } - } - }, - "additionalProperties": false - } - ], - "uniqueItems": true - } - }, - "working_dir": { - "type": "string" - } - }, - "patternProperties": { - "^x-": {} - }, - "additionalProperties": false - }, - "healthcheck": { - "id": "#/definitions/healthcheck", - "type": "object", - "additionalProperties": false, - "properties": { - "disable": { - "type": "boolean" - }, - "interval": { - "type": "string", - "format": "duration" - }, - "retries": { - "type": "number" - }, - "test": { - "oneOf": [ - { - "type": "string" - }, - { - "type": "array", - "items": { - "type": "string" - } - } - ] - }, - "timeout": { - "type": "string", - "format": "duration" - }, - "start_period": { - "type": "string", - "format": "duration" - } - } - }, - "deployment": { - "id": "#/definitions/deployment", - "type": [ - "object", - "null" - ], - "properties": { - "mode": { - "type": "string" - }, - "endpoint_mode": { - "type": "string" - }, - "replicas": { - "type": "integer" - }, - "labels": { - "$ref": "#/definitions/list_or_dict" - }, - "rollback_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" - }, - "order": { - "type": "string", - "enum": [ - "start-first", - "stop-first" - ] - } - }, - "additionalProperties": false - }, - "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" - }, - "order": { - "type": "string", - "enum": [ - "start-first", - "stop-first" - ] - } - }, - "additionalProperties": false - }, - "resources": { - "type": "object", - "properties": { - "limits": { - "type": "object", - "properties": { - "cpus": { - "type": "string" - }, - "memory": { - "type": "string" - } - }, - "additionalProperties": false - }, - "reservations": { - "type": "object", - "properties": { - "cpus": { - "type": "string" - }, - "memory": { - "type": "string" - }, - "generic_resources": { - "$ref": "#/definitions/generic_resources" - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false - }, - "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" - } - }, - "preferences": { - "type": "array", - "items": { - "type": "object", - "properties": { - "spread": { - "type": "string" - } - }, - "additionalProperties": false - } - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false - }, - "generic_resources": { - "id": "#/definitions/generic_resources", - "type": "array", - "items": { - "type": "object", - "properties": { - "discrete_resource_spec": { - "type": "object", - "properties": { - "kind": { - "type": "string" - }, - "value": { - "type": "number" - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false - } - }, - "network": { - "id": "#/definitions/network", - "type": [ - "object", - "null" - ], - "properties": { - "name": { - "type": "string" - }, - "driver": { - "type": "string" - }, - "driver_opts": { - "type": "object", - "patternProperties": { - "^.+$": { - "type": [ - "string", - "number" - ] - } - } - }, - "ipam": { - "type": "object", - "properties": { - "driver": { - "type": "string" - }, - "config": { - "type": "array", - "items": { - "type": "object", - "properties": { - "subnet": { - "type": "string" - } - }, - "additionalProperties": false - } - } - }, - "additionalProperties": false - }, - "external": { - "type": [ - "boolean", - "object" - ], - "properties": { - "name": { - "type": "string" - } - }, - "additionalProperties": false - }, - "internal": { - "type": "boolean" - }, - "attachable": { - "type": "boolean" - }, - "labels": { - "$ref": "#/definitions/list_or_dict" - } - }, - "patternProperties": { - "^x-": {} - }, - "additionalProperties": false - }, - "volume": { - "id": "#/definitions/volume", - "type": [ - "object", - "null" - ], - "properties": { - "name": { - "type": "string" - }, - "driver": { - "type": "string" - }, - "driver_opts": { - "type": "object", - "patternProperties": { - "^.+$": { - "type": [ - "string", - "number" - ] - } - } - }, - "external": { - "type": [ - "boolean", - "object" - ], - "properties": { - "name": { - "type": "string" - } - }, - "additionalProperties": false - }, - "labels": { - "$ref": "#/definitions/list_or_dict" - } - }, - "patternProperties": { - "^x-": {} - }, - "additionalProperties": false - }, - "secret": { - "id": "#/definitions/secret", - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "file": { - "type": "string" - }, - "external": { - "type": [ - "boolean", - "object" - ], - "properties": { - "name": { - "type": "string" - } - } - }, - "labels": { - "$ref": "#/definitions/list_or_dict" - } - }, - "patternProperties": { - "^x-": {} - }, - "additionalProperties": false - }, - "config": { - "id": "#/definitions/config", - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "file": { - "type": "string" - }, - "external": { - "type": [ - "boolean", - "object" - ], - "properties": { - "name": { - "type": "string" - } - } - }, - "labels": { - "$ref": "#/definitions/list_or_dict" - } - }, - "patternProperties": { - "^x-": {} - }, - "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/config_schema_v3.8.json b/compose/config/config_schema_v4.0.json similarity index 89% rename from compose/config/config_schema_v3.8.json rename to compose/config/config_schema_v4.0.json index e9bc20cee69..316fbf9a09d 100644 --- a/compose/config/config_schema_v3.8.json +++ b/compose/config/config_schema_v4.0.json @@ -1,10 +1,7 @@ { "$schema": "http://json-schema.org/draft-04/schema#", - "id": "config_schema_v3.8.json", + "id": "config_schema_v4.0.json", "type": "object", - "required": [ - "version" - ], "properties": { "version": { "type": "string" @@ -190,7 +187,26 @@ "additionalProperties": false }, "depends_on": { - "$ref": "#/definitions/list_of_strings" + "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", @@ -199,6 +215,13 @@ }, "uniqueItems": true }, + "dns_opt": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + }, "dns": { "$ref": "#/definitions/string_or_list" }, @@ -238,6 +261,23 @@ }, "uniqueItems": true }, + "extends": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + + "properties": { + "service": {"type": "string"}, + "file": {"type": "string"} + }, + "required": ["service"], + "additionalProperties": false + } + ] + }, "external_links": { "type": "array", "items": { @@ -300,6 +340,10 @@ "mac_address": { "type": "string" }, + "mem_limit": {"type": ["number", "string"]}, + "mem_reservation": {"type": ["string", "integer"]}, + "mem_swappiness": {"type": "integer"}, + "memswap_limit": {"type": ["number", "string"]}, "network_mode": { "type": "string" }, @@ -324,7 +368,11 @@ }, "ipv6_address": { "type": "string" - } + }, + "link_local_ips": { + "$ref": "#/definitions/list_of_strings" + }, + "priority": {"type": "number"} }, "additionalProperties": false }, @@ -338,6 +386,15 @@ } ] }, + "oom_kill_disable": {"type": "boolean"}, + "oom_score_adj": {"type": "integer", "minimum": -1000, "maximum": 1000}, + "group_add": { + "type": "array", + "items": { + "type": ["string", "number"] + }, + "uniqueItems": true + }, "pid": { "type": [ "string", @@ -387,6 +444,12 @@ "restart": { "type": "string" }, + "runtime": { + "type": "string" + }, + "scale": { + "type": "integer" + }, "security_opt": { "type": "array", "items": { @@ -543,6 +606,13 @@ "uniqueItems": true } }, + "volumes_from": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + }, "working_dir": { "type": "string" } @@ -810,11 +880,28 @@ "type": "object", "properties": { "subnet": { - "type": "string" + "type": "string", + "format": "subnet_ip_address" + }, + "ip_range": {"type": "string"}, + "gateway": {"type": "string"}, + "aux_addresses": { + "type": "object", + "patternProperties": { + "^.+$": {"type": "string"} + }, + "additionalProperties": false } }, "additionalProperties": false } + }, + "options": { + "type": "object", + "patternProperties": { + "^.+$": {"type": "string"} + }, + "additionalProperties": false } }, "additionalProperties": false diff --git a/compose/config/interpolation.py b/compose/config/interpolation.py index 9f0fc48472d..bfa6a56c9a6 100644 --- a/compose/config/interpolation.py +++ b/compose/config/interpolation.py @@ -3,7 +3,7 @@ from string import Template from .errors import ConfigurationError -from compose.const import COMPOSEFILE_V2_0 as V2_0 +from compose.const import COMPOSEFILE_V1 as V1 from compose.utils import parse_bytes from compose.utils import parse_nanoseconds_int @@ -25,7 +25,7 @@ def interpolate(self, string): def interpolate_environment_variables(version, config, section, environment): - if version <= V2_0: + if version == V1: interpolator = Interpolator(Template, environment) else: interpolator = Interpolator(TemplateWithDefaults, environment) diff --git a/compose/config/serialize.py b/compose/config/serialize.py index fe0d007a60f..a162e387338 100644 --- a/compose/config/serialize.py +++ b/compose/config/serialize.py @@ -2,12 +2,7 @@ from compose.config import types from compose.const import COMPOSEFILE_V1 as V1 -from compose.const import COMPOSEFILE_V2_1 as V2_1 -from compose.const import COMPOSEFILE_V2_3 as V2_3 -from compose.const import COMPOSEFILE_V3_0 as V3_0 -from compose.const import COMPOSEFILE_V3_2 as V3_2 -from compose.const import COMPOSEFILE_V3_4 as V3_4 -from compose.const import COMPOSEFILE_V3_5 as V3_5 +from compose.const import COMPOSEFILE_V4 as VERSION def serialize_config_type(dumper, data): @@ -49,7 +44,7 @@ def serialize_string_escape_dollar(dumper, data): def denormalize_config(config, image_digests=None): - result = {'version': str(V2_1) if config.version == V1 else str(config.version)} + result = {'version': str(config.version)} denormalized_services = [ denormalize_service_dict( service_dict, @@ -72,25 +67,11 @@ def denormalize_config(config, image_digests=None): del conf['external_name'] if 'name' in conf: - if config.version < V2_1 or ( - config.version >= V3_0 and config.version < v3_introduced_name_key(key)): - del conf['name'] - elif 'external' in conf: + if 'external' in conf: conf['external'] = bool(conf['external']) - - if 'attachable' in conf and config.version < V3_2: - # For compatibility mode, this option is invalid in v2 - del conf['attachable'] - return result -def v3_introduced_name_key(key): - if key == 'volumes': - return V3_4 - return V3_5 - - def serialize_config(config, image_digests=None, escape_dollar=True): if escape_dollar: yaml.SafeDumper.add_representer(str, serialize_string_escape_dollar) @@ -140,7 +121,7 @@ def denormalize_service_dict(service_dict, version, image_digest=None): 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 or version >= V3_0): + if 'depends_on' in service_dict: service_dict['depends_on'] = sorted([ svc for svc in service_dict['depends_on'].keys() ]) @@ -162,10 +143,10 @@ def denormalize_service_dict(service_dict, version, image_digest=None): if 'ports' in service_dict: service_dict['ports'] = [ - p.legacy_repr() if p.external_ip or version < V3_2 else p + p.legacy_repr() if p.external_ip or version < VERSION else p for p in service_dict['ports'] ] - if 'volumes' in service_dict and (version < V2_3 or (version > V3_0 and version < V3_2)): + if 'volumes' in service_dict and (version == V1): service_dict['volumes'] = [ v.legacy_repr() if isinstance(v, types.MountSpec) else v for v in service_dict['volumes'] ] diff --git a/compose/config/validation.py b/compose/config/validation.py index 3161b239533..f52de03de0a 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -435,15 +435,29 @@ def process_config_schema_errors(error): return handle_generic_error(error, path) -def validate_against_config_schema(config_file): - schema = load_jsonschema(config_file) +def keys_to_str(config_file): + """ + Non-string keys may break validator with patterned fields. + """ + d = {} + for k, v in config_file.items(): + d[str(k)] = v + if isinstance(v, dict): + d[str(k)] = keys_to_str(v) + return d + + +def validate_against_config_schema(config_file, version): + schema = load_jsonschema(version) + config = keys_to_str(config_file.config) + format_checker = FormatChecker(["ports", "expose", "subnet_ip_address"]) validator = Draft4Validator( schema, resolver=RefResolver(get_resolver_path(), schema), format_checker=format_checker) handle_errors( - validator.iter_errors(config_file.config), + validator.iter_errors(config), process_config_schema_errors, config_file.filename) @@ -453,7 +467,7 @@ def handler(errors): return process_service_constraint_errors( errors, service_name, config_file.version) - schema = load_jsonschema(config_file) + schema = load_jsonschema(config_file.version) validator = Draft4Validator(schema['definitions']['constraints']['service']) handle_errors(validator.iter_errors(config), handler, None) @@ -472,16 +486,15 @@ def get_schema_path(): return os.path.dirname(os.path.abspath(__file__)) -def load_jsonschema(config_file): +def load_jsonschema(version): filename = os.path.join( get_schema_path(), - "config_schema_v{0}.json".format(config_file.version)) + "config_schema_v{0}.json".format(version)) if not os.path.exists(filename): raise ConfigurationError( 'Version in "{}" is unsupported. {}' - .format(config_file.filename, VERSION_EXPLANATION)) - + .format(filename, VERSION_EXPLANATION)) with open(filename, "r") as fh: return json.load(fh) diff --git a/compose/const.py b/compose/const.py index d80e23c9ef8..9623769d101 100644 --- a/compose/const.py +++ b/compose/const.py @@ -24,56 +24,16 @@ WINDOWS_LONGPATH_PREFIX = '\\\\?\\' COMPOSEFILE_V1 = ComposeVersion('1') -COMPOSEFILE_V2_0 = ComposeVersion('2.0') -COMPOSEFILE_V2_1 = ComposeVersion('2.1') -COMPOSEFILE_V2_2 = ComposeVersion('2.2') -COMPOSEFILE_V2_3 = ComposeVersion('2.3') -COMPOSEFILE_V2_4 = ComposeVersion('2.4') - -COMPOSEFILE_V3_0 = ComposeVersion('3.0') -COMPOSEFILE_V3_1 = ComposeVersion('3.1') -COMPOSEFILE_V3_2 = ComposeVersion('3.2') -COMPOSEFILE_V3_3 = ComposeVersion('3.3') -COMPOSEFILE_V3_4 = ComposeVersion('3.4') -COMPOSEFILE_V3_5 = ComposeVersion('3.5') -COMPOSEFILE_V3_6 = ComposeVersion('3.6') -COMPOSEFILE_V3_7 = ComposeVersion('3.7') -COMPOSEFILE_V3_8 = ComposeVersion('3.8') +COMPOSEFILE_V4 = ComposeVersion('4.0') # minimum DOCKER ENGINE API version needed to support # features for each compose schema version API_VERSIONS = { COMPOSEFILE_V1: '1.21', - COMPOSEFILE_V2_0: '1.22', - COMPOSEFILE_V2_1: '1.24', - COMPOSEFILE_V2_2: '1.25', - COMPOSEFILE_V2_3: '1.30', - COMPOSEFILE_V2_4: '1.35', - COMPOSEFILE_V3_0: '1.25', - COMPOSEFILE_V3_1: '1.25', - COMPOSEFILE_V3_2: '1.25', - COMPOSEFILE_V3_3: '1.30', - COMPOSEFILE_V3_4: '1.30', - COMPOSEFILE_V3_5: '1.30', - COMPOSEFILE_V3_6: '1.36', - COMPOSEFILE_V3_7: '1.38', - COMPOSEFILE_V3_8: '1.38', + COMPOSEFILE_V4: '1.38', } 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_V2_2]: '1.13.0', - API_VERSIONS[COMPOSEFILE_V2_3]: '17.06.0', - API_VERSIONS[COMPOSEFILE_V2_4]: '17.12.0', - API_VERSIONS[COMPOSEFILE_V3_0]: '1.13.0', - API_VERSIONS[COMPOSEFILE_V3_1]: '1.13.0', - API_VERSIONS[COMPOSEFILE_V3_2]: '1.13.0', - API_VERSIONS[COMPOSEFILE_V3_3]: '17.06.0', - API_VERSIONS[COMPOSEFILE_V3_4]: '17.06.0', - API_VERSIONS[COMPOSEFILE_V3_5]: '17.06.0', - API_VERSIONS[COMPOSEFILE_V3_6]: '18.02.0', - API_VERSIONS[COMPOSEFILE_V3_7]: '18.06.0', - API_VERSIONS[COMPOSEFILE_V3_8]: '18.06.0', + API_VERSIONS[COMPOSEFILE_V4]: '18.06.0', } diff --git a/docker-compose.spec b/docker-compose.spec index 4c8db3e51b9..35195f9b409 100644 --- a/docker-compose.spec +++ b/docker-compose.spec @@ -23,73 +23,8 @@ exe = EXE(pyz, 'DATA' ), ( - 'compose/config/config_schema_v2.0.json', - 'compose/config/config_schema_v2.0.json', - 'DATA' - ), - ( - 'compose/config/config_schema_v2.1.json', - 'compose/config/config_schema_v2.1.json', - 'DATA' - ), - ( - 'compose/config/config_schema_v2.2.json', - 'compose/config/config_schema_v2.2.json', - 'DATA' - ), - ( - 'compose/config/config_schema_v2.3.json', - 'compose/config/config_schema_v2.3.json', - 'DATA' - ), - ( - 'compose/config/config_schema_v2.4.json', - 'compose/config/config_schema_v2.4.json', - 'DATA' - ), - ( - 'compose/config/config_schema_v3.0.json', - 'compose/config/config_schema_v3.0.json', - 'DATA' - ), - ( - 'compose/config/config_schema_v3.1.json', - 'compose/config/config_schema_v3.1.json', - 'DATA' - ), - ( - 'compose/config/config_schema_v3.2.json', - 'compose/config/config_schema_v3.2.json', - 'DATA' - ), - ( - 'compose/config/config_schema_v3.3.json', - 'compose/config/config_schema_v3.3.json', - 'DATA' - ), - ( - 'compose/config/config_schema_v3.4.json', - 'compose/config/config_schema_v3.4.json', - 'DATA' - ), - ( - 'compose/config/config_schema_v3.5.json', - 'compose/config/config_schema_v3.5.json', - 'DATA' - ), - ( - 'compose/config/config_schema_v3.6.json', - 'compose/config/config_schema_v3.6.json', - 'DATA' - ), - ( - 'compose/config/config_schema_v3.7.json', - 'compose/config/config_schema_v3.7.json', - 'DATA' - ), - ( - 'compose/config/config_schema_v3.8.json', - 'compose/config/config_schema_v3.8.json', + 'compose/config/config_schema_v4.0.json', + 'compose/config/config_schema_v4.0.json', 'DATA' ), ( diff --git a/docker-compose_darwin.spec b/docker-compose_darwin.spec index df7fcdd6f66..f4642314ebe 100644 --- a/docker-compose_darwin.spec +++ b/docker-compose_darwin.spec @@ -32,73 +32,8 @@ coll = COLLECT(exe, 'DATA' ), ( - 'compose/config/config_schema_v2.0.json', - 'compose/config/config_schema_v2.0.json', - 'DATA' - ), - ( - 'compose/config/config_schema_v2.1.json', - 'compose/config/config_schema_v2.1.json', - 'DATA' - ), - ( - 'compose/config/config_schema_v2.2.json', - 'compose/config/config_schema_v2.2.json', - 'DATA' - ), - ( - 'compose/config/config_schema_v2.3.json', - 'compose/config/config_schema_v2.3.json', - 'DATA' - ), - ( - 'compose/config/config_schema_v2.4.json', - 'compose/config/config_schema_v2.4.json', - 'DATA' - ), - ( - 'compose/config/config_schema_v3.0.json', - 'compose/config/config_schema_v3.0.json', - 'DATA' - ), - ( - 'compose/config/config_schema_v3.1.json', - 'compose/config/config_schema_v3.1.json', - 'DATA' - ), - ( - 'compose/config/config_schema_v3.2.json', - 'compose/config/config_schema_v3.2.json', - 'DATA' - ), - ( - 'compose/config/config_schema_v3.3.json', - 'compose/config/config_schema_v3.3.json', - 'DATA' - ), - ( - 'compose/config/config_schema_v3.4.json', - 'compose/config/config_schema_v3.4.json', - 'DATA' - ), - ( - 'compose/config/config_schema_v3.5.json', - 'compose/config/config_schema_v3.5.json', - 'DATA' - ), - ( - 'compose/config/config_schema_v3.6.json', - 'compose/config/config_schema_v3.6.json', - 'DATA' - ), - ( - 'compose/config/config_schema_v3.7.json', - 'compose/config/config_schema_v3.7.json', - 'DATA' - ), - ( - 'compose/config/config_schema_v3.8.json', - 'compose/config/config_schema_v3.8.json', + 'compose/config/config_schema_v4.0.json', + 'compose/config/config_schema_v4.0.json', 'DATA' ), ( diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index c84d3f8cb99..51f6428beb0 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -20,6 +20,8 @@ from ..helpers import create_host_file from compose.cli.command import get_project from compose.config.errors import DuplicateOverrideFileFound +from compose.const import COMPOSEFILE_V1 as V1 +from compose.const import COMPOSEFILE_V4 as VERSION from compose.container import Container from compose.project import OneOffFilter from compose.utils import nanoseconds_from_time_seconds @@ -29,10 +31,6 @@ from tests.integration.testcases import no_cluster from tests.integration.testcases import pull_busybox from tests.integration.testcases import SWARM_SKIP_RM_VOLUMES -from tests.integration.testcases import v2_1_only -from tests.integration.testcases import v2_2_only -from tests.integration.testcases import v2_only -from tests.integration.testcases import v3_only DOCKER_COMPOSE_EXECUTABLE = 'docker-compose' @@ -42,7 +40,7 @@ BUILD_CACHE_TEXT = 'Using cache' BUILD_PULL_TEXT = 'Status: Image is up to date for busybox:1.27.2' COMPOSE_COMPATIBILITY_DICT = { - 'version': '2.3', + 'version': str(VERSION), 'volumes': {'foo': {'driver': 'default'}}, 'networks': {'bar': {}}, 'services': { @@ -287,7 +285,7 @@ def test_config_default(self): output = yaml.safe_load(result.stdout) expected = { - 'version': '2.0', + 'version': str(VERSION), 'volumes': {'data': {'driver': 'local'}}, 'networks': {'front': {}}, 'services': { @@ -311,7 +309,7 @@ def test_config_restart(self): self.base_dir = 'tests/fixtures/restart' result = self.dispatch(['config']) assert yaml.safe_load(result.stdout) == { - 'version': '2.0', + 'version': str(VERSION), 'services': { 'never': { 'image': 'busybox', @@ -343,10 +341,12 @@ def test_config_external_network(self): assert 'networks' in json_result assert json_result['networks'] == { 'networks_foo': { - 'external': True # {'name': 'networks_foo'} + 'external': True, + 'name': 'networks_foo' }, 'bar': { - 'external': {'name': 'networks_bar'} + 'external': True, + 'name': 'networks_bar' } } @@ -355,14 +355,14 @@ def test_config_with_dot_env(self): result = self.dispatch(['config']) json_result = yaml.safe_load(result.stdout) assert json_result == { + 'version': str(VERSION), 'services': { 'web': { 'command': 'true', 'image': 'alpine:latest', - 'ports': ['5643/tcp', '9999/tcp'] + 'ports': [{'target': 5643}, {'target': 9999}] } - }, - 'version': '2.4' + } } def test_config_with_env_file(self): @@ -370,14 +370,14 @@ def test_config_with_env_file(self): result = self.dispatch(['--env-file', '.env2', 'config']) json_result = yaml.safe_load(result.stdout) assert json_result == { + 'version': str(VERSION), 'services': { 'web': { 'command': 'false', 'image': 'alpine:latest', - 'ports': ['5644/tcp', '9998/tcp'] + 'ports': [{'target': 5644}, {'target': 9998}] } - }, - 'version': '2.4' + } } def test_config_with_dot_env_and_override_dir(self): @@ -385,14 +385,14 @@ def test_config_with_dot_env_and_override_dir(self): result = self.dispatch(['--project-directory', 'alt/', 'config']) json_result = yaml.safe_load(result.stdout) assert json_result == { + 'version': str(VERSION), 'services': { 'web': { 'command': 'echo uwu', 'image': 'alpine:3.10.1', - 'ports': ['3341/tcp', '4449/tcp'] + 'ports': [{'target': 3341}, {'target': 4449}] } - }, - 'version': '2.4' + } } def test_config_external_volume_v2(self): @@ -403,11 +403,11 @@ def test_config_external_volume_v2(self): assert json_result['volumes'] == { 'foo': { 'external': True, + 'name': 'foo', }, 'bar': { - 'external': { - 'name': 'some_bar', - }, + 'external': True, + 'name': 'some_bar', } } @@ -435,11 +435,11 @@ def test_config_external_volume_v3_x(self): assert json_result['volumes'] == { 'foo': { 'external': True, + 'name': 'foo', }, 'bar': { - 'external': { - 'name': 'some_bar', - }, + 'external': True, + 'name': 'some_bar', } } @@ -479,7 +479,7 @@ def test_config_v1(self): self.base_dir = 'tests/fixtures/v1-config' result = self.dispatch(['config']) assert yaml.safe_load(result.stdout) == { - 'version': '2.1', + 'version': str(V1), 'services': { 'net': { 'image': 'busybox', @@ -498,13 +498,11 @@ def test_config_v1(self): }, } - @v3_only() def test_config_v3(self): self.base_dir = 'tests/fixtures/v3-full' result = self.dispatch(['config']) - assert yaml.safe_load(result.stdout) == { - 'version': '3.5', + 'version': str(VERSION), 'volumes': { 'foobar': { 'labels': { @@ -576,12 +574,14 @@ def test_config_v3(self): }, } + @pytest.mark.skip(reason='deprecated option') def test_config_compatibility_mode(self): self.base_dir = 'tests/fixtures/compatibility-mode' result = self.dispatch(['--compatibility', 'config']) assert yaml.load(result.stdout) == COMPOSE_COMPATIBILITY_DICT + @pytest.mark.skip(reason='deprecated option') @mock.patch.dict(os.environ) def test_config_compatibility_mode_from_env(self): self.base_dir = 'tests/fixtures/compatibility-mode' @@ -590,6 +590,7 @@ def test_config_compatibility_mode_from_env(self): assert yaml.load(result.stdout) == COMPOSE_COMPATIBILITY_DICT + @pytest.mark.skip(reason='deprecated option') @mock.patch.dict(os.environ) def test_config_compatibility_mode_from_env_and_option_precedence(self): self.base_dir = 'tests/fixtures/compatibility-mode' @@ -1018,7 +1019,6 @@ def test_down_invalid_rmi_flag(self): result = self.dispatch(['down', '--rmi', 'bogus'], returncode=1) assert '--rmi flag must be' in result.stderr - @v2_only() def test_down(self): self.base_dir = 'tests/fixtures/v2-full' @@ -1103,7 +1103,6 @@ def test_up_attached(self): assert '{} exited with code 0'.format(simple_name) in result.stdout assert '{} exited with code 0'.format(another_name) in result.stdout - @v2_only() def test_up(self): self.base_dir = 'tests/fixtures/v2-simple' self.dispatch(['up', '-d'], None) @@ -1135,7 +1134,6 @@ def test_up(self): for service in services: assert self.lookup(container, service.name) - @v2_only() def test_up_no_start(self): self.base_dir = 'tests/fixtures/v2-full' self.dispatch(['up', '--no-start'], None) @@ -1166,7 +1164,6 @@ def test_up_no_start(self): ] assert len(remote_volumes) > 0 - @v2_only() def test_up_no_start_remove_orphans(self): self.base_dir = 'tests/fixtures/v2-simple' self.dispatch(['up', '--no-start'], None) @@ -1182,7 +1179,6 @@ def test_up_no_start_remove_orphans(self): stopped=True) + next.containers(stopped=True)), services) assert len(stopped2) == 1 - @v2_only() def test_up_no_ansi(self): self.base_dir = 'tests/fixtures/v2-simple' result = self.dispatch(['--no-ansi', 'up', '-d'], None) @@ -1190,7 +1186,6 @@ def test_up_no_ansi(self): assert "%c[1A" % 27 not in result.stderr assert "%c[1B" % 27 not in result.stderr - @v2_only() def test_up_with_default_network_config(self): filename = 'default-network-config.yml' @@ -1204,7 +1199,6 @@ def test_up_with_default_network_config(self): 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' @@ -1232,7 +1226,6 @@ def test_up_with_network_aliases(self): 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' @@ -1250,7 +1243,6 @@ def test_up_with_network_internal(self): assert networks[0]['Internal'] is True - @v2_only() def test_up_with_network_static_addresses(self): filename = 'network-static-addresses.yml' ipv4_address = '172.16.100.100' @@ -1274,7 +1266,6 @@ def test_up_with_network_static_addresses(self): 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' self.dispatch(['up', '-d'], None) @@ -1322,7 +1313,6 @@ def test_up_with_networks(self): # 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' @@ -1332,7 +1322,6 @@ def test_up_missing_network(self): assert 'Service "web" uses an undefined network "foo"' in result.stderr - @v2_only() @no_cluster('container networks not supported in Swarm') def test_up_with_network_mode(self): c = self.client.create_container( @@ -1371,7 +1360,6 @@ def test_up_with_network_mode(self): 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' @@ -1395,7 +1383,6 @@ def test_up_external_networks(self): 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' @@ -1418,7 +1405,6 @@ def test_up_with_external_default_network(self): 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' @@ -1438,7 +1424,6 @@ def test_up_with_network_labels(self): 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): filename = 'volume-label.yml' @@ -1458,7 +1443,6 @@ def test_up_with_volume_labels(self): assert 'label_key' in volumes[0]['Labels'] assert volumes[0]['Labels']['label_key'] == 'label_val' - @v2_only() def test_up_no_services(self): self.base_dir = 'tests/fixtures/no-services' self.dispatch(['up', '-d'], None) @@ -1515,7 +1499,6 @@ def test_up_with_net_v1(self): bar_container.id ) - @v3_only() def test_up_with_healthcheck(self): def wait_on_health_status(container, status): def condition(): @@ -1649,7 +1632,6 @@ def test_up_handles_sigterm(self): 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']) @@ -1674,7 +1656,6 @@ def test_up_handles_abort_on_container_exit_code(self): proc.wait() assert proc.returncode == 1 - @v2_only() @no_cluster('Container PID mode does not work across clusters') def test_up_with_pid_mode(self): c = self.client.create_container( @@ -1738,7 +1719,6 @@ def test_exec_custom_user(self): assert stdout == "operator\n" assert stderr == "" - @v3_only() def test_exec_workdir(self): self.base_dir = 'tests/fixtures/links-composefile' os.environ['COMPOSE_API_VERSION'] = '1.35' @@ -1748,7 +1728,6 @@ def test_exec_workdir(self): stdout, stderr = self.dispatch(['exec', '-T', '--workdir', '/etc', 'console', 'ls']) assert 'passwd' in stdout - @v2_2_only() def test_exec_service_with_environment_overridden(self): name = 'service' self.base_dir = 'tests/fixtures/environment-exec' @@ -1793,7 +1772,6 @@ def test_run_service_with_links(self): assert len(db.containers()) == 1 assert 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) @@ -2105,7 +2083,6 @@ def test_run_service_with_workdir_overridden_short_form(self): container = service.containers(stopped=True, one_off=True)[0] assert workdir == container.get('Config.WorkingDir') - @v2_only() def test_run_service_with_use_aliases(self): filename = 'network-aliases.yml' self.base_dir = 'tests/fixtures/networks' @@ -2127,7 +2104,6 @@ def test_run_service_with_use_aliases(self): assert 'forward_facing' in front_aliases assert 'ahead' in front_aliases - @v2_only() def test_run_interactive_connects_to_network(self): self.base_dir = 'tests/fixtures/networks' @@ -2153,7 +2129,6 @@ def test_run_interactive_connects_to_network(self): aliases = set(config['Aliases'] or []) - {container.short_id} assert not aliases - @v2_only() def test_run_detached_connects_to_network(self): self.base_dir = 'tests/fixtures/networks' self.dispatch(['up', '-d']) @@ -2332,7 +2307,6 @@ def test_start_no_containers(self): assert 'failed' in result.stderr assert 'No containers to start' in result.stderr - @v2_only() def test_up_logging(self): self.base_dir = 'tests/fixtures/logging-composefile' self.dispatch(['up', '-d']) @@ -2563,11 +2537,6 @@ def test_scale(self): assert len(project.get_service('simple').containers()) == 0 assert len(project.get_service('another').containers()) == 0 - def test_scale_v2_2(self): - self.base_dir = 'tests/fixtures/scale' - result = self.dispatch(['scale', 'web=1'], returncode=1) - assert 'incompatible with the v2.2 format' in result.stderr - def test_up_scale_scale_up(self): self.base_dir = 'tests/fixtures/scale' project = self.project diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 19d27185c9b..cb40dbd43f2 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -21,11 +21,7 @@ from compose.config import types from compose.config.types import VolumeFromSpec from compose.config.types import VolumeSpec -from compose.const import COMPOSEFILE_V2_0 as V2_0 -from compose.const import COMPOSEFILE_V2_1 as V2_1 -from compose.const import COMPOSEFILE_V2_2 as V2_2 -from compose.const import COMPOSEFILE_V2_3 as V2_3 -from compose.const import COMPOSEFILE_V3_1 as V3_1 +from compose.const import COMPOSEFILE_V4 as VERSION from compose.const import LABEL_PROJECT from compose.const import LABEL_SERVICE from compose.container import Container @@ -37,16 +33,11 @@ from tests.integration.testcases import if_runtime_available from tests.integration.testcases import is_cluster from tests.integration.testcases import no_cluster -from tests.integration.testcases import v2_1_only -from tests.integration.testcases import v2_2_only -from tests.integration.testcases import v2_3_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'), + version=kwargs.get('version', VERSION), services=kwargs.get('services'), volumes=kwargs.get('volumes'), networks=kwargs.get('networks'), @@ -106,7 +97,6 @@ def test_containers_with_extra_service(self): def test_parallel_pull_with_no_image(self): config_data = build_config( - version=V2_3, services=[{ 'name': 'web', 'build': {'context': '.'}, @@ -162,14 +152,12 @@ def test_volumes_from_container(self): db = project.get_service('db') assert db._get_volumes_from() == [data_container.id + ':rw'] - @v2_only() @no_cluster('container networks not supported in Swarm') def test_network_mode_from_service(self): project = Project.from_config( name='composetest', client=self.client, config_data=load_config({ - 'version': str(V2_0), 'services': { 'net': { 'image': BUSYBOX_IMAGE_WITH_TAG, @@ -190,14 +178,12 @@ def test_network_mode_from_service(self): net = project.get_service('net') assert web.network_mode.mode == 'container:' + net.containers()[0].id - @v2_only() @no_cluster('container networks not supported in Swarm') def test_network_mode_from_container(self): def get_project(): return Project.from_config( name='composetest', config_data=load_config({ - 'version': str(V2_0), 'services': { 'web': { 'image': BUSYBOX_IMAGE_WITH_TAG, @@ -448,7 +434,6 @@ def test_recreate_preserves_volumes(self): assert db_container.id != old_db_id assert db_container.get('Volumes./etc') == db_volume_path - @v2_3_only() def test_recreate_preserves_mounts(self): web = self.create_service('web') db = self.create_service('db', volumes=[types.MountSpec(type='volume', target='/etc')]) @@ -656,10 +641,8 @@ def test_unscale_after_restart(self): service = project.get_service('web') assert len(service.containers()) == 1 - @v2_only() def test_project_up_networks(self): config_data = build_config( - version=V2_0, services=[{ 'name': 'web', 'image': BUSYBOX_IMAGE_WITH_TAG, @@ -701,10 +684,8 @@ def test_project_up_networks(self): foo_data = self.client.inspect_network('composetest_foo') assert foo_data['Driver'] == 'bridge' - @v2_only() def test_up_with_ipam_config(self): config_data = build_config( - version=V2_0, services=[{ 'name': 'web', 'image': BUSYBOX_IMAGE_WITH_TAG, @@ -761,10 +742,8 @@ def test_up_with_ipam_config(self): }], } - @v2_only() def test_up_with_ipam_options(self): config_data = build_config( - version=V2_0, services=[{ 'name': 'web', 'image': BUSYBOX_IMAGE_WITH_TAG, @@ -796,10 +775,8 @@ def test_up_with_ipam_options(self): "com.docker.compose.network.test": "9-29-045" } - @v2_1_only() def test_up_with_network_static_addresses(self): config_data = build_config( - version=V2_1, services=[{ 'name': 'web', 'image': BUSYBOX_IMAGE_WITH_TAG, @@ -845,13 +822,11 @@ def test_up_with_network_static_addresses(self): assert ipam_config.get('IPv4Address') == '172.16.100.100' assert ipam_config.get('IPv6Address') == 'fe80::1001:102' - @v2_3_only() def test_up_with_network_priorities(self): mac_address = '74:6f:75:68:6f:75' def get_config_data(p1, p2, p3): return build_config( - version=V2_3, services=[{ 'name': 'web', 'image': BUSYBOX_IMAGE_WITH_TAG, @@ -910,11 +885,9 @@ def get_config_data(p1, p2, p3): net_config = service_container.inspect()['NetworkSettings']['Networks']['composetest_n3'] assert net_config['MacAddress'] == mac_address - @v2_1_only() def test_up_with_enable_ipv6(self): self.require_api_version('1.23') config_data = build_config( - version=V2_1, services=[{ 'name': 'web', 'image': BUSYBOX_IMAGE_WITH_TAG, @@ -954,10 +927,8 @@ def test_up_with_enable_ipv6(self): get('IPAMConfig', {})) assert ipam_config.get('IPv6Address') == 'fe80::1001:102' - @v2_only() def test_up_with_network_static_addresses_missing_subnet(self): config_data = build_config( - version=V2_0, services=[{ 'name': 'web', 'image': BUSYBOX_IMAGE_WITH_TAG, @@ -990,10 +961,8 @@ def test_up_with_network_static_addresses_missing_subnet(self): with pytest.raises(ProjectError): project.up() - @v2_1_only() def test_up_with_network_link_local_ips(self): config_data = build_config( - version=V2_1, services=[{ 'name': 'web', 'image': BUSYBOX_IMAGE_WITH_TAG, @@ -1025,10 +994,8 @@ def test_up_with_network_link_local_ips(self): assert 'LinkLocalIPs' in ipam_config assert ipam_config['LinkLocalIPs'] == ['169.254.8.8'] - @v2_1_only() def test_up_with_custom_name_resources(self): config_data = build_config( - version=V2_2, services=[{ 'name': 'web', 'volumes': [VolumeSpec.parse('foo:/container-path')], @@ -1062,11 +1029,9 @@ def test_up_with_custom_name_resources(self): assert network['Labels']['com.docker.compose.test_value'] == 'sharpdressedman' assert volume['Labels']['com.docker.compose.test_value'] == 'thefuror' - @v2_1_only() def test_up_with_isolation(self): self.require_api_version('1.24') config_data = build_config( - version=V2_1, services=[{ 'name': 'web', 'image': BUSYBOX_IMAGE_WITH_TAG, @@ -1082,11 +1047,9 @@ def test_up_with_isolation(self): service_container = project.get_service('web').containers(stopped=True)[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 = build_config( - version=V2_1, services=[{ 'name': 'web', 'image': BUSYBOX_IMAGE_WITH_TAG, @@ -1101,12 +1064,10 @@ def test_up_with_invalid_isolation(self): with pytest.raises(ProjectError): project.up() - @v2_3_only() @if_runtime_available('runc') def test_up_with_runtime(self): self.require_api_version('1.30') config_data = build_config( - version=V2_3, services=[{ 'name': 'web', 'image': BUSYBOX_IMAGE_WITH_TAG, @@ -1122,11 +1083,9 @@ def test_up_with_runtime(self): service_container = project.get_service('web').containers(stopped=True)[0] assert service_container.inspect()['HostConfig']['Runtime'] == 'runc' - @v2_3_only() def test_up_with_invalid_runtime(self): self.require_api_version('1.30') config_data = build_config( - version=V2_3, services=[{ 'name': 'web', 'image': BUSYBOX_IMAGE_WITH_TAG, @@ -1141,12 +1100,10 @@ def test_up_with_invalid_runtime(self): with pytest.raises(ProjectError): project.up() - @v2_3_only() @if_runtime_available('nvidia') def test_up_with_nvidia_runtime(self): self.require_api_version('1.30') config_data = build_config( - version=V2_3, services=[{ 'name': 'web', 'image': BUSYBOX_IMAGE_WITH_TAG, @@ -1162,11 +1119,9 @@ def test_up_with_nvidia_runtime(self): service_container = project.get_service('web').containers(stopped=True)[0] assert service_container.inspect()['HostConfig']['Runtime'] == 'nvidia' - @v2_only() def test_project_up_with_network_internal(self): self.require_api_version('1.23') config_data = build_config( - version=V2_0, services=[{ 'name': 'web', 'image': BUSYBOX_IMAGE_WITH_TAG, @@ -1188,14 +1143,12 @@ def test_project_up_with_network_internal(self): 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 = build_config( - version=V2_1, services=[{ 'name': 'web', 'image': BUSYBOX_IMAGE_WITH_TAG, @@ -1223,12 +1176,10 @@ def test_project_up_with_network_label(self): assert 'label_key' in networks[0]['Labels'] assert networks[0]['Labels']['label_key'] == 'label_val' - @v2_only() def test_project_up_volumes(self): vol_name = '{0:x}'.format(random.getrandbits(32)) full_vol_name = 'composetest_{0}'.format(vol_name) config_data = build_config( - version=V2_0, services=[{ 'name': 'web', 'image': BUSYBOX_IMAGE_WITH_TAG, @@ -1248,14 +1199,12 @@ def test_project_up_volumes(self): assert volume_data['Name'].split('/')[-1] == full_vol_name assert 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 = build_config( - version=V2_1, services=[{ 'name': 'web', 'image': BUSYBOX_IMAGE_WITH_TAG, @@ -1290,12 +1239,10 @@ def test_project_up_with_volume_labels(self): 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): base_file = config.ConfigFile( 'base.yml', { - 'version': str(V2_0), 'services': { 'simple': {'image': BUSYBOX_IMAGE_WITH_TAG, 'command': 'top'}, 'another': { @@ -1314,7 +1261,6 @@ def test_project_up_logging_with_multiple_files(self): override_file = config.ConfigFile( 'override.yml', { - 'version': str(V2_0), 'services': { 'another': { 'logging': { @@ -1342,12 +1288,10 @@ def test_project_up_logging_with_multiple_files(self): assert log_config assert log_config.get('Type') == 'none' - @v2_only() def test_project_up_port_mappings_with_multiple_files(self): base_file = config.ConfigFile( 'base.yml', { - 'version': str(V2_0), 'services': { 'simple': { 'image': BUSYBOX_IMAGE_WITH_TAG, @@ -1360,7 +1304,6 @@ def test_project_up_port_mappings_with_multiple_files(self): override_file = config.ConfigFile( 'override.yml', { - 'version': str(V2_0), 'services': { 'simple': { 'ports': ['1234:1234'] @@ -1378,10 +1321,8 @@ def test_project_up_port_mappings_with_multiple_files(self): containers = project.containers() assert len(containers) == 1 - @v2_2_only() def test_project_up_config_scale(self): config_data = build_config( - version=V2_2, services=[{ 'name': 'web', 'image': BUSYBOX_IMAGE_WITH_TAG, @@ -1406,12 +1347,10 @@ def test_project_up_config_scale(self): project.up() assert len(project.containers()) == 3 - @v2_only() def test_initialize_volumes(self): vol_name = '{0:x}'.format(random.getrandbits(32)) full_vol_name = 'composetest_{0}'.format(vol_name) config_data = build_config( - version=V2_0, services=[{ 'name': 'web', 'image': BUSYBOX_IMAGE_WITH_TAG, @@ -1430,12 +1369,10 @@ def test_initialize_volumes(self): assert volume_data['Name'].split('/')[-1] == 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 = build_config( - version=V2_0, services=[{ 'name': 'web', 'image': BUSYBOX_IMAGE_WITH_TAG, @@ -1454,12 +1391,10 @@ def test_project_up_implicit_volume_driver(self): assert volume_data['Name'].split('/')[-1] == full_vol_name assert volume_data['Driver'] == 'local' - @v3_only() def test_project_up_with_secrets(self): node = create_host_file(self.client, os.path.abspath('tests/fixtures/secrets/default')) config_data = build_config( - version=V3_1, services=[{ 'name': 'web', 'image': BUSYBOX_IMAGE_WITH_TAG, @@ -1491,12 +1426,10 @@ def test_project_up_with_secrets(self): output = container.logs() assert output == b"This is the secret\n" - @v3_only() def test_project_up_with_added_secrets(self): node = create_host_file(self.client, os.path.abspath('tests/fixtures/secrets/default')) config_input1 = { - 'version': V3_1, 'services': [ { 'name': 'web', @@ -1545,12 +1478,11 @@ def test_project_up_with_added_secrets(self): output = container.logs() assert output == b"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 = build_config( - version=V2_0, + version=VERSION, services=[{ 'name': 'web', 'image': BUSYBOX_IMAGE_WITH_TAG, @@ -1566,14 +1498,12 @@ def test_initialize_volumes_invalid_volume_driver(self): with pytest.raises(APIError if is_cluster(self.client) else config.ConfigurationError): project.volumes.initialize() - @v2_only() @no_cluster('inspect volume by name defect on Swarm Classic') def test_initialize_volumes_updated_driver(self): vol_name = '{0:x}'.format(random.getrandbits(32)) full_vol_name = 'composetest_{0}'.format(vol_name) config_data = build_config( - version=V2_0, services=[{ 'name': 'web', 'image': BUSYBOX_IMAGE_WITH_TAG, @@ -1605,7 +1535,6 @@ def test_initialize_volumes_updated_driver(self): vol_name ) in str(e.value) - @v2_only() @no_cluster('inspect volume by name defect on Swarm Classic') def test_initialize_volumes_updated_driver_opts(self): vol_name = '{0:x}'.format(random.getrandbits(32)) @@ -1615,7 +1544,6 @@ def test_initialize_volumes_updated_driver_opts(self): driver_opts = {'o': 'bind', 'device': tmpdir, 'type': 'none'} config_data = build_config( - version=V2_0, services=[{ 'name': 'web', 'image': BUSYBOX_IMAGE_WITH_TAG, @@ -1651,13 +1579,11 @@ def test_initialize_volumes_updated_driver_opts(self): vol_name, driver_opts['device'] ) in str(e.value) - @v2_only() def test_initialize_volumes_updated_blank_driver(self): vol_name = '{0:x}'.format(random.getrandbits(32)) full_vol_name = 'composetest_{0}'.format(vol_name) config_data = build_config( - version=V2_0, services=[{ 'name': 'web', 'image': BUSYBOX_IMAGE_WITH_TAG, @@ -1688,7 +1614,6 @@ def test_initialize_volumes_updated_blank_driver(self): assert volume_data['Name'].split('/')[-1] == full_vol_name assert volume_data['Driver'] == 'local' - @v2_only() @no_cluster('inspect volume by name defect on Swarm Classic') def test_initialize_volumes_external_volumes(self): # Use composetest_ prefix so it gets garbage-collected in tearDown() @@ -1696,7 +1621,6 @@ def test_initialize_volumes_external_volumes(self): full_vol_name = 'composetest_{0}'.format(vol_name) self.client.create_volume(vol_name) config_data = build_config( - version=V2_0, services=[{ 'name': 'web', 'image': BUSYBOX_IMAGE_WITH_TAG, @@ -1715,12 +1639,10 @@ def test_initialize_volumes_external_volumes(self): with pytest.raises(NotFound): self.client.inspect_volume(full_vol_name) - @v2_only() def test_initialize_volumes_inexistent_external_volume(self): vol_name = '{0:x}'.format(random.getrandbits(32)) config_data = build_config( - version=V2_0, services=[{ 'name': 'web', 'image': BUSYBOX_IMAGE_WITH_TAG, @@ -1740,7 +1662,6 @@ def test_initialize_volumes_inexistent_external_volume(self): vol_name ) in str(e.value) - @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) @@ -1748,7 +1669,6 @@ def test_project_up_named_volumes_in_binds(self): base_file = config.ConfigFile( 'base.yml', { - 'version': str(V2_0), 'services': { 'simple': { 'image': BUSYBOX_IMAGE_WITH_TAG, @@ -1839,7 +1759,6 @@ def test_project_up_ignore_orphans(self): mock_log.warning.assert_not_called() - @v2_1_only() def test_project_up_healthy_dependency(self): config_dict = { 'version': '2.1', @@ -1876,7 +1795,6 @@ def test_project_up_healthy_dependency(self): 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', @@ -1915,7 +1833,6 @@ def test_project_up_unhealthy_dependency(self): with pytest.raises(HealthCheckFailed): svc1.is_healthy() - @v2_1_only() def test_project_up_no_healthcheck_dependency(self): config_dict = { 'version': '2.1', diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index f787923adde..e6de0b921c4 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -12,13 +12,7 @@ from compose.config.environment import Environment from compose.const import API_VERSIONS from compose.const import COMPOSEFILE_V1 as V1 -from compose.const import COMPOSEFILE_V2_0 as V2_0 -from compose.const import COMPOSEFILE_V2_0 as V2_1 -from compose.const import COMPOSEFILE_V2_2 as V2_2 -from compose.const import COMPOSEFILE_V2_3 as V2_3 -from compose.const import COMPOSEFILE_V3_0 as V3_0 -from compose.const import COMPOSEFILE_V3_2 as V3_2 -from compose.const import COMPOSEFILE_V3_5 as V3_5 +from compose.const import COMPOSEFILE_V4 as VERSION from compose.const import LABEL_PROJECT from compose.progress_stream import stream_output from compose.service import Service @@ -45,17 +39,11 @@ def format_link(link): def engine_max_version(): if 'DOCKER_VERSION' not in os.environ: - return V3_5 + return VERSION version = os.environ['DOCKER_VERSION'].partition('-')[0] if version_lt(version, '1.10'): return V1 - if version_lt(version, '1.12'): - return V2_0 - if version_lt(version, '1.13'): - return V2_1 - if version_lt(version, '17.06'): - return V3_2 - return V3_5 + return VERSION def min_version_skip(version): @@ -66,23 +54,23 @@ def min_version_skip(version): def v2_only(): - return min_version_skip(V2_0) + return min_version_skip(VERSION) def v2_1_only(): - return min_version_skip(V2_1) + return min_version_skip(VERSION) def v2_2_only(): - return min_version_skip(V2_2) + return min_version_skip(VERSION) def v2_3_only(): - return min_version_skip(V2_3) + return min_version_skip(VERSION) def v3_only(): - return min_version_skip(V3_0) + return min_version_skip(VERSION) class DockerClientTestCase(unittest.TestCase): diff --git a/tests/unit/cli/errors_test.py b/tests/unit/cli/errors_test.py index cb5f59df19b..f7359a48b1c 100644 --- a/tests/unit/cli/errors_test.py +++ b/tests/unit/cli/errors_test.py @@ -34,19 +34,19 @@ def test_generic_connection_error(self, mock_logging): def test_api_error_version_mismatch(self, mock_logging): with pytest.raises(errors.ConnectionError): - with handle_connection_errors(mock.Mock(api_version='1.22')): + with handle_connection_errors(mock.Mock(api_version='1.38')): 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] + assert "Docker Engine of version 18.06.0 or greater" in args[0] def test_api_error_version_mismatch_unicode_explanation(self, mock_logging): with pytest.raises(errors.ConnectionError): - with handle_connection_errors(mock.Mock(api_version='1.22')): + with handle_connection_errors(mock.Mock(api_version='1.38')): raise APIError(None, None, u"client is newer than server") _, args, _ = mock_logging.error.mock_calls[0] - assert "Docker Engine of version 1.10.0 or greater" in args[0] + assert "Docker Engine of version 18.06.0 or greater" in args[0] def test_api_error_version_other(self, mock_logging): msg = b"Something broke!" diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 1f9a168c0a9..612d0f38ffe 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -27,20 +27,12 @@ from compose.config.serialize import serialize_ns_time_value from compose.config.types import VolumeSpec from compose.const import COMPOSEFILE_V1 as V1 -from compose.const import COMPOSEFILE_V2_0 as V2_0 -from compose.const import COMPOSEFILE_V2_1 as V2_1 -from compose.const import COMPOSEFILE_V2_2 as V2_2 -from compose.const import COMPOSEFILE_V2_3 as V2_3 -from compose.const import COMPOSEFILE_V3_0 as V3_0 -from compose.const import COMPOSEFILE_V3_1 as V3_1 -from compose.const import COMPOSEFILE_V3_2 as V3_2 -from compose.const import COMPOSEFILE_V3_3 as V3_3 -from compose.const import COMPOSEFILE_V3_5 as V3_5 +from compose.const import COMPOSEFILE_V4 as VERSION from compose.const import IS_WINDOWS_PLATFORM from tests import mock from tests import unittest -DEFAULT_VERSION = V2_0 +DEFAULT_VERSION = VERSION def make_service_dict(name, service_dict, working_dir='.', filename=None): @@ -73,8 +65,10 @@ def test_load(self): service_dicts = config.load( build_config_details( { - 'foo': {'image': 'busybox'}, - 'bar': {'image': 'busybox', 'environment': ['FOO=1']}, + 'services': { + 'foo': {'image': 'busybox'}, + 'bar': {'image': 'busybox', 'environment': ['FOO=1']}, + } }, 'tests/fixtures/extends', 'common.yml' @@ -169,23 +163,23 @@ def test_load_v2(self): def test_valid_versions(self): for version in ['2', '2.0']: cfg = config.load(build_config_details({'version': version})) - assert cfg.version == V2_0 + assert cfg.version == VERSION cfg = config.load(build_config_details({'version': '2.1'})) - assert cfg.version == V2_1 + assert cfg.version == VERSION cfg = config.load(build_config_details({'version': '2.2'})) - assert cfg.version == V2_2 + assert cfg.version == VERSION cfg = config.load(build_config_details({'version': '2.3'})) - assert cfg.version == V2_3 + assert cfg.version == VERSION for version in ['3', '3.0']: cfg = config.load(build_config_details({'version': version})) - assert cfg.version == V3_0 + assert cfg.version == VERSION cfg = config.load(build_config_details({'version': '3.1'})) - assert cfg.version == V3_1 + assert cfg.version == VERSION def test_v1_file_version(self): cfg = config.load(build_config_details({'web': {'image': 'busybox'}})) @@ -197,7 +191,7 @@ def test_v1_file_version(self): assert list(s['name'] for s in cfg.services) == ['version'] def test_wrong_version_type(self): - for version in [None, 1, 2, 2.0]: + for version in [1, 2, 2.0]: with pytest.raises(ConfigurationError) as excinfo: config.load( build_config_details( @@ -213,12 +207,12 @@ def test_unsupported_version(self): with pytest.raises(ConfigurationError) as excinfo: config.load( build_config_details( - {'version': '2.18'}, + {'version': '1'}, filename='filename.yml', ) ) - assert 'Version in "filename.yml" is unsupported' in excinfo.exconly() + assert 'Version in "filename.yml" is invalid' in excinfo.exconly() assert VERSION_EXPLANATION in excinfo.exconly() def test_version_1_is_invalid(self): @@ -328,7 +322,6 @@ def test_load_service_with_name_version(self): } }, 'working_dir', 'filename.yml') ) - assert 'Unexpected type for "version" key in "filename.yml"' \ in mock_logging.warning.call_args[0][0] @@ -376,7 +369,7 @@ def test_load_config_link_local_ips_network(self): base_file = config.ConfigFile( 'base.yaml', { - 'version': str(V2_1), + 'version': '2', 'services': { 'web': { 'image': 'example/web', @@ -511,7 +504,15 @@ def test_load_config_invalid_service_names(self): for invalid_name in ['?not?allowed', ' ', '', '!', '/', '\xe2']: with pytest.raises(ConfigurationError) as exc: config.load(build_config_details( - {invalid_name: {'image': 'busybox'}})) + { + 'version': '2', + 'services': { + invalid_name: + { + 'image': 'busybox' + } + } + })) assert 'Invalid service name \'%s\'' % invalid_name in exc.exconly() def test_load_config_invalid_service_names_v2(self): @@ -543,17 +544,24 @@ 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'}, + 'version': '2', + 'services': { + 'web': {'image': 'busybox', 'name': 'bogus'} + } }, 'working_dir', 'filename.yml', )) - - assert "Unsupported config option for web: 'name'" in exc.exconly() + assert "Unsupported config option for services.web: 'name'" in exc.exconly() def test_load_invalid_service_definition(self): config_details = build_config_details( - {'web': 'wrong'}, + { + 'version': '2', + 'services': { + 'web': 'wrong' + } + }, 'working_dir', 'filename.yml') with pytest.raises(ConfigurationError) as exc: @@ -585,7 +593,10 @@ def test_config_integer_service_name_raise_validation_error(self): with pytest.raises(ConfigurationError) as excinfo: config.load( build_config_details( - {1: {'image': 'busybox'}}, + { + 'version': '2', + 'services': {1: {'image': 'busybox'}} + }, 'working_dir', 'filename.yml' ) @@ -836,10 +847,10 @@ def test_load_mixed_extends_resolution(self): def test_load_with_multiple_files_and_invalid_override(self): base_file = config.ConfigFile( 'base.yaml', - {'web': {'image': 'example/web'}}) + {'version': '2', 'services': {'web': {'image': 'example/web'}}}) override_file = config.ConfigFile( 'override.yaml', - {'bogus': 'thing'}) + {'version': '2', 'services': {'bogus': 'thing'}}) details = config.ConfigDetails('.', [base_file, override_file]) with pytest.raises(ConfigurationError) as exc: @@ -977,7 +988,6 @@ def test_load_build_labels_dict(self): service = config.load( build_config_details( { - 'version': str(V3_3), 'services': { 'web': { 'build': { @@ -1424,7 +1434,7 @@ def test_config_invalid_ipam_config(self): config.load( build_config_details( { - 'version': str(V2_1), + 'version': str(VERSION), 'networks': { 'foo': { 'driver': 'default', @@ -1455,7 +1465,6 @@ def test_config_valid_ipam_config(self): networks = config.load( build_config_details( { - 'version': str(V2_1), 'networks': { 'foo': { 'driver': 'default', @@ -1487,7 +1496,10 @@ def test_config_hint(self): config.load( build_config_details( { - 'foo': {'image': 'busybox', 'privilige': 'something'}, + 'version': str(VERSION), + 'services': { + 'foo': {'image': 'busybox', 'privilige': 'something'}, + } }, 'tests/fixtures/extends', 'filename.yml' @@ -1508,7 +1520,10 @@ def test_invalid_config_v1(self): config.load( build_config_details( { - 'foo': {'image': 1}, + 'version': str(VERSION), + 'services': { + 'foo': {'image': 1}, + } }, 'tests/fixtures/extends', 'filename.yml' @@ -1555,7 +1570,10 @@ def test_invalid_config_type_should_be_an_array(self): config.load( build_config_details( { - 'foo': {'image': 'busybox', 'links': 'an_link'}, + 'version': str(VERSION), + 'services': { + 'foo': {'image': 'busybox', 'links': 'an_link'}, + } }, 'tests/fixtures/extends', 'filename.yml' @@ -1583,7 +1601,10 @@ def test_invalid_config_not_unique_items(self): config.load( build_config_details( { - 'web': {'build': '.', 'devices': ['/dev/foo:/dev/foo', '/dev/foo:/dev/foo']} + 'version': str(VERSION), + 'services': { + 'web': {'build': '.', 'devices': ['/dev/foo:/dev/foo', '/dev/foo:/dev/foo']} + } }, 'tests/fixtures/extends', 'filename.yml' @@ -1597,7 +1618,10 @@ def test_invalid_list_of_strings_format(self): config.load( build_config_details( { - 'web': {'build': '.', 'command': [1]} + 'version': str(VERSION), + 'services': { + 'web': {'build': '.', 'command': [1]} + } }, 'tests/fixtures/extends', 'filename.yml' @@ -1622,10 +1646,13 @@ def test_config_extra_hosts_string_raises_validation_error(self): with pytest.raises(ConfigurationError) as excinfo: config.load( build_config_details( - {'web': { - 'image': 'busybox', - 'extra_hosts': 'somehost:162.242.195.82' - }}, + { + 'version': str(VERSION), + 'services': { + 'web': { + 'image': 'busybox', + 'extra_hosts': 'somehost:162.242.195.82'}} + }, 'working_dir', 'filename.yml' ) @@ -1638,13 +1665,16 @@ def test_config_extra_hosts_list_of_dicts_validation_error(self): with pytest.raises(ConfigurationError) as excinfo: config.load( build_config_details( - {'web': { - 'image': 'busybox', - 'extra_hosts': [ - {'somehost': '162.242.195.82'}, - {'otherhost': '50.31.209.229'} - ] - }}, + { + 'version': str(VERSION), + 'services': { + 'web': { + 'image': 'busybox', + 'extra_hosts': [ + {'somehost': '162.242.195.82'}, + {'otherhost': '50.31.209.229'} + ]}} + }, 'working_dir', 'filename.yml' ) @@ -1658,13 +1688,16 @@ def test_config_ulimits_invalid_keys_validation_error(self): with pytest.raises(ConfigurationError) as exc: config.load(build_config_details( { - 'web': { - 'image': 'busybox', - 'ulimits': { - 'nofile': { - "not_soft_or_hard": 100, - "soft": 10000, - "hard": 20000, + 'version': str(VERSION), + 'services': { + 'web': { + 'image': 'busybox', + 'ulimits': { + 'nofile': { + "not_soft_or_hard": 100, + "soft": 10000, + "hard": 20000, + } } } } @@ -1679,9 +1712,12 @@ def test_config_ulimits_required_keys_validation_error(self): with pytest.raises(ConfigurationError) as exc: config.load(build_config_details( { - 'web': { - 'image': 'busybox', - 'ulimits': {'nofile': {"soft": 10000}} + 'version': str(VERSION), + 'services': { + 'web': { + 'image': 'busybox', + 'ulimits': {'nofile': {"soft": 10000}} + } } }, 'working_dir', @@ -1695,10 +1731,13 @@ def test_config_ulimits_soft_greater_than_hard_error(self): with pytest.raises(ConfigurationError) as exc: config.load(build_config_details( { - 'web': { - 'image': 'busybox', - 'ulimits': { - 'nofile': {"soft": 10000, "hard": 1000} + 'version': str(VERSION), + 'services': { + 'web': { + 'image': 'busybox', + 'ulimits': { + 'nofile': {"soft": 10000, "hard": 1000} + } } } }, @@ -1711,10 +1750,12 @@ def test_valid_config_which_allows_two_type_definitions(self): for expose in expose_values: service = config.load( build_config_details( - {'web': { - 'image': 'busybox', - 'expose': expose - }}, + { + 'version': str(VERSION), + 'services': { + 'web': { + 'image': 'busybox', + 'expose': expose}}}, 'working_dir', 'filename.yml' ) @@ -1726,10 +1767,12 @@ def test_valid_config_oneof_string_or_list(self): for entrypoint in entrypoint_values: service = config.load( build_config_details( - {'web': { - 'image': 'busybox', - 'entrypoint': entrypoint - }}, + { + 'version': str(VERSION), + 'services': { + 'web': { + 'image': 'busybox', + 'entrypoint': entrypoint}}}, 'working_dir', 'filename.yml' ) @@ -1738,9 +1781,12 @@ def test_valid_config_oneof_string_or_list(self): def test_logs_warning_for_boolean_in_environment(self): config_details = build_config_details({ - 'web': { - 'image': 'busybox', - 'environment': {'SHOW_STUFF': True} + 'version': str(VERSION), + 'services': { + 'web': { + 'image': 'busybox', + 'environment': {'SHOW_STUFF': True} + } } }) @@ -1752,10 +1798,12 @@ def test_logs_warning_for_boolean_in_environment(self): def test_config_valid_environment_dict_key_contains_dashes(self): services = config.load( build_config_details( - {'web': { - 'image': 'busybox', - 'environment': {'SPRING_JPA_HIBERNATE_DDL-AUTO': 'none'} - }}, + { + 'version': str(VERSION), + 'services': { + 'web': { + 'image': 'busybox', + 'environment': {'SPRING_JPA_HIBERNATE_DDL-AUTO': 'none'}}}}, 'working_dir', 'filename.yml' ) @@ -1794,9 +1842,12 @@ def test_load_yaml_with_bom(self): def test_validate_extra_hosts_invalid(self): with pytest.raises(ConfigurationError) as exc: config.load(build_config_details({ - 'web': { - 'image': 'alpine', - 'extra_hosts': "www.example.com: 192.168.0.17", + 'version': str(VERSION), + 'services': { + 'web': { + 'image': 'alpine', + 'extra_hosts': "www.example.com: 192.168.0.17", + } } })) assert "web.extra_hosts contains an invalid type" in exc.exconly() @@ -1804,22 +1855,28 @@ def test_validate_extra_hosts_invalid(self): def test_validate_extra_hosts_invalid_list(self): with pytest.raises(ConfigurationError) as exc: config.load(build_config_details({ - 'web': { - 'image': 'alpine', - 'extra_hosts': [ - {'www.example.com': '192.168.0.17'}, - {'api.example.com': '192.168.0.18'} - ], + 'version': str(VERSION), + 'services': { + 'web': { + 'image': 'alpine', + 'extra_hosts': [ + {'www.example.com': '192.168.0.17'}, + {'api.example.com': '192.168.0.18'} + ], + } } })) assert "which is an invalid type" in exc.exconly() def test_normalize_dns_options(self): actual = config.load(build_config_details({ - 'web': { - 'image': 'alpine', - 'dns': '8.8.8.8', - 'dns_search': 'domain.local', + 'version': str(VERSION), + 'services': { + 'web': { + 'image': 'alpine', + 'dns': '8.8.8.8', + 'dns_search': 'domain.local', + } } })) assert actual.services == [ @@ -1947,7 +2004,6 @@ def test_dns_opt_option(self): def test_isolation_option(self): actual = config.load(build_config_details({ - 'version': str(V2_1), 'services': { 'web': { 'image': 'win10', @@ -1966,7 +2022,6 @@ def test_isolation_option(self): def test_runtime_option(self): actual = config.load(build_config_details({ - 'version': str(V2_3), 'services': { 'web': { 'image': 'nvidia/cuda', @@ -2088,7 +2143,7 @@ def test_merge_service_dicts_heterogeneous_volumes(self): } actual = config.merge_service_dicts_from_files( - base, override, V3_2 + base, override, VERSION ) assert actual['volumes'] == [ @@ -2135,7 +2190,7 @@ def test_merge_logging_v2(self): } } - actual = config.merge_service_dicts(base, override, V2_0) + actual = config.merge_service_dicts(base, override, VERSION) assert actual == { 'image': 'alpine:edge', 'logging': { @@ -2169,7 +2224,7 @@ def test_merge_logging_v2_override_driver(self): } } - actual = config.merge_service_dicts(base, override, V2_0) + actual = config.merge_service_dicts(base, override, VERSION) assert actual == { 'image': 'alpine:edge', 'logging': { @@ -2201,7 +2256,7 @@ def test_merge_logging_v2_no_base_driver(self): } } - actual = config.merge_service_dicts(base, override, V2_0) + actual = config.merge_service_dicts(base, override, VERSION) assert actual == { 'image': 'alpine:edge', 'logging': { @@ -2233,7 +2288,7 @@ def test_merge_logging_v2_no_drivers(self): } } - actual = config.merge_service_dicts(base, override, V2_0) + actual = config.merge_service_dicts(base, override, VERSION) assert actual == { 'image': 'alpine:edge', 'logging': { @@ -2262,7 +2317,7 @@ def test_merge_logging_v2_no_override_options(self): } } - actual = config.merge_service_dicts(base, override, V2_0) + actual = config.merge_service_dicts(base, override, VERSION) assert actual == { 'image': 'alpine:edge', 'logging': { @@ -2282,7 +2337,7 @@ def test_merge_logging_v2_no_base(self): } } } - actual = config.merge_service_dicts(base, override, V2_0) + actual = config.merge_service_dicts(base, override, VERSION) assert actual == { 'image': 'alpine:edge', 'logging': { @@ -2304,7 +2359,7 @@ def test_merge_logging_v2_no_override(self): } } override = {} - actual = config.merge_service_dicts(base, override, V2_0) + actual = config.merge_service_dicts(base, override, VERSION) assert actual == { 'image': 'alpine:edge', 'logging': { @@ -2332,7 +2387,7 @@ def test_merge_mixed_ports(self): 'ports': ['1245:1245/udp'] } - actual = config.merge_service_dicts(base, override, V3_1) + actual = config.merge_service_dicts(base, override, VERSION) assert actual == { 'image': BUSYBOX_IMAGE_WITH_TAG, 'command': 'top', @@ -2348,7 +2403,7 @@ def test_merge_depends_on_no_override(self): } } override = {} - actual = config.merge_service_dicts(base, override, V2_1) + actual = config.merge_service_dicts(base, override, VERSION) assert actual == base def test_merge_depends_on_mixed_syntax(self): @@ -2363,7 +2418,7 @@ def test_merge_depends_on_mixed_syntax(self): 'depends_on': ['app3'] } - actual = config.merge_service_dicts(base, override, V2_1) + actual = config.merge_service_dicts(base, override, VERSION) assert actual == { 'image': 'busybox', 'depends_on': { @@ -2401,7 +2456,7 @@ def test_merge_pid(self): 'labels': {'com.docker.compose.test': 'yes'} } - actual = config.merge_service_dicts(base, override, V2_0) + actual = config.merge_service_dicts(base, override, VERSION) assert actual == { 'image': 'busybox', 'pid': 'host', @@ -2417,7 +2472,7 @@ def test_merge_different_secrets(self): } override = {'secrets': ['other-src.txt']} - actual = config.merge_service_dicts(base, override, V3_1) + actual = config.merge_service_dicts(base, override, VERSION) assert secret_sort(actual['secrets']) == secret_sort([ {'source': 'src.txt'}, {'source': 'other-src.txt'} @@ -2437,7 +2492,7 @@ def test_merge_secrets_override(self): } ] } - actual = config.merge_service_dicts(base, override, V3_1) + actual = config.merge_service_dicts(base, override, VERSION) assert actual['secrets'] == override['secrets'] def test_merge_different_configs(self): @@ -2449,7 +2504,7 @@ def test_merge_different_configs(self): } override = {'configs': ['other-src.txt']} - actual = config.merge_service_dicts(base, override, V3_3) + actual = config.merge_service_dicts(base, override, VERSION) assert secret_sort(actual['configs']) == secret_sort([ {'source': 'src.txt'}, {'source': 'other-src.txt'} @@ -2469,7 +2524,7 @@ def test_merge_configs_override(self): } ] } - actual = config.merge_service_dicts(base, override, V3_3) + actual = config.merge_service_dicts(base, override, VERSION) assert actual['configs'] == override['configs'] def test_merge_deploy(self): @@ -2484,7 +2539,7 @@ def test_merge_deploy(self): } } } - actual = config.merge_service_dicts(base, override, V3_0) + actual = config.merge_service_dicts(base, override, VERSION) assert actual['deploy'] == override['deploy'] def test_merge_deploy_override(self): @@ -2540,7 +2595,7 @@ def test_merge_deploy_override(self): 'update_config': {'max_failure_ratio': 0.712, 'parallelism': 4} } } - actual = config.merge_service_dicts(base, override, V3_5) + actual = config.merge_service_dicts(base, override, VERSION) assert actual['deploy'] == { 'mode': 'replicated', 'endpoint_mode': 'vip', @@ -2596,7 +2651,7 @@ def test_merge_credential_spec(self): } } - actual = config.merge_service_dicts(base, override, V3_3) + actual = config.merge_service_dicts(base, override, VERSION) assert actual['credential_spec'] == override['credential_spec'] def test_merge_scale(self): @@ -2609,7 +2664,7 @@ def test_merge_scale(self): 'scale': 4, } - actual = config.merge_service_dicts(base, override, V2_2) + actual = config.merge_service_dicts(base, override, VERSION) assert actual == {'image': 'bar', 'scale': 4} def test_merge_blkio_config(self): @@ -2644,7 +2699,7 @@ def test_merge_blkio_config(self): } } - actual = config.merge_service_dicts(base, override, V2_2) + actual = config.merge_service_dicts(base, override, VERSION) assert actual == { 'image': 'bar', 'blkio_config': { @@ -2671,7 +2726,7 @@ def test_merge_extra_hosts(self): 'extra_hosts': ['bar:5.6.7.8', 'foo:127.0.0.1'] } - actual = config.merge_service_dicts(base, override, V2_0) + actual = config.merge_service_dicts(base, override, VERSION) assert actual['extra_hosts'] == { 'foo': '127.0.0.1', 'bar': '5.6.7.8', @@ -2695,7 +2750,7 @@ def test_merge_healthcheck_config(self): } } - actual = config.merge_service_dicts(base, override, V2_3) + actual = config.merge_service_dicts(base, override, VERSION) assert actual['healthcheck'] == { 'start_period': base['healthcheck']['start_period'], 'test': override['healthcheck']['test'], @@ -2721,7 +2776,7 @@ def test_merge_healthcheck_override_disables(self): } } - actual = config.merge_service_dicts(base, override, V2_3) + actual = config.merge_service_dicts(base, override, VERSION) assert actual['healthcheck'] == {'disabled': True} def test_merge_healthcheck_override_enables(self): @@ -2743,7 +2798,7 @@ def test_merge_healthcheck_override_enables(self): } } - actual = config.merge_service_dicts(base, override, V2_3) + actual = config.merge_service_dicts(base, override, VERSION) assert actual['healthcheck'] == override['healthcheck'] def test_merge_device_cgroup_rules(self): @@ -2756,7 +2811,7 @@ def test_merge_device_cgroup_rules(self): 'device_cgroup_rules': ['c 7:128 rwm', 'f 0:128 n'] } - actual = config.merge_service_dicts(base, override, V2_3) + actual = config.merge_service_dicts(base, override, VERSION) assert sorted(actual['device_cgroup_rules']) == sorted( ['c 7:128 rwm', 'x 3:244 rw', 'f 0:128 n'] ) @@ -2771,7 +2826,7 @@ def test_merge_isolation(self): 'isolation': 'hyperv', } - actual = config.merge_service_dicts(base, override, V2_3) + actual = config.merge_service_dicts(base, override, VERSION) assert actual == { 'image': 'bar', 'isolation': 'hyperv', @@ -2793,7 +2848,7 @@ def test_merge_storage_opt(self): } } - actual = config.merge_service_dicts(base, override, V2_3) + actual = config.merge_service_dicts(base, override, VERSION) assert actual['storage_opt'] == { 'size': '2G', 'readonly': 'false', @@ -3350,6 +3405,7 @@ def test_config_non_unique_ports_validation(self): assert "non-unique" in exc.value.msg + @pytest.mark.skip(reason="Validator is one_off (generic error)") def test_config_invalid_ports_format_validation(self): for invalid_ports in self.INVALID_PORT_MAPPINGS: with pytest.raises(ConfigurationError) as exc: @@ -4369,7 +4425,8 @@ def test_resolve_path(self): service_dict = config.load( build_config_details( - {'foo': {'build': '.', 'volumes': ['$HOSTENV:$CONTAINERENV']}}, + {'services': { + 'foo': {'build': '.', 'volumes': ['$HOSTENV:$CONTAINERENV']}}}, "tests/fixtures/env", ) ).services[0] @@ -4377,7 +4434,8 @@ def test_resolve_path(self): service_dict = config.load( build_config_details( - {'foo': {'build': '.', 'volumes': ['/opt${HOSTENV}:/opt${CONTAINERENV}']}}, + {'services': { + 'foo': {'build': '.', 'volumes': ['/opt${HOSTENV}:/opt${CONTAINERENV}']}}}, "tests/fixtures/env", ) ).services[0] @@ -4498,7 +4556,11 @@ def test_extends_validation_empty_dictionary(self): config.load( build_config_details( { - 'web': {'image': 'busybox', 'extends': {}}, + 'version': '3', + 'services': + { + 'web': {'image': 'busybox', 'extends': {}}, + } }, 'tests/fixtures/extends', 'filename.yml' @@ -4512,7 +4574,14 @@ def test_extends_validation_missing_service_key(self): config.load( build_config_details( { - 'web': {'image': 'busybox', 'extends': {'file': 'common.yml'}}, + 'version': '3', + 'services': + { + 'web': { + 'image': 'busybox', + 'extends': {'file': 'common.yml'} + } + } }, 'tests/fixtures/extends', 'filename.yml' @@ -4526,14 +4595,18 @@ def test_extends_validation_invalid_key(self): config.load( build_config_details( { - 'web': { - 'image': 'busybox', - 'extends': { - 'file': 'common.yml', - 'service': 'web', - 'rogue_key': 'is not allowed' - } - }, + 'version': '3', + 'services': + { + 'web': { + 'image': 'busybox', + 'extends': { + 'file': 'common.yml', + 'service': 'web', + 'rogue_key': 'is not allowed' + } + }, + } }, 'tests/fixtures/extends', 'filename.yml' @@ -4548,11 +4621,14 @@ def test_extends_validation_sub_property_key(self): config.load( build_config_details( { - 'web': { - 'image': 'busybox', - 'extends': { - 'file': 1, - 'service': 'web', + 'version': '3', + 'services': { + 'web': { + 'image': 'busybox', + 'extends': { + 'file': 1, + 'service': 'web', + } } }, }, @@ -5205,7 +5281,7 @@ def test_denormalize_depends_on_v3(self): } } - assert denormalize_service_dict(service_dict, V3_0) == { + assert denormalize_service_dict(service_dict, VERSION) == { 'image': 'busybox', 'command': 'true', 'depends_on': ['service2', 'service3'] @@ -5221,7 +5297,11 @@ def test_denormalize_depends_on_v2_1(self): } } - assert denormalize_service_dict(service_dict, V2_1) == service_dict + assert denormalize_service_dict(service_dict, VERSION) == { + 'image': 'busybox', + 'command': 'true', + 'depends_on': ['service2', 'service3'] + } def test_serialize_time(self): data = { @@ -5255,7 +5335,7 @@ def test_denormalize_healthcheck(self): processed_service = config.process_service(config.ServiceConfig( '.', 'test', 'test', service_dict )) - denormalized_service = denormalize_service_dict(processed_service, V2_3) + denormalized_service = denormalize_service_dict(processed_service, VERSION) assert denormalized_service['healthcheck']['interval'] == '100s' assert denormalized_service['healthcheck']['timeout'] == '30s' assert denormalized_service['healthcheck']['start_period'] == '2090ms' @@ -5266,7 +5346,7 @@ def test_denormalize_image_has_digest(self): } image_digest = 'busybox@sha256:abcde' - assert denormalize_service_dict(service_dict, V3_0, image_digest) == { + assert denormalize_service_dict(service_dict, VERSION, image_digest) == { 'image': 'busybox@sha256:abcde' } @@ -5275,7 +5355,7 @@ def test_denormalize_image_no_digest(self): 'image': 'busybox' } - assert denormalize_service_dict(service_dict, V3_0) == { + assert denormalize_service_dict(service_dict, VERSION) == { 'image': 'busybox' } @@ -5308,10 +5388,10 @@ def test_serialize_secrets(self): serialized_service = serialized_config['services']['web'] assert secret_sort(serialized_service['secrets']) == secret_sort(service_dict['secrets']) assert 'secrets' in serialized_config - assert serialized_config['secrets']['two'] == secrets_dict['two'] + assert serialized_config['secrets']['two'] == {'external': True, 'name': 'two'} def test_serialize_ports(self): - config_dict = config.Config(version=V2_0, services=[ + config_dict = config.Config(version=VERSION, services=[ { 'ports': [types.ServicePort('80', '8080', None, None, None)], 'image': 'alpine', @@ -5320,10 +5400,10 @@ def test_serialize_ports(self): ], volumes={}, networks={}, secrets={}, configs={}) serialized_config = yaml.safe_load(serialize_config(config_dict)) - assert '8080:80/tcp' in serialized_config['services']['web']['ports'] + assert [{'published': 8080, 'target': 80}] == serialized_config['services']['web']['ports'] def test_serialize_ports_with_ext_ip(self): - config_dict = config.Config(version=V3_5, services=[ + config_dict = config.Config(version=VERSION, services=[ { 'ports': [types.ServicePort('80', '8080', None, None, '127.0.0.1')], 'image': 'alpine', @@ -5363,7 +5443,7 @@ def test_serialize_configs(self): serialized_service = serialized_config['services']['web'] assert secret_sort(serialized_service['configs']) == secret_sort(service_dict['configs']) assert 'configs' in serialized_config - assert serialized_config['configs']['two'] == configs_dict['two'] + assert serialized_config['configs']['two'] == {'external': True, 'name': 'two'} def test_serialize_bool_string(self): cfg = { diff --git a/tests/unit/config/interpolation_test.py b/tests/unit/config/interpolation_test.py index 4efcf865a60..dfc7beb2ed2 100644 --- a/tests/unit/config/interpolation_test.py +++ b/tests/unit/config/interpolation_test.py @@ -8,9 +8,7 @@ from compose.config.interpolation import InvalidInterpolation from compose.config.interpolation import TemplateWithDefaults from compose.config.interpolation import UnsetRequiredSubstitution -from compose.const import COMPOSEFILE_V2_0 as V2_0 -from compose.const import COMPOSEFILE_V2_3 as V2_3 -from compose.const import COMPOSEFILE_V3_4 as V3_4 +from compose.const import COMPOSEFILE_V4 as VERSION @pytest.fixture @@ -63,7 +61,7 @@ def test_interpolate_environment_variables_in_services(mock_env): } } } - value = interpolate_environment_variables(V2_0, services, 'service', mock_env) + value = interpolate_environment_variables(VERSION, services, 'service', mock_env) assert value == expected @@ -88,7 +86,7 @@ def test_interpolate_environment_variables_in_volumes(mock_env): }, 'other': {}, } - value = interpolate_environment_variables(V2_0, volumes, 'volume', mock_env) + value = interpolate_environment_variables(VERSION, volumes, 'volume', mock_env) assert value == expected @@ -113,7 +111,7 @@ def test_interpolate_environment_variables_in_secrets(mock_env): }, 'other': {}, } - value = interpolate_environment_variables(V3_4, secrets, 'secret', mock_env) + value = interpolate_environment_variables(VERSION, secrets, 'secret', mock_env) assert value == expected @@ -184,7 +182,7 @@ def test_interpolate_environment_services_convert_types_v2(mock_env): } } - value = interpolate_environment_variables(V2_3, entry, 'service', mock_env) + value = interpolate_environment_variables(VERSION, entry, 'service', mock_env) assert value == expected @@ -257,7 +255,7 @@ def test_interpolate_environment_services_convert_types_v3(mock_env): } } - value = interpolate_environment_variables(V3_4, entry, 'service', mock_env) + value = interpolate_environment_variables(VERSION, entry, 'service', mock_env) assert value == expected @@ -265,21 +263,21 @@ def test_interpolate_environment_services_convert_types_invalid(mock_env): entry = {'service1': {'privileged': '${POSINT}'}} with pytest.raises(ConfigurationError) as exc: - interpolate_environment_variables(V2_3, entry, 'service', mock_env) + interpolate_environment_variables(VERSION, entry, 'service', mock_env) assert 'Error while attempting to convert service.service1.privileged to '\ 'appropriate type: "50" is not a valid boolean value' in exc.exconly() entry = {'service1': {'cpus': '${TRUE}'}} with pytest.raises(ConfigurationError) as exc: - interpolate_environment_variables(V2_3, entry, 'service', mock_env) + interpolate_environment_variables(VERSION, entry, 'service', mock_env) assert 'Error while attempting to convert service.service1.cpus to '\ 'appropriate type: "True" is not a valid float' in exc.exconly() entry = {'service1': {'ulimits': {'nproc': '${FLOAT}'}}} with pytest.raises(ConfigurationError) as exc: - interpolate_environment_variables(V2_3, entry, 'service', mock_env) + interpolate_environment_variables(VERSION, entry, 'service', mock_env) assert 'Error while attempting to convert service.service1.ulimits.nproc to '\ 'appropriate type: "0.145" is not a valid integer' in exc.exconly() @@ -302,7 +300,7 @@ def test_interpolate_environment_network_convert_types(mock_env): } } - value = interpolate_environment_variables(V3_4, entry, 'network', mock_env) + value = interpolate_environment_variables(VERSION, entry, 'network', mock_env) assert value == expected @@ -319,13 +317,13 @@ def test_interpolate_environment_external_resource_convert_types(mock_env): } } - value = interpolate_environment_variables(V3_4, entry, 'network', mock_env) + value = interpolate_environment_variables(VERSION, entry, 'network', mock_env) assert value == expected - value = interpolate_environment_variables(V3_4, entry, 'volume', mock_env) + value = interpolate_environment_variables(VERSION, entry, 'volume', mock_env) assert value == expected - value = interpolate_environment_variables(V3_4, entry, 'secret', mock_env) + value = interpolate_environment_variables(VERSION, entry, 'secret', mock_env) assert value == expected - value = interpolate_environment_variables(V3_4, entry, 'config', mock_env) + value = interpolate_environment_variables(VERSION, entry, 'config', mock_env) assert value == expected @@ -356,7 +354,7 @@ def test_interpolate_service_name_uses_dot(mock_env): } } - value = interpolate_environment_variables(V3_4, entry, 'service', mock_env) + value = interpolate_environment_variables(VERSION, entry, 'service', mock_env) assert value == expected diff --git a/tests/unit/config/types_test.py b/tests/unit/config/types_test.py index c0991b9dc14..5271c0d27a3 100644 --- a/tests/unit/config/types_test.py +++ b/tests/unit/config/types_test.py @@ -6,7 +6,7 @@ from compose.config.types import VolumeFromSpec from compose.config.types import VolumeSpec from compose.const import COMPOSEFILE_V1 as V1 -from compose.const import COMPOSEFILE_V2_0 as V2_0 +from compose.const import COMPOSEFILE_V4 as VERSION def test_parse_extra_hosts_list(): @@ -233,26 +233,26 @@ def test_parse_v1_invalid(self): VolumeFromSpec.parse('unknown:format:ro', self.services, V1) def test_parse_v2_from_service(self): - volume_from = VolumeFromSpec.parse('servicea', self.services, V2_0) + volume_from = VolumeFromSpec.parse('servicea', self.services, VERSION) 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_0) + volume_from = VolumeFromSpec.parse('servicea:ro', self.services, VERSION) assert volume_from == VolumeFromSpec('servicea', 'ro', 'service') def test_parse_v2_from_container(self): - volume_from = VolumeFromSpec.parse('container:foo', self.services, V2_0) + volume_from = VolumeFromSpec.parse('container:foo', self.services, VERSION) 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_0) + volume_from = VolumeFromSpec.parse('container:foo:ro', self.services, VERSION) 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_0) + VolumeFromSpec.parse('bogus:foo:ro', self.services, VERSION) 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_0) + VolumeFromSpec.parse('unknown:format:ro', self.services, VERSION) diff --git a/tests/unit/project_test.py b/tests/unit/project_test.py index 1ad49c1aae5..e80d46b2b19 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -14,9 +14,7 @@ from compose.config.config import Config from compose.config.types import VolumeFromSpec from compose.const import COMPOSEFILE_V1 as V1 -from compose.const import COMPOSEFILE_V2_0 as V2_0 -from compose.const import COMPOSEFILE_V2_4 as V2_4 -from compose.const import COMPOSEFILE_V3_7 as V3_7 +from compose.const import COMPOSEFILE_V4 as VERSION from compose.const import DEFAULT_TIMEOUT from compose.const import LABEL_SERVICE from compose.container import Container @@ -29,6 +27,17 @@ from compose.service import Service +def build_config(**kwargs): + return Config( + version=kwargs.get('version', VERSION), + services=kwargs.get('services'), + volumes=kwargs.get('volumes'), + networks=kwargs.get('networks'), + secrets=kwargs.get('secrets'), + configs=kwargs.get('configs'), + ) + + class ProjectTest(unittest.TestCase): def setUp(self): self.mock_client = mock.create_autospec(docker.APIClient) @@ -36,7 +45,7 @@ def setUp(self): self.mock_client.api_version = docker.constants.DEFAULT_DOCKER_API_VERSION def test_from_config_v1(self): - config = Config( + config = build_config( version=V1, services=[ { @@ -67,8 +76,7 @@ def test_from_config_v1(self): @mock.patch('compose.network.Network.true_name', lambda n: n.full_name) def test_from_config_v2(self): - config = Config( - version=V2_0, + config = build_config( services=[ { 'name': 'web', @@ -174,8 +182,7 @@ def test_use_volumes_from_container(self): project = Project.from_config( name='test', client=self.mock_client, - config_data=Config( - version=V2_0, + config_data=build_config( services=[{ 'name': 'test', 'image': BUSYBOX_IMAGE_WITH_TAG, @@ -202,8 +209,7 @@ def test_use_volumes_from_service_no_container(self): project = Project.from_config( name='test', client=self.mock_client, - config_data=Config( - version=V2_0, + config_data=build_config( services=[ { 'name': 'vol', @@ -230,8 +236,7 @@ def test_use_volumes_from_service_container(self): project = Project.from_config( name='test', client=None, - config_data=Config( - version=V2_0, + config_data=build_config( services=[ { 'name': 'vol', @@ -540,7 +545,7 @@ def test_net_unset(self): project = Project.from_config( name='test', client=self.mock_client, - config_data=Config( + config_data=build_config( version=V1, services=[ { @@ -565,8 +570,7 @@ def test_use_net_from_container(self): project = Project.from_config( name='test', client=self.mock_client, - config_data=Config( - version=V2_0, + config_data=build_config( services=[ { 'name': 'test', @@ -596,8 +600,7 @@ def test_use_net_from_service(self): project = Project.from_config( name='test', client=self.mock_client, - config_data=Config( - version=V2_0, + config_data=build_config( services=[ { 'name': 'aaa', @@ -623,8 +626,7 @@ def test_uses_default_network_true(self): project = Project.from_config( name='test', client=self.mock_client, - config_data=Config( - version=V2_0, + config_data=build_config( services=[ { 'name': 'foo', @@ -644,8 +646,7 @@ def test_uses_default_network_false(self): project = Project.from_config( name='test', client=self.mock_client, - config_data=Config( - version=V2_0, + config_data=build_config( services=[ { 'name': 'foo', @@ -679,8 +680,7 @@ def test_container_without_name(self): project = Project.from_config( name='test', client=self.mock_client, - config_data=Config( - version=V2_0, + config_data=build_config( services=[{ 'name': 'web', 'image': BUSYBOX_IMAGE_WITH_TAG, @@ -697,8 +697,7 @@ def test_down_with_no_resources(self): project = Project.from_config( name='test', client=self.mock_client, - config_data=Config( - version=V2_0, + config_data=build_config( services=[{ 'name': 'web', 'image': BUSYBOX_IMAGE_WITH_TAG, @@ -748,8 +747,8 @@ def test_project_platform_value(self): 'name': 'web', 'image': BUSYBOX_IMAGE_WITH_TAG, } - config_data = Config( - version=V2_4, services=[service_config], networks={}, volumes={}, secrets=None, configs=None + config_data = build_config( + services=[service_config], networks={}, volumes={}, secrets=None, configs=None ) project = Project.from_config(name='test', client=self.mock_client, config_data=config_data) @@ -770,8 +769,7 @@ def test_project_platform_value(self): assert project.get_service('web').platform == 'linux/s390x' def test_build_container_operation_with_timeout_func_does_not_mutate_options_with_timeout(self): - config_data = Config( - version=V3_7, + config_data = build_config( services=[ {'name': 'web', 'image': BUSYBOX_IMAGE_WITH_TAG}, {'name': 'db', 'image': BUSYBOX_IMAGE_WITH_TAG, 'stop_grace_period': '1s'}, @@ -802,8 +800,7 @@ def test_error_parallel_pull(self, mock_write): project = Project.from_config( name='test', client=self.mock_client, - config_data=Config( - version=V2_0, + config_data=build_config( services=[{ 'name': 'web', 'image': BUSYBOX_IMAGE_WITH_TAG, From c06b30548d4edbb4612cbe6eccc345c358508821 Mon Sep 17 00:00:00 2001 From: aiordache Date: Wed, 8 Jul 2020 15:02:07 +0200 Subject: [PATCH 3957/4072] cleanup compatibility and tests Signed-off-by: aiordache --- compose/cli/command.py | 16 +------ compose/config/config.py | 80 +++----------------------------- compose/project.py | 19 ++++++++ tests/unit/config/config_test.py | 2 + 4 files changed, 29 insertions(+), 88 deletions(-) diff --git a/compose/cli/command.py b/compose/cli/command.py index f18f7663995..e907a05cc1e 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -66,7 +66,6 @@ def project_from_options(project_dir, options, additional_options=None): context=context, environment=environment, override_dir=override_dir, - compatibility=compatibility_from_options(project_dir, options, environment), interpolate=(not additional_options.get('--no-interpolate')), environment_file=environment_file ) @@ -98,7 +97,6 @@ def get_config_from_options(base_dir, options, additional_options=None): ) return config.load( config.find(base_dir, config_path, environment, override_dir), - compatibility_from_options(config_path, options, environment), not additional_options.get('--no-interpolate') ) @@ -120,14 +118,14 @@ def unicode_paths(paths): def get_project(project_dir, config_path=None, project_name=None, verbose=False, context=None, environment=None, override_dir=None, - compatibility=False, interpolate=True, environment_file=None): + interpolate=True, environment_file=None): if not environment: environment = Environment.from_env_file(project_dir) config_details = config.find(project_dir, config_path, environment, override_dir) project_name = get_project_name( config_details.working_dir, project_name, environment ) - config_data = config.load(config_details, compatibility, interpolate) + config_data = config.load(config_details, interpolate) api_version = environment.get( 'COMPOSE_API_VERSION', @@ -188,13 +186,3 @@ def normalize_name(name): return normalize_name(project) return 'default' - - -def compatibility_from_options(working_dir, options=None, environment=None): - """Get compose v3 compatibility from --compatibility option - or from COMPOSE_COMPATIBILITY environment variable.""" - - compatibility_option = options.get('--compatibility') - compatibility_environment = environment.get_boolean('COMPOSE_COMPATIBILITY') - - return compatibility_option or compatibility_environment diff --git a/compose/config/config.py b/compose/config/config.py index 1557e52d0bd..5eed6b59a8e 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -365,7 +365,7 @@ def find_candidates_in_parent_dirs(filenames, path): return (candidates, path) -def check_swarm_only_config(service_dicts, compatibility=False): +def check_swarm_only_config(service_dicts): warning_template = ( "Some services ({services}) use the '{key}' key, which will be ignored. " "Compose does not support '{key}' configuration - use " @@ -386,7 +386,7 @@ def check_swarm_only_key(service_dicts, key): check_swarm_only_key(service_dicts, 'configs') -def load(config_details, compatibility=False, interpolate=True): +def load(config_details, interpolate=True): """Load the configuration from a working directory and a list of configuration files. Files are loaded in order, and merged on top of each other to create the final configuration. @@ -416,13 +416,13 @@ def load(config_details, compatibility=False, interpolate=True): configs = load_mapping( config_details.config_files, 'get_configs', 'Config', config_details.working_dir ) - service_dicts = load_services(config_details, main_file, compatibility, interpolate=interpolate) + service_dicts = load_services(config_details, main_file, interpolate=interpolate) if main_file.version != V1: for service_dict in service_dicts: match_named_volumes(service_dict, volumes) - check_swarm_only_config(service_dicts, compatibility) + check_swarm_only_config(service_dicts) version = main_file.version @@ -469,7 +469,7 @@ def validate_external(entity_type, name, config, version): entity_type, name, ', '.join(k for k in config if k != 'external'))) -def load_services(config_details, config_file, compatibility=False, interpolate=True): +def load_services(config_details, config_file, interpolate=True): def build_service(service_name, service_dict, service_names): service_config = ServiceConfig.with_abs_paths( config_details.working_dir, @@ -488,7 +488,6 @@ def build_service(service_name, service_dict, service_names): service_names, config_file.version, config_details.environment, - compatibility, interpolate ) return service_dict @@ -887,7 +886,7 @@ def finalize_service_volumes(service_dict, environment): return service_dict -def finalize_service(service_config, service_names, version, environment, compatibility, +def finalize_service(service_config, service_names, version, environment, interpolate=True): service_dict = dict(service_config.config) @@ -929,17 +928,6 @@ def finalize_service(service_config, service_names, version, environment, compat normalize_build(service_dict, service_config.working_dir, environment) - if compatibility: - service_dict = translate_credential_spec_to_security_opt(service_dict) - service_dict, ignored_keys = translate_deploy_keys_to_container_config( - service_dict - ) - if ignored_keys: - log.warning( - 'The following deploy sub-keys are not supported in compatibility mode and have' - ' been ignored: {}'.format(', '.join(ignored_keys)) - ) - service_dict['name'] = service_config.name return normalize_v1_service_format(service_dict) @@ -973,62 +961,6 @@ def convert_credential_spec_to_security_opt(credential_spec): return 'registry://{registry}'.format(registry=credential_spec['registry']) -def translate_credential_spec_to_security_opt(service_dict): - result = [] - - if 'credential_spec' in service_dict: - spec = convert_credential_spec_to_security_opt(service_dict['credential_spec']) - result.append('credentialspec={spec}'.format(spec=spec)) - - if result: - service_dict['security_opt'] = result - - return service_dict - - -def translate_deploy_keys_to_container_config(service_dict): - if 'credential_spec' in service_dict: - del service_dict['credential_spec'] - if 'configs' in service_dict: - del service_dict['configs'] - - if 'deploy' not in service_dict: - return service_dict, [] - - deploy_dict = service_dict['deploy'] - ignored_keys = [ - k for k in ['endpoint_mode', 'labels', 'update_config', 'rollback_config'] - if k in deploy_dict - ] - - if 'replicas' in deploy_dict and deploy_dict.get('mode', 'replicated') == 'replicated': - scale = deploy_dict.get('replicas', 1) - max_replicas = deploy_dict.get('placement', {}).get('max_replicas_per_node', scale) - service_dict['scale'] = min(scale, max_replicas) - if max_replicas < scale: - log.warning("Scale is limited to {} ('max_replicas_per_node' field).".format( - max_replicas)) - - if 'restart_policy' in deploy_dict: - service_dict['restart'] = { - 'Name': convert_restart_policy(deploy_dict['restart_policy'].get('condition', 'any')), - 'MaximumRetryCount': deploy_dict['restart_policy'].get('max_attempts', 0) - } - for k in deploy_dict['restart_policy'].keys(): - if k != 'condition' and k != 'max_attempts': - ignored_keys.append('restart_policy.{}'.format(k)) - - ignored_keys.extend( - translate_resource_keys_to_container_config( - deploy_dict.get('resources', {}), service_dict - ) - ) - - del service_dict['deploy'] - - return service_dict, ignored_keys - - def normalize_v1_service_format(service_dict): if 'log_driver' in service_dict or 'log_opt' in service_dict: if 'logging' not in service_dict: diff --git a/compose/project.py b/compose/project.py index 4c8b197f287..216615aee6c 100644 --- a/compose/project.py +++ b/compose/project.py @@ -123,6 +123,8 @@ def from_config(cls, name, config_data, client, default_platform=None, extra_lab service_dict.pop('secrets', None) or [], config_data.secrets) + service_dict['scale'] = project.get_service_scale(service_dict) + project.services.append( Service( service_dict.pop('name'), @@ -262,6 +264,23 @@ def get_pid_mode(self, service_dict): return PidMode(pid_mode) + def get_service_scale(self, service_dict): + # service.scale for v2 and deploy.replicas for v3 + scale = service_dict.get('scale', 1) + deploy_dict = service_dict.get('deploy', None) + if deploy_dict: + if deploy_dict.get('mode', 'replicated') == 'replicated': + scale = deploy_dict.get('replicas', scale) + # deploy may contain placement constraints introduced in v3.8 + max_replicas = deploy_dict.get('placement', {}).get( + 'max_replicas_per_node', + scale) + scale = min(scale, max_replicas) + if max_replicas < scale: + log.warning("Scale is limited to {} ('max_replicas_per_node' field).".format( + max_replicas)) + return scale + def start(self, service_names=None, **options): containers = [] diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 612d0f38ffe..4196d8d83bc 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -3655,6 +3655,7 @@ def test_unset_variable_produces_warning(self): assert 'BAR' in warnings[0] assert 'FOO' in warnings[1] + @pytest.mark.skip(reason='compatibility mode was removed internally') def test_compatibility_mode_warnings(self): config_details = build_config_details({ 'version': '3.5', @@ -3693,6 +3694,7 @@ def test_compatibility_mode_warnings(self): assert 'restart_policy.delay' in warn_message assert 'restart_policy.window' in warn_message + @pytest.mark.skip(reason='compatibility mode was removed internally') def test_compatibility_mode_load(self): config_details = build_config_details({ 'version': '3.5', From cb56036a6e7ed1f3e0957032b7c6973a0a08cc02 Mon Sep 17 00:00:00 2001 From: aiordache Date: Thu, 9 Jul 2020 15:07:09 +0200 Subject: [PATCH 3958/4072] rename schema to compose_spec Signed-off-by: aiordache --- compose/config/config.py | 4 ++-- ...g_schema_v4.0.json => config_schema_compose_spec.json} | 2 +- compose/config/serialize.py | 2 +- compose/config/validation.py | 8 ++++++-- compose/const.py | 6 +++--- docker-compose.spec | 4 ++-- docker-compose_darwin.spec | 4 ++-- tests/acceptance/cli_test.py | 2 +- tests/integration/project_test.py | 2 +- tests/integration/testcases.py | 2 +- tests/unit/config/config_test.py | 2 +- tests/unit/config/interpolation_test.py | 2 +- tests/unit/config/types_test.py | 2 +- tests/unit/project_test.py | 2 +- 14 files changed, 24 insertions(+), 20 deletions(-) rename compose/config/{config_schema_v4.0.json => config_schema_compose_spec.json} (99%) diff --git a/compose/config/config.py b/compose/config/config.py index 5eed6b59a8e..a3110682c16 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -12,8 +12,8 @@ from cached_property import cached_property from . import types +from ..const import COMPOSE_SPEC as VERSION from ..const import COMPOSEFILE_V1 as V1 -from ..const import COMPOSEFILE_V4 as VERSION from ..utils import build_string_dict from ..utils import json_hash from ..utils import parse_bytes @@ -214,7 +214,7 @@ def version(self): .format(self.filename)) if isinstance(version, str): - version_pattern = re.compile(r"^[1-4]+(\.\d+)?$") + version_pattern = re.compile(r"^[1-3]+(\.\d+)?$") if not version_pattern.match(version): raise ConfigurationError( 'Version "{}" in "{}" is invalid.' diff --git a/compose/config/config_schema_v4.0.json b/compose/config/config_schema_compose_spec.json similarity index 99% rename from compose/config/config_schema_v4.0.json rename to compose/config/config_schema_compose_spec.json index 316fbf9a09d..0400cd866bd 100644 --- a/compose/config/config_schema_v4.0.json +++ b/compose/config/config_schema_compose_spec.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-04/schema#", - "id": "config_schema_v4.0.json", + "id": "config_schema_compose_spec.json", "type": "object", "properties": { "version": { diff --git a/compose/config/serialize.py b/compose/config/serialize.py index a162e387338..e41e1ba4f27 100644 --- a/compose/config/serialize.py +++ b/compose/config/serialize.py @@ -1,8 +1,8 @@ import yaml from compose.config import types +from compose.const import COMPOSE_SPEC as VERSION from compose.const import COMPOSEFILE_V1 as V1 -from compose.const import COMPOSEFILE_V4 as VERSION def serialize_config_type(dumper, data): diff --git a/compose/config/validation.py b/compose/config/validation.py index f52de03de0a..942c4e03672 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -282,7 +282,7 @@ def handle_error_for_schema_with_id(error, path): invalid_config_key = parse_key_from_error_msg(error) return get_unsupported_config_msg(path, invalid_config_key) - if schema_id.startswith('config_schema_v'): + if schema_id.startswith('config_schema_'): invalid_config_key = parse_key_from_error_msg(error) return ('Invalid top-level property "{key}". Valid top-level ' 'sections for this Compose file are: {properties}, and ' @@ -487,9 +487,13 @@ def get_schema_path(): def load_jsonschema(version): + suffix = "compose_spec" + if version == V1: + suffix = "v1" + filename = os.path.join( get_schema_path(), - "config_schema_v{0}.json".format(version)) + "config_schema_{0}.json".format(suffix)) if not os.path.exists(filename): raise ConfigurationError( diff --git a/compose/const.py b/compose/const.py index 9623769d101..c51e6ac0b00 100644 --- a/compose/const.py +++ b/compose/const.py @@ -24,16 +24,16 @@ WINDOWS_LONGPATH_PREFIX = '\\\\?\\' COMPOSEFILE_V1 = ComposeVersion('1') -COMPOSEFILE_V4 = ComposeVersion('4.0') +COMPOSE_SPEC = ComposeVersion('3') # minimum DOCKER ENGINE API version needed to support # features for each compose schema version API_VERSIONS = { COMPOSEFILE_V1: '1.21', - COMPOSEFILE_V4: '1.38', + COMPOSE_SPEC: '1.38', } API_VERSION_TO_ENGINE_VERSION = { API_VERSIONS[COMPOSEFILE_V1]: '1.9.0', - API_VERSIONS[COMPOSEFILE_V4]: '18.06.0', + API_VERSIONS[COMPOSE_SPEC]: '18.06.0', } diff --git a/docker-compose.spec b/docker-compose.spec index 35195f9b409..ff739ab0d88 100644 --- a/docker-compose.spec +++ b/docker-compose.spec @@ -23,8 +23,8 @@ exe = EXE(pyz, 'DATA' ), ( - 'compose/config/config_schema_v4.0.json', - 'compose/config/config_schema_v4.0.json', + 'compose/config/config_schema_compose_spec.json', + 'compose/config/config_schema_compose_spec.json', 'DATA' ), ( diff --git a/docker-compose_darwin.spec b/docker-compose_darwin.spec index f4642314ebe..6f96b29a442 100644 --- a/docker-compose_darwin.spec +++ b/docker-compose_darwin.spec @@ -32,8 +32,8 @@ coll = COLLECT(exe, 'DATA' ), ( - 'compose/config/config_schema_v4.0.json', - 'compose/config/config_schema_v4.0.json', + 'compose/config/config_schema_compose_spec.json', + 'compose/config/config_schema_compose_spec.json', 'DATA' ), ( diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 51f6428beb0..180c3824f4b 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -20,8 +20,8 @@ from ..helpers import create_host_file from compose.cli.command import get_project from compose.config.errors import DuplicateOverrideFileFound +from compose.const import COMPOSE_SPEC as VERSION from compose.const import COMPOSEFILE_V1 as V1 -from compose.const import COMPOSEFILE_V4 as VERSION from compose.container import Container from compose.project import OneOffFilter from compose.utils import nanoseconds_from_time_seconds diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index cb40dbd43f2..4848c53ee20 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -21,7 +21,7 @@ from compose.config import types from compose.config.types import VolumeFromSpec from compose.config.types import VolumeSpec -from compose.const import COMPOSEFILE_V4 as VERSION +from compose.const import COMPOSE_SPEC as VERSION from compose.const import LABEL_PROJECT from compose.const import LABEL_SERVICE from compose.container import Container diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index e6de0b921c4..e84eadc9347 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -11,8 +11,8 @@ from compose.config.config import resolve_environment from compose.config.environment import Environment from compose.const import API_VERSIONS +from compose.const import COMPOSE_SPEC as VERSION from compose.const import COMPOSEFILE_V1 as V1 -from compose.const import COMPOSEFILE_V4 as VERSION from compose.const import LABEL_PROJECT from compose.progress_stream import stream_output from compose.service import Service diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 4196d8d83bc..0e01dc73882 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -26,8 +26,8 @@ from compose.config.serialize import serialize_config from compose.config.serialize import serialize_ns_time_value from compose.config.types import VolumeSpec +from compose.const import COMPOSE_SPEC as VERSION from compose.const import COMPOSEFILE_V1 as V1 -from compose.const import COMPOSEFILE_V4 as VERSION from compose.const import IS_WINDOWS_PLATFORM from tests import mock from tests import unittest diff --git a/tests/unit/config/interpolation_test.py b/tests/unit/config/interpolation_test.py index dfc7beb2ed2..f8ff8c662e7 100644 --- a/tests/unit/config/interpolation_test.py +++ b/tests/unit/config/interpolation_test.py @@ -8,7 +8,7 @@ from compose.config.interpolation import InvalidInterpolation from compose.config.interpolation import TemplateWithDefaults from compose.config.interpolation import UnsetRequiredSubstitution -from compose.const import COMPOSEFILE_V4 as VERSION +from compose.const import COMPOSE_SPEC as VERSION @pytest.fixture diff --git a/tests/unit/config/types_test.py b/tests/unit/config/types_test.py index 5271c0d27a3..23b9b6767c1 100644 --- a/tests/unit/config/types_test.py +++ b/tests/unit/config/types_test.py @@ -5,8 +5,8 @@ from compose.config.types import ServicePort from compose.config.types import VolumeFromSpec from compose.config.types import VolumeSpec +from compose.const import COMPOSE_SPEC as VERSION from compose.const import COMPOSEFILE_V1 as V1 -from compose.const import COMPOSEFILE_V4 as VERSION def test_parse_extra_hosts_list(): diff --git a/tests/unit/project_test.py b/tests/unit/project_test.py index e80d46b2b19..b889e5e2c3a 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -13,8 +13,8 @@ from compose.config import ConfigurationError from compose.config.config import Config from compose.config.types import VolumeFromSpec +from compose.const import COMPOSE_SPEC as VERSION from compose.const import COMPOSEFILE_V1 as V1 -from compose.const import COMPOSEFILE_V4 as VERSION from compose.const import DEFAULT_TIMEOUT from compose.const import LABEL_SERVICE from compose.container import Container From 48232603109e30d8020d2920265f06e83251088d Mon Sep 17 00:00:00 2001 From: aiordache Date: Thu, 9 Jul 2020 18:22:55 +0200 Subject: [PATCH 3959/4072] align schema with compose-spec Signed-off-by: aiordache --- .../config/config_schema_compose_spec.json | 1006 ++++++----------- 1 file changed, 325 insertions(+), 681 deletions(-) diff --git a/compose/config/config_schema_compose_spec.json b/compose/config/config_schema_compose_spec.json index 0400cd866bd..dee9826fc8c 100644 --- a/compose/config/config_schema_compose_spec.json +++ b/compose/config/config_schema_compose_spec.json @@ -1,10 +1,13 @@ { - "$schema": "http://json-schema.org/draft-04/schema#", + "$schema": "http://json-schema.org/draft/2019-09/schema#", "id": "config_schema_compose_spec.json", "type": "object", + "title": "Compose Specification", + "description": "The Compose file is a YAML file defining a multi-containers based application.", "properties": { "version": { - "type": "string" + "type": "string", + "description": "Version of the Compose specification used. Tools not implementing required version MUST reject the configuration file." }, "services": { "id": "#/properties/services", @@ -56,135 +59,111 @@ "additionalProperties": false } }, - "patternProperties": { - "^x-": {} - }, + "patternProperties": {"^x-": {}}, "additionalProperties": false, "definitions": { "service": { "id": "#/definitions/service", "type": "object", "properties": { - "deploy": { - "$ref": "#/definitions/deployment" - }, + "deploy": {"$ref": "#/definitions/deployment"}, "build": { "oneOf": [ - { - "type": "string" - }, + {"type": "string"}, { "type": "object", "properties": { - "context": { - "type": "string" - }, - "dockerfile": { - "type": "string" - }, - "args": { - "$ref": "#/definitions/list_or_dict" - }, - "labels": { - "$ref": "#/definitions/list_or_dict" - }, - "cache_from": { - "$ref": "#/definitions/list_of_strings" - }, - "network": { - "type": "string" - }, - "target": { - "type": "string" - }, - "shm_size": { - "type": [ - "integer", - "string" - ] - } + "context": {"type": "string"}, + "dockerfile": {"type": "string"}, + "args": {"$ref": "#/definitions/list_or_dict"}, + "labels": {"$ref": "#/definitions/list_or_dict"}, + "cache_from": {"$ref": "#/definitions/list_of_strings"}, + "network": {"type": "string"}, + "target": {"type": "string"}, + "shm_size": {"type": ["integer", "string"]}, + "extra_hosts": {"$ref": "#/definitions/list_or_dict"}, + "isolation": {"type": "string"} }, - "additionalProperties": false + "additionalProperties": false, + "patternProperties": {"^x-": {}} } ] }, - "cap_add": { - "type": "array", - "items": { - "type": "string" - }, - "uniqueItems": true - }, - "cap_drop": { - "type": "array", - "items": { - "type": "string" + "blkio_config": { + "type": "object", + "properties": { + "device_read_bps": { + "type": "array", + "items": {"$ref": "#/definitions/blkio_limit"} + }, + "device_read_iops": { + "type": "array", + "items": {"$ref": "#/definitions/blkio_limit"} + }, + "device_write_bps": { + "type": "array", + "items": {"$ref": "#/definitions/blkio_limit"} + }, + "device_write_iops": { + "type": "array", + "items": {"$ref": "#/definitions/blkio_limit"} + }, + "weight": {"type": "integer"}, + "weight_device": { + "type": "array", + "items": {"$ref": "#/definitions/blkio_weight"} + } }, - "uniqueItems": true - }, - "cgroup_parent": { - "type": "string" + "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" - } - } + {"type": "string"}, + {"type": "array", "items": {"type": "string"}} ] }, "configs": { "type": "array", "items": { "oneOf": [ - { - "type": "string" - }, + {"type": "string"}, { "type": "object", "properties": { - "source": { - "type": "string" - }, - "target": { - "type": "string" - }, - "uid": { - "type": "string" - }, - "gid": { - "type": "string" - }, - "mode": { - "type": "number" - } - } + "source": {"type": "string"}, + "target": {"type": "string"}, + "uid": {"type": "string"}, + "gid": {"type": "string"}, + "mode": {"type": "number"} + }, + "additionalProperties": false, + "patternProperties": {"^x-": {}} } ] } }, - "container_name": { - "type": "string" - }, + "container_name": {"type": "string"}, + "cpu_count": {"type": "integer", "minimum": 0}, + "cpu_percent": {"type": "integer", "minimum": 0, "maximum": 100}, + "cpu_shares": {"type": ["number", "string"]}, + "cpu_quota": {"type": ["number", "string"]}, + "cpu_period": {"type": ["number", "string"]}, + "cpu_rt_period": {"type": ["number", "string"]}, + "cpu_rt_runtime": {"type": ["number", "string"]}, + "cpus": {"type": "number", "minimum": 0}, + "cpuset": {"type": "string"}, "credential_spec": { "type": "object", "properties": { - "config": { - "type": "string" - }, - "file": { - "type": "string" - }, - "registry": { - "type": "string" - } + "config": {"type": "string"}, + "file": {"type": "string"}, + "registry": {"type": "string"} }, - "additionalProperties": false + "additionalProperties": false, + "patternProperties": {"^x-": {}} }, "depends_on": { "oneOf": [ @@ -208,67 +187,36 @@ } ] }, - "devices": { - "type": "array", - "items": { - "type": "string" - }, - "uniqueItems": true - }, - "dns_opt": { - "type": "array", - "items": { - "type": "string" - }, - "uniqueItems": true - }, - "dns": { - "$ref": "#/definitions/string_or_list" - }, - "dns_search": { - "$ref": "#/definitions/string_or_list" - }, - "domainname": { - "type": "string" - }, + "device_cgroup_rules": {"$ref": "#/definitions/list_of_strings"}, + "devices": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "dns": {"$ref": "#/definitions/string_or_list"}, + + "dns_opt": {"type": "array","items": {"type": "string"}, "uniqueItems": true}, + "dns_search": {"$ref": "#/definitions/string_or_list"}, + "domainname": {"type": "string"}, "entrypoint": { "oneOf": [ - { - "type": "string" - }, - { - "type": "array", - "items": { - "type": "string" - } - } + {"type": "string"}, + {"type": "array", "items": {"type": "string"}} ] }, - "env_file": { - "$ref": "#/definitions/string_or_list" - }, - "environment": { - "$ref": "#/definitions/list_or_dict" - }, + "env_file": {"$ref": "#/definitions/string_or_list"}, + "environment": {"$ref": "#/definitions/list_or_dict"}, + "expose": { "type": "array", "items": { - "type": [ - "string", - "number" - ], + "type": ["string", "number"], "format": "expose" }, "uniqueItems": true }, + "extends": { "oneOf": [ - { - "type": "string" - }, + {"type": "string"}, { "type": "object", - "properties": { "service": {"type": "string"}, "file": {"type": "string"} @@ -278,80 +226,46 @@ } ] }, - "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" - }, - "init": { - "type": "boolean" - }, - "ipc": { - "type": "string" - }, - "isolation": { - "type": "string" - }, - "labels": { - "$ref": "#/definitions/list_or_dict" - }, - "links": { + "external_links": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "extra_hosts": {"$ref": "#/definitions/list_or_dict"}, + "group_add": { "type": "array", "items": { - "type": "string" + "type": ["string", "number"] }, "uniqueItems": true }, + "healthcheck": {"$ref": "#/definitions/healthcheck"}, + "hostname": {"type": "string"}, + "image": {"type": "string"}, + "init": {"type": "boolean"}, + "ipc": {"type": "string"}, + "isolation": {"type": "string"}, + "labels": {"$ref": "#/definitions/list_or_dict"}, + "links": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, "logging": { "type": "object", "properties": { - "driver": { - "type": "string" - }, + "driver": {"type": "string"}, "options": { "type": "object", "patternProperties": { - "^.+$": { - "type": [ - "string", - "number", - "null" - ] - } + "^.+$": {"type": ["string", "number", "null"]} } } }, - "additionalProperties": false - }, - "mac_address": { - "type": "string" + "additionalProperties": false, + "patternProperties": {"^x-": {}} }, + "mac_address": {"type": "string"}, "mem_limit": {"type": ["number", "string"]}, "mem_reservation": {"type": ["string", "integer"]}, "mem_swappiness": {"type": "integer"}, "memswap_limit": {"type": ["number", "string"]}, - "network_mode": { - "type": "string" - }, + "network_mode": {"type": "string"}, "networks": { "oneOf": [ - { - "$ref": "#/definitions/list_of_strings" - }, + {"$ref": "#/definitions/list_of_strings"}, { "type": "object", "patternProperties": { @@ -360,25 +274,16 @@ { "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" - }, + "aliases": {"$ref": "#/definitions/list_of_strings"}, + "ipv4_address": {"type": "string"}, + "ipv6_address": {"type": "string"}, + "link_local_ips": {"$ref": "#/definitions/list_of_strings"}, "priority": {"type": "number"} }, - "additionalProperties": false + "additionalProperties": false, + "patternProperties": {"^x-": {}} }, - { - "type": "null" - } + {"type": "null"} ] } }, @@ -388,207 +293,122 @@ }, "oom_kill_disable": {"type": "boolean"}, "oom_score_adj": {"type": "integer", "minimum": -1000, "maximum": 1000}, - "group_add": { - "type": "array", - "items": { - "type": ["string", "number"] - }, - "uniqueItems": true - }, - "pid": { - "type": [ - "string", - "null" - ] - }, + "pid": {"type": ["string", "null"]}, + "pids_limit": {"type": ["number", "string"]}, + "platform": {"type": "string"}, "ports": { "type": "array", "items": { "oneOf": [ - { - "type": "number", - "format": "ports" - }, - { - "type": "string", - "format": "ports" - }, + {"type": "number", "format": "ports"}, + {"type": "string", "format": "ports"}, { "type": "object", "properties": { - "mode": { - "type": "string" - }, - "target": { - "type": "integer" - }, - "published": { - "type": "integer" - }, - "protocol": { - "type": "string" - } + "mode": {"type": "string"}, + "target": {"type": "integer"}, + "published": {"type": "integer"}, + "protocol": {"type": "string"} }, - "additionalProperties": false + "additionalProperties": false, + "patternProperties": {"^x-": {}} } ] }, "uniqueItems": true }, - "privileged": { - "type": "boolean" - }, - "read_only": { - "type": "boolean" - }, - "restart": { - "type": "string" - }, + "privileged": {"type": "boolean"}, + "pull_policy": {"type": "string", "enum": [ + "always", "never", "if_not_present" + ]}, + "read_only": {"type": "boolean"}, + "restart": {"type": "string"}, "runtime": { + "deprecated": true, "type": "string" }, "scale": { "type": "integer" }, - "security_opt": { - "type": "array", - "items": { - "type": "string" - }, - "uniqueItems": true - }, - "shm_size": { - "type": [ - "number", - "string" - ] - }, + "security_opt": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "shm_size": {"type": ["number", "string"]}, "secrets": { "type": "array", "items": { "oneOf": [ - { - "type": "string" - }, + {"type": "string"}, { "type": "object", "properties": { - "source": { - "type": "string" - }, - "target": { - "type": "string" - }, - "uid": { - "type": "string" - }, - "gid": { - "type": "string" - }, - "mode": { - "type": "number" - } - } + "source": {"type": "string"}, + "target": {"type": "string"}, + "uid": {"type": "string"}, + "gid": {"type": "string"}, + "mode": {"type": "number"} + }, + "additionalProperties": false, + "patternProperties": {"^x-": {}} } ] } }, - "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" - }, + "sysctls": {"$ref": "#/definitions/list_or_dict"}, + "stdin_open": {"type": "boolean"}, + "stop_grace_period": {"type": "string", "format": "duration"}, + "stop_signal": {"type": "string"}, + "tmpfs": {"$ref": "#/definitions/string_or_list"}, + "tty": {"type": "boolean"}, "ulimits": { "type": "object", "patternProperties": { "^[a-z]+$": { "oneOf": [ - { - "type": "integer" - }, + {"type": "integer"}, { "type": "object", "properties": { - "hard": { - "type": "integer" - }, - "soft": { - "type": "integer" - } + "hard": {"type": "integer"}, + "soft": {"type": "integer"} }, - "required": [ - "soft", - "hard" - ], - "additionalProperties": false + "required": ["soft", "hard"], + "additionalProperties": false, + "patternProperties": {"^x-": {}} } ] } } }, - "user": { - "type": "string" - }, - "userns_mode": { - "type": "string" - }, + "user": {"type": "string"}, + "userns_mode": {"type": "string"}, "volumes": { "type": "array", "items": { "oneOf": [ - { - "type": "string" - }, + {"type": "string"}, { "type": "object", - "required": [ - "type" - ], + "required": ["type"], "properties": { - "type": { - "type": "string" - }, - "source": { - "type": "string" - }, - "target": { - "type": "string" - }, - "read_only": { - "type": "boolean" - }, - "consistency": { - "type": "string" - }, + "type": {"type": "string"}, + "source": {"type": "string"}, + "target": {"type": "string"}, + "read_only": {"type": "boolean"}, + "consistency": {"type": "string"}, "bind": { "type": "object", "properties": { - "propagation": { - "type": "string" - } - } + "propagation": {"type": "string"} + }, + "additionalProperties": false, + "patternProperties": {"^x-": {}} }, "volume": { "type": "object", "properties": { - "nocopy": { - "type": "boolean" - } - } + "nocopy": {"type": "boolean"} + }, + "additionalProperties": false, + "patternProperties": {"^x-": {}} }, "tmpfs": { "type": "object", @@ -597,10 +417,13 @@ "type": "integer", "minimum": 0 } - } + }, + "additionalProperties": false, + "patternProperties": {"^x-": {}} } }, - "additionalProperties": false + "additionalProperties": false, + "patternProperties": {"^x-": {}} } ], "uniqueItems": true @@ -608,136 +431,71 @@ }, "volumes_from": { "type": "array", - "items": { - "type": "string" - }, + "items": {"type": "string"}, "uniqueItems": true }, - "working_dir": { - "type": "string" - } - }, - "patternProperties": { - "^x-": {} + "working_dir": {"type": "string"} }, + "patternProperties": {"^x-": {}}, "additionalProperties": false }, + "healthcheck": { "id": "#/definitions/healthcheck", "type": "object", - "additionalProperties": false, "properties": { - "disable": { - "type": "boolean" - }, - "interval": { - "type": "string", - "format": "duration" - }, - "retries": { - "type": "number" - }, + "disable": {"type": "boolean"}, + "interval": {"type": "string", "format": "duration"}, + "retries": {"type": "number"}, "test": { "oneOf": [ - { - "type": "string" - }, - { - "type": "array", - "items": { - "type": "string" - } - } + {"type": "string"}, + {"type": "array", "items": {"type": "string"}} ] }, - "timeout": { - "type": "string", - "format": "duration" - }, - "start_period": { - "type": "string", - "format": "duration" - } - } + "timeout": {"type": "string", "format": "duration"}, + "start_period": {"type": "string", "format": "duration"} + }, + "additionalProperties": false, + "patternProperties": {"^x-": {}} }, "deployment": { "id": "#/definitions/deployment", - "type": [ - "object", - "null" - ], + "type": ["object", "null"], "properties": { - "mode": { - "type": "string" - }, - "endpoint_mode": { - "type": "string" - }, - "replicas": { - "type": "integer" - }, - "labels": { - "$ref": "#/definitions/list_or_dict" - }, + "mode": {"type": "string"}, + "endpoint_mode": {"type": "string"}, + "replicas": {"type": "integer"}, + "labels": {"$ref": "#/definitions/list_or_dict"}, "rollback_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" - }, - "order": { - "type": "string", - "enum": [ - "start-first", - "stop-first" - ] - } + "parallelism": {"type": "integer"}, + "delay": {"type": "string", "format": "duration"}, + "failure_action": {"type": "string"}, + "monitor": {"type": "string", "format": "duration"}, + "max_failure_ratio": {"type": "number"}, + "order": {"type": "string", "enum": [ + "start-first", "stop-first" + ]} }, - "additionalProperties": false + "additionalProperties": false, + "patternProperties": {"^x-": {}} }, "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" - }, - "order": { - "type": "string", - "enum": [ - "start-first", - "stop-first" - ] - } + "parallelism": {"type": "integer"}, + "delay": {"type": "string", "format": "duration"}, + "failure_action": {"type": "string"}, + "monitor": {"type": "string", "format": "duration"}, + "max_failure_ratio": {"type": "number"}, + "order": {"type": "string", "enum": [ + "start-first", "stop-first" + ]} }, - "additionalProperties": false + "additionalProperties": false, + "patternProperties": {"^x-": {}} }, "resources": { "type": "object", @@ -745,82 +503,60 @@ "limits": { "type": "object", "properties": { - "cpus": { - "type": "string" - }, - "memory": { - "type": "string" - } + "cpus": {"type": "string"}, + "memory": {"type": "string"} }, - "additionalProperties": false + "additionalProperties": false, + "patternProperties": {"^x-": {}} }, "reservations": { "type": "object", "properties": { - "cpus": { - "type": "string" - }, - "memory": { - "type": "string" - }, - "generic_resources": { - "$ref": "#/definitions/generic_resources" - } + "cpus": {"type": "string"}, + "memory": {"type": "string"}, + "generic_resources": {"$ref": "#/definitions/generic_resources"} }, - "additionalProperties": false + "additionalProperties": false, + "patternProperties": {"^x-": {}} } }, - "additionalProperties": false + "additionalProperties": false, + "patternProperties": {"^x-": {}} }, "restart_policy": { "type": "object", "properties": { - "condition": { - "type": "string" - }, - "delay": { - "type": "string", - "format": "duration" - }, - "max_attempts": { - "type": "integer" - }, - "window": { - "type": "string", - "format": "duration" - } + "condition": {"type": "string"}, + "delay": {"type": "string", "format": "duration"}, + "max_attempts": {"type": "integer"}, + "window": {"type": "string", "format": "duration"} }, - "additionalProperties": false + "additionalProperties": false, + "patternProperties": {"^x-": {}} }, "placement": { "type": "object", "properties": { - "constraints": { - "type": "array", - "items": { - "type": "string" - } - }, + "constraints": {"type": "array", "items": {"type": "string"}}, "preferences": { "type": "array", "items": { "type": "object", "properties": { - "spread": { - "type": "string" - } + "spread": {"type": "string"} }, - "additionalProperties": false + "additionalProperties": false, + "patternProperties": {"^x-": {}} } }, - "max_replicas_per_node": { - "type": "integer" - } + "max_replicas_per_node": {"type": "integer"} }, - "additionalProperties": false + "additionalProperties": false, + "patternProperties": {"^x-": {}} } }, - "additionalProperties": false + "additionalProperties": false, + "patternProperties": {"^x-": {}} }, "generic_resources": { "id": "#/definitions/generic_resources", @@ -831,248 +567,160 @@ "discrete_resource_spec": { "type": "object", "properties": { - "kind": { - "type": "string" - }, - "value": { - "type": "number" - } + "kind": {"type": "string"}, + "value": {"type": "number"} }, - "additionalProperties": false + "additionalProperties": false, + "patternProperties": {"^x-": {}} } }, - "additionalProperties": false + "additionalProperties": false, + "patternProperties": {"^x-": {}} } }, "network": { "id": "#/definitions/network", - "type": [ - "object", - "null" - ], + "type": ["object", "null"], "properties": { - "name": { - "type": "string" - }, - "driver": { - "type": "string" - }, + "name": {"type": "string"}, + "driver": {"type": "string"}, "driver_opts": { "type": "object", "patternProperties": { - "^.+$": { - "type": [ - "string", - "number" - ] - } + "^.+$": {"type": ["string", "number"]} } }, "ipam": { "type": "object", "properties": { - "driver": { - "type": "string" - }, + "driver": {"type": "string"}, "config": { "type": "array", "items": { "type": "object", "properties": { - "subnet": { - "type": "string", - "format": "subnet_ip_address" - }, + "subnet": {"type": "string", "format": "subnet_ip_address"}, "ip_range": {"type": "string"}, "gateway": {"type": "string"}, "aux_addresses": { "type": "object", - "patternProperties": { - "^.+$": {"type": "string"} - }, - "additionalProperties": false + "additionalProperties": false, + "patternProperties": {"^.+$": {"type": "string"}} } - }, - "additionalProperties": false - } + } + }, + "additionalProperties": false, + "patternProperties": {"^x-": {}} }, "options": { "type": "object", - "patternProperties": { - "^.+$": {"type": "string"} - }, - "additionalProperties": false + "additionalProperties": false, + "patternProperties": {"^.+$": {"type": "string"}} } }, "additionalProperties": false }, "external": { - "type": [ - "boolean", - "object" - ], + "type": ["boolean", "object"], "properties": { "name": { + "deprecated": true, "type": "string" } }, - "additionalProperties": false + "additionalProperties": false, + "patternProperties": {"^x-": {}} }, - "internal": { - "type": "boolean" - }, - "attachable": { - "type": "boolean" - }, - "labels": { - "$ref": "#/definitions/list_or_dict" - } + "internal": {"type": "boolean"}, + "attachable": {"type": "boolean"}, + "labels": {"$ref": "#/definitions/list_or_dict"} }, - "patternProperties": { - "^x-": {} - }, - "additionalProperties": false + "additionalProperties": false, + "patternProperties": {"^x-": {}} }, "volume": { "id": "#/definitions/volume", - "type": [ - "object", - "null" - ], + "type": ["object", "null"], "properties": { - "name": { - "type": "string" - }, - "driver": { - "type": "string" - }, + "name": {"type": "string"}, + "driver": {"type": "string"}, "driver_opts": { "type": "object", "patternProperties": { - "^.+$": { - "type": [ - "string", - "number" - ] - } + "^.+$": {"type": ["string", "number"]} } }, "external": { - "type": [ - "boolean", - "object" - ], + "type": ["boolean", "object"], "properties": { "name": { + "deprecated": true, "type": "string" } }, - "additionalProperties": false + "additionalProperties": false, + "patternProperties": {"^x-": {}} }, - "labels": { - "$ref": "#/definitions/list_or_dict" - } - }, - "patternProperties": { - "^x-": {} + "labels": {"$ref": "#/definitions/list_or_dict"} }, - "additionalProperties": false + "additionalProperties": false, + "patternProperties": {"^x-": {}} }, "secret": { "id": "#/definitions/secret", "type": "object", "properties": { - "name": { - "type": "string" - }, - "file": { - "type": "string" - }, + "name": {"type": "string"}, + "file": {"type": "string"}, "external": { - "type": [ - "boolean", - "object" - ], + "type": ["boolean", "object"], "properties": { - "name": { - "type": "string" - } + "name": {"type": "string"} } }, - "labels": { - "$ref": "#/definitions/list_or_dict" - }, - "driver": { - "type": "string" - }, + "labels": {"$ref": "#/definitions/list_or_dict"}, + "driver": {"type": "string"}, "driver_opts": { "type": "object", "patternProperties": { - "^.+$": { - "type": [ - "string", - "number" - ] - } + "^.+$": {"type": ["string", "number"]} } }, - "template_driver": { - "type": "string" - } - }, - "patternProperties": { - "^x-": {} + "template_driver": {"type": "string"} }, - "additionalProperties": false + "additionalProperties": false, + "patternProperties": {"^x-": {}} }, "config": { "id": "#/definitions/config", "type": "object", "properties": { - "name": { - "type": "string" - }, - "file": { - "type": "string" - }, + "name": {"type": "string"}, + "file": {"type": "string"}, "external": { - "type": [ - "boolean", - "object" - ], + "type": ["boolean", "object"], "properties": { "name": { + "deprecated": true, "type": "string" } } }, - "labels": { - "$ref": "#/definitions/list_or_dict" - }, - "template_driver": { - "type": "string" - } - }, - "patternProperties": { - "^x-": {} + "labels": {"$ref": "#/definitions/list_or_dict"}, + "template_driver": {"type": "string"} }, - "additionalProperties": false + "additionalProperties": false, + "patternProperties": {"^x-": {}} }, "string_or_list": { "oneOf": [ - { - "type": "string" - }, - { - "$ref": "#/definitions/list_of_strings" - } + {"type": "string"}, + {"$ref": "#/definitions/list_of_strings"} ] }, "list_of_strings": { "type": "array", - "items": { - "type": "string" - }, + "items": {"type": "string"}, "uniqueItems": true }, "list_or_dict": { @@ -1081,44 +729,40 @@ "type": "object", "patternProperties": { ".+": { - "type": [ - "string", - "number", - "null" - ] + "type": ["string", "number", "null"] } }, "additionalProperties": false }, - { - "type": "array", - "items": { - "type": "string" - }, - "uniqueItems": true - } + {"type": "array", "items": {"type": "string"}, "uniqueItems": true} ] }, + "blkio_limit": { + "type": "object", + "properties": { + "path": {"type": "string"}, + "rate": {"type": ["integer", "string"]} + }, + "additionalProperties": false + }, + "blkio_weight": { + "type": "object", + "properties": { + "path": {"type": "string"}, + "weight": {"type": "integer"} + }, + "additionalProperties": false + }, "constraints": { "service": { "id": "#/definitions/constraints/service", "anyOf": [ - { - "required": [ - "build" - ] - }, - { - "required": [ - "image" - ] - } + {"required": ["build"]}, + {"required": ["image"]} ], "properties": { "build": { - "required": [ - "context" - ] + "required": ["context"] } } } From 945faab54d27924fe78461a29ddb3477135c9165 Mon Sep 17 00:00:00 2001 From: aiordache Date: Fri, 10 Jul 2020 11:39:39 +0200 Subject: [PATCH 3960/4072] parse deploy.resources Signed-off-by: aiordache --- compose/config/config.py | 29 -------------- compose/project.py | 85 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 29 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index a3110682c16..c5791e1533c 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -932,35 +932,6 @@ def finalize_service(service_config, service_names, version, environment, return normalize_v1_service_format(service_dict) -def translate_resource_keys_to_container_config(resources_dict, service_dict): - if 'limits' in resources_dict: - service_dict['mem_limit'] = resources_dict['limits'].get('memory') - if 'cpus' in resources_dict['limits']: - service_dict['cpus'] = float(resources_dict['limits']['cpus']) - if 'reservations' in resources_dict: - service_dict['mem_reservation'] = resources_dict['reservations'].get('memory') - if 'cpus' in resources_dict['reservations']: - return ['resources.reservations.cpus'] - return [] - - -def convert_restart_policy(name): - try: - return { - 'any': 'always', - 'none': 'no', - 'on-failure': 'on-failure' - }[name] - except KeyError: - raise ConfigurationError('Invalid restart policy "{}"'.format(name)) - - -def convert_credential_spec_to_security_opt(credential_spec): - if 'file' in credential_spec: - return 'file://{file}'.format(file=credential_spec['file']) - return 'registry://{registry}'.format(registry=credential_spec['registry']) - - def normalize_v1_service_format(service_dict): if 'log_driver' in service_dict or 'log_opt' in service_dict: if 'logging' not in service_dict: diff --git a/compose/project.py b/compose/project.py index 216615aee6c..c02a1d8dd85 100644 --- a/compose/project.py +++ b/compose/project.py @@ -125,6 +125,16 @@ def from_config(cls, name, config_data, client, default_platform=None, extra_lab service_dict['scale'] = project.get_service_scale(service_dict) + service_dict = translate_credential_spec_to_security_opt(service_dict) + service_dict, ignored_keys = translate_deploy_keys_to_container_config( + service_dict + ) + if ignored_keys: + log.warning( + 'The following deploy sub-keys are not supported and have' + ' been ignored: {}'.format(', '.join(ignored_keys)) + ) + project.services.append( Service( service_dict.pop('name'), @@ -796,6 +806,81 @@ def container_operation_with_timeout(container): return container_operation_with_timeout +def translate_credential_spec_to_security_opt(service_dict): + result = [] + + if 'credential_spec' in service_dict: + spec = convert_credential_spec_to_security_opt(service_dict['credential_spec']) + result.append('credentialspec={spec}'.format(spec=spec)) + + if result: + service_dict['security_opt'] = result + + return service_dict + + +def translate_resource_keys_to_container_config(resources_dict, service_dict): + if 'limits' in resources_dict: + service_dict['mem_limit'] = resources_dict['limits'].get('memory') + if 'cpus' in resources_dict['limits']: + service_dict['cpus'] = float(resources_dict['limits']['cpus']) + if 'reservations' in resources_dict: + service_dict['mem_reservation'] = resources_dict['reservations'].get('memory') + if 'cpus' in resources_dict['reservations']: + return ['resources.reservations.cpus'] + return [] + + +def convert_restart_policy(name): + try: + return { + 'any': 'always', + 'none': 'no', + 'on-failure': 'on-failure' + }[name] + except KeyError: + raise ConfigurationError('Invalid restart policy "{}"'.format(name)) + + +def convert_credential_spec_to_security_opt(credential_spec): + if 'file' in credential_spec: + return 'file://{file}'.format(file=credential_spec['file']) + return 'registry://{registry}'.format(registry=credential_spec['registry']) + + +def translate_deploy_keys_to_container_config(service_dict): + if 'credential_spec' in service_dict: + del service_dict['credential_spec'] + if 'configs' in service_dict: + del service_dict['configs'] + + if 'deploy' not in service_dict: + return service_dict, [] + + deploy_dict = service_dict['deploy'] + ignored_keys = [ + k for k in ['endpoint_mode', 'labels', 'update_config', 'rollback_config'] + if k in deploy_dict + ] + + if 'restart_policy' in deploy_dict: + service_dict['restart'] = { + 'Name': convert_restart_policy(deploy_dict['restart_policy'].get('condition', 'any')), + 'MaximumRetryCount': deploy_dict['restart_policy'].get('max_attempts', 0) + } + for k in deploy_dict['restart_policy'].keys(): + if k != 'condition' and k != 'max_attempts': + ignored_keys.append('restart_policy.{}'.format(k)) + + ignored_keys.extend( + translate_resource_keys_to_container_config( + deploy_dict.get('resources', {}), service_dict + ) + ) + del service_dict['deploy'] + return service_dict, ignored_keys + + def get_volumes_from(project, service_dict): volumes_from = service_dict.pop('volumes_from', None) if not volumes_from: From faedc4aa9e219f0bb9f70096873cf04ee49cf60c Mon Sep 17 00:00:00 2001 From: aiordache Date: Fri, 10 Jul 2020 13:31:12 +0200 Subject: [PATCH 3961/4072] Add ipam.patternProperties to schema Signed-off-by: aiordache --- compose/config/config_schema_compose_spec.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/compose/config/config_schema_compose_spec.json b/compose/config/config_schema_compose_spec.json index dee9826fc8c..3b26ba82560 100644 --- a/compose/config/config_schema_compose_spec.json +++ b/compose/config/config_schema_compose_spec.json @@ -618,7 +618,8 @@ "patternProperties": {"^.+$": {"type": "string"}} } }, - "additionalProperties": false + "additionalProperties": false, + "patternProperties": {"^x-": {}} }, "external": { "type": ["boolean", "object"], From 6cf72381f9359c3040153324540f478c907311b3 Mon Sep 17 00:00:00 2001 From: aiordache Date: Fri, 10 Jul 2020 17:28:02 +0200 Subject: [PATCH 3962/4072] error out on both scale and deploy.replicas being set Signed-off-by: aiordache --- compose/project.py | 36 ++++++++++++++++++++++++------------ 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/compose/project.py b/compose/project.py index c02a1d8dd85..af6b9ce0c88 100644 --- a/compose/project.py +++ b/compose/project.py @@ -276,19 +276,31 @@ def get_pid_mode(self, service_dict): def get_service_scale(self, service_dict): # service.scale for v2 and deploy.replicas for v3 - scale = service_dict.get('scale', 1) + scale = service_dict.get('scale', None) deploy_dict = service_dict.get('deploy', None) - if deploy_dict: - if deploy_dict.get('mode', 'replicated') == 'replicated': - scale = deploy_dict.get('replicas', scale) - # deploy may contain placement constraints introduced in v3.8 - max_replicas = deploy_dict.get('placement', {}).get( - 'max_replicas_per_node', - scale) - scale = min(scale, max_replicas) - if max_replicas < scale: - log.warning("Scale is limited to {} ('max_replicas_per_node' field).".format( - max_replicas)) + if not deploy_dict: + return 1 if scale is None else scale + + if deploy_dict.get('mode', 'replicated') != 'replicated': + return 1 if scale is None else scale + + replicas = deploy_dict.get('replicas', None) + if scale and replicas: + raise ConfigurationError( + "Both service.scale and service.deploy.replicas are set." + " Only one of them must be set." + ) + if replicas: + scale = replicas + # deploy may contain placement constraints introduced in v3.8 + max_replicas = deploy_dict.get('placement', {}).get( + 'max_replicas_per_node', + scale) + + scale = min(scale, max_replicas) + if max_replicas < scale: + log.warning("Scale is limited to {} ('max_replicas_per_node' field).".format( + max_replicas)) return scale def start(self, service_names=None, **options): From 63115111170d042fbf6728e0f0152e0b7a838f60 Mon Sep 17 00:00:00 2001 From: "Santiago M. Mola" Date: Wed, 15 Jul 2020 18:37:19 +0200 Subject: [PATCH 3963/4072] avoid using realpath in scripts scripts/run uses realpath when COMPOSE_FILE is set. realpath is not available in some systems (e.g. macOS), and readlink -f isn't either. Replaced with a more portable approach. Signed-off-by: Santiago M. Mola --- script/run/run.sh | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/script/run/run.sh b/script/run/run.sh index a6de02f7339..8bc6798f3fe 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -36,7 +36,10 @@ if [ "$(pwd)" != '/' ]; then fi if [ -n "$COMPOSE_FILE" ]; then COMPOSE_OPTIONS="$COMPOSE_OPTIONS -e COMPOSE_FILE=$COMPOSE_FILE" - compose_dir=$(realpath "$(dirname "$COMPOSE_FILE")") + compose_dir="$(dirname "$COMPOSE_FILE")" + # canonicalize dir, do not use realpath or readlink -f + # since they are not available in some systems (e.g. macOS). + compose_dir="$(cd "$compose_dir" && pwd)" fi if [ -n "$COMPOSE_PROJECT_NAME" ]; then COMPOSE_OPTIONS="-e COMPOSE_PROJECT_NAME $COMPOSE_OPTIONS" From c1451033859e449ff193560a690fa5c11139d5d2 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2020 21:22:57 +0000 Subject: [PATCH 3964/4072] Bump coverage from 5.1 to 5.2.1 Bumps [coverage](https://github.com/nedbat/coveragepy) from 5.1 to 5.2.1. - [Release notes](https://github.com/nedbat/coveragepy/releases) - [Changelog](https://github.com/nedbat/coveragepy/blob/master/CHANGES.rst) - [Commits](https://github.com/nedbat/coveragepy/compare/coverage-5.1...coverage-5.2.1) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index b31226b6933..b0f568e0ee6 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,5 +1,5 @@ Click==7.1.2 -coverage==5.1 +coverage==5.2.1 ddt==1.4.1 flake8==3.8.3 gitpython==2.1.15 From aec2f5acba5e74515fc32c120cb60319c408d19a Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2020 21:26:09 +0000 Subject: [PATCH 3965/4072] Bump virtualenv from 16.2.0 to 20.0.29 Bumps [virtualenv](https://github.com/pypa/virtualenv) from 16.2.0 to 20.0.29. - [Release notes](https://github.com/pypa/virtualenv/releases) - [Changelog](https://github.com/pypa/virtualenv/blob/master/docs/changelog.rst) - [Commits](https://github.com/pypa/virtualenv/compare/16.2.0...20.0.29) Signed-off-by: dependabot-preview[bot] --- requirements-indirect.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-indirect.txt b/requirements-indirect.txt index 472e9393a5e..3ef98211e8a 100644 --- a/requirements-indirect.txt +++ b/requirements-indirect.txt @@ -24,5 +24,5 @@ smmap==3.0.4 smmap2==3.0.1 toml==0.10.1 tox==2.9.1 -virtualenv==16.2.0 +virtualenv==20.0.29 wcwidth==0.1.9 From 5a47b692c1142e632f89729ad76a1eddb626a33f Mon Sep 17 00:00:00 2001 From: aiordache Date: Wed, 5 Aug 2020 10:49:40 +0200 Subject: [PATCH 3966/4072] Bump virtualenv version to 20.0.29 Signed-off-by: aiordache --- Dockerfile | 2 +- script/build/windows.ps1 | 2 +- script/setup/osx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 90745f9c448..a49890aca92 100644 --- a/Dockerfile +++ b/Dockerfile @@ -46,7 +46,7 @@ ENTRYPOINT ["sh", "/usr/local/bin/docker-compose-entrypoint.sh"] COPY --from=docker-cli /usr/local/bin/docker /usr/local/bin/docker WORKDIR /code/ # FIXME(chris-crone): virtualenv 16.3.0 breaks build, force 16.2.0 until fixed -RUN pip install virtualenv==16.2.0 +RUN pip install virtualenv==20.0.29 RUN pip install tox==2.9.1 COPY requirements-indirect.txt . diff --git a/script/build/windows.ps1 b/script/build/windows.ps1 index 472b31ca1ed..b01feb7509b 100644 --- a/script/build/windows.ps1 +++ b/script/build/windows.ps1 @@ -16,7 +16,7 @@ # # 4. In Powershell, run the following commands: # -# $ pip install 'virtualenv==16.2.0' +# $ pip install 'virtualenv==20.0.29' # $ Set-ExecutionPolicy -Scope CurrentUser RemoteSigned # # 5. Clone the repository: diff --git a/script/setup/osx b/script/setup/osx index 678b7f22a47..25b13ded216 100755 --- a/script/setup/osx +++ b/script/setup/osx @@ -36,7 +36,7 @@ if ! [ -x "$(command -v python3)" ]; then brew install python3 fi if ! [ -x "$(command -v virtualenv)" ]; then - pip3 install virtualenv==16.2.0 + pip3 install virtualenv==20.0.29 fi # From ef2f0ce5ab30a414c24b11cabf497d92a0a17f81 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Wed, 5 Aug 2020 08:53:35 +0000 Subject: [PATCH 3967/4072] Bump gitpython from 2.1.15 to 3.1.7 Bumps [gitpython](https://github.com/gitpython-developers/GitPython) from 2.1.15 to 3.1.7. - [Release notes](https://github.com/gitpython-developers/GitPython/releases) - [Changelog](https://github.com/gitpython-developers/GitPython/blob/master/CHANGES) - [Commits](https://github.com/gitpython-developers/GitPython/compare/2.1.15...3.1.7) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index b0f568e0ee6..b0be27d3b39 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -2,7 +2,7 @@ Click==7.1.2 coverage==5.2.1 ddt==1.4.1 flake8==3.8.3 -gitpython==2.1.15 +gitpython==3.1.7 mock==3.0.5 pytest==5.4.3; python_version >= '3.5' pytest==4.6.5; python_version < '3.5' From b4e378934d34be0bb78b173279bd34ebdbff5737 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2020 21:27:14 +0000 Subject: [PATCH 3968/4072] Bump tox from 2.9.1 to 3.18.1 Bumps [tox](https://github.com/tox-dev/tox) from 2.9.1 to 3.18.1. - [Release notes](https://github.com/tox-dev/tox/releases) - [Changelog](https://github.com/tox-dev/tox/blob/master/docs/changelog.rst) - [Commits](https://github.com/tox-dev/tox/compare/2.9.1...3.18.1) Signed-off-by: dependabot-preview[bot] --- requirements-indirect.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-indirect.txt b/requirements-indirect.txt index 9f9c0aec0fd..60c92e728e4 100644 --- a/requirements-indirect.txt +++ b/requirements-indirect.txt @@ -23,6 +23,6 @@ pyrsistent==0.16.0 smmap==3.0.4 smmap2==3.0.1 toml==0.10.1 -tox==2.9.1 +tox==3.18.1 virtualenv==20.0.29 wcwidth==0.1.9 From 836fc569014de13181793599f50060b2c1e07729 Mon Sep 17 00:00:00 2001 From: aiordache Date: Wed, 5 Aug 2020 15:06:47 +0200 Subject: [PATCH 3969/4072] Bump tox version to 3.18.1 in dockerfile Signed-off-by: aiordache --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index a49890aca92..4661b645bb1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -47,7 +47,7 @@ COPY --from=docker-cli /usr/local/bin/docker /usr/local/bin/docker WORKDIR /code/ # FIXME(chris-crone): virtualenv 16.3.0 breaks build, force 16.2.0 until fixed RUN pip install virtualenv==20.0.29 -RUN pip install tox==2.9.1 +RUN pip install tox==3.18.1 COPY requirements-indirect.txt . COPY requirements.txt . From a9c191d7523c025dddfdcc21f4f6df0c92200a17 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Wed, 5 Aug 2020 14:50:23 +0000 Subject: [PATCH 3970/4072] Bump wcwidth from 0.1.9 to 0.2.5 Bumps [wcwidth](https://github.com/jquast/wcwidth) from 0.1.9 to 0.2.5. - [Release notes](https://github.com/jquast/wcwidth/releases) - [Commits](https://github.com/jquast/wcwidth/compare/0.1.9...0.2.5) Signed-off-by: dependabot-preview[bot] --- requirements-indirect.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-indirect.txt b/requirements-indirect.txt index 60c92e728e4..7e06205baa2 100644 --- a/requirements-indirect.txt +++ b/requirements-indirect.txt @@ -25,4 +25,4 @@ smmap2==3.0.1 toml==0.10.1 tox==3.18.1 virtualenv==20.0.29 -wcwidth==0.1.9 +wcwidth==0.2.5 From e1fbd0cd130702e814c5df5fe2d3ded02d5d01ec Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2020 21:25:39 +0000 Subject: [PATCH 3971/4072] Bump pynacl from 1.3.0 to 1.4.0 Bumps [pynacl](https://github.com/pyca/pynacl) from 1.3.0 to 1.4.0. - [Release notes](https://github.com/pyca/pynacl/releases) - [Changelog](https://github.com/pyca/pynacl/blob/master/CHANGELOG.rst) - [Commits](https://github.com/pyca/pynacl/compare/1.3.0...1.4.0) Signed-off-by: dependabot-preview[bot] --- requirements-indirect.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-indirect.txt b/requirements-indirect.txt index 60c92e728e4..d60a01638c2 100644 --- a/requirements-indirect.txt +++ b/requirements-indirect.txt @@ -17,7 +17,7 @@ py==1.9.0 pycodestyle==2.6.0 pycparser==2.20 pyflakes==2.2.0 -PyNaCl==1.3.0 +PyNaCl==1.4.0 pyparsing==2.4.7 pyrsistent==0.16.0 smmap==3.0.4 From 3affbbcf8e3fcc91f166084d3d982475146c14bf Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Wed, 5 Aug 2020 16:10:00 +0000 Subject: [PATCH 3972/4072] Bump distlib from 0.3.0 to 0.3.1 Bumps [distlib](https://bitbucket.org/pypa/distlib) from 0.3.0 to 0.3.1. - [Changelog](https://bitbucket.org/pypa/distlib/src/master/CHANGES.rst) - [Commits](https://bitbucket.org/pypa/distlib/branches/compare/0.3.1..0.3.0) Signed-off-by: dependabot-preview[bot] --- requirements-indirect.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-indirect.txt b/requirements-indirect.txt index 5e667e5b331..917ef6817fd 100644 --- a/requirements-indirect.txt +++ b/requirements-indirect.txt @@ -4,7 +4,7 @@ attrs==19.3.0 bcrypt==3.1.7 cffi==1.14.0 cryptography==2.9.2 -distlib==0.3.0 +distlib==0.3.1 entrypoints==0.3 filelock==3.0.12 gitdb2==4.0.2 From 06462cd604d29ca88bb73a1a030b2e4f58c2d2a3 Mon Sep 17 00:00:00 2001 From: Eric Hripko Date: Fri, 15 May 2020 14:32:18 +0100 Subject: [PATCH 3973/4072] Make run behave in the same way as up Signed-off-by: Eric Hripko --- compose/cli/main.py | 37 +++++++++---------- compose/project.py | 18 ++++++--- compose/service.py | 35 ++++++++++++++---- tests/acceptance/cli_test.py | 8 ++++ .../docker-compose.yml | 19 ++++++++++ tests/unit/cli_test.py | 30 ++++++++++++++- 6 files changed, 112 insertions(+), 35 deletions(-) create mode 100644 tests/fixtures/v2-unhealthy-dependencies/docker-compose.yml diff --git a/compose/cli/main.py b/compose/cli/main.py index 079ea160a57..7f9a6c7836b 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -1298,31 +1298,28 @@ def build_one_off_container_options(options, detach, command): def run_one_off_container(container_options, project, service, options, toplevel_options, toplevel_environment): - if not options['--no-deps']: - deps = service.get_dependency_names() - if deps: - project.up( - service_names=deps, - start_deps=True, - strategy=ConvergenceStrategy.never, - rescale=False - ) - - project.initialize() - - container = service.create_container( - quiet=True, + detach = options.get('--detach') + use_network_aliases = options.get('--use-aliases') + containers = project.up( + service_names=[service.name], + start_deps=not options['--no-deps'], + strategy=ConvergenceStrategy.never, + detached=detach, + rescale=False, one_off=True, - **container_options) - - use_network_aliases = options['--use-aliases'] + override_options=container_options, + ) + try: + container = next(c for c in containers if c.service == service.name) + except StopIteration: + raise OperationFailedError('Could not bring up the requested service') - if options.get('--detach'): + if detach: service.start_container(container, use_network_aliases) print(container.name) return - def remove_container(force=False): + def remove_container(): if options['--rm']: project.client.remove_container(container.id, force=True, v=True) @@ -1355,7 +1352,7 @@ def remove_container(force=False): exit_code = 1 except (signals.ShutdownException, signals.HangUpException): project.client.kill(container.id) - remove_container(force=True) + remove_container() sys.exit(2) remove_container() diff --git a/compose/project.py b/compose/project.py index af6b9ce0c88..e80d68ef1ae 100644 --- a/compose/project.py +++ b/compose/project.py @@ -565,6 +565,8 @@ def up(self, renew_anonymous_volumes=False, silent=False, cli=False, + one_off=False, + override_options=None, ): if cli: @@ -584,7 +586,11 @@ def up(self, for svc in services: svc.ensure_image_exists(do_build=do_build, silent=silent, cli=cli) plans = self._get_convergence_plans( - services, strategy, always_recreate_deps=always_recreate_deps) + services, + strategy, + always_recreate_deps=always_recreate_deps, + one_off=service_names if one_off else [], + ) def do(service): @@ -597,6 +603,7 @@ def do(service): start=start, reset_container_image=reset_container_image, renew_anonymous_volumes=renew_anonymous_volumes, + override_options=override_options, ) def get_deps(service): @@ -628,7 +635,7 @@ def initialize(self): self.networks.initialize() self.volumes.initialize() - def _get_convergence_plans(self, services, strategy, always_recreate_deps=False): + def _get_convergence_plans(self, services, strategy, always_recreate_deps=False, one_off=None): plans = {} for service in services: @@ -638,6 +645,7 @@ def _get_convergence_plans(self, services, strategy, always_recreate_deps=False) if name in plans and plans[name].action in ('recreate', 'create') ] + is_one_off = one_off and service.name in one_off if updated_dependencies and strategy.allows_recreate: log.debug('%s has upstream changes (%s)', @@ -649,11 +657,11 @@ def _get_convergence_plans(self, services, strategy, always_recreate_deps=False) container_has_links = any(c.get('HostConfig.Links') for c in service.containers()) should_recreate_for_links = service_has_links ^ container_has_links if always_recreate_deps or containers_stopped or should_recreate_for_links: - plan = service.convergence_plan(ConvergenceStrategy.always) + plan = service.convergence_plan(ConvergenceStrategy.always, is_one_off) else: - plan = service.convergence_plan(strategy) + plan = service.convergence_plan(strategy, is_one_off) else: - plan = service.convergence_plan(strategy) + plan = service.convergence_plan(strategy, is_one_off) plans[service.name] = plan diff --git a/compose/service.py b/compose/service.py index 673b0b335e8..50b00279617 100644 --- a/compose/service.py +++ b/compose/service.py @@ -388,9 +388,12 @@ def platform(self): platform = self.default_platform return platform - def convergence_plan(self, strategy=ConvergenceStrategy.changed): + def convergence_plan(self, strategy=ConvergenceStrategy.changed, one_off=False): containers = self.containers(stopped=True) + if one_off: + return ConvergencePlan('one_off', []) + if not containers: return ConvergencePlan('create', []) @@ -439,25 +442,37 @@ def _containers_have_diverged(self, containers): return has_diverged - def _execute_convergence_create(self, scale, detached, start): + def _execute_convergence_create(self, scale, detached, start, one_off=False, override_options=None): i = self._next_container_number() def create_and_start(service, n): - container = service.create_container(number=n, quiet=True) + if one_off: + container = service.create_container(one_off=True, quiet=True, **override_options) + else: + container = service.create_container(number=n, quiet=True) if not detached: container.attach_log_stream() - if start: + if start and not one_off: self.start_container(container) return container + def get_name(service_name): + if one_off: + return "_".join([ + service_name.project, + service_name.service, + "run", + ]) + return self.get_container_name(service_name.service, service_name.number) + containers, errors = parallel_execute( [ ServiceName(self.project, self.name, index) for index in range(i, i + scale) ], lambda service_name: create_and_start(self, service_name.number), - lambda service_name: self.get_container_name(service_name.service, service_name.number), + get_name, "Creating" ) for error in errors.values(): @@ -528,16 +543,20 @@ def stop_and_remove(container): def execute_convergence_plan(self, plan, timeout=None, detached=False, start=True, scale_override=None, rescale=True, reset_container_image=False, - renew_anonymous_volumes=False): + renew_anonymous_volumes=False, override_options=None): (action, containers) = plan scale = scale_override if scale_override is not None else self.scale_num containers = sorted(containers, key=attrgetter('number')) self.show_scale_warnings(scale) - if action == 'create': + if action in ['create', 'one_off']: return self._execute_convergence_create( - scale, detached, start + scale, + detached, + start, + one_off=(action == 'one_off'), + override_options=override_options ) # The create action needs always needs an initial scale, but otherwise, diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 180c3824f4b..f3ef08415f3 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1780,6 +1780,14 @@ def test_run_service_with_dependencies(self): assert len(db.containers()) == 1 assert len(console.containers()) == 0 + def test_run_service_with_unhealthy_dependencies(self): + self.base_dir = 'tests/fixtures/v2-unhealthy-dependencies' + result = self.dispatch(['run', 'web', '/bin/true'], returncode=1) + assert re.search( + re.compile('for web .*is unhealthy.*', re.MULTILINE), + result.stderr + ) + def test_run_service_with_scaled_dependencies(self): self.base_dir = 'tests/fixtures/v2-dependencies' self.dispatch(['up', '-d', '--scale', 'db=2', '--scale', 'console=0']) diff --git a/tests/fixtures/v2-unhealthy-dependencies/docker-compose.yml b/tests/fixtures/v2-unhealthy-dependencies/docker-compose.yml new file mode 100644 index 00000000000..d96473e5a9f --- /dev/null +++ b/tests/fixtures/v2-unhealthy-dependencies/docker-compose.yml @@ -0,0 +1,19 @@ +version: "2.1" +services: + db: + image: busybox:1.31.0-uclibc + command: top + healthcheck: + test: exit 1 + interval: 1s + timeout: 1s + retries: 1 + web: + image: busybox:1.31.0-uclibc + command: top + depends_on: + db: + condition: service_healthy + console: + image: busybox:1.31.0-uclibc + command: top diff --git a/tests/unit/cli_test.py b/tests/unit/cli_test.py index c6891bc3412..2c53e00a819 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -18,6 +18,8 @@ from compose.cli.errors import UserError from compose.cli.main import TopLevelCommand from compose.const import IS_WINDOWS_PLATFORM +from compose.const import LABEL_SERVICE +from compose.container import Container from compose.project import Project @@ -94,12 +96,26 @@ def test_command_help_nonexistent(self): @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) + @mock.patch('compose.service.Container.create') @mock.patch.dict(os.environ) - def test_run_interactive_passes_logs_false(self, mock_pseudo_terminal, mock_run_operation): + def test_run_interactive_passes_logs_false( + self, + mock_container_create, + mock_pseudo_terminal, + mock_run_operation, + ): os.environ['COMPOSE_INTERACTIVE_NO_CLI'] = 'true' mock_client = mock.create_autospec(docker.APIClient) mock_client.api_version = DEFAULT_DOCKER_API_VERSION mock_client._general_configs = {} + mock_container_create.return_value = Container(mock_client, { + 'Id': '37b35e0ba80d91009d37e16f249b32b84f72bda269985578ed6c75a0a13fcaa8', + 'Config': { + 'Labels': { + LABEL_SERVICE: 'service', + } + }, + }, has_been_inspected=True) project = Project.from_config( name='composetest', client=mock_client, @@ -132,10 +148,20 @@ def test_run_interactive_passes_logs_false(self, mock_pseudo_terminal, mock_run_ _, _, call_kwargs = mock_run_operation.mock_calls[0] assert call_kwargs['logs'] is False - def test_run_service_with_restart_always(self): + @mock.patch('compose.service.Container.create') + def test_run_service_with_restart_always(self, mock_container_create): mock_client = mock.create_autospec(docker.APIClient) mock_client.api_version = DEFAULT_DOCKER_API_VERSION mock_client._general_configs = {} + mock_container_create.return_value = Container(mock_client, { + 'Id': '37b35e0ba80d91009d37e16f249b32b84f72bda269985578ed6c75a0a13fcaa8', + 'Name': 'composetest_service_37b35', + 'Config': { + 'Labels': { + LABEL_SERVICE: 'service', + } + }, + }, has_been_inspected=True) project = Project.from_config( name='composetest', From efb5601323d243595248f85e2e2aa57cb0afded9 Mon Sep 17 00:00:00 2001 From: Eric Hripko Date: Fri, 1 May 2020 16:56:46 +0100 Subject: [PATCH 3974/4072] Implement service mode for ipc Signed-off-by: Eric Hripko --- compose/config/config.py | 2 + compose/config/sort_services.py | 1 + compose/config/validation.py | 15 +++++++ compose/project.py | 26 ++++++++++++ compose/service.py | 48 +++++++++++++++++++++- tests/acceptance/cli_test.py | 26 ++++++++++++ tests/fixtures/ipc-mode/docker-compose.yml | 17 ++++++++ tests/integration/service_test.py | 12 ++++++ 8 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/ipc-mode/docker-compose.yml diff --git a/compose/config/config.py b/compose/config/config.py index c5791e1533c..90baeeaa866 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -47,6 +47,7 @@ from .validation import validate_depends_on from .validation import validate_extends_file_path from .validation import validate_healthcheck +from .validation import validate_ipc_mode from .validation import validate_links from .validation import validate_network_mode from .validation import validate_pid_mode @@ -734,6 +735,7 @@ def validate_service(service_config, service_names, config_file): validate_cpu(service_config) validate_ulimits(service_config) + validate_ipc_mode(service_config, service_names) validate_network_mode(service_config, service_names) validate_pid_mode(service_config, service_names) validate_depends_on(service_config, service_names) diff --git a/compose/config/sort_services.py b/compose/config/sort_services.py index a600139b2ac..65953891f95 100644 --- a/compose/config/sort_services.py +++ b/compose/config/sort_services.py @@ -36,6 +36,7 @@ def get_service_dependents(service_dict, services): 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 == get_service_name_from_network_mode(service.get('pid')) or + name == get_service_name_from_network_mode(service.get('ipc')) or name in service.get('depends_on', [])) ] diff --git a/compose/config/validation.py b/compose/config/validation.py index 942c4e03672..61a3370db37 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -218,6 +218,21 @@ def validate_pid_mode(service_config, service_names): ) +def validate_ipc_mode(service_config, service_names): + ipc_mode = service_config.config.get('ipc') + if not ipc_mode: + return + + dependency = get_service_name_from_network_mode(ipc_mode) + if not dependency: + return + if dependency not in service_names: + raise ConfigurationError( + "Service '{s.name}' uses the IPC namespace of service '{dep}' which " + "is undefined.".format(s=service_config, dep=dependency) + ) + + def validate_links(service_config, service_names): for link in service_config.config.get('links', []): if link.split(':')[0] not in service_names: diff --git a/compose/project.py b/compose/project.py index e80d68ef1ae..5a03b30ca0d 100644 --- a/compose/project.py +++ b/compose/project.py @@ -26,14 +26,17 @@ from .network import ProjectNetworks from .progress_stream import read_status from .service import BuildAction +from .service import ContainerIpcMode from .service import ContainerNetworkMode from .service import ContainerPidMode from .service import ConvergenceStrategy +from .service import IpcMode from .service import NetworkMode from .service import NoSuchImageError from .service import parse_repository_tag from .service import PidMode from .service import Service +from .service import ServiceIpcMode from .service import ServiceNetworkMode from .service import ServicePidMode from .utils import microseconds_from_time_nano @@ -106,6 +109,7 @@ def from_config(cls, name, config_data, client, default_platform=None, extra_lab service_dict.pop('networks', None) links = project.get_links(service_dict) + ipc_mode = project.get_ipc_mode(service_dict) network_mode = project.get_network_mode( service_dict, list(service_networks.keys()) ) @@ -147,6 +151,7 @@ def from_config(cls, name, config_data, client, default_platform=None, extra_lab volumes_from=volumes_from, secrets=secrets, pid_mode=pid_mode, + ipc_mode=ipc_mode, platform=service_dict.pop('platform', None), default_platform=default_platform, extra_labels=extra_labels, @@ -274,6 +279,27 @@ def get_pid_mode(self, service_dict): return PidMode(pid_mode) + def get_ipc_mode(self, service_dict): + ipc_mode = service_dict.pop('ipc', None) + if not ipc_mode: + return IpcMode(None) + + service_name = get_service_name_from_network_mode(ipc_mode) + if service_name: + return ServiceIpcMode(self.get_service(service_name)) + + container_name = get_container_name_from_network_mode(ipc_mode) + if container_name: + try: + return ContainerIpcMode(Container.from_id(self.client, container_name)) + except APIError: + raise ConfigurationError( + "Service '{name}' uses the IPC namespace of container '{dep}' which " + "does not exist.".format(name=service_dict['name'], dep=container_name) + ) + + return IpcMode(ipc_mode) + def get_service_scale(self, service_dict): # service.scale for v2 and deploy.replicas for v3 scale = service_dict.get('scale', None) diff --git a/compose/service.py b/compose/service.py index 50b00279617..f52bd6ffe58 100644 --- a/compose/service.py +++ b/compose/service.py @@ -176,6 +176,7 @@ def __init__( networks=None, secrets=None, scale=1, + ipc_mode=None, pid_mode=None, default_platform=None, extra_labels=None, @@ -187,6 +188,7 @@ def __init__( self.use_networking = use_networking self.links = links or [] self.volumes_from = volumes_from or [] + self.ipc_mode = ipc_mode or IpcMode(None) self.network_mode = network_mode or NetworkMode(None) self.pid_mode = pid_mode or PidMode(None) self.networks = networks or {} @@ -719,17 +721,20 @@ def image_id(): def get_dependency_names(self): net_name = self.network_mode.service_name pid_namespace = self.pid_mode.service_name + ipc_namespace = self.ipc_mode.service_name return ( self.get_linked_service_names() + self.get_volumes_from_names() + ([net_name] if net_name else []) + ([pid_namespace] if pid_namespace else []) + + ([ipc_namespace] if ipc_namespace else []) + list(self.options.get('depends_on', {}).keys()) ) def get_dependency_configs(self): net_name = self.network_mode.service_name pid_namespace = self.pid_mode.service_name + ipc_namespace = self.ipc_mode.service_name configs = dict( [(name, None) for name in self.get_linked_service_names()] @@ -739,6 +744,7 @@ def get_dependency_configs(self): )) configs.update({net_name: None} if net_name else {}) configs.update({pid_namespace: None} if pid_namespace else {}) + configs.update({ipc_namespace: None} if ipc_namespace else {}) configs.update(self.options.get('depends_on', {})) for svc, config in self.options.get('depends_on', {}).items(): if config['condition'] == CONDITION_STARTED: @@ -1025,7 +1031,7 @@ def _get_container_host_config(self, override_options, one_off=False): read_only=options.get('read_only'), pid_mode=self.pid_mode.mode, security_opt=security_opt, - ipc_mode=options.get('ipc'), + ipc_mode=self.ipc_mode.mode, cgroup_parent=options.get('cgroup_parent'), cpu_quota=options.get('cpu_quota'), shm_size=options.get('shm_size'), @@ -1329,6 +1335,46 @@ def short_id_alias_exists(container, network): return container.short_id in aliases +class IpcMode(object): + def __init__(self, mode): + self._mode = mode + + @property + def mode(self): + return self._mode + + @property + def service_name(self): + return None + + +class ServiceIpcMode(IpcMode): + def __init__(self, service): + self.service = service + + @property + def service_name(self): + return self.service.name + + @property + def mode(self): + containers = self.service.containers() + if containers: + return 'container:' + containers[0].id + + log.warning( + "Service %s is trying to use reuse the IPC namespace " + "of another service that is not running." % (self.service_name) + ) + return None + + +class ContainerIpcMode(IpcMode): + def __init__(self, container): + self.container = container + self._mode = 'container:{}'.format(container.id) + + class PidMode(object): def __init__(self, mode): self._mode = mode diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 82558651492..7fae1f22e0e 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1681,6 +1681,32 @@ def test_up_with_pid_mode(self): host_mode_container = self.project.get_service('host').containers()[0] assert host_mode_container.get('HostConfig.PidMode') == 'host' + @v2_only() + @no_cluster('Container IPC mode does not work across clusters') + def test_up_with_ipc_mode(self): + c = self.client.create_container( + 'busybox', 'top', name='composetest_ipc_mode_container', + host_config={} + ) + self.addCleanup(self.client.remove_container, c, force=True) + self.client.start(c) + container_mode_source = 'container:{}'.format(c['Id']) + + self.base_dir = 'tests/fixtures/ipc-mode' + + self.dispatch(['up', '-d'], None) + + service_mode_source = 'container:{}'.format( + self.project.get_service('shareable').containers()[0].id) + service_mode_container = self.project.get_service('service').containers()[0] + assert service_mode_container.get('HostConfig.IpcMode') == service_mode_source + + container_mode_container = self.project.get_service('container').containers()[0] + assert container_mode_container.get('HostConfig.IpcMode') == container_mode_source + + shareable_mode_container = self.project.get_service('shareable').containers()[0] + assert shareable_mode_container.get('HostConfig.IpcMode') == 'shareable' + def test_exec_without_tty(self): self.base_dir = 'tests/fixtures/links-composefile' self.dispatch(['up', '-d', 'console']) diff --git a/tests/fixtures/ipc-mode/docker-compose.yml b/tests/fixtures/ipc-mode/docker-compose.yml new file mode 100644 index 00000000000..c58ce24484d --- /dev/null +++ b/tests/fixtures/ipc-mode/docker-compose.yml @@ -0,0 +1,17 @@ +version: "2.4" + +services: + service: + image: busybox + command: top + ipc: "service:shareable" + + container: + image: busybox + command: top + ipc: "container:composetest_ipc_mode_container" + + shareable: + image: busybox + command: top + ipc: shareable diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 9830accb85c..ba6dd177070 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -38,6 +38,7 @@ from compose.service import BuildAction from compose.service import ConvergencePlan from compose.service import ConvergenceStrategy +from compose.service import IpcMode from compose.service import NetworkMode from compose.service import PidMode from compose.service import Service @@ -1480,6 +1481,17 @@ def test_pid_mode_host(self): container = create_and_start_container(service) assert container.get('HostConfig.PidMode') == 'host' + def test_ipc_mode_none_defined(self): + service = self.create_service('web', ipc_mode=None) + container = create_and_start_container(service) + print(container.get('HostConfig.IpcMode')) + assert container.get('HostConfig.IpcMode') == 'shareable' + + def test_ipc_mode_host(self): + service = self.create_service('web', ipc_mode=IpcMode('host')) + container = create_and_start_container(service) + assert container.get('HostConfig.IpcMode') == 'host' + @v2_1_only() def test_userns_mode_none_defined(self): service = self.create_service('web', userns_mode=None) From a8511e0884054ceed9fd022ef675d90801be8168 Mon Sep 17 00:00:00 2001 From: Eric Hripko Date: Fri, 7 Aug 2020 14:46:17 +0100 Subject: [PATCH 3975/4072] Remove the now unneeded version qualifier Signed-off-by: Eric Hripko --- tests/acceptance/cli_test.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 7fae1f22e0e..dd43e00c893 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1681,7 +1681,6 @@ def test_up_with_pid_mode(self): host_mode_container = self.project.get_service('host').containers()[0] assert host_mode_container.get('HostConfig.PidMode') == 'host' - @v2_only() @no_cluster('Container IPC mode does not work across clusters') def test_up_with_ipc_mode(self): c = self.client.create_container( From 7322dee672f68f80d73b8f8ee7868713babe4097 Mon Sep 17 00:00:00 2001 From: aiordache Date: Fri, 7 Aug 2020 18:50:06 +0200 Subject: [PATCH 3976/4072] Cleanup v*_only test decorators Signed-off-by: aiordache --- tests/integration/service_test.py | 22 ---------------------- tests/integration/testcases.py | 20 -------------------- 2 files changed, 42 deletions(-) diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index ba6dd177070..76efba4402c 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -46,11 +46,6 @@ from tests.helpers import create_custom_host_file from tests.integration.testcases import is_cluster from tests.integration.testcases import no_cluster -from tests.integration.testcases import v2_1_only -from tests.integration.testcases import v2_2_only -from tests.integration.testcases import v2_3_only -from tests.integration.testcases import v2_only -from tests.integration.testcases import v3_only def create_and_start_container(service, **override_options): @@ -136,7 +131,6 @@ def test_create_container_with_cpu_rt(self): assert container.get('HostConfig.CpuRealtimeRuntime') == 40000 assert container.get('HostConfig.CpuRealtimePeriod') == 150000 - @v2_2_only() def test_create_container_with_cpu_count(self): self.require_api_version('1.25') service = self.create_service('db', cpu_count=2) @@ -144,7 +138,6 @@ def test_create_container_with_cpu_count(self): service.start_container(container) assert container.get('HostConfig.CpuCount') == 2 - @v2_2_only() @pytest.mark.skipif(not IS_WINDOWS_PLATFORM, reason='cpu_percent is not supported for Linux') def test_create_container_with_cpu_percent(self): self.require_api_version('1.25') @@ -153,7 +146,6 @@ def test_create_container_with_cpu_percent(self): service.start_container(container) assert container.get('HostConfig.CpuPercent') == 12 - @v2_2_only() def test_create_container_with_cpus(self): self.require_api_version('1.25') service = self.create_service('db', cpus=1) @@ -301,7 +293,6 @@ def test_create_container_with_specified_volume(self): "Last component differs: %s, %s" % (actual_host_path, host_path) ) - @v2_3_only() def test_create_container_with_host_mount(self): host_path = '/tmp/host-path' container_path = '/container-path' @@ -321,7 +312,6 @@ def test_create_container_with_host_mount(self): assert path.basename(mount['Source']) == path.basename(host_path) assert mount['RW'] is False - @v2_3_only() def test_create_container_with_tmpfs_mount(self): container_path = '/container-tmpfs' service = self.create_service( @@ -334,7 +324,6 @@ def test_create_container_with_tmpfs_mount(self): assert mount assert mount['Type'] == 'tmpfs' - @v2_3_only() def test_create_container_with_tmpfs_mount_tmpfs_size(self): container_path = '/container-tmpfs' service = self.create_service( @@ -351,7 +340,6 @@ def test_create_container_with_tmpfs_mount_tmpfs_size(self): 'SizeBytes': 5368709 } - @v2_3_only() def test_create_container_with_volume_mount(self): container_path = '/container-volume' volume_name = 'composetest_abcde' @@ -366,7 +354,6 @@ def test_create_container_with_volume_mount(self): assert mount assert mount['Name'] == volume_name - @v3_only() def test_create_container_with_legacy_mount(self): # Ensure mounts are converted to volumes if API version < 1.30 # Needed to support long syntax in the 3.2 format @@ -383,7 +370,6 @@ def test_create_container_with_legacy_mount(self): assert mount assert mount['Name'] == volume_name - @v3_only() def test_create_container_with_legacy_tmpfs_mount(self): # Ensure tmpfs mounts are converted to tmpfs entries if API version < 1.30 # Needed to support long syntax in the 3.2 format @@ -590,7 +576,6 @@ def test_execute_convergence_plan_recreate_twice(self): orig_container = new_container - @v2_3_only() def test_execute_convergence_plan_recreate_twice_with_mount(self): service = self.create_service( 'db', @@ -1135,7 +1120,6 @@ def test_build_with_network(self): assert service.image() - @v2_3_only() @no_cluster('Not supported on UCP 2.2.0-beta1') # FIXME: remove once support is added def test_build_with_target(self): self.require_api_version('1.30') @@ -1158,7 +1142,6 @@ def test_build_with_target(self): assert service.image() assert service.image()['Config']['Labels']['com.docker.compose.test.target'] == 'one' - @v2_3_only() def test_build_with_extra_hosts(self): self.require_api_version('1.27') base_dir = tempfile.mkdtemp() @@ -1199,7 +1182,6 @@ def test_build_with_gzip(self): service.build(gzip=True) assert service.image() - @v2_1_only() def test_build_with_isolation(self): base_dir = tempfile.mkdtemp() self.addCleanup(shutil.rmtree, base_dir) @@ -1492,13 +1474,11 @@ def test_ipc_mode_host(self): container = create_and_start_container(service) assert container.get('HostConfig.IpcMode') == '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) assert 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) @@ -1574,7 +1554,6 @@ def test_dns_search(self): container = create_and_start_container(service) assert 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) @@ -1608,7 +1587,6 @@ def test_env_from_file_combined_with_env(self): }.items(): assert env[k] == v - @v3_only() def test_build_with_cachefrom(self): base_dir = tempfile.mkdtemp() self.addCleanup(shutil.rmtree, base_dir) diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index e84eadc9347..742d0e1c265 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -53,26 +53,6 @@ def min_version_skip(version): ) -def v2_only(): - return min_version_skip(VERSION) - - -def v2_1_only(): - return min_version_skip(VERSION) - - -def v2_2_only(): - return min_version_skip(VERSION) - - -def v2_3_only(): - return min_version_skip(VERSION) - - -def v3_only(): - return min_version_skip(VERSION) - - class DockerClientTestCase(unittest.TestCase): @classmethod def setUpClass(cls): From 17b41b27a8ab23797f5db9c0df23e1a247ebf4cc Mon Sep 17 00:00:00 2001 From: Vitor Anjos Date: Sat, 27 Jun 2020 15:07:32 -0300 Subject: [PATCH 3977/4072] Ignore build context path validation when it is not necessary Signed-off-by: Vitor Anjos --- compose/config/config.py | 15 ++++++++++++++- tests/acceptance/cli_test.py | 14 ++++++++++++++ tests/fixtures/no-build/docker-compose.yml | 8 ++++++++ 3 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/no-build/docker-compose.yml diff --git a/compose/config/config.py b/compose/config/config.py index 90baeeaa866..c7cc724040b 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -729,9 +729,22 @@ def validate_extended_service_dict(service_dict, filename, service): def validate_service(service_config, service_names, config_file): + def build_image(): + args = sys.argv[1:] + if 'pull' in args: + return False + + if '--no-build' in args: + return False + + return True + service_dict, service_name = service_config.config, service_config.name validate_service_constraints(service_dict, service_name, config_file) - validate_paths(service_dict) + + if build_image(): + # We only care about valid paths when actually building images + validate_paths(service_dict) validate_cpu(service_config) validate_ulimits(service_config) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index dd43e00c893..92168e93fde 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -748,6 +748,20 @@ def test_build_no_cache(self): assert BUILD_CACHE_TEXT not in result.stdout assert BUILD_PULL_TEXT not in result.stdout + def test_up_ignore_missing_build_directory(self): + self.base_dir = 'tests/fixtures/no-build' + result = self.dispatch(['up', '--no-build']) + + assert 'alpine exited with code 0' in result.stdout + self.base_dir = None + + def test_pull_ignore_missing_build_directory(self): + self.base_dir = 'tests/fixtures/no-build' + result = self.dispatch(['pull']) + + assert 'Pulling my-alpine' in result.stderr + self.base_dir = None + def test_build_pull(self): # Make sure we have the latest busybox already pull_busybox(self.client) diff --git a/tests/fixtures/no-build/docker-compose.yml b/tests/fixtures/no-build/docker-compose.yml new file mode 100644 index 00000000000..5de4d9b60e2 --- /dev/null +++ b/tests/fixtures/no-build/docker-compose.yml @@ -0,0 +1,8 @@ +version: "3" +services: + my-alpine: + image: alpine:3.12 + container_name: alpine + entrypoint: 'echo It works!' + build: + context: /this/path/doesnt/exists # and we don't really care. We just want to run containers already pulled. From de1afd977d20ed635cc4fc00fd3fcf151c88bf8c Mon Sep 17 00:00:00 2001 From: Vitor Anjos Date: Fri, 7 Aug 2020 09:33:45 -0300 Subject: [PATCH 3978/4072] Fix typo Signed-off-by: Vitor Anjos --- tests/fixtures/no-build/docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/fixtures/no-build/docker-compose.yml b/tests/fixtures/no-build/docker-compose.yml index 5de4d9b60e2..f320d17c394 100644 --- a/tests/fixtures/no-build/docker-compose.yml +++ b/tests/fixtures/no-build/docker-compose.yml @@ -5,4 +5,4 @@ services: container_name: alpine entrypoint: 'echo It works!' build: - context: /this/path/doesnt/exists # and we don't really care. We just want to run containers already pulled. + context: /this/path/doesnt/exist # and we don't really care. We just want to run containers already pulled. From f8dd8e828737a1a92a89f71aee42cc5aba7b1e53 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2020 21:26:02 +0000 Subject: [PATCH 3979/4072] Bump docker from 4.2.2 to 4.3.0 Bumps [docker](https://github.com/docker/docker-py) from 4.2.2 to 4.3.0. - [Release notes](https://github.com/docker/docker-py/releases) - [Commits](https://github.com/docker/docker-py/compare/4.2.2...4.3.0) Signed-off-by: dependabot-preview[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 1a0e903706e..d0a386642d1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ certifi==2020.6.20 chardet==3.0.4 colorama==0.4.3; sys_platform == 'win32' distro==1.5.0 -docker==4.2.2 +docker==4.3.0 docker-pycreds==0.4.0 dockerpty==0.4.1 docopt==0.6.2 From 112d4cd7e77cfc995f26559ac46c95c745566b2f Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2020 21:26:25 +0000 Subject: [PATCH 3980/4072] Bump tox from 3.18.1 to 3.19.0 Bumps [tox](https://github.com/tox-dev/tox) from 3.18.1 to 3.19.0. - [Release notes](https://github.com/tox-dev/tox/releases) - [Changelog](https://github.com/tox-dev/tox/blob/master/docs/changelog.rst) - [Commits](https://github.com/tox-dev/tox/compare/3.18.1...3.19.0) Signed-off-by: dependabot-preview[bot] --- requirements-indirect.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-indirect.txt b/requirements-indirect.txt index 917ef6817fd..1f028ca3ef2 100644 --- a/requirements-indirect.txt +++ b/requirements-indirect.txt @@ -23,6 +23,6 @@ pyrsistent==0.16.0 smmap==3.0.4 smmap2==3.0.1 toml==0.10.1 -tox==3.18.1 +tox==3.19.0 virtualenv==20.0.29 wcwidth==0.2.5 From 95abc7f815e9c9579e3e58cc6b80bc7925613200 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2020 21:27:28 +0000 Subject: [PATCH 3981/4072] Bump pytest from 5.4.3 to 6.0.1 Bumps [pytest](https://github.com/pytest-dev/pytest) from 5.4.3 to 6.0.1. - [Release notes](https://github.com/pytest-dev/pytest/releases) - [Changelog](https://github.com/pytest-dev/pytest/blob/master/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest/compare/5.4.3...6.0.1) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index b0be27d3b39..1695e98e20a 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -4,6 +4,6 @@ ddt==1.4.1 flake8==3.8.3 gitpython==3.1.7 mock==3.0.5 -pytest==5.4.3; python_version >= '3.5' +pytest==6.0.1; python_version >= '3.5' pytest==4.6.5; python_version < '3.5' pytest-cov==2.10.0 From e5edc787397c169ec7d0798595e56992d4723d8d Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2020 21:27:50 +0000 Subject: [PATCH 3982/4072] Bump cryptography from 2.9.2 to 3.0 Bumps [cryptography](https://github.com/pyca/cryptography) from 2.9.2 to 3.0. - [Release notes](https://github.com/pyca/cryptography/releases) - [Changelog](https://github.com/pyca/cryptography/blob/master/CHANGELOG.rst) - [Commits](https://github.com/pyca/cryptography/compare/2.9.2...3.0) Signed-off-by: dependabot-preview[bot] --- requirements-indirect.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-indirect.txt b/requirements-indirect.txt index 917ef6817fd..b417a22d90d 100644 --- a/requirements-indirect.txt +++ b/requirements-indirect.txt @@ -3,7 +3,7 @@ appdirs==1.4.4 attrs==19.3.0 bcrypt==3.1.7 cffi==1.14.0 -cryptography==2.9.2 +cryptography==3.0 distlib==0.3.1 entrypoints==0.3 filelock==3.0.12 From 095e297dcbd30640b9afbcf4a57f12cb1e4bdde5 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2020 21:28:34 +0000 Subject: [PATCH 3983/4072] Bump urllib3 from 1.25.9 to 1.25.10 Bumps [urllib3](https://github.com/urllib3/urllib3) from 1.25.9 to 1.25.10. - [Release notes](https://github.com/urllib3/urllib3/releases) - [Changelog](https://github.com/urllib3/urllib3/blob/master/CHANGES.rst) - [Commits](https://github.com/urllib3/urllib3/compare/1.25.9...1.25.10) Signed-off-by: dependabot-preview[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 1a0e903706e..09fa58ece02 100644 --- a/requirements.txt +++ b/requirements.txt @@ -21,5 +21,5 @@ python-dotenv==0.14.0 PyYAML==5.3.1 requests==2.24.0 texttable==1.6.2 -urllib3==1.25.9; python_version == '3.3' +urllib3==1.25.10; python_version == '3.3' websocket-client==0.57.0 From 5c47805dc30672da769ed31b976cc60dd6af10a9 Mon Sep 17 00:00:00 2001 From: aiordache Date: Tue, 11 Aug 2020 12:37:53 +0200 Subject: [PATCH 3984/4072] Bump tox version to 3.19.0 in Dockerfile Signed-off-by: aiordache --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 4661b645bb1..fb6b41487ce 100644 --- a/Dockerfile +++ b/Dockerfile @@ -47,7 +47,7 @@ COPY --from=docker-cli /usr/local/bin/docker /usr/local/bin/docker WORKDIR /code/ # FIXME(chris-crone): virtualenv 16.3.0 breaks build, force 16.2.0 until fixed RUN pip install virtualenv==20.0.29 -RUN pip install tox==3.18.1 +RUN pip install tox==3.19.0 COPY requirements-indirect.txt . COPY requirements.txt . From 4d3d9f64b965af66662a934fc07af430e83ce524 Mon Sep 17 00:00:00 2001 From: alexrecuenco Date: Mon, 24 Feb 2020 13:24:57 +0100 Subject: [PATCH 3985/4072] Removed Python2 support Closes: #6890 Some remarks, - `# coding ... utf-8` statements are not needed - isdigit on strings instead of a try-catch. - Default opening mode is read, so we can do `open()` without the `'r'` everywhere - Removed inheritinng from `object` class, it isn't necessary in python3. - `super(ClassName, self)` can now be replaced with `super()` - Use of itertools and `chain` on a couple places dealing with sets. - Used the operator module instead of lambdas when warranted `itemgetter(0)` instead of `lambda x: x[0]` `attrgetter('name')` instead of `lambda x: x.name` - `sorted` returns a list, so no need to use `list(sorted(...))` - Removed `dict()` using dictionary comprehensions whenever possible - Attempted to remove python3.2 support Signed-off-by: alexrecuenco --- .pre-commit-config.yaml | 6 ++ compose/__init__.py | 1 - compose/cli/colors.py | 8 +-- compose/cli/command.py | 13 ++-- compose/cli/docopt_command.py | 4 +- compose/cli/errors.py | 4 +- compose/cli/formatter.py | 17 ++--- compose/cli/log_printer.py | 11 +-- compose/cli/main.py | 13 ++-- compose/cli/utils.py | 13 +--- compose/cli/verbose_proxy.py | 8 +-- compose/config/config.py | 51 +++++++------ compose/config/environment.py | 16 ++--- compose/config/errors.py | 6 +- compose/config/interpolation.py | 26 +++---- compose/config/serialize.py | 2 +- compose/config/sort_services.py | 2 +- compose/config/types.py | 25 +++---- compose/config/validation.py | 8 +-- compose/container.py | 14 ++-- compose/errors.py | 6 +- compose/network.py | 21 +++--- compose/parallel.py | 8 +-- compose/progress_stream.py | 10 +-- compose/project.py | 34 ++++----- compose/service.py | 72 +++++++++---------- compose/timeparse.py | 19 +++-- compose/utils.py | 6 +- compose/volume.py | 21 +++--- .../migrate-compose-file-v1-to-v2.py | 2 +- docker-compose_darwin.spec | 2 +- requirements.txt | 2 - script/release/utils.py | 4 +- setup.py | 4 +- tests/acceptance/cli_test.py | 23 +++--- tests/acceptance/context_test.py | 1 - tests/helpers.py | 2 +- tests/integration/environment_test.py | 4 +- tests/integration/project_test.py | 60 ++++++++-------- tests/integration/resilience_test.py | 2 +- tests/integration/service_test.py | 28 ++++---- tests/integration/state_test.py | 46 ++++++------ tests/integration/volume_test.py | 2 +- tests/unit/cli/command_test.py | 3 +- tests/unit/cli/docker_client_test.py | 16 ++--- tests/unit/cli/errors_test.py | 6 +- tests/unit/cli/formatter_test.py | 4 +- tests/unit/cli/log_printer_test.py | 10 +-- tests/unit/cli/main_test.py | 10 +-- tests/unit/cli_test.py | 1 - tests/unit/config/config_test.py | 13 ++-- tests/unit/config/environment_test.py | 1 - tests/unit/config/interpolation_test.py | 3 +- tests/unit/config/sort_services_test.py | 2 +- tests/unit/config/types_test.py | 6 +- tests/unit/progress_stream_test.py | 4 +- tests/unit/project_test.py | 3 +- tests/unit/service_test.py | 6 +- tests/unit/split_buffer_test.py | 2 +- tests/unit/utils_test.py | 13 ++-- tests/unit/volume_test.py | 2 +- 61 files changed, 350 insertions(+), 382 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a2aeb014a00..05cd5202658 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -20,3 +20,9 @@ language_version: 'python3.7' args: - --py3-plus +- repo: https://github.com/asottile/pyupgrade + rev: v2.1.0 + hooks: + - id: pyupgrade + args: + - --py3-plus diff --git a/compose/__init__.py b/compose/__init__.py index 00afb07c074..76e27d25f73 100644 --- a/compose/__init__.py +++ b/compose/__init__.py @@ -1,2 +1 @@ - __version__ = '1.27.0dev' diff --git a/compose/cli/colors.py b/compose/cli/colors.py index c6a869bf59d..a4983a9f51a 100644 --- a/compose/cli/colors.py +++ b/compose/cli/colors.py @@ -14,16 +14,16 @@ def get_pairs(): for i, name in enumerate(NAMES): - yield(name, str(30 + i)) - yield('intense_' + name, str(30 + i) + ';1') + yield (name, str(30 + i)) + yield ('intense_' + name, str(30 + i) + ';1') def ansi(code): - return '\033[{0}m'.format(code) + return '\033[{}m'.format(code) def ansi_color(code, s): - return '{0}{1}{2}'.format(ansi(code), s, ansi(0)) + return '{}{}{}'.format(ansi(code), s, ansi(0)) def make_color_fn(code): diff --git a/compose/cli/command.py b/compose/cli/command.py index e907a05cc1e..8882727ba79 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -147,15 +147,17 @@ def get_project(project_dir, config_path=None, project_name=None, verbose=False, def execution_context_labels(config_details, environment_file): extra_labels = [ - '{0}={1}'.format(LABEL_WORKING_DIR, os.path.abspath(config_details.working_dir)) + '{}={}'.format(LABEL_WORKING_DIR, os.path.abspath(config_details.working_dir)) ] if not use_config_from_stdin(config_details): - extra_labels.append('{0}={1}'.format(LABEL_CONFIG_FILES, config_files_label(config_details))) + extra_labels.append('{}={}'.format(LABEL_CONFIG_FILES, config_files_label(config_details))) if environment_file is not None: - extra_labels.append('{0}={1}'.format(LABEL_ENVIRONMENT_FILE, - os.path.normpath(environment_file))) + extra_labels.append('{}={}'.format( + LABEL_ENVIRONMENT_FILE, + os.path.normpath(environment_file)) + ) return extra_labels @@ -168,7 +170,8 @@ def use_config_from_stdin(config_details): def config_files_label(config_details): return ",".join( - map(str, (os.path.normpath(c.filename) for c in config_details.config_files))) + os.path.normpath(c.filename) for c in config_details.config_files + ) def get_project_name(working_dir, project_name=None, environment=None): diff --git a/compose/cli/docopt_command.py b/compose/cli/docopt_command.py index 856c9234818..d0ba7f67026 100644 --- a/compose/cli/docopt_command.py +++ b/compose/cli/docopt_command.py @@ -11,7 +11,7 @@ def docopt_full_help(docstring, *args, **kwargs): raise SystemExit(docstring) -class DocoptDispatcher(object): +class DocoptDispatcher: def __init__(self, command_class, options): self.command_class = command_class @@ -50,7 +50,7 @@ def get_handler(command_class, command): class NoSuchCommand(Exception): def __init__(self, command, supercommand): - super(NoSuchCommand, self).__init__("No such command: %s" % command) + super().__init__("No such command: %s" % command) self.command = command self.supercommand = supercommand diff --git a/compose/cli/errors.py b/compose/cli/errors.py index d1a47f07833..a807c7d1c78 100644 --- a/compose/cli/errors.py +++ b/compose/cli/errors.py @@ -26,11 +26,9 @@ class UserError(Exception): def __init__(self, msg): self.msg = dedent(msg).strip() - def __unicode__(self): + def __str__(self): return self.msg - __str__ = __unicode__ - class ConnectionError(Exception): pass diff --git a/compose/cli/formatter.py b/compose/cli/formatter.py index a59f0742c6a..ff81ee65163 100644 --- a/compose/cli/formatter.py +++ b/compose/cli/formatter.py @@ -1,15 +1,10 @@ import logging -import shutil +from shutil import get_terminal_size import texttable from compose.cli import colors -if hasattr(shutil, "get_terminal_size"): - from shutil import get_terminal_size -else: - from backports.shutil_get_terminal_size import get_terminal_size - def get_tty_width(): try: @@ -45,15 +40,15 @@ class ConsoleWarningFormatter(logging.Formatter): def get_level_message(self, record): separator = ': ' - if record.levelno == logging.WARNING: - return colors.yellow(record.levelname) + separator - if record.levelno == logging.ERROR: + if record.levelno >= logging.ERROR: return colors.red(record.levelname) + separator + if record.levelno >= logging.WARNING: + return colors.yellow(record.levelname) + separator return '' def format(self, record): if isinstance(record.msg, bytes): record.msg = record.msg.decode('utf-8') - message = super(ConsoleWarningFormatter, self).format(record) - return '{0}{1}'.format(self.get_level_message(record), message) + message = super().format(record) + return '{}{}'.format(self.get_level_message(record), message) diff --git a/compose/cli/log_printer.py b/compose/cli/log_printer.py index 100f3a8250d..cd9f73c2c0c 100644 --- a/compose/cli/log_printer.py +++ b/compose/cli/log_printer.py @@ -2,6 +2,7 @@ import sys from collections import namedtuple from itertools import cycle +from operator import attrgetter from queue import Empty from queue import Queue from threading import Thread @@ -13,7 +14,7 @@ from compose.utils import split_buffer -class LogPresenter(object): +class LogPresenter: def __init__(self, prefix_width, color_func): self.prefix_width = prefix_width @@ -50,7 +51,7 @@ def max_name_width(service_names, max_index_width=3): return max(len(name) for name in service_names) + max_index_width -class LogPrinter(object): +class LogPrinter: """Print logs from many containers to a single output stream.""" def __init__(self, @@ -133,7 +134,7 @@ def build_thread_map(initial_containers, presenters, thread_args): # Container order is unspecified, so they are sorted by name in order to make # container:presenter (log color) assignment deterministic when given a list of containers # with the same names. - for container in sorted(initial_containers, key=lambda c: c.name) + for container in sorted(initial_containers, key=attrgetter('name')) } @@ -194,9 +195,9 @@ def build_log_generator(container, log_args): def wait_on_exit(container): try: exit_code = container.wait() - return "%s exited with code %s\n" % (container.name, exit_code) + return "{} exited with code {}\n".format(container.name, exit_code) except APIError as e: - return "Unexpected API error for %s (HTTP code %s)\nResponse body:\n%s\n" % ( + return "Unexpected API error for {} (HTTP code {})\nResponse body:\n{}\n".format( container.name, e.response.status_code, e.response.text or '[empty]' ) diff --git a/compose/cli/main.py b/compose/cli/main.py index 1de8a774fc3..8809318f76e 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -73,7 +73,7 @@ def main(): log.error(e.msg) sys.exit(1) except BuildError as e: - log.error("Service '%s' failed to build: %s" % (e.service.name, e.reason)) + log.error("Service '{}' failed to build: {}".format(e.service.name, e.reason)) sys.exit(1) except StreamOutputError as e: log.error(e) @@ -175,7 +175,7 @@ def parse_doc_section(name, source): return [s.strip() for s in pattern.findall(source)] -class TopLevelCommand(object): +class TopLevelCommand: """Define and run multi-container applications with Docker. Usage: @@ -546,7 +546,7 @@ def images(self, options): key=attrgetter('name')) if options['--quiet']: - for image in set(c.image for c in containers): + for image in {c.image for c in containers}: print(image.split(':')[1]) return @@ -1130,7 +1130,7 @@ def compute_service_exit_code(exit_value_from, attached_containers): attached_containers)) if not candidates: log.error( - 'No containers matching the spec "{0}" ' + 'No containers matching the spec "{}" ' 'were run.'.format(exit_value_from) ) return 2 @@ -1453,10 +1453,7 @@ def call_docker(args, dockeropts, environment): args = [executable_path] + tls_options + args log.debug(" ".join(map(pipes.quote, args))) - filtered_env = {} - for k, v in environment.items(): - if v is not None: - filtered_env[k] = environment[k] + filtered_env = {k: v for k, v in environment.items() if v is not None} return subprocess.call(args, env=filtered_env) diff --git a/compose/cli/utils.py b/compose/cli/utils.py index b91ab6a13c2..6a4615a9660 100644 --- a/compose/cli/utils.py +++ b/compose/cli/utils.py @@ -11,13 +11,6 @@ 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. -try: - WindowsError -except NameError: - WindowsError = OSError - def yesno(prompt, default=None): """ @@ -58,7 +51,7 @@ def call_silently(*args, **kwargs): with open(os.devnull, 'w') as shutup: try: return subprocess.call(*args, stdout=shutup, stderr=shutup, **kwargs) - except WindowsError: + except OSError: # On Windows, subprocess.call() can still raise exceptions. Normalize # to POSIXy behaviour by returning a nonzero exit code. return 1 @@ -120,7 +113,7 @@ def generate_user_agent(): try: p_system = platform.system() p_release = platform.release() - except IOError: + except OSError: pass else: parts.append("{}/{}".format(p_system, p_release)) @@ -133,7 +126,7 @@ def human_readable_file_size(size): if order >= len(suffixes): order = len(suffixes) - 1 - return '{0:.4g} {1}'.format( + return '{:.4g} {}'.format( size / pow(10, order * 3), suffixes[order] ) diff --git a/compose/cli/verbose_proxy.py b/compose/cli/verbose_proxy.py index 1d2f28b5c37..c9340c4e0d2 100644 --- a/compose/cli/verbose_proxy.py +++ b/compose/cli/verbose_proxy.py @@ -6,13 +6,13 @@ def format_call(args, kwargs): args = (repr(a) for a in args) - kwargs = ("{0!s}={1!r}".format(*item) for item in kwargs.items()) - return "({0})".format(", ".join(chain(args, kwargs))) + kwargs = ("{!s}={!r}".format(*item) for item in kwargs.items()) + return "({})".format(", ".join(chain(args, kwargs))) def format_return(result, max_lines): if isinstance(result, (list, tuple, set)): - return "({0} with {1} items)".format(type(result).__name__, len(result)) + return "({} with {} items)".format(type(result).__name__, len(result)) if result: lines = pprint.pformat(result).split('\n') @@ -22,7 +22,7 @@ def format_return(result, max_lines): return result -class VerboseProxy(object): +class VerboseProxy: """Proxy all function calls to another class and log method name, arguments and return values for each call. """ diff --git a/compose/config/config.py b/compose/config/config.py index c7cc724040b..8f579021543 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -1,12 +1,13 @@ import functools -import io import logging import os import re import string import sys from collections import namedtuple +from itertools import chain from operator import attrgetter +from operator import itemgetter import yaml from cached_property import cached_property @@ -166,7 +167,7 @@ class ConfigDetails(namedtuple('_ConfigDetails', 'working_dir config_files envir 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__( + return super().__new__( cls, working_dir, config_files, environment ) @@ -315,8 +316,8 @@ def validate_config_version(config_files): if main_file.version != next_file.version: raise ConfigurationError( - "Version mismatch: file {0} specifies version {1} but " - "extension file {2} uses version {3}".format( + "Version mismatch: file {} specifies version {} but " + "extension file {} uses version {}".format( main_file.filename, main_file.version, next_file.filename, @@ -595,7 +596,7 @@ def process_config_file(config_file, environment, service_name=None, interpolate return config_file -class ServiceExtendsResolver(object): +class ServiceExtendsResolver: def __init__(self, service_config, config_file, environment, already_seen=None): self.service_config = service_config self.working_dir = service_config.working_dir @@ -703,7 +704,7 @@ def resolve_build_args(buildargs, environment): def validate_extended_service_dict(service_dict, filename, service): - error_prefix = "Cannot extend service '%s' in %s:" % (service, filename) + error_prefix = "Cannot extend service '{}' in {}:".format(service, filename) if 'links' in service_dict: raise ConfigurationError( @@ -826,9 +827,9 @@ def process_ports(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'] - ]) + service_dict['depends_on'] = { + svc: {'condition': 'service_started'} for svc in service_dict['depends_on'] + } return service_dict @@ -1071,9 +1072,9 @@ def merge_service_dicts(base, override, version): def merge_unique_items_lists(base, override): - override = [str(o) for o in override] - base = [str(b) for b in base] - return sorted(set().union(base, override)) + override = (str(o) for o in override) + base = (str(b) for b in base) + return sorted(set(chain(base, override))) def merge_healthchecks(base, override): @@ -1086,9 +1087,7 @@ def merge_healthchecks(base, override): def merge_ports(md, base, override): def parse_sequence_func(seq): - acc = [] - for item in seq: - acc.extend(ServicePort.parse(item)) + acc = [s for item in seq for s in ServicePort.parse(item)] return to_mapping(acc, 'merge_field') field = 'ports' @@ -1098,7 +1097,7 @@ def parse_sequence_func(seq): merged = parse_sequence_func(md.base.get(field, [])) merged.update(parse_sequence_func(md.override.get(field, []))) - md[field] = [item for item in sorted(merged.values(), key=lambda x: x.target)] + md[field] = [item for item in sorted(merged.values(), key=attrgetter("target"))] def merge_build(output, base, override): @@ -1170,8 +1169,8 @@ def merge_reservations(base, override): def merge_unique_objects_lists(base, override): - result = dict((json_hash(i), i) for i in base + override) - return [i[1] for i in sorted([(k, v) for k, v in result.items()], key=lambda x: x[0])] + result = {json_hash(i): i for i in base + override} + return [i[1] for i in sorted(((k, v) for k, v in result.items()), key=itemgetter(0))] def merge_blkio_config(base, override): @@ -1179,11 +1178,11 @@ def merge_blkio_config(base, override): md.merge_scalar('weight') def merge_blkio_limits(base, override): - index = dict((b['path'], b) for b in base) - for o in override: - index[o['path']] = o + get_path = itemgetter('path') + index = {get_path(b): b for b in base} + index.update((get_path(o), o) for o in override) - return sorted(list(index.values()), key=lambda x: x['path']) + return sorted(index.values(), key=get_path) for field in [ "device_read_bps", "device_read_iops", "device_write_bps", @@ -1304,7 +1303,7 @@ def resolve_volume_path(working_dir, volume): if host_path.startswith('.'): host_path = expand_path(working_dir, host_path) host_path = os.path.expanduser(host_path) - return u"{}:{}{}".format(host_path, container_path, (':' + mode if mode else '')) + return "{}:{}{}".format(host_path, container_path, (':' + mode if mode else '')) return container_path @@ -1447,13 +1446,13 @@ def has_uppercase(name): def load_yaml(filename, encoding=None, binary=True): try: - with io.open(filename, 'rb' if binary else 'r', encoding=encoding) as fh: + with open(filename, 'rb' if binary else 'r', encoding=encoding) as fh: return yaml.safe_load(fh) - except (IOError, yaml.YAMLError, UnicodeDecodeError) as e: + except (OSError, yaml.YAMLError, UnicodeDecodeError) as e: if encoding is None: # Sometimes the user's locale sets an encoding that doesn't match # the YAML files. Im such cases, retry once with the "default" # UTF-8 encoding return load_yaml(filename, encoding='utf-8-sig', binary=False) error_name = getattr(e, '__module__', '') + '.' + e.__class__.__name__ - raise ConfigurationError(u"{}: {}".format(error_name, e)) + raise ConfigurationError("{}: {}".format(error_name, e)) diff --git a/compose/config/environment.py b/compose/config/environment.py index 4526d0b3ef2..1780851fdcb 100644 --- a/compose/config/environment.py +++ b/compose/config/environment.py @@ -43,7 +43,7 @@ def env_vars_from_file(filename, interpolate=True): class Environment(dict): def __init__(self, *args, **kwargs): - super(Environment, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self.missing_keys = [] self.silent = False @@ -81,11 +81,11 @@ def from_command_line(cls, parsed_env_opts): def __getitem__(self, key): try: - return super(Environment, self).__getitem__(key) + return super().__getitem__(key) except KeyError: if IS_WINDOWS_PLATFORM: try: - return super(Environment, self).__getitem__(key.upper()) + return super().__getitem__(key.upper()) except KeyError: pass if not self.silent and key not in self.missing_keys: @@ -98,20 +98,20 @@ def __getitem__(self, key): return "" def __contains__(self, key): - result = super(Environment, self).__contains__(key) + result = super().__contains__(key) if IS_WINDOWS_PLATFORM: return ( - result or super(Environment, self).__contains__(key.upper()) + result or super().__contains__(key.upper()) ) return result def get(self, key, *args, **kwargs): if IS_WINDOWS_PLATFORM: - return super(Environment, self).get( + return super().get( key, - super(Environment, self).get(key.upper(), *args, **kwargs) + super().get(key.upper(), *args, **kwargs) ) - return super(Environment, self).get(key, *args, **kwargs) + return super().get(key, *args, **kwargs) def get_boolean(self, key): # Convert a value to a boolean using "common sense" rules. diff --git a/compose/config/errors.py b/compose/config/errors.py index 7db079e9c78..b66433a7998 100644 --- a/compose/config/errors.py +++ b/compose/config/errors.py @@ -1,5 +1,3 @@ - - VERSION_EXPLANATION = ( 'You might be seeing this error because you\'re using the wrong Compose file version. ' 'Either specify a supported version (e.g "2.2" or "3.3") and place ' @@ -40,7 +38,7 @@ def msg(self): class ComposeFileNotFound(ConfigurationError): def __init__(self, supported_filenames): - super(ComposeFileNotFound, self).__init__(""" + super().__init__(""" Can't find a suitable configuration file in this directory or any parent. Are you in the right directory? @@ -51,7 +49,7 @@ def __init__(self, supported_filenames): class DuplicateOverrideFileFound(ConfigurationError): def __init__(self, override_filenames): self.override_filenames = override_filenames - super(DuplicateOverrideFileFound, self).__init__( + super().__init__( "Multiple override files found: {}. You may only use a single " "override file.".format(", ".join(override_filenames)) ) diff --git a/compose/config/interpolation.py b/compose/config/interpolation.py index bfa6a56c9a6..71e78bbaa64 100644 --- a/compose/config/interpolation.py +++ b/compose/config/interpolation.py @@ -11,7 +11,7 @@ log = logging.getLogger(__name__) -class Interpolator(object): +class Interpolator: def __init__(self, templater, mapping): self.templater = templater @@ -31,15 +31,15 @@ def interpolate_environment_variables(version, config, section, environment): interpolator = Interpolator(TemplateWithDefaults, environment) def process_item(name, config_dict): - return dict( - (key, interpolate_value(name, key, val, section, interpolator)) + return { + key: interpolate_value(name, key, val, section, interpolator) for key, val in (config_dict or {}).items() - ) + } - return dict( - (name, process_item(name, config_dict or {})) + return { + name: process_item(name, config_dict or {}) for name, config_dict in config.items() - ) + } def get_config_path(config_key, section, name): @@ -75,10 +75,10 @@ def append(config_path, key): if isinstance(obj, str): return converter.convert(config_path, interpolator.interpolate(obj)) if isinstance(obj, dict): - return dict( - (key, recursive_interpolate(val, interpolator, append(config_path, key))) - for (key, val) in obj.items() - ) + return { + key: recursive_interpolate(val, interpolator, append(config_path, key)) + for key, val in obj.items() + } if isinstance(obj, list): return [recursive_interpolate(val, interpolator, config_path) for val in obj] return converter.convert(config_path, obj) @@ -135,7 +135,7 @@ def convert(mo): val = mapping[named] if isinstance(val, bytes): val = val.decode('utf-8') - return '%s' % (val,) + return '{}'.format(val) if mo.group('escaped') is not None: return self.delimiter if mo.group('invalid') is not None: @@ -224,7 +224,7 @@ def to_microseconds(v): return int(parse_nanoseconds_int(v) / 1000) -class ConversionMap(object): +class ConversionMap: map = { service_path('blkio_config', 'weight'): to_int, service_path('blkio_config', 'weight_device', 'weight'): to_int, diff --git a/compose/config/serialize.py b/compose/config/serialize.py index e41e1ba4f27..2dd2c47f1a3 100644 --- a/compose/config/serialize.py +++ b/compose/config/serialize.py @@ -104,7 +104,7 @@ def serialize_ns_time_value(value): result = (int(value), stage[1]) else: break - return '{0}{1}'.format(*result) + return '{}{}'.format(*result) def denormalize_service_dict(service_dict, version, image_digest=None): diff --git a/compose/config/sort_services.py b/compose/config/sort_services.py index 65953891f95..0a7eb2b4fda 100644 --- a/compose/config/sort_services.py +++ b/compose/config/sort_services.py @@ -21,7 +21,7 @@ def get_source_name_from_network_mode(network_mode, source_type): def get_service_names(links): - return [link.split(':')[0] for link in links] + return [link.split(':', 1)[0] for link in links] def get_service_names_from_volumes_from(volumes_from): diff --git a/compose/config/types.py b/compose/config/types.py index 0c654fa6f8d..f52b5654139 100644 --- a/compose/config/types.py +++ b/compose/config/types.py @@ -146,7 +146,7 @@ def normpath(path, win_host=False): return path -class MountSpec(object): +class MountSpec: options_map = { 'volume': { 'nocopy': 'no_copy' @@ -338,9 +338,9 @@ def merge_field(self): return self.source def repr(self): - return dict( - [(k, v) for k, v in zip(self._fields, self) if v is not None] - ) + return { + k: v for k, v in zip(self._fields, self) if v is not None + } class ServiceSecret(ServiceConfigBase): @@ -362,10 +362,7 @@ def __new__(cls, target, published, *args, **kwargs): if published: if isinstance(published, str) and '-' in published: # "x-y:z" format a, b = published.split('-', 1) - try: - int(a) - int(b) - except ValueError: + if not a.isdigit() or not b.isdigit(): raise ConfigurationError('Invalid published port: {}'.format(published)) else: try: @@ -373,7 +370,7 @@ def __new__(cls, target, published, *args, **kwargs): except ValueError: raise ConfigurationError('Invalid published port: {}'.format(published)) - return super(ServicePort, cls).__new__( + return super().__new__( cls, target, published, *args, **kwargs ) @@ -422,9 +419,9 @@ def merge_field(self): return (self.target, self.published, self.external_ip, self.protocol) def repr(self): - return dict( - [(k, v) for k, v in zip(self._fields, self) if v is not None] - ) + return { + k: v for k, v in zip(self._fields, self) if v is not None + } def legacy_repr(self): return normalize_port_dict(self.repr()) @@ -484,9 +481,9 @@ def parse(cls, value): if con[0] == 'seccomp' and con[1] != 'unconfined': try: - with open(unquote_path(con[1]), 'r') as f: + with open(unquote_path(con[1])) as f: seccomp_data = json.load(f) - except (IOError, ValueError) as e: + except (OSError, ValueError) as e: raise ConfigurationError('Error reading seccomp profile: {}'.format(e)) return cls( 'seccomp={}'.format(json.dumps(seccomp_data)), con[1] diff --git a/compose/config/validation.py b/compose/config/validation.py index 61a3370db37..2b46cafe4f3 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -100,7 +100,7 @@ def match_named_volumes(service_dict, project_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' + 'Named volume "{}" is used in service "{}" but no' ' declaration was found in the volumes section.'.format( volume_spec.repr(), service_dict.get('name') ) @@ -508,13 +508,13 @@ def load_jsonschema(version): filename = os.path.join( get_schema_path(), - "config_schema_{0}.json".format(suffix)) + "config_schema_{}.json".format(suffix)) if not os.path.exists(filename): raise ConfigurationError( 'Version in "{}" is unsupported. {}' .format(filename, VERSION_EXPLANATION)) - with open(filename, "r") as fh: + with open(filename) as fh: return json.load(fh) @@ -534,7 +534,7 @@ def handle_errors(errors, format_error_func, filename): 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)) + errors = sorted(errors, key=str) if not errors: return diff --git a/compose/container.py b/compose/container.py index 7160a982e76..00626b6190e 100644 --- a/compose/container.py +++ b/compose/container.py @@ -12,7 +12,7 @@ from .version import ComposeVersion -class Container(object): +class Container: """ Represents a Docker container, constructed from the output of GET /containers/:id:/json. @@ -78,8 +78,8 @@ def service(self): @property def name_without_project(self): - if self.name.startswith('{0}_{1}'.format(self.project, self.service)): - return '{0}_{1}'.format(self.service, self.number if self.number is not None else self.slug) + if self.name.startswith('{}_{}'.format(self.project, self.service)): + return '{}_{}'.format(self.service, self.number if self.number is not None else self.slug) else: return self.name @@ -91,7 +91,7 @@ def number(self): number = self.labels.get(LABEL_CONTAINER_NUMBER) if not number: - raise ValueError("Container {0} does not have a {1} label".format( + raise ValueError("Container {} does not have a {} label".format( self.short_id, LABEL_CONTAINER_NUMBER)) return int(number) @@ -224,7 +224,7 @@ def get_value(dictionary, key): return reduce(get_value, key.split('.'), self.dictionary) def get_local_port(self, port, protocol='tcp'): - port = self.ports.get("%s/%s" % (port, protocol)) + port = self.ports.get("{}/{}".format(port, protocol)) return "{HostIp}:{HostPort}".format(**port[0]) if port else None def get_mount(self, mount_dest): @@ -266,7 +266,7 @@ def rename_to_tmp_name(self): """ if not self.name.startswith(self.short_id): self.client.rename( - self.id, '{0}_{1}'.format(self.short_id, self.name) + self.id, '{}_{}'.format(self.short_id, self.name) ) def inspect_if_not_inspected(self): @@ -309,7 +309,7 @@ def has_legacy_proj_name(self, project_name): ) def __repr__(self): - return '' % (self.name, self.id[:6]) + return ''.format(self.name, self.id[:6]) def __eq__(self, other): if type(self) != type(other): diff --git a/compose/errors.py b/compose/errors.py index 53065617360..d4fead251ad 100644 --- a/compose/errors.py +++ b/compose/errors.py @@ -1,5 +1,3 @@ - - class OperationFailedError(Exception): def __init__(self, reason): self.msg = reason @@ -17,14 +15,14 @@ def __init__(self, reason): class HealthCheckFailed(HealthCheckException): def __init__(self, container_id): - super(HealthCheckFailed, self).__init__( + super().__init__( 'Container "{}" is unhealthy.'.format(container_id) ) class NoHealthCheckConfigured(HealthCheckException): def __init__(self, service_name): - super(NoHealthCheckConfigured, self).__init__( + super().__init__( 'Service "{}" is missing a healthcheck configuration'.format( service_name ) diff --git a/compose/network.py b/compose/network.py index bc3ade16870..a67c703c01f 100644 --- a/compose/network.py +++ b/compose/network.py @@ -1,6 +1,7 @@ import logging import re from collections import OrderedDict +from operator import itemgetter from docker.errors import NotFound from docker.types import IPAMConfig @@ -24,7 +25,7 @@ ] -class Network(object): +class Network: def __init__(self, client, project, name, driver=None, driver_opts=None, ipam=None, external=False, internal=False, enable_ipv6=False, labels=None, custom_name=False): @@ -51,7 +52,7 @@ def ensure(self): try: self.inspect() log.debug( - 'Network {0} declared as external. No new ' + 'Network {} declared as external. No new ' 'network will be created.'.format(self.name) ) except NotFound: @@ -107,7 +108,7 @@ def inspect(self, legacy=False): def legacy_full_name(self): if self.custom_name: return self.name - return '{0}_{1}'.format( + return '{}_{}'.format( re.sub(r'[_-]', '', self.project), self.name ) @@ -115,7 +116,7 @@ def legacy_full_name(self): def full_name(self): if self.custom_name: return self.name - return '{0}_{1}'.format(self.project, self.name) + return '{}_{}'.format(self.project, self.name) @property def true_name(self): @@ -167,7 +168,7 @@ def create_ipam_config_from_dict(ipam_dict): class NetworkConfigChangedError(ConfigurationError): def __init__(self, net_name, property_name): - super(NetworkConfigChangedError, self).__init__( + super().__init__( 'Network "{}" needs to be recreated - {} has changed'.format( net_name, property_name ) @@ -258,7 +259,7 @@ def build_networks(name, config_data, client): return networks -class ProjectNetworks(object): +class ProjectNetworks: def __init__(self, networks, use_networking): self.networks = networks or {} @@ -299,10 +300,10 @@ 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 {})) + return { + net: (config or {}) for net, config in networks.items() - ) + } def get_network_names_for_service(service_dict): @@ -328,4 +329,4 @@ def get_networks(service_dict, network_definitions): else: # Ensure Compose will pick a consistent primary network if no # priority is set - return OrderedDict(sorted(networks.items(), key=lambda t: t[0])) + return OrderedDict(sorted(networks.items(), key=itemgetter(0))) diff --git a/compose/parallel.py b/compose/parallel.py index 15c3ad572ab..acf9e4a84cf 100644 --- a/compose/parallel.py +++ b/compose/parallel.py @@ -25,7 +25,7 @@ STOP = object() -class GlobalLimit(object): +class GlobalLimit: """Simple class to hold a global semaphore limiter for a project. This class should be treated as a singleton that is instantiated when the project is. """ @@ -114,7 +114,7 @@ def _no_deps(x): return [] -class State(object): +class State: """ Holds the state of a partially-complete parallel operation. @@ -136,7 +136,7 @@ def pending(self): return set(self.objects) - self.started - self.finished - self.failed -class NoLimit(object): +class NoLimit: def __enter__(self): pass @@ -252,7 +252,7 @@ class UpstreamError(Exception): pass -class ParallelStreamWriter(object): +class ParallelStreamWriter: """Write out messages for operations happening in parallel. Each operation has its own line, and ANSI code characters are used diff --git a/compose/progress_stream.py b/compose/progress_stream.py index 8792ff287cb..3c03cc4b5b9 100644 --- a/compose/progress_stream.py +++ b/compose/progress_stream.py @@ -79,19 +79,19 @@ def print_output_event(event, stream, is_terminal): status = event.get('status', '') if 'progress' in event: - write_to_stream("%s %s%s" % (status, event['progress'], terminator), stream) + write_to_stream("{} {}{}".format(status, event['progress'], terminator), stream) elif 'progressDetail' in event: detail = event['progressDetail'] total = detail.get('total') if 'current' in detail and total: percentage = float(detail['current']) / float(total) * 100 - write_to_stream('%s (%.1f%%)%s' % (status, percentage, terminator), stream) + write_to_stream('{} ({:.1f}%){}'.format(status, percentage, terminator), stream) else: - write_to_stream('%s%s' % (status, terminator), stream) + write_to_stream('{}{}'.format(status, terminator), stream) elif 'stream' in event: - write_to_stream("%s%s" % (event['stream'], terminator), stream) + write_to_stream("{}{}".format(event['stream'], terminator), stream) else: - write_to_stream("%s%s\n" % (status, terminator), stream) + write_to_stream("{}{}\n".format(status, terminator), stream) def get_digest_from_pull(events): diff --git a/compose/project.py b/compose/project.py index 5a03b30ca0d..0ae5721bc6d 100644 --- a/compose/project.py +++ b/compose/project.py @@ -55,16 +55,16 @@ class OneOffFilter(enum.Enum): @classmethod def update_labels(cls, value, labels): if value == cls.only: - labels.append('{0}={1}'.format(LABEL_ONE_OFF, "True")) + labels.append('{}={}'.format(LABEL_ONE_OFF, "True")) elif value == cls.exclude: - labels.append('{0}={1}'.format(LABEL_ONE_OFF, "False")) + labels.append('{}={}'.format(LABEL_ONE_OFF, "False")) elif value == cls.include: pass else: raise ValueError("Invalid value for one_off: {}".format(repr(value))) -class Project(object): +class Project: """ A collection of services. """ @@ -80,7 +80,7 @@ def labels(self, one_off=OneOffFilter.exclude, legacy=False): name = self.name if legacy: name = re.sub(r'[_-]', '', name) - labels = ['{0}={1}'.format(LABEL_PROJECT, name)] + labels = ['{}={}'.format(LABEL_PROJECT, name)] OneOffFilter.update_labels(one_off, labels) return labels @@ -549,10 +549,10 @@ def build_container_event(event): 'action': event['status'], 'id': event['Actor']['ID'], 'service': container_attrs.get(LABEL_SERVICE), - 'attributes': dict([ - (k, v) for k, v in container_attrs.items() + 'attributes': { + k: v for k, v in container_attrs.items() if not k.startswith('com.docker.compose.') - ]), + }, 'container': container, } @@ -812,7 +812,7 @@ def _find(): return if remove_orphans: for ctnr in orphans: - log.info('Removing orphan container "{0}"'.format(ctnr.name)) + log.info('Removing orphan container "{}"'.format(ctnr.name)) try: ctnr.kill() except APIError: @@ -820,7 +820,7 @@ def _find(): ctnr.remove(force=True) else: log.warning( - 'Found orphan containers ({0}) for this project. If ' + 'Found orphan containers ({}) 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( @@ -966,16 +966,16 @@ def get_secrets(service, service_secrets, secret_defs): .format(service=service, secret=secret.source)) if secret_def.get('external'): - log.warning("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)) + log.warning('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 if secret.uid or secret.gid or secret.mode: log.warning( - "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}" 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 ) ) @@ -983,8 +983,8 @@ def get_secrets(service, service_secrets, secret_defs): secret_file = secret_def.get('file') if not path.isfile(str(secret_file)): log.warning( - "Service \"{service}\" uses an undefined secret file \"{secret_file}\", " - "the following file should be created \"{secret_file}\"".format( + 'Service "{service}" uses an undefined secret file "{secret_file}", ' + 'the following file should be created "{secret_file}"'.format( service=service, secret_file=secret_file ) ) diff --git a/compose/service.py b/compose/service.py index f52bd6ffe58..5980310b300 100644 --- a/compose/service.py +++ b/compose/service.py @@ -163,7 +163,7 @@ class BuildAction(enum.Enum): skip = 2 -class Service(object): +class Service: def __init__( self, name, @@ -230,10 +230,10 @@ def get_container(self, number=1): """Return a :class:`compose.container.Container` for this service. The container must be active, and match `number`. """ - for container in self.containers(labels=['{0}={1}'.format(LABEL_CONTAINER_NUMBER, number)]): + for container in self.containers(labels=['{}={}'.format(LABEL_CONTAINER_NUMBER, number)]): return container - raise ValueError("No container found for %s_%s" % (self.name, number)) + raise ValueError("No container found for {}_{}".format(self.name, number)) def start(self, **options): containers = self.containers(stopped=True) @@ -642,7 +642,7 @@ def start_container(self, container, use_network_aliases=True): expl = binarystr_to_unicode(ex.explanation) if "driver failed programming external connectivity" in expl: log.warn("Host is already in use by another container") - raise OperationFailedError("Cannot start service %s: %s" % (self.name, expl)) + raise OperationFailedError("Cannot start service {}: {}".format(self.name, expl)) return container @property @@ -736,12 +736,12 @@ def get_dependency_configs(self): pid_namespace = self.pid_mode.service_name ipc_namespace = self.ipc_mode.service_name - configs = dict( - [(name, None) for name in self.get_linked_service_names()] + configs = { + name: None for name in self.get_linked_service_names() + } + configs.update( + (name, None) for name in self.get_volumes_from_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({pid_namespace: None} if pid_namespace else {}) configs.update({ipc_namespace: None} if ipc_namespace else {}) @@ -863,9 +863,9 @@ def _get_container_create_options( add_config_hash = (not one_off and not override_options) slug = generate_random_id() if one_off else None - container_options = dict( - (k, self.options[k]) - for k in DOCKER_CONFIG_KEYS if k in self.options) + container_options = { + k: self.options[k] + for k in DOCKER_CONFIG_KEYS if k in self.options} override_volumes = override_options.pop('volumes', []) container_options.update(override_options) @@ -957,7 +957,7 @@ def _build_container_volume_options(self, previous_container, container_options, ) container_options['environment'].update(affinity) - container_options['volumes'] = dict((v.internal, {}) for v in container_volumes or {}) + container_options['volumes'] = {v.internal: {} for v in container_volumes or {}} if version_gte(self.client.api_version, '1.30'): override_options['mounts'] = [build_mount(v) for v in container_mounts] or None else: @@ -1159,9 +1159,9 @@ def can_be_built(self): def labels(self, one_off=False, legacy=False): proj_name = self.project if not legacy else re.sub(r'[_-]', '', self.project) return [ - '{0}={1}'.format(LABEL_PROJECT, proj_name), - '{0}={1}'.format(LABEL_SERVICE, self.name), - '{0}={1}'.format(LABEL_ONE_OFF, "True" if one_off else "False"), + '{}={}'.format(LABEL_PROJECT, proj_name), + '{}={}'.format(LABEL_SERVICE, self.name), + '{}={}'.format(LABEL_ONE_OFF, "True" if one_off else "False"), ] @property @@ -1178,7 +1178,7 @@ def get_container_name(self, service_name, number, slug=None): ext_links_origins = [link.split(':')[0] for link in self.options.get('external_links', [])] if container_name in ext_links_origins: raise DependencyError( - 'Service {0} has a self-referential external link: {1}'.format( + 'Service {} has a self-referential external link: {}'.format( self.name, container_name ) ) @@ -1233,11 +1233,9 @@ def _do_pull(self, repo, pull_kwargs, silent, ignore_pull_failures): output = self.client.pull(repo, **pull_kwargs) if silent: with open(os.devnull, 'w') as devnull: - for event in stream_output(output, devnull): - yield event + yield from stream_output(output, devnull) else: - for event in stream_output(output, sys.stdout): - yield event + yield from stream_output(output, sys.stdout) except (StreamOutputError, NotFound) as e: if not ignore_pull_failures: raise @@ -1255,7 +1253,7 @@ def pull(self, ignore_pull_failures=False, silent=False, stream=False): 'platform': self.platform, } if not silent: - log.info('Pulling %s (%s%s%s)...' % (self.name, repo, separator, tag)) + log.info('Pulling {} ({}{}{})...'.format(self.name, repo, separator, tag)) if kwargs['platform'] and version_lt(self.client.api_version, '1.35'): raise OperationFailedError( @@ -1273,7 +1271,7 @@ def push(self, ignore_push_failures=False): 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)) + log.info('Pushing {} ({}{}{})...'.format(self.name, repo, separator, tag)) output = self.client.push(repo, tag=tag, stream=True) try: @@ -1335,7 +1333,7 @@ def short_id_alias_exists(container, network): return container.short_id in aliases -class IpcMode(object): +class IpcMode: def __init__(self, mode): self._mode = mode @@ -1375,7 +1373,7 @@ def __init__(self, container): self._mode = 'container:{}'.format(container.id) -class PidMode(object): +class PidMode: def __init__(self, mode): self._mode = mode @@ -1415,7 +1413,7 @@ def __init__(self, container): self._mode = 'container:{}'.format(container.id) -class NetworkMode(object): +class NetworkMode: """A `standard` network mode (ex: host, bridge)""" service_name = None @@ -1430,7 +1428,7 @@ def id(self): mode = id -class ContainerNetworkMode(object): +class ContainerNetworkMode: """A network mode that uses a container's network stack.""" service_name = None @@ -1447,7 +1445,7 @@ def mode(self): return 'container:' + self.container.id -class ServiceNetworkMode(object): +class ServiceNetworkMode: """A network mode that uses a service's network stack.""" def __init__(self, service): @@ -1552,10 +1550,10 @@ def get_container_data_volumes(container, volumes_option, tmpfs_option, mounts_o volumes = [] volumes_option = volumes_option or [] - container_mounts = dict( - (mount['Destination'], mount) + container_mounts = { + mount['Destination']: mount for mount in container.get('Mounts') or {} - ) + } image_volumes = [ VolumeSpec.parse(volume) @@ -1607,9 +1605,9 @@ def get_container_data_volumes(container, volumes_option, tmpfs_option, mounts_o def warn_on_masked_volume(volumes_option, container_volumes, service): - container_volumes = dict( - (volume.internal, volume.external) - for volume in container_volumes) + container_volumes = { + volume.internal: volume.external + for volume in container_volumes} for volume in volumes_option: if ( @@ -1759,7 +1757,7 @@ def convert_blkio_config(blkio_config): continue arr = [] for item in blkio_config[field]: - arr.append(dict([(k.capitalize(), v) for k, v in item.items()])) + arr.append({k.capitalize(): v for k, v in item.items()}) result[field] = arr return result @@ -1771,7 +1769,7 @@ def rewrite_build_path(path): return path -class _CLIBuilder(object): +class _CLIBuilder: def __init__(self, progress): self._progress = progress @@ -1879,7 +1877,7 @@ def build(self, path, tag=None, quiet=False, fileobj=None, yield json.dumps({"stream": "{}{}\n".format(magic_word, image_id)}) -class _CommandBuilder(object): +class _CommandBuilder: def __init__(self): self._args = ["docker", "build"] diff --git a/compose/timeparse.py b/compose/timeparse.py index bdd9f611f3a..47744562519 100644 --- a/compose/timeparse.py +++ b/compose/timeparse.py @@ -1,5 +1,4 @@ #!/usr/bin/env python -# -*- coding: utf-8 -*- ''' timeparse.py (c) Will Roberts 1 February, 2014 @@ -54,14 +53,14 @@ def opt(x): 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), -]) +MULTIPLIERS = { + '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): @@ -90,4 +89,4 @@ def timeparse(sval): def cast(value): - return int(value, 10) if value.isdigit() else float(value) + return int(value) if value.isdigit() else float(value) diff --git a/compose/utils.py b/compose/utils.py index 8b5ab38d9a1..060ba50cc36 100644 --- a/compose/utils.py +++ b/compose/utils.py @@ -29,7 +29,7 @@ def stream_as_text(stream): yield data -def line_splitter(buffer, separator=u'\n'): +def line_splitter(buffer, separator='\n'): index = buffer.find(str(separator)) if index == -1: return None @@ -45,7 +45,7 @@ def split_buffer(stream, splitter=None, decoder=lambda a: a): of the input. """ splitter = splitter or line_splitter - buffered = str('') + buffered = '' for data in stream_as_text(stream): buffered += data @@ -116,7 +116,7 @@ def parse_nanoseconds_int(value): def build_string_dict(source_dict): - return dict((k, str(v if v is not None else '')) for k, v in source_dict.items()) + return {k: str(v if v is not None else '') for k, v in source_dict.items()} def splitdrive(path): diff --git a/compose/volume.py b/compose/volume.py index d31417a550d..5f36e432ba9 100644 --- a/compose/volume.py +++ b/compose/volume.py @@ -1,5 +1,6 @@ import logging import re +from itertools import chain from docker.errors import NotFound from docker.utils import version_lt @@ -15,7 +16,7 @@ log = logging.getLogger(__name__) -class Volume(object): +class Volume: def __init__(self, client, project, name, driver=None, driver_opts=None, external=False, labels=None, custom_name=False): self.client = client @@ -57,13 +58,13 @@ def exists(self): def full_name(self): if self.custom_name: return self.name - return '{0}_{1}'.format(self.project.lstrip('-_'), self.name) + return '{}_{}'.format(self.project.lstrip('-_'), self.name) @property def legacy_full_name(self): if self.custom_name: return self.name - return '{0}_{1}'.format( + return '{}_{}'.format( re.sub(r'[_-]', '', self.project), self.name ) @@ -96,7 +97,7 @@ def _set_legacy_flag(self): self.legacy = False -class ProjectVolumes(object): +class ProjectVolumes: def __init__(self, volumes): self.volumes = volumes @@ -132,7 +133,7 @@ def initialize(self): volume_exists = volume.exists() if volume.external: log.debug( - 'Volume {0} declared as external. No new ' + 'Volume {} declared as external. No new ' 'volume will be created.'.format(volume.name) ) if not volume_exists: @@ -148,7 +149,7 @@ def initialize(self): if not volume_exists: log.info( - 'Creating volume "{0}" with {1} driver'.format( + 'Creating volume "{}" with {} driver'.format( volume.full_name, volume.driver or 'default' ) ) @@ -157,7 +158,7 @@ def initialize(self): check_remote_volume_config(volume.inspect(legacy=volume.legacy), volume) except NotFound: raise ConfigurationError( - 'Volume %s specifies nonexistent driver %s' % (volume.name, volume.driver) + 'Volume {} specifies nonexistent driver {}'.format(volume.name, volume.driver) ) def namespace_spec(self, volume_spec): @@ -174,7 +175,7 @@ def namespace_spec(self, volume_spec): class VolumeConfigChangedError(ConfigurationError): def __init__(self, local, property_name, local_value, remote_value): - super(VolumeConfigChangedError, self).__init__( + super().__init__( 'Configuration for volume {vol_name} specifies {property_name} ' '{local_value}, but a volume with the same name uses a different ' '{property_name} ({remote_value}). If you wish to use the new ' @@ -192,7 +193,7 @@ def check_remote_volume_config(remote, local): raise VolumeConfigChangedError(local, 'driver', local.driver, remote.get('Driver')) local_opts = local.driver_opts or {} remote_opts = remote.get('Options') or {} - for k in set.union(set(remote_opts.keys()), set(local_opts.keys())): + for k in set(chain(remote_opts, local_opts)): if k.startswith('com.docker.'): # These options are set internally continue if remote_opts.get(k) != local_opts.get(k): @@ -202,7 +203,7 @@ def check_remote_volume_config(remote, local): local_labels = local.labels or {} remote_labels = remote.get('Labels') or {} - for k in set.union(set(remote_labels.keys()), set(local_labels.keys())): + for k in set(chain(remote_labels, local_labels)): if k.startswith('com.docker.'): # We are only interested in user-specified labels continue if remote_labels.get(k) != local_labels.get(k): diff --git a/contrib/migration/migrate-compose-file-v1-to-v2.py b/contrib/migration/migrate-compose-file-v1-to-v2.py index e217b7072c3..26511206c5f 100755 --- a/contrib/migration/migrate-compose-file-v1-to-v2.py +++ b/contrib/migration/migrate-compose-file-v1-to-v2.py @@ -156,7 +156,7 @@ def main(args): opts = parse_opts(args) - with open(opts.filename, 'r') as fh: + with open(opts.filename) as fh: new_format = migrate(fh.read()) if opts.in_place: diff --git a/docker-compose_darwin.spec b/docker-compose_darwin.spec index 6f96b29a442..3228884f5df 100644 --- a/docker-compose_darwin.spec +++ b/docker-compose_darwin.spec @@ -1,4 +1,4 @@ -# -*- mode: python ; coding: utf-8 -*- +# -*- mode: python -*- block_cipher = None diff --git a/requirements.txt b/requirements.txt index d0a386642d1..59317bdfca9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,8 +8,6 @@ docker==4.3.0 docker-pycreds==0.4.0 dockerpty==0.4.1 docopt==0.6.2 -enum34==1.1.6; python_version < '3.4' -functools32==3.2.3.post2; python_version < '3.2' idna==2.10 ipaddress==1.0.23 jsonschema==3.2.0 diff --git a/script/release/utils.py b/script/release/utils.py index 25b39ca7497..5ed53ec85b3 100644 --- a/script/release/utils.py +++ b/script/release/utils.py @@ -6,7 +6,7 @@ def update_init_py_version(version): path = os.path.join(REPO_ROOT, 'compose', '__init__.py') - with open(path, 'r') as f: + with open(path) as f: contents = f.read() contents = re.sub(r"__version__ = '[0-9a-z.-]+'", "__version__ = '{}'".format(version), contents) with open(path, 'w') as f: @@ -15,7 +15,7 @@ def update_init_py_version(version): def update_run_sh_version(version): path = os.path.join(REPO_ROOT, 'script', 'run', 'run.sh') - with open(path, 'r') as f: + with open(path) as f: contents = f.read() contents = re.sub(r'VERSION="[0-9a-z.-]+"', 'VERSION="{}"'.format(version), contents) with open(path, 'w') as f: diff --git a/setup.py b/setup.py index a2e946b3342..6041fce15be 100644 --- a/setup.py +++ b/setup.py @@ -1,5 +1,4 @@ #!/usr/bin/env python -# -*- coding: utf-8 -*- import codecs import os import re @@ -50,7 +49,6 @@ def find_version(*file_paths): tests_require.append('mock >= 1.0.1, < 4') extras_require = { - ':python_version < "3.2"': ['subprocess32 >= 3.5.4, < 4'], ':python_version < "3.4"': ['enum34 >= 1.0.4, < 2'], ':python_version < "3.5"': ['backports.ssl_match_hostname >= 3.5, < 4'], ':python_version < "3.3"': ['backports.shutil_get_terminal_size == 1.0.0', @@ -94,7 +92,7 @@ def find_version(*file_paths): install_requires=install_requires, extras_require=extras_require, tests_require=tests_require, - python_requires='>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*', + python_requires='>=3.0, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*', entry_points={ 'console_scripts': ['docker-compose=compose.cli.main:main'], }, diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 92168e93fde..4dd93521003 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- import datetime import json import os.path @@ -99,7 +98,7 @@ def kill_service(service): container.kill() -class ContainerCountCondition(object): +class ContainerCountCondition: def __init__(self, project, expected): self.project = project @@ -112,7 +111,7 @@ def __str__(self): return "waiting for counter count == %s" % self.expected -class ContainerStateCondition(object): +class ContainerStateCondition: def __init__(self, client, name, status): self.client = client @@ -140,7 +139,7 @@ def __str__(self): class CLITestCase(DockerClientTestCase): def setUp(self): - super(CLITestCase, self).setUp() + super().setUp() self.base_dir = 'tests/fixtures/simple-composefile' self.override_dir = None @@ -162,7 +161,7 @@ def tearDown(self): if hasattr(self, '_project'): del self._project - super(CLITestCase, self).tearDown() + super().tearDown() @property def project(self): @@ -206,14 +205,14 @@ def test_help_nonexistent(self): def test_shorthand_host_opt(self): self.dispatch( - ['-H={0}'.format(os.environ.get('DOCKER_HOST', 'unix://')), + ['-H={}'.format(os.environ.get('DOCKER_HOST', 'unix://')), 'up', '-d'], returncode=0 ) def test_shorthand_host_opt_interactive(self): self.dispatch( - ['-H={0}'.format(os.environ.get('DOCKER_HOST', 'unix://')), + ['-H={}'.format(os.environ.get('DOCKER_HOST', 'unix://')), 'run', 'another', 'ls'], returncode=0 ) @@ -1453,7 +1452,7 @@ def test_up_with_volume_labels(self): if v['Name'].split('/')[-1].startswith('{}_'.format(self.project.name)) ] - assert set([v['Name'].split('/')[-1] for v in volumes]) == {volume_with_label} + assert {v['Name'].split('/')[-1] for v in volumes} == {volume_with_label} assert 'label_key' in volumes[0]['Labels'] assert volumes[0]['Labels']['label_key'] == 'label_val' @@ -1866,12 +1865,12 @@ def test_run_without_command(self): self.dispatch(['run', 'implicit']) service = self.project.get_service('implicit') containers = service.containers(stopped=True, one_off=OneOffFilter.only) - assert [c.human_readable_command for c in containers] == [u'/bin/sh -c echo "success"'] + assert [c.human_readable_command for c in containers] == ['/bin/sh -c echo "success"'] self.dispatch(['run', 'explicit']) service = self.project.get_service('explicit') containers = service.containers(stopped=True, one_off=OneOffFilter.only) - assert [c.human_readable_command for c in containers] == [u'/bin/true'] + assert [c.human_readable_command for c in containers] == ['/bin/true'] @pytest.mark.skipif(SWARM_SKIP_RM_VOLUMES, reason='Swarm DELETE /containers/ bug') def test_run_rm(self): @@ -2701,7 +2700,7 @@ 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), + '{} {}'.format(str_iso_date, str_iso_time), '%Y-%m-%d %H:%M:%S.%f'), datetime.datetime) except ValueError: @@ -2790,7 +2789,7 @@ def test_up_with_extends(self): self.base_dir = 'tests/fixtures/extends' self.dispatch(['up', '-d'], None) - assert set([s.name for s in self.project.services]) == {'mydb', 'myweb'} + assert {s.name for s in self.project.services} == {'mydb', 'myweb'} # Sort by name so we get [db, web] containers = sorted( diff --git a/tests/acceptance/context_test.py b/tests/acceptance/context_test.py index e17e7171785..a5d0c14730f 100644 --- a/tests/acceptance/context_test.py +++ b/tests/acceptance/context_test.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- import os import shutil import unittest diff --git a/tests/helpers.py b/tests/helpers.py index d178684853b..3642e6ebc5f 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -49,7 +49,7 @@ def create_custom_host_file(client, filename, content): def create_host_file(client, filename): - with open(filename, 'r') as fh: + with open(filename) as fh: content = fh.read() return create_custom_host_file(client, filename, content) diff --git a/tests/integration/environment_test.py b/tests/integration/environment_test.py index 43df2c52b03..12a969c9428 100644 --- a/tests/integration/environment_test.py +++ b/tests/integration/environment_test.py @@ -15,7 +15,7 @@ class EnvironmentTest(DockerClientTestCase): @classmethod def setUpClass(cls): - super(EnvironmentTest, cls).setUpClass() + super().setUpClass() cls.compose_file = tempfile.NamedTemporaryFile(mode='w+b') cls.compose_file.write(bytes("""version: '3.2' services: @@ -27,7 +27,7 @@ def setUpClass(cls): @classmethod def tearDownClass(cls): - super(EnvironmentTest, cls).tearDownClass() + super().tearDownClass() cls.compose_file.close() @data('events', diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 4848c53ee20..87970107609 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -289,19 +289,19 @@ def test_start_pause_unpause_stop_kill_remove(self): db_container = db.create_container() project.start(service_names=['web']) - assert set(c.name for c in project.containers() if c.is_running) == { + assert {c.name for c in project.containers() if c.is_running} == { web_container_1.name, web_container_2.name} project.start() - assert set(c.name for c in project.containers() if c.is_running) == { + assert {c.name for c in project.containers() if c.is_running} == { web_container_1.name, web_container_2.name, db_container.name} project.pause(service_names=['web']) - assert set([c.name for c in project.containers() if c.is_paused]) == { + assert {c.name for c in project.containers() if c.is_paused} == { web_container_1.name, web_container_2.name} project.pause() - assert set([c.name for c in project.containers() if c.is_paused]) == { + assert {c.name for c in project.containers() if c.is_paused} == { web_container_1.name, web_container_2.name, db_container.name} project.unpause(service_names=['db']) @@ -311,7 +311,7 @@ def test_start_pause_unpause_stop_kill_remove(self): assert len([c.name for c in project.containers() if c.is_paused]) == 0 project.stop(service_names=['web'], timeout=1) - assert set(c.name for c in project.containers() if c.is_running) == {db_container.name} + assert {c.name for c in project.containers() if c.is_running} == {db_container.name} project.kill(service_names=['db']) assert len([c for c in project.containers() if c.is_running]) == 0 @@ -1177,8 +1177,8 @@ def test_project_up_with_network_label(self): assert networks[0]['Labels']['label_key'] == 'label_val' def test_project_up_volumes(self): - vol_name = '{0:x}'.format(random.getrandbits(32)) - full_vol_name = 'composetest_{0}'.format(vol_name) + vol_name = '{:x}'.format(random.getrandbits(32)) + full_vol_name = 'composetest_{}'.format(vol_name) config_data = build_config( services=[{ 'name': 'web', @@ -1232,9 +1232,9 @@ def test_project_up_with_volume_labels(self): if v['Name'].split('/')[-1].startswith('composetest_') ] - assert set([v['Name'].split('/')[-1] for v in volumes]) == set( - ['composetest_{}'.format(volume_name)] - ) + assert {v['Name'].split('/')[-1] for v in volumes} == { + 'composetest_{}'.format(volume_name) + } assert 'label_key' in volumes[0]['Labels'] assert volumes[0]['Labels']['label_key'] == 'label_val' @@ -1348,8 +1348,8 @@ def test_project_up_config_scale(self): assert len(project.containers()) == 3 def test_initialize_volumes(self): - vol_name = '{0:x}'.format(random.getrandbits(32)) - full_vol_name = 'composetest_{0}'.format(vol_name) + vol_name = '{:x}'.format(random.getrandbits(32)) + full_vol_name = 'composetest_{}'.format(vol_name) config_data = build_config( services=[{ 'name': 'web', @@ -1370,8 +1370,8 @@ def test_initialize_volumes(self): assert volume_data['Driver'] == 'local' def test_project_up_implicit_volume_driver(self): - vol_name = '{0:x}'.format(random.getrandbits(32)) - full_vol_name = 'composetest_{0}'.format(vol_name) + vol_name = '{:x}'.format(random.getrandbits(32)) + full_vol_name = 'composetest_{}'.format(vol_name) config_data = build_config( services=[{ 'name': 'web', @@ -1479,7 +1479,7 @@ def test_project_up_with_added_secrets(self): assert output == b"This is the secret\n" def test_initialize_volumes_invalid_volume_driver(self): - vol_name = '{0:x}'.format(random.getrandbits(32)) + vol_name = '{:x}'.format(random.getrandbits(32)) config_data = build_config( version=VERSION, @@ -1500,8 +1500,8 @@ def test_initialize_volumes_invalid_volume_driver(self): @no_cluster('inspect volume by name defect on Swarm Classic') def test_initialize_volumes_updated_driver(self): - vol_name = '{0:x}'.format(random.getrandbits(32)) - full_vol_name = 'composetest_{0}'.format(vol_name) + vol_name = '{:x}'.format(random.getrandbits(32)) + full_vol_name = 'composetest_{}'.format(vol_name) config_data = build_config( services=[{ @@ -1531,14 +1531,14 @@ def test_initialize_volumes_updated_driver(self): ) with pytest.raises(config.ConfigurationError) as e: project.volumes.initialize() - assert 'Configuration for volume {0} specifies driver smb'.format( + assert 'Configuration for volume {} specifies driver smb'.format( vol_name ) in str(e.value) @no_cluster('inspect volume by name defect on Swarm Classic') def test_initialize_volumes_updated_driver_opts(self): - vol_name = '{0:x}'.format(random.getrandbits(32)) - full_vol_name = 'composetest_{0}'.format(vol_name) + vol_name = '{:x}'.format(random.getrandbits(32)) + full_vol_name = 'composetest_{}'.format(vol_name) tmpdir = tempfile.mkdtemp(prefix='compose_test_') self.addCleanup(shutil.rmtree, tmpdir) driver_opts = {'o': 'bind', 'device': tmpdir, 'type': 'none'} @@ -1575,13 +1575,13 @@ def test_initialize_volumes_updated_driver_opts(self): ) with pytest.raises(config.ConfigurationError) as e: project.volumes.initialize() - assert 'Configuration for volume {0} specifies "device" driver_opt {1}'.format( + assert 'Configuration for volume {} specifies "device" driver_opt {}'.format( vol_name, driver_opts['device'] ) in str(e.value) def test_initialize_volumes_updated_blank_driver(self): - vol_name = '{0:x}'.format(random.getrandbits(32)) - full_vol_name = 'composetest_{0}'.format(vol_name) + vol_name = '{:x}'.format(random.getrandbits(32)) + full_vol_name = 'composetest_{}'.format(vol_name) config_data = build_config( services=[{ @@ -1617,8 +1617,8 @@ def test_initialize_volumes_updated_blank_driver(self): @no_cluster('inspect volume by name defect on Swarm Classic') def test_initialize_volumes_external_volumes(self): # Use composetest_ prefix so it gets garbage-collected in tearDown() - vol_name = 'composetest_{0:x}'.format(random.getrandbits(32)) - full_vol_name = 'composetest_{0}'.format(vol_name) + vol_name = 'composetest_{:x}'.format(random.getrandbits(32)) + full_vol_name = 'composetest_{}'.format(vol_name) self.client.create_volume(vol_name) config_data = build_config( services=[{ @@ -1640,7 +1640,7 @@ def test_initialize_volumes_external_volumes(self): self.client.inspect_volume(full_vol_name) def test_initialize_volumes_inexistent_external_volume(self): - vol_name = '{0:x}'.format(random.getrandbits(32)) + vol_name = '{:x}'.format(random.getrandbits(32)) config_data = build_config( services=[{ @@ -1658,13 +1658,13 @@ def test_initialize_volumes_inexistent_external_volume(self): ) with pytest.raises(config.ConfigurationError) as e: project.volumes.initialize() - assert 'Volume {0} declared as external'.format( + assert 'Volume {} declared as external'.format( vol_name ) in str(e.value) 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) + vol_name = '{:x}'.format(random.getrandbits(32)) + full_vol_name = 'composetest_{}'.format(vol_name) base_file = config.ConfigFile( 'base.yml', @@ -1673,7 +1673,7 @@ def test_project_up_named_volumes_in_binds(self): 'simple': { 'image': BUSYBOX_IMAGE_WITH_TAG, 'command': 'top', - 'volumes': ['{0}:/data'.format(vol_name)] + 'volumes': ['{}:/data'.format(vol_name)] }, }, 'volumes': { diff --git a/tests/integration/resilience_test.py b/tests/integration/resilience_test.py index 81cb2382fe2..2fbaafb2894 100644 --- a/tests/integration/resilience_test.py +++ b/tests/integration/resilience_test.py @@ -22,7 +22,7 @@ def setUp(self): def tearDown(self): del self.project del self.db - super(ResilienceTest, self).tearDown() + super().tearDown() def test_successful_recreate(self): self.project.up(strategy=ConvergenceStrategy.always) diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 76efba4402c..985c4d77ab6 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -248,7 +248,7 @@ def test_create_container_with_security_opt(self): service = self.create_service('db', security_opt=security_opt) container = service.create_container() service.start_container(container) - assert set(container.get('HostConfig.SecurityOpt')) == set([o.repr() for o in security_opt]) + assert set(container.get('HostConfig.SecurityOpt')) == {o.repr() for o in security_opt} @pytest.mark.xfail(True, reason='Not supported on most drivers') def test_create_container_with_storage_opt(self): @@ -290,7 +290,7 @@ def test_create_container_with_specified_volume(self): actual_host_path = container.get_mount(container_path)['Source'] assert path.basename(actual_host_path) == path.basename(host_path), ( - "Last component differs: %s, %s" % (actual_host_path, host_path) + "Last component differs: {}, {}".format(actual_host_path, host_path) ) def test_create_container_with_host_mount(self): @@ -844,11 +844,11 @@ def test_start_container_creates_links(self): db2 = create_and_start_container(db) create_and_start_container(web) - assert set(get_links(web.containers()[0])) == set([ + assert set(get_links(web.containers()[0])) == { db1.name, db1.name_without_project, db2.name, db2.name_without_project, 'db' - ]) + } @no_cluster('No legacy links support in Swarm') def test_start_container_creates_links_with_names(self): @@ -859,11 +859,11 @@ def test_start_container_creates_links_with_names(self): db2 = create_and_start_container(db) create_and_start_container(web) - assert set(get_links(web.containers()[0])) == set([ + assert set(get_links(web.containers()[0])) == { db1.name, db1.name_without_project, db2.name, db2.name_without_project, 'custom_link_name' - ]) + } @no_cluster('No legacy links support in Swarm') def test_start_container_with_external_links(self): @@ -879,11 +879,11 @@ def test_start_container_with_external_links(self): create_and_start_container(web) - assert set(get_links(web.containers()[0])) == set([ + assert set(get_links(web.containers()[0])) == { db_ctnrs[0].name, db_ctnrs[1].name, 'db_3' - ]) + } @no_cluster('No legacy links support in Swarm') def test_start_normal_container_does_not_create_links_to_its_own_service(self): @@ -893,7 +893,7 @@ def test_start_normal_container_does_not_create_links_to_its_own_service(self): create_and_start_container(db) c = create_and_start_container(db) - assert set(get_links(c)) == set([]) + assert set(get_links(c)) == set() @no_cluster('No legacy links support in Swarm') def test_start_one_off_container_creates_links_to_its_own_service(self): @@ -904,11 +904,11 @@ def test_start_one_off_container_creates_links_to_its_own_service(self): c = create_and_start_container(db, one_off=OneOffFilter.only) - assert set(get_links(c)) == set([ + assert set(get_links(c)) == { db1.name, db1.name_without_project, db2.name, db2.name_without_project, 'db' - ]) + } def test_start_container_builds_images(self): service = Service( @@ -1719,14 +1719,14 @@ def test_duplicate_containers(self): options = service._get_container_create_options({}, service._next_container_number()) original = Container.create(service.client, **options) - assert set(service.containers(stopped=True)) == set([original]) + assert set(service.containers(stopped=True)) == {original} assert set(service.duplicate_containers()) == set() options['name'] = 'temporary_container_name' duplicate = Container.create(service.client, **options) - assert set(service.containers(stopped=True)) == set([original, duplicate]) - assert set(service.duplicate_containers()) == set([duplicate]) + assert set(service.containers(stopped=True)) == {original, duplicate} + assert set(service.duplicate_containers()) == {duplicate} def converge(service, strategy=ConvergenceStrategy.changed): diff --git a/tests/integration/state_test.py b/tests/integration/state_test.py index 611d0cc9408..5258e310cfa 100644 --- a/tests/integration/state_test.py +++ b/tests/integration/state_test.py @@ -39,7 +39,7 @@ def make_project(self, cfg): class BasicProjectTest(ProjectTestCase): def setUp(self): - super(BasicProjectTest, self).setUp() + super().setUp() self.cfg = { 'db': {'image': BUSYBOX_IMAGE_WITH_TAG, 'command': 'top'}, @@ -95,7 +95,7 @@ def test_all_change(self): class ProjectWithDependenciesTest(ProjectTestCase): def setUp(self): - super(ProjectWithDependenciesTest, self).setUp() + super().setUp() self.cfg = { 'db': { @@ -116,7 +116,7 @@ def setUp(self): def test_up(self): containers = self.run_up(self.cfg) - assert set(c.service for c in containers) == set(['db', 'web', 'nginx']) + assert {c.service for c in containers} == {'db', 'web', 'nginx'} def test_change_leaf(self): old_containers = self.run_up(self.cfg) @@ -124,7 +124,7 @@ def test_change_leaf(self): self.cfg['nginx']['environment'] = {'NEW_VAR': '1'} new_containers = self.run_up(self.cfg) - assert set(c.service for c in new_containers - old_containers) == set(['nginx']) + assert {c.service for c in new_containers - old_containers} == {'nginx'} def test_change_middle(self): old_containers = self.run_up(self.cfg) @@ -132,7 +132,7 @@ def test_change_middle(self): self.cfg['web']['environment'] = {'NEW_VAR': '1'} new_containers = self.run_up(self.cfg) - assert set(c.service for c in new_containers - old_containers) == set(['web']) + assert {c.service for c in new_containers - old_containers} == {'web'} def test_change_middle_always_recreate_deps(self): old_containers = self.run_up(self.cfg, always_recreate_deps=True) @@ -140,7 +140,7 @@ def test_change_middle_always_recreate_deps(self): self.cfg['web']['environment'] = {'NEW_VAR': '1'} new_containers = self.run_up(self.cfg, always_recreate_deps=True) - assert set(c.service for c in new_containers - old_containers) == {'web', 'nginx'} + assert {c.service for c in new_containers - old_containers} == {'web', 'nginx'} def test_change_root(self): old_containers = self.run_up(self.cfg) @@ -148,7 +148,7 @@ def test_change_root(self): self.cfg['db']['environment'] = {'NEW_VAR': '1'} new_containers = self.run_up(self.cfg) - assert set(c.service for c in new_containers - old_containers) == set(['db']) + assert {c.service for c in new_containers - old_containers} == {'db'} def test_change_root_always_recreate_deps(self): old_containers = self.run_up(self.cfg, always_recreate_deps=True) @@ -156,7 +156,7 @@ def test_change_root_always_recreate_deps(self): self.cfg['db']['environment'] = {'NEW_VAR': '1'} new_containers = self.run_up(self.cfg, always_recreate_deps=True) - assert set(c.service for c in new_containers - old_containers) == { + assert {c.service for c in new_containers - old_containers} == { 'db', 'web', 'nginx' } @@ -213,7 +213,7 @@ def test_service_recreated_when_dependency_created(self): class ProjectWithDependsOnDependenciesTest(ProjectTestCase): def setUp(self): - super(ProjectWithDependsOnDependenciesTest, self).setUp() + super().setUp() self.cfg = { 'version': '2', @@ -238,7 +238,7 @@ def setUp(self): def test_up(self): local_cfg = copy.deepcopy(self.cfg) containers = self.run_up(local_cfg) - assert set(c.service for c in containers) == set(['db', 'web', 'nginx']) + assert {c.service for c in containers} == {'db', 'web', 'nginx'} def test_change_leaf(self): local_cfg = copy.deepcopy(self.cfg) @@ -247,7 +247,7 @@ def test_change_leaf(self): local_cfg['services']['nginx']['environment'] = {'NEW_VAR': '1'} new_containers = self.run_up(local_cfg) - assert set(c.service for c in new_containers - old_containers) == set(['nginx']) + assert {c.service for c in new_containers - old_containers} == {'nginx'} def test_change_middle(self): local_cfg = copy.deepcopy(self.cfg) @@ -256,7 +256,7 @@ def test_change_middle(self): local_cfg['services']['web']['environment'] = {'NEW_VAR': '1'} new_containers = self.run_up(local_cfg) - assert set(c.service for c in new_containers - old_containers) == set(['web']) + assert {c.service for c in new_containers - old_containers} == {'web'} def test_change_middle_always_recreate_deps(self): local_cfg = copy.deepcopy(self.cfg) @@ -265,7 +265,7 @@ def test_change_middle_always_recreate_deps(self): local_cfg['services']['web']['environment'] = {'NEW_VAR': '1'} new_containers = self.run_up(local_cfg, always_recreate_deps=True) - assert set(c.service for c in new_containers - old_containers) == set(['web', 'nginx']) + assert {c.service for c in new_containers - old_containers} == {'web', 'nginx'} def test_change_root(self): local_cfg = copy.deepcopy(self.cfg) @@ -274,7 +274,7 @@ def test_change_root(self): local_cfg['services']['db']['environment'] = {'NEW_VAR': '1'} new_containers = self.run_up(local_cfg) - assert set(c.service for c in new_containers - old_containers) == set(['db']) + assert {c.service for c in new_containers - old_containers} == {'db'} def test_change_root_always_recreate_deps(self): local_cfg = copy.deepcopy(self.cfg) @@ -283,7 +283,7 @@ def test_change_root_always_recreate_deps(self): local_cfg['services']['db']['environment'] = {'NEW_VAR': '1'} new_containers = self.run_up(local_cfg, always_recreate_deps=True) - assert set(c.service for c in new_containers - old_containers) == set(['db', 'web', 'nginx']) + assert {c.service for c in new_containers - old_containers} == {'db', 'web', 'nginx'} def test_change_root_no_recreate(self): local_cfg = copy.deepcopy(self.cfg) @@ -303,24 +303,24 @@ def test_service_removed_while_down(self): del next_cfg['services']['web']['depends_on'] containers = self.run_up(local_cfg) - assert set(c.service for c in containers) == set(['db', 'web', 'nginx']) + assert {c.service for c in containers} == {'db', 'web', 'nginx'} project = self.make_project(local_cfg) project.stop(timeout=1) next_containers = self.run_up(next_cfg) - assert set(c.service for c in next_containers) == set(['web', 'nginx']) + assert {c.service for c in next_containers} == {'web', 'nginx'} def test_service_removed_while_up(self): local_cfg = copy.deepcopy(self.cfg) containers = self.run_up(local_cfg) - assert set(c.service for c in containers) == set(['db', 'web', 'nginx']) + assert {c.service for c in containers} == {'db', 'web', 'nginx'} del local_cfg['services']['db'] del local_cfg['services']['web']['depends_on'] containers = self.run_up(local_cfg) - assert set(c.service for c in containers) == set(['web', 'nginx']) + assert {c.service for c in containers} == {'web', 'nginx'} def test_dependency_removed(self): local_cfg = copy.deepcopy(self.cfg) @@ -328,24 +328,24 @@ def test_dependency_removed(self): del next_cfg['services']['nginx']['depends_on'] containers = self.run_up(local_cfg, service_names=['nginx']) - assert set(c.service for c in containers) == set(['db', 'web', 'nginx']) + assert {c.service for c in containers} == {'db', 'web', 'nginx'} project = self.make_project(local_cfg) project.stop(timeout=1) next_containers = self.run_up(next_cfg, service_names=['nginx']) - assert set(c.service for c in next_containers if c.is_running) == set(['nginx']) + assert {c.service for c in next_containers if c.is_running} == {'nginx'} def test_dependency_added(self): local_cfg = copy.deepcopy(self.cfg) del local_cfg['services']['nginx']['depends_on'] containers = self.run_up(local_cfg, service_names=['nginx']) - assert set(c.service for c in containers) == set(['nginx']) + assert {c.service for c in containers} == {'nginx'} local_cfg['services']['nginx']['depends_on'] = ['db'] containers = self.run_up(local_cfg, service_names=['nginx']) - assert set(c.service for c in containers) == set(['nginx', 'db']) + assert {c.service for c in containers} == {'nginx', 'db'} class ServiceStateTest(DockerClientTestCase): diff --git a/tests/integration/volume_test.py b/tests/integration/volume_test.py index 2ede7bf28d2..0e7c78bc25a 100644 --- a/tests/integration/volume_test.py +++ b/tests/integration/volume_test.py @@ -18,7 +18,7 @@ def tearDown(self): except DockerException: pass del self.tmp_volumes - super(VolumeTest, self).tearDown() + super().tearDown() def create_volume(self, name, driver=None, opts=None, external=None, custom_name=False): if external: diff --git a/tests/unit/cli/command_test.py b/tests/unit/cli/command_test.py index 20702d97580..9d4db5b59a8 100644 --- a/tests/unit/cli/command_test.py +++ b/tests/unit/cli/command_test.py @@ -1,4 +1,3 @@ -# ~*~ encoding: utf-8 ~*~ import os import pytest @@ -9,7 +8,7 @@ from tests import mock -class TestGetConfigPathFromOptions(object): +class TestGetConfigPathFromOptions: def test_path_from_options(self): paths = ['one.yml', 'two.yml'] diff --git a/tests/unit/cli/docker_client_test.py b/tests/unit/cli/docker_client_test.py index 873c1ff3d82..941aed4f4b3 100644 --- a/tests/unit/cli/docker_client_test.py +++ b/tests/unit/cli/docker_client_test.py @@ -55,7 +55,7 @@ def test_custom_timeout_error(self): def test_user_agent(self): client = docker_client(os.environ) - expected = "docker-compose/{0} docker-py/{1} {2}/{3}".format( + expected = "docker-compose/{} docker-py/{} {}/{}".format( compose.__version__, docker.__version__, platform.system(), @@ -151,9 +151,9 @@ def test_assert_hostname_explicit_skip(self): 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), + '--tlscacert': '"{}"'.format(self.ca_cert), + '--tlscert': '"{}"'.format(self.client_cert), + '--tlskey': '"{}"'.format(self.key), '--tlsverify': True } result = tls_config_from_options(options) @@ -185,9 +185,9 @@ def test_tls_flags_override_environment(self): 'DOCKER_TLS_VERIFY': 'false' }) options = { - '--tlscacert': '"{0}"'.format(self.ca_cert), - '--tlscert': '"{0}"'.format(self.client_cert), - '--tlskey': '"{0}"'.format(self.key), + '--tlscacert': '"{}"'.format(self.ca_cert), + '--tlscert': '"{}"'.format(self.client_cert), + '--tlskey': '"{}"'.format(self.key), '--tlsverify': True } @@ -230,7 +230,7 @@ def test_tls_verify_default_cert_path(self): assert result.cert == (self.client_cert, self.key) -class TestGetTlsVersion(object): +class TestGetTlsVersion: def test_get_tls_version_default(self): environment = {} assert get_tls_version(environment) is None diff --git a/tests/unit/cli/errors_test.py b/tests/unit/cli/errors_test.py index f7359a48b1c..3b70ffe7b37 100644 --- a/tests/unit/cli/errors_test.py +++ b/tests/unit/cli/errors_test.py @@ -21,7 +21,7 @@ def patch_find_executable(side_effect): side_effect=side_effect) -class TestHandleConnectionErrors(object): +class TestHandleConnectionErrors: def test_generic_connection_error(self, mock_logging): with pytest.raises(errors.ConnectionError): @@ -43,7 +43,7 @@ def test_api_error_version_mismatch(self, mock_logging): def test_api_error_version_mismatch_unicode_explanation(self, mock_logging): with pytest.raises(errors.ConnectionError): with handle_connection_errors(mock.Mock(api_version='1.38')): - raise APIError(None, None, u"client is newer than server") + raise APIError(None, None, "client is newer than server") _, args, _ = mock_logging.error.mock_calls[0] assert "Docker Engine of version 18.06.0 or greater" in args[0] @@ -57,7 +57,7 @@ def test_api_error_version_other(self, mock_logging): mock_logging.error.assert_called_once_with(msg.decode('utf-8')) def test_api_error_version_other_unicode_explanation(self, mock_logging): - msg = u"Something broke!" + msg = "Something broke!" with pytest.raises(errors.ConnectionError): with handle_connection_errors(mock.Mock(api_version='1.22')): raise APIError(None, None, msg) diff --git a/tests/unit/cli/formatter_test.py b/tests/unit/cli/formatter_test.py index 07f5a8f50c5..08752a6226b 100644 --- a/tests/unit/cli/formatter_test.py +++ b/tests/unit/cli/formatter_test.py @@ -40,10 +40,10 @@ 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')) + assert output == '{}{}'.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')) + assert output == '{}{}'.format(expected, message.decode('utf-8')) diff --git a/tests/unit/cli/log_printer_test.py b/tests/unit/cli/log_printer_test.py index 38dd56c715a..aeeed31f32f 100644 --- a/tests/unit/cli/log_printer_test.py +++ b/tests/unit/cli/log_printer_test.py @@ -29,7 +29,7 @@ def mock_container(): return mock.Mock(spec=Container, name_without_project='web_1') -class TestLogPresenter(object): +class TestLogPresenter: def test_monochrome(self, mock_container): presenters = build_log_presenters(['foo', 'bar'], True) @@ -83,7 +83,7 @@ def test_build_no_log_generator(mock_container): assert "exited with code" not in output -class TestBuildLogGenerator(object): +class TestBuildLogGenerator: def test_no_log_stream(self, mock_container): mock_container.log_stream = None @@ -108,7 +108,7 @@ def test_with_log_stream(self, mock_container): assert next(generator) == "world" def test_unicode(self, output_stream): - glyph = u'\u2022\n' + glyph = '\u2022\n' mock_container.log_stream = iter([glyph.encode('utf-8')]) generator = build_log_generator(mock_container, {}) @@ -125,7 +125,7 @@ def mock_presenters(): return itertools.cycle([mock.Mock()]) -class TestWatchEvents(object): +class TestWatchEvents: def test_stop_event(self, thread_map, mock_presenters): event_stream = [{'action': 'stop', 'id': 'cid'}] @@ -167,7 +167,7 @@ def test_other_event(self, thread_map, mock_presenters): assert container_id not in thread_map -class TestConsumeQueue(object): +class TestConsumeQueue: def test_item_is_an_exception(self): diff --git a/tests/unit/cli/main_test.py b/tests/unit/cli/main_test.py index ac3df2920a7..d75b6bd4c12 100644 --- a/tests/unit/cli/main_test.py +++ b/tests/unit/cli/main_test.py @@ -22,7 +22,7 @@ def mock_container(service, number): container.Container, service=service, number=number, - name_without_project='{0}_{1}'.format(service, number)) + name_without_project='{}_{}'.format(service, number)) @pytest.fixture @@ -32,7 +32,7 @@ def logging_handler(): return logging.StreamHandler(stream=stream) -class TestCLIMainTestCase(object): +class TestCLIMainTestCase: def test_filter_attached_containers(self): containers = [ @@ -135,7 +135,7 @@ def test_get_docker_start_call(self): assert expected_docker_start_call == docker_start_call -class TestSetupConsoleHandlerTestCase(object): +class TestSetupConsoleHandlerTestCase: def test_with_tty_verbose(self, logging_handler): setup_console_handler(logging_handler, True) @@ -155,7 +155,7 @@ def test_with_not_a_tty(self, logging_handler): assert type(logging_handler.formatter) == logging.Formatter -class TestConvergeStrategyFromOptsTestCase(object): +class TestConvergeStrategyFromOptsTestCase: def test_invalid_opts(self): options = {'--force-recreate': True, '--no-recreate': True} @@ -189,7 +189,7 @@ def mock_find_executable(exe): @mock.patch('compose.cli.main.find_executable', mock_find_executable) -class TestCallDocker(object): +class TestCallDocker: def test_simple_no_options(self): with mock.patch('subprocess.call') as fake_call: call_docker(['ps'], {}, {}) diff --git a/tests/unit/cli_test.py b/tests/unit/cli_test.py index 2c53e00a819..f1a3e270ddc 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -1,4 +1,3 @@ -# encoding: utf-8 import os import shutil import tempfile diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index f562141dc8f..03e95f77a55 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -1,4 +1,3 @@ -# encoding: utf-8 import codecs import os import shutil @@ -3885,12 +3884,12 @@ def test_home_directory_with_driver_does_not_expand(self): assert d['volumes'] == ['~:/data'] def test_volume_path_with_non_ascii_directory(self): - volume = u'/Füü/data:/data' + volume = '/Füü/data:/data' container_path = config.resolve_volume_path(".", volume) assert container_path == volume -class MergePathMappingTest(object): +class MergePathMappingTest: config_name = "" def test_empty(self): @@ -3963,7 +3962,7 @@ def test_merge_build_or_image_override_with_other(self): assert config.merge_service_dicts({'image': 'redis'}, {'build': '.'}, V1) == {'build': '.'} -class MergeListsTest(object): +class MergeListsTest: config_name = "" base_config = [] override_config = [] @@ -4396,7 +4395,7 @@ def test_resolve_environment_from_env_file_with_empty_values(self): {'env_file': ['tests/fixtures/env/resolve.env']}, Environment.from_env_file(None) ) == { - 'FILE_DEF': u'bär', + 'FILE_DEF': 'bär', 'FILE_DEF_EMPTY': '', 'ENV_DEF': 'E3', 'NO_DEF': None @@ -5042,14 +5041,14 @@ def test_split_path_mapping_with_windows_path_in_container(self): container_path = 'c:\\scarletdevil\\data' expected_mapping = (container_path, (host_path, None)) - mapping = config.split_path_mapping('{0}:{1}'.format(host_path, container_path)) + mapping = config.split_path_mapping('{}:{}'.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, None)) - mapping = config.split_path_mapping('{0}:{1}'.format(host_path, container_path)) + mapping = config.split_path_mapping('{}:{}'.format(host_path, container_path)) assert mapping == expected_mapping diff --git a/tests/unit/config/environment_test.py b/tests/unit/config/environment_test.py index 50738546899..6a80ff12254 100644 --- a/tests/unit/config/environment_test.py +++ b/tests/unit/config/environment_test.py @@ -1,4 +1,3 @@ -# encoding: utf-8 import codecs import os import shutil diff --git a/tests/unit/config/interpolation_test.py b/tests/unit/config/interpolation_test.py index f8ff8c662e7..6f3533d0b03 100644 --- a/tests/unit/config/interpolation_test.py +++ b/tests/unit/config/interpolation_test.py @@ -1,4 +1,3 @@ -# encoding: utf-8 import pytest from compose.config.environment import Environment @@ -439,7 +438,7 @@ def test_unbraced_separators(defaults_interpolator): def test_interpolate_unicode_values(): variable_mapping = { - 'FOO': '十六夜 咲夜'.encode('utf-8'), + 'FOO': '十六夜 咲夜'.encode(), 'BAR': '十六夜 咲夜' } interpol = Interpolator(TemplateWithDefaults, variable_mapping).interpolate diff --git a/tests/unit/config/sort_services_test.py b/tests/unit/config/sort_services_test.py index 430fed6a620..508c4bba190 100644 --- a/tests/unit/config/sort_services_test.py +++ b/tests/unit/config/sort_services_test.py @@ -5,7 +5,7 @@ from compose.config.types import VolumeFromSpec -class TestSortService(object): +class TestSortService: def test_sort_service_dicts_1(self): services = [ { diff --git a/tests/unit/config/types_test.py b/tests/unit/config/types_test.py index 23b9b6767c1..e5fcde1a621 100644 --- a/tests/unit/config/types_test.py +++ b/tests/unit/config/types_test.py @@ -39,7 +39,7 @@ def test_parse_extra_hosts_dict(): } -class TestServicePort(object): +class TestServicePort: def test_parse_dict(self): data = { 'target': 8000, @@ -129,7 +129,7 @@ def test_parse_invalid_publish_range(self): ServicePort.parse(port_def) -class TestVolumeSpec(object): +class TestVolumeSpec: def test_parse_volume_spec_only_one_path(self): spec = VolumeSpec.parse('/the/volume') @@ -216,7 +216,7 @@ def test_parse_volume_windows_mixed_notations_native(self): ) -class TestVolumesFromSpec(object): +class TestVolumesFromSpec: services = ['servicea', 'serviceb'] diff --git a/tests/unit/progress_stream_test.py b/tests/unit/progress_stream_test.py index b885b239b92..288c9b6e448 100644 --- a/tests/unit/progress_stream_test.py +++ b/tests/unit/progress_stream_test.py @@ -1,5 +1,3 @@ -# ~*~ encoding: utf-8 ~*~ -import io import os import random import shutil @@ -75,7 +73,7 @@ def test_mismatched_encoding_stream_write(self): def mktempfile(encoding): fname = os.path.join(tmpdir, hex(random.getrandbits(128))[2:-1]) - return io.open(fname, mode='w+', encoding=encoding) + return open(fname, mode='w+', encoding=encoding) text = '就吃饭' with mktempfile(encoding='utf-8') as tf: diff --git a/tests/unit/project_test.py b/tests/unit/project_test.py index b889e5e2c3a..01f0b11efc3 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -1,4 +1,3 @@ -# encoding: utf-8 import datetime import os import tempfile @@ -739,7 +738,7 @@ def test_no_warning_with_no_swarm_info(self): assert fake_log.warn.call_count == 0 def test_no_such_service_unicode(self): - assert NoSuchService('十六夜 咲夜'.encode('utf-8')).msg == 'No such service: 十六夜 咲夜' + assert NoSuchService('十六夜 咲夜'.encode()).msg == 'No such service: 十六夜 咲夜' assert NoSuchService('十六夜 咲夜').msg == 'No such service: 十六夜 咲夜' def test_project_platform_value(self): diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index d4e7f3c5d94..72cb1d7f9ed 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -63,9 +63,9 @@ def test_containers_with_containers(self): assert [c.id for c in service.containers()] == list(range(3)) expected_labels = [ - '{0}=myproject'.format(LABEL_PROJECT), - '{0}=db'.format(LABEL_SERVICE), - '{0}=False'.format(LABEL_ONE_OFF), + '{}=myproject'.format(LABEL_PROJECT), + '{}=db'.format(LABEL_SERVICE), + '{}=False'.format(LABEL_ONE_OFF), ] self.mock_client.containers.assert_called_once_with( diff --git a/tests/unit/split_buffer_test.py b/tests/unit/split_buffer_test.py index f1974c83144..d6b5b884c87 100644 --- a/tests/unit/split_buffer_test.py +++ b/tests/unit/split_buffer_test.py @@ -36,7 +36,7 @@ def reader(): self.assert_produces(reader, ['abc\n', 'd']) def test_preserves_unicode_sequences_within_lines(self): - string = u"a\u2022c\n" + string = "a\u2022c\n" def reader(): yield string.encode('utf-8') diff --git a/tests/unit/utils_test.py b/tests/unit/utils_test.py index f1febc13e69..3052e4d8644 100644 --- a/tests/unit/utils_test.py +++ b/tests/unit/utils_test.py @@ -1,8 +1,7 @@ -# encoding: utf-8 from compose import utils -class TestJsonSplitter(object): +class TestJsonSplitter: def test_json_splitter_no_object(self): data = '{"foo": "bar' @@ -17,7 +16,7 @@ def test_json_splitter_leading_whitespace(self): assert utils.json_splitter(data) == ({'foo': 'bar'}, '{"next": "obj"}') -class TestStreamAsText(object): +class TestStreamAsText: def test_stream_with_non_utf_unicode_character(self): stream = [b'\xed\xf3\xf3'] @@ -25,12 +24,12 @@ def test_stream_with_non_utf_unicode_character(self): assert output == '���' def test_stream_with_utf_character(self): - stream = ['ěĝ'.encode('utf-8')] + stream = ['ěĝ'.encode()] output, = utils.stream_as_text(stream) assert output == 'ěĝ' -class TestJsonStream(object): +class TestJsonStream: def test_with_falsy_entries(self): stream = [ @@ -59,7 +58,7 @@ def test_with_leading_whitespace(self): ] -class TestParseBytes(object): +class TestParseBytes: def test_parse_bytes(self): assert utils.parse_bytes('123kb') == 123 * 1024 assert utils.parse_bytes(123) == 123 @@ -67,7 +66,7 @@ def test_parse_bytes(self): assert utils.parse_bytes('123') == 123 -class TestMoreItertools(object): +class TestMoreItertools: def test_unique_everseen(self): unique = utils.unique_everseen assert list(unique([2, 1, 2, 1])) == [2, 1] diff --git a/tests/unit/volume_test.py b/tests/unit/volume_test.py index 8b2f6cfeef8..0dfbfcd40b7 100644 --- a/tests/unit/volume_test.py +++ b/tests/unit/volume_test.py @@ -10,7 +10,7 @@ def mock_client(): return mock.create_autospec(docker.APIClient) -class TestVolume(object): +class TestVolume: def test_remove_local_volume(self, mock_client): vol = volume.Volume(mock_client, 'foo', 'project') From 46624cee75c76ef58b648ecf43727e1f3ce23719 Mon Sep 17 00:00:00 2001 From: alexrecuenco Date: Tue, 11 Aug 2020 17:38:22 +0700 Subject: [PATCH 3986/4072] Suggestions by @ulyssessouza Removed unused versions, (we only support python3.4 onwards) Signed-off-by: alexrecuenco --- setup.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/setup.py b/setup.py index 6041fce15be..590e0ebdc79 100644 --- a/setup.py +++ b/setup.py @@ -49,10 +49,7 @@ def find_version(*file_paths): tests_require.append('mock >= 1.0.1, < 4') extras_require = { - ':python_version < "3.4"': ['enum34 >= 1.0.4, < 2'], ':python_version < "3.5"': ['backports.ssl_match_hostname >= 3.5, < 4'], - ':python_version < "3.3"': ['backports.shutil_get_terminal_size == 1.0.0', - 'ipaddress >= 1.0.16, < 2'], ':sys_platform == "win32"': ['colorama >= 0.4, < 1'], 'socks': ['PySocks >= 1.5.6, != 1.5.7, < 2'], 'tests': tests_require, @@ -92,7 +89,7 @@ def find_version(*file_paths): install_requires=install_requires, extras_require=extras_require, tests_require=tests_require, - python_requires='>=3.0, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*', + python_requires='>=3.4', entry_points={ 'console_scripts': ['docker-compose=compose.cli.main:main'], }, From f2a43d755e351a415fe426ad92c9ee75387ef51d Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2020 15:04:11 +0200 Subject: [PATCH 3987/4072] Bump virtualenv from 20.0.29 to 20.0.30 (#7657) * Bump virtualenv from 20.0.29 to 20.0.30 Bumps [virtualenv](https://github.com/pypa/virtualenv) from 20.0.29 to 20.0.30. - [Release notes](https://github.com/pypa/virtualenv/releases) - [Changelog](https://github.com/pypa/virtualenv/blob/master/docs/changelog.rst) - [Commits](https://github.com/pypa/virtualenv/compare/20.0.29...20.0.30) Signed-off-by: dependabot-preview[bot] * Bump virtualenv version in all files Signed-off-by: aiordache Co-authored-by: dependabot-preview[bot] <27856297+dependabot-preview[bot]@users.noreply.github.com> Co-authored-by: aiordache Co-authored-by: Anca Iordache --- Dockerfile | 2 +- requirements-indirect.txt | 2 +- script/build/windows.ps1 | 2 +- script/setup/osx | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index fb6b41487ce..7c14bc1d6f4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -46,7 +46,7 @@ ENTRYPOINT ["sh", "/usr/local/bin/docker-compose-entrypoint.sh"] COPY --from=docker-cli /usr/local/bin/docker /usr/local/bin/docker WORKDIR /code/ # FIXME(chris-crone): virtualenv 16.3.0 breaks build, force 16.2.0 until fixed -RUN pip install virtualenv==20.0.29 +RUN pip install virtualenv==20.0.30 RUN pip install tox==3.19.0 COPY requirements-indirect.txt . diff --git a/requirements-indirect.txt b/requirements-indirect.txt index 1f028ca3ef2..b25f3c81123 100644 --- a/requirements-indirect.txt +++ b/requirements-indirect.txt @@ -24,5 +24,5 @@ smmap==3.0.4 smmap2==3.0.1 toml==0.10.1 tox==3.19.0 -virtualenv==20.0.29 +virtualenv==20.0.30 wcwidth==0.2.5 diff --git a/script/build/windows.ps1 b/script/build/windows.ps1 index b01feb7509b..2778cc88447 100644 --- a/script/build/windows.ps1 +++ b/script/build/windows.ps1 @@ -16,7 +16,7 @@ # # 4. In Powershell, run the following commands: # -# $ pip install 'virtualenv==20.0.29' +# $ pip install 'virtualenv==20.0.30' # $ Set-ExecutionPolicy -Scope CurrentUser RemoteSigned # # 5. Clone the repository: diff --git a/script/setup/osx b/script/setup/osx index 25b13ded216..44ec4adcc94 100755 --- a/script/setup/osx +++ b/script/setup/osx @@ -36,7 +36,7 @@ if ! [ -x "$(command -v python3)" ]; then brew install python3 fi if ! [ -x "$(command -v virtualenv)" ]; then - pip3 install virtualenv==20.0.29 + pip3 install virtualenv==20.0.30 fi # From 454f5125d93d4c1928ba660aae04431319315703 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2020 13:08:20 +0000 Subject: [PATCH 3988/4072] Bump cffi from 1.14.0 to 1.14.1 Bumps [cffi](https://github.com/python-cffi/release-doc) from 1.14.0 to 1.14.1. - [Release notes](https://github.com/python-cffi/release-doc/releases) - [Commits](https://github.com/python-cffi/release-doc/commits) Signed-off-by: dependabot-preview[bot] --- requirements-indirect.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-indirect.txt b/requirements-indirect.txt index 490701bcf35..ef302dcdffb 100644 --- a/requirements-indirect.txt +++ b/requirements-indirect.txt @@ -2,7 +2,7 @@ altgraph==0.17 appdirs==1.4.4 attrs==19.3.0 bcrypt==3.1.7 -cffi==1.14.0 +cffi==1.14.1 cryptography==3.0 distlib==0.3.1 entrypoints==0.3 From 7009370bf2054d67aafa4bd124014b0cae7e2cbf Mon Sep 17 00:00:00 2001 From: ulyssessouza Date: Tue, 11 Aug 2020 16:46:27 +0200 Subject: [PATCH 3989/4072] Recover ./script/release/release.py Signed-off-by: ulyssessouza --- script/release/release.py | 126 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 script/release/release.py diff --git a/script/release/release.py b/script/release/release.py new file mode 100644 index 00000000000..f53d1f3c181 --- /dev/null +++ b/script/release/release.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +from __future__ import absolute_import +from __future__ import unicode_literals + +import re + +import click +from git import Repo +from utils import update_init_py_version +from utils import update_run_sh_version +from utils import yesno + +VALID_VERSION_PATTERN = re.compile(r"^\d+\.\d+\.\d+(-rc\d+)?$") + + +class Version(str): + def matching_groups(self): + match = VALID_VERSION_PATTERN.match(self) + if not match: + return False + + return match.groups() + + def is_ga_version(self): + groups = self.matching_groups() + if not groups: + return False + + rc_suffix = groups[1] + return not rc_suffix + + def validate(self): + return len(self.matching_groups()) > 0 + + def branch_name(self): + if not self.validate(): + return None + + rc_part = self.matching_groups()[0] + ver = self + if rc_part: + ver = ver[:-len(rc_part)] + + tokens = ver.split(".") + tokens[-1] = 'x' + + return ".".join(tokens) + + +def create_bump_commit(repository, version): + print('Creating bump commit...') + repository.commit('-a', '-s', '-m "Bump {}"'.format(version), '--no-verify') + + +def validate_environment(version, repository): + if not version.validate(): + print('Version "{}" has an invalid format. This should follow D+.D+.D+(-rcD+). ' + 'Like: 1.26.0 or 1.26.0-rc1'.format(version)) + return False + + expected_branch = version.branch_name() + if str(repository.active_branch) != expected_branch: + print('Cannot tag in this branch with version "{}". ' + 'Please checkout "{}" to tag'.format(version, version.branch_name())) + return False + return True + + +@click.group() +def cli(): + pass + + +@cli.command() +@click.argument('version') +def tag(version): + """ + Updates the version related files and tag + """ + repo = Repo(".") + version = Version(version) + if not validate_environment(version, repo): + return + + update_init_py_version(version) + update_run_sh_version(version) + + input('Please add the release notes to the CHANGELOG.md file, then press Enter to continue.') + proceed = False + while not proceed: + print(repo.git.diff()) + proceed = yesno('Are these changes ok? y/N ', default=False) + + if repo.git.diff(): + create_bump_commit(repo.git, version) + else: + print('No changes to commit. Exiting...') + return + + repo.create_tag(version) + + print('Please, check the changes. If everything is OK, you just need to push with:\n' + '$ git push --tags upstream {}'.format(version.branch_name())) + + +@cli.command() +@click.argument('version') +def push_latest(version): + """ + TODO Pushes the latest tag pointing to a certain GA version + """ + raise NotImplementedError + + +@cli.command() +@click.argument('version') +def ghtemplate(version): + """ + TODO Generates the github release page content + """ + version = Version(version) + raise NotImplementedError + + +if __name__ == '__main__': + cli() From c90ba119f59e2c87b09f1938abba42d68b8501dc Mon Sep 17 00:00:00 2001 From: aiordache Date: Tue, 11 Aug 2020 17:22:06 +0200 Subject: [PATCH 3990/4072] Fix tox failures Signed-off-by: aiordache --- compose/config/interpolation.py | 18 +++++++++--------- script/release/release.py | 3 --- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/compose/config/interpolation.py b/compose/config/interpolation.py index 71e78bbaa64..72cb484b151 100644 --- a/compose/config/interpolation.py +++ b/compose/config/interpolation.py @@ -86,17 +86,17 @@ def append(config_path, key): class TemplateWithDefaults(Template): pattern = r""" - %(delim)s(?: - (?P%(delim)s) | - (?P%(id)s) | - {(?P%(bid)s)} | + {delim}(?: + (?P{delim}) | + (?P{id}) | + {{(?P{bid})}} | (?P) ) - """ % { - 'delim': re.escape('$'), - 'id': r'[_a-z][_a-z0-9]*', - 'bid': r'[_a-z][_a-z0-9]*(?:(?P:?[-?])[^}]*)?', - } + """.format( + delim=re.escape('$'), + id=r'[_a-z][_a-z0-9]*', + bid=r'[_a-z][_a-z0-9]*(?:(?P:?[-?])[^}]*)?', + ) @staticmethod def process_braced_group(braced, sep, mapping): diff --git a/script/release/release.py b/script/release/release.py index f53d1f3c181..c8e5e7f767d 100644 --- a/script/release/release.py +++ b/script/release/release.py @@ -1,7 +1,4 @@ #!/usr/bin/env python3 -from __future__ import absolute_import -from __future__ import unicode_literals - import re import click From f3928aac7e9c2c580c13aef1fdb5a995d8d9ff4b Mon Sep 17 00:00:00 2001 From: aiordache Date: Tue, 11 Aug 2020 19:12:15 +0200 Subject: [PATCH 3991/4072] Set agent in Release.Jenkinsfile Signed-off-by: aiordache --- Release.Jenkinsfile | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Release.Jenkinsfile b/Release.Jenkinsfile index be3d935d935..52574b4c715 100644 --- a/Release.Jenkinsfile +++ b/Release.Jenkinsfile @@ -37,6 +37,9 @@ pipeline { } } stage('Test') { + agent { + label 'linux && docker && ubuntu-2004' + } steps { // TODO use declarative 1.5.0 `matrix` once available on CI script { From 096d938bacbc5797a6ca053f43122e1ad3c2e2b5 Mon Sep 17 00:00:00 2001 From: aiordache Date: Tue, 11 Aug 2020 20:48:37 +0200 Subject: [PATCH 3992/4072] Update jenkins node filter Signed-off-by: aiordache --- Release.Jenkinsfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Release.Jenkinsfile b/Release.Jenkinsfile index 52574b4c715..1594d2f275d 100644 --- a/Release.Jenkinsfile +++ b/Release.Jenkinsfile @@ -244,7 +244,7 @@ def buildImage(baseImage) { def runTests(dockerVersion, pythonVersion, baseImage) { return { stage("python=${pythonVersion} docker=${dockerVersion} ${baseImage}") { - node("linux") { + node("linux && docker && ubuntu-2004") { def scmvar = checkout(scm) def imageName = "dockerbuildbot/compose:${baseImage}-${scmvar.GIT_COMMIT}" def storageDriver = sh(script: "docker info -f \'{{.Driver}}\'", returnStdout: true).trim() From 35f1334cbd34074c271d0e3b1932a9a3e860ac66 Mon Sep 17 00:00:00 2001 From: Ryosuke TOKUAMI Date: Sun, 9 Aug 2020 20:03:24 +0900 Subject: [PATCH 3993/4072] Use docker cli on run when the envvar passed. Make docker-compose run pass the the cli option to project.up to build images using docker cli considering COMPOSE_DOCKER_CLI_BUILD environment variable. Signed-off-by: Ryosuke TOKUAMI --- compose/cli/main.py | 2 ++ tests/unit/cli_test.py | 49 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/compose/cli/main.py b/compose/cli/main.py index 8809318f76e..b07b2b7285c 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -1298,6 +1298,7 @@ def build_one_off_container_options(options, detach, command): def run_one_off_container(container_options, project, service, options, toplevel_options, toplevel_environment): + native_builder = toplevel_environment.get_boolean('COMPOSE_DOCKER_CLI_BUILD') detach = options.get('--detach') use_network_aliases = options.get('--use-aliases') containers = project.up( @@ -1306,6 +1307,7 @@ def run_one_off_container(container_options, project, service, options, toplevel strategy=ConvergenceStrategy.never, detached=detach, rescale=False, + cli=native_builder, one_off=True, override_options=container_options, ) diff --git a/tests/unit/cli_test.py b/tests/unit/cli_test.py index f1a3e270ddc..bcd8123d4fe 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -220,6 +220,55 @@ def test_run_service_with_restart_always(self, mock_container_create): assert not mock_client.create_host_config.call_args[1].get('restart_policy') + @mock.patch('compose.project.Project.up') + @mock.patch.dict(os.environ) + def test_run_up_with_docker_cli_build(self, mock_project_up): + os.environ['COMPOSE_DOCKER_CLI_BUILD'] = '1' + mock_client = mock.create_autospec(docker.APIClient) + mock_client.api_version = DEFAULT_DOCKER_API_VERSION + mock_client._general_configs = {} + container = Container(mock_client, { + 'Id': '37b35e0ba80d91009d37e16f249b32b84f72bda269985578ed6c75a0a13fcaa8', + 'Name': 'composetest_service_37b35', + 'Config': { + 'Labels': { + LABEL_SERVICE: 'service', + } + }, + }, has_been_inspected=True) + mock_project_up.return_value = [container] + + project = Project.from_config( + name='composetest', + config_data=build_config({ + 'service': {'image': 'busybox'} + }), + client=mock_client, + ) + + command = TopLevelCommand(project) + command.run({ + 'SERVICE': 'service', + 'COMMAND': None, + '-e': [], + '--label': [], + '--user': None, + '--no-deps': None, + '--detach': True, + '-T': None, + '--entrypoint': None, + '--service-ports': None, + '--use-aliases': None, + '--publish': [], + '--volume': [], + '--rm': None, + '--name': None, + '--workdir': None, + }) + + _, _, call_kwargs = mock_project_up.mock_calls[0] + assert call_kwargs.get('cli') + def test_command_manual_and_service_ports_together(self): project = Project.from_config( name='composetest', From d773719060967929e1300fb440c9c236e2a15060 Mon Sep 17 00:00:00 2001 From: aiordache Date: Mon, 17 Aug 2020 11:59:23 +0200 Subject: [PATCH 3994/4072] set scale default to 1 on deploy Signed-off-by: aiordache --- compose/project.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/compose/project.py b/compose/project.py index 0ae5721bc6d..a5bb39b932f 100644 --- a/compose/project.py +++ b/compose/project.py @@ -318,6 +318,8 @@ def get_service_scale(self, service_dict): ) if replicas: scale = replicas + if scale is None: + return 1 # deploy may contain placement constraints introduced in v3.8 max_replicas = deploy_dict.get('placement', {}).get( 'max_replicas_per_node', From 2b4d409ac3da2096c43284e262f2b0c1b66c488b Mon Sep 17 00:00:00 2001 From: aiordache Date: Mon, 17 Aug 2020 18:58:12 +0200 Subject: [PATCH 3995/4072] Update schema and fix memory limit parsing Signed-off-by: aiordache --- compose/config/config_schema_compose_spec.json | 7 ++++--- compose/config/interpolation.py | 1 + requirements.txt | 3 ++- tests/acceptance/cli_test.py | 4 ++-- tests/fixtures/v3-full/docker-compose.yml | 4 ++-- 5 files changed, 11 insertions(+), 8 deletions(-) diff --git a/compose/config/config_schema_compose_spec.json b/compose/config/config_schema_compose_spec.json index 3b26ba82560..8af7faa63b7 100644 --- a/compose/config/config_schema_compose_spec.json +++ b/compose/config/config_schema_compose_spec.json @@ -258,7 +258,7 @@ "patternProperties": {"^x-": {}} }, "mac_address": {"type": "string"}, - "mem_limit": {"type": ["number", "string"]}, + "mem_limit": {"type": "string"}, "mem_reservation": {"type": ["string", "integer"]}, "mem_swappiness": {"type": "integer"}, "memswap_limit": {"type": ["number", "string"]}, @@ -503,7 +503,7 @@ "limits": { "type": "object", "properties": { - "cpus": {"type": "string"}, + "cpus": {"type": "number", "minimum": 0}, "memory": {"type": "string"} }, "additionalProperties": false, @@ -512,7 +512,7 @@ "reservations": { "type": "object", "properties": { - "cpus": {"type": "string"}, + "cpus": {"type": "number", "minimum": 0}, "memory": {"type": "string"}, "generic_resources": {"$ref": "#/definitions/generic_resources"} }, @@ -633,6 +633,7 @@ "patternProperties": {"^x-": {}} }, "internal": {"type": "boolean"}, + "enable_ipv6": {"type": "boolean"}, "attachable": {"type": "boolean"}, "labels": {"$ref": "#/definitions/list_or_dict"} }, diff --git a/compose/config/interpolation.py b/compose/config/interpolation.py index 72cb484b151..832344e2c2b 100644 --- a/compose/config/interpolation.py +++ b/compose/config/interpolation.py @@ -241,6 +241,7 @@ class ConversionMap: service_path('healthcheck', 'disable'): to_boolean, service_path('deploy', 'labels', PATH_JOKER): to_str, service_path('deploy', 'replicas'): to_int, + service_path('deploy', 'resources', 'limits', "cpus"): to_float, service_path('deploy', 'update_config', 'parallelism'): to_int, service_path('deploy', 'update_config', 'max_failure_ratio'): to_float, service_path('deploy', 'rollback_config', 'parallelism'): to_int, diff --git a/requirements.txt b/requirements.txt index b9954286103..28ecf8f6e5c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,10 +4,11 @@ certifi==2020.6.20 chardet==3.0.4 colorama==0.4.3; sys_platform == 'win32' distro==1.5.0 -docker==4.3.0 docker-pycreds==0.4.0 dockerpty==0.4.1 docopt==0.6.2 +# temporary fix for the mem_limit float parsing +git+git://github.com/docker/docker-py@2c522fb362247a692c0493f0b47a33988eb2f3e3#egg=docker idna==2.10 ipaddress==1.0.23 jsonschema==3.2.0 diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 4dd93521003..ced0a27333a 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -525,11 +525,11 @@ def test_config_v3(self): }, 'resources': { 'limits': { - 'cpus': '0.05', + 'cpus': 0.05, 'memory': '50M', }, 'reservations': { - 'cpus': '0.01', + 'cpus': 0.01, 'memory': '20M', }, }, diff --git a/tests/fixtures/v3-full/docker-compose.yml b/tests/fixtures/v3-full/docker-compose.yml index 3a7ac25c905..0a51565826e 100644 --- a/tests/fixtures/v3-full/docker-compose.yml +++ b/tests/fixtures/v3-full/docker-compose.yml @@ -14,10 +14,10 @@ services: max_failure_ratio: 0.3 resources: limits: - cpus: '0.05' + cpus: 0.05 memory: 50M reservations: - cpus: '0.01' + cpus: 0.01 memory: 20M restart_policy: condition: on-failure From 6cebde77fbd7e7779f5d5d72e9e9f06382020772 Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Wed, 19 Aug 2020 22:32:25 +0200 Subject: [PATCH 3996/4072] Parse network-mode on CLI build Signed-off-by: Ulysses Souza --- compose/service.py | 1 + 1 file changed, 1 insertion(+) diff --git a/compose/service.py b/compose/service.py index 5980310b300..471f9e199f3 100644 --- a/compose/service.py +++ b/compose/service.py @@ -1845,6 +1845,7 @@ def build(self, path, tag=None, quiet=False, fileobj=None, command_builder.add_flag("--force-rm", forcerm) command_builder.add_params("--label", labels) command_builder.add_arg("--memory", container_limits.get("memory")) + command_builder.add_arg("--network", network_mode) command_builder.add_flag("--no-cache", nocache) command_builder.add_arg("--progress", self._progress) command_builder.add_flag("--pull", pull) From 81ce72c95244cebd3711d9b81899e600803d6816 Mon Sep 17 00:00:00 2001 From: aiordache Date: Wed, 19 Aug 2020 20:11:42 +0200 Subject: [PATCH 3997/4072] Use docker-py's default api version for engine queries Bump docker-py version to 4.3.1 Signed-off-by: aiordache --- compose/cli/command.py | 5 +---- compose/config/config.py | 23 ----------------------- requirements.txt | 3 +-- 3 files changed, 2 insertions(+), 29 deletions(-) diff --git a/compose/cli/command.py b/compose/cli/command.py index 8882727ba79..d471e78df1a 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -6,7 +6,6 @@ from .. import config from .. import parallel from ..config.environment import Environment -from ..const import API_VERSIONS from ..const import LABEL_CONFIG_FILES from ..const import LABEL_ENVIRONMENT_FILE from ..const import LABEL_WORKING_DIR @@ -127,9 +126,7 @@ def get_project(project_dir, config_path=None, project_name=None, verbose=False, ) config_data = config.load(config_details, interpolate) - api_version = environment.get( - 'COMPOSE_API_VERSION', - API_VERSIONS[config_data.version]) + api_version = environment.get('COMPOSE_API_VERSION') client = get_client( verbose=verbose, version=api_version, context=context, environment=environment diff --git a/compose/config/config.py b/compose/config/config.py index 8f579021543..881f5d68382 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -367,27 +367,6 @@ def find_candidates_in_parent_dirs(filenames, path): return (candidates, path) -def check_swarm_only_config(service_dicts): - warning_template = ( - "Some services ({services}) use the '{key}' key, which will be ignored. " - "Compose does not support '{key}' configuration - use " - "`docker stack deploy` to deploy to a swarm." - ) - - def check_swarm_only_key(service_dicts, key): - services = [s for s in service_dicts if s.get(key)] - if services: - log.warning( - warning_template.format( - services=", ".join(sorted(s['name'] for s in services)), - key=key - ) - ) - - check_swarm_only_key(service_dicts, 'deploy') - check_swarm_only_key(service_dicts, 'configs') - - def load(config_details, interpolate=True): """Load the configuration from a working directory and a list of configuration files. Files are loaded in order, and merged on top @@ -424,8 +403,6 @@ def load(config_details, interpolate=True): for service_dict in service_dicts: match_named_volumes(service_dict, volumes) - check_swarm_only_config(service_dicts) - version = main_file.version return Config(version, service_dicts, volumes, networks, secrets, configs) diff --git a/requirements.txt b/requirements.txt index 28ecf8f6e5c..7de88eefc37 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,11 +4,10 @@ certifi==2020.6.20 chardet==3.0.4 colorama==0.4.3; sys_platform == 'win32' distro==1.5.0 +docker==4.3.1 docker-pycreds==0.4.0 dockerpty==0.4.1 docopt==0.6.2 -# temporary fix for the mem_limit float parsing -git+git://github.com/docker/docker-py@2c522fb362247a692c0493f0b47a33988eb2f3e3#egg=docker idna==2.10 ipaddress==1.0.23 jsonschema==3.2.0 From 5bbd67016651704fc3d1597a7c2e358f2f4e59c2 Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Fri, 21 Aug 2020 16:42:00 +0200 Subject: [PATCH 3998/4072] Update tests to use the version on docker client Signed-off-by: Ulysses Souza --- tests/unit/cli/docker_client_test.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/unit/cli/docker_client_test.py b/tests/unit/cli/docker_client_test.py index 941aed4f4b3..74e0f9c8f26 100644 --- a/tests/unit/cli/docker_client_test.py +++ b/tests/unit/cli/docker_client_test.py @@ -6,6 +6,7 @@ import pytest import compose +from compose import const from compose.cli import errors from compose.cli.docker_client import docker_client from compose.cli.docker_client import get_tls_version @@ -23,18 +24,18 @@ def test_docker_client_no_home(self): del os.environ['HOME'] except KeyError: pass - docker_client(os.environ) + docker_client(os.environ, version=const.API_VERSIONS[const.COMPOSE_SPEC]) @mock.patch.dict(os.environ) def test_docker_client_with_custom_timeout(self): os.environ['COMPOSE_HTTP_TIMEOUT'] = '123' - client = docker_client(os.environ) + client = docker_client(os.environ, version=const.API_VERSIONS[const.COMPOSE_SPEC]) 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) + client = docker_client(os.environ, version=const.API_VERSIONS[const.COMPOSE_SPEC]) with mock.patch('compose.cli.errors.log') as fake_log: with pytest.raises(errors.ConnectionError): @@ -54,7 +55,7 @@ def test_custom_timeout_error(self): assert '123' in fake_log.error.call_args[0][0] def test_user_agent(self): - client = docker_client(os.environ) + client = docker_client(os.environ, version=const.API_VERSIONS[const.COMPOSE_SPEC]) expected = "docker-compose/{} docker-py/{} {}/{}".format( compose.__version__, docker.__version__, From c447ff8c2ce00c3d5b84a5c99877aab879a846a6 Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Fri, 21 Aug 2020 18:11:27 +0200 Subject: [PATCH 3999/4072] Update API version for docker client Signed-off-by: Ulysses Souza --- tests/unit/cli/docker_client_test.py | 9 +++++---- tests/unit/cli_test.py | 5 ++++- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/tests/unit/cli/docker_client_test.py b/tests/unit/cli/docker_client_test.py index 74e0f9c8f26..5413effdace 100644 --- a/tests/unit/cli/docker_client_test.py +++ b/tests/unit/cli/docker_client_test.py @@ -4,6 +4,7 @@ import docker import pytest +from docker.constants import DEFAULT_DOCKER_API_VERSION import compose from compose import const @@ -24,18 +25,18 @@ def test_docker_client_no_home(self): del os.environ['HOME'] except KeyError: pass - docker_client(os.environ, version=const.API_VERSIONS[const.COMPOSE_SPEC]) + docker_client(os.environ, version=DEFAULT_DOCKER_API_VERSION) @mock.patch.dict(os.environ) def test_docker_client_with_custom_timeout(self): os.environ['COMPOSE_HTTP_TIMEOUT'] = '123' - client = docker_client(os.environ, version=const.API_VERSIONS[const.COMPOSE_SPEC]) + client = docker_client(os.environ, version=DEFAULT_DOCKER_API_VERSION) 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, version=const.API_VERSIONS[const.COMPOSE_SPEC]) + client = docker_client(os.environ, version=DEFAULT_DOCKER_API_VERSION) with mock.patch('compose.cli.errors.log') as fake_log: with pytest.raises(errors.ConnectionError): @@ -55,7 +56,7 @@ def test_custom_timeout_error(self): assert '123' in fake_log.error.call_args[0][0] def test_user_agent(self): - client = docker_client(os.environ, version=const.API_VERSIONS[const.COMPOSE_SPEC]) + client = docker_client(os.environ, version=DEFAULT_DOCKER_API_VERSION) expected = "docker-compose/{} docker-py/{} {}/{}".format( compose.__version__, docker.__version__, diff --git a/tests/unit/cli_test.py b/tests/unit/cli_test.py index bcd8123d4fe..03522d5facb 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -20,6 +20,7 @@ from compose.const import LABEL_SERVICE from compose.container import Container from compose.project import Project +from compose.config.environment import Environment class CLITestCase(unittest.TestCase): @@ -77,7 +78,9 @@ def test_project_name_with_environment_file(self): def test_get_project(self): base_dir = 'tests/fixtures/longer-filename-composefile' - project = get_project(base_dir) + env = Environment.from_env_file(base_dir) + env['COMPOSE_API_VERSION'] = DEFAULT_DOCKER_API_VERSION + project = get_project(base_dir, environment=env) assert project.name == 'longer-filename-composefile' assert project.client assert project.services From dff3ce28f49e2d0a572da93d5e092d157d8cf0f0 Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Fri, 21 Aug 2020 19:24:12 +0200 Subject: [PATCH 4000/4072] Fix flake8 Signed-off-by: Ulysses Souza --- tests/unit/cli/docker_client_test.py | 1 - tests/unit/cli_test.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/unit/cli/docker_client_test.py b/tests/unit/cli/docker_client_test.py index 5413effdace..307e47f1bae 100644 --- a/tests/unit/cli/docker_client_test.py +++ b/tests/unit/cli/docker_client_test.py @@ -7,7 +7,6 @@ from docker.constants import DEFAULT_DOCKER_API_VERSION import compose -from compose import const from compose.cli import errors from compose.cli.docker_client import docker_client from compose.cli.docker_client import get_tls_version diff --git a/tests/unit/cli_test.py b/tests/unit/cli_test.py index 03522d5facb..fa6e76747f4 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -16,11 +16,11 @@ from compose.cli.docopt_command import NoSuchCommand from compose.cli.errors import UserError from compose.cli.main import TopLevelCommand +from compose.config.environment import Environment from compose.const import IS_WINDOWS_PLATFORM from compose.const import LABEL_SERVICE from compose.container import Container from compose.project import Project -from compose.config.environment import Environment class CLITestCase(unittest.TestCase): From 76c92b01d455b56324f5241acc81d3471178c10f Mon Sep 17 00:00:00 2001 From: Erfan Gholamian Date: Tue, 18 Aug 2020 19:59:37 +0430 Subject: [PATCH 4001/4072] Check stderr when building with docker cli. Signed-off-by: Erfan Gholamian --- compose/service.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/compose/service.py b/compose/service.py index 471f9e199f3..9d96a4abec4 100644 --- a/compose/service.py +++ b/compose/service.py @@ -1856,7 +1856,9 @@ def build(self, path, tag=None, quiet=False, fileobj=None, magic_word = "Successfully built " appear = False - with subprocess.Popen(args, stdout=subprocess.PIPE, universal_newlines=True) as p: + with subprocess.Popen(args, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + universal_newlines=True) as p: while True: line = p.stdout.readline() if not line: @@ -1865,6 +1867,10 @@ def build(self, path, tag=None, quiet=False, fileobj=None, appear = True yield json.dumps({"stream": line}) + err = p.stderr.readline().strip() + if err: + raise StreamOutputError(err) + with open(iidfile) as f: line = f.readline() image_id = line.split(":")[1].strip() From 76963e44add9810c1d906b5fbd052dc3fb480479 Mon Sep 17 00:00:00 2001 From: Erfan Gholamian Date: Fri, 21 Aug 2020 19:32:37 +0430 Subject: [PATCH 4002/4072] Added integration test for build error when building with docker cli Signed-off-by: Erfan Gholamian --- tests/integration/service_test.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 985c4d77ab6..e11616491b3 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -36,6 +36,7 @@ from compose.project import OneOffFilter from compose.project import Project from compose.service import BuildAction +from compose.service import BuildError from compose.service import ConvergencePlan from compose.service import ConvergenceStrategy from compose.service import IpcMode @@ -987,6 +988,26 @@ def test_build_cli_with_build_labels(self): image = self.client.inspect_image('composetest_web') assert image['Config']['Labels']['com.docker.compose.test'] + def test_build_cli_with_build_error(self): + base_dir = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, base_dir) + + with open(os.path.join(base_dir, 'Dockerfile'), 'w') as f: + f.write('\n'.join([ + "FROM busybox", + "RUN exit 2", + ])) + service = self.create_service('web', + build={ + 'context': base_dir, + 'labels': {'com.docker.compose.test': 'true'}}, + ) + with pytest.raises(BuildError) as excinfo: + service.build(cli=True) + + reason = excinfo.value.reason + assert "The command '/bin/sh -c exit 2' returned a non-zero code: 2" == reason + def test_up_build_cli(self): base_dir = tempfile.mkdtemp() self.addCleanup(shutil.rmtree, base_dir) From da0e18305256e73e1406b8767491173705b6340d Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2020 21:23:43 +0000 Subject: [PATCH 4003/4072] Bump pytest-cov from 2.10.0 to 2.10.1 Bumps [pytest-cov](https://github.com/pytest-dev/pytest-cov) from 2.10.0 to 2.10.1. - [Release notes](https://github.com/pytest-dev/pytest-cov/releases) - [Changelog](https://github.com/pytest-dev/pytest-cov/blob/master/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest-cov/compare/v2.10.0...v2.10.1) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 1695e98e20a..8655e5c0aa1 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -6,4 +6,4 @@ gitpython==3.1.7 mock==3.0.5 pytest==6.0.1; python_version >= '3.5' pytest==4.6.5; python_version < '3.5' -pytest-cov==2.10.0 +pytest-cov==2.10.1 From e9b93d706fd612dbd262cccaed40e9a9370ea36d Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2020 21:23:25 +0000 Subject: [PATCH 4004/4072] Bump attrs from 19.3.0 to 20.1.0 Bumps [attrs](https://github.com/python-attrs/attrs) from 19.3.0 to 20.1.0. - [Release notes](https://github.com/python-attrs/attrs/releases) - [Changelog](https://github.com/python-attrs/attrs/blob/master/CHANGELOG.rst) - [Commits](https://github.com/python-attrs/attrs/compare/19.3.0...20.1.0) Signed-off-by: dependabot-preview[bot] --- requirements-indirect.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-indirect.txt b/requirements-indirect.txt index ef302dcdffb..9d9ae3684d9 100644 --- a/requirements-indirect.txt +++ b/requirements-indirect.txt @@ -1,6 +1,6 @@ altgraph==0.17 appdirs==1.4.4 -attrs==19.3.0 +attrs==20.1.0 bcrypt==3.1.7 cffi==1.14.1 cryptography==3.0 From 9ee6b17d9c3ed7f507873c7c88b9d6c0b21ad485 Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Mon, 31 Aug 2020 21:40:11 +0200 Subject: [PATCH 4005/4072] Add Anca to Maintainers Signed-off-by: Ulysses Souza --- .github/CODEOWNERS | 2 +- MAINTAINERS | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 13fb9bac0ea..85ab9015f3b 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -3,4 +3,4 @@ # # KEEP THIS FILE SORTED. Order is important. Last match takes precedence. -* @ndeloof @rumpl @ulyssessouza +* @aiordache @ndeloof @rumpl @ulyssessouza diff --git a/MAINTAINERS b/MAINTAINERS index 273f724ec44..7e178147e84 100644 --- a/MAINTAINERS +++ b/MAINTAINERS @@ -11,6 +11,7 @@ [Org] [Org."Core maintainers"] people = [ + "aiordache", "ndeloof", "rumpl", "ulyssessouza", @@ -53,6 +54,11 @@ Email = "aanand.prasad@gmail.com" GitHub = "aanand" + [people.aiordache] + Name = "Anca Iordache" + Email = "anca.iordache@docker.com" + GitHub = "aiordache" + [people.bfirsh] Name = "Ben Firshman" Email = "ben@firshman.co.uk" From 827c68fe6f9c86657952832d83f03704531468e4 Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Mon, 31 Aug 2020 15:17:56 +0200 Subject: [PATCH 4006/4072] Fix bump of docker-py on `setup.py` Signed-off-by: Ulysses Souza --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 590e0ebdc79..e0d4340e5f6 100644 --- a/setup.py +++ b/setup.py @@ -32,7 +32,7 @@ def find_version(*file_paths): 'texttable >= 0.9.0, < 2', 'websocket-client >= 0.32.0, < 1', 'distro >= 1.5.0, < 2', - 'docker[ssh] >= 4.2.2, < 5', + 'docker[ssh] >= 4.3.1, < 5', 'dockerpty >= 0.4.1, < 1', 'jsonschema >= 2.5.1, < 4', 'python-dotenv >= 0.13.0, < 1', From 3b17b3c2c09e468a1d6bf4db9a6e15cde21c48a0 Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Mon, 31 Aug 2020 21:16:18 +0200 Subject: [PATCH 4007/4072] Fix stderr on returncode is different of 0 Signed-off-by: Ulysses Souza --- compose/cli/main.py | 7 +++++-- compose/service.py | 7 +++---- tests/integration/service_test.py | 5 +---- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index b07b2b7285c..84a5ab08840 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -73,13 +73,16 @@ def main(): log.error(e.msg) sys.exit(1) except BuildError as e: - log.error("Service '{}' failed to build: {}".format(e.service.name, e.reason)) + reason = "" + if e.reason: + reason = " : " + e.reason + log.error("Service '{}' failed to build{}".format(e.service.name, reason)) sys.exit(1) except StreamOutputError as e: log.error(e) sys.exit(1) except NeedsBuildError as e: - log.error("Service '%s' needs to be built, but --no-build was passed." % e.service.name) + log.error("Service '{}' needs to be built, but --no-build was passed.".format(e.service.name)) sys.exit(1) except NoSuchCommand as e: commands = "\n".join(parse_doc_section("commands:", getdoc(e.supercommand))) diff --git a/compose/service.py b/compose/service.py index 9d96a4abec4..70939cac791 100644 --- a/compose/service.py +++ b/compose/service.py @@ -1857,7 +1857,6 @@ def build(self, path, tag=None, quiet=False, fileobj=None, magic_word = "Successfully built " appear = False with subprocess.Popen(args, stdout=subprocess.PIPE, - stderr=subprocess.PIPE, universal_newlines=True) as p: while True: line = p.stdout.readline() @@ -1867,9 +1866,9 @@ def build(self, path, tag=None, quiet=False, fileobj=None, appear = True yield json.dumps({"stream": line}) - err = p.stderr.readline().strip() - if err: - raise StreamOutputError(err) + p.communicate() + if p.returncode != 0: + raise StreamOutputError() with open(iidfile) as f: line = f.readline() diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index e11616491b3..efb1fd5fa8b 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -1002,12 +1002,9 @@ def test_build_cli_with_build_error(self): 'context': base_dir, 'labels': {'com.docker.compose.test': 'true'}}, ) - with pytest.raises(BuildError) as excinfo: + with pytest.raises(BuildError): service.build(cli=True) - reason = excinfo.value.reason - assert "The command '/bin/sh -c exit 2' returned a non-zero code: 2" == reason - def test_up_build_cli(self): base_dir = tempfile.mkdtemp() self.addCleanup(shutil.rmtree, base_dir) From 134319c73504633b60a917606e3430a738010906 Mon Sep 17 00:00:00 2001 From: aiordache Date: Tue, 1 Sep 2020 14:31:18 +0200 Subject: [PATCH 4008/4072] Pass context to docker cli Signed-off-by: aiordache --- compose/cli/main.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/compose/cli/main.py b/compose/cli/main.py index 84a5ab08840..acac122463e 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -1439,6 +1439,7 @@ def call_docker(args, dockeropts, environment): key = dockeropts.get('--tlskey') verify = dockeropts.get('--tlsverify') host = dockeropts.get('--host') + context = dockeropts.get('--context') tls_options = [] if tls: tls_options.append('--tls') @@ -1454,6 +1455,10 @@ def call_docker(args, dockeropts, environment): tls_options.extend( ['--host', re.sub(r'^https?://', 'tcp://', host.lstrip('='))] ) + if context: + tls_options.extend( + ['--context', context] + ) args = [executable_path] + tls_options + args log.debug(" ".join(map(pipes.quote, args))) From 5db0adda77ec186859e19b29c9a842d39a3e8c0f Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Mon, 7 Sep 2020 15:19:16 +0200 Subject: [PATCH 4009/4072] Add docker.github.io documentation checker and update doc message Signed-off-by: Ulysses Souza --- compose/cli/main.py | 6 +++--- script/docs/check_help.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) create mode 100755 script/docs/check_help.py diff --git a/compose/cli/main.py b/compose/cli/main.py index acac122463e..7ec09bea255 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -310,14 +310,14 @@ def config(self, options): Options: --resolve-image-digests Pin image tags to digests. - --no-interpolate Don't interpolate environment variables + --no-interpolate Don't interpolate environment variables. -q, --quiet Only validate the configuration, don't print anything. --services Print the service names, one per line. --volumes Print the volume names, one per line. --hash="*" Print the service config hash, one per line. Set "service1,service2" for a list of specified services - or use the wildcard symbol to display all services + or use the wildcard symbol to display all services. """ additional_options = {'--no-interpolate': options.get('--no-interpolate')} @@ -1005,7 +1005,7 @@ def up(self, options): --build Build images before starting containers. --abort-on-container-exit Stops all containers if any container was stopped. Incompatible with -d. - --attach-dependencies Attach to dependent containers + --attach-dependencies Attach to dependent containers. -t, --timeout TIMEOUT Use this timeout in seconds for container shutdown when attached or when containers are already running. (default: 10) diff --git a/script/docs/check_help.py b/script/docs/check_help.py new file mode 100755 index 00000000000..0904f00c4f6 --- /dev/null +++ b/script/docs/check_help.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +import glob +import os.path +import re +import subprocess + +USAGE_RE = re.compile(r"```.*?\nUsage:.*?```", re.MULTILINE | re.DOTALL) +USAGE_IN_CMD_RE = re.compile(r"^Usage:.*", re.MULTILINE | re.DOTALL) + +HELP_CMD = "docker run --rm docker/compose:latest %s --help" + +for file in glob.glob("compose/reference/*.md"): + with open(file) as f: + data = f.read() + if not USAGE_RE.search(data): + print("Not a command:", file) + continue + subcmd = os.path.basename(file).replace(".md", "") + if subcmd == "overview": + continue + print(f"Found {subcmd}: {file}") + help_cmd = HELP_CMD % subcmd + help = subprocess.check_output(help_cmd.split()) + help = help.decode("utf-8") + help = USAGE_IN_CMD_RE.findall(help)[0] + help = help.strip() + data = USAGE_RE.sub(f"```none\n{help}\n```", data) + with open(file, "w") as f: + f.write(data) From 060e4e05da19c85d4008dd6ce90504b5469d0e2a Mon Sep 17 00:00:00 2001 From: aiordache Date: Tue, 8 Sep 2020 10:37:09 +0200 Subject: [PATCH 4010/4072] Add 1.27.0 release notes to changelog and bump dev version Signed-off-by: aiordache --- CHANGELOG.md | 43 +++++++++++++++++++++++++++++++++++++++++++ compose/__init__.py | 2 +- 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e7a6bbf09a..5aac988d3d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,49 @@ Change log ========== +1.27.0 (2020-09-07) +------------------- + +### Features + +- Merge 2.x and 3.x compose formats and align with COMPOSE_SPEC schema + +- Implement service mode for ipc + +- Pass `COMPOSE_PROJECT_NAME` environment variable in container mode + +- Make run behave in the same way as up + +- Use `docker build` on `docker-compose run` when `COMPOSE_DOCKER_CLI_BUILD` environment variable is set + +- Use docker-py default API version for engine queries (`auto`) + +- Parse `network_mode` on build + +### Bugs + +- Ignore build context path validation when building is not required + +- Fix float to bytes conversion via docker-py bump to 4.3.1 + +- Fix scale bug when deploy section is set + +- Fix `docker-py` bump in `setup.py` + +- Fix experimental build failure detection + +- Fix context propagation to docker cli + +### Miscellaneous + +- Bump `docker-py` to 4.3.1 + +- Bump `tox` to 3.19.0 + +- Bump `virtualenv` to 20.0.30 + +- Add script for docs syncronization + 1.26.1 (2020-06-30) ------------------- diff --git a/compose/__init__.py b/compose/__init__.py index 76e27d25f73..444d27edb84 100644 --- a/compose/__init__.py +++ b/compose/__init__.py @@ -1 +1 @@ -__version__ = '1.27.0dev' +__version__ = '1.28.0dev' From 3ee52f2f28344a56b4621bf023b4f27baf7ffb5b Mon Sep 17 00:00:00 2001 From: aiordache Date: Tue, 8 Sep 2020 10:57:05 +0200 Subject: [PATCH 4011/4072] Add 1.26.2 release notes to changelog Signed-off-by: aiordache --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5aac988d3d5..e2bdbbd3ab7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,13 @@ Change log - Add script for docs syncronization +1.26.2 (2020-07-02) +------------------- + +### Bugs + +- Enforce `docker-py` 4.2.2 as minimum version when installing with pip + 1.26.1 (2020-06-30) ------------------- From b111ef63224f0f08d999e197f9b63675ffe4ee76 Mon Sep 17 00:00:00 2001 From: aiordache Date: Wed, 9 Sep 2020 10:23:10 +0200 Subject: [PATCH 4012/4072] Re-enable 0 scale/deploy.replicas Signed-off-by: aiordache --- compose/project.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/compose/project.py b/compose/project.py index a5bb39b932f..420cb6548bc 100644 --- a/compose/project.py +++ b/compose/project.py @@ -311,12 +311,12 @@ def get_service_scale(self, service_dict): return 1 if scale is None else scale replicas = deploy_dict.get('replicas', None) - if scale and replicas: + if scale is not None and replicas is not None: raise ConfigurationError( "Both service.scale and service.deploy.replicas are set." " Only one of them must be set." ) - if replicas: + if replicas is not None: scale = replicas if scale is None: return 1 From 884a1c4286d1ec556ff8d07304a5761b66f6cc7b Mon Sep 17 00:00:00 2001 From: aiordache Date: Thu, 10 Sep 2020 09:56:49 +0200 Subject: [PATCH 4013/4072] Allow driver property for external networks Signed-off-by: aiordache --- compose/config/config.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/compose/config/config.py b/compose/config/config.py index 881f5d68382..eb1c7809caa 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -441,6 +441,8 @@ def load_mapping(config_files, get_func, entity_type, working_dir=None): def validate_external(entity_type, name, config, version): for k in config.keys(): + if entity_type == 'Network' and k == 'driver': + continue if k not in ['external', 'name']: raise ConfigurationError( "{} {} declared as external but specifies additional attributes " From 9f47d4e5d7c1c4eea52f8a94b3171842b2d0a29d Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Wed, 9 Sep 2020 15:31:01 +0200 Subject: [PATCH 4014/4072] Pin `docker-compose config`'s version to `3.9` This avoids bugs on using it's output on swarm Signed-off-by: Ulysses Souza --- compose/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/const.py b/compose/const.py index c51e6ac0b00..04342980255 100644 --- a/compose/const.py +++ b/compose/const.py @@ -24,7 +24,7 @@ WINDOWS_LONGPATH_PREFIX = '\\\\?\\' COMPOSEFILE_V1 = ComposeVersion('1') -COMPOSE_SPEC = ComposeVersion('3') +COMPOSE_SPEC = ComposeVersion('3.9') # minimum DOCKER ENGINE API version needed to support # features for each compose schema version From 6979a337e072d6313c46af05f40c0fa0069794db Mon Sep 17 00:00:00 2001 From: aiordache Date: Thu, 10 Sep 2020 12:11:02 +0200 Subject: [PATCH 4015/4072] Set service scale to 1 for oneoff containers Signed-off-by: aiordache --- compose/cli/main.py | 1 + 1 file changed, 1 insertion(+) diff --git a/compose/cli/main.py b/compose/cli/main.py index 7ec09bea255..d01bf86a415 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -1304,6 +1304,7 @@ def run_one_off_container(container_options, project, service, options, toplevel native_builder = toplevel_environment.get_boolean('COMPOSE_DOCKER_CLI_BUILD') detach = options.get('--detach') use_network_aliases = options.get('--use-aliases') + service.scale_num = 1 containers = project.up( service_names=[service.name], start_deps=not options['--no-deps'], From fde1c681a7a7829d35abd4048183f768d1276758 Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Thu, 10 Sep 2020 11:20:29 +0200 Subject: [PATCH 4016/4072] Preserve the version when specified in the file Signed-off-by: Ulysses Souza --- compose/config/config.py | 2 -- tests/acceptance/cli_test.py | 18 +++++++++--------- tests/unit/config/config_test.py | 27 +++++++++++---------------- 3 files changed, 20 insertions(+), 27 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 881f5d68382..899e8c5b792 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -224,8 +224,6 @@ def version(self): if version.startswith("1"): version = V1 - else: - version = VERSION if version == V1: raise ConfigurationError( diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index ced0a27333a..3d9a31c2dc7 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -284,7 +284,7 @@ def test_config_default(self): output = yaml.safe_load(result.stdout) expected = { - 'version': str(VERSION), + 'version': '2', 'volumes': {'data': {'driver': 'local'}}, 'networks': {'front': {}}, 'services': { @@ -308,7 +308,7 @@ def test_config_restart(self): self.base_dir = 'tests/fixtures/restart' result = self.dispatch(['config']) assert yaml.safe_load(result.stdout) == { - 'version': str(VERSION), + 'version': '2', 'services': { 'never': { 'image': 'busybox', @@ -354,12 +354,12 @@ def test_config_with_dot_env(self): result = self.dispatch(['config']) json_result = yaml.safe_load(result.stdout) assert json_result == { - 'version': str(VERSION), + 'version': '2.4', 'services': { 'web': { 'command': 'true', 'image': 'alpine:latest', - 'ports': [{'target': 5643}, {'target': 9999}] + 'ports': ['5643/tcp', '9999/tcp'] } } } @@ -369,12 +369,12 @@ def test_config_with_env_file(self): result = self.dispatch(['--env-file', '.env2', 'config']) json_result = yaml.safe_load(result.stdout) assert json_result == { - 'version': str(VERSION), + 'version': '2.4', 'services': { 'web': { 'command': 'false', 'image': 'alpine:latest', - 'ports': [{'target': 5644}, {'target': 9998}] + 'ports': ['5644/tcp', '9998/tcp'] } } } @@ -384,12 +384,12 @@ def test_config_with_dot_env_and_override_dir(self): result = self.dispatch(['--project-directory', 'alt/', 'config']) json_result = yaml.safe_load(result.stdout) assert json_result == { - 'version': str(VERSION), + 'version': '2.4', 'services': { 'web': { 'command': 'echo uwu', 'image': 'alpine:3.10.1', - 'ports': [{'target': 3341}, {'target': 4449}] + 'ports': ['3341/tcp', '4449/tcp'] } } } @@ -501,7 +501,7 @@ def test_config_v3(self): self.base_dir = 'tests/fixtures/v3-full' result = self.dispatch(['config']) assert yaml.safe_load(result.stdout) == { - 'version': str(VERSION), + 'version': '3.5', 'volumes': { 'foobar': { 'labels': { diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 03e95f77a55..8b0d37526a3 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -160,25 +160,20 @@ def test_load_v2(self): } def test_valid_versions(self): - for version in ['2', '2.0']: - cfg = config.load(build_config_details({'version': version})) - assert cfg.version == VERSION - - cfg = config.load(build_config_details({'version': '2.1'})) - assert cfg.version == VERSION - - cfg = config.load(build_config_details({'version': '2.2'})) - assert cfg.version == VERSION - - cfg = config.load(build_config_details({'version': '2.3'})) + cfg = config.load( + build_config_details({ + 'services': { + 'foo': {'image': 'busybox'}, + 'bar': {'image': 'busybox', 'environment': ['FOO=1']}, + } + }) + ) assert cfg.version == VERSION - for version in ['3', '3.0']: + for version in ['2', '2.0', '2.1', '2.2', '2.3', + '3', '3.0', '3.1', '3.2', '3.3', '3.4', '3.5', '3.6', '3.7', '3.8']: cfg = config.load(build_config_details({'version': version})) - assert cfg.version == VERSION - - cfg = config.load(build_config_details({'version': '3.1'})) - assert cfg.version == VERSION + assert cfg.version == version def test_v1_file_version(self): cfg = config.load(build_config_details({'web': {'image': 'busybox'}})) From 46fb338812976fd2009a83f9be9f8c69084f6b13 Mon Sep 17 00:00:00 2001 From: aiordache Date: Wed, 9 Sep 2020 11:02:22 +0200 Subject: [PATCH 4017/4072] Fix release notest for 1.27.0 - add removal of python2 support Signed-off-by: aiordache --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e2bdbbd3ab7..a319cbb49d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,8 @@ Change log ### Miscellaneous +- Drop support for Python 2.7 + - Bump `docker-py` to 4.3.1 - Bump `tox` to 3.19.0 From b4a944c6cba856d01b84e5978ed3d6c43f03d197 Mon Sep 17 00:00:00 2001 From: aiordache Date: Thu, 10 Sep 2020 16:53:05 +0200 Subject: [PATCH 4018/4072] Use `detach=true` for oneoff containers Signed-off-by: aiordache --- 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 d01bf86a415..626ef294aeb 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -1309,7 +1309,7 @@ def run_one_off_container(container_options, project, service, options, toplevel service_names=[service.name], start_deps=not options['--no-deps'], strategy=ConvergenceStrategy.never, - detached=detach, + detached=True, rescale=False, cli=native_builder, one_off=True, From 204655be137ecf1d901ab0c875ba217447f9fa57 Mon Sep 17 00:00:00 2001 From: aiordache Date: Thu, 10 Sep 2020 16:07:03 +0200 Subject: [PATCH 4019/4072] Update changelog for 1.27.2 Signed-off-by: aiordache --- CHANGELOG.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a319cbb49d3..4b98799b5c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,26 @@ Change log ========== +1.27.2 (2020-09-10) +------------------- + +### Bugs + +- Fix bug on `docker-compose run` container attach + +1.27.1 (2020-09-10) +------------------- + +### Bugs + +- Fix `docker-compose run` when `service.scale` is specified + +- Allow `driver` property for external networks as temporary workaround for swarm network propagation issue + +- Pin new internal schema version to `3.9` as the default + +- Preserve the version when configured in the compose file + 1.27.0 (2020-09-07) ------------------- From d811500fa0702c30310289f736c66874171b0657 Mon Sep 17 00:00:00 2001 From: Kevin Clark Date: Thu, 10 Sep 2020 17:05:11 -0400 Subject: [PATCH 4020/4072] Added merge for max_replicas_per_node Signed-off-by: Kevin Clark --- compose/config/config.py | 1 + tests/unit/config/config_test.py | 2 ++ 2 files changed, 3 insertions(+) diff --git a/compose/config/config.py b/compose/config/config.py index 243529bd6f5..7b3969b6c01 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -1114,6 +1114,7 @@ def merge_deploy(base, override): md['resources'] = dict(resources_md) if md.needs_merge('placement'): placement_md = MergeDict(md.base.get('placement') or {}, md.override.get('placement') or {}) + placement_md.merge_scalar('max_replicas_per_node') placement_md.merge_field('constraints', merge_unique_items_lists, default=[]) placement_md.merge_field('preferences', merge_unique_objects_lists, default=[]) md['placement'] = dict(placement_md) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 8b0d37526a3..63eece17edc 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -2543,6 +2543,7 @@ def test_merge_deploy_override(self): 'labels': ['com.docker.compose.a=1', 'com.docker.compose.b=2'], 'mode': 'replicated', 'placement': { + 'max_replicas_per_node': 1, 'constraints': [ 'node.role == manager', 'engine.labels.aws == true' ], @@ -2599,6 +2600,7 @@ def test_merge_deploy_override(self): 'com.docker.compose.c': '3' }, 'placement': { + 'max_replicas_per_node': 1, 'constraints': [ 'engine.labels.aws == true', 'engine.labels.dev == true', 'node.role == manager', 'node.role == worker' From a75b6249f83be2e1b8fadc40b2c29aa7d09921ef Mon Sep 17 00:00:00 2001 From: aiordache Date: Mon, 14 Sep 2020 11:41:30 +0200 Subject: [PATCH 4021/4072] Fix depends_on serialisation on `docker-compose config` Signed-off-by: aiordache --- compose/config/serialize.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/compose/config/serialize.py b/compose/config/serialize.py index 2dd2c47f1a3..2d9493a0362 100644 --- a/compose/config/serialize.py +++ b/compose/config/serialize.py @@ -121,11 +121,6 @@ def denormalize_service_dict(service_dict, version, image_digest=None): if version == V1 and 'network_mode' not in service_dict: service_dict['network_mode'] = 'bridge' - if 'depends_on' in service_dict: - service_dict['depends_on'] = sorted([ - 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( From fa720787d62cf833ed7648ff55dbd248267a5b74 Mon Sep 17 00:00:00 2001 From: aiordache Date: Mon, 14 Sep 2020 18:01:35 +0200 Subject: [PATCH 4022/4072] update depends_on tests Signed-off-by: aiordache --- tests/unit/config/config_test.py | 24 ++---------------------- 1 file changed, 2 insertions(+), 22 deletions(-) diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 63eece17edc..b1586ae1f2d 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -5269,7 +5269,7 @@ def make_files(dirname, filenames): class SerializeTest(unittest.TestCase): - def test_denormalize_depends_on_v3(self): + def test_denormalize_depends(self): service_dict = { 'image': 'busybox', 'command': 'true', @@ -5279,27 +5279,7 @@ def test_denormalize_depends_on_v3(self): } } - assert denormalize_service_dict(service_dict, VERSION) == { - '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, VERSION) == { - 'image': 'busybox', - 'command': 'true', - 'depends_on': ['service2', 'service3'] - } + assert denormalize_service_dict(service_dict, VERSION) == service_dict def test_serialize_time(self): data = { From 50a4afaf1743d908088576e554c5040ea93d1456 Mon Sep 17 00:00:00 2001 From: aiordache Date: Fri, 11 Sep 2020 17:23:40 +0200 Subject: [PATCH 4023/4072] Fix scaling when some containers are not running Signed-off-by: aiordache --- compose/service.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/compose/service.py b/compose/service.py index 70939cac791..a1a500cb213 100644 --- a/compose/service.py +++ b/compose/service.py @@ -411,7 +411,7 @@ def convergence_plan(self, strategy=ConvergenceStrategy.changed, one_off=False): stopped = [c for c in containers if not c.is_running] if stopped: - return ConvergencePlan('start', stopped) + return ConvergencePlan('start', containers) return ConvergencePlan('noop', containers) @@ -514,8 +514,9 @@ def _execute_convergence_start(self, containers, scale, timeout, detached, start self._downscale(containers[scale:], timeout) containers = containers[:scale] if start: + stopped = [c for c in containers if not c.is_running] _, errors = parallel_execute( - containers, + stopped, lambda c: self.start_container_if_stopped(c, attach_logs=not detached, quiet=True), lambda c: c.name, "Starting", From a85d2bc64ccd79960e1f7c744f0702710e3887f0 Mon Sep 17 00:00:00 2001 From: aiordache Date: Fri, 11 Sep 2020 18:06:31 +0200 Subject: [PATCH 4024/4072] update test for start trigger Signed-off-by: aiordache --- tests/integration/state_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/state_test.py b/tests/integration/state_test.py index 5258e310cfa..8168cddf010 100644 --- a/tests/integration/state_test.py +++ b/tests/integration/state_test.py @@ -375,7 +375,7 @@ def test_trigger_start(self): assert [c.is_running for c in containers] == [False, True] - assert ('start', containers[0:1]) == web.convergence_plan() + assert ('start', containers) == web.convergence_plan() def test_trigger_recreate_with_config_change(self): web = self.create_service('web', command=["top"]) From 5340a6d760c463c759cdf3514357dfe5eb11a31e Mon Sep 17 00:00:00 2001 From: aiordache Date: Mon, 14 Sep 2020 19:11:13 +0200 Subject: [PATCH 4025/4072] Add test for scale with stopped containers Signed-off-by: aiordache --- tests/integration/project_test.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 87970107609..96929f209b6 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -1347,6 +1347,36 @@ def test_project_up_config_scale(self): project.up() assert len(project.containers()) == 3 + def test_project_up_scale_with_stopped_containers(self): + config_data = build_config( + services=[{ + 'name': 'web', + 'image': BUSYBOX_IMAGE_WITH_TAG, + 'command': 'top', + 'scale': 2 + }] + ) + project = Project.from_config( + name='composetest', config_data=config_data, client=self.client + ) + + project.up() + containers = project.containers() + assert len(containers) == 2 + + self.client.stop(containers[0].id) + project.up(scale_override={'web': 2}) + containers = project.containers() + assert len(containers) == 2 + + self.client.stop(containers[0].id) + project.up(scale_override={'web': 3}) + assert len(project.containers()) == 3 + + self.client.stop(containers[0].id) + project.up(scale_override={'web': 1}) + assert len(project.containers()) == 1 + def test_initialize_volumes(self): vol_name = '{:x}'.format(random.getrandbits(32)) full_vol_name = 'composetest_{}'.format(vol_name) From 8c81a9da7a846c1f0b1fe3b40e7c7ab2e70c2215 Mon Sep 17 00:00:00 2001 From: aiordache Date: Mon, 14 Sep 2020 17:02:19 +0200 Subject: [PATCH 4026/4072] Enable relative paths for driver_opts.device Signed-off-by: aiordache --- compose/config/config.py | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 7b3969b6c01..69d0d902dc8 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -423,17 +423,31 @@ def load_mapping(config_files, get_func, entity_type, working_dir=None): elif not config.get('name'): config['name'] = name - if 'driver_opts' in config: - config['driver_opts'] = build_string_dict( - config['driver_opts'] - ) - if 'labels' in config: config['labels'] = parse_labels(config['labels']) if 'file' in config: config['file'] = expand_path(working_dir, config['file']) + if 'driver_opts' in config: + config['driver_opts'] = build_string_dict( + config['driver_opts'] + ) + if entity_type != 'Volume': + continue + # default driver is 'local' + driver = config.get('driver', 'local') + if driver != 'local': + continue + o = config['driver_opts'].get('o') + device = config['driver_opts'].get('device') + if o and o == 'bind' and device: + fullpath = os.path.abspath(os.path.expanduser(device)) + if not os.path.exists(fullpath): + raise ConfigurationError( + "Device path {} does not exist.".format(fullpath)) + config['driver_opts']['device'] = fullpath + return mapping From c960b028b96970ebedac981bfbd64cfd0a04c848 Mon Sep 17 00:00:00 2001 From: aiordache Date: Mon, 14 Sep 2020 18:25:12 +0200 Subject: [PATCH 4027/4072] fix flake8 complexity Signed-off-by: aiordache --- compose/config/config.py | 35 ++++++++++++++++++++--------------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 69d0d902dc8..55e8c2757db 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -433,24 +433,29 @@ def load_mapping(config_files, get_func, entity_type, working_dir=None): config['driver_opts'] = build_string_dict( config['driver_opts'] ) - if entity_type != 'Volume': - continue - # default driver is 'local' - driver = config.get('driver', 'local') - if driver != 'local': - continue - o = config['driver_opts'].get('o') - device = config['driver_opts'].get('device') - if o and o == 'bind' and device: - fullpath = os.path.abspath(os.path.expanduser(device)) - if not os.path.exists(fullpath): - raise ConfigurationError( - "Device path {} does not exist.".format(fullpath)) - config['driver_opts']['device'] = fullpath - + device = format_device_option(entity_type, config) + if device: + config['driver_opts']['device'] = device return mapping +def format_device_option(entity_type, config): + if entity_type != 'Volume': + return + # default driver is 'local' + driver = config.get('driver', 'local') + if driver != 'local': + return + o = config['driver_opts'].get('o') + device = config['driver_opts'].get('device') + if o and o == 'bind' and device: + fullpath = os.path.abspath(os.path.expanduser(device)) + if not os.path.exists(fullpath): + raise ConfigurationError( + "Device path {} does not exist.".format(fullpath)) + return fullpath + + def validate_external(entity_type, name, config, version): for k in config.keys(): if entity_type == 'Network' and k == 'driver': From 60514c1adbeee1ce829f7394a0c6ee2176ac9405 Mon Sep 17 00:00:00 2001 From: aiordache Date: Wed, 16 Sep 2020 14:06:16 +0200 Subject: [PATCH 4028/4072] Allow strings for cpus fields Signed-off-by: aiordache --- compose/config/config_schema_compose_spec.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/compose/config/config_schema_compose_spec.json b/compose/config/config_schema_compose_spec.json index 8af7faa63b7..43d3a3edf0f 100644 --- a/compose/config/config_schema_compose_spec.json +++ b/compose/config/config_schema_compose_spec.json @@ -153,7 +153,7 @@ "cpu_period": {"type": ["number", "string"]}, "cpu_rt_period": {"type": ["number", "string"]}, "cpu_rt_runtime": {"type": ["number", "string"]}, - "cpus": {"type": "number", "minimum": 0}, + "cpus": {"type": ["number", "string"]}, "cpuset": {"type": "string"}, "credential_spec": { "type": "object", @@ -503,7 +503,7 @@ "limits": { "type": "object", "properties": { - "cpus": {"type": "number", "minimum": 0}, + "cpus": {"type": ["number", "string"]}, "memory": {"type": "string"} }, "additionalProperties": false, @@ -512,7 +512,7 @@ "reservations": { "type": "object", "properties": { - "cpus": {"type": "number", "minimum": 0}, + "cpus": {"type": ["number", "string"]}, "memory": {"type": "string"}, "generic_resources": {"$ref": "#/definitions/generic_resources"} }, From 1192a4e817feda3031b228f801e58f02b88ee6db Mon Sep 17 00:00:00 2001 From: aiordache Date: Thu, 17 Sep 2020 09:47:07 +0200 Subject: [PATCH 4029/4072] Update changelog for 1.27.3 release Signed-off-by: aiordache --- CHANGELOG.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b98799b5c7..5df82a02ec8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,21 @@ Change log ========== +1.27.3 (2020-09-16) +------------------- + +### Bugs + +- Merge `max_replicas_per_node` on `docker-compose config` + +- Fix `depends_on` serialization on `docker-compose config` + +- Fix scaling when some containers are not running on `docker-compose up` + +- Enable relative paths for `driver_opts.device` for `local` driver + +- Allow strings for `cpus` fields + 1.27.2 (2020-09-10) ------------------- From 1ff05ac060b2aaba2b0396d5bccebf80d4543699 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20H=C3=B6ltje?= Date: Wed, 11 Mar 2020 14:39:18 -0400 Subject: [PATCH 4030/4072] run.sh: handle unix:// prefix in DOCKER_HOST MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docker currently requires the `unix://` prefix when pointing `DOCKER_HOST` at a socket. fixes #7281 Signed-off-by: Christian Höltje --- script/run/run.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/script/run/run.sh b/script/run/run.sh index 8bc6798f3fe..bacf39d88cf 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -21,10 +21,10 @@ IMAGE="docker/compose:$VERSION" # Setup options for connecting to docker host if [ -z "$DOCKER_HOST" ]; then - DOCKER_HOST="/var/run/docker.sock" + DOCKER_HOST='unix:///var/run/docker.sock' fi -if [ -S "$DOCKER_HOST" ]; then - DOCKER_ADDR="-v $DOCKER_HOST:$DOCKER_HOST -e DOCKER_HOST" +if [ -S "${DOCKER_HOST#unix://}" ]; then + DOCKER_ADDR="-v ${DOCKER_HOST#unix://}:${DOCKER_HOST#unix://} -e DOCKER_HOST" else DOCKER_ADDR="-e DOCKER_HOST -e DOCKER_TLS_VERIFY -e DOCKER_CERT_PATH" fi From ce59a4c22397624d21a318745d534f2463ba4fd1 Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Tue, 22 Sep 2020 17:47:24 +0200 Subject: [PATCH 4031/4072] Fix port rendering Signed-off-by: Ulysses Souza --- compose/config/config.py | 25 ++++++++++++++++--------- compose/config/serialize.py | 2 +- tests/acceptance/cli_test.py | 6 +++--- tests/integration/project_test.py | 1 + tests/unit/config/config_test.py | 20 +++++++++++++++++--- tests/unit/project_test.py | 1 + 6 files changed, 39 insertions(+), 16 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index 55e8c2757db..aa8dd068b28 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -20,6 +20,7 @@ from ..utils import parse_bytes from ..utils import parse_nanoseconds_int from ..utils import splitdrive +from ..version import ComposeVersion from .environment import env_vars_from_file from .environment import Environment from .environment import split_env @@ -184,6 +185,13 @@ class ConfigFile(namedtuple('_ConfigFile', 'filename config')): def from_filename(cls, filename): return cls(filename, load_yaml(filename)) + @cached_property + def config_version(self): + version = self.config.get('version', None) + if isinstance(version, dict): + return V1 + return ComposeVersion(version) if version else self.version + @cached_property def version(self): version = self.config.get('version', None) @@ -222,15 +230,13 @@ def version(self): 'Version "{}" in "{}" is invalid.' .format(version, self.filename)) - if version.startswith("1"): - version = V1 - - if version == V1: + if version.startswith("1"): raise ConfigurationError( 'Version in "{}" is invalid. {}' .format(self.filename, VERSION_EXPLANATION) ) - return version + + return VERSION def get_service(self, name): return self.get_service_dicts()[name] @@ -253,8 +259,10 @@ def get_configs(self): return {} if self.version == V1 else self.config.get('configs', {}) -class Config(namedtuple('_Config', 'version services volumes networks secrets configs')): +class Config(namedtuple('_Config', 'config_version version services volumes networks secrets configs')): """ + :param config_version: configuration file version + :type config_version: int :param version: configuration version :type version: int :param services: List of service description dictionaries @@ -401,9 +409,8 @@ def load(config_details, interpolate=True): for service_dict in service_dicts: match_named_volumes(service_dict, volumes) - version = main_file.version - - return Config(version, service_dicts, volumes, networks, secrets, configs) + return Config(main_file.config_version, main_file.version, + service_dicts, volumes, networks, secrets, configs) def load_mapping(config_files, get_func, entity_type, working_dir=None): diff --git a/compose/config/serialize.py b/compose/config/serialize.py index 2d9493a0362..e3295df78ea 100644 --- a/compose/config/serialize.py +++ b/compose/config/serialize.py @@ -44,7 +44,7 @@ def serialize_string_escape_dollar(dumper, data): def denormalize_config(config, image_digests=None): - result = {'version': str(config.version)} + result = {'version': str(config.config_version)} denormalized_services = [ denormalize_service_dict( service_dict, diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 3d9a31c2dc7..d678130c1ec 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -359,7 +359,7 @@ def test_config_with_dot_env(self): 'web': { 'command': 'true', 'image': 'alpine:latest', - 'ports': ['5643/tcp', '9999/tcp'] + 'ports': [{'target': 5643}, {'target': 9999}] } } } @@ -374,7 +374,7 @@ def test_config_with_env_file(self): 'web': { 'command': 'false', 'image': 'alpine:latest', - 'ports': ['5644/tcp', '9998/tcp'] + 'ports': [{'target': 5644}, {'target': 9998}] } } } @@ -389,7 +389,7 @@ def test_config_with_dot_env_and_override_dir(self): 'web': { 'command': 'echo uwu', 'image': 'alpine:3.10.1', - 'ports': ['3341/tcp', '4449/tcp'] + 'ports': [{'target': 3341}, {'target': 4449}] } } } diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 96929f209b6..c4210291f14 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -37,6 +37,7 @@ def build_config(**kwargs): return config.Config( + config_version=kwargs.get('version', VERSION), version=kwargs.get('version', VERSION), services=kwargs.get('services'), volumes=kwargs.get('volumes'), diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index b1586ae1f2d..b67ecdaf4f0 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -168,12 +168,14 @@ def test_valid_versions(self): } }) ) + assert cfg.config_version == VERSION assert cfg.version == VERSION for version in ['2', '2.0', '2.1', '2.2', '2.3', '3', '3.0', '3.1', '3.2', '3.3', '3.4', '3.5', '3.6', '3.7', '3.8']: cfg = config.load(build_config_details({'version': version})) - assert cfg.version == version + assert cfg.config_version == version + assert cfg.version == VERSION def test_v1_file_version(self): cfg = config.load(build_config_details({'web': {'image': 'busybox'}})) @@ -5369,7 +5371,7 @@ def test_serialize_secrets(self): assert serialized_config['secrets']['two'] == {'external': True, 'name': 'two'} def test_serialize_ports(self): - config_dict = config.Config(version=VERSION, services=[ + config_dict = config.Config(config_version=VERSION, version=VERSION, services=[ { 'ports': [types.ServicePort('80', '8080', None, None, None)], 'image': 'alpine', @@ -5380,8 +5382,20 @@ def test_serialize_ports(self): serialized_config = yaml.safe_load(serialize_config(config_dict)) assert [{'published': 8080, 'target': 80}] == serialized_config['services']['web']['ports'] + def test_serialize_ports_v1(self): + config_dict = config.Config(config_version=V1, version=V1, services=[ + { + 'ports': [types.ServicePort('80', '8080', None, None, None)], + 'image': 'alpine', + 'name': 'web' + } + ], volumes={}, networks={}, secrets={}, configs={}) + + serialized_config = yaml.safe_load(serialize_config(config_dict)) + assert ['8080:80/tcp'] == serialized_config['services']['web']['ports'] + def test_serialize_ports_with_ext_ip(self): - config_dict = config.Config(version=VERSION, services=[ + config_dict = config.Config(config_version=VERSION, version=VERSION, services=[ { 'ports': [types.ServicePort('80', '8080', None, None, '127.0.0.1')], 'image': 'alpine', diff --git a/tests/unit/project_test.py b/tests/unit/project_test.py index 01f0b11efc3..a3ffdb67dae 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -28,6 +28,7 @@ def build_config(**kwargs): return Config( + config_version=kwargs.get('config_version', VERSION), version=kwargs.get('version', VERSION), services=kwargs.get('services'), volumes=kwargs.get('volumes'), From df05472bcc6f1596e519a1e02f01cebd4e160011 Mon Sep 17 00:00:00 2001 From: aiordache Date: Tue, 22 Sep 2020 10:27:14 +0200 Subject: [PATCH 4032/4072] Remove path check for bind mounts Signed-off-by: aiordache --- compose/config/config.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/compose/config/config.py b/compose/config/config.py index aa8dd068b28..1b067e78877 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -457,9 +457,6 @@ def format_device_option(entity_type, config): device = config['driver_opts'].get('device') if o and o == 'bind' and device: fullpath = os.path.abspath(os.path.expanduser(device)) - if not os.path.exists(fullpath): - raise ConfigurationError( - "Device path {} does not exist.".format(fullpath)) return fullpath From 062deb19c01ef386333f122aa057724bfb318694 Mon Sep 17 00:00:00 2001 From: aiordache Date: Fri, 25 Sep 2020 09:49:48 +0200 Subject: [PATCH 4033/4072] Update changelog for 1.27.4 Signed-off-by: aiordache --- CHANGELOG.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5df82a02ec8..3a9078d27d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,17 @@ Change log ========== +1.27.4 (2020-09-24) +------------------- + +### Bugs + +- Remove path checks for bind mounts + +- Fix port rendering to output long form syntax for non-v1 + +- Add protocol to the docker socket address + 1.27.3 (2020-09-16) ------------------- From d51249acf4d2f8e6a4b9b7e9f4084621cc95203b Mon Sep 17 00:00:00 2001 From: Luca Nardelli Date: Thu, 15 Oct 2020 00:21:58 +0200 Subject: [PATCH 4034/4072] Report which variable fails interpolation when they are mandatory Add default value before raising UnsetRequiredSubstitution Signed-off-by: Luca Nardelli --- compose/config/interpolation.py | 2 ++ tests/unit/config/interpolation_test.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/compose/config/interpolation.py b/compose/config/interpolation.py index 832344e2c2b..a464f946569 100644 --- a/compose/config/interpolation.py +++ b/compose/config/interpolation.py @@ -111,12 +111,14 @@ def process_braced_group(braced, sep, mapping): var, _, err = braced.partition(':?') result = mapping.get(var) if not result: + err = err or var raise UnsetRequiredSubstitution(err) return result elif '?' == sep: var, _, err = braced.partition('?') if var in mapping: return mapping.get(var) + err = err or var raise UnsetRequiredSubstitution(err) # Modified from python2.7/string.py diff --git a/tests/unit/config/interpolation_test.py b/tests/unit/config/interpolation_test.py index 6f3533d0b03..1fd50d60bdd 100644 --- a/tests/unit/config/interpolation_test.py +++ b/tests/unit/config/interpolation_test.py @@ -416,7 +416,7 @@ def test_interpolate_mandatory_no_err_msg(defaults_interpolator): with pytest.raises(UnsetRequiredSubstitution) as e: defaults_interpolator("not ok ${BAZ?}") - assert e.value.err == '' + assert e.value.err == 'BAZ' def test_interpolate_mixed_separators(defaults_interpolator): From cddaa77fea2e53d7e825c2cbbb4f03f9bef58203 Mon Sep 17 00:00:00 2001 From: Kaushal Rohit Date: Fri, 8 May 2020 23:30:19 +0530 Subject: [PATCH 4035/4072] Added option to disable log prefix via cli Signed-off-by: Kaushal Rohit --- compose/cli/log_printer.py | 18 +++++++++++------- compose/cli/main.py | 22 ++++++++++++++-------- tests/acceptance/cli_test.py | 9 +++++++++ 3 files changed, 34 insertions(+), 15 deletions(-) diff --git a/compose/cli/log_printer.py b/compose/cli/log_printer.py index cd9f73c2c0c..c49b817de7e 100644 --- a/compose/cli/log_printer.py +++ b/compose/cli/log_printer.py @@ -16,18 +16,22 @@ class LogPresenter: - def __init__(self, prefix_width, color_func): + def __init__(self, prefix_width, color_func, keep_prefix=True): self.prefix_width = prefix_width self.color_func = color_func + self.keep_prefix = keep_prefix 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) + to_log = '{line}'.format(line=line) + if self.keep_prefix: + prefix = container.name_without_project.ljust(self.prefix_width) + to_log = '{prefix} '.format(prefix=self.color_func(prefix + ' |')) + to_log -def build_log_presenters(service_names, monochrome): + return to_log + + +def build_log_presenters(service_names, monochrome, keep_prefix=True): """Return an iterable of functions. Each function can be used to format the logs output of a container. @@ -38,7 +42,7 @@ def no_color(text): return text for color_func in cycle([no_color] if monochrome else colors.rainbow()): - yield LogPresenter(prefix_width, color_func) + yield LogPresenter(prefix_width, color_func, keep_prefix) def max_name_width(service_names, max_index_width=3): diff --git a/compose/cli/main.py b/compose/cli/main.py index 626ef294aeb..6776dae9e5f 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -610,11 +610,12 @@ def logs(self, options): Usage: logs [options] [--] [SERVICE...] Options: - --no-color Produce monochrome output. - -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. + --no-color Produce monochrome output. + -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. + --no-log-prefix Don't print prefix in logs. """ containers = self.project.containers(service_names=options['SERVICE'], stopped=True) @@ -635,7 +636,8 @@ def logs(self, options): containers, set_no_color_if_clicolor(options['--no-color']), log_args, - event_stream=self.project.events(service_names=options['SERVICE'])).run() + event_stream=self.project.events(service_names=options['SERVICE']), + keep_prefix=not options['--no-log-prefix']).run() def pause(self, options): """ @@ -1017,6 +1019,7 @@ def up(self, options): container. Implies --abort-on-container-exit. --scale SERVICE=NUM Scale SERVICE to NUM instances. Overrides the `scale` setting in the Compose file if present. + --no-log-prefix Don't print prefix in logs. """ start_deps = not options['--no-deps'] always_recreate_deps = options['--always-recreate-deps'] @@ -1028,6 +1031,7 @@ def up(self, options): detached = options.get('--detach') no_start = options.get('--no-start') attach_dependencies = options.get('--attach-dependencies') + keep_prefix = not options['--no-log-prefix'] if detached and (cascade_stop or exit_value_from or attach_dependencies): raise UserError( @@ -1094,7 +1098,8 @@ def up(rebuild): set_no_color_if_clicolor(options['--no-color']), {'follow': True}, cascade_stop, - event_stream=self.project.events(service_names=service_names)) + event_stream=self.project.events(service_names=service_names), + keep_prefix=keep_prefix) print("Attaching to", list_containers(log_printer.containers)) cascade_starter = log_printer.run() @@ -1382,10 +1387,11 @@ def log_printer_from_project( log_args, cascade_stop=False, event_stream=None, + keep_prefix=True, ): return LogPrinter( containers, - build_log_presenters(project.service_names, monochrome), + build_log_presenters(project.service_names, monochrome, keep_prefix), event_stream or project.events(), cascade_stop=cascade_stop, log_args=log_args) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index d678130c1ec..a7ccd148940 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -3034,3 +3034,12 @@ def test_up_with_stop_process_flag(self): another = self.project.get_service('--log-service') assert len(service.containers()) == 1 assert len(another.containers()) == 1 + + def test_up_no_log_prefix(self): + self.base_dir = 'tests/fixtures/echo-services' + result = self.dispatch(['up', '--no-log-prefix']) + + assert 'simple' in result.stdout + assert 'another' in result.stdout + assert 'exited with code 0' in result.stdout + assert 'exited with code 0' in result.stdout From df99124d72ad8dce6e87bcfd5f4330197b4b3ed7 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 26 Oct 2020 21:23:52 +0000 Subject: [PATCH 4036/4072] Bump gitpython from 3.1.7 to 3.1.11 Bumps [gitpython](https://github.com/gitpython-developers/GitPython) from 3.1.7 to 3.1.11. - [Release notes](https://github.com/gitpython-developers/GitPython/releases) - [Changelog](https://github.com/gitpython-developers/GitPython/blob/master/CHANGES) - [Commits](https://github.com/gitpython-developers/GitPython/compare/3.1.7...3.1.11) Signed-off-by: dependabot-preview[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 8655e5c0aa1..c235777b1b6 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -2,7 +2,7 @@ Click==7.1.2 coverage==5.2.1 ddt==1.4.1 flake8==3.8.3 -gitpython==3.1.7 +gitpython==3.1.11 mock==3.0.5 pytest==6.0.1; python_version >= '3.5' pytest==4.6.5; python_version < '3.5' From 675c9674e1701cc22763bab2bcd429ac5b82e102 Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Wed, 21 Oct 2020 10:45:41 +0200 Subject: [PATCH 4037/4072] Add Makefile including spec download target Signed-off-by: Ulysses Souza --- Dockerfile | 9 +-- Makefile | 57 +++++++++++++++++++ ...ma_compose_spec.json => compose_spec.json} | 42 ++++++++++---- compose/config/validation.py | 6 +- docker-compose.spec | 4 +- docker-compose_darwin.spec | 4 +- requirements-dev.txt | 1 + tests/unit/config/config_test.py | 4 +- 8 files changed, 104 insertions(+), 23 deletions(-) create mode 100644 Makefile rename compose/config/{config_schema_compose_spec.json => compose_spec.json} (98%) diff --git a/Dockerfile b/Dockerfile index 7c14bc1d6f4..8b8099e104a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -45,13 +45,14 @@ COPY docker-compose-entrypoint.sh /usr/local/bin/ ENTRYPOINT ["sh", "/usr/local/bin/docker-compose-entrypoint.sh"] COPY --from=docker-cli /usr/local/bin/docker /usr/local/bin/docker WORKDIR /code/ -# FIXME(chris-crone): virtualenv 16.3.0 breaks build, force 16.2.0 until fixed -RUN pip install virtualenv==20.0.30 -RUN pip install tox==3.19.0 +RUN pip install \ + virtualenv==20.0.30 \ + tox==3.19.0 +COPY requirements-dev.txt . COPY requirements-indirect.txt . COPY requirements.txt . -COPY requirements-dev.txt . +RUN pip install -r requirements.txt -r requirements-indirect.txt -r requirements-dev.txt COPY .pre-commit-config.yaml . COPY tox.ini . COPY setup.py . diff --git a/Makefile b/Makefile new file mode 100644 index 00000000000..f673eace0a3 --- /dev/null +++ b/Makefile @@ -0,0 +1,57 @@ +TAG = "docker-compose:alpine-$(shell git rev-parse --short HEAD)" +GIT_VOLUME = "--volume=$(shell pwd)/.git:/code/.git" + +DOCKERFILE ?="Dockerfile" +DOCKER_BUILD_TARGET ?="build" + +UNAME_S := $(shell uname -s) +ifeq ($(UNAME_S),Linux) + BUILD_SCRIPT = linux +endif +ifeq ($(UNAME_S),Darwin) + BUILD_SCRIPT = osx +endif + +COMPOSE_SPEC_SCHEMA_PATH = "compose/config/config_schema_compose_spec.json" +COMPOSE_SPEC_RAW_URL = "https://raw.githubusercontent.com/compose-spec/compose-spec/master/schema/compose-spec.json" + +all: cli + +cli: download-compose-spec ## Compile the cli + ./script/build/$(BUILD_SCRIPT) + +download-compose-spec: ## Download the compose-spec schema from it's repo + curl -so $(COMPOSE_SPEC_SCHEMA_PATH) $(COMPOSE_SPEC_RAW_URL) + +cache-clear: ## Clear the builder cache + @docker builder prune --force --filter type=exec.cachemount --filter=unused-for=24h + +base-image: ## Builds base image + docker build -f $(DOCKERFILE) -t $(TAG) --target $(DOCKER_BUILD_TARGET) . + +lint: base-image ## Run linter + docker run --rm \ + --tty \ + $(GIT_VOLUME) \ + $(TAG) \ + tox -e pre-commit + +test-unit: base-image ## Run tests + docker run --rm \ + --tty \ + $(GIT_VOLUME) \ + $(TAG) \ + pytest -v tests/unit/ + +test: ## Run all tests + ./script/test/default + +pre-commit: lint test-unit cli + +help: ## Show help + @echo Please specify a build target. The choices are: + @grep -E '^[0-9a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' + +FORCE: + +.PHONY: all cli download-compose-spec cache-clear base-image lint test-unit test pre-commit help diff --git a/compose/config/config_schema_compose_spec.json b/compose/config/compose_spec.json similarity index 98% rename from compose/config/config_schema_compose_spec.json rename to compose/config/compose_spec.json index 43d3a3edf0f..26825674427 100644 --- a/compose/config/config_schema_compose_spec.json +++ b/compose/config/compose_spec.json @@ -1,14 +1,16 @@ { "$schema": "http://json-schema.org/draft/2019-09/schema#", - "id": "config_schema_compose_spec.json", + "id": "compose_spec.json", "type": "object", "title": "Compose Specification", "description": "The Compose file is a YAML file defining a multi-containers based application.", + "properties": { "version": { "type": "string", "description": "Version of the Compose specification used. Tools not implementing required version MUST reject the configuration file." }, + "services": { "id": "#/properties/services", "type": "object", @@ -19,6 +21,7 @@ }, "additionalProperties": false }, + "networks": { "id": "#/properties/networks", "type": "object", @@ -28,6 +31,7 @@ } } }, + "volumes": { "id": "#/properties/volumes", "type": "object", @@ -38,6 +42,7 @@ }, "additionalProperties": false }, + "secrets": { "id": "#/properties/secrets", "type": "object", @@ -48,6 +53,7 @@ }, "additionalProperties": false }, + "configs": { "id": "#/properties/configs", "type": "object", @@ -59,12 +65,16 @@ "additionalProperties": false } }, + "patternProperties": {"^x-": {}}, "additionalProperties": false, + "definitions": { + "service": { "id": "#/definitions/service", "type": "object", + "properties": { "deploy": {"$ref": "#/definitions/deployment"}, "build": { @@ -190,7 +200,6 @@ "device_cgroup_rules": {"$ref": "#/definitions/list_of_strings"}, "devices": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, "dns": {"$ref": "#/definitions/string_or_list"}, - "dns_opt": {"type": "array","items": {"type": "string"}, "uniqueItems": true}, "dns_search": {"$ref": "#/definitions/string_or_list"}, "domainname": {"type": "string"}, @@ -211,12 +220,12 @@ }, "uniqueItems": true }, - "extends": { "oneOf": [ {"type": "string"}, { "type": "object", + "properties": { "service": {"type": "string"}, "file": {"type": "string"} @@ -245,6 +254,7 @@ "links": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, "logging": { "type": "object", + "properties": { "driver": {"type": "string"}, "options": { @@ -258,7 +268,7 @@ "patternProperties": {"^x-": {}} }, "mac_address": {"type": "string"}, - "mem_limit": {"type": "string"}, + "mem_limit": {"type": ["number", "string"]}, "mem_reservation": {"type": ["string", "integer"]}, "mem_swappiness": {"type": "integer"}, "memswap_limit": {"type": ["number", "string"]}, @@ -425,9 +435,9 @@ "additionalProperties": false, "patternProperties": {"^x-": {}} } - ], - "uniqueItems": true - } + ] + }, + "uniqueItems": true }, "volumes_from": { "type": "array", @@ -558,6 +568,7 @@ "additionalProperties": false, "patternProperties": {"^x-": {}} }, + "generic_resources": { "id": "#/definitions/generic_resources", "type": "array", @@ -578,6 +589,7 @@ "patternProperties": {"^x-": {}} } }, + "network": { "id": "#/definitions/network", "type": ["object", "null"], @@ -607,10 +619,10 @@ "additionalProperties": false, "patternProperties": {"^.+$": {"type": "string"}} } - } - }, - "additionalProperties": false, - "patternProperties": {"^x-": {}} + }, + "additionalProperties": false, + "patternProperties": {"^x-": {}} + } }, "options": { "type": "object", @@ -640,6 +652,7 @@ "additionalProperties": false, "patternProperties": {"^x-": {}} }, + "volume": { "id": "#/definitions/volume", "type": ["object", "null"], @@ -668,6 +681,7 @@ "additionalProperties": false, "patternProperties": {"^x-": {}} }, + "secret": { "id": "#/definitions/secret", "type": "object", @@ -693,6 +707,7 @@ "additionalProperties": false, "patternProperties": {"^x-": {}} }, + "config": { "id": "#/definitions/config", "type": "object", @@ -714,17 +729,20 @@ "additionalProperties": false, "patternProperties": {"^x-": {}} }, + "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": [ { @@ -739,6 +757,7 @@ {"type": "array", "items": {"type": "string"}, "uniqueItems": true} ] }, + "blkio_limit": { "type": "object", "properties": { @@ -755,6 +774,7 @@ }, "additionalProperties": false }, + "constraints": { "service": { "id": "#/definitions/constraints/service", diff --git a/compose/config/validation.py b/compose/config/validation.py index 2b46cafe4f3..d9aaeda4bd2 100644 --- a/compose/config/validation.py +++ b/compose/config/validation.py @@ -502,13 +502,13 @@ def get_schema_path(): def load_jsonschema(version): - suffix = "compose_spec" + name = "compose_spec" if version == V1: - suffix = "v1" + name = "config_schema_v1" filename = os.path.join( get_schema_path(), - "config_schema_{}.json".format(suffix)) + "{}.json".format(name)) if not os.path.exists(filename): raise ConfigurationError( diff --git a/docker-compose.spec b/docker-compose.spec index ff739ab0d88..0c2fa3dec9f 100644 --- a/docker-compose.spec +++ b/docker-compose.spec @@ -23,8 +23,8 @@ exe = EXE(pyz, 'DATA' ), ( - 'compose/config/config_schema_compose_spec.json', - 'compose/config/config_schema_compose_spec.json', + 'compose/config/compose_spec.json', + 'compose/config/compose_spec.json', 'DATA' ), ( diff --git a/docker-compose_darwin.spec b/docker-compose_darwin.spec index 3228884f5df..24889475990 100644 --- a/docker-compose_darwin.spec +++ b/docker-compose_darwin.spec @@ -32,8 +32,8 @@ coll = COLLECT(exe, 'DATA' ), ( - 'compose/config/config_schema_compose_spec.json', - 'compose/config/config_schema_compose_spec.json', + 'compose/config/compose_spec.json', + 'compose/config/compose_spec.json', 'DATA' ), ( diff --git a/requirements-dev.txt b/requirements-dev.txt index 8655e5c0aa1..9cc00c64e85 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -7,3 +7,4 @@ mock==3.0.5 pytest==6.0.1; python_version >= '3.5' pytest==4.6.5; python_version < '3.5' pytest-cov==2.10.1 +PyYAML==5.3.1 diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index b67ecdaf4f0..426a7c97866 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -238,7 +238,9 @@ def test_v1_file_with_version_is_invalid(self): ) ) - assert 'Invalid top-level property "web"' in excinfo.exconly() + assert "compose.config.errors.ConfigurationError: " \ + "The Compose file 'filename.yml' is invalid because:\n" \ + "'web' does not match any of the regexes: '^x-'" in excinfo.exconly() assert VERSION_EXPLANATION in excinfo.exconly() def test_named_volume_config_empty(self): From 3cfccc1d641525d47696dbd22f26725b929339a5 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 2 Nov 2020 22:17:02 +0000 Subject: [PATCH 4038/4072] Bump more-itertools from 8.4.0 to 8.6.0 Bumps [more-itertools](https://github.com/more-itertools/more-itertools) from 8.4.0 to 8.6.0. - [Release notes](https://github.com/more-itertools/more-itertools/releases) - [Commits](https://github.com/more-itertools/more-itertools/compare/v8.4.0...v8.6.0) Signed-off-by: dependabot-preview[bot] --- requirements-indirect.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-indirect.txt b/requirements-indirect.txt index 9d9ae3684d9..900aff73aa7 100644 --- a/requirements-indirect.txt +++ b/requirements-indirect.txt @@ -9,7 +9,7 @@ entrypoints==0.3 filelock==3.0.12 gitdb2==4.0.2 mccabe==0.6.1 -more-itertools==8.4.0; python_version >= '3.5' +more-itertools==8.6.0; python_version >= '3.5' more-itertools==5.0.0; python_version < '3.5' packaging==20.4 pluggy==0.13.1 From f825cec2fc54369fb5e028a13f795fffd2abc7b1 Mon Sep 17 00:00:00 2001 From: Chris Crone Date: Tue, 20 Oct 2020 17:53:47 +0200 Subject: [PATCH 4039/4072] build: Refactor to use BuildKit Signed-off-by: Chris Crone --- Dockerfile | 16 ++++++++++------ Jenkinsfile | 5 ++++- Release.Jenkinsfile | 7 +++++-- script/build/linux | 16 +++++++--------- script/build/test-image | 2 +- 5 files changed, 27 insertions(+), 19 deletions(-) diff --git a/Dockerfile b/Dockerfile index 8b8099e104a..21e090db33c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,7 +5,7 @@ ARG BUILD_DEBIAN_VERSION=slim-stretch ARG RUNTIME_ALPINE_VERSION=3.11.5 ARG RUNTIME_DEBIAN_VERSION=stretch-20200414-slim -ARG BUILD_PLATFORM=alpine +ARG DISTRO=alpine FROM docker:${DOCKER_VERSION} AS docker-cli @@ -40,15 +40,14 @@ RUN apt-get update && apt-get install --no-install-recommends -y \ openssl \ zlib1g-dev -FROM build-${BUILD_PLATFORM} AS build -COPY docker-compose-entrypoint.sh /usr/local/bin/ +FROM build-${DISTRO} AS build ENTRYPOINT ["sh", "/usr/local/bin/docker-compose-entrypoint.sh"] -COPY --from=docker-cli /usr/local/bin/docker /usr/local/bin/docker WORKDIR /code/ +COPY docker-compose-entrypoint.sh /usr/local/bin/ +COPY --from=docker-cli /usr/local/bin/docker /usr/local/bin/docker RUN pip install \ virtualenv==20.0.30 \ tox==3.19.0 - COPY requirements-dev.txt . COPY requirements-indirect.txt . COPY requirements.txt . @@ -64,9 +63,14 @@ ARG GIT_COMMIT=unknown ENV DOCKER_COMPOSE_GITSHA=$GIT_COMMIT RUN script/build/linux-entrypoint +FROM scratch AS bin +ARG TARGETARCH +ARG TARGETOS +COPY --from=build /usr/local/bin/docker-compose /docker-compose-${TARGETOS}-${TARGETARCH} + FROM alpine:${RUNTIME_ALPINE_VERSION} AS runtime-alpine FROM debian:${RUNTIME_DEBIAN_VERSION} AS runtime-debian -FROM runtime-${BUILD_PLATFORM} AS runtime +FROM runtime-${DISTRO} AS runtime COPY docker-compose-entrypoint.sh /usr/local/bin/ ENTRYPOINT ["sh", "/usr/local/bin/docker-compose-entrypoint.sh"] COPY --from=docker-cli /usr/local/bin/docker /usr/local/bin/docker diff --git a/Jenkinsfile b/Jenkinsfile index 127a26c7fa1..4c77091035e 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -13,6 +13,9 @@ pipeline { timeout(time: 2, unit: 'HOURS') timestamps() } + environment { + DOCKER_BUILDKIT="1" + } stages { stage('Build test images') { @@ -69,7 +72,7 @@ def buildImage(baseImage) { ansiColor('xterm') { sh """docker build -t ${imageName} \\ --target build \\ - --build-arg BUILD_PLATFORM="${baseImage}" \\ + --build-arg DISTRO="${baseImage}" \\ --build-arg GIT_COMMIT="${scmvar.GIT_COMMIT}" \\ .\\ """ diff --git a/Release.Jenkinsfile b/Release.Jenkinsfile index 1594d2f275d..91cd8928478 100644 --- a/Release.Jenkinsfile +++ b/Release.Jenkinsfile @@ -13,6 +13,9 @@ pipeline { timeout(time: 2, unit: 'HOURS') timestamps() } + environment { + DOCKER_BUILDKIT="1" + } stages { stage('Build test images') { @@ -229,7 +232,7 @@ def buildImage(baseImage) { ansiColor('xterm') { sh """docker build -t ${imageName} \\ --target build \\ - --build-arg BUILD_PLATFORM="${baseImage}" \\ + --build-arg DISTRO="${baseImage}" \\ --build-arg GIT_COMMIT="${scmvar.GIT_COMMIT}" \\ .\\ """ @@ -276,7 +279,7 @@ def buildRuntimeImage(baseImage) { def imageName = "docker/compose:${baseImage}-${env.BRANCH_NAME}" ansiColor('xterm') { sh """docker build -t ${imageName} \\ - --build-arg BUILD_PLATFORM="${baseImage}" \\ + --build-arg DISTRO="${baseImage}" \\ --build-arg GIT_COMMIT="${scmvar.GIT_COMMIT.take(7)}" \\ . """ diff --git a/script/build/linux b/script/build/linux index ca5620b8527..2e56b625c0f 100755 --- a/script/build/linux +++ b/script/build/linux @@ -5,14 +5,12 @@ set -ex ./script/clean DOCKER_COMPOSE_GITSHA="$(script/build/write-git-sha)" -TAG="docker/compose:tmp-glibc-linux-binary-${DOCKER_COMPOSE_GITSHA}" -docker build -t "${TAG}" . \ - --build-arg BUILD_PLATFORM=debian \ - --build-arg GIT_COMMIT="${DOCKER_COMPOSE_GITSHA}" -TMP_CONTAINER=$(docker create "${TAG}") -mkdir -p dist +docker build . \ + --target bin \ + --build-arg DISTRO=debian \ + --build-arg GIT_COMMIT="${DOCKER_COMPOSE_GITSHA}" \ + --output dist/ ARCH=$(uname -m) -docker cp "${TMP_CONTAINER}":/usr/local/bin/docker-compose "dist/docker-compose-Linux-${ARCH}" -docker container rm -f "${TMP_CONTAINER}" -docker image rm -f "${TAG}" +# Ensure that we output the binary with the same name as we did before +mv dist/docker-compose-linux-amd64 "dist/docker-compose-Linux-${ARCH}" diff --git a/script/build/test-image b/script/build/test-image index 4964a5f9da4..ddb8057d05c 100755 --- a/script/build/test-image +++ b/script/build/test-image @@ -13,6 +13,6 @@ IMAGE="docker/compose-tests" DOCKER_COMPOSE_GITSHA="$(script/build/write-git-sha)" docker build -t "${IMAGE}:${TAG}" . \ --target build \ - --build-arg BUILD_PLATFORM="debian" \ + --build-arg DISTRO="debian" \ --build-arg GIT_COMMIT="${DOCKER_COMPOSE_GITSHA}" docker tag "${IMAGE}":"${TAG}" "${IMAGE}":latest From bf61244f37d498e778a3365ffddaf6d85880a758 Mon Sep 17 00:00:00 2001 From: Chris Crone Date: Tue, 20 Oct 2020 17:54:19 +0200 Subject: [PATCH 4040/4072] build: Add build for CentOS Signed-off-by: Chris Crone --- Dockerfile | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/Dockerfile b/Dockerfile index 21e090db33c..d4c8ea83bdf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,10 @@ ARG DOCKER_VERSION=19.03.8 ARG PYTHON_VERSION=3.7.7 ARG BUILD_ALPINE_VERSION=3.11 +ARG BUILD_CENTOS_VERSION=7 ARG BUILD_DEBIAN_VERSION=slim-stretch ARG RUNTIME_ALPINE_VERSION=3.11.5 +ARG RUNTIME_CENTOS_VERSION=7 ARG RUNTIME_DEBIAN_VERSION=stretch-20200414-slim ARG DISTRO=alpine @@ -40,6 +42,24 @@ RUN apt-get update && apt-get install --no-install-recommends -y \ openssl \ zlib1g-dev +FROM centos:${BUILD_CENTOS_VERSION} AS build-centos +RUN yum install -y \ + gcc \ + git \ + libffi-devel \ + make \ + openssl \ + openssl-devel +WORKDIR /tmp/python3/ +ARG PYTHON_VERSION +RUN curl -L https://www.python.org/ftp/python/${PYTHON_VERSION}/Python-${PYTHON_VERSION}.tgz | tar xzf - \ + && cd Python-${PYTHON_VERSION} \ + && ./configure --enable-optimizations --enable-shared --prefix=/usr LDFLAGS="-Wl,-rpath /usr/lib" \ + && make altinstall +RUN alternatives --install /usr/bin/python python /usr/bin/python2.7 50 +RUN alternatives --install /usr/bin/python python /usr/bin/python3.7 60 +RUN curl https://bootstrap.pypa.io/get-pip.py | python - + FROM build-${DISTRO} AS build ENTRYPOINT ["sh", "/usr/local/bin/docker-compose-entrypoint.sh"] WORKDIR /code/ @@ -70,6 +90,7 @@ COPY --from=build /usr/local/bin/docker-compose /docker-compose-${TARGETOS}-${TA FROM alpine:${RUNTIME_ALPINE_VERSION} AS runtime-alpine FROM debian:${RUNTIME_DEBIAN_VERSION} AS runtime-debian +FROM centos:${RUNTIME_CENTOS_VERSION} AS runtime-centos FROM runtime-${DISTRO} AS runtime COPY docker-compose-entrypoint.sh /usr/local/bin/ ENTRYPOINT ["sh", "/usr/local/bin/docker-compose-entrypoint.sh"] From ea28d8edac9f2da20e73832d97d9c1983c624d39 Mon Sep 17 00:00:00 2001 From: Chris Crone Date: Wed, 4 Nov 2020 13:25:53 +0100 Subject: [PATCH 4041/4072] readme: Simplify and add cloud deployment Signed-off-by: Chris Crone --- README.md | 102 +++++++++++++++++++++++++++++++++--------------------- 1 file changed, 63 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index c9ea28f710f..d0d23d8af60 100644 --- a/README.md +++ b/README.md @@ -1,62 +1,86 @@ Docker Compose ============== +[![Build Status](https://ci-next.docker.com/public/buildStatus/icon?job=compose/master)](https://ci-next.docker.com/public/job/compose/job/master/) + ![Docker Compose](logo.png?raw=true "Docker Compose Logo") -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/docker.github.io/blob/master/compose/index.md#features). +Docker Compose is a tool for running multi-container applications on Docker +defined using the [Compose file format](https://compose-spec.io). +A Compose file is used to define how the one or more containers that make up +your application are configured. +Once you have a Compose file, you can create and start your application with a +single command: `docker-compose up`. -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/docker.github.io/blob/master/compose/index.md#common-use-cases). +Compose files can be used to deploy applications locally, or to the cloud on +[Amazon ECS](https://aws.amazon.com/ecs) or +[Microsoft ACI](https://azure.microsoft.com/services/container-instances/) using +the Docker CLI. You can read more about how to do this: +- [Compose for Amazon ECS](https://docs.docker.com/engine/context/ecs-integration/) +- [Compose for Microsoft ACI](https://docs.docker.com/engine/context/aci-integration/) -Using Compose is basically a three-step process. +Where to get Docker Compose +---------------------------- -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. +### Windows and macOS -A `docker-compose.yml` looks like this: +Docker Compose is included in +[Docker Desktop](https://www.docker.com/products/docker-desktop) +for Windows and macOS. - version: '2' +### Linux - services: - web: - build: . - ports: - - "5000:5000" - volumes: - - .:/code - redis: - image: redis +You can download Docker Compose binaries from the +[release page](https://github.com/docker/compose/releases) on this repository. -For more information about the Compose file, see the -[Compose file reference](https://github.com/docker/docker.github.io/blob/master/compose/compose-file/compose-versioning.md). +### Using pip -Compose has commands for managing the whole lifecycle of your application: +If your platform is not supported, you can download Docker Compose using `pip`: - * 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 +```console +pip install docker-compose +``` -Installation and documentation ------------------------------- +> **Note:** Docker Compose requires Python 3.6 or later. -- Full documentation is available on [Docker's website](https://docs.docker.com/compose/). -- Code repository for Compose is on [GitHub](https://github.com/docker/compose). -- If you find any problems please fill out an [issue](https://github.com/docker/compose/issues/new/choose). Thank you! +Quick Start +----------- + +Using Docker 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 Compose file looks like this: + +```yaml +services: + web: + build: . + ports: + - "5000:5000" + volumes: + - .:/code + redis: + image: redis +``` + +You can find examples of Compose applications in our +[Awesome Compose repository](https://github.com/docker/awesome-compose). + +For more information about the Compose format, see the +[Compose file reference](https://docs.docker.com/compose/compose-file/). Contributing ------------ -[![Build Status](https://ci-next.docker.com/public/buildStatus/icon?job=compose/master)](https://ci-next.docker.com/public/job/compose/job/master/) +Want to help develop Docker Compose? Check out our +[contributing documentation](https://github.com/docker/compose/blob/master/CONTRIBUTING.md). -Want to help build Compose? Check out our [contributing documentation](https://github.com/docker/compose/blob/master/CONTRIBUTING.md). +If you find an issue, please report it on the +[issue tracker](https://github.com/docker/compose/issues/new/choose). Releasing --------- From 3ebfa4b089448968d168593c32e3544f2e6e3c5b Mon Sep 17 00:00:00 2001 From: Mark Gallagher Date: Fri, 13 Nov 2020 01:50:59 +0000 Subject: [PATCH 4042/4072] Remove duplicate values check for build.cache_from The `docker` command accepts duplicate values, so there is no benefit to performing this check. Fixes #7342. Signed-off-by: Mark Gallagher --- compose/config/compose_spec.json | 2 +- tests/unit/config/config_test.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/compose/config/compose_spec.json b/compose/config/compose_spec.json index 26825674427..38cc27fd204 100644 --- a/compose/config/compose_spec.json +++ b/compose/config/compose_spec.json @@ -87,7 +87,7 @@ "dockerfile": {"type": "string"}, "args": {"$ref": "#/definitions/list_or_dict"}, "labels": {"$ref": "#/definitions/list_or_dict"}, - "cache_from": {"$ref": "#/definitions/list_of_strings"}, + "cache_from": {"type": "array", "items": {"type": "string"}}, "network": {"type": "string"}, "target": {"type": "string"}, "shm_size": {"type": ["integer", "string"]}, diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 426a7c97866..ffc16e0853d 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -669,7 +669,7 @@ def test_config_invalid_service_name_raise_validation_error(self): assert 'Invalid service name \'mong\\o\'' in excinfo.exconly() - def test_config_duplicate_cache_from_values_validation_error(self): + def test_config_duplicate_cache_from_values_no_validation_error(self): with pytest.raises(ConfigurationError) as exc: config.load( build_config_details({ @@ -681,7 +681,7 @@ def test_config_duplicate_cache_from_values_validation_error(self): }) ) - assert 'build.cache_from contains non-unique items' in exc.exconly() + assert 'build.cache_from contains non-unique items' not in exc.exconly() def test_load_with_multiple_files_v1(self): base_file = config.ConfigFile( From 854c003359bd07d0d3ca137d7a08509cfeab0436 Mon Sep 17 00:00:00 2001 From: aiordache Date: Fri, 23 Oct 2020 11:45:40 +0200 Subject: [PATCH 4043/4072] Implement device requests for GPU support Signed-off-by: aiordache --- compose/config/compose_spec.json | 20 +++++++++++++++++++- compose/project.py | 28 +++++++++++++++++++++++++++- compose/service.py | 4 ++++ 3 files changed, 50 insertions(+), 2 deletions(-) diff --git a/compose/config/compose_spec.json b/compose/config/compose_spec.json index 26825674427..0ecb3c69e3c 100644 --- a/compose/config/compose_spec.json +++ b/compose/config/compose_spec.json @@ -524,7 +524,8 @@ "properties": { "cpus": {"type": ["number", "string"]}, "memory": {"type": "string"}, - "generic_resources": {"$ref": "#/definitions/generic_resources"} + "generic_resources": {"$ref": "#/definitions/generic_resources"}, + "devices": {"$ref": "#/definitions/devices"} }, "additionalProperties": false, "patternProperties": {"^x-": {}} @@ -590,6 +591,23 @@ } }, + "devices": { + "id": "#/definitions/devices", + "type": "array", + "items": { + "type": "object", + "properties": { + "capabilities": {"$ref": "#/definitions/list_of_strings"}, + "count": {"type": ["string", "integer"]}, + "device_ids": {"$ref": "#/definitions/list_of_strings"}, + "driver":{"type": "string"}, + "options":{"$ref": "#/definitions/list_or_dict"} + }, + "additionalProperties": false, + "patternProperties": {"^x-": {}} + } + }, + "network": { "id": "#/definitions/network", "type": ["object", "null"], diff --git a/compose/project.py b/compose/project.py index 420cb6548bc..900487d4f23 100644 --- a/compose/project.py +++ b/compose/project.py @@ -128,7 +128,7 @@ def from_config(cls, name, config_data, client, default_platform=None, extra_lab config_data.secrets) service_dict['scale'] = project.get_service_scale(service_dict) - + device_requests = project.get_device_requests(service_dict) service_dict = translate_credential_spec_to_security_opt(service_dict) service_dict, ignored_keys = translate_deploy_keys_to_container_config( service_dict @@ -154,6 +154,7 @@ def from_config(cls, name, config_data, client, default_platform=None, extra_lab ipc_mode=ipc_mode, platform=service_dict.pop('platform', None), default_platform=default_platform, + device_requests=device_requests, extra_labels=extra_labels, **service_dict) ) @@ -331,6 +332,31 @@ def get_service_scale(self, service_dict): max_replicas)) return scale + def get_device_requests(self, service_dict): + deploy_dict = service_dict.get('deploy', None) + if not deploy_dict: + return + + resources = deploy_dict.get('resources', None) + if not resources or not resources.get('reservations', None): + return + devices = resources['reservations'].get('devices') + if not devices: + return + + for dev in devices: + count = dev.get("count", -1) + if not isinstance(count, int): + if count != "all": + raise ConfigurationError( + 'Invalid value "{}" for devices count'.format(dev["count"]), + '(expected integer or "all")') + dev["count"] = -1 + + if 'capabilities' in dev: + dev['capabilities'] = [dev['capabilities']] + return devices + def start(self, service_names=None, **options): containers = [] diff --git a/compose/service.py b/compose/service.py index a1a500cb213..e00a537cfce 100644 --- a/compose/service.py +++ b/compose/service.py @@ -77,6 +77,7 @@ 'cpuset', 'device_cgroup_rules', 'devices', + 'device_requests', 'dns', 'dns_search', 'dns_opt', @@ -180,6 +181,7 @@ def __init__( pid_mode=None, default_platform=None, extra_labels=None, + device_requests=None, **options ): self.name = name @@ -195,6 +197,7 @@ def __init__( self.secrets = secrets or [] self.scale_num = scale self.default_platform = default_platform + self.device_requests = device_requests self.options = options self.extra_labels = extra_labels or [] @@ -1016,6 +1019,7 @@ def _get_container_host_config(self, override_options, one_off=False): privileged=options.get('privileged', False), network_mode=self.network_mode.mode, devices=options.get('devices'), + device_requests=self.device_requests, dns=options.get('dns'), dns_opt=options.get('dns_opt'), dns_search=options.get('dns_search'), From e28c948f34a96fba9b3ba957eec34b434725418b Mon Sep 17 00:00:00 2001 From: aiordache Date: Mon, 19 Oct 2020 17:12:53 +0200 Subject: [PATCH 4044/4072] Shell out to ssh client for ssh connections Signed-off-by: aiordache --- compose/cli/docker_client.py | 4 ++-- requirements.txt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/compose/cli/docker_client.py b/compose/cli/docker_client.py index b65262344e0..e4a0fea61b4 100644 --- a/compose/cli/docker_client.py +++ b/compose/cli/docker_client.py @@ -166,8 +166,8 @@ def docker_client(environment, version=None, context=None, tls_version=None): kwargs['credstore_env'] = { 'LD_LIBRARY_PATH': environment.get('LD_LIBRARY_PATH_ORIG'), } - - client = APIClient(**kwargs) + use_paramiko_ssh = int(environment.get('COMPOSE_PARAMIKO_SSH', 0)) + client = APIClient(use_ssh_client=not use_paramiko_ssh, **kwargs) client._original_base_url = kwargs.get('base_url') return client diff --git a/requirements.txt b/requirements.txt index 7de88eefc37..b2f6fc7046d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ certifi==2020.6.20 chardet==3.0.4 colorama==0.4.3; sys_platform == 'win32' distro==1.5.0 -docker==4.3.1 +docker==4.4.0 docker-pycreds==0.4.0 dockerpty==0.4.1 docopt==0.6.2 From 8785279ffd40d04cae409a1d3a94aebd94f2e199 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 23 Nov 2020 22:16:04 +0000 Subject: [PATCH 4045/4072] Bump virtualenv from 20.0.30 to 20.2.1 Bumps [virtualenv](https://github.com/pypa/virtualenv) from 20.0.30 to 20.2.1. - [Release notes](https://github.com/pypa/virtualenv/releases) - [Changelog](https://github.com/pypa/virtualenv/blob/main/docs/changelog.rst) - [Commits](https://github.com/pypa/virtualenv/compare/20.0.30...20.2.1) Signed-off-by: dependabot-preview[bot] --- requirements-indirect.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-indirect.txt b/requirements-indirect.txt index 9d9ae3684d9..a32f80b2264 100644 --- a/requirements-indirect.txt +++ b/requirements-indirect.txt @@ -24,5 +24,5 @@ smmap==3.0.4 smmap2==3.0.1 toml==0.10.1 tox==3.19.0 -virtualenv==20.0.30 +virtualenv==20.2.1 wcwidth==0.2.5 From a3e6e28eeb834435eca5a7e96a8016c7314c603e Mon Sep 17 00:00:00 2001 From: Chris Crone Date: Wed, 4 Nov 2020 16:22:39 +0100 Subject: [PATCH 4046/4072] deps: Bump Python, Docker, base images Signed-off-by: Chris Crone --- .pre-commit-config.yaml | 2 +- Dockerfile | 18 ++++++++++-------- Jenkinsfile | 4 ++-- Release.Jenkinsfile | 8 ++++---- requirements-build.txt | 2 +- requirements-indirect.txt | 2 +- script/build/linux-entrypoint | 4 ++-- script/build/windows.ps1 | 6 +++--- script/setup/osx | 8 ++++---- script/test/all | 4 ++-- setup.py | 2 ++ tox.ini | 2 +- 12 files changed, 33 insertions(+), 29 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 05cd5202658..45f6f6fcf1f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -17,7 +17,7 @@ sha: v1.3.4 hooks: - id: reorder-python-imports - language_version: 'python3.7' + language_version: 'python3.9' args: - --py3-plus - repo: https://github.com/asottile/pyupgrade diff --git a/Dockerfile b/Dockerfile index d4c8ea83bdf..fd9fd45c149 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,13 @@ -ARG DOCKER_VERSION=19.03.8 -ARG PYTHON_VERSION=3.7.7 -ARG BUILD_ALPINE_VERSION=3.11 +ARG DOCKER_VERSION=19.03 +ARG PYTHON_VERSION=3.9.0 + +ARG BUILD_ALPINE_VERSION=3.12 ARG BUILD_CENTOS_VERSION=7 -ARG BUILD_DEBIAN_VERSION=slim-stretch -ARG RUNTIME_ALPINE_VERSION=3.11.5 +ARG BUILD_DEBIAN_VERSION=slim-buster + +ARG RUNTIME_ALPINE_VERSION=3.12 ARG RUNTIME_CENTOS_VERSION=7 -ARG RUNTIME_DEBIAN_VERSION=stretch-20200414-slim +ARG RUNTIME_DEBIAN_VERSION=buster-slim ARG DISTRO=alpine @@ -36,7 +38,7 @@ RUN apt-get update && apt-get install --no-install-recommends -y \ git \ libc-dev \ libffi-dev \ - libgcc-6-dev \ + libgcc-8-dev \ libssl-dev \ make \ openssl \ @@ -57,7 +59,7 @@ RUN curl -L https://www.python.org/ftp/python/${PYTHON_VERSION}/Python-${PYTHON_ && ./configure --enable-optimizations --enable-shared --prefix=/usr LDFLAGS="-Wl,-rpath /usr/lib" \ && make altinstall RUN alternatives --install /usr/bin/python python /usr/bin/python2.7 50 -RUN alternatives --install /usr/bin/python python /usr/bin/python3.7 60 +RUN alternatives --install /usr/bin/python python /usr/bin/python$(echo "${PYTHON_VERSION}" | cut -c1-3) 60 RUN curl https://bootstrap.pypa.io/get-pip.py | python - FROM build-${DISTRO} AS build diff --git a/Jenkinsfile b/Jenkinsfile index 4c77091035e..40629216a38 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -1,8 +1,8 @@ #!groovy -def dockerVersions = ['19.03.8'] +def dockerVersions = ['19.03.13'] def baseImages = ['alpine', 'debian'] -def pythonVersions = ['py37'] +def pythonVersions = ['py39'] pipeline { agent none diff --git a/Release.Jenkinsfile b/Release.Jenkinsfile index 91cd8928478..32c66c42744 100644 --- a/Release.Jenkinsfile +++ b/Release.Jenkinsfile @@ -1,8 +1,8 @@ #!groovy -def dockerVersions = ['19.03.8', '18.09.9'] +def dockerVersions = ['19.03.13', '18.09.9'] def baseImages = ['alpine', 'debian'] -def pythonVersions = ['py37'] +def pythonVersions = ['py39'] pipeline { agent none @@ -84,7 +84,7 @@ pipeline { steps { checkout scm sh './script/setup/osx' - sh 'tox -e py37 -- tests/unit' + sh 'tox -e py39 -- tests/unit' sh './script/build/osx' dir ('dist') { checksum('docker-compose-Darwin-x86_64') @@ -121,7 +121,7 @@ pipeline { } steps { checkout scm - bat 'tox.exe -e py37 -- tests/unit' + bat 'tox.exe -e py39 -- tests/unit' powershell '.\\script\\build\\windows.ps1' dir ('dist') { checksum('docker-compose-Windows-x86_64.exe') diff --git a/requirements-build.txt b/requirements-build.txt index 9126f8af9d8..9ca8d66679a 100644 --- a/requirements-build.txt +++ b/requirements-build.txt @@ -1 +1 @@ -pyinstaller==3.6 +pyinstaller==4.1 diff --git a/requirements-indirect.txt b/requirements-indirect.txt index 9d9ae3684d9..17e8841f801 100644 --- a/requirements-indirect.txt +++ b/requirements-indirect.txt @@ -3,7 +3,7 @@ appdirs==1.4.4 attrs==20.1.0 bcrypt==3.1.7 cffi==1.14.1 -cryptography==3.0 +cryptography==3.2 distlib==0.3.1 entrypoints==0.3 filelock==3.0.12 diff --git a/script/build/linux-entrypoint b/script/build/linux-entrypoint index d75b9927d09..a0e86ee261a 100755 --- a/script/build/linux-entrypoint +++ b/script/build/linux-entrypoint @@ -3,7 +3,7 @@ set -ex CODE_PATH=/code -VENV="${CODE_PATH}"/.tox/py37 +VENV="${CODE_PATH}"/.tox/py39 cd "${CODE_PATH}" mkdir -p dist @@ -24,7 +24,7 @@ if [ ! -z "${BUILD_BOOTLOADER}" ]; then git clone --single-branch --branch develop https://github.com/pyinstaller/pyinstaller.git /tmp/pyinstaller cd /tmp/pyinstaller/bootloader # Checkout commit corresponding to version in requirements-build - git checkout v3.6 + git checkout v4.1 "${VENV}"/bin/python3 ./waf configure --no-lsb all "${VENV}"/bin/pip3 install .. cd "${CODE_PATH}" diff --git a/script/build/windows.ps1 b/script/build/windows.ps1 index 2778cc88447..120cab33a04 100644 --- a/script/build/windows.ps1 +++ b/script/build/windows.ps1 @@ -6,11 +6,11 @@ # # http://git-scm.com/download/win # -# 2. Install Python 3.7.x: +# 2. Install Python 3.9.x: # # https://www.python.org/downloads/ # -# 3. Append ";C:\Python37;C:\Python37\Scripts" to the "Path" environment variable: +# 3. Append ";C:\Python39;C:\Python39\Scripts" to the "Path" environment variable: # # https://www.microsoft.com/resources/documentation/windows/xp/all/proddocs/en-us/sysdm_advancd_environmnt_addchange_variable.mspx?mfr=true # @@ -39,7 +39,7 @@ if (Test-Path venv) { Get-ChildItem -Recurse -Include *.pyc | foreach ($_) { Remove-Item $_.FullName } # Create virtualenv -virtualenv -p C:\Python37\python.exe .\venv +virtualenv -p C:\Python39\python.exe .\venv # pip and pyinstaller generate lots of warnings, so we need to ignore them $ErrorActionPreference = "Continue" diff --git a/script/setup/osx b/script/setup/osx index 44ec4adcc94..108ffd3e1b2 100755 --- a/script/setup/osx +++ b/script/setup/osx @@ -13,13 +13,13 @@ if ! [ ${DEPLOYMENT_TARGET} == "$(macos_version)" ]; then SDK_SHA1=dd228a335194e3392f1904ce49aff1b1da26ca62 fi -OPENSSL_VERSION=1.1.1g +OPENSSL_VERSION=1.1.1h OPENSSL_URL=https://www.openssl.org/source/openssl-${OPENSSL_VERSION}.tar.gz -OPENSSL_SHA1=b213a293f2127ec3e323fb3cfc0c9807664fd997 +OPENSSL_SHA1=8d0d099e8973ec851368c8c775e05e1eadca1794 -PYTHON_VERSION=3.7.7 +PYTHON_VERSION=3.9.0 PYTHON_URL=https://www.python.org/ftp/python/${PYTHON_VERSION}/Python-${PYTHON_VERSION}.tgz -PYTHON_SHA1=8e9968663a214aea29659ba9dfa959e8a7d82b39 +PYTHON_SHA1=5744a10ba989d2badacbab3c00cdcb83c83106c7 # # Install prerequisites. diff --git a/script/test/all b/script/test/all index 57b76d5a988..64a5a99f24f 100755 --- a/script/test/all +++ b/script/test/all @@ -11,7 +11,7 @@ docker run --rm \ "$TAG" tox -e pre-commit get_versions="docker run --rm - --entrypoint=/code/.tox/py37/bin/python + --entrypoint=/code/.tox/py39/bin/python $TAG /code/script/test/versions.py docker/docker-ce,moby/moby" @@ -23,7 +23,7 @@ fi BUILD_NUMBER=${BUILD_NUMBER-$USER} -PY_TEST_VERSIONS=${PY_TEST_VERSIONS:-py37} +PY_TEST_VERSIONS=${PY_TEST_VERSIONS:-py39} for version in $DOCKER_VERSIONS; do >&2 echo "Running tests against Docker $version" diff --git a/setup.py b/setup.py index e0d4340e5f6..712a862d869 100644 --- a/setup.py +++ b/setup.py @@ -102,5 +102,7 @@ def find_version(*file_paths): 'Programming Language :: Python :: 3.4', 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', ], ) diff --git a/tox.ini b/tox.ini index 021c4c0f9cf..174fd5ccdc9 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py37,pre-commit +envlist = py39,pre-commit [testenv] usedevelop=True From b187f19f94ca8a8d447ea1f767bf6c3662f9cb98 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Thu, 26 Nov 2020 18:48:15 +0000 Subject: [PATCH 4047/4072] Bump cryptography from 3.2 to 3.2.1 Bumps [cryptography](https://github.com/pyca/cryptography) from 3.2 to 3.2.1. - [Release notes](https://github.com/pyca/cryptography/releases) - [Changelog](https://github.com/pyca/cryptography/blob/master/CHANGELOG.rst) - [Commits](https://github.com/pyca/cryptography/compare/3.2...3.2.1) Signed-off-by: dependabot-preview[bot] --- requirements-indirect.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-indirect.txt b/requirements-indirect.txt index 17e8841f801..3d1cca37c6c 100644 --- a/requirements-indirect.txt +++ b/requirements-indirect.txt @@ -3,7 +3,7 @@ appdirs==1.4.4 attrs==20.1.0 bcrypt==3.1.7 cffi==1.14.1 -cryptography==3.2 +cryptography==3.2.1 distlib==0.3.1 entrypoints==0.3 filelock==3.0.12 From 2d2a8a0469cb82dc46554bdc3dfd6663bb0c4ca9 Mon Sep 17 00:00:00 2001 From: Roman Anasal Date: Sun, 15 Nov 2020 16:07:54 +0100 Subject: [PATCH 4048/4072] Implement service profiles Implement profiles as introduced in compose-spec/compose-spec#110 fixes #7919 closes #1896 closes #6742 closes #7539 Signed-off-by: Roman Anasal --- compose/cli/command.py | 18 ++++- compose/cli/main.py | 3 +- compose/config/compose_spec.json | 1 + compose/config/config.py | 3 +- compose/project.py | 56 ++++++++++--- compose/service.py | 18 +++++ tests/acceptance/cli_test.py | 92 ++++++++++++++++++++++ tests/fixtures/profiles/docker-compose.yml | 20 +++++ tests/fixtures/profiles/merge-profiles.yml | 5 ++ 9 files changed, 203 insertions(+), 13 deletions(-) create mode 100644 tests/fixtures/profiles/docker-compose.yml create mode 100644 tests/fixtures/profiles/merge-profiles.yml diff --git a/compose/cli/command.py b/compose/cli/command.py index d471e78df1a..ee991309ad5 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -66,7 +66,8 @@ def project_from_options(project_dir, options, additional_options=None): environment=environment, override_dir=override_dir, interpolate=(not additional_options.get('--no-interpolate')), - environment_file=environment_file + environment_file=environment_file, + enabled_profiles=get_profiles_from_options(options, environment) ) @@ -115,9 +116,21 @@ def unicode_paths(paths): return None +def get_profiles_from_options(options, environment): + profile_option = options.get('--profile') + if profile_option: + return profile_option + + profiles = environment.get('COMPOSE_PROFILE') + if profiles: + return profiles.split(',') + + return [] + + def get_project(project_dir, config_path=None, project_name=None, verbose=False, context=None, environment=None, override_dir=None, - interpolate=True, environment_file=None): + interpolate=True, environment_file=None, enabled_profiles=None): if not environment: environment = Environment.from_env_file(project_dir) config_details = config.find(project_dir, config_path, environment, override_dir) @@ -139,6 +152,7 @@ def get_project(project_dir, config_path=None, project_name=None, verbose=False, client, environment.get('DOCKER_DEFAULT_PLATFORM'), execution_context_labels(config_details, environment_file), + enabled_profiles, ) diff --git a/compose/cli/main.py b/compose/cli/main.py index 6776dae9e5f..21399a17bb6 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -182,7 +182,7 @@ class TopLevelCommand: """Define and run multi-container applications with Docker. Usage: - docker-compose [-f ...] [options] [--] [COMMAND] [ARGS...] + docker-compose [-f ...] [--profile ...] [options] [--] [COMMAND] [ARGS...] docker-compose -h|--help Options: @@ -190,6 +190,7 @@ class TopLevelCommand: (default: docker-compose.yml) -p, --project-name NAME Specify an alternate project name (default: directory name) + --profile NAME Specify a profile to enable -c, --context NAME Specify a context name --verbose Show more output --log-level LEVEL Set log level (DEBUG, INFO, WARNING, ERROR, CRITICAL) diff --git a/compose/config/compose_spec.json b/compose/config/compose_spec.json index 0ecb3c69e3c..7cc18a17273 100644 --- a/compose/config/compose_spec.json +++ b/compose/config/compose_spec.json @@ -328,6 +328,7 @@ "uniqueItems": true }, "privileged": {"type": "boolean"}, + "profiles": {"$ref": "#/definitions/list_of_strings"}, "pull_policy": {"type": "string", "enum": [ "always", "never", "if_not_present" ]}, diff --git a/compose/config/config.py b/compose/config/config.py index 1b067e78877..eee6b12dd7b 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -133,6 +133,7 @@ 'logging', 'network_mode', 'platform', + 'profiles', 'scale', 'stop_grace_period', ] @@ -1047,7 +1048,7 @@ def merge_service_dicts(base, override, version): for field in [ 'cap_add', 'cap_drop', 'expose', 'external_links', - 'volumes_from', 'device_cgroup_rules', + 'volumes_from', 'device_cgroup_rules', 'profiles', ]: md.merge_field(field, merge_unique_items_lists, default=[]) diff --git a/compose/project.py b/compose/project.py index 900487d4f23..49469c1a6a7 100644 --- a/compose/project.py +++ b/compose/project.py @@ -68,13 +68,15 @@ class Project: """ A collection of services. """ - def __init__(self, name, services, client, networks=None, volumes=None, config_version=None): + def __init__(self, name, services, client, networks=None, volumes=None, config_version=None, + enabled_profiles=None): self.name = name self.services = services self.client = client self.volumes = volumes or ProjectVolumes({}) self.networks = networks or ProjectNetworks({}, False) self.config_version = config_version + self.enabled_profiles = enabled_profiles or [] def labels(self, one_off=OneOffFilter.exclude, legacy=False): name = self.name @@ -86,7 +88,8 @@ def labels(self, one_off=OneOffFilter.exclude, legacy=False): return labels @classmethod - def from_config(cls, name, config_data, client, default_platform=None, extra_labels=None): + def from_config(cls, name, config_data, client, default_platform=None, extra_labels=None, + enabled_profiles=None): """ Construct a Project from a config.Config object. """ @@ -98,7 +101,7 @@ def from_config(cls, name, config_data, client, default_platform=None, extra_lab networks, use_networking) volumes = ProjectVolumes.from_config(name, config_data, client) - project = cls(name, [], client, project_networks, volumes, config_data.version) + project = cls(name, [], client, project_networks, volumes, config_data.version, enabled_profiles) for service_dict in config_data.services: service_dict = dict(service_dict) @@ -186,7 +189,7 @@ def validate_service_names(self, service_names): if name not in valid_names: raise NoSuchService(name) - def get_services(self, service_names=None, include_deps=False): + def get_services(self, service_names=None, include_deps=False, auto_enable_profiles=True): """ Returns a list of this project's services filtered by the provided list of names, or all services if service_names is None @@ -199,15 +202,36 @@ def get_services(self, service_names=None, include_deps=False): reordering as needed to resolve dependencies. Raises NoSuchService if any of the named services do not exist. + + Raises ConfigurationError if any service depended on is not enabled by active profiles """ + # create a copy so we can *locally* add auto-enabled profiles later + enabled_profiles = self.enabled_profiles.copy() + if service_names is None or len(service_names) == 0: - service_names = self.service_names + auto_enable_profiles = False + service_names = [ + service.name + for service in self.services + if service.enabled_for_profiles(enabled_profiles) + ] unsorted = [self.get_service(name) for name in service_names] services = [s for s in self.services if s in unsorted] + if auto_enable_profiles: + # enable profiles of explicitly targeted services + for service in services: + for profile in service.get_profiles(): + if profile not in enabled_profiles: + enabled_profiles.append(profile) + if include_deps: - services = reduce(self._inject_deps, services, []) + services = reduce( + lambda acc, s: self._inject_deps(acc, s, enabled_profiles), + services, + [] + ) uniques = [] [uniques.append(s) for s in services if s not in uniques] @@ -438,10 +462,12 @@ def down( self.remove_images(remove_image_type) def remove_images(self, remove_image_type): - for service in self.get_services(): + for service in self.services: service.remove_image(remove_image_type) def restart(self, service_names=None, **options): + # filter service_names by enabled profiles + service_names = [s.name for s in self.get_services(service_names)] containers = self.containers(service_names, stopped=True) parallel.parallel_execute( @@ -856,14 +882,26 @@ def _find(): ) ) - def _inject_deps(self, acc, service): + def _inject_deps(self, acc, service, enabled_profiles): dep_names = service.get_dependency_names() if len(dep_names) > 0: dep_services = self.get_services( service_names=list(set(dep_names)), - include_deps=True + include_deps=True, + auto_enable_profiles=False ) + + for dep in dep_services: + if not dep.enabled_for_profiles(enabled_profiles): + raise ConfigurationError( + 'Service "{dep_name}" was pulled in as a dependency of ' + 'service "{service_name}" but is not enabled by the ' + 'active profiles. ' + 'You may fix this by adding a common profile to ' + '"{dep_name}" and "{service_name}".' + .format(dep_name=dep.name, service_name=service.name) + ) else: dep_services = [] diff --git a/compose/service.py b/compose/service.py index e00a537cfce..bb55fee56c8 100644 --- a/compose/service.py +++ b/compose/service.py @@ -1331,6 +1331,24 @@ def _parse_proxy_config(self): return result + def get_profiles(self): + if 'profiles' not in self.options: + return [] + + return self.options.get('profiles') + + def enabled_for_profiles(self, enabled_profiles): + # if service has no profiles specified it is always enabled + if 'profiles' not in self.options: + return True + + service_profiles = self.options.get('profiles') + for profile in enabled_profiles: + if profile in service_profiles: + return True + + return False + 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 a7ccd148940..2ff51573982 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1719,6 +1719,98 @@ def test_up_with_ipc_mode(self): shareable_mode_container = self.project.get_service('shareable').containers()[0] assert shareable_mode_container.get('HostConfig.IpcMode') == 'shareable' + def test_profiles_up_with_no_profile(self): + self.base_dir = 'tests/fixtures/profiles' + self.dispatch(['up']) + + containers = self.project.containers(stopped=True) + service_names = [c.service for c in containers] + + assert 'foo' in service_names + assert len(containers) == 1 + + def test_profiles_up_with_profile(self): + self.base_dir = 'tests/fixtures/profiles' + self.dispatch(['--profile', 'test', 'up']) + + containers = self.project.containers(stopped=True) + service_names = [c.service for c in containers] + + assert 'foo' in service_names + assert 'bar' in service_names + assert 'baz' in service_names + assert len(containers) == 3 + + def test_profiles_up_invalid_dependency(self): + self.base_dir = 'tests/fixtures/profiles' + result = self.dispatch(['--profile', 'debug', 'up'], returncode=1) + + assert ('Service "bar" was pulled in as a dependency of service "zot" ' + 'but is not enabled by the active profiles.') in result.stderr + + def test_profiles_up_with_multiple_profiles(self): + self.base_dir = 'tests/fixtures/profiles' + self.dispatch(['--profile', 'debug', '--profile', 'test', 'up']) + + containers = self.project.containers(stopped=True) + service_names = [c.service for c in containers] + + assert 'foo' in service_names + assert 'bar' in service_names + assert 'baz' in service_names + assert 'zot' in service_names + assert len(containers) == 4 + + def test_profiles_up_with_profile_enabled_by_service(self): + self.base_dir = 'tests/fixtures/profiles' + self.dispatch(['up', 'bar']) + + containers = self.project.containers(stopped=True) + service_names = [c.service for c in containers] + + assert 'bar' in service_names + assert len(containers) == 1 + + def test_profiles_up_with_dependency_and_profile_enabled_by_service(self): + self.base_dir = 'tests/fixtures/profiles' + self.dispatch(['up', 'baz']) + + containers = self.project.containers(stopped=True) + service_names = [c.service for c in containers] + + assert 'bar' in service_names + assert 'baz' in service_names + assert len(containers) == 2 + + def test_profiles_up_with_invalid_dependency_for_target_service(self): + self.base_dir = 'tests/fixtures/profiles' + result = self.dispatch(['up', 'zot'], returncode=1) + + assert ('Service "bar" was pulled in as a dependency of service "zot" ' + 'but is not enabled by the active profiles.') in result.stderr + + def test_profiles_up_with_profile_for_dependency(self): + self.base_dir = 'tests/fixtures/profiles' + self.dispatch(['--profile', 'test', 'up', 'zot']) + + containers = self.project.containers(stopped=True) + service_names = [c.service for c in containers] + + assert 'bar' in service_names + assert 'zot' in service_names + assert len(containers) == 2 + + def test_profiles_up_with_merged_profiles(self): + self.base_dir = 'tests/fixtures/profiles' + self.dispatch(['-f', 'docker-compose.yml', '-f', 'merge-profiles.yml', 'up', 'zot']) + + containers = self.project.containers(stopped=True) + service_names = [c.service for c in containers] + + assert 'bar' in service_names + assert 'zot' in service_names + assert len(containers) == 2 + def test_exec_without_tty(self): self.base_dir = 'tests/fixtures/links-composefile' self.dispatch(['up', '-d', 'console']) diff --git a/tests/fixtures/profiles/docker-compose.yml b/tests/fixtures/profiles/docker-compose.yml new file mode 100644 index 00000000000..ba77f03b446 --- /dev/null +++ b/tests/fixtures/profiles/docker-compose.yml @@ -0,0 +1,20 @@ +version: "3" +services: + foo: + image: busybox:1.31.0-uclibc + bar: + image: busybox:1.31.0-uclibc + profiles: + - test + baz: + image: busybox:1.31.0-uclibc + depends_on: + - bar + profiles: + - test + zot: + image: busybox:1.31.0-uclibc + depends_on: + - bar + profiles: + - debug diff --git a/tests/fixtures/profiles/merge-profiles.yml b/tests/fixtures/profiles/merge-profiles.yml new file mode 100644 index 00000000000..42b0cfa4308 --- /dev/null +++ b/tests/fixtures/profiles/merge-profiles.yml @@ -0,0 +1,5 @@ +version: "3" +services: + bar: + profiles: + - debug From 7ca88de76bdbb347b9a44c8c074bc85377da3a1f Mon Sep 17 00:00:00 2001 From: aiordache Date: Wed, 25 Nov 2020 18:45:56 +0100 Subject: [PATCH 4049/4072] Bring back warning for swarm configs Signed-off-by: aiordache --- compose/config/config.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/compose/config/config.py b/compose/config/config.py index 1b067e78877..15b193e5c81 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -373,6 +373,23 @@ def find_candidates_in_parent_dirs(filenames, path): return (candidates, path) +def check_swarm_only_config(service_dicts): + warning_template = ( + "Some services ({services}) use the '{key}' key, which will be ignored. " + "Compose does not support '{key}' configuration - use " + "`docker stack deploy` to deploy to a swarm." + ) + key = 'configs' + services = [s for s in service_dicts if s.get(key)] + if services: + log.warning( + warning_template.format( + services=", ".join(sorted(s['name'] for s in services)), + key=key + ) + ) + + def load(config_details, interpolate=True): """Load the configuration from a working directory and a list of configuration files. Files are loaded in order, and merged on top @@ -409,6 +426,8 @@ def load(config_details, interpolate=True): for service_dict in service_dicts: match_named_volumes(service_dict, volumes) + check_swarm_only_config(service_dicts) + return Config(main_file.config_version, main_file.version, service_dicts, volumes, networks, secrets, configs) From 8f2dbd9b12914f3049c31723553efeb5ecd276d3 Mon Sep 17 00:00:00 2001 From: aiordache Date: Tue, 1 Dec 2020 12:40:05 +0100 Subject: [PATCH 4050/4072] Add devices to config hash to trigger container recreate on change * add unit test * update path to compose spec schema in Makefile Signed-off-by: aiordache --- Makefile | 2 +- compose/config/config.py | 1 + compose/service.py | 8 ++++++-- tests/unit/service_test.py | 23 +++++++++++++++++++++++ 4 files changed, 31 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index f673eace0a3..0a7a5c366b4 100644 --- a/Makefile +++ b/Makefile @@ -12,7 +12,7 @@ ifeq ($(UNAME_S),Darwin) BUILD_SCRIPT = osx endif -COMPOSE_SPEC_SCHEMA_PATH = "compose/config/config_schema_compose_spec.json" +COMPOSE_SPEC_SCHEMA_PATH = "compose/config/compose_spec.json" COMPOSE_SPEC_RAW_URL = "https://raw.githubusercontent.com/compose-spec/compose-spec/master/schema/compose-spec.json" all: cli diff --git a/compose/config/config.py b/compose/config/config.py index 1b067e78877..2c50827362a 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -1166,6 +1166,7 @@ def merge_reservations(base, override): md.merge_scalar('cpus') md.merge_scalar('memory') md.merge_sequence('generic_resources', types.GenericResource.parse) + md.merge_field('devices', merge_unique_objects_lists, default=[]) return dict(md) diff --git a/compose/service.py b/compose/service.py index e00a537cfce..cb0baefc601 100644 --- a/compose/service.py +++ b/compose/service.py @@ -709,7 +709,7 @@ def image_id(): except NoSuchImageError: return None - return { + c = { 'options': self.options, 'image_id': image_id(), 'links': self.get_link_names(), @@ -719,9 +719,13 @@ def image_id(): 'volumes_from': [ (v.source.name, v.mode) for v in self.volumes_from if isinstance(v.source, Service) - ], + ] } + if self.device_requests: + c['devices'] = self.device_requests + return c + def get_dependency_names(self): net_name = self.network_mode.service_name pid_namespace = self.pid_mode.service_name diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 72cb1d7f9ed..4d49d2f253b 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -732,6 +732,29 @@ def test_config_dict_with_network_mode_from_container(self): } assert config_dict == expected + def test_config_dict_with_device_requests(self): + self.mock_client.inspect_image.return_value = {'Id': 'abcd'} + service = Service( + 'foo', + image='example.com/foo', + client=self.mock_client, + network_mode=ServiceNetworkMode(Service('other')), + networks={'default': None}, + device_requests=[{'driver': 'nvidia', 'device_ids': ['0'], 'capabilities': ['gpu']}]) + + config_dict = service.config_dict() + expected = { + 'image_id': 'abcd', + 'options': {'image': 'example.com/foo'}, + 'links': [], + 'net': 'other', + 'secrets': [], + 'networks': {'default': None}, + 'volumes_from': [], + 'devices': [{'driver': 'nvidia', 'device_ids': ['0'], 'capabilities': ['gpu']}], + } + assert config_dict == expected + def test_config_hash_matches_label(self): self.mock_client.inspect_image.return_value = {'Id': 'abcd'} service = Service( From 21c07bd76c2da2f6b20408810186f87d2250ecf2 Mon Sep 17 00:00:00 2001 From: aiordache Date: Tue, 1 Dec 2020 16:46:29 +0100 Subject: [PATCH 4051/4072] Move device requests to service_dict to avoid adding another field to config hash Signed-off-by: aiordache --- compose/project.py | 3 +-- compose/service.py | 10 ++-------- tests/unit/service_test.py | 23 ----------------------- 3 files changed, 3 insertions(+), 33 deletions(-) diff --git a/compose/project.py b/compose/project.py index 900487d4f23..d1fecf01757 100644 --- a/compose/project.py +++ b/compose/project.py @@ -128,7 +128,7 @@ def from_config(cls, name, config_data, client, default_platform=None, extra_lab config_data.secrets) service_dict['scale'] = project.get_service_scale(service_dict) - device_requests = project.get_device_requests(service_dict) + service_dict['device_requests'] = project.get_device_requests(service_dict) service_dict = translate_credential_spec_to_security_opt(service_dict) service_dict, ignored_keys = translate_deploy_keys_to_container_config( service_dict @@ -154,7 +154,6 @@ def from_config(cls, name, config_data, client, default_platform=None, extra_lab ipc_mode=ipc_mode, platform=service_dict.pop('platform', None), default_platform=default_platform, - device_requests=device_requests, extra_labels=extra_labels, **service_dict) ) diff --git a/compose/service.py b/compose/service.py index cb0baefc601..f94ba2e30cf 100644 --- a/compose/service.py +++ b/compose/service.py @@ -181,7 +181,6 @@ def __init__( pid_mode=None, default_platform=None, extra_labels=None, - device_requests=None, **options ): self.name = name @@ -197,7 +196,6 @@ def __init__( self.secrets = secrets or [] self.scale_num = scale self.default_platform = default_platform - self.device_requests = device_requests self.options = options self.extra_labels = extra_labels or [] @@ -709,7 +707,7 @@ def image_id(): except NoSuchImageError: return None - c = { + return { 'options': self.options, 'image_id': image_id(), 'links': self.get_link_names(), @@ -722,10 +720,6 @@ def image_id(): ] } - if self.device_requests: - c['devices'] = self.device_requests - return c - def get_dependency_names(self): net_name = self.network_mode.service_name pid_namespace = self.pid_mode.service_name @@ -1023,7 +1017,7 @@ def _get_container_host_config(self, override_options, one_off=False): privileged=options.get('privileged', False), network_mode=self.network_mode.mode, devices=options.get('devices'), - device_requests=self.device_requests, + device_requests=options.get('device_requests'), dns=options.get('dns'), dns_opt=options.get('dns_opt'), dns_search=options.get('dns_search'), diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 4d49d2f253b..72cb1d7f9ed 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -732,29 +732,6 @@ def test_config_dict_with_network_mode_from_container(self): } assert config_dict == expected - def test_config_dict_with_device_requests(self): - self.mock_client.inspect_image.return_value = {'Id': 'abcd'} - service = Service( - 'foo', - image='example.com/foo', - client=self.mock_client, - network_mode=ServiceNetworkMode(Service('other')), - networks={'default': None}, - device_requests=[{'driver': 'nvidia', 'device_ids': ['0'], 'capabilities': ['gpu']}]) - - config_dict = service.config_dict() - expected = { - 'image_id': 'abcd', - 'options': {'image': 'example.com/foo'}, - 'links': [], - 'net': 'other', - 'secrets': [], - 'networks': {'default': None}, - 'volumes_from': [], - 'devices': [{'driver': 'nvidia', 'device_ids': ['0'], 'capabilities': ['gpu']}], - } - assert config_dict == expected - def test_config_hash_matches_label(self): self.mock_client.inspect_image.return_value = {'Id': 'abcd'} service = Service( From c87844c504f03bbd44222da8558d919394df5c92 Mon Sep 17 00:00:00 2001 From: Chris Crone Date: Mon, 30 Nov 2020 10:10:33 +0100 Subject: [PATCH 4052/4072] win: Bump Python version for release Signed-off-by: Chris Crone --- Release.Jenkinsfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Release.Jenkinsfile b/Release.Jenkinsfile index 32c66c42744..38cc53433fc 100644 --- a/Release.Jenkinsfile +++ b/Release.Jenkinsfile @@ -117,7 +117,7 @@ pipeline { label 'windows-python' } environment { - PATH = "$PATH;C:\\Python37;C:\\Python37\\Scripts" + PATH = "C:\\Python39;C:\\Python39\\Scripts;$PATH" } steps { checkout scm From 21f1d7c5e661e721c9911039be37c8f164d98e1c Mon Sep 17 00:00:00 2001 From: Chris Crone Date: Mon, 30 Nov 2020 10:13:52 +0100 Subject: [PATCH 4053/4072] centos: Simplify short version variable Signed-off-by: Chris Crone --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index fd9fd45c149..725a13167f9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -59,7 +59,7 @@ RUN curl -L https://www.python.org/ftp/python/${PYTHON_VERSION}/Python-${PYTHON_ && ./configure --enable-optimizations --enable-shared --prefix=/usr LDFLAGS="-Wl,-rpath /usr/lib" \ && make altinstall RUN alternatives --install /usr/bin/python python /usr/bin/python2.7 50 -RUN alternatives --install /usr/bin/python python /usr/bin/python$(echo "${PYTHON_VERSION}" | cut -c1-3) 60 +RUN alternatives --install /usr/bin/python python /usr/bin/python$(echo "${PYTHON_VERSION%.*}") 60 RUN curl https://bootstrap.pypa.io/get-pip.py | python - FROM build-${DISTRO} AS build From 929ca84db1e534688c021ae71f6decbed6678af2 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Wed, 2 Dec 2020 18:19:18 +0000 Subject: [PATCH 4054/4072] Bump cffi from 1.14.1 to 1.14.4 Bumps [cffi](https://github.com/python-cffi/release-doc) from 1.14.1 to 1.14.4. - [Release notes](https://github.com/python-cffi/release-doc/releases) - [Commits](https://github.com/python-cffi/release-doc/commits) Signed-off-by: dependabot-preview[bot] --- requirements-indirect.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-indirect.txt b/requirements-indirect.txt index 3d1cca37c6c..ded4495aeee 100644 --- a/requirements-indirect.txt +++ b/requirements-indirect.txt @@ -2,7 +2,7 @@ altgraph==0.17 appdirs==1.4.4 attrs==20.1.0 bcrypt==3.1.7 -cffi==1.14.1 +cffi==1.14.4 cryptography==3.2.1 distlib==0.3.1 entrypoints==0.3 From ac06e35c00680d30f4125ed9fefc8c6327b21c31 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Wed, 2 Dec 2020 18:47:11 +0000 Subject: [PATCH 4055/4072] Bump attrs from 20.1.0 to 20.3.0 Bumps [attrs](https://github.com/python-attrs/attrs) from 20.1.0 to 20.3.0. - [Release notes](https://github.com/python-attrs/attrs/releases) - [Changelog](https://github.com/python-attrs/attrs/blob/master/CHANGELOG.rst) - [Commits](https://github.com/python-attrs/attrs/compare/20.1.0...20.3.0) Signed-off-by: dependabot-preview[bot] --- requirements-indirect.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-indirect.txt b/requirements-indirect.txt index ded4495aeee..bae1f5d6d6f 100644 --- a/requirements-indirect.txt +++ b/requirements-indirect.txt @@ -1,6 +1,6 @@ altgraph==0.17 appdirs==1.4.4 -attrs==20.1.0 +attrs==20.3.0 bcrypt==3.1.7 cffi==1.14.4 cryptography==3.2.1 From d6e3af36dd7d0e476fb3773379d28154cc476e58 Mon Sep 17 00:00:00 2001 From: aiordache Date: Wed, 2 Dec 2020 19:54:36 +0100 Subject: [PATCH 4056/4072] Bump version in build scripts Signed-off-by: aiordache --- Dockerfile | 2 +- script/build/windows.ps1 | 2 +- script/setup/osx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index d4c8ea83bdf..fa87ab5399f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -66,7 +66,7 @@ WORKDIR /code/ COPY docker-compose-entrypoint.sh /usr/local/bin/ COPY --from=docker-cli /usr/local/bin/docker /usr/local/bin/docker RUN pip install \ - virtualenv==20.0.30 \ + virtualenv==20.2.1 \ tox==3.19.0 COPY requirements-dev.txt . COPY requirements-indirect.txt . diff --git a/script/build/windows.ps1 b/script/build/windows.ps1 index 2778cc88447..acac12b3487 100644 --- a/script/build/windows.ps1 +++ b/script/build/windows.ps1 @@ -16,7 +16,7 @@ # # 4. In Powershell, run the following commands: # -# $ pip install 'virtualenv==20.0.30' +# $ pip install 'virtualenv==20.2.1' # $ Set-ExecutionPolicy -Scope CurrentUser RemoteSigned # # 5. Clone the repository: diff --git a/script/setup/osx b/script/setup/osx index 44ec4adcc94..29650b46b11 100755 --- a/script/setup/osx +++ b/script/setup/osx @@ -36,7 +36,7 @@ if ! [ -x "$(command -v python3)" ]; then brew install python3 fi if ! [ -x "$(command -v virtualenv)" ]; then - pip3 install virtualenv==20.0.30 + pip3 install virtualenv==20.2.1 fi # From 4139d701f3d33d17928e638701b9b805a3ee6b9a Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Wed, 2 Dec 2020 19:01:24 +0000 Subject: [PATCH 4057/4072] Bump bcrypt from 3.1.7 to 3.2.0 Bumps [bcrypt](https://github.com/pyca/bcrypt) from 3.1.7 to 3.2.0. - [Release notes](https://github.com/pyca/bcrypt/releases) - [Changelog](https://github.com/pyca/bcrypt/blob/master/release.py) - [Commits](https://github.com/pyca/bcrypt/compare/3.1.7...3.2.0) Signed-off-by: dependabot-preview[bot] --- requirements-indirect.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-indirect.txt b/requirements-indirect.txt index bae1f5d6d6f..f03bfb6afbe 100644 --- a/requirements-indirect.txt +++ b/requirements-indirect.txt @@ -1,7 +1,7 @@ altgraph==0.17 appdirs==1.4.4 attrs==20.3.0 -bcrypt==3.1.7 +bcrypt==3.2.0 cffi==1.14.4 cryptography==3.2.1 distlib==0.3.1 From 059fd29ec36482796b231a7d44c02e0336070e56 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Wed, 2 Dec 2020 20:14:04 +0100 Subject: [PATCH 4058/4072] Bump tox from 3.19.0 to 3.20.1 (#7863) * Bump tox from 3.19.0 to 3.20.1 * Bump tox version in Dockerfile Bumps [tox](https://github.com/tox-dev/tox) from 3.19.0 to 3.20.1. - [Release notes](https://github.com/tox-dev/tox/releases) - [Changelog](https://github.com/tox-dev/tox/blob/master/docs/changelog.rst) - [Commits](https://github.com/tox-dev/tox/compare/3.19.0...3.20.1) Signed-off-by: dependabot-preview[bot] Signed-off-by: aiordache --- Dockerfile | 2 +- requirements-indirect.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index f8611c58ac9..5491912c563 100644 --- a/Dockerfile +++ b/Dockerfile @@ -69,7 +69,7 @@ COPY docker-compose-entrypoint.sh /usr/local/bin/ COPY --from=docker-cli /usr/local/bin/docker /usr/local/bin/docker RUN pip install \ virtualenv==20.2.1 \ - tox==3.19.0 + tox==3.20.1 COPY requirements-dev.txt . COPY requirements-indirect.txt . COPY requirements.txt . diff --git a/requirements-indirect.txt b/requirements-indirect.txt index fb100a39af6..dc5726eef47 100644 --- a/requirements-indirect.txt +++ b/requirements-indirect.txt @@ -23,6 +23,6 @@ pyrsistent==0.16.0 smmap==3.0.4 smmap2==3.0.1 toml==0.10.1 -tox==3.19.0 +tox==3.20.1 virtualenv==20.2.1 wcwidth==0.2.5 From 3e31f8097742d8d4d5c6a1dea43d86d17e01c83b Mon Sep 17 00:00:00 2001 From: EricsonMacedo Date: Wed, 2 Dec 2020 16:49:54 -0300 Subject: [PATCH 4059/4072] Setup environment variables for compose. (#7490) * Setup environment variables for compose. - Setup environment variables to work as expected for compose config and context in container mode. - Setup volume mounts based on -f, --file argument for compose config and context. Signed-off-by: Ericson Macedo * Improve parsing of specified compose file - Update parsing of multiple -f, --file parameters. - Remove usage of eval command. Signed-off-by: Ericson Macedo --- script/run/run.sh | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/script/run/run.sh b/script/run/run.sh index bacf39d88cf..658cf47ac5a 100755 --- a/script/run/run.sh +++ b/script/run/run.sh @@ -44,13 +44,34 @@ fi if [ -n "$COMPOSE_PROJECT_NAME" ]; then COMPOSE_OPTIONS="-e COMPOSE_PROJECT_NAME $COMPOSE_OPTIONS" fi -# TODO: also check --file argument if [ -n "$compose_dir" ]; then VOLUMES="$VOLUMES -v $compose_dir:$compose_dir" fi if [ -n "$HOME" ]; then VOLUMES="$VOLUMES -v $HOME:$HOME -e HOME" # Pass in HOME to share docker.config and allow ~/-relative paths to work. fi +i=$# +while [ $i -gt 0 ]; do + arg=$1 + i=$((i - 1)) + shift + + case "$arg" in + -f|--file) + value=$1 + i=$((i - 1)) + shift + set -- "$@" "$arg" "$value" + + file_dir=$(realpath "$(dirname "$value")") + VOLUMES="$VOLUMES -v $file_dir:$file_dir" + ;; + *) set -- "$@" "$arg" ;; + esac +done + +# Setup environment variables for compose config and context +ENV_OPTIONS=$(printenv | sed -E "/^PATH=.*/d; s/^/-e /g; s/=.*//g; s/\n/ /g") # Only allocate tty if we detect one if [ -t 0 ] && [ -t 1 ]; then @@ -67,4 +88,4 @@ if docker info --format '{{json .SecurityOptions}}' 2>/dev/null | grep -q 'name= fi # shellcheck disable=SC2086 -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 $ENV_OPTIONS $VOLUMES -w "$(pwd)" $IMAGE "$@" From 7b5be97c45c190b0ec59b4d14576176c98c7fcf0 Mon Sep 17 00:00:00 2001 From: Ofek Lev Date: Wed, 2 Dec 2020 14:51:39 -0500 Subject: [PATCH 4060/4072] Upgrade Windows dependency (#7537) Signed-off-by: Ofek Lev --- requirements.txt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index b2f6fc7046d..e05bcd3f252 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,10 +12,9 @@ idna==2.10 ipaddress==1.0.23 jsonschema==3.2.0 paramiko==2.7.1 -pypiwin32==219; sys_platform == 'win32' and python_version < '3.6' -pypiwin32==223; sys_platform == 'win32' and python_version >= '3.6' PySocks==1.7.1 python-dotenv==0.14.0 +pywin32==227; sys_platform == 'win32' PyYAML==5.3.1 requests==2.24.0 texttable==1.6.2 From 3f4d1ea97edbaf71d759268eef0a09fd44263671 Mon Sep 17 00:00:00 2001 From: lcsdtw <64416184+lcsdtw@users.noreply.github.com> Date: Wed, 2 Dec 2020 16:56:34 -0300 Subject: [PATCH 4061/4072] docker-compose-help: improve help about down (#7411) One could think just using `docker-compose down` already remove volumes Signed-off-by: Dara Keon --- 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 21399a17bb6..55b8ccadc45 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -215,7 +215,7 @@ class TopLevelCommand: build Build or rebuild services config Validate and view the Compose file create Create services - down Stop and remove containers, networks, images, and volumes + down Stop and remove resources events Receive real time events from containers exec Execute a command in a running container help Get help on a command From 6f3f696bd10c76023e9a8286e5037594341e6fe0 Mon Sep 17 00:00:00 2001 From: Paco Xu Date: Thu, 3 Dec 2020 03:58:22 +0800 Subject: [PATCH 4062/4072] parallel_pull is default behavior in new versions (#7395) Signed-off-by: pacoxu --- compose/project.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/project.py b/compose/project.py index b70e20146f9..9b21e1cd846 100644 --- a/compose/project.py +++ b/compose/project.py @@ -746,7 +746,7 @@ def _get_convergence_plans(self, services, strategy, always_recreate_deps=False, return plans - def pull(self, service_names=None, ignore_pull_failures=False, parallel_pull=False, silent=False, + def pull(self, service_names=None, ignore_pull_failures=False, parallel_pull=True, silent=False, include_deps=False): services = self.get_services(service_names, include_deps) From e0edc908b5a5ef244dff6caa64c44ba3078b4a9e Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Wed, 2 Dec 2020 09:34:28 -0300 Subject: [PATCH 4063/4072] Fix project_dir to take first file in account The order of precedence is: - '--project-directory' option - first file directory in '--file' option - current directory Signed-off-by: Ulysses Souza --- compose/cli/command.py | 22 +++++++++++++++------- compose/cli/main.py | 18 ++++++++++-------- tests/unit/cli/command_test.py | 22 +++++++--------------- 3 files changed, 32 insertions(+), 30 deletions(-) diff --git a/compose/cli/command.py b/compose/cli/command.py index ee991309ad5..599df9969a7 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -35,7 +35,7 @@ def project_from_options(project_dir, options, additional_options=None): additional_options = additional_options or {} - override_dir = options.get('--project-directory') + override_dir = get_project_dir(options) environment_file = options.get('--env-file') environment = Environment.from_env_file(override_dir or project_dir, environment_file) environment.silent = options.get('COMMAND', None) in SILENT_COMMANDS @@ -59,7 +59,7 @@ def project_from_options(project_dir, options, additional_options=None): return get_project( project_dir, - get_config_path_from_options(project_dir, options, environment), + get_config_path_from_options(options, environment), project_name=options.get('--project-name'), verbose=options.get('--verbose'), context=context, @@ -87,21 +87,29 @@ def set_parallel_limit(environment): parallel.GlobalLimit.set_global_limit(parallel_limit) +def get_project_dir(options): + override_dir = None + files = get_config_path_from_options(options, os.environ) + if files: + if files[0] == '-': + return '.' + override_dir = os.path.dirname(files[0]) + return options.get('--project-directory') or override_dir + + def get_config_from_options(base_dir, options, additional_options=None): additional_options = additional_options or {} - override_dir = options.get('--project-directory') + override_dir = get_project_dir(options) environment_file = options.get('--env-file') environment = Environment.from_env_file(override_dir or base_dir, environment_file) - config_path = get_config_path_from_options( - base_dir, options, environment - ) + config_path = get_config_path_from_options(options, environment) return config.load( config.find(base_dir, config_path, environment, override_dir), not additional_options.get('--no-interpolate') ) -def get_config_path_from_options(base_dir, options, environment): +def get_config_path_from_options(options, environment): def unicode_paths(paths): return [p.decode('utf-8') if isinstance(p, bytes) else p for p in paths] diff --git a/compose/cli/main.py b/compose/cli/main.py index 55b8ccadc45..9b2a5d0b067 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -39,6 +39,7 @@ from ..service import NeedsBuildError from ..service import OperationFailedError from .command import get_config_from_options +from .command import get_project_dir from .command import project_from_options from .docopt_command import DocoptDispatcher from .docopt_command import get_handler @@ -245,7 +246,7 @@ def __init__(self, project, options=None): @property def project_dir(self): - return self.toplevel_options.get('--project-directory') or '.' + return get_project_dir(self.toplevel_options) @property def toplevel_environment(self): @@ -431,6 +432,7 @@ def events(self, options): Options: --json Output events as a stream of json objects """ + def format_event(event): attributes = ["%s=%s" % item for item in event['attributes'].items()] return ("{time} {type} {action} {id} ({attrs})").format( @@ -1382,13 +1384,13 @@ def get_docker_start_call(container_options, container_id): def log_printer_from_project( - project, - containers, - monochrome, - log_args, - cascade_stop=False, - event_stream=None, - keep_prefix=True, + project, + containers, + monochrome, + log_args, + cascade_stop=False, + event_stream=None, + keep_prefix=True, ): return LogPrinter( containers, diff --git a/tests/unit/cli/command_test.py b/tests/unit/cli/command_test.py index 9d4db5b59a8..60638864c37 100644 --- a/tests/unit/cli/command_test.py +++ b/tests/unit/cli/command_test.py @@ -14,49 +14,41 @@ def test_path_from_options(self): paths = ['one.yml', 'two.yml'] opts = {'--file': paths} environment = Environment.from_env_file('.') - assert get_config_path_from_options('.', opts, environment) == paths + 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' environment = Environment.from_env_file('.') - assert get_config_path_from_options('.', {}, environment) == ['one.yml'] + 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' environment = Environment.from_env_file('.') - assert get_config_path_from_options( - '.', {}, environment - ) == ['one.yml', 'two.yml'] + 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' environment = Environment.from_env_file('.') - assert get_config_path_from_options( - '.', {}, environment - ) == ['one.yml', 'two.yml'] + assert get_config_path_from_options({}, environment) == ['one.yml', 'two.yml'] def test_multiple_path_from_env_custom_separator(self): with mock.patch.dict(os.environ): os.environ['COMPOSE_PATH_SEPARATOR'] = '^' os.environ['COMPOSE_FILE'] = 'c:\\one.yml^.\\semi;colon.yml' environment = Environment.from_env_file('.') - assert get_config_path_from_options( - '.', {}, environment - ) == ['c:\\one.yml', '.\\semi;colon.yml'] + assert get_config_path_from_options({}, environment) == ['c:\\one.yml', '.\\semi;colon.yml'] def test_no_path(self): environment = Environment.from_env_file('.') - assert not get_config_path_from_options('.', {}, environment) + assert not get_config_path_from_options({}, environment) def test_unicode_path_from_options(self): paths = [b'\xe5\xb0\xb1\xe5\x90\x83\xe9\xa5\xad/docker-compose.yml'] opts = {'--file': paths} environment = Environment.from_env_file('.') - assert get_config_path_from_options( - '.', opts, environment - ) == ['就吃饭/docker-compose.yml'] + assert get_config_path_from_options(opts, environment) == ['就吃饭/docker-compose.yml'] From fee4756e3320b83ff930b58ff229a79d446665d3 Mon Sep 17 00:00:00 2001 From: aiordache Date: Thu, 3 Dec 2020 19:00:39 +0100 Subject: [PATCH 4064/4072] Bump docker-py in setup.py Signed-off-by: aiordache --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 712a862d869..57e1313355d 100644 --- a/setup.py +++ b/setup.py @@ -32,7 +32,7 @@ def find_version(*file_paths): 'texttable >= 0.9.0, < 2', 'websocket-client >= 0.32.0, < 1', 'distro >= 1.5.0, < 2', - 'docker[ssh] >= 4.3.1, < 5', + 'docker[ssh] >= 4.4.0, < 5', 'dockerpty >= 0.4.1, < 1', 'jsonschema >= 2.5.1, < 4', 'python-dotenv >= 0.13.0, < 1', From 3f46dc1d76c8771d9f7f355e9e0daa86e9f7072d Mon Sep 17 00:00:00 2001 From: Anca Iordache Date: Fri, 4 Dec 2020 17:22:31 +0100 Subject: [PATCH 4065/4072] Revert "Bump gitpython from 3.1.7 to 3.1.11" (#7974) Signed-off-by: aiordache --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 00f7a455b07..9cc00c64e85 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -2,7 +2,7 @@ Click==7.1.2 coverage==5.2.1 ddt==1.4.1 flake8==3.8.3 -gitpython==3.1.11 +gitpython==3.1.7 mock==3.0.5 pytest==6.0.1; python_version >= '3.5' pytest==4.6.5; python_version < '3.5' From 6c55ef6a5d15c809e5ef29f0047aa9e1392d4505 Mon Sep 17 00:00:00 2001 From: Anca Iordache Date: Fri, 4 Dec 2020 17:32:14 +0100 Subject: [PATCH 4066/4072] Revert "Bump virtualenv from 20.0.30 to 20.2.1" (#7975) This reverts commit 8785279ffd40d04cae409a1d3a94aebd94f2e199. Signed-off-by: aiordache --- Dockerfile | 2 +- requirements-indirect.txt | 2 +- script/build/windows.ps1 | 2 +- script/setup/osx | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index 5491912c563..54cf2d10ec8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -68,7 +68,7 @@ WORKDIR /code/ COPY docker-compose-entrypoint.sh /usr/local/bin/ COPY --from=docker-cli /usr/local/bin/docker /usr/local/bin/docker RUN pip install \ - virtualenv==20.2.1 \ + virtualenv==20.0.30 \ tox==3.20.1 COPY requirements-dev.txt . COPY requirements-indirect.txt . diff --git a/requirements-indirect.txt b/requirements-indirect.txt index dc5726eef47..376c1c5ba00 100644 --- a/requirements-indirect.txt +++ b/requirements-indirect.txt @@ -24,5 +24,5 @@ smmap==3.0.4 smmap2==3.0.1 toml==0.10.1 tox==3.20.1 -virtualenv==20.2.1 +virtualenv==20.0.30 wcwidth==0.2.5 diff --git a/script/build/windows.ps1 b/script/build/windows.ps1 index 5d16f32058e..120cab33a04 100644 --- a/script/build/windows.ps1 +++ b/script/build/windows.ps1 @@ -16,7 +16,7 @@ # # 4. In Powershell, run the following commands: # -# $ pip install 'virtualenv==20.2.1' +# $ pip install 'virtualenv==20.0.30' # $ Set-ExecutionPolicy -Scope CurrentUser RemoteSigned # # 5. Clone the repository: diff --git a/script/setup/osx b/script/setup/osx index 5415c12af46..108ffd3e1b2 100755 --- a/script/setup/osx +++ b/script/setup/osx @@ -36,7 +36,7 @@ if ! [ -x "$(command -v python3)" ]; then brew install python3 fi if ! [ -x "$(command -v virtualenv)" ]; then - pip3 install virtualenv==20.2.1 + pip3 install virtualenv==20.0.30 fi # From 89fcfc5499ed8266cb98b487c22b00adf819a749 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 23 Nov 2020 22:16:04 +0000 Subject: [PATCH 4067/4072] Bump virtualenv from 20.0.30 to 20.2.2 Signed-off-by: dependabot-preview[bot] (cherry picked from commit 8785279ffd40d04cae409a1d3a94aebd94f2e199) Signed-off-by: Ulysses Souza --- Dockerfile | 2 +- requirements-dev.txt | 2 +- requirements-indirect.txt | 2 +- script/build/windows.ps1 | 2 +- script/setup/osx | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index 54cf2d10ec8..a4fc34a92c1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -68,7 +68,7 @@ WORKDIR /code/ COPY docker-compose-entrypoint.sh /usr/local/bin/ COPY --from=docker-cli /usr/local/bin/docker /usr/local/bin/docker RUN pip install \ - virtualenv==20.0.30 \ + virtualenv==20.2.2 \ tox==3.20.1 COPY requirements-dev.txt . COPY requirements-indirect.txt . diff --git a/requirements-dev.txt b/requirements-dev.txt index 9cc00c64e85..00f7a455b07 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -2,7 +2,7 @@ Click==7.1.2 coverage==5.2.1 ddt==1.4.1 flake8==3.8.3 -gitpython==3.1.7 +gitpython==3.1.11 mock==3.0.5 pytest==6.0.1; python_version >= '3.5' pytest==4.6.5; python_version < '3.5' diff --git a/requirements-indirect.txt b/requirements-indirect.txt index 90a841ba475..218a4eddd34 100644 --- a/requirements-indirect.txt +++ b/requirements-indirect.txt @@ -24,5 +24,5 @@ smmap==3.0.4 smmap2==3.0.1 toml==0.10.1 tox==3.20.1 -virtualenv==20.0.30 +virtualenv==20.2.2 wcwidth==0.2.5 diff --git a/script/build/windows.ps1 b/script/build/windows.ps1 index 120cab33a04..147d0f07d88 100644 --- a/script/build/windows.ps1 +++ b/script/build/windows.ps1 @@ -16,7 +16,7 @@ # # 4. In Powershell, run the following commands: # -# $ pip install 'virtualenv==20.0.30' +# $ pip install 'virtualenv==20.2.2' # $ Set-ExecutionPolicy -Scope CurrentUser RemoteSigned # # 5. Clone the repository: diff --git a/script/setup/osx b/script/setup/osx index 108ffd3e1b2..289155bafcd 100755 --- a/script/setup/osx +++ b/script/setup/osx @@ -36,7 +36,7 @@ if ! [ -x "$(command -v python3)" ]; then brew install python3 fi if ! [ -x "$(command -v virtualenv)" ]; then - pip3 install virtualenv==20.0.30 + pip3 install virtualenv==20.2.2 fi # From affb0d504d29884154d85bca811bbf51264ab7ed Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Mon, 7 Dec 2020 22:10:52 +0100 Subject: [PATCH 4068/4072] Make COMPOSE_DOCKER_CLI_BUILD=1 the default This changes compose to use "native" build through the CLI by default. With this, docker-compose can take advantage of BuildKit (which is now enabled by default on Docker Desktop 2.5 and up). Users that want to use the python client for building can opt-out of this feature by setting COMPOSE_DOCKER_CLI_BUILD=0 Signed-off-by: Sebastiaan van Stijn --- compose/cli/main.py | 6 ++---- compose/config/environment.py | 4 ++-- compose/project.py | 6 ++++-- tests/acceptance/cli_test.py | 8 ++++++++ tests/integration/service_test.py | 9 ++++++--- 5 files changed, 22 insertions(+), 11 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 9b2a5d0b067..ff054ac172c 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -272,8 +272,6 @@ def build(self, options): --no-rm Do not remove intermediate containers after a successful build. --parallel Build images in parallel. --progress string Set type of progress output (auto, plain, tty). - EXPERIMENTAL flag for native builder. - To enable, run with COMPOSE_DOCKER_CLI_BUILD=1) --pull Always attempt to pull a newer version of the image. -q, --quiet Don't print anything to STDOUT """ @@ -287,7 +285,7 @@ def build(self, options): ) build_args = resolve_build_args(build_args, self.toplevel_environment) - native_builder = self.toplevel_environment.get_boolean('COMPOSE_DOCKER_CLI_BUILD') + native_builder = self.toplevel_environment.get_boolean('COMPOSE_DOCKER_CLI_BUILD', True) self.project.build( service_names=options['SERVICE'], @@ -1049,7 +1047,7 @@ def up(self, options): for excluded in [x for x in opts if options.get(x) and no_start]: raise UserError('--no-start and {} cannot be combined.'.format(excluded)) - native_builder = self.toplevel_environment.get_boolean('COMPOSE_DOCKER_CLI_BUILD') + native_builder = self.toplevel_environment.get_boolean('COMPOSE_DOCKER_CLI_BUILD', True) with up_shutdown_context(self.project, service_names, timeout, detached): warn_for_swarm_mode(self.project.client) diff --git a/compose/config/environment.py b/compose/config/environment.py index 1780851fdcb..8769df9f8f7 100644 --- a/compose/config/environment.py +++ b/compose/config/environment.py @@ -113,13 +113,13 @@ def get(self, key, *args, **kwargs): ) return super().get(key, *args, **kwargs) - def get_boolean(self, key): + def get_boolean(self, key, default=False): # 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 + return default if value.lower() in ['0', 'false']: return False return True diff --git a/compose/project.py b/compose/project.py index 9b21e1cd846..d0bcb3f73a6 100644 --- a/compose/project.py +++ b/compose/project.py @@ -489,7 +489,8 @@ def build(self, service_names=None, no_cache=False, pull=False, force_rm=False, log.info('%s uses an image, skipping' % service.name) if cli: - log.warning("Native build is an experimental feature and could change at any time") + log.info("Building with native build. Learn about native build in Compose here: " + "https://docs.docker.com/go/compose-native-build/") if parallel_build: log.warning("Flag '--parallel' is ignored when building with " "COMPOSE_DOCKER_CLI_BUILD=1") @@ -649,7 +650,8 @@ def up(self, ): if cli: - log.warning("Native build is an experimental feature and could change at any time") + log.info("Building with native build. Learn about native build in Compose here: " + "https://docs.docker.com/go/compose-native-build/") self.initialize() if not ignore_orphans: diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 2ff51573982..bc298fe7a17 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -783,7 +783,11 @@ def test_build_no_cache_pull(self): assert BUILD_CACHE_TEXT not in result.stdout assert BUILD_PULL_TEXT in result.stdout + @mock.patch.dict(os.environ) def test_build_log_level(self): + os.environ['COMPOSE_DOCKER_CLI_BUILD'] = '0' + os.environ['DOCKER_BUILDKIT'] = '0' + self.test_env_file_relative_to_compose_file() self.base_dir = 'tests/fixtures/simple-dockerfile' result = self.dispatch(['--log-level', 'warning', 'build', 'simple']) assert result.stderr == '' @@ -845,13 +849,17 @@ def test_build_rm(self): for c in self.project.client.containers(all=True): self.addCleanup(self.project.client.remove_container, c, force=True) + @mock.patch.dict(os.environ) def test_build_shm_size_build_option(self): + os.environ['COMPOSE_DOCKER_CLI_BUILD'] = '0' pull_busybox(self.client) self.base_dir = 'tests/fixtures/build-shm-size' result = self.dispatch(['build', '--no-cache'], None) assert 'shm_size: 96' in result.stdout + @mock.patch.dict(os.environ) def test_build_memory_build_option(self): + os.environ['COMPOSE_DOCKER_CLI_BUILD'] = '0' pull_busybox(self.client) self.base_dir = 'tests/fixtures/build-memory' result = self.dispatch(['build', '--no-cache', '--memory', '96m', 'service'], None) diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index efb1fd5fa8b..06a97508d1f 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -948,7 +948,12 @@ def test_build(self): with open(os.path.join(base_dir, 'Dockerfile'), 'w') as f: f.write("FROM busybox\n") - service = self.create_service('web', build={'context': base_dir}) + service = self.create_service('web', + build={'context': base_dir}, + environment={ + 'COMPOSE_DOCKER_CLI_BUILD': '0', + 'DOCKER_BUILDKIT': '0', + }) service.build() self.addCleanup(self.client.remove_image, service.image_name) @@ -964,7 +969,6 @@ def test_build_cli(self): service = self.create_service('web', build={'context': base_dir}, environment={ - 'COMPOSE_DOCKER_CLI_BUILD': '1', 'DOCKER_BUILDKIT': '1', }) service.build(cli=True) @@ -1015,7 +1019,6 @@ def test_up_build_cli(self): web = self.create_service('web', build={'context': base_dir}, environment={ - 'COMPOSE_DOCKER_CLI_BUILD': '1', 'DOCKER_BUILDKIT': '1', }) project = Project('composetest', [web], self.client) From 1b5278f977cef828546e00a49c2d1687ca0532aa Mon Sep 17 00:00:00 2001 From: Daniil Sigalov Date: Sun, 20 Dec 2020 15:44:01 +0300 Subject: [PATCH 4069/4072] Only attach services we'll read logs from in up When 'up' is run with explicit list of services, compose will start them together with their dependencies. It will attach to all started services, but won't read output from dependencies (their logs are not printed by 'up') - so the receive buffer of dependencies will fill and at some point will start blocking those services. Fix that by only attaching to services given in the list. To do that, move logic of choosing which services to attach from cli/main.py to utils.py and use it from project.py to decide if service should be attached. Fixes #6018 Signed-off-by: Daniil Sigalov --- compose/cli/main.py | 14 +++++++------- compose/project.py | 11 +++++++++-- compose/utils.py | 15 +++++++++++++++ 3 files changed, 31 insertions(+), 9 deletions(-) diff --git a/compose/cli/main.py b/compose/cli/main.py index 9b2a5d0b067..c0fe3bb4308 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -38,6 +38,7 @@ from ..service import ImageType from ..service import NeedsBuildError from ..service import OperationFailedError +from ..utils import filter_attached_for_up from .command import get_config_from_options from .command import get_project_dir from .command import project_from_options @@ -1071,6 +1072,7 @@ def up(rebuild): renew_anonymous_volumes=options.get('--renew-anon-volumes'), silent=options.get('--quiet-pull'), cli=native_builder, + attach_dependencies=attach_dependencies, ) try: @@ -1401,13 +1403,11 @@ def log_printer_from_project( def filter_attached_containers(containers, service_names, attach_dependencies=False): - if attach_dependencies or not service_names: - return containers - - return [ - container - for container in containers if container.service in service_names - ] + return filter_attached_for_up( + containers, + service_names, + attach_dependencies, + lambda container: container.service) @contextlib.contextmanager diff --git a/compose/project.py b/compose/project.py index 9b21e1cd846..3bc7382818c 100644 --- a/compose/project.py +++ b/compose/project.py @@ -39,6 +39,7 @@ from .service import ServiceIpcMode from .service import ServiceNetworkMode from .service import ServicePidMode +from .utils import filter_attached_for_up from .utils import microseconds_from_time_nano from .utils import truncate_string from .volume import ProjectVolumes @@ -645,6 +646,7 @@ def up(self, silent=False, cli=False, one_off=False, + attach_dependencies=False, override_options=None, ): @@ -671,12 +673,17 @@ def up(self, one_off=service_names if one_off else [], ) - def do(service): + services_to_attach = filter_attached_for_up( + services, + service_names, + attach_dependencies, + lambda service: service.name) + def do(service): return service.execute_convergence_plan( plans[service.name], timeout=timeout, - detached=detached, + detached=detached or (service not in services_to_attach), scale_override=scale_override.get(service.name), rescale=rescale, start=start, diff --git a/compose/utils.py b/compose/utils.py index 060ba50cc36..86af8f8852a 100644 --- a/compose/utils.py +++ b/compose/utils.py @@ -174,3 +174,18 @@ def truncate_string(s, max_chars=35): if len(s) > max_chars: return s[:max_chars - 2] + '...' return s + + +def filter_attached_for_up(items, service_names, attach_dependencies=False, + item_to_service_name=lambda x: x): + """This function contains the logic of choosing which services to + attach when doing docker-compose up. It may be used both with containers + and services, and any other entities that map to service names - + this mapping is provided by item_to_service_name.""" + if attach_dependencies or not service_names: + return items + + return [ + item + for item in items if item_to_service_name(item) in service_names + ] From b9d86f4b51a154514428c4e960cb01a19a25ae82 Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Tue, 22 Dec 2020 17:52:24 -0300 Subject: [PATCH 4070/4072] Avoid setting unsuported parameter for subprocess.Popen on Windows Signed-off-by: Ulysses Souza --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index e05bcd3f252..82816e80f02 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ certifi==2020.6.20 chardet==3.0.4 colorama==0.4.3; sys_platform == 'win32' distro==1.5.0 -docker==4.4.0 +docker==4.4.1 docker-pycreds==0.4.0 dockerpty==0.4.1 docopt==0.6.2 From 369eb3220a6fd474274162fbc5546f78e353946d Mon Sep 17 00:00:00 2001 From: Ulysses Souza Date: Mon, 7 Dec 2020 15:27:14 -0300 Subject: [PATCH 4071/4072] Add metrics Signed-off-by: Ulysses Souza --- compose/cli/docopt_command.py | 12 ++- compose/cli/main.py | 104 ++++++++++++++++++++---- compose/metrics/__init__.py | 0 compose/metrics/client.py | 55 +++++++++++++ compose/metrics/decorator.py | 21 +++++ script/test/all | 1 + tests/acceptance/cli_test.py | 14 ++-- tests/integration/metrics_test.py | 125 +++++++++++++++++++++++++++++ tests/integration/testcases.py | 1 + tests/unit/metrics/__init__.py | 0 tests/unit/metrics/metrics_test.py | 36 +++++++++ 11 files changed, 344 insertions(+), 25 deletions(-) create mode 100644 compose/metrics/__init__.py create mode 100644 compose/metrics/client.py create mode 100644 compose/metrics/decorator.py create mode 100644 tests/integration/metrics_test.py create mode 100644 tests/unit/metrics/__init__.py create mode 100644 tests/unit/metrics/metrics_test.py diff --git a/compose/cli/docopt_command.py b/compose/cli/docopt_command.py index d0ba7f67026..e56b37835bc 100644 --- a/compose/cli/docopt_command.py +++ b/compose/cli/docopt_command.py @@ -17,10 +17,16 @@ def __init__(self, command_class, options): self.command_class = command_class self.options = options + @classmethod + def get_command_and_options(cls, doc_entity, argv, options): + command_help = getdoc(doc_entity) + opt = docopt_full_help(command_help, argv, **options) + command = opt['COMMAND'] + return command_help, opt, command + def parse(self, argv): - command_help = getdoc(self.command_class) - options = docopt_full_help(command_help, argv, **self.options) - command = options['COMMAND'] + command_help, options, command = DocoptDispatcher.get_command_and_options( + self.command_class, argv, self.options) if command is None: raise SystemExit(command_help) diff --git a/compose/cli/main.py b/compose/cli/main.py index c0fe3bb4308..0f63b979133 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -26,6 +26,7 @@ from ..config.types import VolumeSpec from ..const import IS_WINDOWS_PLATFORM from ..errors import StreamParseError +from ..metrics.decorator import metrics from ..progress_stream import StreamOutputError from ..project import get_image_digests from ..project import MissingDigests @@ -53,6 +54,8 @@ from .utils import get_version_info from .utils import human_readable_file_size from .utils import yesno +from compose.metrics.client import MetricsCommand +from compose.metrics.client import Status if not IS_WINDOWS_PLATFORM: @@ -62,36 +65,77 @@ console_handler = logging.StreamHandler(sys.stderr) -def main(): +def main(): # noqa: C901 signals.ignore_sigpipe() + command = None try: - command = dispatch() - command() + _, opts, command = DocoptDispatcher.get_command_and_options( + TopLevelCommand, + get_filtered_args(sys.argv[1:]), + {'options_first': True, 'version': get_version_info('compose')}) + except Exception: + pass + try: + command_func = dispatch() + command_func() except (KeyboardInterrupt, signals.ShutdownException): - log.error("Aborting.") - sys.exit(1) + exit_with_metrics(command, "Aborting.", status=Status.FAILURE) except (UserError, NoSuchService, ConfigurationError, ProjectError, OperationFailedError) as e: - log.error(e.msg) - sys.exit(1) + exit_with_metrics(command, e.msg, status=Status.FAILURE) except BuildError as e: reason = "" if e.reason: reason = " : " + e.reason - log.error("Service '{}' failed to build{}".format(e.service.name, reason)) - sys.exit(1) + exit_with_metrics(command, + "Service '{}' failed to build{}".format(e.service.name, reason), + status=Status.FAILURE) except StreamOutputError as e: - log.error(e) - sys.exit(1) + exit_with_metrics(command, e, status=Status.FAILURE) except NeedsBuildError as e: - log.error("Service '{}' needs to be built, but --no-build was passed.".format(e.service.name)) - sys.exit(1) + exit_with_metrics(command, + "Service '{}' needs to be built, but --no-build was passed.".format( + e.service.name), status=Status.FAILURE) 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) + exit_with_metrics(e.command, "No such command: {}\n\n{}".format(e.command, commands)) except (errors.ConnectionError, StreamParseError): - sys.exit(1) + exit_with_metrics(command, status=Status.FAILURE) + except SystemExit as e: + status = Status.SUCCESS + if len(sys.argv) > 1 and '--help' not in sys.argv: + status = Status.FAILURE + + if command and len(sys.argv) >= 3 and sys.argv[2] == '--help': + command = '--help ' + command + + if not command and len(sys.argv) >= 2 and sys.argv[1] == '--help': + command = '--help' + + msg = e.args[0] if len(e.args) else "" + code = 0 + if isinstance(e.code, int): + code = e.code + exit_with_metrics(command, log_msg=msg, status=status, + exit_code=code) + + +def get_filtered_args(args): + if args[0] in ('-h', '--help'): + return [] + if args[0] == '--version': + return ['version'] + + +def exit_with_metrics(command, log_msg=None, status=Status.SUCCESS, exit_code=1): + if log_msg: + if not exit_code: + log.info(log_msg) + else: + log.error(log_msg) + + MetricsCommand(command, status=status).send_metrics() + sys.exit(exit_code) def dispatch(): @@ -133,8 +177,10 @@ def setup_logging(): root_logger.addHandler(console_handler) root_logger.setLevel(logging.DEBUG) - # Disable requests logging + # Disable requests and docker-py logging + logging.getLogger("urllib3").propagate = False logging.getLogger("requests").propagate = False + logging.getLogger("docker").propagate = False def setup_parallel_logger(noansi): @@ -254,6 +300,7 @@ def toplevel_environment(self): environment_file = self.toplevel_options.get('--env-file') return Environment.from_env_file(self.project_dir, environment_file) + @metrics() def build(self, options): """ Build or rebuild services. @@ -305,6 +352,7 @@ def build(self, options): progress=options.get('--progress'), ) + @metrics() def config(self, options): """ Validate and view the Compose file. @@ -354,6 +402,7 @@ def config(self, options): print(serialize_config(compose_config, image_digests, not options['--no-interpolate'])) + @metrics() def create(self, options): """ Creates containers for a service. @@ -382,6 +431,7 @@ def create(self, options): do_build=build_action_from_opts(options), ) + @metrics() def down(self, options): """ Stops containers and removes containers, networks, volumes, and images @@ -450,6 +500,7 @@ def json_format_event(event): print(formatter(event)) sys.stdout.flush() + @metrics("exec") def exec_command(self, options): """ Execute a command in a running container @@ -526,6 +577,7 @@ def exec_command(self, options): sys.exit(exit_code) @classmethod + @metrics() def help(cls, options): """ Get help on a command. @@ -539,6 +591,7 @@ def help(cls, options): print(getdoc(subject)) + @metrics() def images(self, options): """ List images used by the created containers. @@ -593,6 +646,7 @@ def add_default_tag(img_name): ]) print(Formatter.table(headers, rows)) + @metrics() def kill(self, options): """ Force stop service containers. @@ -607,6 +661,7 @@ def kill(self, options): self.project.kill(service_names=options['SERVICE'], signal=signal) + @metrics() def logs(self, options): """ View output from containers. @@ -643,6 +698,7 @@ def logs(self, options): event_stream=self.project.events(service_names=options['SERVICE']), keep_prefix=not options['--no-log-prefix']).run() + @metrics() def pause(self, options): """ Pause services. @@ -652,6 +708,7 @@ def pause(self, options): containers = self.project.pause(service_names=options['SERVICE']) exit_if(not containers, 'No containers to pause', 1) + @metrics() def port(self, options): """ Print the public port for a port binding. @@ -673,6 +730,7 @@ def port(self, options): options['PRIVATE_PORT'], protocol=options.get('--protocol') or 'tcp') or '') + @metrics() def ps(self, options): """ List containers. @@ -729,6 +787,7 @@ def ps(self, options): ]) print(Formatter.table(headers, rows)) + @metrics() def pull(self, options): """ Pulls images for services defined in a Compose file, but does not start the containers. @@ -752,6 +811,7 @@ def pull(self, options): include_deps=options.get('--include-deps'), ) + @metrics() def push(self, options): """ Pushes images for services. @@ -766,6 +826,7 @@ def push(self, options): ignore_push_failures=options.get('--ignore-push-failures') ) + @metrics() def rm(self, options): """ Removes stopped service containers. @@ -810,6 +871,7 @@ def rm(self, options): else: print("No stopped containers") + @metrics() def run(self, options): """ Run a one-off command on a service. @@ -870,6 +932,7 @@ def run(self, options): self.toplevel_options, self.toplevel_environment ) + @metrics() def scale(self, options): """ Set number of containers to run for a service. @@ -898,6 +961,7 @@ def scale(self, options): for service_name, num in parse_scale_args(options['SERVICE=NUM']).items(): self.project.get_service(service_name).scale(num, timeout=timeout) + @metrics() def start(self, options): """ Start existing containers. @@ -907,6 +971,7 @@ def start(self, options): containers = self.project.start(service_names=options['SERVICE']) exit_if(not containers, 'No containers to start', 1) + @metrics() def stop(self, options): """ Stop running containers without removing them. @@ -922,6 +987,7 @@ def stop(self, options): timeout = timeout_from_opts(options) self.project.stop(service_names=options['SERVICE'], timeout=timeout) + @metrics() def restart(self, options): """ Restart running containers. @@ -936,6 +1002,7 @@ def restart(self, options): containers = self.project.restart(service_names=options['SERVICE'], timeout=timeout) exit_if(not containers, 'No containers to restart', 1) + @metrics() def top(self, options): """ Display the running processes @@ -963,6 +1030,7 @@ def top(self, options): print(container.name) print(Formatter.table(headers, rows)) + @metrics() def unpause(self, options): """ Unpause services. @@ -972,6 +1040,7 @@ def unpause(self, options): containers = self.project.unpause(service_names=options['SERVICE']) exit_if(not containers, 'No containers to unpause', 1) + @metrics() def up(self, options): """ Builds, (re)creates, starts, and attaches to containers for a service. @@ -1122,6 +1191,7 @@ def up(rebuild): sys.exit(exit_code) @classmethod + @metrics() def version(cls, options): """ Show version information and quit. diff --git a/compose/metrics/__init__.py b/compose/metrics/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/compose/metrics/client.py b/compose/metrics/client.py new file mode 100644 index 00000000000..cf4afda5de4 --- /dev/null +++ b/compose/metrics/client.py @@ -0,0 +1,55 @@ +import os +from enum import Enum + +import requests +from docker import ContextAPI +from docker.transport import UnixHTTPAdapter + +from compose.const import IS_WINDOWS_PLATFORM + + +class Status(Enum): + SUCCESS = "success" + FAILURE = "failure" + CANCELED = "canceled" + + +class MetricsSource: + CLI = "docker-compose" + + +if IS_WINDOWS_PLATFORM: + METRICS_SOCKET_FILE = 'http+unix://\\\\.\\pipe\\docker_cli' +else: + METRICS_SOCKET_FILE = 'http+unix:///var/run/metrics-docker-cli.sock' + + +class MetricsCommand(requests.Session): + """ + Representation of a command in the metrics. + """ + + def __init__(self, command, + context_type=None, status=Status.SUCCESS, + source=MetricsSource.CLI, uri=None): + super().__init__() + self.command = "compose " + command if command else "compose --help" + self.context = context_type or ContextAPI.get_current_context().context_type or 'moby' + self.source = source + self.status = status.value + self.uri = uri or os.environ.get("METRICS_SOCKET_FILE", METRICS_SOCKET_FILE) + self.mount("http+unix://", UnixHTTPAdapter(self.uri)) + + def send_metrics(self): + try: + return self.post("http+unix://localhost/", json=self.to_map(), timeout=.05) + except Exception as e: + return e + + def to_map(self): + return { + 'command': self.command, + 'context': self.context, + 'source': self.source, + 'status': self.status, + } diff --git a/compose/metrics/decorator.py b/compose/metrics/decorator.py new file mode 100644 index 00000000000..3126e6941fa --- /dev/null +++ b/compose/metrics/decorator.py @@ -0,0 +1,21 @@ +import functools + +from compose.metrics.client import MetricsCommand +from compose.metrics.client import Status + + +class metrics: + def __init__(self, command_name=None): + self.command_name = command_name + + def __call__(self, fn): + @functools.wraps(fn, + assigned=functools.WRAPPER_ASSIGNMENTS, + updated=functools.WRAPPER_UPDATES) + def wrapper(*args, **kwargs): + if not self.command_name: + self.command_name = fn.__name__ + result = fn(*args, **kwargs) + MetricsCommand(self.command_name, status=Status.SUCCESS).send_metrics() + return result + return wrapper diff --git a/script/test/all b/script/test/all index 64a5a99f24f..1626fed37f9 100755 --- a/script/test/all +++ b/script/test/all @@ -21,6 +21,7 @@ elif [ "$DOCKER_VERSIONS" == "all" ]; then DOCKER_VERSIONS=$($get_versions -n 2 recent) fi +DOCKER_VERSIONS=19.03.14 BUILD_NUMBER=${BUILD_NUMBER-$USER} PY_TEST_VERSIONS=${PY_TEST_VERSIONS:-py39} diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 2ff51573982..d0441d35c47 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -58,13 +58,16 @@ } -def start_process(base_dir, options): +def start_process(base_dir, options, executable=None, env=None): + executable = executable or DOCKER_COMPOSE_EXECUTABLE proc = subprocess.Popen( - [DOCKER_COMPOSE_EXECUTABLE] + options, + [executable] + options, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, - cwd=base_dir) + cwd=base_dir, + env=env, + ) print("Running process: %s" % proc.pid) return proc @@ -78,9 +81,10 @@ def wait_on_process(proc, returncode=0, stdin=None): return ProcessResult(stdout.decode('utf-8'), stderr.decode('utf-8')) -def dispatch(base_dir, options, project_options=None, returncode=0, stdin=None): +def dispatch(base_dir, options, + project_options=None, returncode=0, stdin=None, executable=None, env=None): project_options = project_options or [] - proc = start_process(base_dir, project_options + options) + proc = start_process(base_dir, project_options + options, executable=executable, env=env) return wait_on_process(proc, returncode=returncode, stdin=stdin) diff --git a/tests/integration/metrics_test.py b/tests/integration/metrics_test.py new file mode 100644 index 00000000000..3d6e3fe220e --- /dev/null +++ b/tests/integration/metrics_test.py @@ -0,0 +1,125 @@ +import logging +import os +import socket +from http.server import BaseHTTPRequestHandler +from http.server import HTTPServer +from threading import Thread + +import requests +from docker.transport import UnixHTTPAdapter + +from tests.acceptance.cli_test import dispatch +from tests.integration.testcases import DockerClientTestCase + + +TEST_SOCKET_FILE = '/tmp/test-metrics-docker-cli.sock' + + +class MetricsTest(DockerClientTestCase): + test_session = requests.sessions.Session() + test_env = None + base_dir = 'tests/fixtures/v3-full' + + @classmethod + def setUpClass(cls): + super().setUpClass() + MetricsTest.test_session.mount("http+unix://", UnixHTTPAdapter(TEST_SOCKET_FILE)) + MetricsTest.test_env = os.environ.copy() + MetricsTest.test_env['METRICS_SOCKET_FILE'] = TEST_SOCKET_FILE + MetricsServer().start() + + @classmethod + def test_metrics_help(cls): + # root `docker-compose` command is considered as a `--help` + dispatch(cls.base_dir, [], env=MetricsTest.test_env) + assert cls.get_content() == \ + b'{"command": "compose --help", "context": "moby", ' \ + b'"source": "docker-compose", "status": "success"}' + dispatch(cls.base_dir, ['help', 'run'], env=MetricsTest.test_env) + assert cls.get_content() == \ + b'{"command": "compose help", "context": "moby", ' \ + b'"source": "docker-compose", "status": "success"}' + dispatch(cls.base_dir, ['--help'], env=MetricsTest.test_env) + assert cls.get_content() == \ + b'{"command": "compose --help", "context": "moby", ' \ + b'"source": "docker-compose", "status": "success"}' + dispatch(cls.base_dir, ['run', '--help'], env=MetricsTest.test_env) + assert cls.get_content() == \ + b'{"command": "compose --help run", "context": "moby", ' \ + b'"source": "docker-compose", "status": "success"}' + dispatch(cls.base_dir, ['up', '--help', 'extra_args'], env=MetricsTest.test_env) + assert cls.get_content() == \ + b'{"command": "compose --help up", "context": "moby", ' \ + b'"source": "docker-compose", "status": "success"}' + + @classmethod + def test_metrics_simple_commands(cls): + dispatch(cls.base_dir, ['ps'], env=MetricsTest.test_env) + assert cls.get_content() == \ + b'{"command": "compose ps", "context": "moby", ' \ + b'"source": "docker-compose", "status": "success"}' + dispatch(cls.base_dir, ['version'], env=MetricsTest.test_env) + assert cls.get_content() == \ + b'{"command": "compose version", "context": "moby", ' \ + b'"source": "docker-compose", "status": "success"}' + dispatch(cls.base_dir, ['version', '--yyy'], env=MetricsTest.test_env) + assert cls.get_content() == \ + b'{"command": "compose version", "context": "moby", ' \ + b'"source": "docker-compose", "status": "failure"}' + + @staticmethod + def get_content(): + resp = MetricsTest.test_session.get("http+unix://localhost") + print(resp.content) + return resp.content + + +def start_server(uri=TEST_SOCKET_FILE): + try: + os.remove(uri) + except OSError: + pass + httpd = HTTPServer(uri, MetricsHTTPRequestHandler, False) + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + sock.bind(TEST_SOCKET_FILE) + sock.listen(0) + httpd.socket = sock + print('Serving on ', uri) + httpd.serve_forever() + sock.shutdown(socket.SHUT_RDWR) + sock.close() + os.remove(uri) + + +class MetricsServer: + @classmethod + def start(cls): + t = Thread(target=start_server, daemon=True) + t.start() + + +class MetricsHTTPRequestHandler(BaseHTTPRequestHandler): + usages = [] + + def do_GET(self): + self.client_address = ('',) # avoid exception in BaseHTTPServer.py log_message() + self.send_response(200) + self.end_headers() + for u in MetricsHTTPRequestHandler.usages: + self.wfile.write(u) + MetricsHTTPRequestHandler.usages = [] + + def do_POST(self): + self.client_address = ('',) # avoid exception in BaseHTTPServer.py log_message() + content_length = int(self.headers['Content-Length']) + body = self.rfile.read(content_length) + print(body) + MetricsHTTPRequestHandler.usages.append(body) + self.send_response(200) + self.end_headers() + + +if __name__ == '__main__': + logging.getLogger("urllib3").propagate = False + logging.getLogger("requests").propagate = False + start_server() diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index 742d0e1c265..d4fbc9f61a2 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -61,6 +61,7 @@ def setUpClass(cls): @classmethod def tearDownClass(cls): + cls.client.close() del cls.client def tearDown(self): diff --git a/tests/unit/metrics/__init__.py b/tests/unit/metrics/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/unit/metrics/metrics_test.py b/tests/unit/metrics/metrics_test.py new file mode 100644 index 00000000000..e9f23720a35 --- /dev/null +++ b/tests/unit/metrics/metrics_test.py @@ -0,0 +1,36 @@ +import unittest + +from compose.metrics.client import MetricsCommand +from compose.metrics.client import Status + + +class MetricsTest(unittest.TestCase): + @classmethod + def test_metrics(cls): + assert MetricsCommand('up', 'moby').to_map() == { + 'command': 'compose up', + 'context': 'moby', + 'status': 'success', + 'source': 'docker-compose', + } + + assert MetricsCommand('down', 'local').to_map() == { + 'command': 'compose down', + 'context': 'local', + 'status': 'success', + 'source': 'docker-compose', + } + + assert MetricsCommand('help', 'aci', Status.FAILURE).to_map() == { + 'command': 'compose help', + 'context': 'aci', + 'status': 'failure', + 'source': 'docker-compose', + } + + assert MetricsCommand('run', 'ecs').to_map() == { + 'command': 'compose run', + 'context': 'ecs', + 'status': 'success', + 'source': 'docker-compose', + } From c380604a9e1f7126c010bdb525e6a1d638d07c47 Mon Sep 17 00:00:00 2001 From: "guillaume.tardif" Date: Tue, 5 Jan 2021 15:01:46 +0100 Subject: [PATCH 4072/4072] Support windows npipe, set content type & corrrect URL /usage. Also fixed socket name for desktop mac Signed-off-by: guillaume.tardif --- compose/metrics/client.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/compose/metrics/client.py b/compose/metrics/client.py index cf4afda5de4..513e22ce61c 100644 --- a/compose/metrics/client.py +++ b/compose/metrics/client.py @@ -7,6 +7,9 @@ from compose.const import IS_WINDOWS_PLATFORM +if IS_WINDOWS_PLATFORM: + from docker.transport import NpipeHTTPAdapter + class Status(Enum): SUCCESS = "success" @@ -19,9 +22,9 @@ class MetricsSource: if IS_WINDOWS_PLATFORM: - METRICS_SOCKET_FILE = 'http+unix://\\\\.\\pipe\\docker_cli' + METRICS_SOCKET_FILE = 'npipe://\\\\.\\pipe\\docker_cli' else: - METRICS_SOCKET_FILE = 'http+unix:///var/run/metrics-docker-cli.sock' + METRICS_SOCKET_FILE = 'http+unix:///var/run/docker-cli.sock' class MetricsCommand(requests.Session): @@ -38,11 +41,17 @@ def __init__(self, command, self.source = source self.status = status.value self.uri = uri or os.environ.get("METRICS_SOCKET_FILE", METRICS_SOCKET_FILE) - self.mount("http+unix://", UnixHTTPAdapter(self.uri)) + if IS_WINDOWS_PLATFORM: + self.mount("http+unix://", NpipeHTTPAdapter(self.uri)) + else: + self.mount("http+unix://", UnixHTTPAdapter(self.uri)) def send_metrics(self): try: - return self.post("http+unix://localhost/", json=self.to_map(), timeout=.05) + return self.post("http+unix://localhost/usage", + json=self.to_map(), + timeout=.05, + headers={'Content-Type': 'application/json'}) except Exception as e: return e

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